@beyondwork/docx-react-component 1.0.11 → 1.0.13

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 (40) hide show
  1. package/README.md +8 -2
  2. package/package.json +35 -21
  3. package/src/api/public-types.ts +103 -1
  4. package/src/core/commands/formatting-commands.ts +742 -0
  5. package/src/core/commands/image-commands.ts +84 -2
  6. package/src/core/commands/structural-helpers.ts +309 -0
  7. package/src/core/commands/table-structure-commands.ts +721 -0
  8. package/src/core/commands/text-commands.ts +166 -1
  9. package/src/core/state/editor-state.ts +318 -9
  10. package/src/formats/xlsx/io/parse-sheet.ts +177 -7
  11. package/src/formats/xlsx/io/parse-styles.ts +2 -0
  12. package/src/formats/xlsx/io/xlsx-session.ts +18 -12
  13. package/src/formats/xlsx/model/sheet.ts +81 -1
  14. package/src/formats/xlsx/model/workbook.ts +10 -6
  15. package/src/io/docx-session.ts +392 -22
  16. package/src/io/export/export-session.ts +55 -0
  17. package/src/io/export/serialize-footnotes.ts +5 -20
  18. package/src/io/export/serialize-headers-footers.ts +5 -31
  19. package/src/io/export/serialize-main-document.ts +78 -5
  20. package/src/io/normalize/normalize-text.ts +90 -1
  21. package/src/io/ooxml/parse-footnotes.ts +68 -5
  22. package/src/io/ooxml/parse-headers-footers.ts +67 -9
  23. package/src/io/ooxml/parse-main-document.ts +169 -6
  24. package/src/io/opc/package-reader.ts +3 -3
  25. package/src/io/source-package-provenance.ts +241 -0
  26. package/src/model/canonical-document.ts +450 -2
  27. package/src/model/cds-1.0.0.ts +5 -2
  28. package/src/model/snapshot.ts +190 -19
  29. package/src/preservation/package-preservation.ts +0 -7
  30. package/src/runtime/document-runtime.ts +7 -1
  31. package/src/runtime/read-only-diagnostics-runtime.ts +1 -1
  32. package/src/runtime/surface-projection.ts +200 -17
  33. package/src/runtime/table-commands.ts +79 -0
  34. package/src/runtime/table-schema.ts +9 -0
  35. package/src/ui/WordReviewEditor.tsx +708 -16
  36. package/src/ui-tailwind/editor-surface/pm-schema.ts +121 -5
  37. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +73 -7
  38. package/src/ui-tailwind/editor-surface/search-plugin.ts +76 -16
  39. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +162 -14
  40. package/src/validation/compatibility-engine.ts +208 -0
@@ -6,6 +6,25 @@ import {
6
6
  tableHeaderCellNodeSpec,
7
7
  } from "../../runtime/table-schema.ts";
8
8
 
9
+ const HEX_COLOR_RE = /^[0-9A-Fa-f]{3,8}$/;
10
+ const SAFE_FONT_RE = /^[A-Za-z0-9 ,\-'"]+$/;
11
+ const SAFE_ALIGNMENT = new Set(["left", "center", "right", "justify", "start", "end"]);
12
+
13
+ /** Validate a raw hex color string from OOXML (no leading #). Returns sanitized `#hex` or null. */
14
+ function safeHexColor(raw: string | null | undefined): string | null {
15
+ if (!raw || raw === "auto") return null;
16
+ return HEX_COLOR_RE.test(raw) ? `#${raw}` : null;
17
+ }
18
+
19
+ /** Validate a CSS color value (may already include #). Returns the value or null. */
20
+ function safeCssColor(raw: string | null | undefined): string | null {
21
+ if (!raw) return null;
22
+ // Allow #hex, named colors (single word), rgb/rgba functions
23
+ if (/^#[0-9A-Fa-f]{3,8}$/.test(raw)) return raw;
24
+ if (/^[a-zA-Z]+$/.test(raw)) return raw;
25
+ return null;
26
+ }
27
+
9
28
  /**
10
29
  * ProseMirror schema for the supported live surface slice.
11
30
  *
@@ -27,6 +46,20 @@ export const editorSchema = new Schema({
27
46
  numberingInstanceId: { default: null },
28
47
  numberingLevel: { default: null },
29
48
  alignment: { default: null },
49
+ spacingBefore: { default: null },
50
+ spacingAfter: { default: null },
51
+ lineSpacing: { default: null },
52
+ lineRule: { default: null },
53
+ indentLeft: { default: null },
54
+ indentRight: { default: null },
55
+ indentFirstLine: { default: null },
56
+ shadingFill: { default: null },
57
+ borderTop: { default: null },
58
+ borderBottom: { default: null },
59
+ borderLeft: { default: null },
60
+ borderRight: { default: null },
61
+ bidi: { default: null },
62
+ pageBreakBefore: { default: null },
30
63
  },
31
64
  parseDOM: [{ tag: "p" }],
32
65
  toDOM(node) {
@@ -39,8 +72,41 @@ export const editorSchema = new Schema({
39
72
  else if (lower === "heading3") classes.push("text-lg font-medium");
40
73
  }
41
74
  const attrs: Record<string, string> = { class: classes.join(" ") };
75
+ const styles: string[] = [];
42
76
  const alignment = node.attrs.alignment as string | null;
43
- if (alignment) attrs.style = `text-align: ${alignment}`;
77
+ const safeAlign = alignment === "both" ? "justify" : alignment;
78
+ if (safeAlign && SAFE_ALIGNMENT.has(safeAlign)) styles.push(`text-align: ${safeAlign}`);
79
+ const spacingBefore = node.attrs.spacingBefore as number | null;
80
+ if (spacingBefore) styles.push(`margin-top: ${spacingBefore / 20}px`);
81
+ const spacingAfter = node.attrs.spacingAfter as number | null;
82
+ if (spacingAfter) styles.push(`margin-bottom: ${spacingAfter / 20}px`);
83
+ const lineSpacing = node.attrs.lineSpacing as number | null;
84
+ const lineRule = node.attrs.lineRule as string | null;
85
+ if (lineSpacing && lineRule === "auto") styles.push(`line-height: ${lineSpacing / 240}`);
86
+ else if (lineSpacing && lineRule === "exact") styles.push(`line-height: ${lineSpacing / 20}px`);
87
+ else if (lineSpacing && lineRule === "atLeast") styles.push(`min-height: ${lineSpacing / 20}px`);
88
+ const indentLeft = node.attrs.indentLeft as number | null;
89
+ if (indentLeft) styles.push(`padding-left: ${indentLeft / 20}px`);
90
+ const indentRight = node.attrs.indentRight as number | null;
91
+ if (indentRight) styles.push(`padding-right: ${indentRight / 20}px`);
92
+ const indentFirstLine = node.attrs.indentFirstLine as number | null;
93
+ if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}px`);
94
+ const shadingColor = safeHexColor(node.attrs.shadingFill as string | null);
95
+ if (shadingColor) styles.push(`background-color: ${shadingColor}`);
96
+ for (const [side, attrName] of [["top", "borderTop"], ["bottom", "borderBottom"], ["left", "borderLeft"], ["right", "borderRight"]] as const) {
97
+ const border = node.attrs[attrName] as { color?: string; sz?: number; val?: string } | null;
98
+ if (border && border.val && border.val !== "none") {
99
+ const width = border.sz ? `${border.sz / 8}px` : "1px";
100
+ const color = safeHexColor(border.color ?? null) ?? "#000000";
101
+ const bStyle = border.val === "dotted" ? "dotted" : border.val === "dashed" ? "dashed" : "solid";
102
+ styles.push(`border-${side}: ${width} ${bStyle} ${color}`);
103
+ }
104
+ }
105
+ const pageBreak = node.attrs.pageBreakBefore as boolean | null;
106
+ if (pageBreak) styles.push("border-top: 2px dashed rgba(0,0,0,0.1); padding-top: 8px; margin-top: 16px");
107
+ const bidi = node.attrs.bidi as boolean | null;
108
+ if (bidi) attrs.dir = "rtl";
109
+ if (styles.length > 0) attrs.style = styles.join("; ");
44
110
  return ["p", attrs, 0];
45
111
  },
46
112
  },
@@ -64,7 +130,14 @@ export const editorSchema = new Schema({
64
130
  group: "inline",
65
131
  atom: true,
66
132
  selectable: false,
67
- toDOM() {
133
+ attrs: {
134
+ tabWidth: { default: null },
135
+ },
136
+ toDOM(node) {
137
+ const width = node.attrs.tabWidth as number | null;
138
+ if (width && width > 0) {
139
+ return ["span", { style: `display: inline-block; width: ${width}px`, "data-node-type": "tab" }, "\u00A0"];
140
+ }
68
141
  return ["span", { class: "inline-block w-8", "data-node-type": "tab" }, "\u00A0"];
69
142
  },
70
143
  },
@@ -336,6 +409,31 @@ export const editorSchema = new Schema({
336
409
  return ["s", 0];
337
410
  },
338
411
  },
412
+ doubleStrikethrough: {
413
+ toDOM() {
414
+ return ["span", { style: "text-decoration: line-through double" }, 0];
415
+ },
416
+ },
417
+ vanish: {
418
+ toDOM() {
419
+ return ["span", { style: "opacity: 0.3; text-decoration: underline dotted; text-decoration-color: rgba(0,0,0,0.3)" }, 0];
420
+ },
421
+ },
422
+ emboss: {
423
+ toDOM() {
424
+ return ["span", { style: "text-shadow: 1px -1px 0 rgba(255,255,255,0.6), -1px 1px 0 rgba(0,0,0,0.2)" }, 0];
425
+ },
426
+ },
427
+ imprint: {
428
+ toDOM() {
429
+ return ["span", { style: "text-shadow: -1px 1px 0 rgba(255,255,255,0.6), 1px -1px 0 rgba(0,0,0,0.2)" }, 0];
430
+ },
431
+ },
432
+ shadow: {
433
+ toDOM() {
434
+ return ["span", { style: "text-shadow: 1px 1px 2px rgba(0,0,0,0.3)" }, 0];
435
+ },
436
+ },
339
437
  superscript: {
340
438
  excludes: "subscript",
341
439
  parseDOM: [{ tag: "sup" }],
@@ -372,6 +470,19 @@ export const editorSchema = new Schema({
372
470
  return ["span", { style: "text-transform: uppercase" }, 0];
373
471
  },
374
472
  },
473
+ char_spacing: {
474
+ attrs: { value: { default: 0 } },
475
+ toDOM(mark) {
476
+ const twips = mark.attrs.value as number;
477
+ return ["span", { style: `letter-spacing: ${twips / 20}px` }, 0];
478
+ },
479
+ },
480
+ font_kerning: {
481
+ attrs: { threshold: { default: 0 } },
482
+ toDOM() {
483
+ return ["span", { style: "font-kerning: normal" }, 0];
484
+ },
485
+ },
375
486
  font_family: {
376
487
  attrs: { family: { default: null } },
377
488
  parseDOM: [
@@ -381,7 +492,9 @@ export const editorSchema = new Schema({
381
492
  },
382
493
  ],
383
494
  toDOM(mark) {
384
- return ["span", { style: `font-family: ${mark.attrs.family as string}` }, 0];
495
+ const family = mark.attrs.family as string;
496
+ if (!SAFE_FONT_RE.test(family)) return ["span", 0];
497
+ return ["span", { style: `font-family: ${family}` }, 0];
385
498
  },
386
499
  },
387
500
  font_size: {
@@ -408,7 +521,8 @@ export const editorSchema = new Schema({
408
521
  },
409
522
  ],
410
523
  toDOM(mark) {
411
- const color = mark.attrs.color as string;
524
+ const color = safeCssColor(mark.attrs.color as string);
525
+ if (!color) return ["span", 0];
412
526
  return ["span", { style: `color: ${color}` }, 0];
413
527
  },
414
528
  },
@@ -421,7 +535,9 @@ export const editorSchema = new Schema({
421
535
  },
422
536
  ],
423
537
  toDOM(mark) {
424
- return ["mark", { style: `background-color: ${mark.attrs.color as string}` }, 0];
538
+ const color = safeCssColor(mark.attrs.color as string);
539
+ if (!color) return ["mark", 0];
540
+ return ["mark", { style: `background-color: ${color}` }, 0];
425
541
  },
426
542
  },
427
543
  link: {
@@ -1,5 +1,5 @@
1
1
  import { Fragment, type Node as PMNode } from "prosemirror-model";
2
- import { EditorState, type Plugin, TextSelection } from "prosemirror-state";
2
+ import { EditorState, type Plugin, Selection, TextSelection } from "prosemirror-state";
3
3
 
4
4
  import type {
5
5
  EditorSurfaceSnapshot,
@@ -43,12 +43,13 @@ export function createPMStateFromSnapshot(
43
43
  positionMap.pmDocSize - 1,
44
44
  );
45
45
 
46
- let pmSelection: TextSelection;
46
+ let pmSelection: Selection;
47
47
  try {
48
- pmSelection = TextSelection.create(doc, pmAnchor, pmHead);
48
+ pmSelection = TextSelection.between(doc.resolve(pmAnchor), doc.resolve(pmHead));
49
49
  } catch {
50
- // If the position is invalid (e.g., inside an atom), fall back to start
51
- pmSelection = TextSelection.create(doc, 1);
50
+ // If the mapped runtime selection is invalid or lands in a non-text block,
51
+ // let ProseMirror choose the nearest valid starting selection.
52
+ pmSelection = Selection.atStart(doc);
52
53
  }
53
54
 
54
55
  const state = EditorState.create({
@@ -87,10 +88,24 @@ function buildParagraph(
87
88
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
88
89
  ): PMNode {
89
90
  const content: PMNode[] = [];
91
+ const tabStops = block.tabStops ?? [];
92
+ let tabIndex = 0;
90
93
 
91
94
  for (const segment of block.segments) {
92
- const nodes = buildInlineContent(segment);
93
- content.push(...nodes);
95
+ if (segment.kind === "tab" && tabIndex < tabStops.length) {
96
+ const stop = tabStops[tabIndex];
97
+ const stopPos = (stop as { pos?: number }).pos ?? (stop as { position?: number }).position ?? 0;
98
+ const prevStop = tabIndex > 0 ? tabStops[tabIndex - 1] : null;
99
+ const prevPos = prevStop
100
+ ? ((prevStop as { pos?: number }).pos ?? (prevStop as { position?: number }).position ?? 0)
101
+ : 0;
102
+ const widthPx = Math.round((stopPos - prevPos) / 15);
103
+ content.push(editorSchema.nodes.tab_char.create({ tabWidth: widthPx > 8 ? widthPx : null }));
104
+ tabIndex++;
105
+ } else {
106
+ const nodes = buildInlineContent(segment);
107
+ content.push(...nodes);
108
+ }
94
109
  }
95
110
 
96
111
  return editorSchema.nodes.paragraph.create(
@@ -98,6 +113,21 @@ function buildParagraph(
98
113
  styleId: block.styleId ?? null,
99
114
  numberingInstanceId: block.numbering?.numberingInstanceId ?? null,
100
115
  numberingLevel: block.numbering?.level ?? null,
116
+ alignment: block.alignment ?? null,
117
+ spacingBefore: block.spacing?.before ?? null,
118
+ spacingAfter: block.spacing?.after ?? null,
119
+ lineSpacing: block.spacing?.line ?? null,
120
+ lineRule: block.spacing?.lineRule ?? null,
121
+ indentLeft: block.indentation?.left ?? null,
122
+ indentRight: block.indentation?.right ?? null,
123
+ indentFirstLine: block.indentation?.firstLine ?? null,
124
+ shadingFill: block.shading?.fill ?? null,
125
+ borderTop: (block.borders as Record<string, unknown>)?.top ?? null,
126
+ borderBottom: (block.borders as Record<string, unknown>)?.bottom ?? null,
127
+ borderLeft: (block.borders as Record<string, unknown>)?.left ?? null,
128
+ borderRight: (block.borders as Record<string, unknown>)?.right ?? null,
129
+ bidi: block.bidi ?? null,
130
+ pageBreakBefore: block.pageBreakBefore ?? null,
101
131
  },
102
132
  content.length > 0 ? Fragment.from(content) : undefined,
103
133
  );
@@ -116,12 +146,47 @@ function buildInlineContent(segment: SurfaceInlineSegment): PMNode[] {
116
146
  const pmMarks = [];
117
147
  if (segment.marks) {
118
148
  for (const mark of segment.marks) {
149
+ // Map surface mark names that differ from PM schema mark names
150
+ if (mark === "smallCaps") {
151
+ pmMarks.push(editorSchema.marks.small_caps.create());
152
+ continue;
153
+ }
154
+ if (mark === "allCaps") {
155
+ pmMarks.push(editorSchema.marks.all_caps.create());
156
+ continue;
157
+ }
119
158
  const pmMark = editorSchema.marks[mark];
120
159
  if (pmMark) {
121
160
  pmMarks.push(pmMark.create());
122
161
  }
123
162
  }
124
163
  }
164
+ if (segment.kind === "text" && segment.markAttrs) {
165
+ if (segment.markAttrs.backgroundColor) {
166
+ pmMarks.push(editorSchema.marks.highlight.create({ color: `#${segment.markAttrs.backgroundColor}` }));
167
+ }
168
+ if (segment.markAttrs.fontFamily) {
169
+ pmMarks.push(editorSchema.marks.font_family.create({ family: segment.markAttrs.fontFamily }));
170
+ }
171
+ if (segment.markAttrs.fontSize) {
172
+ pmMarks.push(editorSchema.marks.font_size.create({ size: segment.markAttrs.fontSize / 2 }));
173
+ }
174
+ if (segment.markAttrs.textColor) {
175
+ pmMarks.push(editorSchema.marks.text_color.create({ color: `#${segment.markAttrs.textColor}` }));
176
+ }
177
+ if (segment.markAttrs.charSpacing) {
178
+ pmMarks.push(editorSchema.marks.char_spacing.create({ value: segment.markAttrs.charSpacing }));
179
+ }
180
+ if (segment.markAttrs.kerning) {
181
+ pmMarks.push(editorSchema.marks.font_kerning.create({ threshold: segment.markAttrs.kerning }));
182
+ }
183
+ if (segment.markAttrs.textFill && !segment.markAttrs.textColor) {
184
+ const colorMatch = segment.markAttrs.textFill.match(/\bval="([0-9A-Fa-f]{6})"/);
185
+ if (colorMatch) {
186
+ pmMarks.push(editorSchema.marks.text_color.create({ color: `#${colorMatch[1]}` }));
187
+ }
188
+ }
189
+ }
125
190
  if (segment.hyperlinkHref) {
126
191
  pmMarks.push(editorSchema.marks.link.create({ href: segment.hyperlinkHref }));
127
192
  }
@@ -194,6 +259,7 @@ function buildTable(
194
259
  rowspan: cell.rowspan,
195
260
  gridSpan: cell.gridSpan,
196
261
  verticalMerge: cell.verticalMerge,
262
+ backgroundColor: cell.backgroundColor ?? null,
197
263
  },
198
264
  Fragment.from(cellContent),
199
265
  ),
@@ -15,6 +15,8 @@ import { Plugin, PluginKey } from "prosemirror-state";
15
15
  import type { EditorState, Transaction } from "prosemirror-state";
16
16
  import { Decoration, DecorationSet } from "prosemirror-view";
17
17
 
18
+ import type { SearchOptions as PublicSearchOptions } from "../../api/public-types";
19
+
18
20
  // ---------------------------------------------------------------------------
19
21
  // Public types
20
22
  // ---------------------------------------------------------------------------
@@ -26,12 +28,14 @@ export interface SearchResult {
26
28
  index: number;
27
29
  }
28
30
 
29
- export interface SearchOptions {
31
+ export interface SearchOptions extends PublicSearchOptions {
30
32
  caseSensitive?: boolean;
31
33
  regex?: boolean;
32
34
  highlightColor?: string;
33
35
  }
34
36
 
37
+ export const DEFAULT_SEARCH_HIGHLIGHT_COLOR = "#fde68a";
38
+
35
39
  // ---------------------------------------------------------------------------
36
40
  // Plugin state
37
41
  // ---------------------------------------------------------------------------
@@ -102,21 +106,8 @@ export function performSearch(
102
106
  query: string,
103
107
  options: SearchOptions = {},
104
108
  ): SearchResult[] {
105
- if (!query) return [];
106
-
107
- const { caseSensitive = false, regex = false } = options;
108
-
109
- let pattern: RegExp;
110
- try {
111
- if (regex) {
112
- pattern = new RegExp(query, caseSensitive ? "g" : "gi");
113
- } else {
114
- const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115
- pattern = new RegExp(escaped, caseSensitive ? "g" : "gi");
116
- }
117
- } catch {
118
- return [];
119
- }
109
+ const pattern = buildSearchPattern(query, options);
110
+ if (!pattern) return [];
120
111
 
121
112
  const results: SearchResult[] = [];
122
113
 
@@ -132,12 +123,81 @@ export function performSearch(
132
123
  text: match[0],
133
124
  index: results.length,
134
125
  });
126
+
127
+ if (match[0].length === 0) {
128
+ pattern.lastIndex += 1;
129
+ }
135
130
  }
136
131
  });
137
132
 
138
133
  return results;
139
134
  }
140
135
 
136
+ export function findSearchMatches(
137
+ text: string,
138
+ query: string,
139
+ options: SearchOptions = {},
140
+ ): SearchResult[] {
141
+ const pattern = buildSearchPattern(query, options);
142
+ if (!pattern) return [];
143
+
144
+ const results: SearchResult[] = [];
145
+ let match: RegExpExecArray | null;
146
+ pattern.lastIndex = 0;
147
+ while ((match = pattern.exec(text)) !== null) {
148
+ results.push({
149
+ from: match.index,
150
+ to: match.index + match[0].length,
151
+ text: match[0],
152
+ index: results.length,
153
+ });
154
+
155
+ if (match[0].length === 0) {
156
+ pattern.lastIndex += 1;
157
+ }
158
+ }
159
+
160
+ return results;
161
+ }
162
+
163
+ export function createSearchExcerpt(
164
+ text: string,
165
+ from: number,
166
+ to: number,
167
+ radius = 24,
168
+ ): string {
169
+ const safeFrom = Math.max(0, Math.min(from, text.length));
170
+ const safeTo = Math.max(safeFrom, Math.min(to, text.length));
171
+ const start = Math.max(0, safeFrom - radius);
172
+ const end = Math.min(text.length, safeTo + radius);
173
+ const prefix = start > 0 ? "…" : "";
174
+ const suffix = end < text.length ? "…" : "";
175
+ return `${prefix}${text.slice(start, end)}${suffix}`;
176
+ }
177
+
178
+ function buildSearchPattern(
179
+ query: string,
180
+ options: SearchOptions,
181
+ ): RegExp | null {
182
+ if (!query) {
183
+ return null;
184
+ }
185
+
186
+ const caseSensitive = options.matchCase ?? options.caseSensitive ?? false;
187
+ const regex = options.regex ?? false;
188
+ const wholeWord = options.wholeWord ?? false;
189
+
190
+ try {
191
+ const source = regex
192
+ ? query
193
+ : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
194
+ const wrapped = wholeWord ? `\\b${source}\\b` : source;
195
+ return new RegExp(wrapped, caseSensitive ? "g" : "gi");
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
141
201
  // ---------------------------------------------------------------------------
142
202
  // Clear helper (ProseMirror Command signature)
143
203
  // ---------------------------------------------------------------------------
@@ -1,11 +1,24 @@
1
- import React, { type FocusEventHandler, useEffect, useMemo, useRef } from "react";
1
+ import React, {
2
+ forwardRef,
3
+ type FocusEventHandler,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ } from "react";
2
9
  import { EditorView } from "prosemirror-view";
3
10
 
4
11
  import type {
5
12
  EditorUser,
6
13
  RuntimeRenderSnapshot,
14
+ SearchOptions,
15
+ SearchResultSnapshot,
7
16
  SelectionSnapshot,
8
17
  } from "../../api/public-types";
18
+ import {
19
+ getTableSelectionDescriptor,
20
+ type TableSelectionDescriptor,
21
+ } from "../../runtime/table-commands.ts";
9
22
  import {
10
23
  createCommentDecorationModel,
11
24
  type MarkupDisplay,
@@ -18,6 +31,14 @@ import {
18
31
  } from "./pm-command-bridge";
19
32
  import { buildDecorations } from "./pm-decorations";
20
33
  import type { PositionMap } from "./pm-position-map";
34
+ import {
35
+ clearSearch as clearSearchPlugin,
36
+ createSearchExcerpt,
37
+ createSearchPlugin,
38
+ DEFAULT_SEARCH_HIGHLIGHT_COLOR,
39
+ performSearch,
40
+ searchPluginKey,
41
+ } from "./search-plugin";
21
42
  import { tableNodeViews } from "./tw-table-node-view";
22
43
 
23
44
  /**
@@ -43,7 +64,16 @@ export interface TwProseMirrorSurfaceProps {
43
64
  onRevisionActivated?: (revisionId: string) => void;
44
65
  }
45
66
 
46
- export function TwProseMirrorSurface(props: TwProseMirrorSurfaceProps) {
67
+ export interface TwProseMirrorSurfaceRef {
68
+ search(query: string, options?: SearchOptions): SearchResultSnapshot[];
69
+ clearSearch(): void;
70
+ getTableSelection(): TableSelectionDescriptor | null;
71
+ }
72
+
73
+ export const TwProseMirrorSurface = forwardRef<
74
+ TwProseMirrorSurfaceRef,
75
+ TwProseMirrorSurfaceProps
76
+ >(function TwProseMirrorSurface(props, ref) {
47
77
  const {
48
78
  currentUser,
49
79
  snapshot,
@@ -61,6 +91,7 @@ export function TwProseMirrorSurface(props: TwProseMirrorSurfaceProps) {
61
91
  const viewRef = useRef<EditorView | null>(null);
62
92
  const positionMapRef = useRef<PositionMap | null>(null);
63
93
  const callbacksRef = useRef<CommandBridgeCallbacks | null>(null);
94
+ const activeSearchRef = useRef<{ query: string; options: SearchOptions } | null>(null);
64
95
 
65
96
  // Keep callbacks ref up to date (avoids stale closures in PM plugins)
66
97
  callbacksRef.current = {
@@ -91,18 +122,21 @@ export function TwProseMirrorSurface(props: TwProseMirrorSurfaceProps) {
91
122
 
92
123
  // Create PM plugins (stable across renders — callbacks accessed via ref)
93
124
  const plugins = useMemo(() => {
94
- return createCommandBridgePlugins({
95
- onInsertText: (text) => callbacksRef.current?.onInsertText(text),
96
- onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
97
- onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
98
- onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
99
- onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
100
- onInsertTab: () => callbacksRef.current?.onInsertTab(),
101
- onUndo: () => callbacksRef.current?.onUndo(),
102
- onRedo: () => callbacksRef.current?.onRedo(),
103
- onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
104
- getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
105
- });
125
+ return [
126
+ ...createCommandBridgePlugins({
127
+ onInsertText: (text) => callbacksRef.current?.onInsertText(text),
128
+ onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
129
+ onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
130
+ onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
131
+ onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
132
+ onInsertTab: () => callbacksRef.current?.onInsertTab(),
133
+ onUndo: () => callbacksRef.current?.onUndo(),
134
+ onRedo: () => callbacksRef.current?.onRedo(),
135
+ onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
136
+ getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
137
+ }),
138
+ createSearchPlugin(),
139
+ ];
106
140
  }, []);
107
141
 
108
142
  // Create or update PM view whenever surface becomes available or changes.
@@ -148,6 +182,13 @@ export function TwProseMirrorSurface(props: TwProseMirrorSurfaceProps) {
148
182
  });
149
183
  viewRef.current.updateState(state);
150
184
  }
185
+
186
+ if (activeSearchRef.current) {
187
+ applySearch(
188
+ activeSearchRef.current.query,
189
+ activeSearchRef.current.options,
190
+ );
191
+ }
151
192
  }, [snapshot.revisionToken, surface, commentModel, revisionModel, markupDisplay, canEdit]);
152
193
 
153
194
  // Cleanup on unmount
@@ -158,6 +199,90 @@ export function TwProseMirrorSurface(props: TwProseMirrorSurfaceProps) {
158
199
  };
159
200
  }, []);
160
201
 
202
+ useImperativeHandle(
203
+ ref,
204
+ () => ({
205
+ search: (query, options = {}) => {
206
+ const normalizedQuery = query.trim();
207
+ if (!normalizedQuery) {
208
+ activeSearchRef.current = null;
209
+ clearLiveSearch();
210
+ return [];
211
+ }
212
+
213
+ activeSearchRef.current = { query: normalizedQuery, options };
214
+ return applySearch(normalizedQuery, options);
215
+ },
216
+ clearSearch: () => {
217
+ activeSearchRef.current = null;
218
+ clearLiveSearch();
219
+ },
220
+ getTableSelection: () => {
221
+ const view = viewRef.current;
222
+ if (!view) {
223
+ return null;
224
+ }
225
+ return getTableSelectionDescriptor(view.state);
226
+ },
227
+ }),
228
+ [snapshot.selection, snapshot.surface],
229
+ );
230
+
231
+ function applySearch(query: string, options: SearchOptions): SearchResultSnapshot[] {
232
+ const view = viewRef.current;
233
+ const positionMap = positionMapRef.current;
234
+ if (!view || !positionMap) {
235
+ return [];
236
+ }
237
+
238
+ const rawResults = performSearch(view.state, query, options).slice(
239
+ 0,
240
+ options.limit ?? Number.POSITIVE_INFINITY,
241
+ );
242
+ view.dispatch(
243
+ view.state.tr.setMeta(searchPluginKey, {
244
+ results: rawResults,
245
+ highlightColor: DEFAULT_SEARCH_HIGHLIGHT_COLOR,
246
+ }),
247
+ );
248
+
249
+ const activeResultIndex = getActiveSearchResultIndex(
250
+ rawResults,
251
+ (position) => positionMap.pmToRuntime(position),
252
+ snapshot.selection,
253
+ );
254
+ const plainText = snapshot.surface?.plainText ?? "";
255
+ return rawResults.map((result, index) => {
256
+ const runtimeFrom = positionMap.pmToRuntime(result.from);
257
+ const runtimeTo = positionMap.pmToRuntime(result.to);
258
+ return {
259
+ resultId: `search-result-${index}`,
260
+ anchor: {
261
+ kind: "range",
262
+ from: runtimeFrom,
263
+ to: runtimeTo,
264
+ assoc: {
265
+ start: -1,
266
+ end: 1,
267
+ },
268
+ },
269
+ excerpt: createSearchExcerpt(plainText, runtimeFrom, runtimeTo),
270
+ isActive: index === activeResultIndex,
271
+ };
272
+ });
273
+ }
274
+
275
+ function clearLiveSearch(): void {
276
+ const view = viewRef.current;
277
+ if (!view) {
278
+ return;
279
+ }
280
+
281
+ clearSearchPlugin(view.state, (tr) => {
282
+ view.dispatch(tr);
283
+ });
284
+ }
285
+
161
286
  const fontClass =
162
287
  markupDisplay === "clean"
163
288
  ? "font-[family-name:var(--font-legal-sans)]"
@@ -212,4 +337,27 @@ export function TwProseMirrorSurface(props: TwProseMirrorSurfaceProps) {
212
337
  ) : null}
213
338
  </section>
214
339
  );
340
+ });
341
+
342
+ function getActiveSearchResultIndex(
343
+ results: Array<{ from: number; to: number }>,
344
+ toRuntimePosition: (position: number) => number,
345
+ selection: SelectionSnapshot,
346
+ ): number {
347
+ if (results.length === 0) {
348
+ return -1;
349
+ }
350
+
351
+ const selectionFrom = Math.min(selection.anchor, selection.head);
352
+ const selectionTo = Math.max(selection.anchor, selection.head);
353
+ const activeIndex = results.findIndex((result) => {
354
+ const from = toRuntimePosition(result.from);
355
+ const to = toRuntimePosition(result.to);
356
+ if (selectionFrom === selectionTo) {
357
+ return selectionFrom >= from && selectionFrom <= to;
358
+ }
359
+ return selectionFrom < to && selectionTo > from;
360
+ });
361
+
362
+ return activeIndex >= 0 ? activeIndex : 0;
215
363
  }