@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.
- package/package.json +38 -37
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +541 -5
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +601 -9
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +131 -1
- package/src/io/docx-session.ts +672 -2
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +367 -0
- package/src/io/ooxml/workflow-payload.ts +317 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +639 -124
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- 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 +139 -14
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +441 -48
- package/src/runtime/layout/public-facet.ts +585 -14
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +80 -16
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +654 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +111 -11
- package/src/ui/editor-shell-view.tsx +21 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- 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-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- 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 +10 -256
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +37 -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/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- 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:
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
//
|
|
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
|
});
|
|
@@ -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
|
-
...(
|
|
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
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
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({
|