feat: add paragraph spacing

This commit is contained in:
Steffen Becker committed 2026-06-02 23:03:10 +02:00
1 parent 734d51e887
commit e9c153b874
4 files changed
+477 -25

No files matched your search

+321 -2
View File
@@ -184,6 +184,64 @@
return mixed ? '' : (lh ?? DEFAULT_LINE_HEIGHT);
});
// Paragraph spacing (space above / below), in points. null = "Default" (the
// style/CSS default — see paragraphSpacing.ts). Shares the line-spacing dropdown.
const SPACING_PRESETS: (number | null)[] = [null, 0, 6, 12, 18, 24];
function spacingLabel(v: number | null): string {
return v === null ? 'Default' : `${v} pt`;
}
function currentSpacing(attr: 'spaceBefore' | 'spaceAfter'): number | null | '' {
if (tick < 0 || !editor) return null;
const { from, to, empty } = editor.state.selection;
if (empty) {
return (editor.state.selection.$head.parent.attrs[attr] ?? null) as number | null;
}
let v: number | null | undefined;
let mixed = false;
editor.state.doc.nodesBetween(from, to, (node) => {
if (mixed || !(attr in node.attrs)) return;
const s = (node.attrs[attr] ?? null) as number | null;
if (v === undefined) v = s;
else if (v !== s) mixed = true;
});
return mixed ? '' : (v ?? null);
}
let currentSpaceBefore = $derived(currentSpacing('spaceBefore'));
let currentSpaceAfter = $derived(currentSpacing('spaceAfter'));
// Each spacing axis is a font-size–style combo: a free-text input plus a
// preset dropdown. Empty input = "Default" (null). State per axis.
let spaceBeforeOpen = $state(false);
let spaceAfterOpen = $state(false);
let spaceBeforeFocused = $state(false);
let spaceAfterFocused = $state(false);
let spaceBeforeInput = $state('');
let spaceAfterInput = $state('');
// Keep the inputs in sync with the selection while not being edited.
$effect(() => {
if (!spaceBeforeFocused) {
spaceBeforeInput = typeof currentSpaceBefore === 'number' ? String(currentSpaceBefore) : '';
}
});
$effect(() => {
if (!spaceAfterFocused) {
spaceAfterInput = typeof currentSpaceAfter === 'number' ? String(currentSpaceAfter) : '';
}
});
// Close the per-axis preset lists whenever the parent dropdown closes so they
// don't reappear open on the next open.
$effect(() => {
if (!lineHeightOpen) {
spaceBeforeOpen = false;
spaceAfterOpen = false;
}
});
// Row 1 = greyscale, row 2 = standard hues. Rows 3–7 are generated per column
// as a light→dark gradient over the same hue, so each column reads as a shade ramp.
const COLUMN_HUES: { h: number; sBase: number }[] = [
@@ -419,6 +477,85 @@
editor.chain().focus().setLineHeight(value).run();
}
// Apply a resolved spacing value (pt number, or null for "Default") to the axis.
// Restores the saved selection so applying from the input doesn't lose it.
function applySpaceValue(axis: 'before' | 'after', value: number | null) {
if (!editor) return;
const from = savedFrom ?? editor.state.selection.from;
const to = savedTo ?? editor.state.selection.to;
savedFrom = null;
savedTo = null;
const chain = editor.chain().focus().setTextSelection({ from, to });
(axis === 'before' ? chain.setSpaceBefore(value) : chain.setSpaceAfter(value)).run();
}
// Parse a free-text input. Empty → Default (null); otherwise a non-negative pt
// number (capped). Invalid input is ignored.
function applySpaceInput(axis: 'before' | 'after', raw: string) {
const t = raw.trim();
if (t === '') return applySpaceValue(axis, null);
const n = parseFloat(t);
if (isNaN(n) || n < 0 || n > 200) return;
applySpaceValue(axis, n);
}
function onSpaceFocus(axis: 'before' | 'after', e: FocusEvent) {
if (!editor) return;
fontOpen = false;
sizeOpen = false;
sizeInputFocused = false;
alignOpen = false;
colorOpen = false;
spaceBeforeOpen = false;
spaceAfterOpen = false;
if (axis === 'before') spaceBeforeFocused = true; else spaceAfterFocused = true;
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
(e.target as HTMLInputElement).select();
}
function onSpaceKeydown(axis: 'before' | 'after', e: KeyboardEvent) {
const input = e.target as HTMLInputElement;
if (e.key === 'Enter') {
e.preventDefault();
applySpaceInput(axis, input.value);
if (axis === 'before') spaceBeforeFocused = false; else spaceAfterFocused = false;
input.blur();
} else if (e.key === 'Escape') {
if (axis === 'before') spaceBeforeFocused = false; else spaceAfterFocused = false;
input.blur();
}
}
function onSpaceBlur(axis: 'before' | 'after') {
if (axis === 'before') spaceBeforeFocused = false; else spaceAfterFocused = false;
}
function openSpacePreset(axis: 'before' | 'after') {
if (!editor) return;
if (axis === 'before') {
spaceAfterOpen = false;
spaceBeforeOpen = !spaceBeforeOpen;
} else {
spaceBeforeOpen = false;
spaceAfterOpen = !spaceAfterOpen;
}
}
function pickSpacePreset(axis: 'before' | 'after', value: number | null) {
if (axis === 'before') spaceBeforeOpen = false; else spaceAfterOpen = false;
applySpaceValue(axis, value);
}
function spacePresetClickOutside(node: HTMLElement, axis: 'before' | 'after') {
function handler(e: MouseEvent) {
if (node.contains(e.target as Node)) return;
if (axis === 'before') spaceBeforeOpen = false; else spaceAfterOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
function lineHeightPickerClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) lineHeightOpen = false;
@@ -880,7 +1017,7 @@
<div class="toolbar-separator"></div>
<div class="lh-picker" use:lineHeightPickerClickOutside>
<button class="lh-trigger" onclick={openLineHeightPicker} title="Line spacing">
<button class="lh-trigger" onclick={openLineHeightPicker} title="Line &amp; paragraph spacing">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="5" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5" y1="8" x2="14" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
@@ -894,6 +1031,7 @@
</button>
{#if lineHeightOpen}
<div class="lh-dropdown">
<div class="lh-section-label">Line spacing</div>
{#each LINE_HEIGHTS as lh}
<button
class="lh-option"
@@ -901,6 +1039,72 @@
onclick={() => pickLineHeight(lh.value)}
>{lh.label}</button>
{/each}
<div class="lh-section-label">Space before paragraph (pt)</div>
<div class="sp-field" use:spacePresetClickOutside={'before'}>
<div class="sp-input-wrap">
<input
type="text"
class="sp-input"
bind:value={spaceBeforeInput}
onfocus={(e) => onSpaceFocus('before', e)}
onkeydown={(e) => onSpaceKeydown('before', e)}
onblur={() => onSpaceBlur('before')}
placeholder="Default"
inputmode="decimal"
title="Space before paragraph (pt)"
/>
<button class="sp-chevron" onclick={() => openSpacePreset('before')} tabindex="-1" title="Space before presets">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
{#if spaceBeforeOpen}
<div class="sp-dropdown">
{#each SPACING_PRESETS as v}
<button
class="sp-option"
class:active={currentSpaceBefore === v}
onclick={() => pickSpacePreset('before', v)}
>{spacingLabel(v)}</button>
{/each}
</div>
{/if}
</div>
<div class="lh-section-label">Space after paragraph (pt)</div>
<div class="sp-field" use:spacePresetClickOutside={'after'}>
<div class="sp-input-wrap">
<input
type="text"
class="sp-input"
bind:value={spaceAfterInput}
onfocus={(e) => onSpaceFocus('after', e)}
onkeydown={(e) => onSpaceKeydown('after', e)}
onblur={() => onSpaceBlur('after')}
placeholder="Default"
inputmode="decimal"
title="Space after paragraph (pt)"
/>
<button class="sp-chevron" onclick={() => openSpacePreset('after')} tabindex="-1" title="Space after presets">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
{#if spaceAfterOpen}
<div class="sp-dropdown">
{#each SPACING_PRESETS as v}
<button
class="sp-option"
class:active={currentSpaceAfter === v}
onclick={() => pickSpacePreset('after', v)}
>{spacingLabel(v)}</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -1314,7 +1518,7 @@
position: absolute;
top: calc(100% + 3px);
left: 0;
min-width: 110px;
min-width: 200px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
@@ -1325,6 +1529,121 @@
flex-direction: column;
}
.lh-section-label {
padding: 0.4rem 0.6rem 0.2rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text);
font-family: var(--font-sans);
user-select: none;
}
.lh-section-label:not(:first-child) {
margin-top: 4px;
border-top: 1px solid var(--color-border);
}
.sp-field {
position: relative;
padding: 0 2px 2px;
}
.sp-input-wrap {
display: flex;
align-items: center;
height: 1.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
overflow: hidden;
transition: border-color 0.15s;
}
.sp-input-wrap:hover,
.sp-input-wrap:focus-within {
border-color: var(--color-primary);
}
.sp-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 0 0 0.5rem;
border: none;
background: transparent;
color: var(--color-text);
font-size: 0.8rem;
font-family: var(--font-sans);
outline: none;
}
.sp-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
min-width: unset;
height: 100%;
padding: 0;
border: none;
border-left: 1px solid var(--color-border);
border-radius: 0;
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.sp-chevron:hover {
background: var(--color-btn-hover);
}
.sp-dropdown {
position: absolute;
top: calc(100% + 1px);
left: 2px;
right: 2px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 210;
padding: 2px;
display: flex;
flex-direction: column;
max-height: 220px;
overflow-y: auto;
}
.sp-option {
display: block;
width: 100%;
padding: 0.3rem 0.6rem;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
font-size: 0.8rem;
font-family: var(--font-sans);
text-align: left;
cursor: pointer;
white-space: nowrap;
transition: background 0.1s;
min-width: unset;
height: auto;
justify-content: flex-start;
}
.sp-option:hover {
background: var(--color-btn-hover);
}
.sp-option.active {
background: var(--color-primary);
color: white;
}
.lh-option {
display: block;
width: 100%;
+2
View File
@@ -16,6 +16,7 @@ import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { PageBreaks } from './pageBreaks';
import { LineHeight } from './lineHeight';
import { ParagraphSpacing } from './paragraphSpacing';
import { FormattingMarks } from './formattingMarks';
export const extensions = [
@@ -31,6 +32,7 @@ export const extensions = [
FontWeight,
FontColor,
LineHeight,
ParagraphSpacing,
Heading.configure({ levels: [1, 2, 3] }),
BulletList,
OrderedList,
+87
View File
@@ -0,0 +1,87 @@
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.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
paragraphSpacing: {
setSpaceBefore: (pt: number | null) => ReturnType;
setSpaceAfter: (pt: number | null) => ReturnType;
};
}
}
function parseSpacing(value: string | null): number | null {
if (value == null || value === '') return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
export const ParagraphSpacing = Extension.create({
name: 'paragraphSpacing',
addOptions() {
return {
types: ['paragraph', 'heading'] as string[],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
spaceBefore: {
default: null,
parseHTML: (element: HTMLElement) =>
parseSpacing(element.getAttribute('data-space-before')),
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.spaceBefore == null) return {};
const pt = Number(attributes.spaceBefore);
return {
'data-space-before': String(pt),
style: `margin-top: ${pt}pt`,
};
},
},
spaceAfter: {
default: null,
parseHTML: (element: HTMLElement) =>
parseSpacing(element.getAttribute('data-space-after')),
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.spaceAfter == null) return {};
const pt = Number(attributes.spaceAfter);
return {
'data-space-after': String(pt),
style: `margin-bottom: ${pt}pt`,
};
},
},
},
},
];
},
addCommands() {
return {
setSpaceBefore: (pt: number | null) => ({ commands }) => {
return this.options.types
.map((type) => commands.updateAttributes(type, { spaceBefore: pt }))
.some((r) => r);
},
setSpaceAfter: (pt: number | null) => ({ commands }) => {
return this.options.types
.map((type) => commands.updateAttributes(type, { spaceAfter: pt }))
.some((r) => r);
},
};
},
});
+67 -23
View File
@@ -39,6 +39,8 @@ const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: stri
function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
if (!attrs) return false;
if (attrs.lineHeight) return true;
if (attrs.spaceBefore != null) return true;
if (attrs.spaceAfter != null) return true;
const ta = attrs.textAlign;
return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify';
}
@@ -60,40 +62,57 @@ function injectCustomTypes(node: TiptapNode, inList = false): TiptapNode {
return node;
}
// Collect the alignment of each listItem's first paragraph, in DFS order —
// matching the order that odf-kit emits <text:list-item> elements into
// content.xml. Items without a non-default alignment yield null.
function collectListItemAligns(node: TiptapNode, result: (AlignValue | null)[]): void {
// Per-list-item paragraph properties carried over to the exported .odt.
type ListItemStyle = {
align: AlignValue | null;
spaceBefore: number | null;
spaceAfter: number | null;
};
function listItemStyleIsEmpty(s: ListItemStyle): boolean {
return s.align === null && s.spaceBefore === null && s.spaceAfter === null;
}
// Collect the alignment + paragraph spacing of each listItem's first paragraph,
// in DFS order — matching the order that odf-kit emits <text:list-item> elements
// into content.xml. Items without overrides yield an all-null descriptor.
function collectListItemStyles(node: TiptapNode, result: ListItemStyle[]): void {
if (node.type === 'listItem') {
const firstPara = node.content?.find(c => c.type === 'paragraph');
const ta = firstPara?.attrs?.textAlign as AlignValue | undefined;
result.push(ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null);
const sb = firstPara?.attrs?.spaceBefore;
const sa = firstPara?.attrs?.spaceAfter;
result.push({
align: ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null,
spaceBefore: typeof sb === 'number' ? sb : null,
spaceAfter: typeof sa === 'number' ? sa : null,
});
// Recurse into nested lists only (their listItems extend the DFS sequence).
for (const child of node.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
collectListItemAligns(child, result);
collectListItemStyles(child, result);
}
}
return;
}
for (const child of node.content ?? []) {
collectListItemAligns(child, result);
collectListItemStyles(child, result);
}
}
// 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.
function applyListItemAlignments(odtBytes: Uint8Array, aligns: (AlignValue | null)[]): Uint8Array {
if (aligns.every(a => a === null)) return odtBytes;
// from the list paragraph style and add fo:text-align / fo:margin-top / fo:margin-bottom.
function applyListItemStyles(odtBytes: Uint8Array, styles: ListItemStyle[]): Uint8Array {
if (styles.every(listItemStyleIsEmpty)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
const styleDefs: { name: string; parent: string; align: AlignValue }[] = [];
const styleDefs: { name: string; parent: string; style: ListItemStyle }[] = [];
const nameByKey = new Map<string, string>();
let counter = 0;
let idx = 0;
@@ -102,15 +121,15 @@ function applyListItemAlignments(odtBytes: Uint8Array, aligns: (AlignValue | nul
content = content.replace(
/(<text:list-item>\s*<text:p text:style-name=")(List_20_Bullet|List_20_Number)(")/g,
(_match, pre, parentStyle, post) => {
const align = aligns[idx++];
if (!align) return `${pre}${parentStyle}${post}`;
const key = `${parentStyle}|${align}`;
const style = styles[idx++];
if (!style || listItemStyleIsEmpty(style)) return `${pre}${parentStyle}${post}`;
const key = `${parentStyle}|${style.align}|${style.spaceBefore}|${style.spaceAfter}`;
let name = nameByKey.get(key);
if (!name) {
counter++;
name = `LP${counter}`;
nameByKey.set(key, name);
styleDefs.push({ name, parent: parentStyle, align });
styleDefs.push({ name, parent: parentStyle, style });
}
return `${pre}${name}${post}`;
},
@@ -118,9 +137,13 @@ function applyListItemAlignments(odtBytes: Uint8Array, aligns: (AlignValue | nul
if (styleDefs.length === 0) return odtBytes;
const newStyles = styleDefs.map(({ name, parent, align }) =>
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties fo:text-align="${align}"/></style:style>`,
).join('\n');
const newStyles = styleDefs.map(({ name, parent, style }) => {
const props: string[] = [];
if (style.align) props.push(`fo:text-align="${style.align}"`);
if (style.spaceBefore != null) props.push(`fo:margin-top="${style.spaceBefore}pt"`);
if (style.spaceAfter != null) props.push(`fo:margin-bottom="${style.spaceAfter}pt"`);
return `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties ${props.join(' ')}/></style:style>`;
}).join('\n');
content = content.replace('</office:automatic-styles>', `${newStyles}\n</office:automatic-styles>`);
@@ -161,6 +184,20 @@ 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).
for (const name of ['List_20_Bullet', 'List_20_Number']) {
styles = styles.replace(
new RegExp(`(<style:style style:name="${name}"[^>]*?)/>`),
`$1><style:paragraph-properties fo:margin-bottom="0cm"/></style:style>`,
);
}
// Scope each rewrite to its own <style:style …>…</style:style> block so the
// font-size/margin replacements never bleed across heading levels.
for (const { name, fontSize, marginTop, marginBottom } of HEADING_STYLE_OVERRIDES) {
@@ -246,7 +283,12 @@ export async function exportToOdt(editor: Editor): Promise<void> {
marginLeft: PAGE_MARGIN_H,
marginRight: PAGE_MARGIN_H,
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
const opts: { lineHeight?: number | string; align?: AlignValue } = {};
const opts: {
lineHeight?: number | string;
align?: AlignValue;
spaceBefore?: string;
spaceAfter?: string;
} = {};
if (node.attrs?.lineHeight != null) {
const lhRaw = String(node.attrs.lineHeight);
const lhNum = parseFloat(lhRaw);
@@ -258,6 +300,8 @@ export async function exportToOdt(editor: Editor): Promise<void> {
if (ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify') {
opts.align = ta;
}
if (node.attrs?.spaceBefore != null) opts.spaceBefore = `${node.attrs.spaceBefore}pt`;
if (node.attrs?.spaceAfter != null) opts.spaceAfter = `${node.attrs.spaceAfter}pt`;
const content = node.content ?? [];
if (node.type === CUST_P) {
@@ -273,10 +317,10 @@ export async function exportToOdt(editor: Editor): Promise<void> {
},
});
const listAligns: (AlignValue | null)[] = [];
collectListItemAligns(raw, listAligns);
const aligned = applyListItemAlignments(odt as Uint8Array, listAligns);
const finalBytes = rewriteStylesXml(aligned);
const listStyles: ListItemStyle[] = [];
collectListItemStyles(raw, listStyles);
const styledLists = applyListItemStyles(odt as Uint8Array, listStyles);
const finalBytes = rewriteStylesXml(styledLists);
const blob = new Blob([finalBytes as Uint8Array<ArrayBuffer>], { type: 'application/vnd.oasis.opendocument.text' });
const url = URL.createObjectURL(blob);