mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-24 23:33:05 +00:00
deploy: 0563460204
This commit is contained in:
1 parent
63d2fb9871
commit
0b51e06ccb
5 files changed
+580
-31
No files matched your search
@@ -1254,6 +1254,10 @@ video {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.border-separate {
|
||||
border-collapse: separate;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+206
-23
@@ -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 =
|
||||
'<button type="button" class="md-copy" aria-label="Copy">' +
|
||||
'<svg class="md-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2.5"></rect><path d="M15 9V6.5A2.5 2.5 0 0 0 12.5 4h-6A2.5 2.5 0 0 0 4 6.5v6A2.5 2.5 0 0 0 6.5 15H9"></path></svg>' +
|
||||
'<svg class="md-icon-done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"></path></svg>' +
|
||||
'</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 += '<div class="md-gap"></div>';
|
||||
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 += `<div class="md-entry" style="padding-left: ${1.25 * Math.min(level, 6)}em;"><span class="md-mark">${escapeHtml(String(e.number ?? '•'))}</span><span class="md-text">${segments(e.content)}</span></div>`;
|
||||
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) : ''}</${tag}>`;
|
||||
const row = (cells, tag) =>
|
||||
'<tr>' + 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('') + '</tr>';
|
||||
gap(b.header.length > 0 ? b.header[0].from : pos);
|
||||
html += '<div class="md-table"><div class="md-frame"><table>' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '</table></div></div>';
|
||||
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, '<div>', '</div>');
|
||||
break;
|
||||
case 'heading':
|
||||
block(b.content, `<div class="md-h${b.level === 1 || b.level === 2 || b.level === 3 ? b.level : 4}">`, '</div>');
|
||||
break;
|
||||
case 'codeBlock':
|
||||
block(b.content, '<div class="md-code"><pre>', '</pre>' + COPY_BUTTON + '</div>');
|
||||
break;
|
||||
case 'quote':
|
||||
block(b.content, '<div class="md-quote"><span class="md-bar"></span><div class="md-text">', '</div></div>');
|
||||
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, '<hr class="md-rule">', '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function markupHtml(token) {
|
||||
switch (token && token.type) {
|
||||
case 'softBreak':
|
||||
return ' ';
|
||||
case 'lineBreak':
|
||||
case 'hardBreak':
|
||||
return '<br>';
|
||||
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 += `<strong>${escapeHtml(text)}</strong>`;
|
||||
html += withBlocks && format.singleStar ? `<em>${escapeHtml(text)}</em>` : `<strong>${escapeHtml(text)}</strong>`;
|
||||
break;
|
||||
case 'italic':
|
||||
html += `<em>${escapeHtml(text)}</em>`;
|
||||
@@ -126,6 +276,11 @@ function renderMarkdown(fts) {
|
||||
case 'phone':
|
||||
html += `<a href="tel:${escapeAttr(text)}">${escapeHtml(text)}</a>`;
|
||||
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;
|
||||
}
|
||||
|
||||
+162
-6
@@ -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 =
|
||||
'<button type="button" class="md-copy" aria-label="Copy">' +
|
||||
'<svg class="md-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2.5"></rect><path d="M15 9V6.5A2.5 2.5 0 0 0 12.5 4h-6A2.5 2.5 0 0 0 4 6.5v6A2.5 2.5 0 0 0 6.5 15H9"></path></svg>' +
|
||||
'<svg class="md-icon-done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"></path></svg>' +
|
||||
'</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 += '<div class="md-gap"></div>';
|
||||
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 += `<div class="md-entry" style="padding-left: ${1.25 * Math.min(level, 6)}em;"><span class="md-mark">${escapeHtml(String(e.number ?? '•'))}</span><span class="md-text">${segments(e.content)}</span></div>`;
|
||||
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) : ''}</${tag}>`;
|
||||
const row = (cells, tag) =>
|
||||
'<tr>' + 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('') + '</tr>';
|
||||
gap(b.header.length > 0 ? b.header[0].from : pos);
|
||||
html += '<div class="md-table"><div class="md-frame"><table>' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '</table></div></div>';
|
||||
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, '<div>', '</div>');
|
||||
break;
|
||||
case 'heading':
|
||||
block(b.content, `<div class="md-h${b.level === 1 || b.level === 2 || b.level === 3 ? b.level : 4}">`, '</div>');
|
||||
break;
|
||||
case 'codeBlock':
|
||||
block(b.content, '<div class="md-code"><pre>', '</pre>' + COPY_BUTTON + '</div>');
|
||||
break;
|
||||
case 'quote':
|
||||
block(b.content, '<div class="md-quote"><span class="md-bar"></span><div class="md-text">', '</div></div>');
|
||||
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, '<hr class="md-rule">', '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function markupHtml(token) {
|
||||
switch (token && token.type) {
|
||||
case 'softBreak':
|
||||
return ' ';
|
||||
case 'lineBreak':
|
||||
case 'hardBreak':
|
||||
return '<br>';
|
||||
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 += `<strong>${escapeHtml(text)}</strong>`;
|
||||
html += withBlocks && format.singleStar ? `<em>${escapeHtml(text)}</em>` : `<strong>${escapeHtml(text)}</strong>`;
|
||||
break;
|
||||
case 'italic':
|
||||
html += `<em>${escapeHtml(text)}</em>`;
|
||||
@@ -131,6 +281,11 @@ function renderMarkdown(fts) {
|
||||
case 'phone':
|
||||
html += `<a href="tel:${escapeAttr(text)}">${escapeHtml(text)}</a>`;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+157
-2
@@ -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 =
|
||||
'<button type="button" class="md-copy" aria-label="Copy">' +
|
||||
'<svg class="md-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2.5"></rect><path d="M15 9V6.5A2.5 2.5 0 0 0 12.5 4h-6A2.5 2.5 0 0 0 4 6.5v6A2.5 2.5 0 0 0 6.5 15H9"></path></svg>' +
|
||||
'<svg class="md-icon-done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"></path></svg>' +
|
||||
'</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 += '<div class="md-gap"></div>';
|
||||
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 += `<div class="md-entry" style="padding-left: ${1.25 * Math.min(level, 6)}em;"><span class="md-mark">${escapeHtml(String(e.number ?? '•'))}</span><span class="md-text">${segments(e.content)}</span></div>`;
|
||||
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) : ''}</${tag}>`;
|
||||
const row = (cells, tag) =>
|
||||
'<tr>' + 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('') + '</tr>';
|
||||
gap(b.header.length > 0 ? b.header[0].from : pos);
|
||||
html += '<div class="md-table"><div class="md-frame"><table>' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '</table></div></div>';
|
||||
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, '<div>', '</div>');
|
||||
break;
|
||||
case 'heading':
|
||||
block(b.content, `<div class="md-h${b.level === 1 || b.level === 2 || b.level === 3 ? b.level : 4}">`, '</div>');
|
||||
break;
|
||||
case 'codeBlock':
|
||||
block(b.content, '<div class="md-code"><pre>', '</pre>' + COPY_BUTTON + '</div>');
|
||||
break;
|
||||
case 'quote':
|
||||
block(b.content, '<div class="md-quote"><span class="md-bar"></span><div class="md-text">', '</div></div>');
|
||||
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, '<hr class="md-rule">', '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function markupHtml(token) {
|
||||
switch (token && token.type) {
|
||||
case 'softBreak':
|
||||
return ' ';
|
||||
case 'lineBreak':
|
||||
case 'hardBreak':
|
||||
return '<br>';
|
||||
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 += `<strong>${escapeHtml(text)}</strong>`;
|
||||
html += withBlocks && format.singleStar ? `<em>${escapeHtml(text)}</em>` : `<strong>${escapeHtml(text)}</strong>`;
|
||||
break;
|
||||
case 'italic':
|
||||
html += `<em>${escapeHtml(text)}</em>`;
|
||||
@@ -124,6 +274,11 @@ function renderMarkdown(fts) {
|
||||
case 'phone':
|
||||
html += `<a href="tel:${escapeAttr(text)}">${escapeHtml(text)}</a>`;
|
||||
break;
|
||||
case 'escape':
|
||||
break;
|
||||
case 'markup':
|
||||
html += withBlocks ? markupHtml(format.token) : escapeHtml(text);
|
||||
break;
|
||||
case 'unknown':
|
||||
html += escapeHtml(text);
|
||||
break;
|
||||
|
||||
Reference in new issue
Block a user