feat: add line spacing button to extended toolbar

This commit is contained in:
Steffen Becker committed 2026-05-26 19:00:02 +02:00
1 parent 3fab929838
commit 4ba16c62af
6 files changed
+266 -11

No files matched your search

+1
View File
@@ -33,6 +33,7 @@
- Zoom feature
- font selection
- font size selection
- line spacing button
### Not yet implemented
- ODT import / open existing file
+146
View File
@@ -75,8 +75,35 @@
return mixed ? '' : (size ?? DEFAULT_FONT_SIZE);
});
// Must match line-height in editor.css (.paper .tiptap)
const DEFAULT_LINE_HEIGHT = '1.5';
const LINE_HEIGHTS = [
{ value: '1', label: 'Single' },
{ value: '1.15', label: '1.15' },
{ value: '1.5', label: '1.5' },
{ value: '2', label: 'Double' },
] as const;
let currentLineHeight = $derived.by(() => {
if (tick < 0 || !editor) return DEFAULT_LINE_HEIGHT;
const { from, to, empty } = editor.state.selection;
if (empty) {
return editor.state.selection.$head.parent.attrs.lineHeight ?? DEFAULT_LINE_HEIGHT;
}
let lh: string | undefined;
let mixed = false;
editor.state.doc.nodesBetween(from, to, (node) => {
if (mixed || !('lineHeight' in node.attrs)) return;
const h: string = node.attrs.lineHeight ?? DEFAULT_LINE_HEIGHT;
if (lh === undefined) lh = h;
else if (lh !== h) mixed = true;
});
return mixed ? '' : (lh ?? DEFAULT_LINE_HEIGHT);
});
let fontOpen = $state(false);
let sizeOpen = $state(false);
let lineHeightOpen = $state(false);
let sizeInputFocused = $state(false);
let sizeInputValue = $state('');
let savedFrom: number | null = null;
@@ -92,6 +119,7 @@
if (!editor) return;
sizeOpen = false;
sizeInputFocused = false;
lineHeightOpen = false;
// Save selection before the picker button steals focus.
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
@@ -129,6 +157,7 @@
if (!editor) return;
sizeInputFocused = true;
fontOpen = false;
lineHeightOpen = false;
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
(e.target as HTMLInputElement).select();
@@ -154,6 +183,7 @@
function openSizePicker() {
if (!editor) return;
fontOpen = false;
lineHeightOpen = false;
if (!sizeInputFocused) {
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
@@ -177,6 +207,28 @@
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
function openLineHeightPicker() {
if (!editor) return;
fontOpen = false;
sizeOpen = false;
sizeInputFocused = false;
lineHeightOpen = !lineHeightOpen;
}
function pickLineHeight(value: string) {
if (!editor) return;
lineHeightOpen = false;
editor.chain().focus().setLineHeight(value).run();
}
function lineHeightPickerClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) lineHeightOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
</script>
<div class="toolbar-expanded">
@@ -254,6 +306,34 @@
<div class="toolbar-separator"></div>
<div class="lh-picker" use:lineHeightPickerClickOutside>
<button class="lh-trigger" onclick={openLineHeightPicker} title="Line 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"/>
<line x1="5" y1="13" x2="14" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M2 5.5V1.5M2 1.5L1 2.8M2 1.5L3 2.8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 10.5V14.5M2 14.5L1 13.2M2 14.5L3 13.2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<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>
{#if lineHeightOpen}
<div class="lh-dropdown">
{#each LINE_HEIGHTS as lh}
<button
class="lh-option"
class:active={currentLineHeight === lh.value}
onclick={() => pickLineHeight(lh.value)}
>{lh.label}</button>
{/each}
</div>
{/if}
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button
class:active={isAlignLeft}
@@ -554,4 +634,70 @@
background: var(--color-primary);
color: white;
}
.lh-picker {
position: relative;
}
.lh-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
height: 2rem;
padding: 0 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
min-width: unset;
transition: border-color 0.15s;
}
.lh-trigger:hover {
border-color: var(--color-primary);
}
.lh-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
min-width: 110px;
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: 200;
padding: 2px;
display: flex;
flex-direction: column;
}
.lh-option {
display: block;
width: 100%;
padding: 0.35rem 0.6rem;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
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;
}
.lh-option:hover {
background: var(--color-btn-hover);
}
.lh-option.active {
background: var(--color-primary);
color: white;
}
</style>
+2
View File
@@ -12,6 +12,7 @@ import ListItem from '@tiptap/extension-list-item';
import History from '@tiptap/extension-history';
import Placeholder from '@tiptap/extension-placeholder';
import { PageBreaks } from './pageBreaks';
import { LineHeight } from './lineHeight';
export const extensions = [
Document,
@@ -23,6 +24,7 @@ export const extensions = [
TextStyle,
FontFamily,
FontSize,
LineHeight,
Heading.configure({ levels: [1, 2, 3] }),
BulletList,
OrderedList,
+53
View File
@@ -0,0 +1,53 @@
import { Extension } from '@tiptap/core';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
lineHeight: {
setLineHeight: (lineHeight: string) => ReturnType;
unsetLineHeight: () => ReturnType;
};
}
}
export const LineHeight = Extension.create({
name: 'lineHeight',
addOptions() {
return {
types: ['paragraph', 'heading'] as string[],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
lineHeight: {
default: null,
parseHTML: (element: HTMLElement) => element.style.lineHeight || null,
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes.lineHeight) return {};
return { style: `line-height: ${attributes.lineHeight}` };
},
},
},
},
];
},
addCommands() {
return {
setLineHeight: (lineHeight: string) => ({ commands }) => {
return this.options.types.every((type) =>
commands.updateAttributes(type, { lineHeight })
);
},
unsetLineHeight: () => ({ commands }) => {
return this.options.types.every((type) =>
commands.resetAttributes(type, 'lineHeight')
);
},
};
},
});
+63 -10
View File
@@ -1,14 +1,66 @@
import type { Editor } from '@tiptap/core';
import { tiptapToOdt } from 'odf-kit';
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder } from 'odf-kit';
// tiptapToOdt ignores paragraph/heading node attrs (including lineHeight).
// We rename nodes that carry lineHeight to custom types and handle them via
// unknownNodeHandler, which has access to the full OdtDocument API.
const LH_P = '__lh_p__';
const LH_H = '__lh_h__';
function injectLineHeightTypes(node: TiptapNode): TiptapNode {
if (node.attrs?.lineHeight) {
if (node.type === 'paragraph') return { ...node, type: LH_P };
if (node.type === 'heading') return { ...node, type: LH_H };
}
if (node.content?.length) {
return { ...node, content: node.content.map(injectLineHeightTypes) };
}
return node;
}
function applyRuns(p: ParagraphBuilder, content: TiptapNode[] = []) {
for (const node of content) {
if (node.type !== 'text' || !node.text) continue;
const marks = node.marks ?? [];
const tsm = marks.find(m => m.type === 'textStyle');
const fmt: TextFormatting = {};
if (marks.some(m => m.type === 'bold')) fmt.bold = true;
if (marks.some(m => m.type === 'italic')) fmt.italic = true;
if (marks.some(m => m.type === 'underline')) fmt.underline = true;
if (tsm?.attrs?.fontFamily) fmt.fontFamily = String(tsm.attrs.fontFamily);
if (tsm?.attrs?.fontSize) fmt.fontSize = String(tsm.attrs.fontSize);
if (tsm?.attrs?.color) fmt.color = String(tsm.attrs.color);
p.addText(node.text, Object.keys(fmt).length ? fmt : undefined);
}
}
export async function exportToOdt(editor: Editor): Promise<void> {
const json = editor.getJSON();
const odt = await tiptapToOdt(json);
const raw = editor.getJSON() as TiptapNode;
const json = injectLineHeightTypes(raw);
const blob = new Blob([odt], {
type: 'application/vnd.oasis.opendocument.text',
const odt = await tiptapToOdt(json, {
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
const lhRaw = String(node.attrs?.lineHeight ?? '1.5');
const lhNum = parseFloat(lhRaw);
const lineHeight = isNaN(lhNum) ? lhRaw : lhNum;
const opts = { lineHeight };
const content = node.content ?? [];
if (node.type === LH_P) {
if (content.length === 0) {
doc.addParagraph('', opts);
} else {
doc.addParagraph((p: ParagraphBuilder) => applyRuns(p, content), opts);
}
} else if (node.type === LH_H) {
const level = (node.attrs?.level as number) ?? 1;
doc.addHeading((p: ParagraphBuilder) => applyRuns(p, content), level, opts);
}
},
});
const blob = new Blob([odt as Uint8Array<ArrayBuffer>], { type: 'application/vnd.oasis.opendocument.text' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -18,13 +70,14 @@ export async function exportToOdt(editor: Editor): Promise<void> {
}
function getFilename(editor: Editor): string {
// Try to derive filename from first heading
const json = editor.getJSON();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const json = editor.getJSON() as any;
const heading = json.content?.find(
(node) => node.type === 'heading' && node.content?.length
(node: any) => node.type === 'heading' && node.content?.length
);
if (heading?.content?.[0]?.text) {
const name = heading.content[0].text
const firstText: string | undefined = heading?.content?.[0]?.text;
if (firstText) {
const name = firstText
.slice(0, 50)
.replace(/[^a-zA-Z0-9\s-]/g, '')
.trim()
+1 -1
View File
@@ -24,7 +24,7 @@
outline: none;
font-family: var(--font-serif);
font-size: 1.05rem;
line-height: 1.6;
line-height: 1.5;
color: var(--color-text);
/* Multi-page background: page surfaces separated by editor background */