/* global React, marked, DOMPurify */
const { useState, useEffect, useRef, useLayoutEffect, useMemo } = React;
// ── Configure marked + DOMPurify once ────────────────────────────────────────
if (typeof marked !== "undefined") {
marked.setOptions({ gfm: true, breaks: true });
}
if (typeof DOMPurify !== "undefined" && !DOMPurify.__rndHooked) {
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A" && node.getAttribute("href")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
});
DOMPurify.__rndHooked = true;
}
function renderMarkdown(src) {
if (!src) return "";
if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
const escaped = String(src).replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c]));
return escaped.replace(/\n/g, "
");
}
const html = marked.parse(String(src));
return DOMPurify.sanitize(html, {
ADD_ATTR: ["target", "rel"],
FORBID_TAGS: ["style", "script", "iframe"],
});
}
// ── Rendered markdown view with collapse / expand ────────────────────────────
function MarkdownView({ source, collapsedHeight = 160, className = "", style }) {
const [expanded, setExpanded] = useState(false);
const [overflows, setOverflows] = useState(false);
const ref = useRef(null);
const html = useMemo(() => renderMarkdown(source), [source]);
useLayoutEffect(() => {
if (!ref.current) return;
const measure = () => {
const el = ref.current;
if (!el) return;
setOverflows(el.scrollHeight > collapsedHeight + 4);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(ref.current);
return () => ro.disconnect();
}, [html, collapsedHeight]);
const showCollapse = overflows && !expanded;
return (
{overflows && (
)}
);
}
// ── Markdown editor (toolbar + textarea + preview tab) ───────────────────────
function MarkdownEditor({ value, onChange, onKeyDown, rows = 8, autoFocus = false, placeholder = "Write something… (Markdown supported)" }) {
const [tab, setTab] = useState("write");
const ref = useRef(null);
useEffect(() => {
if (autoFocus && tab === "write" && ref.current) {
ref.current.focus();
const el = ref.current;
if (el.value) el.setSelectionRange(el.value.length, el.value.length);
}
}, [autoFocus, tab]);
const wrapSelection = (before, after = before, placeholder = "") => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const sel = el.value.slice(start, end);
const inner = sel || placeholder;
const next = el.value.slice(0, start) + before + inner + after + el.value.slice(end);
onChange(next);
requestAnimationFrame(() => {
el.focus();
const pos = start + before.length;
el.setSelectionRange(pos, pos + inner.length);
});
};
const linePrefix = (prefix) => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const v = el.value;
const lineStart = v.lastIndexOf("\n", start - 1) + 1;
const next = v.slice(0, lineStart) + prefix + v.slice(lineStart);
onChange(next);
requestAnimationFrame(() => {
el.focus();
const pos = end + prefix.length;
el.setSelectionRange(pos, pos);
});
};
const insertLink = () => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const sel = el.value.slice(start, end) || "link text";
const snippet = `[${sel}](https://)`;
const next = el.value.slice(0, start) + snippet + el.value.slice(end);
onChange(next);
requestAnimationFrame(() => {
el.focus();
const urlStart = start + sel.length + 3;
el.setSelectionRange(urlStart, urlStart + 8);
});
};
const handleToolbarMouseDown = (e) => e.preventDefault(); // keep textarea focus
const Btn = ({ title, onClick, children }) => (
);
return (
{tab === "write" && (
wrapSelection("**", "**", "bold")}>B
wrapSelection("*", "*", "italic")}>I
wrapSelection("`", "`", "code")}>{"<>"}
linePrefix("## ")}>H
linePrefix("- ")}>•
linePrefix("1. ")}>1.
linePrefix("> ")}>❝
🔗
wrapSelection("\n```\n", "\n```\n", "code")}>{"{ }"}
)}
{tab === "write" ? (
);
}
// ── Inline-editable markdown field (parallels InlineField) ───────────────────
function InlineMarkdownField({ value, onSave, placeholder = "Click to add…", collapsedHeight = 160, rows = 8 }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const mouseDownPos = useRef(null);
const startEdit = (e) => {
if (e.target.closest && e.target.closest(".md-expand-btn, a")) return;
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
if (mouseDownPos.current) {
const dx = Math.abs(e.clientX - mouseDownPos.current.x);
const dy = Math.abs(e.clientY - mouseDownPos.current.y);
if (dx > 4 || dy > 4) return;
}
setDraft(value ?? "");
setError(null);
setEditing(true);
};
const save = async () => {
if (saving) return;
setSaving(true);
setError(null);
try {
await onSave(draft);
setEditing(false);
} catch (err) {
setError(err.message || "Failed to save");
} finally {
setSaving(false);
}
};
const cancel = () => { setEditing(false); setError(null); };
const handleKeyDown = (e) => {
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancel(); return; }
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); return; }
};
if (editing) {
return (
{error &&
{error}
}
Ctrl+Enter to save
);
}
return (
{ mouseDownPos.current = { x: e.clientX, y: e.clientY }; }}
onClick={startEdit}
>
{value
?
:
{placeholder}}
);
}
window.PRMarkdown = { MarkdownView, MarkdownEditor, InlineMarkdownField, renderMarkdown };