docs: shorten code comments for readability
This commit is contained in:
1 parent
173963b909
commit
6dbacf8899
16 files changed
+193
-424
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
Reference in new issue
Block a user