This commit is contained in:
Reudy 2026-09-26 19:13:02 +02:00
commit 0ad405c26e
78 changed files with 9127 additions and 0 deletions

View file

@ -0,0 +1,754 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import DownloadIcon from '@lucide/svelte/icons/download';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import TrashIcon from '@lucide/svelte/icons/trash-2';
import CheckIcon from '@lucide/svelte/icons/check';
import type { CanvasDoc } from '$lib/state/doc.svelte';
import { viewport } from '$lib/state/viewport.svelte';
import { tools } from '$lib/state/tools.svelte';
import { settings } from '$lib/state/settings.svelte';
import { workspace } from '$lib/state/workspace.svelte';
import { menu, type MenuItem } from '$lib/state/menu.svelte';
import { dirName, displayName } from '$lib/storage/vault.svelte';
import { bounds, intersects, rectBeside, type Rect } from '$lib/model/layout';
import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree';
import { PAGE_PRESETS, type Stroke } from '$lib/model/types';
import { PalmRejector } from '$lib/ink/palm';
import { StrokeRecorder, hitStroke } from '$lib/ink/stroke';
import { pageCssVars } from '$lib/editor/pageStyle';
import { downloadCanvas } from './download';
import PageView from './PageView.svelte';
import Toolbar from './Toolbar.svelte';
let { doc }: { doc: CanvasDoc } = $props();
let stage: HTMLDivElement;
const cssVars = pageCssVars();
// ---- autosave --------------------------------------------------------
$effect(() => {
if (doc.version > 0) untrack(() => workspace.scheduleSave());
});
// ---- viewport --------------------------------------------------------
onMount(() => {
const ro = new ResizeObserver(() => {
const r = stage.getBoundingClientRect();
const first = viewport.width === 0;
viewport.width = r.width;
viewport.height = r.height;
if (first) fitPage(workspace.focusPage ?? doc.activeId);
});
viewport.width = 0;
ro.observe(stage);
stage.addEventListener('wheel', onWheel, { passive: false });
return () => {
ro.disconnect();
stage.removeEventListener('wheel', onWheel);
};
});
// Search hits etc. ask for a page to be revealed.
$effect(() => {
const id = workspace.focusPage;
if (!id || !viewport.width) return;
untrack(() => {
if (doc.pages[id]) {
doc.activeId = id;
fitPage(id);
}
workspace.focusPage = null;
});
});
function fitPage(id: string | null) {
const r = (id && doc.rectOf(id)) || doc.rectOf(doc.order[0]);
if (r) viewport.fit(r);
}
function fitAll() {
const b = bounds(doc.rects.values());
if (b) viewport.fit(b, 48);
}
const visible = $derived.by(() => {
const w = viewport.world;
// One extra screen of margin around the view so panning doesn't pop.
const area = { x: w.x - w.width, y: w.y - w.height, width: w.width * 3, height: w.height * 3 };
return doc.order.filter((id) => {
const r = doc.rects.get(id);
return r && intersects(r, area);
});
});
// ---- adding pages ----------------------------------------------------
const ghostSlots = $derived.by((): Slot[] => {
if (tools.placing || !doc.activeId) return [];
return freeSlotsOf(doc.tree, doc.activeId);
});
const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []);
// Show every possible spot when an imported PDF is waiting to be placed.
$effect(() => {
if (!tools.placing) return;
untrack(() => {
const size = tools.placing!.pages[0];
const rects = [...doc.rects.values(), ...placeSlots.map((s) => slotRect(s, size)).filter((r): r is Rect => !!r)];
const b = bounds(rects);
if (b) viewport.fit(b, 48);
});
});
function slotRect(slot: Slot, size?: { width: number; height: number }): Rect | null {
const a = doc.rectOf(slot.anchor);
if (!a) return null;
const page = doc.pages[slot.anchor];
const s = size ?? (page && !page.origin ? { width: page.width, height: page.height } : { width: 595.28, height: 841.89 });
return rectBeside(a, slot.dir, s);
}
/** Run a tree change without the anchor page jumping on screen. */
function keepAnchor(anchor: string, fn: () => void) {
const before = doc.rectOf(anchor);
fn();
const after = doc.rectOf(anchor);
if (before && after) viewport.panBy(-(after.x - before.x) * viewport.scale, -(after.y - before.y) * viewport.scale);
}
function revealPage(id: string) {
const r = doc.rectOf(id);
if (!r) return;
const w = viewport.world;
const inView = r.x >= w.x && r.y >= w.y && r.x + r.width <= w.x + w.width && r.y + r.height <= w.y + w.height;
if (!inView) {
const fits = r.width * viewport.scale <= viewport.width && r.height * viewport.scale <= viewport.height;
if (fits) viewport.center(r);
else viewport.fit(r);
}
}
function addPage(anchor: string, dir: Dir) {
let id: string | null = null;
keepAnchor(anchor, () => (id = doc.addBlankPage(anchor, dir)));
if (id) revealPage(id);
}
function place(slot: Slot) {
const p = tools.placing;
if (!p) return;
let ok = false;
keepAnchor(slot.anchor, () => (ok = doc.insertPages(slot.anchor, slot.dir, p.pages)));
if (ok) {
tools.placing = null;
revealPage(p.pages[0].id);
workspace.toast(`Placed ${p.pages.length} page${p.pages.length === 1 ? '' : 's'} from ${p.name}`);
}
}
function go(dir: Dir) {
const id = doc.activeId;
if (!id) return;
const next = neighbor(doc.tree, id, dir);
if (next) {
doc.activeId = next;
revealPage(next);
} else if (canInsert(doc.tree, id, dir)) addPage(id, dir);
}
// ---- page menu -------------------------------------------------------
function pageMenu(x: number, y: number, id: string) {
const page = doc.pages[id];
if (!page) return;
doc.activeId = id;
const dirs: [Dir, string][] = [
['up', 'Insert page above'],
['down', 'Insert page below'],
['left', 'Insert page left'],
['right', 'Insert page right']
];
const items: MenuItem[] = dirs.map(([dir, label]) => ({
label,
disabled: !canInsert(doc.tree, id, dir),
action: () => addPage(id, dir)
}));
if (!page.origin) {
items.push('separator');
for (const p of PAGE_PRESETS) {
const current = Math.abs(p.width - page.width) < 0.5 && Math.abs(p.height - page.height) < 0.5;
items.push({
label: p.label,
icon: current ? CheckIcon : undefined,
hint: `${Math.round(p.width)}×${Math.round(p.height)}`,
action: () => keepAnchor(id, () => doc.resizePage(id, p.width, p.height))
});
}
items.push({ label: 'Custom size…', action: () => customSize(id) });
}
items.push('separator', { label: 'Delete page', icon: TrashIcon, danger: true, action: () => doc.deletePage(id) });
menu.show(x, y, items);
}
function customSize(id: string) {
const page = doc.pages[id];
const mm = (pt: number) => Math.round((pt / 72) * 25.4);
const answer = prompt('Page size in millimetres (width × height)', `${mm(page.width)} × ${mm(page.height)}`);
const m = answer && /^\s*(\d+(?:\.\d+)?)\s*[x×*,\s]\s*(\d+(?:\.\d+)?)\s*$/i.exec(answer);
if (!m) return;
const pt = (v: string) => Math.min(5000, Math.max(50, (parseFloat(v) / 25.4) * 72));
keepAnchor(id, () => doc.resizePage(id, pt(m[1]), pt(m[2])));
}
const activeMenuPos = $derived.by(() => {
const id = doc.activeId;
const r = id && doc.rects.get(id);
if (!r || tools.placing) return null;
const p = viewport.toScreen(r.x + r.width, r.y);
return { x: p.x + 6, y: p.y };
});
// ---- input -----------------------------------------------------------
const palm = new PalmRejector(settings.data.palm);
$effect(() => {
palm.options = settings.data.palm;
});
type Gesture =
| { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder }
| { kind: 'erase'; pointerId: number; pointerType: string; hits: Map<string, Set<string>> }
| { kind: 'pan'; pointerId: number; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean }
| { kind: 'pinch' };
let gesture: Gesture | null = null;
const touches = new Map<number, { x: number; y: number }>();
let pinchLast: { dist: number; mx: number; my: number } | null = null;
let spaceDown = $state(false);
// Reactive bits for rendering in-progress ink.
let live = $state.raw<{ pageId: string; stroke: Stroke } | null>(null);
let tick = $state(0);
let erasing = $state.raw<Set<string>>(new Set());
let eraserAt = $state<{ x: number; y: number } | null>(null);
function local(e: PointerEvent | MouseEvent) {
const r = stage.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function pageAt(sx: number, sy: number): string | null {
const w = viewport.toWorld(sx, sy);
for (const id of visible) {
const r = doc.rects.get(id)!;
if (w.x >= r.x && w.x <= r.x + r.width && w.y >= r.y && w.y <= r.y + r.height) return id;
}
return null;
}
function pagePoint(pageId: string, sx: number, sy: number) {
const r = doc.rects.get(pageId)!;
const w = viewport.toWorld(sx, sy);
return { x: w.x - r.x, y: w.y - r.y };
}
/** Pointer capture can throw if the pointer is already gone; drawing still works without it. */
function capture(id: number) {
try {
stage.setPointerCapture(id);
} catch {
/* ignore */
}
}
const isEraserButton = (e: PointerEvent) => e.pointerType === 'pen' && (e.button === 5 || (e.buttons & 32) !== 0);
function onPointerDown(e: PointerEvent) {
palm.track(e);
if (menu.open) menu.close();
const target = e.target as Element;
if (target.closest('.no-stage')) return;
const p = local(e);
if (e.pointerType === 'touch') {
const decision = palm.evaluate(e);
if (!decision.accept) return; // palm: ignore entirely
touches.set(e.pointerId, p);
if (touches.size >= 2) {
cancelGesture();
gesture = { kind: 'pinch' };
pinchLast = pinchState();
return;
}
}
const onPage = pageAt(p.x, p.y);
const inEditor = !!target.closest('.cm-editor');
// Pressing anywhere outside the text leaves the editor (preventDefault
// below would otherwise keep focus in it).
if (!inEditor) (document.activeElement as HTMLElement | null)?.blur?.();
// Panning: middle mouse, space+drag, or background drags.
const wantsPan =
e.button === 1 ||
spaceDown ||
(e.pointerType === 'touch' && (!tools.inking || !settings.data.fingerDraw)) ||
(e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.ghost, .slot'));
if (wantsPan) {
if (onPage && !inEditor) doc.activeId = onPage;
gesture = { kind: 'pan', pointerId: e.pointerId, lastX: p.x, lastY: p.y, startX: p.x, startY: p.y, moved: false, tap: inEditor || !!target.closest('.ghost, .slot') };
if (e.pointerType !== 'touch' || !gesture.tap) {
if (e.pointerType !== 'touch') e.preventDefault();
capture(e.pointerId);
}
return;
}
if (onPage) doc.activeId = onPage;
if (e.button !== 0 && e.pointerType === 'mouse') return;
const eraser = tools.tool === 'eraser' || isEraserButton(e);
if ((tools.inking || isEraserButton(e)) && onPage) {
e.preventDefault();
capture(e.pointerId);
if (eraser) {
gesture = { kind: 'erase', pointerId: e.pointerId, pointerType: e.pointerType, hits: new Map() };
erasing = new Set();
eraseAt(e);
} else {
const tool = tools.tool === 'highlighter' ? 'highlighter' : 'pen';
const rec = new StrokeRecorder(tool, tools.color, tools.width, e.pointerType === 'pen');
const pt = pagePoint(onPage, p.x, p.y);
rec.recordPoint(pt.x, pt.y, e);
gesture = { kind: 'draw', pointerId: e.pointerId, pointerType: e.pointerType, pageId: onPage, rec };
live = { pageId: onPage, stroke: rec.stroke };
tick++;
}
}
}
function onPointerMove(e: PointerEvent) {
palm.track(e);
const p = local(e);
if (tools.tool === 'eraser' || isEraserButton(e)) eraserAt = p;
if (e.pointerType === 'touch' && touches.has(e.pointerId)) {
touches.set(e.pointerId, p);
if (gesture?.kind === 'pinch') {
const s = pinchState();
if (s && pinchLast) {
viewport.zoomAt(s.dist / pinchLast.dist, s.mx, s.my);
viewport.panBy(s.mx - pinchLast.mx, s.my - pinchLast.my);
}
pinchLast = s;
return;
}
}
if (!gesture || gesture.kind === 'pinch' || gesture.pointerId !== e.pointerId) return;
if (gesture.kind === 'pan') {
const dx = p.x - gesture.lastX;
const dy = p.y - gesture.lastY;
if (!gesture.moved && Math.hypot(p.x - gesture.startX, p.y - gesture.startY) < 8) return;
if (!gesture.moved && gesture.tap) capture(e.pointerId);
gesture.moved = true;
viewport.panBy(dx, dy);
gesture.lastX = p.x;
gesture.lastY = p.y;
return;
}
// A touch stroke is cut off as soon as the palm policy rejects it
// (e.g. the pen just came into range).
if (gesture.pointerType === 'touch' && !palm.evaluate(e).accept) {
cancelGesture();
return;
}
if (gesture.kind === 'erase') {
eraseAt(e);
return;
}
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
for (const ev of events.length ? events : [e]) {
const q = local(ev);
const pt = pagePoint(gesture.pageId, q.x, q.y);
gesture.rec.recordPoint(pt.x, pt.y, ev);
}
tick++;
}
function onPointerUp(e: PointerEvent) {
palm.track(e);
touches.delete(e.pointerId);
if (gesture?.kind === 'pinch') {
if (touches.size < 2) {
gesture = null;
pinchLast = null;
}
return;
}
if (!gesture || gesture.pointerId !== e.pointerId) return;
const g = gesture;
gesture = null;
if (g.kind === 'draw') {
live = null;
if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke);
} else if (g.kind === 'erase') {
for (const [pageId, ids] of g.hits) doc.setStrokes(pageId, (s) => s.filter((x) => !ids.has(x.id)));
erasing = new Set();
}
}
function cancelGesture() {
if (gesture?.kind === 'draw') live = null;
if (gesture?.kind === 'erase') erasing = new Set();
gesture = null;
}
function eraseAt(e: PointerEvent) {
if (gesture?.kind !== 'erase') return;
const p = local(e);
const pageId = pageAt(p.x, p.y);
if (!pageId) return;
const pt = pagePoint(pageId, p.x, p.y);
const r = tools.eraserRadius / viewport.scale;
let set = gesture.hits.get(pageId);
let changed = false;
for (const s of doc.pages[pageId].strokes) {
if (set?.has(s.id) || !hitStroke(s, pt.x, pt.y, r)) continue;
if (!set) gesture.hits.set(pageId, (set = new Set()));
set.add(s.id);
changed = true;
}
if (changed) erasing = new Set([...gesture.hits.values()].flatMap((x) => [...x]));
}
function pinchState() {
const pts = [...touches.values()];
if (pts.length < 2) return null;
const [a, b] = pts;
return { dist: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? viewport.height : 1;
const p = local(e);
if (e.ctrlKey || e.metaKey) {
viewport.zoomAt(Math.exp(-e.deltaY * unit * 0.0025), p.x, p.y);
} else if (e.shiftKey && !e.deltaX) {
viewport.panBy(-e.deltaY * unit, 0);
} else {
viewport.panBy(-e.deltaX * unit, -e.deltaY * unit);
}
}
function onContextMenu(e: MouseEvent) {
if ((e.target as Element).closest('.cm-editor') && !tools.inking) return; // native menu for text
e.preventDefault();
const p = local(e);
const id = pageAt(p.x, p.y);
if (id) pageMenu(e.clientX, e.clientY, id);
}
// ---- keyboard --------------------------------------------------------
function editable(t: EventTarget | null) {
const el = t as HTMLElement | null;
return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
}
function onKeyDown(e: KeyboardEvent) {
if (workspace.settingsOpen) return;
const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
if (mod && (k === '=' || k === '+')) return void (e.preventDefault(), viewport.zoomAt(1.25));
if (mod && k === '-') return void (e.preventDefault(), viewport.zoomAt(0.8));
if (mod && k === '0') return void (e.preventDefault(), fitPage(doc.activeId));
if (mod && k === '9') return void (e.preventDefault(), fitAll());
if (editable(e.target)) return;
if (mod && k === 'z' && !e.shiftKey) return void (e.preventDefault(), doc.undo());
if (mod && ((k === 'z' && e.shiftKey) || k === 'y')) return void (e.preventDefault(), doc.redo());
if (e.key === 'Escape') {
if (tools.placing) tools.placing = null;
return;
}
if (e.altKey && e.key.startsWith('Arrow')) {
e.preventDefault();
go(e.key.slice(5).toLowerCase() as Dir);
return;
}
if (e.key === ' ' && !e.repeat) {
spaceDown = true;
e.preventDefault();
return;
}
if (mod || e.altKey) return;
const shortcuts: Record<string, typeof tools.tool> = { t: 'text', p: 'pen', h: 'highlighter', e: 'eraser' };
if (shortcuts[k]) tools.tool = shortcuts[k];
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === ' ') spaceDown = false;
}
const panning = $derived(spaceDown);
</script>
<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onblur={() => (spaceDown = false)} />
<div class="view">
<header class="titlebar">
<div class="crumbs">
{#if dirName(doc.path)}<span class="dir">{dirName(doc.path).replaceAll('/', ' / ')} /</span>{/if}
<span class="name">{displayName(doc.path)}</span>
</div>
<button class="icon-btn" title="Download PDF" onclick={() => downloadCanvas(doc.path)}>
<DownloadIcon size={16} strokeWidth={1.75} />
</button>
</header>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="stage"
class:inking={tools.inking}
class:panning
class:erasing={tools.tool === 'eraser'}
bind:this={stage}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
onpointerleave={(e) => {
palm.track(e);
eraserAt = null;
}}
oncontextmenu={onContextMenu}
>
<div
class="world"
style="transform: translate({viewport.x}px, {viewport.y}px) scale({viewport.scale}); --s: {viewport.scale}; {cssVars}"
>
{#each visible as id (id)}
{@const page = doc.pages[id]}
{@const rect = doc.rects.get(id)}
{#if page && rect}
<PageView
{page}
{rect}
active={doc.activeId === id}
invert={settings.dark && settings.data.pageStyle === 'match'}
scale={viewport.scale}
live={live?.pageId === id ? live.stroke : null}
{tick}
{erasing}
onchange={() => doc.touch()}
onactivate={() => (doc.activeId = id)}
/>
{/if}
{/each}
{#each ghostSlots as slot (slot.anchor + slot.dir)}
{@const r = slotRect(slot)}
{#if r}
<button
class="ghost"
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
title="Add page ({slot.dir})"
onclick={() => addPage(slot.anchor, slot.dir)}
>
<span class="plus" style="--icon: {Math.min(r.width, r.height) * 0.14}px">
<CirclePlusIcon size="100%" strokeWidth={1.25} />
</span>
</button>
{/if}
{/each}
{#if tools.placing}
{#each placeSlots as slot (slot.anchor + slot.dir)}
{@const first = tools.placing.pages[0]}
{@const r = slotRect(slot, first)}
{#if r}
<button
class="slot"
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
onclick={() => place(slot)}
>
<span class="arrow" data-dir={slot.dir} style="--icon: {Math.min(r.width, r.height) * 0.16}px">
<ArrowUpIcon size="100%" strokeWidth={1.25} />
</span>
</button>
{/if}
{/each}
{/if}
</div>
{#if activeMenuPos && doc.activeId}
<button
class="icon-btn page-menu no-stage"
style="left:{activeMenuPos.x}px; top:{activeMenuPos.y}px"
title="Page options"
onclick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
pageMenu(r.left, r.bottom + 4, doc.activeId!);
}}
>
<EllipsisIcon size={16} />
</button>
{/if}
{#if eraserAt && tools.tool === 'eraser'}
<div class="eraser-ring" style="left:{eraserAt.x}px; top:{eraserAt.y}px; width:{tools.eraserRadius * 2}px; height:{tools.eraserRadius * 2}px"></div>
{/if}
{#if tools.placing}
<div class="banner no-stage">
Choose where to place <b>{tools.placing.name}</b> ({tools.placing.pages.length} page{tools.placing.pages.length === 1 ? '' : 's'})
<button class="btn" onclick={() => (tools.placing = null)}>Cancel</button>
</div>
{/if}
</div>
<Toolbar {doc} onfit={() => fitPage(doc.activeId)} onfitall={fitAll} />
</div>
<style>
.view {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.titlebar {
height: 38px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 0 8px;
}
.crumbs {
display: flex;
gap: 6px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.crumbs .dir {
color: var(--faint);
}
.titlebar .icon-btn {
position: absolute;
right: 8px;
}
.stage {
position: relative;
flex: 1;
overflow: hidden;
touch-action: none;
user-select: none;
-webkit-user-select: none;
background: var(--bg-canvas);
}
.stage :global(.cm-content) {
user-select: text;
-webkit-user-select: text;
}
.stage.inking {
cursor: crosshair;
}
.stage.erasing {
cursor: none;
}
.stage.inking :global(.page .md),
.stage.panning :global(.page .md) {
pointer-events: none;
}
.stage.panning {
cursor: grab;
}
.world {
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
}
.ghost,
.slot {
position: absolute;
display: grid;
place-items: center;
background: var(--ghost);
border: calc(1px / var(--s)) dashed var(--ghost-border);
border-radius: calc(4px / var(--s));
color: var(--faint);
cursor: pointer;
padding: 0;
transition: background 0.12s, color 0.12s;
}
.ghost:hover,
.slot:hover {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.slot {
border-style: solid;
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
.plus,
.arrow {
display: grid;
width: var(--icon);
height: var(--icon);
}
.arrow[data-dir='down'] {
transform: rotate(180deg);
}
.arrow[data-dir='left'] {
transform: rotate(-90deg);
}
.arrow[data-dir='right'] {
transform: rotate(90deg);
}
.page-menu {
position: absolute;
width: 26px;
height: 26px;
background: var(--bg);
box-shadow: var(--shadow);
}
.eraser-ring {
position: absolute;
transform: translate(-50%, -50%);
border: 1.5px solid var(--muted);
border-radius: 50%;
pointer-events: none;
}
.banner {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 6px 6px 6px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
white-space: nowrap;
}
</style>