contact messages: visitor panel and composer
The composer is a constrained contenteditable (plain-text paste, toolbar formatting, upload chips for images/attachments) serialized to a Markdown subset by richtext.js — the wire format is text and the server re-renders it, so no user markup ever reaches a page. Uploads go to /_contact/files with the session token (admins share the component via /_api/files). ContactPanel shows the thread pushed live over the activity socket: pagerite.js lazy-loads the bundle on trigger clicks, routes socket pushes to it, re-registers the session token on every reconnect (the resync), and dots trigger buttons on unread replies.
This commit is contained in:
1 parent
776f408743
commit
eea89032a2
6 files changed
+669
No files matched your search
@@ -0,0 +1,101 @@
|
||||
// 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')
|
||||
}
|
||||
|
||||
// One message's Markdown from the composer root's child nodes: each
|
||||
// top-level node becomes a block, joined by blank lines.
|
||||
export function serializeRich(root) {
|
||||
const blocks = []
|
||||
for (const node of root.childNodes) block(node, blocks)
|
||||
return blocks
|
||||
.map((b) => b.trim())
|
||||
.filter((b) => b)
|
||||
.join('\n\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function block(node, out) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
out.push(escapeMd(node.textContent))
|
||||
return
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||
switch (node.tagName) {
|
||||
case 'UL':
|
||||
out.push([...node.children].map((li) => `- ${inline(li).trim()}`).join('\n'))
|
||||
break
|
||||
case 'OL':
|
||||
out.push([...node.children].map((li, i) => `${i + 1}. ${inline(li).trim()}`).join('\n'))
|
||||
break
|
||||
case 'BLOCKQUOTE':
|
||||
out.push(inline(node).split('\n').map((l) => `> ${l}`).join('\n'))
|
||||
break
|
||||
case 'PRE':
|
||||
out.push('```\n' + node.textContent + '\n```')
|
||||
break
|
||||
case 'IMG':
|
||||
// Uploaded image (data-path set by the composer on insert).
|
||||
if (node.dataset.path) out.push(``)
|
||||
break
|
||||
case 'BR':
|
||||
break
|
||||
default:
|
||||
// DIV, P and anything else: a paragraph of inline content.
|
||||
out.push(inline(node))
|
||||
}
|
||||
}
|
||||
|
||||
function inline(node) {
|
||||
let out = ''
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
out += escapeMd(child.textContent)
|
||||
continue
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue
|
||||
switch (child.tagName) {
|
||||
case 'B':
|
||||
case 'STRONG':
|
||||
out += `**${inline(child)}**`
|
||||
break
|
||||
case 'I':
|
||||
case 'EM':
|
||||
out += `*${inline(child)}*`
|
||||
break
|
||||
case 'CODE':
|
||||
out += '`' + child.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`'
|
||||
break
|
||||
case 'BR':
|
||||
out += '\n'
|
||||
break
|
||||
case 'A': {
|
||||
const text = inline(child)
|
||||
const href = child.getAttribute('href') || ''
|
||||
out += /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text
|
||||
break
|
||||
}
|
||||
case 'IMG':
|
||||
if (child.dataset.path) out += ``
|
||||
break
|
||||
case 'SPAN':
|
||||
// Attachment chip (data-path + data-name); other spans unwrap.
|
||||
out += child.dataset.path && child.dataset.name
|
||||
? `[📎 ${escapeMd(child.dataset.name)}](${child.dataset.path})`
|
||||
: inline(child)
|
||||
break
|
||||
default:
|
||||
out += inline(child)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user