diff --git a/js/activity.js b/js/activity.js index 36ec729e76..ad647a5c13 100644 --- a/js/activity.js +++ b/js/activity.js @@ -452,7 +452,15 @@ class Activity { this.hideBlocksContainer = null; this.collapseBlocksContainer = null; + // --- DOM reads (batched to avoid forced synchronous layout) --- this.searchWidget = document.getElementById("search"); + this.progressBar = document.getElementById("myProgress"); + const pasteEl = document.getElementById("paste"); + new createjs.DOMElement(pasteEl); + this.paste = pasteEl; + this.toolbarHeight = document.getElementById("toolbars").offsetHeight; + + // --- DOM writes (after all reads complete) --- this.searchWidget.style.visibility = "hidden"; this.searchWidget.placeholder = _("Search for blocks"); @@ -461,15 +469,9 @@ class Activity { this.helpfulSearchWidget.style.visibility = "hidden"; this.helpfulSearchWidget.placeholder = _("Search for blocks"); this.helpfulSearchWidget.classList.add("ui-autocomplete"); - this.progressBar = document.getElementById("myProgress"); this.progressBar.style.visibility = "hidden"; - - new createjs.DOMElement(document.getElementById("paste")); - this.paste = document.getElementById("paste"); this.paste.style.visibility = "hidden"; - this.toolbarHeight = document.getElementById("toolbars").offsetHeight; - this.helpfulWheelItems = []; this.setHelpfulSearchDiv(); @@ -2633,47 +2635,55 @@ class Activity { * Handles touch start event on the canvas. * @param {TouchEvent} event - The touch event object. */ - myCanvas.addEventListener("touchstart", event => { - if (event.touches.length === 2) { - for (let i = 0; i < 2; i++) { - initialTouches[i][0] = event.touches[i].clientY; - initialTouches[i][1] = event.touches[i].clientX; + myCanvas.addEventListener( + "touchstart", + event => { + if (event.touches.length === 2) { + for (let i = 0; i < 2; i++) { + initialTouches[i][0] = event.touches[i].clientY; + initialTouches[i][1] = event.touches[i].clientX; + } } - } - }); + }, + { passive: true } + ); /** * Handles touch move event on the canvas. * @param {TouchEvent} event - The touch event object. */ - myCanvas.addEventListener("touchmove", event => { - if (event.touches.length === 2) { - for (let i = 0; i < 2; i++) { - const touchY = event.touches[i].clientY; - const touchX = event.touches[i].clientX; - - if (initialTouches[i][0] !== null && initialTouches[i][1] !== null) { - const deltaY = touchY - initialTouches[i][0]; - const deltaX = touchX - initialTouches[i][1]; - - if (deltaY !== 0) { - closeAnyOpenMenusAndLabels(); - that.blocksContainer.y -= deltaY; - } + myCanvas.addEventListener( + "touchmove", + event => { + if (event.touches.length === 2) { + for (let i = 0; i < 2; i++) { + const touchY = event.touches[i].clientY; + const touchX = event.touches[i].clientX; + + if (initialTouches[i][0] !== null && initialTouches[i][1] !== null) { + const deltaY = touchY - initialTouches[i][0]; + const deltaX = touchX - initialTouches[i][1]; + + if (deltaY !== 0) { + closeAnyOpenMenusAndLabels(); + that.blocksContainer.y -= deltaY; + } - if (that.scrollBlockContainer && deltaX !== 0) { - closeAnyOpenMenusAndLabels(); - that.blocksContainer.x -= deltaX; - } + if (that.scrollBlockContainer && deltaX !== 0) { + closeAnyOpenMenusAndLabels(); + that.blocksContainer.x -= deltaX; + } - initialTouches[i][0] = touchY; - initialTouches[i][1] = touchX; + initialTouches[i][0] = touchY; + initialTouches[i][1] = touchX; + } } - } - that.refreshCanvas(); - } - }); + that.refreshCanvas(); + } + }, + { passive: true } + ); /** * Handles touch end event on the canvas. @@ -2946,8 +2956,12 @@ class Activity { window.addEventListener("mousemove", resetIdleTimer); window.addEventListener("mousedown", resetIdleTimer); window.addEventListener("keydown", resetIdleTimer); - window.addEventListener("touchstart", resetIdleTimer); - window.addEventListener("wheel", resetIdleTimer); + window.addEventListener("touchstart", resetIdleTimer, { + passive: true + }); + window.addEventListener("wheel", resetIdleTimer, { + passive: true + }); // Periodic check for idle state setInterval(() => { @@ -6865,15 +6879,16 @@ class Activity { const img = new Image(); img.src = "data:image/svg+xml;base64," + window.btoa(base64Encode(name)); + // Accessibility: derive alt text from the button label + const altText = label ? label.replace(/\s*\[.*\]$/, "") : "Toolbar button"; + img.setAttribute("alt", altText); + // Batch DOM reads before writes to avoid forced synchronous layout + const rightPos = document.body.clientWidth - x; container.appendChild(img); container.setAttribute( "style", - "position: absolute; right:" + - (document.body.clientWidth - x) + - "px; top: " + - y + - "px;" + "position: absolute; right:" + rightPos + "px; top: " + y + "px;" ); document.getElementById("buttoncontainerBOTTOM").appendChild(container); return container; @@ -7291,14 +7306,17 @@ class Activity { * Inits everything. The main function. */ this.init = async () => { + // Batch DOM reads before any writes to avoid forced synchronous layout this._clientWidth = document.body.clientWidth; this._clientHeight = document.body.clientHeight; this._innerWidth = window.innerWidth; this._innerHeight = window.innerHeight; this._outerWidth = window.outerWidth; this._outerHeight = window.outerHeight; + const loaderEl = document.getElementById("loader"); - document.getElementById("loader").className = "loader"; + // DOM write: apply class after all geometry reads + loaderEl.className = "loader"; /* * Run browser check before implementing onblur --> diff --git a/js/blocks.js b/js/blocks.js index 2358e290e9..2d1dc15c0e 100644 --- a/js/blocks.js +++ b/js/blocks.js @@ -5797,7 +5797,38 @@ class Blocks { const blockOffset = this.blockList.length; const firstBlock = this.blockList.length; - for (let b = 0; b < this._loadCounter; b++) { + /** + * Chunked block-loading: yield to the main thread every ~50ms + * so the browser can paint and remain interactive during + * large-project loads. Each chunk processes CHUNK_SIZE blocks + * synchronously, then schedules the next chunk via setTimeout(0). + */ + const CHUNK_SIZE = 20; + const totalBlocks = this._loadCounter; + let bIndex = 0; + + const processChunk = () => { + const chunkEnd = Math.min(bIndex + CHUNK_SIZE, totalBlocks); + for (let b = bIndex; b < chunkEnd; b++) { + this._processOneBlock(b, blockObjs, blockOffset, firstBlock); + } + bIndex = chunkEnd; + if (bIndex < totalBlocks) { + setTimeout(processChunk, 0); + } + }; + + processChunk(); + }; + + /** + * Processes a single block object during load. Extracted from + * the former monolithic for-loop inside loadNewBlocks so the + * loop can be chunked across frames. + * @private + */ + this._processOneBlock = (b, blockObjs, blockOffset, firstBlock) => { + { const thisBlock = blockOffset + b; const blkData = blockObjs[b]; let blkInfo; diff --git a/js/turtles.js b/js/turtles.js index 628e4256f8..ed91008475 100644 --- a/js/turtles.js +++ b/js/turtles.js @@ -906,15 +906,14 @@ Turtles.TurtlesView = class { }; const img = new Image(); img.src = "data:image/svg+xml;base64," + window.btoa(base64Encode(svg)); + img.setAttribute("alt", object.label || object.name || "Canvas button"); + // Batch DOM reads before writes to avoid forced synchronous layout + const rightPos = document.body.clientWidth - x; container.appendChild(img); container.setAttribute( "style", - "position: absolute; right:" + - (document.body.clientWidth - x) + - "px; top: " + - y + - "px;" + "position: absolute; right:" + rightPos + "px; top: " + y + "px;" ); docById("buttoncontainerTOP").appendChild(container); return container; diff --git a/js/widgets/aiwidget.js b/js/widgets/aiwidget.js index 511c7c05ce..f83deef74b 100644 --- a/js/widgets/aiwidget.js +++ b/js/widgets/aiwidget.js @@ -239,18 +239,20 @@ function AIWidget() { return blocks; } - //function to search index for particular type of block mainly used to find nammeddo block in repeat block + // Fast index lookup — builds a one-time Map for O(1) repeated searches + // on the same array, with a transparent fallback to linear scan. + const _indexMaps = new WeakMap(); function searchIndexForMusicBlock(array, x) { - // Iterate over each sub-array in the main array - for (let i = 0; i < array.length; i++) { - // Check if the 0th element of the sub-array matches x - if (array[i][0] === x) { - // Return the index if a match is found - return i; + let map = _indexMaps.get(array); + if (!map) { + map = new Map(); + for (let i = 0; i < array.length; i++) { + map.set(array[i][0], i); } + _indexMaps.set(array, map); } - // Return -1 if no match is found - return -1; + const idx = map.get(x); + return idx !== undefined ? idx : -1; } this._parseABC = async function (tune) { @@ -277,8 +279,10 @@ function AIWidget() { }); for (const lineId in organizeBlock) { organizeBlock[lineId].arrangedBlocks?.forEach(staff => { - if (!staffBlocksMap.hasOwnProperty(lineId)) { - staffBlocksMap[lineId] = { + // Cache the entry for this lineId to avoid repeated property lookups + let entry = staffBlocksMap[lineId]; + if (!entry) { + entry = { meterNum: staff?.meter?.value[0]?.num || 4, meterDen: staff?.meter?.value[0]?.den || 4, keySignature: staff.key, @@ -376,6 +380,7 @@ function AIWidget() { //for adding above 17 blocks above blockId = blockId + 17; + staffBlocksMap[lineId] = entry; } const actionBlock = []; @@ -398,7 +403,7 @@ function AIWidget() { staff.key, actionBlock, tripletFinder, - staffBlocksMap[lineId].meterDen + entry.meterDen ); if (element?.endTriplet !== null && element?.endTriplet !== undefined) { tripletFinder = null; @@ -431,14 +436,10 @@ function AIWidget() { actionBlock[actionBlock.length - 1][4][1] = null; //update the namedo block if not first nameddo block appear - if (staffBlocksMap[lineId].baseBlocks.length != 0) { - staffBlocksMap[lineId].baseBlocks[ - staffBlocksMap[lineId].baseBlocks.length - 1 - ][0][ - staffBlocksMap[lineId].baseBlocks[ - staffBlocksMap[lineId].baseBlocks.length - 1 - ][0].length - 4 - ][4][1] = blockId; + const baseBlocks = entry.baseBlocks; + if (baseBlocks.length != 0) { + const lastBase = baseBlocks[baseBlocks.length - 1]; + lastBase[0][lastBase[0].length - 4][4][1] = blockId; } //add the nameddo action text and hidden block for each line actionBlock.push( @@ -448,21 +449,17 @@ function AIWidget() { "nameddo", { value: `V: ${parseInt(lineId) + 1} Line ${ - staffBlocksMap[lineId]?.baseBlocks?.length + 1 + baseBlocks.length + 1 }` } ], 0, 0, [ - staffBlocksMap[lineId].baseBlocks.length === 0 + baseBlocks.length === 0 ? null - : staffBlocksMap[lineId].baseBlocks[ - staffBlocksMap[lineId].baseBlocks.length - 1 - ][0][ - staffBlocksMap[lineId].baseBlocks[ - staffBlocksMap[lineId].baseBlocks.length - 1 - ][0].length - 4 + : baseBlocks[baseBlocks.length - 1][0][ + baseBlocks[baseBlocks.length - 1][0].length - 4 ][0], null ] @@ -480,7 +477,7 @@ function AIWidget() { "text", { value: `V: ${parseInt(lineId) + 1} Line ${ - staffBlocksMap[lineId]?.baseBlocks?.length + 1 + baseBlocks.length + 1 }` } ], @@ -491,20 +488,20 @@ function AIWidget() { [blockId + 3, "hidden", 0, 0, [blockId + 1, actionBlock[0][0]]] ); // blockid of topaction block - if (!staffBlocksMap[lineId].nameddoArray) { - staffBlocksMap[lineId].nameddoArray = {}; + if (!entry.nameddoArray) { + entry.nameddoArray = {}; } // Ensure the array at nameddoArray[lineId] is initialized if it doesn't exist - if (!staffBlocksMap[lineId].nameddoArray[lineId]) { - staffBlocksMap[lineId].nameddoArray[lineId] = []; + if (!entry.nameddoArray[lineId]) { + entry.nameddoArray[lineId] = []; } - staffBlocksMap[lineId].nameddoArray[lineId].push(blockId); + entry.nameddoArray[lineId].push(blockId); blockId = blockId + 4; musicBlocksJSON.push(actionBlock); - staffBlocksMap[lineId].baseBlocks.push([actionBlock]); + baseBlocks.push([actionBlock]); }); }); } @@ -665,9 +662,8 @@ function AIWidget() { staffBlocksMap[staffIndex].repeatBlock[prevrepeatnameddo][4][3] = blockId; } if (afternamedo != -1) { - staffBlocksMap[staffIndex].baseBlocks[repeatId.end][0][ - afternamedo - ][4][1] = null; + staffBlocksMap[staffIndex].baseBlocks[repeatId.end][0][afternamedo][4][1] = + null; } staffBlocksMap[staffIndex].baseBlocks[repeatId.start][0][ @@ -834,21 +830,17 @@ function AIWidget() { }; this._save_lock = false; - widgetWindow.addButton( - "export-chunk.svg", - ICONSIZE, - _("Save sample"), - "" - ).onclick = function () { - // Debounce button - if (!that._get_save_lock()) { - that._save_lock = true; - that._saveSample(); - setTimeout(function () { - that._save_lock = false; - }, 1000); - } - }; + widgetWindow.addButton("export-chunk.svg", ICONSIZE, _("Save sample"), "").onclick = + function () { + // Debounce button + if (!that._get_save_lock()) { + that._save_lock = true; + that._saveSample(); + setTimeout(function () { + that._save_lock = false; + }, 1000); + } + }; widgetWindow.sendToCenter(); this.widgetWindow = widgetWindow; @@ -878,6 +870,21 @@ function AIWidget() { * Plays the reference pitch based on the current sample's pitch, accidental, and octave. * @returns {void} */ + // Reuse a single AudioContext across plays to avoid the browser limit + // on the number of AudioContexts that can be created. + let _sharedAudioContext = null; + function _getAudioContext() { + window.AudioContext = + window.AudioContext || + window.webkitAudioContext || + navigator.mozAudioContext || + navigator.msAudioContext; + if (!_sharedAudioContext || _sharedAudioContext.state === "closed") { + _sharedAudioContext = new window.AudioContext(); + } + return _sharedAudioContext; + } + this._playABCSong = function () { const abc = abcNotationSong; const stopAudioButton = document.querySelector(".stop-audio"); @@ -887,16 +894,7 @@ function AIWidget() { })[0]; if (ABCJS.synth.supportsAudio()) { - // An audio context is needed - this can be passed in for two reasons: - // 1) So that you can share this audio context with other elements on your page. - // 2) So that you can create it during a user interaction so that the browser doesn't block the sound. - // Setting this is optional - if you don't set an audioContext, then abcjs will create one. - window.AudioContext = - window.AudioContext || - window.webkitAudioContext || - navigator.mozAudioContext || - navigator.msAudioContext; - const audioContext = new window.AudioContext(); + const audioContext = _getAudioContext(); audioContext.resume().then(function () { // In theory the AC shouldn't start suspended because it is being initialized in a click handler, but iOS seems to anyway. @@ -1033,9 +1031,10 @@ function AIWidget() { this._scale = function () { let width, height; const canvas = document.getElementsByClassName("samplerCanvas"); - Array.prototype.forEach.call(canvas, ele => { - this.widgetWindow.getWidgetBody().removeChild(ele); - }); + const body = this.widgetWindow.getWidgetBody(); + for (let i = canvas.length - 1; i >= 0; i--) { + body.removeChild(canvas[i]); + } if (!this.widgetWindow.isMaximized()) { width = SAMPLEWIDTH; height = SAMPLEHEIGHT; @@ -1056,52 +1055,55 @@ function AIWidget() { * @returns {void} */ this.makeCanvas = function (width, height) { + // Build the entire widget DOM off-screen in a DocumentFragment, + // then append once to avoid multiple reflows. + const fragment = document.createDocumentFragment(); + // Create a container to center the elements const container = document.createElement("div"); - - this.widgetWindow.getWidgetBody().appendChild(container); + fragment.appendChild(container); // Create a scrollable container for the textarea const scrollContainer = document.createElement("div"); - scrollContainer.style.overflowY = "auto"; // Enable vertical scrolling - scrollContainer.style.height = height + "px"; // Set the height of the scroll container - scrollContainer.style.border = "1px solid #ccc"; // Optional: Add a border for visibility - scrollContainer.style.marginBottom = "8px"; - scrollContainer.style.marginLeft = "8px"; - scrollContainer.style.display = "flex"; // Use flexbox for centering - scrollContainer.style.flexDirection = "column"; // Stack elements vertically - scrollContainer.style.alignItems = "center"; // Center items horizontally + scrollContainer.style.cssText = + "overflow-y:auto;" + + "height:" + + height + + "px;" + + "border:1px solid #ccc;" + + "margin-bottom:8px;" + + "margin-left:8px;" + + "display:flex;" + + "flex-direction:column;" + + "align-items:center"; container.appendChild(scrollContainer); // Create the textarea element const textarea = document.createElement("textarea"); - textarea.style.height = height + "px"; // Keep the height for the scrollable area - textarea.style.width = width + "px"; + textarea.style.cssText = + "height:" + + height + + "px;" + + "width:" + + width + + "px;" + + "margin-left:20px;" + + "font-size:20px;" + + "padding:10px"; textarea.className = "samplerTextarea"; - textarea.style.marginLeft = "20px"; - textarea.style.fontSize = "20px"; - textarea.style.padding = "10px"; scrollContainer.appendChild(textarea); // Append textarea to scroll container // Create hint text elements const hintsContainer = document.createElement("div"); - hintsContainer.style.marginBottom = "10px"; - - hintsContainer.style.display = "flex"; - hintsContainer.style.justifyContent = "center"; - hintsContainer.style.marginTop = "8px"; + hintsContainer.style.cssText = + "margin-bottom:10px;display:flex;justify-content:center;margin-top:8px"; const hints = ["Dance tune", "Fiddle jig", "Nice melody", "Fun song", "Simple canon"]; hints.forEach(hintText => { const hint = document.createElement("span"); hint.textContent = hintText; - hint.style.marginRight = "20px"; - hint.style.cursor = "pointer"; - hint.style.marginRight = "4px"; - hint.style.fontSize = "20px"; - hint.style.color = "blue"; - hint.style.backgroundColor = "rgb(227 162 162 / 80%)"; // Light white background - hint.style.padding = "10px"; // Add padding for spacing - hint.style.borderRadius = "5px"; // Optional: Rounded corners + hint.style.cssText = + "cursor:pointer;margin-right:4px;font-size:20px;color:blue;" + + "background-color:rgb(227 162 162 / 80%);padding:10px;border-radius:5px"; hint.onclick = function () { inputField.value = hintText; @@ -1117,12 +1119,9 @@ function AIWidget() { inputField.type = "text"; inputField.className = "inputField"; inputField.placeholder = "Enter text here"; - inputField.style.fontSize = "20px"; - inputField.style.marginRight = "2px"; - inputField.style.marginLeft = "64px"; - inputField.style.padding = "10px"; - inputField.style.marginBottom = "10px"; - inputField.style.width = "60%"; + inputField.style.cssText = + "font-size:20px;margin-right:2px;margin-left:64px;" + + "padding:10px;margin-bottom:10px;width:60%"; container.appendChild(inputField); inputField.addEventListener("click", function () { @@ -1250,5 +1249,8 @@ function AIWidget() { textarea.addEventListener("input", function () { abcNotationSong = textarea.value; }); + + // Single DOM append — all elements are now in the fragment + this.widgetWindow.getWidgetBody().appendChild(fragment); }; }