@beyondwork/docx-react-component 1.0.18 → 1.0.20
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 +24 -34
- package/src/api/README.md +5 -1
- package/src/api/public-types.ts +710 -4
- package/src/api/session-state.ts +60 -0
- package/src/core/commands/formatting-commands.ts +2 -1
- package/src/core/commands/image-commands.ts +147 -0
- package/src/core/commands/index.ts +19 -3
- package/src/core/commands/list-commands.ts +231 -36
- package/src/core/commands/paragraph-layout-commands.ts +339 -0
- package/src/core/commands/section-layout-commands.ts +680 -0
- package/src/core/commands/style-commands.ts +262 -0
- package/src/core/search/search-text.ts +357 -0
- package/src/core/selection/mapping.ts +41 -0
- package/src/core/state/editor-state.ts +4 -1
- package/src/index.ts +51 -0
- package/src/io/docx-session.ts +623 -56
- package/src/io/export/serialize-comments.ts +104 -34
- package/src/io/export/serialize-footnotes.ts +198 -1
- package/src/io/export/serialize-headers-footers.ts +203 -10
- package/src/io/export/serialize-main-document.ts +285 -8
- package/src/io/export/serialize-numbering.ts +28 -7
- package/src/io/export/split-review-boundaries.ts +181 -19
- package/src/io/normalize/normalize-text.ts +144 -32
- package/src/io/ooxml/highlight-colors.ts +39 -0
- package/src/io/ooxml/numbering-sentinels.ts +44 -0
- package/src/io/ooxml/parse-comments.ts +85 -19
- package/src/io/ooxml/parse-fields.ts +396 -0
- package/src/io/ooxml/parse-footnotes.ts +452 -22
- package/src/io/ooxml/parse-headers-footers.ts +657 -29
- package/src/io/ooxml/parse-inline-media.ts +30 -0
- package/src/io/ooxml/parse-main-document.ts +807 -20
- package/src/io/ooxml/parse-numbering.ts +7 -0
- package/src/io/ooxml/parse-revisions.ts +317 -38
- package/src/io/ooxml/parse-settings.ts +184 -0
- package/src/io/ooxml/parse-shapes.ts +25 -0
- package/src/io/ooxml/parse-styles.ts +463 -0
- package/src/io/ooxml/parse-theme.ts +32 -0
- package/src/legal/bookmarks.ts +44 -0
- package/src/legal/cross-references.ts +59 -1
- package/src/model/canonical-document.ts +250 -4
- package/src/model/cds-1.0.0.ts +13 -0
- package/src/model/snapshot.ts +87 -2
- package/src/review/store/revision-store.ts +6 -0
- package/src/review/store/revision-types.ts +1 -0
- package/src/runtime/document-layout.ts +332 -0
- package/src/runtime/document-navigation.ts +603 -0
- package/src/runtime/document-runtime.ts +1754 -78
- package/src/runtime/document-search.ts +145 -0
- package/src/runtime/numbering-prefix.ts +47 -26
- package/src/runtime/page-layout-estimation.ts +212 -0
- package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
- package/src/runtime/session-capabilities.ts +35 -3
- package/src/runtime/story-context.ts +164 -0
- package/src/runtime/story-targeting.ts +162 -0
- package/src/runtime/surface-projection.ts +324 -36
- package/src/runtime/table-schema.ts +89 -7
- package/src/runtime/view-state.ts +477 -0
- package/src/runtime/workflow-markup.ts +349 -0
- package/src/ui/WordReviewEditor.tsx +2469 -1344
- package/src/ui/browser-export.ts +52 -0
- package/src/ui/editor-command-bag.ts +120 -0
- package/src/ui/editor-runtime-boundary.ts +1422 -0
- package/src/ui/editor-shell-view.tsx +134 -0
- package/src/ui/editor-surface-controller.tsx +51 -0
- package/src/ui/headless/preserve-editor-selection.ts +5 -0
- package/src/ui/headless/revision-decoration-model.ts +4 -4
- package/src/ui/headless/selection-helpers.ts +20 -0
- package/src/ui/headless/selection-toolbar-model.ts +22 -0
- package/src/ui/headless/use-editor-keyboard.ts +6 -1
- package/src/ui/runtime-snapshot-selectors.ts +197 -0
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
- package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
- package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
- package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
- package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
- package/src/ui-tailwind/index.ts +2 -1
- package/src/ui-tailwind/page-chrome-model.ts +27 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
- package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
- package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
- package/src/ui-tailwind/theme/editor-theme.css +127 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
- package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
- package/src/validation/compatibility-engine.ts +119 -24
- package/src/validation/compatibility-report.ts +1 -0
- package/src/validation/diagnostics.ts +1 -0
- package/src/validation/docx-comment-proof.ts +707 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, {
|
|
2
2
|
forwardRef,
|
|
3
3
|
type FocusEventHandler,
|
|
4
|
+
useCallback,
|
|
4
5
|
useEffect,
|
|
5
6
|
useImperativeHandle,
|
|
6
7
|
useMemo,
|
|
@@ -9,12 +10,16 @@ import React, {
|
|
|
9
10
|
import { EditorView } from "prosemirror-view";
|
|
10
11
|
|
|
11
12
|
import type {
|
|
13
|
+
DocumentNavigationSnapshot,
|
|
12
14
|
EditorUser,
|
|
13
15
|
RuntimeRenderSnapshot,
|
|
14
16
|
SearchOptions,
|
|
15
17
|
SearchResultSnapshot,
|
|
16
18
|
SelectionSnapshot,
|
|
19
|
+
WorkflowScope,
|
|
17
20
|
} from "../../api/public-types";
|
|
21
|
+
import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
|
|
22
|
+
import { searchDocument } from "../../runtime/document-search.ts";
|
|
18
23
|
import {
|
|
19
24
|
getTableSelectionDescriptor,
|
|
20
25
|
type TableSelectionDescriptor,
|
|
@@ -24,22 +29,37 @@ import {
|
|
|
24
29
|
type MarkupDisplay,
|
|
25
30
|
} from "../../ui/headless/comment-decoration-model";
|
|
26
31
|
import { createRevisionDecorationModel } from "../../ui/headless/revision-decoration-model";
|
|
27
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
createPMSelectionFromSnapshot,
|
|
34
|
+
createPMStateFromSnapshot,
|
|
35
|
+
} from "./pm-state-from-snapshot";
|
|
28
36
|
import {
|
|
29
37
|
createCommandBridgePlugins,
|
|
30
38
|
type CommandBridgeCallbacks,
|
|
31
39
|
} from "./pm-command-bridge";
|
|
32
40
|
import { buildDecorations } from "./pm-decorations";
|
|
41
|
+
import { createContextualInteractionPlugin } from "./pm-contextual-ui";
|
|
42
|
+
import {
|
|
43
|
+
finishPerfProbe,
|
|
44
|
+
incrementInvalidationCounter,
|
|
45
|
+
recordPerfSample,
|
|
46
|
+
startPerfProbe,
|
|
47
|
+
} from "./perf-probe";
|
|
33
48
|
import type { PositionMap } from "./pm-position-map";
|
|
34
49
|
import {
|
|
35
50
|
clearSearch as clearSearchPlugin,
|
|
36
|
-
createSearchExcerpt,
|
|
37
51
|
createSearchPlugin,
|
|
38
52
|
DEFAULT_SEARCH_HIGHLIGHT_COLOR,
|
|
39
53
|
performSearch,
|
|
40
54
|
searchPluginKey,
|
|
41
55
|
} from "./search-plugin";
|
|
56
|
+
import {
|
|
57
|
+
createSurfaceDecorationKey,
|
|
58
|
+
createSurfaceDocumentBuildKey,
|
|
59
|
+
} from "./surface-build-keys";
|
|
42
60
|
import { tableNodeViews } from "./tw-table-node-view";
|
|
61
|
+
import type { SelectionToolbarAnchor } from "../../ui/headless/selection-toolbar-model";
|
|
62
|
+
import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
|
|
43
63
|
|
|
44
64
|
/**
|
|
45
65
|
* Same props interface as the legacy TwEditorSurface — drop-in replacement.
|
|
@@ -47,10 +67,14 @@ import { tableNodeViews } from "./tw-table-node-view";
|
|
|
47
67
|
export interface TwProseMirrorSurfaceProps {
|
|
48
68
|
currentUser: EditorUser;
|
|
49
69
|
snapshot: RuntimeRenderSnapshot;
|
|
70
|
+
canonicalDocument: CanonicalDocumentEnvelope;
|
|
71
|
+
documentNavigation: DocumentNavigationSnapshot;
|
|
50
72
|
reviewMode: "editing" | "review";
|
|
51
73
|
markupDisplay: MarkupDisplay;
|
|
52
74
|
activeRevisionId?: string;
|
|
53
75
|
showTrackedChanges?: boolean;
|
|
76
|
+
/** When true, the surface renders inside the page workspace (vs canvas). */
|
|
77
|
+
isPageWorkspace?: boolean;
|
|
54
78
|
onFocus: FocusEventHandler<HTMLDivElement>;
|
|
55
79
|
onBlur: FocusEventHandler<HTMLDivElement>;
|
|
56
80
|
onSelectionChange?: (selection: SelectionSnapshot) => void;
|
|
@@ -58,10 +82,14 @@ export interface TwProseMirrorSurfaceProps {
|
|
|
58
82
|
onDeleteBackward?: () => void;
|
|
59
83
|
onDeleteForward?: () => void;
|
|
60
84
|
onInsertTab?: () => void;
|
|
85
|
+
onOutdentTab?: () => void;
|
|
61
86
|
onInsertHardBreak?: () => void;
|
|
62
87
|
onSplitParagraph?: () => void;
|
|
63
88
|
onCommentActivated?: (commentId: string) => void;
|
|
64
89
|
onRevisionActivated?: (revisionId: string) => void;
|
|
90
|
+
onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
|
|
91
|
+
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
92
|
+
workflowScopes?: readonly WorkflowScope[];
|
|
65
93
|
}
|
|
66
94
|
|
|
67
95
|
export interface TwProseMirrorSurfaceRef {
|
|
@@ -82,6 +110,17 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
82
110
|
onBlur,
|
|
83
111
|
} = props;
|
|
84
112
|
const surface = snapshot.surface;
|
|
113
|
+
const mediaPreviewKey = useMemo(
|
|
114
|
+
() =>
|
|
115
|
+
Object.entries(props.mediaPreviews ?? {})
|
|
116
|
+
.sort(([leftId], [rightId]) => leftId.localeCompare(rightId))
|
|
117
|
+
.map(
|
|
118
|
+
([mediaId, preview]) =>
|
|
119
|
+
`${mediaId}:${preview.widthEmu ?? ""}:${preview.heightEmu ?? ""}:${preview.src}`,
|
|
120
|
+
)
|
|
121
|
+
.join("|"),
|
|
122
|
+
[props.mediaPreviews],
|
|
123
|
+
);
|
|
85
124
|
|
|
86
125
|
const canEdit = Boolean(
|
|
87
126
|
surface && snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError,
|
|
@@ -92,19 +131,44 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
92
131
|
const positionMapRef = useRef<PositionMap | null>(null);
|
|
93
132
|
const callbacksRef = useRef<CommandBridgeCallbacks | null>(null);
|
|
94
133
|
const activeSearchRef = useRef<{ query: string; options: SearchOptions } | null>(null);
|
|
134
|
+
const pendingTypingProbeRef = useRef<string | null>(null);
|
|
135
|
+
const pendingSelectionProbeRef = useRef<string | null>(null);
|
|
136
|
+
const documentBuildKeyRef = useRef<string | null>(null);
|
|
137
|
+
const decorationBuildKeyRef = useRef<string | null>(null);
|
|
138
|
+
const suppressSelectionEchoRef = useRef(false);
|
|
139
|
+
const selectionToolbarFrameRef = useRef<number | null>(null);
|
|
140
|
+
const lastSelectionToolbarMeasurementRef = useRef<{
|
|
141
|
+
key: string | null;
|
|
142
|
+
anchor: SelectionToolbarAnchor | null;
|
|
143
|
+
}>({
|
|
144
|
+
key: null,
|
|
145
|
+
anchor: null,
|
|
146
|
+
});
|
|
95
147
|
|
|
96
148
|
// Keep callbacks ref up to date (avoids stale closures in PM plugins)
|
|
97
149
|
callbacksRef.current = {
|
|
98
|
-
onInsertText: (text) =>
|
|
150
|
+
onInsertText: (text) => {
|
|
151
|
+
pendingTypingProbeRef.current = startPerfProbe("typing");
|
|
152
|
+
props.onInsertText?.(text);
|
|
153
|
+
},
|
|
99
154
|
onDeleteBackward: () => props.onDeleteBackward?.(),
|
|
100
155
|
onDeleteForward: () => props.onDeleteForward?.(),
|
|
101
156
|
onSplitParagraph: () => props.onSplitParagraph?.(),
|
|
102
157
|
onInsertHardBreak: () => props.onInsertHardBreak?.(),
|
|
103
158
|
onInsertTab: () => props.onInsertTab?.(),
|
|
159
|
+
onOutdentTab: () => props.onOutdentTab?.(),
|
|
104
160
|
onUndo: () => {}, // Handled by toolbar, not PM
|
|
105
161
|
onRedo: () => {}, // Handled by toolbar, not PM
|
|
106
|
-
onSelectionChange: (sel) =>
|
|
162
|
+
onSelectionChange: (sel) => {
|
|
163
|
+
pendingSelectionProbeRef.current = startPerfProbe("selection");
|
|
164
|
+
props.onSelectionChange?.(
|
|
165
|
+
snapshot.activeStory.kind === "main"
|
|
166
|
+
? sel
|
|
167
|
+
: { ...sel, storyTarget: snapshot.activeStory },
|
|
168
|
+
);
|
|
169
|
+
},
|
|
107
170
|
getPositionMap: () => positionMapRef.current,
|
|
171
|
+
isSelectionSyncSuppressed: () => suppressSelectionEchoRef.current,
|
|
108
172
|
};
|
|
109
173
|
|
|
110
174
|
// Comment/revision decoration models
|
|
@@ -119,6 +183,34 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
119
183
|
() => createRevisionDecorationModel(snapshot.trackedChanges, props.activeRevisionId),
|
|
120
184
|
[snapshot.trackedChanges, props.activeRevisionId],
|
|
121
185
|
);
|
|
186
|
+
const documentBuildKey = useMemo(
|
|
187
|
+
() =>
|
|
188
|
+
createSurfaceDocumentBuildKey({
|
|
189
|
+
surface,
|
|
190
|
+
activeStory: snapshot.activeStory,
|
|
191
|
+
mediaPreviewKey,
|
|
192
|
+
}),
|
|
193
|
+
[mediaPreviewKey, snapshot.activeStory, surface],
|
|
194
|
+
);
|
|
195
|
+
const decorationBuildKey = useMemo(
|
|
196
|
+
() =>
|
|
197
|
+
createSurfaceDecorationKey({
|
|
198
|
+
markupDisplay,
|
|
199
|
+
showTrackedChanges,
|
|
200
|
+
canEdit,
|
|
201
|
+
activeCommentId: snapshot.comments.activeCommentId,
|
|
202
|
+
activeRevisionId: props.activeRevisionId,
|
|
203
|
+
workflowScopeSignature: JSON.stringify(props.workflowScopes ?? []),
|
|
204
|
+
}),
|
|
205
|
+
[
|
|
206
|
+
canEdit,
|
|
207
|
+
markupDisplay,
|
|
208
|
+
props.activeRevisionId,
|
|
209
|
+
props.workflowScopes,
|
|
210
|
+
showTrackedChanges,
|
|
211
|
+
snapshot.comments.activeCommentId,
|
|
212
|
+
],
|
|
213
|
+
);
|
|
122
214
|
|
|
123
215
|
// Create PM plugins (stable across renders — callbacks accessed via ref)
|
|
124
216
|
const plugins = useMemo(() => {
|
|
@@ -130,28 +222,68 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
130
222
|
onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
|
|
131
223
|
onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
|
|
132
224
|
onInsertTab: () => callbacksRef.current?.onInsertTab(),
|
|
225
|
+
onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
|
|
133
226
|
onUndo: () => callbacksRef.current?.onUndo(),
|
|
134
227
|
onRedo: () => callbacksRef.current?.onRedo(),
|
|
135
228
|
onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
|
|
136
229
|
getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
|
|
230
|
+
isSelectionSyncSuppressed: () =>
|
|
231
|
+
callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
|
|
232
|
+
}),
|
|
233
|
+
createContextualInteractionPlugin({
|
|
234
|
+
onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
|
|
235
|
+
onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
|
|
137
236
|
}),
|
|
138
237
|
createSearchPlugin(),
|
|
139
238
|
];
|
|
140
|
-
}, []);
|
|
239
|
+
}, [props.onCommentActivated, props.onRevisionActivated]);
|
|
141
240
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
241
|
+
const applyDecorationProps = useCallback(
|
|
242
|
+
(view: EditorView, positionMap: PositionMap): void => {
|
|
243
|
+
const decorations = buildDecorations(
|
|
244
|
+
view.state.doc,
|
|
245
|
+
positionMap,
|
|
246
|
+
commentModel,
|
|
247
|
+
revisionModel,
|
|
248
|
+
markupDisplay,
|
|
249
|
+
showTrackedChanges,
|
|
250
|
+
props.workflowScopes,
|
|
251
|
+
snapshot.activeStory,
|
|
252
|
+
);
|
|
253
|
+
view.setProps({
|
|
254
|
+
editable: () => canEdit,
|
|
255
|
+
decorations: () => decorations,
|
|
256
|
+
});
|
|
257
|
+
decorationBuildKeyRef.current = decorationBuildKey;
|
|
258
|
+
recordPerfSample("pm.decorations");
|
|
259
|
+
incrementInvalidationCounter("pm.laneB.decorationUpdates");
|
|
260
|
+
},
|
|
261
|
+
[
|
|
262
|
+
canEdit,
|
|
263
|
+
commentModel,
|
|
264
|
+
decorationBuildKey,
|
|
265
|
+
markupDisplay,
|
|
266
|
+
revisionModel,
|
|
267
|
+
showTrackedChanges,
|
|
268
|
+
props.workflowScopes,
|
|
269
|
+
],
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Create or update the PM document only when the structural key changes.
|
|
145
273
|
useEffect(() => {
|
|
146
274
|
if (!mountRef.current || !surface) return;
|
|
147
275
|
|
|
276
|
+
if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
148
280
|
const { state, positionMap } = createPMStateFromSnapshot(
|
|
149
281
|
surface,
|
|
150
282
|
snapshot.selection,
|
|
151
283
|
plugins,
|
|
284
|
+
props.mediaPreviews,
|
|
152
285
|
);
|
|
153
286
|
positionMapRef.current = positionMap;
|
|
154
|
-
|
|
155
287
|
const decorations = buildDecorations(
|
|
156
288
|
state.doc,
|
|
157
289
|
positionMap,
|
|
@@ -159,7 +291,11 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
159
291
|
revisionModel,
|
|
160
292
|
markupDisplay,
|
|
161
293
|
showTrackedChanges,
|
|
294
|
+
props.workflowScopes,
|
|
295
|
+
snapshot.activeStory,
|
|
162
296
|
);
|
|
297
|
+
recordPerfSample("pm.rebuild");
|
|
298
|
+
incrementInvalidationCounter("pm.laneA.rebuilds");
|
|
163
299
|
|
|
164
300
|
if (!viewRef.current) {
|
|
165
301
|
// First time surface is available — create the EditorView
|
|
@@ -174,14 +310,16 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
174
310
|
},
|
|
175
311
|
});
|
|
176
312
|
viewRef.current = view;
|
|
313
|
+
recordPerfSample("pm.mount");
|
|
177
314
|
} else {
|
|
178
|
-
|
|
179
|
-
viewRef.current.setProps({
|
|
180
|
-
editable: () => canEdit,
|
|
181
|
-
decorations: () => decorations,
|
|
182
|
-
});
|
|
315
|
+
suppressSelectionEchoRef.current = true;
|
|
183
316
|
viewRef.current.updateState(state);
|
|
317
|
+
queueMicrotask(() => {
|
|
318
|
+
suppressSelectionEchoRef.current = false;
|
|
319
|
+
});
|
|
184
320
|
}
|
|
321
|
+
documentBuildKeyRef.current = documentBuildKey;
|
|
322
|
+
applyDecorationProps(viewRef.current, positionMap);
|
|
185
323
|
|
|
186
324
|
if (activeSearchRef.current) {
|
|
187
325
|
applySearch(
|
|
@@ -189,11 +327,74 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
189
327
|
activeSearchRef.current.options,
|
|
190
328
|
);
|
|
191
329
|
}
|
|
192
|
-
|
|
330
|
+
if (pendingTypingProbeRef.current) {
|
|
331
|
+
finishPerfProbe(pendingTypingProbeRef.current);
|
|
332
|
+
pendingTypingProbeRef.current = null;
|
|
333
|
+
}
|
|
334
|
+
}, [
|
|
335
|
+
applyDecorationProps,
|
|
336
|
+
documentBuildKey,
|
|
337
|
+
surface,
|
|
338
|
+
snapshot.selection,
|
|
339
|
+
plugins,
|
|
340
|
+
props.mediaPreviews,
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
// Update decorations and editability without rebuilding the PM document.
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
const view = viewRef.current;
|
|
346
|
+
const positionMap = positionMapRef.current;
|
|
347
|
+
if (!view || !surface || !positionMap) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (decorationBuildKeyRef.current === decorationBuildKey) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
applyDecorationProps(view, positionMap);
|
|
356
|
+
}, [applyDecorationProps, decorationBuildKey, surface]);
|
|
357
|
+
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
const view = viewRef.current;
|
|
360
|
+
const positionMap = positionMapRef.current;
|
|
361
|
+
if (!view || !surface || !positionMap) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const nextSelection = createPMSelectionFromSnapshot(
|
|
366
|
+
view.state.doc,
|
|
367
|
+
positionMap,
|
|
368
|
+
snapshot.selection,
|
|
369
|
+
);
|
|
370
|
+
if (view.state.selection.eq(nextSelection)) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
suppressSelectionEchoRef.current = true;
|
|
375
|
+
view.dispatch(view.state.tr.setSelection(nextSelection));
|
|
376
|
+
recordPerfSample("selection.sync");
|
|
377
|
+
queueMicrotask(() => {
|
|
378
|
+
suppressSelectionEchoRef.current = false;
|
|
379
|
+
});
|
|
380
|
+
}, [snapshot.selection, surface]);
|
|
381
|
+
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
if (!pendingSelectionProbeRef.current) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
finishPerfProbe(pendingSelectionProbeRef.current);
|
|
387
|
+
pendingSelectionProbeRef.current = null;
|
|
388
|
+
}, [snapshot.selection]);
|
|
193
389
|
|
|
194
390
|
// Cleanup on unmount
|
|
195
391
|
useEffect(() => {
|
|
196
392
|
return () => {
|
|
393
|
+
const win = mountRef.current?.ownerDocument.defaultView;
|
|
394
|
+
if (selectionToolbarFrameRef.current !== null && win) {
|
|
395
|
+
win.cancelAnimationFrame(selectionToolbarFrameRef.current);
|
|
396
|
+
selectionToolbarFrameRef.current = null;
|
|
397
|
+
}
|
|
197
398
|
viewRef.current?.destroy();
|
|
198
399
|
viewRef.current = null;
|
|
199
400
|
};
|
|
@@ -230,45 +431,77 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
230
431
|
|
|
231
432
|
function applySearch(query: string, options: SearchOptions): SearchResultSnapshot[] {
|
|
232
433
|
const view = viewRef.current;
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
434
|
+
const hiddenDeletionRanges =
|
|
435
|
+
markupDisplay === "clean"
|
|
436
|
+
? snapshot.trackedChanges.revisions
|
|
437
|
+
.filter(
|
|
438
|
+
(
|
|
439
|
+
revision,
|
|
440
|
+
): revision is typeof revision & {
|
|
441
|
+
anchor: Extract<typeof revision.anchor, { kind: "range" }>;
|
|
442
|
+
} =>
|
|
443
|
+
revision.kind === "deletion" &&
|
|
444
|
+
revision.status === "active" &&
|
|
445
|
+
revision.anchor.kind === "range",
|
|
446
|
+
)
|
|
447
|
+
.map((revision) => ({
|
|
448
|
+
from: revision.anchor.from,
|
|
449
|
+
to: revision.anchor.to,
|
|
450
|
+
}))
|
|
451
|
+
: [];
|
|
452
|
+
if (view) {
|
|
453
|
+
const rawResults = performSearch(view.state, query, options)
|
|
454
|
+
.filter((result) => {
|
|
455
|
+
if (hiddenDeletionRanges.length === 0) {
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
const positionMap = positionMapRef.current;
|
|
459
|
+
if (!positionMap) {
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
const runtimeFrom = positionMap.pmToRuntime(result.from);
|
|
463
|
+
const runtimeTo = positionMap.pmToRuntime(result.to);
|
|
464
|
+
return !hiddenDeletionRanges.some(
|
|
465
|
+
(range) => runtimeFrom < range.to && runtimeTo > range.from,
|
|
466
|
+
);
|
|
467
|
+
})
|
|
468
|
+
.slice(0, options.limit ?? Number.POSITIVE_INFINITY);
|
|
469
|
+
view.dispatch(
|
|
470
|
+
view.state.tr.setMeta(searchPluginKey, {
|
|
471
|
+
results: rawResults,
|
|
472
|
+
highlightColor: DEFAULT_SEARCH_HIGHLIGHT_COLOR,
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
236
475
|
}
|
|
237
476
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
477
|
+
return filterHiddenDeletionSearchResults(
|
|
478
|
+
searchDocument(
|
|
479
|
+
props.canonicalDocument,
|
|
480
|
+
snapshot.selection,
|
|
481
|
+
snapshot.activeStory,
|
|
482
|
+
props.documentNavigation,
|
|
483
|
+
query,
|
|
484
|
+
options,
|
|
485
|
+
),
|
|
486
|
+
hiddenDeletionRanges,
|
|
247
487
|
);
|
|
488
|
+
}
|
|
248
489
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
assoc: {
|
|
265
|
-
start: -1,
|
|
266
|
-
end: 1,
|
|
267
|
-
},
|
|
268
|
-
},
|
|
269
|
-
excerpt: createSearchExcerpt(plainText, runtimeFrom, runtimeTo),
|
|
270
|
-
isActive: index === activeResultIndex,
|
|
271
|
-
};
|
|
490
|
+
function filterHiddenDeletionSearchResults(
|
|
491
|
+
results: SearchResultSnapshot[],
|
|
492
|
+
hiddenRanges: Array<{ from: number; to: number }>,
|
|
493
|
+
): SearchResultSnapshot[] {
|
|
494
|
+
if (hiddenRanges.length === 0) {
|
|
495
|
+
return results;
|
|
496
|
+
}
|
|
497
|
+
return results.filter((result) => {
|
|
498
|
+
const anchor = result.anchor;
|
|
499
|
+
if (anchor.kind !== "range") {
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
return !hiddenRanges.some(
|
|
503
|
+
(range) => anchor.from < range.to && anchor.to > range.from,
|
|
504
|
+
);
|
|
272
505
|
});
|
|
273
506
|
}
|
|
274
507
|
|
|
@@ -288,37 +521,192 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
288
521
|
? "font-[family-name:var(--font-legal-sans)]"
|
|
289
522
|
: "font-[family-name:var(--font-legal-serif)]";
|
|
290
523
|
|
|
524
|
+
// Story focus indicator — runtime-backed, not DOM-only
|
|
525
|
+
const storyKind = snapshot.activeStory.kind;
|
|
526
|
+
const storyFocusAttr = storyKind !== "main" ? storyKind : undefined;
|
|
527
|
+
|
|
528
|
+
// Table focus cue — add subtle ring when selection head is inside a table block
|
|
529
|
+
const tableFocusClass = (() => {
|
|
530
|
+
if (!surface || !snapshot.selection) return "";
|
|
531
|
+
const head = snapshot.selection.head;
|
|
532
|
+
const inTable = surface.blocks.some(
|
|
533
|
+
(b) => b.kind === "table" && head >= b.from && head <= b.to,
|
|
534
|
+
);
|
|
535
|
+
return inTable ? "prosemirror-table-focus" : "";
|
|
536
|
+
})();
|
|
537
|
+
|
|
538
|
+
const workspaceLabel = props.isPageWorkspace ? "Document page" : "Document canvas";
|
|
539
|
+
|
|
540
|
+
const selectionToolbarMeasurementKey = useMemo(
|
|
541
|
+
() => buildSelectionToolbarMeasurementKey(snapshot.selection, snapshot.activeStory),
|
|
542
|
+
[snapshot.activeStory, snapshot.selection],
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const emitSelectionToolbarAnchor = useCallback((): void => {
|
|
546
|
+
const callback = props.onSelectionToolbarAnchorChange;
|
|
547
|
+
if (!callback) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const nextAnchor = measureSelectionToolbarAnchor();
|
|
552
|
+
const previous = lastSelectionToolbarMeasurementRef.current;
|
|
553
|
+
if (
|
|
554
|
+
previous.key === selectionToolbarMeasurementKey &&
|
|
555
|
+
selectionToolbarAnchorsEqual(previous.anchor, nextAnchor)
|
|
556
|
+
) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
lastSelectionToolbarMeasurementRef.current = {
|
|
561
|
+
key: selectionToolbarMeasurementKey,
|
|
562
|
+
anchor: nextAnchor,
|
|
563
|
+
};
|
|
564
|
+
callback(nextAnchor);
|
|
565
|
+
}, [
|
|
566
|
+
props.onSelectionToolbarAnchorChange,
|
|
567
|
+
selectionToolbarMeasurementKey,
|
|
568
|
+
snapshot.activeStory,
|
|
569
|
+
snapshot.selection,
|
|
570
|
+
]);
|
|
571
|
+
|
|
572
|
+
const scheduleSelectionToolbarAnchorUpdate = useCallback((): void => {
|
|
573
|
+
const callback = props.onSelectionToolbarAnchorChange;
|
|
574
|
+
const mount = mountRef.current;
|
|
575
|
+
const win = mount?.ownerDocument.defaultView;
|
|
576
|
+
if (!callback || !win) {
|
|
577
|
+
emitSelectionToolbarAnchor();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (selectionToolbarFrameRef.current !== null) {
|
|
582
|
+
win.cancelAnimationFrame(selectionToolbarFrameRef.current);
|
|
583
|
+
selectionToolbarFrameRef.current = null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
selectionToolbarFrameRef.current = win.requestAnimationFrame(() => {
|
|
587
|
+
selectionToolbarFrameRef.current = null;
|
|
588
|
+
emitSelectionToolbarAnchor();
|
|
589
|
+
});
|
|
590
|
+
}, [emitSelectionToolbarAnchor, props.onSelectionToolbarAnchorChange]);
|
|
591
|
+
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
scheduleSelectionToolbarAnchorUpdate();
|
|
594
|
+
}, [
|
|
595
|
+
scheduleSelectionToolbarAnchorUpdate,
|
|
596
|
+
snapshot.revisionToken,
|
|
597
|
+
snapshot.selection,
|
|
598
|
+
snapshot.surface,
|
|
599
|
+
props.isPageWorkspace,
|
|
600
|
+
]);
|
|
601
|
+
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
const mount = mountRef.current;
|
|
604
|
+
const callback = props.onSelectionToolbarAnchorChange;
|
|
605
|
+
if (!mount || !callback) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const updateAnchor = () => {
|
|
610
|
+
scheduleSelectionToolbarAnchorUpdate();
|
|
611
|
+
};
|
|
612
|
+
const scrollRoot = mount.closest<HTMLElement>("[data-wre-scroll-root='true']");
|
|
613
|
+
const win = mount.ownerDocument.defaultView;
|
|
614
|
+
const resizeObserver =
|
|
615
|
+
typeof ResizeObserver !== "undefined"
|
|
616
|
+
? new ResizeObserver(() => {
|
|
617
|
+
updateAnchor();
|
|
618
|
+
})
|
|
619
|
+
: null;
|
|
620
|
+
|
|
621
|
+
updateAnchor();
|
|
622
|
+
scrollRoot?.addEventListener("scroll", updateAnchor, { passive: true });
|
|
623
|
+
win?.addEventListener("resize", updateAnchor);
|
|
624
|
+
resizeObserver?.observe(mount);
|
|
625
|
+
if (scrollRoot) {
|
|
626
|
+
resizeObserver?.observe(scrollRoot);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return () => {
|
|
630
|
+
scrollRoot?.removeEventListener("scroll", updateAnchor);
|
|
631
|
+
win?.removeEventListener("resize", updateAnchor);
|
|
632
|
+
resizeObserver?.disconnect();
|
|
633
|
+
};
|
|
634
|
+
}, [
|
|
635
|
+
props.onSelectionToolbarAnchorChange,
|
|
636
|
+
scheduleSelectionToolbarAnchorUpdate,
|
|
637
|
+
snapshot.revisionToken,
|
|
638
|
+
snapshot.selection,
|
|
639
|
+
]);
|
|
640
|
+
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
return () => {
|
|
643
|
+
lastSelectionToolbarMeasurementRef.current = {
|
|
644
|
+
key: null,
|
|
645
|
+
anchor: null,
|
|
646
|
+
};
|
|
647
|
+
props.onSelectionToolbarAnchorChange?.(null);
|
|
648
|
+
};
|
|
649
|
+
}, [props.onSelectionToolbarAnchorChange]);
|
|
650
|
+
|
|
291
651
|
return (
|
|
292
|
-
<section
|
|
652
|
+
<section
|
|
653
|
+
aria-label={workspaceLabel}
|
|
654
|
+
className="min-w-0"
|
|
655
|
+
data-active-story={storyFocusAttr}
|
|
656
|
+
data-workspace={props.isPageWorkspace ? "page" : "canvas"}
|
|
657
|
+
>
|
|
293
658
|
{/* ProseMirror mount point — document content including headings is editable */}
|
|
294
659
|
{surface ? (
|
|
295
660
|
<div
|
|
296
661
|
ref={mountRef}
|
|
297
662
|
role="textbox"
|
|
663
|
+
tabIndex={0}
|
|
298
664
|
aria-multiline="true"
|
|
299
|
-
className={`px-12 py-10 ${fontClass} text-[15px] text-primary leading-relaxed prosemirror-surface outline-none`}
|
|
300
|
-
onFocus={
|
|
665
|
+
className={`px-12 py-10 ${fontClass} text-[15px] text-primary leading-relaxed prosemirror-surface outline-none ${tableFocusClass}`}
|
|
666
|
+
onFocus={(event) => {
|
|
667
|
+
onFocus(event);
|
|
668
|
+
if (event.target === event.currentTarget) {
|
|
669
|
+
viewRef.current?.focus();
|
|
670
|
+
}
|
|
671
|
+
}}
|
|
301
672
|
onBlur={onBlur as unknown as React.FocusEventHandler<HTMLDivElement>}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const commentEl = target.closest?.("[data-comment-id]");
|
|
306
|
-
if (commentEl) {
|
|
307
|
-
const commentId = commentEl.getAttribute("data-comment-id");
|
|
308
|
-
if (commentId) {
|
|
309
|
-
props.onCommentActivated?.(commentId);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
673
|
+
onKeyDown={(event) => {
|
|
674
|
+
if (event.target !== event.currentTarget) {
|
|
675
|
+
return;
|
|
312
676
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
props.
|
|
318
|
-
|
|
677
|
+
|
|
678
|
+
switch (event.key) {
|
|
679
|
+
case "Backspace":
|
|
680
|
+
event.preventDefault();
|
|
681
|
+
props.onDeleteBackward?.();
|
|
682
|
+
return;
|
|
683
|
+
case "Delete":
|
|
684
|
+
event.preventDefault();
|
|
685
|
+
props.onDeleteForward?.();
|
|
686
|
+
return;
|
|
687
|
+
case "Enter":
|
|
688
|
+
event.preventDefault();
|
|
689
|
+
if (event.shiftKey) {
|
|
690
|
+
props.onInsertHardBreak?.();
|
|
691
|
+
} else {
|
|
692
|
+
props.onSplitParagraph?.();
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
case "Tab":
|
|
696
|
+
event.preventDefault();
|
|
697
|
+
if (event.shiftKey) {
|
|
698
|
+
props.onOutdentTab?.();
|
|
699
|
+
} else {
|
|
700
|
+
props.onInsertTab?.();
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
default:
|
|
704
|
+
return;
|
|
319
705
|
}
|
|
320
706
|
}}
|
|
321
707
|
aria-label="Document surface"
|
|
708
|
+
data-wre-document-surface="true"
|
|
709
|
+
data-story-focus={storyFocusAttr}
|
|
322
710
|
/>
|
|
323
711
|
) : (
|
|
324
712
|
<div className="px-12 pb-10">
|
|
@@ -337,27 +725,82 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
337
725
|
) : null}
|
|
338
726
|
</section>
|
|
339
727
|
);
|
|
728
|
+
|
|
729
|
+
function measureSelectionToolbarAnchor(): SelectionToolbarAnchor | null {
|
|
730
|
+
const callback = props.onSelectionToolbarAnchorChange;
|
|
731
|
+
const view = viewRef.current;
|
|
732
|
+
const mount = mountRef.current;
|
|
733
|
+
const positionMap = positionMapRef.current;
|
|
734
|
+
const range = snapshot.selection.activeRange;
|
|
735
|
+
|
|
736
|
+
if (!callback || !view || !mount || !positionMap || snapshot.selection.isCollapsed || range.kind !== "range") {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const rootRect = mount.getBoundingClientRect();
|
|
741
|
+
if (rootRect.width <= 0 || rootRect.height <= 0) {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
const pmFrom = positionMap.runtimeToPm(range.from);
|
|
747
|
+
const pmTo = positionMap.runtimeToPm(range.to);
|
|
748
|
+
const startRect = view.coordsAtPos(pmFrom);
|
|
749
|
+
const endRect = view.coordsAtPos(pmTo);
|
|
750
|
+
const left = Math.max(rootRect.left, Math.min(startRect.left, endRect.left));
|
|
751
|
+
const right = Math.min(rootRect.right, Math.max(startRect.right, endRect.right));
|
|
752
|
+
const top = Math.max(rootRect.top, Math.min(startRect.top, endRect.top));
|
|
753
|
+
const bottom = Math.min(rootRect.bottom, Math.max(startRect.bottom, endRect.bottom));
|
|
754
|
+
|
|
755
|
+
if (
|
|
756
|
+
!Number.isFinite(left) ||
|
|
757
|
+
!Number.isFinite(right) ||
|
|
758
|
+
!Number.isFinite(top) ||
|
|
759
|
+
!Number.isFinite(bottom) ||
|
|
760
|
+
right <= left ||
|
|
761
|
+
bottom <= top ||
|
|
762
|
+
bottom < rootRect.top ||
|
|
763
|
+
top > rootRect.bottom
|
|
764
|
+
) {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return { left, right, top, bottom };
|
|
769
|
+
} catch {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
340
773
|
});
|
|
341
774
|
|
|
342
|
-
function
|
|
343
|
-
results: Array<{ from: number; to: number }>,
|
|
344
|
-
toRuntimePosition: (position: number) => number,
|
|
775
|
+
function buildSelectionToolbarMeasurementKey(
|
|
345
776
|
selection: SelectionSnapshot,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
777
|
+
activeStory: RuntimeRenderSnapshot["activeStory"],
|
|
778
|
+
): string | null {
|
|
779
|
+
if (selection.isCollapsed || selection.activeRange.kind !== "range") {
|
|
780
|
+
return null;
|
|
349
781
|
}
|
|
350
782
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const to = toRuntimePosition(result.to);
|
|
356
|
-
if (selectionFrom === selectionTo) {
|
|
357
|
-
return selectionFrom >= from && selectionFrom <= to;
|
|
358
|
-
}
|
|
359
|
-
return selectionFrom < to && selectionTo > from;
|
|
783
|
+
return JSON.stringify({
|
|
784
|
+
story: activeStory,
|
|
785
|
+
from: selection.activeRange.from,
|
|
786
|
+
to: selection.activeRange.to,
|
|
360
787
|
});
|
|
788
|
+
}
|
|
361
789
|
|
|
362
|
-
|
|
790
|
+
function selectionToolbarAnchorsEqual(
|
|
791
|
+
left: SelectionToolbarAnchor | null,
|
|
792
|
+
right: SelectionToolbarAnchor | null,
|
|
793
|
+
): boolean {
|
|
794
|
+
if (left === right) {
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
if (!left || !right) {
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
return (
|
|
801
|
+
left.left === right.left &&
|
|
802
|
+
left.right === right.right &&
|
|
803
|
+
left.top === right.top &&
|
|
804
|
+
left.bottom === right.bottom
|
|
805
|
+
);
|
|
363
806
|
}
|