Files
edentext/src/styles/editor.css
T
Steffen Becker 7a4174cd07 fix: a table cell's metrics follow LibreOffice, not the browser
Two measured deviations, both worth ~1mm a row on a fixture: a cell's first block
got the space above that LibreOffice adds nowhere in a cell, and a repeating
header row rendered centred and bold because it is a <th> and the UA says so.
Pagination's page-top-drop detection now skips cell leaves, or the space that
is never there would read as a decoration to add back.
2026-08-10 13:49:12 +00:00

926 lines
34 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
.editor {
flex: 1;
/* Top padding clears the toolbar island overlaying this scroll area (height set
by App.svelte); extra bottom padding clears the fixed status bar. */
padding: calc(var(--toolbar-overlay-h, 0px) + 1.25rem) 1rem 3rem;
overflow: auto;
/* Positioning context for the floating table toolbar (TableToolbar.svelte),
which is an absolutely-positioned child that scrolls with the content. */
position: relative;
}
/* Reserves the SCALED footprint of the transformed .paper (a CSS transform takes
no layout space of its own), so the scrollbars and horizontal centering reflect
the zoomed page. Its width/height are sized in JS (Editor.svelte). */
.paper-scaler {
margin: 0 auto;
}
/* A4 paper container (794px = 210mm at 96dpi by default; --user-page-width is
set from the Layout panel and swaps to 1123px in landscape — pageOrientation.ts) */
.paper {
/* Driven by --user-page-height (pageOrientation.ts); fallback = A4 portrait. The
page geometry lives here, not on .tiptap, so both inherit the same values. */
--page-height: var(--user-page-height, 1123px);
--page-gap: 20px;
--page-cycle: calc(var(--page-height) + var(--page-gap));
width: var(--user-page-width, 794px);
/* Zoom is a `transform: scale()` set inline (Editor.svelte); pin the origin so the
page scales down/right from the top-left of its reserved footprint. */
transform-origin: top left;
/* Positioning context for the table page-break overlay (.band-layer in
Editor.svelte), which lives inside .paper so the same transform scales it
identically to the .tiptap page background. */
position: relative;
/* A frame or a formula wider than the text area is cut at the sheet's edge, as it is
in LibreOffice. `clip` rather than `hidden`: it must not become a scroll box. */
overflow-x: clip;
/* The page surface (white) alternating with the gap, where .tiptap paints only the
borders on top of it. It sits here so a header's page background (.hf-bg-layer,
z-index -1) can paint between the sheet and the body text. */
background: repeating-linear-gradient(
to bottom,
var(--color-page-bg) 0px,
var(--color-page-bg) var(--page-height),
transparent var(--page-height),
transparent var(--page-cycle)
);
background-size: 100% var(--page-cycle);
background-repeat: repeat-y;
}
.paper .tiptap {
/* Driven by --user-margin-* set on :root from the Layout panel (pageMargins.ts).
Fallbacks preserve the original hardcoded layout when unset. */
--page-margin-top: var(--user-margin-top, 96px);
--page-margin-bottom: var(--user-margin-bottom, 96px);
--page-margin-left: var(--user-margin-left, 80px);
--page-margin-right: var(--user-margin-right, 80px);
position: relative;
width: 100%;
min-height: var(--page-height);
padding: var(--page-margin-top) var(--page-margin-right) var(--page-margin-bottom) var(--page-margin-left);
outline: none;
font-family: var(--font-serif);
/* 12pt matches odf-kit's Standard style and the toolbar's declared default. */
font-size: 12pt;
/* "Single" spacing is the font's natural line height (Liberation Serif ≈ 1.15× em),
not CSS unitless 1. A paragraph's line spacing multiplies that, so the two ride as
separate variables and the block rule below is the one place they meet. */
--natural-line: 1.15;
--line-factor: 1;
line-height: 1.15;
color: var(--color-page-text);
/* Page borders, drawn over the sheet .paper paints */
background:
/* Left border per page */
repeating-linear-gradient(
to bottom,
var(--color-page-border) 0px,
var(--color-page-border) var(--page-height),
transparent var(--page-height),
transparent var(--page-cycle)
),
/* Right border per page */
repeating-linear-gradient(
to bottom,
var(--color-page-border) 0px,
var(--color-page-border) var(--page-height),
transparent var(--page-height),
transparent var(--page-cycle)
),
/* Top/bottom border lines of each page */
repeating-linear-gradient(
to bottom,
var(--color-page-border) 0px,
var(--color-page-border) 1px,
transparent 1px,
transparent calc(var(--page-height) - 1px),
var(--color-page-border) calc(var(--page-height) - 1px),
var(--color-page-border) var(--page-height),
transparent var(--page-height),
transparent var(--page-cycle)
);
background-size:
1px var(--page-cycle),
1px var(--page-cycle),
100% var(--page-cycle);
background-position:
left top,
right top,
left top;
background-repeat: repeat-y;
border: none;
/* Subtle overall shadow for depth */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
/* Page break spacer widgets (inserted by ProseMirror decorations).
line-height/font-size are zeroed so the spacer doesn't pick up the
inherited inline strut when it sits mid-paragraph between text nodes —
otherwise the browser adds a phantom line of height around the widget
and the continuation text on the next page lands too low.
Scoped to div so it never overrides the display of the table-row spacer below. */
.paper .tiptap div[data-page-break-spacer] {
display: block;
width: 100%;
margin: 0;
padding: 0;
border: none;
background: none;
line-height: 0;
font-size: 0;
overflow: hidden;
}
/* Table page-break spacer: a borderless, empty <tr>/<td> that bridges the page
gap so the following rows start on the next page (a table can't hold a block
spacer between its rows). Keeps default table-row/cell display. */
.paper .tiptap tr[data-page-break-spacer] {
background: none;
}
.paper .tiptap td[data-page-break-spacer-cell] {
padding: 0;
border: none;
line-height: 0;
font-size: 0;
}
/* No paragraph spacing by default, like LibreOffice's Default Paragraph Style and
Word's implied 0 — an editor default here would show up as extra space in every
imported document (export/odt.ts zeroes odf-kit's Standard style to match). */
.paper .tiptap p {
margin-top: 0;
margin-bottom: 0;
}
/* A page-anchored frame is placed from its page corner and is out of the flow, so the
paragraph the importer hoists it into reserves no space (image.ts). */
.paper .tiptap p:has(> [data-anchor-page]) {
height: 0;
margin: 0;
}
/* Paragraph "colored field" + rule line (paragraphBox.ts). Background-color and per-side
borders are inline styles from renderHTML; a bordered block gets a little padding so the
text doesn't touch the rule (matches LibreOffice's fo:padding on such paragraphs). */
.paper .tiptap [data-pborder-top],
.paper .tiptap [data-pborder-bottom],
.paper .tiptap [data-pborder-left],
.paper .tiptap [data-pborder-right] {
padding: 0.04cm 0.08cm;
}
/* Hyperlinks (link.ts). Ctrl/Cmd+click opens them (Editor.svelte). */
.paper .tiptap a {
color: #1a56db;
text-decoration: underline;
cursor: text;
}
/* Find & Replace match highlights (searchReplace.ts); current match is stronger. */
.paper .tiptap .search-match {
background: rgba(255, 213, 0, 0.45);
border-radius: 2px;
}
.paper .tiptap .search-match-current {
background: rgba(255, 145, 0, 0.8);
}
/* Generated table of contents (tableOfContents.ts). A non-editable block listing
headings with dot leaders to their live page number; entries are clickable. */
.paper .tiptap .toc {
margin: 0 0 0.5cm;
user-select: none;
cursor: default;
}
.paper .tiptap .toc-title {
font-size: 16pt;
font-weight: bold;
margin-bottom: 0.3cm;
}
.paper .tiptap .toc-empty {
font-style: italic;
opacity: 0.6;
}
.paper .tiptap .toc-entry {
display: flex;
align-items: baseline;
cursor: pointer;
}
.paper .tiptap .toc-entry:hover .toc-text {
text-decoration: underline;
}
/* One level = 0.5cm, the indent LibreOffice's own Contents 1…5 styles carry — the
fallback for an index whose file names no per-level style (that one brings its own). */
.paper .tiptap .toc-level-2:not([data-style]) { padding-left: 0.5cm; }
.paper .tiptap .toc-level-3:not([data-style]) { padding-left: 1cm; }
.paper .tiptap .toc-level-4:not([data-style]) { padding-left: 1.5cm; }
.paper .tiptap .toc-level-5:not([data-style]) { padding-left: 2cm; }
.paper .tiptap .toc-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
}
/* Leader dots filling the gap: the row grants the span the space that is left and
clips the dots that do not fit, so the page number stays put. */
.paper .tiptap .toc-leader {
flex: 1 1 0; /* basis 0: the dots are decoration, they must not size the row */
min-width: 1em;
overflow: hidden;
white-space: nowrap;
}
.paper .tiptap .toc-page {
flex: 0 0 auto;
white-space: nowrap;
}
/* Formulas (formula.ts). MathML needs a font with an OpenType MATH table or Chromium
drops ∑/ϕ and won't stretch brackets — STIX Two Math is bundled for that. */
/* The family must be set on <math> itself: the UA stylesheet declares
`math { font-family: math }` on the element, and an inherited value never beats it. */
.paper .tiptap .formula math {
font-family: 'STIX Two Math', math, serif;
font-style: normal;
}
/* Baseline, not middle: an inline formula sits on the text baseline in LibreOffice and
Word, and MathML already carries the right baseline for its own box. */
.paper .tiptap span.formula {
display: inline-block;
vertical-align: baseline;
cursor: pointer;
}
/* A display formula owns its line, centered, as in LibreOffice and Word. */
.paper .tiptap span.formula[data-display='true'] {
display: block;
text-align: center;
}
.paper .tiptap span.formula.selected {
outline: 2px solid var(--color-primary, #52748b);
outline-offset: 1px;
border-radius: 2px;
}
/* Inline, as-character images (image.ts). The wrapper reserves the rotated bounding
box; the rotor holds the <img>, resize handles and rotate grip, rotating together. */
.paper .tiptap .image-node {
position: relative;
display: inline-block;
vertical-align: bottom;
line-height: 0;
}
/* While selected, lift the node (and its handles) above the header/footer layer
(z-index 22) so a rotate/resize handle poking into a margin stays grabbable. */
.paper .tiptap .image-node.image-selected {
z-index: 25;
}
.paper .tiptap .image-rotor {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.paper .tiptap .image-rotor img {
width: 100%;
height: 100%;
display: block;
}
.paper .tiptap .image-node.image-selected .image-rotor img {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 1px;
}
.paper .tiptap .image-resize-handle {
display: none;
position: absolute;
width: 10px;
height: 10px;
background: var(--accent, #3b82f6);
border: 1px solid #fff;
border-radius: 50%;
z-index: 30;
}
.paper .tiptap .image-node.image-selected .image-resize-handle,
.paper .tiptap .image-node.image-selected .image-rotate-handle,
.paper .tiptap .textbox-node.textbox-selected .image-resize-handle,
.paper .tiptap .textbox-node.textbox-selected .image-rotate-handle,
.paper .tiptap .textbox-node.textbox-active .image-resize-handle,
.paper .tiptap .textbox-node.textbox-active .image-rotate-handle {
display: block;
}
.paper .tiptap .image-resize-nw { top: -5px; left: -5px; cursor: nwse-resize; }
.paper .tiptap .image-resize-n { top: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; }
.paper .tiptap .image-resize-ne { top: -5px; right: -5px; cursor: nesw-resize; }
.paper .tiptap .image-resize-e { top: 50%; right: -5px; margin-top: -5px; cursor: ew-resize; }
.paper .tiptap .image-resize-se { bottom: -5px; right: -5px; cursor: nwse-resize; }
.paper .tiptap .image-resize-s { bottom: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; }
.paper .tiptap .image-resize-sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
.paper .tiptap .image-resize-w { top: 50%; left: -5px; margin-top: -5px; cursor: ew-resize; }
/* Rotate grip floats above top-centre with a short connector line; the icon is a
circular rotation arrow. */
.paper .tiptap .image-rotate-handle {
display: none;
position: absolute;
top: -28px;
left: 50%;
width: 16px;
height: 16px;
margin-left: -8px;
background: #fff center / 11px 11px no-repeat
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 1 1-2.6-6.4'/%3E%3Cpath d='M21 4v5h-5'/%3E%3C/svg%3E");
border: 1px solid var(--accent, #3b82f6);
border-radius: 50%;
cursor: grab;
z-index: 31;
}
.paper .tiptap .image-rotate-handle::before {
content: '';
position: absolute;
top: 15px;
left: 50%;
width: 1px;
height: 13px;
margin-left: -0.5px;
background: var(--accent, #3b82f6);
}
/* Live size readout while resizing. */
.paper .tiptap .image-size-badge {
display: none;
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
padding: 2px 7px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
border-radius: 4px;
pointer-events: none;
z-index: 32;
}
/* Text boxes / shapes (textBox.ts). Same wrapper/rotor split as images: the wrapper
reserves the rotated bounding box, the rotor carries fill/stroke/rotation and the
shared image-* handles; .textbox-content is the editable area. */
.paper .tiptap .textbox-node {
position: relative;
}
.paper .tiptap .textbox-node.textbox-selected,
.paper .tiptap .textbox-node.textbox-active {
z-index: 25;
}
.paper .tiptap .textbox-rotor {
position: absolute;
left: 50%;
top: 50%;
box-sizing: border-box;
transform: translate(-50%, -50%);
}
.paper .tiptap .textbox-node.textbox-selected .textbox-rotor,
.paper .tiptap .textbox-node.textbox-active .textbox-rotor {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 1px;
}
/* Grab affordance on the frame edge (outside the text area). */
.paper .tiptap .textbox-rotor {
cursor: move;
}
.paper .tiptap .textbox-content {
cursor: text;
min-height: 1em;
}
.paper .tiptap .textbox-content > :first-child {
margin-top: 0;
padding-top: 0;
}
.paper .tiptap .textbox-content > :last-child {
margin-bottom: 0;
}
/* Multi-column section (columns.ts); column-count/gap come from the node's
inline style. Balanced fill matches ODF/Word section behavior; no visual
affordance — the section must read as plain flowing text. */
.paper .tiptap .columns-node {
column-fill: balance;
}
.paper .tiptap .columns-node > :first-child {
margin-top: 0;
padding-top: 0;
}
.paper .tiptap .columns-node > :last-child {
margin-bottom: 0;
}
/* A tab stop's leader fills the gap the tab was given as margin-left. The span is
zero-width (tab-size:0), so its right padding edge is where the stop sits. Not scoped
to .tiptap: a static header/footer zone carries the same spans. */
.paper .tab-leader {
position: relative;
}
.paper .tab-leader::before {
position: absolute;
right: 0;
top: 0;
width: var(--leader-w, 0);
overflow: hidden;
white-space: pre;
line-height: inherit;
pointer-events: none;
/* Long enough to outrun any gap A4 can hold; the box clips the rest. */
content: '................................................................................................................................................................................................................';
}
.paper .tab-leader[data-leader='-']::before {
content: '----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------';
}
.paper .tab-leader[data-leader='_']::before {
content: '____________________________________________________________________________________________________________________________________________________________________________________________________________';
}
.paper .tab-leader[data-leader='·']::before {
content: '····································································································································································································································';
}
/* The document's own tab interval (storage/tabInterval.ts), LibreOffice's by default. */
.paper .tiptap {
tab-size: var(--tab-interval, 1.25cm);
/* prosemirror-view sets `break-spaces`, which forbids a line-end space from
hanging into the margin, so lines break one word earlier than in
Word/LibreOffice. `pre-wrap` lets it hang, as they do. */
white-space: pre-wrap;
/* A word too long for its line (a URL, a German compound) is broken by Word and
LibreOffice rather than run past the margin. `break-word`, not `anywhere`, so a
table column's min-content width is still the longest word. */
overflow-wrap: break-word;
}
.paper .tiptap h1,
.paper .tiptap h2,
.paper .tiptap h3,
.paper .tiptap h4,
.paper .tiptap h5 {
/* Neutral base: font, size and margins come from the named paragraph styles
(styles/styleSheet.ts → the #document-styles element Editor.svelte writes). */
font: inherit;
margin: 0;
}
/* Space above a block. LibreOffice adds it to the space below the block above, which CSS
margins can't do (they collapse to the larger) — so it renders as padding. A Word
document takes the larger, LibreOffice included, and gets a margin instead. */
@property --space-before {
syntax: '<length>';
inherits: false;
initial-value: 0;
}
.paper .tiptap :is(p, h1, h2, h3, h4, h5, .toc, .toc-title, .toc-entry) {
padding-top: var(--space-before);
}
.paper[data-spacing-model='max'] .tiptap :is(p, h1, h2, h3, h4, h5, .toc, .toc-title, .toc-entry) {
padding-top: 0;
margin-top: var(--space-before);
}
/* LibreOffice adds no space above the first block of a table cell (measured; its
AddParaTableSpacingAtStart), Word does — so it follows the document's spacing model. */
.paper:not([data-spacing-model='max']) .tiptap :is(td, th) > :first-child {
padding-top: 0;
margin-top: 0;
}
/* The line box: the paragraph's spacing factor times its font's natural line height.
Either half may come from the style rule or, nearer, from the block's own attrs
(lineHeight.ts / blockFontSize.ts) — multiplying them here is what keeps both. */
.paper .tiptap :is(p, h1, h2, h3, h4, h5, li, .toc-entry) {
line-height: calc(var(--line-factor) * var(--natural-line));
}
/* A spacing above single puts all of its extra leading *below* each line (measured in
LibreOffice); CSS splits it half above. Shifting the block up by that half draws its
lines where LibreOffice does and leaves the flow — so pagination — untouched. */
.paper .tiptap :is(p, h1, h2, h3, h4, h5) {
position: relative;
top: calc((1 - var(--line-factor)) * var(--natural-line) * 0.5em);
}
.paper .tiptap ul,
.paper .tiptap ol {
/* One level = LIST_LEFT_STEP_CM (export/docx.ts, and the 1.27cm base the ODT list
styles use), so the on-screen item text sits where both exports place it. */
padding-left: 1.27cm;
margin-top: 0;
/* Matches the exported List_20_* styles, which inherit Standard's zeroed margin. */
margin-bottom: 0;
}
/* The marker is drawn by the item block's ::before, not by ::marker, because Word and
LibreOffice put it at the level's hanging indent (LIST_HANGING_CM 0.635cm, which both
exports write) and CSS gives ::marker no position — Chromium sets it flush against the
text, ~4mm to the right. `list-style: none` only drops the marker box; the li stays a
list-item, so the list-item counter and <ol start> keep working. */
.paper .tiptap li {
list-style: none;
}
/* Out of flow, so the marker cannot touch the line it labels — in flow it grew every
list line by a pixel, which cost the thesis fixture a page. `top: 0` seats it on the
first line, since the pseudo takes the block's own line-height. */
.paper .tiptap li > :is(p, h1, h2, h3, h4, h5) {
position: relative;
}
.paper .tiptap li > :is(p, h1, h2, h3, h4, h5)::before {
position: absolute;
top: 0;
left: -0.635cm;
white-space: pre;
line-height: inherit;
/* The marker follows the item's first text portion (listMarker.ts sets the custom
properties on the <li>, which the block inherits). */
font-family: var(--marker-family, inherit);
font-weight: var(--marker-weight, inherit);
font-style: var(--marker-style, inherit);
font-size: var(--marker-size, inherit);
color: var(--marker-color, inherit);
}
/* A label set against the far end of the hanging indent grows into the margin instead
of into the text — what keeps Word's Roman numerals off the words they label. */
.paper .tiptap :is(ol, ul)[data-marker-align="right"] > li > :is(p, h1, h2, h3, h4, h5)::before {
left: auto;
right: calc(100% + 0.635cm);
}
/* Ordered numbering, keyed by data-eff-list-style — the effective numbering
orderedList.ts's plugin resolves per <ol> from its attr + ancestor chain. The
undecorated <ol> is the plain decimal fallback and must come first. */
.paper .tiptap ol > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, decimal) ". "; }
.paper .tiptap ol[data-eff-list-style="decimal"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, decimal) ". "; }
.paper .tiptap ol[data-eff-list-style="decimal-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, decimal) ") "; }
.paper .tiptap ol[data-eff-list-style="lower-alpha"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, lower-alpha) ". "; }
.paper .tiptap ol[data-eff-list-style="lower-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, lower-alpha) ") "; }
.paper .tiptap ol[data-eff-list-style="upper-alpha"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, upper-alpha) ". "; }
.paper .tiptap ol[data-eff-list-style="upper-alpha-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, upper-alpha) ") "; }
.paper .tiptap ol[data-eff-list-style="lower-roman"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, lower-roman) ". "; }
.paper .tiptap ol[data-eff-list-style="lower-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, lower-roman) ") "; }
.paper .tiptap ol[data-eff-list-style="upper-roman"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, upper-roman) ". "; }
.paper .tiptap ol[data-eff-list-style="upper-roman-paren"] > li > :is(p, h1, h2, h3, h4, h5)::before { content: counter(list-item, upper-roman) ") "; }
/* Legal/outline numbering (1., 1.1., 1.2.1. …): the plugin tags the multilevel list and
every attr-less member of its chain with data-eff-list-style="multilevel". */
.paper .tiptap ol[data-eff-list-style="multilevel"] > li > :is(p, h1, h2, h3, h4, h5)::before {
content: counters(list-item, ".") ". ";
}
/* Default bullet markers per nesting depth — the same cycle the ODT/DOCX export writes
(DEFAULT_BULLET_CYCLE in bulletListTypes.ts). The bundled symbol shim comes first: it
holds only the glyphs Liberation Serif lacks (❖ ➢ ⇨ ✓ ▸ ◆), so those stay compact and
platform-independent while every other char falls through to the text font. */
.paper .tiptap ul > li > :is(p, h1, h2, h3, h4, h5)::before {
content: '• ';
font-family: 'EdenText Symbols', var(--marker-family, var(--font-serif)), serif;
}
.paper .tiptap ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '◦ '; }
.paper .tiptap ul ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '▪ '; }
.paper .tiptap ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '▸ '; }
.paper .tiptap ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '– '; }
.paper .tiptap ul ul ul ul ul ul > li > :is(p, h1, h2, h3, h4, h5)::before { content: '· '; }
/* A list's own `bulletChar` beats the cycle at any depth — an attribute outranks the
descendant chain. A nested list without one keeps its depth's char, which is what both
exports write, even though --bullet inherits into it. */
.paper .tiptap ul[data-bullet] > li > :is(p, h1, h2, h3, h4, h5)::before {
content: var(--bullet) ' ';
}
/* The PDF clone (the .paper itself) replaces every marker with a real span (pdf.ts). */
.paper.pdf-list-markers li > :is(p, h1, h2, h3, h4, h5)::before {
content: none;
}
.paper .tiptap li {
margin-top: 0;
margin-bottom: 0;
}
.paper .tiptap li p {
margin-bottom: 0;
}
/* Tables. The visual border/padding must match the ODT export so the editor
and the exported .odt look identical (see export/odt.ts CUST_TABLE handler):
• border → export emits 0.5pt solid #000000; 1px solid renders crisply
on screen and is the visual equivalent of a thin 0.5pt line.
• padding → Word's/LibreOffice's cell margins. The fallback below is Word's
own default; a file's own value rides --cell-pad (tableCellPadding.ts).
Tables are wrapped in <div class="tableWrapper"> by TipTap's node view. */
.paper .tiptap .tableWrapper {
/* Default is flush with the text area; a dragged outer edge sets an inline
margin-left/-right here (tableView.ts), which is what the export writes. */
margin: 0;
overflow-x: auto;
}
.paper .tiptap table {
width: 100%;
/* fixed layout → the per-column <col> percentage widths from the custom node
view (tableView.ts) are authoritative; width:100% keeps the table the full
text width, matching the ODT export (export/odt.ts emits per-column widths
summing to the text width with table:align="margins"). */
table-layout: fixed;
border-collapse: collapse;
}
.paper .tiptap td,
.paper .tiptap th {
border: 1px solid #000;
padding: var(--cell-pad, 0 0.19cm);
vertical-align: top;
/* A repeating header row is a <th>, and no word processor centres or bolds its text
for that reason — drop the two UA defaults so the cell follows its own style. */
text-align: inherit;
font-weight: inherit;
/* Positioning context for the column-resize handle widget (a child div the
resize plugin places at the cell's right edge). */
position: relative;
/* Cells hold paragraphs; suppress their bottom margin so a single-line cell
isn't taller than its text (matches the list-item paragraph treatment). */
}
.paper .tiptap td > p,
.paper .tiptap th > p {
margin-bottom: 0;
}
/* Header-row cells (tableHeaderRow.ts toggle): bold rendered presentationally so all
text — existing, typed, or pasted — is bold. The light fill comes from the cell's
inline background-color; export bakes the bold for Word/LibreOffice consistency. */
.paper .tiptap td.cell-header,
.paper .tiptap th.cell-header {
font-weight: 600;
}
/* Selected-cell highlight while editing (ProseMirror tables add .selectedCell).
A translucent overlay via the ::after avoids disturbing the cell border. */
.paper .tiptap .selectedCell {
position: relative;
}
.paper .tiptap .selectedCell::after {
content: '';
position: absolute;
inset: 0;
background: var(--color-primary);
opacity: 0.12;
pointer-events: none;
}
/* Column resize (tableColumnResize.ts). The handle is a widget the plugin places
at a cell's right edge when the pointer is near an internal column border; the
plugin reads the pointer position itself, so the handle ignores pointer events.
`.resize-cursor` is toggled on the whole editor while a border is active. */
.paper .tiptap.resize-cursor {
cursor: col-resize;
}
.paper .tiptap .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
z-index: 20;
background: var(--color-primary);
pointer-events: none;
}
/* The table's outer left edge: same handle, mirrored to the cell's left side. */
.paper .tiptap .column-resize-handle.left {
left: -2px;
right: auto;
}
.paper .tiptap .column-resize-dragging {
/* Suppress text-selection flicker while dragging a column border. */
user-select: none;
}
/* Row resize (tableRowResize.ts). Mirrors the column handle but sits at a cell's
lower edge; one handle per cell forms a continuous line across the active row.
`.row-resize-cursor` is toggled on the whole editor while a row border is active.
Column resize takes precedence at a corner, so the two never fight. */
.paper .tiptap.row-resize-cursor {
cursor: row-resize;
}
.paper .tiptap .row-resize-handle {
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 4px;
z-index: 20;
background: var(--color-primary);
pointer-events: none;
}
.paper .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--color-text-muted);
pointer-events: none;
height: 0;
}
/* Paragraph mark (¶). Reserved in the layout at all times, so the toggle
only recolors it and text never reflows. Zero-size inline-block, glyph overflows
visibly: width/height/line-height 0 drop both footprint and line-box strut. */
.paper .tiptap p::after,
.paper .tiptap h1::after,
.paper .tiptap h2::after,
.paper .tiptap h3::after,
.paper .tiptap h4::after,
.paper .tiptap h5::after {
content: '\00A0¶';
display: inline-block;
width: 0;
height: 0;
line-height: 0;
overflow: visible;
white-space: pre;
color: transparent;
opacity: 0.5;
font-family: var(--font-sans);
font-weight: 400;
user-select: none;
pointer-events: none;
}
.paper.show-formatting-marks .tiptap p::after,
.paper.show-formatting-marks .tiptap h1::after,
.paper.show-formatting-marks .tiptap h2::after,
.paper.show-formatting-marks .tiptap h3::after,
.paper.show-formatting-marks .tiptap h4::after,
.paper.show-formatting-marks .tiptap h5::after {
color: var(--color-primary);
}
/* After an inline atom ProseMirror's trailing-break BR adds a line the block has not
got, so a paragraph holding an image renders one too tall. Kept after a real BR
(it carries the caret onto the empty line) and in an empty block (its only line). */
.paper .tiptap :not(br) + br.ProseMirror-trailingBreak {
display: none;
}
/* A block ProseMirror ends with a trailing-break BR — an empty one, or one ending in a
hard break — puts the inline ¶ on a line of its own past that BR, so the block renders
one line too tall. Pull ::after out of flow, onto the last line. */
.paper .tiptap p:has(> br.ProseMirror-trailingBreak),
.paper .tiptap h1:has(> br.ProseMirror-trailingBreak),
.paper .tiptap h2:has(> br.ProseMirror-trailingBreak),
.paper .tiptap h3:has(> br.ProseMirror-trailingBreak),
.paper .tiptap h4:has(> br.ProseMirror-trailingBreak),
.paper .tiptap h5:has(> br.ProseMirror-trailingBreak),
.paper .tiptap li > p:has(> br.ProseMirror-trailingBreak) {
position: relative;
}
.paper .tiptap p:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap h1:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap h2:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap h3:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap h4:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap h5:has(> br.ProseMirror-trailingBreak)::after,
.paper .tiptap li > p:has(> br.ProseMirror-trailingBreak)::after {
position: absolute;
/* Out of flow, so restore the paragraph's line-height (the base rule zeroes it) to
seat the glyph on a line instead of the collapsed top edge — the last one, which
for an empty block is also the first. */
line-height: inherit;
top: calc(100% - 1lh);
left: 0;
margin-left: 0;
}
.paper.show-formatting-marks .tiptap .pm-space-mark {
position: relative;
}
.paper.show-formatting-marks .tiptap .pm-space-mark::before {
content: '·';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
opacity: 0.55;
font-weight: 700;
pointer-events: none;
user-select: none;
}
/* Tab marks: a → arrow overlaid at the start of the tab's width (the tab char
itself supplies the gap), mirroring Word/LibreOffice. */
.paper.show-formatting-marks .tiptap .pm-tab-mark {
position: relative;
}
.paper.show-formatting-marks .tiptap .pm-tab-mark::before {
content: '→';
position: absolute;
left: 0.1em;
top: 0;
color: var(--color-primary);
opacity: 0.5;
pointer-events: none;
user-select: none;
}
/* Spell-check: red wavy underline under misspelled words (drawn by spellCheck.ts).
skip-ink:none keeps the wave continuous under descenders. */
.tiptap .pm-spell-error {
text-decoration: red wavy underline;
text-decoration-skip-ink: none;
}
/* Print styles: proper A4 pagination */
@media print {
.editor {
padding: 0;
overflow: visible;
}
/* Never scale for print — the real page size comes from @page below. */
.paper-scaler {
width: auto;
height: auto;
}
.paper {
width: 100%;
max-width: none;
transform: none;
background: white !important;
}
.paper .tiptap {
background: none !important;
box-shadow: none;
border: none;
padding: 0;
min-height: auto;
}
/* Squiggles are an editing aid, never printed. */
.tiptap .pm-spell-error {
text-decoration: none;
}
.paper .tiptap > *[data-page-break] {
page-break-before: always;
}
@page {
size: A4;
margin: 25.4mm;
}
/* Hide the space/tab glyphs when printing (they're out-of-flow ::before, so removing
them changes nothing). The ¶ stays reserved but transparent so print pagination
matches the screen — removing it would reflow paragraphs it shares a full line with. */
.paper.show-formatting-marks .tiptap .pm-space-mark::before,
.paper.show-formatting-marks .tiptap .pm-tab-mark::before {
content: none;
}
.paper.show-formatting-marks .tiptap p::after,
.paper.show-formatting-marks .tiptap h1::after,
.paper.show-formatting-marks .tiptap h2::after,
.paper.show-formatting-marks .tiptap h3::after,
.paper.show-formatting-marks .tiptap h4::after,
.paper.show-formatting-marks .tiptap h5::after {
color: transparent;
}
}
/* Fallback channel: drive color from the --font-color custom property so the
visible color survives even if ProseMirror's incremental DOM update leaves
a reused span with a stale `style` attribute that no longer contains
`color:`. The inline `color:` declaration still wins when present. */
.tiptap [data-color] {
color: var(--font-color, currentColor);
}
/* allBlack theme: force all explicitly-colored text to the page text color.
The color is still stored on the textStyle mark, so .odt exports remain faithful. */
[data-theme="allBlack"] .tiptap [data-color] {
color: var(--color-page-text) !important;
}