@kubohiroya/turbowarp-plus-editor-layout 0.1.1 → 0.1.3
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/dist/stage-surface.d.ts +31 -0
- package/dist/stage-surface.js +81 -0
- package/dist/standard-layout.d.ts +2 -1
- package/dist/standard-layout.js +9 -6
- package/package.json +16 -1
- package/src/editor.css +97 -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/src/stage-surface.ts +104 -0
- package/src/standard-layout.ts +10 -6
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Context, ElementType } from 'react';
|
|
2
|
+
interface StageWindowConnection<Target> {
|
|
3
|
+
ready: Promise<Target>;
|
|
4
|
+
disconnected: AbortSignal;
|
|
5
|
+
close(): void;
|
|
6
|
+
}
|
|
7
|
+
interface StageWindowHost<Request, Target> {
|
|
8
|
+
open(request: Request): StageWindowConnection<Target> | null;
|
|
9
|
+
}
|
|
10
|
+
export declare function createStagePopoutHost<Request, Target extends {
|
|
11
|
+
mount: HTMLElement;
|
|
12
|
+
}>(host: StageWindowHost<Request, Target>, title: string): StageWindowHost<Request, Target>;
|
|
13
|
+
export declare function stageDockActionLabel(locale: string, { drawer, detached, external }: {
|
|
14
|
+
drawer?: boolean;
|
|
15
|
+
detached: boolean;
|
|
16
|
+
external?: boolean;
|
|
17
|
+
}): "ドックに戻す" | "Dock stage" | "別窓で開く" | "別ウィンドウで開く" | "Open in a separate window" | "アンドック" | "Undock stage";
|
|
18
|
+
export declare function createStageSurface(React: typeof import('react'), { context, EdgeDrawer, Panel }: {
|
|
19
|
+
context: Context<{
|
|
20
|
+
locale: string;
|
|
21
|
+
stageDrawer?: boolean;
|
|
22
|
+
setStageDrawerOpen?: (open: boolean) => void;
|
|
23
|
+
}>;
|
|
24
|
+
EdgeDrawer: ElementType;
|
|
25
|
+
Panel: ElementType;
|
|
26
|
+
}): ({ externalHost, onDockActionChange, ...props }: {
|
|
27
|
+
[x: string]: any;
|
|
28
|
+
externalHost: any;
|
|
29
|
+
onDockActionChange: any;
|
|
30
|
+
}) => import("react").ReactElement<any, string | import("react").JSXElementConstructor<any>>;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createDockview, themeLight } from 'dockview';
|
|
2
|
+
export function createStagePopoutHost(host, title) {
|
|
3
|
+
return {
|
|
4
|
+
open(request) {
|
|
5
|
+
const connection = host.open(request);
|
|
6
|
+
if (!connection)
|
|
7
|
+
return null;
|
|
8
|
+
const disconnected = new AbortController();
|
|
9
|
+
const disconnect = () => disconnected.abort();
|
|
10
|
+
connection.disconnected.addEventListener('abort', disconnect, { once: true });
|
|
11
|
+
if (connection.disconnected.aborted)
|
|
12
|
+
disconnect();
|
|
13
|
+
let dockview;
|
|
14
|
+
let removed;
|
|
15
|
+
let closed = false;
|
|
16
|
+
const ready = connection.ready.then(target => {
|
|
17
|
+
if (closed || disconnected.signal.aborted)
|
|
18
|
+
throw new Error('Stage window closed');
|
|
19
|
+
const content = target.mount.ownerDocument.createElement('div');
|
|
20
|
+
Object.assign(content.style, { width: '100%', height: '100%' });
|
|
21
|
+
dockview = createDockview(target.mount, {
|
|
22
|
+
createComponent: () => ({ element: content, init() { } }),
|
|
23
|
+
theme: themeLight,
|
|
24
|
+
disableDnd: true,
|
|
25
|
+
disableFloatingGroups: true
|
|
26
|
+
});
|
|
27
|
+
dockview.addPanel({ id: 'stage', component: 'stage', title });
|
|
28
|
+
removed = dockview.onDidRemovePanel(disconnect);
|
|
29
|
+
return { ...target, mount: content };
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
ready,
|
|
33
|
+
disconnected: disconnected.signal,
|
|
34
|
+
close() {
|
|
35
|
+
if (closed)
|
|
36
|
+
return;
|
|
37
|
+
closed = true;
|
|
38
|
+
connection.disconnected.removeEventListener('abort', disconnect);
|
|
39
|
+
try {
|
|
40
|
+
removed?.dispose();
|
|
41
|
+
dockview?.dispose();
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
connection.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const isJapanese = (locale) => /^ja(?:-|$)/i.test(locale);
|
|
52
|
+
export function stageDockActionLabel(locale, { drawer, detached, external }) {
|
|
53
|
+
const japanese = isJapanese(locale);
|
|
54
|
+
if (detached)
|
|
55
|
+
return japanese ? 'ドックに戻す' : 'Dock stage';
|
|
56
|
+
if (external)
|
|
57
|
+
return japanese ? (drawer ? '別窓で開く' : '別ウィンドウで開く') : 'Open in a separate window';
|
|
58
|
+
return japanese ? 'アンドック' : 'Undock stage';
|
|
59
|
+
}
|
|
60
|
+
export function createStageSurface(React, { context, EdgeDrawer, Panel }) {
|
|
61
|
+
return function StageSurface({ externalHost, onDockActionChange, ...props }) {
|
|
62
|
+
const { locale, stageDrawer, setStageDrawerOpen } = React.useContext(context);
|
|
63
|
+
const japanese = isJapanese(locale);
|
|
64
|
+
const title = japanese ? 'ステージ' : 'Stage';
|
|
65
|
+
const host = React.useMemo(() => stageDrawer && externalHost ?
|
|
66
|
+
createStagePopoutHost(externalHost, title) : externalHost, [stageDrawer, externalHost, title]);
|
|
67
|
+
const panel = React.createElement(Panel, {
|
|
68
|
+
...props,
|
|
69
|
+
id: 'stage',
|
|
70
|
+
title,
|
|
71
|
+
externalHost: host,
|
|
72
|
+
externalOnly: stageDrawer,
|
|
73
|
+
onDockActionChange: stageDrawer && !host ? undefined : onDockActionChange
|
|
74
|
+
});
|
|
75
|
+
return stageDrawer ? React.createElement(EdgeDrawer, {
|
|
76
|
+
edge: 'right',
|
|
77
|
+
label: japanese ? 'ステージを展開' : 'Reveal stage',
|
|
78
|
+
onOpenChange: setStageDrawerOpen
|
|
79
|
+
}, panel) : panel;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -4,12 +4,13 @@ export declare function createStandardLayout(React: any, { styles, targetStyles,
|
|
|
4
4
|
context: any;
|
|
5
5
|
LayoutMenu: any;
|
|
6
6
|
}): {
|
|
7
|
-
StandardLayout: ({ enabled, splitter, windowLayout, stageActionEnabled, onPaletteModeChange, windowTools, Container, render, ...props }: {
|
|
7
|
+
StandardLayout: ({ enabled, splitter, windowLayout, stageActionEnabled, stageDrawer, onPaletteModeChange, windowTools, Container, render, ...props }: {
|
|
8
8
|
[x: string]: any;
|
|
9
9
|
enabled: any;
|
|
10
10
|
splitter: any;
|
|
11
11
|
windowLayout: any;
|
|
12
12
|
stageActionEnabled: any;
|
|
13
|
+
stageDrawer?: boolean;
|
|
13
14
|
onPaletteModeChange: any;
|
|
14
15
|
windowTools: any;
|
|
15
16
|
Container: any;
|
package/dist/standard-layout.js
CHANGED
|
@@ -10,10 +10,11 @@ export function createStandardLayout(React, { styles, targetStyles, context, Lay
|
|
|
10
10
|
const targets = createTargetList(React, targetStyles);
|
|
11
11
|
const targetSearch = React.createContext('');
|
|
12
12
|
const targetTools = React.createContext(null);
|
|
13
|
-
function StandardLayout({ enabled, splitter, windowLayout, stageActionEnabled, onPaletteModeChange, windowTools, Container, render, ...props }) {
|
|
13
|
+
function StandardLayout({ enabled, splitter, windowLayout, stageActionEnabled, stageDrawer = false, onPaletteModeChange, windowTools, Container, render, ...props }) {
|
|
14
14
|
const [showUndock, setShowUndock] = useState(false);
|
|
15
15
|
const [targetsPinned, setTargetsPinned] = useState(false);
|
|
16
16
|
const [showStage, setShowStage] = useState(true);
|
|
17
|
+
const [stageDrawerOpen, setStageDrawerOpen] = useState(false);
|
|
17
18
|
const [size, setSize] = useState(null);
|
|
18
19
|
const [targetRatio, setTargetRatio] = useState(null);
|
|
19
20
|
const [preferredStageWidth, setPreferredStageWidth] = useState(null);
|
|
@@ -83,10 +84,11 @@ export function createStandardLayout(React, { styles, targetStyles, context, Lay
|
|
|
83
84
|
if (!plusLayout)
|
|
84
85
|
return shared;
|
|
85
86
|
const stageOverlap = `${stageWidth + 20}px`;
|
|
87
|
+
const stageOverlaps = showStage && (!stageDrawer || stageDrawerOpen);
|
|
86
88
|
const bodyStyle = {
|
|
87
89
|
display: 'grid',
|
|
88
|
-
'--plus-stage-overlap':
|
|
89
|
-
'--plus-stage-tab-inset':
|
|
90
|
+
'--plus-stage-overlap': stageOverlaps ? stageOverlap : '0px',
|
|
91
|
+
'--plus-stage-tab-inset': stageOverlaps ? stageOverlap : '3rem'
|
|
90
92
|
};
|
|
91
93
|
if (resizable) {
|
|
92
94
|
bodyStyle['--plus-target-width'] = `${targetSplit.first}px`;
|
|
@@ -106,7 +108,7 @@ export function createStandardLayout(React, { styles, targetStyles, context, Lay
|
|
|
106
108
|
columnSplitter,
|
|
107
109
|
editorStyle: { gridArea: 'editor', minWidth: 0, minHeight: 0 },
|
|
108
110
|
stageColumnStyle: { display: 'contents' },
|
|
109
|
-
stageClassName: `${styles.stagePanel} ${styles.floatingStage}${stageHidden ? ` ${styles.stageHidden}` : ''}`,
|
|
111
|
+
stageClassName: `${styles.stagePanel} ${styles.floatingStage}${stageDrawer ? ` ${styles.stageDrawer}` : ''}${stageHidden ? ` ${styles.stageHidden}` : ''}`,
|
|
110
112
|
targetClassName: targetsPinned ? styles.standardTargets :
|
|
111
113
|
`${styles.standardTargets} ${styles.retractedTargets}`,
|
|
112
114
|
stageDisplayWidth: stageWidth,
|
|
@@ -130,7 +132,8 @@ export function createStandardLayout(React, { styles, targetStyles, context, Lay
|
|
|
130
132
|
render: slots => render(applyStandardLayout(slots), showUndock)
|
|
131
133
|
});
|
|
132
134
|
return element(context.Provider, { value: {
|
|
133
|
-
showUndock, plusLayout, targetsPinned, setTargetsPinned, stageHidden, setStageVisible: setShowStage, stageResize, stageActionMount,
|
|
135
|
+
showUndock, plusLayout, targetsPinned, setTargetsPinned, stageHidden, setStageVisible: setShowStage, stageResize, stageActionMount,
|
|
136
|
+
stageDrawer: plusLayout && stageDrawer, setStageDrawerOpen, locale: props.locale
|
|
134
137
|
} }, Container ? element(Container, { locale: props.locale }, layout) : layout);
|
|
135
138
|
}
|
|
136
139
|
function EdgeDrawer({ edge, label, pinned = false, onOpenChange, children }) {
|
|
@@ -180,7 +183,7 @@ export function createStandardLayout(React, { styles, targetStyles, context, Lay
|
|
|
180
183
|
panel.current.focus({ preventScroll: true });
|
|
181
184
|
});
|
|
182
185
|
}
|
|
183
|
-
},
|
|
186
|
+
}, { left: '›', right: '‹', bottom: '⌃' }[edge]), element('div', { className: styles.edgePanel, ref: panel, tabIndex: -1, hidden: !open }, children));
|
|
184
187
|
}
|
|
185
188
|
function TargetLayout({ compact, render }) {
|
|
186
189
|
const { plusLayout, targetsPinned, locale } = useContext(context);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubohiroya/turbowarp-plus-editor-layout",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Resizable panes and layout for TurboWarp Plus Editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"types": "./dist/standard-layout.d.ts",
|
|
14
14
|
"default": "./dist/standard-layout.js"
|
|
15
15
|
},
|
|
16
|
+
"./stage-surface": {
|
|
17
|
+
"types": "./dist/stage-surface.d.ts",
|
|
18
|
+
"default": "./dist/stage-surface.js"
|
|
19
|
+
},
|
|
16
20
|
"./editor.css": "./src/editor.css",
|
|
17
21
|
"./targets.css": "./src/targets.css",
|
|
18
22
|
"./sprite-properties": {
|
|
@@ -29,6 +33,17 @@
|
|
|
29
33
|
"types": "./dist/sprite-tabs.d.ts",
|
|
30
34
|
"default": "./dist/sprite-tabs.js"
|
|
31
35
|
},
|
|
36
|
+
"./sprite-asset-pane": {
|
|
37
|
+
"types": "./dist/sprite-asset-pane.d.ts",
|
|
38
|
+
"default": "./dist/sprite-asset-pane.js"
|
|
39
|
+
},
|
|
40
|
+
"./sprite-asset-pane.css": "./src/sprite-asset-pane.css",
|
|
41
|
+
"./sprite-editor-toolbar": {
|
|
42
|
+
"types": "./dist/sprite-editor-toolbar.d.ts",
|
|
43
|
+
"default": "./dist/sprite-editor-toolbar.js"
|
|
44
|
+
},
|
|
45
|
+
"./sprite-editor-toolbar.css": "./src/sprite-editor-toolbar.css",
|
|
46
|
+
"./sprite-layout-menu.css": "./src/sprite-layout-menu.css",
|
|
32
47
|
"./dockview-styles": "./dist/dockview-styles.js",
|
|
33
48
|
"./extension-file-menu": {
|
|
34
49
|
"types": "./dist/extension-file-menu.d.ts",
|
package/src/editor.css
CHANGED
|
@@ -327,6 +327,10 @@
|
|
|
327
327
|
z-index: 90;
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
.standard-body .stage-drawer {
|
|
331
|
+
right: 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
330
334
|
.standard-body {
|
|
331
335
|
--plus-extension-button-width: 2.75rem;
|
|
332
336
|
--plus-extension-button-gap: 0.75rem;
|
|
@@ -470,6 +474,25 @@ details.layout-menu button {
|
|
|
470
474
|
pointer-events: none;
|
|
471
475
|
}
|
|
472
476
|
|
|
477
|
+
.edge-drawer[data-edge="right"] {
|
|
478
|
+
flex-direction: row-reverse;
|
|
479
|
+
pointer-events: none;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
[dir="rtl"] .edge-drawer[data-edge="right"] {
|
|
483
|
+
flex-direction: row;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.edge-drawer[data-edge="right"] .edge-trigger {
|
|
487
|
+
width: 0.625rem;
|
|
488
|
+
min-height: 3rem;
|
|
489
|
+
visibility: visible;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.edge-drawer[data-edge="right"] .edge-panel {
|
|
493
|
+
background: transparent;
|
|
494
|
+
}
|
|
495
|
+
|
|
473
496
|
.edge-drawer[data-edge="bottom"] {
|
|
474
497
|
z-index: var(--plus-docked-utility-layer);
|
|
475
498
|
flex: none;
|
|
@@ -603,11 +626,31 @@ details.layout-menu button {
|
|
|
603
626
|
flex: 1 1 0;
|
|
604
627
|
min-width: 0;
|
|
605
628
|
min-height: 0;
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
--dv-
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.sprite-tabs :global(.dockview-theme-light) {
|
|
632
|
+
color-scheme: var(--color-scheme, light);
|
|
633
|
+
--dv-group-view-background-color: var(--ui-primary, #fff);
|
|
634
|
+
--dv-tabs-and-actions-container-background-color: var(--ui-primary, #f3f3f3);
|
|
635
|
+
--dv-activegroup-visiblepanel-tab-background-color: var(--ui-white, #fff);
|
|
636
|
+
--dv-activegroup-visiblepanel-tab-color: var(--text-primary, #333);
|
|
637
|
+
--dv-activegroup-hiddenpanel-tab-background-color: var(--ui-secondary, #ececec);
|
|
638
|
+
--dv-activegroup-hiddenpanel-tab-color: var(--text-primary, #333);
|
|
639
|
+
--dv-inactivegroup-visiblepanel-tab-background-color: var(--ui-tertiary, #eee);
|
|
640
|
+
--dv-inactivegroup-visiblepanel-tab-color: var(--text-primary, #333);
|
|
641
|
+
--dv-inactivegroup-hiddenpanel-tab-background-color: var(--ui-secondary, #ececec);
|
|
642
|
+
--dv-inactivegroup-hiddenpanel-tab-color: var(--text-primary, #333);
|
|
643
|
+
--dv-sash-color: var(--ui-black-transparent, #0002);
|
|
644
|
+
--dv-active-sash-color: var(--looks-secondary, #855cd6);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.standard-body:has(.sprite-tabs[data-multi-view]) .floating-stage {
|
|
648
|
+
top: 5.5rem;
|
|
649
|
+
height: fit-content;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.standard-body:has(.sprite-tabs[data-multi-view]):has(.retracted-targets) {
|
|
653
|
+
padding-left: 0.625rem;
|
|
611
654
|
}
|
|
612
655
|
|
|
613
656
|
.sprite-tabs > div,
|
|
@@ -649,6 +692,55 @@ details.layout-menu button {
|
|
|
649
692
|
min-height: 0;
|
|
650
693
|
}
|
|
651
694
|
|
|
695
|
+
.sprite-tabs :global(.tw-plus-sprite-tab-slot--split) {
|
|
696
|
+
display: flex;
|
|
697
|
+
flex-direction: column;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
.sprite-tabs :global(.tw-plus-sprite-tab-body) {
|
|
701
|
+
flex: 1 1 0;
|
|
702
|
+
min-width: 0;
|
|
703
|
+
min-height: 0;
|
|
704
|
+
overflow: auto;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.sprite-tabs :global(.tw-plus-sprite-tab-asset-preview) {
|
|
708
|
+
display: flex;
|
|
709
|
+
flex-direction: column;
|
|
710
|
+
height: 100%;
|
|
711
|
+
min-height: 0;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.sprite-tabs :global(.tw-plus-sprite-tab-editor[hidden]),
|
|
715
|
+
.sprite-tabs :global(.tw-plus-sprite-tab-asset-preview[hidden]) { display: none; }
|
|
716
|
+
|
|
717
|
+
.sprite-tabs :global([data-layout-asset-detail]) {
|
|
718
|
+
min-inline-size: 0;
|
|
719
|
+
min-block-size: 0;
|
|
720
|
+
overflow: auto;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.sprite-tabs :global([data-layout-asset-panel]) {
|
|
724
|
+
box-sizing: border-box;
|
|
725
|
+
inline-size: 100%;
|
|
726
|
+
block-size: 100%;
|
|
727
|
+
min-inline-size: 0;
|
|
728
|
+
min-block-size: 0;
|
|
729
|
+
flex-basis: 0;
|
|
730
|
+
overflow: hidden;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.sprite-tabs :global([data-layout-asset-list]) {
|
|
734
|
+
flex: 0 0 auto;
|
|
735
|
+
max-inline-size: 45%;
|
|
736
|
+
min-block-size: 0;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.sprite-tabs :global(.tw-plus-sprite-tab-slot:focus-within) .open-window-action {
|
|
740
|
+
opacity: 1;
|
|
741
|
+
pointer-events: auto;
|
|
742
|
+
}
|
|
743
|
+
|
|
652
744
|
/* The native tab panel shells remain mounted for the host's tab selection. */
|
|
653
745
|
:global([class*="gui_tabs_"]):has(> .sprite-tabs) > :global([role="tabpanel"]) {
|
|
654
746
|
display: none;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
.tw-sprite-media,
|
|
2
|
+
.tw-sprite-variables {
|
|
3
|
+
box-sizing: border-box;
|
|
4
|
+
block-size: 100%;
|
|
5
|
+
min-block-size: 0;
|
|
6
|
+
min-inline-size: 0;
|
|
7
|
+
color: var(--text-primary, #273146);
|
|
8
|
+
background: var(--ui-white, #fff);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.tw-sprite-media {
|
|
12
|
+
display: grid;
|
|
13
|
+
grid-template-columns: minmax(3.25rem, 24%) minmax(0, 1fr);
|
|
14
|
+
grid-template-rows: minmax(0, 1fr);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.tw-sprite-media__list {
|
|
18
|
+
margin: 0;
|
|
19
|
+
padding: .35rem;
|
|
20
|
+
overflow: auto;
|
|
21
|
+
list-style: none;
|
|
22
|
+
border-inline-end: 1px solid var(--ui-black-transparent, #ccd5e2);
|
|
23
|
+
background: var(--ui-secondary, #edf3fc);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.tw-sprite-media__list li + li {
|
|
27
|
+
margin-block-start: .35rem;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.tw-sprite-media__item {
|
|
31
|
+
box-sizing: border-box;
|
|
32
|
+
display: grid;
|
|
33
|
+
inline-size: 100%;
|
|
34
|
+
justify-items: center;
|
|
35
|
+
gap: .25rem;
|
|
36
|
+
padding: .35rem;
|
|
37
|
+
border: 1px solid var(--ui-black-transparent, #ccd5e2);
|
|
38
|
+
border-radius: .375rem;
|
|
39
|
+
color: inherit;
|
|
40
|
+
background: var(--ui-white, #fff);
|
|
41
|
+
font: inherit;
|
|
42
|
+
text-align: start;
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.tw-sprite-media__item[aria-disabled] {
|
|
47
|
+
cursor: default;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.tw-sprite-media__item[aria-current] {
|
|
51
|
+
border-color: var(--looks-secondary, #7759cf);
|
|
52
|
+
box-shadow: inset 0 0 0 1px var(--looks-secondary, #7759cf);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.tw-sprite-media__thumbnail {
|
|
56
|
+
inline-size: 100%;
|
|
57
|
+
block-size: 2rem;
|
|
58
|
+
object-fit: contain;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.tw-sprite-media__symbol {
|
|
62
|
+
display: grid;
|
|
63
|
+
place-items: center;
|
|
64
|
+
block-size: 2rem;
|
|
65
|
+
font-size: 1.35rem;
|
|
66
|
+
color: var(--looks-secondary, #7759cf);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.tw-sprite-media__name {
|
|
70
|
+
max-inline-size: 100%;
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
text-overflow: ellipsis;
|
|
73
|
+
white-space: nowrap;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.tw-sprite-media__detail {
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
gap: .5rem;
|
|
80
|
+
padding: .6rem;
|
|
81
|
+
overflow: auto;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.tw-sprite-media__detail > p,
|
|
85
|
+
.tw-sprite-media__title {
|
|
86
|
+
margin: 0;
|
|
87
|
+
overflow-wrap: anywhere;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.tw-sprite-media__title {
|
|
91
|
+
font-size: 1em;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.tw-sprite-media__surface {
|
|
95
|
+
display: grid;
|
|
96
|
+
flex: 1;
|
|
97
|
+
min-block-size: 3rem;
|
|
98
|
+
min-inline-size: 0;
|
|
99
|
+
place-items: center;
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
border: 1px solid var(--ui-black-transparent, #ccd5e2);
|
|
102
|
+
border-radius: .375rem;
|
|
103
|
+
background: var(--ui-secondary, #edf3fc);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.tw-sprite-media__preview,
|
|
107
|
+
.tw-sprite-media__waveform {
|
|
108
|
+
display: block;
|
|
109
|
+
inline-size: 100%;
|
|
110
|
+
block-size: 100%;
|
|
111
|
+
min-block-size: 0;
|
|
112
|
+
object-fit: contain;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.tw-sprite-media__axis,
|
|
116
|
+
.tw-sprite-media__peaks {
|
|
117
|
+
fill: none;
|
|
118
|
+
stroke: var(--looks-secondary, #7759cf);
|
|
119
|
+
stroke-width: 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.tw-sprite-media__axis {
|
|
123
|
+
opacity: .4;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.tw-sprite-media__duration {
|
|
127
|
+
text-align: end;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.tw-sprite-variables {
|
|
131
|
+
padding: .6rem;
|
|
132
|
+
overflow: auto;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.tw-sprite-variables__table {
|
|
136
|
+
inline-size: 100%;
|
|
137
|
+
table-layout: fixed;
|
|
138
|
+
border-collapse: collapse;
|
|
139
|
+
text-align: start;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.tw-sprite-variables__table th,
|
|
143
|
+
.tw-sprite-variables__table td {
|
|
144
|
+
padding: .35rem;
|
|
145
|
+
border-block-end: 1px solid var(--ui-black-transparent, #ccd5e2);
|
|
146
|
+
overflow-wrap: anywhere;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.tw-sprite-variables__scope {
|
|
150
|
+
display: block;
|
|
151
|
+
font-size: .8em;
|
|
152
|
+
font-weight: normal;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.tw-sprite-variables__input {
|
|
156
|
+
box-sizing: border-box;
|
|
157
|
+
inline-size: 100%;
|
|
158
|
+
padding: .35rem .5rem;
|
|
159
|
+
border: 1px solid var(--ui-black-transparent, #ccd5e2);
|
|
160
|
+
border-radius: .25rem;
|
|
161
|
+
color: inherit;
|
|
162
|
+
background: var(--input-background, #fff);
|
|
163
|
+
font: inherit;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.tw-sprite-media--detail {
|
|
167
|
+
inline-size: 100%;
|
|
168
|
+
grid-template-columns: minmax(0, 1fr);
|
|
169
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SpriteMediaAsset {
|
|
4
|
+
/** Host identity for this item within its target, independent of the list order. */
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
imageUrl?: string;
|
|
8
|
+
duration?: number;
|
|
9
|
+
/** Downsampled waveform magnitudes in the range from zero to one. */
|
|
10
|
+
peaks?: readonly number[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SpriteVariable {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
value: string | number;
|
|
17
|
+
scope?: 'local' | 'global';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PaneProps {
|
|
21
|
+
locale: string;
|
|
22
|
+
editable: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SpriteMediaPaneProps extends PaneProps {
|
|
26
|
+
kind: 'costumes' | 'sounds';
|
|
27
|
+
items: readonly SpriteMediaAsset[];
|
|
28
|
+
selectedId: string | null;
|
|
29
|
+
detailOnly?: boolean;
|
|
30
|
+
onSelect(id: string): void;
|
|
31
|
+
/** Optional host surface. It must honor editable without unmounting its layout. */
|
|
32
|
+
renderEditor?(selected: SpriteMediaAsset, editable: boolean): React.ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SpriteVariablePaneProps extends PaneProps {
|
|
36
|
+
kind: 'variables';
|
|
37
|
+
variables: readonly SpriteVariable[];
|
|
38
|
+
onWrite(id: string, value: SpriteVariable['value']): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SpriteAssetPaneProps = SpriteMediaPaneProps | SpriteVariablePaneProps;
|
|
42
|
+
|
|
43
|
+
const english = {
|
|
44
|
+
costumes: 'Costumes', sounds: 'Sounds', variables: 'Variables', name: 'Name', value: 'Value',
|
|
45
|
+
local: 'Sprite', global: 'Project', emptyCostumes: 'No costumes', emptySounds: 'No sounds',
|
|
46
|
+
emptyVariables: 'No variables', selectCostume: 'Select a costume', selectSound: 'Select a sound',
|
|
47
|
+
imageUnavailable: 'Image unavailable', waveformUnavailable: 'Waveform unavailable', waveform: 'Waveform',
|
|
48
|
+
seconds: 's'
|
|
49
|
+
};
|
|
50
|
+
const japanese: typeof english = {
|
|
51
|
+
costumes: 'コスチューム', sounds: '音', variables: '変数', name: '名前', value: '値',
|
|
52
|
+
local: 'スプライト', global: '作品', emptyCostumes: 'コスチュームがありません', emptySounds: '音がありません',
|
|
53
|
+
emptyVariables: '変数がありません', selectCostume: 'コスチュームを選択してください', selectSound: '音を選択してください',
|
|
54
|
+
imageUnavailable: '画像を表示できません', waveformUnavailable: '波形を表示できません', waveform: '波形',
|
|
55
|
+
seconds: '秒'
|
|
56
|
+
};
|
|
57
|
+
const labelsFor = (locale: string) => /^ja(?:-|$)/i.test(locale) ? japanese : english;
|
|
58
|
+
|
|
59
|
+
function Waveform({name, peaks, label}: {name: string; peaks: readonly number[]; label: string}) {
|
|
60
|
+
const path = peaks.map((sample, index) => {
|
|
61
|
+
const x = (index + 0.5) * 100 / peaks.length;
|
|
62
|
+
const magnitude = Number.isFinite(sample) ? Math.max(0, Math.min(sample, 1)) : 0;
|
|
63
|
+
const y = magnitude * 49;
|
|
64
|
+
return 'M' + x + ' ' + (50 - y) + 'V' + (50 + y);
|
|
65
|
+
}).join(' ');
|
|
66
|
+
return <svg className="tw-sprite-media__waveform" viewBox="0 0 100 100" preserveAspectRatio="none"
|
|
67
|
+
role="img" aria-label={label + ': ' + name}>
|
|
68
|
+
<path d="M0 50H100" vectorEffect="non-scaling-stroke" className="tw-sprite-media__axis" />
|
|
69
|
+
<path d={path} vectorEffect="non-scaling-stroke" className="tw-sprite-media__peaks" />
|
|
70
|
+
</svg>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function MediaPane({kind, items, selectedId, onSelect, renderEditor, editable, locale, detailOnly}: SpriteMediaPaneProps) {
|
|
74
|
+
const labels = labelsFor(locale);
|
|
75
|
+
const isSound = kind === 'sounds';
|
|
76
|
+
const selected = items.find(asset => asset.id === selectedId);
|
|
77
|
+
const duration = selected?.duration;
|
|
78
|
+
return <section className={detailOnly ? 'tw-sprite-media tw-sprite-media--detail' : 'tw-sprite-media'} aria-label={labels[kind]}>
|
|
79
|
+
{!detailOnly && <ul className="tw-sprite-media__list" aria-label={labels[kind]}>
|
|
80
|
+
{items.map(asset => <li key={asset.id}>
|
|
81
|
+
<button type="button" className="tw-sprite-media__item" aria-current={asset.id === selectedId || undefined}
|
|
82
|
+
aria-disabled={!editable || undefined} tabIndex={editable ? 0 : -1} onClick={() => {
|
|
83
|
+
if (editable) onSelect(asset.id);
|
|
84
|
+
}}>
|
|
85
|
+
{asset.imageUrl ? <img src={asset.imageUrl} alt="" className="tw-sprite-media__thumbnail" /> :
|
|
86
|
+
<span className="tw-sprite-media__symbol" aria-hidden="true">{isSound ? '♪' : '◫'}</span>}
|
|
87
|
+
<span className="tw-sprite-media__name">{asset.name}</span>
|
|
88
|
+
</button>
|
|
89
|
+
</li>)}
|
|
90
|
+
</ul>}
|
|
91
|
+
<div className="tw-sprite-media__detail">
|
|
92
|
+
{selected ? <>
|
|
93
|
+
<h3 className="tw-sprite-media__title">{selected.name}</h3>
|
|
94
|
+
<div className="tw-sprite-media__surface">
|
|
95
|
+
{renderEditor?.(selected, editable) ?? (isSound ?
|
|
96
|
+
(selected.peaks?.length ? <Waveform peaks={selected.peaks} name={selected.name} label={labels.waveform} /> :
|
|
97
|
+
<p>{labels.waveformUnavailable}</p>) :
|
|
98
|
+
(selected.imageUrl ? <img src={selected.imageUrl} alt={selected.name} className="tw-sprite-media__preview" /> :
|
|
99
|
+
<p>{labels.imageUnavailable}</p>))}
|
|
100
|
+
</div>
|
|
101
|
+
{isSound && Number.isFinite(duration) && duration >= 0 &&
|
|
102
|
+
<p className="tw-sprite-media__duration">{new Intl.NumberFormat(locale, {maximumFractionDigits: 2}).format(duration)} {labels.seconds}</p>}
|
|
103
|
+
</> : <p>{items.length ? (isSound ? labels.selectSound : labels.selectCostume) :
|
|
104
|
+
(isSound ? labels.emptySounds : labels.emptyCostumes)}</p>}
|
|
105
|
+
</div>
|
|
106
|
+
</section>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function VariableField({variable, editable, onWrite}: {
|
|
110
|
+
variable: SpriteVariable;
|
|
111
|
+
editable: boolean;
|
|
112
|
+
onWrite: SpriteVariablePaneProps['onWrite'];
|
|
113
|
+
}) {
|
|
114
|
+
const [text, setText] = React.useState<string | null>(null);
|
|
115
|
+
const pending = React.useRef<{text: string; variable: SpriteVariable; write: typeof onWrite} | null>(null);
|
|
116
|
+
const finish = (save: boolean) => {
|
|
117
|
+
const draft = pending.current;
|
|
118
|
+
if (!draft) return;
|
|
119
|
+
pending.current = null;
|
|
120
|
+
setText(null);
|
|
121
|
+
if (!save || draft.text === String(variable.value)) return;
|
|
122
|
+
const number = Number(draft.text);
|
|
123
|
+
const value = typeof draft.variable.value === 'number' && draft.text.trim() && Number.isFinite(number) ?
|
|
124
|
+
number : draft.text;
|
|
125
|
+
draft.write(draft.variable.id, value);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
React.useLayoutEffect(() => {
|
|
129
|
+
// A click can change the active pane before the old input receives blur.
|
|
130
|
+
if (!editable) finish(true);
|
|
131
|
+
}, [editable]);
|
|
132
|
+
|
|
133
|
+
return <input className="tw-sprite-variables__input" aria-label={variable.name} data-variable-id={variable.id} readOnly={!editable}
|
|
134
|
+
tabIndex={editable ? 0 : -1} value={text ?? String(variable.value)}
|
|
135
|
+
onChange={event => {
|
|
136
|
+
if (!editable) return;
|
|
137
|
+
const next = event.currentTarget.value;
|
|
138
|
+
pending.current = {text: next, variable, write: onWrite};
|
|
139
|
+
setText(next);
|
|
140
|
+
}}
|
|
141
|
+
onBlur={() => finish(true)}
|
|
142
|
+
onKeyDown={event => {
|
|
143
|
+
if (!editable || event.nativeEvent.isComposing) return;
|
|
144
|
+
if (event.key !== 'Enter' && event.key !== 'Escape') return;
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
event.stopPropagation();
|
|
147
|
+
finish(event.key === 'Enter');
|
|
148
|
+
}} />;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function VariablePane({variables, editable, onWrite, locale}: SpriteVariablePaneProps) {
|
|
152
|
+
const labels = labelsFor(locale);
|
|
153
|
+
return <section className="tw-sprite-variables" aria-label={labels.variables}>
|
|
154
|
+
{variables.length ? <table className="tw-sprite-variables__table">
|
|
155
|
+
<thead><tr><th scope="col">{labels.name}</th><th scope="col">{labels.value}</th></tr></thead>
|
|
156
|
+
<tbody>{variables.map(variable => <tr key={variable.id}>
|
|
157
|
+
<th scope="row">{variable.name}
|
|
158
|
+
{variable.scope && <span className="tw-sprite-variables__scope">{labels[variable.scope]}</span>}
|
|
159
|
+
</th>
|
|
160
|
+
<td><VariableField variable={variable} editable={editable} onWrite={onWrite} /></td>
|
|
161
|
+
</tr>)}</tbody>
|
|
162
|
+
</table> : <p>{labels.emptyVariables}</p>}
|
|
163
|
+
</section>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function SpriteAssetPane(props: SpriteAssetPaneProps) {
|
|
167
|
+
return props.kind === 'variables' ? <VariablePane {...props} /> : <MediaPane {...props} />;
|
|
168
|
+
}
|