@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/cloud/index.js.map +1 -1
- package/dist/index.d.ts +53 -11
- package/dist/index.js +957 -104
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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 (
|
|
518
|
+
if (!canUpdateBlock(blockId, updates)) return;
|
|
142
519
|
const block = findBlockById(state.content.blocks, blockId);
|
|
143
|
-
if (block)
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 (
|
|
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 (
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
640
|
-
|
|
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
|
-
|
|
762
|
-
|
|
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,
|
|
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 :
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1528
|
-
const next = new Map(locks.value);
|
|
1529
|
-
next.
|
|
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 =
|
|
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 (
|
|
1537
|
-
next
|
|
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(
|
|
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
|
-
|
|
1583
|
-
|
|
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
|