@templatical/core 0.48.0 → 0.50.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/README.md +3 -1
- package/dist/cloud/index.d.ts +3 -151
- package/dist/cloud/index.js +2 -907
- package/dist/cloud/index.js.map +1 -1
- package/dist/index.d.ts +286 -7
- package/dist/index.js +1152 -18
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/dist/editor-Bl4WsPU3.d.ts +0 -105
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { SdkError, assertNoSlotInContent, assertNoWrapperInContent, cloneBlock, createBlock, createDefaultTemplateContent, generateId, safeClone } from "@templatical/types";
|
|
2
|
-
import { computed, reactive, readonly, ref, watch } from "@vue/reactivity";
|
|
3
|
-
import { computed as computed$1, onScopeDispose, ref as ref$1, watch as watch$1 } from "vue";
|
|
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";
|
|
3
|
+
import { computed as computed$1, onScopeDispose as onScopeDispose$1, ref as ref$1, watch as watch$1 } from "vue";
|
|
4
4
|
//#region src/error-reporting.ts
|
|
5
5
|
/**
|
|
6
6
|
* Turn a thrown value into an `Error`.
|
|
@@ -32,11 +32,6 @@ function notifyHandler(onError, run) {
|
|
|
32
32
|
}
|
|
33
33
|
//#endregion
|
|
34
34
|
//#region src/editor.ts
|
|
35
|
-
function getColumnCount(layout) {
|
|
36
|
-
if (layout === "1") return 1;
|
|
37
|
-
if (layout === "3") return 3;
|
|
38
|
-
return 2;
|
|
39
|
-
}
|
|
40
35
|
function useEditor(options) {
|
|
41
36
|
const seedContent = options.content ?? createDefaultTemplateContent(options.defaultFontFamily, options.templateDefaults);
|
|
42
37
|
assertNoSlotInContent(seedContent);
|
|
@@ -53,6 +48,8 @@ function useEditor(options) {
|
|
|
53
48
|
isLoading: false,
|
|
54
49
|
uiTheme: "auto"
|
|
55
50
|
});
|
|
51
|
+
const loadVersion = ref(0);
|
|
52
|
+
let loadingRequest = 0;
|
|
56
53
|
/**
|
|
57
54
|
* Bumped by every content mutation. `save()` captures it before awaiting the
|
|
58
55
|
* provider and only clears `isDirty` if it is unchanged afterwards, so an edit
|
|
@@ -257,6 +254,7 @@ function useEditor(options) {
|
|
|
257
254
|
const { create: providerCreate } = provider;
|
|
258
255
|
if (typeof providerCreate !== "function") refuse("create()", "is disabled by the provider — its `create` is `false`.");
|
|
259
256
|
let created;
|
|
257
|
+
const request = ++loadingRequest;
|
|
260
258
|
state.isLoading = true;
|
|
261
259
|
try {
|
|
262
260
|
if (input?.content) state.content = input.content;
|
|
@@ -267,31 +265,35 @@ function useEditor(options) {
|
|
|
267
265
|
} : { content: state.content });
|
|
268
266
|
state.template = created;
|
|
269
267
|
if (revision === revisionAtRequest) state.isDirty = false;
|
|
268
|
+
loadVersion.value++;
|
|
270
269
|
} catch (error) {
|
|
271
270
|
options.onError?.(error);
|
|
272
271
|
throw error;
|
|
273
272
|
} finally {
|
|
274
|
-
state.isLoading = false;
|
|
273
|
+
if (loadingRequest === request) state.isLoading = false;
|
|
275
274
|
}
|
|
276
275
|
notify(() => provider.onCreated?.(created));
|
|
277
276
|
return created;
|
|
278
277
|
}
|
|
279
|
-
async function load(templateId) {
|
|
278
|
+
async function load(templateId, shouldApply) {
|
|
280
279
|
const provider = requireProvider("load()");
|
|
281
280
|
let loaded;
|
|
281
|
+
const request = ++loadingRequest;
|
|
282
282
|
state.isLoading = true;
|
|
283
283
|
try {
|
|
284
284
|
loaded = await provider.load(templateId);
|
|
285
285
|
assertNoSlotInContent(loaded.content);
|
|
286
286
|
assertNoWrapperInContent(loaded.content);
|
|
287
|
+
if (shouldApply && !shouldApply()) return loaded;
|
|
287
288
|
state.template = loaded;
|
|
288
289
|
state.content = loaded.content;
|
|
289
290
|
state.isDirty = false;
|
|
291
|
+
loadVersion.value++;
|
|
290
292
|
} catch (error) {
|
|
291
293
|
options.onError?.(error);
|
|
292
294
|
throw error;
|
|
293
295
|
} finally {
|
|
294
|
-
state.isLoading = false;
|
|
296
|
+
if (loadingRequest === request) state.isLoading = false;
|
|
295
297
|
}
|
|
296
298
|
notify(() => provider.onLoaded?.(loaded));
|
|
297
299
|
return loaded;
|
|
@@ -333,6 +335,7 @@ function useEditor(options) {
|
|
|
333
335
|
}
|
|
334
336
|
return {
|
|
335
337
|
state: readonly(state),
|
|
338
|
+
loadVersion: readonly(loadVersion),
|
|
336
339
|
content,
|
|
337
340
|
selectedBlock,
|
|
338
341
|
isBlockLocked,
|
|
@@ -362,7 +365,7 @@ const MAX_STACK_SIZE = 50;
|
|
|
362
365
|
const DEBOUNCE_MS = 300;
|
|
363
366
|
const NAVIGATE_IDLE_MS = 1500;
|
|
364
367
|
function useHistory(options) {
|
|
365
|
-
const { content, setContent,
|
|
368
|
+
const { content, setContent, maxSize = MAX_STACK_SIZE } = options;
|
|
366
369
|
const undoStack = ref([]);
|
|
367
370
|
const redoStack = ref([]);
|
|
368
371
|
const isNavigating = ref(false);
|
|
@@ -384,13 +387,11 @@ function useHistory(options) {
|
|
|
384
387
|
}
|
|
385
388
|
}
|
|
386
389
|
function record() {
|
|
387
|
-
if (isRemoteOperation?.()) return;
|
|
388
390
|
flushPendingDebounce();
|
|
389
391
|
pushToUndoStack(cloneContent());
|
|
390
392
|
redoStack.value = [];
|
|
391
393
|
}
|
|
392
394
|
function recordDebounced(blockId) {
|
|
393
|
-
if (isRemoteOperation?.()) return;
|
|
394
395
|
if (pendingDebounce && pendingDebounce.blockId === blockId) {
|
|
395
396
|
clearTimeout(pendingDebounce.timeoutId);
|
|
396
397
|
pendingDebounce.timeoutId = setTimeout(() => {
|
|
@@ -622,8 +623,8 @@ function useDataSourceFetch(options) {
|
|
|
622
623
|
* Wraps editor mutation methods to record history snapshots before each
|
|
623
624
|
* operation. Mutates the editor object in place.
|
|
624
625
|
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
626
|
+
* Not installed with a collaboration provider: its controller captures
|
|
627
|
+
* changes by diffing the content and keeps its own undo history.
|
|
627
628
|
*/
|
|
628
629
|
function useHistoryInterceptor(editor, history) {
|
|
629
630
|
const originalAddBlock = editor.addBlock;
|
|
@@ -658,6 +659,1139 @@ function useHistoryInterceptor(editor, history) {
|
|
|
658
659
|
};
|
|
659
660
|
}
|
|
660
661
|
//#endregion
|
|
662
|
+
//#region src/diff-content.ts
|
|
663
|
+
const ROOT_KEY = "root";
|
|
664
|
+
const BLOCK_SKIP = /* @__PURE__ */ new Set([
|
|
665
|
+
"id",
|
|
666
|
+
"type",
|
|
667
|
+
"children"
|
|
668
|
+
]);
|
|
669
|
+
const RESIZED_SECTION_SKIP = /* @__PURE__ */ new Set([
|
|
670
|
+
"id",
|
|
671
|
+
"type",
|
|
672
|
+
"children",
|
|
673
|
+
"columns"
|
|
674
|
+
]);
|
|
675
|
+
const NO_SKIP = /* @__PURE__ */ new Set();
|
|
676
|
+
function containerKey(parent) {
|
|
677
|
+
return parent === null ? ROOT_KEY : JSON.stringify([parent.sectionId, parent.column]);
|
|
678
|
+
}
|
|
679
|
+
function columnsOf(block) {
|
|
680
|
+
const children = block.children;
|
|
681
|
+
return block.type === "section" && Array.isArray(children) ? children : [];
|
|
682
|
+
}
|
|
683
|
+
/** Every block by id, with its container. The first occurrence of an id wins. */
|
|
684
|
+
function indexBlocks(blocks) {
|
|
685
|
+
const placements = /* @__PURE__ */ new Map();
|
|
686
|
+
const walk = (list, parent) => {
|
|
687
|
+
list.forEach((block, index) => {
|
|
688
|
+
if (!placements.has(block.id)) placements.set(block.id, {
|
|
689
|
+
block,
|
|
690
|
+
key: containerKey(parent),
|
|
691
|
+
index
|
|
692
|
+
});
|
|
693
|
+
columnsOf(block).forEach((column, columnIndex) => {
|
|
694
|
+
if (Array.isArray(column)) walk(column, {
|
|
695
|
+
sectionId: block.id,
|
|
696
|
+
column: columnIndex
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
};
|
|
701
|
+
walk(blocks, null);
|
|
702
|
+
return placements;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* The positions in `values` of one longest strictly increasing run: the blocks
|
|
706
|
+
* that can stay where they are while the others move around them.
|
|
707
|
+
*/
|
|
708
|
+
function longestIncreasingRun(values) {
|
|
709
|
+
const tails = [];
|
|
710
|
+
const previous = new Array(values.length).fill(-1);
|
|
711
|
+
values.forEach((value, position) => {
|
|
712
|
+
let low = 0;
|
|
713
|
+
let high = tails.length;
|
|
714
|
+
while (low < high) {
|
|
715
|
+
const middle = low + high >> 1;
|
|
716
|
+
if (values[tails[middle]] < value) low = middle + 1;
|
|
717
|
+
else high = middle;
|
|
718
|
+
}
|
|
719
|
+
if (low > 0) previous[position] = tails[low - 1];
|
|
720
|
+
tails[low] = position;
|
|
721
|
+
});
|
|
722
|
+
const run = /* @__PURE__ */ new Set();
|
|
723
|
+
let position = tails.length > 0 ? tails[tails.length - 1] : -1;
|
|
724
|
+
while (position !== -1) {
|
|
725
|
+
run.add(position);
|
|
726
|
+
position = previous[position];
|
|
727
|
+
}
|
|
728
|
+
return run;
|
|
729
|
+
}
|
|
730
|
+
/** Keys whose values differ, as `set` and `unset`. `undefined` counts as absent. */
|
|
731
|
+
function keyChanges(before, after, skip) {
|
|
732
|
+
const set = {};
|
|
733
|
+
const unset = [];
|
|
734
|
+
for (const key of Object.keys(after)) {
|
|
735
|
+
if (skip.has(key) || after[key] === void 0) continue;
|
|
736
|
+
if (!deepEqual(before[key], after[key])) set[key] = safeClone(after[key]);
|
|
737
|
+
}
|
|
738
|
+
for (const key of Object.keys(before)) {
|
|
739
|
+
if (skip.has(key) || before[key] === void 0) continue;
|
|
740
|
+
if (after[key] === void 0) unset.push(key);
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
set,
|
|
744
|
+
unset
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* The operations that turn `before` into `after`. Blocks are matched by id, so
|
|
749
|
+
* a drag comes out as a `moveBlock` and a layout change as a `columns` change
|
|
750
|
+
* plus moves, never as deletions and re-additions.
|
|
751
|
+
*
|
|
752
|
+
* `applyOperations(before, diffContent(before, after))` deep-equals `after`
|
|
753
|
+
* when both are well formed: unique ids, sections only at the top level, and
|
|
754
|
+
* each section's `children` as long as its layout's column count. The order is
|
|
755
|
+
* what the engine needs:
|
|
756
|
+
*
|
|
757
|
+
* 1. Deletions that take nothing still needed with them.
|
|
758
|
+
* 2. The top level: additions (a new section carries its new children) and moves.
|
|
759
|
+
* 3. `columns` growth, so new columns exist before blocks move into them.
|
|
760
|
+
* 4. Every section column: additions and moves, out of shrinking columns too.
|
|
761
|
+
* 5. `columns` shrinkage, once the dropped columns are empty.
|
|
762
|
+
* 6. Deletion of sections whose surviving blocks have moved out.
|
|
763
|
+
* 7. Key changes on blocks, then on settings.
|
|
764
|
+
*
|
|
765
|
+
* No operation carries `expect`: an ordinary edit applies over anything that
|
|
766
|
+
* doesn't conflict with it, and locks keep two people off one block.
|
|
767
|
+
*/
|
|
768
|
+
function diffContent(before, after) {
|
|
769
|
+
const was = indexBlocks(before.blocks);
|
|
770
|
+
const now = indexBlocks(after.blocks);
|
|
771
|
+
const ops = [];
|
|
772
|
+
const typeChanged = (id) => {
|
|
773
|
+
const next = now.get(id);
|
|
774
|
+
const previous = was.get(id);
|
|
775
|
+
return next !== void 0 && previous !== void 0 && next.block.type !== previous.block.type;
|
|
776
|
+
};
|
|
777
|
+
const survives = (block) => now.has(block.id) && !typeChanged(block.id);
|
|
778
|
+
const hasSurvivor = (block) => columnsOf(block).some((column) => Array.isArray(column) && column.some((child) => survives(child) || !typeChanged(child.id) && hasSurvivor(child)));
|
|
779
|
+
const gone = /* @__PURE__ */ new Set();
|
|
780
|
+
const deferred = [];
|
|
781
|
+
const visit = (block, fate) => {
|
|
782
|
+
let childFate = "kept";
|
|
783
|
+
if (fate === "dropped") {
|
|
784
|
+
gone.add(block.id);
|
|
785
|
+
childFate = "dropped";
|
|
786
|
+
} else if (typeChanged(block.id)) {
|
|
787
|
+
ops.push({
|
|
788
|
+
op: "deleteBlock",
|
|
789
|
+
blockId: block.id
|
|
790
|
+
});
|
|
791
|
+
gone.add(block.id);
|
|
792
|
+
childFate = "dropped";
|
|
793
|
+
} else if (!now.has(block.id)) {
|
|
794
|
+
if (fate === "deferred") childFate = "deferred";
|
|
795
|
+
else if (hasSurvivor(block)) {
|
|
796
|
+
deferred.push(block.id);
|
|
797
|
+
childFate = "deferred";
|
|
798
|
+
} else {
|
|
799
|
+
ops.push({
|
|
800
|
+
op: "deleteBlock",
|
|
801
|
+
blockId: block.id
|
|
802
|
+
});
|
|
803
|
+
gone.add(block.id);
|
|
804
|
+
childFate = "dropped";
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
for (const column of columnsOf(block)) if (Array.isArray(column)) for (const child of column) visit(child, childFate);
|
|
808
|
+
};
|
|
809
|
+
for (const block of before.blocks) visit(block, "kept");
|
|
810
|
+
const exists = (id) => was.has(id) && !gone.has(id);
|
|
811
|
+
const payloadOf = (block) => {
|
|
812
|
+
const payload = safeClone(block);
|
|
813
|
+
if (payload.type === "section") payload.children = columnsOf(block).map((column) => Array.isArray(column) ? column.filter((child) => !exists(child.id)).map((child) => safeClone(child)) : column);
|
|
814
|
+
return payload;
|
|
815
|
+
};
|
|
816
|
+
const place = (parent, finalList, arrivedWithParent) => {
|
|
817
|
+
const key = containerKey(parent);
|
|
818
|
+
const indexes = [];
|
|
819
|
+
const positions = [];
|
|
820
|
+
finalList.forEach((block, position) => {
|
|
821
|
+
const previous = was.get(block.id);
|
|
822
|
+
if (exists(block.id) && previous !== void 0 && previous.key === key) {
|
|
823
|
+
indexes.push(previous.index);
|
|
824
|
+
positions.push(position);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
const staying = new Set([...longestIncreasingRun(indexes)].map((run) => positions[run]));
|
|
828
|
+
finalList.forEach((block, position) => {
|
|
829
|
+
if (staying.has(position) || arrivedWithParent(block)) return;
|
|
830
|
+
const afterId = position > 0 ? finalList[position - 1].id : null;
|
|
831
|
+
ops.push(exists(block.id) ? {
|
|
832
|
+
op: "moveBlock",
|
|
833
|
+
blockId: block.id,
|
|
834
|
+
parent,
|
|
835
|
+
after: afterId
|
|
836
|
+
} : {
|
|
837
|
+
op: "addBlock",
|
|
838
|
+
block: payloadOf(block),
|
|
839
|
+
parent,
|
|
840
|
+
after: afterId
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
};
|
|
844
|
+
place(null, after.blocks, () => false);
|
|
845
|
+
const sections = after.blocks.filter((block) => block.type === "section");
|
|
846
|
+
const resized = /* @__PURE__ */ new Set();
|
|
847
|
+
const resize = (grow) => {
|
|
848
|
+
for (const section of sections) {
|
|
849
|
+
if (!exists(section.id)) continue;
|
|
850
|
+
const fromCount = columnsOf(was.get(section.id).block).length;
|
|
851
|
+
const toCount = columnsOf(section).length;
|
|
852
|
+
if (grow ? toCount > fromCount : toCount < fromCount) {
|
|
853
|
+
ops.push({
|
|
854
|
+
op: "updateBlock",
|
|
855
|
+
blockId: section.id,
|
|
856
|
+
set: { columns: section.columns }
|
|
857
|
+
});
|
|
858
|
+
resized.add(section.id);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
resize(true);
|
|
863
|
+
for (const section of sections) {
|
|
864
|
+
const isNew = !exists(section.id);
|
|
865
|
+
columnsOf(section).forEach((column, columnIndex) => {
|
|
866
|
+
if (!Array.isArray(column)) return;
|
|
867
|
+
place({
|
|
868
|
+
sectionId: section.id,
|
|
869
|
+
column: columnIndex
|
|
870
|
+
}, column, (child) => isNew && !exists(child.id));
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
resize(false);
|
|
874
|
+
for (const id of deferred) ops.push({
|
|
875
|
+
op: "deleteBlock",
|
|
876
|
+
blockId: id
|
|
877
|
+
});
|
|
878
|
+
for (const [id, placement] of now) {
|
|
879
|
+
if (!exists(id)) continue;
|
|
880
|
+
const { set, unset } = keyChanges(was.get(id).block, placement.block, resized.has(id) ? RESIZED_SECTION_SKIP : BLOCK_SKIP);
|
|
881
|
+
if (Object.keys(set).length === 0 && unset.length === 0) continue;
|
|
882
|
+
ops.push({
|
|
883
|
+
op: "updateBlock",
|
|
884
|
+
blockId: id,
|
|
885
|
+
...Object.keys(set).length > 0 ? { set } : {},
|
|
886
|
+
...unset.length > 0 ? { unset } : {}
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
const settings = keyChanges(before.settings, after.settings, NO_SKIP);
|
|
890
|
+
if (Object.keys(settings.set).length > 0 || settings.unset.length > 0) ops.push({
|
|
891
|
+
op: "updateSettings",
|
|
892
|
+
...Object.keys(settings.set).length > 0 ? { set: settings.set } : {},
|
|
893
|
+
...settings.unset.length > 0 ? { unset: settings.unset } : {}
|
|
894
|
+
});
|
|
895
|
+
return ops;
|
|
896
|
+
}
|
|
897
|
+
/** A revision that hasn't arrived after this long is fetched. */
|
|
898
|
+
const COLLABORATION_GAP_TIMEOUT_MS = 1e3;
|
|
899
|
+
/** A held lock is renewed this often; the backend lets it lapse after 30 s. */
|
|
900
|
+
const COLLABORATION_LOCK_RENEW_MS = 1e4;
|
|
901
|
+
/** Waits between attempts at a batch that failed in transit. */
|
|
902
|
+
const RETRY_DELAYS_MS = [
|
|
903
|
+
1e3,
|
|
904
|
+
2e3,
|
|
905
|
+
5e3,
|
|
906
|
+
1e4,
|
|
907
|
+
3e4
|
|
908
|
+
];
|
|
909
|
+
/** The snapshot history's depth, kept for the operation history too. */
|
|
910
|
+
const DEFAULT_MAX_UNDO = 50;
|
|
911
|
+
/** OperationBatch allows 1–100 operations. */
|
|
912
|
+
const MAX_BATCH_OPS = 100;
|
|
913
|
+
const REFUSAL_CODES = /* @__PURE__ */ new Set([
|
|
914
|
+
"block_exists",
|
|
915
|
+
"block_missing",
|
|
916
|
+
"anchor_missing",
|
|
917
|
+
"precondition_failed",
|
|
918
|
+
"lock_held",
|
|
919
|
+
"locked_by_others",
|
|
920
|
+
"invalid_operation",
|
|
921
|
+
"lock_lost",
|
|
922
|
+
"content_too_large"
|
|
923
|
+
]);
|
|
924
|
+
function refusalCode(error) {
|
|
925
|
+
const code = error?.code;
|
|
926
|
+
return typeof code === "string" && REFUSAL_CODES.has(code) ? code : null;
|
|
927
|
+
}
|
|
928
|
+
function refusalHolder(error) {
|
|
929
|
+
const holder = error?.holder;
|
|
930
|
+
return typeof holder === "object" && holder !== null && typeof holder.sessionId === "string" ? holder : void 0;
|
|
931
|
+
}
|
|
932
|
+
/** Network failures, 5xx, 408 and 429 are worth retrying; other 4xx aren't. */
|
|
933
|
+
function isTransient(error) {
|
|
934
|
+
const status = error?.statusCode;
|
|
935
|
+
if (typeof status !== "number") return true;
|
|
936
|
+
return status >= 500 || status === 408 || status === 429;
|
|
937
|
+
}
|
|
938
|
+
function containsBlock(blocks, blockId) {
|
|
939
|
+
return blocks.some((block) => {
|
|
940
|
+
if (block.id === blockId) return true;
|
|
941
|
+
const children = block.children;
|
|
942
|
+
return block.type === "section" && Array.isArray(children) && children.some((column) => Array.isArray(column) && containsBlock(column, blockId));
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
/** Typing into one block, or one settings change: worth batching up. */
|
|
946
|
+
function isCoalescible(ops) {
|
|
947
|
+
if (ops.length === 1 && ops[0].op === "updateSettings") return true;
|
|
948
|
+
const first = ops[0];
|
|
949
|
+
return first.op === "updateBlock" && ops.every((op) => op.op === "updateBlock" && op.blockId === first.blockId);
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* A remote batch without its preconditions. The backend checked them against
|
|
953
|
+
* its own copy before it accepted the batch. This copy can differ from that one
|
|
954
|
+
* in merge-tag markup alone — tokens are normalized into tags on load and on
|
|
955
|
+
* arrival — and an `expect` compares content exactly, so checking it here
|
|
956
|
+
* refuses a batch the backend applied and reloads the template. Structural
|
|
957
|
+
* checks still run, and still catch a copy that really drifted.
|
|
958
|
+
*/
|
|
959
|
+
function withoutPreconditions(ops) {
|
|
960
|
+
return ops.map((op) => {
|
|
961
|
+
if (!("expect" in op)) return op;
|
|
962
|
+
const { expect: _expect, ...rest } = op;
|
|
963
|
+
return rest;
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Real-time collaboration over a {@link CollaborationProvider}.
|
|
968
|
+
*
|
|
969
|
+
* The controller keeps the content the backend confirmed at revision R, plus
|
|
970
|
+
* this session's pending batches; the canvas shows the confirmed content with
|
|
971
|
+
* the pending batches applied. Local changes are captured by diffing the
|
|
972
|
+
* canvas against that view: structural changes, selection changes and blur
|
|
973
|
+
* send at once, typing into one block after a short pause. One batch is in
|
|
974
|
+
* flight at a time. Batches from other sessions apply strictly by revision,
|
|
975
|
+
* with this session's pending batches replayed on top; a gap that isn't
|
|
976
|
+
* filled within a second, a re-sync, a reconnect and a refusal all fetch what
|
|
977
|
+
* was missed.
|
|
978
|
+
*
|
|
979
|
+
* Selecting a block takes its lock (when the backend has locks), renewed
|
|
980
|
+
* while selected and released on deselect, template switch and page hide.
|
|
981
|
+
* Undo and redo send inverse batches guarded by preconditions; one that no
|
|
982
|
+
* longer applies is dropped with a notice. Changes from others never enter the
|
|
983
|
+
* stacks.
|
|
984
|
+
*/
|
|
985
|
+
function useCollaboration(options) {
|
|
986
|
+
const { provider } = options;
|
|
987
|
+
const sessionId = options.sessionId ?? crypto.randomUUID();
|
|
988
|
+
const maxUndo = options.maxUndo ?? DEFAULT_MAX_UNDO;
|
|
989
|
+
const locking = typeof provider.acquireLock === "function" && typeof provider.renewLock === "function" && typeof provider.releaseLock === "function";
|
|
990
|
+
const members = shallowRef([]);
|
|
991
|
+
const locks = shallowRef(/* @__PURE__ */ new Map());
|
|
992
|
+
const connection = ref(null);
|
|
993
|
+
const revision = ref(0);
|
|
994
|
+
const historyVersion = ref(0);
|
|
995
|
+
let editor = null;
|
|
996
|
+
let active = null;
|
|
997
|
+
let suspended = false;
|
|
998
|
+
let writing = false;
|
|
999
|
+
let captureQueued = false;
|
|
1000
|
+
let typingTimer = null;
|
|
1001
|
+
let typingSince = 0;
|
|
1002
|
+
let destroyed = false;
|
|
1003
|
+
/** The watchers `attach` set up; `destroy` stops them, scope or not. */
|
|
1004
|
+
const stopWatching = [];
|
|
1005
|
+
let wanted = null;
|
|
1006
|
+
let held = null;
|
|
1007
|
+
let lockQueue = Promise.resolve();
|
|
1008
|
+
let renewTimer = null;
|
|
1009
|
+
const canUndo = computed(() => {
|
|
1010
|
+
historyVersion.value;
|
|
1011
|
+
return (active?.undo.length ?? 0) > 0;
|
|
1012
|
+
});
|
|
1013
|
+
const canRedo = computed(() => {
|
|
1014
|
+
historyVersion.value;
|
|
1015
|
+
return (active?.redo.length ?? 0) > 0;
|
|
1016
|
+
});
|
|
1017
|
+
function reportError(error) {
|
|
1018
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
1019
|
+
}
|
|
1020
|
+
function notify(notice, session = active) {
|
|
1021
|
+
if (session !== active || destroyed) return;
|
|
1022
|
+
options.onNotice?.(notice);
|
|
1023
|
+
}
|
|
1024
|
+
function lockContext() {
|
|
1025
|
+
return {
|
|
1026
|
+
locks: [...locks.value.values()].map((lock) => ({
|
|
1027
|
+
blockId: lock.blockId,
|
|
1028
|
+
sessionId: lock.holder.sessionId
|
|
1029
|
+
})),
|
|
1030
|
+
session: sessionId
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
function bumpHistory() {
|
|
1034
|
+
historyVersion.value++;
|
|
1035
|
+
}
|
|
1036
|
+
function pushCapped(stack, entry) {
|
|
1037
|
+
stack.push(entry);
|
|
1038
|
+
if (stack.length > maxUndo) stack.splice(0, stack.length - maxUndo);
|
|
1039
|
+
}
|
|
1040
|
+
/** Put `view` on the canvas, changing only what differs from it. */
|
|
1041
|
+
function renderView(view) {
|
|
1042
|
+
if (!editor || suspended) return;
|
|
1043
|
+
writing = true;
|
|
1044
|
+
try {
|
|
1045
|
+
const live = editor.content.value;
|
|
1046
|
+
const ops = diffContent(live, view);
|
|
1047
|
+
if (ops.length > 0) try {
|
|
1048
|
+
applyOperationsInPlace(live, ops);
|
|
1049
|
+
} catch {
|
|
1050
|
+
editor.setContent(safeClone(view), false);
|
|
1051
|
+
}
|
|
1052
|
+
if (!deepEqual(editor.content.value, view)) editor.setContent(safeClone(view), false);
|
|
1053
|
+
} finally {
|
|
1054
|
+
writing = false;
|
|
1055
|
+
}
|
|
1056
|
+
const selected = editor.state.selectedBlockId;
|
|
1057
|
+
if (selected && !containsBlock(view.blocks, selected)) editor.selectBlock(null);
|
|
1058
|
+
}
|
|
1059
|
+
function scheduleCapture() {
|
|
1060
|
+
if (writing || captureQueued) return;
|
|
1061
|
+
captureQueued = true;
|
|
1062
|
+
queueMicrotask(() => {
|
|
1063
|
+
captureQueued = false;
|
|
1064
|
+
capture(false);
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
function clearTypingTimer() {
|
|
1068
|
+
if (typingTimer) {
|
|
1069
|
+
clearTimeout(typingTimer);
|
|
1070
|
+
typingTimer = null;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Diff the canvas against the view and send what changed. `duringLoad` is
|
|
1075
|
+
* for the moment a load or create starts: the canvas still shows this
|
|
1076
|
+
* template, and its unsent typing must go before the content is replaced.
|
|
1077
|
+
*/
|
|
1078
|
+
function capture(force, duringLoad = false) {
|
|
1079
|
+
const session = active;
|
|
1080
|
+
if (!editor || !session || suspended || writing) return;
|
|
1081
|
+
if (editor.state.isLoading && !duringLoad) return;
|
|
1082
|
+
if (editor.state.template?.id !== session.templateId) return;
|
|
1083
|
+
const ops = diffContent(session.view, editor.content.value);
|
|
1084
|
+
if (ops.length === 0) {
|
|
1085
|
+
clearTypingTimer();
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (!force && isCoalescible(ops)) {
|
|
1089
|
+
const now = Date.now();
|
|
1090
|
+
if (typingTimer === null) typingSince = now;
|
|
1091
|
+
if (now - typingSince < 1e3) {
|
|
1092
|
+
if (typingTimer) clearTimeout(typingTimer);
|
|
1093
|
+
typingTimer = setTimeout(() => {
|
|
1094
|
+
typingTimer = null;
|
|
1095
|
+
capture(true);
|
|
1096
|
+
}, 300);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
clearTypingTimer();
|
|
1101
|
+
commit(session, ops);
|
|
1102
|
+
}
|
|
1103
|
+
function flush() {
|
|
1104
|
+
capture(true);
|
|
1105
|
+
}
|
|
1106
|
+
function commit(session, proposed) {
|
|
1107
|
+
let ops = proposed;
|
|
1108
|
+
let next;
|
|
1109
|
+
try {
|
|
1110
|
+
next = applyOperations(session.view, ops, lockContext());
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
if (!(error instanceof OperationError)) throw error;
|
|
1113
|
+
renderView(session.view);
|
|
1114
|
+
const holder = error.blockId === void 0 ? void 0 : locks.value.get(error.blockId)?.holder;
|
|
1115
|
+
notify({
|
|
1116
|
+
type: "editRefused",
|
|
1117
|
+
code: error.code,
|
|
1118
|
+
...holder ? { holder } : {}
|
|
1119
|
+
});
|
|
1120
|
+
if (error.code === "invalid_operation") reportError(error);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const live = editor.content.value;
|
|
1124
|
+
if (!deepEqual(next, live)) {
|
|
1125
|
+
reportError(/* @__PURE__ */ new Error("[Templatical] Collaboration: a change couldn't be expressed as block operations, so the whole content was sent."));
|
|
1126
|
+
ops = [{
|
|
1127
|
+
op: "setContent",
|
|
1128
|
+
content: safeClone(live)
|
|
1129
|
+
}];
|
|
1130
|
+
try {
|
|
1131
|
+
next = applyOperations(session.view, ops, lockContext());
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
if (!(error instanceof OperationError)) throw error;
|
|
1134
|
+
renderView(session.view);
|
|
1135
|
+
notify({
|
|
1136
|
+
type: "editRefused",
|
|
1137
|
+
code: error.code
|
|
1138
|
+
});
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const entry = { ops: invertOperations$1(session.view, ops) };
|
|
1143
|
+
pushCapped(session.undo, entry);
|
|
1144
|
+
session.redo = [];
|
|
1145
|
+
bumpHistory();
|
|
1146
|
+
session.view = next;
|
|
1147
|
+
enqueue(session, ops, "edit", entry);
|
|
1148
|
+
}
|
|
1149
|
+
function enqueue(session, ops, origin, entry) {
|
|
1150
|
+
const group = { dropped: false };
|
|
1151
|
+
let offset = 0;
|
|
1152
|
+
do {
|
|
1153
|
+
session.pending.push({
|
|
1154
|
+
batchId: crypto.randomUUID(),
|
|
1155
|
+
ops: ops.slice(offset, offset + MAX_BATCH_OPS),
|
|
1156
|
+
origin,
|
|
1157
|
+
entry,
|
|
1158
|
+
group,
|
|
1159
|
+
sent: false,
|
|
1160
|
+
revision: null
|
|
1161
|
+
});
|
|
1162
|
+
offset += MAX_BATCH_OPS;
|
|
1163
|
+
} while (offset < ops.length);
|
|
1164
|
+
pump(session);
|
|
1165
|
+
}
|
|
1166
|
+
function pump(session) {
|
|
1167
|
+
if (session.inFlight) return;
|
|
1168
|
+
const next = session.pending.find((batch) => !batch.sent);
|
|
1169
|
+
if (!next) {
|
|
1170
|
+
settleWaiters(session);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
next.sent = true;
|
|
1174
|
+
session.inFlight = next;
|
|
1175
|
+
send(session, next, 0);
|
|
1176
|
+
}
|
|
1177
|
+
function send(session, batch, attempt) {
|
|
1178
|
+
provider.submit(session.templateId, {
|
|
1179
|
+
batchId: batch.batchId,
|
|
1180
|
+
baseRevision: session.revision,
|
|
1181
|
+
ops: batch.ops,
|
|
1182
|
+
...batch.origin === "ai" ? { origin: "ai" } : {}
|
|
1183
|
+
}).then(({ revision: confirmedAt }) => {
|
|
1184
|
+
session.inFlight = null;
|
|
1185
|
+
confirmOwn(session, batch, confirmedAt);
|
|
1186
|
+
pump(session);
|
|
1187
|
+
}, (error) => {
|
|
1188
|
+
if (refusalCode(error) !== null) {
|
|
1189
|
+
session.inFlight = null;
|
|
1190
|
+
refuse(session, batch, error);
|
|
1191
|
+
pump(session);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (isTransient(error)) {
|
|
1195
|
+
rejectWaiters(session, error);
|
|
1196
|
+
const delay = RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)];
|
|
1197
|
+
setTimeout(() => {
|
|
1198
|
+
if (!destroyed) send(session, batch, attempt + 1);
|
|
1199
|
+
}, delay);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
session.inFlight = null;
|
|
1203
|
+
reportError(error);
|
|
1204
|
+
refuse(session, batch, error);
|
|
1205
|
+
pump(session);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
function confirmOwn(session, batch, at) {
|
|
1209
|
+
if (batch.revision !== null) return;
|
|
1210
|
+
batch.revision = at;
|
|
1211
|
+
if (at <= session.revision) {
|
|
1212
|
+
dropPending(session, batch);
|
|
1213
|
+
rebuild(session);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
session.own.set(at, batch);
|
|
1217
|
+
drain(session);
|
|
1218
|
+
}
|
|
1219
|
+
function dropPending(session, batch) {
|
|
1220
|
+
session.pending = session.pending.filter((pending) => pending !== batch);
|
|
1221
|
+
settleWaiters(session);
|
|
1222
|
+
}
|
|
1223
|
+
function takeBackEntry(session, batch) {
|
|
1224
|
+
if (!batch.entry) return;
|
|
1225
|
+
for (const stack of [session.undo, session.redo]) {
|
|
1226
|
+
const position = stack.indexOf(batch.entry);
|
|
1227
|
+
if (position !== -1) stack.splice(position, 1);
|
|
1228
|
+
}
|
|
1229
|
+
bumpHistory();
|
|
1230
|
+
}
|
|
1231
|
+
function dropRefusedGroup(session, batch) {
|
|
1232
|
+
const firstRefusal = !batch.group.dropped;
|
|
1233
|
+
batch.group.dropped = true;
|
|
1234
|
+
session.pending = session.pending.filter((pending) => pending.group !== batch.group || pending.sent);
|
|
1235
|
+
if (firstRefusal) takeBackEntry(session, batch);
|
|
1236
|
+
settleWaiters(session);
|
|
1237
|
+
return firstRefusal;
|
|
1238
|
+
}
|
|
1239
|
+
function refuse(session, batch, error) {
|
|
1240
|
+
dropPending(session, batch);
|
|
1241
|
+
if (dropRefusedGroup(session, batch)) {
|
|
1242
|
+
const code = refusalCode(error) ?? "invalid_operation";
|
|
1243
|
+
if (batch.origin === "edit" || batch.origin === "ai") {
|
|
1244
|
+
const holder = refusalHolder(error);
|
|
1245
|
+
notify({
|
|
1246
|
+
type: "editRefused",
|
|
1247
|
+
code,
|
|
1248
|
+
...holder ? { holder } : {}
|
|
1249
|
+
}, session);
|
|
1250
|
+
} else notify({
|
|
1251
|
+
type: "undoSkipped",
|
|
1252
|
+
code
|
|
1253
|
+
}, session);
|
|
1254
|
+
}
|
|
1255
|
+
rebuild(session);
|
|
1256
|
+
if (session === active) catchUp();
|
|
1257
|
+
}
|
|
1258
|
+
function settleWaiters(session) {
|
|
1259
|
+
if (session.pending.length > 0 || session.waiters.length === 0) return;
|
|
1260
|
+
for (const waiter of session.waiters.splice(0)) waiter.resolve();
|
|
1261
|
+
}
|
|
1262
|
+
function rejectWaiters(session, error) {
|
|
1263
|
+
const reason = error instanceof Error ? error : new Error(String(error));
|
|
1264
|
+
for (const waiter of session.waiters.splice(0)) waiter.reject(reason);
|
|
1265
|
+
}
|
|
1266
|
+
function receive(session, batch) {
|
|
1267
|
+
if (batch.revision <= session.revision) return;
|
|
1268
|
+
const mine = session.pending.find((pending) => pending.batchId === batch.batchId);
|
|
1269
|
+
if (mine) {
|
|
1270
|
+
confirmOwn(session, mine, batch.revision);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
session.remote.set(batch.revision, batch);
|
|
1274
|
+
drain(session);
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* The ops another session's batch applies here: rewritten by
|
|
1278
|
+
* `normalizeRemoteOperations`, then without their preconditions. A
|
|
1279
|
+
* normalizer that throws is reported, and the batch applies as it came.
|
|
1280
|
+
*/
|
|
1281
|
+
function arriving(session, batch) {
|
|
1282
|
+
let ops = batch.ops;
|
|
1283
|
+
if (options.normalizeRemoteOperations) try {
|
|
1284
|
+
ops = options.normalizeRemoteOperations(batch.ops, session.confirmed);
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
reportError(error);
|
|
1287
|
+
}
|
|
1288
|
+
return withoutPreconditions(ops);
|
|
1289
|
+
}
|
|
1290
|
+
/** Apply whatever is next in revision order, own and remote alike. */
|
|
1291
|
+
function drain(session) {
|
|
1292
|
+
const applied = [];
|
|
1293
|
+
let confirmedDroppedChunk = false;
|
|
1294
|
+
for (;;) {
|
|
1295
|
+
const at = session.revision + 1;
|
|
1296
|
+
const remote = session.remote.get(at);
|
|
1297
|
+
const own = session.own.get(at);
|
|
1298
|
+
if (!remote && !own) break;
|
|
1299
|
+
try {
|
|
1300
|
+
session.confirmed = applyOperations(session.confirmed, remote ? arriving(session, remote) : own.ops);
|
|
1301
|
+
} catch {
|
|
1302
|
+
if (session.recovering) {
|
|
1303
|
+
session.remote.clear();
|
|
1304
|
+
session.own.clear();
|
|
1305
|
+
reportError(/* @__PURE__ */ new Error("[Templatical] Collaboration: this editor can't follow the template's changes. Reload the page."));
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
reload(session);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
session.revision = at;
|
|
1312
|
+
if (remote) {
|
|
1313
|
+
session.remote.delete(at);
|
|
1314
|
+
applied.push(remote);
|
|
1315
|
+
} else {
|
|
1316
|
+
session.own.delete(at);
|
|
1317
|
+
if (own.group.dropped) confirmedDroppedChunk = true;
|
|
1318
|
+
dropPending(session, own);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
if (session === active) revision.value = session.revision;
|
|
1322
|
+
if (session.remote.size > 0 || session.own.size > 0) armGapTimer(session);
|
|
1323
|
+
else clearGapTimer(session);
|
|
1324
|
+
if (applied.length === 0 && !confirmedDroppedChunk) return;
|
|
1325
|
+
rebuild(session);
|
|
1326
|
+
if (session !== active) return;
|
|
1327
|
+
for (const batch of applied) try {
|
|
1328
|
+
options.onRemoteChange?.({
|
|
1329
|
+
actor: batch.actor,
|
|
1330
|
+
ops: batch.ops
|
|
1331
|
+
});
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
reportError(error);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
/** Recompute the view from `confirmed`, replaying what is still pending. */
|
|
1337
|
+
function rebuild(session) {
|
|
1338
|
+
if (session === active) flush();
|
|
1339
|
+
let view = session.confirmed;
|
|
1340
|
+
for (let index = 0; index < session.pending.length; index++) {
|
|
1341
|
+
const batch = session.pending[index];
|
|
1342
|
+
try {
|
|
1343
|
+
view = applyOperations(view, batch.ops);
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
if (batch.sent) continue;
|
|
1346
|
+
if (dropRefusedGroup(session, batch)) {
|
|
1347
|
+
const code = error instanceof OperationError ? error.code : "invalid_operation";
|
|
1348
|
+
notify(batch.origin === "edit" || batch.origin === "ai" ? {
|
|
1349
|
+
type: "editRefused",
|
|
1350
|
+
code
|
|
1351
|
+
} : {
|
|
1352
|
+
type: "undoSkipped",
|
|
1353
|
+
code
|
|
1354
|
+
}, session);
|
|
1355
|
+
}
|
|
1356
|
+
view = session.confirmed;
|
|
1357
|
+
index = -1;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
session.view = view;
|
|
1361
|
+
settleWaiters(session);
|
|
1362
|
+
if (session === active) renderView(view);
|
|
1363
|
+
}
|
|
1364
|
+
function armGapTimer(session) {
|
|
1365
|
+
if (session.gapTimer) return;
|
|
1366
|
+
session.gapTimer = setTimeout(() => {
|
|
1367
|
+
session.gapTimer = null;
|
|
1368
|
+
if (session === active) catchUp();
|
|
1369
|
+
}, COLLABORATION_GAP_TIMEOUT_MS);
|
|
1370
|
+
}
|
|
1371
|
+
function clearGapTimer(session) {
|
|
1372
|
+
if (session.gapTimer) {
|
|
1373
|
+
clearTimeout(session.gapTimer);
|
|
1374
|
+
session.gapTimer = null;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function catchUp() {
|
|
1378
|
+
const session = active;
|
|
1379
|
+
if (!session) return Promise.resolve();
|
|
1380
|
+
if (session.catchingUp) return session.catchingUp;
|
|
1381
|
+
session.catchingUp = (async () => {
|
|
1382
|
+
try {
|
|
1383
|
+
const result = await provider.operationsAfter(session.templateId, session.revision);
|
|
1384
|
+
if (session.retired) return;
|
|
1385
|
+
if ("resync" in result) {
|
|
1386
|
+
await reload(session);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
for (const batch of result.batches) receive(session, batch);
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
reportError(error);
|
|
1392
|
+
} finally {
|
|
1393
|
+
session.catchingUp = null;
|
|
1394
|
+
}
|
|
1395
|
+
})();
|
|
1396
|
+
return session.catchingUp;
|
|
1397
|
+
}
|
|
1398
|
+
async function reload(session) {
|
|
1399
|
+
session.recovering = true;
|
|
1400
|
+
try {
|
|
1401
|
+
const template = await options.loadTemplate(session.templateId);
|
|
1402
|
+
if (session.retired) return;
|
|
1403
|
+
const at = template.revision ?? session.revision;
|
|
1404
|
+
session.confirmed = safeClone(template.content);
|
|
1405
|
+
session.revision = at;
|
|
1406
|
+
session.remote = new Map([...session.remote].filter(([remoteAt]) => remoteAt > at));
|
|
1407
|
+
for (const [ownAt, batch] of [...session.own]) if (ownAt <= at) {
|
|
1408
|
+
session.own.delete(ownAt);
|
|
1409
|
+
dropPending(session, batch);
|
|
1410
|
+
}
|
|
1411
|
+
if (session === active) revision.value = at;
|
|
1412
|
+
rebuild(session);
|
|
1413
|
+
drain(session);
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
reportError(error);
|
|
1416
|
+
} finally {
|
|
1417
|
+
session.recovering = false;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
function handlersFor(session) {
|
|
1421
|
+
const live = () => session === active && !session.retired;
|
|
1422
|
+
return {
|
|
1423
|
+
onBatch: (batch) => {
|
|
1424
|
+
if (live()) receive(session, batch);
|
|
1425
|
+
},
|
|
1426
|
+
onResync: (at, actor) => {
|
|
1427
|
+
if (!live() || at <= session.revision) return;
|
|
1428
|
+
if (actor) try {
|
|
1429
|
+
options.onRemoteResync?.({
|
|
1430
|
+
revision: at,
|
|
1431
|
+
actor
|
|
1432
|
+
});
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
reportError(error);
|
|
1435
|
+
}
|
|
1436
|
+
catchUp();
|
|
1437
|
+
},
|
|
1438
|
+
onLock: (lock) => {
|
|
1439
|
+
if (live()) setLock(lock);
|
|
1440
|
+
},
|
|
1441
|
+
onUnlock: (blockId, holderSession) => {
|
|
1442
|
+
if (live()) removeLock(blockId, holderSession);
|
|
1443
|
+
},
|
|
1444
|
+
onMembers: (next) => {
|
|
1445
|
+
if (live()) setMembers(next);
|
|
1446
|
+
},
|
|
1447
|
+
onConnectionChange: (state) => {
|
|
1448
|
+
if (live()) changeConnection(session, state);
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
function join(session, refresh) {
|
|
1453
|
+
provider.join(session.templateId, { sessionId }, handlersFor(session)).then((joined) => {
|
|
1454
|
+
if (session.retired || session !== active) {
|
|
1455
|
+
if (!refresh) joined.leave();
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
session.leave = joined.leave;
|
|
1459
|
+
replaceLocks(joined.locks, !refresh);
|
|
1460
|
+
setMembers(joined.members);
|
|
1461
|
+
if (joined.members.length > 0) connection.value = "connected";
|
|
1462
|
+
if (joined.revision > session.revision) catchUp();
|
|
1463
|
+
if (refresh) reconcileLock();
|
|
1464
|
+
}, (error) => reportError(error));
|
|
1465
|
+
}
|
|
1466
|
+
function start(template) {
|
|
1467
|
+
const content = safeClone(template.content);
|
|
1468
|
+
const session = {
|
|
1469
|
+
templateId: template.id,
|
|
1470
|
+
confirmed: content,
|
|
1471
|
+
revision: template.revision ?? 0,
|
|
1472
|
+
pending: [],
|
|
1473
|
+
view: safeClone(content),
|
|
1474
|
+
remote: /* @__PURE__ */ new Map(),
|
|
1475
|
+
own: /* @__PURE__ */ new Map(),
|
|
1476
|
+
inFlight: null,
|
|
1477
|
+
gapTimer: null,
|
|
1478
|
+
catchingUp: null,
|
|
1479
|
+
leave: null,
|
|
1480
|
+
retired: false,
|
|
1481
|
+
recovering: false,
|
|
1482
|
+
undo: [],
|
|
1483
|
+
redo: [],
|
|
1484
|
+
waiters: []
|
|
1485
|
+
};
|
|
1486
|
+
active = session;
|
|
1487
|
+
revision.value = session.revision;
|
|
1488
|
+
bumpHistory();
|
|
1489
|
+
join(session, false);
|
|
1490
|
+
if (editor?.state.selectedBlockId) onSelection(editor.state.selectedBlockId);
|
|
1491
|
+
scheduleCapture();
|
|
1492
|
+
}
|
|
1493
|
+
function retire(session) {
|
|
1494
|
+
session.retired = true;
|
|
1495
|
+
clearGapTimer(session);
|
|
1496
|
+
session.leave?.();
|
|
1497
|
+
session.leave = null;
|
|
1498
|
+
rejectWaiters(session, /* @__PURE__ */ new Error("[Templatical] The template was closed."));
|
|
1499
|
+
}
|
|
1500
|
+
function changeConnection(session, state) {
|
|
1501
|
+
const previous = connection.value;
|
|
1502
|
+
connection.value = state;
|
|
1503
|
+
if (state === "connected" && previous !== null && previous !== "connected") join(session, true);
|
|
1504
|
+
}
|
|
1505
|
+
function switchTo(template) {
|
|
1506
|
+
if (active && active.templateId === template?.id) return;
|
|
1507
|
+
if (active) {
|
|
1508
|
+
clearTypingTimer();
|
|
1509
|
+
retire(active);
|
|
1510
|
+
active = null;
|
|
1511
|
+
}
|
|
1512
|
+
members.value = [];
|
|
1513
|
+
locks.value = /* @__PURE__ */ new Map();
|
|
1514
|
+
connection.value = null;
|
|
1515
|
+
wanted = null;
|
|
1516
|
+
reconcileLock();
|
|
1517
|
+
bumpHistory();
|
|
1518
|
+
if (template) start(template);
|
|
1519
|
+
}
|
|
1520
|
+
function setLock(lock) {
|
|
1521
|
+
if (lock.holder.sessionId === sessionId) return;
|
|
1522
|
+
const next = new Map([...locks.value].filter(([, existing]) => existing.holder.sessionId !== lock.holder.sessionId));
|
|
1523
|
+
next.set(lock.blockId, lock);
|
|
1524
|
+
locks.value = next;
|
|
1525
|
+
if (editor?.state.selectedBlockId === lock.blockId) {
|
|
1526
|
+
editor.selectBlock(null);
|
|
1527
|
+
notify({
|
|
1528
|
+
type: "selectionRefused",
|
|
1529
|
+
blockId: lock.blockId,
|
|
1530
|
+
holder: lock.holder
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
function removeLock(blockId, holderSession) {
|
|
1535
|
+
if (locks.value.get(blockId)?.holder.sessionId !== holderSession) return;
|
|
1536
|
+
const next = new Map(locks.value);
|
|
1537
|
+
next.delete(blockId);
|
|
1538
|
+
locks.value = next;
|
|
1539
|
+
}
|
|
1540
|
+
function replaceLocks(list, merge) {
|
|
1541
|
+
const next = merge ? new Map(locks.value) : /* @__PURE__ */ new Map();
|
|
1542
|
+
for (const lock of list) {
|
|
1543
|
+
if (lock.holder.sessionId === sessionId) continue;
|
|
1544
|
+
if (merge && next.has(lock.blockId)) continue;
|
|
1545
|
+
next.set(lock.blockId, lock);
|
|
1546
|
+
}
|
|
1547
|
+
locks.value = next;
|
|
1548
|
+
}
|
|
1549
|
+
function setMembers(next) {
|
|
1550
|
+
const present = new Set(next.map((member) => member.sessionId));
|
|
1551
|
+
const departed = new Set(members.value.map((member) => member.sessionId).filter((id) => !present.has(id)));
|
|
1552
|
+
members.value = next;
|
|
1553
|
+
if (departed.size === 0) return;
|
|
1554
|
+
const remaining = new Map([...locks.value].filter(([, lock]) => !departed.has(lock.holder.sessionId)));
|
|
1555
|
+
if (remaining.size !== locks.value.size) locks.value = remaining;
|
|
1556
|
+
}
|
|
1557
|
+
function queueLock(task) {
|
|
1558
|
+
lockQueue = lockQueue.then(task).catch(reportError);
|
|
1559
|
+
}
|
|
1560
|
+
/** Move what the backend granted toward what is selected, one call at a time. */
|
|
1561
|
+
function reconcileLock() {
|
|
1562
|
+
if (!locking) return;
|
|
1563
|
+
queueLock(async () => {
|
|
1564
|
+
if (held && (wanted === null || held.templateId !== wanted.templateId)) {
|
|
1565
|
+
const releasing = held;
|
|
1566
|
+
held = null;
|
|
1567
|
+
stopRenewing();
|
|
1568
|
+
await provider.releaseLock(releasing.templateId, releasing.blockId);
|
|
1569
|
+
}
|
|
1570
|
+
const target = wanted;
|
|
1571
|
+
if (!target || held?.templateId === target.templateId && held.blockId === target.blockId) return;
|
|
1572
|
+
try {
|
|
1573
|
+
await provider.acquireLock(target.templateId, target.blockId);
|
|
1574
|
+
held = target;
|
|
1575
|
+
startRenewing();
|
|
1576
|
+
forgetLock(target.blockId);
|
|
1577
|
+
} catch (error) {
|
|
1578
|
+
if (held?.templateId === target.templateId) {
|
|
1579
|
+
const releasing = held;
|
|
1580
|
+
held = null;
|
|
1581
|
+
stopRenewing();
|
|
1582
|
+
await provider.releaseLock(releasing.templateId, releasing.blockId).catch(() => {});
|
|
1583
|
+
}
|
|
1584
|
+
if (wanted !== target) return;
|
|
1585
|
+
refuseSelection(target.blockId, error);
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
function forgetLock(blockId) {
|
|
1590
|
+
if (!locks.value.has(blockId)) return;
|
|
1591
|
+
const next = new Map(locks.value);
|
|
1592
|
+
next.delete(blockId);
|
|
1593
|
+
locks.value = next;
|
|
1594
|
+
}
|
|
1595
|
+
function refuseSelection(blockId, error) {
|
|
1596
|
+
const code = refusalCode(error);
|
|
1597
|
+
if (code === "lock_held" || code === "block_missing") {
|
|
1598
|
+
wanted = null;
|
|
1599
|
+
const holder = refusalHolder(error);
|
|
1600
|
+
if (holder) setLock({
|
|
1601
|
+
blockId,
|
|
1602
|
+
holder,
|
|
1603
|
+
expiresAt: ""
|
|
1604
|
+
});
|
|
1605
|
+
else if (editor?.state.selectedBlockId === blockId) editor.selectBlock(null);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
reportError(error);
|
|
1609
|
+
setTimeout(() => {
|
|
1610
|
+
if (wanted?.blockId === blockId) reconcileLock();
|
|
1611
|
+
}, RETRY_DELAYS_MS[0]);
|
|
1612
|
+
}
|
|
1613
|
+
function startRenewing() {
|
|
1614
|
+
if (renewTimer) return;
|
|
1615
|
+
renewTimer = setInterval(() => {
|
|
1616
|
+
queueLock(async () => {
|
|
1617
|
+
const current = held;
|
|
1618
|
+
if (!current) return;
|
|
1619
|
+
try {
|
|
1620
|
+
await provider.renewLock(current.templateId, current.blockId);
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
if (refusalCode(error) === "lock_lost") {
|
|
1623
|
+
held = null;
|
|
1624
|
+
stopRenewing();
|
|
1625
|
+
reconcileLock();
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
reportError(error);
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
}, COLLABORATION_LOCK_RENEW_MS);
|
|
1632
|
+
}
|
|
1633
|
+
function stopRenewing() {
|
|
1634
|
+
if (renewTimer) {
|
|
1635
|
+
clearInterval(renewTimer);
|
|
1636
|
+
renewTimer = null;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
function onSelection(blockId) {
|
|
1640
|
+
flush();
|
|
1641
|
+
if (!active) return;
|
|
1642
|
+
wanted = blockId ? {
|
|
1643
|
+
templateId: active.templateId,
|
|
1644
|
+
blockId
|
|
1645
|
+
} : null;
|
|
1646
|
+
reconcileLock();
|
|
1647
|
+
}
|
|
1648
|
+
/** The page is going away: send what's typed, let go of the lock, leave. */
|
|
1649
|
+
function onPageHide() {
|
|
1650
|
+
flush();
|
|
1651
|
+
if (held && locking) {
|
|
1652
|
+
provider.releaseLock(held.templateId, held.blockId).catch(() => {});
|
|
1653
|
+
held = null;
|
|
1654
|
+
stopRenewing();
|
|
1655
|
+
}
|
|
1656
|
+
active?.leave?.();
|
|
1657
|
+
if (active) active.leave = null;
|
|
1658
|
+
}
|
|
1659
|
+
function step(direction) {
|
|
1660
|
+
const session = active;
|
|
1661
|
+
if (!session || suspended) return;
|
|
1662
|
+
flush();
|
|
1663
|
+
const entry = (direction === "undo" ? session.undo : session.redo).pop();
|
|
1664
|
+
if (!entry) return;
|
|
1665
|
+
bumpHistory();
|
|
1666
|
+
let next;
|
|
1667
|
+
try {
|
|
1668
|
+
next = applyOperations(session.view, entry.ops, lockContext());
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
if (!(error instanceof OperationError)) throw error;
|
|
1671
|
+
notify({
|
|
1672
|
+
type: "undoSkipped",
|
|
1673
|
+
code: error.code
|
|
1674
|
+
});
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
const counter = { ops: invertOperations$1(session.view, entry.ops) };
|
|
1678
|
+
pushCapped(direction === "undo" ? session.redo : session.undo, counter);
|
|
1679
|
+
session.view = next;
|
|
1680
|
+
renderView(next);
|
|
1681
|
+
editor?.markDirty();
|
|
1682
|
+
enqueue(session, entry.ops, direction, counter);
|
|
1683
|
+
}
|
|
1684
|
+
function applyFromTurn(ops, options) {
|
|
1685
|
+
const session = active;
|
|
1686
|
+
if (!editor || !session || suspended) return {
|
|
1687
|
+
applied: false,
|
|
1688
|
+
error: new OperationError("invalid_operation", 0, "No template is open for editing.")
|
|
1689
|
+
};
|
|
1690
|
+
flush();
|
|
1691
|
+
let next;
|
|
1692
|
+
let inverse;
|
|
1693
|
+
try {
|
|
1694
|
+
next = applyOperations(session.view, ops, lockContext());
|
|
1695
|
+
inverse = invertOperations$1(session.view, ops);
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
if (error instanceof OperationError) return {
|
|
1698
|
+
applied: false,
|
|
1699
|
+
error
|
|
1700
|
+
};
|
|
1701
|
+
throw error;
|
|
1702
|
+
}
|
|
1703
|
+
const top = session.undo.at(-1);
|
|
1704
|
+
let entry;
|
|
1705
|
+
if (top && top.turn === options.turn) {
|
|
1706
|
+
top.ops = [...inverse, ...top.ops];
|
|
1707
|
+
entry = top;
|
|
1708
|
+
} else {
|
|
1709
|
+
entry = {
|
|
1710
|
+
ops: inverse,
|
|
1711
|
+
turn: options.turn
|
|
1712
|
+
};
|
|
1713
|
+
pushCapped(session.undo, entry);
|
|
1714
|
+
}
|
|
1715
|
+
session.redo = [];
|
|
1716
|
+
bumpHistory();
|
|
1717
|
+
session.view = next;
|
|
1718
|
+
renderView(next);
|
|
1719
|
+
editor.markDirty();
|
|
1720
|
+
enqueue(session, ops, options.origin, entry);
|
|
1721
|
+
return {
|
|
1722
|
+
applied: true,
|
|
1723
|
+
inverse
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
function attach(target) {
|
|
1727
|
+
editor = target;
|
|
1728
|
+
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()));
|
|
1729
|
+
if (typeof window !== "undefined") {
|
|
1730
|
+
window.addEventListener("pagehide", onPageHide);
|
|
1731
|
+
window.addEventListener("blur", flush);
|
|
1732
|
+
}
|
|
1733
|
+
if (getCurrentScope()) onScopeDispose(destroy);
|
|
1734
|
+
}
|
|
1735
|
+
function settle() {
|
|
1736
|
+
flush();
|
|
1737
|
+
const session = active;
|
|
1738
|
+
if (!session || session.pending.length === 0) return Promise.resolve();
|
|
1739
|
+
return new Promise((resolve, reject) => session.waiters.push({
|
|
1740
|
+
resolve,
|
|
1741
|
+
reject
|
|
1742
|
+
}));
|
|
1743
|
+
}
|
|
1744
|
+
function suspend() {
|
|
1745
|
+
flush();
|
|
1746
|
+
suspended = true;
|
|
1747
|
+
}
|
|
1748
|
+
function resume() {
|
|
1749
|
+
suspended = false;
|
|
1750
|
+
if (active) renderView(active.view);
|
|
1751
|
+
}
|
|
1752
|
+
function destroy() {
|
|
1753
|
+
if (destroyed) return;
|
|
1754
|
+
flush();
|
|
1755
|
+
for (const stop of stopWatching.splice(0)) stop();
|
|
1756
|
+
clearTypingTimer();
|
|
1757
|
+
onPageHide();
|
|
1758
|
+
destroyed = true;
|
|
1759
|
+
if (active) retire(active);
|
|
1760
|
+
active = null;
|
|
1761
|
+
stopRenewing();
|
|
1762
|
+
if (typeof window !== "undefined") {
|
|
1763
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
1764
|
+
window.removeEventListener("blur", flush);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
return {
|
|
1768
|
+
sessionId,
|
|
1769
|
+
members,
|
|
1770
|
+
locks,
|
|
1771
|
+
connection,
|
|
1772
|
+
revision,
|
|
1773
|
+
canUndo,
|
|
1774
|
+
canRedo,
|
|
1775
|
+
attach,
|
|
1776
|
+
undo: () => step("undo"),
|
|
1777
|
+
redo: () => step("redo"),
|
|
1778
|
+
clearHistory: () => {
|
|
1779
|
+
if (!active) return;
|
|
1780
|
+
active.undo = [];
|
|
1781
|
+
active.redo = [];
|
|
1782
|
+
bumpHistory();
|
|
1783
|
+
},
|
|
1784
|
+
flush,
|
|
1785
|
+
settle,
|
|
1786
|
+
suspend,
|
|
1787
|
+
resume,
|
|
1788
|
+
catchUp,
|
|
1789
|
+
hasConfirmedBlock: (blockId) => active ? containsBlock(active.confirmed.blocks, blockId) : false,
|
|
1790
|
+
applyOperations: applyFromTurn,
|
|
1791
|
+
destroy
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
//#endregion
|
|
661
1795
|
//#region src/saved-blocks.ts
|
|
662
1796
|
/**
|
|
663
1797
|
* Reactive state over a {@link SavedBlocksProvider}.
|
|
@@ -1402,9 +2536,9 @@ function useCommentListener(options) {
|
|
|
1402
2536
|
}
|
|
1403
2537
|
});
|
|
1404
2538
|
}, { immediate: true });
|
|
1405
|
-
onScopeDispose(stop);
|
|
2539
|
+
onScopeDispose$1(stop);
|
|
1406
2540
|
}
|
|
1407
2541
|
//#endregion
|
|
1408
|
-
export { DEFAULT_AUTO_SAVE_DEBOUNCE_MS, createLocalStorageMediaProvider, createLocalStorageSavedBlocksProvider, useAutoSave, useBlockActions, useCommentListener, useComments, useConditionPreview, useDataSourceFetch, useEditor, useHistory, useHistoryInterceptor, useSavedBlocks, useVersionHistory };
|
|
2542
|
+
export { DEFAULT_AUTO_SAVE_DEBOUNCE_MS, createLocalStorageMediaProvider, createLocalStorageSavedBlocksProvider, diffContent, invertOperations, useAutoSave, useBlockActions, useCollaboration, useCommentListener, useComments, useConditionPreview, useDataSourceFetch, useEditor, useHistory, useHistoryInterceptor, useSavedBlocks, useVersionHistory };
|
|
1409
2543
|
|
|
1410
2544
|
//# sourceMappingURL=index.js.map
|