Files
pagerite/frontend/src/richtext.js
T
LeoVasanko 49bdedd938 contact messages: fix inline formatting loss, attachment uploads
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).
2026-09-26 01:31:45 +00:00

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 ? `![${escapeMd(node.alt || 'image')}](${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
}