@beyondwork/docx-react-component 1.0.84 → 1.0.85

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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/api/internal/build-ref-projections.ts +3 -0
  3. package/src/api/public-types.ts +38 -0
  4. package/src/api/v3/_runtime-handle.ts +11 -0
  5. package/src/api/v3/runtime/content.ts +148 -1
  6. package/src/api/v3/runtime/formatting.ts +41 -0
  7. package/src/api/v3/runtime/review.ts +98 -0
  8. package/src/core/commands/index.ts +81 -25
  9. package/src/core/state/editor-state.ts +15 -0
  10. package/src/io/ooxml/header-footer-reference.ts +38 -0
  11. package/src/io/ooxml/parse-headers-footers.ts +11 -23
  12. package/src/io/ooxml/parse-main-document.ts +7 -10
  13. package/src/model/canonical-document.ts +9 -0
  14. package/src/model/review/comment-types.ts +2 -0
  15. package/src/runtime/document-runtime.ts +677 -54
  16. package/src/runtime/formatting/field/resolver.ts +73 -8
  17. package/src/runtime/layout/layout-engine-version.ts +31 -12
  18. package/src/runtime/layout/paginated-layout-engine.ts +18 -11
  19. package/src/runtime/layout/public-facet.ts +119 -16
  20. package/src/runtime/layout/resolve-page-fields.ts +68 -6
  21. package/src/runtime/layout/resolve-page-previews.ts +1 -1
  22. package/src/runtime/suggestions-snapshot.ts +24 -0
  23. package/src/runtime/surface-projection.ts +59 -2
  24. package/src/shell/ref-commands.ts +3 -354
  25. package/src/shell/session-bootstrap.ts +8 -0
  26. package/src/ui/WordReviewEditor.tsx +95 -9
  27. package/src/ui/editor-command-bag.ts +3 -1
  28. package/src/ui/headless/revision-decoration-model.ts +13 -0
  29. package/src/ui/headless/selection-tool-types.ts +2 -0
  30. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +7 -3
  31. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +175 -25
  32. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -1
  33. package/src/ui-tailwind/editor-surface/pm-decorations.ts +12 -0
  34. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +18 -30
  35. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -1
  36. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +1 -1
  37. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +20 -11
  38. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +9 -4
  39. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +12 -7
  40. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +29 -10
  41. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +1 -1
  42. package/src/ui-tailwind/review-workspace/types.ts +3 -2
  43. package/src/ui-tailwind/review-workspace/use-page-markers.ts +11 -1
  44. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -1
  45. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +2 -1
  46. package/src/ui-tailwind/tw-review-workspace.tsx +18 -2
@@ -14,6 +14,7 @@ import type {
14
14
  WorkspaceMode,
15
15
  } from "../api/public-types.ts";
16
16
  import type { ReviewRailTab } from "../ui-tailwind/review/tw-review-rail.tsx";
17
+ import type { MarkupDisplay } from "./headless/comment-decoration-model.ts";
17
18
 
18
19
  type CommandHandler = (...args: any[]) => unknown;
19
20
 
@@ -22,6 +23,7 @@ export interface EditorCommandBag {
22
23
  onZoomChange?(level: ZoomLevel): void;
23
24
  onActiveRailTabChange(value: ReviewRailTab): void;
24
25
  onShowTrackedChangesChange(show: boolean): void;
26
+ onReviewMarkupModeChange?(mode: MarkupDisplay): void;
25
27
  onUndo(): void;
26
28
  onRedo(): void;
27
29
  onSetParagraphStyle?(styleId: string): void;
@@ -102,7 +104,7 @@ export interface EditorCommandBag {
102
104
  /** Open the footer story for a specific page (double-click on its band). */
103
105
  onOpenFooterStoryForPage?(pageIndex: number): void;
104
106
  /**
105
- * P8.11 — per-page header/footer band click handler. Receives the
107
+ * P8.11 — per-page header/footer band double-click handler. Receives the
106
108
  * exact `EditorStoryTarget` the band represents; the command bag wires
107
109
  * this to `runtime.openStory(target)`.
108
110
  */
@@ -225,6 +225,19 @@ export function buildClassFromRevisionDisplay(
225
225
  parts.push("text-secondary");
226
226
  }
227
227
 
228
+ // Formatting/property-change revisions carry their semantics through
229
+ // `kind` even when markup posture has no underline/strike flag. Give
230
+ // mounted suggestion authoring a visible, non-destructive cue instead
231
+ // of silently relying on the sidebar/card path.
232
+ if (
233
+ parts.length === 0 &&
234
+ (display.kind === "formatting" || display.kind === "property-change")
235
+ ) {
236
+ parts.push(
237
+ "underline decoration-accent/70 decoration-dotted decoration-1 underline-offset-2",
238
+ );
239
+ }
240
+
228
241
  // Surface the author palette color as a CSS variable the renderer
229
242
  // can pick up via `var(--wre-revision-author)`. Consumer stylesheet
230
243
  // composes this into ring/underline color when the palette slot is
@@ -81,6 +81,8 @@ export interface SuggestionReviewSelectionToolModel extends BaseSelectionToolMod
81
81
  canReject: boolean;
82
82
  canEditSuggestion: boolean;
83
83
  canAddComment: boolean;
84
+ commentThreadIds?: string[];
85
+ replyCount?: number;
84
86
  }
85
87
 
86
88
  export type StructureContextKind = "table" | "image" | "object" | "list";
@@ -32,9 +32,12 @@ const focusRingClass =
32
32
  export function TwSuggestionCard(props: TwSuggestionCardProps) {
33
33
  const contextLabel = summarizeSuggestionContext(props.model);
34
34
  const commentDisabled = !props.model.canAddComment;
35
+ const replyCount = props.model.replyCount ?? 0;
35
36
  const tooltipLabel = commentDisabled
36
37
  ? props.model.disabledReason ?? "Commenting is unavailable for this selection"
37
- : "Comment on suggestion";
38
+ : props.model.commentThreadIds?.length
39
+ ? "Reply to tracked change"
40
+ : "Start tracked-change discussion";
38
41
 
39
42
  return (
40
43
  <div
@@ -87,14 +90,15 @@ export function TwSuggestionCard(props: TwSuggestionCardProps) {
87
90
  <Tooltip.Trigger asChild>
88
91
  <button
89
92
  type="button"
90
- aria-label="Comment on suggestion"
93
+ aria-label="Reply to tracked change"
94
+ data-testid="suggestion-card-reply"
91
95
  disabled={commentDisabled}
92
96
  onMouseDown={preserveEditorSelectionMouseDown}
93
97
  onClick={props.onAddComment}
94
98
  className={`inline-flex h-7 items-center gap-1 rounded-md border border-[var(--color-border-default)] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-hover)] disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
95
99
  >
96
100
  <MessageSquare className="h-3 w-3" />
97
- Comment
101
+ Reply{replyCount > 0 ? ` ${replyCount}` : ""}
98
102
  </button>
99
103
  </Tooltip.Trigger>
100
104
  <Tooltip.Portal>
@@ -31,23 +31,18 @@ export interface TwTableContextToolbarProps {
31
31
  /**
32
32
  * Phase D.2 — progressive-disclosure compact mode.
33
33
  *
34
- * When `true`, the toolbar reduces to:
35
- * - the tier badge + size + selection label (diagnostic context)
36
- * - a single "More…" button that fires `onOpenMore`
34
+ * When `true`, the toolbar stays action-first:
35
+ * - a small context label
36
+ * - the tier's highest-frequency table actions
37
+ * - a single "More…" button that opens the shared command graph
37
38
  *
38
- * All action controls (Add row / Merge / Split / Fill / Style /
39
- * Delete / Align / V-align / Distribute / Borders) are suppressed;
40
- * the full set lives in the shared editor-action-registry and is
41
- * accessed via right-click OR the "More…" button (which opens the
42
- * same context menu). Per DESIGN-EDITOR.md §6.4 ("right-click
43
- * cannot be richer than the floating surface; cannot duplicate
44
- * command trees") the compact variant pins that rule — there IS no
45
- * richer surface to diverge from.
39
+ * Diagnostic metadata such as "3 x 4" or "R1 C1" is intentionally
40
+ * omitted from the compact surface. It belongs in properties /
41
+ * diagnostics, not primary editing chrome.
46
42
  *
47
- * Default `false` preserves the rich in-tree behavior so no
48
- * existing integrator breaks. Phase D.2 ships the opt-in; Phase E
49
- * flips the default to `true` once the end-to-end
50
- * `onContextMenuRequested` wiring is proven.
43
+ * Default `false` preserves the rich in-tree behavior for standalone
44
+ * and back-compat mounts. The product selection-tool path opts into
45
+ * compact mode.
51
46
  */
52
47
  compact?: boolean;
53
48
  /**
@@ -146,29 +141,37 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
146
141
  : null;
147
142
  const selectionLabel = tableContext ? formatSelectionLabel(tableContext, tier) : null;
148
143
 
149
- // Phase D.2 — compact variant: minimal inline affordance + one
150
- // "More…" button that opens the shared context menu. The full
151
- // action set lives in editor-action-registry so right-click and
152
- // "More…" always show identical options (DESIGN-EDITOR.md §6.4).
144
+ // Product compact variant: action-first local chrome + one "More…"
145
+ // button that opens the shared context menu. The full action set
146
+ // still lives in editor-action-registry so right-click and "More…"
147
+ // stay identical; this surface only promotes the tier's obvious next
148
+ // actions and leaves metadata to deeper surfaces.
153
149
  if (props.compact) {
154
150
  return (
155
151
  <div
156
152
  data-testid="table-context-toolbar"
157
153
  data-tier={tier}
158
154
  data-variant="compact"
159
- className="inline-flex items-center gap-[6px] rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-canvas)] px-2 py-1 shadow-[var(--shadow-float)]"
155
+ data-purpose="table-actions"
156
+ className="inline-flex max-w-[min(28rem,calc(100vw-1.5rem))] flex-wrap items-center gap-[4px] rounded-[var(--radius-lg)] border border-[var(--color-border-subtle)] bg-[var(--color-bg-canvas)] px-2 py-1 shadow-[var(--shadow-float)]"
160
157
  >
161
- <span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
162
- {tierLabel(tier)}
158
+ <span
159
+ data-testid="table-context-toolbar-label"
160
+ className="mr-0.5 text-[9px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]"
161
+ >
162
+ {compactTierLabel(tier)}
163
163
  </span>
164
- {tableSizeLabel ? <ToolbarBadge>{tableSizeLabel}</ToolbarBadge> : null}
165
- {selectionLabel ? <ToolbarBadge>{selectionLabel}</ToolbarBadge> : null}
164
+ <CompactTableActions
165
+ props={props}
166
+ tableContext={tableContext}
167
+ tier={tier}
168
+ />
166
169
  <button
167
170
  type="button"
168
171
  data-testid="table-context-toolbar-more"
169
172
  aria-label="Table actions menu"
170
173
  className="inline-flex h-6 items-center gap-1 rounded-[var(--radius-sm)] px-2 text-xs font-medium text-secondary hover:bg-hover focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]"
171
- disabled={props.disabled}
174
+ disabled={props.disabled || !props.onOpenMore}
172
175
  onMouseDown={preserveEditorSelectionMouseDown}
173
176
  onClick={(ev) => {
174
177
  const rect = (ev.currentTarget as HTMLButtonElement).getBoundingClientRect();
@@ -457,6 +460,149 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
457
460
  );
458
461
  }
459
462
 
463
+ function CompactTableActions(args: {
464
+ props: TwTableContextToolbarProps;
465
+ tableContext: TableStructureContextSnapshot | null;
466
+ tier: TableTier;
467
+ }) {
468
+ const { props, tableContext, tier } = args;
469
+ switch (tier) {
470
+ case "caret-in-cell":
471
+ return (
472
+ <>
473
+ <ToolbarButton
474
+ ariaLabel="Insert row below"
475
+ capability={tableContext?.operations.addRowAfter}
476
+ disabled={props.disabled}
477
+ onClick={props.onAddRowAfter}
478
+ >
479
+ Row +
480
+ </ToolbarButton>
481
+ <ToolbarButton
482
+ ariaLabel="Insert column right"
483
+ capability={tableContext?.operations.addColumnAfter}
484
+ disabled={props.disabled}
485
+ onClick={props.onAddColumnAfter}
486
+ >
487
+ Col +
488
+ </ToolbarButton>
489
+ </>
490
+ );
491
+ case "multi-cell":
492
+ return (
493
+ <>
494
+ <ToolbarButton
495
+ ariaLabel="Merge cells"
496
+ capability={tableContext?.operations.mergeCells}
497
+ disabled={props.disabled}
498
+ onClick={props.onMergeCells}
499
+ >
500
+ Merge
501
+ </ToolbarButton>
502
+ <ToolbarButton
503
+ ariaLabel="Split cell"
504
+ capability={tableContext?.operations.splitCell}
505
+ disabled={props.disabled}
506
+ onClick={props.onSplitCell}
507
+ >
508
+ Split
509
+ </ToolbarButton>
510
+ </>
511
+ );
512
+ case "row-selected":
513
+ return (
514
+ <>
515
+ <ToolbarButton
516
+ ariaLabel="Insert row below"
517
+ capability={tableContext?.operations.addRowAfter}
518
+ disabled={props.disabled}
519
+ onClick={props.onAddRowAfter}
520
+ >
521
+ Row +
522
+ </ToolbarButton>
523
+ <ToolbarButton
524
+ ariaLabel="Delete row"
525
+ capability={tableContext?.operations.deleteRow}
526
+ danger
527
+ disabled={props.disabled}
528
+ onClick={props.onDeleteRow}
529
+ >
530
+ Delete
531
+ </ToolbarButton>
532
+ <ToolbarButton
533
+ ariaLabel="Toggle header row"
534
+ capability={tableContext?.operations.setRowIsHeader}
535
+ active={tableContext?.currentCell.isHeader}
536
+ disabled={props.disabled}
537
+ onClick={props.onToggleRowHeader}
538
+ >
539
+ Header
540
+ </ToolbarButton>
541
+ </>
542
+ );
543
+ case "column-selected":
544
+ return (
545
+ <>
546
+ <ToolbarButton
547
+ ariaLabel="Insert column right"
548
+ capability={tableContext?.operations.addColumnAfter}
549
+ disabled={props.disabled}
550
+ onClick={props.onAddColumnAfter}
551
+ >
552
+ Col +
553
+ </ToolbarButton>
554
+ <ToolbarButton
555
+ ariaLabel="Delete column"
556
+ capability={tableContext?.operations.deleteColumn}
557
+ danger
558
+ disabled={props.disabled}
559
+ onClick={props.onDeleteColumn}
560
+ >
561
+ Delete
562
+ </ToolbarButton>
563
+ <ToolbarButton
564
+ ariaLabel="Distribute columns evenly"
565
+ capability={tableContext?.operations.distributeColumnsEvenly}
566
+ disabled={props.disabled}
567
+ onClick={props.onDistributeColumnsEvenly}
568
+ >
569
+ Distribute
570
+ </ToolbarButton>
571
+ </>
572
+ );
573
+ case "whole-table":
574
+ return (
575
+ <>
576
+ <ToolbarButton
577
+ ariaLabel="Insert row below"
578
+ capability={tableContext?.operations.addRowAfter}
579
+ disabled={props.disabled}
580
+ onClick={props.onAddRowAfter}
581
+ >
582
+ Row +
583
+ </ToolbarButton>
584
+ <ToolbarButton
585
+ ariaLabel="Insert column right"
586
+ capability={tableContext?.operations.addColumnAfter}
587
+ disabled={props.disabled}
588
+ onClick={props.onAddColumnAfter}
589
+ >
590
+ Col +
591
+ </ToolbarButton>
592
+ <ToolbarButton
593
+ ariaLabel="Delete table"
594
+ capability={tableContext?.operations.deleteTable}
595
+ danger
596
+ disabled={props.disabled}
597
+ onClick={props.onDeleteTable}
598
+ >
599
+ Delete
600
+ </ToolbarButton>
601
+ </>
602
+ );
603
+ }
604
+ }
605
+
460
606
  function formatSelectionLabel(
461
607
  ctx: TableStructureContextSnapshot,
462
608
  tier: TableTier,
@@ -491,6 +637,10 @@ function tierLabel(tier: TableTier): string {
491
637
  }
492
638
  }
493
639
 
640
+ function compactTierLabel(tier: TableTier): string {
641
+ return tier === "whole-table" ? "Table" : tierLabel(tier);
642
+ }
643
+
494
644
  function tierWidthCap(tier: TableTier): string {
495
645
  switch (tier) {
496
646
  case "caret-in-cell":
@@ -154,7 +154,7 @@ export interface TwChromeOverlayProps {
154
154
  */
155
155
  activeStory?: EditorStoryTarget;
156
156
  /**
157
- * Fired when the user clicks a per-page header / footer band to
157
+ * Fired when the user double-clicks a per-page header / footer band to
158
158
  * promote it into the active editing surface. Task 10 will route PM
159
159
  * into the matching band via React portals; today the handler is a
160
160
  * pass-through to `runtime.openStory`.
@@ -513,6 +513,18 @@ export function buildDecorations(
513
513
  }),
514
514
  );
515
515
  revisionCount += 1;
516
+ } else if (rev.kind === "property-change" || rev.kind === "formatting") {
517
+ const propertyChangeClass =
518
+ buildClassFromRevisionDisplay(revDisplayFlags) ||
519
+ "underline decoration-accent/70 decoration-dotted decoration-1 underline-offset-2";
520
+ decorations.push(
521
+ Decoration.inline(pmFrom, pmTo, {
522
+ class: propertyChangeClass,
523
+ "data-revision-id": rev.revisionId,
524
+ "data-revision-kind": rev.kind,
525
+ }),
526
+ );
527
+ revisionCount += 1;
516
528
  }
517
529
  continue;
518
530
  }
@@ -392,7 +392,7 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
392
392
  root.style.userSelect = "none";
393
393
 
394
394
  if (input.posture === "canvas") {
395
- // Single dotted horizontal line with a small page-number callout.
395
+ // Single dotted horizontal line with an unframed page-number label.
396
396
  root.style.height = `${input.interGapPx + 1}px`;
397
397
  root.style.position = "relative";
398
398
 
@@ -406,35 +406,23 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
406
406
  line.style.borderTop = "1px dotted var(--color-border-default)";
407
407
  root.appendChild(line);
408
408
 
409
- // L6d.U2: badge is now a true pill — `--radius-pill` geometry,
410
- // hairline `--color-border-default` border, subtle `--shadow-soft`
411
- // so the callout reads as a card floating over the seam line rather
412
- // than text painted on top of it. The pill is 18 px tall (10 px
413
- // font + 8 px vertical padding + hairline border); re-center so the
414
- // seam line bisects the pill vertically.
415
- const PILL_HEIGHT_PX = 18;
416
- const badge = document.createElement("span");
417
- badge.className = "wre-page-chrome-canvas-badge";
418
- badge.setAttribute("data-kind", "canvas-seam-badge");
419
- badge.setAttribute("data-variant", "pill");
420
- badge.textContent = input.nextPageLabel;
421
- badge.style.position = "absolute";
422
- badge.style.top = `${Math.round(input.interGapPx / 2) - Math.round(PILL_HEIGHT_PX / 2)}px`;
423
- badge.style.left = "50%";
424
- badge.style.transform = "translateX(-50%)";
425
- badge.style.display = "inline-flex";
426
- badge.style.alignItems = "center";
427
- badge.style.height = `${PILL_HEIGHT_PX}px`;
428
- badge.style.padding = "0 10px";
429
- badge.style.fontSize = "10px";
430
- badge.style.letterSpacing = "0.12em";
431
- badge.style.textTransform = "uppercase";
432
- badge.style.color = "var(--color-text-tertiary)";
433
- badge.style.backgroundColor = "var(--color-surface)";
434
- badge.style.border = "1px solid var(--color-border-default)";
435
- badge.style.borderRadius = "var(--radius-pill)";
436
- badge.style.boxShadow = "var(--shadow-soft)";
437
- root.appendChild(badge);
409
+ const label = document.createElement("span");
410
+ label.className = "wre-page-chrome-canvas-page-number";
411
+ label.setAttribute("data-kind", "canvas-seam-page-number");
412
+ label.textContent = input.nextPageLabel;
413
+ label.style.position = "absolute";
414
+ label.style.top = `${Math.round(input.interGapPx / 2)}px`;
415
+ label.style.left = "50%";
416
+ label.style.transform = "translate(-50%, -50%)";
417
+ label.style.display = "inline-flex";
418
+ label.style.alignItems = "center";
419
+ label.style.padding = "0 2px";
420
+ label.style.fontSize = "10px";
421
+ label.style.letterSpacing = "0.12em";
422
+ label.style.textTransform = "uppercase";
423
+ label.style.color = "var(--color-text-tertiary)";
424
+ label.style.pointerEvents = "none";
425
+ root.appendChild(label);
438
426
  return root;
439
427
  }
440
428
 
@@ -562,7 +562,7 @@ function buildInlineContent(
562
562
  fieldTarget: segment.fieldTarget ?? null,
563
563
  instruction: segment.instruction,
564
564
  refreshStatus: segment.refreshStatus,
565
- label: segment.label,
565
+ label: segment.displayText ?? segment.label,
566
566
  }),
567
567
  ];
568
568
 
@@ -75,7 +75,7 @@ function renderSegment(seg: SurfaceInlineSegment, tabWidthsPt: Map<string, numbe
75
75
  data-node-type="field_ref"
76
76
  style={{ opacity: 0.6, fontSize: "0.85em" }}
77
77
  >
78
- [field]
78
+ {seg.displayText ?? seg.label}
79
79
  </span>
80
80
  );
81
81
  case "note_ref":
@@ -35,7 +35,8 @@ export interface TwPageChromeEntryProps {
35
35
  page: PublicPageNode;
36
36
  facet: WordReviewEditorLayoutFacet;
37
37
  activeStory: EditorStoryTarget;
38
- onOpenStory?: (target: EditorStoryTarget) => void;
38
+ activeStoryPageIndex?: number | null;
39
+ onOpenStory?: (target: EditorStoryTarget, pageIndex: number) => void;
39
40
  visiblePageIndexRange?: { start: number; end: number } | null;
40
41
  renderFrameRevision: number;
41
42
  /** Preview catalog threaded into header/footer/footnote region renderers
@@ -58,6 +59,7 @@ function TwPageChromeEntryInner({
58
59
  page,
59
60
  facet,
60
61
  activeStory,
62
+ activeStoryPageIndex,
61
63
  onOpenStory,
62
64
  visiblePageIndexRange,
63
65
  renderFrameRevision,
@@ -129,14 +131,14 @@ function TwPageChromeEntryInner({
129
131
  // eslint-disable-next-line react-hooks/exhaustive-deps
130
132
  }, [facet, pageIndex, page, renderFrameRevision]);
131
133
 
132
- const handleHeaderClick = React.useCallback(
133
- () => headerStory && onOpenStory?.(headerStory),
134
- [onOpenStory, headerStory],
134
+ const handleHeaderDoubleClick = React.useCallback(
135
+ () => headerStory && onOpenStory?.(headerStory, pageIndex),
136
+ [onOpenStory, headerStory, pageIndex],
135
137
  );
136
138
 
137
- const handleFooterClick = React.useCallback(
138
- () => footerStory && onOpenStory?.(footerStory),
139
- [onOpenStory, footerStory],
139
+ const handleFooterDoubleClick = React.useCallback(
140
+ () => footerStory && onOpenStory?.(footerStory, pageIndex),
141
+ [onOpenStory, footerStory, pageIndex],
140
142
  );
141
143
 
142
144
  const frameHeightPx = rect.bottomPx - rect.topPx;
@@ -175,8 +177,14 @@ function TwPageChromeEntryInner({
175
177
  const sectionNumber = (page.sectionIndex ?? 0) + 1;
176
178
  const headerSectionLabel = `Header — Section ${sectionNumber}`;
177
179
  const footerSectionLabel = `Footer — Section ${sectionNumber}`;
178
- const headerActive = headerStory && isActiveStoryMatch(activeStory, headerStory);
179
- const footerActive = footerStory && isActiveStoryMatch(activeStory, footerStory);
180
+ const headerActive =
181
+ headerStory &&
182
+ isActiveStoryMatch(activeStory, headerStory) &&
183
+ (activeStoryPageIndex == null || activeStoryPageIndex === pageIndex);
184
+ const footerActive =
185
+ footerStory &&
186
+ isActiveStoryMatch(activeStory, footerStory) &&
187
+ (activeStoryPageIndex == null || activeStoryPageIndex === pageIndex);
180
188
 
181
189
  return (
182
190
  <div
@@ -201,7 +209,7 @@ function TwPageChromeEntryInner({
201
209
  bandHeightPx={px(headerRegion.heightTwips)}
202
210
  isActiveSlot={Boolean(headerActive)}
203
211
  sectionLabel={headerActive ? headerSectionLabel : undefined}
204
- onClick={handleHeaderClick}
212
+ onDoubleClick={handleHeaderDoubleClick}
205
213
  mediaPreviews={mediaPreviews}
206
214
  ribbonProps={headerActive ? activeBandRibbonProps ?? null : null}
207
215
  />
@@ -216,7 +224,7 @@ function TwPageChromeEntryInner({
216
224
  bandHeightPx={px(footerRegion.heightTwips)}
217
225
  isActiveSlot={Boolean(footerActive)}
218
226
  sectionLabel={footerActive ? footerSectionLabel : undefined}
219
- onClick={handleFooterClick}
227
+ onDoubleClick={handleFooterDoubleClick}
220
228
  mediaPreviews={mediaPreviews}
221
229
  ribbonProps={footerActive ? activeBandRibbonProps ?? null : null}
222
230
  />
@@ -256,6 +264,7 @@ function propsAreEqual(
256
264
  prev.page === next.page &&
257
265
  prev.facet === next.facet &&
258
266
  prev.activeStory === next.activeStory &&
267
+ prev.activeStoryPageIndex === next.activeStoryPageIndex &&
259
268
  prev.onOpenStory === next.onOpenStory &&
260
269
  prev.visiblePageIndexRange === next.visiblePageIndexRange &&
261
270
  prev.renderFrameRevision === next.renderFrameRevision &&
@@ -37,7 +37,7 @@ export interface TwPageFooterBandProps {
37
37
  * Only rendered when `isActiveSlot` is true.
38
38
  */
39
39
  sectionLabel?: string;
40
- onClick: () => void;
40
+ onDoubleClick: () => void;
41
41
  "data-testid"?: string;
42
42
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
43
43
  /**
@@ -57,7 +57,7 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
57
57
  widthPx,
58
58
  isActiveSlot,
59
59
  sectionLabel,
60
- onClick,
60
+ onDoubleClick,
61
61
  "data-testid": testId,
62
62
  mediaPreviews,
63
63
  ribbonProps,
@@ -69,14 +69,18 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
69
69
  data-page-index={pageIndex}
70
70
  data-active={isActiveSlot ? "true" : undefined}
71
71
  data-testid={testId}
72
- onClick={onClick}
72
+ onDoubleClick={(event) => {
73
+ event.preventDefault();
74
+ event.stopPropagation();
75
+ onDoubleClick();
76
+ }}
73
77
  style={{
74
78
  position: "absolute",
75
79
  bottom: `${bottomPx}px`,
76
80
  left: `${leftPx}px`,
77
81
  width: `${widthPx}px`,
78
82
  height: `${bandHeightPx}px`,
79
- cursor: "pointer",
83
+ cursor: "text",
80
84
  }}
81
85
  >
82
86
  {isActiveSlot && sectionLabel ? (
@@ -95,6 +99,7 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
95
99
  <div
96
100
  data-pm-portal-slot
97
101
  data-page-band-slot="footer"
102
+ data-page-index={pageIndex}
98
103
  style={{ width: "100%", height: "100%" }}
99
104
  />
100
105
  ) : (
@@ -19,9 +19,9 @@ import {
19
19
  // promoted this band to the active story slot (P8.10 wires the PM
20
20
  // surface into this target via React portal).
21
21
  //
22
- // Clicks on the band bubble to the chrome layer's `openStory` dispatch
23
- // (wired in P8.8 / P8.10) so legal reviewers can promote a header into
24
- // the active editing surface.
22
+ // Double-clicks on the band dispatch to the chrome layer's `openStory`
23
+ // handler so legal reviewers can promote a header into the active editing
24
+ // surface without single-clicking out of the main body flow.
25
25
  // ---------------------------------------------------------------------------
26
26
 
27
27
  export interface TwPageHeaderBandProps {
@@ -38,7 +38,7 @@ export interface TwPageHeaderBandProps {
38
38
  * Only rendered when `isActiveSlot` is true.
39
39
  */
40
40
  sectionLabel?: string;
41
- onClick: () => void;
41
+ onDoubleClick: () => void;
42
42
  "data-testid"?: string;
43
43
  /** Preview catalog threaded to the region renderer so header images
44
44
  * (CCEP logos on 7-of-8 CCEP docs) render as real <img>s instead of
@@ -63,7 +63,7 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
63
63
  widthPx,
64
64
  isActiveSlot,
65
65
  sectionLabel,
66
- onClick,
66
+ onDoubleClick,
67
67
  "data-testid": testId,
68
68
  mediaPreviews,
69
69
  ribbonProps,
@@ -75,14 +75,18 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
75
75
  data-page-index={pageIndex}
76
76
  data-active={isActiveSlot ? "true" : undefined}
77
77
  data-testid={testId}
78
- onClick={onClick}
78
+ onDoubleClick={(event) => {
79
+ event.preventDefault();
80
+ event.stopPropagation();
81
+ onDoubleClick();
82
+ }}
79
83
  style={{
80
84
  position: "absolute",
81
85
  top: `${topPx}px`,
82
86
  left: `${leftPx}px`,
83
87
  width: `${widthPx}px`,
84
88
  height: `${bandHeightPx}px`,
85
- cursor: "pointer",
89
+ cursor: "text",
86
90
  }}
87
91
  >
88
92
  {isActiveSlot && sectionLabel ? (
@@ -101,6 +105,7 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
101
105
  <div
102
106
  data-pm-portal-slot
103
107
  data-page-band-slot="header"
108
+ data-page-index={pageIndex}
104
109
  style={{ width: "100%", height: "100%" }}
105
110
  />
106
111
  ) : (