mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-06 05:30:52 +00:00
Add multi-frame timeline UI to the pixel editor (Phase C1)
_pixelEdit now tracks frames[] with currentFrame index and the animation's fps/loop fields; _pixelEdit.indices is a live reference to the active frame's Uint8Array so every existing paint / fill / shape / mirror code path keeps mutating the right frame with no further changes. decodePixelArtFrames / encodePixelArtFrames round- trip the whole frame set between JSON RLE and in-memory typed arrays. A timeline strip below the canvas holds: - + / ⎘ / − buttons to add a blank frame, duplicate the current frame, and delete the current frame (delete disables when only one frame remains). - A scrollable row of frame thumbnails — each thumb is the frame's indices rendered at native size and scaled up with image-rendering: pixelated. Active frame has an accent-coloured border. - Click-to-switch and HTML5 drag-reorder. - Thumbnails refresh on stroke end, transform, resize, and frame ops. Left / Right arrow keys cycle to the previous / next frame (wraps). Transforms (flip / rotate / resize) now iterate over every frame so dims stay coherent across the animation. Undo snapshots capture the full frame set plus currentFrame; restoring an edit also restores the editing frame so the user lands back where they edited. Commit paths (closePixelEditor, pixelCommitIfStandalone, pixelEditTearDown) all encode all frames + fps + loop. Old single- frame saves decode correctly because decodePixelArtFrames falls back to a one-element array. sanitizePixelArtBlob caps at 32 frames as a defensive ceiling and preserves fps / loop. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
2a8d0e3707
commit
6dad832a8d
1 file changed
+303
-39
+303
-39
@@ -753,10 +753,39 @@
|
||||
.pixelBarSpacer { flex: 1; }
|
||||
.pixelBarInfo { font-size: .76rem; color: #64748b; }
|
||||
.pixelEditorMain { flex: 1; display: flex; min-height: 0; }
|
||||
.pixelEditorCanvasColumn { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
.pixelEditorCanvasWrap {
|
||||
flex: 1; overflow: auto; background: #2a2f3a;
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
}
|
||||
.pixelTimelineStrip {
|
||||
flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.pixelTimelineFrameOps { display: flex; gap: 4px; flex: 0 0 auto; }
|
||||
.pixelTimelineFrames {
|
||||
display: flex; gap: 4px; overflow-x: auto; flex: 1; min-width: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
.pixelTimelineFrame {
|
||||
flex: 0 0 auto; width: 44px; height: 44px; padding: 2px;
|
||||
border: 1px solid var(--line); border-radius: 4px;
|
||||
background: repeating-conic-gradient(#e2e8f0 0% 25%, #f8fafc 0% 50%) 0 0 / 8px 8px;
|
||||
cursor: pointer; position: relative;
|
||||
}
|
||||
.pixelTimelineFrame.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.pixelTimelineFrame.dragging { opacity: .5; }
|
||||
.pixelTimelineFrame.drop-target { border-color: var(--accent); border-style: dashed; }
|
||||
.pixelTimelineFrame canvas {
|
||||
width: 100%; height: 100%; display: block;
|
||||
image-rendering: pixelated; image-rendering: crisp-edges;
|
||||
}
|
||||
.pixelTimelineIndex {
|
||||
position: absolute; top: 1px; left: 3px; font-size: .58rem;
|
||||
color: #334155; background: rgba(255,255,255,.7); padding: 0 3px;
|
||||
border-radius: 3px; pointer-events: none; line-height: 1.2;
|
||||
}
|
||||
#pixelGridCanvas {
|
||||
image-rendering: pixelated; image-rendering: crisp-edges;
|
||||
background: repeating-conic-gradient(#cbd5e1 0% 25%, #f1f5f9 0% 50%) 0 0 / 16px 16px;
|
||||
@@ -3510,8 +3539,18 @@
|
||||
<button type="button" id="pixelEditorDoneBtn" class="primary">Done</button>
|
||||
</div>
|
||||
<div class="pixelEditorMain">
|
||||
<div class="pixelEditorCanvasWrap" id="pixelEditorCanvasWrap">
|
||||
<canvas id="pixelGridCanvas"></canvas>
|
||||
<div class="pixelEditorCanvasColumn">
|
||||
<div class="pixelEditorCanvasWrap" id="pixelEditorCanvasWrap">
|
||||
<canvas id="pixelGridCanvas"></canvas>
|
||||
</div>
|
||||
<div class="pixelTimelineStrip">
|
||||
<div class="pixelTimelineFrameOps">
|
||||
<button type="button" id="pixelFrameAddBtn" class="pixelBarBtn" title="Add blank frame">+</button>
|
||||
<button type="button" id="pixelFrameDupBtn" class="pixelBarBtn" title="Duplicate current frame">⎘</button>
|
||||
<button type="button" id="pixelFrameDelBtn" class="pixelBarBtn" title="Delete current frame">−</button>
|
||||
</div>
|
||||
<div class="pixelTimelineFrames" id="pixelTimelineFrames"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pixelPalettePanel">
|
||||
<div class="pixelPaletteLabel">Palette</div>
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user