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 @@
-
@@ -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);