Files
backlog/web/dialogs.jsx
T
Ilia Sharin 95a628249d Fix body field mismatch when switching between items in edit dialog
The RichTextEditor was not updating its content when switching from one
item to another because initializedRef was only reset when value became
null. Now uses prevValueRef to properly detect value changes.
2026-08-20 13:01:55 -04:00

593 lines
23 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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', `<img src="${reader.result}" alt="${file.name}" class="rte-img">`);
};
reader.readAsDataURL(file);
};
input.click();
}, [execCmd]);
const insertTable = useCallbackD(() => {
const html = '<table class="rte-table"><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead><tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table><p><br></p>';
execCmd('insertHTML', html);
}, [execCmd]);
const insertCodeBlock = useCallbackD(() => {
execCmd('insertHTML', '<pre class="rte-code-block"><code>code here</code></pre><p><br></p>');
}, [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 (
<div className="rte-collapsed" onClick={expand}>
<span className="rte-collapsed-placeholder">{placeholder}</span>
<Icon name="plus" size={12} />
</div>
);
}
return (
<div className="rte-wrap">
<div className="rte-toolbar" ref={toolbarRef}>
<button type="button" onClick={() => execCmd('bold')} title="Bold (Ctrl+B)"><strong>B</strong></button>
<button type="button" onClick={() => execCmd('italic')} title="Italic (Ctrl+I)"><em>I</em></button>
<span className="rte-toolbar-sep" />
<button type="button" onClick={insertLink} title="Link (Ctrl+K)">
<svg width="14" height="14" viewBox="0 0 20 20"><path d="M8 12l4-4M6 10l-1 1a3 3 0 0 0 4.24 4.24l1-1M14 10l1-1a3 3 0 0 0-4.24-4.24l-1 1" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
</button>
<span className="rte-toolbar-sep" />
<button type="button" onClick={() => execCmd('formatBlock', 'h1')} title="Heading 1">H1</button>
<button type="button" onClick={() => execCmd('formatBlock', 'h2')} title="Heading 2">H2</button>
<button type="button" onClick={() => execCmd('formatBlock', 'h3')} title="Heading 3">H3</button>
<span className="rte-toolbar-sep" />
<button type="button" onClick={() => execCmd('insertUnorderedList')} title="Bullet list">
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><line x1="8" y1="6" x2="17" y2="6"/><line x1="8" y1="10" x2="17" y2="10"/><line x1="8" y1="14" x2="17" y2="14"/><circle cx="4" cy="6" r="1" fill="currentColor"/><circle cx="4" cy="10" r="1" fill="currentColor"/><circle cx="4" cy="14" r="1" fill="currentColor"/></g></svg>
</button>
<button type="button" onClick={() => execCmd('insertOrderedList')} title="Numbered list">
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><line x1="8" y1="6" x2="17" y2="6"/><line x1="8" y1="10" x2="17" y2="10"/><line x1="8" y1="14" x2="17" y2="14"/><text x="3" y="7" fontSize="5" fill="currentColor" stroke="none">1</text><text x="3" y="11" fontSize="5" fill="currentColor" stroke="none">2</text><text x="3" y="15" fontSize="5" fill="currentColor" stroke="none">3</text></g></svg>
</button>
<span className="rte-toolbar-sep" />
<button type="button" onClick={insertCodeBlock} title="Code block">
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="7,5 3,10 7,15"/><polyline points="13,5 17,10 13,15"/></g></svg>
</button>
<button type="button" onClick={insertTable} title="Table">
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="4" width="14" height="12" rx="1.5"/><line x1="3" y1="8" x2="17" y2="8"/><line x1="10" y1="8" x2="10" y2="16"/></g></svg>
</button>
<button type="button" onClick={insertImage} title="Image">
<svg width="14" height="14" viewBox="0 0 20 20"><g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><rect x="3" y="4" width="14" height="12" rx="1.5"/><circle cx="7" cy="8" r="1.5"/><path d="M3 14l4-4 3 3 4-4 3 3"/></g></svg>
</button>
<span className="rte-toolbar-sep" />
<button type="button" onClick={handleCopyMenu} title="Copy as..." className="rte-copy-btn">
<Icon name="copy" size={12} />
<svg width="8" height="8" viewBox="0 0 10 10"><polyline points="2,4 5,7 8,4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
</div>
{showCopyMenu && (
<div className="rte-copy-menu" style={{ left: copyMenuPos.x, top: copyMenuPos.y }}>
<button type="button" onClick={() => copyAs('html')}>Copy for Docs/Word</button>
<button type="button" onClick={() => copyAs('markdown')}>Copy as Markdown</button>
<button type="button" onClick={() => copyAs('text')}>Copy as Plain Text</button>
</div>
)}
<div
ref={editorRef}
className="rte-editor"
contentEditable
onInput={handleInput}
onKeyDown={handleKeyDown}
data-placeholder={placeholder}
onContextMenu={(e) => {
// Allow default context menu but add copy options via showCopyMenu
}}
/>
</div>
);
}
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 (
<div className="dlg-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose?.(); }}>
<div className="dlg" role="dialog" aria-modal="true" aria-labelledby={labelledBy} style={{ maxWidth: width }}>
{children}
</div>
</div>
);
}
function DialogHeader({ eyebrow, title, onClose, id }) {
return (
<div className="dlg-head">
<div>
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
<h3 id={id} className="dlg-title">{title}</h3>
</div>
{onClose && (
<button className="dlg-close" onClick={onClose} aria-label="Close">
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
</button>
)}
</div>
);
}
// --- 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 (
<Dialog open={open} onClose={onClose} width={520} labelledBy="dlg-item-title">
<form onSubmit={submit}>
<DialogHeader
id="dlg-item-title"
eyebrow={mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
title={mode === "edit" ? "Edit details" : "Create item"}
onClose={onClose}
/>
<div className="dlg-body">
<div className="field">
<label className="field-label">Title</label>
<input
ref={titleRef}
className="text-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to happen?"
required
/>
</div>
<div className="field">
<label className="field-label">Details <span className="field-hint">rich text, supports markdown</span></label>
<RichTextEditor
value={body}
onChange={setBody}
placeholder="Add notes, context, or details..."
collapsed={!body}
/>
</div>
<div className="field-row">
<div className="field">
<label className="field-label">Priority</label>
<div className="seg">
{PRIORITIES.map(p => (
<button type="button" key={p}
className={`seg-btn pri-${p} ${priority === p ? "active" : ""}`}
onClick={() => setPriority(p)}>
{p === "P0" && <span className="p0-dot"/>}
{p}
</button>
))}
</div>
</div>
<div className="field">
<label className="field-label">Due</label>
<input className="text-input" type="date" value={due} onChange={(e) => setDue(e.target.value)}/>
</div>
</div>
<div className="field">
<label className="field-label">Status</label>
<div className="status-grid">
{STATUSES.map(s => (
<button type="button" key={s.key}
className={`status-card ${status === s.key ? "active" : ""}`}
onClick={() => setStatus(s.key)}>
<span className={`status-card-icon status-${s.key}`}>
<StatusIcon status={s.key} size={16}/>
</span>
<span className="status-card-label">{s.label}</span>
</button>
))}
</div>
</div>
{status !== "cancelled" && (
<div className="field">
<label className="field-label">
Completion
<span className="field-hint">
{status === "done"
? "locked at 100% while marked done"
: "rough estimate — set 100% to mark done"}
</span>
</label>
<div className="progress-row">
<div className={`seg progress-seg ${status === "done" ? "locked" : ""}`}>
{PROGRESS_STEPS.map(p => (
<button type="button" key={p}
className={`seg-btn ${progress === p ? "active" : ""}`}
onClick={() => setProgress(p)}
disabled={status === "done"}>
{p}%
</button>
))}
</div>
<ProgressGauge value={status === "done" ? 100 : progress} size="md"/>
</div>
</div>
)}
{status === "blocked" && (
<div className="field">
<label className="field-label">Block reason</label>
<input className="text-input" value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Waiting for…"/>
</div>
)}
<div className="field">
<label className="field-label">Tags <span className="field-hint">type a name, press Enter or comma to add</span></label>
<TagAutocompleteInput
tags={tags}
setTags={setTags}
tagInput={tagInput}
setTagInput={setTagInput}
addTag={addTag}
removeTag={removeTag}
allTags={recentTags}
/>
</div>
</div>
<div className="dlg-foot">
<span className="dlg-hint">⌘↩ to save · Esc to cancel</span>
<div className="dlg-foot-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!title.trim()}>
{mode === "edit" ? "Save changes" : "Create item"}
</button>
</div>
</div>
</form>
</Dialog>
);
}
// --- Confirm dialog ---
function ConfirmDialog({ open, title, message, detail, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) {
return (
<Dialog open={open} onClose={onCancel} width={420} labelledBy="dlg-confirm-title">
<DialogHeader id="dlg-confirm-title" eyebrow="Confirm" title={title} onClose={onCancel}/>
<div className="dlg-body">
<p className="dlg-msg">{message}</p>
{detail && <div className="dlg-detail">{detail}</div>}
</div>
<div className="dlg-foot">
<span/>
<div className="dlg-foot-actions">
<button type="button" className="btn-secondary" onClick={onCancel}>Cancel</button>
<button type="button"
className={danger ? "btn-danger" : "btn-primary"}
onClick={onConfirm} autoFocus>{confirmLabel}</button>
</div>
</div>
</Dialog>
);
}
// --- 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 (
<div className="tag-ac" ref={wrapRef}>
<div className={`tag-input ${focused ? "focused" : ""}`} onClick={(e) => {
if (e.target === e.currentTarget) inputRef.current?.focus();
}}>
{tags.map(t => (
<span key={t} className="tag-pill">
#{t}
<button type="button" onClick={() => removeTag(t)} aria-label={`Remove ${t}`}>×</button>
</span>
))}
<input
ref={inputRef}
className="tag-input-field"
placeholder={tags.length ? "+ add tag" : "e.g. urgent, frontend, errand"}
value={tagInput}
onChange={(e) => 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}
/>
</div>
{focused && items.length > 0 && (
<div className="tag-ac-pop" role="listbox">
{!q && (
<div className="tag-ac-cap">Recent</div>
)}
{items.map((it, i) => (
<button
key={`${it.kind}-${it.value}`}
type="button"
role="option"
aria-selected={i === highlighted}
className={`tag-ac-item ${i === highlighted ? "highlighted" : ""} ${it.kind === "create" ? "create" : ""}`}
onMouseEnter={() => setHighlighted(i)}
onMouseDown={(e) => { e.preventDefault(); pick(it); }}
>
{it.kind === "create" ? (
<>
<Icon name="plus" size={11}/>
<span>Create <strong>#{it.value}</strong></span>
</>
) : (
<span>{q ? <TagMatchHL text={it.value} match={q}/> : `#${it.value}`}</span>
)}
</button>
))}
<div className="tag-ac-foot">
<span><kbd>↑</kbd><kbd>↓</kbd> to navigate</span>
<span><kbd>↵</kbd> to add</span>
<span><kbd>esc</kbd> to dismiss</span>
</div>
</div>
)}
</div>
);
}
function TagMatchHL({ text, match }) {
const i = text.toLowerCase().indexOf(match);
if (i < 0) return `#${text}`;
return (
<>
#{text.slice(0, i)}<span className="hl">{text.slice(i, i + match.length)}</span>{text.slice(i + match.length)}
</>
);
}
window.Dialog = Dialog;
window.ItemDialog = ItemDialog;
window.ConfirmDialog = ConfirmDialog;
window.RichTextEditor = RichTextEditor;