The serializer treated only DIV/P as blocks: a formatting element directly under the editable root (a bolded segment of an undivided line is a top-level <b>) became a separate PLAIN block, losing both the formatting and the paragraph structure. Top-level inline runs now accumulate into one paragraph, and style-based formatting (Firefox's font-weight/font-style spans) maps back to markers. Attachments: the upload allowlist rejected everything but a handful of types with a console-only 415. The logic inverts — raster images get derivatives, script carriers (svg/html/js/xml...) are refused, and any other file stores as a download chip. Upload failures now show in the composer, and toolbar buttons keep the contenteditable selection on click (mousedown.prevent).
120 lines
4.0 KiB
JavaScript
120 lines
4.0 KiB
JavaScript
// Serialize the constrained contenteditable composer DOM to the contact
|
|
// message Markdown subset (emphasis, links, lists, code, /_f/ images and
|
|
// file chips). The wire format is text: the server re-renders it with a
|
|
// strict, html-less MarkdownIt (pagerite/messages.py), so no user markup
|
|
// of any kind ever reaches a page. This serializer is for fidelity, not
|
|
// safety — anything it does not know is simply unwrapped.
|
|
|
|
const ESCAPE_RE = /([\\`*_[\]])/g
|
|
|
|
export function escapeMd(text) {
|
|
return text.replace(ESCAPE_RE, '\\$1')
|
|
}
|
|
|
|
// Tags that open a new block at the composer's top level. Everything else
|
|
// there — text, B/I/A/SPAN formatting elements, inline images — is an
|
|
// inline run and joins the paragraph being accumulated: browsers happily
|
|
// put formatting directly under the editable root (a bolded segment of an
|
|
// undivided first line is a top-level <b>), and treating those as blocks
|
|
// would split the paragraph and drop the formatting.
|
|
const BLOCK_TAGS = new Set([
|
|
'DIV', 'P', 'UL', 'OL', 'BLOCKQUOTE', 'PRE',
|
|
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'TABLE',
|
|
])
|
|
|
|
// One message's Markdown from the composer root: inline runs become
|
|
// paragraphs, block elements become their own blocks, blank lines between.
|
|
export function serializeRich(root) {
|
|
const blocks = []
|
|
let run = []
|
|
const flush = () => {
|
|
const text = run.join('').trim()
|
|
run = []
|
|
if (text) blocks.push(text)
|
|
}
|
|
for (const node of root.childNodes) {
|
|
if (node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName)) {
|
|
flush()
|
|
block(node, blocks)
|
|
} else {
|
|
run.push(inline(node))
|
|
}
|
|
}
|
|
flush()
|
|
return blocks.join('\n\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
}
|
|
|
|
function block(node, out) {
|
|
switch (node.tagName) {
|
|
case 'UL':
|
|
out.push([...node.children].map((li) => `- ${inlineChildren(li).trim()}`).join('\n'))
|
|
break
|
|
case 'OL':
|
|
out.push([...node.children].map((li, i) => `${i + 1}. ${inlineChildren(li).trim()}`).join('\n'))
|
|
break
|
|
case 'BLOCKQUOTE':
|
|
out.push(inlineChildren(node).split('\n').map((l) => `> ${l}`).join('\n'))
|
|
break
|
|
case 'PRE':
|
|
out.push('```\n' + node.textContent + '\n```')
|
|
break
|
|
case 'HR':
|
|
break
|
|
default: {
|
|
// DIV, P, headings: a paragraph of inline content.
|
|
const text = inlineChildren(node).trim()
|
|
if (text) out.push(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
function inlineChildren(node) {
|
|
return [...node.childNodes].map(inline).join('')
|
|
}
|
|
|
|
function inline(node) {
|
|
if (node.nodeType === Node.TEXT_NODE) return escapeMd(node.textContent)
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return ''
|
|
switch (node.tagName) {
|
|
case 'B':
|
|
case 'STRONG':
|
|
return `**${inlineChildren(node)}**`
|
|
case 'I':
|
|
case 'EM':
|
|
return `*${inlineChildren(node)}*`
|
|
case 'CODE':
|
|
return '`' + node.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`'
|
|
case 'BR':
|
|
return '\n'
|
|
case 'A': {
|
|
const text = inlineChildren(node)
|
|
const href = node.getAttribute('href') || ''
|
|
return /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text
|
|
}
|
|
case 'IMG':
|
|
// Uploaded image (data-path set by the composer on insert).
|
|
return node.dataset.path ? `` : ''
|
|
case 'SPAN':
|
|
// Attachment chip (data-path + data-name); other spans unwrap.
|
|
if (node.dataset.path && node.dataset.name) {
|
|
return `[📎 ${escapeMd(node.dataset.name)}](${node.dataset.path})`
|
|
}
|
|
return wrapStyle(node, inlineChildren(node))
|
|
default:
|
|
return wrapStyle(node, inlineChildren(node))
|
|
}
|
|
}
|
|
|
|
// Some browsers style formatting instead of tagging it (Firefox's
|
|
// execCommand produces <span style="font-weight: bold">): keep span-level
|
|
// bold/italic alive into the Markdown.
|
|
function wrapStyle(node, text) {
|
|
if (!text.trim()) return text
|
|
const s = node.style
|
|
if (s) {
|
|
if (/bold|bolder|[6-9]00/.test(s.fontWeight)) text = `**${text}**`
|
|
if (s.fontStyle === 'italic' || s.fontStyle === 'oblique') text = `*${text}*`
|
|
}
|
|
return text
|
|
}
|