From 0b51e06ccb4d9e1ee5593c2437e88555acb131ab Mon Sep 17 00:00:00 2001 From: epoberezkin Date: Sun, 18 Oct 2026 21:45:20 +0000 Subject: [PATCH] deploy: 056346020487d4c78ac538957c6baef9865c884a --- css/tailwind.css | 4 + directory/index.html | 51 ++++++++++ js/channel-preview.js | 229 +++++++++++++++++++++++++++++++++++++----- js/directory.js | 168 +++++++++++++++++++++++++++++-- js/simplex-lib.js | 159 ++++++++++++++++++++++++++++- 5 files changed, 580 insertions(+), 31 deletions(-) diff --git a/css/tailwind.css b/css/tailwind.css index 7666e460a9..68ff665246 100644 --- a/css/tailwind.css +++ b/css/tailwind.css @@ -1254,6 +1254,10 @@ video { flex-grow: 1; } +.border-collapse { + border-collapse: collapse; +} + .border-separate { border-collapse: separate; } diff --git a/directory/index.html b/directory/index.html index f14822e5bf..492d783462 100644 --- a/directory/index.html +++ b/directory/index.html @@ -621,6 +621,57 @@ window.addEventListener('click',(e)=>{ color: magenta; } + #directory .entry { + --md-card: rgba(139, 135, 134, 0.12); + --md-line: rgba(139, 135, 134, 0.35); + --md-bar: rgba(0, 0, 0, 0.16); + --md-stripe: rgba(139, 135, 134, 0.08); + --md-link: #0053D0; + } + + .dark #directory .entry { + --md-card: rgba(255, 255, 255, 0.08); + --md-line: rgba(139, 135, 134, 0.45); + --md-bar: rgba(255, 255, 255, 0.22); + --md-stripe: rgba(139, 135, 134, 0.12); + --md-link: #70F0F9; + } + + #directory .entry .text-container { min-width: 0; } + #directory .entry .md-gap { height: 0.7em; } + #directory .entry .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h4 { font-weight: 700; } + #directory .entry .md-entry { display: flex; gap: 6px; } + #directory .entry .md-mark { flex: none; min-width: 1.1em; } + #directory .entry .md-text { flex: 1; min-width: 0; } + #directory .entry .md-quote { display: flex; gap: 10px; } + #directory .entry .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--md-bar); } + #directory .entry .md-quote > .md-text { opacity: 0.7; } + #directory .entry .md-code { position: relative; background: var(--md-card); border-radius: 4px; overflow: hidden; } + #directory .entry .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } + #directory .entry .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 4px; background: var(--md-card); color: var(--md-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } + #directory .entry .md-code:hover .md-copy, + #directory .entry .md-code.tapped .md-copy, + #directory .entry .md-copy:focus-visible { opacity: 1; pointer-events: auto; } + #directory .entry .md-copy svg { width: 18px; height: 18px; } + #directory .entry .md-icon-done, + #directory .entry .md-copy.copied .md-icon-copy { display: none; } + #directory .entry .md-copy.copied .md-icon-done { display: block; } + #directory .entry .md-table { overflow-x: auto; } + #directory .entry .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--md-line); border-radius: 4px; overflow: hidden; } + #directory .entry .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } + #directory .entry .md-frame th, + #directory .entry .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--md-line); border-bottom: 1px solid var(--md-line); } + #directory .entry .md-frame th:last-child, + #directory .entry .md-frame td:last-child { border-right: 0; } + #directory .entry .md-frame tr:last-child th, + #directory .entry .md-frame tr:last-child td { border-bottom: 0; } + #directory .entry .md-frame th { font-weight: 700; background: var(--md-card); } + #directory .entry .md-frame tr:nth-child(odd) td { background: var(--md-stripe); } + #directory .entry .md-rule { border: 0; border-top: 1px solid var(--md-line); margin: 6px 0; } + .pagination { display: flex; justify-content: center; diff --git a/js/channel-preview.js b/js/channel-preview.js index 30e5a97bd7..959f55ec91 100644 --- a/js/channel-preview.js +++ b/js/channel-preview.js @@ -67,7 +67,157 @@ function targetBlank(uri) { return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } -function renderMarkdown(fts) { +const MAX_LIST_DEPTH = 7; + +const MAX_TABLE_CELLS = 1000; + +const COPY_BUTTON = + ''; + +// navigator.clipboard is undefined outside secure contexts; fall back to execCommand +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function(resolve, reject) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + ok ? resolve() : reject(new Error('copy failed')); + }); +} + +function handleCodeClick(e) { + const card = e.target.closest('.md-code'); + if (!card) return; + const button = e.target.closest('.md-copy'); + if (!button) { + card.classList.toggle('tapped'); + return; + } + const pre = card.querySelector('pre'); + if (!pre) return; + copyToClipboard(pre.innerText).then(() => { + button.classList.add('copied'); + setTimeout(() => button.classList.remove('copied'), 1500); + }).catch(() => {}); +} + +function validBlocks(fts, blocks) { + let pos = 0; + const range = (r) => { + const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length; + if (valid) pos = r.to; + return valid; + }; + const entries = (items, depth) => + Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1)); + const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range)); + return Array.isArray(blocks) && blocks.every((b) => { + switch (b && b.type) { + case 'paragraph': + case 'heading': + case 'codeBlock': + case 'quote': + return range(b.content); + case 'list': + return entries(b.items, 1); + case 'table': + return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows); + case 'rule': + return range(b.mark); + default: + return false; + } + }); +} + +function renderBlocks(fts, blocks) { + let html = ''; + let pos = 0; + const segments = (r) => renderSegments(fts.slice(r.from, r.to), true); + const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak'; + const gap = (to) => { + if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '
'; + pos = Math.max(pos, to); + }; + const block = (r, open, close) => { + gap(r.from); + html += open + segments(r) + close; + pos = r.to; + }; + const entry = (e, level) => { + html += `
${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`; + pos = e.content.to; + e.items.forEach((item) => entry(item, level + 1)); + }; + const table = (b) => { + const rows = [b.header].concat(b.rows); + const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0; + const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left'); + const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}`; + const row = (cells, tag) => + '' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + ''; + gap(b.header.length > 0 ? b.header[0].from : pos); + html += '
' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
'; + const last = rows[rows.length - 1]; + pos = last.length > 0 ? last[last.length - 1].to : pos; + }; + for (const b of blocks) { + switch (b.type) { + case 'paragraph': + block(b.content, '
', '
'); + break; + case 'heading': + block(b.content, `
`, '
'); + break; + case 'codeBlock': + block(b.content, '
', '
' + COPY_BUTTON + '
'); + break; + case 'quote': + block(b.content, '
', '
'); + break; + case 'list': + if (b.items.length > 0) gap(b.items[0].content.from); + b.items.forEach((item) => entry(item, 0)); + break; + case 'table': + table(b); + break; + case 'rule': + block(b.mark, '
', ''); + break; + } + } + return html; +} + +function markupHtml(token) { + switch (token && token.type) { + case 'softBreak': + return ' '; + case 'lineBreak': + case 'hardBreak': + return '
'; + default: + return ''; + } +} + +function renderMarkdown(fts, blocks) { + return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false); +} + +function renderSegments(fts, withBlocks) { let html = ''; for (const ft of fts) { const { format, text } = ft; @@ -78,7 +228,7 @@ function renderMarkdown(fts) { try { switch (format.type) { case 'bold': - html += `${escapeHtml(text)}`; + html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`; break; case 'italic': html += `${escapeHtml(text)}`; @@ -126,6 +276,11 @@ function renderMarkdown(fts) { case 'phone': html += `${escapeHtml(text)}`; break; + case 'escape': + break; + case 'markup': + html += withBlocks ? markupHtml(format.token) : escapeHtml(text); + break; case 'unknown': html += escapeHtml(text); break; @@ -189,6 +344,9 @@ const STYLE = ` --sp-color-white: #000; --sp-qr-fg: #062D56; --sp-qr-bg: #ffffff; + --sp-md-line: rgba(139, 135, 134, 0.35); + --sp-md-bar: rgba(0, 0, 0, 0.16); + --sp-md-stripe: rgba(139, 135, 134, 0.08); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 1.4; @@ -224,6 +382,9 @@ const STYLE = ` --sp-color-white: #fff; --sp-qr-fg: #FFFBFA; --sp-qr-bg: transparent; + --sp-md-line: rgba(139, 135, 134, 0.45); + --sp-md-bar: rgba(255, 255, 255, 0.22); + --sp-md-stripe: rgba(139, 135, 134, 0.12); } .simplex-preview-header { @@ -663,6 +824,44 @@ const STYLE = ` .simplex-preview-text .black { color: var(--sp-color-black); } .simplex-preview-text .white { color: var(--sp-color-white); } +.simplex-preview-msg-col { + min-width: 0; +} + +.simplex-preview-text .md-gap { height: 0.7em; } +.simplex-preview-text .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h4 { font-weight: 700; } +.simplex-preview-text .md-entry { display: flex; gap: 6px; } +.simplex-preview-text .md-mark { flex: none; min-width: 1.1em; } +.simplex-preview-text .md-text { flex: 1; min-width: 0; } +.simplex-preview-text .md-quote { display: flex; gap: 10px; } +.simplex-preview-text .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--sp-md-bar); } +.simplex-preview-text .md-quote > .md-text { opacity: 0.7; } +.simplex-preview-text .md-code { position: relative; background: var(--sp-quote); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } +.simplex-preview-text .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 7px; background: var(--sp-quote); color: var(--sp-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } +.simplex-preview-text .md-code:hover .md-copy, +.simplex-preview-text .md-code.tapped .md-copy, +.simplex-preview-text .md-copy:focus-visible { opacity: 1; pointer-events: auto; } +.simplex-preview-text .md-copy svg { width: 18px; height: 18px; } +.simplex-preview-text .md-icon-done, +.simplex-preview-text .md-copy.copied .md-icon-copy { display: none; } +.simplex-preview-text .md-copy.copied .md-icon-done { display: block; } +.simplex-preview-text .md-table { overflow-x: auto; } +.simplex-preview-text .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--sp-md-line); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } +.simplex-preview-text .md-frame th, +.simplex-preview-text .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--sp-md-line); border-bottom: 1px solid var(--sp-md-line); } +.simplex-preview-text .md-frame th:last-child, +.simplex-preview-text .md-frame td:last-child { border-right: 0; } +.simplex-preview-text .md-frame tr:last-child th, +.simplex-preview-text .md-frame tr:last-child td { border-bottom: 0; } +.simplex-preview-text .md-frame th { font-weight: 700; background: var(--sp-quote); } +.simplex-preview-text .md-frame tr:nth-child(odd) td { background: var(--sp-md-stripe); } +.simplex-preview-text .md-rule { border: 0; border-top: 1px solid var(--sp-md-line); margin: 6px 0; } + .simplex-preview-main { flex: 1; min-width: 0; @@ -1051,6 +1250,7 @@ function render(container, data, channelLink, showAppBadges) { ts: messages.length > 0 ? messages[0].ts : new Date().toISOString(), content: { type: 'text', text: typeof welcome === 'string' ? welcome : '' }, formattedText: Array.isArray(welcome) ? welcome : null, + markdownBlocks: data.welcomeMessageBlocks, reactions: [] }; allMessages = [welcomeMsg].concat(messages); @@ -1153,7 +1353,7 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp if (shortDescr) { const descrDiv = document.createElement('div'); descrDiv.className = 'simplex-preview-info-descr'; - descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr) : escapeHtml(shortDescr); + descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr, data.shortDescriptionBlocks) : escapeHtml(shortDescr); container.appendChild(descrDiv); } @@ -1234,25 +1434,6 @@ function qrColor(value, fallback) { return value === 'transparent' ? '#0000' : value; } -// navigator.clipboard is undefined outside secure contexts; fall back to execCommand -function copyToClipboard(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { - return navigator.clipboard.writeText(text); - } - return new Promise(function(resolve, reject) { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.top = '-9999px'; - document.body.appendChild(ta); - ta.select(); - let ok = false; - try { ok = document.execCommand('copy'); } catch (e) {} - document.body.removeChild(ta); - ok ? resolve() : reject(new Error('copy failed')); - }); -} - function renderDesktopConversion(container, channelLink, showAppBadges) { if (showAppBadges) { renderAppBadges(container); @@ -1356,6 +1537,7 @@ function setupSecretToggles(container) { container.addEventListener('click', (e) => { const secret = e.target.closest('.secret'); if (secret) secret.classList.toggle('visible'); + handleCodeClick(e); }); } @@ -1412,6 +1594,7 @@ function renderMessages(container, messages, membersMap, channel) { } const col = document.createElement('div'); + col.className = 'simplex-preview-msg-col'; const bubble = renderBubble(msg, member, showTail, membersMap, channel); col.appendChild(bubble); @@ -1685,7 +1868,7 @@ function appendTextBlock(bubble, msg) { textDiv.className = 'simplex-preview-text'; const meta = renderMetaHTML(msg); if (msg.formattedText && msg.formattedText.length > 0) { - textDiv.innerHTML = renderMarkdown(msg.formattedText) + meta; + textDiv.innerHTML = renderMarkdown(msg.formattedText, msg.markdownBlocks) + meta; } else { textDiv.innerHTML = escapeHtml(msg.content.text || '') + meta; } diff --git a/js/directory.js b/js/directory.js index 18fdb2926f..f7c021605e 100644 --- a/js/directory.js +++ b/js/directory.js @@ -72,7 +72,157 @@ function targetBlank(uri) { return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } -function renderMarkdown(fts) { +const MAX_LIST_DEPTH = 7; + +const MAX_TABLE_CELLS = 1000; + +const COPY_BUTTON = + ''; + +// navigator.clipboard is undefined outside secure contexts; fall back to execCommand +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function(resolve, reject) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + ok ? resolve() : reject(new Error('copy failed')); + }); +} + +function handleCodeClick(e) { + const card = e.target.closest('.md-code'); + if (!card) return; + const button = e.target.closest('.md-copy'); + if (!button) { + card.classList.toggle('tapped'); + return; + } + const pre = card.querySelector('pre'); + if (!pre) return; + copyToClipboard(pre.innerText).then(() => { + button.classList.add('copied'); + setTimeout(() => button.classList.remove('copied'), 1500); + }).catch(() => {}); +} + +function validBlocks(fts, blocks) { + let pos = 0; + const range = (r) => { + const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length; + if (valid) pos = r.to; + return valid; + }; + const entries = (items, depth) => + Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1)); + const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range)); + return Array.isArray(blocks) && blocks.every((b) => { + switch (b && b.type) { + case 'paragraph': + case 'heading': + case 'codeBlock': + case 'quote': + return range(b.content); + case 'list': + return entries(b.items, 1); + case 'table': + return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows); + case 'rule': + return range(b.mark); + default: + return false; + } + }); +} + +function renderBlocks(fts, blocks) { + let html = ''; + let pos = 0; + const segments = (r) => renderSegments(fts.slice(r.from, r.to), true); + const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak'; + const gap = (to) => { + if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '
'; + pos = Math.max(pos, to); + }; + const block = (r, open, close) => { + gap(r.from); + html += open + segments(r) + close; + pos = r.to; + }; + const entry = (e, level) => { + html += `
${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`; + pos = e.content.to; + e.items.forEach((item) => entry(item, level + 1)); + }; + const table = (b) => { + const rows = [b.header].concat(b.rows); + const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0; + const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left'); + const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}`; + const row = (cells, tag) => + '' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + ''; + gap(b.header.length > 0 ? b.header[0].from : pos); + html += '
' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
'; + const last = rows[rows.length - 1]; + pos = last.length > 0 ? last[last.length - 1].to : pos; + }; + for (const b of blocks) { + switch (b.type) { + case 'paragraph': + block(b.content, '
', '
'); + break; + case 'heading': + block(b.content, `
`, '
'); + break; + case 'codeBlock': + block(b.content, '
', '
' + COPY_BUTTON + '
'); + break; + case 'quote': + block(b.content, '
', '
'); + break; + case 'list': + if (b.items.length > 0) gap(b.items[0].content.from); + b.items.forEach((item) => entry(item, 0)); + break; + case 'table': + table(b); + break; + case 'rule': + block(b.mark, '
', ''); + break; + } + } + return html; +} + +function markupHtml(token) { + switch (token && token.type) { + case 'softBreak': + return ' '; + case 'lineBreak': + case 'hardBreak': + return '
'; + default: + return ''; + } +} + +function renderMarkdown(fts, blocks) { + return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false); +} + +function renderSegments(fts, withBlocks) { let html = ''; for (const ft of fts) { const { format, text } = ft; @@ -83,7 +233,7 @@ function renderMarkdown(fts) { try { switch (format.type) { case 'bold': - html += `${escapeHtml(text)}`; + html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`; break; case 'italic': html += `${escapeHtml(text)}`; @@ -131,6 +281,11 @@ function renderMarkdown(fts) { case 'phone': html += `${escapeHtml(text)}`; break; + case 'escape': + break; + case 'markup': + html += withBlocks ? markupHtml(format.token) : escapeHtml(text); + break; case 'unknown': html += escapeHtml(text); break; @@ -198,6 +353,7 @@ async function initDirectory() { liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn)); newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn)); topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn)); + document.getElementById('directory').addEventListener('click', handleCodeClick); window.addEventListener('popstate', applyHash); function applyHash() { @@ -395,7 +551,7 @@ function displayEntries(entries) { for (let entry of entries) { try { - const { entryType, displayName, simplexName, groupLink, shortDescr, welcomeMessage, imageFile } = entry; + const { entryType, displayName, simplexName, groupLink, shortDescr, shortDescrBlocks, welcomeMessage, welcomeMessageBlocks, imageFile } = entry; const entryDiv = document.createElement('div'); entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8'; @@ -414,11 +570,11 @@ function displayEntries(entries) { textContainer.appendChild(simplexNameElement); } - const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined; - const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined; + const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage, welcomeMessageBlocks) : undefined; + const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr, shortDescrBlocks) : undefined; if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) { const descrElement = document.createElement('p'); - descrElement.innerHTML = renderMarkdown(shortDescr); + descrElement.innerHTML = shortDescrHTML; textContainer.appendChild(descrElement); } diff --git a/js/simplex-lib.js b/js/simplex-lib.js index e8f11c8efb..c97d2336e8 100644 --- a/js/simplex-lib.js +++ b/js/simplex-lib.js @@ -65,7 +65,157 @@ function targetBlank(uri) { return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } -function renderMarkdown(fts) { +const MAX_LIST_DEPTH = 7; + +const MAX_TABLE_CELLS = 1000; + +const COPY_BUTTON = + ''; + +// navigator.clipboard is undefined outside secure contexts; fall back to execCommand +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function(resolve, reject) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + ok ? resolve() : reject(new Error('copy failed')); + }); +} + +function handleCodeClick(e) { + const card = e.target.closest('.md-code'); + if (!card) return; + const button = e.target.closest('.md-copy'); + if (!button) { + card.classList.toggle('tapped'); + return; + } + const pre = card.querySelector('pre'); + if (!pre) return; + copyToClipboard(pre.innerText).then(() => { + button.classList.add('copied'); + setTimeout(() => button.classList.remove('copied'), 1500); + }).catch(() => {}); +} + +function validBlocks(fts, blocks) { + let pos = 0; + const range = (r) => { + const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length; + if (valid) pos = r.to; + return valid; + }; + const entries = (items, depth) => + Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1)); + const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range)); + return Array.isArray(blocks) && blocks.every((b) => { + switch (b && b.type) { + case 'paragraph': + case 'heading': + case 'codeBlock': + case 'quote': + return range(b.content); + case 'list': + return entries(b.items, 1); + case 'table': + return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows); + case 'rule': + return range(b.mark); + default: + return false; + } + }); +} + +function renderBlocks(fts, blocks) { + let html = ''; + let pos = 0; + const segments = (r) => renderSegments(fts.slice(r.from, r.to), true); + const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak'; + const gap = (to) => { + if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '
'; + pos = Math.max(pos, to); + }; + const block = (r, open, close) => { + gap(r.from); + html += open + segments(r) + close; + pos = r.to; + }; + const entry = (e, level) => { + html += `
${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`; + pos = e.content.to; + e.items.forEach((item) => entry(item, level + 1)); + }; + const table = (b) => { + const rows = [b.header].concat(b.rows); + const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0; + const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left'); + const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}`; + const row = (cells, tag) => + '' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + ''; + gap(b.header.length > 0 ? b.header[0].from : pos); + html += '
' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
'; + const last = rows[rows.length - 1]; + pos = last.length > 0 ? last[last.length - 1].to : pos; + }; + for (const b of blocks) { + switch (b.type) { + case 'paragraph': + block(b.content, '
', '
'); + break; + case 'heading': + block(b.content, `
`, '
'); + break; + case 'codeBlock': + block(b.content, '
', '
' + COPY_BUTTON + '
'); + break; + case 'quote': + block(b.content, '
', '
'); + break; + case 'list': + if (b.items.length > 0) gap(b.items[0].content.from); + b.items.forEach((item) => entry(item, 0)); + break; + case 'table': + table(b); + break; + case 'rule': + block(b.mark, '
', ''); + break; + } + } + return html; +} + +function markupHtml(token) { + switch (token && token.type) { + case 'softBreak': + return ' '; + case 'lineBreak': + case 'hardBreak': + return '
'; + default: + return ''; + } +} + +function renderMarkdown(fts, blocks) { + return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false); +} + +function renderSegments(fts, withBlocks) { let html = ''; for (const ft of fts) { const { format, text } = ft; @@ -76,7 +226,7 @@ function renderMarkdown(fts) { try { switch (format.type) { case 'bold': - html += `${escapeHtml(text)}`; + html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`; break; case 'italic': html += `${escapeHtml(text)}`; @@ -124,6 +274,11 @@ function renderMarkdown(fts) { case 'phone': html += `${escapeHtml(text)}`; break; + case 'escape': + break; + case 'markup': + html += withBlocks ? markupHtml(format.token) : escapeHtml(text); + break; case 'unknown': html += escapeHtml(text); break;