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