@beyondwork/docx-react-component 1.0.20 → 1.0.21

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,8 +1,9 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.20",
4
+ "version": "1.0.21",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
+ "packageManager": "pnpm@10.30.3",
6
7
  "type": "module",
7
8
  "sideEffects": [
8
9
  "**/*.css"
@@ -73,6 +74,14 @@
73
74
  "types": "./src/core/commands/table-structure-commands.ts",
74
75
  "import": "./src/core/commands/table-structure-commands.ts"
75
76
  },
77
+ "./core/commands/style-commands": {
78
+ "types": "./src/core/commands/style-commands.ts",
79
+ "import": "./src/core/commands/style-commands.ts"
80
+ },
81
+ "./core/commands/section-layout-commands": {
82
+ "types": "./src/core/commands/section-layout-commands.ts",
83
+ "import": "./src/core/commands/section-layout-commands.ts"
84
+ },
76
85
  "./core/state/editor-state": {
77
86
  "types": "./src/core/state/editor-state.ts",
78
87
  "import": "./src/core/state/editor-state.ts"
@@ -80,6 +89,30 @@
80
89
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css"
81
90
  },
82
91
  "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:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
99
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
100
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
101
+ "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
102
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
103
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
104
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
105
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
106
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
107
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
108
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
109
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
110
+ "wave:status": "bash scripts/wave-status.sh",
111
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
112
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
113
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
114
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
115
+ },
83
116
  "keywords": [
84
117
  "docx",
85
118
  "word",
@@ -142,28 +175,14 @@
142
175
  "tsup": "^8.3.0",
143
176
  "tsx": "^4.21.0"
144
177
  },
145
- "scripts": {
146
- "build": "tsup",
147
- "test": "bash scripts/run-workspace-tests.sh",
148
- "test:repo": "node scripts/run-repo-tests.mjs core",
149
- "test:repo:all": "node scripts/run-repo-tests.mjs all",
150
- "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
151
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
152
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
153
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
154
- "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
155
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
156
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
157
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
158
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
159
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
160
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
161
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
162
- "wave:launch:managed": "bash scripts/wave-launch.sh",
163
- "wave:status": "bash scripts/wave-status.sh",
164
- "wave:watch": "bash scripts/wave-watch.sh --follow",
165
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
166
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
167
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
178
+ "pnpm": {
179
+ "onlyBuiltDependencies": [
180
+ "esbuild",
181
+ "sharp"
182
+ ],
183
+ "overrides": {
184
+ "react": "19.2.4",
185
+ "react-dom": "19.2.4"
186
+ }
168
187
  }
169
- }
188
+ }
@@ -726,7 +726,7 @@ function exportDocxEditorSession(
726
726
  state.initialCanonicalSignature;
727
727
  const canReuse = canReuseSourceBytesForCurrentDocument(state, currentDocument);
728
728
  const commentCount = Object.keys(currentDocument.review?.comments ?? {}).length;
729
- console.error(`[DEBUG-EXPORT] docId=${sessionState.documentId} signatureMatch=${signatureMatch} canReuse=${canReuse} preservedDefs=${state.preservedCommentDefinitions.length} blockingDiags=${state.blockingCommentDiagnostics.length} comments=${commentCount}`);
729
+
730
730
  if (signatureMatch && canReuse) {
731
731
  return {
732
732
  bytes: new Uint8Array(state.sourceBytes),
@@ -2442,12 +2442,12 @@ function collectFieldsFromSubParts(
2442
2442
  return index;
2443
2443
  }
2444
2444
  let nextIndex = index;
2445
- for (const header of subParts.headers) {
2445
+ for (const header of subParts.headers ?? []) {
2446
2446
  for (const block of header.blocks) {
2447
2447
  nextIndex = collectFieldsFromBlock(block, entries, nextIndex);
2448
2448
  }
2449
2449
  }
2450
- for (const footer of subParts.footers) {
2450
+ for (const footer of subParts.footers ?? []) {
2451
2451
  for (const block of footer.blocks) {
2452
2452
  nextIndex = collectFieldsFromBlock(block, entries, nextIndex);
2453
2453
  }
@@ -45,6 +45,7 @@ import type {
45
45
  UpdateFieldsResult,
46
46
  ViewMode as EditorViewMode,
47
47
  WorkflowBlockedCommandReason,
48
+ WorkflowMarkupSnapshot,
48
49
  WorkflowScopeSnapshot,
49
50
  WordReviewEditorEvent,
50
51
  WordReviewEditorProps,
@@ -151,6 +152,7 @@ import type {
151
152
  SelectionToolbarModel,
152
153
  } from "./headless/selection-toolbar-model";
153
154
  import { type EditorCommandBag, useCommandBag } from "./editor-command-bag.ts";
155
+ import { deriveVisibleWorkflowBlockedRails } from "./workflow-surface-blocked-rails.ts";
154
156
  import {
155
157
  type WordReviewEditorRuntime,
156
158
  persistAndExport as persistAndExportFromBoundary,
@@ -645,6 +647,14 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
645
647
  { blockedReasons: [] } satisfies InteractionGuardSnapshot,
646
648
  interactionGuardSnapshotsEqual,
647
649
  );
650
+ const workflowMarkupSnapshot = useMemo(
651
+ () => (runtime ? runtime.getWorkflowMarkupSnapshot() : null),
652
+ [runtime, snapshot.revisionToken],
653
+ );
654
+ const workflowBlockedRails = useMemo(
655
+ () => deriveVisibleWorkflowBlockedRails(snapshot.surface, workflowMarkupSnapshot),
656
+ [snapshot.surface, workflowMarkupSnapshot],
657
+ );
648
658
  const sessionState = useMemo(
649
659
  () => (runtime ? runtime.getSessionState() : loadingSessionState),
650
660
  [loadingSessionState, runtime, snapshot.revisionToken],
@@ -1611,6 +1621,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1611
1621
  mediaPreviews={mediaPreviews}
1612
1622
  isPageWorkspace={isPageWorkspace}
1613
1623
  workflowScopes={workflowScopeSnapshot?.scopes}
1624
+ workflowCandidates={workflowScopeSnapshot?.candidates}
1625
+ workflowBlockedReasons={workflowBlockedRails}
1614
1626
  onSelectionToolbarAnchorChange={handleSelectionToolbarAnchorChange}
1615
1627
  {...editorCallbacks}
1616
1628
  onCommentActivated={(commentId) => {
@@ -1904,6 +1916,13 @@ function applyRuntimeInsertSectionBreak(
1904
1916
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1905
1917
  return;
1906
1918
  }
1919
+ if (snapshot.documentMode === "suggesting") {
1920
+ runtime.emitBlockedCommand("insertSectionBreak", [{
1921
+ code: "unsupported_surface",
1922
+ message: "Section break insertion is not supported in suggesting mode.",
1923
+ }]);
1924
+ return;
1925
+ }
1907
1926
 
1908
1927
  const sessionState = runtime.getSessionState();
1909
1928
  const timestamp = new Date().toISOString();
@@ -2075,6 +2094,14 @@ function applyRuntimeSetHeaderFooterLink(
2075
2094
  }
2076
2095
 
2077
2096
  function applyRuntimeInsertPageBreak(runtime: WordReviewEditorRuntime): void {
2097
+ const snapshot = runtime.getRenderSnapshot();
2098
+ if (snapshot.documentMode === "suggesting") {
2099
+ runtime.emitBlockedCommand("insertPageBreak", [{
2100
+ code: "unsupported_surface",
2101
+ message: "Page break insertion is not supported in suggesting mode.",
2102
+ }]);
2103
+ return;
2104
+ }
2078
2105
  const context = getStoryMutationContext(runtime);
2079
2106
  if (!context) {
2080
2107
  return;
@@ -2092,6 +2119,14 @@ function applyRuntimeInsertTable(
2092
2119
  runtime: WordReviewEditorRuntime,
2093
2120
  options: InsertTableOptions,
2094
2121
  ): void {
2122
+ const snapshot = runtime.getRenderSnapshot();
2123
+ if (snapshot.documentMode === "suggesting") {
2124
+ runtime.emitBlockedCommand("insertTable", [{
2125
+ code: "unsupported_surface",
2126
+ message: "Table insertion is not supported in suggesting mode.",
2127
+ }]);
2128
+ return;
2129
+ }
2095
2130
  const context = getStoryMutationContext(runtime);
2096
2131
  if (!context) {
2097
2132
  return;
@@ -2110,6 +2145,14 @@ function applyRuntimeInsertImage(
2110
2145
  runtime: WordReviewEditorRuntime,
2111
2146
  options: InsertImageOptions,
2112
2147
  ): void {
2148
+ const snapshot = runtime.getRenderSnapshot();
2149
+ if (snapshot.documentMode === "suggesting") {
2150
+ runtime.emitBlockedCommand("insertImage", [{
2151
+ code: "unsupported_surface",
2152
+ message: "Image insertion is not supported in suggesting mode.",
2153
+ }]);
2154
+ return;
2155
+ }
2113
2156
  const context = getStoryMutationContext(runtime);
2114
2157
  if (!context) {
2115
2158
  return;
@@ -2148,6 +2191,13 @@ function applyRuntimeImageResize(
2148
2191
  if (!canApplyRuntimeMutation(snapshot)) {
2149
2192
  return;
2150
2193
  }
2194
+ if (snapshot.documentMode === "suggesting") {
2195
+ runtime.emitBlockedCommand("setImageLayout", [{
2196
+ code: "unsupported_surface",
2197
+ message: "Image resize is not supported in suggesting mode.",
2198
+ }]);
2199
+ return;
2200
+ }
2151
2201
 
2152
2202
  try {
2153
2203
  const sessionState = runtime.getSessionState();
@@ -2172,6 +2222,14 @@ function applyRuntimeImageReposition(
2172
2222
  mediaId: string,
2173
2223
  offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
2174
2224
  ): void {
2225
+ const snapshot = runtime.getRenderSnapshot();
2226
+ if (snapshot.documentMode === "suggesting") {
2227
+ runtime.emitBlockedCommand("setImageFrame", [{
2228
+ code: "unsupported_surface",
2229
+ message: "Image reposition is not supported in suggesting mode.",
2230
+ }]);
2231
+ return;
2232
+ }
2175
2233
  const context = getStoryMutationContext(runtime);
2176
2234
  if (!context) {
2177
2235
  return;
@@ -2217,6 +2275,14 @@ function applyRuntimeTableStructureOperation(
2217
2275
  | { type: "split-cell" }
2218
2276
  | { type: "set-cell-background"; color: string },
2219
2277
  ): void {
2278
+ const snapshot = runtime.getRenderSnapshot();
2279
+ if (snapshot.documentMode === "suggesting") {
2280
+ runtime.emitBlockedCommand(`table.${operation.type}`, [{
2281
+ code: "unsupported_surface",
2282
+ message: `Table operation "${operation.type}" is not supported in suggesting mode.`,
2283
+ }]);
2284
+ return;
2285
+ }
2220
2286
  const context = getStoryMutationContext(runtime);
2221
2287
  if (!context) {
2222
2288
  return;
@@ -73,12 +73,17 @@ export interface CreateRuntimeArgs {
73
73
  interface RuntimeLifecycleHandlers {
74
74
  onWarning?: (warning: EditorWarning) => void;
75
75
  onError?: (error: EditorError) => void;
76
+ onEvent?: (event: WordReviewEditorEvent) => void;
76
77
  }
77
78
 
78
79
  export interface WordReviewEditorRuntime extends DocumentRuntime {
79
80
  getFatalError?(): EditorError | undefined;
80
81
  dispose?(): void;
81
82
  setDefaultAuthorId?(authorId?: string): void;
83
+ emitBlockedCommand(
84
+ command: string,
85
+ reasons: Extract<WordReviewEditorEvent, { type: "command_blocked" }>["reasons"],
86
+ ): void;
82
87
  }
83
88
 
84
89
  type PackageBackedDocxSession = ReturnType<typeof loadDocxEditorSession>;
@@ -357,6 +362,7 @@ export function useEditorRuntimeBoundary(
357
362
  {
358
363
  onWarning: onWarningRef.current,
359
364
  onError: onErrorRef.current,
365
+ onEvent: onEventRef.current,
360
366
  },
361
367
  );
362
368
  recordPerfSample("runtime.create");
@@ -538,7 +544,7 @@ function createRuntime(
538
544
  ? applySessionExportBarrier(initialSessionState, snapshotExportResolution.barrier)
539
545
  : initialSessionState;
540
546
 
541
- return createDocumentRuntime({
547
+ const runtime: WordReviewEditorRuntime = Object.assign(createDocumentRuntime({
542
548
  documentId: args.documentId,
543
549
  initialSessionState: runtimeSessionState,
544
550
  sourceKind: args.source.source,
@@ -569,7 +575,26 @@ function createRuntime(
569
575
  onWarning: handlers.onWarning,
570
576
  onError: handlers.onError,
571
577
  defaultAuthorId: args.currentUserId,
578
+ }), {
579
+ emitBlockedCommand: (
580
+ command: string,
581
+ reasons: Extract<WordReviewEditorEvent, { type: "command_blocked" }>["reasons"],
582
+ ) => {
583
+ emitEditorEvent({
584
+ hostAdapter: args.hostAdapter,
585
+ datastore: args.datastore,
586
+ onEvent: handlers.onEvent,
587
+ event: {
588
+ type: "command_blocked",
589
+ documentId: args.documentId,
590
+ command,
591
+ reasons,
592
+ },
593
+ });
594
+ },
572
595
  });
596
+
597
+ return runtime;
573
598
  }
574
599
 
575
600
  function createLoadingSnapshot(
@@ -691,6 +716,7 @@ function createLoadingRuntimeBridge(input: {
691
716
  return {
692
717
  subscribe: () => () => undefined,
693
718
  subscribeToEvents: () => () => undefined,
719
+ emitBlockedCommand: () => undefined,
694
720
  getRenderSnapshot: () => input.snapshot,
695
721
  replaceText: () => undefined,
696
722
  dispatch: () => undefined,
@@ -5,6 +5,8 @@ import type {
5
5
  EditorUser,
6
6
  RuntimeRenderSnapshot,
7
7
  SelectionSnapshot,
8
+ WorkflowBlockedCommandReason,
9
+ WorkflowCandidateRange,
8
10
  WorkflowScope,
9
11
  } from "../api/public-types.ts";
10
12
  import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
@@ -41,6 +43,8 @@ export interface EditorSurfaceControllerProps {
41
43
  onCommentActivated?: (commentId: string) => void;
42
44
  onRevisionActivated?: (revisionId: string) => void;
43
45
  workflowScopes?: readonly WorkflowScope[];
46
+ workflowCandidates?: readonly WorkflowCandidateRange[];
47
+ workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[];
44
48
  }
45
49
 
46
50
  export const EditorSurfaceController = forwardRef<
@@ -0,0 +1,94 @@
1
+ import type {
2
+ EditorStoryTarget,
3
+ RuntimeRenderSnapshot,
4
+ SurfaceBlockSnapshot,
5
+ WorkflowBlockedCommandReason,
6
+ WorkflowMarkupSnapshot,
7
+ } from "../api/public-types";
8
+
9
+ export function deriveVisibleWorkflowBlockedRails(
10
+ surface: RuntimeRenderSnapshot["surface"] | undefined,
11
+ markupSnapshot: WorkflowMarkupSnapshot | null,
12
+ ): WorkflowBlockedCommandReason[] {
13
+ if (!surface || !markupSnapshot) {
14
+ return [];
15
+ }
16
+
17
+ const visibleFragments = collectVisibleOpaqueFragmentStoryKeys(surface);
18
+ return markupSnapshot.opaqueFragments
19
+ .filter((fragment) =>
20
+ visibleFragments.has(
21
+ createOpaqueFragmentStoryKey(fragment.fragmentId, fragment.storyTarget ?? { kind: "main" }),
22
+ ),
23
+ )
24
+ .map((fragment) => ({
25
+ code: fragment.blockedReasonCode,
26
+ message: fragment.detail,
27
+ anchor: fragment.anchor,
28
+ storyTarget: fragment.storyTarget,
29
+ }));
30
+ }
31
+
32
+ function collectVisibleOpaqueFragmentStoryKeys(
33
+ surface: NonNullable<RuntimeRenderSnapshot["surface"]>,
34
+ ): Set<string> {
35
+ const keys = new Set<string>();
36
+ collectVisibleOpaqueFragmentStoryKeysInBlocks(surface.blocks, { kind: "main" }, keys);
37
+ for (const story of surface.secondaryStories) {
38
+ collectVisibleOpaqueFragmentStoryKeysInBlocks(story.blocks, story.target, keys);
39
+ }
40
+ return keys;
41
+ }
42
+
43
+ function collectVisibleOpaqueFragmentStoryKeysInBlocks(
44
+ blocks: readonly SurfaceBlockSnapshot[],
45
+ storyTarget: EditorStoryTarget,
46
+ keys: Set<string>,
47
+ ): void {
48
+ for (const block of blocks) {
49
+ if (block.kind === "paragraph") {
50
+ for (const segment of block.segments) {
51
+ if (segment.kind === "opaque_inline") {
52
+ keys.add(createOpaqueFragmentStoryKey(segment.fragmentId, storyTarget));
53
+ }
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if (block.kind === "table") {
59
+ for (const row of block.rows) {
60
+ for (const cell of row.cells) {
61
+ collectVisibleOpaqueFragmentStoryKeysInBlocks(cell.content, storyTarget, keys);
62
+ }
63
+ }
64
+ continue;
65
+ }
66
+
67
+ if (block.kind === "sdt_block") {
68
+ collectVisibleOpaqueFragmentStoryKeysInBlocks(block.children, storyTarget, keys);
69
+ continue;
70
+ }
71
+
72
+ keys.add(createOpaqueFragmentStoryKey(block.fragmentId, storyTarget));
73
+ }
74
+ }
75
+
76
+ function createOpaqueFragmentStoryKey(
77
+ fragmentId: string,
78
+ storyTarget: EditorStoryTarget,
79
+ ): string {
80
+ return `${fragmentId}:${serializeStoryTargetKey(storyTarget)}`;
81
+ }
82
+
83
+ function serializeStoryTargetKey(storyTarget: EditorStoryTarget): string {
84
+ switch (storyTarget.kind) {
85
+ case "main":
86
+ return "main";
87
+ case "header":
88
+ case "footer":
89
+ return `${storyTarget.kind}:${storyTarget.relationshipId}:${storyTarget.variant}:${storyTarget.sectionIndex ?? "none"}`;
90
+ case "footnote":
91
+ case "endnote":
92
+ return `${storyTarget.kind}:${storyTarget.noteId}`;
93
+ }
94
+ }
@@ -4,11 +4,156 @@ import type { CommentDecorationModel } from "../../ui/headless/comment-decoratio
4
4
  import { getCommentHighlightClass, type MarkupDisplay } from "../../ui/headless/comment-decoration-model";
5
5
  import type { RevisionDecorationModel } from "../../ui/headless/revision-decoration-model";
6
6
  import { getRevisionHighlightClass } from "../../ui/headless/revision-decoration-model";
7
- import type { EditorStoryTarget, WorkflowScope } from "../../api/public-types";
7
+ import type {
8
+ EditorAnchorProjection,
9
+ EditorStoryTarget,
10
+ WorkflowBlockedCommandReason,
11
+ WorkflowCandidateRange,
12
+ WorkflowScope,
13
+ } from "../../api/public-types";
8
14
  import { MAIN_STORY_TARGET, storyTargetsEqual } from "../../core/selection/mapping.ts";
9
15
  import type { PositionMap } from "./pm-position-map";
10
16
  import type { Node as PMNode } from "prosemirror-model";
11
17
 
18
+ type RailDecorationSpec = {
19
+ railKind: "scope" | "candidate" | "blocked";
20
+ className: string;
21
+ attrs: Record<string, string>;
22
+ };
23
+
24
+ function getWorkflowInlineClass(scope: WorkflowScope): string {
25
+ if (scope.mode === "edit") return "wre-workflow-inline wre-workflow-inline-edit";
26
+ if (scope.mode === "suggest") return "wre-workflow-inline wre-workflow-inline-suggest";
27
+ if (scope.mode === "comment") return "wre-workflow-inline wre-workflow-inline-comment";
28
+ return "wre-workflow-inline wre-workflow-inline-view";
29
+ }
30
+
31
+ function getWorkflowRailClass(scope: WorkflowScope): string {
32
+ if (scope.mode === "edit") return "wre-workflow-rail wre-workflow-rail-edit";
33
+ if (scope.mode === "suggest") return "wre-workflow-rail wre-workflow-rail-suggest";
34
+ if (scope.mode === "comment") return "wre-workflow-rail wre-workflow-rail-comment";
35
+ return "wre-workflow-rail wre-workflow-rail-view";
36
+ }
37
+
38
+ function getWorkflowCandidateInlineClass(): string {
39
+ return "wre-workflow-inline wre-workflow-inline-candidate";
40
+ }
41
+
42
+ function getWorkflowCandidateRailClass(): string {
43
+ return "wre-workflow-rail wre-workflow-rail-candidate";
44
+ }
45
+
46
+ function getWorkflowBlockedInlineClass(reason: WorkflowBlockedCommandReason): string {
47
+ if (reason.code === "workflow_blocked_import") {
48
+ return "wre-workflow-inline wre-workflow-inline-blocked-import";
49
+ }
50
+ return "wre-workflow-inline wre-workflow-inline-preserve-only";
51
+ }
52
+
53
+ function getWorkflowBlockedRailClass(reason: WorkflowBlockedCommandReason): string {
54
+ if (reason.code === "workflow_blocked_import") {
55
+ return "wre-workflow-rail wre-workflow-rail-blocked-import";
56
+ }
57
+ return "wre-workflow-rail wre-workflow-rail-preserve-only";
58
+ }
59
+
60
+ function hasBlockChildren(node: PMNode): boolean {
61
+ for (let index = 0; index < node.childCount; index += 1) {
62
+ if (node.child(index).isBlock) {
63
+ return true;
64
+ }
65
+ }
66
+ return false;
67
+ }
68
+
69
+ function collectRailRanges(doc: PMNode, from: number, to: number): Array<{ from: number; to: number }> {
70
+ const effectiveTo = Math.max(to, from + 1);
71
+ const ranges = new Map<string, { from: number; to: number }>();
72
+ let fallbackFrom: number | null = null;
73
+ let fallbackTo: number | null = null;
74
+ let fallbackSize: number | null = null;
75
+
76
+ doc.descendants((node, pos) => {
77
+ if (!node.isBlock || node.type.name === "doc") {
78
+ return true;
79
+ }
80
+
81
+ const nodeFrom = pos;
82
+ const nodeTo = pos + node.nodeSize;
83
+ if (nodeTo <= from || nodeFrom >= effectiveTo) {
84
+ return true;
85
+ }
86
+
87
+ if (!hasBlockChildren(node)) {
88
+ ranges.set(`${nodeFrom}:${nodeTo}`, { from: nodeFrom, to: nodeTo });
89
+ return true;
90
+ }
91
+
92
+ if (nodeFrom <= from && nodeTo >= effectiveTo) {
93
+ const size = nodeTo - nodeFrom;
94
+ if (fallbackSize === null || size < fallbackSize) {
95
+ fallbackFrom = nodeFrom;
96
+ fallbackTo = nodeTo;
97
+ fallbackSize = size;
98
+ }
99
+ }
100
+
101
+ return true;
102
+ });
103
+
104
+ if (ranges.size > 0) {
105
+ return [...ranges.values()];
106
+ }
107
+
108
+ if (fallbackFrom !== null && fallbackTo !== null) {
109
+ return [{ from: fallbackFrom, to: fallbackTo }];
110
+ }
111
+
112
+ return [];
113
+ }
114
+
115
+ function buildAnchorPmRange(
116
+ anchor: EditorAnchorProjection,
117
+ positionMap: PositionMap,
118
+ ): { from: number; to: number; allowInline: boolean } | null {
119
+ if (anchor.kind === "detached") {
120
+ return null;
121
+ }
122
+
123
+ if (anchor.kind === "range") {
124
+ return {
125
+ from: positionMap.runtimeToPm(anchor.from),
126
+ to: positionMap.runtimeToPm(anchor.to),
127
+ allowInline: true,
128
+ };
129
+ }
130
+
131
+ const pmAt = positionMap.runtimeToPm(anchor.at);
132
+ return {
133
+ from: pmAt,
134
+ to: pmAt + 1,
135
+ allowInline: false,
136
+ };
137
+ }
138
+
139
+ function pushRailDecorations(
140
+ decorations: Decoration[],
141
+ doc: PMNode,
142
+ from: number,
143
+ to: number,
144
+ spec: RailDecorationSpec,
145
+ ): void {
146
+ for (const range of collectRailRanges(doc, from, to)) {
147
+ decorations.push(
148
+ Decoration.node(range.from, range.to, {
149
+ class: spec.className,
150
+ "data-workflow-rail": spec.railKind,
151
+ ...spec.attrs,
152
+ }),
153
+ );
154
+ }
155
+ }
156
+
12
157
  /**
13
158
  * Build ProseMirror DecorationSet from runtime comment and revision models.
14
159
  *
@@ -25,6 +170,8 @@ export function buildDecorations(
25
170
  showTrackedChanges = true,
26
171
  workflowScopes?: readonly WorkflowScope[],
27
172
  activeStory: EditorStoryTarget = MAIN_STORY_TARGET,
173
+ workflowCandidates?: readonly WorkflowCandidateRange[],
174
+ workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[],
28
175
  ): DecorationSet {
29
176
  const decorations: Decoration[] = [];
30
177
 
@@ -98,34 +245,89 @@ export function buildDecorations(
98
245
  }
99
246
  }
100
247
 
101
- // Walk workflow scopes and create inline decorations for scope emphasis.
102
248
  if (workflowScopes) {
103
249
  for (const scope of workflowScopes) {
104
250
  const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
105
251
  if (!storyTargetsEqual(scopeStoryTarget, activeStory)) continue;
106
- if (scope.anchor.kind === "detached") continue;
107
- const from = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
108
- const to = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
109
- const pmFrom = positionMap.runtimeToPm(from);
110
- const pmTo = positionMap.runtimeToPm(to);
111
- if (pmFrom >= pmTo) continue;
112
-
113
- const modeClass =
114
- scope.mode === "edit"
115
- ? "bg-blue-50/40 ring-1 ring-blue-200/50"
116
- : scope.mode === "suggest"
117
- ? "bg-amber-50/40 ring-1 ring-amber-200/50"
118
- : scope.mode === "comment"
119
- ? "bg-green-50/40 ring-1 ring-green-200/50"
120
- : "bg-gray-50/40 ring-1 ring-gray-200/50";
121
-
122
- decorations.push(
123
- Decoration.inline(pmFrom, pmTo, {
124
- class: modeClass,
252
+ const pmRange = buildAnchorPmRange(scope.anchor, positionMap);
253
+ if (!pmRange) continue;
254
+
255
+ if (pmRange.allowInline && pmRange.from < pmRange.to) {
256
+ decorations.push(
257
+ Decoration.inline(pmRange.from, pmRange.to, {
258
+ class: getWorkflowInlineClass(scope),
259
+ "data-workflow-scope-id": scope.scopeId,
260
+ "data-workflow-scope-mode": scope.mode,
261
+ }),
262
+ );
263
+ }
264
+
265
+ pushRailDecorations(decorations, doc, pmRange.from, pmRange.to, {
266
+ railKind: "scope",
267
+ className: getWorkflowRailClass(scope),
268
+ attrs: {
125
269
  "data-workflow-scope-id": scope.scopeId,
126
270
  "data-workflow-scope-mode": scope.mode,
127
- }),
128
- );
271
+ },
272
+ });
273
+ }
274
+ }
275
+
276
+ if (workflowCandidates) {
277
+ for (const candidate of workflowCandidates) {
278
+ const candidateStoryTarget = candidate.storyTarget ?? MAIN_STORY_TARGET;
279
+ if (!storyTargetsEqual(candidateStoryTarget, activeStory)) continue;
280
+ const pmRange = buildAnchorPmRange(candidate.anchor, positionMap);
281
+ if (!pmRange) continue;
282
+
283
+ if (pmRange.allowInline && pmRange.from < pmRange.to) {
284
+ decorations.push(
285
+ Decoration.inline(pmRange.from, pmRange.to, {
286
+ class: getWorkflowCandidateInlineClass(),
287
+ "data-workflow-candidate-id": candidate.candidateId,
288
+ }),
289
+ );
290
+ }
291
+
292
+ pushRailDecorations(decorations, doc, pmRange.from, pmRange.to, {
293
+ railKind: "candidate",
294
+ className: getWorkflowCandidateRailClass(),
295
+ attrs: {
296
+ "data-workflow-candidate-id": candidate.candidateId,
297
+ },
298
+ });
299
+ }
300
+ }
301
+
302
+ if (workflowBlockedReasons) {
303
+ for (const reason of workflowBlockedReasons) {
304
+ if (
305
+ reason.code !== "workflow_preserve_only" &&
306
+ reason.code !== "workflow_blocked_import"
307
+ ) {
308
+ continue;
309
+ }
310
+ const reasonStoryTarget = reason.storyTarget ?? MAIN_STORY_TARGET;
311
+ if (!storyTargetsEqual(reasonStoryTarget, activeStory) || !reason.anchor) continue;
312
+ const pmRange = buildAnchorPmRange(reason.anchor, positionMap);
313
+ if (!pmRange) continue;
314
+
315
+ if (pmRange.allowInline && pmRange.from < pmRange.to) {
316
+ decorations.push(
317
+ Decoration.inline(pmRange.from, pmRange.to, {
318
+ class: getWorkflowBlockedInlineClass(reason),
319
+ "data-workflow-blocked-code": reason.code,
320
+ }),
321
+ );
322
+ }
323
+
324
+ pushRailDecorations(decorations, doc, pmRange.from, pmRange.to, {
325
+ railKind: "blocked",
326
+ className: getWorkflowBlockedRailClass(reason),
327
+ attrs: {
328
+ "data-workflow-blocked-code": reason.code,
329
+ },
330
+ });
129
331
  }
130
332
  }
131
333
 
@@ -39,6 +39,8 @@ export function createSurfaceDecorationKey(input: {
39
39
  activeCommentId?: string;
40
40
  activeRevisionId?: string;
41
41
  workflowScopeSignature?: string;
42
+ workflowCandidateSignature?: string;
43
+ workflowBlockedSignature?: string;
42
44
  }): string {
43
45
  return JSON.stringify({
44
46
  markupDisplay: input.markupDisplay,
@@ -47,5 +49,7 @@ export function createSurfaceDecorationKey(input: {
47
49
  activeCommentId: input.activeCommentId ?? null,
48
50
  activeRevisionId: input.activeRevisionId ?? null,
49
51
  workflowScopeSignature: input.workflowScopeSignature ?? null,
52
+ workflowCandidateSignature: input.workflowCandidateSignature ?? null,
53
+ workflowBlockedSignature: input.workflowBlockedSignature ?? null,
50
54
  });
51
55
  }
@@ -16,6 +16,8 @@ import type {
16
16
  SearchOptions,
17
17
  SearchResultSnapshot,
18
18
  SelectionSnapshot,
19
+ WorkflowBlockedCommandReason,
20
+ WorkflowCandidateRange,
19
21
  WorkflowScope,
20
22
  } from "../../api/public-types";
21
23
  import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
@@ -90,6 +92,8 @@ export interface TwProseMirrorSurfaceProps {
90
92
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
91
93
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
92
94
  workflowScopes?: readonly WorkflowScope[];
95
+ workflowCandidates?: readonly WorkflowCandidateRange[];
96
+ workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[];
93
97
  }
94
98
 
95
99
  export interface TwProseMirrorSurfaceRef {
@@ -201,11 +205,15 @@ export const TwProseMirrorSurface = forwardRef<
201
205
  activeCommentId: snapshot.comments.activeCommentId,
202
206
  activeRevisionId: props.activeRevisionId,
203
207
  workflowScopeSignature: JSON.stringify(props.workflowScopes ?? []),
208
+ workflowCandidateSignature: JSON.stringify(props.workflowCandidates ?? []),
209
+ workflowBlockedSignature: JSON.stringify(props.workflowBlockedReasons ?? []),
204
210
  }),
205
211
  [
206
212
  canEdit,
207
213
  markupDisplay,
208
214
  props.activeRevisionId,
215
+ props.workflowCandidates,
216
+ props.workflowBlockedReasons,
209
217
  props.workflowScopes,
210
218
  showTrackedChanges,
211
219
  snapshot.comments.activeCommentId,
@@ -249,6 +257,8 @@ export const TwProseMirrorSurface = forwardRef<
249
257
  showTrackedChanges,
250
258
  props.workflowScopes,
251
259
  snapshot.activeStory,
260
+ props.workflowCandidates,
261
+ props.workflowBlockedReasons,
252
262
  );
253
263
  view.setProps({
254
264
  editable: () => canEdit,
@@ -265,7 +275,11 @@ export const TwProseMirrorSurface = forwardRef<
265
275
  markupDisplay,
266
276
  revisionModel,
267
277
  showTrackedChanges,
278
+ props.workflowBlockedReasons,
279
+ props.workflowCandidates,
268
280
  props.workflowScopes,
281
+ props.workflowCandidates,
282
+ props.workflowBlockedReasons,
269
283
  ],
270
284
  );
271
285
 
@@ -293,6 +307,8 @@ export const TwProseMirrorSurface = forwardRef<
293
307
  showTrackedChanges,
294
308
  props.workflowScopes,
295
309
  snapshot.activeStory,
310
+ props.workflowCandidates,
311
+ props.workflowBlockedReasons,
296
312
  );
297
313
  recordPerfSample("pm.rebuild");
298
314
  incrementInvalidationCounter("pm.laneA.rebuilds");
@@ -258,6 +258,132 @@
258
258
  outline: none;
259
259
  }
260
260
 
261
+ .prosemirror-surface .ProseMirror .wre-workflow-inline {
262
+ border-radius: 0.25rem;
263
+ box-shadow: inset 0 0 0 1px transparent;
264
+ }
265
+
266
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-edit {
267
+ background: color-mix(in srgb, var(--color-accent) 10%, transparent);
268
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 18%, transparent);
269
+ }
270
+
271
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-suggest {
272
+ background: color-mix(in srgb, var(--color-warning) 12%, transparent);
273
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-warning) 20%, transparent);
274
+ }
275
+
276
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-comment {
277
+ background: color-mix(in srgb, var(--color-insert) 10%, transparent);
278
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-insert) 18%, transparent);
279
+ }
280
+
281
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-view {
282
+ background: color-mix(in srgb, var(--color-secondary) 7%, transparent);
283
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-secondary) 14%, transparent);
284
+ }
285
+
286
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-candidate {
287
+ background: color-mix(in srgb, var(--color-warning) 6%, transparent);
288
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-warning) 18%, transparent);
289
+ text-decoration: underline;
290
+ text-decoration-style: dashed;
291
+ text-decoration-color: color-mix(in srgb, var(--color-warning) 55%, transparent);
292
+ text-underline-offset: 0.18em;
293
+ }
294
+
295
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-preserve-only {
296
+ background: color-mix(in srgb, var(--color-danger) 7%, transparent);
297
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-danger) 18%, transparent);
298
+ }
299
+
300
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-blocked-import {
301
+ background: color-mix(in srgb, var(--color-danger) 8%, transparent);
302
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-danger) 24%, transparent);
303
+ text-decoration: underline;
304
+ text-decoration-style: wavy;
305
+ text-decoration-color: color-mix(in srgb, var(--color-danger) 70%, transparent);
306
+ text-underline-offset: 0.18em;
307
+ }
308
+
309
+ .prosemirror-surface .ProseMirror .wre-workflow-rail {
310
+ position: relative;
311
+ padding-left: 0.875rem;
312
+ border-radius: 0.25rem;
313
+ }
314
+
315
+ .prosemirror-surface .ProseMirror .wre-workflow-rail::before {
316
+ content: "";
317
+ position: absolute;
318
+ left: 0;
319
+ top: 0.2rem;
320
+ bottom: 0.2rem;
321
+ width: 0.3rem;
322
+ border-radius: 999px;
323
+ background: var(--wre-workflow-rail-color, var(--color-border-strong));
324
+ }
325
+
326
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-edit {
327
+ --wre-workflow-rail-color: var(--color-accent);
328
+ background: color-mix(in srgb, var(--color-accent) 7%, transparent);
329
+ }
330
+
331
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-suggest {
332
+ --wre-workflow-rail-color: var(--color-warning);
333
+ background: color-mix(in srgb, var(--color-warning) 8%, transparent);
334
+ }
335
+
336
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-comment {
337
+ --wre-workflow-rail-color: var(--color-insert);
338
+ background: color-mix(in srgb, var(--color-insert) 7%, transparent);
339
+ }
340
+
341
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-view {
342
+ --wre-workflow-rail-color: var(--color-secondary);
343
+ background: color-mix(in srgb, var(--color-secondary) 6%, transparent);
344
+ }
345
+
346
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-candidate {
347
+ --wre-workflow-rail-color: var(--color-warning);
348
+ background: color-mix(in srgb, var(--color-warning) 4%, transparent);
349
+ }
350
+
351
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-candidate::before {
352
+ background:
353
+ repeating-linear-gradient(
354
+ to bottom,
355
+ var(--wre-workflow-rail-color, var(--color-warning)) 0,
356
+ var(--wre-workflow-rail-color, var(--color-warning)) 6px,
357
+ transparent 6px,
358
+ transparent 10px
359
+ );
360
+ }
361
+
362
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-preserve-only {
363
+ --wre-workflow-rail-color: var(--color-danger);
364
+ background: color-mix(in srgb, var(--color-danger) 5%, transparent);
365
+ }
366
+
367
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-preserve-only::before {
368
+ opacity: 0.85;
369
+ }
370
+
371
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-blocked-import {
372
+ --wre-workflow-rail-color: var(--color-danger);
373
+ background: color-mix(in srgb, var(--color-danger) 8%, transparent);
374
+ }
375
+
376
+ .prosemirror-surface .ProseMirror .wre-workflow-rail-blocked-import::before {
377
+ background:
378
+ repeating-linear-gradient(
379
+ to bottom,
380
+ var(--wre-workflow-rail-color, var(--color-danger)) 0,
381
+ var(--wre-workflow-rail-color, var(--color-danger)) 4px,
382
+ transparent 4px,
383
+ transparent 8px
384
+ );
385
+ }
386
+
261
387
  .prosemirror-surface:focus-visible {
262
388
  outline: none;
263
389
  }