feat: introduce text alignment

This commit is contained in:
Steffen Becker committed 2026-05-28 00:17:51 +02:00
1 parent e5b7528c24
commit 416a405d0f
6 files changed
+328 -65

No files matched your search

+1
View File
@@ -34,6 +34,7 @@
- font selection
- font size selection
- line spacing button
- text alignment
### Not yet implemented
- ODT import / open existing file
+14
View File
@@ -21,6 +21,7 @@
"@tiptap/extension-paragraph": "^3.23.6",
"@tiptap/extension-placeholder": "^3.23.6",
"@tiptap/extension-text": "^3.23.6",
"@tiptap/extension-text-align": "^3.23.6",
"@tiptap/extension-text-style": "^3.23.6",
"@tiptap/extension-underline": "^3.23.6",
"@tiptap/pm": "^3.23.6",
@@ -643,6 +644,19 @@
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.23.6.tgz",
"integrity": "sha512-pE+gDmvnrSMWHADDnDSzaO7YUi9kOywQkyrqZ9bEPLddZred7ICoJ4NtD2DqLjDSur+HijaJuByResWb78c6FA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.23.6.tgz",
+1
View File
@@ -29,6 +29,7 @@
"@tiptap/extension-paragraph": "^3.23.6",
"@tiptap/extension-placeholder": "^3.23.6",
"@tiptap/extension-text": "^3.23.6",
"@tiptap/extension-text-align": "^3.23.6",
"@tiptap/extension-text-style": "^3.23.6",
"@tiptap/extension-underline": "^3.23.6",
"@tiptap/pm": "^3.23.6",
+191 -47
View File
@@ -3,10 +3,33 @@
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
let isAlignLeft = $derived(tick >= 0 && !!editor?.isActive({ textAlign: 'left' }));
let isAlignCenter = $derived(tick >= 0 && !!editor?.isActive({ textAlign: 'center' }));
let isAlignRight = $derived(tick >= 0 && !!editor?.isActive({ textAlign: 'right' }));
let isAlignJustify = $derived(tick >= 0 && !!editor?.isActive({ textAlign: 'justify' }));
type AlignValue = 'left' | 'center' | 'right' | 'justify';
const ALIGNMENTS: { value: AlignValue; label: string }[] = [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
{ value: 'justify', label: 'Justify' },
];
// Empty string when the selection spans paragraphs/headings with different alignments.
let currentAlign = $derived.by<AlignValue | ''>(() => {
if (tick < 0 || !editor) return 'left';
const { from, to, empty } = editor.state.selection;
if (empty) {
return (editor.state.selection.$head.parent.attrs.textAlign ?? 'left') as AlignValue;
}
let a: AlignValue | undefined;
let mixed = false;
editor.state.doc.nodesBetween(from, to, (node) => {
if (mixed) return;
if (node.type.name !== 'paragraph' && node.type.name !== 'heading') return;
const v = (node.attrs.textAlign ?? 'left') as AlignValue;
if (a === undefined) a = v;
else if (a !== v) mixed = true;
});
return mixed ? '' : (a ?? 'left');
});
// Must match the first font in --font-serif in editor.css
const DEFAULT_EDITOR_FONT = 'Georgia';
@@ -104,6 +127,7 @@
let fontOpen = $state(false);
let sizeOpen = $state(false);
let lineHeightOpen = $state(false);
let alignOpen = $state(false);
let sizeInputFocused = $state(false);
let sizeInputValue = $state('');
let savedFrom: number | null = null;
@@ -120,6 +144,7 @@
sizeOpen = false;
sizeInputFocused = false;
lineHeightOpen = false;
alignOpen = false;
// Save selection before the picker button steals focus.
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
@@ -158,6 +183,7 @@
sizeInputFocused = true;
fontOpen = false;
lineHeightOpen = false;
alignOpen = false;
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
(e.target as HTMLInputElement).select();
@@ -184,6 +210,7 @@
if (!editor) return;
fontOpen = false;
lineHeightOpen = false;
alignOpen = false;
if (!sizeInputFocused) {
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
@@ -213,6 +240,7 @@
fontOpen = false;
sizeOpen = false;
sizeInputFocused = false;
alignOpen = false;
lineHeightOpen = !lineHeightOpen;
}
@@ -229,6 +257,29 @@
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
function openAlignPicker() {
if (!editor) return;
fontOpen = false;
sizeOpen = false;
sizeInputFocused = false;
lineHeightOpen = false;
alignOpen = !alignOpen;
}
function pickAlign(value: AlignValue) {
if (!editor) return;
alignOpen = false;
editor.chain().focus().setTextAlign(value).run();
}
function alignPickerClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) alignOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
</script>
<div class="toolbar-expanded">
@@ -334,51 +385,76 @@
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button
class:active={isAlignLeft}
disabled
title="Align left (coming soon)"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="10" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button
class:active={isAlignCenter}
disabled
title="Align center (coming soon)"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="3" y1="11" x2="13" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button
class:active={isAlignRight}
disabled
title="Align right (coming soon)"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="6" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button
class:active={isAlignJustify}
disabled
title="Justify (coming soon)"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<div class="align-picker" use:alignPickerClickOutside>
<button class="align-trigger" onclick={openAlignPicker} title="Text alignment">
{#if currentAlign === 'center'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="3" y1="11" x2="13" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else if currentAlign === 'right'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="6" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else if currentAlign === 'justify'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="10" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{/if}
<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 alignOpen}
<div class="align-dropdown">
{#each ALIGNMENTS as a}
<button
class="align-option"
class:active={currentAlign === a.value}
onclick={() => pickAlign(a.value)}
title="Align {a.label.toLowerCase()}"
>
{#if a.value === 'left'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="10" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else if a.value === 'center'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="3" y1="11" x2="13" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else if a.value === 'right'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="6" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{/if}
<span>{a.label}</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="toolbar-separator"></div>
@@ -700,4 +776,72 @@
background: var(--color-primary);
color: white;
}
.align-picker {
position: relative;
}
.align-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;
}
.align-trigger:hover {
border-color: var(--color-primary);
}
.align-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
min-width: 130px;
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;
}
.align-option {
display: flex;
align-items: center;
gap: 0.5rem;
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;
}
.align-option:hover {
background: var(--color-btn-hover);
}
.align-option.active {
background: var(--color-primary);
color: white;
}
</style>
+6
View File
@@ -12,6 +12,7 @@ import OrderedList from '@tiptap/extension-ordered-list';
import ListItem from '@tiptap/extension-list-item';
import History from '@tiptap/extension-history';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { PageBreaks } from './pageBreaks';
import { LineHeight } from './lineHeight';
@@ -33,5 +34,10 @@ export const extensions = [
ListItem,
History,
Placeholder.configure({ placeholder: 'Start typing…' }),
TextAlign.configure({
types: ['paragraph', 'heading'],
alignments: ['left', 'center', 'right', 'justify'],
defaultAlignment: 'left',
}),
PageBreaks,
];
+115 -18
View File
@@ -1,32 +1,119 @@
import type { Editor } from '@tiptap/core';
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder } from 'odf-kit';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
// 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.
type AlignValue = 'left' | 'center' | 'right' | 'justify';
const LH_P = '__lh_p__';
const LH_H = '__lh_h__';
// tiptapToOdt ignores paragraph/heading node attrs (including lineHeight and
// textAlign). We rename nodes that carry either attr to custom types and
// handle them via unknownNodeHandler, which has access to the full OdtDocument API.
const CUST_P = '__cust_p__';
const CUST_H = '__cust_h__';
const DEFAULT_FONT = 'Georgia'; // must match DEFAULT_EDITOR_FONT in ToolbarExpanded.svelte
function injectLineHeightTypes(node: TiptapNode, inList = false): TiptapNode {
function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
if (!attrs) return false;
if (attrs.lineHeight) return true;
const ta = attrs.textAlign;
return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify';
}
function injectCustomTypes(node: TiptapNode, inList = false): TiptapNode {
// Don't rename paragraphs inside list items — tiptapToOdt walks them by
// type === "paragraph" to build list content; renaming breaks that.
if (!inList && node.attrs?.lineHeight) {
if (node.type === 'paragraph') return { ...node, type: LH_P };
if (node.type === 'heading') return { ...node, type: LH_H };
if (!inList && hasCustomAttrs(node.attrs)) {
if (node.type === 'paragraph') return { ...node, type: CUST_P };
if (node.type === 'heading') return { ...node, type: CUST_H };
}
if (node.content?.length) {
const childInList = inList
|| node.type === 'bulletList'
|| node.type === 'orderedList'
|| node.type === 'listItem';
return { ...node, content: node.content.map(c => injectLineHeightTypes(c, childInList)) };
return { ...node, content: node.content.map(c => injectCustomTypes(c, childInList)) };
}
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 {
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);
// 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);
}
}
return;
}
for (const child of node.content ?? []) {
collectListItemAligns(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;
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 nameByKey = new Map<string, string>();
let counter = 0;
let idx = 0;
// Each text:list-item directly contains the item's paragraph as its first child.
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}`;
let name = nameByKey.get(key);
if (!name) {
counter++;
name = `LP${counter}`;
nameByKey.set(key, name);
styleDefs.push({ name, parent: parentStyle, align });
}
return `${pre}${name}${post}`;
},
);
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');
content = content.replace('</office:automatic-styles>', `${newStyles}\n</office:automatic-styles>`);
files['content.xml'] = strToU8(content);
// Re-zip preserving ODF's mimetype-first uncompressed requirement.
const mimetype = files['mimetype'];
const out: Record<string, [Uint8Array, { level: 0 | 6 }]> = {};
if (mimetype) out['mimetype'] = [mimetype, { level: 0 }];
for (const [path, data] of Object.entries(files)) {
if (path === 'mimetype') continue;
out[path] = [data, { level: 6 }];
}
return zipSync(out);
}
function applyRuns(p: ParagraphBuilder, content: TiptapNode[] = []) {
for (const node of content) {
if (node.type !== 'text' || !node.text) continue;
@@ -45,30 +132,40 @@ function applyRuns(p: ParagraphBuilder, content: TiptapNode[] = []) {
export async function exportToOdt(editor: Editor): Promise<void> {
const raw = editor.getJSON() as TiptapNode;
const json = injectLineHeightTypes(raw);
const json = injectCustomTypes(raw);
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 opts: { lineHeight?: number | string; align?: AlignValue } = {};
if (node.attrs?.lineHeight != null) {
const lhRaw = String(node.attrs.lineHeight);
const lhNum = parseFloat(lhRaw);
opts.lineHeight = isNaN(lhNum) ? lhRaw : lhNum;
}
const ta = node.attrs?.textAlign;
if (ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify') {
opts.align = ta;
}
const content = node.content ?? [];
if (node.type === LH_P) {
if (node.type === CUST_P) {
if (content.length === 0) {
doc.addParagraph('', opts);
} else {
doc.addParagraph((p: ParagraphBuilder) => applyRuns(p, content), opts);
}
} else if (node.type === LH_H) {
} else if (node.type === CUST_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 listAligns: (AlignValue | null)[] = [];
collectListItemAligns(raw, listAligns);
const finalBytes = applyListItemAlignments(odt as Uint8Array, listAligns);
const blob = new Blob([finalBytes as Uint8Array<ArrayBuffer>], { type: 'application/vnd.oasis.opendocument.text' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;