feat: implement user preference management with persistence, reactive CSS variables, and layout customization
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
@@ -12,7 +12,7 @@ interface MarkdownNoteEditorProps {
|
||||
|
||||
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
|
||||
const { t } = useI18n();
|
||||
const [mode, setMode] = useState<"edit" | "preview">("preview");
|
||||
const [mode, setMode] = useState<"edit" | "preview">("edit");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -26,74 +26,32 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
||||
marked.use({ renderer, breaks: true, gfm: true });
|
||||
}, []);
|
||||
|
||||
// Insert markdown syntax at selection or cursor position
|
||||
const insertSyntax = useCallback(
|
||||
(before: string, after = "", placeholder = "") => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) {
|
||||
onChange(value + before + placeholder + after);
|
||||
return;
|
||||
}
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? ta.value.length;
|
||||
const selected = ta.value.slice(start, end) || placeholder;
|
||||
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
|
||||
onChange(newVal);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + selected.length;
|
||||
});
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "b") {
|
||||
e.preventDefault();
|
||||
insertSyntax("**", "**", "bold");
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "i") {
|
||||
e.preventDefault();
|
||||
insertSyntax("*", "*", "italic");
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (onSave) onSave();
|
||||
} else if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
insertSyntax(" ");
|
||||
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;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle interactive checkboxes in markdown preview
|
||||
const handlePreviewCheckboxToggle = (index: number) => {
|
||||
const lines = value.split("\n");
|
||||
let checkboxCount = 0;
|
||||
const newLines = lines.map((line) => {
|
||||
const match = line.match(/^(\s*[-*+]\s*\[)([ xX])(\])(.*)$/);
|
||||
if (match) {
|
||||
if (checkboxCount === index) {
|
||||
const currentChecked = match[2].toLowerCase() === "x";
|
||||
const newChecked = currentChecked ? " " : "x";
|
||||
checkboxCount++;
|
||||
return `${match[1]}${newChecked}${match[3]}${match[4]}`;
|
||||
}
|
||||
checkboxCount++;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
onChange(newLines.join("\n"));
|
||||
};
|
||||
|
||||
// Convert markdown to sanitized HTML with interactive checkboxes and autolinks
|
||||
// Convert markdown to sanitized HTML with safe links
|
||||
const renderMarkdownHtml = () => {
|
||||
if (!value || !value.trim()) {
|
||||
return `<p style="color: var(--text-tertiary); font-style: italic;">${t("notesPlaceholder").split("\n")[0]}</p>`;
|
||||
return `<p style="color: var(--text-tertiary); font-style: italic; padding: 8px 0;">${t("notesPlaceholder").split("\n")[0]}</p>`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Auto-detect plain URLs and wrap them in markdown links if not already wrapped
|
||||
const textWithLinks = value.replace(
|
||||
/(^|[^"'])(https?:\/\/[^\s<]+)/g,
|
||||
(match, prefix, url) => {
|
||||
@@ -102,263 +60,121 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
||||
}
|
||||
);
|
||||
|
||||
let rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string;
|
||||
const rawHtml = marked.parse(textWithLinks, { breaks: true, gfm: true }) as string;
|
||||
|
||||
// Replace task list checkboxes with interactive ones
|
||||
let cbIdx = 0;
|
||||
rawHtml = rawHtml.replace(/<input[^>]*type="checkbox"[^>]*checked[^>]*>/gi, () => {
|
||||
const id = `cb-${cbIdx++}`;
|
||||
return `<span class="markdown-checkbox-box checked" data-idx="${id}"></span>`;
|
||||
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"],
|
||||
});
|
||||
rawHtml = rawHtml.replace(/<input[^>]*type="checkbox"[^>]*>/gi, () => {
|
||||
const id = `cb-${cbIdx++}`;
|
||||
return `<span class="markdown-checkbox-box" data-idx="${id}"></span>`;
|
||||
});
|
||||
|
||||
// XSS 방어: DOMPurify sanitize (링크 target=_blank, class, data-idx 허용)
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
ADD_ATTR: ["target", "rel", "data-idx", "class"],
|
||||
ALLOW_DATA_ATTR: true,
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="note-editor-wrap">
|
||||
{/* Top Toolbar */}
|
||||
<div className="note-editor-toolbar">
|
||||
{/* Mode Switcher Tabs */}
|
||||
<div className="note-toolbar-group">
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", color: "var(--text-tertiary)" }}>
|
||||
{t("notes").toUpperCase()}
|
||||
</span>
|
||||
|
||||
<div className="view-switcher-group" style={{ padding: 2 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`note-toolbar-btn${mode === "preview" ? " active" : ""}`}
|
||||
onClick={() => setMode("preview")}
|
||||
title="Preview rendered Markdown"
|
||||
>
|
||||
👁️ Preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`note-toolbar-btn${mode === "edit" ? " active" : ""}`}
|
||||
className={`view-switcher-btn${mode === "edit" ? " active" : ""}`}
|
||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}}
|
||||
title="Edit raw Markdown"
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="note-toolbar-divider" />
|
||||
|
||||
{/* Formatting actions */}
|
||||
<div className="note-toolbar-group">
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="H1 Heading"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("# ", "", "Heading 1");
|
||||
}}
|
||||
className={`view-switcher-btn${mode === "preview" ? " active" : ""}`}
|
||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||
onClick={() => setMode("preview")}
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="H2 Heading"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("## ", "", "Heading 2");
|
||||
}}
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="H3 Heading"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("### ", "", "Heading 3");
|
||||
}}
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="note-toolbar-divider" />
|
||||
|
||||
<div className="note-toolbar-group">
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Bold (Ctrl+B)"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("**", "**", "bold");
|
||||
}}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Italic (Ctrl+I)"
|
||||
style={{ fontStyle: "italic" }}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("*", "*", "italic");
|
||||
}}
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Strikethrough"
|
||||
style={{ textDecoration: "line-through" }}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("~~", "~~", "strikethrough");
|
||||
}}
|
||||
>
|
||||
S
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="note-toolbar-divider" />
|
||||
|
||||
<div className="note-toolbar-group">
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Checkbox item"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("\n- [ ] ", "", "task");
|
||||
}}
|
||||
>
|
||||
☑️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Bullet list"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("\n- ", "", "list item");
|
||||
}}
|
||||
>
|
||||
•
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Numbered list"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("\n1. ", "", "list item");
|
||||
}}
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Quote"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("\n> ", "", "quote");
|
||||
}}
|
||||
>
|
||||
❝
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Code Block"
|
||||
style={{ fontFamily: "monospace" }}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("\n```\n", "\n```\n", "code");
|
||||
}}
|
||||
>
|
||||
{"< >"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Horizontal Divider"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("\n---\n");
|
||||
}}
|
||||
>
|
||||
―
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="note-toolbar-btn"
|
||||
title="Link"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
insertSyntax("[", "](https://example.com)", "link title");
|
||||
}}
|
||||
>
|
||||
🔗
|
||||
👁️ Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor / Preview Content Area */}
|
||||
{mode === "edit" ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="note-textarea"
|
||||
className="note-textarea"
|
||||
placeholder={t("notesPlaceholder")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="markdown-body"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Handle link clicks cleanly
|
||||
const link = target.closest("a");
|
||||
if (link && link.href) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(link.href, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle interactive checkbox clicks
|
||||
if (target.classList.contains("markdown-checkbox-box")) {
|
||||
const idxStr = target.getAttribute("data-idx");
|
||||
if (idxStr) {
|
||||
const idx = parseInt(idxStr.replace("cb-", ""), 10);
|
||||
if (!isNaN(idx)) handlePreviewCheckboxToggle(idx);
|
||||
}
|
||||
} else if (e.detail === 2) {
|
||||
// Double click anywhere to quickly switch to edit mode
|
||||
{/* Editor / Preview Area */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", position: "relative" }}>
|
||||
{mode === "edit" ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="note-editor-textarea"
|
||||
style={{
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 120,
|
||||
padding: "8px 10px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.6,
|
||||
resize: "none",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
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: "8px 10px",
|
||||
overflowY: "auto",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user