Feature: Add rich interactive Markdown renderer, wide adaptive memo panel, and custom language popover

This commit is contained in:
Wonhee Han
2026-08-20 14:14:27 +09:00
parent 3710c313e9
commit 633e760599
10 changed files with 1950 additions and 229 deletions
+325
View File
@@ -0,0 +1,325 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { marked } from "marked";
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();
const [mode, setMode] = useState<"edit" | "preview">("preview");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 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") {
e.preventDefault();
if (onSave) onSave();
} else if (e.key === "Tab") {
e.preventDefault();
insertSyntax(" ");
}
};
// 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
const renderMarkdownHtml = () => {
if (!value || !value.trim()) {
return `<p style="color: var(--text-tertiary); font-style: italic;">${t("notesPlaceholder").split("\n")[0]}</p>`;
}
try {
let rawHtml = marked.parse(value, { 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>`;
});
rawHtml = rawHtml.replace(/<input[^>]*type="checkbox"[^>]*>/gi, () => {
const id = `cb-${cbIdx++}`;
return `<span class="markdown-checkbox-box" data-idx="${id}"></span>`;
});
return rawHtml;
} catch {
return value;
}
};
return (
<div className="note-editor-wrap">
{/* Top Toolbar */}
<div className="note-editor-toolbar">
{/* Mode Switcher Tabs */}
<div className="note-toolbar-group">
<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" : ""}`}
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");
}}
>
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");
}}
>
🔗
</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
className="markdown-body"
dangerouslySetInnerHTML={{ __html: renderMarkdownHtml() }}
onClick={(e) => {
const target = e.target as HTMLElement;
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
setMode("edit");
}
}}
/>
)}
</div>
);
}
+143 -145
View File
@@ -2,6 +2,7 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useI18n } from "@/lib/i18n";
import { Task } from "./TaskList";
import { MarkdownNoteEditor } from "./MarkdownNoteEditor";
interface Props {
task: Task;
@@ -32,11 +33,11 @@ export function TaskDetail({
const [priority, setPriority] = useState(task.priority);
const [newSubtitle, setNewSubtitle] = useState("");
const [subtasks, setSubtasks] = useState(task.children || []);
const [showSubtasks, setShowSubtasks] = useState(true);
const [saving, setSaving] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTaskId = useRef(task.id);
const noteRef = useRef<HTMLTextAreaElement>(null);
const priorityMap = [
{ label: t("priorityNone"), color: "var(--text-tertiary)", icon: "" },
@@ -113,32 +114,10 @@ export function TaskDetail({
[save]
);
// Insert markdown at cursor position
const insertAtCursor = useCallback(
(before: string, after = "", placeholder = "") => {
const ta = noteRef.current;
if (!ta) {
setNote((n) => {
const v = n + before + placeholder + after;
debounceSave({ note: v });
return v;
});
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);
setNote(newVal);
debounceSave({ note: newVal });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + selected.length;
});
},
[debounceSave]
);
const handleNoteChange = (newNote: string) => {
setNote(newNote);
debounceSave({ note: newNote });
};
const handleToggleCompleted = useCallback(
async (newCompleted: boolean) => {
@@ -306,7 +285,7 @@ export function TaskDetail({
return (
<aside className="detail-panel">
{/* Header */}
{/* Header Bar */}
<div className="detail-header">
<button
className={`task-check-btn${task.completed ? " checked" : ""}`}
@@ -329,8 +308,8 @@ export function TaskDetail({
</button>
</div>
{/* Body */}
<div className="detail-body">
{/* Main Body - Centered on Wide Memo Experience */}
<div className="detail-body" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 14 }}>
{/* Title */}
<textarea
id="detail-title"
@@ -341,39 +320,42 @@ export function TaskDetail({
debounceSave({ title: e.target.value });
}}
placeholder={t("taskTitlePlaceholder")}
rows={2}
rows={1}
style={{
textDecoration: task.completed ? "line-through" : "none",
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
fontSize: 20,
fontWeight: 700,
}}
/>
{/* Priority */}
<div className="detail-section">
<div className="detail-section-label">{t("priority")}</div>
<div className="priority-selector">
{priorityMap.map((p, i) => (
<button
key={i}
id={`priority-${i}`}
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
style={{ color: priority === i ? p.color : undefined }}
onClick={() => {
setPriority(i);
debounceSave({ priority: i });
}}
>
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
{p.label}
</button>
))}
{/* Compact Metadata Strip (Priority & Due Date in a single line) */}
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", paddingBottom: 4, borderBottom: "1px solid var(--border)" }}>
{/* Priority */}
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase" }}>{t("priority")}:</span>
<div className="priority-selector" style={{ gap: 4 }}>
{priorityMap.map((p, i) => (
<button
key={i}
id={`priority-${i}`}
className={`priority-btn${priority === i ? ` active-${["none", "low", "medium", "high"][i]}` : ""}`}
style={{ color: priority === i ? p.color : undefined, padding: "3px 8px", fontSize: 11 }}
onClick={() => {
setPriority(i);
debounceSave({ priority: i });
}}
type="button"
>
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
{p.label}
</button>
))}
</div>
</div>
</div>
{/* Due date */}
<div className="detail-section">
<div className="detail-section-label">{t("dueDate")}</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{/* Due Date */}
<div style={{ display: "flex", alignItems: "center", gap: 6, marginLeft: "auto" }}>
<input
id="due-date-input"
type="date"
@@ -383,15 +365,17 @@ export function TaskDetail({
setDueDate(e.target.value);
debounceSave({ dueDate: e.target.value || null });
}}
style={{ width: "auto" }}
style={{ width: "auto", padding: "3px 8px", fontSize: 12 }}
/>
{dueDate && (
<button
className="btn btn-ghost btn-sm"
style={{ padding: "2px 6px", fontSize: 11 }}
onClick={() => {
setDueDate("");
debounceSave({ dueDate: null });
}}
type="button"
>
{t("clear")}
</button>
@@ -399,107 +383,121 @@ export function TaskDetail({
</div>
</div>
{/* Notes — wide memo area */}
<div className="detail-section" style={{ flex: 1 }}>
<div className="detail-section-label">{t("notes")}</div>
<div className="note-editor-wrap">
<div className="note-editor-toolbar">
<button className="note-toolbar-btn" title={t("bold")} onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
<button className="note-toolbar-btn" title={t("italic")} style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
<button className="note-toolbar-btn" title={t("heading")} onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
<button className="note-toolbar-btn" title={t("bulletList")} onClick={() => insertAtCursor("\n- ", "", "item")}></button>
<button className="note-toolbar-btn" title={t("numberedList")} onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
<button className="note-toolbar-btn" title={t("checkbox")} onClick={() => insertAtCursor("\n- [ ] ", "", "task")}></button>
<button className="note-toolbar-btn" title={t("code")} style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
</div>
<textarea
ref={noteRef}
id="note-textarea"
className="note-textarea"
placeholder={t("notesPlaceholder")}
value={note}
onChange={(e) => {
setNote(e.target.value);
debounceSave({ note: e.target.value });
}}
/>
</div>
{/* The Wide Adaptive Markdown Note Editor (Takes ALL Remaining Space) */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<MarkdownNoteEditor
value={note}
onChange={handleNoteChange}
onSave={() => save(task.id, { note })}
/>
</div>
{/* Sub-tasks */}
<div className="detail-section">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<div className="detail-section-label">{t("subtasks")}</div>
{/* Sub-tasks Section (Collapsible Accordion at Bottom) */}
<div style={{ borderTop: "1px solid var(--border)", paddingTop: 10 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
userSelect: "none",
marginBottom: showSubtasks ? 8 : 0,
}}
onClick={() => setShowSubtasks((p) => !p)}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
style={{ transform: showSubtasks ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span className="detail-section-label" style={{ marginBottom: 0 }}>{t("subtasks")}</span>
</div>
{subtasks.length > 0 && (
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>
<span style={{ fontSize: 11, color: "var(--text-tertiary)", fontWeight: 600 }}>
{completedCount}/{subtasks.length}
</span>
)}
</div>
{subtasks.length > 0 && (
<div className="progress-bar" style={{ marginBottom: 10 }}>
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
{showSubtasks && (
<div>
{subtasks.length > 0 && (
<div className="progress-bar" style={{ marginBottom: 8, height: 3 }}>
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
</div>
)}
<div className="detail-subtasks" style={{ maxHeight: 160, overflowY: "auto" }}>
{subtasks.map((sub) => (
<div
key={sub.id}
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
id={`detail-sub-${sub.id}`}
>
<button
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
onClick={() => toggleSubtask(sub)}
aria-label="Toggle subtask"
type="button"
/>
<span className="detail-subtask-title">{sub.title}</span>
<button
className="icon-btn"
style={{ width: 20, height: 20, opacity: 0.4 }}
onClick={() => deleteSubtask(sub.id)}
aria-label="Delete subtask"
type="button"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
<div className="add-subtask-row" style={{ padding: "4px 8px" }}>
<span style={{ fontSize: 15, lineHeight: 1, color: "var(--accent)" }}>+</span>
<input
id="add-subtask-input"
placeholder={t("addSubtaskPlaceholder")}
style={{ flex: 1, background: "none", fontSize: 12.5, color: "var(--text-primary)" }}
value={newSubtitle}
onChange={(e) => setNewSubtitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addSubtask();
}
}}
/>
{newSubtitle.trim() && (
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask} type="button">
{t("add")}
</button>
)}
</div>
</div>
</div>
)}
<div className="detail-subtasks">
{subtasks.map((sub) => (
<div
key={sub.id}
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
id={`detail-sub-${sub.id}`}
>
<button
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
onClick={() => toggleSubtask(sub)}
aria-label="Toggle subtask"
/>
<span className="detail-subtask-title">{sub.title}</span>
<button
className="icon-btn"
style={{ width: 20, height: 20, opacity: 0.4 }}
onClick={() => deleteSubtask(sub.id)}
aria-label="Delete subtask"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
<div className="add-subtask-row">
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span>
<input
id="add-subtask-input"
placeholder={t("addSubtaskPlaceholder")}
style={{ flex: 1, background: "none", fontSize: 13, color: "var(--text-primary)" }}
value={newSubtitle}
onChange={(e) => setNewSubtitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addSubtask();
}
}}
/>
{newSubtitle.trim() && (
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>
{t("add")}
</button>
)}
</div>
</div>
</div>
{/* Metadata */}
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}>
{t("created")}{" "}
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
{/* Footer Timestamp */}
<div style={{ fontSize: 11, color: "var(--text-tertiary)", display: "flex", justifyContent: "space-between" }}>
<span>
{t("created")}:{" "}
{new Date(task.createdAt).toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
</div>
</div>
</aside>