// Pure helpers for the Notion-style block editor (BlockEditor.jsx). // // Storage format stays a single string (raw_text / `text` state) — but it's // now a small Markdown-ish dialect instead of plain prose, so old plain-text // lessons keep working unchanged (they parse as paragraph blocks). // // Grammar (intentionally minimal — internal round-trip only, not CommonMark): // - heading1/2/3, bulleted, numbered, quote, divider = exactly one line // - paragraph = one or more consecutive "plain" lines, joined with
// - blank lines separate blocks and are otherwise dropped // - inline marks: **bold**, *italic*, ~~strikethrough~~, __underline__, ==highlight== let idCounter = 0 export function genId() { idCounter += 1 return `b${Date.now().toString(36)}${idCounter}` } export function newBlock(type = 'paragraph', html = '') { return { id: genId(), type, html } } export const BLOCK_TAGS = { paragraph: 'p', heading1: 'h1', heading2: 'h2', heading3: 'h3', bulleted: 'li', numbered: 'li', quote: 'blockquote', divider: 'hr', } export const BLOCK_TYPES = [ { type: 'paragraph', label: 'Text', icon: 'Aa' }, { type: 'heading1', label: 'Heading 1', icon: 'H1' }, { type: 'heading2', label: 'Heading 2', icon: 'H2' }, { type: 'heading3', label: 'Heading 3', icon: 'H3' }, { type: 'bulleted', label: 'Bulleted list', icon: '•' }, { type: 'numbered', label: 'Numbered list', icon: '1.' }, { type: 'quote', label: 'Quote', icon: '❝' }, { type: 'divider', label: 'Divider', icon: '―' }, ] export const PLACEHOLDERS = { paragraph: "Type '/' for commands…", heading1: 'Heading 1', heading2: 'Heading 2', heading3: 'Heading 3', bulleted: 'List item', numbered: 'List item', quote: 'Quote', } const HEADING_RE = /^(#{1,3})\s(.*)$/ const BULLETED_RE = /^[-*]\s(.*)$/ const NUMBERED_RE = /^\d+\.\s(.*)$/ const QUOTE_RE = /^>\s?(.*)$/ const DIVIDER_RE = /^-{3,}$/ function escapeHtml(str) { return str.replace(/&/g, '&').replace(//g, '>') } function inlineMarkdownToHtml(text) { let html = escapeHtml(text) html = html.replace(/\*\*([^*]+?)\*\*/g, '$1') html = html.replace(/~~([^~]+?)~~/g, '$1') html = html.replace(/__([^_]+?)__/g, '$1') html = html.replace(/==([^=]+?)==/g, '$1') html = html.replace(/\*([^*]+?)\*/g, '$1') return html } // Walk inline HTML (only /////////
// + text expected) back into our inline markdown dialect. function serializeInlineNode(node) { let out = '' for (const child of node.childNodes) { if (child.nodeType === Node.TEXT_NODE) { out += child.nodeValue } else if (child.nodeType === Node.ELEMENT_NODE) { const tag = child.tagName.toLowerCase() const inner = serializeInlineNode(child) if (tag === 'strong' || tag === 'b') out += `**${inner}**` else if (tag === 'em' || tag === 'i') out += `*${inner}*` else if (tag === 'u') out += `__${inner}__` else if (tag === 's' || tag === 'strike' || tag === 'del') out += `~~${inner}~~` else if (tag === 'mark') out += `==${inner}==` else if (tag === 'br') out += '\n' else out += inner } } return out } function htmlToInlineMarkdown(html) { if (!html) return '' const container = document.createElement('div') container.innerHTML = html return serializeInlineNode(container) } function htmlToPlainText(html) { if (!html) return '' const container = document.createElement('div') container.innerHTML = html.replace(//gi, ' ') return container.textContent } export function markdownToBlocks(markdown) { const lines = (markdown || '').replace(/\r\n/g, '\n').split('\n') const blocks = [] let paraLines = [] const flushPara = () => { if (paraLines.length === 0) return const html = paraLines.map(inlineMarkdownToHtml).join('
') blocks.push(newBlock('paragraph', html)) paraLines = [] } for (const line of lines) { if (line.trim() === '') { flushPara() continue } let m if (DIVIDER_RE.test(line.trim())) { flushPara() blocks.push(newBlock('divider', '')) } else if ((m = HEADING_RE.exec(line))) { flushPara() blocks.push(newBlock(`heading${m[1].length}`, inlineMarkdownToHtml(m[2]))) } else if ((m = QUOTE_RE.exec(line))) { flushPara() blocks.push(newBlock('quote', inlineMarkdownToHtml(m[1]))) } else if ((m = BULLETED_RE.exec(line))) { flushPara() blocks.push(newBlock('bulleted', inlineMarkdownToHtml(m[1]))) } else if ((m = NUMBERED_RE.exec(line))) { flushPara() blocks.push(newBlock('numbered', inlineMarkdownToHtml(m[1]))) } else { paraLines.push(line) } } flushPara() if (blocks.length === 0) blocks.push(newBlock('paragraph', '')) return blocks } export function blocksToMarkdown(blocks) { const lines = [] let numberCounter = 0 for (const block of blocks) { if (block.type === 'numbered') numberCounter += 1 else numberCounter = 0 const flat = () => htmlToInlineMarkdown(block.html).replace(/\n/g, ' ') switch (block.type) { case 'heading1': lines.push(`# ${flat()}`); break case 'heading2': lines.push(`## ${flat()}`); break case 'heading3': lines.push(`### ${flat()}`); break case 'bulleted': lines.push(`- ${flat()}`); break case 'numbered': lines.push(`${numberCounter}. ${flat()}`); break case 'quote': lines.push(`> ${flat()}`); break case 'divider': lines.push('---'); break case 'paragraph': default: lines.push(htmlToInlineMarkdown(block.html)); break } } return lines.join('\n') } // Plain-text extraction for spaCy/LLM context (no markdown markers, no HTML). export function blocksToPlainText(blocks) { return blocks .map((b) => (b.type === 'divider' ? '' : htmlToPlainText(b.html))) .filter((t) => t.trim() !== '') .join('\n') } export function stripMarkdown(markdown) { return blocksToPlainText(markdownToBlocks(markdown)) }