@beyondwork/docx-react-component 1.0.13 → 1.0.14
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 +23 -34
- package/src/core/selection/review-anchors.ts +89 -0
- package/src/runtime/document-runtime.ts +13 -0
- package/src/runtime/session-capabilities.ts +22 -1
- package/src/ui/WordReviewEditor.tsx +15 -6
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +8 -2
- package/src/ui-tailwind/tw-review-workspace.tsx +3 -0
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.14",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
|
-
"packageManager": "pnpm@10.30.3",
|
|
7
6
|
"type": "module",
|
|
8
7
|
"sideEffects": [
|
|
9
8
|
"**/*.css"
|
|
@@ -46,29 +45,6 @@
|
|
|
46
45
|
"./package.json": "./package.json"
|
|
47
46
|
},
|
|
48
47
|
"types": "./src/index.ts",
|
|
49
|
-
"scripts": {
|
|
50
|
-
"build": "tsup",
|
|
51
|
-
"test": "bash scripts/run-workspace-tests.sh",
|
|
52
|
-
"test:repo": "node scripts/run-repo-tests.mjs core",
|
|
53
|
-
"test:repo:all": "node scripts/run-repo-tests.mjs all",
|
|
54
|
-
"test:repo:optional": "node scripts/run-repo-tests.mjs optional",
|
|
55
|
-
"test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
|
|
56
|
-
"test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
|
|
57
|
-
"lint": "pnpm run lint:no-authored-js && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
|
|
58
|
-
"lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
|
|
59
|
-
"lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
|
|
60
|
-
"lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
|
|
61
|
-
"context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
|
|
62
|
-
"wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
|
|
63
|
-
"wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
|
|
64
|
-
"wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
|
|
65
|
-
"wave:launch:managed": "bash scripts/wave-launch.sh",
|
|
66
|
-
"wave:status": "bash scripts/wave-status.sh",
|
|
67
|
-
"wave:watch": "bash scripts/wave-watch.sh --follow",
|
|
68
|
-
"wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
|
|
69
|
-
"wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
|
|
70
|
-
"harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
|
|
71
|
-
},
|
|
72
48
|
"keywords": [
|
|
73
49
|
"docx",
|
|
74
50
|
"word",
|
|
@@ -131,14 +107,27 @@
|
|
|
131
107
|
"tsup": "^8.3.0",
|
|
132
108
|
"tsx": "^4.21.0"
|
|
133
109
|
},
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
"
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
110
|
+
"scripts": {
|
|
111
|
+
"build": "tsup",
|
|
112
|
+
"test": "bash scripts/run-workspace-tests.sh",
|
|
113
|
+
"test:repo": "node scripts/run-repo-tests.mjs core",
|
|
114
|
+
"test:repo:all": "node scripts/run-repo-tests.mjs all",
|
|
115
|
+
"test:repo:optional": "node scripts/run-repo-tests.mjs optional",
|
|
116
|
+
"test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
|
|
117
|
+
"test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
|
|
118
|
+
"lint": "pnpm run lint:no-authored-js && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
|
|
119
|
+
"lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
|
|
120
|
+
"lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
|
|
121
|
+
"lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
|
|
122
|
+
"context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
|
|
123
|
+
"wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
|
|
124
|
+
"wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
|
|
125
|
+
"wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
|
|
126
|
+
"wave:launch:managed": "bash scripts/wave-launch.sh",
|
|
127
|
+
"wave:status": "bash scripts/wave-status.sh",
|
|
128
|
+
"wave:watch": "bash scripts/wave-watch.sh --follow",
|
|
129
|
+
"wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
|
|
130
|
+
"wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
|
|
131
|
+
"harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
|
|
143
132
|
}
|
|
144
133
|
}
|
|
@@ -76,6 +76,16 @@ export function rangeStaysWithinSingleParagraph(
|
|
|
76
76
|
return true;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
const surfaceBlocks = readSurfaceBlocks(content);
|
|
80
|
+
if (surfaceBlocks) {
|
|
81
|
+
return surfaceBlocks.some(
|
|
82
|
+
(block) =>
|
|
83
|
+
block.kind === "paragraph" &&
|
|
84
|
+
normalized.from >= block.from &&
|
|
85
|
+
normalized.to <= block.to,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
const story = parseTextStory(content);
|
|
80
90
|
const upperBound = Math.min(normalized.to, story.units.length);
|
|
81
91
|
|
|
@@ -92,3 +102,82 @@ export function rangeStaysWithinSingleParagraph(
|
|
|
92
102
|
|
|
93
103
|
return true;
|
|
94
104
|
}
|
|
105
|
+
|
|
106
|
+
export function canCreateDocxCommentAnchor(
|
|
107
|
+
content: unknown,
|
|
108
|
+
anchor: ReviewAnchor,
|
|
109
|
+
): boolean {
|
|
110
|
+
if (anchor.kind !== "range") {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const normalized = normalizeRange(anchor.range);
|
|
115
|
+
if (normalized.from === normalized.to) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return rangeStaysWithinSingleParagraph(content, normalized);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readSurfaceBlocks(
|
|
123
|
+
content: unknown,
|
|
124
|
+
): Array<{ kind: string; from: number; to: number }> | undefined {
|
|
125
|
+
if (!content || typeof content !== "object" || !("blocks" in content)) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const blocks = (content as { blocks?: unknown }).blocks;
|
|
130
|
+
if (!Array.isArray(blocks)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const normalized = flattenSurfaceBlocks(blocks);
|
|
135
|
+
|
|
136
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function flattenSurfaceBlocks(
|
|
140
|
+
blocks: unknown[],
|
|
141
|
+
): Array<{ kind: string; from: number; to: number }> {
|
|
142
|
+
const flattened: Array<{ kind: string; from: number; to: number }> = [];
|
|
143
|
+
|
|
144
|
+
for (const block of blocks) {
|
|
145
|
+
if (
|
|
146
|
+
!block ||
|
|
147
|
+
typeof block !== "object" ||
|
|
148
|
+
typeof (block as { kind?: unknown }).kind !== "string" ||
|
|
149
|
+
typeof (block as { from?: unknown }).from !== "number" ||
|
|
150
|
+
typeof (block as { to?: unknown }).to !== "number"
|
|
151
|
+
) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
flattened.push({
|
|
156
|
+
kind: (block as { kind: string }).kind,
|
|
157
|
+
from: (block as { from: number }).from,
|
|
158
|
+
to: (block as { to: number }).to,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
(block as { kind: string }).kind === "table" &&
|
|
163
|
+
Array.isArray((block as { rows?: unknown }).rows)
|
|
164
|
+
) {
|
|
165
|
+
for (const row of (block as { rows: Array<{ cells?: unknown[] }> }).rows) {
|
|
166
|
+
for (const cell of row.cells ?? []) {
|
|
167
|
+
if (cell && typeof cell === "object" && Array.isArray((cell as { content?: unknown[] }).content)) {
|
|
168
|
+
flattened.push(...flattenSurfaceBlocks((cell as { content: unknown[] }).content));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
(block as { kind: string }).kind === "sdt_block" &&
|
|
176
|
+
Array.isArray((block as { children?: unknown[] }).children)
|
|
177
|
+
) {
|
|
178
|
+
flattened.push(...flattenSurfaceBlocks((block as { children: unknown[] }).children));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return flattened;
|
|
183
|
+
}
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
createRangeAnchor,
|
|
42
42
|
type EditorAnchorProjection as InternalEditorAnchorProjection,
|
|
43
43
|
} from "../core/selection/mapping.ts";
|
|
44
|
+
import { canCreateDocxCommentAnchor } from "../core/selection/review-anchors.ts";
|
|
44
45
|
import { createCommentSidebarProjection } from "../review/store/comment-store.ts";
|
|
45
46
|
import { createCommentStoreFromRuntimeComments } from "../review/store/runtime-comment-store.ts";
|
|
46
47
|
import {
|
|
@@ -212,6 +213,18 @@ export function createDocumentRuntime(
|
|
|
212
213
|
const anchor = params.anchor
|
|
213
214
|
? toInternalAnchorProjection(params.anchor)
|
|
214
215
|
: state.selection.activeRange;
|
|
216
|
+
if (!canCreateDocxCommentAnchor(state.document.content, anchor)) {
|
|
217
|
+
const message =
|
|
218
|
+
"DOCX comments must use a non-empty range that stays within a single paragraph.";
|
|
219
|
+
emitError({
|
|
220
|
+
errorId: createSessionId("comment-anchor", clock()),
|
|
221
|
+
code: "validation_failed",
|
|
222
|
+
isFatal: false,
|
|
223
|
+
message,
|
|
224
|
+
source: "runtime",
|
|
225
|
+
});
|
|
226
|
+
throw new Error(message);
|
|
227
|
+
}
|
|
215
228
|
const authorId = params.authorId ?? options.defaultAuthorId ?? "unknown";
|
|
216
229
|
const createdAt = clock();
|
|
217
230
|
const entries: CommentEntryRecord[] = [
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { RuntimeRenderSnapshot } from "../api/public-types";
|
|
2
|
+
import {
|
|
3
|
+
createDetachedAnchor,
|
|
4
|
+
createNodeAnchor,
|
|
5
|
+
createRangeAnchor,
|
|
6
|
+
} from "../core/selection/mapping.ts";
|
|
7
|
+
import { canCreateDocxCommentAnchor } from "../core/selection/review-anchors";
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Session capabilities derived from the runtime snapshot.
|
|
@@ -80,7 +86,11 @@ export function deriveCapabilities(
|
|
|
80
86
|
const canEdit = isReady && !isReadOnly && !hasFatalError;
|
|
81
87
|
const canUndo = snapshot.commandState.canUndo && canEdit;
|
|
82
88
|
const canRedo = snapshot.commandState.canRedo && canEdit;
|
|
83
|
-
const canAddComment =
|
|
89
|
+
const canAddComment =
|
|
90
|
+
canEdit &&
|
|
91
|
+
!snapshot.selection.isCollapsed &&
|
|
92
|
+
Boolean(snapshot.surface) &&
|
|
93
|
+
canCreateDocxCommentAnchor(snapshot.surface, toRuntimeAnchor(snapshot.selection.activeRange));
|
|
84
94
|
const canExport = isReady && !exportBlocked && !hasFatalError;
|
|
85
95
|
|
|
86
96
|
// Revision capabilities
|
|
@@ -136,3 +146,14 @@ export function deriveCapabilities(
|
|
|
136
146
|
hasFatalError,
|
|
137
147
|
};
|
|
138
148
|
}
|
|
149
|
+
|
|
150
|
+
function toRuntimeAnchor(anchor: RuntimeRenderSnapshot["selection"]["activeRange"]) {
|
|
151
|
+
switch (anchor.kind) {
|
|
152
|
+
case "range":
|
|
153
|
+
return createRangeAnchor(anchor.from, anchor.to, anchor.assoc);
|
|
154
|
+
case "node":
|
|
155
|
+
return createNodeAnchor(anchor.at, anchor.assoc);
|
|
156
|
+
case "detached":
|
|
157
|
+
return createDetachedAnchor(anchor.lastKnownRange, anchor.reason);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -958,12 +958,16 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
958
958
|
}
|
|
959
959
|
|
|
960
960
|
function addReviewComment(): void {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
961
|
+
try {
|
|
962
|
+
activeRuntime.addComment({
|
|
963
|
+
anchor: snapshot.selection.activeRange,
|
|
964
|
+
body: "New review comment",
|
|
965
|
+
authorId: currentUser.userId,
|
|
966
|
+
});
|
|
967
|
+
setActiveRailTab("comments");
|
|
968
|
+
} catch {
|
|
969
|
+
// Runtime already emitted a concrete export-safety error for invalid anchors.
|
|
970
|
+
}
|
|
967
971
|
}
|
|
968
972
|
|
|
969
973
|
function exportCurrentDocument(): void {
|
|
@@ -991,6 +995,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
991
995
|
? derivedCapabilities
|
|
992
996
|
: { ...derivedCapabilities, reviewRailVisible: false };
|
|
993
997
|
const diagnosticsModeMessage = getDiagnosticsModeMessage(loadError ?? snapshot.fatalError);
|
|
998
|
+
const addCommentDisabledReason =
|
|
999
|
+
!capabilities.canAddComment && !snapshot.selection.isCollapsed
|
|
1000
|
+
? "Select text within one paragraph to add a DOCX comment."
|
|
1001
|
+
: undefined;
|
|
994
1002
|
const accessibilityInstructionsId = `${documentId}-accessibility-instructions`;
|
|
995
1003
|
const accessibilityStatusId = `${documentId}-accessibility-status`;
|
|
996
1004
|
const accessibilityAlertId = `${documentId}-accessibility-alert`;
|
|
@@ -1147,6 +1155,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
1147
1155
|
activeRevisionId={activeRevisionId}
|
|
1148
1156
|
showTrackedChanges={showTrackedChanges}
|
|
1149
1157
|
selectionPreview={selectionPreview}
|
|
1158
|
+
addCommentDisabledReason={addCommentDisabledReason}
|
|
1150
1159
|
onViewModeChange={setViewMode}
|
|
1151
1160
|
onActiveRailTabChange={setActiveRailTab}
|
|
1152
1161
|
onShowTrackedChangesChange={setShowTrackedChanges}
|
|
@@ -5,6 +5,8 @@ import { MessageSquare } from "lucide-react";
|
|
|
5
5
|
export interface TwSelectionToolbarProps {
|
|
6
6
|
selectionPreview: string;
|
|
7
7
|
readOnly: boolean;
|
|
8
|
+
canAddComment?: boolean;
|
|
9
|
+
disabledReason?: string;
|
|
8
10
|
onAddComment?: () => void;
|
|
9
11
|
}
|
|
10
12
|
|
|
@@ -12,6 +14,10 @@ const focusRingClass =
|
|
|
12
14
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
|
|
13
15
|
|
|
14
16
|
export function TwSelectionToolbar(props: TwSelectionToolbarProps) {
|
|
17
|
+
const addCommentDisabled = props.readOnly || props.canAddComment === false;
|
|
18
|
+
const tooltipLabel = addCommentDisabled
|
|
19
|
+
? props.disabledReason ?? "Select text within one paragraph to add a DOCX comment"
|
|
20
|
+
: "Add comment";
|
|
15
21
|
return (
|
|
16
22
|
<div className="mb-6 inline-flex items-center gap-1 rounded-lg bg-canvas shadow-lg ring-1 ring-border p-1">
|
|
17
23
|
<Tooltip.Root>
|
|
@@ -19,7 +25,7 @@ export function TwSelectionToolbar(props: TwSelectionToolbarProps) {
|
|
|
19
25
|
<button
|
|
20
26
|
type="button"
|
|
21
27
|
aria-label="Comment"
|
|
22
|
-
disabled={
|
|
28
|
+
disabled={addCommentDisabled}
|
|
23
29
|
onClick={props.onAddComment}
|
|
24
30
|
className={`inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors text-accent hover:bg-accent-soft disabled:opacity-30 disabled:cursor-not-allowed ${focusRingClass}`}
|
|
25
31
|
>
|
|
@@ -31,7 +37,7 @@ export function TwSelectionToolbar(props: TwSelectionToolbarProps) {
|
|
|
31
37
|
className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
|
|
32
38
|
sideOffset={6}
|
|
33
39
|
>
|
|
34
|
-
|
|
40
|
+
{tooltipLabel}
|
|
35
41
|
</Tooltip.Content>
|
|
36
42
|
</Tooltip.Portal>
|
|
37
43
|
</Tooltip.Root>
|
|
@@ -27,6 +27,7 @@ export interface TwReviewWorkspaceProps {
|
|
|
27
27
|
activeRevisionId?: string;
|
|
28
28
|
showTrackedChanges: boolean;
|
|
29
29
|
selectionPreview?: string | null;
|
|
30
|
+
addCommentDisabledReason?: string;
|
|
30
31
|
onViewModeChange: (value: ViewMode) => void;
|
|
31
32
|
onActiveRailTabChange: (value: ReviewRailTab) => void;
|
|
32
33
|
onShowTrackedChangesChange: (show: boolean) => void;
|
|
@@ -92,6 +93,8 @@ export function TwReviewWorkspace(props: TwReviewWorkspaceProps) {
|
|
|
92
93
|
<TwSelectionToolbar
|
|
93
94
|
selectionPreview={props.selectionPreview}
|
|
94
95
|
readOnly={snapshot.readOnly}
|
|
96
|
+
canAddComment={props.capabilities?.canAddComment}
|
|
97
|
+
disabledReason={props.addCommentDisabledReason}
|
|
95
98
|
onAddComment={props.onAddComment}
|
|
96
99
|
/>
|
|
97
100
|
</div>
|