import { renderInline } from "../inline/render.js"; import type { BlockEditor } from "./editor.type.js"; import type { FormatRange, InlineModel } from "./inline-edit.js"; import { activeFormatsAt, applyHighlights, applyTextEdit, clearHighlights, modelToMarkdown, parseInlineModel, rangeToOffsets, toggleFormat, walkTextNodes, } from "./inline-edit.js"; export const paragraphEditor: BlockEditor = (container, raw, _attrs, onCommit, onCancel) => { let mode: "format" | "text" = "format"; let cleanup: (() => void) | null = null; // The host reuses the same container div across edit sessions, so the // keydown listener below must come off when the session ends — // otherwise one Ctrl+Enter fires N commits after N edits. const teardown = () => { cleanup?.(); cleanup = null; container.removeEventListener("keydown", onKeydown); }; const commitAndClean = (newRaw: string) => { teardown(); onCommit(newRaw); }; const cancelAndClean = () => { teardown(); onCancel(); }; const setupFormatMode = () => { let model = parseInlineModel(raw); let selOffsets: { start: number; end: number } | null = null; container.innerHTML = `

${renderInline(model.text)}

`; const getContentEl = (): Element | null => container.querySelector("p"); const rerender = () => { const el = getContentEl(); if (el) el.innerHTML = renderInline(model.text); }; const refreshHighlights = () => { const el = getContentEl(); if (el) applyHighlights(model.formats, walkTextNodes(el)); }; refreshHighlights(); const toolbar = createFormatToolbar( () => model, () => selOffsets, (updated) => { model = updated; raw = modelToMarkdown(model); rerender(); model = parseInlineModel(raw); refreshHighlights(); }, ); toolbar.style.display = "none"; container.style.position = "relative"; container.appendChild(toolbar); const onMouseUp = () => { const contentEl = getContentEl(); if (!contentEl) return; const sel = document.getSelection(); if (!sel || sel.isCollapsed) { toolbar.style.display = "none"; selOffsets = null; return; } const range = sel.rangeCount > 0 ? sel.getRangeAt(0) : null; if (!range || !contentEl.contains(range.startContainer)) { toolbar.style.display = "none"; selOffsets = null; return; } const textNodes = walkTextNodes(contentEl); const offsets = rangeToOffsets(range, textNodes); if (!offsets) { toolbar.style.display = "none"; selOffsets = null; return; } selOffsets = offsets; const active = activeFormatsAt(model.formats, offsets.start, offsets.end); for (const btn of toolbar.querySelectorAll("button[data-fmt]")) { btn.classList.toggle("active", active.has((btn as HTMLElement).dataset.fmt!)); } toolbar.style.display = "flex"; }; container.addEventListener("mouseup", onMouseUp); const onSelectionChange = () => { requestAnimationFrame(() => onMouseUp()); }; document.addEventListener("selectionchange", onSelectionChange); const onDblClick = (e: MouseEvent) => { if ((e.target as HTMLElement).closest("button")) return; e.preventDefault(); e.stopPropagation(); cleanup?.(); cleanup = null; mode = "text"; setupTextMode(model); }; container.addEventListener("dblclick", onDblClick); cleanup = () => { container.removeEventListener("mouseup", onMouseUp); container.removeEventListener("dblclick", onDblClick); document.removeEventListener("selectionchange", onSelectionChange); toolbar.remove(); clearHighlights(); container.style.position = ""; }; }; const setupTextMode = (initialModel?: InlineModel) => { const model = initialModel ?? parseInlineModel(raw); const toolbar = document.createElement("div"); toolbar.classList.add("toolbar"); const formatButtons: { type: "bold" | "italic" | "code"; html: string; el?: HTMLButtonElement; }[] = [ { type: "bold", html: "B" }, { type: "italic", html: "I" }, { type: "code", html: "</>" }, ]; const input = createTextInput(model.text); const updateToolbarState = () => { const active = activeFormatsAt(model.formats, input.selectionStart, input.selectionEnd); for (const fb of formatButtons) { fb.el?.classList.toggle("active", active.has(fb.type)); } }; for (const fb of formatButtons) { const btn = document.createElement("button"); btn.innerHTML = fb.html; btn.addEventListener("mousedown", (e) => { e.preventDefault(); const start = input.selectionStart; const end = input.selectionEnd; if (start === end) return; model.formats = toggleFormat(model.formats, start, end, fb.type); updateToolbarState(); }); fb.el = btn; toolbar.appendChild(btn); } const linkBtn = document.createElement("button"); linkBtn.textContent = "Link"; linkBtn.addEventListener("mousedown", (e) => { e.preventDefault(); const start = input.selectionStart; const end = input.selectionEnd; if (start === end) return; const existing = model.formats.find( (f) => f.type === "link" && f.start <= start && f.end >= end, ); if (existing) { model.formats = model.formats.filter((f) => f !== existing); } else { const url = prompt("URL:"); if (!url) return; model.formats.push({ start, end, type: "link", url }); } updateToolbarState(); }); toolbar.appendChild(linkBtn); let prevText = model.text; input.addEventListener("input", () => { model.formats = applyTextEdit(model.formats, prevText, input.value, input.selectionStart); model.text = input.value; prevText = input.value; }); input.addEventListener("keyup", updateToolbarState); input.addEventListener("mouseup", updateToolbarState); container.innerHTML = ""; container.appendChild(toolbar); container.appendChild(input); input.focus(); updateToolbarState(); cleanup = () => { model.text = input.value; raw = modelToMarkdown(model); }; }; // Keyboard handler for container — Ctrl+Enter commits, Escape cancels const onKeydown = (e: KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); cleanup?.(); commitAndClean(raw); } if (e.key === "Escape") { e.preventDefault(); cancelAndClean(); } }; container.addEventListener("keydown", onKeydown); if (mode === "format" && raw.trim()) setupFormatMode(); else setupTextMode(); }; // -- Helpers ----------------------------------------------------------------- function createTextInput(value: string): HTMLTextAreaElement { const input = document.createElement("textarea"); input.classList.add("edit-input"); input.value = value; return input; } function createFormatToolbar( getModel: () => InlineModel, getOffsets: () => { start: number; end: number } | null, onFormat: (updated: InlineModel) => void, ): HTMLElement { const toolbar = document.createElement("div"); toolbar.classList.add("floating-toolbar"); const applyFmt = (type: "bold" | "italic" | "code") => { const offsets = getOffsets(); if (!offsets) return; const model = getModel(); model.formats = toggleFormat(model.formats, offsets.start, offsets.end, type); onFormat(model); toolbar.style.display = "none"; }; for (const fd of [ { type: "bold" as const, html: "B" }, { type: "italic" as const, html: "I" }, { type: "code" as const, html: "</>" }, ]) { const btn = document.createElement("button"); btn.innerHTML = fd.html; btn.dataset.fmt = fd.type; btn.addEventListener("mousedown", (e) => { e.preventDefault(); applyFmt(fd.type); }); toolbar.appendChild(btn); } const linkBtn = document.createElement("button"); linkBtn.textContent = "Link"; linkBtn.dataset.fmt = "link"; linkBtn.addEventListener("mousedown", (e) => { e.preventDefault(); const offsets = getOffsets(); if (!offsets) return; const model = getModel(); const { start, end } = offsets; const existing = model.formats.find( (f: FormatRange) => f.type === "link" && f.start <= start && f.end >= end, ); if (existing) { model.formats = model.formats.filter((f: FormatRange) => f !== existing); } else { const url = prompt("URL:"); if (!url) return; model.formats.push({ start, end, type: "link", url }); } onFormat(model); toolbar.style.display = "none"; }); toolbar.appendChild(linkBtn); return toolbar; }