Paint: copy previous should skip empty frames

Copy previous looked for the nearest earlier ENABLED frame, and every frame is
enabled until you thin the strip out - so it resolved to f-1, which has nothing
on it, and the button appeared to only ever copy the immediate sibling while
reporting "nothing to copy".

It now takes the nearest earlier enabled frame that actually carries a drawing.
That behaves identically before and after you curate the strip, which is the
point: the rhythm of the drawings should not depend on whether you have got
round to deleting frames yet.

Cels stay tied to the keep-set. They are plate drawings and they hold until the
next enabled frame, as originally specified - an earlier version of this commit
gave them their own independent set, which is wrong for what they are.

Frames carrying a drawing are now marked in the strip, because "copy previous"
reaching back to a frame you cannot see is not much better than it reaching to
the wrong one.

Also fixes stale paint labels: onChange refreshed the strip and the panes but
not the paint header, so the drawn-frames readout lagged a copy behind. Split
the text update out of drawPaint so it can run from inside commit() without
re-rendering the canvas underneath itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-24 19:25:15 -04:00
parent 9d516a03ac
commit b68deb838a
3 changed files with 48 additions and 15 deletions

View file

@ -894,6 +894,10 @@ function drawStrip() {
const tag = document.createElement('span');
tag.textContent = f;
// Mark the frames that actually carry a drawing. Without it the only way to
// know where your cels are is to scrub and look, and "copy previous" then
// reaches back to somewhere you cannot see.
if ((state.cels.get(f) || []).length) cell.classList.add('cel');
cell.append(cv, tag);
cell.draggable = true;
cell.ondragstart = (ev) => ev.dataTransfer.setData('text/plain', String(f));
@ -1290,7 +1294,9 @@ const paint = new PaintUI({
}
compositeRender(cv, state.frame, z);
},
onChange: () => { saveCels(); drawPanes(); drawStrip(); drawWorksheet(); },
// paintLabels, not drawPaint: drawPaint re-renders the canvas, and this runs
// from inside commit(), which renders immediately afterwards anyway.
onChange: () => { saveCels(); paintLabels(); drawPanes(); drawStrip(); drawWorksheet(); },
});
el('paintTool').addEventListener('change', () => {
@ -1306,18 +1312,25 @@ el('btn-celclear').onclick = () => {
paint.commit();
};
el('btn-celprev').onclick = () => {
// Copy the previous kept frame's finished drawing onto this one. The same
// thing the drag-and-drop does, for the case you reach for constantly.
// Copy the last finished drawing onto this one - the case you reach for
// constantly, stepping forward and carrying the previous cel with you.
//
// "The last drawing" means the nearest earlier enabled frame that ACTUALLY
// HAS one, not simply the nearest earlier enabled frame. Every frame is
// enabled until you curate the strip, so the naive rule resolved to f-1,
// which is empty, and the button looked like it only ever copied the frame
// immediately to the left. Skipping the empties makes it behave the same
// before and after you thin the strip out.
if (!state.dense) return;
const kept = keptSorted(), here = celFrame();
const prev = kept.filter((k) => k < here).pop();
const here = celFrame();
const prev = keptSorted()
.filter((k) => k < here && (state.cels.get(k) || []).length)
.pop();
if (prev === undefined) { paint.say('no earlier drawing to copy'); return; }
const src = state.cels.get(prev);
if (!src || !src.length) { paint.say(`f${prev} has nothing to copy`); return; }
state.cels.set(here, cloneCel(src));
state.cels.set(here, cloneCel(state.cels.get(prev)));
paint.sel = -1;
paint.commit();
paint.say(`copied f${prev} — ${src.length} layers`);
paint.say(`copied f${prev} → f${here} — ${state.cels.get(here).length} layers`);
};
PALETTE.forEach((p, i) => {
@ -1345,13 +1358,21 @@ function loadCels() {
}
function drawPaint() {
if (!state.dense) { paint.render(); return; }
paintLabels();
paint.render();
}
function paintLabels() {
if (!state.dense) return;
const kept = keptSorted(), cf = celFrame();
const until = (kept[kept.indexOf(cf) + 1] ?? state.dense.length) - 1;
const drawn = kept.filter((k) => (state.cels.get(k) || []).length);
el('paintframe').textContent =
`drawing cel f${cf}` + (until > cf ? ` — holds to f${until}` : '') +
(state.frame !== cf ? ` (playhead f${state.frame})` : '');
paint.render();
(state.frame !== cf ? ` · playhead f${state.frame}` : '');
el('paintcels').textContent = drawn.length
? `${drawn.length} drawn: ${drawn.join(' ')}`
: 'nothing drawn yet';
}
// #synth / #frames autorun, so the tool can be driven headlessly for smoke tests
@ -1368,3 +1389,4 @@ else status('ready — Load frames, then step with \u2190 \u2192 and delete with
window.__roto = state; // headless smoke test reads this
window.__render = compositeRender; // ...and renders arbitrary frames off-screen
window.__lead = leadIndex; // ...and resolves the performance frame
window.__drawAll = drawAll; // ...and forces a full redraw