| import { useEffect, useRef, useState } from 'react' |
| import { markdownToBlocks, blocksToMarkdown, newBlock, BLOCK_TAGS, BLOCK_TYPES, PLACEHOLDERS } from '../blocks' |
|
|
| |
|
|
| 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) |
| } |
|
|
| |
| 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 } |
| } |
|
|
| |
| |
| 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 |
| } |
|
|
| |
|
|
| export default function BlockEditor({ value, onChange }) { |
| const [blocks, setBlocks] = useState(() => markdownToBlocks(value)) |
| const [slashMenu, setSlashMenu] = useState(null) |
| const [slashIndex, setSlashIndex] = useState(0) |
| const [toolbar, setToolbar] = useState(null) |
| const [turnIntoOpen, setTurnIntoOpen] = useState(false) |
| const [pendingFocus, setPendingFocus] = useState(null) |
| const blockRefs = useRef({}) |
| const containerRef = useRef(null) |
|
|
| |
| useEffect(() => { |
| const t = setTimeout(() => onChange(blocksToMarkdown(blocks)), 300) |
| return () => clearTimeout(t) |
| |
| }, [blocks]) |
|
|
| |
| 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) |
| |
| }, [blocks, pendingFocus]) |
|
|
| |
| 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) |
| } |
|
|
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| 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 |
| } |
|
|
| |
| 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 }) |
| } |
|
|
| |
| |
| 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, '<br>') |
| 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 <hr key={block.id} className="fc-block fc-block-divider" contentEditable={false} /> |
| } |
| const Tag = BLOCK_TAGS[block.type] |
| return ( |
| <Tag |
| key={block.id} |
| ref={(el) => { |
| 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( |
| <ListTag className="fc-block-list" key={group[0].id}> |
| {group.map((b) => renderBlock(b))} |
| </ListTag> |
| ) |
| } else { |
| out.push(renderBlock(block)) |
| i += 1 |
| } |
| } |
| return out |
| } |
|
|
| return ( |
| <div className="fc-block-editor" ref={containerRef}> |
| {renderBlocks()} |
| {slashMenu && ( |
| <div className="fc-slash-menu" style={{ top: slashMenu.top, left: slashMenu.left }}> |
| {BLOCK_TYPES.map((bt, i) => ( |
| <div |
| key={bt.type} |
| className={`fc-slash-menu-item${i === slashIndex ? ' fc-slash-menu-item-active' : ''}`} |
| onMouseDown={(e) => { e.preventDefault(); applySlashChoice(slashMenu.blockId, bt.type) }} |
| onMouseEnter={() => setSlashIndex(i)} |
| > |
| <span className="fc-slash-menu-icon">{bt.icon}</span> |
| {bt.label} |
| </div> |
| ))} |
| </div> |
| )} |
| {toolbar && ( |
| <div className="fc-floating-toolbar" style={{ top: toolbar.top, left: toolbar.left }}> |
| <button className="fc-floating-toolbar-btn" title="Bold" onMouseDown={(e) => { e.preventDefault(); applyFormat('strong') }}><b>B</b></button> |
| <button className="fc-floating-toolbar-btn" title="Italic" onMouseDown={(e) => { e.preventDefault(); applyFormat('em') }}><i>I</i></button> |
| <button className="fc-floating-toolbar-btn" title="Underline" onMouseDown={(e) => { e.preventDefault(); applyFormat('u') }}><u>U</u></button> |
| <button className="fc-floating-toolbar-btn" title="Highlight" onMouseDown={(e) => { e.preventDefault(); applyFormat('mark') }}>🖍️</button> |
| <button className="fc-floating-toolbar-btn" title="Strikethrough" onMouseDown={(e) => { e.preventDefault(); applyFormat('s') }}><s>S</s></button> |
| <div className="fc-floating-toolbar-sep" /> |
| <button |
| className="fc-floating-toolbar-btn fc-floating-toolbar-turninto" |
| title="Turn into…" |
| onMouseDown={(e) => { e.preventDefault(); setTurnIntoOpen((o) => !o) }} |
| > |
| ¶ ▾ |
| </button> |
| {turnIntoOpen && toolbar.blockId && ( |
| <div className="fc-turn-into-menu"> |
| {BLOCK_TYPES.filter((bt) => bt.type !== 'divider').map((bt) => ( |
| <div |
| key={bt.type} |
| className="fc-slash-menu-item" |
| onMouseDown={(e) => { e.preventDefault(); convertBlockType(toolbar.blockId, bt.type) }} |
| > |
| <span className="fc-slash-menu-icon">{bt.icon}</span> |
| {bt.label} |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|