| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, '<').replace(/>/g, '>') |
| } |
|
|
| function inlineMarkdownToHtml(text) { |
| let html = escapeHtml(text) |
| html = html.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>') |
| html = html.replace(/~~([^~]+?)~~/g, '<s>$1</s>') |
| html = html.replace(/__([^_]+?)__/g, '<u>$1</u>') |
| html = html.replace(/==([^=]+?)==/g, '<mark>$1</mark>') |
| html = html.replace(/\*([^*]+?)\*/g, '<em>$1</em>') |
| return html |
| } |
|
|
| |
| |
| 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(/<br\s*\/?>/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('<br>') |
| 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') |
| } |
|
|
| |
| 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)) |
| } |
|
|