diff --git a/CHANGELOG.md b/CHANGELOG.md index 21cffec..edf146e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - font selection - font size selection - line spacing button +- text alignment ### Not yet implemented - ODT import / open existing file diff --git a/package-lock.json b/package-lock.json index d952fca..8103a0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 93ec325..9f1ca1b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/editor/ToolbarExpanded.svelte b/src/lib/editor/ToolbarExpanded.svelte index 48b017d..5fe5c94 100644 --- a/src/lib/editor/ToolbarExpanded.svelte +++ b/src/lib/editor/ToolbarExpanded.svelte @@ -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(() => { + 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); } }; + }
@@ -334,51 +385,76 @@
-
- - - - + {#if alignOpen} +
+ {#each ALIGNMENTS as a} + + {/each} +
+ {/if}
@@ -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; + } diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 5c043b9..a2fc690 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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, ]; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index f371bb0..2425e19 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 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(); + let counter = 0; + let idx = 0; + + // Each text:list-item directly contains the item's paragraph as its first child. + content = content.replace( + /(\s* { + 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 }) => + ``, + ).join('\n'); + + content = content.replace('', `${newStyles}\n`); + + files['content.xml'] = strToU8(content); + + // Re-zip preserving ODF's mimetype-first uncompressed requirement. + const mimetype = files['mimetype']; + const out: Record = {}; + 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 { 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], { 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], { type: 'application/vnd.oasis.opendocument.text' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url;