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