@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/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 for the duration of a `create()` or `load()` call. */
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 and make it the editor's content. */
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 { CollaborationEditor, CollaborationNotice, CollaborationRemoteResync, CollaborationTemplate, EditorState, LocalStorageMediaProviderOptions, LocalStorageSavedBlocksOptions, UseAutoSaveOptions, UseAutoSaveReturn, UseBlockActionsOptions, UseBlockActionsReturn, UseCollaborationOptions, UseCollaborationReturn, UseCommentListenerOptions, UseCommentsOptions, UseCommentsReturn, UseConditionPreviewReturn, UseEditorOptions, UseEditorReturn, UseHistoryOptions, UseHistoryReturn, UseSavedBlocksOptions, UseSavedBlocksReturn, UseVersionHistoryOptions, UseVersionHistoryReturn };
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$1(block) {
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$1(block).forEach((column, columnIndex) => {
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$1(block).some((column) => Array.isArray(column) && column.some((child) => survives(child) || !typeChanged(child.id) && hasSurvivor(child)));
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$1(block)) if (Array.isArray(column)) for (const child of column) visit(child, childFate);
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$1(block).map((column) => Array.isArray(column) ? column.filter((child) => !exists(child.id)).map((child) => safeClone(child)) : column);
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$1(was.get(section.id).block).length;
843
- const toCount = columnsOf$1(section).length;
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$1(section).forEach((column, columnIndex) => {
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
- session.pending.push({
1276
- batchId: crypto.randomUUID(),
1277
- ops,
1278
- origin,
1279
- entry,
1280
- sent: false,
1281
- revision: null
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
- 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 } : {}
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
- } else notify({
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
- 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;
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
  }