French-Coach / frontend /src /components /BlockEditor.jsx
Asma-F's picture
Deploy: French Coach app (MiniCPM4.1-8B ZeroGPU + React frontend)
4fd1234 verified
Raw
History Blame Contribute Delete
18.5 kB
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 (<strong>, <em>, <u>, <mark>, <s>) 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 <tagName>, 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 <tagName> (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, '<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>
)
}