Feat v0.6.5 renderer pipeline (#40)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #40.
This commit is contained in:
@@ -258,7 +258,8 @@
|
||||
<${Button} size="sm" onClick=${saveEdit}>Save<//>
|
||||
</div>
|
||||
</div>
|
||||
` : html`
|
||||
` : msg.content_type === 'markdown' && sw?.markdown?.ready ? html`
|
||||
<div class="chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
|
||||
<div class="chat-msg__content">${msg.content}</div>`}
|
||||
<div class="chat-msg__meta">
|
||||
<span class="chat-msg__time">${timeAgo(msg.created_at)}</span>
|
||||
|
||||
@@ -3,136 +3,20 @@
|
||||
// ==========================================
|
||||
// Renders ```csv and ```tsv code blocks as sortable HTML tables.
|
||||
// No external dependencies.
|
||||
//
|
||||
// Registers with sw.renderers via the sw:ready event.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'csv-table',
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
|
||||
ctx.renderers.register('csv-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'csv' || l === 'tsv';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
|
||||
const rows = self._parseCSV(code.trim(), delimiter);
|
||||
|
||||
if (rows.length === 0) {
|
||||
container.innerHTML = '<div class="csv-empty">No data</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = rows[0];
|
||||
const data = rows.slice(1);
|
||||
const tableId = 'csv-' + Math.random().toString(36).slice(2, 9);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="csv-table-block ext-rendered" data-csv-id="${tableId}">
|
||||
<div class="csv-toolbar">
|
||||
<span class="csv-info">${data.length} row${data.length !== 1 ? 's' : ''} × ${headers.length} col${headers.length !== 1 ? 's' : ''}</span>
|
||||
<button class="csv-copy-btn" title="Copy as CSV">📋 Copy</button>
|
||||
</div>
|
||||
<div class="csv-table-scroll">
|
||||
<table class="csv-table">
|
||||
<thead>
|
||||
<tr>${headers.map((h, i) => `<th data-col="${i}" title="Click to sort">${self._escapeHtml(h)}<span class="csv-sort-icon"></span></th>`).join('')}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.map(row => `<tr>${row.map(cell => `<td>${self._escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="csv-source">
|
||||
<summary>📄 View raw</summary>
|
||||
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach sort handlers + copy
|
||||
self._attachHandlers(container, tableId, code.trim());
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_attachHandlers(container, tableId, rawCSV) {
|
||||
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
|
||||
if (!block) return;
|
||||
|
||||
// ── Column sorting ──
|
||||
const ths = block.querySelectorAll('th[data-col]');
|
||||
let sortCol = -1;
|
||||
let sortAsc = true;
|
||||
|
||||
ths.forEach(th => {
|
||||
th.style.cursor = 'pointer';
|
||||
th.addEventListener('click', () => {
|
||||
const col = parseInt(th.dataset.col, 10);
|
||||
if (sortCol === col) {
|
||||
sortAsc = !sortAsc;
|
||||
} else {
|
||||
sortCol = col;
|
||||
sortAsc = true;
|
||||
}
|
||||
|
||||
const tbody = block.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const cellA = (a.children[col]?.textContent || '').trim();
|
||||
const cellB = (b.children[col]?.textContent || '').trim();
|
||||
const numA = parseFloat(cellA);
|
||||
const numB = parseFloat(cellB);
|
||||
|
||||
// Numeric sort if both are numbers
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
return sortAsc ? numA - numB : numB - numA;
|
||||
}
|
||||
// String sort
|
||||
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
|
||||
// Update sort icons
|
||||
ths.forEach(h => {
|
||||
const icon = h.querySelector('.csv-sort-icon');
|
||||
if (icon) {
|
||||
const hCol = parseInt(h.dataset.col, 10);
|
||||
icon.textContent = hCol === sortCol ? (sortAsc ? ' ▲' : ' ▼') : '';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Copy button ──
|
||||
const copyBtn = block.querySelector('.csv-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(rawCSV).then(() => {
|
||||
copyBtn.textContent = '✓ Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
||||
}).catch(() => {
|
||||
copyBtn.textContent = '✗ Failed';
|
||||
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Simple CSV parser that handles quoted fields.
|
||||
*/
|
||||
_parseCSV(text, delimiter) {
|
||||
function _parseCSV(text, delimiter) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
@@ -141,64 +25,98 @@ Extensions.register({
|
||||
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
// Peek ahead: escaped quote or end of quoted field
|
||||
if (i + 1 < text.length && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
field += '"'; i += 2;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
i++;
|
||||
inQuotes = false; i++;
|
||||
}
|
||||
} else {
|
||||
field += ch;
|
||||
i++;
|
||||
field += ch; i++;
|
||||
}
|
||||
} else {
|
||||
if (ch === '"' && field === '') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
inQuotes = true; i++;
|
||||
} else if (ch === delimiter) {
|
||||
row.push(field.trim());
|
||||
field = '';
|
||||
i++;
|
||||
row.push(field.trim()); field = ''; i++;
|
||||
} else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
|
||||
row.push(field.trim());
|
||||
if (row.some(cell => cell !== '')) rows.push(row);
|
||||
row = [];
|
||||
field = '';
|
||||
row = []; field = '';
|
||||
i += (ch === '\r') ? 2 : 1;
|
||||
} else {
|
||||
field += ch;
|
||||
i++;
|
||||
field += ch; i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last field/row
|
||||
row.push(field.trim());
|
||||
if (row.some(cell => cell !== '')) rows.push(row);
|
||||
|
||||
// Normalize: pad short rows to header length
|
||||
if (rows.length > 0) {
|
||||
const maxCols = Math.max(...rows.map(r => r.length));
|
||||
rows.forEach(r => {
|
||||
while (r.length < maxCols) r.push('');
|
||||
rows.forEach(r => { while (r.length < maxCols) r.push(''); });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function _attachHandlers(container, tableId, rawCSV) {
|
||||
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
|
||||
if (!block) return;
|
||||
|
||||
const ths = block.querySelectorAll('th[data-col]');
|
||||
let sortCol = -1;
|
||||
let sortAsc = true;
|
||||
|
||||
ths.forEach(th => {
|
||||
th.style.cursor = 'pointer';
|
||||
th.addEventListener('click', () => {
|
||||
const col = parseInt(th.dataset.col, 10);
|
||||
if (sortCol === col) { sortAsc = !sortAsc; }
|
||||
else { sortCol = col; sortAsc = true; }
|
||||
|
||||
const tbody = block.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
rows.sort((a, b) => {
|
||||
const cellA = (a.children[col]?.textContent || '').trim();
|
||||
const cellB = (b.children[col]?.textContent || '').trim();
|
||||
const numA = parseFloat(cellA);
|
||||
const numB = parseFloat(cellB);
|
||||
if (!isNaN(numA) && !isNaN(numB))
|
||||
return sortAsc ? numA - numB : numB - numA;
|
||||
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
|
||||
ths.forEach(h => {
|
||||
const icon = h.querySelector('.csv-sort-icon');
|
||||
if (icon) {
|
||||
const hCol = parseInt(h.dataset.col, 10);
|
||||
icon.textContent = hCol === sortCol ? (sortAsc ? ' \u25b2' : ' \u25bc') : '';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const copyBtn = block.querySelector('.csv-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(rawCSV).then(() => {
|
||||
copyBtn.textContent = '\u2713 Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
|
||||
}).catch(() => {
|
||||
copyBtn.textContent = '\u2717 Failed';
|
||||
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
},
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
_injectStyles() {
|
||||
function _injectStyles() {
|
||||
if (document.getElementById('ext-style-csv-table')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-csv-table';
|
||||
@@ -246,9 +164,66 @@ Extensions.register({
|
||||
}
|
||||
.csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-csv-table')?.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Registration ────────────────────────
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
_injectStyles();
|
||||
|
||||
sw.renderers.register('csv-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'csv' || l === 'tsv';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
|
||||
const rows = _parseCSV(code.trim(), delimiter);
|
||||
|
||||
if (rows.length === 0) {
|
||||
container.innerHTML = '<div class="csv-empty">No data</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = rows[0];
|
||||
const data = rows.slice(1);
|
||||
const tableId = 'csv-' + Math.random().toString(36).slice(2, 9);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="csv-table-block ext-rendered" data-csv-id="${tableId}">
|
||||
<div class="csv-toolbar">
|
||||
<span class="csv-info">${data.length} row${data.length !== 1 ? 's' : ''} \u00d7 ${headers.length} col${headers.length !== 1 ? 's' : ''}</span>
|
||||
<button class="csv-copy-btn" title="Copy as CSV">\ud83d\udccb Copy</button>
|
||||
</div>
|
||||
<div class="csv-table-scroll">
|
||||
<table class="csv-table">
|
||||
<thead>
|
||||
<tr>${headers.map((h, i) => `<th data-col="${i}" title="Click to sort">${_escapeHtml(h)}<span class="csv-sort-icon"></span></th>`).join('')}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.map(row => `<tr>${row.map(cell => `<td>${_escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="csv-source">
|
||||
<summary>\ud83d\udcc4 View raw</summary>
|
||||
<pre><code>${_escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
_attachHandlers(container, tableId, code.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -4,106 +4,22 @@
|
||||
// Renders ```diff code blocks with syntax-highlighted
|
||||
// additions, deletions, hunk headers, and context lines.
|
||||
// No external dependencies.
|
||||
//
|
||||
// Registers with sw.renderers via the sw:ready event.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'diff-viewer',
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
|
||||
ctx.renderers.register('diff-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
pattern: 'diff',
|
||||
render(lang, code, container) {
|
||||
const lines = code.split('\n');
|
||||
let stats = { added: 0, removed: 0 };
|
||||
let currentFile = '';
|
||||
|
||||
const rendered = lines.map(line => {
|
||||
const escaped = self._escapeHtml(line);
|
||||
|
||||
// File headers
|
||||
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
if (line.startsWith('+++ ')) {
|
||||
currentFile = line.slice(4).trim();
|
||||
}
|
||||
return `<div class="diff-line diff-file-header">${escaped}</div>`;
|
||||
}
|
||||
|
||||
// Hunk headers
|
||||
if (line.startsWith('@@')) {
|
||||
const hunkMatch = line.match(/^@@\s*-(\d+)(?:,\d+)?\s*\+(\d+)(?:,\d+)?\s*@@\s*(.*)/);
|
||||
const context = hunkMatch ? hunkMatch[3] : '';
|
||||
return `<div class="diff-line diff-hunk">${escaped}</div>`;
|
||||
}
|
||||
|
||||
// Additions
|
||||
if (line.startsWith('+')) {
|
||||
stats.added++;
|
||||
return `<div class="diff-line diff-add"><span class="diff-indicator">+</span>${self._escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
|
||||
// Deletions
|
||||
if (line.startsWith('-')) {
|
||||
stats.removed++;
|
||||
return `<div class="diff-line diff-del"><span class="diff-indicator">-</span>${self._escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
|
||||
// Context (lines starting with space or no prefix)
|
||||
if (line.startsWith(' ')) {
|
||||
return `<div class="diff-line diff-ctx"><span class="diff-indicator"> </span>${self._escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
|
||||
// Other (commit info, etc)
|
||||
return `<div class="diff-line diff-meta">${escaped}</div>`;
|
||||
}).join('');
|
||||
|
||||
const fileLabel = currentFile ? `<span class="diff-filename" title="${self._escapeHtml(currentFile)}">${self._escapeHtml(currentFile)}</span>` : '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="diff-block ext-rendered">
|
||||
<div class="diff-toolbar">
|
||||
${fileLabel}
|
||||
<span class="diff-stats">
|
||||
<span class="diff-stat-add">+${stats.added}</span>
|
||||
<span class="diff-stat-del">-${stats.removed}</span>
|
||||
</span>
|
||||
<button class="diff-copy-btn" title="Copy diff">📋 Copy</button>
|
||||
</div>
|
||||
<div class="diff-content">${rendered}</div>
|
||||
<details class="diff-source-toggle">
|
||||
<summary>📝 View raw</summary>
|
||||
<pre><code>${self._escapeHtml(code)}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Copy handler
|
||||
const copyBtn = container.querySelector('.diff-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
copyBtn.textContent = '✓ Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
}
|
||||
|
||||
_injectStyles() {
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
function _injectStyles() {
|
||||
if (document.getElementById('ext-style-diff-viewer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-diff-viewer';
|
||||
@@ -158,9 +74,86 @@ Extensions.register({
|
||||
max-height: 200px; overflow: auto;
|
||||
}`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-diff-viewer')?.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Registration ────────────────────────
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
_injectStyles();
|
||||
|
||||
sw.renderers.register('diff-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
pattern: 'diff',
|
||||
render(lang, code, container) {
|
||||
const lines = code.split('\n');
|
||||
let stats = { added: 0, removed: 0 };
|
||||
let currentFile = '';
|
||||
|
||||
const rendered = lines.map(line => {
|
||||
const escaped = _escapeHtml(line);
|
||||
|
||||
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
if (line.startsWith('+++ ')) currentFile = line.slice(4).trim();
|
||||
return `<div class="diff-line diff-file-header">${escaped}</div>`;
|
||||
}
|
||||
if (line.startsWith('@@')) {
|
||||
return `<div class="diff-line diff-hunk">${escaped}</div>`;
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
stats.added++;
|
||||
return `<div class="diff-line diff-add"><span class="diff-indicator">+</span>${_escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
if (line.startsWith('-')) {
|
||||
stats.removed++;
|
||||
return `<div class="diff-line diff-del"><span class="diff-indicator">-</span>${_escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
if (line.startsWith(' ')) {
|
||||
return `<div class="diff-line diff-ctx"><span class="diff-indicator"> </span>${_escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
return `<div class="diff-line diff-meta">${escaped}</div>`;
|
||||
}).join('');
|
||||
|
||||
const fileLabel = currentFile
|
||||
? `<span class="diff-filename" title="${_escapeHtml(currentFile)}">${_escapeHtml(currentFile)}</span>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="diff-block ext-rendered">
|
||||
<div class="diff-toolbar">
|
||||
${fileLabel}
|
||||
<span class="diff-stats">
|
||||
<span class="diff-stat-add">+${stats.added}</span>
|
||||
<span class="diff-stat-del">-${stats.removed}</span>
|
||||
</span>
|
||||
<button class="diff-copy-btn" title="Copy diff">\ud83d\udccb Copy</button>
|
||||
</div>
|
||||
<div class="diff-content">${rendered}</div>
|
||||
<details class="diff-source-toggle">
|
||||
<summary>\ud83d\udcdd View raw</summary>
|
||||
<pre><code>${_escapeHtml(code)}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const copyBtn = container.querySelector('.diff-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
copyBtn.textContent = '\u2713 Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -65,9 +65,9 @@
|
||||
T.assert(d.version === '0.31.1', 'version mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/surfaces (dashboard in list)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/packages (dashboard in list)', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
T.assertHasKey(d, 'data', '/admin/packages');
|
||||
var found = d.data.some(function (s) { return s.id === 'dashboard'; });
|
||||
T.assert(found, 'dashboard package should appear in surfaces list');
|
||||
});
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
T.assert(d.version === '0.31.0', 'version mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/surfaces (editor in list)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/packages (editor in list)', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
T.assertHasKey(d, 'data', '/admin/packages');
|
||||
var found = d.data.some(function (s) { return s.id === 'editor'; });
|
||||
T.assert(found, 'editor package should appear in surfaces list');
|
||||
});
|
||||
@@ -78,7 +78,7 @@
|
||||
await T.test('crud', 'editorPackage', 'edpkg: core /editor removed (404)', async function () {
|
||||
// The old /editor route should no longer exist — it was removed in CS1.
|
||||
// We test by checking the surfaces list for a core editor entry.
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
var surfaces = d.data || [];
|
||||
var coreEditor = surfaces.find(function (s) { return s.id === 'editor' && s.source === 'core'; });
|
||||
T.assert(!coreEditor, 'no core editor surface should exist — editor is now a package');
|
||||
@@ -139,7 +139,7 @@
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: editor in extension nav items', async function () {
|
||||
// When installed, editor should appear in the surfaces list as an extension surface
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
var surfaces = d.data || [];
|
||||
var editorSurface = surfaces.find(function (s) { return s.id === 'editor'; });
|
||||
T.assert(editorSurface, 'editor should be in surfaces list');
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
var surfaceId = null;
|
||||
|
||||
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (happy path)', async function () {
|
||||
await T.test('crud', 'surfaces', 'POST /admin/packages/install (happy path)', async function () {
|
||||
surfaceId = 'icd-test-' + Date.now();
|
||||
var manifest = JSON.stringify({
|
||||
id: surfaceId,
|
||||
@@ -28,32 +28,32 @@
|
||||
route: '/s/' + surfaceId
|
||||
});
|
||||
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
||||
await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface');
|
||||
await T.apiUpload('/admin/packages/install', zipBlob, surfaceId + '.surface');
|
||||
|
||||
// Verify it exists
|
||||
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
|
||||
var d = await T.apiGet('/admin/packages/' + surfaceId);
|
||||
T.assert(d.id === surfaceId, 'installed surface id mismatch');
|
||||
T.assert(d.title === 'ICD Test Surface', 'title mismatch');
|
||||
T.registerCleanup(async function () {
|
||||
if (surfaceId) {
|
||||
var token = await T.getAuthToken();
|
||||
return T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId);
|
||||
return T.authFetch(token, 'DELETE', '/admin/packages/' + surfaceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (surfaceId) {
|
||||
await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (read)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
|
||||
await T.test('crud', 'surfaces', 'GET /admin/packages/:id (read)', async function () {
|
||||
var d = await T.apiGet('/admin/packages/' + surfaceId);
|
||||
T.assertShape(d, T.S.surfaceAdmin, 'surface detail');
|
||||
T.assert(d.id === surfaceId, 'id mismatch');
|
||||
T.assert(d.source === 'extension', 'source should be extension');
|
||||
T.assert(d.enabled === true, 'newly installed surface should be enabled');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'GET /admin/surfaces (list includes new)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
await T.test('crud', 'surfaces', 'GET /admin/packages (list includes new)', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
T.assertHasKey(d, 'data', '/admin/packages');
|
||||
var found = d.data.some(function (s) { return s.id === surfaceId; });
|
||||
T.assert(found, 'installed surface should appear in admin list');
|
||||
});
|
||||
@@ -70,9 +70,9 @@
|
||||
T.assert(entry.enabled === undefined, 'nav entry should NOT have enabled');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/disable', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/:id/disable', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/surfaces/' + surfaceId + '/disable');
|
||||
'/admin/packages/' + surfaceId + '/disable');
|
||||
T.assertStatus(d, 200, 'disable');
|
||||
T.assert(d.enabled === false, 'should report disabled');
|
||||
});
|
||||
@@ -83,16 +83,16 @@
|
||||
T.assert(!found, 'disabled surface should NOT appear in user nav list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'GET /admin/surfaces (disabled still in admin)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
await T.test('crud', 'surfaces', 'GET /admin/packages (disabled still in admin)', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
var entry = d.data.find(function (s) { return s.id === surfaceId; });
|
||||
T.assert(entry, 'disabled surface should still appear in admin list');
|
||||
T.assert(entry.enabled === false, 'should show as disabled in admin list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/enable', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/:id/enable', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/surfaces/' + surfaceId + '/enable');
|
||||
'/admin/packages/' + surfaceId + '/enable');
|
||||
T.assertStatus(d, 200, 'enable');
|
||||
T.assert(d.enabled === true, 'should report enabled');
|
||||
});
|
||||
@@ -103,53 +103,53 @@
|
||||
T.assert(found, 're-enabled surface should appear in user nav list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/chat/disable (reject → 400)', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/chat/disable (reject → 400)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/surfaces/chat/disable');
|
||||
'/admin/packages/chat/disable');
|
||||
T.assert(d._status === 400, 'disabling chat should return 400, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/admin/disable (reject → 400)', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/admin/disable (reject → 400)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/surfaces/admin/disable');
|
||||
'/admin/packages/admin/disable');
|
||||
T.assert(d._status === 400, 'disabling admin should return 400, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/chat (core reject → 400)', async function () {
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/packages/chat (core reject → 400)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'DELETE',
|
||||
'/admin/surfaces/chat');
|
||||
'/admin/packages/chat');
|
||||
T.assert(d._status === 400, 'deleting core surface should return 400, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (not found → 404)', async function () {
|
||||
await T.test('crud', 'surfaces', 'GET /admin/packages/:id (not found → 404)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'GET',
|
||||
'/admin/surfaces/nonexistent-surface-id');
|
||||
'/admin/packages/nonexistent-surface-id');
|
||||
T.assert(d._status === 404, 'nonexistent should return 404, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/nonexistent/disable (404)', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/nonexistent/disable (404)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/surfaces/nonexistent-surface-id/disable');
|
||||
'/admin/packages/nonexistent-surface-id/disable');
|
||||
T.assert(d._status === 404, 'disable nonexistent should return 404, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (missing manifest → 400)', async function () {
|
||||
await T.test('crud', 'surfaces', 'POST /admin/packages/install (missing manifest → 400)', async function () {
|
||||
var zipBlob = buildTestZip({ 'readme.txt': 'no manifest here' });
|
||||
var d;
|
||||
try {
|
||||
d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'bad.surface');
|
||||
d = await T.apiUpload('/admin/packages/install', zipBlob, 'bad.surface');
|
||||
T.assert(false, 'should have thrown');
|
||||
} catch (e) {
|
||||
T.assert(e.message.indexOf('400') !== -1, 'missing manifest should 400: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (core conflict → 409)', async function () {
|
||||
await T.test('crud', 'surfaces', 'POST /admin/packages/install (core conflict → 409)', async function () {
|
||||
var manifest = JSON.stringify({ id: 'chat', title: 'Evil Chat', route: '/s/chat' });
|
||||
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
||||
var d;
|
||||
try {
|
||||
d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'chat.surface');
|
||||
d = await T.apiUpload('/admin/packages/install', zipBlob, 'chat.surface');
|
||||
// If no throw, check status manually
|
||||
T.assert(false, 'overwriting core surface should have thrown');
|
||||
} catch (e) {
|
||||
@@ -157,10 +157,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (reseed preserves enabled)', async function () {
|
||||
await T.test('crud', 'surfaces', 'POST /admin/packages/install (reseed preserves enabled)', async function () {
|
||||
// Surface was re-enabled above. Disable it, then re-install.
|
||||
await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/surfaces/' + surfaceId + '/disable');
|
||||
'/admin/packages/' + surfaceId + '/disable');
|
||||
|
||||
// Re-install same ID
|
||||
var manifest = JSON.stringify({
|
||||
@@ -169,33 +169,33 @@
|
||||
route: '/s/' + surfaceId
|
||||
});
|
||||
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
||||
await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface');
|
||||
await T.apiUpload('/admin/packages/install', zipBlob, surfaceId + '.surface');
|
||||
|
||||
// Verify title updated but enabled preserved as false
|
||||
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
|
||||
var d = await T.apiGet('/admin/packages/' + surfaceId);
|
||||
T.assert(d.title === 'ICD Test Surface v2', 'title should be updated after reseed');
|
||||
T.assert(d.enabled === false, 'enabled should be preserved as false after reseed');
|
||||
|
||||
// Re-enable for cleanup
|
||||
await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/surfaces/' + surfaceId + '/enable');
|
||||
'/admin/packages/' + surfaceId + '/enable');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/:id', async function () {
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/packages/:id', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId);
|
||||
var d = await T.authFetch(token, 'DELETE', '/admin/packages/' + surfaceId);
|
||||
T.assertStatus(d, 200, 'delete');
|
||||
T.assert(d.deleted === true, 'should report deleted');
|
||||
|
||||
// Verify gone
|
||||
var check = await T.authFetch(token, 'GET', '/admin/surfaces/' + surfaceId);
|
||||
var check = await T.authFetch(token, 'GET', '/admin/packages/' + surfaceId);
|
||||
T.assert(check._status === 404, 'deleted surface should 404 on get');
|
||||
surfaceId = null;
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/nonexistent (404)', async function () {
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/packages/nonexistent (404)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'DELETE',
|
||||
'/admin/surfaces/nonexistent-surface-id');
|
||||
'/admin/packages/nonexistent-surface-id');
|
||||
T.assert(d._status === 404, 'delete nonexistent should return 404, got ' + d._status);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
};
|
||||
|
||||
// ─── API Wrappers ───────────────────────────────────────────
|
||||
// v0.37.14: raw fetch — old API._* globals removed in scorched earth.
|
||||
// Raw fetch — kernel provides no global API wrappers; extensions use fetch directly.
|
||||
|
||||
async function _fetchJSON(method, path, body) {
|
||||
var token = await T.getAuthToken();
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
var adminGets = [
|
||||
'/admin/stats', '/admin/users', '/admin/settings',
|
||||
'/admin/configs', '/admin/models', '/admin/personas',
|
||||
'/admin/teams', '/admin/groups', '/admin/surfaces',
|
||||
'/admin/teams', '/admin/groups', '/admin/packages',
|
||||
'/admin/providers/health', '/admin/routing/policies',
|
||||
'/admin/permissions', '/admin/roles', '/admin/vault/status',
|
||||
'/admin/storage/status', '/admin/audit', '/admin/pricing',
|
||||
@@ -72,8 +72,8 @@
|
||||
'expected 403/401, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'admin-deny', 'user → POST /admin/surfaces/install (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/surfaces/install', {});
|
||||
await T.test('authz', 'admin-deny', 'user → POST /admin/packages/install (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/packages/install', {});
|
||||
T.assert(d._status === 403 || d._status === 401 || d._status === 400,
|
||||
'expected 403/401/400, got ' + d._status);
|
||||
});
|
||||
|
||||
@@ -543,7 +543,7 @@
|
||||
await T.test('security', 'input-validation', '[P0] path traversal in surface archive', async function () {
|
||||
var blob = new Blob([JSON.stringify({ id: '../../../etc/evil', title: 'Path Traversal Test' })], { type: 'application/json' });
|
||||
try {
|
||||
var d = await T.apiUpload('/admin/surfaces/install', blob, 'evil.surface');
|
||||
var d = await T.apiUpload('/admin/packages/install', blob, 'evil.surface');
|
||||
T.assert(d._status === 400 || d._status === 409,
|
||||
'path traversal surface accepted! got ' + d._status);
|
||||
} catch (e) {
|
||||
@@ -644,8 +644,8 @@
|
||||
assertDenied(d._status, 'CRITICAL: team admin can delete users');
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/surfaces/install', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/surfaces/install', {});
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/packages/install', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/packages/install', {});
|
||||
if (d._status === 400) return; // bad upload format, not a security issue
|
||||
assertDenied(d._status, 'team admin can install surfaces');
|
||||
});
|
||||
|
||||
@@ -326,9 +326,9 @@
|
||||
T.assertHasKey(d, 'data', '/admin/groups');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/surfaces', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
await T.test('smoke', 'admin', 'GET /admin/packages', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
T.assertHasKey(d, 'data', '/admin/packages');
|
||||
T.assert(Array.isArray(d.data), 'surfaces should be array');
|
||||
if (d.data.length > 0) {
|
||||
T.assertShape(d.data[0], T.S.surfaceAdmin, 'surfaces[0]');
|
||||
|
||||
@@ -3,177 +3,141 @@
|
||||
// ==========================================
|
||||
// Renders ```latex / ```math code blocks and inline $...$ / $$...$$ syntax.
|
||||
// Loads KaTeX dynamically on first use.
|
||||
//
|
||||
// Registers with sw.renderers via the sw:ready event.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'katex-renderer',
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
_katexReady: false,
|
||||
_katexLoading: null,
|
||||
let _katexReady = false;
|
||||
let _katexLoading = null;
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
// ── KaTeX Library Loading ──────────────
|
||||
|
||||
// ── Block renderer: match ```latex or ```math ──
|
||||
ctx.renderers.register('katex-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="katex-block ext-rendered" data-katex-id="${id}">
|
||||
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
|
||||
<div class="katex-loading">
|
||||
<span class="katex-spinner"></span> Rendering math…
|
||||
</div>
|
||||
</div>
|
||||
<details class="katex-source">
|
||||
<summary>𝑓(𝑥) View source</summary>
|
||||
<pre><code class="language-latex">${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
function _loadKaTeX() {
|
||||
if (_katexReady) return Promise.resolve();
|
||||
if (_katexLoading) return _katexLoading;
|
||||
|
||||
_katexLoading = new Promise((resolve, reject) => {
|
||||
if (typeof katex !== 'undefined') {
|
||||
_katexReady = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const base = (window.__BASE__ || '');
|
||||
const localCSS = `${base}/vendor/katex/katex.min.css`;
|
||||
const localJS = `${base}/vendor/katex/katex.min.js`;
|
||||
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
|
||||
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = localCSS;
|
||||
link.onerror = () => { link.href = cdnCSS; };
|
||||
document.head.appendChild(link);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = localJS;
|
||||
script.onload = () => { _katexReady = true; resolve(); };
|
||||
script.onerror = () => {
|
||||
console.warn('[KaTeX] Local vendor not found, trying CDN');
|
||||
const cdn = document.createElement('script');
|
||||
cdn.src = cdnJS;
|
||||
cdn.onload = () => { _katexReady = true; resolve(); };
|
||||
cdn.onerror = () => {
|
||||
console.error('[KaTeX] Failed to load from both local and CDN');
|
||||
reject(new Error('Failed to load KaTeX'));
|
||||
};
|
||||
document.head.appendChild(cdn);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return _katexLoading;
|
||||
}
|
||||
|
||||
// ── Post renderer: render KaTeX blocks + find inline math ──
|
||||
ctx.renderers.register('katex-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
// Render code blocks with data-katex-src
|
||||
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
|
||||
if (blocks.length > 0) {
|
||||
blocks.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
self._renderBlock(el);
|
||||
});
|
||||
}
|
||||
// ── Block Rendering ──────────────────────
|
||||
|
||||
// Process inline math in text content
|
||||
self._processInlineMath(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load KaTeX
|
||||
this._loadKaTeX();
|
||||
},
|
||||
|
||||
async _renderBlock(el) {
|
||||
async function _renderBlock(el) {
|
||||
const code = decodeURIComponent(el.dataset.katexSrc);
|
||||
const displayMode = el.dataset.katexDisplay === 'true';
|
||||
|
||||
try {
|
||||
await this._loadKaTeX();
|
||||
await _loadKaTeX();
|
||||
el.innerHTML = katex.renderToString(code, {
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
strict: false,
|
||||
displayMode, throwOnError: false, trust: false, strict: false,
|
||||
});
|
||||
el.dataset.rendered = 'true';
|
||||
} catch (e) {
|
||||
el.innerHTML = `
|
||||
<div class="katex-error">
|
||||
<strong>Math error:</strong> ${this._escapeHtml(e.message || String(e))}
|
||||
<strong>Math error:</strong> ${_escapeHtml(e.message || String(e))}
|
||||
</div>
|
||||
`;
|
||||
el.dataset.rendered = 'error';
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async _processInlineMath(container) {
|
||||
// Skip if no $ signs present at all
|
||||
// ── Inline Math Processing ───────────────
|
||||
|
||||
async function _processInlineMath(container) {
|
||||
if (!container.textContent || !container.textContent.includes('$')) return;
|
||||
try { await _loadKaTeX(); } catch { return; }
|
||||
|
||||
try {
|
||||
await this._loadKaTeX();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk text nodes, skip code/pre/katex-already-rendered
|
||||
const walker = document.createTreeWalker(
|
||||
container,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
// Skip code, pre, already-rendered katex, and script elements
|
||||
const tag = parent.tagName;
|
||||
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
|
||||
tag === 'TEXTAREA' || tag === 'STYLE') {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
const tag = parent.tagName;
|
||||
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
|
||||
tag === 'TEXTAREA' || tag === 'STYLE') return NodeFilter.FILTER_REJECT;
|
||||
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre'))
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const textNodes = [];
|
||||
let node;
|
||||
while (node = walker.nextNode()) textNodes.push(node);
|
||||
for (const textNode of textNodes) _replaceInlineMath(textNode);
|
||||
}
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
this._replaceInlineMath(textNode);
|
||||
}
|
||||
},
|
||||
|
||||
_replaceInlineMath(textNode) {
|
||||
function _replaceInlineMath(textNode) {
|
||||
const text = textNode.textContent;
|
||||
// Match $$...$$ (display) and $...$ (inline), non-greedy
|
||||
// Avoid matching escaped \$ or empty $$
|
||||
const pattern = /\$\$([^$]+?)\$\$|\$([^$\n]+?)\$/g;
|
||||
let match;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
// Text before the match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
|
||||
const displayMode = !!match[1]; // $$ ... $$
|
||||
const displayMode = !!match[1];
|
||||
const expr = match[1] || match[2];
|
||||
|
||||
try {
|
||||
const html = katex.renderToString(expr.trim(), {
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
strict: false,
|
||||
displayMode, throwOnError: false, trust: false, strict: false,
|
||||
});
|
||||
parts.push({ type: 'katex', value: html, displayMode });
|
||||
} catch {
|
||||
// Leave as-is on error
|
||||
parts.push({ type: 'text', value: match[0] });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (parts.length === 0) return; // No math found
|
||||
if (parts.length === 0) return;
|
||||
if (lastIndex < text.length) parts.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
|
||||
// Remaining text after last match
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
// Replace text node with a span containing the rendered parts
|
||||
const span = document.createElement('span');
|
||||
span.className = 'katex-inline-container';
|
||||
for (const part of parts) {
|
||||
@@ -186,70 +150,12 @@ Extensions.register({
|
||||
span.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
textNode.parentNode.replaceChild(span, textNode);
|
||||
},
|
||||
}
|
||||
|
||||
_loadKaTeX() {
|
||||
if (this._katexReady) return Promise.resolve();
|
||||
if (this._katexLoading) return this._katexLoading;
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
this._katexLoading = new Promise((resolve, reject) => {
|
||||
if (typeof katex !== 'undefined') {
|
||||
this._katexReady = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const base = (window.__BASE__ || '');
|
||||
const localCSS = `${base}/vendor/katex/katex.min.css`;
|
||||
const localJS = `${base}/vendor/katex/katex.min.js`;
|
||||
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
|
||||
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
|
||||
|
||||
// Load CSS first
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = localCSS;
|
||||
link.onerror = () => {
|
||||
link.href = cdnCSS;
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Load JS
|
||||
const script = document.createElement('script');
|
||||
script.src = localJS;
|
||||
script.onload = () => {
|
||||
this._katexReady = true;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.warn('[KaTeX] Local vendor not found, trying CDN');
|
||||
const cdn = document.createElement('script');
|
||||
cdn.src = cdnJS;
|
||||
cdn.onload = () => {
|
||||
this._katexReady = true;
|
||||
resolve();
|
||||
};
|
||||
cdn.onerror = () => {
|
||||
console.error('[KaTeX] Failed to load from both local and CDN');
|
||||
reject(new Error('Failed to load KaTeX'));
|
||||
};
|
||||
document.head.appendChild(cdn);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return this._katexLoading;
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
_injectStyles() {
|
||||
function _injectStyles() {
|
||||
if (document.getElementById('ext-style-katex-renderer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-katex-renderer';
|
||||
@@ -289,9 +195,64 @@ Extensions.register({
|
||||
.katex-inline .katex { font-size: 1.05em; }
|
||||
.katex-display { display: block; text-align: center; margin: 8px 0; }`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-katex-renderer')?.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Registration ────────────────────────
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
_injectStyles();
|
||||
|
||||
// Block renderer: match ```latex, ```math, ```tex, ```katex
|
||||
sw.renderers.register('katex-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="katex-block ext-rendered" data-katex-id="${id}">
|
||||
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
|
||||
<div class="katex-loading">
|
||||
<span class="katex-spinner"></span> Rendering math\u2026
|
||||
</div>
|
||||
</div>
|
||||
<details class="katex-source">
|
||||
<summary>\ud835\udc53(\ud835\udc65) View source</summary>
|
||||
<pre><code class="language-latex">${_escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Post renderer: render KaTeX blocks + find inline math
|
||||
sw.renderers.register('katex-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
|
||||
if (blocks.length > 0) {
|
||||
blocks.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
_renderBlock(el);
|
||||
});
|
||||
}
|
||||
_processInlineMath(container);
|
||||
}
|
||||
});
|
||||
|
||||
_loadKaTeX();
|
||||
}
|
||||
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -2,94 +2,33 @@
|
||||
// Mermaid Diagram Renderer — Browser Extension
|
||||
// ==========================================
|
||||
// Renders ```mermaid code blocks as interactive SVG diagrams.
|
||||
// Features: viewBox-based zoom/pan, context-aware expand (fullscreen
|
||||
// or side-panel pop-out), SVG/PNG export, source copy.
|
||||
// Features: viewBox-based zoom/pan, fullscreen expand,
|
||||
// SVG/PNG export, source copy.
|
||||
// Loads mermaid.js dynamically on first use.
|
||||
//
|
||||
// Uses ctx.ui primitives from the host app for toast notifications,
|
||||
// side-panel preview, theme detection, and mobile awareness.
|
||||
// Registers with sw.renderers via the sw:ready event.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'mermaid-renderer',
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
_mermaidReady: false,
|
||||
_mermaidLoading: null,
|
||||
_ctx: null,
|
||||
let _mermaidReady = false;
|
||||
let _mermaidLoading = null;
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
this._ctx = ctx;
|
||||
function _isDark() {
|
||||
return document.documentElement.dataset.theme === 'dark' ||
|
||||
document.documentElement.classList.contains('dark');
|
||||
}
|
||||
|
||||
this._injectStyles();
|
||||
|
||||
// ── Block renderer: match ```mermaid ──
|
||||
ctx.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: 'mermaid',
|
||||
priority: 10,
|
||||
render(lang, code, container) {
|
||||
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="mermaid-block" data-mermaid-id="${id}">
|
||||
<div class="mermaid-toolbar">
|
||||
<span class="mermaid-title">\u{1f4ca} Diagram</span>
|
||||
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
|
||||
<div class="mermaid-toolbar-btns">
|
||||
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
|
||||
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">\u2212</button>
|
||||
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</button>
|
||||
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Expand">\u26f6</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
|
||||
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mermaid-viewport" data-viewport="${id}">
|
||||
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
|
||||
<div class="mermaid-loading">
|
||||
<span class="mermaid-spinner"></span> Rendering diagram\u2026
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="mermaid-source">
|
||||
<summary>
|
||||
<span>\u{1f4cb} View source</span>
|
||||
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
|
||||
</summary>
|
||||
<pre><code class="language-mermaid" data-source="${id}">${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Post renderer: render diagrams + wire interactivity ──
|
||||
ctx.renderers.register('mermaid-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]');
|
||||
if (diagrams.length === 0) return;
|
||||
|
||||
diagrams.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
self._renderDiagram(el);
|
||||
});
|
||||
|
||||
self._wireToolbar(container);
|
||||
}
|
||||
});
|
||||
|
||||
this._loadMermaid();
|
||||
},
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── ViewBox Zoom/Pan State ──────────────
|
||||
|
||||
_getState(id) {
|
||||
function _getState(id) {
|
||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||
if (!vp) return null;
|
||||
if (!vp._mmdState) {
|
||||
@@ -102,10 +41,10 @@ Extensions.register({
|
||||
};
|
||||
}
|
||||
return vp._mmdState;
|
||||
},
|
||||
}
|
||||
|
||||
_initState(id) {
|
||||
const state = this._getState(id);
|
||||
function _initState(id) {
|
||||
const state = _getState(id);
|
||||
if (!state) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
@@ -113,69 +52,51 @@ Extensions.register({
|
||||
const vb = svg.getAttribute('viewBox');
|
||||
if (!vb) {
|
||||
const bbox = svg.getBBox();
|
||||
state.natX = bbox.x;
|
||||
state.natY = bbox.y;
|
||||
state.natW = bbox.width;
|
||||
state.natH = bbox.height;
|
||||
state.natX = bbox.x; state.natY = bbox.y;
|
||||
state.natW = bbox.width; state.natH = bbox.height;
|
||||
} else {
|
||||
const parts = vb.trim().split(/[\s,]+/).map(Number);
|
||||
state.natX = parts[0] || 0;
|
||||
state.natY = parts[1] || 0;
|
||||
state.natW = parts[2] || 0;
|
||||
state.natH = parts[3] || 0;
|
||||
state.natX = parts[0] || 0; state.natY = parts[1] || 0;
|
||||
state.natW = parts[2] || 0; state.natH = parts[3] || 0;
|
||||
}
|
||||
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = state.natW;
|
||||
state.vbH = state.natH;
|
||||
state.vbX = state.natX; state.vbY = state.natY;
|
||||
state.vbW = state.natW; state.vbH = state.natH;
|
||||
state.ready = true;
|
||||
_applyViewBox(id);
|
||||
}
|
||||
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
_applyViewBox(id) {
|
||||
const state = this._getState(id);
|
||||
function _applyViewBox(id) {
|
||||
const state = _getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
|
||||
svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`);
|
||||
|
||||
const zoomPct = Math.round((state.natW / state.vbW) * 100);
|
||||
const label = document.querySelector(`[data-zoom-label="${id}"]`);
|
||||
if (label) label.textContent = zoomPct + '%';
|
||||
},
|
||||
}
|
||||
|
||||
_zoom(id, factor) {
|
||||
const state = this._getState(id);
|
||||
function _zoom(id, factor) {
|
||||
const state = _getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
|
||||
const scale = 1 / (1 + factor);
|
||||
const newW = state.vbW * scale;
|
||||
const newH = state.vbH * scale;
|
||||
|
||||
const minW = state.natW / 20;
|
||||
const maxW = state.natW * 2;
|
||||
if (newW < minW || newW > maxW) return;
|
||||
|
||||
if (newW < state.natW / 20 || newW > state.natW * 2) return;
|
||||
const cx = state.vbX + state.vbW / 2;
|
||||
const cy = state.vbY + state.vbH / 2;
|
||||
state.vbW = newW; state.vbH = newH;
|
||||
state.vbX = cx - newW / 2; state.vbY = cy - newH / 2;
|
||||
_applyViewBox(id);
|
||||
}
|
||||
|
||||
state.vbW = newW;
|
||||
state.vbH = newH;
|
||||
state.vbX = cx - newW / 2;
|
||||
state.vbY = cy - newH / 2;
|
||||
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
_zoomAt(id, factor, clientX, clientY) {
|
||||
const state = this._getState(id);
|
||||
function _zoomAt(id, factor, clientX, clientY) {
|
||||
const state = _getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0 || svgRect.height === 0) return;
|
||||
|
||||
@@ -187,147 +108,76 @@ Extensions.register({
|
||||
const scale = 1 / (1 + factor);
|
||||
const newW = state.vbW * scale;
|
||||
const newH = state.vbH * scale;
|
||||
if (newW < state.natW / 20 || newW > state.natW * 2) return;
|
||||
state.vbW = newW; state.vbH = newH;
|
||||
state.vbX = pointX - fx * newW; state.vbY = pointY - fy * newH;
|
||||
_applyViewBox(id);
|
||||
}
|
||||
|
||||
const minW = state.natW / 20;
|
||||
const maxW = state.natW * 2;
|
||||
if (newW < minW || newW > maxW) return;
|
||||
|
||||
state.vbW = newW;
|
||||
state.vbH = newH;
|
||||
state.vbX = pointX - fx * newW;
|
||||
state.vbY = pointY - fy * newH;
|
||||
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
_zoomReset(id) {
|
||||
const state = this._getState(id);
|
||||
function _zoomReset(id) {
|
||||
const state = _getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = state.natW;
|
||||
state.vbH = state.natH;
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
state.vbX = state.natX; state.vbY = state.natY;
|
||||
state.vbW = state.natW; state.vbH = state.natH;
|
||||
_applyViewBox(id);
|
||||
}
|
||||
|
||||
_zoomFit(id) {
|
||||
const state = this._getState(id);
|
||||
function _zoomFit(id) {
|
||||
const state = _getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||
if (!vp) return;
|
||||
|
||||
const vpRect = vp.getBoundingClientRect();
|
||||
if (vpRect.width === 0 || vpRect.height === 0) return;
|
||||
|
||||
const vpAspect = vpRect.width / vpRect.height;
|
||||
const natAspect = state.natW / state.natH;
|
||||
|
||||
if (vpAspect > natAspect) {
|
||||
const newW = state.natH * vpAspect;
|
||||
state.vbX = state.natX - (newW - state.natW) / 2;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = newW;
|
||||
state.vbH = state.natH;
|
||||
state.vbW = newW; state.vbH = state.natH;
|
||||
} else {
|
||||
const newH = state.natW / vpAspect;
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY - (newH - state.natH) / 2;
|
||||
state.vbW = state.natW;
|
||||
state.vbH = newH;
|
||||
state.vbW = state.natW; state.vbH = newH;
|
||||
}
|
||||
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
// ── Expand (context-aware) ──────────────
|
||||
// Side panel open → pop out into it (replaces current content).
|
||||
// Side panel closed → fullscreen overlay.
|
||||
|
||||
_expand(id) {
|
||||
if (this._ctx.ui.isPanelOpen()) {
|
||||
this._popOutToPanel(id);
|
||||
} else {
|
||||
this._toggleFullscreen(id);
|
||||
}
|
||||
},
|
||||
_applyViewBox(id);
|
||||
}
|
||||
|
||||
// ── Fullscreen ──────────────────────────
|
||||
|
||||
_toggleFullscreen(id) {
|
||||
function _toggleFullscreen(id) {
|
||||
const block = document.querySelector(`[data-mermaid-id="${id}"]`);
|
||||
if (!block) return;
|
||||
|
||||
const isFS = block.classList.toggle('mermaid-fullscreen');
|
||||
|
||||
if (isFS) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Close button overlay (always visible — critical for mobile)
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'mmd-fullscreen-close';
|
||||
closeBtn.innerHTML = '\u2715';
|
||||
closeBtn.title = 'Close fullscreen';
|
||||
closeBtn.addEventListener('click', () => this._toggleFullscreen(id));
|
||||
closeBtn.addEventListener('click', () => _toggleFullscreen(id));
|
||||
block.appendChild(closeBtn);
|
||||
|
||||
// Escape listener (desktop convenience, not sole exit)
|
||||
block._mmdEscHandler = (e) => {
|
||||
if (e.key === 'Escape') this._toggleFullscreen(id);
|
||||
};
|
||||
block._mmdEscHandler = (e) => { if (e.key === 'Escape') _toggleFullscreen(id); };
|
||||
document.addEventListener('keydown', block._mmdEscHandler);
|
||||
|
||||
requestAnimationFrame(() => this._zoomFit(id));
|
||||
requestAnimationFrame(() => _zoomFit(id));
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
|
||||
const closeBtn = block.querySelector('.mmd-fullscreen-close');
|
||||
if (closeBtn) closeBtn.remove();
|
||||
|
||||
if (block._mmdEscHandler) {
|
||||
document.removeEventListener('keydown', block._mmdEscHandler);
|
||||
delete block._mmdEscHandler;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Side Panel Pop-out ──────────────────
|
||||
|
||||
_popOutToPanel(id) {
|
||||
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
if (state?.ready) {
|
||||
clone.setAttribute('viewBox',
|
||||
`${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||
}
|
||||
clone.setAttribute('width', '100%');
|
||||
clone.removeAttribute('height');
|
||||
clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
const isDark = this._ctx.ui.isDark();
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; display: flex; justify-content: center;
|
||||
align-items: flex-start; min-height: 100vh;
|
||||
background: ${isDark ? '#1a1a2e' : '#fff'};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
svg { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head><body>${clone.outerHTML}</body></html>`;
|
||||
|
||||
this._ctx.ui.openPreview(html);
|
||||
},
|
||||
}
|
||||
|
||||
// ── Toolbar Wiring ──────────────────────
|
||||
|
||||
_wireToolbar(container) {
|
||||
const self = this;
|
||||
|
||||
function _wireToolbar(container) {
|
||||
if (container._mmdWired) return;
|
||||
container._mmdWired = true;
|
||||
|
||||
@@ -339,14 +189,14 @@ Extensions.register({
|
||||
if (!id) return;
|
||||
|
||||
switch (action) {
|
||||
case 'zoom-in': self._zoom(id, 0.25); break;
|
||||
case 'zoom-out': self._zoom(id, -0.2); break;
|
||||
case 'zoom-reset': self._zoomReset(id); break;
|
||||
case 'zoom-fit': self._zoomFit(id); break;
|
||||
case 'expand': self._expand(id); break;
|
||||
case 'export-svg': self._exportSVG(id); break;
|
||||
case 'export-png': self._exportPNG(id); break;
|
||||
case 'copy-src': self._copySource(id); break;
|
||||
case 'zoom-in': _zoom(id, 0.25); break;
|
||||
case 'zoom-out': _zoom(id, -0.2); break;
|
||||
case 'zoom-reset': _zoomReset(id); break;
|
||||
case 'zoom-fit': _zoomFit(id); break;
|
||||
case 'expand': _toggleFullscreen(id); break;
|
||||
case 'export-svg': _exportSVG(id); break;
|
||||
case 'export-png': _exportPNG(id); break;
|
||||
case 'copy-src': _copySource(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -359,42 +209,35 @@ Extensions.register({
|
||||
vp.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
self._zoomAt(id, delta, e.clientX, e.clientY);
|
||||
_zoomAt(id, delta, e.clientX, e.clientY);
|
||||
}, { passive: false });
|
||||
|
||||
vp.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const state = self._getState(id);
|
||||
const state = _getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
state.dragging = true;
|
||||
state.startX = e.clientX;
|
||||
state.startY = e.clientY;
|
||||
state.startVbX = state.vbX;
|
||||
state.startVbY = state.vbY;
|
||||
state.startX = e.clientX; state.startY = e.clientY;
|
||||
state.startVbX = state.vbX; state.startVbY = state.vbY;
|
||||
vp.classList.add('mmd-grabbing');
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
vp.addEventListener('mousemove', (e) => {
|
||||
const state = self._getState(id);
|
||||
const state = _getState(id);
|
||||
if (!state?.dragging) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0) return;
|
||||
|
||||
const pxToVb = state.vbW / svgRect.width;
|
||||
const dx = (e.clientX - state.startX) * pxToVb;
|
||||
const dy = (e.clientY - state.startY) * pxToVb;
|
||||
|
||||
state.vbX = state.startVbX - dx;
|
||||
state.vbY = state.startVbY - dy;
|
||||
self._applyViewBox(id);
|
||||
state.vbX = state.startVbX - (e.clientX - state.startX) * pxToVb;
|
||||
state.vbY = state.startVbY - (e.clientY - state.startY) * pxToVb;
|
||||
_applyViewBox(id);
|
||||
});
|
||||
|
||||
const endDrag = () => {
|
||||
const state = self._getState(id);
|
||||
const state = _getState(id);
|
||||
if (!state) return;
|
||||
state.dragging = false;
|
||||
vp.classList.remove('mmd-grabbing');
|
||||
@@ -403,9 +246,8 @@ Extensions.register({
|
||||
vp.addEventListener('mouseleave', endDrag);
|
||||
|
||||
let lastTouchDist = 0;
|
||||
let lastTouchCenter = null;
|
||||
vp.addEventListener('touchstart', (e) => {
|
||||
const state = self._getState(id);
|
||||
const state = _getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
if (e.touches.length === 1) {
|
||||
state.dragging = true;
|
||||
@@ -418,28 +260,21 @@ Extensions.register({
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
lastTouchCenter = {
|
||||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||
};
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
vp.addEventListener('touchmove', (e) => {
|
||||
const state = self._getState(id);
|
||||
const state = _getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
if (e.touches.length === 1 && state.dragging) {
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0) return;
|
||||
|
||||
const pxToVb = state.vbW / svgRect.width;
|
||||
const dx = (e.touches[0].clientX - state.startX) * pxToVb;
|
||||
const dy = (e.touches[0].clientY - state.startY) * pxToVb;
|
||||
state.vbX = state.startVbX - dx;
|
||||
state.vbY = state.startVbY - dy;
|
||||
self._applyViewBox(id);
|
||||
state.vbX = state.startVbX - (e.touches[0].clientX - state.startX) * pxToVb;
|
||||
state.vbY = state.startVbY - (e.touches[0].clientY - state.startY) * pxToVb;
|
||||
_applyViewBox(id);
|
||||
e.preventDefault();
|
||||
} else if (e.touches.length === 2) {
|
||||
const dist = Math.hypot(
|
||||
@@ -452,46 +287,42 @@ Extensions.register({
|
||||
};
|
||||
if (lastTouchDist > 0) {
|
||||
const factor = (dist - lastTouchDist) / lastTouchDist;
|
||||
self._zoomAt(id, factor, center.x, center.y);
|
||||
_zoomAt(id, factor, center.x, center.y);
|
||||
}
|
||||
lastTouchDist = dist;
|
||||
lastTouchCenter = center;
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
vp.addEventListener('touchend', () => {
|
||||
const state = self._getState(id);
|
||||
const state = _getState(id);
|
||||
if (state) state.dragging = false;
|
||||
lastTouchDist = 0;
|
||||
lastTouchCenter = null;
|
||||
}, { passive: true });
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
// ── Export ───────────────────────────────
|
||||
|
||||
_exportSVG(id) {
|
||||
function _exportSVG(id) {
|
||||
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const state = this._getState(id);
|
||||
const state = _getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
if (state?.ready) {
|
||||
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||
}
|
||||
const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' });
|
||||
this._download(blob, `diagram-${id}.svg`);
|
||||
},
|
||||
_download(blob, `diagram-${id}.svg`);
|
||||
}
|
||||
|
||||
_exportPNG(id) {
|
||||
function _exportPNG(id) {
|
||||
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const state = this._getState(id);
|
||||
const state = _getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
if (state?.ready) {
|
||||
@@ -503,7 +334,6 @@ Extensions.register({
|
||||
const svgData = new XMLSerializer().serializeToString(clone);
|
||||
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
const self = this;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
@@ -515,9 +345,8 @@ Extensions.register({
|
||||
ctx.scale(scale, scale);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) self._download(blob, `diagram-${id}.png`);
|
||||
if (blob) _download(blob, `diagram-${id}.png`);
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = () => {
|
||||
@@ -525,9 +354,9 @@ Extensions.register({
|
||||
console.error('[Mermaid] PNG export failed');
|
||||
};
|
||||
img.src = url;
|
||||
},
|
||||
}
|
||||
|
||||
_download(blob, filename) {
|
||||
function _download(blob, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
@@ -535,25 +364,24 @@ Extensions.register({
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
},
|
||||
}
|
||||
|
||||
_copySource(id) {
|
||||
function _copySource(id) {
|
||||
const code = document.querySelector(`[data-source="${id}"]`);
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||
this._ctx.ui.toast('Source copied', 'success');
|
||||
if (window.sw?.toast) sw.toast('Source copied', 'success');
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
// ── Diagram Rendering ───────────────────
|
||||
|
||||
async _renderDiagram(el) {
|
||||
async function _renderDiagram(el) {
|
||||
const code = decodeURIComponent(el.dataset.mermaidSrc);
|
||||
const id = el.dataset.diagram;
|
||||
|
||||
try {
|
||||
await this._loadMermaid();
|
||||
|
||||
await _loadMermaid();
|
||||
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
|
||||
const { svg } = await mermaid.render(svgId, code);
|
||||
el.innerHTML = svg;
|
||||
@@ -566,28 +394,27 @@ Extensions.register({
|
||||
svgEl.style.maxWidth = 'none';
|
||||
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
}
|
||||
|
||||
this._initState(id);
|
||||
_initState(id);
|
||||
} catch (e) {
|
||||
el.innerHTML = `
|
||||
<div class="mermaid-error">
|
||||
<strong>Diagram error:</strong> ${this._escapeHtml(e.message || String(e))}
|
||||
<strong>Diagram error:</strong> ${_escapeHtml(e.message || String(e))}
|
||||
</div>
|
||||
`;
|
||||
el.dataset.rendered = 'error';
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ── Mermaid Library Loading ──────────────
|
||||
|
||||
_loadMermaid() {
|
||||
if (this._mermaidReady) return Promise.resolve();
|
||||
if (this._mermaidLoading) return this._mermaidLoading;
|
||||
function _loadMermaid() {
|
||||
if (_mermaidReady) return Promise.resolve();
|
||||
if (_mermaidLoading) return _mermaidLoading;
|
||||
|
||||
this._mermaidLoading = new Promise((resolve, reject) => {
|
||||
_mermaidLoading = new Promise((resolve, reject) => {
|
||||
if (typeof mermaid !== 'undefined') {
|
||||
this._initMermaid();
|
||||
this._mermaidReady = true;
|
||||
_initMermaid();
|
||||
_mermaidReady = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
@@ -599,8 +426,8 @@ Extensions.register({
|
||||
const script = document.createElement('script');
|
||||
script.src = localSrc;
|
||||
script.onload = () => {
|
||||
this._initMermaid();
|
||||
this._mermaidReady = true;
|
||||
_initMermaid();
|
||||
_mermaidReady = true;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
@@ -608,8 +435,8 @@ Extensions.register({
|
||||
const cdn = document.createElement('script');
|
||||
cdn.src = cdnSrc;
|
||||
cdn.onload = () => {
|
||||
this._initMermaid();
|
||||
this._mermaidReady = true;
|
||||
_initMermaid();
|
||||
_mermaidReady = true;
|
||||
resolve();
|
||||
};
|
||||
cdn.onerror = () => {
|
||||
@@ -620,25 +447,23 @@ Extensions.register({
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return _mermaidLoading;
|
||||
}
|
||||
|
||||
return this._mermaidLoading;
|
||||
},
|
||||
|
||||
_initMermaid() {
|
||||
function _initMermaid() {
|
||||
if (typeof mermaid === 'undefined') return;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: this._ctx.ui.isDark() ? 'dark' : 'default',
|
||||
theme: _isDark() ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'inherit',
|
||||
logLevel: 'error',
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
_injectStyles() {
|
||||
function _injectStyles() {
|
||||
if (document.getElementById('ext-style-mermaid-renderer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-mermaid-renderer';
|
||||
@@ -647,8 +472,6 @@ Extensions.register({
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
||||
}
|
||||
|
||||
/* ── Fullscreen mode ── */
|
||||
.mermaid-block.mermaid-fullscreen {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 10000; border-radius: 0; margin: 0;
|
||||
@@ -656,15 +479,11 @@ Extensions.register({
|
||||
background: var(--bg-1, var(--bg-2, #1a1a2e));
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
|
||||
background: var(--bg-2, #1a1a2e);
|
||||
flex: 1; max-height: none;
|
||||
background: var(--bg-2, #1a1a2e); flex: 1; max-height: none;
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-toolbar {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 6px 14px;
|
||||
border-bottom: 1px solid var(--border); padding: 6px 14px;
|
||||
}
|
||||
|
||||
/* Fullscreen close button — always visible, critical for mobile */
|
||||
.mmd-fullscreen-close {
|
||||
position: absolute; top: 12px; right: 12px; z-index: 10001;
|
||||
width: 40px; height: 40px; border-radius: 50%;
|
||||
@@ -676,11 +495,8 @@ Extensions.register({
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.mmd-fullscreen-close:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.15));
|
||||
transform: scale(1.1);
|
||||
background: var(--bg-hover, rgba(255,255,255,0.15)); transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.mermaid-toolbar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-bottom: 1px solid var(--border);
|
||||
@@ -699,24 +515,14 @@ Extensions.register({
|
||||
}
|
||||
.mmd-btn:hover { color: var(--text-1); border-color: var(--text-3); background: var(--bg-3); }
|
||||
.mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
|
||||
|
||||
/* Viewport */
|
||||
.mermaid-viewport {
|
||||
overflow: hidden; position: relative;
|
||||
min-height: 80px; max-height: 600px;
|
||||
cursor: grab; user-select: none;
|
||||
}
|
||||
.mermaid-viewport.mmd-grabbing { cursor: grabbing; }
|
||||
|
||||
/* Diagram wrapper */
|
||||
.mermaid-diagram {
|
||||
width: 100%; height: 100%; min-height: 60px;
|
||||
}
|
||||
.mermaid-diagram svg {
|
||||
display: block; width: 100%; height: 100%;
|
||||
}
|
||||
|
||||
/* Loading / Error */
|
||||
.mermaid-diagram { width: 100%; height: 100%; min-height: 60px; }
|
||||
.mermaid-diagram svg { display: block; width: 100%; height: 100%; }
|
||||
.mermaid-loading {
|
||||
color: var(--text-3); font-size: 13px; padding: 20px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
@@ -732,8 +538,6 @@ Extensions.register({
|
||||
padding: 12px 16px; border-radius: 4px; font-size: 13px;
|
||||
font-family: var(--mono); white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Source panel */
|
||||
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
|
||||
.mermaid-source summary {
|
||||
padding: 6px 12px; cursor: pointer; color: var(--text-3);
|
||||
@@ -746,34 +550,91 @@ Extensions.register({
|
||||
margin: 0; border-radius: 0; border: none;
|
||||
max-height: 200px; overflow: auto;
|
||||
}
|
||||
|
||||
/* Hide source panel in fullscreen */
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
|
||||
|
||||
/* ── Mobile adjustments ── */
|
||||
@media (max-width: 768px) {
|
||||
.mermaid-toolbar { padding: 6px 10px; gap: 4px; }
|
||||
.mmd-btn { padding: 4px 10px; font-size: 12px; }
|
||||
.mmd-sep { margin: 0 2px; }
|
||||
.mmd-fullscreen-close { width: 48px; height: 48px; font-size: 24px; top: 16px; right: 16px; }
|
||||
}
|
||||
`;
|
||||
}`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-mermaid-renderer')?.remove();
|
||||
document.querySelectorAll('.mermaid-fullscreen').forEach(el => {
|
||||
el.classList.remove('mermaid-fullscreen');
|
||||
const closeBtn = el.querySelector('.mmd-fullscreen-close');
|
||||
if (closeBtn) closeBtn.remove();
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Registration ────────────────────────
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
_injectStyles();
|
||||
|
||||
// Block renderer: match ```mermaid
|
||||
sw.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: 'mermaid',
|
||||
priority: 10,
|
||||
render(lang, code, container) {
|
||||
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="mermaid-block" data-mermaid-id="${id}">
|
||||
<div class="mermaid-toolbar">
|
||||
<span class="mermaid-title">\u{1f4ca} Diagram</span>
|
||||
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
|
||||
<div class="mermaid-toolbar-btns">
|
||||
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
|
||||
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">\u2212</button>
|
||||
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</button>
|
||||
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Fullscreen">\u26f6</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
|
||||
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mermaid-viewport" data-viewport="${id}">
|
||||
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
|
||||
<div class="mermaid-loading">
|
||||
<span class="mermaid-spinner"></span> Rendering diagram\u2026
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="mermaid-source">
|
||||
<summary>
|
||||
<span>\u{1f4cb} View source</span>
|
||||
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
|
||||
</summary>
|
||||
<pre><code class="language-mermaid" data-source="${id}">${_escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Post renderer: render diagrams + wire interactivity
|
||||
sw.renderers.register('mermaid-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]');
|
||||
if (diagrams.length === 0) return;
|
||||
|
||||
diagrams.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
_renderDiagram(el);
|
||||
});
|
||||
|
||||
_wireToolbar(container);
|
||||
}
|
||||
});
|
||||
|
||||
_loadMermaid();
|
||||
}
|
||||
|
||||
// Boot: register immediately if SDK ready, otherwise listen
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -69,100 +69,13 @@
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Markdown renderer (inline, no deps)
|
||||
// Markdown rendering via sw.markdown
|
||||
// ═══════════════════════════════════════════
|
||||
// Preload marked + DOMPurify so renderSync works in useMemo
|
||||
sw.markdown.preload();
|
||||
|
||||
function renderMarkdown(src) {
|
||||
if (!src) return '';
|
||||
var lines = src.split('\n');
|
||||
var out = [];
|
||||
var inCode = false;
|
||||
var inList = false;
|
||||
var listTag = '';
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
|
||||
// fenced code blocks
|
||||
if (line.trim().startsWith('```')) {
|
||||
if (inCode) {
|
||||
out.push('</code></pre>');
|
||||
inCode = false;
|
||||
} else {
|
||||
if (inList) { out.push('</' + listTag + '>'); inList = false; }
|
||||
out.push('<pre><code>');
|
||||
inCode = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
out.push(escHtml(line) + '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
// blank line
|
||||
if (!line.trim()) {
|
||||
if (inList) { out.push('</' + listTag + '>'); inList = false; }
|
||||
continue;
|
||||
}
|
||||
|
||||
// headings
|
||||
if (line.startsWith('### ')) { closeList(); out.push('<h3>' + inline(line.slice(4)) + '</h3>'); continue; }
|
||||
if (line.startsWith('## ')) { closeList(); out.push('<h2>' + inline(line.slice(3)) + '</h2>'); continue; }
|
||||
if (line.startsWith('# ')) { closeList(); out.push('<h1>' + inline(line.slice(2)) + '</h1>'); continue; }
|
||||
|
||||
// hr
|
||||
if (/^[-*_]{3,}\s*$/.test(line)) { closeList(); out.push('<hr>'); continue; }
|
||||
|
||||
// blockquote
|
||||
if (line.startsWith('> ')) { closeList(); out.push('<blockquote><p>' + inline(line.slice(2)) + '</p></blockquote>'); continue; }
|
||||
|
||||
// unordered list
|
||||
if (/^[\-*+]\s/.test(line)) {
|
||||
if (!inList || listTag !== 'ul') { closeList(); out.push('<ul>'); inList = true; listTag = 'ul'; }
|
||||
out.push('<li>' + inline(line.replace(/^[\-*+]\s/, '')) + '</li>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// ordered list
|
||||
if (/^\d+\.\s/.test(line)) {
|
||||
if (!inList || listTag !== 'ol') { closeList(); out.push('<ol>'); inList = true; listTag = 'ol'; }
|
||||
out.push('<li>' + inline(line.replace(/^\d+\.\s/, '')) + '</li>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// paragraph
|
||||
closeList();
|
||||
out.push('<p>' + inline(line) + '</p>');
|
||||
}
|
||||
if (inCode) out.push('</code></pre>');
|
||||
if (inList) out.push('</' + listTag + '>');
|
||||
return out.join('\n');
|
||||
|
||||
function closeList() { if (inList) { out.push('</' + listTag + '>'); inList = false; } }
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function inline(s) {
|
||||
s = escHtml(s);
|
||||
// inline code
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
// bold
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
s = s.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||
// italic
|
||||
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
s = s.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
// links
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
// wikilinks: [[Note Title]] → clickable internal link
|
||||
s = s.replace(/\[\[([^\]]+)\]\]/g, function(match, linkText) {
|
||||
return '<a class="wikilink" data-wikilink="' + linkText.trim() + '" href="#">' + linkText.trim() + '</a>';
|
||||
});
|
||||
return s;
|
||||
function _escAttr(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
|
||||
@@ -854,13 +767,13 @@
|
||||
}, [note ? note.id : null, saveCount]);
|
||||
|
||||
var previewHtml = useMemo(function() {
|
||||
var h = renderMarkdown(body);
|
||||
var h = sw.markdown.renderSync(body, { sanitize: true });
|
||||
// mark unresolved wikilinks
|
||||
for (var i = 0; i < outgoingLinks.length; i++) {
|
||||
var link = outgoingLinks[i];
|
||||
if (!link.target_id) {
|
||||
var search = 'class="wikilink" data-wikilink="' + escHtml(link.link_text) + '"';
|
||||
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + escHtml(link.link_text) + '"';
|
||||
var search = 'class="wikilink" data-wikilink="' + _escAttr(link.link_text) + '"';
|
||||
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"';
|
||||
h = h.split(search).join(replace);
|
||||
}
|
||||
}
|
||||
@@ -884,6 +797,13 @@
|
||||
return function() { el.removeEventListener('click', handleClick); };
|
||||
}, [viewMode, onNavigateToTitle]);
|
||||
|
||||
// ── Run post-renderers (mermaid, katex, etc.) on preview ──
|
||||
useEffect(function() {
|
||||
var el = previewRef.current;
|
||||
if (!el || viewMode === 'edit') return;
|
||||
sw.renderers.runPostRenderers(el);
|
||||
}, [previewHtml, viewMode]);
|
||||
|
||||
// ── Synced scroll in split mode ──
|
||||
useEffect(function() {
|
||||
if (viewMode !== 'split' || !cmEditorRef.current) return;
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
await T.test('admin', 'surfaces', 'sw.api.admin.surfaces.list()', {
|
||||
sdk: function () { return sw.api.admin.surfaces.list(); },
|
||||
raw: { method: 'GET', path: '/admin/surfaces' },
|
||||
raw: { method: 'GET', path: '/admin/packages' },
|
||||
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user