feat: paragraph style manager ui
This commit is contained in:
1 parent
ebb76a5104
commit
603f4df905
14 files changed
+773
-76
No files matched your search
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import AlignIcon from './AlignIcon.svelte';
|
||||
|
||||
import type { AlignValue } from './AlignIcon.svelte';
|
||||
|
||||
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
|
||||
|
||||
type AlignValue = 'left' | 'center' | 'right' | 'justify';
|
||||
|
||||
const ALIGNMENTS: AlignValue[] = ['left', 'center', 'right', 'justify'];
|
||||
|
||||
// Empty string when the selection spans paragraphs/headings with different alignments.
|
||||
@@ -51,31 +52,7 @@
|
||||
|
||||
<div class="align-picker" use:alignPickerClickOutside>
|
||||
<button class="align-trigger" onclick={openAlignPicker} title={t().align.title}>
|
||||
{#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}
|
||||
<AlignIcon value={currentAlign || 'left'} />
|
||||
<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>
|
||||
@@ -90,31 +67,7 @@
|
||||
onclick={() => pickAlign(a)}
|
||||
title={t().align.alignTo(t().align[a])}
|
||||
>
|
||||
{#if a === '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 === '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 === '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}
|
||||
<AlignIcon value={a} />
|
||||
<span>{t().align[a]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
// The four alignment glyphs, shared by the toolbar's align picker and the style
|
||||
// manager so both show the same control.
|
||||
export type AlignValue = 'left' | 'center' | 'right' | 'justify';
|
||||
|
||||
let { value }: { value: AlignValue } = $props();
|
||||
|
||||
// Per line: x1 and x2 of the three rules that make up the glyph.
|
||||
const LINES: Record<AlignValue, [number, number][]> = {
|
||||
left: [[2, 14], [2, 10], [2, 12]],
|
||||
center: [[2, 14], [4, 12], [3, 13]],
|
||||
right: [[2, 14], [6, 14], [4, 14]],
|
||||
justify: [[2, 14], [2, 14], [2, 14]],
|
||||
};
|
||||
</script>
|
||||
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
{#each LINES[value] as [x1, x2], i}
|
||||
<line x1={x1} y1={3 + i * 4} x2={x2} y2={3 + i * 4} stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
{/each}
|
||||
</svg>
|
||||
@@ -0,0 +1,470 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import {
|
||||
DEFAULT_STYLE, HEADING_PARENT, isAbstractStyle, propsFromBlock, resolveStyle, styleDelta,
|
||||
styleOrder, uniqueStyleName, type Style,
|
||||
} from '../styles/styleSheet';
|
||||
import { deleteStyle, putStyle, renameStyle, resetStyle, styleSheet } from '../styles/sheet.svelte';
|
||||
import { blockStyleName } from '../editor/extensions/paragraphStyle';
|
||||
import { CANDIDATE_FONTS, detectAvailableFonts } from '../utils/fontDetect';
|
||||
import AlignIcon, { type AlignValue } from './AlignIcon.svelte';
|
||||
|
||||
// LibreOffice's style manager: pick a style, edit its properties, or make a new one
|
||||
// from the cursor's formatting. Edits apply live — every block using the style follows.
|
||||
let { open = $bindable(false), editor }: { open?: boolean; editor: Editor | null } = $props();
|
||||
|
||||
const ALIGNMENTS: AlignValue[] = ['left', 'center', 'right', 'justify'];
|
||||
|
||||
let dialogEl: HTMLDialogElement | null = $state(null);
|
||||
let selected = $state(DEFAULT_STYLE);
|
||||
// The fonts installed on this machine, detected once the dialog is first opened
|
||||
// (same list the font picker uses).
|
||||
let fonts = $state<string[]>([]);
|
||||
|
||||
let sheet = $derived(styleSheet());
|
||||
// Indented by inheritance depth, so the chain is visible.
|
||||
let rows = $derived(styleOrder(sheet, true).map((s) => ({ style: s, depth: depthOf(s) })));
|
||||
let style = $derived(sheet.paragraph[selected] ?? sheet.paragraph[DEFAULT_STYLE]);
|
||||
let resolved = $derived(resolveStyle(sheet, selected));
|
||||
// A style can't inherit from itself or from one of its own descendants.
|
||||
let parentOptions = $derived(
|
||||
Object.keys(sheet.paragraph).filter((name) => name !== style.name && !descendsFrom(name, style.name)),
|
||||
);
|
||||
|
||||
function depthOf(s: Style): number {
|
||||
let depth = 0;
|
||||
let cur = s.parent;
|
||||
const seen = new Set<string>();
|
||||
while (cur && sheet.paragraph[cur] && !seen.has(cur)) { seen.add(cur); depth++; cur = sheet.paragraph[cur].parent; }
|
||||
return depth;
|
||||
}
|
||||
|
||||
function descendsFrom(name: string, ancestor: string): boolean {
|
||||
let cur: string | null = name;
|
||||
const seen = new Set<string>();
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
if (cur === ancestor) return true;
|
||||
cur = sheet.paragraph[cur]?.parent ?? null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const el = dialogEl;
|
||||
if (!el) return;
|
||||
if (open && !el.open) el.showModal();
|
||||
else if (!open && el.open) el.close();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open && !fonts.length) fonts = detectAvailableFonts(CANDIDATE_FONTS);
|
||||
});
|
||||
|
||||
// Follow the cursor's style when the dialog opens (LibreOffice preselects it too).
|
||||
$effect(() => {
|
||||
if (open && editor) selected = blockStyleName(editor.state.selection.$from.parent as never);
|
||||
});
|
||||
|
||||
// An undefined patch value clears the style's own property, so it inherits again.
|
||||
function edit(patch: { para?: Partial<Style['para']>; text?: Partial<Style['text']> }) {
|
||||
const merge = <T extends object>(base: T, over: Partial<T>): T => {
|
||||
const out = { ...base } as T;
|
||||
for (const [key, value] of Object.entries(over)) {
|
||||
if (value === undefined) delete out[key as keyof T];
|
||||
else out[key as keyof T] = value as T[keyof T];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
putStyle({ ...style, para: merge(style.para, patch.para ?? {}), text: merge(style.text, patch.text ?? {}) });
|
||||
}
|
||||
|
||||
// A style may name a font that isn't installed here; keep it in the list so editing
|
||||
// another property doesn't silently drop it.
|
||||
let fontOptions = $derived(
|
||||
style.text.fontFamily && !fonts.includes(style.text.fontFamily)
|
||||
? [style.text.fontFamily, ...fonts]
|
||||
: fonts,
|
||||
);
|
||||
|
||||
// A field left empty clears the style's own value, so it inherits again.
|
||||
const num = (v: string) => (v.trim() === '' ? undefined : Number(v));
|
||||
|
||||
// An empty field shows what the parent chain provides, marked as inherited so it can't
|
||||
// be mistaken for this style's own value.
|
||||
const inherited = (value: string | number | undefined) =>
|
||||
value === undefined || value === '' ? '' : `${value} (${t().styles.inherited})`;
|
||||
|
||||
// The cursor's block, with the marks of its first text run.
|
||||
function blockFormatting() {
|
||||
if (!editor) return null;
|
||||
const node = editor.state.selection.$from.parent;
|
||||
const first = node.firstChild;
|
||||
return {
|
||||
node,
|
||||
current: blockStyleName(node as never),
|
||||
shown: propsFromBlock(node as never, (first?.marks ?? []) as never),
|
||||
};
|
||||
}
|
||||
|
||||
function newFromSelection() {
|
||||
const info = blockFormatting();
|
||||
if (!info || !editor) return;
|
||||
const name = uniqueStyleName(sheet, t().styles.newName);
|
||||
const own = styleDelta(info.shown, resolveStyle(sheet, info.current));
|
||||
putStyle({ name, parent: info.current, next: info.current, para: own.para, text: own.text });
|
||||
// The formatting now lives in the style, so the block drops it (LibreOffice's behavior).
|
||||
editor.commands.setParagraphStyle(name);
|
||||
editor.commands.clearDirectFormatting();
|
||||
selected = name;
|
||||
}
|
||||
|
||||
function updateFromSelection() {
|
||||
const info = blockFormatting();
|
||||
if (!info || !editor) return;
|
||||
const own = styleDelta(info.shown, resolveStyle(sheet, style.parent ?? DEFAULT_STYLE));
|
||||
putStyle({ ...style, para: { ...style.para, ...own.para }, text: { ...style.text, ...own.text } });
|
||||
editor.commands.setParagraphStyle(style.name);
|
||||
editor.commands.clearDirectFormatting();
|
||||
}
|
||||
|
||||
function rename() {
|
||||
const next = prompt(t().styles.renamePrompt, style.name)?.trim();
|
||||
if (!next || next === style.name || sheet.paragraph[next]) return;
|
||||
const from = style.name;
|
||||
renameStyle(from, next);
|
||||
// Retag every block that referenced the old name.
|
||||
editor?.commands.command(({ tr, state, dispatch }) => {
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.attrs?.styleName === from) tr.setNodeAttribute(pos, 'styleName', next);
|
||||
});
|
||||
if (dispatch && tr.docChanged) dispatch(tr);
|
||||
return true;
|
||||
});
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function remove() {
|
||||
const gone = style.name;
|
||||
const fallback = style.parent ?? DEFAULT_STYLE;
|
||||
deleteStyle(gone);
|
||||
editor?.commands.command(({ tr, state, dispatch }) => {
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.attrs?.styleName === gone) tr.setNodeAttribute(pos, 'styleName', fallback);
|
||||
});
|
||||
if (dispatch && tr.docChanged) dispatch(tr);
|
||||
return true;
|
||||
});
|
||||
selected = fallback;
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
class="styles"
|
||||
bind:this={dialogEl}
|
||||
onclose={() => (open = false)}
|
||||
onclick={(e) => { if (e.target === dialogEl) open = false; }}
|
||||
aria-label={t().styles.title}
|
||||
>
|
||||
<div class="card">
|
||||
<header>
|
||||
<h2>{t().styles.title}</h2>
|
||||
<button class="close" onclick={() => (open = false)} aria-label={t().common.close}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<ul class="list">
|
||||
{#each rows as { style: s, depth } (s.name)}
|
||||
<li>
|
||||
<button
|
||||
class="entry"
|
||||
class:active={selected === s.name}
|
||||
style="padding-left: {0.6 + depth * 0.8}rem"
|
||||
onclick={() => (selected = s.name)}
|
||||
>
|
||||
{s.name}
|
||||
{#if isAbstractStyle(s.name)}<span class="badge">{t().styles.abstract}</span>
|
||||
{:else if !s.builtin}<span class="badge">{t().styles.custom}</span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="fields">
|
||||
<!-- Fallbacks are the page's own defaults, not `inherit`: an inline `inherit`
|
||||
would beat the .preview rule and pull in the dialog's UI text color. -->
|
||||
<div class="preview" style="
|
||||
font-family: {resolved.text.fontFamily ?? 'var(--font-serif)'};
|
||||
font-size: {Math.min(24, resolved.text.fontSizePt ?? 12)}pt;
|
||||
font-weight: {resolved.text.bold ? 700 : 400};
|
||||
font-style: {resolved.text.italic ? 'italic' : 'normal'};
|
||||
color: {resolved.text.color ?? 'var(--color-page-text)'};
|
||||
text-decoration: {[resolved.text.underline && 'underline', resolved.text.strike && 'line-through'].filter(Boolean).join(' ') || 'none'};
|
||||
text-align: {resolved.para.textAlign ?? 'left'};
|
||||
">{style.name}</div>
|
||||
|
||||
<label>{t().styles.parent}
|
||||
<select value={style.parent ?? ''} onchange={(e) => putStyle({ ...style, parent: e.currentTarget.value || null })}>
|
||||
<option value="">—</option>
|
||||
{#each parentOptions as name}<option value={name}>{name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>{t().styles.font}
|
||||
<select
|
||||
value={style.text.fontFamily ?? ''}
|
||||
onchange={(e) => edit({ text: { fontFamily: e.currentTarget.value || undefined } })}
|
||||
>
|
||||
<option value="">{inherited(resolved.text.fontFamily) || '—'}</option>
|
||||
{#each fontOptions as font}
|
||||
<option value={font} style="font-family: '{font}'">{font}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<label>{t().styles.size}
|
||||
<input type="number" min="1" max="400" step="0.5"
|
||||
value={style.text.fontSizePt ?? ''}
|
||||
placeholder={inherited(resolved.text.fontSizePt)}
|
||||
onchange={(e) => edit({ text: { fontSizePt: num(e.currentTarget.value) } })} />
|
||||
</label>
|
||||
<label>{t().styles.color}
|
||||
<input type="color" value={style.text.color ?? resolved.text.color ?? '#000000'}
|
||||
onchange={(e) => edit({ text: { color: e.currentTarget.value.toUpperCase() } })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row toggles">
|
||||
<label class="check"><input type="checkbox" checked={!!resolved.text.bold}
|
||||
onchange={(e) => edit({ text: { bold: e.currentTarget.checked } })} />{t().styles.bold}</label>
|
||||
<label class="check"><input type="checkbox" checked={!!resolved.text.italic}
|
||||
onchange={(e) => edit({ text: { italic: e.currentTarget.checked } })} />{t().styles.italic}</label>
|
||||
<label class="check"><input type="checkbox" checked={!!resolved.text.underline}
|
||||
onchange={(e) => edit({ text: { underline: e.currentTarget.checked } })} />{t().styles.underline}</label>
|
||||
<label class="check"><input type="checkbox" checked={!!resolved.text.strike}
|
||||
onchange={(e) => edit({ text: { strike: e.currentTarget.checked } })} />{t().styles.strike}</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">{t().styles.alignment}</span>
|
||||
<div class="align-row">
|
||||
{#each ALIGNMENTS as a}
|
||||
<button
|
||||
class="align-btn"
|
||||
class:active={style.para.textAlign === a}
|
||||
class:inherit={!style.para.textAlign && (resolved.para.textAlign ?? 'left') === a}
|
||||
title={t().align.alignTo(t().align[a])}
|
||||
aria-pressed={style.para.textAlign === a}
|
||||
onclick={() => edit({ para: { textAlign: style.para.textAlign === a ? undefined : a } })}
|
||||
>
|
||||
<AlignIcon value={a} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>{t().styles.spaceBefore}
|
||||
<input type="number" min="0" max="200" step="1" value={style.para.spaceBefore ?? ''}
|
||||
placeholder={inherited(resolved.para.spaceBefore ?? 0)}
|
||||
onchange={(e) => edit({ para: { spaceBefore: num(e.currentTarget.value) } })} />
|
||||
</label>
|
||||
<label>{t().styles.spaceAfter}
|
||||
<input type="number" min="0" max="200" step="1" value={style.para.spaceAfter ?? ''}
|
||||
placeholder={inherited(resolved.para.spaceAfter ?? 0)}
|
||||
onchange={(e) => edit({ para: { spaceAfter: num(e.currentTarget.value) } })} />
|
||||
</label>
|
||||
<label>{t().styles.indent}
|
||||
<input type="number" min="0" max="20" step="0.25" value={style.para.indent ?? ''}
|
||||
placeholder={inherited(resolved.para.indent ?? 0)}
|
||||
onchange={(e) => edit({ para: { indent: num(e.currentTarget.value) } })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<button onclick={newFromSelection}>{t().styles.newFromSelection}</button>
|
||||
<button onclick={updateFromSelection}>{t().styles.updateFromSelection}</button>
|
||||
<span class="spacer"></span>
|
||||
{#if style.builtin}
|
||||
<button onclick={() => resetStyle(style.name)}>{t().styles.reset}</button>
|
||||
{:else}
|
||||
<button onclick={rename}>{t().styles.rename}</button>
|
||||
<button class="danger" onclick={remove} disabled={style.name === HEADING_PARENT}>{t().common.remove}</button>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.styles {
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
max-width: none;
|
||||
}
|
||||
.styles::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(46rem, 92vw);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--island-radius);
|
||||
/* The card's own outline: in dark mode a shadow can't separate it from the
|
||||
backdrop, so the edge has to be a real line. */
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
font-family: var(--font-sans);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
header h2 { margin: 0; font-size: 0.95rem; }
|
||||
.close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.close:hover { background: var(--color-btn-hover); }
|
||||
|
||||
.body { display: flex; gap: 0; max-height: min(30rem, 70vh); }
|
||||
|
||||
.list {
|
||||
width: 14rem;
|
||||
margin: 0;
|
||||
padding: 0.4rem 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.entry:hover { background: var(--color-btn-hover); }
|
||||
.entry.active { background: var(--color-primary); color: white; }
|
||||
.badge {
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.preview {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-page-bg, #fff);
|
||||
color: var(--color-page-text);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.row { display: flex; gap: 0.6rem; }
|
||||
.field { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.field-label { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.align-row { display: flex; gap: 2px; }
|
||||
.align-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--toolbar-btn-size);
|
||||
height: var(--toolbar-btn-size);
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.align-btn:hover { background: var(--color-btn-hover); }
|
||||
.align-btn.active { background: var(--color-primary); color: white; }
|
||||
/* What the parent chain provides while this style sets none of its own. */
|
||||
.align-btn.inherit { border-color: var(--color-border); color: var(--color-text-muted); }
|
||||
.row > label { flex: 1; }
|
||||
.toggles { align-items: center; gap: 1rem; }
|
||||
.check {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
input, select {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.35rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
input[type='color'] { padding: 0.1rem; height: 1.9rem; }
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
footer button {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
footer button:hover:not(:disabled) { border-color: var(--color-primary); }
|
||||
footer button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.danger:hover:not(:disabled) { border-color: #c0392b; color: #c0392b; }
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@
|
||||
import { blockStyleName } from '../editor/extensions/paragraphStyle';
|
||||
import { DEFAULT_STYLE, resolveStyle, styleOrder } from '../styles/styleSheet';
|
||||
import { styleSheet } from '../styles/sheet.svelte';
|
||||
import StyleManagerDialog from './StyleManagerDialog.svelte';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import { withShortcut } from '../i18n/shortcut';
|
||||
|
||||
@@ -42,6 +43,7 @@
|
||||
}
|
||||
|
||||
let stylesOpen = $state(false);
|
||||
let managerOpen = $state(false);
|
||||
|
||||
// $derived re-evaluates whenever `tick` changes (i.e. on every TipTap transaction)
|
||||
let isHeading = $derived(tick >= 0 && !!editor?.isActive('heading'));
|
||||
@@ -209,6 +211,9 @@
|
||||
{styleLabel(s.name)}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="ol-option manage" onclick={() => { stylesOpen = false; managerOpen = true; }}>
|
||||
{t().styles.manage}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -341,6 +346,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<StyleManagerDialog bind:open={managerOpen} {editor} />
|
||||
|
||||
<style>
|
||||
/* Sits inside the header island (which paints the frosted background). Not a
|
||||
stacking context: its dropdowns (z:200) must join the header's context so they
|
||||
@@ -430,6 +437,14 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.manage {
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Ordered-list split button: main toggle + chevron that opens the numbering
|
||||
style menu, rendered as one joined control. */
|
||||
.ol-picker {
|
||||
|
||||
@@ -129,10 +129,12 @@ export const extensions = [
|
||||
TrailingNode,
|
||||
History,
|
||||
Placeholder.configure({ placeholder: 'Start typing…' }),
|
||||
// No defaultAlignment: it would put textAlign:'left' on every block, and that inline
|
||||
// style beats the paragraph style's alignment. Unset = follow the style; an explicit
|
||||
// 'left' stays direct formatting that overrides it, as in Word/LibreOffice.
|
||||
TextAlign.configure({
|
||||
types: ['paragraph', 'heading'],
|
||||
alignments: ['left', 'center', 'right', 'justify'],
|
||||
defaultAlignment: 'left',
|
||||
}),
|
||||
PageBreaks,
|
||||
FormattingMarks,
|
||||
|
||||
@@ -22,7 +22,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
|
||||
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { defaultBulletChar } from '../utils/bulletListTypes';
|
||||
import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, type HfExport } from './odt';
|
||||
import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt';
|
||||
import { builtinStyleSheet, DEFAULT_STYLE, type Style, type StyleSheet } from '../styles/styleSheet';
|
||||
import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
|
||||
|
||||
@@ -862,8 +862,8 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions {
|
||||
const p = style.para;
|
||||
const t = style.text;
|
||||
const run: Writable<IRunStylePropertiesOptions> = {};
|
||||
// Liberation Serif is the on-screen name; the file declares its metric twin.
|
||||
if (t.fontFamily) run.font = t.fontFamily === 'Liberation Serif' ? DOC_FONT : t.fontFamily;
|
||||
// The registry holds the on-screen family; the file declares its metric twin.
|
||||
if (t.fontFamily) run.font = twinFontName(t.fontFamily);
|
||||
if (t.fontSizePt != null) run.size = Math.round(t.fontSizePt * 2);
|
||||
if (t.bold != null) run.bold = t.bold;
|
||||
if (t.italic != null) run.italics = t.italic;
|
||||
|
||||
+10
-2
@@ -148,6 +148,14 @@ export const HEADING_FONT = 'Arial';
|
||||
export const MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length;
|
||||
export const HEADING_LEVELS = HEADING_STYLE_OVERRIDES.map((_, i) => i + 1);
|
||||
|
||||
// The bundled screen font → the metric-identical name declared in files, so
|
||||
// LibreOffice and Word render with the editor's metrics.
|
||||
export function twinFontName(family: string): string {
|
||||
if (family === ODFKIT_DEFAULT_FONT) return EXPORT_FONT;
|
||||
if (family === 'Liberation Sans') return HEADING_FONT;
|
||||
return family;
|
||||
}
|
||||
|
||||
// ODF encodes spaces in style names as _20_ ("Heading 1" → "Heading_20_1").
|
||||
export function odfStyleName(name: string): string {
|
||||
return name.replace(/ /g, '_20_');
|
||||
@@ -1000,8 +1008,8 @@ function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<stri
|
||||
}
|
||||
const text: Record<string, string> = {};
|
||||
if (t.fontFamily) {
|
||||
// Liberation Serif is the on-screen name; the file declares its metric twin.
|
||||
const font = t.fontFamily === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : String(t.fontFamily);
|
||||
// The registry holds the on-screen family; the file declares its metric twin.
|
||||
const font = twinFontName(String(t.fontFamily));
|
||||
text['style:font-name'] = font;
|
||||
text['style:font-name-asian'] = font;
|
||||
text['style:font-name-complex'] = font;
|
||||
|
||||
@@ -68,6 +68,31 @@ const de: Messages = {
|
||||
'>': 'Winkel',
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
title: 'Absatzvorlagen',
|
||||
manage: 'Vorlagen verwalten…',
|
||||
custom: 'eigen',
|
||||
inherited: 'geerbt',
|
||||
abstract: 'nur Elternstil',
|
||||
parent: 'Erbt von',
|
||||
font: 'Schriftart',
|
||||
size: 'Größe (pt)',
|
||||
color: 'Farbe',
|
||||
bold: 'Fett',
|
||||
italic: 'Kursiv',
|
||||
underline: 'Unterstrichen',
|
||||
strike: 'Durchgestrichen',
|
||||
alignment: 'Ausrichtung',
|
||||
spaceBefore: 'Abstand oben (pt)',
|
||||
spaceAfter: 'Abstand unten (pt)',
|
||||
indent: 'Einzug (cm)',
|
||||
newFromSelection: 'Neu aus Auswahl',
|
||||
updateFromSelection: 'Aus Auswahl aktualisieren',
|
||||
rename: 'Umbenennen…',
|
||||
reset: 'Auf Standard zurücksetzen',
|
||||
renamePrompt: 'Neuer Name der Vorlage:',
|
||||
newName: 'Vorlage',
|
||||
},
|
||||
history: {
|
||||
undo: 'Rückgängig',
|
||||
redo: 'Wiederholen',
|
||||
|
||||
@@ -72,6 +72,31 @@ const en = {
|
||||
'>': 'Chevron',
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
title: 'Paragraph styles',
|
||||
manage: 'Manage styles…',
|
||||
custom: 'own',
|
||||
inherited: 'inherited',
|
||||
abstract: 'parent only',
|
||||
parent: 'Inherits from',
|
||||
font: 'Font',
|
||||
size: 'Size (pt)',
|
||||
color: 'Color',
|
||||
bold: 'Bold',
|
||||
italic: 'Italic',
|
||||
underline: 'Underline',
|
||||
strike: 'Strikethrough',
|
||||
alignment: 'Alignment',
|
||||
spaceBefore: 'Space above (pt)',
|
||||
spaceAfter: 'Space below (pt)',
|
||||
indent: 'Indent (cm)',
|
||||
newFromSelection: 'New from selection',
|
||||
updateFromSelection: 'Update from selection',
|
||||
rename: 'Rename…',
|
||||
reset: 'Reset to default',
|
||||
renamePrompt: 'New style name:',
|
||||
newName: 'Style',
|
||||
},
|
||||
history: {
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
|
||||
@@ -588,7 +588,8 @@ function styleText(ctx: Ctx, id: string | null): TextProps {
|
||||
const run = ctx.styles.paragraphRun(id);
|
||||
const out: TextProps = {};
|
||||
// Our own export declares the metric twin; keep the registry on the on-screen name.
|
||||
if (run.font) out.fontFamily = run.font === 'Times New Roman' ? 'Liberation Serif' : run.font;
|
||||
if (run.font) out.fontFamily = run.font === 'Times New Roman' ? 'Liberation Serif'
|
||||
: run.font === 'Arial' ? 'Liberation Sans' : run.font;
|
||||
if (run.sizeHalfPt != null) out.fontSizePt = Math.round((run.sizeHalfPt / 2) * 10) / 10;
|
||||
if (run.bold != null) out.bold = run.bold;
|
||||
if (run.italic != null) out.italic = run.italic;
|
||||
|
||||
@@ -693,6 +693,14 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
|
||||
};
|
||||
}
|
||||
|
||||
// The inverse of export/odt.ts twinFontName: a declared metric twin reads back as the
|
||||
// bundled family the editor renders.
|
||||
function screenFontName(family: string): string {
|
||||
if (family === 'Times New Roman') return 'Liberation Serif';
|
||||
if (family === 'Arial') return 'Liberation Sans';
|
||||
return family;
|
||||
}
|
||||
|
||||
// ODF encodes spaces as _20_; a style:display-name (when present) is authoritative.
|
||||
function displayStyleName(odfName: string, display?: string): string {
|
||||
return display || odfName.replace(/_20_/g, ' ');
|
||||
@@ -729,7 +737,7 @@ function textPropsFromOdf(props: PropMap, resolver: StyleResolver): TextProps {
|
||||
const family = resolver.fontFamilyOf(props);
|
||||
// Our own export declares the metric twin; keep the registry on the on-screen name
|
||||
// so an export→import loop doesn't drift.
|
||||
if (family) out.fontFamily = family === 'Times New Roman' ? 'Liberation Serif' : family;
|
||||
if (family) out.fontFamily = screenFontName(family);
|
||||
const size = lengthToPt(props['fo:font-size']);
|
||||
if (size != null) out.fontSizePt = Math.round(size * 10) / 10;
|
||||
const weight = props['fo:font-weight'];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// components read styleSheet() in a template or $derived and re-render when it is
|
||||
// replaced. Persisted to localStorage like the other document side-cars.
|
||||
|
||||
import { builtinStyleSheet, type Style, type StyleSheet } from './styleSheet';
|
||||
import { builtinStyleSheet, DEFAULT_STYLE, mergeStoredSheet, STYLE_SHEET_VERSION, type Style, type StyleSheet } from './styleSheet';
|
||||
|
||||
const STORAGE_KEY = 'odf-editor-styles';
|
||||
|
||||
@@ -10,10 +10,7 @@ function load(): StyleSheet {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return builtinStyleSheet();
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StyleSheet;
|
||||
if (!parsed?.paragraph || typeof parsed.paragraph !== 'object') return builtinStyleSheet();
|
||||
// Built-ins are re-seeded so a new release's additions show up; stored entries win.
|
||||
return { paragraph: { ...builtinStyleSheet().paragraph, ...parsed.paragraph } };
|
||||
return mergeStoredSheet(JSON.parse(raw));
|
||||
} catch {
|
||||
return builtinStyleSheet();
|
||||
}
|
||||
@@ -27,10 +24,46 @@ export function styleSheet(): StyleSheet {
|
||||
|
||||
export function setStyleSheet(next: StyleSheet): void {
|
||||
current = next;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: STYLE_SHEET_VERSION, ...next }));
|
||||
}
|
||||
|
||||
// Replace one style (add or edit) — every block using it re-renders via styleCss.
|
||||
export function putStyle(style: Style): void {
|
||||
setStyleSheet({ paragraph: { ...current.paragraph, [style.name]: style } });
|
||||
}
|
||||
|
||||
// Rename a style and re-point everything that referenced it (children, next-styles).
|
||||
// Blocks keep their own `styleName`; the caller retags them.
|
||||
export function renameStyle(from: string, to: string): void {
|
||||
const style = current.paragraph[from];
|
||||
if (!style || from === to || current.paragraph[to]) return;
|
||||
const paragraph: Record<string, Style> = {};
|
||||
for (const [name, s] of Object.entries(current.paragraph)) {
|
||||
if (name === from) continue;
|
||||
paragraph[name] = { ...s, parent: s.parent === from ? to : s.parent, next: s.next === from ? to : s.next };
|
||||
}
|
||||
paragraph[to] = { ...style, name: to, builtin: undefined };
|
||||
setStyleSheet({ paragraph });
|
||||
}
|
||||
|
||||
// Delete a style; its children re-parent to its own parent, as in LibreOffice.
|
||||
export function deleteStyle(name: string): void {
|
||||
const style = current.paragraph[name];
|
||||
if (!style || style.builtin) return;
|
||||
const paragraph: Record<string, Style> = {};
|
||||
for (const [key, s] of Object.entries(current.paragraph)) {
|
||||
if (key === name) continue;
|
||||
paragraph[key] = {
|
||||
...s,
|
||||
parent: s.parent === name ? style.parent : s.parent,
|
||||
next: s.next === name ? style.next ?? DEFAULT_STYLE : s.next,
|
||||
};
|
||||
}
|
||||
setStyleSheet({ paragraph });
|
||||
}
|
||||
|
||||
// Restore a built-in to its factory definition.
|
||||
export function resetStyle(name: string): void {
|
||||
const factory = builtinStyleSheet().paragraph[name];
|
||||
if (factory) putStyle(factory);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const BUILTINS: Style[] = [
|
||||
{ name: DEFAULT_STYLE, parent: null, next: null, builtin: true,
|
||||
para: { spaceBefore: 0, spaceAfter: 0 }, text: { fontFamily: 'Liberation Serif', fontSizePt: 12 } },
|
||||
{ name: HEADING_PARENT, parent: DEFAULT_STYLE, next: DEFAULT_STYLE, builtin: true,
|
||||
para: { spaceBefore: 12, spaceAfter: 6 }, text: { fontFamily: 'Arial', bold: true } },
|
||||
para: { spaceBefore: 12, spaceAfter: 6 }, text: { fontFamily: 'Liberation Sans', bold: true } },
|
||||
...[18, 16, 14, 13, 12].map((size, i) => ({
|
||||
name: `Heading ${i + 1}`, parent: HEADING_PARENT, next: DEFAULT_STYLE,
|
||||
outlineLevel: i + 1, builtin: true,
|
||||
@@ -65,20 +65,47 @@ const BUILTINS: Style[] = [
|
||||
para: { indent: 1, spaceAfter: 14 }, text: {} },
|
||||
];
|
||||
|
||||
// Bumped whenever the built-in definitions change: a stored sheet from an older version
|
||||
// keeps its user styles but takes the new factory built-ins (see mergeStoredSheet).
|
||||
export const STYLE_SHEET_VERSION = 2;
|
||||
|
||||
// A persisted sheet merged onto the current built-ins. Same version: stored entries win
|
||||
// (a document's own styles, and edits to built-ins). Older: only user styles survive.
|
||||
export function mergeStoredSheet(stored: unknown): StyleSheet {
|
||||
const sheet = builtinStyleSheet();
|
||||
const data = stored as { v?: number; paragraph?: Record<string, Style> } | null;
|
||||
if (!data?.paragraph || typeof data.paragraph !== 'object') return sheet;
|
||||
const current = data.v === STYLE_SHEET_VERSION;
|
||||
for (const [name, style] of Object.entries(data.paragraph)) {
|
||||
if (!style || typeof style !== 'object') continue;
|
||||
if (!current && style.builtin) continue;
|
||||
sheet.paragraph[name] = style;
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export function builtinStyleSheet(): StyleSheet {
|
||||
const paragraph: Record<string, Style> = {};
|
||||
for (const s of BUILTINS) paragraph[s.name] = structuredClone(s);
|
||||
return { paragraph };
|
||||
}
|
||||
|
||||
// The gallery/manager order: built-ins as listed above, user styles after them.
|
||||
export function styleOrder(sheet: StyleSheet): Style[] {
|
||||
// The gallery order: built-ins as listed above, user styles after them. `Heading` is an
|
||||
// abstract parent (LibreOffice's, holding what all levels share) — never assignable, so
|
||||
// the gallery hides it; the style manager passes withAbstract to edit it.
|
||||
export function styleOrder(sheet: StyleSheet, withAbstract = false): Style[] {
|
||||
const names = Object.keys(sheet.paragraph);
|
||||
const builtinOrder = BUILTINS.map(b => b.name).filter(n => names.includes(n) && n !== HEADING_PARENT);
|
||||
const rest = names.filter(n => !builtinOrder.includes(n) && n !== HEADING_PARENT).sort();
|
||||
const hidden = (n: string) => !withAbstract && n === HEADING_PARENT;
|
||||
const builtinOrder = BUILTINS.map(b => b.name).filter(n => names.includes(n) && !hidden(n));
|
||||
const rest = names.filter(n => !builtinOrder.includes(n) && !hidden(n)).sort();
|
||||
return [...builtinOrder, ...rest].map(n => sheet.paragraph[n]);
|
||||
}
|
||||
|
||||
// Styles that exist only to be inherited from.
|
||||
export function isAbstractStyle(name: string): boolean {
|
||||
return name === HEADING_PARENT;
|
||||
}
|
||||
|
||||
export function headingStyleName(level: number): string {
|
||||
return `Heading ${level}`;
|
||||
}
|
||||
@@ -99,9 +126,16 @@ export function resolveStyle(sheet: StyleSheet, name: string | null | undefined)
|
||||
cur = style.parent ?? '';
|
||||
}
|
||||
const out: ResolvedStyle = { para: {}, text: {} };
|
||||
// Only defined values layer: a key present as undefined means "not set here", so it
|
||||
// must not wipe what the parent provides.
|
||||
const layer = <T extends object>(target: T, source: T) => {
|
||||
for (const key of Object.keys(source) as (keyof T)[]) {
|
||||
if (source[key] !== undefined) target[key] = source[key];
|
||||
}
|
||||
};
|
||||
for (const style of chain.reverse()) {
|
||||
Object.assign(out.para, style.para);
|
||||
Object.assign(out.text, style.text);
|
||||
layer(out.para, style.para);
|
||||
layer(out.text, style.text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -109,7 +143,7 @@ export function resolveStyle(sheet: StyleSheet, name: string | null | undefined)
|
||||
// The bundled fonts are exposed as CSS variables; anything else renders by name.
|
||||
function cssFontFamily(name: string): string {
|
||||
if (name === 'Liberation Serif') return 'var(--font-serif)';
|
||||
if (name === 'Arial') return 'var(--font-heading)';
|
||||
if (name === 'Liberation Sans' || name === 'Arial') return 'var(--font-heading)';
|
||||
return `'${name.replace(/'/g, "\\'")}', var(--font-serif)`;
|
||||
}
|
||||
|
||||
@@ -153,3 +187,67 @@ export function styleCss(sheet: StyleSheet): string {
|
||||
}
|
||||
return rules.join('\n\n');
|
||||
}
|
||||
|
||||
// ---- block ⇄ style ------------------------------------------------------------------
|
||||
|
||||
type BlockNode = { type?: { name: string }; attrs?: Record<string, unknown> };
|
||||
type BlockMark = { type: { name: string } | string; attrs?: Record<string, unknown> };
|
||||
|
||||
// The formatting a block currently shows, read off its attrs and the marks of its text —
|
||||
// the raw material for "new style from selection".
|
||||
export function propsFromBlock(node: BlockNode, marks: BlockMark[] = []): ResolvedStyle {
|
||||
const a = node.attrs ?? {};
|
||||
const para: ParaProps = {};
|
||||
// Every block carries textAlign:'left' (the TextAlign extension's default), so only a
|
||||
// real choice counts as the block's own alignment.
|
||||
const ta = a.textAlign;
|
||||
if (ta === 'center' || ta === 'right' || ta === 'justify') para.textAlign = ta;
|
||||
if (typeof a.lineHeight === 'string' && a.lineHeight) para.lineHeight = a.lineHeight;
|
||||
if (typeof a.spaceBefore === 'number') para.spaceBefore = a.spaceBefore;
|
||||
if (typeof a.spaceAfter === 'number') para.spaceAfter = a.spaceAfter;
|
||||
if (typeof a.indent === 'number' && a.indent > 0) para.indent = a.indent;
|
||||
if (typeof a.backgroundColor === 'string' && a.backgroundColor) para.backgroundColor = a.backgroundColor;
|
||||
for (const side of ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'] as const) {
|
||||
const v = a[side];
|
||||
if (typeof v === 'string' && v && v !== 'none') para[side] = v;
|
||||
}
|
||||
|
||||
const text: TextProps = {};
|
||||
// The paragraph-mark size sizes the block itself; a run's own size wins below.
|
||||
if (typeof a.fontSize === 'string' && a.fontSize) text.fontSizePt = parseFloat(a.fontSize);
|
||||
const nameOf = (m: BlockMark) => (typeof m.type === 'string' ? m.type : m.type.name);
|
||||
for (const mark of marks) {
|
||||
const name = nameOf(mark);
|
||||
if (name === 'bold') text.bold = true;
|
||||
else if (name === 'italic') text.italic = true;
|
||||
else if (name === 'underline') text.underline = true;
|
||||
else if (name === 'strike') text.strike = true;
|
||||
else if (name === 'textStyle') {
|
||||
const attrs = mark.attrs ?? {};
|
||||
if (typeof attrs.fontFamily === 'string' && attrs.fontFamily) text.fontFamily = attrs.fontFamily;
|
||||
if (typeof attrs.fontSize === 'string' && attrs.fontSize) text.fontSizePt = parseFloat(attrs.fontSize);
|
||||
if (typeof attrs.color === 'string' && attrs.color) text.color = attrs.color;
|
||||
if (attrs.fontWeight === 'normal') text.bold = false;
|
||||
}
|
||||
}
|
||||
return { para, text };
|
||||
}
|
||||
|
||||
// What `shown` declares beyond `base` — a style's own properties (LibreOffice's
|
||||
// "new style from selection" stores exactly this against the parent).
|
||||
export function styleDelta(shown: ResolvedStyle, base: ResolvedStyle): ResolvedStyle {
|
||||
const pick = <T extends object>(a: T, b: T): T => {
|
||||
const out = {} as T;
|
||||
for (const key of Object.keys(a) as (keyof T)[]) {
|
||||
if (a[key] !== undefined && a[key] !== b[key]) out[key] = a[key];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return { para: pick(shown.para, base.para), text: pick(shown.text, base.text) };
|
||||
}
|
||||
|
||||
// A name that isn't taken yet ("Style", "Style 2", …).
|
||||
export function uniqueStyleName(sheet: StyleSheet, base: string): string {
|
||||
if (!sheet.paragraph[base]) return base;
|
||||
for (let i = 2; ; i++) if (!sheet.paragraph[`${base} ${i}`]) return `${base} ${i}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
builtinStyleSheet, resolveStyle, styleCss, styleOrder, DEFAULT_STYLE, type StyleSheet,
|
||||
builtinStyleSheet, mergeStoredSheet, resolveStyle, styleCss, styleOrder, DEFAULT_STYLE,
|
||||
STYLE_SHEET_VERSION, type StyleSheet,
|
||||
} from '../../src/lib/styles/styleSheet';
|
||||
import { HEADING_STYLE_OVERRIDES } from '../../src/lib/export/odt';
|
||||
|
||||
@@ -12,13 +13,22 @@ describe('named paragraph styles', () => {
|
||||
const h1 = resolveStyle(sheet, 'Heading 1');
|
||||
expect(h1.text.fontSizePt).toBe(18); // own
|
||||
expect(h1.text.bold).toBe(true); // from Heading
|
||||
expect(h1.text.fontFamily).toBe('Arial'); // from Heading, shadowing Standard's serif
|
||||
expect(h1.text.fontFamily).toBe('Liberation Sans'); // from Heading, shadowing Standard's serif
|
||||
expect(h1.para.spaceBefore).toBe(12); // from Heading
|
||||
const standard = resolveStyle(sheet, DEFAULT_STYLE);
|
||||
expect(standard.text.fontFamily).toBe('Liberation Serif');
|
||||
expect(standard.para.spaceBefore).toBe(0);
|
||||
});
|
||||
|
||||
it('treats an undefined own value as "not set" (the parent still applies)', () => {
|
||||
const sheet = builtinStyleSheet();
|
||||
// What the style manager stores when a field is cleared.
|
||||
sheet.paragraph['Heading 2'] = { ...sheet.paragraph['Heading 2'], text: { fontSizePt: undefined } };
|
||||
const h2 = resolveStyle(sheet, 'Heading 2');
|
||||
expect(h2.text.fontFamily).toBe('Liberation Sans'); // still inherited from Heading
|
||||
expect(h2.text.fontSizePt).toBe(12); // falls back to Standard's size
|
||||
});
|
||||
|
||||
it('falls back to Standard for unknown names and survives a parent cycle', () => {
|
||||
const sheet = builtinStyleSheet();
|
||||
expect(resolveStyle(sheet, 'Nope').text.fontSizePt).toBe(12);
|
||||
@@ -58,3 +68,31 @@ describe('named paragraph styles', () => {
|
||||
expect(names).toContain('Quotations');
|
||||
});
|
||||
});
|
||||
|
||||
// The sheet lives in localStorage, so a released change to the built-ins has to reach
|
||||
// installations that already stored the old ones.
|
||||
describe('persisted style sheet', () => {
|
||||
const own = { name: 'Merksatz', parent: 'Standard', next: null, para: {}, text: { bold: true } };
|
||||
|
||||
it('re-seeds built-ins from an older version but keeps user styles', () => {
|
||||
const stale = { v: 1, paragraph: {
|
||||
Heading: { name: 'Heading', parent: 'Standard', next: 'Standard', builtin: true, para: {}, text: { fontFamily: 'Arial' } },
|
||||
Merksatz: own,
|
||||
} };
|
||||
const sheet = mergeStoredSheet(stale);
|
||||
expect(sheet.paragraph['Heading'].text.fontFamily).toBe('Liberation Sans'); // factory again
|
||||
expect(sheet.paragraph['Merksatz']).toEqual(own); // user style kept
|
||||
});
|
||||
|
||||
it('keeps stored built-ins of the current version (edits, imported documents)', () => {
|
||||
const edited = { v: STYLE_SHEET_VERSION, paragraph: {
|
||||
Heading: { name: 'Heading', parent: 'Standard', next: 'Standard', builtin: true, para: {}, text: { fontFamily: 'Georgia' } },
|
||||
} };
|
||||
expect(mergeStoredSheet(edited).paragraph['Heading'].text.fontFamily).toBe('Georgia');
|
||||
});
|
||||
|
||||
it('falls back to the built-ins for junk', () => {
|
||||
expect(mergeStoredSheet(null).paragraph['Standard'].text.fontSizePt).toBe(12);
|
||||
expect(mergeStoredSheet({ v: 2 }).paragraph['Heading 1'].text.fontSizePt).toBe(18);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user