import { useEffect, useRef, useState } from 'react' import { markdownToBlocks, blocksToMarkdown, newBlock, BLOCK_TAGS, BLOCK_TYPES, PLACEHOLDERS } from '../blocks' // ── Selection / caret helpers (operate on a single block's DOM node) ─────── function isCaretAtStart(el) { const sel = window.getSelection() if (!sel || !sel.rangeCount) return false const range = sel.getRangeAt(0) if (!range.collapsed) return false const pre = range.cloneRange() pre.selectNodeContents(el) pre.setEnd(range.startContainer, range.startOffset) return pre.toString().length === 0 } function isCaretAtEnd(el) { const sel = window.getSelection() if (!sel || !sel.rangeCount) return false const range = sel.getRangeAt(0) if (!range.collapsed) return false const post = range.cloneRange() post.selectNodeContents(el) post.setStart(range.endContainer, range.endOffset) return post.toString().length === 0 } function placeCaretAtStart(el) { const range = document.createRange() range.selectNodeContents(el) range.collapse(true) const sel = window.getSelection() sel.removeAllRanges() sel.addRange(range) } function placeCaretAtEnd(el) { const range = document.createRange() range.selectNodeContents(el) range.collapse(false) const sel = window.getSelection() sel.removeAllRanges() sel.addRange(range) } function setCaretOffset(el, offset) { const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) let remaining = offset let target = el let targetOffset = 0 let node = walker.nextNode() while (node) { const len = node.nodeValue.length if (remaining <= len) { target = node targetOffset = remaining break } remaining -= len target = node targetOffset = len node = walker.nextNode() } const range = document.createRange() range.setStart(target, targetOffset) range.collapse(true) const sel = window.getSelection() sel.removeAllRanges() sel.addRange(range) } // Split a block's content at the caret into "before"/"after" HTML fragments. function splitAtCaret(el) { const sel = window.getSelection() if (!sel || !sel.rangeCount) return { before: el.innerHTML, after: '' } const range = sel.getRangeAt(0) const beforeRange = document.createRange() beforeRange.selectNodeContents(el) beforeRange.setEnd(range.startContainer, range.startOffset) const afterRange = document.createRange() afterRange.selectNodeContents(el) afterRange.setStart(range.endContainer, range.endOffset) const beforeDiv = document.createElement('div') beforeDiv.appendChild(beforeRange.cloneContents()) const afterDiv = document.createElement('div') afterDiv.appendChild(afterRange.cloneContents()) return { before: beforeDiv.innerHTML, after: afterDiv.innerHTML } } // Remove the first `n` characters of text content (the markdown marker that // triggered an auto-format conversion), keeping any inline formatting intact. function stripLeadingMarker(el, n) { let remaining = n const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) let node = walker.nextNode() while (node && remaining > 0) { const len = node.nodeValue.length if (len <= remaining) { node.nodeValue = '' remaining -= len } else { node.nodeValue = node.nodeValue.slice(remaining) remaining = 0 } node = walker.nextNode() } } const SHORTCUT_RE = /^(#{1,3}|[-*]|\d+\.|>)\s(.*)$/ function shortcutType(marker) { if (/^#{1,3}$/.test(marker)) return `heading${marker.length}` if (marker === '-' || marker === '*') return 'bulleted' if (/^\d+\.$/.test(marker)) return 'numbered' if (marker === '>') return 'quote' return null } // ── Component ──────────────────────────────────────────────────────────── export default function BlockEditor({ value, onChange }) { const [blocks, setBlocks] = useState(() => markdownToBlocks(value)) const [slashMenu, setSlashMenu] = useState(null) // {blockId, top, left} const [slashIndex, setSlashIndex] = useState(0) const [toolbar, setToolbar] = useState(null) // {top, left} const [pendingFocus, setPendingFocus] = useState(null) // {id, position, offset} const blockRefs = useRef({}) const containerRef = useRef(null) // Debounced serialize-up to the parent (raw_text / `text` state). useEffect(() => { const t = setTimeout(() => onChange(blocksToMarkdown(blocks)), 300) return () => clearTimeout(t) // eslint-disable-next-line react-hooks/exhaustive-deps }, [blocks]) // Restore focus/caret after structural edits (split, merge, convert…). useEffect(() => { if (!pendingFocus) return const el = blockRefs.current[pendingFocus.id] if (el) { el.focus() if (pendingFocus.position === 'end') placeCaretAtEnd(el) else if (pendingFocus.position === 'offset') setCaretOffset(el, pendingFocus.offset || 0) else placeCaretAtStart(el) } setPendingFocus(null) // eslint-disable-next-line react-hooks/exhaustive-deps }, [blocks, pendingFocus]) // Floating selection toolbar (Bold/Italic/Strikethrough). useEffect(() => { const handler = () => { const sel = window.getSelection() const container = containerRef.current if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !container) { setToolbar(null) return } const range = sel.getRangeAt(0) if (!container.contains(range.commonAncestorContainer)) { setToolbar(null) return } const rect = range.getBoundingClientRect() if (rect.width === 0 && rect.height === 0) { setToolbar(null) return } const containerRect = container.getBoundingClientRect() setToolbar({ top: rect.top - containerRect.top - 42, left: rect.left - containerRect.left + rect.width / 2, }) } document.addEventListener('selectionchange', handler) return () => document.removeEventListener('selectionchange', handler) }, []) function focusBlock(id, position) { const el = blockRefs.current[id] if (!el) return el.focus() if (position === 'end') placeCaretAtEnd(el) else placeCaretAtStart(el) } function applyFormat(cmd) { document.execCommand(cmd) const sel = window.getSelection() const node = sel && sel.anchorNode const anchorEl = node && (node.nodeType === 1 ? node : node.parentElement) const blockEl = anchorEl && anchorEl.closest('[data-block-id]') if (blockEl) { const id = blockEl.dataset.blockId const html = blockEl.innerHTML setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, html } : b))) } } function handleInput(id, el) { let html = el.innerHTML const text = el.textContent || '' if (text === '') html = '' const block = blocks.find((b) => b.id === id) if (!block) return if (block.type === 'paragraph') { if (text === '/') { const rect = el.getBoundingClientRect() const containerRect = containerRef.current.getBoundingClientRect() setSlashMenu({ blockId: id, top: rect.bottom - containerRect.top + 4, left: rect.left - containerRect.left }) setSlashIndex(0) } else if (slashMenu && slashMenu.blockId === id) { setSlashMenu(null) } if (text === '---') { const next = newBlock('paragraph', '') setBlocks((prev) => { const idx = prev.findIndex((b) => b.id === id) const copy = [...prev] copy[idx] = { ...copy[idx], type: 'divider', html: '' } copy.splice(idx + 1, 0, next) return copy }) setPendingFocus({ id: next.id, position: 'start' }) return } const m = SHORTCUT_RE.exec(text) const newType = m && shortcutType(m[1]) if (newType) { stripLeadingMarker(el, m[1].length + 1) const newHtml = el.innerHTML setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, type: newType, html: newHtml } : b))) setPendingFocus({ id, position: 'start' }) return } } else if (slashMenu && slashMenu.blockId === id) { setSlashMenu(null) } setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, html } : b))) } function handleEnter(id, el, block) { if ((block.type === 'bulleted' || block.type === 'numbered') && el.textContent.trim() === '') { el.innerHTML = '' setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, type: 'paragraph', html: '' } : b))) setPendingFocus({ id, position: 'start' }) return } const { before, after } = splitAtCaret(el) const newType = block.type === 'bulleted' || block.type === 'numbered' ? block.type : 'paragraph' const next = newBlock(newType, after) el.innerHTML = before setBlocks((prev) => { const idx = prev.findIndex((b) => b.id === id) const copy = [...prev] copy[idx] = { ...copy[idx], html: before } copy.splice(idx + 1, 0, next) return copy }) setPendingFocus({ id: next.id, position: 'start' }) } function mergeWithPrevious(idx) { const prevBlock = blocks[idx - 1] const curBlock = blocks[idx] if (prevBlock.type === 'divider') { setBlocks((prev) => prev.filter((b) => b.id !== prevBlock.id)) setPendingFocus({ id: curBlock.id, position: 'start' }) return } const prevEl = blockRefs.current[prevBlock.id] const caretOffset = (prevEl ? prevEl.textContent : '').length const mergedHtml = (prevEl ? prevEl.innerHTML : prevBlock.html) + curBlock.html if (prevEl) prevEl.innerHTML = mergedHtml setBlocks((prev) => { const copy = prev.filter((b) => b.id !== curBlock.id) const pIdx = copy.findIndex((b) => b.id === prevBlock.id) copy[pIdx] = { ...copy[pIdx], html: mergedHtml } return copy }) setPendingFocus({ id: prevBlock.id, position: 'offset', offset: caretOffset }) } function applySlashChoice(id, type) { setSlashMenu(null) if (type === 'divider') { const next = newBlock('paragraph', '') setBlocks((prev) => { const idx = prev.findIndex((b) => b.id === id) const copy = [...prev] copy[idx] = { ...copy[idx], type: 'divider', html: '' } copy.splice(idx + 1, 0, next) return copy }) setPendingFocus({ id: next.id, position: 'start' }) } else { const el = blockRefs.current[id] if (el) el.innerHTML = '' setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, type, html: '' } : b))) setPendingFocus({ id, position: 'start' }) } } function handleKeyDown(e, id) { const el = blockRefs.current[id] const block = blocks.find((b) => b.id === id) if (!el || !block) return if (slashMenu && slashMenu.blockId === id) { if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex((i) => (i + 1) % BLOCK_TYPES.length); return } if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex((i) => (i - 1 + BLOCK_TYPES.length) % BLOCK_TYPES.length); return } if (e.key === 'Enter') { e.preventDefault(); applySlashChoice(id, BLOCK_TYPES[slashIndex].type); return } if (e.key === 'Escape') { e.preventDefault(); setSlashMenu(null); return } } if (e.key === 'Enter' && e.shiftKey) { e.preventDefault() document.execCommand('insertHTML', false, '
') return } if (e.key === 'Enter') { e.preventDefault() handleEnter(id, el, block) return } if (e.key === 'Backspace' && isCaretAtStart(el)) { if (block.type !== 'paragraph') { e.preventDefault() setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, type: 'paragraph' } : b))) return } const idx = blocks.findIndex((b) => b.id === id) if (idx > 0) { e.preventDefault() mergeWithPrevious(idx) } return } if (e.key === 'ArrowUp' && isCaretAtStart(el)) { const idx = blocks.findIndex((b) => b.id === id) if (idx > 0) { e.preventDefault() focusBlock(blocks[idx - 1].id, 'end') } return } if (e.key === 'ArrowDown' && isCaretAtEnd(el)) { const idx = blocks.findIndex((b) => b.id === id) if (idx < blocks.length - 1) { e.preventDefault() focusBlock(blocks[idx + 1].id, 'start') } } } function renderBlock(block) { if (block.type === 'divider') { return
} const Tag = BLOCK_TAGS[block.type] return ( { if (el) { blockRefs.current[block.id] = el if (el.innerHTML === '' && block.html) el.innerHTML = block.html } else { delete blockRefs.current[block.id] } }} className={`fc-block fc-block-${block.type}`} contentEditable suppressContentEditableWarning data-block-id={block.id} data-placeholder={!block.html ? PLACEHOLDERS[block.type] || PLACEHOLDERS.paragraph : ''} onInput={(e) => handleInput(block.id, e.currentTarget)} onKeyDown={(e) => handleKeyDown(e, block.id)} /> ) } function renderBlocks() { const out = [] let i = 0 while (i < blocks.length) { const block = blocks[i] if (block.type === 'bulleted' || block.type === 'numbered') { const groupType = block.type const group = [] while (i < blocks.length && blocks[i].type === groupType) { group.push(blocks[i]) i += 1 } const ListTag = groupType === 'bulleted' ? 'ul' : 'ol' out.push( {group.map((b) => renderBlock(b))} ) } else { out.push(renderBlock(block)) i += 1 } } return out } return (
{renderBlocks()} {slashMenu && (
{BLOCK_TYPES.map((bt, i) => (
{ e.preventDefault(); applySlashChoice(slashMenu.blockId, bt.type) }} onMouseEnter={() => setSlashIndex(i)} > {bt.icon} {bt.label}
))}
)} {toolbar && (
)}
) }