"use client"; /** * ProseEditor — shared-toolbar rich text editor * * Architecture: * - ProseToolbarProvider wraps the editing zone and holds shared state * - SharedProseToolbar rendered ONCE; operates on whichever editor has focus * - ProseEditor individual editable field; no own toolbar * * Features: * Bold · Italic · Underline · H1/H2/H3 · Block-quote · Lists · Indent * Find & Replace · Floating selection mini-toolbar · Live word/char count * Keyboard shortcuts: ⌘B/I/U · ⌘Z/Y · ⌘H · Tab/Shift-Tab * Markdown triggers: "# ", "## ", "### ", "> ", "- ", "1. " + Space */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; // ─── Types ──────────────────────────────────────────────────────────────────── export type ProseEditorProps = { /** Current markdown / plain-text value (pipeline format) */ value: string; /** Called on every change with the new plain-text / markdown value */ onChange: (value: string) => void; /** Label shown above the toolbar */ label?: string; /** Placeholder text shown when the editor is empty */ placeholder?: string; /** Approximate content rows to size the editor initially */ rows?: number; /** Class names appended to the outer wrapper */ className?: string; }; // ─── Helpers: convert pipeline prose ↔ HTML ────────────────────────────────── /** * Convert the pipeline's plain-text / markdown-lite format to HTML for editing. * Pipeline prose uses: * - Double newline: paragraph break * - > ... lines: blockquote * - *"..."* (reference) : italic scripture inline (kept as-is in ) * - **text**: bold * - *text*: italic (when not a scripture pattern) */ function textToHtml(text: string): string { if (!text) return "


"; // Split into paragraph-level chunks on double newlines const chunks = text.split(/\n{2,}/); return chunks .map((chunk) => { const trimmed = chunk.trim(); if (!trimmed) return ""; // Blockquote lines (> prefix) if (trimmed.startsWith("> ") || trimmed.startsWith(">")) { const inner = trimmed.replace(/^>\s?/, "").trim(); return `
${inlineToHtml(inner)}
`; } // Headings if (trimmed.startsWith("### ")) return `

${inlineToHtml(trimmed.slice(4))}

`; if (trimmed.startsWith("## ")) return `

${inlineToHtml(trimmed.slice(3))}

`; if (trimmed.startsWith("# ")) return `

${inlineToHtml(trimmed.slice(2))}

`; // Unordered list items (- item or • item) if (/^[-•]\s/.test(trimmed)) { return ``; } // Ordered list items (1. item) if (/^\d+\.\s/.test(trimmed)) { return `
    ${trimmed .split(/\n/) .map((l) => l.replace(/^\d+\.\s/, "").trim()) .filter(Boolean) .map((l) => `
  1. ${inlineToHtml(l)}
  2. `) .join("")}
`; } return `

${inlineToHtml(trimmed)}

`; }) .filter(Boolean) .join("") || "


"; } /** Handle inline markdown within a block (bold, italic, scripture em) */ function inlineToHtml(text: string): string { return text // Bold: **text** .replace(/\*\*(.+?)\*\*/g, "$1") // Scripture inline italic: *"text"* (reference) .replace(/\*(".*?")\*/g, "$1") // Plain italic: *text* .replace(/\*([^*]+)\*/g, "$1") // Underline: __text__ (non-standard but useful editorially) .replace(/__(.+?)__/g, "$1"); } /** * Convert the editor HTML back to the pipeline's plain-text / markdown-lite format. * This is the value stored in ChapterDraft.sections[].body. */ function htmlToText(html: string): string { const div = document.createElement("div"); div.innerHTML = html; function nodeToText(node: Node): string { if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ""; const el = node as Element; const tag = el.tagName?.toLowerCase(); const children = Array.from(node.childNodes).map(nodeToText).join(""); if (tag === "br") return "\n"; if (tag === "strong" || tag === "b") return `**${children}**`; if (tag === "em" || tag === "i") return `*${children}*`; if (tag === "u") return `__${children}__`; if (tag === "blockquote") { return children .split("\n") .filter((l) => l.trim()) .map((l) => `> ${l}`) .join("\n"); } if (tag === "h1") return `# ${children}`; if (tag === "h2") return `## ${children}`; if (tag === "h3") return `### ${children}`; if (tag === "li") return children; if (tag === "ul") { return Array.from(el.children) .map((li) => `- ${nodeToText(li)}`) .join("\n"); } if (tag === "ol") { return Array.from(el.children) .map((li, i) => `${i + 1}. ${nodeToText(li)}`) .join("\n"); } if (tag === "p") return children || ""; return children; } // Process top-level block nodes separated by double newlines const blocks = Array.from(div.childNodes) .map(nodeToText) .filter((b) => b.trim().length > 0); return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim(); } // ─── Word + char count ──────────────────────────────────────────────────────── function countWordsInHtml(html: string): { words: number; chars: number } { const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); const words = text ? text.split(/\s+/).length : 0; return { words, chars: text.length }; } // ─── Toolbar button ─────────────────────────────────────────────────────────── function ToolbarBtn({ title, active, onClick, children, }: { title: string; active?: boolean; onClick: (e: React.MouseEvent) => void; children: React.ReactNode; }) { return ( ); } // ─── Shared Toolbar Context ─────────────────────────────────────────────────── type ProseToolbarCtx = { /** Ref to the currently focused editor div — used by Find & Replace */ activeEditorRef: React.MutableRefObject; activeFormats: Set; setActiveFormats: (f: Set) => void; showFindReplace: boolean; setShowFindReplace: React.Dispatch>; /** Whether any ProseEditor is currently focused */ hasActiveEditor: boolean; setHasActiveEditor: (v: boolean) => void; }; const ProseToolbarContext = createContext(null); /** Wrap the editing zone with this to share one toolbar across all ProseEditors inside */ export function ProseToolbarProvider({ children }: { children: React.ReactNode }) { const activeEditorRef = useRef(null); const [activeFormats, setActiveFormats] = useState>(new Set()); const [showFindReplace, setShowFindReplace] = useState(false); const [hasActiveEditor, setHasActiveEditor] = useState(false); const value = useMemo( () => ({ activeEditorRef, activeFormats, setActiveFormats, showFindReplace, setShowFindReplace, hasActiveEditor, setHasActiveEditor }), [activeFormats, showFindReplace, hasActiveEditor], ); return {children}; } function useProseToolbar() { return useContext(ProseToolbarContext); } // ─── Shared exec helper (works on currently focused contenteditable) ────────── function sharedExec(cmd: string, val?: string) { if (cmd === "formatBlock") { document.execCommand("formatBlock", false, val ?? "p"); } else if (cmd === "indent") { document.execCommand("indent"); } else if (cmd === "outdent") { document.execCommand("outdent"); } else { document.execCommand(cmd, false, val); } } function readFormats(editorEl?: HTMLDivElement | null): Set { const fmts = new Set(); try { if (document.queryCommandState("bold")) fmts.add("bold"); if (document.queryCommandState("italic")) fmts.add("italic"); if (document.queryCommandState("underline")) fmts.add("underline"); } catch { /* execCommand may throw in some envs */ } const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { let node: Node | null = sel.getRangeAt(0).commonAncestorContainer; while (node && node !== editorEl) { const tag = (node as Element).tagName; if (tag === "BLOCKQUOTE") { fmts.add("blockquote"); break; } if (tag === "H1") { fmts.add("h1"); break; } if (tag === "H2") { fmts.add("h2"); break; } if (tag === "H3") { fmts.add("h3"); break; } node = node.parentNode; } } return fmts; } // ─── Find & Replace panel ───────────────────────────────────────────────────── function FindReplacePanel({ onClose }: { onClose: () => void }) { const ctx = useProseToolbar(); const [find, setFind] = useState(""); const [replace, setReplace] = useState(""); const [count, setCount] = useState(null); const highlight = useCallback(() => { const el = ctx?.activeEditorRef.current; if (!el || !find) return; const text = el.innerText; const matches = text.match(new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi")); setCount(matches?.length ?? 0); }, [find, ctx]); useEffect(() => { highlight(); }, [highlight]); const doReplace = useCallback((all: boolean) => { const el = ctx?.activeEditorRef.current; if (!el || !find) return; el.focus(); const escaped = find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); el.innerHTML = el.innerHTML.replace(new RegExp(escaped, all ? "gi" : "i"), replace); el.dispatchEvent(new Event("input", { bubbles: true })); highlight(); }, [find, replace, ctx, highlight]); return (
Find & Replace
setFind(e.target.value)} /> setReplace(e.target.value)} />
{count !== null && find && (

{count === 0 ? "No matches" : `${count} match${count !== 1 ? "es" : ""}`}

)}
); } // ─── Floating mini-toolbar (appears over selected text) ─────────────────────── function FloatingToolbar({ rect, activeFormats }: { rect: DOMRect; activeFormats: Set }) { const style: React.CSSProperties = { position: "fixed", top: Math.max(4, rect.top - 52), left: rect.left + rect.width / 2, transform: "translateX(-50%)", zIndex: 9999, }; return (
sharedExec("bold")}> B sharedExec("italic")}> I sharedExec("underline")}> U sharedExec("formatBlock", "blockquote")}>
); } // ─── SharedProseToolbar — render once above all editors ────────────────────── export function SharedProseToolbar({ className = "" }: { className?: string }) { const ctx = useProseToolbar(); const af = ctx?.activeFormats ?? new Set(); const inactive = !ctx?.hasActiveEditor; const exec = useCallback((cmd: string, val?: string) => { sharedExec(cmd, val); if (ctx) { // Re-read format state after command setTimeout(() => ctx.setActiveFormats(readFormats(ctx.activeEditorRef.current)), 0); } }, [ctx]); return (
{/* History */} exec("undo")}> exec("redo")}> {/* Inline */} exec("bold")}> B exec("italic")}> I exec("underline")}> U {/* Headings */} exec("formatBlock", af.has("h1") ? "p" : "h1")}> H1 exec("formatBlock", af.has("h2") ? "p" : "h2")}> H2 exec("formatBlock", af.has("h3") ? "p" : "h3")}> H3 {/* Block quote */} exec("formatBlock", af.has("blockquote") ? "p" : "blockquote")}> {/* Lists */} exec("insertUnorderedList")}> exec("insertOrderedList")}> {/* Indent */} exec("indent")}> exec("outdent")}> {/* Active field label */} {ctx?.hasActiveEditor && ( Editing )} {/* Find & Replace */} ctx?.setShowFindReplace((p) => !p)}>
{ctx?.showFindReplace && ctx.setShowFindReplace(false)} />} {/* Prose surface styles (injected once) */}
); } // ─── ProseEditor — individual editable field (no own toolbar) ───────────────── export function ProseEditor({ value, onChange, label, placeholder, rows = 10, className = "", }: ProseEditorProps) { const ctx = useProseToolbar(); const editorRef = useRef(null); const [floatRect, setFloatRect] = useState(null); const [localFormats, setLocalFormats] = useState>(new Set()); const [stats, setStats] = useState({ words: 0, chars: 0 }); const lastValueRef = useRef(""); // Init HTML from value prop useEffect(() => { if (!editorRef.current) return; if (value === lastValueRef.current) return; const html = textToHtml(value); editorRef.current.innerHTML = html; setStats(countWordsInHtml(html)); lastValueRef.current = value; }, [value]); const refreshFormats = useCallback(() => { const fmts = readFormats(editorRef.current); setLocalFormats(fmts); if (ctx) ctx.setActiveFormats(fmts); }, [ctx]); const emitChange = useCallback(() => { if (!editorRef.current) return; const html = editorRef.current.innerHTML; setStats(countWordsInHtml(html)); const text = htmlToText(html); lastValueRef.current = text; onChange(text); }, [onChange]); // Floating mini-toolbar on selection useEffect(() => { const handler = () => { refreshFormats(); const sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) { setFloatRect(null); return; } const range = sel.getRangeAt(0); if (!editorRef.current?.contains(range.commonAncestorContainer)) { setFloatRect(null); return; } setFloatRect(range.getBoundingClientRect()); }; document.addEventListener("selectionchange", handler); return () => document.removeEventListener("selectionchange", handler); }, [refreshFormats]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const isMod = e.metaKey || e.ctrlKey; if (isMod && e.key === "b") { e.preventDefault(); sharedExec("bold"); refreshFormats(); emitChange(); return; } if (isMod && e.key === "i") { e.preventDefault(); sharedExec("italic"); refreshFormats(); emitChange(); return; } if (isMod && e.key === "u") { e.preventDefault(); sharedExec("underline"); refreshFormats(); emitChange(); return; } if (isMod && e.key === "z" && !e.shiftKey) { e.preventDefault(); sharedExec("undo"); emitChange(); return; } if ((isMod && e.key === "y") || (isMod && e.shiftKey && e.key === "z")) { e.preventDefault(); sharedExec("redo"); emitChange(); return; } if (isMod && e.key === "h") { e.preventDefault(); ctx?.setShowFindReplace((p) => !p); return; } if (e.key === " " && editorRef.current) { const sel = window.getSelection(); if (!sel || !sel.rangeCount) return; const range = sel.getRangeAt(0); const node = range.startContainer; if (node.nodeType !== Node.TEXT_NODE) return; const text = node.textContent ?? ""; const offset = range.startOffset; const lineText = text.slice(0, offset); const triggers: [string, string][] = [["#", "h1"], ["##", "h2"], ["###", "h3"], [">", "blockquote"]]; for (const [trigger, block] of triggers) { if (lineText === trigger) { e.preventDefault(); node.textContent = text.slice(offset); document.execCommand("formatBlock", false, block); emitChange(); return; } } if (lineText === "-" || lineText === "*") { e.preventDefault(); node.textContent = text.slice(offset); document.execCommand("insertUnorderedList"); emitChange(); return; } if (/^\d+\.$/.test(lineText)) { e.preventDefault(); node.textContent = text.slice(offset); document.execCommand("insertOrderedList"); emitChange(); return; } } if (e.key === "Tab") { e.preventDefault(); sharedExec(e.shiftKey ? "outdent" : "indent"); emitChange(); } }, [refreshFormats, emitChange, ctx]); const minHeight = `${rows * 1.7}rem`; return (
{label && (
{stats.words.toLocaleString()} words · {stats.chars.toLocaleString()} chars
)}
{ if (ctx) { ctx.activeEditorRef.current = editorRef.current; ctx.setHasActiveEditor(true); } refreshFormats(); }} onBlur={() => { // Keep hasActiveEditor true briefly so toolbar clicks don't deactivate setTimeout(() => { if (ctx && ctx.activeEditorRef.current === editorRef.current) { const focused = document.activeElement; const inEditor = editorRef.current?.contains(focused); if (!inEditor) ctx.setHasActiveEditor(false); } }, 200); }} onKeyDown={handleKeyDown} onInput={emitChange} onMouseUp={refreshFormats} onKeyUp={refreshFormats} style={{ minHeight }} className={[ "w-full rounded-xl border border-slate-700/60 bg-slate-950/70 px-4 py-3", "text-base text-slate-100 leading-relaxed outline-none overflow-y-auto", "focus:border-cyan-500/40", "empty:before:content-[attr(data-placeholder)] empty:before:text-slate-600 empty:before:pointer-events-none", "prose-editor", ].join(" ")} /> {floatRect && }
); }