@olenbetong/appframe-ds 1.0.3 → 1.2.0

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 (173) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +8 -7
  3. package/scripts/copyAssets.mjs +27 -55
  4. package/src/AfLookup/Lookup.css +129 -125
  5. package/src/AfLookup/Lookup.tsx +184 -162
  6. package/src/AfLookup/LookupCombobox.css +78 -78
  7. package/src/AfLookup/LookupCombobox.tsx +237 -237
  8. package/src/AfLookup/LookupEdit.tsx +130 -130
  9. package/src/AfLookup/LookupGrid.tsx +124 -124
  10. package/src/AfLookup/index.ts +5 -5
  11. package/src/autocomplete/Autocomplete.css +256 -256
  12. package/src/autocomplete/Autocomplete.tsx +320 -320
  13. package/src/autocomplete/AutocompleteFrame.tsx +168 -168
  14. package/src/autocomplete/Combobox.tsx +286 -286
  15. package/src/autocomplete/index.ts +4 -4
  16. package/src/autocomplete/types.ts +204 -204
  17. package/src/autocomplete/utils.ts +183 -183
  18. package/src/binding/BoundNumericTextField.tsx +102 -102
  19. package/src/binding/BoundTextField.tsx +49 -49
  20. package/src/binding/CancelButton.tsx +30 -30
  21. package/src/binding/CancelIconButton.tsx +30 -30
  22. package/src/binding/DataEditToolbar.tsx +92 -92
  23. package/src/binding/DeleteButton.tsx +35 -35
  24. package/src/binding/DeleteIconButton.tsx +35 -35
  25. package/src/binding/RefreshButton.tsx +31 -31
  26. package/src/binding/RefreshIconButton.tsx +33 -33
  27. package/src/binding/RefreshRowIconButton.tsx +33 -33
  28. package/src/binding/SaveButton.tsx +37 -37
  29. package/src/binding/SaveIconButton.tsx +38 -38
  30. package/src/container/Container.tsx +49 -49
  31. package/src/container/index.ts +1 -1
  32. package/src/cutoff/CutOffPicker.css +12 -0
  33. package/src/cutoff/CutOffPicker.tsx +165 -0
  34. package/src/cutoff/context.tsx +194 -0
  35. package/src/cutoff/dsCutOffDates.ts +21 -0
  36. package/src/cutoff/index.ts +4 -0
  37. package/src/cutoff/utils.test.ts +59 -0
  38. package/src/cutoff/utils.ts +276 -0
  39. package/src/filter/ChipListInput.tsx +79 -79
  40. package/src/filter/FieldFilterPanel.css +150 -150
  41. package/src/filter/FieldFilterPanel.tsx +309 -309
  42. package/src/filter/FilterBuilder.css +152 -152
  43. package/src/filter/FilterBuilder.test.tsx +153 -153
  44. package/src/filter/FilterBuilder.tsx +435 -435
  45. package/src/filter/FilterEditor.css +231 -231
  46. package/src/filter/FilterEditor.test.tsx +259 -259
  47. package/src/filter/FilterEditor.tsx +75 -74
  48. package/src/filter/FilterGroupEditor.tsx +160 -160
  49. package/src/filter/FilterNameDialog.css +22 -22
  50. package/src/filter/FilterNameDialog.tsx +81 -81
  51. package/src/filter/FilterRow.tsx +117 -117
  52. package/src/filter/FilterShareDialog.css +28 -28
  53. package/src/filter/FilterShareDialog.tsx +201 -201
  54. package/src/filter/FilterStringField.tsx +78 -77
  55. package/src/filter/FilterValueEditor.tsx +220 -220
  56. package/src/filter/SavedFilterTree.css +107 -107
  57. package/src/filter/SavedFilterTree.test.tsx +94 -94
  58. package/src/filter/SavedFilterTree.tsx +218 -222
  59. package/src/filter/fieldFilter.test.ts +158 -158
  60. package/src/filter/fieldFilter.ts +139 -139
  61. package/src/filter/index.ts +27 -27
  62. package/src/filter/types.ts +38 -38
  63. package/src/filter/useDistinctValues.test.ts +102 -102
  64. package/src/filter/useDistinctValues.ts +230 -230
  65. package/src/global.d.ts +11 -11
  66. package/src/grid/AfGridColumnsPanel.tsx +118 -118
  67. package/src/grid/AfGridContext.tsx +51 -51
  68. package/src/grid/AfGridError.tsx +46 -46
  69. package/src/grid/AfHeaderFilterCell.tsx +147 -151
  70. package/src/grid/AfHeaderFilterPanel.tsx +111 -111
  71. package/src/grid/GridEditLookup.css +12 -0
  72. package/src/grid/GridEditLookup.tsx +121 -0
  73. package/src/grid/Toolbar.tsx +233 -233
  74. package/src/grid/editing.ts +100 -100
  75. package/src/grid/filter.ts +78 -78
  76. package/src/grid/formatters.ts +48 -48
  77. package/src/grid/index.css +245 -245
  78. package/src/grid/index.tsx +489 -484
  79. package/src/grid/license.ts +48 -48
  80. package/src/grid/localization.ts +369 -369
  81. package/src/grid/slots/index.tsx +307 -307
  82. package/src/grid/slots/slots.css +64 -64
  83. package/src/grid/theme.tsx +138 -138
  84. package/src/grid/useAfColumns.tsx +371 -371
  85. package/src/grid/useAfCurrentIndex.ts +63 -63
  86. package/src/grid/useAfData.ts +20 -20
  87. package/src/grid/useAfFilter.ts +160 -160
  88. package/src/grid/useAfFilterFields.ts +55 -55
  89. package/src/grid/useAfGridApi.ts +71 -71
  90. package/src/grid/useAfKeyBindings.ts +36 -36
  91. package/src/grid/useAfNewItemRow.ts +258 -258
  92. package/src/grid/useAfPagination.ts +53 -53
  93. package/src/grid/useAfPersistedState.ts +230 -230
  94. package/src/grid/useAfRowEditModel.ts +145 -145
  95. package/src/grid/useAfRowGrouping.ts +47 -47
  96. package/src/grid/useAfServerAggregation.ts +145 -145
  97. package/src/grid/useAfSortModel.ts +84 -84
  98. package/src/i18n.ts +13 -0
  99. package/src/index.ts +14 -12
  100. package/src/input/InputAdornments.css +75 -75
  101. package/src/input/InputAdornments.tsx +102 -102
  102. package/src/input/index.ts +1 -1
  103. package/src/layout/AppMenu.tsx +74 -75
  104. package/src/layout/AppMenuDrawer.css +70 -70
  105. package/src/layout/AppMenuDrawer.tsx +122 -122
  106. package/src/layout/ErrorBoundary.tsx +162 -162
  107. package/src/layout/ResponsiveDialog.css +86 -0
  108. package/src/layout/ResponsiveDialog.tsx +55 -0
  109. package/src/layout/index.tsx +69 -68
  110. package/src/link/Link.tsx +23 -0
  111. package/src/link/index.ts +1 -0
  112. package/src/mdi/Mdi.css +138 -138
  113. package/src/mdi/Mdi.test.tsx +91 -87
  114. package/src/mdi/Mdi.tsx +141 -141
  115. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
  116. package/src/mdi/MdiContext.tsx +59 -59
  117. package/src/mdi/MdiDocumentContext.tsx +60 -60
  118. package/src/mdi/MdiPanel.tsx +61 -61
  119. package/src/mdi/MdiRestore.test.tsx +86 -82
  120. package/src/mdi/MdiTab.tsx +30 -30
  121. package/src/mdi/controller.test.ts +382 -382
  122. package/src/mdi/controller.ts +656 -656
  123. package/src/mdi/dockviewHost.ts +132 -132
  124. package/src/mdi/fakeHost.ts +200 -200
  125. package/src/mdi/host.ts +44 -44
  126. package/src/mdi/ids.test.ts +20 -20
  127. package/src/mdi/ids.ts +18 -18
  128. package/src/mdi/index.ts +31 -31
  129. package/src/mdi/layoutRestore.test.ts +156 -156
  130. package/src/mdi/layoutRestore.ts +311 -311
  131. package/src/mdi/layoutSpec.test.ts +143 -143
  132. package/src/mdi/layoutSpec.ts +214 -214
  133. package/src/mdi/layoutStore.test.ts +93 -93
  134. package/src/mdi/layoutStore.ts +99 -99
  135. package/src/mdi/memorySettingsStore.ts +21 -21
  136. package/src/mdi/registry.ts +34 -34
  137. package/src/mdi/testTypes.ts +37 -37
  138. package/src/mdi/theme.ts +22 -22
  139. package/src/mdi/types.ts +214 -214
  140. package/src/page/Page.css +53 -53
  141. package/src/page/PageBlock.css +64 -64
  142. package/src/page/PageBlock.tsx +92 -92
  143. package/src/page/PageHeader.css +88 -88
  144. package/src/page/PageHeader.tsx +115 -116
  145. package/src/page/PageTitle.css +5 -0
  146. package/src/page/PageTitle.tsx +21 -0
  147. package/src/page/index.ts +4 -3
  148. package/src/paper/Paper.tsx +52 -52
  149. package/src/progress/LinearProgress.css +93 -93
  150. package/src/progress/LinearProgress.tsx +104 -104
  151. package/src/progress/index.ts +1 -1
  152. package/src/report-downloader/ReportDownloader.css +67 -67
  153. package/src/report-downloader/components/StatusItem.tsx +87 -86
  154. package/src/report-downloader/components/StatusList.tsx +28 -28
  155. package/src/report-downloader/index.ts +41 -41
  156. package/src/report-downloader/status.tsx +92 -92
  157. package/src/sortable/DropIndicator.css +51 -51
  158. package/src/sortable/DropIndicator.tsx +27 -27
  159. package/src/sortable/SortableItemList.css +21 -21
  160. package/src/sortable/SortableItemList.tsx +264 -264
  161. package/src/sortable/index.ts +8 -8
  162. package/src/sortable/reorderSortOrder.ts +126 -126
  163. package/src/sortable/sortOrder.ts +101 -101
  164. package/src/sortable/types.ts +9 -9
  165. package/src/sortable/useSortableContainer.ts +53 -53
  166. package/src/sortable/useSortableItem.ts +95 -95
  167. package/src/sortable/useSortableList.ts +117 -117
  168. package/src/test/happy-dom-document-class.ts +24 -24
  169. package/src/test/setup.ts +12 -12
  170. package/src/theme/useDsColorScheme.ts +53 -53
  171. package/tsconfig.build.json +9 -9
  172. package/tsconfig.json +8 -8
  173. 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
+ }