@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.
- package/README.md +17 -0
- package/package.json +5 -4
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +333 -4
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +60 -10
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/search/search-text.ts +15 -2
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +29 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +692 -2
- package/src/io/export/build-app-properties-xml.ts +1 -1
- package/src/io/export/serialize-comments.ts +38 -9
- package/src/io/export/twip.ts +1 -1
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +116 -0
- package/src/io/ooxml/parse-comments.ts +0 -33
- package/src/io/ooxml/parse-complex-content.ts +14 -0
- package/src/io/ooxml/parse-main-document.ts +4 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +661 -42
- package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
- package/src/runtime/edit-dispatch/index.ts +2 -0
- package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +63 -2
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +430 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/prerender/cache-envelope.ts +29 -0
- package/src/runtime/prerender/cache-key.ts +66 -0
- package/src/runtime/prerender/font-fingerprint.ts +17 -0
- package/src/runtime/prerender/graph-canonicalize.ts +121 -0
- package/src/runtime/prerender/indexeddb-cache.ts +184 -0
- package/src/runtime/prerender/prerender-document.ts +145 -0
- package/src/runtime/render/block-fragment-projection.ts +2 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +45 -7
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +142 -237
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +115 -12
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- 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
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return true;
|
|
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
|
});
|