@kubohiroya/turbowarp-plus-editor-layout 0.1.6 → 0.1.9

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.
@@ -4,6 +4,7 @@ export interface DocumentPanelRequest {
4
4
  documentId: string;
5
5
  };
6
6
  filename: string;
7
+ transient?: boolean;
7
8
  mount(element: HTMLElement, close: () => void): {
8
9
  requestClose(): void;
9
10
  dispose(): void;
@@ -1,4 +1,50 @@
1
1
  import React from 'react';
2
+ import { type SpritePropertiesPanelProps } from './sprite-properties.tsx';
3
+ import type { SpriteAssetPreviewItem } from './sprite-tabs.ts';
4
+ interface NativeAssetPanelProps {
5
+ children?: React.ReactNode;
6
+ buttons: readonly {
7
+ title: string;
8
+ img: string;
9
+ onClick(): void;
10
+ }[];
11
+ items: readonly {
12
+ dragPayload?: object;
13
+ name: string;
14
+ url?: string;
15
+ details?: React.ReactNode;
16
+ }[];
17
+ isRtl?: boolean;
18
+ listActions?: React.ElementType;
19
+ selectedItemIndex: number;
20
+ onItemClick(index: number): void;
21
+ }
22
+ interface NativeSelectorProps extends NativeAssetPanelProps {
23
+ wrapperAttributes?: Record<string, string>;
24
+ listRef?(node: HTMLElement | null): void;
25
+ onListScroll?(event: React.UIEvent<HTMLElement>): void;
26
+ draggingType?: null;
27
+ onAddSortable?(): void;
28
+ onRemoveSortable?(): void;
29
+ }
30
+ export type NativeAssetSelector = React.ComponentType<NativeSelectorProps>;
31
+ export type NativeAssetPanelRenderer = (props: NativeAssetPanelProps, options: {
32
+ panelAttributes: Record<string, string>;
33
+ detailAttributes: Record<string, string>;
34
+ renderSelector(Component: NativeAssetSelector, props: NativeSelectorProps): React.ReactNode;
35
+ }) => React.ReactNode;
36
+ export type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number, detail: React.ReactNode, scrollTop: number) => React.ReactNode;
37
+ interface PanelState {
38
+ rememberLayout(render: SpriteAssetLayout): void;
39
+ getScrollTop(): number;
40
+ rememberScrollTop(position: number): void;
41
+ }
42
+ export declare function NativeAssetPanel({ render, previewSelector, panel, state }: {
43
+ render: NativeAssetPanelRenderer;
44
+ previewSelector: NativeAssetSelector;
45
+ panel: NativeAssetPanelProps;
46
+ state: PanelState;
47
+ }): React.JSX.Element;
2
48
  export interface SpriteMediaAsset {
3
49
  /** Host identity for this item within its target, independent of the list order. */
4
50
  id: string;
@@ -30,6 +76,9 @@ export interface SpriteMediaPaneProps extends PaneProps {
30
76
  export interface SpriteVariablePaneProps extends PaneProps {
31
77
  kind: 'variables';
32
78
  variables: readonly SpriteVariable[];
79
+ properties?: SpritePropertiesPanelProps['values'];
80
+ targetId?: string;
81
+ onWriteProperties?: SpritePropertiesPanelProps['onChange'];
33
82
  onWrite(id: string, value: SpriteVariable['value']): void;
34
83
  }
35
84
  export type SpriteAssetPaneProps = SpriteMediaPaneProps | SpriteVariablePaneProps;
@@ -1,4 +1,48 @@
1
1
  import React from 'react';
2
+ import { SpritePropertiesPanel } from "./sprite-properties.js";
3
+ const noop = () => { };
4
+ function NativeAssetPanelSurface({ render, panel, state, previewSelector, scrollTop }) {
5
+ const list = React.useRef(null);
6
+ const setList = React.useCallback((node) => {
7
+ list.current = node;
8
+ }, []);
9
+ React.useLayoutEffect(() => {
10
+ const position = state?.getScrollTop() ?? scrollTop;
11
+ if (list.current && position !== undefined)
12
+ list.current.scrollTop = position;
13
+ }, [state, scrollTop]);
14
+ return render(panel, {
15
+ panelAttributes: { 'data-layout-asset-panel': '' },
16
+ detailAttributes: { 'data-layout-asset-detail': '' },
17
+ renderSelector(Component, props) {
18
+ const selectorProps = { ...props, listRef: setList, wrapperAttributes: { 'data-layout-asset-list': '' } };
19
+ if (previewSelector) {
20
+ return React.createElement("div", { inert: '', style: { display: 'contents' } }, React.createElement(previewSelector, {
21
+ ...selectorProps, draggingType: null, onAddSortable: noop, onRemoveSortable: noop
22
+ }));
23
+ }
24
+ return React.createElement(Component, { ...selectorProps, onListScroll: event => {
25
+ state?.rememberScrollTop(event.currentTarget.scrollTop);
26
+ } });
27
+ }
28
+ });
29
+ }
30
+ export function NativeAssetPanel({ render, previewSelector, panel, state }) {
31
+ const { items, buttons, isRtl, listActions } = panel;
32
+ React.useLayoutEffect(() => {
33
+ const previousItems = new Map(items.map(item => [item.dragPayload, item]));
34
+ const readOnlyButtons = buttons.map(({ title, img }) => ({ title, img, onClick: noop }));
35
+ state.rememberLayout((previewItems, selectedItemIndex, detail, scrollTop) => React.createElement(NativeAssetPanelSurface, { render: render, previewSelector: previewSelector, scrollTop: scrollTop, panel: {
36
+ isRtl, listActions, selectedItemIndex, buttons: readOnlyButtons,
37
+ onItemClick: noop, children: detail,
38
+ items: previewItems.map(item => {
39
+ const previous = previousItems.get(item.asset);
40
+ return { name: item.name, url: item.imageUrl || previous?.url, details: previous?.details };
41
+ })
42
+ } }));
43
+ }, [state, render, previewSelector, items, buttons, isRtl, listActions]);
44
+ return React.createElement(NativeAssetPanelSurface, { render: render, panel: panel, state: state });
45
+ }
2
46
  const english = {
3
47
  costumes: 'Costumes', sounds: 'Sounds', variables: 'Variables', name: 'Name', value: 'Value',
4
48
  local: 'Sprite', global: 'Project', emptyCostumes: 'No costumes', emptySounds: 'No sounds',
@@ -90,19 +134,22 @@ function VariableField({ variable, editable, onWrite }) {
90
134
  finish(event.key === 'Enter');
91
135
  } });
92
136
  }
93
- function VariablePane({ variables, editable, onWrite, locale }) {
137
+ function VariablePane({ variables, editable, onWrite, locale, properties, targetId, onWriteProperties }) {
94
138
  const labels = labelsFor(locale);
95
- return React.createElement("section", { className: "tw-sprite-variables", "aria-label": labels.variables }, variables.length ? React.createElement("table", { className: "tw-sprite-variables__table" },
96
- React.createElement("thead", null,
97
- React.createElement("tr", null,
98
- React.createElement("th", { scope: "col" }, labels.name),
99
- React.createElement("th", { scope: "col" }, labels.value))),
100
- React.createElement("tbody", null, variables.map(variable => React.createElement("tr", { key: variable.id },
101
- React.createElement("th", { scope: "row" },
102
- variable.name,
103
- variable.scope && React.createElement("span", { className: "tw-sprite-variables__scope" }, labels[variable.scope])),
104
- React.createElement("td", null,
105
- React.createElement(VariableField, { variable: variable, editable: editable, onWrite: onWrite })))))) : React.createElement("p", null, labels.emptyVariables));
139
+ return React.createElement("section", { className: "tw-sprite-variables", "aria-label": labels.variables },
140
+ properties && targetId && onWriteProperties &&
141
+ React.createElement(SpritePropertiesPanel, { targetId: targetId, values: properties, locale: locale, disabled: !editable, onChange: onWriteProperties }),
142
+ variables.length ? React.createElement("table", { className: "tw-sprite-variables__table" },
143
+ React.createElement("thead", null,
144
+ React.createElement("tr", null,
145
+ React.createElement("th", { scope: "col" }, labels.name),
146
+ React.createElement("th", { scope: "col" }, labels.value))),
147
+ React.createElement("tbody", null, variables.map(variable => React.createElement("tr", { key: variable.id },
148
+ React.createElement("th", { scope: "row" },
149
+ variable.name,
150
+ variable.scope && React.createElement("span", { className: "tw-sprite-variables__scope" }, labels[variable.scope])),
151
+ React.createElement("td", null,
152
+ React.createElement(VariableField, { variable: variable, editable: editable, onWrite: onWrite })))))) : React.createElement("p", null, labels.emptyVariables));
106
153
  }
107
154
  export function SpriteAssetPane(props) {
108
155
  return props.kind === 'variables' ? React.createElement(VariablePane, { ...props }) : React.createElement(MediaPane, { ...props });
@@ -19,7 +19,8 @@ interface SpriteEditorToolbarProps {
19
19
  activeGroup?: boolean;
20
20
  search?: SpriteEditorSearch;
21
21
  availableSections?: readonly SpriteEditorSection[];
22
+ propertiesInVariables?: boolean;
22
23
  actions?: React.ReactNode;
23
24
  }
24
- export declare function SpriteEditorToolbar({ viewId, locale, section, onSectionChange, isStage, activeGroup, search, availableSections, actions }: SpriteEditorToolbarProps): React.JSX.Element;
25
+ export declare function SpriteEditorToolbar({ viewId, locale, section, onSectionChange, isStage, activeGroup, search, availableSections, propertiesInVariables, actions }: SpriteEditorToolbarProps): React.JSX.Element;
25
26
  export {};
@@ -49,10 +49,10 @@ function SearchBox({ viewId, section, japanese, search }) {
49
49
  } })),
50
50
  expanded && React.createElement("ul", { role: "listbox", id: listId, "aria-label": japanese ? '検索結果' : 'Search results' }, results.map((result, position) => React.createElement("li", { key: result.id, id: optionId(position), role: "option", "aria-selected": position === index, onPointerDown: event => event.preventDefault(), onClick: () => choose(result) }, result.label))));
51
51
  }
52
- export function SpriteEditorToolbar({ viewId, locale, section, onSectionChange, isStage, activeGroup, search, availableSections = spriteEditorSections, actions }) {
52
+ export function SpriteEditorToolbar({ viewId, locale, section, onSectionChange, isStage, activeGroup, search, availableSections = spriteEditorSections, propertiesInVariables = false, actions }) {
53
53
  const japanese = /^ja(?:-|$)/i.test(locale);
54
- const labels = japanese ? { code: 'コード', costumes: isStage ? '背景' : 'コスチューム', sounds: '音', variables: '変数' } :
55
- { code: 'Code', costumes: isStage ? 'Backdrops' : 'Costumes', sounds: 'Sounds', variables: 'Variables' };
54
+ const labels = japanese ? { code: 'コード', costumes: isStage ? '背景' : 'コスチューム', sounds: '音', variables: propertiesInVariables ? 'プロパティ・変数' : '変数' } :
55
+ { code: 'Code', costumes: isStage ? 'Backdrops' : 'Costumes', sounds: 'Sounds', variables: propertiesInVariables ? 'Properties & Variables' : 'Variables' };
56
56
  return React.createElement("div", { className: "tw-plus-sprite-toolbar", "data-active-group": activeGroup },
57
57
  React.createElement("div", { className: "tw-plus-sprite-toolbar-sections", role: "tablist", "aria-label": japanese ? 'スプライトの編集内容' : 'Sprite editor sections' }, availableSections.map((id, index) => React.createElement("button", { key: id, type: "button", role: "tab", id: viewId + '-section-' + id, "aria-selected": section === id, tabIndex: section === id ? 0 : -1, "aria-controls": viewId + '-content', onClick: () => onSectionChange(id), onKeyDown: event => {
58
58
  const direction = event.currentTarget.ownerDocument.dir === 'rtl' ? -1 : 1;
@@ -1,5 +1,8 @@
1
1
  import React from 'react';
2
+ import { NativeAssetPanel } from './sprite-asset-pane.tsx';
3
+ import type { NativeAssetPanelRenderer, NativeAssetSelector, SpriteAssetLayout } from './sprite-asset-pane.tsx';
2
4
  import type { SpriteVariable } from './sprite-asset-pane.tsx';
5
+ import type { SpritePropertiesPanelProps } from './sprite-properties.tsx';
3
6
  import type { SpriteSearchResult } from './sprite-editor-toolbar.tsx';
4
7
  import type { DockviewStagePane } from './dockview-stage.ts';
5
8
  export { getDocumentWorkspace } from './document-workspace.ts';
@@ -49,6 +52,7 @@ export interface SpriteTabAdapter<Snapshot = unknown> {
49
52
  export interface SpriteAssetSelection {
50
53
  restore(target: object, assets: readonly object[]): number | undefined;
51
54
  remember(target: object, asset: object | undefined, select?: (index: number) => void): void;
55
+ renderPanel?(render: NativeAssetPanelRenderer, previewSelector: NativeAssetSelector, panel: React.ComponentProps<typeof NativeAssetPanel>['panel']): React.ReactNode;
52
56
  rememberLayout?(render: SpriteAssetLayout): void;
53
57
  getScrollTop?(): number;
54
58
  rememberScrollTop?(position: number): void;
@@ -62,7 +66,6 @@ export interface SpriteAssetPreviewItem {
62
66
  buffer?: AudioBuffer;
63
67
  duration?: number;
64
68
  }
65
- type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number, detail: React.ReactNode, scrollTop: number) => React.ReactNode;
66
69
  export interface SpriteAssetPreviewData {
67
70
  items: readonly SpriteAssetPreviewItem[];
68
71
  defaultAsset?: object;
@@ -78,7 +81,7 @@ interface TabOptions<Snapshot = unknown> {
78
81
  reopenLabel: string;
79
82
  multiView?: boolean;
80
83
  layoutStorage?: {
81
- storage: Storage;
84
+ storage: Pick<Storage, 'getItem' | 'setItem'>;
82
85
  key: string;
83
86
  isProjectLoading?(): boolean;
84
87
  subscribeProject?(listener: () => void): (() => void) | undefined;
@@ -135,6 +138,7 @@ export declare function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HT
135
138
  setViewSection: (id: string, section: number) => boolean;
136
139
  syncActiveSection: (section: number) => void;
137
140
  refreshSurfaces: () => void;
141
+ rename(id: string): void;
138
142
  selectAsset(id: string, kind: AssetKind, index: number): void;
139
143
  currentView: () => SpriteTabView;
140
144
  views: () => SpriteTabView[];
@@ -154,6 +158,8 @@ type SpriteTabsProps<Snapshot> = Omit<TabOptions<Snapshot>, 'activateSection' |
154
158
  renderToolbar?(view: SpriteTabView, actions: ToolbarActions): React.ReactNode;
155
159
  getAssetPreview?(targetId: string, kind: SpriteAssetKind): SpriteAssetPreviewData;
156
160
  getVariables?(targetId: string): readonly SpriteVariable[];
161
+ getProperties?(targetId: string): SpritePropertiesPanelProps['values'];
162
+ writeProperties?: SpritePropertiesPanelProps['onChange'];
157
163
  writeVariable?(targetId: string, id: string, value: SpriteVariable['value']): void;
158
164
  getCodeSearchItems?(targetId: string): readonly SpriteSearchResult[];
159
165
  openCodeSearchResult?(targetId: string, id: string): void;
@@ -173,6 +179,7 @@ export declare function useSpriteTabs<Snapshot>({ available, enabled, locale, ..
173
179
  setViewSection: (id: string, section: number) => boolean;
174
180
  syncActiveSection: (section: number) => void;
175
181
  refreshSurfaces: () => void;
182
+ rename(id: string): void;
176
183
  selectAsset(id: string, kind: AssetKind, index: number): void;
177
184
  currentView: () => SpriteTabView;
178
185
  views: () => SpriteTabView[];
@@ -184,6 +191,7 @@ export declare function useSpriteTabs<Snapshot>({ available, enabled, locale, ..
184
191
  openLabel: string;
185
192
  select: (id: string) => void;
186
193
  open: (id: string) => void;
194
+ rename: (id: string) => void;
187
195
  };
188
196
  renderEditorTabRow: (children: React.ReactNode) => React.FunctionComponentElement<SpriteTabsProps<Snapshot>>;
189
197
  renderEditorPanel: (index: number, children: React.ReactNode) => React.ReactPortal;
@@ -2,10 +2,10 @@ import React from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { createDockview, DefaultTab, themeLight } from 'dockview';
4
4
  import { createSpriteLayoutMenu } from "./sprite-layout-menu.js";
5
- import { SpriteAssetPane } from "./sprite-asset-pane.js";
5
+ import { NativeAssetPanel, SpriteAssetPane } from "./sprite-asset-pane.js";
6
6
  import { SpriteEditorToolbar, spriteEditorSections } from "./sprite-editor-toolbar.js";
7
7
  import { getDocumentWorkspace } from "./document-workspace.js";
8
- import { readWorkspaceLayout } from "./workspace-layout.js";
8
+ import { readWorkspaceLayout, workspaceWithoutTransientPanels } from "./workspace-layout.js";
9
9
  export { getDocumentWorkspace } from "./document-workspace.js";
10
10
  export { useDockviewStage } from "./dockview-stage.js";
11
11
  const variableSection = spriteEditorSections.indexOf('variables');
@@ -14,32 +14,38 @@ function createAssetSelections(target, source) {
14
14
  const layouts = new Map(source?.layouts);
15
15
  const scroll = new Map(source?.scroll);
16
16
  const selectors = new Map();
17
- const forKind = (kind) => ({
18
- restore(current, assets) {
19
- if (current !== target)
20
- return;
21
- const index = assets.indexOf(selected.get(kind));
22
- if (index >= 0)
23
- return index;
24
- },
25
- remember(current, asset, select) {
26
- if (current !== target)
27
- return;
28
- if (asset)
29
- selected.set(kind, asset);
30
- else
31
- selected.delete(kind);
32
- if (select)
33
- selectors.set(kind, select);
34
- },
35
- rememberLayout(render) {
36
- layouts.set(kind, render);
37
- },
38
- getScrollTop: () => scroll.get(kind) ?? 0,
39
- rememberScrollTop(position) {
40
- scroll.set(kind, position);
41
- }
42
- });
17
+ const forKind = (kind) => {
18
+ const state = {
19
+ rememberLayout(render) {
20
+ layouts.set(kind, render);
21
+ },
22
+ getScrollTop: () => scroll.get(kind) ?? 0,
23
+ rememberScrollTop(position) {
24
+ scroll.set(kind, position);
25
+ }
26
+ };
27
+ return {
28
+ ...state,
29
+ renderPanel: (render, previewSelector, panel) => React.createElement(NativeAssetPanel, { render, previewSelector, panel, state }),
30
+ restore(current, assets) {
31
+ if (current !== target)
32
+ return;
33
+ const index = assets.indexOf(selected.get(kind));
34
+ if (index >= 0)
35
+ return index;
36
+ },
37
+ remember(current, asset, select) {
38
+ if (current !== target)
39
+ return;
40
+ if (asset)
41
+ selected.set(kind, asset);
42
+ else
43
+ selected.delete(kind);
44
+ if (select)
45
+ selectors.set(kind, select);
46
+ },
47
+ };
48
+ };
43
49
  const ports = { costumes: forKind('costumes'), sounds: forKind('sounds') };
44
50
  return { selected, ports, layouts, selectors, scroll };
45
51
  }
@@ -221,11 +227,9 @@ export function mountSpriteTabs(root, content, options) {
221
227
  });
222
228
  return { ...menu, init() { } };
223
229
  }
224
- function isOpen(target) {
225
- return [...views.values()].some(view => view.target === target) && vm.runtime.targets.includes(target);
226
- }
230
+ const canRename = (target) => target.isOriginal && !target.isStage && Boolean(vm.renameSprite) && vm.runtime.targets.includes(target);
227
231
  function openRenameDialog(target) {
228
- if (renameDialog || !isOpen(target))
232
+ if (renameDialog || !canRename(target))
229
233
  return;
230
234
  const dialog = document.createElement('dialog');
231
235
  dialog.className = 'tw-plus-sprite-rename';
@@ -253,7 +257,7 @@ export function mountSpriteTabs(root, content, options) {
253
257
  if (renameDialog !== dialog)
254
258
  return;
255
259
  renameDialog = undefined;
256
- if (dialog.returnValue === 'save' && input.value.trim() && isOpen(target) &&
260
+ if (dialog.returnValue === 'save' && input.value.trim() && canRename(target) &&
257
261
  input.value !== target.getName())
258
262
  vm.renameSprite?.(target.id, input.value);
259
263
  }, { once: true });
@@ -556,9 +560,17 @@ export function mountSpriteTabs(root, content, options) {
556
560
  }
557
561
  if (dockview.getPanel(stagePanelId))
558
562
  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,
563
+ const transient = new Set();
564
+ for (const [id, { request }] of documents) {
565
+ if (request.transient)
566
+ transient.add(id);
567
+ else
568
+ panels[id] = { kind: 'document', ...request.reference };
569
+ }
570
+ const saved = workspaceWithoutTransientPanels(grid, activeGroup, transient, active?.id);
571
+ if (!saved)
572
+ return;
573
+ const snapshot = { version: 1, project: projectSignature(targets), ...saved,
562
574
  editingView: active?.id, panels };
563
575
  try {
564
576
  persistence.storage.setItem(persistence.key, JSON.stringify(snapshot));
@@ -752,6 +764,11 @@ export function mountSpriteTabs(root, content, options) {
752
764
  refresh();
753
765
  return {
754
766
  select, openStage, closeStage, split, closeSplit, activateView, setViewSection, syncActiveSection, refreshSurfaces,
767
+ rename(id) {
768
+ const target = vm.runtime.targets.find(item => item.id === id);
769
+ if (target)
770
+ openRenameDialog(target);
771
+ },
755
772
  selectAsset(id, kind, index) {
756
773
  if (active?.id === id && active.section === (kind === 'costumes' ? 1 : 2)) {
757
774
  active.assets.selectors.get(kind)?.(index);
@@ -928,7 +945,7 @@ function SpriteTabs(props) {
928
945
  const toolbar = props.renderToolbar?.(view, actions) ?? (props.getVariables &&
929
946
  React.createElement(SpriteEditorToolbar, {
930
947
  viewId: view.id, locale: props.locale, section: currentSection, isStage: view.isStage,
931
- activeGroup: view.groupActive,
948
+ activeGroup: view.groupActive, propertiesInVariables: Boolean(props.getProperties),
932
949
  actions: view.active && currentSection !== 'variables' ? props.activeTools : undefined,
933
950
  onSectionChange: section => actions.setSection(spriteEditorSections.indexOf(section)),
934
951
  search: {
@@ -963,6 +980,12 @@ function SpriteTabs(props) {
963
980
  return createPortal(toolbar, element, view.id);
964
981
  }), assetPreviewSlots.map(({ id, targetId, kind, element, editable, selected, renderLayout, scrollTop }) => createPortal(kind === 'variables' ? React.createElement(SpriteAssetPane, {
965
982
  kind, locale: props.locale, editable, variables: props.getVariables?.(targetId) ?? [],
983
+ properties: props.getProperties?.(targetId),
984
+ targetId,
985
+ onWriteProperties(id, patch) {
986
+ props.writeProperties?.(id, patch);
987
+ workspace?.refreshSurfaces();
988
+ },
966
989
  onWrite(variableId, value) {
967
990
  props.writeVariable?.(targetId, variableId, value);
968
991
  workspace?.refreshSurfaces();
@@ -992,7 +1015,10 @@ export function useSpriteTabs({ available, enabled, locale, ...props }) {
992
1015
  return {
993
1016
  controller,
994
1017
  assetSelections: enabled ? selection.assets : undefined,
995
- context: enabled ? { selectedId: selection.id, openLabel: japanese ? '開く' : 'Open', select, open: select } : null,
1018
+ context: enabled ? {
1019
+ selectedId: selection.id, openLabel: japanese ? '開く' : 'Open', select, open: select,
1020
+ rename: props.multiView && props.getProperties ? (id) => controller.current?.rename(id) : undefined
1021
+ } : null,
996
1022
  renderEditorTabRow: available ? (children) => React.createElement((SpriteTabs), {
997
1023
  ...props, enabled, locale, children, slots, onAttach: () => setReady(true),
998
1024
  selectTarget: (id, assets) => setSelection({ id, assets }),
@@ -55,8 +55,9 @@ export declare function createStandardLayout(React: any, { styles, targetStyles,
55
55
  buttons: any;
56
56
  children: any;
57
57
  }) => any;
58
- SpritePropertiesSection: ({ fallback, children }: {
58
+ SpritePropertiesSection: ({ fallback, children, inEditor }: {
59
59
  fallback: any;
60
60
  children: any;
61
+ inEditor?: boolean;
61
62
  }) => any;
62
63
  };
@@ -236,14 +236,15 @@ export function createStandardLayout(React, { styles, targetStyles, context, Lay
236
236
  const unique = buttons.filter(({ title }, index) => buttons.findIndex(button => button.title === title) === index);
237
237
  return element(targets.BackdropFooter, { buttons: unique, label: isJapanese(locale) ? '背景を追加' : 'Add a backdrop' });
238
238
  }
239
- function SpritePropertiesSection({ fallback, children }) {
239
+ function SpritePropertiesSection({ fallback, children, inEditor = false }) {
240
240
  const { plusLayout, locale } = useContext(context);
241
241
  const [expanded, setExpanded] = useState(false);
242
242
  const tools = useContext(targetTools);
243
- return plusLayout ? element('div', { className: styles.spriteProperties }, element('button', {
244
- type: 'button', className: styles.spritePropertiesToggle, 'aria-expanded': expanded,
245
- onClick: () => setExpanded(open => !open)
246
- }, isJapanese(locale) ? 'スプライト' : 'Sprite'), element('div', { ref: tools?.[1] }), element('div', { className: styles.spritePropertiesContent, hidden: !expanded }, children)) : fallback;
243
+ return plusLayout ? element('div', { className: styles.spriteProperties }, inEditor ? element('span', null, isJapanese(locale) ? 'スプライト' : 'Sprite') :
244
+ element('button', {
245
+ type: 'button', className: styles.spritePropertiesToggle, 'aria-expanded': expanded,
246
+ onClick: () => setExpanded(open => !open)
247
+ }, isJapanese(locale) ? 'スプライト' : 'Sprite'), element('div', { ref: tools?.[1] }), !inEditor && element('div', { className: styles.spritePropertiesContent, hidden: !expanded }, children)) : fallback;
247
248
  }
248
249
  function useSpriteListTools() {
249
250
  const { locale, targetsPinned, setTargetsPinned } = useContext(context);
package/dist/targets.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  export declare function createTargetList(React: any, styles: any): {
2
- SpriteGrid: ({ className, compact, standard, stageTile, children, query }: {
2
+ SpriteGrid: ({ className, compact, standard, stageTile, children, query, onRename }: {
3
3
  className: any;
4
4
  compact: any;
5
5
  standard: any;
6
6
  stageTile: any;
7
7
  children: any;
8
8
  query?: string;
9
+ onRename: any;
9
10
  }) => any;
10
11
  BackdropTile: ({ url, label, selected, highlighted, containerRef, onClick, onMouseEnter, onMouseLeave }: {
11
12
  url: any;
package/dist/targets.js CHANGED
@@ -4,7 +4,7 @@ const gap = 6;
4
4
  const padding = gap * 2;
5
5
  export function createTargetList(React, styles) {
6
6
  const { createElement: element, useLayoutEffect, useRef, useState } = React;
7
- function SpriteGrid({ className, compact, standard, stageTile, children, query = '' }) {
7
+ function SpriteGrid({ className, compact, standard, stageTile, children, query = '', onRename }) {
8
8
  const grid = useRef(null);
9
9
  const [size, setSize] = useState({ width: 0, height: 0, visibleRows: 0 });
10
10
  useLayoutEffect(() => {
@@ -53,6 +53,12 @@ export function createTargetList(React, styles) {
53
53
  const placement = standard || size.width < size.height ? 'bottom-right' :
54
54
  (columns >= 2 ? 'top-right' : 'inline');
55
55
  const rows = Math.max(size.visibleRows, Math.ceil((React.Children.count(children) + 1) / columns));
56
+ const tiles = onRename ? React.Children.map(children, child => {
57
+ const tile = React.isValidElement(child) && child.props.children;
58
+ if (!React.isValidElement(tile) || !tile.props.id)
59
+ return child;
60
+ return React.cloneElement(child, {}, React.cloneElement(tile, { onRenameButtonClick: onRename }));
61
+ }) : children;
56
62
  return element('div', {
57
63
  className: compact ? `${className} ${styles.grid}` : className,
58
64
  'data-standard-target-grid': standard || undefined,
@@ -63,7 +69,7 @@ export function createTargetList(React, styles) {
63
69
  '--target-columns': columns,
64
70
  '--target-rows-before-last': rows > 1 ? `repeat(${rows - 1}, ${tileHeight}px)` : undefined
65
71
  } : undefined
66
- }, children, stageTile && element('div', { className: styles.backdropCell, key: 'stage' }, stageTile));
72
+ }, tiles, stageTile && element('div', { className: styles.backdropCell, key: 'stage' }, stageTile));
67
73
  }
68
74
  function BackdropTile({ url, label, selected, highlighted, containerRef, onClick, onMouseEnter, onMouseLeave }) {
69
75
  return element('button', {
@@ -19,6 +19,10 @@ export interface WorkspaceLayout {
19
19
  editingView?: string;
20
20
  panels: Record<string, PanelReference>;
21
21
  }
22
+ export declare function workspaceWithoutTransientPanels(grid: Grid, activeGroup: string, transient: ReadonlySet<string>, editingView?: string): {
23
+ grid: Grid;
24
+ activeGroup: string;
25
+ } | undefined;
22
26
  /** Rebuild only the supported docked grid and logical panel references from storage. */
23
27
  export declare function readWorkspaceLayout(raw: string, project: string): WorkspaceLayout | undefined;
24
28
  export {};
@@ -1,4 +1,31 @@
1
1
  import { Orientation } from 'dockview';
2
+ export function workspaceWithoutTransientPanels(grid, activeGroup, transient, editingView) {
3
+ if (!transient.size)
4
+ return { grid, activeGroup };
5
+ const groups = new Map();
6
+ const trim = (node) => {
7
+ if (Array.isArray(node.data)) {
8
+ const data = node.data.flatMap(child => {
9
+ const kept = trim(child);
10
+ return kept ? [kept] : [];
11
+ });
12
+ return data.length ? { ...node, data } : undefined;
13
+ }
14
+ const views = node.data.views.filter(id => !transient.has(id));
15
+ if (!views.length)
16
+ return;
17
+ const activeView = views.includes(node.data.activeView) ? node.data.activeView : views[0];
18
+ groups.set(node.data.id, views);
19
+ return { ...node, data: { ...node.data, views, activeView } };
20
+ };
21
+ const root = trim(grid.root);
22
+ if (!root)
23
+ return;
24
+ if (groups.has(activeGroup))
25
+ return { grid: { ...grid, root }, activeGroup };
26
+ const fallback = [...groups].find(([, views]) => editingView && views.includes(editingView));
27
+ return { grid: { ...grid, root }, activeGroup: fallback?.[0] ?? groups.keys().next().value };
28
+ }
2
29
  const limit = 256;
3
30
  const validId = (value) => typeof value === 'string' && value.length > 0 && value.length <= 1024;
4
31
  const size = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-layout",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "Resizable panes and layout for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -1,6 +1,7 @@
1
1
  export interface DocumentPanelRequest {
2
2
  reference: {providerId: string; documentId: string};
3
3
  filename: string;
4
+ transient?: boolean;
4
5
  mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
5
6
  }
6
7
 
package/src/editor.css CHANGED
@@ -191,6 +191,17 @@
191
191
  min-block-size: 0;
192
192
  }
193
193
 
194
+ /* The native stage marker is shared by the editor, fullscreen and player entries. */
195
+ :global([data-gui-stage]) {
196
+ --stage-toolbar-inset: 5.75rem;
197
+ --stage-overlay-z-index: 500;
198
+ --stage-overlay-gap: 0.5rem;
199
+ }
200
+
201
+ :global([data-gui-stage="fullscreen"]) {
202
+ --stage-toolbar-inset: 2.75rem;
203
+ }
204
+
194
205
  .stage-frame:not([data-fullscreen]) {
195
206
  padding: 0 0.25rem 0.25rem;
196
207
  border: 1px solid var(--ui-black-transparent);
@@ -1,4 +1,101 @@
1
1
  import React from 'react';
2
+ import {SpritePropertiesPanel, type SpritePropertiesPanelProps} from './sprite-properties.tsx';
3
+ import type {SpriteAssetPreviewItem} from './sprite-tabs.ts';
4
+
5
+ interface NativeAssetPanelProps {
6
+ children?: React.ReactNode;
7
+ buttons: readonly {title: string; img: string; onClick(): void}[];
8
+ items: readonly {dragPayload?: object; name: string; url?: string; details?: React.ReactNode}[];
9
+ isRtl?: boolean;
10
+ listActions?: React.ElementType;
11
+ selectedItemIndex: number;
12
+ onItemClick(index: number): void;
13
+ }
14
+
15
+ interface NativeSelectorProps extends NativeAssetPanelProps {
16
+ wrapperAttributes?: Record<string, string>;
17
+ listRef?(node: HTMLElement | null): void;
18
+ onListScroll?(event: React.UIEvent<HTMLElement>): void;
19
+ draggingType?: null;
20
+ onAddSortable?(): void;
21
+ onRemoveSortable?(): void;
22
+ }
23
+
24
+ export type NativeAssetSelector = React.ComponentType<NativeSelectorProps>;
25
+ export type NativeAssetPanelRenderer = (props: NativeAssetPanelProps, options: {
26
+ panelAttributes: Record<string, string>;
27
+ detailAttributes: Record<string, string>;
28
+ renderSelector(Component: NativeAssetSelector, props: NativeSelectorProps): React.ReactNode;
29
+ }) => React.ReactNode;
30
+
31
+ export type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number,
32
+ detail: React.ReactNode, scrollTop: number) => React.ReactNode;
33
+
34
+ interface PanelState {
35
+ rememberLayout(render: SpriteAssetLayout): void;
36
+ getScrollTop(): number;
37
+ rememberScrollTop(position: number): void;
38
+ }
39
+
40
+ const noop = () => {};
41
+
42
+ function NativeAssetPanelSurface({render, panel, state, previewSelector, scrollTop}: {
43
+ render: NativeAssetPanelRenderer;
44
+ panel: NativeAssetPanelProps;
45
+ state?: PanelState;
46
+ previewSelector?: NativeAssetSelector;
47
+ scrollTop?: number;
48
+ }) {
49
+ const list = React.useRef<HTMLElement | null>(null);
50
+ const setList = React.useCallback((node: HTMLElement | null) => {
51
+ list.current = node;
52
+ }, []);
53
+ React.useLayoutEffect(() => {
54
+ const position = state?.getScrollTop() ?? scrollTop;
55
+ if (list.current && position !== undefined) list.current.scrollTop = position;
56
+ }, [state, scrollTop]);
57
+
58
+ return render(panel, {
59
+ panelAttributes: {'data-layout-asset-panel': ''},
60
+ detailAttributes: {'data-layout-asset-detail': ''},
61
+ renderSelector(Component, props) {
62
+ const selectorProps = {...props, listRef: setList, wrapperAttributes: {'data-layout-asset-list': ''}};
63
+ if (previewSelector) {
64
+ return <div {...{inert: ''}} style={{display: 'contents'}}>
65
+ {React.createElement(previewSelector, {
66
+ ...selectorProps, draggingType: null, onAddSortable: noop, onRemoveSortable: noop
67
+ })}
68
+ </div>;
69
+ }
70
+ return <Component {...selectorProps} onListScroll={event => {
71
+ state?.rememberScrollTop(event.currentTarget.scrollTop);
72
+ }} />;
73
+ }
74
+ });
75
+ }
76
+
77
+ export function NativeAssetPanel({render, previewSelector, panel, state}: {
78
+ render: NativeAssetPanelRenderer;
79
+ previewSelector: NativeAssetSelector;
80
+ panel: NativeAssetPanelProps;
81
+ state: PanelState;
82
+ }) {
83
+ const {items, buttons, isRtl, listActions} = panel;
84
+ React.useLayoutEffect(() => {
85
+ const previousItems = new Map(items.map(item => [item.dragPayload, item]));
86
+ const readOnlyButtons = buttons.map(({title, img}) => ({title, img, onClick: noop}));
87
+ state.rememberLayout((previewItems, selectedItemIndex, detail, scrollTop) => <NativeAssetPanelSurface
88
+ render={render} previewSelector={previewSelector} scrollTop={scrollTop} panel={{
89
+ isRtl, listActions, selectedItemIndex, buttons: readOnlyButtons,
90
+ onItemClick: noop, children: detail,
91
+ items: previewItems.map(item => {
92
+ const previous = previousItems.get(item.asset);
93
+ return {name: item.name, url: item.imageUrl || previous?.url, details: previous?.details};
94
+ })
95
+ }} />);
96
+ }, [state, render, previewSelector, items, buttons, isRtl, listActions]);
97
+ return <NativeAssetPanelSurface render={render} panel={panel} state={state} />;
98
+ }
2
99
 
3
100
  export interface SpriteMediaAsset {
4
101
  /** Host identity for this item within its target, independent of the list order. */
@@ -35,6 +132,9 @@ export interface SpriteMediaPaneProps extends PaneProps {
35
132
  export interface SpriteVariablePaneProps extends PaneProps {
36
133
  kind: 'variables';
37
134
  variables: readonly SpriteVariable[];
135
+ properties?: SpritePropertiesPanelProps['values'];
136
+ targetId?: string;
137
+ onWriteProperties?: SpritePropertiesPanelProps['onChange'];
38
138
  onWrite(id: string, value: SpriteVariable['value']): void;
39
139
  }
40
140
 
@@ -148,9 +248,12 @@ function VariableField({variable, editable, onWrite}: {
148
248
  }} />;
149
249
  }
150
250
 
151
- function VariablePane({variables, editable, onWrite, locale}: SpriteVariablePaneProps) {
251
+ function VariablePane({variables, editable, onWrite, locale, properties, targetId, onWriteProperties}: SpriteVariablePaneProps) {
152
252
  const labels = labelsFor(locale);
153
253
  return <section className="tw-sprite-variables" aria-label={labels.variables}>
254
+ {properties && targetId && onWriteProperties &&
255
+ <SpritePropertiesPanel targetId={targetId} values={properties} locale={locale}
256
+ disabled={!editable} onChange={onWriteProperties} />}
154
257
  {variables.length ? <table className="tw-sprite-variables__table">
155
258
  <thead><tr><th scope="col">{labels.name}</th><th scope="col">{labels.value}</th></tr></thead>
156
259
  <tbody>{variables.map(variable => <tr key={variable.id}>
@@ -23,6 +23,7 @@ interface SpriteEditorToolbarProps {
23
23
  activeGroup?: boolean;
24
24
  search?: SpriteEditorSearch;
25
25
  availableSections?: readonly SpriteEditorSection[];
26
+ propertiesInVariables?: boolean;
26
27
  actions?: React.ReactNode;
27
28
  }
28
29
 
@@ -95,10 +96,10 @@ function SearchBox({viewId, section, japanese, search}: {
95
96
  }
96
97
 
97
98
  export function SpriteEditorToolbar({viewId, locale, section, onSectionChange, isStage, activeGroup, search,
98
- availableSections = spriteEditorSections, actions}: SpriteEditorToolbarProps) {
99
+ availableSections = spriteEditorSections, propertiesInVariables = false, actions}: SpriteEditorToolbarProps) {
99
100
  const japanese = /^ja(?:-|$)/i.test(locale);
100
- const labels = japanese ? {code: 'コード', costumes: isStage ? '背景' : 'コスチューム', sounds: '音', variables: '変数'} :
101
- {code: 'Code', costumes: isStage ? 'Backdrops' : 'Costumes', sounds: 'Sounds', variables: 'Variables'};
101
+ const labels = japanese ? {code: 'コード', costumes: isStage ? '背景' : 'コスチューム', sounds: '音', variables: propertiesInVariables ? 'プロパティ・変数' : '変数'} :
102
+ {code: 'Code', costumes: isStage ? 'Backdrops' : 'Costumes', sounds: 'Sounds', variables: propertiesInVariables ? 'Properties & Variables' : 'Variables'};
102
103
  return <div className="tw-plus-sprite-toolbar" data-active-group={activeGroup}>
103
104
  <div className="tw-plus-sprite-toolbar-sections" role="tablist"
104
105
  aria-label={japanese ? 'スプライトの編集内容' : 'Sprite editor sections'}>
@@ -3,14 +3,16 @@ import {createPortal} from 'react-dom';
3
3
  import {createDockview, DefaultTab, themeLight} from 'dockview';
4
4
  import type {DockviewGroupPanel, GroupPanelPartInitParameters, IDockviewPanel, SerializedDockview} from 'dockview';
5
5
  import {createSpriteLayoutMenu} from './sprite-layout-menu.ts';
6
- import {SpriteAssetPane} from './sprite-asset-pane.tsx';
6
+ import {NativeAssetPanel, SpriteAssetPane} from './sprite-asset-pane.tsx';
7
+ import type {NativeAssetPanelRenderer, NativeAssetSelector, SpriteAssetLayout} from './sprite-asset-pane.tsx';
7
8
  import type {SpriteVariable} from './sprite-asset-pane.tsx';
9
+ import type {SpritePropertiesPanelProps} from './sprite-properties.tsx';
8
10
  import {SpriteEditorToolbar, spriteEditorSections} from './sprite-editor-toolbar.tsx';
9
11
  import type {SpriteSearchResult} from './sprite-editor-toolbar.tsx';
10
12
  import type {DockviewStagePane} from './dockview-stage.ts';
11
13
  import {getDocumentWorkspace} from './document-workspace.ts';
12
14
  import type {DocumentPanelRequest} from './document-workspace.ts';
13
- import {readWorkspaceLayout} from './workspace-layout.ts';
15
+ import {readWorkspaceLayout, workspaceWithoutTransientPanels} from './workspace-layout.ts';
14
16
  import type {PanelReference, WorkspaceLayout} from './workspace-layout.ts';
15
17
 
16
18
  export {getDocumentWorkspace} from './document-workspace.ts';
@@ -61,6 +63,8 @@ export interface SpriteTabAdapter<Snapshot = unknown> {
61
63
  export interface SpriteAssetSelection {
62
64
  restore(target: object, assets: readonly object[]): number | undefined;
63
65
  remember(target: object, asset: object | undefined, select?: (index: number) => void): void;
66
+ renderPanel?(render: NativeAssetPanelRenderer, previewSelector: NativeAssetSelector,
67
+ panel: React.ComponentProps<typeof NativeAssetPanel>['panel']): React.ReactNode;
64
68
  rememberLayout?(render: SpriteAssetLayout): void;
65
69
  getScrollTop?(): number;
66
70
  rememberScrollTop?(position: number): void;
@@ -77,7 +81,6 @@ export interface SpriteAssetPreviewItem {
77
81
  duration?: number;
78
82
  }
79
83
 
80
- type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number, detail: React.ReactNode, scrollTop: number) => React.ReactNode;
81
84
  const variableSection = spriteEditorSections.indexOf('variables');
82
85
 
83
86
  export interface SpriteAssetPreviewData {
@@ -95,26 +98,33 @@ function createAssetSelections(target: Target, source?: {
95
98
  const layouts = new Map(source?.layouts);
96
99
  const scroll = new Map(source?.scroll);
97
100
  const selectors = new Map<AssetKind, (index: number) => void>();
98
- const forKind = (kind: AssetKind): SpriteAssetSelection => ({
99
- restore(current, assets) {
100
- if (current !== target) return;
101
- const index = assets.indexOf(selected.get(kind));
102
- if (index >= 0) return index;
103
- },
104
- remember(current, asset, select) {
105
- if (current !== target) return;
106
- if (asset) selected.set(kind, asset);
107
- else selected.delete(kind);
108
- if (select) selectors.set(kind, select);
109
- },
110
- rememberLayout(render) {
111
- layouts.set(kind, render);
112
- },
113
- getScrollTop: () => scroll.get(kind) ?? 0,
114
- rememberScrollTop(position) {
115
- scroll.set(kind, position);
116
- }
117
- });
101
+ const forKind = (kind: AssetKind): SpriteAssetSelection => {
102
+ const state = {
103
+ rememberLayout(render: SpriteAssetLayout) {
104
+ layouts.set(kind, render);
105
+ },
106
+ getScrollTop: () => scroll.get(kind) ?? 0,
107
+ rememberScrollTop(position: number) {
108
+ scroll.set(kind, position);
109
+ }
110
+ };
111
+ return {
112
+ ...state,
113
+ renderPanel: (render, previewSelector, panel) =>
114
+ React.createElement(NativeAssetPanel, {render, previewSelector, panel, state}),
115
+ restore(current, assets) {
116
+ if (current !== target) return;
117
+ const index = assets.indexOf(selected.get(kind));
118
+ if (index >= 0) return index;
119
+ },
120
+ remember(current, asset, select) {
121
+ if (current !== target) return;
122
+ if (asset) selected.set(kind, asset);
123
+ else selected.delete(kind);
124
+ if (select) selectors.set(kind, select);
125
+ },
126
+ };
127
+ };
118
128
  const ports: AssetSelections = {costumes: forKind('costumes'), sounds: forKind('sounds')};
119
129
  return {selected, ports, layouts, selectors, scroll};
120
130
  }
@@ -135,7 +145,7 @@ interface TabOptions<Snapshot = unknown> {
135
145
  reopenLabel: string;
136
146
  multiView?: boolean;
137
147
  layoutStorage?: {
138
- storage: Storage;
148
+ storage: Pick<Storage, 'getItem' | 'setItem'>;
139
149
  key: string;
140
150
  isProjectLoading?(): boolean;
141
151
  subscribeProject?(listener: () => void): (() => void) | undefined;
@@ -356,12 +366,11 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
356
366
  return {...menu, init() {}};
357
367
  }
358
368
 
359
- function isOpen(target: Target) {
360
- return [...views.values()].some(view => view.target === target) && vm.runtime.targets.includes(target);
361
- }
369
+ const canRename = (target: Target) =>
370
+ target.isOriginal && !target.isStage && Boolean(vm.renameSprite) && vm.runtime.targets.includes(target);
362
371
 
363
372
  function openRenameDialog(target: Target) {
364
- if (renameDialog || !isOpen(target)) return;
373
+ if (renameDialog || !canRename(target)) return;
365
374
  const dialog = document.createElement('dialog');
366
375
  dialog.className = 'tw-plus-sprite-rename';
367
376
  dialog.setAttribute('aria-label', text.title);
@@ -387,7 +396,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
387
396
  dialog.remove();
388
397
  if (renameDialog !== dialog) return;
389
398
  renameDialog = undefined;
390
- if (dialog.returnValue === 'save' && input.value.trim() && isOpen(target) &&
399
+ if (dialog.returnValue === 'save' && input.value.trim() && canRename(target) &&
391
400
  input.value !== target.getName()) vm.renameSprite?.(target.id, input.value);
392
401
  }, {once: true});
393
402
  renameDialog = dialog;
@@ -671,8 +680,14 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
671
680
  panels[id] = {kind: 'sprite', name: targetName(view.target), stage: view.target.isStage, section: view.section};
672
681
  }
673
682
  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,
683
+ const transient = new Set<string>();
684
+ for (const [id, {request}] of documents) {
685
+ if (request.transient) transient.add(id);
686
+ else panels[id] = {kind: 'document', ...request.reference};
687
+ }
688
+ const saved = workspaceWithoutTransientPanels(grid, activeGroup, transient, active?.id);
689
+ if (!saved) return;
690
+ const snapshot: WorkspaceLayout = {version: 1, project: projectSignature(targets), ...saved,
676
691
  editingView: active?.id, panels};
677
692
  try { persistence.storage.setItem(persistence.key, JSON.stringify(snapshot)); } catch { /* Storage can be unavailable. */ }
678
693
  }
@@ -841,6 +856,10 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
841
856
  refresh();
842
857
  return {
843
858
  select, openStage, closeStage, split, closeSplit, activateView, setViewSection, syncActiveSection, refreshSurfaces,
859
+ rename(id: string) {
860
+ const target = vm.runtime.targets.find(item => item.id === id);
861
+ if (target) openRenameDialog(target);
862
+ },
844
863
  selectAsset(id: string, kind: AssetKind, index: number) {
845
864
  if (active?.id === id && active.section === (kind === 'costumes' ? 1 : 2)) {
846
865
  active.assets.selectors.get(kind)?.(index);
@@ -891,6 +910,8 @@ type SpriteTabsProps<Snapshot> = Omit<TabOptions<Snapshot>, 'activateSection' |
891
910
  renderToolbar?(view: SpriteTabView, actions: ToolbarActions): React.ReactNode;
892
911
  getAssetPreview?(targetId: string, kind: SpriteAssetKind): SpriteAssetPreviewData;
893
912
  getVariables?(targetId: string): readonly SpriteVariable[];
913
+ getProperties?(targetId: string): SpritePropertiesPanelProps['values'];
914
+ writeProperties?: SpritePropertiesPanelProps['onChange'];
894
915
  writeVariable?(targetId: string, id: string, value: SpriteVariable['value']): void;
895
916
  getCodeSearchItems?(targetId: string): readonly SpriteSearchResult[];
896
917
  openCodeSearchResult?(targetId: string, id: string): void;
@@ -1039,7 +1060,7 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
1039
1060
  const toolbar = props.renderToolbar?.(view, actions) ?? (props.getVariables &&
1040
1061
  React.createElement(SpriteEditorToolbar, {
1041
1062
  viewId: view.id, locale: props.locale, section: currentSection, isStage: view.isStage,
1042
- activeGroup: view.groupActive,
1063
+ activeGroup: view.groupActive, propertiesInVariables: Boolean(props.getProperties),
1043
1064
  actions: view.active && currentSection !== 'variables' ? props.activeTools : undefined,
1044
1065
  onSectionChange: section => actions.setSection(spriteEditorSections.indexOf(section)),
1045
1066
  search: {
@@ -1071,6 +1092,12 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
1071
1092
  }), assetPreviewSlots.map(({id, targetId, kind, element, editable, selected, renderLayout, scrollTop}) =>
1072
1093
  createPortal(kind === 'variables' ? React.createElement(SpriteAssetPane, {
1073
1094
  kind, locale: props.locale, editable, variables: props.getVariables?.(targetId) ?? [],
1095
+ properties: props.getProperties?.(targetId),
1096
+ targetId,
1097
+ onWriteProperties(id: string, patch: Parameters<SpritePropertiesPanelProps['onChange']>[1]) {
1098
+ props.writeProperties?.(id, patch);
1099
+ workspace?.refreshSurfaces();
1100
+ },
1074
1101
  onWrite(variableId, value) {
1075
1102
  props.writeVariable?.(targetId, variableId, value);
1076
1103
  workspace?.refreshSurfaces();
@@ -1106,7 +1133,10 @@ export function useSpriteTabs<Snapshot>({available, enabled, locale, ...props}:
1106
1133
  return {
1107
1134
  controller,
1108
1135
  assetSelections: enabled ? selection.assets : undefined,
1109
- context: enabled ? {selectedId: selection.id, openLabel: japanese ? '開く' : 'Open', select, open: select} : null,
1136
+ context: enabled ? {
1137
+ selectedId: selection.id, openLabel: japanese ? '開く' : 'Open', select, open: select,
1138
+ rename: props.multiView && props.getProperties ? (id: string) => controller.current?.rename(id) : undefined
1139
+ } : null,
1110
1140
  renderEditorTabRow: available ? (children: React.ReactNode) => React.createElement(SpriteTabs<Snapshot>, {
1111
1141
  ...props, enabled, locale, children, slots, onAttach: () => setReady(true),
1112
1142
  selectTarget: (id, assets) => setSelection({id, assets}),
@@ -255,17 +255,18 @@ export function createStandardLayout(React, {styles, targetStyles, context, Layo
255
255
  return element(targets.BackdropFooter, {buttons: unique, label: isJapanese(locale) ? '背景を追加' : 'Add a backdrop'});
256
256
  }
257
257
 
258
- function SpritePropertiesSection({fallback, children}) {
258
+ function SpritePropertiesSection({fallback, children, inEditor = false}) {
259
259
  const {plusLayout, locale} = useContext(context);
260
260
  const [expanded, setExpanded] = useState(false);
261
261
  const tools = useContext(targetTools);
262
262
  return plusLayout ? element('div', {className: styles.spriteProperties},
263
- element('button', {
264
- type: 'button', className: styles.spritePropertiesToggle, 'aria-expanded': expanded,
265
- onClick: () => setExpanded(open => !open)
266
- }, isJapanese(locale) ? 'スプライト' : 'Sprite'),
263
+ inEditor ? element('span', null, isJapanese(locale) ? 'スプライト' : 'Sprite') :
264
+ element('button', {
265
+ type: 'button', className: styles.spritePropertiesToggle, 'aria-expanded': expanded,
266
+ onClick: () => setExpanded(open => !open)
267
+ }, isJapanese(locale) ? 'スプライト' : 'Sprite'),
267
268
  element('div', {ref: tools?.[1]}),
268
- element('div', {className: styles.spritePropertiesContent, hidden: !expanded}, children)
269
+ !inEditor && element('div', {className: styles.spritePropertiesContent, hidden: !expanded}, children)
269
270
  ) : fallback;
270
271
  }
271
272
 
package/src/targets.ts CHANGED
@@ -6,7 +6,7 @@ const padding = gap * 2;
6
6
  export function createTargetList(React, styles) {
7
7
  const {createElement: element, useLayoutEffect, useRef, useState} = React;
8
8
 
9
- function SpriteGrid({className, compact, standard, stageTile, children, query = ''}) {
9
+ function SpriteGrid({className, compact, standard, stageTile, children, query = '', onRename}) {
10
10
  const grid = useRef(null);
11
11
  const [size, setSize] = useState({width: 0, height: 0, visibleRows: 0});
12
12
  useLayoutEffect(() => {
@@ -51,6 +51,11 @@ export function createTargetList(React, styles) {
51
51
  const placement = standard || size.width < size.height ? 'bottom-right' :
52
52
  (columns >= 2 ? 'top-right' : 'inline');
53
53
  const rows = Math.max(size.visibleRows, Math.ceil((React.Children.count(children) + 1) / columns));
54
+ const tiles = onRename ? React.Children.map(children, child => {
55
+ const tile = React.isValidElement(child) && child.props.children;
56
+ if (!React.isValidElement(tile) || !tile.props.id) return child;
57
+ return React.cloneElement(child, {}, React.cloneElement(tile, {onRenameButtonClick: onRename}));
58
+ }) : children;
54
59
  return element('div', {
55
60
  className: compact ? `${className} ${styles.grid}` : className,
56
61
  'data-standard-target-grid': standard || undefined,
@@ -61,7 +66,7 @@ export function createTargetList(React, styles) {
61
66
  '--target-columns': columns,
62
67
  '--target-rows-before-last': rows > 1 ? `repeat(${rows - 1}, ${tileHeight}px)` : undefined
63
68
  } : undefined
64
- }, children, stageTile && element('div', {className: styles.backdropCell, key: 'stage'}, stageTile));
69
+ }, tiles, stageTile && element('div', {className: styles.backdropCell, key: 'stage'}, stageTile));
65
70
  }
66
71
 
67
72
  function BackdropTile({url, label, selected, highlighted, containerRef, onClick, onMouseEnter, onMouseLeave}) {
@@ -18,6 +18,31 @@ export interface WorkspaceLayout {
18
18
  panels: Record<string, PanelReference>;
19
19
  }
20
20
 
21
+ export function workspaceWithoutTransientPanels(grid: Grid, activeGroup: string, transient: ReadonlySet<string>,
22
+ editingView?: string): {grid: Grid; activeGroup: string} | undefined {
23
+ if (!transient.size) return {grid, activeGroup};
24
+ const groups = new Map<string, readonly string[]>();
25
+ const trim = (node: Node): Node | undefined => {
26
+ if (Array.isArray(node.data)) {
27
+ const data = node.data.flatMap(child => {
28
+ const kept = trim(child);
29
+ return kept ? [kept] : [];
30
+ });
31
+ return data.length ? {...node, data} : undefined;
32
+ }
33
+ const views = node.data.views.filter(id => !transient.has(id));
34
+ if (!views.length) return;
35
+ const activeView = views.includes(node.data.activeView!) ? node.data.activeView : views[0];
36
+ groups.set(node.data.id, views);
37
+ return {...node, data: {...node.data, views, activeView}};
38
+ };
39
+ const root = trim(grid.root);
40
+ if (!root) return;
41
+ if (groups.has(activeGroup)) return {grid: {...grid, root}, activeGroup};
42
+ const fallback = [...groups].find(([, views]) => editingView && views.includes(editingView));
43
+ return {grid: {...grid, root}, activeGroup: fallback?.[0] ?? groups.keys().next().value!};
44
+ }
45
+
21
46
  const limit = 256;
22
47
  const validId = (value: unknown): value is string =>
23
48
  typeof value === 'string' && value.length > 0 && value.length <= 1024;