From e9c153b874440a0267d8e1faa701cb35d3fa6dea Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 2 Jun 2026 17:57:29 +0200 Subject: [PATCH] feat: add paragraph spacing --- src/lib/editor/ToolbarExpanded.svelte | 323 +++++++++++++++++++++++++- src/lib/editor/extensions.ts | 2 + src/lib/editor/paragraphSpacing.ts | 87 +++++++ src/lib/export/odt.ts | 90 +++++-- 4 files changed, 477 insertions(+), 25 deletions(-) create mode 100644 src/lib/editor/paragraphSpacing.ts diff --git a/src/lib/editor/ToolbarExpanded.svelte b/src/lib/editor/ToolbarExpanded.svelte index 5af4295..e1c980a 100644 --- a/src/lib/editor/ToolbarExpanded.svelte +++ b/src/lib/editor/ToolbarExpanded.svelte @@ -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 @@
- {#if lineHeightOpen}
+ {#each LINE_HEIGHTS as lh} {/each} + + +
+
+ onSpaceFocus('before', e)} + onkeydown={(e) => onSpaceKeydown('before', e)} + onblur={() => onSpaceBlur('before')} + placeholder="Default" + inputmode="decimal" + title="Space before paragraph (pt)" + /> + +
+ {#if spaceBeforeOpen} +
+ {#each SPACING_PRESETS as v} + + {/each} +
+ {/if} +
+ + +
+
+ onSpaceFocus('after', e)} + onkeydown={(e) => onSpaceKeydown('after', e)} + onblur={() => onSpaceBlur('after')} + placeholder="Default" + inputmode="decimal" + title="Space after paragraph (pt)" + /> + +
+ {#if spaceAfterOpen} +
+ {#each SPACING_PRESETS as v} + + {/each} +
+ {/if} +
{/if}
@@ -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%; diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index b10821e..77098f7 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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, diff --git a/src/lib/editor/paragraphSpacing.ts b/src/lib/editor/paragraphSpacing.ts new file mode 100644 index 0000000..b628a22 --- /dev/null +++ b/src/lib/editor/paragraphSpacing.ts @@ -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 { + 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) => { + 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) => { + 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); + }, + }; + }, +}); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 984849c..5f1e5d7 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 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 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(); let counter = 0; let idx = 0; @@ -102,15 +121,15 @@ function applyListItemAlignments(odtBytes: Uint8Array, aligns: (AlignValue | nul content = content.replace( /(\s* { - 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 }) => - ``, - ).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 ``; + }).join('\n'); content = content.replace('', `${newStyles}\n`); @@ -161,6 +184,20 @@ function rewriteStylesXml(odtBytes: Uint8Array): Uint8Array { ']*?)/>`), + `$1>`, + ); + } + // Scope each rewrite to its own … 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 { 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 { 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 { }, }); - 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], { type: 'application/vnd.oasis.opendocument.text' }); const url = URL.createObjectURL(blob);