@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,408 @@
|
|
|
1
|
+
import {isRecord, requireDocument, requireElement} from './dom';
|
|
2
|
+
import {defaultDslSort, type DslFileSummary, type DslSort, type DslSortField} from './dsl-store';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The stage-mounted DSL file manager.
|
|
6
|
+
*
|
|
7
|
+
* The dialog owns presentation and interaction state only: sort order, which row is being renamed,
|
|
8
|
+
* and which row is awaiting delete confirmation. Every storage operation is a caller-supplied
|
|
9
|
+
* callback, so the same dialog works against the IndexedDB store, a test double, or a future
|
|
10
|
+
* file-system backend.
|
|
11
|
+
*/
|
|
12
|
+
export interface DslFilesDialogLocaleText {
|
|
13
|
+
title: string;
|
|
14
|
+
add: string;
|
|
15
|
+
open: string;
|
|
16
|
+
rename: string;
|
|
17
|
+
remove: string;
|
|
18
|
+
confirmRemove: string;
|
|
19
|
+
confirm: string;
|
|
20
|
+
cancel: string;
|
|
21
|
+
close: string;
|
|
22
|
+
sortByName: string;
|
|
23
|
+
sortByDate: string;
|
|
24
|
+
sortBySize: string;
|
|
25
|
+
empty: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DslFilesDialogOptions {
|
|
29
|
+
document?: Document;
|
|
30
|
+
mount?: HTMLElement;
|
|
31
|
+
locales: Record<string, DslFilesDialogLocaleText>;
|
|
32
|
+
initialLocale?: string;
|
|
33
|
+
initialSort?: DslSort;
|
|
34
|
+
list(sort: DslSort): Promise<readonly DslFileSummary[]>;
|
|
35
|
+
onOpen(id: string): unknown | Promise<unknown>;
|
|
36
|
+
onAdd(): unknown | Promise<unknown>;
|
|
37
|
+
onRename(id: string, name: string): unknown | Promise<unknown>;
|
|
38
|
+
onRemove(id: string): unknown | Promise<unknown>;
|
|
39
|
+
describeError?(error: unknown): string;
|
|
40
|
+
formatSize?(byteLength: number): string;
|
|
41
|
+
formatDate?(isoDate: string): string;
|
|
42
|
+
onError?(error: unknown): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DslFilesDialog {
|
|
46
|
+
readonly element: HTMLElement;
|
|
47
|
+
readonly locale: string;
|
|
48
|
+
readonly sort: DslSort;
|
|
49
|
+
show(locale?: string): Promise<string>;
|
|
50
|
+
hide(): void;
|
|
51
|
+
refresh(): Promise<void>;
|
|
52
|
+
setLocale(locale: string): string;
|
|
53
|
+
dispose(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const defaultSortDirections: Readonly<Record<DslSortField, DslSort['direction']>> = Object.freeze({
|
|
57
|
+
name: 'asc',
|
|
58
|
+
updatedAt: 'desc',
|
|
59
|
+
byteLength: 'desc'
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function requireLocales(
|
|
63
|
+
value: unknown
|
|
64
|
+
): Record<string, DslFilesDialogLocaleText> {
|
|
65
|
+
if (!isRecord(value) || Object.keys(value).length === 0) {
|
|
66
|
+
throw new TypeError('locales must contain at least one locale');
|
|
67
|
+
}
|
|
68
|
+
return value as Record<string, DslFilesDialogLocaleText>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function textFor(
|
|
72
|
+
locales: Record<string, DslFilesDialogLocaleText>,
|
|
73
|
+
locale: string
|
|
74
|
+
): DslFilesDialogLocaleText {
|
|
75
|
+
const text = locales[locale] ?? locales['en'] ?? Object.values(locales)[0];
|
|
76
|
+
if (text === undefined) throw new TypeError('locales must contain at least one locale');
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function defaultFormatSize(byteLength: number): string {
|
|
81
|
+
if (byteLength < 1024) return `${byteLength} B`;
|
|
82
|
+
if (byteLength < 1024 * 1024) return `${(byteLength / 1024).toFixed(1)} KB`;
|
|
83
|
+
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function defaultFormatDate(isoDate: string): string {
|
|
87
|
+
const parsed = new Date(isoDate);
|
|
88
|
+
if (Number.isNaN(parsed.getTime())) return isoDate;
|
|
89
|
+
const pad = (value: number) => String(value).padStart(2, '0');
|
|
90
|
+
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function defaultDescribeError(error: unknown): string {
|
|
94
|
+
if (error instanceof Error && error.message.length > 0) return error.message;
|
|
95
|
+
return String(error);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createDslFilesDialog(options: DslFilesDialogOptions): DslFilesDialog {
|
|
99
|
+
if (!isRecord(options)) throw new TypeError('DSL files dialog options must be an object');
|
|
100
|
+
const document = requireDocument(options.document ?? globalThis.document);
|
|
101
|
+
const mount = requireElement(options.mount ?? document.body, 'mount');
|
|
102
|
+
const locales = requireLocales(options.locales);
|
|
103
|
+
const describeError = options.describeError ?? defaultDescribeError;
|
|
104
|
+
const formatSize = options.formatSize ?? defaultFormatSize;
|
|
105
|
+
const formatDate = options.formatDate ?? defaultFormatDate;
|
|
106
|
+
|
|
107
|
+
let locale = options.initialLocale ?? 'en';
|
|
108
|
+
let sort: DslSort = options.initialSort ?? defaultDslSort;
|
|
109
|
+
let summaries: readonly DslFileSummary[] = [];
|
|
110
|
+
let renamingId: string | null = null;
|
|
111
|
+
let confirmingRemovalId: string | null = null;
|
|
112
|
+
let status = '';
|
|
113
|
+
let disposed = false;
|
|
114
|
+
|
|
115
|
+
const root = document.createElement('section');
|
|
116
|
+
const panel = document.createElement('div');
|
|
117
|
+
const heading = document.createElement('h1');
|
|
118
|
+
const closeButton = document.createElement('button');
|
|
119
|
+
const toolbar = document.createElement('div');
|
|
120
|
+
const addButton = document.createElement('button');
|
|
121
|
+
const sortBar = document.createElement('div');
|
|
122
|
+
const listElement = document.createElement('div');
|
|
123
|
+
const statusElement = document.createElement('p');
|
|
124
|
+
|
|
125
|
+
root.setAttribute('data-turbowarp-title-menu-dsl-files', 'true');
|
|
126
|
+
root.style.cssText =
|
|
127
|
+
'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;';
|
|
128
|
+
// Visibility is the one property the dialog toggles, so it is set apart from the style sheet.
|
|
129
|
+
root.style.display = 'none';
|
|
130
|
+
panel.style.cssText =
|
|
131
|
+
'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;';
|
|
132
|
+
heading.style.cssText = 'margin:0;font-size:20px;line-height:1.3;';
|
|
133
|
+
toolbar.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;';
|
|
134
|
+
sortBar.style.cssText = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;';
|
|
135
|
+
listElement.style.cssText = 'flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:6px;';
|
|
136
|
+
statusElement.style.cssText = 'margin:0;min-height:18px;font-size:13px;color:#8a1c1c;';
|
|
137
|
+
|
|
138
|
+
const headerRow = document.createElement('div');
|
|
139
|
+
headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;';
|
|
140
|
+
headerRow.appendChild(heading);
|
|
141
|
+
headerRow.appendChild(closeButton);
|
|
142
|
+
panel.appendChild(headerRow);
|
|
143
|
+
toolbar.appendChild(addButton);
|
|
144
|
+
toolbar.appendChild(sortBar);
|
|
145
|
+
panel.appendChild(toolbar);
|
|
146
|
+
panel.appendChild(listElement);
|
|
147
|
+
panel.appendChild(statusElement);
|
|
148
|
+
root.appendChild(panel);
|
|
149
|
+
|
|
150
|
+
const previousMountPosition = mount.style.position;
|
|
151
|
+
if (previousMountPosition === '' || previousMountPosition === 'static') {
|
|
152
|
+
mount.style.position = 'relative';
|
|
153
|
+
}
|
|
154
|
+
mount.appendChild(root);
|
|
155
|
+
|
|
156
|
+
function styleButton(button: HTMLElement, tone: 'primary' | 'plain' | 'danger' | 'active'): void {
|
|
157
|
+
const palette = {
|
|
158
|
+
primary: 'background:#1f6feb;color:#ffffff;border:1px solid #1f6feb;',
|
|
159
|
+
plain: 'background:#f2f4f8;color:#1b1f27;border:1px solid #ccd2de;',
|
|
160
|
+
danger: 'background:#b42318;color:#ffffff;border:1px solid #b42318;',
|
|
161
|
+
active: 'background:#d7e6ff;color:#0b3a82;border:1px solid #1f6feb;'
|
|
162
|
+
}[tone];
|
|
163
|
+
button.style.cssText = `${palette}padding:5px 10px;border-radius:6px;font-size:13px;cursor:pointer;`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function run(operation: () => unknown | Promise<unknown>): void {
|
|
167
|
+
try {
|
|
168
|
+
Promise.resolve(operation()).catch(handleFailure);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
handleFailure(error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function handleFailure(error: unknown): void {
|
|
175
|
+
status = describeError(error);
|
|
176
|
+
options.onError?.(error);
|
|
177
|
+
render();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Every mutation goes through here so the list and the status line stay in step. */
|
|
181
|
+
function mutate(operation: () => unknown | Promise<unknown>): void {
|
|
182
|
+
run(async () => {
|
|
183
|
+
status = '';
|
|
184
|
+
try {
|
|
185
|
+
await operation();
|
|
186
|
+
} catch (error) {
|
|
187
|
+
status = describeError(error);
|
|
188
|
+
options.onError?.(error);
|
|
189
|
+
render();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await reload();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function reload(): Promise<void> {
|
|
197
|
+
if (disposed) return;
|
|
198
|
+
summaries = await options.list(sort);
|
|
199
|
+
render();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function selectSort(field: DslSortField): void {
|
|
203
|
+
if (sort.field === field) {
|
|
204
|
+
sort = {field, direction: sort.direction === 'asc' ? 'desc' : 'asc'};
|
|
205
|
+
} else {
|
|
206
|
+
sort = {field, direction: defaultSortDirections[field]};
|
|
207
|
+
}
|
|
208
|
+
renamingId = null;
|
|
209
|
+
confirmingRemovalId = null;
|
|
210
|
+
mutate(() => undefined);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createButton(label: string, tone: Parameters<typeof styleButton>[1], onClick: () => void) {
|
|
214
|
+
const button = document.createElement('button');
|
|
215
|
+
button.type = 'button';
|
|
216
|
+
button.textContent = label;
|
|
217
|
+
styleButton(button, tone);
|
|
218
|
+
button.addEventListener('click', onClick);
|
|
219
|
+
return button;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderSortBar(text: DslFilesDialogLocaleText): void {
|
|
223
|
+
const arrow = sort.direction === 'asc' ? ' ▲' : ' ▼';
|
|
224
|
+
const fields: Array<[DslSortField, string]> = [
|
|
225
|
+
['name', text.sortByName],
|
|
226
|
+
['updatedAt', text.sortByDate],
|
|
227
|
+
['byteLength', text.sortBySize]
|
|
228
|
+
];
|
|
229
|
+
for (const [field, label] of fields) {
|
|
230
|
+
const active = sort.field === field;
|
|
231
|
+
const button = createButton(active ? `${label}${arrow}` : label, active ? 'active' : 'plain', () =>
|
|
232
|
+
selectSort(field)
|
|
233
|
+
);
|
|
234
|
+
button.setAttribute('data-sort-field', field);
|
|
235
|
+
if (active) button.setAttribute('aria-pressed', 'true');
|
|
236
|
+
sortBar.appendChild(button);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderRow(summary: DslFileSummary, text: DslFilesDialogLocaleText): HTMLElement {
|
|
241
|
+
const row = document.createElement('div');
|
|
242
|
+
row.setAttribute('data-dsl-file-id', summary.id);
|
|
243
|
+
row.style.cssText =
|
|
244
|
+
'display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #e2e6ee;border-radius:8px;';
|
|
245
|
+
|
|
246
|
+
if (renamingId === summary.id) {
|
|
247
|
+
const input = document.createElement('input');
|
|
248
|
+
input.type = 'text';
|
|
249
|
+
input.value = summary.name;
|
|
250
|
+
input.setAttribute('data-rename-input', summary.id);
|
|
251
|
+
input.style.cssText = 'flex:1;min-width:0;padding:5px 8px;border:1px solid #1f6feb;border-radius:6px;font-size:14px;';
|
|
252
|
+
const commit = () => {
|
|
253
|
+
const nextName = input.value;
|
|
254
|
+
renamingId = null;
|
|
255
|
+
mutate(() => options.onRename(summary.id, nextName));
|
|
256
|
+
};
|
|
257
|
+
input.addEventListener('keydown', (event) => {
|
|
258
|
+
const key = (event as KeyboardEvent).key;
|
|
259
|
+
if (key === 'Enter') commit();
|
|
260
|
+
if (key === 'Escape') {
|
|
261
|
+
renamingId = null;
|
|
262
|
+
render();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
row.appendChild(input);
|
|
266
|
+
row.appendChild(createButton(text.confirm, 'primary', commit));
|
|
267
|
+
row.appendChild(
|
|
268
|
+
createButton(text.cancel, 'plain', () => {
|
|
269
|
+
renamingId = null;
|
|
270
|
+
render();
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
return row;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const name = document.createElement('span');
|
|
277
|
+
name.textContent = summary.name;
|
|
278
|
+
name.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;';
|
|
279
|
+
const date = document.createElement('span');
|
|
280
|
+
date.textContent = formatDate(summary.updatedAt);
|
|
281
|
+
date.style.cssText = 'font-size:12px;color:#5a6172;white-space:nowrap;';
|
|
282
|
+
const size = document.createElement('span');
|
|
283
|
+
size.textContent = formatSize(summary.byteLength);
|
|
284
|
+
size.style.cssText = 'font-size:12px;color:#5a6172;white-space:nowrap;min-width:64px;text-align:right;';
|
|
285
|
+
row.appendChild(name);
|
|
286
|
+
row.appendChild(date);
|
|
287
|
+
row.appendChild(size);
|
|
288
|
+
|
|
289
|
+
if (confirmingRemovalId === summary.id) {
|
|
290
|
+
row.appendChild(
|
|
291
|
+
createButton(text.confirmRemove, 'danger', () => {
|
|
292
|
+
confirmingRemovalId = null;
|
|
293
|
+
mutate(() => options.onRemove(summary.id));
|
|
294
|
+
})
|
|
295
|
+
);
|
|
296
|
+
row.appendChild(
|
|
297
|
+
createButton(text.cancel, 'plain', () => {
|
|
298
|
+
confirmingRemovalId = null;
|
|
299
|
+
render();
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
return row;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
row.appendChild(
|
|
306
|
+
createButton(text.open, 'primary', () => {
|
|
307
|
+
mutate(() => options.onOpen(summary.id));
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
row.appendChild(
|
|
311
|
+
createButton(text.rename, 'plain', () => {
|
|
312
|
+
renamingId = summary.id;
|
|
313
|
+
confirmingRemovalId = null;
|
|
314
|
+
render();
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
row.appendChild(
|
|
318
|
+
createButton(text.remove, 'plain', () => {
|
|
319
|
+
confirmingRemovalId = summary.id;
|
|
320
|
+
renamingId = null;
|
|
321
|
+
render();
|
|
322
|
+
})
|
|
323
|
+
);
|
|
324
|
+
return row;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function render(): void {
|
|
328
|
+
if (disposed) return;
|
|
329
|
+
const text = textFor(locales, locale);
|
|
330
|
+
heading.textContent = text.title;
|
|
331
|
+
closeButton.textContent = text.close;
|
|
332
|
+
closeButton.setAttribute('aria-label', text.close);
|
|
333
|
+
addButton.textContent = text.add;
|
|
334
|
+
statusElement.textContent = status;
|
|
335
|
+
|
|
336
|
+
sortBar.replaceChildren();
|
|
337
|
+
renderSortBar(text);
|
|
338
|
+
|
|
339
|
+
listElement.replaceChildren();
|
|
340
|
+
if (summaries.length === 0) {
|
|
341
|
+
const empty = document.createElement('p');
|
|
342
|
+
empty.textContent = text.empty;
|
|
343
|
+
empty.style.cssText = 'margin:8px 0;font-size:14px;color:#5a6172;';
|
|
344
|
+
listElement.appendChild(empty);
|
|
345
|
+
} else {
|
|
346
|
+
for (const summary of summaries) listElement.appendChild(renderRow(summary, text));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
styleButton(closeButton, 'plain');
|
|
351
|
+
styleButton(addButton, 'primary');
|
|
352
|
+
closeButton.type = 'button';
|
|
353
|
+
addButton.type = 'button';
|
|
354
|
+
closeButton.addEventListener('click', () => hide());
|
|
355
|
+
addButton.addEventListener('click', () => {
|
|
356
|
+
mutate(() => options.onAdd());
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
function hide(): void {
|
|
360
|
+
if (disposed) return;
|
|
361
|
+
renamingId = null;
|
|
362
|
+
confirmingRemovalId = null;
|
|
363
|
+
root.style.display = 'none';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function ensureActive(): void {
|
|
367
|
+
if (disposed) throw new TypeError('DSL files dialog is disposed');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
render();
|
|
371
|
+
|
|
372
|
+
return Object.freeze({
|
|
373
|
+
element: root,
|
|
374
|
+
get locale() {
|
|
375
|
+
return locale;
|
|
376
|
+
},
|
|
377
|
+
get sort() {
|
|
378
|
+
return sort;
|
|
379
|
+
},
|
|
380
|
+
async show(nextLocale?: string) {
|
|
381
|
+
ensureActive();
|
|
382
|
+
if (nextLocale !== undefined) locale = nextLocale;
|
|
383
|
+
status = '';
|
|
384
|
+
root.style.display = 'flex';
|
|
385
|
+
await reload();
|
|
386
|
+
return locale;
|
|
387
|
+
},
|
|
388
|
+
hide,
|
|
389
|
+
async refresh() {
|
|
390
|
+
ensureActive();
|
|
391
|
+
await reload();
|
|
392
|
+
},
|
|
393
|
+
setLocale(nextLocale: string) {
|
|
394
|
+
ensureActive();
|
|
395
|
+
locale = nextLocale;
|
|
396
|
+
render();
|
|
397
|
+
return locale;
|
|
398
|
+
},
|
|
399
|
+
dispose() {
|
|
400
|
+
if (disposed) return;
|
|
401
|
+
disposed = true;
|
|
402
|
+
root.remove();
|
|
403
|
+
if (previousMountPosition === '' || previousMountPosition === 'static') {
|
|
404
|
+
mount.style.position = previousMountPosition;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|