@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,33 +1,93 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { forwardRef } from "react";
|
|
2
|
+
import type { FocusEventHandler } from "react";
|
|
2
3
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
3
|
-
import { MessageSquare } from "lucide-react";
|
|
4
|
+
import { Baseline, Bold, Highlighter, Italic, MessageSquare, Underline } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import type { SelectionToolbarModel } from "../../ui/headless/selection-toolbar-model";
|
|
7
|
+
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
4
8
|
|
|
5
9
|
export interface TwSelectionToolbarProps {
|
|
6
|
-
|
|
7
|
-
readOnly: boolean;
|
|
8
|
-
canAddComment?: boolean;
|
|
10
|
+
model: SelectionToolbarModel;
|
|
9
11
|
disabledReason?: string;
|
|
12
|
+
onFocusCapture?: FocusEventHandler<HTMLDivElement>;
|
|
13
|
+
onBlurCapture?: FocusEventHandler<HTMLDivElement>;
|
|
14
|
+
onToggleBold?: () => void;
|
|
15
|
+
onToggleItalic?: () => void;
|
|
16
|
+
onToggleUnderline?: () => void;
|
|
17
|
+
onSetTextColor?: (color: string) => void;
|
|
18
|
+
onSetHighlightColor?: (color: string | null) => void;
|
|
10
19
|
onAddComment?: () => void;
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
const focusRingClass =
|
|
14
23
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
|
|
15
24
|
|
|
16
|
-
export function TwSelectionToolbar(props
|
|
17
|
-
const
|
|
25
|
+
export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarProps>(function TwSelectionToolbar(props, ref) {
|
|
26
|
+
const { model } = props;
|
|
27
|
+
const addCommentDisabled = !model.canAddComment;
|
|
28
|
+
const formattingDisabled = !model.canToggleFormatting;
|
|
29
|
+
const contextLabel = summarizeSelectionContext(model);
|
|
18
30
|
const tooltipLabel = addCommentDisabled
|
|
19
31
|
? props.disabledReason ?? "Select text within one paragraph to add a DOCX comment"
|
|
20
32
|
: "Add comment";
|
|
33
|
+
|
|
21
34
|
return (
|
|
22
|
-
<div
|
|
35
|
+
<div
|
|
36
|
+
ref={ref}
|
|
37
|
+
data-testid="selection-toolbar"
|
|
38
|
+
className="inline-flex max-w-[min(24rem,calc(100vw-2rem))] items-center gap-1.5 rounded-xl border border-border/80 bg-canvas px-1.5 py-1.5 shadow-lg ring-1 ring-border/80"
|
|
39
|
+
role="toolbar"
|
|
40
|
+
aria-label="Selection actions"
|
|
41
|
+
onFocusCapture={props.onFocusCapture}
|
|
42
|
+
onBlurCapture={props.onBlurCapture}
|
|
43
|
+
>
|
|
44
|
+
<ToolbarActionButton
|
|
45
|
+
icon={<Bold className="h-3.5 w-3.5" />}
|
|
46
|
+
label="Bold selection"
|
|
47
|
+
pressed={model.boldActive}
|
|
48
|
+
disabled={formattingDisabled}
|
|
49
|
+
onClick={props.onToggleBold}
|
|
50
|
+
/>
|
|
51
|
+
<ToolbarActionButton
|
|
52
|
+
icon={<Italic className="h-3.5 w-3.5" />}
|
|
53
|
+
label="Italic selection"
|
|
54
|
+
pressed={model.italicActive}
|
|
55
|
+
disabled={formattingDisabled}
|
|
56
|
+
onClick={props.onToggleItalic}
|
|
57
|
+
/>
|
|
58
|
+
<ToolbarActionButton
|
|
59
|
+
icon={<Underline className="h-3.5 w-3.5" />}
|
|
60
|
+
label="Underline selection"
|
|
61
|
+
pressed={model.underlineActive}
|
|
62
|
+
disabled={formattingDisabled}
|
|
63
|
+
onClick={props.onToggleUnderline}
|
|
64
|
+
/>
|
|
65
|
+
<ToolbarActionButton
|
|
66
|
+
icon={<Baseline className="h-3.5 w-3.5" />}
|
|
67
|
+
label="Text color blue"
|
|
68
|
+
pressed={false}
|
|
69
|
+
disabled={formattingDisabled}
|
|
70
|
+
onClick={() => props.onSetTextColor?.("#1660a8")}
|
|
71
|
+
/>
|
|
72
|
+
<ToolbarActionButton
|
|
73
|
+
icon={<Highlighter className="h-3.5 w-3.5" />}
|
|
74
|
+
label="Highlight yellow"
|
|
75
|
+
pressed={false}
|
|
76
|
+
disabled={formattingDisabled}
|
|
77
|
+
onClick={() => props.onSetHighlightColor?.("#ffff00")}
|
|
78
|
+
/>
|
|
79
|
+
|
|
80
|
+
<div className="mx-0.5 h-4 w-px bg-border" />
|
|
81
|
+
|
|
23
82
|
<Tooltip.Root>
|
|
24
83
|
<Tooltip.Trigger asChild>
|
|
25
84
|
<button
|
|
26
85
|
type="button"
|
|
27
|
-
aria-label="
|
|
86
|
+
aria-label="Add comment from selection"
|
|
28
87
|
disabled={addCommentDisabled}
|
|
88
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
29
89
|
onClick={props.onAddComment}
|
|
30
|
-
className={`inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors text-accent hover:bg-accent-soft disabled:
|
|
90
|
+
className={`inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors text-accent hover:bg-accent-soft disabled:cursor-not-allowed disabled:opacity-30 ${focusRingClass}`}
|
|
31
91
|
>
|
|
32
92
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
33
93
|
</button>
|
|
@@ -41,10 +101,86 @@ export function TwSelectionToolbar(props: TwSelectionToolbarProps) {
|
|
|
41
101
|
</Tooltip.Content>
|
|
42
102
|
</Tooltip.Portal>
|
|
43
103
|
</Tooltip.Root>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
104
|
+
|
|
105
|
+
{model.previewText ? (
|
|
106
|
+
<>
|
|
107
|
+
<div className="mx-0.5 h-4 w-px bg-border" />
|
|
108
|
+
<span className="max-w-[8rem] truncate text-[11px] text-secondary">
|
|
109
|
+
{model.previewText}
|
|
110
|
+
</span>
|
|
111
|
+
</>
|
|
112
|
+
) : null}
|
|
113
|
+
|
|
114
|
+
{contextLabel ? (
|
|
115
|
+
<>
|
|
116
|
+
{!model.previewText ? <div className="mx-0.5 h-4 w-px bg-border" /> : null}
|
|
117
|
+
<span
|
|
118
|
+
className={`min-w-0 max-w-[11rem] truncate rounded-full px-2 py-0.5 text-[10px] font-medium tracking-[0.08em] ${
|
|
119
|
+
model.badges.some((badge) => badge.tone === "accent")
|
|
120
|
+
? "bg-accent-soft text-accent"
|
|
121
|
+
: "bg-surface text-tertiary"
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
{contextLabel}
|
|
125
|
+
</span>
|
|
126
|
+
</>
|
|
127
|
+
) : null}
|
|
48
128
|
</div>
|
|
49
129
|
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function summarizeSelectionContext(model: SelectionToolbarModel): string | null {
|
|
133
|
+
if (model.badges.length === 0) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const accentBadges = model.badges.filter((badge) => badge.tone === "accent");
|
|
138
|
+
const source = accentBadges.length > 0 ? accentBadges : model.badges;
|
|
139
|
+
const labels = source.slice(0, 2).map((badge) => badge.label.trim()).filter(Boolean);
|
|
140
|
+
if (labels.length === 0) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const summary = labels.join(" · ");
|
|
145
|
+
return summary.length > 30 ? `${summary.slice(0, 27)}...` : summary;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface ToolbarActionButtonProps {
|
|
149
|
+
icon: React.ReactNode;
|
|
150
|
+
label: string;
|
|
151
|
+
pressed: boolean;
|
|
152
|
+
disabled: boolean;
|
|
153
|
+
onClick?: () => void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ToolbarActionButton(props: ToolbarActionButtonProps) {
|
|
157
|
+
return (
|
|
158
|
+
<Tooltip.Root>
|
|
159
|
+
<Tooltip.Trigger asChild>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
aria-label={props.label}
|
|
163
|
+
aria-pressed={props.pressed}
|
|
164
|
+
disabled={props.disabled}
|
|
165
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
166
|
+
onClick={props.onClick}
|
|
167
|
+
className={`inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:opacity-30 ${
|
|
168
|
+
props.pressed
|
|
169
|
+
? "bg-accent-soft text-accent"
|
|
170
|
+
: "text-secondary hover:bg-surface"
|
|
171
|
+
} ${focusRingClass}`}
|
|
172
|
+
>
|
|
173
|
+
{props.icon}
|
|
174
|
+
</button>
|
|
175
|
+
</Tooltip.Trigger>
|
|
176
|
+
<Tooltip.Portal>
|
|
177
|
+
<Tooltip.Content
|
|
178
|
+
className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
|
|
179
|
+
sideOffset={6}
|
|
180
|
+
>
|
|
181
|
+
{props.label}
|
|
182
|
+
</Tooltip.Content>
|
|
183
|
+
</Tooltip.Portal>
|
|
184
|
+
</Tooltip.Root>
|
|
185
|
+
);
|
|
50
186
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import type { StyleCatalogSnapshot } from "../../api/public-types";
|
|
4
|
+
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
5
|
+
|
|
6
|
+
export interface TwTableContextToolbarProps {
|
|
7
|
+
disabled: boolean;
|
|
8
|
+
tableStyles: StyleCatalogSnapshot["tables"];
|
|
9
|
+
onSetTableStyle?: (styleId: string) => void;
|
|
10
|
+
onAddRowBefore?: () => void;
|
|
11
|
+
onAddRowAfter?: () => void;
|
|
12
|
+
onAddColumnBefore?: () => void;
|
|
13
|
+
onAddColumnAfter?: () => void;
|
|
14
|
+
onDeleteRow?: () => void;
|
|
15
|
+
onDeleteColumn?: () => void;
|
|
16
|
+
onMergeCells?: () => void;
|
|
17
|
+
onSplitCell?: () => void;
|
|
18
|
+
onSetCellBackground?: (color: string) => void;
|
|
19
|
+
onDeleteTable?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CELL_COLORS = [
|
|
23
|
+
"#ffffff",
|
|
24
|
+
"#f0f0ee",
|
|
25
|
+
"#dbeafe",
|
|
26
|
+
"#fef3c7",
|
|
27
|
+
"#dcfce7",
|
|
28
|
+
"#fce7f3",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
data-testid="table-context-toolbar"
|
|
35
|
+
className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-canvas px-3 py-2 shadow-sm"
|
|
36
|
+
>
|
|
37
|
+
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
38
|
+
Table
|
|
39
|
+
</span>
|
|
40
|
+
|
|
41
|
+
<select
|
|
42
|
+
aria-label="Table style"
|
|
43
|
+
className="h-8 rounded-md border border-border bg-canvas px-2 text-xs text-primary disabled:opacity-40"
|
|
44
|
+
disabled={props.disabled || props.tableStyles.length === 0 || !props.onSetTableStyle}
|
|
45
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
46
|
+
onChange={(event) => props.onSetTableStyle?.(event.target.value)}
|
|
47
|
+
defaultValue=""
|
|
48
|
+
>
|
|
49
|
+
<option value="" disabled>Table style</option>
|
|
50
|
+
{props.tableStyles.map((style) => (
|
|
51
|
+
<option key={style.styleId} value={style.styleId}>
|
|
52
|
+
{style.displayName}
|
|
53
|
+
</option>
|
|
54
|
+
))}
|
|
55
|
+
</select>
|
|
56
|
+
|
|
57
|
+
<ToolbarButton ariaLabel="Add row above" disabled={props.disabled} onClick={props.onAddRowBefore}>
|
|
58
|
+
Row above
|
|
59
|
+
</ToolbarButton>
|
|
60
|
+
<ToolbarButton ariaLabel="Add row below" disabled={props.disabled} onClick={props.onAddRowAfter}>
|
|
61
|
+
Row below
|
|
62
|
+
</ToolbarButton>
|
|
63
|
+
<ToolbarButton ariaLabel="Delete row" disabled={props.disabled} onClick={props.onDeleteRow}>
|
|
64
|
+
Delete row
|
|
65
|
+
</ToolbarButton>
|
|
66
|
+
<ToolbarButton ariaLabel="Add column left" disabled={props.disabled} onClick={props.onAddColumnBefore}>
|
|
67
|
+
Column left
|
|
68
|
+
</ToolbarButton>
|
|
69
|
+
<ToolbarButton ariaLabel="Add column right" disabled={props.disabled} onClick={props.onAddColumnAfter}>
|
|
70
|
+
Column right
|
|
71
|
+
</ToolbarButton>
|
|
72
|
+
<ToolbarButton ariaLabel="Delete column" disabled={props.disabled} onClick={props.onDeleteColumn}>
|
|
73
|
+
Delete column
|
|
74
|
+
</ToolbarButton>
|
|
75
|
+
<ToolbarButton ariaLabel="Merge cells" disabled={props.disabled} onClick={props.onMergeCells}>
|
|
76
|
+
Merge
|
|
77
|
+
</ToolbarButton>
|
|
78
|
+
<ToolbarButton ariaLabel="Split cell" disabled={props.disabled} onClick={props.onSplitCell}>
|
|
79
|
+
Split
|
|
80
|
+
</ToolbarButton>
|
|
81
|
+
|
|
82
|
+
<div className="flex items-center gap-1">
|
|
83
|
+
<span className="text-[11px] text-secondary">Fill</span>
|
|
84
|
+
{CELL_COLORS.map((color) => (
|
|
85
|
+
<button
|
|
86
|
+
key={color}
|
|
87
|
+
type="button"
|
|
88
|
+
aria-label={`Set cell fill ${color}`}
|
|
89
|
+
disabled={props.disabled || !props.onSetCellBackground}
|
|
90
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
91
|
+
onClick={() => props.onSetCellBackground?.(color)}
|
|
92
|
+
className="h-6 w-6 rounded border border-border disabled:opacity-40"
|
|
93
|
+
style={{ backgroundColor: color }}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<ToolbarButton ariaLabel="Delete table" danger disabled={props.disabled} onClick={props.onDeleteTable}>
|
|
99
|
+
Delete table
|
|
100
|
+
</ToolbarButton>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ToolbarButton(props: {
|
|
106
|
+
ariaLabel: string;
|
|
107
|
+
children: React.ReactNode;
|
|
108
|
+
danger?: boolean;
|
|
109
|
+
disabled: boolean;
|
|
110
|
+
onClick?: () => void;
|
|
111
|
+
}) {
|
|
112
|
+
return (
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
aria-label={props.ariaLabel}
|
|
116
|
+
disabled={props.disabled || !props.onClick}
|
|
117
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
118
|
+
onClick={props.onClick}
|
|
119
|
+
className={`inline-flex h-8 items-center rounded-md px-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
|
120
|
+
props.danger
|
|
121
|
+
? "text-danger hover:bg-danger/10"
|
|
122
|
+
: "text-primary hover:bg-surface"
|
|
123
|
+
}`}
|
|
124
|
+
>
|
|
125
|
+
{props.children}
|
|
126
|
+
</button>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export type PerfProbeKind =
|
|
2
|
+
| "typing"
|
|
3
|
+
| "selection"
|
|
4
|
+
| "runtime.create"
|
|
5
|
+
| "snapshot.surface"
|
|
6
|
+
| "snapshot.compatibility"
|
|
7
|
+
| "snapshot.navigation"
|
|
8
|
+
| "pm.rebuild"
|
|
9
|
+
| "pm.decorations"
|
|
10
|
+
| "pm.mount"
|
|
11
|
+
| "shell.render"
|
|
12
|
+
| "workspace.chrome"
|
|
13
|
+
| "selection.sync";
|
|
14
|
+
|
|
15
|
+
export interface PerfProbeSample {
|
|
16
|
+
token: string;
|
|
17
|
+
kind: PerfProbeKind;
|
|
18
|
+
durationMs: number;
|
|
19
|
+
recordedAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PendingProbe {
|
|
23
|
+
kind: PerfProbeKind;
|
|
24
|
+
startedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PerfProbeState {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
nextToken?: number;
|
|
30
|
+
pending?: Record<string, PendingProbe>;
|
|
31
|
+
samples?: PerfProbeSample[];
|
|
32
|
+
maxSamples?: number;
|
|
33
|
+
invalidationCounts?: Record<string, number>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PerfProbeSummary {
|
|
37
|
+
samples: PerfProbeSample[];
|
|
38
|
+
latest: Partial<Record<PerfProbeKind, PerfProbeSample | null>>;
|
|
39
|
+
invalidationCounts: Record<string, number>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
declare global {
|
|
43
|
+
interface Window {
|
|
44
|
+
__DOCX_REACT_PERF_PROBE__?: PerfProbeState;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function startPerfProbe(kind: PerfProbeKind): string | null {
|
|
49
|
+
const state = getEnabledState();
|
|
50
|
+
if (!state) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const token = `${kind}-${state.nextToken ?? 0}`;
|
|
55
|
+
state.nextToken = (state.nextToken ?? 0) + 1;
|
|
56
|
+
state.pending ??= {};
|
|
57
|
+
state.pending[token] = {
|
|
58
|
+
kind,
|
|
59
|
+
startedAt: performance.now(),
|
|
60
|
+
};
|
|
61
|
+
return token;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function finishPerfProbe(token: string | null | undefined): PerfProbeSample | null {
|
|
65
|
+
if (!token) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const state = getEnabledState();
|
|
69
|
+
if (!state?.pending?.[token]) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pending = state.pending[token];
|
|
74
|
+
delete state.pending[token];
|
|
75
|
+
|
|
76
|
+
const sample: PerfProbeSample = {
|
|
77
|
+
token,
|
|
78
|
+
kind: pending.kind,
|
|
79
|
+
durationMs: performance.now() - pending.startedAt,
|
|
80
|
+
recordedAt: Date.now(),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
pushSample(state, sample);
|
|
84
|
+
|
|
85
|
+
return sample;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function recordPerfSample(
|
|
89
|
+
kind: PerfProbeKind,
|
|
90
|
+
durationMs = 0,
|
|
91
|
+
): PerfProbeSample | null {
|
|
92
|
+
const state = getEnabledState();
|
|
93
|
+
if (!state) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const token = `${kind}-${state.nextToken ?? 0}`;
|
|
98
|
+
state.nextToken = (state.nextToken ?? 0) + 1;
|
|
99
|
+
const sample: PerfProbeSample = {
|
|
100
|
+
token,
|
|
101
|
+
kind,
|
|
102
|
+
durationMs,
|
|
103
|
+
recordedAt: Date.now(),
|
|
104
|
+
};
|
|
105
|
+
pushSample(state, sample);
|
|
106
|
+
return sample;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function incrementInvalidationCounter(
|
|
110
|
+
counter: string,
|
|
111
|
+
amount = 1,
|
|
112
|
+
): number {
|
|
113
|
+
const state = getEnabledState();
|
|
114
|
+
if (!state) {
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
state.invalidationCounts ??= {};
|
|
119
|
+
state.invalidationCounts[counter] =
|
|
120
|
+
(state.invalidationCounts[counter] ?? 0) + amount;
|
|
121
|
+
return state.invalidationCounts[counter]!;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getLatestPerfSummary(): PerfProbeSummary | null {
|
|
125
|
+
const state = getEnabledState();
|
|
126
|
+
const samples = state?.samples ?? [];
|
|
127
|
+
if (!state || samples.length === 0) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
samples: [...samples],
|
|
133
|
+
latest: buildLatestSampleMap(samples),
|
|
134
|
+
invalidationCounts: { ...(state.invalidationCounts ?? {}) },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function resetPerfProbeState(): void {
|
|
139
|
+
const state = getEnabledState();
|
|
140
|
+
if (!state) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
state.nextToken = 0;
|
|
144
|
+
state.pending = {};
|
|
145
|
+
state.samples = [];
|
|
146
|
+
state.invalidationCounts = {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getEnabledState(): PerfProbeState | null {
|
|
150
|
+
if (typeof window === "undefined") {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const state = window.__DOCX_REACT_PERF_PROBE__;
|
|
154
|
+
if (!state?.enabled) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return state;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function pushSample(state: PerfProbeState, sample: PerfProbeSample): void {
|
|
161
|
+
state.samples ??= [];
|
|
162
|
+
state.samples.push(sample);
|
|
163
|
+
const maxSamples = state.maxSamples ?? 20;
|
|
164
|
+
if (state.samples.length > maxSamples) {
|
|
165
|
+
state.samples.splice(0, state.samples.length - maxSamples);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buildLatestSampleMap(
|
|
170
|
+
samples: PerfProbeSample[],
|
|
171
|
+
): Partial<Record<PerfProbeKind, PerfProbeSample | null>> {
|
|
172
|
+
const latest: Partial<Record<PerfProbeKind, PerfProbeSample | null>> = {};
|
|
173
|
+
for (const sample of [...samples].reverse()) {
|
|
174
|
+
if (latest[sample.kind] === undefined) {
|
|
175
|
+
latest[sample.kind] = sample;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return latest;
|
|
179
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { Plugin, PluginKey } from "prosemirror-state";
|
|
1
|
+
import { NodeSelection, Plugin, PluginKey } from "prosemirror-state";
|
|
2
2
|
import { keymap } from "prosemirror-keymap";
|
|
3
3
|
import { columnResizing, goToNextCell, isInTable, tableEditing } from "prosemirror-tables";
|
|
4
4
|
|
|
5
5
|
import type { SelectionSnapshot } from "../../api/public-types";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
createNodeSelectionSnapshot,
|
|
8
|
+
createSelectionSnapshot,
|
|
9
|
+
} from "../../ui/headless/selection-helpers";
|
|
7
10
|
import type { PositionMap } from "./pm-position-map";
|
|
8
11
|
|
|
9
12
|
export interface CommandBridgeCallbacks {
|
|
@@ -13,10 +16,12 @@ export interface CommandBridgeCallbacks {
|
|
|
13
16
|
onSplitParagraph: () => void;
|
|
14
17
|
onInsertHardBreak: () => void;
|
|
15
18
|
onInsertTab: () => void;
|
|
19
|
+
onOutdentTab?: () => void;
|
|
16
20
|
onUndo: () => void;
|
|
17
21
|
onRedo: () => void;
|
|
18
22
|
onSelectionChange: (selection: SelectionSnapshot) => void;
|
|
19
23
|
getPositionMap: () => PositionMap | null;
|
|
24
|
+
isSelectionSyncSuppressed?: () => boolean;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
const bridgeKey = new PluginKey("command-bridge");
|
|
@@ -31,6 +36,8 @@ const bridgeKey = new PluginKey("command-bridge");
|
|
|
31
36
|
export function createCommandBridgePlugins(
|
|
32
37
|
callbacks: CommandBridgeCallbacks,
|
|
33
38
|
): Plugin[] {
|
|
39
|
+
let isComposing = false;
|
|
40
|
+
|
|
34
41
|
// Transaction filter: block ALL doc-changing transactions.
|
|
35
42
|
// The runtime is the sole authority for document mutations.
|
|
36
43
|
const filterPlugin = new Plugin({
|
|
@@ -48,15 +55,26 @@ export function createCommandBridgePlugins(
|
|
|
48
55
|
view() {
|
|
49
56
|
return {
|
|
50
57
|
update(view, prevState) {
|
|
58
|
+
if (callbacks.isSelectionSyncSuppressed?.()) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (isComposing) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
51
64
|
if (!view.state.selection.eq(prevState.selection)) {
|
|
52
65
|
const posMap = callbacks.getPositionMap();
|
|
53
66
|
if (!posMap) return;
|
|
54
67
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
if (view.state.selection instanceof NodeSelection) {
|
|
69
|
+
callbacks.onSelectionChange(
|
|
70
|
+
createNodeSelectionSnapshot(posMap.pmToRuntime(view.state.selection.from), 1),
|
|
71
|
+
);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { anchor, head } = view.state.selection;
|
|
58
76
|
callbacks.onSelectionChange(
|
|
59
|
-
createSelectionSnapshot(
|
|
77
|
+
createSelectionSnapshot(posMap.pmToRuntime(anchor), posMap.pmToRuntime(head)),
|
|
60
78
|
);
|
|
61
79
|
}
|
|
62
80
|
},
|
|
@@ -67,6 +85,20 @@ export function createCommandBridgePlugins(
|
|
|
67
85
|
// Text input hook: intercept typed characters.
|
|
68
86
|
const inputPlugin = new Plugin({
|
|
69
87
|
props: {
|
|
88
|
+
handleDOMEvents: {
|
|
89
|
+
blur() {
|
|
90
|
+
isComposing = false;
|
|
91
|
+
return false;
|
|
92
|
+
},
|
|
93
|
+
compositionstart() {
|
|
94
|
+
isComposing = true;
|
|
95
|
+
return false;
|
|
96
|
+
},
|
|
97
|
+
compositionend() {
|
|
98
|
+
isComposing = false;
|
|
99
|
+
return false;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
70
102
|
handleTextInput(_view, _from, _to, text) {
|
|
71
103
|
callbacks.onInsertText(text);
|
|
72
104
|
return true; // Block PM from processing
|
|
@@ -87,22 +119,27 @@ export function createCommandBridgePlugins(
|
|
|
87
119
|
// Keymap: intercept editing keys and dispatch runtime commands.
|
|
88
120
|
const keymapPlugin = keymap({
|
|
89
121
|
Backspace: () => {
|
|
122
|
+
if (isComposing) return false;
|
|
90
123
|
callbacks.onDeleteBackward();
|
|
91
124
|
return true;
|
|
92
125
|
},
|
|
93
126
|
Delete: () => {
|
|
127
|
+
if (isComposing) return false;
|
|
94
128
|
callbacks.onDeleteForward();
|
|
95
129
|
return true;
|
|
96
130
|
},
|
|
97
131
|
Enter: () => {
|
|
132
|
+
if (isComposing) return false;
|
|
98
133
|
callbacks.onSplitParagraph();
|
|
99
134
|
return true;
|
|
100
135
|
},
|
|
101
136
|
"Shift-Enter": () => {
|
|
137
|
+
if (isComposing) return false;
|
|
102
138
|
callbacks.onInsertHardBreak();
|
|
103
139
|
return true;
|
|
104
140
|
},
|
|
105
141
|
Tab: (state, dispatch, view) => {
|
|
142
|
+
if (isComposing) return false;
|
|
106
143
|
if (isInTable(state)) {
|
|
107
144
|
return goToNextCell(1)(state, dispatch, view);
|
|
108
145
|
}
|
|
@@ -110,10 +147,12 @@ export function createCommandBridgePlugins(
|
|
|
110
147
|
return true;
|
|
111
148
|
},
|
|
112
149
|
"Shift-Tab": (state, dispatch, view) => {
|
|
150
|
+
if (isComposing) return false;
|
|
113
151
|
if (isInTable(state)) {
|
|
114
152
|
return goToNextCell(-1)(state, dispatch, view);
|
|
115
153
|
}
|
|
116
|
-
|
|
154
|
+
callbacks.onOutdentTab?.();
|
|
155
|
+
return true;
|
|
117
156
|
},
|
|
118
157
|
"Mod-z": () => {
|
|
119
158
|
callbacks.onUndo();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Plugin } from "prosemirror-state";
|
|
2
|
+
|
|
3
|
+
export interface ContextualInteractionCallbacks {
|
|
4
|
+
onCommentActivated?: (commentId: string) => void;
|
|
5
|
+
onRevisionActivated?: (revisionId: string) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createContextualInteractionPlugin(
|
|
9
|
+
callbacks: ContextualInteractionCallbacks,
|
|
10
|
+
): Plugin {
|
|
11
|
+
return new Plugin({
|
|
12
|
+
props: {
|
|
13
|
+
handleClick(_view, _pos, event) {
|
|
14
|
+
const target = event.target as HTMLElement | null;
|
|
15
|
+
const commentId = target?.closest?.("[data-comment-id]")?.getAttribute("data-comment-id");
|
|
16
|
+
if (commentId) {
|
|
17
|
+
callbacks.onCommentActivated?.(commentId);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const revisionId = target?.closest?.("[data-revision-id]")?.getAttribute("data-revision-id");
|
|
22
|
+
if (revisionId) {
|
|
23
|
+
callbacks.onRevisionActivated?.(revisionId);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return false;
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|