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');
- });
-});