Skip to content

Commit 6da202f

Browse files
committed
feat: in-preview editing for headings (double-click to edit)
1 parent c61d3b5 commit 6da202f

4 files changed

Lines changed: 207 additions & 1 deletion

File tree

src/editor/Editor.tsx

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
77
import { ParseError, parseDeck } from '@/ir/parse';
88
import { planDeck } from '@/ir/plan';
99
import { reorderSlide } from '@/ir/source-edit';
10+
import { replaceHeadingOccurrence, type EditableKind } from '@/ir/text-edit';
1011
import { lintColors } from '@/render/lint';
1112
import { resolveTheme } from '@/render/theme-resolver';
1213
import type { Brand, Deck, Density, Mode, ThemeRef } from '@/ir/schema';
@@ -183,6 +184,10 @@ export function Editor({ deckId }: Props) {
183184
setSelectedSlide(to);
184185
}, []);
185186

187+
const handleHeadingEdit = useCallback((kind: EditableKind, index: number, nextText: string) => {
188+
setSource((s) => replaceHeadingOccurrence(s, kind, index, nextText));
189+
}, []);
190+
186191
const handleInsert = useCallback((snippet: string) => {
187192
insertRef.current?.(snippet);
188193
}, []);
@@ -347,6 +352,7 @@ export function Editor({ deckId }: Props) {
347352
selectedSlide={selectedSlide}
348353
onSelectSlide={handleSelectSlide}
349354
onReorderSlide={handleReorderSlide}
355+
onHeadingEdit={handleHeadingEdit}
350356
/>
351357
) : (
352358
<div className="editor__error">
@@ -459,11 +465,13 @@ function PreviewStage({
459465
selectedSlide,
460466
onSelectSlide,
461467
onReorderSlide,
468+
onHeadingEdit,
462469
}: {
463470
deck: Deck;
464471
selectedSlide: number;
465472
onSelectSlide: (i: number) => void;
466473
onReorderSlide: (from: number, to: number) => void;
474+
onHeadingEdit: (kind: EditableKind, index: number, nextText: string) => void;
467475
}) {
468476
const total = deck.slides.length;
469477
const safeIndex = Math.min(Math.max(selectedSlide, 0), Math.max(total - 1, 0));
@@ -488,10 +496,88 @@ function PreviewStage({
488496
return () => window.removeEventListener('keydown', onKey);
489497
}, [safeIndex, total, onSelectSlide]);
490498

499+
const slideRef = useRef<HTMLDivElement>(null);
500+
501+
const beginEdit = useCallback(
502+
(el: HTMLElement) => {
503+
const tag = el.tagName.toLowerCase();
504+
if (!/^h[1-4]$/.test(tag)) return;
505+
// Compute occurrence index across the FULL deck, not just the visible
506+
// slide. We render slides individually, so we have to look through deck.
507+
const kind = tag as EditableKind;
508+
const targetText = el.textContent ?? '';
509+
let occurrence = 0;
510+
const targetLevel = Number(kind.slice(1));
511+
outer: for (let s = 0; s < deck.slides.length; s++) {
512+
const blocks = deck.slides[s].blocks;
513+
const stack: typeof blocks = [...blocks];
514+
while (stack.length > 0) {
515+
const b = stack.shift()!;
516+
if (b.type === 'heading') {
517+
if (b.level === targetLevel) {
518+
if (s === safeIndex && b.text === targetText) {
519+
break outer;
520+
}
521+
occurrence++;
522+
}
523+
} else if (b.type === 'box') {
524+
stack.unshift(...b.children);
525+
} else if (b.type === 'columns') {
526+
stack.unshift(...b.columns.flat());
527+
} else if (b.type === 'grid' || b.type === 'cell') {
528+
stack.unshift(...b.children);
529+
}
530+
}
531+
}
532+
el.contentEditable = 'true';
533+
el.classList.add('preview-editable');
534+
el.focus();
535+
const range = document.createRange();
536+
range.selectNodeContents(el);
537+
const sel = window.getSelection();
538+
sel?.removeAllRanges();
539+
sel?.addRange(range);
540+
541+
const finish = () => {
542+
el.removeEventListener('blur', finish);
543+
el.removeEventListener('keydown', onKey);
544+
el.contentEditable = 'false';
545+
el.classList.remove('preview-editable');
546+
const next = (el.textContent ?? '').trim();
547+
if (next && next !== targetText) onHeadingEdit(kind, occurrence, next);
548+
};
549+
const onKey = (ke: KeyboardEvent) => {
550+
if (ke.key === 'Enter' && !ke.shiftKey) {
551+
ke.preventDefault();
552+
el.blur();
553+
} else if (ke.key === 'Escape') {
554+
ke.preventDefault();
555+
el.textContent = targetText;
556+
el.blur();
557+
}
558+
};
559+
el.addEventListener('blur', finish);
560+
el.addEventListener('keydown', onKey);
561+
},
562+
[deck, safeIndex, onHeadingEdit],
563+
);
564+
565+
const onDoubleClick = useCallback(
566+
(e: React.MouseEvent<HTMLDivElement>) => {
567+
const t = e.target as HTMLElement;
568+
const heading = t.closest('h1, h2, h3, h4') as HTMLElement | null;
569+
if (heading && slideRef.current?.contains(heading)) {
570+
e.preventDefault();
571+
beginEdit(heading);
572+
}
573+
},
574+
[beginEdit],
575+
);
576+
491577
return (
492578
<div className="stage">
493579
<div className="stage__viewport">
494-
<div className="stage__slide">
580+
<div className="stage__slide" ref={slideRef} onDoubleClick={onDoubleClick}>
495581
<DeckRenderer deck={visibleDeck} />
496582
</div>
497583
</div>

src/editor/editor.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,6 +1425,22 @@
14251425
/* Print
14261426
--------------------------------------------------------------------------*/
14271427

1428+
/* ─── In-preview editing ──────────────────────────────────────────────── */
1429+
1430+
.stage__slide h1,
1431+
.stage__slide h2,
1432+
.stage__slide h3,
1433+
.stage__slide h4 {
1434+
cursor: text;
1435+
}
1436+
1437+
.preview-editable {
1438+
outline: 2px solid var(--accent, #6ee7b7);
1439+
outline-offset: 4px;
1440+
border-radius: 4px;
1441+
background: rgba(110, 231, 183, 0.08);
1442+
}
1443+
14281444
/* ─── Export PDF menu ────────────────────────────────────────────────── */
14291445

14301446
.export-pdf {

src/ir/text-edit.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import matter from 'gray-matter';
2+
3+
const HEADING_RE = /^(#{1,4})\s+(.+?)\s*$/;
4+
5+
export type EditableKind = 'h1' | 'h2' | 'h3' | 'h4';
6+
7+
function levelOf(kind: EditableKind): number {
8+
return Number(kind.slice(1));
9+
}
10+
11+
/**
12+
* Replace the n-th occurrence (0-indexed) of a heading at the given level in
13+
* `source` with `nextText`. Frontmatter and code fences are skipped. Pure
14+
* function: same input, same output.
15+
*/
16+
export function replaceHeadingOccurrence(
17+
source: string,
18+
kind: EditableKind,
19+
index: number,
20+
nextText: string,
21+
): string {
22+
const fm = matter(source);
23+
const prefix = source.slice(0, source.length - fm.content.length);
24+
const lines = fm.content.split('\n');
25+
const target = levelOf(kind);
26+
let inFence = false;
27+
let occurrence = 0;
28+
const out: string[] = [];
29+
for (const line of lines) {
30+
const fence = /^\s*```/.test(line);
31+
if (fence) {
32+
inFence = !inFence;
33+
out.push(line);
34+
continue;
35+
}
36+
if (inFence) {
37+
out.push(line);
38+
continue;
39+
}
40+
const m = HEADING_RE.exec(line);
41+
if (m && m[1].length === target) {
42+
if (occurrence === index) {
43+
const safe = nextText.replace(/\r?\n+/g, ' ').trim();
44+
out.push(`${m[1]} ${safe}`);
45+
occurrence++;
46+
continue;
47+
}
48+
occurrence++;
49+
}
50+
out.push(line);
51+
}
52+
return prefix + out.join('\n');
53+
}

tests/ir/text-edit.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { replaceHeadingOccurrence } from '@/ir/text-edit';
4+
5+
describe('replaceHeadingOccurrence', () => {
6+
const sample = `---
7+
title: Demo
8+
---
9+
10+
# A
11+
text
12+
13+
::slide
14+
15+
# B
16+
text
17+
18+
::slide
19+
20+
## subsection
21+
`;
22+
23+
it('replaces the n-th h1 occurrence by index', () => {
24+
const out = replaceHeadingOccurrence(sample, 'h1', 1, 'B prime');
25+
expect(out).toContain('# B prime');
26+
expect(out).toContain('# A');
27+
});
28+
29+
it('does not replace headings of a different level', () => {
30+
const out = replaceHeadingOccurrence(sample, 'h2', 0, 'Renamed');
31+
expect(out).toContain('## Renamed');
32+
expect(out).toContain('# A');
33+
});
34+
35+
it('preserves frontmatter', () => {
36+
const out = replaceHeadingOccurrence(sample, 'h1', 0, 'Hello');
37+
expect(out).toMatch(/^---\ntitle: Demo\n---/);
38+
});
39+
40+
it('skips headings inside code fences', () => {
41+
const src = '\n```\n# fake\n```\n\n# real\n';
42+
const out = replaceHeadingOccurrence(src, 'h1', 0, 'edited');
43+
expect(out).toContain('# fake');
44+
expect(out).toContain('# edited');
45+
});
46+
47+
it('strips embedded newlines from new text', () => {
48+
const out = replaceHeadingOccurrence(sample, 'h1', 0, 'A\nbroken');
49+
expect(out).toContain('# A broken');
50+
});
51+
});

0 commit comments

Comments
 (0)