@beyondwork/docx-react-component 1.0.42 → 1.0.45

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 (82) hide show
  1. package/README.md +17 -0
  2. package/package.json +5 -4
  3. package/src/api/editor-state-types.ts +110 -0
  4. package/src/api/public-types.ts +333 -4
  5. package/src/core/commands/formatting-commands.ts +7 -1
  6. package/src/core/commands/index.ts +60 -10
  7. package/src/core/commands/text-commands.ts +59 -0
  8. package/src/core/search/search-text.ts +15 -2
  9. package/src/core/selection/review-anchors.ts +131 -21
  10. package/src/index.ts +29 -1
  11. package/src/io/chart-preview-resolver.ts +281 -0
  12. package/src/io/docx-session.ts +692 -2
  13. package/src/io/export/build-app-properties-xml.ts +1 -1
  14. package/src/io/export/serialize-comments.ts +38 -9
  15. package/src/io/export/twip.ts +1 -1
  16. package/src/io/load-scheduler.ts +230 -0
  17. package/src/io/normalize/normalize-text.ts +116 -0
  18. package/src/io/ooxml/parse-comments.ts +0 -33
  19. package/src/io/ooxml/parse-complex-content.ts +14 -0
  20. package/src/io/ooxml/parse-main-document.ts +4 -0
  21. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  22. package/src/io/ooxml/workflow-payload.ts +172 -1
  23. package/src/preservation/opaque-region.ts +5 -0
  24. package/src/review/store/comment-remapping.ts +2 -2
  25. package/src/runtime/collab-session.ts +1 -1
  26. package/src/runtime/document-runtime.ts +661 -42
  27. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  28. package/src/runtime/edit-dispatch/index.ts +2 -0
  29. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  30. package/src/runtime/editor-state-channel.ts +544 -0
  31. package/src/runtime/editor-state-integration.ts +217 -0
  32. package/src/runtime/editor-surface/capabilities.ts +411 -0
  33. package/src/runtime/layout/index.ts +2 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +63 -2
  36. package/src/runtime/layout/layout-engine-version.ts +41 -0
  37. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  38. package/src/runtime/layout/public-facet.ts +430 -1
  39. package/src/runtime/perf-counters.ts +28 -0
  40. package/src/runtime/prerender/cache-envelope.ts +29 -0
  41. package/src/runtime/prerender/cache-key.ts +66 -0
  42. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  43. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  44. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  45. package/src/runtime/prerender/prerender-document.ts +145 -0
  46. package/src/runtime/render/block-fragment-projection.ts +2 -0
  47. package/src/runtime/render/render-frame-types.ts +17 -0
  48. package/src/runtime/render/render-kernel.ts +172 -29
  49. package/src/runtime/selection/post-edit-validator.ts +77 -0
  50. package/src/runtime/surface-projection.ts +45 -7
  51. package/src/runtime/workflow-markup.ts +71 -16
  52. package/src/ui/WordReviewEditor.tsx +142 -237
  53. package/src/ui/editor-command-bag.ts +14 -0
  54. package/src/ui/editor-runtime-boundary.ts +115 -12
  55. package/src/ui/editor-shell-view.tsx +10 -0
  56. package/src/ui/editor-surface-controller.tsx +5 -0
  57. package/src/ui/headless/selection-helpers.ts +10 -0
  58. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  59. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  60. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  62. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  63. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  64. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  65. package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
  66. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
  67. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  68. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  69. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  70. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
  71. package/src/ui-tailwind/index.ts +5 -1
  72. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  73. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  74. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  75. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  76. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  77. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  78. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  79. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  80. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  81. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  82. package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
@@ -0,0 +1,281 @@
1
+ import * as React from "react";
2
+
3
+ import type { CollabSession } from "../../runtime/collab-session.ts";
4
+ import type {
5
+ CommentAudience,
6
+ } from "../../api/comment-presentation-types.ts";
7
+ import type { WordReviewEditorChromeOptions } from "../../api/public-types.ts";
8
+ import type { TransportStatus } from "../../api/awareness-identity-types.ts";
9
+ import { CollabPresenceStrip } from "./collab-presence-strip.tsx";
10
+ import { CollabRoleChip } from "./collab-role-chip.tsx";
11
+ import { CollabAudienceChip } from "./collab-audience-chip.tsx";
12
+ import { CollabTamperBanner } from "./collab-tamper-banner.tsx";
13
+ import { CollabNegotiationActionBar } from "./collab-negotiation-action-bar.tsx";
14
+ import { CollabSendToSupplierButton } from "./collab-send-to-supplier-button.tsx";
15
+ import {
16
+ CollabSendToSupplierModal,
17
+ type CollabSendToSupplierSubmitArgs,
18
+ } from "./collab-send-to-supplier-modal.tsx";
19
+
20
+ export type CollabSubSurfaceVisibility = Pick<
21
+ WordReviewEditorChromeOptions,
22
+ | "showCollabTopNav"
23
+ | "showCollabPresenceStrip"
24
+ | "showCollabRoleChip"
25
+ | "showCollabAudienceChip"
26
+ | "showCollabTamperBanner"
27
+ | "showCollabNegotiationActionBar"
28
+ | "showCollabSendToSupplier"
29
+ >;
30
+
31
+ export interface CollabTopNavContainerProps {
32
+ /** Wired collab session. When `undefined` / detached, the container renders nothing. */
33
+ session: CollabSession | undefined;
34
+ /** The currently-focused comment id (if any). Host supplies this from its own selection signal. */
35
+ activeCommentId?: string;
36
+ /**
37
+ * Fresh identity metadata about the local user. The session falls
38
+ * back to `author` if no awareness is wired, which matches the
39
+ * `CollabPosture` contract. This prop is passed through so tests /
40
+ * hosts can override the actor id used by the action bar without
41
+ * also mutating awareness.
42
+ */
43
+ actorId: string;
44
+ /** Current transport status signal (from the host's provider). */
45
+ transportStatus?: TransportStatus;
46
+ /** Visibility toggles from the chrome preset options. */
47
+ visibility?: CollabSubSurfaceVisibility;
48
+ /**
49
+ * Minimum send-to-supplier args a host wants to commit to before the
50
+ * confirmation modal even appears. The modal collects the rest.
51
+ */
52
+ sendBaseline?: {
53
+ originDocumentId: string;
54
+ originPayloadId: string;
55
+ originContentHash: string;
56
+ payloadXml: string;
57
+ };
58
+ /**
59
+ * Optional override for the activeStory filter passed into
60
+ * `getPresenceSnapshot({ activeStoryFilter })`.
61
+ */
62
+ activeStoryFilter?: string;
63
+ className?: string;
64
+ }
65
+
66
+ const DEFAULT_VISIBILITY: Required<CollabSubSurfaceVisibility> = {
67
+ showCollabTopNav: true,
68
+ showCollabPresenceStrip: true,
69
+ showCollabRoleChip: true,
70
+ showCollabAudienceChip: true,
71
+ showCollabTamperBanner: true,
72
+ showCollabNegotiationActionBar: true,
73
+ showCollabSendToSupplier: true,
74
+ };
75
+
76
+ /**
77
+ * P9g — chrome-toolbar container. Subscribes to the `CollabSession`
78
+ * event stream and renders the six P9a–f components from live
79
+ * snapshots. Pure React, no Yjs / awareness coupling — the session
80
+ * abstracts those away.
81
+ *
82
+ * The container intentionally renders `null` when no session is wired
83
+ * so the chrome toolbar can mount it unconditionally while the host
84
+ * decides whether to supply a session.
85
+ *
86
+ * Individual sub-surfaces can be opted out via `visibility`; the
87
+ * defaults mirror the `"collab"` chrome preset so hosts that
88
+ * instantiate directly get the full top nav.
89
+ */
90
+ export function CollabTopNavContainer({
91
+ session,
92
+ activeCommentId,
93
+ actorId,
94
+ transportStatus,
95
+ visibility,
96
+ sendBaseline,
97
+ activeStoryFilter,
98
+ className,
99
+ }: CollabTopNavContainerProps): React.ReactElement | null {
100
+ const effective: Required<CollabSubSurfaceVisibility> = {
101
+ ...DEFAULT_VISIBILITY,
102
+ ...normalizeVisibility(visibility),
103
+ };
104
+
105
+ const [tick, setTick] = React.useState(0);
106
+ const [sendModalOpen, setSendModalOpen] = React.useState(false);
107
+
108
+ React.useEffect(() => {
109
+ if (!session) return undefined;
110
+ const off = session.subscribe((event) => {
111
+ // Any event from the bridge OR the tamper gate triggers a
112
+ // re-read. The container is cheap to re-render; the underlying
113
+ // snapshots are already deep-cloned by the stores.
114
+ //
115
+ // We deliberately ignore the event type — the container is
116
+ // declarative about what it renders, so a single bump is enough.
117
+ void event;
118
+ setTick((n) => n + 1);
119
+ });
120
+ return off;
121
+ }, [session]);
122
+
123
+ if (!session || !effective.showCollabTopNav) return null;
124
+
125
+ const presence = session.getPresenceSnapshot({
126
+ ...(transportStatus ? { transportStatus } : {}),
127
+ ...(activeStoryFilter !== undefined ? { activeStoryFilter } : {}),
128
+ });
129
+ const posture = session.getCollabPosture({
130
+ ...(transportStatus ? { transportStatus } : {}),
131
+ });
132
+ const integrity = session.getMetadataIntegrity();
133
+ const presentation = session.getCommentPresentationSnapshot();
134
+ const negotiation = session.getCommentNegotiationSnapshot();
135
+
136
+ const activePresentation = activeCommentId
137
+ ? presentation.entries.find((e) => e.commentId === activeCommentId)
138
+ : undefined;
139
+ const activeNegotiation = activeCommentId
140
+ ? negotiation.entries.find((e) => e.commentId === activeCommentId)
141
+ : undefined;
142
+
143
+ const internalCount = presentation.entries.filter(
144
+ (e) => e.audience === "internal",
145
+ ).length;
146
+ const shareableCount = presentation.entries.filter(
147
+ (e) => e.audience !== "internal",
148
+ ).length;
149
+
150
+ const canEditAudience = posture.role !== "observer" && activePresentation !== undefined;
151
+ const tampered = integrity === "tampered";
152
+
153
+ const rootClass = [
154
+ "tw-collab-top-nav",
155
+ `tw-collab-top-nav--${posture.role}`,
156
+ tampered ? "tw-collab-top-nav--tampered" : null,
157
+ className ?? null,
158
+ ]
159
+ .filter((v): v is string => v !== null)
160
+ .join(" ");
161
+
162
+ const handleAudienceCycle = (next: CommentAudience): void => {
163
+ if (!activeCommentId) return;
164
+ void session.dispatchCommentPresentation({
165
+ type: "set-audience",
166
+ commentId: activeCommentId,
167
+ audience: next,
168
+ });
169
+ };
170
+
171
+ const handleNegotiationDispatch = (
172
+ action: Parameters<CollabSession["dispatchCommentNegotiation"]>[0],
173
+ ): void => {
174
+ session.dispatchCommentNegotiation(action, {
175
+ role: posture.role,
176
+ now: new Date().toISOString(),
177
+ });
178
+ };
179
+
180
+ const handleSendSubmit = async (
181
+ args: CollabSendToSupplierSubmitArgs,
182
+ ): Promise<void> => {
183
+ if (!sendBaseline) {
184
+ setSendModalOpen(false);
185
+ return;
186
+ }
187
+ await session.sendToExternal({
188
+ payloadXml: sendBaseline.payloadXml,
189
+ originDocumentId: sendBaseline.originDocumentId,
190
+ originPayloadId: sendBaseline.originPayloadId,
191
+ originContentHash: sendBaseline.originContentHash,
192
+ recipient: args.recipient,
193
+ sentBy: actorId,
194
+ archiveRef: args.archiveRef,
195
+ });
196
+ setSendModalOpen(false);
197
+ };
198
+
199
+ return (
200
+ <div
201
+ className={rootClass}
202
+ data-testid="collab-top-nav-container"
203
+ data-tick={tick.toString()}
204
+ data-role={posture.role}
205
+ data-integrity={integrity}
206
+ >
207
+ {effective.showCollabTamperBanner ? (
208
+ <CollabTamperBanner
209
+ integrity={integrity}
210
+ onAcknowledge={() => session.acknowledgeMetadataTampering()}
211
+ />
212
+ ) : null}
213
+
214
+ <div className="tw-collab-top-nav__row">
215
+ {effective.showCollabPresenceStrip ? (
216
+ <CollabPresenceStrip
217
+ presence={presence}
218
+ localRole={posture.role}
219
+ />
220
+ ) : null}
221
+
222
+ {effective.showCollabRoleChip ? (
223
+ <CollabRoleChip
224
+ posture={posture}
225
+ {...(transportStatus ? { transportStatus } : {})}
226
+ />
227
+ ) : null}
228
+
229
+ {effective.showCollabAudienceChip ? (
230
+ <CollabAudienceChip
231
+ audience={activePresentation?.audience}
232
+ canEdit={canEditAudience}
233
+ onCycle={handleAudienceCycle}
234
+ />
235
+ ) : null}
236
+
237
+ {effective.showCollabNegotiationActionBar ? (
238
+ <CollabNegotiationActionBar
239
+ entry={activeNegotiation}
240
+ actorId={actorId}
241
+ role={posture.role}
242
+ onDispatch={handleNegotiationDispatch}
243
+ disabled={tampered}
244
+ />
245
+ ) : null}
246
+
247
+ {effective.showCollabSendToSupplier ? (
248
+ <CollabSendToSupplierButton
249
+ role={posture.role}
250
+ integrity={integrity}
251
+ shareableCount={shareableCount}
252
+ internalCount={internalCount}
253
+ onOpenModal={() => setSendModalOpen(true)}
254
+ />
255
+ ) : null}
256
+ </div>
257
+
258
+ {effective.showCollabSendToSupplier ? (
259
+ <CollabSendToSupplierModal
260
+ open={sendModalOpen}
261
+ shareableCount={shareableCount}
262
+ internalCount={internalCount}
263
+ onClose={() => setSendModalOpen(false)}
264
+ onSubmit={handleSendSubmit}
265
+ />
266
+ ) : null}
267
+ </div>
268
+ );
269
+ }
270
+
271
+ function normalizeVisibility(
272
+ visibility: CollabSubSurfaceVisibility | undefined,
273
+ ): Partial<Required<CollabSubSurfaceVisibility>> {
274
+ if (!visibility) return {};
275
+ const out: Partial<Required<CollabSubSurfaceVisibility>> = {};
276
+ for (const key of Object.keys(DEFAULT_VISIBILITY) as (keyof CollabSubSurfaceVisibility)[]) {
277
+ const v = visibility[key];
278
+ if (typeof v === "boolean") out[key] = v;
279
+ }
280
+ return out;
281
+ }
@@ -17,6 +17,7 @@ import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
17
17
  import type { ScopeRailSegment } from "../../runtime/layout";
18
18
  import type {
19
19
  EditorRole,
20
+ EditorStoryTarget,
20
21
  ScopeIssueAction,
21
22
  TableStructureContextSnapshot,
22
23
  WordReviewEditorLayoutFacet,
@@ -25,6 +26,7 @@ import type {
25
26
  import { TwScopeRailLayer } from "./tw-scope-rail-layer";
26
27
  import { TwScopeCardLayer } from "./tw-scope-card-layer";
27
28
  import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
29
+ import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
28
30
  import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
29
31
 
30
32
  export interface TwChromeOverlayProps {
@@ -119,6 +121,37 @@ export interface TwChromeOverlayProps {
119
121
  * full workspace re-render.
120
122
  */
121
123
  renderFrameRevision?: number;
124
+
125
+ // Page-stack chrome layer (P8.8) --------------------------------------
126
+ /**
127
+ * Current active story target — the page-stack chrome layer uses this
128
+ * to decide which per-page band (if any) should render in active-slot
129
+ * mode. When omitted the chrome layer treats `{ kind: "main" }` as
130
+ * the active story, so no band is promoted.
131
+ */
132
+ activeStory?: EditorStoryTarget;
133
+ /**
134
+ * Fired when the user clicks a per-page header / footer band to
135
+ * promote it into the active editing surface. Task 10 will route PM
136
+ * into the matching band via React portals; today the handler is a
137
+ * pass-through to `runtime.openStory`.
138
+ */
139
+ onOpenStory?: (target: EditorStoryTarget) => void;
140
+ /**
141
+ * P8.11 — PM surface DOM element (`.ProseMirror` div). The chrome
142
+ * layer's portal mechanism reparents this element across the per-page
143
+ * band portal slots as `activeStory` changes. When omitted the
144
+ * reparent step is skipped; the chrome layer still renders read-only
145
+ * bands but the active-slot promotion is inert.
146
+ */
147
+ pmSurfaceElement?: HTMLElement | null;
148
+ /**
149
+ * P8.11 — optional PM view handle with `hasFocus()` + `focus()`. When
150
+ * supplied, the chrome layer re-focuses PM after a portal swap so
151
+ * mid-edit band clicks don't silently drop the caret. Omitting the
152
+ * handle leaves focus-restore as a no-op — DOM reparent still runs.
153
+ */
154
+ pmView?: PmPortalView | null;
122
155
  }
123
156
 
124
157
  /**
@@ -151,6 +184,10 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
151
184
  onSetRowHeight,
152
185
  pageStackScrollRoot,
153
186
  renderFrameRevision,
187
+ activeStory,
188
+ onOpenStory,
189
+ pmSurfaceElement,
190
+ pmView,
154
191
  }) => {
155
192
  return (
156
193
  <div
@@ -165,6 +202,17 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
165
202
  renderFrameRevision={renderFrameRevision ?? 0}
166
203
  />
167
204
  ) : null}
205
+ {pageStackScrollRoot !== undefined ? (
206
+ <TwPageStackChromeLayer
207
+ facet={facet}
208
+ scrollRoot={pageStackScrollRoot}
209
+ renderFrameRevision={renderFrameRevision ?? 0}
210
+ activeStory={activeStory ?? { kind: "main" }}
211
+ onOpenStory={onOpenStory}
212
+ pmSurfaceElement={pmSurfaceElement}
213
+ pmView={pmView}
214
+ />
215
+ ) : null}
168
216
  <TwScopeRailLayer
169
217
  facet={facet}
170
218
  space={space}
@@ -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
  });