@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/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.2",
4
4
  "description": "Resizable panes and layout for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -29,6 +29,17 @@
29
29
  "types": "./dist/sprite-tabs.d.ts",
30
30
  "default": "./dist/sprite-tabs.js"
31
31
  },
32
+ "./sprite-asset-pane": {
33
+ "types": "./dist/sprite-asset-pane.d.ts",
34
+ "default": "./dist/sprite-asset-pane.js"
35
+ },
36
+ "./sprite-asset-pane.css": "./src/sprite-asset-pane.css",
37
+ "./sprite-editor-toolbar": {
38
+ "types": "./dist/sprite-editor-toolbar.d.ts",
39
+ "default": "./dist/sprite-editor-toolbar.js"
40
+ },
41
+ "./sprite-editor-toolbar.css": "./src/sprite-editor-toolbar.css",
42
+ "./sprite-layout-menu.css": "./src/sprite-layout-menu.css",
32
43
  "./dockview-styles": "./dist/dockview-styles.js",
33
44
  "./extension-file-menu": {
34
45
  "types": "./dist/extension-file-menu.d.ts",
package/src/editor.css CHANGED
@@ -603,11 +603,31 @@ details.layout-menu button {
603
603
  flex: 1 1 0;
604
604
  min-width: 0;
605
605
  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);
606
+ }
607
+
608
+ .sprite-tabs :global(.dockview-theme-light) {
609
+ color-scheme: var(--color-scheme, light);
610
+ --dv-group-view-background-color: var(--ui-primary, #fff);
611
+ --dv-tabs-and-actions-container-background-color: var(--ui-primary, #f3f3f3);
612
+ --dv-activegroup-visiblepanel-tab-background-color: var(--ui-white, #fff);
613
+ --dv-activegroup-visiblepanel-tab-color: var(--text-primary, #333);
614
+ --dv-activegroup-hiddenpanel-tab-background-color: var(--ui-secondary, #ececec);
615
+ --dv-activegroup-hiddenpanel-tab-color: var(--text-primary, #333);
616
+ --dv-inactivegroup-visiblepanel-tab-background-color: var(--ui-tertiary, #eee);
617
+ --dv-inactivegroup-visiblepanel-tab-color: var(--text-primary, #333);
618
+ --dv-inactivegroup-hiddenpanel-tab-background-color: var(--ui-secondary, #ececec);
619
+ --dv-inactivegroup-hiddenpanel-tab-color: var(--text-primary, #333);
620
+ --dv-sash-color: var(--ui-black-transparent, #0002);
621
+ --dv-active-sash-color: var(--looks-secondary, #855cd6);
622
+ }
623
+
624
+ .standard-body:has(.sprite-tabs[data-multi-view]) .floating-stage {
625
+ top: 5.5rem;
626
+ height: fit-content;
627
+ }
628
+
629
+ .standard-body:has(.sprite-tabs[data-multi-view]):has(.retracted-targets) {
630
+ padding-left: 0.625rem;
611
631
  }
612
632
 
613
633
  .sprite-tabs > div,
@@ -649,6 +669,55 @@ details.layout-menu button {
649
669
  min-height: 0;
650
670
  }
651
671
 
672
+ .sprite-tabs :global(.tw-plus-sprite-tab-slot--split) {
673
+ display: flex;
674
+ flex-direction: column;
675
+ }
676
+
677
+ .sprite-tabs :global(.tw-plus-sprite-tab-body) {
678
+ flex: 1 1 0;
679
+ min-width: 0;
680
+ min-height: 0;
681
+ overflow: auto;
682
+ }
683
+
684
+ .sprite-tabs :global(.tw-plus-sprite-tab-asset-preview) {
685
+ display: flex;
686
+ flex-direction: column;
687
+ height: 100%;
688
+ min-height: 0;
689
+ }
690
+
691
+ .sprite-tabs :global(.tw-plus-sprite-tab-editor[hidden]),
692
+ .sprite-tabs :global(.tw-plus-sprite-tab-asset-preview[hidden]) { display: none; }
693
+
694
+ .sprite-tabs :global([data-layout-asset-detail]) {
695
+ min-inline-size: 0;
696
+ min-block-size: 0;
697
+ overflow: auto;
698
+ }
699
+
700
+ .sprite-tabs :global([data-layout-asset-panel]) {
701
+ box-sizing: border-box;
702
+ inline-size: 100%;
703
+ block-size: 100%;
704
+ min-inline-size: 0;
705
+ min-block-size: 0;
706
+ flex-basis: 0;
707
+ overflow: hidden;
708
+ }
709
+
710
+ .sprite-tabs :global([data-layout-asset-list]) {
711
+ flex: 0 0 auto;
712
+ max-inline-size: 45%;
713
+ min-block-size: 0;
714
+ }
715
+
716
+ .sprite-tabs :global(.tw-plus-sprite-tab-slot:focus-within) .open-window-action {
717
+ opacity: 1;
718
+ pointer-events: auto;
719
+ }
720
+
652
721
  /* The native tab panel shells remain mounted for the host's tab selection. */
653
722
  :global([class*="gui_tabs_"]):has(> .sprite-tabs) > :global([role="tabpanel"]) {
654
723
  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
+ }
@@ -0,0 +1,108 @@
1
+ .tw-plus-sprite-toolbar {
2
+ --tw-plus-toolbar-height: 28px;
3
+ --tw-plus-toolbar-radius: 999px;
4
+ display: flex;
5
+ flex-wrap: wrap;
6
+ align-items: center;
7
+ gap: 6px 8px;
8
+ padding: 6px;
9
+ color: var(--text-primary, #2c3650);
10
+ background: var(--dv-activegroup-visiblepanel-tab-background-color, var(--ui-white, #fff));
11
+ border-block-end: 1px solid var(--ui-black-transparent, #d5dbe3);
12
+ }
13
+
14
+ .tw-plus-sprite-toolbar[data-active-group="false"] {
15
+ background: var(--dv-inactivegroup-visiblepanel-tab-background-color, var(--ui-tertiary, #e8ecf5));
16
+ }
17
+
18
+ .tw-plus-sprite-toolbar-sections {
19
+ display: flex;
20
+ flex-wrap: wrap;
21
+ gap: 4px;
22
+ min-width: 0;
23
+ }
24
+
25
+ .tw-plus-sprite-toolbar-sections button,
26
+ .tw-plus-sprite-search input {
27
+ box-sizing: border-box;
28
+ min-height: var(--tw-plus-toolbar-height);
29
+ border: 1px solid var(--ui-black-transparent, #c6cfda);
30
+ border-radius: var(--tw-plus-toolbar-radius);
31
+ color: inherit;
32
+ font: inherit;
33
+ }
34
+
35
+ .tw-plus-sprite-toolbar-sections button {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ gap: 4px;
39
+ padding: 0 8px;
40
+ background: var(--ui-secondary, #edf0f7);
41
+ font-size: 12px;
42
+ font-weight: 600;
43
+ cursor: pointer;
44
+ }
45
+
46
+ .tw-plus-sprite-toolbar-sections button:hover { background: var(--ui-tertiary, #dce4f1); }
47
+ .tw-plus-sprite-toolbar-sections button[aria-selected="true"] {
48
+ border-color: var(--looks-secondary-dark, #6747a8);
49
+ background: var(--looks-secondary-dark, #6747a8);
50
+ color: #fff;
51
+ }
52
+
53
+ .tw-plus-sprite-toolbar-sections button:focus-visible,
54
+ .tw-plus-sprite-search input:focus-visible {
55
+ outline: 2px solid var(--looks-secondary, #855cd6);
56
+ outline-offset: 1px;
57
+ }
58
+
59
+ .tw-plus-sprite-toolbar svg { width: 16px; height: 16px; flex: none; }
60
+
61
+ .tw-plus-sprite-search {
62
+ position: relative;
63
+ flex: 0 1 132px;
64
+ min-width: 0;
65
+ margin-inline-start: auto;
66
+ }
67
+
68
+ .tw-plus-sprite-search-field { position: relative; }
69
+ .tw-plus-sprite-search-field svg {
70
+ position: absolute;
71
+ inset-inline-start: 8px;
72
+ inset-block-start: 50%;
73
+ transform: translateY(-50%);
74
+ pointer-events: none;
75
+ }
76
+
77
+ .tw-plus-sprite-search input {
78
+ display: block;
79
+ width: 100%;
80
+ padding-inline: 28px 8px;
81
+ background: var(--input-background, var(--ui-white, #fff));
82
+ color-scheme: var(--color-scheme, light);
83
+ font-size: 12px;
84
+ }
85
+
86
+ .tw-plus-sprite-search input::placeholder { color: inherit; opacity: 0.75; }
87
+
88
+ .tw-plus-sprite-search ul {
89
+ position: absolute;
90
+ z-index: 5;
91
+ inset-inline-end: 0;
92
+ inset-block-start: 100%;
93
+ width: max(100%, 12rem);
94
+ max-width: calc(100cqw - 12px);
95
+ max-height: min(250px, 40vh);
96
+ margin: 4px 0 0;
97
+ padding: 4px;
98
+ overflow: auto;
99
+ border: 1px solid var(--ui-black-transparent, #c6cfda);
100
+ border-radius: 6px;
101
+ background: var(--popover-background, var(--ui-white, #fff));
102
+ box-shadow: 0 4px 12px #0003;
103
+ list-style: none;
104
+ }
105
+
106
+ .tw-plus-sprite-search li { padding: 4px 6px; overflow-wrap: anywhere; cursor: pointer; }
107
+ .tw-plus-sprite-search li:hover,
108
+ .tw-plus-sprite-search li[aria-selected="true"] { background: var(--looks-light-transparent, #e6dff3); }
@@ -0,0 +1,124 @@
1
+ import React from 'react';
2
+
3
+ export const spriteEditorSections = ['code', 'costumes', 'sounds', 'variables'] as const;
4
+ export type SpriteEditorSection = typeof spriteEditorSections[number];
5
+
6
+ export interface SpriteSearchResult {
7
+ id: string;
8
+ label: string;
9
+ }
10
+
11
+ export interface SpriteEditorSearch {
12
+ find(query: string, section: SpriteEditorSection): readonly SpriteSearchResult[];
13
+ select(result: SpriteSearchResult, section: SpriteEditorSection): void;
14
+ onFocus?(): void;
15
+ }
16
+
17
+ interface SpriteEditorToolbarProps {
18
+ viewId: string;
19
+ locale: string;
20
+ section: SpriteEditorSection;
21
+ onSectionChange(section: SpriteEditorSection): void;
22
+ isStage?: boolean;
23
+ activeGroup?: boolean;
24
+ search?: SpriteEditorSearch;
25
+ availableSections?: readonly SpriteEditorSection[];
26
+ actions?: React.ReactNode;
27
+ }
28
+
29
+ const glyphs: Record<SpriteEditorSection | 'search', string> = {
30
+ code: 'm5.5 4-4 4 4 4m5-8 4 4-4 4',
31
+ 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',
32
+ sounds: 'M2 6h3l4-3v10l-4-3H2Zm9.5 0a3 3 0 0 1 0 4m2-6a5.5 5.5 0 0 1 0 8',
33
+ variables: 'M5 2H3v12h2m6-12h2v12h-2M6 6l4 4m0-4-4 4',
34
+ search: 'M10 10l3 3M11 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z'
35
+ };
36
+
37
+ function Icon({kind}: {kind: keyof typeof glyphs}) {
38
+ return <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4"
39
+ strokeLinecap="round" strokeLinejoin="round"><path d={glyphs[kind]} /></svg>;
40
+ }
41
+
42
+ function SearchBox({viewId, section, japanese, search}: {
43
+ viewId: string; section: SpriteEditorSection; japanese: boolean; search: SpriteEditorSearch;
44
+ }) {
45
+ const [query, setQuery] = React.useState('');
46
+ const [open, setOpen] = React.useState(false);
47
+ const [index, setIndex] = React.useState(-1);
48
+ const [focused, setFocused] = React.useState(false);
49
+ const results = open && focused && query.trim() ? search.find(query.trim(), section) : [];
50
+ const listId = viewId + '-search-results';
51
+ const selected = results[index];
52
+ const expanded = results.length > 0;
53
+ const optionId = (position: number) => listId + '-' + position;
54
+
55
+ function choose(result: SpriteSearchResult) {
56
+ setOpen(false);
57
+ setIndex(-1);
58
+ search.select(result, section);
59
+ }
60
+
61
+ return <div className="tw-plus-sprite-search">
62
+ <div className="tw-plus-sprite-search-field">
63
+ <Icon kind="search" />
64
+ <input type="search" role="combobox" autoComplete="off" aria-autocomplete="list"
65
+ aria-label={japanese ? 'スプライト内を検索' : 'Search within this sprite'}
66
+ aria-controls={expanded ? listId : undefined} aria-expanded={expanded}
67
+ aria-activedescendant={selected ? optionId(index) : undefined}
68
+ placeholder={japanese ? '検索' : 'Search'} value={query}
69
+ onFocus={() => {setFocused(true); setOpen(true); search.onFocus?.();}}
70
+ onBlur={() => {setFocused(false); setIndex(-1);}}
71
+ onChange={event => {setQuery(event.currentTarget.value); setOpen(true); setIndex(-1);}}
72
+ onKeyDown={event => {
73
+ if (event.key === 'Escape' && expanded) {
74
+ event.preventDefault();
75
+ event.stopPropagation();
76
+ setOpen(false);
77
+ setIndex(-1);
78
+ } else if (expanded && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
79
+ event.preventDefault();
80
+ const offset = event.key === 'ArrowDown' ? 1 : -1;
81
+ setIndex(selected ? (index + offset + results.length) % results.length :
82
+ offset === 1 ? 0 : results.length - 1);
83
+ } else if (event.key === 'Enter' && selected) {
84
+ event.preventDefault();
85
+ choose(selected);
86
+ }
87
+ }} />
88
+ </div>
89
+ {expanded && <ul role="listbox" id={listId} aria-label={japanese ? '検索結果' : 'Search results'}>
90
+ {results.map((result, position) => <li key={result.id} id={optionId(position)} role="option"
91
+ aria-selected={position === index} onPointerDown={event => event.preventDefault()}
92
+ onClick={() => choose(result)}>{result.label}</li>)}
93
+ </ul>}
94
+ </div>;
95
+ }
96
+
97
+ export function SpriteEditorToolbar({viewId, locale, section, onSectionChange, isStage, activeGroup, search,
98
+ availableSections = spriteEditorSections, actions}: SpriteEditorToolbarProps) {
99
+ 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'};
102
+ return <div className="tw-plus-sprite-toolbar" data-active-group={activeGroup}>
103
+ <div className="tw-plus-sprite-toolbar-sections" role="tablist"
104
+ aria-label={japanese ? 'スプライトの編集内容' : 'Sprite editor sections'}>
105
+ {availableSections.map((id, index) => <button key={id} type="button" role="tab"
106
+ id={viewId + '-section-' + id} aria-selected={section === id} tabIndex={section === id ? 0 : -1}
107
+ aria-controls={viewId + '-content'} onClick={() => onSectionChange(id)} onKeyDown={event => {
108
+ const direction = event.currentTarget.ownerDocument.dir === 'rtl' ? -1 : 1;
109
+ const next = {ArrowLeft: index - direction, ArrowRight: index + direction,
110
+ Home: 0, End: availableSections.length - 1}[event.key];
111
+ if (next === undefined) return;
112
+ event.preventDefault();
113
+ const position = (next + availableSections.length) % availableSections.length;
114
+ onSectionChange(availableSections[position]);
115
+ event.currentTarget.parentElement.querySelectorAll<HTMLButtonElement>('button')[position]?.focus();
116
+ }}>
117
+ <Icon kind={id} />
118
+ {labels[id]}
119
+ </button>)}
120
+ </div>
121
+ {search && <SearchBox {...{viewId, section, japanese, search}} />}
122
+ {actions}
123
+ </div>;
124
+ }