plugin infrastruktur
This commit is contained in:
1 parent
1545b90f39
commit
3f4ba3c0f4
35 files changed
+1303
-195
No files matched your search
+3
-10
@@ -17,6 +17,7 @@ import { openGallery, closeGallery } from './modules/gallery.js';
|
||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
||||
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
|
||||
import { openPluginSettings } from './modules/plugin-settings.js';
|
||||
|
||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
||||
|
||||
@@ -28,6 +29,7 @@ window._closeTasks = closeTasks;
|
||||
window._renderBoard = renderBoard;
|
||||
window._openCalendar = openCalendar;
|
||||
window._triggerSync = triggerSync;
|
||||
window._openPluginSettings = openPluginSettings;
|
||||
window._loadCaldavSettings = loadCaldavSettings;
|
||||
window._loadRelaySettings = loadRelaySettings;
|
||||
window._renderTagBar = renderTagBar;
|
||||
@@ -277,13 +279,4 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
|
||||
|
||||
checkAuth();
|
||||
|
||||
// ── Test runner (lazy-loaded, no cost in production) ──
|
||||
if (new URLSearchParams(location.search).has('test') || false) {
|
||||
import('./test/tests.js');
|
||||
}
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault();
|
||||
import('./test/tests.js').then(() => import('./test/runner.js').then(m => m.open()));
|
||||
}
|
||||
});
|
||||
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
|
||||
@@ -0,0 +1,135 @@
|
||||
// ── hyprcrd Plugin API ──
|
||||
//
|
||||
// Loaded synchronously BEFORE any module script. Plugins and core both
|
||||
// register against window.hyprcrd. Plugins MUST NOT import from core
|
||||
// modules — use this global API instead so they remain decoupled.
|
||||
//
|
||||
// Available namespaces:
|
||||
// hyprcrd.hooks — generic event bus (on/emit)
|
||||
// hyprcrd.editor — toolbar command registry
|
||||
// hyprcrd.markdown — fenced block + post-processing registry
|
||||
// hyprcrd.util — DOM helpers exposed for plugin use
|
||||
//
|
||||
// Plugins are loaded as <script type="module"> tags after this script
|
||||
// but before app.js. They self-register on module evaluation; the core
|
||||
// then queries the registries when needed.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Generic event bus ───────────────────────────────────────────
|
||||
const hookListeners = Object.create(null);
|
||||
const hooks = {
|
||||
on(name, fn) {
|
||||
if (typeof fn !== 'function') return;
|
||||
(hookListeners[name] ||= []).push(fn);
|
||||
},
|
||||
off(name, fn) {
|
||||
const arr = hookListeners[name];
|
||||
if (!arr) return;
|
||||
const idx = arr.indexOf(fn);
|
||||
if (idx >= 0) arr.splice(idx, 1);
|
||||
},
|
||||
emit(name, data) {
|
||||
const arr = hookListeners[name];
|
||||
if (!arr) return data;
|
||||
for (const fn of arr.slice()) {
|
||||
try {
|
||||
const r = fn(data);
|
||||
if (r !== undefined) data = r;
|
||||
} catch (err) {
|
||||
console.error(`[hyprcrd hook ${name}]`, err);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Editor command registry ─────────────────────────────────────
|
||||
// Toolbar buttons with `data-md="<name>"` dispatch to handlers
|
||||
// registered here. Plugins inject buttons + register handlers.
|
||||
const editorCommands = Object.create(null);
|
||||
const editor = {
|
||||
registerCommand(name, fn) {
|
||||
if (typeof fn !== 'function') return;
|
||||
editorCommands[name] = fn;
|
||||
},
|
||||
unregisterCommand(name) {
|
||||
delete editorCommands[name];
|
||||
},
|
||||
getCommand(name) {
|
||||
return editorCommands[name];
|
||||
},
|
||||
// Set by editor.js after init — gives plugins access to
|
||||
// insertMd(before, after, placeholder) and the textarea body
|
||||
// without forcing an import dependency.
|
||||
api: null,
|
||||
};
|
||||
|
||||
// ── Markdown fenced block registry ──────────────────────────────
|
||||
// Each entry: { lang: string, handler: (content) => string }
|
||||
// The handler receives the raw text inside ```<lang> ... ``` and
|
||||
// must return safe HTML (it will pass through DOMPurify after).
|
||||
const fencedBlocks = [];
|
||||
// Pre/post processors run on the full source text, before/after
|
||||
// marked.parse(). Useful for non-fenced syntax extensions.
|
||||
const preProcessors = [];
|
||||
const postProcessors = [];
|
||||
const markdown = {
|
||||
registerFencedBlock(lang, handler) {
|
||||
if (typeof handler !== 'function') return;
|
||||
fencedBlocks.push({ lang, handler });
|
||||
},
|
||||
getFencedBlocks() {
|
||||
return fencedBlocks.slice();
|
||||
},
|
||||
registerPreProcessor(fn) {
|
||||
if (typeof fn === 'function') preProcessors.push(fn);
|
||||
},
|
||||
getPreProcessors() {
|
||||
return preProcessors.slice();
|
||||
},
|
||||
registerPostProcessor(fn) {
|
||||
if (typeof fn === 'function') postProcessors.push(fn);
|
||||
},
|
||||
getPostProcessors() {
|
||||
return postProcessors.slice();
|
||||
},
|
||||
};
|
||||
|
||||
// ── Util helpers (mirror of internal util.js) ───────────────────
|
||||
const util = {
|
||||
$(idOrSel) {
|
||||
if (typeof idOrSel !== 'string') return null;
|
||||
if (idOrSel.startsWith('#') || idOrSel.includes(' ') || idOrSel.includes('.') || idOrSel.includes('[')) {
|
||||
return document.querySelector(idOrSel);
|
||||
}
|
||||
return document.getElementById(idOrSel);
|
||||
},
|
||||
$$(sel) {
|
||||
return Array.from(document.querySelectorAll(sel));
|
||||
},
|
||||
esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
},
|
||||
};
|
||||
|
||||
// ── Plugin metadata registry ────────────────────────────────────
|
||||
// Plugins can register themselves here for introspection / debug.
|
||||
const plugins = [];
|
||||
const registerPlugin = (meta) => {
|
||||
if (meta && meta.name) plugins.push(meta);
|
||||
};
|
||||
|
||||
window.hyprcrd = {
|
||||
hooks,
|
||||
editor,
|
||||
markdown,
|
||||
util,
|
||||
plugins,
|
||||
registerPlugin,
|
||||
version: '1.0',
|
||||
};
|
||||
})();
|
||||
@@ -1,509 +0,0 @@
|
||||
// ── ASCII Art Canvas ──
|
||||
|
||||
import { $ } from './util.js';
|
||||
|
||||
const overlay = $('ascii-canvas-overlay');
|
||||
const grid = $('ascii-canvas-grid');
|
||||
const toolbar = $('ascii-canvas-toolbar');
|
||||
const charInput = $('ascii-char-input');
|
||||
const widthInput = $('ascii-canvas-width');
|
||||
const heightInput = $('ascii-canvas-height');
|
||||
const btnOk = $('ascii-canvas-ok');
|
||||
const btnCancel = $('ascii-canvas-cancel');
|
||||
const btnClear = $('ascii-canvas-clear');
|
||||
const btnResize = $('ascii-canvas-resize');
|
||||
const coordsDisplay = $('ascii-canvas-coords');
|
||||
|
||||
let data = []; // 2D array [row][col]
|
||||
let cols = 40;
|
||||
let rows = 20;
|
||||
let tool = 'brush'; // brush | eraser | line | rect | circle | fill | text
|
||||
let char = '#';
|
||||
let drawing = false;
|
||||
let startCell = null; // {r, c} for shape tools
|
||||
let previewCells = []; // cells to highlight during drag
|
||||
let onInsert = null; // callback(text)
|
||||
let cellW = 0, cellH = 0;
|
||||
let gridRect = null;
|
||||
|
||||
// ── Grid init ──
|
||||
|
||||
function initGrid(w, h) {
|
||||
cols = w; rows = h;
|
||||
data = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
data.push(new Array(cols).fill(' '));
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const lines = data.map(row => row.join(''));
|
||||
grid.textContent = lines.join('\n');
|
||||
measureCells();
|
||||
}
|
||||
|
||||
function measureCells() {
|
||||
if (!grid.firstChild) return;
|
||||
// Use getBoundingClientRect on the grid (inline-block = content-sized)
|
||||
gridRect = grid.getBoundingClientRect();
|
||||
cellW = gridRect.width / cols;
|
||||
cellH = gridRect.height / rows;
|
||||
}
|
||||
|
||||
function cellFromEvent(e) {
|
||||
// Always re-measure to account for scroll/layout changes
|
||||
gridRect = grid.getBoundingClientRect();
|
||||
if (!gridRect || !cellW || !cellH) return null;
|
||||
const x = e.clientX - gridRect.left;
|
||||
const y = e.clientY - gridRect.top;
|
||||
const c = Math.floor(x / cellW);
|
||||
const r = Math.floor(y / cellH);
|
||||
if (r < 0 || r >= rows || c < 0 || c >= cols) return null;
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
// ── Drawing algorithms ──
|
||||
|
||||
function setCell(r, c, ch) {
|
||||
if (r >= 0 && r < rows && c >= 0 && c < cols) {
|
||||
data[r][c] = ch;
|
||||
}
|
||||
}
|
||||
|
||||
function bresenhamLine(r0, c0, r1, c1) {
|
||||
const cells = [];
|
||||
let dr = Math.abs(r1 - r0), dc = Math.abs(c1 - c0);
|
||||
let sr = r0 < r1 ? 1 : -1, sc = c0 < c1 ? 1 : -1;
|
||||
let err = dc - dr;
|
||||
let r = r0, c = c0;
|
||||
while (true) {
|
||||
cells.push({ r, c });
|
||||
if (r === r1 && c === c1) break;
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dr) { err -= dr; c += sc; }
|
||||
if (e2 < dc) { err += dc; r += sr; }
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function rectCells(r0, c0, r1, c1) {
|
||||
const cells = [];
|
||||
const rMin = Math.min(r0, r1), rMax = Math.max(r0, r1);
|
||||
const cMin = Math.min(c0, c1), cMax = Math.max(c0, c1);
|
||||
for (let c = cMin; c <= cMax; c++) { cells.push({ r: rMin, c }); cells.push({ r: rMax, c }); }
|
||||
for (let r = rMin + 1; r < rMax; r++) { cells.push({ r, c: cMin }); cells.push({ r, c: cMax }); }
|
||||
return cells;
|
||||
}
|
||||
|
||||
function circleCells(r0, c0, r1, c1) {
|
||||
const cells = [];
|
||||
const cr = (r0 + r1) / 2, cc = (c0 + c1) / 2;
|
||||
const rx = Math.abs(c1 - c0) / 2, ry = Math.abs(r1 - r0) / 2;
|
||||
if (rx < 1 && ry < 1) { cells.push({ r: Math.round(cr), c: Math.round(cc) }); return cells; }
|
||||
// Parametric ellipse
|
||||
const steps = Math.max(60, Math.round(Math.max(rx, ry) * 4));
|
||||
const seen = new Set();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = (2 * Math.PI * i) / steps;
|
||||
const r = Math.round(cr + ry * Math.sin(a));
|
||||
const c = Math.round(cc + rx * Math.cos(a));
|
||||
const key = r + ',' + c;
|
||||
if (!seen.has(key)) { seen.add(key); cells.push({ r, c }); }
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function floodFill(r, c, ch) {
|
||||
const target = data[r]?.[c];
|
||||
if (target === undefined || target === ch) return;
|
||||
const stack = [{ r, c }];
|
||||
while (stack.length) {
|
||||
const { r: cr, c: cc } = stack.pop();
|
||||
if (cr < 0 || cr >= rows || cc < 0 || cc >= cols) continue;
|
||||
if (data[cr][cc] !== target) continue;
|
||||
data[cr][cc] = ch;
|
||||
stack.push({ r: cr - 1, c: cc }, { r: cr + 1, c: cc }, { r: cr, c: cc - 1 }, { r: cr, c: cc + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering with preview overlay ──
|
||||
|
||||
function renderWithPreview(cells, ch) {
|
||||
const preview = new Map();
|
||||
for (const { r, c } of cells) {
|
||||
if (r >= 0 && r < rows && c >= 0 && c < cols) {
|
||||
preview.set(r + ',' + c, ch);
|
||||
}
|
||||
}
|
||||
const lines = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = '';
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const key = r + ',' + c;
|
||||
line += preview.has(key) ? preview.get(key) : data[r][c];
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
grid.textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Mouse handlers ──
|
||||
|
||||
function onMouseDown(e) {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
measureCells();
|
||||
const cell = cellFromEvent(e);
|
||||
if (!cell) return;
|
||||
drawing = true;
|
||||
startCell = cell;
|
||||
|
||||
if (tool === 'brush') {
|
||||
setCell(cell.r, cell.c, char);
|
||||
render();
|
||||
} else if (tool === 'eraser') {
|
||||
setCell(cell.r, cell.c, ' ');
|
||||
render();
|
||||
} else if (tool === 'fill') {
|
||||
floodFill(cell.r, cell.c, char);
|
||||
render();
|
||||
drawing = false;
|
||||
} else if (tool === 'text') {
|
||||
// Start typing at this position
|
||||
startTextMode(cell.r, cell.c);
|
||||
drawing = false;
|
||||
}
|
||||
updateCoords(cell);
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
const cell = cellFromEvent(e);
|
||||
if (cell) updateCoords(cell);
|
||||
if (!drawing || !cell) return;
|
||||
|
||||
if (tool === 'brush') {
|
||||
// Draw line from last position for smooth strokes
|
||||
if (startCell) {
|
||||
const cells = bresenhamLine(startCell.r, startCell.c, cell.r, cell.c);
|
||||
for (const { r, c } of cells) setCell(r, c, char);
|
||||
startCell = cell;
|
||||
}
|
||||
render();
|
||||
} else if (tool === 'eraser') {
|
||||
if (startCell) {
|
||||
const cells = bresenhamLine(startCell.r, startCell.c, cell.r, cell.c);
|
||||
for (const { r, c } of cells) setCell(r, c, ' ');
|
||||
startCell = cell;
|
||||
}
|
||||
render();
|
||||
} else if (tool === 'line') {
|
||||
const cells = bresenhamLine(startCell.r, startCell.c, cell.r, cell.c);
|
||||
renderWithPreview(cells, char);
|
||||
} else if (tool === 'rect') {
|
||||
const cells = rectCells(startCell.r, startCell.c, cell.r, cell.c);
|
||||
renderWithPreview(cells, char);
|
||||
} else if (tool === 'circle') {
|
||||
const cells = circleCells(startCell.r, startCell.c, cell.r, cell.c);
|
||||
renderWithPreview(cells, char);
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp(e) {
|
||||
if (!drawing) return;
|
||||
const cell = cellFromEvent(e);
|
||||
if (!cell) { drawing = false; return; }
|
||||
|
||||
if (tool === 'line') {
|
||||
for (const p of bresenhamLine(startCell.r, startCell.c, cell.r, cell.c)) setCell(p.r, p.c, char);
|
||||
} else if (tool === 'rect') {
|
||||
for (const p of rectCells(startCell.r, startCell.c, cell.r, cell.c)) setCell(p.r, p.c, char);
|
||||
} else if (tool === 'circle') {
|
||||
for (const p of circleCells(startCell.r, startCell.c, cell.r, cell.c)) setCell(p.r, p.c, char);
|
||||
}
|
||||
drawing = false;
|
||||
render();
|
||||
}
|
||||
|
||||
// ── Text mode ──
|
||||
|
||||
let textCursor = null;
|
||||
|
||||
function startTextMode(r, c) {
|
||||
textCursor = { r, c };
|
||||
renderTextCursor();
|
||||
}
|
||||
|
||||
function renderTextCursor() {
|
||||
if (!textCursor) { render(); return; }
|
||||
// Show cursor as inverted cell
|
||||
const lines = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = '';
|
||||
for (let c = 0; c < cols; c++) {
|
||||
line += data[r][c];
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
// Build HTML with cursor highlight
|
||||
let html = '';
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const ch = data[r][c] === ' ' ? '\u00a0' : data[r][c];
|
||||
if (r === textCursor.r && c === textCursor.c) {
|
||||
html += `<span class="ascii-cursor">${ch}</span>`;
|
||||
} else {
|
||||
html += escChar(data[r][c]);
|
||||
}
|
||||
}
|
||||
if (r < rows - 1) html += '\n';
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
function escChar(ch) {
|
||||
if (ch === '<') return '<';
|
||||
if (ch === '>') return '>';
|
||||
if (ch === '&') return '&';
|
||||
if (ch === ' ') return ' ';
|
||||
return ch;
|
||||
}
|
||||
|
||||
function handleTextKey(e) {
|
||||
if (!textCursor) return false;
|
||||
if (e.key === 'Escape') {
|
||||
textCursor = null;
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') { textCursor.c = Math.max(0, textCursor.c - 1); renderTextCursor(); return true; }
|
||||
if (e.key === 'ArrowRight') { textCursor.c = Math.min(cols - 1, textCursor.c + 1); renderTextCursor(); return true; }
|
||||
if (e.key === 'ArrowUp') { textCursor.r = Math.max(0, textCursor.r - 1); renderTextCursor(); return true; }
|
||||
if (e.key === 'ArrowDown') { textCursor.r = Math.min(rows - 1, textCursor.r + 1); renderTextCursor(); return true; }
|
||||
if (e.key === 'Backspace') {
|
||||
textCursor.c = Math.max(0, textCursor.c - 1);
|
||||
setCell(textCursor.r, textCursor.c, ' ');
|
||||
renderTextCursor();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
textCursor.r = Math.min(rows - 1, textCursor.r + 1);
|
||||
textCursor.c = 0;
|
||||
renderTextCursor();
|
||||
return true;
|
||||
}
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||
setCell(textCursor.r, textCursor.c, e.key);
|
||||
textCursor.c = Math.min(cols - 1, textCursor.c + 1);
|
||||
renderTextCursor();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Coords display ──
|
||||
|
||||
function updateCoords(cell) {
|
||||
if (coordsDisplay) coordsDisplay.textContent = `${cell.c + 1}:${cell.r + 1}`;
|
||||
}
|
||||
|
||||
// ── Tool selection ──
|
||||
|
||||
function selectTool(t) {
|
||||
tool = t;
|
||||
textCursor = null;
|
||||
render();
|
||||
toolbar.querySelectorAll('[data-tool]').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.tool === t);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Character palette ──
|
||||
|
||||
const CHAR_SETS = {
|
||||
basic: ['#', '*', '.', '+', '-', '|', '/', '\\', '=', '_', '~', ':', '^', '@', 'o', 'x'],
|
||||
box: ['─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼'],
|
||||
boxDouble: ['═', '║', '╔', '╗', '╚', '╝', '╠', '╣', '╦', '╩', '╬'],
|
||||
blocks: ['█', '▓', '▒', '░', '▄', '▀', '▌', '▐'],
|
||||
arrows: ['←', '→', '↑', '↓', '↔', '↕', '►', '◄', '▲', '▼'],
|
||||
};
|
||||
|
||||
function buildPalette() {
|
||||
const palette = $('ascii-char-palette');
|
||||
if (!palette) return;
|
||||
palette.innerHTML = '';
|
||||
for (const [group, chars] of Object.entries(CHAR_SETS)) {
|
||||
const grp = document.createElement('div');
|
||||
grp.className = 'ascii-char-group';
|
||||
for (const ch of chars) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ascii-char-btn';
|
||||
btn.textContent = ch;
|
||||
btn.title = ch;
|
||||
btn.addEventListener('click', () => {
|
||||
char = ch;
|
||||
charInput.value = ch;
|
||||
palette.querySelectorAll('.ascii-char-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
});
|
||||
if (ch === char) btn.classList.add('active');
|
||||
grp.appendChild(btn);
|
||||
}
|
||||
palette.appendChild(grp);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Export ──
|
||||
|
||||
function exportText() {
|
||||
// Trim trailing spaces per line, remove trailing empty lines
|
||||
const lines = data.map(row => row.join('').replace(/\s+$/, ''));
|
||||
while (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Open / Close ──
|
||||
|
||||
export function openCanvas(callback, existingText) {
|
||||
onInsert = callback;
|
||||
if (existingText) {
|
||||
// Parse existing canvas content
|
||||
const lines = existingText.split('\n');
|
||||
rows = Math.max(lines.length, 5);
|
||||
cols = Math.max(...lines.map(l => l.length), 20);
|
||||
heightInput.value = rows;
|
||||
widthInput.value = cols;
|
||||
data = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row = [];
|
||||
const line = lines[r] || '';
|
||||
for (let c = 0; c < cols; c++) {
|
||||
row.push(c < line.length ? line[c] : ' ');
|
||||
}
|
||||
data.push(row);
|
||||
}
|
||||
} else {
|
||||
// Auto-fit to available grid container space
|
||||
overlay.classList.add('open');
|
||||
const container = grid.parentElement;
|
||||
requestAnimationFrame(() => {
|
||||
const style = getComputedStyle(grid);
|
||||
// Measure one character by rendering a single char
|
||||
grid.textContent = 'M';
|
||||
const charW = grid.getBoundingClientRect().width;
|
||||
const charH = grid.getBoundingClientRect().height;
|
||||
grid.textContent = '';
|
||||
if (charW > 0 && charH > 0) {
|
||||
const pad = parseFloat(getComputedStyle(container).paddingLeft) * 2;
|
||||
cols = Math.max(20, Math.floor((container.clientWidth - pad) / charW));
|
||||
rows = Math.max(5, Math.floor((container.clientHeight - pad) / charH));
|
||||
} else {
|
||||
cols = 80;
|
||||
rows = 30;
|
||||
}
|
||||
widthInput.value = cols;
|
||||
heightInput.value = rows;
|
||||
initGrid(cols, rows);
|
||||
buildPalette();
|
||||
selectTool('brush');
|
||||
measureCells();
|
||||
});
|
||||
return;
|
||||
}
|
||||
buildPalette();
|
||||
selectTool('brush');
|
||||
overlay.classList.add('open');
|
||||
render();
|
||||
requestAnimationFrame(() => measureCells());
|
||||
}
|
||||
|
||||
function closeCanvas() {
|
||||
overlay.classList.remove('open');
|
||||
textCursor = null;
|
||||
drawing = false;
|
||||
onInsert = null;
|
||||
}
|
||||
|
||||
// ── Event binding ──
|
||||
|
||||
function init() {
|
||||
if (!overlay) return;
|
||||
|
||||
grid.addEventListener('mousedown', onMouseDown);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Keyboard for text tool + tool shortcuts
|
||||
overlay.addEventListener('keydown', e => {
|
||||
if (textCursor && handleTextKey(e)) { e.preventDefault(); e.stopPropagation(); return; }
|
||||
if (e.key === 'Escape') { closeCanvas(); e.stopPropagation(); return; }
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
const shortcuts = { b: 'brush', e: 'eraser', l: 'line', r: 'rect', o: 'circle', f: 'fill', t: 'text' };
|
||||
if (shortcuts[e.key]) { selectTool(shortcuts[e.key]); e.preventDefault(); }
|
||||
});
|
||||
|
||||
// Tool buttons
|
||||
toolbar.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-tool]');
|
||||
if (btn) selectTool(btn.dataset.tool);
|
||||
});
|
||||
|
||||
// Char input
|
||||
charInput.addEventListener('input', () => {
|
||||
if (charInput.value.length > 0) {
|
||||
char = charInput.value[charInput.value.length - 1];
|
||||
charInput.value = char;
|
||||
// Update palette highlight
|
||||
const palette = $('ascii-char-palette');
|
||||
palette?.querySelectorAll('.ascii-char-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.textContent === char);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// OK button
|
||||
btnOk.addEventListener('click', () => {
|
||||
const text = exportText();
|
||||
if (onInsert && text.trim()) onInsert(text);
|
||||
closeCanvas();
|
||||
});
|
||||
|
||||
// Cancel
|
||||
btnCancel.addEventListener('click', closeCanvas);
|
||||
|
||||
// Clear
|
||||
btnClear.addEventListener('click', () => {
|
||||
initGrid(cols, rows);
|
||||
});
|
||||
|
||||
// Resize
|
||||
btnResize.addEventListener('click', () => {
|
||||
const newW = parseInt(widthInput.value) || cols;
|
||||
const newH = parseInt(heightInput.value) || rows;
|
||||
// Preserve existing content
|
||||
const oldData = data;
|
||||
const oldRows = rows, oldCols = cols;
|
||||
cols = Math.max(5, Math.min(200, newW));
|
||||
rows = Math.max(3, Math.min(100, newH));
|
||||
widthInput.value = cols;
|
||||
heightInput.value = rows;
|
||||
data = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
row.push(r < oldRows && c < oldCols ? oldData[r][c] : ' ');
|
||||
}
|
||||
data.push(row);
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
// Close on overlay background click
|
||||
overlay.addEventListener('click', e => {
|
||||
if (e.target === overlay) closeCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
export { closeCanvas };
|
||||
+63
-79
@@ -4,7 +4,6 @@ import { $, esc, api, escRe, getCsrf, fuzzyMatch } from './util.js';
|
||||
import { state, getChildren, CARD_COLORS } from './state.js';
|
||||
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||
import { pushCardEdit, snapshotCard } from './undo.js';
|
||||
import { openCanvas } from './canvas.js';
|
||||
|
||||
const modal = $('modal');
|
||||
const body = $('editor-body');
|
||||
@@ -442,87 +441,72 @@ function insertMd(before, after = '', placeholder = '') {
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// ── Built-in editor commands ──
|
||||
// All commands flow through the plugin command registry. Plugins can
|
||||
// register their own data-md="..." commands via window.hyprcrd.editor.
|
||||
const _hyprEd = (typeof window !== 'undefined' && window.hyprcrd?.editor) || null;
|
||||
const _registerCmd = (name, fn) => {
|
||||
if (_hyprEd) _hyprEd.registerCommand(name, fn);
|
||||
};
|
||||
|
||||
_registerCmd('heading', () => insertMd('## ', '', 'Heading'));
|
||||
_registerCmd('bold', () => insertMd('**', '**', 'bold'));
|
||||
_registerCmd('italic', () => insertMd('*', '*', 'italic'));
|
||||
_registerCmd('strikethrough', () => insertMd('~~', '~~', 'text'));
|
||||
_registerCmd('code', () => insertMd('`', '`', 'code'));
|
||||
_registerCmd('codeblock', () => insertMd('```\n', '\n```', 'code'));
|
||||
_registerCmd('ul', () => insertMd('- ', '', 'item'));
|
||||
_registerCmd('ol', () => insertMd('1. ', '', 'item'));
|
||||
_registerCmd('tree', () => insertMd('```tree\n- ', '\n - child\n', 'parent'));
|
||||
_registerCmd('quote', () => insertMd('> ', '', 'quote'));
|
||||
_registerCmd('todo', () => {
|
||||
const s = body.selectionStart, e = body.selectionEnd;
|
||||
const sel = body.value.substring(s, e);
|
||||
if (sel && sel.includes('\n') || (sel && /^[\s]*[-*+]\s/m.test(sel))) {
|
||||
const lines = sel.split('\n');
|
||||
const hasTodos = lines.some(l => /^\s*- \[[ x]\]/.test(l));
|
||||
const converted = lines.map(l => {
|
||||
const trimmed = l.trim();
|
||||
if (!trimmed) return l;
|
||||
if (hasTodos) return l.replace(/^(\s*)- \[[ x]\] /, '$1- ');
|
||||
return l.replace(/^(\s*)(?:[-*+]|\d+\.)\s/, '$1').replace(/^(\s*)(.+)$/, '$1- [ ] $2');
|
||||
}).join('\n');
|
||||
body.setRangeText(converted, s, e, 'select');
|
||||
body.selectionStart = s;
|
||||
body.selectionEnd = s + converted.length;
|
||||
updatePreview();
|
||||
} else {
|
||||
insertMd('- [ ] ', '', 'task');
|
||||
}
|
||||
});
|
||||
_registerCmd('link', () => insertMd('[', '](url)', 'text'));
|
||||
_registerCmd('cardlink', () => { insertMd('[[', ']]', ''); showLinkAc(''); });
|
||||
_registerCmd('extract', async () => {
|
||||
const sel = body.value.substring(body.selectionStart, body.selectionEnd);
|
||||
if (!sel.trim()) return;
|
||||
const title = sel.trim().split('\n')[0].replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim();
|
||||
await window._extractToCard(sel);
|
||||
const link = `[[${title}]]`;
|
||||
body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
|
||||
updatePreview();
|
||||
});
|
||||
_registerCmd('hr', () => insertMd('\n---\n'));
|
||||
|
||||
// Expose editor API to plugins (textarea, insertMd, preview refresh)
|
||||
if (_hyprEd) {
|
||||
_hyprEd.api = {
|
||||
body,
|
||||
insertMd,
|
||||
updatePreview,
|
||||
};
|
||||
}
|
||||
|
||||
// Toolbar dispatch routes through the plugin registry
|
||||
$('md-toolbar')?.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-md]');
|
||||
if (!btn) return;
|
||||
const cmds = {
|
||||
heading: () => insertMd('## ', '', 'Heading'), bold: () => insertMd('**', '**', 'bold'),
|
||||
italic: () => insertMd('*', '*', 'italic'), strikethrough: () => insertMd('~~', '~~', 'text'),
|
||||
code: () => insertMd('`', '`', 'code'), codeblock: () => insertMd('```\n', '\n```', 'code'),
|
||||
ul: () => insertMd('- ', '', 'item'), ol: () => insertMd('1. ', '', 'item'),
|
||||
tree: () => insertMd('```tree\n- ', '\n - child\n', 'parent'),
|
||||
quote: () => insertMd('> ', '', 'quote'),
|
||||
todo: () => {
|
||||
const s = body.selectionStart, e = body.selectionEnd;
|
||||
const sel = body.value.substring(s, e);
|
||||
if (sel && sel.includes('\n') || (sel && /^[\s]*[-*+]\s/m.test(sel))) {
|
||||
// Multi-line or bullet list: convert each line
|
||||
const lines = sel.split('\n');
|
||||
const hasTodos = lines.some(l => /^\s*- \[[ x]\]/.test(l));
|
||||
const converted = lines.map(l => {
|
||||
const trimmed = l.trim();
|
||||
if (!trimmed) return l;
|
||||
if (hasTodos) {
|
||||
// Toggle back: remove checkbox
|
||||
return l.replace(/^(\s*)- \[[ x]\] /, '$1- ');
|
||||
}
|
||||
// Add checkbox: strip existing bullet if present
|
||||
return l.replace(/^(\s*)(?:[-*+]|\d+\.)\s/, '$1').replace(/^(\s*)(.+)$/, '$1- [ ] $2');
|
||||
}).join('\n');
|
||||
body.setRangeText(converted, s, e, 'select');
|
||||
body.selectionStart = s;
|
||||
body.selectionEnd = s + converted.length;
|
||||
updatePreview();
|
||||
} else {
|
||||
insertMd('- [ ] ', '', 'task');
|
||||
}
|
||||
},
|
||||
link: () => insertMd('[', '](url)', 'text'),
|
||||
cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); },
|
||||
extract: async () => {
|
||||
const sel = body.value.substring(body.selectionStart, body.selectionEnd);
|
||||
if (!sel.trim()) return;
|
||||
const title = sel.trim().split('\n')[0].replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim();
|
||||
await window._extractToCard(sel);
|
||||
// Replace selection with a card link
|
||||
const link = `[[${title}]]`;
|
||||
body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
|
||||
updatePreview();
|
||||
},
|
||||
hr: () => insertMd('\n---\n'),
|
||||
asciiart: () => {
|
||||
// Check if cursor is inside an existing ```ascii block to edit it
|
||||
const val = body.value;
|
||||
const pos = body.selectionStart;
|
||||
const before = val.substring(0, pos);
|
||||
const after = val.substring(pos);
|
||||
const blockStart = before.lastIndexOf('```ascii\n');
|
||||
const blockEndInBefore = blockStart >= 0 ? before.indexOf('\n```', blockStart + 9) : -1;
|
||||
let existingText = null, replaceStart = 0, replaceEnd = 0;
|
||||
if (blockStart >= 0 && blockEndInBefore < 0) {
|
||||
// Cursor is inside a ```ascii block
|
||||
const contentStart = blockStart + 9; // after "```ascii\n"
|
||||
const endMark = val.indexOf('\n```', contentStart);
|
||||
if (endMark >= 0) {
|
||||
existingText = val.substring(contentStart, endMark);
|
||||
replaceStart = blockStart;
|
||||
replaceEnd = endMark + 4;
|
||||
}
|
||||
}
|
||||
openCanvas(text => {
|
||||
const block = '```ascii\n' + text + '\n```';
|
||||
if (existingText !== null) {
|
||||
body.setRangeText(block, replaceStart, replaceEnd, 'end');
|
||||
} else {
|
||||
const s = body.selectionStart;
|
||||
const prefix = s > 0 && val[s - 1] !== '\n' ? '\n' : '';
|
||||
body.setRangeText(prefix + block + '\n', s, body.selectionEnd, 'end');
|
||||
}
|
||||
updatePreview();
|
||||
}, existingText);
|
||||
},
|
||||
};
|
||||
cmds[btn.dataset.md]?.();
|
||||
const fn = _hyprEd?.getCommand(btn.dataset.md);
|
||||
if (fn) fn();
|
||||
});
|
||||
|
||||
const editorDivider = $('editor-divider');
|
||||
|
||||
@@ -11,6 +11,32 @@ marked.use({ breaks: true, gfm: true, renderer: {
|
||||
}
|
||||
}});
|
||||
|
||||
// ── Built-in fenced block handlers ──
|
||||
// Registered through the same plugin API so plugins and core use one path.
|
||||
if (typeof window !== 'undefined' && window.hyprcrd?.markdown) {
|
||||
const md = window.hyprcrd.markdown;
|
||||
|
||||
// ```gallery — image grid
|
||||
md.registerFencedBlock('gallery', (content, { esc: e }) => {
|
||||
const imgs = [];
|
||||
const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
let m;
|
||||
while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] });
|
||||
if (!imgs.length) return '';
|
||||
return '<div class="md-gallery">' + imgs.map(i =>
|
||||
`<div class="md-gallery-item"><img src="${i.src}" alt="${e(i.alt)}" loading="lazy"></div>`
|
||||
).join('') + '</div>';
|
||||
});
|
||||
|
||||
// ```tree / ```bullets — custom list rendering (handled via post-processor)
|
||||
const listHandler = (kind) => (content, { normalizeListBlock: norm }) => {
|
||||
const normalized = norm(content);
|
||||
return `\n<!-- list:${kind} -->\n${normalized}\n`;
|
||||
};
|
||||
md.registerFencedBlock('tree', listHandler('tree'));
|
||||
md.registerFencedBlock('bullets', listHandler('bullets'));
|
||||
}
|
||||
|
||||
const purifyConfig = {
|
||||
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'],
|
||||
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel','align'],
|
||||
@@ -79,27 +105,32 @@ export function renderMd(src) {
|
||||
'</tbody></table>\n';
|
||||
});
|
||||
|
||||
// ── Fenced block registry ─────────────────────────────────────
|
||||
// Apply all registered ```<lang> handlers (built-in + plugins).
|
||||
// Plugins register via window.hyprcrd.markdown.registerFencedBlock().
|
||||
const fencedBlocks = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getFencedBlocks)
|
||||
? window.hyprcrd.markdown.getFencedBlocks()
|
||||
: [];
|
||||
for (const { lang, handler } of fencedBlocks) {
|
||||
// Escape regex metachars in lang (allow simple identifiers)
|
||||
const safeLang = String(lang).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp('```' + safeLang + '\\s*\\n([\\s\\S]*?)(?:```|$)', 'g');
|
||||
processed = processed.replace(re, (_, content) => {
|
||||
try { return handler(content, { esc, normalizeListBlock }); }
|
||||
catch (err) { console.error(`[markdown fenced ${lang}]`, err); return ''; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Plugin pre-processors ─────────────────────────────────────
|
||||
const preProcessors = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getPreProcessors)
|
||||
? window.hyprcrd.markdown.getPreProcessors()
|
||||
: [];
|
||||
for (const fn of preProcessors) {
|
||||
try { processed = fn(processed) ?? processed; }
|
||||
catch (err) { console.error('[markdown pre]', err); }
|
||||
}
|
||||
|
||||
processed = processed
|
||||
// ASCII canvas block: ```ascii
|
||||
.replace(/```ascii\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
|
||||
return `<pre class="ascii-art">${esc(content.replace(/\s+$/, ''))}</pre>`;
|
||||
})
|
||||
// Gallery fenced block: ```gallery
|
||||
.replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
|
||||
const imgs = [];
|
||||
const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
let m;
|
||||
while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] });
|
||||
if (!imgs.length) return '';
|
||||
return '<div class="md-gallery">' + imgs.map(i =>
|
||||
`<div class="md-gallery-item"><img src="${i.src}" alt="${esc(i.alt)}" loading="lazy"></div>`
|
||||
).join('') + '</div>';
|
||||
})
|
||||
// Custom fenced list blocks: ```tree / ```bullets with or without closing ```
|
||||
.replace(/```(tree|bullets)\s*\n([\s\S]*?)(?:```|$)/g, (_, kind, content) => {
|
||||
const normalized = normalizeListBlock(content);
|
||||
return `\n<!-- list:${kind} -->\n${normalized}\n`;
|
||||
})
|
||||
// Help parser: ensure a list can start after text without requiring manual blank line
|
||||
.replace(/([^\n])\n(?=(?: {0,3}[-*+]\s| {0,3}\d+\.\s))/g, '$1\n\n')
|
||||
// Preserve extra blank lines as <br> spacers (3+ newlines = 1+ extra blank lines)
|
||||
@@ -124,6 +155,15 @@ export function renderMd(src) {
|
||||
html = html.replace(/<!--\s*list:tree\s*-->\s*<ul>/g, '<ul class="tree-list">');
|
||||
html = html.replace(/<!--\s*list:bullets\s*-->\s*<ul>/g, '<ul class="bullet-list">');
|
||||
|
||||
// ── Plugin post-processors (run on HTML, before sanitize) ──
|
||||
const postProcessors = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getPostProcessors)
|
||||
? window.hyprcrd.markdown.getPostProcessors()
|
||||
: [];
|
||||
for (const fn of postProcessors) {
|
||||
try { html = fn(html) ?? html; }
|
||||
catch (err) { console.error('[markdown post]', err); }
|
||||
}
|
||||
|
||||
html = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html, purifyConfig) : html;
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// ── Plugin Settings UI ──
|
||||
// Renders the list of installed plugins with on/off toggles in the
|
||||
// settings overlay. Disabled plugins are skipped at PHP render time;
|
||||
// toggling requires a reload to take effect (page must re-fetch the
|
||||
// new script/css/html tag set).
|
||||
|
||||
import { $, esc, api } from './util.js';
|
||||
|
||||
let pluginsCache = null;
|
||||
let dirtyAfterToggle = false;
|
||||
|
||||
async function fetchPlugins() {
|
||||
const res = await fetch('api/plugins');
|
||||
if (!res.ok) throw new Error('failed to load plugins');
|
||||
pluginsCache = await res.json();
|
||||
return pluginsCache;
|
||||
}
|
||||
|
||||
function render(list) {
|
||||
const container = $('plugins-list');
|
||||
if (!container) return;
|
||||
if (!list.length) {
|
||||
container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = list.map(p => `
|
||||
<div class="flex items-start gap-3 px-3 py-2 border border-t-border bg-t-bg rounded-sm" data-plugin="${esc(p.name)}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-pixel text-[11px] text-t-bright">${esc(p.name)}</span>
|
||||
<span class="font-pixel text-[9px] text-t-dim">v${esc(p.version || '?')}</span>
|
||||
${p.hasBackend ? '<span class="font-pixel text-[8px] px-1 py-0.5 border border-t-border text-t-muted uppercase">php</span>' : ''}
|
||||
</div>
|
||||
${p.description ? `<div class="text-[10px] text-t-muted leading-snug mt-1">${esc(p.description)}</div>` : ''}
|
||||
</div>
|
||||
<label class="flex items-center cursor-pointer shrink-0 mt-0.5">
|
||||
<input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${p.enabled ? 'checked' : ''} data-name="${esc(p.name)}">
|
||||
</label>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.querySelectorAll('.plugin-toggle').forEach(input => {
|
||||
input.addEventListener('change', onToggle);
|
||||
});
|
||||
}
|
||||
|
||||
async function onToggle(e) {
|
||||
const input = e.currentTarget;
|
||||
const name = input.dataset.name;
|
||||
const enabled = input.checked;
|
||||
input.disabled = true;
|
||||
try {
|
||||
const res = await api('api/plugins/toggle', {
|
||||
method: 'POST',
|
||||
body: { name, enabled },
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
pluginsCache = res.plugins;
|
||||
dirtyAfterToggle = true;
|
||||
$('plugins-reload-notice')?.classList.remove('hidden');
|
||||
} catch (err) {
|
||||
// Revert UI on failure
|
||||
input.checked = !enabled;
|
||||
console.error('[plugins] toggle failed:', err);
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openPluginSettings() {
|
||||
const container = $('plugins-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="text-xs text-t-dim italic">Loading...</p>';
|
||||
if (!dirtyAfterToggle) {
|
||||
$('plugins-reload-notice')?.classList.add('hidden');
|
||||
}
|
||||
try {
|
||||
const list = await fetchPlugins();
|
||||
render(list);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<p class="text-xs text-t-danger">Failed to load plugins: ${esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Wire reload button once at module load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
|
||||
}, { once: true });
|
||||
// In case DOMContentLoaded already fired (modules are deferred but safe to retry)
|
||||
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
|
||||
@@ -36,6 +36,7 @@ overlay?.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
if (panel === 'relay') { window._loadRelaySettings?.(); }
|
||||
if (panel === 'notifications') renderNotifSettings();
|
||||
if (panel === 'behavior') initBehaviorSettings();
|
||||
if (panel === 'plugins') window._openPluginSettings?.();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user