@templatical/core 0.49.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 +1 -1
- package/dist/cloud/index.d.ts +3 -100
- package/dist/cloud/index.js +1 -627
- package/dist/cloud/index.js.map +1 -1
- package/dist/index.d.ts +32 -21
- package/dist/index.js +125 -189
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Block, BlockDefaults, BlockLock, BlockLockHolder, BlockType, CollaborationActor, CollaborationConflictCode, CollaborationConnectionState, CollaborationMember, CollaborationProvider, CollaborationRemoteChange, Comment, CommentAuthor, CommentInput, CommentPatch, CommentsListParams, CommentsProvider, CustomBlock, CustomBlockDefinition, MediaProvider, Operation, SavedBlock, SavedBlockPatch, SavedBlocksListParams, SavedBlocksProvider, Template, TemplateContent, TemplateDefaults, TemplateSaveTrigger, TemplateSettings, TemplateVersion, TemplatesProvider, UiTheme, VersionHistoryListParams, VersionHistoryProvider, ViewportSize } from "@templatical/types";
|
|
1
|
+
import { Block, BlockDefaults, BlockLock, BlockLockHolder, BlockType, CollaborationActor, CollaborationConflictCode, CollaborationConnectionState, CollaborationMember, CollaborationProvider, CollaborationRemoteChange, Comment, CommentAuthor, CommentInput, CommentPatch, CommentsListParams, CommentsProvider, CustomBlock, CustomBlockDefinition, MediaProvider, Operation, OperationError, SavedBlock, SavedBlockPatch, SavedBlocksListParams, SavedBlocksProvider, Template, TemplateContent, TemplateDefaults, TemplateSaveTrigger, TemplateSettings, TemplateVersion, TemplatesProvider, UiTheme, VersionHistoryListParams, VersionHistoryProvider, ViewportSize, invertOperations } from "@templatical/types";
|
|
2
2
|
import { ComputedRef, DeepReadonly, Ref } from "@vue/reactivity";
|
|
3
3
|
import { ComputedRef as ComputedRef$1, Ref as Ref$1 } from "vue";
|
|
4
4
|
//#region src/editor.d.ts
|
|
@@ -21,7 +21,7 @@ interface EditorState {
|
|
|
21
21
|
isDirty: boolean;
|
|
22
22
|
/** True for the duration of a `save()` call. */
|
|
23
23
|
isSaving: boolean;
|
|
24
|
-
/** True
|
|
24
|
+
/** True while the latest `create()` or `load()` request is pending. */
|
|
25
25
|
isLoading: boolean;
|
|
26
26
|
uiTheme: UiTheme;
|
|
27
27
|
}
|
|
@@ -48,6 +48,8 @@ interface UseEditorOptions {
|
|
|
48
48
|
}
|
|
49
49
|
interface UseEditorReturn {
|
|
50
50
|
state: DeepReadonly<EditorState>;
|
|
51
|
+
/** Increments when create/load commits, including a reload of the same id. */
|
|
52
|
+
loadVersion: Readonly<Ref<number>>;
|
|
51
53
|
content: Ref<TemplateContent>;
|
|
52
54
|
selectedBlock: Ref<Block | null>;
|
|
53
55
|
setContent: (content: TemplateContent, markDirty?: boolean) => void;
|
|
@@ -91,8 +93,8 @@ interface UseEditorReturn {
|
|
|
91
93
|
name?: string;
|
|
92
94
|
content?: TemplateContent;
|
|
93
95
|
}) => Promise<Template>;
|
|
94
|
-
/** Fetch a template
|
|
95
|
-
load: (templateId: string) => Promise<Template>;
|
|
96
|
+
/** Fetch a template; an optional guard can discard a stale response. */
|
|
97
|
+
load: (templateId: string, shouldApply?: () => boolean) => Promise<Template>;
|
|
96
98
|
/**
|
|
97
99
|
* Persist the loaded template's name + content as a patch.
|
|
98
100
|
*
|
|
@@ -268,22 +270,6 @@ export declare function useHistoryInterceptor(editor: HistoryInterceptorEditor,
|
|
|
268
270
|
*/
|
|
269
271
|
export declare function diffContent(before: TemplateContent, after: TemplateContent): Operation[];
|
|
270
272
|
//#endregion
|
|
271
|
-
//#region src/invert-operations.d.ts
|
|
272
|
-
/**
|
|
273
|
-
* The batch that undoes `ops`, given the content they apply to. Each operation
|
|
274
|
-
* is inverted against the content just before it, and the inverses run in
|
|
275
|
-
* reverse order.
|
|
276
|
-
*
|
|
277
|
-
* Every inverse that can be guarded is: it expects what its operation left
|
|
278
|
-
* behind, so an undo that a later edit by someone else has overtaken is refused
|
|
279
|
-
* (`precondition_failed`, `anchor_missing`, `block_missing`) instead of
|
|
280
|
-
* overwriting that edit. A `setContent` inverse has no guard; the backend's
|
|
281
|
-
* `locked_by_others` rule is what stops it while others are editing.
|
|
282
|
-
*
|
|
283
|
-
* Throws the engine's `OperationError` when `ops` don't apply to `before`.
|
|
284
|
-
*/
|
|
285
|
-
export declare function invertOperations(before: TemplateContent, ops: readonly Operation[]): Operation[];
|
|
286
|
-
//#endregion
|
|
287
273
|
//#region src/collaboration.d.ts
|
|
288
274
|
/** The template fields the controller reads, as the editor's readonly state holds them. */
|
|
289
275
|
type CollaborationTemplate = DeepReadonly<Pick<Template, "id" | "revision" | "content">>;
|
|
@@ -325,6 +311,24 @@ interface CollaborationRemoteResync {
|
|
|
325
311
|
revision: number;
|
|
326
312
|
actor: CollaborationActor;
|
|
327
313
|
}
|
|
314
|
+
/** How {@link UseCollaborationReturn.applyOperations} sends a batch. */
|
|
315
|
+
interface CollaborationApplyOptions {
|
|
316
|
+
/** Sent as the batch's `origin`: an AI turn produced it (BY9). */
|
|
317
|
+
origin: "ai";
|
|
318
|
+
/**
|
|
319
|
+
* The turn the batch belongs to. Batches of one turn share an undo step
|
|
320
|
+
* while that step is the newest one.
|
|
321
|
+
*/
|
|
322
|
+
turn: string;
|
|
323
|
+
}
|
|
324
|
+
/** What {@link UseCollaborationReturn.applyOperations} answers. */
|
|
325
|
+
type CollaborationApplyResult = {
|
|
326
|
+
applied: true;
|
|
327
|
+
inverse: Operation[];
|
|
328
|
+
} | {
|
|
329
|
+
applied: false;
|
|
330
|
+
error: OperationError;
|
|
331
|
+
};
|
|
328
332
|
interface UseCollaborationOptions {
|
|
329
333
|
provider: CollaborationProvider;
|
|
330
334
|
/**
|
|
@@ -386,6 +390,13 @@ interface UseCollaborationReturn {
|
|
|
386
390
|
catchUp: () => Promise<void>;
|
|
387
391
|
/** Whether the block exists in the content the backend confirmed. */
|
|
388
392
|
hasConfirmedBlock: (blockId: string) => boolean;
|
|
393
|
+
/**
|
|
394
|
+
* Apply a batch this editor was handed (an AI turn's) as this session's
|
|
395
|
+
* own: checked against the view with this session's locks, put on the
|
|
396
|
+
* canvas, sent with its `origin`, and undone with the rest of its turn. A
|
|
397
|
+
* refusal changes nothing and sends nothing.
|
|
398
|
+
*/
|
|
399
|
+
applyOperations: (ops: Operation[], options: CollaborationApplyOptions) => CollaborationApplyResult;
|
|
389
400
|
destroy: () => void;
|
|
390
401
|
}
|
|
391
402
|
/**
|
|
@@ -687,5 +698,5 @@ interface UseCommentListenerOptions {
|
|
|
687
698
|
*/
|
|
688
699
|
export declare function useCommentListener(options: UseCommentListenerOptions): void;
|
|
689
700
|
//#endregion
|
|
690
|
-
export type
|
|
701
|
+
export { type CollaborationApplyOptions, type CollaborationApplyResult, type CollaborationEditor, type CollaborationNotice, type CollaborationRemoteResync, type CollaborationTemplate, type EditorState, type LocalStorageMediaProviderOptions, type LocalStorageSavedBlocksOptions, type UseAutoSaveOptions, type UseAutoSaveReturn, type UseBlockActionsOptions, type UseBlockActionsReturn, type UseCollaborationOptions, type UseCollaborationReturn, type UseCommentListenerOptions, type UseCommentsOptions, type UseCommentsReturn, type UseConditionPreviewReturn, type UseEditorOptions, type UseEditorReturn, type UseHistoryOptions, type UseHistoryReturn, type UseSavedBlocksOptions, type UseSavedBlocksReturn, type UseVersionHistoryOptions, type UseVersionHistoryReturn, invertOperations };
|
|
691
702
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { OperationError, SdkError, applyOperations, applyOperationsInPlace, assertNoSlotInContent, assertNoWrapperInContent, cloneBlock, createBlock, createDefaultTemplateContent, deepEqual, generateId, getColumnCount, safeClone } from "@templatical/types";
|
|
1
|
+
import { OperationError, SdkError, applyOperations, applyOperationsInPlace, assertNoSlotInContent, assertNoWrapperInContent, cloneBlock, createBlock, createDefaultTemplateContent, deepEqual, generateId, getColumnCount, invertOperations, invertOperations as invertOperations$1, safeClone } from "@templatical/types";
|
|
2
2
|
import { computed, getCurrentScope, onScopeDispose, reactive, readonly, ref, shallowRef, watch } from "@vue/reactivity";
|
|
3
3
|
import { computed as computed$1, onScopeDispose as onScopeDispose$1, ref as ref$1, watch as watch$1 } from "vue";
|
|
4
4
|
//#region src/error-reporting.ts
|
|
@@ -48,6 +48,8 @@ function useEditor(options) {
|
|
|
48
48
|
isLoading: false,
|
|
49
49
|
uiTheme: "auto"
|
|
50
50
|
});
|
|
51
|
+
const loadVersion = ref(0);
|
|
52
|
+
let loadingRequest = 0;
|
|
51
53
|
/**
|
|
52
54
|
* Bumped by every content mutation. `save()` captures it before awaiting the
|
|
53
55
|
* provider and only clears `isDirty` if it is unchanged afterwards, so an edit
|
|
@@ -252,6 +254,7 @@ function useEditor(options) {
|
|
|
252
254
|
const { create: providerCreate } = provider;
|
|
253
255
|
if (typeof providerCreate !== "function") refuse("create()", "is disabled by the provider — its `create` is `false`.");
|
|
254
256
|
let created;
|
|
257
|
+
const request = ++loadingRequest;
|
|
255
258
|
state.isLoading = true;
|
|
256
259
|
try {
|
|
257
260
|
if (input?.content) state.content = input.content;
|
|
@@ -262,31 +265,35 @@ function useEditor(options) {
|
|
|
262
265
|
} : { content: state.content });
|
|
263
266
|
state.template = created;
|
|
264
267
|
if (revision === revisionAtRequest) state.isDirty = false;
|
|
268
|
+
loadVersion.value++;
|
|
265
269
|
} catch (error) {
|
|
266
270
|
options.onError?.(error);
|
|
267
271
|
throw error;
|
|
268
272
|
} finally {
|
|
269
|
-
state.isLoading = false;
|
|
273
|
+
if (loadingRequest === request) state.isLoading = false;
|
|
270
274
|
}
|
|
271
275
|
notify(() => provider.onCreated?.(created));
|
|
272
276
|
return created;
|
|
273
277
|
}
|
|
274
|
-
async function load(templateId) {
|
|
278
|
+
async function load(templateId, shouldApply) {
|
|
275
279
|
const provider = requireProvider("load()");
|
|
276
280
|
let loaded;
|
|
281
|
+
const request = ++loadingRequest;
|
|
277
282
|
state.isLoading = true;
|
|
278
283
|
try {
|
|
279
284
|
loaded = await provider.load(templateId);
|
|
280
285
|
assertNoSlotInContent(loaded.content);
|
|
281
286
|
assertNoWrapperInContent(loaded.content);
|
|
287
|
+
if (shouldApply && !shouldApply()) return loaded;
|
|
282
288
|
state.template = loaded;
|
|
283
289
|
state.content = loaded.content;
|
|
284
290
|
state.isDirty = false;
|
|
291
|
+
loadVersion.value++;
|
|
285
292
|
} catch (error) {
|
|
286
293
|
options.onError?.(error);
|
|
287
294
|
throw error;
|
|
288
295
|
} finally {
|
|
289
|
-
state.isLoading = false;
|
|
296
|
+
if (loadingRequest === request) state.isLoading = false;
|
|
290
297
|
}
|
|
291
298
|
notify(() => provider.onLoaded?.(loaded));
|
|
292
299
|
return loaded;
|
|
@@ -328,6 +335,7 @@ function useEditor(options) {
|
|
|
328
335
|
}
|
|
329
336
|
return {
|
|
330
337
|
state: readonly(state),
|
|
338
|
+
loadVersion: readonly(loadVersion),
|
|
331
339
|
content,
|
|
332
340
|
selectedBlock,
|
|
333
341
|
isBlockLocked,
|
|
@@ -668,7 +676,7 @@ const NO_SKIP = /* @__PURE__ */ new Set();
|
|
|
668
676
|
function containerKey(parent) {
|
|
669
677
|
return parent === null ? ROOT_KEY : JSON.stringify([parent.sectionId, parent.column]);
|
|
670
678
|
}
|
|
671
|
-
function columnsOf
|
|
679
|
+
function columnsOf(block) {
|
|
672
680
|
const children = block.children;
|
|
673
681
|
return block.type === "section" && Array.isArray(children) ? children : [];
|
|
674
682
|
}
|
|
@@ -682,7 +690,7 @@ function indexBlocks(blocks) {
|
|
|
682
690
|
key: containerKey(parent),
|
|
683
691
|
index
|
|
684
692
|
});
|
|
685
|
-
columnsOf
|
|
693
|
+
columnsOf(block).forEach((column, columnIndex) => {
|
|
686
694
|
if (Array.isArray(column)) walk(column, {
|
|
687
695
|
sectionId: block.id,
|
|
688
696
|
column: columnIndex
|
|
@@ -767,7 +775,7 @@ function diffContent(before, after) {
|
|
|
767
775
|
return next !== void 0 && previous !== void 0 && next.block.type !== previous.block.type;
|
|
768
776
|
};
|
|
769
777
|
const survives = (block) => now.has(block.id) && !typeChanged(block.id);
|
|
770
|
-
const hasSurvivor = (block) => columnsOf
|
|
778
|
+
const hasSurvivor = (block) => columnsOf(block).some((column) => Array.isArray(column) && column.some((child) => survives(child) || !typeChanged(child.id) && hasSurvivor(child)));
|
|
771
779
|
const gone = /* @__PURE__ */ new Set();
|
|
772
780
|
const deferred = [];
|
|
773
781
|
const visit = (block, fate) => {
|
|
@@ -796,13 +804,13 @@ function diffContent(before, after) {
|
|
|
796
804
|
childFate = "dropped";
|
|
797
805
|
}
|
|
798
806
|
}
|
|
799
|
-
for (const column of columnsOf
|
|
807
|
+
for (const column of columnsOf(block)) if (Array.isArray(column)) for (const child of column) visit(child, childFate);
|
|
800
808
|
};
|
|
801
809
|
for (const block of before.blocks) visit(block, "kept");
|
|
802
810
|
const exists = (id) => was.has(id) && !gone.has(id);
|
|
803
811
|
const payloadOf = (block) => {
|
|
804
812
|
const payload = safeClone(block);
|
|
805
|
-
if (payload.type === "section") payload.children = columnsOf
|
|
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);
|
|
806
814
|
return payload;
|
|
807
815
|
};
|
|
808
816
|
const place = (parent, finalList, arrivedWithParent) => {
|
|
@@ -839,8 +847,8 @@ function diffContent(before, after) {
|
|
|
839
847
|
const resize = (grow) => {
|
|
840
848
|
for (const section of sections) {
|
|
841
849
|
if (!exists(section.id)) continue;
|
|
842
|
-
const fromCount = columnsOf
|
|
843
|
-
const toCount = columnsOf
|
|
850
|
+
const fromCount = columnsOf(was.get(section.id).block).length;
|
|
851
|
+
const toCount = columnsOf(section).length;
|
|
844
852
|
if (grow ? toCount > fromCount : toCount < fromCount) {
|
|
845
853
|
ops.push({
|
|
846
854
|
op: "updateBlock",
|
|
@@ -854,7 +862,7 @@ function diffContent(before, after) {
|
|
|
854
862
|
resize(true);
|
|
855
863
|
for (const section of sections) {
|
|
856
864
|
const isNew = !exists(section.id);
|
|
857
|
-
columnsOf
|
|
865
|
+
columnsOf(section).forEach((column, columnIndex) => {
|
|
858
866
|
if (!Array.isArray(column)) return;
|
|
859
867
|
place({
|
|
860
868
|
sectionId: section.id,
|
|
@@ -886,141 +894,6 @@ function diffContent(before, after) {
|
|
|
886
894
|
});
|
|
887
895
|
return ops;
|
|
888
896
|
}
|
|
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
897
|
/** A revision that hasn't arrived after this long is fetched. */
|
|
1025
898
|
const COLLABORATION_GAP_TIMEOUT_MS = 1e3;
|
|
1026
899
|
/** A held lock is renewed this often; the backend lets it lapse after 30 s. */
|
|
@@ -1035,6 +908,8 @@ const RETRY_DELAYS_MS = [
|
|
|
1035
908
|
];
|
|
1036
909
|
/** The snapshot history's depth, kept for the operation history too. */
|
|
1037
910
|
const DEFAULT_MAX_UNDO = 50;
|
|
911
|
+
/** OperationBatch allows 1–100 operations. */
|
|
912
|
+
const MAX_BATCH_OPS = 100;
|
|
1038
913
|
const REFUSAL_CODES = /* @__PURE__ */ new Set([
|
|
1039
914
|
"block_exists",
|
|
1040
915
|
"block_missing",
|
|
@@ -1264,7 +1139,7 @@ function useCollaboration(options) {
|
|
|
1264
1139
|
return;
|
|
1265
1140
|
}
|
|
1266
1141
|
}
|
|
1267
|
-
const entry = { ops: invertOperations(session.view, ops) };
|
|
1142
|
+
const entry = { ops: invertOperations$1(session.view, ops) };
|
|
1268
1143
|
pushCapped(session.undo, entry);
|
|
1269
1144
|
session.redo = [];
|
|
1270
1145
|
bumpHistory();
|
|
@@ -1272,14 +1147,20 @@ function useCollaboration(options) {
|
|
|
1272
1147
|
enqueue(session, ops, "edit", entry);
|
|
1273
1148
|
}
|
|
1274
1149
|
function enqueue(session, ops, origin, entry) {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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);
|
|
1283
1164
|
pump(session);
|
|
1284
1165
|
}
|
|
1285
1166
|
function pump(session) {
|
|
@@ -1297,7 +1178,8 @@ function useCollaboration(options) {
|
|
|
1297
1178
|
provider.submit(session.templateId, {
|
|
1298
1179
|
batchId: batch.batchId,
|
|
1299
1180
|
baseRevision: session.revision,
|
|
1300
|
-
ops: batch.ops
|
|
1181
|
+
ops: batch.ops,
|
|
1182
|
+
...batch.origin === "ai" ? { origin: "ai" } : {}
|
|
1301
1183
|
}).then(({ revision: confirmedAt }) => {
|
|
1302
1184
|
session.inFlight = null;
|
|
1303
1185
|
confirmOwn(session, batch, confirmedAt);
|
|
@@ -1346,21 +1228,30 @@ function useCollaboration(options) {
|
|
|
1346
1228
|
}
|
|
1347
1229
|
bumpHistory();
|
|
1348
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
|
+
}
|
|
1349
1239
|
function refuse(session, batch, error) {
|
|
1350
1240
|
dropPending(session, batch);
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
|
1359
1253
|
}, session);
|
|
1360
|
-
}
|
|
1361
|
-
type: "undoSkipped",
|
|
1362
|
-
code
|
|
1363
|
-
}, session);
|
|
1254
|
+
}
|
|
1364
1255
|
rebuild(session);
|
|
1365
1256
|
if (session === active) catchUp();
|
|
1366
1257
|
}
|
|
@@ -1399,6 +1290,7 @@ function useCollaboration(options) {
|
|
|
1399
1290
|
/** Apply whatever is next in revision order, own and remote alike. */
|
|
1400
1291
|
function drain(session) {
|
|
1401
1292
|
const applied = [];
|
|
1293
|
+
let confirmedDroppedChunk = false;
|
|
1402
1294
|
for (;;) {
|
|
1403
1295
|
const at = session.revision + 1;
|
|
1404
1296
|
const remote = session.remote.get(at);
|
|
@@ -1422,13 +1314,14 @@ function useCollaboration(options) {
|
|
|
1422
1314
|
applied.push(remote);
|
|
1423
1315
|
} else {
|
|
1424
1316
|
session.own.delete(at);
|
|
1317
|
+
if (own.group.dropped) confirmedDroppedChunk = true;
|
|
1425
1318
|
dropPending(session, own);
|
|
1426
1319
|
}
|
|
1427
1320
|
}
|
|
1428
1321
|
if (session === active) revision.value = session.revision;
|
|
1429
1322
|
if (session.remote.size > 0 || session.own.size > 0) armGapTimer(session);
|
|
1430
1323
|
else clearGapTimer(session);
|
|
1431
|
-
if (applied.length === 0) return;
|
|
1324
|
+
if (applied.length === 0 && !confirmedDroppedChunk) return;
|
|
1432
1325
|
rebuild(session);
|
|
1433
1326
|
if (session !== active) return;
|
|
1434
1327
|
for (const batch of applied) try {
|
|
@@ -1444,26 +1337,26 @@ function useCollaboration(options) {
|
|
|
1444
1337
|
function rebuild(session) {
|
|
1445
1338
|
if (session === active) flush();
|
|
1446
1339
|
let view = session.confirmed;
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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;
|
|
1455
1358
|
}
|
|
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
1359
|
}
|
|
1466
|
-
session.pending = kept;
|
|
1467
1360
|
session.view = view;
|
|
1468
1361
|
settleWaiters(session);
|
|
1469
1362
|
if (session === active) renderView(view);
|
|
@@ -1781,13 +1674,55 @@ function useCollaboration(options) {
|
|
|
1781
1674
|
});
|
|
1782
1675
|
return;
|
|
1783
1676
|
}
|
|
1784
|
-
const counter = { ops: invertOperations(session.view, entry.ops) };
|
|
1677
|
+
const counter = { ops: invertOperations$1(session.view, entry.ops) };
|
|
1785
1678
|
pushCapped(direction === "undo" ? session.redo : session.undo, counter);
|
|
1786
1679
|
session.view = next;
|
|
1787
1680
|
renderView(next);
|
|
1788
1681
|
editor?.markDirty();
|
|
1789
1682
|
enqueue(session, entry.ops, direction, counter);
|
|
1790
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
|
+
}
|
|
1791
1726
|
function attach(target) {
|
|
1792
1727
|
editor = target;
|
|
1793
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()));
|
|
@@ -1852,6 +1787,7 @@ function useCollaboration(options) {
|
|
|
1852
1787
|
resume,
|
|
1853
1788
|
catchUp,
|
|
1854
1789
|
hasConfirmedBlock: (blockId) => active ? containsBlock(active.confirmed.blocks, blockId) : false,
|
|
1790
|
+
applyOperations: applyFromTurn,
|
|
1855
1791
|
destroy
|
|
1856
1792
|
};
|
|
1857
1793
|
}
|