import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "../lib/utils"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; interface InlineEditorProps { value: string; onSave: (value: string) => void | Promise; as?: "h1" | "h2" | "p" | "span"; className?: string; placeholder?: string; multiline?: boolean; imageUploadHandler?: (file: File) => Promise; mentions?: MentionOption[]; } /** Shared padding so display and edit modes occupy the exact same box. */ const pad = "px-1 -mx-1"; const markdownPad = "px-1"; const AUTOSAVE_DEBOUNCE_MS = 900; export function InlineEditor({ value, onSave, as: Tag = "span", className, placeholder = "Click to edit...", multiline = false, imageUploadHandler, mentions, }: InlineEditorProps) { const [editing, setEditing] = useState(false); const [multilineFocused, setMultilineFocused] = useState(false); const [draft, setDraft] = useState(value); const inputRef = useRef(null); const markdownRef = useRef(null); const autosaveDebounceRef = useRef | null>(null); const { state: autosaveState, markDirty, reset, runSave, } = useAutosaveIndicator(); useEffect(() => { if (multiline && multilineFocused) return; setDraft(value); }, [value, multiline, multilineFocused]); useEffect(() => { return () => { if (autosaveDebounceRef.current) { clearTimeout(autosaveDebounceRef.current); } }; }, []); const autoSize = useCallback((el: HTMLTextAreaElement | null) => { if (!el) return; el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }, []); useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); if (inputRef.current instanceof HTMLTextAreaElement) { autoSize(inputRef.current); } } }, [editing, autoSize]); useEffect(() => { if (!editing || !multiline) return; const frame = requestAnimationFrame(() => { markdownRef.current?.focus(); }); return () => cancelAnimationFrame(frame); }, [editing, multiline]); const commit = useCallback(async (nextValue = draft) => { const trimmed = nextValue.trim(); if (trimmed && trimmed !== value) { await Promise.resolve(onSave(trimmed)); } else { setDraft(value); } if (!multiline) { setEditing(false); } }, [draft, multiline, onSave, value]); function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && !multiline) { e.preventDefault(); void commit(); } if (e.key === "Escape") { if (autosaveDebounceRef.current) { clearTimeout(autosaveDebounceRef.current); } reset(); setDraft(value); if (multiline) { setMultilineFocused(false); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } } else { setEditing(false); } } } useEffect(() => { if (!multiline) return; if (!multilineFocused) return; const trimmed = draft.trim(); if (!trimmed || trimmed === value) { if (autosaveState !== "saved") { reset(); } return; } markDirty(); if (autosaveDebounceRef.current) { clearTimeout(autosaveDebounceRef.current); } autosaveDebounceRef.current = setTimeout(() => { void runSave(() => commit(trimmed)); }, AUTOSAVE_DEBOUNCE_MS); return () => { if (autosaveDebounceRef.current) { clearTimeout(autosaveDebounceRef.current); } }; }, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, reset, runSave, value]); if (multiline) { return (
setMultilineFocused(true)} onBlurCapture={(event) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; if (autosaveDebounceRef.current) { clearTimeout(autosaveDebounceRef.current); } setMultilineFocused(false); const trimmed = draft.trim(); if (!trimmed || trimmed === value) { reset(); void commit(); return; } void runSave(() => commit()); }} onKeyDown={handleKeyDown} > { const trimmed = draft.trim(); if (!trimmed || trimmed === value) { reset(); void commit(); return; } void runSave(() => commit()); }} />
{autosaveState === "saving" ? "Autosaving..." : autosaveState === "saved" ? "Saved" : autosaveState === "error" ? "Could not save" : "Idle"}
); } if (editing) { return (