@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,254 @@
|
|
|
1
|
+
export class DslStoreError extends Error {
|
|
2
|
+
constructor(code, message, cause) {
|
|
3
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
4
|
+
this.name = 'DslStoreError';
|
|
5
|
+
this.code = code;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export const defaultDslSort = Object.freeze({ field: 'updatedAt', direction: 'desc' });
|
|
9
|
+
const fileStoreName = 'files';
|
|
10
|
+
const metaStoreName = 'meta';
|
|
11
|
+
const lastOpenedKey = 'last-opened';
|
|
12
|
+
const nameIndexName = 'by-name';
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
const maximumNameLength = 200;
|
|
15
|
+
/** Rejects control characters so a name cannot break the dialog's text rendering. */
|
|
16
|
+
function hasControlCharacter(value) {
|
|
17
|
+
for (const character of value) {
|
|
18
|
+
const code = character.codePointAt(0) ?? 0;
|
|
19
|
+
if (code < 0x20 || code === 0x7f)
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
function normalizeName(value) {
|
|
25
|
+
if (typeof value !== 'string')
|
|
26
|
+
throw new DslStoreError('invalid-name', 'DSL name must be a string.');
|
|
27
|
+
const name = value.trim();
|
|
28
|
+
if (name.length === 0)
|
|
29
|
+
throw new DslStoreError('invalid-name', 'DSL name must not be empty.');
|
|
30
|
+
if (name.length > maximumNameLength) {
|
|
31
|
+
throw new DslStoreError('invalid-name', `DSL name must be at most ${maximumNameLength} characters.`);
|
|
32
|
+
}
|
|
33
|
+
if (hasControlCharacter(name)) {
|
|
34
|
+
throw new DslStoreError('invalid-name', 'DSL name must not contain control characters.');
|
|
35
|
+
}
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
function request(input) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
input.onsuccess = () => resolve(input.result);
|
|
41
|
+
input.onerror = () => reject(toStoreError(input.error));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function transactionDone(transaction) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
transaction.oncomplete = () => resolve();
|
|
47
|
+
transaction.onabort = () => reject(toStoreError(transaction.error));
|
|
48
|
+
transaction.onerror = () => reject(toStoreError(transaction.error));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function toStoreError(cause) {
|
|
52
|
+
const name = cause?.name;
|
|
53
|
+
if (name === 'QuotaExceededError') {
|
|
54
|
+
return new DslStoreError('quota', 'Browser storage is full. Delete a saved DSL file first.', cause);
|
|
55
|
+
}
|
|
56
|
+
if (name === 'ConstraintError') {
|
|
57
|
+
return new DslStoreError('name-taken', 'A DSL file with that name already exists.', cause);
|
|
58
|
+
}
|
|
59
|
+
return new DslStoreError('failed', 'The DSL storage operation failed.', cause);
|
|
60
|
+
}
|
|
61
|
+
function compareSummaries(sort) {
|
|
62
|
+
const direction = sort.direction === 'asc' ? 1 : -1;
|
|
63
|
+
return (left, right) => {
|
|
64
|
+
if (sort.field === 'name') {
|
|
65
|
+
return direction * left.name.localeCompare(right.name, undefined, { numeric: true });
|
|
66
|
+
}
|
|
67
|
+
if (sort.field === 'byteLength') {
|
|
68
|
+
return direction * (left.byteLength - right.byteLength) || left.name.localeCompare(right.name);
|
|
69
|
+
}
|
|
70
|
+
return direction * left.updatedAt.localeCompare(right.updatedAt) || left.name.localeCompare(right.name);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function toSummary(record) {
|
|
74
|
+
return {
|
|
75
|
+
id: record.id,
|
|
76
|
+
name: record.name,
|
|
77
|
+
byteLength: record.byteLength,
|
|
78
|
+
savedAt: record.savedAt,
|
|
79
|
+
updatedAt: record.updatedAt
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function createDslStore(options = {}) {
|
|
83
|
+
const factory = options.indexedDB ?? globalThis.indexedDB;
|
|
84
|
+
if (factory === undefined || typeof factory.open !== 'function') {
|
|
85
|
+
throw new DslStoreError('unavailable', 'IndexedDB is not available in this environment.');
|
|
86
|
+
}
|
|
87
|
+
const databaseName = options.databaseName ?? 'turbowarp-title-menu';
|
|
88
|
+
const maxSourceBytes = options.maxSourceBytes ?? 1024 * 1024;
|
|
89
|
+
const maxFileCount = options.maxFileCount ?? 64;
|
|
90
|
+
const now = options.now ?? (() => new Date());
|
|
91
|
+
const createId = options.createId ?? defaultCreateId;
|
|
92
|
+
let connection = null;
|
|
93
|
+
function open() {
|
|
94
|
+
connection ?? (connection = new Promise((resolve, reject) => {
|
|
95
|
+
const opening = factory.open(databaseName, 1);
|
|
96
|
+
opening.onupgradeneeded = () => {
|
|
97
|
+
const database = opening.result;
|
|
98
|
+
if (!database.objectStoreNames.contains(fileStoreName)) {
|
|
99
|
+
const files = database.createObjectStore(fileStoreName, { keyPath: 'id' });
|
|
100
|
+
files.createIndex(nameIndexName, 'name', { unique: true });
|
|
101
|
+
}
|
|
102
|
+
if (!database.objectStoreNames.contains(metaStoreName)) {
|
|
103
|
+
database.createObjectStore(metaStoreName, { keyPath: 'key' });
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
opening.onsuccess = () => resolve(opening.result);
|
|
107
|
+
opening.onerror = () => reject(toStoreError(opening.error));
|
|
108
|
+
opening.onblocked = () => reject(new DslStoreError('failed', 'Another tab is upgrading the DSL database.'));
|
|
109
|
+
}));
|
|
110
|
+
return connection;
|
|
111
|
+
}
|
|
112
|
+
async function readAll() {
|
|
113
|
+
const database = await open();
|
|
114
|
+
const transaction = database.transaction(fileStoreName, 'readonly');
|
|
115
|
+
const records = await request(transaction.objectStore(fileStoreName).getAll());
|
|
116
|
+
await transactionDone(transaction);
|
|
117
|
+
return records;
|
|
118
|
+
}
|
|
119
|
+
async function readOne(id) {
|
|
120
|
+
const database = await open();
|
|
121
|
+
const transaction = database.transaction(fileStoreName, 'readonly');
|
|
122
|
+
const record = await request(transaction.objectStore(fileStoreName).get(id));
|
|
123
|
+
await transactionDone(transaction);
|
|
124
|
+
return record ?? null;
|
|
125
|
+
}
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
databaseName,
|
|
128
|
+
async list(sort = defaultDslSort) {
|
|
129
|
+
const records = await readAll();
|
|
130
|
+
return records.map(toSummary).sort(compareSummaries(sort));
|
|
131
|
+
},
|
|
132
|
+
async count() {
|
|
133
|
+
const database = await open();
|
|
134
|
+
const transaction = database.transaction(fileStoreName, 'readonly');
|
|
135
|
+
const total = await request(transaction.objectStore(fileStoreName).count());
|
|
136
|
+
await transactionDone(transaction);
|
|
137
|
+
return total;
|
|
138
|
+
},
|
|
139
|
+
get(id) {
|
|
140
|
+
return readOne(id);
|
|
141
|
+
},
|
|
142
|
+
async save(file) {
|
|
143
|
+
const name = normalizeName(file.name);
|
|
144
|
+
if (typeof file.source !== 'string') {
|
|
145
|
+
throw new DslStoreError('invalid-source', 'DSL source must be a string.');
|
|
146
|
+
}
|
|
147
|
+
const byteLength = encoder.encode(file.source).byteLength;
|
|
148
|
+
if (byteLength > maxSourceBytes) {
|
|
149
|
+
throw new DslStoreError('too-large', `DSL source exceeds ${maxSourceBytes} bytes.`);
|
|
150
|
+
}
|
|
151
|
+
const database = await open();
|
|
152
|
+
const transaction = database.transaction(fileStoreName, 'readwrite');
|
|
153
|
+
const files = transaction.objectStore(fileStoreName);
|
|
154
|
+
const existing = await request(files.index(nameIndexName).get(name));
|
|
155
|
+
if (existing === undefined) {
|
|
156
|
+
const total = await request(files.count());
|
|
157
|
+
if (total >= maxFileCount) {
|
|
158
|
+
transaction.abort();
|
|
159
|
+
throw new DslStoreError('too-many', `The DSL store already holds ${maxFileCount} files.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const timestamp = now().toISOString();
|
|
163
|
+
const record = {
|
|
164
|
+
id: existing?.id ?? createId(),
|
|
165
|
+
name,
|
|
166
|
+
source: file.source,
|
|
167
|
+
byteLength,
|
|
168
|
+
savedAt: existing?.savedAt ?? timestamp,
|
|
169
|
+
updatedAt: timestamp
|
|
170
|
+
};
|
|
171
|
+
await request(files.put(record));
|
|
172
|
+
await transactionDone(transaction);
|
|
173
|
+
return record;
|
|
174
|
+
},
|
|
175
|
+
async rename(id, nextName) {
|
|
176
|
+
const name = normalizeName(nextName);
|
|
177
|
+
const database = await open();
|
|
178
|
+
const transaction = database.transaction(fileStoreName, 'readwrite');
|
|
179
|
+
const files = transaction.objectStore(fileStoreName);
|
|
180
|
+
const existing = await request(files.get(id));
|
|
181
|
+
if (existing === undefined) {
|
|
182
|
+
transaction.abort();
|
|
183
|
+
throw new DslStoreError('not-found', `No DSL file with id ${id}.`);
|
|
184
|
+
}
|
|
185
|
+
if (existing.name !== name) {
|
|
186
|
+
const taken = await request(files.index(nameIndexName).get(name));
|
|
187
|
+
if (taken !== undefined) {
|
|
188
|
+
transaction.abort();
|
|
189
|
+
throw new DslStoreError('name-taken', `A DSL file named ${name} already exists.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const record = { ...existing, name, updatedAt: now().toISOString() };
|
|
193
|
+
await request(files.put(record));
|
|
194
|
+
await transactionDone(transaction);
|
|
195
|
+
return record;
|
|
196
|
+
},
|
|
197
|
+
async remove(id) {
|
|
198
|
+
const database = await open();
|
|
199
|
+
const transaction = database.transaction([fileStoreName, metaStoreName], 'readwrite');
|
|
200
|
+
await request(transaction.objectStore(fileStoreName).delete(id));
|
|
201
|
+
const meta = transaction.objectStore(metaStoreName);
|
|
202
|
+
const pointer = await request(meta.get(lastOpenedKey));
|
|
203
|
+
if (pointer?.id === id)
|
|
204
|
+
await request(meta.delete(lastOpenedKey));
|
|
205
|
+
await transactionDone(transaction);
|
|
206
|
+
},
|
|
207
|
+
async clear() {
|
|
208
|
+
const database = await open();
|
|
209
|
+
const transaction = database.transaction([fileStoreName, metaStoreName], 'readwrite');
|
|
210
|
+
await request(transaction.objectStore(fileStoreName).clear());
|
|
211
|
+
await request(transaction.objectStore(metaStoreName).clear());
|
|
212
|
+
await transactionDone(transaction);
|
|
213
|
+
},
|
|
214
|
+
async lastOpened() {
|
|
215
|
+
const database = await open();
|
|
216
|
+
const transaction = database.transaction(metaStoreName, 'readonly');
|
|
217
|
+
const pointer = await request(transaction.objectStore(metaStoreName).get(lastOpenedKey));
|
|
218
|
+
await transactionDone(transaction);
|
|
219
|
+
if (pointer === undefined)
|
|
220
|
+
return null;
|
|
221
|
+
return readOne(pointer.id);
|
|
222
|
+
},
|
|
223
|
+
async markOpened(id) {
|
|
224
|
+
const record = await readOne(id);
|
|
225
|
+
if (record === null)
|
|
226
|
+
throw new DslStoreError('not-found', `No DSL file with id ${id}.`);
|
|
227
|
+
const database = await open();
|
|
228
|
+
const transaction = database.transaction(metaStoreName, 'readwrite');
|
|
229
|
+
await request(transaction.objectStore(metaStoreName).put({ key: lastOpenedKey, id }));
|
|
230
|
+
await transactionDone(transaction);
|
|
231
|
+
},
|
|
232
|
+
close() {
|
|
233
|
+
const pending = connection;
|
|
234
|
+
connection = null;
|
|
235
|
+
void pending?.then((database) => database.close()).catch(() => undefined);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function defaultCreateId() {
|
|
240
|
+
const crypto = globalThis.crypto;
|
|
241
|
+
if (typeof crypto?.randomUUID === 'function')
|
|
242
|
+
return crypto.randomUUID();
|
|
243
|
+
return `dsl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
244
|
+
}
|
|
245
|
+
/** Reads a picked browser file into a record shape the store accepts. */
|
|
246
|
+
export async function readDslFile(file, maxSourceBytes = 1024 * 1024) {
|
|
247
|
+
if (typeof file?.text !== 'function') {
|
|
248
|
+
throw new DslStoreError('invalid-source', 'file must be a browser File.');
|
|
249
|
+
}
|
|
250
|
+
if (file.size > maxSourceBytes) {
|
|
251
|
+
throw new DslStoreError('too-large', `DSL file exceeds ${maxSourceBytes} bytes.`);
|
|
252
|
+
}
|
|
253
|
+
return { name: file.name, source: await file.text() };
|
|
254
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const dslOpenEventName = 'turbowarp-title-menu:dsl-open';
|
|
2
|
+
export const dslReloadEventName = 'turbowarp-title-menu:dsl-reload';
|
|
3
|
+
/**
|
|
4
|
+
* Announces an opened DSL source on the window.
|
|
5
|
+
*
|
|
6
|
+
* The extension also starts a Scratch hat, but a packaged host that embeds this extension may run
|
|
7
|
+
* its own runtime outside the VM, so the DOM event stays the transport that assumes no Scratch.
|
|
8
|
+
*/
|
|
9
|
+
export function dispatchDslSourceEvent(type, record) {
|
|
10
|
+
const target = globalThis;
|
|
11
|
+
if (typeof target.dispatchEvent !== 'function')
|
|
12
|
+
return;
|
|
13
|
+
const event = typeof CustomEvent === 'function'
|
|
14
|
+
? new CustomEvent(type, { detail: { record } })
|
|
15
|
+
: new Event(type);
|
|
16
|
+
if (!('detail' in event)) {
|
|
17
|
+
Object.defineProperty(event, 'detail', { value: { record } });
|
|
18
|
+
}
|
|
19
|
+
target.dispatchEvent(event);
|
|
20
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const titleLocales = Object.freeze({
|
|
2
|
+
en: {
|
|
3
|
+
title: 'TurboWarp Title Menu',
|
|
4
|
+
author: 'Author: Hiroya Kubo',
|
|
5
|
+
license: 'License: MPL-2.0',
|
|
6
|
+
website: 'Official Website',
|
|
7
|
+
close: 'Close'
|
|
8
|
+
},
|
|
9
|
+
ja: {
|
|
10
|
+
title: 'TurboWarp Title Menu',
|
|
11
|
+
author: '作者: Hiroya Kubo',
|
|
12
|
+
license: 'ライセンス: MPL-2.0',
|
|
13
|
+
website: '公式Webサイト',
|
|
14
|
+
close: '閉じる'
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
export const menuLocales = Object.freeze({
|
|
18
|
+
en: { files: 'DSL files', reload: 'Reload DSL', about: 'About', close: 'Close' },
|
|
19
|
+
ja: { files: 'DSLファイル', reload: 'DSLを再読み込み', about: '情報', close: '閉じる' }
|
|
20
|
+
});
|
|
21
|
+
export const dslFilesLocales = Object.freeze({
|
|
22
|
+
en: {
|
|
23
|
+
title: 'DSL files',
|
|
24
|
+
add: 'Add file',
|
|
25
|
+
open: 'Open',
|
|
26
|
+
rename: 'Rename',
|
|
27
|
+
remove: 'Delete',
|
|
28
|
+
confirmRemove: 'Delete for good?',
|
|
29
|
+
confirm: 'OK',
|
|
30
|
+
cancel: 'Cancel',
|
|
31
|
+
close: 'Close',
|
|
32
|
+
sortByName: 'Name',
|
|
33
|
+
sortByDate: 'Updated',
|
|
34
|
+
sortBySize: 'Size',
|
|
35
|
+
empty: 'No DSL file is saved yet. Use Add file to store one.'
|
|
36
|
+
},
|
|
37
|
+
ja: {
|
|
38
|
+
title: 'DSLファイル',
|
|
39
|
+
add: 'ファイルを追加',
|
|
40
|
+
open: '開く',
|
|
41
|
+
rename: '名前を変える',
|
|
42
|
+
remove: '削除',
|
|
43
|
+
confirmRemove: '本当に削除?',
|
|
44
|
+
confirm: 'OK',
|
|
45
|
+
cancel: 'やめる',
|
|
46
|
+
close: '閉じる',
|
|
47
|
+
sortByName: '名前',
|
|
48
|
+
sortByDate: '更新日時',
|
|
49
|
+
sortBySize: 'サイズ',
|
|
50
|
+
empty: '保存されたDSLファイルはありません。「ファイルを追加」から保存してください。'
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const storeErrorMessages = Object.freeze({
|
|
54
|
+
en: {
|
|
55
|
+
unavailable: 'Browser storage is unavailable, so DSL files cannot be saved.',
|
|
56
|
+
'invalid-name': 'That file name cannot be used.',
|
|
57
|
+
'invalid-source': 'That file could not be read as text.',
|
|
58
|
+
'name-taken': 'Another DSL file already uses that name.',
|
|
59
|
+
'too-large': 'That DSL file is too large to store.',
|
|
60
|
+
'too-many': 'The DSL store is full. Delete a file before adding another.',
|
|
61
|
+
'not-found': 'That DSL file is no longer stored.',
|
|
62
|
+
quota: 'Browser storage is full. Delete a DSL file and try again.',
|
|
63
|
+
failed: 'The DSL storage operation failed.'
|
|
64
|
+
},
|
|
65
|
+
ja: {
|
|
66
|
+
unavailable: 'ブラウザの保存領域が使えないため、DSLファイルを保存できません。',
|
|
67
|
+
'invalid-name': 'そのファイル名は使えません。',
|
|
68
|
+
'invalid-source': 'そのファイルをテキストとして読み込めませんでした。',
|
|
69
|
+
'name-taken': '同じ名前のDSLファイルがすでにあります。',
|
|
70
|
+
'too-large': 'そのDSLファイルは大きすぎて保存できません。',
|
|
71
|
+
'too-many': '保存できる数に達しています。どれかを削除してから追加してください。',
|
|
72
|
+
'not-found': 'そのDSLファイルは保存されていません。',
|
|
73
|
+
quota: 'ブラウザの保存領域がいっぱいです。DSLファイルを削除してからやり直してください。',
|
|
74
|
+
failed: 'DSLファイルの操作に失敗しました。'
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
/** Turns a store error code into operator-facing text, falling back to the raw message. */
|
|
78
|
+
export function describeStoreError(locale, error) {
|
|
79
|
+
const code = error?.code;
|
|
80
|
+
const messages = storeErrorMessages[locale] ?? storeErrorMessages.en;
|
|
81
|
+
if (code !== undefined && code in messages)
|
|
82
|
+
return messages[code];
|
|
83
|
+
if (error instanceof Error && error.message.length > 0)
|
|
84
|
+
return error.message;
|
|
85
|
+
return String(error);
|
|
86
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ensureRelativeMount, invokeSafely, requireDocument, requireElement } from './dom';
|
|
2
|
+
function requireLocaleText(locales, locale) {
|
|
3
|
+
const text = locales[locale] ?? locales.en ?? Object.values(locales)[0];
|
|
4
|
+
if (!text)
|
|
5
|
+
throw new TypeError('locales must contain at least one locale');
|
|
6
|
+
for (const key of ['title', 'website', 'close']) {
|
|
7
|
+
if (typeof text[key] !== 'string' || text[key].length === 0) {
|
|
8
|
+
throw new TypeError(`locales.${locale}.${key} must be a non-empty string`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return text;
|
|
12
|
+
}
|
|
13
|
+
function openWebsite(url) {
|
|
14
|
+
const opener = globalThis.open;
|
|
15
|
+
if (typeof opener === 'function') {
|
|
16
|
+
opener(url, '_blank', 'noopener,noreferrer');
|
|
17
|
+
}
|
|
18
|
+
else if (globalThis.location) {
|
|
19
|
+
globalThis.location.href = url;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function createTitleDialog(options) {
|
|
23
|
+
const document = requireDocument(options.document ?? globalThis.document);
|
|
24
|
+
const mount = requireElement(options.mount ?? document.body, 'mount');
|
|
25
|
+
const locales = options.locales;
|
|
26
|
+
if (!locales || typeof locales !== 'object')
|
|
27
|
+
throw new TypeError('locales must be an object');
|
|
28
|
+
const root = document.createElement('section');
|
|
29
|
+
const panel = document.createElement('div');
|
|
30
|
+
const language = document.createElement('button');
|
|
31
|
+
const close = document.createElement('button');
|
|
32
|
+
const heading = document.createElement('h1');
|
|
33
|
+
const meta = document.createElement('p');
|
|
34
|
+
const website = document.createElement('button');
|
|
35
|
+
root.setAttribute('data-turbowarp-title-dialog', 'true');
|
|
36
|
+
root.setAttribute('role', 'dialog');
|
|
37
|
+
root.setAttribute('aria-modal', 'true');
|
|
38
|
+
root.style.cssText =
|
|
39
|
+
'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;';
|
|
40
|
+
panel.style.cssText =
|
|
41
|
+
'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;';
|
|
42
|
+
language.style.cssText =
|
|
43
|
+
'position:absolute;top:14px;left:16px;border:0;background:transparent;color:#007d66;font-size:14px;cursor:pointer;';
|
|
44
|
+
close.style.cssText =
|
|
45
|
+
'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;';
|
|
46
|
+
heading.style.cssText = 'margin:0 24px 8px;font-size:30px;font-weight:600;line-height:1.15;';
|
|
47
|
+
meta.style.cssText = 'margin:0 0 22px;font-size:14px;line-height:1.4;';
|
|
48
|
+
website.style.cssText =
|
|
49
|
+
'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;';
|
|
50
|
+
language.type = 'button';
|
|
51
|
+
close.type = 'button';
|
|
52
|
+
website.type = 'button';
|
|
53
|
+
close.textContent = 'x';
|
|
54
|
+
panel.append(language, close, heading, meta, website);
|
|
55
|
+
root.appendChild(panel);
|
|
56
|
+
const restoreMount = ensureRelativeMount(mount);
|
|
57
|
+
mount.appendChild(root);
|
|
58
|
+
let locale = options.initialLocale ?? (locales.ja ? 'ja' : Object.keys(locales)[0] ?? 'en');
|
|
59
|
+
let disposed = false;
|
|
60
|
+
const render = () => {
|
|
61
|
+
const text = requireLocaleText(locales, locale);
|
|
62
|
+
heading.textContent = text.title;
|
|
63
|
+
meta.textContent = [text.author, text.license].filter(Boolean).join(' / ');
|
|
64
|
+
meta.hidden = meta.textContent.length === 0;
|
|
65
|
+
website.textContent = text.website;
|
|
66
|
+
website.setAttribute('aria-label', text.website);
|
|
67
|
+
close.setAttribute('aria-label', text.close);
|
|
68
|
+
close.setAttribute('title', text.close);
|
|
69
|
+
language.textContent = text.language ?? locale;
|
|
70
|
+
language.setAttribute('aria-label', text.language ?? locale);
|
|
71
|
+
};
|
|
72
|
+
const handleWebsite = () => {
|
|
73
|
+
if (options.onWebsite)
|
|
74
|
+
invokeSafely(options.onWebsite, options.onError);
|
|
75
|
+
else if (options.websiteUrl)
|
|
76
|
+
openWebsite(options.websiteUrl);
|
|
77
|
+
};
|
|
78
|
+
const handleClose = () => {
|
|
79
|
+
hide();
|
|
80
|
+
if (options.onClose)
|
|
81
|
+
invokeSafely(options.onClose, options.onError);
|
|
82
|
+
};
|
|
83
|
+
const handleLanguage = () => {
|
|
84
|
+
const keys = Object.keys(locales);
|
|
85
|
+
locale = keys[(Math.max(keys.indexOf(locale), 0) + 1) % keys.length] ?? locale;
|
|
86
|
+
render();
|
|
87
|
+
if (options.onLocaleChange)
|
|
88
|
+
invokeSafely(() => options.onLocaleChange?.(locale), options.onError);
|
|
89
|
+
};
|
|
90
|
+
website.addEventListener('click', handleWebsite);
|
|
91
|
+
close.addEventListener('click', handleClose);
|
|
92
|
+
language.addEventListener('click', handleLanguage);
|
|
93
|
+
function show(nextLocale = locale) {
|
|
94
|
+
if (disposed)
|
|
95
|
+
throw new TypeError('title dialog is disposed');
|
|
96
|
+
locale = nextLocale;
|
|
97
|
+
render();
|
|
98
|
+
root.style.display = 'flex';
|
|
99
|
+
return locale;
|
|
100
|
+
}
|
|
101
|
+
function hide() {
|
|
102
|
+
if (!disposed)
|
|
103
|
+
root.style.display = 'none';
|
|
104
|
+
}
|
|
105
|
+
function setLocale(nextLocale) {
|
|
106
|
+
if (disposed)
|
|
107
|
+
throw new TypeError('title dialog is disposed');
|
|
108
|
+
locale = nextLocale;
|
|
109
|
+
render();
|
|
110
|
+
return locale;
|
|
111
|
+
}
|
|
112
|
+
function dispose() {
|
|
113
|
+
if (disposed)
|
|
114
|
+
return;
|
|
115
|
+
disposed = true;
|
|
116
|
+
website.removeEventListener('click', handleWebsite);
|
|
117
|
+
close.removeEventListener('click', handleClose);
|
|
118
|
+
language.removeEventListener('click', handleLanguage);
|
|
119
|
+
root.remove();
|
|
120
|
+
restoreMount();
|
|
121
|
+
}
|
|
122
|
+
render();
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
element: root,
|
|
125
|
+
get locale() {
|
|
126
|
+
return locale;
|
|
127
|
+
},
|
|
128
|
+
show,
|
|
129
|
+
hide,
|
|
130
|
+
setLocale,
|
|
131
|
+
dispose
|
|
132
|
+
});
|
|
133
|
+
}
|