Feat v0.2.9 builtin extension retirement
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 2m26s
CI/CD / test-sqlite (pull_request) Successful in 2m41s
CI/CD / build-and-deploy (pull_request) Has been skipped
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 2m26s
CI/CD / test-sqlite (pull_request) Successful in 2m41s
CI/CD / build-and-deploy (pull_request) Has been skipped
Stop auto-seeding 6 chat-centric extensions on startup. Move them to standard packages with js/ layout and "requires": ["chat"] metadata. Delete SeedBuiltinPackages, seed tests, Dockerfile COPY, and extensions/builtin/ directory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
254
packages/csv-table/js/script.js
Normal file
254
packages/csv-table/js/script.js
Normal file
@@ -0,0 +1,254 @@
|
||||
// ==========================================
|
||||
// CSV Table Viewer — Browser Extension
|
||||
// ==========================================
|
||||
// Renders ```csv and ```tsv code blocks as sortable HTML tables.
|
||||
// No external dependencies.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'csv-table',
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
|
||||
// ── 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) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
|
||||
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;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
field += ch;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
if (ch === '"' && field === '') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
} else if (ch === delimiter) {
|
||||
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 = '';
|
||||
i += (ch === '\r') ? 2 : 1;
|
||||
} else {
|
||||
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('');
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('ext-style-csv-table')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-csv-table';
|
||||
style.textContent = `
|
||||
.csv-table-block {
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
||||
}
|
||||
.csv-toolbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text-3);
|
||||
}
|
||||
.csv-info { flex: 1; }
|
||||
.csv-copy-btn {
|
||||
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 2px 8px; font-size: 12px; cursor: pointer; color: var(--text-3);
|
||||
}
|
||||
.csv-copy-btn:hover { color: var(--text-2); border-color: var(--text-3); }
|
||||
.csv-table-scroll { overflow-x: auto; }
|
||||
.csv-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.csv-table th {
|
||||
background: var(--bg-3); color: var(--text-2); font-weight: 600;
|
||||
padding: 6px 12px; text-align: left; white-space: nowrap;
|
||||
border-bottom: 2px solid var(--border); user-select: none;
|
||||
}
|
||||
.csv-table th:hover { color: var(--text-1); }
|
||||
.csv-sort-icon { font-size: 11px; opacity: 0.7; }
|
||||
.csv-table td {
|
||||
padding: 4px 12px; border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap; max-width: 300px; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.csv-table tr:hover td { background: var(--bg-3); }
|
||||
.csv-source, .csv-empty { border-top: 1px solid var(--border); font-size: 12px; }
|
||||
.csv-source summary {
|
||||
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
|
||||
}
|
||||
.csv-source summary:hover { color: var(--text-2); }
|
||||
.csv-source pre {
|
||||
margin: 0; border-radius: 0; border: none;
|
||||
max-height: 200px; overflow: auto;
|
||||
}
|
||||
.csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-csv-table')?.remove();
|
||||
}
|
||||
});
|
||||
14
packages/csv-table/manifest.json
Normal file
14
packages/csv-table/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "csv-table",
|
||||
"name": "CSV Table Viewer",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Renders ```csv code blocks as sortable, interactive HTML tables",
|
||||
"requires": ["chat"],
|
||||
"permissions": [],
|
||||
"tools": [],
|
||||
"surfaces": [],
|
||||
"settings": {}
|
||||
}
|
||||
166
packages/diff-viewer/js/script.js
Normal file
166
packages/diff-viewer/js/script.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// ==========================================
|
||||
// Diff Viewer — Browser Extension
|
||||
// ==========================================
|
||||
// Renders ```diff code blocks with syntax-highlighted
|
||||
// additions, deletions, hunk headers, and context lines.
|
||||
// No external dependencies.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'diff-viewer',
|
||||
|
||||
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) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('ext-style-diff-viewer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-diff-viewer';
|
||||
style.textContent = `
|
||||
.diff-block {
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
||||
}
|
||||
.diff-toolbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text-3);
|
||||
}
|
||||
.diff-filename {
|
||||
flex: 1; font-family: var(--mono); font-weight: 600;
|
||||
color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.diff-stats { display: flex; gap: 8px; font-family: var(--mono); font-weight: 600; }
|
||||
.diff-stat-add { color: var(--success, #27ae60); }
|
||||
.diff-stat-del { color: var(--error, #e74c3c); }
|
||||
.diff-copy-btn {
|
||||
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 2px 8px; font-size: 12px; cursor: pointer; color: var(--text-3);
|
||||
}
|
||||
.diff-copy-btn:hover { color: var(--text-2); border-color: var(--text-3); }
|
||||
.diff-content {
|
||||
font-family: var(--mono); font-size: 13px; line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.diff-line { padding: 1px 12px; white-space: pre; min-height: 1.5em; }
|
||||
.diff-indicator {
|
||||
display: inline-block; width: 1.2em; text-align: center;
|
||||
opacity: 0.7; user-select: none;
|
||||
}
|
||||
.diff-add { background: rgba(39,174,96,0.12); color: var(--success, #27ae60); }
|
||||
.diff-del { background: rgba(231,76,60,0.12); color: var(--error, #e74c3c); }
|
||||
.diff-hunk {
|
||||
background: rgba(52,152,219,0.08); color: var(--info, #3498db);
|
||||
font-style: italic; border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border); margin: 2px 0;
|
||||
}
|
||||
.diff-file-header { background: var(--bg-3); color: var(--text-2); font-weight: 600; }
|
||||
.diff-ctx { color: var(--text-3); }
|
||||
.diff-meta { color: var(--text-3); font-style: italic; }
|
||||
.diff-source-toggle { border-top: 1px solid var(--border); font-size: 12px; }
|
||||
.diff-source-toggle summary {
|
||||
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
|
||||
}
|
||||
.diff-source-toggle summary:hover { color: var(--text-2); }
|
||||
.diff-source-toggle pre {
|
||||
margin: 0; border-radius: 0; border: none;
|
||||
max-height: 200px; overflow: auto;
|
||||
}`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-diff-viewer')?.remove();
|
||||
}
|
||||
});
|
||||
14
packages/diff-viewer/manifest.json
Normal file
14
packages/diff-viewer/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "diff-viewer",
|
||||
"name": "Diff Viewer",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Renders ```diff code blocks with syntax-highlighted additions, deletions, and context lines",
|
||||
"requires": ["chat"],
|
||||
"permissions": [],
|
||||
"tools": [],
|
||||
"surfaces": [],
|
||||
"settings": {}
|
||||
}
|
||||
202
packages/js-sandbox/js/script.js
Normal file
202
packages/js-sandbox/js/script.js
Normal file
@@ -0,0 +1,202 @@
|
||||
// ==========================================
|
||||
// JavaScript Sandbox — Browser Extension
|
||||
// ==========================================
|
||||
// Executes JavaScript in a sandboxed iframe.
|
||||
// Registers the js_eval tool so the LLM can run code,
|
||||
// verify calculations, and transform data.
|
||||
//
|
||||
// Security: The iframe has sandbox="allow-scripts" only.
|
||||
// No allow-same-origin, no network, no DOM access.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'js-sandbox',
|
||||
|
||||
_iframe: null,
|
||||
_pendingCallbacks: new Map(), // messageId → { resolve, reject, timer }
|
||||
_messageCounter: 0,
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
|
||||
// Create the sandboxed iframe on first use (lazy)
|
||||
ctx.tools.handle('js_eval', async (args) => {
|
||||
return self._execute(args.code || '');
|
||||
});
|
||||
},
|
||||
|
||||
async _execute(code) {
|
||||
if (!code.trim()) {
|
||||
return { error: 'No code provided' };
|
||||
}
|
||||
|
||||
// Ensure iframe is ready
|
||||
await this._ensureIframe();
|
||||
|
||||
const messageId = 'eval-' + (++this._messageCounter);
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Set up timeout
|
||||
const timer = setTimeout(() => {
|
||||
this._pendingCallbacks.delete(messageId);
|
||||
resolve({
|
||||
success: false,
|
||||
error: 'Execution timed out (10s)',
|
||||
output: [],
|
||||
result: null,
|
||||
});
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
this._pendingCallbacks.set(messageId, { resolve, timer });
|
||||
|
||||
// Send code to sandbox
|
||||
this._iframe.contentWindow.postMessage({
|
||||
type: 'eval',
|
||||
id: messageId,
|
||||
code: code,
|
||||
}, '*');
|
||||
});
|
||||
},
|
||||
|
||||
_ensureIframe() {
|
||||
if (this._iframe) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Build sandbox HTML as a blob URL
|
||||
const sandboxHTML = this._buildSandboxHTML();
|
||||
const blob = new Blob([sandboxHTML], { type: 'text/html' });
|
||||
const blobURL = URL.createObjectURL(blob);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.sandbox = 'allow-scripts';
|
||||
iframe.style.cssText = 'display:none;width:0;height:0;border:none;position:absolute;';
|
||||
iframe.src = blobURL;
|
||||
|
||||
iframe.onload = () => {
|
||||
this._iframe = iframe;
|
||||
URL.revokeObjectURL(blobURL);
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Listen for messages from sandbox
|
||||
window.addEventListener('message', (event) => {
|
||||
// Only accept messages from our sandbox iframe
|
||||
if (!this._iframe || event.source !== this._iframe.contentWindow) return;
|
||||
|
||||
const data = event.data;
|
||||
if (data?.type !== 'eval-result') return;
|
||||
|
||||
const pending = this._pendingCallbacks.get(data.id);
|
||||
if (!pending) return;
|
||||
|
||||
clearTimeout(pending.timer);
|
||||
this._pendingCallbacks.delete(data.id);
|
||||
|
||||
pending.resolve({
|
||||
success: !data.error,
|
||||
result: data.result !== undefined ? data.result : null,
|
||||
output: data.output || [],
|
||||
error: data.error || null,
|
||||
});
|
||||
});
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
});
|
||||
},
|
||||
|
||||
_buildSandboxHTML() {
|
||||
// This HTML runs INSIDE the sandboxed iframe.
|
||||
// It has no access to the parent page, no network, no cookies.
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><title>Sandbox</title></head>
|
||||
<body><script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Listen for eval messages from parent
|
||||
window.addEventListener('message', function(event) {
|
||||
var msg = event.data;
|
||||
if (!msg || msg.type !== 'eval') return;
|
||||
|
||||
var output = [];
|
||||
var result = undefined;
|
||||
var error = null;
|
||||
|
||||
// Override console methods to capture output
|
||||
var origLog = console.log;
|
||||
var origWarn = console.warn;
|
||||
var origError = console.error;
|
||||
|
||||
console.log = function() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
output.push({ level: 'log', text: args.map(stringify).join(' ') });
|
||||
};
|
||||
console.warn = function() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
output.push({ level: 'warn', text: args.map(stringify).join(' ') });
|
||||
};
|
||||
console.error = function() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
output.push({ level: 'error', text: args.map(stringify).join(' ') });
|
||||
};
|
||||
|
||||
try {
|
||||
// Use indirect eval for global scope
|
||||
result = (0, eval)(msg.code);
|
||||
} catch (e) {
|
||||
error = (e && e.message) ? e.message : String(e);
|
||||
}
|
||||
|
||||
// Restore console
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
|
||||
// Stringify result for safe transfer
|
||||
var resultStr;
|
||||
try {
|
||||
resultStr = stringify(result);
|
||||
} catch (e2) {
|
||||
resultStr = '[unserializable]';
|
||||
}
|
||||
|
||||
// Post result back to parent
|
||||
event.source.postMessage({
|
||||
type: 'eval-result',
|
||||
id: msg.id,
|
||||
result: resultStr,
|
||||
output: output,
|
||||
error: error,
|
||||
}, '*');
|
||||
});
|
||||
|
||||
function stringify(val) {
|
||||
if (val === undefined) return 'undefined';
|
||||
if (val === null) return 'null';
|
||||
if (typeof val === 'function') return val.toString();
|
||||
if (typeof val === 'object') {
|
||||
try { return JSON.stringify(val, null, 2); }
|
||||
catch(e) { return String(val); }
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
})();
|
||||
</` + `script></body></html>`;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
// Clean up pending callbacks
|
||||
for (const [id, pending] of this._pendingCallbacks) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ success: false, error: 'Extension destroyed', output: [], result: null });
|
||||
}
|
||||
this._pendingCallbacks.clear();
|
||||
|
||||
// Remove iframe
|
||||
if (this._iframe) {
|
||||
this._iframe.remove();
|
||||
this._iframe = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
30
packages/js-sandbox/manifest.json
Normal file
30
packages/js-sandbox/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "js-sandbox",
|
||||
"name": "JavaScript Sandbox",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Executes JavaScript in a sandboxed iframe. Allows the LLM to run code, verify calculations, and transform data — no server compute required.",
|
||||
"requires": ["chat"],
|
||||
"permissions": [],
|
||||
"tools": [
|
||||
{
|
||||
"name": "js_eval",
|
||||
"display_name": "JavaScript Eval",
|
||||
"description": "Execute JavaScript code in a secure browser sandbox. Use this to: verify calculations, run algorithms, transform data, test code snippets, parse/format strings, or any computation. Console output (log/warn/error) is captured. The last expression's value is returned as the result. Has no network access, no DOM access, no access to the parent page. Timeout: 10 seconds.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "JavaScript code to execute. Use console.log() for output. The value of the last expression is returned automatically."
|
||||
}
|
||||
},
|
||||
"required": ["code"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"surfaces": [],
|
||||
"settings": {}
|
||||
}
|
||||
297
packages/katex-renderer/js/script.js
Normal file
297
packages/katex-renderer/js/script.js
Normal file
@@ -0,0 +1,297 @@
|
||||
// ==========================================
|
||||
// KaTeX Math Renderer — Browser Extension
|
||||
// ==========================================
|
||||
// Renders ```latex / ```math code blocks and inline $...$ / $$...$$ syntax.
|
||||
// Loads KaTeX dynamically on first use.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'katex-renderer',
|
||||
|
||||
_katexReady: false,
|
||||
_katexLoading: null,
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
|
||||
// ── 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>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
}
|
||||
|
||||
// Process inline math in text content
|
||||
self._processInlineMath(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load KaTeX
|
||||
this._loadKaTeX();
|
||||
},
|
||||
|
||||
async _renderBlock(el) {
|
||||
const code = decodeURIComponent(el.dataset.katexSrc);
|
||||
const displayMode = el.dataset.katexDisplay === 'true';
|
||||
|
||||
try {
|
||||
await this._loadKaTeX();
|
||||
el.innerHTML = katex.renderToString(code, {
|
||||
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))}
|
||||
</div>
|
||||
`;
|
||||
el.dataset.rendered = 'error';
|
||||
}
|
||||
},
|
||||
|
||||
async _processInlineMath(container) {
|
||||
// Skip if no $ signs present at all
|
||||
if (!container.textContent || !container.textContent.includes('$')) 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 textNodes = [];
|
||||
let node;
|
||||
while (node = walker.nextNode()) textNodes.push(node);
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
this._replaceInlineMath(textNode);
|
||||
}
|
||||
},
|
||||
|
||||
_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 expr = match[1] || match[2];
|
||||
|
||||
try {
|
||||
const html = katex.renderToString(expr.trim(), {
|
||||
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
|
||||
|
||||
// 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) {
|
||||
if (part.type === 'text') {
|
||||
span.appendChild(document.createTextNode(part.value));
|
||||
} else {
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = part.displayMode ? 'katex-display' : 'katex-inline';
|
||||
wrapper.innerHTML = part.value;
|
||||
span.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
textNode.parentNode.replaceChild(span, textNode);
|
||||
},
|
||||
|
||||
_loadKaTeX() {
|
||||
if (this._katexReady) return Promise.resolve();
|
||||
if (this._katexLoading) return this._katexLoading;
|
||||
|
||||
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() {
|
||||
if (document.getElementById('ext-style-katex-renderer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-katex-renderer';
|
||||
style.textContent = `
|
||||
.katex-block {
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
||||
}
|
||||
.katex-display-container {
|
||||
padding: 16px; text-align: center; min-height: 40px;
|
||||
}
|
||||
.katex-loading {
|
||||
color: var(--text-3); font-size: 13px; padding: 20px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
}
|
||||
.katex-spinner {
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid var(--border); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: katex-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes katex-spin { to { transform: rotate(360deg); } }
|
||||
.katex-error {
|
||||
color: var(--error, #e74c3c); background: var(--bg-3, rgba(231,76,60,0.1));
|
||||
padding: 12px 16px; border-radius: 4px; font-size: 13px;
|
||||
font-family: var(--mono); white-space: pre-wrap;
|
||||
}
|
||||
.katex-source { border-top: 1px solid var(--border); font-size: 12px; }
|
||||
.katex-source summary {
|
||||
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
|
||||
}
|
||||
.katex-source summary:hover { color: var(--text-2); }
|
||||
.katex-source pre {
|
||||
margin: 0; border-radius: 0; border: none;
|
||||
max-height: 200px; overflow: auto;
|
||||
}
|
||||
.katex-inline-container { display: inline; }
|
||||
.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();
|
||||
}
|
||||
});
|
||||
14
packages/katex-renderer/manifest.json
Normal file
14
packages/katex-renderer/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "katex-renderer",
|
||||
"name": "KaTeX Math",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Renders LaTeX math expressions: ```latex blocks and inline $...$ / $$...$$ syntax",
|
||||
"requires": ["chat"],
|
||||
"permissions": [],
|
||||
"tools": [],
|
||||
"surfaces": [],
|
||||
"settings": {}
|
||||
}
|
||||
779
packages/mermaid-renderer/js/script.js
Normal file
779
packages/mermaid-renderer/js/script.js
Normal file
@@ -0,0 +1,779 @@
|
||||
// ==========================================
|
||||
// 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.
|
||||
// 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.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'mermaid-renderer',
|
||||
|
||||
_mermaidReady: false,
|
||||
_mermaidLoading: null,
|
||||
_ctx: null,
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
this._ctx = ctx;
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
// ── ViewBox Zoom/Pan State ──────────────
|
||||
|
||||
_getState(id) {
|
||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||
if (!vp) return null;
|
||||
if (!vp._mmdState) {
|
||||
vp._mmdState = {
|
||||
natX: 0, natY: 0, natW: 0, natH: 0,
|
||||
vbX: 0, vbY: 0, vbW: 0, vbH: 0,
|
||||
dragging: false, startX: 0, startY: 0,
|
||||
startVbX: 0, startVbY: 0,
|
||||
ready: false,
|
||||
};
|
||||
}
|
||||
return vp._mmdState;
|
||||
},
|
||||
|
||||
_initState(id) {
|
||||
const state = this._getState(id);
|
||||
if (!state) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
|
||||
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;
|
||||
} 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.vbX = state.natX;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = state.natW;
|
||||
state.vbH = state.natH;
|
||||
state.ready = true;
|
||||
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
_applyViewBox(id) {
|
||||
const state = this._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);
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
_zoomAt(id, factor, clientX, clientY) {
|
||||
const state = this._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;
|
||||
|
||||
const fx = (clientX - svgRect.left) / svgRect.width;
|
||||
const fy = (clientY - svgRect.top) / svgRect.height;
|
||||
const pointX = state.vbX + fx * state.vbW;
|
||||
const pointY = state.vbY + fy * state.vbH;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
if (!state || !state.ready) return;
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = state.natW;
|
||||
state.vbH = state.natH;
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
_zoomFit(id) {
|
||||
const state = this._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;
|
||||
} 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Fullscreen ──────────────────────────
|
||||
|
||||
_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));
|
||||
block.appendChild(closeBtn);
|
||||
|
||||
// Escape listener (desktop convenience, not sole exit)
|
||||
block._mmdEscHandler = (e) => {
|
||||
if (e.key === 'Escape') this._toggleFullscreen(id);
|
||||
};
|
||||
document.addEventListener('keydown', block._mmdEscHandler);
|
||||
|
||||
requestAnimationFrame(() => this._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;
|
||||
|
||||
if (container._mmdWired) return;
|
||||
container._mmdWired = true;
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const id = btn.dataset.target;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Wire each viewport for mouse/touch interaction
|
||||
container.querySelectorAll('.mermaid-viewport').forEach(vp => {
|
||||
if (vp._mmdWired) return;
|
||||
vp._mmdWired = true;
|
||||
const id = vp.dataset.viewport;
|
||||
|
||||
vp.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
self._zoomAt(id, delta, e.clientX, e.clientY);
|
||||
}, { passive: false });
|
||||
|
||||
vp.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const state = self._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;
|
||||
vp.classList.add('mmd-grabbing');
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
vp.addEventListener('mousemove', (e) => {
|
||||
const state = self._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);
|
||||
});
|
||||
|
||||
const endDrag = () => {
|
||||
const state = self._getState(id);
|
||||
if (!state) return;
|
||||
state.dragging = false;
|
||||
vp.classList.remove('mmd-grabbing');
|
||||
};
|
||||
vp.addEventListener('mouseup', endDrag);
|
||||
vp.addEventListener('mouseleave', endDrag);
|
||||
|
||||
let lastTouchDist = 0;
|
||||
let lastTouchCenter = null;
|
||||
vp.addEventListener('touchstart', (e) => {
|
||||
const state = self._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
if (e.touches.length === 1) {
|
||||
state.dragging = true;
|
||||
state.startX = e.touches[0].clientX;
|
||||
state.startY = e.touches[0].clientY;
|
||||
state.startVbX = state.vbX;
|
||||
state.startVbY = state.vbY;
|
||||
} else if (e.touches.length === 2) {
|
||||
lastTouchDist = Math.hypot(
|
||||
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);
|
||||
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);
|
||||
e.preventDefault();
|
||||
} else if (e.touches.length === 2) {
|
||||
const dist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
const center = {
|
||||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||
};
|
||||
if (lastTouchDist > 0) {
|
||||
const factor = (dist - lastTouchDist) / lastTouchDist;
|
||||
self._zoomAt(id, factor, center.x, center.y);
|
||||
}
|
||||
lastTouchDist = dist;
|
||||
lastTouchCenter = center;
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
vp.addEventListener('touchend', () => {
|
||||
const state = self._getState(id);
|
||||
if (state) state.dragging = false;
|
||||
lastTouchDist = 0;
|
||||
lastTouchCenter = null;
|
||||
}, { passive: true });
|
||||
});
|
||||
},
|
||||
|
||||
// ── Export ───────────────────────────────
|
||||
|
||||
_exportSVG(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);
|
||||
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`);
|
||||
},
|
||||
|
||||
_exportPNG(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);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
if (state?.ready) {
|
||||
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||
clone.setAttribute('width', state.natW);
|
||||
clone.setAttribute('height', state.natH);
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
const scale = 2;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth * scale;
|
||||
canvas.height = img.naturalHeight * scale;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(scale, scale);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) self._download(blob, `diagram-${id}.png`);
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
console.error('[Mermaid] PNG export failed');
|
||||
};
|
||||
img.src = url;
|
||||
},
|
||||
|
||||
_download(blob, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
},
|
||||
|
||||
_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');
|
||||
});
|
||||
},
|
||||
|
||||
// ── Diagram Rendering ───────────────────
|
||||
|
||||
async _renderDiagram(el) {
|
||||
const code = decodeURIComponent(el.dataset.mermaidSrc);
|
||||
const id = el.dataset.diagram;
|
||||
|
||||
try {
|
||||
await this._loadMermaid();
|
||||
|
||||
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
|
||||
const { svg } = await mermaid.render(svgId, code);
|
||||
el.innerHTML = svg;
|
||||
el.dataset.rendered = 'true';
|
||||
|
||||
const svgEl = el.querySelector('svg');
|
||||
if (svgEl) {
|
||||
svgEl.removeAttribute('height');
|
||||
svgEl.setAttribute('width', '100%');
|
||||
svgEl.style.maxWidth = 'none';
|
||||
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
}
|
||||
|
||||
this._initState(id);
|
||||
} catch (e) {
|
||||
el.innerHTML = `
|
||||
<div class="mermaid-error">
|
||||
<strong>Diagram error:</strong> ${this._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;
|
||||
|
||||
this._mermaidLoading = new Promise((resolve, reject) => {
|
||||
if (typeof mermaid !== 'undefined') {
|
||||
this._initMermaid();
|
||||
this._mermaidReady = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const base = (window.__BASE__ || '');
|
||||
const localSrc = `${base}/vendor/mermaid/mermaid.min.js`;
|
||||
const cdnSrc = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = localSrc;
|
||||
script.onload = () => {
|
||||
this._initMermaid();
|
||||
this._mermaidReady = true;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.warn('[Mermaid] Local vendor not found, trying CDN');
|
||||
const cdn = document.createElement('script');
|
||||
cdn.src = cdnSrc;
|
||||
cdn.onload = () => {
|
||||
this._initMermaid();
|
||||
this._mermaidReady = true;
|
||||
resolve();
|
||||
};
|
||||
cdn.onerror = () => {
|
||||
console.error('[Mermaid] Failed to load from both local and CDN');
|
||||
reject(new Error('Failed to load mermaid.js'));
|
||||
};
|
||||
document.head.appendChild(cdn);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return this._mermaidLoading;
|
||||
},
|
||||
|
||||
_initMermaid() {
|
||||
if (typeof mermaid === 'undefined') return;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: this._ctx.ui.isDark() ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'inherit',
|
||||
logLevel: 'error',
|
||||
});
|
||||
},
|
||||
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('ext-style-mermaid-renderer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-mermaid-renderer';
|
||||
style.textContent = `
|
||||
.mermaid-block {
|
||||
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;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-1, var(--bg-2, #1a1a2e));
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
background: var(--bg-raised, rgba(0,0,0,0.6));
|
||||
border: 1px solid var(--border, rgba(255,255,255,0.2));
|
||||
color: var(--text, #fff); font-size: 20px; line-height: 1;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.mermaid-toolbar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text-3); flex-wrap: wrap;
|
||||
}
|
||||
.mermaid-title { font-weight: 600; color: var(--text-2); margin-right: auto; }
|
||||
.mermaid-zoom-label {
|
||||
font-family: var(--mono); font-size: 11px; min-width: 36px;
|
||||
text-align: center; color: var(--text-3);
|
||||
}
|
||||
.mermaid-toolbar-btns { display: flex; gap: 2px; align-items: center; }
|
||||
.mmd-btn {
|
||||
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 1px 7px; font-size: 11px; cursor: pointer;
|
||||
color: var(--text-3); font-family: var(--mono); line-height: 1.6;
|
||||
}
|
||||
.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-loading {
|
||||
color: var(--text-3); font-size: 13px; padding: 20px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
}
|
||||
.mermaid-spinner {
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid var(--border); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: mmd-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes mmd-spin { to { transform: rotate(360deg); } }
|
||||
.mermaid-error {
|
||||
color: var(--error, #e74c3c); background: var(--bg-3, rgba(231,76,60,0.1));
|
||||
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);
|
||||
user-select: none; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.mermaid-source summary:hover { color: var(--text-2); }
|
||||
.mermaid-source summary span { flex: 1; }
|
||||
.mmd-copy-src { font-size: 11px; }
|
||||
.mermaid-source pre {
|
||||
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 = '';
|
||||
}
|
||||
});
|
||||
14
packages/mermaid-renderer/manifest.json
Normal file
14
packages/mermaid-renderer/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "mermaid-renderer",
|
||||
"name": "Mermaid Diagrams",
|
||||
"version": "2.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Renders ```mermaid code blocks as interactive SVG diagrams with zoom/pan, SVG/PNG export, and source copy",
|
||||
"requires": ["chat"],
|
||||
"permissions": [],
|
||||
"tools": [],
|
||||
"surfaces": [],
|
||||
"settings": {}
|
||||
}
|
||||
102
packages/regex-tester/js/script.js
Normal file
102
packages/regex-tester/js/script.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// ==========================================
|
||||
// Regex Tester — Browser Extension
|
||||
// ==========================================
|
||||
// Tests regular expressions against input strings.
|
||||
// Registers the regex_test tool so the LLM can verify
|
||||
// patterns instead of guessing.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'regex-tester',
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
|
||||
ctx.tools.handle('regex_test', async (args) => {
|
||||
return self._test(args.pattern, args.flags || '', args.inputs || []);
|
||||
});
|
||||
},
|
||||
|
||||
_test(pattern, flags, inputs) {
|
||||
// Validate pattern
|
||||
let regex;
|
||||
try {
|
||||
regex = new RegExp(pattern, flags);
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid regex: ${e.message}`,
|
||||
pattern,
|
||||
flags,
|
||||
};
|
||||
}
|
||||
|
||||
const results = inputs.map(input => {
|
||||
// Reset lastIndex for global/sticky patterns
|
||||
regex.lastIndex = 0;
|
||||
|
||||
const isGlobal = regex.global;
|
||||
const matches = [];
|
||||
|
||||
if (isGlobal) {
|
||||
// Collect all matches for global flag
|
||||
let match;
|
||||
while ((match = regex.exec(input)) !== null) {
|
||||
matches.push(this._formatMatch(match));
|
||||
// Prevent infinite loops on zero-length matches
|
||||
if (match[0].length === 0) regex.lastIndex++;
|
||||
}
|
||||
} else {
|
||||
const match = regex.exec(input);
|
||||
if (match) {
|
||||
matches.push(this._formatMatch(match));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
input,
|
||||
matched: matches.length > 0,
|
||||
matchCount: matches.length,
|
||||
matches,
|
||||
};
|
||||
});
|
||||
|
||||
const totalMatched = results.filter(r => r.matched).length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pattern,
|
||||
flags,
|
||||
summary: `${totalMatched}/${inputs.length} inputs matched`,
|
||||
results,
|
||||
};
|
||||
},
|
||||
|
||||
_formatMatch(match) {
|
||||
const result = {
|
||||
fullMatch: match[0],
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
};
|
||||
|
||||
// Named groups (ES2018)
|
||||
if (match.groups) {
|
||||
result.namedGroups = {};
|
||||
for (const [name, value] of Object.entries(match.groups)) {
|
||||
result.namedGroups[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Numbered capture groups
|
||||
if (match.length > 1) {
|
||||
result.groups = [];
|
||||
for (let i = 1; i < match.length; i++) {
|
||||
result.groups.push(match[i] !== undefined ? match[i] : null);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
destroy() {}
|
||||
});
|
||||
39
packages/regex-tester/manifest.json
Normal file
39
packages/regex-tester/manifest.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "regex-tester",
|
||||
"name": "Regex Tester",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Tests regular expressions against input strings. Allows the LLM to verify regex patterns and see matches, groups, and indices.",
|
||||
"requires": ["chat"],
|
||||
"permissions": [],
|
||||
"tools": [
|
||||
{
|
||||
"name": "regex_test",
|
||||
"display_name": "Regex Tester",
|
||||
"description": "Test a regular expression against one or more input strings. Returns match details including captured groups, indices, and whether each string matched. Use this to verify regex patterns instead of guessing.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "The regular expression pattern (without delimiters), e.g. '^\\d{3}-\\d{4}$'"
|
||||
},
|
||||
"flags": {
|
||||
"type": "string",
|
||||
"description": "Regex flags, e.g. 'gi' for global+case-insensitive. Default: '' (no flags)"
|
||||
},
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Array of test strings to match against the pattern"
|
||||
}
|
||||
},
|
||||
"required": ["pattern", "inputs"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"surfaces": [],
|
||||
"settings": {}
|
||||
}
|
||||
Reference in New Issue
Block a user