@beyondwork/docx-react-component 1.0.88 → 1.0.89
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 +1 -1
- package/src/api/v3/_runtime-handle.ts +5 -0
- package/src/api/v3/ai/replacement.ts +82 -0
- package/src/api/v3/runtime/content.ts +3 -0
- package/src/api/v3/runtime/formatting.ts +64 -0
- package/src/core/commands/formatting-commands.ts +107 -0
- package/src/core/state/text-transaction.ts +11 -4
- package/src/runtime/document-runtime.ts +51 -0
- package/src/runtime/scopes/_scope-dependencies.ts +1 -0
- package/src/runtime/scopes/action-validation.ts +12 -3
- package/src/runtime/scopes/audit-bundle.ts +2 -2
- package/src/runtime/scopes/compiler-service.ts +70 -0
- package/src/runtime/scopes/formatting/apply.ts +262 -0
- package/src/runtime/scopes/index.ts +12 -0
- package/src/runtime/scopes/replacement/propose.ts +2 -0
- package/src/runtime/scopes/scope-kinds/paragraph.ts +1 -0
- package/src/runtime/scopes/semantic-scope-types.ts +48 -4
- package/src/runtime/scopes/workflow-overlap.ts +9 -11
- package/src/shell/session-bootstrap.ts +1 -0
- package/src/ui/WordReviewEditor.tsx +277 -28
- package/src/ui/editor-command-bag.ts +11 -0
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/headless/chrome-registry.ts +6 -6
- package/src/ui/headless/role-action-sets.ts +4 -10
- package/src/ui/headless/selection-tool-resolver.ts +11 -0
- package/src/ui-tailwind/chrome/editor-action-registry.ts +1 -1
- package/src/ui-tailwind/chrome/tw-context-band.tsx +7 -7
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +13 -18
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +8 -5
- package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +100 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +0 -40
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +9 -7
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +17 -1
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +6 -1
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +17 -7
- package/src/ui-tailwind/editor-surface/preserve-position.ts +30 -5
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +5 -1
- package/src/ui-tailwind/review/tw-review-rail.tsx +5 -0
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +32 -38
- package/src/ui-tailwind/review-workspace/types.ts +2 -0
- package/src/ui-tailwind/theme/editor-theme.css +25 -12
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +13 -4
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +6 -15
- package/src/ui-tailwind/tw-review-workspace.tsx +28 -18
- package/src/ui-tailwind/workflow-scope-layers.ts +70 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared
|
|
3
|
-
*
|
|
2
|
+
* Shared placement primitive for chrome surfaces that can be pinned near
|
|
3
|
+
* the document. Extracted from the hand-rolled implementation in
|
|
4
4
|
* `tw-selection-tool-host.tsx` so the same UX works on the topnav, the
|
|
5
5
|
* selection tier, and any future overlay layer that opts in.
|
|
6
6
|
*
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import React, { useCallback, useEffect, useRef } from "react";
|
|
13
|
-
import { GripHorizontal } from "lucide-react";
|
|
13
|
+
import { GripHorizontal, Pin, PinOff } from "lucide-react";
|
|
14
14
|
|
|
15
15
|
import type {
|
|
16
16
|
ChromePinSurface,
|
|
@@ -21,11 +21,11 @@ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-edi
|
|
|
21
21
|
export interface TwDetachHandleProps {
|
|
22
22
|
/** Which chrome surface this handle controls; stored in ViewState. */
|
|
23
23
|
surface: ChromePinSurface;
|
|
24
|
-
/** Current pin state; `undefined` means
|
|
24
|
+
/** Current pin state; `undefined` means the surface follows its anchor. */
|
|
25
25
|
pin?: PinState;
|
|
26
26
|
/** Callback fired with the next pin state (null = clear). */
|
|
27
27
|
onChange: (surface: ChromePinSurface, pin: PinState | null) => void;
|
|
28
|
-
/** Human label
|
|
28
|
+
/** Human label used for accessible labels. No visible product copy is rendered. */
|
|
29
29
|
label: string;
|
|
30
30
|
/** Optional test id override. */
|
|
31
31
|
"data-testid"?: string;
|
|
@@ -34,7 +34,7 @@ export interface TwDetachHandleProps {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* Compact grip +
|
|
37
|
+
* Compact grip + keep-visible toggle row. Consumers mount this inline
|
|
38
38
|
* above the surface's content; when `pin.detached === true` the consumer
|
|
39
39
|
* translates the surface by `pin.offset.x / y` itself (the handle does
|
|
40
40
|
* not wrap the payload).
|
|
@@ -110,39 +110,34 @@ export function TwDetachHandle(props: TwDetachHandleProps): React.JSX.Element {
|
|
|
110
110
|
return (
|
|
111
111
|
<div
|
|
112
112
|
className={[
|
|
113
|
-
"inline-flex items-center gap-
|
|
113
|
+
"inline-flex items-center gap-0.5 self-center rounded-lg border border-border/70 bg-canvas/94 px-1 py-1 shadow-[0_8px_20px_-18px_var(--color-shadow-strong)]",
|
|
114
114
|
props.className ?? "",
|
|
115
115
|
]
|
|
116
116
|
.filter(Boolean)
|
|
117
117
|
.join(" ")}
|
|
118
118
|
data-testid={props["data-testid"] ?? `detach-handle-${surface}`}
|
|
119
119
|
data-surface={surface}
|
|
120
|
+
aria-label={`${label} placement controls`}
|
|
120
121
|
>
|
|
121
122
|
<button
|
|
122
123
|
type="button"
|
|
123
|
-
aria-label={
|
|
124
|
+
aria-label={`Move ${label}`}
|
|
124
125
|
data-testid={dragHandleTestId}
|
|
125
|
-
className="inline-flex h-6 items-center justify-center rounded-md border border-transparent
|
|
126
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-transparent text-tertiary transition-colors hover:border-border/60 hover:bg-surface hover:text-primary"
|
|
126
127
|
onMouseDown={beginDrag}
|
|
127
128
|
>
|
|
128
129
|
<GripHorizontal className="h-3 w-3" />
|
|
129
130
|
</button>
|
|
130
|
-
<span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
131
|
-
{label}
|
|
132
|
-
</span>
|
|
133
|
-
<span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.08em] text-secondary">
|
|
134
|
-
{isDetached ? "Floating" : "Docked"}
|
|
135
|
-
</span>
|
|
136
131
|
<button
|
|
137
132
|
type="button"
|
|
138
|
-
aria-label={isDetached ?
|
|
133
|
+
aria-label={isDetached ? `Return ${label} to selection` : `Keep ${label} visible`}
|
|
139
134
|
aria-pressed={isDetached}
|
|
140
135
|
data-testid={toggleTestId}
|
|
141
|
-
className="inline-flex h-6 items-center rounded-md border border-border/60
|
|
136
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/60 text-secondary transition-colors hover:bg-surface hover:text-primary"
|
|
142
137
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
143
138
|
onClick={toggle}
|
|
144
139
|
>
|
|
145
|
-
{isDetached ? "
|
|
140
|
+
{isDetached ? <PinOff className="h-3 w-3" /> : <Pin className="h-3 w-3" />}
|
|
146
141
|
</button>
|
|
147
142
|
</div>
|
|
148
143
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { MoreHorizontal } from "lucide-react";
|
|
2
3
|
|
|
3
4
|
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
4
5
|
import type { ActiveImageContext } from "../../ui/headless/selection-tool-types";
|
|
@@ -17,7 +18,7 @@ export interface TwImageContextToolbarProps {
|
|
|
17
18
|
) => void;
|
|
18
19
|
/**
|
|
19
20
|
* Phase D.3 — progressive-disclosure compact mode. When `true`,
|
|
20
|
-
* the toolbar reduces to the image badge + a single
|
|
21
|
+
* the toolbar reduces to the image badge + a single actions button
|
|
21
22
|
* that opens the shared context menu via `onOpenMore`. Size
|
|
22
23
|
* presets + nudge controls move into the registry's image actions
|
|
23
24
|
* (see `editor-action-registry.ts` — image-size-small/medium/large
|
|
@@ -37,6 +38,7 @@ const NUDGE_EMU = 228600;
|
|
|
37
38
|
|
|
38
39
|
export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
|
|
39
40
|
const { activeImage } = props;
|
|
41
|
+
const displayLabel = activeImage.display === "floating" ? "Positioned" : "Inline";
|
|
40
42
|
|
|
41
43
|
if (props.compact) {
|
|
42
44
|
return (
|
|
@@ -49,13 +51,14 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
|
|
|
49
51
|
Image
|
|
50
52
|
</span>
|
|
51
53
|
<span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.1em] text-secondary">
|
|
52
|
-
{
|
|
54
|
+
{displayLabel}
|
|
53
55
|
</span>
|
|
54
56
|
<button
|
|
55
57
|
type="button"
|
|
56
58
|
data-testid="image-context-toolbar-more"
|
|
57
59
|
aria-label="Image actions menu"
|
|
58
|
-
|
|
60
|
+
title="Image actions"
|
|
61
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded-[var(--radius-sm)] text-secondary hover:bg-hover focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]"
|
|
59
62
|
disabled={props.disabled}
|
|
60
63
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
61
64
|
onClick={(ev) => {
|
|
@@ -63,7 +66,7 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
|
|
|
63
66
|
props.onOpenMore?.({ clientX: rect.left, clientY: rect.bottom });
|
|
64
67
|
}}
|
|
65
68
|
>
|
|
66
|
-
|
|
69
|
+
<MoreHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
|
|
67
70
|
</button>
|
|
68
71
|
</div>
|
|
69
72
|
);
|
|
@@ -78,7 +81,7 @@ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
|
|
|
78
81
|
Image
|
|
79
82
|
</span>
|
|
80
83
|
<span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.1em] text-secondary">
|
|
81
|
-
{
|
|
84
|
+
{displayLabel}
|
|
82
85
|
</span>
|
|
83
86
|
<div role="group" aria-label="Image size" className="inline-flex items-center rounded-[var(--radius-sm)] bg-[var(--color-bg-muted)] p-0.5">
|
|
84
87
|
{IMAGE_SIZE_PRESETS.map((preset) => {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
5
|
+
|
|
6
|
+
export interface TwInlineFindBarProps {
|
|
7
|
+
query: string;
|
|
8
|
+
activeIndex: number;
|
|
9
|
+
resultCount: number;
|
|
10
|
+
onQueryChange: (query: string) => void;
|
|
11
|
+
onPrevious: () => void;
|
|
12
|
+
onNext: () => void;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TwInlineFindBar(props: TwInlineFindBarProps): React.JSX.Element {
|
|
17
|
+
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
|
18
|
+
const hasResults = props.resultCount > 0;
|
|
19
|
+
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
inputRef.current?.focus();
|
|
22
|
+
inputRef.current?.select();
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className="pointer-events-auto flex w-[min(420px,calc(100vw-2rem))] items-center gap-2 rounded-2xl border border-[color:color-mix(in_srgb,var(--color-accent-primary)_28%,var(--color-border-subtle))] bg-[color:color-mix(in_srgb,var(--color-bg-canvas)_94%,white)] px-2.5 py-2 shadow-[0_18px_50px_rgba(20,31,29,0.18)]"
|
|
28
|
+
data-testid="inline-find-bar"
|
|
29
|
+
role="search"
|
|
30
|
+
aria-label="Find in document"
|
|
31
|
+
>
|
|
32
|
+
<Search className="h-4 w-4 shrink-0 text-accent" aria-hidden="true" />
|
|
33
|
+
<input
|
|
34
|
+
ref={inputRef}
|
|
35
|
+
aria-label="Find text"
|
|
36
|
+
className="min-w-0 flex-1 bg-transparent px-1 text-[13px] font-medium text-primary outline-none placeholder:text-tertiary"
|
|
37
|
+
placeholder="Find in document"
|
|
38
|
+
type="search"
|
|
39
|
+
value={props.query}
|
|
40
|
+
onChange={(event) => props.onQueryChange(event.currentTarget.value)}
|
|
41
|
+
onKeyDown={(event) => {
|
|
42
|
+
if (event.key === "Escape") {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
props.onClose();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (event.key === "Enter") {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
if (event.shiftKey) {
|
|
50
|
+
props.onPrevious();
|
|
51
|
+
} else {
|
|
52
|
+
props.onNext();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
<span
|
|
58
|
+
className="min-w-[56px] rounded-full bg-[color:color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)] px-2 py-1 text-center text-[11px] font-semibold tabular-nums text-accent"
|
|
59
|
+
aria-live="polite"
|
|
60
|
+
>
|
|
61
|
+
{props.query.trim()
|
|
62
|
+
? hasResults
|
|
63
|
+
? `${props.activeIndex + 1}/${props.resultCount}`
|
|
64
|
+
: "0/0"
|
|
65
|
+
: "Find"}
|
|
66
|
+
</span>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
aria-label="Previous match"
|
|
70
|
+
disabled={!hasResults}
|
|
71
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-secondary transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-35"
|
|
72
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
73
|
+
onClick={props.onPrevious}
|
|
74
|
+
>
|
|
75
|
+
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
|
76
|
+
</button>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
aria-label="Next match"
|
|
80
|
+
disabled={!hasResults}
|
|
81
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-secondary transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-35"
|
|
82
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
83
|
+
onClick={props.onNext}
|
|
84
|
+
>
|
|
85
|
+
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
|
86
|
+
</button>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
aria-label="Close find"
|
|
90
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-tertiary transition-colors hover:bg-hover hover:text-primary"
|
|
91
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
92
|
+
onClick={props.onClose}
|
|
93
|
+
>
|
|
94
|
+
<X className="h-4 w-4" aria-hidden="true" />
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default TwInlineFindBar;
|
|
@@ -49,7 +49,6 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
|
|
|
49
49
|
const density = props.density ?? "full";
|
|
50
50
|
const addCommentDisabled = !model.canAddComment;
|
|
51
51
|
const formattingDisabled = !model.canToggleFormatting;
|
|
52
|
-
const contextLabel = summarizeSelectionContext(model);
|
|
53
52
|
const tooltipLabel = addCommentDisabled
|
|
54
53
|
? props.disabledReason ?? "Select text within one paragraph to add a DOCX comment"
|
|
55
54
|
: "Add comment";
|
|
@@ -144,49 +143,10 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
|
|
|
144
143
|
</Tooltip.Portal>
|
|
145
144
|
</Tooltip.Root>
|
|
146
145
|
|
|
147
|
-
{model.previewText ? (
|
|
148
|
-
<>
|
|
149
|
-
<div className="mx-0.5 h-4 w-px bg-[var(--color-border-subtle)]" />
|
|
150
|
-
<span className="max-w-[7rem] truncate text-[10px] text-secondary">
|
|
151
|
-
{model.previewText}
|
|
152
|
-
</span>
|
|
153
|
-
</>
|
|
154
|
-
) : null}
|
|
155
|
-
|
|
156
|
-
{contextLabel ? (
|
|
157
|
-
<>
|
|
158
|
-
{!model.previewText ? <div className="mx-0.5 h-4 w-px bg-[var(--color-border-subtle)]" /> : null}
|
|
159
|
-
<span
|
|
160
|
-
className={`min-w-0 max-w-[9rem] truncate rounded-full px-1.5 py-0.5 text-[9px] font-medium tracking-[0.08em] ${
|
|
161
|
-
model.badges.some((badge) => badge.tone === "accent")
|
|
162
|
-
? "bg-canvas text-accent ring-1 ring-accent/25"
|
|
163
|
-
: "bg-surface text-tertiary"
|
|
164
|
-
}`}
|
|
165
|
-
>
|
|
166
|
-
{contextLabel}
|
|
167
|
-
</span>
|
|
168
|
-
</>
|
|
169
|
-
) : null}
|
|
170
146
|
</div>
|
|
171
147
|
);
|
|
172
148
|
});
|
|
173
149
|
|
|
174
|
-
function summarizeSelectionContext(model: SelectionToolbarModel): string | null {
|
|
175
|
-
if (model.badges.length === 0) {
|
|
176
|
-
return null;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const accentBadges = model.badges.filter((badge) => badge.tone === "accent");
|
|
180
|
-
const source = accentBadges.length > 0 ? accentBadges : model.badges;
|
|
181
|
-
const labels = source.slice(0, 2).map((badge) => badge.label.trim()).filter(Boolean);
|
|
182
|
-
if (labels.length === 0) {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const summary = labels.join(" · ");
|
|
187
|
-
return summary.length > 30 ? `${summary.slice(0, 27)}...` : summary;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
150
|
interface ToolbarActionButtonProps {
|
|
191
151
|
icon: React.ReactNode;
|
|
192
152
|
label: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { MoreHorizontal } from "lucide-react";
|
|
2
3
|
|
|
3
4
|
import type {
|
|
4
5
|
TableOperationCapabilitySnapshot,
|
|
@@ -34,7 +35,7 @@ export interface TwTableContextToolbarProps {
|
|
|
34
35
|
* When `true`, the toolbar stays action-first:
|
|
35
36
|
* - a small context label
|
|
36
37
|
* - the tier's highest-frequency table actions
|
|
37
|
-
* - a single
|
|
38
|
+
* - a single icon button that opens the shared command graph
|
|
38
39
|
*
|
|
39
40
|
* Diagnostic metadata such as "3 x 4" or "R1 C1" is intentionally
|
|
40
41
|
* omitted from the compact surface. It belongs in properties /
|
|
@@ -46,7 +47,7 @@ export interface TwTableContextToolbarProps {
|
|
|
46
47
|
*/
|
|
47
48
|
compact?: boolean;
|
|
48
49
|
/**
|
|
49
|
-
* Fires when the user clicks the
|
|
50
|
+
* Fires when the user clicks the compact actions button.
|
|
50
51
|
* Receives the button's clientX / clientY so the integrator can
|
|
51
52
|
* open `TwContextMenu` at the button anchor via
|
|
52
53
|
* `chromeControllerRef.current?.openWithKinds({ kinds: ["table-cell"], clientX, clientY })`.
|
|
@@ -84,7 +85,7 @@ const CELL_FILL_PRESETS = [
|
|
|
84
85
|
*
|
|
85
86
|
* - `caret-in-cell` (T2) — single-cell selection. Minimal inline set:
|
|
86
87
|
* row +/−, column +/−. Anything structural (merge/split/fill/delete
|
|
87
|
-
* table) lives behind
|
|
88
|
+
* table) lives behind the actions button to keep the panel ~180px wide.
|
|
88
89
|
* - `multi-cell` (T3) — >1 cell but not a full row/column/table. Adds
|
|
89
90
|
* merge/split/fill palette.
|
|
90
91
|
* - `row-selected` (T4a) — selection spans exactly one full row
|
|
@@ -141,9 +142,9 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
|
|
|
141
142
|
: null;
|
|
142
143
|
const selectionLabel = tableContext ? formatSelectionLabel(tableContext, tier) : null;
|
|
143
144
|
|
|
144
|
-
// Product compact variant: action-first local chrome + one
|
|
145
|
+
// Product compact variant: action-first local chrome + one actions
|
|
145
146
|
// button that opens the shared context menu. The full action set
|
|
146
|
-
// still lives in editor-action-registry so right-click and
|
|
147
|
+
// still lives in editor-action-registry so right-click and local actions
|
|
147
148
|
// stay identical; this surface only promotes the tier's obvious next
|
|
148
149
|
// actions and leaves metadata to deeper surfaces.
|
|
149
150
|
if (props.compact) {
|
|
@@ -170,7 +171,8 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
|
|
|
170
171
|
type="button"
|
|
171
172
|
data-testid="table-context-toolbar-more"
|
|
172
173
|
aria-label="Table actions menu"
|
|
173
|
-
|
|
174
|
+
title="Table actions"
|
|
175
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded-[var(--radius-sm)] text-secondary hover:bg-hover focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]"
|
|
174
176
|
disabled={props.disabled || !props.onOpenMore}
|
|
175
177
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
176
178
|
onClick={(ev) => {
|
|
@@ -181,7 +183,7 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
|
|
|
181
183
|
});
|
|
182
184
|
}}
|
|
183
185
|
>
|
|
184
|
-
|
|
186
|
+
<MoreHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
|
|
185
187
|
</button>
|
|
186
188
|
</div>
|
|
187
189
|
);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import * as React from "react";
|
|
16
16
|
import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
|
|
17
|
-
import type { ScopeRailSegment } from "../../api/public-types.ts";
|
|
17
|
+
import type { ScopeRailPosture, ScopeRailSegment } from "../../api/public-types.ts";
|
|
18
18
|
import type {
|
|
19
19
|
EditorRole,
|
|
20
20
|
EditorStoryTarget,
|
|
@@ -49,6 +49,8 @@ export interface TwChromeOverlayProps {
|
|
|
49
49
|
space?: OverlayCoordinateSpace;
|
|
50
50
|
/** Active scope id (for emphasis + rail tab sync). */
|
|
51
51
|
activeScopeId?: string | null;
|
|
52
|
+
/** Posture filters shared with the Workflow rail. Omitted means all scopes render. */
|
|
53
|
+
visibleScopePostures?: ReadonlySet<ScopeRailPosture>;
|
|
52
54
|
/**
|
|
53
55
|
* Click handler fired when the user clicks a scope rail stripe.
|
|
54
56
|
* P0 wires this to open the scope card (P1 ships the card layer).
|
|
@@ -213,6 +215,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
213
215
|
geometryFacet,
|
|
214
216
|
space,
|
|
215
217
|
activeScopeId,
|
|
218
|
+
visibleScopePostures,
|
|
216
219
|
onScopeStripeClick,
|
|
217
220
|
onScopeSegmentClick,
|
|
218
221
|
onScopeCardClose,
|
|
@@ -242,6 +245,17 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
242
245
|
mediaPreviews,
|
|
243
246
|
activeBandRibbonProps,
|
|
244
247
|
}) => {
|
|
248
|
+
const visibleScopeIds = React.useMemo(() => {
|
|
249
|
+
if (!visibleScopePostures) return undefined;
|
|
250
|
+
const ids = new Set<string>();
|
|
251
|
+
for (const segment of workflowFacet?.getAllRailSegments() ?? []) {
|
|
252
|
+
if (visibleScopePostures.has(segment.posture)) {
|
|
253
|
+
ids.add(segment.scopeId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return ids;
|
|
257
|
+
}, [visibleScopePostures, workflowFacet]);
|
|
258
|
+
|
|
245
259
|
return (
|
|
246
260
|
<div
|
|
247
261
|
className="wre-chrome-overlay pointer-events-none absolute inset-0 z-30"
|
|
@@ -268,6 +282,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
268
282
|
workflowFacet={workflowFacet}
|
|
269
283
|
space={space}
|
|
270
284
|
activeScopeId={activeScopeId}
|
|
285
|
+
visibleScopePostures={visibleScopePostures}
|
|
271
286
|
onStripeClick={onScopeStripeClick}
|
|
272
287
|
onSegmentClick={onScopeSegmentClick}
|
|
273
288
|
/>
|
|
@@ -275,6 +290,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
275
290
|
facet={facet}
|
|
276
291
|
workflowFacet={workflowFacet}
|
|
277
292
|
activeScopeId={activeScopeId ?? null}
|
|
293
|
+
visibleScopeIds={visibleScopeIds}
|
|
278
294
|
onClose={onScopeCardClose ?? noop}
|
|
279
295
|
onModeChange={onScopeCardModeChange ?? noopModeChange}
|
|
280
296
|
onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
|
|
@@ -52,6 +52,8 @@ export interface TwScopeCardLayerProps {
|
|
|
52
52
|
*/
|
|
53
53
|
workflowFacet: WorkflowFacet | null;
|
|
54
54
|
activeScopeId: string | null;
|
|
55
|
+
/** Scope ids currently visible under the Workflow rail layer filters. */
|
|
56
|
+
visibleScopeIds?: ReadonlySet<string>;
|
|
55
57
|
onClose: () => void;
|
|
56
58
|
onModeChange: (scopeId: string, mode: WorkflowScopeMode) => void;
|
|
57
59
|
onIssueAction: (
|
|
@@ -91,6 +93,7 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
|
|
|
91
93
|
facet,
|
|
92
94
|
workflowFacet,
|
|
93
95
|
activeScopeId,
|
|
96
|
+
visibleScopeIds,
|
|
94
97
|
onClose,
|
|
95
98
|
onModeChange,
|
|
96
99
|
onIssueAction,
|
|
@@ -119,7 +122,9 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
|
|
|
119
122
|
// The effective scope is the pinned one if it still resolves to a
|
|
120
123
|
// model, else the active one. When a pinned scope disappears
|
|
121
124
|
// (e.g. the host cleared the overlay), drop the pin.
|
|
122
|
-
const models = workflowFacet?.getAllScopeCardModels() ?? []
|
|
125
|
+
const models = (workflowFacet?.getAllScopeCardModels() ?? []).filter((model) =>
|
|
126
|
+
visibleScopeIds ? visibleScopeIds.has(model.scopeId) : true,
|
|
127
|
+
);
|
|
123
128
|
|
|
124
129
|
const pinnedModel = pinnedScopeId
|
|
125
130
|
? models.find((m) => m.scopeId === pinnedScopeId) ?? null
|
|
@@ -46,6 +46,8 @@ export interface TwScopeRailLayerProps {
|
|
|
46
46
|
railLaneWidthPx?: number;
|
|
47
47
|
/** Scope id that should render with the `active` emphasis. */
|
|
48
48
|
activeScopeId?: string | null;
|
|
49
|
+
/** Posture filters shared with the Workflow rail. Omitted means all scopes render. */
|
|
50
|
+
visibleScopePostures?: ReadonlySet<ScopeRailPosture>;
|
|
49
51
|
/**
|
|
50
52
|
* Fires when the user clicks the rail stripe — opens the scope card.
|
|
51
53
|
* P0 wires this directly; P1 replaces with card-layer-aware routing.
|
|
@@ -87,8 +89,8 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
|
|
|
87
89
|
// ---------------------------------------------------------------------------
|
|
88
90
|
|
|
89
91
|
const DEFAULT_RAIL_LANE_PX = 44;
|
|
90
|
-
const STRIPE_WIDTH_PX =
|
|
91
|
-
const LABEL_WIDTH_PX =
|
|
92
|
+
const STRIPE_WIDTH_PX = 6;
|
|
93
|
+
const LABEL_WIDTH_PX = 58;
|
|
92
94
|
const STACK_OFFSET_PX = 6;
|
|
93
95
|
|
|
94
96
|
export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
@@ -97,12 +99,15 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
97
99
|
space,
|
|
98
100
|
railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
|
|
99
101
|
activeScopeId,
|
|
102
|
+
visibleScopePostures,
|
|
100
103
|
onStripeClick,
|
|
101
104
|
onSegmentClick,
|
|
102
105
|
"data-testid": testId,
|
|
103
106
|
}) => {
|
|
104
107
|
const frame = geometryFacet.getRenderFrame() ?? null;
|
|
105
|
-
const segments = workflowFacet?.getAllRailSegments() ?? []
|
|
108
|
+
const segments = (workflowFacet?.getAllRailSegments() ?? []).filter((segment) =>
|
|
109
|
+
visibleScopePostures ? visibleScopePostures.has(segment.posture) : true,
|
|
110
|
+
);
|
|
106
111
|
|
|
107
112
|
if (!frame || segments.length === 0) {
|
|
108
113
|
return null;
|
|
@@ -223,16 +228,21 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
223
228
|
style={projectRectToOverlay(stripeRect, projectorSpace)}
|
|
224
229
|
/>
|
|
225
230
|
{/* Label pill — revealed on stripe hover via CSS. */}
|
|
226
|
-
<
|
|
227
|
-
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
tabIndex={-1}
|
|
234
|
+
className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} ${
|
|
235
|
+
isActive ? "wre-scope-rail-label-active" : ""
|
|
236
|
+
}`}
|
|
228
237
|
data-scope-id={segment.scopeId}
|
|
229
238
|
data-posture={segment.posture}
|
|
230
|
-
aria-
|
|
239
|
+
aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
|
|
240
|
+
onClick={handleActivate}
|
|
231
241
|
style={projectRectToOverlay(labelRect, projectorSpace)}
|
|
232
242
|
>
|
|
233
243
|
<span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
|
|
234
244
|
<span className="wre-scope-rail-label-text">{style.labelText}</span>
|
|
235
|
-
</
|
|
245
|
+
</button>
|
|
236
246
|
</React.Fragment>
|
|
237
247
|
);
|
|
238
248
|
})}
|
|
@@ -190,8 +190,8 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
|
|
|
190
190
|
* When `true`, wrap the state swap in `capturePosition` /
|
|
191
191
|
* `restorePosition` so the scroll anchor block stays at the same
|
|
192
192
|
* viewport-Y across the replacement. Shipped **disabled by default**
|
|
193
|
-
* after the 2026-04-24 jump-to-top regression
|
|
194
|
-
*
|
|
193
|
+
* after the 2026-04-24 jump-to-top regression; typing paths re-enable
|
|
194
|
+
* it only through a bounded same-story policy.
|
|
195
195
|
*/
|
|
196
196
|
preserveScrollAnchor?: boolean;
|
|
197
197
|
/**
|
|
@@ -229,8 +229,9 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
|
|
|
229
229
|
* after the 2026-04-24 jump-to-top regression report — enabling it
|
|
230
230
|
* requires evidence that the anchor math holds under the
|
|
231
231
|
* rebuild-effect's exact timing (PM DOM mid-mutation, observer-driven
|
|
232
|
-
* scrollTop resets, etc.).
|
|
233
|
-
*
|
|
232
|
+
* scrollTop resets, etc.). When the bounded anchor restore refuses its
|
|
233
|
+
* target, the helper restores the captured scrollTop instead of leaving
|
|
234
|
+
* a PM/browser-origin top jump in place.
|
|
234
235
|
*/
|
|
235
236
|
export function replaceStatePreservingPosition(
|
|
236
237
|
options: ReplaceStateOptions,
|
|
@@ -242,7 +243,10 @@ export function replaceStatePreservingPosition(
|
|
|
242
243
|
options.suppressionRef.current = true;
|
|
243
244
|
options.view.updateState(newState);
|
|
244
245
|
if (preserved) {
|
|
245
|
-
restorePosition(preserved, options);
|
|
246
|
+
const restored = restorePosition(preserved, options);
|
|
247
|
+
if (!restored) {
|
|
248
|
+
restoreCapturedScrollTop(preserved);
|
|
249
|
+
}
|
|
246
250
|
}
|
|
247
251
|
const release = () => {
|
|
248
252
|
options.suppressionRef.current = false;
|
|
@@ -253,3 +257,24 @@ export function replaceStatePreservingPosition(
|
|
|
253
257
|
queueMicrotask(release);
|
|
254
258
|
}
|
|
255
259
|
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Last-resort guard for the typing rebuild path. If PM clears scrollTop
|
|
263
|
+
* during `updateState()` and the precise anchor restore refuses to write
|
|
264
|
+
* (unsafe target, missing block), keep the user near the same viewport
|
|
265
|
+
* instead of accepting a top jump. Requires a real captured anchor so
|
|
266
|
+
* empty/pre-mount roots remain no-op.
|
|
267
|
+
*/
|
|
268
|
+
function restoreCapturedScrollTop(captured: PreservedPosition): boolean {
|
|
269
|
+
if (!captured.scrollRoot || !captured.anchor) return false;
|
|
270
|
+
if (!Number.isFinite(captured.scrollTop) || captured.scrollTop < 0) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
const maxScrollTop = captured.scrollRoot.scrollHeight - captured.scrollRoot.clientHeight;
|
|
274
|
+
const target =
|
|
275
|
+
Number.isFinite(maxScrollTop) && maxScrollTop > 0
|
|
276
|
+
? Math.min(captured.scrollTop, maxScrollTop)
|
|
277
|
+
: captured.scrollTop;
|
|
278
|
+
captured.scrollRoot.scrollTop = target;
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
@@ -958,7 +958,8 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
958
958
|
// replacement stays in the same focused story with a live geometry
|
|
959
959
|
// facet. `maxScrollDeltaPx` is the guardrail: if the anchor target
|
|
960
960
|
// would move by more than the small local-edit budget, the helper
|
|
961
|
-
// refuses
|
|
961
|
+
// refuses that exact target but restores the captured scrollTop so
|
|
962
|
+
// a PM/browser-origin top jump is not accepted as the final state.
|
|
962
963
|
//
|
|
963
964
|
// Ordering invariant is regression-guarded by
|
|
964
965
|
// `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
|
|
@@ -30,6 +30,7 @@ interface OpenVerticalMerge {
|
|
|
30
30
|
col: number;
|
|
31
31
|
colSpan: number;
|
|
32
32
|
continuedThisRow: boolean;
|
|
33
|
+
hasMaterializedRowSpan: boolean;
|
|
33
34
|
layout: TableCellLayout;
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -159,7 +160,9 @@ function computeTableLayout(tableNode: PMNode): TableCellLayout[][] {
|
|
|
159
160
|
if (verticalMerge === "continue") {
|
|
160
161
|
const owner = findVerticalMergeOwner(openVerticalMerges, column, colSpan);
|
|
161
162
|
if (owner) {
|
|
162
|
-
owner.
|
|
163
|
+
if (!owner.hasMaterializedRowSpan) {
|
|
164
|
+
owner.layout.rowSpan += 1;
|
|
165
|
+
}
|
|
163
166
|
owner.continuedThisRow = true;
|
|
164
167
|
layoutRow.push({
|
|
165
168
|
cellIndex,
|
|
@@ -189,6 +192,7 @@ function computeTableLayout(tableNode: PMNode): TableCellLayout[][] {
|
|
|
189
192
|
col: column,
|
|
190
193
|
colSpan,
|
|
191
194
|
continuedThisRow: true,
|
|
195
|
+
hasMaterializedRowSpan: explicitRowSpan > 1,
|
|
192
196
|
layout,
|
|
193
197
|
});
|
|
194
198
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
TwReviewRailFooter,
|
|
24
24
|
type TwReviewRailFooterProps,
|
|
25
25
|
} from "./tw-review-rail-footer";
|
|
26
|
+
import type { WorkflowScopeLayerKey } from "../workflow-scope-layers";
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Review rail with up to four tabs (Workflow / Comments / Changes / Health).
|
|
@@ -66,6 +67,8 @@ export interface TwReviewRailProps {
|
|
|
66
67
|
*/
|
|
67
68
|
scopeRailSegments?: readonly ScopeRailSegment[];
|
|
68
69
|
activeScopeId?: string | null;
|
|
70
|
+
workflowLayerFilters?: ReadonlySet<WorkflowScopeLayerKey>;
|
|
71
|
+
onWorkflowLayerFiltersChange?: (filters: ReadonlySet<WorkflowScopeLayerKey>) => void;
|
|
69
72
|
/**
|
|
70
73
|
* Optional host-provided Workflow-tab override. When supplied this
|
|
71
74
|
* ReactNode replaces the default TwWorkflowTab content while still using
|
|
@@ -262,6 +265,8 @@ export function TwReviewRail(props: TwReviewRailProps) {
|
|
|
262
265
|
<TwWorkflowTab
|
|
263
266
|
segments={workflowSegments}
|
|
264
267
|
activeScopeId={props.activeScopeId ?? null}
|
|
268
|
+
enabledLayerFilters={props.workflowLayerFilters}
|
|
269
|
+
onEnabledLayerFiltersChange={props.onWorkflowLayerFiltersChange}
|
|
265
270
|
onOpenScope={props.onOpenScope}
|
|
266
271
|
/>
|
|
267
272
|
)}
|