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', `${file.name}`); + }; + reader.readAsDataURL(file); + }; + input.click(); + }, [execCmd]); + + const insertTable = useCallbackD(() => { + const html = '
Header 1Header 2
Cell 1Cell 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 => ``).join('')}${rows.map(r => `${r.map(c => ``).join('')}`).join('')}
${h}
${c}
`; + }); + + // Images (base64 or URL) + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + + // 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, '
      $1
    '); + 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 += `![${child.getAttribute('alt') || ''}](${child.getAttribute('src') || ''})`; 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 => ``).join('')}${rows.map(r => `${r.map(c => ``).join('')}`).join('')}
    ${h}
    ${c}
    `; + }); + + // Images (base64 or URL) + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + + // 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, '
      $1
    '); + 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 += `![${child.getAttribute('alt') || ''}](${child.getAttribute('src') || ''})`; + 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 });