@beyondwork/docx-react-component 1.0.37 → 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.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +319 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +815 -55
  6. package/src/io/export/serialize-main-document.ts +2 -11
  7. package/src/io/export/serialize-numbering.ts +1 -2
  8. package/src/io/export/serialize-tables.ts +74 -0
  9. package/src/io/export/table-properties-xml.ts +139 -4
  10. package/src/io/normalize/normalize-text.ts +15 -0
  11. package/src/io/ooxml/parse-footnotes.ts +60 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  13. package/src/io/ooxml/parse-main-document.ts +137 -0
  14. package/src/io/ooxml/parse-tables.ts +249 -0
  15. package/src/model/canonical-document.ts +34 -0
  16. package/src/runtime/document-layout.ts +4 -2
  17. package/src/runtime/document-navigation.ts +1 -1
  18. package/src/runtime/document-runtime.ts +114 -0
  19. package/src/runtime/layout/default-page-format.ts +96 -0
  20. package/src/runtime/layout/index.ts +45 -0
  21. package/src/runtime/layout/inert-layout-facet.ts +14 -0
  22. package/src/runtime/layout/layout-engine-instance.ts +33 -23
  23. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  24. package/src/runtime/layout/page-format-catalog.ts +233 -0
  25. package/src/runtime/layout/page-graph.ts +19 -0
  26. package/src/runtime/layout/paginated-layout-engine.ts +142 -9
  27. package/src/runtime/layout/project-block-fragments.ts +91 -0
  28. package/src/runtime/layout/public-facet.ts +709 -16
  29. package/src/runtime/layout/table-render-plan.ts +229 -0
  30. package/src/runtime/render/block-fragment-projection.ts +35 -0
  31. package/src/runtime/render/decoration-resolver.ts +189 -0
  32. package/src/runtime/render/index.ts +57 -0
  33. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  34. package/src/runtime/render/render-frame-types.ts +317 -0
  35. package/src/runtime/render/render-kernel.ts +755 -0
  36. package/src/runtime/view-state.ts +67 -0
  37. package/src/runtime/workflow-markup.ts +1 -5
  38. package/src/runtime/workflow-rail-segments.ts +280 -0
  39. package/src/ui/WordReviewEditor.tsx +84 -15
  40. package/src/ui/editor-shell-view.tsx +6 -0
  41. package/src/ui/headless/chrome-registry.ts +280 -14
  42. package/src/ui/headless/scoped-chrome-policy.ts +20 -1
  43. package/src/ui/headless/selection-tool-types.ts +10 -0
  44. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  45. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  46. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  47. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  48. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  49. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  52. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  53. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  54. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  55. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  56. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  57. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  58. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  59. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  60. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +12 -1
  61. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  62. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +3 -0
  63. package/src/ui-tailwind/index.ts +33 -0
  64. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  65. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  66. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  68. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  69. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  70. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  71. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  72. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  73. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +69 -0
  74. package/src/ui-tailwind/tw-review-workspace.tsx +136 -1
@@ -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,163 @@
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 from sibling plan P4 when shipped;
46
+ * today we fall back to bySelection against the selected cells' range).
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
+ // Sibling plan P4 will expose `byTableCell(tableBlockId, row, col)`
156
+ // and `byTableColumnEdge` / `byTableRowEdge` on the anchor index.
157
+ // Today we have only `tableBlockIndex` (ordinal), so the best-effort
158
+ // fallback is the selection path — callers chain to `anchorForSelection`
159
+ // when this returns null.
160
+ void frame;
161
+ void table;
162
+ return null;
163
+ }
@@ -1,11 +1,14 @@
1
- import React, { useEffect, useRef, useState, type CSSProperties, type FocusEventHandler, type Ref } from "react";
1
+ import React, { useCallback, type CSSProperties, type FocusEventHandler, type Ref } from "react";
2
2
 
3
- import { GripHorizontal } from "lucide-react";
4
-
5
- import type { RuntimeContextAnalyticsSnapshot } from "../../api/public-types";
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>;
@@ -62,48 +72,22 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
62
72
  return null;
63
73
  }
64
74
 
65
- const [isDetached, setIsDetached] = useState(false);
66
- const [detachedOffset, setDetachedOffset] = useState({ x: 0, y: 0 });
67
- const dragStateRef = useRef<{
68
- startX: number;
69
- startY: number;
70
- originX: number;
71
- originY: number;
72
- } | null>(null);
73
- const supportsTopMenuControls = isTopMenuKind(props.tool.kind);
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
- };
75
+ // R2.3: pin state now rides ViewState.chromePins.selectionTier so
76
+ // pinned tools survive snapshot rebuilds. When the host doesn't pass
77
+ // chromePins/onChromePinChange, the detach handle is suppressed (same
78
+ // as the pre-R2 behavior for tool kinds without the handle).
79
+ const pin = props.chromePins?.selectionTier;
80
+ const isDetached = pin?.detached ?? false;
81
+ const detachedOffset = pin?.offset ?? { x: 0, y: 0 };
82
+ const supportsDetach =
83
+ supportsDetachForKind(props.tool.kind) && Boolean(props.onChromePinChange);
95
84
 
96
- const handleMouseUp = () => {
97
- dragStateRef.current = null;
98
- };
99
-
100
- window.addEventListener("mousemove", handleMouseMove);
101
- window.addEventListener("mouseup", handleMouseUp);
102
- return () => {
103
- window.removeEventListener("mousemove", handleMouseMove);
104
- window.removeEventListener("mouseup", handleMouseUp);
105
- };
106
- }, [supportsTopMenuControls]);
85
+ const handlePinChange = useCallback(
86
+ (surface: ChromePinSurface, next: PinState | null) => {
87
+ props.onChromePinChange?.(surface, next);
88
+ },
89
+ [props.onChromePinChange], // eslint-disable-line react-hooks/exhaustive-deps
90
+ );
107
91
 
108
92
  const overlayTestId = getOverlayTestId(props.tool.kind, Boolean(props.placement));
109
93
  const toolContent = renderTool(props, props.tool);
@@ -124,56 +108,25 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
124
108
  {toolContent}
125
109
  </div>
126
110
  ) : null;
127
- const wrappedContent = content && supportsTopMenuControls ? (
128
- <div className="flex flex-col gap-1">
129
- <div className="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)]">
130
- <button
131
- type="button"
132
- aria-label={isDetached ? "Drag floating menu" : "Drag to float menu"}
133
- data-testid="selection-tool-drag-handle"
134
- 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"
135
- onMouseDown={(event) => {
136
- preserveEditorSelectionMouseDown(event);
137
- setIsDetached(true);
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>
111
+ const wrappedContent =
112
+ content && supportsDetach ? (
113
+ <div className="flex flex-col gap-1">
114
+ <TwDetachHandle
115
+ surface="selectionTier"
116
+ pin={pin}
117
+ onChange={handlePinChange}
118
+ label={getTopMenuLabel(props.tool.kind)}
119
+ data-testid={`selection-tool-detach-${props.tool.kind}`}
120
+ />
121
+ {content}
167
122
  </div>
168
- {content}
169
- </div>
170
- ) : content;
123
+ ) : content;
171
124
 
172
125
  if (!wrappedContent) {
173
126
  return null;
174
127
  }
175
128
 
176
- if (isDetached) {
129
+ if (isDetached && supportsDetach) {
177
130
  return (
178
131
  <div className="pointer-events-none absolute inset-0 z-20" data-testid={overlayTestId}>
179
132
  <div
@@ -285,8 +238,14 @@ function getOverlayTestId(kind: ActiveSelectionToolModel["kind"], hasPlacement:
285
238
  }
286
239
  }
287
240
 
288
- function isTopMenuKind(kind: ActiveSelectionToolModel["kind"]): boolean {
289
- return kind === "formatting-inline" || kind === "suggestion-review" || kind === "workflow-task";
241
+ /**
242
+ * R2.3: the detach affordance lands on every selection tier that has
243
+ * interactive chrome. `blocked-explainer` remains non-detachable —
244
+ * it's a status-only surface that should dismiss when the selection
245
+ * changes, not get pinned.
246
+ */
247
+ function supportsDetachForKind(kind: ActiveSelectionToolModel["kind"]): boolean {
248
+ return kind !== "blocked-explainer";
290
249
  }
291
250
 
292
251
  function getTopMenuLabel(kind: ActiveSelectionToolModel["kind"]): string {
@@ -297,6 +256,12 @@ function getTopMenuLabel(kind: ActiveSelectionToolModel["kind"]): string {
297
256
  return "Suggestion";
298
257
  case "workflow-task":
299
258
  return "Workflow";
259
+ case "structure-context":
260
+ return "Structure";
261
+ case "comment-thread":
262
+ return "Comment";
263
+ case "blocked-explainer":
264
+ return "Menu";
300
265
  default:
301
266
  return "Menu";
302
267
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Selection-tool placement helper — pure function that decides where a
3
+ * selection-anchored floating chrome panel renders relative to its
4
+ * anchor rect.
5
+ *
6
+ * Extracts the right→left→above/below decision that lived in
7
+ * `tw-review-workspace.tsx::resolveSelectionToolbarPlacement` so
8
+ * consumers can feed it render-frame rects instead of DOM rects (R2.1
9
+ * owns the rect production; this helper is zoom-agnostic).
10
+ *
11
+ * The output is a `SelectionToolPlacement` compatible with
12
+ * `tw-selection-tool-host`'s existing contract so the rewire is
13
+ * additive — callers that already use `SelectionToolPlacement` keep
14
+ * working.
15
+ */
16
+
17
+ import type { CSSProperties } from "react";
18
+
19
+ import type { RenderFrameRect } from "../../api/public-types";
20
+
21
+ export interface SelectionToolPlacementDecision {
22
+ placement: "right" | "left" | "above" | "below";
23
+ style: CSSProperties;
24
+ }
25
+
26
+ export interface ResolveSelectionToolPlacementInput {
27
+ /** Anchor rect in overlay coordinate space. */
28
+ anchor: RenderFrameRect;
29
+ /** Container size in overlay coordinate space. */
30
+ container: { widthPx: number; heightPx: number };
31
+ /**
32
+ * Estimated toolbar width in px. Defaults to a reasonable bounded
33
+ * window (`clamp(168, 32% of container, 260)`) so the helper can run
34
+ * before the toolbar measures its own width.
35
+ */
36
+ estimatedToolbarWidthPx?: number;
37
+ /**
38
+ * Estimated toolbar height in px. Defaults to 44 (matches the
39
+ * legacy DOM-based math).
40
+ */
41
+ estimatedToolbarHeightPx?: number;
42
+ /** Edge padding in px. Defaults to 16. */
43
+ edgePaddingPx?: number;
44
+ /** Gap between anchor and toolbar in px. Defaults to 12. */
45
+ gapPx?: number;
46
+ }
47
+
48
+ const DEFAULT_EDGE_PADDING = 16;
49
+ const DEFAULT_GAP = 12;
50
+ const DEFAULT_TOOLBAR_HEIGHT = 44;
51
+ const TOOLBAR_WIDTH_MIN = 168;
52
+ const TOOLBAR_WIDTH_MAX = 260;
53
+ const TOOLBAR_WIDTH_CONTAINER_FRACTION = 0.32;
54
+
55
+ /**
56
+ * Decide where to render the selection-tool panel relative to its
57
+ * anchor. Prefers `right` when there is clearance, falls back to
58
+ * `left`, then `above` / `below` based on vertical room.
59
+ *
60
+ * Returns `null` when the container is zero-sized (the caller should
61
+ * then skip rendering entirely).
62
+ */
63
+ export function resolveSelectionToolPlacement(
64
+ input: ResolveSelectionToolPlacementInput,
65
+ ): SelectionToolPlacementDecision | null {
66
+ const { anchor, container } = input;
67
+ if (container.widthPx <= 0 || container.heightPx <= 0) return null;
68
+
69
+ const edgePadding = input.edgePaddingPx ?? DEFAULT_EDGE_PADDING;
70
+ const gapPx = input.gapPx ?? DEFAULT_GAP;
71
+ const toolbarHeight = input.estimatedToolbarHeightPx ?? DEFAULT_TOOLBAR_HEIGHT;
72
+ const toolbarWidth =
73
+ input.estimatedToolbarWidthPx ??
74
+ Math.min(
75
+ TOOLBAR_WIDTH_MAX,
76
+ Math.max(
77
+ TOOLBAR_WIDTH_MIN,
78
+ container.widthPx * TOOLBAR_WIDTH_CONTAINER_FRACTION,
79
+ ),
80
+ );
81
+
82
+ const anchorLeft = anchor.leftPx;
83
+ const anchorRight = anchor.leftPx + anchor.widthPx;
84
+ const anchorTop = anchor.topPx;
85
+ const anchorBottom = anchor.topPx + anchor.heightPx;
86
+ const centerX = anchorLeft + anchor.widthPx / 2;
87
+ const centerY = anchorTop + anchor.heightPx / 2;
88
+
89
+ const rightClearance = container.widthPx - anchorRight - gapPx - edgePadding;
90
+ const leftClearance = anchorLeft - gapPx - edgePadding;
91
+
92
+ const clampedCenterX = Math.max(
93
+ edgePadding,
94
+ Math.min(
95
+ centerX,
96
+ Math.max(edgePadding, container.widthPx - edgePadding),
97
+ ),
98
+ );
99
+ const clampedCenterY = Math.max(
100
+ edgePadding + toolbarHeight / 2,
101
+ Math.min(
102
+ centerY,
103
+ Math.max(
104
+ edgePadding + toolbarHeight / 2,
105
+ container.heightPx - edgePadding - toolbarHeight / 2,
106
+ ),
107
+ ),
108
+ );
109
+ const maxWidthPx = Math.max(220, container.widthPx - edgePadding * 2);
110
+
111
+ if (rightClearance >= toolbarWidth) {
112
+ return {
113
+ placement: "right",
114
+ style: {
115
+ left: `${anchorRight}px`,
116
+ top: `${clampedCenterY}px`,
117
+ maxWidth: `${maxWidthPx}px`,
118
+ transform: `translate(${gapPx}px, -50%)`,
119
+ },
120
+ };
121
+ }
122
+
123
+ if (leftClearance >= toolbarWidth) {
124
+ return {
125
+ placement: "left",
126
+ style: {
127
+ left: `${anchorLeft}px`,
128
+ top: `${clampedCenterY}px`,
129
+ maxWidth: `${maxWidthPx}px`,
130
+ transform: `translate(calc(-100% - ${gapPx}px), -50%)`,
131
+ },
132
+ };
133
+ }
134
+
135
+ const placement: "above" | "below" =
136
+ anchorTop < toolbarHeight + gapPx + edgePadding ? "below" : "above";
137
+ return {
138
+ placement,
139
+ style: {
140
+ left: `${clampedCenterX}px`,
141
+ top: `${placement === "above" ? anchorTop : anchorBottom}px`,
142
+ maxWidth: `${maxWidthPx}px`,
143
+ transform:
144
+ placement === "above"
145
+ ? `translate(-50%, calc(-100% - ${gapPx}px))`
146
+ : `translate(-50%, ${gapPx}px)`,
147
+ },
148
+ };
149
+ }