From dd2a995f6c11ef8d59be51ff13fc4adf0513c81c Mon Sep 17 00:00:00 2001 From: th Date: Fri, 10 Apr 2026 06:35:30 +0200 Subject: [PATCH] canvas feature --- public/css/style.css | 11 + public/index.php | 47 ++++ public/js/modules/canvas.js | 509 ++++++++++++++++++++++++++++++++++ public/js/modules/editor.js | 32 +++ public/js/modules/markdown.js | 4 + 5 files changed, 603 insertions(+) create mode 100644 public/js/modules/canvas.js diff --git a/public/css/style.css b/public/css/style.css index 5d24de1..1c5b7fc 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -499,6 +499,16 @@ body.detail-image-fs-open { overflow: hidden; } #editor-gutter > div { padding-right: 2px; box-sizing: border-box; } .gutter-active { color: var(--t-accent) !important; font-weight: 700; background: var(--t-raised); } +/* ASCII Canvas */ +#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); } + /* Search */ #search { backdrop-filter: blur(4px); } #search-input { caret-color: var(--t-accent); } @@ -553,6 +563,7 @@ body.detail-image-fs-open { overflow: hidden; } .prose code { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.88em; } .prose pre { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.8em 1em; overflow-x: auto; margin: 0.8em 0; border-radius: 3px; position: relative; } .prose pre code { background: none; border: none; padding: 0; font-size: 0.85em; } +.prose pre.ascii-art { line-height: 1.35; letter-spacing: 0; white-space: pre; font-size: 0.85em; } .prose pre .code-copy { position: absolute; top: 4px; right: 4px; background: var(--t-surface); border: 1px solid var(--t-border); diff --git a/public/index.php b/public/index.php index eea1016..6b13394 100644 --- a/public/index.php +++ b/public/index.php @@ -1826,6 +1826,7 @@ if (str_starts_with($route, '/api/')) {
+
@@ -1842,6 +1843,52 @@ if (str_starts_with($route, '/api/')) { + +
+
+ +
+ + + + + + + +
+ +
+ + + +
+ +
+ 1:1 +
+ +
+ +
+

+            
+ +
+ + +
+
+
+ diff --git a/public/js/modules/canvas.js b/public/js/modules/canvas.js new file mode 100644 index 0000000..3372ff1 --- /dev/null +++ b/public/js/modules/canvas.js @@ -0,0 +1,509 @@ +// ── 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 }; diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index ec6ed95..baa8a2a 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -4,6 +4,7 @@ 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'); @@ -489,6 +490,37 @@ $('md-toolbar')?.addEventListener('click', e => { 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]?.(); }); diff --git a/public/js/modules/markdown.js b/public/js/modules/markdown.js index 1bfa8c7..afc80c4 100644 --- a/public/js/modules/markdown.js +++ b/public/js/modules/markdown.js @@ -80,6 +80,10 @@ export function renderMd(src) { }); processed = processed + // ASCII canvas block: ```ascii + .replace(/```ascii\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => { + return `
${esc(content.replace(/\s+$/, ''))}
`; + }) // Gallery fenced block: ```gallery .replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => { const imgs = [];