@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.
- package/README.ja.md +2 -0
- package/README.md +2 -0
- package/dist/sprite-asset-pane.d.ts +37 -0
- package/dist/sprite-asset-pane.js +109 -0
- package/dist/sprite-editor-toolbar.d.ts +25 -0
- package/dist/sprite-editor-toolbar.js +72 -0
- package/dist/sprite-layout-menu.d.ts +11 -0
- package/dist/sprite-layout-menu.js +114 -0
- package/dist/sprite-tabs.d.ts +113 -20
- package/dist/sprite-tabs.js +496 -76
- package/package.json +12 -1
- package/src/editor.css +74 -5
- package/src/sprite-asset-pane.css +169 -0
- package/src/sprite-asset-pane.tsx +168 -0
- package/src/sprite-editor-toolbar.css +108 -0
- package/src/sprite-editor-toolbar.tsx +124 -0
- package/src/sprite-layout-menu.css +54 -0
- package/src/sprite-layout-menu.ts +119 -0
- package/src/sprite-tabs.ts +593 -95
package/README.ja.md
CHANGED
|
@@ -47,3 +47,5 @@ Editor のルートから、`site/source.json` の GUI を `npm run build:pages`
|
|
|
47
47
|
Plus 標準配置ではスプライト編集タブを既定で表示する。`twPlusDockviewSpriteTabs=0` で従来の編集表示に戻る。
|
|
48
48
|
Editor がファイルメニューの接続口を提供している場合は、各項目のアイコンと「機能拡張の追加」を表示する。
|
|
49
49
|
`twPlusExtensionFileMenu=0` で左下のボタンに戻る。起動時のパラメーターを変更したら再読み込みする。
|
|
50
|
+
|
|
51
|
+
Web 版の Plus 標準配置で `twPlusDockview=1` を付けて起動すると、スプライト編集ペインを左右・上下に分割できます。同じスプライトを複数のペインで開いた場合、表示セクション、コスチュームと音の選択、素材一覧のスクロール位置をペインごとに保持します。変数の値はプロジェクト内で共有され、編集中のペインで変更できます。URL から `twPlusDockview=1` を外して再読み込みすると、通常のスプライトタブに戻ります。
|
package/README.md
CHANGED
|
@@ -44,3 +44,5 @@ The properties form came from the previous sprite properties source at `80825f7`
|
|
|
44
44
|
Run `npm test` to check tab state, file menu actions, and the existing layout. Set `TW_PLUS_LAYOUT_EDITOR_URL` to an Editor URL built with the pinned Scratch GUI and run `npm run test:browser` to verify Dockview through the existing tab strip and editor panel integration. Set `TW_PLUS_LAYOUT_FILE_MENU_URL` for the same command to check a GUI build with the optional file menu integration.
|
|
45
45
|
|
|
46
46
|
Sprite editing tabs are enabled by default in the Plus standard layout. Set `twPlusDockviewSpriteTabs=0` to restore the previous editing view. When the Editor exposes the file menu integration, the menu shows item icons and the Add Extension command. Set `twPlusExtensionFileMenu=0` to use the button at the bottom left again. Reload the editor after changing startup parameters.
|
|
47
|
+
|
|
48
|
+
In the Plus web editor, add `twPlusDockview=1` to enable split sprite panes. Each pane remembers its section, costume and sound selections, and asset-list scroll position. Variable values are shared across panes and can be changed in the active pane. Remove the startup parameter and reload to return to the regular sprite tabs.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface SpriteMediaAsset {
|
|
3
|
+
/** Host identity for this item within its target, independent of the list order. */
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
imageUrl?: string;
|
|
7
|
+
duration?: number;
|
|
8
|
+
/** Downsampled waveform magnitudes in the range from zero to one. */
|
|
9
|
+
peaks?: readonly number[];
|
|
10
|
+
}
|
|
11
|
+
export interface SpriteVariable {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
value: string | number;
|
|
15
|
+
scope?: 'local' | 'global';
|
|
16
|
+
}
|
|
17
|
+
interface PaneProps {
|
|
18
|
+
locale: string;
|
|
19
|
+
editable: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface SpriteMediaPaneProps extends PaneProps {
|
|
22
|
+
kind: 'costumes' | 'sounds';
|
|
23
|
+
items: readonly SpriteMediaAsset[];
|
|
24
|
+
selectedId: string | null;
|
|
25
|
+
detailOnly?: boolean;
|
|
26
|
+
onSelect(id: string): void;
|
|
27
|
+
/** Optional host surface. It must honor editable without unmounting its layout. */
|
|
28
|
+
renderEditor?(selected: SpriteMediaAsset, editable: boolean): React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
export interface SpriteVariablePaneProps extends PaneProps {
|
|
31
|
+
kind: 'variables';
|
|
32
|
+
variables: readonly SpriteVariable[];
|
|
33
|
+
onWrite(id: string, value: SpriteVariable['value']): void;
|
|
34
|
+
}
|
|
35
|
+
export type SpriteAssetPaneProps = SpriteMediaPaneProps | SpriteVariablePaneProps;
|
|
36
|
+
export declare function SpriteAssetPane(props: SpriteAssetPaneProps): React.JSX.Element;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
const english = {
|
|
3
|
+
costumes: 'Costumes', sounds: 'Sounds', variables: 'Variables', name: 'Name', value: 'Value',
|
|
4
|
+
local: 'Sprite', global: 'Project', emptyCostumes: 'No costumes', emptySounds: 'No sounds',
|
|
5
|
+
emptyVariables: 'No variables', selectCostume: 'Select a costume', selectSound: 'Select a sound',
|
|
6
|
+
imageUnavailable: 'Image unavailable', waveformUnavailable: 'Waveform unavailable', waveform: 'Waveform',
|
|
7
|
+
seconds: 's'
|
|
8
|
+
};
|
|
9
|
+
const japanese = {
|
|
10
|
+
costumes: 'コスチューム', sounds: '音', variables: '変数', name: '名前', value: '値',
|
|
11
|
+
local: 'スプライト', global: '作品', emptyCostumes: 'コスチュームがありません', emptySounds: '音がありません',
|
|
12
|
+
emptyVariables: '変数がありません', selectCostume: 'コスチュームを選択してください', selectSound: '音を選択してください',
|
|
13
|
+
imageUnavailable: '画像を表示できません', waveformUnavailable: '波形を表示できません', waveform: '波形',
|
|
14
|
+
seconds: '秒'
|
|
15
|
+
};
|
|
16
|
+
const labelsFor = (locale) => /^ja(?:-|$)/i.test(locale) ? japanese : english;
|
|
17
|
+
function Waveform({ name, peaks, label }) {
|
|
18
|
+
const path = peaks.map((sample, index) => {
|
|
19
|
+
const x = (index + 0.5) * 100 / peaks.length;
|
|
20
|
+
const magnitude = Number.isFinite(sample) ? Math.max(0, Math.min(sample, 1)) : 0;
|
|
21
|
+
const y = magnitude * 49;
|
|
22
|
+
return 'M' + x + ' ' + (50 - y) + 'V' + (50 + y);
|
|
23
|
+
}).join(' ');
|
|
24
|
+
return React.createElement("svg", { className: "tw-sprite-media__waveform", viewBox: "0 0 100 100", preserveAspectRatio: "none", role: "img", "aria-label": label + ': ' + name },
|
|
25
|
+
React.createElement("path", { d: "M0 50H100", vectorEffect: "non-scaling-stroke", className: "tw-sprite-media__axis" }),
|
|
26
|
+
React.createElement("path", { d: path, vectorEffect: "non-scaling-stroke", className: "tw-sprite-media__peaks" }));
|
|
27
|
+
}
|
|
28
|
+
function MediaPane({ kind, items, selectedId, onSelect, renderEditor, editable, locale, detailOnly }) {
|
|
29
|
+
const labels = labelsFor(locale);
|
|
30
|
+
const isSound = kind === 'sounds';
|
|
31
|
+
const selected = items.find(asset => asset.id === selectedId);
|
|
32
|
+
const duration = selected?.duration;
|
|
33
|
+
return React.createElement("section", { className: detailOnly ? 'tw-sprite-media tw-sprite-media--detail' : 'tw-sprite-media', "aria-label": labels[kind] },
|
|
34
|
+
!detailOnly && React.createElement("ul", { className: "tw-sprite-media__list", "aria-label": labels[kind] }, items.map(asset => React.createElement("li", { key: asset.id },
|
|
35
|
+
React.createElement("button", { type: "button", className: "tw-sprite-media__item", "aria-current": asset.id === selectedId || undefined, "aria-disabled": !editable || undefined, tabIndex: editable ? 0 : -1, onClick: () => {
|
|
36
|
+
if (editable)
|
|
37
|
+
onSelect(asset.id);
|
|
38
|
+
} },
|
|
39
|
+
asset.imageUrl ? React.createElement("img", { src: asset.imageUrl, alt: "", className: "tw-sprite-media__thumbnail" }) :
|
|
40
|
+
React.createElement("span", { className: "tw-sprite-media__symbol", "aria-hidden": "true" }, isSound ? '♪' : '◫'),
|
|
41
|
+
React.createElement("span", { className: "tw-sprite-media__name" }, asset.name))))),
|
|
42
|
+
React.createElement("div", { className: "tw-sprite-media__detail" }, selected ? React.createElement(React.Fragment, null,
|
|
43
|
+
React.createElement("h3", { className: "tw-sprite-media__title" }, selected.name),
|
|
44
|
+
React.createElement("div", { className: "tw-sprite-media__surface" }, renderEditor?.(selected, editable) ?? (isSound ?
|
|
45
|
+
(selected.peaks?.length ? React.createElement(Waveform, { peaks: selected.peaks, name: selected.name, label: labels.waveform }) :
|
|
46
|
+
React.createElement("p", null, labels.waveformUnavailable)) :
|
|
47
|
+
(selected.imageUrl ? React.createElement("img", { src: selected.imageUrl, alt: selected.name, className: "tw-sprite-media__preview" }) :
|
|
48
|
+
React.createElement("p", null, labels.imageUnavailable)))),
|
|
49
|
+
isSound && Number.isFinite(duration) && duration >= 0 &&
|
|
50
|
+
React.createElement("p", { className: "tw-sprite-media__duration" },
|
|
51
|
+
new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(duration),
|
|
52
|
+
" ",
|
|
53
|
+
labels.seconds)) : React.createElement("p", null, items.length ? (isSound ? labels.selectSound : labels.selectCostume) :
|
|
54
|
+
(isSound ? labels.emptySounds : labels.emptyCostumes))));
|
|
55
|
+
}
|
|
56
|
+
function VariableField({ variable, editable, onWrite }) {
|
|
57
|
+
const [text, setText] = React.useState(null);
|
|
58
|
+
const pending = React.useRef(null);
|
|
59
|
+
const finish = (save) => {
|
|
60
|
+
const draft = pending.current;
|
|
61
|
+
if (!draft)
|
|
62
|
+
return;
|
|
63
|
+
pending.current = null;
|
|
64
|
+
setText(null);
|
|
65
|
+
if (!save || draft.text === String(variable.value))
|
|
66
|
+
return;
|
|
67
|
+
const number = Number(draft.text);
|
|
68
|
+
const value = typeof draft.variable.value === 'number' && draft.text.trim() && Number.isFinite(number) ?
|
|
69
|
+
number : draft.text;
|
|
70
|
+
draft.write(draft.variable.id, value);
|
|
71
|
+
};
|
|
72
|
+
React.useLayoutEffect(() => {
|
|
73
|
+
// A click can change the active pane before the old input receives blur.
|
|
74
|
+
if (!editable)
|
|
75
|
+
finish(true);
|
|
76
|
+
}, [editable]);
|
|
77
|
+
return React.createElement("input", { className: "tw-sprite-variables__input", "aria-label": variable.name, "data-variable-id": variable.id, readOnly: !editable, tabIndex: editable ? 0 : -1, value: text ?? String(variable.value), onChange: event => {
|
|
78
|
+
if (!editable)
|
|
79
|
+
return;
|
|
80
|
+
const next = event.currentTarget.value;
|
|
81
|
+
pending.current = { text: next, variable, write: onWrite };
|
|
82
|
+
setText(next);
|
|
83
|
+
}, onBlur: () => finish(true), onKeyDown: event => {
|
|
84
|
+
if (!editable || event.nativeEvent.isComposing)
|
|
85
|
+
return;
|
|
86
|
+
if (event.key !== 'Enter' && event.key !== 'Escape')
|
|
87
|
+
return;
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
event.stopPropagation();
|
|
90
|
+
finish(event.key === 'Enter');
|
|
91
|
+
} });
|
|
92
|
+
}
|
|
93
|
+
function VariablePane({ variables, editable, onWrite, locale }) {
|
|
94
|
+
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));
|
|
106
|
+
}
|
|
107
|
+
export function SpriteAssetPane(props) {
|
|
108
|
+
return props.kind === 'variables' ? React.createElement(VariablePane, { ...props }) : React.createElement(MediaPane, { ...props });
|
|
109
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export declare const spriteEditorSections: readonly ["code", "costumes", "sounds", "variables"];
|
|
3
|
+
export type SpriteEditorSection = typeof spriteEditorSections[number];
|
|
4
|
+
export interface SpriteSearchResult {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SpriteEditorSearch {
|
|
9
|
+
find(query: string, section: SpriteEditorSection): readonly SpriteSearchResult[];
|
|
10
|
+
select(result: SpriteSearchResult, section: SpriteEditorSection): void;
|
|
11
|
+
onFocus?(): void;
|
|
12
|
+
}
|
|
13
|
+
interface SpriteEditorToolbarProps {
|
|
14
|
+
viewId: string;
|
|
15
|
+
locale: string;
|
|
16
|
+
section: SpriteEditorSection;
|
|
17
|
+
onSectionChange(section: SpriteEditorSection): void;
|
|
18
|
+
isStage?: boolean;
|
|
19
|
+
activeGroup?: boolean;
|
|
20
|
+
search?: SpriteEditorSearch;
|
|
21
|
+
availableSections?: readonly SpriteEditorSection[];
|
|
22
|
+
actions?: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
export declare function SpriteEditorToolbar({ viewId, locale, section, onSectionChange, isStage, activeGroup, search, availableSections, actions }: SpriteEditorToolbarProps): React.JSX.Element;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export const spriteEditorSections = ['code', 'costumes', 'sounds', 'variables'];
|
|
3
|
+
const glyphs = {
|
|
4
|
+
code: 'm5.5 4-4 4 4 4m5-8 4 4-4 4',
|
|
5
|
+
costumes: 'm6 9 5.5-6.5a1.4 1.4 0 0 1 2 2L7.5 11m-2-2a2.5 2.5 0 0 0-3 2.5L2 14c2.7 0 5-1 5-3a2 2 0 0 0-1.5-2Z',
|
|
6
|
+
sounds: 'M2 6h3l4-3v10l-4-3H2Zm9.5 0a3 3 0 0 1 0 4m2-6a5.5 5.5 0 0 1 0 8',
|
|
7
|
+
variables: 'M5 2H3v12h2m6-12h2v12h-2M6 6l4 4m0-4-4 4',
|
|
8
|
+
search: 'M10 10l3 3M11 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z'
|
|
9
|
+
};
|
|
10
|
+
function Icon({ kind }) {
|
|
11
|
+
return React.createElement("svg", { "aria-hidden": "true", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round" },
|
|
12
|
+
React.createElement("path", { d: glyphs[kind] }));
|
|
13
|
+
}
|
|
14
|
+
function SearchBox({ viewId, section, japanese, search }) {
|
|
15
|
+
const [query, setQuery] = React.useState('');
|
|
16
|
+
const [open, setOpen] = React.useState(false);
|
|
17
|
+
const [index, setIndex] = React.useState(-1);
|
|
18
|
+
const [focused, setFocused] = React.useState(false);
|
|
19
|
+
const results = open && focused && query.trim() ? search.find(query.trim(), section) : [];
|
|
20
|
+
const listId = viewId + '-search-results';
|
|
21
|
+
const selected = results[index];
|
|
22
|
+
const expanded = results.length > 0;
|
|
23
|
+
const optionId = (position) => listId + '-' + position;
|
|
24
|
+
function choose(result) {
|
|
25
|
+
setOpen(false);
|
|
26
|
+
setIndex(-1);
|
|
27
|
+
search.select(result, section);
|
|
28
|
+
}
|
|
29
|
+
return React.createElement("div", { className: "tw-plus-sprite-search" },
|
|
30
|
+
React.createElement("div", { className: "tw-plus-sprite-search-field" },
|
|
31
|
+
React.createElement(Icon, { kind: "search" }),
|
|
32
|
+
React.createElement("input", { type: "search", role: "combobox", autoComplete: "off", "aria-autocomplete": "list", "aria-label": japanese ? 'スプライト内を検索' : 'Search within this sprite', "aria-controls": expanded ? listId : undefined, "aria-expanded": expanded, "aria-activedescendant": selected ? optionId(index) : undefined, placeholder: japanese ? '検索' : 'Search', value: query, onFocus: () => { setFocused(true); setOpen(true); search.onFocus?.(); }, onBlur: () => { setFocused(false); setIndex(-1); }, onChange: event => { setQuery(event.currentTarget.value); setOpen(true); setIndex(-1); }, onKeyDown: event => {
|
|
33
|
+
if (event.key === 'Escape' && expanded) {
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
event.stopPropagation();
|
|
36
|
+
setOpen(false);
|
|
37
|
+
setIndex(-1);
|
|
38
|
+
}
|
|
39
|
+
else if (expanded && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
const offset = event.key === 'ArrowDown' ? 1 : -1;
|
|
42
|
+
setIndex(selected ? (index + offset + results.length) % results.length :
|
|
43
|
+
offset === 1 ? 0 : results.length - 1);
|
|
44
|
+
}
|
|
45
|
+
else if (event.key === 'Enter' && selected) {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
choose(selected);
|
|
48
|
+
}
|
|
49
|
+
} })),
|
|
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
|
+
}
|
|
52
|
+
export function SpriteEditorToolbar({ viewId, locale, section, onSectionChange, isStage, activeGroup, search, availableSections = spriteEditorSections, actions }) {
|
|
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' };
|
|
56
|
+
return React.createElement("div", { className: "tw-plus-sprite-toolbar", "data-active-group": activeGroup },
|
|
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
|
+
const direction = event.currentTarget.ownerDocument.dir === 'rtl' ? -1 : 1;
|
|
59
|
+
const next = { ArrowLeft: index - direction, ArrowRight: index + direction,
|
|
60
|
+
Home: 0, End: availableSections.length - 1 }[event.key];
|
|
61
|
+
if (next === undefined)
|
|
62
|
+
return;
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
const position = (next + availableSections.length) % availableSections.length;
|
|
65
|
+
onSectionChange(availableSections[position]);
|
|
66
|
+
event.currentTarget.parentElement.querySelectorAll('button')[position]?.focus();
|
|
67
|
+
} },
|
|
68
|
+
React.createElement(Icon, { kind: id }),
|
|
69
|
+
labels[id]))),
|
|
70
|
+
search && React.createElement(SearchBox, { viewId, section, japanese, search }),
|
|
71
|
+
actions);
|
|
72
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface SpriteLayoutCommands {
|
|
2
|
+
splitRight(): void;
|
|
3
|
+
splitBelow(): void;
|
|
4
|
+
close(): void;
|
|
5
|
+
canClose(): boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function createSpriteLayoutMenu(document: Document, locale: string, commands: SpriteLayoutCommands): {
|
|
8
|
+
element: HTMLDivElement;
|
|
9
|
+
dispose: () => void;
|
|
10
|
+
};
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const labels = {
|
|
2
|
+
ja: ['編集パネルのレイアウト', 'パネルを横に分割', 'パネルを縦に分割', 'この分割パネルを閉じる'],
|
|
3
|
+
en: ['Editor panel layout', 'Split right', 'Split below', 'Close this split']
|
|
4
|
+
};
|
|
5
|
+
export function createSpriteLayoutMenu(document, locale, commands) {
|
|
6
|
+
const [title, right, below, closeLabel] = /^ja(?:-|$)/i.test(locale) ? labels.ja : labels.en;
|
|
7
|
+
const element = document.createElement('div');
|
|
8
|
+
element.className = 'tw-plus-sprite-layout-menu';
|
|
9
|
+
const trigger = document.createElement('button');
|
|
10
|
+
trigger.type = 'button';
|
|
11
|
+
trigger.className = 'tw-plus-sprite-layout-menu-trigger';
|
|
12
|
+
trigger.title = title;
|
|
13
|
+
trigger.setAttribute('aria-label', title);
|
|
14
|
+
trigger.setAttribute('aria-haspopup', 'menu');
|
|
15
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
16
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
17
|
+
icon.setAttribute('viewBox', '0 0 16 16');
|
|
18
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
19
|
+
icon.setAttribute('fill', 'none');
|
|
20
|
+
icon.setAttribute('stroke', 'currentColor');
|
|
21
|
+
icon.setAttribute('stroke-width', '1.4');
|
|
22
|
+
const shape = document.createElementNS(icon.namespaceURI, 'path');
|
|
23
|
+
shape.setAttribute('d', 'M2.5 3.5h11v9h-11zM2.5 6.5h11m-7 0v6');
|
|
24
|
+
icon.append(shape);
|
|
25
|
+
trigger.append(icon);
|
|
26
|
+
element.append(trigger);
|
|
27
|
+
const popup = document.createElement('div');
|
|
28
|
+
popup.className = 'tw-plus-sprite-layout-menu-popup';
|
|
29
|
+
popup.setAttribute('role', 'menu');
|
|
30
|
+
popup.setAttribute('aria-label', title);
|
|
31
|
+
const buttons = [[right, commands.splitRight], [below, commands.splitBelow], [closeLabel, commands.close]].map(([label, run]) => {
|
|
32
|
+
const item = document.createElement('button');
|
|
33
|
+
item.type = 'button';
|
|
34
|
+
item.textContent = label;
|
|
35
|
+
item.tabIndex = -1;
|
|
36
|
+
item.setAttribute('role', 'menuitem');
|
|
37
|
+
item.addEventListener('click', () => {
|
|
38
|
+
close(true);
|
|
39
|
+
run();
|
|
40
|
+
});
|
|
41
|
+
return item;
|
|
42
|
+
});
|
|
43
|
+
popup.append(...buttons);
|
|
44
|
+
let openedIn;
|
|
45
|
+
let topLayer = false;
|
|
46
|
+
function close(restoreFocus = false) {
|
|
47
|
+
if (!openedIn)
|
|
48
|
+
return;
|
|
49
|
+
openedIn.removeEventListener('pointerdown', dismiss, true);
|
|
50
|
+
openedIn = undefined;
|
|
51
|
+
if (topLayer && popup.matches(':popover-open'))
|
|
52
|
+
popup.hidePopover();
|
|
53
|
+
popup.remove();
|
|
54
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
55
|
+
if (restoreFocus)
|
|
56
|
+
trigger.focus({ preventScroll: true });
|
|
57
|
+
}
|
|
58
|
+
function dismiss(event) {
|
|
59
|
+
const node = event.target;
|
|
60
|
+
if (!trigger.contains(node) && !popup.contains(node))
|
|
61
|
+
close();
|
|
62
|
+
}
|
|
63
|
+
function open(last = false) {
|
|
64
|
+
if (openedIn)
|
|
65
|
+
return;
|
|
66
|
+
openedIn = trigger.ownerDocument;
|
|
67
|
+
buttons[2].disabled = !commands.canClose();
|
|
68
|
+
topLayer = typeof popup.showPopover === 'function';
|
|
69
|
+
if (topLayer) {
|
|
70
|
+
popup.setAttribute('popover', 'manual');
|
|
71
|
+
element.append(popup);
|
|
72
|
+
popup.showPopover();
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
openedIn.body.append(popup);
|
|
76
|
+
}
|
|
77
|
+
const anchor = trigger.getBoundingClientRect();
|
|
78
|
+
const viewport = openedIn.documentElement;
|
|
79
|
+
const { width, height } = popup.getBoundingClientRect();
|
|
80
|
+
popup.style.left = Math.max(4, Math.min(anchor.right - width, viewport.clientWidth - width - 4)) + 'px';
|
|
81
|
+
popup.style.top = (anchor.bottom + height + 8 <= viewport.clientHeight ? anchor.bottom + 4 :
|
|
82
|
+
Math.max(4, anchor.top - height - 4)) + 'px';
|
|
83
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
84
|
+
openedIn.addEventListener('pointerdown', dismiss, true);
|
|
85
|
+
const enabled = buttons.filter(button => !button.disabled);
|
|
86
|
+
enabled[last ? enabled.length - 1 : 0]?.focus({ preventScroll: true });
|
|
87
|
+
}
|
|
88
|
+
trigger.addEventListener('click', () => openedIn ? close(true) : open());
|
|
89
|
+
trigger.addEventListener('keydown', event => {
|
|
90
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
|
|
91
|
+
return;
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
open(event.key === 'ArrowUp');
|
|
94
|
+
});
|
|
95
|
+
popup.addEventListener('focusout', event => {
|
|
96
|
+
if (!popup.contains(event.relatedTarget))
|
|
97
|
+
close();
|
|
98
|
+
});
|
|
99
|
+
popup.addEventListener('keydown', event => {
|
|
100
|
+
if (event.key === 'Escape') {
|
|
101
|
+
event.stopPropagation();
|
|
102
|
+
close(true);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const enabled = buttons.filter(button => !button.disabled);
|
|
106
|
+
const index = enabled.indexOf(event.target);
|
|
107
|
+
const next = { ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: enabled.length - 1 }[event.key];
|
|
108
|
+
if (next === undefined)
|
|
109
|
+
return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
enabled[(next + enabled.length) % enabled.length]?.focus({ preventScroll: true });
|
|
112
|
+
});
|
|
113
|
+
return { element, dispose: () => close() };
|
|
114
|
+
}
|
package/dist/sprite-tabs.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import type { SpriteVariable } from './sprite-asset-pane.tsx';
|
|
3
|
+
import type { SpriteSearchResult } from './sprite-editor-toolbar.tsx';
|
|
2
4
|
interface CostumeAsset {
|
|
3
5
|
encodeDataURI(): string;
|
|
4
6
|
}
|
|
@@ -17,19 +19,83 @@ interface Project {
|
|
|
17
19
|
targets: Target[];
|
|
18
20
|
};
|
|
19
21
|
setEditingTarget(id: string): void;
|
|
20
|
-
renameSprite(id: string, name: string): void;
|
|
22
|
+
renameSprite?(id: string, name: string): void;
|
|
21
23
|
on(event: 'targetsUpdate', listener: () => void): void;
|
|
22
24
|
removeListener(event: 'targetsUpdate', listener: () => void): void;
|
|
23
25
|
}
|
|
24
|
-
interface
|
|
26
|
+
export interface SpriteTabView {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly targetId: string;
|
|
29
|
+
readonly isStage: boolean;
|
|
30
|
+
readonly section: number;
|
|
31
|
+
readonly groupId: string;
|
|
32
|
+
readonly active: boolean;
|
|
33
|
+
readonly groupActive: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface SpriteTabAdapter<Snapshot = unknown> {
|
|
36
|
+
/** Read the state of the mounted surface; the host's selected target may have changed already. */
|
|
37
|
+
capture(view: SpriteTabView): Snapshot;
|
|
38
|
+
renderInactive(container: HTMLElement, view: SpriteTabView, snapshot: Snapshot | undefined): void | (() => void);
|
|
39
|
+
restore(view: SpriteTabView, snapshot: Snapshot | undefined): void;
|
|
40
|
+
dispose?(view: SpriteTabView, snapshot: Snapshot): void;
|
|
41
|
+
}
|
|
42
|
+
/** Optional bridge used by a native asset editor to restore and report its selection. */
|
|
43
|
+
export interface SpriteAssetSelection {
|
|
44
|
+
restore(target: object, assets: readonly object[]): number | undefined;
|
|
45
|
+
remember(target: object, asset: object | undefined, select?: (index: number) => void): void;
|
|
46
|
+
rememberLayout?(render: SpriteAssetLayout): void;
|
|
47
|
+
getScrollTop?(): number;
|
|
48
|
+
rememberScrollTop?(position: number): void;
|
|
49
|
+
}
|
|
50
|
+
export type SpriteAssetKind = 'costumes' | 'sounds';
|
|
51
|
+
type AssetKind = SpriteAssetKind;
|
|
52
|
+
export interface SpriteAssetPreviewItem {
|
|
53
|
+
asset: object;
|
|
54
|
+
name: string;
|
|
55
|
+
imageUrl?: string;
|
|
56
|
+
buffer?: AudioBuffer;
|
|
57
|
+
duration?: number;
|
|
58
|
+
}
|
|
59
|
+
type SpriteAssetLayout = (items: readonly SpriteAssetPreviewItem[], index: number, detail: React.ReactNode, scrollTop: number) => React.ReactNode;
|
|
60
|
+
export interface SpriteAssetPreviewData {
|
|
61
|
+
items: readonly SpriteAssetPreviewItem[];
|
|
62
|
+
defaultAsset?: object;
|
|
63
|
+
}
|
|
64
|
+
type AssetSelections = Readonly<Record<AssetKind, SpriteAssetSelection>>;
|
|
65
|
+
export type SpriteSplitDirection = 'right' | 'below';
|
|
66
|
+
interface TabOptions<Snapshot = unknown> {
|
|
25
67
|
vm: Project;
|
|
26
68
|
getSection(): number;
|
|
27
69
|
activateSection(section: number): void;
|
|
28
|
-
selectTarget(id: string): void;
|
|
70
|
+
selectTarget(id: string, assetSelections?: AssetSelections): void;
|
|
29
71
|
resize(): void;
|
|
30
|
-
park?(): void;
|
|
31
72
|
reopenLabel: string;
|
|
73
|
+
multiView?: boolean;
|
|
74
|
+
variables?: boolean;
|
|
75
|
+
locale?: string;
|
|
76
|
+
viewAdapter?: SpriteTabAdapter<Snapshot>;
|
|
77
|
+
park?(): void;
|
|
32
78
|
renameLabels?: RenameLabels;
|
|
79
|
+
onToolbarSlots?(slots: readonly ToolbarSlot[]): void;
|
|
80
|
+
onAssetPreviewSlots?(slots: readonly AssetPreviewSlot[]): void;
|
|
81
|
+
}
|
|
82
|
+
interface ToolbarActions {
|
|
83
|
+
activate(): void;
|
|
84
|
+
setSection(section: number): void;
|
|
85
|
+
}
|
|
86
|
+
interface ToolbarSlot {
|
|
87
|
+
view: SpriteTabView;
|
|
88
|
+
element: HTMLElement;
|
|
89
|
+
}
|
|
90
|
+
interface AssetPreviewSlot {
|
|
91
|
+
id: string;
|
|
92
|
+
targetId: string;
|
|
93
|
+
kind: AssetKind | 'variables';
|
|
94
|
+
editable: boolean;
|
|
95
|
+
selected?: object;
|
|
96
|
+
renderLayout?: SpriteAssetLayout;
|
|
97
|
+
scrollTop?: number;
|
|
98
|
+
element: HTMLElement;
|
|
33
99
|
}
|
|
34
100
|
declare const renameLabels: {
|
|
35
101
|
en: {
|
|
@@ -46,36 +112,63 @@ declare const renameLabels: {
|
|
|
46
112
|
};
|
|
47
113
|
};
|
|
48
114
|
type RenameLabels = typeof renameLabels.en;
|
|
49
|
-
export declare function mountSpriteTabs(root: HTMLElement, content: HTMLElement, options: TabOptions): {
|
|
115
|
+
export declare function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElement, options: TabOptions<Snapshot>): {
|
|
50
116
|
select: (id: string) => void;
|
|
117
|
+
split: (direction: SpriteSplitDirection, id?: string) => string;
|
|
118
|
+
closeSplit: (id?: string) => boolean;
|
|
119
|
+
activateView: (id: string) => boolean;
|
|
120
|
+
setViewSection: (id: string, section: number) => boolean;
|
|
121
|
+
syncActiveSection: (section: number) => void;
|
|
122
|
+
refreshSurfaces: () => void;
|
|
123
|
+
selectAsset(id: string, kind: AssetKind, index: number): void;
|
|
124
|
+
currentView: () => SpriteTabView;
|
|
125
|
+
views: () => SpriteTabView[];
|
|
51
126
|
dispose(): void;
|
|
52
127
|
};
|
|
53
|
-
|
|
128
|
+
type SpriteTabsController<Snapshot> = ReturnType<typeof mountSpriteTabs<Snapshot>>;
|
|
129
|
+
type SpriteTabsProps<Snapshot> = Omit<TabOptions<Snapshot>, 'activateSection' | 'getSection' | 'park' | 'onToolbarSlots' | 'onAssetPreviewSlots'> & {
|
|
130
|
+
children: React.ReactNode;
|
|
131
|
+
activateSection?(section: number): void;
|
|
132
|
+
slots: HTMLElement[];
|
|
133
|
+
onAttach(): void;
|
|
54
134
|
enabled: boolean;
|
|
55
135
|
activeSection: number;
|
|
56
|
-
vm: Project;
|
|
57
136
|
className: string;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
137
|
+
connect(controller: SpriteTabsController<Snapshot> | null): void;
|
|
138
|
+
activeTools?: React.ReactNode;
|
|
139
|
+
renderToolbar?(view: SpriteTabView, actions: ToolbarActions): React.ReactNode;
|
|
140
|
+
getAssetPreview?(targetId: string, kind: SpriteAssetKind): SpriteAssetPreviewData;
|
|
141
|
+
getVariables?(targetId: string): readonly SpriteVariable[];
|
|
142
|
+
writeVariable?(targetId: string, id: string, value: SpriteVariable['value']): void;
|
|
143
|
+
getCodeSearchItems?(targetId: string): readonly SpriteSearchResult[];
|
|
144
|
+
openCodeSearchResult?(targetId: string, id: string): void;
|
|
145
|
+
};
|
|
146
|
+
export declare function useSpriteTabs<Snapshot>({ available, enabled, locale, ...props }: Omit<SpriteTabsProps<Snapshot>, 'children' | 'connect' | 'selectTarget' | 'reopenLabel' | 'renameLabels' | 'slots' | 'onAttach' | 'activateSection'> & {
|
|
68
147
|
available: boolean;
|
|
69
148
|
locale: string;
|
|
70
|
-
|
|
71
|
-
|
|
149
|
+
activateSection?(section: number): void;
|
|
150
|
+
}): {
|
|
151
|
+
controller: React.MutableRefObject<{
|
|
152
|
+
select: (id: string) => void;
|
|
153
|
+
split: (direction: SpriteSplitDirection, id?: string) => string;
|
|
154
|
+
closeSplit: (id?: string) => boolean;
|
|
155
|
+
activateView: (id: string) => boolean;
|
|
156
|
+
setViewSection: (id: string, section: number) => boolean;
|
|
157
|
+
syncActiveSection: (section: number) => void;
|
|
158
|
+
refreshSurfaces: () => void;
|
|
159
|
+
selectAsset(id: string, kind: AssetKind, index: number): void;
|
|
160
|
+
currentView: () => SpriteTabView;
|
|
161
|
+
views: () => SpriteTabView[];
|
|
162
|
+
dispose(): void;
|
|
163
|
+
}>;
|
|
164
|
+
assetSelections: Readonly<Record<SpriteAssetKind, SpriteAssetSelection>>;
|
|
72
165
|
context: {
|
|
73
166
|
selectedId: string;
|
|
74
167
|
openLabel: string;
|
|
75
168
|
select: (id: string) => void;
|
|
76
169
|
open: (id: string) => void;
|
|
77
170
|
};
|
|
78
|
-
renderEditorTabRow: (children: React.ReactNode) => React.FunctionComponentElement<SpriteTabsProps
|
|
171
|
+
renderEditorTabRow: (children: React.ReactNode) => React.FunctionComponentElement<SpriteTabsProps<Snapshot>>;
|
|
79
172
|
renderEditorPanel: (index: number, children: React.ReactNode) => React.ReactPortal;
|
|
80
173
|
};
|
|
81
174
|
export {};
|