@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.
- package/package.json +41 -31
- package/src/api/public-types.ts +496 -1
- package/src/core/commands/section-layout-commands.ts +58 -0
- package/src/core/commands/table-grid.ts +431 -0
- package/src/core/commands/table-structure-commands.ts +845 -56
- package/src/core/commands/text-commands.ts +122 -2
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-main-document.ts +2 -11
- package/src/io/export/serialize-numbering.ts +43 -10
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/export/serialize-tables.ts +74 -0
- package/src/io/export/table-properties-xml.ts +139 -4
- package/src/io/normalize/normalize-text.ts +15 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-footnotes.ts +60 -0
- package/src/io/ooxml/parse-headers-footers.ts +60 -0
- package/src/io/ooxml/parse-main-document.ts +137 -0
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +117 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +1 -1
- package/src/runtime/document-runtime.ts +248 -18
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/index.ts +47 -0
- package/src/runtime/layout/inert-layout-facet.ts +16 -0
- package/src/runtime/layout/layout-engine-instance.ts +100 -23
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/margin-preset-catalog.ts +178 -0
- package/src/runtime/layout/page-format-catalog.ts +233 -0
- package/src/runtime/layout/page-graph.ts +55 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +484 -37
- package/src/runtime/layout/project-block-fragments.ts +225 -0
- package/src/runtime/layout/public-facet.ts +748 -16
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +249 -0
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/block-fragment-projection.ts +35 -0
- package/src/runtime/render/decoration-resolver.ts +189 -0
- package/src/runtime/render/index.ts +57 -0
- package/src/runtime/render/pending-op-delta-reader.ts +129 -0
- package/src/runtime/render/render-frame-types.ts +317 -0
- package/src/runtime/render/render-kernel.ts +759 -0
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/runtime/view-state.ts +67 -0
- package/src/runtime/workflow-markup.ts +1 -5
- package/src/runtime/workflow-rail-segments.ts +280 -0
- package/src/ui/WordReviewEditor.tsx +368 -19
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +310 -15
- package/src/ui/headless/scoped-chrome-policy.ts +49 -1
- package/src/ui/headless/selection-tool-types.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +29 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
- package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
- package/src/ui-tailwind/theme/editor-theme.css +498 -163
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
- package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
- package/src/runtime/collab-review-sync.ts +0 -254
- 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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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,
|
|
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 (
|
|
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"
|