docs: shorten code comments for readability

This commit is contained in:
Steffen Becker committed 2026-06-12 14:50:18 +02:00
1 parent 173963b909
commit 6dbacf8899
16 files changed
+193 -424

No files matched your search

+27 -63
View File
@@ -27,11 +27,9 @@
applyOrientationVars(orientation);
});
// Nudge the pageBreaks plugin to recompute with the new content area. The
// dispatch bumps the `tick`/`numPages` bindings (via onTransaction/pm-pagecount),
// so it must run OUTSIDE the Svelte effect flush — otherwise the child↔parent
// binding updates re-enter and trip effect_update_depth_exceeded. requestAnimation-
// Frame defers it past the flush; the empty transaction changes no document content.
// Nudge the pageBreaks plugin to recompute with the new content area. The dispatch
// bumps the tick/numPages bindings, so requestAnimationFrame defers it past the
// Svelte effect flush (re-entering it would trip effect_update_depth_exceeded).
let marginRecalcRaf = 0;
$effect(() => {
// track each margin + orientation so the effect re-runs on any change
@@ -109,21 +107,17 @@
return;
}
// Position in the editor container's content space: viewport delta + scroll.
// getBoundingClientRect and scrollTop share the same (zoom-affected) scale,
// so this stays aligned across zoom; the toolbar itself is a non-zoomed
// sibling of .paper, so it renders at a constant size.
// getBoundingClientRect and scrollTop share the same zoom scale, so this stays
// aligned across zoom; the toolbar is a non-zoomed sibling, rendered constant-size.
const tRect = dom.getBoundingClientRect();
const cRect = editorContainer.getBoundingClientRect();
const left = tRect.left - cRect.left + editorContainer.scrollLeft;
// Default: anchor just above the table's top-left corner.
let top = tRect.top - cRect.top + editorContainer.scrollTop;
// When a table spans page breaks, keep the toolbar on the page the cursor is
// actually on, so the user isn't forced to scroll back to the table's first
// page to reach it. Compare the cursor's page to the table's starting page
// (both in document px, via the same cycle math as the page indicator); if
// they differ, re-anchor to the active page's content-top — i.e. the table's
// resumed top on that page — so the toolbar floats in that page's top margin.
// When a table spans page breaks, keep the toolbar on the cursor's page rather
// than the table's first page. If the cursor's page differs from the table's start
// page, re-anchor to that page's content-top so the toolbar floats in its margin.
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
if (tiptap) {
try {
@@ -148,18 +142,9 @@
tableUi = { visible: true, top, left };
}
// --- Table page-break overlay ---
// pageBreaks.ts reports (via pm-pagecount) where a single continuous table box crosses
// a page boundary (a too-tall cell's content flowing across pages, or a between-rows
// push), each as one band in document px relative to .tiptap's top. We render the
// overlay in a .band-layer inside .paper
// (in document px) so the same `transform: scale()` scales it identically to the
// .tiptap page background. Two pieces per break:
// • band — a solid page-coloured mask (content width) hiding the table's borders
// bleeding through the page margins, plus the close/open lines.
// • stripe — the dark page gap, drawn as ONE full-page-width element on top. Using
// a single rectangle for the whole gap avoids a left/right edge where two
// separately-rasterised gradients disagree by a sub-pixel (the seam).
// Table page-break overlay: pageBreaks.ts reports (via pm-pagecount) where a continuous
// table box crosses a page boundary, each as a band in doc px. Rendered in .band-layer
// inside the scaled .paper — a mask hides borders in the margins, a stripe is the gap.
type BandStyle = { top: number; left: number; width: number; height: number };
type GapStripeStyle = { top: number; width: number; height: number; background: string };
let tableBandsDoc = $state<TableBreakBand[]>([]);
@@ -178,26 +163,14 @@
// lives inside the scaled .paper, so the transform applies the scaling — no `* z`.
const border = 1; // 1px page-edge line, like the CSS .tiptap background
bandStyles = tableBandsDoc.map((b) => {
// Use pure page geometry: the band spans exactly the inter-page region — from the
// closing page's content-bottom (closeY) down through margin/gap/margin to the next
// page's content-top. This is precisely where the table must show no vertical
// borders, and pagination guarantees no real content ever sits there, so a solid
// mask here can never eat content. (Anchoring the band to the break's spacers
// instead under-covered atomic-push / leaf-jump breaks, where the spacer renders
// below content-bottom — after the empty gap left by the pushed block — leaving the
// top of the page's bottom margin unmasked and the borders bleeding through it.)
// Match the band exactly to the table's content box (b.left / b.width): the black
// close (top) + open (bottom) lines are this element's top/bottom borders, so they
// must line up with the table's own start/end borders, which span the content width.
// The 1px overhang to also mask the outer L/R borders' half-pixel slivers is done by
// a horizontal-only box-shadow (.table-break-band CSS) so the mask widens without
// lengthening those close/open lines.
// The band spans the inter-page region (closeY through margin/gap/margin to the
// next content-top), where pagination guarantees no content, so the mask can't eat
// content. Matched to the table's content box (b.left/b.width) so the lines align.
return { top: b.closeY, left: b.left, width: b.width, height: b.height };
});
// Full-page-width gap stripe: the dark page gap + its two page-edge lines, at the
// true surface bottom (closeY + marginBottom = N·cycle − gap). Covers the entire
// natural gap (margins + table) with ONE element → no seam. Painted on top of the
// bands (later in DOM) so it overrides their white fill in the gap region.
// Full-page-width gap stripe: the dark page gap + its two edge lines at the surface
// bottom (closeY + marginBottom). One element covers the whole gap → no seam; painted
// after the bands so it overrides their fill in the gap region.
gapStripeStyles = tableBandsDoc.map((b) => {
const gapStart = b.closeY + b.marginBottom;
return {
@@ -431,13 +404,9 @@
</div>
<style>
/* Overlay layer for the table page-break bands. It lives inside .paper (filling it
via inset:0) so the bands are scaled by the same transform as the .tiptap page
background — no sub-pixel seam at fractional zoom. pointer-events:none keeps the
editor clickable. The z-index must clear the column-/row-resize handles
(z-index:20 in editor.css): a too-tall cell is one continuous DOM box, so its resize
handle spans every page gap it crosses; the band masks that handle in the gap just
like it masks the table's borders bleeding through the page margins. */
/* Overlay for the table page-break bands, inside .paper (inset:0) so it scales with
the page background. pointer-events:none keeps the editor clickable. z-index clears
the resize handles (20) so the band also masks a too-tall cell's handle in the gap. */
.band-layer {
position: absolute;
inset: 0;
@@ -445,9 +414,8 @@
pointer-events: none;
}
/* Table page-break mask: a solid page-coloured fill over the table's margin band
that hides the table's vertical borders bleeding through the page margins, plus
the black close (top) + open (bottom) line. Document px inside .band-layer. The
/* Table page-break mask: a solid page-coloured fill over the margin band hiding the
table's vertical borders, plus the black close (top) + open (bottom) lines. The
dark gap itself is drawn by .page-gap-stripe on top. */
.table-break-band {
position: absolute;
@@ -455,21 +423,17 @@
background: var(--color-page-bg);
border-top: 1px solid #000;
border-bottom: 1px solid #000;
/* The fill matches the table's content box exactly so the close/open lines line up
with the table's start/end borders. The table's outer L/R borders are centred on
the content edges, though, so their outer halves sit 0.5px into the margins. Two
horizontal-only box-shadows (0 blur/spread, ±1px x-offset) paint the page colour
1px beyond each side for the band's full height — swallowing those slivers without
extending the top/bottom borders (the close/open lines stay the table's width). */
/* Fill matches the table's content box so the close/open lines align with its
start/end borders. The outer L/R borders sit 0.5px into the margins, so two
horizontal box-shadows paint 1px beyond each side to swallow those slivers. */
box-shadow:
1px 0 0 0 var(--color-page-bg),
-1px 0 0 0 var(--color-page-bg);
}
/* The page gap at a table break, drawn as ONE full-page-width element on top of the
masks so the dark gap line is a single rasterised rectangle — no left/right edge
where it could disagree (sub-pixel, at fractional zoom) with the .tiptap
background gap in the side margins. */
masks so the dark gap is a single rasterised rectangle — no left/right edge to
disagree (sub-pixel, at fractional zoom) with the .tiptap background gap. */
.page-gap-stripe {
position: absolute;
left: 0;
+5 -12
View File
@@ -44,9 +44,8 @@ export const extensions = [
FontWeight,
FontColor,
// multicolor stores the chosen color on the `highlight` mark's `color` attr;
// odf-kit exports that natively to fo:background-color (round-trips with
// LibreOffice and Word). See src/lib/export/odt.ts applyRuns for the
// custom-attr-paragraph export path.
// odf-kit exports that natively to fo:background-color. See export/odt.ts
// applyRuns for the custom-attr-paragraph export path.
Highlight.configure({ multicolor: true }),
LineHeight,
ParagraphSpacing,
@@ -54,15 +53,9 @@ export const extensions = [
BulletList,
OrderedList,
ListItem,
// Columns and rows are flexible and drag-resizable (Word-style). We keep TipTap's
// own resizable:false (so its columnResizing plugin isn't loaded) and instead supply
// a custom node view that renders the <colgroup> with *percentage* widths from the
// `colwidth` cell attrs — so the table is always full text width and stays in sync
// with the ODT export (export/odt.ts emits per-column widths summing to the text
// width). TableColumnResize adds the Word-style column drag handles; ResizableTableRow
// carries a per-row `rowHeight` attr and TableRowResize adds the row drag handles
// (exported as style:min-row-height). Table alone — unlike TableKit — does NOT
// auto-add child extensions, so listing ResizableTableRow here is the only tableRow.
// resizable:false keeps TipTap's columnResizing plugin off; TableView, the two
// resize plugins, and ResizableTableRow supply Word-style drag instead. Table
// (unlike TableKit) doesn't auto-add children, so ResizableTableRow is listed here.
Table.configure({ resizable: false, View: TableView }),
ResizableTableRow,
TableHeader,
+2 -5
View File
@@ -10,11 +10,8 @@ declare module '@tiptap/core' {
}
}
/**
* Adds `color` as an attribute on the TextStyle mark. Emits a `data-color`
* marker alongside the inline style so theme CSS (e.g. allBlack) can target
* only color-bearing spans without affecting other inline styles.
*/
// Adds a `color` attr on the TextStyle mark, plus a `data-color` marker so
// theme CSS (e.g. allBlack) can target only color-bearing spans.
export const FontColor = Extension.create({
name: 'fontColor',
+2 -5
View File
@@ -10,11 +10,8 @@ declare module '@tiptap/core' {
}
}
/**
* Adds `fontWeight` as an attribute on the TextStyle mark so that
* font-weight can be set as an inline style (e.g. 'normal' to override
* the bold appearance of a heading without changing the node type).
*/
// Adds a `fontWeight` attr on the TextStyle mark, settable as an inline style
// (e.g. 'normal' to un-bold a heading without changing the node type).
export const FontWeight = Extension.create({
name: 'fontWeight',
+3 -6
View File
@@ -38,12 +38,9 @@ export const FormattingMarks = Extension.create({
init: () => DecorationSet.empty,
apply(tr: Transaction, old: DecorationSet, _oldState: EditorState, newState: EditorState) {
const force = tr.getMeta(FORCE_REBUILD_META) === true;
// Resolve enabled-state via the DOM class on .paper. The plugin's
// `view` hook below installs a MutationObserver that dispatches a
// FORCE_REBUILD meta whenever that class toggles, so this check
// always reflects the current UI state.
// During init (no view yet) we can't read DOM, so default to keeping
// the previous set mapped through.
// Enabled-state comes from the `.paper` DOM class; the view hook's
// MutationObserver fires FORCE_REBUILD when it toggles. Before the
// view exists we can't read the DOM, so map the previous set through.
if (typeof document === 'undefined') {
return tr.docChanged ? old.map(tr.mapping, tr.doc) : old;
}
+3 -8
View File
@@ -1,11 +1,6 @@
// A labelled mirror of the undo/redo stacks, for the toolbar's history dropdowns.
//
// ProseMirror's history (prosemirror-history, via @tiptap/extension-history) stores no
// human-readable labels for its undo groups — only `undoDepth`/`redoDepth` counts. So we
// keep a parallel list of best-effort labels here, fed one transaction at a time from
// Editor.svelte's onTransaction. The list LENGTHS are reconciled to the authoritative
// depths on every transaction, so jumping N steps is always exact even when a label is
// generic. See HistoryButton.svelte for the consumer.
// Labelled mirror of the undo/redo stacks for the toolbar dropdowns. ProseMirror's
// history exposes only depth counts, no labels, so we keep parallel best-effort
// labels fed from onTransaction and reconcile lengths to the authoritative depths.
import type { Editor } from '@tiptap/core';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import { undoDepth, redoDepth, isHistoryTransaction } from '@tiptap/pm/history';
+3 -5
View File
@@ -1,10 +1,8 @@
import { Extension } from '@tiptap/core';
// ODF/LibreOffice line spacing is a multiple of the font's NATURAL line height
// (Liberation Serif ≈ 1.15× em), not of the font size. CSS unitless line-height
// multiplies the font size, so to make the on-screen box match what LibreOffice
// renders for the same "Single"/"1.5"/"Double" value, we scale by this ratio.
// Keep in sync with the base line-height in editor.css (.paper .tiptap).
// ODF line spacing multiplies the font's natural line height (Liberation Serif
// ≈1.15× em); CSS unitless line-height multiplies the font size. Scale by this
// ratio so the on-screen box matches LibreOffice. Keep in sync with editor.css.
const LINE_HEIGHT_RATIO = 1.15;
declare module '@tiptap/core' {
+3 -7
View File
@@ -1,13 +1,9 @@
import OrderedListBase from '@tiptap/extension-ordered-list';
import { DEFAULT_ORDERED_TYPE } from './orderedListTypes';
// OrderedList carrying a `listStyleType` attr (one of the ORDERED_LIST_TYPES
// keys). Rendered as `data-list-style` on the <ol>; editor.css turns that into
// the on-screen marker (list-style-type / @counter-style) and export/odt.ts
// rewrites odf-kit's default numbering into the matching style:num-format.
//
// We spread the parent's attributes so the inherited `start`/`type` attrs (and
// their parse/render HTML) keep working.
// OrderedList with a `listStyleType` attr (an ORDERED_LIST_TYPES key), rendered
// as `data-list-style` on the <ol>: editor.css maps it to the on-screen marker,
// export/odt.ts to style:num-format. Parent attrs spread in to keep start/type.
export const OrderedList = OrderedListBase.extend({
addAttributes() {
return {
+3 -10
View File
@@ -1,13 +1,6 @@
// Numbering styles for ordered lists (Word/LibreOffice-style). Single source of
// truth shared by:
// • orderedList.ts — the `listStyleType` node attr (default 'decimal')
// • Toolbar.svelte — the split-button dropdown
// • editor.css — on-screen markers via list-style-type / @counter-style
// • export/odt.ts — rewrites odf-kit's default L# list styles on export
//
// `decimal` is the default and deliberately maps to odf-kit's own default output
// (style:num-format="1" style:num-suffix="."), so existing documents export
// unchanged and the export rewrite pass is a no-op for them.
// Ordered-list numbering styles — single source of truth for orderedList.ts (the
// `listStyleType` attr), Toolbar.svelte, editor.css, and export/odt.ts. `decimal`
// is the default and maps to odf-kit's own output, so the export rewrite skips it.
export type OrderedListType =
| 'decimal' | 'decimal-paren'
+60 -109
View File
@@ -17,10 +17,9 @@ export type PageBreakDebugSnapshot = {
index: number;
tag: string;
kind: 'atomic' | 'splittable';
// Table provenance: inTableCell = this leaf lives inside a cell of a row that
// was itself too tall to fit a page (so we split its content). tableRow is set
// when the leaf *is* a whole table row (columns = colspan to bridge, isFirstRow
// = the table's first row, which breaks before the wrapper rather than mid-table).
// inTableCell = leaf lives in a cell of a too-tall row (content split). tableRow
// = leaf is a whole table row (columns = colspan to bridge; isFirstRow breaks
// before the wrapper rather than mid-table).
inTableCell: boolean;
tableRow: { columns: number; isFirstRow: boolean } | null;
naturalTop: number;
@@ -55,11 +54,9 @@ export type PageBreakDebugSnapshot = {
// Unscaled document px relative to .tiptap's top — same values handed to
// Editor.svelte via pm-pagecount to render the mask + gap overlay.
tableBreakBands: TableBreakBand[];
// Live snapshot of the *rendered* table-break overlay + table geometry (captured from
// the DOM at dump time), so we can tell whether the band-layer actually masks the
// table's borders bleeding through each gap. All rects are unscaled document px
// relative to .tiptap's top-left — the same space as `leaves`/`tableBreakBands` — so
// they line up directly. null until the first dump after the overlay mounts.
// Live snapshot of the rendered table-break overlay + table geometry, to check the
// band-layer actually masks the table borders bleeding through each gap. Rects are
// unscaled doc px relative to .tiptap (same space as leaves/bands). null before mount.
overlay: OverlayDebug | null;
};
@@ -111,10 +108,9 @@ export function getPageBreakDebug(view: EditorView): PageBreakDebugSnapshot | nu
return debugAccessors.get(view)?.() ?? null;
}
// A4 page layout constants (px at 96 dpi). The page height (and thus cycle) is
// orientation-dependent and the content-area inset (top/bottom margin) is
// user-adjustable, so both are read per layout pass from live CSS vars instead
// of being constants — the values below are only portrait fallbacks.
// A4 page layout constants (px @96dpi). Page height (orientation-dependent) and
// margins (user-adjustable) are read per pass from live CSS vars; the values below
// are only portrait fallbacks.
const PAGE_HEIGHT = 1123;
const PAGE_GAP = 20;
const DEFAULT_MARGIN_TOP = 96;
@@ -128,10 +124,9 @@ type VMargins = {
cycle: number;
};
// Reads the vertical page margins + page height (px) from the --user-* custom
// props the Layout panel writes (pageMargins.ts / pageOrientation.ts). These are
// document-px values, so they pair directly with the offsetTop measurements below.
// Side margins don't affect pagination (only line wrapping), so we ignore them.
// Reads vertical page margins + page height (px) from the --user-* props the Layout
// panel writes. Document-px values, so they pair directly with offsetTop below. Side
// margins don't affect pagination (only line wrapping), so they're ignored.
function readVerticalMargins(dom: HTMLElement): VMargins {
const cs = getComputedStyle(dom);
const top = parseFloat(cs.getPropertyValue('--user-margin-top'));
@@ -172,20 +167,17 @@ type Leaf = {
kind: 'atomic' | 'splittable';
naturalTop: number;
naturalHeight: number;
// Present when the leaf is a <tr> of a paginated table. Each row is an atomic
// leaf so the table can break between rows across pages (a whole table is
// usually taller than a page). `isFirstRow` pushes the entire table (a block
// Set when the leaf is a <tr> of a paginated table; each row is atomic so the
// table breaks between rows across pages. `isFirstRow` pushes the whole table (block
// spacer before the wrapper); later rows push via a spacer <tr> inside the table.
tableRow?: { columns: number; wrapperEl: HTMLElement; isFirstRow: boolean };
// True for paragraph leaves emitted from inside a too-tall table cell (the cell
// content is flowed across pages). Their breaks get a full-width close/open
// border + margin/gap mask so the single table element looks closed at each
// page break (it can't break structurally — it's one continuous box).
// True for paragraph leaves from inside a too-tall cell whose content flows across
// pages. Their breaks get a full-width close/open mask so the single continuous
// table box looks closed at each page break (it can't break structurally).
inTableCell?: boolean;
// Flow brackets for a too-tall row's cells. Each cell is an independent vertical
// flow that starts at the row top, so the placement loop resets its cumulative
// shift per cell: `cellStart` marks a cell's first leaf (`rowStart` additionally
// the row's first cell), `rowEnd` the last leaf of the last cell.
// Flow brackets for a too-tall row's cells (each cell is an independent flow from
// the row top, so placement resets cumulative shift per cell): cellStart = a cell's
// first leaf, rowStart = the row's first cell, rowEnd = last leaf of the last cell.
cellStart?: boolean;
rowStart?: boolean;
rowEnd?: boolean;
@@ -242,10 +234,8 @@ export const PageBreaks = Extension.create({
});
// Capture the rendered table-break overlay + table geometry from the live DOM.
// The overlay (.band-layer) is a sibling of the editor's host inside .paper, so we
// reach it by climbing to .paper. Viewport rects are converted to unscaled doc px
// relative to .tiptap's top-left (divide by the measured paper scale) so they sit
// in the same coordinate space as the bands/leaves and can be compared directly.
// .band-layer is a sibling inside .paper, reached by climbing to .paper. Viewport
// rects are divided by the paper scale → unscaled doc px, same space as bands/leaves.
function captureOverlay(tipRect: DOMRect): OverlayDebug {
const dom = editorView.dom;
const offH = dom.offsetHeight;
@@ -340,12 +330,9 @@ export const PageBreaks = Extension.create({
return prev;
}
// For a pre-leaf push we want the spacer to render OUTSIDE any
// list-item wrapper, so the <li>'s bullet marker stays aligned with
// its own text. Walk up through <li> ancestors — but stop the moment
// `target` has a prior sibling, otherwise we'd also push earlier
// already-placed paragraphs inside the same <li> (e.g. a multi-
// paragraph LI produced by backspacing across a page break).
// Render the spacer OUTSIDE any <li> wrapper so the bullet marker stays
// aligned with its text. Walk up <li> ancestors, but stop once `target` has a
// prior sibling, or we'd also push earlier paragraphs already placed in the LI.
function preLeafDocPos(leafEl: HTMLElement): number | null {
let target = leafEl;
while (
@@ -404,12 +391,9 @@ export const PageBreaks = Extension.create({
const lines: { top: number; bottom: number }[] = [];
for (const r of allRects) {
const last = lines[lines.length - 1];
// Rects are sorted by top → r.top ≥ last.top. Merge into the
// current line when they vertically overlap, so an inline run with
// a smaller font (lower ascender → larger `top`) shares the line
// of its larger-font neighbours instead of becoming a phantom
// line. The −1 px tolerance keeps touching-but-not-overlapping
// rects on separate lines.
// Rects are sorted by top. Merge into the current line when they overlap
// vertically, so a smaller-font inline run shares its neighbours' line
// instead of forming a phantom line. The −1px tolerance separates touching rects.
if (last && r.top < last.bottom - 1) {
last.top = Math.min(last.top, r.top);
last.bottom = Math.max(last.bottom, r.bottom);
@@ -429,10 +413,9 @@ export const PageBreaks = Extension.create({
if (lines.length === 0) return null;
const elRect = el.getBoundingClientRect();
// Any pre-existing spacers inside this leaf distort the viewport
// y-coordinates of lines below them. Build a table to translate
// viewport y → natural offset within the leaf (unscaled, no intra-
// spacers). offsetHeight is already unscaled, so we don't divide it.
// Pre-existing spacers inside this leaf distort the viewport y of lines below
// them. Build a map: viewport y → natural offset within the leaf (unscaled, no
// intra-spacers). offsetHeight is already unscaled, so it isn't divided.
const intraSpacers = Array.from(
el.querySelectorAll<HTMLElement>('[data-page-break-spacer]'),
)
@@ -452,12 +435,9 @@ export const PageBreaks = Extension.create({
return (viewportY - elRect.top) / scale - dropped;
}
// Line-box bottom in natural coords. Range.getClientRects() reports
// glyph-level bottoms (descender), which can sit a hair above the
// line-box bottom used by layout — enough for sub-pixel overflows to
// be missed at zoom 100. Use the next line's top as the boundary,
// and the leaf's own offsetHeight for the last line so the threshold
// matches the outer `effectiveBottom > contentEnd` check.
// Line-box bottom in natural coords. getClientRects reports glyph bottoms,
// which sit a hair above the layout line-box, missing sub-pixel overflows. Use
// the next line's top (and offsetHeight for the last line) as the boundary.
const intraSpacerTotal = intraSpacers.reduce((s, sp) => s + sp.height, 0);
const leafNaturalHeight = el.offsetHeight - intraSpacerTotal;
function lineBoxBottomNatural(i: number): number {
@@ -517,12 +497,9 @@ export const PageBreaks = Extension.create({
// layout px), matching the offsetHeight-based naturalHeight.
let cumulativeSpacerHeight = 0;
// A leaf's border-box top within .tiptap, in document px. Summing offsetTop
// up the offsetParent chain is unaffected by the `transform: scale()` on
// .paper, so the value is the same at every display zoom. offsetTop includes
// each offsetParent's top padding; the chain ends at .tiptap, whose padding-
// top is the page top margin, so the sum already uses the page-cycle origin
// (page top = 0, content starts at the top margin).
// A leaf's border-box top within .tiptap, in document px. Summing offsetTop up
// the offsetParent chain is unaffected by .paper's transform:scale, so it's the
// same at every zoom; the chain ends at .tiptap so the origin is the page top.
function topWithin(el: HTMLElement): number {
let top = 0;
let node: HTMLElement | null = el;
@@ -533,11 +510,9 @@ export const PageBreaks = Extension.create({
return top;
}
// Emit one atomic leaf per table row so the table can break between
// rows across pages. TipTap renders tables inside a node-view
// <div class="tableWrapper"><table><colgroup><tbody>…. A whole table is
// typically taller than a page, so treating it as a single atomic leaf
// left it overflowing (atomic-too-tall-no-push); row leaves fix that.
// Emit one atomic leaf per table row so the table breaks between rows across
// pages (a whole table is usually taller than a page). TipTap renders tables as
// <div class="tableWrapper"><table><colgroup><tbody>…, so walk the tbody rows.
function walkTableRows(wrapperEl: HTMLElement, inTableCell = false) {
const tableEl = (wrapperEl.tagName === 'TABLE'
? wrapperEl
@@ -577,13 +552,9 @@ export const PageBreaks = Extension.create({
tableRow: { columns, wrapperEl, isFirstRow: !seenRealRow },
});
} else {
// Row taller than a page: each cell is an independent vertical flow
// that starts at the row top and breaks in its own column (like
// LibreOffice/Word row fragments per page). Walk every cell, resetting
// the spacer baseline per cell so a sibling cell's spacers don't shift
// this cell's measured tops; the row consumes the tallest cell's
// spacers. Flow markers (cellStart/rowStart/rowEnd) let the placement
// loop bracket its cumulative shift the same way.
// Row taller than a page: each cell is an independent flow from the row
// top, breaking in its own column. Walk every cell, resetting the spacer
// baseline per cell; the row consumes the tallest cell's spacers.
const cells = (Array.from(tr.children) as HTMLElement[]).filter(
c => c.tagName === 'TD' || c.tagName === 'TH',
);
@@ -619,13 +590,9 @@ export const PageBreaks = Extension.create({
cumulativeSpacerHeight += child.offsetHeight;
continue;
}
// Skip the column-/row-resize handle widgets (tableColumnResize.ts /
// tableRowResize.ts), which ProseMirror injects as direct children of
// the active cells. They're position:absolute, so they're neither
// document content nor part of the flow — measuring one as a leaf yields
// garbage geometry (negative offsetTop, full-table height) that corrupts
// pagination. Being out of flow they take no vertical space, so unlike a
// page-break spacer we skip them without touching cumulativeSpacerHeight.
// Skip the resize-handle widgets (tableColumnResize/tableRowResize), which
// are position:absolute — not flow content, and measuring one yields garbage
// geometry. Out of flow, so skip without touching cumulativeSpacerHeight.
if (
child.classList.contains('column-resize-handle') ||
child.classList.contains('row-resize-handle')
@@ -663,10 +630,9 @@ export const PageBreaks = Extension.create({
walk(child, inTableCell);
continue;
}
// Unknown block. Out-of-flow elements (absolute/fixed) — e.g. any other
// injected widget decoration — can't be paginated and measure as garbage,
// so skip them. getComputedStyle is read only here, off the common path
// (known block tags are handled above), to avoid a per-child reflow cost.
// Unknown block. Out-of-flow elements (absolute/fixed) can't be paginated
// and measure as garbage, so skip them. getComputedStyle is read only here,
// off the common path, to avoid a per-child reflow cost.
const position = getComputedStyle(child).position;
if (position === 'absolute' || position === 'fixed') continue;
// Otherwise measure as a splittable leaf with no intra-spacers.
@@ -723,14 +689,9 @@ export const PageBreaks = Extension.create({
height: number;
row: { columns: number } | null;
}[] = [];
// Full-width close/open borders for in-cell table breaks (see Leaf.inTableCell).
// openY is the page-content-top where the cell content resumes; the band runs
// from the previous page's content-bottom (close) down through margin/gap/margin
// to openY (open). Keyed by the ROUNDED openY (the grouping id, matching the
// spacers' data-page-break-boundary attr) → mapped to the UNROUNDED openY so the
// band geometry below lands on the exact page-cycle position. A rounded openY
// would shift the band's gap stripe up to ~0.5px off the CSS background gap,
// showing as a seam at the table's edges with non-integer margins.
// Close/open bands for in-cell table breaks. Keyed by the rounded openY (the
// grouping id) → mapped to the unrounded openY so the band lands on the exact
// page-cycle position (a rounded value would show a ~0.5px seam at the edges).
const tableBands = new Map<number, number>();
const leavesDebug: PageBreakDebugSnapshot['leaves'] = [];
const placementsDebug: PageBreakDebugSnapshot['placements'] = [];
@@ -800,16 +761,9 @@ export const PageBreaks = Extension.create({
}
}
// A leaf needs a close/open band whenever its break sits inside the single
// continuous table box, where the table's outer left/right borders bleed
// through the borderless page gap:
// • inTableCell — a too-tall cell whose content flows across the page, or
// • a between-rows push (spacerRow set) — the bridging spacer <tr> is
// borderless, but the browser still paints the table's outer side
// borders continuously across it (grey lines through the margins + gap).
// Both get the same page-coloured mask + gap stripe overlay (Editor.svelte).
// First-row pushes use a block spacer before the wrapper (spacerRow === null)
// → the whole table moves to the next page, no internal gap, so no band.
// A break needs a close/open band when it sits inside the continuous table
// box, where the outer borders bleed through the gap: inTableCell, or a
// between-rows push (spacerRow). First-row pushes move the whole table → no band.
const inBand = !!leaf.inTableCell || spacerRow !== null;
if (inBand && bandOpenY !== null && spacerHeight > 0) {
const key = Math.round(bandOpenY);
@@ -841,9 +795,8 @@ export const PageBreaks = Extension.create({
if (spacerHeight > 0 && spacerDocPos !== null) {
// Round to integer px: the browser renders the spacer at integer
// offsetHeight regardless, so storing the unrounded value would
// make the model disagree with the DOM on the next pass and
// cause 1-px shake on every keystroke at non-100 % zoom.
// offsetHeight anyway, so an unrounded value would make the model disagree
// with the DOM next pass → 1px shake on every keystroke at non-100% zoom.
const h = Math.round(spacerHeight);
if (h > 0) {
placements.push({
@@ -886,11 +839,9 @@ export const PageBreaks = Extension.create({
}
}
// Skip the decoration rebuild + transaction dispatch when the
// placement set is identical to the previous pass. Most keystrokes
// don't change pagination, and re-dispatching forces ProseMirror to
// recreate the spacer DOM nodes (no `key` on the widgets) for no
// visible gain.
// Skip the decoration rebuild + dispatch when placements are identical to the
// previous pass. Most keystrokes don't change pagination, and re-dispatching
// recreates the spacer DOM nodes (no `key` on the widgets) for no gain.
const placementsKey =
placements.map((p) => `${p.docPos}:${p.height}:${p.row ? p.row.columns : 'b'}`).join('|');
if (placementsKey !== lastPlacementsKey) {
+3 -9
View File
@@ -1,14 +1,8 @@
import { Extension } from '@tiptap/core';
// Paragraph spacing (space above / below a paragraph) — the LibreOffice/Word
// "paragraph spacing" model. Stored as a plain point value (pt) so it round-trips
// 1:1 to ODF's fo:margin-top / fo:margin-bottom on export. null means "inherit
// the style/CSS default" (≈0.212cm below for body text, 0 for list items), so
// untouched documents keep their existing spacing.
//
// On screen the value renders as an inline margin that overrides the base
// margins in editor.css; on export it is emitted as fo:margin-top/bottom (see
// export/odt.ts), including for list-item paragraphs.
// Space above/below a paragraph, stored in pt so it round-trips 1:1 to ODF's
// fo:margin-top/bottom. null inherits the style/CSS default. Renders as an
// inline margin overriding editor.css; exported via export/odt.ts.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
+3 -10
View File
@@ -6,16 +6,9 @@ import type { EditorView } from '@tiptap/pm/view';
import { TableMap, cellAround, pointsAtCell } from '@tiptap/pm/tables';
import { columnWeightsFromRow, columnPercents } from './tableView';
// Word-style column resizing.
//
// TipTap's built-in columnResizing resizes a single column and lets the table
// width grow (it has no neighbour-trade mode). Instead, this plugin keeps the
// table exactly the full text width: dragging the border between column i and i+1
// only trades width between those two columns (their sum stays constant). Live
// feedback pokes the <col> elements of the custom TableView (tableView.ts); on
// release a single transaction writes the new proportional weights into the
// `colwidth` attribute of every cell, so the document fully describes the layout
// (→ exact, deterministic ODT export).
// Word-style column resizing: dragging the border between columns i and i+1 trades
// width between only those two (their sum stays constant, table stays full width).
// Live drag pokes the TableView <col> elements; release writes `colwidth` weights.
const HANDLE_WIDTH = 5; // px from a column border that activates the handle
const CELL_MIN_PX = 30; // smallest a column may be dragged to
+3 -12
View File
@@ -1,17 +1,8 @@
import { TableRow } from '@tiptap/extension-table';
// TableRow extended with a draggable row height.
//
// TipTap's stock TableRow has no height attribute. We add `rowHeight` (integer
// CSS px, unscaled document pixels at 96 dpi) and render it as `style="height: Npx"`
// on the <tr>. CSS treats a row's height as a *minimum* (the row grows if its
// content is taller), which is exactly the semantics we export — odf-kit gets a
// `style:min-row-height` (see export/odt.ts applyTableRowHeights). `null` means
// "no explicit height" → the row auto-sizes to its content (back-compat for
// documents saved before this feature).
//
// The actual drag interaction + live preview live in tableRowResize.ts; this file
// only owns the persisted attribute and its DOM/serialization mapping.
// TableRow with a `rowHeight` attr (integer CSS px @96dpi), rendered as a <tr>
// min-height and exported as style:min-row-height (export/odt.ts). null auto-sizes
// to content. The drag interaction lives in tableRowResize.ts; this file owns the attr.
export const ResizableTableRow = TableRow.extend({
addAttributes() {
+3 -16
View File
@@ -7,22 +7,9 @@ import type { EditorView } from '@tiptap/pm/view';
import { cellAround } from '@tiptap/pm/tables';
import { domCellAround, tableColumnResizeKey } from './tableColumnResize';
// Word-style row-height dragging.
//
// Dragging a horizontal grid line changes the height of the row ABOVE it (Word
// behaviour). The height is a *minimum*: the row never clips, it grows when its
// content is taller (CSS `height` on a <tr>, exported as style:min-row-height —
// see export/odt.ts). The committed value lives in the tableRow node's `rowHeight`
// attribute (tableRow.ts).
//
// Live feedback during a drag is applied via a Decoration.node carrying a `style:
// height` — ProseMirror owns that DOM write, so (unlike a direct tr.style poke
// inside the content DOM) the DOM observer doesn't revert it. Each move dispatches
// a meta-only transaction (no doc change → no pagination recompute, no history
// entry); release commits the final height in one real transaction.
//
// Column resizing (tableColumnResize.ts) takes precedence at a corner: this plugin
// yields whenever a column border is the active handle.
// Word-style row-height drag: changes the row above the grid line, min-height
// semantics (stored as `rowHeight` in tableRow.ts). Live preview is a meta-only
// Decoration.node (no history); yields to column resize at a corner.
const HANDLE_WIDTH = 5; // px from a row border that activates the handle
const MIN_ROW_PX = 16; // smallest a row may be dragged to (content still protects it)
+3 -15
View File
@@ -1,21 +1,9 @@
import type { Node as PMNode } from '@tiptap/pm/model';
import type { ViewMutationRecord } from '@tiptap/pm/view';
// Custom table node view.
//
// Unlike TipTap's built-in TableView (which renders absolute px column widths and
// sets the table's own width to their sum), this view renders the <colgroup> with
// *percentage* widths and keeps the table at width:100%. Consequences:
// • the table is always exactly the full text width (matches the ODT export,
// where odf-kit emits table:align="margins"), and
// • it stays responsive to page-margin / orientation changes (Layout panel)
// without rewriting the document.
//
// The per-column values stored in the `colwidth` cell attribute are treated as
// proportional *weights* (their absolute scale is irrelevant — only ratios matter).
// `null` means "no explicit width" → equal share. The column-resize plugin
// (tableColumnResize.ts) writes these weights and drives live drag feedback by
// poking the <col> elements this view creates.
// Custom table node view: <colgroup> uses percentage widths and the table stays at
// width:100%, so it's always full text width (matching the export) and responsive
// to margin/orientation. `colwidth` cell attrs are proportional weights (null=equal).
// Expand the first row's cells into a per-column weight array, honouring colspan.
// A cell with colspan k contributes k entries (its colwidth slice, or nulls).
+67 -132
View File
@@ -14,9 +14,8 @@ type AlignValue = 'left' | 'center' | 'right' | 'justify';
const CUST_P = '__cust_p__';
const CUST_H = '__cust_h__';
// Tables are renamed to this so odf-kit routes them to unknownNodeHandler. Its
// native table path (walkTable) calls addTable with no options, emitting cells
// with no border — invisible in LibreOffice/Word. We build the table ourselves
// and pass an explicit border instead.
// native path emits cells with no border (invisible in LibreOffice/Word), so we
// build the table ourselves with an explicit border.
const CUST_TABLE = '__cust_table__';
// Table export styling. Values mirror the editor's table CSS (src/styles/editor.css)
@@ -33,11 +32,9 @@ const ODFKIT_DEFAULT_FONT = 'Liberation Serif';
const EXPORT_FONT = 'Times New Roman';
const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in ToolbarExpanded.svelte
// Sentinel inserted between a cell's blocks (and between list items) so the
// single <text:p> odf-kit emits per cell can be split back into real block
// elements in applyCellBlocks. A Unicode private-use char: never present in user
// text, and passes through odf-kit's XML escaper untouched (escapeXml only
// touches & < >) so it survives serialization as a literal, findable character.
// Sentinel between a cell's blocks (and list items) so the single <text:p> odf-kit
// emits per cell can be split back into real blocks in applyCellBlocks. A private-use
// char: never in user text, and passes odf-kit's XML escaper (only & < >) untouched.
const SEG = '';
// Automatic list styles minted for in-cell lists (see applyCellBlocks). odf-kit's
@@ -46,10 +43,9 @@ const SEG = '';
const CELL_LIST_BULLET_STYLE = 'TblListBullet';
const CELL_LIST_NUMBER_STYLE = 'TblListNumber';
// Heading sizes/margins shown in the editor (editor.css). odf-kit's built-in
// Heading_20_N styles use larger sizes (28/24/20pt), so we rewrite them on
// export. Margins are the editor's em-based values resolved against each
// heading's own font size (top 1.5em, bottom 0.5em), converted to cm.
// Heading sizes/margins shown in the editor (editor.css); odf-kit's Heading_20_N
// defaults are larger, so we rewrite them on export. Margins are the editor's em
// values (top 1.5em, bottom 0.5em) resolved against each heading's font size, in cm.
const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: string; marginBottom: string }[] = [
{ name: 'Heading_20_1', fontSize: '20pt', marginTop: '1.058cm', marginBottom: '0.353cm' },
{ name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.847cm', marginBottom: '0.282cm' },
@@ -99,10 +95,9 @@ function normalizeLineHeight(lh: number | string): string {
return typeof lh === 'number' ? `${Math.round(lh * 100)}%` : lh;
}
// Paragraph property overrides carried over to the exported .odt. Used for both
// list-item paragraphs and table-cell paragraphs — odf-kit's ListBuilder and
// TableBuilder neither support per-paragraph alignment/spacing/line-height, so we
// inject them as automatic paragraph styles in a post-processing pass.
// Paragraph property overrides for the exported .odt, for both list-item and
// table-cell paragraphs (odf-kit's List/TableBuilder support no per-paragraph
// alignment/spacing/line-height), injected as automatic styles in post-processing.
type ParaStyle = {
align: AlignValue | null;
spaceBefore: number | null;
@@ -144,11 +139,9 @@ function paraStyleProps(style: ParaStyle): string[] {
return props;
}
// Per-cell block descriptors, built during exportTable and consumed in document
// order by applyCellBlocks to rebuild real <text:h>/<text:p>/<text:list> elements
// from the single SEG-segmented <text:p> odf-kit emits per cell. Each paragraph,
// heading, and list *item* contributes exactly one SEG-delimited segment, in the
// same DFS order they are emitted.
// Per-cell block descriptors, built in exportTable and consumed in document order by
// applyCellBlocks to rebuild real <text:h>/<text:p>/<text:list> from the SEG-segmented
// <text:p> odf-kit emits. Each paragraph/heading/list item is one segment, in DFS order.
type CellListItem = { style: ParaStyle; nested: CellListBlock | null };
type CellListBlock = { kind: 'list'; ordered: boolean; start: number | null; items: CellListItem[] };
type CellBlock =
@@ -176,10 +169,9 @@ function collectListItemStyles(node: TiptapNode, result: ParaStyle[]): void {
}
}
// Collect each table row's explicit height (px → cm string), in DFS document
// order — matching the order odf-kit emits <table:table-row> into content.xml.
// rowHeight is stored as unscaled document px at 96 dpi (tableRow.ts); convert to
// cm (px × 2.54 / 96). Rows without an explicit height yield null.
// Collect each table row's explicit height (px → cm), in DFS order matching odf-kit's
// <table:table-row> emission. rowHeight is unscaled px @96dpi (tableRow.ts), converted
// to cm (px × 2.54 / 96). Rows without an explicit height yield null.
function collectTableRowHeights(node: TiptapNode, result: (string | null)[]): void {
if (node.type === 'table') {
for (const row of node.content ?? []) {
@@ -199,16 +191,9 @@ function collectTableRowHeights(node: TiptapNode, result: (string | null)[]): vo
}
}
// odf-kit's TableBuilder has no row-height option (TableRowOptions only carries
// backgroundColor → fo:background-color on style:table-row-properties). We add the
// dragged row heights by post-processing content.xml: each <table:table-row> with a
// height gets a table:style-name pointing at a new automatic table-row style with
// style:min-row-height — a *minimum*, so the row grows with content and nothing is
// clipped (round-trips with LibreOffice/Word). use-optimal-row-height="false" stops
// LibreOffice from auto-shrinking back to the content height. The regex consumes one
// heights[] entry per <table:table-row> so the DFS order from collectTableRowHeights
// stays aligned; rows already carrying a table:style-name (not produced today — the
// editor sets no row background) are skipped.
// odf-kit's TableBuilder has no row-height option, so post-process content.xml: each
// <table:table-row> with a height gets an automatic style with style:min-row-height
// (a minimum) + use-optimal-row-height="false". One heights[] entry per row, in order.
function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]): Uint8Array {
if (heights.every(h => h === null)) return odtBytes;
@@ -248,10 +233,9 @@ function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]):
return rezipOdt(files);
}
// odf-kit's ListBuilder doesn't support per-item paragraph options, so list-item
// paragraphs always emit with text:style-name="List_20_Bullet" or "List_20_Number".
// We rewrite content.xml to point those at custom automatic styles that inherit
// from the list paragraph style and add fo:text-align / fo:margin-top / fo:margin-bottom.
// odf-kit's ListBuilder has no per-item paragraph options, so list-item paragraphs all
// emit as List_20_Bullet/Number. Rewrite content.xml to point those at automatic styles
// that inherit the list style and add fo:text-align / fo:margin-top / fo:margin-bottom.
function applyListItemStyles(odtBytes: Uint8Array, styles: ParaStyle[]): Uint8Array {
if (styles.every(paraStyleIsEmpty)) return odtBytes;
@@ -295,23 +279,12 @@ function applyListItemStyles(odtBytes: Uint8Array, styles: ParaStyle[]): Uint8Ar
return rezipOdt(files);
}
// odf-kit always emits ordered lists as style:num-format="1" style:num-suffix="."
// (tiptap-to-odt.js hardcodes { type: "numbered" }; content.js buildListStyle has
// no per-node format). The editor lets the user pick a numbering style per list
// (orderedList `listStyleType` attr — see editor/orderedListTypes.ts); this pass
// rewrites the matching automatic list style on export.
//
// odf-kit names each top-level list L1, L2, … in document order, counting BOTH
// bullet and ordered lists (content.js), so `formats` is collected in that same
// order — one (possibly-null) entry per top-level list. Lists inside table cells
// go through the custom exportTable path and get no L# name, so they aren't
// counted here. An entry is null for bullet lists and for default ('decimal')
// ordered lists, whose output already matches odf-kit's default (no rewrite).
//
// Known limitation: odf-kit emits one L# style (6 levels) per top-level list, so
// the chosen format is applied to *every* nesting level of that list. A nested
// ordered list of a different type renders correctly on screen (per-<ol> CSS) but
// inherits the outer list's numbering in the exported .odt.
// odf-kit always emits ordered lists as "1." (num-format="1" num-suffix="."). This
// rewrites the matching L# list style to the user's chosen format; formats[] has one
// entry per top-level list in odf-kit's order (null for bullets/default 'decimal').
// Limitation: the chosen format applies to every nesting level (odf-kit emits one
// L# style per top-level list), so a nested list of a different type inherits it.
type OrderedFmt = { numFormat: string; numSuffix: string };
function collectOrderedListFormats(node: TiptapNode, result: (OrderedFmt | null)[]): void {
@@ -371,21 +344,12 @@ function buildCellListStyle(styleName: string, ordered: boolean): string {
return `<text:list-style style:name="${styleName}">${levels}</text:list-style>`;
}
// odf-kit's TableBuilder serializes every cell to a single <text:p
// text:style-name="Standard"> of runs — it has no API for headings, lists, or
// multiple paragraphs in a cell. exportTable therefore emits all of a cell's
// inline content into that one paragraph, separated by SEG markers, and records a
// CellBlock[] descriptor per cell (in document order, via buildCellContent). This
// pass splits each cell paragraph back on SEG and rebuilds the real
// <text:h>/<text:p>/<text:list> elements, applying per-paragraph
// alignment/spacing/line-height overrides as minted automatic styles (same
// technique as applyListItemStyles). It subsumes the former applyTableCellStyles
// (single-paragraph cell styling).
//
// Ordering: runs *after* applyListItemStyles (so that pass sees only genuine
// top-level <text:list-item>s — cell lists are still flat runs here), and *before*
// collapseRunWhitespace (so the new in-cell <text:p>/<text:h> get their inter-run
// newlines stripped too).
// odf-kit serializes every cell to a single <text:p> of runs (no API for headings,
// lists, or multiple paragraphs). exportTable emits a cell's content into that paragraph
// SEG-separated; this pass splits on SEG and rebuilds real <text:h>/<text:p>/<text:list>.
// Ordering: after applyListItemStyles (so it sees only top-level list items, not cell
// lists) and before collapseRunWhitespace (so new in-cell paragraphs get newlines stripped).
function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8Array {
const needsWork = (blocks: CellBlock[]): boolean => {
if (blocks.length !== 1) return true;
@@ -422,10 +386,9 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
let usedBulletList = false;
let usedNumberList = false;
// Each <text:list> (root or nested) carries its own style-name based on its own
// type, so mixed bullet/number nesting renders correctly; the actual indent
// level comes from the DOM nesting depth. One segment is consumed per list item,
// in DFS order — matching buildCellContent's emission.
// Each <text:list> (root or nested) carries its own type-based style-name, so mixed
// bullet/number nesting renders correctly; indent comes from nesting depth. One
// segment consumed per list item, DFS order (matching buildCellContent).
const buildList = (list: CellListBlock, segments: string[], cur: { i: number }): string => {
const isBullet = !list.ordered;
if (isBullet) usedBulletList = true; else usedNumberList = true;
@@ -461,10 +424,9 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
return out;
};
// Match each real cell's single paragraph (filled or empty form) in document
// order. Covered cells use <table:covered-table-cell> and never match; at this
// stage a cell holds exactly one <text:p> with no nested </text:p>, so the
// non-greedy inner capture is safe.
// Match each real cell's single paragraph (filled or empty) in document order.
// Covered cells use <table:covered-table-cell> and never match; a cell holds exactly
// one <text:p> with no nesting here, so the non-greedy inner capture is safe.
let idx = 0;
content = content.replace(
/(<table:table-cell\b[^>]*>\s*)<text:p\b[^>]*?(?:\/>|>([\s\S]*?)<\/text:p>)/g,
@@ -510,11 +472,9 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
return zipSync(out);
}
// Rewrite styles.xml so the exported document matches the editor's preview:
// • default font Liberation Serif → Times New Roman (metric-identical; renders
// the same in the editor, LibreOffice, and Word — see EXPORT_FONT).
// • Heading_20_1/2/3 sizes & margins → the editor's values (odf-kit's defaults
// are larger). See HEADING_STYLE_OVERRIDES.
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
function rewriteStylesXml(odtBytes: Uint8Array): Uint8Array {
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
@@ -531,13 +491,9 @@ function rewriteStylesXml(odtBytes: Uint8Array): Uint8Array {
'<style:master-page style:name="Standard"',
);
// List item paragraphs (List_20_Bullet/List_20_Number) inherit the Standard
// style's fo:margin-bottom (0.212cm). The editor zeroes the bottom margin on
// list items (editor.css: `li`/`li p` → margin-bottom: 0), so by default the
// gap between bullets is just line-height. Without this override every
// exported bullet would gain ~6pt of extra spacing vs. the editor preview.
// Items with an explicit paragraph spacing override this via their LP styles
// (see applyListItemStyles).
// List item paragraphs inherit Standard's fo:margin-bottom (0.212cm), but the editor
// zeroes it (editor.css), so without this override every exported bullet gains ~6pt
// vs. the preview. Explicit per-item spacing overrides this via LP styles.
for (const name of ['List_20_Bullet', 'List_20_Number']) {
styles = styles.replace(
new RegExp(`(<style:style style:name="${name}"[^>]*?)/>`),
@@ -563,10 +519,9 @@ function rewriteStylesXml(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
// ODF requires fo:color in `#RRGGBB` form. TipTap stores whatever string went
// in: hex from the color picker, but `rgb(r, g, b)` after any HTML round-trip
// (paste, parseHTML in fontColor.ts). Anything that isn't valid hex is silently
// dropped by Word/LibreOffice → text renders black.
// ODF requires fo:color as #RRGGBB. TipTap may store hex (color picker) or rgb(r,g,b)
// after an HTML round-trip. Anything not valid hex is silently dropped by
// Word/LibreOffice → text renders black, so coerce it here.
function normalizeColor(input: string): string | undefined {
const s = input.trim();
if (!s) return undefined;
@@ -633,16 +588,12 @@ function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []
}
}
// Emit a cell's inline content into odf-kit's run-based CellBuilder and, in
// lockstep, return a CellBlock[] descriptor of its block structure. odf-kit will
// serialize all these runs into a single <text:p>; applyCellBlocks later splits
// that paragraph on the SEG markers we insert between segments and rebuilds the
// real <text:h>/<text:p>/<text:list> elements using this descriptor.
//
// A "segment" is one paragraph, one heading, or one list item's paragraph; we
// emit exactly one SEG between consecutive segments (never leading/trailing), so
// splitting yields one piece per segment in DFS order. Headings and list items
// get no base run formatting here — the real <text:h> / List_20_* styles supply it.
// Emit a cell's inline content into odf-kit's run-based CellBuilder and, in lockstep,
// return a CellBlock[] descriptor. odf-kit serializes the runs into one <text:p>;
// applyCellBlocks splits on SEG and rebuilds real <text:h>/<text:p>/<text:list>.
// A segment is one paragraph, heading, or list item's paragraph; exactly one SEG
// between consecutive segments, so splitting yields one piece per segment in DFS order.
function buildCellContent(cell: TiptapNode, c: CellBuilder): CellBlock[] {
const blocks: CellBlock[] = [];
const state = { emitted: false }; // whether any segment has been emitted yet
@@ -687,13 +638,9 @@ function buildCellContent(cell: TiptapNode, c: CellBuilder): CellBlock[] {
return blocks;
}
// Derive per-column widths (in cm) from the table's first row. The editor stores
// proportional *weights* in each cell's `colwidth` attr (see tableView.ts); we turn
// those into absolute cm widths that sum exactly to the text width. Combined with
// odf-kit's table:align="margins", LibreOffice/Word render the columns at exactly
// these proportions across the full text width — identical to the editor preview.
// Returns undefined when no column has an explicit width (fresh/legacy tables) so
// odf-kit falls back to even distribution.
// Per-column widths (cm) from the table's first row: the editor's proportional
// `colwidth` weights (tableView.ts) turned into absolute cm summing to the text width.
// With table:align="margins" this matches the preview. undefined → odf-kit even split.
function tableColumnWidthsCm(node: TiptapNode, contentWidthCm: number): string[] | undefined {
const firstRow = (node.content ?? []).find(r => r.type === 'tableRow');
if (!firstRow) return undefined;
@@ -721,11 +668,9 @@ function tableColumnWidthsCm(node: TiptapNode, contentWidthCm: number): string[]
return cm.map(v => `${v}cm`);
}
// Build an ODF table from a CUST_TABLE node (renamed from "table" in
// injectCustomTypes). We bypass odf-kit's native walkTable so we can pass an
// explicit cell border — the native path emits none, so the table would be
// invisible in LibreOffice/Word. Column widths come from the editor's per-column
// weights (tableColumnWidthsCm); when absent odf-kit distributes columns evenly.
// Build an ODF table from a CUST_TABLE node, bypassing odf-kit's native walkTable to
// pass an explicit cell border (the native path emits none → invisible). Column widths
// come from tableColumnWidthsCm; when absent odf-kit distributes columns evenly.
function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][]): void {
const rows = (node.content ?? []).filter(r => r.type === 'tableRow');
if (rows.length === 0) return;
@@ -736,10 +681,8 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
for (const cell of row.content ?? []) {
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
// Emit the cell's runs (SEG-separated) and record its block descriptor.
// odf-kit serializes this to one <text:p>; applyCellBlocks splits it back
// into real <text:h>/<text:p>/<text:list> using the descriptor. The push
// happens in document order (addCell runs the callback synchronously),
// matching the order applyCellBlocks walks cells in content.xml.
// addCell runs synchronously, so the push is in document order — matching
// how applyCellBlocks later walks cells in content.xml.
r.addCell((c: CellBuilder) => {
cellBlocks.push(buildCellContent(cell, c));
}, { padding: CELL_PADDING });
@@ -749,17 +692,9 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
}, columnWidths ? { border: TABLE_BORDER, columnWidths } : { border: TABLE_BORDER });
}
// odf-kit's XML builder serializes a multi-child element's children on separate
// lines, joined by "\n" (core/xml.js). Inside a <text:p>/<text:h> that newline
// is significant character data, so LibreOffice/Word collapse it to a space —
// inserting a spurious space wherever a run boundary falls mid-word (e.g. a
// highlight, bold, or colour that starts inside a word splits the text node, and
// the two runs serialize as "He\n<text:span>llo</text:span>" → "He llo").
//
// We strip the bare "\n" separators inside every paragraph/heading element.
// Only the newline characters are removed; any real space in the run text sits
// adjacent to (not inside) the "\n" and is preserved, so word-boundary spacing
// (e.g. "Hello " + bold "world") still round-trips correctly.
// odf-kit joins a multi-child element's children with "\n"; inside a <text:p>/<text:h>
// that newline is significant and collapses to a space, adding a spurious space at any
// mid-word run boundary. Strip the bare "\n" separators (real spaces sit adjacent, kept).
function collapseRunWhitespace(odtBytes: Uint8Array): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];