@beyondwork/docx-react-component 1.0.82 → 1.0.83

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.82",
4
+ "version": "1.0.83",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -2414,9 +2414,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2414
2414
  function addReviewComment(): string | null {
2415
2415
  try {
2416
2416
  const { commentId } = activeRuntime.addComment({
2417
- anchor: snapshot.selection.activeRange,
2417
+ anchor: resolveCommentCommandAnchor(snapshot),
2418
2418
  body: "",
2419
2419
  authorId: currentUser.userId,
2420
+ snapToSafeBoundary: true,
2420
2421
  });
2421
2422
  activeRuntime.openComment(commentId);
2422
2423
  setActiveRailTab("comments");
@@ -4192,6 +4193,176 @@ function summarizeSelectionPreview(snapshot: RuntimeRenderSnapshot): string | nu
4192
4193
  return preview.length > 48 ? `${preview.slice(0, 45)}...` : preview;
4193
4194
  }
4194
4195
 
4196
+ function resolveCommentCommandAnchor(
4197
+ snapshot: RuntimeRenderSnapshot,
4198
+ ): PublicSelectionSnapshot["activeRange"] {
4199
+ const selection = snapshot.selection;
4200
+ if (!selection.isCollapsed && selection.activeRange.kind === "range") {
4201
+ return selection.activeRange;
4202
+ }
4203
+
4204
+ const collapsedRange = resolveCollapsedCommentRange(snapshot.surface, selection);
4205
+ return collapsedRange
4206
+ ? {
4207
+ kind: "range",
4208
+ from: collapsedRange.from,
4209
+ to: collapsedRange.to,
4210
+ assoc: { start: -1, end: 1 },
4211
+ }
4212
+ : selection.activeRange;
4213
+ }
4214
+
4215
+ function resolveCollapsedCommentRange(
4216
+ surface: RuntimeRenderSnapshot["surface"],
4217
+ selection: RuntimeRenderSnapshot["selection"],
4218
+ ): { from: number; to: number } | null {
4219
+ if (!surface) {
4220
+ return null;
4221
+ }
4222
+
4223
+ const position = selection.activeRange.kind === "node"
4224
+ ? selection.activeRange.at
4225
+ : selection.head;
4226
+ const paragraph =
4227
+ findParagraphRangeAtPosition(surface.blocks, position) ??
4228
+ findFirstNonEmptyParagraphRange(surface.blocks);
4229
+ return paragraph
4230
+ ? resolveWordRangeInsideParagraph(surface.plainText, paragraph, position)
4231
+ : null;
4232
+ }
4233
+
4234
+ function findParagraphRangeAtPosition(
4235
+ blocks: readonly SurfaceBlockSnapshot[],
4236
+ position: number,
4237
+ ): { from: number; to: number } | null {
4238
+ for (const block of blocks) {
4239
+ if (
4240
+ block.kind === "paragraph" &&
4241
+ block.to > block.from &&
4242
+ position >= block.from &&
4243
+ position <= block.to
4244
+ ) {
4245
+ return { from: block.from, to: block.to };
4246
+ }
4247
+ const nested = findParagraphRangeInNestedBlocks(block, position);
4248
+ if (nested) {
4249
+ return nested;
4250
+ }
4251
+ }
4252
+ return null;
4253
+ }
4254
+
4255
+ function findFirstNonEmptyParagraphRange(
4256
+ blocks: readonly SurfaceBlockSnapshot[],
4257
+ ): { from: number; to: number } | null {
4258
+ for (const block of blocks) {
4259
+ if (block.kind === "paragraph" && block.to > block.from) {
4260
+ return { from: block.from, to: block.to };
4261
+ }
4262
+ const nested = findFirstNonEmptyParagraphInNestedBlocks(block);
4263
+ if (nested) {
4264
+ return nested;
4265
+ }
4266
+ }
4267
+ return null;
4268
+ }
4269
+
4270
+ function findParagraphRangeInNestedBlocks(
4271
+ block: SurfaceBlockSnapshot,
4272
+ position: number,
4273
+ ): { from: number; to: number } | null {
4274
+ if (block.kind === "sdt_block") {
4275
+ return findParagraphRangeAtPosition(block.children, position);
4276
+ }
4277
+ if (block.kind === "table") {
4278
+ for (const row of block.rows) {
4279
+ for (const cell of row.cells) {
4280
+ const nested = findParagraphRangeAtPosition(cell.content, position);
4281
+ if (nested) {
4282
+ return nested;
4283
+ }
4284
+ }
4285
+ }
4286
+ }
4287
+ return null;
4288
+ }
4289
+
4290
+ function findFirstNonEmptyParagraphInNestedBlocks(
4291
+ block: SurfaceBlockSnapshot,
4292
+ ): { from: number; to: number } | null {
4293
+ if (block.kind === "sdt_block") {
4294
+ return findFirstNonEmptyParagraphRange(block.children);
4295
+ }
4296
+ if (block.kind === "table") {
4297
+ for (const row of block.rows) {
4298
+ for (const cell of row.cells) {
4299
+ const nested = findFirstNonEmptyParagraphRange(cell.content);
4300
+ if (nested) {
4301
+ return nested;
4302
+ }
4303
+ }
4304
+ }
4305
+ }
4306
+ return null;
4307
+ }
4308
+
4309
+ function resolveWordRangeInsideParagraph(
4310
+ plainText: string,
4311
+ paragraph: { from: number; to: number },
4312
+ position: number,
4313
+ ): { from: number; to: number } {
4314
+ const paragraphFrom = paragraph.from;
4315
+ const paragraphTo = paragraph.to;
4316
+ const fallback = { from: paragraphFrom, to: paragraphTo };
4317
+ if (paragraphTo <= paragraphFrom) {
4318
+ return fallback;
4319
+ }
4320
+
4321
+ let cursor = Math.max(paragraphFrom, Math.min(position, paragraphTo - 1));
4322
+ if (isCommentWordBoundary(plainText.charAt(cursor))) {
4323
+ const next = findNearestCommentTextOffset(plainText, cursor, paragraphFrom, paragraphTo);
4324
+ if (next === null) {
4325
+ return fallback;
4326
+ }
4327
+ cursor = next;
4328
+ }
4329
+
4330
+ let from = cursor;
4331
+ while (from > paragraphFrom && !isCommentWordBoundary(plainText.charAt(from - 1))) {
4332
+ from -= 1;
4333
+ }
4334
+
4335
+ let to = cursor + 1;
4336
+ while (to < paragraphTo && !isCommentWordBoundary(plainText.charAt(to))) {
4337
+ to += 1;
4338
+ }
4339
+
4340
+ return to > from ? { from, to } : fallback;
4341
+ }
4342
+
4343
+ function findNearestCommentTextOffset(
4344
+ plainText: string,
4345
+ cursor: number,
4346
+ from: number,
4347
+ to: number,
4348
+ ): number | null {
4349
+ for (let distance = 1; distance < to - from; distance += 1) {
4350
+ const right = cursor + distance;
4351
+ if (right < to && !isCommentWordBoundary(plainText.charAt(right))) {
4352
+ return right;
4353
+ }
4354
+ const left = cursor - distance;
4355
+ if (left >= from && !isCommentWordBoundary(plainText.charAt(left))) {
4356
+ return left;
4357
+ }
4358
+ }
4359
+ return null;
4360
+ }
4361
+
4362
+ function isCommentWordBoundary(value: string): boolean {
4363
+ return value.length === 0 || /\s/.test(value);
4364
+ }
4365
+
4195
4366
  function selectionToolbarAnchorsEqual(
4196
4367
  left: SelectionToolbarAnchor | null,
4197
4368
  right: SelectionToolbarAnchor | null,
@@ -2,6 +2,7 @@ import { useMemo } from "react";
2
2
 
3
3
  import type {
4
4
  ActiveListContext,
5
+ EditorStoryTarget,
5
6
  EditorViewStateSnapshot,
6
7
  InteractionGuardSnapshot,
7
8
  WordReviewEditorChromePreset,
@@ -24,6 +25,7 @@ export interface UseChromePolicyOptions {
24
25
  role: EditorViewStateSnapshot["editorRole"];
25
26
  hasSidebarPanelAccess: boolean;
26
27
  effectiveSelectionMode: ToolbarInteractionPolicy["mode"];
28
+ activeStoryKind?: EditorStoryTarget["kind"];
27
29
  }
28
30
 
29
31
  export interface ChromePolicy {
@@ -52,8 +54,22 @@ export function useChromePolicy(options: UseChromePolicyOptions): ChromePolicy {
52
54
  role,
53
55
  hasSidebarPanelAccess,
54
56
  effectiveSelectionMode,
57
+ activeStoryKind,
55
58
  } = options;
56
59
 
60
+ const canRunCommentCommand = resolveTopBarCommentAvailability({
61
+ caps,
62
+ effectiveSelectionMode,
63
+ activeStoryKind,
64
+ });
65
+ const chromeCaps = useMemo(
66
+ () =>
67
+ caps && caps.canAddComment !== canRunCommentCommand
68
+ ? { ...caps, canAddComment: canRunCommentCommand }
69
+ : caps,
70
+ [canRunCommentCommand, caps],
71
+ );
72
+
57
73
  const responsiveChrome = useMemo(
58
74
  () =>
59
75
  resolveResponsiveChromeState({
@@ -69,7 +85,7 @@ export function useChromePolicy(options: UseChromePolicyOptions): ChromePolicy {
69
85
  resolveScopedChromePolicy({
70
86
  preset: chromePreset,
71
87
  compactMode: responsiveChrome.isNarrow,
72
- capabilities: caps,
88
+ capabilities: chromeCaps,
73
89
  interactionGuardSnapshot,
74
90
  workflowScopeSnapshot,
75
91
  activeListContext,
@@ -77,7 +93,7 @@ export function useChromePolicy(options: UseChromePolicyOptions): ChromePolicy {
77
93
  hasSidebarPanelAccess,
78
94
  }),
79
95
  [
80
- caps,
96
+ chromeCaps,
81
97
  chromePreset,
82
98
  hasSidebarPanelAccess,
83
99
  activeListContext,
@@ -93,12 +109,22 @@ export function useChromePolicy(options: UseChromePolicyOptions): ChromePolicy {
93
109
  mode: effectiveSelectionMode,
94
110
  canFormatText: caps.canEdit && effectiveSelectionMode === "edit",
95
111
  canInsertStructural: caps.canEdit && effectiveSelectionMode === "edit",
96
- canAddComment:
97
- caps.canAddComment &&
98
- effectiveSelectionMode !== "view" &&
99
- effectiveSelectionMode !== "blocked",
112
+ canAddComment: canRunCommentCommand,
100
113
  }
101
114
  : undefined;
102
115
 
103
116
  return { responsiveChrome, scopedChromePolicy, toolbarInteractionPolicy };
104
117
  }
118
+
119
+ export function resolveTopBarCommentAvailability(input: {
120
+ caps: SessionCapabilities | undefined;
121
+ effectiveSelectionMode: ToolbarInteractionPolicy["mode"];
122
+ activeStoryKind?: EditorStoryTarget["kind"];
123
+ }): boolean {
124
+ const { caps, effectiveSelectionMode, activeStoryKind } = input;
125
+ if (!caps) return false;
126
+ if (caps.phase !== "ready" || caps.hasFatalError) return false;
127
+ if (caps.documentMode === "viewing") return false;
128
+ if (activeStoryKind !== undefined && activeStoryKind !== "main") return false;
129
+ return effectiveSelectionMode !== "view" && effectiveSelectionMode !== "blocked";
130
+ }
@@ -139,6 +139,7 @@ export function TwRoleActionRegion(
139
139
  for (const id of order) {
140
140
  const entry = props.policy.toolbar[id];
141
141
  if (!entry?.visible) continue;
142
+ if (!isRoleActionRenderable(id, props)) continue;
142
143
  if (entry.placement === "overflow") {
143
144
  overflowIds.push(id);
144
145
  } else if (entry.placement === "inline") {
@@ -171,6 +172,26 @@ export function TwRoleActionRegion(
171
172
  );
172
173
  }
173
174
 
175
+ function isRoleActionRenderable(
176
+ id: ToolbarChromeItemId,
177
+ props: TwRoleActionRegionProps,
178
+ ): boolean {
179
+ const reviewQueueTotal = props.reviewQueue?.totalCount ?? 0;
180
+ switch (id) {
181
+ case "review-queue-prev":
182
+ case "review-queue-next":
183
+ case "review-queue-counts":
184
+ case "review-queue-active-label":
185
+ case "review-accept":
186
+ case "review-reject":
187
+ case "review-accept-all":
188
+ case "review-reject-all":
189
+ return reviewQueueTotal > 0;
190
+ default:
191
+ return true;
192
+ }
193
+ }
194
+
174
195
  interface RoleActionButtonProps {
175
196
  id: ToolbarChromeItemId;
176
197
  compact: boolean;
@@ -357,6 +357,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
357
357
  role: viewState.editorRole,
358
358
  hasSidebarPanelAccess,
359
359
  effectiveSelectionMode,
360
+ activeStoryKind: snapshot.activeStory.kind,
360
361
  });
361
362
 
362
363
  // L7 Phase 2 Task 2.2.4a — viewport-scroll wiring. Page marker