import { createEffect, on, onMount } from "solid-js";
// M15/THREAT-006: allow-list sanitizer for note bodies. The editor writes
// arbitrary HTML directly to innerHTML; without this any note body could
// inject iframe/base/meta/form/script. Allow-list is intentional — adding
// to it requires reviewing the attack surface of the new tag.
const ALLOWED_NOTE_TAGS = new Set([
"b", "i", "u", "s", "strong", "em",
"h2", "h3", "ul", "ol", "li", "br", "hr", "p",
]);
function sanitizeNoteElement(el: Element): void {
for (const child of Array.from(el.children)) {
if (!ALLOWED_NOTE_TAGS.has(child.tagName.toLowerCase())) {
child.replaceWith(document.createTextNode(child.textContent ?? ""));
} else {
while (child.attributes.length > 0) {
child.removeAttribute(child.attributes[0]!.name);
}
sanitizeNoteElement(child);
}
}
}
export function toHtml(raw: string): string {
if (!/<\/?[a-z]/i.test(raw)) {
return raw
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/\n/g, "
");
}
const doc = new DOMParser().parseFromString(raw, "text/html");
sanitizeNoteElement(doc.body);
return doc.body.innerHTML;
}
const BoldIcon = () => B;
const ItalicIcon = () => I;
const UnderlineIcon = () => U;
const StrikeIcon = () => S;
const HeadingIcon = () => H;
const BulletIcon = () => (
);
const NumberedIcon = () => (
);
const RuleIcon = () => (
);
export type ExecFn = (cmd: string, val?: string) => void;
export function Toolbar(props: { exec: ExecFn; class?: string }) {
const e = props.exec;
function toggleHeading() {
const val = document.queryCommandValue("formatBlock").toLowerCase();
e("formatBlock", val === "h2" ? "p" : "h2");
}
return (
);
}
/* ── Inline editor (Add.tsx, Entry.tsx note view) ── */
interface NoteEditorInlineProps {
initialContent: string;
onContentChange?: (html: string) => void;
onBlur?: (html: string) => void;
onReady?: (el: HTMLDivElement) => void;
autoFocus?: boolean;
/** When this key changes, the editor re-loads initialContent (e.g., switching between vault entries in-place). */
entryKey?: string;
}
export function NoteEditorInline(props: NoteEditorInlineProps) {
let editorRef: HTMLDivElement | undefined;
onMount(() => {
if (!editorRef) return;
editorRef.innerHTML = toHtml(props.initialContent);
if (props.autoFocus !== false) editorRef.focus();
props.onReady?.(editorRef);
});
createEffect(on(() => props.entryKey, (key, prevKey) => {
if (prevKey === undefined || key === prevKey || !editorRef) return;
editorRef.innerHTML = toHtml(props.initialContent);
props.onReady?.(editorRef);
}));
function exec(cmd: string, value?: string) {
document.execCommand(cmd, false, value);
editorRef?.focus();
}
// Paste as plain text. The live editor writes innerHTML directly and only
// load-time content is sanitized (toHtml); pasted HTML would otherwise carry
// foreign inline styles (white-space:pre kills wrapping) and disallowed tags.
// insertText keeps the body clean and wrapping intact, and fires `input` so
// onContentChange persists the change.
function handlePaste(e: ClipboardEvent) {
e.preventDefault();
const text = e.clipboardData?.getData("text/plain") ?? "";
document.execCommand("insertText", false, text);
}
function handleKeyDown(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey;
// Only intercept shortcuts for non-alphanumeric keys to avoid swallowing
// letter input. Letter keys (a–z, etc.) must always pass through freely.
const isLetterKey = e.key.length === 1;
if (isLetterKey) return;
if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }
if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }
if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }
}
return (
props.onContentChange?.(editorRef?.innerHTML ?? "")}
onBlur={() => props.onBlur?.(editorRef?.innerHTML ?? "")}
aria-multiline="true"
aria-label="Note content"
/>
);
}