@beyondwork/docx-react-component 1.0.87 → 1.0.89

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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/api/v3/_runtime-handle.ts +5 -0
  3. package/src/api/v3/ai/replacement.ts +82 -0
  4. package/src/api/v3/runtime/content.ts +3 -0
  5. package/src/api/v3/runtime/formatting.ts +64 -0
  6. package/src/core/commands/formatting-commands.ts +107 -0
  7. package/src/core/state/text-transaction.ts +11 -4
  8. package/src/runtime/document-runtime.ts +293 -27
  9. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  10. package/src/runtime/scopes/action-validation.ts +12 -3
  11. package/src/runtime/scopes/audit-bundle.ts +2 -2
  12. package/src/runtime/scopes/compiler-service.ts +70 -0
  13. package/src/runtime/scopes/formatting/apply.ts +262 -0
  14. package/src/runtime/scopes/index.ts +12 -0
  15. package/src/runtime/scopes/replacement/propose.ts +2 -0
  16. package/src/runtime/scopes/scope-kinds/paragraph.ts +1 -0
  17. package/src/runtime/scopes/semantic-scope-types.ts +48 -4
  18. package/src/runtime/scopes/workflow-overlap.ts +9 -11
  19. package/src/shell/session-bootstrap.ts +1 -0
  20. package/src/ui/WordReviewEditor.tsx +277 -28
  21. package/src/ui/editor-command-bag.ts +11 -0
  22. package/src/ui/editor-shell-view.tsx +10 -0
  23. package/src/ui/headless/chrome-registry.ts +6 -6
  24. package/src/ui/headless/role-action-sets.ts +4 -10
  25. package/src/ui/headless/selection-tool-resolver.ts +11 -0
  26. package/src/ui-tailwind/chrome/editor-action-registry.ts +1 -1
  27. package/src/ui-tailwind/chrome/tw-context-band.tsx +7 -7
  28. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +13 -18
  29. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +8 -5
  30. package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +100 -0
  31. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +0 -40
  32. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +9 -7
  33. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +17 -1
  34. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +6 -1
  35. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +17 -7
  36. package/src/ui-tailwind/editor-surface/preserve-position.ts +61 -11
  37. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +20 -5
  38. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +52 -6
  39. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +5 -1
  40. package/src/ui-tailwind/review/tw-review-rail.tsx +5 -0
  41. package/src/ui-tailwind/review/tw-workflow-tab.tsx +32 -38
  42. package/src/ui-tailwind/review-workspace/types.ts +2 -0
  43. package/src/ui-tailwind/theme/editor-theme.css +25 -12
  44. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +13 -4
  45. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +6 -15
  46. package/src/ui-tailwind/tw-review-workspace.tsx +28 -18
  47. package/src/ui-tailwind/workflow-scope-layers.ts +70 -0
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.87",
4
+ "version": "1.0.89",
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": [
@@ -117,6 +117,10 @@ export type RuntimeApiHandle = Pick<
117
117
  // ReplacementScope` + `runtime.content.replaceText(scopeId, …)`
118
118
  // route compiled plans through this seam.
119
119
  | "applyScopeReplacement"
120
+ // Layer-08 scoped formatting actions. `ai.applyScopeAction` and
121
+ // `runtime.formatting.applyToScope` route exact mark mutations through
122
+ // this seam so formatting edits do not replace text payloads.
123
+ | "applyScopeFormatting"
120
124
  // Nested facets (runtime.layout + runtime.geometry families,
121
125
  // plus debug telemetry bus for UxResponse emission)
122
126
  | "debug"
@@ -187,6 +191,7 @@ export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true
187
191
  compileScopeCardById: true,
188
192
  compileScopeRailSnapshot: true,
189
193
  applyScopeReplacement: true,
194
+ applyScopeFormatting: true,
190
195
  debug: true,
191
196
  layout: true,
192
197
  geometry: true,
@@ -28,14 +28,17 @@ import {
28
28
  import type {
29
29
  ReplacementOperationKind,
30
30
  ReplacementPreservePolicy,
31
+ ScopeFormattingAction,
31
32
  ValidationResult as CompilerValidationResult,
32
33
  } from "../../../runtime/scopes/index.ts";
33
34
  import type { AIAction } from "../../../runtime/workflow/ai-action-policy.ts";
35
+ import type { TextFormattingDirective } from "../../public-types.ts";
34
36
 
35
37
  export interface ReplacementProposalInput {
36
38
  readonly targetScopeId: string;
37
39
  readonly operation: ReplacementOperationKind;
38
40
  readonly proposedText?: string;
41
+ readonly formatting?: TextFormattingDirective;
39
42
  readonly preserve?: ReplacementPreservePolicy;
40
43
  readonly reason?: string;
41
44
  readonly actionId?: AIAction;
@@ -150,6 +153,7 @@ export interface ApplyReplacementScopeInput {
150
153
  readonly text?: string;
151
154
  readonly structured?: unknown;
152
155
  };
156
+ readonly formatting?: TextFormattingDirective;
153
157
  readonly preserve?: ReplacementPreservePolicy;
154
158
  readonly reason?: string;
155
159
  readonly actionId?: AIAction;
@@ -185,6 +189,42 @@ export const applyReplacementScopeMetadata: ApiV3FnMetadata = {
185
189
  "§AI API § ai.applyReplacementScope. Adapter: widened to {targetScopeId, operation, proposedText?, preserve?, actionId?, proposalId?}; routes through applyScopeReplacement (re-validates live state, compiles per-kind plan, dispatches via DocumentRuntime.applyScopeReplacement, emits one ScopeActionAudit on the scope telemetry channel).",
186
190
  };
187
191
 
192
+ export interface ApplyScopeActionInput {
193
+ readonly targetScopeId: string;
194
+ readonly action: ScopeFormattingAction;
195
+ readonly reason?: string;
196
+ readonly actionId?: AIAction;
197
+ readonly actorId?: string;
198
+ readonly origin?: "ui" | "agent" | "host";
199
+ readonly proposalId?: string;
200
+ }
201
+
202
+ export const applyScopeActionMetadata: ApiV3FnMetadata = {
203
+ name: "ai.applyScopeAction",
204
+ status: "live-with-adapter",
205
+ sourceLayer: "semantic-scope-compiler",
206
+ liveEvidence: {
207
+ runnerTest: "test/runtime/scopes/formatting-actions.test.ts",
208
+ commit: "refactor-08-scope-formatting-actions",
209
+ },
210
+ uxIntent: {
211
+ uiVisible: true,
212
+ expectsUxResponse: "inline-change",
213
+ expectedDelta: "formatting inside target scope changes",
214
+ },
215
+ agentMetadata: {
216
+ readOrMutate: "mutate",
217
+ boundedScope: "scope",
218
+ auditCategory: "formatting-apply",
219
+ policyHints: ["requires-approval-for-formatting"],
220
+ },
221
+ stateClass: "A-canonical",
222
+ persistsTo: "canonical",
223
+ broadcastsVia: "crdt",
224
+ rwdReference:
225
+ "§AI API § ai.applyScopeAction. Scope-targeted formatting writer over Layer-08; validates live scope/global guards, dispatches one undoable runtime edit, and emits one ScopeActionAudit. Use for exact mark actions such as clearing only w:highlight.",
226
+ };
227
+
188
228
  /**
189
229
  * Input shape for `validateReplacementScope`. Slice 4 widens the input
190
230
  * to carry enough information for a fresh compile + compose, since the
@@ -314,6 +354,7 @@ export function createReplacementFamily(runtime: RuntimeApiHandle) {
314
354
  : input.proposedText !== undefined
315
355
  ? { proposedText: input.proposedText }
316
356
  : {}),
357
+ ...(input.formatting ? { formatting: input.formatting } : {}),
317
358
  ...(input.preserve ? { preserve: input.preserve } : {}),
318
359
  ...(input.reason ? { reason: input.reason } : {}),
319
360
  ...(input.actionId ? { actionId: input.actionId } : {}),
@@ -341,6 +382,47 @@ export function createReplacementFamily(runtime: RuntimeApiHandle) {
341
382
  authoredRevisionIds: result.authoredRevisionIds,
342
383
  };
343
384
  },
385
+
386
+ applyScopeAction(input: ApplyScopeActionInput): ApplyResult {
387
+ // @endStateApi — live-with-adapter. Routes through the scope
388
+ // compiler's applyFormatting + the same ApplyResult projection
389
+ // used by applyReplacementScope.
390
+ const proposalId =
391
+ input.proposalId ??
392
+ mockId(
393
+ runtime.getSessionState().documentId,
394
+ `scope-action-${input.targetScopeId}-${input.action.kind}`,
395
+ );
396
+
397
+ const result = compiler.applyFormatting({
398
+ targetScopeId: input.targetScopeId,
399
+ action: input.action,
400
+ ...(input.reason ? { reason: input.reason } : {}),
401
+ ...(input.actionId ? { actionId: input.actionId } : {}),
402
+ actorId: input.actorId ?? "agent",
403
+ origin: input.origin ?? "agent",
404
+ emittedAtUtc: new Date(0).toISOString(),
405
+ });
406
+
407
+ emitUxResponse(runtime, {
408
+ apiFn: applyScopeActionMetadata.name,
409
+ intent: applyScopeActionMetadata.uxIntent.expectedDelta ?? "",
410
+ mockOrLive: "live",
411
+ uiVisible: true,
412
+ expectedDelta: applyScopeActionMetadata.uxIntent.expectedDelta,
413
+ });
414
+
415
+ return {
416
+ proposalId,
417
+ applied: result.applied,
418
+ ...(result.reason ? { reason: result.reason } : {}),
419
+ ...(result.validation.blockedReasons.length > 0
420
+ ? { blockers: Object.freeze([...result.validation.blockedReasons]) }
421
+ : {}),
422
+ ...(result.audit ? { auditHint: result.audit.actionId } : {}),
423
+ authoredRevisionIds: result.authoredRevisionIds,
424
+ };
425
+ },
344
426
  };
345
427
  }
346
428
 
@@ -17,6 +17,7 @@ import type {
17
17
  EditorAnchorProjection,
18
18
  SurfaceBlockSnapshot,
19
19
  SurfaceInlineSegment,
20
+ TextFormattingDirective,
20
21
  } from "../../public-types.ts";
21
22
  import { emitUxResponse } from "../_ux-response.ts";
22
23
  import { createScopeCompilerService } from "../../../runtime/scopes/index.ts";
@@ -78,6 +79,7 @@ export const getLocationMetadata: ApiV3FnMetadata = {
78
79
  export interface ReplaceTextInput {
79
80
  readonly scopeId: string;
80
81
  readonly replacement: string;
82
+ readonly formatting?: TextFormattingDirective;
81
83
  }
82
84
 
83
85
  export interface ReplaceTextResult {
@@ -200,6 +202,7 @@ export function createContentFamily(runtime: RuntimeApiHandle) {
200
202
  targetScopeId: input.scopeId,
201
203
  operation: "replace",
202
204
  proposedText: input.replacement,
205
+ ...(input.formatting ? { formatting: input.formatting } : {}),
203
206
  actorId: "user",
204
207
  origin: "ui",
205
208
  emittedAtUtc: new Date(0).toISOString(),
@@ -14,6 +14,10 @@
14
14
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
15
15
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
16
16
  import { emitUxResponse } from "../_ux-response.ts";
17
+ import {
18
+ createScopeCompilerService,
19
+ type ScopeFormattingAction,
20
+ } from "../../../runtime/scopes/index.ts";
17
21
  import {
18
22
  resolveEffectiveFormatting,
19
23
  createFormattingContext,
@@ -159,7 +163,43 @@ export const applyMetadata: ApiV3FnMetadata = {
159
163
  "§Runtime API § runtime.formatting.apply. Live adapter over DocumentRuntime.applyFormattingOperation(); supports direct formatting/paragraph alignment/indentation and authors bounded property-change suggestions when the effective mode is suggesting. Style suggestions are intentionally not accepted by this operation shape.",
160
164
  };
161
165
 
166
+ export interface FormattingApplyToScopeInput {
167
+ readonly scopeId: string;
168
+ readonly action: ScopeFormattingAction;
169
+ }
170
+
171
+ export interface FormattingApplyToScopeResult {
172
+ readonly applied: boolean;
173
+ readonly reason?: string;
174
+ }
175
+
176
+ export const applyToScopeMetadata: ApiV3FnMetadata = {
177
+ name: "runtime.formatting.applyToScope",
178
+ status: "live-with-adapter",
179
+ sourceLayer: "semantic-scope-compiler",
180
+ liveEvidence: {
181
+ runnerTest: "test/runtime/scopes/formatting-actions.test.ts",
182
+ commit: "refactor-08-scope-formatting-actions",
183
+ },
184
+ stateClass: "A-canonical",
185
+ persistsTo: "canonical",
186
+ broadcastsVia: "crdt",
187
+ uxIntent: {
188
+ uiVisible: true,
189
+ expectsUxResponse: "inline-change",
190
+ expectedDelta: "formatting changes inside the target scope",
191
+ },
192
+ agentMetadata: {
193
+ readOrMutate: "mutate",
194
+ boundedScope: "scope",
195
+ auditCategory: "formatting-write",
196
+ },
197
+ rwdReference:
198
+ "§Runtime API § runtime.formatting.applyToScope. Scope-targeted formatting writer over Layer-08; resolves target scope by id, validates scope/global guards, applies exact text-mark action, emits one ScopeActionAudit, and enters the normal undo stack.",
199
+ };
200
+
162
201
  export function createFormattingFamily(runtime: RuntimeApiHandle) {
202
+ const compiler = createScopeCompilerService(runtime);
163
203
  return {
164
204
  apply(operation: FormattingOperation): void {
165
205
  // @endStateApi — live. Delegates to the runtime-owned mutation seam
@@ -175,6 +215,30 @@ export function createFormattingFamily(runtime: RuntimeApiHandle) {
175
215
  });
176
216
  },
177
217
 
218
+ applyToScope(input: FormattingApplyToScopeInput): FormattingApplyToScopeResult {
219
+ // @endStateApi — live-with-adapter. Routes through the scope
220
+ // compiler's applyFormatting with actor=user + origin=ui; returns
221
+ // the authored-revision + audit shape projected for v3 callers.
222
+ const result = compiler.applyFormatting({
223
+ targetScopeId: input.scopeId,
224
+ action: input.action,
225
+ actorId: "user",
226
+ origin: "ui",
227
+ emittedAtUtc: new Date(0).toISOString(),
228
+ });
229
+ emitUxResponse(runtime, {
230
+ apiFn: applyToScopeMetadata.name,
231
+ intent: applyToScopeMetadata.uxIntent.expectedDelta ?? "",
232
+ mockOrLive: "live",
233
+ uiVisible: true,
234
+ expectedDelta: applyToScopeMetadata.uxIntent.expectedDelta,
235
+ });
236
+ return {
237
+ applied: result.applied,
238
+ ...(result.reason ? { reason: result.reason } : {}),
239
+ };
240
+ },
241
+
178
242
  getEffective(
179
243
  nodeRef: FormattingNodeRef,
180
244
  opts?: FormattingGetEffectiveOpts,
@@ -588,6 +588,113 @@ export function applyFormattingOperationToDocument(
588
588
  };
589
589
  }
590
590
 
591
+ export type TextMarkClearTarget = TextMark["type"] | "visualHighlight";
592
+
593
+ export type TextMarkRangeOperation =
594
+ | { type: "clear-mark"; mark: TextMarkClearTarget }
595
+ | { type: "set-mark"; mark: TextMark };
596
+
597
+ /**
598
+ * Apply an exact canonical text-mark mutation over a document-coordinate
599
+ * range without relying on the current selection or a mounted render surface.
600
+ * This is the scope-action path for source-layer edits such as removing only
601
+ * `w:highlight` while preserving shading and every unrelated run mark.
602
+ */
603
+ export function applyTextMarkOperationToDocumentRange(
604
+ document: CanonicalDocumentEnvelope,
605
+ range: { from: number; to: number },
606
+ operation: TextMarkRangeOperation,
607
+ ): FormattingMutationResult {
608
+ const selectionFrom = Math.min(range.from, range.to);
609
+ const selectionTo = Math.max(range.from, range.to);
610
+ const selection: RuntimeRenderSnapshot["selection"] = {
611
+ anchor: selectionFrom,
612
+ head: selectionTo,
613
+ isCollapsed: selectionFrom === selectionTo,
614
+ activeRange: {
615
+ kind: "range",
616
+ from: selectionFrom,
617
+ to: selectionTo,
618
+ assoc: { start: -1, end: 1 },
619
+ },
620
+ };
621
+
622
+ if (selectionFrom === selectionTo) {
623
+ return { document, selection, changed: false };
624
+ }
625
+
626
+ const nextDocument = structuredClone(document);
627
+ const root = nextDocument.content as DocumentRootNode;
628
+ const updateMarks = resolveTextMarkRangeUpdater(operation);
629
+ let changed = false;
630
+ let cursor = 0;
631
+
632
+ for (let blockIndex = 0; blockIndex < root.children.length; blockIndex += 1) {
633
+ const block = root.children[blockIndex];
634
+ const blockFrom = cursor;
635
+ const blockLength =
636
+ block?.type === "paragraph"
637
+ ? block.children.reduce(
638
+ (total, child) => total + inlineNodeLength(child as InlineNode),
639
+ 0,
640
+ )
641
+ : 1;
642
+ const blockTo = blockFrom + blockLength;
643
+
644
+ if (
645
+ block?.type === "paragraph" &&
646
+ rangesOverlap(selectionFrom, selectionTo, blockFrom, blockTo)
647
+ ) {
648
+ const transformed = transformInlineNodes(
649
+ block.children,
650
+ blockFrom,
651
+ selectionFrom,
652
+ selectionTo,
653
+ updateMarks,
654
+ );
655
+ if (transformed.changed) {
656
+ block.children = transformed.nodes;
657
+ changed = true;
658
+ }
659
+ }
660
+
661
+ cursor = blockTo;
662
+ if (blockIndex < root.children.length - 1) {
663
+ cursor += 1;
664
+ }
665
+ }
666
+
667
+ return {
668
+ document: changed ? nextDocument : document,
669
+ selection,
670
+ changed,
671
+ };
672
+ }
673
+
674
+ function resolveTextMarkRangeUpdater(
675
+ operation: TextMarkRangeOperation,
676
+ ): (marks?: TextMark[]) => TextMark[] | undefined {
677
+ if (operation.type === "set-mark") {
678
+ return (marks) => {
679
+ const nextMarks = cloneMarks(marks).filter(
680
+ (candidate) => candidate.type !== operation.mark.type,
681
+ );
682
+ nextMarks.push({ ...operation.mark } as TextMark);
683
+ return nextMarks.length > 0 ? nextMarks : undefined;
684
+ };
685
+ }
686
+
687
+ return (marks) => {
688
+ const nextMarks = cloneMarks(marks).filter((candidate) => {
689
+ if (operation.mark === "visualHighlight") {
690
+ return candidate.type !== "highlight" && candidate.type !== "backgroundColor";
691
+ }
692
+ return candidate.type !== operation.mark;
693
+ });
694
+ return nextMarks.length > 0 ? nextMarks : undefined;
695
+ };
696
+ }
697
+
591
698
  function resolveMarkUpdater(
592
699
  snapshot: RuntimeRenderSnapshot,
593
700
  operation: Exclude<
@@ -829,7 +829,7 @@ function createInsertionUnits(
829
829
  * - If the range is collapsed, use the marks of the text unit immediately left of
830
830
  * the caret (Word-matching behavior for empty-range inserts).
831
831
  * - Otherwise walk text units in `[from, to)`. If every text unit shares the same
832
- * marks (by type), use them. Mixed → fall back to paragraph-default (`undefined`).
832
+ * marks (by type and value), use them. Mixed → fall back to paragraph-default (`undefined`).
833
833
  */
834
834
  function resolveMarksForInsertion(
835
835
  intent: TextTransactionIntent,
@@ -887,9 +887,16 @@ function marksAreEqual(left: TextMark[] | undefined, right: TextMark[] | undefin
887
887
  if (!left && !right) return true;
888
888
  if (!left || !right) return false;
889
889
  if (left.length !== right.length) return false;
890
- const sortedLeft = [...left].map((mark) => mark.type).sort();
891
- const sortedRight = [...right].map((mark) => mark.type).sort();
892
- return sortedLeft.every((type, index) => type === sortedRight[index]);
890
+ const sortedLeft = [...left].map(stableMarkKey).sort();
891
+ const sortedRight = [...right].map(stableMarkKey).sort();
892
+ return sortedLeft.every((key, index) => key === sortedRight[index]);
893
+ }
894
+
895
+ function stableMarkKey(mark: TextMark): string {
896
+ const entries = Object.entries(mark).sort(([left], [right]) =>
897
+ left.localeCompare(right),
898
+ );
899
+ return JSON.stringify(entries);
893
900
  }
894
901
 
895
902
  function resolveParagraphPropertiesAtPosition(