@beyondwork/docx-react-component 1.0.36 → 1.0.38
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 +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +402 -1
- package/src/core/commands/index.ts +18 -1
- package/src/core/commands/section-layout-commands.ts +58 -0
- package/src/core/commands/table-grid.ts +431 -0
- package/src/core/commands/table-structure-commands.ts +815 -55
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +328 -50
- package/src/io/export/serialize-numbering.ts +114 -24
- package/src/io/export/serialize-tables.ts +87 -11
- package/src/io/export/table-properties-xml.ts +174 -20
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +20 -0
- package/src/io/ooxml/parse-footnotes.ts +62 -1
- package/src/io/ooxml/parse-headers-footers.ts +62 -1
- package/src/io/ooxml/parse-main-document.ts +158 -1
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +45 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +2 -306
- package/src/runtime/document-runtime.ts +287 -11
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +233 -0
- package/src/runtime/layout/inert-layout-facet.ts +59 -0
- package/src/runtime/layout/layout-engine-instance.ts +628 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/margin-preset-catalog.ts +178 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-format-catalog.ts +233 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +452 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +921 -0
- package/src/runtime/layout/project-block-fragments.ts +91 -0
- package/src/runtime/layout/public-facet.ts +1398 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/layout/table-render-plan.ts +229 -0
- package/src/runtime/render/block-fragment-projection.ts +35 -0
- package/src/runtime/render/decoration-resolver.ts +189 -0
- package/src/runtime/render/index.ts +57 -0
- package/src/runtime/render/pending-op-delta-reader.ts +129 -0
- package/src/runtime/render/render-frame-types.ts +317 -0
- package/src/runtime/render/render-kernel.ts +755 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/runtime/view-state.ts +67 -0
- package/src/runtime/workflow-markup.ts +1 -5
- package/src/runtime/workflow-rail-segments.ts +280 -0
- package/src/ui/WordReviewEditor.tsx +99 -15
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-shell-view.tsx +6 -0
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +501 -0
- package/src/ui/headless/scoped-chrome-policy.ts +183 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui/headless/selection-tool-types.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
- package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
- package/src/ui-tailwind/index.ts +33 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
- package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
- package/src/ui-tailwind/theme/editor-theme.css +505 -144
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
- package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope rail layer — renders workflow scopes as a continuous zone with a
|
|
3
|
+
* gutter icon + label column OUTSIDE the document flow plus a flat block-
|
|
4
|
+
* level tint BEHIND the scoped paragraphs.
|
|
5
|
+
*
|
|
6
|
+
* Per runtime-rendering-and-chrome-phase.md §5, the rail is a projection
|
|
7
|
+
* over canonical workflow scopes; it never lives inside the PM NodeView
|
|
8
|
+
* tree. Positions come from the render kernel's anchor index — not from
|
|
9
|
+
* DOM rect math — so the rail stays aligned across scroll, zoom, resize,
|
|
10
|
+
* and through predicted-text reconciliation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as React from "react";
|
|
14
|
+
import {
|
|
15
|
+
inflateRect,
|
|
16
|
+
projectRectToOverlay,
|
|
17
|
+
unionRect,
|
|
18
|
+
type OverlayCoordinateSpace,
|
|
19
|
+
} from "./chrome-overlay-projector";
|
|
20
|
+
import type { RenderFrameRect } from "../../runtime/render";
|
|
21
|
+
import type { ScopeRailSegment, ScopeRailPosture } from "../../runtime/layout";
|
|
22
|
+
import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Types
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export interface TwScopeRailLayerProps {
|
|
29
|
+
/** Layout facet that provides segments + anchor index. */
|
|
30
|
+
facet: WordReviewEditorLayoutFacet;
|
|
31
|
+
/** Overlay's coordinate space. Defaults to the overlay's own origin. */
|
|
32
|
+
space?: OverlayCoordinateSpace;
|
|
33
|
+
/** Horizontal padding (px) the rail gutter occupies to the left of body. */
|
|
34
|
+
railLaneWidthPx?: number;
|
|
35
|
+
/** Optional click handler for a segment label (open-scope drawer, etc). */
|
|
36
|
+
onSegmentClick?: (segment: ScopeRailSegment) => void;
|
|
37
|
+
/** Scope id that should render with the `active` emphasis. */
|
|
38
|
+
activeScopeId?: string | null;
|
|
39
|
+
/** Test id applied to the layer root. */
|
|
40
|
+
"data-testid"?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Posture → visual grammar
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
interface PostureStyle {
|
|
48
|
+
labelText: string;
|
|
49
|
+
icon: string; // lucide-style key; CSS ::before handles glyph in production
|
|
50
|
+
railToken: string;
|
|
51
|
+
tintToken: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
|
|
55
|
+
edit: { labelText: "EDIT", icon: "pencil", railToken: "accent", tintToken: "accent" },
|
|
56
|
+
suggest: { labelText: "SUGGEST", icon: "sparkles", railToken: "warning", tintToken: "warning" },
|
|
57
|
+
comment: { labelText: "COMMENT", icon: "message", railToken: "insert", tintToken: "insert" },
|
|
58
|
+
view: { labelText: "IN SCOPE", icon: "eye", railToken: "secondary", tintToken: "secondary" },
|
|
59
|
+
candidate: { labelText: "PROPOSED", icon: "flag", railToken: "warning", tintToken: "warning" },
|
|
60
|
+
"preserve-only": { labelText: "BLOCKED", icon: "lock", railToken: "danger", tintToken: "danger" },
|
|
61
|
+
"blocked-import": { labelText: "BLOCKED", icon: "lock", railToken: "danger", tintToken: "danger" },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Component
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const DEFAULT_RAIL_LANE_PX = 120;
|
|
69
|
+
const LABEL_WIDTH_PX = 92;
|
|
70
|
+
|
|
71
|
+
export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
72
|
+
facet,
|
|
73
|
+
space,
|
|
74
|
+
railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
|
|
75
|
+
onSegmentClick,
|
|
76
|
+
activeScopeId,
|
|
77
|
+
"data-testid": testId,
|
|
78
|
+
}) => {
|
|
79
|
+
// Read the render frame once per paint cycle. The facet.subscribe path
|
|
80
|
+
// already invalidates the caller's React state on layout changes, so we
|
|
81
|
+
// just read on render.
|
|
82
|
+
const frame = typeof facet.getRenderFrame === "function"
|
|
83
|
+
? facet.getRenderFrame() ?? null
|
|
84
|
+
: null;
|
|
85
|
+
const segments = facet.getAllScopeRailSegments();
|
|
86
|
+
|
|
87
|
+
if (!frame || segments.length === 0) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Group segments by scopeId so multi-page scopes render one contiguous
|
|
92
|
+
// tint per page range. (Per-page render happens below because each
|
|
93
|
+
// scope may span pages.)
|
|
94
|
+
const items = segments.map((segment) => {
|
|
95
|
+
const rect = resolveSegmentRect(facet, frame, segment);
|
|
96
|
+
if (!rect) return null;
|
|
97
|
+
const style = POSTURE_STYLES[segment.posture];
|
|
98
|
+
return { segment, rect, style };
|
|
99
|
+
}).filter((item): item is NonNullable<typeof item> => item !== null);
|
|
100
|
+
|
|
101
|
+
const projectorSpace: OverlayCoordinateSpace = space ?? { originLeftPx: 0, originTopPx: 0 };
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
className="wre-scope-rail-layer pointer-events-none absolute inset-0 z-20"
|
|
106
|
+
data-testid={testId ?? "scope-rail-layer"}
|
|
107
|
+
aria-hidden="false"
|
|
108
|
+
role="group"
|
|
109
|
+
aria-label="Workflow scope rail"
|
|
110
|
+
>
|
|
111
|
+
{items.map(({ segment, rect, style }) => {
|
|
112
|
+
const isActive = activeScopeId === segment.scopeId || segment.isActiveWorkItem;
|
|
113
|
+
const tintRect = inflateRect(rect, { leftPx: 4, rightPx: 4, topPx: 2, bottomPx: 2 });
|
|
114
|
+
const labelRect: RenderFrameRect = {
|
|
115
|
+
leftPx: rect.leftPx - railLaneWidthPx,
|
|
116
|
+
topPx: rect.topPx,
|
|
117
|
+
widthPx: LABEL_WIDTH_PX,
|
|
118
|
+
heightPx: Math.max(20, Math.min(rect.heightPx, 48)),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<React.Fragment key={`${segment.scopeId}:${segment.pageIndex}:${segment.fromOffset}`}>
|
|
123
|
+
{/* Flat tint behind the scoped block region */}
|
|
124
|
+
<div
|
|
125
|
+
className={`wre-scope-rail-tint wre-scope-rail-tint-${style.tintToken} absolute ${
|
|
126
|
+
isActive ? "wre-scope-rail-tint-active" : ""
|
|
127
|
+
}`}
|
|
128
|
+
data-scope-id={segment.scopeId}
|
|
129
|
+
data-posture={segment.posture}
|
|
130
|
+
style={projectRectToOverlay(tintRect, projectorSpace)}
|
|
131
|
+
/>
|
|
132
|
+
{/* Gutter label + icon outside the page frame */}
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} pointer-events-auto absolute flex flex-col items-center justify-center gap-1 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] ${
|
|
136
|
+
isActive ? "wre-scope-rail-label-active" : ""
|
|
137
|
+
}`}
|
|
138
|
+
data-scope-id={segment.scopeId}
|
|
139
|
+
data-posture={segment.posture}
|
|
140
|
+
data-icon={style.icon}
|
|
141
|
+
aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
|
|
142
|
+
onClick={onSegmentClick ? () => onSegmentClick(segment) : undefined}
|
|
143
|
+
style={projectRectToOverlay(labelRect, projectorSpace)}
|
|
144
|
+
>
|
|
145
|
+
<span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
|
|
146
|
+
<span className="wre-scope-rail-label-text">{style.labelText}</span>
|
|
147
|
+
</button>
|
|
148
|
+
</React.Fragment>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Internals
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
function resolveSegmentRect(
|
|
160
|
+
facet: WordReviewEditorLayoutFacet,
|
|
161
|
+
_frame: { anchorIndex: { byRuntimeOffset: (offset: number) => RenderFrameRect | null } },
|
|
162
|
+
segment: ScopeRailSegment,
|
|
163
|
+
): RenderFrameRect | null {
|
|
164
|
+
const fromRect = _frame.anchorIndex.byRuntimeOffset(segment.fromOffset);
|
|
165
|
+
const toRect = _frame.anchorIndex.byRuntimeOffset(Math.max(segment.fromOffset, segment.toOffset - 1));
|
|
166
|
+
const unioned = unionRect(fromRect, toRect);
|
|
167
|
+
if (unioned) return unioned;
|
|
168
|
+
// Fall back to the page rect so long scopes that can't resolve per-line
|
|
169
|
+
// still render a posture in the gutter.
|
|
170
|
+
const fallbackPage = facet.getPage(segment.pageIndex);
|
|
171
|
+
if (!fallbackPage) return null;
|
|
172
|
+
// Approximate: derive a rect from the page's body region by asking the
|
|
173
|
+
// render frame for the page rect. Without a render kernel kernel this
|
|
174
|
+
// function returns null and the segment is skipped.
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export default TwScopeRailLayer;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwWorkspaceViewSwitcher — floating dock that sits at the bottom of the
|
|
3
|
+
* chrome overlay. Mirrors the "WORKFLOW VIEW" bottom bar in image copy.png.
|
|
4
|
+
*
|
|
5
|
+
* Per runtime-rendering-and-chrome-phase.md §6.3, the dock is a
|
|
6
|
+
* `DraggableFloat`-compatible surface that lives inside the `ChromeOverlay`
|
|
7
|
+
* plane. Today it ships the preset selector + the canonical workflow
|
|
8
|
+
* actions (review / comment / approve) so consumers can wire them without
|
|
9
|
+
* waiting for the full DraggableFloat primitive.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as React from "react";
|
|
13
|
+
|
|
14
|
+
export type WorkspaceView = "draft" | "layout" | "review" | "workflow";
|
|
15
|
+
|
|
16
|
+
export interface WorkspaceViewAction {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
icon: string;
|
|
20
|
+
onClick?: () => void;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TwWorkspaceViewSwitcherProps {
|
|
25
|
+
activeView: WorkspaceView;
|
|
26
|
+
onViewChange?: (view: WorkspaceView) => void;
|
|
27
|
+
actions?: readonly WorkspaceViewAction[];
|
|
28
|
+
"data-testid"?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_VIEW_ORDER: readonly WorkspaceView[] = [
|
|
32
|
+
"draft",
|
|
33
|
+
"layout",
|
|
34
|
+
"review",
|
|
35
|
+
"workflow",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const VIEW_LABELS: Record<WorkspaceView, string> = {
|
|
39
|
+
draft: "DRAFT",
|
|
40
|
+
layout: "LAYOUT",
|
|
41
|
+
review: "REVIEW",
|
|
42
|
+
workflow: "WORKFLOW VIEW",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const TwWorkspaceViewSwitcher: React.FC<TwWorkspaceViewSwitcherProps> = ({
|
|
46
|
+
activeView,
|
|
47
|
+
onViewChange,
|
|
48
|
+
actions,
|
|
49
|
+
"data-testid": testId,
|
|
50
|
+
}) => {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className="wre-workspace-dock pointer-events-auto absolute left-1/2 bottom-6 z-10 -translate-x-1/2 flex items-center gap-2 rounded-full border border-border/70 bg-canvas/95 px-4 py-2 shadow-lg backdrop-blur"
|
|
54
|
+
data-testid={testId ?? "workspace-view-switcher"}
|
|
55
|
+
role="toolbar"
|
|
56
|
+
aria-label="Workspace view"
|
|
57
|
+
>
|
|
58
|
+
{DEFAULT_VIEW_ORDER.map((view) => (
|
|
59
|
+
<button
|
|
60
|
+
key={view}
|
|
61
|
+
type="button"
|
|
62
|
+
className={`wre-workspace-dock-view-btn inline-flex items-center gap-1 rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${
|
|
63
|
+
view === activeView
|
|
64
|
+
? "wre-workspace-dock-view-btn-active bg-primary text-white"
|
|
65
|
+
: "text-secondary hover:text-primary"
|
|
66
|
+
}`}
|
|
67
|
+
aria-pressed={view === activeView}
|
|
68
|
+
onClick={onViewChange ? () => onViewChange(view) : undefined}
|
|
69
|
+
disabled={!onViewChange}
|
|
70
|
+
>
|
|
71
|
+
<span aria-hidden="true" className="wre-workspace-dock-icon h-3 w-3" />
|
|
72
|
+
{VIEW_LABELS[view]}
|
|
73
|
+
</button>
|
|
74
|
+
))}
|
|
75
|
+
{actions && actions.length > 0 ? (
|
|
76
|
+
<div className="wre-workspace-dock-sep mx-1 h-5 w-px bg-border" />
|
|
77
|
+
) : null}
|
|
78
|
+
{actions?.map((action) => (
|
|
79
|
+
<button
|
|
80
|
+
key={action.id}
|
|
81
|
+
type="button"
|
|
82
|
+
className="wre-workspace-dock-action inline-flex h-7 w-7 items-center justify-center rounded-full text-secondary hover:bg-surface hover:text-primary disabled:opacity-40"
|
|
83
|
+
aria-label={action.label}
|
|
84
|
+
disabled={action.disabled}
|
|
85
|
+
onClick={action.onClick}
|
|
86
|
+
data-action-id={action.id}
|
|
87
|
+
>
|
|
88
|
+
<span aria-hidden="true" className={`wre-workspace-dock-icon wre-workspace-dock-icon-${action.icon}`} />
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export default TwWorkspaceViewSwitcher;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import type { Transaction } from "prosemirror-state";
|
|
2
|
+
import type { EditorView } from "prosemirror-view";
|
|
3
|
+
|
|
4
|
+
import type { TextCommandAck } from "../../api/public-types.ts";
|
|
5
|
+
import type {
|
|
6
|
+
LocalEditSessionState,
|
|
7
|
+
PendingOp,
|
|
8
|
+
PredictedIntent,
|
|
9
|
+
PredictedPreImagePM,
|
|
10
|
+
} from "./local-edit-session-state.ts";
|
|
11
|
+
import {
|
|
12
|
+
incrementInvalidationCounter,
|
|
13
|
+
PREDICTED_LANE_COUNTERS,
|
|
14
|
+
} from "./perf-probe.ts";
|
|
15
|
+
import type { PositionMap } from "./pm-position-map.ts";
|
|
16
|
+
import { PREDICTED_META_KEY } from "./predicted-tx-gate.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runtime-side text command the lane dispatches synchronously after applying
|
|
20
|
+
* a predicted PM transaction. The caller (React surface) wires this to
|
|
21
|
+
* `DocumentRuntime.applyActiveStoryTextCommand(command)` and returns the ack.
|
|
22
|
+
*/
|
|
23
|
+
export type LaneRuntimeCommand =
|
|
24
|
+
| {
|
|
25
|
+
type: "text.insert";
|
|
26
|
+
text: string;
|
|
27
|
+
origin: { opId: string; timestamp: number };
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
type: "text.delete-backward";
|
|
31
|
+
origin: { opId: string; timestamp: number };
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
type: "text.delete-forward";
|
|
35
|
+
origin: { opId: string; timestamp: number };
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
type: "paragraph.split";
|
|
39
|
+
origin: { opId: string; timestamp: number };
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
type: "text.insert-hard-break";
|
|
43
|
+
origin: { opId: string; timestamp: number };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export interface FastTextEditLaneOptions {
|
|
47
|
+
session: LocalEditSessionState;
|
|
48
|
+
getView(): EditorView | null;
|
|
49
|
+
getPositionMap(): PositionMap | null;
|
|
50
|
+
/**
|
|
51
|
+
* Synchronously dispatch the canonical runtime command. The lane expects
|
|
52
|
+
* the returned ack to classify the outcome; if the runtime throws,
|
|
53
|
+
* implementations should return a `rejected` ack rather than propagating.
|
|
54
|
+
*/
|
|
55
|
+
dispatchRuntimeCommand(command: LaneRuntimeCommand): TextCommandAck;
|
|
56
|
+
/**
|
|
57
|
+
* Optional. The lane toggles this around the predicted dispatch window so
|
|
58
|
+
* the surface's selection-sync plugin stays quiet while the PM doc is
|
|
59
|
+
* ahead of the canonical position map.
|
|
60
|
+
*/
|
|
61
|
+
suppressSelectionSync?: (suppressed: boolean) => void;
|
|
62
|
+
/**
|
|
63
|
+
* Optional pre-flight check. When it returns true, the lane skips the
|
|
64
|
+
* predicted PM transaction and dispatches the canonical runtime command
|
|
65
|
+
* directly. Use this for tag families (field, sdt, opaque) where the
|
|
66
|
+
* runtime would reject or diverge anyway — bailing here avoids the
|
|
67
|
+
* predicted-then-restored PM churn.
|
|
68
|
+
*/
|
|
69
|
+
shouldBailBeforePredict?(
|
|
70
|
+
intent: PredictedIntent,
|
|
71
|
+
fromRuntime: number,
|
|
72
|
+
toRuntime: number,
|
|
73
|
+
): boolean;
|
|
74
|
+
onEquivalentAck(ack: TextCommandAck): void;
|
|
75
|
+
onAdjustedAck(ack: TextCommandAck): void;
|
|
76
|
+
onRejectedAck(ack: TextCommandAck): void;
|
|
77
|
+
onStructuralDivergence(ack: TextCommandAck): void;
|
|
78
|
+
/** Optional probe hooks for perf instrumentation. */
|
|
79
|
+
probe?: {
|
|
80
|
+
markPredicted(opId: string): void;
|
|
81
|
+
markReconciled(opId: string, kind: TextCommandAck["kind"]): void;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface FastTextEditLane {
|
|
86
|
+
onInsertText(text: string): void;
|
|
87
|
+
onDeleteBackward(): void;
|
|
88
|
+
onDeleteForward(): void;
|
|
89
|
+
onSplitParagraph(): void;
|
|
90
|
+
onInsertHardBreak(): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let nextOpIdCounter = 0;
|
|
94
|
+
function allocOpId(): string {
|
|
95
|
+
nextOpIdCounter += 1;
|
|
96
|
+
return `op-${Date.now().toString(36)}-${nextOpIdCounter}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createFastTextEditLane(
|
|
100
|
+
options: FastTextEditLaneOptions,
|
|
101
|
+
): FastTextEditLane {
|
|
102
|
+
function run(
|
|
103
|
+
intent: PredictedIntent,
|
|
104
|
+
buildTx: (tr: Transaction) => Transaction | null,
|
|
105
|
+
): void {
|
|
106
|
+
// IME composition holds the DOM range; predicting through it would
|
|
107
|
+
// fight the browser's composition DOM mutations. The bridge's
|
|
108
|
+
// compositionstart/end handlers toggle this flag on the session.
|
|
109
|
+
if (options.session.isComposing()) return;
|
|
110
|
+
const view = options.getView();
|
|
111
|
+
const positionMap = options.getPositionMap();
|
|
112
|
+
if (!view || !positionMap) return;
|
|
113
|
+
|
|
114
|
+
const opId = allocOpId();
|
|
115
|
+
const before = view.state;
|
|
116
|
+
const fromPm = Math.min(before.selection.from, before.selection.to);
|
|
117
|
+
const toPm = Math.max(before.selection.from, before.selection.to);
|
|
118
|
+
|
|
119
|
+
const tr = buildTxCompat(view, intent, buildTx);
|
|
120
|
+
if (!tr) return;
|
|
121
|
+
tr.setMeta(PREDICTED_META_KEY, { opId });
|
|
122
|
+
|
|
123
|
+
const fromRuntime = positionMap.pmToRuntime(fromPm);
|
|
124
|
+
const toRuntime = positionMap.pmToRuntime(toPm);
|
|
125
|
+
|
|
126
|
+
pushLaneDebug({
|
|
127
|
+
opId,
|
|
128
|
+
intent: intent.kind,
|
|
129
|
+
pmFrom: fromPm,
|
|
130
|
+
pmTo: toPm,
|
|
131
|
+
pmDocSize: positionMap.pmDocSize,
|
|
132
|
+
runtimeStorySize: positionMap.runtimeStorySize,
|
|
133
|
+
fromRuntime,
|
|
134
|
+
toRuntime,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (options.shouldBailBeforePredict?.(intent, fromRuntime, toRuntime)) {
|
|
138
|
+
const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
|
|
139
|
+
incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.bailBeforePredict);
|
|
140
|
+
options.probe?.markReconciled(opId, ack.kind);
|
|
141
|
+
switch (ack.kind) {
|
|
142
|
+
case "equivalent":
|
|
143
|
+
options.session.advanceToRevision({
|
|
144
|
+
opId,
|
|
145
|
+
newRevisionToken: ack.newRevisionToken,
|
|
146
|
+
});
|
|
147
|
+
options.onEquivalentAck(ack);
|
|
148
|
+
return;
|
|
149
|
+
case "adjusted":
|
|
150
|
+
options.session.advanceToRevision({
|
|
151
|
+
opId,
|
|
152
|
+
newRevisionToken: ack.newRevisionToken,
|
|
153
|
+
});
|
|
154
|
+
options.onAdjustedAck(ack);
|
|
155
|
+
return;
|
|
156
|
+
case "rejected":
|
|
157
|
+
options.onRejectedAck(ack);
|
|
158
|
+
return;
|
|
159
|
+
case "structural-divergence":
|
|
160
|
+
options.onStructuralDivergence(ack);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const op: PendingOp = {
|
|
166
|
+
opId,
|
|
167
|
+
intent,
|
|
168
|
+
preImagePM: { preState: before },
|
|
169
|
+
fromRuntime,
|
|
170
|
+
toRuntime,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
options.session.appendPending(op);
|
|
175
|
+
incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.applied);
|
|
176
|
+
options.probe?.markPredicted(opId);
|
|
177
|
+
|
|
178
|
+
// run() is invoked synchronously from PM input handlers and JavaScript is
|
|
179
|
+
// single-threaded, so reentrancy is impossible — a plain on/off toggle is
|
|
180
|
+
// correct here; no save/restore is needed.
|
|
181
|
+
options.suppressSelectionSync?.(true);
|
|
182
|
+
view.dispatch(tr);
|
|
183
|
+
op.predictedSelectionHead = view.state.selection.head;
|
|
184
|
+
|
|
185
|
+
const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
|
|
186
|
+
options.probe?.markReconciled(opId, ack.kind);
|
|
187
|
+
|
|
188
|
+
switch (ack.kind) {
|
|
189
|
+
case "equivalent":
|
|
190
|
+
options.session.advanceToRevision({
|
|
191
|
+
opId,
|
|
192
|
+
newRevisionToken: ack.newRevisionToken,
|
|
193
|
+
});
|
|
194
|
+
incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.equivalent);
|
|
195
|
+
options.onEquivalentAck(ack);
|
|
196
|
+
return;
|
|
197
|
+
case "adjusted":
|
|
198
|
+
options.session.advanceToRevision({
|
|
199
|
+
opId,
|
|
200
|
+
newRevisionToken: ack.newRevisionToken,
|
|
201
|
+
});
|
|
202
|
+
incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.adjusted);
|
|
203
|
+
options.onAdjustedAck(ack);
|
|
204
|
+
return;
|
|
205
|
+
case "rejected": {
|
|
206
|
+
const removed = options.session.rollbackOp(opId);
|
|
207
|
+
if (removed?.preImagePM) {
|
|
208
|
+
restorePreImage(view, removed.preImagePM);
|
|
209
|
+
}
|
|
210
|
+
incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.rejected);
|
|
211
|
+
incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.rollback);
|
|
212
|
+
options.onRejectedAck(ack);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
case "structural-divergence": {
|
|
216
|
+
const all = options.session.clearAllPending();
|
|
217
|
+
for (let i = all.length - 1; i >= 0; i -= 1) {
|
|
218
|
+
const pre = all[i].preImagePM;
|
|
219
|
+
if (pre) restorePreImage(view, pre);
|
|
220
|
+
}
|
|
221
|
+
incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.structuralDivergence);
|
|
222
|
+
incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.rollback, all.length);
|
|
223
|
+
options.onStructuralDivergence(ack);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
options.suppressSelectionSync?.(false);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function restorePreImage(view: EditorView, pre: PredictedPreImagePM): void {
|
|
233
|
+
// view.updateState bypasses the gate's filterTransaction entirely — it is
|
|
234
|
+
// the same path the React surface uses for full rebuilds. Safe here because
|
|
235
|
+
// we are restoring a state that already existed in this same view.
|
|
236
|
+
view.updateState(pre.preState);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
onInsertText(text) {
|
|
241
|
+
run({ kind: "text.insert", text }, (tr) => tr.insertText(text));
|
|
242
|
+
},
|
|
243
|
+
onDeleteBackward() {
|
|
244
|
+
run({ kind: "text.delete-backward" }, (tr) => {
|
|
245
|
+
if (!tr.selection.empty) return tr.deleteSelection();
|
|
246
|
+
const from = tr.selection.from;
|
|
247
|
+
if (from <= 1) return null;
|
|
248
|
+
return tr.delete(from - 1, from);
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
onDeleteForward() {
|
|
252
|
+
run({ kind: "text.delete-forward" }, (tr) => {
|
|
253
|
+
if (!tr.selection.empty) return tr.deleteSelection();
|
|
254
|
+
const to = tr.selection.to;
|
|
255
|
+
if (to >= tr.doc.content.size - 1) return null;
|
|
256
|
+
return tr.delete(to, to + 1);
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
onSplitParagraph() {
|
|
260
|
+
run({ kind: "paragraph.split" }, (tr) => tr.split(tr.selection.from));
|
|
261
|
+
},
|
|
262
|
+
onInsertHardBreak() {
|
|
263
|
+
run({ kind: "text.insert-hard-break" }, (tr) => {
|
|
264
|
+
const hardBreak = tr.doc.type.schema.nodes.hard_break;
|
|
265
|
+
if (!hardBreak) return null;
|
|
266
|
+
return tr.replaceSelectionWith(hardBreak.create(), true);
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ----- helpers -----
|
|
273
|
+
|
|
274
|
+
interface LaneDebugEntry {
|
|
275
|
+
opId: string;
|
|
276
|
+
intent: PredictedIntent["kind"];
|
|
277
|
+
pmFrom: number;
|
|
278
|
+
pmTo: number;
|
|
279
|
+
pmDocSize: number;
|
|
280
|
+
runtimeStorySize: number;
|
|
281
|
+
fromRuntime: number;
|
|
282
|
+
toRuntime: number;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
declare global {
|
|
286
|
+
interface Window {
|
|
287
|
+
__DOCX_LANE_DEBUG__?: LaneDebugEntry[];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Push a per-keystroke trace entry to a window-attached ring buffer when
|
|
293
|
+
* `window.__DOCX_LANE_DEBUG__` exists (initialized to an empty array). The
|
|
294
|
+
* buffer is capped at 200 entries; consumers can read it from the browser
|
|
295
|
+
* console to diagnose cursor position mismatches between PM and the runtime.
|
|
296
|
+
*
|
|
297
|
+
* To enable in the browser console:
|
|
298
|
+
* window.__DOCX_LANE_DEBUG__ = [];
|
|
299
|
+
* Then type, then:
|
|
300
|
+
* JSON.stringify(window.__DOCX_LANE_DEBUG__, null, 2)
|
|
301
|
+
*/
|
|
302
|
+
function pushLaneDebug(entry: LaneDebugEntry): void {
|
|
303
|
+
if (typeof window === "undefined") return;
|
|
304
|
+
const buffer = window.__DOCX_LANE_DEBUG__;
|
|
305
|
+
if (!Array.isArray(buffer)) return;
|
|
306
|
+
buffer.push(entry);
|
|
307
|
+
if (buffer.length > 200) {
|
|
308
|
+
buffer.splice(0, buffer.length - 200);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function buildTxCompat(
|
|
313
|
+
view: EditorView,
|
|
314
|
+
_intent: PredictedIntent,
|
|
315
|
+
buildTx: (tr: Transaction) => Transaction | null,
|
|
316
|
+
): Transaction | null {
|
|
317
|
+
return buildTx(view.state.tr);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function toRuntimeCommand(
|
|
321
|
+
intent: PredictedIntent,
|
|
322
|
+
opId: string,
|
|
323
|
+
): LaneRuntimeCommand {
|
|
324
|
+
const origin = { opId, timestamp: Date.now() };
|
|
325
|
+
switch (intent.kind) {
|
|
326
|
+
case "text.insert":
|
|
327
|
+
return { type: "text.insert", text: intent.text, origin };
|
|
328
|
+
case "text.delete-backward":
|
|
329
|
+
return { type: "text.delete-backward", origin };
|
|
330
|
+
case "text.delete-forward":
|
|
331
|
+
return { type: "text.delete-forward", origin };
|
|
332
|
+
case "paragraph.split":
|
|
333
|
+
return { type: "paragraph.split", origin };
|
|
334
|
+
case "text.insert-hard-break":
|
|
335
|
+
return { type: "text.insert-hard-break", origin };
|
|
336
|
+
}
|
|
337
|
+
}
|