@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -77,6 +77,7 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
77
77
  const DEFAULT_RAIL_LANE_PX = 44;
78
78
  const STRIPE_WIDTH_PX = 4;
79
79
  const LABEL_WIDTH_PX = 40;
80
+ const STACK_OFFSET_PX = 6;
80
81
 
81
82
  export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
82
83
  facet,
@@ -96,6 +97,27 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
96
97
  return null;
97
98
  }
98
99
 
100
+ // P2: which scopes currently have a `source: "ai"` candidate
101
+ // overlapping — drives the agent-pending shimmer class on their
102
+ // tints. Reads from the facet's card-model projection so the
103
+ // shimmer logic lives in the runtime, not the overlay.
104
+ const cardModels =
105
+ typeof facet.getAllScopeCardModels === "function"
106
+ ? facet.getAllScopeCardModels()
107
+ : [];
108
+ const agentPendingByScope = new Map<string, boolean>();
109
+ for (const model of cardModels) {
110
+ if (model.agentPending) {
111
+ agentPendingByScope.set(model.scopeId, true);
112
+ }
113
+ }
114
+
115
+ // P3c: stack offsets for overlapping scopes. Two scopes whose
116
+ // offset ranges intersect on the same page render as stacked
117
+ // stripes in the gutter; the inner stripe shifts STACK_OFFSET_PX
118
+ // further right per overlap count so all are clickable.
119
+ const stackIndexByScope = computeStackIndices(segments);
120
+
99
121
  const projectorSpace: OverlayCoordinateSpace = space ?? { originLeftPx: 0, originTopPx: 0 };
100
122
 
101
123
  return (
@@ -121,14 +143,20 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
121
143
  const stripeTopPx = firstLine.topPx;
122
144
  const stripeHeightPx =
123
145
  lastLine.topPx + lastLine.heightPx - firstLine.topPx;
146
+ const stackIndex = stackIndexByScope.get(segment.scopeId) ?? 0;
147
+ const stackOffset = stackIndex * STACK_OFFSET_PX;
124
148
  const stripeRect: RenderFrameRect = {
125
- leftPx: firstLine.leftPx - railLaneWidthPx + (railLaneWidthPx - STRIPE_WIDTH_PX) / 2,
149
+ leftPx:
150
+ firstLine.leftPx
151
+ - railLaneWidthPx
152
+ + (railLaneWidthPx - STRIPE_WIDTH_PX) / 2
153
+ + stackOffset,
126
154
  topPx: stripeTopPx,
127
155
  widthPx: STRIPE_WIDTH_PX,
128
156
  heightPx: Math.max(stripeHeightPx, 14),
129
157
  };
130
158
  const labelRect: RenderFrameRect = {
131
- leftPx: firstLine.leftPx - railLaneWidthPx,
159
+ leftPx: firstLine.leftPx - railLaneWidthPx + stackOffset,
132
160
  topPx: stripeTopPx,
133
161
  widthPx: LABEL_WIDTH_PX,
134
162
  heightPx: 20,
@@ -148,18 +176,28 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
148
176
  return (
149
177
  <React.Fragment key={`${segment.scopeId}:${segment.pageIndex}:${segment.fromOffset}`}>
150
178
  {/* Per-line tint behind the scoped text runs. */}
151
- {lineRects.map((lineRect, index) => (
152
- <div
153
- key={`tint:${index}`}
154
- className={`wre-scope-rail-tint wre-scope-rail-tint-${style.tintToken} ${
155
- isActive ? "wre-scope-rail-tint-active" : ""
156
- }`}
157
- data-scope-id={segment.scopeId}
158
- data-posture={segment.posture}
159
- data-line-index={index}
160
- style={projectRectToOverlay(lineRect, projectorSpace)}
161
- />
162
- ))}
179
+ {lineRects.map((lineRect, index) => {
180
+ const agentPending = agentPendingByScope.get(segment.scopeId) === true;
181
+ const tintClassList = [
182
+ "wre-scope-rail-tint",
183
+ `wre-scope-rail-tint-${style.tintToken}`,
184
+ ];
185
+ if (isActive) tintClassList.push("wre-scope-rail-tint-active");
186
+ if (agentPending) {
187
+ tintClassList.push("wre-scope-rail-tint-agent-pending");
188
+ }
189
+ return (
190
+ <div
191
+ key={`tint:${index}`}
192
+ className={tintClassList.join(" ")}
193
+ data-scope-id={segment.scopeId}
194
+ data-posture={segment.posture}
195
+ data-line-index={index}
196
+ data-agent-pending={agentPending ? "true" : undefined}
197
+ style={projectRectToOverlay(lineRect, projectorSpace)}
198
+ />
199
+ );
200
+ })}
163
201
  {/* Rail stripe in the gutter. */}
164
202
  <button
165
203
  type="button"
@@ -168,6 +206,7 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
168
206
  }`}
169
207
  data-scope-id={segment.scopeId}
170
208
  data-posture={segment.posture}
209
+ data-stack-index={stackIndex}
171
210
  data-testid="scope-rail-stripe"
172
211
  aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
173
212
  aria-expanded={isActive ? "true" : "false"}
@@ -246,4 +285,44 @@ function pushRectsFromPage(
246
285
  }
247
286
  }
248
287
 
288
+ /**
289
+ * P3c: Compute a stack index per scope so overlapping scopes render
290
+ * as offset stripes in the gutter instead of stacking on top of each
291
+ * other. For each scope's first segment, count earlier-document-
292
+ * order scopes whose offset range intersects on the same page — that
293
+ * count is the stack index (0 = outer / earliest, 1 = next, etc).
294
+ *
295
+ * Exported for unit testing.
296
+ */
297
+ export function computeStackIndices(
298
+ segments: readonly ScopeRailSegment[],
299
+ ): Map<string, number> {
300
+ const firstSegmentByScope = new Map<string, ScopeRailSegment>();
301
+ const scopeOrder: string[] = [];
302
+ for (const segment of segments) {
303
+ if (!firstSegmentByScope.has(segment.scopeId)) {
304
+ firstSegmentByScope.set(segment.scopeId, segment);
305
+ scopeOrder.push(segment.scopeId);
306
+ }
307
+ }
308
+
309
+ const result = new Map<string, number>();
310
+ for (let i = 0; i < scopeOrder.length; i += 1) {
311
+ const scopeId = scopeOrder[i];
312
+ const seg = firstSegmentByScope.get(scopeId);
313
+ if (!seg) continue;
314
+ let overlapCount = 0;
315
+ for (let j = 0; j < i; j += 1) {
316
+ const other = firstSegmentByScope.get(scopeOrder[j]);
317
+ if (!other) continue;
318
+ if (other.pageIndex !== seg.pageIndex) continue;
319
+ if (other.toOffset > seg.fromOffset && other.fromOffset < seg.toOffset) {
320
+ overlapCount += 1;
321
+ }
322
+ }
323
+ result.set(scopeId, overlapCount);
324
+ }
325
+ return result;
326
+ }
327
+
249
328
  export default TwScopeRailLayer;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Extract a sequence of callback operations from a plain-text clipboard
3
+ * payload. Used by the `pm-command-bridge` handlePaste / handleDrop
4
+ * hooks to turn a pasted string into calls to `onInsertText` /
5
+ * `onSplitParagraph` / `onInsertHardBreak` — the same runtime-owned
6
+ * callbacks typing already uses.
7
+ *
8
+ * Separator convention:
9
+ * - LF (`\n`), CRLF (`\r\n`), CR (`\r`) → paragraph split
10
+ * ({ kind: "split" }).
11
+ * - U+000B (vertical tab) → hard break ({ kind: "hard_break" }).
12
+ * Word's plain-text clipboard represents shift-enter line breaks
13
+ * as vertical-tab; preserving them keeps paragraph-internal line
14
+ * structure.
15
+ * - Tab (U+0009) stays inside the current text segment. The
16
+ * runtime's `onInsertText` decides whether it is rendered as a
17
+ * tab-stop or swallowed.
18
+ *
19
+ * Consecutive separators produce multiple splits in sequence (empty
20
+ * paragraphs). Leading / trailing separators are emitted verbatim so
21
+ * the caller can decide whether to coalesce into a trailing blank.
22
+ *
23
+ * Pure function: no DOM, no React, no ProseMirror state.
24
+ *
25
+ * Source plan: `docs/plans/editor-paste-drop.md` §Phase 1.
26
+ */
27
+
28
+ export type PastePlainSegment =
29
+ | { kind: "text"; value: string }
30
+ | { kind: "split" }
31
+ | { kind: "hard_break" };
32
+
33
+ export function extractPlainTextSegments(input: string): PastePlainSegment[] {
34
+ if (input.length === 0) return [];
35
+
36
+ const segments: PastePlainSegment[] = [];
37
+ let buffer = "";
38
+
39
+ const flush = (): void => {
40
+ if (buffer.length > 0) {
41
+ segments.push({ kind: "text", value: buffer });
42
+ buffer = "";
43
+ }
44
+ };
45
+
46
+ for (let i = 0; i < input.length; i += 1) {
47
+ const ch = input[i];
48
+
49
+ if (ch === "\r") {
50
+ flush();
51
+ segments.push({ kind: "split" });
52
+ // Collapse CRLF into one split — advance past the LF.
53
+ if (input[i + 1] === "\n") i += 1;
54
+ continue;
55
+ }
56
+ if (ch === "\n") {
57
+ flush();
58
+ segments.push({ kind: "split" });
59
+ continue;
60
+ }
61
+ if (ch === "\u000B") {
62
+ flush();
63
+ segments.push({ kind: "hard_break" });
64
+ continue;
65
+ }
66
+
67
+ buffer += ch;
68
+ }
69
+
70
+ flush();
71
+ return segments;
72
+ }
@@ -7,8 +7,62 @@ import {
7
7
  createSelectionSnapshot,
8
8
  } from "../../ui/headless/selection-helpers";
9
9
  import { resolveSurfaceShortcut } from "../../ui/runtime-shortcut-dispatch";
10
+ import {
11
+ extractPlainTextSegments,
12
+ type PastePlainSegment,
13
+ } from "./paste-plain-text";
10
14
  import type { PositionMap } from "./pm-position-map";
11
15
 
16
+ /**
17
+ * Callback subset used by paste / drop dispatch. Exported so tests can
18
+ * record dispatch order without constructing the full
19
+ * `CommandBridgeCallbacks` surface.
20
+ */
21
+ export interface PasteDispatchCallbacks {
22
+ onInsertText: (text: string) => void;
23
+ onSplitParagraph: () => void;
24
+ onInsertHardBreak: () => void;
25
+ }
26
+
27
+ /**
28
+ * Dispatch an ordered list of plain-text segments to the runtime-owned
29
+ * callbacks. Empty text segments are skipped. Pure with respect to the
30
+ * callbacks — no global state, no PM mutation.
31
+ */
32
+ export function applyPasteSegmentsToCallbacks(
33
+ segments: readonly PastePlainSegment[],
34
+ callbacks: PasteDispatchCallbacks,
35
+ ): void {
36
+ for (const seg of segments) {
37
+ switch (seg.kind) {
38
+ case "text":
39
+ if (seg.value.length > 0) callbacks.onInsertText(seg.value);
40
+ break;
41
+ case "split":
42
+ callbacks.onSplitParagraph();
43
+ break;
44
+ case "hard_break":
45
+ callbacks.onInsertHardBreak();
46
+ break;
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Sum the character length across every `text` segment. Used by the
53
+ * paste / drop handlers to populate the `charCount` field of the
54
+ * public `paste_applied` event.
55
+ */
56
+ export function totalTextCharCount(
57
+ segments: readonly PastePlainSegment[],
58
+ ): number {
59
+ let total = 0;
60
+ for (const seg of segments) {
61
+ if (seg.kind === "text") total += seg.value.length;
62
+ }
63
+ return total;
64
+ }
65
+
12
66
  export interface SelectionSyncCallbacks {
13
67
  onSelectionChange: (selection: SelectionSnapshot) => void;
14
68
  getPositionMap: () => PositionMap | null;
@@ -28,6 +82,17 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
28
82
  onUndo: () => void;
29
83
  onRedo: () => void;
30
84
  onBlockedInput?: (command: "paste" | "drop", message: string) => void;
85
+ /**
86
+ * Optional. Fires after a plain-text paste or drop successfully
87
+ * dispatches through the runtime callbacks. `source` distinguishes
88
+ * paste from drop. Rich paste (HTML, Office clipboard) still fires
89
+ * `onBlockedInput`, not this callback.
90
+ */
91
+ onPasteApplied?: (meta: {
92
+ segmentCount: number;
93
+ charCount: number;
94
+ source: "paste" | "drop";
95
+ }) => void;
31
96
  /**
32
97
  * Optional. Fires on `compositionstart` (true) and `compositionend`
33
98
  * (false). The surface forwards this to the predicted lane's session
@@ -130,16 +195,61 @@ export function createCommandBridgePlugins(
130
195
  return true; // Block PM from processing
131
196
  },
132
197
 
133
- // Block paste (rich paste is not safe, plain paste via text.insert is TODO)
134
- handlePaste() {
135
- callbacks.onBlockedInput?.("paste", "Paste is not supported in the mounted editor yet.");
136
- return true; // Block
198
+ // Plain-text paste: extract text/plain from the clipboard and
199
+ // dispatch through the runtime-owned callbacks that typing uses.
200
+ // Rich paste (HTML, Office clipboard) stays blocked hosts that
201
+ // listen for onBlockedInput still get notified when a non-plain-
202
+ // text payload arrives. See docs/plans/editor-paste-drop.md.
203
+ handlePaste(_view, event) {
204
+ if (isComposing) return true;
205
+ const clipboard = event.clipboardData;
206
+ if (!clipboard) {
207
+ callbacks.onBlockedInput?.("paste", "Clipboard data was not available.");
208
+ return true;
209
+ }
210
+ const plain = clipboard.getData("text/plain");
211
+ if (!plain) {
212
+ callbacks.onBlockedInput?.(
213
+ "paste",
214
+ "Non-plain-text paste is not supported yet.",
215
+ );
216
+ return true;
217
+ }
218
+ const segments = extractPlainTextSegments(plain);
219
+ applyPasteSegmentsToCallbacks(segments, callbacks);
220
+ callbacks.onPasteApplied?.({
221
+ segmentCount: segments.length,
222
+ charCount: totalTextCharCount(segments),
223
+ source: "paste",
224
+ });
225
+ return true;
137
226
  },
138
227
 
139
- // Block drop
140
- handleDrop() {
141
- callbacks.onBlockedInput?.("drop", "Drag and drop is not supported in the mounted editor.");
142
- return true; // Block
228
+ // Plain-text drop: symmetric path — extract text/plain from the
229
+ // DataTransfer and dispatch through the same callbacks paste uses.
230
+ handleDrop(_view, event) {
231
+ if (isComposing) return true;
232
+ const dt = (event as DragEvent).dataTransfer;
233
+ if (!dt) {
234
+ callbacks.onBlockedInput?.("drop", "Drop data was not available.");
235
+ return true;
236
+ }
237
+ const plain = dt.getData("text/plain");
238
+ if (!plain) {
239
+ callbacks.onBlockedInput?.(
240
+ "drop",
241
+ "Non-plain-text drop is not supported yet.",
242
+ );
243
+ return true;
244
+ }
245
+ const segments = extractPlainTextSegments(plain);
246
+ applyPasteSegmentsToCallbacks(segments, callbacks);
247
+ callbacks.onPasteApplied?.({
248
+ segmentCount: segments.length,
249
+ charCount: totalTextCharCount(segments),
250
+ source: "drop",
251
+ });
252
+ return true;
143
253
  },
144
254
  },
145
255
  });
@@ -23,6 +23,39 @@ type RailDecorationSpec = {
23
23
  attrs: Record<string, string>;
24
24
  };
25
25
 
26
+ /**
27
+ * Validate and normalize a host-supplied CSS color before interpolating it
28
+ * into an inline-style string. Accepts only the narrow subset a
29
+ * host-issued metadata color would legitimately use:
30
+ * - `#rgb`, `#rrggbb`, `#rrggbbaa` hex literals
31
+ * - `rgb(...)` / `rgba(...)` with numeric/percent/alpha args, no nested
32
+ * comments or closing-paren escape tricks
33
+ * - named colors drawn from a short whitelist
34
+ * Any semicolon, brace, or parenthesis outside the matched shape is
35
+ * rejected. Returns `null` when the input is unsafe; callers must drop
36
+ * the style attribute entirely in that case.
37
+ */
38
+ export function sanitizeHostCssColor(raw: unknown): string | null {
39
+ if (typeof raw !== "string") return null;
40
+ const value = raw.trim();
41
+ if (value.length === 0 || value.length > 64) return null;
42
+ // Hex: #rgb / #rrggbb / #rrggbbaa
43
+ if (/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$|^#[0-9a-fA-F]{8}$/.test(value)) {
44
+ return value;
45
+ }
46
+ // rgb()/rgba() with digits, dots, percent, commas and a single space.
47
+ if (/^rgba?\(\s*\d+(\.\d+)?%?(\s*,\s*\d+(\.\d+)?%?){2,3}\s*\)$/.test(value)) {
48
+ return value;
49
+ }
50
+ // Named colors — short whitelist that covers every host-chip use.
51
+ const named = new Set([
52
+ "black", "white", "red", "green", "blue", "yellow", "orange",
53
+ "purple", "pink", "brown", "gray", "grey", "transparent",
54
+ ]);
55
+ if (named.has(value.toLowerCase())) return value.toLowerCase();
56
+ return null;
57
+ }
58
+
26
59
  function isSelectionZoneScope(scope: WorkflowScope): boolean {
27
60
  return (
28
61
  scope.scopeId.startsWith("scope-lab-") ||
@@ -511,10 +544,11 @@ export function buildDecorations(
511
544
  const pmRange = buildAnchorPmRange(metadata.anchor, positionMap);
512
545
  if (!pmRange || !pmRange.allowInline || pmRange.from >= pmRange.to) continue;
513
546
 
547
+ const safeColor = sanitizeHostCssColor(metadata.color);
514
548
  decorations.push(
515
549
  Decoration.inline(pmRange.from, pmRange.to, {
516
550
  class: getWorkflowMetadataInlineClass(),
517
- ...(metadata.color ? { style: `--wre-workflow-metadata-color: ${metadata.color};` } : {}),
551
+ ...(safeColor ? { style: `--wre-workflow-metadata-color: ${safeColor};` } : {}),
518
552
  "data-workflow-entry-id": metadata.entryId,
519
553
  "data-workflow-metadata-id": metadata.metadataId,
520
554
  }),
@@ -178,7 +178,60 @@ interface ChromeWidgetInput {
178
178
  nextHeaderPreview: string;
179
179
  }
180
180
 
181
+ // P14.c — cache the widget DOM by input identity. PM rebuilds the
182
+ // page-break decorations on every commit (37 widgets per 38-page doc);
183
+ // each widget's DOM is ~10 elements. When the page IDs, labels, and
184
+ // preview text haven't changed (the common case during typing inside
185
+ // one page), returning the cached DOM lets PM's decoration
186
+ // reconciliation skip the remount entirely. Bounded LRU at 256 entries
187
+ // so a long session with significant content churn doesn't grow without
188
+ // bound.
189
+ const widgetDomCache = new Map<string, HTMLElement>();
190
+ const WIDGET_DOM_CACHE_LIMIT = 256;
191
+
192
+ function widgetCacheKey(input: ChromeWidgetInput): string {
193
+ return [
194
+ input.posture,
195
+ input.prevPageId,
196
+ input.nextPageId,
197
+ input.prevPageIndex,
198
+ input.nextPageIndex,
199
+ input.headerBandPx,
200
+ input.footerBandPx,
201
+ input.interGapPx,
202
+ input.prevPageLabel,
203
+ input.nextPageLabel,
204
+ input.hasPrevFooterStory ? "1" : "0",
205
+ input.hasNextHeaderStory ? "1" : "0",
206
+ input.prevFooterPreview,
207
+ input.nextHeaderPreview,
208
+ ].join("\x1f");
209
+ }
210
+
181
211
  function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
212
+ const key = widgetCacheKey(input);
213
+ const cached = widgetDomCache.get(key);
214
+ if (cached) {
215
+ // Touch LRU recency by re-inserting.
216
+ widgetDomCache.delete(key);
217
+ widgetDomCache.set(key, cached);
218
+ return cached;
219
+ }
220
+ const built = buildChromeWidgetDomUncached(input);
221
+ widgetDomCache.set(key, built);
222
+ if (widgetDomCache.size > WIDGET_DOM_CACHE_LIMIT) {
223
+ const oldest = widgetDomCache.keys().next().value;
224
+ if (oldest !== undefined) widgetDomCache.delete(oldest);
225
+ }
226
+ return built;
227
+ }
228
+
229
+ /** Test-only export — clears the cache between assertions. */
230
+ export function __resetPageBreakWidgetCache(): void {
231
+ widgetDomCache.clear();
232
+ }
233
+
234
+ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
182
235
  const root = document.createElement("div");
183
236
  root.className = "wre-page-chrome-widget";
184
237
  root.setAttribute("data-kind", "page-chrome-widget");
@@ -187,6 +240,12 @@ function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
187
240
  root.setAttribute("data-next-page-id", input.nextPageId);
188
241
  root.setAttribute("data-prev-page-index", String(input.prevPageIndex));
189
242
  root.setAttribute("data-next-page-index", String(input.nextPageIndex));
243
+ // P3.a: expose page-frame boundary markers so the page stack and tests
244
+ // can enumerate pages without re-walking the render graph. Each widget
245
+ // ends page N (`prev`) and starts page N+1 (`next`); the outer workspace
246
+ // frame still accounts for the boundaries at both ends of the document.
247
+ root.setAttribute("data-page-frame-end", input.prevPageId);
248
+ root.setAttribute("data-page-frame-start", input.nextPageId);
190
249
  root.contentEditable = "false";
191
250
  root.setAttribute("aria-hidden", "false");
192
251
  root.style.display = "block";
@@ -245,16 +304,40 @@ function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
245
304
  });
246
305
  root.appendChild(footer);
247
306
 
307
+ // P3.a: the inter-page gap is now a visible canvas strip that reads as
308
+ // "the space between two papers", not a subtle gradient inside one white
309
+ // page. The strip paints in the workspace canvas color (the same color
310
+ // the page frames float on in page mode), with subtle drop/rise shadows
311
+ // on either edge so the preceding footer reads as "bottom of page N's
312
+ // paper" and the following header reads as "top of page N+1's paper".
313
+ //
314
+ // The visual goal: bringing the user closer to a Word-native perception
315
+ // of distinct pages without requiring the PM editable tree to be split
316
+ // into per-page subtrees (that lands in P3.b).
248
317
  const separator = document.createElement("div");
249
318
  separator.className = "wre-page-chrome-separator";
319
+ separator.setAttribute("data-kind", "page-chrome-separator");
250
320
  separator.style.position = "absolute";
251
321
  separator.style.left = "0";
252
322
  separator.style.right = "0";
253
323
  separator.style.top = `${input.footerBandPx}px`;
254
324
  separator.style.height = `${input.interGapPx}px`;
255
- // Background: two subtle page-edge shadows mimicking real paper gap.
256
- separator.style.background =
257
- "linear-gradient(to bottom, rgba(0,0,0,0.045), rgba(0,0,0,0) 40%, rgba(0,0,0,0) 60%, rgba(0,0,0,0.035))";
325
+ // Canvas color (same as the scroll root's bg-surface) so the strip reads
326
+ // as "gap between two papers". Page mode and canvas mode both use the
327
+ // same token so the UX remains consistent at any chrome preset.
328
+ separator.style.backgroundColor = "var(--color-surface, #f1f5f9)";
329
+ // Inner shadows on top/bottom: the previous footer's bottom edge gains
330
+ // a subtle paper-edge shadow, and the next header's top edge likewise.
331
+ // Inset shadows let us avoid touching the footer / header DOM while
332
+ // keeping the shadows flush with the band borders.
333
+ separator.style.boxShadow = [
334
+ // Top edge — simulates the bottom shadow of page N's paper.
335
+ "inset 0 1px 0 rgba(15, 23, 42, 0.06)",
336
+ "inset 0 2px 3px -2px rgba(15, 23, 42, 0.12)",
337
+ // Bottom edge — simulates the top shadow of page N+1's paper.
338
+ "inset 0 -1px 0 rgba(15, 23, 42, 0.06)",
339
+ "inset 0 -2px 3px -2px rgba(15, 23, 42, 0.12)",
340
+ ].join(", ");
258
341
  root.appendChild(separator);
259
342
 
260
343
  const header = buildBand({