@beyondwork/docx-react-component 1.0.93 → 1.0.95
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/ai/replacement.ts +3 -3
- package/src/api/v3/runtime/formatting.ts +3 -3
- package/src/core/commands/formatting-commands.ts +146 -3
- package/src/core/state/text-transaction.ts +6 -3
- package/src/runtime/document-runtime.ts +3 -0
- package/src/runtime/scopes/semantic-scope-types.ts +9 -0
- package/src/ui/WordReviewEditor.tsx +9 -14
- package/src/ui/editor-shell-view.tsx +4 -1
- package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +109 -42
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +30 -5
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +46 -15
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -7
- package/src/ui-tailwind/review-workspace/use-scope-card-state.ts +34 -12
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +78 -16
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +389 -109
- package/src/ui-tailwind/tw-review-workspace.tsx +20 -8
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.95",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
|
@@ -384,9 +384,9 @@ export function createReplacementFamily(runtime: RuntimeApiHandle) {
|
|
|
384
384
|
},
|
|
385
385
|
|
|
386
386
|
applyScopeAction(input: ApplyScopeActionInput): ApplyResult {
|
|
387
|
-
// @endStateApi — live-with-adapter. Routes through the
|
|
388
|
-
// compiler
|
|
389
|
-
//
|
|
387
|
+
// @endStateApi — live-with-adapter. Routes scope actions through the
|
|
388
|
+
// shipped compiler/applyFormatting facade so AI-triggered formatting
|
|
389
|
+
// mutations share validation, audit, and undo semantics.
|
|
390
390
|
const proposalId =
|
|
391
391
|
input.proposalId ??
|
|
392
392
|
mockId(
|
|
@@ -216,9 +216,9 @@ export function createFormattingFamily(runtime: RuntimeApiHandle) {
|
|
|
216
216
|
},
|
|
217
217
|
|
|
218
218
|
applyToScope(input: FormattingApplyToScopeInput): FormattingApplyToScopeResult {
|
|
219
|
-
// @endStateApi — live-with-adapter. Routes through the scope
|
|
220
|
-
//
|
|
221
|
-
//
|
|
219
|
+
// @endStateApi — live-with-adapter. Routes through the scope-compiler
|
|
220
|
+
// facade so scope-targeted formatting shares Layer-08 validation,
|
|
221
|
+
// audit, and runtime-owned mutation semantics.
|
|
222
222
|
const result = compiler.applyFormatting({
|
|
223
223
|
targetScopeId: input.scopeId,
|
|
224
224
|
action: input.action,
|
|
@@ -591,7 +591,7 @@ export function applyFormattingOperationToDocument(
|
|
|
591
591
|
export type TextMarkClearTarget = TextMark["type"] | "visualHighlight";
|
|
592
592
|
|
|
593
593
|
export type TextMarkRangeOperation =
|
|
594
|
-
| { type: "clear-mark"; mark: TextMarkClearTarget }
|
|
594
|
+
| { type: "clear-mark"; mark: TextMarkClearTarget; expandToFullHighlight?: boolean }
|
|
595
595
|
| { type: "set-mark"; mark: TextMark };
|
|
596
596
|
|
|
597
597
|
/**
|
|
@@ -605,8 +605,14 @@ export function applyTextMarkOperationToDocumentRange(
|
|
|
605
605
|
range: { from: number; to: number },
|
|
606
606
|
operation: TextMarkRangeOperation,
|
|
607
607
|
): FormattingMutationResult {
|
|
608
|
-
const
|
|
609
|
-
const
|
|
608
|
+
const inputFrom = Math.min(range.from, range.to);
|
|
609
|
+
const inputTo = Math.max(range.from, range.to);
|
|
610
|
+
const expandedRange =
|
|
611
|
+
operation.type === "clear-mark" && operation.expandToFullHighlight === true
|
|
612
|
+
? expandRangeToCanonicalHighlightExtent(document, inputFrom, inputTo)
|
|
613
|
+
: { from: inputFrom, to: inputTo };
|
|
614
|
+
const selectionFrom = expandedRange.from;
|
|
615
|
+
const selectionTo = expandedRange.to;
|
|
610
616
|
const selection: RuntimeRenderSnapshot["selection"] = {
|
|
611
617
|
anchor: selectionFrom,
|
|
612
618
|
head: selectionTo,
|
|
@@ -671,6 +677,143 @@ export function applyTextMarkOperationToDocumentRange(
|
|
|
671
677
|
};
|
|
672
678
|
}
|
|
673
679
|
|
|
680
|
+
function expandRangeToCanonicalHighlightExtent(
|
|
681
|
+
document: CanonicalDocumentEnvelope,
|
|
682
|
+
inputFrom: number,
|
|
683
|
+
inputTo: number,
|
|
684
|
+
): { from: number; to: number } {
|
|
685
|
+
let from = inputFrom;
|
|
686
|
+
let to = inputTo;
|
|
687
|
+
const root = document.content as DocumentRootNode;
|
|
688
|
+
let cursor = 0;
|
|
689
|
+
|
|
690
|
+
for (let blockIndex = 0; blockIndex < root.children.length; blockIndex += 1) {
|
|
691
|
+
const block = root.children[blockIndex]!;
|
|
692
|
+
const blockFrom = cursor;
|
|
693
|
+
const blockLength =
|
|
694
|
+
block.type === "paragraph"
|
|
695
|
+
? block.children.reduce(
|
|
696
|
+
(total, child) => total + inlineNodeLength(child as InlineNode),
|
|
697
|
+
0,
|
|
698
|
+
)
|
|
699
|
+
: 1;
|
|
700
|
+
const blockTo = blockFrom + blockLength;
|
|
701
|
+
|
|
702
|
+
if (
|
|
703
|
+
block.type === "paragraph" &&
|
|
704
|
+
rangesOverlap(inputFrom, inputTo, blockFrom, blockTo)
|
|
705
|
+
) {
|
|
706
|
+
const spans = collectInlineHighlightSpans(block.children, blockFrom).spans;
|
|
707
|
+
const expanded = expandRangeWithinHighlightSpans(spans, inputFrom, inputTo);
|
|
708
|
+
if (expanded.from < from) from = expanded.from;
|
|
709
|
+
if (expanded.to > to) to = expanded.to;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
cursor = blockTo;
|
|
713
|
+
if (blockIndex < root.children.length - 1) {
|
|
714
|
+
cursor += 1;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return { from, to };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
interface HighlightSpan {
|
|
722
|
+
readonly from: number;
|
|
723
|
+
readonly to: number;
|
|
724
|
+
readonly highlighted: boolean;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function collectInlineHighlightSpans(
|
|
728
|
+
nodes: readonly InlineNode[],
|
|
729
|
+
start: number,
|
|
730
|
+
): { spans: HighlightSpan[]; nextPosition: number } {
|
|
731
|
+
const spans: HighlightSpan[] = [];
|
|
732
|
+
let position = start;
|
|
733
|
+
|
|
734
|
+
for (const node of nodes) {
|
|
735
|
+
if (node.type === "text") {
|
|
736
|
+
const length = Array.from(node.text).length;
|
|
737
|
+
if (length > 0) {
|
|
738
|
+
spans.push({
|
|
739
|
+
from: position,
|
|
740
|
+
to: position + length,
|
|
741
|
+
highlighted: marksHaveVisualHighlight(node.marks),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
position += length;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (node.type === "hyperlink") {
|
|
749
|
+
const nested = collectInlineHighlightSpans(node.children as InlineNode[], position);
|
|
750
|
+
spans.push(...nested.spans);
|
|
751
|
+
position = nested.nextPosition;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const length = inlineNodeLength(node);
|
|
756
|
+
if (length > 0) {
|
|
757
|
+
spans.push({
|
|
758
|
+
from: position,
|
|
759
|
+
to: position + length,
|
|
760
|
+
highlighted: false,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
position += length;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return { spans, nextPosition: position };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function expandRangeWithinHighlightSpans(
|
|
770
|
+
spans: readonly HighlightSpan[],
|
|
771
|
+
inputFrom: number,
|
|
772
|
+
inputTo: number,
|
|
773
|
+
): { from: number; to: number } {
|
|
774
|
+
let from = inputFrom;
|
|
775
|
+
let to = inputTo;
|
|
776
|
+
let touchedLeftIndex = -1;
|
|
777
|
+
let touchedRightIndex = -1;
|
|
778
|
+
|
|
779
|
+
for (let i = 0; i < spans.length; i += 1) {
|
|
780
|
+
const span = spans[i]!;
|
|
781
|
+
if (!span.highlighted) continue;
|
|
782
|
+
if (rangesOverlap(inputFrom, inputTo, span.from, span.to)) {
|
|
783
|
+
if (touchedLeftIndex === -1) touchedLeftIndex = i;
|
|
784
|
+
touchedRightIndex = i;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (touchedLeftIndex === -1) {
|
|
789
|
+
return { from, to };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
for (let i = touchedLeftIndex; i >= 0; i -= 1) {
|
|
793
|
+
const span = spans[i]!;
|
|
794
|
+
if (!span.highlighted) break;
|
|
795
|
+
if (span.to < from) break;
|
|
796
|
+
if (span.from < from) from = span.from;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
for (let i = touchedRightIndex; i < spans.length; i += 1) {
|
|
800
|
+
const span = spans[i]!;
|
|
801
|
+
if (!span.highlighted) break;
|
|
802
|
+
if (span.from > to) break;
|
|
803
|
+
if (span.to > to) to = span.to;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return { from, to };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function marksHaveVisualHighlight(marks: readonly TextMark[] | undefined): boolean {
|
|
810
|
+
return (
|
|
811
|
+
marks?.some((mark) =>
|
|
812
|
+
mark.type === "highlight" || mark.type === "backgroundColor"
|
|
813
|
+
) ?? false
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
674
817
|
function resolveTextMarkRangeUpdater(
|
|
675
818
|
operation: TextMarkRangeOperation,
|
|
676
819
|
): (marks?: TextMark[]) => TextMark[] | undefined {
|
|
@@ -130,9 +130,12 @@ function applyLinearTextTransaction(
|
|
|
130
130
|
|
|
131
131
|
// `normalizedRange.{from,to}` are logical positions (scope markers = 0 width,
|
|
132
132
|
// matching surface-projection). Translate to unit-array indices so scope
|
|
133
|
-
// marker units
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
// marker units sitting exactly on the replacement boundaries stay intact:
|
|
134
|
+
// start-boundary markers remain before the inserted payload, end-boundary
|
|
135
|
+
// markers remain after it. Markers strictly inside the range are still part
|
|
136
|
+
// of the replacement/delete slice.
|
|
137
|
+
const unitFrom = logicalPositionToUnitIndex(story.units, normalizedRange.from, "after");
|
|
138
|
+
const unitTo = logicalPositionToUnitIndex(story.units, normalizedRange.to, "before");
|
|
136
139
|
|
|
137
140
|
ensureEditableRange(story.units.slice(unitFrom, unitTo));
|
|
138
141
|
|
|
@@ -3531,6 +3531,9 @@ export function createDocumentRuntime(
|
|
|
3531
3531
|
? {
|
|
3532
3532
|
type: "clear-mark" as const,
|
|
3533
3533
|
mark: step.formattingAction.mark,
|
|
3534
|
+
...(step.formattingAction.expandToFullHighlight === true
|
|
3535
|
+
? { expandToFullHighlight: true as const }
|
|
3536
|
+
: {}),
|
|
3534
3537
|
}
|
|
3535
3538
|
: {
|
|
3536
3539
|
type: "set-mark" as const,
|
|
@@ -332,6 +332,15 @@ export type ScopeFormattingAction =
|
|
|
332
332
|
* `"visualHighlight"` to remove both visual highlight layers.
|
|
333
333
|
*/
|
|
334
334
|
readonly mark: ScopeFormattingClearTarget;
|
|
335
|
+
/**
|
|
336
|
+
* When true, expand the target range to the full contiguous visual
|
|
337
|
+
* highlight span that touches it before clearing. Mirrors
|
|
338
|
+
* `clearHighlight({expandToFullHighlight:true})`; the clear remains
|
|
339
|
+
* source-layer exact, so `mark:"highlight"` still removes only
|
|
340
|
+
* `w:highlight`, while `mark:"visualHighlight"` removes highlight +
|
|
341
|
+
* shading across the expanded span.
|
|
342
|
+
*/
|
|
343
|
+
readonly expandToFullHighlight?: boolean;
|
|
335
344
|
}
|
|
336
345
|
| {
|
|
337
346
|
readonly kind: "set-mark";
|
|
@@ -3931,18 +3931,11 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
3931
3931
|
}}
|
|
3932
3932
|
onDeselectObject={() => activeRuntime.deselectObject()}
|
|
3933
3933
|
onScopeAskAgent={(payload) => {
|
|
3934
|
-
// Resolve
|
|
3935
|
-
//
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
const
|
|
3939
|
-
const model = models.find((entry) => entry.scopeId === payload.scopeId);
|
|
3940
|
-
if (!model) return;
|
|
3941
|
-
const scopeSnapshot = activeRuntime.getWorkflowScopeSnapshot();
|
|
3942
|
-
const scopes = scopeSnapshot?.scopes ?? [];
|
|
3943
|
-
const scope = scopes.find((entry) => entry.scopeId === payload.scopeId);
|
|
3944
|
-
if (!scope) return;
|
|
3945
|
-
const anchor = scope.anchor;
|
|
3934
|
+
// Resolve scope card + story through the mounted UI API seam so
|
|
3935
|
+
// the shell root does not re-own workflow facet reads.
|
|
3936
|
+
const model = api.ui?.scope.card(payload.scopeId) ?? null;
|
|
3937
|
+
const bundle = api.ui?.scope.getBundle(payload.scopeId) ?? null;
|
|
3938
|
+
const anchor = payload.anchor ?? model?.anchor;
|
|
3946
3939
|
if (!anchor) return;
|
|
3947
3940
|
const requestId =
|
|
3948
3941
|
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
@@ -3957,8 +3950,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
3957
3950
|
requestId,
|
|
3958
3951
|
scopeId: payload.scopeId,
|
|
3959
3952
|
anchor,
|
|
3960
|
-
selectionText: model
|
|
3961
|
-
...(scope.
|
|
3953
|
+
selectionText: model?.label ?? "",
|
|
3954
|
+
...(bundle?.scope.handle.storyTarget
|
|
3955
|
+
? { storyTarget: bundle.scope.handle.storyTarget }
|
|
3956
|
+
: {}),
|
|
3962
3957
|
};
|
|
3963
3958
|
onEventRef.current?.(eventPayload);
|
|
3964
3959
|
}}
|
|
@@ -162,7 +162,10 @@ export interface EditorShellViewProps {
|
|
|
162
162
|
groupId: string;
|
|
163
163
|
}) => void;
|
|
164
164
|
/** K2 — forwarded from workspace to WordReviewEditor. */
|
|
165
|
-
onScopeAskAgent?: (payload: {
|
|
165
|
+
onScopeAskAgent?: (payload: {
|
|
166
|
+
scopeId: string;
|
|
167
|
+
anchor?: import("../api/public-types.ts").EditorAnchorProjection;
|
|
168
|
+
}) => void;
|
|
166
169
|
/** N6 — deselects the currently grabbed object; wired to runtime.deselectObject(). */
|
|
167
170
|
onDeselectObject?: () => void;
|
|
168
171
|
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
@@ -13,14 +13,15 @@
|
|
|
13
13
|
* names sees the right option highlighted without code changes.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import React, { useState } from "react";
|
|
17
|
-
import
|
|
16
|
+
import React, { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
|
17
|
+
import { createPortal } from "react-dom";
|
|
18
18
|
import { ChevronDown, Eye, EyeOff, Highlighter, Scroll } from "lucide-react";
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
21
|
normalizeMarkupDisplay,
|
|
22
22
|
type MarkupDisplay,
|
|
23
23
|
} from "../../ui/headless/comment-decoration-model";
|
|
24
|
+
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
24
25
|
|
|
25
26
|
export type DisplayMode = "all-markup" | "simple-markup" | "no-markup" | "original";
|
|
26
27
|
|
|
@@ -67,70 +68,136 @@ const MODES: readonly ModeEntry[] = [
|
|
|
67
68
|
|
|
68
69
|
export function TwDisplayModeSelector(props: TwDisplayModeSelectorProps): React.ReactElement {
|
|
69
70
|
const [open, setOpen] = useState(false);
|
|
71
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
70
72
|
const canonical = normalizeMarkupDisplay(props.value);
|
|
71
73
|
const activeEntry = MODES.find((m) => m.mode === canonical) ?? MODES[0]!;
|
|
72
74
|
|
|
73
75
|
return (
|
|
74
|
-
|
|
75
|
-
<Popover.Trigger asChild>
|
|
76
|
+
<>
|
|
76
77
|
<button
|
|
78
|
+
ref={triggerRef}
|
|
77
79
|
type="button"
|
|
78
80
|
disabled={props.disabled}
|
|
79
81
|
data-testid={props["data-testid"] ?? "display-mode-selector-trigger"}
|
|
80
82
|
aria-label={`Display mode: ${activeEntry.label}`}
|
|
81
|
-
|
|
83
|
+
aria-expanded={open}
|
|
84
|
+
aria-haspopup="menu"
|
|
85
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
86
|
+
onClick={(event) => {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
setOpen((value) => !value);
|
|
89
|
+
}}
|
|
90
|
+
className={[
|
|
91
|
+
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-primary",
|
|
92
|
+
"hover:bg-surface focus-visible:outline-none focus-visible:bg-surface disabled:opacity-50",
|
|
93
|
+
open ? "bg-canvas text-accent ring-1 ring-accent/30 shadow-sm" : "",
|
|
94
|
+
].join(" ")}
|
|
82
95
|
>
|
|
83
96
|
<activeEntry.icon className="h-3.5 w-3.5 text-tertiary" />
|
|
84
97
|
<span>{activeEntry.label}</span>
|
|
85
98
|
<ChevronDown className="h-3.5 w-3.5 text-tertiary" />
|
|
86
99
|
</button>
|
|
87
|
-
|
|
88
|
-
<Popover.Portal>
|
|
89
|
-
<Popover.Content
|
|
90
|
-
className="z-50 w-[260px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
|
|
91
|
-
sideOffset={8}
|
|
92
|
-
align="end"
|
|
93
|
-
data-testid="display-mode-selector-content"
|
|
94
|
-
>
|
|
100
|
+
<DisplayModePortalMenu anchorRef={triggerRef} open={open}>
|
|
95
101
|
<div className="mb-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
96
102
|
Display mode
|
|
97
103
|
</div>
|
|
98
104
|
{MODES.map((entry) => {
|
|
99
105
|
const isActive = entry.mode === canonical;
|
|
100
106
|
return (
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<span className="text-[10px] text-secondary">{entry.hint}</span>
|
|
107
|
+
<button
|
|
108
|
+
key={entry.mode}
|
|
109
|
+
type="button"
|
|
110
|
+
role="menuitemradio"
|
|
111
|
+
aria-checked={isActive}
|
|
112
|
+
onClick={() => {
|
|
113
|
+
props.onChange(entry.mode);
|
|
114
|
+
setOpen(false);
|
|
115
|
+
}}
|
|
116
|
+
className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-[11px] transition-colors hover:bg-surface focus-visible:outline-none focus-visible:bg-surface"
|
|
117
|
+
data-testid={`display-mode-option-${entry.mode}`}
|
|
118
|
+
data-mode={entry.mode}
|
|
119
|
+
data-active={isActive ? "true" : undefined}
|
|
120
|
+
>
|
|
121
|
+
<entry.icon
|
|
122
|
+
className={[
|
|
123
|
+
"mt-0.5 h-3.5 w-3.5 shrink-0",
|
|
124
|
+
isActive ? "text-accent" : "text-tertiary",
|
|
125
|
+
].join(" ")}
|
|
126
|
+
/>
|
|
127
|
+
<span className="flex flex-col">
|
|
128
|
+
<span className={`font-medium ${isActive ? "text-accent" : "text-primary"}`}>
|
|
129
|
+
{entry.label}
|
|
125
130
|
</span>
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
<span className="text-[10px] text-secondary">{entry.hint}</span>
|
|
132
|
+
</span>
|
|
133
|
+
</button>
|
|
128
134
|
);
|
|
129
135
|
})}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
</DisplayModePortalMenu>
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function DisplayModePortalMenu(props: {
|
|
142
|
+
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
|
143
|
+
children: React.ReactNode;
|
|
144
|
+
open: boolean;
|
|
145
|
+
}): React.ReactPortal | null {
|
|
146
|
+
const style = useDisplayModePortalPosition(props.anchorRef, props.open);
|
|
147
|
+
const body = props.anchorRef.current?.ownerDocument?.body;
|
|
148
|
+
if (!props.open || !body) return null;
|
|
149
|
+
return createPortal(
|
|
150
|
+
<div
|
|
151
|
+
className="z-50 w-[260px] rounded-lg bg-canvas p-1 shadow-lg ring-1 ring-border"
|
|
152
|
+
data-testid="display-mode-selector-content"
|
|
153
|
+
style={style}
|
|
154
|
+
>
|
|
155
|
+
{props.children}
|
|
156
|
+
</div>,
|
|
157
|
+
body,
|
|
133
158
|
);
|
|
134
159
|
}
|
|
135
160
|
|
|
161
|
+
function useDisplayModePortalPosition(
|
|
162
|
+
anchorRef: React.RefObject<HTMLButtonElement | null>,
|
|
163
|
+
open: boolean,
|
|
164
|
+
): CSSProperties {
|
|
165
|
+
const [style, setStyle] = useState<CSSProperties>({
|
|
166
|
+
left: 8,
|
|
167
|
+
position: "fixed",
|
|
168
|
+
top: 8,
|
|
169
|
+
zIndex: 50,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
useLayoutEffect(() => {
|
|
173
|
+
if (!open) return;
|
|
174
|
+
const anchor = anchorRef.current;
|
|
175
|
+
const ownerWindow = anchor?.ownerDocument?.defaultView;
|
|
176
|
+
if (!anchor || !ownerWindow) return;
|
|
177
|
+
const update = () => {
|
|
178
|
+
const rect = anchor.getBoundingClientRect();
|
|
179
|
+
const width = 260;
|
|
180
|
+
const left = Math.min(
|
|
181
|
+
Math.max(8, rect.right - width),
|
|
182
|
+
Math.max(8, (ownerWindow.innerWidth || width + 16) - width - 8),
|
|
183
|
+
);
|
|
184
|
+
setStyle({
|
|
185
|
+
left,
|
|
186
|
+
position: "fixed",
|
|
187
|
+
top: Math.max(8, rect.bottom + 8),
|
|
188
|
+
zIndex: 50,
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
update();
|
|
192
|
+
ownerWindow.addEventListener("resize", update);
|
|
193
|
+
ownerWindow.addEventListener("scroll", update, true);
|
|
194
|
+
return () => {
|
|
195
|
+
ownerWindow.removeEventListener("resize", update);
|
|
196
|
+
ownerWindow.removeEventListener("scroll", update, true);
|
|
197
|
+
};
|
|
198
|
+
}, [anchorRef, open]);
|
|
199
|
+
|
|
200
|
+
return style;
|
|
201
|
+
}
|
|
202
|
+
|
|
136
203
|
export default TwDisplayModeSelector;
|
|
@@ -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,
|
|
@@ -28,6 +28,7 @@ import { TwScopeCardLayer } from "./tw-scope-card-layer";
|
|
|
28
28
|
import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
|
|
29
29
|
import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
|
|
30
30
|
import { TwObjectSelectionOverlay } from "./tw-object-selection-overlay";
|
|
31
|
+
import { useUiApi } from "../ui-api-context";
|
|
31
32
|
|
|
32
33
|
export interface TwChromeOverlayProps {
|
|
33
34
|
/** Layout facet the overlay layers read from (layout-semantic data). */
|
|
@@ -39,16 +40,17 @@ export interface TwChromeOverlayProps {
|
|
|
39
40
|
*/
|
|
40
41
|
geometryFacet: import("../../runtime/geometry/index.ts").GeometryFacet;
|
|
41
42
|
/**
|
|
42
|
-
* Workflow facet —
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* (e.g., during initial mount).
|
|
43
|
+
* Workflow facet — no-provider fallback for scope rail/card reads.
|
|
44
|
+
* Mounted editor paths prefer `api.ui.scope.*`; pass `null` when no
|
|
45
|
+
* runtime is attached (e.g., during initial mount).
|
|
46
46
|
*/
|
|
47
47
|
workflowFacet: import("../../runtime/workflow/rail/types.ts").WorkflowFacet | null;
|
|
48
48
|
/** Optional coordinate space override. Defaults to the overlay origin. */
|
|
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,25 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
242
245
|
mediaPreviews,
|
|
243
246
|
activeBandRibbonProps,
|
|
244
247
|
}) => {
|
|
248
|
+
const ui = useUiApi();
|
|
249
|
+
const scopeRailSegments = React.useMemo(
|
|
250
|
+
() =>
|
|
251
|
+
ui?.scope.rail().segments ??
|
|
252
|
+
workflowFacet?.getAllRailSegments() ??
|
|
253
|
+
[],
|
|
254
|
+
[ui, workflowFacet, renderFrameRevision],
|
|
255
|
+
);
|
|
256
|
+
const visibleScopeIds = React.useMemo(() => {
|
|
257
|
+
if (!visibleScopePostures) return undefined;
|
|
258
|
+
const ids = new Set<string>();
|
|
259
|
+
for (const segment of scopeRailSegments) {
|
|
260
|
+
if (visibleScopePostures.has(segment.posture)) {
|
|
261
|
+
ids.add(segment.scopeId);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return ids;
|
|
265
|
+
}, [scopeRailSegments, visibleScopePostures]);
|
|
266
|
+
|
|
245
267
|
return (
|
|
246
268
|
<div
|
|
247
269
|
className="wre-chrome-overlay pointer-events-none absolute inset-0 z-30"
|
|
@@ -266,8 +288,10 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
266
288
|
<TwScopeRailLayer
|
|
267
289
|
geometryFacet={geometryFacet}
|
|
268
290
|
workflowFacet={workflowFacet}
|
|
291
|
+
scopeRailSegments={scopeRailSegments}
|
|
269
292
|
space={space}
|
|
270
293
|
activeScopeId={activeScopeId}
|
|
294
|
+
visibleScopePostures={visibleScopePostures}
|
|
271
295
|
onStripeClick={onScopeStripeClick}
|
|
272
296
|
onSegmentClick={onScopeSegmentClick}
|
|
273
297
|
/>
|
|
@@ -275,6 +299,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
275
299
|
facet={facet}
|
|
276
300
|
workflowFacet={workflowFacet}
|
|
277
301
|
activeScopeId={activeScopeId ?? null}
|
|
302
|
+
visibleScopeIds={visibleScopeIds}
|
|
278
303
|
onClose={onScopeCardClose ?? noop}
|
|
279
304
|
onModeChange={onScopeCardModeChange ?? noopModeChange}
|
|
280
305
|
onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
|