@dialog-designer/designer 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 +21 -0
- package/README.md +12 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/ai-assets.d.ts +19 -0
- package/dist/ai-assets.d.ts.map +1 -0
- package/dist/ai-assets.js +82 -0
- package/dist/ai-assets.js.map +1 -0
- package/dist/debug-client.d.ts +29 -0
- package/dist/debug-client.d.ts.map +1 -0
- package/dist/debug-client.js +68 -0
- package/dist/debug-client.js.map +1 -0
- package/dist/designer.d.ts +65 -0
- package/dist/designer.d.ts.map +1 -0
- package/dist/designer.js +1259 -0
- package/dist/designer.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.d.ts +2 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +415 -0
- package/dist/styles.js.map +1 -0
- package/package.json +38 -0
package/dist/designer.js
ADDED
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
import { registerInGameDesignerPanel } from "@ai-game-assets/core";
|
|
2
|
+
import { assertDialogAiAssets, assertDialogManifest, cloneDialogManifest, createDialog, createDialogBlock, createDialogDecision, createDialogLine, createDialogOption, dialogNodeChildIds, findDialogNodeParent, getDialog, getDialogNode } from "@dialog-designer/core";
|
|
3
|
+
import { isVoiceAsset, isVoiceLineAsset, readableAssetName, suggestVoiceLineAssetId, voiceAssets, voiceLineAssetIds, voiceLineAssets, voiceLineDirection, voiceLineLabel, voiceLineText } from "./ai-assets.js";
|
|
4
|
+
import { DialogDesignerDebugClient } from "./debug-client.js";
|
|
5
|
+
import { ensureDialogDesignerStyles } from "./styles.js";
|
|
6
|
+
const MAX_HISTORY_ENTRIES = 100;
|
|
7
|
+
export function installDialogDesigner(options) {
|
|
8
|
+
ensureDialogDesignerStyles();
|
|
9
|
+
assertDialogManifest(options.manifest);
|
|
10
|
+
assertDialogAiAssets(options.manifest, options.aiAssets);
|
|
11
|
+
const client = options.client ?? new DialogDesignerDebugClient();
|
|
12
|
+
let manifest = cloneDialogManifest(options.manifest);
|
|
13
|
+
let aiAssets = structuredClone(options.aiAssets);
|
|
14
|
+
const initialDialogIds = Object.keys(manifest.dialogs);
|
|
15
|
+
if (initialDialogIds.length === 0) {
|
|
16
|
+
const dialog = createDialog();
|
|
17
|
+
manifest.dialogs[dialog.id] = dialog;
|
|
18
|
+
}
|
|
19
|
+
let selectedDialogId = options.defaultDialogId && manifest.dialogs[options.defaultDialogId]
|
|
20
|
+
? options.defaultDialogId
|
|
21
|
+
: Object.keys(manifest.dialogs)[0] ?? "";
|
|
22
|
+
let selection = selectedDialogId
|
|
23
|
+
? { type: "dialog", dialogId: selectedDialogId }
|
|
24
|
+
: undefined;
|
|
25
|
+
const past = [];
|
|
26
|
+
const future = [];
|
|
27
|
+
let voiceLineRequestActive = false;
|
|
28
|
+
let destroyed = false;
|
|
29
|
+
let api;
|
|
30
|
+
const elements = createElements(options.title ?? "Dialogs");
|
|
31
|
+
(options.mount ?? document.body).append(elements.root);
|
|
32
|
+
const dockPanel = registerInGameDesignerPanel({
|
|
33
|
+
id: "dialog-designer.dialogs",
|
|
34
|
+
label: "Dialogs",
|
|
35
|
+
panel: elements.panel,
|
|
36
|
+
dragHandle: elements.title,
|
|
37
|
+
button: elements.toggle,
|
|
38
|
+
order: 40,
|
|
39
|
+
ariaLabel: "Toggle dialog designer",
|
|
40
|
+
onOpenChange: applyOpenState
|
|
41
|
+
});
|
|
42
|
+
api = {
|
|
43
|
+
root: elements.root,
|
|
44
|
+
open() {
|
|
45
|
+
dockPanel.open();
|
|
46
|
+
},
|
|
47
|
+
close() {
|
|
48
|
+
dockPanel.close();
|
|
49
|
+
},
|
|
50
|
+
isOpen() {
|
|
51
|
+
return dockPanel.isOpen();
|
|
52
|
+
},
|
|
53
|
+
destroy() {
|
|
54
|
+
if (destroyed)
|
|
55
|
+
return;
|
|
56
|
+
destroyed = true;
|
|
57
|
+
window.removeEventListener("keydown", onKeyDown, true);
|
|
58
|
+
dockPanel.destroy();
|
|
59
|
+
elements.root.remove();
|
|
60
|
+
},
|
|
61
|
+
getManifest() {
|
|
62
|
+
return cloneDialogManifest(manifest);
|
|
63
|
+
},
|
|
64
|
+
getAiAssets() {
|
|
65
|
+
return structuredClone(aiAssets);
|
|
66
|
+
},
|
|
67
|
+
getDialogId() {
|
|
68
|
+
return selectedDialogId;
|
|
69
|
+
},
|
|
70
|
+
getSelection() {
|
|
71
|
+
return selection ? structuredClone(selection) : undefined;
|
|
72
|
+
},
|
|
73
|
+
setManifest(nextManifest) {
|
|
74
|
+
assertDialogManifest(nextManifest);
|
|
75
|
+
assertDialogAiAssets(nextManifest, aiAssets);
|
|
76
|
+
manifest = cloneDialogManifest(nextManifest);
|
|
77
|
+
past.length = 0;
|
|
78
|
+
future.length = 0;
|
|
79
|
+
normalizeSelection();
|
|
80
|
+
render();
|
|
81
|
+
emitManifestChange();
|
|
82
|
+
emitSelectionChange();
|
|
83
|
+
emitDialogChange();
|
|
84
|
+
setStatus("Loaded dialog manifest.", "info");
|
|
85
|
+
},
|
|
86
|
+
setAiAssets(nextAiAssets) {
|
|
87
|
+
assertDialogAiAssets(manifest, nextAiAssets);
|
|
88
|
+
aiAssets = structuredClone(nextAiAssets);
|
|
89
|
+
render();
|
|
90
|
+
options.onAiAssetsChange?.(structuredClone(aiAssets));
|
|
91
|
+
setStatus("Updated AI voice assets.", "info");
|
|
92
|
+
},
|
|
93
|
+
select(nextSelection) {
|
|
94
|
+
selection = nextSelection ? structuredClone(nextSelection) : undefined;
|
|
95
|
+
if (nextSelection?.dialogId && manifest.dialogs[nextSelection.dialogId]) {
|
|
96
|
+
selectedDialogId = nextSelection.dialogId;
|
|
97
|
+
}
|
|
98
|
+
normalizeSelection();
|
|
99
|
+
render();
|
|
100
|
+
emitSelectionChange();
|
|
101
|
+
emitDialogChange();
|
|
102
|
+
},
|
|
103
|
+
addDialog(name = uniqueDialogName("New Dialog")) {
|
|
104
|
+
const dialog = createDialog({ name: name.trim() || uniqueDialogName("New Dialog") });
|
|
105
|
+
commit((draft) => {
|
|
106
|
+
draft.dialogs[dialog.id] = dialog;
|
|
107
|
+
}, { message: `Added “${dialog.name}”.` });
|
|
108
|
+
selectedDialogId = dialog.id;
|
|
109
|
+
selection = { type: "dialog", dialogId: dialog.id };
|
|
110
|
+
render();
|
|
111
|
+
emitSelectionChange();
|
|
112
|
+
emitDialogChange();
|
|
113
|
+
return dialog.id;
|
|
114
|
+
},
|
|
115
|
+
renameDialog(dialogId, name) {
|
|
116
|
+
const nextName = requireText(name, "Dialog name");
|
|
117
|
+
commit((draft) => {
|
|
118
|
+
getDialog(draft, dialogId).name = nextName;
|
|
119
|
+
}, { message: "Renamed dialog." });
|
|
120
|
+
},
|
|
121
|
+
setDialogEnabled(dialogId, enabled) {
|
|
122
|
+
commit((draft) => {
|
|
123
|
+
getDialog(draft, dialogId).enabled = enabled;
|
|
124
|
+
}, { message: enabled ? "Enabled dialog." : "Disabled dialog." });
|
|
125
|
+
},
|
|
126
|
+
removeDialog(dialogId) {
|
|
127
|
+
const dialogName = getDialog(manifest, dialogId).name;
|
|
128
|
+
commit((draft) => {
|
|
129
|
+
delete draft.dialogs[dialogId];
|
|
130
|
+
if (draft.dialogPaths)
|
|
131
|
+
delete draft.dialogPaths[dialogId];
|
|
132
|
+
}, { message: `Removed “${dialogName}”.` });
|
|
133
|
+
},
|
|
134
|
+
addNode(parent, type) {
|
|
135
|
+
const node = type === "block" ? createDialogBlock() : createDialogDecision();
|
|
136
|
+
commit((draft) => {
|
|
137
|
+
const dialog = getDialog(draft, selectedDialogId);
|
|
138
|
+
attachNode(dialog, parent, node.id);
|
|
139
|
+
dialog.nodes[node.id] = node;
|
|
140
|
+
}, { message: `Added ${type}.` });
|
|
141
|
+
selection = { type: "node", dialogId: selectedDialogId, nodeId: node.id };
|
|
142
|
+
render();
|
|
143
|
+
emitSelectionChange();
|
|
144
|
+
return node.id;
|
|
145
|
+
},
|
|
146
|
+
updateNode(nodeId, patch) {
|
|
147
|
+
commit((draft) => {
|
|
148
|
+
const node = getDialogNode(draft, selectedDialogId, nodeId);
|
|
149
|
+
if (patch.name !== undefined)
|
|
150
|
+
node.name = requireText(patch.name, "Node name");
|
|
151
|
+
if (patch.enabled !== undefined)
|
|
152
|
+
node.enabled = patch.enabled;
|
|
153
|
+
if (patch.prompt !== undefined) {
|
|
154
|
+
if (node.type !== "decision")
|
|
155
|
+
throw new Error("Only decisions have prompts.");
|
|
156
|
+
node.prompt = requireText(patch.prompt, "Decision prompt");
|
|
157
|
+
}
|
|
158
|
+
}, { message: "Updated dialog node." });
|
|
159
|
+
},
|
|
160
|
+
removeNode(nodeId) {
|
|
161
|
+
const nodeName = getDialogNode(manifest, selectedDialogId, nodeId).name;
|
|
162
|
+
commit((draft) => {
|
|
163
|
+
removeNodeSubtree(getDialog(draft, selectedDialogId), nodeId);
|
|
164
|
+
}, { message: `Removed “${nodeName}” and its branch.` });
|
|
165
|
+
},
|
|
166
|
+
addLine(blockId, input = {}) {
|
|
167
|
+
const pair = resolveLinePair(input.voiceAssetId, input.lineAssetId);
|
|
168
|
+
const line = createDialogLine({
|
|
169
|
+
voiceAssetId: pair.voiceAssetId,
|
|
170
|
+
lineAssetId: pair.lineAssetId,
|
|
171
|
+
enabled: input.enabled
|
|
172
|
+
});
|
|
173
|
+
commit((draft) => {
|
|
174
|
+
findBlock(draft, selectedDialogId, blockId).lines.push(line);
|
|
175
|
+
}, { message: "Added existing voice line." });
|
|
176
|
+
selection = {
|
|
177
|
+
type: "line",
|
|
178
|
+
dialogId: selectedDialogId,
|
|
179
|
+
nodeId: blockId,
|
|
180
|
+
lineId: line.id
|
|
181
|
+
};
|
|
182
|
+
render();
|
|
183
|
+
emitSelectionChange();
|
|
184
|
+
return line.id;
|
|
185
|
+
},
|
|
186
|
+
updateLine(blockId, lineId, patch) {
|
|
187
|
+
commit((draft) => {
|
|
188
|
+
const line = findLine(draft, selectedDialogId, blockId, lineId);
|
|
189
|
+
if (patch.enabled !== undefined)
|
|
190
|
+
line.enabled = patch.enabled;
|
|
191
|
+
if (patch.voiceAssetId !== undefined)
|
|
192
|
+
line.voiceAssetId = patch.voiceAssetId;
|
|
193
|
+
if (patch.lineAssetId !== undefined)
|
|
194
|
+
line.lineAssetId = patch.lineAssetId;
|
|
195
|
+
}, { message: "Updated dialog line." });
|
|
196
|
+
},
|
|
197
|
+
moveLine(blockId, lineId, direction) {
|
|
198
|
+
commit((draft) => {
|
|
199
|
+
const lines = findBlock(draft, selectedDialogId, blockId).lines;
|
|
200
|
+
const index = lines.findIndex((line) => line.id === lineId);
|
|
201
|
+
if (index === -1)
|
|
202
|
+
throw new Error(`Unknown dialog line “${lineId}”.`);
|
|
203
|
+
const destination = index + direction;
|
|
204
|
+
if (destination < 0 || destination >= lines.length)
|
|
205
|
+
return;
|
|
206
|
+
const [line] = lines.splice(index, 1);
|
|
207
|
+
lines.splice(destination, 0, line);
|
|
208
|
+
}, { message: "Reordered dialog line." });
|
|
209
|
+
},
|
|
210
|
+
removeLine(blockId, lineId) {
|
|
211
|
+
commit((draft) => {
|
|
212
|
+
const block = findBlock(draft, selectedDialogId, blockId);
|
|
213
|
+
if (!block.lines.some((line) => line.id === lineId)) {
|
|
214
|
+
throw new Error(`Unknown dialog line “${lineId}”.`);
|
|
215
|
+
}
|
|
216
|
+
block.lines = block.lines.filter((line) => line.id !== lineId);
|
|
217
|
+
}, { message: "Removed dialog line." });
|
|
218
|
+
},
|
|
219
|
+
async createVoiceLine(blockId, input) {
|
|
220
|
+
if (voiceLineRequestActive)
|
|
221
|
+
throw new Error("A voice line is already being created.");
|
|
222
|
+
const voiceAssetId = requireText(input.voiceAssetId, "Voice");
|
|
223
|
+
const text = requireText(input.text, "Line text");
|
|
224
|
+
if (!isVoiceAsset(aiAssets.assets[voiceAssetId])) {
|
|
225
|
+
throw new Error(`Unknown AI voice asset “${voiceAssetId}”.`);
|
|
226
|
+
}
|
|
227
|
+
// Resolve the block before starting external work so a stale selection fails early.
|
|
228
|
+
const requestDialogId = selectedDialogId;
|
|
229
|
+
findBlock(manifest, requestDialogId, blockId);
|
|
230
|
+
voiceLineRequestActive = true;
|
|
231
|
+
setStatus("Adding voice line to AI Assets…", "working");
|
|
232
|
+
try {
|
|
233
|
+
const result = await client.createVoiceLine({
|
|
234
|
+
voiceAssetId,
|
|
235
|
+
text,
|
|
236
|
+
direction: input.direction?.trim() || undefined,
|
|
237
|
+
assetId: input.assetId?.trim() || suggestVoiceLineAssetId(aiAssets, voiceAssetId, text),
|
|
238
|
+
label: input.label?.trim() || undefined
|
|
239
|
+
});
|
|
240
|
+
const lineAsset = result.aiAssets.assets[result.lineAssetId];
|
|
241
|
+
if (!isVoiceLineAsset(lineAsset) || result.asset.id !== result.lineAssetId) {
|
|
242
|
+
throw new Error("Dialog designer dev server returned an invalid voice-line asset.");
|
|
243
|
+
}
|
|
244
|
+
const line = createDialogLine({
|
|
245
|
+
voiceAssetId,
|
|
246
|
+
lineAssetId: result.lineAssetId
|
|
247
|
+
});
|
|
248
|
+
voiceLineRequestActive = false;
|
|
249
|
+
commit((draft) => {
|
|
250
|
+
findBlock(draft, requestDialogId, blockId).lines.push(line);
|
|
251
|
+
}, {
|
|
252
|
+
aiAssets: result.aiAssets,
|
|
253
|
+
emitAiAssets: true,
|
|
254
|
+
message: `Created voice line “${result.lineAssetId}”.`
|
|
255
|
+
});
|
|
256
|
+
if (selectedDialogId === requestDialogId) {
|
|
257
|
+
selection = {
|
|
258
|
+
type: "line",
|
|
259
|
+
dialogId: requestDialogId,
|
|
260
|
+
nodeId: blockId,
|
|
261
|
+
lineId: line.id
|
|
262
|
+
};
|
|
263
|
+
render();
|
|
264
|
+
emitSelectionChange();
|
|
265
|
+
}
|
|
266
|
+
return line.id;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
setStatus(errorMessage(error), "error");
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
voiceLineRequestActive = false;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
addOption(decisionId, text = "New option") {
|
|
277
|
+
const option = createDialogOption({ text: text.trim() || "New option" });
|
|
278
|
+
commit((draft) => {
|
|
279
|
+
findDecision(draft, selectedDialogId, decisionId).options.push(option);
|
|
280
|
+
}, { message: "Added decision option." });
|
|
281
|
+
selection = {
|
|
282
|
+
type: "option",
|
|
283
|
+
dialogId: selectedDialogId,
|
|
284
|
+
nodeId: decisionId,
|
|
285
|
+
optionId: option.id
|
|
286
|
+
};
|
|
287
|
+
render();
|
|
288
|
+
emitSelectionChange();
|
|
289
|
+
return option.id;
|
|
290
|
+
},
|
|
291
|
+
updateOption(decisionId, optionId, patch) {
|
|
292
|
+
commit((draft) => {
|
|
293
|
+
const option = findOption(draft, selectedDialogId, decisionId, optionId);
|
|
294
|
+
if (patch.text !== undefined)
|
|
295
|
+
option.text = requireText(patch.text, "Option text");
|
|
296
|
+
if (patch.enabled !== undefined)
|
|
297
|
+
option.enabled = patch.enabled;
|
|
298
|
+
}, { message: "Updated decision option." });
|
|
299
|
+
},
|
|
300
|
+
removeOption(decisionId, optionId) {
|
|
301
|
+
commit((draft) => {
|
|
302
|
+
const dialog = getDialog(draft, selectedDialogId);
|
|
303
|
+
const decision = findDecision(draft, selectedDialogId, decisionId);
|
|
304
|
+
const option = decision.options.find((candidate) => candidate.id === optionId);
|
|
305
|
+
if (!option)
|
|
306
|
+
throw new Error(`Unknown dialog option “${optionId}”.`);
|
|
307
|
+
if (option.nextNodeId)
|
|
308
|
+
deleteSubtree(dialog, option.nextNodeId);
|
|
309
|
+
decision.options = decision.options.filter((candidate) => candidate.id !== optionId);
|
|
310
|
+
}, { message: "Removed decision option and its branch." });
|
|
311
|
+
},
|
|
312
|
+
undo() {
|
|
313
|
+
const previous = past.pop();
|
|
314
|
+
if (!previous)
|
|
315
|
+
return;
|
|
316
|
+
assertDialogAiAssets(previous, aiAssets);
|
|
317
|
+
future.push(cloneDialogManifest(manifest));
|
|
318
|
+
trimHistory(future);
|
|
319
|
+
manifest = previous;
|
|
320
|
+
normalizeSelection();
|
|
321
|
+
render();
|
|
322
|
+
emitManifestChange();
|
|
323
|
+
emitSelectionChange();
|
|
324
|
+
emitDialogChange();
|
|
325
|
+
setStatus("Undid dialog edit.", "info");
|
|
326
|
+
},
|
|
327
|
+
redo() {
|
|
328
|
+
const next = future.pop();
|
|
329
|
+
if (!next)
|
|
330
|
+
return;
|
|
331
|
+
assertDialogAiAssets(next, aiAssets);
|
|
332
|
+
past.push(cloneDialogManifest(manifest));
|
|
333
|
+
trimHistory(past);
|
|
334
|
+
manifest = next;
|
|
335
|
+
normalizeSelection();
|
|
336
|
+
render();
|
|
337
|
+
emitManifestChange();
|
|
338
|
+
emitSelectionChange();
|
|
339
|
+
emitDialogChange();
|
|
340
|
+
setStatus("Redid dialog edit.", "info");
|
|
341
|
+
},
|
|
342
|
+
async promote(label = "Promoted dialog changes.") {
|
|
343
|
+
try {
|
|
344
|
+
setStatus("Promoting dialog manifest…", "working");
|
|
345
|
+
const promoted = await client.promote({
|
|
346
|
+
manifest,
|
|
347
|
+
dialogId: selectedDialogId || undefined,
|
|
348
|
+
label
|
|
349
|
+
});
|
|
350
|
+
assertDialogManifest(promoted);
|
|
351
|
+
assertDialogAiAssets(promoted, aiAssets);
|
|
352
|
+
manifest = cloneDialogManifest(promoted);
|
|
353
|
+
normalizeSelection();
|
|
354
|
+
render();
|
|
355
|
+
emitManifestChange();
|
|
356
|
+
emitSelectionChange();
|
|
357
|
+
emitDialogChange();
|
|
358
|
+
setStatus(label, "success");
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
setStatus(errorMessage(error), "error");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
elements.dialogSelect.addEventListener("change", () => {
|
|
366
|
+
selectedDialogId = elements.dialogSelect.value;
|
|
367
|
+
selection = selectedDialogId ? { type: "dialog", dialogId: selectedDialogId } : undefined;
|
|
368
|
+
render();
|
|
369
|
+
emitSelectionChange();
|
|
370
|
+
emitDialogChange();
|
|
371
|
+
});
|
|
372
|
+
elements.addDialog.addEventListener("click", () => perform(() => api.addDialog()));
|
|
373
|
+
elements.undo.addEventListener("click", () => perform(() => api.undo()));
|
|
374
|
+
elements.redo.addEventListener("click", () => perform(() => api.redo()));
|
|
375
|
+
elements.promote.addEventListener("click", () => void api.promote());
|
|
376
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
377
|
+
render();
|
|
378
|
+
emitManifestChange();
|
|
379
|
+
emitSelectionChange();
|
|
380
|
+
emitDialogChange();
|
|
381
|
+
return api;
|
|
382
|
+
function createElements(title) {
|
|
383
|
+
const root = document.createElement("div");
|
|
384
|
+
root.className = "dialog-designer";
|
|
385
|
+
root.dataset.open = "false";
|
|
386
|
+
const toggle = button("Dialogs", "dialog-designer__toggle");
|
|
387
|
+
toggle.setAttribute("aria-expanded", "false");
|
|
388
|
+
const panel = document.createElement("div");
|
|
389
|
+
panel.className = "dialog-designer__panel";
|
|
390
|
+
const header = document.createElement("div");
|
|
391
|
+
header.className = "dialog-designer__header";
|
|
392
|
+
const titleWrap = document.createElement("div");
|
|
393
|
+
titleWrap.className = "dialog-designer__title-wrap";
|
|
394
|
+
const eyebrow = document.createElement("div");
|
|
395
|
+
eyebrow.className = "dialog-designer__eyebrow";
|
|
396
|
+
eyebrow.textContent = "Conversation graph";
|
|
397
|
+
const titleElement = document.createElement("div");
|
|
398
|
+
titleElement.className = "dialog-designer__title";
|
|
399
|
+
titleElement.textContent = title;
|
|
400
|
+
titleWrap.append(eyebrow, titleElement);
|
|
401
|
+
const actions = document.createElement("div");
|
|
402
|
+
actions.className = "dialog-designer__actions";
|
|
403
|
+
const undo = button("Undo", "dialog-designer__button dialog-designer__button--small");
|
|
404
|
+
const redo = button("Redo", "dialog-designer__button dialog-designer__button--small");
|
|
405
|
+
const promote = button("Promote", "dialog-designer__button dialog-designer__button--primary");
|
|
406
|
+
actions.append(undo, redo, promote);
|
|
407
|
+
header.append(titleWrap, actions);
|
|
408
|
+
const body = document.createElement("div");
|
|
409
|
+
body.className = "dialog-designer__body";
|
|
410
|
+
const dialogBar = document.createElement("div");
|
|
411
|
+
dialogBar.className = "dialog-designer__dialog-bar";
|
|
412
|
+
const dialogSelect = document.createElement("select");
|
|
413
|
+
dialogSelect.className = "dialog-designer__select";
|
|
414
|
+
dialogSelect.setAttribute("aria-label", "Selected dialog");
|
|
415
|
+
const addDialog = button("+ Dialog", "dialog-designer__button");
|
|
416
|
+
dialogBar.append(dialogSelect, addDialog);
|
|
417
|
+
const workspace = document.createElement("div");
|
|
418
|
+
workspace.className = "dialog-designer__workspace";
|
|
419
|
+
const treeSection = document.createElement("section");
|
|
420
|
+
treeSection.className = "dialog-designer__section";
|
|
421
|
+
const treeHead = document.createElement("div");
|
|
422
|
+
treeHead.className = "dialog-designer__section-head";
|
|
423
|
+
treeHead.textContent = "Dialog tree";
|
|
424
|
+
const tree = document.createElement("div");
|
|
425
|
+
tree.className = "dialog-designer__tree";
|
|
426
|
+
treeSection.append(treeHead, tree);
|
|
427
|
+
const editor = document.createElement("section");
|
|
428
|
+
editor.className = "dialog-designer__editor";
|
|
429
|
+
workspace.append(treeSection, editor);
|
|
430
|
+
const status = document.createElement("div");
|
|
431
|
+
status.className = "dialog-designer__status";
|
|
432
|
+
status.textContent = "Dialog edits stay local until promoted.";
|
|
433
|
+
status.dataset.tone = "info";
|
|
434
|
+
body.append(dialogBar, workspace, status);
|
|
435
|
+
panel.append(header, body);
|
|
436
|
+
root.append(toggle, panel);
|
|
437
|
+
return {
|
|
438
|
+
root,
|
|
439
|
+
toggle,
|
|
440
|
+
panel,
|
|
441
|
+
title: titleElement,
|
|
442
|
+
dialogSelect,
|
|
443
|
+
addDialog,
|
|
444
|
+
undo,
|
|
445
|
+
redo,
|
|
446
|
+
promote,
|
|
447
|
+
tree,
|
|
448
|
+
editor,
|
|
449
|
+
status
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function render() {
|
|
453
|
+
renderDialogSelect();
|
|
454
|
+
renderHistoryControls();
|
|
455
|
+
renderTree();
|
|
456
|
+
renderEditor();
|
|
457
|
+
}
|
|
458
|
+
function renderDialogSelect() {
|
|
459
|
+
elements.dialogSelect.innerHTML = "";
|
|
460
|
+
const dialogs = Object.values(manifest.dialogs)
|
|
461
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
462
|
+
for (const dialog of dialogs) {
|
|
463
|
+
const option = document.createElement("option");
|
|
464
|
+
option.value = dialog.id;
|
|
465
|
+
option.textContent = dialog.enabled ? dialog.name : `${dialog.name} (disabled)`;
|
|
466
|
+
elements.dialogSelect.append(option);
|
|
467
|
+
}
|
|
468
|
+
elements.dialogSelect.disabled = dialogs.length === 0;
|
|
469
|
+
elements.dialogSelect.value = selectedDialogId;
|
|
470
|
+
}
|
|
471
|
+
function renderHistoryControls() {
|
|
472
|
+
elements.undo.disabled = past.length === 0;
|
|
473
|
+
elements.redo.disabled = future.length === 0;
|
|
474
|
+
elements.promote.disabled = Object.keys(manifest.dialogs).length === 0;
|
|
475
|
+
}
|
|
476
|
+
function renderTree() {
|
|
477
|
+
elements.tree.innerHTML = "";
|
|
478
|
+
const dialog = selectedDialog();
|
|
479
|
+
if (!dialog) {
|
|
480
|
+
elements.tree.append(emptyMessage("Add a dialog to begin authoring."));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (!dialog.entryNodeId) {
|
|
484
|
+
const empty = emptyMessage("This dialog has no opening node.");
|
|
485
|
+
const add = renderAddNodeButtons({ type: "entry", dialogId: dialog.id });
|
|
486
|
+
elements.tree.append(empty, add);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
elements.tree.append(renderNodeBranch(dialog, dialog.entryNodeId, "Opening", new Set()));
|
|
490
|
+
}
|
|
491
|
+
function renderNodeBranch(dialog, nodeId, label, ancestors) {
|
|
492
|
+
const branch = document.createElement("div");
|
|
493
|
+
branch.className = "dialog-designer__branch";
|
|
494
|
+
const branchLabel = document.createElement("div");
|
|
495
|
+
branchLabel.className = "dialog-designer__branch-label";
|
|
496
|
+
branchLabel.textContent = label;
|
|
497
|
+
branch.append(branchLabel);
|
|
498
|
+
const node = dialog.nodes[nodeId];
|
|
499
|
+
if (!node) {
|
|
500
|
+
branch.append(emptyMessage(`Missing node: ${nodeId}`));
|
|
501
|
+
return branch;
|
|
502
|
+
}
|
|
503
|
+
if (ancestors.has(nodeId)) {
|
|
504
|
+
branch.append(emptyMessage(`Cycle detected at ${nodeId}`));
|
|
505
|
+
return branch;
|
|
506
|
+
}
|
|
507
|
+
const nextAncestors = new Set(ancestors);
|
|
508
|
+
nextAncestors.add(nodeId);
|
|
509
|
+
const card = document.createElement("div");
|
|
510
|
+
card.className = "dialog-designer__node";
|
|
511
|
+
card.classList.toggle("is-selected", selection?.type !== "dialog" && selection?.nodeId === node.id);
|
|
512
|
+
card.classList.toggle("is-disabled", !node.enabled);
|
|
513
|
+
const head = document.createElement("div");
|
|
514
|
+
head.className = "dialog-designer__node-head";
|
|
515
|
+
const selectNode = document.createElement("button");
|
|
516
|
+
selectNode.type = "button";
|
|
517
|
+
selectNode.className = "dialog-designer__node-select";
|
|
518
|
+
const type = document.createElement("span");
|
|
519
|
+
type.className = `dialog-designer__type${node.type === "decision" ? " dialog-designer__type--decision" : ""}`;
|
|
520
|
+
type.textContent = node.type;
|
|
521
|
+
const name = document.createElement("span");
|
|
522
|
+
name.className = "dialog-designer__node-name";
|
|
523
|
+
name.textContent = node.name;
|
|
524
|
+
selectNode.append(type, name);
|
|
525
|
+
selectNode.addEventListener("click", () => api.select({
|
|
526
|
+
type: "node",
|
|
527
|
+
dialogId: dialog.id,
|
|
528
|
+
nodeId: node.id
|
|
529
|
+
}));
|
|
530
|
+
const enabled = iconButton(node.enabled ? "●" : "○", node.enabled ? "Disable node" : "Enable node");
|
|
531
|
+
enabled.addEventListener("click", () => perform(() => api.updateNode(node.id, { enabled: !node.enabled })));
|
|
532
|
+
const remove = iconButton("×", `Remove ${node.name}`, true);
|
|
533
|
+
remove.addEventListener("click", () => perform(() => api.removeNode(node.id)));
|
|
534
|
+
head.append(selectNode, enabled, remove);
|
|
535
|
+
card.append(head);
|
|
536
|
+
const children = document.createElement("div");
|
|
537
|
+
children.className = "dialog-designer__node-children";
|
|
538
|
+
if (node.type === "block") {
|
|
539
|
+
renderBlockTreeContent(dialog, node, children, nextAncestors);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
renderDecisionTreeContent(dialog, node, children, nextAncestors);
|
|
543
|
+
}
|
|
544
|
+
card.append(children);
|
|
545
|
+
branch.append(card);
|
|
546
|
+
return branch;
|
|
547
|
+
}
|
|
548
|
+
function renderBlockTreeContent(dialog, block, container, ancestors) {
|
|
549
|
+
if (block.lines.length > 0) {
|
|
550
|
+
const lines = document.createElement("div");
|
|
551
|
+
lines.className = "dialog-designer__mini-lines";
|
|
552
|
+
block.lines.forEach((line, index) => {
|
|
553
|
+
const item = document.createElement("button");
|
|
554
|
+
item.type = "button";
|
|
555
|
+
item.className = "dialog-designer__mini-line";
|
|
556
|
+
item.classList.toggle("is-selected", selection?.type === "line" && selection.lineId === line.id);
|
|
557
|
+
const number = document.createElement("strong");
|
|
558
|
+
number.textContent = `${index + 1}`;
|
|
559
|
+
const copy = document.createElement("span");
|
|
560
|
+
const lineAsset = aiAssets.assets[line.lineAssetId];
|
|
561
|
+
copy.textContent = `${readableAssetName(line.voiceAssetId)}: ${voiceLineText(lineAsset)}`;
|
|
562
|
+
if (!line.enabled)
|
|
563
|
+
copy.textContent += " (disabled)";
|
|
564
|
+
item.append(number, copy);
|
|
565
|
+
item.addEventListener("click", () => api.select({
|
|
566
|
+
type: "line",
|
|
567
|
+
dialogId: dialog.id,
|
|
568
|
+
nodeId: block.id,
|
|
569
|
+
lineId: line.id
|
|
570
|
+
}));
|
|
571
|
+
lines.append(item);
|
|
572
|
+
});
|
|
573
|
+
container.append(lines);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
const hint = document.createElement("div");
|
|
577
|
+
hint.className = "dialog-designer__hint";
|
|
578
|
+
hint.textContent = "No lines — this block will be skipped.";
|
|
579
|
+
container.append(hint);
|
|
580
|
+
}
|
|
581
|
+
const continuation = document.createElement("div");
|
|
582
|
+
continuation.className = "dialog-designer__continuation";
|
|
583
|
+
continuation.append(block.nextNodeId
|
|
584
|
+
? renderNodeBranch(dialog, block.nextNodeId, "Then", ancestors)
|
|
585
|
+
: renderEmptyBranch({ type: "block-next", nodeId: block.id }, "Then"));
|
|
586
|
+
container.append(continuation);
|
|
587
|
+
}
|
|
588
|
+
function renderDecisionTreeContent(dialog, decision, container, ancestors) {
|
|
589
|
+
if (decision.options.length === 0) {
|
|
590
|
+
const hint = document.createElement("div");
|
|
591
|
+
hint.className = "dialog-designer__hint";
|
|
592
|
+
hint.textContent = "No choices — the fallback branch will be used.";
|
|
593
|
+
container.append(hint);
|
|
594
|
+
}
|
|
595
|
+
for (const option of decision.options) {
|
|
596
|
+
const optionBranch = document.createElement("div");
|
|
597
|
+
optionBranch.className = "dialog-designer__option-branch";
|
|
598
|
+
optionBranch.classList.toggle("is-disabled", !option.enabled);
|
|
599
|
+
const head = document.createElement("div");
|
|
600
|
+
head.className = "dialog-designer__option-head";
|
|
601
|
+
const summary = document.createElement("button");
|
|
602
|
+
summary.type = "button";
|
|
603
|
+
summary.className = "dialog-designer__option-summary";
|
|
604
|
+
summary.classList.toggle("is-selected", selection?.type === "option" && selection.optionId === option.id);
|
|
605
|
+
const summaryText = document.createElement("span");
|
|
606
|
+
summaryText.textContent = option.text;
|
|
607
|
+
summary.append(summaryText);
|
|
608
|
+
summary.addEventListener("click", () => api.select({
|
|
609
|
+
type: "option",
|
|
610
|
+
dialogId: dialog.id,
|
|
611
|
+
nodeId: decision.id,
|
|
612
|
+
optionId: option.id
|
|
613
|
+
}));
|
|
614
|
+
const enabled = iconButton(option.enabled ? "●" : "○", option.enabled ? "Disable option" : "Enable option");
|
|
615
|
+
enabled.addEventListener("click", () => perform(() => api.updateOption(decision.id, option.id, { enabled: !option.enabled })));
|
|
616
|
+
const remove = iconButton("×", "Remove option and branch", true);
|
|
617
|
+
remove.addEventListener("click", () => perform(() => api.removeOption(decision.id, option.id)));
|
|
618
|
+
head.append(summary, enabled, remove);
|
|
619
|
+
optionBranch.append(head);
|
|
620
|
+
optionBranch.append(option.nextNodeId
|
|
621
|
+
? renderNodeBranch(dialog, option.nextNodeId, "Branch", ancestors)
|
|
622
|
+
: renderEmptyBranch({ type: "option", nodeId: decision.id, optionId: option.id }, "Branch"));
|
|
623
|
+
container.append(optionBranch);
|
|
624
|
+
}
|
|
625
|
+
const addOption = button("+ Option", "dialog-designer__button dialog-designer__button--small");
|
|
626
|
+
addOption.addEventListener("click", () => perform(() => api.addOption(decision.id)));
|
|
627
|
+
container.append(addOption);
|
|
628
|
+
const fallback = document.createElement("div");
|
|
629
|
+
fallback.className = "dialog-designer__continuation";
|
|
630
|
+
fallback.append(decision.nextNodeId
|
|
631
|
+
? renderNodeBranch(dialog, decision.nextNodeId, "Fallback / no option branch", ancestors)
|
|
632
|
+
: renderEmptyBranch({ type: "decision-next", nodeId: decision.id }, "Fallback / no option branch"));
|
|
633
|
+
container.append(fallback);
|
|
634
|
+
}
|
|
635
|
+
function renderEmptyBranch(parent, label) {
|
|
636
|
+
const branch = document.createElement("div");
|
|
637
|
+
branch.className = "dialog-designer__branch";
|
|
638
|
+
const branchLabel = document.createElement("div");
|
|
639
|
+
branchLabel.className = "dialog-designer__branch-label";
|
|
640
|
+
branchLabel.textContent = label;
|
|
641
|
+
branch.append(branchLabel, renderAddNodeButtons(parent));
|
|
642
|
+
return branch;
|
|
643
|
+
}
|
|
644
|
+
function renderAddNodeButtons(parent) {
|
|
645
|
+
const row = document.createElement("div");
|
|
646
|
+
row.className = "dialog-designer__add-row";
|
|
647
|
+
const addBlock = button("+ Block", "dialog-designer__button dialog-designer__button--small");
|
|
648
|
+
const addDecision = button("+ Decision", "dialog-designer__button dialog-designer__button--small");
|
|
649
|
+
addBlock.addEventListener("click", () => perform(() => api.addNode(parent, "block")));
|
|
650
|
+
addDecision.addEventListener("click", () => perform(() => api.addNode(parent, "decision")));
|
|
651
|
+
row.append(addBlock, addDecision);
|
|
652
|
+
return row;
|
|
653
|
+
}
|
|
654
|
+
function renderEditor() {
|
|
655
|
+
elements.editor.innerHTML = "";
|
|
656
|
+
const head = document.createElement("div");
|
|
657
|
+
head.className = "dialog-designer__section-head";
|
|
658
|
+
const body = document.createElement("div");
|
|
659
|
+
body.className = "dialog-designer__editor-body";
|
|
660
|
+
const dialog = selectedDialog();
|
|
661
|
+
if (!dialog || !selection) {
|
|
662
|
+
head.textContent = "Inspector";
|
|
663
|
+
body.append(emptyMessage("Select a dialog element to edit it."));
|
|
664
|
+
elements.editor.append(head, body);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (selection.type === "dialog") {
|
|
668
|
+
head.textContent = "Dialog";
|
|
669
|
+
renderDialogEditor(dialog, body);
|
|
670
|
+
elements.editor.append(head, body);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const node = dialog.nodes[selection.nodeId];
|
|
674
|
+
if (!node) {
|
|
675
|
+
head.textContent = "Inspector";
|
|
676
|
+
body.append(emptyMessage("The selected node no longer exists."));
|
|
677
|
+
elements.editor.append(head, body);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
head.textContent = node.type === "block" ? "Dialog block" : "Decision";
|
|
681
|
+
renderNodeEditor(node, body);
|
|
682
|
+
elements.editor.append(head, body);
|
|
683
|
+
}
|
|
684
|
+
function renderDialogEditor(dialog, container) {
|
|
685
|
+
const name = textInput(dialog.name);
|
|
686
|
+
name.addEventListener("change", () => perform(() => api.renameDialog(dialog.id, name.value)));
|
|
687
|
+
container.append(field("Name", name));
|
|
688
|
+
const enabled = check("Enabled", dialog.enabled);
|
|
689
|
+
enabled.input.addEventListener("change", () => perform(() => api.setDialogEnabled(dialog.id, enabled.input.checked)));
|
|
690
|
+
container.append(enabled.label);
|
|
691
|
+
const summary = document.createElement("p");
|
|
692
|
+
summary.className = "dialog-designer__hint";
|
|
693
|
+
const nodes = Object.values(dialog.nodes);
|
|
694
|
+
const lineCount = nodes.reduce((count, node) => count + (node.type === "block" ? node.lines.length : 0), 0);
|
|
695
|
+
const optionCount = nodes.reduce((count, node) => count + (node.type === "decision" ? node.options.length : 0), 0);
|
|
696
|
+
summary.textContent = `${nodes.length} nodes · ${lineCount} lines · ${optionCount} choices`;
|
|
697
|
+
container.append(summary);
|
|
698
|
+
const remove = button("Remove dialog", "dialog-designer__button dialog-designer__button--danger");
|
|
699
|
+
remove.addEventListener("click", () => perform(() => api.removeDialog(dialog.id)));
|
|
700
|
+
container.append(sectionBreak("Danger zone"), remove);
|
|
701
|
+
}
|
|
702
|
+
function renderNodeEditor(node, container) {
|
|
703
|
+
const name = textInput(node.name);
|
|
704
|
+
name.addEventListener("change", () => perform(() => api.updateNode(node.id, { name: name.value })));
|
|
705
|
+
container.append(field("Name", name));
|
|
706
|
+
const enabled = check("Enabled", node.enabled);
|
|
707
|
+
enabled.input.addEventListener("change", () => perform(() => api.updateNode(node.id, { enabled: enabled.input.checked })));
|
|
708
|
+
container.append(enabled.label);
|
|
709
|
+
if (node.type === "block") {
|
|
710
|
+
renderBlockEditor(node, container);
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
const prompt = textarea(node.prompt);
|
|
714
|
+
prompt.addEventListener("change", () => perform(() => api.updateNode(node.id, { prompt: prompt.value })));
|
|
715
|
+
container.append(field("Prompt shown with choices", prompt));
|
|
716
|
+
renderDecisionEditor(node, container);
|
|
717
|
+
}
|
|
718
|
+
const remove = button(`Remove ${node.type}`, "dialog-designer__button dialog-designer__button--danger");
|
|
719
|
+
remove.addEventListener("click", () => perform(() => api.removeNode(node.id)));
|
|
720
|
+
container.append(sectionBreak("Danger zone"), remove);
|
|
721
|
+
}
|
|
722
|
+
function renderBlockEditor(block, container) {
|
|
723
|
+
const subhead = sectionBreak("Lines");
|
|
724
|
+
const addExisting = button("+ Existing line", "dialog-designer__button dialog-designer__button--small");
|
|
725
|
+
addExisting.disabled = firstVoiceLinePair() === undefined;
|
|
726
|
+
addExisting.addEventListener("click", () => perform(() => api.addLine(block.id)));
|
|
727
|
+
subhead.append(addExisting);
|
|
728
|
+
container.append(subhead);
|
|
729
|
+
const list = document.createElement("div");
|
|
730
|
+
list.className = "dialog-designer__card-list";
|
|
731
|
+
if (block.lines.length === 0)
|
|
732
|
+
list.append(emptyMessage("No lines in this block."));
|
|
733
|
+
block.lines.forEach((line, index) => list.append(renderLineCard(block, line, index)));
|
|
734
|
+
container.append(list, renderCreateVoiceLineForm(block));
|
|
735
|
+
}
|
|
736
|
+
function renderLineCard(block, line, index) {
|
|
737
|
+
const card = document.createElement("div");
|
|
738
|
+
card.className = "dialog-designer__line-card";
|
|
739
|
+
card.classList.toggle("is-selected", selection?.type === "line" && selection.lineId === line.id);
|
|
740
|
+
card.classList.toggle("is-disabled", !line.enabled);
|
|
741
|
+
card.addEventListener("click", (event) => {
|
|
742
|
+
if (isInteractiveTarget(event.target))
|
|
743
|
+
return;
|
|
744
|
+
api.select({
|
|
745
|
+
type: "line",
|
|
746
|
+
dialogId: selectedDialogId,
|
|
747
|
+
nodeId: block.id,
|
|
748
|
+
lineId: line.id
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
const top = document.createElement("div");
|
|
752
|
+
top.className = "dialog-designer__line-top";
|
|
753
|
+
const lineNumber = document.createElement("span");
|
|
754
|
+
lineNumber.className = "dialog-designer__line-index";
|
|
755
|
+
lineNumber.textContent = `LINE ${index + 1}`;
|
|
756
|
+
const actions = document.createElement("div");
|
|
757
|
+
actions.className = "dialog-designer__compact-actions";
|
|
758
|
+
const up = iconButton("↑", "Move line up");
|
|
759
|
+
up.disabled = index === 0;
|
|
760
|
+
up.addEventListener("click", stop(() => perform(() => api.moveLine(block.id, line.id, -1))));
|
|
761
|
+
const down = iconButton("↓", "Move line down");
|
|
762
|
+
down.disabled = index === block.lines.length - 1;
|
|
763
|
+
down.addEventListener("click", stop(() => perform(() => api.moveLine(block.id, line.id, 1))));
|
|
764
|
+
const remove = iconButton("×", "Remove line", true);
|
|
765
|
+
remove.addEventListener("click", stop(() => perform(() => api.removeLine(block.id, line.id))));
|
|
766
|
+
actions.append(up, down, remove);
|
|
767
|
+
top.append(lineNumber, actions);
|
|
768
|
+
card.append(top);
|
|
769
|
+
const enabled = check("Enabled", line.enabled);
|
|
770
|
+
enabled.input.addEventListener("click", (event) => event.stopPropagation());
|
|
771
|
+
enabled.input.addEventListener("change", () => perform(() => api.updateLine(block.id, line.id, { enabled: enabled.input.checked })));
|
|
772
|
+
card.append(enabled.label);
|
|
773
|
+
const voiceSelect = document.createElement("select");
|
|
774
|
+
voiceSelect.className = "dialog-designer__select";
|
|
775
|
+
for (const voice of voiceAssets(aiAssets)) {
|
|
776
|
+
const option = document.createElement("option");
|
|
777
|
+
option.value = voice.id;
|
|
778
|
+
option.textContent = readableAssetName(voice.id);
|
|
779
|
+
voiceSelect.append(option);
|
|
780
|
+
}
|
|
781
|
+
voiceSelect.value = line.voiceAssetId;
|
|
782
|
+
voiceSelect.addEventListener("click", (event) => event.stopPropagation());
|
|
783
|
+
voiceSelect.addEventListener("change", () => {
|
|
784
|
+
const available = voiceLineAssetIds(aiAssets, voiceSelect.value);
|
|
785
|
+
const nextLineAssetId = available.includes(line.lineAssetId)
|
|
786
|
+
? line.lineAssetId
|
|
787
|
+
: available[0];
|
|
788
|
+
if (!nextLineAssetId) {
|
|
789
|
+
setStatus("That voice has no existing lines. Create one below first.", "error");
|
|
790
|
+
render();
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
perform(() => api.updateLine(block.id, line.id, {
|
|
794
|
+
voiceAssetId: voiceSelect.value,
|
|
795
|
+
lineAssetId: nextLineAssetId
|
|
796
|
+
}));
|
|
797
|
+
});
|
|
798
|
+
card.append(field("Voice", voiceSelect));
|
|
799
|
+
const lineSelect = document.createElement("select");
|
|
800
|
+
lineSelect.className = "dialog-designer__select";
|
|
801
|
+
for (const asset of voiceLineAssets(aiAssets, line.voiceAssetId)) {
|
|
802
|
+
const option = document.createElement("option");
|
|
803
|
+
option.value = asset.id;
|
|
804
|
+
option.textContent = voiceLineLabel(asset);
|
|
805
|
+
lineSelect.append(option);
|
|
806
|
+
}
|
|
807
|
+
lineSelect.value = line.lineAssetId;
|
|
808
|
+
lineSelect.addEventListener("click", (event) => event.stopPropagation());
|
|
809
|
+
lineSelect.addEventListener("change", () => perform(() => api.updateLine(block.id, line.id, { lineAssetId: lineSelect.value })));
|
|
810
|
+
card.append(field("Voice line", lineSelect));
|
|
811
|
+
const asset = aiAssets.assets[line.lineAssetId];
|
|
812
|
+
const direction = voiceLineDirection(asset);
|
|
813
|
+
if (direction) {
|
|
814
|
+
const note = document.createElement("p");
|
|
815
|
+
note.className = "dialog-designer__line-copy";
|
|
816
|
+
note.textContent = `Direction: ${direction}`;
|
|
817
|
+
card.append(note);
|
|
818
|
+
}
|
|
819
|
+
return card;
|
|
820
|
+
}
|
|
821
|
+
function renderCreateVoiceLineForm(block) {
|
|
822
|
+
const details = document.createElement("details");
|
|
823
|
+
details.className = "dialog-designer__create-card";
|
|
824
|
+
const summary = document.createElement("summary");
|
|
825
|
+
summary.textContent = "Create a new AI voice line";
|
|
826
|
+
details.append(summary);
|
|
827
|
+
const form = document.createElement("form");
|
|
828
|
+
form.className = "dialog-designer__create-form";
|
|
829
|
+
const voice = document.createElement("select");
|
|
830
|
+
voice.className = "dialog-designer__select";
|
|
831
|
+
for (const asset of voiceAssets(aiAssets)) {
|
|
832
|
+
const option = document.createElement("option");
|
|
833
|
+
option.value = asset.id;
|
|
834
|
+
option.textContent = readableAssetName(asset.id);
|
|
835
|
+
voice.append(option);
|
|
836
|
+
}
|
|
837
|
+
const lineLabel = textInput("");
|
|
838
|
+
lineLabel.placeholder = "Optional label";
|
|
839
|
+
const text = textarea("");
|
|
840
|
+
text.placeholder = "What should the character say?";
|
|
841
|
+
text.required = true;
|
|
842
|
+
const direction = textarea("");
|
|
843
|
+
direction.placeholder = "Tone, pacing, emotion, pronunciation…";
|
|
844
|
+
const create = button("Create and add line", "dialog-designer__button dialog-designer__button--primary");
|
|
845
|
+
create.type = "submit";
|
|
846
|
+
create.disabled = voice.options.length === 0 || voiceLineRequestActive;
|
|
847
|
+
form.append(field("Voice", voice), field("Line label", lineLabel), field("Text", text), field("Director notes", direction), create);
|
|
848
|
+
form.addEventListener("submit", (event) => {
|
|
849
|
+
event.preventDefault();
|
|
850
|
+
create.disabled = true;
|
|
851
|
+
void api.createVoiceLine(block.id, {
|
|
852
|
+
voiceAssetId: voice.value,
|
|
853
|
+
text: text.value,
|
|
854
|
+
direction: direction.value,
|
|
855
|
+
label: lineLabel.value,
|
|
856
|
+
assetId: suggestVoiceLineAssetId(aiAssets, voice.value, text.value)
|
|
857
|
+
}).catch(() => {
|
|
858
|
+
create.disabled = false;
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
details.append(form);
|
|
862
|
+
return details;
|
|
863
|
+
}
|
|
864
|
+
function renderDecisionEditor(decision, container) {
|
|
865
|
+
const subhead = sectionBreak("Options");
|
|
866
|
+
const add = button("+ Option", "dialog-designer__button dialog-designer__button--small");
|
|
867
|
+
add.addEventListener("click", () => perform(() => api.addOption(decision.id)));
|
|
868
|
+
subhead.append(add);
|
|
869
|
+
container.append(subhead);
|
|
870
|
+
const list = document.createElement("div");
|
|
871
|
+
list.className = "dialog-designer__card-list";
|
|
872
|
+
if (decision.options.length === 0)
|
|
873
|
+
list.append(emptyMessage("No options yet."));
|
|
874
|
+
for (const option of decision.options) {
|
|
875
|
+
const card = document.createElement("div");
|
|
876
|
+
card.className = "dialog-designer__option-card";
|
|
877
|
+
card.classList.toggle("is-selected", selection?.type === "option" && selection.optionId === option.id);
|
|
878
|
+
card.classList.toggle("is-disabled", !option.enabled);
|
|
879
|
+
card.addEventListener("click", (event) => {
|
|
880
|
+
if (isInteractiveTarget(event.target))
|
|
881
|
+
return;
|
|
882
|
+
api.select({
|
|
883
|
+
type: "option",
|
|
884
|
+
dialogId: selectedDialogId,
|
|
885
|
+
nodeId: decision.id,
|
|
886
|
+
optionId: option.id
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
const top = document.createElement("div");
|
|
890
|
+
top.className = "dialog-designer__option-top";
|
|
891
|
+
const enabled = check("Enabled", option.enabled);
|
|
892
|
+
enabled.input.addEventListener("click", (event) => event.stopPropagation());
|
|
893
|
+
enabled.input.addEventListener("change", () => perform(() => api.updateOption(decision.id, option.id, { enabled: enabled.input.checked })));
|
|
894
|
+
const remove = iconButton("×", "Remove option and branch", true);
|
|
895
|
+
remove.addEventListener("click", stop(() => perform(() => api.removeOption(decision.id, option.id))));
|
|
896
|
+
top.append(enabled.label, remove);
|
|
897
|
+
card.append(top);
|
|
898
|
+
const copy = textInput(option.text);
|
|
899
|
+
copy.addEventListener("click", (event) => event.stopPropagation());
|
|
900
|
+
copy.addEventListener("change", () => perform(() => api.updateOption(decision.id, option.id, { text: copy.value })));
|
|
901
|
+
card.append(field("Choice text", copy));
|
|
902
|
+
const branch = document.createElement("p");
|
|
903
|
+
branch.className = "dialog-designer__hint";
|
|
904
|
+
branch.textContent = option.nextNodeId
|
|
905
|
+
? `Branch starts at ${decisionName(option.nextNodeId)}.`
|
|
906
|
+
: "No branch; the decision fallback is used.";
|
|
907
|
+
card.append(branch);
|
|
908
|
+
list.append(card);
|
|
909
|
+
}
|
|
910
|
+
container.append(list);
|
|
911
|
+
}
|
|
912
|
+
function commit(mutator, commitOptions = {}) {
|
|
913
|
+
const draft = cloneDialogManifest(manifest);
|
|
914
|
+
const nextAiAssets = commitOptions.aiAssets
|
|
915
|
+
? structuredClone(commitOptions.aiAssets)
|
|
916
|
+
: aiAssets;
|
|
917
|
+
mutator(draft);
|
|
918
|
+
assertDialogManifest(draft);
|
|
919
|
+
assertDialogAiAssets(draft, nextAiAssets);
|
|
920
|
+
if (commitOptions.history !== false) {
|
|
921
|
+
past.push(cloneDialogManifest(manifest));
|
|
922
|
+
trimHistory(past);
|
|
923
|
+
future.length = 0;
|
|
924
|
+
}
|
|
925
|
+
manifest = draft;
|
|
926
|
+
if (commitOptions.aiAssets)
|
|
927
|
+
aiAssets = nextAiAssets;
|
|
928
|
+
normalizeSelection();
|
|
929
|
+
render();
|
|
930
|
+
emitManifestChange();
|
|
931
|
+
emitSelectionChange();
|
|
932
|
+
emitDialogChange();
|
|
933
|
+
if (commitOptions.emitAiAssets) {
|
|
934
|
+
options.onAiAssetsChange?.(structuredClone(aiAssets));
|
|
935
|
+
}
|
|
936
|
+
if (commitOptions.message)
|
|
937
|
+
setStatus(commitOptions.message, "success");
|
|
938
|
+
}
|
|
939
|
+
function normalizeSelection() {
|
|
940
|
+
if (!selectedDialogId || !manifest.dialogs[selectedDialogId]) {
|
|
941
|
+
selectedDialogId = Object.keys(manifest.dialogs)[0] ?? "";
|
|
942
|
+
}
|
|
943
|
+
if (!selectedDialogId) {
|
|
944
|
+
selection = undefined;
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const dialog = manifest.dialogs[selectedDialogId];
|
|
948
|
+
const currentSelection = selection;
|
|
949
|
+
if (!currentSelection || currentSelection.dialogId !== selectedDialogId) {
|
|
950
|
+
selection = { type: "dialog", dialogId: selectedDialogId };
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (currentSelection.type === "dialog")
|
|
954
|
+
return;
|
|
955
|
+
const node = dialog.nodes[currentSelection.nodeId];
|
|
956
|
+
if (!node) {
|
|
957
|
+
selection = { type: "dialog", dialogId: dialog.id };
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (currentSelection.type === "node")
|
|
961
|
+
return;
|
|
962
|
+
if (currentSelection.type === "line") {
|
|
963
|
+
if (node.type !== "block" || !node.lines.some((line) => line.id === currentSelection.lineId)) {
|
|
964
|
+
selection = { type: "node", dialogId: dialog.id, nodeId: node.id };
|
|
965
|
+
}
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (node.type !== "decision" || !node.options.some((option) => option.id === currentSelection.optionId)) {
|
|
969
|
+
selection = { type: "node", dialogId: dialog.id, nodeId: node.id };
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function selectedDialog() {
|
|
973
|
+
return selectedDialogId ? manifest.dialogs[selectedDialogId] : undefined;
|
|
974
|
+
}
|
|
975
|
+
function uniqueDialogName(base) {
|
|
976
|
+
const names = new Set(Object.values(manifest.dialogs).map((dialog) => dialog.name));
|
|
977
|
+
let candidate = base;
|
|
978
|
+
let index = 2;
|
|
979
|
+
while (names.has(candidate)) {
|
|
980
|
+
candidate = `${base} ${index}`;
|
|
981
|
+
index += 1;
|
|
982
|
+
}
|
|
983
|
+
return candidate;
|
|
984
|
+
}
|
|
985
|
+
function firstVoiceLinePair() {
|
|
986
|
+
for (const voice of voiceAssets(aiAssets)) {
|
|
987
|
+
const lineAssetId = voiceLineAssetIds(aiAssets, voice.id)[0];
|
|
988
|
+
if (lineAssetId)
|
|
989
|
+
return { voiceAssetId: voice.id, lineAssetId };
|
|
990
|
+
}
|
|
991
|
+
return undefined;
|
|
992
|
+
}
|
|
993
|
+
function resolveLinePair(preferredVoiceId, preferredLineId) {
|
|
994
|
+
const lineAsset = preferredLineId ? aiAssets.assets[preferredLineId] : undefined;
|
|
995
|
+
const activeVersionVoiceId = isVoiceLineAsset(lineAsset)
|
|
996
|
+
? lineAsset.versions[lineAsset.activeVersion]?.voiceSettings?.voiceAssetId
|
|
997
|
+
: undefined;
|
|
998
|
+
const voiceAssetId = preferredVoiceId
|
|
999
|
+
?? activeVersionVoiceId
|
|
1000
|
+
?? lineAsset?.voiceSettings?.voiceAssetId
|
|
1001
|
+
?? firstVoiceLinePair()?.voiceAssetId;
|
|
1002
|
+
if (!voiceAssetId || !isVoiceAsset(aiAssets.assets[voiceAssetId])) {
|
|
1003
|
+
throw new Error("Add an AI voice before adding dialog lines.");
|
|
1004
|
+
}
|
|
1005
|
+
const compatible = voiceLineAssetIds(aiAssets, voiceAssetId);
|
|
1006
|
+
const lineAssetId = preferredLineId && compatible.includes(preferredLineId)
|
|
1007
|
+
? preferredLineId
|
|
1008
|
+
: compatible[0];
|
|
1009
|
+
if (!lineAssetId) {
|
|
1010
|
+
throw new Error(`Voice “${readableAssetName(voiceAssetId)}” has no existing lines.`);
|
|
1011
|
+
}
|
|
1012
|
+
return { voiceAssetId, lineAssetId };
|
|
1013
|
+
}
|
|
1014
|
+
function decisionName(nodeId) {
|
|
1015
|
+
return selectedDialog()?.nodes[nodeId]?.name ?? nodeId;
|
|
1016
|
+
}
|
|
1017
|
+
function applyOpenState(isOpen) {
|
|
1018
|
+
elements.root.dataset.open = String(isOpen);
|
|
1019
|
+
elements.toggle.setAttribute("aria-expanded", String(isOpen));
|
|
1020
|
+
options.onOpenChange?.(isOpen);
|
|
1021
|
+
}
|
|
1022
|
+
function emitManifestChange() {
|
|
1023
|
+
options.onManifestChange?.(cloneDialogManifest(manifest));
|
|
1024
|
+
}
|
|
1025
|
+
function emitSelectionChange() {
|
|
1026
|
+
options.onSelectionChange?.(selection ? structuredClone(selection) : undefined);
|
|
1027
|
+
}
|
|
1028
|
+
function emitDialogChange() {
|
|
1029
|
+
const dialog = selectedDialog();
|
|
1030
|
+
if (dialog)
|
|
1031
|
+
options.onDialogChange?.(dialog.id, structuredClone(dialog));
|
|
1032
|
+
}
|
|
1033
|
+
function setStatus(message, tone) {
|
|
1034
|
+
elements.status.textContent = message;
|
|
1035
|
+
elements.status.dataset.tone = tone;
|
|
1036
|
+
}
|
|
1037
|
+
function perform(action) {
|
|
1038
|
+
try {
|
|
1039
|
+
action();
|
|
1040
|
+
}
|
|
1041
|
+
catch (error) {
|
|
1042
|
+
render();
|
|
1043
|
+
setStatus(errorMessage(error), "error");
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
function onKeyDown(event) {
|
|
1047
|
+
if (!api.isOpen() || isEditableElement(event.target))
|
|
1048
|
+
return;
|
|
1049
|
+
if (!(event.metaKey || event.ctrlKey))
|
|
1050
|
+
return;
|
|
1051
|
+
const key = event.key.toLowerCase();
|
|
1052
|
+
if (key === "z" && !event.shiftKey) {
|
|
1053
|
+
event.preventDefault();
|
|
1054
|
+
api.undo();
|
|
1055
|
+
}
|
|
1056
|
+
else if (key === "y" || (key === "z" && event.shiftKey)) {
|
|
1057
|
+
event.preventDefault();
|
|
1058
|
+
api.redo();
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
function attachNode(dialog, parent, nodeId) {
|
|
1063
|
+
if (parent.type === "entry") {
|
|
1064
|
+
if (parent.dialogId !== dialog.id)
|
|
1065
|
+
throw new Error("Entry branch belongs to another dialog.");
|
|
1066
|
+
if (dialog.entryNodeId)
|
|
1067
|
+
throw new Error("Dialog already has an opening node.");
|
|
1068
|
+
dialog.entryNodeId = nodeId;
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const node = dialog.nodes[parent.nodeId];
|
|
1072
|
+
if (!node)
|
|
1073
|
+
throw new Error(`Unknown parent node “${parent.nodeId}”.`);
|
|
1074
|
+
if (parent.type === "block-next") {
|
|
1075
|
+
if (node.type !== "block")
|
|
1076
|
+
throw new Error("The selected parent is not a block.");
|
|
1077
|
+
if (node.nextNodeId)
|
|
1078
|
+
throw new Error("Block continuation already has a node.");
|
|
1079
|
+
node.nextNodeId = nodeId;
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (node.type !== "decision")
|
|
1083
|
+
throw new Error("The selected parent is not a decision.");
|
|
1084
|
+
if (parent.type === "decision-next") {
|
|
1085
|
+
if (node.nextNodeId)
|
|
1086
|
+
throw new Error("Decision fallback already has a node.");
|
|
1087
|
+
node.nextNodeId = nodeId;
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const option = node.options.find((candidate) => candidate.id === parent.optionId);
|
|
1091
|
+
if (!option)
|
|
1092
|
+
throw new Error(`Unknown option “${parent.optionId}”.`);
|
|
1093
|
+
if (option.nextNodeId)
|
|
1094
|
+
throw new Error("Option branch already has a node.");
|
|
1095
|
+
option.nextNodeId = nodeId;
|
|
1096
|
+
}
|
|
1097
|
+
function removeNodeSubtree(dialog, nodeId) {
|
|
1098
|
+
const parent = findDialogNodeParent(dialog, nodeId);
|
|
1099
|
+
if (!parent)
|
|
1100
|
+
throw new Error(`Dialog node “${nodeId}” has no parent.`);
|
|
1101
|
+
if (parent.type === "entry") {
|
|
1102
|
+
dialog.entryNodeId = undefined;
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
const parentNode = dialog.nodes[parent.nodeId];
|
|
1106
|
+
if (!parentNode)
|
|
1107
|
+
throw new Error(`Unknown parent node “${parent.nodeId}”.`);
|
|
1108
|
+
if (parent.type === "block-next") {
|
|
1109
|
+
if (parentNode.type !== "block")
|
|
1110
|
+
throw new Error("Invalid block continuation parent.");
|
|
1111
|
+
parentNode.nextNodeId = undefined;
|
|
1112
|
+
}
|
|
1113
|
+
else if (parent.type === "decision-next") {
|
|
1114
|
+
if (parentNode.type !== "decision")
|
|
1115
|
+
throw new Error("Invalid decision fallback parent.");
|
|
1116
|
+
parentNode.nextNodeId = undefined;
|
|
1117
|
+
}
|
|
1118
|
+
else {
|
|
1119
|
+
if (parentNode.type !== "decision")
|
|
1120
|
+
throw new Error("Invalid decision option parent.");
|
|
1121
|
+
const option = parentNode.options.find((candidate) => candidate.id === parent.optionId);
|
|
1122
|
+
if (!option)
|
|
1123
|
+
throw new Error(`Unknown parent option “${parent.optionId}”.`);
|
|
1124
|
+
option.nextNodeId = undefined;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
deleteSubtree(dialog, nodeId);
|
|
1128
|
+
}
|
|
1129
|
+
function deleteSubtree(dialog, rootNodeId) {
|
|
1130
|
+
const pending = [rootNodeId];
|
|
1131
|
+
const visited = new Set();
|
|
1132
|
+
while (pending.length > 0) {
|
|
1133
|
+
const nodeId = pending.pop();
|
|
1134
|
+
if (visited.has(nodeId))
|
|
1135
|
+
continue;
|
|
1136
|
+
visited.add(nodeId);
|
|
1137
|
+
const node = dialog.nodes[nodeId];
|
|
1138
|
+
if (!node)
|
|
1139
|
+
continue;
|
|
1140
|
+
pending.push(...dialogNodeChildIds(node));
|
|
1141
|
+
}
|
|
1142
|
+
for (const nodeId of visited)
|
|
1143
|
+
delete dialog.nodes[nodeId];
|
|
1144
|
+
}
|
|
1145
|
+
function findBlock(manifest, dialogId, blockId) {
|
|
1146
|
+
const node = getDialogNode(manifest, dialogId, blockId);
|
|
1147
|
+
if (node.type !== "block")
|
|
1148
|
+
throw new Error(`Dialog node “${blockId}” is not a block.`);
|
|
1149
|
+
return node;
|
|
1150
|
+
}
|
|
1151
|
+
function findDecision(manifest, dialogId, decisionId) {
|
|
1152
|
+
const node = getDialogNode(manifest, dialogId, decisionId);
|
|
1153
|
+
if (node.type !== "decision")
|
|
1154
|
+
throw new Error(`Dialog node “${decisionId}” is not a decision.`);
|
|
1155
|
+
return node;
|
|
1156
|
+
}
|
|
1157
|
+
function findLine(manifest, dialogId, blockId, lineId) {
|
|
1158
|
+
const line = findBlock(manifest, dialogId, blockId).lines
|
|
1159
|
+
.find((candidate) => candidate.id === lineId);
|
|
1160
|
+
if (!line)
|
|
1161
|
+
throw new Error(`Unknown dialog line “${lineId}”.`);
|
|
1162
|
+
return line;
|
|
1163
|
+
}
|
|
1164
|
+
function findOption(manifest, dialogId, decisionId, optionId) {
|
|
1165
|
+
const option = findDecision(manifest, dialogId, decisionId).options
|
|
1166
|
+
.find((candidate) => candidate.id === optionId);
|
|
1167
|
+
if (!option)
|
|
1168
|
+
throw new Error(`Unknown dialog option “${optionId}”.`);
|
|
1169
|
+
return option;
|
|
1170
|
+
}
|
|
1171
|
+
function trimHistory(history) {
|
|
1172
|
+
if (history.length > MAX_HISTORY_ENTRIES) {
|
|
1173
|
+
history.splice(0, history.length - MAX_HISTORY_ENTRIES);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
function requireText(value, label) {
|
|
1177
|
+
const trimmed = value.trim();
|
|
1178
|
+
if (!trimmed)
|
|
1179
|
+
throw new Error(`${label} cannot be empty.`);
|
|
1180
|
+
return trimmed;
|
|
1181
|
+
}
|
|
1182
|
+
function button(label, className = "dialog-designer__button") {
|
|
1183
|
+
const element = document.createElement("button");
|
|
1184
|
+
element.type = "button";
|
|
1185
|
+
element.className = className;
|
|
1186
|
+
element.textContent = label;
|
|
1187
|
+
return element;
|
|
1188
|
+
}
|
|
1189
|
+
function iconButton(label, title, danger = false) {
|
|
1190
|
+
const element = button(label, `dialog-designer__icon-button${danger ? " dialog-designer__icon-button--danger" : ""}`);
|
|
1191
|
+
element.title = title;
|
|
1192
|
+
element.setAttribute("aria-label", title);
|
|
1193
|
+
return element;
|
|
1194
|
+
}
|
|
1195
|
+
function textInput(value) {
|
|
1196
|
+
const element = document.createElement("input");
|
|
1197
|
+
element.type = "text";
|
|
1198
|
+
element.className = "dialog-designer__input";
|
|
1199
|
+
element.value = value;
|
|
1200
|
+
return element;
|
|
1201
|
+
}
|
|
1202
|
+
function textarea(value) {
|
|
1203
|
+
const element = document.createElement("textarea");
|
|
1204
|
+
element.className = "dialog-designer__textarea";
|
|
1205
|
+
element.value = value;
|
|
1206
|
+
return element;
|
|
1207
|
+
}
|
|
1208
|
+
function field(label, control) {
|
|
1209
|
+
const wrapper = document.createElement("label");
|
|
1210
|
+
wrapper.className = "dialog-designer__field";
|
|
1211
|
+
const text = document.createElement("span");
|
|
1212
|
+
text.textContent = label;
|
|
1213
|
+
wrapper.append(text, control);
|
|
1214
|
+
return wrapper;
|
|
1215
|
+
}
|
|
1216
|
+
function check(label, checked) {
|
|
1217
|
+
const wrapper = document.createElement("label");
|
|
1218
|
+
wrapper.className = "dialog-designer__check";
|
|
1219
|
+
const input = document.createElement("input");
|
|
1220
|
+
input.type = "checkbox";
|
|
1221
|
+
input.checked = checked;
|
|
1222
|
+
const text = document.createElement("span");
|
|
1223
|
+
text.textContent = label;
|
|
1224
|
+
wrapper.append(input, text);
|
|
1225
|
+
return { label: wrapper, input };
|
|
1226
|
+
}
|
|
1227
|
+
function sectionBreak(label) {
|
|
1228
|
+
const element = document.createElement("div");
|
|
1229
|
+
element.className = "dialog-designer__subhead";
|
|
1230
|
+
const text = document.createElement("span");
|
|
1231
|
+
text.textContent = label;
|
|
1232
|
+
element.append(text);
|
|
1233
|
+
return element;
|
|
1234
|
+
}
|
|
1235
|
+
function emptyMessage(message) {
|
|
1236
|
+
const element = document.createElement("div");
|
|
1237
|
+
element.className = "dialog-designer__empty";
|
|
1238
|
+
element.textContent = message;
|
|
1239
|
+
return element;
|
|
1240
|
+
}
|
|
1241
|
+
function stop(listener) {
|
|
1242
|
+
return (event) => {
|
|
1243
|
+
event.stopPropagation();
|
|
1244
|
+
listener();
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
function isEditableElement(target) {
|
|
1248
|
+
return target instanceof HTMLElement && (target.isContentEditable
|
|
1249
|
+
|| target.tagName === "INPUT"
|
|
1250
|
+
|| target.tagName === "SELECT"
|
|
1251
|
+
|| target.tagName === "TEXTAREA");
|
|
1252
|
+
}
|
|
1253
|
+
function isInteractiveTarget(target) {
|
|
1254
|
+
return target instanceof Element && Boolean(target.closest("button, input, select, textarea, label, summary"));
|
|
1255
|
+
}
|
|
1256
|
+
function errorMessage(error) {
|
|
1257
|
+
return error instanceof Error ? error.message : String(error);
|
|
1258
|
+
}
|
|
1259
|
+
//# sourceMappingURL=designer.js.map
|