diff --git a/README.md b/README.md
index 4d78d22..1d86956 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,9 @@ All your tasks live in `backlog.md`, structured as:
- [ ] [P0] Ship landing page *(due: 2025-06-01, priority: P0, progress: 50)*
+ > This is the **body** with rich text details.
+ > - Milestone 1: Design
+ > - Milestone 2: Build
- [x] Design mockups *(priority: P0, progress: 100)*
- [/] Implement frontend *(priority: P1, progress: 30)*
- [!] [P1] API integration *(priority: P1, reason: waiting for keys)*
@@ -97,9 +100,10 @@ You can edit this file in any text editor. The app detects external changes and
- **6 statuses** — open, in-progress (`/`), blocked (`!`), postponed (`>`), done (`x`), cancelled (`-`)
- **Priorities** — P0 (burning) through P3, with drag-and-drop reordering within each priority
- **Progress tracking** — 0–100% per task; done auto-sets to 100%
+- **Rich text body** — optional details field with WYSIWYG editor (bold, italic, links, headings, lists, code blocks, tables, images); stored as markdown; copy to clipboard as HTML (for Google Docs/Word), Markdown, or plain text
- **Due dates** — with overdue highlighting
- **Tags** — free-form labels with autocomplete
-- **Quick search** — instant text search with hierarchical parent visibility
+- **Quick search** — instant text search across title, body, and tags with hierarchical parent visibility
- **Integrity checks** — SHA-256 checksum on every save; warning-only on mismatch
- **Automatic backups** — timestamped on every save, rotating retention
- **Stats & metrics** — items created/completed, avg time in-progress, most active project — all from real history data
diff --git a/web/dialogs.jsx b/web/dialogs.jsx
index c71f4d6..c82cc18 100644
--- a/web/dialogs.jsx
+++ b/web/dialogs.jsx
@@ -1,6 +1,177 @@
// Stylish dialog primitives + concrete dialogs (Edit/Add Item, Confirm).
-const { useState: useStateD, useEffect: useEffectD, useRef: useRefD } = React;
+const { useState: useStateD, useEffect: useEffectD, useRef: useRefD, useCallback: useCallbackD } = React;
+
+// ---- Rich Text Editor (lightweight WYSIWYG using contenteditable) ----
+function RichTextEditor({ value, onChange, placeholder = "Add details...", collapsed = false, onExpand }) {
+ const editorRef = useRefD(null);
+ const [isExpanded, setIsExpanded] = useStateD(!collapsed || !!value);
+ const [showCopyMenu, setShowCopyMenu] = useStateD(false);
+ const [copyMenuPos, setCopyMenuPos] = useStateD({ x: 0, y: 0 });
+ const toolbarRef = useRefD(null);
+
+ // Initialize editor content
+ useEffectD(() => {
+ if (editorRef.current && value !== undefined) {
+ const html = MarkdownUtils.toHtml(value || '');
+ if (editorRef.current.innerHTML !== html) {
+ editorRef.current.innerHTML = html;
+ }
+ }
+ }, [value]);
+
+ const handleInput = useCallbackD(() => {
+ if (editorRef.current) {
+ const md = MarkdownUtils.toMarkdown(editorRef.current.innerHTML);
+ onChange?.(md || null);
+ }
+ }, [onChange]);
+
+ const execCmd = useCallbackD((cmd, val = null) => {
+ editorRef.current?.focus();
+ document.execCommand(cmd, false, val);
+ handleInput();
+ }, [handleInput]);
+
+ const handleKeyDown = useCallbackD((e) => {
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ execCmd('insertText', ' ');
+ }
+ // Ctrl/Cmd + B/I/K shortcuts
+ if (e.ctrlKey || e.metaKey) {
+ if (e.key === 'b') { e.preventDefault(); execCmd('bold'); }
+ if (e.key === 'i') { e.preventDefault(); execCmd('italic'); }
+ if (e.key === 'k') { e.preventDefault(); insertLink(); }
+ }
+ }, [execCmd]);
+
+ const insertLink = useCallbackD(() => {
+ const url = prompt('Enter URL:');
+ if (url) execCmd('createLink', url);
+ }, [execCmd]);
+
+ const insertImage = useCallbackD(() => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.onchange = async (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ execCmd('insertHTML', `
`);
+ };
+ reader.readAsDataURL(file);
+ };
+ input.click();
+ }, [execCmd]);
+
+ const insertTable = useCallbackD(() => {
+ const html = '
| Header 1 | Header 2 |
|---|
| Cell 1 | Cell 2 |
';
+ execCmd('insertHTML', html);
+ }, [execCmd]);
+
+ const insertCodeBlock = useCallbackD(() => {
+ execCmd('insertHTML', 'code here
');
+ }, [execCmd]);
+
+ const handleCopyMenu = useCallbackD((e) => {
+ e.preventDefault();
+ const rect = e.currentTarget.getBoundingClientRect();
+ setCopyMenuPos({ x: rect.left, y: rect.bottom + 4 });
+ setShowCopyMenu(true);
+ }, []);
+
+ const copyAs = useCallbackD(async (format) => {
+ setShowCopyMenu(false);
+ const html = editorRef.current?.innerHTML || '';
+ const md = MarkdownUtils.toMarkdown(html);
+ if (format === 'html') await ClipboardUtils.copyHtml(html, md);
+ else if (format === 'markdown') await ClipboardUtils.copyMarkdown(md);
+ else if (format === 'text') await ClipboardUtils.copyPlainText(html);
+ }, []);
+
+ const expand = useCallbackD(() => {
+ setIsExpanded(true);
+ onExpand?.();
+ setTimeout(() => editorRef.current?.focus(), 50);
+ }, [onExpand]);
+
+ // Close copy menu on outside click
+ useEffectD(() => {
+ if (!showCopyMenu) return;
+ const close = () => setShowCopyMenu(false);
+ document.addEventListener('click', close);
+ return () => document.removeEventListener('click', close);
+ }, [showCopyMenu]);
+
+ if (!isExpanded) {
+ return (
+
+ {placeholder}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showCopyMenu && (
+
+
+
+
+
+ )}
+
{
+ // Allow default context menu but add copy options via showCopyMenu
+ }}
+ />
+
+ );
+}
function Dialog({ open, onClose, children, width = 460, labelledBy }) {
useEffectD(() => {
@@ -43,6 +214,7 @@ function DialogHeader({ eyebrow, title, onClose, id }) {
// --- Item editor (used for both edit and add) ---
function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] }) {
const [title, setTitle] = useStateD("");
+ const [body, setBody] = useStateD(null);
const [priority, setPriority] = useStateD("P2");
const [status, setStatus] = useStateD("open");
const [due, setDue] = useStateD("");
@@ -55,6 +227,7 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
useEffectD(() => {
if (!open) return;
setTitle(initial?.title || "");
+ setBody(initial?.body || null);
setPriority(initial?.priority || "P2");
setStatus(initial?.status || "open");
setDue(initial?.due || "");
@@ -81,6 +254,7 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
if (status === "done") outProgress = 100;
onSubmit({
title: title.trim(),
+ body: body || null,
priority, status,
due: due || null,
tags,
@@ -114,6 +288,16 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
/>
+
+
+
+
+
@@ -370,3 +554,4 @@ function TagMatchHL({ text, match }) {
window.Dialog = Dialog;
window.ItemDialog = ItemDialog;
window.ConfirmDialog = ConfirmDialog;
+window.RichTextEditor = RichTextEditor;
diff --git a/web/helpers.jsx b/web/helpers.jsx
index a7e10bc..1a42d32 100644
--- a/web/helpers.jsx
+++ b/web/helpers.jsx
@@ -100,7 +100,7 @@ function filterTree(items, f) {
}
if (f.text) {
const q = f.text.toLowerCase();
- const hay = [it.title, it.reason, ...(it.tags || [])].filter(Boolean).join(" ").toLowerCase();
+ const hay = [it.title, it.body, it.reason, ...(it.tags || [])].filter(Boolean).join(" ").toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
@@ -358,10 +358,173 @@ function ProgressGauge({ value, onClick, interactive = false, size = "sm" }) {
);
}
+// ---- Markdown ↔ HTML conversion (lightweight, no external deps) ----
+const MarkdownUtils = {
+ // Convert markdown to HTML for rendering in editor
+ toHtml(md) {
+ if (!md) return '';
+ let html = md;
+
+ // Escape HTML entities first
+ html = html.replace(/&/g, '&').replace(//g, '>');
+
+ // Code blocks (``` ... ```) - must be before inline code
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
+ `
${code.trim()}
`);
+
+ // Tables
+ html = html.replace(/^\|(.+)\|\s*\n\|[-:| ]+\|\s*\n((?:\|.+\|\s*\n?)+)/gm, (_, header, body) => {
+ const headers = header.split('|').map(c => c.trim()).filter(Boolean);
+ const rows = body.trim().split('\n').map(row =>
+ row.split('|').map(c => c.trim()).filter(Boolean)
+ );
+ return `
${headers.map(h => `| ${h} | `).join('')}
${rows.map(r => `${r.map(c => `| ${c} | `).join('')}
`).join('')}
`;
+ });
+
+ // Images (base64 or URL)
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '

');
+
+ // Links
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
$1');
+
+ // Bold **text** or __text__
+ html = html.replace(/\*\*(.+?)\*\*/g, '
$1');
+ html = html.replace(/__(.+?)__/g, '
$1');
+
+ // Italic *text* or _text_ (not inside words)
+ html = html.replace(/(?$1');
+ html = html.replace(/(?$1');
+
+ // Inline code `code`
+ html = html.replace(/`([^`]+)`/g, '
$1');
+
+ // Headings
+ html = html.replace(/^### (.+)$/gm, '
$1
');
+ html = html.replace(/^## (.+)$/gm, '
$1
');
+ html = html.replace(/^# (.+)$/gm, '
$1
');
+
+ // Lists - unordered
+ html = html.replace(/^[-*] (.+)$/gm, '
$1');
+ // Lists - ordered
+ html = html.replace(/^\d+\. (.+)$/gm, '
$1');
+
+ // Wrap consecutive list items
+ html = html.replace(/((?:
.+<\/li>\s*)+)/g, '');
+ html = html.replace(/((?:.+<\/li>\s*)+)/g, '$1
');
+ html = html.replace(/ data-list="[uo]l"/g, '');
+
+ // Paragraphs - wrap remaining text blocks
+ html = html.split('\n\n').map(block => {
+ block = block.trim();
+ if (!block) return '';
+ if (/^<(h[1-3]|ul|ol|pre|table|p)/.test(block)) return block;
+ if (block.includes('<')) return block; // Already has HTML
+ return `${block.replace(/\n/g, '
')}
`;
+ }).join('\n');
+
+ return html;
+ },
+
+ // Convert HTML from editor back to markdown
+ toMarkdown(html) {
+ if (!html) return '';
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ return this._nodeToMd(doc.body).trim();
+ },
+
+ _nodeToMd(node) {
+ let md = '';
+ for (const child of node.childNodes) {
+ if (child.nodeType === Node.TEXT_NODE) {
+ md += child.textContent;
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
+ const tag = child.tagName.toLowerCase();
+ const inner = this._nodeToMd(child);
+ switch (tag) {
+ case 'strong': case 'b': md += `**${inner}**`; break;
+ case 'em': case 'i': md += `*${inner}*`; break;
+ case 'code':
+ if (child.parentElement?.tagName === 'PRE') md += inner;
+ else md += `\`${inner}\``;
+ break;
+ case 'pre': md += `\`\`\`\n${inner}\n\`\`\`\n\n`; break;
+ case 'a': md += `[${inner}](${child.getAttribute('href') || ''})`; break;
+ case 'img': md += ` || ''})`; break;
+ case 'h1': md += `# ${inner}\n\n`; break;
+ case 'h2': md += `## ${inner}\n\n`; break;
+ case 'h3': md += `### ${inner}\n\n`; break;
+ case 'p': md += `${inner}\n\n`; break;
+ case 'br': md += '\n'; break;
+ case 'li': md += `- ${inner}\n`; break;
+ case 'ul': case 'ol': md += inner + '\n'; break;
+ case 'table': md += this._tableToMd(child) + '\n\n'; break;
+ case 'thead': case 'tbody': case 'tr': case 'th': case 'td': md += inner; break;
+ case 'div': md += inner + '\n'; break;
+ default: md += inner;
+ }
+ }
+ }
+ return md;
+ },
+
+ _tableToMd(table) {
+ const rows = Array.from(table.querySelectorAll('tr'));
+ if (!rows.length) return '';
+ const lines = [];
+ rows.forEach((row, i) => {
+ const cells = Array.from(row.querySelectorAll('th, td')).map(c => c.textContent.trim());
+ lines.push('| ' + cells.join(' | ') + ' |');
+ if (i === 0) lines.push('|' + cells.map(() => '---').join('|') + '|');
+ });
+ return lines.join('\n');
+ },
+
+ // Convert HTML to plain text
+ toPlainText(html) {
+ if (!html) return '';
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ return doc.body.textContent || '';
+ }
+};
+
+// ---- Clipboard utilities for multiple formats ----
+const ClipboardUtils = {
+ // Copy to clipboard with multiple formats (HTML primary, plain text also)
+ async copyHtml(html, markdown) {
+ const plain = MarkdownUtils.toPlainText(html);
+ try {
+ // Modern API: write multiple formats
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ 'text/html': new Blob([html], { type: 'text/html' }),
+ 'text/plain': new Blob([plain], { type: 'text/plain' })
+ })
+ ]);
+ return true;
+ } catch {
+ // Fallback: plain text only
+ await navigator.clipboard.writeText(plain);
+ return true;
+ }
+ },
+
+ async copyMarkdown(markdown) {
+ await navigator.clipboard.writeText(markdown);
+ return true;
+ },
+
+ async copyPlainText(html) {
+ const plain = MarkdownUtils.toPlainText(html);
+ await navigator.clipboard.writeText(plain);
+ return true;
+ }
+};
+
Object.assign(window, {
STATUSES, STATUS_BY_KEY, STATUS_ORDER, PRIORITIES,
parseDate, daysBetween, isOverdue, dueRelative, fmtTimestamp, fmtBytes, fmtRelative, fmtShortDate,
walkTree, findItem, allTags, countAll, countByStatus, filterTree,
Icon, StatusIcon, ColorStatusIcon, STATUS_COLORS, STATUS_EMOJI, StatusStyleContext,
- PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge
+ PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge,
+ MarkdownUtils, ClipboardUtils
});
diff --git a/web/storage.jsx b/web/storage.jsx
index 4020543..a13c5c2 100644
--- a/web/storage.jsx
+++ b/web/storage.jsx
@@ -70,9 +70,30 @@ const Parser = {
const uid = () => 'i-' + (++idSeq).toString(36);
const entries = [];
- const stack = [{ children: entries, depth: -1, level: 0 }];
+ const stack = [{ children: entries, depth: -1, level: 0, item: null }];
+ const lines = entriesText.split('\n');
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // Check for body line (starts with whitespace + "> ")
+ const bodyMatch = line.match(/^(\s*)>\s?(.*)$/);
+ if (bodyMatch) {
+ // Body line - attach to current item at matching depth
+ const bodyDepth = bodyMatch[1].length / 2;
+ const bodyLine = bodyMatch[2];
+ // Find the item at this depth
+ for (let j = stack.length - 1; j >= 0; j--) {
+ if (stack[j].depth === bodyDepth && stack[j].item) {
+ if (!stack[j].item.body) stack[j].item.body = '';
+ else stack[j].item.body += '\n';
+ stack[j].item.body += bodyLine;
+ break;
+ }
+ }
+ continue;
+ }
- for (const line of entriesText.split('\n')) {
const m = line.match(/^(\s*)[-*]\s*\[([ x!>/\-])\]\s*(.*)$/);
if (!m) continue;
@@ -108,9 +129,10 @@ const Parser = {
progress: status === 'done' ? 100 : progress,
collapsed: false,
children: [],
+ body: null,
};
stack[stack.length - 1].children.push(item);
- stack.push({ children: item.children, depth, level: item.level });
+ stack.push({ children: item.children, depth, level: item.level, item });
}
return { entries, history, meta, checksumOk };
@@ -143,6 +165,12 @@ const Parser = {
const metaStr = meta.length ? ` *(${meta.join(', ')})*` : '';
const prefix = it.priority ? `[${it.priority}] ` : '';
lines.push(`${indent}- [${GLYPH[it.status] || ' '}] ${prefix}${it.title}${metaStr}`);
+ // Serialize body as indented blockquote lines
+ if (it.body) {
+ for (const bodyLine of it.body.split('\n')) {
+ lines.push(`${indent}> ${bodyLine}`);
+ }
+ }
if (it.children?.length) {
lines.push(...this._serializeEntries(it.children, depth + 1).split('\n'));
}
diff --git a/web/styles.css b/web/styles.css
index 52b62a6..130834c 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -89,6 +89,11 @@ html[data-theme="dark"] .restore-warn-title { color: oklch(0.92 0.05 75); }
html[data-theme="dark"] .restore-warn-body { color: oklch(0.78 0.04 70); }
html[data-theme="dark"] .restore-warn-body code { background: oklch(0 0 0 / 0.30); }
html[data-theme="dark"] .drag-handle { color: oklch(0.50 0.005 60); }
+html[data-theme="dark"] .rte-toolbar { background: oklch(0.25 0.010 60); }
+html[data-theme="dark"] .rte-code-block,
+html[data-theme="dark"] .rte-inline-code { background: oklch(0.25 0.010 60); }
+html[data-theme="dark"] .rte-table th { background: oklch(0.25 0.010 60); }
+html[data-theme="dark"] .rte-table td { background: oklch(0.22 0.008 60); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
@@ -1049,6 +1054,197 @@ input { font: inherit; color: inherit; }
background: var(--bg-2); color: var(--ink-2);
}
+/* ---------- Rich Text Editor ---------- */
+.rte-wrap {
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--bg);
+ overflow: hidden;
+ transition: border-color 0.12s, box-shadow 0.12s;
+}
+.rte-wrap:focus-within {
+ border-color: var(--accent-edge);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+ background: var(--paper);
+}
+
+.rte-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--rule);
+ background: var(--bg-2);
+ flex-wrap: wrap;
+}
+.rte-toolbar button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--ink-2);
+ background: transparent;
+ transition: background 0.1s, color 0.1s;
+}
+.rte-toolbar button:hover {
+ background: var(--paper);
+ color: var(--ink);
+}
+.rte-toolbar-sep {
+ width: 1px;
+ height: 18px;
+ background: var(--rule);
+ margin: 0 4px;
+}
+.rte-copy-btn {
+ width: auto !important;
+ padding: 0 6px;
+ gap: 3px;
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.rte-copy-menu {
+ position: fixed;
+ background: var(--paper);
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-pop);
+ padding: 4px;
+ z-index: 100;
+ min-width: 160px;
+}
+.rte-copy-menu button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 7px 10px;
+ border-radius: 4px;
+ font-size: 12.5px;
+ color: var(--ink);
+}
+.rte-copy-menu button:hover {
+ background: var(--accent-soft);
+ color: var(--accent-deep);
+}
+
+.rte-editor {
+ min-height: 80px;
+ max-height: 240px;
+ overflow-y: auto;
+ padding: 10px 12px;
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--ink);
+ outline: none;
+}
+.rte-editor:empty::before {
+ content: attr(data-placeholder);
+ color: var(--ink-3);
+ pointer-events: none;
+}
+.rte-editor p { margin: 0 0 0.75em; }
+.rte-editor p:last-child { margin-bottom: 0; }
+.rte-editor h1, .rte-editor h2, .rte-editor h3 {
+ margin: 0.8em 0 0.4em;
+ font-weight: 600;
+ line-height: 1.3;
+}
+.rte-editor h1:first-child,
+.rte-editor h2:first-child,
+.rte-editor h3:first-child { margin-top: 0; }
+.rte-editor h1 { font-size: 1.3em; }
+.rte-editor h2 { font-size: 1.15em; }
+.rte-editor h3 { font-size: 1em; }
+.rte-editor ul, .rte-editor ol {
+ margin: 0.5em 0;
+ padding-left: 1.5em;
+}
+.rte-editor li { margin: 0.25em 0; }
+.rte-editor a {
+ color: var(--accent-deep);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.rte-editor a:hover { color: var(--accent); }
+.rte-editor strong { font-weight: 600; }
+.rte-editor em { font-style: italic; }
+
+.rte-inline-code {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+ padding: 1px 5px;
+ background: var(--bg-2);
+ border-radius: 3px;
+ color: var(--ink);
+}
+.rte-code-block {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ line-height: 1.5;
+ padding: 10px 12px;
+ background: var(--bg-2);
+ border-radius: var(--radius);
+ margin: 0.75em 0;
+ overflow-x: auto;
+ white-space: pre;
+}
+.rte-code-block code {
+ background: none;
+ padding: 0;
+ font-size: inherit;
+}
+
+.rte-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0.75em 0;
+ font-size: 12.5px;
+}
+.rte-table th, .rte-table td {
+ border: 1px solid var(--rule);
+ padding: 6px 10px;
+ text-align: left;
+}
+.rte-table th {
+ background: var(--bg-2);
+ font-weight: 600;
+}
+.rte-table td { background: var(--paper); }
+
+.rte-img {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--radius);
+ margin: 0.5em 0;
+}
+
+/* Collapsed state */
+.rte-collapsed {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ min-height: 40px;
+ border: 1px dashed var(--rule);
+ border-radius: var(--radius);
+ background: var(--bg);
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s;
+}
+.rte-collapsed:hover {
+ border-color: var(--accent-edge);
+ background: var(--paper);
+}
+.rte-collapsed-placeholder {
+ font-size: 13px;
+ color: var(--ink-3);
+}
+
/* ---------- Import / Export dialog ---------- */
.ie-tabs { display: flex; gap: 4px; margin-bottom: 12px; border-bottom: 1px solid var(--rule); align-items: stretch; }
.ie-tab {
diff --git a/webapp/index-style-v2.html b/webapp/index-style-v2.html
index d70a970..df39072 100644
--- a/webapp/index-style-v2.html
+++ b/webapp/index-style-v2.html
@@ -101,6 +101,11 @@ html[data-theme="dark"] .restore-warn-title { color: oklch(0.92 0.05 75); }
html[data-theme="dark"] .restore-warn-body { color: oklch(0.78 0.04 70); }
html[data-theme="dark"] .restore-warn-body code { background: oklch(0 0 0 / 0.30); }
html[data-theme="dark"] .drag-handle { color: oklch(0.50 0.005 60); }
+html[data-theme="dark"] .rte-toolbar { background: oklch(0.25 0.010 60); }
+html[data-theme="dark"] .rte-code-block,
+html[data-theme="dark"] .rte-inline-code { background: oklch(0.25 0.010 60); }
+html[data-theme="dark"] .rte-table th { background: oklch(0.25 0.010 60); }
+html[data-theme="dark"] .rte-table td { background: oklch(0.22 0.008 60); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
@@ -1061,6 +1066,197 @@ input { font: inherit; color: inherit; }
background: var(--bg-2); color: var(--ink-2);
}
+/* ---------- Rich Text Editor ---------- */
+.rte-wrap {
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--bg);
+ overflow: hidden;
+ transition: border-color 0.12s, box-shadow 0.12s;
+}
+.rte-wrap:focus-within {
+ border-color: var(--accent-edge);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+ background: var(--paper);
+}
+
+.rte-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--rule);
+ background: var(--bg-2);
+ flex-wrap: wrap;
+}
+.rte-toolbar button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--ink-2);
+ background: transparent;
+ transition: background 0.1s, color 0.1s;
+}
+.rte-toolbar button:hover {
+ background: var(--paper);
+ color: var(--ink);
+}
+.rte-toolbar-sep {
+ width: 1px;
+ height: 18px;
+ background: var(--rule);
+ margin: 0 4px;
+}
+.rte-copy-btn {
+ width: auto !important;
+ padding: 0 6px;
+ gap: 3px;
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.rte-copy-menu {
+ position: fixed;
+ background: var(--paper);
+ border: 1px solid var(--rule);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-pop);
+ padding: 4px;
+ z-index: 100;
+ min-width: 160px;
+}
+.rte-copy-menu button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 7px 10px;
+ border-radius: 4px;
+ font-size: 12.5px;
+ color: var(--ink);
+}
+.rte-copy-menu button:hover {
+ background: var(--accent-soft);
+ color: var(--accent-deep);
+}
+
+.rte-editor {
+ min-height: 80px;
+ max-height: 240px;
+ overflow-y: auto;
+ padding: 10px 12px;
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--ink);
+ outline: none;
+}
+.rte-editor:empty::before {
+ content: attr(data-placeholder);
+ color: var(--ink-3);
+ pointer-events: none;
+}
+.rte-editor p { margin: 0 0 0.75em; }
+.rte-editor p:last-child { margin-bottom: 0; }
+.rte-editor h1, .rte-editor h2, .rte-editor h3 {
+ margin: 0.8em 0 0.4em;
+ font-weight: 600;
+ line-height: 1.3;
+}
+.rte-editor h1:first-child,
+.rte-editor h2:first-child,
+.rte-editor h3:first-child { margin-top: 0; }
+.rte-editor h1 { font-size: 1.3em; }
+.rte-editor h2 { font-size: 1.15em; }
+.rte-editor h3 { font-size: 1em; }
+.rte-editor ul, .rte-editor ol {
+ margin: 0.5em 0;
+ padding-left: 1.5em;
+}
+.rte-editor li { margin: 0.25em 0; }
+.rte-editor a {
+ color: var(--accent-deep);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.rte-editor a:hover { color: var(--accent); }
+.rte-editor strong { font-weight: 600; }
+.rte-editor em { font-style: italic; }
+
+.rte-inline-code {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+ padding: 1px 5px;
+ background: var(--bg-2);
+ border-radius: 3px;
+ color: var(--ink);
+}
+.rte-code-block {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ line-height: 1.5;
+ padding: 10px 12px;
+ background: var(--bg-2);
+ border-radius: var(--radius);
+ margin: 0.75em 0;
+ overflow-x: auto;
+ white-space: pre;
+}
+.rte-code-block code {
+ background: none;
+ padding: 0;
+ font-size: inherit;
+}
+
+.rte-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0.75em 0;
+ font-size: 12.5px;
+}
+.rte-table th, .rte-table td {
+ border: 1px solid var(--rule);
+ padding: 6px 10px;
+ text-align: left;
+}
+.rte-table th {
+ background: var(--bg-2);
+ font-weight: 600;
+}
+.rte-table td { background: var(--paper); }
+
+.rte-img {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--radius);
+ margin: 0.5em 0;
+}
+
+/* Collapsed state */
+.rte-collapsed {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ min-height: 40px;
+ border: 1px dashed var(--rule);
+ border-radius: var(--radius);
+ background: var(--bg);
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s;
+}
+.rte-collapsed:hover {
+ border-color: var(--accent-edge);
+ background: var(--paper);
+}
+.rte-collapsed-placeholder {
+ font-size: 13px;
+ color: var(--ink-3);
+}
+
/* ---------- Import / Export dialog ---------- */
.ie-tabs { display: flex; gap: 4px; margin-bottom: 12px; border-bottom: 1px solid var(--rule); align-items: stretch; }
.ie-tab {
@@ -1576,7 +1772,7 @@ function filterTree(items, f) {
}
if (f.text) {
const q = f.text.toLowerCase();
- const hay = [it.title, it.reason, ...(it.tags || [])].filter(Boolean).join(" ").toLowerCase();
+ const hay = [it.title, it.body, it.reason, ...(it.tags || [])].filter(Boolean).join(" ").toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
@@ -2286,6 +2482,197 @@ function ProgressGauge({
className: "gauge-num"
}, v, "%"));
}
+
+// ---- Markdown ↔ HTML conversion (lightweight, no external deps) ----
+const MarkdownUtils = {
+ // Convert markdown to HTML for rendering in editor
+ toHtml(md) {
+ if (!md) return '';
+ let html = md;
+
+ // Escape HTML entities first
+ html = html.replace(/&/g, '&').replace(//g, '>');
+
+ // Code blocks (``` ... ```) - must be before inline code
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `${code.trim()}
`);
+
+ // Tables
+ html = html.replace(/^\|(.+)\|\s*\n\|[-:| ]+\|\s*\n((?:\|.+\|\s*\n?)+)/gm, (_, header, body) => {
+ const headers = header.split('|').map(c => c.trim()).filter(Boolean);
+ const rows = body.trim().split('\n').map(row => row.split('|').map(c => c.trim()).filter(Boolean));
+ return `${headers.map(h => `| ${h} | `).join('')}
${rows.map(r => `${r.map(c => `| ${c} | `).join('')}
`).join('')}
`;
+ });
+
+ // Images (base64 or URL)
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '
');
+
+ // Links
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
+
+ // Bold **text** or __text__
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1');
+ html = html.replace(/__(.+?)__/g, '$1');
+
+ // Italic *text* or _text_ (not inside words)
+ html = html.replace(/(?$1');
+ html = html.replace(/(?$1');
+
+ // Inline code `code`
+ html = html.replace(/`([^`]+)`/g, '$1');
+
+ // Headings
+ html = html.replace(/^### (.+)$/gm, '$1
');
+ html = html.replace(/^## (.+)$/gm, '$1
');
+ html = html.replace(/^# (.+)$/gm, '$1
');
+
+ // Lists - unordered
+ html = html.replace(/^[-*] (.+)$/gm, '$1');
+ // Lists - ordered
+ html = html.replace(/^\d+\. (.+)$/gm, '
$1');
+
+ // Wrap consecutive list items
+ html = html.replace(/((?:
.+<\/li>\s*)+)/g, '');
+ html = html.replace(/((?:.+<\/li>\s*)+)/g, '$1
');
+ html = html.replace(/ data-list="[uo]l"/g, '');
+
+ // Paragraphs - wrap remaining text blocks
+ html = html.split('\n\n').map(block => {
+ block = block.trim();
+ if (!block) return '';
+ if (/^<(h[1-3]|ul|ol|pre|table|p)/.test(block)) return block;
+ if (block.includes('<')) return block; // Already has HTML
+ return `${block.replace(/\n/g, '
')}
`;
+ }).join('\n');
+ return html;
+ },
+ // Convert HTML from editor back to markdown
+ toMarkdown(html) {
+ if (!html) return '';
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ return this._nodeToMd(doc.body).trim();
+ },
+ _nodeToMd(node) {
+ let md = '';
+ for (const child of node.childNodes) {
+ if (child.nodeType === Node.TEXT_NODE) {
+ md += child.textContent;
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
+ const tag = child.tagName.toLowerCase();
+ const inner = this._nodeToMd(child);
+ switch (tag) {
+ case 'strong':
+ case 'b':
+ md += `**${inner}**`;
+ break;
+ case 'em':
+ case 'i':
+ md += `*${inner}*`;
+ break;
+ case 'code':
+ if (child.parentElement?.tagName === 'PRE') md += inner;else md += `\`${inner}\``;
+ break;
+ case 'pre':
+ md += `\`\`\`\n${inner}\n\`\`\`\n\n`;
+ break;
+ case 'a':
+ md += `[${inner}](${child.getAttribute('href') || ''})`;
+ break;
+ case 'img':
+ md += ` || ''})`;
+ break;
+ case 'h1':
+ md += `# ${inner}\n\n`;
+ break;
+ case 'h2':
+ md += `## ${inner}\n\n`;
+ break;
+ case 'h3':
+ md += `### ${inner}\n\n`;
+ break;
+ case 'p':
+ md += `${inner}\n\n`;
+ break;
+ case 'br':
+ md += '\n';
+ break;
+ case 'li':
+ md += `- ${inner}\n`;
+ break;
+ case 'ul':
+ case 'ol':
+ md += inner + '\n';
+ break;
+ case 'table':
+ md += this._tableToMd(child) + '\n\n';
+ break;
+ case 'thead':
+ case 'tbody':
+ case 'tr':
+ case 'th':
+ case 'td':
+ md += inner;
+ break;
+ case 'div':
+ md += inner + '\n';
+ break;
+ default:
+ md += inner;
+ }
+ }
+ }
+ return md;
+ },
+ _tableToMd(table) {
+ const rows = Array.from(table.querySelectorAll('tr'));
+ if (!rows.length) return '';
+ const lines = [];
+ rows.forEach((row, i) => {
+ const cells = Array.from(row.querySelectorAll('th, td')).map(c => c.textContent.trim());
+ lines.push('| ' + cells.join(' | ') + ' |');
+ if (i === 0) lines.push('|' + cells.map(() => '---').join('|') + '|');
+ });
+ return lines.join('\n');
+ },
+ // Convert HTML to plain text
+ toPlainText(html) {
+ if (!html) return '';
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ return doc.body.textContent || '';
+ }
+};
+
+// ---- Clipboard utilities for multiple formats ----
+const ClipboardUtils = {
+ // Copy to clipboard with multiple formats (HTML primary, plain text also)
+ async copyHtml(html, markdown) {
+ const plain = MarkdownUtils.toPlainText(html);
+ try {
+ // Modern API: write multiple formats
+ await navigator.clipboard.write([new ClipboardItem({
+ 'text/html': new Blob([html], {
+ type: 'text/html'
+ }),
+ 'text/plain': new Blob([plain], {
+ type: 'text/plain'
+ })
+ })]);
+ return true;
+ } catch {
+ // Fallback: plain text only
+ await navigator.clipboard.writeText(plain);
+ return true;
+ }
+ },
+ async copyMarkdown(markdown) {
+ await navigator.clipboard.writeText(markdown);
+ return true;
+ },
+ async copyPlainText(html) {
+ const plain = MarkdownUtils.toPlainText(html);
+ await navigator.clipboard.writeText(plain);
+ return true;
+ }
+};
Object.assign(window, {
STATUSES,
STATUS_BY_KEY,
@@ -2316,7 +2703,9 @@ Object.assign(window, {
progressFor,
shouldShowProgress,
cycleProgress,
- ProgressGauge
+ ProgressGauge,
+ MarkdownUtils,
+ ClipboardUtils
});