@templatical/core 0.52.0 → 0.53.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/dist/index.js CHANGED
@@ -1,6 +1,262 @@
1
- import { OperationError, SdkError, applyOperations, applyOperationsInPlace, assertNoSlotInContent, assertNoWrapperInContent, cloneBlock, createBlock, createDefaultTemplateContent, deepEqual, generateId, getColumnCount, invertOperations, invertOperations as invertOperations$1, safeClone } from "@templatical/types";
2
- import { computed, getCurrentScope, onScopeDispose, reactive, readonly, ref, shallowRef, watch } from "@vue/reactivity";
1
+ import { OperationError, SETTINGS_TRANSLATABLE_FIELDS, SdkError, applyOperations, applyOperationsInPlace, assertNoSlotInContent, assertNoWrapperInContent, cloneBlock, createBlock, createDefaultTemplateContent, createSetDefaultLocaleOperation, deepEqual, defaultLocaleOf, generateId, getColumnCount, invertOperations, invertOperations as invertOperations$1, isLocaleTag, isSafePath, isStructuralLocalePath, isTranslatablePath, localizeBlock, pruneLocaleOrphans, pruneSettingsLocaleOrphans, readPath, resolveLocale, safeClone, sourceHash, translatableFields } from "@templatical/types";
2
+ import { computed, getCurrentScope, isRef, onScopeDispose, reactive, readonly, ref, shallowRef, toRef, watch } from "@vue/reactivity";
3
3
  import { computed as computed$1, onScopeDispose as onScopeDispose$1, ref as ref$1, watch as watch$1 } from "vue";
4
+ // src/localized-edit.ts
5
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6
+ const isIdList = (value) => Array.isArray(value) && value.length > 0 && value.every((item) => isRecord(item) && typeof item.id === "string");
7
+ const SKIP = /* @__PURE__ */ new Set([
8
+ "id",
9
+ "type",
10
+ "children",
11
+ "columns",
12
+ "l10n",
13
+ "renderedHtml"
14
+ ]);
15
+ /** Restore requested fields in a projection, preserving sibling overrides. */
16
+ function resetLocalizedFields(base, view, paths) {
17
+ const restoredAncestors = /* @__PURE__ */ new Set();
18
+ for (const path of paths) {
19
+ if (!isSafePath(path)) continue;
20
+ const segments = path.split(".");
21
+ const key = segments.pop();
22
+ const parentPath = segments.join(".");
23
+ const value = readPath(base, path);
24
+ let parent = view;
25
+ for (const [index, segment] of segments.entries()) {
26
+ const ancestorPath = segments.slice(0, index + 1).join(".");
27
+ if (value !== void 0 && isRecord(parent) && readPath(parent, segment) === void 0 && isRecord(readPath(base, ancestorPath))) {
28
+ parent[segment] = {};
29
+ restoredAncestors.add(ancestorPath);
30
+ }
31
+ parent = readPath(parent, segment);
32
+ }
33
+ if (Array.isArray(parent)) {
34
+ const index = parent.findIndex((item) => isRecord(item) && item.id === key);
35
+ if (index < 0) continue;
36
+ if (value === void 0) parent.splice(index, 1);
37
+ else parent[index] = safeClone(value);
38
+ } else if (isRecord(parent)) {
39
+ if (value === void 0) delete parent[key];
40
+ else parent[key] = safeClone(value);
41
+ if (parentPath && Object.keys(parent).length === 0 && readPath(base, parentPath) === void 0) resetLocalizedFields(base, view, [parentPath]);
42
+ }
43
+ }
44
+ return restoredAncestors;
45
+ }
46
+ /** Leaf paths that differ, walking records and lists addressed by element id. */
47
+ function changedLeaves(base, next, prefix, out, walkIds = true, restoredAncestors = /* @__PURE__ */ new Set()) {
48
+ if (isRecord(base) && isRecord(next) && !restoredAncestors.has(prefix)) {
49
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(next)])) {
50
+ if (!prefix && SKIP.has(key)) continue;
51
+ changedLeaves(base[key], next[key], prefix ? `${prefix}.${key}` : key, out, walkIds, restoredAncestors);
52
+ }
53
+ return;
54
+ }
55
+ if (walkIds && isIdList(base) && isIdList(next)) {
56
+ for (const item of next) {
57
+ if (item.id.includes(".")) continue;
58
+ const match = base.find((candidate) => candidate.id === item.id);
59
+ if (match) changedLeaves(match, item, `${prefix}.${item.id}`, out, walkIds, restoredAncestors);
60
+ }
61
+ return;
62
+ }
63
+ if (!deepEqual(base, next)) out.set(prefix, next);
64
+ }
65
+ /**
66
+ * Derive text and property overrides from a localized view. Unchanged values
67
+ * keep their previous source hash; values equal to the base reset. Clearing a
68
+ * property uses null, the layer's deletion marker. Hidden carries over.
69
+ */
70
+ function deriveLayer(base, view, previous, definitions = [], restoredAncestors = /* @__PURE__ */ new Set()) {
71
+ const leaves = /* @__PURE__ */ new Map();
72
+ changedLeaves(base, view, "", leaves, base.type !== "custom", restoredAncestors);
73
+ const customText = new Set(base.type === "custom" ? translatableFields(base, definitions) : []);
74
+ const text = {};
75
+ const set = {};
76
+ for (const [path, value] of leaves) if ((base.type === "custom" ? customText.has(path) : isTranslatablePath(base.type, path)) && typeof value === "string" && readPath(base, path) !== void 0) {
77
+ const before = previous?.text?.[path];
78
+ text[path] = before && before.value === value ? before : {
79
+ value,
80
+ sourceHash: sourceHash(String(readPath(base, path) ?? ""))
81
+ };
82
+ } else set[path] = value === void 0 ? null : value;
83
+ const layer = {};
84
+ if (Object.keys(text).length) layer.text = text;
85
+ if (Object.keys(set).length) layer.set = set;
86
+ if (previous?.hidden) layer.hidden = true;
87
+ return layer;
88
+ }
89
+ /** Shared list/section structure changes preserve existing elements' base data. */
90
+ function reconcileStructure(base, next, findBlock) {
91
+ let changed = false;
92
+ function reconcileList(target, view, key, childKey) {
93
+ const before = target[key];
94
+ const value = view[key];
95
+ const validList = (list) => Array.isArray(list) && list.every((item) => isRecord(item) && typeof item.id === "string");
96
+ if (!validList(before) || !validList(value)) return;
97
+ if (JSON.stringify(before.map((item) => item.id)) !== JSON.stringify(value.map((item) => item.id))) {
98
+ changed = true;
99
+ target[key] = value.map((item) => safeClone(before.find((old) => old.id === item.id) ?? item));
100
+ }
101
+ if (childKey) for (const item of value) {
102
+ const existing = target[key].find((old) => old.id === item.id);
103
+ if (existing) reconcileList(existing, item, childKey);
104
+ }
105
+ }
106
+ const target = base;
107
+ const view = next;
108
+ if (base.type === "menu") reconcileList(target, view, "items");
109
+ if (base.type === "table") reconcileList(target, view, "rows", "cells");
110
+ if (base.type === "social") reconcileList(target, view, "icons");
111
+ if (base.type === "section" && next.type === "section") {
112
+ const ids = (columns) => columns.map((column) => column.map((block) => block.id));
113
+ if (base.columns !== next.columns || JSON.stringify(ids(base.children)) !== JSON.stringify(ids(next.children))) {
114
+ changed = true;
115
+ base.columns = next.columns;
116
+ base.children = next.children.map((column) => column.map((block) => safeClone(findBlock(block.id) ?? block)));
117
+ }
118
+ }
119
+ return changed;
120
+ }
121
+ /** Settings follow the same leaf rules, with preheaderText as their text field. */
122
+ function deriveSettingsLayer(base, view, previous) {
123
+ const strip = (settings) => {
124
+ const { locale: _locale, locales: _locales, l10n: _l10n, ...rest } = settings;
125
+ return rest;
126
+ };
127
+ const leaves = /* @__PURE__ */ new Map();
128
+ changedLeaves(strip(base), strip(view), "", leaves);
129
+ const layer = {};
130
+ for (const [path, value] of leaves) if (SETTINGS_TRANSLATABLE_FIELDS.includes(path) && typeof value === "string" && readPath(base, path) !== void 0) {
131
+ const before = previous?.text?.[path];
132
+ (layer.text ??= {})[path] = before?.value === value ? before : {
133
+ value,
134
+ sourceHash: sourceHash(String(readPath(base, path) ?? ""))
135
+ };
136
+ } else (layer.set ??= {})[path] = value === void 0 ? null : value;
137
+ return layer;
138
+ }
139
+ /** Merge net changed leaves, updating covering overrides without losing siblings. */
140
+ function changedLayer(previous, before, after, base, view, resetPaths = []) {
141
+ const layer = safeClone(previous ?? {});
142
+ const paths = [.../* @__PURE__ */ new Set([...resetPaths, ...[.../* @__PURE__ */ new Set([
143
+ ...Object.keys(before.text ?? {}),
144
+ ...Object.keys(before.set ?? {}),
145
+ ...Object.keys(after.text ?? {}),
146
+ ...Object.keys(after.set ?? {})
147
+ ])].filter((path) => !deepEqual(before.text?.[path], after.text?.[path]) || !deepEqual(before.set?.[path], after.set?.[path]))])];
148
+ const covering = Object.keys(previous?.set ?? {}).filter((ancestor) => paths.some((path) => path.startsWith(`${ancestor}.`)));
149
+ for (const ancestor of covering) {
150
+ const value = readPath(view, ancestor);
151
+ const resets = paths.filter((path) => path.startsWith(`${ancestor}.`) && deepEqual(readPath(base, path), readPath(view, path)));
152
+ if (deepEqual(value, readPath(base, ancestor))) delete layer.set[ancestor];
153
+ else if (resets.length && isRecord(readPath(base, ancestor)) && isRecord(value)) {
154
+ delete layer.set[ancestor];
155
+ const split = (path, previousValue, nextValue) => {
156
+ if (resets.some((reset) => path === reset || path.startsWith(`${reset}.`))) return;
157
+ if (isRecord(previousValue) && isRecord(nextValue)) {
158
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(previousValue), ...Object.keys(nextValue)])) split(`${path}.${key}`, previousValue[key], nextValue[key]);
159
+ return;
160
+ }
161
+ if (isRecord(base) && base.type !== "custom" && isIdList(previousValue) && isIdList(nextValue)) {
162
+ for (const item of nextValue) split(`${path}.${item.id}`, previousValue.find((old) => old.id === item.id), item);
163
+ return;
164
+ }
165
+ if (isRecord(base) && typeof base.type === "string" && isStructuralLocalePath(base, path, nextValue)) return;
166
+ if (layer.text?.[path] && layer.text[path].value === nextValue) return;
167
+ (layer.set ??= {})[path] = nextValue === void 0 ? null : safeClone(nextValue);
168
+ };
169
+ split(ancestor, readPath(base, ancestor), value);
170
+ } else layer.set[ancestor] = value === void 0 ? null : safeClone(value);
171
+ for (const kind of ["text", "set"]) for (const stored of Object.keys(layer[kind] ?? {})) {
172
+ if (!stored.startsWith(`${ancestor}.`)) continue;
173
+ const storedValue = kind === "text" ? layer.text[stored].value : layer.set[stored] ?? void 0;
174
+ if (!deepEqual(storedValue, readPath(view, stored))) delete layer[kind][stored];
175
+ }
176
+ }
177
+ for (const path of paths) {
178
+ if (covering.includes(path)) continue;
179
+ for (const kind of ["text", "set"]) for (const stored of Object.keys(layer[kind] ?? {})) if (stored === path || stored.startsWith(`${path}.`)) delete layer[kind][stored];
180
+ if (after.text && Object.hasOwn(after.text, path)) (layer.text ??= {})[path] = after.text[path];
181
+ if (after.set && Object.hasOwn(after.set, path) && !covering.some((ancestor) => Object.hasOwn(layer.set ?? {}, ancestor) && path.startsWith(`${ancestor}.`))) (layer.set ??= {})[path] = after.set[path];
182
+ }
183
+ if (layer.text && !Object.keys(layer.text).length) delete layer.text;
184
+ if (layer.set && !Object.keys(layer.set).length) delete layer.set;
185
+ return layer;
186
+ }
187
+ // src/mutation-guard.ts
188
+ function isStructuralMutation(method) {
189
+ return [
190
+ "addBlock",
191
+ "removeBlock",
192
+ "moveBlock",
193
+ "duplicateBlock"
194
+ ].includes(method);
195
+ }
196
+ /** The eligibility shared by structural actions and their history preflight. */
197
+ function canEditStructure(context, method, args) {
198
+ if (context.canEdit && !context.canEdit()) return false;
199
+ const adding = method === "addBlock";
200
+ const duplicating = method === "duplicateBlock";
201
+ const id = typeof args[0] === "string" ? args[0] : args[0].id;
202
+ const block = adding ? args[0] : context.findBlock?.(id);
203
+ if (!adding && context.findBlock && !block) return false;
204
+ const source = adding ? null : context.findBlockLocation?.(id);
205
+ if (!adding && context.findBlockLocation && !source) return false;
206
+ if (!adding && context.isBlockLocked(id, true)) return false;
207
+ if (source?.targetSectionId && context.isBlockLocked(source.targetSectionId, true)) return false;
208
+ if (adding && context.content) {
209
+ const candidate = {
210
+ ...context.content(),
211
+ blocks: [block]
212
+ };
213
+ assertNoSlotInContent(candidate);
214
+ assertNoWrapperInContent(candidate);
215
+ }
216
+ if (method === "removeBlock") return true;
217
+ const implicitDuplicate = duplicating && args[1] === void 0 && args[2] === void 0;
218
+ const sectionId = implicitDuplicate ? source?.targetSectionId : args[method === "moveBlock" ? 2 : 1];
219
+ const columnIndex = (implicitDuplicate ? source?.columnIndex : args[method === "moveBlock" ? 3 : 2]) ?? 0;
220
+ if (sectionId) {
221
+ if (context.isBlockLocked(sectionId, true) || block?.type === "section") return false;
222
+ if (context.findBlock) {
223
+ const section = context.findBlock(sectionId);
224
+ if (section?.type !== "section" || columnIndex < 0 || columnIndex >= getColumnCount(section.columns)) return false;
225
+ }
226
+ }
227
+ if (method === "moveBlock" && source?.targetSectionId === sectionId && (source?.columnIndex ?? 0) === columnIndex && source?.index === args[1]) return false;
228
+ return true;
229
+ }
230
+ const guards = /* @__PURE__ */ new WeakMap();
231
+ function registerMutationGuard(editor, guard) {
232
+ guards.set(editor, guard);
233
+ for (const value of Object.values(editor)) if (typeof value === "function") guards.set(value, guard);
234
+ }
235
+ function inheritMutationGuard(source, target) {
236
+ const guard = guards.get(source);
237
+ if (guard) guards.set(target, guard);
238
+ }
239
+ function canMutate(editor, method, args) {
240
+ const ownMethod = Reflect.get(editor, method);
241
+ const guard = guards.get(editor) ?? (typeof ownMethod === "function" ? guards.get(ownMethod) : void 0);
242
+ if (guard) return guard(method, args);
243
+ if (!isStructuralMutation(method)) return true;
244
+ const isBlockLocked = Reflect.get(editor, "isBlockLocked");
245
+ if (typeof isBlockLocked !== "function") return true;
246
+ const context = { isBlockLocked: (id, structure) => isBlockLocked.call(editor, id, structure) };
247
+ const content = Reflect.get(editor, "content");
248
+ if (isRef(content)) context.content = () => content.value;
249
+ else if (typeof content === "function") context.content = () => content.call(editor);
250
+ for (const key of [
251
+ "canEdit",
252
+ "findBlock",
253
+ "findBlockLocation"
254
+ ]) {
255
+ const member = Reflect.get(editor, key);
256
+ if (typeof member === "function") context[key] = member.bind(editor);
257
+ }
258
+ return canEditStructure(context, method, args);
259
+ }
4
260
  // src/error-reporting.ts
5
261
  /**
6
262
  * Turn a thrown value into an `Error`.
@@ -38,6 +294,7 @@ function useEditor(options) {
38
294
  const state = reactive({
39
295
  template: null,
40
296
  content: seedContent,
297
+ activeLocale: null,
41
298
  selectedBlockId: null,
42
299
  viewport: "desktop",
43
300
  darkMode: false,
@@ -47,6 +304,105 @@ function useEditor(options) {
47
304
  isLoading: false,
48
305
  uiTheme: "auto"
49
306
  });
307
+ const activeLocale = toRef(state, "activeLocale");
308
+ let definitions = [];
309
+ function setActiveLocale(locale) {
310
+ const fallback = defaultLocaleOf(state.content);
311
+ if (locale === fallback) locale = null;
312
+ if (locale !== null && !state.content.settings.locales?.includes(locale)) throw new SdkError(`This template has no "${locale}" version.`, void 0, "unknown_locale");
313
+ activeLocale.value = locale;
314
+ }
315
+ watch(() => activeLocale.value ?? defaultLocaleOf(state.content), (locale) => notifyHandler(options.onError, () => options.onLocaleChange?.(locale)));
316
+ watch(() => state.content.settings.locales?.includes(activeLocale.value ?? ""), () => {
317
+ if (activeLocale.value && !state.content.settings.locales?.includes(activeLocale.value)) setActiveLocale(null);
318
+ });
319
+ function canEditLocale() {
320
+ return activeLocale.value === null || options.localization === void 0 || options.localization.locales.includes(activeLocale.value);
321
+ }
322
+ function setCustomBlockDefinitions(defs) {
323
+ definitions = defs;
324
+ }
325
+ function createSetDefaultLocaleOperation$1(locale) {
326
+ return createSetDefaultLocaleOperation(state.content, locale, definitions);
327
+ }
328
+ function localeListDraft(method, locale) {
329
+ if (!canEditLocale()) return null;
330
+ if (method === "addLocale" && (!isLocaleTag(locale) || options.localization && !options.localization.locales.includes(locale))) throw new SdkError(`Locale "${locale}" is not enabled.`, void 0, "unknown_locale");
331
+ const draft = safeClone(state.content);
332
+ applyOperationsInPlace(draft, [{
333
+ op: method,
334
+ locale
335
+ }]);
336
+ return deepEqual(draft, state.content) ? null : draft;
337
+ }
338
+ function changeLocaleList(method, locale) {
339
+ const draft = localeListDraft(method, locale);
340
+ if (!draft) return;
341
+ state.content = draft;
342
+ touch();
343
+ }
344
+ function addLocale(locale) {
345
+ changeLocaleList("addLocale", locale);
346
+ }
347
+ function removeLocale(locale) {
348
+ changeLocaleList("removeLocale", locale);
349
+ }
350
+ function writeLayer(block, layer) {
351
+ const locale = activeLocale.value;
352
+ if (Object.keys(layer).length) block.l10n = {
353
+ ...block.l10n,
354
+ [locale]: layer
355
+ };
356
+ else if (block.l10n) {
357
+ delete block.l10n[locale];
358
+ if (!Object.keys(block.l10n).length) delete block.l10n;
359
+ }
360
+ }
361
+ function prepareLayerEdit(method, args) {
362
+ const [blockId, value] = args;
363
+ if (!activeLocale.value || !canEditLocale() || isBlockLocked(blockId)) return null;
364
+ const block = findBlockById(state.content.blocks, blockId);
365
+ if (!block) return null;
366
+ const previous = block.l10n?.[activeLocale.value];
367
+ const view = localizeBlock(block, activeLocale.value, defaultLocaleOf(state.content));
368
+ const before = deriveLayer(block, view, previous, definitions);
369
+ const restoredAncestors = method === "resetLocaleFields" && Array.isArray(value) ? resetLocalizedFields(block, view, value) : void 0;
370
+ const layer = method === "resetLocaleFields" ? changedLayer(previous, before, deriveLayer(block, view, previous, definitions, restoredAncestors), block, view, Array.isArray(value) ? value : []) : safeClone(previous ?? {});
371
+ if (method === "setBlockHidden") {
372
+ if (value) layer.hidden = true;
373
+ else delete layer.hidden;
374
+ } else if (method === "resetLocaleFields") {
375
+ if (!value) {
376
+ delete layer.text;
377
+ delete layer.set;
378
+ delete layer.hidden;
379
+ }
380
+ } else for (const path of value) {
381
+ const text = layer.text?.[path];
382
+ if (text) text.sourceHash = sourceHash(String(readPath(block, path) ?? ""));
383
+ }
384
+ if (layer.text && !Object.keys(layer.text).length) delete layer.text;
385
+ if (layer.set && !Object.keys(layer.set).length) delete layer.set;
386
+ return deepEqual(previous ?? {}, layer) ? null : {
387
+ block,
388
+ layer
389
+ };
390
+ }
391
+ function editLayer(method, args) {
392
+ const prepared = prepareLayerEdit(method, args);
393
+ if (!prepared) return;
394
+ writeLayer(prepared.block, prepared.layer);
395
+ touch();
396
+ }
397
+ function setBlockHidden(blockId, hidden) {
398
+ editLayer("setBlockHidden", [blockId, hidden]);
399
+ }
400
+ function resetLocaleFields(blockId, paths) {
401
+ editLayer("resetLocaleFields", [blockId, paths]);
402
+ }
403
+ function keepTranslations(blockId, paths) {
404
+ editLayer("keepTranslations", [blockId, paths]);
405
+ }
50
406
  const loadVersion = ref(0);
51
407
  let loadingRequest = 0;
52
408
  /**
@@ -100,8 +456,18 @@ function useEditor(options) {
100
456
  }
101
457
  return null;
102
458
  }
103
- function isBlockLocked(blockId) {
104
- return options.lockedBlocks?.value.has(blockId) ?? false;
459
+ function isBlockLocked(blockId, structure = false) {
460
+ const locale = activeLocale.value ?? options.lockLocale?.value ?? null;
461
+ for (const [key, value] of options.lockedBlocks?.value ?? []) {
462
+ const lock = value;
463
+ if (lock?.blockId === void 0) {
464
+ if (key === blockId) return true;
465
+ continue;
466
+ }
467
+ if (lock.blockId !== blockId || lock.holder?.sessionId === options.lockSessionId) continue;
468
+ if (structure || locale === null || lock.locale == null || lock.locale === locale) return true;
469
+ }
470
+ return false;
105
471
  }
106
472
  function findBlockLocation(blockId) {
107
473
  const parent = findBlockParent(state.content.blocks, blockId);
@@ -137,34 +503,97 @@ function useEditor(options) {
137
503
  state.previewMode = previewMode;
138
504
  if (previewMode) state.selectedBlockId = null;
139
505
  }
506
+ function canUpdateBlock(blockId, updates) {
507
+ if (!canEditLocale()) return false;
508
+ const block = findBlockById(state.content.blocks, blockId);
509
+ if (!block) return false;
510
+ if ("l10n" in updates) throw new SdkError("l10n is immutable in updateBlock.", void 0, "invalid_operation");
511
+ const next = {
512
+ ...block,
513
+ ...updates
514
+ };
515
+ return !isBlockLocked(blockId, reconcileStructure(safeClone(block), next, (id) => findBlockById(state.content.blocks, id)));
516
+ }
140
517
  function updateBlock(blockId, updates) {
141
- if (isBlockLocked(blockId)) return;
518
+ if (!canUpdateBlock(blockId, updates)) return;
142
519
  const block = findBlockById(state.content.blocks, blockId);
143
- if (block) {
144
- Object.assign(block, updates);
145
- touch();
520
+ if (!block) return;
521
+ const locale = activeLocale.value;
522
+ if (!locale) {
523
+ if (isBlockLocked(blockId, "children" in updates || "columns" in updates)) return;
524
+ const pruned = pruneLocaleOrphans({
525
+ ...block,
526
+ ...updates
527
+ });
528
+ delete block.l10n;
529
+ Object.assign(block, pruned);
530
+ } else {
531
+ const view = localizeBlock(block, locale, defaultLocaleOf(state.content));
532
+ const next = {
533
+ ...view,
534
+ ...updates
535
+ };
536
+ const base = safeClone(block);
537
+ const structural = reconcileStructure(base, next, (id) => findBlockById(state.content.blocks, id));
538
+ if (isBlockLocked(blockId, structural)) return;
539
+ if (structural) options.onStructureChange?.();
540
+ const pruned = pruneLocaleOrphans(base);
541
+ delete block.l10n;
542
+ Object.assign(block, pruned);
543
+ writeLayer(block, changedLayer(block.l10n?.[locale], deriveLayer(block, view, block.l10n?.[locale], definitions), deriveLayer(block, next, block.l10n?.[locale], definitions), block, next));
146
544
  }
545
+ touch();
546
+ }
547
+ function validateSettingsUpdate(updates) {
548
+ if ("locales" in updates || "l10n" in updates) throw new SdkError("locales and l10n are immutable in updateSettings.", void 0, "invalid_operation");
549
+ if ("locale" in updates && updates.locale !== defaultLocaleOf(state.content) && state.content.settings.locales?.length) throw new SdkError("The default locale can't change while language versions exist.", void 0, "invalid_operation");
147
550
  }
148
551
  function updateSettings(updates) {
149
- state.content.settings = {
552
+ if (!canEditLocale()) return;
553
+ validateSettingsUpdate(updates);
554
+ const locale = activeLocale.value;
555
+ if (!locale) state.content.settings = pruneSettingsLocaleOrphans({
150
556
  ...state.content.settings,
151
557
  ...updates
152
- };
558
+ });
559
+ else {
560
+ const settings = state.content.settings;
561
+ const view = resolveLocale(state.content, locale).settings;
562
+ const next = {
563
+ ...view,
564
+ locale: settings.locale,
565
+ ...updates
566
+ };
567
+ const previous = settings.l10n?.[locale];
568
+ const layer = changedLayer(previous, deriveSettingsLayer(settings, view, previous), deriveSettingsLayer(settings, next, previous), settings, next);
569
+ if (Object.keys(layer).length) settings.l10n = {
570
+ ...settings.l10n,
571
+ [locale]: layer
572
+ };
573
+ else if (settings.l10n) {
574
+ delete settings.l10n[locale];
575
+ if (!Object.keys(settings.l10n).length) delete settings.l10n;
576
+ }
577
+ }
153
578
  touch();
154
579
  }
580
+ const structuralContext = {
581
+ canEdit: canEditLocale,
582
+ content: () => state.content,
583
+ findBlock: (id) => findBlockById(state.content.blocks, id),
584
+ findBlockLocation,
585
+ isBlockLocked
586
+ };
155
587
  function addBlock(block, targetSectionId, columnIndex = 0, index) {
156
- const candidate = {
157
- settings: state.content.settings,
158
- blocks: [block]
159
- };
160
- assertNoSlotInContent(candidate);
161
- assertNoWrapperInContent(candidate);
162
- if (targetSectionId && block.type === "section") return;
588
+ if (!canEditStructure(structuralContext, "addBlock", [
589
+ block,
590
+ targetSectionId,
591
+ columnIndex,
592
+ index
593
+ ])) return;
163
594
  if (targetSectionId) {
164
- if (isBlockLocked(targetSectionId)) return;
165
595
  const section = findBlockById(state.content.blocks, targetSectionId);
166
596
  if (section && section.type === "section") {
167
- if (columnIndex < 0 || columnIndex >= getColumnCount(section.columns)) return;
168
597
  section.children[columnIndex] = section.children[columnIndex] || [];
169
598
  const targetArray = section.children[columnIndex];
170
599
  if (index !== void 0 && index < targetArray.length) targetArray.splice(index, 0, block);
@@ -172,10 +601,11 @@ function useEditor(options) {
172
601
  }
173
602
  } else if (index !== void 0 && index < state.content.blocks.length) state.content.blocks.splice(index, 0, block);
174
603
  else state.content.blocks.push(block);
604
+ if (activeLocale.value) options.onStructureChange?.();
175
605
  touch();
176
606
  }
177
607
  function removeBlock(blockId) {
178
- if (isBlockLocked(blockId)) return;
608
+ if (!canEditStructure(structuralContext, "removeBlock", [blockId])) return;
179
609
  const parent = findBlockParent(state.content.blocks, blockId);
180
610
  if (parent) {
181
611
  const index = parent.blocks.findIndex((b) => b.id === blockId);
@@ -186,28 +616,29 @@ function useEditor(options) {
186
616
  collectBlockIds(removed, removedIds);
187
617
  if (removedIds.has(state.selectedBlockId)) state.selectedBlockId = null;
188
618
  }
619
+ if (activeLocale.value) options.onStructureChange?.();
189
620
  touch();
190
621
  }
191
622
  }
192
623
  }
193
624
  function moveBlock(blockId, newIndex, targetSectionId, columnIndex = 0) {
194
- if (isBlockLocked(blockId)) return;
195
- if (targetSectionId && isBlockLocked(targetSectionId)) return;
625
+ if (!canEditStructure(structuralContext, "moveBlock", [
626
+ blockId,
627
+ newIndex,
628
+ targetSectionId,
629
+ columnIndex
630
+ ])) return;
196
631
  const parent = findBlockParent(state.content.blocks, blockId);
197
- if (!parent) return;
198
- const oldIndex = parent.blocks.findIndex((b) => b.id === blockId);
199
- if (oldIndex === -1) return;
200
- if (targetSectionId && parent.blocks[oldIndex].type === "section") return;
632
+ const oldIndex = parent.blocks.findIndex((block) => block.id === blockId);
201
633
  let targetArray;
202
634
  if (targetSectionId) {
203
635
  const section = findBlockById(state.content.blocks, targetSectionId);
204
- if (!section || section.type !== "section") return;
205
- if (columnIndex < 0 || columnIndex >= getColumnCount(section.columns)) return;
206
636
  section.children[columnIndex] = section.children[columnIndex] || [];
207
637
  targetArray = section.children[columnIndex];
208
638
  } else targetArray = state.content.blocks;
209
639
  const [block] = parent.blocks.splice(oldIndex, 1);
210
640
  targetArray.splice(newIndex, 0, block);
641
+ if (activeLocale.value) options.onStructureChange?.();
211
642
  touch();
212
643
  }
213
644
  function markDirty() {
@@ -332,11 +763,20 @@ function useEditor(options) {
332
763
  function hasTemplate() {
333
764
  return state.template?.id !== void 0;
334
765
  }
335
- return {
766
+ const editor = {
336
767
  state: readonly(state),
337
768
  loadVersion: readonly(loadVersion),
338
769
  content,
339
770
  selectedBlock,
771
+ activeLocale,
772
+ setActiveLocale,
773
+ addLocale,
774
+ removeLocale,
775
+ setBlockHidden,
776
+ resetLocaleFields,
777
+ keepTranslations,
778
+ setCustomBlockDefinitions,
779
+ createSetDefaultLocaleOperation: createSetDefaultLocaleOperation$1,
340
780
  isBlockLocked,
341
781
  setContent,
342
782
  selectBlock,
@@ -357,6 +797,16 @@ function useEditor(options) {
357
797
  save,
358
798
  hasTemplate
359
799
  };
800
+ registerMutationGuard(editor, (method, args) => {
801
+ if (isStructuralMutation(method)) return canEditStructure(structuralContext, method, args);
802
+ if (!canEditLocale()) return false;
803
+ if (method === "updateBlock") return canUpdateBlock(args[0], args[1]);
804
+ if (method === "updateSettings") validateSettingsUpdate(args[0]);
805
+ if (method === "addLocale" || method === "removeLocale") return localeListDraft(method, args[0]) !== null;
806
+ if (method === "setBlockHidden" || method === "resetLocaleFields" || method === "keepTranslations") return prepareLayerEdit(method, args) !== null;
807
+ return true;
808
+ });
809
+ return editor;
360
810
  }
361
811
  // src/history.ts
362
812
  const MAX_STACK_SIZE = 50;
@@ -452,7 +902,8 @@ function useHistory(options) {
452
902
  record,
453
903
  recordDebounced,
454
904
  clear,
455
- destroy
905
+ destroy,
906
+ flushDebounce: flushPendingDebounce
456
907
  };
457
908
  }
458
909
  // src/block-actions.ts
@@ -467,6 +918,11 @@ function useBlockActions(options) {
467
918
  }
468
919
  function duplicateBlock(block, targetSectionId, columnIndex) {
469
920
  const cloned = cloneBlock(block);
921
+ if (!canMutate(addBlock, "duplicateBlock", [
922
+ block,
923
+ targetSectionId,
924
+ columnIndex
925
+ ])) return cloned;
470
926
  if (targetSectionId !== void 0 || columnIndex !== void 0) addBlock(cloned, targetSectionId, columnIndex);
471
927
  else {
472
928
  const sourceLocation = findBlockLocation?.(block.id) ?? null;
@@ -620,51 +1076,88 @@ function useDataSourceFetch(options) {
620
1076
  * changes by diffing the content and keeps its own undo history.
621
1077
  */
622
1078
  function useHistoryInterceptor(editor, history) {
1079
+ if (editor.activeLocale) watch(editor.activeLocale, () => history.flushDebounce?.());
1080
+ for (const key of [
1081
+ "addLocale",
1082
+ "removeLocale",
1083
+ "setBlockHidden",
1084
+ "resetLocaleFields",
1085
+ "keepTranslations"
1086
+ ]) {
1087
+ const original = editor[key];
1088
+ if (!original) continue;
1089
+ Object.assign(editor, { [key]: (...args) => {
1090
+ if (!canMutate(editor, key, args)) return;
1091
+ if (!["addLocale", "removeLocale"].includes(key) && editor.isBlockLocked(args[0])) return;
1092
+ history.record();
1093
+ original(...args);
1094
+ } });
1095
+ inheritMutationGuard(original, editor[key]);
1096
+ }
623
1097
  const originalAddBlock = editor.addBlock;
624
1098
  const originalRemoveBlock = editor.removeBlock;
625
1099
  const originalMoveBlock = editor.moveBlock;
626
1100
  const originalUpdateBlock = editor.updateBlock;
627
1101
  const originalUpdateSettings = editor.updateSettings;
628
1102
  editor.addBlock = (block, targetSectionId, columnIndex, index) => {
629
- if (targetSectionId && editor.isBlockLocked(targetSectionId)) return;
1103
+ if (!canMutate(editor, "addBlock", [
1104
+ block,
1105
+ targetSectionId,
1106
+ columnIndex,
1107
+ index
1108
+ ])) return;
630
1109
  history.record();
631
1110
  originalAddBlock(block, targetSectionId, columnIndex, index);
632
1111
  };
633
1112
  editor.removeBlock = (blockId) => {
634
- if (editor.isBlockLocked(blockId)) return;
1113
+ if (!canMutate(editor, "removeBlock", [blockId])) return;
635
1114
  history.record();
636
1115
  originalRemoveBlock(blockId);
637
1116
  };
638
1117
  editor.moveBlock = (blockId, newIndex, targetSectionId, columnIndex) => {
639
- if (editor.isBlockLocked(blockId)) return;
640
- if (targetSectionId && editor.isBlockLocked(targetSectionId)) return;
1118
+ if (!canMutate(editor, "moveBlock", [
1119
+ blockId,
1120
+ newIndex,
1121
+ targetSectionId,
1122
+ columnIndex
1123
+ ])) return;
641
1124
  history.record();
642
1125
  originalMoveBlock(blockId, newIndex, targetSectionId, columnIndex);
643
1126
  };
1127
+ inheritMutationGuard(originalAddBlock, editor.addBlock);
1128
+ inheritMutationGuard(originalRemoveBlock, editor.removeBlock);
1129
+ inheritMutationGuard(originalMoveBlock, editor.moveBlock);
644
1130
  editor.updateBlock = (blockId, updates) => {
1131
+ if (!canMutate(editor, "updateBlock", [blockId, updates])) return;
645
1132
  if (editor.isBlockLocked(blockId)) return;
646
1133
  history.recordDebounced(blockId);
647
1134
  originalUpdateBlock(blockId, updates);
648
1135
  };
649
1136
  editor.updateSettings = (updates) => {
1137
+ if (!canMutate(editor, "updateSettings", [updates])) return;
650
1138
  history.record();
651
1139
  originalUpdateSettings(updates);
652
1140
  };
1141
+ inheritMutationGuard(originalUpdateBlock, editor.updateBlock);
1142
+ inheritMutationGuard(originalUpdateSettings, editor.updateSettings);
653
1143
  }
654
1144
  // src/diff-content.ts
655
1145
  const ROOT_KEY = "root";
656
1146
  const BLOCK_SKIP = /* @__PURE__ */ new Set([
657
1147
  "id",
658
1148
  "type",
659
- "children"
1149
+ "children",
1150
+ "l10n"
660
1151
  ]);
661
1152
  const RESIZED_SECTION_SKIP = /* @__PURE__ */ new Set([
662
1153
  "id",
663
1154
  "type",
664
1155
  "children",
665
- "columns"
1156
+ "columns",
1157
+ "l10n"
666
1158
  ]);
667
1159
  const NO_SKIP = /* @__PURE__ */ new Set();
1160
+ const SETTINGS_SKIP = /* @__PURE__ */ new Set(["locales", "l10n"]);
668
1161
  function containerKey(parent) {
669
1162
  return parent === null ? ROOT_KEY : JSON.stringify([parent.sectionId, parent.column]);
670
1163
  }
@@ -673,7 +1166,7 @@ function columnsOf(block) {
673
1166
  return block.type === "section" && Array.isArray(children) ? children : [];
674
1167
  }
675
1168
  /** Every block by id, with its container. The first occurrence of an id wins. */
676
- function indexBlocks(blocks) {
1169
+ function indexBlocks$1(blocks) {
677
1170
  const placements = /* @__PURE__ */ new Map();
678
1171
  const walk = (list, parent) => {
679
1172
  list.forEach((block, index) => {
@@ -736,6 +1229,18 @@ function keyChanges(before, after, skip) {
736
1229
  unset
737
1230
  };
738
1231
  }
1232
+ /** Changed paths within one language's layer; removed paths reset to the base. */
1233
+ function layerChanges(before = {}, after = {}) {
1234
+ const text = keyChanges(before.text ?? {}, after.text ?? {}, NO_SKIP);
1235
+ const set = keyChanges(before.set ?? {}, after.set ?? {}, NO_SKIP);
1236
+ const unset = [.../* @__PURE__ */ new Set([...text.unset, ...set.unset])];
1237
+ return {
1238
+ ...Object.keys(text.set).length > 0 ? { text: text.set } : {},
1239
+ ...Object.keys(set.set).length > 0 ? { set: set.set } : {},
1240
+ ...unset.length > 0 ? { unset } : {},
1241
+ ...before.hidden !== after.hidden ? { hidden: after.hidden === true } : {}
1242
+ };
1243
+ }
739
1244
  /**
740
1245
  * The operations that turn `before` into `after`. Blocks are matched by id, so
741
1246
  * a drag comes out as a `moveBlock` and a layout change as a `columns` change
@@ -753,13 +1258,18 @@ function keyChanges(before, after, skip) {
753
1258
  * 5. `columns` shrinkage, once the dropped columns are empty.
754
1259
  * 6. Deletion of sections whose surviving blocks have moved out.
755
1260
  * 7. Key changes on blocks, then on settings.
1261
+ * 8. Added locales, block and settings layers, then removed locales.
756
1262
  *
757
1263
  * No operation carries `expect`: an ordinary edit applies over anything that
758
1264
  * doesn't conflict with it, and locks keep two people off one block.
759
1265
  */
760
1266
  function diffContent(before, after) {
761
- const was = indexBlocks(before.blocks);
762
- const now = indexBlocks(after.blocks);
1267
+ if (before.settings.locale !== after.settings.locale && (before.settings.locales?.length || after.settings.locales?.length)) return [{
1268
+ op: "setContent",
1269
+ content: safeClone(after)
1270
+ }];
1271
+ const was = indexBlocks$1(before.blocks);
1272
+ const now = indexBlocks$1(after.blocks);
763
1273
  const ops = [];
764
1274
  const typeChanged = (id) => {
765
1275
  const next = now.get(id);
@@ -878,14 +1388,249 @@ function diffContent(before, after) {
878
1388
  ...unset.length > 0 ? { unset } : {}
879
1389
  });
880
1390
  }
881
- const settings = keyChanges(before.settings, after.settings, NO_SKIP);
1391
+ const settings = keyChanges(before.settings, after.settings, SETTINGS_SKIP);
882
1392
  if (Object.keys(settings.set).length > 0 || settings.unset.length > 0) ops.push({
883
1393
  op: "updateSettings",
884
1394
  ...Object.keys(settings.set).length > 0 ? { set: settings.set } : {},
885
1395
  ...settings.unset.length > 0 ? { unset: settings.unset } : {}
886
1396
  });
1397
+ const previousLocales = new Set(before.settings.locales ?? []);
1398
+ const nextLocales = new Set(after.settings.locales ?? []);
1399
+ for (const locale of nextLocales) if (!previousLocales.has(locale)) ops.push({
1400
+ op: "addLocale",
1401
+ locale
1402
+ });
1403
+ for (const [id, placement] of now) {
1404
+ if (!exists(id)) continue;
1405
+ const previous = was.get(id).block.l10n ?? {};
1406
+ const next = placement.block.l10n ?? {};
1407
+ for (const locale of /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(next)])) {
1408
+ if (!nextLocales.has(locale)) continue;
1409
+ const changes = layerChanges(previous[locale], next[locale]);
1410
+ if (Object.keys(changes).length > 0) ops.push({
1411
+ op: "updateBlockLocale",
1412
+ blockId: id,
1413
+ locale,
1414
+ ...changes
1415
+ });
1416
+ }
1417
+ }
1418
+ const previous = before.settings.l10n ?? {};
1419
+ const next = after.settings.l10n ?? {};
1420
+ for (const locale of /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(next)])) {
1421
+ if (!nextLocales.has(locale)) continue;
1422
+ const changes = layerChanges(previous[locale], next[locale]);
1423
+ if (Object.keys(changes).length > 0) ops.push({
1424
+ op: "updateSettingsLocale",
1425
+ locale,
1426
+ ...changes
1427
+ });
1428
+ }
1429
+ for (const locale of previousLocales) if (!nextLocales.has(locale)) ops.push({
1430
+ op: "removeLocale",
1431
+ locale
1432
+ });
887
1433
  return ops;
888
1434
  }
1435
+ // src/localized-operations.ts
1436
+ function indexBlocks(blocks) {
1437
+ const result = /* @__PURE__ */ new Map();
1438
+ const visit = (block) => {
1439
+ result.set(block.id, block);
1440
+ if (block.type === "section") block.children.flat().forEach(visit);
1441
+ };
1442
+ blocks.forEach(visit);
1443
+ return result;
1444
+ }
1445
+ function container(content, parent) {
1446
+ if (parent === null) return content.blocks;
1447
+ const section = indexBlocks(content.blocks).get(parent.sectionId);
1448
+ return section.type === "section" ? section.children[parent.column] : [];
1449
+ }
1450
+ function placement(content, id) {
1451
+ const find = (blocks, parent) => {
1452
+ const index = blocks.findIndex((block) => block.id === id);
1453
+ if (index >= 0) return {
1454
+ parent,
1455
+ after: blocks[index - 1]?.id ?? null
1456
+ };
1457
+ for (const block of blocks) {
1458
+ if (block.type !== "section") continue;
1459
+ for (const [column, children] of block.children.entries()) {
1460
+ const result = find(children, {
1461
+ sectionId: block.id,
1462
+ column
1463
+ });
1464
+ if (result) return result;
1465
+ }
1466
+ }
1467
+ };
1468
+ return find(content.blocks, null);
1469
+ }
1470
+ /** Keep leading hidden siblings before an insertion at the first visible slot. */
1471
+ function sharedAfter(shared, view, parent, after, moving) {
1472
+ if (after !== null) return after;
1473
+ const visible = container(view, parent).find((block) => block.id !== moving);
1474
+ const siblings = container(shared, parent).filter((block) => block.id !== moving);
1475
+ return siblings[(visible ? siblings.findIndex((block) => block.id === visible.id) : siblings.length) - 1]?.id ?? null;
1476
+ }
1477
+ function writeLayer(target, locale, layer) {
1478
+ if (Object.keys(layer).length) (target.l10n ??= {})[locale] = layer;
1479
+ else if (target.l10n) {
1480
+ delete target.l10n[locale];
1481
+ if (!Object.keys(target.l10n).length) delete target.l10n;
1482
+ }
1483
+ }
1484
+ function stripLayers(block) {
1485
+ const result = safeClone(block);
1486
+ delete result.l10n;
1487
+ if (result.type === "section") result.children = result.children.map((column) => column.map(stripLayers));
1488
+ return result;
1489
+ }
1490
+ /**
1491
+ * Turn operations written against `resolveLocale(base, locale)` into
1492
+ * operations on `base`: changed translatable text becomes `text` (stamped
1493
+ * with the base value's hash), other changed properties become `set`,
1494
+ * structure changes go to the shared tree (a new block's values become
1495
+ * default values, as a paste in that view does). Throws the engine's
1496
+ * OperationError when `ops` don't apply to the projection or include a
1497
+ * whole-template `setContent` replacement.
1498
+ */
1499
+ function mapLocalizedOperations(base, locale, ops, definitions) {
1500
+ let view = resolveLocale(base, locale);
1501
+ let shared = safeClone(base);
1502
+ const initialSettings = safeClone(view.settings);
1503
+ let settingsChanged = false;
1504
+ const lifetimes = /* @__PURE__ */ new Map();
1505
+ const register = (blocks, added) => {
1506
+ for (const [id, block] of indexBlocks(blocks)) lifetimes.set(id, {
1507
+ initial: localizeBlock(block, locale, defaultLocaleOf(base)),
1508
+ added,
1509
+ changed: false
1510
+ });
1511
+ };
1512
+ register(view.blocks, false);
1513
+ const mapped = [];
1514
+ const applyAt = (content, operations, index) => {
1515
+ if (operations.some((operation) => operation?.op === "setDefaultLocale")) throw new OperationError("invalid_operation", index, "Changing the default language isn't possible in a language view; switch to the default language.");
1516
+ if (operations.some((operation) => operation?.op === "setContent")) throw new OperationError("invalid_operation", index, "Replacing the whole template isn't possible in a language view; switch to the default language.");
1517
+ try {
1518
+ return applyOperations(content, operations);
1519
+ } catch (error) {
1520
+ if (error instanceof OperationError) throw new OperationError(error.code, index, error.message, error.blockId);
1521
+ throw error;
1522
+ }
1523
+ };
1524
+ const emit = (operations, index) => {
1525
+ shared = applyAt(shared, operations, index);
1526
+ mapped.push(...operations);
1527
+ };
1528
+ const flush = (id, index) => {
1529
+ const lifetime = lifetimes.get(id);
1530
+ if (!lifetime?.changed) return;
1531
+ const block = indexBlocks(shared.blocks).get(id);
1532
+ const final = indexBlocks(view.blocks).get(id);
1533
+ const next = safeClone(shared);
1534
+ const siblings = container(next, placement(next, id).parent);
1535
+ let target = safeClone(block);
1536
+ if (lifetime.added) {
1537
+ target = stripLayers(final);
1538
+ if (target.type === "section" && block.type === "section") {
1539
+ target.children = block.children;
1540
+ target.columns = block.columns;
1541
+ }
1542
+ } else {
1543
+ const structuralView = target.type === "section" ? {
1544
+ ...final,
1545
+ children: target.children,
1546
+ columns: target.columns
1547
+ } : final;
1548
+ reconcileStructure(target, structuralView, (blockId) => indexBlocks(shared.blocks).get(blockId) ?? null);
1549
+ target = pruneLocaleOrphans(target);
1550
+ const previous = target.l10n?.[locale];
1551
+ writeLayer(target, locale, changedLayer(previous, deriveLayer(target, lifetime.initial, previous, definitions), deriveLayer(target, final, previous, definitions), target, final));
1552
+ }
1553
+ siblings[siblings.findIndex((candidate) => candidate.id === id)] = target;
1554
+ emit(diffContent(shared, next), index);
1555
+ lifetime.changed = false;
1556
+ };
1557
+ const resize = (op, after, index) => {
1558
+ const section = indexBlocks(shared.blocks).get(op.blockId);
1559
+ const projected = indexBlocks(after.blocks).get(op.blockId);
1560
+ if (section.type !== "section" || projected.type !== "section" || section.columns === projected.columns) return;
1561
+ const count = projected.children.length;
1562
+ const kept = section.children.slice(0, count);
1563
+ const overflow = section.children.slice(count).flat();
1564
+ const target = [...kept[count - 1] ?? [], ...overflow];
1565
+ for (const [offset, block] of overflow.entries()) emit([{
1566
+ op: "moveBlock",
1567
+ blockId: block.id,
1568
+ parent: {
1569
+ sectionId: section.id,
1570
+ column: count - 1
1571
+ },
1572
+ after: target[target.length - overflow.length + offset - 1]?.id ?? null
1573
+ }], index);
1574
+ const current = indexBlocks(shared.blocks).get(op.blockId);
1575
+ emit([{
1576
+ op: "updateBlock",
1577
+ blockId: op.blockId,
1578
+ set: { columns: projected.columns },
1579
+ ...op.expect ? { expect: Object.fromEntries(Object.keys(op.expect).map((key) => [key, readPath(current, key) ?? null])) } : {}
1580
+ }], index);
1581
+ };
1582
+ const mapOne = (op, index) => {
1583
+ if (op?.op === "updateSettings" && (Object.hasOwn(op.set ?? {}, "locale") || Array.isArray(op.unset) && op.unset.includes("locale"))) applyAt(shared, [op], index);
1584
+ const after = applyAt(view, [op], index);
1585
+ switch (op.op) {
1586
+ case "addBlock": {
1587
+ const block = stripLayers(op.block);
1588
+ emit([{
1589
+ ...op,
1590
+ block,
1591
+ after: sharedAfter(shared, view, op.parent, op.after)
1592
+ }], index);
1593
+ register([block], true);
1594
+ break;
1595
+ }
1596
+ case "moveBlock":
1597
+ emit([{
1598
+ ...op,
1599
+ after: sharedAfter(shared, view, op.parent, op.after, op.blockId),
1600
+ ...op.expect ? { expect: placement(shared, op.blockId) } : {}
1601
+ }], index);
1602
+ break;
1603
+ case "deleteBlock": {
1604
+ const ids = [...indexBlocks([indexBlocks(shared.blocks).get(op.blockId)]).keys()];
1605
+ for (const id of ids) flush(id, index);
1606
+ emit([{
1607
+ op: "deleteBlock",
1608
+ blockId: op.blockId
1609
+ }], index);
1610
+ for (const id of ids) lifetimes.delete(id);
1611
+ break;
1612
+ }
1613
+ case "updateBlock":
1614
+ resize(op, after, index);
1615
+ lifetimes.get(op.blockId).changed = true;
1616
+ break;
1617
+ case "updateSettings":
1618
+ settingsChanged = true;
1619
+ break;
1620
+ default: emit([op], index);
1621
+ }
1622
+ view = after;
1623
+ };
1624
+ ops.forEach(mapOne);
1625
+ for (const id of lifetimes.keys()) flush(id, ops.length - 1);
1626
+ if (settingsChanged) {
1627
+ const next = safeClone(shared);
1628
+ const previous = next.settings.l10n?.[locale];
1629
+ writeLayer(next.settings, locale, changedLayer(previous, deriveSettingsLayer(next.settings, initialSettings, previous), deriveSettingsLayer(next.settings, view.settings, previous), next.settings, view.settings));
1630
+ emit(diffContent(shared, next), ops.length - 1);
1631
+ }
1632
+ return mapped;
1633
+ }
889
1634
  /** A revision that hasn't arrived after this long is fetched. */
890
1635
  const COLLABORATION_GAP_TIMEOUT_MS = 1e3;
891
1636
  /** A held lock is renewed this often; the backend lets it lapse after 30 s. */
@@ -913,6 +1658,12 @@ const REFUSAL_CODES = /* @__PURE__ */ new Set([
913
1658
  "lock_lost",
914
1659
  "content_too_large"
915
1660
  ]);
1661
+ /** `entry` guarded for `view` when it restores a snapshot. */
1662
+ function guarded(entry, view) {
1663
+ if (entry.ops.some((op) => op.op === "setContent")) entry.view = view;
1664
+ else delete entry.view;
1665
+ return entry;
1666
+ }
916
1667
  function refusalCode(error) {
917
1668
  const code = error?.code;
918
1669
  return typeof code === "string" && REFUSAL_CODES.has(code) ? code : null;
@@ -938,7 +1689,7 @@ function containsBlock(blocks, blockId) {
938
1689
  function isCoalescible(ops) {
939
1690
  if (ops.length === 1 && ops[0].op === "updateSettings") return true;
940
1691
  const first = ops[0];
941
- return first.op === "updateBlock" && ops.every((op) => op.op === "updateBlock" && op.blockId === first.blockId);
1692
+ return first.op === "updateBlock" && ops.every((op) => op.op === "updateBlock" && op.blockId === first.blockId) || first.op === "updateBlockLocale" && ops.every((op) => op.op === "updateBlockLocale" && op.blockId === first.blockId && op.locale === first.locale);
942
1693
  }
943
1694
  /**
944
1695
  * A remote batch without its preconditions. The backend checked them against
@@ -981,6 +1732,10 @@ function useCollaboration(options) {
981
1732
  const locking = typeof provider.acquireLock === "function" && typeof provider.renewLock === "function" && typeof provider.releaseLock === "function";
982
1733
  const members = shallowRef([]);
983
1734
  const locks = shallowRef(/* @__PURE__ */ new Map());
1735
+ const provisionalLocks = /* @__PURE__ */ new WeakSet();
1736
+ const liveLockVersions = /* @__PURE__ */ new WeakMap();
1737
+ let liveLockVersion = 0;
1738
+ const lockLocale = ref(null);
984
1739
  const connection = ref(null);
985
1740
  const revision = ref(0);
986
1741
  const historyVersion = ref(0);
@@ -988,6 +1743,8 @@ function useCollaboration(options) {
988
1743
  let active = null;
989
1744
  let suspended = false;
990
1745
  let writing = false;
1746
+ /** True while `rebuild` replays: `pump` holds batches until it ends. */
1747
+ let rebuilding = false;
991
1748
  let captureQueued = false;
992
1749
  let typingTimer = null;
993
1750
  let typingSince = 0;
@@ -1017,7 +1774,8 @@ function useCollaboration(options) {
1017
1774
  return {
1018
1775
  locks: [...locks.value.values()].map((lock) => ({
1019
1776
  blockId: lock.blockId,
1020
- sessionId: lock.holder.sessionId
1777
+ sessionId: lock.holder.sessionId,
1778
+ locale: lock.locale
1021
1779
  })),
1022
1780
  session: sessionId
1023
1781
  };
@@ -1103,7 +1861,10 @@ function useCollaboration(options) {
1103
1861
  } catch (error) {
1104
1862
  if (!(error instanceof OperationError)) throw error;
1105
1863
  renderView(session.view);
1106
- const holder = error.blockId === void 0 ? void 0 : locks.value.get(error.blockId)?.holder;
1864
+ const holder = error.blockId === void 0 ? void 0 : locksOf(error.blockId).find((lock) => {
1865
+ const op = proposed[error.opIndex];
1866
+ return op.op !== "updateBlockLocale" || lock.locale == null || lock.locale === op.locale;
1867
+ })?.holder;
1107
1868
  notify({
1108
1869
  type: "editRefused",
1109
1870
  code: error.code,
@@ -1131,32 +1892,42 @@ function useCollaboration(options) {
1131
1892
  return;
1132
1893
  }
1133
1894
  }
1134
- const entry = { ops: invertOperations$1(session.view, ops) };
1895
+ const entry = guarded({ ops: invertOperations$1(session.view, ops) }, next);
1135
1896
  pushCapped(session.undo, entry);
1136
1897
  session.redo = [];
1137
1898
  bumpHistory();
1899
+ const base = session.view;
1138
1900
  session.view = next;
1139
- enqueue(session, ops, "edit", entry);
1901
+ enqueue(session, ops, "edit", entry, base);
1140
1902
  }
1141
- function enqueue(session, ops, origin, entry) {
1903
+ /** Queue `ops`, built on `base`, as one or more batches and send them. */
1904
+ function enqueue(session, ops, origin, entry, base) {
1142
1905
  const group = { dropped: false };
1143
1906
  let offset = 0;
1907
+ let chunkBase = base;
1144
1908
  do {
1909
+ const chunk = ops.slice(offset, offset + MAX_BATCH_OPS);
1145
1910
  session.pending.push({
1146
1911
  batchId: crypto.randomUUID(),
1147
- ops: ops.slice(offset, offset + MAX_BATCH_OPS),
1912
+ ops: chunk,
1148
1913
  origin,
1149
1914
  entry,
1150
1915
  group,
1151
1916
  sent: false,
1152
- revision: null
1917
+ revision: null,
1918
+ ...chunkBase && chunk.some((op) => op.op === "setContent") ? { base: chunkBase } : {}
1153
1919
  });
1154
1920
  offset += MAX_BATCH_OPS;
1921
+ if (chunkBase && ops.slice(offset).some((op) => op.op === "setContent")) try {
1922
+ chunkBase = applyOperations(chunkBase, chunk);
1923
+ } catch {
1924
+ chunkBase = null;
1925
+ }
1155
1926
  } while (offset < ops.length);
1156
1927
  pump(session);
1157
1928
  }
1158
1929
  function pump(session) {
1159
- if (session.inFlight) return;
1930
+ if (session.inFlight || rebuilding) return;
1160
1931
  const next = session.pending.find((batch) => !batch.sent);
1161
1932
  if (!next) {
1162
1933
  settleWaiters(session);
@@ -1169,7 +1940,7 @@ function useCollaboration(options) {
1169
1940
  function send(session, batch, attempt) {
1170
1941
  provider.submit(session.templateId, {
1171
1942
  batchId: batch.batchId,
1172
- baseRevision: session.revision,
1943
+ baseRevision: batch.baseRevision ??= session.revision,
1173
1944
  ops: batch.ops,
1174
1945
  ...batch.origin === "ai" ? { origin: "ai" } : {}
1175
1946
  }).then(({ revision: confirmedAt }) => {
@@ -1319,7 +2090,8 @@ function useCollaboration(options) {
1319
2090
  for (const batch of applied) try {
1320
2091
  options.onRemoteChange?.({
1321
2092
  actor: batch.actor,
1322
- ops: batch.ops
2093
+ ops: batch.ops,
2094
+ locale: batch.ops.find((op) => op.op !== "setDefaultLocale" && "locale" in op)?.locale ?? null
1323
2095
  });
1324
2096
  } catch (error) {
1325
2097
  reportError(error);
@@ -1327,27 +2099,39 @@ function useCollaboration(options) {
1327
2099
  }
1328
2100
  /** Recompute the view from `confirmed`, replaying what is still pending. */
1329
2101
  function rebuild(session) {
2102
+ const outer = !rebuilding;
2103
+ rebuilding = true;
2104
+ try {
2105
+ replay(session);
2106
+ } finally {
2107
+ if (outer) rebuilding = false;
2108
+ }
2109
+ if (outer) pump(session);
2110
+ }
2111
+ /** `rebuild`'s work, with sending held until it ends. */
2112
+ function replay(session) {
1330
2113
  if (session === active) flush();
1331
2114
  let view = session.confirmed;
1332
2115
  for (let index = 0; index < session.pending.length; index++) {
1333
2116
  const batch = session.pending[index];
1334
- try {
2117
+ let code;
2118
+ if (batch.base && !deepEqual(batch.base, view)) code = "precondition_failed";
2119
+ else try {
1335
2120
  view = applyOperations(view, batch.ops);
2121
+ continue;
1336
2122
  } catch (error) {
1337
- if (batch.sent) continue;
1338
- if (dropRefusedGroup(session, batch)) {
1339
- const code = error instanceof OperationError ? error.code : "invalid_operation";
1340
- notify(batch.origin === "edit" || batch.origin === "ai" ? {
1341
- type: "editRefused",
1342
- code
1343
- } : {
1344
- type: "undoSkipped",
1345
- code
1346
- }, session);
1347
- }
1348
- view = session.confirmed;
1349
- index = -1;
2123
+ code = error instanceof OperationError ? error.code : "invalid_operation";
1350
2124
  }
2125
+ if (batch.sent) continue;
2126
+ if (dropRefusedGroup(session, batch)) notify(batch.origin === "edit" || batch.origin === "ai" ? {
2127
+ type: "editRefused",
2128
+ code
2129
+ } : {
2130
+ type: "undoSkipped",
2131
+ code
2132
+ }, session);
2133
+ view = session.confirmed;
2134
+ index = -1;
1351
2135
  }
1352
2136
  session.view = view;
1353
2137
  settleWaiters(session);
@@ -1430,8 +2214,8 @@ function useCollaboration(options) {
1430
2214
  onLock: (lock) => {
1431
2215
  if (live()) setLock(lock);
1432
2216
  },
1433
- onUnlock: (blockId, holderSession) => {
1434
- if (live()) removeLock(blockId, holderSession);
2217
+ onUnlock: (blockId, holderSession, locale) => {
2218
+ if (live()) removeLock(blockId, holderSession, locale);
1435
2219
  },
1436
2220
  onMembers: (next) => {
1437
2221
  if (live()) setMembers(next);
@@ -1442,13 +2226,14 @@ function useCollaboration(options) {
1442
2226
  };
1443
2227
  }
1444
2228
  function join(session, refresh) {
2229
+ const locksAtJoin = liveLockVersion;
1445
2230
  provider.join(session.templateId, { sessionId }, handlersFor(session)).then((joined) => {
1446
2231
  if (session.retired || session !== active) {
1447
2232
  if (!refresh) joined.leave();
1448
2233
  return;
1449
2234
  }
1450
2235
  session.leave = joined.leave;
1451
- replaceLocks(joined.locks, !refresh);
2236
+ replaceLocks(joined.locks, !refresh, locksAtJoin);
1452
2237
  setMembers(joined.members);
1453
2238
  if (joined.members.length > 0) connection.value = "connected";
1454
2239
  if (joined.revision > session.revision) catchUp();
@@ -1509,12 +2294,29 @@ function useCollaboration(options) {
1509
2294
  bumpHistory();
1510
2295
  if (template) start(template);
1511
2296
  }
1512
- function setLock(lock) {
2297
+ function lockKey(blockId, locale) {
2298
+ return `${blockId}\u0000${locale ?? ""}`;
2299
+ }
2300
+ function lockConflicts(lock, blockId, locale) {
2301
+ return lock.blockId === blockId && (locale == null || lock.locale == null || lock.locale === locale);
2302
+ }
2303
+ function locksOf(blockId) {
2304
+ return [...locks.value.values()].filter((lock) => lock.blockId === blockId);
2305
+ }
2306
+ function setLockLocale(locale) {
2307
+ if (locale === lockLocale.value) return;
2308
+ flush();
2309
+ lockLocale.value = locale;
2310
+ onSelection(editor?.state.selectedBlockId ?? null);
2311
+ }
2312
+ function setLock(lock, provisional = false) {
1513
2313
  if (lock.holder.sessionId === sessionId) return;
2314
+ if (provisional) provisionalLocks.add(lock);
2315
+ else liveLockVersions.set(lock, ++liveLockVersion);
1514
2316
  const next = new Map([...locks.value].filter(([, existing]) => existing.holder.sessionId !== lock.holder.sessionId));
1515
- next.set(lock.blockId, lock);
2317
+ next.set(lockKey(lock.blockId, lock.locale), lock);
1516
2318
  locks.value = next;
1517
- if (editor?.state.selectedBlockId === lock.blockId) {
2319
+ if (editor?.state.selectedBlockId === lock.blockId && (lockLocale.value === null || lock.locale == null || lock.locale === lockLocale.value)) {
1518
2320
  editor.selectBlock(null);
1519
2321
  notify({
1520
2322
  type: "selectionRefused",
@@ -1523,18 +2325,19 @@ function useCollaboration(options) {
1523
2325
  });
1524
2326
  }
1525
2327
  }
1526
- function removeLock(blockId, holderSession) {
1527
- if (locks.value.get(blockId)?.holder.sessionId !== holderSession) return;
1528
- const next = new Map(locks.value);
1529
- next.delete(blockId);
1530
- locks.value = next;
2328
+ function removeLock(blockId, holderSession, locale) {
2329
+ const key = lockKey(blockId, locale);
2330
+ const next = new Map([...locks.value].filter(([existingKey, lock]) => lock.holder.sessionId !== holderSession || existingKey !== key && !(lock.blockId === blockId && provisionalLocks.has(lock))));
2331
+ if (next.size !== locks.value.size) locks.value = next;
1531
2332
  }
1532
- function replaceLocks(list, merge) {
1533
- const next = merge ? new Map(locks.value) : /* @__PURE__ */ new Map();
2333
+ function replaceLocks(list, merge, locksAtJoin) {
2334
+ const next = new Map([...locks.value].filter(([, lock]) => !provisionalLocks.has(lock) && (merge || (liveLockVersions.get(lock) ?? 0) > locksAtJoin)));
2335
+ const live = [...next.values()];
1534
2336
  for (const lock of list) {
1535
2337
  if (lock.holder.sessionId === sessionId) continue;
1536
- if (merge && next.has(lock.blockId)) continue;
1537
- next.set(lock.blockId, lock);
2338
+ if (live.some((existing) => existing.holder.sessionId === lock.holder.sessionId || lockConflicts(existing, lock.blockId, lock.locale))) continue;
2339
+ for (const [key, existing] of next) if (existing.holder.sessionId === lock.holder.sessionId) next.delete(key);
2340
+ next.set(lockKey(lock.blockId, lock.locale), lock);
1538
2341
  }
1539
2342
  locks.value = next;
1540
2343
  }
@@ -1553,36 +2356,34 @@ function useCollaboration(options) {
1553
2356
  function reconcileLock() {
1554
2357
  if (!locking) return;
1555
2358
  queueLock(async () => {
1556
- if (held && (wanted === null || held.templateId !== wanted.templateId)) {
2359
+ if (held && (wanted === null || held.templateId !== wanted.templateId || held.locale !== wanted.locale)) {
1557
2360
  const releasing = held;
1558
2361
  held = null;
1559
2362
  stopRenewing();
1560
- await provider.releaseLock(releasing.templateId, releasing.blockId);
2363
+ await provider.releaseLock(releasing.templateId, releasing.blockId, releasing.locale);
1561
2364
  }
1562
2365
  const target = wanted;
1563
- if (!target || held?.templateId === target.templateId && held.blockId === target.blockId) return;
2366
+ if (!target || held?.templateId === target.templateId && held.blockId === target.blockId && held.locale === target.locale) return;
1564
2367
  try {
1565
- await provider.acquireLock(target.templateId, target.blockId);
2368
+ const granted = await provider.acquireLock(target.templateId, target.blockId, target.locale);
1566
2369
  held = target;
1567
2370
  startRenewing();
1568
- forgetLock(target.blockId);
2371
+ forgetLock(granted.blockId, granted.locale ?? null);
1569
2372
  } catch (error) {
1570
2373
  if (held?.templateId === target.templateId) {
1571
2374
  const releasing = held;
1572
2375
  held = null;
1573
2376
  stopRenewing();
1574
- await provider.releaseLock(releasing.templateId, releasing.blockId).catch(() => {});
2377
+ await provider.releaseLock(releasing.templateId, releasing.blockId, releasing.locale).catch(() => {});
1575
2378
  }
1576
2379
  if (wanted !== target) return;
1577
2380
  refuseSelection(target.blockId, error);
1578
2381
  }
1579
2382
  });
1580
2383
  }
1581
- function forgetLock(blockId) {
1582
- if (!locks.value.has(blockId)) return;
1583
- const next = new Map(locks.value);
1584
- next.delete(blockId);
1585
- locks.value = next;
2384
+ function forgetLock(blockId, locale) {
2385
+ const next = new Map([...locks.value].filter(([, lock]) => !lockConflicts(lock, blockId, locale)));
2386
+ if (next.size !== locks.value.size) locks.value = next;
1586
2387
  }
1587
2388
  function refuseSelection(blockId, error) {
1588
2389
  const code = refusalCode(error);
@@ -1593,7 +2394,7 @@ function useCollaboration(options) {
1593
2394
  blockId,
1594
2395
  holder,
1595
2396
  expiresAt: ""
1596
- });
2397
+ }, true);
1597
2398
  else if (editor?.state.selectedBlockId === blockId) editor.selectBlock(null);
1598
2399
  return;
1599
2400
  }
@@ -1609,7 +2410,7 @@ function useCollaboration(options) {
1609
2410
  const current = held;
1610
2411
  if (!current) return;
1611
2412
  try {
1612
- await provider.renewLock(current.templateId, current.blockId);
2413
+ await provider.renewLock(current.templateId, current.blockId, current.locale);
1613
2414
  } catch (error) {
1614
2415
  if (refusalCode(error) === "lock_lost") {
1615
2416
  held = null;
@@ -1633,7 +2434,8 @@ function useCollaboration(options) {
1633
2434
  if (!active) return;
1634
2435
  wanted = blockId ? {
1635
2436
  templateId: active.templateId,
1636
- blockId
2437
+ blockId,
2438
+ locale: lockLocale.value
1637
2439
  } : null;
1638
2440
  reconcileLock();
1639
2441
  }
@@ -1641,7 +2443,7 @@ function useCollaboration(options) {
1641
2443
  function onPageHide() {
1642
2444
  flush();
1643
2445
  if (held && locking) {
1644
- provider.releaseLock(held.templateId, held.blockId).catch(() => {});
2446
+ provider.releaseLock(held.templateId, held.blockId, held.locale).catch(() => {});
1645
2447
  held = null;
1646
2448
  stopRenewing();
1647
2449
  }
@@ -1655,6 +2457,13 @@ function useCollaboration(options) {
1655
2457
  const entry = (direction === "undo" ? session.undo : session.redo).pop();
1656
2458
  if (!entry) return;
1657
2459
  bumpHistory();
2460
+ if (entry.view && !deepEqual(entry.view, session.view)) {
2461
+ notify({
2462
+ type: "undoSkipped",
2463
+ code: "precondition_failed"
2464
+ });
2465
+ return;
2466
+ }
1658
2467
  let next;
1659
2468
  try {
1660
2469
  next = applyOperations(session.view, entry.ops, lockContext());
@@ -1666,12 +2475,13 @@ function useCollaboration(options) {
1666
2475
  });
1667
2476
  return;
1668
2477
  }
1669
- const counter = { ops: invertOperations$1(session.view, entry.ops) };
2478
+ const counter = guarded({ ops: invertOperations$1(session.view, entry.ops) }, next);
1670
2479
  pushCapped(direction === "undo" ? session.redo : session.undo, counter);
2480
+ const base = session.view;
1671
2481
  session.view = next;
1672
2482
  renderView(next);
1673
2483
  editor?.markDirty();
1674
- enqueue(session, entry.ops, direction, counter);
2484
+ enqueue(session, entry.ops, direction, counter, base);
1675
2485
  }
1676
2486
  function applyFromTurn(ops, options) {
1677
2487
  const session = active;
@@ -1679,6 +2489,15 @@ function useCollaboration(options) {
1679
2489
  applied: false,
1680
2490
  error: new OperationError("invalid_operation", 0, "No template is open for editing.")
1681
2491
  };
2492
+ if (options.origin === void 0) try {
2493
+ applyOperations(editor.content.value, ops, lockContext());
2494
+ } catch (error) {
2495
+ if (error instanceof OperationError) return {
2496
+ applied: false,
2497
+ error
2498
+ };
2499
+ throw error;
2500
+ }
1682
2501
  flush();
1683
2502
  let next;
1684
2503
  let inverse;
@@ -1694,22 +2513,24 @@ function useCollaboration(options) {
1694
2513
  }
1695
2514
  const top = session.undo.at(-1);
1696
2515
  let entry;
1697
- if (top && top.turn === options.turn) {
2516
+ if (options.origin === "ai" && options.turn !== void 0 && top && top.turn === options.turn) {
2517
+ const overtaken = top.view !== void 0 && !deepEqual(top.view, session.view);
1698
2518
  top.ops = [...inverse, ...top.ops];
1699
- entry = top;
2519
+ entry = overtaken ? top : guarded(top, next);
1700
2520
  } else {
1701
- entry = {
2521
+ entry = guarded({
1702
2522
  ops: inverse,
1703
- turn: options.turn
1704
- };
2523
+ ...options.origin === "ai" && options.turn !== void 0 ? { turn: options.turn } : {}
2524
+ }, next);
1705
2525
  pushCapped(session.undo, entry);
1706
2526
  }
1707
2527
  session.redo = [];
1708
2528
  bumpHistory();
2529
+ const base = session.view;
1709
2530
  session.view = next;
1710
2531
  renderView(next);
1711
2532
  editor.markDirty();
1712
- enqueue(session, ops, options.origin, entry);
2533
+ enqueue(session, ops, options.origin ?? "edit", entry, base);
1713
2534
  return {
1714
2535
  applied: true,
1715
2536
  inverse
@@ -1717,6 +2538,35 @@ function useCollaboration(options) {
1717
2538
  }
1718
2539
  function attach(target) {
1719
2540
  editor = target;
2541
+ const structural = target;
2542
+ if (structural.moveBlock && structural.findBlockLocation) {
2543
+ const original = structural.moveBlock;
2544
+ const move = (...args) => {
2545
+ const session = active;
2546
+ const capturing = session && !suspended && !writing && !target.state.isLoading && target.state.template?.id === session.templateId;
2547
+ if (capturing) capture(true);
2548
+ original(...args);
2549
+ if (!capturing || deepEqual(session.view, target.content.value)) return;
2550
+ const location = structural.findBlockLocation(args[0]);
2551
+ if (!location) return;
2552
+ const parent = location.targetSectionId ? {
2553
+ sectionId: location.targetSectionId,
2554
+ column: location.columnIndex ?? 0
2555
+ } : null;
2556
+ const section = parent ? target.content.value.blocks.find((block) => block.id === parent.sectionId) : void 0;
2557
+ const list = parent && section?.type === "section" ? section.children[parent.column] : target.content.value.blocks;
2558
+ commit(session, [{
2559
+ op: "moveBlock",
2560
+ blockId: args[0],
2561
+ parent,
2562
+ after: list[location.index - 1]?.id ?? null
2563
+ }]);
2564
+ };
2565
+ structural.moveBlock = move;
2566
+ stopWatching.push(() => {
2567
+ if (structural.moveBlock === move) structural.moveBlock = original;
2568
+ });
2569
+ }
1720
2570
  stopWatching.push(watch(() => target.state.template, (template) => switchTo(template), { immediate: true }), watch(() => target.content.value, scheduleCapture, { deep: true }), watch(() => target.state.selectedBlockId, onSelection), watch(() => target.state.isLoading, (loading) => loading ? capture(true, true) : scheduleCapture()));
1721
2571
  if (typeof window !== "undefined") {
1722
2572
  window.addEventListener("pagehide", onPageHide);
@@ -1760,6 +2610,9 @@ function useCollaboration(options) {
1760
2610
  sessionId,
1761
2611
  members,
1762
2612
  locks,
2613
+ locksOf,
2614
+ lockLocale,
2615
+ setLockLocale,
1763
2616
  connection,
1764
2617
  revision,
1765
2618
  canUndo,
@@ -2525,6 +3378,6 @@ function useCommentListener(options) {
2525
3378
  }, { immediate: true });
2526
3379
  onScopeDispose$1(stop);
2527
3380
  }
2528
- export { DEFAULT_AUTO_SAVE_DEBOUNCE_MS, createLocalStorageMediaProvider, createLocalStorageSavedBlocksProvider, diffContent, invertOperations, useAutoSave, useBlockActions, useCollaboration, useCommentListener, useComments, useConditionPreview, useDataSourceFetch, useEditor, useHistory, useHistoryInterceptor, useSavedBlocks, useVersionHistory };
3381
+ export { DEFAULT_AUTO_SAVE_DEBOUNCE_MS, createLocalStorageMediaProvider, createLocalStorageSavedBlocksProvider, diffContent, invertOperations, mapLocalizedOperations, useAutoSave, useBlockActions, useCollaboration, useCommentListener, useComments, useConditionPreview, useDataSourceFetch, useEditor, useHistory, useHistoryInterceptor, useSavedBlocks, useVersionHistory };
2529
3382
 
2530
3383
  //# sourceMappingURL=index.js.map