@beyondwork/docx-react-component 1.0.52 → 1.0.53
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 +31 -40
- package/src/api/public-types.ts +32 -0
- package/src/io/chart-preview-resolver.ts +41 -0
- package/src/io/docx-session.ts +187 -17
- package/src/runtime/collab/runtime-collab-sync.ts +87 -6
- package/src/runtime/document-runtime.ts +159 -0
- package/src/runtime/layout/layout-engine-version.ts +40 -2
- package/src/runtime/layout/public-facet.ts +43 -1
- package/src/runtime/prerender/cache-envelope.ts +30 -0
- package/src/runtime/prerender/customxml-cache.ts +17 -3
- package/src/runtime/prerender/prerender-document.ts +17 -1
- package/src/runtime/render/render-kernel.ts +67 -19
- package/src/runtime/surface-projection.ts +28 -0
- package/src/runtime/table-schema.ts +27 -0
- package/src/runtime/table-style-resolver.ts +51 -0
- package/src/ui/editor-runtime-boundary.ts +39 -2
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +224 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +11 -147
- package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
- package/src/ui-tailwind/theme/editor-theme.css +26 -24
- package/src/ui-tailwind/theme/tokens.css +345 -0
- package/src/ui-tailwind/theme/tokens.ts +313 -0
- package/src/ui-tailwind/theme/use-density.ts +60 -0
|
@@ -27,10 +27,31 @@ export interface ResolvedTableRowStyle {
|
|
|
27
27
|
activeConditionalRegions: TableStyleConditionalRegion[];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* R3.a Phase 2 — typed bundle of table-level resolved properties projected onto
|
|
32
|
+
* the surface snapshot so the PM schema + node-view can render them inline at
|
|
33
|
+
* commit time.
|
|
34
|
+
*
|
|
35
|
+
* Borders are kept as the typed `TableBorders` shape here (BorderSpec per side);
|
|
36
|
+
* the surface-projection step converts them to per-side CSS shorthand strings
|
|
37
|
+
* (e.g. "1px solid #000") to mirror the existing `resolveCellBorderStyles`
|
|
38
|
+
* pattern at `surface-projection.ts:506`.
|
|
39
|
+
*/
|
|
40
|
+
export interface ResolvedTableLevelProperties {
|
|
41
|
+
width?: number;
|
|
42
|
+
widthType?: TableWidth["type"];
|
|
43
|
+
layoutMode?: "fixed" | "autofit";
|
|
44
|
+
cellSpacing?: number;
|
|
45
|
+
cellSpacingType?: TableWidth["type"];
|
|
46
|
+
borders?: TableBorders;
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
export interface ResolvedTableStyleResolution {
|
|
31
50
|
rawTblLook?: TableLook;
|
|
32
51
|
effectiveTblLook: TableLook;
|
|
33
52
|
table?: TableStyleFormatting["table"];
|
|
53
|
+
/** R3.a Phase 2 — table-level resolved properties (width / layout / spacing / borders). */
|
|
54
|
+
tableResolved: ResolvedTableLevelProperties;
|
|
34
55
|
rows: Array<{
|
|
35
56
|
style: ResolvedTableRowStyle;
|
|
36
57
|
cells: ResolvedTableCellStyle[];
|
|
@@ -140,10 +161,40 @@ export function resolveTableStyleResolution(
|
|
|
140
161
|
},
|
|
141
162
|
);
|
|
142
163
|
|
|
164
|
+
// R3.a Phase 2 — collect the table-level resolved property bundle. Direct
|
|
165
|
+
// TableNode properties win over the resolved table-style formatting; both
|
|
166
|
+
// paths still populate the optional fields independently so a partial style
|
|
167
|
+
// (e.g. style provides borders, direct provides width) merges cleanly.
|
|
168
|
+
const resolvedTableLevel: ResolvedTableLevelProperties = {};
|
|
169
|
+
const styleTable = resolvedStyle.formatting?.table;
|
|
170
|
+
const directWidth = table.width;
|
|
171
|
+
const styleWidth = styleTable?.width;
|
|
172
|
+
if (directWidth) {
|
|
173
|
+
resolvedTableLevel.width = directWidth.value;
|
|
174
|
+
resolvedTableLevel.widthType = directWidth.type;
|
|
175
|
+
} else if (styleWidth) {
|
|
176
|
+
resolvedTableLevel.width = styleWidth.value;
|
|
177
|
+
resolvedTableLevel.widthType = styleWidth.type;
|
|
178
|
+
}
|
|
179
|
+
if (table.layoutMode) {
|
|
180
|
+
resolvedTableLevel.layoutMode = table.layoutMode;
|
|
181
|
+
}
|
|
182
|
+
if (table.cellSpacing) {
|
|
183
|
+
resolvedTableLevel.cellSpacing = table.cellSpacing.value;
|
|
184
|
+
resolvedTableLevel.cellSpacingType = table.cellSpacing.type;
|
|
185
|
+
}
|
|
186
|
+
// Borders: prefer direct TableNode.borders, fall back to merged style borders.
|
|
187
|
+
// Both shapes are TableBorders so we can hand the side-bag straight back.
|
|
188
|
+
const mergedBorders = mergeBorderMap<TableBorders>(styleTable?.borders, table.borders);
|
|
189
|
+
if (mergedBorders) {
|
|
190
|
+
resolvedTableLevel.borders = mergedBorders;
|
|
191
|
+
}
|
|
192
|
+
|
|
143
193
|
return {
|
|
144
194
|
...(table.tblLook ? { rawTblLook: table.tblLook } : {}),
|
|
145
195
|
effectiveTblLook,
|
|
146
196
|
...(tableFormatting ? { table: tableFormatting } : {}),
|
|
197
|
+
tableResolved: resolvedTableLevel,
|
|
147
198
|
rows,
|
|
148
199
|
};
|
|
149
200
|
}
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
EditorError,
|
|
9
9
|
EditorHostAdapter,
|
|
10
10
|
EditorSessionState,
|
|
11
|
+
EditorSurfaceSnapshot,
|
|
11
12
|
EditorViewStateSnapshot,
|
|
12
13
|
EditorWarning,
|
|
13
14
|
ExportDocxOptions,
|
|
@@ -134,6 +135,8 @@ export interface EditorRuntimeBoundaryState {
|
|
|
134
135
|
runtime: WordReviewEditorRuntime | null;
|
|
135
136
|
loadError: EditorError | null;
|
|
136
137
|
activeRuntime: WordReviewEditorRuntime;
|
|
138
|
+
/** C2c: partial surface snapshot from body-normalize stage; null once full runtime is ready. */
|
|
139
|
+
progressiveSurface: EditorSurfaceSnapshot | null;
|
|
137
140
|
commandAppliedBridge: RuntimeCommandAppliedBridge;
|
|
138
141
|
fallbackSnapshot: RuntimeRenderSnapshot;
|
|
139
142
|
loadingSessionState: EditorSessionState;
|
|
@@ -302,6 +305,12 @@ export function useEditorRuntimeBoundary(
|
|
|
302
305
|
|
|
303
306
|
const [runtime, setRuntime] = useState<WordReviewEditorRuntime | null>(null);
|
|
304
307
|
const [loadError, setLoadError] = useState<EditorError | null>(null);
|
|
308
|
+
// C2c: progressive initial mount — transient surface snapshot from the
|
|
309
|
+
// body-normalize stage. Cleared when the full runtime commits (setRuntime).
|
|
310
|
+
const [progressiveSurface, setProgressiveSurface] =
|
|
311
|
+
useState<EditorSurfaceSnapshot | null>(null);
|
|
312
|
+
const progressiveSurfaceRef = useRef<EditorSurfaceSnapshot | null>(null);
|
|
313
|
+
progressiveSurfaceRef.current = progressiveSurface;
|
|
305
314
|
const runtimeRef = useRef<WordReviewEditorRuntime | null>(null);
|
|
306
315
|
const pendingReadySourceRef = useRef<"docx" | "session" | "snapshot" | null>(null);
|
|
307
316
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -445,7 +454,20 @@ export function useEditorRuntimeBoundary(
|
|
|
445
454
|
bytes: source.initialDocx,
|
|
446
455
|
editorBuild: "dev",
|
|
447
456
|
scheduler,
|
|
448
|
-
...(probeResult ? { laycacheEnvelope: probeResult.envelope } : {
|
|
457
|
+
...(probeResult ? { laycacheEnvelope: probeResult.envelope } : {
|
|
458
|
+
// C2c: on the cold path (no cached envelope), emit the first
|
|
459
|
+
// viewport surface as soon as body-normalize completes so the
|
|
460
|
+
// UI can show the first page before the full session is ready.
|
|
461
|
+
// Wrapped in startTransition so React treats this as a
|
|
462
|
+
// low-priority update — the full setRuntime commit (urgent)
|
|
463
|
+
// will preempt it if it arrives before React flushes the
|
|
464
|
+
// transition.
|
|
465
|
+
onProgressiveSnapshot: (partial) => {
|
|
466
|
+
React.startTransition(() => {
|
|
467
|
+
setProgressiveSurface(partial.surface);
|
|
468
|
+
});
|
|
469
|
+
},
|
|
470
|
+
}),
|
|
449
471
|
});
|
|
450
472
|
if (cancelled) {
|
|
451
473
|
scheduler.dispose();
|
|
@@ -478,6 +500,10 @@ export function useEditorRuntimeBoundary(
|
|
|
478
500
|
recordPerfSample("runtime.create");
|
|
479
501
|
runtimeRef.current = nextRuntime;
|
|
480
502
|
pendingReadySourceRef.current = source.source;
|
|
503
|
+
// C2c: clear the transient progressive surface — the full runtime
|
|
504
|
+
// snapshot supersedes it. No need for startTransition here since
|
|
505
|
+
// this is the higher-priority commit that preempts the transition.
|
|
506
|
+
setProgressiveSurface(null);
|
|
481
507
|
setRuntime(nextRuntime);
|
|
482
508
|
} catch (error) {
|
|
483
509
|
if (cancelled) {
|
|
@@ -627,6 +653,9 @@ export function useEditorRuntimeBoundary(
|
|
|
627
653
|
sessionState: loadingSessionState,
|
|
628
654
|
viewState: loadingViewState,
|
|
629
655
|
navigation: loadingNavigation,
|
|
656
|
+
// C2c: ref so the bridge can serve the progressive surface in
|
|
657
|
+
// getRenderSnapshot().surface without recreating on each update.
|
|
658
|
+
progressiveSurfaceRef,
|
|
630
659
|
}),
|
|
631
660
|
[fallbackSnapshot, loadingNavigation, loadingSessionState, loadingViewState],
|
|
632
661
|
);
|
|
@@ -635,6 +664,7 @@ export function useEditorRuntimeBoundary(
|
|
|
635
664
|
runtime,
|
|
636
665
|
loadError,
|
|
637
666
|
activeRuntime: runtime ?? loadingRuntimeBridge,
|
|
667
|
+
progressiveSurface,
|
|
638
668
|
commandAppliedBridge,
|
|
639
669
|
fallbackSnapshot,
|
|
640
670
|
loadingSessionState,
|
|
@@ -876,6 +906,8 @@ function createLoadingRuntimeBridge(input: {
|
|
|
876
906
|
sessionState: EditorSessionState;
|
|
877
907
|
viewState: EditorViewStateSnapshot;
|
|
878
908
|
navigation: DocumentNavigationSnapshot;
|
|
909
|
+
/** C2c: when present, getRenderSnapshot() injects the progressive surface. */
|
|
910
|
+
progressiveSurfaceRef?: React.RefObject<EditorSurfaceSnapshot | null>;
|
|
879
911
|
}): WordReviewEditorRuntime {
|
|
880
912
|
const inertLayoutFacet = createInertLayoutFacet();
|
|
881
913
|
const emptyFieldSnapshot: FieldSnapshot = {
|
|
@@ -908,7 +940,11 @@ function createLoadingRuntimeBridge(input: {
|
|
|
908
940
|
subscribe: () => () => undefined,
|
|
909
941
|
subscribeToEvents: () => () => undefined,
|
|
910
942
|
emitBlockedCommand: () => undefined,
|
|
911
|
-
getRenderSnapshot: () =>
|
|
943
|
+
getRenderSnapshot: () => {
|
|
944
|
+
const progressive = input.progressiveSurfaceRef?.current;
|
|
945
|
+
if (progressive == null) return input.snapshot;
|
|
946
|
+
return { ...input.snapshot, surface: progressive };
|
|
947
|
+
},
|
|
912
948
|
getCanonicalDocument: () => input.sessionState.canonicalDocument,
|
|
913
949
|
getSourcePackage: () => input.sessionState.sourcePackage,
|
|
914
950
|
replaceText: () => undefined,
|
|
@@ -922,6 +958,7 @@ function createLoadingRuntimeBridge(input: {
|
|
|
922
958
|
}),
|
|
923
959
|
dispatch: () => undefined,
|
|
924
960
|
applyRemoteCommand: () => undefined,
|
|
961
|
+
applyRemoteCommandBatch: () => undefined,
|
|
925
962
|
undo: () => undefined,
|
|
926
963
|
redo: () => undefined,
|
|
927
964
|
focus: () => undefined,
|
|
@@ -519,6 +519,8 @@ function buildTable(
|
|
|
519
519
|
borderRight: cell.borderRight ?? null,
|
|
520
520
|
borderBottom: cell.borderBottom ?? null,
|
|
521
521
|
borderLeft: cell.borderLeft ?? null,
|
|
522
|
+
// R3.a Phase 2 — per-cell text-flow direction.
|
|
523
|
+
textDirection: cell.textDirection ?? null,
|
|
522
524
|
bandClasses: cell.bandClasses ?? null,
|
|
523
525
|
},
|
|
524
526
|
Fragment.from(cellContent),
|
|
@@ -536,6 +538,20 @@ function buildTable(
|
|
|
536
538
|
Fragment.from(cells),
|
|
537
539
|
));
|
|
538
540
|
}
|
|
541
|
+
// R3.a Phase 2 — convert each typed BorderSpec on `tableResolved.borders`
|
|
542
|
+
// into a CSS shorthand string ("1px solid #000000") so the schema attrs
|
|
543
|
+
// carry render-ready values. The four outer sides are applied as inline
|
|
544
|
+
// styles by the node-view; insideH / insideV ride along for round-trip
|
|
545
|
+
// and future use but do not change CSS at the table level (the per-cell
|
|
546
|
+
// border resolver already paints them on each cell).
|
|
547
|
+
const tr = block.tableResolved;
|
|
548
|
+
const tableBorderTop = tr?.borders?.top ? borderSpecToCss(tr.borders.top) : null;
|
|
549
|
+
const tableBorderRight = tr?.borders?.right ? borderSpecToCss(tr.borders.right) : null;
|
|
550
|
+
const tableBorderBottom = tr?.borders?.bottom ? borderSpecToCss(tr.borders.bottom) : null;
|
|
551
|
+
const tableBorderLeft = tr?.borders?.left ? borderSpecToCss(tr.borders.left) : null;
|
|
552
|
+
const tableBorderInsideH = tr?.borders?.insideH ? borderSpecToCss(tr.borders.insideH) : null;
|
|
553
|
+
const tableBorderInsideV = tr?.borders?.insideV ? borderSpecToCss(tr.borders.insideV) : null;
|
|
554
|
+
|
|
539
555
|
return editorSchema.nodes.table.create(
|
|
540
556
|
{
|
|
541
557
|
styleId: block.styleId ?? null,
|
|
@@ -548,11 +564,49 @@ function buildTable(
|
|
|
548
564
|
tblLookNoHBand: block.tblLook?.noHBand ?? false,
|
|
549
565
|
tblLookNoVBand: block.tblLook?.noVBand ?? false,
|
|
550
566
|
tblLookVal: block.tblLook?.val ?? null,
|
|
567
|
+
// R3.a Phase 2 — table-level resolved properties.
|
|
568
|
+
tableWidth: tr?.width ?? null,
|
|
569
|
+
tableWidthType: tr?.widthType ?? null,
|
|
570
|
+
tableLayoutMode: tr?.layoutMode ?? null,
|
|
571
|
+
tableCellSpacing: tr?.cellSpacing ?? null,
|
|
572
|
+
tableCellSpacingType: tr?.cellSpacingType ?? null,
|
|
573
|
+
tableBorderTop,
|
|
574
|
+
tableBorderRight,
|
|
575
|
+
tableBorderBottom,
|
|
576
|
+
tableBorderLeft,
|
|
577
|
+
tableBorderInsideH,
|
|
578
|
+
tableBorderInsideV,
|
|
551
579
|
},
|
|
552
580
|
Fragment.from(rows),
|
|
553
581
|
);
|
|
554
582
|
}
|
|
555
583
|
|
|
584
|
+
/**
|
|
585
|
+
* R3.a Phase 2 — convert a parsed OOXML `BorderSpec` shape (size in eighths of
|
|
586
|
+
* a point, color hex without `#`, value like "single"/"double"/"dashed") to a
|
|
587
|
+
* CSS shorthand string. Mirrors the cell-side helper at
|
|
588
|
+
* `src/runtime/surface-projection.ts:506` so cell + table borders look the
|
|
589
|
+
* same. Returns `null` for absent / "none" / "nil" specs so callers can stamp
|
|
590
|
+
* `null` cleanly into PM attrs.
|
|
591
|
+
*/
|
|
592
|
+
function borderSpecToCss(
|
|
593
|
+
spec: { value?: string; size?: number; color?: string } | null | undefined,
|
|
594
|
+
): string | null {
|
|
595
|
+
if (!spec) return null;
|
|
596
|
+
if (spec.value === "none" || spec.value === "nil") return null;
|
|
597
|
+
const width = spec.size ? `${Math.max(1, Math.round(spec.size / 8))}px` : "1px";
|
|
598
|
+
const style =
|
|
599
|
+
spec.value === "double"
|
|
600
|
+
? "double"
|
|
601
|
+
: spec.value === "dashed" || spec.value === "dashSmallGap"
|
|
602
|
+
? "dashed"
|
|
603
|
+
: spec.value === "dotted"
|
|
604
|
+
? "dotted"
|
|
605
|
+
: "solid";
|
|
606
|
+
const color = spec.color && spec.color !== "auto" ? `#${spec.color}` : "currentColor";
|
|
607
|
+
return `${width} ${style} ${color}`;
|
|
608
|
+
}
|
|
609
|
+
|
|
556
610
|
function buildSdtBlock(
|
|
557
611
|
block: Extract<SurfaceBlockSnapshot, { kind: "sdt_block" }>,
|
|
558
612
|
mediaPreviews: Record<string, MediaPreviewDescriptor>,
|
|
@@ -282,11 +282,33 @@ function requestTableLayoutSync(start: HTMLElement): void {
|
|
|
282
282
|
table?.dispatchEvent(new Event(TABLE_LAYOUT_SYNC_EVENT));
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// R3.a Phase 2 — single source of truth for the inline style properties
|
|
286
|
+
// `applyTableAttrs` writes back. Adding a new managed property requires only
|
|
287
|
+
// adding it here (and the matching conditional assignment below); the reset
|
|
288
|
+
// loop will pick it up automatically so stale values can never persist
|
|
289
|
+
// across PM snapshot updates.
|
|
290
|
+
const R3A_MANAGED_TABLE_STYLES = [
|
|
291
|
+
"marginLeft",
|
|
292
|
+
"marginRight",
|
|
293
|
+
"width",
|
|
294
|
+
"tableLayout",
|
|
295
|
+
"borderCollapse",
|
|
296
|
+
"borderSpacing",
|
|
297
|
+
"borderTop",
|
|
298
|
+
"borderRight",
|
|
299
|
+
"borderBottom",
|
|
300
|
+
"borderLeft",
|
|
301
|
+
] as const;
|
|
302
|
+
|
|
285
303
|
function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
|
|
286
|
-
|
|
304
|
+
// Reset every R3.a-managed inline style first so re-application doesn't
|
|
305
|
+
// accumulate stale values across PM updates (e.g. width changes, layout
|
|
306
|
+
// mode flips). The base Tailwind className is rebuilt below to match.
|
|
287
307
|
table.setAttribute("data-pm-table-root", "true");
|
|
288
|
-
|
|
289
|
-
|
|
308
|
+
for (const prop of R3A_MANAGED_TABLE_STYLES) {
|
|
309
|
+
table.style[prop] = "";
|
|
310
|
+
}
|
|
311
|
+
|
|
290
312
|
const alignment = node.attrs.alignment as string | null | undefined;
|
|
291
313
|
if (alignment === "center") {
|
|
292
314
|
table.style.marginLeft = "auto";
|
|
@@ -294,6 +316,67 @@ function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
|
|
|
294
316
|
} else if (alignment === "right") {
|
|
295
317
|
table.style.marginLeft = "auto";
|
|
296
318
|
}
|
|
319
|
+
|
|
320
|
+
// R3.a Phase 2 — table-level inline styles from resolved properties.
|
|
321
|
+
// Width: pct → "%", dxa → "pt" (twips/20), auto → "auto", null → unset.
|
|
322
|
+
// When an explicit width is present we drop Tailwind's `w-full` so the
|
|
323
|
+
// explicit value wins; otherwise keep the responsive default.
|
|
324
|
+
const tableWidth = node.attrs.tableWidth as number | null | undefined;
|
|
325
|
+
const tableWidthType = node.attrs.tableWidthType as string | null | undefined;
|
|
326
|
+
let baseClasses = "border-collapse w-full my-2 text-sm";
|
|
327
|
+
if (tableWidthType === "pct" && typeof tableWidth === "number") {
|
|
328
|
+
// OOXML pct widths are fiftieths of a percent (5000 = 100%).
|
|
329
|
+
table.style.width = `${tableWidth / 50}%`;
|
|
330
|
+
baseClasses = "border-collapse my-2 text-sm";
|
|
331
|
+
} else if (tableWidthType === "dxa" && typeof tableWidth === "number") {
|
|
332
|
+
table.style.width = `${tableWidth / 20}pt`;
|
|
333
|
+
baseClasses = "border-collapse my-2 text-sm";
|
|
334
|
+
} else if (tableWidthType === "auto") {
|
|
335
|
+
table.style.width = "auto";
|
|
336
|
+
baseClasses = "border-collapse my-2 text-sm";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Layout mode: w:tblLayout/@w:type. "fixed" → CSS `table-layout: fixed`
|
|
340
|
+
// (column widths from <colgroup> rule); "autofit" falls through to the
|
|
341
|
+
// browser default ("auto").
|
|
342
|
+
const layoutMode = node.attrs.tableLayoutMode as string | null | undefined;
|
|
343
|
+
if (layoutMode === "fixed") {
|
|
344
|
+
table.style.tableLayout = "fixed";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Cell spacing: w:tblCellSpacing. Non-zero spacing requires
|
|
348
|
+
// `border-collapse: separate` in CSS (border-collapse: collapse ignores
|
|
349
|
+
// border-spacing).
|
|
350
|
+
// ECMA-376 §17.4.44: w:tblCellSpacing is total spacing between cells in
|
|
351
|
+
// twentieths of a point; direct CSS border-spacing equivalent.
|
|
352
|
+
const cellSpacing = node.attrs.tableCellSpacing as number | null | undefined;
|
|
353
|
+
const cellSpacingType = node.attrs.tableCellSpacingType as string | null | undefined;
|
|
354
|
+
if (typeof cellSpacing === "number" && cellSpacing > 0 && cellSpacingType === "dxa") {
|
|
355
|
+
table.style.borderCollapse = "separate";
|
|
356
|
+
table.style.borderSpacing = `${cellSpacing / 20}pt`;
|
|
357
|
+
// `border-collapse` overrides Tailwind's `border-collapse` utility, so
|
|
358
|
+
// strip it from the base class set to avoid the conflicting cascade
|
|
359
|
+
// result that some browsers cache.
|
|
360
|
+
baseClasses = baseClasses.replace("border-collapse", "border-separate");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Outer borders (top / right / bottom / left) — apply directly as inline
|
|
364
|
+
// styles. insideH / insideV are intentionally NOT applied at the table
|
|
365
|
+
// level because CSS has no native "table-inside-border" property; their
|
|
366
|
+
// visual effect is realized through per-cell borders projected on the
|
|
367
|
+
// cell snapshot via resolveCellBorderStyles in surface-projection.
|
|
368
|
+
// (See Lane 3b R3.a Phase 2 close-out plan: "option 1 — cell-level
|
|
369
|
+
// fallback".)
|
|
370
|
+
const tBorderTop = node.attrs.tableBorderTop as string | null | undefined;
|
|
371
|
+
const tBorderRight = node.attrs.tableBorderRight as string | null | undefined;
|
|
372
|
+
const tBorderBottom = node.attrs.tableBorderBottom as string | null | undefined;
|
|
373
|
+
const tBorderLeft = node.attrs.tableBorderLeft as string | null | undefined;
|
|
374
|
+
if (tBorderTop) table.style.borderTop = tBorderTop;
|
|
375
|
+
if (tBorderRight) table.style.borderRight = tBorderRight;
|
|
376
|
+
if (tBorderBottom) table.style.borderBottom = tBorderBottom;
|
|
377
|
+
if (tBorderLeft) table.style.borderLeft = tBorderLeft;
|
|
378
|
+
|
|
379
|
+
table.className = baseClasses;
|
|
297
380
|
syncColgroup(table, node);
|
|
298
381
|
}
|
|
299
382
|
|
|
@@ -423,6 +506,27 @@ function applyCellAttrs(cell: HTMLTableCellElement, node: PMNode, isHeader: bool
|
|
|
423
506
|
cell.style.borderRight = borderRight ?? "";
|
|
424
507
|
cell.style.borderBottom = borderBottom ?? "";
|
|
425
508
|
cell.style.borderLeft = borderLeft ?? "";
|
|
509
|
+
|
|
510
|
+
// R3.a Phase 2 — vertical text direction. OOXML: tbRl = top→bottom, right→left
|
|
511
|
+
// (most common for vertical headers, reads when tilting the head right);
|
|
512
|
+
// btLr = bottom→top, left→right (rare, reads tilting head left); lrTb is the
|
|
513
|
+
// default horizontal flow. CSS `writing-mode` is the direct equivalent.
|
|
514
|
+
const textDirection = node.attrs.textDirection as
|
|
515
|
+
| "lrTb"
|
|
516
|
+
| "tbRl"
|
|
517
|
+
| "btLr"
|
|
518
|
+
| null
|
|
519
|
+
| undefined;
|
|
520
|
+
cell.style.writingMode = "";
|
|
521
|
+
if (textDirection === "tbRl") {
|
|
522
|
+
cell.style.writingMode = "vertical-rl";
|
|
523
|
+
cell.setAttribute("data-text-direction", "tbRl");
|
|
524
|
+
} else if (textDirection === "btLr") {
|
|
525
|
+
cell.style.writingMode = "vertical-lr";
|
|
526
|
+
cell.setAttribute("data-text-direction", "btLr");
|
|
527
|
+
} else {
|
|
528
|
+
cell.removeAttribute("data-text-direction");
|
|
529
|
+
}
|
|
426
530
|
}
|
|
427
531
|
|
|
428
532
|
/**
|
|
@@ -32,7 +32,7 @@ export interface TwFootnoteAreaProps {
|
|
|
32
32
|
"data-testid"?: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = ({
|
|
35
|
+
export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
|
|
36
36
|
pageIndex,
|
|
37
37
|
blocks,
|
|
38
38
|
topPx,
|
|
@@ -66,6 +66,6 @@ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = ({
|
|
|
66
66
|
<TwRegionBlockRenderer blocks={blocks} />
|
|
67
67
|
</div>
|
|
68
68
|
);
|
|
69
|
-
};
|
|
69
|
+
});
|
|
70
70
|
|
|
71
71
|
export default TwFootnoteArea;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwPageChromeEntry — memo-wrapped per-page chrome subtree.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `TwPageStackChromeLayer`'s per-page `.map()` so that
|
|
5
|
+
* `React.memo` can skip re-renders for pages whose `page` reference is
|
|
6
|
+
* stable (guaranteed post-D1+B2 for pages past the splice-convergence
|
|
7
|
+
* point). Rect values are compared structurally (topPx / bottomPx /
|
|
8
|
+
* pageId) because `resolvePageOverlayRects` allocates new objects each
|
|
9
|
+
* measurement pass even when positions haven't changed.
|
|
10
|
+
*
|
|
11
|
+
* Block arrays and click-handler callbacks are memoized on
|
|
12
|
+
* `(page, renderFrameRevision)` so they don't create new references
|
|
13
|
+
* when the page is unchanged.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React from "react";
|
|
17
|
+
import type {
|
|
18
|
+
EditorStoryTarget,
|
|
19
|
+
PublicPageNode,
|
|
20
|
+
WordReviewEditorLayoutFacet,
|
|
21
|
+
} from "../../api/public-types.ts";
|
|
22
|
+
import type { PageOverlayRect } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
|
|
23
|
+
import { FRAME_PX_PER_TWIP_AT_96DPI } from "../tw-review-workspace.tsx";
|
|
24
|
+
import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
|
|
25
|
+
import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
|
|
26
|
+
import { TwFootnoteArea } from "./tw-footnote-area.tsx";
|
|
27
|
+
|
|
28
|
+
export interface TwPageChromeEntryProps {
|
|
29
|
+
rect: PageOverlayRect;
|
|
30
|
+
pageIndex: number;
|
|
31
|
+
page: PublicPageNode;
|
|
32
|
+
facet: WordReviewEditorLayoutFacet;
|
|
33
|
+
activeStory: EditorStoryTarget;
|
|
34
|
+
onOpenStory?: (target: EditorStoryTarget) => void;
|
|
35
|
+
visiblePageIndexRange?: { start: number; end: number } | null;
|
|
36
|
+
renderFrameRevision: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TwPageChromeEntryInner({
|
|
40
|
+
rect,
|
|
41
|
+
pageIndex,
|
|
42
|
+
page,
|
|
43
|
+
facet,
|
|
44
|
+
activeStory,
|
|
45
|
+
onOpenStory,
|
|
46
|
+
visiblePageIndexRange,
|
|
47
|
+
renderFrameRevision,
|
|
48
|
+
}: TwPageChromeEntryProps): React.ReactElement {
|
|
49
|
+
const layout = page.layout;
|
|
50
|
+
const headerStory = page.stories.header;
|
|
51
|
+
const footerStory = page.stories.footer;
|
|
52
|
+
const headerRegion = page.regions.header;
|
|
53
|
+
const footerRegion = page.regions.footer;
|
|
54
|
+
const footnoteRegion = page.regions.footnotes?.[0];
|
|
55
|
+
|
|
56
|
+
// All hooks must be called unconditionally before any early return.
|
|
57
|
+
const headerBlocks = React.useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
headerStory
|
|
60
|
+
? facet.getStoryBlocksForRegion(pageIndex, "header").map((b) => b.blockSnapshot)
|
|
61
|
+
: [],
|
|
62
|
+
// `page` as dependency captures per-page reference stability (D1+B2):
|
|
63
|
+
// when page ref is stable, the memo doesn't re-run even if revision ticks.
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
[facet, pageIndex, page, renderFrameRevision],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const footerBlocks = React.useMemo(
|
|
69
|
+
() =>
|
|
70
|
+
footerStory
|
|
71
|
+
? facet.getStoryBlocksForRegion(pageIndex, "footer").map((b) => b.blockSnapshot)
|
|
72
|
+
: [],
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
[facet, pageIndex, page, renderFrameRevision],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const footnoteBlocks = React.useMemo(
|
|
78
|
+
() =>
|
|
79
|
+
footnoteRegion
|
|
80
|
+
? facet
|
|
81
|
+
.getStoryBlocksForRegion(pageIndex, "footnote-area")
|
|
82
|
+
.map((b) => b.blockSnapshot)
|
|
83
|
+
: [],
|
|
84
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
+
[facet, pageIndex, page, renderFrameRevision],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const handleHeaderClick = React.useCallback(
|
|
89
|
+
() => headerStory && onOpenStory?.(headerStory),
|
|
90
|
+
[onOpenStory, headerStory],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const handleFooterClick = React.useCallback(
|
|
94
|
+
() => footerStory && onOpenStory?.(footerStory),
|
|
95
|
+
[onOpenStory, footerStory],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const frameHeightPx = rect.bottomPx - rect.topPx;
|
|
99
|
+
|
|
100
|
+
// Viewport cull — lightweight placeholder outside the visible range.
|
|
101
|
+
const isCulled =
|
|
102
|
+
visiblePageIndexRange !== null &&
|
|
103
|
+
visiblePageIndexRange !== undefined &&
|
|
104
|
+
(pageIndex < visiblePageIndexRange.start || pageIndex >= visiblePageIndexRange.end);
|
|
105
|
+
|
|
106
|
+
if (isCulled) {
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
data-page-chrome-frame=""
|
|
110
|
+
data-page-index={pageIndex}
|
|
111
|
+
data-page-chrome-culled=""
|
|
112
|
+
style={{
|
|
113
|
+
position: "absolute",
|
|
114
|
+
top: `${rect.topPx}px`,
|
|
115
|
+
left: 0,
|
|
116
|
+
width: "100%",
|
|
117
|
+
height: `${frameHeightPx}px`,
|
|
118
|
+
pointerEvents: "none",
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const px = (twips: number): number => twips * FRAME_PX_PER_TWIP_AT_96DPI;
|
|
125
|
+
const bandWidthPx = px(layout.pageWidth - layout.marginLeft - layout.marginRight);
|
|
126
|
+
const bandLeftPx = px(layout.marginLeft);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
data-page-chrome-frame=""
|
|
131
|
+
data-page-index={pageIndex}
|
|
132
|
+
style={{
|
|
133
|
+
position: "absolute",
|
|
134
|
+
top: `${rect.topPx}px`,
|
|
135
|
+
left: 0,
|
|
136
|
+
width: "100%",
|
|
137
|
+
height: `${frameHeightPx}px`,
|
|
138
|
+
pointerEvents: "none",
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
{headerRegion && headerStory ? (
|
|
142
|
+
<TwPageHeaderBand
|
|
143
|
+
pageIndex={pageIndex}
|
|
144
|
+
blocks={headerBlocks}
|
|
145
|
+
topPx={px(layout.headerMargin ?? 720)}
|
|
146
|
+
leftPx={bandLeftPx}
|
|
147
|
+
widthPx={bandWidthPx}
|
|
148
|
+
bandHeightPx={px(headerRegion.heightTwips)}
|
|
149
|
+
isActiveSlot={isActiveStoryMatch(activeStory, headerStory)}
|
|
150
|
+
onClick={handleHeaderClick}
|
|
151
|
+
/>
|
|
152
|
+
) : null}
|
|
153
|
+
{footerRegion && footerStory ? (
|
|
154
|
+
<TwPageFooterBand
|
|
155
|
+
pageIndex={pageIndex}
|
|
156
|
+
blocks={footerBlocks}
|
|
157
|
+
bottomPx={px(layout.footerMargin ?? 720)}
|
|
158
|
+
leftPx={bandLeftPx}
|
|
159
|
+
widthPx={bandWidthPx}
|
|
160
|
+
bandHeightPx={px(footerRegion.heightTwips)}
|
|
161
|
+
isActiveSlot={isActiveStoryMatch(activeStory, footerStory)}
|
|
162
|
+
onClick={handleFooterClick}
|
|
163
|
+
/>
|
|
164
|
+
) : null}
|
|
165
|
+
{footnoteRegion ? (
|
|
166
|
+
<TwFootnoteArea
|
|
167
|
+
pageIndex={pageIndex}
|
|
168
|
+
blocks={footnoteBlocks}
|
|
169
|
+
topPx={px(footnoteRegion.originTwips - layout.marginTop)}
|
|
170
|
+
leftPx={bandLeftPx}
|
|
171
|
+
widthPx={px(footnoteRegion.widthTwips)}
|
|
172
|
+
heightPx={px(footnoteRegion.heightTwips)}
|
|
173
|
+
/>
|
|
174
|
+
) : null}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function propsAreEqual(
|
|
180
|
+
prev: TwPageChromeEntryProps,
|
|
181
|
+
next: TwPageChromeEntryProps,
|
|
182
|
+
): boolean {
|
|
183
|
+
return (
|
|
184
|
+
prev.pageIndex === next.pageIndex &&
|
|
185
|
+
prev.page === next.page &&
|
|
186
|
+
prev.facet === next.facet &&
|
|
187
|
+
prev.activeStory === next.activeStory &&
|
|
188
|
+
prev.onOpenStory === next.onOpenStory &&
|
|
189
|
+
prev.visiblePageIndexRange === next.visiblePageIndexRange &&
|
|
190
|
+
prev.renderFrameRevision === next.renderFrameRevision &&
|
|
191
|
+
prev.rect.topPx === next.rect.topPx &&
|
|
192
|
+
prev.rect.bottomPx === next.rect.bottomPx &&
|
|
193
|
+
prev.rect.pageId === next.rect.pageId
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const TwPageChromeEntry = React.memo(TwPageChromeEntryInner, propsAreEqual);
|
|
198
|
+
|
|
199
|
+
function isActiveStoryMatch(
|
|
200
|
+
active: EditorStoryTarget,
|
|
201
|
+
candidate: EditorStoryTarget,
|
|
202
|
+
): boolean {
|
|
203
|
+
if (active.kind !== candidate.kind) return false;
|
|
204
|
+
if (active.kind === "main" || candidate.kind === "main") {
|
|
205
|
+
return active.kind === candidate.kind;
|
|
206
|
+
}
|
|
207
|
+
if (active.kind === "footnote" && candidate.kind === "footnote") {
|
|
208
|
+
return active.noteId === candidate.noteId;
|
|
209
|
+
}
|
|
210
|
+
if (active.kind === "endnote" && candidate.kind === "endnote") {
|
|
211
|
+
return active.noteId === candidate.noteId;
|
|
212
|
+
}
|
|
213
|
+
if (
|
|
214
|
+
(active.kind === "header" && candidate.kind === "header") ||
|
|
215
|
+
(active.kind === "footer" && candidate.kind === "footer")
|
|
216
|
+
) {
|
|
217
|
+
return (
|
|
218
|
+
active.relationshipId === candidate.relationshipId &&
|
|
219
|
+
active.variant === candidate.variant &&
|
|
220
|
+
active.sectionIndex === candidate.sectionIndex
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
@@ -31,7 +31,7 @@ export interface TwPageFooterBandProps {
|
|
|
31
31
|
"data-testid"?: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
|
|
34
|
+
export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
|
|
35
35
|
pageIndex,
|
|
36
36
|
blocks,
|
|
37
37
|
bandHeightPx,
|
|
@@ -68,6 +68,6 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
|
|
|
68
68
|
)}
|
|
69
69
|
</div>
|
|
70
70
|
);
|
|
71
|
-
};
|
|
71
|
+
});
|
|
72
72
|
|
|
73
73
|
export default TwPageFooterBand;
|
|
@@ -32,7 +32,7 @@ export interface TwPageHeaderBandProps {
|
|
|
32
32
|
"data-testid"?: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
|
|
35
|
+
export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
|
|
36
36
|
pageIndex,
|
|
37
37
|
blocks,
|
|
38
38
|
bandHeightPx,
|
|
@@ -69,6 +69,6 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
|
|
|
69
69
|
)}
|
|
70
70
|
</div>
|
|
71
71
|
);
|
|
72
|
-
};
|
|
72
|
+
});
|
|
73
73
|
|
|
74
74
|
export default TwPageHeaderBand;
|