@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.
- package/dist/document-workspace.d.ts +8 -0
- package/dist/document-workspace.js +20 -0
- package/dist/sprite-asset-pane.d.ts +45 -0
- package/dist/sprite-asset-pane.js +43 -0
- package/dist/sprite-tabs.d.ts +12 -1
- package/dist/sprite-tabs.js +195 -33
- package/dist/workspace-layout.d.ts +24 -0
- package/dist/workspace-layout.js +88 -0
- package/package.json +1 -1
- package/src/document-workspace.ts +24 -0
- package/src/sprite-asset-pane.tsx +96 -0
- package/src/sprite-tabs.ts +179 -29
- package/src/workspace-layout.ts +90 -0
|
@@ -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',
|
package/dist/sprite-tabs.d.ts
CHANGED
|
@@ -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;
|
package/dist/sprite-tabs.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
602
|
-
|
|
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
|
@@ -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. */
|
package/src/sprite-tabs.ts
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
703
|
-
|
|
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
|
+
}
|