@stll/folio-core 0.28.1 → 0.29.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.
Files changed (33) hide show
  1. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +1 -2
  2. package/dist/layout-bridge/convert/toFlowBlocks.js +61 -95
  3. package/dist/markdown/renderParagraph.js +7 -2
  4. package/dist/prosemirror/attrs/index.js +2 -1
  5. package/dist/prosemirror/bookmarkBoundaryAttrs.d.ts +8 -0
  6. package/dist/prosemirror/bookmarkBoundaryAttrs.js +66 -0
  7. package/dist/prosemirror/conversion/fromProseDoc.js +170 -52
  8. package/dist/prosemirror/conversion/toProseDoc.js +169 -61
  9. package/dist/prosemirror/extensions/StarterKit.js +11 -3
  10. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +8 -4
  11. package/dist/prosemirror/extensions/features/PasteCleanupExtension.d.ts +4 -1
  12. package/dist/prosemirror/extensions/features/PasteCleanupExtension.js +9 -5
  13. package/dist/prosemirror/extensions/features/pasteCleanup.d.ts +10 -23
  14. package/dist/prosemirror/extensions/features/pasteCleanup.js +77 -22
  15. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts +9 -0
  16. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +67 -0
  17. package/dist/prosemirror/extensions/nodes/FieldExtension.d.ts +10 -4
  18. package/dist/prosemirror/extensions/nodes/FieldExtension.js +77 -59
  19. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.d.ts +4 -1
  20. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +4 -3
  21. package/dist/prosemirror/listMarker.d.ts +58 -0
  22. package/dist/prosemirror/listMarker.js +185 -0
  23. package/dist/prosemirror/numberedRefFields.d.ts +24 -0
  24. package/dist/prosemirror/numberedRefFields.js +276 -0
  25. package/dist/prosemirror/paraText.js +56 -17
  26. package/dist/prosemirror/plugins/anonymizationDecorations.js +1 -1
  27. package/dist/prosemirror/plugins/pmTextScan.d.ts +3 -1
  28. package/dist/prosemirror/plugins/pmTextScan.js +28 -7
  29. package/dist/prosemirror/plugins/templateDirectives.js +2 -2
  30. package/dist/prosemirror/schema/index.d.ts +2 -2
  31. package/dist/prosemirror/schema/nodes.d.ts +13 -1
  32. package/dist/prosemirror/validation.js +93 -0
  33. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { mergeTextFormatting } from "../../utils/textFormattingMerge.js";
6
6
  import { emuToPixels } from "../../utils/units.js";
7
7
  import { setAutospacingBaseValue } from "../autospacingBase.js";
8
8
  import { buildRunFormattingOverrideAttrs } from "../extensions/marks/RunFormattingOverrideExtension.js";
9
+ import { stampNumberedRefFieldBaselines } from "../numberedRefFields.js";
9
10
  import { directionFromBidi } from "../paragraphDirection.js";
10
11
  import { schema } from "../schema/index.js";
11
12
  import { cascadeStyleTextFormatting } from "../styles/styleToggleCascade.js";
@@ -36,6 +37,56 @@ const createHyperlinkInstanceIndexAllocator = () => {
36
37
  return () => index++;
37
38
  };
38
39
  /**
40
+ * Find bookmark pairs across every editable inline wrapper in a document story.
41
+ * Each endpoint is converted at its own structural position, so a range may
42
+ * start outside a hyperlink and end inside it (or the inverse).
43
+ */
44
+ const collectPairedBookmarkIds = (blocks) => {
45
+ const counts = /* @__PURE__ */ new Map();
46
+ let position = 0;
47
+ const countBoundary = (id, type) => {
48
+ const count = counts.get(id) ?? {
49
+ starts: 0,
50
+ ends: 0
51
+ };
52
+ if (type === "start") {
53
+ count.starts += 1;
54
+ count.firstStart ??= position;
55
+ } else {
56
+ count.ends += 1;
57
+ count.firstEnd ??= position;
58
+ }
59
+ position += 1;
60
+ counts.set(id, count);
61
+ };
62
+ const visitRun = (run) => {
63
+ for (const content of run.content) if (content.type === "shape" && content.shape.textBody) visitBlocks(content.shape.textBody.content);
64
+ };
65
+ const visitHyperlink = (hyperlink) => {
66
+ for (const child of hyperlink.children) if (child.type === "bookmarkStart") countBoundary(child.id, "start");
67
+ else if (child.type === "bookmarkEnd") countBoundary(child.id, "end");
68
+ else visitRun(child);
69
+ };
70
+ const visitParagraphContent = (content) => {
71
+ if (content.type === "bookmarkStart") countBoundary(content.id, "start");
72
+ else if (content.type === "bookmarkEnd") countBoundary(content.id, "end");
73
+ else if (content.type === "run") visitRun(content);
74
+ else if (content.type === "hyperlink") visitHyperlink(content);
75
+ else if (content.type === "simpleField") for (const child of content.content) if (child.type === "hyperlink") visitHyperlink(child);
76
+ else visitRun(child);
77
+ else if (content.type === "complexField") for (const run of [...content.fieldCode ?? [], ...content.fieldResult]) visitRun(run);
78
+ else if (content.type === "inlineSdt") for (const child of content.content) visitParagraphContent(child);
79
+ else if (content.type === "insertion" || content.type === "deletion" || content.type === "moveFrom" || content.type === "moveTo") for (const child of content.content) visitParagraphContent(child);
80
+ };
81
+ const visitBlocks = (nestedBlocks) => {
82
+ for (const block of nestedBlocks) if (block.type === "paragraph") for (const content of block.content) visitParagraphContent(content);
83
+ else if (block.type === "table") for (const row of block.rows) for (const cell of row.cells) visitBlocks(cell.content);
84
+ else visitBlocks(block.content);
85
+ };
86
+ visitBlocks(blocks);
87
+ return new Set([...counts].flatMap(([id, count]) => count.starts === 1 && count.ends === 1 && count.firstStart !== void 0 && count.firstEnd !== void 0 && count.firstStart < count.firstEnd ? [id] : []));
88
+ };
89
+ /**
39
90
  * Convert a Document to a ProseMirror document
40
91
  *
41
92
  * @param document - The Document to convert
@@ -50,7 +101,8 @@ function toProseDoc(document, options) {
50
101
  const conversionContext = {
51
102
  theme,
52
103
  nextTextBoxGroupId,
53
- nextHyperlinkInstanceIndex: createHyperlinkInstanceIndexAllocator()
104
+ nextHyperlinkInstanceIndex: createHyperlinkInstanceIndexAllocator(),
105
+ pairedBookmarkIds: collectPairedBookmarkIds(paragraphs)
54
106
  };
55
107
  const convertBodyBlocks = (blocks) => {
56
108
  const out = [];
@@ -85,7 +137,7 @@ function toProseDoc(document, options) {
85
137
  };
86
138
  nodes.push(...convertBodyBlocks(paragraphs));
87
139
  if (nodes.length === 0) nodes.push(schema.node("paragraph", {}, []));
88
- const pmDoc = schema.node("doc", null, nodes);
140
+ const pmDoc = stampNumberedRefFieldBaselines(schema.node("doc", null, nodes));
89
141
  assertValidProseMirrorDocument(pmDoc, "Document conversion produced an invalid ProseMirror document");
90
142
  return pmDoc;
91
143
  }
@@ -126,7 +178,7 @@ function convertBlockSdt(blockSdt, convertBlocks) {
126
178
  * Resolves style-based text formatting and passes it to runs so that
127
179
  * paragraph styles (like Heading1) apply their font size, color, etc.
128
180
  */
129
- function convertParagraph(paragraph, styleResolver, nextHyperlinkInstanceIndex, activeCommentIds, extraRunFormatting, tableParagraphOverlay, textBoxAnchors) {
181
+ function convertParagraph(paragraph, styleResolver, nextHyperlinkInstanceIndex, pairedBookmarkIds, activeCommentIds, extraRunFormatting, tableParagraphOverlay, textBoxAnchors) {
130
182
  const attrs = paragraphFormattingToAttrs(paragraph, styleResolver, tableParagraphOverlay);
131
183
  const isTocParagraph = attrs._tableOfContentsLevel !== void 0;
132
184
  const inlineNodes = [];
@@ -209,42 +261,56 @@ function convertParagraph(paragraph, styleResolver, nextHyperlinkInstanceIndex,
209
261
  const emitTrackedChange = (change, markType, moveKind) => {
210
262
  emitInlineNodes(convertTrackedChange(change, markType, nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, moveKind, textBoxAnchors));
211
263
  };
212
- for (const content of paragraph.content) {
213
- if (content.type === "commentRangeStart") commentIds.add(content.id);
214
- else if (content.type === "commentRangeEnd") commentIds.delete(content.id);
215
- else if (content.type === "commentReference") anchorPointComment(inlineNodes, content.id);
216
- else if (content.type === "run") emitInlineNodes(convertRun(content, getInheritedRunFormatting(content.formatting), styleResolver, textBoxAnchors));
217
- else if (content.type === "hyperlink") {
218
- const linkNodes = convertHyperlink(content, {
219
- getInheritedRunFormatting,
220
- styleResolver,
221
- hyperlinkIndex: nextHyperlinkInstanceIndex(),
222
- textBoxAnchors
223
- });
224
- if (linkNodes.length === 0) {
225
- emptyHyperlinks ??= [];
226
- emptyHyperlinks.push({
227
- offset: inlineOffset,
228
- ...content.href !== void 0 ? { href: content.href } : {},
229
- ...content.anchor !== void 0 ? { anchor: content.anchor } : {},
230
- ...content.tooltip !== void 0 ? { tooltip: content.tooltip } : {},
231
- ...content.rId !== void 0 ? { rId: content.rId } : {}
232
- });
233
- continue;
234
- }
235
- emitInlineNodes(linkNodes);
236
- } else if (content.type === "simpleField" || content.type === "complexField") emitInlineNode(convertField(content, getInheritedRunFormatting, styleResolver));
237
- else if (content.type === "inlineSdt") emitInlineNode(convertInlineSdt(content, nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, textBoxAnchors));
238
- else if (content.type === "insertion" || content.type === "moveTo") emitTrackedChange(content, "insertion", content.type === "moveTo" ? "moveTo" : null);
239
- else if (content.type === "deletion" || content.type === "moveFrom") emitTrackedChange(content, "deletion", content.type === "moveFrom" ? "moveFrom" : null);
240
- else if (content.type === "mathEquation") emitInlineNode(convertMathEquation(content));
241
- if (content.type === "bookmarkStart") {
242
- if (!bookmarksArr) bookmarksArr = [];
243
- bookmarksArr.push({
244
- id: content.id,
245
- name: content.name
264
+ for (const content of paragraph.content) if (content.type === "commentRangeStart") commentIds.add(content.id);
265
+ else if (content.type === "commentRangeEnd") commentIds.delete(content.id);
266
+ else if (content.type === "commentReference") anchorPointComment(inlineNodes, content.id);
267
+ else if (content.type === "run") emitInlineNodes(convertRun(content, getInheritedRunFormatting(content.formatting), styleResolver, textBoxAnchors));
268
+ else if (content.type === "hyperlink") {
269
+ const linkNodes = convertHyperlink(content, {
270
+ getInheritedRunFormatting,
271
+ styleResolver,
272
+ hyperlinkIndex: nextHyperlinkInstanceIndex(),
273
+ textBoxAnchors
274
+ });
275
+ if (linkNodes.length === 0) {
276
+ emptyHyperlinks ??= [];
277
+ emptyHyperlinks.push({
278
+ offset: inlineOffset,
279
+ ...content.href !== void 0 ? { href: content.href } : {},
280
+ ...content.anchor !== void 0 ? { anchor: content.anchor } : {},
281
+ ...content.tooltip !== void 0 ? { tooltip: content.tooltip } : {},
282
+ ...content.rId !== void 0 ? { rId: content.rId } : {}
246
283
  });
284
+ continue;
247
285
  }
286
+ emitInlineNodes(linkNodes);
287
+ } else if (content.type === "simpleField" || content.type === "complexField") emitInlineNode(convertField(content, {
288
+ getInheritedRunFormatting,
289
+ styleResolver,
290
+ nextHyperlinkInstanceIndex,
291
+ textBoxAnchors
292
+ }));
293
+ else if (content.type === "inlineSdt") emitInlineNode(convertInlineSdt(content, nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, textBoxAnchors));
294
+ else if (content.type === "insertion" || content.type === "moveTo") emitTrackedChange(content, "insertion", content.type === "moveTo" ? "moveTo" : null);
295
+ else if (content.type === "deletion" || content.type === "moveFrom") emitTrackedChange(content, "deletion", content.type === "moveFrom" ? "moveFrom" : null);
296
+ else if (content.type === "mathEquation") emitInlineNode(convertMathEquation(content));
297
+ else if (content.type === "bookmarkStart" && pairedBookmarkIds.has(content.id)) emitInlineNode(schema.node("bookmarkBoundary", {
298
+ type: "start",
299
+ id: content.id,
300
+ name: content.name,
301
+ colFirst: content.colFirst,
302
+ colLast: content.colLast
303
+ }));
304
+ else if (content.type === "bookmarkEnd" && pairedBookmarkIds.has(content.id)) emitInlineNode(schema.node("bookmarkBoundary", {
305
+ type: "end",
306
+ id: content.id
307
+ }));
308
+ else if (content.type === "bookmarkStart") {
309
+ if (!bookmarksArr) bookmarksArr = [];
310
+ bookmarksArr.push({
311
+ id: content.id,
312
+ name: content.name
313
+ });
248
314
  }
249
315
  if (bookmarksArr) attrs.bookmarks = bookmarksArr;
250
316
  if (emptyHyperlinks) attrs._emptyHyperlinks = emptyHyperlinks;
@@ -313,7 +379,7 @@ function convertTrackedChange(change, markType, nextHyperlinkInstanceIndex, getI
313
379
  });
314
380
  }
315
381
  function canCarryTrackedRunMark(node, markType) {
316
- return node.isText || node.isInline && node.type.allowsMarkType(markType) && (node.type.name === "image" || node.type.name === "shape" || node.type.name === "hardBreak" || node.type.name === "tab" || node.type.name === "symbol" || node.type.name === "textBoxAnchor");
382
+ return node.isText || node.isInline && node.type.allowsMarkType(markType) && (node.type.name === "image" || node.type.name === "shape" || node.type.name === "hardBreak" || node.type.name === "tab" || node.type.name === "symbol" || node.type.name === "bookmarkBoundary" || node.type.name === "textBoxAnchor");
317
383
  }
318
384
  /**
319
385
  * Convert ParagraphFormatting to ProseMirror paragraph attrs
@@ -1011,7 +1077,8 @@ function standaloneTableCellToProseMirror(cell, nodeType) {
1011
1077
  context: {
1012
1078
  theme: null,
1013
1079
  nextTextBoxGroupId: createTextBoxGroupIdFactory(),
1014
- nextHyperlinkInstanceIndex: createHyperlinkInstanceIndexAllocator()
1080
+ nextHyperlinkInstanceIndex: createHyperlinkInstanceIndexAllocator(),
1081
+ pairedBookmarkIds: collectPairedBookmarkIds(cell.content)
1015
1082
  },
1016
1083
  isHeader: nodeType === "tableHeader",
1017
1084
  gridWidthPercent: void 0,
@@ -1024,31 +1091,47 @@ function standaloneTableCellToProseMirror(cell, nodeType) {
1024
1091
  defaultCellMargins: void 0
1025
1092
  });
1026
1093
  }
1027
- /**
1028
- * Convert a SimpleField or ComplexField to a ProseMirror field node.
1029
- * Preserves run formatting (bold, fontSize, color, etc.) as PM marks.
1030
- * Accepts a run formatting resolver so fields inherit paragraph-level
1031
- * formatting the same way regular text runs do.
1032
- */
1033
- function convertField(field, getInheritedRunFormatting, styleResolver) {
1094
+ function convertField(field, { getInheritedRunFormatting, styleResolver, nextHyperlinkInstanceIndex, textBoxAnchors }) {
1034
1095
  let displayText = "";
1035
1096
  let fieldFormatting;
1036
- const runs = field.type === "simpleField" ? field.content : field.fieldResult;
1037
- for (const r of runs) if (r.type === "run") {
1038
- for (const c of r.content) if (c.type === "text") displayText += c.text;
1039
- if (!fieldFormatting && r.formatting) fieldFormatting = r.formatting;
1097
+ const inlineNodes = [];
1098
+ const hasStructuredSourceContent = field.type === "simpleField" && field.content.some((content) => content.type === "hyperlink");
1099
+ const appendRun = (run) => {
1100
+ for (const content of run.content) if (content.type === "text") displayText += content.text;
1101
+ fieldFormatting ??= run.formatting;
1102
+ if (!hasStructuredSourceContent) return;
1103
+ inlineNodes.push(...convertRun(run, getInheritedRunFormatting(run.formatting, field.fieldType), styleResolver, textBoxAnchors));
1104
+ };
1105
+ if (field.type === "simpleField") for (const content of field.content) {
1106
+ if (content.type === "run") {
1107
+ appendRun(content);
1108
+ continue;
1109
+ }
1110
+ for (const child of content.children) if (child.type === "run") {
1111
+ for (const runContent of child.content) if (runContent.type === "text") displayText += runContent.text;
1112
+ fieldFormatting ??= child.formatting;
1113
+ }
1114
+ inlineNodes.push(...convertHyperlink(content, {
1115
+ getInheritedRunFormatting: (formatting) => getInheritedRunFormatting(formatting, field.fieldType),
1116
+ styleResolver,
1117
+ hyperlinkIndex: nextHyperlinkInstanceIndex(),
1118
+ textBoxAnchors
1119
+ }));
1040
1120
  }
1121
+ else for (const run of field.fieldResult) appendRun(run);
1041
1122
  if (!fieldFormatting && field.type === "complexField" && field.fieldResult.length === 0) fieldFormatting = field.formatting;
1042
1123
  const inheritedFormatting = getInheritedRunFormatting(fieldFormatting, field.fieldType);
1043
1124
  const { marks } = buildRunMarks(fieldFormatting, inheritedFormatting, styleResolver);
1044
- return schema.node("field", {
1125
+ const hasConvertedHyperlinkContent = inlineNodes.some((node) => node.marks.some((mark) => mark.type.name === "hyperlink"));
1126
+ const createStructuredField = hasStructuredSourceContent && hasConvertedHyperlinkContent;
1127
+ return schema.node(createStructuredField ? "structuredField" : "field", {
1045
1128
  fieldType: field.fieldType,
1046
1129
  instruction: field.instruction,
1047
1130
  displayText,
1048
1131
  fieldKind: field.type === "simpleField" ? "simple" : "complex",
1049
1132
  fldLock: field.fldLock ?? false,
1050
1133
  dirty: field.dirty ?? false
1051
- }, void 0, marks);
1134
+ }, createStructuredField ? inlineNodes : void 0, marks);
1052
1135
  }
1053
1136
  /**
1054
1137
  * Convert a MathEquation to a ProseMirror math node.
@@ -1078,7 +1161,12 @@ function convertInlineSdt(sdt, nextHyperlinkInstanceIndex, getInheritedRunFormat
1078
1161
  });
1079
1162
  inlineNodes.push(...linkNodes);
1080
1163
  } else if (content.type === "simpleField" || content.type === "complexField") {
1081
- const fieldNode = convertField(content, getInheritedRunFormatting, styleResolver);
1164
+ const fieldNode = convertField(content, {
1165
+ getInheritedRunFormatting,
1166
+ styleResolver,
1167
+ nextHyperlinkInstanceIndex,
1168
+ textBoxAnchors
1169
+ });
1082
1170
  if (fieldNode) inlineNodes.push(fieldNode);
1083
1171
  } else if (content.type === "inlineSdt") {
1084
1172
  const nestedSdt = convertInlineSdt(content, nextHyperlinkInstanceIndex, getInheritedRunFormatting, styleResolver, textBoxAnchors);
@@ -1338,11 +1426,30 @@ function convertHyperlink(hyperlink, { getInheritedRunFormatting, styleResolver,
1338
1426
  rId: hyperlink.rId,
1339
1427
  _docxHyperlinkIndex: hyperlinkIndex
1340
1428
  });
1341
- for (const child of hyperlink.children) if (child.type === "run") {
1342
- const inheritedFormatting = getInheritedRunFormatting(child.formatting);
1343
- const { marks: runMarks, mergedFormatting } = buildRunMarks(child.formatting, inheritedFormatting, styleResolver);
1344
- const allMarks = [...runMarks, linkMark];
1345
- for (const content of child.content) nodes.push(...convertRunContent(content, allMarks, mergedFormatting, textBoxAnchors));
1429
+ for (const child of hyperlink.children) {
1430
+ if (child.type === "bookmarkStart") {
1431
+ nodes.push(schema.node("bookmarkBoundary", {
1432
+ type: "start",
1433
+ id: child.id,
1434
+ name: child.name,
1435
+ colFirst: child.colFirst,
1436
+ colLast: child.colLast
1437
+ }, void 0, [linkMark]));
1438
+ continue;
1439
+ }
1440
+ if (child.type === "bookmarkEnd") {
1441
+ nodes.push(schema.node("bookmarkBoundary", {
1442
+ type: "end",
1443
+ id: child.id
1444
+ }, void 0, [linkMark]));
1445
+ continue;
1446
+ }
1447
+ if (child.type === "run") {
1448
+ const inheritedFormatting = getInheritedRunFormatting(child.formatting);
1449
+ const { marks: runMarks, mergedFormatting } = buildRunMarks(child.formatting, inheritedFormatting, styleResolver);
1450
+ const allMarks = [...runMarks, linkMark];
1451
+ for (const content of child.content) nodes.push(...convertRunContent(content, allMarks, mergedFormatting, textBoxAnchors));
1452
+ }
1346
1453
  }
1347
1454
  return nodes;
1348
1455
  }
@@ -1511,7 +1618,7 @@ function convertShape(shape) {
1511
1618
  }
1512
1619
  function convertParagraphWithTextBoxes(block, styleResolver, { textBoxGroupId, context, extraRunFormatting, tableParagraphOverlay }) {
1513
1620
  const { textBoxes, textBoxAnchors } = extractTextBoxesFromParagraph(block, textBoxGroupId);
1514
- const pmParagraph = convertParagraph(block, styleResolver, context.nextHyperlinkInstanceIndex, void 0, extraRunFormatting, tableParagraphOverlay, textBoxAnchors);
1621
+ const pmParagraph = convertParagraph(block, styleResolver, context.nextHyperlinkInstanceIndex, context.pairedBookmarkIds, void 0, extraRunFormatting, tableParagraphOverlay, textBoxAnchors);
1515
1622
  const nodes = [];
1516
1623
  const isEmptyAfterExtraction = textBoxes.length > 0 && !hasContentBesidesTextBoxAnchors(pmParagraph);
1517
1624
  const keepWrapperParagraph = isEmptyAfterExtraction && hasParagraphBoundaryPayload(block, pmParagraph);
@@ -1773,7 +1880,8 @@ function headerFooterToProseDoc(content, options) {
1773
1880
  const conversionContext = {
1774
1881
  theme,
1775
1882
  nextTextBoxGroupId,
1776
- nextHyperlinkInstanceIndex: createHyperlinkInstanceIndexAllocator()
1883
+ nextHyperlinkInstanceIndex: createHyperlinkInstanceIndexAllocator(),
1884
+ pairedBookmarkIds: collectPairedBookmarkIds(content)
1777
1885
  };
1778
1886
  const convertBlocks = (blocks) => {
1779
1887
  const out = [];
@@ -1787,7 +1895,7 @@ function headerFooterToProseDoc(content, options) {
1787
1895
  };
1788
1896
  nodes.push(...convertBlocks(content));
1789
1897
  if (nodes.length === 0) nodes.push(schema.node("paragraph", {}, []));
1790
- const pmDoc = schema.node("doc", null, nodes);
1898
+ const pmDoc = stampNumberedRefFieldBaselines(schema.node("doc", null, nodes));
1791
1899
  assertValidProseMirrorDocument(pmDoc, "Header/footer conversion produced an invalid ProseMirror document");
1792
1900
  return pmDoc;
1793
1901
  }
@@ -43,7 +43,8 @@ import { EmbossExtension, EmphasisMarkExtension, ImprintExtension, TextOutlineEx
43
43
  import { DeletionExtension, InsertionExtension, RunPropertyChangeExtension } from "./marks/TrackedChangeExtensions.js";
44
44
  import { UnderlineExtension } from "./marks/UnderlineExtension.js";
45
45
  import { BlockSdtExtension } from "./nodes/BlockSdtExtension.js";
46
- import { FieldExtension } from "./nodes/FieldExtension.js";
46
+ import { BookmarkBoundaryExtension } from "./nodes/BookmarkBoundaryExtension.js";
47
+ import { FieldExtension, StructuredFieldExtension } from "./nodes/FieldExtension.js";
47
48
  import { HardBreakExtension } from "./nodes/HardBreakExtension.js";
48
49
  import { HorizontalRuleExtension } from "./nodes/HorizontalRuleExtension.js";
49
50
  import { ImageExtension } from "./nodes/ImageExtension.js";
@@ -64,6 +65,11 @@ import { TextBoxExtension } from "./nodes/TextBoxExtension.js";
64
65
  function createStarterKit(options = {}) {
65
66
  const disabled = options.disable ? new Set(options.disable) : /* @__PURE__ */ new Set();
66
67
  const extensions = [];
68
+ let internalClipboardToken;
69
+ const getInternalClipboardToken = () => {
70
+ internalClipboardToken ??= globalThis.crypto.randomUUID();
71
+ return internalClipboardToken;
72
+ };
67
73
  function add(name, ext) {
68
74
  if (!disabled.has(name)) extensions.push(ext);
69
75
  }
@@ -105,12 +111,13 @@ function createStarterKit(options = {}) {
105
111
  add("insertion", InsertionExtension());
106
112
  add("deletion", DeletionExtension());
107
113
  add("runPropertyChange", RunPropertyChangeExtension());
114
+ add("bookmarkBoundary", BookmarkBoundaryExtension({ getInternalClipboardToken }));
108
115
  add("hardBreak", HardBreakExtension());
109
116
  add("tab", TabExtension());
110
117
  add("symbol", SymbolExtension());
111
118
  add("image", ImageExtension());
112
119
  add("textBox", TextBoxExtension());
113
- add("textBoxAnchor", TextBoxAnchorExtension());
120
+ add("textBoxAnchor", TextBoxAnchorExtension({ getInternalClipboardToken }));
114
121
  add("shape", ShapeExtension());
115
122
  add("imageDrag", ImageDragExtension());
116
123
  add("imagePaste", ImagePasteExtension());
@@ -120,11 +127,12 @@ function createStarterKit(options = {}) {
120
127
  add("pageBreak", PageBreakExtension());
121
128
  add("renderedPageBreak", RenderedPageBreakExtension());
122
129
  add("field", FieldExtension());
130
+ add("field", StructuredFieldExtension({ getInternalClipboardToken }));
123
131
  add("sdt", SdtExtension());
124
132
  add("blockSdt", BlockSdtExtension());
125
133
  add("math", MathExtension());
126
134
  if (!disabled.has("table")) extensions.push(...createTableExtensions());
127
- add("pasteCleanup", PasteCleanupExtension());
135
+ add("pasteCleanup", PasteCleanupExtension({ getInternalClipboardToken }));
128
136
  add("pasteStyleInliner", PasteStyleInlinerExtension());
129
137
  add("list", ListExtension());
130
138
  add("baseKeymap", BaseKeymapExtension());
@@ -9,12 +9,16 @@ const autoBidiDetectionKey = new PluginKey("autoBidiDetection");
9
9
  const paragraphDirectionalText = (node) => {
10
10
  let text = "";
11
11
  node.descendants((child) => {
12
+ if (child.marks.some((mark) => mark.type.name === "deletion")) return false;
12
13
  if (child.isText) {
13
- if (!child.marks.some((mark) => mark.type.name === "deletion")) text += child.text ?? "";
14
- } else if (child.type.name === "field") {
15
- const display = child.attrs["displayText"];
16
- if (typeof display === "string") text += display;
14
+ text += child.text ?? "";
15
+ return false;
17
16
  }
17
+ if (child.isLeaf && child.textContent) {
18
+ text += child.textContent;
19
+ return false;
20
+ }
21
+ return true;
18
22
  });
19
23
  return text;
20
24
  };
@@ -14,6 +14,9 @@ import { Extension } from "../types.js";
14
14
  * 2. `Mod-Alt-v` — "paste without formatting", inserting clipboard text with
15
15
  * the source formatting stripped (see {@link pasteWithoutFormatting}).
16
16
  */
17
- declare const PasteCleanupExtension: (options?: Partial<Record<string, unknown>> | undefined) => Extension;
17
+ type PasteCleanupOptions = {
18
+ getInternalClipboardToken?: () => string;
19
+ };
20
+ declare const PasteCleanupExtension: (options?: Partial<PasteCleanupOptions> | undefined) => Extension;
18
21
  //#endregion
19
22
  export { PasteCleanupExtension };
@@ -1,7 +1,7 @@
1
1
  import { pasteWithoutFormatting } from "../../commands/pastePlainText.js";
2
2
  import { createExtension } from "../create.js";
3
3
  import { Priority } from "../types.js";
4
- import { cleanPastedHtml } from "./pasteCleanup.js";
4
+ import { cleanPastedHtml, removeUnpairedBookmarkBoundaries } from "./pasteCleanup.js";
5
5
  import { Plugin } from "prosemirror-state";
6
6
  //#region src/prosemirror/extensions/features/PasteCleanupExtension.ts
7
7
  /**
@@ -21,11 +21,15 @@ import { Plugin } from "prosemirror-state";
21
21
  const PasteCleanupExtension = createExtension({
22
22
  name: "pasteCleanup",
23
23
  priority: Priority.Highest,
24
- onSchemaReady() {
24
+ onSchemaReady(_context, options) {
25
25
  return {
26
- plugins: [new Plugin({ props: { transformPastedHTML(html) {
27
- return cleanPastedHtml(html);
28
- } } })],
26
+ plugins: [new Plugin({ props: {
27
+ transformPastedHTML(html) {
28
+ const internalClipboardToken = options.getInternalClipboardToken?.();
29
+ return cleanPastedHtml(html, { ...internalClipboardToken ? { internalClipboardToken } : {} });
30
+ },
31
+ transformPasted: removeUnpairedBookmarkBoundaries
32
+ } })],
29
33
  keyboardShortcuts: { "Mod-Alt-v": pasteWithoutFormatting }
30
34
  };
31
35
  }
@@ -1,31 +1,18 @@
1
+ import { Slice } from "prosemirror-model";
1
2
  //#region src/prosemirror/extensions/features/pasteCleanup.d.ts
2
- /**
3
- * Office / web paste cleanup
4
- *
5
- * Content pasted from word processors and web pages arrives wrapped in a large
6
- * amount of producer-specific cruft: conditional comments, XML processing
7
- * instructions, namespaced markup (`<o:p>`, `<w:sdt>`, smart tags), `mso-*`
8
- * style declarations, and empty spans. None of it maps to the editor schema,
9
- * and some of it confuses the browser HTML parser that ProseMirror's clipboard
10
- * pipeline relies on. Producer class names (`MsoNormal`, ...) are deliberately
11
- * kept so the downstream style inliner can match `<style>` rules against them.
12
- *
13
- * `cleanPastedHtml` normalizes the raw clipboard HTML string into something the
14
- * schema's `parseDOM` rules can read cleanly. It is a pure string transform so
15
- * it runs in the editor and in tests without a DOM, and it is deliberately
16
- * conservative: it strips producer metadata but never rewrites visible text
17
- * (curly quotes, non-Latin scripts, and whitespace between words are left
18
- * untouched, since this editor targets an international, typography-sensitive
19
- * audience).
20
- */
21
3
  /**
22
4
  * Strip Office/web producer cruft from a raw clipboard HTML string.
23
5
  *
24
- * Best-effort and non-throwing: any unexpected failure returns the original
25
- * markup so a paste degrades to the browser default rather than losing content.
6
+ * Best-effort and non-throwing: any unexpected failure returns empty markup so
7
+ * reconstruction capabilities and other untrusted attributes fail closed.
26
8
  * `<style>` blocks are intentionally left in place for the downstream style
27
9
  * inliner, which resolves class-based CSS before the schema parser runs.
28
10
  */
29
- declare function cleanPastedHtml(html: string): string;
11
+ type CleanPastedHtmlOptions = {
12
+ internalClipboardToken?: string;
13
+ };
14
+ declare function cleanPastedHtml(html: string, options?: CleanPastedHtmlOptions): string;
15
+ /** Remove incomplete or duplicate bookmark pairs at copied slice edges. */
16
+ declare function removeUnpairedBookmarkBoundaries(slice: Slice): Slice;
30
17
  //#endregion
31
- export { cleanPastedHtml };
18
+ export { cleanPastedHtml, removeUnpairedBookmarkBoundaries };
@@ -1,4 +1,6 @@
1
1
  import { stripXmlDeclarations } from "../../../utils/stripXmlDeclarations.js";
2
+ import { readBookmarkBoundaryAttrs } from "../../bookmarkBoundaryAttrs.js";
3
+ import { Fragment, Slice } from "prosemirror-model";
2
4
  //#region src/prosemirror/extensions/features/pasteCleanup.ts
3
5
  /**
4
6
  * Office / web paste cleanup
@@ -117,56 +119,109 @@ const STRAY_XML_TAG = new RegExp(`<\\/?xml${TAG_TAIL}>`, "gi");
117
119
  const NOISE_TAG = new RegExp(`<\\/?(?:font|meta|link)${TAG_TAIL}>`, "gi");
118
120
  const EMPTY_SPAN = new RegExp(`<span(?:\\s${TAG_TAIL})?><\\/span>`, "gi");
119
121
  const MAX_EMPTY_SPAN_PASSES = 5;
120
- const PM_SLICE_MARKER = /\bdata-pm-slice\s*=/i;
122
+ const INTERNAL_CLIPBOARD_ATTR_PATTERN = /\s+data-docx-internal-clipboard(?:="([^"]*)"|='([^']*)'|=([^\s>]+))?/gi;
121
123
  const TEXTBOX_ANCHOR_ATTR = /\s+data-docx-textbox-anchor(?:="[^"]*"|='[^']*')?/gi;
124
+ const BOOKMARK_BOUNDARY_ATTR = /\s+data-docx-bookmark-(?:boundary|id|name|col-first|col-last)(?:="[^"]*"|='[^']*'|=[^\s>]+)?/gi;
125
+ const STRUCTURED_FIELD_ATTR = /\s+data-field-structured(?:="[^"]*"|='[^']*'|=[^\s>]+)?/gi;
122
126
  /**
123
- * Remove the `data-docx-textbox-anchor` marker from HTML that did not come
124
- * from a ProseMirror clipboard slice (see {@link PM_SLICE_MARKER}). Internal
125
- * copy/paste of a real text box carries the marker HTML unmodified so it
126
- * keeps working; anything else has the marker stripped defensively.
127
+ * Treat reconstruction attributes as internal only when the HTML carries the
128
+ * unguessable capability created with this editor schema. `data-pm-slice`
129
+ * cannot establish trust because arbitrary external HTML can forge it.
127
130
  */
128
- function stripForeignTextBoxAnchors(html, originalHtml) {
129
- if (PM_SLICE_MARKER.test(originalHtml)) return html;
131
+ function hasInternalClipboardCapability(html, token) {
132
+ if (!token) return false;
133
+ INTERNAL_CLIPBOARD_ATTR_PATTERN.lastIndex = 0;
134
+ for (const match of html.matchAll(INTERNAL_CLIPBOARD_ATTR_PATTERN)) if ((match[1] ?? match[2] ?? match[3]) === token) return true;
135
+ return false;
136
+ }
137
+ function stripForeignTextBoxAnchors(html, internal) {
138
+ if (internal) return html;
130
139
  return html.replace(TEXTBOX_ANCHOR_ATTR, "");
131
140
  }
141
+ function stripForeignBookmarkBoundaries(html, internal) {
142
+ if (internal) return html;
143
+ return html.replace(BOOKMARK_BOUNDARY_ATTR, "");
144
+ }
145
+ function stripForeignStructuredFields(html, internal) {
146
+ if (internal) return html;
147
+ return html.replace(STRUCTURED_FIELD_ATTR, "");
148
+ }
132
149
  /**
133
150
  * Remove empty spans left behind after stripping `mso-*` styles. Only truly
134
151
  * empty spans are removed (whitespace-only spans are kept so word-separating
135
152
  * spacer runs never collapse two words together). Repeated a bounded number of
136
153
  * times to unwrap nested empties (`<span><span></span></span>`).
137
154
  */
138
- function stripEmptySpans(html) {
155
+ function stripEmptySpans(html, preserveInternalAtoms) {
139
156
  let current = html;
140
157
  for (let pass = 0; pass < MAX_EMPTY_SPAN_PASSES; pass++) {
141
- const next = current.replace(EMPTY_SPAN, "");
158
+ const next = current.replace(EMPTY_SPAN, (span) => {
159
+ if (preserveInternalAtoms && /\bdata-docx-(?:bookmark-boundary|textbox-anchor)\s*=/i.test(span)) return span;
160
+ return "";
161
+ });
142
162
  if (next === current) break;
143
163
  current = next;
144
164
  }
145
165
  return current;
146
166
  }
147
- /**
148
- * Strip Office/web producer cruft from a raw clipboard HTML string.
149
- *
150
- * Best-effort and non-throwing: any unexpected failure returns the original
151
- * markup so a paste degrades to the browser default rather than losing content.
152
- * `<style>` blocks are intentionally left in place for the downstream style
153
- * inliner, which resolves class-based CSS before the schema parser runs.
154
- */
155
- function cleanPastedHtml(html) {
167
+ function cleanPastedHtml(html, options = {}) {
156
168
  if (!html) return html;
157
169
  try {
170
+ const internal = hasInternalClipboardCapability(html, options.internalClipboardToken);
158
171
  let cleaned = stripHtmlComments(html);
159
172
  cleaned = stripXmlDeclarations(cleaned);
160
173
  cleaned = cleaned.replace(NAMESPACED_TAG, "");
161
174
  cleaned = cleaned.replace(STRAY_XML_TAG, "");
162
175
  cleaned = cleaned.replace(NOISE_TAG, "");
163
176
  cleaned = stripMsoStyles(cleaned);
164
- cleaned = stripEmptySpans(cleaned);
165
- cleaned = stripForeignTextBoxAnchors(cleaned, html);
177
+ cleaned = stripEmptySpans(cleaned, internal);
178
+ cleaned = stripForeignTextBoxAnchors(cleaned, internal);
179
+ cleaned = stripForeignBookmarkBoundaries(cleaned, internal);
180
+ cleaned = stripForeignStructuredFields(cleaned, internal);
181
+ cleaned = cleaned.replace(INTERNAL_CLIPBOARD_ATTR_PATTERN, "");
166
182
  return cleaned.trim();
167
183
  } catch {
168
- return html;
184
+ return "";
169
185
  }
170
186
  }
187
+ /** Remove incomplete or duplicate bookmark pairs at copied slice edges. */
188
+ function removeUnpairedBookmarkBoundaries(slice) {
189
+ const counts = /* @__PURE__ */ new Map();
190
+ let boundaryIndex = 0;
191
+ slice.content.descendants((node) => {
192
+ if (node.type.name !== "bookmarkBoundary") return true;
193
+ const result = readBookmarkBoundaryAttrs(node);
194
+ if (!result.ok) return false;
195
+ const attrs = result.value;
196
+ const count = counts.get(attrs.id) ?? {
197
+ starts: 0,
198
+ ends: 0
199
+ };
200
+ if (attrs.type === "start") {
201
+ count.starts += 1;
202
+ count.firstStart ??= boundaryIndex;
203
+ } else {
204
+ count.ends += 1;
205
+ count.firstEnd ??= boundaryIndex;
206
+ }
207
+ boundaryIndex += 1;
208
+ counts.set(attrs.id, count);
209
+ return false;
210
+ });
211
+ const pairedIds = new Set([...counts].flatMap(([id, count]) => count.starts === 1 && count.ends === 1 && count.firstStart !== void 0 && count.firstEnd !== void 0 && count.firstStart < count.firstEnd ? [id] : []));
212
+ const filterFragment = (fragment) => {
213
+ const children = [];
214
+ fragment.forEach((node) => {
215
+ if (node.type.name === "bookmarkBoundary") {
216
+ const result = readBookmarkBoundaryAttrs(node);
217
+ if (result.ok && pairedIds.has(result.value.id)) children.push(node);
218
+ return;
219
+ }
220
+ children.push(node.childCount === 0 ? node : node.copy(filterFragment(node.content)));
221
+ });
222
+ return Fragment.fromArray(children);
223
+ };
224
+ return new Slice(filterFragment(slice.content), slice.openStart, slice.openEnd);
225
+ }
171
226
  //#endregion
172
- export { cleanPastedHtml };
227
+ export { cleanPastedHtml, removeUnpairedBookmarkBoundaries };
@@ -0,0 +1,9 @@
1
+ import { NodeExtension } from "../types.js";
2
+ //#region src/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts
3
+ /** Zero-width bookmark boundary that preserves its position through ProseMirror edits. */
4
+ type BookmarkBoundaryOptions = {
5
+ getInternalClipboardToken?: () => string;
6
+ };
7
+ declare const BookmarkBoundaryExtension: (options?: Partial<BookmarkBoundaryOptions> | undefined) => NodeExtension;
8
+ //#endregion
9
+ export { BookmarkBoundaryExtension };