@beyondwork/docx-react-component 1.0.19 → 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 +44 -25
- package/src/api/public-types.ts +336 -0
- package/src/api/session-state.ts +2 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +14 -2
- package/src/core/search/search-text.ts +28 -0
- package/src/core/state/editor-state.ts +3 -0
- package/src/index.ts +21 -0
- package/src/io/docx-session.ts +363 -17
- package/src/io/export/serialize-comments.ts +104 -34
- package/src/io/export/serialize-footnotes.ts +198 -1
- package/src/io/export/serialize-headers-footers.ts +203 -10
- package/src/io/export/serialize-main-document.ts +83 -3
- package/src/io/export/split-review-boundaries.ts +181 -19
- package/src/io/normalize/normalize-text.ts +82 -8
- package/src/io/ooxml/highlight-colors.ts +39 -0
- package/src/io/ooxml/parse-comments.ts +85 -19
- package/src/io/ooxml/parse-fields.ts +396 -0
- package/src/io/ooxml/parse-footnotes.ts +240 -2
- package/src/io/ooxml/parse-headers-footers.ts +431 -7
- package/src/io/ooxml/parse-inline-media.ts +15 -1
- package/src/io/ooxml/parse-main-document.ts +396 -14
- package/src/io/ooxml/parse-revisions.ts +317 -38
- package/src/legal/bookmarks.ts +44 -0
- package/src/legal/cross-references.ts +59 -1
- package/src/model/canonical-document.ts +117 -1
- package/src/model/snapshot.ts +85 -1
- package/src/review/store/revision-store.ts +6 -0
- package/src/review/store/revision-types.ts +1 -0
- package/src/runtime/document-navigation.ts +52 -13
- package/src/runtime/document-runtime.ts +1521 -75
- package/src/runtime/read-only-diagnostics-runtime.ts +8 -0
- package/src/runtime/session-capabilities.ts +33 -3
- package/src/runtime/surface-projection.ts +86 -25
- package/src/runtime/table-schema.ts +2 -2
- package/src/runtime/view-state.ts +24 -6
- package/src/runtime/workflow-markup.ts +349 -0
- package/src/ui/WordReviewEditor.tsx +915 -1314
- package/src/ui/editor-command-bag.ts +120 -0
- package/src/ui/editor-runtime-boundary.ts +1448 -0
- package/src/ui/editor-shell-view.tsx +134 -0
- package/src/ui/editor-surface-controller.tsx +55 -0
- package/src/ui/headless/revision-decoration-model.ts +4 -4
- package/src/ui/runtime-snapshot-selectors.ts +197 -0
- package/src/ui/workflow-surface-blocked-rails.ts +94 -0
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +237 -0
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
- package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +55 -0
- package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +190 -48
- package/src/ui-tailwind/page-chrome-model.ts +27 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
- package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
- package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
- package/src/ui-tailwind/theme/editor-theme.css +130 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
- package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
- package/src/validation/compatibility-engine.ts +27 -4
- package/src/validation/compatibility-report.ts +1 -0
- package/src/validation/docx-comment-proof.ts +220 -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,55 @@
|
|
|
1
|
+
import React, { forwardRef } from "react";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
DocumentNavigationSnapshot,
|
|
5
|
+
EditorUser,
|
|
6
|
+
RuntimeRenderSnapshot,
|
|
7
|
+
SelectionSnapshot,
|
|
8
|
+
WorkflowBlockedCommandReason,
|
|
9
|
+
WorkflowCandidateRange,
|
|
10
|
+
WorkflowScope,
|
|
11
|
+
} from "../api/public-types.ts";
|
|
12
|
+
import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
|
|
13
|
+
import type { MarkupDisplay } from "./headless/comment-decoration-model.ts";
|
|
14
|
+
import type { SelectionToolbarAnchor } from "./headless/selection-toolbar-model.ts";
|
|
15
|
+
import {
|
|
16
|
+
TwProseMirrorSurface,
|
|
17
|
+
type TwProseMirrorSurfaceRef,
|
|
18
|
+
} from "../ui-tailwind/editor-surface/tw-prosemirror-surface.tsx";
|
|
19
|
+
import type { MediaPreviewDescriptor } from "../ui-tailwind/editor-surface/pm-state-from-snapshot.ts";
|
|
20
|
+
|
|
21
|
+
export interface EditorSurfaceControllerProps {
|
|
22
|
+
currentUser: EditorUser;
|
|
23
|
+
snapshot: RuntimeRenderSnapshot;
|
|
24
|
+
canonicalDocument: CanonicalDocumentEnvelope;
|
|
25
|
+
documentNavigation: DocumentNavigationSnapshot;
|
|
26
|
+
reviewMode: "editing" | "review";
|
|
27
|
+
markupDisplay: MarkupDisplay;
|
|
28
|
+
activeRevisionId?: string;
|
|
29
|
+
showTrackedChanges?: boolean;
|
|
30
|
+
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
31
|
+
isPageWorkspace?: boolean;
|
|
32
|
+
onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
|
|
33
|
+
onFocus: React.FocusEventHandler<HTMLDivElement>;
|
|
34
|
+
onBlur: React.FocusEventHandler<HTMLDivElement>;
|
|
35
|
+
onSelectionChange?: (selection: SelectionSnapshot) => void;
|
|
36
|
+
onInsertText?: (text: string) => void;
|
|
37
|
+
onDeleteBackward?: () => void;
|
|
38
|
+
onDeleteForward?: () => void;
|
|
39
|
+
onInsertTab?: () => void;
|
|
40
|
+
onOutdentTab?: () => void;
|
|
41
|
+
onInsertHardBreak?: () => void;
|
|
42
|
+
onSplitParagraph?: () => void;
|
|
43
|
+
onCommentActivated?: (commentId: string) => void;
|
|
44
|
+
onRevisionActivated?: (revisionId: string) => void;
|
|
45
|
+
workflowScopes?: readonly WorkflowScope[];
|
|
46
|
+
workflowCandidates?: readonly WorkflowCandidateRange[];
|
|
47
|
+
workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const EditorSurfaceController = forwardRef<
|
|
51
|
+
TwProseMirrorSurfaceRef,
|
|
52
|
+
EditorSurfaceControllerProps
|
|
53
|
+
>(function EditorSurfaceController(props, ref) {
|
|
54
|
+
return <TwProseMirrorSurface ref={ref} {...props} />;
|
|
55
|
+
});
|
|
@@ -101,18 +101,18 @@ export function getRevisionHighlightClass(
|
|
|
101
101
|
return "";
|
|
102
102
|
case "simple":
|
|
103
103
|
if (state.hasInsertions) {
|
|
104
|
-
return `underline decoration-insert/
|
|
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-
|
|
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|