@olenbetong/appframe-ds 1.5.0 → 1.6.1

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 (159) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +17 -16
  3. package/scripts/copyAssets.mjs +27 -27
  4. package/src/AfLookup/Lookup.css +147 -130
  5. package/src/AfLookup/Lookup.tsx +186 -186
  6. package/src/AfLookup/LookupCombobox.css +69 -72
  7. package/src/AfLookup/LookupCombobox.tsx +273 -237
  8. package/src/AfLookup/LookupEdit.tsx +130 -130
  9. package/src/AfLookup/LookupGrid.css +145 -52
  10. package/src/AfLookup/LookupGrid.test.tsx +142 -0
  11. package/src/AfLookup/LookupGrid.tsx +324 -124
  12. package/src/AfLookup/index.ts +5 -5
  13. package/src/autocomplete/Autocomplete.css +253 -256
  14. package/src/autocomplete/Autocomplete.tsx +320 -320
  15. package/src/autocomplete/AutocompleteFrame.tsx +168 -168
  16. package/src/autocomplete/Combobox.tsx +286 -286
  17. package/src/autocomplete/index.ts +4 -4
  18. package/src/autocomplete/types.ts +204 -204
  19. package/src/autocomplete/utils.ts +183 -183
  20. package/src/binding/BoundNumberInput.tsx +42 -0
  21. package/src/binding/BoundNumericTextField.tsx +102 -102
  22. package/src/binding/BoundTextField.tsx +49 -49
  23. package/src/binding/CancelButton.tsx +30 -30
  24. package/src/binding/CancelIconButton.tsx +30 -30
  25. package/src/binding/DataEditToolbar.css +23 -23
  26. package/src/binding/DataEditToolbar.tsx +84 -84
  27. package/src/binding/DeleteButton.tsx +35 -35
  28. package/src/binding/DeleteIconButton.tsx +35 -35
  29. package/src/binding/RefreshButton.tsx +31 -31
  30. package/src/binding/RefreshIconButton.tsx +33 -33
  31. package/src/binding/RefreshRowIconButton.tsx +33 -33
  32. package/src/binding/SaveButton.tsx +37 -37
  33. package/src/binding/SaveIconButton.tsx +38 -38
  34. package/src/binding/index.ts +20 -19
  35. package/src/container/Container.tsx +50 -50
  36. package/src/container/index.ts +1 -1
  37. package/src/cutoff/CutOffPicker.css +12 -12
  38. package/src/cutoff/CutOffPicker.tsx +165 -165
  39. package/src/cutoff/context.tsx +194 -194
  40. package/src/cutoff/dsCutOffDates.ts +21 -21
  41. package/src/cutoff/index.ts +4 -4
  42. package/src/cutoff/utils.test.ts +59 -59
  43. package/src/cutoff/utils.ts +276 -276
  44. package/src/filter/ChipListInput.tsx +79 -79
  45. package/src/filter/FieldFilterPanel.css +150 -150
  46. package/src/filter/FieldFilterPanel.tsx +309 -309
  47. package/src/filter/FilterBuilder.css +152 -152
  48. package/src/filter/FilterBuilder.test.tsx +153 -153
  49. package/src/filter/FilterBuilder.tsx +435 -435
  50. package/src/filter/FilterEditor.css +231 -231
  51. package/src/filter/FilterEditor.test.tsx +285 -285
  52. package/src/filter/FilterEditor.tsx +75 -75
  53. package/src/filter/FilterGroupEditor.tsx +160 -160
  54. package/src/filter/FilterNameDialog.css +22 -22
  55. package/src/filter/FilterNameDialog.tsx +81 -81
  56. package/src/filter/FilterRow.tsx +117 -117
  57. package/src/filter/FilterShareDialog.css +28 -28
  58. package/src/filter/FilterShareDialog.tsx +201 -201
  59. package/src/filter/FilterStringField.tsx +78 -78
  60. package/src/filter/FilterValueEditor.tsx +239 -239
  61. package/src/filter/SavedFilterTree.css +107 -107
  62. package/src/filter/SavedFilterTree.test.tsx +94 -94
  63. package/src/filter/SavedFilterTree.tsx +218 -218
  64. package/src/filter/fieldFilter.test.ts +158 -158
  65. package/src/filter/fieldFilter.ts +139 -139
  66. package/src/filter/index.ts +27 -27
  67. package/src/filter/types.ts +38 -38
  68. package/src/filter/useDistinctValues.test.ts +102 -102
  69. package/src/filter/useDistinctValues.ts +230 -230
  70. package/src/global.d.ts +11 -11
  71. package/src/grid/formatters.ts +23 -0
  72. package/src/grid/useAfColumns.tsx +28 -4
  73. package/src/grid/utcDate.test.ts +25 -0
  74. package/src/grid/utcDate.ts +28 -0
  75. package/src/grid/utcDateEditing.tsx +11 -0
  76. package/src/i18n.ts +13 -13
  77. package/src/index.ts +14 -14
  78. package/src/input/InputAdornments.css +80 -84
  79. package/src/input/InputAdornments.tsx +102 -102
  80. package/src/input/NumberInput.css +9 -0
  81. package/src/input/NumberInput.test.tsx +212 -0
  82. package/src/input/NumberInput.tsx +268 -0
  83. package/src/input/index.ts +3 -1
  84. package/src/input/numberFormat.test.ts +79 -0
  85. package/src/input/numberFormat.ts +94 -0
  86. package/src/layout/AppMenu.tsx +74 -74
  87. package/src/layout/AppMenuDrawer.css +71 -71
  88. package/src/layout/AppMenuDrawer.tsx +122 -122
  89. package/src/layout/DialogMotion.css +79 -79
  90. package/src/layout/ErrorBoundary.tsx +162 -162
  91. package/src/layout/PageContainer.css +48 -48
  92. package/src/layout/PageContainer.tsx +41 -41
  93. package/src/layout/ResponsiveDialog.css +86 -86
  94. package/src/layout/ResponsiveDialog.tsx +55 -55
  95. package/src/layout/index.tsx +70 -70
  96. package/src/link/Link.tsx +23 -23
  97. package/src/link/index.ts +1 -1
  98. package/src/mdi/Mdi.css +138 -138
  99. package/src/mdi/Mdi.test.tsx +91 -91
  100. package/src/mdi/Mdi.tsx +141 -141
  101. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
  102. package/src/mdi/MdiContext.tsx +59 -59
  103. package/src/mdi/MdiDocumentContext.tsx +60 -60
  104. package/src/mdi/MdiPanel.tsx +61 -61
  105. package/src/mdi/MdiRestore.test.tsx +86 -86
  106. package/src/mdi/MdiTab.tsx +30 -30
  107. package/src/mdi/controller.test.ts +382 -382
  108. package/src/mdi/controller.ts +656 -656
  109. package/src/mdi/dockviewHost.ts +132 -132
  110. package/src/mdi/fakeHost.ts +200 -200
  111. package/src/mdi/host.ts +44 -44
  112. package/src/mdi/ids.test.ts +20 -20
  113. package/src/mdi/ids.ts +18 -18
  114. package/src/mdi/index.ts +31 -31
  115. package/src/mdi/layoutRestore.test.ts +156 -156
  116. package/src/mdi/layoutRestore.ts +311 -311
  117. package/src/mdi/layoutSpec.test.ts +143 -143
  118. package/src/mdi/layoutSpec.ts +214 -214
  119. package/src/mdi/layoutStore.test.ts +93 -93
  120. package/src/mdi/layoutStore.ts +99 -99
  121. package/src/mdi/memorySettingsStore.ts +21 -21
  122. package/src/mdi/registry.ts +34 -34
  123. package/src/mdi/testTypes.ts +37 -37
  124. package/src/mdi/theme.ts +22 -22
  125. package/src/mdi/types.ts +214 -214
  126. package/src/page/Page.css +57 -57
  127. package/src/page/PageBlock.css +64 -64
  128. package/src/page/PageBlock.tsx +93 -93
  129. package/src/page/PageHeader.css +88 -88
  130. package/src/page/PageHeader.tsx +115 -115
  131. package/src/page/PageTitle.css +5 -5
  132. package/src/page/PageTitle.tsx +21 -21
  133. package/src/page/index.ts +4 -4
  134. package/src/paper/Paper.tsx +52 -52
  135. package/src/progress/LinearProgress.css +121 -93
  136. package/src/progress/LinearProgress.tsx +106 -104
  137. package/src/progress/index.ts +1 -1
  138. package/src/report-downloader/ReportDownloader.css +67 -67
  139. package/src/report-downloader/components/StatusItem.tsx +87 -87
  140. package/src/report-downloader/components/StatusList.tsx +28 -28
  141. package/src/report-downloader/index.ts +41 -41
  142. package/src/report-downloader/status.tsx +92 -92
  143. package/src/sortable/DropIndicator.css +51 -51
  144. package/src/sortable/DropIndicator.tsx +27 -27
  145. package/src/sortable/SortableItemList.css +21 -21
  146. package/src/sortable/SortableItemList.tsx +264 -264
  147. package/src/sortable/index.ts +8 -8
  148. package/src/sortable/reorderSortOrder.ts +126 -126
  149. package/src/sortable/sortOrder.ts +101 -101
  150. package/src/sortable/types.ts +9 -9
  151. package/src/sortable/useSortableContainer.ts +53 -53
  152. package/src/sortable/useSortableItem.ts +95 -95
  153. package/src/sortable/useSortableList.ts +117 -117
  154. package/src/test/happy-dom-document-class.ts +24 -24
  155. package/src/test/setup.ts +27 -12
  156. package/src/theme/useDsColorScheme.ts +53 -53
  157. package/tsconfig.build.json +9 -9
  158. package/tsconfig.json +8 -8
  159. package/vitest.config.ts +10 -10
@@ -1,656 +1,656 @@
1
- import mitt, { type Emitter } from "mitt";
2
-
3
- import type { MdiHost } from "./host.js";
4
- import { panelId } from "./ids.js";
5
- import {
6
- dockPopoutGroups,
7
- isLayoutEmpty,
8
- listGridGroups,
9
- missingRequiredDocuments,
10
- placementFor,
11
- pruneLayout,
12
- } from "./layoutRestore.js";
13
- import { layoutSpecToLayout, resolveLocked } from "./layoutSpec.js";
14
- import { layoutStore as defaultLayoutStore, type MdiLayoutStore } from "./layoutStore.js";
15
- import { resolveClosable, resolveTitle, resolveType } from "./registry.js";
16
- import type {
17
- MdiApi,
18
- MdiCloseOptions,
19
- MdiConfirmCloseResult,
20
- MdiDocumentInfo,
21
- MdiDocumentOptions,
22
- MdiDocumentState,
23
- MdiDocumentTypes,
24
- MdiEvents,
25
- MdiLayout,
26
- MdiLayoutSpec,
27
- MdiOpenOptions,
28
- MdiParams,
29
- } from "./types.js";
30
-
31
- export type MdiControllerOptions = {
32
- /** Storage key within the article. Without it nothing is restored or saved. */
33
- id?: string;
34
- layoutVersion: string | number;
35
- defaultLayout: MdiLayoutSpec;
36
- /** Save every layout change automatically. Off: only `saveLayout()` writes, but a saved layout is still restored on load. */
37
- persist: boolean;
38
- popoutUrl: string;
39
- confirmClose: (document: MdiDocumentInfo) => Promise<MdiConfirmCloseResult>;
40
- };
41
-
42
- type InternalDocument = MdiDocumentState & { dirty: boolean };
43
-
44
- type StoreEvents = MdiEvents & { change: undefined };
45
-
46
- /**
47
- * Owns the documents of one `Mdi`: the registry lookup, the open/close/dirty
48
- * lifecycle, restore and persistence. Talks to dockview through the narrow
49
- * `MdiHost` interface so it can be tested without a DOM. The React layer is a thin
50
- * shell around it; `MdiProvider` creates one and `useMdi()` hands it out.
51
- */
52
- export class MdiController implements MdiApi {
53
- #types: MdiDocumentTypes;
54
- #store: MdiLayoutStore;
55
- #documents = new Map<string, InternalDocument>();
56
- #guards = new Map<string, MdiDocumentOptions>();
57
- #emitter: Emitter<StoreEvents> = mitt<StoreEvents>();
58
- #host: MdiHost | undefined;
59
- #hostDisposables: (() => void)[] = [];
60
- #options: MdiControllerOptions | undefined;
61
- #activeId: string | undefined;
62
- #infoCache = new Map<string, MdiDocumentInfo>();
63
- #listCache: MdiDocumentInfo[] | undefined;
64
- #emptyList: MdiDocumentInfo[] = [];
65
- #initialized = false;
66
- #applying = false;
67
- #generation = 0;
68
-
69
- constructor(types: MdiDocumentTypes, store: MdiLayoutStore = defaultLayoutStore) {
70
- this.#types = types;
71
- this.#store = store;
72
- }
73
-
74
- getTypes(): MdiDocumentTypes {
75
- return this.#types;
76
- }
77
-
78
- setTypes(types: MdiDocumentTypes) {
79
- this.#types = types;
80
- }
81
-
82
- setOptions(options: MdiControllerOptions) {
83
- this.#options = options;
84
- }
85
-
86
- get isAttached(): boolean {
87
- return this.#host !== undefined;
88
- }
89
-
90
- /* ---------------------------------------------------------------- host */
91
-
92
- attach(host: MdiHost) {
93
- this.detach();
94
- this.#host = host;
95
- this.#hostDisposables = [
96
- host.onDidActivePanelChange((id) => this.#setActive(id)),
97
- host.onDidRemovePanel((id) => this.#handleRemoved(id)),
98
- host.onDidLayoutChange(() => {
99
- if (this.#applying) return;
100
- this.#emitter.emit("layoutChange", undefined);
101
- this.#scheduleSave();
102
- }),
103
- ];
104
- }
105
-
106
- detach() {
107
- for (let dispose of this.#hostDisposables) dispose();
108
- this.#hostDisposables = [];
109
- this.#host = undefined;
110
- this.#initialized = false;
111
- this.#generation += 1;
112
- }
113
-
114
- /**
115
- * Restores the persisted layout (dropping documents the app no longer wants) or
116
- * applies the default one, then makes sure every fixed pane is present.
117
- */
118
- async initialize(): Promise<void> {
119
- let options = this.#requireOptions();
120
- let generation = ++this.#generation;
121
- let key = this.#storageKey();
122
- let layout: MdiLayout | undefined;
123
-
124
- if (key) {
125
- await this.#store.loaded;
126
- if (generation !== this.#generation) return;
127
-
128
- let persisted = this.#store.get(key, options.layoutVersion);
129
-
130
- if (persisted) {
131
- layout = await this.#resolvePersisted(persisted);
132
- if (generation !== this.#generation) return;
133
- }
134
- }
135
-
136
- this.#applyRestoredOrDefault(layout);
137
- }
138
-
139
- /* ---------------------------------------------------------- documents */
140
-
141
- openDocument<P extends MdiParams = MdiParams>(options: MdiOpenOptions<P>): string {
142
- let host = this.#requireHost();
143
- let definition = resolveType(this.#types, options.type);
144
- let key = definition.singleton ? undefined : options.key;
145
- let id = panelId(options.type, key);
146
- let existing = this.#documents.get(id);
147
-
148
- if (existing) {
149
- let changed = false;
150
-
151
- if (options.params) {
152
- existing.params = { ...options.params };
153
- changed = true;
154
- }
155
-
156
- if (options.title !== undefined && options.title !== existing.title) {
157
- existing.title = options.title;
158
- host.getPanel(id)?.setTitle(options.title);
159
- this.#emitter.emit("titleChange", { id, title: options.title });
160
- changed = true;
161
- }
162
-
163
- if (changed) {
164
- this.#invalidate(id);
165
- this.#scheduleSave();
166
- }
167
-
168
- if (!options.inactive) {
169
- this.activate(id);
170
- }
171
-
172
- return id;
173
- }
174
-
175
- let params: MdiParams = { ...options.params };
176
- let title = options.title ?? resolveTitle(definition, options.type, params, key);
177
-
178
- this.#documents.set(id, {
179
- type: options.type,
180
- key,
181
- params,
182
- title,
183
- closable: resolveClosable(definition, options.closable),
184
- dirty: false,
185
- });
186
-
187
- host.addPanel({ id, title, position: options.position, inactive: options.inactive });
188
- this.#invalidate(id);
189
- this.#emitter.emit("open", this.getDocument(id)!);
190
- this.#scheduleSave();
191
-
192
- return id;
193
- }
194
-
195
- activate(id: string) {
196
- this.#host?.getPanel(id)?.setActive();
197
- }
198
-
199
- async close(id: string, options: MdiCloseOptions = {}): Promise<boolean> {
200
- let document = this.#documents.get(id);
201
-
202
- if (!document) {
203
- return false;
204
- }
205
-
206
- if (!options.force) {
207
- if (!document.closable) {
208
- return false;
209
- }
210
-
211
- let guards = this.#guards.get(id);
212
-
213
- if (guards?.onClosing && !(await guards.onClosing())) {
214
- return false;
215
- }
216
-
217
- if (document.dirty) {
218
- let result = await this.#requireOptions().confirmClose(this.getDocument(id)!);
219
-
220
- if (result === "cancel") {
221
- return false;
222
- }
223
-
224
- if (result === "save") {
225
- let saved = guards?.onSave ? await guards.onSave() : true;
226
-
227
- if (!saved) {
228
- return false;
229
- }
230
- }
231
- }
232
- }
233
-
234
- // The document may have gone away while a guard was pending.
235
- if (!this.#documents.has(id)) {
236
- return true;
237
- }
238
-
239
- let panel = this.#host?.getPanel(id);
240
-
241
- if (panel) {
242
- panel.close();
243
- } else {
244
- this.#handleRemoved(id, true);
245
- }
246
-
247
- return true;
248
- }
249
-
250
- async closeByKey(key: string, options: MdiCloseOptions = { force: true }): Promise<void> {
251
- let ids = [...this.#documents.entries()].filter(([, document]) => document.key === key).map(([id]) => id);
252
-
253
- for (let id of ids) {
254
- await this.close(id, options);
255
- }
256
- }
257
-
258
- async closeAll(type?: string): Promise<boolean> {
259
- let ids = [...this.#documents.entries()]
260
- .filter(([, document]) => document.closable && (type === undefined || document.type === type))
261
- .map(([id]) => id);
262
-
263
- for (let id of ids) {
264
- if (this.#documents.has(id) && !(await this.close(id))) {
265
- return false;
266
- }
267
- }
268
-
269
- return true;
270
- }
271
-
272
- setTitle(id: string, title: string) {
273
- let document = this.#documents.get(id);
274
-
275
- if (!document || document.title === title) {
276
- return;
277
- }
278
-
279
- document.title = title;
280
- this.#host?.getPanel(id)?.setTitle(title);
281
- this.#invalidate(id);
282
- this.#emitter.emit("titleChange", { id, title });
283
- this.#scheduleSave();
284
- }
285
-
286
- setDirty(id: string, dirty: boolean) {
287
- let document = this.#documents.get(id);
288
-
289
- if (!document || document.dirty === dirty) {
290
- return;
291
- }
292
-
293
- document.dirty = dirty;
294
- this.#invalidate(id);
295
- this.#emitter.emit("dirtyChange", { id, dirty });
296
- }
297
-
298
- getActive = (): MdiDocumentInfo | undefined => {
299
- return this.#activeId ? this.getDocument(this.#activeId) : undefined;
300
- };
301
-
302
- getDocument = (id: string): MdiDocumentInfo | undefined => {
303
- let cached = this.#infoCache.get(id);
304
-
305
- if (cached) {
306
- return cached;
307
- }
308
-
309
- let document = this.#documents.get(id);
310
-
311
- if (!document) {
312
- return undefined;
313
- }
314
-
315
- let info: MdiDocumentInfo = { ...document, id, isActive: id === this.#activeId };
316
- this.#infoCache.set(id, info);
317
-
318
- return info;
319
- };
320
-
321
- /** Every open document, in insertion order. Referentially stable until something changes. */
322
- getDocumentsSnapshot = (): MdiDocumentInfo[] => {
323
- if (!this.#listCache) {
324
- let list = [...this.#documents.keys()].map((id) => this.getDocument(id)!);
325
- this.#listCache = list.length === 0 ? this.#emptyList : list;
326
- }
327
-
328
- return this.#listCache;
329
- };
330
-
331
- getDocuments(type?: string): MdiDocumentInfo[] {
332
- let all = this.getDocumentsSnapshot();
333
- return type === undefined ? [...all] : all.filter((document) => document.type === type);
334
- }
335
-
336
- hasDirty(): boolean {
337
- for (let document of this.#documents.values()) {
338
- if (document.dirty) return true;
339
- }
340
-
341
- return false;
342
- }
343
-
344
- async popout(id: string): Promise<boolean> {
345
- let host = this.#requireHost();
346
- return host.addPopoutGroup(id, this.#requireOptions().popoutUrl);
347
- }
348
-
349
- getWindow(id: string): Window {
350
- return this.#host?.getPanel(id)?.getWindow() ?? globalThis.window;
351
- }
352
-
353
- panelId(type: string, key?: string): string {
354
- let definition = this.#types[type];
355
- return panelId(type, definition?.singleton ? undefined : key);
356
- }
357
-
358
- /* -------------------------------------------------------------- guards */
359
-
360
- /** Registers the close guards of a mounted document component. Returns the unregister function. */
361
- registerGuards(id: string, guards: MdiDocumentOptions): () => void {
362
- this.#guards.set(id, guards);
363
-
364
- return () => {
365
- // Another instance may already have registered for the same id (React remounts).
366
- if (this.#guards.get(id) === guards) {
367
- this.#guards.delete(id);
368
- }
369
- };
370
- }
371
-
372
- /* -------------------------------------------------------------- events */
373
-
374
- subscribe<K extends keyof MdiEvents>(event: K, handler: (event: MdiEvents[K]) => void): () => void {
375
- this.#emitter.on(event, handler);
376
- return () => this.#emitter.off(event, handler);
377
- }
378
-
379
- /** Fires on every change of the document list, a document, or the active document. For `useSyncExternalStore`. */
380
- subscribeStore = (handler: () => void): (() => void) => {
381
- this.#emitter.on("change", handler);
382
- return () => this.#emitter.off("change", handler);
383
- };
384
-
385
- /* -------------------------------------------------------------- layout */
386
-
387
- getLayout(): MdiLayout | undefined {
388
- if (!this.#host || !this.#options) {
389
- return undefined;
390
- }
391
-
392
- let documents: Record<string, MdiDocumentState> = {};
393
-
394
- for (let [id, document] of this.#documents) {
395
- let { dirty: _dirty, ...state } = document;
396
- documents[id] = state;
397
- }
398
-
399
- return {
400
- format: 1,
401
- layoutVersion: this.#options.layoutVersion,
402
- documents,
403
- dockview: this.#host.toJSON(),
404
- };
405
- }
406
-
407
- async saveLayout(): Promise<MdiLayout | undefined> {
408
- let key = this.#storageKey();
409
- let layout = this.getLayout();
410
-
411
- if (key && layout) {
412
- await this.#store.setNow(key, layout);
413
- }
414
-
415
- return layout;
416
- }
417
-
418
- async loadLayout(layout?: MdiLayout): Promise<void> {
419
- let options = this.#requireOptions();
420
- let key = this.#storageKey();
421
- let source = layout ?? (key ? this.#store.get(key, options.layoutVersion) : undefined);
422
- let resolved = source ? await this.#resolvePersisted(source) : undefined;
423
-
424
- this.#applyRestoredOrDefault(resolved);
425
- }
426
-
427
- async resetLayout(): Promise<void> {
428
- let key = this.#storageKey();
429
-
430
- if (key) {
431
- await this.#store.remove(key);
432
- }
433
-
434
- this.#applyRestoredOrDefault(undefined);
435
- }
436
-
437
- /* ------------------------------------------------------------ internal */
438
-
439
- #requireHost(): MdiHost {
440
- if (!this.#host) {
441
- throw new Error("Mdi: no dock is mounted yet. Open documents after <Mdi> has rendered.");
442
- }
443
-
444
- return this.#host;
445
- }
446
-
447
- #requireOptions(): MdiControllerOptions {
448
- if (!this.#options) {
449
- throw new Error("Mdi: the controller has no options. Render <Mdi> inside the provider first.");
450
- }
451
-
452
- return this.#options;
453
- }
454
-
455
- #storageKey(): string | undefined {
456
- let id = this.#options?.id;
457
- return id ? this.#store.key(id) : undefined;
458
- }
459
-
460
- #defaultLayout(): MdiLayout {
461
- let options = this.#requireOptions();
462
- return layoutSpecToLayout(options.defaultLayout, this.#types, options.layoutVersion);
463
- }
464
-
465
- #applyRestoredOrDefault(layout: MdiLayout | undefined) {
466
- let options = this.#requireOptions();
467
-
468
- if (layout && !isLayoutEmpty(layout)) {
469
- try {
470
- this.#applyLayout(layout);
471
- } catch (error) {
472
- console.warn("Mdi: could not apply the saved layout, falling back to the default.", error);
473
- layout = undefined;
474
- }
475
- } else {
476
- layout = undefined;
477
- }
478
-
479
- if (!layout) {
480
- layout = this.#defaultLayout();
481
- this.#applyLayout(layout);
482
- }
483
-
484
- this.#initialized = true;
485
- this.#addMissingRequired(options.defaultLayout, layout);
486
- }
487
-
488
- /** Re-docks popouts, runs the `restore` resolvers and drops what they reject. */
489
- async #resolvePersisted(persisted: MdiLayout): Promise<MdiLayout> {
490
- let layout: MdiLayout = { ...persisted, dockview: dockPopoutGroups(persisted.dockview) };
491
- let keep = new Set<string>();
492
- let documents: Record<string, MdiDocumentState> = {};
493
-
494
- await Promise.all(
495
- Object.entries(layout.documents).map(async ([id, document]) => {
496
- let definition = this.#types[document.type];
497
-
498
- if (!definition) {
499
- console.warn(`Mdi: dropping document "${id}", its type is no longer registered.`);
500
- return;
501
- }
502
-
503
- try {
504
- let result = definition.restore
505
- ? await definition.restore({ key: document.key, params: document.params })
506
- : true;
507
-
508
- if (result === false) {
509
- return;
510
- }
511
-
512
- let state: MdiDocumentState = { ...document, params: { ...document.params } };
513
-
514
- if (typeof result === "object") {
515
- if (result.params) state.params = { ...result.params };
516
- if (result.title !== undefined) state.title = result.title;
517
- }
518
-
519
- keep.add(id);
520
- documents[id] = state;
521
- } catch (error) {
522
- console.warn(`Mdi: dropping document "${id}", restore failed.`, error);
523
- }
524
- }),
525
- );
526
-
527
- let pruned = pruneLayout({ ...layout, documents }, keep);
528
-
529
- for (let [id, document] of Object.entries(pruned.documents)) {
530
- let panel = pruned.dockview.panels[id];
531
- if (panel) panel.title = document.title;
532
- }
533
-
534
- return pruned;
535
- }
536
-
537
- #applyLayout(layout: MdiLayout) {
538
- let host = this.#requireHost();
539
-
540
- this.#applying = true;
541
-
542
- try {
543
- this.#documents.clear();
544
- this.#guards.clear();
545
-
546
- for (let [id, document] of Object.entries(layout.documents)) {
547
- this.#documents.set(id, { ...document, params: { ...document.params }, dirty: false });
548
- }
549
-
550
- host.fromJSON(structuredClone(layout.dockview));
551
-
552
- // dockview's fromJSON reduces `locked` to a boolean, which still allows drops beside
553
- // the group; put the stricter value back.
554
- for (let group of listGridGroups(layout.dockview)) {
555
- if (group.locked === "no-drop-target") host.getGroup(group.id)?.setLocked("no-drop-target");
556
- }
557
-
558
- // Drop anything dockview did not accept, so the two sides agree.
559
- for (let id of this.#documents.keys()) {
560
- if (!host.getPanel(id)) this.#documents.delete(id);
561
- }
562
- } finally {
563
- this.#applying = false;
564
- }
565
-
566
- this.#activeId = host.getActivePanelId();
567
- this.#invalidate();
568
- this.#emitter.emit("activeChange", this.getActive());
569
- this.#emitter.emit("layoutChange", undefined);
570
- }
571
-
572
- /** Fixed panes added to the default layout after a layout was saved are missing from it; put them back. */
573
- #addMissingRequired(spec: MdiLayoutSpec, layout: MdiLayout) {
574
- let host = this.#requireHost();
575
-
576
- for (let document of missingRequiredDocuments(spec, this.#types, layout)) {
577
- let existing = new Set(this.#documents.keys());
578
- let { position, groupSpec } = placementFor(spec, document.id, this.#types, existing);
579
- let id = this.openDocument({
580
- type: document.type,
581
- key: document.key,
582
- params: document.params,
583
- title: document.title,
584
- closable: false,
585
- position,
586
- inactive: true,
587
- });
588
- let panel = host.getPanel(id);
589
-
590
- if (panel && groupSpec && position.direction !== "within" && panel.group.panelCount === 1) {
591
- let locked = resolveLocked(groupSpec);
592
- if (groupSpec.hideHeader) panel.group.setHeaderHidden(true);
593
- if (locked) panel.group.setLocked(locked);
594
- }
595
- }
596
- }
597
-
598
- #handleRemoved(id: string, force = false) {
599
- if (this.#applying) return;
600
-
601
- // dockview reports removals while moving panels between groups too; only a panel
602
- // that is really gone counts as closed.
603
- if (!force && this.#host?.getPanel(id)) return;
604
-
605
- let info = this.getDocument(id);
606
-
607
- if (!info) return;
608
-
609
- this.#documents.delete(id);
610
- this.#guards.delete(id);
611
-
612
- if (this.#activeId === id) {
613
- this.#activeId = undefined;
614
- }
615
-
616
- this.#invalidate();
617
- this.#emitter.emit("close", info);
618
- this.#scheduleSave();
619
- }
620
-
621
- #setActive(id: string | undefined) {
622
- if (id === this.#activeId) return;
623
-
624
- let previous = this.#activeId;
625
- this.#activeId = id;
626
-
627
- if (previous) this.#infoCache.delete(previous);
628
- if (id) this.#infoCache.delete(id);
629
- this.#listCache = undefined;
630
-
631
- this.#emitter.emit("change", undefined);
632
- this.#emitter.emit("activeChange", this.getActive());
633
- }
634
-
635
- #invalidate(id?: string) {
636
- if (id === undefined) {
637
- this.#infoCache.clear();
638
- } else {
639
- this.#infoCache.delete(id);
640
- }
641
-
642
- this.#listCache = undefined;
643
- this.#emitter.emit("change", undefined);
644
- }
645
-
646
- #scheduleSave() {
647
- if (!this.#initialized || this.#applying || !this.#options?.persist) return;
648
-
649
- let key = this.#storageKey();
650
- let layout = this.getLayout();
651
-
652
- if (key && layout) {
653
- this.#store.set(key, layout);
654
- }
655
- }
656
- }
1
+ import mitt, { type Emitter } from "mitt";
2
+
3
+ import type { MdiHost } from "./host.js";
4
+ import { panelId } from "./ids.js";
5
+ import {
6
+ dockPopoutGroups,
7
+ isLayoutEmpty,
8
+ listGridGroups,
9
+ missingRequiredDocuments,
10
+ placementFor,
11
+ pruneLayout,
12
+ } from "./layoutRestore.js";
13
+ import { layoutSpecToLayout, resolveLocked } from "./layoutSpec.js";
14
+ import { layoutStore as defaultLayoutStore, type MdiLayoutStore } from "./layoutStore.js";
15
+ import { resolveClosable, resolveTitle, resolveType } from "./registry.js";
16
+ import type {
17
+ MdiApi,
18
+ MdiCloseOptions,
19
+ MdiConfirmCloseResult,
20
+ MdiDocumentInfo,
21
+ MdiDocumentOptions,
22
+ MdiDocumentState,
23
+ MdiDocumentTypes,
24
+ MdiEvents,
25
+ MdiLayout,
26
+ MdiLayoutSpec,
27
+ MdiOpenOptions,
28
+ MdiParams,
29
+ } from "./types.js";
30
+
31
+ export type MdiControllerOptions = {
32
+ /** Storage key within the article. Without it nothing is restored or saved. */
33
+ id?: string;
34
+ layoutVersion: string | number;
35
+ defaultLayout: MdiLayoutSpec;
36
+ /** Save every layout change automatically. Off: only `saveLayout()` writes, but a saved layout is still restored on load. */
37
+ persist: boolean;
38
+ popoutUrl: string;
39
+ confirmClose: (document: MdiDocumentInfo) => Promise<MdiConfirmCloseResult>;
40
+ };
41
+
42
+ type InternalDocument = MdiDocumentState & { dirty: boolean };
43
+
44
+ type StoreEvents = MdiEvents & { change: undefined };
45
+
46
+ /**
47
+ * Owns the documents of one `Mdi`: the registry lookup, the open/close/dirty
48
+ * lifecycle, restore and persistence. Talks to dockview through the narrow
49
+ * `MdiHost` interface so it can be tested without a DOM. The React layer is a thin
50
+ * shell around it; `MdiProvider` creates one and `useMdi()` hands it out.
51
+ */
52
+ export class MdiController implements MdiApi {
53
+ #types: MdiDocumentTypes;
54
+ #store: MdiLayoutStore;
55
+ #documents = new Map<string, InternalDocument>();
56
+ #guards = new Map<string, MdiDocumentOptions>();
57
+ #emitter: Emitter<StoreEvents> = mitt<StoreEvents>();
58
+ #host: MdiHost | undefined;
59
+ #hostDisposables: (() => void)[] = [];
60
+ #options: MdiControllerOptions | undefined;
61
+ #activeId: string | undefined;
62
+ #infoCache = new Map<string, MdiDocumentInfo>();
63
+ #listCache: MdiDocumentInfo[] | undefined;
64
+ #emptyList: MdiDocumentInfo[] = [];
65
+ #initialized = false;
66
+ #applying = false;
67
+ #generation = 0;
68
+
69
+ constructor(types: MdiDocumentTypes, store: MdiLayoutStore = defaultLayoutStore) {
70
+ this.#types = types;
71
+ this.#store = store;
72
+ }
73
+
74
+ getTypes(): MdiDocumentTypes {
75
+ return this.#types;
76
+ }
77
+
78
+ setTypes(types: MdiDocumentTypes) {
79
+ this.#types = types;
80
+ }
81
+
82
+ setOptions(options: MdiControllerOptions) {
83
+ this.#options = options;
84
+ }
85
+
86
+ get isAttached(): boolean {
87
+ return this.#host !== undefined;
88
+ }
89
+
90
+ /* ---------------------------------------------------------------- host */
91
+
92
+ attach(host: MdiHost) {
93
+ this.detach();
94
+ this.#host = host;
95
+ this.#hostDisposables = [
96
+ host.onDidActivePanelChange((id) => this.#setActive(id)),
97
+ host.onDidRemovePanel((id) => this.#handleRemoved(id)),
98
+ host.onDidLayoutChange(() => {
99
+ if (this.#applying) return;
100
+ this.#emitter.emit("layoutChange", undefined);
101
+ this.#scheduleSave();
102
+ }),
103
+ ];
104
+ }
105
+
106
+ detach() {
107
+ for (let dispose of this.#hostDisposables) dispose();
108
+ this.#hostDisposables = [];
109
+ this.#host = undefined;
110
+ this.#initialized = false;
111
+ this.#generation += 1;
112
+ }
113
+
114
+ /**
115
+ * Restores the persisted layout (dropping documents the app no longer wants) or
116
+ * applies the default one, then makes sure every fixed pane is present.
117
+ */
118
+ async initialize(): Promise<void> {
119
+ let options = this.#requireOptions();
120
+ let generation = ++this.#generation;
121
+ let key = this.#storageKey();
122
+ let layout: MdiLayout | undefined;
123
+
124
+ if (key) {
125
+ await this.#store.loaded;
126
+ if (generation !== this.#generation) return;
127
+
128
+ let persisted = this.#store.get(key, options.layoutVersion);
129
+
130
+ if (persisted) {
131
+ layout = await this.#resolvePersisted(persisted);
132
+ if (generation !== this.#generation) return;
133
+ }
134
+ }
135
+
136
+ this.#applyRestoredOrDefault(layout);
137
+ }
138
+
139
+ /* ---------------------------------------------------------- documents */
140
+
141
+ openDocument<P extends MdiParams = MdiParams>(options: MdiOpenOptions<P>): string {
142
+ let host = this.#requireHost();
143
+ let definition = resolveType(this.#types, options.type);
144
+ let key = definition.singleton ? undefined : options.key;
145
+ let id = panelId(options.type, key);
146
+ let existing = this.#documents.get(id);
147
+
148
+ if (existing) {
149
+ let changed = false;
150
+
151
+ if (options.params) {
152
+ existing.params = { ...options.params };
153
+ changed = true;
154
+ }
155
+
156
+ if (options.title !== undefined && options.title !== existing.title) {
157
+ existing.title = options.title;
158
+ host.getPanel(id)?.setTitle(options.title);
159
+ this.#emitter.emit("titleChange", { id, title: options.title });
160
+ changed = true;
161
+ }
162
+
163
+ if (changed) {
164
+ this.#invalidate(id);
165
+ this.#scheduleSave();
166
+ }
167
+
168
+ if (!options.inactive) {
169
+ this.activate(id);
170
+ }
171
+
172
+ return id;
173
+ }
174
+
175
+ let params: MdiParams = { ...options.params };
176
+ let title = options.title ?? resolveTitle(definition, options.type, params, key);
177
+
178
+ this.#documents.set(id, {
179
+ type: options.type,
180
+ key,
181
+ params,
182
+ title,
183
+ closable: resolveClosable(definition, options.closable),
184
+ dirty: false,
185
+ });
186
+
187
+ host.addPanel({ id, title, position: options.position, inactive: options.inactive });
188
+ this.#invalidate(id);
189
+ this.#emitter.emit("open", this.getDocument(id)!);
190
+ this.#scheduleSave();
191
+
192
+ return id;
193
+ }
194
+
195
+ activate(id: string) {
196
+ this.#host?.getPanel(id)?.setActive();
197
+ }
198
+
199
+ async close(id: string, options: MdiCloseOptions = {}): Promise<boolean> {
200
+ let document = this.#documents.get(id);
201
+
202
+ if (!document) {
203
+ return false;
204
+ }
205
+
206
+ if (!options.force) {
207
+ if (!document.closable) {
208
+ return false;
209
+ }
210
+
211
+ let guards = this.#guards.get(id);
212
+
213
+ if (guards?.onClosing && !(await guards.onClosing())) {
214
+ return false;
215
+ }
216
+
217
+ if (document.dirty) {
218
+ let result = await this.#requireOptions().confirmClose(this.getDocument(id)!);
219
+
220
+ if (result === "cancel") {
221
+ return false;
222
+ }
223
+
224
+ if (result === "save") {
225
+ let saved = guards?.onSave ? await guards.onSave() : true;
226
+
227
+ if (!saved) {
228
+ return false;
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ // The document may have gone away while a guard was pending.
235
+ if (!this.#documents.has(id)) {
236
+ return true;
237
+ }
238
+
239
+ let panel = this.#host?.getPanel(id);
240
+
241
+ if (panel) {
242
+ panel.close();
243
+ } else {
244
+ this.#handleRemoved(id, true);
245
+ }
246
+
247
+ return true;
248
+ }
249
+
250
+ async closeByKey(key: string, options: MdiCloseOptions = { force: true }): Promise<void> {
251
+ let ids = [...this.#documents.entries()].filter(([, document]) => document.key === key).map(([id]) => id);
252
+
253
+ for (let id of ids) {
254
+ await this.close(id, options);
255
+ }
256
+ }
257
+
258
+ async closeAll(type?: string): Promise<boolean> {
259
+ let ids = [...this.#documents.entries()]
260
+ .filter(([, document]) => document.closable && (type === undefined || document.type === type))
261
+ .map(([id]) => id);
262
+
263
+ for (let id of ids) {
264
+ if (this.#documents.has(id) && !(await this.close(id))) {
265
+ return false;
266
+ }
267
+ }
268
+
269
+ return true;
270
+ }
271
+
272
+ setTitle(id: string, title: string) {
273
+ let document = this.#documents.get(id);
274
+
275
+ if (!document || document.title === title) {
276
+ return;
277
+ }
278
+
279
+ document.title = title;
280
+ this.#host?.getPanel(id)?.setTitle(title);
281
+ this.#invalidate(id);
282
+ this.#emitter.emit("titleChange", { id, title });
283
+ this.#scheduleSave();
284
+ }
285
+
286
+ setDirty(id: string, dirty: boolean) {
287
+ let document = this.#documents.get(id);
288
+
289
+ if (!document || document.dirty === dirty) {
290
+ return;
291
+ }
292
+
293
+ document.dirty = dirty;
294
+ this.#invalidate(id);
295
+ this.#emitter.emit("dirtyChange", { id, dirty });
296
+ }
297
+
298
+ getActive = (): MdiDocumentInfo | undefined => {
299
+ return this.#activeId ? this.getDocument(this.#activeId) : undefined;
300
+ };
301
+
302
+ getDocument = (id: string): MdiDocumentInfo | undefined => {
303
+ let cached = this.#infoCache.get(id);
304
+
305
+ if (cached) {
306
+ return cached;
307
+ }
308
+
309
+ let document = this.#documents.get(id);
310
+
311
+ if (!document) {
312
+ return undefined;
313
+ }
314
+
315
+ let info: MdiDocumentInfo = { ...document, id, isActive: id === this.#activeId };
316
+ this.#infoCache.set(id, info);
317
+
318
+ return info;
319
+ };
320
+
321
+ /** Every open document, in insertion order. Referentially stable until something changes. */
322
+ getDocumentsSnapshot = (): MdiDocumentInfo[] => {
323
+ if (!this.#listCache) {
324
+ let list = [...this.#documents.keys()].map((id) => this.getDocument(id)!);
325
+ this.#listCache = list.length === 0 ? this.#emptyList : list;
326
+ }
327
+
328
+ return this.#listCache;
329
+ };
330
+
331
+ getDocuments(type?: string): MdiDocumentInfo[] {
332
+ let all = this.getDocumentsSnapshot();
333
+ return type === undefined ? [...all] : all.filter((document) => document.type === type);
334
+ }
335
+
336
+ hasDirty(): boolean {
337
+ for (let document of this.#documents.values()) {
338
+ if (document.dirty) return true;
339
+ }
340
+
341
+ return false;
342
+ }
343
+
344
+ async popout(id: string): Promise<boolean> {
345
+ let host = this.#requireHost();
346
+ return host.addPopoutGroup(id, this.#requireOptions().popoutUrl);
347
+ }
348
+
349
+ getWindow(id: string): Window {
350
+ return this.#host?.getPanel(id)?.getWindow() ?? globalThis.window;
351
+ }
352
+
353
+ panelId(type: string, key?: string): string {
354
+ let definition = this.#types[type];
355
+ return panelId(type, definition?.singleton ? undefined : key);
356
+ }
357
+
358
+ /* -------------------------------------------------------------- guards */
359
+
360
+ /** Registers the close guards of a mounted document component. Returns the unregister function. */
361
+ registerGuards(id: string, guards: MdiDocumentOptions): () => void {
362
+ this.#guards.set(id, guards);
363
+
364
+ return () => {
365
+ // Another instance may already have registered for the same id (React remounts).
366
+ if (this.#guards.get(id) === guards) {
367
+ this.#guards.delete(id);
368
+ }
369
+ };
370
+ }
371
+
372
+ /* -------------------------------------------------------------- events */
373
+
374
+ subscribe<K extends keyof MdiEvents>(event: K, handler: (event: MdiEvents[K]) => void): () => void {
375
+ this.#emitter.on(event, handler);
376
+ return () => this.#emitter.off(event, handler);
377
+ }
378
+
379
+ /** Fires on every change of the document list, a document, or the active document. For `useSyncExternalStore`. */
380
+ subscribeStore = (handler: () => void): (() => void) => {
381
+ this.#emitter.on("change", handler);
382
+ return () => this.#emitter.off("change", handler);
383
+ };
384
+
385
+ /* -------------------------------------------------------------- layout */
386
+
387
+ getLayout(): MdiLayout | undefined {
388
+ if (!this.#host || !this.#options) {
389
+ return undefined;
390
+ }
391
+
392
+ let documents: Record<string, MdiDocumentState> = {};
393
+
394
+ for (let [id, document] of this.#documents) {
395
+ let { dirty: _dirty, ...state } = document;
396
+ documents[id] = state;
397
+ }
398
+
399
+ return {
400
+ format: 1,
401
+ layoutVersion: this.#options.layoutVersion,
402
+ documents,
403
+ dockview: this.#host.toJSON(),
404
+ };
405
+ }
406
+
407
+ async saveLayout(): Promise<MdiLayout | undefined> {
408
+ let key = this.#storageKey();
409
+ let layout = this.getLayout();
410
+
411
+ if (key && layout) {
412
+ await this.#store.setNow(key, layout);
413
+ }
414
+
415
+ return layout;
416
+ }
417
+
418
+ async loadLayout(layout?: MdiLayout): Promise<void> {
419
+ let options = this.#requireOptions();
420
+ let key = this.#storageKey();
421
+ let source = layout ?? (key ? this.#store.get(key, options.layoutVersion) : undefined);
422
+ let resolved = source ? await this.#resolvePersisted(source) : undefined;
423
+
424
+ this.#applyRestoredOrDefault(resolved);
425
+ }
426
+
427
+ async resetLayout(): Promise<void> {
428
+ let key = this.#storageKey();
429
+
430
+ if (key) {
431
+ await this.#store.remove(key);
432
+ }
433
+
434
+ this.#applyRestoredOrDefault(undefined);
435
+ }
436
+
437
+ /* ------------------------------------------------------------ internal */
438
+
439
+ #requireHost(): MdiHost {
440
+ if (!this.#host) {
441
+ throw new Error("Mdi: no dock is mounted yet. Open documents after <Mdi> has rendered.");
442
+ }
443
+
444
+ return this.#host;
445
+ }
446
+
447
+ #requireOptions(): MdiControllerOptions {
448
+ if (!this.#options) {
449
+ throw new Error("Mdi: the controller has no options. Render <Mdi> inside the provider first.");
450
+ }
451
+
452
+ return this.#options;
453
+ }
454
+
455
+ #storageKey(): string | undefined {
456
+ let id = this.#options?.id;
457
+ return id ? this.#store.key(id) : undefined;
458
+ }
459
+
460
+ #defaultLayout(): MdiLayout {
461
+ let options = this.#requireOptions();
462
+ return layoutSpecToLayout(options.defaultLayout, this.#types, options.layoutVersion);
463
+ }
464
+
465
+ #applyRestoredOrDefault(layout: MdiLayout | undefined) {
466
+ let options = this.#requireOptions();
467
+
468
+ if (layout && !isLayoutEmpty(layout)) {
469
+ try {
470
+ this.#applyLayout(layout);
471
+ } catch (error) {
472
+ console.warn("Mdi: could not apply the saved layout, falling back to the default.", error);
473
+ layout = undefined;
474
+ }
475
+ } else {
476
+ layout = undefined;
477
+ }
478
+
479
+ if (!layout) {
480
+ layout = this.#defaultLayout();
481
+ this.#applyLayout(layout);
482
+ }
483
+
484
+ this.#initialized = true;
485
+ this.#addMissingRequired(options.defaultLayout, layout);
486
+ }
487
+
488
+ /** Re-docks popouts, runs the `restore` resolvers and drops what they reject. */
489
+ async #resolvePersisted(persisted: MdiLayout): Promise<MdiLayout> {
490
+ let layout: MdiLayout = { ...persisted, dockview: dockPopoutGroups(persisted.dockview) };
491
+ let keep = new Set<string>();
492
+ let documents: Record<string, MdiDocumentState> = {};
493
+
494
+ await Promise.all(
495
+ Object.entries(layout.documents).map(async ([id, document]) => {
496
+ let definition = this.#types[document.type];
497
+
498
+ if (!definition) {
499
+ console.warn(`Mdi: dropping document "${id}", its type is no longer registered.`);
500
+ return;
501
+ }
502
+
503
+ try {
504
+ let result = definition.restore
505
+ ? await definition.restore({ key: document.key, params: document.params })
506
+ : true;
507
+
508
+ if (result === false) {
509
+ return;
510
+ }
511
+
512
+ let state: MdiDocumentState = { ...document, params: { ...document.params } };
513
+
514
+ if (typeof result === "object") {
515
+ if (result.params) state.params = { ...result.params };
516
+ if (result.title !== undefined) state.title = result.title;
517
+ }
518
+
519
+ keep.add(id);
520
+ documents[id] = state;
521
+ } catch (error) {
522
+ console.warn(`Mdi: dropping document "${id}", restore failed.`, error);
523
+ }
524
+ }),
525
+ );
526
+
527
+ let pruned = pruneLayout({ ...layout, documents }, keep);
528
+
529
+ for (let [id, document] of Object.entries(pruned.documents)) {
530
+ let panel = pruned.dockview.panels[id];
531
+ if (panel) panel.title = document.title;
532
+ }
533
+
534
+ return pruned;
535
+ }
536
+
537
+ #applyLayout(layout: MdiLayout) {
538
+ let host = this.#requireHost();
539
+
540
+ this.#applying = true;
541
+
542
+ try {
543
+ this.#documents.clear();
544
+ this.#guards.clear();
545
+
546
+ for (let [id, document] of Object.entries(layout.documents)) {
547
+ this.#documents.set(id, { ...document, params: { ...document.params }, dirty: false });
548
+ }
549
+
550
+ host.fromJSON(structuredClone(layout.dockview));
551
+
552
+ // dockview's fromJSON reduces `locked` to a boolean, which still allows drops beside
553
+ // the group; put the stricter value back.
554
+ for (let group of listGridGroups(layout.dockview)) {
555
+ if (group.locked === "no-drop-target") host.getGroup(group.id)?.setLocked("no-drop-target");
556
+ }
557
+
558
+ // Drop anything dockview did not accept, so the two sides agree.
559
+ for (let id of this.#documents.keys()) {
560
+ if (!host.getPanel(id)) this.#documents.delete(id);
561
+ }
562
+ } finally {
563
+ this.#applying = false;
564
+ }
565
+
566
+ this.#activeId = host.getActivePanelId();
567
+ this.#invalidate();
568
+ this.#emitter.emit("activeChange", this.getActive());
569
+ this.#emitter.emit("layoutChange", undefined);
570
+ }
571
+
572
+ /** Fixed panes added to the default layout after a layout was saved are missing from it; put them back. */
573
+ #addMissingRequired(spec: MdiLayoutSpec, layout: MdiLayout) {
574
+ let host = this.#requireHost();
575
+
576
+ for (let document of missingRequiredDocuments(spec, this.#types, layout)) {
577
+ let existing = new Set(this.#documents.keys());
578
+ let { position, groupSpec } = placementFor(spec, document.id, this.#types, existing);
579
+ let id = this.openDocument({
580
+ type: document.type,
581
+ key: document.key,
582
+ params: document.params,
583
+ title: document.title,
584
+ closable: false,
585
+ position,
586
+ inactive: true,
587
+ });
588
+ let panel = host.getPanel(id);
589
+
590
+ if (panel && groupSpec && position.direction !== "within" && panel.group.panelCount === 1) {
591
+ let locked = resolveLocked(groupSpec);
592
+ if (groupSpec.hideHeader) panel.group.setHeaderHidden(true);
593
+ if (locked) panel.group.setLocked(locked);
594
+ }
595
+ }
596
+ }
597
+
598
+ #handleRemoved(id: string, force = false) {
599
+ if (this.#applying) return;
600
+
601
+ // dockview reports removals while moving panels between groups too; only a panel
602
+ // that is really gone counts as closed.
603
+ if (!force && this.#host?.getPanel(id)) return;
604
+
605
+ let info = this.getDocument(id);
606
+
607
+ if (!info) return;
608
+
609
+ this.#documents.delete(id);
610
+ this.#guards.delete(id);
611
+
612
+ if (this.#activeId === id) {
613
+ this.#activeId = undefined;
614
+ }
615
+
616
+ this.#invalidate();
617
+ this.#emitter.emit("close", info);
618
+ this.#scheduleSave();
619
+ }
620
+
621
+ #setActive(id: string | undefined) {
622
+ if (id === this.#activeId) return;
623
+
624
+ let previous = this.#activeId;
625
+ this.#activeId = id;
626
+
627
+ if (previous) this.#infoCache.delete(previous);
628
+ if (id) this.#infoCache.delete(id);
629
+ this.#listCache = undefined;
630
+
631
+ this.#emitter.emit("change", undefined);
632
+ this.#emitter.emit("activeChange", this.getActive());
633
+ }
634
+
635
+ #invalidate(id?: string) {
636
+ if (id === undefined) {
637
+ this.#infoCache.clear();
638
+ } else {
639
+ this.#infoCache.delete(id);
640
+ }
641
+
642
+ this.#listCache = undefined;
643
+ this.#emitter.emit("change", undefined);
644
+ }
645
+
646
+ #scheduleSave() {
647
+ if (!this.#initialized || this.#applying || !this.#options?.persist) return;
648
+
649
+ let key = this.#storageKey();
650
+ let layout = this.getLayout();
651
+
652
+ if (key && layout) {
653
+ this.#store.set(key, layout);
654
+ }
655
+ }
656
+ }