@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 CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.13",
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
- "pnpm": {
135
- "onlyBuiltDependencies": [
136
- "esbuild",
137
- "sharp"
138
- ],
139
- "overrides": {
140
- "react": "19.2.4",
141
- "react-dom": "19.2.4"
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 = canEdit && !snapshot.selection.isCollapsed;
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
- activeRuntime.addComment({
962
- anchor: snapshot.selection.activeRange,
963
- body: "New review comment",
964
- authorId: currentUser.userId,
965
- });
966
- setActiveRailTab("comments");
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={props.readOnly}
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
- Add comment
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>