@case-framework/survey-core 0.7.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,211 +1,8 @@
1
- import { $ as generateId, nt as structuredCloneMethod, t as Survey, u as SurveyItemTranslations, v as GroupItemCore } from "./survey-BKblV5rN.mjs";
2
- //#region src/editor/item-copy-paste.ts
3
- var ItemCopyPaste = class ItemCopyPaste {
4
- survey;
5
- constructor(survey) {
6
- this.survey = survey;
7
- }
8
- /**
9
- * Copy a survey item and all its data to clipboard format
10
- * When copying a group, automatically includes all items in the subtree
11
- * @param itemId - The id of the item to copy
12
- * @returns Clipboard data that can be serialized to JSON for clipboard
13
- */
14
- copyItem(itemId) {
15
- if (!this.survey.surveyItems.get(itemId)) throw new Error(`Item with id '${itemId}' not found`);
16
- const itemsToCopy = this.collectItemsForCopy(itemId);
17
- const items = itemsToCopy.map((id) => {
18
- const item = this.survey.surveyItems.get(id);
19
- if (!item) throw new Error(`Item with id '${id}' not found during copy`);
20
- return {
21
- itemId: id,
22
- itemData: item.rawItem
23
- };
24
- });
25
- const translations = {};
26
- itemsToCopy.forEach((id) => {
27
- try {
28
- const itemTranslations = this.survey.getItemTranslations(id);
29
- if (itemTranslations?.locales?.length) {
30
- const serializedTranslations = {};
31
- itemTranslations.locales.forEach((locale) => {
32
- const localeContent = itemTranslations.getAllForLocale(locale);
33
- if (localeContent) serializedTranslations[locale] = localeContent;
34
- });
35
- translations[id] = serializedTranslations;
36
- } else translations[id] = {};
37
- } catch {
38
- translations[id] = {};
39
- }
40
- });
41
- return {
42
- type: "survey-item",
43
- version: "1.0.0",
44
- items,
45
- translations,
46
- rootItemId: itemId,
47
- timestamp: Date.now()
48
- };
49
- }
50
- /**
51
- * Collect all items that should be copied, including subtree for groups
52
- * @param itemId - The root item id
53
- * @returns Array of item keys to copy
54
- */
55
- collectItemsForCopy(itemId) {
56
- const itemsToCopy = [itemId];
57
- const item = this.survey.surveyItems.get(itemId);
58
- if (!item) return [];
59
- if (item instanceof GroupItemCore) {
60
- const childIds = item.items;
61
- if (childIds.length > 0) childIds.forEach((childId) => {
62
- const childItems = this.collectItemsForCopy(childId);
63
- itemsToCopy.push(...childItems);
64
- });
65
- }
66
- return itemsToCopy;
67
- }
68
- /**
69
- * Add an item to its parent group.
70
- * Ensures key uniqueness among siblings.
71
- * @param target - Target location information
72
- * @param item - The item to add (may have key adjusted for uniqueness)
73
- */
74
- addItemToParentGroup(target, item) {
75
- const parentGroup = this.survey.surveyItems.get(target.parentId);
76
- if (!parentGroup) throw new Error(`Parent item with id '${target.parentId}' not found`);
77
- if (!(parentGroup instanceof GroupItemCore)) throw new Error(`Parent item '${target.parentId}' is not a group item`);
78
- const siblings = parentGroup.items.map((id) => this.survey.surveyItems.get(id)).filter((s) => s !== void 0);
79
- const siblingKeys = new Set(siblings.map((s) => s.key));
80
- let uniqueKey = item.key;
81
- if (siblingKeys.has(uniqueKey)) {
82
- let counter = 1;
83
- const originalKey = item.key;
84
- while (siblingKeys.has(uniqueKey)) {
85
- uniqueKey = originalKey + `_${counter}`;
86
- counter++;
87
- }
88
- if (uniqueKey !== item.key) {
89
- const updatedRawItem = {
90
- ...item.rawItem,
91
- key: uniqueKey
92
- };
93
- const newItem = this.survey.createItemFromRaw(updatedRawItem);
94
- this.survey.surveyItems.delete(item.id);
95
- this.survey.surveyItems.set(newItem.id, newItem);
96
- item = newItem;
97
- }
98
- }
99
- const insertIndex = target.index !== void 0 ? Math.min(target.index, parentGroup.items.length) : parentGroup.items.length;
100
- parentGroup.addChild(item.id, insertIndex);
101
- }
102
- /**
103
- * Paste a survey item from clipboard data to a target location
104
- * Handles multiple items and subtrees from the clipboard data
105
- * @param clipboardData - The clipboard data containing the item(s) to paste
106
- * @param target - Target location where to paste the item
107
- * @returns The id of the pasted root item
108
- */
109
- pasteItem(clipboardData, target) {
110
- if (!ItemCopyPaste.isValidClipboardData(clipboardData)) throw new Error("Invalid clipboard data format");
111
- const idMapping = {};
112
- clipboardData.items.forEach(({ itemId }) => {
113
- idMapping[itemId] = generateId();
114
- });
115
- clipboardData.items.forEach(({ itemId, itemData }) => {
116
- const newId = idMapping[itemId];
117
- if (newId) {
118
- const updatedItemData = this.updateItemIdsInData(itemData, idMapping);
119
- updatedItemData.id = newId;
120
- const newItem = this.survey.createItemFromRaw(updatedItemData);
121
- this.survey.surveyItems.set(newItem.id, newItem);
122
- if (itemId === clipboardData.rootItemId) this.addItemToParentGroup(target, newItem);
123
- }
124
- });
125
- const updatedTranslations = this.updateTranslations(clipboardData.translations, idMapping);
126
- Object.keys(updatedTranslations).forEach((itemId) => {
127
- const itemTranslations = new SurveyItemTranslations();
128
- const serializedData = updatedTranslations[itemId];
129
- Object.keys(serializedData).forEach((locale) => {
130
- itemTranslations.setAllForLocale(locale, serializedData[locale]);
131
- });
132
- this.survey.translations.setItemTranslations(itemId, itemTranslations);
133
- });
134
- return idMapping[clipboardData.rootItemId];
135
- }
136
- /**
137
- * Update all item ids in the raw item data recursively
138
- */
139
- updateItemIdsInData(itemData, idMapping) {
140
- const updatedData = JSON.parse(JSON.stringify(itemData));
141
- if (updatedData.itemType === "group" && updatedData.config) {
142
- const config = updatedData.config;
143
- if (config.items) config.items = config.items.map((childId) => idMapping[childId] ?? childId);
144
- }
145
- this.updateExpressionsInItemData(updatedData, idMapping);
146
- return updatedData;
147
- }
148
- /**
149
- * Update expressions in item data that reference the old ids
150
- */
151
- updateExpressionsInItemData(itemData, idMapping) {
152
- if (itemData.displayConditions?.root) this.updateExpressionReferences(itemData.displayConditions.root, idMapping);
153
- if (itemData.displayConditions?.components) Object.values(itemData.displayConditions.components).forEach((expr) => {
154
- if (expr) this.updateExpressionReferences(expr, idMapping);
155
- });
156
- if (itemData.disabledConditions?.components) Object.values(itemData.disabledConditions.components).forEach((expr) => {
157
- if (expr) this.updateExpressionReferences(expr, idMapping);
158
- });
159
- if (itemData.validations) Object.values(itemData.validations).forEach((expr) => {
160
- if (expr) this.updateExpressionReferences(expr, idMapping);
161
- });
162
- if (itemData.prefills) itemData.prefills.forEach((prefill) => {
163
- if (prefill.when) this.updateExpressionReferences(prefill.when, idMapping);
164
- if (prefill.source.type === "expression") this.updateExpressionReferences(prefill.source.expression, idMapping);
165
- if (prefill.source.type === "previousResponse") prefill.source.ref.itemId = idMapping[prefill.source.ref.itemId] ?? prefill.source.ref.itemId;
166
- });
167
- }
168
- /**
169
- * Update references in a single expression (recursive)
170
- */
171
- updateExpressionReferences(expression, idMapping) {
172
- if (!expression || typeof expression !== "object") return;
173
- const expr = expression;
174
- if (Array.isArray(expr.data)) expr.data.forEach((arg) => {
175
- if (arg && typeof arg === "object" && arg !== null && "str" in arg) {
176
- const argObj = arg;
177
- if (typeof argObj.str === "string" && Object.prototype.hasOwnProperty.call(idMapping, argObj.str)) argObj.str = idMapping[argObj.str];
178
- this.updateExpressionReferences(arg, idMapping);
179
- }
180
- });
181
- Object.keys(expr).forEach((key) => {
182
- const val = expr[key];
183
- if (val && typeof val === "object") this.updateExpressionReferences(val, idMapping);
184
- });
185
- }
186
- /**
187
- * Update translation keys for the pasted item
188
- */
189
- updateTranslations(translations, idMapping) {
190
- const newTranslations = {};
191
- Object.keys(translations).forEach((itemId) => {
192
- const itemTranslations = translations[itemId];
193
- const newItemId = idMapping[itemId];
194
- newTranslations[newItemId] = itemTranslations;
195
- });
196
- return newTranslations;
197
- }
198
- /**
199
- * Validate clipboard data format
200
- */
201
- static isValidClipboardData(data) {
202
- if (typeof data !== "object" || data === null || data === void 0) return false;
203
- const clipboardData = data;
204
- 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;
205
- 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);
206
- }
207
- };
208
- //#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";
209
6
  //#region src/editor/undo-redo.ts
210
7
  const CommitSource = {
211
8
  USER: "user",
@@ -239,34 +36,17 @@ function cloneAssets(assets) {
239
36
  return structuredCloneMethod(assets);
240
37
  }
241
38
  function normalizeHistoryEntry(entry) {
242
- if ("kind" in entry) {
243
- if (entry.kind === "survey-snapshot") return {
244
- kind: "survey-snapshot",
245
- survey: structuredCloneMethod(entry.survey),
246
- timestamp: entry.timestamp,
247
- meta: entry.meta,
248
- memorySize: entry.memorySize
249
- };
250
- return {
251
- kind: "asset-change",
252
- changes: structuredCloneMethod(entry.changes),
253
- timestamp: entry.timestamp,
254
- meta: entry.meta,
255
- memorySize: entry.memorySize
256
- };
257
- }
39
+ if (entry.kind !== "survey-snapshot" && entry.kind !== "asset-change") throw new Error("Unsupported history entry kind");
258
40
  return {
259
- kind: "survey-snapshot",
260
- survey: structuredCloneMethod(entry.survey),
261
- timestamp: entry.timestamp,
262
- meta: entry.meta,
263
- memorySize: entry.memorySize
41
+ ...structuredCloneMethod(entry),
42
+ id: entry.id ?? generateId()
264
43
  };
265
44
  }
266
45
  var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
267
46
  history = [];
268
47
  currentIndex = -1;
269
48
  _config;
49
+ baseAssetSize;
270
50
  _initialAssets;
271
51
  constructor(initialSurvey, config = {}, meta = {
272
52
  label: "Initial state",
@@ -284,7 +64,8 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
284
64
  saveEntry(entry) {
285
65
  const payload = entry.kind === "survey-snapshot" ? {
286
66
  kind: entry.kind,
287
- survey: entry.survey
67
+ survey: entry.survey,
68
+ changes: entry.changes
288
69
  } : {
289
70
  kind: entry.kind,
290
71
  changes: entry.changes
@@ -293,6 +74,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
293
74
  this.history = this.history.slice(0, this.currentIndex + 1);
294
75
  this.history.push({
295
76
  ...structuredCloneMethod(entry),
77
+ id: generateId(),
296
78
  timestamp: Date.now(),
297
79
  memorySize
298
80
  });
@@ -302,12 +84,12 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
302
84
  saveSnapshot(survey, meta) {
303
85
  this.saveEntry({
304
86
  kind: "survey-snapshot",
305
- survey: structuredCloneMethod(survey),
87
+ survey,
306
88
  meta
307
89
  });
308
90
  }
309
91
  getBaseAssetsSize() {
310
- return this._initialAssets ? MemoryCalculator.calculateSize(this._initialAssets) : 0;
92
+ return this.baseAssetSize ??= this._initialAssets ? MemoryCalculator.calculateSize(this._initialAssets) : 0;
311
93
  }
312
94
  cleanupHistory() {
313
95
  const maxMemoryBytes = this._config.maxTotalMemoryMB * 1024 * 1024;
@@ -321,6 +103,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
321
103
  const nextEntry = this.history[nextStateIndex];
322
104
  const newFirstEntry = {
323
105
  kind: "survey-snapshot",
106
+ id: nextEntry.id,
324
107
  survey: nextStateSurvey,
325
108
  meta: nextEntry.meta,
326
109
  timestamp: nextEntry.timestamp,
@@ -330,8 +113,9 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
330
113
  })
331
114
  };
332
115
  const removedBytes = this.history[0].memorySize;
333
- this._initialAssets = cloneAssets(nextStateAssets);
334
- this.history = [newFirstEntry, ...this.history.slice(nextStateIndex + 1)];
116
+ this._initialAssets = nextStateAssets;
117
+ this.baseAssetSize = void 0;
118
+ this.history = [newFirstEntry, ...this.history.slice(2)];
335
119
  this.currentIndex--;
336
120
  return removedBytes;
337
121
  }
@@ -347,8 +131,8 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
347
131
  throw new Error("Invalid history state");
348
132
  }
349
133
  applyAssetPatch(assets, patch) {
350
- const nextAssets = structuredCloneMethod(assets);
351
- if (patch.next) nextAssets[patch.assetId] = structuredCloneMethod(patch.next);
134
+ const nextAssets = assets;
135
+ if (patch.next) setOwnValue(nextAssets, patch.assetId, structuredCloneMethod(patch.next));
352
136
  else delete nextAssets[patch.assetId];
353
137
  return nextAssets;
354
138
  }
@@ -357,13 +141,23 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
357
141
  let assets = cloneAssets(this._initialAssets) ?? {};
358
142
  for (let i = 1; i <= index; i++) {
359
143
  const entry = this.history[i];
360
- if (entry.kind !== "asset-change") continue;
361
- for (const patch of entry.changes) assets = this.applyAssetPatch(assets, patch);
144
+ for (const patch of entry.changes ?? []) assets = this.applyAssetPatch(assets, patch);
362
145
  }
363
146
  return Object.keys(assets).length > 0 ? assets : void 0;
364
147
  }
365
- commit(survey, meta) {
366
- this.saveSnapshot(survey, meta);
148
+ getEntryId(index) {
149
+ return this.history[index]?.id;
150
+ }
151
+ findEntryIndex(id) {
152
+ return this.history.findIndex((entry) => entry.id === id);
153
+ }
154
+ commit(survey, meta, changes) {
155
+ this.saveEntry({
156
+ kind: "survey-snapshot",
157
+ survey,
158
+ meta,
159
+ ...changes?.length ? { changes } : {}
160
+ });
367
161
  }
368
162
  commitAssetChange(changes, meta) {
369
163
  if (changes.length === 0) return;
@@ -407,7 +201,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
407
201
  }
408
202
  getMemoryUsage() {
409
203
  return {
410
- totalMB: this.getTotalMemoryUsage() / (1024 * 1024),
204
+ totalMB: this.getTotalMemoryUsage() / 1048576,
411
205
  entries: this.history.length
412
206
  };
413
207
  }
@@ -422,6 +216,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
422
216
  index,
423
217
  kind: entry.kind,
424
218
  meta: entry.meta,
219
+ id: entry.id ?? generateId(),
425
220
  timestamp: entry.timestamp,
426
221
  memorySize: entry.memorySize,
427
222
  isCurrent: index === this.currentIndex
@@ -440,6 +235,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
440
235
  index,
441
236
  kind: entry.kind,
442
237
  meta: entry.meta,
238
+ id: entry.id ?? generateId(),
443
239
  timestamp: entry.timestamp,
444
240
  memorySize: entry.memorySize,
445
241
  isCurrent: index === this.currentIndex
@@ -484,6 +280,8 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
484
280
  if (entry.kind === "survey-snapshot") return {
485
281
  kind: "survey-snapshot",
486
282
  survey: entry.survey,
283
+ changes: entry.changes,
284
+ id: entry.id ?? generateId(),
487
285
  timestamp: entry.timestamp,
488
286
  meta: entry.meta,
489
287
  memorySize: entry.memorySize
@@ -491,6 +289,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
491
289
  return {
492
290
  kind: "asset-change",
493
291
  changes: entry.changes,
292
+ id: entry.id ?? generateId(),
494
293
  timestamp: entry.timestamp,
495
294
  meta: entry.meta,
496
295
  memorySize: entry.memorySize
@@ -512,9 +311,10 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
512
311
  if (!jsonData.config) throw new Error("Invalid object: config is required");
513
312
  const normalizedHistory = jsonData.history.map(normalizeHistoryEntry);
514
313
  const firstEntry = normalizedHistory[0];
515
- const instance = new SurveyEditorUndoRedo(firstEntry.kind === "survey-snapshot" ? firstEntry.survey : (() => {
314
+ const firstSurvey = firstEntry.kind === "survey-snapshot" ? firstEntry.survey : (() => {
516
315
  throw new Error("Invalid object: first history entry must resolve to a survey snapshot");
517
- })(), jsonData.config, firstEntry.meta, initialAssetsOverride ?? jsonData.initialAssets);
316
+ })();
317
+ const instance = new SurveyEditorUndoRedo(firstSurvey, jsonData.config, firstEntry.meta, initialAssetsOverride ?? jsonData.initialAssets);
518
318
  instance.history = normalizedHistory;
519
319
  instance.currentIndex = jsonData.currentIndex;
520
320
  instance._initialAssets = cloneAssets(initialAssetsOverride ?? jsonData.initialAssets);
@@ -522,530 +322,1336 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
522
322
  }
523
323
  };
524
324
  //#endregion
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;
332
+ }
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);
340
+ }
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);
371
+ }
372
+ }
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
525
633
  //#region src/editor/survey-editor.ts
526
- function stripAssetsFromSurveySnapshot(survey) {
527
- const snapshot = structuredCloneMethod(survey);
528
- delete snapshot.assets;
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;
529
656
  return snapshot;
530
657
  }
531
- function mergeSurveySnapshotWithAssets(snapshot, assets) {
532
- const merged = structuredCloneMethod(snapshot);
533
- if (assets && Object.keys(assets).length > 0) merged.assets = structuredCloneMethod(assets);
534
- else delete merged.assets;
535
- return merged;
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);
536
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. */
537
722
  var SurveyEditor = class SurveyEditor {
538
- _survey;
539
- _undoRedo;
540
- _hasUncommittedChanges = false;
541
- _pluginRegistry;
542
- constructor(survey, config = {}, meta) {
543
- this._survey = survey;
544
- const { pluginRegistry, ...undoRedoConfig } = config;
545
- this._pluginRegistry = pluginRegistry;
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);
546
742
  const serialized = survey.serialize();
547
- this._undoRedo = new SurveyEditorUndoRedo(stripAssetsFromSurveySnapshot(serialized), undoRedoConfig, meta, serialized.assets);
743
+ this._history = new SurveyEditorUndoRedo(stripAssets(serialized), config, meta, serialized.assets);
548
744
  }
549
- /** Returns an immutable copy of the current survey state. */
550
745
  get survey() {
551
- const serialized = this._survey.serialize();
552
- const registry = this._pluginRegistry ?? this._survey.getPluginRegistry();
553
- return Survey.fromJson(serialized, registry);
746
+ return this.current;
747
+ }
748
+ get history() {
749
+ return this._history;
750
+ }
751
+ getAnswerTypes() {
752
+ return [...this.answerTypes];
554
753
  }
555
754
  get hasUncommittedChanges() {
556
- return this._hasUncommittedChanges;
755
+ return this.pendingSurvey || this.pendingAssets.length > 0;
557
756
  }
558
- get undoRedo() {
559
- return this._undoRedo;
757
+ get hasCommittedChanges() {
758
+ return this.history.getCurrentIndex() > 0;
560
759
  }
561
- commit(meta) {
562
- if (!this._hasUncommittedChanges) return;
563
- this._undoRedo.commit(stripAssetsFromSurveySnapshot(this._survey.serialize()), meta);
564
- this._hasUncommittedChanges = false;
760
+ subscribe(listener) {
761
+ this.listeners.add(listener);
762
+ return () => {
763
+ this.listeners.delete(listener);
764
+ };
565
765
  }
566
- restoreHistoryState(snapshot, assets) {
567
- this._survey = Survey.fromJson(mergeSurveySnapshotWithAssets(snapshot, assets), this._pluginRegistry);
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();
568
950
  }
569
951
  commitIfNeeded() {
570
- if (this._hasUncommittedChanges) this.commit({
952
+ this.commit(this.pendingMeta ?? {
571
953
  label: "Latest content changes",
572
954
  source: CommitSource.SYSTEM
573
955
  });
574
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"
965
+ });
966
+ this.pendingSurvey = false;
967
+ this.pendingAssets = [];
968
+ this.pendingMeta = void 0;
969
+ this.publish(next);
970
+ }
575
971
  undo() {
576
- if (this._hasUncommittedChanges) {
577
- this.restoreHistoryState(this._undoRedo.getCurrentState(), this._undoRedo.getCurrentAssets());
578
- this._hasUncommittedChanges = false;
972
+ if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
973
+ if (this.hasUncommittedChanges) {
974
+ this.restoreHistoryState(this.history.getCurrentState());
579
975
  return true;
580
- } else {
581
- const previousState = this._undoRedo.undo();
582
- if (previousState) {
583
- this.restoreHistoryState(previousState, this._undoRedo.getCurrentAssets());
584
- this._hasUncommittedChanges = false;
585
- return true;
586
- }
587
- return false;
588
976
  }
977
+ const value = this.history.undo();
978
+ if (!value) return false;
979
+ this.restoreHistoryState(value);
980
+ return true;
589
981
  }
590
982
  redo() {
591
- if (this._hasUncommittedChanges) return false;
592
- const nextState = this._undoRedo.redo();
593
- if (nextState) {
594
- this.restoreHistoryState(nextState, this._undoRedo.getCurrentAssets());
595
- this._hasUncommittedChanges = false;
596
- return true;
597
- }
598
- return false;
983
+ if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
984
+ if (this.hasUncommittedChanges) return false;
985
+ const value = this.history.redo();
986
+ if (!value) return false;
987
+ this.restoreHistoryState(value);
988
+ return true;
599
989
  }
600
- /**
601
- * Jump to a specific index in the history (can go forward or backward)
602
- */
603
- jumpToIndex(targetIndex) {
604
- if (this._hasUncommittedChanges) return false;
605
- const targetState = this._undoRedo.jumpToIndex(targetIndex);
606
- if (targetState) {
607
- this.restoreHistoryState(targetState, this._undoRedo.getCurrentAssets());
608
- this._hasUncommittedChanges = false;
609
- return true;
610
- }
611
- return false;
990
+ jumpToIndex(index) {
991
+ if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
992
+ if (this.hasUncommittedChanges) return false;
993
+ const value = this.history.jumpToIndex(index);
994
+ if (!value) return false;
995
+ this.restoreHistoryState(value);
996
+ return true;
612
997
  }
613
998
  canUndo() {
614
- return this._hasUncommittedChanges || this._undoRedo.canUndo();
999
+ return this.hasUncommittedChanges || this.history.canUndo();
615
1000
  }
616
1001
  canRedo() {
617
- return !this._hasUncommittedChanges && this._undoRedo.canRedo();
1002
+ return !this.hasUncommittedChanges && this.history.canRedo();
618
1003
  }
619
1004
  getUndoMeta() {
620
- if (this._hasUncommittedChanges) return {
621
- label: "Latest content changes",
622
- source: CommitSource.SYSTEM
623
- };
624
- return this._undoRedo.getUndoMeta();
1005
+ return this.hasUncommittedChanges ? this.pendingMeta ?? null : this.history.getUndoMeta();
625
1006
  }
626
1007
  getRedoMeta() {
627
- if (this._hasUncommittedChanges) return null;
628
- return this._undoRedo.getRedoMeta();
1008
+ return this.hasUncommittedChanges ? null : this.history.getRedoMeta();
629
1009
  }
630
1010
  getMemoryUsage() {
631
- return this._undoRedo.getMemoryUsage();
1011
+ return this.history.getMemoryUsage();
632
1012
  }
633
1013
  getUndoRedoConfig() {
634
- return this._undoRedo.getConfig();
1014
+ return this.history.getConfig();
635
1015
  }
636
- /**
637
- * Get committed changes after the initial editor state up to the current undo/redo position.
638
- */
639
1016
  getCommitsSinceInitial() {
640
- return this._undoRedo.getCommitsSinceInitial();
1017
+ return this.history.getCommitsSinceInitial();
641
1018
  }
642
- /**
643
- * Serialize the SurveyEditor state to JSON
644
- * @returns A JSON-serializable object containing the complete editor state
645
- */
646
1019
  toJson() {
647
1020
  return {
648
- version: "1.0.0",
649
- survey: this._survey.serialize(),
650
- undoRedo: this._undoRedo.serialize(),
651
- 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()
652
1026
  };
653
1027
  }
654
- /**
655
- * Create a new SurveyEditor instance from JSON data
656
- * @param jsonData The serialized editor state
657
- * @param pluginRegistry Optional plugin registry for deserializing survey state (required when survey contains custom item types)
658
- * @returns A new SurveyEditor instance with the restored state
659
- */
660
- static fromJson(jsonData, pluginRegistry) {
661
- if (!jsonData.survey) throw new Error("Invalid object: survey is required");
662
- if (!jsonData.undoRedo) throw new Error("Invalid object: undoRedo is required");
663
- if (typeof jsonData.hasUncommittedChanges !== "boolean") throw new Error("Invalid object: hasUncommittedChanges must be a boolean");
664
- if (jsonData.version && !jsonData.version.startsWith("1.")) console.warn(`Warning: Loading SurveyEditor with version ${jsonData.version}, current version is 1.0.0`);
665
- const editor = new SurveyEditor(Survey.fromJson(jsonData.survey, pluginRegistry), { pluginRegistry });
666
- editor._undoRedo = SurveyEditorUndoRedo.deserialize({
667
- ...jsonData.undoRedo,
668
- history: jsonData.undoRedo.history.map((entry) => "survey" in entry ? {
669
- ...entry,
670
- survey: stripAssetsFromSurveySnapshot(entry.survey)
671
- } : entry)
672
- }, jsonData.survey.assets);
673
- editor._hasUncommittedChanges = JSON.stringify(stripAssetsFromSurveySnapshot(jsonData.survey)) !== JSON.stringify(editor._undoRedo.getCurrentState());
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");
674
1073
  return editor;
675
1074
  }
676
- markAsModified() {
677
- this._hasUncommittedChanges = true;
678
- }
679
- createEditorTimestamp() {
680
- return (/* @__PURE__ */ new Date()).toISOString();
1075
+ getAsset(id) {
1076
+ return structuredClone(ownValue(this.current.data.assets, id));
681
1077
  }
682
- touchItemModifiedMetadata(itemId, timestamp = this.createEditorTimestamp()) {
683
- const item = this._survey.surveyItems.get(itemId);
684
- if (!item) return;
685
- item.setModifiedAt(timestamp);
1078
+ getAssets() {
1079
+ return new Map(Object.entries(structuredClone(this.current.data.assets ?? {})));
686
1080
  }
687
1081
  addItem(target, item, content) {
688
- if (this._survey.surveyItems.has(item.id)) throw new Error(`Item with id '${item.id}' already exists in the survey`);
689
- this.markAsModified();
690
- const timestamp = this.createEditorTimestamp();
691
- let parentGroup;
692
- if (!target) {
693
- const rootItem = this._survey.rootItem;
694
- if (!rootItem) throw new Error("No root group found in survey");
695
- if (!(rootItem instanceof GroupItemCore)) throw new Error("Root item is not a group item");
696
- parentGroup = rootItem;
697
- } else {
698
- const targetItem = this._survey.surveyItems.get(target.parentId);
699
- if (!targetItem) throw new Error(`Parent item with id '${target.parentId}' not found`);
700
- if (!(targetItem instanceof GroupItemCore)) throw new Error(`Parent item '${target.parentId}' is not a group item (${targetItem.type})`);
701
- parentGroup = targetItem;
702
- }
703
- if (parentGroup.hasChild(item.id)) throw new Error(`Item ${item.id} already in this group`);
704
- const siblings = Array.from(this._survey.surveyItems.values()).filter((sItem) => parentGroup.items?.includes(sItem.id));
705
- let counter = 1;
706
- const originalKey = item.key;
707
- let uniqueKey = originalKey;
708
- while (siblings.some((sibling) => sibling.key === uniqueKey)) {
709
- uniqueKey = `${originalKey}_${counter}`;
710
- counter++;
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);
711
1097
  }
712
- let insertIndex;
713
- if (target?.index !== void 0) insertIndex = Math.min(target.index, parentGroup.items.length);
714
- else insertIndex = parentGroup.items.length;
715
- item.updateRawItem({
716
- ...item.rawItem,
717
- key: uniqueKey
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
+ }
1113
+ }
718
1114
  });
719
- item.setCreatedAt(timestamp);
720
- item.setModifiedAt(timestamp);
721
- this._survey.surveyItems.set(item.id, item);
722
- parentGroup.addChild(item.id, insertIndex);
723
- this.touchItemModifiedMetadata(parentGroup.id, timestamp);
724
- if (content) this._survey.translations.setItemTranslations(item.id, content);
725
- }
726
- removeItem(itemId, nested = false) {
727
- this.markAsModified();
728
- const timestamp = this.createEditorTimestamp();
729
- const item = this._survey.surveyItems.get(itemId);
730
- if (!item) return false;
731
- const parentItem = this._survey.getParentItem(itemId);
732
- if (!parentItem) throw new Error(`Item with id '${itemId}' is the root item`);
733
- if (item instanceof GroupItemCore) for (const childId of item.getChildrenIds()) this.removeItem(childId, true);
734
- this._survey.surveyItems.delete(itemId);
735
- this._survey.translations?.onItemDeleted(itemId);
736
- if (!nested) {
737
- parentItem.removeChild(itemId);
738
- this.touchItemModifiedMetadata(parentItem.id, timestamp);
739
- }
740
- return true;
741
1115
  }
742
- moveItem(itemId, newTarget) {
743
- this.markAsModified();
744
- const timestamp = this.createEditorTimestamp();
745
- const item = this._survey.surveyItems.get(itemId);
746
- if (!item) throw new Error(`Item with id '${itemId}' not found`);
747
- const targetItem = this._survey.surveyItems.get(newTarget.parentId);
748
- if (!targetItem) throw new Error(`Target parent with id '${newTarget.parentId}' not found`);
749
- if (!(targetItem instanceof GroupItemCore)) throw new Error(`Target parent '${newTarget.parentId}' is not a group item (${targetItem.type})`);
750
- if (this._survey.isDescendantOf(newTarget.parentId, itemId)) throw new Error(`Cannot move item '${itemId}' to its descendant '${newTarget.parentId}'`);
751
- const currentParentItem = this._survey.getParentItem(itemId);
752
- if (currentParentItem?.id === newTarget.parentId) throw new Error(`Item '${itemId}' is already in the target parent '${newTarget.parentId}'`);
753
- if (currentParentItem) {
754
- const currentParentGroup = currentParentItem;
755
- currentParentGroup.removeChild(itemId);
756
- this.touchItemModifiedMetadata(currentParentGroup.id, timestamp);
757
- }
758
- const targetGroup = targetItem;
759
- const siblings = Array.from(this._survey.surveyItems.values()).filter((sItem) => targetGroup.hasChild(sItem.id));
760
- let counter = 1;
761
- let uniqueKey = item.key;
762
- while (siblings.some((sibling) => sibling.key === uniqueKey)) {
763
- uniqueKey = `${item.key}_${counter}`;
764
- counter++;
765
- }
766
- if (uniqueKey !== item.key) item.updateRawItem({
767
- ...item.rawItem,
768
- key: uniqueKey
1116
+ getItemDataCopy(itemId) {
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);
769
1133
  });
770
- const insertIndex = newTarget.index !== void 0 ? Math.min(newTarget.index, targetGroup.items.length) : targetGroup.items.length;
771
- targetGroup.addChild(itemId, insertIndex);
772
- this.touchItemModifiedMetadata(targetGroup.id, timestamp);
773
- this.touchItemModifiedMetadata(itemId, timestamp);
774
- return true;
775
1134
  }
776
- /** Move several independent item subtrees as one ordered block. */
777
- moveItems(itemIds, newTarget) {
778
- const requestedIds = [...new Set(itemIds)];
779
- const selectedIds = requestedIds.filter((itemId) => !requestedIds.some((candidateId) => candidateId !== itemId && this._survey.isDescendantOf(itemId, candidateId)));
780
- if (selectedIds.length === 0) return false;
781
- const targetGroup = this._survey.surveyItems.get(newTarget.parentId);
782
- if (!(targetGroup instanceof GroupItemCore)) throw new Error(`Target parent '${newTarget.parentId}' is not a group item`);
783
- const entries = selectedIds.map((itemId) => {
784
- const item = this._survey.surveyItems.get(itemId);
785
- const parent = this._survey.getParentItem(itemId);
786
- if (!item || !(parent instanceof GroupItemCore)) throw new Error(`Item '${itemId}' cannot be moved`);
787
- if (itemId === newTarget.parentId || this._survey.isDescendantOf(newTarget.parentId, itemId)) throw new Error(`Cannot move item '${itemId}' into its own subtree`);
788
- return {
789
- item,
790
- parent,
791
- index: parent.items.indexOf(itemId)
792
- };
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
+ });
793
1158
  });
794
- const order = /* @__PURE__ */ new Map();
795
- let orderIndex = 0;
796
- const visit = (group) => {
797
- for (const childId of group.items) {
798
- order.set(childId, orderIndex++);
799
- const child = this._survey.surveyItems.get(childId);
800
- if (child instanceof GroupItemCore) visit(child);
801
- }
802
- };
803
- const root = this._survey.rootItem;
804
- if (root) visit(root);
805
- entries.sort((a, b) => (order.get(a.item.id) ?? 0) - (order.get(b.item.id) ?? 0));
806
- const selectedSet = new Set(entries.map(({ item }) => item.id));
807
- const requestedIndex = newTarget.index ?? targetGroup.items.length;
808
- const removedBeforeTarget = targetGroup.items.slice(0, requestedIndex).filter((id) => selectedSet.has(id)).length;
809
- const insertionIndex = Math.max(0, requestedIndex - removedBeforeTarget);
810
- const timestamp = this.createEditorTimestamp();
811
- this.markAsModified();
812
- for (const { item, parent } of entries) {
813
- parent.removeChild(item.id);
814
- this.touchItemModifiedMetadata(parent.id, timestamp);
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" });
815
1163
  }
816
- const siblingKeys = new Set(targetGroup.items.map((id) => this._survey.surveyItems.get(id)?.key).filter((key) => Boolean(key)));
817
- entries.forEach(({ item }, offset) => {
818
- const originalKey = item.key;
819
- let uniqueKey = originalKey;
820
- let counter = 1;
821
- while (siblingKeys.has(uniqueKey)) uniqueKey = `${originalKey}_${counter++}`;
822
- if (uniqueKey !== item.key) item.updateRawItem({
823
- ...item.rawItem,
824
- key: uniqueKey
825
- });
826
- siblingKeys.add(uniqueKey);
827
- targetGroup.addChild(item.id, insertionIndex + offset);
828
- this.touchItemModifiedMetadata(item.id, timestamp);
1164
+ return changed;
1165
+ }
1166
+ renameResponseVariables(edits) {
1167
+ return this.updateResponseSettings(edits, []);
1168
+ }
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;
1175
+ }
1176
+ addLocale(locale) {
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: {} });
829
1182
  });
830
- this.touchItemModifiedMetadata(targetGroup.id, timestamp);
831
1183
  return true;
832
1184
  }
833
- /**
834
- * Get a deep copy of an item's raw data.
835
- * The returned object is independent of the survey—mutating it will not affect the survey.
836
- * Use this when you need to edit an item in a form or pass it to updateItem after modifications.
837
- *
838
- * @param itemId - The ID of the item to copy
839
- * @returns A deep copy of the item's RawSurveyItem data
840
- * @throws Error if the item is not found
841
- */
842
- getItemDataCopy(itemId) {
843
- const item = this._survey.surveyItems.get(itemId);
844
- if (!item) throw new Error(`Item with id '${itemId}' not found`);
845
- return JSON.parse(JSON.stringify(item.rawItem));
1185
+ removeLocale(locale) {
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
+ });
1195
+ return true;
846
1196
  }
847
- /**
848
- * Update an item's data in the survey.
849
- * Can update common attributes (displayConditions, disabledConditions, validations, etc.)
850
- * as well as type-specific config.
851
- *
852
- * Use getItemDataCopy to obtain an editable copy, modify it, then pass it here.
853
- * Alternatively, pass a Partial to merge specific fields (top-level keys only;
854
- * e.g. config replaces the entire config object).
855
- *
856
- * @param itemId - The ID of the item to update (must match data.id if provided)
857
- * @param rawItemData - Full RawSurveyItem or Partial to merge. The id cannot be changed.
858
- */
859
- updateItem(itemId, rawItemData) {
860
- const existingItem = this._survey.surveyItems.get(itemId);
861
- if (!existingItem) throw new Error(`Item with id '${itemId}' not found`);
862
- if ("id" in rawItemData && rawItemData.id !== void 0 && rawItemData.id !== itemId) throw new Error(`Cannot change item id from '${itemId}' to '${rawItemData.id}'`);
863
- const merged = {
864
- ...existingItem.rawItem,
865
- ...rawItemData,
866
- id: itemId
867
- };
868
- const newKey = merged.key ?? existingItem.key;
869
- const parentGroup = this._survey.getParentItem(itemId);
870
- if (parentGroup) {
871
- const siblingWithSameKey = (parentGroup.items ?? []).filter((id) => id !== itemId).map((id) => this._survey.surveyItems.get(id)).find((s) => s !== void 0 && s.key === newKey);
872
- if (siblingWithSameKey) throw new Error(`Key '${newKey}' is already in use by sibling item '${siblingWithSameKey.id}'`);
873
- }
874
- const newItem = this._survey.createItemFromRaw({
875
- ...merged,
876
- editorMetadata: existingItem.rawItem.editorMetadata
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;
877
1203
  });
878
- newItem.setModifiedAt(this.createEditorTimestamp());
879
- this._survey.surveyItems.set(itemId, newItem);
880
- this.markAsModified();
881
- }
882
- updateItemTranslations(itemId, updatedContent) {
883
- const item = this._survey.surveyItems.get(itemId);
884
- if (!item) return false;
885
- this.markAsModified();
886
- item.setModifiedAt(this.createEditorTimestamp());
887
- this._survey.translations.setItemTranslations(itemId, updatedContent);
888
1204
  return true;
889
1205
  }
890
- /**
891
- * Update survey-level translations that are not tied to specific items.
892
- * Use these for survey card, navigation, validation messages, etc.
893
- */
894
1206
  updateSurveyTranslations(updates) {
895
- this.markAsModified();
896
- if (updates.surveyCard) this._survey.translations.setSurveyCardContent(updates.surveyCard.locale, updates.surveyCard.content);
897
- if (updates.navigation) this._survey.translations.setNavigationContent(updates.navigation.locale, updates.navigation.content);
898
- if (updates.validationMessages) this._survey.translations.setValidationMessages(updates.validationMessages.locale, updates.validationMessages.content);
899
- }
900
- /** Add an empty survey locale so it can be authored before translated content exists. */
901
- addLocale(locale) {
902
- const added = this._survey.translations.addLocale(locale);
903
- if (added) this.markAsModified();
904
- return added;
905
- }
906
- /** Remove a locale and every survey- and item-level translation stored for it. */
907
- removeLocale(locale) {
908
- if (!this._survey.locales.includes(locale)) return false;
909
- this._survey.translations.removeLocale(locale);
910
- this.markAsModified();
911
- return true;
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
+ });
912
1223
  }
913
- /**
914
- * Get maxItemsPerPage immutably (returns a copy).
915
- */
916
1224
  getMaxItemsPerPage() {
917
- const val = this._survey.maxItemsPerPage;
918
- return val ? { ...val } : void 0;
1225
+ return structuredClone(this.current.data.maxItemsPerPage);
919
1226
  }
920
- /**
921
- * Update maxItemsPerPage.
922
- */
923
1227
  updateMaxItemsPerPage(value) {
924
- this.markAsModified();
925
- 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
+ });
926
1232
  }
927
- /**
928
- * Get metadata immutably (returns a copy).
929
- */
930
1233
  getMetadata() {
931
- const val = this._survey.metadata;
932
- return val ? { ...val } : void 0;
1234
+ return structuredClone(this.current.data.metadata);
933
1235
  }
934
- /**
935
- * Update metadata.
936
- */
937
1236
  updateMetadata(metadata) {
938
- this.markAsModified();
939
- 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
+ });
940
1241
  }
941
- /**
942
- * Get a template value immutably (returns a deep copy).
943
- */
944
1242
  getTemplateValue(key) {
945
- const val = this._survey.getTemplateValue(key);
946
- return val ? structuredCloneMethod(val) : void 0;
1243
+ return structuredClone(this.current.getTemplateValue(key));
947
1244
  }
948
- /**
949
- * Get all template values immutably (returns a new Map with deep-copied values).
950
- */
951
1245
  getTemplateValues() {
952
- const keys = this._survey.getTemplateValueKeys();
953
- const result = /* @__PURE__ */ new Map();
954
- for (const key of keys) {
955
- const val = this._survey.getTemplateValue(key);
956
- if (val) result.set(key, structuredCloneMethod(val));
957
- }
958
- return result;
1246
+ return new Map(Object.entries(structuredClone(this.current.data.templateValues ?? {})));
959
1247
  }
960
- /**
961
- * Add or replace a template value.
962
- */
963
- setTemplateValue(key, templateValue) {
964
- this.markAsModified();
965
- 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
+ });
966
1253
  }
967
- /**
968
- * Remove a template value.
969
- */
970
1254
  removeTemplateValue(key) {
971
- this.markAsModified();
972
- this._survey.deleteTemplateValue(key);
973
- }
974
- /**
975
- * Get a survey asset immutably.
976
- */
977
- getAsset(assetId) {
978
- const asset = this._survey.getAsset(assetId);
979
- return asset ? structuredCloneMethod(asset) : void 0;
1255
+ this.transact({ label: `Remove template: ${key}` }, (draft) => {
1256
+ delete draft.templateValues?.[key];
1257
+ });
980
1258
  }
981
- /**
982
- * Get all survey assets immutably.
983
- */
984
- getAssets() {
985
- 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);
986
1268
  }
987
- /**
988
- * Add or replace a survey asset.
989
- */
990
- setAsset(assetId, asset) {
991
- const previousAsset = this._survey.getAsset(assetId);
992
- this._survey.setAsset(assetId, structuredCloneMethod(asset));
993
- this._undoRedo.commitAssetChange([{
994
- assetId,
995
- prev: previousAsset,
996
- next: structuredCloneMethod(asset)
997
- }], {
998
- label: `Update asset: ${assetId}`,
1269
+ setAsset(id, asset) {
1270
+ this.transact({
1271
+ label: `Update asset: ${id}`,
999
1272
  source: CommitSource.USER
1273
+ }, (draft) => {
1274
+ draft.assets ??= {};
1275
+ setOwnValue(draft.assets, id, asset);
1000
1276
  });
1001
1277
  }
1002
- /**
1003
- * Remove a survey asset.
1004
- */
1005
- removeAsset(assetId) {
1006
- const previousAsset = this._survey.getAsset(assetId);
1007
- if (!previousAsset) return;
1008
- this._survey.deleteAsset(assetId);
1009
- this._undoRedo.commitAssetChange([{
1010
- assetId,
1011
- prev: previousAsset
1012
- }], {
1013
- label: `Remove asset: ${assetId}`,
1278
+ removeAsset(id) {
1279
+ this.transact({
1280
+ label: `Remove asset: ${id}`,
1014
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
+ }
1015
1287
  });
1016
1288
  }
1017
- /**
1018
- * Copy a survey item and all its data to clipboard format
1019
- * @param itemId - The ID of the item to copy
1020
- * @returns Clipboard data that can be serialized to JSON for clipboard
1021
- */
1022
- copyItem(itemId) {
1023
- return new ItemCopyPaste(this._survey).copyItem(itemId);
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
+ }
1342
+ });
1343
+ return {
1344
+ itemId,
1345
+ elementId: element.id
1346
+ };
1024
1347
  }
1025
- /**
1026
- * Paste a survey item from clipboard data to a target location
1027
- * @param clipboardData - The clipboard data containing the item to paste
1028
- * @param target - Target location where to paste the item
1029
- * @returns The ID of the pasted item
1030
- */
1031
- pasteItem(clipboardData, target) {
1032
- this.markAsModified();
1033
- const timestamp = this.createEditorTimestamp();
1034
- const itemIdsBeforePaste = new Set(this._survey.surveyItems.keys());
1035
- const newItemId = new ItemCopyPaste(this._survey).pasteItem(clipboardData, target);
1036
- const pastedItemIds = Array.from(this._survey.surveyItems.keys()).filter((itemId) => !itemIdsBeforePaste.has(itemId));
1037
- for (const itemId of pastedItemIds) {
1038
- const item = this._survey.surveyItems.get(itemId);
1039
- if (item) {
1040
- item.setCreatedAt(timestamp);
1041
- item.setModifiedAt(timestamp);
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;
1396
+ }
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
+ }
1042
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));
1043
1507
  }
1044
- this.touchItemModifiedMetadata(target.parentId, timestamp);
1045
- 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;
1046
1652
  }
1047
1653
  };
1048
1654
  //#endregion
1049
- export { CommitSource, ItemCopyPaste, SurveyEditor, SurveyEditorUndoRedo };
1655
+ export { CommitSource, SURVEY_EDITOR_ITEM_COLORS, SurveyEditor, SurveyEditorUndoRedo, allocateCopiedResponseNames, copySurveyItem, createEditorSessionSchema, createItemClipboardSchema, getAnswerTypeChangeDiagnostics, prepareItemPaste, previewResponseNaming, previewResponseSettings };
1050
1656
 
1051
1657
  //# sourceMappingURL=editor.mjs.map