history.js (9.9 KB)
1 // each frame is a base image plus a log of ops painted on top of it. undo 2 // and redo switch an action's ops off or on and redraw the tiles they cover 3 // from base. once an action leaves every undo stack it is sealed: folded 4 // into base and forgotten 5 const UNDO_MAX = 10; 6 const actions = new Map(); 7 // frames removed by a delete or merge, kept so the action can be undone 8 const limbo = new Map(); 9 const local = { undo: [], redo: [] }; 10 let actSeq = 0; 11 let verSeq = 0; 12 let stroke = null; 13 14 function copyCanvas(src) { 15 const cnv = document.createElement('canvas'); 16 cnv.width = CHUNK; 17 cnv.height = CHUNK; 18 cnv.getContext('2d').drawImage(src, 0, 0); 19 return cnv; 20 } 21 22 function getFrame(id) { 23 return frames.find((f) => f.id === id) || limbo.get(id) || null; 24 } 25 26 function resetTimeline() { 27 actions.clear(); 28 limbo.clear(); 29 local.undo.length = 0; 30 local.redo.length = 0; 31 stroke = null; 32 } 33 34 function strokeFor(fid) { 35 if (stroke && stroke.f === fid) return false; 36 closeStroke(); 37 stroke = { f: fid, a: ++actSeq }; 38 return true; 39 } 40 41 function closeStroke() { 42 net.flush(); 43 stroke = null; 44 } 45 46 function opKeys(op) { 47 const keys = new Set(); 48 const off = Math.floor(op.b / 2); 49 for (let i = 0; i + 3 < op.o.length; i += 4) { 50 const cx0 = Math.floor((Math.min(op.o[i], op.o[i + 2]) - off) / CHUNK); 51 const cx1 = Math.floor((Math.max(op.o[i], op.o[i + 2]) - off + op.b - 1) / CHUNK); 52 const cy0 = Math.floor((Math.min(op.o[i + 1], op.o[i + 3]) - off) / CHUNK); 53 const cy1 = Math.floor((Math.max(op.o[i + 1], op.o[i + 3]) - off + op.b - 1) / CHUNK); 54 for (let cy = cy0; cy <= cy1; cy++) { 55 for (let cx = cx0; cx <= cx1; cx++) keys.add(chunkKey(cx, cy)); 56 } 57 } 58 return keys; 59 } 60 61 function srcKeys(src) { 62 return src ? new Set([...src.keys(), ...src.base.keys()]) : new Set(); 63 } 64 65 // every tile write takes a fresh version, so a saved copy of a tile can 66 // tell whether it is still current 67 function bump(f, keys) { 68 for (const k of keys) f.vers.set(k, ++verSeq); 69 } 70 71 function versions(f, keys) { 72 const v = new Map(); 73 for (const k of keys) v.set(k, f.vers.get(k)); 74 return v; 75 } 76 77 function unchanged(f, v, keys) { 78 for (const k of keys) { 79 if (!v.has(k) || f.vers.get(k) !== v.get(k)) return false; 80 } 81 return true; 82 } 83 84 // taken before an undo redraws, so the redo can put the tiles back instead 85 // of replaying, as long as they still carry the versions the undo left 86 function snapshot(f, keys) { 87 const tiles = new Map(); 88 for (const k of keys) { 89 const c = f.get(k); 90 tiles.set(k, c ? copyCanvas(c.canvas) : null); 91 } 92 return { f, tiles, before: versions(f, keys), after: null }; 93 } 94 95 function restore(snap) { 96 const f = snap.f; 97 for (const [k, cnv] of snap.tiles) { 98 if (!cnv) { 99 f.delete(k); 100 continue; 101 } 102 const [cx, cy] = k.split(',').map(Number); 103 getOrCreateChunk(cx, cy, f).ctx.drawImage(cnv, 0, 0); 104 } 105 for (const [k, n] of snap.before) f.vers.set(k, n); 106 requestDraw(); 107 } 108 109 // base holds only tiles some logged op touches, copied just before the 110 // first one does 111 function registerKeys(f, keys) { 112 for (const k of keys) { 113 if (f.base.has(k)) continue; 114 const c = f.get(k); 115 f.base.set(k, c ? copyCanvas(c.canvas) : null); 116 } 117 } 118 119 function applyPaint(f, op, isNew) { 120 if (isNew) actions.set(op.a, { kind: 'stroke', f: f.id }); 121 const keys = opKeys(op); 122 registerKeys(f, keys); 123 const raster = rasterize(op.o, op.b, op.s); 124 const last = f.log[f.log.length - 1]; 125 if (last && last.a === op.a && !last.src && last.on && last.b === op.b && last.s === op.s 126 && last.c[0] === op.c[0] && last.c[1] === op.c[1] && last.c[2] === op.c[2]) { 127 last.o.push(...op.o); 128 for (const k of keys) last.keys.add(k); 129 last.raster = null; 130 } else { 131 f.log.push({ a: op.a, o: op.o.slice(), c: op.c, b: op.b, s: op.s, on: true, keys, raster }); 132 } 133 fillRaster(f, raster, op.c); 134 bump(f, keys); 135 if (f === chunks) requestDraw(); 136 } 137 138 function banish(rec, a) { 139 const f = frames.find((fr) => fr.id === rec.f); 140 if (!f) return false; 141 const i = removeFrame(f); 142 if (i < 0) return false; 143 rec.at = i; 144 f.limboBy = a; 145 limbo.set(f.id, f); 146 net.pending = net.pending.filter((p) => p.f !== f.id); 147 net.overlays.delete(f.id); 148 return true; 149 } 150 151 function unbanish(rec, a, land) { 152 const f = limbo.get(rec.f); 153 if (!f || f.limboBy !== a) return false; 154 limbo.delete(f.id); 155 f.limboBy = null; 156 insertFrame(f, rec.at, land); 157 return true; 158 } 159 160 function applyAdd(id, at, a, mine) { 161 const f = newFrameMap(id); 162 insertFrame(f, at, mine); 163 actions.set(a, { kind: 'add', f: f.id, at: frames.indexOf(f) }); 164 } 165 166 function applyDelete(id, a) { 167 const rec = { kind: 'del', f: id, at: 0 }; 168 if (!banish(rec, a)) return false; 169 actions.set(a, rec); 170 return true; 171 } 172 173 function applyMerge(id, intoId, a, mine) { 174 const top = frames.find((f) => f.id === id); 175 const bottom = frames.find((f) => f.id === intoId); 176 if (!top || !bottom || top === bottom) return false; 177 const wasCur = top === chunks; 178 const keys = srcKeys(top); 179 registerKeys(bottom, keys); 180 const rec = { kind: 'merge', f: id, into: intoId, at: 0 }; 181 if (!banish(rec, a)) return false; 182 movePeers(id, intoId); 183 bottom.log.push({ a, src: id, on: true }); 184 mergeInto(top, bottom); 185 bump(bottom, keys); 186 top.into = intoId; 187 actions.set(a, rec); 188 if (mine || wasCur) setFrame(frames.indexOf(bottom)); 189 requestDraw(); 190 return true; 191 } 192 193 function overlaps(keys, only) { 194 for (const k of keys) { 195 if (only.has(k)) return true; 196 } 197 return false; 198 } 199 200 function actionKeys(f, a) { 201 const keys = new Set(); 202 for (const op of f.log) { 203 if (op.a !== a) continue; 204 for (const k of op.keys) keys.add(k); 205 } 206 return keys; 207 } 208 209 function rebuild(f, only) { 210 if (only) { 211 registerKeys(f, only); 212 } else { 213 for (const op of f.log) registerKeys(f, op.src ? srcKeys(getFrame(op.src)) : op.keys); 214 only = new Set(f.base.keys()); 215 } 216 bump(f, only); 217 for (const k of only) { 218 const cnv = f.base.get(k); 219 if (!cnv) { 220 f.delete(k); 221 continue; 222 } 223 const [cx, cy] = k.split(',').map(Number); 224 getOrCreateChunk(cx, cy, f).ctx.drawImage(cnv, 0, 0); 225 } 226 // ops reach past the tiles being redrawn; the rest must stay as they are 227 paintClip = only; 228 try { 229 for (const op of f.log) { 230 if (!op.on) continue; 231 if (op.src) mergeInto(getFrame(op.src), f, only); 232 else if (overlaps(op.keys, only)) paintOpInto(f, op); 233 } 234 } finally { 235 paintClip = null; 236 } 237 requestDraw(); 238 // a merged frame's pixels live on in the frame it went into 239 if (f.into) { 240 const g = getFrame(f.into); 241 if (g) rebuild(g, only); 242 } 243 } 244 245 // only the sealed ops at the front of the log can join base; anything 246 // after a live op has to stay so the order is kept 247 function fold(f) { 248 let n = 0; 249 while (n < f.log.length && !actions.has(f.log[n].a)) n++; 250 if (!n) return; 251 const done = f.log.splice(0, n); 252 const tmp = new Map(); 253 for (const [k, cnv] of f.base) { 254 if (cnv) tmp.set(k, { canvas: cnv, ctx: cnv.getContext('2d') }); 255 } 256 for (const op of done) { 257 if (!op.on) continue; 258 if (op.src) { 259 const src = limbo.get(op.src); 260 if (!src) continue; 261 mergeInto(src, tmp); 262 destroyFrame(src); 263 } else { 264 paintOpInto(tmp, op); 265 } 266 } 267 const live = new Set(); 268 for (const op of f.log) { 269 for (const k of op.src ? srcKeys(getFrame(op.src)) : op.keys) live.add(k); 270 } 271 for (const k of [...f.base.keys()]) { 272 if (!live.has(k)) { 273 f.base.delete(k); 274 continue; 275 } 276 const t = tmp.get(k); 277 f.base.set(k, t ? t.canvas : null); 278 } 279 } 280 281 function destroyFrame(f) { 282 limbo.delete(f.id); 283 for (const op of f.log) { 284 if (!op.on || !op.src) continue; 285 const s = limbo.get(op.src); 286 if (s && s.limboBy === op.a) destroyFrame(s); 287 } 288 } 289 290 function seal(list) { 291 const touched = new Set(); 292 for (const a of list) { 293 const rec = actions.get(a); 294 if (!rec) continue; 295 actions.delete(a); 296 const f = getFrame(rec.f); 297 if (rec.kind === 'stroke') { 298 if (f) touched.add(f); 299 } else if (rec.kind === 'merge') { 300 const into = getFrame(rec.into); 301 if (into) touched.add(into); 302 } else if (f && f.limboBy === a) { 303 destroyFrame(f); 304 } 305 } 306 for (const f of touched) fold(f); 307 } 308 309 function redrawAction(rec, f, keys, on, top) { 310 const snap = rec.redo; 311 rec.redo = null; 312 if (on) { 313 if (snap && !f.into && snap.f === f && unchanged(f, snap.after, keys) 314 && (!top || unchanged(top, snap.top, keys))) restore(snap); 315 else rebuild(f, keys); 316 return; 317 } 318 const next = f.into ? null : snapshot(f, keys); 319 rebuild(f, keys); 320 if (next) { 321 next.after = versions(f, keys); 322 if (top) next.top = versions(top, keys); 323 rec.redo = next; 324 } 325 } 326 327 function toggle(a, on, mine) { 328 const rec = actions.get(a); 329 if (!rec) return; 330 if (rec.kind === 'stroke') { 331 const f = getFrame(rec.f); 332 if (!f) return; 333 for (const op of f.log) { 334 if (op.a === a) op.on = on; 335 } 336 redrawAction(rec, f, actionKeys(f, a), on, null); 337 if (mine && frames.includes(f)) setFrame(frames.indexOf(f)); 338 } else if (rec.kind === 'add' || rec.kind === 'del') { 339 if (on === (rec.kind === 'add')) unbanish(rec, a, mine); 340 else banish(rec, a); 341 } else if (rec.kind === 'merge') { 342 const bottom = getFrame(rec.into); 343 const op = bottom && bottom.log.find((o) => o.a === a); 344 if (!op) return; 345 if (on) { 346 const top = frames.find((f) => f.id === rec.f); 347 if (!top || !banish(rec, a)) return; 348 op.on = true; 349 top.into = bottom.id; 350 redrawAction(rec, bottom, srcKeys(top), true, top); 351 if (mine && frames.includes(bottom)) setFrame(frames.indexOf(bottom)); 352 } else { 353 const top = limbo.get(rec.f); 354 if (!top || !unbanish(rec, a, mine)) return; 355 op.on = false; 356 top.into = null; 357 redrawAction(rec, bottom, srcKeys(top), false, top); 358 } 359 } 360 } 361 362 function localActionId() { 363 return 'a' + (++actSeq); 364 } 365 366 function localAction(a) { 367 local.undo.push(a); 368 const done = local.redo.splice(0); 369 if (local.undo.length > UNDO_MAX) done.push(local.undo.shift()); 370 seal(done); 371 } 372 373 function stepHistory(from, to, on, t) { 374 closeStroke(); 375 // in a room the server keeps each painter's stacks 376 if (net.on) { 377 net.send({ t }); 378 return; 379 } 380 const a = from.pop(); 381 if (a === undefined) return; 382 to.push(a); 383 toggle(a, on, true); 384 dirty = true; 385 } 386 387 function undo() { stepHistory(local.undo, local.redo, false, 'undo'); } 388 function redo() { stepHistory(local.redo, local.undo, true, 'redo'); }