@case-framework/survey-core 0.8.0 → 0.9.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/build/editor.mjs CHANGED
@@ -1,442 +1,8 @@
1
- import { C as SurveyItemTranslations, G as generateId, Jn as allocateResponseVariableName, M as GroupItemCore, Y as structuredCloneMethod, Yn as isValidResponseVariableName, cr as ValueType, i as projectSlot, m as Survey, n as getDefaultProjectionIssues, zn as ExpressionType } from "./projection-Bl6qCinx.mjs";
2
- //#region src/editor/response-naming.ts
3
- function readPath(config, path) {
4
- let current = config;
5
- for (const key of path) current = current?.[key];
6
- return current;
7
- }
8
- function writePath(config, path, value) {
9
- if (!path.length || path.some((key) => key === "__proto__" || key === "constructor" || key === "prototype")) throw new Error("Invalid response naming path");
10
- const parent = readPath(config, path.slice(0, -1));
11
- if (!parent || typeof parent !== "object") throw new Error("Response naming declaration does not match its config");
12
- parent[path.at(-1)] = value;
13
- }
14
- const assertName = (name) => {
15
- if (!isValidResponseVariableName(name)) throw new Error(`Invalid variable name "${name}". Use lowercase snake_case, starting with a letter, at most 32 characters.`);
16
- };
17
- /** Detached, validated preview; applying it uses the editor's ordinary mutation/history path. */
18
- function previewNaming(survey, edits) {
19
- const registry = survey.getResponseSlotRegistry();
20
- const raw = structuredClone(survey.serialize());
21
- const config = (slot) => raw.surveyItems.find((item) => item.id === slot.itemId).config;
22
- for (const edit of edits) if ("slotId" in edit) {
23
- const slot = registry.get(edit.slotId);
24
- if (!slot) throw new Error(`Unknown response variable "${edit.slotId}"`);
25
- if (slot.naming?.kind === "matrix") throw new Error("Matrix cell names are derived; edit the matrix prefix or row/column coding names");
26
- if (!slot.naming) throw new Error(`The input owning "${edit.slotId}" does not expose a naming mutation`);
27
- assertName(edit.variableName);
28
- writePath(config(slot), slot.naming.path, edit.variableName);
29
- } else {
30
- const slots = registry.definitions.filter((slot) => slot.primitiveId === edit.primitiveId && slot.naming?.kind === "matrix");
31
- if (!slots.length) throw new Error(`Unknown matrix primitive "${edit.primitiveId}"`);
32
- for (const [ids, kind] of [[edit.rows, "row"], [edit.columns, "column"]]) for (const [id, name] of Object.entries(ids ?? {})) {
33
- assertName(name);
34
- const slot = slots.find((slot) => kind === "row" ? slot.rowId === id : slot.columnId === id);
35
- const naming = slot?.naming;
36
- const path = naming?.kind === "matrix" ? kind === "row" ? naming.rowPath : naming.columnPath : void 0;
37
- if (!slot || !path) throw new Error(`Unknown matrix ${kind} "${id}"`);
38
- writePath(config(slot), path, name);
39
- }
40
- if (edit.prefix !== void 0) {
41
- assertName(edit.prefix);
42
- const slot = slots[0], naming = slot.naming;
43
- if (naming?.kind === "matrix") writePath(config(slot), naming.prefixPath, edit.prefix);
44
- }
45
- }
46
- const next = Survey.fromJson(raw, survey.getPluginRegistry());
47
- const nextRegistry = next.getResponseSlotRegistry();
48
- const beforeIssues = new Set(registry.diagnostics.map((issue) => JSON.stringify(issue)));
49
- const issues = nextRegistry.diagnostics.filter((issue) => !beforeIssues.has(JSON.stringify(issue)));
50
- if (issues.length) throw new Error(issues.map((issue) => issue.message).join("\n"));
51
- const changes = nextRegistry.definitions.flatMap((slot) => {
52
- const before = registry.get(slot.slotId).variableName;
53
- return before === slot.variableName ? [] : [{
54
- slotId: slot.slotId,
55
- itemId: slot.itemId,
56
- before,
57
- after: slot.variableName
58
- }];
59
- });
60
- return {
61
- survey: next.serialize(),
62
- changes
63
- };
64
- }
65
- function assertProjectionChanges(before, after) {
66
- const existing = new Set(getDefaultProjectionIssues(before));
67
- const introduced = getDefaultProjectionIssues(after).filter((issue) => !existing.has(issue));
68
- if (introduced.length) throw new Error(introduced.join("\n"));
69
- }
70
- function previewResponseNaming(survey, edits) {
71
- const preview = previewNaming(survey, edits);
72
- assertProjectionChanges(survey, Survey.fromJson(preview.survey, survey.getPluginRegistry()));
73
- return preview;
74
- }
75
- /** Allocate whole matrix prefixes and individual names in deterministic declaration order. */
76
- function allocateCopiedResponseNames(survey, items) {
77
- const existingIds = new Set(survey.getResponseSlotDefinitions().flatMap(({ slots }) => slots.map((slot) => slot.slotId)));
78
- for (const item of items) for (const slot of item.getResponseSlotDefinitions()) {
79
- if (existingIds.has(slot.slotId)) throw new Error(`Inserted input must allocate a fresh slot identity: ${slot.slotId}`);
80
- existingIds.add(slot.slotId);
81
- }
82
- const used = new Set(survey.getResponseSlotRegistry().definitions.map((slot) => slot.variableName));
83
- const usedPrefixes = new Set(survey.getResponseSlotRegistry().definitions.flatMap((slot) => slot.naming?.kind === "matrix" ? [String(readPath(survey.surveyItems.get(slot.itemId).config, slot.naming.prefixPath))] : []));
84
- const changes = [];
85
- for (const item of items) {
86
- const initial = item.getResponseSlotDefinitions();
87
- const groups = /* @__PURE__ */ new Map();
88
- for (const slot of initial) {
89
- const key = slot.naming?.kind === "matrix" ? JSON.stringify(slot.naming.prefixPath) : slot.slotId;
90
- groups.set(key, [...groups.get(key) ?? [], slot]);
91
- }
92
- for (const group of groups.values()) {
93
- const first = group[0], naming = first.naming;
94
- const raw = structuredClone(item.rawItem);
95
- if (naming?.kind === "matrix") {
96
- const prefix = String(readPath(raw.config, naming.prefixPath));
97
- assertName(prefix);
98
- const tails = group.map((slot) => slot.variableName.slice(prefix.length));
99
- if (new Set(tails).size !== tails.length) throw new Error("Matrix coding components produce duplicate variable names");
100
- let candidate = prefix;
101
- const blocked = /* @__PURE__ */ new Set();
102
- while (usedPrefixes.has(candidate) || tails.some((tail) => used.has(`${candidate}${tail}`))) {
103
- blocked.add(candidate);
104
- const longestTail = Math.max(...tails.map((tail) => tail.length));
105
- try {
106
- candidate = allocateResponseVariableName(prefix, blocked, 32 - longestTail);
107
- } catch {
108
- const longest = group[tails.findIndex((tail) => tail.length === longestTail)];
109
- throw new Error(`Cannot allocate a unique matrix prefix for "${longest.variableName}". Shorten its row or column coding name to leave room for a unique prefix within 32 characters.`);
110
- }
111
- }
112
- usedPrefixes.add(candidate);
113
- if (candidate !== prefix) {
114
- writePath(raw.config, naming.prefixPath, candidate);
115
- item.updateRawItem(raw);
116
- }
117
- } else {
118
- assertName(first.variableName);
119
- const name = allocateResponseVariableName(first.variableName, used);
120
- if (name !== first.variableName) {
121
- if (!naming) throw new Error(`Cannot allocate a unique name for plugin variable "${first.variableName}"`);
122
- writePath(raw.config, naming.path, name);
123
- item.updateRawItem(raw);
124
- }
125
- }
126
- for (const slot of item.getResponseSlotDefinitions().filter((slot) => group.some((original) => original.slotId === slot.slotId))) {
127
- assertName(slot.variableName);
128
- used.add(slot.variableName);
129
- const before = initial.find((original) => original.slotId === slot.slotId).variableName;
130
- if (before !== slot.variableName) changes.push({
131
- slotId: slot.slotId,
132
- itemId: item.id,
133
- before,
134
- after: slot.variableName
135
- });
136
- }
137
- }
138
- }
139
- return changes;
140
- }
141
- /** Naming, categorical codes, and formats share one detached, atomic preview. */
142
- function previewResponseSettings(survey, naming, settings) {
143
- const preview = previewNaming(survey, naming);
144
- const raw = preview.survey;
145
- const registry = survey.getResponseSlotRegistry();
146
- const codeWrites = /* @__PURE__ */ new Map();
147
- for (const edit of settings) {
148
- const slot = registry.get(edit.slotId);
149
- if (!slot) throw new Error(`Unknown response variable "${edit.slotId}"`);
150
- const item = raw.surveyItems.find((item) => item.id === slot.itemId);
151
- for (const [id, code] of Object.entries(edit.optionCodes ?? {})) {
152
- const path = slot.optionCodePaths?.[id];
153
- if (!path) throw new Error(`Option "${id}" does not expose an export-code mutation`);
154
- if (!code.trim()) throw new Error("Option export codes cannot be empty");
155
- const target = JSON.stringify([slot.itemId, path]);
156
- const previous = codeWrites.get(target);
157
- if (previous && previous.code !== code) throw new Error(`Conflicting export codes for shared option "${id}" on variables "${registry.get(previous.slotId).variableName}" and "${slot.variableName}". Supply one code for this shared option.`);
158
- codeWrites.set(target, {
159
- code,
160
- slotId: slot.slotId
161
- });
162
- writePath(item.config, path, code);
163
- }
164
- if (edit.exportSettings) item.responseSettings = {
165
- ...item.responseSettings,
166
- [slot.slotId]: structuredClone(edit.exportSettings)
167
- };
168
- }
169
- const next = Survey.fromJson(raw, survey.getPluginRegistry());
170
- for (const slot of next.getResponseSlotRegistry().definitions.filter((slot) => settings.some((edit) => edit.slotId === slot.slotId))) projectSlot(slot, {
171
- mode: "default",
172
- ...slot.exportSettings
173
- });
174
- assertProjectionChanges(survey, next);
175
- return {
176
- ...preview,
177
- survey: next.serialize()
178
- };
179
- }
180
- //#endregion
181
- //#region src/editor/item-copy-paste.ts
182
- var ItemCopyPaste = class ItemCopyPaste {
183
- lastResponseNameChanges = [];
184
- survey;
185
- constructor(survey) {
186
- this.survey = survey;
187
- }
188
- /**
189
- * Copy a survey item and all its data to clipboard format
190
- * When copying a group, automatically includes all items in the subtree
191
- * @param itemId - The id of the item to copy
192
- * @returns Clipboard data that can be serialized to JSON for clipboard
193
- */
194
- copyItem(itemId) {
195
- if (!this.survey.surveyItems.get(itemId)) throw new Error(`Item with id '${itemId}' not found`);
196
- const itemsToCopy = this.collectItemsForCopy(itemId);
197
- const items = itemsToCopy.map((id) => {
198
- const item = this.survey.surveyItems.get(id);
199
- if (!item) throw new Error(`Item with id '${id}' not found during copy`);
200
- return {
201
- itemId: id,
202
- itemData: item.rawItem
203
- };
204
- });
205
- const translations = Object.create(null);
206
- itemsToCopy.forEach((id) => {
207
- try {
208
- const itemTranslations = this.survey.getItemTranslations(id);
209
- if (itemTranslations?.locales?.length) {
210
- const serializedTranslations = Object.create(null);
211
- itemTranslations.locales.forEach((locale) => {
212
- const localeContent = itemTranslations.getAllForLocale(locale);
213
- if (localeContent) serializedTranslations[locale] = localeContent;
214
- });
215
- translations[id] = serializedTranslations;
216
- } else translations[id] = {};
217
- } catch {
218
- translations[id] = {};
219
- }
220
- });
221
- return {
222
- type: "survey-item",
223
- version: "1.0.0",
224
- items,
225
- translations,
226
- rootItemId: itemId,
227
- timestamp: Date.now()
228
- };
229
- }
230
- /**
231
- * Collect all items that should be copied, including subtree for groups
232
- * @param itemId - The root item id
233
- * @returns Array of item keys to copy
234
- */
235
- collectItemsForCopy(itemId) {
236
- const itemsToCopy = [itemId];
237
- const item = this.survey.surveyItems.get(itemId);
238
- if (!item) return [];
239
- if (item instanceof GroupItemCore) {
240
- const childIds = item.items;
241
- if (childIds.length > 0) childIds.forEach((childId) => {
242
- const childItems = this.collectItemsForCopy(childId);
243
- itemsToCopy.push(...childItems);
244
- });
245
- }
246
- return itemsToCopy;
247
- }
248
- /**
249
- * Add an item to its parent group.
250
- * Ensures key uniqueness among siblings.
251
- * @param target - Target location information
252
- * @param item - The item to add (may have key adjusted for uniqueness)
253
- */
254
- addItemToParentGroup(target, item) {
255
- const parentGroup = this.survey.surveyItems.get(target.parentId);
256
- if (!parentGroup) throw new Error(`Parent item with id '${target.parentId}' not found`);
257
- if (!(parentGroup instanceof GroupItemCore)) throw new Error(`Parent item '${target.parentId}' is not a group item`);
258
- const insertIndex = target.index !== void 0 ? Math.min(target.index, parentGroup.items.length) : parentGroup.items.length;
259
- parentGroup.addChild(item.id, insertIndex);
260
- }
261
- /**
262
- * Paste a survey item from clipboard data to a target location
263
- * Handles multiple items and subtrees from the clipboard data
264
- * @param clipboardData - The clipboard data containing the item(s) to paste
265
- * @param target - Target location where to paste the item
266
- * @returns The id of the pasted root item
267
- */
268
- pasteItem(clipboardData, target) {
269
- if (!ItemCopyPaste.isValidClipboardData(clipboardData)) throw new Error("Invalid clipboard data format");
270
- const itemIds = /* @__PURE__ */ new Set();
271
- for (const { itemId, itemData } of clipboardData.items) {
272
- if (itemIds.has(itemId) || itemId !== itemData.id) throw new Error(`Ambiguous clipboard item identity: ${itemId}`);
273
- itemIds.add(itemId);
274
- }
275
- if (!itemIds.has(clipboardData.rootItemId)) throw new Error("Clipboard root item is missing");
276
- if (!(this.survey.surveyItems.get(target.parentId) instanceof GroupItemCore)) throw new Error(`Paste target '${target.parentId}' must be a group item`);
277
- const idMapping = Object.create(null);
278
- clipboardData.items.forEach(({ itemId }) => {
279
- idMapping[itemId] = generateId();
280
- });
281
- const componentMapping = Object.create(null);
282
- const copiedSlotIds = /* @__PURE__ */ new Set();
283
- clipboardData.items.forEach(({ itemData }) => {
284
- const item = this.survey.createItemFromRaw(itemData);
285
- for (const slot of item.getResponseSlotDefinitions()) {
286
- if (copiedSlotIds.has(slot.slotId)) throw new Error(`Duplicate copied slot ID: ${slot.slotId}`);
287
- copiedSlotIds.add(slot.slotId);
288
- }
289
- const declaredIds = item.getResponseSlotDefinitions().flatMap((slot) => [
290
- slot.slotId,
291
- slot.primitiveId,
292
- slot.rowId,
293
- slot.columnId
294
- ].filter((id) => !!id));
295
- for (const id of new Set([...item.getOwnedComponentIds(), ...declaredIds].filter((id) => id !== item.id))) {
296
- if (componentMapping[id]) throw new Error(`Duplicate owned component ID: ${id}`);
297
- componentMapping[id] = generateId();
298
- }
299
- });
300
- const stagedItems = clipboardData.items.map(({ itemId, itemData }) => {
301
- const newId = idMapping[itemId];
302
- if (newId) {
303
- const updatedItemData = this.updateItemIdsInData(itemData, idMapping, componentMapping);
304
- updatedItemData.id = newId;
305
- return this.survey.createItemFromRaw(updatedItemData);
306
- }
307
- throw new Error(`Missing copied identity for '${itemId}'`);
308
- });
309
- this.lastResponseNameChanges = allocateCopiedResponseNames(this.survey, stagedItems);
310
- for (const item of stagedItems) this.survey.surveyItems.set(item.id, item);
311
- this.addItemToParentGroup(target, stagedItems.find((item) => item.id === idMapping[clipboardData.rootItemId]));
312
- const updatedTranslations = this.updateTranslations(clipboardData.translations, idMapping, componentMapping);
313
- Object.keys(updatedTranslations).forEach((itemId) => {
314
- const itemTranslations = new SurveyItemTranslations();
315
- const serializedData = updatedTranslations[itemId];
316
- Object.keys(serializedData).forEach((locale) => {
317
- itemTranslations.setAllForLocale(locale, serializedData[locale]);
318
- });
319
- this.survey.translations.setItemTranslations(itemId, itemTranslations);
320
- });
321
- return idMapping[clipboardData.rootItemId];
322
- }
323
- /**
324
- * Update all item ids in the raw item data recursively
325
- */
326
- updateItemIdsInData(itemData, idMapping, componentMapping) {
327
- const updatedData = JSON.parse(JSON.stringify(itemData));
328
- if (updatedData.itemType === "group" && updatedData.config) {
329
- const config = updatedData.config;
330
- if (config.items) config.items = config.items.map((childId) => idMapping[childId] ?? childId);
331
- }
332
- const remap = (value, key) => {
333
- if (typeof value === "string") {
334
- if ([
335
- "key",
336
- "prefix",
337
- "variableName",
338
- "rowName",
339
- "columnName",
340
- "code",
341
- "label",
342
- "description",
343
- "errorMessage",
344
- "placeholder",
345
- "unit"
346
- ].includes(key ?? "")) return value;
347
- if ((key === "id" || key === "slotId") && idMapping[value]) return idMapping[value];
348
- return componentMapping[value] ?? value;
349
- }
350
- if (Array.isArray(value)) return value.map((entry) => remap(entry));
351
- if (value && typeof value === "object" && value.type === "previousResponse") return structuredClone(value);
352
- if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([name, entry]) => [componentMapping[name] ?? idMapping[name] ?? name, name === "value" && (typeof entry === "string" || Array.isArray(entry)) && value.type !== ValueType.reference && value.type !== ValueType.referenceArray ? entry : remap(entry, name)]));
353
- return value;
354
- };
355
- updatedData.config = remap(updatedData.config);
356
- if (updatedData.responseSettings) updatedData.responseSettings = Object.fromEntries(Object.entries(updatedData.responseSettings).map(([slotId, settings]) => {
357
- const value = structuredClone(settings);
358
- for (const name of ["optionCodes", "optionLabels"]) if (value[name]) value[name] = Object.fromEntries(Object.entries(value[name]).map(([id, code]) => [componentMapping[id] ?? id, code]));
359
- return [componentMapping[slotId] ?? idMapping[slotId] ?? slotId, value];
360
- }));
361
- if (Object.keys(componentMapping).length) {
362
- updatedData.displayConditions = remap(updatedData.displayConditions);
363
- updatedData.disabledConditions = remap(updatedData.disabledConditions);
364
- updatedData.validations = remap(updatedData.validations);
365
- updatedData.prefills = remap(updatedData.prefills);
366
- }
367
- this.updateExpressionsInItemData(updatedData, idMapping);
368
- return updatedData;
369
- }
370
- /**
371
- * Update expressions in item data that reference the old ids
372
- */
373
- updateExpressionsInItemData(itemData, idMapping) {
374
- const visitBounds = (value) => {
375
- if (!value || typeof value !== "object") return;
376
- for (const [key, child] of Object.entries(value)) if (key === "valueExpression" && child && typeof child === "object") this.updateExpressionReferences(child, idMapping);
377
- else visitBounds(child);
378
- };
379
- visitBounds(itemData.config);
380
- if (itemData.displayConditions?.root) this.updateExpressionReferences(itemData.displayConditions.root, idMapping);
381
- if (itemData.displayConditions?.components) Object.values(itemData.displayConditions.components).forEach((expr) => {
382
- if (expr) this.updateExpressionReferences(expr, idMapping);
383
- });
384
- if (itemData.disabledConditions?.components) Object.values(itemData.disabledConditions.components).forEach((expr) => {
385
- if (expr) this.updateExpressionReferences(expr, idMapping);
386
- });
387
- if (itemData.validations) Object.values(itemData.validations).forEach((expr) => {
388
- if (expr) this.updateExpressionReferences(expr, idMapping);
389
- });
390
- if (itemData.prefills) itemData.prefills.forEach((prefill) => {
391
- if (prefill.when) this.updateExpressionReferences(prefill.when, idMapping);
392
- if (prefill.source.type === "expression") this.updateExpressionReferences(prefill.source.expression, idMapping);
393
- });
394
- }
395
- /**
396
- * Update references in a single expression (recursive)
397
- */
398
- updateExpressionReferences(expression, idMapping) {
399
- if (!expression || typeof expression !== "object") return;
400
- const expr = expression;
401
- if (expr.type === ExpressionType.ResponseVariable && expr.variableRef && typeof expr.variableRef === "object") {
402
- const reference = expr.variableRef;
403
- if (reference.slotId && Object.prototype.hasOwnProperty.call(idMapping, reference.slotId)) reference.slotId = idMapping[reference.slotId];
404
- }
405
- if (Array.isArray(expr.data)) expr.data.forEach((arg) => {
406
- if (arg && typeof arg === "object" && arg !== null && "str" in arg) {
407
- const argObj = arg;
408
- if (typeof argObj.str === "string" && Object.prototype.hasOwnProperty.call(idMapping, argObj.str)) argObj.str = idMapping[argObj.str];
409
- this.updateExpressionReferences(arg, idMapping);
410
- }
411
- });
412
- Object.keys(expr).forEach((key) => {
413
- const val = expr[key];
414
- if (val && typeof val === "object") this.updateExpressionReferences(val, idMapping);
415
- });
416
- }
417
- /**
418
- * Update translation keys for the pasted item
419
- */
420
- updateTranslations(translations, idMapping, componentMapping) {
421
- const newTranslations = Object.create(null);
422
- Object.keys(translations).forEach((itemId) => {
423
- const itemTranslations = translations[itemId];
424
- const newItemId = idMapping[itemId];
425
- newTranslations[newItemId] = Object.fromEntries(Object.entries(itemTranslations).map(([locale, content]) => [locale, Object.fromEntries(Object.entries(content).map(([key, value]) => [key.split(".").map((part) => componentMapping[part] ?? part).join("."), value]))]));
426
- });
427
- return newTranslations;
428
- }
429
- /**
430
- * Validate clipboard data format
431
- */
432
- static isValidClipboardData(data) {
433
- if (typeof data !== "object" || data === null || data === void 0) return false;
434
- const clipboardData = data;
435
- if (clipboardData.rootItemId === void 0 || typeof clipboardData.rootItemId !== "string" || !Array.isArray(clipboardData.items) || clipboardData.items.length === 0 || typeof clipboardData.translations !== "object" || clipboardData.translations === null || Array.isArray(clipboardData.translations)) return false;
436
- return clipboardData.type === "survey-item" && clipboardData.version === "1.0.0" && clipboardData.items.every((item) => typeof item === "object" && item !== null && typeof item.itemId === "string" && item.itemId.length > 0 && typeof item.itemData === "object" && item.itemData !== null && !Array.isArray(item.itemData) && typeof item.itemData.itemType === "string") && clipboardData.items.some((item) => item.itemId === clipboardData.rootItemId);
437
- }
438
- };
439
- //#endregion
1
+ import { $ as inspectAnnotated, G as stringRecord, U as ownValue, W as setOwnValue, X as contentOwnerIds, it as transformAnnotated, l as readLocalizationContent, mt as transformExpression, nt as remapAnnotated, ot as expressionSchema, q as annotatedReferences, tt as referenceId } from "./localization-1lzNpyFX.mjs";
2
+ import { D as DefinitionError, F as allocateResponseVariableName, I as isValidResponseVariableName, O as Survey, Z as generateId, i as localizedExportSlots, k as checkDocumentBudget, n as getDefaultProjectionDiagnostics, r as getDefaultProjectionIssues, s as projectSlot, tt as structuredCloneMethod, w as SurveyStateGraph } from "./projection-DrLPFroE.mjs";
3
+ import { t as answerTypeReservationsSchema } from "./answer-types-Dtm6MqZQ.mjs";
4
+ import { SURVEY_EDITOR_ITEM_COLORS } from "./editor/colors.mjs";
5
+ import { z } from "zod";
440
6
  //#region src/editor/undo-redo.ts
441
7
  const CommitSource = {
442
8
  USER: "user",
@@ -470,31 +36,10 @@ function cloneAssets(assets) {
470
36
  return structuredCloneMethod(assets);
471
37
  }
472
38
  function normalizeHistoryEntry(entry) {
473
- if ("kind" in entry) {
474
- if (entry.kind === "survey-snapshot") return {
475
- kind: "survey-snapshot",
476
- survey: structuredCloneMethod(entry.survey),
477
- id: entry.id ?? generateId(),
478
- timestamp: entry.timestamp,
479
- meta: entry.meta,
480
- memorySize: entry.memorySize
481
- };
482
- return {
483
- kind: "asset-change",
484
- changes: structuredCloneMethod(entry.changes),
485
- id: entry.id ?? generateId(),
486
- timestamp: entry.timestamp,
487
- meta: entry.meta,
488
- memorySize: entry.memorySize
489
- };
490
- }
39
+ if (entry.kind !== "survey-snapshot" && entry.kind !== "asset-change") throw new Error("Unsupported history entry kind");
491
40
  return {
492
- kind: "survey-snapshot",
493
- survey: structuredCloneMethod(entry.survey),
494
- id: entry.id ?? generateId(),
495
- timestamp: entry.timestamp,
496
- meta: entry.meta,
497
- memorySize: entry.memorySize
41
+ ...structuredCloneMethod(entry),
42
+ id: entry.id ?? generateId()
498
43
  };
499
44
  }
500
45
  var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
@@ -519,7 +64,8 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
519
64
  saveEntry(entry) {
520
65
  const payload = entry.kind === "survey-snapshot" ? {
521
66
  kind: entry.kind,
522
- survey: entry.survey
67
+ survey: entry.survey,
68
+ changes: entry.changes
523
69
  } : {
524
70
  kind: entry.kind,
525
71
  changes: entry.changes
@@ -586,7 +132,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
586
132
  }
587
133
  applyAssetPatch(assets, patch) {
588
134
  const nextAssets = assets;
589
- if (patch.next) nextAssets[patch.assetId] = structuredCloneMethod(patch.next);
135
+ if (patch.next) setOwnValue(nextAssets, patch.assetId, structuredCloneMethod(patch.next));
590
136
  else delete nextAssets[patch.assetId];
591
137
  return nextAssets;
592
138
  }
@@ -595,8 +141,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
595
141
  let assets = cloneAssets(this._initialAssets) ?? {};
596
142
  for (let i = 1; i <= index; i++) {
597
143
  const entry = this.history[i];
598
- if (entry.kind !== "asset-change") continue;
599
- for (const patch of entry.changes) assets = this.applyAssetPatch(assets, patch);
144
+ for (const patch of entry.changes ?? []) assets = this.applyAssetPatch(assets, patch);
600
145
  }
601
146
  return Object.keys(assets).length > 0 ? assets : void 0;
602
147
  }
@@ -606,8 +151,13 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
606
151
  findEntryIndex(id) {
607
152
  return this.history.findIndex((entry) => entry.id === id);
608
153
  }
609
- commit(survey, meta) {
610
- this.saveSnapshot(survey, meta);
154
+ commit(survey, meta, changes) {
155
+ this.saveEntry({
156
+ kind: "survey-snapshot",
157
+ survey,
158
+ meta,
159
+ ...changes?.length ? { changes } : {}
160
+ });
611
161
  }
612
162
  commitAssetChange(changes, meta) {
613
163
  if (changes.length === 0) return;
@@ -730,6 +280,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
730
280
  if (entry.kind === "survey-snapshot") return {
731
281
  kind: "survey-snapshot",
732
282
  survey: entry.survey,
283
+ changes: entry.changes,
733
284
  id: entry.id ?? generateId(),
734
285
  timestamp: entry.timestamp,
735
286
  meta: entry.meta,
@@ -771,620 +322,1336 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
771
322
  }
772
323
  };
773
324
  //#endregion
774
- //#region src/editor/survey-editor.ts
775
- function stripAssetsFromSurveySnapshot(survey) {
776
- const { assets: _assets, ...snapshot } = survey;
777
- return snapshot;
325
+ //#region src/editor/response-naming.ts
326
+ function readPath(input, path) {
327
+ let value = input;
328
+ for (const key of path) if (Array.isArray(value) && typeof key === "number") value = value[key];
329
+ else if (value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key)) value = Reflect.get(value, key);
330
+ else throw new Error("Response naming declaration does not match its config");
331
+ return value;
778
332
  }
779
- function mergeSurveySnapshotWithAssets(snapshot, assets) {
780
- const merged = structuredCloneMethod(snapshot);
781
- if (assets && Object.keys(assets).length > 0) merged.assets = structuredCloneMethod(assets);
782
- else delete merged.assets;
783
- return merged;
333
+ function writePath(input, path, value) {
334
+ if (!path.length) throw new Error("Invalid response naming path");
335
+ const parent = readPath(input, path.slice(0, -1));
336
+ if (!parent || typeof parent !== "object") throw new Error("Response naming declaration does not match its config");
337
+ const key = path.at(-1);
338
+ if (Array.isArray(parent) && typeof key === "number") parent[key] = value;
339
+ else setOwnValue(parent, String(key), value);
784
340
  }
785
- var SurveyEditor = class SurveyEditor {
786
- lastResponseNameChanges = [];
787
- renameResponseVariables(edits) {
788
- const preview = previewResponseNaming(this._survey, edits);
789
- if (!preview.changes.length) return [];
790
- const next = Survey.fromJson(preview.survey, this._survey.getPluginRegistry());
791
- this._survey = next;
792
- for (const id of new Set(preview.changes.map((change) => change.itemId))) this.touchItemModifiedMetadata(id, this.createEditorTimestamp());
793
- this.lastResponseNameChanges = preview.changes;
794
- this.markAsModified();
795
- return preview.changes;
796
- }
797
- updateResponseSettings(naming, settings) {
798
- const preview = previewResponseSettings(this._survey, naming, settings);
799
- this._survey = Survey.fromJson(preview.survey, this._survey.getPluginRegistry());
800
- const changedItems = /* @__PURE__ */ new Set([...preview.changes.map((change) => change.itemId), ...settings.map((edit) => this._survey.getResponseSlotRegistry().get(edit.slotId).itemId)]);
801
- for (const itemId of changedItems) this.touchItemModifiedMetadata(itemId, this.createEditorTimestamp());
802
- this.lastResponseNameChanges = preview.changes;
803
- this.markAsModified();
804
- return preview.changes;
805
- }
806
- _survey;
807
- _undoRedo;
808
- _hasUncommittedChanges = false;
809
- mutationEpoch = 0;
810
- pendingAssets = /* @__PURE__ */ new Map();
811
- _pluginRegistry;
812
- constructor(survey, config = {}, meta) {
813
- this._survey = survey;
814
- const { pluginRegistry, ...undoRedoConfig } = config;
815
- this._pluginRegistry = pluginRegistry ?? survey.getPluginRegistry();
816
- const serialized = survey.serialize();
817
- this._undoRedo = new SurveyEditorUndoRedo(stripAssetsFromSurveySnapshot(serialized), undoRedoConfig, meta, serialized.assets);
818
- }
819
- /**
820
- * An isolated field edit owns only its field value (held by the caller) and
821
- * affected item metadata. Any intervening core action invalidates this handle.
822
- * The caller must finish pre-existing work before opening a handle.
823
- */
824
- beginFieldEdit(itemId) {
825
- if (this.hasUncommittedChanges) throw new Error("Finish pending changes before editing a field");
826
- let expectedEpoch = this.mutationEpoch;
827
- const history = this._undoRedo;
828
- const index = history.getCurrentIndex();
829
- const locales = this._survey.translations.locales;
830
- const metadata = itemId ? structuredCloneMethod(this._survey.surveyItems.get(itemId)?.rawItem.editorMetadata) : void 0;
831
- const owns = () => this.mutationEpoch === expectedEpoch && this._undoRedo === history && history.getCurrentIndex() === index;
832
- return {
833
- run: (write) => {
834
- if (!owns()) throw new Error("Field edit was superseded by another action");
835
- const result = write();
836
- expectedEpoch = this.mutationEpoch;
837
- return result;
838
- },
839
- reconcile: (restoreValue) => {
840
- if (!owns()) return false;
841
- restoreValue?.();
842
- if (itemId) {
843
- const item = this._survey.surveyItems.get(itemId);
844
- if (!item) return false;
845
- this._survey.surveyItems.set(itemId, this._survey.createItemFromRaw({
846
- ...item.rawItem,
847
- editorMetadata: structuredCloneMethod(metadata)
848
- }));
849
- }
850
- for (const locale of this._survey.translations.locales) if (!locales.includes(locale)) {
851
- const data = this._survey.translations.serialize()?.[locale];
852
- if (data && !data.surveyCardContent && !data.navigationContent && !data.validationMessages && Object.keys(data.itemTranslations ?? {}).length === 0) this._survey.translations.removeLocale(locale);
853
- }
854
- this._hasUncommittedChanges = false;
855
- this.mutationEpoch++;
856
- expectedEpoch = this.mutationEpoch;
857
- return true;
858
- }
859
- };
860
- }
861
- get hasCommittedChanges() {
862
- return this._undoRedo.getCurrentIndex() > 0;
863
- }
864
- /** Returns an immutable copy of the current survey state. */
865
- get survey() {
866
- const serialized = structuredCloneMethod(this._survey.serialize());
867
- const registry = this._pluginRegistry ?? this._survey.getPluginRegistry();
868
- return Survey.fromJson(serialized, registry);
869
- }
870
- get hasUncommittedChanges() {
871
- return this._hasUncommittedChanges || this.pendingAssets.size > 0;
872
- }
873
- get undoRedo() {
874
- return this._undoRedo;
875
- }
876
- commit(meta) {
877
- if (!this.hasUncommittedChanges) return;
878
- this.mutationEpoch++;
879
- if (this._hasUncommittedChanges) this._undoRedo.commit(stripAssetsFromSurveySnapshot(this._survey.serialize()), meta);
880
- if (this.pendingAssets.size) {
881
- this._undoRedo.commitAssetChange([...this.pendingAssets].map(([assetId, patch]) => ({
882
- assetId,
883
- ...patch
884
- })), meta);
885
- this.pendingAssets.clear();
341
+ const assertName = (name) => {
342
+ if (!isValidResponseVariableName(name)) throw new Error(`Invalid variable name "${name}". Use lowercase snake_case, starting with a letter, at most 32 characters.`);
343
+ };
344
+ /** Detached, validated preview; applying it uses the editor's ordinary mutation/history path. */
345
+ function previewNaming(survey, edits) {
346
+ const registry = survey.slots;
347
+ const raw = structuredClone(survey.serialize());
348
+ const config = (slot) => readPath(raw, [...survey.elements.get(slot.elementId).path, "config"]);
349
+ for (const edit of edits) if ("slotId" in edit) {
350
+ const slot = registry.get(edit.slotId);
351
+ if (!slot) throw new Error(`Unknown response variable "${edit.slotId}"`);
352
+ if (slot.naming?.kind === "matrix") throw new Error("Matrix cell names are derived; edit the matrix prefix or row/column coding names");
353
+ if (!slot.naming) throw new Error(`The input owning "${edit.slotId}" does not expose a naming mutation`);
354
+ assertName(edit.variableName);
355
+ writePath(config(slot), slot.naming.path, edit.variableName);
356
+ } else {
357
+ const slots = registry.definitions.filter((slot) => slot.elementId === edit.elementId && slot.naming?.kind === "matrix");
358
+ if (!slots.length) throw new Error(`Unknown matrix element "${edit.elementId}"`);
359
+ for (const [ids, kind] of [[edit.rows, "row"], [edit.columns, "column"]]) for (const [id, name] of Object.entries(ids ?? {})) {
360
+ assertName(name);
361
+ const slot = slots.find((slot) => kind === "row" ? slot.rowId === id : slot.columnId === id);
362
+ const naming = slot?.naming;
363
+ const path = naming?.kind === "matrix" ? kind === "row" ? naming.rowPath : naming.columnPath : void 0;
364
+ if (!slot || !path) throw new Error(`Unknown matrix ${kind} "${id}"`);
365
+ writePath(config(slot), path, name);
366
+ }
367
+ if (edit.prefix !== void 0) {
368
+ assertName(edit.prefix);
369
+ const slot = slots[0], naming = slot.naming;
370
+ if (naming?.kind === "matrix") writePath(config(slot), naming.prefixPath, edit.prefix);
886
371
  }
887
- this._hasUncommittedChanges = false;
888
- }
889
- restoreHistoryState(snapshot, assets) {
890
- this.mutationEpoch++;
891
- this.lastResponseNameChanges = [];
892
- this.pendingAssets.clear();
893
- this._survey = Survey.fromJson(mergeSurveySnapshotWithAssets(snapshot, assets), this._pluginRegistry);
894
372
  }
895
- commitIfNeeded() {
896
- if (this.hasUncommittedChanges) this.commit({
897
- label: "Latest content changes",
898
- source: CommitSource.SYSTEM
373
+ const next = Survey.parse(raw, survey.registry, { admission: "draft" });
374
+ const nextRegistry = next.slots;
375
+ const beforeIssues = new Set(registry.diagnostics.map((issue) => JSON.stringify(issue)));
376
+ const issues = nextRegistry.diagnostics.filter((issue) => !beforeIssues.has(JSON.stringify(issue)));
377
+ if (issues.length) throw new Error(issues.map((issue) => issue.message).join("\n"));
378
+ const changes = nextRegistry.definitions.flatMap((slot) => {
379
+ const before = registry.get(slot.slotId).variableName;
380
+ return before === slot.variableName ? [] : [{
381
+ slotId: slot.slotId,
382
+ itemId: slot.itemId,
383
+ before,
384
+ after: slot.variableName
385
+ }];
386
+ });
387
+ return {
388
+ survey: next.serialize(),
389
+ changes
390
+ };
391
+ }
392
+ function assertProjectionChanges(before, after) {
393
+ const existing = new Set(getDefaultProjectionIssues(before));
394
+ const introduced = getDefaultProjectionIssues(after).filter((issue) => !existing.has(issue));
395
+ if (introduced.length) throw new Error(introduced.join("\n"));
396
+ }
397
+ function previewResponseNaming(survey, edits) {
398
+ const preview = previewNaming(survey, edits);
399
+ assertProjectionChanges(survey, Survey.parse(preview.survey, survey.registry, { admission: "draft" }));
400
+ return preview;
401
+ }
402
+ /** Naming, categorical codes, and formats share one detached, atomic preview. */
403
+ function previewResponseSettings(survey, naming, settings) {
404
+ const preview = previewNaming(survey, naming);
405
+ const raw = preview.survey;
406
+ const registry = survey.slots;
407
+ const codeWrites = /* @__PURE__ */ new Map();
408
+ for (const edit of settings) {
409
+ const slot = registry.get(edit.slotId);
410
+ if (!slot) throw new Error(`Unknown response variable "${edit.slotId}"`);
411
+ const path = survey.elements.get(slot.elementId).path;
412
+ const element = survey.registry.elementSchema.parse(readPath(raw, path));
413
+ for (const [id, code] of Object.entries(edit.optionCodes ?? {})) {
414
+ const path = ownValue(slot.optionCodePaths, id);
415
+ if (!path) throw new Error(`Option "${id}" does not expose an export-code mutation`);
416
+ if (!code.trim()) throw new Error("Option export codes cannot be empty");
417
+ const target = JSON.stringify([slot.elementId, path]);
418
+ const previous = codeWrites.get(target);
419
+ if (previous && previous.code !== code) throw new Error(`Conflicting export codes for shared option "${id}" on variables "${registry.get(previous.slotId).variableName}" and "${slot.variableName}". Supply one code for this shared option.`);
420
+ codeWrites.set(target, {
421
+ code,
422
+ slotId: slot.slotId
423
+ });
424
+ writePath(element.config, path, code);
425
+ }
426
+ if (edit.exportSettings) element.exportSettings = {
427
+ ...element.exportSettings,
428
+ [slot.slotId]: structuredClone(edit.exportSettings)
429
+ };
430
+ writePath(raw, path, element);
431
+ }
432
+ const next = Survey.parse(raw, survey.registry, { admission: "draft" });
433
+ for (const slot of localizedExportSlots(next).filter((slot) => settings.some((edit) => edit.slotId === slot.slotId))) projectSlot(slot, {
434
+ mode: "default",
435
+ ...slot.exportSettings
436
+ });
437
+ assertProjectionChanges(survey, next);
438
+ return {
439
+ ...preview,
440
+ survey: next.serialize()
441
+ };
442
+ }
443
+ /** Allocate names using declared mutation locations, including every nested element. */
444
+ function allocateCopiedResponseNames(schema, input, registry, occupied) {
445
+ const used = new Set(occupied);
446
+ return transformAnnotated(schema, input, ({ annotation, value }) => {
447
+ if (annotation.kind !== "owner" || !value || typeof value !== "object" || !("kind" in value) || value.kind !== "element") return value;
448
+ const node = registry.elementSchema.parse(value);
449
+ const definition = registry.get(node.elementType);
450
+ const paths = /* @__PURE__ */ new Map();
451
+ for (const slot of definition.inspect(node).slots) {
452
+ const naming = slot.naming;
453
+ if (!naming) {
454
+ if (used.has(slot.variableName)) throw new Error(`Cannot copy variable '${slot.variableName}': element '${node.elementType}' must declare a naming location to resolve the duplicate name`);
455
+ used.add(slot.variableName);
456
+ continue;
457
+ }
458
+ const path = ["config", ...naming.kind === "variable" ? naming.path : naming.prefixPath];
459
+ const base = readPath(node, path);
460
+ if (typeof base !== "string") throw new Error("Invalid response naming location");
461
+ const key = JSON.stringify(path);
462
+ const group = paths.get(key) ?? {
463
+ path,
464
+ base,
465
+ slots: /* @__PURE__ */ new Set(),
466
+ maxLength: 32
467
+ };
468
+ group.slots.add(slot.slotId);
469
+ group.maxLength = Math.min(group.maxLength, 32 - (slot.variableName.length - base.length));
470
+ paths.set(key, group);
471
+ }
472
+ for (const { path, base, slots, maxLength } of paths.values()) {
473
+ let candidate = base, attempts = 0;
474
+ const names = () => definition.inspect(node).slots.filter((slot) => slots.has(slot.slotId)).map((slot) => slot.variableName);
475
+ while (names().some((name) => used.has(name))) {
476
+ if (++attempts > used.size + 1) throw new Error(`Element '${node.elementType}' does not honor its declared naming location`);
477
+ const before = names();
478
+ candidate = allocateResponseVariableName(candidate, /* @__PURE__ */ new Set([candidate]), maxLength);
479
+ writePath(node, path, candidate);
480
+ if (before.every((name, index) => name === names()[index])) throw new Error(`Element '${node.elementType}' does not honor its declared naming location`);
481
+ }
482
+ names().forEach((name) => used.add(name));
483
+ }
484
+ return node;
485
+ });
486
+ }
487
+ //#endregion
488
+ //#region src/editor/item-copy-paste.ts
489
+ /** Only the current format is accepted. Parsing is repeated at the paste boundary. */
490
+ function createItemClipboardSchema(registry) {
491
+ const shape = registry.surveySchema.shape;
492
+ return z.strictObject({
493
+ type: z.literal("survey-item"),
494
+ version: z.literal(2),
495
+ rootItemId: referenceId("item"),
496
+ surveyItems: shape.surveyItems,
497
+ translations: shape.translations,
498
+ assets: shape.assets
499
+ }).superRefine((payload, ctx) => {
500
+ const items = new Map(payload.surveyItems.map((item) => [item.id, item]));
501
+ const visited = /* @__PURE__ */ new Set();
502
+ const visit = (id) => {
503
+ if (visited.has(id)) {
504
+ ctx.addIssue({
505
+ code: "custom",
506
+ message: "Clipboard flow must be a tree"
507
+ });
508
+ return;
509
+ }
510
+ visited.add(id);
511
+ const item = items.get(id);
512
+ if (!item) {
513
+ ctx.addIssue({
514
+ code: "custom",
515
+ message: "Clipboard flow cannot reference an external child"
516
+ });
517
+ return;
518
+ }
519
+ if (item.itemType === "group") {
520
+ if (item.config.isRoot) ctx.addIssue({
521
+ code: "custom",
522
+ message: "A root group cannot be pasted"
523
+ });
524
+ item.config.items.forEach(visit);
525
+ }
526
+ };
527
+ visit(payload.rootItemId);
528
+ if (items.size !== payload.surveyItems.length || visited.size !== items.size) ctx.addIssue({
529
+ code: "custom",
530
+ message: "Clipboard contains duplicate or disconnected flow items"
531
+ });
532
+ });
533
+ }
534
+ function copySurveyItem(survey, itemId) {
535
+ if (!survey.getItem(itemId)) throw new Error(`Unknown item '${itemId}'`);
536
+ const items = survey.data.surveyItems.filter((item) => survey.isDescendantOf(item.id, itemId));
537
+ const itemIds = new Set(items.map((item) => item.id));
538
+ const owners = new Set([...survey.owners.values()].filter((owner) => itemIds.has(owner.itemId)).map((owner) => owner.id));
539
+ const payload = {
540
+ type: "survey-item",
541
+ version: 2,
542
+ rootItemId: itemId,
543
+ surveyItems: structuredClone(items).map((item) => item.itemType === "group" && item.config.isRoot ? {
544
+ ...item,
545
+ config: {
546
+ ...item.config,
547
+ isRoot: false
548
+ }
549
+ } : item),
550
+ translations: Object.fromEntries(Object.entries(survey.data.translations ?? {}).flatMap(([locale, translation]) => {
551
+ const content = Object.fromEntries(Object.entries(translation.content).filter(([id, roles]) => owners.has(id) && Object.keys(roles).length > 0));
552
+ return Object.keys(content).length ? [[locale, { content }]] : [];
553
+ }))
554
+ };
555
+ const schema = createItemClipboardSchema(survey.registry);
556
+ const assetIds = new Set(inspectAnnotated(schema, payload).flatMap(({ annotation, value }) => annotation.kind === "reference" && annotation.target === "asset" && typeof value === "string" ? [value] : []));
557
+ payload.assets = Object.fromEntries([...assetIds].map((id) => [id, ownValue(survey.data.assets, id)]));
558
+ return schema.parse(payload);
559
+ }
560
+ /** Prepare a detached payload; the editor publishes it only after whole-document validation. */
561
+ function prepareItemPaste(survey, input) {
562
+ checkDocumentBudget(input);
563
+ const schema = createItemClipboardSchema(survey.registry);
564
+ const payload = schema.parse(input);
565
+ const ids = /* @__PURE__ */ new Map();
566
+ for (const { annotation, value } of inspectAnnotated(schema, payload)) {
567
+ if (annotation.kind !== "identity" || typeof value !== "string") continue;
568
+ if (ids.has(value)) throw new Error(`Duplicate clipboard identity '${value}'`);
569
+ const reuse = annotation.identity === "asset" && JSON.stringify(ownValue(survey.data.assets, value)) === JSON.stringify(ownValue(payload.assets, value));
570
+ ids.set(value, reuse ? value : generateId());
571
+ }
572
+ return allocateCopiedResponseNames(schema, remapAnnotated(schema, payload, ids), survey.registry, new Set(survey.slots.definitions.map((slot) => slot.variableName)));
573
+ }
574
+ //#endregion
575
+ //#region src/editor/session-schema.ts
576
+ /** The saved editor session is separate from both survey definitions and participant resume state. */
577
+ function createEditorSessionSchema(registry) {
578
+ const survey = registry.surveySchema;
579
+ const asset = registry.surveySchema.shape.assets.unwrap().valueType;
580
+ const assets = stringRecord(z.string().min(1), asset);
581
+ const patch = z.strictObject({
582
+ assetId: z.string().min(1),
583
+ prev: asset.optional(),
584
+ next: asset.optional()
585
+ });
586
+ const entry = {
587
+ id: z.string().min(1).optional(),
588
+ timestamp: z.number().finite(),
589
+ memorySize: z.number().nonnegative().finite(),
590
+ meta: z.strictObject({
591
+ label: z.string(),
592
+ source: z.enum(CommitSource).optional()
593
+ })
594
+ };
595
+ return z.strictObject({
596
+ version: z.literal(2),
597
+ survey,
598
+ hasUncommittedChanges: z.boolean(),
599
+ answerTypes: answerTypeReservationsSchema,
600
+ undoRedo: z.strictObject({
601
+ history: z.array(z.discriminatedUnion("kind", [z.strictObject({
602
+ ...entry,
603
+ kind: z.literal("survey-snapshot"),
604
+ survey,
605
+ changes: z.array(patch).optional()
606
+ }), z.strictObject({
607
+ ...entry,
608
+ kind: z.literal("asset-change"),
609
+ changes: z.array(patch)
610
+ })])).min(1),
611
+ currentIndex: z.number().int().nonnegative(),
612
+ initialAssets: assets.optional(),
613
+ config: z.strictObject({
614
+ maxTotalMemoryMB: z.number().positive().finite(),
615
+ minHistorySize: z.number().int().nonnegative(),
616
+ maxHistorySize: z.number().int().positive()
617
+ })
618
+ }).superRefine((value, context) => {
619
+ if (value.currentIndex >= value.history.length) context.addIssue({
620
+ code: "custom",
621
+ path: ["currentIndex"],
622
+ message: "History index is outside the stored history"
623
+ });
624
+ if (value.history[0]?.kind !== "survey-snapshot") context.addIssue({
625
+ code: "custom",
626
+ path: ["history", 0],
627
+ message: "History must start with a survey snapshot"
628
+ });
629
+ })
630
+ });
631
+ }
632
+ //#endregion
633
+ //#region src/editor/survey-editor.ts
634
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
635
+ function at(input, path) {
636
+ let value = input;
637
+ for (const part of path) if (Array.isArray(value) && typeof part === "number") value = value[part];
638
+ else if (record(value)) value = ownValue(value, String(part));
639
+ else throw new Error(`Invalid derived location ${JSON.stringify(path)}`);
640
+ return value;
641
+ }
642
+ function arrayAt(input, path) {
643
+ const value = at(input, path);
644
+ if (!Array.isArray(value)) throw new Error("Expected array at derived location");
645
+ return value;
646
+ }
647
+ function setAt(input, path, value) {
648
+ const parent = at(input, path.slice(0, -1)), part = path.at(-1);
649
+ if (Array.isArray(parent) && typeof part === "number") parent[part] = value;
650
+ else if (record(parent) && typeof part === "string") setOwnValue(parent, part, value);
651
+ else throw new Error("Invalid mutation location");
652
+ }
653
+ const inside = (ancestor, path) => ancestor.length <= path.length && ancestor.every((part, i) => path[i] === part);
654
+ function stripAssets(survey) {
655
+ const { assets: _assets, ...snapshot } = survey;
656
+ return snapshot;
657
+ }
658
+ /** Compare persisted JSON semantics without depending on object property insertion order. */
659
+ function equal(left, right) {
660
+ if (left === right) return true;
661
+ if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => equal(value, right[index]));
662
+ if (!record(left) || !record(right)) return false;
663
+ const keys = Object.keys(left).filter((key) => left[key] !== void 0);
664
+ return keys.length === Object.keys(right).filter((key) => right[key] !== void 0).length && keys.every((key) => Object.hasOwn(right, key) && equal(left[key], right[key]));
665
+ }
666
+ /** Element mutations may edit their owned wording, never another owner's content or survey UI. */
667
+ function assertElementTranslationScope(before, after, owners) {
668
+ const surfaces = (translation) => Object.fromEntries(Object.entries(translation ?? {}).filter(([key]) => key !== "content"));
669
+ for (const locale of /* @__PURE__ */ new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) {
670
+ const previous = ownValue(before, locale), next = ownValue(after, locale);
671
+ if (!previous || !next || !equal(surfaces(previous), surfaces(next))) throw new Error(`Element edits cannot change survey-level translations or add/remove locale '${locale}'`);
672
+ for (const owner of /* @__PURE__ */ new Set([...Object.keys(previous?.content ?? {}), ...Object.keys(next?.content ?? {})])) if (!owners.has(owner) && !equal(ownValue(previous?.content, owner), ownValue(next?.content, owner))) throw new Error(`Element edits cannot change content owned by '${owner}' in locale '${locale}'`);
673
+ }
674
+ }
675
+ /** Derive modification ownership from the committed revision, including nested localized content. */
676
+ function timestampEditedItems(before, after) {
677
+ const previous = new Map(before.surveyItems.map((item) => [item.id, item]));
678
+ const positions = (items) => new Map(items.flatMap((item) => item.itemType === "group" ? item.config.items.map((id, index) => [id, [item.id, index]]) : []));
679
+ const previousPositions = positions(before.surveyItems);
680
+ const nextPositions = positions(after.data.surveyItems);
681
+ const changed = /* @__PURE__ */ new Set();
682
+ for (const item of after.data.surveyItems) if (!equal(previous.get(item.id), item) || !equal(previousPositions.get(item.id), nextPositions.get(item.id))) changed.add(item.id);
683
+ for (const locale of /* @__PURE__ */ new Set([...Object.keys(before.translations ?? {}), ...after.locales])) {
684
+ const oldContent = ownValue(before.translations, locale)?.content ?? {};
685
+ const newContent = ownValue(after.data.translations, locale)?.content ?? {};
686
+ for (const id of /* @__PURE__ */ new Set([...Object.keys(oldContent), ...Object.keys(newContent)])) {
687
+ if (equal(ownValue(oldContent, id), ownValue(newContent, id))) continue;
688
+ const owner = after.owners.get(id);
689
+ if (owner) changed.add(owner.itemId);
690
+ }
691
+ }
692
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
693
+ return after.withItemEditorMetadata(new Map([...changed].map((id) => {
694
+ const original = previous.get(id);
695
+ return [id, {
696
+ createdAt: original ? original.editorMetadata?.createdAt ?? timestamp : timestamp,
697
+ updatedAt: timestamp
698
+ }];
699
+ })));
700
+ }
701
+ /** A surviving answer identity cannot acquire a different wire value type. */
702
+ function getAnswerTypeChangeDiagnostics(before, after) {
703
+ if (before.slots === after.slots) return [];
704
+ return answerTypeDiagnostics(after, (id) => before.slots.get(id)?.valueType);
705
+ }
706
+ function answerTypeDiagnostics(after, previousType) {
707
+ return after.slots.definitions.flatMap((slot) => {
708
+ const previous = previousType(slot.slotId);
709
+ return previous && previous !== slot.valueType ? [{
710
+ code: "answer-type-change",
711
+ severity: "error",
712
+ path: after.identities.get(slot.slotId)?.path ?? after.elements.get(slot.elementId).path,
713
+ message: `Answer '${slot.variableName}' (${slot.slotId}) cannot change from ${previous} to ${slot.valueType} while keeping its ID. Allocate a new answer ID and explicitly repair its references, prefills and export settings in the same transaction.`
714
+ }] : [];
715
+ });
716
+ }
717
+ function assertStableAnswerTypes(before, after) {
718
+ const diagnostics = getAnswerTypeChangeDiagnostics(before, after);
719
+ if (diagnostics.length) throw new DefinitionError(diagnostics);
720
+ }
721
+ /** All human and assistant operations publish through this transaction/history owner. */
722
+ var SurveyEditor = class SurveyEditor {
723
+ current;
724
+ answerTypes = /* @__PURE__ */ new Map();
725
+ rememberedSlots;
726
+ _history;
727
+ listeners = /* @__PURE__ */ new Set();
728
+ mutationEpoch = 0;
729
+ fieldWriteDepth = 0;
730
+ actionDepth = 0;
731
+ actionMeta;
732
+ pendingSurvey = false;
733
+ pendingAssets = [];
734
+ pendingMeta;
735
+ constructor(survey, options = {}, meta) {
736
+ const { answerTypes, ...config } = options;
737
+ this.answerTypes = new Map(answerTypeReservationsSchema.parse(answerTypes ?? []));
738
+ const diagnostics = answerTypeDiagnostics(survey, (id) => this.answerTypes.get(id));
739
+ if (diagnostics.length) throw new DefinitionError(diagnostics);
740
+ this.current = survey;
741
+ this.rememberAnswerTypes(survey);
742
+ const serialized = survey.serialize();
743
+ this._history = new SurveyEditorUndoRedo(stripAssets(serialized), config, meta, serialized.assets);
744
+ }
745
+ get survey() {
746
+ return this.current;
747
+ }
748
+ get history() {
749
+ return this._history;
750
+ }
751
+ getAnswerTypes() {
752
+ return [...this.answerTypes];
753
+ }
754
+ get hasUncommittedChanges() {
755
+ return this.pendingSurvey || this.pendingAssets.length > 0;
756
+ }
757
+ get hasCommittedChanges() {
758
+ return this.history.getCurrentIndex() > 0;
759
+ }
760
+ subscribe(listener) {
761
+ this.listeners.add(listener);
762
+ return () => {
763
+ this.listeners.delete(listener);
764
+ };
765
+ }
766
+ notify() {
767
+ this.listeners.forEach((listener) => listener());
768
+ }
769
+ rememberAnswerTypes(survey) {
770
+ if (this.rememberedSlots === survey.slots) return;
771
+ for (const slot of survey.slots.definitions) this.answerTypes.set(slot.slotId, slot.valueType);
772
+ this.rememberedSlots = survey.slots;
773
+ }
774
+ publish(next) {
775
+ this.current = next;
776
+ if (this.actionDepth) return;
777
+ this.rememberAnswerTypes(next);
778
+ this.mutationEpoch++;
779
+ this.notify();
780
+ }
781
+ trackPending(next, meta) {
782
+ this.pendingSurvey = !equal(stripAssets(next.data), this.history.getCurrentState());
783
+ const previous = this.history.getCurrentAssets() ?? {}, assets = next.data.assets ?? {};
784
+ this.pendingAssets = [.../* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(assets)])].filter((id) => !equal(ownValue(previous, id), ownValue(assets, id))).map((assetId) => ({
785
+ assetId,
786
+ prev: ownValue(previous, assetId),
787
+ next: ownValue(assets, assetId)
788
+ }));
789
+ this.pendingMeta ??= meta;
790
+ }
791
+ accept(next, meta, staged = false) {
792
+ if (equal(next.data, this.current.data)) return;
793
+ assertStableAnswerTypes(this.current, next);
794
+ if (this.current.slots !== next.slots) {
795
+ const diagnostics = answerTypeDiagnostics(next, (id) => this.answerTypes.get(id));
796
+ if (diagnostics.length) throw new DefinitionError(diagnostics);
797
+ const existing = new Set(getDefaultProjectionDiagnostics(this.current).flatMap((issue) => issue.code === "duplicate-header" ? [issue.header] : []));
798
+ const introduced = getDefaultProjectionDiagnostics(next).filter((issue) => issue.code === "duplicate-header" && !existing.has(issue.header));
799
+ if (introduced.length) throw new Error(introduced.map((issue) => issue.message).join("\n"));
800
+ }
801
+ if (!this.fieldWriteDepth && !this.actionDepth && !staged) {
802
+ const pending = this.current;
803
+ this.commitIfNeeded();
804
+ if (this.current !== pending) {
805
+ const metadata = /* @__PURE__ */ new Map();
806
+ for (const item of next.data.surveyItems) {
807
+ const before = pending.getItem(item.id)?.editorMetadata;
808
+ const committed = this.current.getItem(item.id)?.editorMetadata;
809
+ if (committed && equal(item.editorMetadata, before) && !equal(before, committed)) metadata.set(item.id, committed);
810
+ }
811
+ next = next.withItemEditorMetadata(metadata);
812
+ }
813
+ }
814
+ const baseline = this.current;
815
+ const pending = {
816
+ survey: this.pendingSurvey,
817
+ assets: this.pendingAssets,
818
+ meta: this.pendingMeta
819
+ };
820
+ try {
821
+ this.trackPending(next, meta);
822
+ this.current = next;
823
+ if (!this.fieldWriteDepth && !this.actionDepth && !staged) this.recordCommit(meta);
824
+ } catch (error) {
825
+ this.current = baseline;
826
+ this.pendingSurvey = pending.survey;
827
+ this.pendingAssets = pending.assets;
828
+ this.pendingMeta = pending.meta;
829
+ throw error;
830
+ }
831
+ this.publish(this.current);
832
+ }
833
+ transact(meta, operation) {
834
+ const draft = this.current.serialize();
835
+ const hadLocales = this.current.locales.length > 0;
836
+ operation(draft);
837
+ const first = Object.keys(draft.translations ?? {})[0];
838
+ if (!hadLocales && first !== void 0) draft.defaultLocale ??= first;
839
+ const next = Survey.parse(draft, this.current.registry, { admission: "draft" });
840
+ new SurveyStateGraph(next, { admission: "draft" });
841
+ this.accept(next, meta);
842
+ }
843
+ /** Synchronous compound UI action: validated writes, one publication/history entry, rollback on failure. */
844
+ runAction(operation) {
845
+ if (!this.actionDepth) this.commitIfNeeded();
846
+ const baseline = this.current;
847
+ const pending = {
848
+ survey: this.pendingSurvey,
849
+ assets: this.pendingAssets,
850
+ meta: this.pendingMeta,
851
+ actionMeta: this.actionMeta
852
+ };
853
+ this.actionDepth++;
854
+ let result;
855
+ try {
856
+ result = operation();
857
+ if (result && typeof result === "object" && "then" in result) throw new Error("Editor actions must be synchronous");
858
+ assertStableAnswerTypes(baseline, this.current);
859
+ } catch (error) {
860
+ this.current = baseline;
861
+ this.pendingSurvey = pending.survey;
862
+ this.pendingAssets = pending.assets;
863
+ this.pendingMeta = pending.meta;
864
+ this.actionMeta = pending.actionMeta;
865
+ throw error;
866
+ } finally {
867
+ this.actionDepth--;
868
+ }
869
+ if (!this.actionDepth) {
870
+ const meta = this.actionMeta ?? this.pendingMeta ?? { label: "Edit survey" };
871
+ this.actionMeta = void 0;
872
+ const changed = !equal(baseline.data, this.current.data);
873
+ if (!changed) {
874
+ this.current = baseline;
875
+ this.pendingSurvey = pending.survey;
876
+ this.pendingAssets = pending.assets;
877
+ this.pendingMeta = pending.meta;
878
+ }
879
+ if (changed) {
880
+ try {
881
+ this.recordCommit(meta);
882
+ } catch (error) {
883
+ this.current = baseline;
884
+ this.pendingSurvey = pending.survey;
885
+ this.pendingAssets = pending.assets;
886
+ this.pendingMeta = pending.meta;
887
+ this.actionMeta = pending.actionMeta;
888
+ throw error;
889
+ }
890
+ this.publish(this.current);
891
+ }
892
+ }
893
+ return result;
894
+ }
895
+ getLocalizedContent(ref, locale) {
896
+ return readLocalizationContent(this.current, ref, locale);
897
+ }
898
+ setLocalizedContent(ref, locale, content, meta = { label: "Edit translated content" }) {
899
+ let next = this.current.withLocalizationContent(ref, locale, content);
900
+ if (!this.current.locales.length && next.locales.length && next.data.defaultLocale === void 0) next = next.withDefaultLocale(next.locales[0]);
901
+ this.accept(next, meta);
902
+ }
903
+ /** Reuses the editor's field session boundary; no separate editable survey or history. */
904
+ beginFieldEdit(itemId) {
905
+ if (this.hasUncommittedChanges || this.actionDepth) throw new Error("Finish pending changes before editing a field");
906
+ if (itemId && !this.current.data.surveyItems.some((item) => item.id === itemId)) throw new Error(`Unknown item '${itemId}'`);
907
+ let expectedEpoch = this.mutationEpoch;
908
+ const history = this.history, index = history.getCurrentIndex();
909
+ const owns = () => this.mutationEpoch === expectedEpoch && this.history === history && history.getCurrentIndex() === index;
910
+ const run = (write) => {
911
+ if (!owns()) throw new Error("Field edit was superseded by another action");
912
+ this.fieldWriteDepth++;
913
+ try {
914
+ return write();
915
+ } finally {
916
+ this.fieldWriteDepth--;
917
+ expectedEpoch = this.mutationEpoch;
918
+ }
919
+ };
920
+ return {
921
+ run,
922
+ reconcile: (restoreValue) => {
923
+ if (!owns()) return false;
924
+ if (restoreValue) run(restoreValue);
925
+ this.restoreHistoryState(history.getCurrentState());
926
+ expectedEpoch = this.mutationEpoch;
927
+ return true;
928
+ }
929
+ };
930
+ }
931
+ recordCommit(meta) {
932
+ if (!this.hasUncommittedChanges) return false;
933
+ if (this.pendingSurvey) {
934
+ const stamped = timestampEditedItems(this.history.getCurrentState(), this.current);
935
+ this.history.commit(stripAssets(stamped.serialize()), meta, this.pendingAssets);
936
+ this.current = stamped;
937
+ } else this.history.commitAssetChange(this.pendingAssets, meta);
938
+ this.pendingSurvey = false;
939
+ this.pendingAssets = [];
940
+ this.pendingMeta = void 0;
941
+ this.mutationEpoch++;
942
+ return true;
943
+ }
944
+ commit(meta) {
945
+ if (this.actionDepth) {
946
+ this.actionMeta = meta;
947
+ return;
948
+ }
949
+ if (this.recordCommit(meta)) this.notify();
950
+ }
951
+ commitIfNeeded() {
952
+ this.commit(this.pendingMeta ?? {
953
+ label: "Latest content changes",
954
+ source: CommitSource.SYSTEM
955
+ });
956
+ }
957
+ restoreHistoryState(snapshot) {
958
+ const assets = this.history.getCurrentAssets();
959
+ const next = Survey.parse({
960
+ ...snapshot,
961
+ ...assets ? { assets } : {}
962
+ }, this.current.registry, {
963
+ previous: this.current,
964
+ admission: "draft"
899
965
  });
966
+ this.pendingSurvey = false;
967
+ this.pendingAssets = [];
968
+ this.pendingMeta = void 0;
969
+ this.publish(next);
900
970
  }
901
971
  undo() {
972
+ if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
902
973
  if (this.hasUncommittedChanges) {
903
- this.restoreHistoryState(this._undoRedo.getCurrentState(), this._undoRedo.getCurrentAssets());
904
- this._hasUncommittedChanges = false;
974
+ this.restoreHistoryState(this.history.getCurrentState());
905
975
  return true;
906
- } else {
907
- const previousState = this._undoRedo.undo();
908
- if (previousState) {
909
- this.restoreHistoryState(previousState, this._undoRedo.getCurrentAssets());
910
- this._hasUncommittedChanges = false;
911
- return true;
912
- }
913
- return false;
914
976
  }
977
+ const value = this.history.undo();
978
+ if (!value) return false;
979
+ this.restoreHistoryState(value);
980
+ return true;
915
981
  }
916
982
  redo() {
983
+ if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
917
984
  if (this.hasUncommittedChanges) return false;
918
- const nextState = this._undoRedo.redo();
919
- if (nextState) {
920
- this.restoreHistoryState(nextState, this._undoRedo.getCurrentAssets());
921
- this._hasUncommittedChanges = false;
922
- return true;
923
- }
924
- return false;
985
+ const value = this.history.redo();
986
+ if (!value) return false;
987
+ this.restoreHistoryState(value);
988
+ return true;
925
989
  }
926
- /**
927
- * Jump to a specific index in the history (can go forward or backward)
928
- */
929
- jumpToIndex(targetIndex) {
990
+ jumpToIndex(index) {
991
+ if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
930
992
  if (this.hasUncommittedChanges) return false;
931
- const targetState = this._undoRedo.jumpToIndex(targetIndex);
932
- if (targetState) {
933
- this.restoreHistoryState(targetState, this._undoRedo.getCurrentAssets());
934
- this._hasUncommittedChanges = false;
935
- return true;
936
- }
937
- return false;
993
+ const value = this.history.jumpToIndex(index);
994
+ if (!value) return false;
995
+ this.restoreHistoryState(value);
996
+ return true;
938
997
  }
939
998
  canUndo() {
940
- return this.hasUncommittedChanges || this._undoRedo.canUndo();
999
+ return this.hasUncommittedChanges || this.history.canUndo();
941
1000
  }
942
1001
  canRedo() {
943
- return !this.hasUncommittedChanges && this._undoRedo.canRedo();
1002
+ return !this.hasUncommittedChanges && this.history.canRedo();
944
1003
  }
945
1004
  getUndoMeta() {
946
- if (this.hasUncommittedChanges) return {
947
- label: "Latest content changes",
948
- source: CommitSource.SYSTEM
949
- };
950
- return this._undoRedo.getUndoMeta();
1005
+ return this.hasUncommittedChanges ? this.pendingMeta ?? null : this.history.getUndoMeta();
951
1006
  }
952
1007
  getRedoMeta() {
953
- if (this.hasUncommittedChanges) return null;
954
- return this._undoRedo.getRedoMeta();
1008
+ return this.hasUncommittedChanges ? null : this.history.getRedoMeta();
955
1009
  }
956
1010
  getMemoryUsage() {
957
- return this._undoRedo.getMemoryUsage();
1011
+ return this.history.getMemoryUsage();
958
1012
  }
959
1013
  getUndoRedoConfig() {
960
- return this._undoRedo.getConfig();
1014
+ return this.history.getConfig();
961
1015
  }
962
- /**
963
- * Get committed changes after the initial editor state up to the current undo/redo position.
964
- */
965
1016
  getCommitsSinceInitial() {
966
- return this._undoRedo.getCommitsSinceInitial();
1017
+ return this.history.getCommitsSinceInitial();
967
1018
  }
968
- /**
969
- * Serialize the SurveyEditor state to JSON
970
- * @returns A JSON-serializable object containing the complete editor state
971
- */
972
1019
  toJson() {
973
1020
  return {
974
- version: "1.0.0",
975
- survey: structuredCloneMethod(this._survey.serialize()),
976
- undoRedo: this._undoRedo.serialize(),
977
- hasUncommittedChanges: this.hasUncommittedChanges
1021
+ version: 2,
1022
+ survey: this.current.serialize(),
1023
+ undoRedo: structuredClone(this.history.serialize()),
1024
+ hasUncommittedChanges: this.hasUncommittedChanges,
1025
+ answerTypes: this.getAnswerTypes()
978
1026
  };
979
1027
  }
980
- /**
981
- * Create a new SurveyEditor instance from JSON data
982
- * @param jsonData The serialized editor state
983
- * @param pluginRegistry Optional plugin registry for deserializing survey state (required when survey contains custom item types)
984
- * @returns A new SurveyEditor instance with the restored state
985
- */
986
- static fromJson(jsonData, pluginRegistry) {
987
- if (!jsonData.survey) throw new Error("Invalid object: survey is required");
988
- if (!jsonData.undoRedo) throw new Error("Invalid object: undoRedo is required");
989
- if (typeof jsonData.hasUncommittedChanges !== "boolean") throw new Error("Invalid object: hasUncommittedChanges must be a boolean");
990
- if (jsonData.version && !jsonData.version.startsWith("1.")) console.warn(`Warning: Loading SurveyEditor with version ${jsonData.version}, current version is 1.0.0`);
991
- const survey = Survey.fromJson(jsonData.survey, pluginRegistry);
992
- const editor = new SurveyEditor(survey, { pluginRegistry });
993
- editor._undoRedo = SurveyEditorUndoRedo.deserialize({
994
- ...jsonData.undoRedo,
995
- history: jsonData.undoRedo.history.map((entry) => "survey" in entry ? {
996
- ...entry,
997
- survey: stripAssetsFromSurveySnapshot(entry.survey)
998
- } : entry)
999
- }, jsonData.survey.assets);
1000
- editor._hasUncommittedChanges = JSON.stringify(stripAssetsFromSurveySnapshot(jsonData.survey)) !== JSON.stringify(editor._undoRedo.getCurrentState());
1001
- const baselineAssets = editor._undoRedo.getCurrentAssets() ?? {};
1002
- const liveAssets = jsonData.survey.assets ?? {};
1003
- for (const assetId of /* @__PURE__ */ new Set([...Object.keys(baselineAssets), ...Object.keys(liveAssets)])) if (JSON.stringify(baselineAssets[assetId]) !== JSON.stringify(liveAssets[assetId])) editor.pendingAssets.set(assetId, {
1004
- prev: structuredCloneMethod(baselineAssets[assetId]),
1005
- next: structuredCloneMethod(liveAssets[assetId])
1006
- });
1028
+ static fromJson(input, registry) {
1029
+ const data = createEditorSessionSchema(registry).parse(input);
1030
+ const answerTypes = new Map(data.answerTypes);
1031
+ const checkAnswerTypes = (survey) => {
1032
+ const diagnostics = answerTypeDiagnostics(survey, (id) => answerTypes.get(id));
1033
+ if (diagnostics.length) throw new DefinitionError(diagnostics);
1034
+ for (const slot of survey.slots.definitions) if (!answerTypes.has(slot.slotId)) throw new Error(`Stored answer type is missing for '${slot.slotId}'`);
1035
+ };
1036
+ let historySurvey;
1037
+ let previous;
1038
+ let currentHistoryRevision;
1039
+ const assets = structuredClone(data.undoRedo.initialAssets ?? {});
1040
+ for (const [index, entry] of data.undoRedo.history.entries()) {
1041
+ for (const patch of entry.changes ?? []) {
1042
+ if (!equal(ownValue(assets, patch.assetId), patch.prev)) throw new Error(`Inconsistent asset history for '${patch.assetId}'`);
1043
+ if (patch.next) setOwnValue(assets, patch.assetId, patch.next);
1044
+ else delete assets[patch.assetId];
1045
+ }
1046
+ if (entry.kind === "survey-snapshot") {
1047
+ if (entry.survey.assets !== void 0) throw new Error("History snapshots must use the shared asset history");
1048
+ historySurvey = entry.survey;
1049
+ }
1050
+ if (!historySurvey) throw new Error("History requires an initial survey snapshot");
1051
+ const snapshot = Survey.parse({
1052
+ ...historySurvey,
1053
+ ...Object.keys(assets).length ? { assets } : {}
1054
+ }, registry, {
1055
+ previous,
1056
+ admission: "draft"
1057
+ });
1058
+ if (snapshot.slots !== previous?.slots) new SurveyStateGraph(snapshot, { admission: "draft" });
1059
+ checkAnswerTypes(snapshot);
1060
+ previous = snapshot;
1061
+ if (index === data.undoRedo.currentIndex) currentHistoryRevision = snapshot;
1062
+ }
1063
+ const editor = new SurveyEditor(Survey.parse(data.survey, registry, {
1064
+ previous: currentHistoryRevision,
1065
+ admission: "draft"
1066
+ }));
1067
+ if (editor.survey.slots !== currentHistoryRevision?.slots) new SurveyStateGraph(editor.survey, { admission: "draft" });
1068
+ checkAnswerTypes(editor.survey);
1069
+ editor.answerTypes = answerTypes;
1070
+ editor._history = SurveyEditorUndoRedo.deserialize(data.undoRedo);
1071
+ editor.trackPending(editor.current);
1072
+ if (editor.hasUncommittedChanges !== data.hasUncommittedChanges) throw new Error("Stored pending-edit state does not match the current history revision");
1007
1073
  return editor;
1008
1074
  }
1009
- markAsModified() {
1010
- this.mutationEpoch++;
1011
- this._hasUncommittedChanges = true;
1075
+ getAsset(id) {
1076
+ return structuredClone(ownValue(this.current.data.assets, id));
1012
1077
  }
1013
- createEditorTimestamp() {
1014
- return (/* @__PURE__ */ new Date()).toISOString();
1015
- }
1016
- touchItemModifiedMetadata(itemId, timestamp = this.createEditorTimestamp()) {
1017
- const item = this._survey.surveyItems.get(itemId);
1018
- if (!item) return;
1019
- item.setModifiedAt(timestamp);
1078
+ getAssets() {
1079
+ return new Map(Object.entries(structuredClone(this.current.data.assets ?? {})));
1020
1080
  }
1021
1081
  addItem(target, item, content) {
1022
- if (this._survey.surveyItems.has(item.id)) throw new Error(`Item with id '${item.id}' already exists in the survey`);
1023
- const timestamp = this.createEditorTimestamp();
1024
- let parentGroup;
1025
- if (!target) {
1026
- const rootItem = this._survey.rootItem;
1027
- if (!rootItem) throw new Error("No root group found in survey");
1028
- if (!(rootItem instanceof GroupItemCore)) throw new Error("Root item is not a group item");
1029
- parentGroup = rootItem;
1030
- } else {
1031
- const targetItem = this._survey.surveyItems.get(target.parentId);
1032
- if (!targetItem) throw new Error(`Parent item with id '${target.parentId}' not found`);
1033
- if (!(targetItem instanceof GroupItemCore)) throw new Error(`Parent item '${target.parentId}' is not a group item (${targetItem.type})`);
1034
- parentGroup = targetItem;
1082
+ this.insertItemTree(target, item.id, [item], content);
1083
+ }
1084
+ /** Validate a preset's entire flow subtree together, including forward references. */
1085
+ insertItemTree(target, rootItemId, items, content, meta = { label: "Add item" }) {
1086
+ if (!items.some((item) => item.id === rootItemId)) throw new Error("Preset root is missing");
1087
+ const byId = new Map(items.map((item) => [item.id, item]));
1088
+ if (byId.size !== items.length) throw new Error("Duplicate preset item identity");
1089
+ const visited = /* @__PURE__ */ new Set();
1090
+ const pending = [rootItemId];
1091
+ while (pending.length) {
1092
+ const id = pending.pop();
1093
+ const item = byId.get(id);
1094
+ if (!item || visited.has(id)) throw new Error("Preset must be a self-contained flow tree");
1095
+ visited.add(id);
1096
+ if (item.itemType === "group") pending.push(...item.config.items);
1035
1097
  }
1036
- if (parentGroup.hasChild(item.id)) throw new Error(`Item ${item.id} already in this group`);
1037
- let insertIndex;
1038
- if (target?.index !== void 0) insertIndex = Math.min(target.index, parentGroup.items.length);
1039
- else insertIndex = parentGroup.items.length;
1040
- this.lastResponseNameChanges = allocateCopiedResponseNames(this._survey, [item]);
1041
- this.markAsModified();
1042
- item.setCreatedAt(timestamp);
1043
- item.setModifiedAt(timestamp);
1044
- this._survey.surveyItems.set(item.id, item);
1045
- parentGroup.addChild(item.id, insertIndex);
1046
- this.touchItemModifiedMetadata(parentGroup.id, timestamp);
1047
- if (content) this._survey.translations.setItemTranslations(item.id, content);
1048
- }
1049
- removeItem(itemId, nested = false) {
1050
- this.markAsModified();
1051
- const timestamp = this.createEditorTimestamp();
1052
- const item = this._survey.surveyItems.get(itemId);
1053
- if (!item) return false;
1054
- const parentItem = this._survey.getParentItem(itemId);
1055
- if (!parentItem) throw new Error(`Item with id '${itemId}' is the root item`);
1056
- if (item instanceof GroupItemCore) for (const childId of item.getChildrenIds()) this.removeItem(childId, true);
1057
- this._survey.surveyItems.delete(itemId);
1058
- this._survey.translations?.onItemDeleted(itemId);
1059
- if (!nested) {
1060
- parentItem.removeChild(itemId);
1061
- this.touchItemModifiedMetadata(parentItem.id, timestamp);
1062
- }
1063
- return true;
1064
- }
1065
- moveItem(itemId, newTarget) {
1066
- this.markAsModified();
1067
- const timestamp = this.createEditorTimestamp();
1068
- if (!this._survey.surveyItems.get(itemId)) throw new Error(`Item with id '${itemId}' not found`);
1069
- const targetItem = this._survey.surveyItems.get(newTarget.parentId);
1070
- if (!targetItem) throw new Error(`Target parent with id '${newTarget.parentId}' not found`);
1071
- if (!(targetItem instanceof GroupItemCore)) throw new Error(`Target parent '${newTarget.parentId}' is not a group item (${targetItem.type})`);
1072
- if (this._survey.isDescendantOf(newTarget.parentId, itemId)) throw new Error(`Cannot move item '${itemId}' to its descendant '${newTarget.parentId}'`);
1073
- const currentParentItem = this._survey.getParentItem(itemId);
1074
- if (currentParentItem?.id === newTarget.parentId) throw new Error(`Item '${itemId}' is already in the target parent '${newTarget.parentId}'`);
1075
- if (currentParentItem) {
1076
- const currentParentGroup = currentParentItem;
1077
- currentParentGroup.removeChild(itemId);
1078
- this.touchItemModifiedMetadata(currentParentGroup.id, timestamp);
1079
- }
1080
- const targetGroup = targetItem;
1081
- const insertIndex = newTarget.index !== void 0 ? Math.min(newTarget.index, targetGroup.items.length) : targetGroup.items.length;
1082
- targetGroup.addChild(itemId, insertIndex);
1083
- this.touchItemModifiedMetadata(targetGroup.id, timestamp);
1084
- this.touchItemModifiedMetadata(itemId, timestamp);
1085
- return true;
1086
- }
1087
- /** Move several independent item subtrees as one ordered block. */
1088
- moveItems(itemIds, newTarget) {
1089
- const requestedIds = [...new Set(itemIds)];
1090
- const selectedIds = requestedIds.filter((itemId) => !requestedIds.some((candidateId) => candidateId !== itemId && this._survey.isDescendantOf(itemId, candidateId)));
1091
- if (selectedIds.length === 0) return false;
1092
- const targetGroup = this._survey.surveyItems.get(newTarget.parentId);
1093
- if (!(targetGroup instanceof GroupItemCore)) throw new Error(`Target parent '${newTarget.parentId}' is not a group item`);
1094
- const entries = selectedIds.map((itemId) => {
1095
- const item = this._survey.surveyItems.get(itemId);
1096
- const parent = this._survey.getParentItem(itemId);
1097
- if (!item || !(parent instanceof GroupItemCore)) throw new Error(`Item '${itemId}' cannot be moved`);
1098
- if (itemId === newTarget.parentId || this._survey.isDescendantOf(newTarget.parentId, itemId)) throw new Error(`Cannot move item '${itemId}' into its own subtree`);
1099
- return {
1100
- item,
1101
- parent,
1102
- index: parent.items.indexOf(itemId)
1103
- };
1104
- });
1105
- const order = /* @__PURE__ */ new Map();
1106
- let orderIndex = 0;
1107
- const visit = (group) => {
1108
- for (const childId of group.items) {
1109
- order.set(childId, orderIndex++);
1110
- const child = this._survey.surveyItems.get(childId);
1111
- if (child instanceof GroupItemCore) visit(child);
1098
+ if (visited.size !== items.length) throw new Error("Preset contains disconnected items");
1099
+ this.transact(meta, (draft) => {
1100
+ const parent = draft.surveyItems.find((candidate) => candidate.id === target.parentId);
1101
+ if (parent?.itemType !== "group") throw new Error("Item target must be a group");
1102
+ if (target.index !== void 0 && (!Number.isInteger(target.index) || target.index < 0)) throw new Error("Invalid item insertion index");
1103
+ parent.config.items.splice(Math.min(target.index ?? parent.config.items.length, parent.config.items.length), 0, rootItemId);
1104
+ draft.surveyItems.push(...structuredClone(items));
1105
+ if (content) {
1106
+ draft.translations ??= {};
1107
+ const translation = ownValue(draft.translations, content.locale) ?? { content: {} };
1108
+ setOwnValue(draft.translations, content.locale, translation);
1109
+ for (const [owner, roles] of Object.entries(content.content)) {
1110
+ if (this.current.owners.has(owner)) throw new Error(`Initial localized owner '${owner}' already exists`);
1111
+ setOwnValue(translation.content, owner, structuredClone(roles));
1112
+ }
1112
1113
  }
1113
- };
1114
- const root = this._survey.rootItem;
1115
- if (root) visit(root);
1116
- entries.sort((a, b) => (order.get(a.item.id) ?? 0) - (order.get(b.item.id) ?? 0));
1117
- const selectedSet = new Set(entries.map(({ item }) => item.id));
1118
- const requestedIndex = newTarget.index ?? targetGroup.items.length;
1119
- const removedBeforeTarget = targetGroup.items.slice(0, requestedIndex).filter((id) => selectedSet.has(id)).length;
1120
- const insertionIndex = Math.max(0, requestedIndex - removedBeforeTarget);
1121
- const timestamp = this.createEditorTimestamp();
1122
- this.markAsModified();
1123
- for (const { item, parent } of entries) {
1124
- parent.removeChild(item.id);
1125
- this.touchItemModifiedMetadata(parent.id, timestamp);
1126
- }
1127
- entries.forEach(({ item }, offset) => {
1128
- targetGroup.addChild(item.id, insertionIndex + offset);
1129
- this.touchItemModifiedMetadata(item.id, timestamp);
1130
1114
  });
1131
- this.touchItemModifiedMetadata(targetGroup.id, timestamp);
1132
- return true;
1133
1115
  }
1134
- /**
1135
- * Get a deep copy of an item's raw data.
1136
- * The returned object is independent of the survey—mutating it will not affect the survey.
1137
- * Use this when you need to edit an item in a form or pass it to updateItem after modifications.
1138
- *
1139
- * @param itemId - The ID of the item to copy
1140
- * @returns A deep copy of the item's RawSurveyItem data
1141
- * @throws Error if the item is not found
1142
- */
1143
1116
  getItemDataCopy(itemId) {
1144
- const item = this._survey.surveyItems.get(itemId);
1145
- if (!item) throw new Error(`Item with id '${itemId}' not found`);
1146
- return JSON.parse(JSON.stringify(item.rawItem));
1117
+ const item = this.current.getItem(itemId);
1118
+ if (!item) throw new Error(`Unknown item '${itemId}'`);
1119
+ return structuredClone(item);
1120
+ }
1121
+ updateItem(itemId, update, meta = { label: "Edit item" }) {
1122
+ const existing = this.current.getItem(itemId);
1123
+ if (!existing) throw new Error(`Unknown item '${itemId}'`);
1124
+ if (update.id !== void 0 && update.id !== itemId) throw new Error("Updating an item cannot change its identity");
1125
+ this.transact(meta, (draft) => {
1126
+ const index = draft.surveyItems.findIndex((item) => item.id === itemId);
1127
+ const next = {
1128
+ ...existing,
1129
+ ...update,
1130
+ id: itemId
1131
+ };
1132
+ setAt(draft, ["surveyItems", index], next);
1133
+ });
1147
1134
  }
1148
- /**
1149
- * Update an item's data in the survey.
1150
- * Can update common attributes (displayConditions, disabledConditions, validations, etc.)
1151
- * as well as type-specific config.
1152
- *
1153
- * Use getItemDataCopy to obtain an editable copy, modify it, then pass it here.
1154
- * Alternatively, pass a Partial to merge specific fields (top-level keys only;
1155
- * e.g. config replaces the entire config object).
1156
- *
1157
- * @param itemId - The ID of the item to update (must match data.id if provided)
1158
- * @param rawItemData - Full RawSurveyItem or Partial to merge. The id cannot be changed.
1159
- */
1160
- updateItem(itemId, rawItemData) {
1161
- const existingItem = this._survey.surveyItems.get(itemId);
1162
- if (!existingItem) throw new Error(`Item with id '${itemId}' not found`);
1163
- if ("id" in rawItemData && rawItemData.id !== void 0 && rawItemData.id !== itemId) throw new Error(`Cannot change item id from '${itemId}' to '${rawItemData.id}'`);
1164
- const merged = {
1165
- ...existingItem.rawItem,
1166
- ...rawItemData,
1167
- id: itemId
1168
- };
1169
- if (merged.responseSettings) {
1170
- merged.responseSettings = { ...merged.responseSettings };
1171
- const nextSlotIds = new Set(this._survey.createItemFromRaw(merged).getResponseSlotDefinitions().map((slot) => slot.slotId));
1172
- for (const slot of existingItem.getResponseSlotDefinitions()) if (!nextSlotIds.has(slot.slotId)) delete merged.responseSettings[slot.slotId];
1173
- }
1174
- const stagedRaw = this._survey.serialize();
1175
- stagedRaw.surveyItems = stagedRaw.surveyItems.map((item) => item.id === itemId ? merged : item);
1176
- const beforeIssues = new Set(this._survey.getResponseSlotRegistry().diagnostics.map((issue) => JSON.stringify(issue)));
1177
- const stagedSurvey = Survey.fromJson(stagedRaw, this._survey.getPluginRegistry());
1178
- const introduced = stagedSurvey.getResponseSlotRegistry().diagnostics.filter((issue) => !beforeIssues.has(JSON.stringify(issue)));
1179
- if (introduced.length) throw new Error(introduced.map((issue) => issue.message).join("\n"));
1180
- const previousProjectionIssues = new Set(getDefaultProjectionIssues(this._survey));
1181
- const projectionIssues = getDefaultProjectionIssues(stagedSurvey).filter((issue) => !previousProjectionIssues.has(issue));
1182
- if (projectionIssues.length) throw new Error(projectionIssues.join("\n"));
1183
- const newItem = this._survey.createItemFromRaw({
1184
- ...merged,
1185
- editorMetadata: existingItem.rawItem.editorMetadata
1135
+ /** Repair answer reads only; state targets and previous-survey prefill references are distinct. */
1136
+ rewriteAnswerReferences(replacements) {
1137
+ let changed = 0;
1138
+ const raw = transformAnnotated(this.current.registry.surveySchema, this.current.data, ({ annotation, value }) => {
1139
+ if (annotation.kind !== "expression") return value;
1140
+ return transformExpression(expressionSchema.parse(value), {
1141
+ const: (node) => node,
1142
+ function: (node) => node,
1143
+ contextVariable: (node) => node,
1144
+ ownerState: (node) => node,
1145
+ responseVariable: (node) => {
1146
+ const slotId = ownValue(replacements, node.variableRef.slotId);
1147
+ if (!slotId || slotId === node.variableRef.slotId) return node;
1148
+ changed++;
1149
+ return {
1150
+ ...node,
1151
+ variableRef: {
1152
+ ...node.variableRef,
1153
+ slotId
1154
+ }
1155
+ };
1156
+ }
1157
+ });
1186
1158
  });
1187
- newItem.setModifiedAt(this.createEditorTimestamp());
1188
- this._survey.surveyItems.set(itemId, newItem);
1189
- this.markAsModified();
1190
- }
1191
- /** Detached field read; does not construct a Survey or visit other locales. */
1192
- getItemTranslationContent(itemId, locale, contentKey, fallbackLocale) {
1193
- return this._survey.translations.getItemTranslationContent(itemId, locale, contentKey, fallbackLocale);
1194
- }
1195
- /** Publish accepted content without committing history. Equivalent writes are no-ops. */
1196
- setItemTranslationContent(itemId, locale, contentKey, content) {
1197
- const item = this._survey.surveyItems.get(itemId);
1198
- if (!item) return false;
1199
- if (!this._survey.translations.setItemTranslationContent(itemId, locale, contentKey, content)) return false;
1200
- item.setModifiedAt(this.createEditorTimestamp());
1201
- this.markAsModified();
1202
- return true;
1159
+ if (changed) {
1160
+ const next = Survey.parse(raw, this.current.registry, { admission: "draft" });
1161
+ new SurveyStateGraph(next, { admission: "draft" });
1162
+ this.accept(next, { label: "Repair answer references" });
1163
+ }
1164
+ return changed;
1203
1165
  }
1204
- updateItemTranslations(itemId, updatedContent) {
1205
- const item = this._survey.surveyItems.get(itemId);
1206
- if (!item) return false;
1207
- this.markAsModified();
1208
- item.setModifiedAt(this.createEditorTimestamp());
1209
- this._survey.translations.setItemTranslations(itemId, updatedContent);
1210
- return true;
1166
+ renameResponseVariables(edits) {
1167
+ return this.updateResponseSettings(edits, []);
1211
1168
  }
1212
- /**
1213
- * Update survey-level translations that are not tied to specific items.
1214
- * Use these for survey card, navigation, validation messages, etc.
1215
- */
1216
- updateSurveyTranslations(updates) {
1217
- this.markAsModified();
1218
- if (updates.surveyCard) this._survey.translations.setSurveyCardContent(updates.surveyCard.locale, updates.surveyCard.content);
1219
- if (updates.navigation) this._survey.translations.setNavigationContent(updates.navigation.locale, updates.navigation.content);
1220
- if (updates.validationMessages) this._survey.translations.setValidationMessages(updates.validationMessages.locale, updates.validationMessages.content);
1169
+ updateResponseSettings(naming, settings) {
1170
+ const preview = previewResponseSettings(this.current, naming, settings);
1171
+ const next = Survey.parse(preview.survey, this.current.registry, { admission: "draft" });
1172
+ new SurveyStateGraph(next, { admission: "draft" });
1173
+ this.accept(next, { label: "Edit response variables" });
1174
+ return preview.changes;
1221
1175
  }
1222
- /** Add an empty survey locale so it can be authored before translated content exists. */
1223
1176
  addLocale(locale) {
1224
- const added = this._survey.translations.addLocale(locale);
1225
- if (added) this.markAsModified();
1226
- return added;
1177
+ if (!locale.trim()) throw new Error("A locale is required");
1178
+ if (Object.hasOwn(this.current.data.translations ?? {}, locale)) return false;
1179
+ this.transact({ label: `Add language: ${locale}` }, (draft) => {
1180
+ draft.translations ??= {};
1181
+ setOwnValue(draft.translations, locale, { content: {} });
1182
+ });
1183
+ return true;
1227
1184
  }
1228
- /** Remove a locale and every survey- and item-level translation stored for it. */
1229
1185
  removeLocale(locale) {
1230
- if (!this._survey.locales.includes(locale)) return false;
1231
- this._survey.translations.removeLocale(locale);
1232
- this.markAsModified();
1186
+ if (!Object.hasOwn(this.current.data.translations ?? {}, locale)) return false;
1187
+ this.transact({ label: `Remove language: ${locale}` }, (draft) => {
1188
+ delete draft.translations?.[locale];
1189
+ if (draft.defaultLocale === locale) {
1190
+ const next = Object.keys(draft.translations ?? {})[0];
1191
+ if (next === void 0) delete draft.defaultLocale;
1192
+ else draft.defaultLocale = next;
1193
+ }
1194
+ });
1233
1195
  return true;
1234
1196
  }
1235
- /**
1236
- * Get maxItemsPerPage immutably (returns a copy).
1237
- */
1197
+ /** Sets the source language that exports and hosts fall back to. */
1198
+ setDefaultLocale(locale) {
1199
+ if (!Object.hasOwn(this.current.data.translations ?? {}, locale)) throw new Error(`Unknown locale '${locale}'`);
1200
+ if (this.current.data.defaultLocale === locale) return false;
1201
+ this.transact({ label: `Set default language: ${locale}` }, (draft) => {
1202
+ draft.defaultLocale = locale;
1203
+ });
1204
+ return true;
1205
+ }
1206
+ updateSurveyTranslations(updates) {
1207
+ this.transact({ label: "Edit survey wording" }, (draft) => {
1208
+ const apply = (surface, update) => {
1209
+ if (!update) return;
1210
+ if (!update.locale.trim()) throw new Error("A locale is required");
1211
+ const existing = ownValue(draft.translations, update.locale);
1212
+ if (update.content === void 0 && existing?.[surface] === void 0) return;
1213
+ draft.translations ??= {};
1214
+ const translation = existing ?? { content: {} };
1215
+ setOwnValue(draft.translations, update.locale, translation);
1216
+ if (update.content === void 0) delete translation[surface];
1217
+ else translation[surface] = structuredClone(update.content);
1218
+ };
1219
+ apply("surveyCardContent", updates.surveyCard);
1220
+ apply("navigationContent", updates.navigation);
1221
+ apply("validationMessages", updates.validationMessages);
1222
+ });
1223
+ }
1238
1224
  getMaxItemsPerPage() {
1239
- const val = this._survey.maxItemsPerPage;
1240
- return val ? { ...val } : void 0;
1225
+ return structuredClone(this.current.data.maxItemsPerPage);
1241
1226
  }
1242
- /**
1243
- * Update maxItemsPerPage.
1244
- */
1245
1227
  updateMaxItemsPerPage(value) {
1246
- this.markAsModified();
1247
- this._survey.maxItemsPerPage = value ? { ...value } : void 0;
1228
+ this.transact({ label: "Change page size" }, (draft) => {
1229
+ if (value === void 0) delete draft.maxItemsPerPage;
1230
+ else draft.maxItemsPerPage = structuredClone(value);
1231
+ });
1248
1232
  }
1249
- /**
1250
- * Get metadata immutably (returns a copy).
1251
- */
1252
1233
  getMetadata() {
1253
- const val = this._survey.metadata;
1254
- return val ? { ...val } : void 0;
1234
+ return structuredClone(this.current.data.metadata);
1255
1235
  }
1256
- /**
1257
- * Update metadata.
1258
- */
1259
1236
  updateMetadata(metadata) {
1260
- this.markAsModified();
1261
- this._survey.metadata = metadata ? { ...metadata } : void 0;
1237
+ this.transact({ label: "Edit survey metadata" }, (draft) => {
1238
+ if (metadata === void 0) delete draft.metadata;
1239
+ else draft.metadata = structuredClone(metadata);
1240
+ });
1262
1241
  }
1263
- /**
1264
- * Get a template value immutably (returns a deep copy).
1265
- */
1266
1242
  getTemplateValue(key) {
1267
- const val = this._survey.getTemplateValue(key);
1268
- return val ? structuredCloneMethod(val) : void 0;
1243
+ return structuredClone(this.current.getTemplateValue(key));
1269
1244
  }
1270
- /**
1271
- * Get all template values immutably (returns a new Map with deep-copied values).
1272
- */
1273
1245
  getTemplateValues() {
1274
- const keys = this._survey.getTemplateValueKeys();
1275
- const result = /* @__PURE__ */ new Map();
1276
- for (const key of keys) {
1277
- const val = this._survey.getTemplateValue(key);
1278
- if (val) result.set(key, structuredCloneMethod(val));
1279
- }
1280
- return result;
1246
+ return new Map(Object.entries(structuredClone(this.current.data.templateValues ?? {})));
1281
1247
  }
1282
- /**
1283
- * Add or replace a template value.
1284
- */
1285
- setTemplateValue(key, templateValue) {
1286
- this.markAsModified();
1287
- this._survey.setTemplateValue(key, structuredCloneMethod(templateValue));
1248
+ setTemplateValue(key, value) {
1249
+ this.transact({ label: `Edit template: ${key}` }, (draft) => {
1250
+ draft.templateValues ??= {};
1251
+ setOwnValue(draft.templateValues, key, structuredClone(value));
1252
+ });
1288
1253
  }
1289
- /**
1290
- * Remove a template value.
1291
- */
1292
1254
  removeTemplateValue(key) {
1293
- this.markAsModified();
1294
- this._survey.deleteTemplateValue(key);
1295
- }
1296
- /**
1297
- * Get a survey asset immutably.
1298
- */
1299
- getAsset(assetId) {
1300
- const asset = this._survey.getAsset(assetId);
1301
- return asset ? structuredCloneMethod(asset) : void 0;
1255
+ this.transact({ label: `Remove template: ${key}` }, (draft) => {
1256
+ delete draft.templateValues?.[key];
1257
+ });
1302
1258
  }
1303
- /**
1304
- * Get all survey assets immutably.
1305
- */
1306
- getAssets() {
1307
- return this._survey.getAssets();
1259
+ stageAsset(id, asset) {
1260
+ const draft = this.current.serialize();
1261
+ draft.assets ??= {};
1262
+ if (asset) setOwnValue(draft.assets, id, asset);
1263
+ else {
1264
+ delete draft.assets[id];
1265
+ if (!Object.keys(draft.assets).length) delete draft.assets;
1266
+ }
1267
+ this.accept(Survey.parse(draft, this.current.registry, { admission: "draft" }), { label: `Update asset: ${id}` }, true);
1308
1268
  }
1309
- /**
1310
- * Add or replace a survey asset.
1311
- */
1312
- /** Stage one live asset delta. Default setAsset/removeAsset remain immediate. */
1313
- stageAsset(assetId, asset) {
1314
- const previous = this.pendingAssets.get(assetId)?.prev ?? this._survey.getAsset(assetId);
1315
- const baseline = this.pendingAssets.has(assetId) ? this.pendingAssets.get(assetId).prev : previous;
1316
- if (asset) this._survey.setAsset(assetId, structuredCloneMethod(asset));
1317
- else this._survey.deleteAsset(assetId);
1318
- if (JSON.stringify(baseline) === JSON.stringify(asset)) this.pendingAssets.delete(assetId);
1319
- else this.pendingAssets.set(assetId, {
1320
- prev: baseline,
1321
- next: structuredCloneMethod(asset)
1269
+ setAsset(id, asset) {
1270
+ this.transact({
1271
+ label: `Update asset: ${id}`,
1272
+ source: CommitSource.USER
1273
+ }, (draft) => {
1274
+ draft.assets ??= {};
1275
+ setOwnValue(draft.assets, id, asset);
1322
1276
  });
1323
- this.mutationEpoch++;
1324
1277
  }
1325
- setAsset(assetId, asset) {
1326
- this.mutationEpoch++;
1327
- const previousAsset = this._survey.getAsset(assetId);
1328
- this._survey.setAsset(assetId, structuredCloneMethod(asset));
1329
- this._undoRedo.commitAssetChange([{
1330
- assetId,
1331
- prev: previousAsset,
1332
- next: structuredCloneMethod(asset)
1333
- }], {
1334
- label: `Update asset: ${assetId}`,
1278
+ removeAsset(id) {
1279
+ this.transact({
1280
+ label: `Remove asset: ${id}`,
1335
1281
  source: CommitSource.USER
1282
+ }, (draft) => {
1283
+ if (draft.assets) {
1284
+ delete draft.assets[id];
1285
+ if (!Object.keys(draft.assets).length) delete draft.assets;
1286
+ }
1336
1287
  });
1337
1288
  }
1338
- /**
1339
- * Remove a survey asset.
1340
- */
1341
- removeAsset(assetId) {
1342
- this.mutationEpoch++;
1343
- const previousAsset = this._survey.getAsset(assetId);
1344
- if (!previousAsset) return;
1345
- this._survey.deleteAsset(assetId);
1346
- this._undoRedo.commitAssetChange([{
1347
- assetId,
1348
- prev: previousAsset
1349
- }], {
1350
- label: `Remove asset: ${assetId}`,
1351
- source: CommitSource.USER
1289
+ /** Shared insertion used by the survey catalog and a layout's element catalog. */
1290
+ insertElement(input, target, meta = { label: "Add element" }, initialContent) {
1291
+ const element = this.current.registry.elementSchema.parse(input);
1292
+ const layoutTarget = target && "layoutId" in target ? target : void 0;
1293
+ const bodyTarget = target && "bodyOwnerId" in target ? target : void 0;
1294
+ const body = bodyTarget ? this.current.bodies.get(bodyTarget.bodyOwnerId) : void 0;
1295
+ if (bodyTarget && !body) throw new Error(`Unknown body owner '${bodyTarget.bodyOwnerId}'`);
1296
+ const flowTarget = target && "parentId" in target ? target : { parentId: this.current.rootId };
1297
+ if (target?.index !== void 0 && (!Number.isInteger(target.index) || target.index < 0)) throw new Error("An insertion index must be a nonnegative integer");
1298
+ if (!layoutTarget && !bodyTarget && this.current.getItem(flowTarget.parentId)?.itemType !== "group") throw new Error(`Target parent '${flowTarget.parentId}' is not a group item`);
1299
+ const itemId = layoutTarget ? this.containingItem(layoutTarget.layoutId) : bodyTarget ? this.containingItem(bodyTarget.bodyOwnerId) : generateId();
1300
+ this.transact(meta, (draft) => {
1301
+ if (initialContent) {
1302
+ draft.translations ??= {};
1303
+ const translation = ownValue(draft.translations, initialContent.locale) ?? { content: {} };
1304
+ setOwnValue(draft.translations, initialContent.locale, translation);
1305
+ for (const [owner, roles] of Object.entries(initialContent.content)) {
1306
+ if (this.current.owners.has(owner)) throw new Error(`Initial localized owner '${owner}' already exists`);
1307
+ setOwnValue(translation.content, owner, structuredClone(roles));
1308
+ }
1309
+ }
1310
+ if (layoutTarget) {
1311
+ const elements = arrayAt(draft, [...this.identityPath(layoutTarget.layoutId, "layout"), "elements"]);
1312
+ elements.splice(layoutTarget.index ?? elements.length, 0, element);
1313
+ } else if (body && bodyTarget) {
1314
+ const blocks = arrayAt(draft, [...body.path, "blocks"]);
1315
+ blocks.splice(bodyTarget.index ?? blocks.length, 0, {
1316
+ id: generateId(),
1317
+ kind: "layout",
1318
+ layout: {
1319
+ mode: "automatic",
1320
+ maxColumns: 1
1321
+ },
1322
+ elements: [element]
1323
+ });
1324
+ } else {
1325
+ draft.surveyItems.push({
1326
+ id: itemId,
1327
+ itemType: "content",
1328
+ config: { body: { blocks: [{
1329
+ id: generateId(),
1330
+ kind: "layout",
1331
+ layout: {
1332
+ mode: "automatic",
1333
+ maxColumns: 1
1334
+ },
1335
+ elements: [element]
1336
+ }] } }
1337
+ });
1338
+ const parent = draft.surveyItems.find((item) => item.id === flowTarget.parentId);
1339
+ if (parent?.itemType !== "group") throw new Error("Insertion requires a flow group");
1340
+ parent.config.items.splice(flowTarget.index ?? parent.config.items.length, 0, itemId);
1341
+ }
1352
1342
  });
1343
+ return {
1344
+ itemId,
1345
+ elementId: element.id
1346
+ };
1353
1347
  }
1354
- /**
1355
- * Copy a survey item and all its data to clipboard format
1356
- * @param itemId - The ID of the item to copy
1357
- * @returns Clipboard data that can be serialized to JSON for clipboard
1358
- */
1359
- copyItem(itemId) {
1360
- return new ItemCopyPaste(this._survey).copyItem(itemId);
1348
+ moveItem(itemId, target, meta = { label: "Move item" }) {
1349
+ return this.moveItems([itemId], target, meta);
1350
+ }
1351
+ /** Retain the outline's ordered-block move semantics, publishing one validated revision. */
1352
+ moveItems(itemIds, target, meta = { label: "Move items" }) {
1353
+ const survey = this.current;
1354
+ const requested = [...new Set(itemIds)];
1355
+ if (!requested.length) return false;
1356
+ for (const id of requested) {
1357
+ if (!survey.getItem(id)) throw new Error(`Item with id '${id}' not found`);
1358
+ if (id === survey.rootId) throw new Error("The root group cannot be moved");
1359
+ }
1360
+ const destination = survey.getItem(target.parentId);
1361
+ if (!destination) throw new Error(`Target parent with id '${target.parentId}' not found`);
1362
+ if (destination.itemType !== "group") throw new Error(`Target parent '${target.parentId}' is not a group item`);
1363
+ const selected = requested.filter((id) => !requested.some((other) => other !== id && survey.isDescendantOf(id, other)));
1364
+ for (const id of selected) if (survey.isDescendantOf(destination.id, id)) throw new Error(`Cannot move item '${id}' into its own subtree`);
1365
+ if (target.index !== void 0 && (!Number.isInteger(target.index) || target.index < 0)) throw new Error("An insertion index must be a nonnegative integer");
1366
+ const selectedSet = new Set(selected);
1367
+ const ordered = [];
1368
+ const visit = (id) => {
1369
+ if (selectedSet.has(id)) ordered.push(id);
1370
+ const item = survey.getItem(id);
1371
+ if (item?.itemType === "group") item.config.items.forEach(visit);
1372
+ };
1373
+ visit(survey.rootId);
1374
+ const requestedIndex = Math.min(target.index ?? destination.config.items.length, destination.config.items.length);
1375
+ const removedBefore = destination.config.items.slice(0, requestedIndex).filter((id) => selectedSet.has(id)).length;
1376
+ const parents = new Set(selected.map((id) => survey.getParentItem(id).id));
1377
+ parents.add(destination.id);
1378
+ const lists = /* @__PURE__ */ new Map();
1379
+ for (const id of parents) {
1380
+ const group = survey.getItem(id);
1381
+ if (group?.itemType !== "group") throw new Error(`Unknown parent group '${id}'`);
1382
+ lists.set(id, group.config.items.filter((child) => !selectedSet.has(child)));
1383
+ }
1384
+ lists.get(destination.id).splice(requestedIndex - removedBefore, 0, ...ordered);
1385
+ if (![...lists].some(([id, children]) => {
1386
+ const group = survey.getItem(id);
1387
+ return group?.itemType === "group" && !equal(group.config.items, children);
1388
+ })) return false;
1389
+ this.transact(meta, (draft) => {
1390
+ for (const item of draft.surveyItems) {
1391
+ const children = lists.get(item.id);
1392
+ if (item.itemType === "group" && children) item.config.items = children;
1393
+ }
1394
+ });
1395
+ return true;
1361
1396
  }
1362
- /**
1363
- * Paste a survey item from clipboard data to a target location
1364
- * @param clipboardData - The clipboard data containing the item to paste
1365
- * @param target - Target location where to paste the item
1366
- * @returns The ID of the pasted item
1367
- */
1368
- pasteItem(clipboardData, target) {
1369
- const timestamp = this.createEditorTimestamp();
1370
- const itemIdsBeforePaste = new Set(this._survey.surveyItems.keys());
1371
- const copyPaste = new ItemCopyPaste(this._survey);
1372
- const newItemId = copyPaste.pasteItem(clipboardData, target);
1373
- this.lastResponseNameChanges = copyPaste.lastResponseNameChanges;
1374
- this.markAsModified();
1375
- const pastedItemIds = Array.from(this._survey.surveyItems.keys()).filter((itemId) => !itemIdsBeforePaste.has(itemId));
1376
- for (const itemId of pastedItemIds) {
1377
- const item = this._survey.surveyItems.get(itemId);
1378
- if (item) {
1379
- item.setCreatedAt(timestamp);
1380
- item.setModifiedAt(timestamp);
1397
+ identityPath(id, kind) {
1398
+ const declaration = this.current.identities.get(id);
1399
+ if (!declaration || kind && declaration.kind !== kind) throw new Error(`Unknown ${kind ?? "identity"} '${id}'`);
1400
+ return declaration.path.slice(0, -1);
1401
+ }
1402
+ containingItem(id) {
1403
+ const declaration = this.current.identities.get(id);
1404
+ const owner = declaration?.ownerId ? this.current.owners.get(declaration.ownerId) : void 0;
1405
+ if (!owner) throw new Error(`No containing item for '${id}'`);
1406
+ return owner.itemId;
1407
+ }
1408
+ /** Edit wording only for this element and its added/removed descendants, in existing locales. */
1409
+ updateElement(elementId, update, meta = { label: "Edit element" }, renewOwnerIds = /* @__PURE__ */ new Map()) {
1410
+ const path = this.identityPath(elementId, "element");
1411
+ this.transact(meta, (draft) => {
1412
+ const schema = this.current.registry.elementSchema;
1413
+ const element = schema.parse(at(draft, path));
1414
+ const before = contentOwnerIds(schema, element);
1415
+ const elementType = element.elementType;
1416
+ const translations = draft.translations ?? {};
1417
+ update(element, translations);
1418
+ if (Object.keys(translations).length || draft.translations) draft.translations = translations;
1419
+ if (element.id !== elementId || element.elementType !== elementType) throw new Error("Element identity and type cannot change during a settings update");
1420
+ const next = schema.parse(element);
1421
+ const remaining = contentOwnerIds(schema, next);
1422
+ assertElementTranslationScope(this.current.data.translations, draft.translations, /* @__PURE__ */ new Set([...before, ...remaining]));
1423
+ for (const translation of Object.values(draft.translations ?? {})) for (const ownerId of before) if (!remaining.has(ownerId)) delete translation.content[ownerId];
1424
+ setAt(draft, path, next);
1425
+ for (const from of renewOwnerIds.keys()) if (from === elementId || !before.has(from) || !remaining.has(from)) throw new Error(`Cannot renew '${from}': it must be a surviving owner inside the element`);
1426
+ if (renewOwnerIds.size) {
1427
+ const visits = inspectAnnotated(this.current.registry.surveySchema, draft).sort((a, b) => b.path.length - a.path.length || Number(a.key) - Number(b.key));
1428
+ for (const visit of visits) {
1429
+ if (visit.annotation.kind === "expression") setAt(draft, visit.path, transformExpression(expressionSchema.parse(visit.value), {
1430
+ const: (node) => node,
1431
+ function: (node) => node,
1432
+ contextVariable: (node) => node,
1433
+ responseVariable: (node) => node,
1434
+ ownerState: (node) => ({
1435
+ ...node,
1436
+ targetRef: { ownerId: renewOwnerIds.get(node.targetRef.ownerId) ?? node.targetRef.ownerId }
1437
+ })
1438
+ }));
1439
+ if (visit.annotation.kind !== "identity" && !(visit.annotation.kind === "reference" && !visit.annotation.external && (visit.annotation.target === "owner" || visit.annotation.target === "state")) || typeof visit.value !== "string") continue;
1440
+ const to = renewOwnerIds.get(visit.value);
1441
+ if (!to) continue;
1442
+ if (visit.key) {
1443
+ const parent = at(draft, visit.path.slice(0, -1));
1444
+ if (!record(parent) || Object.hasOwn(parent, to)) throw new Error(`Cannot move owned content to '${to}'`);
1445
+ setOwnValue(parent, to, ownValue(parent, visit.value));
1446
+ delete parent[visit.value];
1447
+ } else setAt(draft, visit.path, to);
1448
+ }
1381
1449
  }
1450
+ });
1451
+ }
1452
+ addLayout(bodyOwnerId) {
1453
+ const body = this.current.bodies.get(bodyOwnerId);
1454
+ if (!body) throw new Error(`Unknown body owner '${bodyOwnerId}'`);
1455
+ const id = generateId();
1456
+ this.transact({ label: "Add layout" }, (draft) => {
1457
+ arrayAt(draft, [...body.path, "blocks"]).push({
1458
+ id,
1459
+ kind: "layout",
1460
+ layout: {
1461
+ mode: "automatic",
1462
+ maxColumns: 2
1463
+ },
1464
+ elements: []
1465
+ });
1466
+ });
1467
+ return id;
1468
+ }
1469
+ addSection(bodyOwnerId) {
1470
+ const body = this.current.bodies.get(bodyOwnerId);
1471
+ if (!body) throw new Error(`Unknown body owner '${bodyOwnerId}'`);
1472
+ const id = generateId();
1473
+ this.transact({ label: "Add section" }, (draft) => {
1474
+ arrayAt(draft, [...body.path, "blocks"]).push({
1475
+ id,
1476
+ kind: "section",
1477
+ body: { blocks: [{
1478
+ id: generateId(),
1479
+ kind: "layout",
1480
+ layout: {
1481
+ mode: "automatic",
1482
+ maxColumns: 1
1483
+ },
1484
+ elements: []
1485
+ }] }
1486
+ });
1487
+ });
1488
+ return id;
1489
+ }
1490
+ updateLayout(layoutId, layout) {
1491
+ this.transact({ label: "Change columns" }, (draft) => setAt(draft, [...this.identityPath(layoutId, "layout"), "layout"], layout));
1492
+ }
1493
+ removalScope(ids) {
1494
+ const paths = [];
1495
+ for (const id of new Set(ids)) {
1496
+ if (id === this.current.rootId) throw new Error("The root group cannot be removed");
1497
+ const declaration = this.current.identities.get(id);
1498
+ if (!declaration || ![
1499
+ "element",
1500
+ "layout",
1501
+ "section",
1502
+ "item"
1503
+ ].includes(declaration.kind)) throw new Error("Only structural nodes can be removed directly");
1504
+ if (declaration.kind === "item") {
1505
+ for (const item of this.current.data.surveyItems) if (this.current.isDescendantOf(item.id, id)) paths.push(this.identityPath(item.id));
1506
+ } else paths.push(this.identityPath(id));
1382
1507
  }
1383
- this.touchItemModifiedMetadata(target.parentId, timestamp);
1384
- return newItemId;
1508
+ const roots = paths.filter((path, index) => !paths.some((other, otherIndex) => otherIndex !== index && inside(other, path) && (other.length < path.length || otherIndex < index)));
1509
+ const includes = (path) => roots.some((root) => inside(root, path));
1510
+ return {
1511
+ roots,
1512
+ owned: new Set([...this.current.identities.values()].filter((entry) => includes(entry.path)).map((entry) => entry.id)),
1513
+ includes
1514
+ };
1515
+ }
1516
+ /** Includes whole flow subtrees; deletion never silently rewrites an authored rule. */
1517
+ deletionImpact(id) {
1518
+ const { owned, includes } = this.removalScope(typeof id === "string" ? [id] : id);
1519
+ return annotatedReferences(inspectAnnotated(this.current.registry.surveySchema, this.current.data)).filter((ref) => owned.has(ref.id) && !includes(ref.path) && ref.path[0] !== "translations" && !(ref.target === "item" && ref.path[0] === "surveyItems" && ref.path.includes("items")));
1520
+ }
1521
+ remove(id) {
1522
+ return this.removeNodes([id]);
1523
+ }
1524
+ removeItem(itemId) {
1525
+ if (!this.current.getItem(itemId)) return false;
1526
+ return this.removeItems([itemId]);
1527
+ }
1528
+ removeItems(itemIds) {
1529
+ if (itemIds.some((id) => !this.current.getItem(id))) throw new Error("Unknown flow item in removal request");
1530
+ return this.removeNodes(itemIds);
1531
+ }
1532
+ removeNodes(ids) {
1533
+ if (!ids.length) return false;
1534
+ const { roots, owned } = this.removalScope(ids);
1535
+ roots.sort((left, right) => {
1536
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
1537
+ const a = left[i], b = right[i];
1538
+ if (a !== b) return typeof a === "number" && typeof b === "number" ? b - a : String(b).localeCompare(String(a));
1539
+ }
1540
+ return right.length - left.length;
1541
+ });
1542
+ this.transact({ label: "Remove content" }, (draft) => {
1543
+ for (const path of roots) {
1544
+ const index = path.at(-1);
1545
+ if (typeof index !== "number") throw new Error("Structural content must belong to an ordered list");
1546
+ arrayAt(draft, path.slice(0, -1)).splice(index, 1);
1547
+ }
1548
+ for (const item of draft.surveyItems) if (item.itemType === "group") item.config.items = item.config.items.filter((child) => !owned.has(child));
1549
+ for (const translation of Object.values(draft.translations ?? {})) for (const owner of owned) delete translation.content[owner];
1550
+ });
1551
+ return true;
1552
+ }
1553
+ /** index is the final position after removal, as reported by composition sortables. */
1554
+ moveElement(elementId, layoutId, index, ruleTransfer = "reject") {
1555
+ this.moveCompositionContent(elementId, this.identityPath(elementId, "element"), [...this.identityPath(layoutId, "layout"), "elements"], layoutId, index, ruleTransfer);
1556
+ }
1557
+ /** Reorders/moves vertical blocks; index is the final position after removal. */
1558
+ moveBlock(blockId, bodyOwnerId, index, ruleTransfer = "reject") {
1559
+ const identity = this.current.identities.get(blockId);
1560
+ if (!identity || identity.kind !== "layout" && identity.kind !== "section") throw new Error("Only layouts and sections can move between bodies");
1561
+ const body = this.current.bodies.get(bodyOwnerId);
1562
+ if (!body) throw new Error(`Unknown body owner '${bodyOwnerId}'`);
1563
+ this.moveCompositionContent(blockId, this.identityPath(blockId), [...body.path, "blocks"], bodyOwnerId, index, ruleTransfer);
1564
+ }
1565
+ moveCompositionContent(elementId, source, destination, targetOwnerId, index, ruleTransfer) {
1566
+ if (inside(source, destination)) throw new Error("Content cannot move into its own descendant");
1567
+ const fromItem = this.containingItem(elementId), toItem = this.containingItem(targetOwnerId);
1568
+ const ownedSlots = new Set(this.current.slots.definitions.filter((slot) => {
1569
+ const owner = this.current.owners.get(slot.elementId);
1570
+ return owner && inside(source, owner.path);
1571
+ }).map((slot) => slot.slotId));
1572
+ this.transact({ label: "Move content" }, (draft) => {
1573
+ const elements = arrayAt(draft, destination);
1574
+ if (index !== void 0 && (!Number.isInteger(index) || index < 0 || index > elements.length)) throw new Error("Invalid content insertion index");
1575
+ const parent = arrayAt(draft, source.slice(0, -1));
1576
+ const position = source.at(-1);
1577
+ if (typeof position !== "number") throw new Error("Content must belong to an ordered list");
1578
+ if (fromItem !== toItem) {
1579
+ const oldItem = draft.surveyItems.find((item) => item.id === fromItem), newItem = draft.surveyItems.find((item) => item.id === toItem);
1580
+ if (oldItem?.itemType !== "content" || newItem?.itemType !== "content") throw new Error("Invalid content-item move");
1581
+ const affected = oldItem.config.validationRules?.filter((rule) => rule.targets.some((id) => ownedSlots.has(id))) ?? [];
1582
+ if (affected.length && (ruleTransfer !== "move-with-targets" || affected.some((rule) => rule.targets.some((id) => !ownedSlots.has(id))))) throw new Error("This move changes validation-rule ownership. Move all targets with explicit ruleTransfer or repair the rules first.");
1583
+ if (affected.length) {
1584
+ oldItem.config.validationRules = oldItem.config.validationRules?.filter((rule) => !affected.includes(rule));
1585
+ newItem.config.validationRules = [...newItem.config.validationRules ?? [], ...affected];
1586
+ }
1587
+ }
1588
+ const [element] = parent.splice(position, 1);
1589
+ elements.splice(index ?? elements.length, 0, element);
1590
+ });
1591
+ }
1592
+ /** Move selected roots into a new group under the first root's parent, as in the outline UI. */
1593
+ groupItems(itemIds, meta = { label: "Group selected items" }) {
1594
+ const requested = new Set(itemIds);
1595
+ if (!requested.size || requested.has(this.current.rootId) || [...requested].some((id) => !this.current.getItem(id))) throw new Error("Select existing non-root items to group");
1596
+ const roots = this.current.itemOutline.map((entry) => entry.itemId).filter((id) => requested.has(id) && ![...requested].some((other) => other !== id && this.current.isDescendantOf(id, other)));
1597
+ const parent = this.current.getParentItem(roots[0]);
1598
+ const id = generateId();
1599
+ this.transact(meta, (draft) => {
1600
+ const destination = draft.surveyItems.find((item) => item.id === parent.id);
1601
+ if (destination?.itemType !== "group") throw new Error("Grouping requires a flow parent");
1602
+ const index = destination.config.items.indexOf(roots[0]);
1603
+ destination.config.items.splice(index, 0, id);
1604
+ for (const item of draft.surveyItems) if (item.itemType === "group") item.config.items = item.config.items.filter((child) => !roots.includes(child));
1605
+ draft.surveyItems.push({
1606
+ id,
1607
+ itemType: "group",
1608
+ config: { items: roots }
1609
+ });
1610
+ });
1611
+ return id;
1612
+ }
1613
+ copyItem(itemId) {
1614
+ return copySurveyItem(this.current, itemId);
1615
+ }
1616
+ pasteItem(input, target) {
1617
+ if (this.current.getItem(target.parentId)?.itemType !== "group") throw new Error("Paste target must be a group");
1618
+ if (target.index !== void 0 && (!Number.isInteger(target.index) || target.index < 0)) throw new Error("Invalid paste index");
1619
+ const payload = prepareItemPaste(this.current, input);
1620
+ this.transact({ label: "Paste item" }, (draft) => {
1621
+ const group = draft.surveyItems.find((item) => item.id === target.parentId);
1622
+ if (group?.itemType !== "group") throw new Error("Paste target must be a group");
1623
+ group.config.items.splice(Math.min(target.index ?? group.config.items.length, group.config.items.length), 0, payload.rootItemId);
1624
+ draft.surveyItems.push(...payload.surveyItems);
1625
+ for (const [locale, translation] of Object.entries(payload.translations ?? {})) {
1626
+ if (!Object.values(translation.content).some((roles) => Object.keys(roles).length)) continue;
1627
+ draft.translations ??= {};
1628
+ const destination = ownValue(draft.translations, locale) ?? { content: {} };
1629
+ setOwnValue(draft.translations, locale, destination);
1630
+ for (const [ownerId, roles] of Object.entries(translation.content)) setOwnValue(destination.content, ownerId, roles);
1631
+ }
1632
+ if (Object.keys(payload.assets ?? {}).length) draft.assets = {
1633
+ ...draft.assets,
1634
+ ...payload.assets
1635
+ };
1636
+ });
1637
+ return payload.rootItemId;
1638
+ }
1639
+ copyElement(elementId, layoutId) {
1640
+ const sourcePath = this.identityPath(elementId, "element");
1641
+ const source = this.current.elements.get(elementId);
1642
+ const ids = new Map([...this.current.identities.values()].filter((declaration) => inside(sourcePath, declaration.path)).map((declaration) => [declaration.id, generateId()]));
1643
+ const copy = allocateCopiedResponseNames(this.current.registry.elementSchema, remapAnnotated(this.current.registry.elementSchema, source.node, ids), this.current.registry, new Set(this.current.slots.definitions.map((slot) => slot.variableName)));
1644
+ this.transact({ label: "Copy element" }, (draft) => {
1645
+ arrayAt(draft, [...this.identityPath(layoutId, "layout"), "elements"]).push(copy);
1646
+ for (const translation of Object.values(draft.translations ?? {})) for (const [oldId, newId] of ids) {
1647
+ const content = ownValue(translation.content, oldId);
1648
+ if (content) setOwnValue(translation.content, newId, structuredClone(content));
1649
+ }
1650
+ });
1651
+ return copy.id;
1385
1652
  }
1386
1653
  };
1387
1654
  //#endregion
1388
- export { CommitSource, ItemCopyPaste, SurveyEditor, SurveyEditorUndoRedo, allocateCopiedResponseNames, previewResponseNaming, previewResponseSettings };
1655
+ export { CommitSource, SURVEY_EDITOR_ITEM_COLORS, SurveyEditor, SurveyEditorUndoRedo, allocateCopiedResponseNames, copySurveyItem, createEditorSessionSchema, createItemClipboardSchema, getAnswerTypeChangeDiagnostics, prepareItemPaste, previewResponseNaming, previewResponseSettings };
1389
1656
 
1390
1657
  //# sourceMappingURL=editor.mjs.map