@beyondwork/docx-react-component 1.0.50 → 1.0.51

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.
@@ -36,6 +36,7 @@ import {
36
36
  type SearchMatchRange,
37
37
  } from "./decoration-resolver.ts";
38
38
  import { classifyBlockKind as classifyBlockKindFromId } from "./block-fragment-projection.ts";
39
+ import { diffRenderFrames } from "./render-frame-diff.ts";
39
40
  import {
40
41
  EMPTY_DECORATION_INDEX,
41
42
  defaultChromeReservations,
@@ -141,6 +142,11 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
141
142
  const getActiveStory = input.getActiveStory ?? (() => MAIN_STORY_TARGET);
142
143
  let zoom: RenderZoom = input.initialZoom ?? resolveDefaultZoom();
143
144
  let cache: { revision: number; frame: RenderFrame } | null = null;
145
+ // P10 Phase B — retained across `invalidate()` and `cache = null` so
146
+ // `frame_diff` can compute a meaningful diff when the next build
147
+ // produces a structurally-equal frame. Distinct from `cache`, which
148
+ // is an optimizer for repeat reads at the same revision.
149
+ let lastEmittedFrame: RenderFrame | null = null;
144
150
 
145
151
  const listeners = new Set<(event: RenderKernelEvent) => void>();
146
152
  const unsubscribeFacet = facet.subscribe((event) => {
@@ -254,6 +260,20 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
254
260
  if (options === undefined) {
255
261
  cache = { revision: frame.revision, frame };
256
262
  emit({ kind: "frame_built", revision: frame.revision, reason: "full" });
263
+ // P10 Phase B — compute structural diff vs. the last emitted
264
+ // frame (retained across `invalidate()`) so page-stack consumers
265
+ // can skip React reconciliation on unchanged pages even after
266
+ // a cache invalidation. On cold start (no prior emission) the
267
+ // diff reports every page in `addedPages`, giving the initial
268
+ // mount a consistent subscription contract.
269
+ const diffT0 =
270
+ typeof performance !== "undefined" ? performance.now() : 0;
271
+ const diff = diffRenderFrames(lastEmittedFrame, frame);
272
+ if (diffT0 > 0) {
273
+ recordPerfSample("render.frame_diff", performance.now() - diffT0);
274
+ }
275
+ emit({ kind: "frame_diff", revision: frame.revision, diff });
276
+ lastEmittedFrame = frame;
257
277
  }
258
278
  return frame;
259
279
  },
@@ -688,25 +708,31 @@ function buildAnchorIndex(
688
708
  }
689
709
  }
690
710
 
691
- // Pending-op deltas are read here as a seam for a future decoration-
692
- // resolver-driven rect shift; today the kernel does not mutate rects
693
- // during the predicted-dispatch window because the correct reconciliation
694
- // belongs to R4's decoration resolver (it needs per-line width info the
695
- // MVP doesn't surface yet). The deltas are still read so consumers can
696
- // rely on the accessor shape landing now.
697
- void pendingDeltas;
711
+ // P9 Phase A2 during the predicted-dispatch window, runtime offsets
712
+ // passed in by chrome surfaces reflect the visible (predicted) text but
713
+ // the anchor-index maps are keyed on the *pre-delta* runtime offsets
714
+ // emitted by the last-committed page graph. Shift the lookup offset
715
+ // back by the sum of deltas applied at or before it so chrome rects
716
+ // stay aligned with the caret while the predicted text is on screen.
717
+ // The rect pixel positions are correct as drawn — only the offset →
718
+ // rect mapping needs compensation.
719
+ const shiftForDeltas = (offset: number): number => {
720
+ if (pendingDeltas.length === 0) return offset;
721
+ return offset - sumDeltasBefore(pendingDeltas, offset);
722
+ };
698
723
 
699
724
  const resolveByRuntimeOffset = (
700
725
  offset: number,
701
726
  _story?: EditorStoryTarget,
702
727
  ): RenderFrameRect | null => {
703
728
  void _story;
704
- const exact = byRuntimeOffset.get(offset);
729
+ const lookup = shiftForDeltas(offset);
730
+ const exact = byRuntimeOffset.get(lookup);
705
731
  if (exact) return exact;
706
732
  let best: RenderFrameRect | null = null;
707
733
  let bestDistance = Number.POSITIVE_INFINITY;
708
734
  for (const [key, rect] of byRuntimeOffset) {
709
- const distance = Math.abs(key - offset);
735
+ const distance = Math.abs(key - lookup);
710
736
  if (distance < bestDistance) {
711
737
  best = rect;
712
738
  bestDistance = distance;
@@ -734,9 +760,13 @@ function buildAnchorIndex(
734
760
  if (lo === hi) {
735
761
  return resolveByRuntimeOffset(lo, story);
736
762
  }
763
+ // Shift range endpoints back to pre-delta space before iterating
764
+ // the anchor maps (see `shiftForDeltas` rationale above).
765
+ const loShifted = shiftForDeltas(lo);
766
+ const hiShifted = shiftForDeltas(hi);
737
767
  let union: RenderFrameRect | null = null;
738
768
  for (const [key, rect] of byRuntimeOffset) {
739
- if (key < lo || key >= hi) continue;
769
+ if (key < loShifted || key >= hiShifted) continue;
740
770
  union = unionRects(union, rect);
741
771
  }
742
772
  if (union) return union;
@@ -0,0 +1,202 @@
1
+ /**
2
+ * R.1 Phase 6a — SelectionLayer cursor primitives.
3
+ *
4
+ * Pure functions over `(DocumentRootNode, CursorSelection, op) → CursorSelection`.
5
+ * `extend: true` keeps the anchor fixed and moves only the head — the
6
+ * LibreOffice `SwPaM` head/anchor split that matches Shift+arrow semantics.
7
+ *
8
+ * Positions are 0-based logical positions per the canonical story layer
9
+ * (`createPlainText(parseTextStory(doc))`). Scope markers are zero-width;
10
+ * paragraph breaks are 1 wide; text / tab / hard_break / image / opaque are 1
11
+ * wide. The plain-text string produced by `createPlainText` is a 1:1 mapping
12
+ * of those logical positions to characters, which makes position-math trivial
13
+ * and makes `Intl.Segmenter` (for word boundaries) a drop-in fit.
14
+ *
15
+ * What this module deliberately does NOT ship yet:
16
+ * - `moveUp` / `moveDown` — genuinely layout-dependent (need column tracking
17
+ * + line-wrap info). Follows Phase 6b once Lane 3a P9 exposes the per-run
18
+ * layout facet needed for column-preserving movement.
19
+ * - `moveLineStart` / `moveLineEnd` — same; these need soft-wrap info that
20
+ * the canonical story layer does not expose.
21
+ */
22
+
23
+ import { createPlainText, parseTextStory } from "../../core/schema/text-schema.ts";
24
+ import type { DocumentRootNode } from "../../model/canonical-document.ts";
25
+
26
+ export interface CursorSelection {
27
+ anchor: number;
28
+ head: number;
29
+ }
30
+
31
+ export interface CursorMoveOptions {
32
+ /**
33
+ * When true, the anchor stays fixed and only the head moves — matches
34
+ * Shift+arrow "extend selection" semantics. When false (default), both
35
+ * anchor and head land at the new head, collapsing any range selection.
36
+ */
37
+ extend?: boolean;
38
+ }
39
+
40
+ export function moveCharLeft(
41
+ doc: DocumentRootNode,
42
+ selection: CursorSelection,
43
+ options: CursorMoveOptions = {},
44
+ ): CursorSelection {
45
+ const text = documentPlainText(doc);
46
+ // Word semantics (no-extend): a range selection collapses to the LEFT edge
47
+ // without moving; only a collapsed caret actually decrements by one.
48
+ if (!options.extend && selection.anchor !== selection.head) {
49
+ const leftEdge = Math.min(selection.anchor, selection.head);
50
+ return { anchor: leftEdge, head: leftEdge };
51
+ }
52
+ const currentHead = selection.head;
53
+ const nextHead = Math.max(0, currentHead - 1);
54
+ return finalize(selection, nextHead, options);
55
+ }
56
+
57
+ export function moveCharRight(
58
+ doc: DocumentRootNode,
59
+ selection: CursorSelection,
60
+ options: CursorMoveOptions = {},
61
+ ): CursorSelection {
62
+ const text = documentPlainText(doc);
63
+ // Word semantics (no-extend): a range selection collapses to the RIGHT edge
64
+ // without moving; only a collapsed caret actually increments by one.
65
+ if (!options.extend && selection.anchor !== selection.head) {
66
+ const rightEdge = Math.max(selection.anchor, selection.head);
67
+ return { anchor: rightEdge, head: rightEdge };
68
+ }
69
+ const currentHead = selection.head;
70
+ const nextHead = Math.min(text.length, currentHead + 1);
71
+ return finalize(selection, nextHead, options);
72
+ }
73
+
74
+ export function moveParagraphStart(
75
+ doc: DocumentRootNode,
76
+ selection: CursorSelection,
77
+ options: CursorMoveOptions = {},
78
+ ): CursorSelection {
79
+ const text = documentPlainText(doc);
80
+ const head = options.extend ? selection.head : selection.head;
81
+ // Walk backward until we find a paragraph break ('\n') or reach 0. The
82
+ // paragraph start is the character immediately after the break.
83
+ let cursor = Math.max(0, Math.min(text.length, head));
84
+ while (cursor > 0 && text[cursor - 1] !== "\n") {
85
+ cursor -= 1;
86
+ }
87
+ return finalize(selection, cursor, options);
88
+ }
89
+
90
+ export function moveParagraphEnd(
91
+ doc: DocumentRootNode,
92
+ selection: CursorSelection,
93
+ options: CursorMoveOptions = {},
94
+ ): CursorSelection {
95
+ const text = documentPlainText(doc);
96
+ const head = options.extend ? selection.head : selection.head;
97
+ let cursor = Math.max(0, Math.min(text.length, head));
98
+ while (cursor < text.length && text[cursor] !== "\n") {
99
+ cursor += 1;
100
+ }
101
+ return finalize(selection, cursor, options);
102
+ }
103
+
104
+ /**
105
+ * `Intl.Segmenter` with `granularity: "word"` returns ICU word boundaries —
106
+ * the standards-backed equivalent of LibreOffice's `SwBreakIt`. We walk the
107
+ * boundary list (cached per-`doc` call) to find the next / previous
108
+ * word-start relative to the head, matching Word's Ctrl+arrow behavior.
109
+ *
110
+ * Word semantics:
111
+ * - Ctrl+Right: jump to the end of the current word, OR to the end of the
112
+ * next word if already at word-end. In practice we land on the next
113
+ * word-boundary strictly greater than the current head.
114
+ * - Ctrl+Left: jump to the start of the current word, OR to the start of
115
+ * the previous word if already at word-start. We land on the previous
116
+ * word-boundary strictly less than the current head.
117
+ */
118
+ export function moveWordRight(
119
+ doc: DocumentRootNode,
120
+ selection: CursorSelection,
121
+ options: CursorMoveOptions = {},
122
+ ): CursorSelection {
123
+ const text = documentPlainText(doc);
124
+ const currentHead = options.extend
125
+ ? selection.head
126
+ : Math.max(selection.anchor, selection.head);
127
+ const boundaries = wordBoundaries(text);
128
+ const nextBoundary =
129
+ boundaries.find((pos) => pos > currentHead) ?? text.length;
130
+ return finalize(selection, nextBoundary, options);
131
+ }
132
+
133
+ export function moveWordLeft(
134
+ doc: DocumentRootNode,
135
+ selection: CursorSelection,
136
+ options: CursorMoveOptions = {},
137
+ ): CursorSelection {
138
+ const text = documentPlainText(doc);
139
+ const currentHead = options.extend
140
+ ? selection.head
141
+ : Math.min(selection.anchor, selection.head);
142
+ const boundaries = wordBoundaries(text);
143
+ let prevBoundary = 0;
144
+ for (const pos of boundaries) {
145
+ if (pos >= currentHead) break;
146
+ prevBoundary = pos;
147
+ }
148
+ return finalize(selection, prevBoundary, options);
149
+ }
150
+
151
+ function finalize(
152
+ selection: CursorSelection,
153
+ head: number,
154
+ options: CursorMoveOptions,
155
+ ): CursorSelection {
156
+ if (options.extend) {
157
+ return { anchor: selection.anchor, head };
158
+ }
159
+ return { anchor: head, head };
160
+ }
161
+
162
+ function documentPlainText(doc: DocumentRootNode): string {
163
+ const story = parseTextStory(doc);
164
+ return createPlainText(story);
165
+ }
166
+
167
+ /**
168
+ * `Intl.Segmenter` is available in Node 16+ and all modern browsers. We lazy-
169
+ * init the default-locale instance; word segmentation is stateless, so one
170
+ * segmenter per process is fine.
171
+ */
172
+ let cachedSegmenter: Intl.Segmenter | undefined;
173
+ function wordSegmenter(): Intl.Segmenter {
174
+ if (!cachedSegmenter) {
175
+ cachedSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
176
+ }
177
+ return cachedSegmenter;
178
+ }
179
+
180
+ /**
181
+ * Produce the sorted list of word-boundary positions in `text`. A boundary is
182
+ * the start of any segment whose `isWordLike` is true, plus the end-of-text.
183
+ * Whitespace and punctuation segments are skipped so caret jumps land on
184
+ * word endpoints rather than stopping at every space — matching Word's
185
+ * Ctrl+arrow behavior.
186
+ */
187
+ function wordBoundaries(text: string): number[] {
188
+ const segments = wordSegmenter().segment(text);
189
+ const boundaries: number[] = [];
190
+ for (const segment of segments) {
191
+ if (segment.isWordLike) {
192
+ // Add both the start and the end of each word-like segment so
193
+ // Ctrl+Right from "hello|world" (between two words) advances to the end
194
+ // of "world" rather than stopping at the start.
195
+ boundaries.push(segment.index);
196
+ boundaries.push(segment.index + segment.segment.length);
197
+ }
198
+ }
199
+ // Dedup + sort so callers can binary-search or linear-walk deterministically.
200
+ boundaries.push(text.length);
201
+ return Array.from(new Set(boundaries)).sort((a, b) => a - b);
202
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * R.1 SelectionLayer — named module for cursor movement, validation, and
3
+ * anchor projection. See `docs/plans/lane-1-editing-foundation.md` §R.1.
4
+ *
5
+ * This file is the layer's single entry point. Callers consume
6
+ * `SelectionLayer.move(doc, sel, op)` / `SelectionLayer.validate(doc, sel)`
7
+ * rather than reaching into individual helpers. Phase 6a ships the six
8
+ * layout-independent primitives (char/word/paragraph × left/right). Phase 6b
9
+ * adds `moveUp` / `moveDown` / `moveLineStart` / `moveLineEnd` once Lane 3a
10
+ * P9's layout facet exposes per-run column info.
11
+ */
12
+
13
+ import type { DocumentRootNode } from "../../model/canonical-document.ts";
14
+ import type { CanonicalDocumentEnvelope, SelectionSnapshot } from "../../core/state/editor-state.ts";
15
+ import {
16
+ moveCharLeft,
17
+ moveCharRight,
18
+ moveParagraphEnd,
19
+ moveParagraphStart,
20
+ moveWordLeft,
21
+ moveWordRight,
22
+ type CursorMoveOptions,
23
+ type CursorSelection,
24
+ } from "./cursor-ops.ts";
25
+ import { validateSelectionAgainstDocument } from "./post-edit-validator.ts";
26
+
27
+ export type {
28
+ CursorMoveOptions,
29
+ CursorSelection,
30
+ } from "./cursor-ops.ts";
31
+
32
+ /**
33
+ * Cursor-move operations supported by the layer. Phase 6b will extend this
34
+ * with `"up" | "down" | "line-start" | "line-end"`.
35
+ */
36
+ export type CursorMoveOp =
37
+ | "char-left"
38
+ | "char-right"
39
+ | "word-left"
40
+ | "word-right"
41
+ | "paragraph-start"
42
+ | "paragraph-end";
43
+
44
+ export interface SelectionLayer {
45
+ /**
46
+ * Apply a cursor-move operation and return the resulting selection. Pure;
47
+ * does not mutate any argument.
48
+ */
49
+ move(
50
+ doc: DocumentRootNode,
51
+ selection: CursorSelection,
52
+ op: CursorMoveOp,
53
+ options?: CursorMoveOptions,
54
+ ): CursorSelection;
55
+
56
+ /**
57
+ * Run the I5 post-edit selection validator. Snaps orphaned offsets into the
58
+ * nearest valid position. Pure; returns the (possibly adjusted) selection.
59
+ */
60
+ validate(
61
+ doc: CanonicalDocumentEnvelope,
62
+ selection: SelectionSnapshot,
63
+ maxOffset: number,
64
+ ): SelectionSnapshot;
65
+ }
66
+
67
+ /**
68
+ * Default SelectionLayer instance. Stateless — the same instance is safe to
69
+ * share across runtimes.
70
+ */
71
+ export const selectionLayer: SelectionLayer = {
72
+ move(doc, selection, op, options) {
73
+ switch (op) {
74
+ case "char-left":
75
+ return moveCharLeft(doc, selection, options);
76
+ case "char-right":
77
+ return moveCharRight(doc, selection, options);
78
+ case "word-left":
79
+ return moveWordLeft(doc, selection, options);
80
+ case "word-right":
81
+ return moveWordRight(doc, selection, options);
82
+ case "paragraph-start":
83
+ return moveParagraphStart(doc, selection, options);
84
+ case "paragraph-end":
85
+ return moveParagraphEnd(doc, selection, options);
86
+ }
87
+ },
88
+ validate(doc, selection, maxOffset) {
89
+ return validateSelectionAgainstDocument(doc, selection, maxOffset);
90
+ },
91
+ };
@@ -52,6 +52,7 @@ import {
52
52
  resolveNumberingMarkerRunFormatting,
53
53
  } from "./paragraph-style-resolver.ts";
54
54
  import { resolveHyperlinkRunFormatting } from "./hyperlink-color-resolver.ts";
55
+ import { concretizeThemeColors } from "./theme-color-resolver.ts";
55
56
  import type { CanonicalParagraphFormatting, CanonicalRunFormatting } from "../model/canonical-document.ts";
56
57
 
57
58
  interface ParagraphAccumulator {
@@ -874,6 +875,11 @@ function appendInlineSegments(
874
875
  characterStyleId: undefined,
875
876
  direct: directRunFormatting,
876
877
  };
878
+ // L2.c — non-hyperlink body text also gets theme-color concretization
879
+ // so paragraph styles declaring `<w:color w:themeColor="accent1"/>`
880
+ // render with their theme slot's hex instead of falling back to
881
+ // default black. Hyperlink branch already resolves theme via the
882
+ // Hyperlink-style cascade; only the non-hyperlink path was missing.
877
883
  const resolvedRunFormatting = cullBuild
878
884
  ? {}
879
885
  : hyperlinkHref
@@ -882,7 +888,10 @@ function appendInlineSegments(
882
888
  document.styles,
883
889
  document.subParts?.resolvedTheme,
884
890
  )
885
- : resolveEffectiveRunFormatting(runResolveInput, document.styles);
891
+ : concretizeThemeColors(
892
+ resolveEffectiveRunFormatting(runResolveInput, document.styles),
893
+ document.subParts?.resolvedTheme,
894
+ );
886
895
  paragraph.segments.push({
887
896
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
888
897
  kind: "text",
@@ -57,6 +57,52 @@ export function resolveThemeColorHex(
57
57
  return applyThemeTintShade(baseHex, rPr.colorThemeTint, rPr.colorThemeShade);
58
58
  }
59
59
 
60
+ /**
61
+ * Post-process a cascade of run formatting to concretize any
62
+ * theme-color-only reference into a paintable `colorHex`.
63
+ *
64
+ * L2.c body-text call site: `surface-projection.ts` runs the standard
65
+ * `resolveEffectiveRunFormatting` cascade, then pipes the result through
66
+ * this function with the document's resolved theme. Runs that declared
67
+ * `<w:color w:themeColor="accent1"/>` without a concrete `w:val` end up
68
+ * with `colorHex` set to the theme-resolved hex so downstream
69
+ * `pm-state-from-snapshot` can paint them.
70
+ *
71
+ * The theme-slot fields (`colorThemeSlot` / `Tint` / `Shade`) stay on
72
+ * the cascade so that the export path can re-emit the original theme
73
+ * reference — this function never overwrites them, it only adds
74
+ * `colorHex` when it was absent or `"auto"`.
75
+ *
76
+ * No-ops when:
77
+ * - no theme-slot is declared (nothing to resolve),
78
+ * - `colorHex` is already a concrete non-`"auto"` hex (direct wins),
79
+ * - theme is missing or the slot is unknown (graceful fallback),
80
+ * - resolver returns `undefined` (e.g. malformed tint/shade).
81
+ */
82
+ export function concretizeThemeColors(
83
+ cascade: CanonicalRunFormatting,
84
+ theme: ResolvedTheme | undefined,
85
+ ): CanonicalRunFormatting {
86
+ if (!cascade.colorThemeSlot) return cascade;
87
+ if (cascade.colorHex && cascade.colorHex !== "auto") return cascade;
88
+ // When colorHex is "auto" we intentionally bypass it during theme
89
+ // resolution: Word's cascade treats `<w:color w:val="auto"
90
+ // w:themeColor="accent1"/>` as "paint with the theme slot; auto is
91
+ // the fallback if theme is missing." Pass a stripped input to the
92
+ // resolver so `colorHex === "auto"` doesn't short-circuit.
93
+ const resolved = resolveThemeColorHex(
94
+ {
95
+ colorThemeSlot: cascade.colorThemeSlot,
96
+ colorThemeTint: cascade.colorThemeTint,
97
+ colorThemeShade: cascade.colorThemeShade,
98
+ },
99
+ theme,
100
+ );
101
+ if (!resolved || resolved === "auto") return cascade;
102
+ if (resolved === cascade.colorHex) return cascade;
103
+ return { ...cascade, colorHex: resolved };
104
+ }
105
+
60
106
  /**
61
107
  * Apply `w:themeTint` (shift toward white) and/or `w:themeShade` (shift
62
108
  * toward black) to a base hex colour. Pure function.