@beyondwork/docx-react-component 1.0.48 → 1.0.49

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 (45) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +84 -12
  4. package/src/core/commands/index.ts +9 -1
  5. package/src/core/commands/text-commands.ts +3 -1
  6. package/src/core/selection/anchor-conversion.ts +112 -0
  7. package/src/core/selection/review-anchors.ts +108 -3
  8. package/src/core/state/text-transaction.ts +86 -2
  9. package/src/internal/harness-debug-ports.ts +168 -0
  10. package/src/io/chart-preview-resolver.ts +32 -1
  11. package/src/io/export/serialize-main-document.ts +9 -0
  12. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  13. package/src/io/export/serialize-run-formatting.ts +10 -1
  14. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  15. package/src/io/ooxml/chart/color-palette.ts +101 -0
  16. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  17. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  18. package/src/io/ooxml/chart/parse-series.ts +76 -11
  19. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  20. package/src/io/ooxml/chart/types.ts +30 -11
  21. package/src/io/ooxml/parse-complex-content.ts +6 -3
  22. package/src/io/ooxml/parse-main-document.ts +41 -0
  23. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  24. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  25. package/src/io/ooxml/property-grab-bag.ts +211 -0
  26. package/src/model/canonical-document.ts +69 -3
  27. package/src/runtime/collab/index.ts +7 -0
  28. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  29. package/src/runtime/collab/workflow-shared.ts +247 -0
  30. package/src/runtime/document-locations.ts +1 -9
  31. package/src/runtime/document-outline.ts +1 -9
  32. package/src/runtime/document-runtime.ts +74 -49
  33. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  34. package/src/runtime/surface-projection.ts +94 -36
  35. package/src/runtime/theme-color-resolver.ts +188 -0
  36. package/src/runtime/workflow-markup.ts +7 -18
  37. package/src/ui/WordReviewEditor.tsx +18 -2
  38. package/src/ui/editor-runtime-boundary.ts +36 -0
  39. package/src/ui/headless/selection-helpers.ts +10 -23
  40. package/src/ui/unsupported-previews-policy.ts +23 -0
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  42. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  43. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  45. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -0,0 +1,247 @@
1
+ import * as Y from "yjs";
2
+
3
+ import type { CollabBlockReason } from "../../api/comment-negotiation-types.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Public types
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export interface SharedWorkflowState {
10
+ lockedMode?: "editing" | "suggesting" | "commenting" | "viewing";
11
+ roundDeadline?: string; // ISO-8601
12
+ assignedReviewers?: string[]; // userIds
13
+ workItemId?: string;
14
+ }
15
+
16
+ export interface CreateWorkflowSharedOptions {
17
+ ydoc: Y.Doc;
18
+ role: "author" | "reviewer" | "observer";
19
+ /**
20
+ * Reserved for future audit use — emit `{ actor: localAuthorId }` on
21
+ * writes once the audit path lands. Currently unused at runtime;
22
+ * optional so callers without an author-id scheme don't have to
23
+ * fabricate one.
24
+ */
25
+ localAuthorId?: string;
26
+ }
27
+
28
+ export type WorkflowSharedResult =
29
+ | { ok: true }
30
+ | { ok: false; reason: CollabBlockReason };
31
+
32
+ export interface WorkflowSharedHandle {
33
+ get(): SharedWorkflowState;
34
+ setLockedMode(mode: SharedWorkflowState["lockedMode"]): WorkflowSharedResult;
35
+ setRoundDeadline(deadline: string | undefined): WorkflowSharedResult;
36
+ setAssignedReviewers(reviewers: string[]): WorkflowSharedResult;
37
+ setWorkItemId(id: string | undefined): WorkflowSharedResult;
38
+ subscribe(listener: (state: SharedWorkflowState) => void): () => void;
39
+ destroy(): void;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Y.Map key constants
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const WORKFLOW_MAP_NAME = "workflow";
47
+ const KEY_LOCKED_MODE = "lockedMode";
48
+ const KEY_ROUND_DEADLINE = "roundDeadline";
49
+ const KEY_ASSIGNED_REVIEWERS = "assignedReviewers";
50
+ const KEY_WORK_ITEM_ID = "workItemId";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Shallow-equality helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function arrayShallowEqual(
57
+ a: string[] | undefined,
58
+ b: string[] | undefined,
59
+ ): boolean {
60
+ if (a === b) return true;
61
+ if (a == null || b == null) return false;
62
+ if (a.length !== b.length) return false;
63
+ for (let i = 0; i < a.length; i++) {
64
+ if (a[i] !== b[i]) return false;
65
+ }
66
+ return true;
67
+ }
68
+
69
+ function stateShallowEqual(
70
+ a: SharedWorkflowState,
71
+ b: SharedWorkflowState,
72
+ ): boolean {
73
+ return (
74
+ a.lockedMode === b.lockedMode &&
75
+ a.roundDeadline === b.roundDeadline &&
76
+ a.workItemId === b.workItemId &&
77
+ arrayShallowEqual(a.assignedReviewers, b.assignedReviewers)
78
+ );
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Factory
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Creates a handle over `ydoc.getMap<unknown>("workflow")` that propagates
87
+ * shared workflow state — `lockedMode`, `roundDeadline`, `assignedReviewers`,
88
+ * and `workItemId` — across collab peers via per-key Yjs LWW conflict
89
+ * resolution.
90
+ *
91
+ * Role-gating (§7 lane plan):
92
+ * - observer: all writes refused with `collab_observer_readonly`.
93
+ * - reviewer: only `setAssignedReviewers` allowed; other writes refused with
94
+ * `collab_role_restricted`.
95
+ * - author: all writes allowed.
96
+ */
97
+ export function createWorkflowShared(
98
+ options: CreateWorkflowSharedOptions,
99
+ ): WorkflowSharedHandle {
100
+ const { ydoc, role } = options;
101
+ // Reserved for future audit path — see CreateWorkflowSharedOptions.localAuthorId.
102
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
103
+ const _localAuthorId = options.localAuthorId;
104
+
105
+ const yMap = ydoc.getMap<unknown>(WORKFLOW_MAP_NAME);
106
+
107
+ const listeners = new Set<(state: SharedWorkflowState) => void>();
108
+ let destroyed = false;
109
+ let lastEmitted: SharedWorkflowState | null = null;
110
+
111
+ // -------------------------------------------------------------------------
112
+ // Read helper
113
+ // -------------------------------------------------------------------------
114
+
115
+ function readState(): SharedWorkflowState {
116
+ const state: SharedWorkflowState = {};
117
+ const lockedMode = yMap.get(KEY_LOCKED_MODE);
118
+ if (lockedMode !== undefined) {
119
+ state.lockedMode = lockedMode as SharedWorkflowState["lockedMode"];
120
+ }
121
+ const roundDeadline = yMap.get(KEY_ROUND_DEADLINE);
122
+ if (roundDeadline !== undefined) {
123
+ state.roundDeadline = roundDeadline as string;
124
+ }
125
+ const assignedReviewers = yMap.get(KEY_ASSIGNED_REVIEWERS);
126
+ if (assignedReviewers !== undefined) {
127
+ state.assignedReviewers = [...(assignedReviewers as string[])];
128
+ }
129
+ const workItemId = yMap.get(KEY_WORK_ITEM_ID);
130
+ if (workItemId !== undefined) {
131
+ state.workItemId = workItemId as string;
132
+ }
133
+ return state;
134
+ }
135
+
136
+ // -------------------------------------------------------------------------
137
+ // Observer — dedup-fires listeners on any Y.Map change
138
+ // -------------------------------------------------------------------------
139
+
140
+ function onMapChange(): void {
141
+ if (destroyed) return;
142
+ if (listeners.size === 0) {
143
+ lastEmitted = null;
144
+ return;
145
+ }
146
+ const next = readState();
147
+ if (lastEmitted !== null && stateShallowEqual(lastEmitted, next)) {
148
+ return; // deduplicated — same state, skip fire
149
+ }
150
+ lastEmitted = next;
151
+ for (const listener of [...listeners]) {
152
+ try {
153
+ listener(next);
154
+ } catch {
155
+ // Listener exceptions are isolated; the handle continues.
156
+ }
157
+ }
158
+ }
159
+
160
+ yMap.observe(onMapChange);
161
+
162
+ // -------------------------------------------------------------------------
163
+ // Role-gating helpers
164
+ // -------------------------------------------------------------------------
165
+
166
+ function checkObserver(): WorkflowSharedResult | null {
167
+ if (role === "observer") {
168
+ return { ok: false, reason: "collab_observer_readonly" };
169
+ }
170
+ return null;
171
+ }
172
+
173
+ function checkReviewerRestricted(): WorkflowSharedResult | null {
174
+ if (role === "reviewer") {
175
+ return { ok: false, reason: "collab_role_restricted" };
176
+ }
177
+ return null;
178
+ }
179
+
180
+ // -------------------------------------------------------------------------
181
+ // Handle
182
+ // -------------------------------------------------------------------------
183
+
184
+ return {
185
+ get(): SharedWorkflowState {
186
+ return readState();
187
+ },
188
+
189
+ setLockedMode(mode): WorkflowSharedResult {
190
+ const denied = checkObserver() ?? checkReviewerRestricted();
191
+ if (denied) return denied;
192
+ if (mode === undefined) {
193
+ yMap.delete(KEY_LOCKED_MODE);
194
+ } else {
195
+ yMap.set(KEY_LOCKED_MODE, mode);
196
+ }
197
+ return { ok: true };
198
+ },
199
+
200
+ setRoundDeadline(deadline): WorkflowSharedResult {
201
+ const denied = checkObserver() ?? checkReviewerRestricted();
202
+ if (denied) return denied;
203
+ if (deadline === undefined) {
204
+ yMap.delete(KEY_ROUND_DEADLINE);
205
+ } else {
206
+ yMap.set(KEY_ROUND_DEADLINE, deadline);
207
+ }
208
+ return { ok: true };
209
+ },
210
+
211
+ setAssignedReviewers(reviewers): WorkflowSharedResult {
212
+ const denied = checkObserver();
213
+ if (denied) return denied;
214
+ // reviewer is allowed here — do NOT call checkReviewerRestricted().
215
+ yMap.set(KEY_ASSIGNED_REVIEWERS, [...reviewers]);
216
+ return { ok: true };
217
+ },
218
+
219
+ setWorkItemId(id): WorkflowSharedResult {
220
+ const denied = checkObserver() ?? checkReviewerRestricted();
221
+ if (denied) return denied;
222
+ if (id === undefined) {
223
+ yMap.delete(KEY_WORK_ITEM_ID);
224
+ } else {
225
+ yMap.set(KEY_WORK_ITEM_ID, id);
226
+ }
227
+ return { ok: true };
228
+ },
229
+
230
+ subscribe(listener): () => void {
231
+ listeners.add(listener);
232
+ // Reset dedup baseline so the next change always fires fresh.
233
+ lastEmitted = null;
234
+ return () => {
235
+ listeners.delete(listener);
236
+ };
237
+ },
238
+
239
+ destroy(): void {
240
+ if (destroyed) return;
241
+ destroyed = true;
242
+ yMap.unobserve(onMapChange);
243
+ listeners.clear();
244
+ lastEmitted = null;
245
+ },
246
+ };
247
+ }
@@ -18,6 +18,7 @@ import type {
18
18
  } from "../api/public-types";
19
19
  import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
20
20
  import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
21
+ import { createPublicRangeAnchor } from "../core/selection/anchor-conversion.ts";
21
22
  import {
22
23
  createDocumentSectionSnapshots,
23
24
  findBookmarkNameForOffset,
@@ -47,15 +48,6 @@ function createLocationId(anchor: EditorAnchorProjection, storyTarget?: EditorSt
47
48
  }
48
49
  }
49
50
 
50
- function createPublicRangeAnchor(from: number, to: number): EditorAnchorProjection {
51
- return {
52
- kind: "range",
53
- from,
54
- to,
55
- assoc: { start: -1, end: 1 },
56
- };
57
- }
58
-
59
51
  function resolveOffsetMetadata(
60
52
  navigation: DocumentNavigationSnapshot,
61
53
  offset: number,
@@ -18,6 +18,7 @@ import type {
18
18
  import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
19
19
  import { createSelectionSnapshot } from "../core/state/editor-state.ts";
20
20
  import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
21
+ import { createPublicRangeAnchor } from "../core/selection/anchor-conversion.ts";
21
22
  import { parseTocLevelRange } from "../io/ooxml/parse-fields.ts";
22
23
  import { buildPageLayoutSnapshot, buildResolvedSections } from "./document-layout.ts";
23
24
  import { createDocumentNavigationSnapshot } from "./document-navigation.ts";
@@ -34,15 +35,6 @@ function getAnchorOffset(anchor: EditorAnchorProjection): number | undefined {
34
35
  }
35
36
  }
36
37
 
37
- function createPublicRangeAnchor(from: number, to: number): EditorAnchorProjection {
38
- return {
39
- kind: "range",
40
- from,
41
- to,
42
- assoc: { start: -1, end: 1 },
43
- };
44
- }
45
-
46
38
  export function resolveHeadingPath(
47
39
  headings: readonly DocumentHeadingSnapshot[],
48
40
  offset: number | undefined,
@@ -30,6 +30,7 @@ import type {
30
30
  DocumentTextToken,
31
31
  EditorSessionState,
32
32
  EditorAnchorProjection,
33
+ TextFormattingDirective,
33
34
  EditorError,
34
35
  EditorStoryTarget,
35
36
  EditorViewStateSnapshot,
@@ -104,7 +105,14 @@ import {
104
105
  storyTargetsEqual,
105
106
  type EditorAnchorProjection as InternalEditorAnchorProjection,
106
107
  } from "../core/selection/mapping.ts";
107
- import { canCreateDocxCommentAnchor } from "../core/selection/review-anchors.ts";
108
+ import {
109
+ toInternalAnchorProjection,
110
+ toPublicAnchorProjection,
111
+ } from "../core/selection/anchor-conversion.ts";
112
+ import {
113
+ commentAnchorRejectionReason,
114
+ snapCommentAnchorAwayFromTable,
115
+ } from "../core/selection/review-anchors.ts";
108
116
  import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
109
117
  import {
110
118
  describeOpaqueFragment,
@@ -232,6 +240,7 @@ import type {
232
240
  } from "../api/editor-state-types.ts";
233
241
  import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
234
242
  import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
243
+ import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
235
244
 
236
245
  /** Internal extension of ExportDocxOptions that threads the collected
237
246
  * editorState payload from the runtime to the docx serializer. */
@@ -265,7 +274,7 @@ export interface DocumentRuntime {
265
274
  getRenderSnapshot(): RuntimeRenderSnapshot;
266
275
  getCanonicalDocument(): CanonicalDocumentEnvelope;
267
276
  getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
268
- replaceText(text: string, target?: EditorAnchorProjection): void;
277
+ replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
269
278
  applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
270
279
  dispatch(command: EditorCommand): void;
271
280
  /**
@@ -359,6 +368,7 @@ export interface DocumentRuntime {
359
368
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
360
369
  clearWorkflowOverlay(): void;
361
370
  getWorkflowOverlay(): WorkflowOverlay | null;
371
+ setSharedWorkflowState(state: SharedWorkflowState | null): void;
362
372
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
363
373
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
364
374
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
@@ -629,6 +639,9 @@ export function createDocumentRuntime(
629
639
  ?? options.initialSnapshot?.workflowMetadata?.entries
630
640
  ?? [];
631
641
  let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
642
+ // P13 Slice B: shared workflow state from the collab Y.Map "workflow".
643
+ // Set via setSharedWorkflowState(); observed by evaluateWorkflowBlockedReasons.
644
+ let sharedWorkflowState: SharedWorkflowState | null = null;
632
645
  const initialPersistedSnapshot = options.initialSessionState
633
646
  ? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
634
647
  savedAt: options.initialSessionState.updatedAt,
@@ -873,6 +886,7 @@ export function createDocumentRuntime(
873
886
  documentMode: DocumentMode;
874
887
  protectionSnapshot: ProtectionSnapshot;
875
888
  workflowOverlay: WorkflowOverlay | null;
889
+ sharedWorkflowState: SharedWorkflowState | null;
876
890
  snapshot: InteractionGuardSnapshot;
877
891
  }
878
892
  | undefined;
@@ -1140,6 +1154,29 @@ export function createDocumentRuntime(
1140
1154
  commandType?: string,
1141
1155
  ): WorkflowBlockedCommandReason[] {
1142
1156
  const reasons: WorkflowBlockedCommandReason[] = [];
1157
+ // P13 Slice B: shared lockedMode overrides all other scope checks when
1158
+ // non-editing. Short-circuit: no other scope reason applies when the round
1159
+ // is locked (the round state supersedes scope/overlay-level gating).
1160
+ // Emit a reason code whose effectiveMode mapping matches the mode intent:
1161
+ // "commenting" → workflow_comment_only (maps to effectiveMode: "comment")
1162
+ // "viewing" → workflow_view_only (maps to effectiveMode: "view")
1163
+ // "suggesting" → workflow_round_locked (no existing mapping; stays "blocked"
1164
+ // for this slice — full suggesting-mode semantics will be a
1165
+ // future slice that hooks getEffectiveDocumentMode instead).
1166
+ if (sharedWorkflowState?.lockedMode && sharedWorkflowState.lockedMode !== "editing") {
1167
+ const lockedMode = sharedWorkflowState.lockedMode;
1168
+ const code: WorkflowBlockedCommandReason["code"] =
1169
+ lockedMode === "commenting"
1170
+ ? "workflow_comment_only"
1171
+ : lockedMode === "viewing"
1172
+ ? "workflow_view_only"
1173
+ : "workflow_round_locked";
1174
+ reasons.push({
1175
+ code,
1176
+ message: `Round is locked to ${lockedMode} mode.`,
1177
+ });
1178
+ return reasons;
1179
+ }
1143
1180
  const selectionBounds = {
1144
1181
  from: Math.min(selection.anchor, selection.head),
1145
1182
  to: Math.max(selection.anchor, selection.head),
@@ -1552,7 +1589,8 @@ export function createDocumentRuntime(
1552
1589
  cachedInteractionGuardSnapshot.readOnly === state.readOnly &&
1553
1590
  cachedInteractionGuardSnapshot.documentMode === viewState.documentMode &&
1554
1591
  cachedInteractionGuardSnapshot.protectionSnapshot === protectionSnapshot &&
1555
- cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay
1592
+ cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay &&
1593
+ cachedInteractionGuardSnapshot.sharedWorkflowState === sharedWorkflowState
1556
1594
  ) {
1557
1595
  return cachedInteractionGuardSnapshot.snapshot;
1558
1596
  }
@@ -1613,6 +1651,7 @@ export function createDocumentRuntime(
1613
1651
  documentMode: viewState.documentMode,
1614
1652
  protectionSnapshot,
1615
1653
  workflowOverlay,
1654
+ sharedWorkflowState,
1616
1655
  snapshot,
1617
1656
  };
1618
1657
  return snapshot;
@@ -2256,13 +2295,14 @@ export function createDocumentRuntime(
2256
2295
  getDefaultAuthorId() {
2257
2296
  return defaultAuthorId;
2258
2297
  },
2259
- replaceText(text, target) {
2298
+ replaceText(text, target, formatting) {
2260
2299
  try {
2261
2300
  const timestamp = clock();
2262
2301
  applyTextCommandInActiveStory(
2263
2302
  {
2264
2303
  type: "text.insert",
2265
2304
  text,
2305
+ ...(formatting ? { formatting } : {}),
2266
2306
  origin: createOrigin("api", timestamp),
2267
2307
  },
2268
2308
  {
@@ -2309,22 +2349,39 @@ export function createDocumentRuntime(
2309
2349
  throw toStructuredRuntimeException(error);
2310
2350
  }
2311
2351
  const commentId = createEntityId("comment", state.document.review.comments, clock());
2312
- const anchor = params.anchor
2352
+ let anchor = params.anchor
2313
2353
  ? toInternalAnchorProjection(params.anchor)
2314
2354
  : state.selection.activeRange;
2315
- const selection = params.anchor
2355
+ let selection = params.anchor
2316
2356
  ? createSelectionFromPublicAnchor(params.anchor)
2317
2357
  : state.selection;
2318
- if (!canCreateDocxCommentAnchor(cachedRenderSnapshot.surface, anchor)) {
2358
+ if (params.snapToSafeBoundary === true) {
2359
+ const snapped = snapCommentAnchorAwayFromTable(
2360
+ cachedRenderSnapshot.surface,
2361
+ anchor,
2362
+ );
2363
+ if (snapped !== null && snapped !== anchor) {
2364
+ anchor = snapped;
2365
+ selection = createSelectionFromPublicAnchor(toPublicAnchorProjection(snapped));
2366
+ }
2367
+ }
2368
+ const rejectionReason = commentAnchorRejectionReason(
2369
+ cachedRenderSnapshot.surface,
2370
+ anchor,
2371
+ );
2372
+ if (rejectionReason !== null) {
2373
+ const message =
2374
+ rejectionReason === "comment_anchor_table_adjacent"
2375
+ ? "DOCX comments cannot currently anchor mid-run within a paragraph adjacent to a table boundary — snap the range to a paragraph or word boundary and retry."
2376
+ : "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.";
2319
2377
  const error: InternalEditorError = {
2320
2378
  errorId: createSessionId("comment-anchor", clock()),
2321
2379
  code: "validation_failed",
2322
2380
  isFatal: false,
2323
- message:
2324
- "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.",
2381
+ message,
2325
2382
  source: "runtime",
2326
2383
  details: {
2327
- reason: "invalid_comment_anchor",
2384
+ reason: rejectionReason,
2328
2385
  },
2329
2386
  };
2330
2387
  emitError(error);
@@ -2954,6 +3011,13 @@ export function createDocumentRuntime(
2954
3011
  getWorkflowOverlay() {
2955
3012
  return workflowOverlay;
2956
3013
  },
3014
+ setSharedWorkflowState(state) {
3015
+ if (state === sharedWorkflowState) return;
3016
+ sharedWorkflowState = state;
3017
+ // Invalidate guard/scope caches so next derivation reflects the new state.
3018
+ cachedInteractionGuardSnapshot = undefined;
3019
+ cachedWorkflowScopeSnapshot = undefined;
3020
+ },
2957
3021
  getWorkflowScopeSnapshot() {
2958
3022
  return getCachedWorkflowScopeSnapshot();
2959
3023
  },
@@ -4152,45 +4216,6 @@ function toPublicSelectionSnapshot(
4152
4216
  };
4153
4217
  }
4154
4218
 
4155
- function toPublicAnchorProjection(
4156
- anchor: InternalEditorAnchorProjection,
4157
- ): EditorAnchorProjection {
4158
- switch (anchor.kind) {
4159
- case "range":
4160
- return {
4161
- kind: "range",
4162
- from: anchor.range.from,
4163
- to: anchor.range.to,
4164
- assoc: anchor.assoc,
4165
- };
4166
- case "node":
4167
- return {
4168
- kind: "node",
4169
- at: anchor.at,
4170
- assoc: anchor.assoc,
4171
- };
4172
- case "detached":
4173
- return {
4174
- kind: "detached",
4175
- lastKnownRange: anchor.lastKnownRange,
4176
- reason: anchor.reason,
4177
- };
4178
- }
4179
- }
4180
-
4181
- function toInternalAnchorProjection(
4182
- anchor: EditorAnchorProjection,
4183
- ): InternalEditorAnchorProjection {
4184
- switch (anchor.kind) {
4185
- case "range":
4186
- return createRangeAnchor(anchor.from, anchor.to, anchor.assoc);
4187
- case "node":
4188
- return createNodeAnchor(anchor.at, anchor.assoc);
4189
- case "detached":
4190
- return createDetachedAnchor(anchor.lastKnownRange, anchor.reason);
4191
- }
4192
- }
4193
-
4194
4219
  function createSelectionFromPublicAnchor(
4195
4220
  anchor: EditorAnchorProjection,
4196
4221
  ): import("../core/state/editor-state.ts").SelectionSnapshot {
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Lane 3 V7 — hyperlink color cascade.
3
+ *
4
+ * OOXML convention: runs inside `<w:hyperlink>` inherit the `Hyperlink`
5
+ * character style implicitly, EVEN WHEN source XML does not declare an
6
+ * explicit `<w:rStyle w:val="Hyperlink"/>` on each run. Word applies the
7
+ * style based on the containing hyperlink element's context alone.
8
+ *
9
+ * Our existing cascade (`resolveEffectiveRunFormatting`) only applies the
10
+ * character-style chain when `input.characterStyleId` is populated — so
11
+ * runs inside hyperlinks that lacked explicit rStyle were inheriting
12
+ * whatever the paragraph style said (usually black body text).
13
+ *
14
+ * This module closes that gap by resolving hyperlink color via a
15
+ * four-tier fallback chain:
16
+ *
17
+ * 1. Direct color on the run (`colorHex !== "auto"`) — wins outright.
18
+ * 2. Character-style cascade — forces Hyperlink style participation.
19
+ * 3. Theme hlink slot (`ResolvedTheme.colors.hlink`).
20
+ * 4. Hardcoded Word default `#0563C1`.
21
+ *
22
+ * The resolver also honors `colorThemeSlot` + `colorThemeTint`/`colorThemeShade`
23
+ * from L2.c by delegating to `resolveThemeColorHex`.
24
+ *
25
+ * Contract: the returned `CanonicalRunFormatting` is the effective cascade
26
+ * result with `colorHex` concretized to a non-theme hex (or `"auto"`). The
27
+ * original `colorThemeSlot` / `colorThemeTint` / `colorThemeShade` fields
28
+ * are preserved on the returned object so downstream code (or re-export
29
+ * via the canonical document) still sees the theme reference.
30
+ */
31
+
32
+ import type {
33
+ CanonicalRunFormatting,
34
+ ResolvedTheme,
35
+ StylesCatalog,
36
+ } from "../model/canonical-document.ts";
37
+ import { resolveThemeColor } from "../io/ooxml/parse-theme.ts";
38
+ import { resolveThemeColorHex } from "./theme-color-resolver.ts";
39
+ import {
40
+ resolveEffectiveRunFormatting,
41
+ type RunResolveInput,
42
+ } from "./paragraph-style-resolver.ts";
43
+
44
+ export const HYPERLINK_CHARACTER_STYLE_ID = "Hyperlink";
45
+
46
+ /**
47
+ * Microsoft Word's default hyperlink color (applied when neither the
48
+ * Hyperlink character style nor the theme's `hlink` slot supplies one).
49
+ * Matches Word 2013+ fresh-document rendering.
50
+ */
51
+ export const DEFAULT_HYPERLINK_COLOR_HEX = "0563C1";
52
+
53
+ /**
54
+ * Resolve effective run formatting for a hyperlink-inner run. Honors the
55
+ * Hyperlink character style implicitly, resolves any theme-slot color
56
+ * references, and applies the Word-default fallback when upstream data
57
+ * is absent.
58
+ *
59
+ * `input.characterStyleId` is respected when the caller already passed
60
+ * one (e.g., source XML had an explicit `<w:rStyle>` overriding the
61
+ * implicit Hyperlink). Only when it is absent does the resolver inject
62
+ * `"Hyperlink"` itself.
63
+ */
64
+ export function resolveHyperlinkRunFormatting(
65
+ input: RunResolveInput,
66
+ catalog: StylesCatalog | undefined,
67
+ theme: ResolvedTheme | undefined,
68
+ ): CanonicalRunFormatting {
69
+ // V7a — auto-apply the Hyperlink character style when the caller did
70
+ // not supply one (runs inside <w:hyperlink> typically lack explicit
71
+ // rStyle; Word applies the style by context).
72
+ const augmentedInput: RunResolveInput =
73
+ input.characterStyleId === undefined
74
+ ? { ...input, characterStyleId: HYPERLINK_CHARACTER_STYLE_ID }
75
+ : input;
76
+
77
+ const cascade = resolveEffectiveRunFormatting(augmentedInput, catalog);
78
+
79
+ // V7b — concretize the color through the theme resolver + Word default.
80
+ const resolvedColor = resolveHyperlinkColorHex(cascade, theme);
81
+ if (resolvedColor && resolvedColor !== cascade.colorHex) {
82
+ return { ...cascade, colorHex: resolvedColor };
83
+ }
84
+ return cascade;
85
+ }
86
+
87
+ /**
88
+ * Four-tier hyperlink color fallback. Exported for targeted testing; use
89
+ * `resolveHyperlinkRunFormatting` as the primary entry point.
90
+ */
91
+ export function resolveHyperlinkColorHex(
92
+ cascade: Pick<
93
+ CanonicalRunFormatting,
94
+ "colorHex" | "colorThemeSlot" | "colorThemeTint" | "colorThemeShade"
95
+ >,
96
+ theme: ResolvedTheme | undefined,
97
+ ): string | undefined {
98
+ // Tier 1 — direct non-auto hex wins.
99
+ if (cascade.colorHex && cascade.colorHex !== "auto") {
100
+ return cascade.colorHex;
101
+ }
102
+ // Tier 2 — theme-slot reference from the cascade (which now includes the
103
+ // Hyperlink style's rPr — typically `<w:color w:themeColor="hlink"/>`).
104
+ if (cascade.colorThemeSlot) {
105
+ const viaTheme = resolveThemeColorHex(cascade, theme);
106
+ if (viaTheme && viaTheme !== "auto") {
107
+ return viaTheme;
108
+ }
109
+ }
110
+ // Tier 3 — theme hlink slot even when the cascade never wrote a slot
111
+ // reference. This catches docs whose Hyperlink style lacks a color
112
+ // declaration entirely but whose theme defines hlink.
113
+ const themeHlink = resolveThemeColor(theme, "hlink");
114
+ if (themeHlink) {
115
+ return themeHlink;
116
+ }
117
+ // Tier 4 — Word's hardcoded default.
118
+ return DEFAULT_HYPERLINK_COLOR_HEX;
119
+ }