@kubohiroya/turbowarp-title-menu 0.1.0
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/LICENSE +373 -0
- package/README.ja.md +188 -0
- package/README.md +276 -0
- package/dist/extension-manifest.json +57 -0
- package/dist/lib/composition.js +5 -0
- package/dist/lib/dom.js +33 -0
- package/dist/lib/dsl-files-dialog.js +323 -0
- package/dist/lib/dsl-store.js +254 -0
- package/dist/lib/events.js +20 -0
- package/dist/lib/locales.js +86 -0
- package/dist/lib/title-dialog.js +133 -0
- package/dist/turbowarp-title-menu.js +1485 -0
- package/dist/types/composition.d.ts +10 -0
- package/dist/types/dom.d.ts +7 -0
- package/dist/types/dsl-files-dialog.d.ts +51 -0
- package/dist/types/dsl-store.d.ts +59 -0
- package/dist/types/events.d.ts +13 -0
- package/dist/types/locales.d.ts +14 -0
- package/dist/types/title-dialog.d.ts +29 -0
- package/docs/architecture.ja.md +63 -0
- package/docs/architecture.md +73 -0
- package/docs/index.html +34 -0
- package/package.json +84 -0
- package/schemas/extension-manifest.schema.json +49 -0
- package/src/block-definitions.json +65 -0
- package/src/composition.ts +26 -0
- package/src/config.ts +13 -0
- package/src/dom.ts +39 -0
- package/src/dsl-files-dialog.ts +408 -0
- package/src/dsl-store.ts +358 -0
- package/src/events.ts +30 -0
- package/src/extension-manifest.ts +149 -0
- package/src/extension.ts +252 -0
- package/src/globals.d.ts +33 -0
- package/src/index.ts +28 -0
- package/src/locales.ts +103 -0
- package/src/title-dialog.ts +168 -0
package/README.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# TurboWarp Title Menu
|
|
2
|
+
|
|
3
|
+
[日本語](README.ja.md)
|
|
4
|
+
|
|
5
|
+
Reusable title, application menu, and DSL source storage controls for TurboWarp extensions and packaged projects.
|
|
6
|
+
|
|
7
|
+
**User guide:** [English](https://kubohiroya.github.io/turbowarp-title-menu/)
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- Shows a reusable title dialog with title, author, license, official website, language, and close controls.
|
|
12
|
+
- Shows a stage-mounted application menu built on the shared `@kubohiroya/turbowarp-app-shell` primitive, so a host project can define its own actions instead of a fixed set.
|
|
13
|
+
- Shows a DSL file manager that adds, opens, renames, and deletes stored files, and sorts them by name, update time, or size.
|
|
14
|
+
- Stores many DSL files in IndexedDB without tying the mechanism to TM Kamishibai.
|
|
15
|
+
- Exposes a small Composition API for projects that want to wire the controls into their own runtime.
|
|
16
|
+
|
|
17
|
+
## Documentation and block icon
|
|
18
|
+
|
|
19
|
+
This extension publishes its English user documentation with GitHub Pages.
|
|
20
|
+
|
|
21
|
+
- `docsURI` points to the English GitHub Pages documentation: `https://kubohiroya.github.io/turbowarp-title-menu/`.
|
|
22
|
+
- `blockIconURI` is a self-contained SVG encoded as `data:image/svg+xml;base64,...`.
|
|
23
|
+
- The block icon represents a small title/menu panel.
|
|
24
|
+
|
|
25
|
+
## Requirements and safety
|
|
26
|
+
|
|
27
|
+
- TurboWarp Desktop, TurboWarp Web, or a packaged TurboWarp project that allows custom extensions.
|
|
28
|
+
- Browser DOM access for the title and menu overlays.
|
|
29
|
+
- Browser IndexedDB for DSL source persistence.
|
|
30
|
+
- Unsandboxed extension mode.
|
|
31
|
+
|
|
32
|
+
> [!IMPORTANT]
|
|
33
|
+
> This extension must run without the sandbox because it creates DOM controls above the stage and uses browser storage. Load unsandboxed extension code only from a source you trust.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
### Ready-to-use JavaScript
|
|
38
|
+
|
|
39
|
+
1. Download [`dist/turbowarp-title-menu.js`](dist/turbowarp-title-menu.js?raw=1).
|
|
40
|
+
2. Open **Extensions** in TurboWarp.
|
|
41
|
+
3. Choose **Custom Extension** and load the file.
|
|
42
|
+
4. Enable **Run extension without sandbox**.
|
|
43
|
+
|
|
44
|
+
The reviewed JavaScript build is committed to this repository, so users do not need Node.js to install the extension.
|
|
45
|
+
|
|
46
|
+
### npm package
|
|
47
|
+
|
|
48
|
+
Install an exact version that you have reviewed:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pnpm add --save-exact @kubohiroya/turbowarp-title-menu@0.1.0
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Load the standalone bundle from:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
node_modules/@kubohiroya/turbowarp-title-menu/dist/turbowarp-title-menu.js
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A version-pinned CDN URL is:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-title-menu@0.1.0/dist/turbowarp-title-menu.js
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
1. Load `dist/turbowarp-title-menu.js` as an unsandboxed custom extension.
|
|
69
|
+
2. Run `show title dialog` immediately after startup.
|
|
70
|
+
3. Use `show application menu`, or `show DSL file manager` directly, when the project should expose DSL file actions.
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
when green flag clicked
|
|
74
|
+
show title dialog
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Block reference
|
|
78
|
+
|
|
79
|
+
The block reference is generated from [`src/block-definitions.json`](src/block-definitions.json). Do not edit the generated section manually.
|
|
80
|
+
|
|
81
|
+
<!-- BEGIN GENERATED BLOCKS -->
|
|
82
|
+
|
|
83
|
+
### `show title dialog`
|
|
84
|
+
|
|
85
|
+
Shows the configured title dialog above the TurboWarp stage.
|
|
86
|
+
|
|
87
|
+
| Property | Value |
|
|
88
|
+
|---|---|
|
|
89
|
+
| Type | Command |
|
|
90
|
+
| Opcode | `showTitle` |
|
|
91
|
+
|
|
92
|
+
### `show application menu`
|
|
93
|
+
|
|
94
|
+
Shows the application menu above the TurboWarp stage.
|
|
95
|
+
|
|
96
|
+
| Property | Value |
|
|
97
|
+
|---|---|
|
|
98
|
+
| Type | Command |
|
|
99
|
+
| Opcode | `showMenu` |
|
|
100
|
+
|
|
101
|
+
### `show DSL file manager`
|
|
102
|
+
|
|
103
|
+
Shows the dialog that adds, opens, renames, deletes, and sorts stored DSL files.
|
|
104
|
+
|
|
105
|
+
| Property | Value |
|
|
106
|
+
|---|---|
|
|
107
|
+
| Type | Command |
|
|
108
|
+
| Opcode | `showDslFiles` |
|
|
109
|
+
|
|
110
|
+
### `when a DSL source is opened`
|
|
111
|
+
|
|
112
|
+
Runs after the operator opens a stored DSL file, or after the opened source is announced again.
|
|
113
|
+
|
|
114
|
+
| Property | Value |
|
|
115
|
+
|---|---|
|
|
116
|
+
| Type | Hat |
|
|
117
|
+
| Opcode | `whenDslSourceOpened` |
|
|
118
|
+
|
|
119
|
+
### `reload the opened DSL source`
|
|
120
|
+
|
|
121
|
+
Announces the currently opened DSL source again without showing a dialog.
|
|
122
|
+
|
|
123
|
+
| Property | Value |
|
|
124
|
+
|---|---|
|
|
125
|
+
| Type | Command |
|
|
126
|
+
| Opcode | `reloadOpenedDsl` |
|
|
127
|
+
|
|
128
|
+
### `opened DSL file name`
|
|
129
|
+
|
|
130
|
+
Returns the name of the DSL file that is currently open, or an empty string.
|
|
131
|
+
|
|
132
|
+
| Property | Value |
|
|
133
|
+
|---|---|
|
|
134
|
+
| Type | Reporter |
|
|
135
|
+
| Opcode | `openedDslName` |
|
|
136
|
+
|
|
137
|
+
### `opened DSL source`
|
|
138
|
+
|
|
139
|
+
Returns the text of the DSL file that is currently open, or an empty string.
|
|
140
|
+
|
|
141
|
+
| Property | Value |
|
|
142
|
+
|---|---|
|
|
143
|
+
| Type | Reporter |
|
|
144
|
+
| Opcode | `openedDslSource` |
|
|
145
|
+
|
|
146
|
+
### `has a saved DSL file?`
|
|
147
|
+
|
|
148
|
+
Reports whether at least one DSL file is stored in IndexedDB.
|
|
149
|
+
|
|
150
|
+
| Property | Value |
|
|
151
|
+
|---|---|
|
|
152
|
+
| Type | Boolean |
|
|
153
|
+
| Opcode | `hasSavedDsl` |
|
|
154
|
+
|
|
155
|
+
### `saved DSL file count`
|
|
156
|
+
|
|
157
|
+
Returns how many DSL files are stored in IndexedDB.
|
|
158
|
+
|
|
159
|
+
| Property | Value |
|
|
160
|
+
|---|---|
|
|
161
|
+
| Type | Reporter |
|
|
162
|
+
| Opcode | `savedDslCount` |
|
|
163
|
+
|
|
164
|
+
### `last DSL storage error`
|
|
165
|
+
|
|
166
|
+
Returns the most recent DSL storage failure in the interface language, or an empty string.
|
|
167
|
+
|
|
168
|
+
| Property | Value |
|
|
169
|
+
|---|---|
|
|
170
|
+
| Type | Reporter |
|
|
171
|
+
| Opcode | `lastDslError` |
|
|
172
|
+
|
|
173
|
+
<!-- END GENERATED BLOCKS -->
|
|
174
|
+
|
|
175
|
+
## Important behavior
|
|
176
|
+
|
|
177
|
+
| Situation | Behavior |
|
|
178
|
+
|---|---|
|
|
179
|
+
| Startup title | `show title dialog` mounts a dialog over the stage and keeps Scratch sprites untouched. |
|
|
180
|
+
| Official website | The title dialog opens the configured website URL in a new browser tab. |
|
|
181
|
+
| Adding a DSL file | `Add file` opens a browser file picker and stores the chosen source. It does not open the file; the operator presses `Open` when the project should use it. |
|
|
182
|
+
| Renaming | Names are unique. Renaming a file to a name another file already uses fails and leaves both files unchanged. |
|
|
183
|
+
| Deleting | Deleting asks for a second confirming click, and deleting the open file clears the opened source. |
|
|
184
|
+
| Storage limits | A file over 1 MiB, or a 65th file, is refused with a message. The store never evicts a file on its own. |
|
|
185
|
+
| Storage failures | Every failure is shown in the dialog and reported by `last DSL storage error`; a script is never interrupted by a thrown storage error. |
|
|
186
|
+
| Project stop | The controls remain explicit UI overlays; consumers can dispose Composition API instances when their runtime ends. |
|
|
187
|
+
|
|
188
|
+
## Composition API
|
|
189
|
+
|
|
190
|
+
Importing the Composition API gives host projects direct control over DOM mounting, callbacks,
|
|
191
|
+
locales, the storage database, and DSL source events. The application menu is re-exported from
|
|
192
|
+
`@kubohiroya/turbowarp-app-shell`, so a host owns its own action vocabulary.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import {
|
|
196
|
+
createApplicationMenu,
|
|
197
|
+
createDslFilesDialog,
|
|
198
|
+
createDslStore,
|
|
199
|
+
createTitleDialog,
|
|
200
|
+
dslReloadEventName
|
|
201
|
+
} from '@kubohiroya/turbowarp-title-menu';
|
|
202
|
+
|
|
203
|
+
const store = createDslStore({databaseName: 'my-runtime'});
|
|
204
|
+
|
|
205
|
+
const title = createTitleDialog({
|
|
206
|
+
mount: stageContainer,
|
|
207
|
+
locales: {
|
|
208
|
+
en: {
|
|
209
|
+
title: 'My Story',
|
|
210
|
+
author: 'Author: Example',
|
|
211
|
+
license: 'License: MPL-2.0',
|
|
212
|
+
website: 'Official Website',
|
|
213
|
+
close: 'Close'
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
websiteUrl: 'https://example.com'
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const files = createDslFilesDialog({
|
|
220
|
+
mount: stageContainer,
|
|
221
|
+
locales: {en: myFileManagerLabels},
|
|
222
|
+
list: (sort) => store.list(sort),
|
|
223
|
+
onAdd: () => addFileThroughMyOwnPicker(),
|
|
224
|
+
onOpen: async (id) => {
|
|
225
|
+
const record = await store.get(id);
|
|
226
|
+
if (record === null) return;
|
|
227
|
+
await store.markOpened(id);
|
|
228
|
+
window.dispatchEvent(new CustomEvent(dslReloadEventName, {detail: {record}}));
|
|
229
|
+
},
|
|
230
|
+
onRename: (id, name) => store.rename(id, name),
|
|
231
|
+
onRemove: (id) => store.remove(id)
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const menu = createApplicationMenu({
|
|
235
|
+
document,
|
|
236
|
+
mount: stageContainer,
|
|
237
|
+
actions: [
|
|
238
|
+
{id: 'files', labels: {en: 'DSL files'}, onSelect: () => files.show('en')},
|
|
239
|
+
{id: 'about', labels: {en: 'About'}, onSelect: () => title.show('en')}
|
|
240
|
+
]
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
menu.show('en');
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Compatibility
|
|
247
|
+
|
|
248
|
+
| Identifier | Value | Stability |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| Product name | `TurboWarp Title Menu` | Human-facing |
|
|
251
|
+
| Repository | `kubohiroya/turbowarp-title-menu` | Current source location |
|
|
252
|
+
| npm package | `@kubohiroya/turbowarp-title-menu` | Public package contract |
|
|
253
|
+
| Extension ID | `kubohiroyaturbowarptitlemenu` | Stored in SB3; migration required to change |
|
|
254
|
+
|
|
255
|
+
## Development
|
|
256
|
+
|
|
257
|
+
Use Node.js 22 or later and the pnpm version declared by `packageManager`.
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
corepack enable
|
|
261
|
+
pnpm install --frozen-lockfile
|
|
262
|
+
pnpm run check
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Release
|
|
266
|
+
|
|
267
|
+
1. Update the version in `package.json`.
|
|
268
|
+
2. Run `pnpm run check`.
|
|
269
|
+
3. Merge the release PR.
|
|
270
|
+
4. Create tag `v<version>` on the verified commit.
|
|
271
|
+
5. Publish the same version to npm when applicable.
|
|
272
|
+
6. Verify GitHub Release, npm tarball, GitHub Pages, `docsURI`, block icon, and CDN artifacts.
|
|
273
|
+
|
|
274
|
+
## License
|
|
275
|
+
|
|
276
|
+
[Mozilla Public License 2.0](LICENSE) (SPDX: `MPL-2.0`).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"formatVersion": 1,
|
|
3
|
+
"id": "kubohiroyaturbowarptitlemenu",
|
|
4
|
+
"blocks": [
|
|
5
|
+
{
|
|
6
|
+
"opcode": "hasSavedDsl",
|
|
7
|
+
"blockType": "BOOLEAN",
|
|
8
|
+
"arguments": []
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"opcode": "lastDslError",
|
|
12
|
+
"blockType": "REPORTER",
|
|
13
|
+
"arguments": []
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"opcode": "openedDslName",
|
|
17
|
+
"blockType": "REPORTER",
|
|
18
|
+
"arguments": []
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"opcode": "openedDslSource",
|
|
22
|
+
"blockType": "REPORTER",
|
|
23
|
+
"arguments": []
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"opcode": "reloadOpenedDsl",
|
|
27
|
+
"blockType": "COMMAND",
|
|
28
|
+
"arguments": []
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"opcode": "savedDslCount",
|
|
32
|
+
"blockType": "REPORTER",
|
|
33
|
+
"arguments": []
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"opcode": "showDslFiles",
|
|
37
|
+
"blockType": "COMMAND",
|
|
38
|
+
"arguments": []
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"opcode": "showMenu",
|
|
42
|
+
"blockType": "COMMAND",
|
|
43
|
+
"arguments": []
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"opcode": "showTitle",
|
|
47
|
+
"blockType": "COMMAND",
|
|
48
|
+
"arguments": []
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"opcode": "whenDslSourceOpened",
|
|
52
|
+
"blockType": "HAT",
|
|
53
|
+
"arguments": []
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"menus": []
|
|
57
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createAppShellApplicationMenu as createApplicationMenu } from '@kubohiroya/turbowarp-app-shell';
|
|
2
|
+
export { createDslFilesDialog } from './dsl-files-dialog.js';
|
|
3
|
+
export { createDslStore, defaultDslSort, DslStoreError, readDslFile } from './dsl-store.js';
|
|
4
|
+
export { dslOpenEventName, dslReloadEventName } from './events.js';
|
|
5
|
+
export { createTitleDialog } from './title-dialog.js';
|
package/dist/lib/dom.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function isRecord(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function requireDocument(value) {
|
|
5
|
+
if (!isRecord(value) || typeof value.createElement !== 'function') {
|
|
6
|
+
throw new TypeError('document must provide createElement');
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
export function requireElement(value, name) {
|
|
11
|
+
if (!isRecord(value) || typeof value.appendChild !== 'function') {
|
|
12
|
+
throw new TypeError(`${name} must be a DOM element`);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export function ensureRelativeMount(mount) {
|
|
17
|
+
const previous = mount.style.position;
|
|
18
|
+
if (previous === '' || previous === 'static') {
|
|
19
|
+
mount.style.position = 'relative';
|
|
20
|
+
return () => {
|
|
21
|
+
mount.style.position = previous;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return () => { };
|
|
25
|
+
}
|
|
26
|
+
export function invokeSafely(operation, onError) {
|
|
27
|
+
try {
|
|
28
|
+
Promise.resolve(operation()).catch((error) => onError?.(error));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
onError?.(error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { isRecord, requireDocument, requireElement } from './dom';
|
|
2
|
+
import { defaultDslSort } from './dsl-store';
|
|
3
|
+
const defaultSortDirections = Object.freeze({
|
|
4
|
+
name: 'asc',
|
|
5
|
+
updatedAt: 'desc',
|
|
6
|
+
byteLength: 'desc'
|
|
7
|
+
});
|
|
8
|
+
function requireLocales(value) {
|
|
9
|
+
if (!isRecord(value) || Object.keys(value).length === 0) {
|
|
10
|
+
throw new TypeError('locales must contain at least one locale');
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function textFor(locales, locale) {
|
|
15
|
+
const text = locales[locale] ?? locales['en'] ?? Object.values(locales)[0];
|
|
16
|
+
if (text === undefined)
|
|
17
|
+
throw new TypeError('locales must contain at least one locale');
|
|
18
|
+
return text;
|
|
19
|
+
}
|
|
20
|
+
function defaultFormatSize(byteLength) {
|
|
21
|
+
if (byteLength < 1024)
|
|
22
|
+
return `${byteLength} B`;
|
|
23
|
+
if (byteLength < 1024 * 1024)
|
|
24
|
+
return `${(byteLength / 1024).toFixed(1)} KB`;
|
|
25
|
+
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`;
|
|
26
|
+
}
|
|
27
|
+
function defaultFormatDate(isoDate) {
|
|
28
|
+
const parsed = new Date(isoDate);
|
|
29
|
+
if (Number.isNaN(parsed.getTime()))
|
|
30
|
+
return isoDate;
|
|
31
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
32
|
+
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`;
|
|
33
|
+
}
|
|
34
|
+
function defaultDescribeError(error) {
|
|
35
|
+
if (error instanceof Error && error.message.length > 0)
|
|
36
|
+
return error.message;
|
|
37
|
+
return String(error);
|
|
38
|
+
}
|
|
39
|
+
export function createDslFilesDialog(options) {
|
|
40
|
+
if (!isRecord(options))
|
|
41
|
+
throw new TypeError('DSL files dialog options must be an object');
|
|
42
|
+
const document = requireDocument(options.document ?? globalThis.document);
|
|
43
|
+
const mount = requireElement(options.mount ?? document.body, 'mount');
|
|
44
|
+
const locales = requireLocales(options.locales);
|
|
45
|
+
const describeError = options.describeError ?? defaultDescribeError;
|
|
46
|
+
const formatSize = options.formatSize ?? defaultFormatSize;
|
|
47
|
+
const formatDate = options.formatDate ?? defaultFormatDate;
|
|
48
|
+
let locale = options.initialLocale ?? 'en';
|
|
49
|
+
let sort = options.initialSort ?? defaultDslSort;
|
|
50
|
+
let summaries = [];
|
|
51
|
+
let renamingId = null;
|
|
52
|
+
let confirmingRemovalId = null;
|
|
53
|
+
let status = '';
|
|
54
|
+
let disposed = false;
|
|
55
|
+
const root = document.createElement('section');
|
|
56
|
+
const panel = document.createElement('div');
|
|
57
|
+
const heading = document.createElement('h1');
|
|
58
|
+
const closeButton = document.createElement('button');
|
|
59
|
+
const toolbar = document.createElement('div');
|
|
60
|
+
const addButton = document.createElement('button');
|
|
61
|
+
const sortBar = document.createElement('div');
|
|
62
|
+
const listElement = document.createElement('div');
|
|
63
|
+
const statusElement = document.createElement('p');
|
|
64
|
+
root.setAttribute('data-turbowarp-title-menu-dsl-files', 'true');
|
|
65
|
+
root.style.cssText =
|
|
66
|
+
'position:absolute;inset:0;z-index:2147483646;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;background:rgba(0,0,0,.6);font-family:sans-serif;';
|
|
67
|
+
// Visibility is the one property the dialog toggles, so it is set apart from the style sheet.
|
|
68
|
+
root.style.display = 'none';
|
|
69
|
+
panel.style.cssText =
|
|
70
|
+
'width:min(720px,96%);max-height:90%;display:flex;flex-direction:column;gap:10px;padding:18px;box-sizing:border-box;background:#ffffff;color:#1b1f27;border-radius:10px;box-shadow:0 10px 36px rgba(0,0,0,.45);overflow:hidden;';
|
|
71
|
+
heading.style.cssText = 'margin:0;font-size:20px;line-height:1.3;';
|
|
72
|
+
toolbar.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;';
|
|
73
|
+
sortBar.style.cssText = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;';
|
|
74
|
+
listElement.style.cssText = 'flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:6px;';
|
|
75
|
+
statusElement.style.cssText = 'margin:0;min-height:18px;font-size:13px;color:#8a1c1c;';
|
|
76
|
+
const headerRow = document.createElement('div');
|
|
77
|
+
headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;';
|
|
78
|
+
headerRow.appendChild(heading);
|
|
79
|
+
headerRow.appendChild(closeButton);
|
|
80
|
+
panel.appendChild(headerRow);
|
|
81
|
+
toolbar.appendChild(addButton);
|
|
82
|
+
toolbar.appendChild(sortBar);
|
|
83
|
+
panel.appendChild(toolbar);
|
|
84
|
+
panel.appendChild(listElement);
|
|
85
|
+
panel.appendChild(statusElement);
|
|
86
|
+
root.appendChild(panel);
|
|
87
|
+
const previousMountPosition = mount.style.position;
|
|
88
|
+
if (previousMountPosition === '' || previousMountPosition === 'static') {
|
|
89
|
+
mount.style.position = 'relative';
|
|
90
|
+
}
|
|
91
|
+
mount.appendChild(root);
|
|
92
|
+
function styleButton(button, tone) {
|
|
93
|
+
const palette = {
|
|
94
|
+
primary: 'background:#1f6feb;color:#ffffff;border:1px solid #1f6feb;',
|
|
95
|
+
plain: 'background:#f2f4f8;color:#1b1f27;border:1px solid #ccd2de;',
|
|
96
|
+
danger: 'background:#b42318;color:#ffffff;border:1px solid #b42318;',
|
|
97
|
+
active: 'background:#d7e6ff;color:#0b3a82;border:1px solid #1f6feb;'
|
|
98
|
+
}[tone];
|
|
99
|
+
button.style.cssText = `${palette}padding:5px 10px;border-radius:6px;font-size:13px;cursor:pointer;`;
|
|
100
|
+
}
|
|
101
|
+
function run(operation) {
|
|
102
|
+
try {
|
|
103
|
+
Promise.resolve(operation()).catch(handleFailure);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
handleFailure(error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function handleFailure(error) {
|
|
110
|
+
status = describeError(error);
|
|
111
|
+
options.onError?.(error);
|
|
112
|
+
render();
|
|
113
|
+
}
|
|
114
|
+
/** Every mutation goes through here so the list and the status line stay in step. */
|
|
115
|
+
function mutate(operation) {
|
|
116
|
+
run(async () => {
|
|
117
|
+
status = '';
|
|
118
|
+
try {
|
|
119
|
+
await operation();
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
status = describeError(error);
|
|
123
|
+
options.onError?.(error);
|
|
124
|
+
render();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await reload();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function reload() {
|
|
131
|
+
if (disposed)
|
|
132
|
+
return;
|
|
133
|
+
summaries = await options.list(sort);
|
|
134
|
+
render();
|
|
135
|
+
}
|
|
136
|
+
function selectSort(field) {
|
|
137
|
+
if (sort.field === field) {
|
|
138
|
+
sort = { field, direction: sort.direction === 'asc' ? 'desc' : 'asc' };
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
sort = { field, direction: defaultSortDirections[field] };
|
|
142
|
+
}
|
|
143
|
+
renamingId = null;
|
|
144
|
+
confirmingRemovalId = null;
|
|
145
|
+
mutate(() => undefined);
|
|
146
|
+
}
|
|
147
|
+
function createButton(label, tone, onClick) {
|
|
148
|
+
const button = document.createElement('button');
|
|
149
|
+
button.type = 'button';
|
|
150
|
+
button.textContent = label;
|
|
151
|
+
styleButton(button, tone);
|
|
152
|
+
button.addEventListener('click', onClick);
|
|
153
|
+
return button;
|
|
154
|
+
}
|
|
155
|
+
function renderSortBar(text) {
|
|
156
|
+
const arrow = sort.direction === 'asc' ? ' ▲' : ' ▼';
|
|
157
|
+
const fields = [
|
|
158
|
+
['name', text.sortByName],
|
|
159
|
+
['updatedAt', text.sortByDate],
|
|
160
|
+
['byteLength', text.sortBySize]
|
|
161
|
+
];
|
|
162
|
+
for (const [field, label] of fields) {
|
|
163
|
+
const active = sort.field === field;
|
|
164
|
+
const button = createButton(active ? `${label}${arrow}` : label, active ? 'active' : 'plain', () => selectSort(field));
|
|
165
|
+
button.setAttribute('data-sort-field', field);
|
|
166
|
+
if (active)
|
|
167
|
+
button.setAttribute('aria-pressed', 'true');
|
|
168
|
+
sortBar.appendChild(button);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function renderRow(summary, text) {
|
|
172
|
+
const row = document.createElement('div');
|
|
173
|
+
row.setAttribute('data-dsl-file-id', summary.id);
|
|
174
|
+
row.style.cssText =
|
|
175
|
+
'display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #e2e6ee;border-radius:8px;';
|
|
176
|
+
if (renamingId === summary.id) {
|
|
177
|
+
const input = document.createElement('input');
|
|
178
|
+
input.type = 'text';
|
|
179
|
+
input.value = summary.name;
|
|
180
|
+
input.setAttribute('data-rename-input', summary.id);
|
|
181
|
+
input.style.cssText = 'flex:1;min-width:0;padding:5px 8px;border:1px solid #1f6feb;border-radius:6px;font-size:14px;';
|
|
182
|
+
const commit = () => {
|
|
183
|
+
const nextName = input.value;
|
|
184
|
+
renamingId = null;
|
|
185
|
+
mutate(() => options.onRename(summary.id, nextName));
|
|
186
|
+
};
|
|
187
|
+
input.addEventListener('keydown', (event) => {
|
|
188
|
+
const key = event.key;
|
|
189
|
+
if (key === 'Enter')
|
|
190
|
+
commit();
|
|
191
|
+
if (key === 'Escape') {
|
|
192
|
+
renamingId = null;
|
|
193
|
+
render();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
row.appendChild(input);
|
|
197
|
+
row.appendChild(createButton(text.confirm, 'primary', commit));
|
|
198
|
+
row.appendChild(createButton(text.cancel, 'plain', () => {
|
|
199
|
+
renamingId = null;
|
|
200
|
+
render();
|
|
201
|
+
}));
|
|
202
|
+
return row;
|
|
203
|
+
}
|
|
204
|
+
const name = document.createElement('span');
|
|
205
|
+
name.textContent = summary.name;
|
|
206
|
+
name.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;';
|
|
207
|
+
const date = document.createElement('span');
|
|
208
|
+
date.textContent = formatDate(summary.updatedAt);
|
|
209
|
+
date.style.cssText = 'font-size:12px;color:#5a6172;white-space:nowrap;';
|
|
210
|
+
const size = document.createElement('span');
|
|
211
|
+
size.textContent = formatSize(summary.byteLength);
|
|
212
|
+
size.style.cssText = 'font-size:12px;color:#5a6172;white-space:nowrap;min-width:64px;text-align:right;';
|
|
213
|
+
row.appendChild(name);
|
|
214
|
+
row.appendChild(date);
|
|
215
|
+
row.appendChild(size);
|
|
216
|
+
if (confirmingRemovalId === summary.id) {
|
|
217
|
+
row.appendChild(createButton(text.confirmRemove, 'danger', () => {
|
|
218
|
+
confirmingRemovalId = null;
|
|
219
|
+
mutate(() => options.onRemove(summary.id));
|
|
220
|
+
}));
|
|
221
|
+
row.appendChild(createButton(text.cancel, 'plain', () => {
|
|
222
|
+
confirmingRemovalId = null;
|
|
223
|
+
render();
|
|
224
|
+
}));
|
|
225
|
+
return row;
|
|
226
|
+
}
|
|
227
|
+
row.appendChild(createButton(text.open, 'primary', () => {
|
|
228
|
+
mutate(() => options.onOpen(summary.id));
|
|
229
|
+
}));
|
|
230
|
+
row.appendChild(createButton(text.rename, 'plain', () => {
|
|
231
|
+
renamingId = summary.id;
|
|
232
|
+
confirmingRemovalId = null;
|
|
233
|
+
render();
|
|
234
|
+
}));
|
|
235
|
+
row.appendChild(createButton(text.remove, 'plain', () => {
|
|
236
|
+
confirmingRemovalId = summary.id;
|
|
237
|
+
renamingId = null;
|
|
238
|
+
render();
|
|
239
|
+
}));
|
|
240
|
+
return row;
|
|
241
|
+
}
|
|
242
|
+
function render() {
|
|
243
|
+
if (disposed)
|
|
244
|
+
return;
|
|
245
|
+
const text = textFor(locales, locale);
|
|
246
|
+
heading.textContent = text.title;
|
|
247
|
+
closeButton.textContent = text.close;
|
|
248
|
+
closeButton.setAttribute('aria-label', text.close);
|
|
249
|
+
addButton.textContent = text.add;
|
|
250
|
+
statusElement.textContent = status;
|
|
251
|
+
sortBar.replaceChildren();
|
|
252
|
+
renderSortBar(text);
|
|
253
|
+
listElement.replaceChildren();
|
|
254
|
+
if (summaries.length === 0) {
|
|
255
|
+
const empty = document.createElement('p');
|
|
256
|
+
empty.textContent = text.empty;
|
|
257
|
+
empty.style.cssText = 'margin:8px 0;font-size:14px;color:#5a6172;';
|
|
258
|
+
listElement.appendChild(empty);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
for (const summary of summaries)
|
|
262
|
+
listElement.appendChild(renderRow(summary, text));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
styleButton(closeButton, 'plain');
|
|
266
|
+
styleButton(addButton, 'primary');
|
|
267
|
+
closeButton.type = 'button';
|
|
268
|
+
addButton.type = 'button';
|
|
269
|
+
closeButton.addEventListener('click', () => hide());
|
|
270
|
+
addButton.addEventListener('click', () => {
|
|
271
|
+
mutate(() => options.onAdd());
|
|
272
|
+
});
|
|
273
|
+
function hide() {
|
|
274
|
+
if (disposed)
|
|
275
|
+
return;
|
|
276
|
+
renamingId = null;
|
|
277
|
+
confirmingRemovalId = null;
|
|
278
|
+
root.style.display = 'none';
|
|
279
|
+
}
|
|
280
|
+
function ensureActive() {
|
|
281
|
+
if (disposed)
|
|
282
|
+
throw new TypeError('DSL files dialog is disposed');
|
|
283
|
+
}
|
|
284
|
+
render();
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
element: root,
|
|
287
|
+
get locale() {
|
|
288
|
+
return locale;
|
|
289
|
+
},
|
|
290
|
+
get sort() {
|
|
291
|
+
return sort;
|
|
292
|
+
},
|
|
293
|
+
async show(nextLocale) {
|
|
294
|
+
ensureActive();
|
|
295
|
+
if (nextLocale !== undefined)
|
|
296
|
+
locale = nextLocale;
|
|
297
|
+
status = '';
|
|
298
|
+
root.style.display = 'flex';
|
|
299
|
+
await reload();
|
|
300
|
+
return locale;
|
|
301
|
+
},
|
|
302
|
+
hide,
|
|
303
|
+
async refresh() {
|
|
304
|
+
ensureActive();
|
|
305
|
+
await reload();
|
|
306
|
+
},
|
|
307
|
+
setLocale(nextLocale) {
|
|
308
|
+
ensureActive();
|
|
309
|
+
locale = nextLocale;
|
|
310
|
+
render();
|
|
311
|
+
return locale;
|
|
312
|
+
},
|
|
313
|
+
dispose() {
|
|
314
|
+
if (disposed)
|
|
315
|
+
return;
|
|
316
|
+
disposed = true;
|
|
317
|
+
root.remove();
|
|
318
|
+
if (previousMountPosition === '' || previousMountPosition === 'static') {
|
|
319
|
+
mount.style.position = previousMountPosition;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|