@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.
- package/README.md +8 -2
- package/package.json +35 -21
- package/src/api/public-types.ts +103 -1
- package/src/core/commands/formatting-commands.ts +742 -0
- package/src/core/commands/image-commands.ts +84 -2
- package/src/core/commands/structural-helpers.ts +309 -0
- package/src/core/commands/table-structure-commands.ts +721 -0
- package/src/core/commands/text-commands.ts +166 -1
- package/src/core/state/editor-state.ts +318 -9
- package/src/formats/xlsx/io/parse-sheet.ts +177 -7
- package/src/formats/xlsx/io/parse-styles.ts +2 -0
- package/src/formats/xlsx/io/xlsx-session.ts +18 -12
- package/src/formats/xlsx/model/sheet.ts +81 -1
- package/src/formats/xlsx/model/workbook.ts +10 -6
- package/src/io/docx-session.ts +392 -22
- package/src/io/export/export-session.ts +55 -0
- package/src/io/export/serialize-footnotes.ts +5 -20
- package/src/io/export/serialize-headers-footers.ts +5 -31
- package/src/io/export/serialize-main-document.ts +78 -5
- package/src/io/normalize/normalize-text.ts +90 -1
- package/src/io/ooxml/parse-footnotes.ts +68 -5
- package/src/io/ooxml/parse-headers-footers.ts +67 -9
- package/src/io/ooxml/parse-main-document.ts +169 -6
- package/src/io/opc/package-reader.ts +3 -3
- package/src/io/source-package-provenance.ts +241 -0
- package/src/model/canonical-document.ts +450 -2
- package/src/model/cds-1.0.0.ts +5 -2
- package/src/model/snapshot.ts +190 -19
- package/src/preservation/package-preservation.ts +0 -7
- package/src/runtime/document-runtime.ts +7 -1
- package/src/runtime/read-only-diagnostics-runtime.ts +1 -1
- package/src/runtime/surface-projection.ts +200 -17
- package/src/runtime/table-commands.ts +79 -0
- package/src/runtime/table-schema.ts +9 -0
- package/src/ui/WordReviewEditor.tsx +708 -16
- package/src/ui-tailwind/editor-surface/pm-schema.ts +121 -5
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +73 -7
- package/src/ui-tailwind/editor-surface/search-plugin.ts +76 -16
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +162 -14
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
46
|
+
let pmSelection: Selection;
|
|
47
47
|
try {
|
|
48
|
-
pmSelection = TextSelection.
|
|
48
|
+
pmSelection = TextSelection.between(doc.resolve(pmAnchor), doc.resolve(pmHead));
|
|
49
49
|
} catch {
|
|
50
|
-
// If the
|
|
51
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
}
|