@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.
Files changed (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. 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
- selectionPreview: string;
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: TwSelectionToolbarProps) {
17
- const addCommentDisabled = props.readOnly || props.canAddComment === false;
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 className="mb-6 inline-flex items-center gap-1 rounded-lg bg-canvas shadow-lg ring-1 ring-border p-1">
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="Comment"
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:opacity-30 disabled:cursor-not-allowed ${focusRingClass}`}
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
- <div className="h-4 w-px bg-border mx-0.5" />
45
- <span className="text-xs text-tertiary px-2 max-w-[200px] truncate">
46
- {props.selectionPreview}
47
- </span>
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 { createSelectionSnapshot } from "../../ui/headless/selection-helpers";
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
- const { from, to } = view.state.selection;
56
- const runtimeAnchor = posMap.pmToRuntime(from);
57
- const runtimeHead = posMap.pmToRuntime(to);
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(runtimeAnchor, runtimeHead),
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
- return false;
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
+ }