// ===== BRAILLE ENCODING =====
const DOT_BITS = [
[0x01, 0x08],
[0x02, 0x10],
[0x04, 0x20],
[0x40, 0x80],
];
function createGrid(rows, cols) {
return Array.from({ length: rows }, () => Array(cols).fill(0));
}
function cloneGrid(grid) {
return grid.map(r => [...r]);
}
function gridToBraille(grid, cellCols, cellRows) {
const lines = [];
for (let cr = 0; cr < cellRows; cr++) {
let line = '';
for (let cc = 0; cc < cellCols; cc++) {
let mask = 0;
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 2; c++) {
const gr = cr * 4 + r;
const gc = cc * 2 + c;
if (gr < grid.length && gc < grid[0].length && grid[gr][gc]) {
mask |= DOT_BITS[r][c];
}
}
}
line += String.fromCodePoint(0x2800 + mask);
}
lines.push(line);
}
return lines;
}
function decodeBraille(char) {
const code = char.codePointAt(0) - 0x2800;
const grid = Array.from({ length: 4 }, () => [0, 0]);
DOT_BITS.forEach((row, r) => row.forEach((bit, c) => {
grid[r][c] = (code & bit) ? 1 : 0;
}));
return grid;
}
function gridToUnicode(grid, cellCols, cellRows) {
const lines = gridToBraille(grid, cellCols, cellRows);
return lines.map(l => [...l].map(ch => 'U+' + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ')).join('\n');
}
function gridToAscii(grid) {
return grid.map(row => row.map(v => v ? '##' : '..').join('')).join('\n');
}
function gridToTermdot(grid) {
return '"' + grid.map(row => row.map(v => v ? '*' : '.').join('')).join('\\n') + '"';
}
function gridToJs(grid, cellCols, cellRows) {
const lines = gridToBraille(grid, cellCols, cellRows);
return lines.length === 1 ? "'" + lines[0] + "'" : "[\n" + lines.map(l => " '" + l + "'").join(',\n') + "\n]";
}
// ===== ANIMATION EXPORT =====
function exportAnimation(frames, cellCols, cellRows, format) {
const exportFrames = state.bounce ? [...frames, ...[...frames].reverse()] : frames;
const allBraille = exportFrames.map(f => gridToBraille(f, cellCols, cellRows));
switch (format) {
case 'js-braille': {
const items = allBraille.map(lines => lines.length === 1 ? '"' + lines[0] + '"' : '[' + lines.map(l => '"' + l + '"').join(', ') + ']');
return 'const frames = [\n' + items.map(i => ' ' + i).join(',\n') + '\n];';
}
case 'js-termdot': {
const items = exportFrames.map(f => '"' + f.map(row => row.map(v => v ? '*' : '.').join('')).join('\\n') + '"');
return 'const frames = [\n' + items.map(i => ' ' + i).join(',\n') + '\n];';
}
case 'js-grid':
return 'const frames = ' + JSON.stringify(exportFrames, null, 2) + ';';
case 'json':
return JSON.stringify({ cols: cellCols, rows: cellRows, fps: state.fps, frames: allBraille }, null, 2);
case 'python': {
const items = allBraille.map(lines => lines.length === 1 ? '"' + lines[0] + '"' : '[' + lines.map(l => '"' + l + '"').join(', ') + ']');
return 'frames = [\n' + items.map(i => ' ' + i).join(',\n') + '\n]';
}
case 'rust': {
const items = allBraille.map(lines => lines.length === 1 ? '"' + lines[0] + '"' : '&[' + lines.map(l => '"' + l + '"').join(', ') + ']');
return 'let frames = vec![\n' + items.map(i => ' ' + i).join(',\n') + '\n];';
}
case 'c': {
const items = allBraille.map(lines => lines.length === 1 ? '"' + lines[0] + '"' : '{' + lines.map(l => '"' + l + '"').join(', ') + '}');
return 'const char *frames[] = {\n' + items.map(i => ' ' + i).join(',\n') + '\n};';
}
default:
return '';
}
}
// ===== DRAWING TOOLS =====
function bresenhamLine(r0, c0, r1, c1) {
const points = [];
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) {
points.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 points;
}
function rectOutline(r0, c0, r1, c1) {
const points = [];
const minR = Math.min(r0, r1), maxR = Math.max(r0, r1);
const minC = Math.min(c0, c1), maxC = Math.max(c0, c1);
for (let c = minC; c <= maxC; c++) { points.push([minR, c]); points.push([maxR, c]); }
for (let r = minR + 1; r < maxR; r++) { points.push([r, minC]); points.push([r, maxC]); }
return points;
}
function rectFilled(r0, c0, r1, c1) {
const points = [];
const minR = Math.min(r0, r1), maxR = Math.max(r0, r1);
const minC = Math.min(c0, c1), maxC = Math.max(c0, c1);
for (let r = minR; r <= maxR; r++)
for (let c = minC; c <= maxC; c++)
points.push([r, c]);
return points;
}
function getMirrorPoints(r, c, rows, cols, mirrorMode) {
const points = [[r, c]];
if (mirrorMode === 'h' || mirrorMode === 'hv') points.push([r, cols - 1 - c]);
if (mirrorMode === 'v' || mirrorMode === 'hv') points.push([rows - 1 - r, c]);
if (mirrorMode === 'hv') points.push([rows - 1 - r, cols - 1 - c]);
return points;
}
// ===== IMPORT PARSING =====
function parseImport(text) {
text = text.trim();
if (!text) return null;
// JSON
try {
const obj = JSON.parse(text);
if (obj.frames && Array.isArray(obj.frames)) {
const frames = obj.frames.map(f => {
if (typeof f === 'string' || (Array.isArray(f) && typeof f[0] === 'string')) {
const lines = Array.isArray(f) ? f : [f];
const cellRows = lines.length;
const cellCols = [...lines[0]].length;
const grid = createGrid(cellRows * 4, cellCols * 2);
lines.forEach((line, lr) => {
[...line].forEach((ch, lc) => {
const decoded = decodeBraille(ch);
for (let r = 0; r < 4; r++)
for (let c = 0; c < 2; c++)
grid[lr * 4 + r][lc * 2 + c] = decoded[r][c];
});
});
return grid;
}
return f;
});
return { format: 'JSON', frames, cols: obj.cols || 2, rows: obj.rows || 1 };
}
if (Array.isArray(obj)) {
if (Array.isArray(obj[0]) && Array.isArray(obj[0][0])) {
return { format: 'Grid Arrays', frames: obj, cols: Math.ceil(obj[0][0].length / 2), rows: Math.ceil(obj[0].length / 4) };
}
}
} catch (e) {}
// JS Array
const jsMatch = text.match(/\[[\s\S]*\]/);
if (jsMatch) {
try {
const arr = eval('(' + jsMatch[0] + ')');
if (Array.isArray(arr) && arr.length > 0) {
if (typeof arr[0] === 'string') {
const frames = arr.map(s => {
const lines = s.split('\n').filter(Boolean);
const cellRows = lines.length;
const cellCols = [...lines[0]].length;
const grid = createGrid(cellRows * 4, cellCols * 2);
lines.forEach((line, lr) => {
[...line].forEach((ch, lc) => {
const decoded = decodeBraille(ch);
for (let r = 0; r < 4; r++)
for (let c = 0; c < 2; c++)
grid[lr * 4 + r][lc * 2 + c] = decoded[r][c];
});
});
return grid;
});
const firstLines = arr[0].split('\n').filter(Boolean);
return { format: 'JS Array', frames, cols: [...firstLines[0]].length, rows: firstLines.length };
}
}
} catch (e) {}
}
// Raw braille text
const blocks = text.split(/\n\s*\n/).filter(Boolean);
if (blocks.length > 0 && blocks[0].match(/[\u2800-\u28FF]/)) {
const frames = blocks.map(block => {
const lines = block.split('\n').filter(Boolean);
const cellRows = lines.length;
const cellCols = [...lines[0]].length;
const grid = createGrid(cellRows * 4, cellCols * 2);
lines.forEach((line, lr) => {
[...line].forEach((ch, lc) => {
if (ch.codePointAt(0) >= 0x2800 && ch.codePointAt(0) <= 0x28FF) {
const decoded = decodeBraille(ch);
for (let r = 0; r < 4; r++)
for (let c = 0; c < 2; c++)
grid[lr * 4 + r][lc * 2 + c] = decoded[r][c];
}
});
});
return grid;
});
const firstLines = blocks[0].split('\n').filter(Boolean);
return { format: 'Raw Braille', frames, cols: [...firstLines[0]].length, rows: firstLines.length };
}
return null;
}
// ===== STATE =====
const state = {
cellCols: 2,
cellRows: 1,
frames: [createGrid(4, 4)],
currentFrame: 0,
mode: 'draw',
mirrorMode: 'off',
showGuides: true,
isPlaying: false,
fps: 4,
loop: true,
bounce: false,
playDirection: 1,
onionSkin: false,
undoStack: [],
redoStack: [],
playIntervalId: null,
isPointerDown: false,
paintVal: 1,
lineStart: null,
rectStart: null,
animFormat: 'js-braille',
};
function pixelRows() { return state.cellRows * 4; }
function pixelCols() { return state.cellCols * 2; }
function currentGrid() { return state.frames[state.currentFrame]; }
// ===== UNDO/REDO =====
function pushUndo() {
state.undoStack.push({
frames: state.frames.map(cloneGrid),
currentFrame: state.currentFrame,
cellCols: state.cellCols,
cellRows: state.cellRows,
});
if (state.undoStack.length > 100) state.undoStack.shift();
state.redoStack = [];
}
function undo() {
if (!state.undoStack.length) return;
state.redoStack.push({
frames: state.frames.map(cloneGrid),
currentFrame: state.currentFrame,
cellCols: state.cellCols,
cellRows: state.cellRows,
});
const snap = state.undoStack.pop();
state.frames = snap.frames;
state.currentFrame = snap.currentFrame;
state.cellCols = snap.cellCols;
state.cellRows = snap.cellRows;
document.getElementById('cell-cols').value = state.cellCols;
document.getElementById('cell-rows').value = state.cellRows;
renderAll();
}
function redo() {
if (!state.redoStack.length) return;
state.undoStack.push({
frames: state.frames.map(cloneGrid),
currentFrame: state.currentFrame,
cellCols: state.cellCols,
cellRows: state.cellRows,
});
const snap = state.redoStack.pop();
state.frames = snap.frames;
state.currentFrame = snap.currentFrame;
state.cellCols = snap.cellCols;
state.cellRows = snap.cellRows;
document.getElementById('cell-cols').value = state.cellCols;
document.getElementById('cell-rows').value = state.cellRows;
renderAll();
}
// ===== AUTO-SAVE =====
let saveTimer = null;
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
localStorage.setItem('braille-draw-pro', JSON.stringify({
cellCols: state.cellCols,
cellRows: state.cellRows,
frames: state.frames,
currentFrame: state.currentFrame,
fps: state.fps,
loop: state.loop,
bounce: state.bounce,
}));
} catch (e) {}
}, 500);
}
function loadSaved() {
try {
const raw = localStorage.getItem('braille-draw-pro');
if (!raw) return;
const data = JSON.parse(raw);
if (data.frames && data.frames.length) {
state.cellCols = data.cellCols || 2;
state.cellRows = data.cellRows || 1;
state.frames = data.frames;
state.currentFrame = Math.min(data.currentFrame || 0, data.frames.length - 1);
state.fps = data.fps || 4;
state.loop = data.loop !== false;
state.bounce = data.bounce === true;
document.getElementById('cell-cols').value = state.cellCols;
document.getElementById('cell-rows').value = state.cellRows;
document.getElementById('fps-slider').value = state.fps;
document.getElementById('fps-value').textContent = state.fps;
if (state.loop) document.getElementById('btn-loop').classList.add('active');
if (state.bounce) document.getElementById('btn-bounce').classList.add('active');
}
} catch (e) {}
}
// ===== TOAST =====
function showToast(message) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
container.appendChild(toast);
if (container.children.length > 3) container.removeChild(container.firstChild);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 150);
}, 2000);
}
// ===== RENDER DOT GRID =====
function renderGrid() {
const grid = currentGrid();
const container = document.getElementById('dot-grid');
container.innerHTML = '';
const rows = pixelRows();
const cols = pixelCols();
const prevGrid = state.onionSkin && state.currentFrame > 0 ? state.frames[state.currentFrame - 1] : null;
for (let r = 0; r < rows; r++) {
const rowEl = document.createElement('div');
rowEl.className = 'grid-row';
for (let c = 0; c < cols; c++) {
const dot = document.createElement('div');
dot.className = 'dot';
if (grid[r] && grid[r][c]) dot.classList.add('on');
else if (prevGrid && prevGrid[r] && prevGrid[r][c]) dot.classList.add('ghost');
if (state.showGuides) {
if (c % 2 === 1 && c < cols - 1) dot.classList.add('cell-right');
if (r % 4 === 3 && r < rows - 1) dot.classList.add('cell-bottom');
}
dot.dataset.r = r;
dot.dataset.c = c;
rowEl.appendChild(dot);
}
container.appendChild(rowEl);
}
document.getElementById('grid-label').textContent =
`${cols} × ${rows} pixels — ${state.cellCols * state.cellRows === 1 ? '1 char' : state.cellCols * state.cellRows + ' chars'}`;
}
// ===== RENDER OUTPUTS =====
function renderOutputs() {
const grid = currentGrid();
const braille = gridToBraille(grid, state.cellCols, state.cellRows);
document.getElementById('out-braille').textContent = braille.join('\n');
document.getElementById('out-unicode').textContent = gridToUnicode(grid, state.cellCols, state.cellRows);
document.getElementById('out-ascii').textContent = gridToAscii(grid);
document.getElementById('out-termdot').textContent = gridToTermdot(grid);
document.getElementById('out-js').textContent = gridToJs(grid, state.cellCols, state.cellRows);
document.getElementById('out-animation').textContent = exportAnimation(state.frames, state.cellCols, state.cellRows, state.animFormat);
}
// ===== RENDER FRAME STRIP =====
function renderFrameStrip() {
const strip = document.getElementById('frame-strip');
strip.innerHTML = '';
state.frames.forEach((frame, i) => {
const thumb = document.createElement('div');
thumb.className = 'frame-thumb' + (i === state.currentFrame ? ' active' : '');
thumb.draggable = true;
thumb.dataset.index = i;
const num = document.createElement('div');
num.className = 'frame-number';
num.textContent = i + 1;
thumb.appendChild(num);
const preview = document.createElement('div');
preview.className = 'frame-preview';
const braille = gridToBraille(frame, state.cellCols, state.cellRows);
preview.textContent = braille[0] || '';
thumb.appendChild(preview);
thumb.addEventListener('click', () => {
state.currentFrame = i;
renderAll();
});
// Drag and drop
thumb.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', i.toString());
thumb.classList.add('dragging');
});
thumb.addEventListener('dragend', () => thumb.classList.remove('dragging'));
thumb.addEventListener('dragover', (e) => e.preventDefault());
thumb.addEventListener('drop', (e) => {
e.preventDefault();
const from = parseInt(e.dataTransfer.getData('text/plain'));
const to = i;
if (from !== to) {
pushUndo();
const [moved] = state.frames.splice(from, 1);
state.frames.splice(to, 0, moved);
if (state.currentFrame === from) state.currentFrame = to;
else if (from < state.currentFrame && to >= state.currentFrame) state.currentFrame--;
else if (from > state.currentFrame && to <= state.currentFrame) state.currentFrame++;
renderAll();
}
});
strip.appendChild(thumb);
});
// Add button
const addBtn = document.createElement('button');
addBtn.className = 'frame-add-btn';
addBtn.textContent = '+';
addBtn.title = 'Add frame';
addBtn.addEventListener('click', addFrame);
strip.appendChild(addBtn);
document.getElementById('frame-counter').textContent = `${state.currentFrame + 1} / ${state.frames.length}`;
}
// ===== RENDER ALL =====
function renderAll() {
renderGrid();
renderOutputs();
renderFrameStrip();
scheduleSave();
}
// ===== FRAME ACTIONS =====
function addFrame() {
pushUndo();
state.frames.splice(state.currentFrame + 1, 0, createGrid(pixelRows(), pixelCols()));
state.currentFrame++;
renderAll();
}
function duplicateFrame() {
pushUndo();
state.frames.splice(state.currentFrame + 1, 0, cloneGrid(currentGrid()));
state.currentFrame++;
renderAll();
}
function deleteFrame() {
if (state.frames.length <= 1) return;
pushUndo();
state.frames.splice(state.currentFrame, 1);
if (state.currentFrame >= state.frames.length) state.currentFrame = state.frames.length - 1;
renderAll();
}
function fillAll() {
pushUndo();
const grid = currentGrid();
for (let r = 0; r < grid.length; r++)
for (let c = 0; c < grid[0].length; c++)
grid[r][c] = 1;
renderAll();
}
function invertAll() {
pushUndo();
const grid = currentGrid();
for (let r = 0; r < grid.length; r++)
for (let c = 0; c < grid[0].length; c++)
grid[r][c] = grid[r][c] ? 0 : 1;
renderAll();
}
function clearGrid() {
pushUndo();
const grid = currentGrid();
for (let r = 0; r < grid.length; r++)
for (let c = 0; c < grid[0].length; c++)
grid[r][c] = 0;
renderAll();
}
// ===== PLAYBACK =====
function startPlayback() {
if (state.frames.length <= 1) return;
state.isPlaying = true;
state.playDirection = 1;
document.getElementById('btn-play').textContent = '⏸ Pause';
state.playIntervalId = setInterval(() => {
const next = state.currentFrame + state.playDirection;
if (next >= 0 && next < state.frames.length) {
state.currentFrame = next;
} else if (state.bounce) {
state.playDirection *= -1;
state.currentFrame += state.playDirection;
} else if (state.loop) {
state.currentFrame = 0;
} else {
stopPlayback();
return;
}
renderAll();
}, 1000 / state.fps);
}
function stopPlayback() {
state.isPlaying = false;
document.getElementById('btn-play').textContent = '▶ Play';
if (state.playIntervalId) {
clearInterval(state.playIntervalId);
state.playIntervalId = null;
}
}
function togglePlayback() {
if (state.isPlaying) stopPlayback();
else startPlayback();
}
// ===== GRID RESIZE =====
function resizeGrid(newCols, newRows) {
pushUndo();
const newPixelRows = newRows * 4;
const newPixelCols = newCols * 2;
state.frames = state.frames.map(oldGrid => {
const newGrid = createGrid(newPixelRows, newPixelCols);
for (let r = 0; r < Math.min(oldGrid.length, newPixelRows); r++)
for (let c = 0; c < Math.min(oldGrid[0].length, newPixelCols); c++)
newGrid[r][c] = oldGrid[r][c];
return newGrid;
});
state.cellCols = newCols;
state.cellRows = newRows;
renderAll();
}
// ===== PROJECT SAVE/LOAD =====
function saveProject() {
const data = JSON.stringify({
cellCols: state.cellCols,
cellRows: state.cellRows,
fps: state.fps,
loop: state.loop,
bounce: state.bounce,
frames: state.frames,
}, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'dab.braille-project.json';
a.click();
URL.revokeObjectURL(a.href);
showToast('Project saved');
}
function loadProject() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
if (data.frames && data.frames.length) {
pushUndo();
state.cellCols = data.cellCols || 2;
state.cellRows = data.cellRows || 1;
state.frames = data.frames;
state.currentFrame = 0;
state.fps = data.fps || 4;
state.loop = data.loop !== false;
state.bounce = data.bounce === true;
document.getElementById('cell-cols').value = state.cellCols;
document.getElementById('cell-rows').value = state.cellRows;
document.getElementById('fps-slider').value = state.fps;
document.getElementById('fps-value').textContent = state.fps;
renderAll();
showToast('Project loaded');
}
} catch (err) {
showToast('Invalid project file');
}
};
reader.readAsText(file);
};
input.click();
}
// ===== COMMAND PALETTE =====
const commands = [
{ label: 'Draw mode', shortcut: 'D', action: () => setMode('draw') },
{ label: 'Erase mode', shortcut: 'E', action: () => setMode('erase') },
{ label: 'Line tool', shortcut: 'L', action: () => setMode('line') },
{ label: 'Rectangle tool', shortcut: 'R', action: () => setMode('rect') },
{ label: 'Fill all', shortcut: 'F', action: fillAll },
{ label: 'Invert grid', shortcut: 'I', action: invertAll },
{ label: 'Clear grid', shortcut: 'C', action: clearGrid },
{ label: 'New frame', shortcut: 'N', action: addFrame },
{ label: 'Duplicate frame', shortcut: '', action: duplicateFrame },
{ label: 'Delete frame', shortcut: 'Del', action: deleteFrame },
{ label: 'Play / Pause', shortcut: 'Space', action: togglePlayback },
{ label: 'Previous frame', shortcut: '←', action: () => { if (state.currentFrame > 0) { state.currentFrame--; renderAll(); } } },
{ label: 'Next frame', shortcut: '→', action: () => { if (state.currentFrame < state.frames.length - 1) { state.currentFrame++; renderAll(); } } },
{ label: 'Toggle guides', shortcut: 'G', action: () => { state.showGuides = !state.showGuides; renderGrid(); } },
{ label: 'Cycle mirror', shortcut: 'M', action: cycleMirror },
{ label: 'Toggle loop', shortcut: '', action: () => { state.loop = !state.loop; if (state.loop) { state.bounce = false; state.playDirection = 1; document.getElementById('btn-bounce').classList.remove('active'); } document.getElementById('btn-loop').classList.toggle('active', state.loop); } },
{ label: 'Toggle bounce', shortcut: '', action: () => { state.bounce = !state.bounce; if (state.bounce) { state.loop = false; document.getElementById('btn-loop').classList.remove('active'); } else { state.playDirection = 1; } document.getElementById('btn-bounce').classList.toggle('active', state.bounce); } },
{ label: 'Undo', shortcut: '⌘Z', action: undo },
{ label: 'Redo', shortcut: '⌘⇧Z', action: redo },
{ label: 'Save project', shortcut: '⌘S', action: saveProject },
{ label: 'Import animation', shortcut: '', action: () => openModal('import-modal') },
{ label: 'Load project', shortcut: '', action: loadProject },
];
let paletteIndex = 0;
let filteredCommands = [...commands];
function openPalette() {
const modal = document.getElementById('command-palette');
modal.classList.add('open');
const input = document.getElementById('palette-search');
input.value = '';
input.focus();
filteredCommands = [...commands];
paletteIndex = 0;
renderPalette();
}
function closePalette() {
document.getElementById('command-palette').classList.remove('open');
}
function renderPalette() {
const list = document.getElementById('palette-list');
list.innerHTML = '';
filteredCommands.forEach((cmd, i) => {
const item = document.createElement('div');
item.className = 'palette-item' + (i === paletteIndex ? ' selected' : '');
item.innerHTML = `${cmd.label}${cmd.shortcut}`;
item.addEventListener('click', () => { cmd.action(); closePalette(); });
item.addEventListener('mouseenter', () => { paletteIndex = i; renderPalette(); });
list.appendChild(item);
});
}
function filterPalette(query) {
const q = query.toLowerCase();
filteredCommands = commands.filter(c => c.label.toLowerCase().includes(q));
paletteIndex = 0;
renderPalette();
}
// ===== IMPORT MODAL =====
let importData = null;
function openModal(id) {
document.getElementById(id).classList.add('open');
if (id === 'import-modal') {
document.getElementById('import-textarea').value = '';
document.getElementById('import-info').textContent = '';
document.getElementById('import-error').textContent = '';
document.getElementById('import-confirm').disabled = true;
importData = null;
}
}
function closeModal(id) {
document.getElementById(id).classList.remove('open');
}
let importDebounce = null;
function handleImportInput(text) {
clearTimeout(importDebounce);
importDebounce = setTimeout(() => {
const result = parseImport(text);
const info = document.getElementById('import-info');
const error = document.getElementById('import-error');
const btn = document.getElementById('import-confirm');
if (!text.trim()) {
info.textContent = '';
error.textContent = '';
btn.disabled = true;
importData = null;
return;
}
if (result) {
importData = result;
info.textContent = `Detected: ${result.format} | Frames: ${result.frames.length} | Grid: ${result.cols}×${result.rows}`;
error.textContent = '';
btn.disabled = false;
btn.textContent = `Import ${result.frames.length} frame${result.frames.length > 1 ? 's' : ''}`;
} else {
importData = null;
info.textContent = '';
error.textContent = 'Could not detect format. Check your data.';
btn.disabled = true;
btn.textContent = 'Import';
}
}, 300);
}
function doImport() {
if (!importData) return;
pushUndo();
const mode = document.querySelector('input[name="import-mode"]:checked').value;
if (mode === 'replace') {
state.frames = importData.frames;
state.currentFrame = 0;
} else {
state.frames.push(...importData.frames);
}
state.cellCols = importData.cols;
state.cellRows = importData.rows;
document.getElementById('cell-cols').value = state.cellCols;
document.getElementById('cell-rows').value = state.cellRows;
closeModal('import-modal');
renderAll();
showToast(`Imported ${importData.frames.length} frame${importData.frames.length > 1 ? 's' : ''}`);
}
// ===== CONFIRM DIALOG =====
let confirmCallback = null;
function showConfirm(title, message, callback) {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-message').textContent = message;
confirmCallback = callback;
openModal('confirm-dialog');
}
// ===== MODE =====
function setMode(m) {
state.mode = m;
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === m);
});
state.lineStart = null;
state.rectStart = null;
const gridEl = document.getElementById('dot-grid');
if (m === 'draw' || m === 'erase') gridEl.dataset.cursor = m;
else delete gridEl.dataset.cursor;
}
function cycleMirror() {
const modes = ['off', 'h', 'v', 'hv'];
const i = modes.indexOf(state.mirrorMode);
state.mirrorMode = modes[(i + 1) % modes.length];
showToast('Mirror: ' + state.mirrorMode.toUpperCase());
}
// ===== COPY =====
function copyToClipboard(text) {
navigator.clipboard.writeText(text).catch(() => {});
}
// ===== DOT GRID INTERACTION =====
function setupGridEvents() {
const gridEl = document.getElementById('dot-grid');
gridEl.addEventListener('pointerdown', (e) => {
const dot = e.target.closest('.dot');
if (!dot) return;
e.preventDefault();
const r = parseInt(dot.dataset.r);
const c = parseInt(dot.dataset.c);
const grid = currentGrid();
if (state.mode === 'line') {
if (!state.lineStart) {
state.lineStart = [r, c];
showToast('Line: click end point');
} else {
pushUndo();
const points = bresenhamLine(state.lineStart[0], state.lineStart[1], r, c);
points.forEach(([pr, pc]) => {
getMirrorPoints(pr, pc, pixelRows(), pixelCols(), state.mirrorMode).forEach(([mr, mc]) => {
if (mr >= 0 && mr < grid.length && mc >= 0 && mc < grid[0].length) grid[mr][mc] = 1;
});
});
state.lineStart = null;
renderAll();
}
return;
}
if (state.mode === 'rect') {
if (!state.rectStart) {
state.rectStart = [r, c];
showToast('Rect: click opposite corner');
} else {
pushUndo();
const fn = e.shiftKey ? rectFilled : rectOutline;
const points = fn(state.rectStart[0], state.rectStart[1], r, c);
points.forEach(([pr, pc]) => {
getMirrorPoints(pr, pc, pixelRows(), pixelCols(), state.mirrorMode).forEach(([mr, mc]) => {
if (mr >= 0 && mr < grid.length && mc >= 0 && mc < grid[0].length) grid[mr][mc] = 1;
});
});
state.rectStart = null;
renderAll();
}
return;
}
state.isPointerDown = true;
state._lastR = r;
state._lastC = c;
gridEl.setPointerCapture(e.pointerId);
gridEl.classList.add('pressing');
pushUndo();
const val = (e.shiftKey || state.mode === 'erase') ? 0 : 1;
state.paintVal = val;
getMirrorPoints(r, c, pixelRows(), pixelCols(), state.mirrorMode).forEach(([mr, mc]) => {
if (mr >= 0 && mr < grid.length && mc >= 0 && mc < grid[0].length) grid[mr][mc] = val;
});
renderAll();
});
gridEl.addEventListener('pointermove', (e) => {
if (!state.isPointerDown) return;
const dot = document.elementFromPoint(e.clientX, e.clientY)?.closest('.dot');
if (!dot) return;
const r = parseInt(dot.dataset.r);
const c = parseInt(dot.dataset.c);
if (state._lastR === r && state._lastC === c) return;
state._lastR = r;
state._lastC = c;
const grid = currentGrid();
const val = state.paintVal;
getMirrorPoints(r, c, pixelRows(), pixelCols(), state.mirrorMode).forEach(([mr, mc]) => {
if (mr >= 0 && mr < grid.length && mc >= 0 && mc < grid[0].length) grid[mr][mc] = val;
});
renderAll();
});
gridEl.addEventListener('lostpointercapture', () => {
state.isPointerDown = false;
state._lastR = -1;
state._lastC = -1;
gridEl.classList.remove('pressing');
});
document.addEventListener('pointerup', () => {
state.isPointerDown = false;
state._lastR = -1;
state._lastC = -1;
gridEl.classList.remove('pressing');
});
}
// ===== KEYBOARD SHORTCUTS =====
function setupShortcuts() {
document.addEventListener('keydown', (e) => {
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
if (e.key === 'Escape') {
closePalette();
closeModal('import-modal');
closeModal('confirm-dialog');
}
if (e.target.id === 'palette-search') {
if (e.key === 'ArrowDown') { e.preventDefault(); paletteIndex = Math.min(paletteIndex + 1, filteredCommands.length - 1); renderPalette(); }
if (e.key === 'ArrowUp') { e.preventDefault(); paletteIndex = Math.max(paletteIndex - 1, 0); renderPalette(); }
if (e.key === 'Enter' && filteredCommands[paletteIndex]) { filteredCommands[paletteIndex].action(); closePalette(); }
}
return;
}
const meta = e.metaKey || e.ctrlKey;
if (e.key === 'Escape') {
closePalette();
closeModal('import-modal');
closeModal('confirm-dialog');
return;
}
if (meta && e.key === 'k') { e.preventDefault(); openPalette(); return; }
if (meta && e.shiftKey && e.key === 'z') { e.preventDefault(); redo(); return; }
if (meta && e.key === 'z') { e.preventDefault(); undo(); return; }
if (meta && e.key === 's') { e.preventDefault(); saveProject(); return; }
if (meta && e.key === 'c') {
e.preventDefault();
copyToClipboard(JSON.stringify(currentGrid()));
showToast('Frame copied');
return;
}
if (meta && e.key === 'x') {
e.preventDefault();
copyToClipboard(JSON.stringify(currentGrid()));
deleteFrame();
showToast('Frame cut');
return;
}
if (meta && e.key === 'v') {
e.preventDefault();
navigator.clipboard.readText().then(text => {
try {
const grid = JSON.parse(text);
if (Array.isArray(grid) && Array.isArray(grid[0])) {
pushUndo();
state.frames.splice(state.currentFrame + 1, 0, grid);
state.currentFrame++;
renderAll();
showToast('Frame pasted');
}
} catch (err) {}
});
return;
}
switch (e.key.toLowerCase()) {
case 'd': setMode('draw'); break;
case 'e': setMode('erase'); break;
case 'l': setMode('line'); break;
case 'r': setMode('rect'); break;
case 'f': fillAll(); break;
case 'i': invertAll(); break;
case 'c': clearGrid(); break;
case 'n': addFrame(); break;
case 'g': state.showGuides = !state.showGuides; renderGrid(); break;
case 'm': cycleMirror(); break;
case ' ': e.preventDefault(); togglePlayback(); break;
case 'arrowleft': if (state.currentFrame > 0) { state.currentFrame--; renderAll(); } break;
case 'arrowright': if (state.currentFrame < state.frames.length - 1) { state.currentFrame++; renderAll(); } break;
case 'delete': case 'backspace': deleteFrame(); break;
}
});
}
// ===== WIRE UP UI =====
function init() {
loadSaved();
setupGridEvents();
setupShortcuts();
document.getElementById('dot-grid').dataset.cursor = state.mode === 'draw' || state.mode === 'erase' ? state.mode : '';
// Grid size selects
document.getElementById('cell-cols').addEventListener('change', (e) => resizeGrid(parseInt(e.target.value), state.cellRows));
document.getElementById('cell-rows').addEventListener('change', (e) => resizeGrid(state.cellCols, parseInt(e.target.value)));
// Mode buttons
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => setMode(btn.dataset.mode));
});
// Tool buttons
document.getElementById('btn-fill').addEventListener('click', fillAll);
document.getElementById('btn-invert').addEventListener('click', invertAll);
document.getElementById('btn-clear').addEventListener('click', clearGrid);
document.getElementById('btn-new-frame').addEventListener('click', addFrame);
document.getElementById('btn-dup-frame').addEventListener('click', duplicateFrame);
document.getElementById('btn-del-frame').addEventListener('click', deleteFrame);
document.getElementById('btn-prev-frame').addEventListener('click', () => { if (state.currentFrame > 0) { state.currentFrame--; renderAll(); } });
document.getElementById('btn-next-frame').addEventListener('click', () => { if (state.currentFrame < state.frames.length - 1) { state.currentFrame++; renderAll(); } });
// Transport
document.getElementById('btn-prev').addEventListener('click', () => { if (state.currentFrame > 0) { state.currentFrame--; renderAll(); } });
document.getElementById('btn-play').addEventListener('click', togglePlayback);
document.getElementById('btn-next').addEventListener('click', () => { if (state.currentFrame < state.frames.length - 1) { state.currentFrame++; renderAll(); } });
// FPS
document.getElementById('fps-slider').addEventListener('input', (e) => {
state.fps = parseInt(e.target.value);
document.getElementById('fps-value').textContent = state.fps;
if (state.isPlaying) { stopPlayback(); startPlayback(); }
});
// Toggles
document.getElementById('btn-loop').addEventListener('click', () => {
state.loop = !state.loop;
if (state.loop) {
state.bounce = false;
state.playDirection = 1;
document.getElementById('btn-bounce').classList.remove('active');
}
document.getElementById('btn-loop').classList.toggle('active', state.loop);
renderOutputs();
});
document.getElementById('btn-bounce').addEventListener('click', () => {
state.bounce = !state.bounce;
if (state.bounce) {
state.loop = false;
document.getElementById('btn-loop').classList.remove('active');
} else {
state.playDirection = 1;
}
document.getElementById('btn-bounce').classList.toggle('active', state.bounce);
renderOutputs();
});
document.getElementById('btn-onion').addEventListener('click', () => {
state.onionSkin = !state.onionSkin;
document.getElementById('btn-onion').classList.toggle('active', state.onionSkin);
renderGrid();
});
// Import/Save/Load
document.getElementById('btn-import').addEventListener('click', () => openModal('import-modal'));
document.getElementById('btn-save').addEventListener('click', saveProject);
document.getElementById('btn-load').addEventListener('click', loadProject);
// Import modal
document.getElementById('import-textarea').addEventListener('input', (e) => handleImportInput(e.target.value));
document.getElementById('import-file-btn').addEventListener('click', () => document.getElementById('import-file-input').click());
document.getElementById('import-file-input').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
document.getElementById('import-textarea').value = ev.target.result;
handleImportInput(ev.target.result);
};
reader.readAsText(file);
});
document.getElementById('import-cancel').addEventListener('click', () => closeModal('import-modal'));
document.getElementById('import-confirm').addEventListener('click', doImport);
// Animation format
document.getElementById('anim-format').addEventListener('change', (e) => {
state.animFormat = e.target.value;
renderOutputs();
});
// Copy buttons
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const target = document.getElementById(btn.dataset.target);
if (target) {
copyToClipboard(target.textContent);
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1200);
}
});
});
// Command palette
document.getElementById('palette-search').addEventListener('input', (e) => filterPalette(e.target.value));
// Confirm dialog
document.getElementById('confirm-cancel').addEventListener('click', () => closeModal('confirm-dialog'));
document.getElementById('confirm-ok').addEventListener('click', () => {
closeModal('confirm-dialog');
if (confirmCallback) { confirmCallback(); confirmCallback = null; }
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('open');
});
});
renderAll();
}
document.addEventListener('DOMContentLoaded', init);