589 lines
19 KiB
JavaScript
589 lines
19 KiB
JavaScript
// ── 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',
|
|
});
|