diff --git a/src/app/new/NewDeckGallery.tsx b/src/app/new/NewDeckGallery.tsx index ce8156a..db44fb4 100644 --- a/src/app/new/NewDeckGallery.tsx +++ b/src/app/new/NewDeckGallery.tsx @@ -69,17 +69,22 @@ export function NewDeckGallery() { const router = useRouter(); const create = async (preset: TemplatePreset) => { - const deck = await createDeck({ - source: preset.seed, - theme: { - styleId: preset.styleId, - paletteId: preset.paletteId, - density: preset.density, - mode: preset.mode, - }, - templateName: preset.id === 'blank' ? undefined : preset.name, - }); - router.push(`/d/${deck.id}/edit`); + try { + const deck = await createDeck({ + source: preset.seed, + theme: { + styleId: preset.styleId, + paletteId: preset.paletteId, + density: preset.density, + mode: preset.mode, + }, + templateName: preset.id === 'blank' ? undefined : preset.name, + }); + router.push(`/d/${deck.id}/edit`); + } catch (err) { + const message = err instanceof Error ? err.message : 'Could not create deck'; + window.alert(`Could not create the deck: ${message}`); + } }; const totalChoices = TEMPLATE_PRESETS.length + 1; diff --git a/src/app/templates/TemplatesGallery.tsx b/src/app/templates/TemplatesGallery.tsx index c3e2cbc..f7df2d9 100644 --- a/src/app/templates/TemplatesGallery.tsx +++ b/src/app/templates/TemplatesGallery.tsx @@ -24,17 +24,22 @@ export function TemplatesGallery() { const router = useRouter(); const applyTemplate = async (preset: TemplatePreset) => { - const deck = await createDeck({ - source: preset.seed, - theme: { - styleId: preset.styleId, - paletteId: preset.paletteId, - density: preset.density, - mode: preset.mode, - }, - templateName: preset.name, - }); - router.push(`/d/${deck.id}/edit`); + try { + const deck = await createDeck({ + source: preset.seed, + theme: { + styleId: preset.styleId, + paletteId: preset.paletteId, + density: preset.density, + mode: preset.mode, + }, + templateName: preset.name, + }); + router.push(`/d/${deck.id}/edit`); + } catch (err) { + const message = err instanceof Error ? err.message : 'Could not create deck'; + window.alert(`Could not create the deck: ${message}`); + } }; return ( diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 9c6f6f5..32e6116 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -7,7 +7,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ParseError, parseDeck } from '@/ir/parse'; import { planDeck } from '@/ir/plan'; import { reorderSlide } from '@/ir/source-edit'; -import { replaceHeadingOccurrence, type EditableKind } from '@/ir/text-edit'; import { lintColors } from '@/render/lint'; import { resolveTheme } from '@/render/theme-resolver'; import type { Brand, Deck, Density, Mode, ThemeRef } from '@/ir/schema'; @@ -184,10 +183,6 @@ export function Editor({ deckId }: Props) { setSelectedSlide(to); }, []); - const handleHeadingEdit = useCallback((kind: EditableKind, index: number, nextText: string) => { - setSource((s) => replaceHeadingOccurrence(s, kind, index, nextText)); - }, []); - const handleInsert = useCallback((snippet: string) => { insertRef.current?.(snippet); }, []); @@ -358,7 +353,6 @@ export function Editor({ deckId }: Props) { selectedSlide={selectedSlide} onSelectSlide={handleSelectSlide} onReorderSlide={handleReorderSlide} - onHeadingEdit={handleHeadingEdit} /> ) : (
@@ -471,13 +465,11 @@ function PreviewStage({ selectedSlide, onSelectSlide, onReorderSlide, - onHeadingEdit, }: { deck: Deck; selectedSlide: number; onSelectSlide: (i: number) => void; onReorderSlide: (from: number, to: number) => void; - onHeadingEdit: (kind: EditableKind, index: number, nextText: string) => void; }) { const total = deck.slides.length; const safeIndex = Math.min(Math.max(selectedSlide, 0), Math.max(total - 1, 0)); @@ -502,88 +494,10 @@ function PreviewStage({ return () => window.removeEventListener('keydown', onKey); }, [safeIndex, total, onSelectSlide]); - const slideRef = useRef(null); - - const beginEdit = useCallback( - (el: HTMLElement) => { - const tag = el.tagName.toLowerCase(); - if (!/^h[1-4]$/.test(tag)) return; - // Compute occurrence index across the FULL deck, not just the visible - // slide. We render slides individually, so we have to look through deck. - const kind = tag as EditableKind; - const targetText = el.textContent ?? ''; - let occurrence = 0; - const targetLevel = Number(kind.slice(1)); - outer: for (let s = 0; s < deck.slides.length; s++) { - const blocks = deck.slides[s].blocks; - const stack: typeof blocks = [...blocks]; - while (stack.length > 0) { - const b = stack.shift()!; - if (b.type === 'heading') { - if (b.level === targetLevel) { - if (s === safeIndex && b.text === targetText) { - break outer; - } - occurrence++; - } - } else if (b.type === 'box') { - stack.unshift(...b.children); - } else if (b.type === 'columns') { - stack.unshift(...b.columns.flat()); - } else if (b.type === 'grid' || b.type === 'cell') { - stack.unshift(...b.children); - } - } - } - el.contentEditable = 'true'; - el.classList.add('preview-editable'); - el.focus(); - const range = document.createRange(); - range.selectNodeContents(el); - const sel = window.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(range); - - const finish = () => { - el.removeEventListener('blur', finish); - el.removeEventListener('keydown', onKey); - el.contentEditable = 'false'; - el.classList.remove('preview-editable'); - const next = (el.textContent ?? '').trim(); - if (next && next !== targetText) onHeadingEdit(kind, occurrence, next); - }; - const onKey = (ke: KeyboardEvent) => { - if (ke.key === 'Enter' && !ke.shiftKey) { - ke.preventDefault(); - el.blur(); - } else if (ke.key === 'Escape') { - ke.preventDefault(); - el.textContent = targetText; - el.blur(); - } - }; - el.addEventListener('blur', finish); - el.addEventListener('keydown', onKey); - }, - [deck, safeIndex, onHeadingEdit], - ); - - const onDoubleClick = useCallback( - (e: React.MouseEvent) => { - const t = e.target as HTMLElement; - const heading = t.closest('h1, h2, h3, h4') as HTMLElement | null; - if (heading && slideRef.current?.contains(heading)) { - e.preventDefault(); - beginEdit(heading); - } - }, - [beginEdit], - ); - return (
-
+
diff --git a/src/editor/editor.css b/src/editor/editor.css index 1d13135..234e369 100644 --- a/src/editor/editor.css +++ b/src/editor/editor.css @@ -1425,22 +1425,6 @@ /* Print --------------------------------------------------------------------------*/ -/* ─── In-preview editing ──────────────────────────────────────────────── */ - -.stage__slide h1, -.stage__slide h2, -.stage__slide h3, -.stage__slide h4 { - cursor: text; -} - -.preview-editable { - outline: 2px solid var(--accent, #6ee7b7); - outline-offset: 4px; - border-radius: 4px; - background: rgba(110, 231, 183, 0.08); -} - /* ─── Export PDF menu ────────────────────────────────────────────────── */ .export-pdf { diff --git a/src/ir/text-edit.ts b/src/ir/text-edit.ts deleted file mode 100644 index a736f25..0000000 --- a/src/ir/text-edit.ts +++ /dev/null @@ -1,53 +0,0 @@ -import matter from 'gray-matter'; - -const HEADING_RE = /^(#{1,4})\s+(.+?)\s*$/; - -export type EditableKind = 'h1' | 'h2' | 'h3' | 'h4'; - -function levelOf(kind: EditableKind): number { - return Number(kind.slice(1)); -} - -/** - * Replace the n-th occurrence (0-indexed) of a heading at the given level in - * `source` with `nextText`. Frontmatter and code fences are skipped. Pure - * function: same input, same output. - */ -export function replaceHeadingOccurrence( - source: string, - kind: EditableKind, - index: number, - nextText: string, -): string { - const fm = matter(source); - const prefix = source.slice(0, source.length - fm.content.length); - const lines = fm.content.split('\n'); - const target = levelOf(kind); - let inFence = false; - let occurrence = 0; - const out: string[] = []; - for (const line of lines) { - const fence = /^\s*```/.test(line); - if (fence) { - inFence = !inFence; - out.push(line); - continue; - } - if (inFence) { - out.push(line); - continue; - } - const m = HEADING_RE.exec(line); - if (m && m[1].length === target) { - if (occurrence === index) { - const safe = nextText.replace(/\r?\n+/g, ' ').trim(); - out.push(`${m[1]} ${safe}`); - occurrence++; - continue; - } - occurrence++; - } - out.push(line); - } - return prefix + out.join('\n'); -} diff --git a/src/library/DeckLibrary.tsx b/src/library/DeckLibrary.tsx index 4e94d2a..6dcc264 100644 --- a/src/library/DeckLibrary.tsx +++ b/src/library/DeckLibrary.tsx @@ -34,11 +34,30 @@ export function DeckLibrary() { const [query, setQuery] = useState(''); const [sort, setSort] = useState('recent'); + const [loadError, setLoadError] = useState(null); + useEffect(() => { - listDecks().then(setDecks); + listDecks() + .then((d) => { + setDecks(d); + setLoadError(null); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : 'Could not load decks'; + setLoadError(message); + setDecks([]); + }); }, []); - const refresh = async () => setDecks(await listDecks()); + const refresh = async () => { + try { + setDecks(await listDecks()); + setLoadError(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Could not load decks'; + setLoadError(message); + } + }; const visibleDecks = useMemo(() => { if (!decks) return null; @@ -83,7 +102,11 @@ export function DeckLibrary() { /> - {decks === null ? ( + {loadError ? ( + + {loadError} + + ) : decks === null ? ( Loading decks diff --git a/src/storage/db.ts b/src/storage/db.ts index 8662d19..85fc827 100644 --- a/src/storage/db.ts +++ b/src/storage/db.ts @@ -12,8 +12,18 @@ let dbPromise: Promise | null = null; function openDb(): Promise { if (dbPromise) return dbPromise; - dbPromise = new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, DB_VERSION); + dbPromise = new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject(new Error('IndexedDB is not available in this environment')); + return; + } + let req: IDBOpenDBRequest; + try { + req = indexedDB.open(DB_NAME, DB_VERSION); + } catch (err) { + reject(err as Error); + return; + } req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE_DECKS)) { @@ -31,8 +41,23 @@ function openDb(): Promise { store.createIndex('createdAt', 'createdAt'); } }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); + req.onsuccess = () => { + const db = req.result; + // Let other tabs upgrade us out by closing our connection on demand. + db.onversionchange = () => db.close(); + resolve(db); + }; + req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed')); + req.onblocked = () => + reject( + new Error( + 'IndexedDB upgrade blocked by another open tab. Close other tabs of this app and reload.', + ), + ); + }); + // Don't poison future calls if this one rejects. + dbPromise.catch(() => { + dbPromise = null; }); return dbPromise; } diff --git a/tests/ir/text-edit.test.ts b/tests/ir/text-edit.test.ts deleted file mode 100644 index b8fefd4..0000000 --- a/tests/ir/text-edit.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { replaceHeadingOccurrence } from '@/ir/text-edit'; - -describe('replaceHeadingOccurrence', () => { - const sample = `--- -title: Demo ---- - -# A -text - -::slide - -# B -text - -::slide - -## subsection -`; - - it('replaces the n-th h1 occurrence by index', () => { - const out = replaceHeadingOccurrence(sample, 'h1', 1, 'B prime'); - expect(out).toContain('# B prime'); - expect(out).toContain('# A'); - }); - - it('does not replace headings of a different level', () => { - const out = replaceHeadingOccurrence(sample, 'h2', 0, 'Renamed'); - expect(out).toContain('## Renamed'); - expect(out).toContain('# A'); - }); - - it('preserves frontmatter', () => { - const out = replaceHeadingOccurrence(sample, 'h1', 0, 'Hello'); - expect(out).toMatch(/^---\ntitle: Demo\n---/); - }); - - it('skips headings inside code fences', () => { - const src = '\n```\n# fake\n```\n\n# real\n'; - const out = replaceHeadingOccurrence(src, 'h1', 0, 'edited'); - expect(out).toContain('# fake'); - expect(out).toContain('# edited'); - }); - - it('strips embedded newlines from new text', () => { - const out = replaceHeadingOccurrence(sample, 'h1', 0, 'A\nbroken'); - expect(out).toContain('# A broken'); - }); -});