1844 lines
61 KiB
Svelte
1844 lines
61 KiB
Svelte
<script lang="ts">
|
||
import type { Editor } from '@tiptap/core';
|
||
import { onMount } from 'svelte';
|
||
import ColorPicker from './ColorPicker.svelte';
|
||
import TablePicker from './TablePicker.svelte';
|
||
import {
|
||
CANDIDATE_FONTS,
|
||
detectAvailableFonts,
|
||
queryLocalFontsIfAllowed,
|
||
supportsLocalFontAccess,
|
||
} from './fontDetect';
|
||
import { DEFAULT_MARGINS, cmToPx, type PageMargins } from '../storage/pageMargins';
|
||
import type { Orientation } from '../storage/pageOrientation';
|
||
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
|
||
import { listContext } from './indent';
|
||
|
||
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), hfActive = null, onEditZone, onDebugDump }:
|
||
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; hfDistances?: HfDistances; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void } = $props();
|
||
|
||
// Must match the first font in --font-serif in global.css. Bundled as a
|
||
// webfont so it is always available and matches the exported .odt's font.
|
||
const DEFAULT_EDITOR_FONT = 'Liberation Serif';
|
||
|
||
// Always-shown fonts — render in the picker even when detection fails or is blocked.
|
||
const WEB_SAFE_FONTS: readonly string[] = [
|
||
'Liberation Serif', 'Arial', 'Verdana', 'Trebuchet MS', 'Georgia', 'Times New Roman', 'Courier New',
|
||
];
|
||
const WEB_SAFE_SET = new Set<string>(WEB_SAFE_FONTS);
|
||
|
||
const RECENT_FONTS_KEY = 'odf-editor-recent-fonts';
|
||
const MAX_RECENT_FONTS = 5;
|
||
|
||
let recentFonts = $state<string[]>([]);
|
||
let detectedFonts = $state<string[]>([]);
|
||
let allInstalledFonts = $state<string[] | null>(null);
|
||
let detectionRan = false;
|
||
const localFontAccessSupported =
|
||
typeof window !== 'undefined' && supportsLocalFontAccess();
|
||
|
||
function loadRecents(): string[] {
|
||
try {
|
||
const raw = localStorage.getItem(RECENT_FONTS_KEY);
|
||
if (!raw) return [];
|
||
const parsed: unknown = JSON.parse(raw);
|
||
if (!Array.isArray(parsed)) return [];
|
||
return parsed.filter((x): x is string => typeof x === 'string').slice(0, MAX_RECENT_FONTS);
|
||
} catch { return []; }
|
||
}
|
||
|
||
function saveRecents(fonts: string[]) {
|
||
try { localStorage.setItem(RECENT_FONTS_KEY, JSON.stringify(fonts)); } catch { /* quota or disabled */ }
|
||
}
|
||
|
||
function ensureDetectionRan() {
|
||
if (detectionRan) return;
|
||
detectionRan = true;
|
||
detectedFonts = detectAvailableFonts(CANDIDATE_FONTS);
|
||
}
|
||
|
||
onMount(() => {
|
||
recentFonts = loadRecents();
|
||
// Run detection during browser idle time after first paint so the picker
|
||
// is primed before the user ever clicks it, without delaying initial render.
|
||
const schedule = typeof requestIdleCallback === 'function'
|
||
? (cb: () => void) => requestIdleCallback(cb, { timeout: 1000 })
|
||
: (cb: () => void) => setTimeout(cb, 0);
|
||
schedule(() => ensureDetectionRan());
|
||
});
|
||
|
||
let extraFontsList = $derived.by(() => {
|
||
const source = allInstalledFonts ?? detectedFonts;
|
||
const out: string[] = [];
|
||
const recentSet = new Set(recentFonts);
|
||
for (const f of source) {
|
||
if (!WEB_SAFE_SET.has(f) && !recentSet.has(f)) out.push(f);
|
||
}
|
||
return out.sort((a, b) => a.localeCompare(b));
|
||
});
|
||
|
||
async function showAllInstalledFonts() {
|
||
const list = await queryLocalFontsIfAllowed();
|
||
if (list && list.length > 0) allInstalledFonts = list;
|
||
}
|
||
|
||
// Returns the uniform font of the selection, or '' when fonts are mixed.
|
||
// Plain Text without an explicit mark falls back to DEFAULT_EDITOR_FONT.
|
||
let currentFont = $derived.by(() => {
|
||
if (tick < 0 || !editor) return '';
|
||
const { from, to, empty } = editor.state.selection;
|
||
if (empty) {
|
||
const marks = editor.state.storedMarks ?? editor.state.selection.$head.marks();
|
||
return marks.find(m => m.type.name === 'textStyle')?.attrs.fontFamily ?? DEFAULT_EDITOR_FONT;
|
||
}
|
||
let font: string | undefined;
|
||
let mixed = false;
|
||
editor.state.doc.nodesBetween(from, to, (node) => {
|
||
if (mixed || !node.isText) return;
|
||
const f: string = node.marks.find(m => m.type.name === 'textStyle')?.attrs.fontFamily ?? DEFAULT_EDITOR_FONT;
|
||
if (font === undefined) font = f;
|
||
else if (font !== f) mixed = true;
|
||
});
|
||
return mixed ? '' : (font ?? DEFAULT_EDITOR_FONT);
|
||
});
|
||
|
||
// Must match the common document default and editor.css heading sizes (in pt)
|
||
const DEFAULT_FONT_SIZE = '12pt';
|
||
const HEADING_SIZES: Record<number, string> = { 1: '20pt', 2: '16pt', 3: '14pt' };
|
||
const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36, 48, 72];
|
||
|
||
function effectiveSize(node: { isText: boolean; marks: readonly { type: { name: string }; attrs: Record<string, string> }[] }, parent: { type: { name: string }; attrs: Record<string, number> } | null): string {
|
||
const explicit = node.marks.find(m => m.type.name === 'textStyle')?.attrs.fontSize;
|
||
if (explicit) return explicit;
|
||
if (parent?.type.name === 'heading') return HEADING_SIZES[parent.attrs.level] ?? DEFAULT_FONT_SIZE;
|
||
return DEFAULT_FONT_SIZE;
|
||
}
|
||
|
||
let currentFontSize = $derived.by(() => {
|
||
if (tick < 0 || !editor) return '';
|
||
const { from, to, empty } = editor.state.selection;
|
||
if (empty) {
|
||
const head = editor.state.selection.$head;
|
||
const marks = editor.state.storedMarks ?? head.marks();
|
||
const explicit = marks.find(m => m.type.name === 'textStyle')?.attrs.fontSize;
|
||
if (explicit) return explicit;
|
||
const parent = head.parent;
|
||
if (parent.type.name === 'heading') return HEADING_SIZES[parent.attrs.level] ?? DEFAULT_FONT_SIZE;
|
||
return DEFAULT_FONT_SIZE;
|
||
}
|
||
let size: string | undefined;
|
||
let mixed = false;
|
||
editor.state.doc.nodesBetween(from, to, (node, _pos, parent) => {
|
||
if (mixed || !node.isText) return;
|
||
const s = effectiveSize(node as Parameters<typeof effectiveSize>[0], parent as Parameters<typeof effectiveSize>[1]);
|
||
if (size === undefined) size = s;
|
||
else if (size !== s) mixed = true;
|
||
});
|
||
return mixed ? '' : (size ?? DEFAULT_FONT_SIZE);
|
||
});
|
||
|
||
// Must match line-height in editor.css (.paper .tiptap)
|
||
const DEFAULT_LINE_HEIGHT = '1';
|
||
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);
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
});
|
||
|
||
// Uniform color of the selection for a given mark. null = no color anywhere; '' = mixed.
|
||
function uniformMarkColor(markName: string): string | null {
|
||
if (tick < 0 || !editor) return null;
|
||
const { from, to, empty } = editor.state.selection;
|
||
if (empty) {
|
||
const marks = editor.state.storedMarks ?? editor.state.selection.$head.marks();
|
||
return marks.find(m => m.type.name === markName)?.attrs.color ?? null;
|
||
}
|
||
let color: string | null | undefined;
|
||
let mixed = false;
|
||
editor.state.doc.nodesBetween(from, to, (node) => {
|
||
if (mixed || !node.isText) return;
|
||
const c = node.marks.find(m => m.type.name === markName)?.attrs.color ?? null;
|
||
if (color === undefined) color = c;
|
||
else if (color !== c) mixed = true;
|
||
});
|
||
return mixed ? '' : (color ?? null);
|
||
}
|
||
// Font color lives on the textStyle mark (fontColor.ts); highlight on the
|
||
// dedicated `highlight` mark (multicolor → exports to fo:background-color).
|
||
let currentFontColor = $derived(uniformMarkColor('textStyle'));
|
||
let currentHighlightColor = $derived(uniformMarkColor('highlight'));
|
||
|
||
// Sub/superscript are mutually exclusive (Word-style): toggling one clears the
|
||
// other. odf-kit maps these marks to text:position (round-trips with LibreOffice
|
||
// and Word). See export/odt.ts applyRuns for the custom-attr-paragraph path.
|
||
let isSuperscript = $derived(tick >= 0 && !!editor?.isActive('superscript'));
|
||
let isSubscript = $derived(tick >= 0 && !!editor?.isActive('subscript'));
|
||
|
||
function toggleSuperscript() {
|
||
editor?.chain().focus().unsetSubscript().toggleSuperscript().run();
|
||
}
|
||
function toggleSubscript() {
|
||
editor?.chain().focus().unsetSuperscript().toggleSubscript().run();
|
||
}
|
||
|
||
let fontOpen = $state(false);
|
||
let sizeOpen = $state(false);
|
||
let lineHeightOpen = $state(false);
|
||
let fontColorOpen = $state(false);
|
||
let highlightColorOpen = $state(false);
|
||
let sizeInputFocused = $state(false);
|
||
let sizeInputValue = $state('');
|
||
let savedFrom: number | null = null;
|
||
let savedTo: number | null = null;
|
||
|
||
$effect(() => {
|
||
if (!sizeInputFocused) {
|
||
sizeInputValue = currentFontSize ? currentFontSize.replace('pt', '') : '';
|
||
}
|
||
});
|
||
|
||
function openFontPicker() {
|
||
if (!editor) return;
|
||
sizeOpen = false;
|
||
sizeInputFocused = false;
|
||
lineHeightOpen = false;
|
||
fontColorOpen = false;
|
||
highlightColorOpen = false;
|
||
// Save selection before the picker button steals focus.
|
||
savedFrom = editor.state.selection.from;
|
||
savedTo = editor.state.selection.to;
|
||
// Detection normally runs on mount; this is the safety net if the user
|
||
// somehow clicks the picker before the idle callback has fired.
|
||
if (!fontOpen) ensureDetectionRan();
|
||
fontOpen = !fontOpen;
|
||
}
|
||
|
||
function pickFont(value: string) {
|
||
if (!editor) return;
|
||
fontOpen = false;
|
||
const from = savedFrom ?? editor.state.selection.from;
|
||
const to = savedTo ?? editor.state.selection.to;
|
||
savedFrom = null;
|
||
savedTo = null;
|
||
editor.chain().focus().setTextSelection({ from, to }).setFontFamily(value).run();
|
||
|
||
const next = [value, ...recentFonts.filter((f) => f !== value)].slice(0, MAX_RECENT_FONTS);
|
||
recentFonts = next;
|
||
saveRecents(next);
|
||
}
|
||
|
||
function fontPickerClickOutside(node: HTMLElement) {
|
||
function handler(e: MouseEvent) {
|
||
if (!node.contains(e.target as Node)) fontOpen = false;
|
||
}
|
||
window.addEventListener('mousedown', handler);
|
||
return { destroy() { window.removeEventListener('mousedown', handler); } };
|
||
}
|
||
|
||
function applyFontSize(size: number) {
|
||
if (!editor) return;
|
||
const from = savedFrom ?? editor.state.selection.from;
|
||
const to = savedTo ?? editor.state.selection.to;
|
||
savedFrom = null;
|
||
savedTo = null;
|
||
editor.chain().focus().setTextSelection({ from, to }).setFontSize(`${size}pt`).run();
|
||
}
|
||
|
||
function onSizeInputFocus(e: FocusEvent) {
|
||
if (!editor) return;
|
||
sizeInputFocused = true;
|
||
fontOpen = false;
|
||
lineHeightOpen = false;
|
||
fontColorOpen = false;
|
||
highlightColorOpen = false;
|
||
savedFrom = editor.state.selection.from;
|
||
savedTo = editor.state.selection.to;
|
||
(e.target as HTMLInputElement).select();
|
||
}
|
||
|
||
function onSizeInputKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const num = parseInt(sizeInputValue, 10);
|
||
if (!isNaN(num) && num >= 1 && num <= 400) applyFontSize(num);
|
||
sizeInputFocused = false;
|
||
(e.target as HTMLInputElement).blur();
|
||
} else if (e.key === 'Escape') {
|
||
sizeInputFocused = false;
|
||
(e.target as HTMLInputElement).blur();
|
||
}
|
||
}
|
||
|
||
function onSizeInputBlur() {
|
||
sizeInputFocused = false;
|
||
}
|
||
|
||
function openSizePicker() {
|
||
if (!editor) return;
|
||
fontOpen = false;
|
||
lineHeightOpen = false;
|
||
fontColorOpen = false;
|
||
highlightColorOpen = false;
|
||
if (!sizeInputFocused) {
|
||
savedFrom = editor.state.selection.from;
|
||
savedTo = editor.state.selection.to;
|
||
}
|
||
sizeOpen = !sizeOpen;
|
||
}
|
||
|
||
function pickSize(size: number) {
|
||
if (!editor) return;
|
||
sizeOpen = false;
|
||
sizeInputFocused = false;
|
||
applyFontSize(size);
|
||
}
|
||
|
||
function sizePickerClickOutside(node: HTMLElement) {
|
||
function handler(e: MouseEvent) {
|
||
if (!node.contains(e.target as Node)) {
|
||
sizeOpen = false;
|
||
}
|
||
}
|
||
window.addEventListener('mousedown', handler);
|
||
return { destroy() { window.removeEventListener('mousedown', handler); } };
|
||
}
|
||
|
||
function openLineHeightPicker() {
|
||
if (!editor) return;
|
||
fontOpen = false;
|
||
sizeOpen = false;
|
||
sizeInputFocused = false;
|
||
fontColorOpen = false;
|
||
highlightColorOpen = false;
|
||
lineHeightOpen = !lineHeightOpen;
|
||
}
|
||
|
||
function pickLineHeight(value: string) {
|
||
if (!editor) return;
|
||
lineHeightOpen = false;
|
||
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;
|
||
fontColorOpen = false;
|
||
highlightColorOpen = 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;
|
||
}
|
||
window.addEventListener('mousedown', handler);
|
||
return { destroy() { window.removeEventListener('mousedown', handler); } };
|
||
}
|
||
|
||
// --- Page layout / margins (cm) ---
|
||
type MarginAxis = 'top' | 'bottom' | 'left' | 'right';
|
||
const MARGIN_FIELDS: { axis: MarginAxis; label: string }[] = [
|
||
{ axis: 'top', label: 'Top' },
|
||
{ axis: 'bottom', label: 'Bottom' },
|
||
{ axis: 'left', label: 'Left' },
|
||
{ axis: 'right', label: 'Right' },
|
||
];
|
||
const MARGIN_STEP = 0.1;
|
||
const MARGIN_MIN = 0;
|
||
const MARGIN_MAX = 10;
|
||
|
||
let layoutOpen = $state(false);
|
||
let marginInputs = $state<Record<MarginAxis, string>>({ top: '', bottom: '', left: '', right: '' });
|
||
|
||
function fmtCm(n: number): string {
|
||
return String(Math.round(n * 100) / 100);
|
||
}
|
||
|
||
// Refresh the editable display strings from the current margins. Called on open
|
||
// and after each change — no $effect, so there's no chance of a reactive loop
|
||
// and typing is never clobbered mid-edit.
|
||
function syncMarginInputs() {
|
||
marginInputs = {
|
||
top: fmtCm(pageMargins.top),
|
||
bottom: fmtCm(pageMargins.bottom),
|
||
left: fmtCm(pageMargins.left),
|
||
right: fmtCm(pageMargins.right),
|
||
};
|
||
}
|
||
|
||
function setMargin(axis: MarginAxis, value: number) {
|
||
const clamped = Math.min(MARGIN_MAX, Math.max(MARGIN_MIN, Math.round(value * 100) / 100));
|
||
pageMargins = { ...pageMargins, [axis]: clamped };
|
||
marginInputs[axis] = fmtCm(clamped);
|
||
}
|
||
|
||
function stepMargin(axis: MarginAxis, delta: number) {
|
||
setMargin(axis, pageMargins[axis] + delta);
|
||
}
|
||
|
||
function commitMarginInput(axis: MarginAxis) {
|
||
const n = parseFloat(marginInputs[axis]);
|
||
if (Number.isNaN(n)) {
|
||
marginInputs[axis] = fmtCm(pageMargins[axis]); // revert invalid input
|
||
return;
|
||
}
|
||
setMargin(axis, n);
|
||
}
|
||
|
||
function onMarginKeydown(axis: MarginAxis, e: KeyboardEvent) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
commitMarginInput(axis);
|
||
(e.target as HTMLInputElement).blur();
|
||
} else if (e.key === 'Escape') {
|
||
marginInputs[axis] = fmtCm(pageMargins[axis]);
|
||
(e.target as HTMLInputElement).blur();
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
stepMargin(axis, MARGIN_STEP);
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
stepMargin(axis, -MARGIN_STEP);
|
||
}
|
||
}
|
||
|
||
// --- Header/footer edge distances (cm) ---
|
||
type HfAxis = 'header' | 'footer';
|
||
const HF_DIST_FIELDS: { axis: HfAxis; label: string }[] = [
|
||
{ axis: 'header', label: 'Header from top' },
|
||
{ axis: 'footer', label: 'Footer from bottom' },
|
||
];
|
||
let hfDistInputs = $state<Record<HfAxis, string>>({ header: '', footer: '' });
|
||
|
||
function syncHfDistInputs() {
|
||
hfDistInputs = { header: fmtCm(hfDistances.header), footer: fmtCm(hfDistances.footer) };
|
||
}
|
||
|
||
function setHfDist(axis: HfAxis, value: number) {
|
||
const clamped = clampHfDistance(value);
|
||
hfDistances = { ...hfDistances, [axis]: clamped };
|
||
hfDistInputs[axis] = fmtCm(clamped);
|
||
}
|
||
|
||
function stepHfDist(axis: HfAxis, delta: number) {
|
||
setHfDist(axis, hfDistances[axis] + delta);
|
||
}
|
||
|
||
function commitHfDistInput(axis: HfAxis) {
|
||
const n = parseFloat(hfDistInputs[axis]);
|
||
if (Number.isNaN(n)) {
|
||
hfDistInputs[axis] = fmtCm(hfDistances[axis]); // revert invalid input
|
||
return;
|
||
}
|
||
setHfDist(axis, n);
|
||
}
|
||
|
||
function onHfDistKeydown(axis: HfAxis, e: KeyboardEvent) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
commitHfDistInput(axis);
|
||
(e.target as HTMLInputElement).blur();
|
||
} else if (e.key === 'Escape') {
|
||
hfDistInputs[axis] = fmtCm(hfDistances[axis]);
|
||
(e.target as HTMLInputElement).blur();
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
stepHfDist(axis, MARGIN_STEP);
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
stepHfDist(axis, -MARGIN_STEP);
|
||
}
|
||
}
|
||
|
||
function openLayout() {
|
||
fontOpen = false;
|
||
sizeOpen = false;
|
||
sizeInputFocused = false;
|
||
lineHeightOpen = false;
|
||
fontColorOpen = false;
|
||
highlightColorOpen = false;
|
||
layoutOpen = !layoutOpen;
|
||
if (layoutOpen) {
|
||
syncMarginInputs();
|
||
syncHfDistInputs();
|
||
}
|
||
}
|
||
|
||
function layoutClickOutside(node: HTMLElement) {
|
||
function handler(e: MouseEvent) {
|
||
if (!node.contains(e.target as Node)) layoutOpen = false;
|
||
}
|
||
window.addEventListener('mousedown', handler);
|
||
return { destroy() { window.removeEventListener('mousedown', handler); } };
|
||
}
|
||
|
||
// The color pickers (font + highlight) live in ColorPicker.svelte. When one
|
||
// opens it asks the parent (via the ColorPicker `onOpen` prop) to close the
|
||
// sibling toolbar dropdowns and the other color picker.
|
||
function onColorPickerOpen(which: 'font' | 'highlight') {
|
||
fontOpen = false;
|
||
sizeOpen = false;
|
||
sizeInputFocused = false;
|
||
lineHeightOpen = false;
|
||
if (which === 'font') highlightColorOpen = false;
|
||
else fontColorOpen = false;
|
||
}
|
||
|
||
// --- Table insertion (TablePicker.svelte) ---
|
||
let tableOpen = $state(false);
|
||
|
||
// Close the sibling dropdowns when the table picker opens (mirrors openLayout).
|
||
function onTablePickerOpen() {
|
||
fontOpen = false;
|
||
sizeOpen = false;
|
||
sizeInputFocused = false;
|
||
lineHeightOpen = false;
|
||
fontColorOpen = false;
|
||
highlightColorOpen = false;
|
||
layoutOpen = false;
|
||
}
|
||
|
||
function insertTable(rows: number, cols: number, range: { from: number; to: number }) {
|
||
if (!editor) return;
|
||
editor.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run();
|
||
}
|
||
|
||
// --- Image insertion (image.ts) ---
|
||
let imageInput = $state<HTMLInputElement | null>(null);
|
||
|
||
// The page text box in px, so an inserted image never starts wider/taller than
|
||
// one page (mirrors export's content-width math; A4 dims swap with orientation).
|
||
function contentBoxPx(): { maxW: number; maxH: number } {
|
||
const land = pageOrientation === 'landscape';
|
||
const wCm = (land ? 29.7 : 21) - pageMargins.left - pageMargins.right;
|
||
const hCm = (land ? 21 : 29.7) - pageMargins.top - pageMargins.bottom;
|
||
return { maxW: Math.round(cmToPx(wCm)), maxH: Math.round(cmToPx(hCm)) };
|
||
}
|
||
|
||
function onImageFile(e: Event) {
|
||
const input = e.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
input.value = ''; // allow re-selecting the same file
|
||
if (!file || !editor || hfActive) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const src = reader.result as string;
|
||
const probe = document.createElement('img');
|
||
probe.onload = () => {
|
||
let w = probe.naturalWidth || 1;
|
||
let h = probe.naturalHeight || 1;
|
||
const { maxW, maxH } = contentBoxPx();
|
||
if (w > maxW) { h = (h * maxW) / w; w = maxW; }
|
||
if (h > maxH) { w = (w * maxH) / h; h = maxH; }
|
||
editor!.chain().focus().setImage({ src, alt: file.name, width: Math.round(w), height: Math.round(h) }).run();
|
||
};
|
||
probe.src = src;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
// --- Indent (Einzug) ---
|
||
// In a list, step the list point one level (indentListForward/Backward — the Tab
|
||
// keymap's commands, see indent.ts); outside a list, step the paragraph's left indent.
|
||
function changeIndent(dir: 1 | -1) {
|
||
if (!editor) return;
|
||
const c = editor.chain().focus();
|
||
if (listContext(editor.state).inList) {
|
||
(dir === 1 ? c.indentListForward() : c.indentListBackward()).run();
|
||
} else {
|
||
(dir === 1 ? c.indentMore() : c.indentLess()).run();
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="toolbar-expanded">
|
||
{#if editor}
|
||
<div class="font-picker" use:fontPickerClickOutside>
|
||
<button class="font-trigger" onclick={openFontPicker} title="Font Name">
|
||
<span class="font-trigger-label" style={currentFont ? `font-family: ${currentFont}` : ''}>{currentFont}</span>
|
||
<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 fontOpen}
|
||
<div class="font-dropdown">
|
||
{#if recentFonts.length > 0}
|
||
<div class="font-section-label">Recent</div>
|
||
{#each recentFonts as font}
|
||
<button
|
||
class="font-option"
|
||
class:active={currentFont === font}
|
||
style="font-family: {font}"
|
||
onclick={() => pickFont(font)}
|
||
>{font}</button>
|
||
{/each}
|
||
{/if}
|
||
|
||
<div class="font-section-label">Web-safe</div>
|
||
{#each WEB_SAFE_FONTS as font}
|
||
<button
|
||
class="font-option"
|
||
class:active={currentFont === font}
|
||
style="font-family: {font}"
|
||
onclick={() => pickFont(font)}
|
||
>{font}</button>
|
||
{/each}
|
||
|
||
{#if extraFontsList.length > 0}
|
||
<div class="font-section-label">All fonts</div>
|
||
{#each extraFontsList as font}
|
||
<button
|
||
class="font-option"
|
||
class:active={currentFont === font}
|
||
style="font-family: {font}"
|
||
onclick={() => pickFont(font)}
|
||
>{font}</button>
|
||
{/each}
|
||
{/if}
|
||
|
||
{#if localFontAccessSupported && !allInstalledFonts}
|
||
<button class="font-show-all" onclick={showAllInstalledFonts}>
|
||
Load all installed fonts
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="size-picker" use:sizePickerClickOutside>
|
||
<div class="size-trigger-wrap">
|
||
<input
|
||
type="text"
|
||
class="size-input"
|
||
bind:value={sizeInputValue}
|
||
onfocus={onSizeInputFocus}
|
||
onkeydown={onSizeInputKeydown}
|
||
onblur={onSizeInputBlur}
|
||
inputmode="numeric"
|
||
title="Font size"
|
||
/>
|
||
<button class="size-chevron" onclick={openSizePicker} tabindex="-1" title="Font size list">
|
||
<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 sizeOpen}
|
||
<div class="size-dropdown">
|
||
<div class="lh-section-label">Font size</div>
|
||
{#each FONT_SIZES as size}
|
||
<button
|
||
class="size-option"
|
||
class:active={currentFontSize === `${size}pt`}
|
||
onclick={() => pickSize(size)}
|
||
>{size}</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="toolbar-group">
|
||
<ColorPicker
|
||
{editor}
|
||
currentColor={currentFontColor}
|
||
defaultColor="#C00000"
|
||
title="Font color"
|
||
chevronTitle="Choose font color"
|
||
bind:open={fontColorOpen}
|
||
onOpen={() => onColorPickerOpen('font')}
|
||
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setColor(c).run()}
|
||
onClear={(r) => editor?.chain().focus().setTextSelection(r).unsetColor().run()}
|
||
>
|
||
{#snippet icon()}
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<text x="3" y="11" font-size="10" font-weight="bold" font-family="sans-serif" fill="currentColor">A</text>
|
||
</svg>
|
||
{/snippet}
|
||
</ColorPicker>
|
||
<ColorPicker
|
||
{editor}
|
||
currentColor={currentHighlightColor}
|
||
defaultColor="#FFFF00"
|
||
title="Highlight color"
|
||
chevronTitle="Choose highlight color"
|
||
clearLabel="No color"
|
||
bind:open={highlightColorOpen}
|
||
onOpen={() => onColorPickerOpen('highlight')}
|
||
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setHighlight({ color: c }).run()}
|
||
onClear={(r) => editor?.chain().focus().setTextSelection(r).unsetHighlight().run()}
|
||
>
|
||
{#snippet icon()}
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<rect x="2" y="2" width="12" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||
<rect x="4" y="4" width="8" height="8" rx="1" fill="currentColor" opacity="0.3"/>
|
||
</svg>
|
||
{/snippet}
|
||
</ColorPicker>
|
||
</div>
|
||
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="toolbar-group">
|
||
<button
|
||
class:active={isSuperscript}
|
||
onclick={toggleSuperscript}
|
||
title="Superscript (Ctrl+.)"
|
||
aria-pressed={isSuperscript}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<path d="M2.5 11.5L7 5M7 11.5L2.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<path d="M10.5 4.7c0-.8.7-1.4 1.5-1.4s1.5.6 1.5 1.4c0 1.2-1.6 1.5-3 3.1h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class:active={isSubscript}
|
||
onclick={toggleSubscript}
|
||
title="Subscript (Ctrl+,)"
|
||
aria-pressed={isSubscript}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<path d="M2.5 11.5L7 5M7 11.5L2.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<path d="M10.5 12.2c0-.8.7-1.4 1.5-1.4s1.5.6 1.5 1.4c0 1.2-1.6 1.5-3 3.1h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="toolbar-group">
|
||
<button onclick={() => changeIndent(-1)} title="Decrease indent">
|
||
<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="9" 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="M5.5 8H1.5M3.5 5.5L1.5 8L3.5 10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick={() => changeIndent(1)} title="Increase indent">
|
||
<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="9" 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="M1.5 8H5.5M3.5 5.5L5.5 8L3.5 10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="lh-picker" use:lineHeightPickerClickOutside>
|
||
<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="8" y1="3" x2="16" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||
<line x1="8" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||
<line x1="8" y1="13" x2="16" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||
<path d="M2.8 6.5V1.6M2.8 1.6L0.7 4.1M2.8 1.6L4.9 4.1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<path d="M2.8 9.5V14.4M2.8 14.4L0.7 11.9M2.8 14.4L4.9 11.9" 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">
|
||
<div class="lh-section-label">Line spacing</div>
|
||
{#each LINE_HEIGHTS as lh}
|
||
<button
|
||
class="lh-option"
|
||
class:active={currentLineHeight === lh.value}
|
||
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>
|
||
|
||
<div class="layout-picker right-group" use:layoutClickOutside>
|
||
<button class="layout-trigger" onclick={openLayout} title="Page layout & margins" aria-pressed={layoutOpen}>
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<rect x="2.5" y="1.5" width="11" height="13" rx="1" stroke="currentColor" stroke-width="1.3"/>
|
||
<rect x="4.5" y="3.5" width="7" height="9" rx="0.5" stroke="currentColor" stroke-width="1" stroke-dasharray="2 1.5"/>
|
||
</svg>
|
||
<span class="layout-trigger-label">Layout</span>
|
||
<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 layoutOpen}
|
||
<div class="layout-dropdown">
|
||
<div class="lh-section-label">Orientation</div>
|
||
<div class="orientation-row">
|
||
<button
|
||
class="orientation-btn"
|
||
class:active={pageOrientation === 'portrait'}
|
||
aria-pressed={pageOrientation === 'portrait'}
|
||
onclick={() => (pageOrientation = 'portrait')}
|
||
title="Portrait"
|
||
>
|
||
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" aria-hidden="true">
|
||
<rect x="2.5" y="1.5" width="9" height="13" rx="1" stroke="currentColor" stroke-width="1.3"/>
|
||
</svg>
|
||
<span>Portrait</span>
|
||
</button>
|
||
<button
|
||
class="orientation-btn"
|
||
class:active={pageOrientation === 'landscape'}
|
||
aria-pressed={pageOrientation === 'landscape'}
|
||
onclick={() => (pageOrientation = 'landscape')}
|
||
title="Landscape"
|
||
>
|
||
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" aria-hidden="true">
|
||
<rect x="1.5" y="2.5" width="13" height="9" rx="1" stroke="currentColor" stroke-width="1.3"/>
|
||
</svg>
|
||
<span>Landscape</span>
|
||
</button>
|
||
</div>
|
||
<div class="lh-section-label">Page margins (cm)</div>
|
||
<div class="margin-grid">
|
||
{#each MARGIN_FIELDS as f}
|
||
<div class="margin-field">
|
||
<span class="margin-label">{f.label}</span>
|
||
<div class="margin-input-wrap">
|
||
<input
|
||
type="text"
|
||
class="margin-input"
|
||
bind:value={marginInputs[f.axis]}
|
||
onkeydown={(e) => onMarginKeydown(f.axis, e)}
|
||
onblur={() => commitMarginInput(f.axis)}
|
||
inputmode="decimal"
|
||
title="{f.label} margin (cm)"
|
||
/>
|
||
<div class="margin-steppers">
|
||
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(f.axis, MARGIN_STEP)} title="Increase {f.label.toLowerCase()} margin" aria-label="Increase {f.label.toLowerCase()} margin">
|
||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||
<path d="M1 5.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(f.axis, -MARGIN_STEP)} title="Decrease {f.label.toLowerCase()} margin" aria-label="Decrease {f.label.toLowerCase()} margin">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<div class="lh-section-label">Header & footer</div>
|
||
<div class="hf-edit-row">
|
||
<button
|
||
class="hf-edit-btn"
|
||
class:active={hfActive === 'header'}
|
||
onclick={() => onEditZone?.('header')}
|
||
>Edit header</button>
|
||
<button
|
||
class="hf-edit-btn"
|
||
class:active={hfActive === 'footer'}
|
||
onclick={() => onEditZone?.('footer')}
|
||
>Edit footer</button>
|
||
</div>
|
||
|
||
<div class="lh-section-label">Position (cm)</div>
|
||
<div class="margin-grid">
|
||
{#each HF_DIST_FIELDS as f}
|
||
<div class="margin-field">
|
||
<span class="margin-label">{f.label}</span>
|
||
<div class="margin-input-wrap">
|
||
<input
|
||
type="text"
|
||
class="margin-input"
|
||
bind:value={hfDistInputs[f.axis]}
|
||
onkeydown={(e) => onHfDistKeydown(f.axis, e)}
|
||
onblur={() => commitHfDistInput(f.axis)}
|
||
inputmode="decimal"
|
||
title="{f.label} (cm)"
|
||
/>
|
||
<div class="margin-steppers">
|
||
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(f.axis, MARGIN_STEP)} title="Increase" aria-label="Increase {f.label.toLowerCase()}">
|
||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||
<path d="M1 5.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(f.axis, -MARGIN_STEP)} title="Decrease" aria-label="Decrease {f.label.toLowerCase()}">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="toolbar-group">
|
||
<button
|
||
class:active={showFormattingMarks}
|
||
onclick={() => (showFormattingMarks = !showFormattingMarks)}
|
||
title="Formatting marks"
|
||
aria-pressed={showFormattingMarks}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<path d="M11 2.25v11.5M7.5 2.25v11.5M11 2.25H6.75a2.75 2.75 0 0 0 0 5.5H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="toolbar-group">
|
||
<TablePicker
|
||
{editor}
|
||
bind:open={tableOpen}
|
||
onOpen={onTablePickerOpen}
|
||
onInsert={insertTable}
|
||
/>
|
||
<button
|
||
onclick={() => imageInput?.click()}
|
||
disabled={!!hfActive}
|
||
title={hfActive ? 'Images are not available in headers/footers' : 'Insert image'}
|
||
aria-label="Insert image"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" stroke="currentColor" />
|
||
<circle cx="5.3" cy="6" r="1.2" fill="currentColor" />
|
||
<path d="M2 12l3.7-3.6 2.4 2.4 2.8-2.8L14 11.5" stroke="currentColor" stroke-linejoin="round" />
|
||
</svg>
|
||
</button>
|
||
<input
|
||
bind:this={imageInput}
|
||
type="file"
|
||
accept="image/*"
|
||
style="display:none"
|
||
onchange={onImageFile}
|
||
/>
|
||
</div>
|
||
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="toolbar-group">
|
||
<button
|
||
onclick={() =>
|
||
editor?.chain().focus()
|
||
.unsetAllMarks()
|
||
.clearNodes()
|
||
.unsetLineHeight()
|
||
.unsetTextAlign()
|
||
.setSpaceBefore(null)
|
||
.setSpaceAfter(null)
|
||
.unsetIndent()
|
||
.run()}
|
||
title="Clear formatting"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<!-- bold + italic A -->
|
||
<text x="0" y="12" font-size="12" font-family="var(--font-serif, serif)"
|
||
font-weight="700" font-style="italic" fill="currentColor">A</text>
|
||
<!-- underline beneath the A -->
|
||
<line x1="0.5" y1="14.2" x2="7.5" y2="14.2"
|
||
stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||
<!-- big eraser sweeping across the A's upper-right -->
|
||
<g transform="rotate(35 10 6)">
|
||
<rect x="6.5" y="3.4" width="9" height="4.4" rx="1.3"
|
||
fill="currentColor" fill-opacity="0.12"
|
||
stroke="currentColor" stroke-width="1.4"/>
|
||
<line x1="9.7" y1="3.4" x2="9.7" y2="7.8" stroke="currentColor" stroke-width="1.2"/>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{#if import.meta.env.DEV && onDebugDump}
|
||
<div class="toolbar-separator"></div>
|
||
|
||
<div class="toolbar-group">
|
||
<button class="debug-btn" onclick={onDebugDump} title="Download page-break debug snapshot">
|
||
Debug
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
/* Inner content of the second toolbar row; the surrounding .toolbar-secondary
|
||
(App.svelte) owns the background, divider and elevation. */
|
||
.toolbar-expanded {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
/* Layout sits at the far right (logical separation from the rest). order:1
|
||
renders it after the left cluster; margin-left:auto opens the gap. Its
|
||
dropdown anchors right so it stays inside the viewport. */
|
||
.right-group { order: 1; }
|
||
.layout-picker.right-group { margin-left: auto; }
|
||
.layout-picker.right-group .layout-dropdown { left: auto; right: 0; }
|
||
|
||
.toolbar-group {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
|
||
.toolbar-separator {
|
||
width: 1px;
|
||
height: 1.5rem;
|
||
background: var(--color-border);
|
||
margin: 0 0.5rem;
|
||
}
|
||
|
||
button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 2rem;
|
||
height: 2rem;
|
||
padding: 0 0.5rem;
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
font-size: 0.85rem;
|
||
font-family: var(--font-sans);
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
button:hover:not(:disabled) {
|
||
background: var(--color-btn-hover);
|
||
}
|
||
|
||
button.active {
|
||
background: var(--color-primary);
|
||
color: white;
|
||
}
|
||
|
||
button:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.font-picker {
|
||
position: relative;
|
||
}
|
||
|
||
.font-trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
height: 2rem;
|
||
padding: 0 0.4rem 0 0.6rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius);
|
||
background: var(--color-surface);
|
||
color: var(--color-text);
|
||
font-size: 0.8rem;
|
||
font-family: var(--font-sans);
|
||
cursor: pointer;
|
||
width: 145px;
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.font-trigger:hover {
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.font-trigger-label {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
text-align: left;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.font-dropdown {
|
||
position: absolute;
|
||
top: calc(100% + 3px);
|
||
left: 0;
|
||
min-width: 100%;
|
||
max-height: 360px;
|
||
overflow-y: auto;
|
||
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;
|
||
}
|
||
|
||
.font-section-label {
|
||
padding: 0.4rem 0.6rem 0.2rem;
|
||
font-size: 0.65rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--color-text-muted);
|
||
font-family: var(--font-sans);
|
||
user-select: none;
|
||
}
|
||
|
||
.font-section-label:not(:first-child) {
|
||
margin-top: 4px;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.font-show-all {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 0.4rem 0.6rem;
|
||
margin-top: 2px;
|
||
border: none;
|
||
border-top: 1px solid var(--color-border);
|
||
border-radius: 0;
|
||
background: var(--color-surface);
|
||
color: var(--color-primary);
|
||
font-size: 0.8rem;
|
||
font-family: var(--font-sans);
|
||
text-align: left;
|
||
cursor: pointer;
|
||
position: sticky;
|
||
bottom: -2px;
|
||
}
|
||
|
||
.font-show-all:hover {
|
||
background: var(--color-btn-hover);
|
||
}
|
||
|
||
.font-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;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
transition: background 0.1s;
|
||
min-width: unset;
|
||
height: auto;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.font-option:hover {
|
||
background: var(--color-btn-hover);
|
||
}
|
||
|
||
.font-option.active {
|
||
background: var(--color-primary);
|
||
color: white;
|
||
}
|
||
|
||
.size-picker {
|
||
position: relative;
|
||
}
|
||
|
||
.size-trigger-wrap {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 2rem;
|
||
width: 62px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius);
|
||
background: var(--color-surface);
|
||
transition: border-color 0.15s;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.size-trigger-wrap:hover,
|
||
.size-trigger-wrap:focus-within {
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.size-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;
|
||
}
|
||
|
||
.size-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;
|
||
}
|
||
|
||
.size-chevron:hover {
|
||
background: var(--color-btn-hover);
|
||
}
|
||
|
||
.size-dropdown {
|
||
position: absolute;
|
||
top: calc(100% + 3px);
|
||
left: 0;
|
||
min-width: 100%;
|
||
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;
|
||
max-height: 260px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.size-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.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;
|
||
}
|
||
|
||
.size-option:hover {
|
||
background: var(--color-btn-hover);
|
||
}
|
||
|
||
.size-option.active {
|
||
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: 200px;
|
||
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;
|
||
}
|
||
|
||
.layout-picker {
|
||
position: relative;
|
||
}
|
||
|
||
.layout-trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
height: 2rem;
|
||
padding: 0 0.5rem;
|
||
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;
|
||
}
|
||
|
||
.layout-trigger:hover {
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.layout-trigger-label {
|
||
font-size: 0.8rem;
|
||
font-family: var(--font-sans);
|
||
}
|
||
|
||
.layout-dropdown {
|
||
position: absolute;
|
||
top: calc(100% + 3px);
|
||
left: 0;
|
||
width: 232px;
|
||
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 2px 6px;
|
||
}
|
||
|
||
.orientation-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
padding: 4px 6px 2px;
|
||
}
|
||
|
||
.orientation-btn {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
padding: 8px 4px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius);
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
font-family: var(--font-sans);
|
||
font-size: 0.72rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.orientation-btn:hover {
|
||
background: var(--color-btn-hover);
|
||
}
|
||
|
||
.orientation-btn.active {
|
||
border-color: var(--color-primary);
|
||
background: var(--color-primary);
|
||
color: white;
|
||
}
|
||
|
||
.margin-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
padding: 4px 6px 2px;
|
||
}
|
||
|
||
.hf-edit-row {
|
||
display: flex;
|
||
gap: 6px;
|
||
padding: 2px 6px 4px;
|
||
}
|
||
|
||
.hf-edit-btn {
|
||
flex: 1;
|
||
padding: 5px 8px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius);
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
font-family: var(--font-sans);
|
||
font-size: 0.78rem;
|
||
cursor: pointer;
|
||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||
}
|
||
|
||
.hf-edit-btn:hover {
|
||
background: var(--color-btn-hover);
|
||
}
|
||
|
||
.hf-edit-btn.active {
|
||
background: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
|
||
.margin-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
/* Allow the grid track to shrink below the input's intrinsic width so the
|
||
fields stay inside the panel instead of overflowing to the right. */
|
||
min-width: 0;
|
||
}
|
||
|
||
.margin-label {
|
||
font-size: 0.72rem;
|
||
color: var(--color-text);
|
||
font-family: var(--font-sans);
|
||
}
|
||
|
||
.margin-input-wrap {
|
||
display: flex;
|
||
align-items: stretch;
|
||
/* Pin the input to the bottom of its grid cell so a wrapped two-line label
|
||
(e.g. "Footer from bottom") doesn't push its box below the sibling's. */
|
||
margin-top: auto;
|
||
height: 1.9rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius);
|
||
background: var(--color-surface);
|
||
overflow: hidden;
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.margin-input-wrap:hover,
|
||
.margin-input-wrap:focus-within {
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.margin-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
height: 100%;
|
||
padding: 0 0.4rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
font-size: 0.8rem;
|
||
font-family: var(--font-sans);
|
||
outline: none;
|
||
}
|
||
|
||
.margin-steppers {
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-left: 1px solid var(--color-border);
|
||
}
|
||
|
||
.margin-step {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 18px;
|
||
min-width: unset;
|
||
height: 50%;
|
||
padding: 0;
|
||
border: none;
|
||
border-radius: 0;
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.margin-step:first-child {
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.margin-step:hover {
|
||
background: var(--color-btn-hover);
|
||
}
|
||
|
||
.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%;
|
||
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;
|
||
}
|
||
|
||
.debug-btn {
|
||
padding: 0.35rem 0.7rem;
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
border: 1px dashed var(--color-border);
|
||
border-radius: var(--radius);
|
||
font-size: 0.75rem;
|
||
font-family: var(--font-sans);
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
|
||
.debug-btn:hover:not(:disabled) {
|
||
background: var(--color-btn-hover);
|
||
color: var(--color-text);
|
||
}
|
||
</style>
|