// ── 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 += `${ch}`; } 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 };