@kubohiroya/turbowarp-plus-editor-layout 0.1.5 → 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.
@@ -9,7 +9,15 @@ export interface DocumentPanelRequest {
9
9
  dispose(): void;
10
10
  };
11
11
  }
12
+ interface DocumentPanelSource {
13
+ getPanelRequest(reference: DocumentPanelRequest['reference']): DocumentPanelRequest | undefined;
14
+ subscribePanelRequests(listener: () => void): () => void;
15
+ }
12
16
  export declare function getDocumentWorkspace(vm: object): {
13
17
  open(request: DocumentPanelRequest): boolean;
14
18
  connect(handler: NonNullable<(request: DocumentPanelRequest) => void>): () => void;
19
+ connectSource(next: DocumentPanelSource): () => void;
20
+ resolve(reference: DocumentPanelRequest["reference"]): DocumentPanelRequest;
21
+ subscribe(listener: () => void): () => void;
15
22
  };
23
+ export {};
@@ -1,5 +1,8 @@
1
1
  function createDocumentWorkspace() {
2
2
  let open;
3
+ let source;
4
+ const listeners = new Set();
5
+ const notify = () => listeners.forEach(listener => listener());
3
6
  return {
4
7
  open(request) {
5
8
  if (!open)
@@ -11,6 +14,23 @@ function createDocumentWorkspace() {
11
14
  open = handler;
12
15
  return () => { if (open === handler)
13
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); };
14
34
  }
15
35
  };
16
36
  }
@@ -12,6 +12,9 @@ interface Target {
12
12
  isOriginal: boolean;
13
13
  isStage: boolean;
14
14
  getName(): string;
15
+ getCostumes?(): {
16
+ assetId?: string;
17
+ }[];
15
18
  getCurrentCostume?(): {
16
19
  asset?: CostumeAsset;
17
20
  } | undefined;
@@ -74,6 +77,12 @@ interface TabOptions<Snapshot = unknown> {
74
77
  resize(): void;
75
78
  reopenLabel: string;
76
79
  multiView?: boolean;
80
+ layoutStorage?: {
81
+ storage: Storage;
82
+ key: string;
83
+ isProjectLoading?(): boolean;
84
+ subscribeProject?(listener: () => void): (() => void) | undefined;
85
+ };
77
86
  stagePane?: DockviewStagePane;
78
87
  variables?: boolean;
79
88
  locale?: string;
@@ -5,6 +5,7 @@ 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
7
  import { getDocumentWorkspace } from "./document-workspace.js";
8
+ import { readWorkspaceLayout } from "./workspace-layout.js";
8
9
  export { getDocumentWorkspace } from "./document-workspace.js";
9
10
  export { useDockviewStage } from "./dockview-stage.js";
10
11
  const variableSection = spriteEditorSections.indexOf('variables');
@@ -44,10 +45,15 @@ function createAssetSelections(target, source) {
44
45
  }
45
46
  const stagePanelId = 'tw-plus-stage-display';
46
47
  const documentComponent = 'tw-plus-document';
48
+ const documentTitle = ({ filename, reference: { providerId, documentId } }) => `${filename} (${providerId} / ${documentId})`;
47
49
  const renameLabels = {
48
50
  en: { title: 'Rename sprite', name: 'Sprite name', cancel: 'Cancel', save: 'Rename' },
49
51
  ja: { title: 'スプライト名の変更', name: 'スプライト名', cancel: 'キャンセル', save: '変更' }
50
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());
51
57
  class SpriteTab extends DefaultTab {
52
58
  icon;
53
59
  constructor(document) {
@@ -83,13 +89,21 @@ export function mountSpriteTabs(root, content, options) {
83
89
  const costumeIcons = new WeakMap();
84
90
  const remembered = new WeakMap();
85
91
  const lastUsed = new WeakMap();
86
- const closed = new WeakSet();
92
+ let closed = new WeakSet();
87
93
  let active;
88
94
  let observed = vm.editingTarget;
89
95
  let syncing = false;
90
96
  let nextView = 0;
91
97
  let renameDialog;
92
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);
93
107
  const dockview = createDockview(root, {
94
108
  theme: themeLight,
95
109
  disableFloatingGroups: true,
@@ -384,7 +398,7 @@ export function mountSpriteTabs(root, content, options) {
384
398
  if (splitPosition || views.has(id) || id === stagePanelId || documents.has(id)) {
385
399
  do {
386
400
  id = 'sprite-view-' + ++nextView;
387
- } 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));
388
402
  }
389
403
  const source = splitPosition && views.get(splitPosition.referencePanel);
390
404
  views.set(id, { id, target, section: section ?? remembered.get(target) ?? options.getSection(),
@@ -421,6 +435,8 @@ export function mountSpriteTabs(root, content, options) {
421
435
  activateView(view?.id ?? add(target).id);
422
436
  }
423
437
  function openDocument(request) {
438
+ if (pendingRestore)
439
+ finishRestore();
424
440
  const { providerId, documentId } = request.reference;
425
441
  for (const [id, { request: opened }] of documents) {
426
442
  if (opened.reference.providerId === providerId && opened.reference.documentId === documentId) {
@@ -432,7 +448,7 @@ export function mountSpriteTabs(root, content, options) {
432
448
  do {
433
449
  id = 'document-view-' + ++nextView;
434
450
  } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(target => target.id === id));
435
- const title = request.filename + ' (' + providerId + ' / ' + documentId + ')';
451
+ const title = documentTitle(request);
436
452
  documents.set(id, { request });
437
453
  try {
438
454
  dockview.addPanel({ id, title, component: documentComponent, renderer: 'always' });
@@ -463,6 +479,8 @@ export function mountSpriteTabs(root, content, options) {
463
479
  function openStage() {
464
480
  if (!stagePane)
465
481
  return;
482
+ if (pendingRestore)
483
+ finishRestore();
466
484
  const existing = dockview.getPanel(stagePanelId);
467
485
  if (existing) {
468
486
  existing.api.setActive();
@@ -514,6 +532,7 @@ export function mountSpriteTabs(root, content, options) {
514
532
  viewAdapter?.restore(describe(view), view.snapshots?.get(section)?.snapshot);
515
533
  }
516
534
  refreshSurfaces();
535
+ scheduleSave();
517
536
  return true;
518
537
  }
519
538
  function syncActiveSection(section) {
@@ -521,6 +540,124 @@ export function mountSpriteTabs(root, content, options) {
521
540
  return;
522
541
  active.section = section;
523
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);
524
661
  }
525
662
  function refresh() {
526
663
  if (syncing)
@@ -557,6 +694,7 @@ export function mountSpriteTabs(root, content, options) {
557
694
  finally {
558
695
  syncing = false;
559
696
  }
697
+ scheduleRestore();
560
698
  }
561
699
  function disposeView(view) {
562
700
  for (const stored of view.snapshots?.values() ?? [])
@@ -577,6 +715,7 @@ export function mountSpriteTabs(root, content, options) {
577
715
  finally {
578
716
  syncing = false;
579
717
  }
718
+ scheduleSave();
580
719
  });
581
720
  const removal = dockview.onDidRemovePanel(panel => {
582
721
  const item = documents.get(panel.id);
@@ -598,8 +737,15 @@ export function mountSpriteTabs(root, content, options) {
598
737
  if (![...views.values()].some(item => item.target === view.target))
599
738
  closed.add(view.target);
600
739
  });
601
- const layout = multiView && dockview.onDidLayoutChange(refreshSurfaces);
602
- const disconnectDocuments = multiView && getDocumentWorkspace(vm).connect(openDocument);
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);
603
749
  root.addEventListener('click', guardDocumentClose, true);
604
750
  root.addEventListener('keydown', guardDocumentClose, true);
605
751
  vm.on('targetsUpdate', refresh);
@@ -614,6 +760,15 @@ export function mountSpriteTabs(root, content, options) {
614
760
  currentView: () => active && describe(active),
615
761
  views: () => [...views.values()].map(view => describe(view)),
616
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();
617
772
  if (disconnectDocuments)
618
773
  disconnectDocuments();
619
774
  root.removeEventListener('click', guardDocumentClose, true);
@@ -730,6 +885,7 @@ function SpriteTabs(props) {
730
885
  resize: () => latest.current.resize(),
731
886
  reopenLabel: props.reopenLabel,
732
887
  multiView: props.multiView,
888
+ layoutStorage: props.layoutStorage,
733
889
  stagePane: props.stagePane,
734
890
  variables: hasVariables,
735
891
  locale: props.locale,
@@ -749,7 +905,7 @@ function SpriteTabs(props) {
749
905
  instance.dispose();
750
906
  };
751
907
  }, [content, props.enabled, props.vm, props.reopenLabel, props.multiView, props.locale,
752
- props.viewAdapter, props.stagePane, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
908
+ props.viewAdapter, props.stagePane, props.layoutStorage, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
753
909
  React.useLayoutEffect(() => {
754
910
  controller.current?.syncActiveSection(props.activeSection);
755
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.5",
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",
@@ -4,8 +4,16 @@ export interface DocumentPanelRequest {
4
4
  mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
5
5
  }
6
6
 
7
+ interface DocumentPanelSource {
8
+ getPanelRequest(reference: DocumentPanelRequest['reference']): DocumentPanelRequest | undefined;
9
+ subscribePanelRequests(listener: () => void): () => void;
10
+ }
11
+
7
12
  function createDocumentWorkspace() {
8
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());
9
17
  return {
10
18
  open(request: DocumentPanelRequest) {
11
19
  if (!open) return false;
@@ -15,6 +23,22 @@ function createDocumentWorkspace() {
15
23
  connect(handler: NonNullable<typeof open>) {
16
24
  open = handler;
17
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); };
18
42
  }
19
43
  };
20
44
  }
@@ -1,7 +1,7 @@
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';
@@ -10,6 +10,8 @@ import type {SpriteSearchResult} from './sprite-editor-toolbar.tsx';
10
10
  import type {DockviewStagePane} from './dockview-stage.ts';
11
11
  import {getDocumentWorkspace} from './document-workspace.ts';
12
12
  import type {DocumentPanelRequest} from './document-workspace.ts';
13
+ import {readWorkspaceLayout} from './workspace-layout.ts';
14
+ import type {PanelReference, WorkspaceLayout} from './workspace-layout.ts';
13
15
 
14
16
  export {getDocumentWorkspace} from './document-workspace.ts';
15
17
 
@@ -24,6 +26,7 @@ interface Target {
24
26
  isOriginal: boolean;
25
27
  isStage: boolean;
26
28
  getName(): string;
29
+ getCostumes?(): {assetId?: string}[];
27
30
  getCurrentCostume?(): {asset?: CostumeAsset} | undefined;
28
31
  }
29
32
 
@@ -120,6 +123,8 @@ export type SpriteSplitDirection = 'right' | 'below';
120
123
 
121
124
  const stagePanelId = 'tw-plus-stage-display';
122
125
  const documentComponent = 'tw-plus-document';
126
+ const documentTitle = ({filename, reference: {providerId, documentId}}: DocumentPanelRequest) =>
127
+ `${filename} (${providerId} / ${documentId})`;
123
128
 
124
129
  interface TabOptions<Snapshot = unknown> {
125
130
  vm: Project;
@@ -129,6 +134,12 @@ interface TabOptions<Snapshot = unknown> {
129
134
  resize(): void;
130
135
  reopenLabel: string;
131
136
  multiView?: boolean;
137
+ layoutStorage?: {
138
+ storage: Storage;
139
+ key: string;
140
+ isProjectLoading?(): boolean;
141
+ subscribeProject?(listener: () => void): (() => void) | undefined;
142
+ };
132
143
  stagePane?: DockviewStagePane;
133
144
  variables?: boolean;
134
145
  locale?: string;
@@ -181,6 +192,10 @@ const renameLabels = {
181
192
  ja: {title: 'スプライト名の変更', name: 'スプライト名', cancel: 'キャンセル', save: '変更'}
182
193
  };
183
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());
184
199
 
185
200
  class SpriteTab extends DefaultTab {
186
201
  private readonly icon: HTMLImageElement;
@@ -219,13 +234,21 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
219
234
  const costumeIcons = new WeakMap<CostumeAsset, string>();
220
235
  const remembered = new WeakMap<Target, number>();
221
236
  const lastUsed = new WeakMap<Target, View<Snapshot>>();
222
- const closed = new WeakSet<Target>();
237
+ let closed = new WeakSet<Target>();
223
238
  let active: View<Snapshot> | undefined;
224
239
  let observed = vm.editingTarget;
225
240
  let syncing = false;
226
241
  let nextView = 0;
227
242
  let renameDialog: HTMLDialogElement | undefined;
228
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);
229
252
 
230
253
  const dockview = createDockview(root, {
231
254
  theme: themeLight,
@@ -496,7 +519,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
496
519
  if (splitPosition || views.has(id) || id === stagePanelId || documents.has(id)) {
497
520
  do {
498
521
  id = 'sprite-view-' + ++nextView;
499
- } 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));
500
523
  }
501
524
  const source = splitPosition && views.get(splitPosition.referencePanel);
502
525
  views.set(id, {id, target, section: section ?? remembered.get(target) ?? options.getSection(),
@@ -533,6 +556,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
533
556
  }
534
557
 
535
558
  function openDocument(request: DocumentPanelRequest) {
559
+ if (pendingRestore) finishRestore();
536
560
  const {providerId, documentId} = request.reference;
537
561
  for (const [id, {request: opened}] of documents) {
538
562
  if (opened.reference.providerId === providerId && opened.reference.documentId === documentId) {
@@ -544,7 +568,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
544
568
  do {
545
569
  id = 'document-view-' + ++nextView;
546
570
  } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(target => target.id === id));
547
- const title = request.filename + ' (' + providerId + ' / ' + documentId + ')';
571
+ const title = documentTitle(request);
548
572
  documents.set(id, {request});
549
573
  try {
550
574
  dockview.addPanel({id, title, component: documentComponent, renderer: 'always'});
@@ -574,6 +598,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
574
598
 
575
599
  function openStage() {
576
600
  if (!stagePane) return;
601
+ if (pendingRestore) finishRestore();
577
602
  const existing = dockview.getPanel(stagePanelId);
578
603
  if (existing) {
579
604
  existing.api.setActive();
@@ -624,6 +649,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
624
649
  viewAdapter?.restore(describe(view), view.snapshots?.get(section)?.snapshot);
625
650
  }
626
651
  refreshSurfaces();
652
+ scheduleSave();
627
653
  return true;
628
654
  }
629
655
 
@@ -631,6 +657,106 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
631
657
  if (!active || !usesNativeSection(active.section) || active.section === section) return;
632
658
  active.section = section;
633
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);
634
760
  }
635
761
 
636
762
  function refresh() {
@@ -663,6 +789,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
663
789
  } finally {
664
790
  syncing = false;
665
791
  }
792
+ scheduleRestore();
666
793
  }
667
794
 
668
795
  function disposeView(view: View<Snapshot>) {
@@ -681,6 +808,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
681
808
  } finally {
682
809
  syncing = false;
683
810
  }
811
+ scheduleSave();
684
812
  });
685
813
  const removal = dockview.onDidRemovePanel(panel => {
686
814
  const item = documents.get(panel.id);
@@ -699,8 +827,14 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
699
827
  disposeView(view);
700
828
  if (![...views.values()].some(item => item.target === view.target)) closed.add(view.target);
701
829
  });
702
- const layout = multiView && dockview.onDidLayoutChange(refreshSurfaces);
703
- const disconnectDocuments = multiView && getDocumentWorkspace(vm).connect(openDocument);
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);
704
838
  root.addEventListener('click', guardDocumentClose, true);
705
839
  root.addEventListener('keydown', guardDocumentClose, true);
706
840
  vm.on('targetsUpdate', refresh);
@@ -715,6 +849,12 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
715
849
  currentView: () => active && describe(active),
716
850
  views: () => [...views.values()].map(view => describe(view)),
717
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();
718
858
  if (disconnectDocuments) disconnectDocuments();
719
859
  root.removeEventListener('click', guardDocumentClose, true);
720
860
  root.removeEventListener('keydown', guardDocumentClose, true);
@@ -853,6 +993,7 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
853
993
  resize: () => latest.current.resize(),
854
994
  reopenLabel: props.reopenLabel,
855
995
  multiView: props.multiView,
996
+ layoutStorage: props.layoutStorage,
856
997
  stagePane: props.stagePane,
857
998
  variables: hasVariables,
858
999
  locale: props.locale,
@@ -871,7 +1012,7 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
871
1012
  instance.dispose();
872
1013
  };
873
1014
  }, [content, props.enabled, props.vm, props.reopenLabel, props.multiView, props.locale,
874
- props.viewAdapter, props.stagePane, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
1015
+ props.viewAdapter, props.stagePane, props.layoutStorage, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
875
1016
  React.useLayoutEffect(() => {
876
1017
  controller.current?.syncActiveSection(props.activeSection);
877
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
+ }