@beyondwork/docx-react-component 1.0.48 → 1.0.50

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 (53) hide show
  1. package/README.md +19 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +103 -12
  4. package/src/core/commands/index.ts +30 -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-comments.ts +50 -5
  12. package/src/io/export/serialize-main-document.ts +9 -0
  13. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  14. package/src/io/export/serialize-run-formatting.ts +10 -1
  15. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  16. package/src/io/ooxml/chart/color-palette.ts +101 -0
  17. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  18. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  19. package/src/io/ooxml/chart/parse-series.ts +76 -11
  20. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  21. package/src/io/ooxml/chart/types.ts +30 -11
  22. package/src/io/ooxml/parse-complex-content.ts +6 -3
  23. package/src/io/ooxml/parse-main-document.ts +41 -0
  24. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  25. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  26. package/src/io/ooxml/property-grab-bag.ts +211 -0
  27. package/src/io/paste/word-clipboard.ts +114 -0
  28. package/src/model/canonical-document.ts +69 -3
  29. package/src/runtime/collab/index.ts +7 -0
  30. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  31. package/src/runtime/collab/workflow-shared.ts +247 -0
  32. package/src/runtime/document-locations.ts +1 -9
  33. package/src/runtime/document-outline.ts +1 -9
  34. package/src/runtime/document-runtime.ts +98 -50
  35. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  36. package/src/runtime/layout/layout-engine-version.ts +11 -1
  37. package/src/runtime/layout/public-facet.ts +5 -12
  38. package/src/runtime/render/render-frame-types.ts +14 -0
  39. package/src/runtime/render/render-kernel.ts +40 -2
  40. package/src/runtime/structure-ops/fragment-insert.ts +134 -0
  41. package/src/runtime/surface-projection.ts +94 -36
  42. package/src/runtime/theme-color-resolver.ts +188 -0
  43. package/src/runtime/workflow-markup.ts +7 -18
  44. package/src/ui/WordReviewEditor.tsx +22 -4
  45. package/src/ui/editor-runtime-boundary.ts +37 -0
  46. package/src/ui/headless/selection-helpers.ts +10 -23
  47. package/src/ui/unsupported-previews-policy.ts +23 -0
  48. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  49. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  50. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
  51. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  52. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  53. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -22,6 +22,7 @@ import {
22
22
  } from "./event-types.ts";
23
23
  import { computeBaseDocFingerprint } from "./base-doc-fingerprint.ts";
24
24
  import type { Checkpoint } from "./checkpoint-store.ts";
25
+ import { createWorkflowShared, type WorkflowSharedHandle } from "./workflow-shared.ts";
25
26
 
26
27
  /** Shared Y.Map key — {@link SHARED_META_MAP_KEY}. */
27
28
  const SHARED_META_MAP_KEY = "meta";
@@ -188,6 +189,17 @@ export interface RuntimeCollabSyncOptions {
188
189
  runtime: DocumentRuntime;
189
190
  authorId: string;
190
191
  commandAppliedBridge: RuntimeCommandAppliedBridge;
192
+ /**
193
+ * Role of the local peer for workflow-shared gating. Defaults to `"author"`
194
+ * for backward compatibility with pre-P13 callers — ⚠ OMITTING THIS FIELD
195
+ * GRANTS AUTHOR-LEVEL WRITE ACCESS to the shared `workflow` Y.Map. Hosts
196
+ * that derive role from Awareness or an external auth layer should pass
197
+ * it explicitly to avoid silently promoting reviewers/observers to authors.
198
+ * - `"author"`: all workflow writes allowed.
199
+ * - `"reviewer"`: only `setAssignedReviewers` allowed; other writes refused.
200
+ * - `"observer"`: all writes refused with `collab_observer_readonly`.
201
+ */
202
+ role?: "author" | "reviewer" | "observer";
191
203
  }
192
204
 
193
205
  export interface RuntimeCollabSyncHandle {
@@ -217,6 +229,14 @@ export interface RuntimeCollabSyncHandle {
217
229
  * checkpoint covers previously-seen events).
218
230
  */
219
231
  getAppliedEventCount(): number;
232
+ /**
233
+ * Returns the shared workflow handle backing the `workflow` Y.Map.
234
+ * Hosts can call `.setLockedMode()`, `.setRoundDeadline()`,
235
+ * `.setAssignedReviewers()`, and `.setWorkItemId()` to propagate
236
+ * state changes to other peers. Role gating (passed in `options.role`)
237
+ * enforces write permissions per §7 of the lane plan.
238
+ */
239
+ getWorkflowShared(): WorkflowSharedHandle;
220
240
  }
221
241
 
222
242
  export function createRuntimeCommandAppliedBridge(): RuntimeCommandAppliedBridge {
@@ -332,6 +352,31 @@ export function createRuntimeCollabSync(
332
352
  emit({ type: "collab_sync_attached", baseDocFingerprint });
333
353
  }
334
354
 
355
+ // ---------------------------------------------------------------------------
356
+ // Workflow shared state — P13 Slice C
357
+ // ---------------------------------------------------------------------------
358
+ // Construct a WorkflowSharedHandle over `ydoc.getMap("workflow")`. The
359
+ // handle subscribes to Y.Map changes and propagates them to the runtime
360
+ // via `setSharedWorkflowState`. Role gating is enforced by the handle
361
+ // itself; the `role` option defaults to `"author"` for historical compat.
362
+ const effectiveRole = options.role ?? "author";
363
+ const workflowShared = createWorkflowShared({
364
+ ydoc,
365
+ role: effectiveRole,
366
+ localAuthorId: authorId,
367
+ });
368
+
369
+ // Seed initial state synchronously. For a fresh Y.Doc this is `{}`.
370
+ // For a late joiner it may already be populated — the seed ensures the
371
+ // runtime reflects pre-existing shared state at attach time (i.e. if a
372
+ // peer already set `lockedMode`, the new peer starts out locked).
373
+ runtime.setSharedWorkflowState(workflowShared.get());
374
+
375
+ const workflowUnsub = workflowShared.subscribe((state) => {
376
+ if (readOnly) return; // don't propagate while in read-only (post-mismatch)
377
+ runtime.setSharedWorkflowState(state);
378
+ });
379
+
335
380
  const unsubscribeCommandApplied = commandAppliedBridge.subscribe((command, _transaction, context, meta) => {
336
381
  if (readOnly) {
337
382
  return;
@@ -453,6 +498,9 @@ export function createRuntimeCollabSync(
453
498
  yEvents.unobserve(onYEventsChange);
454
499
  yMeta.unobserve(checkFingerprintAgainstMeta);
455
500
  yCheckpoints.unobserve(onCheckpointsChange);
501
+ workflowUnsub();
502
+ workflowShared.destroy();
503
+ runtime.setSharedWorkflowState(null);
456
504
  listeners.clear();
457
505
  },
458
506
  subscribe(listener) {
@@ -481,6 +529,9 @@ export function createRuntimeCollabSync(
481
529
  getAppliedEventCount() {
482
530
  return appliedEventIds.size;
483
531
  },
532
+ getWorkflowShared() {
533
+ return workflowShared;
534
+ },
484
535
  };
485
536
 
486
537
  function onYEventsChange(event: Y.YArrayEvent<CommandEvent>): void {
@@ -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,8 @@ import type {
30
30
  DocumentTextToken,
31
31
  EditorSessionState,
32
32
  EditorAnchorProjection,
33
+ CanonicalDocumentFragment,
34
+ TextFormattingDirective,
33
35
  EditorError,
34
36
  EditorStoryTarget,
35
37
  EditorViewStateSnapshot,
@@ -104,7 +106,14 @@ import {
104
106
  storyTargetsEqual,
105
107
  type EditorAnchorProjection as InternalEditorAnchorProjection,
106
108
  } from "../core/selection/mapping.ts";
107
- import { canCreateDocxCommentAnchor } from "../core/selection/review-anchors.ts";
109
+ import {
110
+ toInternalAnchorProjection,
111
+ toPublicAnchorProjection,
112
+ } from "../core/selection/anchor-conversion.ts";
113
+ import {
114
+ commentAnchorRejectionReason,
115
+ snapCommentAnchorAwayFromTable,
116
+ } from "../core/selection/review-anchors.ts";
108
117
  import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
109
118
  import {
110
119
  describeOpaqueFragment,
@@ -232,6 +241,7 @@ import type {
232
241
  } from "../api/editor-state-types.ts";
233
242
  import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
234
243
  import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
244
+ import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
235
245
 
236
246
  /** Internal extension of ExportDocxOptions that threads the collected
237
247
  * editorState payload from the runtime to the docx serializer. */
@@ -257,7 +267,8 @@ export type ActiveStoryTextCommand =
257
267
  | Extract<EditorCommand, { type: "text.insert-tab" }>
258
268
  | Extract<EditorCommand, { type: "text.outdent-tab" }>
259
269
  | Extract<EditorCommand, { type: "text.insert-hard-break" }>
260
- | Extract<EditorCommand, { type: "paragraph.split" }>;
270
+ | Extract<EditorCommand, { type: "paragraph.split" }>
271
+ | Extract<EditorCommand, { type: "fragment.insert" }>;
261
272
 
262
273
  export interface DocumentRuntime {
263
274
  subscribe(listener: () => void): Unsubscribe;
@@ -265,7 +276,8 @@ export interface DocumentRuntime {
265
276
  getRenderSnapshot(): RuntimeRenderSnapshot;
266
277
  getCanonicalDocument(): CanonicalDocumentEnvelope;
267
278
  getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
268
- replaceText(text: string, target?: EditorAnchorProjection): void;
279
+ replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
280
+ insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
269
281
  applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
270
282
  dispatch(command: EditorCommand): void;
271
283
  /**
@@ -359,6 +371,7 @@ export interface DocumentRuntime {
359
371
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
360
372
  clearWorkflowOverlay(): void;
361
373
  getWorkflowOverlay(): WorkflowOverlay | null;
374
+ setSharedWorkflowState(state: SharedWorkflowState | null): void;
362
375
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
363
376
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
364
377
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
@@ -629,6 +642,9 @@ export function createDocumentRuntime(
629
642
  ?? options.initialSnapshot?.workflowMetadata?.entries
630
643
  ?? [];
631
644
  let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
645
+ // P13 Slice B: shared workflow state from the collab Y.Map "workflow".
646
+ // Set via setSharedWorkflowState(); observed by evaluateWorkflowBlockedReasons.
647
+ let sharedWorkflowState: SharedWorkflowState | null = null;
632
648
  const initialPersistedSnapshot = options.initialSessionState
633
649
  ? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
634
650
  savedAt: options.initialSessionState.updatedAt,
@@ -873,6 +889,7 @@ export function createDocumentRuntime(
873
889
  documentMode: DocumentMode;
874
890
  protectionSnapshot: ProtectionSnapshot;
875
891
  workflowOverlay: WorkflowOverlay | null;
892
+ sharedWorkflowState: SharedWorkflowState | null;
876
893
  snapshot: InteractionGuardSnapshot;
877
894
  }
878
895
  | undefined;
@@ -1140,6 +1157,29 @@ export function createDocumentRuntime(
1140
1157
  commandType?: string,
1141
1158
  ): WorkflowBlockedCommandReason[] {
1142
1159
  const reasons: WorkflowBlockedCommandReason[] = [];
1160
+ // P13 Slice B: shared lockedMode overrides all other scope checks when
1161
+ // non-editing. Short-circuit: no other scope reason applies when the round
1162
+ // is locked (the round state supersedes scope/overlay-level gating).
1163
+ // Emit a reason code whose effectiveMode mapping matches the mode intent:
1164
+ // "commenting" → workflow_comment_only (maps to effectiveMode: "comment")
1165
+ // "viewing" → workflow_view_only (maps to effectiveMode: "view")
1166
+ // "suggesting" → workflow_round_locked (no existing mapping; stays "blocked"
1167
+ // for this slice — full suggesting-mode semantics will be a
1168
+ // future slice that hooks getEffectiveDocumentMode instead).
1169
+ if (sharedWorkflowState?.lockedMode && sharedWorkflowState.lockedMode !== "editing") {
1170
+ const lockedMode = sharedWorkflowState.lockedMode;
1171
+ const code: WorkflowBlockedCommandReason["code"] =
1172
+ lockedMode === "commenting"
1173
+ ? "workflow_comment_only"
1174
+ : lockedMode === "viewing"
1175
+ ? "workflow_view_only"
1176
+ : "workflow_round_locked";
1177
+ reasons.push({
1178
+ code,
1179
+ message: `Round is locked to ${lockedMode} mode.`,
1180
+ });
1181
+ return reasons;
1182
+ }
1143
1183
  const selectionBounds = {
1144
1184
  from: Math.min(selection.anchor, selection.head),
1145
1185
  to: Math.max(selection.anchor, selection.head),
@@ -1552,7 +1592,8 @@ export function createDocumentRuntime(
1552
1592
  cachedInteractionGuardSnapshot.readOnly === state.readOnly &&
1553
1593
  cachedInteractionGuardSnapshot.documentMode === viewState.documentMode &&
1554
1594
  cachedInteractionGuardSnapshot.protectionSnapshot === protectionSnapshot &&
1555
- cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay
1595
+ cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay &&
1596
+ cachedInteractionGuardSnapshot.sharedWorkflowState === sharedWorkflowState
1556
1597
  ) {
1557
1598
  return cachedInteractionGuardSnapshot.snapshot;
1558
1599
  }
@@ -1613,6 +1654,7 @@ export function createDocumentRuntime(
1613
1654
  documentMode: viewState.documentMode,
1614
1655
  protectionSnapshot,
1615
1656
  workflowOverlay,
1657
+ sharedWorkflowState,
1616
1658
  snapshot,
1617
1659
  };
1618
1660
  return snapshot;
@@ -2256,13 +2298,14 @@ export function createDocumentRuntime(
2256
2298
  getDefaultAuthorId() {
2257
2299
  return defaultAuthorId;
2258
2300
  },
2259
- replaceText(text, target) {
2301
+ replaceText(text, target, formatting) {
2260
2302
  try {
2261
2303
  const timestamp = clock();
2262
2304
  applyTextCommandInActiveStory(
2263
2305
  {
2264
2306
  type: "text.insert",
2265
2307
  text,
2308
+ ...(formatting ? { formatting } : {}),
2266
2309
  origin: createOrigin("api", timestamp),
2267
2310
  },
2268
2311
  {
@@ -2274,6 +2317,26 @@ export function createDocumentRuntime(
2274
2317
  emitError(toRuntimeError(error));
2275
2318
  }
2276
2319
  },
2320
+ insertFragment(fragment, target) {
2321
+ // I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
2322
+ // runtime command handler routes into `applyFragmentInsert` (structure-ops).
2323
+ try {
2324
+ const timestamp = clock();
2325
+ applyTextCommandInActiveStory(
2326
+ {
2327
+ type: "fragment.insert",
2328
+ fragment,
2329
+ origin: createOrigin("api", timestamp),
2330
+ },
2331
+ {
2332
+ selection: target ? createSelectionFromPublicAnchor(target) : state.selection,
2333
+ blockedCommandName: "insertFragment",
2334
+ },
2335
+ );
2336
+ } catch (error) {
2337
+ emitError(toRuntimeError(error));
2338
+ }
2339
+ },
2277
2340
  applyActiveStoryTextCommand(command) {
2278
2341
  try {
2279
2342
  return applyTextCommandInActiveStory(command);
@@ -2309,22 +2372,39 @@ export function createDocumentRuntime(
2309
2372
  throw toStructuredRuntimeException(error);
2310
2373
  }
2311
2374
  const commentId = createEntityId("comment", state.document.review.comments, clock());
2312
- const anchor = params.anchor
2375
+ let anchor = params.anchor
2313
2376
  ? toInternalAnchorProjection(params.anchor)
2314
2377
  : state.selection.activeRange;
2315
- const selection = params.anchor
2378
+ let selection = params.anchor
2316
2379
  ? createSelectionFromPublicAnchor(params.anchor)
2317
2380
  : state.selection;
2318
- if (!canCreateDocxCommentAnchor(cachedRenderSnapshot.surface, anchor)) {
2381
+ if (params.snapToSafeBoundary === true) {
2382
+ const snapped = snapCommentAnchorAwayFromTable(
2383
+ cachedRenderSnapshot.surface,
2384
+ anchor,
2385
+ );
2386
+ if (snapped !== null && snapped !== anchor) {
2387
+ anchor = snapped;
2388
+ selection = createSelectionFromPublicAnchor(toPublicAnchorProjection(snapped));
2389
+ }
2390
+ }
2391
+ const rejectionReason = commentAnchorRejectionReason(
2392
+ cachedRenderSnapshot.surface,
2393
+ anchor,
2394
+ );
2395
+ if (rejectionReason !== null) {
2396
+ const message =
2397
+ rejectionReason === "comment_anchor_table_adjacent"
2398
+ ? "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."
2399
+ : "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.";
2319
2400
  const error: InternalEditorError = {
2320
2401
  errorId: createSessionId("comment-anchor", clock()),
2321
2402
  code: "validation_failed",
2322
2403
  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.",
2404
+ message,
2325
2405
  source: "runtime",
2326
2406
  details: {
2327
- reason: "invalid_comment_anchor",
2407
+ reason: rejectionReason,
2328
2408
  },
2329
2409
  };
2330
2410
  emitError(error);
@@ -2954,6 +3034,13 @@ export function createDocumentRuntime(
2954
3034
  getWorkflowOverlay() {
2955
3035
  return workflowOverlay;
2956
3036
  },
3037
+ setSharedWorkflowState(state) {
3038
+ if (state === sharedWorkflowState) return;
3039
+ sharedWorkflowState = state;
3040
+ // Invalidate guard/scope caches so next derivation reflects the new state.
3041
+ cachedInteractionGuardSnapshot = undefined;
3042
+ cachedWorkflowScopeSnapshot = undefined;
3043
+ },
2957
3044
  getWorkflowScopeSnapshot() {
2958
3045
  return getCachedWorkflowScopeSnapshot();
2959
3046
  },
@@ -4152,45 +4239,6 @@ function toPublicSelectionSnapshot(
4152
4239
  };
4153
4240
  }
4154
4241
 
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
4242
  function createSelectionFromPublicAnchor(
4195
4243
  anchor: EditorAnchorProjection,
4196
4244
  ): import("../core/state/editor-state.ts").SelectionSnapshot {