@beyondwork/docx-react-component 1.0.34 → 1.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.34",
4
+ "version": "1.0.36",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
- "packageManager": "pnpm@10.30.3",
7
6
  "type": "module",
8
7
  "sideEffects": [
9
8
  "**/*.css"
@@ -89,31 +88,6 @@
89
88
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css"
90
89
  },
91
90
  "types": "./src/index.ts",
92
- "scripts": {
93
- "build": "tsup",
94
- "test": "bash scripts/run-workspace-tests.sh",
95
- "test:repo": "node scripts/run-repo-tests.mjs core",
96
- "test:repo:all": "node scripts/run-repo-tests.mjs all",
97
- "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
98
- "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
99
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
100
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
101
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
102
- "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
103
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
104
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
105
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
106
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
107
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
108
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
109
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
110
- "wave:launch:managed": "bash scripts/wave-launch.sh",
111
- "wave:status": "bash scripts/wave-status.sh",
112
- "wave:watch": "bash scripts/wave-watch.sh --follow",
113
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
114
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
115
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
116
- },
117
91
  "keywords": [
118
92
  "docx",
119
93
  "word",
@@ -162,9 +136,15 @@
162
136
  "y-protocols": "^1.0.0"
163
137
  },
164
138
  "peerDependenciesMeta": {
165
- "yjs": { "optional": true },
166
- "y-prosemirror": { "optional": true },
167
- "y-protocols": { "optional": true }
139
+ "yjs": {
140
+ "optional": true
141
+ },
142
+ "y-prosemirror": {
143
+ "optional": true
144
+ },
145
+ "y-protocols": {
146
+ "optional": true
147
+ }
168
148
  },
169
149
  "devDependencies": {
170
150
  "@chllming/wave-orchestration": "^0.9.15",
@@ -187,14 +167,29 @@
187
167
  "y-protocols": "^1.0.7",
188
168
  "yjs": "^13.6.30"
189
169
  },
190
- "pnpm": {
191
- "onlyBuiltDependencies": [
192
- "esbuild",
193
- "sharp"
194
- ],
195
- "overrides": {
196
- "react": "19.2.4",
197
- "react-dom": "19.2.4"
198
- }
170
+ "scripts": {
171
+ "build": "tsup",
172
+ "test": "bash scripts/run-workspace-tests.sh",
173
+ "test:repo": "node scripts/run-repo-tests.mjs core",
174
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
175
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
176
+ "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
177
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
178
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
179
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
180
+ "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
181
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
182
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
183
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
184
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
185
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
186
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
187
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
188
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
189
+ "wave:status": "bash scripts/wave-status.sh",
190
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
191
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
192
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
193
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
199
194
  }
200
- }
195
+ }
@@ -920,7 +920,7 @@ export type ViewMode = "editing" | "review" | "view";
920
920
  * Distinct from `ViewMode` (editor posture) and `WorkspaceMode` (shell layout).
921
921
  * `DocumentMode` reflects document-level editing authority, not presentation.
922
922
  */
923
- export type DocumentMode = "editing" | "suggesting" | "viewing";
923
+ export type DocumentMode = "editing" | "suggesting" | "viewing" | "commenting";
924
924
 
925
925
  /**
926
926
  * A single permission range parsed from `w:permStart` / `w:permEnd` in the
@@ -1145,6 +1145,7 @@ export interface WorkflowCandidateRange {
1145
1145
 
1146
1146
  export interface WorkflowScope {
1147
1147
  scopeId: string;
1148
+ version?: number;
1148
1149
  mode: WorkflowScopeMode;
1149
1150
  anchor: EditorAnchorProjection;
1150
1151
  storyTarget?: EditorStoryTarget;
@@ -1152,6 +1153,13 @@ export interface WorkflowScope {
1152
1153
  label?: string;
1153
1154
  domain?: "legal" | "commercial" | "finance" | "other";
1154
1155
  metadataRefs?: string[];
1156
+ metadata?: WorkflowScopeMetadataField[];
1157
+ }
1158
+
1159
+ export interface WorkflowScopeMetadataField {
1160
+ key: string;
1161
+ valueType?: "string" | "number" | "boolean" | "json";
1162
+ value?: string | number | boolean | Record<string, unknown>;
1155
1163
  }
1156
1164
 
1157
1165
  export interface WorkflowWorkItem {
@@ -2,6 +2,10 @@ import type {
2
2
  EditorSessionState,
3
3
  PersistedEditorSnapshot,
4
4
  } from "./public-types.ts";
5
+ import {
6
+ assertPersistedEditorSnapshot,
7
+ validatePersistedEditorSnapshot,
8
+ } from "../model/snapshot.ts";
5
9
 
6
10
  export const EDITOR_SESSION_STATE_VERSION = "editor-session-state/1" as const;
7
11
 
@@ -62,3 +66,23 @@ export function persistedSnapshotFromEditorSessionState(
62
66
  workflowMetadata: sessionState.workflowMetadata,
63
67
  });
64
68
  }
69
+
70
+ export function validateEditorSessionState(
71
+ sessionState: EditorSessionState,
72
+ ): ReturnType<typeof validatePersistedEditorSnapshot> {
73
+ return validatePersistedEditorSnapshot(
74
+ persistedSnapshotFromEditorSessionState(sessionState, {
75
+ savedAt: sessionState.updatedAt,
76
+ }),
77
+ );
78
+ }
79
+
80
+ export function assertEditorSessionState(
81
+ sessionState: EditorSessionState,
82
+ ): void {
83
+ assertPersistedEditorSnapshot(
84
+ persistedSnapshotFromEditorSessionState(sessionState, {
85
+ savedAt: sessionState.updatedAt,
86
+ }),
87
+ );
88
+ }
@@ -195,7 +195,7 @@ export interface EditorTransaction {
195
195
 
196
196
  export interface CommandExecutionContext {
197
197
  timestamp: string;
198
- documentMode?: "editing" | "suggesting" | "viewing";
198
+ documentMode?: "editing" | "suggesting" | "viewing" | "commenting";
199
199
  defaultAuthorId?: string;
200
200
  }
201
201
 
@@ -1365,6 +1365,59 @@ function applySuggestingInsert(
1365
1365
  result.mapping,
1366
1366
  );
1367
1367
 
1368
+ // Extend an existing adjacent insertion revision if the cursor is right at its end.
1369
+ // After remapping, a prior insertion revision's `to` stays at `insertedFrom` (assoc.end = -1),
1370
+ // so consecutive keystrokes coalesce into one revision instead of one per character.
1371
+ const existingInsertion = findAdjacentInsertionRevision(
1372
+ reviewState.document.review.revisions,
1373
+ insertedFrom,
1374
+ authorId,
1375
+ );
1376
+
1377
+ if (existingInsertion && existingInsertion.anchor.kind === "range") {
1378
+ const updatedRevision: CanonicalRevisionRecord = {
1379
+ ...existingInsertion,
1380
+ anchor: {
1381
+ kind: "range",
1382
+ range: { from: existingInsertion.anchor.range.from, to: insertedTo },
1383
+ assoc: { start: 1, end: -1 },
1384
+ },
1385
+ };
1386
+
1387
+ const finalDocument: CanonicalDocumentEnvelope = {
1388
+ ...reviewState.document,
1389
+ review: {
1390
+ ...reviewState.document.review,
1391
+ revisions: {
1392
+ ...reviewState.document.review.revisions,
1393
+ [updatedRevision.changeId]: updatedRevision,
1394
+ },
1395
+ },
1396
+ };
1397
+
1398
+ return createTransaction(
1399
+ {
1400
+ ...state,
1401
+ document: finalDocument,
1402
+ selection: result.selection,
1403
+ warnings: reviewState.warnings,
1404
+ runtime: {
1405
+ ...state.runtime,
1406
+ activeCommentId: reviewState.activeCommentId,
1407
+ },
1408
+ },
1409
+ {
1410
+ historyBoundary: "push",
1411
+ markDirty: true,
1412
+ mapping: result.mapping,
1413
+ effects: {
1414
+ ...reviewState.effects,
1415
+ revisionAuthored: { changeId: updatedRevision.changeId, kind: "insertion" },
1416
+ },
1417
+ },
1418
+ );
1419
+ }
1420
+
1368
1421
  // Create the revision with pre-mapping positions — it refers to content
1369
1422
  // that was just inserted, so its anchors are already correct in the new document
1370
1423
  const revision = createAuthoredRevision(
@@ -1850,6 +1903,26 @@ function applySuggestingParagraphSplit(
1850
1903
  );
1851
1904
  }
1852
1905
 
1906
+ function findAdjacentInsertionRevision(
1907
+ revisions: Record<string, CanonicalRevisionRecord>,
1908
+ cursorPos: number,
1909
+ authorId: string,
1910
+ ): CanonicalRevisionRecord | undefined {
1911
+ for (const revision of Object.values(revisions)) {
1912
+ if (
1913
+ revision.kind === "insertion" &&
1914
+ revision.status === "open" &&
1915
+ revision.metadata?.source === "runtime" &&
1916
+ revision.anchor.kind === "range" &&
1917
+ revision.anchor.range.to === cursorPos &&
1918
+ revision.authorId === authorId
1919
+ ) {
1920
+ return revision;
1921
+ }
1922
+ }
1923
+ return undefined;
1924
+ }
1925
+
1853
1926
  function findOverlappingAuthoredDeletion(
1854
1927
  revisions: Record<string, CanonicalRevisionRecord>,
1855
1928
  from: number,
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { WordReviewEditor } from "./ui/WordReviewEditor.tsx";
2
2
  export {
3
3
  createEditorSessionState,
4
+ assertEditorSessionState,
4
5
  editorSessionStateFromPersistedSnapshot,
5
6
  persistedSnapshotFromEditorSessionState,
7
+ validateEditorSessionState,
6
8
  EDITOR_SESSION_STATE_VERSION,
7
9
  } from "./api/session-state.ts";
8
10
  export type {
@@ -9,6 +9,11 @@ import type {
9
9
  PersistedEditorSnapshot,
10
10
  ProtectionRange,
11
11
  ProtectionSnapshot,
12
+ WorkflowMetadataSnapshot,
13
+ WorkflowOverlay,
14
+ WorkflowScope,
15
+ WorkflowScopeMetadataField,
16
+ WorkflowWorkItem,
12
17
  } from "../api/public-types.ts";
13
18
  import { editorSessionStateFromPersistedSnapshot } from "../api/session-state.ts";
14
19
  import type {
@@ -655,6 +660,12 @@ export function loadDocxEditorSession(
655
660
  : undefined;
656
661
 
657
662
  const timestamp = new Date().toISOString();
663
+ const translatedWorkflowState = translateClmCommentsToWorkflow({
664
+ comments: normalizedComments.threads,
665
+ workflowOverlay: embeddedWorkflowOverlay,
666
+ workflowMetadata: embeddedWorkflowMetadata,
667
+ timestamp,
668
+ });
658
669
  const document = createImportedCanonicalDocument({
659
670
  documentId: options.documentId,
660
671
  timestamp,
@@ -703,7 +714,7 @@ export function loadDocxEditorSession(
703
714
  errors: [],
704
715
  },
705
716
  review: {
706
- comments: toRuntimeCommentRecords(normalizedComments.threads),
717
+ comments: toRuntimeCommentRecords(translatedWorkflowState.comments),
707
718
  revisions: toRuntimeRevisionRecords([
708
719
  ...normalizedRevisions.revisions,
709
720
  ...importedStoryRevisions,
@@ -722,8 +733,8 @@ export function loadDocxEditorSession(
722
733
  compatibility: toPublicCompatibilityReport(compatibility),
723
734
  protectionSnapshot: importedProtectionSnapshot,
724
735
  sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
725
- workflowOverlay: embeddedWorkflowOverlay,
726
- workflowMetadata: embeddedWorkflowMetadata,
736
+ workflowOverlay: translatedWorkflowState.workflowOverlay,
737
+ workflowMetadata: translatedWorkflowState.workflowMetadata,
727
738
  });
728
739
  const snapshotIssues = validatePersistedEditorSnapshot(snapshot);
729
740
  if (snapshotIssues.length > 0) {
@@ -1940,6 +1951,252 @@ function normalizeImportedCommentThreads(
1940
1951
  };
1941
1952
  }
1942
1953
 
1954
+ function translateClmCommentsToWorkflow(input: {
1955
+ comments: readonly CommentThread[];
1956
+ workflowOverlay?: WorkflowOverlay;
1957
+ workflowMetadata?: WorkflowMetadataSnapshot;
1958
+ timestamp: string;
1959
+ }): {
1960
+ comments: CommentThread[];
1961
+ workflowOverlay?: WorkflowOverlay;
1962
+ workflowMetadata?: WorkflowMetadataSnapshot;
1963
+ } {
1964
+ const nextComments = input.comments.map((thread) => structuredClone(thread));
1965
+ let nextOverlay = input.workflowOverlay ? structuredClone(input.workflowOverlay) : undefined;
1966
+ let overlayChanged = false;
1967
+
1968
+ for (const [index, thread] of nextComments.entries()) {
1969
+ const directive = parseClmCommentDirective(thread);
1970
+ if (!directive || thread.anchor.kind !== "range") {
1971
+ continue;
1972
+ }
1973
+
1974
+ if (!nextOverlay) {
1975
+ nextOverlay = {
1976
+ overlayVersion: "workflow-overlay/1",
1977
+ scopes: [],
1978
+ };
1979
+ }
1980
+
1981
+ const existingScope = findExistingClmScope(nextOverlay.scopes, directive);
1982
+ if (!existingScope) {
1983
+ const version = getNextClmScopeVersion(nextOverlay.scopes, thread.anchor);
1984
+ const scopeId = `clm-scope-${thread.commentId}-v${version}`;
1985
+ const workItem = directive.tag === "TASK"
1986
+ ? createClmWorkflowWorkItem(thread, directive.description, scopeId)
1987
+ : undefined;
1988
+ const scope = createClmWorkflowScope(thread, directive, version, scopeId, workItem?.workItemId);
1989
+
1990
+ nextOverlay.scopes = [...nextOverlay.scopes, scope];
1991
+ if (workItem) {
1992
+ nextOverlay.workItems = [...(nextOverlay.workItems ?? []), workItem];
1993
+ if (!nextOverlay.activeWorkItemId) {
1994
+ nextOverlay.activeWorkItemId = workItem.workItemId;
1995
+ }
1996
+ }
1997
+ overlayChanged = true;
1998
+ }
1999
+
2000
+ nextComments[index] = resolveImportedCommentThreadOnTranslation(thread, input.timestamp);
2001
+ }
2002
+
2003
+ return {
2004
+ comments: nextComments,
2005
+ workflowOverlay: overlayChanged || input.workflowOverlay ? nextOverlay : undefined,
2006
+ workflowMetadata: input.workflowMetadata,
2007
+ };
2008
+ }
2009
+
2010
+ function parseClmCommentDirective(
2011
+ thread: CommentThread,
2012
+ ): { tag: "TASK" | "READ" | "COMMENT" | "EDIT"; description: string; sourceCommentId: string; sourceCommentDurableId?: string; sourceCommentParaId?: string; mode: WorkflowScope["mode"] } | undefined {
2013
+ if (thread.metadata?.rootParaId === undefined && thread.entries[0]?.metadata?.parentParaId) {
2014
+ return undefined;
2015
+ }
2016
+ const firstMeaningfulLine = thread.entries
2017
+ .flatMap((entry) => entry.body.split(/\r?\n/u))
2018
+ .map((line) => line.trim())
2019
+ .find((line) => line.length > 0);
2020
+ if (!firstMeaningfulLine) {
2021
+ return undefined;
2022
+ }
2023
+ const match = /^CLM:([A-Z]+):(.*)$/u.exec(firstMeaningfulLine);
2024
+ if (!match) {
2025
+ return undefined;
2026
+ }
2027
+ const tag = match[1] as "TASK" | "READ" | "COMMENT" | "EDIT";
2028
+ const description = (match[2] ?? "").trim();
2029
+ const mode = getClmWorkflowScopeMode(tag);
2030
+ if (!mode || description.length === 0) {
2031
+ return undefined;
2032
+ }
2033
+ return {
2034
+ tag,
2035
+ description,
2036
+ sourceCommentId: thread.metadata?.rootOoxmlCommentId ?? thread.commentId,
2037
+ sourceCommentDurableId: thread.entries[0]?.metadata?.durableId,
2038
+ sourceCommentParaId: thread.metadata?.rootParaId ?? thread.entries[0]?.metadata?.paraId,
2039
+ mode,
2040
+ };
2041
+ }
2042
+
2043
+ function getClmWorkflowScopeMode(tag: string): WorkflowScope["mode"] | undefined {
2044
+ switch (tag) {
2045
+ case "TASK":
2046
+ return "suggest";
2047
+ case "READ":
2048
+ return "view";
2049
+ case "COMMENT":
2050
+ return "comment";
2051
+ case "EDIT":
2052
+ return "edit";
2053
+ default:
2054
+ return undefined;
2055
+ }
2056
+ }
2057
+
2058
+ function createClmWorkflowWorkItem(
2059
+ thread: CommentThread,
2060
+ description: string,
2061
+ scopeId: string,
2062
+ ): WorkflowWorkItem {
2063
+ return {
2064
+ workItemId: `clm-task-${thread.commentId}`,
2065
+ title: description,
2066
+ description,
2067
+ status: "pending",
2068
+ scopeIds: [scopeId],
2069
+ };
2070
+ }
2071
+
2072
+ function createClmWorkflowScope(
2073
+ thread: CommentThread,
2074
+ directive: NonNullable<ReturnType<typeof parseClmCommentDirective>>,
2075
+ version: number,
2076
+ scopeId: string,
2077
+ workItemId?: string,
2078
+ ): WorkflowScope {
2079
+ if (workItemId) {
2080
+ return {
2081
+ scopeId,
2082
+ version,
2083
+ mode: directive.mode,
2084
+ anchor: toPublicAnchorProjection(thread.anchor),
2085
+ storyTarget: { kind: "main" },
2086
+ workItemId,
2087
+ label: directive.description,
2088
+ metadata: createClmScopeMetadata(directive),
2089
+ };
2090
+ }
2091
+ return {
2092
+ scopeId,
2093
+ version,
2094
+ mode: directive.mode,
2095
+ anchor: toPublicAnchorProjection(thread.anchor),
2096
+ storyTarget: { kind: "main" },
2097
+ label: directive.description,
2098
+ metadata: createClmScopeMetadata(directive),
2099
+ };
2100
+ }
2101
+
2102
+ function createClmScopeMetadata(
2103
+ directive: NonNullable<ReturnType<typeof parseClmCommentDirective>>,
2104
+ ): WorkflowScopeMetadataField[] {
2105
+ return [
2106
+ {
2107
+ key: "workblock.clm.tag",
2108
+ valueType: "string",
2109
+ value: directive.tag,
2110
+ },
2111
+ {
2112
+ key: "workblock.clm.description",
2113
+ valueType: "string",
2114
+ value: directive.description,
2115
+ },
2116
+ {
2117
+ key: "workblock.sourceCommentId",
2118
+ valueType: "string",
2119
+ value: directive.sourceCommentId,
2120
+ },
2121
+ ...(directive.sourceCommentDurableId
2122
+ ? [{
2123
+ key: "workblock.sourceCommentDurableId",
2124
+ valueType: "string" as const,
2125
+ value: directive.sourceCommentDurableId,
2126
+ }]
2127
+ : []),
2128
+ ...(directive.sourceCommentParaId
2129
+ ? [{
2130
+ key: "workblock.sourceCommentParaId",
2131
+ valueType: "string" as const,
2132
+ value: directive.sourceCommentParaId,
2133
+ }]
2134
+ : []),
2135
+ ];
2136
+ }
2137
+
2138
+ function findExistingClmScope(
2139
+ scopes: readonly WorkflowScope[],
2140
+ directive: NonNullable<ReturnType<typeof parseClmCommentDirective>>,
2141
+ ): WorkflowScope | undefined {
2142
+ return scopes.find((scope) => {
2143
+ const sourceCommentId = getWorkflowScopeMetadataValue(scope.metadata, "workblock.sourceCommentId");
2144
+ const sourceCommentDurableId = getWorkflowScopeMetadataValue(
2145
+ scope.metadata,
2146
+ "workblock.sourceCommentDurableId",
2147
+ );
2148
+ return (
2149
+ sourceCommentId === directive.sourceCommentId ||
2150
+ (directive.sourceCommentDurableId !== undefined &&
2151
+ sourceCommentDurableId === directive.sourceCommentDurableId)
2152
+ );
2153
+ });
2154
+ }
2155
+
2156
+ function getWorkflowScopeMetadataValue(
2157
+ metadata: WorkflowScope["metadata"] | undefined,
2158
+ key: string,
2159
+ ): string | undefined {
2160
+ const field = metadata?.find((entry) => entry.key === key);
2161
+ return typeof field?.value === "string" ? field.value : undefined;
2162
+ }
2163
+
2164
+ function getNextClmScopeVersion(
2165
+ scopes: readonly WorkflowScope[],
2166
+ anchor: Extract<CommentThread["anchor"], { kind: "range" }>,
2167
+ ): number {
2168
+ const anchorRange = {
2169
+ from: anchor.range.from,
2170
+ to: anchor.range.to,
2171
+ };
2172
+ const overlappingVersions = scopes.flatMap((scope) => {
2173
+ if (scope.anchor.kind !== "range") {
2174
+ return [];
2175
+ }
2176
+ return rangesIntersect(scope.anchor, anchorRange) && typeof scope.version === "number"
2177
+ ? [scope.version]
2178
+ : [];
2179
+ });
2180
+ return overlappingVersions.length > 0 ? Math.max(...overlappingVersions) + 1 : 1;
2181
+ }
2182
+
2183
+ function resolveImportedCommentThreadOnTranslation(
2184
+ thread: CommentThread,
2185
+ timestamp: string,
2186
+ ): CommentThread {
2187
+ if (thread.status === "resolved") {
2188
+ return thread;
2189
+ }
2190
+ return {
2191
+ ...thread,
2192
+ status: thread.status === "detached" ? "detached" : "resolved",
2193
+ resolution: thread.resolution ?? {
2194
+ resolvedAt: timestamp,
2195
+ resolvedBy: thread.entries[thread.entries.length - 1]?.authorId ?? thread.createdBy,
2196
+ },
2197
+ };
2198
+ }
2199
+
1943
2200
  function resolveDefinitionRootCommentId(
1944
2201
  definition: ImportedCommentDefinition,
1945
2202
  definitions: readonly ImportedCommentDefinition[],
@@ -371,19 +371,46 @@ function buildWorkblockExtensionXml(workflowOverlay: WorkflowOverlay | undefined
371
371
  }
372
372
 
373
373
  function buildWorkflowScopeXml(scope: WorkflowScope): string {
374
+ const scopeMetadataXml = buildWorkflowScopeMetadataXml(scope.metadata);
374
375
  return [
375
376
  `<bw:scope`,
376
377
  ` id="${escapeXml(scope.scopeId)}"`,
378
+ typeof scope.version === "number" ? ` version="${String(scope.version)}"` : "",
377
379
  ` mode="${escapeXml(scope.mode)}"`,
378
380
  scope.workItemId ? ` workItemRef="${escapeXml(scope.workItemId)}"` : "",
379
381
  scope.label ? ` label="${escapeXml(scope.label)}"` : "",
380
382
  scope.domain ? ` domain="${escapeXml(scope.domain)}"` : "",
381
383
  `>`,
382
384
  indentLines(buildWorkflowAnchorXml(scope.anchor, scope.storyTarget), 2),
385
+ scopeMetadataXml ? indentLines(scopeMetadataXml, 2) : "",
383
386
  `</bw:scope>`,
384
387
  ].join("");
385
388
  }
386
389
 
390
+ function buildWorkflowScopeMetadataXml(scopeMetadata: WorkflowScope["metadata"]): string {
391
+ if (!scopeMetadata || scopeMetadata.length === 0) {
392
+ return "";
393
+ }
394
+ return [
395
+ `<bw:scopeMetadata>`,
396
+ indentLines(
397
+ scopeMetadata
398
+ .map((field) => {
399
+ const serialized = serializeWorkflowScopeMetadataValue(field.valueType, field.value);
400
+ return [
401
+ `<bw:field`,
402
+ ` key="${escapeXml(field.key)}"`,
403
+ serialized.valueType ? ` type="${escapeXml(serialized.valueType)}"` : "",
404
+ `>${escapeXml(serialized.text)}</bw:field>`,
405
+ ].join("");
406
+ })
407
+ .join("\n"),
408
+ 2,
409
+ ),
410
+ `</bw:scopeMetadata>`,
411
+ ].join("\n");
412
+ }
413
+
387
414
  function buildWorkflowAnchorXml(
388
415
  anchor: EditorAnchorProjection,
389
416
  storyTarget: WorkflowScope["storyTarget"],
@@ -563,15 +590,110 @@ function parseWorkflowScope(attributesSource: string, body: string): WorkflowSco
563
590
  : createDefaultRangeAnchor(0, 0);
564
591
  return {
565
592
  scopeId: attributes.id,
593
+ version: attributes.version !== undefined ? Number(attributes.version) : undefined,
566
594
  mode: attributes.mode as WorkflowScope["mode"],
567
595
  anchor,
568
596
  storyTarget: anchorMatch ? parseOverlayStoryTarget(parseAttributes(anchorMatch[1] ?? "")) : { kind: "main" },
569
597
  workItemId: attributes.workItemRef,
570
598
  label: attributes.label,
571
599
  domain: attributes.domain as WorkflowScope["domain"],
600
+ metadata: parseWorkflowScopeMetadata(body),
601
+ };
602
+ }
603
+
604
+ function parseWorkflowScopeMetadata(body: string): WorkflowScope["metadata"] | undefined {
605
+ const metadataMatch = body.match(/<bw:scopeMetadata\b[^>]*>([\s\S]*?)<\/bw:scopeMetadata>/u);
606
+ if (!metadataMatch) {
607
+ return undefined;
608
+ }
609
+ const fields = [...(metadataMatch[1] ?? "").matchAll(/<bw:field\b([^>]*)>([\s\S]*?)<\/bw:field>/gu)]
610
+ .map((match) => parseWorkflowScopeMetadataField(match[1] ?? "", match[2] ?? ""))
611
+ .filter((field): field is NonNullable<WorkflowScope["metadata"]>[number] => field !== null);
612
+ return fields.length > 0 ? fields : undefined;
613
+ }
614
+
615
+ function parseWorkflowScopeMetadataField(
616
+ attributesSource: string,
617
+ body: string,
618
+ ): NonNullable<WorkflowScope["metadata"]>[number] | null {
619
+ const attributes = parseAttributes(attributesSource);
620
+ if (!attributes.key) {
621
+ return null;
622
+ }
623
+ const valueType = attributes.type as NonNullable<WorkflowScope["metadata"]>[number]["valueType"] | undefined;
624
+ return {
625
+ key: attributes.key,
626
+ ...(valueType ? { valueType } : {}),
627
+ value: parseWorkflowScopeMetadataValue(valueType, body),
628
+ };
629
+ }
630
+
631
+ function serializeWorkflowScopeMetadataValue(
632
+ valueType: NonNullable<WorkflowScope["metadata"]>[number]["valueType"],
633
+ value: NonNullable<WorkflowScope["metadata"]>[number]["value"],
634
+ ): { valueType?: "string" | "number" | "boolean" | "json"; text: string } {
635
+ const resolvedValueType =
636
+ valueType ??
637
+ (typeof value === "number"
638
+ ? "number"
639
+ : typeof value === "boolean"
640
+ ? "boolean"
641
+ : typeof value === "string"
642
+ ? "string"
643
+ : value && typeof value === "object"
644
+ ? "json"
645
+ : undefined);
646
+
647
+ if (resolvedValueType === "json") {
648
+ return {
649
+ valueType: "json",
650
+ text: JSON.stringify(value ?? {}),
651
+ };
652
+ }
653
+ if (resolvedValueType === "number") {
654
+ return {
655
+ valueType: "number",
656
+ text: typeof value === "number" ? String(value) : "0",
657
+ };
658
+ }
659
+ if (resolvedValueType === "boolean") {
660
+ return {
661
+ valueType: "boolean",
662
+ text: String(value === true),
663
+ };
664
+ }
665
+ return {
666
+ valueType: resolvedValueType,
667
+ text: typeof value === "string" ? value : value === undefined ? "" : String(value),
572
668
  };
573
669
  }
574
670
 
671
+ function parseWorkflowScopeMetadataValue(
672
+ valueType: NonNullable<WorkflowScope["metadata"]>[number]["valueType"],
673
+ payloadText: string,
674
+ ): NonNullable<WorkflowScope["metadata"]>[number]["value"] {
675
+ const decodedText = decodeXmlEntities(payloadText);
676
+ if (valueType === "json") {
677
+ try {
678
+ const parsed = JSON.parse(decodedText) as unknown;
679
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
680
+ return parsed as Record<string, unknown>;
681
+ }
682
+ return { value: parsed };
683
+ } catch {
684
+ return { value: decodedText };
685
+ }
686
+ }
687
+ if (valueType === "number") {
688
+ const numericValue = Number(decodedText);
689
+ return Number.isFinite(numericValue) ? numericValue : 0;
690
+ }
691
+ if (valueType === "boolean") {
692
+ return decodedText === "true" || decodedText === "1";
693
+ }
694
+ return decodedText;
695
+ }
696
+
575
697
  function parseWorkflowWorkItem(attributesSource: string, body: string): WorkflowWorkItem | null {
576
698
  const attributes = parseAttributes(attributesSource);
577
699
  if (!attributes.id || !attributes.title) {
@@ -124,12 +124,20 @@ export interface PersistedWorkflowMetadataSnapshot {
124
124
 
125
125
  export interface PersistedWorkflowScope {
126
126
  scopeId: string;
127
+ version?: number;
127
128
  mode: "edit" | "suggest" | "comment" | "view";
128
129
  anchor: Record<string, unknown>;
129
130
  storyTarget?: Record<string, unknown>;
130
131
  workItemId?: string;
131
132
  label?: string;
132
133
  domain?: "legal" | "commercial" | "finance" | "other";
134
+ metadata?: PersistedWorkflowScopeMetadataField[];
135
+ }
136
+
137
+ export interface PersistedWorkflowScopeMetadataField {
138
+ key: string;
139
+ valueType?: "string" | "number" | "boolean" | "json";
140
+ value?: string | number | boolean | Record<string, unknown>;
133
141
  }
134
142
 
135
143
  export interface PersistedWorkflowWorkItem {
@@ -726,12 +734,62 @@ function validateWorkflowScope(
726
734
  issues.push({ path: `${path}.mode`, message: "mode must be edit, suggest, comment, or view." });
727
735
  }
728
736
  asPlainObject(record.anchor, `${path}.anchor`, issues);
737
+ if (record.version !== undefined && !Number.isInteger(record.version)) {
738
+ issues.push({ path: `${path}.version`, message: "version must be an integer." });
739
+ }
729
740
  if (record.workItemId !== undefined) {
730
741
  expectString(record.workItemId, `${path}.workItemId`, issues);
731
742
  }
732
743
  if (record.label !== undefined) {
733
744
  expectString(record.label, `${path}.label`, issues);
734
745
  }
746
+ if (record.metadata !== undefined) {
747
+ if (!Array.isArray(record.metadata)) {
748
+ issues.push({ path: `${path}.metadata`, message: "metadata must be an array." });
749
+ } else {
750
+ record.metadata.forEach((field, index) =>
751
+ validateWorkflowScopeMetadataField(field, `${path}.metadata[${index}]`, issues),
752
+ );
753
+ }
754
+ }
755
+ }
756
+
757
+ function validateWorkflowScopeMetadataField(
758
+ value: unknown,
759
+ path: string,
760
+ issues: ModelValidationIssue[],
761
+ ): void {
762
+ const record = asPlainObject(value, path, issues);
763
+ if (!record) {
764
+ return;
765
+ }
766
+ expectString(record.key, `${path}.key`, issues);
767
+ if (
768
+ record.valueType !== undefined &&
769
+ record.valueType !== "string" &&
770
+ record.valueType !== "number" &&
771
+ record.valueType !== "boolean" &&
772
+ record.valueType !== "json"
773
+ ) {
774
+ issues.push({
775
+ path: `${path}.valueType`,
776
+ message: "valueType must be string, number, boolean, or json.",
777
+ });
778
+ }
779
+ if (
780
+ record.value !== undefined &&
781
+ typeof record.value !== "string" &&
782
+ typeof record.value !== "number" &&
783
+ typeof record.value !== "boolean"
784
+ ) {
785
+ const nestedRecord = asPlainObject(record.value, `${path}.value`, issues);
786
+ if (!nestedRecord) {
787
+ issues.push({
788
+ path: `${path}.value`,
789
+ message: "value must be a string, number, boolean, or plain object.",
790
+ });
791
+ }
792
+ }
735
793
  }
736
794
 
737
795
  function validateWorkflowWorkItem(
@@ -738,7 +738,7 @@ export function createDocumentRuntime(
738
738
  });
739
739
  }
740
740
 
741
- if (viewState.documentMode === "viewing") {
741
+ if (viewState.documentMode === "viewing" || viewState.documentMode === "commenting") {
742
742
  reasons.push({
743
743
  code: "document_viewing_mode",
744
744
  message: "Document is in viewing mode.",
@@ -822,8 +822,8 @@ export function createDocumentRuntime(
822
822
  function getEffectiveDocumentMode(
823
823
  selection: EditorState["selection"],
824
824
  ): DocumentMode {
825
- if (viewState.documentMode === "viewing") {
826
- return "viewing";
825
+ if (viewState.documentMode === "viewing" || viewState.documentMode === "commenting") {
826
+ return viewState.documentMode;
827
827
  }
828
828
  const matchingScope = getMatchingWorkflowScope(selection);
829
829
  if (matchingScope?.mode === "suggest") {
@@ -1529,13 +1529,13 @@ export function createDocumentRuntime(
1529
1529
  }
1530
1530
 
1531
1531
  if (command.type === "history.undo") {
1532
- if (viewState.documentMode === "viewing") return;
1532
+ if (viewState.documentMode === "viewing" || viewState.documentMode === "commenting") return;
1533
1533
  applyHistory("undo");
1534
1534
  return;
1535
1535
  }
1536
1536
 
1537
1537
  if (command.type === "history.redo") {
1538
- if (viewState.documentMode === "viewing") return;
1538
+ if (viewState.documentMode === "viewing" || viewState.documentMode === "commenting") return;
1539
1539
  applyHistory("redo");
1540
1540
  return;
1541
1541
  }
@@ -3949,10 +3949,11 @@ function resolveSupportedFieldDisplay(
3949
3949
  if (!field.fieldFamily || !isSupportedFieldFamily(field.fieldFamily)) {
3950
3950
  return undefined;
3951
3951
  }
3952
+ if (field.fieldFamily === "TOC") {
3953
+ return undefined;
3954
+ }
3952
3955
  if (!field.fieldTarget) {
3953
- return field.fieldFamily === "TOC"
3954
- ? undefined
3955
- : { displayText: "", refreshStatus: "unresolvable" };
3956
+ return { displayText: "", refreshStatus: "unresolvable" };
3956
3957
  }
3957
3958
  if (field.fieldFamily === "REF") {
3958
3959
  const result = resolveRefFieldText(document, bookmarkMap, field.fieldTarget);
@@ -23,7 +23,7 @@ export interface SessionCapabilities {
23
23
 
24
24
  // ── Document mode ──
25
25
  /** Runtime document mode — editing authority, distinct from view/workspace mode. */
26
- documentMode: "editing" | "suggesting" | "viewing";
26
+ documentMode: "editing" | "suggesting" | "viewing" | "commenting";
27
27
 
28
28
  // ── Command capabilities ──
29
29
  canUndo: boolean;
@@ -101,12 +101,15 @@ export function deriveCapabilities(
101
101
  ? "read-only-diagnostics"
102
102
  : reviewMode;
103
103
 
104
- // Command capabilities — document mode "viewing" disables editing
105
- const canEdit = isReady && !isReadOnly && !hasFatalError && documentMode !== "viewing";
104
+ // Command capabilities — "viewing" and "commenting" modes both disable editing;
105
+ // "commenting" additionally keeps comment creation enabled.
106
+ const canEdit = isReady && !isReadOnly && !hasFatalError
107
+ && documentMode !== "viewing" && documentMode !== "commenting";
106
108
  const canUndo = snapshot.commandState.canUndo && canEdit;
107
109
  const canRedo = snapshot.commandState.canRedo && canEdit;
110
+ const canComment = isReady && !hasFatalError && documentMode !== "viewing";
108
111
  const canAddComment =
109
- canEdit &&
112
+ canComment &&
110
113
  activeStory.kind === "main" &&
111
114
  !snapshot.selection.isCollapsed &&
112
115
  Boolean(snapshot.surface) &&
@@ -1427,6 +1427,14 @@ function describePreservedInlinePreview(
1427
1427
  };
1428
1428
  }
1429
1429
 
1430
+ if (/\b(?:w:)?br\b[^>]*\b(?:w:)?type="page"/u.test(payloadReference)) {
1431
+ return {
1432
+ label: "Page break",
1433
+ detail: "Word page-break marker preserved for export safety.",
1434
+ presentation: "quiet-marker",
1435
+ };
1436
+ }
1437
+
1430
1438
  if (/\b(?:w:)?permStart\b/u.test(payloadReference)) {
1431
1439
  const editorGroup = /\bw:edGrp="([^"]+)"/u.exec(payloadReference)?.[1];
1432
1440
  return {
@@ -309,11 +309,11 @@ export function buildDecorations(
309
309
  // This is the critical behavior: "hide tracked changes" must show
310
310
  // the document as if accepted, not show deleted text as kept text.
311
311
  if (markupDisplay === "clean" && rev.kind === "deletion") {
312
- const pmFrom = positionMap.runtimeToPm(rev.from);
313
- const pmTo = positionMap.runtimeToPm(rev.to);
314
- if (pmFrom < pmTo) {
312
+ const cleanPmFrom = positionMap.runtimeToPm(rev.from);
313
+ const cleanPmTo = positionMap.runtimeToPm(rev.to);
314
+ if (cleanPmFrom < cleanPmTo) {
315
315
  decorations.push(
316
- Decoration.inline(pmFrom, pmTo, {
316
+ Decoration.inline(cleanPmFrom, cleanPmTo, {
317
317
  class: "hidden",
318
318
  "data-revision-id": rev.revisionId,
319
319
  }),
@@ -322,13 +322,11 @@ export function buildDecorations(
322
322
  continue;
323
323
  }
324
324
 
325
- // Skip visual styling when tracked changes display is off
326
- if (!showTrackedChanges) continue;
327
-
328
325
  const pmFrom = positionMap.runtimeToPm(rev.from);
329
326
  const pmTo = positionMap.runtimeToPm(rev.to);
330
327
  if (pmFrom >= pmTo) continue;
331
328
 
329
+ // Suggestions styling is always shown regardless of showTrackedChanges toggle.
332
330
  if (suggestionsEnabled) {
333
331
  if (rev.kind === "insertion") {
334
332
  decorations.push(
@@ -366,6 +364,9 @@ export function buildDecorations(
366
364
  continue;
367
365
  }
368
366
 
367
+ // Skip normal markup styling when tracked changes display is off
368
+ if (!showTrackedChanges) continue;
369
+
369
370
  const cls = getRevisionHighlightClass(
370
371
  revisionModel,
371
372
  rev.from,
@@ -37,6 +37,7 @@ export function createSurfaceDocumentBuildKey(input: {
37
37
  export function createSurfaceDecorationKey(input: {
38
38
  markupDisplay: string;
39
39
  showTrackedChanges: boolean;
40
+ suggestionsEnabled?: boolean;
40
41
  canEdit: boolean;
41
42
  activeCommentId?: string;
42
43
  activeRevisionId?: string;
@@ -51,6 +52,7 @@ export function createSurfaceDecorationKey(input: {
51
52
  return JSON.stringify({
52
53
  markupDisplay: input.markupDisplay,
53
54
  showTrackedChanges: input.showTrackedChanges,
55
+ suggestionsEnabled: input.suggestionsEnabled ?? false,
54
56
  canEdit: input.canEdit,
55
57
  activeCommentId: input.activeCommentId ?? null,
56
58
  activeRevisionId: input.activeRevisionId ?? null,
@@ -223,6 +223,7 @@ export const TwProseMirrorSurface = forwardRef<
223
223
  createSurfaceDecorationKey({
224
224
  markupDisplay,
225
225
  showTrackedChanges,
226
+ suggestionsEnabled,
226
227
  canEdit,
227
228
  activeCommentId: snapshot.comments.activeCommentId,
228
229
  activeRevisionId: props.activeRevisionId,
@@ -246,6 +247,7 @@ export const TwProseMirrorSurface = forwardRef<
246
247
  props.activeWorkflowScopeIds,
247
248
  props.workflowScopes,
248
249
  showTrackedChanges,
250
+ suggestionsEnabled,
249
251
  snapshot.comments.activeCommentId,
250
252
  ],
251
253
  );