Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
281c7e7
loading.js uses canvas-based animation and each sketch instance will …
Jextic Aug 30, 2026
1c46bb1
Small descriptions and grammar fixes
Jextic Aug 30, 2026
b3a4f09
Updated loading.js tests to use mockP5 and mockP5Prototype
Jextic Sep 1, 2026
a6e57bc
Merge branch 'main' into issue-8922
Jextic Sep 1, 2026
9752dcc
"noLoadingIndicator()" function added
Jextic Sep 4, 2026
02c56b6
Added test for "noLoadingIndicator()"
Jextic Sep 4, 2026
7c62ee8
Merge branch 'main' into issue-8922
Jextic Sep 4, 2026
6ebe59c
Merge branch 'processing:main' into issue-8922
Jextic Sep 11, 2026
338dced
custom loading indicator (first implementation)
Jextic Sep 12, 2026
aeded15
gets rid of loading indicator if promises are unresolved
Jextic Sep 12, 2026
2ee4751
optional transparent background for loading indicator
Jextic Sep 12, 2026
bb9fc0a
Merge branch 'processing:main' into issue-8922
Jextic Oct 4, 2026
492394e
Merge branch 'main' into issue-8922
Jextic Oct 9, 2026
113d4bc
disableLoadingIndicator flag added
Jextic Oct 11, 2026
4bbe13b
discovered and fixed a bug where custom loading indicator is never cr…
Jextic Oct 11, 2026
7ed29a6
changed loading indicator flag and if promises are rejected in setup(…
Jextic Oct 11, 2026
26621b7
Merge branch 'main' into issue-8922
Jextic Oct 11, 2026
3247013
Merge branch 'issue-8922' of https://github.com/Jextic/p5.js into iss…
Jextic Oct 11, 2026
a179189
Added new and revised old loading test cases
Jextic Oct 11, 2026
4665294
loading try-catch comment
Jextic Oct 11, 2026
c3b75f5
Merge branch 'main' into issue-8922
Jextic Oct 21, 2026
3954c03
Merge branch 'main' into issue-8922
Jextic Oct 27, 2026
c6ab49b
Fixed canvas desynchronization and typo fix
Jextic Oct 27, 2026
f4f84d6
Test for correct overlay positions when loading
Jextic Oct 27, 2026
63a5f47
Merge branch 'main' into issue-8922
ksen0 Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 231 additions & 44 deletions src/core/loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,75 +4,262 @@
* @private
*
* Handles the logic for creating a loading indicator.
* Currently, the loading indicator is basic and can be extended in the future.
*/

/**
* Creates a loading indicator when the sketch's setup() function is running.
* It is called and removed automatically using the presetup and postsetup lifecycles hooks.
* It is called and removed automatically using the presetup and postsetup lifecycle hooks.
* Registers loading indicator decorators for createCanvas(), resizeCanvas(), and noCanvas()
* to manage the loading indicator overlay.
*
* @private
* @param {*} p5 The p5 constructor
* @param {*} fn The p5 prototype object
* @param {*} lifecycles Lifecycle hooks for the sketch
*/
export default function loading(p5, fn, lifecycles) {
lifecycles.presetup = function () {
if (typeof window === 'undefined' || this._loadingIndicator) {
return;
}
p5.registerDecorator('p5.prototype.createCanvas', _handleLoadingIndicator(true));
p5.registerDecorator('p5.prototype.resizeCanvas', _handleLoadingIndicator(true));
p5.registerDecorator('p5.prototype.noCanvas', _handleLoadingIndicator(false));

const canvasParent = this.canvas?.parentElement;
let container = this._userNode || canvasParent || document.body;
/**
* Sets a custom loading animation for the current sketch.
*
* Calling `loadingAnimation()` without a callback disables the loading
* animation. Calling it with a callback replaces the default animation.
* The callback receives the overlay's 2D rendering context, its width,
* its height, and the current animation frame. The callback's `this` value
* is the current p5 instance.
*
* @method loadingAnimation
* @param {Function} [callback] Function used to draw each animation frame.
* @chainable
*/
fn.loadingAnimation = function (callback) {
this._hasCustomLoading = true;
this._loadingAnimation = callback;

if (typeof container === 'string') {
container = document.getElementById(container) || document.body;
// Calling without a callback disables the loading animation.
// A callback replaces the animation on the next frame.
if (typeof callback !== 'function') {
_removeLoadingOverlay(this);
}

this._loadingIndicator = createLoadingIndicator(container);
return this;
};

lifecycles.presetup = function () {
this._isSketchLoading = true;
};

lifecycles.postsetup = function () {
if (this._loadingIndicator) {
this._loadingIndicator.remove();
this._loadingIndicator = null;
}
this._isSketchLoading = false;
_removeLoadingOverlay(this);
};
}

/**
* Creates and stylizes the loading indicator.
* As a helper function, it can be extensible and modified in future versions.
* Creates the loading canvas to directly overlay the sketch canvas
* and starts the spinning logo animation loop.
*
* @private
* @param {HTMLElement} container The HTML element to append the indicator to
* @returns {HTMLElement} The loading indicator div element
* @param {p5} pInst The p5 instance.
*/
function createLoadingIndicator(container) {
if (!document.getElementById('p5-loading-style')) {
const loadingStyle = document.createElement('style');
loadingStyle.id = 'p5-loading-style';
loadingStyle.textContent =
'@keyframes p5-loading-spin { to { transform: rotate(360deg); } }';
document.head.appendChild(loadingStyle);
function _createLoadingOverlay(pInst) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what the root cause is but here's an example of the disagreement between canvas and overlay position:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body { margin: 10px; }
    canvas { outline: 3px dashed red; background: transparent; }
    canvas.loading-indicator { outline: 3px solid limegreen; }
  </style>
  <script src="../p5.min.js"></script>
</head>
<body>
<script>
const makeSketch = (w, h, delay, label) => (p) => {
  p.setup = async function() {
    p.createCanvas(w, h);
    await new Promise(r => setTimeout(r, delay));
    p.background(240);
    p.fill(0);
    p.textAlign(p.CENTER, p.CENTER);
    p.textSize(12);
    p.text(label, w / 2, h / 2);
  };
};

new p5(makeSketch(600, 300, 7000, 'big 600x300'));
new p5(makeSketch(200, 200, 7000, 'small 200x200'));
new p5(makeSketch(100, 500, 7000, 'tall 100x500'));
</script>
</body>
</html>

During loading:
Image

After loading:
Image

if (pInst.constructor.loadingAnimation === false) {
return;
}

if (pInst._hasCustomLoading && typeof pInst._loadingAnimation !== 'function') {
return;
}

const actualCanvas = pInst.canvas?.elt || pInst.canvas;
if (!actualCanvas) return;

let overlay = pInst._loadingOverlay;

// If overlay doesn't exist yet, create it and animate it
if (!overlay) {
overlay = document.createElement('canvas');
overlay.id = `${actualCanvas.id || 'defaultCanvas0'}_loadingOverlay`;
overlay.classList.add('loading-indicator');
pInst._loadingOverlay = overlay;

const ctx = overlay.getContext('2d');
let frameCount = 0;

const animate = () => {
if (!pInst._isSketchLoading) {
return;
}

const currentCanvas = pInst.canvas?.elt || pInst.canvas;
if (currentCanvas) {
_positionCanvas(overlay, currentCanvas);
}

ctx.clearRect(0, 0, overlay.width, overlay.height);
const frame = frameCount++;
if (pInst._hasCustomLoading) {
pInst._loadingAnimation.call(
pInst,
ctx,
overlay.width,
overlay.height,
frame
);
}
else {
_drawLoadingIndicator(
ctx,
overlay.width / 2,
overlay.height / 2,
frame
);
}

pInst._loadingOverlayFrame = requestAnimationFrame(animate);
};

animate();
}

const indicator = document.createElement('div');
indicator.className = 'loading-indicator';
indicator.style.cssText = `
position: fixed;
inset: 0;
margin: auto;
width: 30px;
height: 30px;
border-radius: 50%;

border: 3px solid rgba(0, 0, 0, 0.1);
border-top-color: rgba(0, 0, 0, 0.8);
animation: p5-loading-spin 1s linear infinite;
z-index: 9999;
`;

container.appendChild(indicator);
return indicator;
// Positions the loading indicator to overlay the sketch canvas
_positionCanvas(overlay, actualCanvas);

if (overlay.parentNode !== actualCanvas.parentNode) {
actualCanvas.parentNode.insertBefore(overlay, actualCanvas.nextSibling);
}
}

/**
* Matches the size and position of the loading canvas to the user's sketch canvas.
*
* @private
* @param {HTMLCanvasElement} loadingCanvas The overlay canvas element.
* @param {HTMLCanvasElement} actualCanvas The sketch canvas element.
*/
function _positionCanvas(loadingCanvas, actualCanvas) {
if (loadingCanvas.width !== actualCanvas.width) {
loadingCanvas.width = actualCanvas.width;
}
if (loadingCanvas.height !== actualCanvas.height) {
loadingCanvas.height = actualCanvas.height;
}

const width = actualCanvas.style.width || `${actualCanvas.offsetWidth || actualCanvas.width}px`;
const height = actualCanvas.style.height || `${actualCanvas.offsetHeight || actualCanvas.height}px`;
const top = `${actualCanvas.offsetTop}px`;
const left = `${actualCanvas.offsetLeft}px`;

if (loadingCanvas.style.top !== top) loadingCanvas.style.top = top;
if (loadingCanvas.style.left !== left) loadingCanvas.style.left = left;
if (loadingCanvas.style.width !== width) loadingCanvas.style.width = width;
if (loadingCanvas.style.height !== height) loadingCanvas.style.height = height;

if (loadingCanvas.style.position !== 'absolute') {
Object.assign(loadingCanvas.style, {
position: 'absolute',
margin: '0',
padding: '0',
pointerEvents: 'none',
zIndex: '9999'
});
}
}

/**
* Stops the loading indicator animation and removes the overlay canvas from the DOM.
* @private
* @param {p5} pInst The p5 instance.
*/
function _removeLoadingOverlay(pInst) {
if (pInst._loadingOverlay) {
cancelAnimationFrame(pInst._loadingOverlayFrame);
pInst._loadingOverlay.remove();
pInst._loadingOverlay = null;
}
}

/**
* Draws a canvas-based animated loading indicator.
* The loading indicator is a spinning p5 logo.
*
* Credits to Raphaël de Courville for creating the p5 logo sketch
*
* @private
* @param {CanvasRenderingContext2D} ctx The 2D canvas context to draw on.
* @param {Number} x The x-coordinate for the logo center.
* @param {Number} y The y-coordinate for the logo center.
* @param {Number} t The frame count used to calculate rotation.
*/
function _drawLoadingIndicator(ctx, x, y, t) {
let rotationSpeed = 3.25;
let indicatorSize = 1.5;

// Semi-transparent gray background
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

ctx.save();
ctx.translate(x, y);
ctx.scale(indicatorSize, indicatorSize);

ctx.rotate((t * rotationSpeed * Math.PI) / 180);
ctx.translate(-14, -14);

ctx.fillStyle = '#ED225D';
ctx.beginPath();

ctx.moveTo(16.909, 10.259);
ctx.lineTo(25.442, 7.683);
ctx.lineTo(27.118, 12.839);
ctx.lineTo(18.62, 15.738);
ctx.lineTo(23.895, 23.218);
ctx.lineTo(19.448, 26.443);
ctx.lineTo(13.895, 19.095);
ctx.lineTo(8.487, 26.25);
ctx.lineTo(4.169, 22.961);
ctx.lineTo(9.444, 15.738);
ctx.lineTo(0.88, 12.647);
ctx.lineTo(2.558, 7.487);
ctx.lineTo(11.156, 10.258);
ctx.lineTo(11.156, 1.364);
ctx.lineTo(16.91, 1.364);

ctx.closePath();
ctx.fill();
ctx.restore();
}

/**
* Intercepts canvas methods to create, update, or remove the loading indicator
*
* @private
* @internal
*
* @param {Boolean} isLoading True to show the loading indicator; false to hide it.
* @return {Function} A decorator function for the target canvas method.
*/
export function _handleLoadingIndicator(isLoading) {
return function (target) {
return function (...args) {
const result = target.call(this, ...args);

// Create loading overlay if canvas is loading
if (isLoading) {
if (this._isSketchLoading) {
_createLoadingOverlay(this);
}
}

// Remove loading overlay if canvas isn't loading
else {
_removeLoadingOverlay(this);
}

return result;
};
};
}
32 changes: 27 additions & 5 deletions src/core/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* @return {p5} a p5 instance
*/
class p5 {
static loadingAnimation = true;
static VERSION = constants.VERSION;
// This is a pointer to our global mode p5 instance, if we're in
// global mode.
Expand All @@ -44,7 +45,7 @@
};

// FES stub
static _checkForUserDefinedFunctions = () => {};

Check warning on line 48 in src/core/main.js

View workflow job for this annotation

GitHub Actions / lint

eslint(no-dupe-class-members)

src/core/main.js:48:10: Duplicate class member: "_checkForUserDefinedFunctions"
static _friendlyFileLoadError = () => {};
static _friendlyError = () => {};
static _checkForUserDefinedFunctions = () => {};
Expand Down Expand Up @@ -227,7 +228,17 @@
}
}

await this.#_setup();
try {
await this.#_setup();
}
catch (error) {
// Ensure loading indicator is cleaned up if setup rejects before
// the normal postsetup lifecycle hook completes
if (this._isSketchLoading) {
await this._runLifecycleHook('postsetup');
}
throw error;
}
if (this.hitCriticalError) return;
if (!this._recording) {
this._draw();
Expand All @@ -251,8 +262,14 @@
this._millisStart = globalThis.performance.now();

const context = this._isGlobal ? window : this;
if (typeof context.setup === 'function') {
await context.setup();
try {
if (typeof context.setup === 'function') {
await context.setup();
}
}
catch (error) {
await this._runLifecycleHook('postsetup');
throw error;
}
if (this.hitCriticalError) return;

Expand Down Expand Up @@ -394,7 +411,10 @@
this._removeAbortController.abort();

// remove DOM elements created by p5
this._elements.slice().forEach(e => e.remove());
const elementsToRemove = this._elements.slice();
for (const e of elementsToRemove) {
e.remove();
}

// Run `remove` hooks
await this._runLifecycleHook('remove');
Expand Down Expand Up @@ -680,7 +700,9 @@
p5.registerAddon(renderer);
p5.registerAddon(renderer2D);
p5.registerAddon(graphics);
p5.registerAddon(loading);
if (typeof window !== 'undefined') {
p5.registerAddon(loading);
}

export default p5;

Expand Down
Loading
Loading