@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.
@@ -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;
@@ -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': showStage ? stageOverlap : '0px',
89
- '--plus-stage-tab-inset': showStage ? stageOverlap : '3rem'
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, locale: props.locale
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
- }, edge === 'left' ? '›' : '⌃'), element('div', { className: styles.edgePanel, ref: panel, tabIndex: -1, hidden: !open }, children));
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.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
- --dv-tabs-and-actions-container-background-color: var(--ui-primary);
607
- --dv-activegroup-visiblepanel-tab-background-color: var(--ui-white);
608
- --dv-activegroup-visiblepanel-tab-color: var(--text-primary);
609
- --dv-activegroup-hiddenpanel-tab-background-color: var(--ui-secondary);
610
- --dv-activegroup-hiddenpanel-tab-color: var(--text-primary);
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
+ }