@beyondwork/docx-react-component 1.0.94 → 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-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/tw-review-workspace.tsx +15 -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>;
|
|
@@ -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}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
projectRectToOverlay,
|
|
18
18
|
type OverlayCoordinateSpace,
|
|
19
19
|
} from "./chrome-overlay-projector";
|
|
20
|
+
import { useUiApi } from "../ui-api-context";
|
|
20
21
|
import type { RenderFrame, RenderFrameRect } from "../../api/public-types.ts";
|
|
21
22
|
import type { ScopeRailSegment, ScopeRailPosture } from "../../api/public-types.ts";
|
|
22
23
|
import type { WorkflowFacet } from "../../api/public-types.ts";
|
|
@@ -30,22 +31,29 @@ export interface TwScopeRailLayerProps {
|
|
|
30
31
|
/**
|
|
31
32
|
* Geometry facet — used for `getRenderFrame()`. Migrated from
|
|
32
33
|
* `facet: WordReviewEditorLayoutFacet` in refactor/05 cross-lane-coord
|
|
33
|
-
* §8.4 pass.
|
|
34
|
-
* `workflowFacet` per Layer-06 Slice 4's seam inversion.
|
|
34
|
+
* §8.4 pass. Mounted rail/card data flows through `api.ui.scope.*`.
|
|
35
35
|
*/
|
|
36
36
|
geometryFacet: GeometryFacet;
|
|
37
37
|
/**
|
|
38
|
-
* Workflow facet —
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* Workflow facet — no-provider fallback for scope rail/card reads.
|
|
39
|
+
* Mounted editor paths prefer `api.ui.scope.*`; passing `null` makes
|
|
40
|
+
* fallback reads no-op.
|
|
41
41
|
*/
|
|
42
42
|
workflowFacet: WorkflowFacet | null;
|
|
43
|
+
/**
|
|
44
|
+
* Optional pre-read rail snapshot from `ui.scope.rail()`. When omitted,
|
|
45
|
+
* the layer reads the mounted UI API itself, then falls back to the
|
|
46
|
+
* workflow facet for no-provider paths.
|
|
47
|
+
*/
|
|
48
|
+
scopeRailSegments?: readonly ScopeRailSegment[];
|
|
43
49
|
/** Overlay's coordinate space. Defaults to the overlay's own origin. */
|
|
44
50
|
space?: OverlayCoordinateSpace;
|
|
45
51
|
/** Horizontal pad (px) the rail gutter occupies to the left of body. */
|
|
46
52
|
railLaneWidthPx?: number;
|
|
47
53
|
/** Scope id that should render with the `active` emphasis. */
|
|
48
54
|
activeScopeId?: string | null;
|
|
55
|
+
/** Posture filters shared with the Workflow rail. Omitted means all scopes render. */
|
|
56
|
+
visibleScopePostures?: ReadonlySet<ScopeRailPosture>;
|
|
49
57
|
/**
|
|
50
58
|
* Fires when the user clicks the rail stripe — opens the scope card.
|
|
51
59
|
* P0 wires this directly; P1 replaces with card-layer-aware routing.
|
|
@@ -87,22 +95,32 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
|
|
|
87
95
|
// ---------------------------------------------------------------------------
|
|
88
96
|
|
|
89
97
|
const DEFAULT_RAIL_LANE_PX = 44;
|
|
90
|
-
const STRIPE_WIDTH_PX =
|
|
91
|
-
const LABEL_WIDTH_PX =
|
|
98
|
+
const STRIPE_WIDTH_PX = 6;
|
|
99
|
+
const LABEL_WIDTH_PX = 58;
|
|
92
100
|
const STACK_OFFSET_PX = 6;
|
|
93
101
|
|
|
94
102
|
export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
95
103
|
geometryFacet,
|
|
96
104
|
workflowFacet,
|
|
105
|
+
scopeRailSegments,
|
|
97
106
|
space,
|
|
98
107
|
railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
|
|
99
108
|
activeScopeId,
|
|
109
|
+
visibleScopePostures,
|
|
100
110
|
onStripeClick,
|
|
101
111
|
onSegmentClick,
|
|
102
112
|
"data-testid": testId,
|
|
103
113
|
}) => {
|
|
114
|
+
const ui = useUiApi();
|
|
104
115
|
const frame = geometryFacet.getRenderFrame() ?? null;
|
|
105
|
-
const
|
|
116
|
+
const railSegments =
|
|
117
|
+
scopeRailSegments ??
|
|
118
|
+
ui?.scope.rail().segments ??
|
|
119
|
+
workflowFacet?.getAllRailSegments() ??
|
|
120
|
+
[];
|
|
121
|
+
const segments = railSegments.filter((segment) =>
|
|
122
|
+
visibleScopePostures ? visibleScopePostures.has(segment.posture) : true,
|
|
123
|
+
);
|
|
106
124
|
|
|
107
125
|
if (!frame || segments.length === 0) {
|
|
108
126
|
return null;
|
|
@@ -110,15 +128,23 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
110
128
|
|
|
111
129
|
// P2: which scopes currently have a `source: "ai"` candidate
|
|
112
130
|
// overlapping — drives the agent-pending shimmer class on their
|
|
113
|
-
// tints.
|
|
114
|
-
//
|
|
115
|
-
const cardModels = workflowFacet?.getAllScopeCardModels() ?? [];
|
|
131
|
+
// tints. Mounted surfaces read card projections through ui.scope.card;
|
|
132
|
+
// no-provider paths fall back to the workflow facet.
|
|
133
|
+
const cardModels = ui ? [] : workflowFacet?.getAllScopeCardModels() ?? [];
|
|
116
134
|
const agentPendingByScope = new Map<string, boolean>();
|
|
117
135
|
for (const model of cardModels) {
|
|
118
136
|
if (model.agentPending) {
|
|
119
137
|
agentPendingByScope.set(model.scopeId, true);
|
|
120
138
|
}
|
|
121
139
|
}
|
|
140
|
+
if (ui) {
|
|
141
|
+
for (const segment of segments) {
|
|
142
|
+
const model = ui.scope.card(segment.scopeId);
|
|
143
|
+
if (model?.agentPending) {
|
|
144
|
+
agentPendingByScope.set(segment.scopeId, true);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
122
148
|
|
|
123
149
|
// P3c: stack offsets for overlapping scopes. Two scopes whose
|
|
124
150
|
// offset ranges intersect on the same page render as stacked
|
|
@@ -223,16 +249,21 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
223
249
|
style={projectRectToOverlay(stripeRect, projectorSpace)}
|
|
224
250
|
/>
|
|
225
251
|
{/* Label pill — revealed on stripe hover via CSS. */}
|
|
226
|
-
<
|
|
227
|
-
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
tabIndex={-1}
|
|
255
|
+
className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} ${
|
|
256
|
+
isActive ? "wre-scope-rail-label-active" : ""
|
|
257
|
+
}`}
|
|
228
258
|
data-scope-id={segment.scopeId}
|
|
229
259
|
data-posture={segment.posture}
|
|
230
|
-
aria-
|
|
260
|
+
aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
|
|
261
|
+
onClick={handleActivate}
|
|
231
262
|
style={projectRectToOverlay(labelRect, projectorSpace)}
|
|
232
263
|
>
|
|
233
264
|
<span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
|
|
234
265
|
<span className="wre-scope-rail-label-text">{style.labelText}</span>
|
|
235
|
-
</
|
|
266
|
+
</button>
|
|
236
267
|
</React.Fragment>
|
|
237
268
|
);
|
|
238
269
|
})}
|
|
@@ -402,13 +402,7 @@ function buildParagraph(
|
|
|
402
402
|
indentRight: paragraphLayout.indentation?.right ?? null,
|
|
403
403
|
indentFirstLine: paragraphLayout.indentation?.firstLine ?? null,
|
|
404
404
|
indentHanging: paragraphLayout.indentation?.hanging ?? null,
|
|
405
|
-
numberingMarkerWidth:
|
|
406
|
-
paragraphLayout.markerLane?.width ??
|
|
407
|
-
paragraphLayout.indentation?.hanging ??
|
|
408
|
-
(paragraphLayout.indentation?.firstLine !== undefined &&
|
|
409
|
-
paragraphLayout.indentation.firstLine < 0
|
|
410
|
-
? Math.abs(paragraphLayout.indentation.firstLine)
|
|
411
|
-
: null),
|
|
405
|
+
numberingMarkerWidth: paragraphLayout.markerLane?.width ?? null,
|
|
412
406
|
numberingMarkerStart: paragraphLayout.markerLane?.start ?? null,
|
|
413
407
|
numberingMarkerJustification: paragraphLayout.markerJustification ?? null,
|
|
414
408
|
numberingMarkerRunProperties: block.resolvedNumbering?.markerRunProperties ?? null,
|
|
@@ -9,13 +9,13 @@ import {
|
|
|
9
9
|
cycleScopeIndex,
|
|
10
10
|
shouldHandleScopeNavKey,
|
|
11
11
|
} from "../chrome-overlay/scope-keyboard-cycle";
|
|
12
|
+
import { useUiApi } from "../ui-api-context.tsx";
|
|
12
13
|
|
|
13
14
|
export interface UseScopeCardStateOptions {
|
|
14
15
|
layoutFacet?: import("../../runtime/layout/index.ts").WordReviewEditorLayoutFacet;
|
|
15
16
|
/**
|
|
16
|
-
* Layer-06 workflow facet —
|
|
17
|
-
*
|
|
18
|
-
* rail-seam inversion removed those methods from `layoutFacet`.
|
|
17
|
+
* Layer-06 workflow facet — no-provider fallback for scope card models.
|
|
18
|
+
* Mounted paths prefer `api.ui.scope.rail/card`.
|
|
19
19
|
*/
|
|
20
20
|
workflowFacet?: import("../../runtime/workflow/rail/types.ts").WorkflowFacet;
|
|
21
21
|
onScopeModeChangeRequested?: (payload: {
|
|
@@ -79,6 +79,31 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
|
|
|
79
79
|
void layoutFacet;
|
|
80
80
|
|
|
81
81
|
const [activeScopeId, setActiveScopeId] = useState<string | null>(null);
|
|
82
|
+
const ui = useUiApi();
|
|
83
|
+
|
|
84
|
+
const readScopeIds = useCallback((): string[] => {
|
|
85
|
+
if (ui) {
|
|
86
|
+
const ids: string[] = [];
|
|
87
|
+
const seen = new Set<string>();
|
|
88
|
+
for (const segment of ui.scope.rail().segments) {
|
|
89
|
+
if (seen.has(segment.scopeId)) continue;
|
|
90
|
+
seen.add(segment.scopeId);
|
|
91
|
+
ids.push(segment.scopeId);
|
|
92
|
+
}
|
|
93
|
+
return ids;
|
|
94
|
+
}
|
|
95
|
+
return workflowFacet?.getAllScopeCardModels().map((model) => model.scopeId) ?? [];
|
|
96
|
+
}, [ui, workflowFacet]);
|
|
97
|
+
|
|
98
|
+
const readScopeCard = useCallback(
|
|
99
|
+
(scopeId: string) => {
|
|
100
|
+
if (ui) return ui.scope.card(scopeId);
|
|
101
|
+
return workflowFacet
|
|
102
|
+
?.getAllScopeCardModels()
|
|
103
|
+
.find((m) => m.scopeId === scopeId) ?? null;
|
|
104
|
+
},
|
|
105
|
+
[ui, workflowFacet],
|
|
106
|
+
);
|
|
82
107
|
|
|
83
108
|
const handleScopeStripeClick = useCallback(
|
|
84
109
|
(segment: { scopeId: string }) => {
|
|
@@ -94,14 +119,13 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
|
|
|
94
119
|
}, []);
|
|
95
120
|
|
|
96
121
|
useEffect(() => {
|
|
97
|
-
if (!workflowFacet) {
|
|
122
|
+
if (!ui && !workflowFacet) {
|
|
98
123
|
return undefined;
|
|
99
124
|
}
|
|
100
125
|
const onKey = (event: KeyboardEvent) => {
|
|
101
126
|
if (!shouldHandleScopeNavKey(event)) return;
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
const ids = models.map((model) => model.scopeId);
|
|
127
|
+
const ids = readScopeIds();
|
|
128
|
+
if (ids.length === 0) return;
|
|
105
129
|
const key = event.key.toLowerCase();
|
|
106
130
|
if (key === "enter") {
|
|
107
131
|
if (!activeScopeId) {
|
|
@@ -117,7 +141,7 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
|
|
|
117
141
|
};
|
|
118
142
|
window.addEventListener("keydown", onKey);
|
|
119
143
|
return () => window.removeEventListener("keydown", onKey);
|
|
120
|
-
}, [workflowFacet, activeScopeId]);
|
|
144
|
+
}, [ui, workflowFacet, activeScopeId, readScopeIds]);
|
|
121
145
|
|
|
122
146
|
const handleScopeCardModeChange = useCallback(
|
|
123
147
|
(scopeId: string, mode: WorkflowScopeMode) => {
|
|
@@ -149,12 +173,10 @@ export function useScopeCardState(options: UseScopeCardStateOptions): ScopeCardS
|
|
|
149
173
|
|
|
150
174
|
const handleScopeCardAskAgent = useCallback(
|
|
151
175
|
(scopeId: string) => {
|
|
152
|
-
const cardModel =
|
|
153
|
-
?.getAllScopeCardModels()
|
|
154
|
-
.find((m) => m.scopeId === scopeId);
|
|
176
|
+
const cardModel = readScopeCard(scopeId);
|
|
155
177
|
onScopeAskAgent?.({ scopeId, anchor: cardModel?.anchor });
|
|
156
178
|
},
|
|
157
|
-
[onScopeAskAgent,
|
|
179
|
+
[onScopeAskAgent, readScopeCard],
|
|
158
180
|
);
|
|
159
181
|
|
|
160
182
|
return {
|
|
@@ -283,13 +283,20 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
283
283
|
// changed, re-read any attached anchors" marker. Per-query
|
|
284
284
|
// invalidation fan-out is a Phase Q follow-up — the geometry
|
|
285
285
|
// facet does not yet expose per-kind invalidation events.
|
|
286
|
-
const
|
|
286
|
+
const uiApi = useUiApi();
|
|
287
287
|
const shellChannels = useUiShellChannels();
|
|
288
288
|
React.useEffect(() => {
|
|
289
|
-
if (!
|
|
290
|
-
shellChannels.viewport.emit(
|
|
289
|
+
if (!uiApi || !shellChannels) return;
|
|
290
|
+
shellChannels.viewport.emit(uiApi.viewport.get());
|
|
291
291
|
shellChannels.overlays.emit({ kind: "page", value: 0 });
|
|
292
|
-
}, [renderFrameRevision,
|
|
292
|
+
}, [renderFrameRevision, uiApi, shellChannels]);
|
|
293
|
+
const scopeRailSegments = useMemo(
|
|
294
|
+
() =>
|
|
295
|
+
uiApi?.scope.rail().segments ??
|
|
296
|
+
props.workflowFacet?.getAllRailSegments() ??
|
|
297
|
+
[],
|
|
298
|
+
[uiApi, props.workflowFacet, renderFrameRevision],
|
|
299
|
+
);
|
|
293
300
|
const headings = props.documentNavigation?.headings ?? [];
|
|
294
301
|
const headerVariant = snapshot.pageLayout?.headerVariants[0]?.variant ?? "default";
|
|
295
302
|
const footerVariant = snapshot.pageLayout?.footerVariants[0]?.variant ?? "default";
|
|
@@ -1272,10 +1279,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
1272
1279
|
onRejectRevision: props.onRejectRevision,
|
|
1273
1280
|
onAcceptAllChanges: props.onAcceptAllChanges,
|
|
1274
1281
|
onRejectAllChanges: props.onRejectAllChanges,
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
scopeRailSegments
|
|
1282
|
+
// Layer 11 closeout: mounted workspace chrome reads scope
|
|
1283
|
+
// rail data through `api.ui.scope.rail()`, with the workflow
|
|
1284
|
+
// facet retained as the no-provider fallback.
|
|
1285
|
+
scopeRailSegments,
|
|
1279
1286
|
activeScopeId,
|
|
1280
1287
|
onOpenScope: (segment) => {
|
|
1281
1288
|
handleScopeStripeClick({ scopeId: segment.scopeId });
|