@beyondwork/docx-react-component 1.0.86 → 1.0.88
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/public-types.ts +49 -0
- package/src/api/v3/ui/chrome-composition.ts +2 -11
- package/src/api/v3/ui/chrome.ts +6 -8
- package/src/index.ts +5 -0
- package/src/io/export/serialize-main-document.ts +215 -6
- package/src/io/ooxml/parse-drawing.ts +15 -1
- package/src/io/ooxml/parse-fields.ts +410 -12
- package/src/model/canonical-document.ts +177 -2
- package/src/model/layout/page-layout-snapshot.ts +2 -0
- package/src/model/layout/runtime-page-graph-types.ts +6 -0
- package/src/preservation/store.ts +4 -5
- package/src/runtime/document-outline.ts +80 -0
- package/src/runtime/document-runtime.ts +580 -40
- package/src/runtime/formatting/field/page-number-format.ts +49 -0
- package/src/runtime/formatting/field/resolver.ts +61 -40
- package/src/runtime/layout/layout-engine-instance.ts +18 -1
- package/src/runtime/layout/layout-engine-version.ts +19 -1
- package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
- package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
- package/src/runtime/layout/page-graph.ts +13 -2
- package/src/runtime/layout/paginated-layout-engine.ts +440 -117
- package/src/runtime/layout/project-block-fragments.ts +87 -4
- package/src/runtime/layout/resolve-page-fields.ts +8 -5
- package/src/runtime/layout/table-row-split.ts +97 -23
- package/src/runtime/surface-projection.ts +227 -27
- package/src/shell/session-bootstrap.ts +6 -1
- package/src/ui/WordReviewEditor.tsx +8 -0
- package/src/ui/editor-surface-controller.tsx +1 -0
- package/src/ui/headless/revision-decoration-model.ts +11 -13
- package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
- package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
- package/src/ui-tailwind/editor-surface/preserve-position.ts +31 -6
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +20 -5
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +92 -50
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +32 -3
- package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
- package/src/ui-tailwind/tw-review-workspace.tsx +18 -0
|
@@ -137,7 +137,23 @@ export function restoreScrollAnchor(
|
|
|
137
137
|
anchor: ScrollAnchor | null,
|
|
138
138
|
options?: FindScrollAnchorOptions,
|
|
139
139
|
): void {
|
|
140
|
-
|
|
140
|
+
const targetScrollTop = resolveScrollTopForAnchor(root, anchor, options);
|
|
141
|
+
if (root && targetScrollTop !== null) {
|
|
142
|
+
root.scrollTop = targetScrollTop;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the `scrollTop` that would restore `anchor` without mutating the
|
|
148
|
+
* scroll container. Used by the edit-path preservation guard so it can reject
|
|
149
|
+
* unsafe restores before writing scroll state.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveScrollTopForAnchor(
|
|
152
|
+
root: HTMLElement | null,
|
|
153
|
+
anchor: ScrollAnchor | null,
|
|
154
|
+
options?: FindScrollAnchorOptions,
|
|
155
|
+
): number | null {
|
|
156
|
+
if (!root || !anchor) return null;
|
|
141
157
|
|
|
142
158
|
if (options?.geometryFacet && !options.prepaintFallback) {
|
|
143
159
|
const geometry = options.geometryFacet.getBlock(anchor.blockId);
|
|
@@ -150,15 +166,14 @@ export function restoreScrollAnchor(
|
|
|
150
166
|
// newScrollTop = newBlockTop + offsetWithinBlock.
|
|
151
167
|
// Matches the DOM-path formula below (round-trip verified by
|
|
152
168
|
// `test/ui/mode-toggle-scroll-anchor.test.ts`).
|
|
153
|
-
|
|
154
|
-
return;
|
|
169
|
+
return rect.topPx + anchor.offsetWithinBlock;
|
|
155
170
|
}
|
|
156
171
|
// No block match through facet; fall through to DOM path.
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
const selector = `[data-block-id="${cssEscape(anchor.blockId)}"]`;
|
|
160
175
|
const block = root.querySelector<HTMLElement>(selector);
|
|
161
|
-
if (!block) return;
|
|
176
|
+
if (!block) return null;
|
|
162
177
|
// Cold-open / pre-paint DOM fallback — same rationale as
|
|
163
178
|
// findScrollAnchor's fallback above.
|
|
164
179
|
// geometry:allow-dom-fallback
|
|
@@ -171,7 +186,7 @@ export function restoreScrollAnchor(
|
|
|
171
186
|
// capture time). Scrolling by `delta` shifts rects by `-delta`, so
|
|
172
187
|
// solve for delta: delta = blockRect.top - rootRect.top + offsetWithinBlock.
|
|
173
188
|
const delta = blockRect.top - rootRect.top + anchor.offsetWithinBlock;
|
|
174
|
-
|
|
189
|
+
return root.scrollTop + delta;
|
|
175
190
|
}
|
|
176
191
|
|
|
177
192
|
/**
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from "./pm-command-bridge";
|
|
52
52
|
import { buildDecorations } from "./pm-decorations";
|
|
53
53
|
import { buildPageBreakDecorations } from "./pm-page-break-decorations";
|
|
54
|
+
import { findBlockIndexRangeForPage } from "./page-slice-util.ts";
|
|
54
55
|
import { DecorationSet } from "prosemirror-view";
|
|
55
56
|
import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
|
|
56
57
|
import { buildPagePreviewMaps } from "../../api/public-types";
|
|
@@ -63,11 +64,15 @@ import {
|
|
|
63
64
|
} from "./perf-probe";
|
|
64
65
|
import { buildPositionMap, type PositionMap } from "./pm-position-map";
|
|
65
66
|
import { createLocalEditSessionState } from "./local-edit-session-state";
|
|
66
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
createFastTextEditLane,
|
|
69
|
+
getTextCommandRefreshClass,
|
|
70
|
+
} from "./fast-text-edit-lane";
|
|
67
71
|
import { createPredictedTxGate } from "./predicted-tx-gate";
|
|
68
72
|
import { replaceStatePreservingPosition } from "./preserve-position";
|
|
69
73
|
import {
|
|
70
74
|
createScopeTagRegistry,
|
|
75
|
+
storyTargetsEqual,
|
|
71
76
|
type ScopeTagRegistry,
|
|
72
77
|
} from "../../api/public-types";
|
|
73
78
|
import { hasBailIfCrossedTagInRange } from "./predicted-tag-preflight";
|
|
@@ -88,6 +93,24 @@ import { chartNodeViews } from "./chart-node-view.tsx";
|
|
|
88
93
|
import type { SelectionToolbarAnchor } from "../../ui/headless/selection-toolbar-model";
|
|
89
94
|
import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
|
|
90
95
|
|
|
96
|
+
type RebuildScrollAnchorPolicy = "bounded-same-story";
|
|
97
|
+
|
|
98
|
+
const BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX = 256;
|
|
99
|
+
|
|
100
|
+
export function shouldPreserveScrollAnchorForRebuild(options: {
|
|
101
|
+
policy: RebuildScrollAnchorPolicy | null;
|
|
102
|
+
view: Pick<EditorView, "hasFocus"> | null;
|
|
103
|
+
geometryFacet?: import("../../runtime/geometry/index.ts").GeometryFacet;
|
|
104
|
+
previousStory: EditorStoryTarget | null;
|
|
105
|
+
nextStory: EditorStoryTarget;
|
|
106
|
+
}): boolean {
|
|
107
|
+
if (options.policy !== "bounded-same-story") return false;
|
|
108
|
+
if (!options.view?.hasFocus()) return false;
|
|
109
|
+
if (!options.geometryFacet) return false;
|
|
110
|
+
if (!options.previousStory) return false;
|
|
111
|
+
return storyTargetsEqual(options.previousStory, options.nextStory);
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
/**
|
|
92
115
|
* Build page-break widget decorations from the layout facet's current page
|
|
93
116
|
* graph. Returns `[]` when the facet is unavailable, the active story is
|
|
@@ -148,9 +171,12 @@ function buildPageBreakDecorationsFromProps(
|
|
|
148
171
|
: undefined;
|
|
149
172
|
|
|
150
173
|
// L7 Phase 2 Task 2.2.4a — compute per-page block-index ranges from the
|
|
151
|
-
// render frame's page offsets + the surface blocks list.
|
|
152
|
-
// `from`/`to` offset;
|
|
153
|
-
//
|
|
174
|
+
// render frame's page offsets + the surface blocks list. Each block has a
|
|
175
|
+
// `from`/`to` offset; a block belongs to every page whose offset window it
|
|
176
|
+
// overlaps. That matters for large tables/objects that can straddle a page
|
|
177
|
+
// boundary: matching only `block.from` would omit the active block from the
|
|
178
|
+
// page marker, cull it on the next viewport refresh, and remap the caret to
|
|
179
|
+
// the wrong PM position during snapshot replacement.
|
|
154
180
|
// This map is passed into `buildPageBreakDecorations` so the chrome widgets
|
|
155
181
|
// carry `data-page-first-block-index` / `data-page-last-block-index`
|
|
156
182
|
// attributes needed by `useVisibleBlockRange`.
|
|
@@ -160,24 +186,9 @@ function buildPageBreakDecorationsFromProps(
|
|
|
160
186
|
for (let pi = 0; pi < frame.pages.length; pi++) {
|
|
161
187
|
const page = frame.pages[pi]!;
|
|
162
188
|
if (page.page.isBlankFiller) continue;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
? frame.pages[pi + 1]!.page.startOffset
|
|
167
|
-
: Infinity;
|
|
168
|
-
let first = -1;
|
|
169
|
-
let last = -1;
|
|
170
|
-
for (let bi = 0; bi < surfaceBlocks.length; bi++) {
|
|
171
|
-
const block = surfaceBlocks[bi]!;
|
|
172
|
-
const blockFrom = block.from; // from is required on all SurfaceBlockSnapshot variants
|
|
173
|
-
// Block belongs to this page if its start falls within the page's offset window.
|
|
174
|
-
if (blockFrom >= pageStart && blockFrom < pageEnd) {
|
|
175
|
-
if (first === -1) first = bi;
|
|
176
|
-
last = bi;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (first !== -1) {
|
|
180
|
-
blockIndexRangeByPageIndex.set(page.page.pageIndex, { first, last });
|
|
189
|
+
const range = findBlockIndexRangeForPage(surfaceBlocks, page.page);
|
|
190
|
+
if (range) {
|
|
191
|
+
blockIndexRangeByPageIndex.set(page.page.pageIndex, range);
|
|
181
192
|
}
|
|
182
193
|
}
|
|
183
194
|
}
|
|
@@ -290,6 +301,7 @@ export interface TwProseMirrorSurfaceProps {
|
|
|
290
301
|
}) => void;
|
|
291
302
|
onCommentActivated?: (commentId: string) => void;
|
|
292
303
|
onRevisionActivated?: (revisionId: string) => void;
|
|
304
|
+
onRevisionHovered?: (revisionId: string | null) => void;
|
|
293
305
|
onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
|
|
294
306
|
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
295
307
|
workflowScopes?: readonly WorkflowScope[];
|
|
@@ -441,7 +453,9 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
441
453
|
const suppressSelectionEchoRef = useRef(false);
|
|
442
454
|
const sessionRef = useRef<import("./local-edit-session-state").LocalEditSessionState | null>(null);
|
|
443
455
|
const laneRef = useRef<import("./fast-text-edit-lane").FastTextEditLane | null>(null);
|
|
444
|
-
const
|
|
456
|
+
const equivalentAckLedgerRef = useRef<Map<string, string>>(new Map());
|
|
457
|
+
const pendingRebuildScrollAnchorRef = useRef<RebuildScrollAnchorPolicy | null>(null);
|
|
458
|
+
const lastBuiltStoryRef = useRef<EditorStoryTarget | null>(null);
|
|
445
459
|
const selectionToolbarFrameRef = useRef<number | null>(null);
|
|
446
460
|
const lastSelectionToolbarMeasurementRef = useRef<{
|
|
447
461
|
key: string | null;
|
|
@@ -653,10 +667,11 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
653
667
|
createContextualInteractionPlugin({
|
|
654
668
|
onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
|
|
655
669
|
onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
|
|
670
|
+
onRevisionHovered: (revisionId) => props.onRevisionHovered?.(revisionId),
|
|
656
671
|
}),
|
|
657
672
|
createSearchPlugin(),
|
|
658
673
|
];
|
|
659
|
-
}, [props.awareness, props.onCommentActivated, props.onRevisionActivated]);
|
|
674
|
+
}, [props.awareness, props.onCommentActivated, props.onRevisionActivated, props.onRevisionHovered]);
|
|
660
675
|
|
|
661
676
|
const applyDecorationProps = useCallback(
|
|
662
677
|
(view: EditorView, positionMap: PositionMap): void => {
|
|
@@ -771,6 +786,8 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
771
786
|
useEffect(() => {
|
|
772
787
|
if (!props.dispatchRuntimeCommand || !sessionRef.current) {
|
|
773
788
|
laneRef.current = null;
|
|
789
|
+
equivalentAckLedgerRef.current.clear();
|
|
790
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
774
791
|
return;
|
|
775
792
|
}
|
|
776
793
|
// Wave 1 Slice E1/E2 — lane observability.
|
|
@@ -815,28 +832,34 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
815
832
|
toRuntime,
|
|
816
833
|
);
|
|
817
834
|
},
|
|
818
|
-
onEquivalentAck: () => {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
835
|
+
onEquivalentAck: (ack) => {
|
|
836
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
837
|
+
if (
|
|
838
|
+
ack.opId &&
|
|
839
|
+
ack.newRevisionToken &&
|
|
840
|
+
getTextCommandRefreshClass(ack) === "local-text-equivalent"
|
|
841
|
+
) {
|
|
842
|
+
equivalentAckLedgerRef.current.set(ack.newRevisionToken, ack.opId);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
equivalentAckLedgerRef.current.clear();
|
|
829
846
|
},
|
|
830
|
-
onAdjustedAck: () => {
|
|
847
|
+
onAdjustedAck: (ack) => {
|
|
831
848
|
// Adjusted path: allow the rebuild effect to run (it will call
|
|
832
849
|
// view.updateState with the canonical snapshot).
|
|
833
|
-
|
|
850
|
+
equivalentAckLedgerRef.current.clear();
|
|
851
|
+
pendingRebuildScrollAnchorRef.current =
|
|
852
|
+
getTextCommandRefreshClass(ack) === "surface-only"
|
|
853
|
+
? "bounded-same-story"
|
|
854
|
+
: null;
|
|
834
855
|
},
|
|
835
856
|
onRejectedAck: () => {
|
|
836
|
-
|
|
857
|
+
equivalentAckLedgerRef.current.clear();
|
|
858
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
837
859
|
},
|
|
838
860
|
onStructuralDivergence: () => {
|
|
839
|
-
|
|
861
|
+
equivalentAckLedgerRef.current.clear();
|
|
862
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
840
863
|
},
|
|
841
864
|
});
|
|
842
865
|
}, [props.dispatchRuntimeCommand, scopeTagRegistry]);
|
|
@@ -845,6 +868,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
845
868
|
if (!mountRef.current || !surface) return;
|
|
846
869
|
|
|
847
870
|
if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
|
|
871
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
848
872
|
return;
|
|
849
873
|
}
|
|
850
874
|
|
|
@@ -852,13 +876,16 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
852
876
|
// ack, the PM doc already matches the canonical snapshot. Update tracking
|
|
853
877
|
// refs and decorations without rebuilding the PM state.
|
|
854
878
|
//
|
|
855
|
-
// INVARIANT:
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
|
|
879
|
+
// INVARIANT: equivalent acks are tracked by revision token and op id, not
|
|
880
|
+
// by the previous build key. This keeps the short-circuit valid if the ack
|
|
881
|
+
// and render snapshot stop arriving in the same synchronous React pass.
|
|
882
|
+
const equivalentAckOpId =
|
|
883
|
+
sessionRef.current && !sessionRef.current.hasPending()
|
|
884
|
+
? equivalentAckLedgerRef.current.get(snapshot.revisionToken)
|
|
885
|
+
: undefined;
|
|
859
886
|
if (
|
|
860
887
|
viewRef.current &&
|
|
861
|
-
|
|
888
|
+
equivalentAckOpId !== undefined &&
|
|
862
889
|
sessionRef.current &&
|
|
863
890
|
!sessionRef.current.hasPending() &&
|
|
864
891
|
sessionRef.current.getBaseRevisionToken() === snapshot.revisionToken
|
|
@@ -868,7 +895,9 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
868
895
|
positionMapRef.current = buildPositionMap(surface);
|
|
869
896
|
documentBuildKeyRef.current = documentBuildKey;
|
|
870
897
|
applyDecorationProps(viewRef.current, positionMapRef.current);
|
|
871
|
-
|
|
898
|
+
equivalentAckLedgerRef.current.delete(snapshot.revisionToken);
|
|
899
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
900
|
+
lastBuiltStoryRef.current = snapshot.activeStory;
|
|
872
901
|
if (pendingTypingProbeRef.current) {
|
|
873
902
|
finishPerfProbe(pendingTypingProbeRef.current);
|
|
874
903
|
pendingTypingProbeRef.current = null;
|
|
@@ -924,24 +953,37 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
924
953
|
// AFTER, so PM's internal selection-change events during the
|
|
925
954
|
// swap are swallowed by the selection-sync plugin.
|
|
926
955
|
//
|
|
927
|
-
// Scroll-anchor preservation
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
956
|
+
// Scroll-anchor preservation is narrowly re-enabled only when a
|
|
957
|
+
// predicted edit receives a `surface-only` adjusted ack and the
|
|
958
|
+
// replacement stays in the same focused story with a live geometry
|
|
959
|
+
// facet. `maxScrollDeltaPx` is the guardrail: if the anchor target
|
|
960
|
+
// would move by more than the small local-edit budget, the helper
|
|
961
|
+
// refuses the restore and leaves the browser/runtime position alone.
|
|
932
962
|
//
|
|
933
963
|
// Ordering invariant is regression-guarded by
|
|
934
964
|
// `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
|
|
965
|
+
const scrollAnchorPolicy = pendingRebuildScrollAnchorRef.current;
|
|
966
|
+
pendingRebuildScrollAnchorRef.current = null;
|
|
967
|
+
const preserveScrollAnchor = shouldPreserveScrollAnchorForRebuild({
|
|
968
|
+
policy: scrollAnchorPolicy,
|
|
969
|
+
view: viewRef.current,
|
|
970
|
+
geometryFacet: props.geometryFacet,
|
|
971
|
+
previousStory: lastBuiltStoryRef.current,
|
|
972
|
+
nextStory: snapshot.activeStory,
|
|
973
|
+
});
|
|
935
974
|
replaceStatePreservingPosition(
|
|
936
975
|
{
|
|
937
976
|
view: viewRef.current,
|
|
938
977
|
geometryFacet: props.geometryFacet,
|
|
939
978
|
suppressionRef: suppressSelectionEchoRef,
|
|
979
|
+
preserveScrollAnchor,
|
|
980
|
+
maxScrollDeltaPx: BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX,
|
|
940
981
|
},
|
|
941
982
|
state,
|
|
942
983
|
);
|
|
943
984
|
}
|
|
944
985
|
documentBuildKeyRef.current = documentBuildKey;
|
|
986
|
+
lastBuiltStoryRef.current = snapshot.activeStory;
|
|
945
987
|
applyDecorationProps(viewRef.current, positionMap);
|
|
946
988
|
|
|
947
989
|
if (activeSearchRef.current) {
|
|
@@ -172,7 +172,7 @@ function resolveBlockRangeFromOffsetSpan(input: {
|
|
|
172
172
|
const block = blocks[index];
|
|
173
173
|
if (!block) continue;
|
|
174
174
|
if (block.from >= endOffset) break;
|
|
175
|
-
if (block.from
|
|
175
|
+
if (block.from < endOffset && block.to > startOffset) {
|
|
176
176
|
if (first < 0) first = index;
|
|
177
177
|
last = index;
|
|
178
178
|
}
|
|
@@ -4,6 +4,7 @@ import { Check, MessageSquare, X } from "lucide-react";
|
|
|
4
4
|
import type { TrackedChangesSnapshot, TrackedChangeEntrySnapshot } from "../../api/public-types";
|
|
5
5
|
import { selectVisibleRevisions } from "../../ui/shared/revision-filters";
|
|
6
6
|
import type { MarkupDisplay } from "../../ui/headless/comment-decoration-model";
|
|
7
|
+
import { getAuthorColor } from "../../ui/headless/revision-decoration-model";
|
|
7
8
|
|
|
8
9
|
export interface TwRevisionSidebarProps {
|
|
9
10
|
trackedChanges: TrackedChangesSnapshot;
|
|
@@ -37,6 +38,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
|
|
|
37
38
|
|
|
38
39
|
const [typeFilter, setTypeFilter] = React.useState<TypeFilter>("all");
|
|
39
40
|
const [authorFilter, setAuthorFilter] = React.useState<string | null>(null);
|
|
41
|
+
const activeCardRef = React.useRef<HTMLDivElement | null>(null);
|
|
40
42
|
|
|
41
43
|
// Derive distinct authors from all visible revisions
|
|
42
44
|
const authors = React.useMemo(() => {
|
|
@@ -79,6 +81,11 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
|
|
|
79
81
|
}
|
|
80
82
|
}, [filteredRevisions, typeFilter, authorFilter, props.onRejectAllChanges, props.onRejectRevision]);
|
|
81
83
|
|
|
84
|
+
React.useEffect(() => {
|
|
85
|
+
if (!activeRevisionId) return;
|
|
86
|
+
activeCardRef.current?.scrollIntoView({ block: "nearest" });
|
|
87
|
+
}, [activeRevisionId, filteredRevisions]);
|
|
88
|
+
|
|
82
89
|
return (
|
|
83
90
|
<div className="flex flex-col outline-none">
|
|
84
91
|
{/* Stats header */}
|
|
@@ -119,17 +126,28 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
|
|
|
119
126
|
<div className="space-y-2">
|
|
120
127
|
{filteredRevisions.map((rev) => {
|
|
121
128
|
const isActive = activeRevisionId === rev.revisionId;
|
|
129
|
+
const authorColor = getAuthorColor(rev.authorId);
|
|
122
130
|
|
|
123
131
|
return (
|
|
124
132
|
<div
|
|
125
133
|
key={rev.revisionId}
|
|
126
|
-
|
|
134
|
+
ref={(node) => {
|
|
135
|
+
if (isActive) {
|
|
136
|
+
activeCardRef.current = node;
|
|
137
|
+
}
|
|
138
|
+
}}
|
|
139
|
+
className={`w-full text-left flex rounded-md bg-surface/90 transition-colors ring-1 ring-border ${isActive ? "bg-accent-soft/40 ring-accent/25 shadow-[var(--shadow-soft)]" : "hover:bg-surface"}`}
|
|
140
|
+
style={
|
|
141
|
+
authorColor && isActive
|
|
142
|
+
? { boxShadow: `0 0 0 1px ${authorColor}, var(--shadow-soft)` }
|
|
143
|
+
: undefined
|
|
144
|
+
}
|
|
127
145
|
>
|
|
128
146
|
<div className={`w-0.5 shrink-0 rounded-l-md ${
|
|
129
147
|
rev.kind === "insertion" ? "bg-insert"
|
|
130
148
|
: rev.kind === "deletion" ? "bg-danger"
|
|
131
149
|
: "bg-tertiary"
|
|
132
|
-
}`} />
|
|
150
|
+
}`} style={authorColor ? { backgroundColor: authorColor } : undefined} />
|
|
133
151
|
<div className="flex-1 min-w-0">
|
|
134
152
|
<button
|
|
135
153
|
type="button"
|
|
@@ -140,7 +158,18 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
|
|
|
140
158
|
<span className="text-[11px] font-medium text-primary">{rev.anchorLabel}</span>
|
|
141
159
|
<RevisionBadge status={rev.status} actionability={rev.actionability} />
|
|
142
160
|
</div>
|
|
143
|
-
<p className="mb-1 text-[10px] text-tertiary">
|
|
161
|
+
<p className="mb-1 flex items-center gap-1.5 text-[10px] text-tertiary">
|
|
162
|
+
{authorColor ? (
|
|
163
|
+
<span
|
|
164
|
+
aria-hidden="true"
|
|
165
|
+
className="h-2 w-2 rounded-full"
|
|
166
|
+
style={{ backgroundColor: authorColor }}
|
|
167
|
+
/>
|
|
168
|
+
) : null}
|
|
169
|
+
<span className="truncate">{rev.authorId}</span>
|
|
170
|
+
<span aria-hidden="true">·</span>
|
|
171
|
+
<span>{rev.createdAt}</span>
|
|
172
|
+
</p>
|
|
144
173
|
{rev.excerpt ? (
|
|
145
174
|
<p className={`text-[11px] ${
|
|
146
175
|
rev.kind === "insertion" ? "text-insert"
|
|
@@ -21,7 +21,7 @@ export interface ReviewRailState {
|
|
|
21
21
|
* Review-rail open/close state + the responsive transition effect.
|
|
22
22
|
*
|
|
23
23
|
* When the responsive signature flips (narrow↔wide, or
|
|
24
|
-
* `reviewRailAvailable` changes), the rail resets to its default
|
|
24
|
+
* `reviewRailAvailable` changes), the rail resets to its default closed
|
|
25
25
|
* state per `getInitialReviewRailOpen`. A ref guards the effect so it
|
|
26
26
|
* only fires on actual transitions, not every viewport resize.
|
|
27
27
|
*
|
|
@@ -181,6 +181,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
181
181
|
// in the render tree below.
|
|
182
182
|
const { bodySlotRef, pmSurfaceElement } = usePmSurfaceCapture();
|
|
183
183
|
const { scrollRootRef, pageStackScrollRoot } = useScrollRootCapture();
|
|
184
|
+
const lastHoveredRevisionIdRef = useRef<string | null>(null);
|
|
184
185
|
const caps = props.capabilities;
|
|
185
186
|
const isPageWorkspace = props.workspaceMode === "page";
|
|
186
187
|
const markupDisplay = props.markupDisplay;
|
|
@@ -248,6 +249,22 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
248
249
|
reviewRailAvailable,
|
|
249
250
|
viewportWidth,
|
|
250
251
|
});
|
|
252
|
+
const handleDocumentMouseOver = useCallback(
|
|
253
|
+
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
254
|
+
const element = event.target as HTMLElement | null;
|
|
255
|
+
const revisionId =
|
|
256
|
+
element?.closest?.("[data-revision-id]")?.getAttribute("data-revision-id") ?? null;
|
|
257
|
+
if (!revisionId || (revisionId === lastHoveredRevisionIdRef.current && reviewRailOpen)) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
lastHoveredRevisionIdRef.current = revisionId;
|
|
261
|
+
if (reviewRailAvailable) {
|
|
262
|
+
setReviewRailOpen(true);
|
|
263
|
+
props.onActiveRailTabChange?.("changes");
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
[props.onActiveRailTabChange, reviewRailAvailable, reviewRailOpen, setReviewRailOpen],
|
|
267
|
+
);
|
|
251
268
|
// Incremented on zoom_changed / render_frame_ready so the placement
|
|
252
269
|
// useMemo below re-executes when the render kernel emits new rects.
|
|
253
270
|
const renderFrameRevision = useLayoutFacetRenderSignal(props.layoutFacet);
|
|
@@ -928,6 +945,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
928
945
|
<div className="flex flex-1 flex-col min-w-0">
|
|
929
946
|
<div
|
|
930
947
|
ref={scrollRootRef}
|
|
948
|
+
onMouseOver={handleDocumentMouseOver}
|
|
931
949
|
className="flex-1 overflow-y-auto bg-surface"
|
|
932
950
|
data-wre-scroll-root="true"
|
|
933
951
|
>
|