feat: introduce text alignment
This commit is contained in:
1 parent
e5b7528c24
commit
416a405d0f
6 files changed
+328
-65
No files matched your search
@@ -34,6 +34,7 @@
|
||||
- font selection
|
||||
- font size selection
|
||||
- line spacing button
|
||||
- text alignment
|
||||
|
||||
### Not yet implemented
|
||||
- ODT import / open existing file
|
||||
|
||||
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user