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, blockId} const [turnIntoOpen, setTurnIntoOpen] = useState(false) 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 (inline marks + "Turn into" block type). useEffect(() => { const handler = () => { const sel = window.getSelection() const container = containerRef.current if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !container) { setToolbar(null) setTurnIntoOpen(false) return } const range = sel.getRangeAt(0) if (!container.contains(range.commonAncestorContainer)) { setToolbar(null) setTurnIntoOpen(false) return } const rect = range.getBoundingClientRect() if (rect.width === 0 && rect.height === 0) { setToolbar(null) setTurnIntoOpen(false) return } let node = range.commonAncestorContainer if (node.nodeType !== 1) node = node.parentElement const blockEl = node && node.closest('[data-block-id]') const containerRect = container.getBoundingClientRect() setToolbar({ top: rect.top - containerRect.top - 42, left: rect.left - containerRect.left + rect.width / 2, blockId: blockEl ? blockEl.dataset.blockId : null, }) } 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) } // Toggle an inline mark (, , , , ) on the current // selection. Works via the Range API directly rather than the deprecated // (and inconsistently-supported) document.execCommand, so it behaves the // same for freshly-typed text and text loaded from a saved page. function toggleInline(tagName) { const sel = window.getSelection() if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null const range = sel.getRangeAt(0) let container = range.commonAncestorContainer if (container.nodeType !== 1) container = container.parentElement const blockEl = container && container.closest('[data-block-id]') if (!blockEl) return null // If the selection exactly matches an existing , unwrap it // (toggle off). const markEl = container.closest(tagName) if (markEl && blockEl.contains(markEl) && markEl.textContent === range.toString()) { const parent = markEl.parentNode while (markEl.firstChild) parent.insertBefore(markEl.firstChild, markEl) parent.removeChild(markEl) blockEl.normalize() return blockEl } // Otherwise wrap the selection in a new (toggle on). const wrapper = document.createElement(tagName) wrapper.appendChild(range.extractContents()) range.insertNode(wrapper) blockEl.normalize() return blockEl } function applyFormat(tagName) { const blockEl = toggleInline(tagName) if (!blockEl) return 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 }) } // "Turn into" — change an existing block's type without touching its // content (Notion-style retroactive heading/list/quote conversion). function convertBlockType(id, newType) { setTurnIntoOpen(false) setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, type: newType } : b))) } 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 && (
{turnIntoOpen && toolbar.blockId && (
{BLOCK_TYPES.filter((bt) => bt.type !== 'divider').map((bt) => (
{ e.preventDefault(); convertBlockType(toolbar.blockId, bt.type) }} > {bt.icon} {bt.label}
))}
)}
)}
) }