209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
"use client";
|
|
import React, { useState, useRef, useEffect } from "react";
|
|
import { marked } from "marked";
|
|
import DOMPurify from "dompurify";
|
|
import { useI18n } from "@/lib/i18n";
|
|
|
|
interface MarkdownNoteEditorProps {
|
|
value: string;
|
|
onChange: (newValue: string) => void;
|
|
onSave?: () => void;
|
|
}
|
|
|
|
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
|
|
const { t } = useI18n();
|
|
// Default to preview mode as requested
|
|
const [mode, setMode] = useState<"edit" | "preview">("preview");
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Reset to preview whenever value or active task changes
|
|
useEffect(() => {
|
|
// Keep preview mode as primary
|
|
}, []);
|
|
|
|
// Configure marked to open links in new tabs safely
|
|
useEffect(() => {
|
|
const renderer = new marked.Renderer();
|
|
renderer.link = ({ href, title, text }: { href: string; title?: string | null; text: string }) => {
|
|
const titleAttr = title ? ` title="${title}"` : "";
|
|
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr} class="markdown-link">${text}</a>`;
|
|
};
|
|
marked.use({ renderer, breaks: true, gfm: true });
|
|
}, []);
|
|
|
|
// Keyboard shortcuts
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
if (onSave) onSave();
|
|
} else if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
const start = ta.selectionStart;
|
|
const end = ta.selectionEnd;
|
|
const newVal = value.slice(0, start) + " " + value.slice(end);
|
|
onChange(newVal);
|
|
requestAnimationFrame(() => {
|
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
|
});
|
|
}
|
|
};
|
|
|
|
// Convert markdown to sanitized HTML with safe links
|
|
const renderMarkdownHtml = () => {
|
|
if (!value || !value.trim()) {
|
|
return `<div style="color: var(--text-tertiary); font-style: italic; user-select: none; line-height: 1.6;">${t("notesPlaceholder")}</div>`;
|
|
}
|
|
|
|
try {
|
|
const textWithLinks = value.replace(
|
|
/(^|[^"'])(https?:\/\/[^\s<]+)/g,
|
|
(match, prefix, url) => {
|
|
if (match.includes("](") || match.includes('href="')) return match;
|
|
return `${prefix}[${url}](${url})`;
|
|
}
|
|
);
|
|
|
|
const rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string;
|
|
|
|
return DOMPurify.sanitize(rawHtml, {
|
|
ALLOWED_TAGS: [
|
|
"h1", "h2", "h3", "h4", "h5", "h6", "p", "a", "span", "strong", "em", "del", "s",
|
|
"ul", "ol", "li", "code", "pre", "blockquote", "hr", "br", "table", "thead", "tbody",
|
|
"tr", "th", "td"
|
|
],
|
|
ALLOWED_ATTR: ["href", "title", "target", "rel", "class", "style"],
|
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
|
ADD_ATTR: ["target", "rel", "class"],
|
|
});
|
|
} catch {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "100%",
|
|
width: "100%",
|
|
minHeight: 0,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{/* Minimal Header with Mode Switcher */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "4px 8px 8px 8px",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}>
|
|
{t("notes").toUpperCase()}
|
|
</span>
|
|
|
|
{/* Minimal Matte One-touch Mode Toggle */}
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
style={{
|
|
fontSize: 11.5,
|
|
padding: "2px 8px",
|
|
height: 24,
|
|
borderRadius: "var(--radius-sm)",
|
|
border: "1px solid var(--border)",
|
|
background: mode === "edit" ? "var(--accent-light)" : "var(--bg-secondary)",
|
|
color: mode === "edit" ? "var(--accent)" : "var(--text-secondary)",
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
transition: "all var(--dur-fast)",
|
|
}}
|
|
onClick={() => {
|
|
if (mode === "preview") {
|
|
setMode("edit");
|
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
|
} else {
|
|
setMode("preview");
|
|
}
|
|
}}
|
|
title={mode === "preview" ? "Switch to Edit" : "Switch to Preview"}
|
|
>
|
|
{mode === "preview" ? "✏️ Edit" : "👁️ Preview"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Editor / Preview Area */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
position: "relative",
|
|
background: "var(--bg-primary)",
|
|
borderRadius: "var(--radius-md)",
|
|
border: "1px solid var(--border)",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{mode === "edit" ? (
|
|
<textarea
|
|
ref={textareaRef}
|
|
className="note-editor-textarea"
|
|
style={{
|
|
flex: 1,
|
|
width: "100%",
|
|
height: "100%",
|
|
padding: "10px 12px",
|
|
background: "transparent",
|
|
border: "none",
|
|
outline: "none",
|
|
color: "var(--text-primary)",
|
|
fontSize: 13.5,
|
|
lineHeight: 1.6,
|
|
resize: "none",
|
|
fontFamily: "inherit",
|
|
boxSizing: "border-box",
|
|
}}
|
|
placeholder={t("notesPlaceholder")}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={() => {
|
|
if (onSave) onSave();
|
|
}}
|
|
/>
|
|
) : (
|
|
<div
|
|
ref={previewRef}
|
|
className="note-editor-preview"
|
|
style={{
|
|
flex: 1,
|
|
width: "100%",
|
|
height: "100%",
|
|
padding: "10px 12px",
|
|
overflowY: "auto",
|
|
color: "var(--text-primary)",
|
|
fontSize: 13.5,
|
|
lineHeight: 1.6,
|
|
boxSizing: "border-box",
|
|
cursor: "text",
|
|
}}
|
|
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
|
onClick={() => {
|
|
setMode("edit");
|
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|