/** * Loading, creating, switching and deleting projects, plus the top bar's menu that triggers it. */ import { RenameProjectCommand, SetCollisionLayersCommand, SetPlayFromCommand, SetSymbolScaleCommand, SetWhilePlayingCommand, } from '../core/commands/projectCommands'; import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore'; import { releaseUrl, type ReleaseStore } from '../core/store/ReleaseStore'; import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore'; import { Dialog, Menu, type MenuEntry, type Widget } from '../ui'; import { dialogSlot } from './FormDialog'; import { OpenProjectDialog } from './OpenProjectDialog'; import { ReleaseDialog } from './ReleaseDialog'; import { releaseState, releaseWarnings } from './releases'; import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog'; import { clearSaved, hasSaved } from './saves'; import { snapshotBlocker, snapshotTime } from './snapshots'; import { SnapshotsDialog } from './SnapshotsDialog'; import type { EditorState } from './state'; export class ProjectSession { private readonly settingsDialog = dialogSlot(); private readonly pickerDialog = dialogSlot(); private readonly snapshotsDialog = dialogSlot(); private readonly releaseDialog = dialogSlot(); /** * `beforeSwitch` runs before a different project is loaded or the current one is deleted: a * run in progress holds a copy of the project being replaced and has to end first. */ constructor( private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void, /** * What the server copies of the project: snapshots (#231) and the release (#266). `flush` writes * what the autosave still holds, so a copy is of the stand on screen. */ private readonly copies: { snapshots: SnapshotStore; releases: ReleaseStore; flush: () => Promise }, /** * Runs on the project as it came off the server, before it is read into the model: the plugins * this project switches on are loaded there (#119), so that what they register is in place * before the deserializer would drop it as unknown. */ private readonly prepare: (raw: unknown) => Promise = () => Promise.resolve(), ) {} /** Opens the project named in the URL, else the first one on the server, else a new one. */ async boot(): Promise { const wanted = new URLSearchParams(location.search).get('project'); try { if (wanted) { await this.open(wanted); return; } await this.openFirstOrCreate(); } catch (error) { this.state.setNotice( `Could not load project: ${error instanceof Error ? error.message : String(error)}`, 'error', ); } } /** Opens a project by its id – from the user management, where it may be somebody else's (#179). */ openProject(id: string): void { void this.run(this.open(id)); } private async open(id: string): Promise { this.beforeSwitch(); const loaded = await this.store.load(id, this.prepare); this.state.loadProject(loaded.project, loaded.revision); const url = new URL(location.href); url.searchParams.set('project', id); history.replaceState(null, '', url); } private async openFirstOrCreate(): Promise { const list = await this.store.list(); if (list[0]) await this.open(list[0].id); else await this.create('My first project'); } private async create(name: string): Promise { const created = await this.store.create(name); await this.open(created.project.id); } private async deleteProject(id: string): Promise { // Only the project being replaced needs the run stopped; deleting another one leaves the // loaded project, and the run with it, exactly where it was. if (id === this.state.project.id) this.beforeSwitch(); await this.store.delete(id); if (id === this.state.project.id) await this.openFirstOrCreate(); } /** * The one menu of the top bar (#183): everything that is about the project rather than a scene. * `more` comes last – what the editor adds that is not about the project (#179). */ openMainMenu(anchor: Widget, more: MenuEntry[] = []): void { const items: MenuEntry[] = [ { label: 'New project', onSelect: () => void this.run(this.createNext()) }, { label: 'Open project…', onSelect: () => this.openProjectPicker() }, { separator: true }, { label: 'Snapshots…', onSelect: () => this.openSnapshots() }, { label: 'Release…', onSelect: () => this.openRelease() }, { label: 'Project settings…', onSelect: () => this.openProjectSettings() }, { label: 'Clear saved data', onSelect: () => this.clearSavedData() }, ...more, ]; new Menu(items).openBelow(anchor); } /** Forgets what persistent variables saved in this browser (#166), so the next run is a first start. */ private clearSavedData(): void { const id = this.state.project.id; const had = hasSaved(id); clearSaved(id); this.state.setNotice(had ? 'Saved data cleared' : 'Nothing saved yet'); } private async createNext(): Promise { const list = await this.store.list(); await this.create(`Project ${list.length + 1}`); } /** The project list as a dialog, with Open and Delete on the one picked. */ openProjectPicker(): void { void this.run( (async () => { const list = await this.store.list(); this.pickerDialog.open( new OpenProjectDialog(list, this.state.project.id, { onOpen: (id) => void this.run(this.open(id)), onDelete: (id) => this.confirmDelete(id, list.find((p) => p.id === id)?.name ?? id), }), ); })(), ); } /** The open project picker, for the check scripts. */ projectPicker(): OpenProjectDialog | null { return this.pickerDialog.current(); } /** The question stands in for the picker: Cancel goes back to it, and so does the deletion. */ private confirmDelete(id: string, name: string): void { new Dialog(`Delete project "${name}"?`, 'All assets and scenes are removed permanently.', [ { label: 'Cancel', onClick: () => this.openProjectPicker() }, { label: 'Delete', danger: true, onClick: () => void this.run( (async () => { await this.deleteProject(id); this.openProjectPicker(); })(), ), }, ]).openCentered(); } /** The snapshots of the open project (#231). */ openSnapshots(): void { const projectId = this.state.project.id; void this.run( (async () => { const list = await this.copies.snapshots.list(projectId); this.snapshotsDialog.open( new SnapshotsDialog(list, { onCreate: (name) => void this.run(this.createSnapshot(projectId, name)), onRestore: (snapshot) => this.confirmRestore(projectId, snapshot), onDelete: (snapshot) => this.confirmDeleteSnapshot(projectId, snapshot), }), ); })(), ); } /** The open snapshots dialog, for the check scripts. */ snapshotList(): SnapshotsDialog | null { return this.snapshotsDialog.current(); } /** The link that plays only the game (#266). */ openRelease(): void { const projectId = this.state.project.id; void this.run( (async () => { const release = await this.copies.releases.of(projectId); const link = release && releaseUrl(location.origin, release.token); this.releaseDialog.open( new ReleaseDialog( release, link, releaseState(release, this.state.revision), releaseWarnings(this.state.project), { onPublish: () => void this.run(this.publish(projectId, release !== null)), onTakeDown: () => this.confirmTakeDown(projectId), onCopy: (url) => void this.run(this.copyLink(url)), onOpen: (url) => window.open(url, '_blank', 'noopener'), }, ), ); })(), ); } /** The open release dialog, for the check scripts. */ releaseView(): ReleaseDialog | null { return this.releaseDialog.current(); } /** Publishing copies what the server has stored, so everything on screen is saved first. */ private async publish(projectId: string, again: boolean): Promise { await this.copies.flush(); const blocker = snapshotBlocker(this.state.saveStatus, 'release'); if (blocker) { this.state.setNotice(blocker, 'error'); return; } await this.copies.releases.publish(projectId); this.state.setNotice(again ? 'Release updated' : 'Released – the link plays the game'); this.openRelease(); } private confirmTakeDown(projectId: string): void { new Dialog('Take the release down?', 'The link stops working. Publishing again makes a new one.', [ { label: 'Cancel', onClick: () => this.openRelease() }, { label: 'Take down', danger: true, onClick: () => void this.run( (async () => { await this.copies.releases.takeDown(projectId); this.state.setNotice('Release taken down'); this.openRelease(); })(), ), }, ]).openCentered(); } private async copyLink(url: string): Promise { await navigator.clipboard.writeText(url); this.state.setNotice('Link copied'); } private async createSnapshot(projectId: string, name: string): Promise { await this.copies.flush(); const blocker = snapshotBlocker(this.state.saveStatus); if (blocker) { this.state.setNotice(blocker, 'error'); return; } const made = await this.copies.snapshots.create(projectId, name || snapshotTime(new Date())); this.state.setNotice(`Snapshot "${made.name}" created`); this.openSnapshots(); } private confirmRestore(projectId: string, snapshot: SnapshotSummary): void { new Dialog( `Restore snapshot "${snapshot.name}"?`, 'The project as it is now is kept as a snapshot of its own first, so this can be taken back.', [ { label: 'Cancel', onClick: () => this.openSnapshots() }, { label: 'Restore', primary: true, onClick: () => void this.run(this.restoreSnapshot(projectId, snapshot)), }, ], ).openCentered(); } /** Writes the snapshot back on the server, then loads the project again: the undo history starts over. */ private async restoreSnapshot(projectId: string, snapshot: SnapshotSummary): Promise { this.beforeSwitch(); await this.copies.flush(); const blocker = snapshotBlocker(this.state.saveStatus); if (blocker) { this.state.setNotice(blocker, 'error'); return; } try { const { before } = await this.copies.snapshots.restore(projectId, snapshot.id, this.state.revision); await this.open(projectId); this.state.setNotice(`Restored "${snapshot.name}" – the previous stand is kept as "${before.name}"`); } catch (error) { if (!(error instanceof StaleRevisionError)) throw error; this.state.setSaveStatus('conflict'); this.state.setNotice('This project was changed elsewhere. Reload to continue.', 'error'); } } private confirmDeleteSnapshot(projectId: string, snapshot: SnapshotSummary): void { new Dialog(`Delete snapshot "${snapshot.name}"?`, 'The snapshot is removed permanently.', [ { label: 'Cancel', onClick: () => this.openSnapshots() }, { label: 'Delete', danger: true, onClick: () => void this.run( (async () => { await this.copies.snapshots.delete(projectId, snapshot.id); this.openSnapshots(); })(), ), }, ]).openCentered(); } /** * Name, the scene Play starts, how closely the panels follow it, the symbol scale and the collision layers (#152) in one dialog * (#183). Each is an undo step of its own – one each, and only for what actually changed. */ openProjectSettings(): ProjectSettingsDialog { const { project } = this.state; const before = { name: project.name, playFrom: this.state.playFrom, whilePlaying: project.whilePlaying, collisionLayers: [...project.collisionLayers], symbolScale: project.symbolScale, }; return this.settingsDialog.open( new ProjectSettingsDialog(before, (settings) => { const change = settingsChanges(before, settings); if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name)); if (change.collisionLayers !== undefined) this.state.execute(new SetCollisionLayersCommand(change.collisionLayers)); if (change.symbolScale !== undefined) this.state.execute(new SetSymbolScaleCommand(change.symbolScale)); if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom)); if (change.whilePlaying !== undefined) this.state.execute(new SetWhilePlayingCommand(change.whilePlaying)); }), ); } /** The open settings dialog, for the check scripts. */ projectSettings(): ProjectSettingsDialog | null { return this.settingsDialog.current(); } private run(work: Promise): Promise { return work.catch((error) => this.state.setNotice(String(error), 'error')); } }