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;
}