@beyondwork/docx-react-component 1.0.95 → 1.0.96

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.95",
4
+ "version": "1.0.96",
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": [
@@ -13,6 +13,7 @@ import {
13
13
  type EditorWarning as InternalEditorWarning,
14
14
  } from "../core/state/editor-state.ts";
15
15
  import {
16
+ createPlainText,
16
17
  logicalPositionToUnitIndex,
17
18
  parseTextStory,
18
19
  serializeTextStory,
@@ -3195,6 +3196,13 @@ export function createDocumentRuntime(
3195
3196
  replaceText(text, target, formatting) {
3196
3197
  try {
3197
3198
  const timestamp = clock();
3199
+ const selection = target ? createSelectionFromPublicAnchor(target) : state.selection;
3200
+ if (
3201
+ shouldPreserveEquivalentReplacement(formatting) &&
3202
+ replacementTextMatchesCurrentRange(state.document, activeStory, selection, text)
3203
+ ) {
3204
+ return;
3205
+ }
3198
3206
  applyTextCommandInActiveStory(
3199
3207
  {
3200
3208
  type: "text.insert",
@@ -3203,7 +3211,7 @@ export function createDocumentRuntime(
3203
3211
  origin: createOrigin("api", timestamp),
3204
3212
  },
3205
3213
  {
3206
- selection: target ? createSelectionFromPublicAnchor(target) : state.selection,
3214
+ selection,
3207
3215
  blockedCommandName: "replaceText",
3208
3216
  },
3209
3217
  );
@@ -6448,6 +6456,43 @@ function createSelectionFromPublicAnchor(
6448
6456
  }
6449
6457
  }
6450
6458
 
6459
+ function shouldPreserveEquivalentReplacement(formatting: TextFormattingDirective | undefined): boolean {
6460
+ return !formatting || formatting.mode === "match-replaced-range";
6461
+ }
6462
+
6463
+ function replacementTextMatchesCurrentRange(
6464
+ document: CanonicalDocumentEnvelope,
6465
+ activeStory: EditorStoryTarget,
6466
+ selection: import("../core/state/editor-state.ts").SelectionSnapshot,
6467
+ replacement: string,
6468
+ ): boolean {
6469
+ const from = Math.max(0, Math.min(selection.anchor, selection.head));
6470
+ const to = Math.max(0, Math.max(selection.anchor, selection.head));
6471
+ if (from === to) {
6472
+ return replacement.length === 0;
6473
+ }
6474
+
6475
+ const content = activeStory.kind === "main"
6476
+ ? document.content
6477
+ : {
6478
+ type: "doc" as const,
6479
+ children: [...getStoryBlocks(document, activeStory)],
6480
+ };
6481
+ const story = parseTextStory(content);
6482
+ if (from > story.size || to > story.size) {
6483
+ return false;
6484
+ }
6485
+
6486
+ const unitFrom = logicalPositionToUnitIndex(story.units, from, "after");
6487
+ const unitTo = logicalPositionToUnitIndex(story.units, to, "before");
6488
+ const selectedText = createPlainText({
6489
+ firstParagraph: story.firstParagraph,
6490
+ units: story.units.slice(unitFrom, unitTo),
6491
+ size: to - from,
6492
+ });
6493
+ return selectedText === replacement;
6494
+ }
6495
+
6451
6496
  /**
6452
6497
  * I2 Tier B Slice 4b — extract the selection range from a document as a
6453
6498
  * `CanonicalDocumentFragment`. The fragment preserves text + marks +
@@ -94,6 +94,11 @@ export function collectScopeRailSegments(
94
94
  const activeIds = new Set(input.activeWorkItemScopeIds ?? []);
95
95
 
96
96
  for (const scope of input.scopes ?? []) {
97
+ // Invisible scopes are runtime/agent context only. They may still
98
+ // participate in guard decisions, but they must not surface as rail,
99
+ // card, or body-tint chrome.
100
+ if (scope.visibility === "invisible") continue;
101
+
97
102
  const range = anchorToRuntimeRange(scope.anchor);
98
103
  if (!range) continue;
99
104
  const storyTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
@@ -14,7 +14,7 @@
14
14
 
15
15
  import * as React from "react";
16
16
  import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
17
- import type { ScopeRailPosture, ScopeRailSegment } from "../../api/public-types.ts";
17
+ import type { ScopeRailSegment } from "../../api/public-types.ts";
18
18
  import type {
19
19
  EditorRole,
20
20
  EditorStoryTarget,
@@ -49,8 +49,6 @@ export interface TwChromeOverlayProps {
49
49
  space?: OverlayCoordinateSpace;
50
50
  /** Active scope id (for emphasis + rail tab sync). */
51
51
  activeScopeId?: string | null;
52
- /** Posture filters shared with the Workflow rail. Omitted means all scopes render. */
53
- visibleScopePostures?: ReadonlySet<ScopeRailPosture>;
54
52
  /**
55
53
  * Click handler fired when the user clicks a scope rail stripe.
56
54
  * P0 wires this to open the scope card (P1 ships the card layer).
@@ -215,7 +213,6 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
215
213
  geometryFacet,
216
214
  space,
217
215
  activeScopeId,
218
- visibleScopePostures,
219
216
  onScopeStripeClick,
220
217
  onScopeSegmentClick,
221
218
  onScopeCardClose,
@@ -253,16 +250,6 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
253
250
  [],
254
251
  [ui, workflowFacet, renderFrameRevision],
255
252
  );
256
- const visibleScopeIds = React.useMemo(() => {
257
- if (!visibleScopePostures) return undefined;
258
- const ids = new Set<string>();
259
- for (const segment of scopeRailSegments) {
260
- if (visibleScopePostures.has(segment.posture)) {
261
- ids.add(segment.scopeId);
262
- }
263
- }
264
- return ids;
265
- }, [scopeRailSegments, visibleScopePostures]);
266
253
 
267
254
  return (
268
255
  <div
@@ -291,7 +278,6 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
291
278
  scopeRailSegments={scopeRailSegments}
292
279
  space={space}
293
280
  activeScopeId={activeScopeId}
294
- visibleScopePostures={visibleScopePostures}
295
281
  onStripeClick={onScopeStripeClick}
296
282
  onSegmentClick={onScopeSegmentClick}
297
283
  />
@@ -299,7 +285,6 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
299
285
  facet={facet}
300
286
  workflowFacet={workflowFacet}
301
287
  activeScopeId={activeScopeId ?? null}
302
- visibleScopeIds={visibleScopeIds}
303
288
  onClose={onScopeCardClose ?? noop}
304
289
  onModeChange={onScopeCardModeChange ?? noopModeChange}
305
290
  onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
@@ -53,8 +53,6 @@ export interface TwScopeCardLayerProps {
53
53
  */
54
54
  workflowFacet: WorkflowFacet | null;
55
55
  activeScopeId: string | null;
56
- /** Scope ids currently visible under the Workflow rail layer filters. */
57
- visibleScopeIds?: ReadonlySet<string>;
58
56
  onClose: () => void;
59
57
  onModeChange: (scopeId: string, mode: WorkflowScopeMode) => void;
60
58
  onIssueAction: (
@@ -94,7 +92,6 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
94
92
  facet,
95
93
  workflowFacet,
96
94
  activeScopeId,
97
- visibleScopeIds,
98
95
  onClose,
99
96
  onModeChange,
100
97
  onIssueAction,
@@ -131,11 +128,10 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
131
128
  const getVisibleScopeCardModel = React.useCallback(
132
129
  (scopeId: string | null): ScopeCardModel | null => {
133
130
  if (!scopeId) return null;
134
- if (visibleScopeIds && !visibleScopeIds.has(scopeId)) return null;
135
131
  if (ui) return ui.scope.card(scopeId);
136
132
  return getWorkflowScopeCardModel(scopeId);
137
133
  },
138
- [getWorkflowScopeCardModel, ui, visibleScopeIds],
134
+ [getWorkflowScopeCardModel, ui],
139
135
  );
140
136
 
141
137
  // The effective scope is the pinned one if it still resolves to a
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Scope rail layer — renders workflow scopes as a thin color stripe in
3
- * the reserved left-gutter lane plus a per-line flat tint behind the
4
- * scoped text runs.
3
+ * the reserved left-gutter lane plus a border-only line outline. The
4
+ * visible scope ownership border lives on the PM inline text decoration.
5
5
  *
6
6
  * Per runtime-rendering-and-chrome-phase.md §5 and
7
7
  * docs/plans/scope-card-overlay.md P0, the rail is a projection over
@@ -52,8 +52,6 @@ export interface TwScopeRailLayerProps {
52
52
  railLaneWidthPx?: number;
53
53
  /** Scope id that should render with the `active` emphasis. */
54
54
  activeScopeId?: string | null;
55
- /** Posture filters shared with the Workflow rail. Omitted means all scopes render. */
56
- visibleScopePostures?: ReadonlySet<ScopeRailPosture>;
57
55
  /**
58
56
  * Fires when the user clicks the rail stripe — opens the scope card.
59
57
  * P0 wires this directly; P1 replaces with card-layer-aware routing.
@@ -95,8 +93,8 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
95
93
  // ---------------------------------------------------------------------------
96
94
 
97
95
  const DEFAULT_RAIL_LANE_PX = 44;
98
- const STRIPE_WIDTH_PX = 6;
99
- const LABEL_WIDTH_PX = 58;
96
+ const STRIPE_WIDTH_PX = 4;
97
+ const LABEL_WIDTH_PX = 28;
100
98
  const STACK_OFFSET_PX = 6;
101
99
 
102
100
  export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
@@ -106,21 +104,17 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
106
104
  space,
107
105
  railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
108
106
  activeScopeId,
109
- visibleScopePostures,
110
107
  onStripeClick,
111
108
  onSegmentClick,
112
109
  "data-testid": testId,
113
110
  }) => {
114
111
  const ui = useUiApi();
115
112
  const frame = geometryFacet.getRenderFrame() ?? null;
116
- const railSegments =
113
+ const segments =
117
114
  scopeRailSegments ??
118
115
  ui?.scope.rail().segments ??
119
116
  workflowFacet?.getAllRailSegments() ??
120
117
  [];
121
- const segments = railSegments.filter((segment) =>
122
- visibleScopePostures ? visibleScopePostures.has(segment.posture) : true,
123
- );
124
118
 
125
119
  if (!frame || segments.length === 0) {
126
120
  return null;
@@ -248,16 +242,14 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
248
242
  onKeyDown={handleStripeKey}
249
243
  style={projectRectToOverlay(stripeRect, projectorSpace)}
250
244
  />
251
- {/* Label pill — revealed on stripe hover via CSS. */}
245
+ {/* Edit handle — revealed on stripe hover via CSS. */}
252
246
  <button
253
247
  type="button"
254
248
  tabIndex={-1}
255
- className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} ${
256
- isActive ? "wre-scope-rail-label-active" : ""
257
- }`}
249
+ className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken}`}
258
250
  data-scope-id={segment.scopeId}
259
251
  data-posture={segment.posture}
260
- aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
252
+ aria-label={`Edit scope${segment.label ? `: ${segment.label}` : ""}`}
261
253
  onClick={handleActivate}
262
254
  style={projectRectToOverlay(labelRect, projectorSpace)}
263
255
  >
@@ -379,17 +379,31 @@
379
379
  /*
380
380
  * ─── Workflow inline text emphasis ───
381
381
  *
382
- * Since R3a the workflow scope rail + flat block tint are painted on the
383
- * ChromeOverlay plane (see src/ui-tailwind/chrome-overlay/). PM
384
- * decorations retain ONLY inline text emphasis for postures that carry
385
- * unique per-text signals (candidate = dashed underline, blocked-import =
386
- * wavy underline, active = thin outline). The rounded in-text background
387
- * boxes that previously wrapped every run are gone — the overlay's flat
388
- * tint handles that signal.
382
+ * Scopes should read as text ownership, not block selection. PM inline
383
+ * decorations carry the visible border on the scoped text, while the
384
+ * ChromeOverlay plane supplies the gutter/action rail. Keep this
385
+ * border-only: no filled boxes over document content.
389
386
  */
390
387
  .prosemirror-surface .ProseMirror .wre-workflow-inline {
391
388
  -webkit-box-decoration-break: clone;
392
389
  box-decoration-break: clone;
390
+ border-radius: 2px;
391
+ }
392
+
393
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-edit {
394
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent) 52%, transparent);
395
+ }
396
+
397
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-suggest {
398
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-warning) 56%, transparent);
399
+ }
400
+
401
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-comment {
402
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-insert) 48%, transparent);
403
+ }
404
+
405
+ .prosemirror-surface .ProseMirror .wre-workflow-inline-view {
406
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-secondary) 46%, transparent);
393
407
  }
394
408
 
395
409
  .prosemirror-surface .ProseMirror .wre-workflow-inline-candidate {
@@ -408,31 +422,29 @@
408
422
 
409
423
  /*
410
424
  * Locked zone marker for inline runs: a subtle dotted right edge so the
411
- * reader can tell where the locked range ends when the gutter label scrolls
412
- * out of view. The overlay's flat tint carries the primary signal.
425
+ * reader can tell where the locked range ends when the gutter handle scrolls
426
+ * out of view. The rail carries the action affordance.
413
427
  */
414
428
  .prosemirror-surface .ProseMirror .wre-workflow-inline-locked-zone {
415
429
  box-shadow: inset -1px 0 0 color-mix(in srgb, var(--color-danger) 35%, transparent);
416
430
  }
417
431
 
418
432
  /*
419
- * `wre-workflow-inline-active` no longer emits a visual outline. The
420
- * per-run inset box-shadow produced a halo around every text fragment
421
- * (one box per run, due to box-decoration-break: clone above), which
422
- * fought with the overlay's flat tint. The class name is kept on the
423
- * inline decoration as a data hook (no visual), and emphasis for the
424
- * active scope now lives on the ChromeOverlay rail stripe + scope card.
433
+ * Active scope emphasis is a stronger text border plus the gutter handle.
434
+ * This keeps focus local to scoped text without reintroducing filled green
435
+ * rectangles.
425
436
  */
426
437
  .prosemirror-surface .ProseMirror .wre-workflow-inline-active {
427
- /* intentionally empty — visual emphasis handled by ChromeOverlay */
438
+ box-shadow:
439
+ 0 0 0 1px color-mix(in srgb, var(--color-accent) 72%, transparent),
440
+ 0 0 0 3px color-mix(in srgb, var(--color-accent) 12%, transparent);
428
441
  }
429
442
 
430
443
  /*
431
444
  * ─── ChromeOverlay: scope rail layer ───
432
445
  *
433
- * The overlay sits above PM and paints the flat block-tint + gutter labels
434
- * that used to be inline PM decorations. Positions come from the render
435
- * kernel's anchor index, not DOM rects.
446
+ * The overlay sits above PM and paints gutter handles plus optional
447
+ * border-only line outlines. It must not fill document content.
436
448
  */
437
449
  .wre-scope-rail-layer {
438
450
  pointer-events: none;
@@ -471,58 +483,36 @@
471
483
  border-radius: 0.2rem;
472
484
  pointer-events: none;
473
485
  z-index: 0;
474
- transition: background 140ms ease-out;
475
- }
476
-
477
- .wre-scope-rail-tint-accent {
478
- background: color-mix(in srgb, var(--color-accent) 12%, transparent);
479
- }
480
- .wre-scope-rail-tint-warning {
481
- background: color-mix(in srgb, var(--color-warning) 14%, transparent);
482
- }
483
- .wre-scope-rail-tint-insert {
484
- background: color-mix(in srgb, var(--color-insert) 12%, transparent);
485
- }
486
- .wre-scope-rail-tint-secondary {
487
- background: color-mix(in srgb, var(--color-secondary) 9%, transparent);
488
- }
489
- .wre-scope-rail-tint-danger {
490
- background: color-mix(in srgb, var(--color-danger) 14%, transparent);
491
- }
492
-
493
- /* §3.7 canonical scope families */
494
- .wre-scope-rail-tint-blocked {
495
- background: var(--color-scope-tint-blocked);
496
- }
497
- .wre-scope-rail-tint-in-scope {
498
- background: var(--color-scope-tint-in-scope);
499
- }
500
- .wre-scope-rail-tint-suggest {
501
- background: var(--color-scope-tint-suggest);
502
- }
503
- .wre-scope-rail-tint-comment {
504
- background: var(--color-scope-tint-comment);
505
- }
506
- .wre-scope-rail-tint-scheduled {
507
- background: var(--color-scope-tint-scheduled);
508
- }
486
+ background: transparent;
487
+ transition: box-shadow 140ms ease-out;
488
+ }
489
+
490
+ .wre-scope-rail-tint-accent,
491
+ .wre-scope-rail-tint-warning,
492
+ .wre-scope-rail-tint-insert,
493
+ .wre-scope-rail-tint-secondary,
494
+ .wre-scope-rail-tint-danger,
495
+ .wre-scope-rail-tint-blocked,
496
+ .wre-scope-rail-tint-in-scope,
497
+ .wre-scope-rail-tint-suggest,
498
+ .wre-scope-rail-tint-comment,
499
+ .wre-scope-rail-tint-scheduled,
509
500
  .wre-scope-rail-tint-proposed {
510
- background: var(--color-scope-tint-proposed);
501
+ background: transparent;
511
502
  }
512
503
 
513
504
  .wre-scope-rail-tint-active {
514
- outline: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
505
+ outline: 1px solid color-mix(in srgb, var(--color-accent) 52%, transparent);
515
506
  outline-offset: -1px;
516
507
  }
517
508
 
518
509
  /*
519
510
  * ─── Agent-pending shimmer (K2 / scope-card-overlay P2) ───
520
511
  *
521
- * Painted on every scope tint that overlaps a WorkflowCandidateRange
522
- * with `source: "ai"`. A soft 1.8s pulse signals the agent is
523
- * thinking without competing with the active outline. Reduced-
524
- * motion disables the animation and holds a static 60% opacity
525
- * border so the posture is still readable.
512
+ * Painted on every scope outline that overlaps a WorkflowCandidateRange
513
+ * with `source: "ai"`. A soft 1.8s pulse signals the agent is thinking
514
+ * without competing with the active outline. Reduced-motion disables the
515
+ * animation and holds a static 60% opacity outline.
526
516
  */
527
517
  @keyframes wre-agent-pulse {
528
518
  0%, 100% { opacity: 0.4; }
@@ -544,9 +534,9 @@
544
534
  * ─── Scope rail stripe ───
545
535
  *
546
536
  * The rail stripe is the rest-state representation of a scope: a 4px
547
- * color stripe in the gutter lane. Posture color comes from the
548
- * accent/warning/insert/secondary/danger tokens. Hover widens the
549
- * stripe via transform (zero layout cost) and reveals the label pill.
537
+ * color stripe in the gutter lane. Posture color comes from the
538
+ * accent/warning/insert/secondary/danger tokens. Hover widens the stripe
539
+ * via transform (zero layout cost) and reveals the edit handle.
550
540
  */
551
541
  .wre-scope-rail-stripe {
552
542
  position: absolute;
@@ -595,34 +585,31 @@
595
585
  .wre-scope-rail-stripe.wre-scope-rail-tint-proposed { background: var(--color-scope-tint-proposed); }
596
586
 
597
587
  /*
598
- * ─── Scope rail label pill ───
588
+ * ─── Scope rail edit handle ───
599
589
  *
600
- * Shown only on stripe hover (CSS-driven). The pill overlays the
601
- * stripe with icon + short posture label, anchored to the first line
602
- * of the scope.
590
+ * Shown only on stripe hover (CSS-driven). The handle overlays the
591
+ * stripe with a compact icon anchored to the first line of the scope.
603
592
  */
604
593
  .wre-scope-rail-label {
605
594
  position: absolute;
606
595
  display: flex;
607
596
  align-items: center;
608
597
  justify-content: center;
609
- gap: 0.2rem;
610
- padding: 0.15rem 0.3rem;
611
- border-radius: var(--radius-sm);
598
+ width: 24px;
599
+ height: 24px;
600
+ padding: 0;
601
+ border-radius: 999px;
612
602
  border: 1px solid transparent;
613
603
  background: var(--color-canvas, #fff);
614
604
  box-shadow: var(--shadow-sm);
615
- font-size: 9.5px;
616
- line-height: 1;
617
- text-transform: uppercase;
618
- letter-spacing: 0.06em;
619
- font-weight: 600;
605
+ font: inherit;
620
606
  cursor: pointer;
621
607
  z-index: 2;
622
608
  opacity: 0;
623
609
  pointer-events: none;
624
610
  transition: opacity 140ms ease-out, transform 140ms ease-out;
625
611
  transform: translateX(-4px);
612
+ margin: 0;
626
613
  }
627
614
 
628
615
  .wre-scope-rail-stripe:hover + .wre-scope-rail-label,
@@ -697,8 +684,8 @@
697
684
 
698
685
  .wre-scope-rail-icon {
699
686
  display: inline-block;
700
- width: 14px;
701
- height: 14px;
687
+ width: 13px;
688
+ height: 13px;
702
689
  background-color: currentColor;
703
690
  mask-repeat: no-repeat;
704
691
  mask-position: center;
@@ -708,6 +695,10 @@
708
695
  -webkit-mask-size: contain;
709
696
  }
710
697
 
698
+ .wre-scope-rail-label-text {
699
+ display: none;
700
+ }
701
+
711
702
  /* Simple inline-SVG-as-mask icons so consumers don't need an icon font. */
712
703
  .wre-scope-rail-icon-lock {
713
704
  mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 018 0v4"/></svg>');