@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.
- package/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/mosetta.json +6 -0
- package/package.json +35 -3
- package/prefabs/workbench/prefab.json +11 -0
- package/prefabs/workbench/versions/1.json +120 -0
- package/prefabs/workbench/versions/2.json +13 -0
- package/prefabs/workbench/versions/3.json +19 -0
- package/prefabs/workbench/versions/4.json +29 -0
- package/prefabs/workbench/versions/5.json +29 -0
- package/tiles/canvas/canvas.component.tsx +2038 -0
- package/tiles/canvas/canvas.css +973 -0
- package/tiles/canvas/canvasTools.ts +66 -0
- package/tiles/canvas/chromeNodes.ts +35 -0
- package/tiles/canvas/ghosts.ts +33 -0
- package/tiles/canvas/insertPopover.tsx +56 -0
- package/tiles/canvas/popoverPlacement.ts +104 -0
- package/tiles/canvas/popoverSize.ts +87 -0
- package/tiles/canvas/providerChrome.tsx +191 -0
- package/tiles/canvas/scrollWatch.ts +43 -0
- package/tiles/canvas/settingsPopover.tsx +257 -0
- package/tiles/canvas/settingsTabs.ts +78 -0
- package/tiles/canvas/toolPanel.tsx +86 -0
- package/tiles/content/autoBind.ts +61 -0
- package/tiles/content/blockPage.css +390 -0
- package/tiles/content/blockPage.tsx +379 -0
- package/tiles/content/blockPicker.css +215 -0
- package/tiles/content/blockPicker.tsx +501 -0
- package/tiles/content/blockUpgrades.ts +145 -0
- package/tiles/content/blockVariants.ts +185 -0
- package/tiles/content/blockVocabulary.ts +77 -0
- package/tiles/content/catalogVersions.ts +106 -0
- package/tiles/content/componentPreview.css +82 -0
- package/tiles/content/componentPreview.tsx +249 -0
- package/tiles/content/content.server.ts +593 -0
- package/tiles/content/contentCommon.ts +133 -0
- package/tiles/content/disconnected.ts +56 -0
- package/tiles/content/distributions.css +256 -0
- package/tiles/content/distributions.tsx +271 -0
- package/tiles/content/editingAutosave.ts +125 -0
- package/tiles/content/editingClipboard.ts +185 -0
- package/tiles/content/editingConflicts.ts +223 -0
- package/tiles/content/editingHotkeys.ts +59 -0
- package/tiles/content/editingNotices.ts +105 -0
- package/tiles/content/formRoot.ts +18 -0
- package/tiles/content/fuzzySearch.ts +121 -0
- package/tiles/content/mediaUpload.ts +55 -0
- package/tiles/content/moduleSiblings.ts +9 -0
- package/tiles/content/mountedTree.ts +136 -0
- package/tiles/content/nodeSettings.css +483 -0
- package/tiles/content/nodeSettings.tsx +963 -0
- package/tiles/content/pasteText.ts +131 -0
- package/tiles/content/prefabFields.tsx +158 -0
- package/tiles/content/prefabParts.ts +105 -0
- package/tiles/content/previewEnvironment.ts +46 -0
- package/tiles/content/projectConfig.ts +46 -0
- package/tiles/content/projectPrefabs.ts +57 -0
- package/tiles/content/publishModal.css +109 -0
- package/tiles/content/publishModal.tsx +92 -0
- package/tiles/content/refsEditing.ts +412 -0
- package/tiles/content/scaledStage.ts +63 -0
- package/tiles/content/searchField.css +32 -0
- package/tiles/content/searchField.tsx +32 -0
- package/tiles/content/settingsDraft.ts +178 -0
- package/tiles/content/settingsIssues.tsx +124 -0
- package/tiles/content/themeScope.ts +72 -0
- package/tiles/content/themeVariables.ts +47 -0
- package/tiles/content/tooltipPlacement.ts +17 -0
- package/tiles/content/tooltips.tsx +131 -0
- package/tiles/content/treeMounter.ts +33 -0
- package/tiles/content/updatesModal.css +124 -0
- package/tiles/content/updatesModal.tsx +110 -0
- package/tiles/content/useCtrlHeld.ts +24 -0
- package/tiles/content/useEditingSession.ts +115 -0
- package/tiles/content/useMountedTree.ts +47 -0
- package/tiles/content/workbenchScale.css +27 -0
- package/tiles/hostFallback/hostFallback.component.tsx +32 -0
- package/tiles/hostFallback/hostFallback.css +12 -0
- package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
- package/tiles/page/page.component.tsx +7 -0
- package/tiles/page/page.script.tsx +5 -0
- package/tiles/pageModules/pageModules.component.tsx +740 -0
- package/tiles/pageModules/pageModules.css +383 -0
- package/tiles/pageModules/pageModules.icon.svg +1 -0
- package/tiles/pageModules/servedPage.ts +465 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
- package/tiles/pagesPanel/pagesPanel.css +713 -0
- package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
- package/tiles/pagesPanel/pagesSearch.ts +194 -0
- package/tiles/pagesPanel/routeTree.ts +98 -0
- package/tiles/previewBridge/landing.ts +66 -0
- package/tiles/previewBridge/previewAgent.ts +457 -0
- package/tiles/previewBridge/previewBridge.component.tsx +137 -0
- package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
- package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
- package/tiles/previewBridge/standIn.ts +22 -0
- package/tiles/treeView/treeView.component.tsx +514 -0
- package/tiles/treeView/treeView.css +303 -0
- package/tiles/treeView/treeView.editor.tsx +12 -0
- package/tiles/workbench/assistant.css +401 -0
- package/tiles/workbench/assistant.tsx +516 -0
- package/tiles/workbench/assistantChat.ts +165 -0
- package/tiles/workbench/dictation.ts +354 -0
- package/tiles/workbench/projectInfo.ts +72 -0
- package/tiles/workbench/recent.ts +67 -0
- package/tiles/workbench/workbench.component.tsx +476 -0
- package/tiles/workbench/workbench.css +376 -0
- package/tiles/workbench/workbenchAddress.ts +202 -0
- package/tiles/workbench/workspace.ts +775 -0
- package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
- package/tiles/workbenchEditor/workbenchEditor.css +370 -0
- package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
- package/tiles/workbenchHome/workbenchHome.css +271 -0
- 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
|
+
}
|