feat: add line spacing button to extended toolbar
This commit is contained in:
1 parent
3fab929838
commit
4ba16c62af
6 files changed
+266
-11
No files matched your search
@@ -33,6 +33,7 @@
|
||||
- Zoom feature
|
||||
- font selection
|
||||
- font size selection
|
||||
- line spacing button
|
||||
|
||||
### Not yet implemented
|
||||
- ODT import / open existing 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user