@kubohiroya/turbowarp-plus-editor-layout 0.1.1 → 0.1.2

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.
@@ -1,7 +1,12 @@
1
1
  import React from 'react';
2
2
  import {createPortal} from 'react-dom';
3
3
  import {createDockview, DefaultTab, themeLight} from 'dockview';
4
- import type {GroupPanelPartInitParameters, IDockviewPanel} from 'dockview';
4
+ import type {DockviewGroupPanel, GroupPanelPartInitParameters, IDockviewPanel} from 'dockview';
5
+ import {createSpriteLayoutMenu} from './sprite-layout-menu.ts';
6
+ import {SpriteAssetPane} from './sprite-asset-pane.tsx';
7
+ import type {SpriteVariable} from './sprite-asset-pane.tsx';
8
+ import {SpriteEditorToolbar, spriteEditorSections} from './sprite-editor-toolbar.tsx';
9
+ import type {SpriteSearchResult} from './sprite-editor-toolbar.tsx';
5
10
 
6
11
  interface CostumeAsset {
7
12
  encodeDataURI(): string;
@@ -19,20 +24,145 @@ interface Project {
19
24
  editingTarget?: Target;
20
25
  runtime: {targets: Target[]};
21
26
  setEditingTarget(id: string): void;
22
- renameSprite(id: string, name: string): void;
27
+ renameSprite?(id: string, name: string): void;
23
28
  on(event: 'targetsUpdate', listener: () => void): void;
24
29
  removeListener(event: 'targetsUpdate', listener: () => void): void;
25
30
  }
26
31
 
27
- interface TabOptions {
32
+ export interface SpriteTabView {
33
+ readonly id: string;
34
+ readonly targetId: string;
35
+ readonly isStage: boolean;
36
+ readonly section: number;
37
+ readonly groupId: string;
38
+ readonly active: boolean;
39
+ readonly groupActive: boolean;
40
+ }
41
+
42
+ export interface SpriteTabAdapter<Snapshot = unknown> {
43
+ /** Read the state of the mounted surface; the host's selected target may have changed already. */
44
+ capture(view: SpriteTabView): Snapshot;
45
+ renderInactive(container: HTMLElement, view: SpriteTabView, snapshot: Snapshot | undefined): void | (() => void);
46
+ restore(view: SpriteTabView, snapshot: Snapshot | undefined): void;
47
+ dispose?(view: SpriteTabView, snapshot: Snapshot): void;
48
+ }
49
+
50
+ /** Optional bridge used by a native asset editor to restore and report its selection. */
51
+ export interface SpriteAssetSelection {
52
+ restore(target: object, assets: readonly object[]): number | undefined;
53
+ remember(target: object, asset: object | undefined, select?: (index: number) => void): void;
54
+ rememberLayout?(render: SpriteAssetLayout): void;
55
+ getScrollTop?(): number;
56
+ rememberScrollTop?(position: number): void;
57
+ }
58
+
59
+ export type SpriteAssetKind = 'costumes' | 'sounds';
60
+ type AssetKind = SpriteAssetKind;
61
+
62
+ export interface SpriteAssetPreviewItem {
63
+ asset: object;
64
+ name: string;
65
+ imageUrl?: string;
66
+ buffer?: AudioBuffer;
67
+ duration?: number;
68
+ }
69
+
70
+ type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number, detail: React.ReactNode, scrollTop: number) => React.ReactNode;
71
+ const variableSection = spriteEditorSections.indexOf('variables');
72
+
73
+ export interface SpriteAssetPreviewData {
74
+ items: readonly SpriteAssetPreviewItem[];
75
+ defaultAsset?: object;
76
+ }
77
+ type AssetSelections = Readonly<Record<AssetKind, SpriteAssetSelection>>;
78
+
79
+ function createAssetSelections(target: Target, source?: {
80
+ selected: Map<AssetKind, object>;
81
+ layouts: Map<AssetKind, SpriteAssetLayout>;
82
+ scroll: Map<AssetKind, number>;
83
+ }) {
84
+ const selected = new Map(source?.selected);
85
+ const layouts = new Map(source?.layouts);
86
+ const scroll = new Map(source?.scroll);
87
+ const selectors = new Map<AssetKind, (index: number) => void>();
88
+ const forKind = (kind: AssetKind): SpriteAssetSelection => ({
89
+ restore(current, assets) {
90
+ if (current !== target) return;
91
+ const index = assets.indexOf(selected.get(kind));
92
+ if (index >= 0) return index;
93
+ },
94
+ remember(current, asset, select) {
95
+ if (current !== target) return;
96
+ if (asset) selected.set(kind, asset);
97
+ else selected.delete(kind);
98
+ if (select) selectors.set(kind, select);
99
+ },
100
+ rememberLayout(render) {
101
+ layouts.set(kind, render);
102
+ },
103
+ getScrollTop: () => scroll.get(kind) ?? 0,
104
+ rememberScrollTop(position) {
105
+ scroll.set(kind, position);
106
+ }
107
+ });
108
+ const ports: AssetSelections = {costumes: forKind('costumes'), sounds: forKind('sounds')};
109
+ return {selected, ports, layouts, selectors, scroll};
110
+ }
111
+
112
+ export type SpriteSplitDirection = 'right' | 'below';
113
+
114
+ interface TabOptions<Snapshot = unknown> {
28
115
  vm: Project;
29
116
  getSection(): number;
30
117
  activateSection(section: number): void;
31
- selectTarget(id: string): void;
118
+ selectTarget(id: string, assetSelections?: AssetSelections): void;
32
119
  resize(): void;
33
- park?(): void;
34
120
  reopenLabel: string;
121
+ multiView?: boolean;
122
+ variables?: boolean;
123
+ locale?: string;
124
+ viewAdapter?: SpriteTabAdapter<Snapshot>;
125
+ park?(): void;
35
126
  renameLabels?: RenameLabels;
127
+ onToolbarSlots?(slots: readonly ToolbarSlot[]): void;
128
+ onAssetPreviewSlots?(slots: readonly AssetPreviewSlot[]): void;
129
+ }
130
+
131
+ interface ToolbarActions {
132
+ activate(): void;
133
+ setSection(section: number): void;
134
+ }
135
+
136
+ interface ToolbarSlot {
137
+ view: SpriteTabView;
138
+ element: HTMLElement;
139
+ }
140
+
141
+ interface AssetPreviewSlot {
142
+ id: string;
143
+ targetId: string;
144
+ kind: AssetKind | 'variables';
145
+ editable: boolean;
146
+ selected?: object;
147
+ renderLayout?: SpriteAssetLayout;
148
+ scrollTop?: number;
149
+ element: HTMLElement;
150
+ }
151
+
152
+ interface TabSlot {
153
+ body: HTMLElement;
154
+ toolbar?: HTMLElement;
155
+ assetPreview?: HTMLElement;
156
+ clearInactive?: () => void;
157
+ inactiveSection?: number;
158
+ }
159
+
160
+ interface View<Snapshot> {
161
+ id: string;
162
+ target: Target;
163
+ section: number;
164
+ assets: ReturnType<typeof createAssetSelections>;
165
+ snapshots?: Map<number, {snapshot: Snapshot; view: SpriteTabView}>;
36
166
  }
37
167
 
38
168
  const renameLabels = {
@@ -65,34 +195,90 @@ class SpriteTab extends DefaultTab {
65
195
  }
66
196
  }
67
197
 
68
- export function mountSpriteTabs(root: HTMLElement, content: HTMLElement, options: TabOptions) {
69
- const {vm} = options;
198
+ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElement, options: TabOptions<Snapshot>) {
199
+ const {vm, multiView = false, locale = 'en'} = options;
200
+ const viewAdapter = multiView ? options.viewAdapter : undefined;
201
+ const usesNativeSection = (section: number) => section !== variableSection || !options.variables;
70
202
  const document = root.ownerDocument;
71
- const slots = new Map<string, HTMLElement>();
72
- const targets = new Map<string, Target>();
203
+ const editLabel = /^ja(?:-|$)/i.test(locale) ? 'クリックしてこのビューで編集' : 'Activate this view to edit';
204
+ const slots = new Map<string, TabSlot>();
205
+ const views = new Map<string, View<Snapshot>>();
73
206
  const costumeIcons = new WeakMap<CostumeAsset, string>();
74
- const sections = new WeakMap<Target, number>();
207
+ const remembered = new WeakMap<Target, number>();
208
+ const lastUsed = new WeakMap<Target, View<Snapshot>>();
75
209
  const closed = new WeakSet<Target>();
76
- let active: Target | undefined;
210
+ let active: View<Snapshot> | undefined;
77
211
  let observed = vm.editingTarget;
78
212
  let syncing = false;
213
+ let nextView = 0;
79
214
  let renameDialog: HTMLDialogElement | undefined;
80
215
  const text = options.renameLabels ?? renameLabels.en;
81
216
 
82
217
  const dockview = createDockview(root, {
83
218
  theme: themeLight,
84
219
  disableFloatingGroups: true,
220
+ ...(multiView && {createRightHeaderActionComponent: createLayoutMenu}),
85
221
  defaultTabComponent: 'sprite',
86
222
  createTabComponent: () => new SpriteTab(document),
87
223
  getTabContextMenuItems: ({panel}) => {
88
- const target = targets.get(panel.id);
89
- if (!target || target.isStage) return [];
224
+ const target = views.get(panel.id)?.target;
225
+ if (!target || target.isStage || !vm.renameSprite) return [];
90
226
  return [{label: text.title, action: () => openRenameDialog(target)}];
91
227
  },
92
228
  createComponent: () => {
93
229
  const element = document.createElement('div');
94
230
  element.className = 'tw-plus-sprite-tab-slot';
95
- return {element, init: ({api}) => { slots.set(api.id, element); }};
231
+ const body = multiView ? document.createElement('div') : element;
232
+ const toolbar = multiView && options.onToolbarSlots ? document.createElement('div') : undefined;
233
+ const assetPreview = multiView && options.onAssetPreviewSlots ? document.createElement('div') : undefined;
234
+ let id: string;
235
+ let dimensions: {dispose(): void} | undefined;
236
+ if (multiView) {
237
+ element.classList.add('tw-plus-sprite-tab-slot--split');
238
+ body.className = 'tw-plus-sprite-tab-body';
239
+ if (toolbar) body.setAttribute('role', 'tabpanel');
240
+ if (toolbar) element.append(toolbar);
241
+ if (assetPreview) {
242
+ assetPreview.className = 'tw-plus-sprite-tab-asset-preview';
243
+ assetPreview.hidden = true;
244
+ assetPreview.tabIndex = 0;
245
+ assetPreview.setAttribute('role', 'group');
246
+ assetPreview.setAttribute('aria-label', editLabel);
247
+ assetPreview.addEventListener('keydown', event => {
248
+ if (event.target !== assetPreview || (event.key !== 'Enter' && event.key !== ' ')) return;
249
+ event.preventDefault();
250
+ event.stopPropagation();
251
+ activateView(id);
252
+ });
253
+ body.append(assetPreview);
254
+ }
255
+ element.append(body);
256
+ body.addEventListener('click', event => {
257
+ if (active?.id === id) return;
258
+ event.preventDefault();
259
+ event.stopPropagation();
260
+ activateView(id);
261
+ }, {capture: true});
262
+ }
263
+ const slot: TabSlot = {body, toolbar, assetPreview};
264
+ return {
265
+ element,
266
+ init(params) {
267
+ id = params.api.id;
268
+ slots.set(id, slot);
269
+ if (toolbar) body.id = id + '-content';
270
+ if (multiView) {
271
+ dimensions = params.api.onDidDimensionsChange(() => {
272
+ if (active?.id === id && body.contains(content)) options.resize();
273
+ });
274
+ }
275
+ },
276
+ dispose() {
277
+ dimensions?.dispose();
278
+ clearInactive(slot);
279
+ if (slots.get(id) === slot) slots.delete(id);
280
+ }
281
+ };
96
282
  },
97
283
  createWatermarkComponent: () => {
98
284
  const button = document.createElement('button');
@@ -104,36 +290,24 @@ export function mountSpriteTabs(root: HTMLElement, content: HTMLElement, options
104
290
  return {element: button, init() {}};
105
291
  }
106
292
  });
107
- // The host has one editable surface. Keep reordering inside its tab group.
108
293
  const preventSplit = (event: {kind: string; position: string; preventDefault(): void}) => {
109
294
  if (event.kind === 'edge' || event.kind === 'content' && event.position !== 'center') event.preventDefault();
110
295
  };
111
- const overlay = dockview.onWillShowOverlay(preventSplit);
112
- const drop = dockview.onWillDrop(preventSplit);
113
-
114
- function show(panel: IDockviewPanel | undefined) {
115
- const next = targets.get(panel?.id);
116
- if (active === next) return;
117
- if (active) sections.set(active, options.getSection());
118
- const section = sections.get(next) ?? options.getSection();
119
- active = next;
120
- if (!next) {
121
- if (options.park) options.park();
122
- else content.remove();
123
- options.selectTarget('');
124
- return;
125
- }
296
+ const overlay = !multiView && dockview.onWillShowOverlay(preventSplit);
297
+ const drop = !multiView && dockview.onWillDrop(preventSplit);
126
298
 
127
- slots.get(next.id).appendChild(content);
128
- if (vm.editingTarget !== next) vm.setEditingTarget(next.id);
129
- observed = vm.editingTarget;
130
- options.activateSection(section);
131
- options.selectTarget(next.id);
132
- options.resize();
299
+ function createLayoutMenu(group: DockviewGroupPanel) {
300
+ const menu = createSpriteLayoutMenu(document, locale, {
301
+ splitRight: () => { if (group.activePanel) split('right', group.activePanel.id); },
302
+ splitBelow: () => { if (group.activePanel) split('below', group.activePanel.id); },
303
+ close: () => { if (group.activePanel) closeSplit(group.activePanel.id); },
304
+ canClose: () => dockview.groups.length > 1
305
+ });
306
+ return {...menu, init() {}};
133
307
  }
134
308
 
135
309
  function isOpen(target: Target) {
136
- return targets.get(target.id) === target && vm.runtime.targets.includes(target);
310
+ return [...views.values()].some(view => view.target === target) && vm.runtime.targets.includes(target);
137
311
  }
138
312
 
139
313
  function openRenameDialog(target: Target) {
@@ -164,7 +338,7 @@ export function mountSpriteTabs(root: HTMLElement, content: HTMLElement, options
164
338
  if (renameDialog !== dialog) return;
165
339
  renameDialog = undefined;
166
340
  if (dialog.returnValue === 'save' && input.value.trim() && isOpen(target) &&
167
- input.value !== target.getName()) vm.renameSprite(target.id, input.value);
341
+ input.value !== target.getName()) vm.renameSprite?.(target.id, input.value);
168
342
  }, {once: true});
169
343
  renameDialog = dialog;
170
344
  root.append(dialog);
@@ -179,19 +353,196 @@ export function mountSpriteTabs(root: HTMLElement, content: HTMLElement, options
179
353
  return costumeIcons.get(asset);
180
354
  }
181
355
 
182
- function add(target: Target) {
183
- targets.set(target.id, target);
184
- return dockview.addPanel({
185
- id: target.id, title: target.getName(), component: 'sprite', inactive: true,
186
- params: {icon: getIcon(target)}
187
- });
356
+ function describe(view: View<Snapshot>, panel = dockview.getPanel(view.id)): SpriteTabView {
357
+ return {
358
+ id: view.id, targetId: view.target.id, isStage: view.target.isStage,
359
+ section: view.section, groupId: panel?.api.group.id ?? '',
360
+ active: active === view, groupActive: panel?.api.isGroupActive ?? false
361
+ };
362
+ }
363
+
364
+ function clearInactive(slot: TabSlot) {
365
+ if (slot.assetPreview) slot.assetPreview.hidden = true;
366
+ if (slot.inactiveSection === undefined) return;
367
+ slot.clearInactive?.();
368
+ slot.clearInactive = undefined;
369
+ slot.inactiveSection = undefined;
370
+ // Keep the React portal host mounted while its children reconcile.
371
+ slot.body.replaceChildren(...(slot.assetPreview ? [slot.assetPreview] : []));
372
+ }
373
+
374
+ function refreshSurfaces() {
375
+ if (!multiView) return;
376
+ const toolbars: ToolbarSlot[] = [];
377
+ const assetPreviews: AssetPreviewSlot[] = [];
378
+ for (const [id, slot] of slots) {
379
+ const view = views.get(id);
380
+ const panel = dockview.getPanel(id);
381
+ if (!view || !panel || panel.api.group.activePanel !== panel) {
382
+ clearInactive(slot);
383
+ continue;
384
+ }
385
+ const descriptor = describe(view, panel);
386
+ if (slot.toolbar) {
387
+ toolbars.push({view: descriptor, element: slot.toolbar});
388
+ slot.body.setAttribute('aria-labelledby', id + '-section-' + (spriteEditorSections[view.section] ?? 'code'));
389
+ }
390
+ if (view.section === variableSection && options.variables && slot.assetPreview) {
391
+ slot.assetPreview.hidden = false;
392
+ assetPreviews.push({id, targetId: descriptor.targetId, kind: 'variables', editable: active === view,
393
+ element: slot.assetPreview});
394
+ continue;
395
+ }
396
+ if (active === view) continue;
397
+ const kind = view.section === 1 ? 'costumes' : view.section === 2 ? 'sounds' : undefined;
398
+ if (kind && slot.assetPreview) {
399
+ clearInactive(slot);
400
+ slot.assetPreview.hidden = false;
401
+ assetPreviews.push({id, targetId: descriptor.targetId, kind, element: slot.assetPreview,
402
+ selected: view.assets.selected.get(kind), editable: false, renderLayout: view.assets.layouts.get(kind), scrollTop: view.assets.scroll.get(kind)});
403
+ continue;
404
+ }
405
+ if (slot.inactiveSection === view.section) continue;
406
+ clearInactive(slot);
407
+ slot.inactiveSection = view.section;
408
+ if (viewAdapter) {
409
+ const cleanup = viewAdapter.renderInactive(slot.body, descriptor, view.snapshots?.get(view.section)?.snapshot);
410
+ if (cleanup) slot.clearInactive = cleanup;
411
+ } else {
412
+ const button = slot.body.ownerDocument.createElement('button');
413
+ button.type = 'button';
414
+ button.textContent = editLabel;
415
+ slot.body.append(button);
416
+ }
417
+ }
418
+ options.onToolbarSlots?.(toolbars);
419
+ options.onAssetPreviewSlots?.(assetPreviews);
420
+ }
421
+
422
+ function remember(view: View<Snapshot>) {
423
+ if (usesNativeSection(view.section)) view.section = options.getSection();
424
+ remembered.set(view.target, view.section);
425
+ if (!viewAdapter || !usesNativeSection(view.section) || !slots.get(view.id)?.body.contains(content)) return;
426
+ const descriptor = describe(view);
427
+ const snapshot = viewAdapter.capture(descriptor);
428
+ view.snapshots ??= new Map();
429
+ const previous = view.snapshots.get(view.section);
430
+ if (previous && snapshot !== previous.snapshot) viewAdapter.dispose?.(previous.view, previous.snapshot);
431
+ view.snapshots.set(view.section, {snapshot, view: descriptor});
432
+ }
433
+
434
+ function show(panel: IDockviewPanel | undefined) {
435
+ const next = views.get(panel?.id);
436
+ if (active === next) {
437
+ refreshSurfaces();
438
+ return;
439
+ }
440
+ if (active && views.get(active.id) === active) remember(active);
441
+ active = next;
442
+ if (!next) {
443
+ if (options.park) options.park();
444
+ else content.remove();
445
+ options.selectTarget('');
446
+ } else {
447
+ if (!multiView) next.section = remembered.get(next.target) ?? options.getSection();
448
+ const slot = slots.get(next.id);
449
+ clearInactive(slot);
450
+ slot.body.appendChild(content);
451
+ if (vm.editingTarget !== next.target) vm.setEditingTarget(next.target.id);
452
+ observed = vm.editingTarget;
453
+ options.selectTarget(next.target.id, multiView ? next.assets.ports : undefined);
454
+ content.hidden = !usesNativeSection(next.section);
455
+ if (usesNativeSection(next.section)) options.activateSection(next.section);
456
+ viewAdapter?.restore(describe(next, panel), next.snapshots?.get(next.section)?.snapshot);
457
+ options.resize();
458
+ }
459
+ refreshSurfaces();
460
+ }
461
+
462
+ function add(target: Target, splitPosition?: {referencePanel: string; direction: SpriteSplitDirection}, section?: number) {
463
+ let id = target.id;
464
+ if (splitPosition || views.has(id)) {
465
+ do {
466
+ id = 'sprite-view-' + ++nextView;
467
+ } while (views.has(id) || vm.runtime.targets.some(item => item.id === id));
468
+ }
469
+ const source = splitPosition && views.get(splitPosition.referencePanel);
470
+ views.set(id, {id, target, section: section ?? remembered.get(target) ?? options.getSection(),
471
+ assets: createAssetSelections(target, source?.assets)});
472
+ try {
473
+ return dockview.addPanel({id, title: target.getName(), component: 'sprite', inactive: !splitPosition,
474
+ position: splitPosition, params: {icon: getIcon(target)}});
475
+ } catch (error) {
476
+ views.delete(id);
477
+ throw error;
478
+ }
479
+ }
480
+
481
+ function activateView(id: string) {
482
+ const view = views.get(id);
483
+ if (!view) return false;
484
+ lastUsed.set(view.target, view);
485
+ dockview.getPanel(id)?.api.setActive();
486
+ return true;
188
487
  }
189
488
 
190
489
  function select(id: string) {
191
490
  const target = vm.runtime.targets.find(item => item.id === id && item.isOriginal);
192
491
  if (!target) return;
193
492
  closed.delete(target);
194
- (dockview.getPanel(id) ?? add(target)).api.setActive();
493
+ if (active?.target === target && views.get(active.id) === active) {
494
+ activateView(active.id);
495
+ return;
496
+ }
497
+ const recent = lastUsed.get(target);
498
+ const view = recent && views.get(recent.id) === recent ? recent :
499
+ [...views.values()].find(item => item.target === target);
500
+ activateView(view?.id ?? add(target).id);
501
+ }
502
+
503
+ function split(direction: SpriteSplitDirection, id = active?.id) {
504
+ const reference = views.get(id);
505
+ if (!multiView || !reference) return;
506
+ const section = reference === active && usesNativeSection(reference.section) ? options.getSection() : reference.section;
507
+ const panel = add(reference.target, {referencePanel: id, direction}, section);
508
+ activateView(panel.id);
509
+ return panel.id;
510
+ }
511
+
512
+ function closeSplit(id = active?.id) {
513
+ const panel = dockview.getPanel(id);
514
+ if (!multiView || !panel || dockview.groups.length <= 1) return false;
515
+ syncing = true;
516
+ try {
517
+ dockview.removeGroup(panel.api.group);
518
+ show(dockview.activePanel);
519
+ } finally {
520
+ syncing = false;
521
+ }
522
+ return true;
523
+ }
524
+
525
+ function setViewSection(id: string, section: number) {
526
+ const view = views.get(id);
527
+ if (!view || !Number.isInteger(section) || section < 0 || section > (options.variables ? variableSection : 2) || view.section === section) return false;
528
+ if (view === active && viewAdapter) remember(view);
529
+ view.section = section;
530
+ if (view === active) {
531
+ content.hidden = !usesNativeSection(section);
532
+ if (usesNativeSection(section)) {
533
+ options.activateSection(section);
534
+ options.resize();
535
+ }
536
+ viewAdapter?.restore(describe(view), view.snapshots?.get(section)?.snapshot);
537
+ }
538
+ refreshSurfaces();
539
+ return true;
540
+ }
541
+
542
+ function syncActiveSection(section: number) {
543
+ if (!active || !usesNativeSection(active.section) || active.section === section) return;
544
+ active.section = section;
545
+ refreshSurfaces();
195
546
  }
196
547
 
197
548
  function refresh() {
@@ -200,17 +551,21 @@ export function mountSpriteTabs(root: HTMLElement, content: HTMLElement, options
200
551
  try {
201
552
  const available = new Set(vm.runtime.targets.filter(target => target.isOriginal));
202
553
  const editing = vm.editingTarget;
203
- for (const [id, target] of targets) {
204
- if (!available.has(target)) dockview.getPanel(id)?.api.close();
554
+ for (const [id, view] of views) {
555
+ if (!available.has(view.target)) dockview.getPanel(id)?.api.close();
556
+ }
557
+ const represented = new Set<Target>();
558
+ for (const [id, view] of views) {
559
+ represented.add(view.target);
560
+ const panel = dockview.getPanel(id);
561
+ if (panel && panel.title !== view.target.getName()) panel.api.setTitle(view.target.getName());
562
+ const icon = getIcon(view.target);
563
+ if (panel && panel.api.getParameters().icon !== icon) panel.api.updateParameters({icon});
205
564
  }
206
565
  for (const target of available) {
207
- if (target.isStage && target !== editing && !targets.has(target.id)) continue;
208
- const panel = dockview.getPanel(target.id);
209
- if (panel) {
210
- if (panel.title !== target.getName()) panel.api.setTitle(target.getName());
211
- const icon = getIcon(target);
212
- if (panel.api.getParameters().icon !== icon) panel.api.updateParameters({icon});
213
- } else if (!closed.has(target)) add(target);
566
+ if (!represented.has(target) && (!target.isStage || target === editing) && !closed.has(target)) {
567
+ add(target);
568
+ }
214
569
  }
215
570
  if (editing && (observed !== editing || !active && !closed.has(editing))) {
216
571
  observed = editing;
@@ -222,6 +577,14 @@ export function mountSpriteTabs(root: HTMLElement, content: HTMLElement, options
222
577
  }
223
578
  }
224
579
 
580
+ function disposeView(view: View<Snapshot>) {
581
+ for (const stored of view.snapshots?.values() ?? []) viewAdapter?.dispose?.(stored.view, stored.snapshot);
582
+ const slot = slots.get(view.id);
583
+ if (slot) clearInactive(slot);
584
+ slots.delete(view.id);
585
+ views.delete(view.id);
586
+ }
587
+
225
588
  const selection = dockview.onDidActivePanelChange(({panel}) => {
226
589
  if (syncing) return;
227
590
  syncing = true;
@@ -232,45 +595,98 @@ export function mountSpriteTabs(root: HTMLElement, content: HTMLElement, options
232
595
  }
233
596
  });
234
597
  const removal = dockview.onDidRemovePanel(panel => {
235
- const target = targets.get(panel.id);
236
- if (target) closed.add(target);
237
- targets.delete(panel.id);
238
- slots.delete(panel.id);
598
+ const view = views.get(panel.id);
599
+ if (!view) return;
600
+ if (view === active) remembered.set(view.target, usesNativeSection(view.section) ? options.getSection() : view.section);
601
+ disposeView(view);
602
+ if (![...views.values()].some(item => item.target === view.target)) closed.add(view.target);
239
603
  });
604
+ const layout = multiView && dockview.onDidLayoutChange(refreshSurfaces);
240
605
  vm.on('targetsUpdate', refresh);
241
606
  refresh();
242
607
  return {
243
- select,
608
+ select, split, closeSplit, activateView, setViewSection, syncActiveSection, refreshSurfaces,
609
+ selectAsset(id: string, kind: AssetKind, index: number) {
610
+ if (active?.id === id && active.section === (kind === 'costumes' ? 1 : 2)) {
611
+ active.assets.selectors.get(kind)?.(index);
612
+ }
613
+ },
614
+ currentView: () => active && describe(active),
615
+ views: () => [...views.values()].map(view => describe(view)),
244
616
  dispose() {
245
617
  renameDialog?.remove();
246
618
  renameDialog = undefined;
247
619
  vm.removeListener('targetsUpdate', refresh);
248
620
  selection.dispose();
249
621
  removal.dispose();
250
- overlay.dispose();
251
- drop.dispose();
622
+ if (overlay) overlay.dispose();
623
+ if (drop) drop.dispose();
624
+ if (layout) layout.dispose();
625
+ for (const view of views.values()) disposeView(view);
252
626
  content.remove();
253
627
  dockview.dispose();
254
628
  }
255
629
  };
256
630
  }
257
631
 
258
- interface SpriteTabsProps {
632
+ type SpriteTabsController<Snapshot> = ReturnType<typeof mountSpriteTabs<Snapshot>>;
633
+
634
+ type SpriteTabsProps<Snapshot> = Omit<TabOptions<Snapshot>, 'activateSection' | 'getSection' | 'park' | 'onToolbarSlots' | 'onAssetPreviewSlots'> & {
635
+ children: React.ReactNode;
636
+ activateSection?(section: number): void;
637
+ slots: HTMLElement[];
638
+ onAttach(): void;
259
639
  enabled: boolean;
260
640
  activeSection: number;
261
- vm: Project;
262
641
  className: string;
263
- slots: HTMLElement[];
264
- onAttach(): void;
265
- children: React.ReactNode;
266
- selectTarget(id: string): void;
267
- resize(): void;
268
- reopenLabel: string;
269
- renameLabels: RenameLabels;
270
- connect(controller: ReturnType<typeof mountSpriteTabs> | null): void;
642
+ connect(controller: SpriteTabsController<Snapshot> | null): void;
643
+ activeTools?: React.ReactNode;
644
+ renderToolbar?(view: SpriteTabView, actions: ToolbarActions): React.ReactNode;
645
+ getAssetPreview?(targetId: string, kind: SpriteAssetKind): SpriteAssetPreviewData;
646
+ getVariables?(targetId: string): readonly SpriteVariable[];
647
+ writeVariable?(targetId: string, id: string, value: SpriteVariable['value']): void;
648
+ getCodeSearchItems?(targetId: string): readonly SpriteSearchResult[];
649
+ openCodeSearchResult?(targetId: string, id: string): void;
650
+ };
651
+
652
+ function AssetPreview({locale, data, kind, selected, renderLayout, scrollTop}: {
653
+ locale: string;
654
+ data: SpriteAssetPreviewData;
655
+ kind: SpriteAssetKind;
656
+ selected?: object;
657
+ renderLayout?: SpriteAssetLayout;
658
+ scrollTop?: number;
659
+ }) {
660
+ const chosen = data.items.find(item => item.asset === selected) ??
661
+ data.items.find(item => item.asset === data.defaultAsset) ?? data.items[0];
662
+ const peaks = React.useMemo(() => {
663
+ if (!chosen?.buffer?.length) return undefined;
664
+ const samples = chosen.buffer.getChannelData(0);
665
+ const chunk = Math.ceil(samples.length / 192);
666
+ const levels: number[] = [];
667
+ for (let offset = 0; offset < samples.length; offset += chunk) {
668
+ let peak = 0;
669
+ for (let i = offset; i < Math.min(offset + chunk, samples.length); i++) {
670
+ peak = Math.max(peak, Math.abs(samples[i]));
671
+ }
672
+ levels.push(peak);
673
+ }
674
+ return levels;
675
+ }, [chosen?.buffer]);
676
+ const index = chosen ? data.items.indexOf(chosen) : -1;
677
+ const pane = React.createElement(SpriteAssetPane, {
678
+ locale, kind, editable: false, onSelect() {},
679
+ detailOnly: Boolean(renderLayout),
680
+ selectedId: chosen ? String(index) : null,
681
+ items: data.items.map((item, position) => ({
682
+ id: String(position), name: item.name, imageUrl: item.imageUrl,
683
+ duration: item.duration ?? item.buffer?.duration, peaks: item === chosen ? peaks : undefined
684
+ }))
685
+ });
686
+ return renderLayout ? renderLayout(data.items, index, pane, scrollTop ?? 0) : pane;
271
687
  }
272
688
 
273
- function SpriteTabs(props: SpriteTabsProps) {
689
+ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
274
690
  const root = React.useRef<HTMLDivElement>(null);
275
691
  const sections = React.useRef<HTMLDivElement>(null);
276
692
  const parking = React.useRef<HTMLDivElement>(null);
@@ -281,6 +697,12 @@ function SpriteTabs(props: SpriteTabsProps) {
281
697
  node.className = 'tw-plus-sprite-tab-editor';
282
698
  return node;
283
699
  });
700
+ const [toolbarSlots, setToolbarSlots] = React.useState<readonly ToolbarSlot[]>([]);
701
+ const [assetPreviewSlots, setAssetPreviewSlots] = React.useState<readonly AssetPreviewSlot[]>([]);
702
+ const controller = React.useRef<SpriteTabsController<Snapshot>>(null);
703
+ const hasVariables = Boolean(props.getVariables);
704
+ const hasToolbar = Boolean(props.renderToolbar) || hasVariables;
705
+ const hasAssetPreview = Boolean(props.getAssetPreview) || hasVariables;
284
706
  React.useLayoutEffect(() => {
285
707
  sections.current.append(...props.slots);
286
708
  parking.current.appendChild(content);
@@ -291,19 +713,26 @@ function SpriteTabs(props: SpriteTabsProps) {
291
713
  }, [content, props.slots]);
292
714
  React.useLayoutEffect(() => {
293
715
  if (!props.enabled) {
716
+ setToolbarSlots([]);
717
+ setAssetPreviewSlots([]);
718
+ content.hidden = false;
294
719
  root.current.appendChild(content);
295
720
  latest.current.resize();
296
721
  return () => content.remove();
297
722
  }
298
- const park = () => parking.current?.appendChild(content);
299
- park();
300
723
  const frame = content.ownerDocument.defaultView;
301
724
  let restoreFrame: number;
302
- const controller = mountSpriteTabs(root.current, content, {
725
+ const park = () => parking.current?.appendChild(content);
726
+ park();
727
+ const instance = mountSpriteTabs(root.current, content, {
303
728
  vm: props.vm,
304
729
  getSection: () => latest.current.activeSection,
305
730
  activateSection(section) {
306
731
  frame.cancelAnimationFrame(restoreFrame);
732
+ if (latest.current.activateSection) {
733
+ latest.current.activateSection(section);
734
+ return;
735
+ }
307
736
  // Dockview attaches the active panel after dispatching the selection event.
308
737
  restoreFrame = frame.requestAnimationFrame(() => {
309
738
  if (content.isConnected && section !== latest.current.activeSection) {
@@ -311,38 +740,104 @@ function SpriteTabs(props: SpriteTabsProps) {
311
740
  }
312
741
  });
313
742
  },
314
- selectTarget: id => latest.current.selectTarget(id),
315
- resize: () => latest.current.resize(),
316
743
  park,
744
+ renameLabels: props.renameLabels,
745
+ selectTarget: (id, assets) => latest.current.selectTarget(id, assets),
746
+ resize: () => latest.current.resize(),
317
747
  reopenLabel: props.reopenLabel,
318
- renameLabels: props.renameLabels
748
+ multiView: props.multiView,
749
+ variables: hasVariables,
750
+ locale: props.locale,
751
+ viewAdapter: props.viewAdapter,
752
+ onToolbarSlots: hasToolbar ? setToolbarSlots : undefined,
753
+ onAssetPreviewSlots: hasAssetPreview ? setAssetPreviewSlots : undefined
319
754
  });
320
- latest.current.connect(controller);
755
+ controller.current = instance;
756
+ latest.current.connect(instance);
321
757
  return () => {
322
758
  frame.cancelAnimationFrame(restoreFrame);
759
+ controller.current = null;
323
760
  latest.current.connect(null);
324
- controller.dispose();
761
+ instance.dispose();
325
762
  };
326
- }, [content, props.enabled, props.vm, props.reopenLabel, props.renameLabels]);
763
+ }, [content, props.enabled, props.vm, props.reopenLabel, props.multiView, props.locale,
764
+ props.viewAdapter, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
765
+ React.useLayoutEffect(() => {
766
+ controller.current?.syncActiveSection(props.activeSection);
767
+ }, [props.activeSection]);
768
+ const workspace = controller.current;
327
769
  // The native tab controller also reacts to bubbled events from elements with role="tab".
328
770
  const isolateDockviewTabs = (event: React.SyntheticEvent) => {
329
771
  if (!content.contains(event.target as Node)) event.stopPropagation();
330
772
  };
331
- return React.createElement('div', {className: props.className},
773
+ return React.createElement('div', {className: props.className,
774
+ 'data-multi-view': props.enabled && props.multiView || undefined},
332
775
  React.createElement('div', {ref: root, onClick: isolateDockviewTabs, onKeyDown: isolateDockviewTabs}),
333
776
  React.createElement('div', {ref: parking, hidden: true}),
334
- createPortal(React.createElement(React.Fragment, null, props.children,
335
- React.createElement('div', {ref: sections, className: 'tw-plus-sprite-tab-sections'})), content));
336
- }
337
-
338
- interface SpriteTabSlots extends Pick<SpriteTabsProps, 'activeSection' | 'vm' | 'enabled' | 'className' | 'resize'> {
339
- available: boolean;
340
- locale: string;
777
+ createPortal(React.createElement(React.Fragment, null,
778
+ props.multiView && props.getVariables ? React.createElement('div', {hidden: true}, props.children) : props.children,
779
+ React.createElement('div', {ref: sections, className: 'tw-plus-sprite-tab-sections'})), content), toolbarSlots.map(({view, element}) => {
780
+ const actions = {
781
+ activate: () => { workspace?.activateView(view.id); },
782
+ setSection: (section: number) => {
783
+ workspace?.setViewSection(view.id, section);
784
+ workspace?.activateView(view.id);
785
+ }
786
+ };
787
+ const currentSection = spriteEditorSections[view.section] ?? 'code';
788
+ const toolbar = props.renderToolbar?.(view, actions) ?? (props.getVariables &&
789
+ React.createElement(SpriteEditorToolbar, {
790
+ viewId: view.id, locale: props.locale, section: currentSection, isStage: view.isStage,
791
+ activeGroup: view.groupActive,
792
+ actions: view.active && currentSection !== 'variables' ? props.activeTools : undefined,
793
+ onSectionChange: section => actions.setSection(spriteEditorSections.indexOf(section)),
794
+ search: {
795
+ onFocus: actions.activate,
796
+ find(query, section) {
797
+ let items: readonly SpriteSearchResult[] = [];
798
+ if (section === 'code') items = props.getCodeSearchItems?.(view.targetId) ?? [];
799
+ else if (section === 'variables') {
800
+ items = props.getVariables(view.targetId).map(variable => ({id: variable.id, label: variable.name}));
801
+ } else {
802
+ items = props.getAssetPreview?.(view.targetId, section).items.map((item, index) =>
803
+ ({id: String(index), label: item.name})) ?? [];
804
+ }
805
+ const text = query.toLocaleLowerCase();
806
+ return items.filter(item => item.label.toLocaleLowerCase().includes(text));
807
+ },
808
+ select(result, section) {
809
+ actions.activate();
810
+ if (section === 'code') props.openCodeSearchResult?.(view.targetId, result.id);
811
+ else if (section === 'variables') {
812
+ const field = assetPreviewSlots.find(slot => slot.id === view.id)?.element;
813
+ [...(field?.querySelectorAll<HTMLInputElement>('input[data-variable-id]') ?? [])]
814
+ .find(input => input.dataset.variableId === result.id)?.focus();
815
+ } else workspace?.selectAsset(view.id, section, Number(result.id));
816
+ }
817
+ }
818
+ }));
819
+ return createPortal(toolbar, element, view.id);
820
+ }), assetPreviewSlots.map(({id, targetId, kind, element, editable, selected, renderLayout, scrollTop}) =>
821
+ createPortal(kind === 'variables' ? React.createElement(SpriteAssetPane, {
822
+ kind, locale: props.locale, editable, variables: props.getVariables?.(targetId) ?? [],
823
+ onWrite(variableId, value) {
824
+ props.writeVariable?.(targetId, variableId, value);
825
+ workspace?.refreshSurfaces();
826
+ }
827
+ }) : props.getAssetPreview && React.createElement(AssetPreview, {
828
+ locale: props.locale, kind, selected, renderLayout, scrollTop, data: props.getAssetPreview(targetId, kind)
829
+ }), element, id)));
341
830
  }
342
831
 
343
- export function useSpriteTabs({available, enabled, locale, ...props}: SpriteTabSlots) {
344
- const controller = React.useRef<ReturnType<typeof mountSpriteTabs>>(null);
345
- const [selectedId, setSelected] = React.useState('');
832
+ export function useSpriteTabs<Snapshot>({available, enabled, locale, ...props}:
833
+ Omit<SpriteTabsProps<Snapshot>, 'children' | 'connect' | 'selectTarget' | 'reopenLabel' | 'renameLabels' |
834
+ 'slots' | 'onAttach' | 'activateSection'> & {
835
+ available: boolean;
836
+ locale: string;
837
+ activateSection?(section: number): void;
838
+ }) {
839
+ const controller = React.useRef<SpriteTabsController<Snapshot>>(null);
840
+ const [selection, setSelection] = React.useState<{id: string; assets?: AssetSelections}>({id: ''});
346
841
  const [ready, setReady] = React.useState(false);
347
842
  const [slots] = React.useState(() => available ? Array.from({length: 3}, () => {
348
843
  const slot = document.createElement('div');
@@ -358,9 +853,12 @@ export function useSpriteTabs({available, enabled, locale, ...props}: SpriteTabS
358
853
  const japanese = /^ja(?:-|$)/i.test(locale);
359
854
  const select = (id: string) => controller.current?.select(id);
360
855
  return {
361
- context: enabled ? {selectedId, openLabel: japanese ? '開く' : 'Open', select, open: select} : null,
362
- renderEditorTabRow: available ? (children: React.ReactNode) => React.createElement(SpriteTabs, {
363
- ...props, enabled, slots, children, onAttach: () => setReady(true), selectTarget: setSelected,
856
+ controller,
857
+ assetSelections: enabled ? selection.assets : undefined,
858
+ context: enabled ? {selectedId: selection.id, openLabel: japanese ? '開く' : 'Open', select, open: select} : null,
859
+ renderEditorTabRow: available ? (children: React.ReactNode) => React.createElement(SpriteTabs<Snapshot>, {
860
+ ...props, enabled, locale, children, slots, onAttach: () => setReady(true),
861
+ selectTarget: (id, assets) => setSelection({id, assets}),
364
862
  connect: next => { controller.current = next; },
365
863
  reopenLabel: japanese ? '編集中のスプライトを開く' : 'Open the editing sprite',
366
864
  renameLabels: japanese ? renameLabels.ja : renameLabels.en