@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
|
@@ -0,0 +1,1485 @@
|
|
|
1
|
+
// Name: TurboWarp Title Menu
|
|
2
|
+
// ID: kubohiroyaturbowarptitlemenu
|
|
3
|
+
// Description: Reusable title, application menu, and DSL source storage controls for TurboWarp.
|
|
4
|
+
// By: Hiroya Kubo
|
|
5
|
+
// License: MPL-2.0
|
|
6
|
+
|
|
7
|
+
(function (Scratch) {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
function isRecord$1(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function requireElement$1(value, name) {
|
|
14
|
+
if (!isRecord$1(value) || typeof value["appendChild"] !== "function") {
|
|
15
|
+
throw new TypeError(`${name} must be a DOM element.`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function requireDocument$1(value) {
|
|
20
|
+
if (!isRecord$1(value) || typeof value["createElement"] !== "function") {
|
|
21
|
+
throw new TypeError("document must provide the DOM document contract.");
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function requireString(value, name) {
|
|
26
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
27
|
+
throw new TypeError(`${name} must be a non-empty string.`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function optionalBoolean(value, name, fallback) {
|
|
32
|
+
if (value === void 0)
|
|
33
|
+
return fallback;
|
|
34
|
+
if (typeof value !== "boolean")
|
|
35
|
+
throw new TypeError(`${name} must be a boolean.`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function optionalString(value, name) {
|
|
39
|
+
if (value === void 0)
|
|
40
|
+
return void 0;
|
|
41
|
+
if (typeof value !== "string")
|
|
42
|
+
throw new TypeError(`${name} must be a string.`);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function requireLocalizedLabels(value, name) {
|
|
46
|
+
if (!isRecord$1(value))
|
|
47
|
+
throw new TypeError(`${name} must be an object.`);
|
|
48
|
+
const entries = Object.entries(value);
|
|
49
|
+
if (entries.length === 0)
|
|
50
|
+
throw new TypeError(`${name} must include at least one locale.`);
|
|
51
|
+
for (const [locale, label] of entries) {
|
|
52
|
+
if (typeof label !== "string" || label.length === 0) {
|
|
53
|
+
throw new TypeError(`${name}.${locale} must be a non-empty string.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function firstLocale(locales, name) {
|
|
59
|
+
const [locale] = Object.keys(locales);
|
|
60
|
+
if (locale === void 0)
|
|
61
|
+
throw new TypeError(`${name} must include at least one locale.`);
|
|
62
|
+
return locale;
|
|
63
|
+
}
|
|
64
|
+
function resolveInjectedLocale(locales, requested, fallback, name) {
|
|
65
|
+
if (requested !== void 0 && Object.hasOwn(locales, requested))
|
|
66
|
+
return requested;
|
|
67
|
+
if (fallback !== void 0 && Object.hasOwn(locales, fallback))
|
|
68
|
+
return fallback;
|
|
69
|
+
if (Object.hasOwn(locales, "en"))
|
|
70
|
+
return "en";
|
|
71
|
+
return firstLocale(locales, name);
|
|
72
|
+
}
|
|
73
|
+
function localized(locales, locale, fallback, name) {
|
|
74
|
+
const resolved = resolveInjectedLocale(locales, locale, fallback, name);
|
|
75
|
+
return locales[resolved];
|
|
76
|
+
}
|
|
77
|
+
function requireIcon(value, name) {
|
|
78
|
+
if (value === void 0)
|
|
79
|
+
return void 0;
|
|
80
|
+
if (!isRecord$1(value))
|
|
81
|
+
throw new TypeError(`${name} must be an object.`);
|
|
82
|
+
const url = optionalString(value["url"], `${name}.url`);
|
|
83
|
+
const text = optionalString(value["text"], `${name}.text`);
|
|
84
|
+
const filter = optionalString(value["filter"], `${name}.filter`);
|
|
85
|
+
const size = optionalString(value["size"], `${name}.size`);
|
|
86
|
+
const fontSize = optionalString(value["fontSize"], `${name}.fontSize`);
|
|
87
|
+
if ((url === void 0 || url.length === 0) && (text === void 0 || text.length === 0)) {
|
|
88
|
+
throw new TypeError(`${name} must include a non-empty url or text.`);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...url === void 0 ? {} : { url },
|
|
92
|
+
...text === void 0 ? {} : { text },
|
|
93
|
+
...filter === void 0 ? {} : { filter },
|
|
94
|
+
...size === void 0 ? {} : { size },
|
|
95
|
+
...fontSize === void 0 ? {} : { fontSize }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function requireAttributes(value, name) {
|
|
99
|
+
if (value === void 0)
|
|
100
|
+
return void 0;
|
|
101
|
+
if (!isRecord$1(value))
|
|
102
|
+
throw new TypeError(`${name} must be an object.`);
|
|
103
|
+
for (const [attribute, attributeValue] of Object.entries(value)) {
|
|
104
|
+
if (attribute.length === 0)
|
|
105
|
+
throw new TypeError(`${name} names must be non-empty.`);
|
|
106
|
+
if (typeof attributeValue !== "string") {
|
|
107
|
+
throw new TypeError(`${name}.${attribute} must be a string.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
function applyAttributes(element, attributes) {
|
|
113
|
+
if (attributes === void 0)
|
|
114
|
+
return;
|
|
115
|
+
for (const [name, value] of Object.entries(attributes))
|
|
116
|
+
element.setAttribute(name, value);
|
|
117
|
+
}
|
|
118
|
+
function requireRect(value, name) {
|
|
119
|
+
if (value === void 0)
|
|
120
|
+
return void 0;
|
|
121
|
+
if (!isRecord$1(value))
|
|
122
|
+
throw new TypeError(`${name} must be an object.`);
|
|
123
|
+
const left = optionalString(value["left"], `${name}.left`);
|
|
124
|
+
const top = optionalString(value["top"], `${name}.top`);
|
|
125
|
+
const width = optionalString(value["width"], `${name}.width`);
|
|
126
|
+
const height = optionalString(value["height"], `${name}.height`);
|
|
127
|
+
return {
|
|
128
|
+
...left === void 0 ? {} : { left },
|
|
129
|
+
...top === void 0 ? {} : { top },
|
|
130
|
+
...width === void 0 ? {} : { width },
|
|
131
|
+
...height === void 0 ? {} : { height }
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function applyRect(element, rect) {
|
|
135
|
+
if (rect === void 0)
|
|
136
|
+
return;
|
|
137
|
+
if (rect.left !== void 0)
|
|
138
|
+
element.style.left = rect.left;
|
|
139
|
+
if (rect.top !== void 0)
|
|
140
|
+
element.style.top = rect.top;
|
|
141
|
+
if (rect.width !== void 0)
|
|
142
|
+
element.style.width = rect.width;
|
|
143
|
+
if (rect.height !== void 0)
|
|
144
|
+
element.style.height = rect.height;
|
|
145
|
+
}
|
|
146
|
+
function requireTestId(value, name) {
|
|
147
|
+
if (value === void 0)
|
|
148
|
+
return void 0;
|
|
149
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
150
|
+
throw new TypeError(`${name} must be a non-empty string.`);
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
function applyTestId(element, testId) {
|
|
155
|
+
if (testId !== void 0)
|
|
156
|
+
element.setAttribute("data-testid", testId);
|
|
157
|
+
}
|
|
158
|
+
function restoreableMountPosition(document, mount) {
|
|
159
|
+
if (mount === document.body)
|
|
160
|
+
return null;
|
|
161
|
+
const previous = mount.style.position;
|
|
162
|
+
if (previous === void 0 || previous === "" || previous === "static") {
|
|
163
|
+
mount.style.position = "relative";
|
|
164
|
+
return () => {
|
|
165
|
+
mount.style.position = previous ?? "";
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
function setButtonEnabled(button, enabled) {
|
|
171
|
+
button.disabled = !enabled;
|
|
172
|
+
button.setAttribute("aria-disabled", String(!enabled));
|
|
173
|
+
button.style.cursor = enabled ? "pointer" : "not-allowed";
|
|
174
|
+
button.style.opacity = enabled ? "1" : "0.42";
|
|
175
|
+
}
|
|
176
|
+
function setElementVisible(element, visible, display) {
|
|
177
|
+
element.hidden = !visible;
|
|
178
|
+
element.style.display = visible ? display : "none";
|
|
179
|
+
}
|
|
180
|
+
function reportActionError(onError, error) {
|
|
181
|
+
try {
|
|
182
|
+
onError?.(error);
|
|
183
|
+
} catch {
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function invokeAction(action, onError, event) {
|
|
187
|
+
event?.preventDefault();
|
|
188
|
+
event?.stopPropagation();
|
|
189
|
+
try {
|
|
190
|
+
Promise.resolve(action()).catch((error) => {
|
|
191
|
+
reportActionError(onError, error);
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
reportActionError(onError, error);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function iconCssUrl(url) {
|
|
198
|
+
return `url("${url.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}")`;
|
|
199
|
+
}
|
|
200
|
+
function renderIcon(element, icon) {
|
|
201
|
+
element.textContent = "";
|
|
202
|
+
element.style.backgroundImage = "";
|
|
203
|
+
element.style.display = icon === void 0 ? "none" : "inline-flex";
|
|
204
|
+
if (icon === void 0)
|
|
205
|
+
return;
|
|
206
|
+
element.style.filter = icon.filter ?? "";
|
|
207
|
+
if (icon.size !== void 0) {
|
|
208
|
+
element.style.width = icon.size;
|
|
209
|
+
element.style.height = icon.size;
|
|
210
|
+
}
|
|
211
|
+
if (icon.fontSize !== void 0)
|
|
212
|
+
element.style.fontSize = icon.fontSize;
|
|
213
|
+
if (icon.url !== void 0) {
|
|
214
|
+
element.style.backgroundImage = iconCssUrl(icon.url);
|
|
215
|
+
element.style.backgroundPosition = "center";
|
|
216
|
+
element.style.backgroundRepeat = "no-repeat";
|
|
217
|
+
element.style.backgroundSize = "contain";
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
element.textContent = icon.text ?? "";
|
|
221
|
+
}
|
|
222
|
+
function loadingTone(tone) {
|
|
223
|
+
if (tone === "warning")
|
|
224
|
+
return "#805300";
|
|
225
|
+
if (tone === "error")
|
|
226
|
+
return "#a00020";
|
|
227
|
+
if (tone === "info")
|
|
228
|
+
return "#176e9f";
|
|
229
|
+
return "#35524c";
|
|
230
|
+
}
|
|
231
|
+
function resolveAppShellLocale(input) {
|
|
232
|
+
const preferred = globalThis.navigator?.language ?? "";
|
|
233
|
+
return /^ja(?:-|$)/iu.test(preferred) ? "ja" : "en";
|
|
234
|
+
}
|
|
235
|
+
function boundedText(value, limit = 2e3) {
|
|
236
|
+
const text = String(value ?? "");
|
|
237
|
+
const scalars = [...text];
|
|
238
|
+
return scalars.length <= limit ? text : `${scalars.slice(0, limit - 1).join("")}...`;
|
|
239
|
+
}
|
|
240
|
+
function createAppShellApplicationMenu(options) {
|
|
241
|
+
if (!isRecord$1(options))
|
|
242
|
+
throw new TypeError("application menu options must be an object.");
|
|
243
|
+
const document = requireDocument$1(options.document);
|
|
244
|
+
const mount = requireElement$1(options.mount, "mount");
|
|
245
|
+
if (!Array.isArray(options.actions) || options.actions.length === 0) {
|
|
246
|
+
throw new TypeError("actions must include at least one menu action.");
|
|
247
|
+
}
|
|
248
|
+
const fallbackLocale = optionalString(options.fallbackLocale, "fallbackLocale");
|
|
249
|
+
const rootTestId = requireTestId(options.rootTestId, "rootTestId");
|
|
250
|
+
const statusTestId = requireTestId(options.statusTestId, "statusTestId");
|
|
251
|
+
const menuAttributes = options.attributes ?? {};
|
|
252
|
+
if (!isRecord$1(menuAttributes))
|
|
253
|
+
throw new TypeError("attributes must be an object.");
|
|
254
|
+
const rootAttributes = requireAttributes(menuAttributes.root, "attributes.root");
|
|
255
|
+
const statusAttributes = requireAttributes(menuAttributes.status, "attributes.status");
|
|
256
|
+
const root = document.createElement("section");
|
|
257
|
+
const status = document.createElement("p");
|
|
258
|
+
const restoreMountPosition = restoreableMountPosition(document, mount);
|
|
259
|
+
root.setAttribute("data-turbowarp-app-shell-application-menu", "true");
|
|
260
|
+
root.setAttribute("aria-label", options.ariaLabel ?? "Application menu");
|
|
261
|
+
root.style.cssText = "position:absolute;inset:0;z-index:2147483600;display:none;box-sizing:border-box;overflow:hidden;pointer-events:auto;font-family:sans-serif;container-type:inline-size;";
|
|
262
|
+
root.style.position = "absolute";
|
|
263
|
+
root.style.display = "none";
|
|
264
|
+
root.style.cursor = "pointer";
|
|
265
|
+
applyTestId(root, rootTestId);
|
|
266
|
+
applyAttributes(root, rootAttributes);
|
|
267
|
+
status.setAttribute("data-turbowarp-app-shell-menu-status", "true");
|
|
268
|
+
status.setAttribute("role", "status");
|
|
269
|
+
status.setAttribute("aria-live", "polite");
|
|
270
|
+
status.style.cssText = "position:absolute;left:10%;top:93%;width:80%;margin:0;color:#35524c;font-size:2.7cqw;line-height:1.1;text-align:center;";
|
|
271
|
+
applyTestId(status, statusTestId);
|
|
272
|
+
applyAttributes(status, statusAttributes);
|
|
273
|
+
const buttons = /* @__PURE__ */ new Map();
|
|
274
|
+
const seen = /* @__PURE__ */ new Set();
|
|
275
|
+
let locale = optionalString(options.initialLocale, "initialLocale") ?? resolveAppShellLocale();
|
|
276
|
+
for (const [index, definition] of options.actions.entries()) {
|
|
277
|
+
if (!isRecord$1(definition))
|
|
278
|
+
throw new TypeError(`actions.${index} must be an object.`);
|
|
279
|
+
const id = requireString(definition.id, `actions.${index}.id`);
|
|
280
|
+
if (seen.has(id))
|
|
281
|
+
throw new TypeError(`Duplicate application menu action id: ${id}.`);
|
|
282
|
+
seen.add(id);
|
|
283
|
+
const labels = requireLocalizedLabels(definition.labels, `actions.${index}.labels`);
|
|
284
|
+
const icon = requireIcon(definition.icon, `actions.${index}.icon`);
|
|
285
|
+
const enabled = optionalBoolean(definition.enabled, `actions.${index}.enabled`, true);
|
|
286
|
+
const visible = optionalBoolean(definition.visible, `actions.${index}.visible`, true);
|
|
287
|
+
const testId = requireTestId(definition.testId, `actions.${index}.testId`);
|
|
288
|
+
const position = requireRect(definition.position, `actions.${index}.position`);
|
|
289
|
+
const actionAttributes = requireAttributes(definition.attributes, `actions.${index}.attributes`);
|
|
290
|
+
if (typeof definition.onSelect !== "function") {
|
|
291
|
+
throw new TypeError(`actions.${index}.onSelect must be a function.`);
|
|
292
|
+
}
|
|
293
|
+
const onSelect = definition.onSelect;
|
|
294
|
+
const button = document.createElement("button");
|
|
295
|
+
const iconElement = document.createElement("span");
|
|
296
|
+
const label = document.createElement("span");
|
|
297
|
+
const row = Math.floor(index / 2);
|
|
298
|
+
const column = index % 2;
|
|
299
|
+
button.type = "button";
|
|
300
|
+
button.setAttribute("data-turbowarp-app-shell-menu-action", id);
|
|
301
|
+
button.style.cssText = `position:absolute;left:${column === 0 ? "10%" : "53.3333%"};top:${25.5556 + row * 30}%;width:36.6667%;height:24.4444%;display:flex;min-width:0;min-height:0;align-items:center;justify-content:center;flex-direction:column;gap:.4167cqw;border:.4167cqw solid #005f50;border-radius:2.9167cqw;background:#007d66;color:#fff;box-shadow:0 .625cqw 1.6667cqw rgba(0,0,0,.2);cursor:pointer;font:inherit;`;
|
|
302
|
+
button.style.cursor = "pointer";
|
|
303
|
+
applyTestId(button, testId);
|
|
304
|
+
applyAttributes(button, actionAttributes);
|
|
305
|
+
iconElement.setAttribute("aria-hidden", "true");
|
|
306
|
+
iconElement.style.cssText = "display:inline-flex;width:10cqw;height:10cqw;align-items:center;justify-content:center;line-height:1;font-size:6cqw;";
|
|
307
|
+
label.style.cssText = "font-size:3.8cqw;line-height:1.15;text-align:center;";
|
|
308
|
+
button.appendChild(iconElement);
|
|
309
|
+
button.appendChild(label);
|
|
310
|
+
const onClick = (event) => {
|
|
311
|
+
const current = buttons.get(id);
|
|
312
|
+
if (current === void 0 || !current.enabled || !current.visible)
|
|
313
|
+
return;
|
|
314
|
+
invokeAction(current.onSelect, options.onError, event);
|
|
315
|
+
};
|
|
316
|
+
button.addEventListener("click", onClick);
|
|
317
|
+
buttons.set(id, {
|
|
318
|
+
button,
|
|
319
|
+
icon: iconElement,
|
|
320
|
+
label,
|
|
321
|
+
labels,
|
|
322
|
+
iconDefinition: icon,
|
|
323
|
+
enabled,
|
|
324
|
+
visible,
|
|
325
|
+
position,
|
|
326
|
+
onSelect,
|
|
327
|
+
onClick
|
|
328
|
+
});
|
|
329
|
+
root.appendChild(button);
|
|
330
|
+
}
|
|
331
|
+
root.appendChild(status);
|
|
332
|
+
mount.appendChild(root);
|
|
333
|
+
let statusState = {
|
|
334
|
+
text: options.status?.text ?? "",
|
|
335
|
+
visible: options.status?.visible ?? false,
|
|
336
|
+
tone: options.status?.tone ?? "neutral",
|
|
337
|
+
color: optionalString(options.status?.color, "status.color")
|
|
338
|
+
};
|
|
339
|
+
if (typeof statusState.text !== "string")
|
|
340
|
+
throw new TypeError("status.text must be a string.");
|
|
341
|
+
if (typeof statusState.visible !== "boolean")
|
|
342
|
+
throw new TypeError("status.visible must be a boolean.");
|
|
343
|
+
if (!["neutral", "info", "warning", "error"].includes(statusState.tone)) {
|
|
344
|
+
throw new TypeError("status.tone must be neutral, info, warning, or error.");
|
|
345
|
+
}
|
|
346
|
+
let disposed = false;
|
|
347
|
+
function renderMenu() {
|
|
348
|
+
for (const [id, value] of buttons) {
|
|
349
|
+
const label = localized(value.labels, locale, fallbackLocale, `menu action ${id} labels`);
|
|
350
|
+
value.label.textContent = label;
|
|
351
|
+
value.button.setAttribute("aria-label", label);
|
|
352
|
+
renderIcon(value.icon, value.iconDefinition);
|
|
353
|
+
setButtonEnabled(value.button, value.enabled);
|
|
354
|
+
setElementVisible(value.button, value.visible, "flex");
|
|
355
|
+
value.button.style.boxShadow = value.enabled ? "0 .625cqw 1.6667cqw rgba(0,0,0,.2)" : "none";
|
|
356
|
+
applyRect(value.button, value.position);
|
|
357
|
+
}
|
|
358
|
+
status.textContent = boundedText(statusState.text, 500);
|
|
359
|
+
status.style.color = statusState.color ?? loadingTone(statusState.tone);
|
|
360
|
+
setElementVisible(status, statusState.visible && statusState.text.length > 0, "block");
|
|
361
|
+
}
|
|
362
|
+
function ensureActive() {
|
|
363
|
+
if (disposed)
|
|
364
|
+
throw new TypeError("application menu is disposed.");
|
|
365
|
+
}
|
|
366
|
+
renderMenu();
|
|
367
|
+
return Object.freeze({
|
|
368
|
+
show(nextLocale) {
|
|
369
|
+
ensureActive();
|
|
370
|
+
locale = optionalString(nextLocale, "nextLocale") ?? locale;
|
|
371
|
+
renderMenu();
|
|
372
|
+
root.style.display = "block";
|
|
373
|
+
return locale;
|
|
374
|
+
},
|
|
375
|
+
hide() {
|
|
376
|
+
if (!disposed)
|
|
377
|
+
root.style.display = "none";
|
|
378
|
+
},
|
|
379
|
+
setLocale(nextLocale) {
|
|
380
|
+
ensureActive();
|
|
381
|
+
locale = requireString(nextLocale, "nextLocale");
|
|
382
|
+
renderMenu();
|
|
383
|
+
return locale;
|
|
384
|
+
},
|
|
385
|
+
setActionState(id, state) {
|
|
386
|
+
ensureActive();
|
|
387
|
+
const action = buttons.get(requireString(id, "action id"));
|
|
388
|
+
if (action === void 0)
|
|
389
|
+
throw new TypeError(`Unknown application menu action: ${id}.`);
|
|
390
|
+
if (!isRecord$1(state))
|
|
391
|
+
throw new TypeError("application menu action state must be an object.");
|
|
392
|
+
if (state.enabled !== void 0) {
|
|
393
|
+
if (typeof state.enabled !== "boolean")
|
|
394
|
+
throw new TypeError("action enabled must be a boolean.");
|
|
395
|
+
action.enabled = state.enabled;
|
|
396
|
+
}
|
|
397
|
+
if (state.visible !== void 0) {
|
|
398
|
+
if (typeof state.visible !== "boolean")
|
|
399
|
+
throw new TypeError("action visible must be a boolean.");
|
|
400
|
+
action.visible = state.visible;
|
|
401
|
+
}
|
|
402
|
+
if (state.labels !== void 0) {
|
|
403
|
+
action.labels = requireLocalizedLabels(state.labels, "action state labels");
|
|
404
|
+
}
|
|
405
|
+
if (state.icon !== void 0) {
|
|
406
|
+
action.iconDefinition = requireIcon(state.icon, "action state icon");
|
|
407
|
+
}
|
|
408
|
+
if (state.position !== void 0) {
|
|
409
|
+
action.position = requireRect(state.position, "action state position");
|
|
410
|
+
}
|
|
411
|
+
renderMenu();
|
|
412
|
+
},
|
|
413
|
+
setStatus(nextStatus) {
|
|
414
|
+
ensureActive();
|
|
415
|
+
if (!isRecord$1(nextStatus))
|
|
416
|
+
throw new TypeError("application menu status must be an object.");
|
|
417
|
+
const nextToneValue = nextStatus["tone"] ?? statusState.tone;
|
|
418
|
+
if (!["neutral", "info", "warning", "error"].includes(String(nextToneValue))) {
|
|
419
|
+
throw new TypeError("status.tone must be neutral, info, warning, or error.");
|
|
420
|
+
}
|
|
421
|
+
const nextTone = nextToneValue;
|
|
422
|
+
statusState = {
|
|
423
|
+
text: optionalString(nextStatus["text"], "status.text") ?? statusState.text,
|
|
424
|
+
visible: optionalBoolean(nextStatus["visible"], "status.visible", statusState.visible),
|
|
425
|
+
tone: nextTone,
|
|
426
|
+
color: optionalString(nextStatus["color"], "status.color") ?? statusState.color
|
|
427
|
+
};
|
|
428
|
+
renderMenu();
|
|
429
|
+
},
|
|
430
|
+
dispose() {
|
|
431
|
+
if (disposed)
|
|
432
|
+
return;
|
|
433
|
+
disposed = true;
|
|
434
|
+
for (const { button, onClick } of buttons.values()) {
|
|
435
|
+
button.removeEventListener("click", onClick);
|
|
436
|
+
}
|
|
437
|
+
buttons.clear();
|
|
438
|
+
root.remove();
|
|
439
|
+
restoreMountPosition?.();
|
|
440
|
+
},
|
|
441
|
+
get element() {
|
|
442
|
+
return root;
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const extensionConfig = {
|
|
447
|
+
id: "kubohiroyaturbowarptitlemenu",
|
|
448
|
+
name: "TurboWarp Title Menu",
|
|
449
|
+
homepage: "https://github.com/kubohiroya/turbowarp-title-menu",
|
|
450
|
+
docsURI: "https://kubohiroya.github.io/turbowarp-title-menu/",
|
|
451
|
+
blockIconURI: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCI+PHJlY3QgeD0iNSIgeT0iOCIgd2lkdGg9IjM4IiBoZWlnaHQ9IjMyIiByeD0iNCIgZmlsbD0iIzAwN2Q2NiIvPjxyZWN0IHg9IjkiIHk9IjEyIiB3aWR0aD0iMzAiIGhlaWdodD0iMjQiIHJ4PSIyIiBmaWxsPSIjZjRmZmZiIi8+PHBhdGggZD0iTTE1IDE5aDE4TTE1IDI0aDE4TTE1IDI5aDEyIiBzdHJva2U9IiMwMDdkNjYiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+"
|
|
452
|
+
};
|
|
453
|
+
function isRecord(value) {
|
|
454
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
455
|
+
}
|
|
456
|
+
function requireDocument(value) {
|
|
457
|
+
if (!isRecord(value) || typeof value.createElement !== "function") {
|
|
458
|
+
throw new TypeError("document must provide createElement");
|
|
459
|
+
}
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
462
|
+
function requireElement(value, name) {
|
|
463
|
+
if (!isRecord(value) || typeof value.appendChild !== "function") {
|
|
464
|
+
throw new TypeError(`${name} must be a DOM element`);
|
|
465
|
+
}
|
|
466
|
+
return value;
|
|
467
|
+
}
|
|
468
|
+
function ensureRelativeMount(mount) {
|
|
469
|
+
const previous = mount.style.position;
|
|
470
|
+
if (previous === "" || previous === "static") {
|
|
471
|
+
mount.style.position = "relative";
|
|
472
|
+
return () => {
|
|
473
|
+
mount.style.position = previous;
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return () => {
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
function invokeSafely(operation, onError) {
|
|
480
|
+
try {
|
|
481
|
+
Promise.resolve(operation()).catch((error) => onError?.(error));
|
|
482
|
+
} catch (error) {
|
|
483
|
+
onError?.(error);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
class DslStoreError extends Error {
|
|
487
|
+
constructor(code, message, cause) {
|
|
488
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
489
|
+
this.name = "DslStoreError";
|
|
490
|
+
this.code = code;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const defaultDslSort = Object.freeze({ field: "updatedAt", direction: "desc" });
|
|
494
|
+
const fileStoreName = "files";
|
|
495
|
+
const metaStoreName = "meta";
|
|
496
|
+
const lastOpenedKey = "last-opened";
|
|
497
|
+
const nameIndexName = "by-name";
|
|
498
|
+
const encoder = new TextEncoder();
|
|
499
|
+
const maximumNameLength = 200;
|
|
500
|
+
function hasControlCharacter(value) {
|
|
501
|
+
for (const character of value) {
|
|
502
|
+
const code = character.codePointAt(0) ?? 0;
|
|
503
|
+
if (code < 32 || code === 127) return true;
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
function normalizeName(value) {
|
|
508
|
+
if (typeof value !== "string") throw new DslStoreError("invalid-name", "DSL name must be a string.");
|
|
509
|
+
const name = value.trim();
|
|
510
|
+
if (name.length === 0) throw new DslStoreError("invalid-name", "DSL name must not be empty.");
|
|
511
|
+
if (name.length > maximumNameLength) {
|
|
512
|
+
throw new DslStoreError("invalid-name", `DSL name must be at most ${maximumNameLength} characters.`);
|
|
513
|
+
}
|
|
514
|
+
if (hasControlCharacter(name)) {
|
|
515
|
+
throw new DslStoreError("invalid-name", "DSL name must not contain control characters.");
|
|
516
|
+
}
|
|
517
|
+
return name;
|
|
518
|
+
}
|
|
519
|
+
function request(input) {
|
|
520
|
+
return new Promise((resolve, reject) => {
|
|
521
|
+
input.onsuccess = () => resolve(input.result);
|
|
522
|
+
input.onerror = () => reject(toStoreError(input.error));
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
function transactionDone(transaction) {
|
|
526
|
+
return new Promise((resolve, reject) => {
|
|
527
|
+
transaction.oncomplete = () => resolve();
|
|
528
|
+
transaction.onabort = () => reject(toStoreError(transaction.error));
|
|
529
|
+
transaction.onerror = () => reject(toStoreError(transaction.error));
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
function toStoreError(cause) {
|
|
533
|
+
const name = cause?.name;
|
|
534
|
+
if (name === "QuotaExceededError") {
|
|
535
|
+
return new DslStoreError("quota", "Browser storage is full. Delete a saved DSL file first.", cause);
|
|
536
|
+
}
|
|
537
|
+
if (name === "ConstraintError") {
|
|
538
|
+
return new DslStoreError("name-taken", "A DSL file with that name already exists.", cause);
|
|
539
|
+
}
|
|
540
|
+
return new DslStoreError("failed", "The DSL storage operation failed.", cause);
|
|
541
|
+
}
|
|
542
|
+
function compareSummaries(sort) {
|
|
543
|
+
const direction = sort.direction === "asc" ? 1 : -1;
|
|
544
|
+
return (left, right) => {
|
|
545
|
+
if (sort.field === "name") {
|
|
546
|
+
return direction * left.name.localeCompare(right.name, void 0, { numeric: true });
|
|
547
|
+
}
|
|
548
|
+
if (sort.field === "byteLength") {
|
|
549
|
+
return direction * (left.byteLength - right.byteLength) || left.name.localeCompare(right.name);
|
|
550
|
+
}
|
|
551
|
+
return direction * left.updatedAt.localeCompare(right.updatedAt) || left.name.localeCompare(right.name);
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
function toSummary(record) {
|
|
555
|
+
return {
|
|
556
|
+
id: record.id,
|
|
557
|
+
name: record.name,
|
|
558
|
+
byteLength: record.byteLength,
|
|
559
|
+
savedAt: record.savedAt,
|
|
560
|
+
updatedAt: record.updatedAt
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function createDslStore(options = {}) {
|
|
564
|
+
const factory = options.indexedDB ?? globalThis.indexedDB;
|
|
565
|
+
if (factory === void 0 || typeof factory.open !== "function") {
|
|
566
|
+
throw new DslStoreError("unavailable", "IndexedDB is not available in this environment.");
|
|
567
|
+
}
|
|
568
|
+
const databaseName = options.databaseName ?? "turbowarp-title-menu";
|
|
569
|
+
const maxSourceBytes = options.maxSourceBytes ?? 1024 * 1024;
|
|
570
|
+
const maxFileCount = options.maxFileCount ?? 64;
|
|
571
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
572
|
+
const createId = options.createId ?? defaultCreateId;
|
|
573
|
+
let connection = null;
|
|
574
|
+
function open() {
|
|
575
|
+
connection ?? (connection = new Promise((resolve, reject) => {
|
|
576
|
+
const opening = factory.open(databaseName, 1);
|
|
577
|
+
opening.onupgradeneeded = () => {
|
|
578
|
+
const database = opening.result;
|
|
579
|
+
if (!database.objectStoreNames.contains(fileStoreName)) {
|
|
580
|
+
const files = database.createObjectStore(fileStoreName, { keyPath: "id" });
|
|
581
|
+
files.createIndex(nameIndexName, "name", { unique: true });
|
|
582
|
+
}
|
|
583
|
+
if (!database.objectStoreNames.contains(metaStoreName)) {
|
|
584
|
+
database.createObjectStore(metaStoreName, { keyPath: "key" });
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
opening.onsuccess = () => resolve(opening.result);
|
|
588
|
+
opening.onerror = () => reject(toStoreError(opening.error));
|
|
589
|
+
opening.onblocked = () => reject(new DslStoreError("failed", "Another tab is upgrading the DSL database."));
|
|
590
|
+
}));
|
|
591
|
+
return connection;
|
|
592
|
+
}
|
|
593
|
+
async function readAll() {
|
|
594
|
+
const database = await open();
|
|
595
|
+
const transaction = database.transaction(fileStoreName, "readonly");
|
|
596
|
+
const records = await request(
|
|
597
|
+
transaction.objectStore(fileStoreName).getAll()
|
|
598
|
+
);
|
|
599
|
+
await transactionDone(transaction);
|
|
600
|
+
return records;
|
|
601
|
+
}
|
|
602
|
+
async function readOne(id) {
|
|
603
|
+
const database = await open();
|
|
604
|
+
const transaction = database.transaction(fileStoreName, "readonly");
|
|
605
|
+
const record = await request(
|
|
606
|
+
transaction.objectStore(fileStoreName).get(id)
|
|
607
|
+
);
|
|
608
|
+
await transactionDone(transaction);
|
|
609
|
+
return record ?? null;
|
|
610
|
+
}
|
|
611
|
+
return Object.freeze({
|
|
612
|
+
databaseName,
|
|
613
|
+
async list(sort = defaultDslSort) {
|
|
614
|
+
const records = await readAll();
|
|
615
|
+
return records.map(toSummary).sort(compareSummaries(sort));
|
|
616
|
+
},
|
|
617
|
+
async count() {
|
|
618
|
+
const database = await open();
|
|
619
|
+
const transaction = database.transaction(fileStoreName, "readonly");
|
|
620
|
+
const total = await request(transaction.objectStore(fileStoreName).count());
|
|
621
|
+
await transactionDone(transaction);
|
|
622
|
+
return total;
|
|
623
|
+
},
|
|
624
|
+
get(id) {
|
|
625
|
+
return readOne(id);
|
|
626
|
+
},
|
|
627
|
+
async save(file) {
|
|
628
|
+
const name = normalizeName(file.name);
|
|
629
|
+
if (typeof file.source !== "string") {
|
|
630
|
+
throw new DslStoreError("invalid-source", "DSL source must be a string.");
|
|
631
|
+
}
|
|
632
|
+
const byteLength = encoder.encode(file.source).byteLength;
|
|
633
|
+
if (byteLength > maxSourceBytes) {
|
|
634
|
+
throw new DslStoreError("too-large", `DSL source exceeds ${maxSourceBytes} bytes.`);
|
|
635
|
+
}
|
|
636
|
+
const database = await open();
|
|
637
|
+
const transaction = database.transaction(fileStoreName, "readwrite");
|
|
638
|
+
const files = transaction.objectStore(fileStoreName);
|
|
639
|
+
const existing = await request(
|
|
640
|
+
files.index(nameIndexName).get(name)
|
|
641
|
+
);
|
|
642
|
+
if (existing === void 0) {
|
|
643
|
+
const total = await request(files.count());
|
|
644
|
+
if (total >= maxFileCount) {
|
|
645
|
+
transaction.abort();
|
|
646
|
+
throw new DslStoreError("too-many", `The DSL store already holds ${maxFileCount} files.`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const timestamp = now().toISOString();
|
|
650
|
+
const record = {
|
|
651
|
+
id: existing?.id ?? createId(),
|
|
652
|
+
name,
|
|
653
|
+
source: file.source,
|
|
654
|
+
byteLength,
|
|
655
|
+
savedAt: existing?.savedAt ?? timestamp,
|
|
656
|
+
updatedAt: timestamp
|
|
657
|
+
};
|
|
658
|
+
await request(files.put(record));
|
|
659
|
+
await transactionDone(transaction);
|
|
660
|
+
return record;
|
|
661
|
+
},
|
|
662
|
+
async rename(id, nextName) {
|
|
663
|
+
const name = normalizeName(nextName);
|
|
664
|
+
const database = await open();
|
|
665
|
+
const transaction = database.transaction(fileStoreName, "readwrite");
|
|
666
|
+
const files = transaction.objectStore(fileStoreName);
|
|
667
|
+
const existing = await request(
|
|
668
|
+
files.get(id)
|
|
669
|
+
);
|
|
670
|
+
if (existing === void 0) {
|
|
671
|
+
transaction.abort();
|
|
672
|
+
throw new DslStoreError("not-found", `No DSL file with id ${id}.`);
|
|
673
|
+
}
|
|
674
|
+
if (existing.name !== name) {
|
|
675
|
+
const taken = await request(
|
|
676
|
+
files.index(nameIndexName).get(name)
|
|
677
|
+
);
|
|
678
|
+
if (taken !== void 0) {
|
|
679
|
+
transaction.abort();
|
|
680
|
+
throw new DslStoreError("name-taken", `A DSL file named ${name} already exists.`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const record = { ...existing, name, updatedAt: now().toISOString() };
|
|
684
|
+
await request(files.put(record));
|
|
685
|
+
await transactionDone(transaction);
|
|
686
|
+
return record;
|
|
687
|
+
},
|
|
688
|
+
async remove(id) {
|
|
689
|
+
const database = await open();
|
|
690
|
+
const transaction = database.transaction([fileStoreName, metaStoreName], "readwrite");
|
|
691
|
+
await request(transaction.objectStore(fileStoreName).delete(id));
|
|
692
|
+
const meta = transaction.objectStore(metaStoreName);
|
|
693
|
+
const pointer = await request(
|
|
694
|
+
meta.get(lastOpenedKey)
|
|
695
|
+
);
|
|
696
|
+
if (pointer?.id === id) await request(meta.delete(lastOpenedKey));
|
|
697
|
+
await transactionDone(transaction);
|
|
698
|
+
},
|
|
699
|
+
async clear() {
|
|
700
|
+
const database = await open();
|
|
701
|
+
const transaction = database.transaction([fileStoreName, metaStoreName], "readwrite");
|
|
702
|
+
await request(transaction.objectStore(fileStoreName).clear());
|
|
703
|
+
await request(transaction.objectStore(metaStoreName).clear());
|
|
704
|
+
await transactionDone(transaction);
|
|
705
|
+
},
|
|
706
|
+
async lastOpened() {
|
|
707
|
+
const database = await open();
|
|
708
|
+
const transaction = database.transaction(metaStoreName, "readonly");
|
|
709
|
+
const pointer = await request(
|
|
710
|
+
transaction.objectStore(metaStoreName).get(lastOpenedKey)
|
|
711
|
+
);
|
|
712
|
+
await transactionDone(transaction);
|
|
713
|
+
if (pointer === void 0) return null;
|
|
714
|
+
return readOne(pointer.id);
|
|
715
|
+
},
|
|
716
|
+
async markOpened(id) {
|
|
717
|
+
const record = await readOne(id);
|
|
718
|
+
if (record === null) throw new DslStoreError("not-found", `No DSL file with id ${id}.`);
|
|
719
|
+
const database = await open();
|
|
720
|
+
const transaction = database.transaction(metaStoreName, "readwrite");
|
|
721
|
+
await request(transaction.objectStore(metaStoreName).put({ key: lastOpenedKey, id }));
|
|
722
|
+
await transactionDone(transaction);
|
|
723
|
+
},
|
|
724
|
+
close() {
|
|
725
|
+
const pending = connection;
|
|
726
|
+
connection = null;
|
|
727
|
+
void pending?.then((database) => database.close()).catch(() => void 0);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
function defaultCreateId() {
|
|
732
|
+
const crypto = globalThis.crypto;
|
|
733
|
+
if (typeof crypto?.randomUUID === "function") return crypto.randomUUID();
|
|
734
|
+
return `dsl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
735
|
+
}
|
|
736
|
+
async function readDslFile(file, maxSourceBytes = 1024 * 1024) {
|
|
737
|
+
if (typeof file?.text !== "function") {
|
|
738
|
+
throw new DslStoreError("invalid-source", "file must be a browser File.");
|
|
739
|
+
}
|
|
740
|
+
if (file.size > maxSourceBytes) {
|
|
741
|
+
throw new DslStoreError("too-large", `DSL file exceeds ${maxSourceBytes} bytes.`);
|
|
742
|
+
}
|
|
743
|
+
return { name: file.name, source: await file.text() };
|
|
744
|
+
}
|
|
745
|
+
const defaultSortDirections = Object.freeze({
|
|
746
|
+
name: "asc",
|
|
747
|
+
updatedAt: "desc",
|
|
748
|
+
byteLength: "desc"
|
|
749
|
+
});
|
|
750
|
+
function requireLocales(value) {
|
|
751
|
+
if (!isRecord(value) || Object.keys(value).length === 0) {
|
|
752
|
+
throw new TypeError("locales must contain at least one locale");
|
|
753
|
+
}
|
|
754
|
+
return value;
|
|
755
|
+
}
|
|
756
|
+
function textFor(locales, locale) {
|
|
757
|
+
const text = locales[locale] ?? locales["en"] ?? Object.values(locales)[0];
|
|
758
|
+
if (text === void 0) throw new TypeError("locales must contain at least one locale");
|
|
759
|
+
return text;
|
|
760
|
+
}
|
|
761
|
+
function defaultFormatSize(byteLength) {
|
|
762
|
+
if (byteLength < 1024) return `${byteLength} B`;
|
|
763
|
+
if (byteLength < 1024 * 1024) return `${(byteLength / 1024).toFixed(1)} KB`;
|
|
764
|
+
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`;
|
|
765
|
+
}
|
|
766
|
+
function defaultFormatDate(isoDate) {
|
|
767
|
+
const parsed = new Date(isoDate);
|
|
768
|
+
if (Number.isNaN(parsed.getTime())) return isoDate;
|
|
769
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
770
|
+
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`;
|
|
771
|
+
}
|
|
772
|
+
function defaultDescribeError(error) {
|
|
773
|
+
if (error instanceof Error && error.message.length > 0) return error.message;
|
|
774
|
+
return String(error);
|
|
775
|
+
}
|
|
776
|
+
function createDslFilesDialog(options) {
|
|
777
|
+
if (!isRecord(options)) throw new TypeError("DSL files dialog options must be an object");
|
|
778
|
+
const document = requireDocument(options.document ?? globalThis.document);
|
|
779
|
+
const mount = requireElement(options.mount ?? document.body, "mount");
|
|
780
|
+
const locales = requireLocales(options.locales);
|
|
781
|
+
const describeError = options.describeError ?? defaultDescribeError;
|
|
782
|
+
const formatSize = options.formatSize ?? defaultFormatSize;
|
|
783
|
+
const formatDate = options.formatDate ?? defaultFormatDate;
|
|
784
|
+
let locale = options.initialLocale ?? "en";
|
|
785
|
+
let sort = options.initialSort ?? defaultDslSort;
|
|
786
|
+
let summaries = [];
|
|
787
|
+
let renamingId = null;
|
|
788
|
+
let confirmingRemovalId = null;
|
|
789
|
+
let status = "";
|
|
790
|
+
let disposed = false;
|
|
791
|
+
const root = document.createElement("section");
|
|
792
|
+
const panel = document.createElement("div");
|
|
793
|
+
const heading = document.createElement("h1");
|
|
794
|
+
const closeButton = document.createElement("button");
|
|
795
|
+
const toolbar = document.createElement("div");
|
|
796
|
+
const addButton = document.createElement("button");
|
|
797
|
+
const sortBar = document.createElement("div");
|
|
798
|
+
const listElement = document.createElement("div");
|
|
799
|
+
const statusElement = document.createElement("p");
|
|
800
|
+
root.setAttribute("data-turbowarp-title-menu-dsl-files", "true");
|
|
801
|
+
root.style.cssText = "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;";
|
|
802
|
+
root.style.display = "none";
|
|
803
|
+
panel.style.cssText = "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;";
|
|
804
|
+
heading.style.cssText = "margin:0;font-size:20px;line-height:1.3;";
|
|
805
|
+
toolbar.style.cssText = "display:flex;gap:8px;align-items:center;flex-wrap:wrap;";
|
|
806
|
+
sortBar.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;";
|
|
807
|
+
listElement.style.cssText = "flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:6px;";
|
|
808
|
+
statusElement.style.cssText = "margin:0;min-height:18px;font-size:13px;color:#8a1c1c;";
|
|
809
|
+
const headerRow = document.createElement("div");
|
|
810
|
+
headerRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:8px;";
|
|
811
|
+
headerRow.appendChild(heading);
|
|
812
|
+
headerRow.appendChild(closeButton);
|
|
813
|
+
panel.appendChild(headerRow);
|
|
814
|
+
toolbar.appendChild(addButton);
|
|
815
|
+
toolbar.appendChild(sortBar);
|
|
816
|
+
panel.appendChild(toolbar);
|
|
817
|
+
panel.appendChild(listElement);
|
|
818
|
+
panel.appendChild(statusElement);
|
|
819
|
+
root.appendChild(panel);
|
|
820
|
+
const previousMountPosition = mount.style.position;
|
|
821
|
+
if (previousMountPosition === "" || previousMountPosition === "static") {
|
|
822
|
+
mount.style.position = "relative";
|
|
823
|
+
}
|
|
824
|
+
mount.appendChild(root);
|
|
825
|
+
function styleButton(button, tone) {
|
|
826
|
+
const palette = {
|
|
827
|
+
primary: "background:#1f6feb;color:#ffffff;border:1px solid #1f6feb;",
|
|
828
|
+
plain: "background:#f2f4f8;color:#1b1f27;border:1px solid #ccd2de;",
|
|
829
|
+
danger: "background:#b42318;color:#ffffff;border:1px solid #b42318;",
|
|
830
|
+
active: "background:#d7e6ff;color:#0b3a82;border:1px solid #1f6feb;"
|
|
831
|
+
}[tone];
|
|
832
|
+
button.style.cssText = `${palette}padding:5px 10px;border-radius:6px;font-size:13px;cursor:pointer;`;
|
|
833
|
+
}
|
|
834
|
+
function run(operation) {
|
|
835
|
+
try {
|
|
836
|
+
Promise.resolve(operation()).catch(handleFailure);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
handleFailure(error);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
function handleFailure(error) {
|
|
842
|
+
status = describeError(error);
|
|
843
|
+
options.onError?.(error);
|
|
844
|
+
render();
|
|
845
|
+
}
|
|
846
|
+
function mutate(operation) {
|
|
847
|
+
run(async () => {
|
|
848
|
+
status = "";
|
|
849
|
+
try {
|
|
850
|
+
await operation();
|
|
851
|
+
} catch (error) {
|
|
852
|
+
status = describeError(error);
|
|
853
|
+
options.onError?.(error);
|
|
854
|
+
render();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
await reload();
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
async function reload() {
|
|
861
|
+
if (disposed) return;
|
|
862
|
+
summaries = await options.list(sort);
|
|
863
|
+
render();
|
|
864
|
+
}
|
|
865
|
+
function selectSort(field) {
|
|
866
|
+
if (sort.field === field) {
|
|
867
|
+
sort = { field, direction: sort.direction === "asc" ? "desc" : "asc" };
|
|
868
|
+
} else {
|
|
869
|
+
sort = { field, direction: defaultSortDirections[field] };
|
|
870
|
+
}
|
|
871
|
+
renamingId = null;
|
|
872
|
+
confirmingRemovalId = null;
|
|
873
|
+
mutate(() => void 0);
|
|
874
|
+
}
|
|
875
|
+
function createButton(label, tone, onClick) {
|
|
876
|
+
const button = document.createElement("button");
|
|
877
|
+
button.type = "button";
|
|
878
|
+
button.textContent = label;
|
|
879
|
+
styleButton(button, tone);
|
|
880
|
+
button.addEventListener("click", onClick);
|
|
881
|
+
return button;
|
|
882
|
+
}
|
|
883
|
+
function renderSortBar(text) {
|
|
884
|
+
const arrow = sort.direction === "asc" ? " ▲" : " ▼";
|
|
885
|
+
const fields = [
|
|
886
|
+
["name", text.sortByName],
|
|
887
|
+
["updatedAt", text.sortByDate],
|
|
888
|
+
["byteLength", text.sortBySize]
|
|
889
|
+
];
|
|
890
|
+
for (const [field, label] of fields) {
|
|
891
|
+
const active = sort.field === field;
|
|
892
|
+
const button = createButton(
|
|
893
|
+
active ? `${label}${arrow}` : label,
|
|
894
|
+
active ? "active" : "plain",
|
|
895
|
+
() => selectSort(field)
|
|
896
|
+
);
|
|
897
|
+
button.setAttribute("data-sort-field", field);
|
|
898
|
+
if (active) button.setAttribute("aria-pressed", "true");
|
|
899
|
+
sortBar.appendChild(button);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
function renderRow(summary, text) {
|
|
903
|
+
const row = document.createElement("div");
|
|
904
|
+
row.setAttribute("data-dsl-file-id", summary.id);
|
|
905
|
+
row.style.cssText = "display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #e2e6ee;border-radius:8px;";
|
|
906
|
+
if (renamingId === summary.id) {
|
|
907
|
+
const input = document.createElement("input");
|
|
908
|
+
input.type = "text";
|
|
909
|
+
input.value = summary.name;
|
|
910
|
+
input.setAttribute("data-rename-input", summary.id);
|
|
911
|
+
input.style.cssText = "flex:1;min-width:0;padding:5px 8px;border:1px solid #1f6feb;border-radius:6px;font-size:14px;";
|
|
912
|
+
const commit = () => {
|
|
913
|
+
const nextName = input.value;
|
|
914
|
+
renamingId = null;
|
|
915
|
+
mutate(() => options.onRename(summary.id, nextName));
|
|
916
|
+
};
|
|
917
|
+
input.addEventListener("keydown", (event) => {
|
|
918
|
+
const key = event.key;
|
|
919
|
+
if (key === "Enter") commit();
|
|
920
|
+
if (key === "Escape") {
|
|
921
|
+
renamingId = null;
|
|
922
|
+
render();
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
row.appendChild(input);
|
|
926
|
+
row.appendChild(createButton(text.confirm, "primary", commit));
|
|
927
|
+
row.appendChild(
|
|
928
|
+
createButton(text.cancel, "plain", () => {
|
|
929
|
+
renamingId = null;
|
|
930
|
+
render();
|
|
931
|
+
})
|
|
932
|
+
);
|
|
933
|
+
return row;
|
|
934
|
+
}
|
|
935
|
+
const name = document.createElement("span");
|
|
936
|
+
name.textContent = summary.name;
|
|
937
|
+
name.style.cssText = "flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;";
|
|
938
|
+
const date = document.createElement("span");
|
|
939
|
+
date.textContent = formatDate(summary.updatedAt);
|
|
940
|
+
date.style.cssText = "font-size:12px;color:#5a6172;white-space:nowrap;";
|
|
941
|
+
const size = document.createElement("span");
|
|
942
|
+
size.textContent = formatSize(summary.byteLength);
|
|
943
|
+
size.style.cssText = "font-size:12px;color:#5a6172;white-space:nowrap;min-width:64px;text-align:right;";
|
|
944
|
+
row.appendChild(name);
|
|
945
|
+
row.appendChild(date);
|
|
946
|
+
row.appendChild(size);
|
|
947
|
+
if (confirmingRemovalId === summary.id) {
|
|
948
|
+
row.appendChild(
|
|
949
|
+
createButton(text.confirmRemove, "danger", () => {
|
|
950
|
+
confirmingRemovalId = null;
|
|
951
|
+
mutate(() => options.onRemove(summary.id));
|
|
952
|
+
})
|
|
953
|
+
);
|
|
954
|
+
row.appendChild(
|
|
955
|
+
createButton(text.cancel, "plain", () => {
|
|
956
|
+
confirmingRemovalId = null;
|
|
957
|
+
render();
|
|
958
|
+
})
|
|
959
|
+
);
|
|
960
|
+
return row;
|
|
961
|
+
}
|
|
962
|
+
row.appendChild(
|
|
963
|
+
createButton(text.open, "primary", () => {
|
|
964
|
+
mutate(() => options.onOpen(summary.id));
|
|
965
|
+
})
|
|
966
|
+
);
|
|
967
|
+
row.appendChild(
|
|
968
|
+
createButton(text.rename, "plain", () => {
|
|
969
|
+
renamingId = summary.id;
|
|
970
|
+
confirmingRemovalId = null;
|
|
971
|
+
render();
|
|
972
|
+
})
|
|
973
|
+
);
|
|
974
|
+
row.appendChild(
|
|
975
|
+
createButton(text.remove, "plain", () => {
|
|
976
|
+
confirmingRemovalId = summary.id;
|
|
977
|
+
renamingId = null;
|
|
978
|
+
render();
|
|
979
|
+
})
|
|
980
|
+
);
|
|
981
|
+
return row;
|
|
982
|
+
}
|
|
983
|
+
function render() {
|
|
984
|
+
if (disposed) return;
|
|
985
|
+
const text = textFor(locales, locale);
|
|
986
|
+
heading.textContent = text.title;
|
|
987
|
+
closeButton.textContent = text.close;
|
|
988
|
+
closeButton.setAttribute("aria-label", text.close);
|
|
989
|
+
addButton.textContent = text.add;
|
|
990
|
+
statusElement.textContent = status;
|
|
991
|
+
sortBar.replaceChildren();
|
|
992
|
+
renderSortBar(text);
|
|
993
|
+
listElement.replaceChildren();
|
|
994
|
+
if (summaries.length === 0) {
|
|
995
|
+
const empty = document.createElement("p");
|
|
996
|
+
empty.textContent = text.empty;
|
|
997
|
+
empty.style.cssText = "margin:8px 0;font-size:14px;color:#5a6172;";
|
|
998
|
+
listElement.appendChild(empty);
|
|
999
|
+
} else {
|
|
1000
|
+
for (const summary of summaries) listElement.appendChild(renderRow(summary, text));
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
styleButton(closeButton, "plain");
|
|
1004
|
+
styleButton(addButton, "primary");
|
|
1005
|
+
closeButton.type = "button";
|
|
1006
|
+
addButton.type = "button";
|
|
1007
|
+
closeButton.addEventListener("click", () => hide());
|
|
1008
|
+
addButton.addEventListener("click", () => {
|
|
1009
|
+
mutate(() => options.onAdd());
|
|
1010
|
+
});
|
|
1011
|
+
function hide() {
|
|
1012
|
+
if (disposed) return;
|
|
1013
|
+
renamingId = null;
|
|
1014
|
+
confirmingRemovalId = null;
|
|
1015
|
+
root.style.display = "none";
|
|
1016
|
+
}
|
|
1017
|
+
function ensureActive() {
|
|
1018
|
+
if (disposed) throw new TypeError("DSL files dialog is disposed");
|
|
1019
|
+
}
|
|
1020
|
+
render();
|
|
1021
|
+
return Object.freeze({
|
|
1022
|
+
element: root,
|
|
1023
|
+
get locale() {
|
|
1024
|
+
return locale;
|
|
1025
|
+
},
|
|
1026
|
+
get sort() {
|
|
1027
|
+
return sort;
|
|
1028
|
+
},
|
|
1029
|
+
async show(nextLocale) {
|
|
1030
|
+
ensureActive();
|
|
1031
|
+
if (nextLocale !== void 0) locale = nextLocale;
|
|
1032
|
+
status = "";
|
|
1033
|
+
root.style.display = "flex";
|
|
1034
|
+
await reload();
|
|
1035
|
+
return locale;
|
|
1036
|
+
},
|
|
1037
|
+
hide,
|
|
1038
|
+
async refresh() {
|
|
1039
|
+
ensureActive();
|
|
1040
|
+
await reload();
|
|
1041
|
+
},
|
|
1042
|
+
setLocale(nextLocale) {
|
|
1043
|
+
ensureActive();
|
|
1044
|
+
locale = nextLocale;
|
|
1045
|
+
render();
|
|
1046
|
+
return locale;
|
|
1047
|
+
},
|
|
1048
|
+
dispose() {
|
|
1049
|
+
if (disposed) return;
|
|
1050
|
+
disposed = true;
|
|
1051
|
+
root.remove();
|
|
1052
|
+
if (previousMountPosition === "" || previousMountPosition === "static") {
|
|
1053
|
+
mount.style.position = previousMountPosition;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
const dslOpenEventName = "turbowarp-title-menu:dsl-open";
|
|
1059
|
+
const dslReloadEventName = "turbowarp-title-menu:dsl-reload";
|
|
1060
|
+
function dispatchDslSourceEvent(type, record) {
|
|
1061
|
+
const target = globalThis;
|
|
1062
|
+
if (typeof target.dispatchEvent !== "function") return;
|
|
1063
|
+
const event = typeof CustomEvent === "function" ? new CustomEvent(type, { detail: { record } }) : new Event(type);
|
|
1064
|
+
if (!("detail" in event)) {
|
|
1065
|
+
Object.defineProperty(event, "detail", { value: { record } });
|
|
1066
|
+
}
|
|
1067
|
+
target.dispatchEvent(event);
|
|
1068
|
+
}
|
|
1069
|
+
function requireLocaleText(locales, locale) {
|
|
1070
|
+
const text = locales[locale] ?? locales.en ?? Object.values(locales)[0];
|
|
1071
|
+
if (!text) throw new TypeError("locales must contain at least one locale");
|
|
1072
|
+
for (const key of ["title", "website", "close"]) {
|
|
1073
|
+
if (typeof text[key] !== "string" || text[key].length === 0) {
|
|
1074
|
+
throw new TypeError(`locales.${locale}.${key} must be a non-empty string`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return text;
|
|
1078
|
+
}
|
|
1079
|
+
function openWebsite(url) {
|
|
1080
|
+
const opener = globalThis.open;
|
|
1081
|
+
if (typeof opener === "function") {
|
|
1082
|
+
opener(url, "_blank", "noopener,noreferrer");
|
|
1083
|
+
} else if (globalThis.location) {
|
|
1084
|
+
globalThis.location.href = url;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
function createTitleDialog(options) {
|
|
1088
|
+
const document = requireDocument(options.document ?? globalThis.document);
|
|
1089
|
+
const mount = requireElement(options.mount ?? document.body, "mount");
|
|
1090
|
+
const locales = options.locales;
|
|
1091
|
+
if (!locales || typeof locales !== "object") throw new TypeError("locales must be an object");
|
|
1092
|
+
const root = document.createElement("section");
|
|
1093
|
+
const panel = document.createElement("div");
|
|
1094
|
+
const language = document.createElement("button");
|
|
1095
|
+
const close = document.createElement("button");
|
|
1096
|
+
const heading = document.createElement("h1");
|
|
1097
|
+
const meta = document.createElement("p");
|
|
1098
|
+
const website = document.createElement("button");
|
|
1099
|
+
root.setAttribute("data-turbowarp-title-dialog", "true");
|
|
1100
|
+
root.setAttribute("role", "dialog");
|
|
1101
|
+
root.setAttribute("aria-modal", "true");
|
|
1102
|
+
root.style.cssText = "position:absolute;inset:0;z-index:2147483647;display:none;align-items:center;justify-content:center;box-sizing:border-box;background:rgba(0,0,0,.35);font-family:sans-serif;";
|
|
1103
|
+
panel.style.cssText = "position:relative;box-sizing:border-box;width:min(88%,420px);padding:36px 28px 28px;text-align:center;background:#f4fffb;border:1px solid #007d66;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.3);color:#006b58;";
|
|
1104
|
+
language.style.cssText = "position:absolute;top:14px;left:16px;border:0;background:transparent;color:#007d66;font-size:14px;cursor:pointer;";
|
|
1105
|
+
close.style.cssText = "position:absolute;top:12px;right:12px;width:32px;height:32px;border:0;border-radius:50%;background:#007d66;color:#fff;font-size:22px;line-height:28px;cursor:pointer;";
|
|
1106
|
+
heading.style.cssText = "margin:0 24px 8px;font-size:30px;font-weight:600;line-height:1.15;";
|
|
1107
|
+
meta.style.cssText = "margin:0 0 22px;font-size:14px;line-height:1.4;";
|
|
1108
|
+
website.style.cssText = "display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:8px 18px;border:0;border-radius:10px;background:#007d66;color:#fff;font-size:16px;cursor:pointer;";
|
|
1109
|
+
language.type = "button";
|
|
1110
|
+
close.type = "button";
|
|
1111
|
+
website.type = "button";
|
|
1112
|
+
close.textContent = "x";
|
|
1113
|
+
panel.append(language, close, heading, meta, website);
|
|
1114
|
+
root.appendChild(panel);
|
|
1115
|
+
const restoreMount = ensureRelativeMount(mount);
|
|
1116
|
+
mount.appendChild(root);
|
|
1117
|
+
let locale = options.initialLocale ?? (locales.ja ? "ja" : Object.keys(locales)[0] ?? "en");
|
|
1118
|
+
let disposed = false;
|
|
1119
|
+
const render = () => {
|
|
1120
|
+
const text = requireLocaleText(locales, locale);
|
|
1121
|
+
heading.textContent = text.title;
|
|
1122
|
+
meta.textContent = [text.author, text.license].filter(Boolean).join(" / ");
|
|
1123
|
+
meta.hidden = meta.textContent.length === 0;
|
|
1124
|
+
website.textContent = text.website;
|
|
1125
|
+
website.setAttribute("aria-label", text.website);
|
|
1126
|
+
close.setAttribute("aria-label", text.close);
|
|
1127
|
+
close.setAttribute("title", text.close);
|
|
1128
|
+
language.textContent = text.language ?? locale;
|
|
1129
|
+
language.setAttribute("aria-label", text.language ?? locale);
|
|
1130
|
+
};
|
|
1131
|
+
const handleWebsite = () => {
|
|
1132
|
+
if (options.onWebsite) invokeSafely(options.onWebsite, options.onError);
|
|
1133
|
+
else if (options.websiteUrl) openWebsite(options.websiteUrl);
|
|
1134
|
+
};
|
|
1135
|
+
const handleClose = () => {
|
|
1136
|
+
hide();
|
|
1137
|
+
if (options.onClose) invokeSafely(options.onClose, options.onError);
|
|
1138
|
+
};
|
|
1139
|
+
const handleLanguage = () => {
|
|
1140
|
+
const keys = Object.keys(locales);
|
|
1141
|
+
locale = keys[(Math.max(keys.indexOf(locale), 0) + 1) % keys.length] ?? locale;
|
|
1142
|
+
render();
|
|
1143
|
+
if (options.onLocaleChange) invokeSafely(() => options.onLocaleChange?.(locale), options.onError);
|
|
1144
|
+
};
|
|
1145
|
+
website.addEventListener("click", handleWebsite);
|
|
1146
|
+
close.addEventListener("click", handleClose);
|
|
1147
|
+
language.addEventListener("click", handleLanguage);
|
|
1148
|
+
function show(nextLocale = locale) {
|
|
1149
|
+
if (disposed) throw new TypeError("title dialog is disposed");
|
|
1150
|
+
locale = nextLocale;
|
|
1151
|
+
render();
|
|
1152
|
+
root.style.display = "flex";
|
|
1153
|
+
return locale;
|
|
1154
|
+
}
|
|
1155
|
+
function hide() {
|
|
1156
|
+
if (!disposed) root.style.display = "none";
|
|
1157
|
+
}
|
|
1158
|
+
function setLocale(nextLocale) {
|
|
1159
|
+
if (disposed) throw new TypeError("title dialog is disposed");
|
|
1160
|
+
locale = nextLocale;
|
|
1161
|
+
render();
|
|
1162
|
+
return locale;
|
|
1163
|
+
}
|
|
1164
|
+
function dispose() {
|
|
1165
|
+
if (disposed) return;
|
|
1166
|
+
disposed = true;
|
|
1167
|
+
website.removeEventListener("click", handleWebsite);
|
|
1168
|
+
close.removeEventListener("click", handleClose);
|
|
1169
|
+
language.removeEventListener("click", handleLanguage);
|
|
1170
|
+
root.remove();
|
|
1171
|
+
restoreMount();
|
|
1172
|
+
}
|
|
1173
|
+
render();
|
|
1174
|
+
return Object.freeze({
|
|
1175
|
+
element: root,
|
|
1176
|
+
get locale() {
|
|
1177
|
+
return locale;
|
|
1178
|
+
},
|
|
1179
|
+
show,
|
|
1180
|
+
hide,
|
|
1181
|
+
setLocale,
|
|
1182
|
+
dispose
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
const extensionName = "TurboWarp Title Menu";
|
|
1186
|
+
const blocks = [{ "opcode": "showTitle", "blockType": "COMMAND", "text": "show title dialog", "description": "Shows the configured title dialog above the TurboWarp stage." }, { "opcode": "showMenu", "blockType": "COMMAND", "text": "show application menu", "description": "Shows the application menu above the TurboWarp stage." }, { "opcode": "showDslFiles", "blockType": "COMMAND", "text": "show DSL file manager", "description": "Shows the dialog that adds, opens, renames, deletes, and sorts stored DSL files." }, { "opcode": "whenDslSourceOpened", "blockType": "HAT", "text": "when a DSL source is opened", "description": "Runs after the operator opens a stored DSL file, or after the opened source is announced again." }, { "opcode": "reloadOpenedDsl", "blockType": "COMMAND", "text": "reload the opened DSL source", "description": "Announces the currently opened DSL source again without showing a dialog." }, { "opcode": "openedDslName", "blockType": "REPORTER", "text": "opened DSL file name", "description": "Returns the name of the DSL file that is currently open, or an empty string." }, { "opcode": "openedDslSource", "blockType": "REPORTER", "text": "opened DSL source", "description": "Returns the text of the DSL file that is currently open, or an empty string." }, { "opcode": "hasSavedDsl", "blockType": "BOOLEAN", "text": "has a saved DSL file?", "description": "Reports whether at least one DSL file is stored in IndexedDB." }, { "opcode": "savedDslCount", "blockType": "REPORTER", "text": "saved DSL file count", "description": "Returns how many DSL files are stored in IndexedDB." }, { "opcode": "lastDslError", "blockType": "REPORTER", "text": "last DSL storage error", "description": "Returns the most recent DSL storage failure in the interface language, or an empty string." }];
|
|
1187
|
+
const definitions = {
|
|
1188
|
+
extensionName,
|
|
1189
|
+
blocks
|
|
1190
|
+
};
|
|
1191
|
+
const titleLocales = Object.freeze({
|
|
1192
|
+
en: {
|
|
1193
|
+
title: "TurboWarp Title Menu",
|
|
1194
|
+
author: "Author: Hiroya Kubo",
|
|
1195
|
+
license: "License: MPL-2.0",
|
|
1196
|
+
website: "Official Website",
|
|
1197
|
+
close: "Close"
|
|
1198
|
+
},
|
|
1199
|
+
ja: {
|
|
1200
|
+
title: "TurboWarp Title Menu",
|
|
1201
|
+
author: "作者: Hiroya Kubo",
|
|
1202
|
+
license: "ライセンス: MPL-2.0",
|
|
1203
|
+
website: "公式Webサイト",
|
|
1204
|
+
close: "閉じる"
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
const menuLocales = Object.freeze({
|
|
1208
|
+
en: { files: "DSL files", reload: "Reload DSL", about: "About", close: "Close" },
|
|
1209
|
+
ja: { files: "DSLファイル", reload: "DSLを再読み込み", about: "情報", close: "閉じる" }
|
|
1210
|
+
});
|
|
1211
|
+
const dslFilesLocales = Object.freeze({
|
|
1212
|
+
en: {
|
|
1213
|
+
title: "DSL files",
|
|
1214
|
+
add: "Add file",
|
|
1215
|
+
open: "Open",
|
|
1216
|
+
rename: "Rename",
|
|
1217
|
+
remove: "Delete",
|
|
1218
|
+
confirmRemove: "Delete for good?",
|
|
1219
|
+
confirm: "OK",
|
|
1220
|
+
cancel: "Cancel",
|
|
1221
|
+
close: "Close",
|
|
1222
|
+
sortByName: "Name",
|
|
1223
|
+
sortByDate: "Updated",
|
|
1224
|
+
sortBySize: "Size",
|
|
1225
|
+
empty: "No DSL file is saved yet. Use Add file to store one."
|
|
1226
|
+
},
|
|
1227
|
+
ja: {
|
|
1228
|
+
title: "DSLファイル",
|
|
1229
|
+
add: "ファイルを追加",
|
|
1230
|
+
open: "開く",
|
|
1231
|
+
rename: "名前を変える",
|
|
1232
|
+
remove: "削除",
|
|
1233
|
+
confirmRemove: "本当に削除?",
|
|
1234
|
+
confirm: "OK",
|
|
1235
|
+
cancel: "やめる",
|
|
1236
|
+
close: "閉じる",
|
|
1237
|
+
sortByName: "名前",
|
|
1238
|
+
sortByDate: "更新日時",
|
|
1239
|
+
sortBySize: "サイズ",
|
|
1240
|
+
empty: "保存されたDSLファイルはありません。「ファイルを追加」から保存してください。"
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
const storeErrorMessages = Object.freeze({
|
|
1244
|
+
en: {
|
|
1245
|
+
unavailable: "Browser storage is unavailable, so DSL files cannot be saved.",
|
|
1246
|
+
"invalid-name": "That file name cannot be used.",
|
|
1247
|
+
"invalid-source": "That file could not be read as text.",
|
|
1248
|
+
"name-taken": "Another DSL file already uses that name.",
|
|
1249
|
+
"too-large": "That DSL file is too large to store.",
|
|
1250
|
+
"too-many": "The DSL store is full. Delete a file before adding another.",
|
|
1251
|
+
"not-found": "That DSL file is no longer stored.",
|
|
1252
|
+
quota: "Browser storage is full. Delete a DSL file and try again.",
|
|
1253
|
+
failed: "The DSL storage operation failed."
|
|
1254
|
+
},
|
|
1255
|
+
ja: {
|
|
1256
|
+
unavailable: "ブラウザの保存領域が使えないため、DSLファイルを保存できません。",
|
|
1257
|
+
"invalid-name": "そのファイル名は使えません。",
|
|
1258
|
+
"invalid-source": "そのファイルをテキストとして読み込めませんでした。",
|
|
1259
|
+
"name-taken": "同じ名前のDSLファイルがすでにあります。",
|
|
1260
|
+
"too-large": "そのDSLファイルは大きすぎて保存できません。",
|
|
1261
|
+
"too-many": "保存できる数に達しています。どれかを削除してから追加してください。",
|
|
1262
|
+
"not-found": "そのDSLファイルは保存されていません。",
|
|
1263
|
+
quota: "ブラウザの保存領域がいっぱいです。DSLファイルを削除してからやり直してください。",
|
|
1264
|
+
failed: "DSLファイルの操作に失敗しました。"
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
function describeStoreError(locale, error) {
|
|
1268
|
+
const code = error?.code;
|
|
1269
|
+
const messages = storeErrorMessages[locale] ?? storeErrorMessages.en;
|
|
1270
|
+
if (code !== void 0 && code in messages) return messages[code];
|
|
1271
|
+
if (error instanceof Error && error.message.length > 0) return error.message;
|
|
1272
|
+
return String(error);
|
|
1273
|
+
}
|
|
1274
|
+
const blockDefinitions = definitions.blocks;
|
|
1275
|
+
const dslFileAccept = ".txt,.yaml,.yml,.json,.k4,.kamishibai";
|
|
1276
|
+
function stageMount() {
|
|
1277
|
+
return Scratch.vm?.renderer?.canvas?.parentElement ?? globalThis.document?.body;
|
|
1278
|
+
}
|
|
1279
|
+
class TurboWarpTitleMenuExtension {
|
|
1280
|
+
constructor() {
|
|
1281
|
+
this.titleDialog = null;
|
|
1282
|
+
this.applicationMenu = null;
|
|
1283
|
+
this.filesDialog = null;
|
|
1284
|
+
this.store = null;
|
|
1285
|
+
this.openedRecord = null;
|
|
1286
|
+
this.lastError = "";
|
|
1287
|
+
}
|
|
1288
|
+
getInfo() {
|
|
1289
|
+
return {
|
|
1290
|
+
id: extensionConfig.id,
|
|
1291
|
+
name: Scratch.translate(definitions.extensionName),
|
|
1292
|
+
docsURI: extensionConfig.docsURI,
|
|
1293
|
+
blockIconURI: extensionConfig.blockIconURI,
|
|
1294
|
+
blocks: blockDefinitions.map((block) => this.toScratchBlock(block))
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
showTitle() {
|
|
1298
|
+
this.ensureTitleDialog().show(this.locale());
|
|
1299
|
+
}
|
|
1300
|
+
showMenu() {
|
|
1301
|
+
this.ensureApplicationMenu().show(this.locale());
|
|
1302
|
+
}
|
|
1303
|
+
showDslFiles() {
|
|
1304
|
+
return this.ensureFilesDialog().show(this.locale());
|
|
1305
|
+
}
|
|
1306
|
+
/** The hat is started by the open path, so its own handler only has to accept the match. */
|
|
1307
|
+
whenDslSourceOpened() {
|
|
1308
|
+
return true;
|
|
1309
|
+
}
|
|
1310
|
+
reloadOpenedDsl() {
|
|
1311
|
+
if (this.openedRecord === null) return;
|
|
1312
|
+
this.announce(dslReloadEventName, this.openedRecord);
|
|
1313
|
+
}
|
|
1314
|
+
openedDslName() {
|
|
1315
|
+
return this.openedRecord?.name ?? "";
|
|
1316
|
+
}
|
|
1317
|
+
openedDslSource() {
|
|
1318
|
+
return this.openedRecord?.source ?? "";
|
|
1319
|
+
}
|
|
1320
|
+
async hasSavedDsl() {
|
|
1321
|
+
return await this.savedDslCount() > 0;
|
|
1322
|
+
}
|
|
1323
|
+
async savedDslCount() {
|
|
1324
|
+
try {
|
|
1325
|
+
return await this.requireStore().count();
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
this.recordFailure(error);
|
|
1328
|
+
return 0;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
lastDslError() {
|
|
1332
|
+
return this.lastError;
|
|
1333
|
+
}
|
|
1334
|
+
locale() {
|
|
1335
|
+
return resolveAppShellLocale() === "ja" ? "ja" : "en";
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Opens the store on first use.
|
|
1339
|
+
*
|
|
1340
|
+
* A browser without IndexedDB, or one with storage blocked, fails here rather than at extension
|
|
1341
|
+
* load: the title and menu blocks stay usable even when nothing can be stored.
|
|
1342
|
+
*/
|
|
1343
|
+
requireStore() {
|
|
1344
|
+
this.store ?? (this.store = createDslStore({ databaseName: extensionConfig.id }));
|
|
1345
|
+
return this.store;
|
|
1346
|
+
}
|
|
1347
|
+
recordFailure(error) {
|
|
1348
|
+
this.lastError = describeStoreError(this.locale(), error);
|
|
1349
|
+
}
|
|
1350
|
+
announce(eventName, record) {
|
|
1351
|
+
dispatchDslSourceEvent(eventName, record);
|
|
1352
|
+
Scratch.vm?.runtime?.startHats?.(`${extensionConfig.id}_whenDslSourceOpened`);
|
|
1353
|
+
}
|
|
1354
|
+
ensureTitleDialog() {
|
|
1355
|
+
if (this.titleDialog) return this.titleDialog;
|
|
1356
|
+
const mount = stageMount();
|
|
1357
|
+
this.titleDialog = createTitleDialog({
|
|
1358
|
+
document: globalThis.document,
|
|
1359
|
+
...mount ? { mount } : {},
|
|
1360
|
+
locales: titleLocales,
|
|
1361
|
+
initialLocale: this.locale(),
|
|
1362
|
+
websiteUrl: extensionConfig.homepage
|
|
1363
|
+
});
|
|
1364
|
+
return this.titleDialog;
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Builds the menu from the shared app-shell primitive.
|
|
1368
|
+
*
|
|
1369
|
+
* The actions below are this extension's own vocabulary, not the primitive's: a host application
|
|
1370
|
+
* that needs different actions composes `createAppShellApplicationMenu` itself through the
|
|
1371
|
+
* composition API instead of being limited to these four.
|
|
1372
|
+
*/
|
|
1373
|
+
ensureApplicationMenu() {
|
|
1374
|
+
if (this.applicationMenu) return this.applicationMenu;
|
|
1375
|
+
const mount = stageMount();
|
|
1376
|
+
if (mount === void 0) throw new TypeError("a stage container is required to show the menu");
|
|
1377
|
+
const labels = (key) => ({
|
|
1378
|
+
en: menuLocales.en[key],
|
|
1379
|
+
ja: menuLocales.ja[key]
|
|
1380
|
+
});
|
|
1381
|
+
this.applicationMenu = createAppShellApplicationMenu({
|
|
1382
|
+
document: globalThis.document,
|
|
1383
|
+
mount,
|
|
1384
|
+
initialLocale: this.locale(),
|
|
1385
|
+
actions: [
|
|
1386
|
+
{ id: "files", labels: labels("files"), icon: { text: "📂" }, onSelect: () => this.showDslFiles() },
|
|
1387
|
+
{ id: "reload", labels: labels("reload"), icon: { text: "↻" }, onSelect: () => this.reloadOpenedDsl() },
|
|
1388
|
+
{ id: "about", labels: labels("about"), icon: { text: "i" }, onSelect: () => this.showTitle() },
|
|
1389
|
+
{ id: "close", labels: labels("close"), icon: { text: "x" }, onSelect: () => this.applicationMenu?.hide() }
|
|
1390
|
+
]
|
|
1391
|
+
});
|
|
1392
|
+
return this.applicationMenu;
|
|
1393
|
+
}
|
|
1394
|
+
ensureFilesDialog() {
|
|
1395
|
+
if (this.filesDialog) return this.filesDialog;
|
|
1396
|
+
const mount = stageMount();
|
|
1397
|
+
this.filesDialog = createDslFilesDialog({
|
|
1398
|
+
document: globalThis.document,
|
|
1399
|
+
...mount ? { mount } : {},
|
|
1400
|
+
locales: dslFilesLocales,
|
|
1401
|
+
initialLocale: this.locale(),
|
|
1402
|
+
list: (sort) => this.requireStore().list(sort),
|
|
1403
|
+
onAdd: () => this.addDslFile(),
|
|
1404
|
+
onOpen: (id) => this.openDslFile(id),
|
|
1405
|
+
onRename: (id, name) => this.requireStore().rename(id, name),
|
|
1406
|
+
onRemove: (id) => this.removeDslFile(id),
|
|
1407
|
+
describeError: (error) => describeStoreError(this.locale(), error),
|
|
1408
|
+
onError: (error) => this.recordFailure(error)
|
|
1409
|
+
});
|
|
1410
|
+
return this.filesDialog;
|
|
1411
|
+
}
|
|
1412
|
+
async addDslFile() {
|
|
1413
|
+
const chosen = await this.pickDslFile();
|
|
1414
|
+
if (chosen === null) return;
|
|
1415
|
+
await this.requireStore().save(await readDslFile(chosen));
|
|
1416
|
+
this.lastError = "";
|
|
1417
|
+
}
|
|
1418
|
+
async openDslFile(id) {
|
|
1419
|
+
const store = this.requireStore();
|
|
1420
|
+
const record = await store.get(id);
|
|
1421
|
+
if (record === null) return;
|
|
1422
|
+
await store.markOpened(id);
|
|
1423
|
+
this.openedRecord = record;
|
|
1424
|
+
this.lastError = "";
|
|
1425
|
+
this.announce(dslOpenEventName, record);
|
|
1426
|
+
}
|
|
1427
|
+
async removeDslFile(id) {
|
|
1428
|
+
await this.requireStore().remove(id);
|
|
1429
|
+
if (this.openedRecord?.id === id) this.openedRecord = null;
|
|
1430
|
+
this.lastError = "";
|
|
1431
|
+
}
|
|
1432
|
+
pickDslFile() {
|
|
1433
|
+
const document = globalThis.document;
|
|
1434
|
+
if (!document) throw new TypeError("document is required to open a DSL file");
|
|
1435
|
+
const input = document.createElement("input");
|
|
1436
|
+
input.type = "file";
|
|
1437
|
+
input.accept = dslFileAccept;
|
|
1438
|
+
return new Promise((resolve) => {
|
|
1439
|
+
input.addEventListener(
|
|
1440
|
+
"change",
|
|
1441
|
+
() => {
|
|
1442
|
+
resolve(input.files?.[0] ?? null);
|
|
1443
|
+
},
|
|
1444
|
+
{ once: true }
|
|
1445
|
+
);
|
|
1446
|
+
input.click();
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
toScratchBlock(block) {
|
|
1450
|
+
const scratchBlock = {
|
|
1451
|
+
opcode: block.opcode,
|
|
1452
|
+
blockType: Scratch.BlockType[block.blockType],
|
|
1453
|
+
text: Scratch.translate(block.text),
|
|
1454
|
+
arguments: Object.fromEntries(
|
|
1455
|
+
Object.entries(block.arguments ?? {}).map(([name, argument]) => [
|
|
1456
|
+
name,
|
|
1457
|
+
{
|
|
1458
|
+
type: Scratch.ArgumentType[argument.type],
|
|
1459
|
+
defaultValue: argument.defaultValue
|
|
1460
|
+
}
|
|
1461
|
+
])
|
|
1462
|
+
)
|
|
1463
|
+
};
|
|
1464
|
+
if (block.blockType === "HAT") scratchBlock["isEdgeActivated"] = false;
|
|
1465
|
+
return scratchBlock;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
const publicApi = Object.freeze({
|
|
1469
|
+
createApplicationMenu: createAppShellApplicationMenu,
|
|
1470
|
+
createDslFilesDialog,
|
|
1471
|
+
createDslStore,
|
|
1472
|
+
createTitleDialog,
|
|
1473
|
+
dslOpenEventName,
|
|
1474
|
+
dslReloadEventName
|
|
1475
|
+
});
|
|
1476
|
+
Object.defineProperty(globalThis, "TurboWarpTitleMenu", {
|
|
1477
|
+
value: publicApi,
|
|
1478
|
+
configurable: true
|
|
1479
|
+
});
|
|
1480
|
+
if (!Scratch.extensions.unsandboxed) {
|
|
1481
|
+
throw new Error(`${extensionConfig.name} must run unsandboxed.`);
|
|
1482
|
+
}
|
|
1483
|
+
Scratch.extensions.register(new TurboWarpTitleMenuExtension());
|
|
1484
|
+
|
|
1485
|
+
})(Scratch);
|