Files
edentext/src/lib/components/StyleManagerDialog.svelte
T

875 lines
34 KiB
Svelte

<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, type StyleFamily,
} from '../styles/styleSheet';
import {
deleteCharacterStyle, deleteStyle, deleteTableStyle, putStyle, putTableStyle,
renameStyle, resetStyle, styleSheet,
} from '../styles/sheet.svelte';
import {
TABLE_REGIONS, previewCellCss, previewTextCss,
type TableLook, type TableRegion, type TableRegionProps, type TableStyle,
} from '../styles/tableStyles';
import { borderAttrValue, parseBorderAttr } from '../editor/extensions/tableCellBorders';
import { blockStyleName } from '../editor/extensions/paragraphStyle';
import { activeCharacterStyle } from '../editor/extensions/characterStyle';
import { activeTableStyle } from '../editor/extensions/tableStyle';
import { CANDIDATE_FONTS, detectAvailableFonts } from '../utils/fontDetect';
import AlignIcon, { type AlignValue } from './AlignIcon.svelte';
import ColorPicker from './ColorPicker.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, family: openFamily = 'paragraph' }:
{ open?: boolean; editor: Editor | null; family?: StyleFamily } = $props();
const ALIGNMENTS: AlignValue[] = ['left', 'center', 'right', 'justify'];
// Beyond this the indent would push the name out of the 14rem pane, so deeper
// chains keep stacking at the last level.
const MAX_INDENT_DEPTH = 6;
const indentRem = (depth: number) => 0.6 + Math.min(depth, MAX_INDENT_DEPTH) * 0.8;
let dialogEl: HTMLDialogElement | null = $state(null);
// The tab to land on; the $effect below takes it from the caller on every open, so
// each entry point (styles gallery vs. table menu) lands where it should.
let family = $state<StyleFamily>('paragraph');
let selected = $state(DEFAULT_STYLE);
let selectedChar = $state('');
let selectedTable = $state('');
// Which conditional area of a table style is being edited.
let region = $state<TableRegion | 'wholeTable'>('wholeTable');
// 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());
let isChar = $derived(family === 'character');
let isTable = $derived(family === 'table');
let styles = $derived(isChar ? sheet.character : sheet.paragraph);
// Indented by inheritance depth, so the chain is visible.
let rows = $derived(styleOrder(sheet, true, family).map((s) => ({ style: s, depth: depthOf(s) })));
let current = $derived(isTable ? selectedTable : isChar ? selectedChar : selected);
let style = $derived(styles[current] ?? Object.values(styles)[0]);
let resolved = $derived(resolveStyle(sheet, style?.name, family));
// A style can't inherit from itself or from one of its own descendants.
let parentOptions = $derived(
Object.keys(styles).filter((name) => name !== style?.name && !descendsFrom(name, style?.name ?? '')),
);
function select(name: string) {
if (isTable) selectedTable = name;
else if (isChar) selectedChar = name;
else selected = name;
}
// ---- table styles: their own shape (no inheritance), so their own small set of state.
let tableList = $derived(Object.values(sheet.table ?? {}));
let tStyle = $derived(sheet.table?.[selectedTable] ?? tableList[0]);
let regionProps = $derived<TableRegionProps>(
(region === 'wholeTable' ? tStyle?.wholeTable : tStyle?.regions[region]) ?? {},
);
function editTable(patch: Partial<TableStyle>) {
if (tStyle) putTableStyle({ ...tStyle, ...patch });
}
function editRegion(patch: TableRegionProps) {
const next = { ...regionProps, ...patch };
for (const key of Object.keys(patch) as (keyof TableRegionProps)[]) {
if (patch[key] === undefined) delete next[key];
}
if (region === 'wholeTable') editTable({ wholeTable: next });
else editTable({ regions: { ...tStyle.regions, [region]: next } });
}
function newTableStyle() {
const name = uniqueStyleName(sheet, t().styles.newName, 'table');
putTableStyle({ name, border: null, innerBorder: null, regions: {} });
selectedTable = name;
}
const BORDER_WIDTHS = ['none', '0.5', '0.75', '1', '1.5', '2.25'];
// The three border controls; the inner ones fall back to the shared innerBorder.
type BorderKey = 'border' | 'innerBorderH' | 'innerBorderV';
const BORDER_FIELDS: { key: BorderKey; label: () => string }[] = [
{ key: 'border', label: () => t().styles.outerBorder },
{ key: 'innerBorderH', label: () => t().styles.rowLines },
{ key: 'innerBorderV', label: () => t().styles.columnLines },
];
const borderOf = (key: BorderKey): string | null | undefined =>
tStyle?.[key] !== undefined ? tStyle[key] : key === 'border' ? undefined : tStyle?.innerBorder;
const PREVIEW_ROWS = 5;
const PREVIEW_COLS = 4;
// The manager edits the definition, not one table's options, so it shows every area.
const ALL_AREAS = Object.fromEntries(TABLE_REGIONS.map(r => [r, true])) as TableLook;
// The border select and its color picker split one canonical '<W>pt solid #RRGGBB'.
function borderParts(value: string | null | undefined) {
const b = parseBorderAttr(value ?? null);
if (b === 'none') return { width: 'none', color: '#000000' };
return b ? { width: String(b.widthPt), color: b.color } : { width: '0.5', color: '#000000' };
}
const borderValue = (width: string, color: string) =>
width === 'none' ? 'none' : borderAttrValue({ widthPt: parseFloat(width), color });
function depthOf(s: Style): number {
let depth = 0;
let cur = s.parent;
const seen = new Set<string>();
while (cur && styles[cur] && !seen.has(cur)) { seen.add(cur); depth++; cur = styles[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 = styles[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);
});
$effect(() => {
if (open) family = openFamily;
});
// Follow the cursor's style when the dialog opens (LibreOffice preselects it too).
$effect(() => {
if (!open || !editor) return;
selected = blockStyleName(editor.state.selection.$from.parent as never);
selectedChar = activeCharacterStyle(editor.state as never) ?? Object.keys(sheet.character)[0] ?? '';
selectedTable = activeTableStyle(editor.state as never) ?? Object.keys(sheet.table ?? {})[0] ?? '';
});
// 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 ?? {}) }, family);
}
// The text fields are shared by all three families; a table style edits the selected
// region instead of the style itself (and has no parent chain, so own = resolved).
let ownText = $derived(isTable ? regionProps.text ?? {} : style.text);
let resolvedText = $derived(isTable ? regionProps.text ?? {} : resolved.text);
function editText(patch: Partial<Style['text']>) {
if (!isTable) return edit({ text: patch });
const text = { ...(regionProps.text ?? {}), ...patch };
for (const key of Object.keys(patch) as (keyof Style['text'])[]) {
if (patch[key] === undefined) delete text[key];
}
editRegion({ 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(
ownText.fontFamily && !fonts.includes(ownText.fontFamily)
? [ownText.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),
};
}
// The marks of the selection's first run — the raw material for a character style.
function runFormatting() {
if (!editor) return null;
const { from, to, empty } = editor.state.selection;
const marks = empty
? editor.state.storedMarks ?? editor.state.selection.$head.marks()
: (editor.state.doc.nodeAt(from) ?? editor.state.doc.nodeAt(Math.max(0, to - 1)))?.marks ?? [];
return propsFromBlock({ attrs: {} }, marks as never).text;
}
// Drop the direct marks the new style now carries, then apply it — as in LibreOffice.
function applyCharStyle(name: string, text: ReturnType<typeof runFormatting>) {
if (!editor || !text) return;
const chain = editor.chain().focus();
if (text.bold) chain.unsetBold();
if (text.italic) chain.unsetItalic();
if (text.underline) chain.unsetUnderline();
if (text.strike) chain.unsetStrike();
if (text.fontFamily) chain.unsetFontFamily();
if (text.fontSizePt != null) chain.unsetFontSize();
if (text.color) chain.unsetColor();
chain.removeEmptyTextStyle().setCharacterStyle(name).run();
}
function newFromSelection() {
if (!editor) return;
if (isChar) {
const text = runFormatting();
if (!text || !Object.keys(text).length) return;
const name = uniqueStyleName(sheet, t().styles.newName, 'character');
putStyle({ name, parent: null, next: null, para: {}, text }, 'character');
applyCharStyle(name, text);
selectedChar = name;
return;
}
const info = blockFormatting();
if (!info) 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() {
if (!editor || !style) return;
if (isChar) {
const text = runFormatting();
if (!text) return;
putStyle({ ...style, text: { ...style.text, ...text } }, 'character');
applyCharStyle(style.name, text);
return;
}
const info = blockFormatting();
if (!info) 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();
}
// A character style with nothing selected yet: start from the built-in defaults.
function newEmptyStyle() {
const name = uniqueStyleName(sheet, t().styles.newName, 'character');
putStyle({ name, parent: null, next: null, para: {}, text: {} }, 'character');
selectedChar = name;
}
// Renaming happens in place in the list — via double-click or the Rename button.
let editingName = $state<string | null>(null);
function commitRename(from: string, next: string) {
editingName = null;
next = next.trim();
if (!next || next === from || (isTable ? sheet.table[next] : styles[next])) return;
renameStyle(from, next, family);
// Retag every block that referenced the old name.
retag(from, next);
select(next);
}
function focusSelect(el: HTMLInputElement) {
el.focus();
el.select();
}
// Re-point every block (or run) that referenced `from`; null drops the reference.
function retag(from: string, to: string | null) {
editor?.commands.command(({ tr, state, dispatch }) => {
state.doc.descendants((node, pos) => {
if (isChar) {
const mark = node.marks.find((m) => m.type.name === 'charStyle' && m.attrs.name === from);
if (!mark) return;
tr.removeMark(pos, pos + node.nodeSize, mark);
if (to) tr.addMark(pos, pos + node.nodeSize, mark.type.create({ name: to }));
} else if (isTable) {
if (node.attrs?.tableStyle === from) tr.setNodeAttribute(pos, 'tableStyle', to);
} else if (node.attrs?.styleName === from && to) {
tr.setNodeAttribute(pos, 'styleName', to);
}
});
if (dispatch && tr.docChanged) dispatch(tr);
return true;
});
}
function remove() {
const gone = isTable ? tStyle.name : style.name;
if (isTable) {
deleteTableStyle(gone);
// The link goes, the painted cells stay — as when a LibreOffice AutoFormat is gone.
retag(gone, null);
selectedTable = Object.keys(sheet.table)[0] ?? '';
return;
}
if (isChar) {
deleteCharacterStyle(gone);
retag(gone, null);
selectedChar = Object.keys(sheet.character)[0] ?? '';
return;
}
const fallback = style.parent ?? DEFAULT_STYLE;
deleteStyle(gone);
retag(gone, fallback);
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">
<div class="pane">
<div class="family">
<button class:active={family === 'paragraph'} onclick={() => (family = 'paragraph')}>{t().styles.tabs.paragraph}</button>
<button class:active={isChar} onclick={() => (family = 'character')}>{t().styles.tabs.character}</button>
<button class:active={isTable} onclick={() => (family = 'table')}>{t().styles.tabs.table}</button>
</div>
<ul class="list">
{#if isTable}
{#each tableList as s (s.name)}
<li>
{#if editingName === s.name}
<input
class="entry rename"
style="padding-left: {indentRem(0)}rem"
value={s.name}
use:focusSelect
onblur={(e) => editingName === s.name && commitRename(s.name, e.currentTarget.value)}
onkeydown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
else if (e.key === 'Escape') { e.preventDefault(); editingName = null; }
}}
/>
{:else}
<button
class="entry"
class:active={current === s.name}
style="padding-left: {indentRem(0)}rem"
title={s.name}
onclick={() => select(s.name)}
ondblclick={() => { if (!s.builtin) editingName = s.name; }}
>
<span class="name">{s.name}</span>
{#if !s.builtin}<span class="badge">{t().styles.custom}</span>{/if}
</button>
{/if}
</li>
{/each}
{/if}
{#each rows as { style: s, depth } (s.name)}
<li>
{#if editingName === s.name}
<input
class="entry rename"
style="padding-left: {indentRem(depth)}rem"
value={s.name}
use:focusSelect
onblur={(e) => editingName === s.name && commitRename(s.name, e.currentTarget.value)}
onkeydown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
// Escape would otherwise close the whole dialog.
else if (e.key === 'Escape') { e.preventDefault(); editingName = null; }
}}
/>
{:else}
<button
class="entry"
class:active={current === s.name}
style="padding-left: {indentRem(depth)}rem"
title={s.name}
onclick={() => select(s.name)}
ondblclick={() => { if (!s.builtin) editingName = s.name; }}
>
<span class="name">{s.name}</span>
{#if isAbstractStyle(s.name)}<span class="badge">{t().styles.abstract}</span>
{:else if !s.builtin}<span class="badge">{t().styles.custom}</span>{/if}
</button>
{/if}
</li>
{/each}
</ul>
</div>
<div class="fields">
{#if isTable}
<!-- The same grid the toolbar gallery shows, from the same resolver. -->
<div class="preview table-preview">
<table aria-hidden="true">
<tbody>
{#each Array(PREVIEW_ROWS) as _, r}
<tr>
{#each Array(PREVIEW_COLS) as _, c}
<td style={previewCellCss(tStyle, r, c, PREVIEW_ROWS, PREVIEW_COLS, ALL_AREAS)}>
<i class="tp-text" style={previewTextCss(tStyle, r, c, PREVIEW_ROWS, PREVIEW_COLS, ALL_AREAS)}></i>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<label>{t().styles.region}
<select value={region} onchange={(e) => (region = e.currentTarget.value as TableRegion | 'wholeTable')}>
<option value="wholeTable">{t().styles.regions.wholeTable}</option>
{#each TABLE_REGIONS as name}<option value={name}>{t().styles.regions[name]}</option>{/each}
</select>
</label>
<div class="row">
<div class="field">
<span class="field-label">{t().styles.fill}</span>
{#key current + region}
<ColorPicker
editor={null}
currentColor={regionProps.fill ?? null}
defaultColor={regionProps.fill ?? '#F2F2F2'}
title={t().styles.fill}
chevronTitle={t().styles.fill}
clearLabel={t().table.noFill}
onApply={(c) => editRegion({ fill: c })}
onClear={() => editRegion({ fill: undefined })}
>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M7.5 2.5l6 6-5 5a1.4 1.4 0 0 1-2 0l-4-4a1.4 1.4 0 0 1 0-2z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
</svg>
{/snippet}
</ColorPicker>
{/key}
</div>
</div>
{:else}
<!-- 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 }, family)}>
<option value="">—</option>
{#each parentOptions as name}<option value={name}>{name}</option>{/each}
</select>
</label>
{/if}
<label>{t().styles.font}
<select
value={ownText.fontFamily ?? ''}
onchange={(e) => editText({ fontFamily: e.currentTarget.value || undefined })}
>
<option value="">{inherited(resolvedText.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={ownText.fontSizePt ?? ''}
placeholder={inherited(resolvedText.fontSizePt)}
onchange={(e) => editText({ fontSizePt: num(e.currentTarget.value) })} />
</label>
<div class="field">
<span class="field-label">{t().styles.color}</span>
<!-- No editor: the picker sets a style property, not a document range. -->
{#key current + region}
<ColorPicker
editor={null}
currentColor={ownText.color ?? null}
defaultColor={ownText.color ?? resolvedText.color ?? '#000000'}
title={t().styles.color}
chevronTitle={t().styles.color}
clearLabel={inherited(resolvedText.color) || '—'}
onApply={(c) => editText({ color: c })}
onClear={() => editText({ color: undefined })}
>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<text x="8" y="14" text-anchor="middle" font-size="15" font-family="sans-serif" fill="currentColor">A</text>
</svg>
{/snippet}
</ColorPicker>
{/key}
</div>
</div>
<div class="row toggles">
<label class="check"><input type="checkbox" checked={!!resolvedText.bold}
onchange={(e) => editText({ bold: e.currentTarget.checked })} />{t().styles.bold}</label>
<label class="check"><input type="checkbox" checked={!!resolvedText.italic}
onchange={(e) => editText({ italic: e.currentTarget.checked })} />{t().styles.italic}</label>
<label class="check"><input type="checkbox" checked={!!resolvedText.underline}
onchange={(e) => editText({ underline: e.currentTarget.checked })} />{t().styles.underline}</label>
<label class="check"><input type="checkbox" checked={!!resolvedText.strike}
onchange={(e) => editText({ strike: e.currentTarget.checked })} />{t().styles.strike}</label>
</div>
{#if isTable}
<div class="row">
{#each BORDER_FIELDS as { key, label }}
<div class="field">
<span class="field-label">{label()}</span>
<div class="border-row">
<select
value={borderParts(borderOf(key)).width}
onchange={(e) => editTable({ [key]: borderValue(e.currentTarget.value, borderParts(borderOf(key)).color) })}
>
{#each BORDER_WIDTHS as w}
<option value={w}>{w === 'none' ? t().styles.noBorder : t().borders.pt(Number(w))}</option>
{/each}
</select>
{#key current + key}
<ColorPicker
editor={null}
currentColor={borderParts(borderOf(key)).color}
defaultColor={borderParts(borderOf(key)).color}
title={t().borders.lineColor}
chevronTitle={t().borders.lineColor}
clearLabel="—"
onApply={(c) => editTable({ [key]: borderValue(borderParts(borderOf(key)).width, c) })}
onClear={() => editTable({ [key]: null })}
>
{#snippet icon()}
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2" y="2" width="14" height="14" stroke="currentColor" stroke-width="1.3"/>
</svg>
{/snippet}
</ColorPicker>
{/key}
</div>
</div>
{/each}
</div>
{/if}
{#if !isChar && !isTable}
<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>
{/if}
</div>
</div>
<footer>
{#if isTable}
<button onclick={newTableStyle}>{t().styles.newStyle}</button>
<span class="spacer"></span>
{#if tStyle?.builtin}
<button onclick={() => resetStyle(tStyle.name, 'table')}>{t().styles.reset}</button>
{:else}
<button onclick={() => (editingName = tStyle.name)}>{t().styles.rename}</button>
<button class="danger" onclick={remove}>{t().common.remove}</button>
{/if}
{:else}
{#if isChar}<button onclick={newEmptyStyle}>{t().styles.newStyle}</button>{/if}
<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, family)}>{t().styles.reset}</button>
{:else}
<button onclick={() => (editingName = style.name)}>{t().styles.rename}</button>
<button class="danger" onclick={remove} disabled={style.name === HEADING_PARENT}>{t().common.remove}</button>
{/if}
{/if}
</footer>
</div>
</dialog>
<style>
.table-preview { display: flex; align-items: center; justify-content: center; }
.table-preview table {
width: 12rem;
table-layout: fixed;
border-collapse: collapse;
background: var(--color-page-bg);
color: var(--color-page-text);
}
.table-preview td { height: 1rem; padding: 0 3px; }
.tp-text { display: block; width: 100%; }
.border-row { display: flex; align-items: center; gap: 0.3rem; }
.border-row select { flex: 1; min-width: 0; }
.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); }
.pane {
display: flex;
flex-direction: column;
width: 14rem;
border-right: 1px solid var(--color-border);
}
.family {
display: flex;
padding: 0.4rem 0.4rem 0;
gap: 2px;
}
.family button {
flex: 1;
/* Without this a flex item can't shrink below its longest word — a German
compound has no break opportunity and pushed the strip out of the pane. */
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
padding: 0.3rem 0.2rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text-muted);
font: inherit;
font-size: 0.72rem;
cursor: pointer;
}
.family button.active {
background: var(--color-btn-hover);
color: var(--color-text);
}
.list {
width: 100%;
margin: 0;
padding: 0.4rem 0;
list-style: none;
overflow-y: auto;
}
.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); }
.rename { padding-right: 0.6rem; outline: 1px solid var(--color-primary); outline-offset: -1px; }
.entry.active { background: var(--color-primary); color: white; }
/* A long name is clipped instead of overflowing the pane (the title shows it in full). */
.name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge {
flex: none;
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);
}
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>