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,223 @@
|
||||
<script setup>
|
||||
// The message composer shared by the visitor contact panel and the admin
|
||||
// inbox: a constrained contenteditable (plain-text paste, toolbar-driven
|
||||
// formatting, upload chips) serialized to the Markdown subset on send
|
||||
// (./richtext.js). The server re-renders that with its strict parser —
|
||||
// what shows in threads is never this DOM.
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { apiFetch } from 'paskia'
|
||||
import { serializeRich } from './richtext.js'
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: { type: String, default: 'Write a message…' },
|
||||
// Visitor session token for the public upload endpoint; empty = admin,
|
||||
// whose uploads go to the /_api file store instead.
|
||||
token: { type: String, default: '' },
|
||||
autofocus: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['send'])
|
||||
|
||||
const box = ref(null)
|
||||
const imageInput = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) box.value?.focus()
|
||||
})
|
||||
|
||||
function cmd(name, value = null) {
|
||||
box.value?.focus()
|
||||
document.execCommand(name, false, value)
|
||||
}
|
||||
|
||||
function link() {
|
||||
const url = window.prompt('Link URL', 'https://')
|
||||
if (url && /^(https?:\/\/|mailto:)/.test(url)) cmd('createLink', url)
|
||||
}
|
||||
|
||||
const escHtml = (s) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
// Paste is plain text only — the grammar (bold, links, chips…) comes from
|
||||
// the toolbar and uploads, never from foreign HTML.
|
||||
function onPaste(ev) {
|
||||
ev.preventDefault()
|
||||
if (ev.clipboardData?.files?.length) {
|
||||
uploadFiles(ev.clipboardData.files)
|
||||
return
|
||||
}
|
||||
const text = ev.clipboardData?.getData('text/plain') || ''
|
||||
if (!text) return
|
||||
const html = text
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => `<div>${p.split(/\r\n|\r|\n/).map(escHtml).join('<br>')}</div>`)
|
||||
.join('')
|
||||
document.execCommand('insertHTML', false, html)
|
||||
}
|
||||
|
||||
function onDrop(ev) {
|
||||
if (ev.dataTransfer?.files?.length) {
|
||||
ev.preventDefault()
|
||||
uploadFiles(ev.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFiles(files) {
|
||||
for (const file of files) {
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
console.warn('contact upload skipped (over 20 MB):', file.name)
|
||||
continue
|
||||
}
|
||||
uploading.value++
|
||||
try {
|
||||
const path = `/_${props.token ? 'contact' : 'api'}/files/${encodeURIComponent(file.name)}`
|
||||
const init = { method: 'PUT', body: file }
|
||||
if (props.token) init.headers = { 'x-contact-token': props.token }
|
||||
const r = await (props.token ? fetch(path, init) : apiFetch(path, init))
|
||||
if (!r.ok) throw new Error(`upload failed with status ${r.status}`)
|
||||
insertUpload(await r.json())
|
||||
} catch (e) {
|
||||
console.error('contact upload failed:', e)
|
||||
} finally {
|
||||
uploading.value--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An uploaded file becomes an inline image or an attachment chip; both
|
||||
// serialize back to /_f/ Markdown references on send.
|
||||
function insertUpload({ path, image, name }) {
|
||||
const html = image
|
||||
? `<img class="contact-img" src="${escHtml(path)}" data-path="${escHtml(path)}" alt="${escHtml(name)}">`
|
||||
: `<span class="contact-file" contenteditable="false" data-path="${escHtml(path)}" data-name="${escHtml(name)}">📎 ${escHtml(name)}</span> `
|
||||
box.value?.focus()
|
||||
document.execCommand('insertHTML', false, html)
|
||||
}
|
||||
|
||||
function send() {
|
||||
const md = serializeRich(box.value)
|
||||
if (!md || uploading.value) return
|
||||
emit('send', md)
|
||||
box.value.innerHTML = ''
|
||||
}
|
||||
|
||||
function onKeydown(ev) {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') send()
|
||||
}
|
||||
|
||||
defineExpose({ focus: () => box.value?.focus() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer">
|
||||
<div
|
||||
ref="box"
|
||||
class="composer-box"
|
||||
contenteditable="true"
|
||||
:data-placeholder="placeholder"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
@paste="onPaste"
|
||||
@drop="onDrop"
|
||||
@keydown="onKeydown"
|
||||
></div>
|
||||
<div class="composer-bar">
|
||||
<button type="button" title="bold" @click="cmd('bold')"><b>B</b></button>
|
||||
<button type="button" title="italic" @click="cmd('italic')"><i>I</i></button>
|
||||
<button type="button" title="link" @click="link">🔗</button>
|
||||
<button type="button" title="list" @click="cmd('insertUnorderedList')">≡</button>
|
||||
<button type="button" title="image" @click="imageInput.click()">🖼</button>
|
||||
<button type="button" title="attachment" @click="fileInput.click()">📎</button>
|
||||
<span v-if="uploading" class="uploading">uploading…</span>
|
||||
<button type="button" class="send" title="send (Ctrl+Enter)" @click="send">➤</button>
|
||||
</div>
|
||||
<input
|
||||
ref="imageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
@change="uploadFiles($event.target.files); $event.target.value = ''"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
@change="uploadFiles($event.target.files); $event.target.value = ''"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.composer {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg, Canvas);
|
||||
}
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-box {
|
||||
min-height: 3.2rem;
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.7rem;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.composer-box:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.composer-box :deep(img.contact-img) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 10rem;
|
||||
margin: 0.2rem 0;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.composer-box :deep(.contact-file) {
|
||||
display: inline-block;
|
||||
padding: 0.05em 0.5em;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1em;
|
||||
background: color-mix(var(--muted) 10%, transparent);
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.composer-bar button {
|
||||
min-width: 1.7rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border: none;
|
||||
border-radius: 0.3rem;
|
||||
background: none;
|
||||
color: var(--text, CanvasText);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.composer-bar button:hover {
|
||||
background: color-mix(var(--muted) 15%, transparent);
|
||||
}
|
||||
.composer-bar .uploading {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.composer-bar .send {
|
||||
margin-left: auto;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
// The visitor's contact thread: messages pushed live over the activity
|
||||
// socket (via the bridge pagerite.js hands to contact-main), with the
|
||||
// composer shared with the admin inbox. state is contact-main's reactive
|
||||
// store ({open, thread, unread}).
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import Composer from './Composer.vue'
|
||||
|
||||
const props = defineProps({
|
||||
state: { type: Object, required: true },
|
||||
token: { type: String, required: true },
|
||||
})
|
||||
const emit = defineEmits(['send', 'close'])
|
||||
|
||||
const scroll = ref(null)
|
||||
|
||||
async function scrollDown() {
|
||||
await nextTick()
|
||||
if (scroll.value) scroll.value.scrollTop = scroll.value.scrollHeight
|
||||
}
|
||||
|
||||
watch(() => props.state.thread?.messages?.length, scrollDown)
|
||||
watch(() => props.state.open, (open) => { if (open) scrollDown() })
|
||||
|
||||
function fmtTime(t) {
|
||||
return new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="state.open" class="contact-backdrop" @click.self="emit('close')">
|
||||
<section class="contact-panel" role="dialog" aria-label="Contact us">
|
||||
<header>
|
||||
<h2>Contact us</h2>
|
||||
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
<div ref="scroll" class="contact-messages">
|
||||
<p v-if="!state.thread || !state.thread.messages.length" class="contact-empty">
|
||||
Send us a message — the reply appears here.
|
||||
</p>
|
||||
<div
|
||||
v-for="m in state.thread?.messages || []"
|
||||
:key="m.id"
|
||||
class="contact-msg"
|
||||
:class="{ mine: !m.admin }"
|
||||
>
|
||||
<!-- m.html is the server's strict-markdown rendering of the
|
||||
message source (pagerite/messages.py, html=False): markdown-it
|
||||
output only, never user markup. -->
|
||||
<div class="contact-msg-body" v-html="m.html"></div>
|
||||
<footer>
|
||||
<span v-if="m.admin" class="who">{{ m.admin }}</span>
|
||||
<a v-if="m.page" class="page" :href="m.page" :title="`sent from ${m.page}`">⌂</a>
|
||||
<time>{{ fmtTime(m.t) }}</time>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<Composer :token="token" autofocus @send="(md) => emit('send', md)" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
background: rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.contact-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(26rem, 94vw);
|
||||
padding: 0.8rem;
|
||||
gap: 0.6rem;
|
||||
background: var(--bg, Canvas);
|
||||
color: var(--text, CanvasText);
|
||||
border-left: 1px solid var(--line);
|
||||
box-shadow: -0.5rem 0 2rem rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
.contact-panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.contact-panel h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.contact-panel .close {
|
||||
margin-left: auto;
|
||||
padding: 0 0.3rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.contact-panel .close:hover {
|
||||
color: var(--text, CanvasText);
|
||||
}
|
||||
|
||||
.contact-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
.contact-empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.contact-msg {
|
||||
max-width: 88%;
|
||||
margin: 0.45rem 0;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 0.6rem;
|
||||
background: color-mix(var(--muted) 12%, transparent);
|
||||
}
|
||||
.contact-msg.mine {
|
||||
margin-left: auto;
|
||||
background: color-mix(var(--accent) 16%, transparent);
|
||||
}
|
||||
.contact-msg-body {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.contact-msg-body :deep(p) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.contact-msg-body :deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.contact-msg-body :deep(a.attachment) {
|
||||
display: inline-block;
|
||||
padding: 0.05em 0.5em;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1em;
|
||||
background: var(--bg, Canvas);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.contact-msg footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.contact-msg footer .who {
|
||||
font-weight: 600;
|
||||
}
|
||||
.contact-msg footer .page {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.contact-msg footer .page:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -878,6 +878,37 @@ article {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* {contact: Label} directive: the button opening the visitor contact
|
||||
panel (docs/messages.md). A standalone accent button in the article
|
||||
flow; .has-unread (set by contact-main) marks a new admin reply. */
|
||||
.contact-trigger {
|
||||
position: relative;
|
||||
padding: 0.35em 1.1em;
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 1.2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-trigger:hover {
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.contact-trigger.has-unread::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -0.25em;
|
||||
right: -0.25em;
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 0.15em var(--bg);
|
||||
}
|
||||
|
||||
.edit-link {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Contact panel entry: imported on demand by pagerite.js when a
|
||||
// {contact:} trigger button is clicked. The panel rides the activity
|
||||
// socket through the bridge pagerite.js hands over ({send, register}) —
|
||||
// no extra connection is opened. The session token is a localStorage
|
||||
// random; the server stores only its hash.
|
||||
import { createApp, reactive } from 'vue'
|
||||
import ContactPanel from './ContactPanel.vue'
|
||||
|
||||
const TOKEN_KEY = 'pagerite-contact-token'
|
||||
const SEEN_KEY = 'pagerite-contact-seen'
|
||||
|
||||
let app = null
|
||||
const state = reactive({ open: false, thread: null, unread: false })
|
||||
|
||||
function ensureToken() {
|
||||
let token = localStorage.getItem(TOKEN_KEY)
|
||||
if (!token) {
|
||||
token = crypto.randomUUID()
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
function seenAt() {
|
||||
return Number(localStorage.getItem(SEEN_KEY) || 0)
|
||||
}
|
||||
|
||||
function markSeen() {
|
||||
localStorage.setItem(SEEN_KEY, String(Date.now()))
|
||||
state.unread = false
|
||||
refreshBadges()
|
||||
}
|
||||
|
||||
// A "new reply" dot on every contact trigger button while the panel is
|
||||
// closed and the newest admin message is younger than the last look.
|
||||
// Called on thread pushes and (from pagerite.js) after navigations,
|
||||
// whose swap rebuilds the buttons.
|
||||
export function refreshBadges() {
|
||||
for (const btn of document.querySelectorAll('.contact-trigger')) {
|
||||
btn.classList.toggle('has-unread', state.unread)
|
||||
}
|
||||
}
|
||||
|
||||
function onThread(thread) {
|
||||
state.thread = thread
|
||||
const lastAdmin = [...thread.messages].reverse().find((m) => m.admin)
|
||||
state.unread = Boolean(lastAdmin) && Date.parse(lastAdmin.t) > seenAt() && !state.open
|
||||
if (state.open) markSeen()
|
||||
else refreshBadges()
|
||||
}
|
||||
|
||||
export function open(bridge) {
|
||||
if (!app) {
|
||||
const token = ensureToken()
|
||||
app = createApp(ContactPanel, {
|
||||
state,
|
||||
token,
|
||||
onSend: (md) => bridge.send({ token, msg: md }),
|
||||
onClose: () => {
|
||||
state.open = false
|
||||
refreshBadges()
|
||||
},
|
||||
})
|
||||
const el = document.createElement('div')
|
||||
el.id = 'contact-root'
|
||||
document.body.append(el)
|
||||
app.mount(el)
|
||||
// Registration doubles as the resync: the server pushes the thread
|
||||
// state back, and pagerite.js re-registers on every socket reconnect.
|
||||
bridge.register(token, onThread)
|
||||
}
|
||||
state.open = true
|
||||
markSeen()
|
||||
}
|
||||
@@ -456,6 +456,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
// Editor-driven swaps don't go through load(): re-evaluate the
|
||||
// language selector from the fresh copy too.
|
||||
mountLangselect(new DOMParser().parseFromString(ev.detail.html, "text/html"));
|
||||
contactMod?.refreshBadges();
|
||||
});
|
||||
|
||||
// Editors mutate site-wide state (theme, structure, headings, banners),
|
||||
@@ -629,6 +630,20 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
ws.onopen = () => {
|
||||
wsPolicy.opened();
|
||||
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
|
||||
// Re-registering resyncs the contact thread after a reconnect.
|
||||
if (contactToken) ws.send(JSON.stringify({ token: contactToken }));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
// Contact thread pushes ride this socket (docs/messages.md).
|
||||
if (!contactListener) return;
|
||||
let msg = null;
|
||||
try {
|
||||
msg = JSON.parse(ev.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg?.type === "thread") contactListener(msg);
|
||||
else if (msg?.type === "contact-error") console.warn("contact:", msg.detail);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
@@ -869,6 +884,61 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
ping({ to: currentPath, lang: tag });
|
||||
});
|
||||
|
||||
// --- Contact panel ------------------------------------------------------
|
||||
// {contact: Label} directives in articles render .contact-trigger buttons.
|
||||
// The panel bundle (Vue composer + thread view) loads on first click and
|
||||
// rides the activity socket for realtime thread state — no extra
|
||||
// connection (docs/messages.md). Registration re-sends on every socket
|
||||
// (re)connect, and the server pushes the thread back as the resync.
|
||||
let contactMod = null;
|
||||
let contactToken = "";
|
||||
let contactListener = null;
|
||||
const contactBridge = {
|
||||
register(token, onThread) {
|
||||
contactToken = token;
|
||||
contactListener = onThread;
|
||||
contactBridge.send({ token });
|
||||
},
|
||||
send(msg) {
|
||||
msg.fr = currentPath;
|
||||
activityWs();
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
return;
|
||||
} catch { /* fall through to queueing */ }
|
||||
}
|
||||
wsQueue.push(msg);
|
||||
},
|
||||
};
|
||||
|
||||
async function openContactPanel() {
|
||||
try {
|
||||
contactMod ??= await import(/* @vite-ignore */ assets["pagerite:contact-src"]);
|
||||
for (const css of (assets["pagerite:contact-css"] || "").split(",")) {
|
||||
if (css && !document.querySelector(`link[href="${css}"]`)) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = css;
|
||||
link.dataset.pagerite = "contact-css";
|
||||
document.head.append(link);
|
||||
}
|
||||
}
|
||||
contactMod.open(contactBridge);
|
||||
} catch (e) {
|
||||
console.error("contact panel load failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener("click", (ev) => {
|
||||
const btn = ev.target.closest?.(".contact-trigger");
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
// In the editor preview the button is inert.
|
||||
if (document.body.classList.contains("editing")) return;
|
||||
openContactPanel();
|
||||
});
|
||||
|
||||
// --- Fetch navigation ------------------------------------------------
|
||||
async function load(url, push = true, back = false) {
|
||||
// Navigating with the editor open keeps it open: the shell retargets to
|
||||
@@ -978,6 +1048,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
applyEffects();
|
||||
mountAnalytics(doc);
|
||||
mountLangselect(doc);
|
||||
// The swap rebuilt any contact-trigger buttons: re-apply the
|
||||
// "new reply" dot to the fresh elements.
|
||||
contactMod?.refreshBadges();
|
||||
};
|
||||
// Rotating cube page transition (styles injected as #pagerite-transition
|
||||
// from the selected design's transition.css, e.g. themes/cube/);
|
||||
|
||||
@@ -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