@stll/folio-core 0.54.1 → 0.54.2

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.
Files changed (69) hide show
  1. package/dist/ai-edits/apply.d.ts +1 -1
  2. package/dist/ai-edits/apply.js +18 -31
  3. package/dist/ai-edits/headless.d.ts +1 -2
  4. package/dist/ai-edits/headless.js +12 -27
  5. package/dist/ai-edits/read.d.ts +1 -0
  6. package/dist/ai-edits/read.js +7 -2
  7. package/dist/ai-edits/table-mutation-plan.d.ts +2 -1
  8. package/dist/ai-edits/table-mutation-plan.js +39 -1
  9. package/dist/ai-suggestions/apply.d.ts +1 -1
  10. package/dist/ai-suggestions/apply.js +3 -2
  11. package/dist/compare/compare.js +2 -1
  12. package/dist/docx/bookmarkIds.d.ts +11 -0
  13. package/dist/docx/bookmarkIds.js +33 -0
  14. package/dist/docx/documentParser.d.ts +14 -1
  15. package/dist/docx/documentParser.js +17 -4
  16. package/dist/docx/drawingIdNormalization.js +23 -8
  17. package/dist/docx/ensureParaIds.d.ts +5 -0
  18. package/dist/docx/ensureParaIds.js +10 -2
  19. package/dist/docx/listNumberingInstances.js +11 -9
  20. package/dist/docx/noteIds.d.ts +4 -0
  21. package/dist/docx/noteIds.js +13 -0
  22. package/dist/docx/numberingIds.d.ts +14 -0
  23. package/dist/docx/numberingIds.js +16 -0
  24. package/dist/docx/numericIdAllocator.d.ts +12 -0
  25. package/dist/docx/numericIdAllocator.js +33 -0
  26. package/dist/docx/numericIdNormalization.d.ts +16 -0
  27. package/dist/docx/numericIdNormalization.js +174 -0
  28. package/dist/docx/paragraphTextBoxEnrichment.js +2 -1
  29. package/dist/docx/parser.js +17 -5
  30. package/dist/docx/rasterMime.d.ts +64 -0
  31. package/dist/docx/rasterMime.js +116 -0
  32. package/dist/docx/replyToComment.js +13 -6
  33. package/dist/docx/rezip.js +14 -7
  34. package/dist/docx/selectiveSave.js +10 -0
  35. package/dist/docx/serializer/partNamespaces.js +4 -2
  36. package/dist/docx/serializer/runSerializer.js +5 -4
  37. package/dist/docx/serializer/trackedChangeAttributes.js +7 -1
  38. package/dist/docx/server/build.js +14 -3
  39. package/dist/docx/server/createBilingualDocument.js +10 -9
  40. package/dist/docx/streamingXmlParser.d.ts +10 -1
  41. package/dist/docx/streamingXmlParser.js +88 -19
  42. package/dist/docx/unzip.d.ts +3 -1
  43. package/dist/docx/unzip.js +36 -16
  44. package/dist/layout-bridge/convert/fixedTableColumnWidths.d.ts +6 -0
  45. package/dist/layout-bridge/convert/fixedTableColumnWidths.js +23 -0
  46. package/dist/layout-bridge/convert/tableConversion.js +4 -1
  47. package/dist/layout-painter/index.js +2 -5
  48. package/dist/paged-layout/editorScrollRoot.d.ts +15 -0
  49. package/dist/paged-layout/editorScrollRoot.js +72 -0
  50. package/dist/paged-layout/scrollToPmPosition.js +8 -22
  51. package/dist/prosemirror/commands/clearParagraphIndent.d.ts +6 -0
  52. package/dist/prosemirror/commands/clearParagraphIndent.js +25 -0
  53. package/dist/prosemirror/commentIdAllocator.d.ts +15 -22
  54. package/dist/prosemirror/commentIdAllocator.js +50 -25
  55. package/dist/prosemirror/extensions/core/HistoryExtension.js +97 -5
  56. package/dist/prosemirror/extensions/core/ParagraphExtension.js +3 -1
  57. package/dist/prosemirror/extensions/features/BaseKeymapExtension.js +22 -28
  58. package/dist/prosemirror/extensions/nodes/ShapeExtension.js +8 -6
  59. package/dist/prosemirror/plugins/revisionIds.d.ts +23 -19
  60. package/dist/prosemirror/plugins/revisionIds.js +110 -29
  61. package/dist/prosemirror/plugins/suggestionMode.d.ts +2 -2
  62. package/dist/prosemirror/plugins/suggestionMode.js +196 -40
  63. package/dist/prosemirror/storyListNumbering.js +11 -9
  64. package/dist/prosemirror/textInput.d.ts +6 -1
  65. package/dist/prosemirror/textInput.js +9 -2
  66. package/dist/prosemirror/utils/visualLineNavigation.js +6 -8
  67. package/dist/utils/mergeDocumentContent.d.ts +2 -2
  68. package/dist/utils/mergeDocumentContent.js +11 -8
  69. package/package.json +2 -2
@@ -1,31 +1,35 @@
1
1
  import { Node } from "prosemirror-model";
2
2
  //#region src/prosemirror/plugins/revisionIds.d.ts
3
+ declare const RevisionIdAllocationError_base: import("better-result").TaggedErrorClass<"RevisionIdAllocationError">;
4
+ declare class RevisionIdAllocationError extends RevisionIdAllocationError_base<{
5
+ message: string;
6
+ first: number;
7
+ next: number;
8
+ }> {}
9
+ /** Deterministic package-local batches prefer ids above loaded revisions, then a free interval. */
10
+ declare const revisionIdSeedAbove: (existingIds: Iterable<number>) => number;
11
+ /** The next free id, including after the signed 32-bit boundary wraps. */
12
+ declare const nextRevisionId: () => number;
3
13
  /**
4
- * Mint the next tracked-revision id (`w:id`). Strictly monotonic per realm,
5
- * and always inside the range serialized `w:id` may occupy.
14
+ * A shared batch increments its seed locally, so a single free id is not
15
+ * enough: start in the largest free interval. Every contiguous batch that
16
+ * can fit anywhere fits here, including after loaded ids shorten the tail.
17
+ * Continue within this interval until another producer changes the cursor.
6
18
  */
19
+ declare const nextRevisionIdRange: () => number;
20
+ /** Reserve a deterministic batch without changing its caller-supplied ids. */
21
+ declare const reserveRevisionIds: (first: number, next: number) => void;
22
+ /** Shared producers must never cross a loaded or already issued id on wrap. */
23
+ declare const claimRevisionIds: (first: number, next: number) => void;
24
+ /** Mint a bounded id without reusing loaded or previously issued ids. */
7
25
  declare function mintRevisionId(): number;
8
- /**
9
- * Raise the counter above `maxId` so freshly minted ids cannot collide with
10
- * ids already present in a loaded document. Only ever raises.
11
- *
12
- * `maxId` is derived from file content, so it is untrusted: a malformed or
13
- * out-of-range value is ignored outright rather than folded into range.
14
- */
26
+ /** Reserve a valid loaded id and prefer the range above it. */
15
27
  declare function seedRevisionIdsAbove(maxId: number): void;
16
- /**
17
- * The highest id in `doc` from any part of the annotation space.
18
- *
19
- * A revision, a comment and a bookmark draw their `w:id` from one space, so a
20
- * counter seeded above its own kind alone still mints over another kind's id.
21
- * Both allocators seed from this, and the save-time normalization avoids the
22
- * same space, so the three agree on what is taken.
23
- */
24
- declare function maxAnnotationIdInDoc(doc: Node): number;
28
+ declare const maxAnnotationIdInDoc: (doc: Node) => number;
25
29
  /**
26
30
  * Raise the counter above every annotation id already present in `doc`.
27
31
  * Called from the suggestion-mode plugin's `state.init`.
28
32
  */
29
33
  declare function seedRevisionIdsFromDoc(doc: Node): void;
30
34
  //#endregion
31
- export { maxAnnotationIdInDoc, mintRevisionId, seedRevisionIdsAbove, seedRevisionIdsFromDoc };
35
+ export { RevisionIdAllocationError, claimRevisionIds, maxAnnotationIdInDoc, mintRevisionId, nextRevisionId, nextRevisionIdRange, reserveRevisionIds, revisionIdSeedAbove, seedRevisionIdsAbove, seedRevisionIdsFromDoc };
@@ -1,3 +1,4 @@
1
+ import { TaggedError } from "better-result";
1
2
  import { MAX_REVISION_ID } from "@stll/docx-core/model";
2
3
  //#region src/prosemirror/plugins/revisionIds.ts
3
4
  /**
@@ -9,23 +10,98 @@ import { MAX_REVISION_ID } from "@stll/docx-core/model";
9
10
  * int consumers read `w:id` into.
10
11
  */
11
12
  let counter = 1;
13
+ const occupiedIds = /* @__PURE__ */ new Set();
14
+ var RevisionIdAllocationError = class extends TaggedError("RevisionIdAllocationError") {};
15
+ /** Batches use contiguous ids; on rollover prefer the largest unused interval. */
16
+ const largestFreeIntervalStart = (reservedIds) => {
17
+ let first = 1;
18
+ let bestFirst = 1;
19
+ let bestSize = 0;
20
+ const boundaries = [...reservedIds].filter((id) => id > 0).toSorted((a, b) => a - b);
21
+ boundaries.push(MAX_REVISION_ID + 1);
22
+ for (const boundary of boundaries) {
23
+ const size = boundary - first;
24
+ if (size > bestSize) {
25
+ bestFirst = first;
26
+ bestSize = size;
27
+ }
28
+ first = boundary + 1;
29
+ }
30
+ if (bestSize === 0) throw new RevisionIdAllocationError({
31
+ message: "Revision id space exhausted",
32
+ first: 1,
33
+ next: 1
34
+ });
35
+ return bestFirst;
36
+ };
37
+ /** Deterministic package-local batches prefer ids above loaded revisions, then a free interval. */
38
+ const revisionIdSeedAbove = (existingIds) => {
39
+ const reservedIds = /* @__PURE__ */ new Set();
40
+ let maxId = 0;
41
+ for (const id of existingIds) {
42
+ if (!Number.isInteger(id) || id < 0 || id > MAX_REVISION_ID) continue;
43
+ reservedIds.add(id);
44
+ maxId = Math.max(maxId, id);
45
+ }
46
+ return maxId < MAX_REVISION_ID ? maxId + 1 : largestFreeIntervalStart(reservedIds);
47
+ };
48
+ /** The next free id, including after the signed 32-bit boundary wraps. */
49
+ const nextRevisionId = () => {
50
+ if (counter > MAX_REVISION_ID) counter = largestFreeIntervalStart(occupiedIds);
51
+ while (occupiedIds.has(counter)) {
52
+ counter += 1;
53
+ if (counter > MAX_REVISION_ID) counter = largestFreeIntervalStart(occupiedIds);
54
+ if (occupiedIds.size >= MAX_REVISION_ID) throw new RevisionIdAllocationError({
55
+ message: "Revision id space exhausted",
56
+ first: counter,
57
+ next: counter
58
+ });
59
+ }
60
+ return counter;
61
+ };
12
62
  /**
13
- * Mint the next tracked-revision id (`w:id`). Strictly monotonic per realm,
14
- * and always inside the range serialized `w:id` may occupy.
63
+ * A shared batch increments its seed locally, so a single free id is not
64
+ * enough: start in the largest free interval. Every contiguous batch that
65
+ * can fit anywhere fits here, including after loaded ids shorten the tail.
66
+ * Continue within this interval until another producer changes the cursor.
15
67
  */
68
+ const nextRevisionIdRange = () => {
69
+ counter = largestFreeIntervalStart(occupiedIds);
70
+ return counter;
71
+ };
72
+ const validateRevisionIdRange = (first, next) => {
73
+ if (!Number.isInteger(first) || !Number.isInteger(next) || first < 0 || next < first || next > MAX_REVISION_ID + 1) throw new RevisionIdAllocationError({
74
+ message: "Revision id range exceeds the OOXML limit",
75
+ first,
76
+ next
77
+ });
78
+ };
79
+ /** Reserve a deterministic batch without changing its caller-supplied ids. */
80
+ const reserveRevisionIds = (first, next) => {
81
+ validateRevisionIdRange(first, next);
82
+ for (let id = first; id < next; id += 1) occupiedIds.add(id);
83
+ if (next > counter) counter = next;
84
+ };
85
+ /** Shared producers must never cross a loaded or already issued id on wrap. */
86
+ const claimRevisionIds = (first, next) => {
87
+ validateRevisionIdRange(first, next);
88
+ for (let id = first; id < next && id <= MAX_REVISION_ID; id += 1) if (occupiedIds.has(id)) throw new RevisionIdAllocationError({
89
+ message: "Revision id range overlaps an occupied id",
90
+ first,
91
+ next
92
+ });
93
+ reserveRevisionIds(first, next);
94
+ };
95
+ /** Mint a bounded id without reusing loaded or previously issued ids. */
16
96
  function mintRevisionId() {
17
- if (counter > MAX_REVISION_ID) counter = 1;
18
- return counter++;
97
+ const id = nextRevisionId();
98
+ claimRevisionIds(id, id + 1);
99
+ return id;
19
100
  }
20
- /**
21
- * Raise the counter above `maxId` so freshly minted ids cannot collide with
22
- * ids already present in a loaded document. Only ever raises.
23
- *
24
- * `maxId` is derived from file content, so it is untrusted: a malformed or
25
- * out-of-range value is ignored outright rather than folded into range.
26
- */
101
+ /** Reserve a valid loaded id and prefer the range above it. */
27
102
  function seedRevisionIdsAbove(maxId) {
28
- if (!Number.isInteger(maxId) || maxId < 0 || maxId >= MAX_REVISION_ID) return;
103
+ if (!Number.isInteger(maxId) || maxId < 0 || maxId > MAX_REVISION_ID) return;
104
+ occupiedIds.add(maxId);
29
105
  if (maxId >= counter) counter = maxId + 1;
30
106
  }
31
107
  /** Node attrs that carry a revision id directly (`revisionId` field). */
@@ -33,40 +109,45 @@ const DIRECT_REVISION_ATTR_KEYS = ["trIns", "trDel"];
33
109
  /**
34
110
  * The highest id in `doc` from any part of the annotation space.
35
111
  *
36
- * A revision, a comment and a bookmark draw their `w:id` from one space, so a
37
- * counter seeded above its own kind alone still mints over another kind's id.
38
- * Both allocators seed from this, and the save-time normalization avoids the
39
- * same space, so the three agree on what is taken.
112
+ * Preserve the editor's conservative annotation seeding: revisions reserve
113
+ * comment and bookmark ids too, although OOXML defines separate id spaces.
40
114
  */
41
- function maxAnnotationIdInDoc(doc) {
115
+ const visitAnnotationIds = (doc, visit) => {
42
116
  let max = 0;
43
117
  const consider = (id) => {
44
- if (typeof id === "number" && Number.isInteger(id) && id > max && id <= MAX_REVISION_ID) max = id;
118
+ if (typeof id === "number" && Number.isInteger(id) && id >= 0 && id <= MAX_REVISION_ID) {
119
+ visit(id);
120
+ max = Math.max(max, id);
121
+ }
122
+ };
123
+ const considerMetadata = (value) => {
124
+ if (typeof value !== "object" || value === null) return;
125
+ for (const [key, child] of Object.entries(value)) if (key === "id" || key === "revisionId" || key === "commentId") consider(child);
126
+ else considerMetadata(child);
45
127
  };
46
128
  doc.descendants((node) => {
47
129
  for (const mark of node.marks) {
48
130
  consider(mark.attrs["revisionId"]);
49
131
  consider(mark.attrs["commentId"]);
132
+ considerMetadata(mark.attrs["_docxRevisionAncestors"]);
133
+ considerMetadata(mark.attrs["changes"]);
50
134
  }
51
135
  if (node.type.name === "bookmarkBoundary") consider(node.attrs["id"]);
52
136
  const attrs = node.attrs;
53
- const pPrMark = attrs["pPrMark"];
54
- if (pPrMark?.info) consider(pPrMark.info.id);
55
- for (const key of DIRECT_REVISION_ATTR_KEYS) {
56
- const value = attrs[key];
57
- if (value) consider(value.revisionId);
58
- }
59
- const cellMarker = attrs["cellMarker"];
60
- if (cellMarker?.info) consider(cellMarker.info.revisionId);
137
+ considerMetadata(attrs["_propertyChanges"]);
138
+ considerMetadata(attrs["pPrMark"]);
139
+ for (const key of DIRECT_REVISION_ATTR_KEYS) considerMetadata(attrs[key]);
140
+ considerMetadata(attrs["cellMarker"]);
61
141
  });
62
142
  return max;
63
- }
143
+ };
144
+ const maxAnnotationIdInDoc = (doc) => visitAnnotationIds(doc, () => {});
64
145
  /**
65
146
  * Raise the counter above every annotation id already present in `doc`.
66
147
  * Called from the suggestion-mode plugin's `state.init`.
67
148
  */
68
149
  function seedRevisionIdsFromDoc(doc) {
69
- seedRevisionIdsAbove(maxAnnotationIdInDoc(doc));
150
+ visitAnnotationIds(doc, seedRevisionIdsAbove);
70
151
  }
71
152
  //#endregion
72
- export { maxAnnotationIdInDoc, mintRevisionId, seedRevisionIdsAbove, seedRevisionIdsFromDoc };
153
+ export { RevisionIdAllocationError, claimRevisionIds, maxAnnotationIdInDoc, mintRevisionId, nextRevisionId, nextRevisionIdRange, reserveRevisionIds, revisionIdSeedAbove, seedRevisionIdsAbove, seedRevisionIdsFromDoc };
@@ -66,8 +66,8 @@ declare function handleSuggestionEnter(view: EditorView, pluginState: Suggestion
66
66
  * - Delete-at-paragraph-end → current paragraph.
67
67
  *
68
68
  * Returns `null` when there is no adjacent sibling paragraph (the join would
69
- * not produce paragraph-mark merging — e.g. doc start, doc end, or sibling
70
- * is a table). The default merge path then runs unchanged.
69
+ * not produce paragraph-mark merging, e.g. doc start, doc end, or a table).
70
+ * Bookmark markers at an edge do not hide the adjacent paragraph break.
71
71
  */
72
72
  declare function paragraphBoundaryTarget(state: EditorState, direction: "backward" | "forward"): number | null;
73
73
  /** Apply the editor's selected-content deletion path for a host cut action. */
@@ -1,4 +1,5 @@
1
1
  import { expectParagraphAttrs, expectTrackedChangeMarkAttrs } from "../attrs/index.js";
2
+ import { clearIndentOnBackspace } from "../commands/clearParagraphIndent.js";
2
3
  import { paragraphPropertiesSnapshot } from "../commands/propertyChangeScope.js";
3
4
  import { paragraphEndsItsContainer } from "../containerFinalParagraph.js";
4
5
  import { encloseWholeControls } from "../contentControlRevisions.js";
@@ -10,14 +11,16 @@ import { rebaseParagraphRuns } from "../rebaseParagraphRunFormatting.js";
10
11
  import { RUN_FORMATTING_MARK_NAMES } from "../runFormattingMarkNames.js";
11
12
  import { paragraphRunStyleContext } from "../runStyleFormatting.js";
12
13
  import { cellPasteRange, pasteTableCells } from "../tableCellPaste.js";
13
- import { handleEditorBeforeInput } from "../textInput.js";
14
+ import { afterNativeCompositionFlush, handleEditorBeforeInput } from "../textInput.js";
14
15
  import { canCarryTrackedRunMark } from "../trackedRunInlineAtoms.js";
15
16
  import { getDocumentNumbering } from "./documentNumbering.js";
16
- import { getDocumentStyleResolver } from "./documentStyleState.js";
17
+ import { documentStylesKey, getDocumentStyleResolver } from "./documentStyleState.js";
17
18
  import { mintRevisionId, seedRevisionIdsFromDoc } from "./revisionIds.js";
18
19
  import { Slice } from "prosemirror-model";
20
+ import { CellSelection } from "prosemirror-tables";
19
21
  import { Mapping, ReplaceStep } from "prosemirror-transform";
20
- import { AllSelection, Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state";
22
+ import { AllSelection, EditorState, Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state";
23
+ import { joinBackward, joinForward, selectNodeBackward, selectNodeForward } from "prosemirror-commands";
21
24
  import { isHistoryTransaction } from "prosemirror-history";
22
25
  import { undoInputRule } from "prosemirror-inputrules";
23
26
  //#region src/prosemirror/plugins/suggestionMode.ts
@@ -241,10 +244,44 @@ function markRangeAsInserted(tr, doc, from, to, insertionType, deletionType, att
241
244
  function handleSuggestionPaste(view, slice, pluginState) {
242
245
  const { selection } = view.state;
243
246
  const selectsAll = selection instanceof AllSelection;
244
- if (!(selection instanceof TextSelection || selectsAll) || selection.empty) return false;
247
+ if (!(selection instanceof TextSelection || selectsAll)) return false;
245
248
  const insertionType = view.state.schema.marks["insertion"];
246
249
  const deletionType = view.state.schema.marks["deletion"];
247
250
  if (!insertionType || !deletionType) return false;
251
+ if (selection.empty) {
252
+ const { $from } = selection;
253
+ const closedTable = slice.openStart === 0 && slice.openEnd === 0 && slice.content.firstChild?.type.name === "table" && slice.content.lastChild?.type.name === "table";
254
+ const prefix = $from.parent.content.cut(0, $from.parentOffset);
255
+ let deletedPrefix = prefix.size > 0;
256
+ prefix.forEach((node) => {
257
+ if (!node.marks.some((mark) => mark.type === deletionType)) deletedPrefix = false;
258
+ });
259
+ if (!closedTable || $from.parent.type.name !== "paragraph" || !deletedPrefix) return false;
260
+ const suffix = $from.parent.content.cut($from.parentOffset);
261
+ let deletedSuffix = true;
262
+ suffix.forEach((node) => {
263
+ if (!node.marks.some((mark) => mark.type === deletionType)) deletedSuffix = false;
264
+ });
265
+ const tr = view.state.tr.setMeta(SUGGESTION_META, true).setMeta("paste", true);
266
+ const at = $from.before();
267
+ tr.replaceRange(at, at, slice);
268
+ const revision = makeMarkAttrs(pluginState);
269
+ const end = tr.mapping.map(at, 1);
270
+ enclosePastedRunRevisions({
271
+ tr,
272
+ from: at,
273
+ to: end,
274
+ revision
275
+ });
276
+ markRangeAsInserted(tr, tr.doc, at, end, insertionType, deletionType, revision);
277
+ if (deletedSuffix && $from.parent.attrs["pPrMark"] == null && !paragraphEndsItsContainer($from.doc.resolve($from.before()), "paragraph")) tr.setNodeAttribute(tr.mapping.map($from.before()), "pPrMark", {
278
+ kind: "del",
279
+ info: makeParagraphMarkInfo(pluginState)
280
+ });
281
+ tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(selection.from)));
282
+ view.dispatch(tr.scrollIntoView());
283
+ return true;
284
+ }
248
285
  if (selectsAll && (slice.openStart > 0 || slice.openEnd > 0)) slice = new Slice(view.state.tr.replaceSelection(slice).doc.content, 0, 0);
249
286
  const { from, to } = selectsAll ? {
250
287
  from: Selection.atStart(view.state.doc).from,
@@ -372,16 +409,54 @@ function handleSuggestionTableCellPaste(view, slice, pluginState) {
372
409
  const insertFrom = tr.mapping.slice(mapFrom).map(to);
373
410
  const sizeBefore = tr.doc.content.size;
374
411
  tr.replaceRange(insertFrom, insertFrom, content);
375
- const insertTo = insertFrom + (tr.doc.content.size - sizeBefore);
412
+ let insertTo = insertFrom + (tr.doc.content.size - sizeBefore);
413
+ let pastedFrom = insertFrom;
414
+ const finalParagraph = cell.lastChild;
415
+ const $pasteFrom = tr.doc.resolve(pastedFrom);
416
+ const $pasteTo = tr.doc.resolve(insertTo);
417
+ if (content.openStart > 0 && content.openEnd > 0 && finalParagraph?.type.name === "paragraph" && $pasteFrom.parent.type.name === "paragraph" && $pasteTo.parent.type.name === "paragraph" && $pasteFrom.before() !== $pasteTo.before()) {
418
+ tr.setNodeAttribute($pasteFrom.before(), "pPrMark", {
419
+ kind: "ins",
420
+ info: {
421
+ id: revision.revisionId,
422
+ author: revision.author,
423
+ date: revision.date
424
+ }
425
+ });
426
+ tr.setNodeAttribute($pasteTo.before(), "pPrMark", finalParagraph.attrs["pPrMark"]);
427
+ recordReplacedParagraphProperties({
428
+ tr,
429
+ position: $pasteTo.before(),
430
+ replaced: finalParagraph,
431
+ revision: {
432
+ id: revision.revisionId,
433
+ author: revision.author,
434
+ date: revision.date
435
+ }
436
+ });
437
+ }
438
+ const emptyInsertions = [];
439
+ tr.doc.nodeAt(cellPos)?.forEach((node, offset) => {
440
+ const pos = cellPos + 1 + offset;
441
+ if (pos < pastedFrom && node.type.name === "paragraph" && node.content.size === 0 && isCurrentAuthorParagraphInsertion(node.attrs["pPrMark"], pluginState.author)) emptyInsertions.push({
442
+ from: pos,
443
+ to: pos + node.nodeSize
444
+ });
445
+ });
446
+ const retraction = tr.mapping.maps.length;
447
+ for (const range of emptyInsertions.toReversed()) tr.delete(range.from, range.to);
448
+ const retracted = tr.mapping.slice(retraction);
449
+ pastedFrom = retracted.map(pastedFrom);
450
+ insertTo = retracted.map(insertTo);
376
451
  enclosePastedRunRevisions({
377
452
  tr,
378
- from: insertFrom,
453
+ from: pastedFrom,
379
454
  to: insertTo,
380
455
  revision
381
456
  });
382
- markRangeAsInserted(tr, tr.doc, insertFrom, insertTo, insertionType, deletionType, revision);
383
- tr.doc.nodesBetween(insertFrom, insertTo, (node, pos) => {
384
- if (node.type.name === "paragraph" && pos >= insertFrom && pos + node.nodeSize <= insertTo && node.attrs["pPrMark"] == null) tr.setNodeAttribute(pos, "pPrMark", {
457
+ markRangeAsInserted(tr, tr.doc, pastedFrom, insertTo, insertionType, deletionType, revision);
458
+ tr.doc.nodesBetween(pastedFrom, insertTo, (node, pos) => {
459
+ if (node.type.name === "paragraph" && pos >= pastedFrom && pos + node.nodeSize <= insertTo && node.attrs["pPrMark"] == null) tr.setNodeAttribute(pos, "pPrMark", {
385
460
  kind: "ins",
386
461
  info: {
387
462
  id: revision.revisionId,
@@ -533,15 +608,38 @@ function suggestRangeDeletion(state, tr, from, to) {
533
608
  * prior author's revision must not be silently overwritten.
534
609
  */
535
610
  function handleSuggestionEnter(view, pluginState) {
536
- const { $from } = view.state.selection;
537
- if ($from.parent.type.name !== "paragraph") return false;
611
+ const state = view.state;
612
+ if (state.selection.$from.parent.type.name !== "paragraph") return false;
613
+ const replacement = { tr: null };
614
+ let splitState = state;
615
+ if (state.selection instanceof TextSelection && !state.selection.empty) {
616
+ if (!handleSuggestionDelete(state, (tr) => {
617
+ replacement.tr = tr;
618
+ const styles = documentStylesKey.get(state);
619
+ const plugins = [];
620
+ if (styles) {
621
+ const spec = { ...styles.spec };
622
+ delete spec.appendTransaction;
623
+ delete spec.filterTransaction;
624
+ plugins.push(new Plugin(spec));
625
+ }
626
+ splitState = state.reconfigure({ plugins }).apply(tr);
627
+ }, "forward")) return false;
628
+ }
629
+ const { $from } = splitState.selection;
538
630
  const sourcePos = $from.before();
539
631
  const sourceAttrs = $from.parent.attrs;
540
632
  const captured = { tr: null };
541
- if (!splitBlockClearBorders(view.state, (tr) => {
633
+ if (!splitBlockClearBorders(splitState, (tr) => {
542
634
  captured.tr = tr;
543
635
  }, view) || !captured.tr) return false;
544
- const tr = captured.tr;
636
+ const tr = replacement.tr ?? captured.tr;
637
+ if (replacement.tr) {
638
+ for (const step of captured.tr.steps) tr.step(step);
639
+ tr.setSelection(captured.tr.selection.getBookmark().resolve(tr.doc));
640
+ tr.setStoredMarks(captured.tr.storedMarks);
641
+ }
642
+ tr.setMeta(SUGGESTION_META, true);
545
643
  if (sourceAttrs["pPrMark"] == null) {
546
644
  if (tr.doc.nodeAt(sourcePos)?.type.name === "paragraph") {
547
645
  const markInfo = {
@@ -561,6 +659,24 @@ function handleSuggestionEnter(view, pluginState) {
561
659
  view.dispatch(tr.scrollIntoView());
562
660
  return true;
563
661
  }
662
+ /** Find the adjacent visible unit without consuming a bookmark range marker. */
663
+ function caretDeleteTarget(state, direction) {
664
+ const { $from } = state.selection;
665
+ const backward = direction === "backward";
666
+ let from = backward ? $from.pos - 1 : $from.pos;
667
+ let to = backward ? $from.pos : $from.pos + 1;
668
+ while (from >= $from.start() && to <= $from.end()) {
669
+ const adjacent = state.doc.resolve(from).nodeAfter;
670
+ if (adjacent?.type.name !== "bookmarkBoundary") return {
671
+ type: "inlineUnit",
672
+ from,
673
+ to
674
+ };
675
+ from += backward ? -adjacent.nodeSize : adjacent.nodeSize;
676
+ to += backward ? -adjacent.nodeSize : adjacent.nodeSize;
677
+ }
678
+ return { type: "paragraphEdge" };
679
+ }
564
680
  /**
565
681
  * Detect a caret-at-paragraph-boundary scenario where Backspace/Delete should
566
682
  * record a tracked paragraph-mark deletion instead of joining paragraphs.
@@ -572,8 +688,8 @@ function handleSuggestionEnter(view, pluginState) {
572
688
  * - Delete-at-paragraph-end → current paragraph.
573
689
  *
574
690
  * Returns `null` when there is no adjacent sibling paragraph (the join would
575
- * not produce paragraph-mark merging — e.g. doc start, doc end, or sibling
576
- * is a table). The default merge path then runs unchanged.
691
+ * not produce paragraph-mark merging, e.g. doc start, doc end, or a table).
692
+ * Bookmark markers at an edge do not hide the adjacent paragraph break.
577
693
  */
578
694
  function paragraphBoundaryTarget(state, direction) {
579
695
  const { $from, empty } = state.selection;
@@ -581,14 +697,13 @@ function paragraphBoundaryTarget(state, direction) {
581
697
  if ($from.parent.type.name !== "paragraph") return null;
582
698
  const paragraphStart = $from.before();
583
699
  const paragraphEnd = $from.after();
700
+ if (caretDeleteTarget(state, direction).type !== "paragraphEdge") return null;
584
701
  if (direction === "backward") {
585
- if ($from.parentOffset !== 0) return null;
586
702
  if (paragraphStart === 0) return null;
587
703
  const prev = state.doc.resolve(paragraphStart).nodeBefore;
588
704
  if (!prev || prev.type.name !== "paragraph") return null;
589
705
  return paragraphStart - prev.nodeSize;
590
706
  }
591
- if ($from.parentOffset !== $from.parent.content.size) return null;
592
707
  if (paragraphEnd >= state.doc.content.size) return null;
593
708
  const next = state.doc.resolve(paragraphEnd).nodeAfter;
594
709
  if (!next || next.type.name !== "paragraph") return null;
@@ -612,7 +727,13 @@ function applyPPrDel(view, targetParagraphPos, pluginState) {
612
727
  const joined = view.state.doc.nodeAt(joinPos);
613
728
  try {
614
729
  tr.join(joinPos);
615
- tr.setNodeAttribute(targetParagraphPos, "pPrMark", null);
730
+ tr.setNodeAttribute(targetParagraphPos, "pPrMark", joined?.type === targetNode.type ? expectParagraphAttrs(joined).pPrMark : null);
731
+ if (joined?.type === targetNode.type) recordReplacedParagraphProperties({
732
+ tr,
733
+ position: targetParagraphPos,
734
+ replaced: joined,
735
+ revision: existingMark.info
736
+ });
616
737
  const styleResolver = getDocumentStyleResolver(view.state);
617
738
  if (styleResolver && joined?.type === targetNode.type && joined.content.size > 0) rebaseParagraphRuns({
618
739
  previousContext: paragraphRunStyleContext(joined, styleResolver),
@@ -671,15 +792,28 @@ function handleSuggestionDelete(state, dispatch, direction) {
671
792
  to: rangeTo.pos
672
793
  }).sort((a, b) => b.from - a.from);
673
794
  for (const { from, to } of ranges) markRangeAsDeleted(tr, state.doc, from, to, insertionType, deletionType, pluginState, state);
674
- const cursorPos = tr.mapping.map($to.pos);
675
- tr.setSelection(TextSelection.near(tr.doc.resolve(cursorPos)));
795
+ if (state.selection instanceof CellSelection) tr.setSelection(state.selection.map(tr.doc, tr.mapping));
796
+ else {
797
+ const cursorPos = tr.mapping.map($to.pos);
798
+ tr.setSelection(TextSelection.near(tr.doc.resolve(cursorPos)));
799
+ }
676
800
  dispatch(tr.scrollIntoView());
677
801
  return true;
678
802
  }
679
803
  const isBackward = direction === "backward";
680
- const deletePos = isBackward ? $from.pos - 1 : $from.pos;
681
- const deleteEnd = isBackward ? $from.pos : $from.pos + 1;
682
- if (deletePos < 0 || deleteEnd > state.doc.content.size) return true;
804
+ const target = caretDeleteTarget(state, direction);
805
+ if (target.type === "paragraphEdge") {
806
+ const edge = isBackward ? $from.start() : $from.end();
807
+ if ($from.pos === edge) return false;
808
+ const virtual = EditorState.create({
809
+ doc: state.doc,
810
+ selection: TextSelection.create(state.doc, edge)
811
+ });
812
+ if ((isBackward ? joinBackward : joinForward)(virtual, dispatch)) return true;
813
+ return (isBackward ? selectNodeBackward : selectNodeForward)(virtual, dispatch);
814
+ }
815
+ const deletePos = target.from;
816
+ const deleteEnd = target.to;
683
817
  const noteRange = expandNoteReferenceDeletionRange(state.doc, deletePos, deleteEnd);
684
818
  const rangeFrom = noteRange?.from ?? deletePos;
685
819
  const rangeTo = noteRange?.to ?? deleteEnd;
@@ -701,19 +835,7 @@ function handleSuggestionDelete(state, dispatch, direction) {
701
835
  }
702
836
  /** Apply the editor's selected-content deletion path for a host cut action. */
703
837
  const deleteSelectionAsSuggestion = (state, dispatch) => state.selection.empty ? false : handleSuggestionDelete(state, dispatch, "forward");
704
- /**
705
- * Mark the text committed by an IME composition as a tracked insertion.
706
- *
707
- * Invoked from `compositionend` (deferred to a microtask) rather than from the
708
- * `appendTransaction` catch-all. ProseMirror commits composed text as part of
709
- * its own composition-end flush; adding the insertion mark *synchronously*
710
- * during that flush re-wraps the very text node the IME just finalized, which
711
- * corrupts CJK (Japanese / Chinese / Korean) input — characters duplicate or
712
- * garble. Running one tick later, after the composition has settled, wraps the
713
- * committed range safely. `markRangeAsInserted` skips nodes that already carry a
714
- * tracked-change mark, so this is idempotent. eigenpal/docx-editor#938.
715
- */
716
- function markComposedAsInsertion(view, from, pluginState, replaced) {
838
+ function markComposedAsInsertion({ view, from, pluginState, replaced, compositionId }) {
717
839
  const insertionType = view.state.schema.marks["insertion"];
718
840
  const deletionType = view.state.schema.marks["deletion"];
719
841
  if (!insertionType || !deletionType) return;
@@ -721,9 +843,16 @@ function markComposedAsInsertion(view, from, pluginState, replaced) {
721
843
  if (to <= from && !replaced) return;
722
844
  const tr = view.state.tr;
723
845
  tr.setMeta(SUGGESTION_META, true);
846
+ if (compositionId !== null) tr.setMeta("composition", compositionId);
724
847
  if (replaced) {
725
- tr.insert(from, replaced.content);
726
- markRangeAsDeleted(tr, tr.doc, from, from + replaced.content.size, insertionType, deletionType, pluginState);
848
+ tr.replaceRange(from, from, replaced);
849
+ markRangeAsDeleted(tr, tr.doc, from, tr.mapping.map(from, 1), insertionType, deletionType, pluginState);
850
+ const restoredEnd = tr.doc.resolve(tr.mapping.map(from, 1));
851
+ const restoredStart = tr.doc.resolve(from);
852
+ if (restoredStart.parent.type.name === "paragraph" && restoredEnd.parent.type.name === "paragraph" && restoredStart.parent !== restoredEnd.parent) {
853
+ const mark = expectParagraphAttrs(restoredStart.parent).pPrMark;
854
+ if (mark?.kind === "del") carryIntoParagraphLeft(view.state, tr, view.state.doc.resolve(from).parent, restoredEnd.before(), mark.info);
855
+ }
727
856
  }
728
857
  const insertionFrom = tr.mapping.map(from, 1);
729
858
  const insertionTo = tr.mapping.map(to, 1);
@@ -773,6 +902,7 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
773
902
  let composing = false;
774
903
  let compositionFrom = null;
775
904
  let compositionReplaced = null;
905
+ let compositionId = null;
776
906
  return new Plugin({
777
907
  key: suggestionModeKey,
778
908
  state: {
@@ -784,6 +914,8 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
784
914
  };
785
915
  },
786
916
  apply(tr, state) {
917
+ const nativeCompositionId = tr.getMeta("composition");
918
+ if (composing && tr.docChanged && typeof nativeCompositionId === "number") compositionId = nativeCompositionId;
787
919
  const meta = tr.getMeta(suggestionModeKey);
788
920
  if (meta) return {
789
921
  ...state,
@@ -809,6 +941,7 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
809
941
  if (!suggestionModeKey.getState(view.state)?.active) return false;
810
942
  if (composing) return false;
811
943
  composing = true;
944
+ compositionId = null;
812
945
  const { from, to } = view.state.selection;
813
946
  compositionFrom = from;
814
947
  compositionReplaced = from === to ? null : view.state.doc.slice(from, to);
@@ -822,14 +955,23 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
822
955
  compositionReplaced = null;
823
956
  if (!pluginState?.active || from == null) {
824
957
  composing = false;
958
+ compositionId = null;
825
959
  return false;
826
960
  }
827
- queueMicrotask(() => {
961
+ afterNativeCompositionFlush(() => {
828
962
  try {
829
963
  const current = suggestionModeKey.getState(view.state);
830
- if (current?.active) markComposedAsInsertion(view, from, current, replaced);
964
+ const nativeCompositionId = compositionId;
965
+ if (current?.active) markComposedAsInsertion({
966
+ view,
967
+ from,
968
+ pluginState: current,
969
+ replaced,
970
+ compositionId: nativeCompositionId
971
+ });
831
972
  } finally {
832
973
  composing = false;
974
+ compositionId = null;
833
975
  }
834
976
  });
835
977
  return false;
@@ -851,6 +993,20 @@ function createSuggestionModePlugin(initialActive = false, author = "User") {
851
993
  if (event.key === "Backspace" || event.key === "Delete") {
852
994
  const { $from, empty } = view.state.selection;
853
995
  if (event.key === "Backspace" && empty && $from.parentOffset === 0 && $from.parent.type.name === "paragraph" && expectParagraphAttrs($from.parent).numPr?.kind === "reference") return false;
996
+ if (event.key === "Backspace" && clearIndentOnBackspace(view.state, (tr) => {
997
+ const revision = makeMarkAttrs(pluginState);
998
+ recordReplacedParagraphProperties({
999
+ tr,
1000
+ position: $from.before(),
1001
+ replaced: $from.parent,
1002
+ revision: {
1003
+ id: revision.revisionId,
1004
+ author: revision.author,
1005
+ date: revision.date
1006
+ }
1007
+ });
1008
+ view.dispatch(tr.setMeta("suggestionModeApplied", true));
1009
+ })) return true;
854
1010
  const boundaryTarget = paragraphBoundaryTarget(view.state, event.key === "Backspace" ? "backward" : "forward");
855
1011
  if (boundaryTarget !== null) return applyPPrDel(view, boundaryTarget, pluginState);
856
1012
  const direction = event.key === "Backspace" ? "backward" : "forward";