diff --git a/js/activity.js b/js/activity.js index 67b3c35fa0..ec21fa519b 100644 --- a/js/activity.js +++ b/js/activity.js @@ -2849,13 +2849,15 @@ class Activity { const bitmap = new createjs.Bitmap(img); container.addChild(bitmap); - bitmap.cache(0, 0, 1200, 900); + // Do NOT cache the bitmap here. Each cached grid allocates a + // 1200x900x4 = ~4.3 MB backing canvas, and with 8 grids that + // totals ~35 MB even though at most 1 grid is visible at a time. + // Instead, we cache lazily in _show*() and uncache in _hide*(). bitmap.x = (this.canvas.width - 1200) / 2; bitmap.y = (this.canvas.height - 900) / 2; bitmap.scaleX = bitmap.scaleY = bitmap.scale = 1; bitmap.visible = false; - bitmap.updateCache(); return bitmap; }; @@ -4264,6 +4266,19 @@ class Activity { const blk = this.blocks.dragGroup[b]; this.blocks.blockList[blk].trash = false; this.blocks.moveBlockRelative(blk, dx, dy); + + // Re-cache the container if it was uncached to save + // memory in sendStackToTrash(). + const block = this.blocks.blockList[blk]; + if (block.container && !block.container.bitmapCache) { + block.container.cache( + 0, + 0, + Math.max(block.width, 1), + Math.max(block.height, 1) + ); + } + this.blocks.blockList[blk].show(); } @@ -4672,6 +4687,11 @@ class Activity { */ this.clearCache = () => { this.blocks.blockList.forEach(block => { + // Skip trashed blocks — they are hidden and their backing + // canvases are freed in sendStackToTrash(). Re-caching them + // here would waste ~0.5–2 MB per trashed block. + if (block.trash) return; + if (block.container) { block.container.uncache(); block.container.cache(); @@ -5855,7 +5875,7 @@ class Activity { */ this._hideCartesian = () => { this.cartesianBitmap.visible = false; - this.cartesianBitmap.updateCache(); + this.cartesianBitmap.uncache(); this.update = true; }; @@ -5864,6 +5884,7 @@ class Activity { */ this._showCartesian = () => { this.cartesianBitmap.visible = true; + this.cartesianBitmap.cache(0, 0, 1200, 900); this.cartesianBitmap.updateCache(); this.update = true; }; @@ -5873,7 +5894,7 @@ class Activity { */ this._hidePolar = () => { this.polarBitmap.visible = false; - this.polarBitmap.updateCache(); + this.polarBitmap.uncache(); this.update = true; }; @@ -5882,6 +5903,7 @@ class Activity { */ this._showPolar = () => { this.polarBitmap.visible = true; + this.polarBitmap.cache(0, 0, 1200, 900); this.polarBitmap.updateCache(); this.update = true; }; @@ -5894,45 +5916,33 @@ class Activity { for (let i = 0; i < 7; i++) { this.grandSharpBitmap[i].visible = false; this.grandSharpBitmap[i].x = newX; - this.grandSharpBitmap[i].updateCache(); this.grandFlatBitmap[i].visible = false; this.grandFlatBitmap[i].x = newX; - this.grandFlatBitmap[i].updateCache(); this.trebleSharpBitmap[i].visible = false; this.trebleSharpBitmap[i].x = newX; - this.trebleSharpBitmap[i].updateCache(); this.trebleFlatBitmap[i].visible = false; this.trebleFlatBitmap[i].x = newX; - this.trebleFlatBitmap[i].updateCache(); this.sopranoSharpBitmap[i].visible = false; this.sopranoSharpBitmap[i].x = newX; - this.sopranoSharpBitmap[i].updateCache(); this.sopranoFlatBitmap[i].visible = false; this.sopranoFlatBitmap[i].x = newX; - this.sopranoFlatBitmap[i].updateCache(); this.altoSharpBitmap[i].visible = false; this.altoSharpBitmap[i].x = newX; - this.altoSharpBitmap[i].updateCache(); this.altoFlatBitmap[i].visible = false; this.altoFlatBitmap[i].x = newX; - this.altoFlatBitmap[i].updateCache(); this.tenorSharpBitmap[i].visible = false; this.tenorSharpBitmap[i].x = newX; - this.tenorSharpBitmap[i].updateCache(); this.tenorFlatBitmap[i].visible = false; this.tenorFlatBitmap[i].x = newX; - this.tenorFlatBitmap[i].updateCache(); this.bassSharpBitmap[i].visible = false; this.bassSharpBitmap[i].x = newX; - this.bassSharpBitmap[i].updateCache(); this.bassFlatBitmap[i].visible = false; this.bassFlatBitmap[i].x = newX; - this.bassFlatBitmap[i].updateCache(); } this.update = true; }; @@ -5942,7 +5952,7 @@ class Activity { */ this._hideTreble = () => { this.trebleBitmap.visible = false; - this.trebleBitmap.updateCache(); + this.trebleBitmap.uncache(); this._hideAccidentals(); this.update = true; }; @@ -5952,6 +5962,7 @@ class Activity { */ this._showTreble = () => { this.trebleBitmap.visible = true; + this.trebleBitmap.cache(0, 0, 1200, 900); this.trebleBitmap.updateCache(); this._hideAccidentals(); // eslint-disable-next-line no-console @@ -5982,13 +5993,11 @@ class Activity { if (scale.includes(_sharps[i])) { this.trebleSharpBitmap[i].x += dx; this.trebleSharpBitmap[i].visible = true; - this.trebleSharpBitmap[i].updateCache(); dx += 15; } if (scale.includes(_flats[i])) { this.trebleFlatBitmap[i].x += dx; this.trebleFlatBitmap[i].visible = true; - this.trebleFlatBitmap[i].updateCache(); dx += 15; } } @@ -6001,7 +6010,7 @@ class Activity { */ this._hideGrand = () => { this.grandBitmap.visible = false; - this.grandBitmap.updateCache(); + this.grandBitmap.uncache(); this._hideAccidentals(); this.update = true; }; @@ -6011,6 +6020,7 @@ class Activity { */ this._showGrand = () => { this.grandBitmap.visible = true; + this.grandBitmap.cache(0, 0, 1200, 900); this.grandBitmap.updateCache(); this._hideAccidentals(); // eslint-disable-next-line no-console @@ -6041,13 +6051,11 @@ class Activity { if (scale.includes(_sharps[i])) { this.grandSharpBitmap[i].x += dx; this.grandSharpBitmap[i].visible = true; - this.grandSharpBitmap[i].updateCache(); dx += 15; } if (scale.includes(_flats[i])) { this.grandFlatBitmap[i].x += dx; this.grandFlatBitmap[i].visible = true; - this.grandFlatBitmap[i].updateCache(); dx += 15; } } @@ -6059,7 +6067,7 @@ class Activity { */ this._hideSoprano = () => { this.sopranoBitmap.visible = false; - this.sopranoBitmap.updateCache(); + this.sopranoBitmap.uncache(); this.update = true; }; @@ -6068,6 +6076,7 @@ class Activity { */ this._showSoprano = () => { this.sopranoBitmap.visible = true; + this.sopranoBitmap.cache(0, 0, 1200, 900); this.sopranoBitmap.updateCache(); this._hideAccidentals(); // eslint-disable-next-line no-console @@ -6098,13 +6107,11 @@ class Activity { if (scale.includes(_sharps[i])) { this.sopranoSharpBitmap[i].x += dx; this.sopranoSharpBitmap[i].visible = true; - this.sopranoSharpBitmap[i].updateCache(); dx += 15; } if (scale.includes(_flats[i])) { this.sopranoFlatBitmap[i].x += dx; this.sopranoFlatBitmap[i].visible = true; - this.sopranoFlatBitmap[i].updateCache(); dx += 15; } } @@ -6117,7 +6124,7 @@ class Activity { */ this._hideAlto = () => { this.altoBitmap.visible = false; - this.altoBitmap.updateCache(); + this.altoBitmap.uncache(); this._hideAccidentals(); this.update = true; }; @@ -6131,6 +6138,7 @@ class Activity { */ this._showAlto = () => { this.altoBitmap.visible = true; + this.altoBitmap.cache(0, 0, 1200, 900); this.altoBitmap.updateCache(); this._hideAccidentals(); // eslint-disable-next-line no-console @@ -6161,13 +6169,11 @@ class Activity { if (scale.includes(_sharps[i])) { this.altoSharpBitmap[i].x += dx; this.altoSharpBitmap[i].visible = true; - this.altoSharpBitmap[i].updateCache(); dx += 15; } if (scale.includes(_flats[i])) { this.altoFlatBitmap[i].x += dx; this.altoFlatBitmap[i].visible = true; - this.altoFlatBitmap[i].updateCache(); dx += 15; } } @@ -6180,7 +6186,7 @@ class Activity { */ this._hideTenor = () => { this.tenorBitmap.visible = false; - this.tenorBitmap.updateCache(); + this.tenorBitmap.uncache(); this.update = true; }; @@ -6189,6 +6195,7 @@ class Activity { */ this._showTenor = () => { this.tenorBitmap.visible = true; + this.tenorBitmap.cache(0, 0, 1200, 900); this.tenorBitmap.updateCache(); this._hideAccidentals(); // eslint-disable-next-line no-console @@ -6219,13 +6226,11 @@ class Activity { if (scale.includes(_sharps[i])) { this.tenorSharpBitmap[i].x += dx; this.tenorSharpBitmap[i].visible = true; - this.tenorSharpBitmap[i].updateCache(); dx += 15; } if (scale.includes(_flats[i])) { this.tenorFlatBitmap[i].x += dx; this.tenorFlatBitmap[i].visible = true; - this.tenorFlatBitmap[i].updateCache(); dx += 15; } } @@ -6238,7 +6243,7 @@ class Activity { */ this._hideBass = () => { this.bassBitmap.visible = false; - this.bassBitmap.updateCache(); + this.bassBitmap.uncache(); this._hideAccidentals(); this.update = true; }; @@ -6248,6 +6253,7 @@ class Activity { */ this._showBass = () => { this.bassBitmap.visible = true; + this.bassBitmap.cache(0, 0, 1200, 900); this.bassBitmap.updateCache(); this._hideAccidentals(); // eslint-disable-next-line no-console @@ -6278,13 +6284,11 @@ class Activity { if (scale.includes(_sharps[i])) { this.bassSharpBitmap[i].x += dx; this.bassSharpBitmap[i].visible = true; - this.bassSharpBitmap[i].updateCache(); dx += 15; } if (scale.includes(_flats[i])) { this.bassFlatBitmap[i].x += dx; this.bassFlatBitmap[i].visible = true; - this.bassFlatBitmap[i].updateCache(); dx += 15; } } diff --git a/js/block.js b/js/block.js index f00bea3cd1..c297b4b017 100644 --- a/js/block.js +++ b/js/block.js @@ -399,6 +399,13 @@ class Block { updateCache() { const that = this; return new Promise((resolve, reject) => { + // If the container has no active bitmap cache (e.g., trashed + // blocks whose cache was freed), skip the update silently. + if (that.container && !that.container.bitmapCache) { + resolve(); + return; + } + let loopCount = 0; const MAX_RETRIES = 15; const INITIAL_DELAY = 100; diff --git a/js/blocks.js b/js/blocks.js index 2358e290e9..a4e8a8ebdf 100644 --- a/js/blocks.js +++ b/js/blocks.js @@ -6943,6 +6943,13 @@ class Blocks { /** Add this block to the list of blocks in the trash so we can undo this action. */ this.trashStacks.push(thisBlock); + // Cap the undo history to prevent unbounded memory growth. + // Keep the 100 most recent trashed stacks. + const MAX_TRASH_UNDO = 100; + if (this.trashStacks.length > MAX_TRASH_UNDO) { + this.trashStacks = this.trashStacks.slice(-MAX_TRASH_UNDO); + } + /** Disconnect block. */ const parentBlock = myBlock.connections[0]; if (parentBlock != null) { @@ -6988,6 +6995,21 @@ class Blocks { const blk = this.dragGroup[b]; this.blockList[blk].trash = true; this.blockList[blk].hide(); + + // Free the backing canvas memory for trashed blocks. + // Each cached block holds a bitmap canvas (~0.5-2 MB). + if (this.blockList[blk].container) { + this.blockList[blk].container.uncache(); + } + + // Clean up SVG art strings for trashed blocks to free memory. + if (this.blockArt[blk]) { + delete this.blockArt[blk]; + } + if (this.blockCollapseArt[blk]) { + delete this.blockCollapseArt[blk]; + } + const title = this.blockList[blk].protoblock.staticLabels[0]; closeBlkWidgets(_(title)); this.activity.refreshCanvas(); diff --git a/js/turtle-painter.js b/js/turtle-painter.js index 850460b78a..a767348cd3 100644 --- a/js/turtle-painter.js +++ b/js/turtle-painter.js @@ -1366,14 +1366,16 @@ class Painter { turtles.gx = this.turtle.ctx.canvas.width; turtles.gy = this.turtle.ctx.canvas.height; turtles.canvas1 = document.createElement("canvas"); - turtles.canvas1.width = 3 * this.turtle.ctx.canvas.width; - turtles.canvas1.height = 3 * this.turtle.ctx.canvas.height; + // Use 2x viewport instead of 3x to save ~40 MB of memory. + // At 1920x1080: 2x = 3840x2160x4 = 33 MB vs 3x = 5760x3240x4 = 75 MB. + turtles.canvas1.width = 2 * this.turtle.ctx.canvas.width; + turtles.canvas1.height = 2 * this.turtle.ctx.canvas.height; turtles.c1ctx = turtles.canvas1.getContext("2d", { willReadFrequently: true }); turtles.c1ctx.rect( 0, 0, - 3 * this.turtle.ctx.canvas.width, - 3 * this.turtle.ctx.canvas.height + 2 * this.turtle.ctx.canvas.width, + 2 * this.turtle.ctx.canvas.height ); turtles.c1ctx.fillStyle = "#F9F9F9"; turtles.c1ctx.fill(); @@ -1383,15 +1385,12 @@ class Painter { turtles.gy -= dy; turtles.gx -= dx; + // Clamp to the bounds of the 2x scroll canvas (max offset = 1x viewport) turtles.gx = - 2 * this.turtle.ctx.canvas.width > turtles.gx - ? turtles.gx - : 2 * this.turtle.ctx.canvas.width; + this.turtle.ctx.canvas.width > turtles.gx ? turtles.gx : this.turtle.ctx.canvas.width; turtles.gx = 0 > turtles.gx ? 0 : turtles.gx; turtles.gy = - 2 * this.turtle.ctx.canvas.height > turtles.gy - ? turtles.gy - : 2 * this.turtle.ctx.canvas.height; + this.turtle.ctx.canvas.height > turtles.gy ? turtles.gy : this.turtle.ctx.canvas.height; turtles.gy = 0 > turtles.gy ? 0 : turtles.gy; const newImgData = turtles.c1ctx.getImageData(