@mosetta/editor 0.0.0-stage → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +10 -2
  4. package/mosetta.json +6 -0
  5. package/package.json +35 -3
  6. package/prefabs/workbench/prefab.json +11 -0
  7. package/prefabs/workbench/versions/1.json +120 -0
  8. package/prefabs/workbench/versions/2.json +13 -0
  9. package/prefabs/workbench/versions/3.json +19 -0
  10. package/prefabs/workbench/versions/4.json +29 -0
  11. package/prefabs/workbench/versions/5.json +29 -0
  12. package/tiles/canvas/canvas.component.tsx +2038 -0
  13. package/tiles/canvas/canvas.css +973 -0
  14. package/tiles/canvas/canvasTools.ts +66 -0
  15. package/tiles/canvas/chromeNodes.ts +35 -0
  16. package/tiles/canvas/ghosts.ts +33 -0
  17. package/tiles/canvas/insertPopover.tsx +56 -0
  18. package/tiles/canvas/popoverPlacement.ts +104 -0
  19. package/tiles/canvas/popoverSize.ts +87 -0
  20. package/tiles/canvas/providerChrome.tsx +191 -0
  21. package/tiles/canvas/scrollWatch.ts +43 -0
  22. package/tiles/canvas/settingsPopover.tsx +257 -0
  23. package/tiles/canvas/settingsTabs.ts +78 -0
  24. package/tiles/canvas/toolPanel.tsx +86 -0
  25. package/tiles/content/autoBind.ts +61 -0
  26. package/tiles/content/blockPage.css +390 -0
  27. package/tiles/content/blockPage.tsx +379 -0
  28. package/tiles/content/blockPicker.css +215 -0
  29. package/tiles/content/blockPicker.tsx +501 -0
  30. package/tiles/content/blockUpgrades.ts +145 -0
  31. package/tiles/content/blockVariants.ts +185 -0
  32. package/tiles/content/blockVocabulary.ts +77 -0
  33. package/tiles/content/catalogVersions.ts +106 -0
  34. package/tiles/content/componentPreview.css +82 -0
  35. package/tiles/content/componentPreview.tsx +249 -0
  36. package/tiles/content/content.server.ts +593 -0
  37. package/tiles/content/contentCommon.ts +133 -0
  38. package/tiles/content/disconnected.ts +56 -0
  39. package/tiles/content/distributions.css +256 -0
  40. package/tiles/content/distributions.tsx +271 -0
  41. package/tiles/content/editingAutosave.ts +125 -0
  42. package/tiles/content/editingClipboard.ts +185 -0
  43. package/tiles/content/editingConflicts.ts +223 -0
  44. package/tiles/content/editingHotkeys.ts +59 -0
  45. package/tiles/content/editingNotices.ts +105 -0
  46. package/tiles/content/formRoot.ts +18 -0
  47. package/tiles/content/fuzzySearch.ts +121 -0
  48. package/tiles/content/mediaUpload.ts +55 -0
  49. package/tiles/content/moduleSiblings.ts +9 -0
  50. package/tiles/content/mountedTree.ts +136 -0
  51. package/tiles/content/nodeSettings.css +483 -0
  52. package/tiles/content/nodeSettings.tsx +963 -0
  53. package/tiles/content/pasteText.ts +131 -0
  54. package/tiles/content/prefabFields.tsx +158 -0
  55. package/tiles/content/prefabParts.ts +105 -0
  56. package/tiles/content/previewEnvironment.ts +46 -0
  57. package/tiles/content/projectConfig.ts +46 -0
  58. package/tiles/content/projectPrefabs.ts +57 -0
  59. package/tiles/content/publishModal.css +109 -0
  60. package/tiles/content/publishModal.tsx +92 -0
  61. package/tiles/content/refsEditing.ts +412 -0
  62. package/tiles/content/scaledStage.ts +63 -0
  63. package/tiles/content/searchField.css +32 -0
  64. package/tiles/content/searchField.tsx +32 -0
  65. package/tiles/content/settingsDraft.ts +178 -0
  66. package/tiles/content/settingsIssues.tsx +124 -0
  67. package/tiles/content/themeScope.ts +72 -0
  68. package/tiles/content/themeVariables.ts +47 -0
  69. package/tiles/content/tooltipPlacement.ts +17 -0
  70. package/tiles/content/tooltips.tsx +131 -0
  71. package/tiles/content/treeMounter.ts +33 -0
  72. package/tiles/content/updatesModal.css +124 -0
  73. package/tiles/content/updatesModal.tsx +110 -0
  74. package/tiles/content/useCtrlHeld.ts +24 -0
  75. package/tiles/content/useEditingSession.ts +115 -0
  76. package/tiles/content/useMountedTree.ts +47 -0
  77. package/tiles/content/workbenchScale.css +27 -0
  78. package/tiles/hostFallback/hostFallback.component.tsx +32 -0
  79. package/tiles/hostFallback/hostFallback.css +12 -0
  80. package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
  81. package/tiles/page/page.component.tsx +7 -0
  82. package/tiles/page/page.script.tsx +5 -0
  83. package/tiles/pageModules/pageModules.component.tsx +740 -0
  84. package/tiles/pageModules/pageModules.css +383 -0
  85. package/tiles/pageModules/pageModules.icon.svg +1 -0
  86. package/tiles/pageModules/servedPage.ts +465 -0
  87. package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
  88. package/tiles/pagesPanel/pagesPanel.css +713 -0
  89. package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
  90. package/tiles/pagesPanel/pagesSearch.ts +194 -0
  91. package/tiles/pagesPanel/routeTree.ts +98 -0
  92. package/tiles/previewBridge/landing.ts +66 -0
  93. package/tiles/previewBridge/previewAgent.ts +457 -0
  94. package/tiles/previewBridge/previewBridge.component.tsx +137 -0
  95. package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
  96. package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
  97. package/tiles/previewBridge/standIn.ts +22 -0
  98. package/tiles/treeView/treeView.component.tsx +514 -0
  99. package/tiles/treeView/treeView.css +303 -0
  100. package/tiles/treeView/treeView.editor.tsx +12 -0
  101. package/tiles/workbench/assistant.css +401 -0
  102. package/tiles/workbench/assistant.tsx +516 -0
  103. package/tiles/workbench/assistantChat.ts +165 -0
  104. package/tiles/workbench/dictation.ts +354 -0
  105. package/tiles/workbench/projectInfo.ts +72 -0
  106. package/tiles/workbench/recent.ts +67 -0
  107. package/tiles/workbench/workbench.component.tsx +476 -0
  108. package/tiles/workbench/workbench.css +376 -0
  109. package/tiles/workbench/workbenchAddress.ts +202 -0
  110. package/tiles/workbench/workspace.ts +775 -0
  111. package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
  112. package/tiles/workbenchEditor/workbenchEditor.css +370 -0
  113. package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
  114. package/tiles/workbenchHome/workbenchHome.css +271 -0
  115. package/tsconfig.json +3 -0
@@ -0,0 +1,775 @@
1
+ // The WORKSPACE: the editor page's state as one service — the open tabs and the one in front,
2
+ // whether the tree column shows and how wide — and the address bar as its persistent form
3
+ // (workbenchAddress). The editor is one page of the workbench (`/workbench/{kind}/{name}`,
4
+ // ADR-0051 §4): a document coming to the front is a SHALLOW move of the client router (the
5
+ // address changes, the document of the page does not); leaving for a list or Home is a move to
6
+ // another page. The address speaks first on a load: every `open` entry gets a tab, its session
7
+ // keyed before any canvas mounts, its document loaded straight into it; without entries, the
8
+ // tabs this browser tab remembered (sessionStorage) come back — a visit to a list loses nothing.
9
+ //
10
+ // Documents live in the editing sessions (one per tab, keyed by the tab id — the canvas picks its
11
+ // session up by that key); the workspace reads what a tab shows from its session, never from a
12
+ // copy: a draft that was published reopens as the revision it became, a page deleted leaves its
13
+ // tab closing, and the tab's title follows.
14
+ //
15
+ // Created by the editor block and registered in the DI so the other blocks — the quick picker
16
+ // opening a document, the canvas toggling the tree — find it optionally: outside the editor they
17
+ // simply do without (the pages list moves to the editor's address instead).
18
+ import { DiContainer, SingleDiToken } from '@mosetta/di';
19
+ import {
20
+ ContentKind,
21
+ EditingSession,
22
+ EditingSessionHub,
23
+ ILoadedContent,
24
+ ILoadedDraft,
25
+ IPageDoc,
26
+ } from '@mosetta/content';
27
+ import { fullServerTileId } from '@mosetta/server-action/core';
28
+ import {
29
+ ensureServerActionClient,
30
+ IServerDataHandle,
31
+ SERVER_DATA_MANAGER,
32
+ } from '@mosetta/server-action/client';
33
+ import type { ITileReference } from '@mosetta/core';
34
+ import type { IRouter } from '@mosetta/runtime/client';
35
+ import {
36
+ ContentActionResult,
37
+ IPageListEntry,
38
+ listPagesToken,
39
+ loadPageDraftToken,
40
+ loadPageToken,
41
+ } from '../content/contentCommon';
42
+ import {
43
+ DRAFT_PARAM,
44
+ IEditorAddress,
45
+ IOpenDoc,
46
+ isUnversioned,
47
+ OPEN_PARAM,
48
+ parseEditorAddress,
49
+ parseOpenDocs,
50
+ pathOfView,
51
+ REVISION_PARAM,
52
+ sameDoc,
53
+ serializeOpenDocs,
54
+ TABS_MEMORY_KEY,
55
+ TOOL_PARAM,
56
+ TOOL_STATE_PARAM,
57
+ TREE_PARAM,
58
+ } from './workbenchAddress';
59
+ import type { IToolAddress } from './workbenchAddress';
60
+ import { forgetRecent, noteRecent } from './recent';
61
+ import { noticesOf } from '../content/editingNotices';
62
+
63
+ export interface IWorkspaceTab {
64
+ readonly id: string;
65
+ /** the document the tab shows — its session's word once the load landed */
66
+ readonly doc: IOpenDoc;
67
+ /** the words on the tab: the route (or the prefab's name) and the version */
68
+ readonly label: string;
69
+ readonly title: string;
70
+ readonly loading: boolean;
71
+ }
72
+
73
+ export interface IWorkspaceState {
74
+ readonly tabs: readonly IWorkspaceTab[];
75
+ /** the tab in front (its canvas shows); null with no tabs */
76
+ readonly front: string | null;
77
+ readonly treeShown: boolean;
78
+ /** the tree column's width for the document in front */
79
+ readonly treeWidth: number;
80
+ /** the quick picker showing, with which list */
81
+ readonly picker: ContentKind | null;
82
+ }
83
+
84
+ export abstract class Workspace {
85
+ /** the path the workbench lives under (`/workbench`) */
86
+ abstract readonly base: string;
87
+
88
+ abstract readonly snapshot: () => IWorkspaceState;
89
+
90
+ abstract readonly subscribe: (listener: () => void) => () => void;
91
+
92
+ /** leave the editor for Home or a list — a move to another page of the workbench */
93
+ abstract go(view: 'home' | ContentKind): void;
94
+
95
+ /**
96
+ * Show a document in the editor: the tab already showing it comes to the front, otherwise a
97
+ * fresh tab opens and the document loads into it (`loaded` spares the load when the caller
98
+ * holds the document already — a draft just created). A document named without a version
99
+ * opens its live revision, or its newest draft while nothing is published.
100
+ */
101
+ abstract open(doc: IOpenDoc, loaded?: IPageDoc): void;
102
+
103
+ /** bring an open tab to the front */
104
+ abstract activate(tabId: string): void;
105
+
106
+ abstract close(tabId: string): void;
107
+
108
+ abstract setTreeShown(shown: boolean): void;
109
+
110
+ /** the tree column's width for the document in front — remembered for that document */
111
+ abstract setTreeWidth(width: number): void;
112
+
113
+ /** show the quick picker with this list — or close it when it shows this list already */
114
+ abstract togglePicker(kind: ContentKind): void;
115
+
116
+ abstract closePicker(): void;
117
+
118
+ /** fires when the tab comes to the front; returns the unsubscribe */
119
+ abstract onDidActivate(tabId: string, listener: () => void): () => void;
120
+
121
+ /** the editor tool open over a tab's document, and its state — the address carries the front one's */
122
+ abstract toolOf(tabId: string): IToolAddress | null;
123
+
124
+ /** a tab's canvas opened a tool, closed it, or the tool's state moved */
125
+ abstract setTool(tabId: string, tool: IToolAddress | null): void;
126
+
127
+ /** the front tab's session takes the focus — after the canvases mounted (each claims it) */
128
+ abstract focusFront(): void;
129
+
130
+ /** a page (or prefab) that is gone leaves the recent list; `tell` says so in a notice */
131
+ abstract forget(doc: Pick<IOpenDoc, 'kind' | 'page'>, tell?: boolean): void;
132
+
133
+ /** the address moved under the editor (Back, a link): the tabs follow it */
134
+ abstract locate(address: IEditorAddress): void;
135
+ }
136
+
137
+ export const WORKSPACE_TOKEN = new SingleDiToken<Workspace>('WORKSPACE_TOKEN');
138
+
139
+ /** which column shows the tree — a preference of this browser, not content */
140
+ const TREE_SHOWN_KEY = 'mosetta-workbench-tree';
141
+ /** the tree's width per document (`kind/page` → px) — this browser's, the address carries the front one */
142
+ const TREE_WIDTHS_KEY = 'mosetta-workbench-tree-widths';
143
+ export const TREE_WIDTH_DEFAULT = 280;
144
+ export const TREE_WIDTH_MIN = 180;
145
+ export const TREE_WIDTH_MAX = 1200;
146
+
147
+ /** a width the column can take: an integer between the bounds, or null for anything else */
148
+ export function treeWidthOf(value: string | number | null | undefined): number | null {
149
+ const width = typeof value === 'string' ? Number(value) : value;
150
+ if (width === null || width === undefined || !Number.isInteger(width)) return null;
151
+ return width >= TREE_WIDTH_MIN && width <= TREE_WIDTH_MAX ? width : null;
152
+ }
153
+
154
+ type TDraftInput = { name: string; draftId: string; kind?: ContentKind };
155
+ type TDraftOutput = ContentActionResult<{ draft: ILoadedDraft<IPageDoc> }>;
156
+ type TRevisionInput = { name: string; rev: number; kind?: ContentKind };
157
+ type TRevisionOutput = ContentActionResult<{ page: ILoadedContent<IPageDoc> }>;
158
+ type TListInput = { kind?: ContentKind };
159
+ type TListOutput = ContentActionResult<{ pages: IPageListEntry[] }>;
160
+
161
+ interface ITabRecord {
162
+ readonly id: string;
163
+ /** what was asked for — the session says what is actually shown once the load landed */
164
+ wanted: IOpenDoc;
165
+ /** the drafts this tab held that BECAME revisions: an address naming one still finds the tab */
166
+ retired: IOpenDoc[];
167
+ /** the document reached the session once: an empty session after that means the page is gone */
168
+ loaded: boolean;
169
+ loading: boolean;
170
+ unsubscribe: () => void;
171
+ }
172
+
173
+ /** the version to show when the address names the page alone */
174
+ export function defaultVersionOf(entry: IPageListEntry): IOpenDoc | null {
175
+ const doc = { kind: 'pages' as ContentKind, page: entry.name };
176
+ if (entry.meta.live != null) return { ...doc, revision: entry.meta.live };
177
+ const newest = [...entry.drafts].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
178
+ return newest ? { ...doc, draftId: newest.id } : null;
179
+ }
180
+
181
+ /** the tab's words: the route (a prefab's name) and the version it shows */
182
+ export function tabLabelOf(doc: IOpenDoc, page: IPageDoc | null): { title: string; label: string } {
183
+ const title = doc.kind === 'prefabs' ? doc.page : page?.pattern ?? doc.page;
184
+ const version = doc.revision !== undefined
185
+ ? `r${doc.revision}`
186
+ : doc.draftId !== undefined ? page?.title ?? doc.draftId.slice(0, 8) : '';
187
+ return { title, label: version ? `${title} · ${version}` : title };
188
+ }
189
+
190
+ /** the document a session shows, in the address's shape */
191
+ function docOfSession(held: { kind: ContentKind; pageName: string | null; draftId: string | null; revision: number | null }): IOpenDoc {
192
+ return {
193
+ kind: held.kind,
194
+ page: held.pageName ?? '',
195
+ ...(held.draftId !== null ? { draftId: held.draftId } : held.revision !== null ? { revision: held.revision } : {}),
196
+ };
197
+ }
198
+
199
+ /** the minimal query encoder of @mosetta/runtime: the address stays readable (ADR-0038) */
200
+ function queryText(part: string): string {
201
+ return encodeURIComponent(part)
202
+ .replace(/%2C/gi, ',')
203
+ .replace(/%3A/gi, ':')
204
+ .replace(/%3B/gi, ';')
205
+ .replace(/%40/gi, '@')
206
+ .replace(/%2F/gi, '/')
207
+ .replace(/%7E/gi, '~');
208
+ }
209
+
210
+ /** a width's key: the document, whichever version of it is open */
211
+ const docKeyOf = (doc: IOpenDoc): string => `${doc.kind}/${doc.page}`;
212
+
213
+ function readWidths(host: Pick<Window, 'localStorage'>): Map<string, number> {
214
+ try {
215
+ const parsed: unknown = JSON.parse(host.localStorage.getItem(TREE_WIDTHS_KEY) ?? '{}');
216
+ const widths = new Map<string, number>();
217
+ if (parsed && typeof parsed === 'object') {
218
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
219
+ const width = treeWidthOf(typeof value === 'number' ? value : null);
220
+ if (width !== null) widths.set(key, width);
221
+ }
222
+ }
223
+ return widths;
224
+ } catch {
225
+ return new Map();
226
+ }
227
+ }
228
+
229
+ function writeWidths(host: Pick<Window, 'localStorage'>, widths: ReadonlyMap<string, number>): void {
230
+ try {
231
+ host.localStorage.setItem(TREE_WIDTHS_KEY, JSON.stringify(Object.fromEntries(widths)));
232
+ } catch {
233
+ // a document that denies storage: the widths live for the page only
234
+ }
235
+ }
236
+
237
+ function readFlag(host: Pick<Window, 'localStorage'>, key: string, fallback: boolean): boolean {
238
+ try {
239
+ const raw = host.localStorage.getItem(key);
240
+ return raw === null ? fallback : raw === '1';
241
+ } catch {
242
+ return fallback;
243
+ }
244
+ }
245
+
246
+ function writeFlag(host: Pick<Window, 'localStorage'>, key: string, value: boolean): void {
247
+ try {
248
+ host.localStorage.setItem(key, value ? '1' : '0');
249
+ } catch {
250
+ // a document that denies storage: the preference lives for the page only
251
+ }
252
+ }
253
+
254
+ /** the tabs this browser tab remembers, for a return from a list without `?open=` */
255
+ function readRememberedTabs(host: Pick<Window, 'sessionStorage'>): IOpenDoc[] {
256
+ try {
257
+ return parseOpenDocs(host.sessionStorage.getItem(TABS_MEMORY_KEY));
258
+ } catch {
259
+ return [];
260
+ }
261
+ }
262
+
263
+ function rememberTabs(host: Pick<Window, 'sessionStorage'>, open: string | null): void {
264
+ try {
265
+ if (open === null) host.sessionStorage.removeItem(TABS_MEMORY_KEY);
266
+ else host.sessionStorage.setItem(TABS_MEMORY_KEY, open);
267
+ } catch {
268
+ // a document that denies storage: the tabs live in the address only
269
+ }
270
+ }
271
+
272
+ /** a history entry the client router made — its pops are the router's, not ours */
273
+ const isRouterEntry = (state: unknown): boolean => typeof state === 'object' && state !== null && 'mosetta' in state;
274
+
275
+ export class AddressWorkspace extends Workspace {
276
+ readonly base: string;
277
+
278
+ private readonly listeners = new Set<() => void>();
279
+
280
+ private readonly activations = new Map<string, Set<() => void>>();
281
+
282
+ private readonly tabs: ITabRecord[] = [];
283
+
284
+ private front: string | null = null;
285
+
286
+ private treeShown: boolean;
287
+
288
+ private readonly treeWidths: Map<string, number>;
289
+
290
+ /** the tool open over each document (`kind/page`), as the address names it for the one in front */
291
+ private readonly tools = new Map<string, IToolAddress>();
292
+
293
+ private picker: ContentKind | null = null;
294
+
295
+ private state: IWorkspaceState | null = null;
296
+
297
+ private counter = 0;
298
+
299
+ /** the listing is asked once per unversioned open — remembered while it is in the air */
300
+ private listing: IServerDataHandle<TListOutput, TListInput> | null = null;
301
+
302
+ constructor(
303
+ private readonly di: DiContainer,
304
+ private readonly hub: EditingSessionHub,
305
+ private readonly reference: ITileReference,
306
+ private readonly host: Window & typeof globalThis,
307
+ base: string,
308
+ private readonly router: IRouter | null,
309
+ ) {
310
+ super();
311
+ this.base = base;
312
+ this.treeShown = readFlag(host, TREE_SHOWN_KEY, true);
313
+ this.treeWidths = readWidths(host);
314
+ this.restore();
315
+ }
316
+
317
+ /**
318
+ * Follow the browser's Back and Forward while the editor is mounted — for the entries the
319
+ * client router did not make (its own it answers itself, and the route change reaches
320
+ * `locate` through the editor block); returns the detach.
321
+ */
322
+ attach(): () => void {
323
+ this.host.addEventListener('popstate', this.onPopState);
324
+ return () => this.host.removeEventListener('popstate', this.onPopState);
325
+ }
326
+
327
+ // ---- the state ------------------------------------------------------------------------------
328
+
329
+ readonly snapshot = (): IWorkspaceState => {
330
+ if (!this.state) {
331
+ this.state = {
332
+ front: this.front,
333
+ treeShown: this.treeShown,
334
+ treeWidth: this.treeWidthOfFront(),
335
+ picker: this.picker,
336
+ tabs: this.tabs.map((tab) => {
337
+ const held = this.hub.peek(tab.id)?.snapshot() ?? null;
338
+ const doc = held?.pageName ? docOfSession(held) : tab.wanted;
339
+ return { id: tab.id, doc, loading: tab.loading, ...tabLabelOf(doc, held?.doc ?? null) };
340
+ }),
341
+ };
342
+ }
343
+ return this.state;
344
+ };
345
+
346
+ readonly subscribe = (listener: () => void): (() => void) => {
347
+ this.listeners.add(listener);
348
+ return () => {
349
+ this.listeners.delete(listener);
350
+ };
351
+ };
352
+
353
+ private emit(): void {
354
+ this.state = null;
355
+ for (const listener of [...this.listeners]) listener();
356
+ }
357
+
358
+ // ---- navigation -----------------------------------------------------------------------------
359
+
360
+ go(view: 'home' | ContentKind): void {
361
+ this.picker = null;
362
+ this.emit();
363
+ const { pathname } = pathOfView(this.base, view === 'home' ? { view: 'home' } : { view: 'list', kind: view });
364
+ this.moveTo(pathname);
365
+ }
366
+
367
+ open(doc: IOpenDoc, loaded?: IPageDoc): void {
368
+ this.picker = null;
369
+ if (isUnversioned(doc)) {
370
+ this.resolveVersion(doc);
371
+ return;
372
+ }
373
+ const tab = this.findTab(doc) ?? this.openTab(doc, loaded);
374
+ this.bringFront(tab);
375
+ this.record(true);
376
+ this.emit();
377
+ }
378
+
379
+ activate(tabId: string): void {
380
+ const tab = this.tabs.find((candidate) => candidate.id === tabId);
381
+ if (!tab) return;
382
+ this.bringFront(tab);
383
+ this.record(true);
384
+ this.emit();
385
+ }
386
+
387
+ close(tabId: string): void {
388
+ const at = this.tabs.findIndex((tab) => tab.id === tabId);
389
+ if (at < 0) return;
390
+ const [closed] = this.tabs.splice(at, 1);
391
+ closed.unsubscribe();
392
+ this.activations.delete(closed.id);
393
+ this.hub.retire(closed.id);
394
+ if (this.front === closed.id) {
395
+ // the neighbour to the right takes the front, else the one to the left; the last tab
396
+ // closing leaves for the list the document came from
397
+ const next = this.tabs[at] ?? this.tabs[at - 1] ?? null;
398
+ this.front = next?.id ?? null;
399
+ if (!next) {
400
+ this.picker = null;
401
+ rememberTabs(this.host, null);
402
+ this.emit();
403
+ this.go(closed.wanted.kind);
404
+ return;
405
+ }
406
+ this.focusFront();
407
+ this.record(true);
408
+ } else {
409
+ this.record(false);
410
+ }
411
+ this.emit();
412
+ }
413
+
414
+ setTreeShown(shown: boolean): void {
415
+ this.treeShown = shown;
416
+ writeFlag(this.host, TREE_SHOWN_KEY, shown);
417
+ this.emit();
418
+ }
419
+
420
+ setTreeWidth(width: number): void {
421
+ const key = this.frontDocKey();
422
+ const fitted = treeWidthOf(Math.round(width));
423
+ if (!key || fitted === null) return;
424
+ this.treeWidths.set(key, fitted);
425
+ writeWidths(this.host, this.treeWidths);
426
+ this.record(false);
427
+ this.emit();
428
+ }
429
+
430
+ togglePicker(kind: ContentKind): void {
431
+ this.picker = this.picker === kind ? null : kind;
432
+ this.emit();
433
+ }
434
+
435
+ closePicker(): void {
436
+ if (this.picker === null) return;
437
+ this.picker = null;
438
+ this.emit();
439
+ }
440
+
441
+ onDidActivate(tabId: string, listener: () => void): () => void {
442
+ let set = this.activations.get(tabId);
443
+ if (!set) {
444
+ set = new Set();
445
+ this.activations.set(tabId, set);
446
+ }
447
+ set.add(listener);
448
+ return () => {
449
+ set.delete(listener);
450
+ };
451
+ }
452
+
453
+ focusFront(): void {
454
+ if (this.front && this.tabs.some((tab) => tab.id === this.front)) this.hub.focus(this.front);
455
+ }
456
+
457
+ forget(doc: Pick<IOpenDoc, 'kind' | 'page'>, tell = false): void {
458
+ const known = forgetRecent(this.host, doc);
459
+ // the author reached for it — from the recent list, or through the address — and found it
460
+ // gone: said in a notice, since nothing else on screen explains the empty-handed return
461
+ if (tell) {
462
+ const what = known?.title ?? doc.page;
463
+ noticesOf(this.di)?.show('refuse', `${doc.kind === 'prefabs' ? 'The prefab' : 'The page'} ${what} is gone${known ? ' — it left the recent list' : ''}`);
464
+ }
465
+ }
466
+
467
+ /**
468
+ * The address under the editor changed (a Back, a link into the editor): the tabs it names
469
+ * are opened if this workspace lacks them, the document it names comes to the front — nothing
470
+ * when it is in front already (the workspace's own move coming back as a route change).
471
+ */
472
+ locate(address: IEditorAddress): void {
473
+ for (const doc of address.open) {
474
+ if (!this.findTab(doc)) this.openTab(doc);
475
+ }
476
+ if (address.tool) this.tools.set(docKeyOf(address.doc), address.tool);
477
+ else this.tools.delete(docKeyOf(address.doc));
478
+ if (address.tree !== null) {
479
+ const width = treeWidthOf(address.tree);
480
+ if (width !== null) {
481
+ this.treeWidths.set(docKeyOf(address.doc), width);
482
+ writeWidths(this.host, this.treeWidths);
483
+ }
484
+ }
485
+ if (isUnversioned(address.doc)) {
486
+ this.resolveVersion(address.doc);
487
+ return;
488
+ }
489
+ const tab = this.findTab(address.doc) ?? this.openTab(address.doc);
490
+ this.bringFront(tab);
491
+ this.record(false);
492
+ this.emit();
493
+ }
494
+
495
+ /** the tab comes to the front: its listeners are told, its session takes the focus */
496
+ private bringFront(tab: ITabRecord): void {
497
+ if (this.front === tab.id) return;
498
+ this.front = tab.id;
499
+ for (const listener of [...(this.activations.get(tab.id) ?? [])]) listener();
500
+ this.focusFront();
501
+ }
502
+
503
+ private readonly onPopState = (event: PopStateEvent): void => {
504
+ if (isRouterEntry(event.state)) return; // the router's own: it re-reads the route, the editor block locates
505
+ const address = parseEditorAddress(this.host.location.pathname, this.base, new this.host.URLSearchParams(this.host.location.search));
506
+ if (address) this.locate(address);
507
+ };
508
+
509
+ /** a move to another page of the workbench: the router's, or the browser's where there is none */
510
+ private moveTo(pathname: string): void {
511
+ const fallback = (): void => this.host.location.assign(pathname);
512
+ if (!this.router) {
513
+ fallback();
514
+ return;
515
+ }
516
+ void this.router.navigate(pathname).then((taken) => {
517
+ if (!taken) fallback();
518
+ });
519
+ }
520
+
521
+ // ---- the address ----------------------------------------------------------------------------
522
+
523
+ /**
524
+ * The address from the state; other parameters (a search term) stay as they were. A SHALLOW
525
+ * move of the router — the same page, a new address — and the history API where the router has
526
+ * no say (a realm with no route stamped).
527
+ */
528
+ private record(push: boolean): void {
529
+ const url = new this.host.URL(this.host.location.href);
530
+ const params = new this.host.URLSearchParams(url.search);
531
+ for (const owned of [OPEN_PARAM, DRAFT_PARAM, REVISION_PARAM, TREE_PARAM, TOOL_PARAM, TOOL_STATE_PARAM]) params.delete(owned);
532
+ const front = this.tabs.find((tab) => tab.id === this.front);
533
+ if (!front) return;
534
+ // the workspace outlives the editor page (its tabs wait for the way back): while another
535
+ // page of the workbench shows, a document OPENED here means a move to the editor's page,
536
+ // and anything else (a load landing behind the scenes) leaves the address alone
537
+ const onEditor = parseEditorAddress(url.pathname, this.base, params) !== null;
538
+ if (!onEditor) {
539
+ if (!push) return;
540
+ const doc = this.docOf(front);
541
+ const { pathname, version } = pathOfView(this.base, { view: 'editor', ...doc });
542
+ const query = new this.host.URLSearchParams(version).toString();
543
+ rememberTabs(this.host, serializeOpenDocs(this.tabs.map((tab) => this.docOf(tab))));
544
+ this.moveTo(query ? `${pathname}?${query}` : pathname);
545
+ return;
546
+ }
547
+ const doc = this.docOf(front);
548
+ const { pathname, version } = pathOfView(this.base, { view: 'editor', ...doc });
549
+ for (const [name, value] of Object.entries(version)) params.set(name, value);
550
+ const open = serializeOpenDocs(this.tabs.map((tab) => this.docOf(tab)));
551
+ if (open !== null) params.set(OPEN_PARAM, open);
552
+ rememberTabs(this.host, open);
553
+ // the tree's width rides with the document in front, when it is not the default one
554
+ const width = this.treeWidths.get(docKeyOf(doc));
555
+ if (width !== undefined && width !== TREE_WIDTH_DEFAULT) params.set(TREE_PARAM, String(width));
556
+ // …and so does the tool open over it, with what of the tool is open
557
+ const tool = this.tools.get(docKeyOf(doc));
558
+ if (tool) {
559
+ params.set(TOOL_PARAM, tool.tool);
560
+ if (tool.state) params.set(TOOL_STATE_PARAM, tool.state);
561
+ }
562
+ url.pathname = pathname;
563
+ url.search = [...params].map(([key, item]) => `${queryText(key)}=${queryText(item)}`).join('&');
564
+ if (url.href === this.host.location.href) return;
565
+ const write = (): void => {
566
+ try {
567
+ if (push) this.host.history.pushState(null, '', url.href);
568
+ else this.host.history.replaceState(this.host.history.state, '', url.href);
569
+ } catch {
570
+ // browsers throttle history writes (Safari: ~100 per 30s) and THROW past the limit — the
571
+ // address is the state's mirror, not the state; a dropped write is caught up by the next
572
+ }
573
+ };
574
+ if (!this.router) {
575
+ write();
576
+ return;
577
+ }
578
+ void this.router.navigate(url.href, { shallow: true, replace: !push, scroll: false }).then((taken) => {
579
+ if (!taken) write();
580
+ });
581
+ }
582
+
583
+ /**
584
+ * The address speaks first: every `open` entry gets a tab with its document loading, the
585
+ * document in the path gets the front — opened too when the entries left it out. Without
586
+ * entries, the tabs this browser tab remembered come back. A page named without a version is
587
+ * looked up in the listing first.
588
+ */
589
+ private restore(): void {
590
+ const address = parseEditorAddress(this.host.location.pathname, this.base, new this.host.URLSearchParams(this.host.location.search));
591
+ if (!address) return;
592
+ const open = address.open.length > 0 ? address.open : readRememberedTabs(this.host);
593
+ this.locate({ ...address, open });
594
+ }
595
+
596
+ // ---- the tools ------------------------------------------------------------------------------
597
+
598
+ toolOf(tabId: string): IToolAddress | null {
599
+ const tab = this.tabs.find((candidate) => candidate.id === tabId);
600
+ return tab ? this.tools.get(docKeyOf(this.docOf(tab))) ?? null : null;
601
+ }
602
+
603
+ setTool(tabId: string, tool: IToolAddress | null): void {
604
+ const tab = this.tabs.find((candidate) => candidate.id === tabId);
605
+ if (!tab) return;
606
+ const key = docKeyOf(this.docOf(tab));
607
+ const was = this.tools.get(key);
608
+ if (tool ? was?.tool === tool.tool && was.state === tool.state : !was) return;
609
+ if (tool) this.tools.set(key, tool);
610
+ else this.tools.delete(key);
611
+ if (this.front === tabId) this.record(false);
612
+ }
613
+
614
+ // ---- the tabs -------------------------------------------------------------------------------
615
+
616
+ /** the document in front, as the widths are keyed: `kind/page` — a prefab and a page apart */
617
+ private frontDocKey(): string | null {
618
+ const tab = this.tabs.find((candidate) => candidate.id === this.front);
619
+ return tab ? docKeyOf(this.docOf(tab)) : null;
620
+ }
621
+
622
+ private treeWidthOfFront(): number {
623
+ const key = this.frontDocKey();
624
+ return (key ? this.treeWidths.get(key) : undefined) ?? TREE_WIDTH_DEFAULT;
625
+ }
626
+
627
+ private findTab(doc: IOpenDoc): ITabRecord | null {
628
+ return this.tabs.find((tab) => sameDoc(this.docOf(tab), doc))
629
+ ?? this.tabs.find((tab) => tab.retired.some((was) => sameDoc(was, doc)))
630
+ ?? null;
631
+ }
632
+
633
+ private docOf(tab: ITabRecord): IOpenDoc {
634
+ const held = this.hub.peek(tab.id)?.snapshot();
635
+ return held?.pageName ? docOfSession(held) : tab.wanted;
636
+ }
637
+
638
+ private openTab(doc: IOpenDoc, loaded?: IPageDoc): ITabRecord {
639
+ this.counter += 1;
640
+ const id = `c${this.counter}`;
641
+ const session = this.hub.sessionFor(id);
642
+ const tab: ITabRecord = { id, wanted: doc, retired: [], loaded: false, loading: false, unsubscribe: () => undefined };
643
+ tab.unsubscribe = session.subscribe(() => this.onSessionChange(tab));
644
+ this.tabs.push(tab);
645
+ // the session may hold the document already: the default session, adopted with the work
646
+ // started before any tab existed (a draft created from the list)
647
+ if (sameDoc(docOfSession(session.snapshot()), doc) && session.snapshot().doc) {
648
+ tab.loaded = true;
649
+ this.noteRecent(tab);
650
+ } else if (loaded && doc.draftId !== undefined) {
651
+ session.openPage(doc.page, doc.draftId, loaded, doc.kind);
652
+ } else {
653
+ this.load(tab, session);
654
+ }
655
+ return tab;
656
+ }
657
+
658
+ private load(tab: ITabRecord, session: EditingSession): void {
659
+ const { wanted } = tab;
660
+ tab.loading = true;
661
+ let settled = false;
662
+ const settle = (): void => {
663
+ settled = true;
664
+ tab.loading = false;
665
+ // a document landing in a session is an act of selection there (the anchor moves to it):
666
+ // the tab in front keeps the focus — a restored tab behind it must not pull the tree over
667
+ this.focusFront();
668
+ this.record(false);
669
+ this.emit();
670
+ };
671
+ if (wanted.revision !== undefined) {
672
+ const handle = this.handleOf<TRevisionInput, TRevisionOutput>(loadPageToken);
673
+ handle.subscribe(() => {
674
+ const data = handle.snapshot();
675
+ if (data.isLoading() || settled) return;
676
+ if (data.isSuccess() && data.serverData.ok) {
677
+ const { page } = data.serverData;
678
+ session.openRevision(page.name, page.rev, page.doc, wanted.kind);
679
+ } else {
680
+ // the revision is gone from the store: nothing to show — and when the store itself
681
+ // said so (not a transport failure), the page has no place in the recent list either
682
+ if (data.isSuccess()) this.forget(wanted, true);
683
+ this.dropTab(tab);
684
+ }
685
+ settle();
686
+ });
687
+ handle.snapshot().start!({ name: wanted.page, rev: wanted.revision, kind: wanted.kind });
688
+ } else {
689
+ const handle = this.handleOf<TDraftInput, TDraftOutput>(loadPageDraftToken);
690
+ handle.subscribe(() => {
691
+ const data = handle.snapshot();
692
+ if (data.isLoading() || settled) return;
693
+ if (data.isSuccess() && data.serverData.ok) {
694
+ const { draft } = data.serverData;
695
+ session.openPage(draft.name, draft.id, draft.doc, wanted.kind);
696
+ } else {
697
+ if (data.isSuccess()) this.forget(wanted, true);
698
+ this.dropTab(tab);
699
+ }
700
+ settle();
701
+ });
702
+ handle.snapshot().start!({ name: wanted.page, draftId: wanted.draftId!, kind: wanted.kind });
703
+ }
704
+ }
705
+
706
+ /** the page named without a version: the listing says which version stands for it */
707
+ private resolveVersion(doc: IOpenDoc): void {
708
+ const listing = this.listing ?? this.handleOf<TListInput, TListOutput>(listPagesToken);
709
+ this.listing = listing;
710
+ let settled = false;
711
+ listing.subscribe(() => {
712
+ const data = listing.snapshot();
713
+ if (data.isLoading() || settled) return;
714
+ settled = true;
715
+ this.listing = null;
716
+ const entry = data.isSuccess() && data.serverData.ok ? data.serverData.pages.find((page) => page.name === doc.page) : undefined;
717
+ const resolved = entry ? defaultVersionOf(entry) : null;
718
+ if (resolved) this.open({ ...resolved, kind: doc.kind });
719
+ else {
720
+ // a page with no versions at all (or none at all) cannot be shown: the list it was
721
+ // named in — and a page the listing does not know leaves the recent list
722
+ if (data.isSuccess() && data.serverData.ok && !entry) this.forget(doc, true);
723
+ if (this.tabs.length === 0) this.go(doc.kind);
724
+ }
725
+ });
726
+ listing.snapshot().start!({ kind: doc.kind });
727
+ }
728
+
729
+ /** the session changed: a page gone closes its tab; a landed document names it and the recent */
730
+ private onSessionChange(tab: ITabRecord): void {
731
+ const held = this.hub.peek(tab.id)?.snapshot();
732
+ if (!held) return;
733
+ if (held.pageName && held.doc) {
734
+ const landed = !tab.loaded;
735
+ tab.loaded = true;
736
+ if (landed || !sameDoc(tab.wanted, docOfSession(held))) {
737
+ const next = docOfSession(held);
738
+ // a draft published from elsewhere reopens here as the revision it became: the address
739
+ // that still names the draft must find this tab, not open a second one for a draft gone
740
+ if (!landed && tab.wanted.draftId !== undefined && next.revision !== undefined) tab.retired.push(tab.wanted);
741
+ tab.wanted = next;
742
+ this.noteRecent(tab);
743
+ }
744
+ } else if (tab.loaded) {
745
+ this.dropTab(tab);
746
+ return;
747
+ }
748
+ this.record(false);
749
+ this.emit();
750
+ }
751
+
752
+ private dropTab(tab: ITabRecord): void {
753
+ if (this.tabs.includes(tab)) this.close(tab.id);
754
+ }
755
+
756
+ private noteRecent(tab: ITabRecord): void {
757
+ const held = this.hub.peek(tab.id)?.snapshot();
758
+ const doc = this.docOf(tab);
759
+ const { title } = tabLabelOf(doc, held?.doc ?? null);
760
+ noteRecent(this.host, { kind: doc.kind, page: doc.page, title, at: Date.now() });
761
+ }
762
+
763
+ private handleOf<TIn, TOut>(token: typeof loadPageDraftToken | typeof loadPageToken | typeof listPagesToken): IServerDataHandle<TOut, TIn> {
764
+ ensureServerActionClient(this.di);
765
+ return this.di.getByToken(SERVER_DATA_MANAGER).createManual<TIn, TOut>({
766
+ fullId: fullServerTileId({
767
+ namespace: this.reference.namespace,
768
+ version: this.reference.version,
769
+ name: token.serverModuleName ?? this.reference.name,
770
+ }),
771
+ actionName: token.actionName,
772
+ invalidates: token.invalidates ?? [],
773
+ });
774
+ }
775
+ }