// Stylish dialog primitives + concrete dialogs (Edit/Add Item, Confirm). 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(() => { if (!open) return; const onKey = (e) => { if (e.key === "Escape") onClose?.(); }; document.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, [open, onClose]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose?.(); }}>
{children}
); } function DialogHeader({ eyebrow, title, onClose, id }) { return (
{eyebrow &&
{eyebrow}
}

{title}

{onClose && ( )}
); } // --- 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(""); const [tags, setTags] = useStateD([]); const [tagInput, setTagInput] = useStateD(""); const [reason, setReason] = useStateD(""); const [progress, setProgress] = useStateD(0); const titleRef = useRefD(null); useEffectD(() => { if (!open) return; setTitle(initial?.title || ""); setBody(initial?.body || null); setPriority(initial?.priority || "P2"); setStatus(initial?.status || "open"); setDue(initial?.due || ""); setTags(initial?.tags || []); setTagInput(""); setReason(initial?.reason || ""); setProgress(initial?.progress ?? 0); setTimeout(() => titleRef.current?.focus(), 50); }, [open, initial]); const addTag = (t) => { const v = t.trim().replace(/^#/, ""); if (!v) return; if (tags.includes(v)) return; setTags([...tags, v]); }; const removeTag = (t) => setTags(tags.filter(x => x !== t)); const submit = (e) => { e?.preventDefault(); if (!title.trim()) return; // Reconcile progress with status before submitting. let outProgress = snapProgress(progress); if (status === "done") outProgress = 100; onSubmit({ title: title.trim(), body: body || null, priority, status, due: due || null, tags, reason: status === "blocked" ? reason.trim() || null : null, progress: outProgress }); }; const tagSuggestions = recentTags.filter(t => !tags.includes(t)).slice(0, 8); return (
setTitle(e.target.value)} placeholder="What needs to happen?" required />
{PRIORITIES.map(p => ( ))}
setDue(e.target.value)}/>
{STATUSES.map(s => ( ))}
{status !== "cancelled" && (
{PROGRESS_STEPS.map(p => ( ))}
)} {status === "blocked" && (
setReason(e.target.value)} placeholder="Waiting for…"/>
)}
⌘↩ to save · Esc to cancel
); } // --- Confirm dialog --- function ConfirmDialog({ open, title, message, detail, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) { return (

{message}

{detail &&
{detail}
}
); } // --- Tag autocomplete (typed input + filtered dropdown) --- function TagAutocompleteInput({ tags, setTags, tagInput, setTagInput, addTag, removeTag, allTags }) { const [focused, setFocused] = useStateD(false); const [highlighted, setHighlighted] = useStateD(0); const inputRef = useRefD(null); const wrapRef = useRefD(null); const q = tagInput.trim().toLowerCase().replace(/^#/, ""); // suggestions: filter out already-applied; rank exact prefix > contains const pool = (allTags || []).filter(t => !tags.includes(t)); let suggestions; if (q) { const starts = pool.filter(t => t.toLowerCase().startsWith(q)); const contains = pool.filter(t => !t.toLowerCase().startsWith(q) && t.toLowerCase().includes(q)); suggestions = [...starts, ...contains].slice(0, 8); } else { suggestions = pool.slice(0, 8); // recents } const exactMatch = q && pool.some(t => t.toLowerCase() === q); const showCreate = q && !exactMatch && !tags.includes(q); // index 0 = "create new" (if shown), then suggestions const items = [ ...(showCreate ? [{ kind: "create", value: q }] : []), ...suggestions.map(t => ({ kind: "suggestion", value: t })) ]; useEffectD(() => { setHighlighted(0); }, [tagInput, focused]); const pick = (it) => { if (!it) return; addTag(it.value); setTagInput(""); setHighlighted(0); }; const onKey = (e) => { if (e.key === "ArrowDown") { e.preventDefault(); if (items.length) setHighlighted((h) => (h + 1) % items.length); } else if (e.key === "ArrowUp") { e.preventDefault(); if (items.length) setHighlighted((h) => (h - 1 + items.length) % items.length); } else if (e.key === "Enter") { e.preventDefault(); if (items[highlighted]) pick(items[highlighted]); else if (tagInput.trim()) { addTag(tagInput); setTagInput(""); } } else if (e.key === "," || e.key === "Tab") { if (tagInput.trim()) { e.preventDefault(); if (items[highlighted]) pick(items[highlighted]); else { addTag(tagInput); setTagInput(""); } } } else if (e.key === "Backspace" && !tagInput && tags.length) { removeTag(tags[tags.length - 1]); } else if (e.key === "Escape") { if (tagInput) { setTagInput(""); e.preventDefault(); } else inputRef.current?.blur(); } }; return (
{ if (e.target === e.currentTarget) inputRef.current?.focus(); }}> {tags.map(t => ( #{t} ))} setTagInput(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setTimeout(() => setFocused(false), 150)} onKeyDown={onKey} autoComplete="off" aria-autocomplete="list" aria-expanded={focused && items.length > 0} />
{focused && items.length > 0 && (
{!q && (
Recent
)} {items.map((it, i) => ( ))}
↑↓ to navigate ↵ to add esc to dismiss
)}
); } function TagMatchHL({ text, match }) { const i = text.toLowerCase().indexOf(match); if (i < 0) return `#${text}`; return ( <> #{text.slice(0, i)}{text.slice(i, i + match.length)}{text.slice(i + match.length)} ); } window.Dialog = Dialog; window.ItemDialog = ItemDialog; window.ConfirmDialog = ConfirmDialog; window.RichTextEditor = RichTextEditor;