Palette
@@ -8529,6 +8568,28 @@
return runs;
}
+ // Decode every frame in a pixelArt blob into editor-side in-memory state.
+ // Always returns at least one frame so the editor never has to special-case
+ // an empty animation.
+ function decodePixelArtFrames(pa, w, h) {
+ const src = (pa && Array.isArray(pa.frames) && pa.frames.length) ? pa.frames : [{ id: 'f1', pixels: [] }];
+ return src.map((f, i) => ({
+ id: f.id || ('f' + (i + 1)),
+ durationMs: Math.max(20, (f.durationMs | 0) || 0) || 120,
+ indices: decodePixelRLE(f, w, h),
+ }));
+ }
+
+ // Encode the editor's in-memory frames back to JSON-shaped frame records.
+ // Drops default-only fields to keep the published payload tight.
+ function encodePixelArtFrames(frames) {
+ return frames.map((f, i) => {
+ const out = { id: f.id || ('f' + (i + 1)), pixels: encodePixelRLE(f.indices) };
+ if (f.durationMs && f.durationMs !== 120) out.durationMs = f.durationMs;
+ return out;
+ });
+ }
+
// Decode a pixel grid to a native-size offscreen canvas.
function renderPixelGridToCanvas(pixelArt) {
const w = Math.max(1, (pixelArt && pixelArt.width | 0) || 1);
@@ -8674,11 +8735,14 @@
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();
+ const frames = decodePixelArtFrames(pa, w, h);
_pixelEdit = {
layerId, w, h,
- indices: decodePixelRLE(frame, w, h),
+ frames, currentFrame: 0,
+ indices: frames[0].indices,
+ fps: Math.max(1, Math.min(60, (pa.fps | 0) || 8)),
+ loop: pa.loop !== false,
palette,
current: Math.min(1, palette.length - 1),
tool: 'pencil',
@@ -8708,6 +8772,7 @@
const dims = $('pixelEditorDims'); if (dims) dims.textContent = `${w} × ${h} px`;
renderPixelPalette();
renderPixelGrid();
+ renderPixelTimeline();
updateUndoRedoUI();
}
@@ -8723,7 +8788,9 @@
layer.pixelArt = {
width: _pixelEdit.w, height: _pixelEdit.h,
palette: _pixelEdit.palette.slice(),
- frames: [{ id: 'f1', pixels: encodePixelRLE(_pixelEdit.indices) }]
+ frames: encodePixelArtFrames(_pixelEdit.frames),
+ fps: _pixelEdit.fps,
+ loop: _pixelEdit.loop,
};
}
}
@@ -8796,17 +8863,23 @@
let _pixelHistorySuppress = false;
function _pixelCloneState() {
return {
- indices: _pixelEdit.indices.slice(),
+ frames: _pixelEdit.frames.map(f => ({
+ id: f.id, durationMs: f.durationMs, indices: f.indices.slice(),
+ })),
+ currentFrame: _pixelEdit.currentFrame,
w: _pixelEdit.w, h: _pixelEdit.h,
};
}
function _pixelRestoreState(s) {
- _pixelEdit.indices = s.indices;
+ _pixelEdit.frames = s.frames;
+ _pixelEdit.currentFrame = Math.max(0, Math.min(s.frames.length - 1, s.currentFrame | 0));
+ _pixelEdit.indices = _pixelEdit.frames[_pixelEdit.currentFrame].indices;
_pixelEdit.w = s.w;
_pixelEdit.h = s.h;
const dims = $('pixelEditorDims');
if (dims) dims.textContent = `${s.w} × ${s.h} px`;
renderPixelGrid();
+ renderPixelTimeline();
}
function pixelSnapshot() {
if (!_pixelEdit) return;
@@ -8907,34 +8980,45 @@
function pixelTransformOp(op) {
if (!_pixelEdit) return;
pixelSnapshot();
- const { w, h, indices } = _pixelEdit;
- if (op === 'flipH') {
- const out = new Uint8Array(w * h);
- for (let y = 0; y < h; y++)
- for (let x = 0; x < w; x++) out[y * w + (w - 1 - x)] = indices[y * w + x];
- _pixelEdit.indices = out;
- } else if (op === 'flipV') {
- const out = new Uint8Array(w * h);
- for (let y = 0; y < h; y++)
- for (let x = 0; x < w; x++) out[(h - 1 - y) * w + x] = indices[y * w + x];
- _pixelEdit.indices = out;
- } else if (op === 'rotCW' || op === 'rotCCW') {
+ const { w, h } = _pixelEdit;
+ // Transforms apply to every frame so the animation stays coherent;
+ // a rotate that only touched the current frame would mismatch dims
+ // across frames.
+ const transformOne = (src) => {
+ if (op === 'flipH') {
+ const out = new Uint8Array(w * h);
+ for (let y = 0; y < h; y++)
+ for (let x = 0; x < w; x++) out[y * w + (w - 1 - x)] = src[y * w + x];
+ return out;
+ }
+ if (op === 'flipV') {
+ const out = new Uint8Array(w * h);
+ for (let y = 0; y < h; y++)
+ for (let x = 0; x < w; x++) out[(h - 1 - y) * w + x] = src[y * w + x];
+ return out;
+ }
+ // rotCW / rotCCW
const newW = h, newH = w;
const out = new Uint8Array(newW * newH);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const nx = op === 'rotCW' ? h - 1 - y : y;
const ny = op === 'rotCW' ? x : w - 1 - x;
- out[ny * newW + nx] = indices[y * w + x];
+ out[ny * newW + nx] = src[y * w + x];
}
}
- _pixelEdit.w = newW;
- _pixelEdit.h = newH;
- _pixelEdit.indices = out;
+ return out;
+ };
+ for (const f of _pixelEdit.frames) f.indices = transformOne(f.indices);
+ if (op === 'rotCW' || op === 'rotCCW') {
+ _pixelEdit.w = h;
+ _pixelEdit.h = w;
const dims = $('pixelEditorDims');
- if (dims) dims.textContent = `${newW} × ${newH} px`;
+ if (dims) dims.textContent = `${h} × ${w} px`;
}
+ _pixelEdit.indices = _pixelEdit.frames[_pixelEdit.currentFrame].indices;
renderPixelGrid();
+ renderPixelTimeline();
pixelCommitIfStandalone();
}
@@ -8944,22 +9028,129 @@
newH = Math.max(4, Math.min(128, newH | 0));
if (newW === _pixelEdit.w && newH === _pixelEdit.h) return;
pixelSnapshot();
- const { w, h, indices } = _pixelEdit;
- // Zero-initialized Uint8Array = palette index 0 = transparent by convention.
- const out = new Uint8Array(newW * newH);
+ const w = _pixelEdit.w, h = _pixelEdit.h;
const cw = Math.min(w, newW);
const ch = Math.min(h, newH);
- for (let y = 0; y < ch; y++)
- for (let x = 0; x < cw; x++) out[y * newW + x] = indices[y * w + x];
+ // Resize every frame — zero-initialized Uint8Array = palette index 0 = transparent.
+ for (const f of _pixelEdit.frames) {
+ const out = new Uint8Array(newW * newH);
+ for (let y = 0; y < ch; y++)
+ for (let x = 0; x < cw; x++) out[y * newW + x] = f.indices[y * w + x];
+ f.indices = out;
+ }
_pixelEdit.w = newW;
_pixelEdit.h = newH;
- _pixelEdit.indices = out;
+ _pixelEdit.indices = _pixelEdit.frames[_pixelEdit.currentFrame].indices;
const dims = $('pixelEditorDims');
if (dims) dims.textContent = `${newW} × ${newH} px`;
renderPixelGrid();
+ renderPixelTimeline();
pixelCommitIfStandalone();
}
+ function pixelSetCurrentFrame(i) {
+ if (!_pixelEdit || !_pixelEdit.frames.length) return;
+ const ni = Math.max(0, Math.min(_pixelEdit.frames.length - 1, i | 0));
+ _pixelEdit.currentFrame = ni;
+ _pixelEdit.indices = _pixelEdit.frames[ni].indices;
+ _pixelEdit.last = null;
+ _pixelEdit.shapeBackup = null;
+ _pixelEdit.shapeStart = null;
+ renderPixelGrid();
+ renderPixelTimeline();
+ }
+
+ function _pixelNewFrameId() {
+ return 'f' + Math.random().toString(36).slice(2, 8);
+ }
+
+ function pixelFrameAdd() {
+ if (!_pixelEdit) return;
+ pixelSnapshot();
+ const at = _pixelEdit.currentFrame + 1;
+ _pixelEdit.frames.splice(at, 0, {
+ id: _pixelNewFrameId(),
+ durationMs: 120,
+ indices: new Uint8Array(_pixelEdit.w * _pixelEdit.h),
+ });
+ pixelSetCurrentFrame(at);
+ pixelCommitIfStandalone();
+ }
+
+ function pixelFrameDuplicate() {
+ if (!_pixelEdit) return;
+ const src = _pixelEdit.frames[_pixelEdit.currentFrame];
+ if (!src) return;
+ pixelSnapshot();
+ const at = _pixelEdit.currentFrame + 1;
+ _pixelEdit.frames.splice(at, 0, {
+ id: _pixelNewFrameId(),
+ durationMs: src.durationMs,
+ indices: src.indices.slice(),
+ });
+ pixelSetCurrentFrame(at);
+ pixelCommitIfStandalone();
+ }
+
+ function pixelFrameDelete() {
+ if (!_pixelEdit) return;
+ if (_pixelEdit.frames.length <= 1) return;
+ pixelSnapshot();
+ const i = _pixelEdit.currentFrame;
+ _pixelEdit.frames.splice(i, 1);
+ pixelSetCurrentFrame(Math.min(i, _pixelEdit.frames.length - 1));
+ pixelCommitIfStandalone();
+ }
+
+ function pixelFrameMove(from, to) {
+ if (!_pixelEdit) return;
+ const n = _pixelEdit.frames.length;
+ if (from < 0 || from >= n || to < 0 || to >= n || from === to) return;
+ pixelSnapshot();
+ const [f] = _pixelEdit.frames.splice(from, 1);
+ _pixelEdit.frames.splice(to, 0, f);
+ let cur = _pixelEdit.currentFrame;
+ if (cur === from) cur = to;
+ else if (from < cur && cur <= to) cur--;
+ else if (to <= cur && cur < from) cur++;
+ pixelSetCurrentFrame(cur);
+ pixelCommitIfStandalone();
+ }
+
+ // Render the frame strip below the canvas. Each frame is a tiny
+ // pixel-accurate thumbnail of its own indices.
+ function renderPixelTimeline() {
+ const host = $('pixelTimelineFrames');
+ if (!host || !_pixelEdit) return;
+ host.innerHTML = '';
+ const { w, h, frames, currentFrame, palette } = _pixelEdit;
+ // Thumb canvas is rendered at native cell size, then CSS scales it
+ // up with image-rendering: pixelated.
+ frames.forEach((f, i) => {
+ const slot = document.createElement('div');
+ slot.className = 'pixelTimelineFrame' + (i === currentFrame ? ' active' : '');
+ slot.draggable = true;
+ slot.dataset.idx = String(i);
+ const cv = document.createElement('canvas');
+ cv.width = w; cv.height = h;
+ const ctx = cv.getContext('2d');
+ for (let p = 0; p < f.indices.length; p++) {
+ const hex = palette[f.indices[p]];
+ if (!hex || pixelHexTransparent(hex)) continue;
+ ctx.fillStyle = pixelHexToRgba(hex);
+ ctx.fillRect(p % w, (p / w) | 0, 1, 1);
+ }
+ slot.appendChild(cv);
+ const idxBadge = document.createElement('span');
+ idxBadge.className = 'pixelTimelineIndex';
+ idxBadge.textContent = String(i + 1);
+ slot.appendChild(idxBadge);
+ host.appendChild(slot);
+ });
+ const delBtn = $('pixelFrameDelBtn');
+ if (delBtn) delBtn.disabled = frames.length <= 1;
+ }
+
function pixelResizeMenuToggle(forceOpen) {
const menu = $('pixelResizeMenu');
const btn = $('pixelResizeBtn');
@@ -9068,11 +9259,17 @@
const palette = (Array.isArray(pa.palette) && pa.palette.length)
? pa.palette.slice(0, 64).map(c => (typeof c === 'string' ? c : '#00000000'))
: DEFAULT_PIXEL_PALETTE.slice();
- const srcFrame = (Array.isArray(pa.frames) && pa.frames[0]) ? pa.frames[0] : { pixels: [] };
- // decode then re-encode so we own the buffer and catch corrupt RLE runs.
- const indices = decodePixelRLE(srcFrame, w, h);
- const pixels = encodePixelRLE(indices);
- return { width: w, height: h, palette, frames: [{ id: 'f1', pixels }] };
+ // decode then re-encode every frame so we own the buffers and catch
+ // corrupt RLE runs. Cap at 32 frames as a defensive ceiling.
+ const srcFrames = (Array.isArray(pa.frames) && pa.frames.length) ? pa.frames.slice(0, 32) : [{ pixels: [] }];
+ const frames = srcFrames.map((f, i) => ({
+ id: f.id || ('f' + (i + 1)),
+ pixels: encodePixelRLE(decodePixelRLE(f, w, h)),
+ }));
+ const out = { width: w, height: h, palette, frames };
+ if (pa.fps) out.fps = Math.max(1, Math.min(60, pa.fps | 0));
+ if (pa.loop === false) out.loop = false;
+ return out;
}
function importPixelArtAsLayer(payload, targetOverride = '') {
@@ -9173,7 +9370,9 @@
state.pixelArt = {
width: _pixelEdit.w, height: _pixelEdit.h,
palette: _pixelEdit.palette.slice(),
- frames: [{ id: 'f1', pixels: encodePixelRLE(_pixelEdit.indices) }]
+ frames: encodePixelArtFrames(_pixelEdit.frames),
+ fps: _pixelEdit.fps,
+ loop: _pixelEdit.loop,
};
}
@@ -9188,7 +9387,9 @@
layer.pixelArt = {
width: _pixelEdit.w, height: _pixelEdit.h,
palette: _pixelEdit.palette.slice(),
- frames: [{ id: 'f1', pixels: encodePixelRLE(_pixelEdit.indices) }]
+ frames: encodePixelArtFrames(_pixelEdit.frames),
+ fps: _pixelEdit.fps,
+ loop: _pixelEdit.loop,
};
}
}
@@ -9209,11 +9410,15 @@
if (!state.pixelArt) state.pixelArt = defaultPixelArt(32, 32);
const pa = state.pixelArt;
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();
+ const frames = decodePixelArtFrames(pa, w, h);
_pixelEdit = {
standalone: true, layerId: null, w, h,
- indices: decodePixelRLE(frame, w, h), palette,
+ frames, currentFrame: 0,
+ indices: frames[0].indices,
+ fps: Math.max(1, Math.min(60, (pa.fps | 0) || 8)),
+ loop: pa.loop !== false,
+ palette,
current: Math.min(1, palette.length - 1),
tool: 'pencil', zoom: 12, gridOn: true, mirrorX: false, mirrorY: false, shapeFill: false,
undo: [], redo: [], last: null, shapeBackup: null, shapeStart: null
@@ -9226,6 +9431,7 @@
const dims = $('pixelEditorDims'); if (dims) dims.textContent = `${w} × ${h} px`;
renderPixelPalette();
renderPixelGrid();
+ renderPixelTimeline();
updateUndoRedoUI();
requestAnimationFrame(() => {
if (!_pixelEdit || !_pixelEdit.standalone) return;
@@ -19427,6 +19633,52 @@
$('pixelMirrorX')?.addEventListener('change', (e) => { if (_pixelEdit) _pixelEdit.mirrorX = e.target.checked; });
$('pixelMirrorY')?.addEventListener('change', (e) => { if (_pixelEdit) _pixelEdit.mirrorY = e.target.checked; });
$('pixelShapeFill')?.addEventListener('change', (e) => { if (_pixelEdit) _pixelEdit.shapeFill = e.target.checked; });
+ // ---- Frame timeline wiring ----
+ $('pixelFrameAddBtn')?.addEventListener('click', () => pixelFrameAdd());
+ $('pixelFrameDupBtn')?.addEventListener('click', () => pixelFrameDuplicate());
+ $('pixelFrameDelBtn')?.addEventListener('click', () => pixelFrameDelete());
+ const _pixelFramesEl = $('pixelTimelineFrames');
+ if (_pixelFramesEl) {
+ _pixelFramesEl.addEventListener('click', (ev) => {
+ const slot = ev.target.closest('.pixelTimelineFrame');
+ if (!slot) return;
+ const idx = Number(slot.dataset.idx);
+ if (!Number.isFinite(idx)) return;
+ pixelSetCurrentFrame(idx);
+ });
+ // Drag-reorder: HTML5 drag/drop. Each .pixelTimelineFrame is draggable
+ // and we track the source index in a closure (dataTransfer can't be
+ // read on dragover, only on drop).
+ let _dragFromIdx = -1;
+ _pixelFramesEl.addEventListener('dragstart', (ev) => {
+ const slot = ev.target.closest('.pixelTimelineFrame');
+ if (!slot) return;
+ _dragFromIdx = Number(slot.dataset.idx);
+ slot.classList.add('dragging');
+ try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(_dragFromIdx)); } catch (e) {}
+ });
+ _pixelFramesEl.addEventListener('dragend', () => {
+ _pixelFramesEl.querySelectorAll('.dragging,.drop-target').forEach(el => {
+ el.classList.remove('dragging'); el.classList.remove('drop-target');
+ });
+ _dragFromIdx = -1;
+ });
+ _pixelFramesEl.addEventListener('dragover', (ev) => {
+ const slot = ev.target.closest('.pixelTimelineFrame');
+ if (!slot || _dragFromIdx < 0) return;
+ ev.preventDefault();
+ _pixelFramesEl.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target'));
+ slot.classList.add('drop-target');
+ });
+ _pixelFramesEl.addEventListener('drop', (ev) => {
+ const slot = ev.target.closest('.pixelTimelineFrame');
+ if (!slot || _dragFromIdx < 0) return;
+ ev.preventDefault();
+ const to = Number(slot.dataset.idx);
+ if (Number.isFinite(to) && to !== _dragFromIdx) pixelFrameMove(_dragFromIdx, to);
+ _dragFromIdx = -1;
+ });
+ }
// Sync the OS picker <-> hex text input <-> in-app HSV picker. The
// in-app picker (Sat/Val square + Hue strip) is the reliable input
// path; the other two are kept in sync purely for display / typing.
@@ -19642,6 +19894,9 @@
_pixelEdit.shapeStart = null;
}
pixelCommitIfStandalone();
+ // Refresh the timeline so the current frame's thumb reflects the
+ // stroke that just ended.
+ renderPixelTimeline();
};
_pgc.addEventListener('pointerup', _endPaint);
_pgc.addEventListener('pointercancel', _endPaint);
@@ -19653,6 +19908,15 @@
else if ((e.ctrlKey || e.metaKey) && k === 'y') { e.preventDefault(); e.stopPropagation(); pixelRedo(); }
else if (k === 'escape' && _pixelEdit && !_pixelEdit.standalone) { e.preventDefault(); e.stopPropagation(); closePixelEditor(true); }
else if (!e.ctrlKey && !e.metaKey && !isEditableFocus()) {
+ if (k === 'arrowleft' || k === 'arrowright') {
+ if (!_pixelEdit || _pixelEdit.frames.length <= 1) return;
+ e.preventDefault(); e.stopPropagation();
+ const n = _pixelEdit.frames.length;
+ const delta = k === 'arrowleft' ? -1 : 1;
+ // Wrap around so holding the key cycles through the animation.
+ pixelSetCurrentFrame((_pixelEdit.currentFrame + delta + n) % n);
+ return;
+ }
const map = { p: 'pencil', e: 'eraser', f: 'fill', i: 'eyedropper', l: 'line', r: 'rect', o: 'ellipse' };
if (map[k]) { e.preventDefault(); e.stopPropagation(); _pixelEdit.tool = map[k]; pixelSyncToolButtons(); }
}