@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
@@ -0,0 +1,23 @@
1
+ import { expectTableCellAttrs, expectTableRowAttrs } from "../../prosemirror/attrs/index.js";
2
+ //#region src/layout-bridge/convert/fixedTableColumnWidths.ts
3
+ /** Resolve absolute cell preferences for an unbounded fixed-layout grid (§17.4.53). */
4
+ const fixedTableColumnWidths = (table, grid) => {
5
+ const preferred = /* @__PURE__ */ new Map();
6
+ for (let rowIndex = 0; rowIndex < table.childCount; rowIndex++) {
7
+ const row = table.child(rowIndex);
8
+ const rowAttrs = expectTableRowAttrs(row);
9
+ if (rowAttrs.trIns || rowAttrs.trDel || rowAttrs.hidden) return grid;
10
+ let column = Math.max(0, rowAttrs._originalFormatting?.gridBefore ?? 0);
11
+ for (let cellIndex = 0; cellIndex < row.childCount; cellIndex++) {
12
+ const attrs = expectTableCellAttrs(row.child(cellIndex));
13
+ if (attrs._omittedGridSlot) continue;
14
+ const width = attrs._authoredWidth;
15
+ if (attrs.colspan !== 1 || attrs.rowspan !== 1 || attrs._preserveVMergeRestart || attrs._originalFormatting?.vMerge !== void 0 || !width || width.type !== "dxa" || !Number.isFinite(width.value) || width.value <= 0) return grid;
16
+ if (column < grid.length) preferred.set(column, Math.max(preferred.get(column) ?? 0, width.value));
17
+ column++;
18
+ }
19
+ }
20
+ return grid.map((width, column) => preferred.get(column) ?? width);
21
+ };
22
+ //#endregion
23
+ export { fixedTableColumnWidths };
@@ -3,6 +3,7 @@ import { DEFAULT_TEXTBOX_MARGINS } from "../../layout-engine/types.js";
3
3
  import { expectParagraphAttrs, expectTableAttrs, expectTableCellAttrs, expectTableRowAttrs, expectTextBoxAttrs } from "../../prosemirror/attrs/index.js";
4
4
  import { textBoxHostParagraph } from "../../prosemirror/textBoxHostParagraph.js";
5
5
  import { normalizeShapeTextAnchor } from "../../types/documentEnumValues.js";
6
+ import { fixedTableColumnWidths } from "./fixedTableColumnWidths.js";
6
7
  import { convertBorderSpecToLayout } from "./flowBorders.js";
7
8
  import { nextBlockId, twipsToPixels } from "./flowConversionShared.js";
8
9
  import { countPageBreakRuns, hasSingleLeadingProjectedPageBreak } from "./pageBreakSplitting.js";
@@ -183,7 +184,9 @@ function convertTable(node, startPos, options) {
183
184
  if (child.type.name === "tableRow") rows.push(convertTableRow(child, offset, options, rowContext));
184
185
  offset += child.nodeSize;
185
186
  });
186
- let columnWidths = attrs.columnWidths?.map(twipsToPixels);
187
+ const columnWidthsTwips = attrs.columnWidths;
188
+ const hasTableWidth = (attrs.widthType === "dxa" || attrs.widthType === "pct") && (attrs.width ?? 0) > 0;
189
+ let columnWidths = (columnWidthsTwips && attrs._originalFormatting?.layout === "fixed" && !hasTableWidth ? fixedTableColumnWidths(node, columnWidthsTwips) : columnWidthsTwips)?.map(twipsToPixels);
187
190
  const width = attrs.width;
188
191
  const widthType = attrs.widthType;
189
192
  if (!columnWidths && rows.length > 0) {
@@ -1,4 +1,4 @@
1
- import { prefersReducedMotionBehavior } from "../paged-layout/scrollNavigation.js";
1
+ import { scrollEditorElementIntoView } from "../paged-layout/editorScrollRoot.js";
2
2
  import { createDefaultRegistry } from "./registry/modules.js";
3
3
  import { createFeatureRegistry } from "./registry/registry.js";
4
4
  import { FRAGMENT_CLASS_NAMES, renderFragment } from "./renderFragment.js";
@@ -167,10 +167,7 @@ var LayoutPainter = class {
167
167
  */
168
168
  scrollToPage(pageNumber) {
169
169
  const state = this.pageStates.find((s) => s.pageNumber === pageNumber);
170
- if (state?.element) state.element.scrollIntoView({
171
- behavior: prefersReducedMotionBehavior(),
172
- block: "start"
173
- });
170
+ if (state?.element) scrollEditorElementIntoView(state.element, { block: "start" });
174
171
  }
175
172
  };
176
173
  /**
@@ -0,0 +1,15 @@
1
+ //#region src/paged-layout/editorScrollRoot.d.ts
2
+ declare const getEditorScrollRoot: (element: HTMLElement | null) => HTMLElement | null;
3
+ /** Resolve the root even when a caller holds a painted descendant or viewport. */
4
+ declare const scrollEditorTo: (element: HTMLElement | null, options: ScrollToOptions) => void;
5
+ declare const scrollEditorBy: (element: HTMLElement | null, delta: number) => void;
6
+ type ScrollEditorElementOptions = {
7
+ block?: "start" | "center" | "end" | "nearest";
8
+ behavior?: ScrollBehavior;
9
+ /** Space to leave between the target and the visible root edge. */
10
+ margin?: number;
11
+ };
12
+ /** Scroll only the marked root, including when the target is inside a scaled viewport. */
13
+ declare const scrollEditorElementIntoView: (element: HTMLElement | null, { block, behavior, margin }?: ScrollEditorElementOptions) => void;
14
+ //#endregion
15
+ export { getEditorScrollRoot, scrollEditorBy, scrollEditorElementIntoView, scrollEditorTo };
@@ -0,0 +1,72 @@
1
+ import { prefersReducedMotionBehavior } from "./scrollNavigation.js";
2
+ //#region src/paged-layout/editorScrollRoot.ts
3
+ /** The marked editor owns scrolling; navigation must never move host ancestors. */
4
+ const EDITOR_SCROLL_ROOT_SELECTOR = "[data-folio-scroll]";
5
+ const getEditorScrollRoot = (element) => element?.closest(EDITOR_SCROLL_ROOT_SELECTOR) ?? null;
6
+ /** Resolve the root even when a caller holds a painted descendant or viewport. */
7
+ const scrollEditorTo = (element, options) => {
8
+ getEditorScrollRoot(element)?.scrollTo(options);
9
+ };
10
+ const scrollEditorBy = (element, delta) => {
11
+ const root = getEditorScrollRoot(element);
12
+ if (root) scrollEditorTo(root, {
13
+ top: root.scrollTop + delta,
14
+ behavior: "instant"
15
+ });
16
+ };
17
+ const nearestScrollOffset = ({ targetStart, targetEnd, viewportStart, viewportEnd }) => {
18
+ const above = targetStart < viewportStart;
19
+ const below = targetEnd > viewportEnd;
20
+ if (above && below) return 0;
21
+ const targetSize = targetEnd - targetStart;
22
+ const viewportSize = viewportEnd - viewportStart;
23
+ if (above && targetSize <= viewportSize || below && targetSize > viewportSize) return targetStart - viewportStart;
24
+ if (below && targetSize <= viewportSize || above && targetSize > viewportSize) return targetEnd - viewportEnd;
25
+ return 0;
26
+ };
27
+ /** Scroll only the marked root, including when the target is inside a scaled viewport. */
28
+ const scrollEditorElementIntoView = (element, { block = "center", behavior = prefersReducedMotionBehavior(), margin = 0 } = {}) => {
29
+ const root = getEditorScrollRoot(element);
30
+ if (!root || !element) return;
31
+ const targetRect = element.getBoundingClientRect();
32
+ const rootRect = root.getBoundingClientRect();
33
+ const visibleTop = rootRect.top + root.clientTop;
34
+ const targetTop = targetRect.top - visibleTop + root.scrollTop;
35
+ const targetBottom = targetRect.bottom - visibleTop + root.scrollTop;
36
+ let top;
37
+ switch (block) {
38
+ case "start":
39
+ top = targetTop - margin;
40
+ break;
41
+ case "center":
42
+ top = targetTop - (root.clientHeight - targetRect.height) / 2;
43
+ break;
44
+ case "end":
45
+ top = targetBottom - root.clientHeight + margin;
46
+ break;
47
+ case "nearest":
48
+ top = root.scrollTop + nearestScrollOffset({
49
+ targetStart: targetTop,
50
+ targetEnd: targetBottom,
51
+ viewportStart: root.scrollTop + margin,
52
+ viewportEnd: root.scrollTop + root.clientHeight - margin
53
+ });
54
+ break;
55
+ default: return block;
56
+ }
57
+ const visibleLeft = rootRect.left + root.clientLeft;
58
+ const left = root.scrollLeft + nearestScrollOffset({
59
+ targetStart: targetRect.left,
60
+ targetEnd: targetRect.right,
61
+ viewportStart: visibleLeft,
62
+ viewportEnd: visibleLeft + root.clientWidth
63
+ });
64
+ if (top === root.scrollTop && left === root.scrollLeft) return;
65
+ scrollEditorTo(root, {
66
+ top: Math.max(0, top),
67
+ left: Math.max(0, left),
68
+ behavior
69
+ });
70
+ };
71
+ //#endregion
72
+ export { getEditorScrollRoot, scrollEditorBy, scrollEditorElementIntoView, scrollEditorTo };
@@ -1,6 +1,7 @@
1
1
  import { findBodyPmAnchor, findBodyPmAnchors } from "../layout-bridge/dom/findBodyPmSpans.js";
2
2
  import { findPageShellForPmPos } from "../layout-painter/renderPage.js";
3
- import { isValidPmScrollPosition, prefersReducedMotionBehavior } from "./scrollNavigation.js";
3
+ import { getEditorScrollRoot, scrollEditorElementIntoView } from "./editorScrollRoot.js";
4
+ import { isValidPmScrollPosition } from "./scrollNavigation.js";
4
5
  //#region src/paged-layout/scrollToPmPosition.ts
5
6
  /** Class on the pages container `PagedEditor` paints into. */
6
7
  const PAGES_CONTAINER_CLASS = "paged-editor__pages";
@@ -9,10 +10,7 @@ const scrollPagesToPmPosition = (pageContainer, pmPos) => {
9
10
  if (!isValidPmScrollPosition(pmPos)) return;
10
11
  const exact = findBodyPmAnchor(pageContainer, pmPos);
11
12
  if (exact) {
12
- exact.scrollIntoView({
13
- behavior: prefersReducedMotionBehavior(),
14
- block: "center"
15
- });
13
+ scrollEditorElementIntoView(exact);
16
14
  return;
17
15
  }
18
16
  let runMatch = null;
@@ -27,28 +25,19 @@ const scrollPagesToPmPosition = (pageContainer, pmPos) => {
27
25
  }
28
26
  }
29
27
  if (runMatch) {
30
- runMatch.scrollIntoView({
31
- behavior: prefersReducedMotionBehavior(),
32
- block: "center"
33
- });
28
+ scrollEditorElementIntoView(runMatch);
34
29
  return;
35
30
  }
36
31
  const shellHit = findPageShellForPmPos(pageContainer, pmPos);
37
32
  if (!shellHit) return;
38
33
  const { element: shell } = shellHit;
39
- shell.scrollIntoView({
40
- behavior: prefersReducedMotionBehavior(),
41
- block: "center"
42
- });
34
+ scrollEditorElementIntoView(shell);
43
35
  let attempts = 0;
44
36
  const refine = () => {
45
37
  attempts++;
46
38
  const exactInShell = findBodyPmAnchor(shell, pmPos);
47
39
  if (exactInShell) {
48
- exactInShell.scrollIntoView({
49
- behavior: prefersReducedMotionBehavior(),
50
- block: "center"
51
- });
40
+ scrollEditorElementIntoView(exactInShell);
52
41
  return;
53
42
  }
54
43
  let bestEl = null;
@@ -68,10 +57,7 @@ const scrollPagesToPmPosition = (pageContainer, pmPos) => {
68
57
  }
69
58
  }
70
59
  if (bestEl) {
71
- bestEl.scrollIntoView({
72
- behavior: prefersReducedMotionBehavior(),
73
- block: "center"
74
- });
60
+ scrollEditorElementIntoView(bestEl);
75
61
  return;
76
62
  }
77
63
  if (attempts < 20) requestAnimationFrame(refine);
@@ -86,7 +72,7 @@ const scrollPagesToPmPosition = (pageContainer, pmPos) => {
86
72
  * callers can fall back to coordinate-based scrolling.
87
73
  */
88
74
  const scrollFolioPositionIntoView = (view, pmPos) => {
89
- const pageContainer = view.dom.closest("[data-folio-scroll]")?.querySelector(`.paged-editor__pages`) ?? null;
75
+ const pageContainer = getEditorScrollRoot(view.dom)?.querySelector(`.paged-editor__pages`) ?? null;
90
76
  if (!pageContainer) return false;
91
77
  scrollPagesToPmPosition(pageContainer, pmPos);
92
78
  return true;
@@ -0,0 +1,6 @@
1
+ import { Command } from "prosemirror-state";
2
+ //#region src/prosemirror/commands/clearParagraphIndent.d.ts
3
+ /** Clear indentation at paragraph start before Backspace joins paragraphs. */
4
+ declare const clearIndentOnBackspace: Command;
5
+ //#endregion
6
+ export { clearIndentOnBackspace };
@@ -0,0 +1,25 @@
1
+ import { expectParagraphAttrs } from "../attrs/index.js";
2
+ import { TextSelection } from "prosemirror-state";
3
+ //#region src/prosemirror/commands/clearParagraphIndent.ts
4
+ /** Clear indentation at paragraph start before Backspace joins paragraphs. */
5
+ const clearIndentOnBackspace = (state, dispatch) => {
6
+ if (!(state.selection instanceof TextSelection)) return false;
7
+ const { $cursor } = state.selection;
8
+ if (!$cursor || $cursor.parentOffset !== 0 || $cursor.parent.type.name !== "paragraph") return false;
9
+ const attrs = expectParagraphAttrs($cursor.parent);
10
+ const hasFirstLine = typeof attrs.indentFirstLine === "number" && attrs.indentFirstLine > 0;
11
+ const hasIndentLeft = typeof attrs.indentLeft === "number" && attrs.indentLeft > 0;
12
+ if (!hasFirstLine && !attrs.hangingIndent && !hasIndentLeft) return false;
13
+ if (dispatch) {
14
+ const position = $cursor.before();
15
+ dispatch(state.tr.setNodeMarkup(position, void 0, {
16
+ ...attrs,
17
+ indentFirstLine: null,
18
+ hangingIndent: null,
19
+ indentLeft: null
20
+ }).scrollIntoView());
21
+ }
22
+ return true;
23
+ };
24
+ //#endregion
25
+ export { clearIndentOnBackspace };
@@ -3,30 +3,23 @@ import { EditorView } from "prosemirror-view";
3
3
  //#region src/prosemirror/commentIdAllocator.d.ts
4
4
  /** Sentinel ID for a comment that hasn't been persisted yet (anchored to selection). */
5
5
  declare const PENDING_COMMENT_ID = -1;
6
+ declare const CommentIdSpaceExhaustedError_base: import("better-result").TaggedErrorClass<"CommentIdSpaceExhaustedError">;
7
+ declare class CommentIdSpaceExhaustedError extends CommentIdSpaceExhaustedError_base<{
8
+ message: string;
9
+ }> {}
10
+ /** Allocate a valid, unused comment ID in this realm. */
11
+ declare function allocateCommentId(): number;
12
+ /** Reserve a loaded ID and advance past it when the upper bound permits. */
13
+ declare function seedCommentIdAbove(id: number): void;
6
14
  type CommentIdAllocator = {
7
- /** Allocate the next ID and advance the counter. */
15
+ /** Allocate the next unused ID. */
8
16
  next(): number;
9
- /**
10
- * On document load, bump the counter above the highest ID found in the
11
- * loaded comments and tracked-change marks so subsequent allocations don't
12
- * collide with already-present IDs.
13
- */
14
- seedAbove(maxId: number): void;
17
+ /** Reserve a loaded ID and seed above it when possible. */
18
+ seedAbove(id: number): void;
15
19
  };
16
- /**
17
- * Create an instance-scoped monotonic comment/revision ID allocator. IDs are
18
- * never reused (deleting a comment does not free its ID), and the counter is
19
- * private to this allocator — multiple editors get independent ID spaces.
20
- */
20
+ /** Every handle uses the same counter, including separate editor instances. */
21
21
  declare function createCommentIdAllocator(): CommentIdAllocator;
22
- /**
23
- * Seed an allocator above every comment/revision ID currently in the document
24
- * — comment objects (including replies, which carry no mark) plus
25
- * tracked-change `revisionId` marks. Because `seedAbove` only ever raises the
26
- * counter, this is safe to call on load (React) or before each allocation
27
- * (Vue): new IDs never collide with or reuse an existing one, and the comment
28
- * and revision ID spaces stay unified.
29
- */
30
- declare function seedCommentAllocator(allocator: CommentIdAllocator, comments: Comment[] | undefined, view: EditorView | null): void;
22
+ /** Seed from comments, replies, and the document's conservative annotation maximum. */
23
+ declare function seedCommentAllocator(allocator: CommentIdAllocator, comments: Comment[] | undefined, view: Pick<EditorView, "state"> | null): void;
31
24
  //#endregion
32
- export { CommentIdAllocator, PENDING_COMMENT_ID, createCommentIdAllocator, seedCommentAllocator };
25
+ export { CommentIdAllocator, CommentIdSpaceExhaustedError, PENDING_COMMENT_ID, allocateCommentId, createCommentIdAllocator, seedCommentAllocator, seedCommentIdAbove };
@@ -1,40 +1,65 @@
1
1
  import { maxAnnotationIdInDoc, seedRevisionIdsAbove } from "./plugins/revisionIds.js";
2
+ import { readRangeAnchorAttrs } from "./rangeAnchorAttrs.js";
3
+ import { TaggedError } from "better-result";
2
4
  import { MAX_REVISION_ID } from "@stll/docx-core/model";
3
5
  //#region src/prosemirror/commentIdAllocator.ts
4
- /** Sentinel ID for a comment that hasn't been persisted yet (anchored to selection). */
5
- const PENDING_COMMENT_ID = -1;
6
6
  /**
7
- * Create an instance-scoped monotonic comment/revision ID allocator. IDs are
8
- * never reused (deleting a comment does not free its ID), and the counter is
9
- * private to this allocator — multiple editors get independent ID spaces.
7
+ * Realm-wide comment ID allocation, shared by live editors and headless reviewers.
8
+ * Loaded and issued IDs stay reserved even when a comment is deleted.
10
9
  */
10
+ /** Sentinel ID for a comment that hasn't been persisted yet (anchored to selection). */
11
+ const PENDING_COMMENT_ID = -1;
12
+ var CommentIdSpaceExhaustedError = class extends TaggedError("CommentIdSpaceExhaustedError") {};
13
+ let nextId = 1;
14
+ const reservedIds = /* @__PURE__ */ new Set();
15
+ /** Allocate a valid, unused comment ID in this realm. */
16
+ function allocateCommentId() {
17
+ if (reservedIds.size >= MAX_REVISION_ID) throw new CommentIdSpaceExhaustedError({ message: "The OOXML comment ID space is exhausted." });
18
+ while (reservedIds.has(nextId)) nextId = nextId === MAX_REVISION_ID ? 1 : nextId + 1;
19
+ const id = nextId;
20
+ reservedIds.add(id);
21
+ nextId = id === MAX_REVISION_ID ? 1 : id + 1;
22
+ return id;
23
+ }
24
+ /** Reserve a loaded ID and advance past it when the upper bound permits. */
25
+ function seedCommentIdAbove(id) {
26
+ if (!Number.isInteger(id) || id < 0 || id > MAX_REVISION_ID) return;
27
+ if (id > 0) reservedIds.add(id);
28
+ if (id >= nextId) nextId = id === MAX_REVISION_ID ? 1 : id + 1;
29
+ }
30
+ /** Every handle uses the same counter, including separate editor instances. */
11
31
  function createCommentIdAllocator() {
12
- let nextId = 1;
13
32
  return {
14
- next: () => {
15
- if (nextId > MAX_REVISION_ID) nextId = 1;
16
- return nextId++;
17
- },
18
- seedAbove(maxId) {
19
- if (!Number.isInteger(maxId) || maxId < 0 || maxId >= MAX_REVISION_ID) return;
20
- if (maxId >= nextId) nextId = maxId + 1;
21
- }
33
+ next: allocateCommentId,
34
+ seedAbove: seedCommentIdAbove
22
35
  };
23
36
  }
24
- /**
25
- * Seed an allocator above every comment/revision ID currently in the document
26
- * — comment objects (including replies, which carry no mark) plus
27
- * tracked-change `revisionId` marks. Because `seedAbove` only ever raises the
28
- * counter, this is safe to call on load (React) or before each allocation
29
- * (Vue): new IDs never collide with or reuse an existing one, and the comment
30
- * and revision ID spaces stay unified.
31
- */
37
+ /** Seed from comments, replies, and the document's conservative annotation maximum. */
32
38
  function seedCommentAllocator(allocator, comments, view) {
33
39
  let max = 0;
34
- for (const comment of comments ?? []) if (Number.isInteger(comment.id) && comment.id > max && comment.id <= MAX_REVISION_ID) max = comment.id;
35
- if (view) max = Math.max(max, maxAnnotationIdInDoc(view.state.doc));
40
+ for (const comment of comments ?? []) {
41
+ allocator.seedAbove(comment.id);
42
+ if (Number.isInteger(comment.id) && comment.id > max && comment.id <= MAX_REVISION_ID) max = comment.id;
43
+ }
44
+ if (view) {
45
+ view.state.doc.descendants((node) => {
46
+ for (const mark of node.marks) {
47
+ const id = mark.attrs["commentId"];
48
+ if (typeof id === "number") allocator.seedAbove(id);
49
+ }
50
+ if (node.type.name === "commentReference") {
51
+ const id = node.attrs["commentId"];
52
+ if (typeof id === "number") allocator.seedAbove(id);
53
+ }
54
+ if (node.type.name === "rangeAnchor") {
55
+ const attrs = readRangeAnchorAttrs(node);
56
+ if (attrs.ok && attrs.value.start.type === "commentRangeStart") allocator.seedAbove(attrs.value.start.id);
57
+ }
58
+ });
59
+ max = Math.max(max, maxAnnotationIdInDoc(view.state.doc));
60
+ }
36
61
  allocator.seedAbove(max);
37
62
  seedRevisionIdsAbove(max);
38
63
  }
39
64
  //#endregion
40
- export { PENDING_COMMENT_ID, createCommentIdAllocator, seedCommentAllocator };
65
+ export { CommentIdSpaceExhaustedError, PENDING_COMMENT_ID, allocateCommentId, createCommentIdAllocator, seedCommentAllocator, seedCommentIdAbove };
@@ -1,5 +1,97 @@
1
1
  import { createExtension } from "../create.js";
2
- import { history, redo, undo } from "prosemirror-history";
2
+ import { Plugin } from "prosemirror-state";
3
+ import { closeHistory, history, isHistoryTransaction, redo, undo } from "prosemirror-history";
4
+ //#region src/prosemirror/extensions/core/HistoryExtension.ts
5
+ /**
6
+ * History Extension — undo/redo via prosemirror-history
7
+ */
8
+ /**
9
+ * History adjacency is based on step maps, which differ for physical deletions
10
+ * and suggestion marks. Group continuous typing and same-direction deletions explicitly; each
11
+ * structural edit, paste, drop, or composition starts its own undo event.
12
+ * Capture DOM input before the shared input router or a keymap handles it.
13
+ */
14
+ const inputBoundaryPlugin = (newGroupDelay) => {
15
+ let previousKind;
16
+ let deletionGroup = 0;
17
+ let previousInputTime = 0;
18
+ let pendingDeletionGroup = null;
19
+ return new Plugin({
20
+ filterTransaction(tr) {
21
+ const pendingGroup = pendingDeletionGroup;
22
+ pendingDeletionGroup = null;
23
+ if (isHistoryTransaction(tr) || !tr.docChanged && tr.selectionSet) {
24
+ previousKind = void 0;
25
+ return true;
26
+ }
27
+ if (tr.docChanged && (previousKind === "deleteBackward" || previousKind === "deleteForward" || previousKind === "deletion")) {
28
+ if (pendingGroup !== null) tr.setMeta("composition", pendingGroup);
29
+ else if (!tr.getMeta("appendedTransaction")) previousKind = void 0;
30
+ }
31
+ return true;
32
+ },
33
+ view(view) {
34
+ const startInput = (kind) => {
35
+ const now = Date.now();
36
+ if (kind !== previousKind || kind === "structure" || kind === "paste" || kind === "drop" || now - previousInputTime > newGroupDelay) {
37
+ deletionGroup--;
38
+ view.dispatch(closeHistory(view.state.tr));
39
+ }
40
+ previousInputTime = now;
41
+ previousKind = kind;
42
+ pendingDeletionGroup = kind === "deleteBackward" || kind === "deleteForward" || kind === "deletion" ? deletionGroup : null;
43
+ };
44
+ const keydown = (event) => {
45
+ if (event.isComposing) return;
46
+ if (event.key === "Backspace" || event.key === "Delete") {
47
+ startInput(event.key === "Backspace" ? "deleteBackward" : "deleteForward");
48
+ return;
49
+ }
50
+ if (event.key === "Enter") {
51
+ startInput("structure");
52
+ return;
53
+ }
54
+ if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) startInput("typing");
55
+ };
56
+ const beforeinput = (event) => {
57
+ if (event.inputType === "insertCompositionText" || event.isComposing) {
58
+ startInput("composition");
59
+ return;
60
+ }
61
+ if (event.inputType === "insertText" || event.inputType === "insertReplacementText") {
62
+ startInput("typing");
63
+ return;
64
+ }
65
+ if (event.inputType.startsWith("delete")) if (event.inputType.endsWith("Backward")) startInput("deleteBackward");
66
+ else if (event.inputType.endsWith("Forward")) startInput("deleteForward");
67
+ else startInput("deletion");
68
+ else if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") startInput("structure");
69
+ };
70
+ const paste = () => startInput("paste");
71
+ const drop = () => startInput("drop");
72
+ const compositionstart = () => {
73
+ view.dispatch(closeHistory(view.state.tr));
74
+ previousKind = "composition";
75
+ };
76
+ view.dom.addEventListener("keydown", keydown, true);
77
+ view.dom.addEventListener("beforeinput", beforeinput, true);
78
+ view.dom.addEventListener("paste", paste, true);
79
+ view.dom.addEventListener("drop", drop, true);
80
+ view.dom.addEventListener("compositionstart", compositionstart, true);
81
+ return { destroy() {
82
+ view.dom.removeEventListener("keydown", keydown, true);
83
+ view.dom.removeEventListener("beforeinput", beforeinput, true);
84
+ view.dom.removeEventListener("paste", paste, true);
85
+ view.dom.removeEventListener("drop", drop, true);
86
+ view.dom.removeEventListener("compositionstart", compositionstart, true);
87
+ } };
88
+ }
89
+ });
90
+ };
91
+ const consumeHistoryShortcut = (command) => (state, dispatch, view) => {
92
+ command(state, dispatch, view);
93
+ return true;
94
+ };
3
95
  const HistoryExtension = createExtension({
4
96
  name: "history",
5
97
  defaultOptions: {
@@ -12,15 +104,15 @@ const HistoryExtension = createExtension({
12
104
  plugins: [history({
13
105
  depth: options.depth,
14
106
  newGroupDelay: options.newGroupDelay
15
- })],
107
+ }), inputBoundaryPlugin(options.newGroupDelay)],
16
108
  commands: {
17
109
  undo: () => undo,
18
110
  redo: () => redo
19
111
  },
20
112
  ...options.shortcuts === "editor" && { keyboardShortcuts: {
21
- "Mod-z": undo,
22
- "Mod-y": redo,
23
- "Mod-Shift-z": redo
113
+ "Mod-z": consumeHistoryShortcut(undo),
114
+ "Mod-y": consumeHistoryShortcut(redo),
115
+ "Mod-Shift-z": consumeHistoryShortcut(redo)
24
116
  } }
25
117
  };
26
118
  }
@@ -1,3 +1,4 @@
1
+ import { mintBookmarkId, reserveProseBookmarkIds } from "../../../docx/bookmarkIds.js";
1
2
  import { BUILT_IN_STYLE_NAME } from "../../../docx/builtInStyles.js";
2
3
  import { PROSE_PARAGRAPH_SOURCE_TOKEN_ATTR } from "../../../docx/paragraphPropertySource.js";
3
4
  import { isStyleSourcedParagraphNumbering } from "../../../internal/paragraphFormattingSerialization.js";
@@ -671,6 +672,7 @@ const ParagraphExtension = createNodeExtension({
671
672
  if (!dispatch) return true;
672
673
  const { schema: s } = state;
673
674
  const tr = state.tr;
675
+ reserveProseBookmarkIds(state.doc);
674
676
  const bookmarkEntries = [];
675
677
  for (const h of headings) {
676
678
  const bookmarkName = `_Toc${Math.floor(1e8 + Math.random() * 9e8)}`;
@@ -683,7 +685,7 @@ const ParagraphExtension = createNodeExtension({
683
685
  const paragraphNode = tr.doc.resolve(mappedPos).nodeAfter;
684
686
  if (paragraphNode && paragraphNode.type.name === "paragraph") {
685
687
  const newBookmarks = [...(paragraphNode.attrs["bookmarks"] ?? []).filter((b) => !b.name.startsWith("_Toc")), {
686
- id: Math.floor(Math.random() * 2147483647),
688
+ id: mintBookmarkId(),
687
689
  name: bookmarkName
688
690
  }];
689
691
  tr.setNodeMarkup(mappedPos, void 0, {
@@ -1,5 +1,6 @@
1
1
  import { mergeTextFormatting } from "../../../utils/textFormattingMerge.js";
2
2
  import { expectCharacterStyleMarkAttrs, expectRunFormattingOverrideMarkAttrs } from "../../attrs/index.js";
3
+ import { clearIndentOnBackspace } from "../../commands/clearParagraphIndent.js";
3
4
  import { keepSectionBreaksOnSurvivingMarks } from "../../commands/sectionBreak.js";
4
5
  import { getDocumentStyleResolver } from "../../plugins/documentStyleState.js";
5
6
  import { RUN_FORMATTING_MARK_NAMES } from "../../runFormattingMarkNames.js";
@@ -8,6 +9,7 @@ import { paragraphAttrsFromResolvedStyle } from "../../styles/resolvedStyleAttrs
8
9
  import { createExtension } from "../create.js";
9
10
  import { marksToTextFormatting, textFormattingToMarks } from "../marks/markUtils.js";
10
11
  import { Priority } from "../types.js";
12
+ import { EditorState, TextSelection } from "prosemirror-state";
11
13
  import { baseKeymap, deleteSelection, joinBackward, joinForward, selectAll, selectParentNode, splitBlock } from "prosemirror-commands";
12
14
  //#region src/prosemirror/extensions/features/BaseKeymapExtension.ts
13
15
  /**
@@ -22,31 +24,6 @@ function chainCommands(...commands) {
22
24
  };
23
25
  }
24
26
  /**
25
- * Backspace at the start of a paragraph clears first-line indent / hanging indent
26
- * before joining with the previous paragraph (matches Word behavior).
27
- */
28
- const clearIndentOnBackspace = (state, dispatch) => {
29
- const { $cursor } = state.selection;
30
- if (!$cursor) return false;
31
- if ($cursor.parentOffset !== 0) return false;
32
- if ($cursor.parent.type.name !== "paragraph") return false;
33
- const attrs = $cursor.parent.attrs;
34
- const hasFirstLine = attrs["indentFirstLine"] !== null && attrs["indentFirstLine"] > 0;
35
- const hasHanging = !!attrs["hangingIndent"];
36
- const hasIndentLeft = attrs["indentLeft"] !== null && attrs["indentLeft"] > 0;
37
- if (!hasFirstLine && !hasHanging && !hasIndentLeft) return false;
38
- if (dispatch) {
39
- const pos = $cursor.before();
40
- dispatch(state.tr.setNodeMarkup(pos, void 0, {
41
- ...attrs,
42
- indentFirstLine: null,
43
- hangingIndent: null,
44
- indentLeft: null
45
- }).scrollIntoView());
46
- }
47
- return true;
48
- };
49
- /**
50
27
  * Custom Enter handler: splits the block, inherits style-related attrs,
51
28
  * clears paragraph borders, and preserves run formatting on the new paragraph.
52
29
  *
@@ -117,16 +94,33 @@ function applyNextParagraphStyle(tr, sourcePara, newPara, resolver) {
117
94
  tr.setStoredMarks(resolved.runFormatting ? textFormattingToMarks(resolved.runFormatting, tr.doc.type.schema) : []);
118
95
  return true;
119
96
  }
97
+ const blockBoundaryDeletion = (state) => {
98
+ if (!(state.selection instanceof TextSelection) || state.selection.empty) return null;
99
+ const tr = state.tr.deleteSelection();
100
+ return tr.doc.resolve(tr.mapping.map(state.selection.from)).parent.isTextblock ? null : tr;
101
+ };
120
102
  const splitBlockClearBorders = (state, dispatch, view) => {
121
- const { $from: preSplitFrom } = state.selection;
103
+ const deletion = blockBoundaryDeletion(state);
104
+ const splitState = deletion ? EditorState.create({
105
+ doc: deletion.doc,
106
+ selection: deletion.selection,
107
+ storedMarks: deletion.storedMarks
108
+ }) : state;
109
+ const { $from: preSplitFrom } = splitState.selection;
122
110
  const sourcePara = preSplitFrom.parent.type.name === "paragraph" ? preSplitFrom.parent : null;
123
- const caretFormattingMarks = (state.storedMarks || preSplitFrom.marks()).filter((mark) => RUN_FORMATTING_MARK_NAMES.has(mark.type.name));
111
+ const caretFormattingMarks = (splitState.storedMarks || preSplitFrom.marks()).filter((mark) => RUN_FORMATTING_MARK_NAMES.has(mark.type.name));
124
112
  const resolver = getDocumentStyleResolver(state);
125
113
  const splitResult = { tr: null };
126
- if (!splitBlock(state, dispatch ? (tr) => {
114
+ if (!splitBlock(splitState, dispatch ? (tr) => {
127
115
  splitResult.tr = tr;
128
116
  } : void 0, view)) return false;
129
117
  if (dispatch && splitResult.tr !== null) {
118
+ if (deletion) {
119
+ for (const step of splitResult.tr.steps) deletion.step(step);
120
+ deletion.setSelection(splitResult.tr.selection.getBookmark().resolve(deletion.doc));
121
+ deletion.setStoredMarks(splitResult.tr.storedMarks);
122
+ splitResult.tr = deletion;
123
+ }
130
124
  const tr = splitResult.tr;
131
125
  const { $from } = tr.selection;
132
126
  const newPara = $from.parent;
@@ -268,9 +268,9 @@ function createShapeElement(type, w, h, adjustments) {
268
268
  case "straightConnector1": {
269
269
  const el = document.createElementNS(SVG_NS, "line");
270
270
  setNum(el, "x1", 0);
271
- setNum(el, "y1", h / 2);
271
+ setNum(el, "y1", 0);
272
272
  setNum(el, "x2", w);
273
- setNum(el, "y2", h / 2);
273
+ setNum(el, "y2", h);
274
274
  return el;
275
275
  }
276
276
  case "rightBrace": {
@@ -537,9 +537,11 @@ const ShapeExtension = createNodeExtension({
537
537
  const strokeWidth = finiteNumber(attrs.outlineWidth) ?? 1;
538
538
  const svg = document.createElementNS(SVG_NS, "svg");
539
539
  svg.setAttribute("xmlns", SVG_NS);
540
- setNum(svg, "width", w);
541
- setNum(svg, "height", h);
542
- svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
540
+ const viewportWidth = w === 0 ? 1 : w;
541
+ const viewportHeight = h === 0 ? 1 : h;
542
+ setNum(svg, "width", viewportWidth);
543
+ setNum(svg, "height", viewportHeight);
544
+ svg.setAttribute("viewBox", `0 0 ${viewportWidth} ${viewportHeight}`);
543
545
  let gradient = null;
544
546
  let fillAttr;
545
547
  if (attrs.fillType === "gradient" && attrs.gradientStops) {
@@ -548,7 +550,7 @@ const ShapeExtension = createNodeExtension({
548
550
  fillAttr = gradient ? `url(#${gradId})` : safeFillColor;
549
551
  } else fillAttr = attrs.fillType === "none" ? "none" : safeFillColor;
550
552
  const strokeAttr = attrs.outlineStyle === "none" ? "none" : safeStrokeColor;
551
- svg.setAttribute("style", `fill:${fillAttr};stroke:${strokeAttr};stroke-width:${strokeWidth}`);
553
+ svg.setAttribute("style", `fill:${fillAttr};stroke:${strokeAttr};stroke-width:${strokeWidth};overflow:visible`);
552
554
  if (gradient) {
553
555
  const defs = document.createElementNS(SVG_NS, "defs");
554
556
  defs.append(gradient);