@beyondwork/docx-react-component 1.0.81 → 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.81",
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": [
@@ -5614,7 +5614,8 @@ export interface WordReviewEditorProps {
5614
5614
  * Optional host-callback extension bag for workspace command chrome.
5615
5615
  * The default `<WordReviewEditor />` path now mounts
5616
5616
  * `TwWorkspaceChromeHost` with product-backed commands for formatting,
5617
- * paragraph/list actions, comments, and table insertion/structure.
5617
+ * paragraph/list/style/font/color actions, search/navigation host
5618
+ * delegation, comments, and table insertion/structure.
5618
5619
  * Supplying this bag overrides or extends those defaults for host-owned
5619
5620
  * actions such as custom table properties, hyperlink handling, or
5620
5621
  * object metadata. Actions without a wired callback are hidden from
@@ -43,6 +43,7 @@ export type RuntimeApiHandle = Pick<
43
43
  DocumentRuntime,
44
44
  // Session + export (runtime.document family)
45
45
  | "getSessionState"
46
+ | "setDocumentMode"
46
47
  | "exportDocx"
47
48
  | "getCompatibilityReport"
48
49
  | "getWarnings"
@@ -54,6 +55,7 @@ export type RuntimeApiHandle = Pick<
54
55
  | "findAllText"
55
56
  // Review (runtime.review family)
56
57
  | "getReviewWorkSnapshot"
58
+ | "getSuggestionsSnapshot"
57
59
  | "acceptChange"
58
60
  | "rejectChange"
59
61
  | "resolveComment"
@@ -136,6 +138,7 @@ export type RuntimeApiHandle = Pick<
136
138
  */
137
139
  export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true> = {
138
140
  getSessionState: true,
141
+ setDocumentMode: true,
139
142
  exportDocx: true,
140
143
  getCompatibilityReport: true,
141
144
  getWarnings: true,
@@ -143,6 +146,7 @@ export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true
143
146
  getCanonicalDocument: true,
144
147
  findAllText: true,
145
148
  getReviewWorkSnapshot: true,
149
+ getSuggestionsSnapshot: true,
146
150
  acceptChange: true,
147
151
  rejectChange: true,
148
152
  resolveComment: true,
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * @endStateApi v3 — `runtime.document` family.
3
3
  *
4
- * See docs/reference/public-api.md § runtime.document. Three functions:
4
+ * See docs/reference/public-api.md § runtime.document.
5
5
  * `load` (partial — runtime pre-load is the caller's responsibility; v3
6
- * exposes a re-mount semantic later), `export` (live; delegates to
7
- * `runtime.exportDocx`), `validate` (partial; read live, write mock).
6
+ * exposes a re-mount semantic later), `getMode` / `setMode` (live;
7
+ * delegates to the runtime view-state posture), `export` (live; delegates
8
+ * to `runtime.exportDocx`), `validate` (partial; read live, write mock).
8
9
  */
9
10
 
10
11
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
11
12
  import type {
13
+ DocumentMode,
12
14
  EditorError,
13
15
  ExportDocxOptions,
14
16
  ExportResult,
@@ -88,6 +90,42 @@ export const loadMetadata: ApiV3FnMetadata = {
88
90
  "§Runtime API § runtime.document.load. Graduation (2026-04-22, post-eb7d14fa): `live` via direct delegation to `loadDocxSessionAsync` (src/session/import/loader.ts). Returns a PersistedEditorSnapshot the caller can pass to DocxSession.reopenFromSnapshot or persist for later rehydrate. Note per arch §R8 Option B: v3 does NOT construct the receiving DocumentRuntime — that's the caller's job via createDocumentRuntime(initialSessionState).",
89
91
  };
90
92
 
93
+ /* ================================================================== */
94
+ /* mode */
95
+ /* ================================================================== */
96
+
97
+ export const getModeMetadata: ApiV3FnMetadata = {
98
+ name: "runtime.document.getMode",
99
+ status: "live",
100
+ sourceLayer: "runtime-core",
101
+ liveEvidence: {
102
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
103
+ commit: "refactor-03-tracked-changes-v1-api-adapter",
104
+ },
105
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
106
+ agentMetadata: { readOrMutate: "read", boundedScope: "document", auditCategory: "document-mode" },
107
+ stateClass: "C-local",
108
+ persistsTo: "none",
109
+ rwdReference:
110
+ "§Runtime API § runtime.document.getMode. Live adapter over the runtime render snapshot's DocumentMode; suggesting remains the tracked-change authoring posture.",
111
+ };
112
+
113
+ export const setModeMetadata: ApiV3FnMetadata = {
114
+ name: "runtime.document.setMode",
115
+ status: "live",
116
+ sourceLayer: "runtime-core",
117
+ liveEvidence: {
118
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
119
+ commit: "refactor-03-tracked-changes-v1-api-adapter",
120
+ },
121
+ uxIntent: { uiVisible: true, expectsUxResponse: "surface-refresh", expectedDelta: "document mode changes" },
122
+ agentMetadata: { readOrMutate: "mutate", boundedScope: "document", auditCategory: "document-mode" },
123
+ stateClass: "C-local",
124
+ persistsTo: "none",
125
+ rwdReference:
126
+ "§Runtime API § runtime.document.setMode. Live adapter over runtime.setDocumentMode(); mode 'suggesting' is the v3 entry to tracked-change authoring.",
127
+ };
128
+
91
129
  /* ================================================================== */
92
130
  /* export */
93
131
  /* ================================================================== */
@@ -196,6 +234,26 @@ export function createDocumentFamily(runtime: RuntimeApiHandle) {
196
234
  return result;
197
235
  },
198
236
 
237
+ getMode(): DocumentMode {
238
+ // @endStateApi — live. Reads the runtime view-state posture that
239
+ // render snapshots already expose.
240
+ return runtime.getRenderSnapshot().documentMode;
241
+ },
242
+
243
+ setMode(mode: DocumentMode): void {
244
+ // @endStateApi — live. Delegates to the runtime's document-mode
245
+ // setter; `suggesting` is the tracked-change authoring posture.
246
+ runtime.setDocumentMode(mode);
247
+ emitUxResponse(runtime, {
248
+ apiFn: setModeMetadata.name,
249
+ intent: setModeMetadata.uxIntent.expectedDelta ?? "",
250
+ mockOrLive: "live",
251
+ uiVisible: true,
252
+ expectedDelta: setModeMetadata.uxIntent.expectedDelta,
253
+ actualDelta: { kind: "surface-refresh", payload: { mode } },
254
+ });
255
+ },
256
+
199
257
  async export(options?: ExportDocxOptions): Promise<ExportResult> {
200
258
  // @endStateApi — live. Delegates to the shipped runtime export path.
201
259
  const result = await runtime.exportDocx(options);
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * @endStateApi v3 — `runtime.review` family.
3
3
  *
4
- * getComments (live) / getChanges (live) / acceptChange (live) /
5
- * resolveComment (live).
4
+ * getComments (live) / getChanges (live) / getSuggestions (live) /
5
+ * acceptChange (live) / rejectChange (live) / resolveComment (live).
6
6
  */
7
7
 
8
8
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
9
9
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
10
10
  import type {
11
11
  CommentSidebarThreadSnapshot,
12
+ SuggestionsSnapshot,
12
13
  TrackedChangeEntrySnapshot,
13
14
  } from "../../public-types.ts";
14
15
  import { emitUxResponse } from "../_ux-response.ts";
@@ -51,6 +52,22 @@ export const getChangesMetadata: ApiV3FnMetadata = {
51
52
  rwdReference: "§Runtime API § runtime.review.getChanges",
52
53
  };
53
54
 
55
+ export const getSuggestionsMetadata: ApiV3FnMetadata = {
56
+ name: "runtime.review.getSuggestions",
57
+ status: "live",
58
+ sourceLayer: "workflow-review",
59
+ liveEvidence: {
60
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
61
+ commit: "refactor-03-tracked-changes-v1-api-adapter",
62
+ },
63
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
64
+ agentMetadata: { readOrMutate: "read", boundedScope: "document", auditCategory: "review-read" },
65
+ stateClass: "A-canonical",
66
+ persistsTo: "canonical",
67
+ rwdReference:
68
+ "§Runtime API § runtime.review.getSuggestions. Live adapter over runtime.getSuggestionsSnapshot(); semantic suggestion readback is grouped by the runtime, not by v3.",
69
+ };
70
+
54
71
  export const acceptChangeMetadata: ApiV3FnMetadata = {
55
72
  name: "runtime.review.acceptChange",
56
73
  status: "live",
@@ -70,6 +87,23 @@ export const acceptChangeMetadata: ApiV3FnMetadata = {
70
87
  rwdReference: "§Runtime API § runtime.review.acceptChange",
71
88
  };
72
89
 
90
+ export const rejectChangeMetadata: ApiV3FnMetadata = {
91
+ name: "runtime.review.rejectChange",
92
+ status: "live",
93
+ sourceLayer: "workflow-review",
94
+ liveEvidence: {
95
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
96
+ commit: "refactor-03-tracked-changes-v1-api-adapter",
97
+ },
98
+ uxIntent: { uiVisible: true, expectsUxResponse: "inline-change", expectedDelta: "change mark disappears and text restores" },
99
+ agentMetadata: { readOrMutate: "mutate", boundedScope: "scope", auditCategory: "change-reject" },
100
+ stateClass: "A-canonical",
101
+ persistsTo: "canonical",
102
+ broadcastsVia: "crdt",
103
+ rwdReference:
104
+ "§Runtime API § runtime.review.rejectChange. Live adapter over runtime.rejectChange; mirrors acceptChange for individual tracked-change review.",
105
+ };
106
+
73
107
  export const resolveCommentMetadata: ApiV3FnMetadata = {
74
108
  name: "runtime.review.resolveComment",
75
109
  status: "live",
@@ -100,6 +134,12 @@ export function createReviewFamily(runtime: RuntimeApiHandle) {
100
134
  return runtime.getRenderSnapshot().trackedChanges.revisions;
101
135
  },
102
136
 
137
+ getSuggestions(): SuggestionsSnapshot {
138
+ // @endStateApi — live. Delegates to the runtime's semantic
139
+ // suggestion grouping rather than regrouping raw revisions here.
140
+ return runtime.getSuggestionsSnapshot();
141
+ },
142
+
103
143
  acceptChange(changeId: string): void {
104
144
  // @endStateApi — live. Delegates.
105
145
  runtime.acceptChange(changeId);
@@ -113,6 +153,19 @@ export function createReviewFamily(runtime: RuntimeApiHandle) {
113
153
  });
114
154
  },
115
155
 
156
+ rejectChange(changeId: string): void {
157
+ // @endStateApi — live. Delegates.
158
+ runtime.rejectChange(changeId);
159
+ emitUxResponse(runtime, {
160
+ apiFn: rejectChangeMetadata.name,
161
+ intent: rejectChangeMetadata.uxIntent.expectedDelta ?? "",
162
+ mockOrLive: "live",
163
+ uiVisible: true,
164
+ expectedDelta: rejectChangeMetadata.uxIntent.expectedDelta,
165
+ actualDelta: { kind: "inline-change", payload: { changeId } },
166
+ });
167
+ },
168
+
116
169
  resolveComment(commentId: string): void {
117
170
  // @endStateApi — live.
118
171
  runtime.resolveComment(commentId);
@@ -671,7 +671,10 @@ function normalizeDrawingFrameNode(
671
671
  const filename = packagePartName.slice(packagePartName.lastIndexOf("/") + 1) || "image.bin";
672
672
  state.media.items[node.content.mediaId] = {
673
673
  mediaId: node.content.mediaId,
674
- contentType: existingMediaItem?.contentType ?? "application/octet-stream",
674
+ contentType:
675
+ node.content.contentType ??
676
+ existingMediaItem?.contentType ??
677
+ "application/octet-stream",
675
678
  filename,
676
679
  packagePartName,
677
680
  relationshipId: node.content.blipRef,
@@ -188,8 +188,12 @@ function resolveContent(
188
188
  const partPath = normalizePartPath(
189
189
  resolveRelationshipTarget(opts.sourcePartPath ?? "/word/document.xml", rel),
190
190
  );
191
+ const mediaPart = opts.mediaParts?.get(partPath);
191
192
  pic.packagePartName = partPath;
192
193
  pic.mediaId = `media:${partPath.slice(1)}`;
194
+ if (mediaPart?.contentType) {
195
+ pic.contentType = mediaPart.contentType;
196
+ }
193
197
  }
194
198
  // F4.1 — preserve outer drawing XML for lossless round-trip serialization
195
199
  pic.rawXml = rawXml;
@@ -1922,6 +1922,8 @@ export interface PictureContent {
1922
1922
  mediaId?: string;
1923
1923
  /** Absolute package path for media catalog lookup. */
1924
1924
  packagePartName?: string;
1925
+ /** MIME resolved from the OPC media part, when known. */
1926
+ contentType?: string;
1925
1927
  srcRect?: { top: number; bottom: number; left: number; right: number };
1926
1928
  stretch?: boolean;
1927
1929
  /**
@@ -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");
@@ -3341,13 +3342,40 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3341
3342
  onToggleItalic: commands.onToggleItalic,
3342
3343
  onToggleUnderline: commands.onToggleUnderline,
3343
3344
  onToggleStrikethrough: commands.onToggleStrikethrough,
3345
+ onSetParagraphStyle: (styleId) => {
3346
+ const resolvedStyleId = resolveProductParagraphStyleId(styleCatalog, styleId);
3347
+ if (!resolvedStyleId) {
3348
+ activeRuntime.emitBlockedCommand("setParagraphStyle", [{
3349
+ code: "unsupported_surface",
3350
+ message: `${styleId} is not available in this document's style catalog.`,
3351
+ }]);
3352
+ return;
3353
+ }
3354
+ commands.onSetParagraphStyle?.(resolvedStyleId);
3355
+ },
3356
+ onSetFontFamily: commands.onSetFontFamily,
3357
+ onSetFontSize: commands.onSetFontSize,
3358
+ onSetTextColor: commands.onSetTextColor,
3359
+ onSetHighlightColor: commands.onSetHighlightColor,
3344
3360
  onToggleBulletedList: commands.onToggleBulletedList,
3345
3361
  onToggleNumberedList: commands.onToggleNumberedList,
3346
3362
  onOutdent: commands.onOutdent,
3347
3363
  onIndent: commands.onIndent,
3348
3364
  onSetAlignment: (alignment) => commands.onSetAlignment?.(alignment),
3365
+ onInsertPageBreak: commands.onInsertPageBreak,
3366
+ onInsertSectionBreak: (type) => commands.onInsertSectionBreak?.(type),
3349
3367
  onInsertTable: commands.onInsertTable,
3350
3368
  onAddComment: commands.onAddComment,
3369
+ onFindRequested: onFindRequested
3370
+ ? () => onFindRequested({ selectionText: "", selectionRange: snapshot.selection })
3371
+ : undefined,
3372
+ onReplaceRequested: onReplaceRequested
3373
+ ? () => onReplaceRequested({ selectionText: "", selectionRange: snapshot.selection })
3374
+ : undefined,
3375
+ onPrintRequested,
3376
+ onGoToRequested: onGoToRequested
3377
+ ? () => onGoToRequested({ selectionText: "", selectionRange: snapshot.selection })
3378
+ : undefined,
3351
3379
  onInsertRowAbove: commands.onAddRowBefore,
3352
3380
  onInsertRowBelow: commands.onAddRowAfter,
3353
3381
  onInsertColumnBefore: commands.onAddColumnBefore,
@@ -3362,7 +3390,17 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3362
3390
  return editorActionHost
3363
3391
  ? { ...defaultHost, ...editorActionHost }
3364
3392
  : defaultHost;
3365
- }, [activeRuntime, commands, editorActionHost]);
3393
+ }, [
3394
+ activeRuntime,
3395
+ commands,
3396
+ editorActionHost,
3397
+ onFindRequested,
3398
+ onGoToRequested,
3399
+ onPrintRequested,
3400
+ onReplaceRequested,
3401
+ snapshot.selection,
3402
+ styleCatalog,
3403
+ ]);
3366
3404
 
3367
3405
  const harnessShowUnsupportedPreviews = readHarnessDebugPortsFlag(__harnessDebugPorts, "unsupportedObjectPreviews");
3368
3406
  const effectiveShowUnsupportedPreviews = computeEffectiveShowUnsupportedPreviews({
@@ -4155,6 +4193,176 @@ function summarizeSelectionPreview(snapshot: RuntimeRenderSnapshot): string | nu
4155
4193
  return preview.length > 48 ? `${preview.slice(0, 45)}...` : preview;
4156
4194
  }
4157
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
+
4158
4366
  function selectionToolbarAnchorsEqual(
4159
4367
  left: SelectionToolbarAnchor | null,
4160
4368
  right: SelectionToolbarAnchor | null,
@@ -4574,6 +4782,39 @@ function createSelectionToolbarStyleBadge(
4574
4782
  return { label: styleEntry.displayName };
4575
4783
  }
4576
4784
 
4785
+ function resolveProductParagraphStyleId(
4786
+ styleCatalog: StyleCatalogSnapshot,
4787
+ requestedStyleId: string,
4788
+ ): string | null {
4789
+ switch (requestedStyleId) {
4790
+ case "Heading1":
4791
+ return resolveHeadingShortcutStyleId(styleCatalog, 1);
4792
+ case "Heading2":
4793
+ return resolveHeadingShortcutStyleId(styleCatalog, 2);
4794
+ case "Heading3":
4795
+ return resolveHeadingShortcutStyleId(styleCatalog, 3);
4796
+ case "Normal": {
4797
+ const defaultStyle = styleCatalog.paragraphs.find((entry) => entry.isDefault);
4798
+ if (defaultStyle) return defaultStyle.styleId;
4799
+ break;
4800
+ }
4801
+ default:
4802
+ break;
4803
+ }
4804
+
4805
+ const requestedToken = normalizeProductStyleToken(requestedStyleId);
4806
+ const match = styleCatalog.paragraphs.find(
4807
+ (entry) =>
4808
+ normalizeProductStyleToken(entry.styleId) === requestedToken ||
4809
+ normalizeProductStyleToken(entry.displayName) === requestedToken,
4810
+ );
4811
+ return match?.styleId ?? null;
4812
+ }
4813
+
4814
+ function normalizeProductStyleToken(value: string): string {
4815
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
4816
+ }
4817
+
4577
4818
  function createSelectionToolbarListBadge(
4578
4819
  viewState: ReturnType<WordReviewEditorRuntime["getViewState"]>,
4579
4820
  ): SelectionToolbarModel["badges"][number] | null {