@kubohiroya/turbowarp-title-menu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.ja.md +188 -0
- package/README.md +276 -0
- package/dist/extension-manifest.json +57 -0
- package/dist/lib/composition.js +5 -0
- package/dist/lib/dom.js +33 -0
- package/dist/lib/dsl-files-dialog.js +323 -0
- package/dist/lib/dsl-store.js +254 -0
- package/dist/lib/events.js +20 -0
- package/dist/lib/locales.js +86 -0
- package/dist/lib/title-dialog.js +133 -0
- package/dist/turbowarp-title-menu.js +1485 -0
- package/dist/types/composition.d.ts +10 -0
- package/dist/types/dom.d.ts +7 -0
- package/dist/types/dsl-files-dialog.d.ts +51 -0
- package/dist/types/dsl-store.d.ts +59 -0
- package/dist/types/events.d.ts +13 -0
- package/dist/types/locales.d.ts +14 -0
- package/dist/types/title-dialog.d.ts +29 -0
- package/docs/architecture.ja.md +63 -0
- package/docs/architecture.md +73 -0
- package/docs/index.html +34 -0
- package/package.json +84 -0
- package/schemas/extension-manifest.schema.json +49 -0
- package/src/block-definitions.json +65 -0
- package/src/composition.ts +26 -0
- package/src/config.ts +13 -0
- package/src/dom.ts +39 -0
- package/src/dsl-files-dialog.ts +408 -0
- package/src/dsl-store.ts +358 -0
- package/src/events.ts +30 -0
- package/src/extension-manifest.ts +149 -0
- package/src/extension.ts +252 -0
- package/src/globals.d.ts +33 -0
- package/src/index.ts +28 -0
- package/src/locales.ts +103 -0
- package/src/title-dialog.ts +168 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import {createAppShellApplicationMenu, resolveAppShellLocale} from '@kubohiroya/turbowarp-app-shell';
|
|
2
|
+
|
|
3
|
+
import definitions from './block-definitions.json';
|
|
4
|
+
import {extensionConfig} from './config';
|
|
5
|
+
import {createDslFilesDialog, type DslFilesDialog} from './dsl-files-dialog';
|
|
6
|
+
import {createDslStore, readDslFile, type DslFileRecord, type DslStore} from './dsl-store';
|
|
7
|
+
import {dispatchDslSourceEvent, dslOpenEventName, dslReloadEventName} from './events';
|
|
8
|
+
import {
|
|
9
|
+
describeStoreError,
|
|
10
|
+
dslFilesLocales,
|
|
11
|
+
menuLocales,
|
|
12
|
+
titleLocales,
|
|
13
|
+
type SupportedLocale
|
|
14
|
+
} from './locales';
|
|
15
|
+
import {createTitleDialog, type TitleDialog} from './title-dialog';
|
|
16
|
+
|
|
17
|
+
type ApplicationMenu = ReturnType<typeof createAppShellApplicationMenu>;
|
|
18
|
+
|
|
19
|
+
type BlockTypeName = 'COMMAND' | 'REPORTER' | 'BOOLEAN' | 'HAT';
|
|
20
|
+
type ArgumentTypeName = 'STRING';
|
|
21
|
+
|
|
22
|
+
interface DefinitionArgument {
|
|
23
|
+
type: ArgumentTypeName;
|
|
24
|
+
defaultValue: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface BlockDefinition {
|
|
28
|
+
opcode: string;
|
|
29
|
+
blockType: BlockTypeName;
|
|
30
|
+
text: string;
|
|
31
|
+
description: string;
|
|
32
|
+
arguments?: Record<string, DefinitionArgument>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const blockDefinitions = definitions.blocks as readonly BlockDefinition[];
|
|
36
|
+
|
|
37
|
+
const dslFileAccept = '.txt,.yaml,.yml,.json,.k4,.kamishibai';
|
|
38
|
+
|
|
39
|
+
function stageMount(): HTMLElement | undefined {
|
|
40
|
+
return Scratch.vm?.renderer?.canvas?.parentElement ?? globalThis.document?.body;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class TurboWarpTitleMenuExtension implements TurboWarpExtension {
|
|
44
|
+
private titleDialog: TitleDialog | null = null;
|
|
45
|
+
private applicationMenu: ApplicationMenu | null = null;
|
|
46
|
+
private filesDialog: DslFilesDialog | null = null;
|
|
47
|
+
private store: DslStore | null = null;
|
|
48
|
+
private openedRecord: DslFileRecord | null = null;
|
|
49
|
+
private lastError = '';
|
|
50
|
+
|
|
51
|
+
public getInfo(): Record<string, unknown> {
|
|
52
|
+
return {
|
|
53
|
+
id: extensionConfig.id,
|
|
54
|
+
name: Scratch.translate(definitions.extensionName),
|
|
55
|
+
docsURI: extensionConfig.docsURI,
|
|
56
|
+
blockIconURI: extensionConfig.blockIconURI,
|
|
57
|
+
blocks: blockDefinitions.map((block) => this.toScratchBlock(block))
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public showTitle(): void {
|
|
62
|
+
this.ensureTitleDialog().show(this.locale());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public showMenu(): void {
|
|
66
|
+
this.ensureApplicationMenu().show(this.locale());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public showDslFiles(): Promise<unknown> {
|
|
70
|
+
return this.ensureFilesDialog().show(this.locale());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The hat is started by the open path, so its own handler only has to accept the match. */
|
|
74
|
+
public whenDslSourceOpened(): boolean {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public reloadOpenedDsl(): void {
|
|
79
|
+
if (this.openedRecord === null) return;
|
|
80
|
+
this.announce(dslReloadEventName, this.openedRecord);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public openedDslName(): string {
|
|
84
|
+
return this.openedRecord?.name ?? '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public openedDslSource(): string {
|
|
88
|
+
return this.openedRecord?.source ?? '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public async hasSavedDsl(): Promise<boolean> {
|
|
92
|
+
return (await this.savedDslCount()) > 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public async savedDslCount(): Promise<number> {
|
|
96
|
+
try {
|
|
97
|
+
return await this.requireStore().count();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.recordFailure(error);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public lastDslError(): string {
|
|
105
|
+
return this.lastError;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private locale(): SupportedLocale {
|
|
109
|
+
return resolveAppShellLocale() === 'ja' ? 'ja' : 'en';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Opens the store on first use.
|
|
114
|
+
*
|
|
115
|
+
* A browser without IndexedDB, or one with storage blocked, fails here rather than at extension
|
|
116
|
+
* load: the title and menu blocks stay usable even when nothing can be stored.
|
|
117
|
+
*/
|
|
118
|
+
private requireStore(): DslStore {
|
|
119
|
+
this.store ??= createDslStore({databaseName: extensionConfig.id});
|
|
120
|
+
return this.store;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private recordFailure(error: unknown): void {
|
|
124
|
+
this.lastError = describeStoreError(this.locale(), error);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private announce(eventName: string, record: DslFileRecord): void {
|
|
128
|
+
dispatchDslSourceEvent(eventName, record);
|
|
129
|
+
Scratch.vm?.runtime?.startHats?.(`${extensionConfig.id}_whenDslSourceOpened`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private ensureTitleDialog(): TitleDialog {
|
|
133
|
+
if (this.titleDialog) return this.titleDialog;
|
|
134
|
+
const mount = stageMount();
|
|
135
|
+
this.titleDialog = createTitleDialog({
|
|
136
|
+
document: globalThis.document,
|
|
137
|
+
...(mount ? {mount} : {}),
|
|
138
|
+
locales: titleLocales,
|
|
139
|
+
initialLocale: this.locale(),
|
|
140
|
+
websiteUrl: extensionConfig.homepage
|
|
141
|
+
});
|
|
142
|
+
return this.titleDialog;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Builds the menu from the shared app-shell primitive.
|
|
147
|
+
*
|
|
148
|
+
* The actions below are this extension's own vocabulary, not the primitive's: a host application
|
|
149
|
+
* that needs different actions composes `createAppShellApplicationMenu` itself through the
|
|
150
|
+
* composition API instead of being limited to these four.
|
|
151
|
+
*/
|
|
152
|
+
private ensureApplicationMenu(): ApplicationMenu {
|
|
153
|
+
if (this.applicationMenu) return this.applicationMenu;
|
|
154
|
+
const mount = stageMount();
|
|
155
|
+
if (mount === undefined) throw new TypeError('a stage container is required to show the menu');
|
|
156
|
+
const labels = (key: keyof (typeof menuLocales)['en']) => ({
|
|
157
|
+
en: menuLocales.en[key],
|
|
158
|
+
ja: menuLocales.ja[key]
|
|
159
|
+
});
|
|
160
|
+
this.applicationMenu = createAppShellApplicationMenu({
|
|
161
|
+
document: globalThis.document,
|
|
162
|
+
mount,
|
|
163
|
+
initialLocale: this.locale(),
|
|
164
|
+
actions: [
|
|
165
|
+
{id: 'files', labels: labels('files'), icon: {text: '\u{1F4C2}'}, onSelect: () => this.showDslFiles()},
|
|
166
|
+
{id: 'reload', labels: labels('reload'), icon: {text: '↻'}, onSelect: () => this.reloadOpenedDsl()},
|
|
167
|
+
{id: 'about', labels: labels('about'), icon: {text: 'i'}, onSelect: () => this.showTitle()},
|
|
168
|
+
{id: 'close', labels: labels('close'), icon: {text: 'x'}, onSelect: () => this.applicationMenu?.hide()}
|
|
169
|
+
]
|
|
170
|
+
});
|
|
171
|
+
return this.applicationMenu;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private ensureFilesDialog(): DslFilesDialog {
|
|
175
|
+
if (this.filesDialog) return this.filesDialog;
|
|
176
|
+
const mount = stageMount();
|
|
177
|
+
this.filesDialog = createDslFilesDialog({
|
|
178
|
+
document: globalThis.document,
|
|
179
|
+
...(mount ? {mount} : {}),
|
|
180
|
+
locales: dslFilesLocales,
|
|
181
|
+
initialLocale: this.locale(),
|
|
182
|
+
list: (sort) => this.requireStore().list(sort),
|
|
183
|
+
onAdd: () => this.addDslFile(),
|
|
184
|
+
onOpen: (id) => this.openDslFile(id),
|
|
185
|
+
onRename: (id, name) => this.requireStore().rename(id, name),
|
|
186
|
+
onRemove: (id) => this.removeDslFile(id),
|
|
187
|
+
describeError: (error) => describeStoreError(this.locale(), error),
|
|
188
|
+
onError: (error) => this.recordFailure(error)
|
|
189
|
+
});
|
|
190
|
+
return this.filesDialog;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async addDslFile(): Promise<void> {
|
|
194
|
+
const chosen = await this.pickDslFile();
|
|
195
|
+
if (chosen === null) return;
|
|
196
|
+
await this.requireStore().save(await readDslFile(chosen));
|
|
197
|
+
this.lastError = '';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async openDslFile(id: string): Promise<void> {
|
|
201
|
+
const store = this.requireStore();
|
|
202
|
+
const record = await store.get(id);
|
|
203
|
+
if (record === null) return;
|
|
204
|
+
await store.markOpened(id);
|
|
205
|
+
this.openedRecord = record;
|
|
206
|
+
this.lastError = '';
|
|
207
|
+
this.announce(dslOpenEventName, record);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async removeDslFile(id: string): Promise<void> {
|
|
211
|
+
await this.requireStore().remove(id);
|
|
212
|
+
if (this.openedRecord?.id === id) this.openedRecord = null;
|
|
213
|
+
this.lastError = '';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private pickDslFile(): Promise<File | null> {
|
|
217
|
+
const document = globalThis.document;
|
|
218
|
+
if (!document) throw new TypeError('document is required to open a DSL file');
|
|
219
|
+
const input = document.createElement('input');
|
|
220
|
+
input.type = 'file';
|
|
221
|
+
input.accept = dslFileAccept;
|
|
222
|
+
return new Promise<File | null>((resolve) => {
|
|
223
|
+
input.addEventListener(
|
|
224
|
+
'change',
|
|
225
|
+
() => {
|
|
226
|
+
resolve(input.files?.[0] ?? null);
|
|
227
|
+
},
|
|
228
|
+
{once: true}
|
|
229
|
+
);
|
|
230
|
+
input.click();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private toScratchBlock(block: BlockDefinition): Record<string, unknown> {
|
|
235
|
+
const scratchBlock: Record<string, unknown> = {
|
|
236
|
+
opcode: block.opcode,
|
|
237
|
+
blockType: Scratch.BlockType[block.blockType],
|
|
238
|
+
text: Scratch.translate(block.text),
|
|
239
|
+
arguments: Object.fromEntries(
|
|
240
|
+
Object.entries(block.arguments ?? {}).map(([name, argument]) => [
|
|
241
|
+
name,
|
|
242
|
+
{
|
|
243
|
+
type: Scratch.ArgumentType[argument.type],
|
|
244
|
+
defaultValue: argument.defaultValue
|
|
245
|
+
}
|
|
246
|
+
])
|
|
247
|
+
)
|
|
248
|
+
};
|
|
249
|
+
if (block.blockType === 'HAT') scratchBlock['isEdgeActivated'] = false;
|
|
250
|
+
return scratchBlock;
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/globals.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
interface TurboWarpExtension {
|
|
2
|
+
getInfo(): Record<string, unknown>;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
interface ScratchTranslate {
|
|
6
|
+
(text: string): string;
|
|
7
|
+
(message: {default: string; description?: string}, placeholders?: Record<string, string | number>): string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ScratchApi {
|
|
11
|
+
extensions: {
|
|
12
|
+
unsandboxed: boolean;
|
|
13
|
+
register(extension: TurboWarpExtension): void;
|
|
14
|
+
};
|
|
15
|
+
vm?: {
|
|
16
|
+
renderer?: {
|
|
17
|
+
canvas?: HTMLCanvasElement;
|
|
18
|
+
};
|
|
19
|
+
runtime?: {
|
|
20
|
+
startHats?(opcode: string, matchFields?: Record<string, unknown>): unknown;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
BlockType: Record<'COMMAND' | 'REPORTER' | 'BOOLEAN' | 'HAT', string>;
|
|
24
|
+
ArgumentType: Record<'STRING' | 'NUMBER' | 'BOOLEAN', string>;
|
|
25
|
+
Cast: {
|
|
26
|
+
toString(value: unknown): string;
|
|
27
|
+
toNumber(value: unknown): number;
|
|
28
|
+
toBoolean(value: unknown): boolean;
|
|
29
|
+
};
|
|
30
|
+
translate: ScratchTranslate;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare const Scratch: ScratchApi;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {createAppShellApplicationMenu} from '@kubohiroya/turbowarp-app-shell';
|
|
2
|
+
|
|
3
|
+
import {extensionConfig} from './config.js';
|
|
4
|
+
import {createDslFilesDialog} from './dsl-files-dialog.js';
|
|
5
|
+
import {createDslStore} from './dsl-store.js';
|
|
6
|
+
import {dslOpenEventName, dslReloadEventName} from './events.js';
|
|
7
|
+
import {createTitleDialog} from './title-dialog.js';
|
|
8
|
+
import {TurboWarpTitleMenuExtension} from './extension.js';
|
|
9
|
+
|
|
10
|
+
const publicApi = Object.freeze({
|
|
11
|
+
createApplicationMenu: createAppShellApplicationMenu,
|
|
12
|
+
createDslFilesDialog,
|
|
13
|
+
createDslStore,
|
|
14
|
+
createTitleDialog,
|
|
15
|
+
dslOpenEventName,
|
|
16
|
+
dslReloadEventName
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
Object.defineProperty(globalThis, 'TurboWarpTitleMenu', {
|
|
20
|
+
value: publicApi,
|
|
21
|
+
configurable: true
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (extensionConfig.unsandboxed && !Scratch.extensions.unsandboxed) {
|
|
25
|
+
throw new Error(`${extensionConfig.name} must run unsandboxed.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Scratch.extensions.register(new TurboWarpTitleMenuExtension());
|
package/src/locales.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type {DslFilesDialogLocaleText} from './dsl-files-dialog';
|
|
2
|
+
import type {DslStoreErrorCode} from './dsl-store';
|
|
3
|
+
import type {TitleDialogLocaleText} from './title-dialog';
|
|
4
|
+
|
|
5
|
+
export type SupportedLocale = 'en' | 'ja';
|
|
6
|
+
|
|
7
|
+
export interface MenuActionLabels {
|
|
8
|
+
files: string;
|
|
9
|
+
reload: string;
|
|
10
|
+
about: string;
|
|
11
|
+
close: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const titleLocales: Readonly<Record<SupportedLocale, TitleDialogLocaleText>> = Object.freeze({
|
|
15
|
+
en: {
|
|
16
|
+
title: 'TurboWarp Title Menu',
|
|
17
|
+
author: 'Author: Hiroya Kubo',
|
|
18
|
+
license: 'License: MPL-2.0',
|
|
19
|
+
website: 'Official Website',
|
|
20
|
+
close: 'Close'
|
|
21
|
+
},
|
|
22
|
+
ja: {
|
|
23
|
+
title: 'TurboWarp Title Menu',
|
|
24
|
+
author: '作者: Hiroya Kubo',
|
|
25
|
+
license: 'ライセンス: MPL-2.0',
|
|
26
|
+
website: '公式Webサイト',
|
|
27
|
+
close: '閉じる'
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const menuLocales: Readonly<Record<SupportedLocale, MenuActionLabels>> = Object.freeze({
|
|
32
|
+
en: {files: 'DSL files', reload: 'Reload DSL', about: 'About', close: 'Close'},
|
|
33
|
+
ja: {files: 'DSLファイル', reload: 'DSLを再読み込み', about: '情報', close: '閉じる'}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const dslFilesLocales: Readonly<Record<SupportedLocale, DslFilesDialogLocaleText>> =
|
|
37
|
+
Object.freeze({
|
|
38
|
+
en: {
|
|
39
|
+
title: 'DSL files',
|
|
40
|
+
add: 'Add file',
|
|
41
|
+
open: 'Open',
|
|
42
|
+
rename: 'Rename',
|
|
43
|
+
remove: 'Delete',
|
|
44
|
+
confirmRemove: 'Delete for good?',
|
|
45
|
+
confirm: 'OK',
|
|
46
|
+
cancel: 'Cancel',
|
|
47
|
+
close: 'Close',
|
|
48
|
+
sortByName: 'Name',
|
|
49
|
+
sortByDate: 'Updated',
|
|
50
|
+
sortBySize: 'Size',
|
|
51
|
+
empty: 'No DSL file is saved yet. Use Add file to store one.'
|
|
52
|
+
},
|
|
53
|
+
ja: {
|
|
54
|
+
title: 'DSLファイル',
|
|
55
|
+
add: 'ファイルを追加',
|
|
56
|
+
open: '開く',
|
|
57
|
+
rename: '名前を変える',
|
|
58
|
+
remove: '削除',
|
|
59
|
+
confirmRemove: '本当に削除?',
|
|
60
|
+
confirm: 'OK',
|
|
61
|
+
cancel: 'やめる',
|
|
62
|
+
close: '閉じる',
|
|
63
|
+
sortByName: '名前',
|
|
64
|
+
sortByDate: '更新日時',
|
|
65
|
+
sortBySize: 'サイズ',
|
|
66
|
+
empty: '保存されたDSLファイルはありません。「ファイルを追加」から保存してください。'
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const storeErrorMessages: Readonly<Record<SupportedLocale, Record<DslStoreErrorCode, string>>> =
|
|
71
|
+
Object.freeze({
|
|
72
|
+
en: {
|
|
73
|
+
unavailable: 'Browser storage is unavailable, so DSL files cannot be saved.',
|
|
74
|
+
'invalid-name': 'That file name cannot be used.',
|
|
75
|
+
'invalid-source': 'That file could not be read as text.',
|
|
76
|
+
'name-taken': 'Another DSL file already uses that name.',
|
|
77
|
+
'too-large': 'That DSL file is too large to store.',
|
|
78
|
+
'too-many': 'The DSL store is full. Delete a file before adding another.',
|
|
79
|
+
'not-found': 'That DSL file is no longer stored.',
|
|
80
|
+
quota: 'Browser storage is full. Delete a DSL file and try again.',
|
|
81
|
+
failed: 'The DSL storage operation failed.'
|
|
82
|
+
},
|
|
83
|
+
ja: {
|
|
84
|
+
unavailable: 'ブラウザの保存領域が使えないため、DSLファイルを保存できません。',
|
|
85
|
+
'invalid-name': 'そのファイル名は使えません。',
|
|
86
|
+
'invalid-source': 'そのファイルをテキストとして読み込めませんでした。',
|
|
87
|
+
'name-taken': '同じ名前のDSLファイルがすでにあります。',
|
|
88
|
+
'too-large': 'そのDSLファイルは大きすぎて保存できません。',
|
|
89
|
+
'too-many': '保存できる数に達しています。どれかを削除してから追加してください。',
|
|
90
|
+
'not-found': 'そのDSLファイルは保存されていません。',
|
|
91
|
+
quota: 'ブラウザの保存領域がいっぱいです。DSLファイルを削除してからやり直してください。',
|
|
92
|
+
failed: 'DSLファイルの操作に失敗しました。'
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** Turns a store error code into operator-facing text, falling back to the raw message. */
|
|
97
|
+
export function describeStoreError(locale: SupportedLocale, error: unknown): string {
|
|
98
|
+
const code = (error as {code?: DslStoreErrorCode} | null)?.code;
|
|
99
|
+
const messages = storeErrorMessages[locale] ?? storeErrorMessages.en;
|
|
100
|
+
if (code !== undefined && code in messages) return messages[code];
|
|
101
|
+
if (error instanceof Error && error.message.length > 0) return error.message;
|
|
102
|
+
return String(error);
|
|
103
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {ensureRelativeMount, invokeSafely, requireDocument, requireElement} from './dom';
|
|
2
|
+
|
|
3
|
+
export type TitleMenuLocale = string;
|
|
4
|
+
|
|
5
|
+
export interface TitleDialogLocaleText {
|
|
6
|
+
title: string;
|
|
7
|
+
author?: string;
|
|
8
|
+
license?: string;
|
|
9
|
+
website: string;
|
|
10
|
+
close: string;
|
|
11
|
+
language?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TitleDialogOptions {
|
|
15
|
+
document?: Document;
|
|
16
|
+
mount?: HTMLElement;
|
|
17
|
+
locales: Record<string, TitleDialogLocaleText>;
|
|
18
|
+
initialLocale?: TitleMenuLocale;
|
|
19
|
+
websiteUrl?: string;
|
|
20
|
+
onWebsite?: () => unknown | Promise<unknown>;
|
|
21
|
+
onClose?: () => unknown | Promise<unknown>;
|
|
22
|
+
onLocaleChange?: (locale: TitleMenuLocale) => unknown | Promise<unknown>;
|
|
23
|
+
onError?: (error: unknown) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TitleDialog {
|
|
27
|
+
readonly element: HTMLElement;
|
|
28
|
+
readonly locale: TitleMenuLocale;
|
|
29
|
+
show(locale?: TitleMenuLocale): TitleMenuLocale;
|
|
30
|
+
hide(): void;
|
|
31
|
+
setLocale(locale: TitleMenuLocale): TitleMenuLocale;
|
|
32
|
+
dispose(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requireLocaleText(locales: Record<string, TitleDialogLocaleText>, locale: string): TitleDialogLocaleText {
|
|
36
|
+
const text = locales[locale] ?? locales.en ?? Object.values(locales)[0];
|
|
37
|
+
if (!text) throw new TypeError('locales must contain at least one locale');
|
|
38
|
+
for (const key of ['title', 'website', 'close'] as const) {
|
|
39
|
+
if (typeof text[key] !== 'string' || text[key].length === 0) {
|
|
40
|
+
throw new TypeError(`locales.${locale}.${key} must be a non-empty string`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function openWebsite(url: string): void {
|
|
47
|
+
const opener = globalThis.open;
|
|
48
|
+
if (typeof opener === 'function') {
|
|
49
|
+
opener(url, '_blank', 'noopener,noreferrer');
|
|
50
|
+
} else if (globalThis.location) {
|
|
51
|
+
globalThis.location.href = url;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createTitleDialog(options: TitleDialogOptions): TitleDialog {
|
|
56
|
+
const document = requireDocument(options.document ?? globalThis.document);
|
|
57
|
+
const mount = requireElement(options.mount ?? document.body, 'mount');
|
|
58
|
+
const locales = options.locales;
|
|
59
|
+
if (!locales || typeof locales !== 'object') throw new TypeError('locales must be an object');
|
|
60
|
+
|
|
61
|
+
const root = document.createElement('section');
|
|
62
|
+
const panel = document.createElement('div');
|
|
63
|
+
const language = document.createElement('button');
|
|
64
|
+
const close = document.createElement('button');
|
|
65
|
+
const heading = document.createElement('h1');
|
|
66
|
+
const meta = document.createElement('p');
|
|
67
|
+
const website = document.createElement('button');
|
|
68
|
+
root.setAttribute('data-turbowarp-title-dialog', 'true');
|
|
69
|
+
root.setAttribute('role', 'dialog');
|
|
70
|
+
root.setAttribute('aria-modal', 'true');
|
|
71
|
+
root.style.cssText =
|
|
72
|
+
'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;';
|
|
73
|
+
panel.style.cssText =
|
|
74
|
+
'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;';
|
|
75
|
+
language.style.cssText =
|
|
76
|
+
'position:absolute;top:14px;left:16px;border:0;background:transparent;color:#007d66;font-size:14px;cursor:pointer;';
|
|
77
|
+
close.style.cssText =
|
|
78
|
+
'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;';
|
|
79
|
+
heading.style.cssText = 'margin:0 24px 8px;font-size:30px;font-weight:600;line-height:1.15;';
|
|
80
|
+
meta.style.cssText = 'margin:0 0 22px;font-size:14px;line-height:1.4;';
|
|
81
|
+
website.style.cssText =
|
|
82
|
+
'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;';
|
|
83
|
+
language.type = 'button';
|
|
84
|
+
close.type = 'button';
|
|
85
|
+
website.type = 'button';
|
|
86
|
+
close.textContent = 'x';
|
|
87
|
+
|
|
88
|
+
panel.append(language, close, heading, meta, website);
|
|
89
|
+
root.appendChild(panel);
|
|
90
|
+
const restoreMount = ensureRelativeMount(mount);
|
|
91
|
+
mount.appendChild(root);
|
|
92
|
+
|
|
93
|
+
let locale = options.initialLocale ?? (locales.ja ? 'ja' : Object.keys(locales)[0] ?? 'en');
|
|
94
|
+
let disposed = false;
|
|
95
|
+
|
|
96
|
+
const render = (): void => {
|
|
97
|
+
const text = requireLocaleText(locales, locale);
|
|
98
|
+
heading.textContent = text.title;
|
|
99
|
+
meta.textContent = [text.author, text.license].filter(Boolean).join(' / ');
|
|
100
|
+
meta.hidden = meta.textContent.length === 0;
|
|
101
|
+
website.textContent = text.website;
|
|
102
|
+
website.setAttribute('aria-label', text.website);
|
|
103
|
+
close.setAttribute('aria-label', text.close);
|
|
104
|
+
close.setAttribute('title', text.close);
|
|
105
|
+
language.textContent = text.language ?? locale;
|
|
106
|
+
language.setAttribute('aria-label', text.language ?? locale);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleWebsite = (): void => {
|
|
110
|
+
if (options.onWebsite) invokeSafely(options.onWebsite, options.onError);
|
|
111
|
+
else if (options.websiteUrl) openWebsite(options.websiteUrl);
|
|
112
|
+
};
|
|
113
|
+
const handleClose = (): void => {
|
|
114
|
+
hide();
|
|
115
|
+
if (options.onClose) invokeSafely(options.onClose, options.onError);
|
|
116
|
+
};
|
|
117
|
+
const handleLanguage = (): void => {
|
|
118
|
+
const keys = Object.keys(locales);
|
|
119
|
+
locale = keys[(Math.max(keys.indexOf(locale), 0) + 1) % keys.length] ?? locale;
|
|
120
|
+
render();
|
|
121
|
+
if (options.onLocaleChange) invokeSafely(() => options.onLocaleChange?.(locale), options.onError);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
website.addEventListener('click', handleWebsite);
|
|
125
|
+
close.addEventListener('click', handleClose);
|
|
126
|
+
language.addEventListener('click', handleLanguage);
|
|
127
|
+
|
|
128
|
+
function show(nextLocale = locale): TitleMenuLocale {
|
|
129
|
+
if (disposed) throw new TypeError('title dialog is disposed');
|
|
130
|
+
locale = nextLocale;
|
|
131
|
+
render();
|
|
132
|
+
root.style.display = 'flex';
|
|
133
|
+
return locale;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function hide(): void {
|
|
137
|
+
if (!disposed) root.style.display = 'none';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function setLocale(nextLocale: TitleMenuLocale): TitleMenuLocale {
|
|
141
|
+
if (disposed) throw new TypeError('title dialog is disposed');
|
|
142
|
+
locale = nextLocale;
|
|
143
|
+
render();
|
|
144
|
+
return locale;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function dispose(): void {
|
|
148
|
+
if (disposed) return;
|
|
149
|
+
disposed = true;
|
|
150
|
+
website.removeEventListener('click', handleWebsite);
|
|
151
|
+
close.removeEventListener('click', handleClose);
|
|
152
|
+
language.removeEventListener('click', handleLanguage);
|
|
153
|
+
root.remove();
|
|
154
|
+
restoreMount();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
render();
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
element: root,
|
|
160
|
+
get locale() {
|
|
161
|
+
return locale;
|
|
162
|
+
},
|
|
163
|
+
show,
|
|
164
|
+
hide,
|
|
165
|
+
setLocale,
|
|
166
|
+
dispose
|
|
167
|
+
});
|
|
168
|
+
}
|