feat: add paragraph spacing
This commit is contained in:
1 parent
734d51e887
commit
e9c153b874
4 files changed
+477
-25
No files matched your search
@@ -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 @@
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<div class="lh-picker" use:lineHeightPickerClickOutside>
|
||||
<button class="lh-trigger" onclick={openLineHeightPicker} title="Line spacing">
|
||||
<button class="lh-trigger" onclick={openLineHeightPicker} title="Line & paragraph 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"/>
|
||||
@@ -894,6 +1031,7 @@
|
||||
</button>
|
||||
{#if lineHeightOpen}
|
||||
<div class="lh-dropdown">
|
||||
<div class="lh-section-label">Line spacing</div>
|
||||
{#each LINE_HEIGHTS as lh}
|
||||
<button
|
||||
class="lh-option"
|
||||
@@ -901,6 +1039,72 @@
|
||||
onclick={() => pickLineHeight(lh.value)}
|
||||
>{lh.label}</button>
|
||||
{/each}
|
||||
|
||||
<div class="lh-section-label">Space before paragraph (pt)</div>
|
||||
<div class="sp-field" use:spacePresetClickOutside={'before'}>
|
||||
<div class="sp-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="sp-input"
|
||||
bind:value={spaceBeforeInput}
|
||||
onfocus={(e) => onSpaceFocus('before', e)}
|
||||
onkeydown={(e) => onSpaceKeydown('before', e)}
|
||||
onblur={() => onSpaceBlur('before')}
|
||||
placeholder="Default"
|
||||
inputmode="decimal"
|
||||
title="Space before paragraph (pt)"
|
||||
/>
|
||||
<button class="sp-chevron" onclick={() => openSpacePreset('before')} tabindex="-1" title="Space before presets">
|
||||
<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>
|
||||
</div>
|
||||
{#if spaceBeforeOpen}
|
||||
<div class="sp-dropdown">
|
||||
{#each SPACING_PRESETS as v}
|
||||
<button
|
||||
class="sp-option"
|
||||
class:active={currentSpaceBefore === v}
|
||||
onclick={() => pickSpacePreset('before', v)}
|
||||
>{spacingLabel(v)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="lh-section-label">Space after paragraph (pt)</div>
|
||||
<div class="sp-field" use:spacePresetClickOutside={'after'}>
|
||||
<div class="sp-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="sp-input"
|
||||
bind:value={spaceAfterInput}
|
||||
onfocus={(e) => onSpaceFocus('after', e)}
|
||||
onkeydown={(e) => onSpaceKeydown('after', e)}
|
||||
onblur={() => onSpaceBlur('after')}
|
||||
placeholder="Default"
|
||||
inputmode="decimal"
|
||||
title="Space after paragraph (pt)"
|
||||
/>
|
||||
<button class="sp-chevron" onclick={() => openSpacePreset('after')} tabindex="-1" title="Space after presets">
|
||||
<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>
|
||||
</div>
|
||||
{#if spaceAfterOpen}
|
||||
<div class="sp-dropdown">
|
||||
{#each SPACING_PRESETS as v}
|
||||
<button
|
||||
class="sp-option"
|
||||
class:active={currentSpaceAfter === v}
|
||||
onclick={() => pickSpacePreset('after', v)}
|
||||
>{spacingLabel(v)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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%;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReturnType> {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+67
-23
@@ -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 <text:list-item> 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 <text:list-item> 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<string, string>();
|
||||
let counter = 0;
|
||||
let idx = 0;
|
||||
@@ -102,15 +121,15 @@ function applyListItemAlignments(odtBytes: Uint8Array, aligns: (AlignValue | nul
|
||||
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}`;
|
||||
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 }) =>
|
||||
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties fo:text-align="${align}"/></style:style>`,
|
||||
).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 `<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties ${props.join(' ')}/></style:style>`;
|
||||
}).join('\n');
|
||||
|
||||
content = content.replace('</office:automatic-styles>', `${newStyles}\n</office:automatic-styles>`);
|
||||
|
||||
@@ -161,6 +184,20 @@ function rewriteStylesXml(odtBytes: Uint8Array): Uint8Array {
|
||||
'<style:master-page style:name="Standard"',
|
||||
);
|
||||
|
||||
// List item paragraphs (List_20_Bullet/List_20_Number) inherit the Standard
|
||||
// style's fo:margin-bottom (0.212cm). The editor zeroes the bottom margin on
|
||||
// list items (editor.css: `li`/`li p` → margin-bottom: 0), so by default the
|
||||
// gap between bullets is just line-height. Without this override every
|
||||
// exported bullet would gain ~6pt of extra spacing vs. the editor preview.
|
||||
// Items with an explicit paragraph spacing override this via their LP styles
|
||||
// (see applyListItemStyles).
|
||||
for (const name of ['List_20_Bullet', 'List_20_Number']) {
|
||||
styles = styles.replace(
|
||||
new RegExp(`(<style:style style:name="${name}"[^>]*?)/>`),
|
||||
`$1><style:paragraph-properties fo:margin-bottom="0cm"/></style:style>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Scope each rewrite to its own <style:style …>…</style:style> 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
},
|
||||
});
|
||||
|
||||
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<ArrayBuffer>], { type: 'application/vnd.oasis.opendocument.text' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
Reference in new issue
Block a user