@kubohiroya/turbowarp-plus-editor-layout 0.1.5 → 0.1.7

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
  }
@@ -1,4 +1,49 @@
1
1
  import React from 'react';
2
+ import type { SpriteAssetPreviewItem } from './sprite-tabs.ts';
3
+ interface NativeAssetPanelProps {
4
+ children?: React.ReactNode;
5
+ buttons: readonly {
6
+ title: string;
7
+ img: string;
8
+ onClick(): void;
9
+ }[];
10
+ items: readonly {
11
+ dragPayload?: object;
12
+ name: string;
13
+ url?: string;
14
+ details?: React.ReactNode;
15
+ }[];
16
+ isRtl?: boolean;
17
+ listActions?: React.ElementType;
18
+ selectedItemIndex: number;
19
+ onItemClick(index: number): void;
20
+ }
21
+ interface NativeSelectorProps extends NativeAssetPanelProps {
22
+ wrapperAttributes?: Record<string, string>;
23
+ listRef?(node: HTMLElement | null): void;
24
+ onListScroll?(event: React.UIEvent<HTMLElement>): void;
25
+ draggingType?: null;
26
+ onAddSortable?(): void;
27
+ onRemoveSortable?(): void;
28
+ }
29
+ export type NativeAssetSelector = React.ComponentType<NativeSelectorProps>;
30
+ export type NativeAssetPanelRenderer = (props: NativeAssetPanelProps, options: {
31
+ panelAttributes: Record<string, string>;
32
+ detailAttributes: Record<string, string>;
33
+ renderSelector(Component: NativeAssetSelector, props: NativeSelectorProps): React.ReactNode;
34
+ }) => React.ReactNode;
35
+ export type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number, detail: React.ReactNode, scrollTop: number) => React.ReactNode;
36
+ interface PanelState {
37
+ rememberLayout(render: SpriteAssetLayout): void;
38
+ getScrollTop(): number;
39
+ rememberScrollTop(position: number): void;
40
+ }
41
+ export declare function NativeAssetPanel({ render, previewSelector, panel, state }: {
42
+ render: NativeAssetPanelRenderer;
43
+ previewSelector: NativeAssetSelector;
44
+ panel: NativeAssetPanelProps;
45
+ state: PanelState;
46
+ }): React.JSX.Element;
2
47
  export interface SpriteMediaAsset {
3
48
  /** Host identity for this item within its target, independent of the list order. */
4
49
  id: string;
@@ -1,4 +1,47 @@
1
1
  import React from 'react';
2
+ const noop = () => { };
3
+ function NativeAssetPanelSurface({ render, panel, state, previewSelector, scrollTop }) {
4
+ const list = React.useRef(null);
5
+ const setList = React.useCallback((node) => {
6
+ list.current = node;
7
+ }, []);
8
+ React.useLayoutEffect(() => {
9
+ const position = state?.getScrollTop() ?? scrollTop;
10
+ if (list.current && position !== undefined)
11
+ list.current.scrollTop = position;
12
+ }, [state, scrollTop]);
13
+ return render(panel, {
14
+ panelAttributes: { 'data-layout-asset-panel': '' },
15
+ detailAttributes: { 'data-layout-asset-detail': '' },
16
+ renderSelector(Component, props) {
17
+ const selectorProps = { ...props, listRef: setList, wrapperAttributes: { 'data-layout-asset-list': '' } };
18
+ if (previewSelector) {
19
+ return React.createElement("div", { inert: '', style: { display: 'contents' } }, React.createElement(previewSelector, {
20
+ ...selectorProps, draggingType: null, onAddSortable: noop, onRemoveSortable: noop
21
+ }));
22
+ }
23
+ return React.createElement(Component, { ...selectorProps, onListScroll: event => {
24
+ state?.rememberScrollTop(event.currentTarget.scrollTop);
25
+ } });
26
+ }
27
+ });
28
+ }
29
+ export function NativeAssetPanel({ render, previewSelector, panel, state }) {
30
+ const { items, buttons, isRtl, listActions } = panel;
31
+ React.useLayoutEffect(() => {
32
+ const previousItems = new Map(items.map(item => [item.dragPayload, item]));
33
+ const readOnlyButtons = buttons.map(({ title, img }) => ({ title, img, onClick: noop }));
34
+ state.rememberLayout((previewItems, selectedItemIndex, detail, scrollTop) => React.createElement(NativeAssetPanelSurface, { render: render, previewSelector: previewSelector, scrollTop: scrollTop, panel: {
35
+ isRtl, listActions, selectedItemIndex, buttons: readOnlyButtons,
36
+ onItemClick: noop, children: detail,
37
+ items: previewItems.map(item => {
38
+ const previous = previousItems.get(item.asset);
39
+ return { name: item.name, url: item.imageUrl || previous?.url, details: previous?.details };
40
+ })
41
+ } }));
42
+ }, [state, render, previewSelector, items, buttons, isRtl, listActions]);
43
+ return React.createElement(NativeAssetPanelSurface, { render: render, panel: panel, state: state });
44
+ }
2
45
  const english = {
3
46
  costumes: 'Costumes', sounds: 'Sounds', variables: 'Variables', name: 'Name', value: 'Value',
4
47
  local: 'Sprite', global: 'Project', emptyCostumes: 'No costumes', emptySounds: 'No sounds',
@@ -1,4 +1,6 @@
1
1
  import React from 'react';
2
+ import { NativeAssetPanel } from './sprite-asset-pane.tsx';
3
+ import type { NativeAssetPanelRenderer, NativeAssetSelector, SpriteAssetLayout } from './sprite-asset-pane.tsx';
2
4
  import type { SpriteVariable } from './sprite-asset-pane.tsx';
3
5
  import type { SpriteSearchResult } from './sprite-editor-toolbar.tsx';
4
6
  import type { DockviewStagePane } from './dockview-stage.ts';
@@ -12,6 +14,9 @@ interface Target {
12
14
  isOriginal: boolean;
13
15
  isStage: boolean;
14
16
  getName(): string;
17
+ getCostumes?(): {
18
+ assetId?: string;
19
+ }[];
15
20
  getCurrentCostume?(): {
16
21
  asset?: CostumeAsset;
17
22
  } | undefined;
@@ -46,6 +51,7 @@ export interface SpriteTabAdapter<Snapshot = unknown> {
46
51
  export interface SpriteAssetSelection {
47
52
  restore(target: object, assets: readonly object[]): number | undefined;
48
53
  remember(target: object, asset: object | undefined, select?: (index: number) => void): void;
54
+ renderPanel?(render: NativeAssetPanelRenderer, previewSelector: NativeAssetSelector, panel: React.ComponentProps<typeof NativeAssetPanel>['panel']): React.ReactNode;
49
55
  rememberLayout?(render: SpriteAssetLayout): void;
50
56
  getScrollTop?(): number;
51
57
  rememberScrollTop?(position: number): void;
@@ -59,7 +65,6 @@ export interface SpriteAssetPreviewItem {
59
65
  buffer?: AudioBuffer;
60
66
  duration?: number;
61
67
  }
62
- type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number, detail: React.ReactNode, scrollTop: number) => React.ReactNode;
63
68
  export interface SpriteAssetPreviewData {
64
69
  items: readonly SpriteAssetPreviewItem[];
65
70
  defaultAsset?: object;
@@ -74,6 +79,12 @@ interface TabOptions<Snapshot = unknown> {
74
79
  resize(): void;
75
80
  reopenLabel: string;
76
81
  multiView?: boolean;
82
+ layoutStorage?: {
83
+ storage: Storage;
84
+ key: string;
85
+ isProjectLoading?(): boolean;
86
+ subscribeProject?(listener: () => void): (() => void) | undefined;
87
+ };
77
88
  stagePane?: DockviewStagePane;
78
89
  variables?: boolean;
79
90
  locale?: string;
@@ -2,9 +2,10 @@ import React from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { createDockview, DefaultTab, themeLight } from 'dockview';
4
4
  import { createSpriteLayoutMenu } from "./sprite-layout-menu.js";
5
- import { SpriteAssetPane } from "./sprite-asset-pane.js";
5
+ import { NativeAssetPanel, 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');
@@ -13,41 +14,52 @@ function createAssetSelections(target, source) {
13
14
  const layouts = new Map(source?.layouts);
14
15
  const scroll = new Map(source?.scroll);
15
16
  const selectors = new Map();
16
- const forKind = (kind) => ({
17
- restore(current, assets) {
18
- if (current !== target)
19
- return;
20
- const index = assets.indexOf(selected.get(kind));
21
- if (index >= 0)
22
- return index;
23
- },
24
- remember(current, asset, select) {
25
- if (current !== target)
26
- return;
27
- if (asset)
28
- selected.set(kind, asset);
29
- else
30
- selected.delete(kind);
31
- if (select)
32
- selectors.set(kind, select);
33
- },
34
- rememberLayout(render) {
35
- layouts.set(kind, render);
36
- },
37
- getScrollTop: () => scroll.get(kind) ?? 0,
38
- rememberScrollTop(position) {
39
- scroll.set(kind, position);
40
- }
41
- });
17
+ const forKind = (kind) => {
18
+ const state = {
19
+ rememberLayout(render) {
20
+ layouts.set(kind, render);
21
+ },
22
+ getScrollTop: () => scroll.get(kind) ?? 0,
23
+ rememberScrollTop(position) {
24
+ scroll.set(kind, position);
25
+ }
26
+ };
27
+ return {
28
+ ...state,
29
+ renderPanel: (render, previewSelector, panel) => React.createElement(NativeAssetPanel, { render, previewSelector, panel, state }),
30
+ restore(current, assets) {
31
+ if (current !== target)
32
+ return;
33
+ const index = assets.indexOf(selected.get(kind));
34
+ if (index >= 0)
35
+ return index;
36
+ },
37
+ remember(current, asset, select) {
38
+ if (current !== target)
39
+ return;
40
+ if (asset)
41
+ selected.set(kind, asset);
42
+ else
43
+ selected.delete(kind);
44
+ if (select)
45
+ selectors.set(kind, select);
46
+ },
47
+ };
48
+ };
42
49
  const ports = { costumes: forKind('costumes'), sounds: forKind('sounds') };
43
50
  return { selected, ports, layouts, selectors, scroll };
44
51
  }
45
52
  const stagePanelId = 'tw-plus-stage-display';
46
53
  const documentComponent = 'tw-plus-document';
54
+ const documentTitle = ({ filename, reference: { providerId, documentId } }) => `${filename} (${providerId} / ${documentId})`;
47
55
  const renameLabels = {
48
56
  en: { title: 'Rename sprite', name: 'Sprite name', cancel: 'Cancel', save: 'Rename' },
49
57
  ja: { title: 'スプライト名の変更', name: 'スプライト名', cancel: 'キャンセル', save: '変更' }
50
58
  };
59
+ const targetName = (target) => target.isStage ? 'Stage' : target.getName();
60
+ const projectSignature = (targets) => JSON.stringify(targets
61
+ .map(target => JSON.stringify([target.isStage, targetName(target), target.getCostumes?.()[0]?.assetId ?? '']))
62
+ .sort());
51
63
  class SpriteTab extends DefaultTab {
52
64
  icon;
53
65
  constructor(document) {
@@ -83,13 +95,21 @@ export function mountSpriteTabs(root, content, options) {
83
95
  const costumeIcons = new WeakMap();
84
96
  const remembered = new WeakMap();
85
97
  const lastUsed = new WeakMap();
86
- const closed = new WeakSet();
98
+ let closed = new WeakSet();
87
99
  let active;
88
100
  let observed = vm.editingTarget;
89
101
  let syncing = false;
90
102
  let nextView = 0;
91
103
  let renameDialog;
92
104
  const text = options.renameLabels ?? renameLabels.en;
105
+ const persistence = multiView && options.layoutStorage;
106
+ const window = document.defaultView;
107
+ const documentWorkspace = multiView && getDocumentWorkspace(vm);
108
+ let pendingRestore = Boolean(persistence);
109
+ let restoreFrame;
110
+ let restoreTimeout;
111
+ let saveFrame;
112
+ const originalTargets = () => vm.runtime.targets.filter(target => target.isOriginal);
93
113
  const dockview = createDockview(root, {
94
114
  theme: themeLight,
95
115
  disableFloatingGroups: true,
@@ -384,7 +404,7 @@ export function mountSpriteTabs(root, content, options) {
384
404
  if (splitPosition || views.has(id) || id === stagePanelId || documents.has(id)) {
385
405
  do {
386
406
  id = 'sprite-view-' + ++nextView;
387
- } while (views.has(id) || vm.runtime.targets.some(item => item.id === id));
407
+ } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(item => item.id === id));
388
408
  }
389
409
  const source = splitPosition && views.get(splitPosition.referencePanel);
390
410
  views.set(id, { id, target, section: section ?? remembered.get(target) ?? options.getSection(),
@@ -421,6 +441,8 @@ export function mountSpriteTabs(root, content, options) {
421
441
  activateView(view?.id ?? add(target).id);
422
442
  }
423
443
  function openDocument(request) {
444
+ if (pendingRestore)
445
+ finishRestore();
424
446
  const { providerId, documentId } = request.reference;
425
447
  for (const [id, { request: opened }] of documents) {
426
448
  if (opened.reference.providerId === providerId && opened.reference.documentId === documentId) {
@@ -432,7 +454,7 @@ export function mountSpriteTabs(root, content, options) {
432
454
  do {
433
455
  id = 'document-view-' + ++nextView;
434
456
  } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(target => target.id === id));
435
- const title = request.filename + ' (' + providerId + ' / ' + documentId + ')';
457
+ const title = documentTitle(request);
436
458
  documents.set(id, { request });
437
459
  try {
438
460
  dockview.addPanel({ id, title, component: documentComponent, renderer: 'always' });
@@ -463,6 +485,8 @@ export function mountSpriteTabs(root, content, options) {
463
485
  function openStage() {
464
486
  if (!stagePane)
465
487
  return;
488
+ if (pendingRestore)
489
+ finishRestore();
466
490
  const existing = dockview.getPanel(stagePanelId);
467
491
  if (existing) {
468
492
  existing.api.setActive();
@@ -514,6 +538,7 @@ export function mountSpriteTabs(root, content, options) {
514
538
  viewAdapter?.restore(describe(view), view.snapshots?.get(section)?.snapshot);
515
539
  }
516
540
  refreshSurfaces();
541
+ scheduleSave();
517
542
  return true;
518
543
  }
519
544
  function syncActiveSection(section) {
@@ -521,6 +546,124 @@ export function mountSpriteTabs(root, content, options) {
521
546
  return;
522
547
  active.section = section;
523
548
  refreshSurfaces();
549
+ scheduleSave();
550
+ }
551
+ function writeLayout() {
552
+ saveFrame = undefined;
553
+ const targets = originalTargets();
554
+ if (!persistence || pendingRestore || syncing || !targets.length || persistence.isProjectLoading?.())
555
+ return;
556
+ const { grid, activeGroup } = dockview.toJSON();
557
+ if (!activeGroup || grid.width <= 0 || grid.height <= 0)
558
+ return;
559
+ const panels = Object.create(null);
560
+ for (const [id, view] of views) {
561
+ panels[id] = { kind: 'sprite', name: targetName(view.target), stage: view.target.isStage, section: view.section };
562
+ }
563
+ if (dockview.getPanel(stagePanelId))
564
+ panels[stagePanelId] = { kind: 'stage' };
565
+ for (const [id, { request }] of documents)
566
+ panels[id] = { kind: 'document', ...request.reference };
567
+ const snapshot = { version: 1, project: projectSignature(targets), grid, activeGroup,
568
+ editingView: active?.id, panels };
569
+ try {
570
+ persistence.storage.setItem(persistence.key, JSON.stringify(snapshot));
571
+ }
572
+ catch { /* Storage can be unavailable. */ }
573
+ }
574
+ function scheduleSave() {
575
+ if (persistence && !pendingRestore && saveFrame === undefined) {
576
+ saveFrame = window.requestAnimationFrame(writeLayout);
577
+ }
578
+ }
579
+ function flushLayout() {
580
+ if (saveFrame !== undefined) {
581
+ window.cancelAnimationFrame(saveFrame);
582
+ writeLayout();
583
+ }
584
+ }
585
+ function finishRestore() {
586
+ pendingRestore = false;
587
+ window.clearTimeout(restoreTimeout);
588
+ }
589
+ function restoreLayout() {
590
+ restoreFrame = undefined;
591
+ if (!pendingRestore || !persistence || persistence.isProjectLoading?.())
592
+ return;
593
+ const targets = originalTargets();
594
+ if (!targets.length || !vm.editingTarget)
595
+ return;
596
+ let stored;
597
+ try {
598
+ const raw = persistence.storage.getItem(persistence.key);
599
+ if (raw)
600
+ stored = readWorkspaceLayout(raw, projectSignature(targets));
601
+ }
602
+ catch { /* Storage can be unavailable. */ }
603
+ if (!stored)
604
+ return finishRestore();
605
+ const resolvedSprites = new Map();
606
+ const resolvedDocuments = new Map();
607
+ const panels = Object.create(null);
608
+ for (const [id, reference] of Object.entries(stored.panels)) {
609
+ if (reference.kind === 'sprite') {
610
+ const target = targets.find(item => item.isStage === reference.stage && targetName(item) === reference.name);
611
+ if (!target || id === stagePanelId || (reference.section === variableSection && !options.variables))
612
+ return finishRestore();
613
+ resolvedSprites.set(id, { id, target, section: reference.section, assets: createAssetSelections(target) });
614
+ panels[id] = { id, title: target.getName(), contentComponent: 'sprite', params: { icon: getIcon(target) } };
615
+ }
616
+ else if (reference.kind === 'stage') {
617
+ if (!stagePane || id !== stagePanelId)
618
+ return finishRestore();
619
+ panels[id] = { id, title: stagePane.title, contentComponent: stagePanelId, renderer: 'always' };
620
+ }
621
+ else {
622
+ if (id === stagePanelId)
623
+ return finishRestore();
624
+ const request = documentWorkspace && documentWorkspace.resolve(reference);
625
+ if (!request) {
626
+ // The project can finish loading before the Blockly host publishes its document catalog.
627
+ restoreTimeout ??= window.setTimeout(finishRestore, 2000);
628
+ return;
629
+ }
630
+ resolvedDocuments.set(id, request);
631
+ panels[id] = { id, title: documentTitle(request), contentComponent: documentComponent, renderer: 'always' };
632
+ }
633
+ }
634
+ finishRestore();
635
+ syncing = true;
636
+ try {
637
+ dockview.clear();
638
+ active = undefined;
639
+ const represented = new Set([...resolvedSprites.values()].map(view => view.target));
640
+ closed = new WeakSet(targets.filter(target => !represented.has(target)));
641
+ for (const [id, view] of resolvedSprites)
642
+ views.set(id, view);
643
+ for (const [id, request] of resolvedDocuments)
644
+ documents.set(id, { request });
645
+ dockview.fromJSON({ grid: stored.grid, activeGroup: stored.activeGroup, panels });
646
+ const selected = dockview.activePanel;
647
+ show(views.has(selected?.id) ? selected : dockview.getPanel(stored.editingView) ?? selected);
648
+ }
649
+ catch {
650
+ dockview.clear();
651
+ for (const view of views.values())
652
+ disposeView(view);
653
+ for (const item of documents.values())
654
+ item.session?.dispose();
655
+ documents.clear();
656
+ closed = new WeakSet();
657
+ active = undefined;
658
+ }
659
+ finally {
660
+ syncing = false;
661
+ }
662
+ refresh();
663
+ }
664
+ function scheduleRestore() {
665
+ if (pendingRestore && restoreFrame === undefined)
666
+ restoreFrame = window.requestAnimationFrame(restoreLayout);
524
667
  }
525
668
  function refresh() {
526
669
  if (syncing)
@@ -557,6 +700,7 @@ export function mountSpriteTabs(root, content, options) {
557
700
  finally {
558
701
  syncing = false;
559
702
  }
703
+ scheduleRestore();
560
704
  }
561
705
  function disposeView(view) {
562
706
  for (const stored of view.snapshots?.values() ?? [])
@@ -577,6 +721,7 @@ export function mountSpriteTabs(root, content, options) {
577
721
  finally {
578
722
  syncing = false;
579
723
  }
724
+ scheduleSave();
580
725
  });
581
726
  const removal = dockview.onDidRemovePanel(panel => {
582
727
  const item = documents.get(panel.id);
@@ -598,8 +743,15 @@ export function mountSpriteTabs(root, content, options) {
598
743
  if (![...views.values()].some(item => item.target === view.target))
599
744
  closed.add(view.target);
600
745
  });
601
- const layout = multiView && dockview.onDidLayoutChange(refreshSurfaces);
602
- const disconnectDocuments = multiView && getDocumentWorkspace(vm).connect(openDocument);
746
+ const layout = multiView && dockview.onDidLayoutChange(() => {
747
+ refreshSurfaces();
748
+ scheduleSave();
749
+ });
750
+ const disconnectDocuments = documentWorkspace && documentWorkspace.connect(openDocument);
751
+ const unsubscribeDocuments = persistence && documentWorkspace && documentWorkspace.subscribe(scheduleRestore);
752
+ const unsubscribeProject = persistence && persistence.subscribeProject?.(scheduleRestore);
753
+ if (persistence)
754
+ window.addEventListener('pagehide', flushLayout);
603
755
  root.addEventListener('click', guardDocumentClose, true);
604
756
  root.addEventListener('keydown', guardDocumentClose, true);
605
757
  vm.on('targetsUpdate', refresh);
@@ -614,6 +766,15 @@ export function mountSpriteTabs(root, content, options) {
614
766
  currentView: () => active && describe(active),
615
767
  views: () => [...views.values()].map(view => describe(view)),
616
768
  dispose() {
769
+ flushLayout();
770
+ window.cancelAnimationFrame(restoreFrame);
771
+ window.clearTimeout(restoreTimeout);
772
+ if (persistence)
773
+ window.removeEventListener('pagehide', flushLayout);
774
+ if (unsubscribeDocuments)
775
+ unsubscribeDocuments();
776
+ if (unsubscribeProject)
777
+ unsubscribeProject();
617
778
  if (disconnectDocuments)
618
779
  disconnectDocuments();
619
780
  root.removeEventListener('click', guardDocumentClose, true);
@@ -730,6 +891,7 @@ function SpriteTabs(props) {
730
891
  resize: () => latest.current.resize(),
731
892
  reopenLabel: props.reopenLabel,
732
893
  multiView: props.multiView,
894
+ layoutStorage: props.layoutStorage,
733
895
  stagePane: props.stagePane,
734
896
  variables: hasVariables,
735
897
  locale: props.locale,
@@ -749,7 +911,7 @@ function SpriteTabs(props) {
749
911
  instance.dispose();
750
912
  };
751
913
  }, [content, props.enabled, props.vm, props.reopenLabel, props.multiView, props.locale,
752
- props.viewAdapter, props.stagePane, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
914
+ props.viewAdapter, props.stagePane, props.layoutStorage, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
753
915
  React.useLayoutEffect(() => {
754
916
  controller.current?.syncActiveSection(props.activeSection);
755
917
  }, [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.7",
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,4 +1,100 @@
1
1
  import React from 'react';
2
+ import type {SpriteAssetPreviewItem} from './sprite-tabs.ts';
3
+
4
+ interface NativeAssetPanelProps {
5
+ children?: React.ReactNode;
6
+ buttons: readonly {title: string; img: string; onClick(): void}[];
7
+ items: readonly {dragPayload?: object; name: string; url?: string; details?: React.ReactNode}[];
8
+ isRtl?: boolean;
9
+ listActions?: React.ElementType;
10
+ selectedItemIndex: number;
11
+ onItemClick(index: number): void;
12
+ }
13
+
14
+ interface NativeSelectorProps extends NativeAssetPanelProps {
15
+ wrapperAttributes?: Record<string, string>;
16
+ listRef?(node: HTMLElement | null): void;
17
+ onListScroll?(event: React.UIEvent<HTMLElement>): void;
18
+ draggingType?: null;
19
+ onAddSortable?(): void;
20
+ onRemoveSortable?(): void;
21
+ }
22
+
23
+ export type NativeAssetSelector = React.ComponentType<NativeSelectorProps>;
24
+ export type NativeAssetPanelRenderer = (props: NativeAssetPanelProps, options: {
25
+ panelAttributes: Record<string, string>;
26
+ detailAttributes: Record<string, string>;
27
+ renderSelector(Component: NativeAssetSelector, props: NativeSelectorProps): React.ReactNode;
28
+ }) => React.ReactNode;
29
+
30
+ export type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number,
31
+ detail: React.ReactNode, scrollTop: number) => React.ReactNode;
32
+
33
+ interface PanelState {
34
+ rememberLayout(render: SpriteAssetLayout): void;
35
+ getScrollTop(): number;
36
+ rememberScrollTop(position: number): void;
37
+ }
38
+
39
+ const noop = () => {};
40
+
41
+ function NativeAssetPanelSurface({render, panel, state, previewSelector, scrollTop}: {
42
+ render: NativeAssetPanelRenderer;
43
+ panel: NativeAssetPanelProps;
44
+ state?: PanelState;
45
+ previewSelector?: NativeAssetSelector;
46
+ scrollTop?: number;
47
+ }) {
48
+ const list = React.useRef<HTMLElement | null>(null);
49
+ const setList = React.useCallback((node: HTMLElement | null) => {
50
+ list.current = node;
51
+ }, []);
52
+ React.useLayoutEffect(() => {
53
+ const position = state?.getScrollTop() ?? scrollTop;
54
+ if (list.current && position !== undefined) list.current.scrollTop = position;
55
+ }, [state, scrollTop]);
56
+
57
+ return render(panel, {
58
+ panelAttributes: {'data-layout-asset-panel': ''},
59
+ detailAttributes: {'data-layout-asset-detail': ''},
60
+ renderSelector(Component, props) {
61
+ const selectorProps = {...props, listRef: setList, wrapperAttributes: {'data-layout-asset-list': ''}};
62
+ if (previewSelector) {
63
+ return <div {...{inert: ''}} style={{display: 'contents'}}>
64
+ {React.createElement(previewSelector, {
65
+ ...selectorProps, draggingType: null, onAddSortable: noop, onRemoveSortable: noop
66
+ })}
67
+ </div>;
68
+ }
69
+ return <Component {...selectorProps} onListScroll={event => {
70
+ state?.rememberScrollTop(event.currentTarget.scrollTop);
71
+ }} />;
72
+ }
73
+ });
74
+ }
75
+
76
+ export function NativeAssetPanel({render, previewSelector, panel, state}: {
77
+ render: NativeAssetPanelRenderer;
78
+ previewSelector: NativeAssetSelector;
79
+ panel: NativeAssetPanelProps;
80
+ state: PanelState;
81
+ }) {
82
+ const {items, buttons, isRtl, listActions} = panel;
83
+ React.useLayoutEffect(() => {
84
+ const previousItems = new Map(items.map(item => [item.dragPayload, item]));
85
+ const readOnlyButtons = buttons.map(({title, img}) => ({title, img, onClick: noop}));
86
+ state.rememberLayout((previewItems, selectedItemIndex, detail, scrollTop) => <NativeAssetPanelSurface
87
+ render={render} previewSelector={previewSelector} scrollTop={scrollTop} panel={{
88
+ isRtl, listActions, selectedItemIndex, buttons: readOnlyButtons,
89
+ onItemClick: noop, children: detail,
90
+ items: previewItems.map(item => {
91
+ const previous = previousItems.get(item.asset);
92
+ return {name: item.name, url: item.imageUrl || previous?.url, details: previous?.details};
93
+ })
94
+ }} />);
95
+ }, [state, render, previewSelector, items, buttons, isRtl, listActions]);
96
+ return <NativeAssetPanelSurface render={render} panel={panel} state={state} />;
97
+ }
2
98
 
3
99
  export interface SpriteMediaAsset {
4
100
  /** Host identity for this item within its target, independent of the list order. */
@@ -1,15 +1,18 @@
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
- import {SpriteAssetPane} from './sprite-asset-pane.tsx';
6
+ import {NativeAssetPanel, SpriteAssetPane} from './sprite-asset-pane.tsx';
7
+ import type {NativeAssetPanelRenderer, NativeAssetSelector, SpriteAssetLayout} from './sprite-asset-pane.tsx';
7
8
  import type {SpriteVariable} from './sprite-asset-pane.tsx';
8
9
  import {SpriteEditorToolbar, spriteEditorSections} from './sprite-editor-toolbar.tsx';
9
10
  import type {SpriteSearchResult} from './sprite-editor-toolbar.tsx';
10
11
  import type {DockviewStagePane} from './dockview-stage.ts';
11
12
  import {getDocumentWorkspace} from './document-workspace.ts';
12
13
  import type {DocumentPanelRequest} from './document-workspace.ts';
14
+ import {readWorkspaceLayout} from './workspace-layout.ts';
15
+ import type {PanelReference, WorkspaceLayout} from './workspace-layout.ts';
13
16
 
14
17
  export {getDocumentWorkspace} from './document-workspace.ts';
15
18
 
@@ -24,6 +27,7 @@ interface Target {
24
27
  isOriginal: boolean;
25
28
  isStage: boolean;
26
29
  getName(): string;
30
+ getCostumes?(): {assetId?: string}[];
27
31
  getCurrentCostume?(): {asset?: CostumeAsset} | undefined;
28
32
  }
29
33
 
@@ -58,6 +62,8 @@ export interface SpriteTabAdapter<Snapshot = unknown> {
58
62
  export interface SpriteAssetSelection {
59
63
  restore(target: object, assets: readonly object[]): number | undefined;
60
64
  remember(target: object, asset: object | undefined, select?: (index: number) => void): void;
65
+ renderPanel?(render: NativeAssetPanelRenderer, previewSelector: NativeAssetSelector,
66
+ panel: React.ComponentProps<typeof NativeAssetPanel>['panel']): React.ReactNode;
61
67
  rememberLayout?(render: SpriteAssetLayout): void;
62
68
  getScrollTop?(): number;
63
69
  rememberScrollTop?(position: number): void;
@@ -74,7 +80,6 @@ export interface SpriteAssetPreviewItem {
74
80
  duration?: number;
75
81
  }
76
82
 
77
- type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number, detail: React.ReactNode, scrollTop: number) => React.ReactNode;
78
83
  const variableSection = spriteEditorSections.indexOf('variables');
79
84
 
80
85
  export interface SpriteAssetPreviewData {
@@ -92,26 +97,33 @@ function createAssetSelections(target: Target, source?: {
92
97
  const layouts = new Map(source?.layouts);
93
98
  const scroll = new Map(source?.scroll);
94
99
  const selectors = new Map<AssetKind, (index: number) => void>();
95
- const forKind = (kind: AssetKind): SpriteAssetSelection => ({
96
- restore(current, assets) {
97
- if (current !== target) return;
98
- const index = assets.indexOf(selected.get(kind));
99
- if (index >= 0) return index;
100
- },
101
- remember(current, asset, select) {
102
- if (current !== target) return;
103
- if (asset) selected.set(kind, asset);
104
- else selected.delete(kind);
105
- if (select) selectors.set(kind, select);
106
- },
107
- rememberLayout(render) {
108
- layouts.set(kind, render);
109
- },
110
- getScrollTop: () => scroll.get(kind) ?? 0,
111
- rememberScrollTop(position) {
112
- scroll.set(kind, position);
113
- }
114
- });
100
+ const forKind = (kind: AssetKind): SpriteAssetSelection => {
101
+ const state = {
102
+ rememberLayout(render: SpriteAssetLayout) {
103
+ layouts.set(kind, render);
104
+ },
105
+ getScrollTop: () => scroll.get(kind) ?? 0,
106
+ rememberScrollTop(position: number) {
107
+ scroll.set(kind, position);
108
+ }
109
+ };
110
+ return {
111
+ ...state,
112
+ renderPanel: (render, previewSelector, panel) =>
113
+ React.createElement(NativeAssetPanel, {render, previewSelector, panel, state}),
114
+ restore(current, assets) {
115
+ if (current !== target) return;
116
+ const index = assets.indexOf(selected.get(kind));
117
+ if (index >= 0) return index;
118
+ },
119
+ remember(current, asset, select) {
120
+ if (current !== target) return;
121
+ if (asset) selected.set(kind, asset);
122
+ else selected.delete(kind);
123
+ if (select) selectors.set(kind, select);
124
+ },
125
+ };
126
+ };
115
127
  const ports: AssetSelections = {costumes: forKind('costumes'), sounds: forKind('sounds')};
116
128
  return {selected, ports, layouts, selectors, scroll};
117
129
  }
@@ -120,6 +132,8 @@ export type SpriteSplitDirection = 'right' | 'below';
120
132
 
121
133
  const stagePanelId = 'tw-plus-stage-display';
122
134
  const documentComponent = 'tw-plus-document';
135
+ const documentTitle = ({filename, reference: {providerId, documentId}}: DocumentPanelRequest) =>
136
+ `${filename} (${providerId} / ${documentId})`;
123
137
 
124
138
  interface TabOptions<Snapshot = unknown> {
125
139
  vm: Project;
@@ -129,6 +143,12 @@ interface TabOptions<Snapshot = unknown> {
129
143
  resize(): void;
130
144
  reopenLabel: string;
131
145
  multiView?: boolean;
146
+ layoutStorage?: {
147
+ storage: Storage;
148
+ key: string;
149
+ isProjectLoading?(): boolean;
150
+ subscribeProject?(listener: () => void): (() => void) | undefined;
151
+ };
132
152
  stagePane?: DockviewStagePane;
133
153
  variables?: boolean;
134
154
  locale?: string;
@@ -181,6 +201,10 @@ const renameLabels = {
181
201
  ja: {title: 'スプライト名の変更', name: 'スプライト名', cancel: 'キャンセル', save: '変更'}
182
202
  };
183
203
  type RenameLabels = typeof renameLabels.en;
204
+ const targetName = (target: Target) => target.isStage ? 'Stage' : target.getName();
205
+ const projectSignature = (targets: readonly Target[]) => JSON.stringify(targets
206
+ .map(target => JSON.stringify([target.isStage, targetName(target), target.getCostumes?.()[0]?.assetId ?? '']))
207
+ .sort());
184
208
 
185
209
  class SpriteTab extends DefaultTab {
186
210
  private readonly icon: HTMLImageElement;
@@ -219,13 +243,21 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
219
243
  const costumeIcons = new WeakMap<CostumeAsset, string>();
220
244
  const remembered = new WeakMap<Target, number>();
221
245
  const lastUsed = new WeakMap<Target, View<Snapshot>>();
222
- const closed = new WeakSet<Target>();
246
+ let closed = new WeakSet<Target>();
223
247
  let active: View<Snapshot> | undefined;
224
248
  let observed = vm.editingTarget;
225
249
  let syncing = false;
226
250
  let nextView = 0;
227
251
  let renameDialog: HTMLDialogElement | undefined;
228
252
  const text = options.renameLabels ?? renameLabels.en;
253
+ const persistence = multiView && options.layoutStorage;
254
+ const window = document.defaultView;
255
+ const documentWorkspace = multiView && getDocumentWorkspace(vm);
256
+ let pendingRestore = Boolean(persistence);
257
+ let restoreFrame: number | undefined;
258
+ let restoreTimeout: number | undefined;
259
+ let saveFrame: number | undefined;
260
+ const originalTargets = () => vm.runtime.targets.filter(target => target.isOriginal);
229
261
 
230
262
  const dockview = createDockview(root, {
231
263
  theme: themeLight,
@@ -496,7 +528,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
496
528
  if (splitPosition || views.has(id) || id === stagePanelId || documents.has(id)) {
497
529
  do {
498
530
  id = 'sprite-view-' + ++nextView;
499
- } while (views.has(id) || vm.runtime.targets.some(item => item.id === id));
531
+ } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(item => item.id === id));
500
532
  }
501
533
  const source = splitPosition && views.get(splitPosition.referencePanel);
502
534
  views.set(id, {id, target, section: section ?? remembered.get(target) ?? options.getSection(),
@@ -533,6 +565,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
533
565
  }
534
566
 
535
567
  function openDocument(request: DocumentPanelRequest) {
568
+ if (pendingRestore) finishRestore();
536
569
  const {providerId, documentId} = request.reference;
537
570
  for (const [id, {request: opened}] of documents) {
538
571
  if (opened.reference.providerId === providerId && opened.reference.documentId === documentId) {
@@ -544,7 +577,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
544
577
  do {
545
578
  id = 'document-view-' + ++nextView;
546
579
  } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(target => target.id === id));
547
- const title = request.filename + ' (' + providerId + ' / ' + documentId + ')';
580
+ const title = documentTitle(request);
548
581
  documents.set(id, {request});
549
582
  try {
550
583
  dockview.addPanel({id, title, component: documentComponent, renderer: 'always'});
@@ -574,6 +607,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
574
607
 
575
608
  function openStage() {
576
609
  if (!stagePane) return;
610
+ if (pendingRestore) finishRestore();
577
611
  const existing = dockview.getPanel(stagePanelId);
578
612
  if (existing) {
579
613
  existing.api.setActive();
@@ -624,6 +658,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
624
658
  viewAdapter?.restore(describe(view), view.snapshots?.get(section)?.snapshot);
625
659
  }
626
660
  refreshSurfaces();
661
+ scheduleSave();
627
662
  return true;
628
663
  }
629
664
 
@@ -631,6 +666,106 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
631
666
  if (!active || !usesNativeSection(active.section) || active.section === section) return;
632
667
  active.section = section;
633
668
  refreshSurfaces();
669
+ scheduleSave();
670
+ }
671
+
672
+ function writeLayout() {
673
+ saveFrame = undefined;
674
+ const targets = originalTargets();
675
+ if (!persistence || pendingRestore || syncing || !targets.length || persistence.isProjectLoading?.()) return;
676
+ const {grid, activeGroup} = dockview.toJSON();
677
+ if (!activeGroup || grid.width <= 0 || grid.height <= 0) return;
678
+ const panels: Record<string, PanelReference> = Object.create(null);
679
+ for (const [id, view] of views) {
680
+ panels[id] = {kind: 'sprite', name: targetName(view.target), stage: view.target.isStage, section: view.section};
681
+ }
682
+ if (dockview.getPanel(stagePanelId)) panels[stagePanelId] = {kind: 'stage'};
683
+ for (const [id, {request}] of documents) panels[id] = {kind: 'document', ...request.reference};
684
+ const snapshot: WorkspaceLayout = {version: 1, project: projectSignature(targets), grid, activeGroup,
685
+ editingView: active?.id, panels};
686
+ try { persistence.storage.setItem(persistence.key, JSON.stringify(snapshot)); } catch { /* Storage can be unavailable. */ }
687
+ }
688
+
689
+ function scheduleSave() {
690
+ if (persistence && !pendingRestore && saveFrame === undefined) {
691
+ saveFrame = window.requestAnimationFrame(writeLayout);
692
+ }
693
+ }
694
+
695
+ function flushLayout() {
696
+ if (saveFrame !== undefined) {
697
+ window.cancelAnimationFrame(saveFrame);
698
+ writeLayout();
699
+ }
700
+ }
701
+
702
+ function finishRestore() {
703
+ pendingRestore = false;
704
+ window.clearTimeout(restoreTimeout);
705
+ }
706
+
707
+ function restoreLayout() {
708
+ restoreFrame = undefined;
709
+ if (!pendingRestore || !persistence || persistence.isProjectLoading?.()) return;
710
+ const targets = originalTargets();
711
+ if (!targets.length || !vm.editingTarget) return;
712
+ let stored: WorkspaceLayout | undefined;
713
+ try {
714
+ const raw = persistence.storage.getItem(persistence.key);
715
+ if (raw) stored = readWorkspaceLayout(raw, projectSignature(targets));
716
+ } catch { /* Storage can be unavailable. */ }
717
+ if (!stored) return finishRestore();
718
+ const resolvedSprites = new Map<string, View<Snapshot>>();
719
+ const resolvedDocuments = new Map<string, DocumentPanelRequest>();
720
+ const panels: SerializedDockview['panels'] = Object.create(null);
721
+ for (const [id, reference] of Object.entries(stored.panels)) {
722
+ if (reference.kind === 'sprite') {
723
+ const target = targets.find(item => item.isStage === reference.stage && targetName(item) === reference.name);
724
+ if (!target || id === stagePanelId || (reference.section === variableSection && !options.variables)) return finishRestore();
725
+ resolvedSprites.set(id, {id, target, section: reference.section, assets: createAssetSelections(target)});
726
+ panels[id] = {id, title: target.getName(), contentComponent: 'sprite', params: {icon: getIcon(target)}};
727
+ } else if (reference.kind === 'stage') {
728
+ if (!stagePane || id !== stagePanelId) return finishRestore();
729
+ panels[id] = {id, title: stagePane.title, contentComponent: stagePanelId, renderer: 'always'};
730
+ } else {
731
+ if (id === stagePanelId) return finishRestore();
732
+ const request = documentWorkspace && documentWorkspace.resolve(reference);
733
+ if (!request) {
734
+ // The project can finish loading before the Blockly host publishes its document catalog.
735
+ restoreTimeout ??= window.setTimeout(finishRestore, 2000);
736
+ return;
737
+ }
738
+ resolvedDocuments.set(id, request);
739
+ panels[id] = {id, title: documentTitle(request), contentComponent: documentComponent, renderer: 'always'};
740
+ }
741
+ }
742
+ finishRestore();
743
+ syncing = true;
744
+ try {
745
+ dockview.clear();
746
+ active = undefined;
747
+ const represented = new Set([...resolvedSprites.values()].map(view => view.target));
748
+ closed = new WeakSet(targets.filter(target => !represented.has(target)));
749
+ for (const [id, view] of resolvedSprites) views.set(id, view);
750
+ for (const [id, request] of resolvedDocuments) documents.set(id, {request});
751
+ dockview.fromJSON({grid: stored.grid, activeGroup: stored.activeGroup, panels});
752
+ const selected = dockview.activePanel;
753
+ show(views.has(selected?.id) ? selected : dockview.getPanel(stored.editingView) ?? selected);
754
+ } catch {
755
+ dockview.clear();
756
+ for (const view of views.values()) disposeView(view);
757
+ for (const item of documents.values()) item.session?.dispose();
758
+ documents.clear();
759
+ closed = new WeakSet();
760
+ active = undefined;
761
+ } finally {
762
+ syncing = false;
763
+ }
764
+ refresh();
765
+ }
766
+
767
+ function scheduleRestore() {
768
+ if (pendingRestore && restoreFrame === undefined) restoreFrame = window.requestAnimationFrame(restoreLayout);
634
769
  }
635
770
 
636
771
  function refresh() {
@@ -663,6 +798,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
663
798
  } finally {
664
799
  syncing = false;
665
800
  }
801
+ scheduleRestore();
666
802
  }
667
803
 
668
804
  function disposeView(view: View<Snapshot>) {
@@ -681,6 +817,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
681
817
  } finally {
682
818
  syncing = false;
683
819
  }
820
+ scheduleSave();
684
821
  });
685
822
  const removal = dockview.onDidRemovePanel(panel => {
686
823
  const item = documents.get(panel.id);
@@ -699,8 +836,14 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
699
836
  disposeView(view);
700
837
  if (![...views.values()].some(item => item.target === view.target)) closed.add(view.target);
701
838
  });
702
- const layout = multiView && dockview.onDidLayoutChange(refreshSurfaces);
703
- const disconnectDocuments = multiView && getDocumentWorkspace(vm).connect(openDocument);
839
+ const layout = multiView && dockview.onDidLayoutChange(() => {
840
+ refreshSurfaces();
841
+ scheduleSave();
842
+ });
843
+ const disconnectDocuments = documentWorkspace && documentWorkspace.connect(openDocument);
844
+ const unsubscribeDocuments = persistence && documentWorkspace && documentWorkspace.subscribe(scheduleRestore);
845
+ const unsubscribeProject = persistence && persistence.subscribeProject?.(scheduleRestore);
846
+ if (persistence) window.addEventListener('pagehide', flushLayout);
704
847
  root.addEventListener('click', guardDocumentClose, true);
705
848
  root.addEventListener('keydown', guardDocumentClose, true);
706
849
  vm.on('targetsUpdate', refresh);
@@ -715,6 +858,12 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
715
858
  currentView: () => active && describe(active),
716
859
  views: () => [...views.values()].map(view => describe(view)),
717
860
  dispose() {
861
+ flushLayout();
862
+ window.cancelAnimationFrame(restoreFrame);
863
+ window.clearTimeout(restoreTimeout);
864
+ if (persistence) window.removeEventListener('pagehide', flushLayout);
865
+ if (unsubscribeDocuments) unsubscribeDocuments();
866
+ if (unsubscribeProject) unsubscribeProject();
718
867
  if (disconnectDocuments) disconnectDocuments();
719
868
  root.removeEventListener('click', guardDocumentClose, true);
720
869
  root.removeEventListener('keydown', guardDocumentClose, true);
@@ -853,6 +1002,7 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
853
1002
  resize: () => latest.current.resize(),
854
1003
  reopenLabel: props.reopenLabel,
855
1004
  multiView: props.multiView,
1005
+ layoutStorage: props.layoutStorage,
856
1006
  stagePane: props.stagePane,
857
1007
  variables: hasVariables,
858
1008
  locale: props.locale,
@@ -871,7 +1021,7 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
871
1021
  instance.dispose();
872
1022
  };
873
1023
  }, [content, props.enabled, props.vm, props.reopenLabel, props.multiView, props.locale,
874
- props.viewAdapter, props.stagePane, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
1024
+ props.viewAdapter, props.stagePane, props.layoutStorage, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
875
1025
  React.useLayoutEffect(() => {
876
1026
  controller.current?.syncActiveSection(props.activeSection);
877
1027
  }, [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
+ }