diff --git a/docs/PIXEL_ART_FEATURE.md b/docs/PIXEL_ART_FEATURE.md
index e211e4c..c029740 100644
--- a/docs/PIXEL_ART_FEATURE.md
+++ b/docs/PIXEL_ART_FEATURE.md
@@ -84,6 +84,25 @@ template, it is *any design*.
---
+## Implementation status
+
+- **Phase A1 — embedded pixel layer: built (pending in-browser testing).**
+ The `pixelart` layer type + `pixelArt` data model (palette + RLE
+ frames), `renderPixelGridToCanvas` and crisp rendering across the
+ editor / preview-DOM renderers / export canvas. A `#pixelEditorView`
+ with pencil / eraser / fill / eyedropper, a palette panel (pick / add),
+ grid toggle, mirror X/Y, zoom, and a per-stroke in-editor undo.
+ Reachable via the **▦** tool-palette button ("Add Layer → Pixel Art")
+ and an "Edit pixels" button / double-click on a selected pixel layer.
+ Single static frame only.
+- **Phase A2 — standalone Pixel Art design** (`templateMode: 'pixelart'`,
+ publish/fork as `casewrap-pixelart`, `pixel-art` category): not started.
+- **Phases B / C / D:** not started. (Note: a `drawPixelArtToCanvas`
+ export path is already wired, so pixel layers appear in PDF/JPEG
+ export — Phase B's dedicated PNG export is still pending.)
+
+---
+
## Why this fits the repo
### What's reused
diff --git a/src/index.html b/src/index.html
index 49be5e1..4361076 100644
--- a/src/index.html
+++ b/src/index.html
@@ -566,6 +566,7 @@
body.template-deck:not(.editing-deck-slide) #tool-pencil,
body.template-deck:not(.editing-deck-slide) #tool-shape,
body.template-deck:not(.editing-deck-slide) #tool-text,
+ body.template-deck:not(.editing-deck-slide) #tool-pixel,
body.template-deck:not(.editing-deck-slide) .toolPaletteDivider { display: none !important; }
#deckPresentBtn { display: none; font-size: .9rem; }
body.template-deck #deckPresentBtn,
@@ -710,6 +711,66 @@
transform: translateY(115%); opacity: 0; pointer-events: none;
}
+ /* ---- Pixel Editor ---- */
+ #pixelEditorView { display: none; }
+ body.pixel-editing #pixelEditorView {
+ display: flex; flex-direction: column; width: 100%;
+ min-height: calc(100vh - 160px);
+ }
+ body.pixel-editing #sheetWrap,
+ body.pixel-editing #discDesignerView,
+ body.pixel-editing #deckSorterView,
+ body.pixel-editing #toolPalette,
+ body.pixel-editing #toolOptions,
+ body.pixel-editing #canvasToolbar { display: none !important; }
+ .pixelEditorBar {
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+ padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel);
+ }
+ .pixelToolGroup { display: flex; gap: 2px; }
+ .pixelToolBtn, .pixelBarBtn {
+ border: 1px solid var(--line); background: #fff; color: var(--ink);
+ border-radius: 6px; cursor: pointer; font-size: .95rem;
+ width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
+ }
+ .pixelBarBtn { width: auto; padding: 0 10px; font-size: .82rem; }
+ .pixelToolBtn:hover, .pixelBarBtn:hover { background: #f1f5f9; }
+ .pixelToolBtn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
+ .pixelBarToggle { display: flex; align-items: center; gap: 4px; font-size: .78rem; cursor: pointer; }
+ .pixelBarDivider { width: 1px; height: 22px; background: var(--line); }
+ .pixelBarSpacer { flex: 1; }
+ .pixelBarInfo { font-size: .76rem; color: #64748b; }
+ .pixelEditorMain { flex: 1; display: flex; min-height: 0; }
+ .pixelEditorCanvasWrap {
+ flex: 1; overflow: auto; background: #2a2f3a;
+ display: flex; align-items: center; justify-content: center; padding: 24px;
+ }
+ #pixelGridCanvas {
+ image-rendering: pixelated; image-rendering: crisp-edges;
+ background: repeating-conic-gradient(#cbd5e1 0% 25%, #f1f5f9 0% 50%) 0 0 / 16px 16px;
+ box-shadow: 0 0 0 1px rgba(0,0,0,.35);
+ cursor: crosshair; touch-action: none;
+ }
+ .pixelPalettePanel {
+ width: 188px; flex: 0 0 auto; border-left: 1px solid var(--line);
+ background: var(--panel); padding: 12px; box-sizing: border-box; overflow-y: auto;
+ }
+ .pixelPaletteLabel { font-weight: 600; font-size: .8rem; margin: 0 0 8px; }
+ .pixelPaletteSwatches { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; }
+ .pixelSwatch {
+ aspect-ratio: 1; border: 2px solid var(--line); border-radius: 4px;
+ cursor: pointer; padding: 0; background-color: transparent;
+ }
+ .pixelSwatch.pixelSwatchTransparent {
+ background-image: repeating-conic-gradient(#cbd5e1 0% 25%, #fff 0% 50%);
+ background-size: 8px 8px;
+ }
+ .pixelSwatch.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,184,230,.35); }
+ .pixelPaletteEdit { display: flex; gap: 6px; margin-top: 10px; }
+ .pixelPaletteEdit input[type=color] { flex: 0 0 auto; width: 38px; height: 32px; padding: 0; }
+ body.dark .pixelToolBtn, body.dark .pixelBarBtn { background: var(--panel); }
+ body.dark .pixelToolBtn:hover, body.dark .pixelBarBtn:hover { background: #1e293b; }
+
.designerDiscWrap {
position: relative;
width: 380px;
@@ -2799,6 +2860,13 @@
+
+
+ Edit pixels
+
+
Opens the pixel editor. Double-clicking the layer on the canvas works too.
+
+
QR data (URL or text)
@@ -3189,6 +3257,42 @@
← Back to deck
+
+
+
+
+ ✏
+ ⌫
+ ▣
+ ⊙
+
+
↶
+
+
Grid
+
Mirror X
+
Mirror Y
+
+
−
+
+
+
+
+
Done
+
+
+
+
+
+
+
Palette
+
+
+
+ + Add
+
+
Click a swatch to paint with it. Pick a colour and Add to extend the palette.
+
+
+
@@ -3199,6 +3303,7 @@
✎
▢
T
+ ▦
▶
@@ -7551,6 +7656,317 @@
});
}
+ // ---- Pixel-art layers --------------------------------------------
+ // A `pixelart` layer stores an editable palette-indexed grid in JSON;
+ // it renders to a drawn crisp (nearest-neighbour) at any size.
+ const DEFAULT_PIXEL_PALETTE = [
+ '#00000000', // index 0 — transparent, by convention
+ '#000000ff', '#ffffffff', '#9badb7ff', '#5a6988ff',
+ '#e43b44ff', '#f77622ff', '#feae34ff', '#fee761ff',
+ '#63c74dff', '#3e8948ff', '#0099dbff', '#124e89ff',
+ '#b55088ff', '#68386cff'
+ ];
+
+ function defaultPixelArt(w = 32, h = 32) {
+ return {
+ width: w, height: h,
+ palette: DEFAULT_PIXEL_PALETTE.slice(),
+ frames: [{ id: 'f1', pixels: [[0, w * h]] }]
+ };
+ }
+
+ function pixelHexTransparent(hex) {
+ return typeof hex === 'string' && hex.length === 9 && hex.slice(7).toLowerCase() === '00';
+ }
+
+ // RLE: pixels is an array of [paletteIndex, runLength], row-major.
+ function decodePixelRLE(frame, w, h) {
+ const total = w * h;
+ const out = new Uint8Array(total);
+ const runs = (frame && Array.isArray(frame.pixels)) ? frame.pixels : [];
+ let i = 0;
+ for (const run of runs) {
+ const idx = run[0] | 0;
+ let count = run[1] | 0;
+ while (count-- > 0 && i < total) out[i++] = idx;
+ }
+ return out;
+ }
+ function encodePixelRLE(indices) {
+ const runs = [];
+ for (let i = 0; i < indices.length; i++) {
+ const v = indices[i];
+ const last = runs[runs.length - 1];
+ if (last && last[0] === v) last[1]++;
+ else runs.push([v, 1]);
+ }
+ return runs;
+ }
+
+ // Decode a pixel grid to a native-size offscreen canvas.
+ function renderPixelGridToCanvas(pixelArt) {
+ const w = Math.max(1, (pixelArt && pixelArt.width | 0) || 1);
+ const h = Math.max(1, (pixelArt && pixelArt.height | 0) || 1);
+ const cv = document.createElement('canvas');
+ cv.width = w; cv.height = h;
+ const ctx = cv.getContext('2d');
+ const frame = (pixelArt && pixelArt.frames && pixelArt.frames[0]) || { pixels: [] };
+ const idx = decodePixelRLE(frame, w, h);
+ const pal = (pixelArt && pixelArt.palette) || [];
+ for (let i = 0; i < idx.length; i++) {
+ const hex = pal[idx[i]];
+ if (!hex || pixelHexTransparent(hex)) continue;
+ ctx.fillStyle = hex;
+ ctx.fillRect(i % w, (i / w) | 0, 1, 1);
+ }
+ return cv;
+ }
+
+ // Render a pixel-art layer into its freeLayer element (crisp scaling).
+ function renderPixelArtIntoElement(el, layer) {
+ el.innerHTML = '';
+ try {
+ const cv = renderPixelGridToCanvas(layer.pixelArt || {});
+ cv.style.cssText = 'width:100%;height:100%;display:block;image-rendering:pixelated;image-rendering:crisp-edges;pointer-events:none';
+ el.appendChild(cv);
+ } catch (e) {}
+ }
+
+ // Draw a pixel-art layer onto an export canvas (ctx centred on the layer).
+ function drawPixelArtToCanvas(ctx, layer, w, h) {
+ try {
+ const grid = renderPixelGridToCanvas(layer.pixelArt || {});
+ ctx.save();
+ ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(grid, -w / 2, -h / 2, w, h);
+ ctx.restore();
+ } catch (e) {}
+ }
+
+ // ---- Pixel Editor — interactive grid editing ----------------------
+ const PIXEL_ZOOMS = [2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 40];
+ let _pixelEditActive = false;
+ let _pixelEdit = null;
+
+ function openPixelEditor(layerId) {
+ const layer = getLayer(layerId);
+ if (!layer || layer.type !== 'pixelart') return;
+ const pa = layer.pixelArt || defaultPixelArt();
+ const w = (pa.width | 0) || 32, h = (pa.height | 0) || 32;
+ const frame = (pa.frames && pa.frames[0]) || { pixels: [] };
+ const palette = (pa.palette && pa.palette.length) ? pa.palette.slice() : DEFAULT_PIXEL_PALETTE.slice();
+ _pixelEdit = {
+ layerId, w, h,
+ indices: decodePixelRLE(frame, w, h),
+ palette,
+ current: Math.min(1, palette.length - 1),
+ tool: 'pencil',
+ zoom: 12,
+ gridOn: true, mirrorX: false, mirrorY: false,
+ undo: [], last: null
+ };
+ _pixelEditActive = true;
+ document.body.classList.add('pixel-editing');
+ // Fit zoom: the largest step that fits the editor viewport.
+ requestAnimationFrame(() => {
+ if (!_pixelEdit) return;
+ const wrap = $('pixelEditorCanvasWrap');
+ const availW = (wrap?.clientWidth || 640) - 60;
+ const availH = (wrap?.clientHeight || 420) - 60;
+ let z = PIXEL_ZOOMS[0];
+ for (const cand of PIXEL_ZOOMS) {
+ if (cand * w <= availW && cand * h <= availH) z = cand;
+ }
+ _pixelEdit.zoom = z;
+ renderPixelGrid();
+ });
+ pixelSyncToolButtons();
+ const gt = $('pixelGridToggle'); if (gt) gt.checked = true;
+ const mx = $('pixelMirrorX'); if (mx) mx.checked = false;
+ const my = $('pixelMirrorY'); if (my) my.checked = false;
+ const dims = $('pixelEditorDims'); if (dims) dims.textContent = `${w} × ${h} px`;
+ renderPixelPalette();
+ renderPixelGrid();
+ }
+
+ function closePixelEditor(commit = true) {
+ if (!_pixelEditActive) return;
+ if (commit && _pixelEdit) {
+ const layer = getLayer(_pixelEdit.layerId);
+ if (layer) {
+ pushHistory('edit pixel art');
+ layer.pixelArt = {
+ width: _pixelEdit.w, height: _pixelEdit.h,
+ palette: _pixelEdit.palette.slice(),
+ frames: [{ id: 'f1', pixels: encodePixelRLE(_pixelEdit.indices) }]
+ };
+ }
+ }
+ _pixelEditActive = false;
+ _pixelEdit = null;
+ document.body.classList.remove('pixel-editing');
+ render();
+ }
+
+ function renderPixelGrid() {
+ if (!_pixelEdit) return;
+ const cv = $('pixelGridCanvas');
+ if (!cv) return;
+ const { w, h, zoom, indices, palette, gridOn } = _pixelEdit;
+ cv.width = w * zoom;
+ cv.height = h * zoom;
+ const ctx = cv.getContext('2d');
+ ctx.clearRect(0, 0, cv.width, cv.height);
+ for (let i = 0; i < indices.length; i++) {
+ const hex = palette[indices[i]];
+ if (!hex || pixelHexTransparent(hex)) continue;
+ ctx.fillStyle = hex;
+ ctx.fillRect((i % w) * zoom, ((i / w) | 0) * zoom, zoom, zoom);
+ }
+ if (gridOn && zoom >= 5) {
+ ctx.strokeStyle = 'rgba(0,0,0,.16)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ for (let x = 0; x <= w; x++) { ctx.moveTo(x * zoom + 0.5, 0); ctx.lineTo(x * zoom + 0.5, h * zoom); }
+ for (let y = 0; y <= h; y++) { ctx.moveTo(0, y * zoom + 0.5); ctx.lineTo(w * zoom, y * zoom + 0.5); }
+ ctx.stroke();
+ }
+ }
+
+ function renderPixelPalette() {
+ if (!_pixelEdit) return;
+ const wrap = $('pixelPaletteSwatches');
+ if (!wrap) return;
+ wrap.innerHTML = '';
+ _pixelEdit.palette.forEach((hex, i) => {
+ const sw = document.createElement('button');
+ sw.type = 'button';
+ sw.className = 'pixelSwatch' + (i === _pixelEdit.current ? ' active' : '');
+ if (pixelHexTransparent(hex)) sw.classList.add('pixelSwatchTransparent');
+ else sw.style.backgroundColor = hex;
+ sw.title = i === 0 ? 'Transparent' : hex;
+ sw.addEventListener('click', () => { _pixelEdit.current = i; renderPixelPalette(); });
+ wrap.appendChild(sw);
+ });
+ }
+
+ function pixelSyncToolButtons() {
+ document.querySelectorAll('.pixelToolBtn').forEach((b) => {
+ b.classList.toggle('active', !!_pixelEdit && b.dataset.ptool === _pixelEdit.tool);
+ });
+ }
+
+ function pixelCellFromEvent(e) {
+ const cv = $('pixelGridCanvas');
+ if (!cv || !_pixelEdit) return null;
+ const r = cv.getBoundingClientRect();
+ const x = Math.floor((e.clientX - r.left) / r.width * _pixelEdit.w);
+ const y = Math.floor((e.clientY - r.top) / r.height * _pixelEdit.h);
+ if (x < 0 || y < 0 || x >= _pixelEdit.w || y >= _pixelEdit.h) return null;
+ return { x, y };
+ }
+
+ function pixelSnapshot() {
+ if (!_pixelEdit) return;
+ _pixelEdit.undo.push(_pixelEdit.indices.slice());
+ if (_pixelEdit.undo.length > 40) _pixelEdit.undo.shift();
+ }
+ function pixelUndo() {
+ if (!_pixelEdit || !_pixelEdit.undo.length) return;
+ _pixelEdit.indices = _pixelEdit.undo.pop();
+ renderPixelGrid();
+ }
+
+ function pixelPaintCell(x, y, idx) {
+ const { w, h, mirrorX, mirrorY, indices } = _pixelEdit;
+ const put = (cx, cy) => { if (cx >= 0 && cy >= 0 && cx < w && cy < h) indices[cy * w + cx] = idx; };
+ put(x, y);
+ if (mirrorX) put(w - 1 - x, y);
+ if (mirrorY) put(x, h - 1 - y);
+ if (mirrorX && mirrorY) put(w - 1 - x, h - 1 - y);
+ }
+ function pixelPaintLine(x0, y0, x1, y1, idx) {
+ let dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
+ let sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
+ let err = dx - dy;
+ while (true) {
+ pixelPaintCell(x0, y0, idx);
+ if (x0 === x1 && y0 === y1) break;
+ const e2 = 2 * err;
+ if (e2 > -dy) { err -= dy; x0 += sx; }
+ if (e2 < dx) { err += dx; y0 += sy; }
+ }
+ }
+ function pixelFloodFill(x, y, idx) {
+ const { w, h, indices } = _pixelEdit;
+ const target = indices[y * w + x];
+ if (target === idx) return;
+ const stack = [[x, y]];
+ while (stack.length) {
+ const [cx, cy] = stack.pop();
+ if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue;
+ if (indices[cy * w + cx] !== target) continue;
+ indices[cy * w + cx] = idx;
+ stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
+ }
+ }
+
+ function pixelApplyTool(cell, isStart) {
+ if (!_pixelEdit) return;
+ const t = _pixelEdit.tool;
+ if (t === 'eyedropper') {
+ if (!isStart) return;
+ _pixelEdit.current = _pixelEdit.indices[cell.y * _pixelEdit.w + cell.x];
+ renderPixelPalette();
+ return;
+ }
+ if (t === 'fill') {
+ if (!isStart) return;
+ pixelSnapshot();
+ pixelFloodFill(cell.x, cell.y, _pixelEdit.current);
+ renderPixelGrid();
+ return;
+ }
+ // pencil / eraser — drag-paint with line interpolation.
+ const idx = t === 'eraser' ? 0 : _pixelEdit.current;
+ if (isStart) { pixelSnapshot(); _pixelEdit.last = null; }
+ if (_pixelEdit.last) pixelPaintLine(_pixelEdit.last.x, _pixelEdit.last.y, cell.x, cell.y, idx);
+ else pixelPaintCell(cell.x, cell.y, idx);
+ _pixelEdit.last = { x: cell.x, y: cell.y };
+ renderPixelGrid();
+ }
+
+ function pixelStepZoom(dir) {
+ if (!_pixelEdit) return;
+ let i = PIXEL_ZOOMS.indexOf(_pixelEdit.zoom);
+ if (i < 0) i = 5;
+ i = Math.max(0, Math.min(PIXEL_ZOOMS.length - 1, i + dir));
+ _pixelEdit.zoom = PIXEL_ZOOMS[i];
+ renderPixelGrid();
+ }
+
+ // Add a new pixel-art layer to the current design and open the editor.
+ function addPixelArtLayer(targetOverride = '') {
+ const target = targetOverride || $('layerTarget')?.value || primaryTargetForMode(state.templateMode || 'cover');
+ const bounds = targetBounds(target);
+ applyTargetMode(target);
+ const size = Math.min(bounds.w, bounds.h) * 0.45;
+ pushHistory('add pixel art layer');
+ const layer = {
+ id: makeLayerId(),
+ type: 'pixelart',
+ name: 'Pixel art',
+ target,
+ x: bounds.x + (bounds.w - size) / 2,
+ y: bounds.y + (bounds.h - size) / 2,
+ w: size, h: size,
+ rotate: 0, opacity: 1, z: nextLayerZ(),
+ pixelArt: defaultPixelArt(32, 32)
+ };
+ addLayer(layer);
+ openPixelEditor(layer.id);
+ }
+
// ---- Vector shape layers (Phase A: primitives + solid fill) ----
const SHAPE_KINDS = ['rect', 'rounded-rect', 'circle', 'ellipse', 'triangle', 'polygon', 'star', 'line', 'path'];
@@ -8710,6 +9126,8 @@
renderShapeIntoElement(el, layer);
} else if (layer.type === 'qr') {
renderQrIntoElement(el, layer);
+ } else if (layer.type === 'pixelart') {
+ renderPixelArtIntoElement(el, layer);
} else {
const text = document.createElement('div');
text.className = 'textLayer';
@@ -8731,6 +9149,12 @@
}
el.addEventListener('pointerdown', startLayerDrag);
+ if (layer.type === 'pixelart') {
+ el.addEventListener('dblclick', (event) => {
+ event.stopPropagation();
+ openPixelEditor(layer.id);
+ });
+ }
el.addEventListener('click', (event) => {
event.stopPropagation();
state.selectedLayerId = layer.id;
@@ -10548,6 +10972,8 @@
const qrPanel = $('qrLayerControls');
if (qrPanel) qrPanel.style.display = layer && layer.type === 'qr' ? 'block' : 'none';
if (layer && layer.type === 'qr') syncQrLayerInputs(layer);
+ const pixelPanel = $('pixelLayerControls');
+ if (pixelPanel) pixelPanel.style.display = layer && layer.type === 'pixelart' ? 'block' : 'none';
if (layer) syncLayerClipInputs(layer);
const layerNameRow = $('layerName');
const layerNameLabel = layerNameRow?.previousElementSibling;
@@ -11212,6 +11638,8 @@
}
} else if (layer.type === 'qr') {
drawQrToCanvas(ctx, layer, w, h);
+ } else if (layer.type === 'pixelart') {
+ drawPixelArtToCanvas(ctx, layer, w, h);
} else if (layer.type === 'image' && layer.src) {
try {
const img = await loadExportImage(layer.src);
@@ -15167,6 +15595,8 @@
renderShapeIntoElement(el, layer);
} else if (layer.type === 'qr') {
renderQrIntoElement(el, layer);
+ } else if (layer.type === 'pixelart') {
+ renderPixelArtIntoElement(el, layer);
} else if (layer.type === 'image' && layer.src) {
const img = document.createElement('img');
img.src = layer.src;
@@ -15377,6 +15807,8 @@
renderShapeIntoElement(el, layer);
} else if (layer.type === 'qr') {
renderQrIntoElement(el, layer);
+ } else if (layer.type === 'pixelart') {
+ renderPixelArtIntoElement(el, layer);
} else if (layer.type === 'image' && layer.src) {
const img = document.createElement('img');
img.src = layer.src;
@@ -15458,6 +15890,8 @@
renderShapeIntoElement(el, layer);
} else if (layer.type === 'qr') {
renderQrIntoElement(el, layer);
+ } else if (layer.type === 'pixelart') {
+ renderPixelArtIntoElement(el, layer);
} else if (layer.type === 'image' && layer.src) {
const img = document.createElement('img');
img.src = layer.src;
@@ -15696,6 +16130,8 @@
renderShapeIntoElement(el, layer);
} else if (layer.type === 'qr') {
renderQrIntoElement(el, layer);
+ } else if (layer.type === 'pixelart') {
+ renderPixelArtIntoElement(el, layer);
} else if (layer.type === 'image' && layer.src) {
const img = document.createElement('img');
img.src = layer.src;
@@ -15834,6 +16270,8 @@
renderShapeIntoElement(el, layer);
} else if (layer.type === 'qr') {
renderQrIntoElement(el, layer);
+ } else if (layer.type === 'pixelart') {
+ renderPixelArtIntoElement(el, layer);
} else if (layer.type === 'image' && layer.src) {
const img = document.createElement('img');
img.src = layer.src;
@@ -16717,6 +17155,68 @@
// ---- Presenter Mode wiring ----
$('deckPresentBtn')?.addEventListener('click', presentCurrentDeck);
+
+ // ---- Pixel Editor wiring ----
+ $('tool-pixel')?.addEventListener('click', () => addPixelArtLayer());
+ $('editPixelsBtn')?.addEventListener('click', () => {
+ const l = selectedLayer();
+ if (l && l.type === 'pixelart') openPixelEditor(l.id);
+ });
+ $('pixelEditorDoneBtn')?.addEventListener('click', () => closePixelEditor(true));
+ $('pixelUndoBtn')?.addEventListener('click', pixelUndo);
+ $('pixelZoomIn')?.addEventListener('click', () => pixelStepZoom(1));
+ $('pixelZoomOut')?.addEventListener('click', () => pixelStepZoom(-1));
+ $('pixelGridToggle')?.addEventListener('change', (e) => {
+ if (_pixelEdit) { _pixelEdit.gridOn = e.target.checked; renderPixelGrid(); }
+ });
+ $('pixelMirrorX')?.addEventListener('change', (e) => { if (_pixelEdit) _pixelEdit.mirrorX = e.target.checked; });
+ $('pixelMirrorY')?.addEventListener('change', (e) => { if (_pixelEdit) _pixelEdit.mirrorY = e.target.checked; });
+ $('pixelAddColorBtn')?.addEventListener('click', () => {
+ if (!_pixelEdit) return;
+ const hex = (($('pixelColorPicker')?.value) || '#000000').toLowerCase() + 'ff';
+ _pixelEdit.palette.push(hex);
+ _pixelEdit.current = _pixelEdit.palette.length - 1;
+ renderPixelPalette();
+ });
+ document.querySelectorAll('.pixelToolBtn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ if (!_pixelEdit) return;
+ _pixelEdit.tool = btn.dataset.ptool;
+ pixelSyncToolButtons();
+ });
+ });
+ const _pgc = $('pixelGridCanvas');
+ if (_pgc) {
+ let _painting = false;
+ _pgc.addEventListener('pointerdown', (e) => {
+ if (!_pixelEditActive) return;
+ const cell = pixelCellFromEvent(e);
+ if (!cell) return;
+ e.preventDefault();
+ _painting = true;
+ try { _pgc.setPointerCapture(e.pointerId); } catch (err) {}
+ pixelApplyTool(cell, true);
+ });
+ _pgc.addEventListener('pointermove', (e) => {
+ if (!_pixelEditActive || !_painting) return;
+ const cell = pixelCellFromEvent(e);
+ if (cell) pixelApplyTool(cell, false);
+ });
+ const _endPaint = () => { _painting = false; if (_pixelEdit) _pixelEdit.last = null; };
+ _pgc.addEventListener('pointerup', _endPaint);
+ _pgc.addEventListener('pointercancel', _endPaint);
+ }
+ document.addEventListener('keydown', (e) => {
+ if (!_pixelEditActive) return;
+ const k = (e.key || '').toLowerCase();
+ if ((e.ctrlKey || e.metaKey) && k === 'z') { e.preventDefault(); e.stopPropagation(); pixelUndo(); }
+ else if (k === 'escape') { e.preventDefault(); e.stopPropagation(); closePixelEditor(true); }
+ else if (!e.ctrlKey && !e.metaKey && !isEditableFocus()) {
+ const map = { p: 'pencil', e: 'eraser', f: 'fill', i: 'eyedropper' };
+ if (map[k]) { e.preventDefault(); e.stopPropagation(); _pixelEdit.tool = map[k]; pixelSyncToolButtons(); }
+ }
+ }, true);
+
$('presenterPrevBtn')?.addEventListener('click', () => presenterNav(-1));
$('presenterNextBtn')?.addEventListener('click', () => presenterNav(1));
$('presenterViewBtn')?.addEventListener('click', presenterToggleView);