@beyondwork/docx-react-component 1.0.12 → 1.0.14

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.
@@ -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
  }
@@ -27,6 +27,7 @@ export interface TwReviewWorkspaceProps {
27
27
  activeRevisionId?: string;
28
28
  showTrackedChanges: boolean;
29
29
  selectionPreview?: string | null;
30
+ addCommentDisabledReason?: string;
30
31
  onViewModeChange: (value: ViewMode) => void;
31
32
  onActiveRailTabChange: (value: ReviewRailTab) => void;
32
33
  onShowTrackedChangesChange: (show: boolean) => void;
@@ -92,6 +93,8 @@ export function TwReviewWorkspace(props: TwReviewWorkspaceProps) {
92
93
  <TwSelectionToolbar
93
94
  selectionPreview={props.selectionPreview}
94
95
  readOnly={snapshot.readOnly}
96
+ canAddComment={props.capabilities?.canAddComment}
97
+ disabledReason={props.addCommentDisabledReason}
95
98
  onAddComment={props.onAddComment}
96
99
  />
97
100
  </div>