diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index dc46638..da34de9 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -7,6 +7,7 @@ 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'; @@ -183,6 +184,10 @@ 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); }, []); @@ -347,6 +352,7 @@ export function Editor({ deckId }: Props) { selectedSlide={selectedSlide} onSelectSlide={handleSelectSlide} onReorderSlide={handleReorderSlide} + onHeadingEdit={handleHeadingEdit} /> ) : (
@@ -459,11 +465,13 @@ 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)); @@ -488,10 +496,88 @@ 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 a331f48..a0f69b4 100644 --- a/src/editor/editor.css +++ b/src/editor/editor.css @@ -1425,6 +1425,22 @@ /* 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 new file mode 100644 index 0000000..a736f25 --- /dev/null +++ b/src/ir/text-edit.ts @@ -0,0 +1,53 @@ +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/tests/ir/text-edit.test.ts b/tests/ir/text-edit.test.ts new file mode 100644 index 0000000..b8fefd4 --- /dev/null +++ b/tests/ir/text-edit.test.ts @@ -0,0 +1,51 @@ +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'); + }); +});