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'); }