// 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); const isEditingRef = useRefD(false); const prevValueRef = useRefD(value); // Initialize editor content only when value changes externally (not from our own edits) useEffectD(() => { if (editorRef.current && !isEditingRef.current) { // Update if value changed from previous or editor is empty if (prevValueRef.current !== value || editorRef.current.innerHTML === '') { const html = MarkdownUtils.toHtml(value || ''); editorRef.current.innerHTML = html; prevValueRef.current = value; } } }, [value]); const handleInput = useCallbackD(() => { if (editorRef.current) { isEditingRef.current = true; const md = MarkdownUtils.toMarkdown(editorRef.current.innerHTML); onChange?.(md || null); // Reset editing flag after a short delay to allow state updates setTimeout(() => { isEditingRef.current = false; }, 100); } }, [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', ' '); } // Auto-convert "- " or "* " at line start to bullet list if (e.key === ' ') { const sel = window.getSelection(); if (sel.rangeCount > 0) { const range = sel.getRangeAt(0); const node = range.startContainer; if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent; const offset = range.startOffset; // Check if cursor is right after "- " or "* " at line start if (offset === 1 && (text[0] === '-' || text[0] === '*')) { e.preventDefault(); // Remove the "-" or "*" character node.textContent = text.slice(1); // Convert to bullet list execCmd('insertUnorderedList'); return; } // Also check for "1. " to convert to numbered list if (offset === 2 && text[0] === '1' && text[1] === '.') { e.preventDefault(); node.textContent = text.slice(2); execCmd('insertOrderedList'); return; } } } } // 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 = [], isMultiProject = false, projects = [], defaultProjectId = null }) { 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 [createAsProject, setCreateAsProject] = useStateD(false); const [projectPath, setProjectPath] = useStateD(""); const [selectedProjectId, setSelectedProjectId] = useStateD(null); const titleRef = useRefD(null); const showProjectOption = isMultiProject && mode === "add"; const showPathInput = showProjectOption && createAsProject; const showProjectSelect = showProjectOption && !createAsProject && projects.length > 0; 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); setCreateAsProject(false); setProjectPath(""); setSelectedProjectId( defaultProjectId && projects.some(p => p.id === defaultProjectId) ? defaultProjectId : (projects[0] ? projects[0].id : null) ); 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(); // Item type requires a title; Project type only requires a path (title is an optional display name). if (showPathInput ? !projectPath.trim() : !title.trim()) return; // Reconcile progress with status before submitting. let outProgress = snapProgress(progress); if (status === "done") outProgress = 100; const payload = { title: title.trim(), body: body || null, priority, status, due: due || null, tags, reason: status === "blocked" ? reason.trim() || null : null, progress: outProgress }; if (isMultiProject) { const rawPath = projectPath.trim(); payload.createAsProject = showPathInput; payload.projectPath = showPathInput ? (rawPath.endsWith('.md') ? rawPath : rawPath.replace(/\/$/, '') + '/backlog.md') : null; payload.projectId = showProjectSelect ? selectedProjectId : null; } onSubmit(payload); }; const tagSuggestions = recentTags.filter(t => !tags.includes(t)).slice(0, 8); return (
{ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); } }}>
setTitle(e.target.value)} placeholder={showPathInput ? "Optional display name" : "What needs to happen?"} required={!showPathInput} readOnly={mode === "view"} />
{mode === "view" ? (
) : ( )}
{PRIORITIES.map(p => ( ))}
setDue(e.target.value)} readOnly={mode === "view"}/>
{STATUSES.map(s => ( ))}
{status !== "cancelled" && (
{PROGRESS_STEPS.map(p => ( ))}
)} {status === "blocked" && (
setReason(e.target.value)} placeholder="Waiting for…" readOnly={mode === "view"}/>
)}
{mode === "view" ? (
{tags.length === 0 ? No tags : tags.map(t => ( {t} ))}
) : ( )}
{showProjectOption && (
)} {showProjectSelect && (
)} {showPathInput && (
setProjectPath(e.target.value)} placeholder="/home/user/project/backlog.md" />
)}
{mode === "view" ? ( <> Read-only · archived {initial?.archived || ''}
) : ( <> ⌘↩ to save · Esc to cancel
)}
); } // --- Confirm dialog --- function ConfirmDialog({ open, title, message, detail, checkbox, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) { const [checked, setChecked] = useStateD(false); useEffectD(() => { if (open) setChecked(false); }, [open]); return (

{message}

{detail &&
{detail}
} {checkbox && ( )}
); } // --- Prompt dialog (styled input) --- function PromptDialog({ open, title, message, placeholder = "", confirmLabel = "OK", onCancel, onConfirm }) { const [value, setValue] = useStateD(""); const inputRef = useRefD(null); useEffectD(() => { if (open) { setValue(""); setTimeout(() => inputRef.current?.focus(), 50); } }, [open]); const submit = (e) => { e?.preventDefault(); if (!value.trim()) return; onConfirm(value.trim()); }; return (

{message}

setValue(e.target.value)} placeholder={placeholder} style={{marginTop: 12}} />
); } // --- 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.PromptDialog = PromptDialog; window.RichTextEditor = RichTextEditor;