@beyondwork/docx-react-component 1.0.86 → 1.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +338 -13
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +8 -0
  29. package/src/ui/editor-surface-controller.tsx +1 -0
  30. package/src/ui/headless/revision-decoration-model.ts +11 -13
  31. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  32. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  33. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  34. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  35. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  36. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  37. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  38. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  39. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +41 -44
  40. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  41. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +32 -3
  42. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  43. package/src/ui-tailwind/tw-review-workspace.tsx +18 -0
@@ -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,7 +64,10 @@ 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 { createFastTextEditLane } from "./fast-text-edit-lane";
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 {
@@ -148,9 +152,12 @@ function buildPageBreakDecorationsFromProps(
148
152
  : undefined;
149
153
 
150
154
  // 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. Each block has a
152
- // `from`/`to` offset; we find the first and last block whose offset range
153
- // falls within each page's [startOffset, nextPage.startOffset) window.
155
+ // render frame's page offsets + the surface blocks list. Each block has a
156
+ // `from`/`to` offset; a block belongs to every page whose offset window it
157
+ // overlaps. That matters for large tables/objects that can straddle a page
158
+ // boundary: matching only `block.from` would omit the active block from the
159
+ // page marker, cull it on the next viewport refresh, and remap the caret to
160
+ // the wrong PM position during snapshot replacement.
154
161
  // This map is passed into `buildPageBreakDecorations` so the chrome widgets
155
162
  // carry `data-page-first-block-index` / `data-page-last-block-index`
156
163
  // attributes needed by `useVisibleBlockRange`.
@@ -160,24 +167,9 @@ function buildPageBreakDecorationsFromProps(
160
167
  for (let pi = 0; pi < frame.pages.length; pi++) {
161
168
  const page = frame.pages[pi]!;
162
169
  if (page.page.isBlankFiller) continue;
163
- const pageStart = page.page.startOffset;
164
- const pageEnd =
165
- pi + 1 < frame.pages.length
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 });
170
+ const range = findBlockIndexRangeForPage(surfaceBlocks, page.page);
171
+ if (range) {
172
+ blockIndexRangeByPageIndex.set(page.page.pageIndex, range);
181
173
  }
182
174
  }
183
175
  }
@@ -290,6 +282,7 @@ export interface TwProseMirrorSurfaceProps {
290
282
  }) => void;
291
283
  onCommentActivated?: (commentId: string) => void;
292
284
  onRevisionActivated?: (revisionId: string) => void;
285
+ onRevisionHovered?: (revisionId: string | null) => void;
293
286
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
294
287
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
295
288
  workflowScopes?: readonly WorkflowScope[];
@@ -441,7 +434,7 @@ export const TwProseMirrorSurface = forwardRef<
441
434
  const suppressSelectionEchoRef = useRef(false);
442
435
  const sessionRef = useRef<import("./local-edit-session-state").LocalEditSessionState | null>(null);
443
436
  const laneRef = useRef<import("./fast-text-edit-lane").FastTextEditLane | null>(null);
444
- const equivalentAckKeyRef = useRef<string | null>(null);
437
+ const equivalentAckLedgerRef = useRef<Map<string, string>>(new Map());
445
438
  const selectionToolbarFrameRef = useRef<number | null>(null);
446
439
  const lastSelectionToolbarMeasurementRef = useRef<{
447
440
  key: string | null;
@@ -653,10 +646,11 @@ export const TwProseMirrorSurface = forwardRef<
653
646
  createContextualInteractionPlugin({
654
647
  onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
655
648
  onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
649
+ onRevisionHovered: (revisionId) => props.onRevisionHovered?.(revisionId),
656
650
  }),
657
651
  createSearchPlugin(),
658
652
  ];
659
- }, [props.awareness, props.onCommentActivated, props.onRevisionActivated]);
653
+ }, [props.awareness, props.onCommentActivated, props.onRevisionActivated, props.onRevisionHovered]);
660
654
 
661
655
  const applyDecorationProps = useCallback(
662
656
  (view: EditorView, positionMap: PositionMap): void => {
@@ -771,6 +765,7 @@ export const TwProseMirrorSurface = forwardRef<
771
765
  useEffect(() => {
772
766
  if (!props.dispatchRuntimeCommand || !sessionRef.current) {
773
767
  laneRef.current = null;
768
+ equivalentAckLedgerRef.current.clear();
774
769
  return;
775
770
  }
776
771
  // Wave 1 Slice E1/E2 — lane observability.
@@ -815,28 +810,27 @@ export const TwProseMirrorSurface = forwardRef<
815
810
  toRuntime,
816
811
  );
817
812
  },
818
- onEquivalentAck: () => {
819
- // INVARIANT: this marker is set only by onEquivalentAck, which the
820
- // runtime invokes synchronously from dispatchRuntimeCommand. The
821
- // rebuild effect's short-circuit (search for "Predicted-lane
822
- // short-circuit" below) reads it during the same React render cycle
823
- // that the predicted dispatch triggered. If the runtime ack ever
824
- // becomes async (microtask, animation frame, network round-trip),
825
- // this marker will be stale by the time the rebuild effect runs and
826
- // the short-circuit must be replaced with a
827
- // `pendingEquivalentAckOpIds: Set<string>` ledger keyed by opId.
828
- equivalentAckKeyRef.current = documentBuildKeyRef.current;
813
+ onEquivalentAck: (ack) => {
814
+ if (
815
+ ack.opId &&
816
+ ack.newRevisionToken &&
817
+ getTextCommandRefreshClass(ack) === "local-text-equivalent"
818
+ ) {
819
+ equivalentAckLedgerRef.current.set(ack.newRevisionToken, ack.opId);
820
+ return;
821
+ }
822
+ equivalentAckLedgerRef.current.clear();
829
823
  },
830
824
  onAdjustedAck: () => {
831
825
  // Adjusted path: allow the rebuild effect to run (it will call
832
826
  // view.updateState with the canonical snapshot).
833
- equivalentAckKeyRef.current = null;
827
+ equivalentAckLedgerRef.current.clear();
834
828
  },
835
829
  onRejectedAck: () => {
836
- equivalentAckKeyRef.current = null;
830
+ equivalentAckLedgerRef.current.clear();
837
831
  },
838
832
  onStructuralDivergence: () => {
839
- equivalentAckKeyRef.current = null;
833
+ equivalentAckLedgerRef.current.clear();
840
834
  },
841
835
  });
842
836
  }, [props.dispatchRuntimeCommand, scopeTagRegistry]);
@@ -852,13 +846,16 @@ export const TwProseMirrorSurface = forwardRef<
852
846
  // ack, the PM doc already matches the canonical snapshot. Update tracking
853
847
  // refs and decorations without rebuilding the PM state.
854
848
  //
855
- // INVARIANT: reads `equivalentAckKeyRef.current` set by `onEquivalentAck`
856
- // above. Depends on the runtime ack being synchronous so the marker is
857
- // already in place when this effect runs after the predicted dispatch.
858
- // See the comment at `onEquivalentAck` for the async-ack migration path.
849
+ // INVARIANT: equivalent acks are tracked by revision token and op id, not
850
+ // by the previous build key. This keeps the short-circuit valid if the ack
851
+ // and render snapshot stop arriving in the same synchronous React pass.
852
+ const equivalentAckOpId =
853
+ sessionRef.current && !sessionRef.current.hasPending()
854
+ ? equivalentAckLedgerRef.current.get(snapshot.revisionToken)
855
+ : undefined;
859
856
  if (
860
857
  viewRef.current &&
861
- equivalentAckKeyRef.current !== null &&
858
+ equivalentAckOpId !== undefined &&
862
859
  sessionRef.current &&
863
860
  !sessionRef.current.hasPending() &&
864
861
  sessionRef.current.getBaseRevisionToken() === snapshot.revisionToken
@@ -868,7 +865,7 @@ export const TwProseMirrorSurface = forwardRef<
868
865
  positionMapRef.current = buildPositionMap(surface);
869
866
  documentBuildKeyRef.current = documentBuildKey;
870
867
  applyDecorationProps(viewRef.current, positionMapRef.current);
871
- equivalentAckKeyRef.current = null;
868
+ equivalentAckLedgerRef.current.delete(snapshot.revisionToken);
872
869
  if (pendingTypingProbeRef.current) {
873
870
  finishPerfProbe(pendingTypingProbeRef.current);
874
871
  pendingTypingProbeRef.current = null;
@@ -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 >= startOffset && block.from < endOffset) {
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
- 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" : "hover:bg-surface"}`}
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">{rev.authorId} · {rev.createdAt}</p>
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 open
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
  >