Files
checkflow/src/components/tasks/MarkdownNoteEditor.tsx
T
2026-08-22 09:08:10 +09:00

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