"use client"; import { CheckIcon, CodeIcon, Heading2Icon, LightbulbIcon, ListIcon, type LucideIcon, MinusIcon, QuoteIcon, SquareCheckIcon, TypeIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ChangeEvent, type KeyboardEvent, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; function useReducedMotionAfterMount() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export type BlockType = "text" | "heading" | "todo" | "bullet" | "quote" | "callout" | "code" | "divider"; export interface SlashBlock { id: string; type: BlockType; text: string; checked?: boolean; } export interface SlashCommand { type: BlockType; label: string; hint: string; icon: LucideIcon; group: string; aliases?: string[]; } export interface SlashMenuProps { title?: string; defaultBlocks?: SlashBlock[]; commands?: SlashCommand[]; onChange?: (blocks: SlashBlock[]) => void; className?: string; } export const defaultCommands: SlashCommand[] = [ { type: "text", label: "Text", hint: "Plain paragraph", icon: TypeIcon, group: "Basic", aliases: ["paragraph", "p"] }, { type: "heading", label: "Heading", hint: "Section title", icon: Heading2Icon, group: "Basic", aliases: ["h2", "title"] }, { type: "todo", label: "To-do", hint: "Track a task", icon: SquareCheckIcon, group: "Basic", aliases: ["task", "checkbox", "check"] }, { type: "bullet", label: "Bulleted list", hint: "Simple list", icon: ListIcon, group: "Basic", aliases: ["ul", "list"] }, { type: "quote", label: "Quote", hint: "Pull out a line", icon: QuoteIcon, group: "Blocks", aliases: ["blockquote"] }, { type: "callout", label: "Callout", hint: "Make it stand out", icon: LightbulbIcon, group: "Blocks", aliases: ["note", "tip", "info"] }, { type: "code", label: "Code", hint: "Monospace snippet", icon: CodeIcon, group: "Blocks", aliases: ["snippet", "pre"] }, { type: "divider", label: "Divider", hint: "Separate sections", icon: MinusIcon, group: "Blocks", aliases: ["hr", "line", "rule"] }, ]; const placeholders: Record = { text: "Type / for blocks", heading: "Heading", todo: "To-do", bullet: "List item", quote: "Quote", callout: "Something worth noticing", code: "// code", divider: "", }; let counter = 0; const newId = () => `b${Date.now().toString(36)}${counter++}`; const MENU_HEIGHT = 300; function caretPoint(el: HTMLTextAreaElement, index: number) { const style = getComputedStyle(el); const mirror = document.createElement("div"); for (const prop of [ "boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderLeftWidth", "fontFamily", "fontSize", "fontWeight", "fontStyle", "letterSpacing", "lineHeight", "textTransform", "wordSpacing", "tabSize", ] as const) { mirror.style[prop] = style[prop]; } mirror.style.position = "absolute"; mirror.style.visibility = "hidden"; mirror.style.whiteSpace = "pre-wrap"; mirror.style.overflowWrap = "break-word"; mirror.textContent = el.value.slice(0, index); const marker = document.createElement("span"); marker.textContent = "​"; mirror.appendChild(marker); document.body.appendChild(mirror); const point = { left: marker.offsetLeft, top: marker.offsetTop, height: Number.parseFloat(style.lineHeight) || marker.offsetHeight, }; mirror.remove(); return point; } function rank(cmd: SlashCommand, q: string) { if (!q) return 1; const label = cmd.label.toLowerCase(); if (label.startsWith(q)) return 3; if (cmd.aliases?.some((a) => a.startsWith(q))) return 2; if (label.includes(q)) return 1; return 0; } function Highlight({ text, query }: { text: string; query: string }) { const i = query ? text.toLowerCase().indexOf(query) : -1; if (i < 0) return <>{text}; return ( <> {text.slice(0, i)} {text.slice(i, i + query.length)} {text.slice(i + query.length)} ); } interface MenuState { blockId: string; slash: number; query: string; x: number; y: number; above: boolean; } export function SlashMenu({ title = "Offsite, Lisbon", defaultBlocks, commands = defaultCommands, onChange, className, }: SlashMenuProps) { const reduce = useReducedMotionAfterMount(); const listId = useId(); const [blocks, setBlocks] = useState( () => defaultBlocks ?? [{ id: newId(), type: "text", text: "" }], ); const [menu, setMenu] = useState(null); const [active, setActive] = useState(0); const [focusedId, setFocusedId] = useState(null); const [flashId, setFlashId] = useState(null); const refs = useRef(new Map()); const wrapRef = useRef(null); const pendingFocus = useRef<{ id: string; at: number | "end" } | null>(null); const flashTimer = useRef>(undefined); useEffect(() => () => clearTimeout(flashTimer.current), []); useEffect(() => onChange?.(blocks), [blocks, onChange]); useLayoutEffect(() => { const req = pendingFocus.current; if (!req) return; const el = refs.current.get(req.id); if (!el) return; pendingFocus.current = null; el.focus(); const at = req.at === "end" ? el.value.length : req.at; el.setSelectionRange(at, at); }); const results = useMemo(() => { if (!menu) return []; const q = menu.query.toLowerCase(); return commands .map((c, i) => ({ c, score: rank(c, q), i })) .filter((r) => r.score > 0) .sort((a, b) => b.score - a.score || a.i - b.i) .map((r) => r.c); }, [menu, commands]); const groups = useMemo(() => { if (menu?.query) return [{ name: "Best match", items: results }]; const out: { name: string; items: SlashCommand[] }[] = []; for (const c of results) { const g = out.find((x) => x.name === c.group); if (g) g.items.push(c); else out.push({ name: c.group, items: [c] }); } return out; }, [results, menu?.query]); const activeOptionId = menu && results[active] ? `${listId}-${results[active].type}` : undefined; useEffect(() => { if (activeOptionId) document.getElementById(activeOptionId)?.scrollIntoView({ block: "nearest" }); }, [activeOptionId]); const update = (id: string, patch: Partial) => setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, ...patch } : b))); const flash = (id: string) => { setFlashId(id); clearTimeout(flashTimer.current); flashTimer.current = setTimeout(() => setFlashId(null), 700); }; const detectSlash = (block: SlashBlock, el: HTMLTextAreaElement) => { const caret = el.selectionStart; const before = el.value.slice(0, caret); const m = /(^|\s)\/([\p{L}\d-]{0,24})$/u.exec(before); if (!m || block.type === "code") { setMenu(null); return; } const slash = caret - m[2].length - 1; const wrap = wrapRef.current; if (!wrap) return; const p = caretPoint(el, slash); const elRect = el.getBoundingClientRect(); const wrapRect = wrap.getBoundingClientRect(); const x = Math.min(elRect.left - wrapRect.left + p.left - 8, wrap.clientWidth - 248); const top = elRect.top - wrapRect.top + p.top - el.scrollTop; const above = top + p.height + MENU_HEIGHT > wrap.clientHeight + 120 && top > MENU_HEIGHT; setMenu((prev) => { if (prev?.query !== m[2]) setActive(0); return { blockId: block.id, slash, query: m[2], x: Math.max(0, x), y: above ? top - 6 : top + p.height + 6, above }; }); }; const onInput = (block: SlashBlock, e: ChangeEvent) => { update(block.id, { text: e.target.value }); detectSlash(block, e.target); }; const apply = (cmd: SlashCommand) => { if (!menu) return; const block = blocks.find((b) => b.id === menu.blockId); const el = refs.current.get(menu.blockId); if (!block || !el) return; const caret = el.selectionStart; const rest = (block.text.slice(0, menu.slash) + block.text.slice(caret)).replace(/\s+$/, ""); const index = blocks.findIndex((b) => b.id === block.id); setMenu(null); setBlocks((bs) => { const next = [...bs]; if (cmd.type === "divider") { const after = { id: newId(), type: "text" as const, text: "" }; const divider = { id: newId(), type: "divider" as const, text: "" }; if (rest.trim()) next.splice(index, 1, { ...block, text: rest }, divider, after); else next.splice(index, 1, divider, after); pendingFocus.current = { id: after.id, at: 0 }; flash(divider.id); return next; } if (!rest.trim()) { next[index] = { ...block, type: cmd.type, text: "", checked: false }; pendingFocus.current = { id: block.id, at: 0 }; flash(block.id); } else { const created = { id: newId(), type: cmd.type, text: "" }; next.splice(index, 1, { ...block, text: rest }, created); pendingFocus.current = { id: created.id, at: 0 }; flash(created.id); } return next; }); }; const focusBlock = (i: number, at: number | "end") => { const target = blocks.slice(0, i + 1).reverse().find((b) => b.type !== "divider"); const forward = blocks.slice(i).find((b) => b.type !== "divider"); const pick = at === "end" ? target : forward; if (!pick) return; pendingFocus.current = { id: pick.id, at }; setFocusedId(pick.id); }; const onKeyDown = (block: SlashBlock, e: KeyboardEvent) => { const el = e.currentTarget; const index = blocks.findIndex((b) => b.id === block.id); if (menu && menu.blockId === block.id) { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!results.length) return; setActive((a) => (a + (e.key === "ArrowDown" ? 1 : -1) + results.length) % results.length); return; } if ((e.key === "Enter" || e.key === "Tab") && results.length) { e.preventDefault(); apply(results[active] ?? results[0]); return; } if (e.key === "Escape") { e.preventDefault(); setMenu(null); return; } } const atStart = el.selectionStart === 0 && el.selectionEnd === 0; const atEnd = el.selectionStart === el.value.length; if (e.key === "Enter" && !e.shiftKey && block.type !== "code") { e.preventDefault(); const listy = block.type === "todo" || block.type === "bullet"; if (listy && !block.text.trim()) { update(block.id, { type: "text" }); return; } const head = el.value.slice(0, el.selectionStart); const tail = el.value.slice(el.selectionEnd); const created: SlashBlock = { id: newId(), type: listy ? block.type : "text", text: tail }; setBlocks((bs) => { const next = [...bs]; next.splice(index, 1, { ...block, text: head }, created); return next; }); pendingFocus.current = { id: created.id, at: 0 }; return; } if (e.key === "Backspace" && atStart) { if (block.type !== "text") { e.preventDefault(); update(block.id, { type: "text" }); return; } if (index > 0) { e.preventDefault(); const prev = blocks[index - 1]; if (prev.type === "divider") { setBlocks((bs) => bs.filter((b) => b.id !== prev.id)); pendingFocus.current = { id: block.id, at: 0 }; return; } const join = prev.text.length; setBlocks((bs) => bs.filter((b) => b.id !== block.id).map((b) => (b.id === prev.id ? { ...b, text: b.text + block.text } : b)), ); pendingFocus.current = { id: prev.id, at: join }; } return; } if (e.key === "ArrowUp" && atStart && index > 0) { e.preventDefault(); focusBlock(index - 1, "end"); } else if (e.key === "ArrowDown" && atEnd && index < blocks.length - 1) { e.preventDefault(); focusBlock(index + 1, 0); } }; const lastEmpty = blocks[blocks.length - 1]; return (

{title}

{blocks.length} blocks
{blocks.map((block, i) => ( { setFocusedId((f) => (f === block.id ? null : f)); setMenu((m) => (m?.blockId === block.id ? null : m)); }} onChange={(e) => onInput(block, e)} onFocus={() => setFocusedId(block.id)} onKeyDown={(e) => onKeyDown(block, e)} onSelect={(e) => menu?.blockId === block.id && detectSlash(block, e.currentTarget)} onToggle={() => update(block.id, { checked: !block.checked })} placeholderAlways={block === lastEmpty && block.type === "text" && !block.text && i > 0} register={(el) => { if (el) refs.current.set(block.id, el); else refs.current.delete(block.id); }} /> ))} {menu && ( e.preventDefault()} style={{ left: menu.x, top: menu.above ? undefined : menu.y, bottom: menu.above ? `calc(100% - ${menu.y}px)` : undefined, transformOrigin: menu.above ? "8px 100%" : "8px 0", }} transition={{ duration: 0.14, ease: [0.23, 1, 0.32, 1] }} >
{results.length === 0 ? (

No blocks for “{menu.query}”

Keep typing, or press Esc

) : ( groups.map((g) => (
{g.name}
{g.items.map((c) => { const idx = results.indexOf(c); const on = idx === active; const Icon = c.icon; return (
apply(c)} onMouseMove={() => idx !== active && setActive(idx)} role="option" tabIndex={-1} > {c.hint}
); })}
)) )}
↑ ↓ move ↵ insert esc
)}
); } function Key({ children }: { children: React.ReactNode }) { return ( {children} ); } interface BlockProps { block: SlashBlock; focused: boolean; flash: boolean; menuOpen: boolean; menuProps: { listId: string; activeId?: string }; placeholderAlways: boolean; register: (el: HTMLTextAreaElement | null) => void; onChange: (e: ChangeEvent) => void; onKeyDown: (e: KeyboardEvent) => void; onSelect: (e: React.SyntheticEvent) => void; onFocus: () => void; onBlur: () => void; onToggle: () => void; } function Block({ block, focused, flash, menuOpen, menuProps, placeholderAlways, register, onChange, onKeyDown, onSelect, onFocus, onBlur, onToggle, }: BlockProps) { const ref = useRef(null); useLayoutEffect(() => { const el = ref.current; if (!el) return; el.style.height = "0px"; el.style.height = `${el.scrollHeight}px`; }, [block.text, block.type]); const flashClass = cn( "rounded-lg transition-[background-color,box-shadow] duration-500 ease-out", flash && "bg-info/10 duration-0", ); if (block.type === "divider") { return (
); } const showPlaceholder = !block.text && (focused || placeholderAlways || block.type !== "text"); const area = (