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