add per-painter undo/redo, merge down, fix multi-frame gif import Hunter Sunday, November 1st, 2026 at 10:41 PM
14 <script src="resources/view.js"></script> 15 <script src="resources/render.js"></script> 16 <script src="resources/io.js"></script> 17 +<script src="resources/history.js"></script> 18 <script src="resources/input.js"></script> 19 <script src="resources/net.js"></script> 20 <script>resize(); netConnect();</script>
37 - the last-tapped direction sets the playback direction 38 - press `space` to toggle playback 39 - you can continue painting while the canvas animates 40 +- press `M` to merge the current frame into the one below it 41 42 <img src="readme_images/controls.gif"> 43 44 +### undo / redo 45 +- press `⌘ + Z` to undo (up to 10 actions) 46 +- press `⌘ + shift + Z` to redo 47 + 48 ### importing / exporting 49 - hold `⌘` (or `ctrl`) and press `S` to save your painting as a .GIF file 50 - hold `⌘` (or `ctrl`) and press `O` to open an existing painting from your filesystem (will clear the current canvas)
Binary files differ.
4 5 // keyed cache: the local brush and every peer's brush pull from the same one 6 const brushShapes = new Map(); -const BRUSH_CACHE_MAX = 64; -function getBrushShape() { return brushShapeFor(brush, roundness, r, g, b); } -function brushShapeFor(n, round, sr, sg, sb) { - const key = n + ',' + round + ',' + sr + ',' + sg + ',' + sb; 7 +const BRUSH_CACHE_MAX = 1024; 8 +function brushShapeFor(n, round) { 9 + const key = n + ',' + round; 10 const hit = brushShapes.get(key); 11 if (hit) return hit; 12 if (brushShapes.size >= BRUSH_CACHE_MAX) brushShapes.clear(); 25 if (qx * qx + qy * qy <= rad2 + 1e-9) inside[dy * n + dx] = 1; 26 } 27 } - const sprite = document.createElement('canvas'); - sprite.width = n; - sprite.height = n; - const sctx = sprite.getContext('2d'); - const img = sctx.createImageData(n, n); - const data = img.data; - for (let i = 0; i < n * n; i++) { - if (inside[i]) { - data[i * 4] = sr; - data[i * 4 + 1] = sg; - data[i * 4 + 2] = sb; - data[i * 4 + 3] = 255; - } - } - sctx.putImageData(img, 0, 0); - 28 const fillRuns = []; 29 for (let dy = 0; dy < n; dy++) { 30 let dx = 0; 36 dx = dx1; 37 } 38 } 39 + const shape = { fillRuns, outlineRuns: null, n }; 40 + brushShapes.set(key, shape); 41 + return shape; 42 +} 43 44 +function brushOutline(shape) { 45 + if (shape.outlineRuns) return shape.outlineRuns; 46 + const n = shape.n; 47 + const inside = new Uint8Array(n * n); 48 + const runs = shape.fillRuns; 49 + for (let i = 0; i < runs.length; i += 3) { 50 + const at = runs[i + 1] * n + runs[i]; 51 + inside.fill(1, at, at + runs[i + 2]); 52 + } 53 // 1-pixel outline ring: cells outside the shape that are 8-way adjacent 54 // to any inside cell. stored as row-runs in an (n+2)x(n+2) grid with 55 // coordinates offset by -1 so they index directly in shape-local space. 80 x = x1; 81 } 82 } - - const shape = { inside, sprite, fillRuns, outlineRuns, n }; - brushShapes.set(key, shape); - return shape; 83 + shape.outlineRuns = outlineRuns; 84 + return outlineRuns; 85 } 86 87 // outline blend: 0 = dark color (lighten with screen), 1 = light color (darken with multiply).
8 function newFrameMap(id) { 9 const m = new Map(); 10 m.id = id || ('l' + (++frameSeq)); 11 + m.base = new Map(); 12 + m.log = []; 13 + m.into = null; 14 + m.limboBy = null; 15 + m.vers = new Map(); 16 return m; 17 } 18 let frames = [newFrameMap()]; 30 31 function chunkKey(cx, cy) { return cx + ',' + cy; } 32 -// remote strokes redirect every paint write into the frame they belong to -let paintMap = null; 33 +let paintClip = null; 34 35 function getOrCreateChunk(cx, cy, map) { - map = map || paintMap || chunks; 36 + map = map || chunks; 37 const k = chunkKey(cx, cy); 38 let c = map.get(k); 39 if (c) return c; 64 return m; 65 } 66 -function paintRect(wx, wy, w, h, color) { - const x0 = wx, y0 = wy, x1 = wx + w, y1 = wy + h; - const cx0 = Math.floor(x0 / CHUNK); - const cy0 = Math.floor(y0 / CHUNK); - const cx1 = Math.floor((x1 - 1) / CHUNK); - const cy1 = Math.floor((y1 - 1) / CHUNK); - for (let cy = cy0; cy <= cy1; cy++) { - for (let cx = cx0; cx <= cx1; cx++) { - const c = getOrCreateChunk(cx, cy); - const lx = Math.max(x0, cx * CHUNK) - cx * CHUNK; - const ly = Math.max(y0, cy * CHUNK) - cy * CHUNK; - const rx = Math.min(x1, (cx + 1) * CHUNK) - cx * CHUNK; - const ry = Math.min(y1, (cy + 1) * CHUNK) - cy * CHUNK; - c.ctx.fillStyle = color; - c.ctx.fillRect(lx, ly, rx - lx, ry - ly); - } - } -} - 67 function readPixel(wx, wy) { 68 const cx = Math.floor(wx / CHUNK); 69 const cy = Math.floor(wy / CHUNK); 94 // frame structure changes round-trip through the server when connected, so 95 // every painter ends up with the same list in the same order 96 function addFrame() { 97 + closeStroke(); 98 const at = dir === 1 ? frameIdx + 1 : frameIdx; 99 if (net.on) { net.send({ t: 'af', at }); return; } - applyAddFrame(at, null, true); -} - -function applyAddFrame(at, id, mine) { - at = clamp(at, 0, frames.length); - const cur = chunks; - frames.splice(at, 0, newFrameMap(id)); - setFrame(mine ? at : frames.indexOf(cur)); 100 + const a = localActionId(); 101 + applyAdd(null, at, a, true); 102 + localAction(a); 103 } 104 105 function deleteFrame() { 106 if (frames.length === 1) return; 107 + closeStroke(); 108 if (net.on) { net.send({ t: 'df', id: chunks.id }); return; } - applyDeleteFrame(chunks.id); 109 + const a = localActionId(); 110 + if (applyDelete(chunks.id, a)) localAction(a); 111 } 112 -function applyDeleteFrame(id) { 113 +function mergeDown() { 114 if (frames.length === 1) return; - const i = frames.findIndex((f) => f.id === id); - if (i < 0) return; 115 + closeStroke(); 116 + const into = frames[(frameIdx - dir + frames.length) % frames.length]; 117 + if (net.on) { net.send({ t: 'mf', id: chunks.id, into: into.id }); return; } 118 + const a = localActionId(); 119 + if (applyMerge(chunks.id, into.id, a, true)) localAction(a); 120 +} 121 + 122 +function insertFrame(f, at, land) { 123 + at = clamp(at, 0, frames.length); 124 + const cur = chunks; 125 + frames.splice(at, 0, f); 126 + setFrame(land ? at : frames.indexOf(cur)); 127 +} 128 + 129 +function removeFrame(f) { 130 + const i = frames.indexOf(f); 131 + if (i < 0 || frames.length === 1) return -1; 132 const cur = chunks; 133 frames.splice(i, 1); 134 + movePeers(f.id, frames[Math.min(i, frames.length - 1)].id); 135 if (playing && frames.length === 1) stopPlayback(); 136 const j = frames.indexOf(cur); 137 setFrame(j >= 0 ? j : Math.min(i, frames.length - 1)); 138 + return i; 139 +} 140 + 141 +function mergeInto(top, bottom, only) { 142 + const tmp = document.createElement('canvas'); 143 + tmp.width = CHUNK; 144 + tmp.height = CHUNK; 145 + const tctx = tmp.getContext('2d'); 146 + for (const [k, c] of top) { 147 + if (only && !only.has(k)) continue; 148 + const img = c.ctx.getImageData(0, 0, CHUNK, CHUNK); 149 + const d = img.data; 150 + for (let i = 0; i < d.length; i += 4) { 151 + if (d[i] === 0 && d[i + 1] === 0 && d[i + 2] === 0) d[i + 3] = 0; 152 + } 153 + tctx.putImageData(img, 0, 0); 154 + const [cx, cy] = k.split(',').map(Number); 155 + getOrCreateChunk(cx, cy, bottom).ctx.drawImage(tmp, 0, 0); 156 + } 157 } 158 159 function stopPlayback() { 195 return { x: cx - off, y: cy - off }; 196 } 197 -function paintAt(cx, cy) { - dirty = true; - const tl = brushTopLeft(cx, cy); - if (roundness === 0) { - paintRect(tl.x, tl.y, brush, brush, 'rgb(' + r + ',' + g + ',' + b + ')'); - return; - } - const sprite = getBrushShape().sprite; - const cx0 = Math.floor(tl.x / CHUNK); - const cy0 = Math.floor(tl.y / CHUNK); - const cx1 = Math.floor((tl.x + brush - 1) / CHUNK); - const cy1 = Math.floor((tl.y + brush - 1) / CHUNK); - for (let ccy = cy0; ccy <= cy1; ccy++) { - for (let ccx = cx0; ccx <= cx1; ccx++) { - const c = getOrCreateChunk(ccx, ccy); - c.ctx.drawImage(sprite, tl.x - ccx * CHUNK, tl.y - ccy * CHUNK); - } - } -} - 198 // every mark the local user makes goes out as a segment; a dot 199 // is just a zero-length one. in a room the pixels land on the frame's 200 // overlay - the frame itself only takes strokes in the server's order 201 function localPaintAt(x, y) { - if (net.on) paintMap = overlayFor(chunks.id); - try { - paintAt(x, y); - } finally { - paintMap = null; - } - net.paint(x, y, x, y); 202 + localPaintLine(x, y, x, y); 203 } 204 205 function localPaintLine(x0, y0, x1, y1) { - if (net.on) paintMap = overlayFor(chunks.id); - try { - paintLine(x0, y0, x1, y1); - } finally { - paintMap = null; 206 + const fresh = strokeFor(chunks.id); 207 + if (net.on) { 208 + fillRaster(overlayFor(chunks.id), rasterize([x0, y0, x1, y1], brush, roundness), [r, g, b]); 209 + net.paint(x0, y0, x1, y1); 210 + return; 211 } - net.paint(x0, y0, x1, y1); 212 + const a = 'a' + stroke.a; 213 + applyPaint(chunks, { a, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness }, fresh); 214 + if (fresh) localAction(a); 215 } 216 -// replay someone else's segment into whichever frame it belongs to, with -// their brush, without disturbing the local one -function applyPaintOp(op) { - const f = frames.find((fr) => fr.id === op.f); - if (!f || !op.o || !op.c) return; - const pm = paintMap, pr = r, pg = g, pb = b, pbrush = brush, pround = roundness; - paintMap = f; - r = op.c[0]; g = op.c[1]; b = op.c[2]; - brush = op.b; roundness = op.s; - try { - for (let i = 0; i + 3 < op.o.length; i += 4) { - paintLine(op.o[i], op.o[i + 1], op.o[i + 2], op.o[i + 3]); 217 +// replay an op into whichever frame it belongs to, with its own brush, 218 +// rasterizing it only the first time 219 +function paintOpInto(f, op) { 220 + if (!op.o || !op.c) return; 221 + if (!op.raster) op.raster = rasterize(op.o, op.b, op.s); 222 + fillRaster(f, op.raster, op.c); 223 +} 224 + 225 +function mergeSpans(row) { 226 + const order = []; 227 + for (let i = 0; i < row.length; i += 2) order.push(i); 228 + order.sort((p, q) => row[p] - row[q]); 229 + const out = [row[order[0]], row[order[0] + 1]]; 230 + for (let j = 1; j < order.length; j++) { 231 + const s = row[order[j]], e = row[order[j] + 1]; 232 + const n = out.length; 233 + if (s > out[n - 1]) out.push(s, e); 234 + else if (e > out[n - 1]) out[n - 1] = e; 235 + } 236 + return out; 237 +} 238 + 239 +function rasterize(o, n, round) { 240 + const runs = brushShapeFor(n, round).fillRuns; 241 + const off = Math.floor(n / 2); 242 + const rows = new Map(); 243 + const index = new Map(); 244 + const tiles = []; 245 + for (let i = 0; i + 3 < o.length; i += 4) { 246 + const x1 = o[i + 2], y1 = o[i + 3]; 247 + let x = o[i], y = o[i + 1]; 248 + const dx = Math.abs(x1 - x), sx = x < x1 ? 1 : -1; 249 + const dy = -Math.abs(y1 - y), sy = y < y1 ? 1 : -1; 250 + let err = dx + dy; 251 + while (true) { 252 + const tx = x - off, ty = y - off; 253 + const cx0 = Math.floor(tx / CHUNK), cx1 = Math.floor((tx + n - 1) / CHUNK); 254 + const cy0 = Math.floor(ty / CHUNK), cy1 = Math.floor((ty + n - 1) / CHUNK); 255 + for (let cy = cy0; cy <= cy1; cy++) { 256 + for (let cx = cx0; cx <= cx1; cx++) { 257 + const k = chunkKey(cx, cy); 258 + if (index.has(k)) continue; 259 + index.set(k, tiles.length / 2); 260 + tiles.push(cx, cy); 261 + } 262 + } 263 + for (let j = 0; j < runs.length; j += 3) { 264 + const ry = ty + runs[j + 1]; 265 + const s = tx + runs[j], e = s + runs[j + 2]; 266 + const row = rows.get(ry); 267 + if (!row) { 268 + rows.set(ry, [s, e]); 269 + continue; 270 + } 271 + const m = row.length; 272 + if (s <= row[m - 1] && e >= row[m - 2]) { 273 + if (s < row[m - 2]) row[m - 2] = s; 274 + if (e > row[m - 1]) row[m - 1] = e; 275 + } else { 276 + row.push(s, e); 277 + } 278 + } 279 + if (x === x1 && y === y1) break; 280 + const e2 = 2 * err; 281 + if (e2 >= dy) { err += dy; x += sx; } 282 + if (e2 <= dx) { err += dx; y += sy; } 283 } - } finally { - paintMap = pm; - r = pr; g = pg; b = pb; - brush = pbrush; roundness = pround; 284 } - if (f === chunks) requestDraw(); 285 + const spans = []; 286 + const open = new Map(); 287 + for (const y of [...rows.keys()].sort((p, q) => p - q)) { 288 + const row = rows.get(y); 289 + const merged = row.length > 2 ? mergeSpans(row) : row; 290 + const cy = Math.floor(y / CHUNK); 291 + const ly = y - cy * CHUNK; 292 + for (let i = 0; i < merged.length; i += 2) { 293 + const s = merged[i], e = merged[i + 1]; 294 + for (let cx = Math.floor(s / CHUNK); cx <= Math.floor((e - 1) / CHUNK); cx++) { 295 + const ti = index.get(chunkKey(cx, cy)); 296 + const lx = Math.max(s, cx * CHUNK) - cx * CHUNK; 297 + const w = Math.min(e, (cx + 1) * CHUNK) - cx * CHUNK - lx; 298 + const key = (ti * CHUNK + lx) * (CHUNK + 1) + w; 299 + const at = open.get(key); 300 + if (at !== undefined && spans[at + 2] + spans[at + 4] === ly) { 301 + spans[at + 4]++; 302 + } else { 303 + open.set(key, spans.length); 304 + spans.push(ti, lx, ly, w, 1); 305 + } 306 + } 307 + } 308 + } 309 + return { tiles, spans }; 310 } 311 -function paintLine(x0, y0, x1, y1) { - let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; - let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; - let err = dx + dy; - let x = x0, y = y0; - while (true) { - paintAt(x, y); - if (x === x1 && y === y1) break; - const e2 = 2 * err; - if (e2 >= dy) { err += dy; x += sx; } - if (e2 <= dx) { err += dx; y += sy; } 312 +function fillRaster(map, ras, c) { 313 + dirty = true; 314 + const color = 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')'; 315 + const t = ras.tiles; 316 + const ctxs = new Array(t.length / 2); 317 + for (let i = 0; i < t.length; i += 2) { 318 + if (paintClip && !paintClip.has(chunkKey(t[i], t[i + 1]))) continue; 319 + const ctx = getOrCreateChunk(t[i], t[i + 1], map).ctx; 320 + ctx.fillStyle = color; 321 + ctxs[i / 2] = ctx; 322 + } 323 + const sp = ras.spans; 324 + for (let i = 0; i < sp.length; i += 5) { 325 + const ctx = ctxs[sp[i]]; 326 + if (ctx) ctx.fillRect(sp[i + 1], sp[i + 2], sp[i + 3], sp[i + 4]); 327 } 328 }
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'); }
142 requestDraw(); 143 return; 144 } 145 + closeStroke(); 146 painting = true; 147 lastPaintX = curX; 148 lastPaintY = curY; 270 if (keys['c']) { 271 picking = true; 272 } else { 273 + closeStroke(); 274 painting = true; 275 lastPaintX = curX; 276 lastPaintY = curY; 406 fileInput.click(); 407 return; 408 } 409 + if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'z') { 410 + e.preventDefault(); 411 + if (e.repeat) return; 412 + if (e.shiftKey) redo(); 413 + else undo(); 414 + return; 415 + } 416 if ((e.metaKey || e.ctrlKey) && !e.altKey) { 417 if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); return; } 418 if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); return; } 425 if (e.key === ' ') { e.preventDefault(); playing ? stopPlayback() : startPlayback(); return; } 426 if (lk === 'a') { addFrame(); return; } 427 if (lk === 'd') { deleteFrame(); return; } 428 + if (lk === 'm') { mergeDown(); return; } 429 if (lk === 'o') { 430 if (playing) { stopPlayback(); onionskin = true; } 431 else onionskin = !onionskin;
225 const ox = -Math.floor(w / 2); 226 const oy = -Math.floor(h / 2); 227 const f = newFrameMap(); - drawIntoFrame(f, src, w, h, ox, oy); - return { f, w, h, ox, oy }; -} - -function drawIntoFrame(f, src, w, h, ox, oy) { 228 const cx0 = Math.floor(ox / CHUNK); 229 const cy0 = Math.floor(oy / CHUNK); 230 const cx1 = Math.floor((ox + w - 1) / CHUNK); 234 getOrCreateChunk(cx, cy, f).ctx.drawImage(src, ox - cx * CHUNK, oy - cy * CHUNK); 235 } 236 } 237 + return { f, w, h, ox, oy }; 238 } 239 240 function finishImport(newFrames, w, h, ox, oy) { 241 frames = newFrames; 242 + resetTimeline(); 243 dir = 1; 244 setFrame(0); 245 camX = ox - (cssW / zoom - w) / 2; 248 requestDraw(); 249 } 250 -function loadImage(src) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = src; - }); 251 +function lzwDecode(minCode, data, out) { 252 + const clear = 1 << minCode; 253 + const end = clear + 1; 254 + const prefix = new Int16Array(4096); 255 + const suffix = new Uint8Array(4096); 256 + const first = new Uint8Array(4096); 257 + const len = new Uint16Array(4096); 258 + for (let i = 0; i < clear; i++) { 259 + prefix[i] = -1; 260 + suffix[i] = i; 261 + first[i] = i; 262 + len[i] = 1; 263 + } 264 + let size = minCode + 1, next = end + 1, prev = -1; 265 + let acc = 0, bits = 0, at = 0; 266 + for (let i = 0; i < data.length; i++) { 267 + acc |= data[i] << bits; 268 + bits += 8; 269 + while (bits >= size) { 270 + const code = acc & ((1 << size) - 1); 271 + acc >>>= size; 272 + bits -= size; 273 + if (code === clear) { 274 + size = minCode + 1; 275 + next = end + 1; 276 + prev = -1; 277 + continue; 278 + } 279 + if (code === end || code > next || (code === next && prev < 0)) return; 280 + if (prev >= 0 && next < 4096) { 281 + prefix[next] = prev; 282 + suffix[next] = code < next ? first[code] : first[prev]; 283 + first[next] = first[prev]; 284 + len[next] = len[prev] + 1; 285 + next++; 286 + if (next === 1 << size && size < 12) size++; 287 + } 288 + let p = at + len[code] - 1; 289 + for (let c = code; c >= 0; c = prefix[c], p--) { 290 + if (p < out.length) out[p] = suffix[c]; 291 + } 292 + at += len[code]; 293 + if (at >= out.length) return; 294 + prev = code; 295 + } 296 + } 297 +} 298 + 299 +function decodeGIF(buf) { 300 + const p = new Uint8Array(buf); 301 + const w = p[6] | (p[7] << 8), h = p[8] | (p[9] << 8); 302 + let at = 13; 303 + let gct = null; 304 + if (p[10] & 0x80) { 305 + const n = 3 << ((p[10] & 7) + 1); 306 + gct = p.subarray(at, at + n); 307 + at += n; 308 + } 309 + const img = new ImageData(w, h); 310 + const px = img.data; 311 + const srcs = []; 312 + let interval = 0; 313 + let disposal = 0, delay = 0, trans = -1; 314 + while (at < p.length) { 315 + const block = p[at++]; 316 + if (block === 0x21) { 317 + if (p[at] === 0xf9) { 318 + const q = at + 2; 319 + disposal = (p[q] >> 2) & 7; 320 + delay = (p[q + 1] | (p[q + 2] << 8)) * 10; 321 + trans = p[q] & 1 ? p[q + 3] : -1; 322 + } 323 + at++; 324 + while (p[at]) at += p[at] + 1; 325 + at++; 326 + continue; 327 + } 328 + if (block !== 0x2c) break; 329 + const fx = p[at] | (p[at + 1] << 8), fy = p[at + 2] | (p[at + 3] << 8); 330 + const fw = p[at + 4] | (p[at + 5] << 8), fh = p[at + 6] | (p[at + 7] << 8); 331 + const flags = p[at + 8]; 332 + at += 9; 333 + let ct = gct; 334 + if (flags & 0x80) { 335 + const n = 3 << ((flags & 7) + 1); 336 + ct = p.subarray(at, at + n); 337 + at += n; 338 + } 339 + const minCode = p[at++]; 340 + const parts = []; 341 + let total = 0; 342 + while (p[at]) { 343 + parts.push(p.subarray(at + 1, at + 1 + p[at])); 344 + total += p[at]; 345 + at += p[at] + 1; 346 + } 347 + at++; 348 + const data = new Uint8Array(total); 349 + let o = 0; 350 + for (const s of parts) { 351 + data.set(s, o); 352 + o += s.length; 353 + } 354 + const idx = new Uint8Array(fw * fh); 355 + lzwDecode(minCode, data, idx); 356 + const rows = []; 357 + if (flags & 0x40) { 358 + for (const [y0, step] of [[0, 8], [4, 8], [2, 4], [1, 2]]) { 359 + for (let y = y0; y < fh; y += step) rows.push(y); 360 + } 361 + } else { 362 + for (let y = 0; y < fh; y++) rows.push(y); 363 + } 364 + const saved = disposal === 3 ? px.slice() : null; 365 + const x1 = Math.min(fw, w - fx); 366 + for (let i = 0; i < fh; i++) { 367 + const y = fy + rows[i]; 368 + if (y >= h) continue; 369 + for (let x = 0; x < x1; x++) { 370 + const c = idx[i * fw + x]; 371 + if (c === trans) continue; 372 + const d = (y * w + fx + x) * 4; 373 + px[d] = ct[c * 3]; 374 + px[d + 1] = ct[c * 3 + 1]; 375 + px[d + 2] = ct[c * 3 + 2]; 376 + px[d + 3] = 255; 377 + } 378 + } 379 + const cnv = document.createElement('canvas'); 380 + cnv.width = w; 381 + cnv.height = h; 382 + cnv.getContext('2d').putImageData(img, 0, 0); 383 + srcs.push({ src: cnv, w, h }); 384 + if (srcs.length === 1 && delay) interval = Math.max(20, delay); 385 + if (disposal === 2) { 386 + for (let y = fy; y < Math.min(h, fy + fh); y++) { 387 + px.fill(0, (y * w + fx) * 4, (y * w + fx + x1) * 4); 388 + } 389 + } else if (saved) { 390 + px.set(saved); 391 + } 392 + disposal = 0; 393 + delay = 0; 394 + trans = -1; 395 + } 396 + return { srcs, interval }; 397 } 398 399 // decoded frames of the file as {src, w, h, close} 400 async function decodeSources(file) { - // ImageDecoder gives us every frame of an animated gif; fall back to - // single-image import where unsupported 401 + if (file.type === 'image/gif') return decodeGIF(await file.arrayBuffer()); 402 + // ImageDecoder gives us every frame of other animated formats, but only 403 + // in secure contexts; fall back to single-image import where unsupported 404 if (typeof ImageDecoder !== 'undefined') { 405 try { 406 const dec = new ImageDecoder({ data: await file.arrayBuffer(), type: file.type }); 417 } catch (err) {} 418 } 419 const url = URL.createObjectURL(file); 420 + const img = new Image(); 421 + img.src = url; 422 try { - const img = await loadImage(url); 423 + await img.decode(); 424 return { srcs: [{ src: img, w: img.width, h: img.height }], interval: 0 }; 425 } catch (err) { 426 return null; 439 if (last) finishImport(newFrames, last.w, last.h, last.ox, last.oy); 440 } 441 -// flat png per frame, positioned in world space, so an import can be handed 442 +// each frame's chunks, positioned in world space, so an import can be handed 443 // to everyone else in the room (and to whoever joins later) -function sourcesToBases(srcs) { - const cnv = document.createElement('canvas'); - const cctx = cnv.getContext('2d'); - return srcs.map((s) => { - cnv.width = s.w; - cnv.height = s.h; - cctx.drawImage(s.src, 0, 0); - return { img: cnv.toDataURL('image/png'), ox: -Math.floor(s.w / 2), oy: -Math.floor(s.h / 2), w: s.w, h: s.h }; - }); 444 +async function sourcesToChunks(srcs) { 445 + const out = []; 446 + for (const s of srcs) out.push({ ch: await encodeFrame(imageToFrame(s.src, s.w, s.h).f) }); 447 + return out; 448 } 449 450 async function importFile(file) { 453 const dec = await decodeSources(file); 454 if (!dec || !dec.srcs.length) return; 455 if (dec.interval) frameInterval = dec.interval; - if (net.on) net.send({ t: 'reset', frames: sourcesToBases(dec.srcs), interval: frameInterval }); - else applyImport(dec.srcs); 456 + if (net.on) { 457 + const s = dec.srcs[0]; 458 + const view = { ox: -Math.floor(s.w / 2), oy: -Math.floor(s.h / 2), w: s.w, h: s.h }; 459 + net.send({ t: 'reset', frames: await sourcesToChunks(dec.srcs), interval: frameInterval, view }); 460 + } else { 461 + applyImport(dec.srcs); 462 + } 463 for (const s of dec.srcs) if (s.close) s.close(); 464 }
7 ws: null, 8 peers: new Map(), 9 joined: false, // has this page ever been in a room - queue: null, // messages held while a snapshot loads 10 + queue: null, // messages held while frames load 11 lastCursor: '', 12 batch: null, // segments painted this frame, not yet sent 13 key: 0, 15 overlays: new Map(), // frame id -> chunk map of those unacked strokes 16 }; 17 18 +// send a message to the room; does nothing when solo 19 net.send = function (msg) { 20 if (!net.on) return; 21 try { 24 }; 25 26 // a fast pen reports far more moves than anyone can see, so segments pile up -// into one message per animation frame. a new frame, color, or brush starts -// a new batch, since a batch carries a single style 27 +// into one message per animation frame. a new stroke, frame, color, or brush 28 +// starts a new batch, since a batch carries a single style 29 net.paint = function (x0, y0, x1, y1) { 30 if (!net.on) return; 31 const cur = net.batch; - if (cur && cur.f === chunks.id && cur.b === brush && cur.s === roundness 32 + if (cur && cur.a === stroke.a && cur.f === chunks.id && cur.b === brush && cur.s === roundness 33 && cur.c[0] === r && cur.c[1] === g && cur.c[2] === b) { 34 cur.o.push(x0, y0, x1, y1); 35 return; 36 } 37 net.flush(); - net.batch = { t: 'p', k: ++net.key, f: chunks.id, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness }; 38 + net.batch = { t: 'p', k: ++net.key, a: stroke.a, f: chunks.id, o: [x0, y0, x1, y1], c: [r, g, b], b: brush, s: roundness }; 39 // tracked from the first segment, so the overlay it painted onto is held 40 // until the echo commits it to the frame proper 41 net.pending.push({ k: net.batch.k, f: chunks.id }); 42 }; 43 44 +// send the batch being built, if any 45 net.flush = function () { 46 const out = net.batch; 47 if (!out) return; 68 net.send({ t: 'c', x: curX, y: curY, b: brush, s: roundness, c: [r, g, b], f: chunks.id, v }); 69 }; 70 -function frameSnapshot(f) { - let any = false, minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity; - for (const k of f.keys()) { - any = true; - const [cx, cy] = k.split(',').map(Number); - if (cx < minCx) minCx = cx; - if (cy < minCy) minCy = cy; - if (cx > maxCx) maxCx = cx; - if (cy > maxCy) maxCy = cy; - } - if (!any) return { id: f.id, img: null }; - const w = (maxCx - minCx + 1) * CHUNK; - const h = (maxCy - minCy + 1) * CHUNK; - const cnv = document.createElement('canvas'); - cnv.width = w; - cnv.height = h; - const cctx = cnv.getContext('2d'); - cctx.fillStyle = '#000'; - cctx.fillRect(0, 0, w, h); - for (const [k, c] of f) { - const [cx, cy] = k.split(',').map(Number); - cctx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK); - } - return { id: f.id, img: cnv.toDataURL('image/png'), ox: minCx * CHUNK, oy: minCy * CHUNK, w, h }; 71 +// compress bytes to base64 for the wire 72 +async function deflateB64(bytes) { 73 + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate')); 74 + const buf = new Uint8Array(await new Response(stream).arrayBuffer()); 75 + let bin = ''; 76 + for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000)); 77 + return btoa(bin); 78 } 79 -// rebuild frames from flat images the server is holding -async function framesFromBases(list) { 80 +// undo deflateB64 81 +async function inflateB64(str) { 82 + const bin = atob(str); 83 + const bytes = new Uint8Array(bin.length); 84 + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); 85 + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate')); 86 + return new Uint8Array(await new Response(stream).arrayBuffer()); 87 +} 88 + 89 +// a frame's tiles as compressed rgb, the way the server stores them 90 +async function encodeFrame(f) { 91 const out = []; - for (const e of list) { - const f = newFrameMap(e.id); - if (e.img) { - try { - const img = await loadImage(e.img); - drawIntoFrame(f, img, e.w || img.width, e.h || img.height, e.ox || 0, e.oy || 0); - } catch (err) {} 92 + for (const [k, c] of f) { 93 + const d = c.ctx.getImageData(0, 0, CHUNK, CHUNK).data; 94 + const rgb = new Uint8Array(CHUNK * CHUNK * 3); 95 + for (let i = 0, j = 0; i < d.length; i += 4, j += 3) { 96 + rgb[j] = d[i]; 97 + rgb[j + 1] = d[i + 1]; 98 + rgb[j + 2] = d[i + 2]; 99 } - out.push(f); 100 + out.push([k, await deflateB64(rgb)]); 101 } 102 return out; 103 } 104 105 +// a frame and its log, rebuilt from what the server sent 106 +async function buildFrame(e) { 107 + const f = newFrameMap(e.id); 108 + for (const [k, data] of e.ch || []) { 109 + const rgb = await inflateB64(data); 110 + const img = new ImageData(CHUNK, CHUNK); 111 + const d = img.data; 112 + for (let i = 0, j = 0; j < rgb.length; i += 4, j += 3) { 113 + d[i] = rgb[j]; 114 + d[i + 1] = rgb[j + 1]; 115 + d[i + 2] = rgb[j + 2]; 116 + d[i + 3] = 255; 117 + } 118 + const [cx, cy] = k.split(',').map(Number); 119 + getOrCreateChunk(cx, cy, f).ctx.putImageData(img, 0, 0); 120 + } 121 + f.log = (e.log || []).map((op) => op.src 122 + ? { a: op.a, src: op.src, on: op.on } 123 + : { a: op.a, o: op.o, c: op.c, b: op.b, s: op.s, on: op.on, keys: opKeys(op) }); 124 + f.limboBy = e.limboBy || null; 125 + return f; 126 +} 127 + 128 +// move peers standing on a removed frame to where their own page sends them 129 +function movePeers(from, to) { 130 + for (const p of net.peers.values()) { 131 + if (p.f === from) p.f = to; 132 + } 133 +} 134 + 135 // hold live messages until the frames they refer to exist 136 function netDefer(msg) { 137 if (!net.queue) return false; 139 return true; 140 } 141 142 +// stop holding messages and handle the held ones in order 143 function netFlush() { 144 const q = net.queue; 145 net.queue = null; 147 for (const msg of q) netHandle(msg); 148 } 149 -// swap in a server-supplied frame list, replaying anything that arrived -// while the images were decoding only after `after` has caught us up -async function netAdopt(list, after) { 150 +// swap in the server's frames and timeline, replaying anything that arrived 151 +// while they were decoding only after `after` has caught us up 152 +async function netAdopt(msg, after) { 153 net.queue = []; - const built = await framesFromBases(list); 154 + const built = []; 155 + for (const e of msg.frames) built.push(await buildFrame(e)); 156 + const parked = []; 157 + for (const e of msg.limbo || []) parked.push(await buildFrame(e)); 158 frames = built; 159 + resetTimeline(); 160 + for (const f of parked) limbo.set(f.id, f); 161 + for (const [a, rec] of msg.acts || []) actions.set(a, rec); 162 + for (const f of [...built, ...parked]) { 163 + for (const op of f.log) { 164 + const src = op.src && op.on ? getFrame(op.src) : null; 165 + if (src) src.into = f.id; 166 + } 167 + } 168 + for (const f of [...parked, ...built]) rebuild(f); 169 setFrame(0); 170 if (after) after(); 171 netFlush(); 172 requestDraw(); 173 } 174 175 +// act on one message from the room 176 function netHandle(msg) { 177 if (msg.t === 'init') { 178 net.id = msg.id; 180 net.pending.length = 0; 181 net.overlays.clear(); 182 if (msg.interval) frameInterval = msg.interval; - netAdopt(msg.frames, () => { - for (const e of msg.frames) { - for (const op of e.ops || []) applyPaintOp({ ...op, f: e.id }); - } 183 + netAdopt(msg, () => { 184 dirty = false; 185 }); 186 return; 191 // them - ours included. until now our own stroke existed only on the 192 // overlay; this echo is what commits it, so every painter's frame is 193 // the same ops in the same order - applyPaintOp(msg); 194 + const f = frames.find((fr) => fr.id === msg.f); 195 + if (f) applyPaint(f, { a: msg.a, o: msg.o, c: msg.c, b: msg.b, s: msg.s }, !!msg.n); 196 if (msg.by === net.id) { 197 const i = net.pending.findIndex((p) => p.k === msg.k); 198 if (i < 0) return; 221 } 222 requestDraw(); 223 } else if (msg.t === 'af') { - applyAddFrame(msg.at, msg.id, msg.by === net.id); 224 + applyAdd(msg.id, msg.at, msg.a, msg.by === net.id); 225 } else if (msg.t === 'df') { - // the server drops strokes aimed at a deleted frame, so their echoes - // never come; the frame is going away, and its overlay with it - net.pending = net.pending.filter((p) => p.f !== msg.id); - net.overlays.delete(msg.id); - applyDeleteFrame(msg.id); 226 + applyDelete(msg.id, msg.a); 227 + } else if (msg.t === 'mf') { 228 + applyMerge(msg.id, msg.into, msg.a, msg.by === net.id); 229 + } else if (msg.t === 'tog') { 230 + toggle(msg.a, msg.on, msg.by === net.id); 231 + if (msg.by === net.id) dirty = true; 232 + } else if (msg.t === 'seal') { 233 + seal(msg.a || []); 234 } else if (msg.t === 'reset') { 235 if (playing) stopPlayback(); 236 net.pending.length = 0; 237 net.overlays.clear(); 238 if (msg.interval) frameInterval = msg.interval; - netAdopt(msg.frames, () => { - const first = msg.frames[0] || {}; 239 + netAdopt(msg, () => { 240 + const v = msg.view || {}; 241 dir = 1; - camX = (first.ox || 0) - (cssW / zoom - (first.w || 0)) / 2; - camY = (first.oy || 0) - (cssH / zoom - (first.h || 0)) / 2; 242 + camX = (v.ox || 0) - (cssW / zoom - (v.w || 0)) / 2; 243 + camY = (v.oy || 0) - (cssH / zoom - (v.h || 0)) / 2; 244 dirty = false; 245 }); - } else if (msg.t === 'snap') { - // the server's stroke log got long - hand it flat images instead so - // the next person to join doesn't have to replay the whole session - net.send({ t: 'snapshot', upto: msg.upto, frames: frames.map(frameSnapshot) }); 246 } 247 } 248 249 +// join the room served from this page's host, and keep rejoining after a drop 250 function netConnect() { 251 if (location.protocol !== 'http:' && location.protocol !== 'https:') return; 252 const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'); 272 net.batch = null; 273 net.pending.length = 0; 274 net.overlays.clear(); 275 + seal([...actions.keys()]); 276 + resetTimeline(); 277 if (wasOn) requestDraw(); 278 // a page that was never in a room (opened from a file, or from a 279 // static host) stays solo; one that was keeps reaching for the
188 // that is exactly what it is - a brush on some other frame 189 function drawCursorShape(sx, sy, n, round, col, lightness, alpha, pxD, ghost) { 190 if (alpha <= 0) return; - const shape = round === 0 ? null : brushShapeFor(n, round, col[0], col[1], col[2]); 191 + const shape = round === 0 ? null : brushShapeFor(n, round); 192 vctx.save(); 193 vctx.globalAlpha = alpha; 194 if (ghost) vctx.globalCompositeOperation = 'screen'; 208 209 const drawOutline = () => { 210 if (shape) { - const runs = shape.outlineRuns; 211 + const runs = brushOutline(shape); 212 for (let i = 0; i < runs.length; i += 3) { 213 vctx.fillRect(sx + runs[i] * pxD, sy + runs[i + 1] * pxD, runs[i + 2] * pxD, pxD); 214 }
9 import json 10 import os 11 import queue 12 +import re 13 import socket 14 import struct 15 import sys 16 import threading -import time 17 +import zlib 18 from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer 19 20 ROOT = os.path.dirname(os.path.abspath(__file__)) 26 WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' 27 MAX_PAYLOAD = 64 * 1024 * 1024 28 -# once the stored stroke log for the room passes this, ask a painter for flat -# images of the frames so joiners don't have to replay an entire session -SNAPSHOT_AFTER_OPS = 4000 -SNAPSHOT_COOLDOWN = 15.0 29 +CHUNK = 256 30 +CHUNK_BYTES = CHUNK * CHUNK * 3 31 +UNDO_MAX = 10 32 +LOG_ACTIONS_MAX = 100 33 +COLOR_RUNS = re.compile(rb'(?:\x00\x00\x00)+|((?:(?!\x00\x00\x00)[\x00-\xff]{3})+)') 34 35 # the ways a connection ends when the other side simply walks off 36 DEAD_SOCKET = (ConnectionError, BrokenPipeError, TimeoutError, socket.timeout) 130 def __init__(self, sock): 131 self.sock = sock 132 self.alive = True - self.joined = time.time() 133 self.q = queue.Queue(maxsize=SEND_QUEUE_MAX) 134 with Conn._seq_lock: 135 Conn._seq += 1 136 self.id = 'c%d' % Conn._seq 137 + self.stroke = None 138 self.writer = threading.Thread(target=self._pump, daemon=True) 139 self.writer.start() 140 180 self.raw(json.dumps(obj, separators=(',', ':')).encode('utf-8'), droppable=droppable) 181 182 183 +_shapes = {} 184 +INF = float('inf') 185 + 186 + 187 +def brush_rows(n, s): 188 + key = (n, s) 189 + rows = _shapes.get(key) 190 + if rows is not None: 191 + return rows 192 + if len(_shapes) >= 1024: 193 + _shapes.clear() 194 + rad = s * (n / 2) 195 + rad2 = rad * rad 196 + lo = rad - 0.5 197 + hi = n - 0.5 - rad 198 + left = [INF] * n 199 + right = [-INF] * n 200 + for dy in range(n): 201 + qy = lo - dy if dy < lo else (dy - hi if dy > hi else 0) 202 + for dx in range(n): 203 + qx = lo - dx if dx < lo else (dx - hi if dx > hi else 0) 204 + if qx * qx + qy * qy <= rad2 + 1e-9: 205 + if left[dy] == INF: 206 + left[dy] = dx 207 + right[dy] = dx + 1 208 + rows = (left, right) 209 + _shapes[key] = rows 210 + return rows 211 + 212 + 213 +class Draft(dict): 214 + def __init__(self, under): 215 + super().__init__() 216 + self.under = under 217 + 218 + def __missing__(self, k): 219 + buf = self.under.get(k) 220 + buf = bytearray(CHUNK_BYTES) if buf is None else bytearray(buf) 221 + self[k] = buf 222 + return buf 223 + 224 + 225 +def add_span(spans, y, s, e): 226 + row = spans.get(y) 227 + if row is None: 228 + spans[y] = [[s, e]] 229 + return 230 + last = row[-1] 231 + if s <= last[1] and e >= last[0]: 232 + if s < last[0]: 233 + last[0] = s 234 + if e > last[1]: 235 + last[1] = e 236 + else: 237 + row.append([s, e]) 238 + 239 + 240 +def chains_of(o): 241 + chains = [] 242 + cur = None 243 + way = 0 244 + px = py = None 245 + for i in range(0, len(o) - 3, 4): 246 + x, y, x1, y1 = o[i], o[i + 1], o[i + 2], o[i + 3] 247 + dx = abs(x1 - x) 248 + sx = 1 if x < x1 else -1 249 + dy = -abs(y1 - y) 250 + sy = 1 if y < y1 else -1 251 + err = dx + dy 252 + while True: 253 + if cur is not None: 254 + if abs(x - px) > 1 or abs(y - py) > 1: 255 + cur = None 256 + else: 257 + d = (y > py) - (y < py) 258 + if d and way and d != way: 259 + cur = None 260 + elif d: 261 + way = d 262 + if cur is None: 263 + cur = [] 264 + chains.append(cur) 265 + way = 0 266 + g = cur[-1] if cur else None 267 + if g is not None and g[0] == y: 268 + if x < g[1]: 269 + g[1] = x 270 + elif x > g[2]: 271 + g[2] = x 272 + else: 273 + cur.append([y, x, x]) 274 + px, py = x, y 275 + if x == x1 and y == y1: 276 + break 277 + e2 = 2 * err 278 + if e2 >= dy: 279 + err += dy 280 + x += sx 281 + if e2 <= dx: 282 + err += dx 283 + y += sy 284 + return chains 285 + 286 + 287 +def render_op(base, op): 288 + n = op['b'] 289 + left, right = brush_rows(n, op['s']) 290 + off = n // 2 291 + spans = {} 292 + tiles = set() 293 + for groups in chains_of(op['o']): 294 + y0 = min(g[0] for g in groups) - off 295 + h = max(g[0] for g in groups) - off - y0 + n 296 + lo = [INF] * h 297 + hi = [-INF] * h 298 + for y, xa, xb in groups: 299 + k = y - off - y0 300 + lo[k:k + n] = map(min, lo[k:k + n], [xa - off + v for v in left]) 301 + hi[k:k + n] = map(max, hi[k:k + n], [xb - off + v for v in right]) 302 + for cy in range((y - off) // CHUNK, (y - off + n - 1) // CHUNK + 1): 303 + for cx in range((xa - off) // CHUNK, (xb - off + n - 1) // CHUNK + 1): 304 + tiles.add((cx, cy)) 305 + for k in range(h): 306 + if lo[k] < hi[k]: 307 + add_span(spans, y0 + k, lo[k], hi[k]) 308 + for k in tiles: 309 + base[k] 310 + line = bytes(op['c']) * CHUNK 311 + for y, row in spans.items(): 312 + if len(row) > 1: 313 + row.sort() 314 + merged = [row[0]] 315 + for s, e in row[1:]: 316 + if s > merged[-1][1]: 317 + merged.append([s, e]) 318 + elif e > merged[-1][1]: 319 + merged[-1][1] = e 320 + row = merged 321 + cy = y // CHUNK 322 + at = (y - cy * CHUNK) * CHUNK 323 + for s, e in row: 324 + x = s 325 + while x < e: 326 + cx = x // CHUNK 327 + stop = min(e, (cx + 1) * CHUNK) 328 + p = (at + x - cx * CHUNK) * 3 329 + base[(cx, cy)][p:p + (stop - x) * 3] = line[:(stop - x) * 3] 330 + x = stop 331 + 332 + 333 +def composite(dst, src): 334 + for (cx, cy), sbuf in src.items(): 335 + dbuf = dst[(cx, cy)] 336 + for m in COLOR_RUNS.finditer(sbuf): 337 + if m.group(1) is not None: 338 + dbuf[m.start():m.end()] = sbuf[m.start():m.end()] 339 + 340 + 341 class Room: 342 def __init__(self): 343 + """one empty frame, no painters, and the flattening worker started""" 344 self.lock = threading.RLock() 345 self.clients = [] 346 self.frame_seq = 0 - self.op_seq = 0 - self.op_count = 0 # stored strokes, tracked rather than recounted 347 + self.act_seq = 0 348 self.frames = [self.blank_frame()] - self.index = {f['id']: f for f in self.frames} 349 + self.limbo = {} 350 + self.actions = {} 351 + self.stacks = {} 352 self.interval = 250 - self.snap_pending = False - self.snap_at = 0.0 353 + self.jobs = queue.Queue() 354 + self.busy = 0 355 + self.idle = threading.Condition(self.lock) 356 + threading.Thread(target=self.work, daemon=True).start() 357 358 def blank_frame(self): 359 + """a new empty frame with a fresh id""" 360 self.frame_seq += 1 - return {'id': 'f%d' % self.frame_seq, 'img': None, 'ox': 0, 'oy': 0, 'w': 0, 'h': 0, 'ops': []} - - def base_frame(self, src): - f = self.blank_frame() - if isinstance(src, dict) and src.get('img'): - f['img'] = src['img'] - f['ox'] = int(src.get('ox', 0)) - f['oy'] = int(src.get('oy', 0)) - f['w'] = int(src.get('w', 0)) - f['h'] = int(src.get('h', 0)) - return f - - def frame(self, fid): - return self.index.get(fid) - - def wire_frames(self, with_ops=True): - out = [] 361 + return {'id': 'f%d' % self.frame_seq, 'base': {}, 'log': [], 'acts': {}, 'limbo_by': None} 362 + 363 + def listed(self, fid): 364 + """the frame with this id if it's in the frame list, else None""" 365 for f in self.frames: - e = {'id': f['id'], 'img': f['img'], 'ox': f['ox'], 'oy': f['oy'], 'w': f['w'], 'h': f['h']} - if with_ops: - e['ops'] = f['ops'] - out.append(e) - return out 366 + if f['id'] == fid: 367 + return f 368 + return None 369 + 370 + def get_frame(self, fid): 371 + """the frame with this id, whether listed or in limbo""" 372 + return self.listed(fid) or self.limbo.get(fid) 373 + 374 + def wire_frame(self, f): 375 + """a frame as clients receive it: compressed base tiles plus its log""" 376 + ch = [['%d,%d' % k, base64.b64encode(zlib.compress(bytes(buf), 6)).decode('ascii')] for k, buf in f['base'].items()] 377 + return {'id': f['id'], 'ch': ch, 'log': f['log'], 'limboBy': f['limbo_by']} 378 379 def broadcast(self, msg, skip=None, droppable=False): 380 + """send a message to every painter except skip""" 381 payload = json.dumps(msg, separators=(',', ':')).encode('utf-8') 382 with self.lock: 383 targets = [c for c in self.clients if c is not skip] 385 c.raw(payload, droppable=droppable) 386 387 def join(self, conn): 388 + """add a painter and send them the whole room, once no flattening is pending""" 389 with self.lock: 390 + while self.busy: 391 + self.idle.wait() 392 self.clients.append(conn) 393 conn.send({ 394 't': 'init', 395 'id': conn.id, - 'frames': self.wire_frames(), 396 + 'frames': [self.wire_frame(f) for f in self.frames], 397 + 'limbo': [self.wire_frame(f) for f in self.limbo.values()], 398 + 'acts': [[a, rec] for a, rec in self.actions.items()], 399 'interval': self.interval, 400 }) 401 print(' + %s joined (%d painting)' % (conn.id, len(self.clients))) 402 403 def leave(self, conn): 404 + """remove a painter, sealing their undo and redo stacks""" 405 with self.lock: 406 if conn in self.clients: 407 self.clients.remove(conn) - # the painter we asked for a flat copy may be the one leaving; let - # the next stroke ask someone else - self.snap_pending = False 408 + st = self.stacks.pop(conn.id, None) 409 + if st: 410 + self.seal(st['undo'] + st['redo']) 411 print(' - %s left (%d painting)' % (conn.id, len(self.clients))) 412 self.broadcast({'t': 'bye', 'id': conn.id}) 413 - def maybe_snapshot(self): - """ask one painter to flatten the room so the stroke log stays bounded""" - now = time.time() - if self.snap_pending or now - self.snap_at < SNAPSHOT_COOLDOWN: 414 + def new_action(self, conn, a, rec): 415 + """push a painter's new action onto their undo stack; returns the actions that fell off, for sealing""" 416 + rec['by'] = conn.id 417 + self.actions[a] = rec 418 + st = self.stacks.setdefault(conn.id, {'undo': [], 'redo': []}) 419 + st['undo'].append(a) 420 + done = st['redo'] 421 + st['redo'] = [] 422 + if len(st['undo']) > UNDO_MAX: 423 + done.append(st['undo'].pop(0)) 424 + return done 425 + 426 + def append(self, f, op): 427 + """add a stroke op to a frame's log, extending the last op when it continues it""" 428 + log = f['log'] 429 + last = log[-1] if log else None 430 + if (last and last['a'] == op['a'] and 'src' not in last and last['on'] 431 + and last['c'] == op['c'] and last['b'] == op['b'] and last['s'] == op['s']): 432 + last['o'].extend(op['o']) 433 + return 434 + log.append(op) 435 + f['acts'][op['a']] = f['acts'].get(op['a'], 0) + 1 436 + 437 + def banish(self, rec, a): 438 + """move a listed frame into limbo on behalf of action a""" 439 + f = self.listed(rec['f']) 440 + if f is None or len(self.frames) <= 1: 441 + return False 442 + rec['at'] = self.frames.index(f) 443 + self.frames.remove(f) 444 + f['limbo_by'] = a 445 + self.limbo[f['id']] = f 446 + return True 447 + 448 + def unbanish(self, rec, a): 449 + """put a frame that action a banished back where it was""" 450 + f = self.limbo.get(rec['f']) 451 + if f is None or f['limbo_by'] != a: 452 + return False 453 + del self.limbo[f['id']] 454 + f['limbo_by'] = None 455 + self.frames.insert(max(0, min(rec['at'], len(self.frames))), f) 456 + return True 457 + 458 + def capture(self, op): 459 + """freeze an op for the worker; a merge takes its source frame out of limbo with it""" 460 + if 'src' not in op: 461 + return op 462 + src = self.limbo.pop(op['src'], None) 463 + if src is None: 464 + return None 465 + items = [self.capture(sop) for sop in src['log'] if sop['on']] 466 + src['log'] = [] 467 + src['acts'] = {} 468 + return (src, [it for it in items if it is not None]) 469 + 470 + def draw(self, base, item): 471 + """render a captured op into base, compositing merged frames over it""" 472 + if isinstance(item, dict): 473 + render_op(base, item) 474 + return 475 + src, items = item 476 + draft = Draft(src['base']) 477 + for it in items: 478 + self.draw(draft, it) 479 + composite(base, {**src['base'], **draft}) 480 + 481 + def work(self): 482 + """worker thread: flatten queued folds, then swap the results in under the lock""" 483 + while True: 484 + f, items = self.jobs.get() 485 + draft = Draft(f['base']) 486 + for it in items: 487 + self.draw(draft, it) 488 + with self.lock: 489 + f['base'].update(draft) 490 + self.busy -= 1 491 + self.idle.notify_all() 492 + 493 + def destroy(self, f): 494 + """drop a limbo frame and any frames merged into it""" 495 + self.limbo.pop(f['id'], None) 496 + for op in f['log']: 497 + if not op['on'] or 'src' not in op: 498 + continue 499 + s = self.limbo.get(op['src']) 500 + if s is not None and s['limbo_by'] == op['a']: 501 + self.destroy(s) 502 + 503 + def fold(self, f): 504 + """queue a frame's leading sealed ops to be flattened into its base""" 505 + log = f['log'] 506 + n = 0 507 + while n < len(log) and log[n]['a'] not in self.actions: 508 + n += 1 509 + if not n: 510 return - if self.op_count < SNAPSHOT_AFTER_OPS or not self.clients: 511 + done = log[:n] 512 + del log[:n] 513 + items = [] 514 + for op in done: 515 + left = f['acts'].get(op['a'], 1) - 1 516 + if left: 517 + f['acts'][op['a']] = left 518 + else: 519 + f['acts'].pop(op['a'], None) 520 + if op['on']: 521 + it = self.capture(op) 522 + if it is not None: 523 + items.append(it) 524 + if items: 525 + self.busy += 1 526 + self.jobs.put((f, items)) 527 + 528 + def seal(self, aids): 529 + """make actions permanent: drop them from the undo stacks and fold them into base""" 530 + sealed = [] 531 + touched = [] 532 + for a in aids: 533 + rec = self.actions.pop(a, None) 534 + if rec is None: 535 + continue 536 + sealed.append(a) 537 + st = self.stacks.get(rec['by']) 538 + if st: 539 + for side in ('undo', 'redo'): 540 + if a in st[side]: 541 + st[side].remove(a) 542 + f = self.get_frame(rec['f']) 543 + if rec['kind'] == 'stroke': 544 + if f is not None: 545 + touched.append(f) 546 + elif rec['kind'] == 'merge': 547 + into = self.get_frame(rec['into']) 548 + if into is not None: 549 + touched.append(into) 550 + elif f is not None and f['limbo_by'] == a: 551 + self.destroy(f) 552 + if sealed: 553 + self.broadcast({'t': 'seal', 'a': sealed}) 554 + for f in touched: 555 + self.fold(f) 556 + 557 + def cap(self, f): 558 + """seal a frame's oldest actions until its log holds at most LOG_ACTIONS_MAX""" 559 + while len(f['acts']) > LOG_ACTIONS_MAX: 560 + self.fold(f) 561 + if len(f['acts']) <= LOG_ACTIONS_MAX: 562 + break 563 + self.seal([f['log'][0]['a']]) 564 + 565 + def toggle(self, a, on): 566 + """switch action a on (redo) or off (undo)""" 567 + rec = self.actions.get(a) 568 + if rec is None: 569 return - self.snap_pending = True - self.snap_at = now - self.clients[0].send({'t': 'snap', 'upto': self.op_seq}) 570 + kind = rec['kind'] 571 + if kind == 'stroke': 572 + f = self.get_frame(rec['f']) 573 + if f is not None: 574 + for op in f['log']: 575 + if op['a'] == a: 576 + op['on'] = on 577 + elif kind in ('add', 'del'): 578 + if on == (kind == 'add'): 579 + self.unbanish(rec, a) 580 + else: 581 + self.banish(rec, a) 582 + elif kind == 'merge': 583 + bottom = self.get_frame(rec['into']) 584 + op = next((o for o in bottom['log'] if o['a'] == a), None) if bottom else None 585 + if op is None: 586 + return 587 + if on: 588 + if self.banish(rec, a): 589 + op['on'] = True 590 + elif self.unbanish(rec, a): 591 + op['on'] = False 592 - def set_frames(self, frames): - self.frames = frames - self.index = {f['id']: f for f in frames} - self.op_count = sum(len(f['ops']) for f in frames) 593 + def frame_action(self): 594 + """a fresh action id for a frame add, delete or merge""" 595 + self.act_seq += 1 596 + return 'x%d' % self.act_seq 597 598 def handle(self, conn, msg): 599 + """act on one message from a painter""" 600 t = msg.get('t') 601 if t == 'p': 602 + op = { 603 + 'a': '%s.%s' % (conn.id, int(msg.get('a'))), 604 + 'o': [int(v) for v in msg.get('o')], 605 + 'c': [int(v) for v in msg.get('c')], 606 + 'b': int(msg.get('b')), 607 + 's': float(msg.get('s')), 608 + 'on': True, 609 + } 610 with self.lock: - f = self.frame(msg.get('f')) 611 + f = self.listed(msg.get('f')) 612 if f is None: 613 return - self.op_seq += 1 - op = { - 'n': self.op_seq, - 'o': msg.get('o'), - 'c': msg.get('c'), - 'b': msg.get('b'), - 's': msg.get('s'), - } - f['ops'].append(op) - self.op_count += 1 - out = dict(op) - out['t'] = 'p' - out['f'] = f['id'] - out['by'] = conn.id 614 + new = op['a'] != conn.stroke 615 + conn.stroke = op['a'] 616 + done = self.new_action(conn, op['a'], {'kind': 'stroke', 'f': f['id']}) if new else [] 617 + self.append(f, op) 618 + out = {'t': 'p', 'f': f['id'], 'a': op['a'], 'o': op['o'], 'c': op['c'], 'b': op['b'], 's': op['s'], 'by': conn.id} 619 + if new: 620 + out['n'] = 1 621 # the painter's own tag for this batch, so they can recognise it 622 # coming back; not stored, it means nothing to anyone else 623 if msg.get('k') is not None: 624 out['k'] = msg['k'] - # broadcast under the lock so wire order matches sequence order; - # sends only enqueue, so nothing slow happens in here. echoed to - # the painter too: the ack is what commits their stroke 625 + # broadcast under the lock so wire order matches the order the room 626 + # applies them; sends only enqueue, so nothing slow happens in 627 + # here. echoed to the painter too: the ack is what commits their 628 + # stroke 629 self.broadcast(out) - # after the broadcast, so whoever we ask to flatten already has - # this stroke queued ahead of the request - self.maybe_snapshot() 630 + self.seal(done) 631 + self.cap(f) 632 elif t == 'c': 633 msg['id'] = conn.id 634 self.broadcast(msg, skip=conn, droppable=True) 637 at = max(0, min(int(msg.get('at', len(self.frames))), len(self.frames))) 638 f = self.blank_frame() 639 self.frames.insert(at, f) - self.index[f['id']] = f - self.broadcast({'t': 'af', 'at': at, 'id': f['id'], 'by': conn.id}) 640 + a = self.frame_action() 641 + done = self.new_action(conn, a, {'kind': 'add', 'f': f['id'], 'at': at}) 642 + self.broadcast({'t': 'af', 'at': at, 'id': f['id'], 'a': a, 'by': conn.id}) 643 + self.seal(done) 644 elif t == 'df': 645 with self.lock: - if len(self.frames) <= 1: 646 + rec = {'kind': 'del', 'f': msg.get('id'), 'at': 0} 647 + a = self.frame_action() 648 + if not self.banish(rec, a): 649 return - f = self.frame(msg.get('id')) - if f is None: 650 + done = self.new_action(conn, a, rec) 651 + self.broadcast({'t': 'df', 'id': rec['f'], 'a': a, 'by': conn.id}) 652 + self.seal(done) 653 + elif t == 'mf': 654 + with self.lock: 655 + top = self.listed(msg.get('id')) 656 + bottom = self.listed(msg.get('into')) 657 + if top is None or bottom is None or top is bottom: 658 return - self.frames.remove(f) - del self.index[f['id']] - self.op_count -= len(f['ops']) - self.broadcast({'t': 'df', 'id': f['id'], 'by': conn.id}) 659 + rec = {'kind': 'merge', 'f': top['id'], 'into': bottom['id'], 'at': 0} 660 + a = self.frame_action() 661 + if not self.banish(rec, a): 662 + return 663 + bottom['log'].append({'a': a, 'src': top['id'], 'on': True}) 664 + bottom['acts'][a] = 1 665 + done = self.new_action(conn, a, rec) 666 + self.broadcast({'t': 'mf', 'id': top['id'], 'into': bottom['id'], 'a': a, 'by': conn.id}) 667 + self.seal(done) 668 + self.cap(bottom) 669 + elif t == 'undo' or t == 'redo': 670 + with self.lock: 671 + st = self.stacks.get(conn.id) 672 + if not st: 673 + return 674 + src, dst = (st['undo'], st['redo']) if t == 'undo' else (st['redo'], st['undo']) 675 + if not src: 676 + return 677 + a = src.pop() 678 + dst.append(a) 679 + on = t == 'redo' 680 + self.toggle(a, on) 681 + self.broadcast({'t': 'tog', 'a': a, 'on': on, 'by': conn.id}) 682 elif t == 'reset': - srcs = msg.get('frames') or [] - if not srcs: 683 + bases = [] 684 + for src in msg.get('frames') or []: 685 + base = {} 686 + for k, data in src.get('ch') or []: 687 + buf = zlib.decompress(base64.b64decode(data)) 688 + cx, cy = (int(v) for v in k.split(',')) 689 + base[(cx, cy)] = bytearray(buf) 690 + bases.append(base) 691 + if not bases: 692 return 693 with self.lock: - self.set_frames([self.base_frame(s) for s in srcs]) 694 + frames = [] 695 + for base in bases: 696 + f = self.blank_frame() 697 + f['base'] = base 698 + frames.append(f) 699 + self.frames = frames 700 + self.limbo = {} 701 + self.actions = {} 702 + self.stacks = {} 703 + for c in self.clients: 704 + c.stroke = None 705 self.interval = int(msg.get('interval') or self.interval) - self.snap_pending = False 706 self.broadcast({ 707 't': 'reset', - 'frames': self.wire_frames(with_ops=False), 708 + 'frames': [self.wire_frame(f) for f in frames], 709 + 'view': msg.get('view'), 710 'interval': self.interval, 711 'by': conn.id, 712 }) - elif t == 'snapshot': - upto = int(msg.get('upto') or 0) - with self.lock: - self.snap_pending = False - for src in msg.get('frames') or []: - f = self.frame(src.get('id')) - if f is None: - continue - f['img'] = src.get('img') - f['ox'] = int(src.get('ox', 0)) - f['oy'] = int(src.get('oy', 0)) - f['w'] = int(src.get('w', 0)) - f['h'] = int(src.get('h', 0)) - # strokes newer than the snapshot are kept; a stroke that made it - # into the image and also survives here just paints itself twice - kept = 0 - for f in self.frames: - f['ops'] = [o for o in f['ops'] if o['n'] > upto] - kept += len(f['ops']) - self.op_count = kept 713 elif t == 'interval': 714 with self.lock: 715 self.interval = int(msg.get('v') or self.interval)