@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -0,0 +1,134 @@
1
+ import React, { type CSSProperties, type ReactNode } from "react";
2
+
3
+ import type {
4
+ DocumentNavigationSnapshot,
5
+ EditorError,
6
+ FormattingStateSnapshot,
7
+ InteractionGuardSnapshot,
8
+ RuntimeRenderSnapshot,
9
+ StyleCatalogSnapshot,
10
+ WorkflowScopeSnapshot,
11
+ WorkspaceMode,
12
+ ZoomLevel,
13
+ } from "../api/public-types.ts";
14
+ import type { SessionCapabilities } from "../runtime/session-capabilities.ts";
15
+ import type { MarkupDisplay } from "./headless/comment-decoration-model.ts";
16
+ import type {
17
+ SelectionToolbarAnchor,
18
+ SelectionToolbarModel,
19
+ } from "./headless/selection-toolbar-model.ts";
20
+ import type { EditorCommandBag } from "./editor-command-bag.ts";
21
+ import type { ReviewRailTab } from "../ui-tailwind/review/tw-review-rail.tsx";
22
+ import { TwReviewWorkspace } from "../ui-tailwind/tw-review-workspace.tsx";
23
+ import type {
24
+ ActiveImageContext,
25
+ } from "../ui-tailwind/chrome/tw-image-context-toolbar.tsx";
26
+ import type {
27
+ ActiveObjectContext,
28
+ } from "../ui-tailwind/chrome/tw-object-context-toolbar.tsx";
29
+ import type { EditorViewStateSnapshot } from "../api/public-types.ts";
30
+
31
+ export interface EditorShellViewProps {
32
+ shellRef: React.RefObject<HTMLDivElement | null>;
33
+ documentId: string;
34
+ snapshot: RuntimeRenderSnapshot;
35
+ loadError: EditorError | null;
36
+ diagnosticsModeMessage: string | null;
37
+ accessibilityInstructionsId: string;
38
+ accessibilityStatusId: string;
39
+ accessibilityAlertId: string;
40
+ accessibilityStatusMessage: string;
41
+ visuallyHiddenStyles: CSSProperties;
42
+ onShellKeyDownCapture: React.KeyboardEventHandler<HTMLDivElement>;
43
+ viewState: EditorViewStateSnapshot;
44
+ markupDisplay: MarkupDisplay;
45
+ currentUserId: string;
46
+ capabilities: SessionCapabilities;
47
+ reviewMode: "editing" | "review";
48
+ workspaceMode: WorkspaceMode;
49
+ zoomLevel?: ZoomLevel;
50
+ formattingState?: FormattingStateSnapshot;
51
+ styleCatalog?: StyleCatalogSnapshot;
52
+ activeRailTab: ReviewRailTab;
53
+ activeCommentId?: string;
54
+ activeRevisionId?: string;
55
+ showTrackedChanges: boolean;
56
+ workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
57
+ interactionGuardSnapshot?: InteractionGuardSnapshot;
58
+ selectionToolbar?: SelectionToolbarModel | null;
59
+ selectionToolbarAnchor?: SelectionToolbarAnchor | null;
60
+ documentNavigation?: DocumentNavigationSnapshot;
61
+ commands: EditorCommandBag;
62
+ document: ReactNode;
63
+ onAddCommentFromSelection?: () => void;
64
+ onDismissSelectionToolbar?: () => void;
65
+ onSelectionToolbarFocusCapture?: React.FocusEventHandler<HTMLDivElement>;
66
+ onSelectionToolbarBlurCapture?: React.FocusEventHandler<HTMLDivElement>;
67
+ selectionToolbarRef?: React.Ref<HTMLDivElement>;
68
+ activeImageContext?: ActiveImageContext | null;
69
+ activeObjectContext?: ActiveObjectContext | null;
70
+ }
71
+
72
+ export function EditorShellView(props: EditorShellViewProps) {
73
+ const {
74
+ shellRef,
75
+ documentId,
76
+ snapshot,
77
+ loadError,
78
+ diagnosticsModeMessage,
79
+ accessibilityInstructionsId,
80
+ accessibilityStatusId,
81
+ accessibilityAlertId,
82
+ accessibilityStatusMessage,
83
+ visuallyHiddenStyles,
84
+ onShellKeyDownCapture,
85
+ document,
86
+ commands,
87
+ ...workspaceProps
88
+ } = props;
89
+
90
+ return (
91
+ <div
92
+ ref={shellRef}
93
+ role="region"
94
+ aria-label={`Word review editor for ${snapshot.sourceLabel ?? documentId}`}
95
+ aria-describedby={`${accessibilityInstructionsId} ${accessibilityStatusId}${
96
+ diagnosticsModeMessage ? ` ${accessibilityAlertId}` : ""
97
+ }`}
98
+ className="relative h-full"
99
+ onKeyDownCapture={onShellKeyDownCapture}
100
+ >
101
+ <p id={accessibilityInstructionsId} style={visuallyHiddenStyles}>
102
+ Press F6 to move focus between the toolbar, document surface, review rail, and status bar.
103
+ </p>
104
+ <div
105
+ id={accessibilityStatusId}
106
+ role="status"
107
+ aria-live="polite"
108
+ aria-atomic="true"
109
+ style={visuallyHiddenStyles}
110
+ >
111
+ {accessibilityStatusMessage}
112
+ </div>
113
+ {diagnosticsModeMessage ? (
114
+ <div
115
+ id={accessibilityAlertId}
116
+ data-wre-alert="true"
117
+ role="alert"
118
+ aria-live="assertive"
119
+ aria-atomic="true"
120
+ tabIndex={-1}
121
+ className="border-b border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger"
122
+ >
123
+ {diagnosticsModeMessage}
124
+ </div>
125
+ ) : null}
126
+ <TwReviewWorkspace
127
+ snapshot={snapshot}
128
+ commands={commands}
129
+ document={document}
130
+ {...workspaceProps}
131
+ />
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,51 @@
1
+ import React, { forwardRef } from "react";
2
+
3
+ import type {
4
+ DocumentNavigationSnapshot,
5
+ EditorUser,
6
+ RuntimeRenderSnapshot,
7
+ SelectionSnapshot,
8
+ WorkflowScope,
9
+ } from "../api/public-types.ts";
10
+ import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
11
+ import type { MarkupDisplay } from "./headless/comment-decoration-model.ts";
12
+ import type { SelectionToolbarAnchor } from "./headless/selection-toolbar-model.ts";
13
+ import {
14
+ TwProseMirrorSurface,
15
+ type TwProseMirrorSurfaceRef,
16
+ } from "../ui-tailwind/editor-surface/tw-prosemirror-surface.tsx";
17
+ import type { MediaPreviewDescriptor } from "../ui-tailwind/editor-surface/pm-state-from-snapshot.ts";
18
+
19
+ export interface EditorSurfaceControllerProps {
20
+ currentUser: EditorUser;
21
+ snapshot: RuntimeRenderSnapshot;
22
+ canonicalDocument: CanonicalDocumentEnvelope;
23
+ documentNavigation: DocumentNavigationSnapshot;
24
+ reviewMode: "editing" | "review";
25
+ markupDisplay: MarkupDisplay;
26
+ activeRevisionId?: string;
27
+ showTrackedChanges?: boolean;
28
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
29
+ isPageWorkspace?: boolean;
30
+ onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
31
+ onFocus: React.FocusEventHandler<HTMLDivElement>;
32
+ onBlur: React.FocusEventHandler<HTMLDivElement>;
33
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
34
+ onInsertText?: (text: string) => void;
35
+ onDeleteBackward?: () => void;
36
+ onDeleteForward?: () => void;
37
+ onInsertTab?: () => void;
38
+ onOutdentTab?: () => void;
39
+ onInsertHardBreak?: () => void;
40
+ onSplitParagraph?: () => void;
41
+ onCommentActivated?: (commentId: string) => void;
42
+ onRevisionActivated?: (revisionId: string) => void;
43
+ workflowScopes?: readonly WorkflowScope[];
44
+ }
45
+
46
+ export const EditorSurfaceController = forwardRef<
47
+ TwProseMirrorSurfaceRef,
48
+ EditorSurfaceControllerProps
49
+ >(function EditorSurfaceController(props, ref) {
50
+ return <TwProseMirrorSurface ref={ref} {...props} />;
51
+ });
@@ -0,0 +1,5 @@
1
+ import type { MouseEvent } from "react";
2
+
3
+ export function preserveEditorSelectionMouseDown(event: MouseEvent<HTMLElement>): void {
4
+ event.preventDefault();
5
+ }
@@ -101,18 +101,18 @@ export function getRevisionHighlightClass(
101
101
  return "";
102
102
  case "simple":
103
103
  if (state.hasInsertions) {
104
- return `underline decoration-insert/40 decoration-1 underline-offset-2${activeRing}`;
104
+ return `underline decoration-insert/60 decoration-1 underline-offset-2 text-primary${activeRing}`;
105
105
  }
106
106
  if (state.hasDeletions) {
107
- return `text-secondary line-through decoration-1${activeRing}`;
107
+ return `text-secondary line-through decoration-danger/70 decoration-1${activeRing}`;
108
108
  }
109
109
  return activeRing;
110
110
  case "all":
111
111
  if (state.hasInsertions) {
112
- return `text-insert bg-insert-soft${activeRing}`;
112
+ return `text-primary bg-insert-soft/80 ring-1 ring-insert/20${activeRing}`;
113
113
  }
114
114
  if (state.hasDeletions) {
115
- return `text-danger line-through decoration-1 bg-delete-soft${activeRing}`;
115
+ return `text-danger line-through decoration-danger/80 decoration-1 bg-delete-soft/70${activeRing}`;
116
116
  }
117
117
  return activeRing;
118
118
  }
@@ -19,6 +19,26 @@ export function createSelectionSnapshot(anchor: number, head = anchor): Selectio
19
19
  };
20
20
  }
21
21
 
22
+ export function createNodeSelectionSnapshot(at: number, assoc: -1 | 1 = 1): SelectionSnapshot {
23
+ return {
24
+ anchor: at,
25
+ head: at,
26
+ isCollapsed: true,
27
+ activeRange: {
28
+ kind: "node",
29
+ at,
30
+ assoc,
31
+ },
32
+ };
33
+ }
34
+
35
+ export function isCollapsedAtBlockStart(
36
+ selection: SelectionSnapshot,
37
+ blockFrom: number,
38
+ ): boolean {
39
+ return selection.isCollapsed && selection.head === blockFrom;
40
+ }
41
+
22
42
  export function selectionTouchesRange(
23
43
  selection: SelectionSnapshot,
24
44
  from: number,
@@ -0,0 +1,22 @@
1
+ export interface SelectionToolbarBadge {
2
+ label: string;
3
+ tone?: "neutral" | "accent";
4
+ }
5
+
6
+ export interface SelectionToolbarModel {
7
+ previewText: string;
8
+ badges: SelectionToolbarBadge[];
9
+ canToggleFormatting: boolean;
10
+ boldActive: boolean;
11
+ italicActive: boolean;
12
+ underlineActive: boolean;
13
+ canAddComment: boolean;
14
+ disabledReason?: string;
15
+ }
16
+
17
+ export interface SelectionToolbarAnchor {
18
+ left: number;
19
+ right: number;
20
+ top: number;
21
+ bottom: number;
22
+ }
@@ -9,6 +9,7 @@ export interface EditorKeyboardCallbacks {
9
9
  onDeleteBackward?: () => void;
10
10
  onDeleteForward?: () => void;
11
11
  onInsertTab?: () => void;
12
+ onOutdentTab?: () => void;
12
13
  onInsertHardBreak?: () => void;
13
14
  onSplitParagraph?: () => void;
14
15
  }
@@ -65,7 +66,11 @@ export function createEditorKeyboardHandler(
65
66
  return;
66
67
  case "Tab":
67
68
  event.preventDefault();
68
- callbacks.onInsertTab?.();
69
+ if (event.shiftKey) {
70
+ callbacks.onOutdentTab?.();
71
+ } else {
72
+ callbacks.onInsertTab?.();
73
+ }
69
74
  return;
70
75
  case "Enter":
71
76
  event.preventDefault();
@@ -0,0 +1,197 @@
1
+ import { useRef, useSyncExternalStore } from "react";
2
+ import type {
3
+ DocumentNavigationSnapshot,
4
+ RuntimeRenderSnapshot,
5
+ } from "../api/public-types.ts";
6
+
7
+ export interface RuntimeSnapshotSource {
8
+ subscribe(listener: () => void): () => void;
9
+ getRenderSnapshot(): RuntimeRenderSnapshot;
10
+ }
11
+
12
+ export interface RuntimeValueSource<T> {
13
+ subscribe(listener: () => void): () => void;
14
+ getValue(): T;
15
+ }
16
+
17
+ export interface ToolbarSlice {
18
+ commandState: RuntimeRenderSnapshot["commandState"];
19
+ isDirty: RuntimeRenderSnapshot["isDirty"];
20
+ isReady: RuntimeRenderSnapshot["isReady"];
21
+ readOnly: RuntimeRenderSnapshot["readOnly"];
22
+ fatalError: RuntimeRenderSnapshot["fatalError"];
23
+ }
24
+
25
+ export interface SurfaceSlice {
26
+ revisionToken: RuntimeRenderSnapshot["revisionToken"];
27
+ selection: RuntimeRenderSnapshot["selection"];
28
+ activeStory: RuntimeRenderSnapshot["activeStory"];
29
+ surface: RuntimeRenderSnapshot["surface"];
30
+ }
31
+
32
+ export interface ReviewSlice {
33
+ comments: RuntimeRenderSnapshot["comments"];
34
+ trackedChanges: RuntimeRenderSnapshot["trackedChanges"];
35
+ compatibility: RuntimeRenderSnapshot["compatibility"];
36
+ }
37
+
38
+ export interface ViewSlice {
39
+ documentMode: RuntimeRenderSnapshot["documentMode"];
40
+ activeStory: RuntimeRenderSnapshot["activeStory"];
41
+ pageLayout: RuntimeRenderSnapshot["pageLayout"];
42
+ }
43
+
44
+ export interface StatusSlice {
45
+ warnings: RuntimeRenderSnapshot["warnings"];
46
+ fatalError: RuntimeRenderSnapshot["fatalError"];
47
+ isDirty: RuntimeRenderSnapshot["isDirty"];
48
+ documentStats: RuntimeRenderSnapshot["documentStats"];
49
+ protectionSnapshot: RuntimeRenderSnapshot["protectionSnapshot"];
50
+ }
51
+
52
+ export interface NavigationSlice {
53
+ documentNavigation: DocumentNavigationSnapshot | undefined;
54
+ }
55
+
56
+ export interface MetaSlice {
57
+ documentId: RuntimeRenderSnapshot["documentId"];
58
+ sessionId: RuntimeRenderSnapshot["sessionId"];
59
+ sourceLabel: RuntimeRenderSnapshot["sourceLabel"];
60
+ }
61
+
62
+ export function selectToolbarSlice(snapshot: RuntimeRenderSnapshot): ToolbarSlice {
63
+ return {
64
+ commandState: snapshot.commandState,
65
+ isDirty: snapshot.isDirty,
66
+ isReady: snapshot.isReady,
67
+ readOnly: snapshot.readOnly,
68
+ fatalError: snapshot.fatalError,
69
+ };
70
+ }
71
+
72
+ export function selectSurfaceSlice(snapshot: RuntimeRenderSnapshot): SurfaceSlice {
73
+ return {
74
+ revisionToken: snapshot.revisionToken,
75
+ selection: snapshot.selection,
76
+ activeStory: snapshot.activeStory,
77
+ surface: snapshot.surface,
78
+ };
79
+ }
80
+
81
+ export function selectReviewSlice(snapshot: RuntimeRenderSnapshot): ReviewSlice {
82
+ return {
83
+ comments: snapshot.comments,
84
+ trackedChanges: snapshot.trackedChanges,
85
+ compatibility: snapshot.compatibility,
86
+ };
87
+ }
88
+
89
+ export function selectViewSlice(snapshot: RuntimeRenderSnapshot): ViewSlice {
90
+ return {
91
+ documentMode: snapshot.documentMode,
92
+ activeStory: snapshot.activeStory,
93
+ pageLayout: snapshot.pageLayout,
94
+ };
95
+ }
96
+
97
+ export function selectStatusSlice(snapshot: RuntimeRenderSnapshot): StatusSlice {
98
+ return {
99
+ warnings: snapshot.warnings,
100
+ fatalError: snapshot.fatalError,
101
+ isDirty: snapshot.isDirty,
102
+ documentStats: snapshot.documentStats,
103
+ protectionSnapshot: snapshot.protectionSnapshot,
104
+ };
105
+ }
106
+
107
+ export function selectNavigationSlice(
108
+ documentNavigation?: DocumentNavigationSnapshot,
109
+ ): NavigationSlice {
110
+ return {
111
+ documentNavigation,
112
+ };
113
+ }
114
+
115
+ export function selectMetaSlice(snapshot: RuntimeRenderSnapshot): MetaSlice {
116
+ return {
117
+ documentId: snapshot.documentId,
118
+ sessionId: snapshot.sessionId,
119
+ sourceLabel: snapshot.sourceLabel,
120
+ };
121
+ }
122
+
123
+ export function shallowEqualRecord<T extends object>(
124
+ left: T,
125
+ right: T,
126
+ ): boolean {
127
+ const leftRecord = left as Record<string, unknown>;
128
+ const rightRecord = right as Record<string, unknown>;
129
+ const leftKeys = Object.keys(left);
130
+ const rightKeys = Object.keys(right);
131
+ if (leftKeys.length !== rightKeys.length) {
132
+ return false;
133
+ }
134
+
135
+ for (const key of leftKeys) {
136
+ if (leftRecord[key] !== rightRecord[key]) {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ return true;
142
+ }
143
+
144
+ export function useRuntimeSnapshotSlice<T>(
145
+ runtime: RuntimeSnapshotSource | null,
146
+ fallbackSnapshot: RuntimeRenderSnapshot,
147
+ selector: (snapshot: RuntimeRenderSnapshot) => T,
148
+ isEqual: (left: T, right: T) => boolean = Object.is,
149
+ ): T {
150
+ const cachedSelectionRef = useRef<T>(selector(fallbackSnapshot));
151
+ return useSyncExternalStore(
152
+ (listener) => runtime?.subscribe(listener) ?? (() => undefined),
153
+ () => {
154
+ const next = selector(runtime?.getRenderSnapshot() ?? fallbackSnapshot);
155
+ if (isEqual(cachedSelectionRef.current, next)) {
156
+ return cachedSelectionRef.current;
157
+ }
158
+ cachedSelectionRef.current = next;
159
+ return next;
160
+ },
161
+ () => {
162
+ const next = selector(runtime?.getRenderSnapshot() ?? fallbackSnapshot);
163
+ if (isEqual(cachedSelectionRef.current, next)) {
164
+ return cachedSelectionRef.current;
165
+ }
166
+ cachedSelectionRef.current = next;
167
+ return next;
168
+ },
169
+ );
170
+ }
171
+
172
+ export function useRuntimeValue<T>(
173
+ source: RuntimeValueSource<T> | null,
174
+ fallbackValue: T,
175
+ isEqual: (left: T, right: T) => boolean = Object.is,
176
+ ): T {
177
+ const cachedValueRef = useRef<T>(fallbackValue);
178
+ return useSyncExternalStore(
179
+ (listener) => source?.subscribe(listener) ?? (() => undefined),
180
+ () => {
181
+ const next = source?.getValue() ?? fallbackValue;
182
+ if (isEqual(cachedValueRef.current, next)) {
183
+ return cachedValueRef.current;
184
+ }
185
+ cachedValueRef.current = next;
186
+ return next;
187
+ },
188
+ () => {
189
+ const next = source?.getValue() ?? fallbackValue;
190
+ if (isEqual(cachedValueRef.current, next)) {
191
+ return cachedValueRef.current;
192
+ }
193
+ cachedValueRef.current = next;
194
+ return next;
195
+ },
196
+ );
197
+ }
@@ -1,15 +1,16 @@
1
1
  import React from "react";
2
2
  import { AlertTriangle, XCircle } from "lucide-react";
3
3
 
4
- import type { RuntimeRenderSnapshot } from "../../api/public-types";
4
+ import type { RuntimeRenderSnapshot, WorkflowBlockedCommandReason } from "../../api/public-types";
5
5
 
6
6
  export interface TwAlertBannerProps {
7
7
  snapshot: RuntimeRenderSnapshot;
8
8
  preserveOnlyCount: number;
9
+ workflowBlockedReasons?: WorkflowBlockedCommandReason[];
9
10
  }
10
11
 
11
12
  export function TwAlertBanner(props: TwAlertBannerProps) {
12
- const { snapshot, preserveOnlyCount } = props;
13
+ const { snapshot, preserveOnlyCount, workflowBlockedReasons = [] } = props;
13
14
 
14
15
  if (snapshot.fatalError) {
15
16
  return (
@@ -44,5 +45,20 @@ export function TwAlertBanner(props: TwAlertBannerProps) {
44
45
  );
45
46
  }
46
47
 
48
+ if (workflowBlockedReasons.length > 0) {
49
+ const firstReason = workflowBlockedReasons[0];
50
+ return (
51
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 text-amber-700 text-xs">
52
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0" />
53
+ <span>
54
+ {firstReason.message}
55
+ {workflowBlockedReasons.length > 1
56
+ ? ` (+${workflowBlockedReasons.length - 1} more)`
57
+ : ""}
58
+ </span>
59
+ </div>
60
+ );
61
+ }
62
+
47
63
  return null;
48
64
  }
@@ -0,0 +1,129 @@
1
+ import React from "react";
2
+
3
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
4
+
5
+ export interface ActiveImageContext {
6
+ mediaId: string;
7
+ display: "inline" | "floating";
8
+ widthEmu?: number;
9
+ heightEmu?: number;
10
+ horizontalOffsetEmu?: number;
11
+ verticalOffsetEmu?: number;
12
+ }
13
+
14
+ export interface TwImageContextToolbarProps {
15
+ activeImage: ActiveImageContext;
16
+ disabled: boolean;
17
+ onSetImageLayout?: (
18
+ mediaId: string,
19
+ dimensions: { widthEmu: number; heightEmu: number },
20
+ ) => void;
21
+ onSetImageFrame?: (
22
+ mediaId: string,
23
+ offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
24
+ ) => void;
25
+ }
26
+
27
+ const IMAGE_SIZE_PRESETS = [
28
+ { label: "Small image", widthEmu: 1828800, heightEmu: 914400 },
29
+ { label: "Medium image", widthEmu: 2743200, heightEmu: 1371600 },
30
+ { label: "Large image", widthEmu: 3657600, heightEmu: 1828800 },
31
+ ] as const;
32
+
33
+ const NUDGE_EMU = 228600;
34
+
35
+ export function TwImageContextToolbar(props: TwImageContextToolbarProps) {
36
+ const { activeImage } = props;
37
+
38
+ return (
39
+ <div
40
+ data-testid="image-context-toolbar"
41
+ className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-canvas px-3 py-2 shadow-sm"
42
+ >
43
+ <span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
44
+ Image
45
+ </span>
46
+ <span className="rounded-full bg-surface px-2 py-1 text-[10px] font-medium uppercase tracking-[0.1em] text-secondary">
47
+ {activeImage.display}
48
+ </span>
49
+ {IMAGE_SIZE_PRESETS.map((preset) => (
50
+ <ToolbarButton
51
+ key={preset.label}
52
+ ariaLabel={preset.label}
53
+ disabled={props.disabled || !props.onSetImageLayout}
54
+ onClick={() =>
55
+ props.onSetImageLayout?.(activeImage.mediaId, {
56
+ widthEmu: preset.widthEmu,
57
+ heightEmu: preset.heightEmu,
58
+ })}
59
+ >
60
+ {preset.label.replace(" image", "")}
61
+ </ToolbarButton>
62
+ ))}
63
+ {activeImage.display === "floating" ? (
64
+ <>
65
+ <ToolbarButton
66
+ ariaLabel="Nudge image left"
67
+ disabled={props.disabled || !props.onSetImageFrame}
68
+ onClick={() =>
69
+ props.onSetImageFrame?.(activeImage.mediaId, {
70
+ horizontalOffsetEmu: (activeImage.horizontalOffsetEmu ?? 0) - NUDGE_EMU,
71
+ })}
72
+ >
73
+ Left
74
+ </ToolbarButton>
75
+ <ToolbarButton
76
+ ariaLabel="Nudge image right"
77
+ disabled={props.disabled || !props.onSetImageFrame}
78
+ onClick={() =>
79
+ props.onSetImageFrame?.(activeImage.mediaId, {
80
+ horizontalOffsetEmu: (activeImage.horizontalOffsetEmu ?? 0) + NUDGE_EMU,
81
+ })}
82
+ >
83
+ Right
84
+ </ToolbarButton>
85
+ <ToolbarButton
86
+ ariaLabel="Nudge image up"
87
+ disabled={props.disabled || !props.onSetImageFrame}
88
+ onClick={() =>
89
+ props.onSetImageFrame?.(activeImage.mediaId, {
90
+ verticalOffsetEmu: (activeImage.verticalOffsetEmu ?? 0) - NUDGE_EMU,
91
+ })}
92
+ >
93
+ Up
94
+ </ToolbarButton>
95
+ <ToolbarButton
96
+ ariaLabel="Nudge image down"
97
+ disabled={props.disabled || !props.onSetImageFrame}
98
+ onClick={() =>
99
+ props.onSetImageFrame?.(activeImage.mediaId, {
100
+ verticalOffsetEmu: (activeImage.verticalOffsetEmu ?? 0) + NUDGE_EMU,
101
+ })}
102
+ >
103
+ Down
104
+ </ToolbarButton>
105
+ </>
106
+ ) : null}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function ToolbarButton(props: {
112
+ ariaLabel: string;
113
+ children: React.ReactNode;
114
+ disabled: boolean;
115
+ onClick?: () => void;
116
+ }) {
117
+ return (
118
+ <button
119
+ type="button"
120
+ aria-label={props.ariaLabel}
121
+ disabled={props.disabled}
122
+ onMouseDown={preserveEditorSelectionMouseDown}
123
+ onClick={props.onClick}
124
+ className="inline-flex h-8 items-center rounded-md px-2 text-xs font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
125
+ >
126
+ {props.children}
127
+ </button>
128
+ );
129
+ }