plugin infrastruktur
This commit is contained in:
1 parent
1545b90f39
commit
3f4ba3c0f4
35 files changed
+1303
-195
No files matched your search
@@ -0,0 +1,43 @@
|
||||
/* Canvas Plugin styles */
|
||||
#ascii-canvas-overlay { background: rgba(0,0,0,0.5); backdrop-filter: blur(2px); }
|
||||
.ascii-grid {
|
||||
white-space: pre;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
display: inline-block;
|
||||
width: max-content;
|
||||
}
|
||||
.ascii-cursor { background: var(--t-accent); color: var(--t-bg); }
|
||||
.ascii-char-btn {
|
||||
font-family: var(--t-font-mono, monospace);
|
||||
font-size: 13px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: 3px;
|
||||
background: var(--t-bg);
|
||||
color: var(--t-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.ascii-char-btn:hover { background: var(--t-raised); color: var(--t-bright); }
|
||||
.ascii-char-btn.active {
|
||||
background: var(--t-accent);
|
||||
color: var(--t-bg);
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
.ascii-char-group { display: inline-flex; gap: 2px; margin-right: 6px; }
|
||||
#ascii-canvas-toolbar .toolbar-btn.active { background: var(--t-accent); color: var(--t-bg); }
|
||||
|
||||
/* Preview rendering of ```ascii blocks */
|
||||
.prose pre.ascii-art {
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
white-space: pre;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!-- Canvas Plugin: ASCII art overlay -->
|
||||
<div class="overlay fixed inset-0" id="ascii-canvas-overlay" style="z-index:80;">
|
||||
<div class="ascii-canvas-modal bg-t-surface border border-t-border rounded-lg shadow-xl flex flex-col" style="width:90vw;max-width:960px;height:85vh;max-height:700px;margin:auto;position:absolute;inset:0;">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-1 px-3 py-2 border-b border-t-border shrink-0 flex-wrap" id="ascii-canvas-toolbar">
|
||||
<button data-tool="brush" class="toolbar-btn active" title="Brush (B)"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||
<button data-tool="eraser" class="toolbar-btn" title="Eraser (E)"><i class="ph ph-eraser text-sm"></i></button>
|
||||
<button data-tool="line" class="toolbar-btn" title="Line (L)"><i class="ph ph-line-segment text-sm"></i></button>
|
||||
<button data-tool="rect" class="toolbar-btn" title="Rectangle (R)"><i class="ph ph-rectangle text-sm"></i></button>
|
||||
<button data-tool="circle" class="toolbar-btn" title="Ellipse (O)"><i class="ph ph-circle text-sm"></i></button>
|
||||
<button data-tool="fill" class="toolbar-btn" title="Fill (F)"><i class="ph ph-paint-bucket text-sm"></i></button>
|
||||
<button data-tool="text" class="toolbar-btn" title="Text (T)"><i class="ph ph-text-t text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||
<span>Char:</span>
|
||||
<input id="ascii-char-input" type="text" value="#" maxlength="1" class="w-6 text-center bg-t-bg border border-t-border rounded text-t-text text-sm font-mono px-0 py-0">
|
||||
</label>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||
<span>W:</span>
|
||||
<input id="ascii-canvas-width" type="number" value="40" min="5" max="200" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||
<span>H:</span>
|
||||
<input id="ascii-canvas-height" type="number" value="20" min="3" max="100" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
|
||||
</label>
|
||||
<button id="ascii-canvas-resize" class="toolbar-btn text-xs" title="Resize"><i class="ph ph-arrows-out text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button id="ascii-canvas-clear" class="toolbar-btn text-xs" title="Clear"><i class="ph ph-trash text-sm"></i></button>
|
||||
<div class="flex-1"></div>
|
||||
<span id="ascii-canvas-coords" class="text-t-dim text-xs font-mono">1:1</span>
|
||||
</div>
|
||||
<!-- Char palette -->
|
||||
<div id="ascii-char-palette" class="flex items-center gap-0.5 px-3 py-1.5 border-b border-t-border shrink-0 flex-wrap overflow-x-auto"></div>
|
||||
<!-- Grid -->
|
||||
<div class="flex-1 min-h-0 overflow-auto bg-t-bg p-2">
|
||||
<pre id="ascii-canvas-grid" class="ascii-grid font-mono text-sm text-t-text select-none cursor-crosshair" style="line-height:1.35;letter-spacing:0;margin:0;"></pre>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-2 border-t border-t-border shrink-0">
|
||||
<button id="ascii-canvas-cancel" class="toolbar-btn text-xs px-3 py-1">Cancel</button>
|
||||
<button id="ascii-canvas-ok" class="bg-t-accent text-t-bg text-xs font-bold px-4 py-1 rounded hover:opacity-90 cursor-pointer">Insert</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,588 @@
|
||||
// ── Canvas Plugin: ASCII Art Editor ──
|
||||
//
|
||||
// Self-registers via window.hyprcrd:
|
||||
// - Markdown fenced block ```ascii``` → <pre class="ascii-art">
|
||||
// - Editor toolbar button + "asciiart" command
|
||||
// - Re-opens canvas to edit if cursor is inside an existing block
|
||||
|
||||
const hyprcrd = window.hyprcrd;
|
||||
if (!hyprcrd) {
|
||||
throw new Error('[canvas plugin] window.hyprcrd not found — plugin API missing');
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
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();
|
||||
|
||||
// ── Plugin self-registration ──────────────────────────────────────
|
||||
|
||||
// Register markdown fenced block: ```ascii``` → styled <pre>
|
||||
hyprcrd.markdown.registerFencedBlock('ascii', (content, { esc }) => {
|
||||
return `<pre class="ascii-art">${esc(content.replace(/\s+$/, ''))}</pre>`;
|
||||
});
|
||||
|
||||
// Register editor toolbar command "asciiart"
|
||||
hyprcrd.editor.registerCommand('asciiart', () => {
|
||||
const ed = hyprcrd.editor.api;
|
||||
if (!ed || !ed.body) return;
|
||||
const body = ed.body;
|
||||
|
||||
// Detect if cursor is inside an existing ```ascii``` block (edit mode)
|
||||
const val = body.value;
|
||||
const pos = body.selectionStart;
|
||||
const before = val.substring(0, 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) {
|
||||
const contentStart = blockStart + 9;
|
||||
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');
|
||||
}
|
||||
ed.updatePreview?.();
|
||||
}, existingText);
|
||||
});
|
||||
|
||||
// Inject toolbar button into #md-toolbar (after the "extract" button if present)
|
||||
function injectToolbarButton() {
|
||||
const toolbar = $('md-toolbar');
|
||||
if (!toolbar) return;
|
||||
if (toolbar.querySelector('[data-md="asciiart"]')) return; // already injected
|
||||
const btn = document.createElement('button');
|
||||
btn.setAttribute('data-md', 'asciiart');
|
||||
btn.setAttribute('title', 'ASCII Canvas');
|
||||
btn.className = 'toolbar-btn';
|
||||
btn.innerHTML = '<i class="ph ph-grid-four text-sm"></i>';
|
||||
// Insert before the right-side spacer (flex-1 div), or append at end
|
||||
const spacer = toolbar.querySelector('.flex-1');
|
||||
if (spacer) toolbar.insertBefore(btn, spacer);
|
||||
else toolbar.appendChild(btn);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectToolbarButton);
|
||||
} else {
|
||||
injectToolbarButton();
|
||||
}
|
||||
|
||||
// Register plugin metadata for introspection
|
||||
hyprcrd.registerPlugin({
|
||||
name: 'canvas',
|
||||
version: '1.0.0',
|
||||
description: 'ASCII art canvas editor',
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "canvas",
|
||||
"version": "1.0.0",
|
||||
"description": "ASCII art canvas editor — draw with characters in a monospace grid and embed via ```ascii blocks.",
|
||||
"frontend": {
|
||||
"js": ["canvas.js"],
|
||||
"css": ["canvas.css"],
|
||||
"html": ["canvas.html"]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Bucket Bar', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async function openEditor() {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
async function openCanvas() {
|
||||
await openEditor();
|
||||
const btn = document.querySelector('[data-md="asciiart"]');
|
||||
if (!btn) throw new Error('canvas toolbar button not found');
|
||||
btn.click();
|
||||
// openCanvas does layout in rAF
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
function closeCanvasIfOpen() {
|
||||
const overlay = $('#ascii-canvas-overlay');
|
||||
if (overlay?.classList.contains('open')) {
|
||||
const cancel = $('#ascii-canvas-cancel');
|
||||
cancel?.click();
|
||||
}
|
||||
}
|
||||
|
||||
function getGridText() {
|
||||
return $('#ascii-canvas-grid')?.textContent || '';
|
||||
}
|
||||
|
||||
function getGridLines() {
|
||||
return getGridText().split('\n');
|
||||
}
|
||||
|
||||
// Dispatch a mousedown at a logical (col, row) cell on the grid
|
||||
function clickCell(col, row) {
|
||||
const grid = $('#ascii-canvas-grid');
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const cols = parseInt($('#ascii-canvas-width').value);
|
||||
const rows = parseInt($('#ascii-canvas-height').value);
|
||||
const cellW = rect.width / cols;
|
||||
const cellH = rect.height / rows;
|
||||
const x = rect.left + cellW * col + cellW / 2;
|
||||
const y = rect.top + cellH * row + cellH / 2;
|
||||
grid.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, clientX: x, clientY: y }));
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0, clientX: x, clientY: y }));
|
||||
}
|
||||
|
||||
// ── Plugin Registration ──
|
||||
|
||||
describe('Canvas Plugin: Registration', () => {
|
||||
it('plugin is registered with hyprcrd', () => {
|
||||
expect(window.hyprcrd).toExist();
|
||||
const meta = window.hyprcrd.plugins.find(p => p.name === 'canvas');
|
||||
expect(meta).toExist();
|
||||
});
|
||||
|
||||
it('asciiart editor command is registered', () => {
|
||||
expect(window.hyprcrd.editor.getCommand('asciiart')).toExist();
|
||||
});
|
||||
|
||||
it('ascii fenced block handler is registered', () => {
|
||||
const handlers = window.hyprcrd.markdown.getFencedBlocks();
|
||||
const ascii = handlers.find(h => h.lang === 'ascii');
|
||||
expect(ascii).toExist();
|
||||
});
|
||||
|
||||
it('toolbar button is injected into md-toolbar', () => {
|
||||
const btn = document.querySelector('#md-toolbar [data-md="asciiart"]');
|
||||
expect(btn).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Overlay Structure ──
|
||||
|
||||
describe('Canvas Plugin: Overlay Structure', () => {
|
||||
it('overlay element exists in DOM', () => {
|
||||
expect($('#ascii-canvas-overlay')).toExist();
|
||||
});
|
||||
|
||||
it('grid, toolbar, and inputs exist', () => {
|
||||
expect($('#ascii-canvas-grid')).toExist();
|
||||
expect($('#ascii-canvas-toolbar')).toExist();
|
||||
expect($('#ascii-char-input')).toExist();
|
||||
expect($('#ascii-canvas-width')).toExist();
|
||||
expect($('#ascii-canvas-height')).toExist();
|
||||
});
|
||||
|
||||
it('all 7 tool buttons exist', () => {
|
||||
const tools = ['brush', 'eraser', 'line', 'rect', 'circle', 'fill', 'text'];
|
||||
for (const t of tools) {
|
||||
const btn = document.querySelector(`[data-tool="${t}"]`);
|
||||
expect(btn).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('action buttons exist (ok, cancel, clear, resize)', () => {
|
||||
expect($('#ascii-canvas-ok')).toExist();
|
||||
expect($('#ascii-canvas-cancel')).toExist();
|
||||
expect($('#ascii-canvas-clear')).toExist();
|
||||
expect($('#ascii-canvas-resize')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Open / Close ──
|
||||
|
||||
describe('Canvas Plugin: Open & Close', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('overlay is hidden initially', () => {
|
||||
const overlay = $('#ascii-canvas-overlay');
|
||||
expect(overlay.classList.contains('open')).toBe(false);
|
||||
});
|
||||
|
||||
it('opens via toolbar button', async () => {
|
||||
await openCanvas();
|
||||
expect($('#ascii-canvas-overlay').classList.contains('open')).toBe(true);
|
||||
});
|
||||
|
||||
it('cancel button closes overlay', async () => {
|
||||
await openCanvas();
|
||||
click('#ascii-canvas-cancel');
|
||||
await sleep(50);
|
||||
expect($('#ascii-canvas-overlay').classList.contains('open')).toBe(false);
|
||||
});
|
||||
|
||||
it('overlay is filled with character grid on open', async () => {
|
||||
await openCanvas();
|
||||
const lines = getGridLines();
|
||||
const cols = parseInt($('#ascii-canvas-width').value);
|
||||
const rows = parseInt($('#ascii-canvas-height').value);
|
||||
expect(lines.length).toBe(rows);
|
||||
expect(lines[0].length).toBe(cols);
|
||||
});
|
||||
|
||||
it('palette is populated with character buttons', async () => {
|
||||
await openCanvas();
|
||||
const buttons = document.querySelectorAll('#ascii-char-palette .ascii-char-btn');
|
||||
expect(buttons.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tool Selection ──
|
||||
|
||||
describe('Canvas Plugin: Tool Selection', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('brush is the default active tool', async () => {
|
||||
await openCanvas();
|
||||
const brushBtn = document.querySelector('[data-tool="brush"]');
|
||||
expect(brushBtn.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('clicking a tool button activates it and deactivates others', async () => {
|
||||
await openCanvas();
|
||||
click(document.querySelector('[data-tool="line"]'));
|
||||
await sleep(20);
|
||||
expect(document.querySelector('[data-tool="line"]').classList.contains('active')).toBe(true);
|
||||
expect(document.querySelector('[data-tool="brush"]').classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('keyboard shortcut switches tool', async () => {
|
||||
await openCanvas();
|
||||
const overlay = $('#ascii-canvas-overlay');
|
||||
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', bubbles: true }));
|
||||
await sleep(20);
|
||||
expect(document.querySelector('[data-tool="rect"]').classList.contains('active')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Drawing ──
|
||||
|
||||
describe('Canvas Plugin: Drawing', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('brush click sets a character at the clicked cell', async () => {
|
||||
await openCanvas();
|
||||
// Use a known character
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = 'X';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
|
||||
clickCell(5, 3);
|
||||
await sleep(20);
|
||||
|
||||
const lines = getGridLines();
|
||||
expect(lines[3][5]).toBe('X');
|
||||
});
|
||||
|
||||
it('eraser replaces character with space', async () => {
|
||||
await openCanvas();
|
||||
// Draw first
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = '#';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
clickCell(2, 2);
|
||||
await sleep(20);
|
||||
expect(getGridLines()[2][2]).toBe('#');
|
||||
|
||||
// Switch to eraser and click same cell
|
||||
click(document.querySelector('[data-tool="eraser"]'));
|
||||
await sleep(20);
|
||||
clickCell(2, 2);
|
||||
await sleep(20);
|
||||
expect(getGridLines()[2][2]).toBe(' ');
|
||||
});
|
||||
|
||||
it('clear button empties the grid', async () => {
|
||||
await openCanvas();
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = '#';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
clickCell(1, 1);
|
||||
clickCell(2, 2);
|
||||
clickCell(3, 3);
|
||||
await sleep(20);
|
||||
|
||||
click('#ascii-canvas-clear');
|
||||
await sleep(20);
|
||||
|
||||
const lines = getGridLines();
|
||||
// All chars should be space
|
||||
const allSpace = lines.every(l => /^ *$/.test(l));
|
||||
expect(allSpace).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Character Selection ──
|
||||
|
||||
describe('Canvas Plugin: Character Selection', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('clicking palette button updates active char', async () => {
|
||||
await openCanvas();
|
||||
const targetBtn = document.querySelector('#ascii-char-palette .ascii-char-btn');
|
||||
const ch = targetBtn.textContent;
|
||||
click(targetBtn);
|
||||
await sleep(20);
|
||||
|
||||
// Draw with that char
|
||||
clickCell(0, 0);
|
||||
await sleep(20);
|
||||
|
||||
const lines = getGridLines();
|
||||
expect(lines[0][0]).toBe(ch);
|
||||
});
|
||||
|
||||
it('typing in char input changes active character', async () => {
|
||||
await openCanvas();
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = '@';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
|
||||
clickCell(4, 4);
|
||||
await sleep(20);
|
||||
|
||||
const lines = getGridLines();
|
||||
expect(lines[4][4]).toBe('@');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Insert into Editor ──
|
||||
|
||||
describe('Canvas Plugin: Insert', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('OK button inserts ```ascii block into editor body', async () => {
|
||||
await openCanvas();
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = 'Z';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
|
||||
clickCell(0, 0);
|
||||
clickCell(1, 0);
|
||||
clickCell(2, 0);
|
||||
await sleep(20);
|
||||
|
||||
click('#ascii-canvas-ok');
|
||||
await sleep(50);
|
||||
|
||||
const body = $('#editor-body');
|
||||
expect(body.value).toContain('```ascii');
|
||||
expect(body.value).toContain('ZZZ');
|
||||
expect(body.value).toContain('```\n');
|
||||
});
|
||||
|
||||
it('cancel does NOT insert anything', async () => {
|
||||
await openEditor();
|
||||
const body = $('#editor-body');
|
||||
const beforeLen = body.value.length;
|
||||
|
||||
const btn = document.querySelector('[data-md="asciiart"]');
|
||||
btn.click();
|
||||
await sleep(100);
|
||||
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = 'Q';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
clickCell(0, 0);
|
||||
await sleep(20);
|
||||
|
||||
click('#ascii-canvas-cancel');
|
||||
await sleep(50);
|
||||
|
||||
expect(body.value.length).toBe(beforeLen);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit existing block ──
|
||||
|
||||
describe('Canvas Plugin: Edit Existing Block', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('cursor inside ```ascii block loads existing content', async () => {
|
||||
await openEditor();
|
||||
const body = $('#editor-body');
|
||||
body.value = '```ascii\n###\n###\n```';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// Place cursor inside the block content
|
||||
const insidePos = body.value.indexOf('###') + 1;
|
||||
body.selectionStart = body.selectionEnd = insidePos;
|
||||
|
||||
const btn = document.querySelector('[data-md="asciiart"]');
|
||||
btn.click();
|
||||
await sleep(150);
|
||||
|
||||
const lines = getGridLines();
|
||||
// First two lines should contain the existing # chars
|
||||
expect(lines[0].startsWith('###')).toBe(true);
|
||||
expect(lines[1].startsWith('###')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Markdown Rendering ──
|
||||
|
||||
describe('Canvas Plugin: Markdown Rendering', () => {
|
||||
it('```ascii fenced block renders as <pre class="ascii-art">', async () => {
|
||||
// Use the registered handler directly via the registry
|
||||
const handlers = window.hyprcrd.markdown.getFencedBlocks();
|
||||
const ascii = handlers.find(h => h.lang === 'ascii');
|
||||
expect(ascii).toExist();
|
||||
const html = ascii.handler('###\n###\n###', { esc: window.hyprcrd.util.esc });
|
||||
expect(html).toContain('<pre class="ascii-art">');
|
||||
expect(html).toContain('###');
|
||||
});
|
||||
|
||||
it('renderMd produces ascii-art pre when given an ```ascii block', async () => {
|
||||
const { renderMd } = await import('../../js/modules/markdown.js');
|
||||
const html = renderMd('```ascii\n***\n***\n```');
|
||||
expect(html).toContain('ascii-art');
|
||||
expect(html).toContain('***');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Card: Tag Operations', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js';
|
||||
import { showDialog } from '../modules/dialog.js';
|
||||
import { showDialog } from '../../js/modules/dialog.js';
|
||||
|
||||
describe('Datepicker: Rendering', () => {
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Favorites Bucket', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state, parseTags, sortedCards } from '../modules/state.js';
|
||||
import { state, parseTags, sortedCards } from '../../js/modules/state.js';
|
||||
|
||||
describe('Filtering: Tag AND Logic', () => {
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('Image Fullscreen Viewer', () => {
|
||||
|
||||
// Check that images in detail would get fullscreen handler
|
||||
// The overlay is created lazily, verify the create function structure
|
||||
const { bindDetailImageFullscreen } = await import('../modules/detail.js');
|
||||
const { bindDetailImageFullscreen } = await import('../../js/modules/detail.js');
|
||||
expect(typeof bindDetailImageFullscreen).toBe('function');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// ── Tests Plugin: conditional loader ──
|
||||
//
|
||||
// The test runner is heavy (40+ test files), so we only load it when:
|
||||
// - URL contains ?test query param → auto-run
|
||||
// - Ctrl+Shift+T pressed → manual open
|
||||
//
|
||||
// Tests are completely optional — without this plugin the app has no
|
||||
// test runner code loaded at all.
|
||||
|
||||
const hyprcrd = window.hyprcrd;
|
||||
|
||||
// Auto-load if ?test in query string
|
||||
if (new URLSearchParams(location.search).has('test')) {
|
||||
import('./tests.js');
|
||||
}
|
||||
|
||||
// Keyboard shortcut: Ctrl+Shift+T → open runner
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault();
|
||||
import('./tests.js').then(() =>
|
||||
import('./runner.js').then(m => m.open?.())
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
hyprcrd?.registerPlugin?.({
|
||||
name: 'tests',
|
||||
version: '1.0.0',
|
||||
description: 'UI + state test runner',
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { renderMd } from '../modules/markdown.js';
|
||||
import { renderMd } from '../../js/modules/markdown.js';
|
||||
|
||||
describe('Markdown: Blank Lines', () => {
|
||||
it('image after blank lines renders', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { renderMd, formatTodoItems, stripMd } from '../modules/markdown.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { renderMd, formatTodoItems, stripMd } from '../../js/modules/markdown.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Markdown: Basic Rendering', () => {
|
||||
it('renders paragraph', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, sleep, closeAll } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Multi-Select', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "tests",
|
||||
"version": "1.0.0",
|
||||
"description": "UI + state test runner. Loads only when ?test query param is present or Ctrl+Shift+T is pressed.",
|
||||
"frontend": {
|
||||
"js": ["loader.js"],
|
||||
"css": [],
|
||||
"html": []
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
// RRULE is set by CalDAV sync, not via PATCH. Test the data model and existing cards.
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Panel is hidden during test run so interactions are visible.
|
||||
// Backup is created before and auto-restored after every run.
|
||||
|
||||
import { getCsrf } from '../modules/util.js';
|
||||
import { stopBackgroundSync, startBackgroundSync } from '../modules/caldav.js';
|
||||
import { getCsrf } from '../../js/modules/util.js';
|
||||
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js';
|
||||
|
||||
const suites = [];
|
||||
let currentSuite = null;
|
||||
|
||||
@@ -9,12 +9,12 @@ describe('Settings: Open/Close', () => {
|
||||
expect($('#settings-overlay')).toBeVisible();
|
||||
});
|
||||
|
||||
it('has all 7 tabs', async () => {
|
||||
it('has all 8 tabs', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
const names = $$('.settings-tab').map(t => t.dataset.tab);
|
||||
expect(names.length).toBe(7);
|
||||
for (const tab of ['branding', 'account', 'theme', 'caldav', 'relay', 'behavior', 'notifications']) {
|
||||
expect(names.length).toBe(8);
|
||||
for (const tab of ['branding', 'account', 'theme', 'caldav', 'relay', 'behavior', 'notifications', 'plugins']) {
|
||||
expect(names).toContain(tab);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, csrf } from './runner.js';
|
||||
import { state, sortedCards } from '../modules/state.js';
|
||||
import { state, sortedCards } from '../../js/modules/state.js';
|
||||
|
||||
describe('Sorting: All Modes', () => {
|
||||
it('manual sort by position', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { state, parseTags, sortedCards, getChildren, getGroupCards, getRecent, markRecent, getFavorites, toggleFavorite, isFavorite } from '../modules/state.js';
|
||||
import { state, parseTags, sortedCards, getChildren, getGroupCards, getRecent, markRecent, getFavorites, toggleFavorite, isFavorite } from '../../js/modules/state.js';
|
||||
|
||||
describe('State: parseTags', () => {
|
||||
it('splits comma-separated tags', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import './editor-features.test.js';
|
||||
import './editor-save.test.js';
|
||||
import './editor-toolbar.test.js';
|
||||
import './line-numbers.test.js';
|
||||
import './canvas.test.js';
|
||||
import './snippets.test.js';
|
||||
import './image-fullscreen.test.js';
|
||||
import './markdown.test.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Toolbar: Filter Toggles', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('Undo: Card Delete', () => {
|
||||
// The child might be inside a group view — let's just test via the undo module directly
|
||||
|
||||
// Import undo and test directly
|
||||
const { pushCardDelete, snapshotCard, undo, canUndo } = await import('../modules/undo.js');
|
||||
const { pushCardDelete, snapshotCard, undo, canUndo } = await import('../../js/modules/undo.js');
|
||||
const snap = { ...child, parent_id: parent.id };
|
||||
pushCardDelete('test-del', snap);
|
||||
await fetch(`api/cards/${child.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } });
|
||||
@@ -317,7 +317,7 @@ describe('Undo: Multiple Undos', () => {
|
||||
|
||||
describe('Undo: No-Op When Empty', () => {
|
||||
it('Ctrl+Z with empty stack does nothing', async () => {
|
||||
const { canUndo } = await import('../modules/undo.js');
|
||||
const { canUndo } = await import('../../js/modules/undo.js');
|
||||
// Clear stack by undoing everything
|
||||
while (canUndo()) {
|
||||
ctrlZ();
|
||||
|
||||
Reference in new issue
Block a user