// 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', `
`);
};
reader.readAsDataURL(file);
};
input.click();
}, [execCmd]);
const insertTable = useCallbackD(() => {
const html = '
{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 (
);
}
// --- Confirm dialog ---
function ConfirmDialog({ open, title, message, detail, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) {
return (
);
}
// --- 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;