@kubohiroya/turbowarp-plus-editor-layout 0.1.4 → 0.1.6

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.
@@ -0,0 +1,23 @@
1
+ export interface DocumentPanelRequest {
2
+ reference: {
3
+ providerId: string;
4
+ documentId: string;
5
+ };
6
+ filename: string;
7
+ mount(element: HTMLElement, close: () => void): {
8
+ requestClose(): void;
9
+ dispose(): void;
10
+ };
11
+ }
12
+ interface DocumentPanelSource {
13
+ getPanelRequest(reference: DocumentPanelRequest['reference']): DocumentPanelRequest | undefined;
14
+ subscribePanelRequests(listener: () => void): () => void;
15
+ }
16
+ export declare function getDocumentWorkspace(vm: object): {
17
+ open(request: DocumentPanelRequest): boolean;
18
+ connect(handler: NonNullable<(request: DocumentPanelRequest) => void>): () => void;
19
+ connectSource(next: DocumentPanelSource): () => void;
20
+ resolve(reference: DocumentPanelRequest["reference"]): DocumentPanelRequest;
21
+ subscribe(listener: () => void): () => void;
22
+ };
23
+ export {};
@@ -0,0 +1,45 @@
1
+ function createDocumentWorkspace() {
2
+ let open;
3
+ let source;
4
+ const listeners = new Set();
5
+ const notify = () => listeners.forEach(listener => listener());
6
+ return {
7
+ open(request) {
8
+ if (!open)
9
+ return false;
10
+ open(request);
11
+ return true;
12
+ },
13
+ connect(handler) {
14
+ open = handler;
15
+ return () => { if (open === handler)
16
+ open = undefined; };
17
+ },
18
+ connectSource(next) {
19
+ source = next;
20
+ const unsubscribe = next.subscribePanelRequests(notify);
21
+ notify();
22
+ return () => {
23
+ unsubscribe();
24
+ if (source === next)
25
+ source = undefined;
26
+ };
27
+ },
28
+ resolve(reference) {
29
+ return source?.getPanelRequest(reference);
30
+ },
31
+ subscribe(listener) {
32
+ listeners.add(listener);
33
+ return () => { listeners.delete(listener); };
34
+ }
35
+ };
36
+ }
37
+ const workspaces = new WeakMap();
38
+ export function getDocumentWorkspace(vm) {
39
+ let workspace = workspaces.get(vm);
40
+ if (!workspace) {
41
+ workspace = createDocumentWorkspace();
42
+ workspaces.set(vm, workspace);
43
+ }
44
+ return workspace;
45
+ }
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import type { SpriteVariable } from './sprite-asset-pane.tsx';
3
3
  import type { SpriteSearchResult } from './sprite-editor-toolbar.tsx';
4
4
  import type { DockviewStagePane } from './dockview-stage.ts';
5
+ export { getDocumentWorkspace } from './document-workspace.ts';
5
6
  export { useDockviewStage } from './dockview-stage.ts';
6
7
  interface CostumeAsset {
7
8
  encodeDataURI(): string;
@@ -11,6 +12,9 @@ interface Target {
11
12
  isOriginal: boolean;
12
13
  isStage: boolean;
13
14
  getName(): string;
15
+ getCostumes?(): {
16
+ assetId?: string;
17
+ }[];
14
18
  getCurrentCostume?(): {
15
19
  asset?: CostumeAsset;
16
20
  } | undefined;
@@ -73,6 +77,12 @@ interface TabOptions<Snapshot = unknown> {
73
77
  resize(): void;
74
78
  reopenLabel: string;
75
79
  multiView?: boolean;
80
+ layoutStorage?: {
81
+ storage: Storage;
82
+ key: string;
83
+ isProjectLoading?(): boolean;
84
+ subscribeProject?(listener: () => void): (() => void) | undefined;
85
+ };
76
86
  stagePane?: DockviewStagePane;
77
87
  variables?: boolean;
78
88
  locale?: string;
@@ -4,6 +4,9 @@ import { createDockview, DefaultTab, themeLight } from 'dockview';
4
4
  import { createSpriteLayoutMenu } from "./sprite-layout-menu.js";
5
5
  import { SpriteAssetPane } from "./sprite-asset-pane.js";
6
6
  import { SpriteEditorToolbar, spriteEditorSections } from "./sprite-editor-toolbar.js";
7
+ import { getDocumentWorkspace } from "./document-workspace.js";
8
+ import { readWorkspaceLayout } from "./workspace-layout.js";
9
+ export { getDocumentWorkspace } from "./document-workspace.js";
7
10
  export { useDockviewStage } from "./dockview-stage.js";
8
11
  const variableSection = spriteEditorSections.indexOf('variables');
9
12
  function createAssetSelections(target, source) {
@@ -41,10 +44,16 @@ function createAssetSelections(target, source) {
41
44
  return { selected, ports, layouts, selectors, scroll };
42
45
  }
43
46
  const stagePanelId = 'tw-plus-stage-display';
47
+ const documentComponent = 'tw-plus-document';
48
+ const documentTitle = ({ filename, reference: { providerId, documentId } }) => `${filename} (${providerId} / ${documentId})`;
44
49
  const renameLabels = {
45
50
  en: { title: 'Rename sprite', name: 'Sprite name', cancel: 'Cancel', save: 'Rename' },
46
51
  ja: { title: 'スプライト名の変更', name: 'スプライト名', cancel: 'キャンセル', save: '変更' }
47
52
  };
53
+ const targetName = (target) => target.isStage ? 'Stage' : target.getName();
54
+ const projectSignature = (targets) => JSON.stringify(targets
55
+ .map(target => JSON.stringify([target.isStage, targetName(target), target.getCostumes?.()[0]?.assetId ?? '']))
56
+ .sort());
48
57
  class SpriteTab extends DefaultTab {
49
58
  icon;
50
59
  constructor(document) {
@@ -76,16 +85,25 @@ export function mountSpriteTabs(root, content, options) {
76
85
  const editLabel = /^ja(?:-|$)/i.test(locale) ? 'クリックしてこのビューで編集' : 'Activate this view to edit';
77
86
  const slots = new Map();
78
87
  const views = new Map();
88
+ const documents = new Map();
79
89
  const costumeIcons = new WeakMap();
80
90
  const remembered = new WeakMap();
81
91
  const lastUsed = new WeakMap();
82
- const closed = new WeakSet();
92
+ let closed = new WeakSet();
83
93
  let active;
84
94
  let observed = vm.editingTarget;
85
95
  let syncing = false;
86
96
  let nextView = 0;
87
97
  let renameDialog;
88
98
  const text = options.renameLabels ?? renameLabels.en;
99
+ const persistence = multiView && options.layoutStorage;
100
+ const window = document.defaultView;
101
+ const documentWorkspace = multiView && getDocumentWorkspace(vm);
102
+ let pendingRestore = Boolean(persistence);
103
+ let restoreFrame;
104
+ let restoreTimeout;
105
+ let saveFrame;
106
+ const originalTargets = () => vm.runtime.targets.filter(target => target.isOriginal);
89
107
  const dockview = createDockview(root, {
90
108
  theme: themeLight,
91
109
  disableFloatingGroups: true,
@@ -98,8 +116,17 @@ export function mountSpriteTabs(root, content, options) {
98
116
  return [];
99
117
  return [{ label: text.title, action: () => openRenameDialog(target) }];
100
118
  },
101
- createComponent: ({ name }) => {
119
+ createComponent: ({ name, id: componentId }) => {
102
120
  const element = document.createElement('div');
121
+ if (name === documentComponent) {
122
+ element.className = 'tw-plus-document-slot';
123
+ return { element, init({ api }) {
124
+ const item = documents.get(componentId);
125
+ element.dataset.providerId = item.request.reference.providerId;
126
+ element.dataset.documentId = item.request.reference.documentId;
127
+ item.session = item.request.mount(element, () => api.close());
128
+ } };
129
+ }
103
130
  if (name === stagePanelId) {
104
131
  element.className = 'tw-plus-dockview-stage-slot';
105
132
  return { element, init() { stagePane.attach(element); } };
@@ -189,7 +216,7 @@ export function mountSpriteTabs(root, content, options) {
189
216
  split('below', group.activePanel.id); },
190
217
  close: () => { if (group.activePanel)
191
218
  closeSplit(group.activePanel.id); },
192
- canClose: () => dockview.groups.length > 1,
219
+ canClose: () => canCloseGroup(group),
193
220
  canSplit: () => views.has(group.activePanel?.id)
194
221
  });
195
222
  return { ...menu, init() { } };
@@ -328,8 +355,8 @@ export function mountSpriteTabs(root, content, options) {
328
355
  view.snapshots.set(view.section, { snapshot, view: descriptor });
329
356
  }
330
357
  function show(panel) {
331
- if (panel?.id === stagePanelId) {
332
- // Displaying the Stage does not select the Stage target or interrupt the sprite editor.
358
+ if (panel?.id === stagePanelId || documents.has(panel?.id)) {
359
+ // Utility panels do not change the VM editing target.
333
360
  refreshSurfaces();
334
361
  return;
335
362
  }
@@ -368,10 +395,10 @@ export function mountSpriteTabs(root, content, options) {
368
395
  }
369
396
  function add(target, splitPosition, section) {
370
397
  let id = target.id;
371
- if (splitPosition || views.has(id) || id === stagePanelId) {
398
+ if (splitPosition || views.has(id) || id === stagePanelId || documents.has(id)) {
372
399
  do {
373
400
  id = 'sprite-view-' + ++nextView;
374
- } while (views.has(id) || vm.runtime.targets.some(item => item.id === id));
401
+ } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(item => item.id === id));
375
402
  }
376
403
  const source = splitPosition && views.get(splitPosition.referencePanel);
377
404
  views.set(id, { id, target, section: section ?? remembered.get(target) ?? options.getSection(),
@@ -407,9 +434,53 @@ export function mountSpriteTabs(root, content, options) {
407
434
  [...views.values()].find(item => item.target === target);
408
435
  activateView(view?.id ?? add(target).id);
409
436
  }
437
+ function openDocument(request) {
438
+ if (pendingRestore)
439
+ finishRestore();
440
+ const { providerId, documentId } = request.reference;
441
+ for (const [id, { request: opened }] of documents) {
442
+ if (opened.reference.providerId === providerId && opened.reference.documentId === documentId) {
443
+ dockview.getPanel(id)?.api.setActive();
444
+ return;
445
+ }
446
+ }
447
+ let id;
448
+ do {
449
+ id = 'document-view-' + ++nextView;
450
+ } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(target => target.id === id));
451
+ const title = documentTitle(request);
452
+ documents.set(id, { request });
453
+ try {
454
+ dockview.addPanel({ id, title, component: documentComponent, renderer: 'always' });
455
+ }
456
+ catch (error) {
457
+ documents.get(id)?.session?.dispose();
458
+ documents.delete(id);
459
+ throw error;
460
+ }
461
+ }
462
+ function guardDocumentClose(event) {
463
+ const element = event.target;
464
+ const tab = element.closest('[data-tab-panel-id]');
465
+ const item = documents.get(tab?.dataset.tabPanelId);
466
+ if (!item)
467
+ return;
468
+ const close = event.type === 'click' ? Boolean(element.closest('.dv-default-tab-action')) :
469
+ event.target === tab && ['Delete', 'Backspace'].includes(event.key);
470
+ if (!close)
471
+ return;
472
+ event.preventDefault();
473
+ event.stopPropagation();
474
+ item.session?.requestClose();
475
+ }
476
+ function canCloseGroup(group) {
477
+ return dockview.groups.length > 1 && group.panels.every(panel => !documents.has(panel.id));
478
+ }
410
479
  function openStage() {
411
480
  if (!stagePane)
412
481
  return;
482
+ if (pendingRestore)
483
+ finishRestore();
413
484
  const existing = dockview.getPanel(stagePanelId);
414
485
  if (existing) {
415
486
  existing.api.setActive();
@@ -433,7 +504,7 @@ export function mountSpriteTabs(root, content, options) {
433
504
  }
434
505
  function closeSplit(id = active?.id) {
435
506
  const panel = dockview.getPanel(id);
436
- if (!multiView || !panel || dockview.groups.length <= 1)
507
+ if (!multiView || !panel || !canCloseGroup(panel.api.group))
437
508
  return false;
438
509
  syncing = true;
439
510
  try {
@@ -461,6 +532,7 @@ export function mountSpriteTabs(root, content, options) {
461
532
  viewAdapter?.restore(describe(view), view.snapshots?.get(section)?.snapshot);
462
533
  }
463
534
  refreshSurfaces();
535
+ scheduleSave();
464
536
  return true;
465
537
  }
466
538
  function syncActiveSection(section) {
@@ -468,6 +540,124 @@ export function mountSpriteTabs(root, content, options) {
468
540
  return;
469
541
  active.section = section;
470
542
  refreshSurfaces();
543
+ scheduleSave();
544
+ }
545
+ function writeLayout() {
546
+ saveFrame = undefined;
547
+ const targets = originalTargets();
548
+ if (!persistence || pendingRestore || syncing || !targets.length || persistence.isProjectLoading?.())
549
+ return;
550
+ const { grid, activeGroup } = dockview.toJSON();
551
+ if (!activeGroup || grid.width <= 0 || grid.height <= 0)
552
+ return;
553
+ const panels = Object.create(null);
554
+ for (const [id, view] of views) {
555
+ panels[id] = { kind: 'sprite', name: targetName(view.target), stage: view.target.isStage, section: view.section };
556
+ }
557
+ if (dockview.getPanel(stagePanelId))
558
+ panels[stagePanelId] = { kind: 'stage' };
559
+ for (const [id, { request }] of documents)
560
+ panels[id] = { kind: 'document', ...request.reference };
561
+ const snapshot = { version: 1, project: projectSignature(targets), grid, activeGroup,
562
+ editingView: active?.id, panels };
563
+ try {
564
+ persistence.storage.setItem(persistence.key, JSON.stringify(snapshot));
565
+ }
566
+ catch { /* Storage can be unavailable. */ }
567
+ }
568
+ function scheduleSave() {
569
+ if (persistence && !pendingRestore && saveFrame === undefined) {
570
+ saveFrame = window.requestAnimationFrame(writeLayout);
571
+ }
572
+ }
573
+ function flushLayout() {
574
+ if (saveFrame !== undefined) {
575
+ window.cancelAnimationFrame(saveFrame);
576
+ writeLayout();
577
+ }
578
+ }
579
+ function finishRestore() {
580
+ pendingRestore = false;
581
+ window.clearTimeout(restoreTimeout);
582
+ }
583
+ function restoreLayout() {
584
+ restoreFrame = undefined;
585
+ if (!pendingRestore || !persistence || persistence.isProjectLoading?.())
586
+ return;
587
+ const targets = originalTargets();
588
+ if (!targets.length || !vm.editingTarget)
589
+ return;
590
+ let stored;
591
+ try {
592
+ const raw = persistence.storage.getItem(persistence.key);
593
+ if (raw)
594
+ stored = readWorkspaceLayout(raw, projectSignature(targets));
595
+ }
596
+ catch { /* Storage can be unavailable. */ }
597
+ if (!stored)
598
+ return finishRestore();
599
+ const resolvedSprites = new Map();
600
+ const resolvedDocuments = new Map();
601
+ const panels = Object.create(null);
602
+ for (const [id, reference] of Object.entries(stored.panels)) {
603
+ if (reference.kind === 'sprite') {
604
+ const target = targets.find(item => item.isStage === reference.stage && targetName(item) === reference.name);
605
+ if (!target || id === stagePanelId || (reference.section === variableSection && !options.variables))
606
+ return finishRestore();
607
+ resolvedSprites.set(id, { id, target, section: reference.section, assets: createAssetSelections(target) });
608
+ panels[id] = { id, title: target.getName(), contentComponent: 'sprite', params: { icon: getIcon(target) } };
609
+ }
610
+ else if (reference.kind === 'stage') {
611
+ if (!stagePane || id !== stagePanelId)
612
+ return finishRestore();
613
+ panels[id] = { id, title: stagePane.title, contentComponent: stagePanelId, renderer: 'always' };
614
+ }
615
+ else {
616
+ if (id === stagePanelId)
617
+ return finishRestore();
618
+ const request = documentWorkspace && documentWorkspace.resolve(reference);
619
+ if (!request) {
620
+ // The project can finish loading before the Blockly host publishes its document catalog.
621
+ restoreTimeout ??= window.setTimeout(finishRestore, 2000);
622
+ return;
623
+ }
624
+ resolvedDocuments.set(id, request);
625
+ panels[id] = { id, title: documentTitle(request), contentComponent: documentComponent, renderer: 'always' };
626
+ }
627
+ }
628
+ finishRestore();
629
+ syncing = true;
630
+ try {
631
+ dockview.clear();
632
+ active = undefined;
633
+ const represented = new Set([...resolvedSprites.values()].map(view => view.target));
634
+ closed = new WeakSet(targets.filter(target => !represented.has(target)));
635
+ for (const [id, view] of resolvedSprites)
636
+ views.set(id, view);
637
+ for (const [id, request] of resolvedDocuments)
638
+ documents.set(id, { request });
639
+ dockview.fromJSON({ grid: stored.grid, activeGroup: stored.activeGroup, panels });
640
+ const selected = dockview.activePanel;
641
+ show(views.has(selected?.id) ? selected : dockview.getPanel(stored.editingView) ?? selected);
642
+ }
643
+ catch {
644
+ dockview.clear();
645
+ for (const view of views.values())
646
+ disposeView(view);
647
+ for (const item of documents.values())
648
+ item.session?.dispose();
649
+ documents.clear();
650
+ closed = new WeakSet();
651
+ active = undefined;
652
+ }
653
+ finally {
654
+ syncing = false;
655
+ }
656
+ refresh();
657
+ }
658
+ function scheduleRestore() {
659
+ if (pendingRestore && restoreFrame === undefined)
660
+ restoreFrame = window.requestAnimationFrame(restoreLayout);
471
661
  }
472
662
  function refresh() {
473
663
  if (syncing)
@@ -504,6 +694,7 @@ export function mountSpriteTabs(root, content, options) {
504
694
  finally {
505
695
  syncing = false;
506
696
  }
697
+ scheduleRestore();
507
698
  }
508
699
  function disposeView(view) {
509
700
  for (const stored of view.snapshots?.values() ?? [])
@@ -524,8 +715,15 @@ export function mountSpriteTabs(root, content, options) {
524
715
  finally {
525
716
  syncing = false;
526
717
  }
718
+ scheduleSave();
527
719
  });
528
720
  const removal = dockview.onDidRemovePanel(panel => {
721
+ const item = documents.get(panel.id);
722
+ if (item) {
723
+ documents.delete(panel.id);
724
+ item.session?.dispose();
725
+ return;
726
+ }
529
727
  if (panel.id === stagePanelId) {
530
728
  stagePane?.attach(null);
531
729
  return;
@@ -539,7 +737,17 @@ export function mountSpriteTabs(root, content, options) {
539
737
  if (![...views.values()].some(item => item.target === view.target))
540
738
  closed.add(view.target);
541
739
  });
542
- const layout = multiView && dockview.onDidLayoutChange(refreshSurfaces);
740
+ const layout = multiView && dockview.onDidLayoutChange(() => {
741
+ refreshSurfaces();
742
+ scheduleSave();
743
+ });
744
+ const disconnectDocuments = documentWorkspace && documentWorkspace.connect(openDocument);
745
+ const unsubscribeDocuments = persistence && documentWorkspace && documentWorkspace.subscribe(scheduleRestore);
746
+ const unsubscribeProject = persistence && persistence.subscribeProject?.(scheduleRestore);
747
+ if (persistence)
748
+ window.addEventListener('pagehide', flushLayout);
749
+ root.addEventListener('click', guardDocumentClose, true);
750
+ root.addEventListener('keydown', guardDocumentClose, true);
543
751
  vm.on('targetsUpdate', refresh);
544
752
  refresh();
545
753
  return {
@@ -552,6 +760,22 @@ export function mountSpriteTabs(root, content, options) {
552
760
  currentView: () => active && describe(active),
553
761
  views: () => [...views.values()].map(view => describe(view)),
554
762
  dispose() {
763
+ flushLayout();
764
+ window.cancelAnimationFrame(restoreFrame);
765
+ window.clearTimeout(restoreTimeout);
766
+ if (persistence)
767
+ window.removeEventListener('pagehide', flushLayout);
768
+ if (unsubscribeDocuments)
769
+ unsubscribeDocuments();
770
+ if (unsubscribeProject)
771
+ unsubscribeProject();
772
+ if (disconnectDocuments)
773
+ disconnectDocuments();
774
+ root.removeEventListener('click', guardDocumentClose, true);
775
+ root.removeEventListener('keydown', guardDocumentClose, true);
776
+ for (const item of documents.values())
777
+ item.session?.dispose();
778
+ documents.clear();
555
779
  stagePane?.attach(null);
556
780
  renameDialog?.remove();
557
781
  renameDialog = undefined;
@@ -661,6 +885,7 @@ function SpriteTabs(props) {
661
885
  resize: () => latest.current.resize(),
662
886
  reopenLabel: props.reopenLabel,
663
887
  multiView: props.multiView,
888
+ layoutStorage: props.layoutStorage,
664
889
  stagePane: props.stagePane,
665
890
  variables: hasVariables,
666
891
  locale: props.locale,
@@ -680,7 +905,7 @@ function SpriteTabs(props) {
680
905
  instance.dispose();
681
906
  };
682
907
  }, [content, props.enabled, props.vm, props.reopenLabel, props.multiView, props.locale,
683
- props.viewAdapter, props.stagePane, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
908
+ props.viewAdapter, props.stagePane, props.layoutStorage, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
684
909
  React.useLayoutEffect(() => {
685
910
  controller.current?.syncActiveSection(props.activeSection);
686
911
  }, [props.activeSection]);
@@ -0,0 +1,24 @@
1
+ import type { SerializedDockview } from 'dockview';
2
+ import type { DocumentPanelRequest } from './document-workspace.ts';
3
+ type Grid = SerializedDockview['grid'];
4
+ export type PanelReference = {
5
+ kind: 'sprite';
6
+ name: string;
7
+ stage: boolean;
8
+ section: number;
9
+ } | {
10
+ kind: 'stage';
11
+ } | ({
12
+ kind: 'document';
13
+ } & DocumentPanelRequest['reference']);
14
+ export interface WorkspaceLayout {
15
+ version: 1;
16
+ project: string;
17
+ grid: Grid;
18
+ activeGroup: string;
19
+ editingView?: string;
20
+ panels: Record<string, PanelReference>;
21
+ }
22
+ /** Rebuild only the supported docked grid and logical panel references from storage. */
23
+ export declare function readWorkspaceLayout(raw: string, project: string): WorkspaceLayout | undefined;
24
+ export {};
@@ -0,0 +1,88 @@
1
+ import { Orientation } from 'dockview';
2
+ const limit = 256;
3
+ const validId = (value) => typeof value === 'string' && value.length > 0 && value.length <= 1024;
4
+ const size = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100000;
5
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
6
+ /** Rebuild only the supported docked grid and logical panel references from storage. */
7
+ export function readWorkspaceLayout(raw, project) {
8
+ if (raw.length > 1024 * 1024)
9
+ return;
10
+ let input;
11
+ try {
12
+ input = JSON.parse(raw);
13
+ }
14
+ catch {
15
+ return;
16
+ }
17
+ if (!record(input) || input.version !== 1 || input.project !== project || !record(input.grid) ||
18
+ !record(input.panels) || !validId(input.activeGroup))
19
+ return;
20
+ const { width, height, orientation } = input.grid;
21
+ if (!size(width) || !width || !size(height) || !height ||
22
+ (orientation !== Orientation.HORIZONTAL && orientation !== Orientation.VERTICAL))
23
+ return;
24
+ const groups = new Set();
25
+ const ids = new Set();
26
+ let nodes = 0;
27
+ function readNode(node, depth) {
28
+ if (depth > 32 || ++nodes > limit || !record(node) || !size(node.size))
29
+ return;
30
+ if (node.type === 'branch' && Array.isArray(node.data) && node.data.length > 0 && node.data.length <= limit) {
31
+ const data = [];
32
+ for (const child of node.data) {
33
+ const next = readNode(child, depth + 1);
34
+ if (!next)
35
+ return;
36
+ data.push(next);
37
+ }
38
+ return { type: 'branch', size: node.size, data };
39
+ }
40
+ if (node.type !== 'leaf' || !record(node.data))
41
+ return;
42
+ const { id, views, activeView } = node.data;
43
+ if (!validId(id) || groups.has(id) || !Array.isArray(views) || !views.length ||
44
+ views.length > limit || !validId(activeView) || !views.includes(activeView))
45
+ return;
46
+ groups.add(id);
47
+ for (const panel of views) {
48
+ if (!validId(panel) || ids.has(panel) || ids.size >= limit)
49
+ return;
50
+ ids.add(panel);
51
+ }
52
+ return { type: 'leaf', size: node.size, data: { id, views, activeView } };
53
+ }
54
+ const root = readNode(input.grid.root, 0);
55
+ if (root?.type !== 'branch' || !groups.has(input.activeGroup) ||
56
+ ids.size !== Object.keys(input.panels).length)
57
+ return;
58
+ const panels = Object.create(null);
59
+ const documents = new Set();
60
+ let hasStage = false;
61
+ for (const id of ids) {
62
+ const panel = input.panels[id];
63
+ if (!Object.hasOwn(input.panels, id) || !record(panel))
64
+ return;
65
+ if (panel.kind === 'sprite' && validId(panel.name) && typeof panel.stage === 'boolean' &&
66
+ typeof panel.section === 'number' && Number.isInteger(panel.section) && panel.section >= 0 && panel.section <= 3) {
67
+ panels[id] = { kind: 'sprite', name: panel.name, stage: panel.stage, section: panel.section };
68
+ }
69
+ else if (panel.kind === 'stage' && !hasStage) {
70
+ hasStage = true;
71
+ panels[id] = { kind: 'stage' };
72
+ }
73
+ else if (panel.kind === 'document' && validId(panel.providerId) && validId(panel.documentId)) {
74
+ const key = JSON.stringify([panel.providerId, panel.documentId]);
75
+ if (documents.has(key))
76
+ return;
77
+ documents.add(key);
78
+ panels[id] = { kind: 'document', providerId: panel.providerId, documentId: panel.documentId };
79
+ }
80
+ else
81
+ return;
82
+ }
83
+ const editingView = input.editingView;
84
+ if (editingView !== undefined && (!validId(editingView) || panels[editingView]?.kind !== 'sprite'))
85
+ return;
86
+ return { version: 1, project, grid: { root, width, height, orientation }, activeGroup: input.activeGroup,
87
+ editingView: typeof editingView === 'string' ? editingView : undefined, panels };
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-layout",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Resizable panes and layout for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -0,0 +1,55 @@
1
+ export interface DocumentPanelRequest {
2
+ reference: {providerId: string; documentId: string};
3
+ filename: string;
4
+ mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
5
+ }
6
+
7
+ interface DocumentPanelSource {
8
+ getPanelRequest(reference: DocumentPanelRequest['reference']): DocumentPanelRequest | undefined;
9
+ subscribePanelRequests(listener: () => void): () => void;
10
+ }
11
+
12
+ function createDocumentWorkspace() {
13
+ let open: ((request: DocumentPanelRequest) => void) | undefined;
14
+ let source: DocumentPanelSource | undefined;
15
+ const listeners = new Set<() => void>();
16
+ const notify = () => listeners.forEach(listener => listener());
17
+ return {
18
+ open(request: DocumentPanelRequest) {
19
+ if (!open) return false;
20
+ open(request);
21
+ return true;
22
+ },
23
+ connect(handler: NonNullable<typeof open>) {
24
+ open = handler;
25
+ return () => { if (open === handler) open = undefined; };
26
+ },
27
+ connectSource(next: DocumentPanelSource) {
28
+ source = next;
29
+ const unsubscribe = next.subscribePanelRequests(notify);
30
+ notify();
31
+ return () => {
32
+ unsubscribe();
33
+ if (source === next) source = undefined;
34
+ };
35
+ },
36
+ resolve(reference: DocumentPanelRequest['reference']) {
37
+ return source?.getPanelRequest(reference);
38
+ },
39
+ subscribe(listener: () => void) {
40
+ listeners.add(listener);
41
+ return () => { listeners.delete(listener); };
42
+ }
43
+ };
44
+ }
45
+
46
+ const workspaces = new WeakMap<object, ReturnType<typeof createDocumentWorkspace>>();
47
+
48
+ export function getDocumentWorkspace(vm: object) {
49
+ let workspace = workspaces.get(vm);
50
+ if (!workspace) {
51
+ workspace = createDocumentWorkspace();
52
+ workspaces.set(vm, workspace);
53
+ }
54
+ return workspace;
55
+ }
package/src/editor.css CHANGED
@@ -774,3 +774,10 @@ details.layout-menu button {
774
774
  .floating-stage:has([data-stage-in-workspace]:empty) {
775
775
  pointer-events: none;
776
776
  }
777
+
778
+ :global(.tw-plus-document-slot) {
779
+ inline-size: 100%;
780
+ block-size: 100%;
781
+ min-inline-size: 0;
782
+ min-block-size: 0;
783
+ }
@@ -1,13 +1,19 @@
1
1
  import React from 'react';
2
2
  import {createPortal} from 'react-dom';
3
3
  import {createDockview, DefaultTab, themeLight} from 'dockview';
4
- import type {DockviewGroupPanel, GroupPanelPartInitParameters, IDockviewPanel} from 'dockview';
4
+ import type {DockviewGroupPanel, GroupPanelPartInitParameters, IDockviewPanel, SerializedDockview} from 'dockview';
5
5
  import {createSpriteLayoutMenu} from './sprite-layout-menu.ts';
6
6
  import {SpriteAssetPane} from './sprite-asset-pane.tsx';
7
7
  import type {SpriteVariable} from './sprite-asset-pane.tsx';
8
8
  import {SpriteEditorToolbar, spriteEditorSections} from './sprite-editor-toolbar.tsx';
9
9
  import type {SpriteSearchResult} from './sprite-editor-toolbar.tsx';
10
10
  import type {DockviewStagePane} from './dockview-stage.ts';
11
+ import {getDocumentWorkspace} from './document-workspace.ts';
12
+ import type {DocumentPanelRequest} from './document-workspace.ts';
13
+ import {readWorkspaceLayout} from './workspace-layout.ts';
14
+ import type {PanelReference, WorkspaceLayout} from './workspace-layout.ts';
15
+
16
+ export {getDocumentWorkspace} from './document-workspace.ts';
11
17
 
12
18
  export {useDockviewStage} from './dockview-stage.ts';
13
19
 
@@ -20,6 +26,7 @@ interface Target {
20
26
  isOriginal: boolean;
21
27
  isStage: boolean;
22
28
  getName(): string;
29
+ getCostumes?(): {assetId?: string}[];
23
30
  getCurrentCostume?(): {asset?: CostumeAsset} | undefined;
24
31
  }
25
32
 
@@ -115,6 +122,9 @@ function createAssetSelections(target: Target, source?: {
115
122
  export type SpriteSplitDirection = 'right' | 'below';
116
123
 
117
124
  const stagePanelId = 'tw-plus-stage-display';
125
+ const documentComponent = 'tw-plus-document';
126
+ const documentTitle = ({filename, reference: {providerId, documentId}}: DocumentPanelRequest) =>
127
+ `${filename} (${providerId} / ${documentId})`;
118
128
 
119
129
  interface TabOptions<Snapshot = unknown> {
120
130
  vm: Project;
@@ -124,6 +134,12 @@ interface TabOptions<Snapshot = unknown> {
124
134
  resize(): void;
125
135
  reopenLabel: string;
126
136
  multiView?: boolean;
137
+ layoutStorage?: {
138
+ storage: Storage;
139
+ key: string;
140
+ isProjectLoading?(): boolean;
141
+ subscribeProject?(listener: () => void): (() => void) | undefined;
142
+ };
127
143
  stagePane?: DockviewStagePane;
128
144
  variables?: boolean;
129
145
  locale?: string;
@@ -176,6 +192,10 @@ const renameLabels = {
176
192
  ja: {title: 'スプライト名の変更', name: 'スプライト名', cancel: 'キャンセル', save: '変更'}
177
193
  };
178
194
  type RenameLabels = typeof renameLabels.en;
195
+ const targetName = (target: Target) => target.isStage ? 'Stage' : target.getName();
196
+ const projectSignature = (targets: readonly Target[]) => JSON.stringify(targets
197
+ .map(target => JSON.stringify([target.isStage, targetName(target), target.getCostumes?.()[0]?.assetId ?? '']))
198
+ .sort());
179
199
 
180
200
  class SpriteTab extends DefaultTab {
181
201
  private readonly icon: HTMLImageElement;
@@ -210,16 +230,25 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
210
230
  const editLabel = /^ja(?:-|$)/i.test(locale) ? 'クリックしてこのビューで編集' : 'Activate this view to edit';
211
231
  const slots = new Map<string, TabSlot>();
212
232
  const views = new Map<string, View<Snapshot>>();
233
+ const documents = new Map<string, {request: DocumentPanelRequest; session?: ReturnType<DocumentPanelRequest['mount']>}>();
213
234
  const costumeIcons = new WeakMap<CostumeAsset, string>();
214
235
  const remembered = new WeakMap<Target, number>();
215
236
  const lastUsed = new WeakMap<Target, View<Snapshot>>();
216
- const closed = new WeakSet<Target>();
237
+ let closed = new WeakSet<Target>();
217
238
  let active: View<Snapshot> | undefined;
218
239
  let observed = vm.editingTarget;
219
240
  let syncing = false;
220
241
  let nextView = 0;
221
242
  let renameDialog: HTMLDialogElement | undefined;
222
243
  const text = options.renameLabels ?? renameLabels.en;
244
+ const persistence = multiView && options.layoutStorage;
245
+ const window = document.defaultView;
246
+ const documentWorkspace = multiView && getDocumentWorkspace(vm);
247
+ let pendingRestore = Boolean(persistence);
248
+ let restoreFrame: number | undefined;
249
+ let restoreTimeout: number | undefined;
250
+ let saveFrame: number | undefined;
251
+ const originalTargets = () => vm.runtime.targets.filter(target => target.isOriginal);
223
252
 
224
253
  const dockview = createDockview(root, {
225
254
  theme: themeLight,
@@ -232,8 +261,17 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
232
261
  if (!target || target.isStage || !vm.renameSprite) return [];
233
262
  return [{label: text.title, action: () => openRenameDialog(target)}];
234
263
  },
235
- createComponent: ({name}) => {
264
+ createComponent: ({name, id: componentId}) => {
236
265
  const element = document.createElement('div');
266
+ if (name === documentComponent) {
267
+ element.className = 'tw-plus-document-slot';
268
+ return {element, init({api}) {
269
+ const item = documents.get(componentId);
270
+ element.dataset.providerId = item.request.reference.providerId;
271
+ element.dataset.documentId = item.request.reference.documentId;
272
+ item.session = item.request.mount(element, () => api.close());
273
+ }};
274
+ }
237
275
  if (name === stagePanelId) {
238
276
  element.className = 'tw-plus-dockview-stage-slot';
239
277
  return {element, init() { stagePane.attach(element); }};
@@ -312,7 +350,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
312
350
  splitRight: () => { if (group.activePanel) split('right', group.activePanel.id); },
313
351
  splitBelow: () => { if (group.activePanel) split('below', group.activePanel.id); },
314
352
  close: () => { if (group.activePanel) closeSplit(group.activePanel.id); },
315
- canClose: () => dockview.groups.length > 1,
353
+ canClose: () => canCloseGroup(group),
316
354
  canSplit: () => views.has(group.activePanel?.id)
317
355
  });
318
356
  return {...menu, init() {}};
@@ -444,8 +482,8 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
444
482
  }
445
483
 
446
484
  function show(panel: IDockviewPanel | undefined) {
447
- if (panel?.id === stagePanelId) {
448
- // Displaying the Stage does not select the Stage target or interrupt the sprite editor.
485
+ if (panel?.id === stagePanelId || documents.has(panel?.id)) {
486
+ // Utility panels do not change the VM editing target.
449
487
  refreshSurfaces();
450
488
  return;
451
489
  }
@@ -478,10 +516,10 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
478
516
 
479
517
  function add(target: Target, splitPosition?: {referencePanel: string; direction: SpriteSplitDirection}, section?: number) {
480
518
  let id = target.id;
481
- if (splitPosition || views.has(id) || id === stagePanelId) {
519
+ if (splitPosition || views.has(id) || id === stagePanelId || documents.has(id)) {
482
520
  do {
483
521
  id = 'sprite-view-' + ++nextView;
484
- } while (views.has(id) || vm.runtime.targets.some(item => item.id === id));
522
+ } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(item => item.id === id));
485
523
  }
486
524
  const source = splitPosition && views.get(splitPosition.referencePanel);
487
525
  views.set(id, {id, target, section: section ?? remembered.get(target) ?? options.getSection(),
@@ -517,8 +555,50 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
517
555
  activateView(view?.id ?? add(target).id);
518
556
  }
519
557
 
558
+ function openDocument(request: DocumentPanelRequest) {
559
+ if (pendingRestore) finishRestore();
560
+ const {providerId, documentId} = request.reference;
561
+ for (const [id, {request: opened}] of documents) {
562
+ if (opened.reference.providerId === providerId && opened.reference.documentId === documentId) {
563
+ dockview.getPanel(id)?.api.setActive();
564
+ return;
565
+ }
566
+ }
567
+ let id: string;
568
+ do {
569
+ id = 'document-view-' + ++nextView;
570
+ } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(target => target.id === id));
571
+ const title = documentTitle(request);
572
+ documents.set(id, {request});
573
+ try {
574
+ dockview.addPanel({id, title, component: documentComponent, renderer: 'always'});
575
+ } catch (error) {
576
+ documents.get(id)?.session?.dispose();
577
+ documents.delete(id);
578
+ throw error;
579
+ }
580
+ }
581
+
582
+ function guardDocumentClose(event: MouseEvent | KeyboardEvent) {
583
+ const element = event.target as HTMLElement;
584
+ const tab = element.closest<HTMLElement>('[data-tab-panel-id]');
585
+ const item = documents.get(tab?.dataset.tabPanelId);
586
+ if (!item) return;
587
+ const close = event.type === 'click' ? Boolean(element.closest('.dv-default-tab-action')) :
588
+ event.target === tab && ['Delete', 'Backspace'].includes((event as KeyboardEvent).key);
589
+ if (!close) return;
590
+ event.preventDefault();
591
+ event.stopPropagation();
592
+ item.session?.requestClose();
593
+ }
594
+
595
+ function canCloseGroup(group: DockviewGroupPanel) {
596
+ return dockview.groups.length > 1 && group.panels.every(panel => !documents.has(panel.id));
597
+ }
598
+
520
599
  function openStage() {
521
600
  if (!stagePane) return;
601
+ if (pendingRestore) finishRestore();
522
602
  const existing = dockview.getPanel(stagePanelId);
523
603
  if (existing) {
524
604
  existing.api.setActive();
@@ -544,7 +624,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
544
624
 
545
625
  function closeSplit(id = active?.id) {
546
626
  const panel = dockview.getPanel(id);
547
- if (!multiView || !panel || dockview.groups.length <= 1) return false;
627
+ if (!multiView || !panel || !canCloseGroup(panel.api.group)) return false;
548
628
  syncing = true;
549
629
  try {
550
630
  dockview.removeGroup(panel.api.group);
@@ -569,6 +649,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
569
649
  viewAdapter?.restore(describe(view), view.snapshots?.get(section)?.snapshot);
570
650
  }
571
651
  refreshSurfaces();
652
+ scheduleSave();
572
653
  return true;
573
654
  }
574
655
 
@@ -576,6 +657,106 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
576
657
  if (!active || !usesNativeSection(active.section) || active.section === section) return;
577
658
  active.section = section;
578
659
  refreshSurfaces();
660
+ scheduleSave();
661
+ }
662
+
663
+ function writeLayout() {
664
+ saveFrame = undefined;
665
+ const targets = originalTargets();
666
+ if (!persistence || pendingRestore || syncing || !targets.length || persistence.isProjectLoading?.()) return;
667
+ const {grid, activeGroup} = dockview.toJSON();
668
+ if (!activeGroup || grid.width <= 0 || grid.height <= 0) return;
669
+ const panels: Record<string, PanelReference> = Object.create(null);
670
+ for (const [id, view] of views) {
671
+ panels[id] = {kind: 'sprite', name: targetName(view.target), stage: view.target.isStage, section: view.section};
672
+ }
673
+ if (dockview.getPanel(stagePanelId)) panels[stagePanelId] = {kind: 'stage'};
674
+ for (const [id, {request}] of documents) panels[id] = {kind: 'document', ...request.reference};
675
+ const snapshot: WorkspaceLayout = {version: 1, project: projectSignature(targets), grid, activeGroup,
676
+ editingView: active?.id, panels};
677
+ try { persistence.storage.setItem(persistence.key, JSON.stringify(snapshot)); } catch { /* Storage can be unavailable. */ }
678
+ }
679
+
680
+ function scheduleSave() {
681
+ if (persistence && !pendingRestore && saveFrame === undefined) {
682
+ saveFrame = window.requestAnimationFrame(writeLayout);
683
+ }
684
+ }
685
+
686
+ function flushLayout() {
687
+ if (saveFrame !== undefined) {
688
+ window.cancelAnimationFrame(saveFrame);
689
+ writeLayout();
690
+ }
691
+ }
692
+
693
+ function finishRestore() {
694
+ pendingRestore = false;
695
+ window.clearTimeout(restoreTimeout);
696
+ }
697
+
698
+ function restoreLayout() {
699
+ restoreFrame = undefined;
700
+ if (!pendingRestore || !persistence || persistence.isProjectLoading?.()) return;
701
+ const targets = originalTargets();
702
+ if (!targets.length || !vm.editingTarget) return;
703
+ let stored: WorkspaceLayout | undefined;
704
+ try {
705
+ const raw = persistence.storage.getItem(persistence.key);
706
+ if (raw) stored = readWorkspaceLayout(raw, projectSignature(targets));
707
+ } catch { /* Storage can be unavailable. */ }
708
+ if (!stored) return finishRestore();
709
+ const resolvedSprites = new Map<string, View<Snapshot>>();
710
+ const resolvedDocuments = new Map<string, DocumentPanelRequest>();
711
+ const panels: SerializedDockview['panels'] = Object.create(null);
712
+ for (const [id, reference] of Object.entries(stored.panels)) {
713
+ if (reference.kind === 'sprite') {
714
+ const target = targets.find(item => item.isStage === reference.stage && targetName(item) === reference.name);
715
+ if (!target || id === stagePanelId || (reference.section === variableSection && !options.variables)) return finishRestore();
716
+ resolvedSprites.set(id, {id, target, section: reference.section, assets: createAssetSelections(target)});
717
+ panels[id] = {id, title: target.getName(), contentComponent: 'sprite', params: {icon: getIcon(target)}};
718
+ } else if (reference.kind === 'stage') {
719
+ if (!stagePane || id !== stagePanelId) return finishRestore();
720
+ panels[id] = {id, title: stagePane.title, contentComponent: stagePanelId, renderer: 'always'};
721
+ } else {
722
+ if (id === stagePanelId) return finishRestore();
723
+ const request = documentWorkspace && documentWorkspace.resolve(reference);
724
+ if (!request) {
725
+ // The project can finish loading before the Blockly host publishes its document catalog.
726
+ restoreTimeout ??= window.setTimeout(finishRestore, 2000);
727
+ return;
728
+ }
729
+ resolvedDocuments.set(id, request);
730
+ panels[id] = {id, title: documentTitle(request), contentComponent: documentComponent, renderer: 'always'};
731
+ }
732
+ }
733
+ finishRestore();
734
+ syncing = true;
735
+ try {
736
+ dockview.clear();
737
+ active = undefined;
738
+ const represented = new Set([...resolvedSprites.values()].map(view => view.target));
739
+ closed = new WeakSet(targets.filter(target => !represented.has(target)));
740
+ for (const [id, view] of resolvedSprites) views.set(id, view);
741
+ for (const [id, request] of resolvedDocuments) documents.set(id, {request});
742
+ dockview.fromJSON({grid: stored.grid, activeGroup: stored.activeGroup, panels});
743
+ const selected = dockview.activePanel;
744
+ show(views.has(selected?.id) ? selected : dockview.getPanel(stored.editingView) ?? selected);
745
+ } catch {
746
+ dockview.clear();
747
+ for (const view of views.values()) disposeView(view);
748
+ for (const item of documents.values()) item.session?.dispose();
749
+ documents.clear();
750
+ closed = new WeakSet();
751
+ active = undefined;
752
+ } finally {
753
+ syncing = false;
754
+ }
755
+ refresh();
756
+ }
757
+
758
+ function scheduleRestore() {
759
+ if (pendingRestore && restoreFrame === undefined) restoreFrame = window.requestAnimationFrame(restoreLayout);
579
760
  }
580
761
 
581
762
  function refresh() {
@@ -608,6 +789,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
608
789
  } finally {
609
790
  syncing = false;
610
791
  }
792
+ scheduleRestore();
611
793
  }
612
794
 
613
795
  function disposeView(view: View<Snapshot>) {
@@ -626,8 +808,15 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
626
808
  } finally {
627
809
  syncing = false;
628
810
  }
811
+ scheduleSave();
629
812
  });
630
813
  const removal = dockview.onDidRemovePanel(panel => {
814
+ const item = documents.get(panel.id);
815
+ if (item) {
816
+ documents.delete(panel.id);
817
+ item.session?.dispose();
818
+ return;
819
+ }
631
820
  if (panel.id === stagePanelId) {
632
821
  stagePane?.attach(null);
633
822
  return;
@@ -638,7 +827,16 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
638
827
  disposeView(view);
639
828
  if (![...views.values()].some(item => item.target === view.target)) closed.add(view.target);
640
829
  });
641
- const layout = multiView && dockview.onDidLayoutChange(refreshSurfaces);
830
+ const layout = multiView && dockview.onDidLayoutChange(() => {
831
+ refreshSurfaces();
832
+ scheduleSave();
833
+ });
834
+ const disconnectDocuments = documentWorkspace && documentWorkspace.connect(openDocument);
835
+ const unsubscribeDocuments = persistence && documentWorkspace && documentWorkspace.subscribe(scheduleRestore);
836
+ const unsubscribeProject = persistence && persistence.subscribeProject?.(scheduleRestore);
837
+ if (persistence) window.addEventListener('pagehide', flushLayout);
838
+ root.addEventListener('click', guardDocumentClose, true);
839
+ root.addEventListener('keydown', guardDocumentClose, true);
642
840
  vm.on('targetsUpdate', refresh);
643
841
  refresh();
644
842
  return {
@@ -651,6 +849,17 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
651
849
  currentView: () => active && describe(active),
652
850
  views: () => [...views.values()].map(view => describe(view)),
653
851
  dispose() {
852
+ flushLayout();
853
+ window.cancelAnimationFrame(restoreFrame);
854
+ window.clearTimeout(restoreTimeout);
855
+ if (persistence) window.removeEventListener('pagehide', flushLayout);
856
+ if (unsubscribeDocuments) unsubscribeDocuments();
857
+ if (unsubscribeProject) unsubscribeProject();
858
+ if (disconnectDocuments) disconnectDocuments();
859
+ root.removeEventListener('click', guardDocumentClose, true);
860
+ root.removeEventListener('keydown', guardDocumentClose, true);
861
+ for (const item of documents.values()) item.session?.dispose();
862
+ documents.clear();
654
863
  stagePane?.attach(null);
655
864
  renameDialog?.remove();
656
865
  renameDialog = undefined;
@@ -784,6 +993,7 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
784
993
  resize: () => latest.current.resize(),
785
994
  reopenLabel: props.reopenLabel,
786
995
  multiView: props.multiView,
996
+ layoutStorage: props.layoutStorage,
787
997
  stagePane: props.stagePane,
788
998
  variables: hasVariables,
789
999
  locale: props.locale,
@@ -802,7 +1012,7 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
802
1012
  instance.dispose();
803
1013
  };
804
1014
  }, [content, props.enabled, props.vm, props.reopenLabel, props.multiView, props.locale,
805
- props.viewAdapter, props.stagePane, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
1015
+ props.viewAdapter, props.stagePane, props.layoutStorage, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
806
1016
  React.useLayoutEffect(() => {
807
1017
  controller.current?.syncActiveSection(props.activeSection);
808
1018
  }, [props.activeSection]);
@@ -0,0 +1,90 @@
1
+ import {Orientation} from 'dockview';
2
+ import type {SerializedDockview} from 'dockview';
3
+ import type {DocumentPanelRequest} from './document-workspace.ts';
4
+
5
+ type Grid = SerializedDockview['grid'];
6
+ type Node = Grid['root'];
7
+ export type PanelReference =
8
+ | {kind: 'sprite'; name: string; stage: boolean; section: number}
9
+ | {kind: 'stage'}
10
+ | ({kind: 'document'} & DocumentPanelRequest['reference']);
11
+
12
+ export interface WorkspaceLayout {
13
+ version: 1;
14
+ project: string;
15
+ grid: Grid;
16
+ activeGroup: string;
17
+ editingView?: string;
18
+ panels: Record<string, PanelReference>;
19
+ }
20
+
21
+ const limit = 256;
22
+ const validId = (value: unknown): value is string =>
23
+ typeof value === 'string' && value.length > 0 && value.length <= 1024;
24
+ const size = (value: unknown): value is number =>
25
+ typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100000;
26
+ const record = (value: unknown): value is Record<string, unknown> =>
27
+ value !== null && typeof value === 'object' && !Array.isArray(value);
28
+
29
+ /** Rebuild only the supported docked grid and logical panel references from storage. */
30
+ export function readWorkspaceLayout(raw: string, project: string): WorkspaceLayout | undefined {
31
+ if (raw.length > 1024 * 1024) return;
32
+ let input: unknown;
33
+ try { input = JSON.parse(raw); } catch { return; }
34
+ if (!record(input) || input.version !== 1 || input.project !== project || !record(input.grid) ||
35
+ !record(input.panels) || !validId(input.activeGroup)) return;
36
+ const {width, height, orientation} = input.grid;
37
+ if (!size(width) || !width || !size(height) || !height ||
38
+ (orientation !== Orientation.HORIZONTAL && orientation !== Orientation.VERTICAL)) return;
39
+ const groups = new Set<string>();
40
+ const ids = new Set<string>();
41
+ let nodes = 0;
42
+ function readNode(node: unknown, depth: number): Node | undefined {
43
+ if (depth > 32 || ++nodes > limit || !record(node) || !size(node.size)) return;
44
+ if (node.type === 'branch' && Array.isArray(node.data) && node.data.length > 0 && node.data.length <= limit) {
45
+ const data: Node[] = [];
46
+ for (const child of node.data) {
47
+ const next = readNode(child, depth + 1);
48
+ if (!next) return;
49
+ data.push(next);
50
+ }
51
+ return {type: 'branch', size: node.size, data};
52
+ }
53
+ if (node.type !== 'leaf' || !record(node.data)) return;
54
+ const {id, views, activeView} = node.data;
55
+ if (!validId(id) || groups.has(id) || !Array.isArray(views) || !views.length ||
56
+ views.length > limit || !validId(activeView) || !views.includes(activeView)) return;
57
+ groups.add(id);
58
+ for (const panel of views) {
59
+ if (!validId(panel) || ids.has(panel) || ids.size >= limit) return;
60
+ ids.add(panel);
61
+ }
62
+ return {type: 'leaf', size: node.size, data: {id, views, activeView}};
63
+ }
64
+ const root = readNode(input.grid.root, 0);
65
+ if (root?.type !== 'branch' || !groups.has(input.activeGroup) ||
66
+ ids.size !== Object.keys(input.panels).length) return;
67
+ const panels: WorkspaceLayout['panels'] = Object.create(null);
68
+ const documents = new Set<string>();
69
+ let hasStage = false;
70
+ for (const id of ids) {
71
+ const panel = input.panels[id];
72
+ if (!Object.hasOwn(input.panels, id) || !record(panel)) return;
73
+ if (panel.kind === 'sprite' && validId(panel.name) && typeof panel.stage === 'boolean' &&
74
+ typeof panel.section === 'number' && Number.isInteger(panel.section) && panel.section >= 0 && panel.section <= 3) {
75
+ panels[id] = {kind: 'sprite', name: panel.name, stage: panel.stage, section: panel.section};
76
+ } else if (panel.kind === 'stage' && !hasStage) {
77
+ hasStage = true;
78
+ panels[id] = {kind: 'stage'};
79
+ } else if (panel.kind === 'document' && validId(panel.providerId) && validId(panel.documentId)) {
80
+ const key = JSON.stringify([panel.providerId, panel.documentId]);
81
+ if (documents.has(key)) return;
82
+ documents.add(key);
83
+ panels[id] = {kind: 'document', providerId: panel.providerId, documentId: panel.documentId};
84
+ } else return;
85
+ }
86
+ const editingView = input.editingView;
87
+ if (editingView !== undefined && (!validId(editingView) || panels[editingView]?.kind !== 'sprite')) return;
88
+ return {version: 1, project, grid: {root, width, height, orientation}, activeGroup: input.activeGroup,
89
+ editingView: typeof editingView === 'string' ? editingView : undefined, panels};
90
+ }