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