@beyondwork/docx-react-component 1.0.37 → 1.0.39
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 +41 -31
- package/src/api/public-types.ts +496 -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 +845 -56
- package/src/core/commands/text-commands.ts +122 -2
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-main-document.ts +2 -11
- package/src/io/export/serialize-numbering.ts +43 -10
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/export/serialize-tables.ts +74 -0
- package/src/io/export/table-properties-xml.ts +139 -4
- package/src/io/normalize/normalize-text.ts +15 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-footnotes.ts +60 -0
- package/src/io/ooxml/parse-headers-footers.ts +60 -0
- package/src/io/ooxml/parse-main-document.ts +137 -0
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +117 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +1 -1
- package/src/runtime/document-runtime.ts +248 -18
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/index.ts +47 -0
- package/src/runtime/layout/inert-layout-facet.ts +16 -0
- package/src/runtime/layout/layout-engine-instance.ts +100 -23
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/margin-preset-catalog.ts +178 -0
- package/src/runtime/layout/page-format-catalog.ts +233 -0
- package/src/runtime/layout/page-graph.ts +55 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +484 -37
- package/src/runtime/layout/project-block-fragments.ts +225 -0
- package/src/runtime/layout/public-facet.ts +748 -16
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +249 -0
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -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 +759 -0
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -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 +368 -19
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +310 -15
- package/src/ui/headless/scoped-chrome-policy.ts +49 -1
- 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/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +29 -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 +498 -163
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -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.tsx +104 -2
- package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role → ordered primary-action id mapping for the top-toolbar's inline
|
|
3
|
+
* role-action region (spec §6.4).
|
|
4
|
+
*
|
|
5
|
+
* The registry in `chrome-registry.ts` knows which ids exist per role;
|
|
6
|
+
* this module decides the *rendering order* so the role region presents
|
|
7
|
+
* a tight, task-focused set instead of the preset-density order.
|
|
8
|
+
*
|
|
9
|
+
* Consumers of `TwRoleActionRegion` look up the array for the active
|
|
10
|
+
* role, iterate it in order, and let `scoped-chrome-policy` filter out
|
|
11
|
+
* anything currently invisible (for instance, review-role accept/reject
|
|
12
|
+
* buttons disappear when there are no pending revisions).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { EditorRole } from "../../api/public-types";
|
|
16
|
+
import type { ToolbarChromeItemId } from "../../ui/headless/chrome-registry";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ordered role-action ids. Each array covers the *role-primary* actions
|
|
20
|
+
* only — the general left cluster (history, formatting, style selectors)
|
|
21
|
+
* and right cluster (tracked-changes, workspace mode, zoom, health,
|
|
22
|
+
* export) stay in the base `TwToolbar` layout regardless of role.
|
|
23
|
+
*/
|
|
24
|
+
export const ROLE_ACTION_SETS: Record<
|
|
25
|
+
EditorRole,
|
|
26
|
+
ReadonlyArray<ToolbarChromeItemId>
|
|
27
|
+
> = {
|
|
28
|
+
editor: [
|
|
29
|
+
// Comment + inline tracked-changes toggle are the two review-layer
|
|
30
|
+
// actions relevant to authoring. They live in the role region rather
|
|
31
|
+
// than the right cluster so the right cluster stays view-focused.
|
|
32
|
+
"comment",
|
|
33
|
+
"tracked-changes-toggle",
|
|
34
|
+
],
|
|
35
|
+
review: [
|
|
36
|
+
// Optional sidebar panel shortcuts — visible only when the host provides
|
|
37
|
+
// hasSidebarPanelAccess (e.g. the harness). Hidden in base runtime.
|
|
38
|
+
"review-sidebar-tracked-changes",
|
|
39
|
+
"review-sidebar-comments",
|
|
40
|
+
// Inline review actions shared with editor role.
|
|
41
|
+
"comment",
|
|
42
|
+
"tracked-changes-toggle",
|
|
43
|
+
// Queue navigation + counts, collapsed from the old TwReviewQueueBar.
|
|
44
|
+
"review-queue-prev",
|
|
45
|
+
"review-queue-next",
|
|
46
|
+
"review-queue-counts",
|
|
47
|
+
"review-queue-active-label",
|
|
48
|
+
// Per-item accept/reject — the canonical review actions.
|
|
49
|
+
"review-accept",
|
|
50
|
+
"review-reject",
|
|
51
|
+
// Batch + markup-mode live in the overflow popover.
|
|
52
|
+
"review-accept-all",
|
|
53
|
+
"review-reject-all",
|
|
54
|
+
"review-markup-mode",
|
|
55
|
+
],
|
|
56
|
+
workflow: [
|
|
57
|
+
// Scoping/posture menu is the primary workflow action (tagging sections).
|
|
58
|
+
"editor-scope-posture-menu",
|
|
59
|
+
// Work-item navigation (distinct from review-queue nav).
|
|
60
|
+
"workflow-prev",
|
|
61
|
+
"workflow-next",
|
|
62
|
+
// Primary workflow actions.
|
|
63
|
+
"workflow-mark-complete",
|
|
64
|
+
"workflow-claim",
|
|
65
|
+
"workflow-skip",
|
|
66
|
+
"workflow-mark-blocked",
|
|
67
|
+
"workflow-jump-to-scope",
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reverse lookup: role → entry order with registry details merged in.
|
|
73
|
+
* Consumers who need to iterate role actions with presets/runtimeBehavior
|
|
74
|
+
* import from here to avoid re-running `Array.find` per render.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveRoleActionOrder(
|
|
77
|
+
role: EditorRole,
|
|
78
|
+
): ReadonlyArray<ToolbarChromeItemId> {
|
|
79
|
+
return ROLE_ACTION_SETS[role];
|
|
80
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared detach/attach primitive for chrome surfaces that can float vs
|
|
3
|
+
* dock. Extracted from the hand-rolled implementation in
|
|
4
|
+
* `tw-selection-tool-host.tsx` so the same UX works on the topnav, the
|
|
5
|
+
* selection tier, and any future overlay layer that opts in.
|
|
6
|
+
*
|
|
7
|
+
* Per runtime-rendering-and-chrome-phase.md §6.3 every overlay child
|
|
8
|
+
* should consume this shape. State is owned by `ViewState.chromePins`
|
|
9
|
+
* so pin offsets survive snapshot rebuilds within one session.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useCallback, useEffect, useRef } from "react";
|
|
13
|
+
import { GripHorizontal } from "lucide-react";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
ChromePinSurface,
|
|
17
|
+
PinState,
|
|
18
|
+
} from "../../api/public-types";
|
|
19
|
+
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
20
|
+
|
|
21
|
+
export interface TwDetachHandleProps {
|
|
22
|
+
/** Which chrome surface this handle controls; stored in ViewState. */
|
|
23
|
+
surface: ChromePinSurface;
|
|
24
|
+
/** Current pin state; `undefined` means docked-default. */
|
|
25
|
+
pin?: PinState;
|
|
26
|
+
/** Callback fired with the next pin state (null = clear). */
|
|
27
|
+
onChange: (surface: ChromePinSurface, pin: PinState | null) => void;
|
|
28
|
+
/** Human label rendered next to the status chip. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** Optional test id override. */
|
|
31
|
+
"data-testid"?: string;
|
|
32
|
+
/** Optional className spliced onto the root. */
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compact grip + Float/Dock toggle row. Consumers mount this inline
|
|
38
|
+
* above the surface's content; when `pin.detached === true` the consumer
|
|
39
|
+
* translates the surface by `pin.offset.x / y` itself (the handle does
|
|
40
|
+
* not wrap the payload).
|
|
41
|
+
*/
|
|
42
|
+
export function TwDetachHandle(props: TwDetachHandleProps): React.JSX.Element {
|
|
43
|
+
const { surface, pin, onChange, label } = props;
|
|
44
|
+
const isDetached = pin?.detached ?? false;
|
|
45
|
+
const offset = pin?.offset ?? { x: 0, y: 0 };
|
|
46
|
+
const dragState = useRef<
|
|
47
|
+
| {
|
|
48
|
+
startX: number;
|
|
49
|
+
startY: number;
|
|
50
|
+
originX: number;
|
|
51
|
+
originY: number;
|
|
52
|
+
}
|
|
53
|
+
| null
|
|
54
|
+
>(null);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (typeof window === "undefined") {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handleMove = (event: MouseEvent) => {
|
|
62
|
+
if (!dragState.current) return;
|
|
63
|
+
const nextX =
|
|
64
|
+
dragState.current.originX + (event.clientX - dragState.current.startX);
|
|
65
|
+
const nextY =
|
|
66
|
+
dragState.current.originY + (event.clientY - dragState.current.startY);
|
|
67
|
+
onChange(surface, { detached: true, offset: { x: nextX, y: nextY } });
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleUp = () => {
|
|
71
|
+
dragState.current = null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
window.addEventListener("mousemove", handleMove);
|
|
75
|
+
window.addEventListener("mouseup", handleUp);
|
|
76
|
+
return () => {
|
|
77
|
+
window.removeEventListener("mousemove", handleMove);
|
|
78
|
+
window.removeEventListener("mouseup", handleUp);
|
|
79
|
+
};
|
|
80
|
+
}, [onChange, surface]);
|
|
81
|
+
|
|
82
|
+
const beginDrag = useCallback(
|
|
83
|
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
84
|
+
preserveEditorSelectionMouseDown(event);
|
|
85
|
+
dragState.current = {
|
|
86
|
+
startX: event.clientX,
|
|
87
|
+
startY: event.clientY,
|
|
88
|
+
originX: isDetached ? offset.x : 0,
|
|
89
|
+
originY: isDetached ? offset.y : 0,
|
|
90
|
+
};
|
|
91
|
+
if (!isDetached) {
|
|
92
|
+
onChange(surface, { detached: true, offset: { x: 0, y: 0 } });
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
[isDetached, offset.x, offset.y, onChange, surface],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const toggle = useCallback(() => {
|
|
99
|
+
if (isDetached) {
|
|
100
|
+
onChange(surface, null);
|
|
101
|
+
} else {
|
|
102
|
+
onChange(surface, { detached: true, offset: { x: 0, y: 0 } });
|
|
103
|
+
}
|
|
104
|
+
}, [isDetached, onChange, surface]);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
className={[
|
|
109
|
+
"inline-flex items-center gap-1.5 self-center rounded-lg border border-border/70 bg-canvas/94 px-1.5 py-1 shadow-[0_8px_20px_-18px_var(--color-shadow-strong)]",
|
|
110
|
+
props.className ?? "",
|
|
111
|
+
]
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.join(" ")}
|
|
114
|
+
data-testid={props["data-testid"] ?? `detach-handle-${surface}`}
|
|
115
|
+
data-surface={surface}
|
|
116
|
+
>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
aria-label={isDetached ? "Drag floating menu" : "Drag to float menu"}
|
|
120
|
+
data-testid={`${surface}-detach-drag-handle`}
|
|
121
|
+
className="inline-flex h-6 items-center justify-center rounded-md border border-transparent px-1.5 text-tertiary transition-colors hover:border-border/60 hover:bg-surface hover:text-primary"
|
|
122
|
+
onMouseDown={beginDrag}
|
|
123
|
+
>
|
|
124
|
+
<GripHorizontal className="h-3 w-3" />
|
|
125
|
+
</button>
|
|
126
|
+
<span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
127
|
+
{label}
|
|
128
|
+
</span>
|
|
129
|
+
<span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.08em] text-secondary">
|
|
130
|
+
{isDetached ? "Floating" : "Docked"}
|
|
131
|
+
</span>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
aria-label={isDetached ? "Dock menu" : "Float menu"}
|
|
135
|
+
aria-pressed={isDetached}
|
|
136
|
+
data-testid={`${surface}-detach-toggle`}
|
|
137
|
+
className="inline-flex h-6 items-center rounded-md border border-border/60 px-2 text-[10px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary"
|
|
138
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
139
|
+
onClick={toggle}
|
|
140
|
+
>
|
|
141
|
+
{isDetached ? "Dock menu" : "Float menu"}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default TwDetachHandle;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection anchor resolver — pure helper that turns a runtime selection
|
|
3
|
+
* into an overlay-space `RenderFrameRect` via the layout facet's render-
|
|
4
|
+
* kernel anchor index.
|
|
5
|
+
*
|
|
6
|
+
* Replaces the DOM-rect + zoomScale math that lived in
|
|
7
|
+
* `tw-review-workspace.tsx` (`resolveSelectionToolbarPlacement`, dropped
|
|
8
|
+
* in R2). Per runtime-rendering-and-chrome-phase.md §6.2, every chrome
|
|
9
|
+
* surface reads anchors from the kernel — not DOM rects — so selection
|
|
10
|
+
* chrome stays glued to canonical positions through scroll, zoom, and
|
|
11
|
+
* predicted-text reconciliation.
|
|
12
|
+
*
|
|
13
|
+
* The resolver is intentionally pure: same facet + selection + tool kind
|
|
14
|
+
* returns the same rect. Consumers compose it with the placement
|
|
15
|
+
* helper (`tw-selection-tool-placement.ts`) and the overlay projector.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
RenderFrameRect,
|
|
20
|
+
SelectionSnapshot,
|
|
21
|
+
TableStructureContextSnapshot,
|
|
22
|
+
WordReviewEditorLayoutFacet,
|
|
23
|
+
} from "../../api/public-types";
|
|
24
|
+
import type {
|
|
25
|
+
ActiveImageContext,
|
|
26
|
+
ActiveObjectContext,
|
|
27
|
+
ActiveSelectionToolModel,
|
|
28
|
+
} from "../../ui/headless/selection-tool-types";
|
|
29
|
+
|
|
30
|
+
export interface ResolveSelectionAnchorInput {
|
|
31
|
+
facet: WordReviewEditorLayoutFacet;
|
|
32
|
+
selection: SelectionSnapshot;
|
|
33
|
+
tool: ActiveSelectionToolModel | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the anchor rect for the currently active selection tool.
|
|
38
|
+
*
|
|
39
|
+
* - formatting / suggestion / comment / workflow / blocked → bySelection
|
|
40
|
+
* over the selection's from/to offsets (collapsed selections fall
|
|
41
|
+
* through to byRuntimeOffset).
|
|
42
|
+
* - structure(image) → byBlockId when the image has a mediaId that the
|
|
43
|
+
* engine can map back to a block; else falls through to selection.
|
|
44
|
+
* - structure(object) → same as image.
|
|
45
|
+
* - structure(table) → byTableCell(tableBlockId, currentCell.row, col);
|
|
46
|
+
* blockId from deterministic "table-{tableBlockIndex}" scheme (P4).
|
|
47
|
+
* - structure(list) → bySelection.
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` when the facet has no render kernel installed or the
|
|
50
|
+
* selection does not resolve to any anchor.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveSelectionAnchor(
|
|
53
|
+
input: ResolveSelectionAnchorInput,
|
|
54
|
+
): RenderFrameRect | null {
|
|
55
|
+
const { facet, selection, tool } = input;
|
|
56
|
+
// No kernel, no anchor — caller falls back to DOM rects in that case.
|
|
57
|
+
const frame =
|
|
58
|
+
typeof facet.getRenderFrame === "function"
|
|
59
|
+
? facet.getRenderFrame() ?? null
|
|
60
|
+
: null;
|
|
61
|
+
if (!frame) return null;
|
|
62
|
+
|
|
63
|
+
if (!tool) {
|
|
64
|
+
return anchorForSelection(frame.anchorIndex, selection);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
switch (tool.kind) {
|
|
68
|
+
case "structure-context": {
|
|
69
|
+
const structural = resolveStructuralAnchor(frame, tool);
|
|
70
|
+
if (structural) return structural;
|
|
71
|
+
return anchorForSelection(frame.anchorIndex, selection);
|
|
72
|
+
}
|
|
73
|
+
case "formatting-inline":
|
|
74
|
+
case "suggestion-review":
|
|
75
|
+
case "comment-thread":
|
|
76
|
+
case "workflow-task":
|
|
77
|
+
case "blocked-explainer":
|
|
78
|
+
return anchorForSelection(frame.anchorIndex, selection);
|
|
79
|
+
default: {
|
|
80
|
+
const _exhaustive: never = tool;
|
|
81
|
+
void _exhaustive;
|
|
82
|
+
return anchorForSelection(frame.anchorIndex, selection);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Internals
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function anchorForSelection(
|
|
92
|
+
anchorIndex: import("../../api/public-types").RenderAnchorIndex,
|
|
93
|
+
selection: SelectionSnapshot,
|
|
94
|
+
): RenderFrameRect | null {
|
|
95
|
+
if (selection.activeRange.kind === "node") {
|
|
96
|
+
// Node selection — snap to the node's runtime offset.
|
|
97
|
+
return anchorIndex.byRuntimeOffset(selection.activeRange.at);
|
|
98
|
+
}
|
|
99
|
+
const from = Math.min(selection.anchor, selection.head);
|
|
100
|
+
const to = Math.max(selection.anchor, selection.head);
|
|
101
|
+
if (from === to) {
|
|
102
|
+
return anchorIndex.byRuntimeOffset(from);
|
|
103
|
+
}
|
|
104
|
+
return anchorIndex.bySelection(from, to);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveStructuralAnchor(
|
|
108
|
+
frame: import("../../api/public-types").RenderFrame,
|
|
109
|
+
tool: ActiveSelectionToolModel & { kind: "structure-context" },
|
|
110
|
+
): RenderFrameRect | null {
|
|
111
|
+
const { anchorIndex } = frame;
|
|
112
|
+
switch (tool.structureKind) {
|
|
113
|
+
case "image":
|
|
114
|
+
return resolveImageAnchor(anchorIndex, tool.activeImage);
|
|
115
|
+
case "object":
|
|
116
|
+
return resolveObjectAnchor(anchorIndex, tool.activeObject);
|
|
117
|
+
case "table":
|
|
118
|
+
return resolveTableAnchor(frame, tool.activeTable ?? undefined);
|
|
119
|
+
case "list":
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveImageAnchor(
|
|
125
|
+
anchorIndex: import("../../api/public-types").RenderAnchorIndex,
|
|
126
|
+
image: ActiveImageContext | undefined,
|
|
127
|
+
): RenderFrameRect | null {
|
|
128
|
+
if (!image) return null;
|
|
129
|
+
// Images are identified by mediaId; the engine emits block fragments
|
|
130
|
+
// whose blockId ties back to the image's anchor block. When the
|
|
131
|
+
// chrome has no better handle, fall back to byBlockId(mediaId) — which
|
|
132
|
+
// may or may not match depending on the engine's mapping. If it
|
|
133
|
+
// doesn't, the outer caller falls through to bySelection.
|
|
134
|
+
const rect = anchorIndex.byBlockId(image.mediaId);
|
|
135
|
+
return rect;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveObjectAnchor(
|
|
139
|
+
anchorIndex: import("../../api/public-types").RenderAnchorIndex,
|
|
140
|
+
_object: ActiveObjectContext | undefined,
|
|
141
|
+
): RenderFrameRect | null {
|
|
142
|
+
void anchorIndex;
|
|
143
|
+
void _object;
|
|
144
|
+
// Shape/textbox anchors don't have a stable id today; fall through to
|
|
145
|
+
// the selection path. Sibling plan P4 adds `byTableCell` and similar
|
|
146
|
+
// precise accessors for structural objects; this lane will adopt them.
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveTableAnchor(
|
|
151
|
+
frame: import("../../api/public-types").RenderFrame,
|
|
152
|
+
table: TableStructureContextSnapshot | undefined,
|
|
153
|
+
): RenderFrameRect | null {
|
|
154
|
+
if (!table) return null;
|
|
155
|
+
// BlockIds follow the deterministic prefix scheme from surface-projection.ts:
|
|
156
|
+
// table blocks are keyed as "table-{tableBlockIndex}".
|
|
157
|
+
const tableBlockId = `table-${table.tableBlockIndex}`;
|
|
158
|
+
const { rowIndex, columnIndex } = table.currentCell;
|
|
159
|
+
return frame.anchorIndex.byTableCell(tableBlockId, rowIndex, columnIndex);
|
|
160
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useCallback, type CSSProperties, type FocusEventHandler, type Ref } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import type {
|
|
4
|
+
ChromePinsState,
|
|
5
|
+
ChromePinSurface,
|
|
6
|
+
PinState,
|
|
7
|
+
RuntimeContextAnalyticsSnapshot,
|
|
8
|
+
} from "../../api/public-types";
|
|
6
9
|
import type { ActiveSelectionToolModel } from "../../ui/headless/selection-tool-types";
|
|
7
|
-
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
8
10
|
import { TwContextAnalyticsSummary } from "./tw-context-analytics-summary";
|
|
11
|
+
import { TwDetachHandle } from "./tw-detach-handle";
|
|
9
12
|
import { TwSelectionToolBlocked } from "./tw-selection-tool-blocked";
|
|
10
13
|
import { TwSelectionToolComment } from "./tw-selection-tool-comment";
|
|
11
14
|
import { TwSelectionToolFormatting } from "./tw-selection-tool-formatting";
|
|
@@ -22,6 +25,13 @@ export interface TwSelectionToolHostProps {
|
|
|
22
25
|
tool: ActiveSelectionToolModel | null;
|
|
23
26
|
contextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
|
|
24
27
|
placement: SelectionToolPlacement | null;
|
|
28
|
+
/**
|
|
29
|
+
* Chrome pin state (R2.3) — selection-tier detach/float persistence
|
|
30
|
+
* rides ViewState.chromePins.selectionTier so pinned tools survive
|
|
31
|
+
* snapshot rebuilds. When absent, the host renders non-pinnable.
|
|
32
|
+
*/
|
|
33
|
+
chromePins?: ChromePinsState;
|
|
34
|
+
onChromePinChange?: (surface: ChromePinSurface, pin: PinState | null) => void;
|
|
25
35
|
rootRef?: Ref<HTMLDivElement>;
|
|
26
36
|
onFocusCapture?: FocusEventHandler<HTMLDivElement>;
|
|
27
37
|
onBlurCapture?: FocusEventHandler<HTMLDivElement>;
|
|
@@ -55,6 +65,12 @@ export interface TwSelectionToolHostProps {
|
|
|
55
65
|
) => void;
|
|
56
66
|
onRestartNumbering?: () => void;
|
|
57
67
|
onContinueNumbering?: () => void;
|
|
68
|
+
// P6: new table ops
|
|
69
|
+
onToggleRowHeader?: () => void;
|
|
70
|
+
onToggleRowCantSplit?: () => void;
|
|
71
|
+
onDistributeColumnsEvenly?: () => void;
|
|
72
|
+
onSetTableAlignment?: (alignment: "left" | "center" | "right") => void;
|
|
73
|
+
onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
|
|
@@ -62,48 +78,22 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
|
|
|
62
78
|
return null;
|
|
63
79
|
}
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
setIsDetached(false);
|
|
77
|
-
setDetachedOffset({ x: 0, y: 0 });
|
|
78
|
-
dragStateRef.current = null;
|
|
79
|
-
}, [props.tool.kind]);
|
|
80
|
-
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (!supportsTopMenuControls || typeof window === "undefined") {
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const handleMouseMove = (event: MouseEvent) => {
|
|
87
|
-
if (!dragStateRef.current) {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
setDetachedOffset({
|
|
91
|
-
x: dragStateRef.current.originX + (event.clientX - dragStateRef.current.startX),
|
|
92
|
-
y: dragStateRef.current.originY + (event.clientY - dragStateRef.current.startY),
|
|
93
|
-
});
|
|
94
|
-
};
|
|
81
|
+
// R2.3: pin state now rides ViewState.chromePins.selectionTier so
|
|
82
|
+
// pinned tools survive snapshot rebuilds. When the host doesn't pass
|
|
83
|
+
// chromePins/onChromePinChange, the detach handle is suppressed (same
|
|
84
|
+
// as the pre-R2 behavior for tool kinds without the handle).
|
|
85
|
+
const pin = props.chromePins?.selectionTier;
|
|
86
|
+
const isDetached = pin?.detached ?? false;
|
|
87
|
+
const detachedOffset = pin?.offset ?? { x: 0, y: 0 };
|
|
88
|
+
const supportsDetach =
|
|
89
|
+
supportsDetachForKind(props.tool.kind) && Boolean(props.onChromePinChange);
|
|
95
90
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return () => {
|
|
103
|
-
window.removeEventListener("mousemove", handleMouseMove);
|
|
104
|
-
window.removeEventListener("mouseup", handleMouseUp);
|
|
105
|
-
};
|
|
106
|
-
}, [supportsTopMenuControls]);
|
|
91
|
+
const handlePinChange = useCallback(
|
|
92
|
+
(surface: ChromePinSurface, next: PinState | null) => {
|
|
93
|
+
props.onChromePinChange?.(surface, next);
|
|
94
|
+
},
|
|
95
|
+
[props.onChromePinChange], // eslint-disable-line react-hooks/exhaustive-deps
|
|
96
|
+
);
|
|
107
97
|
|
|
108
98
|
const overlayTestId = getOverlayTestId(props.tool.kind, Boolean(props.placement));
|
|
109
99
|
const toolContent = renderTool(props, props.tool);
|
|
@@ -124,56 +114,25 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
|
|
|
124
114
|
{toolContent}
|
|
125
115
|
</div>
|
|
126
116
|
) : null;
|
|
127
|
-
const wrappedContent =
|
|
128
|
-
|
|
129
|
-
<div className="
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
dragStateRef.current = {
|
|
139
|
-
startX: event.clientX,
|
|
140
|
-
startY: event.clientY,
|
|
141
|
-
originX: isDetached ? detachedOffset.x : 0,
|
|
142
|
-
originY: isDetached ? detachedOffset.y : 0,
|
|
143
|
-
};
|
|
144
|
-
}}
|
|
145
|
-
>
|
|
146
|
-
<GripHorizontal className="h-3 w-3" />
|
|
147
|
-
</button>
|
|
148
|
-
<span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
149
|
-
{getTopMenuLabel(props.tool.kind)}
|
|
150
|
-
</span>
|
|
151
|
-
<span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.08em] text-secondary">
|
|
152
|
-
{isDetached ? "Floating" : "Docked"}
|
|
153
|
-
</span>
|
|
154
|
-
<button
|
|
155
|
-
type="button"
|
|
156
|
-
aria-label={isDetached ? "Dock menu" : "Float menu"}
|
|
157
|
-
aria-pressed={isDetached}
|
|
158
|
-
data-testid="selection-tool-attach-toggle"
|
|
159
|
-
className="inline-flex h-6 items-center rounded-md border border-border/60 px-2 text-[10px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary"
|
|
160
|
-
onMouseDown={preserveEditorSelectionMouseDown}
|
|
161
|
-
onClick={() => {
|
|
162
|
-
setIsDetached((current) => !current);
|
|
163
|
-
}}
|
|
164
|
-
>
|
|
165
|
-
{isDetached ? "Dock menu" : "Float menu"}
|
|
166
|
-
</button>
|
|
117
|
+
const wrappedContent =
|
|
118
|
+
content && supportsDetach ? (
|
|
119
|
+
<div className="flex flex-col gap-1">
|
|
120
|
+
<TwDetachHandle
|
|
121
|
+
surface="selectionTier"
|
|
122
|
+
pin={pin}
|
|
123
|
+
onChange={handlePinChange}
|
|
124
|
+
label={getTopMenuLabel(props.tool.kind)}
|
|
125
|
+
data-testid={`selection-tool-detach-${props.tool.kind}`}
|
|
126
|
+
/>
|
|
127
|
+
{content}
|
|
167
128
|
</div>
|
|
168
|
-
|
|
169
|
-
</div>
|
|
170
|
-
) : content;
|
|
129
|
+
) : content;
|
|
171
130
|
|
|
172
131
|
if (!wrappedContent) {
|
|
173
132
|
return null;
|
|
174
133
|
}
|
|
175
134
|
|
|
176
|
-
if (isDetached) {
|
|
135
|
+
if (isDetached && supportsDetach) {
|
|
177
136
|
return (
|
|
178
137
|
<div className="pointer-events-none absolute inset-0 z-20" data-testid={overlayTestId}>
|
|
179
138
|
<div
|
|
@@ -263,6 +222,11 @@ function renderTool(
|
|
|
263
222
|
onSetImageFrame={props.onSetImageFrame}
|
|
264
223
|
onRestartNumbering={props.onRestartNumbering}
|
|
265
224
|
onContinueNumbering={props.onContinueNumbering}
|
|
225
|
+
onToggleRowHeader={props.onToggleRowHeader}
|
|
226
|
+
onToggleRowCantSplit={props.onToggleRowCantSplit}
|
|
227
|
+
onDistributeColumnsEvenly={props.onDistributeColumnsEvenly}
|
|
228
|
+
onSetTableAlignment={props.onSetTableAlignment}
|
|
229
|
+
onSetCellVerticalAlign={props.onSetCellVerticalAlign}
|
|
266
230
|
/>
|
|
267
231
|
);
|
|
268
232
|
case "comment-thread":
|
|
@@ -285,8 +249,14 @@ function getOverlayTestId(kind: ActiveSelectionToolModel["kind"], hasPlacement:
|
|
|
285
249
|
}
|
|
286
250
|
}
|
|
287
251
|
|
|
288
|
-
|
|
289
|
-
|
|
252
|
+
/**
|
|
253
|
+
* R2.3: the detach affordance lands on every selection tier that has
|
|
254
|
+
* interactive chrome. `blocked-explainer` remains non-detachable —
|
|
255
|
+
* it's a status-only surface that should dismiss when the selection
|
|
256
|
+
* changes, not get pinned.
|
|
257
|
+
*/
|
|
258
|
+
function supportsDetachForKind(kind: ActiveSelectionToolModel["kind"]): boolean {
|
|
259
|
+
return kind !== "blocked-explainer";
|
|
290
260
|
}
|
|
291
261
|
|
|
292
262
|
function getTopMenuLabel(kind: ActiveSelectionToolModel["kind"]): string {
|
|
@@ -297,6 +267,12 @@ function getTopMenuLabel(kind: ActiveSelectionToolModel["kind"]): string {
|
|
|
297
267
|
return "Suggestion";
|
|
298
268
|
case "workflow-task":
|
|
299
269
|
return "Workflow";
|
|
270
|
+
case "structure-context":
|
|
271
|
+
return "Structure";
|
|
272
|
+
case "comment-thread":
|
|
273
|
+
return "Comment";
|
|
274
|
+
case "blocked-explainer":
|
|
275
|
+
return "Menu";
|
|
300
276
|
default:
|
|
301
277
|
return "Menu";
|
|
302
278
|
}
|