@stll/folio-core 0.28.0 → 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 (56) hide show
  1. package/dist/docx/styleParser.js +15 -14
  2. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +1 -2
  3. package/dist/layout-bridge/convert/toFlowBlocks.js +135 -118
  4. package/dist/layout-engine/footnoteColumnReflow.d.ts +15 -0
  5. package/dist/layout-engine/footnoteColumnReflow.js +74 -0
  6. package/dist/layout-engine/index.d.ts +3 -5
  7. package/dist/layout-engine/index.js +36 -15
  8. package/dist/layout-engine/measure/cache.d.ts +1 -1
  9. package/dist/layout-engine/measure/cache.js +11 -2
  10. package/dist/layout-engine/measure/listMarkerWidth.d.ts +3 -1
  11. package/dist/layout-engine/measure/listMarkerWidth.js +6 -4
  12. package/dist/layout-engine/paginator.d.ts +6 -0
  13. package/dist/layout-engine/paginator.js +30 -11
  14. package/dist/layout-engine/types.d.ts +11 -6
  15. package/dist/markdown/renderParagraph.js +7 -2
  16. package/dist/prosemirror/attrs/index.js +5 -3
  17. package/dist/prosemirror/bookmarkBoundaryAttrs.d.ts +8 -0
  18. package/dist/prosemirror/bookmarkBoundaryAttrs.js +66 -0
  19. package/dist/prosemirror/commands/formatPainter.js +45 -1
  20. package/dist/prosemirror/conversion/fromProseDoc.d.ts +4 -1
  21. package/dist/prosemirror/conversion/fromProseDoc.js +224 -91
  22. package/dist/prosemirror/conversion/toProseDoc.d.ts +4 -1
  23. package/dist/prosemirror/conversion/toProseDoc.js +325 -94
  24. package/dist/prosemirror/extensions/StarterKit.js +11 -3
  25. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +8 -4
  26. package/dist/prosemirror/extensions/features/PasteCleanupExtension.d.ts +4 -1
  27. package/dist/prosemirror/extensions/features/PasteCleanupExtension.js +9 -5
  28. package/dist/prosemirror/extensions/features/pasteCleanup.d.ts +10 -23
  29. package/dist/prosemirror/extensions/features/pasteCleanup.js +77 -22
  30. package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.js +22 -22
  31. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts +9 -0
  32. package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +67 -0
  33. package/dist/prosemirror/extensions/nodes/FieldExtension.d.ts +10 -4
  34. package/dist/prosemirror/extensions/nodes/FieldExtension.js +77 -59
  35. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.d.ts +4 -1
  36. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +4 -3
  37. package/dist/prosemirror/listMarker.d.ts +58 -0
  38. package/dist/prosemirror/listMarker.js +185 -0
  39. package/dist/prosemirror/numberedRefFields.d.ts +24 -0
  40. package/dist/prosemirror/numberedRefFields.js +276 -0
  41. package/dist/prosemirror/paraText.js +56 -17
  42. package/dist/prosemirror/plugins/anonymizationDecorations.js +1 -1
  43. package/dist/prosemirror/plugins/pmTextScan.d.ts +3 -1
  44. package/dist/prosemirror/plugins/pmTextScan.js +28 -7
  45. package/dist/prosemirror/plugins/templateDirectives.js +2 -2
  46. package/dist/prosemirror/schema/index.d.ts +2 -2
  47. package/dist/prosemirror/schema/marks.d.ts +3 -1
  48. package/dist/prosemirror/schema/nodes.d.ts +13 -1
  49. package/dist/prosemirror/styles/styleResolver.d.ts +0 -1
  50. package/dist/prosemirror/styles/styleResolver.js +22 -29
  51. package/dist/prosemirror/styles/styleToggleCascade.d.ts +37 -0
  52. package/dist/prosemirror/styles/styleToggleCascade.js +52 -0
  53. package/dist/prosemirror/validation.js +93 -0
  54. package/dist/utils/textFormattingMerge.d.ts +9 -1
  55. package/dist/utils/textFormattingMerge.js +32 -1
  56. package/package.json +1 -1
@@ -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 };
@@ -4,38 +4,38 @@ import { createMarkExtension } from "../create.js";
4
4
  function buildRunFormattingOverrideAttrs(formatting) {
5
5
  if (!formatting) return;
6
6
  const attrs = {};
7
- if (formatting.bold === false) attrs.bold = false;
8
- if (formatting.italic === false) attrs.italic = false;
7
+ if (formatting.bold !== void 0) attrs.bold = formatting.bold;
8
+ if (formatting.italic !== void 0) attrs.italic = formatting.italic;
9
9
  if (formatting.underline?.style === "none") attrs.underline = "none";
10
- if (formatting.strike === false) attrs.strike = false;
10
+ if (formatting.strike !== void 0) attrs.strike = formatting.strike;
11
11
  if (formatting.doubleStrike === false) attrs.doubleStrike = false;
12
- if (formatting.allCaps === false) attrs.allCaps = false;
13
- if (formatting.smallCaps === false) attrs.smallCaps = false;
14
- if (formatting.hidden === false) attrs.hidden = false;
15
- if (formatting.emboss === false) attrs.emboss = false;
16
- if (formatting.imprint === false) attrs.imprint = false;
17
- if (formatting.shadow === false) attrs.shadow = false;
18
- if (formatting.outline === false) attrs.outline = false;
12
+ if (formatting.allCaps !== void 0) attrs.allCaps = formatting.allCaps;
13
+ if (formatting.smallCaps !== void 0) attrs.smallCaps = formatting.smallCaps;
14
+ if (formatting.hidden !== void 0) attrs.hidden = formatting.hidden;
15
+ if (formatting.emboss !== void 0) attrs.emboss = formatting.emboss;
16
+ if (formatting.imprint !== void 0) attrs.imprint = formatting.imprint;
17
+ if (formatting.shadow !== void 0) attrs.shadow = formatting.shadow;
18
+ if (formatting.outline !== void 0) attrs.outline = formatting.outline;
19
19
  if (formatting.rtl === false) attrs.rtl = false;
20
- if (formatting.boldCs !== void 0 && formatting.boldCs !== formatting.bold) attrs.boldCs = formatting.boldCs;
21
- if (formatting.italicCs !== void 0 && formatting.italicCs !== formatting.italic) attrs.italicCs = formatting.italicCs;
20
+ if (formatting.boldCs !== void 0) attrs.boldCs = formatting.boldCs;
21
+ if (formatting.italicCs !== void 0) attrs.italicCs = formatting.italicCs;
22
22
  if (formatting.fontSizeCs !== void 0 && formatting.fontSizeCs !== formatting.fontSize) attrs.fontSizeCs = formatting.fontSizeCs;
23
23
  if (formatting.cs !== void 0) attrs.cs = formatting.cs;
24
24
  return Object.keys(attrs).length > 0 ? attrs : void 0;
25
25
  }
26
26
  function applyRunFormattingOverrideAttrs(formatting, attrs) {
27
- if (attrs.bold === false) formatting.bold = false;
28
- if (attrs.italic === false) formatting.italic = false;
27
+ if (attrs.bold !== void 0) formatting.bold = attrs.bold;
28
+ if (attrs.italic !== void 0) formatting.italic = attrs.italic;
29
29
  if (attrs.underline === "none") formatting.underline = { style: "none" };
30
- if (attrs.strike === false) formatting.strike = false;
30
+ if (attrs.strike !== void 0) formatting.strike = attrs.strike;
31
31
  if (attrs.doubleStrike === false) formatting.doubleStrike = false;
32
- if (attrs.allCaps === false) formatting.allCaps = false;
33
- if (attrs.smallCaps === false) formatting.smallCaps = false;
34
- if (attrs.hidden === false) formatting.hidden = false;
35
- if (attrs.emboss === false) formatting.emboss = false;
36
- if (attrs.imprint === false) formatting.imprint = false;
37
- if (attrs.shadow === false) formatting.shadow = false;
38
- if (attrs.outline === false) formatting.outline = false;
32
+ if (attrs.allCaps !== void 0) formatting.allCaps = attrs.allCaps;
33
+ if (attrs.smallCaps !== void 0) formatting.smallCaps = attrs.smallCaps;
34
+ if (attrs.hidden !== void 0) formatting.hidden = attrs.hidden;
35
+ if (attrs.emboss !== void 0) formatting.emboss = attrs.emboss;
36
+ if (attrs.imprint !== void 0) formatting.imprint = attrs.imprint;
37
+ if (attrs.shadow !== void 0) formatting.shadow = attrs.shadow;
38
+ if (attrs.outline !== void 0) formatting.outline = attrs.outline;
39
39
  if (attrs.rtl === false) formatting.rtl = false;
40
40
  if (attrs.boldCs !== void 0) formatting.boldCs = attrs.boldCs;
41
41
  if (attrs.italicCs !== void 0) formatting.italicCs = attrs.italicCs;
@@ -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 };
@@ -0,0 +1,67 @@
1
+ import { expectBookmarkBoundaryAttrs } from "../../bookmarkBoundaryAttrs.js";
2
+ import { createNodeExtension } from "../create.js";
3
+ //#region src/prosemirror/extensions/nodes/BookmarkBoundaryExtension.ts
4
+ /** Zero-width bookmark boundary that preserves its position through ProseMirror edits. */
5
+ function readNonnegativeInteger(value) {
6
+ if (value === null || !/^(?:0|[1-9]\d*)$/.test(value)) return false;
7
+ const parsed = Number(value);
8
+ return Number.isSafeInteger(parsed) ? parsed : false;
9
+ }
10
+ function readOptionalColumn(dom, attribute) {
11
+ const value = dom.getAttribute(attribute);
12
+ return value === null ? void 0 : readNonnegativeInteger(value);
13
+ }
14
+ const BookmarkBoundaryExtension = createNodeExtension({
15
+ name: "bookmarkBoundary",
16
+ schemaNodeName: "bookmarkBoundary",
17
+ nodeSpec: (options) => ({
18
+ inline: true,
19
+ group: "inline",
20
+ marks: "_",
21
+ atom: true,
22
+ selectable: false,
23
+ attrs: {
24
+ type: {},
25
+ id: {},
26
+ name: { default: null },
27
+ colFirst: { default: null },
28
+ colLast: { default: null }
29
+ },
30
+ parseDOM: [{
31
+ tag: "span[data-docx-bookmark-boundary]",
32
+ getAttrs(dom) {
33
+ const type = dom.getAttribute("data-docx-bookmark-boundary");
34
+ const id = readNonnegativeInteger(dom.getAttribute("data-docx-bookmark-id"));
35
+ if (type !== "start" && type !== "end" || id === false) return false;
36
+ const name = dom.getAttribute("data-docx-bookmark-name");
37
+ if (type === "start" && !name) return false;
38
+ const colFirst = readOptionalColumn(dom, "data-docx-bookmark-col-first");
39
+ const colLast = readOptionalColumn(dom, "data-docx-bookmark-col-last");
40
+ if (colFirst === false || colLast === false) return false;
41
+ return {
42
+ type,
43
+ id,
44
+ ...name ? { name } : {},
45
+ ...colFirst !== void 0 ? { colFirst } : {},
46
+ ...colLast !== void 0 ? { colLast } : {}
47
+ };
48
+ }
49
+ }],
50
+ toDOM(node) {
51
+ const attrs = expectBookmarkBoundaryAttrs(node);
52
+ return ["span", {
53
+ "data-docx-bookmark-boundary": attrs.type,
54
+ "data-docx-bookmark-id": String(attrs.id),
55
+ ...attrs.type === "start" ? { "data-docx-bookmark-name": attrs.name } : {},
56
+ ...attrs.type === "start" && attrs.colFirst !== void 0 ? { "data-docx-bookmark-col-first": String(attrs.colFirst) } : {},
57
+ ...attrs.type === "start" && attrs.colLast !== void 0 ? { "data-docx-bookmark-col-last": String(attrs.colLast) } : {},
58
+ "aria-hidden": "true",
59
+ contenteditable: "false",
60
+ style: "display: none;",
61
+ ...options.getInternalClipboardToken ? { "data-docx-internal-clipboard": options.getInternalClipboardToken() } : {}
62
+ }];
63
+ }
64
+ })
65
+ });
66
+ //#endregion
67
+ export { BookmarkBoundaryExtension };
@@ -1,11 +1,17 @@
1
1
  import { NodeExtension } from "../types.js";
2
2
  //#region src/prosemirror/extensions/nodes/FieldExtension.d.ts
3
3
  /**
4
- * Field Extension — inline field node (PAGE, NUMPAGES, DATE, MERGEFIELD, etc.)
4
+ * Field extensionsatomic inline fields (PAGE, NUMPAGES, REF, etc.).
5
5
  *
6
- * Represents OOXML simple and complex fields as an atomic inline node.
7
- * At render time, field values are substituted (e.g., PAGE → actual page number).
6
+ * Ordinary fields are true ProseMirror leaves, so `textContent`, `textBetween`,
7
+ * and the default plain-text clipboard serializer can use `leafText`.
8
+ * Simple fields that must preserve hyperlink structure use a distinct atomic
9
+ * node with inline children; their visible text comes from those children.
8
10
  */
11
+ type StructuredFieldOptions = {
12
+ getInternalClipboardToken?: () => string;
13
+ };
9
14
  declare const FieldExtension: (options?: Partial<Record<string, unknown>> | undefined) => NodeExtension;
15
+ declare const StructuredFieldExtension: (options?: Partial<StructuredFieldOptions> | undefined) => NodeExtension;
10
16
  //#endregion
11
- export { FieldExtension };
17
+ export { FieldExtension, StructuredFieldExtension };
@@ -2,12 +2,49 @@ import { parseFieldInstruction } from "../../../docx/fieldParser.js";
2
2
  import { expectFieldAttrs } from "../../attrs/index.js";
3
3
  import { createNodeExtension } from "../create.js";
4
4
  //#region src/prosemirror/extensions/nodes/FieldExtension.ts
5
- /**
6
- * Field Extension inline field node (PAGE, NUMPAGES, DATE, MERGEFIELD, etc.)
7
- *
8
- * Represents OOXML simple and complex fields as an atomic inline node.
9
- * At render time, field values are substituted (e.g., PAGE → actual page number).
10
- */
5
+ const createFieldAttrs = () => ({
6
+ fieldType: { default: "UNKNOWN" },
7
+ instruction: { default: "" },
8
+ displayText: { default: "" },
9
+ _numberedRefBaseline: { default: void 0 },
10
+ fieldKind: { default: "simple" },
11
+ fldLock: { default: false },
12
+ dirty: { default: false }
13
+ });
14
+ const readFieldDomAttrs = (dom) => ({
15
+ fieldType: dom.dataset["fieldType"] ?? "UNKNOWN",
16
+ instruction: dom.dataset["instruction"] ?? "",
17
+ displayText: dom.textContent ?? "",
18
+ fieldKind: dom.dataset["fieldKind"] ?? "simple",
19
+ fldLock: dom.dataset["fldLock"] === "true",
20
+ dirty: dom.dataset["dirty"] === "true"
21
+ });
22
+ const getFieldDomAttrs = (node) => {
23
+ const { fieldType, instruction, fieldKind, fldLock, dirty } = expectFieldAttrs(node);
24
+ return {
25
+ class: `docx-field docx-field-${fieldType.toLowerCase()}`,
26
+ "data-field-type": fieldType,
27
+ "data-instruction": instruction,
28
+ "data-field-kind": fieldKind,
29
+ ...fldLock ? { "data-fld-lock": "true" } : {},
30
+ ...dirty ? { "data-dirty": "true" } : {},
31
+ style: "outline: 1px solid var(--doc-field-outline, rgba(200,200,200,0.4)); padding: 0 1px; border-radius: 2px;"
32
+ };
33
+ };
34
+ const getFieldVisibleText = (node) => {
35
+ const { fieldType, instruction, displayText } = expectFieldAttrs(node);
36
+ if (displayText) return displayText;
37
+ switch (fieldType) {
38
+ case "PAGE": return "{page}";
39
+ case "NUMPAGES": return "{pages}";
40
+ case "DATE":
41
+ case "TIME":
42
+ case "CREATEDATE":
43
+ case "SAVEDATE": return (/* @__PURE__ */ new Date()).toLocaleDateString();
44
+ case "MERGEFIELD": return `«${getMergeFieldName(instruction)}»`;
45
+ default: return `{${fieldType}}`;
46
+ }
47
+ };
11
48
  const FieldExtension = createNodeExtension({
12
49
  name: "field",
13
50
  schemaNodeName: "field",
@@ -16,72 +53,53 @@ const FieldExtension = createNodeExtension({
16
53
  group: "inline",
17
54
  atom: true,
18
55
  selectable: true,
19
- attrs: {
20
- /** Field type: PAGE, NUMPAGES, DATE, MERGEFIELD, etc. */
21
- fieldType: { default: "UNKNOWN" },
22
- /** Full field instruction (e.g., "PAGE \\* MERGEFORMAT") */
23
- instruction: { default: "" },
24
- /** Display text (the current/cached field value) */
25
- displayText: { default: "" },
26
- /** Whether this is a simple or complex field */
27
- fieldKind: { default: "simple" },
28
- /** Field is locked */
29
- fldLock: { default: false },
30
- /** Field is dirty (needs update) */
31
- dirty: { default: false }
32
- },
56
+ attrs: createFieldAttrs(),
57
+ leafText: getFieldVisibleText,
58
+ parseDOM: [{
59
+ tag: "span.docx-field:not([data-field-structured])",
60
+ getAttrs: readFieldDomAttrs
61
+ }],
62
+ toDOM(node) {
63
+ return [
64
+ "span",
65
+ getFieldDomAttrs(node),
66
+ getFieldVisibleText(node)
67
+ ];
68
+ }
69
+ }
70
+ });
71
+ const StructuredFieldExtension = createNodeExtension({
72
+ name: "structuredField",
73
+ schemaNodeName: "structuredField",
74
+ nodeSpec: (options) => ({
75
+ inline: true,
76
+ group: "inline",
77
+ content: "(text | bookmarkBoundary | tab | symbol | hardBreak | image | shape | renderedPageBreak | textBoxAnchor)+",
78
+ atom: true,
79
+ selectable: true,
80
+ attrs: createFieldAttrs(),
33
81
  parseDOM: [{
34
- tag: "span.docx-field",
82
+ tag: "span.docx-field[data-field-structured=\"true\"]",
35
83
  getAttrs(dom) {
36
- return {
37
- fieldType: dom.dataset["fieldType"] ?? "UNKNOWN",
38
- instruction: dom.dataset["instruction"] ?? "",
39
- displayText: dom.textContent,
40
- fieldKind: dom.dataset["fieldKind"] ?? "simple",
41
- fldLock: dom.dataset["fldLock"] === "true",
42
- dirty: dom.dataset["dirty"] === "true"
43
- };
84
+ if (dom.dataset["fieldKind"] !== "simple" || !dom.querySelector("a[href]")) return false;
85
+ return readFieldDomAttrs(dom);
44
86
  }
45
87
  }],
46
88
  toDOM(node) {
47
- const { fieldType, instruction, displayText, fieldKind, fldLock, dirty } = expectFieldAttrs(node);
48
- let text = displayText || "";
49
- if (!text) switch (fieldType) {
50
- case "PAGE":
51
- text = "{page}";
52
- break;
53
- case "NUMPAGES":
54
- text = "{pages}";
55
- break;
56
- case "DATE":
57
- case "TIME":
58
- case "CREATEDATE":
59
- case "SAVEDATE":
60
- text = (/* @__PURE__ */ new Date()).toLocaleDateString();
61
- break;
62
- case "MERGEFIELD":
63
- text = `«${getMergeFieldName(instruction)}»`;
64
- break;
65
- default: text = `{${fieldType}}`;
66
- }
67
89
  return [
68
90
  "span",
69
91
  {
70
- class: `docx-field docx-field-${fieldType.toLowerCase()}`,
71
- "data-field-type": fieldType,
72
- "data-instruction": instruction,
73
- "data-field-kind": fieldKind,
74
- ...fldLock ? { "data-fld-lock": "true" } : {},
75
- ...dirty ? { "data-dirty": "true" } : {},
76
- style: "outline: 1px solid var(--doc-field-outline, rgba(200,200,200,0.4)); padding: 0 1px; border-radius: 2px;"
92
+ ...getFieldDomAttrs(node),
93
+ "data-field-structured": "true",
94
+ ...options.getInternalClipboardToken ? { "data-docx-internal-clipboard": options.getInternalClipboardToken() } : {}
77
95
  },
78
- text
96
+ 0
79
97
  ];
80
98
  }
81
- }
99
+ })
82
100
  });
83
101
  function getMergeFieldName(instruction) {
84
102
  return parseFieldInstruction(instruction).argument ?? "";
85
103
  }
86
104
  //#endregion
87
- export { FieldExtension };
105
+ export { FieldExtension, StructuredFieldExtension };
@@ -7,6 +7,9 @@ import { NodeExtension } from "../types.js";
7
7
  * map its position as surrounding text is edited. The save conversion consumes
8
8
  * it when the sibling text box is restored to the DOCX run stream.
9
9
  */
10
- declare const TextBoxAnchorExtension: (options?: Partial<Record<string, unknown>> | undefined) => NodeExtension;
10
+ type TextBoxAnchorOptions = {
11
+ getInternalClipboardToken?: () => string;
12
+ };
13
+ declare const TextBoxAnchorExtension: (options?: Partial<TextBoxAnchorOptions> | undefined) => NodeExtension;
11
14
  //#endregion
12
15
  export { TextBoxAnchorExtension };
@@ -11,7 +11,7 @@ import { createNodeExtension } from "../create.js";
11
11
  const TextBoxAnchorExtension = createNodeExtension({
12
12
  name: "textBoxAnchor",
13
13
  schemaNodeName: "textBoxAnchor",
14
- nodeSpec: {
14
+ nodeSpec: (options) => ({
15
15
  inline: true,
16
16
  group: "inline",
17
17
  marks: "_",
@@ -32,10 +32,11 @@ const TextBoxAnchorExtension = createNodeExtension({
32
32
  "data-docx-textbox-anchor": anchorId,
33
33
  "aria-hidden": "true",
34
34
  contenteditable: "false",
35
- style: "display: inline-block; width: 0; height: 0; overflow: hidden; pointer-events: none;"
35
+ style: "display: inline-block; width: 0; height: 0; overflow: hidden; pointer-events: none;",
36
+ ...options.getInternalClipboardToken ? { "data-docx-internal-clipboard": options.getInternalClipboardToken() } : {}
36
37
  }];
37
38
  }
38
- }
39
+ })
39
40
  });
40
41
  //#endregion
41
42
  export { TextBoxAnchorExtension };