@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -43,6 +43,14 @@ export interface LocalEditSessionState {
43
43
  clearAllPending(): PendingOp[];
44
44
  hasPending(): boolean;
45
45
  isPredicted(opId: string): boolean;
46
+ /**
47
+ * IME composition state. Set to true while the browser is composing an
48
+ * IME input sequence (between `compositionstart` and `compositionend`);
49
+ * the predicted lane must bail from `run()` when composing so IME and
50
+ * prediction do not fight over the same DOM range.
51
+ */
52
+ isComposing(): boolean;
53
+ setComposing(composing: boolean): void;
46
54
  }
47
55
 
48
56
  export interface CreateLocalEditSessionStateOptions {
@@ -53,12 +61,15 @@ export function createLocalEditSessionState(
53
61
  options: CreateLocalEditSessionStateOptions,
54
62
  ): LocalEditSessionState {
55
63
  let baseRevisionToken = options.baseRevisionToken;
64
+ let composing = false;
56
65
  const pendingOps: PendingOp[] = [];
57
66
  const predictedIds = new Set<string>();
58
67
 
59
68
  return {
60
69
  getBaseRevisionToken: () => baseRevisionToken,
61
70
  getPendingOps: () => pendingOps.slice(),
71
+ isComposing: () => composing,
72
+ setComposing: (value) => { composing = value; },
62
73
  appendPending(op) {
63
74
  pendingOps.push(op);
64
75
  predictedIds.add(op.opId);
@@ -0,0 +1,15 @@
1
+ import type { DocumentPageSnapshot, SurfaceBlockSnapshot } from "../../api/public-types.ts";
2
+
3
+ /**
4
+ * Filter surface blocks to those that overlap a page's character-offset range.
5
+ * A block overlaps [startOffset, endOffset) when block.from < endOffset AND block.to > startOffset.
6
+ * Blocks that straddle a page boundary appear on both pages.
7
+ */
8
+ export function sliceBlocksForPage(
9
+ blocks: SurfaceBlockSnapshot[],
10
+ page: Pick<DocumentPageSnapshot, "startOffset" | "endOffset">,
11
+ ): SurfaceBlockSnapshot[] {
12
+ return blocks.filter(
13
+ (b) => b.from < page.endOffset && b.to > page.startOffset,
14
+ );
15
+ }
@@ -15,7 +15,13 @@ export type PerfProbeKind =
15
15
  | "workspace.chrome"
16
16
  | "selection.sync"
17
17
  | "layout.incremental"
18
- | "layout.full";
18
+ | "layout.full"
19
+ | "render.frame_build"
20
+ | "render.frame_diff"
21
+ | "render.decoration_resolve"
22
+ | "chrome.overlay_reposition"
23
+ | "chrome.hit_test"
24
+ | "rail.segment_project";
19
25
 
20
26
  /**
21
27
  * Counter names the FastTextEditLane emits via `incrementInvalidationCounter`.
@@ -23,9 +23,17 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
23
23
  onInsertHardBreak: () => void;
24
24
  onInsertTab: () => void;
25
25
  onOutdentTab?: () => void;
26
+ onListIndent?: () => void;
27
+ onListOutdent?: () => void;
26
28
  onUndo: () => void;
27
29
  onRedo: () => void;
28
30
  onBlockedInput?: (command: "paste" | "drop", message: string) => void;
31
+ /**
32
+ * Optional. Fires on `compositionstart` (true) and `compositionend`
33
+ * (false). The surface forwards this to the predicted lane's session
34
+ * so the lane can bail from `run()` while IME is composing.
35
+ */
36
+ onCompositionChange?: (composing: boolean) => void;
29
37
  /**
30
38
  * Optional predicted-tx gate plugin. When provided, it replaces the
31
39
  * default unconditional filter so the FastTextEditLane can apply
@@ -71,8 +79,14 @@ export function createSelectionSyncPlugin(
71
79
  });
72
80
  }
73
81
 
82
+ export interface CommandBridgePluginOptions {
83
+ /** P6: when true, omit prosemirror-tables columnResizing() — chrome overlay grips take over. */
84
+ useChromeColumnResizing?: boolean;
85
+ }
86
+
74
87
  export function createCommandBridgePlugins(
75
88
  callbacks: CommandBridgeCallbacks,
89
+ options?: CommandBridgePluginOptions,
76
90
  ): Plugin[] {
77
91
  let isComposing = false;
78
92
 
@@ -91,15 +105,20 @@ export function createCommandBridgePlugins(
91
105
  props: {
92
106
  handleDOMEvents: {
93
107
  blur() {
94
- isComposing = false;
108
+ if (isComposing) {
109
+ isComposing = false;
110
+ callbacks.onCompositionChange?.(false);
111
+ }
95
112
  return false;
96
113
  },
97
114
  compositionstart() {
98
115
  isComposing = true;
116
+ callbacks.onCompositionChange?.(true);
99
117
  return false;
100
118
  },
101
119
  compositionend() {
102
120
  isComposing = false;
121
+ callbacks.onCompositionChange?.(false);
103
122
  return false;
104
123
  },
105
124
  },
@@ -134,6 +153,18 @@ export function createCommandBridgePlugins(
134
153
  return false;
135
154
  }
136
155
 
156
+ // Alt+Shift+Right → list indent; Alt+Shift+Left → list outdent (Word keyboard behavior)
157
+ if (event.altKey && event.shiftKey) {
158
+ if (event.key === "ArrowRight") {
159
+ callbacks.onListIndent?.();
160
+ return true;
161
+ }
162
+ if (event.key === "ArrowLeft") {
163
+ callbacks.onListOutdent?.();
164
+ return true;
165
+ }
166
+ }
167
+
137
168
  const resolution = resolveSurfaceShortcut(
138
169
  {
139
170
  key: event.key,
@@ -184,7 +215,12 @@ export function createCommandBridgePlugins(
184
215
  // selection handles. Doc-changing table transactions (new rows, etc.) are
185
216
  // filtered by the runtime filter above; navigation-only steps pass through.
186
217
  const tablePlugin = tableEditing();
187
- const columnResizingPlugin = columnResizing();
188
-
189
- return [filterPlugin, selectionPlugin, inputPlugin, keydownPlugin, tablePlugin, columnResizingPlugin];
218
+ // P6: chrome overlay grips replace prosemirror-tables column resizing.
219
+ // useChromeColuumnResizing=true removes the PM plugin; set to false to fall
220
+ // back to the legacy prosemirror-tables drag handle for one release cycle.
221
+ const plugins: Plugin[] = [filterPlugin, selectionPlugin, inputPlugin, keydownPlugin, tablePlugin];
222
+ if (!options?.useChromeColumnResizing) {
223
+ plugins.push(columnResizing());
224
+ }
225
+ return plugins;
190
226
  }
@@ -223,6 +223,19 @@ function subtractInlineOverlaps(
223
223
  return segments.filter((segment) => segment.from < segment.to);
224
224
  }
225
225
 
226
+ /**
227
+ * Rail decorations are now rendered on the `ChromeOverlay` plane via the
228
+ * `TwScopeRailLayer` consumer of `facet.getAllScopeRailSegments()`, not
229
+ * through PM Decoration.node. This function keeps its signature so the
230
+ * call sites below continue to compile; it warms the range cache (which
231
+ * other PM decorations can still consume) but emits no node decoration.
232
+ *
233
+ * Per runtime-rendering-and-chrome-phase.md §5 the rail must live outside
234
+ * the PM NodeView tree so: (a) the user perceives it as chrome, not
235
+ * document content, (b) predicted transactions never flash rail visuals,
236
+ * and (c) the rail can extend into the page-margin gutter, which PM
237
+ * cannot paint through block decorations.
238
+ */
226
239
  function pushRailDecorations(
227
240
  decorations: Decoration[],
228
241
  doc: PMNode,
@@ -231,19 +244,11 @@ function pushRailDecorations(
231
244
  spec: RailDecorationSpec,
232
245
  rangeCache: Map<string, Array<{ from: number; to: number }>>,
233
246
  ): void {
247
+ void decorations;
248
+ void spec;
234
249
  const cacheKey = `${from}:${to}`;
235
- const ranges = rangeCache.get(cacheKey) ?? collectRailRanges(doc, from, to);
236
250
  if (!rangeCache.has(cacheKey)) {
237
- rangeCache.set(cacheKey, ranges);
238
- }
239
- for (const range of ranges) {
240
- decorations.push(
241
- Decoration.node(range.from, range.to, {
242
- class: spec.className,
243
- "data-workflow-rail": spec.railKind,
244
- ...spec.attrs,
245
- }),
246
- );
251
+ rangeCache.set(cacheKey, collectRailRanges(doc, from, to));
247
252
  }
248
253
  }
249
254
 
@@ -458,7 +463,12 @@ export function buildDecorations(
458
463
  activeScopeIds.has(scope.scopeId)
459
464
  );
460
465
 
461
- if (isSelectionZone && pmRange.allowInline && pmRange.from < pmRange.to) {
466
+ if (pmRange.allowInline && pmRange.from < pmRange.to) {
467
+ // Post-R3a: every workflow scope emits inline decorations with
468
+ // the scope-id attribution. The flat block-tint + gutter rail
469
+ // render on the ChromeOverlay — PM keeps only inline class hooks
470
+ // so selection tools, accessibility, and host scripts can still
471
+ // resolve the active scope at a text offset.
462
472
  const visibleScopeSegments = subtractInlineOverlaps(
463
473
  { from: pmRange.from, to: pmRange.to },
464
474
  lockedPmRanges.filter((range) => range.to > pmRange.from && range.from < pmRange.to),
@@ -0,0 +1,389 @@
1
+ /**
2
+ * In-flow page chrome as ProseMirror widget decorations.
3
+ *
4
+ * Every page boundary (between pages N and N+1) gets a widget whose DOM
5
+ * renders the full visible inter-page chrome:
6
+ * - the bottom edge + footer band of page N (with a PAGE/NUMPAGES read
7
+ * for page N)
8
+ * - a visible vertical gap with the inter-page "separator line" between
9
+ * the two frames (the frames themselves are rendered as CSS borders
10
+ * on the chrome widget's sections, so no absolute overlay is needed)
11
+ * - the top edge + header band of page N+1 (with a PAGE/NUMPAGES read
12
+ * for page N+1)
13
+ *
14
+ * Because the chrome lives INSIDE the PM flow, it cannot drift relative
15
+ * to paragraphs: whatever height the browser paints for the paragraphs
16
+ * naturally stacks with the widget's fixed height. No DOM measurement
17
+ * or absolute positioning is needed — this closes the alignment gap the
18
+ * earlier absolute-overlay approach suffered from.
19
+ *
20
+ * Double-clicking a band dispatches a custom event
21
+ * `wre-open-header-story-for-page` / `wre-open-footer-story-for-page`
22
+ * whose `detail.pageIndex` is the target page. The shell listens at the
23
+ * document level and routes to `runtime.openStory()`.
24
+ */
25
+
26
+ import { Decoration } from "prosemirror-view";
27
+ import type { RuntimePageGraph } from "../../runtime/layout/page-graph.ts";
28
+ import { resolvePageFieldDisplayText } from "../../runtime/layout/resolve-page-fields.ts";
29
+
30
+ export const PAGE_CHROME_DEFAULTS = {
31
+ headerBandPx: 32,
32
+ footerBandPx: 32,
33
+ interGapPx: 24,
34
+ } as const;
35
+
36
+ export function totalPageBreakGapPx(
37
+ dimensions: {
38
+ headerBandPx?: number;
39
+ footerBandPx?: number;
40
+ interGapPx?: number;
41
+ } = {},
42
+ ): number {
43
+ const header = dimensions.headerBandPx ?? PAGE_CHROME_DEFAULTS.headerBandPx;
44
+ const footer = dimensions.footerBandPx ?? PAGE_CHROME_DEFAULTS.footerBandPx;
45
+ const gap = dimensions.interGapPx ?? PAGE_CHROME_DEFAULTS.interGapPx;
46
+ return header + footer + gap;
47
+ }
48
+
49
+ export type PageChromePosture = "canvas" | "page";
50
+
51
+ export interface PageBreakDecorationInput {
52
+ graph: RuntimePageGraph | null;
53
+ /** Controls the visual weight of the chrome; canvas is minimal. */
54
+ posture: PageChromePosture;
55
+ /** Height in px of each page's header band. Default 32. */
56
+ headerBandPx?: number;
57
+ /** Height in px of each page's footer band. Default 32. */
58
+ footerBandPx?: number;
59
+ /** Visible gap between the footer of page N and the header of page N+1. */
60
+ interGapPx?: number;
61
+ /**
62
+ * Map a canonical runtime offset to a ProseMirror document offset. The
63
+ * surface's `PositionMap` provides this. Optional — when omitted, we
64
+ * use the runtime offset as-is (tests without a mapped PM surface).
65
+ */
66
+ runtimeToPmOffset?: (runtimeOffset: number) => number | null;
67
+ /**
68
+ * Optional per-page preview text for the header band (`pageId` →
69
+ * flattened first-paragraph text with PAGE / NUMPAGES resolved). When a
70
+ * preview is present it replaces the generic "Header" label in the band
71
+ * on that page. See `resolve-page-previews.ts`.
72
+ */
73
+ headerPreviewByPageId?: ReadonlyMap<string, string>;
74
+ /** Same shape for footers. */
75
+ footerPreviewByPageId?: ReadonlyMap<string, string>;
76
+ }
77
+
78
+ export function buildPageBreakDecorations(
79
+ input: PageBreakDecorationInput,
80
+ ): Decoration[] {
81
+ const { graph, posture, runtimeToPmOffset } = input;
82
+ if (!graph || graph.pages.length < 2) return [];
83
+
84
+ const headerBandPx =
85
+ input.headerBandPx ?? PAGE_CHROME_DEFAULTS.headerBandPx;
86
+ const footerBandPx =
87
+ input.footerBandPx ?? PAGE_CHROME_DEFAULTS.footerBandPx;
88
+ const interGapPx = input.interGapPx ?? PAGE_CHROME_DEFAULTS.interGapPx;
89
+
90
+ const decorations: Decoration[] = [];
91
+ for (let i = 1; i < graph.pages.length; i += 1) {
92
+ const prev = graph.pages[i - 1]!;
93
+ const next = graph.pages[i]!;
94
+ if (next.isBlankFiller) continue;
95
+ const runtimeOffset = next.startOffset;
96
+ const pmOffset = runtimeToPmOffset
97
+ ? runtimeToPmOffset(runtimeOffset)
98
+ : runtimeOffset;
99
+ if (pmOffset === null || pmOffset === undefined) continue;
100
+
101
+ const prevPageLabel = pageLabelForChrome(prev, graph, posture);
102
+ const nextPageLabel = pageLabelForChrome(next, graph, posture);
103
+
104
+ const prevFooterPreview =
105
+ input.footerPreviewByPageId?.get(prev.pageId) ?? "";
106
+ const nextHeaderPreview =
107
+ input.headerPreviewByPageId?.get(next.pageId) ?? "";
108
+
109
+ decorations.push(
110
+ Decoration.widget(
111
+ pmOffset,
112
+ () =>
113
+ buildChromeWidgetDom({
114
+ posture,
115
+ prevPageId: prev.pageId,
116
+ prevPageIndex: prev.pageIndex,
117
+ nextPageId: next.pageId,
118
+ nextPageIndex: next.pageIndex,
119
+ headerBandPx,
120
+ footerBandPx,
121
+ interGapPx,
122
+ prevPageLabel,
123
+ nextPageLabel,
124
+ hasPrevFooterStory: Boolean(prev.stories.footer),
125
+ hasNextHeaderStory: Boolean(next.stories.header),
126
+ prevFooterPreview,
127
+ nextHeaderPreview,
128
+ }),
129
+ {
130
+ side: -1,
131
+ key: `pb-${prev.pageId}-${next.pageId}-${posture}`,
132
+ ignoreSelection: true,
133
+ stopEvent: (event) => {
134
+ // Keep the dbl-click from bubbling into PM and stealing focus.
135
+ if (event.type === "mousedown" || event.type === "click") {
136
+ return true;
137
+ }
138
+ return false;
139
+ },
140
+ },
141
+ ),
142
+ );
143
+ }
144
+ return decorations;
145
+ }
146
+
147
+ /**
148
+ * The page label shown in both the footer band of page N and the header
149
+ * band of page N+1. Canvas mode uses a terser "N / M" format because the
150
+ * dotted line is already minimal; page mode uses the full "Page N of M".
151
+ */
152
+ function pageLabelForChrome(
153
+ page: { stories: { displayPageNumber: number } },
154
+ graph: RuntimePageGraph,
155
+ posture: PageChromePosture,
156
+ ): string {
157
+ const total = graph.contentPageCount;
158
+ if (posture === "canvas") {
159
+ return `${page.stories.displayPageNumber} / ${total}`;
160
+ }
161
+ return `Page ${page.stories.displayPageNumber} of ${total}`;
162
+ }
163
+
164
+ interface ChromeWidgetInput {
165
+ posture: PageChromePosture;
166
+ prevPageId: string;
167
+ prevPageIndex: number;
168
+ nextPageId: string;
169
+ nextPageIndex: number;
170
+ headerBandPx: number;
171
+ footerBandPx: number;
172
+ interGapPx: number;
173
+ prevPageLabel: string;
174
+ nextPageLabel: string;
175
+ hasPrevFooterStory: boolean;
176
+ hasNextHeaderStory: boolean;
177
+ prevFooterPreview: string;
178
+ nextHeaderPreview: string;
179
+ }
180
+
181
+ function buildChromeWidgetDom(input: ChromeWidgetInput): HTMLElement {
182
+ const root = document.createElement("div");
183
+ root.className = "wre-page-chrome-widget";
184
+ root.setAttribute("data-kind", "page-chrome-widget");
185
+ root.setAttribute("data-posture", input.posture);
186
+ root.setAttribute("data-prev-page-id", input.prevPageId);
187
+ root.setAttribute("data-next-page-id", input.nextPageId);
188
+ root.setAttribute("data-prev-page-index", String(input.prevPageIndex));
189
+ root.setAttribute("data-next-page-index", String(input.nextPageIndex));
190
+ root.contentEditable = "false";
191
+ root.setAttribute("aria-hidden", "false");
192
+ root.style.display = "block";
193
+ root.style.width = "100%";
194
+ root.style.userSelect = "none";
195
+
196
+ if (input.posture === "canvas") {
197
+ // Single dotted horizontal line with a small page-number callout.
198
+ root.style.height = `${input.interGapPx + 1}px`;
199
+ root.style.position = "relative";
200
+
201
+ const line = document.createElement("div");
202
+ line.className = "wre-page-chrome-canvas-seam";
203
+ line.style.position = "absolute";
204
+ line.style.left = "0";
205
+ line.style.right = "0";
206
+ line.style.top = `${Math.round(input.interGapPx / 2)}px`;
207
+ line.style.height = "0";
208
+ line.style.borderTop = "1px dotted var(--color-border, rgba(0,0,0,0.3))";
209
+ root.appendChild(line);
210
+
211
+ const badge = document.createElement("span");
212
+ badge.className = "wre-page-chrome-canvas-badge";
213
+ badge.setAttribute("data-kind", "canvas-seam-badge");
214
+ badge.textContent = input.nextPageLabel;
215
+ badge.style.position = "absolute";
216
+ badge.style.top = `${Math.round(input.interGapPx / 2) - 9}px`;
217
+ badge.style.left = "50%";
218
+ badge.style.transform = "translateX(-50%)";
219
+ badge.style.fontSize = "10px";
220
+ badge.style.letterSpacing = "0.12em";
221
+ badge.style.textTransform = "uppercase";
222
+ badge.style.color = "var(--color-text-tertiary, #6b7280)";
223
+ badge.style.backgroundColor =
224
+ "var(--color-surface, rgba(255,255,255,0.9))";
225
+ badge.style.padding = "0 8px";
226
+ root.appendChild(badge);
227
+ return root;
228
+ }
229
+
230
+ // PAGE-MODE chrome: footer band of prev + visible gap + header band of next.
231
+ root.style.height = `${
232
+ input.footerBandPx + input.interGapPx + input.headerBandPx
233
+ }px`;
234
+ root.style.position = "relative";
235
+
236
+ const footer = buildBand({
237
+ kind: "footer",
238
+ pageId: input.prevPageId,
239
+ pageIndex: input.prevPageIndex,
240
+ pageLabel: input.prevPageLabel,
241
+ bandPx: input.footerBandPx,
242
+ position: "top",
243
+ hasStory: input.hasPrevFooterStory,
244
+ previewText: input.prevFooterPreview,
245
+ });
246
+ root.appendChild(footer);
247
+
248
+ const separator = document.createElement("div");
249
+ separator.className = "wre-page-chrome-separator";
250
+ separator.style.position = "absolute";
251
+ separator.style.left = "0";
252
+ separator.style.right = "0";
253
+ separator.style.top = `${input.footerBandPx}px`;
254
+ separator.style.height = `${input.interGapPx}px`;
255
+ // Background: two subtle page-edge shadows mimicking real paper gap.
256
+ separator.style.background =
257
+ "linear-gradient(to bottom, rgba(0,0,0,0.045), rgba(0,0,0,0) 40%, rgba(0,0,0,0) 60%, rgba(0,0,0,0.035))";
258
+ root.appendChild(separator);
259
+
260
+ const header = buildBand({
261
+ kind: "header",
262
+ pageId: input.nextPageId,
263
+ pageIndex: input.nextPageIndex,
264
+ pageLabel: input.nextPageLabel,
265
+ bandPx: input.headerBandPx,
266
+ position: "bottom",
267
+ topOffsetPx: input.footerBandPx + input.interGapPx,
268
+ hasStory: input.hasNextHeaderStory,
269
+ previewText: input.nextHeaderPreview,
270
+ });
271
+ root.appendChild(header);
272
+
273
+ return root;
274
+ }
275
+
276
+ function buildBand(input: {
277
+ kind: "header" | "footer";
278
+ pageId: string;
279
+ pageIndex: number;
280
+ pageLabel: string;
281
+ bandPx: number;
282
+ position: "top" | "bottom";
283
+ topOffsetPx?: number;
284
+ hasStory: boolean;
285
+ previewText: string;
286
+ }): HTMLElement {
287
+ const band = document.createElement("div");
288
+ band.className = `wre-page-chrome-band wre-page-chrome-band-${input.kind}`;
289
+ band.setAttribute("data-band-kind", input.kind);
290
+ band.setAttribute("data-page-id", input.pageId);
291
+ band.setAttribute("data-page-index", String(input.pageIndex));
292
+ band.style.position = "absolute";
293
+ band.style.left = "0";
294
+ band.style.right = "0";
295
+ band.style.top = `${input.topOffsetPx ?? 0}px`;
296
+ band.style.height = `${input.bandPx}px`;
297
+ band.style.display = "flex";
298
+ band.style.alignItems = "center";
299
+ band.style.justifyContent = "space-between";
300
+ band.style.padding = "0 16px";
301
+ band.style.fontSize = "10px";
302
+ band.style.letterSpacing = "0.12em";
303
+ band.style.textTransform = "uppercase";
304
+ band.style.color = "var(--color-text-tertiary, #6b7280)";
305
+ band.style.backgroundColor =
306
+ "var(--color-surface-subtle, rgba(0,0,0,0.02))";
307
+ band.style.borderTop =
308
+ input.kind === "header"
309
+ ? "1px solid var(--color-border, rgba(0,0,0,0.08))"
310
+ : "none";
311
+ band.style.borderBottom =
312
+ input.kind === "footer"
313
+ ? "1px solid var(--color-border, rgba(0,0,0,0.08))"
314
+ : "none";
315
+ // Bands are interactive: double-click fires a custom event the shell
316
+ // forwards to `runtime.openStory()`.
317
+ band.style.pointerEvents = "auto";
318
+ band.style.cursor = input.hasStory ? "pointer" : "default";
319
+ band.title = input.hasStory
320
+ ? `Double-click to edit ${input.kind}`
321
+ : `No ${input.kind} defined for this page`;
322
+
323
+ const label = document.createElement("span");
324
+ label.className = "wre-page-chrome-band-label";
325
+ if (input.previewText && input.previewText.trim().length > 0) {
326
+ // Show the live content (with PAGE/NUMPAGES resolved) rather than the
327
+ // static "Header" / "Footer" placeholder. Band is compact so truncate
328
+ // at ~80 chars visually via CSS overflow; keep raw in textContent.
329
+ label.textContent = input.previewText;
330
+ label.style.textTransform = "none";
331
+ label.style.letterSpacing = "0";
332
+ label.style.fontSize = "11px";
333
+ label.style.color = "var(--color-text-secondary, #374151)";
334
+ label.style.overflow = "hidden";
335
+ label.style.textOverflow = "ellipsis";
336
+ label.style.whiteSpace = "nowrap";
337
+ label.style.maxWidth = "70%";
338
+ } else {
339
+ label.textContent = input.kind === "header" ? "Header" : "Footer";
340
+ }
341
+ band.appendChild(label);
342
+
343
+ const pageLabel = document.createElement("span");
344
+ pageLabel.className = "wre-page-chrome-band-page";
345
+ pageLabel.textContent = input.pageLabel;
346
+ band.appendChild(pageLabel);
347
+
348
+ if (input.hasStory) {
349
+ band.addEventListener("dblclick", (event) => {
350
+ event.stopPropagation();
351
+ event.preventDefault();
352
+ const eventName =
353
+ input.kind === "header"
354
+ ? "wre-open-header-story-for-page"
355
+ : "wre-open-footer-story-for-page";
356
+ // Use the band's owning document's `CustomEvent` constructor so the
357
+ // event passes through jsdom's instance-of check. In a real browser
358
+ // `band.ownerDocument.defaultView.CustomEvent` is the same as the
359
+ // global `CustomEvent`; in jsdom the two differ and the global one
360
+ // fails `dispatchEvent`'s internal Event-type convert step.
361
+ const view = band.ownerDocument?.defaultView as
362
+ | (Window & typeof globalThis)
363
+ | null;
364
+ const Ctor = view?.CustomEvent ?? CustomEvent;
365
+ band.dispatchEvent(
366
+ new Ctor(eventName, {
367
+ bubbles: true,
368
+ detail: { pageIndex: input.pageIndex, pageId: input.pageId },
369
+ }),
370
+ );
371
+ });
372
+ }
373
+
374
+ return band;
375
+ }
376
+
377
+ /**
378
+ * Resolve a `PAGE` or `NUMPAGES` value for a specific page, using the graph.
379
+ * Small re-export + convenience wrapper so the PM surface's field-atom
380
+ * renderer can swap the cached display text without importing the
381
+ * resolver module directly.
382
+ */
383
+ export function resolvePageFieldForPage(
384
+ family: "PAGE" | "NUMPAGES",
385
+ cachedText: string,
386
+ input: { page: RuntimePageGraph["pages"][number]; graph: RuntimePageGraph },
387
+ ): string {
388
+ return resolvePageFieldDisplayText(family, cachedText, input);
389
+ }
@@ -137,6 +137,7 @@ export const editorSchema = new Schema({
137
137
  numberingSuffix: { default: null },
138
138
  numberingMarkerWidth: { default: null },
139
139
  numberingMarkerJustification: { default: null },
140
+ numberingMarkerRunProperties: { default: null },
140
141
  alignment: { default: null },
141
142
  spacingBefore: { default: null },
142
143
  spacingAfter: { default: null },
@@ -266,10 +267,47 @@ export const editorSchema = new Schema({
266
267
  : numberingSuffix === "space"
267
268
  ? "0.5rem"
268
269
  : "0.75rem";
270
+
271
+ const markerRunProperties = node.attrs.numberingMarkerRunProperties as
272
+ | {
273
+ bold?: boolean;
274
+ italic?: boolean;
275
+ underline?: string;
276
+ fontSizeHalfPoints?: number;
277
+ colorHex?: string;
278
+ fontFamily?: string;
279
+ fontFamilyAscii?: string;
280
+ }
281
+ | null;
282
+
283
+ const baseClasses: string[] = ["inline-flex", "select-none", "items-center"];
284
+ if (!markerRunProperties) {
285
+ baseClasses.push("text-tertiary", "font-[family-name:var(--font-legal-sans)]");
286
+ }
287
+
269
288
  const prefixStyles = [
270
289
  `font-variant-numeric: tabular-nums`,
271
290
  `justify-content: ${resolveMarkerJustificationCss(numberingMarkerJustification)}`,
272
291
  ];
292
+
293
+ if (markerRunProperties) {
294
+ if (markerRunProperties.bold) prefixStyles.push("font-weight: bold");
295
+ if (markerRunProperties.italic) prefixStyles.push("font-style: italic");
296
+ if (markerRunProperties.underline && markerRunProperties.underline !== "none") {
297
+ prefixStyles.push("text-decoration: underline");
298
+ }
299
+ if (typeof markerRunProperties.fontSizeHalfPoints === "number") {
300
+ prefixStyles.push(`font-size: ${markerRunProperties.fontSizeHalfPoints / 2}pt`);
301
+ }
302
+ if (markerRunProperties.colorHex && markerRunProperties.colorHex !== "auto") {
303
+ prefixStyles.push(`color: #${markerRunProperties.colorHex.toLowerCase()}`);
304
+ }
305
+ const family = markerRunProperties.fontFamilyAscii ?? markerRunProperties.fontFamily;
306
+ if (family && SAFE_FONT_RE.test(family)) {
307
+ prefixStyles.push(`font-family: ${family}`);
308
+ }
309
+ }
310
+
273
311
  if (hasResolvedMarkerWidth) {
274
312
  const markerWidthPx = Math.max(1, Math.round(numberingMarkerWidth / 20));
275
313
  prefixStyles.push(
@@ -285,11 +323,11 @@ export const editorSchema = new Schema({
285
323
  `margin-right: ${fallbackMarginRight}`,
286
324
  );
287
325
  }
326
+
288
327
  children.push([
289
328
  "span",
290
329
  {
291
- class:
292
- "inline-flex select-none items-center text-tertiary font-[family-name:var(--font-legal-sans)]",
330
+ class: baseClasses.join(" "),
293
331
  contenteditable: "false",
294
332
  "data-numbering-prefix": numberingPrefix,
295
333
  ...(typeof numberingLevel === "number"