@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,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
+ }
@@ -0,0 +1,54 @@
1
+ .tw-plus-sprite-layout-menu {
2
+ align-self: center;
3
+ padding-inline: 4px;
4
+ }
5
+
6
+ .tw-plus-sprite-layout-menu-trigger {
7
+ display: grid;
8
+ place-items: center;
9
+ width: 28px;
10
+ height: 28px;
11
+ padding: 4px;
12
+ color: var(--text-primary, #263247);
13
+ background: transparent;
14
+ border: 1px solid transparent;
15
+ border-radius: 4px;
16
+ cursor: pointer;
17
+ }
18
+
19
+ .tw-plus-sprite-layout-menu-trigger svg { width: 16px; height: 16px; }
20
+ .tw-plus-sprite-layout-menu-trigger:hover { background: var(--ui-black-transparent, #e2e7f0); }
21
+
22
+ .tw-plus-sprite-layout-menu-popup {
23
+ position: fixed;
24
+ z-index: 1000;
25
+ inset: auto;
26
+ box-sizing: border-box;
27
+ max-width: calc(100vw - 8px);
28
+ margin: 0;
29
+ padding: 4px;
30
+ color: var(--text-primary, #263247);
31
+ background: var(--popover-background, var(--ui-white, #fff));
32
+ border: 1px solid var(--ui-black-transparent, #c6cfda);
33
+ border-radius: 6px;
34
+ box-shadow: 0 4px 12px #0003;
35
+ font: 13px system-ui, sans-serif;
36
+ }
37
+
38
+ .tw-plus-sprite-layout-menu-popup button {
39
+ display: block;
40
+ width: 100%;
41
+ padding: 6px 8px;
42
+ color: inherit;
43
+ background: transparent;
44
+ border: 0;
45
+ border-radius: 4px;
46
+ text-align: start;
47
+ font: inherit;
48
+ cursor: pointer;
49
+ }
50
+
51
+ .tw-plus-sprite-layout-menu-popup button:disabled { opacity: 0.55; cursor: default; }
52
+ .tw-plus-sprite-layout-menu-popup button:enabled:is(:hover, :focus-visible) {
53
+ background: var(--looks-light-transparent, #e6dff3);
54
+ }
@@ -0,0 +1,119 @@
1
+ interface SpriteLayoutCommands {
2
+ splitRight(): void;
3
+ splitBelow(): void;
4
+ close(): void;
5
+ canClose(): boolean;
6
+ }
7
+
8
+ const labels = {
9
+ ja: ['編集パネルのレイアウト', 'パネルを横に分割', 'パネルを縦に分割', 'この分割パネルを閉じる'],
10
+ en: ['Editor panel layout', 'Split right', 'Split below', 'Close this split']
11
+ };
12
+
13
+ export function createSpriteLayoutMenu(document: Document, locale: string, commands: SpriteLayoutCommands) {
14
+ const [title, right, below, closeLabel] = /^ja(?:-|$)/i.test(locale) ? labels.ja : labels.en;
15
+ const element = document.createElement('div');
16
+ element.className = 'tw-plus-sprite-layout-menu';
17
+ const trigger = document.createElement('button');
18
+ trigger.type = 'button';
19
+ trigger.className = 'tw-plus-sprite-layout-menu-trigger';
20
+ trigger.title = title;
21
+ trigger.setAttribute('aria-label', title);
22
+ trigger.setAttribute('aria-haspopup', 'menu');
23
+ trigger.setAttribute('aria-expanded', 'false');
24
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
25
+ icon.setAttribute('viewBox', '0 0 16 16');
26
+ icon.setAttribute('aria-hidden', 'true');
27
+ icon.setAttribute('fill', 'none');
28
+ icon.setAttribute('stroke', 'currentColor');
29
+ icon.setAttribute('stroke-width', '1.4');
30
+ const shape = document.createElementNS(icon.namespaceURI, 'path');
31
+ shape.setAttribute('d', 'M2.5 3.5h11v9h-11zM2.5 6.5h11m-7 0v6');
32
+ icon.append(shape);
33
+ trigger.append(icon);
34
+ element.append(trigger);
35
+
36
+ const popup = document.createElement('div');
37
+ popup.className = 'tw-plus-sprite-layout-menu-popup';
38
+ popup.setAttribute('role', 'menu');
39
+ popup.setAttribute('aria-label', title);
40
+ const buttons = [[right, commands.splitRight], [below, commands.splitBelow], [closeLabel, commands.close]].map(
41
+ ([label, run]: [string, () => void]) => {
42
+ const item = document.createElement('button');
43
+ item.type = 'button';
44
+ item.textContent = label;
45
+ item.tabIndex = -1;
46
+ item.setAttribute('role', 'menuitem');
47
+ item.addEventListener('click', () => {
48
+ close(true);
49
+ run();
50
+ });
51
+ return item;
52
+ });
53
+ popup.append(...buttons);
54
+ let openedIn: Document | undefined;
55
+ let topLayer = false;
56
+
57
+ function close(restoreFocus = false) {
58
+ if (!openedIn) return;
59
+ openedIn.removeEventListener('pointerdown', dismiss, true);
60
+ openedIn = undefined;
61
+ if (topLayer && popup.matches(':popover-open')) popup.hidePopover();
62
+ popup.remove();
63
+ trigger.setAttribute('aria-expanded', 'false');
64
+ if (restoreFocus) trigger.focus({preventScroll: true});
65
+ }
66
+
67
+ function dismiss(event: PointerEvent) {
68
+ const node = event.target as Node;
69
+ if (!trigger.contains(node) && !popup.contains(node)) close();
70
+ }
71
+
72
+ function open(last = false) {
73
+ if (openedIn) return;
74
+ openedIn = trigger.ownerDocument;
75
+ buttons[2].disabled = !commands.canClose();
76
+ topLayer = typeof popup.showPopover === 'function';
77
+ if (topLayer) {
78
+ popup.setAttribute('popover', 'manual');
79
+ element.append(popup);
80
+ popup.showPopover();
81
+ } else {
82
+ openedIn.body.append(popup);
83
+ }
84
+ const anchor = trigger.getBoundingClientRect();
85
+ const viewport = openedIn.documentElement;
86
+ const {width, height} = popup.getBoundingClientRect();
87
+ popup.style.left = Math.max(4, Math.min(anchor.right - width, viewport.clientWidth - width - 4)) + 'px';
88
+ popup.style.top = (anchor.bottom + height + 8 <= viewport.clientHeight ? anchor.bottom + 4 :
89
+ Math.max(4, anchor.top - height - 4)) + 'px';
90
+ trigger.setAttribute('aria-expanded', 'true');
91
+ openedIn.addEventListener('pointerdown', dismiss, true);
92
+ const enabled = buttons.filter(button => !button.disabled);
93
+ enabled[last ? enabled.length - 1 : 0]?.focus({preventScroll: true});
94
+ }
95
+
96
+ trigger.addEventListener('click', () => openedIn ? close(true) : open());
97
+ trigger.addEventListener('keydown', event => {
98
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
99
+ event.preventDefault();
100
+ open(event.key === 'ArrowUp');
101
+ });
102
+ popup.addEventListener('focusout', event => {
103
+ if (!popup.contains(event.relatedTarget as Node)) close();
104
+ });
105
+ popup.addEventListener('keydown', event => {
106
+ if (event.key === 'Escape') {
107
+ event.stopPropagation();
108
+ close(true);
109
+ return;
110
+ }
111
+ const enabled = buttons.filter(button => !button.disabled);
112
+ const index = enabled.indexOf(event.target as HTMLButtonElement);
113
+ const next = {ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: enabled.length - 1}[event.key];
114
+ if (next === undefined) return;
115
+ event.preventDefault();
116
+ enabled[(next + enabled.length) % enabled.length]?.focus({preventScroll: true});
117
+ });
118
+ return {element, dispose: () => close()};
119
+ }