feat: a list marker sits at the level's hanging indent

CSS gives ::marker no position, so Chromium set it flush against the
text — about 4mm right of where Word and LibreOffice draw it. The marker
is now the item block's ::before, placed at the hanging indent both
exports already write (LIST_HANGING_CM 0.635cm): measured against LO's
own render, the three bullet levels land within 0.04mm.

Out of flow, because in flow it grew every list line by a pixel and cost
a fixture fixture a page. `list-style: none` drops only the marker box,
so the list-item counter and <ol start> keep numbering; a bullet's own
char rides an inherited property, since attr() in a pseudo would read the
block rather than the list.
This commit is contained in:
Steffen Becker committed 2026-08-08 12:57:24 +00:00
1 parent eb4d15c02c
commit 63149d52d7
3 files changed
+74 -51

No files matched your search

+3 -2
View File
@@ -22,8 +22,9 @@ export const BulletList = BulletListBase.extend({
parseHTML: (element) => element.getAttribute('data-bullet') || null,
renderHTML: (attributes) => {
const ch = attributes.bulletChar;
// The trailing space inside the string marker is the marker-to-text gap.
return ch ? { 'data-bullet': ch, style: `list-style-type: "${ch} "` } : {};
// The marker is the item block's ::before, whose attr() would read that block,
// not the list — so the char travels as an inherited custom property instead.
return ch ? { 'data-bullet': ch, style: `--bullet: "${ch}"` } : {};
},
},
};
+5 -6
View File
@@ -179,21 +179,20 @@ function listMarkerGlyph(li: HTMLElement, root: HTMLElement): string {
return defaultBulletChar(depth - 1);
}
// html2canvas can't paint our markers (string list-style-type, @counter-style paren
// numbering, counters() chains), so each marker becomes a real span hanging left of the
// item's first line — also keeping it beside float-pushed lines and in the text layer.
// html2canvas can't paint our markers (counter() numbering, counters() chains), so each
// marker becomes a real span at the same hanging indent editor.css gives the ::before —
// which the class switches off — keeping it beside float-pushed lines and in the text layer.
function materializeListMarkers(root: HTMLElement): void {
root.classList.add('pdf-list-markers');
for (const li of Array.from(root.querySelectorAll<HTMLLIElement>('li'))) {
const cs = getComputedStyle(li);
if (cs.listStyleType === 'none') continue;
const glyph = listMarkerGlyph(li, root);
if (!glyph) continue;
li.style.listStyleType = 'none';
const target = li.querySelector(':scope > p, :scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5') ?? li;
const slot = document.createElement('span');
slot.style.cssText = 'display:inline-block;width:0;overflow:visible;vertical-align:baseline';
const label = document.createElement('span');
label.style.cssText = `display:inline-block;white-space:pre;transform:translateX(-100%);padding-right:0.33em;color:${cs.color}`;
label.style.cssText = `display:inline-block;white-space:pre;transform:translateX(-0.635cm);color:${cs.color}`;
// Same symbol shim the editor's ::marker uses (glyphs Liberation Serif lacks).
label.style.fontFamily = `'EdenText Symbols', ${cs.fontFamily}`;
label.textContent = glyph;
+66 -43
View File
@@ -417,37 +417,30 @@
margin-bottom: 0;
}
/* Ordered-list numbering, keyed by data-eff-list-style — the effective numbering
orderedList.ts's plugin resolves per <ol> from its attr + ancestor chain. Dot
variants reuse the built-in counter styles; bracket variants override the suffix. */
@counter-style oledit-decimal-paren { system: extends decimal; suffix: ") "; }
@counter-style oledit-lower-alpha-paren { system: extends lower-alpha; suffix: ") "; }
@counter-style oledit-upper-alpha-paren { system: extends upper-alpha; suffix: ") "; }
@counter-style oledit-lower-roman-paren { system: extends lower-roman; suffix: ") "; }
@counter-style oledit-upper-roman-paren { system: extends upper-roman; suffix: ") "; }
.paper .tiptap ol[data-eff-list-style="decimal"] { list-style-type: decimal; }
.paper .tiptap ol[data-eff-list-style="decimal-paren"] { list-style-type: oledit-decimal-paren; }
.paper .tiptap ol[data-eff-list-style="lower-alpha"] { list-style-type: lower-alpha; }
.paper .tiptap ol[data-eff-list-style="lower-alpha-paren"] { list-style-type: oledit-lower-alpha-paren; }
.paper .tiptap ol[data-eff-list-style="upper-alpha"] { list-style-type: upper-alpha; }
.paper .tiptap ol[data-eff-list-style="upper-alpha-paren"] { list-style-type: oledit-upper-alpha-paren; }
.paper .tiptap ol[data-eff-list-style="lower-roman"] { list-style-type: lower-roman; }
.paper .tiptap ol[data-eff-list-style="lower-roman-paren"] { list-style-type: oledit-lower-roman-paren; }
.paper .tiptap ol[data-eff-list-style="upper-roman"] { list-style-type: upper-roman; }
.paper .tiptap ol[data-eff-list-style="upper-roman-paren"] { list-style-type: oledit-upper-roman-paren; }
/* Legal/outline numbering (1., 1.1., 1.2.1. …): the whole chain rendered via CSS
counters. 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::marker {
content: counters(list-item, ".") ". ";
/* 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;
}
/* The marker follows the item's first text portion (listMarker.ts sets the custom
properties on the <li>): a mark sits inside the paragraph and never reaches
::marker, which only inherits the item's own font. */
.paper .tiptap li::marker {
/* 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);
@@ -455,22 +448,52 @@
color: var(--marker-color, inherit);
}
/* Bullet markers render through the bundled symbol shim first: it holds only the
glyphs Liberation Serif lacks (❖ ➢ ⇨ ✓ ▸ ◆), so those come out compact and
platform-independent while every other char falls through to the text font. */
.paper .tiptap ul > li::marker {
font-family: 'EdenText Symbols', var(--marker-family, var(--font-serif)), serif;
/* 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). A custom `bulletChar` attr
overrides these via an inline list-style-type from bulletList.ts renderHTML. */
.paper .tiptap ul { list-style-type: "• "; }
.paper .tiptap ul ul { list-style-type: "◦ "; }
.paper .tiptap ul ul ul { list-style-type: "▪ "; }
.paper .tiptap ul ul ul ul { list-style-type: "▸ "; }
.paper .tiptap ul ul ul ul ul { list-style-type: "– "; }
.paper .tiptap ul ul ul ul ul ul { list-style-type: "· "; }
/* 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;