Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
215 changes: 170 additions & 45 deletions src/core/loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,199 @@
* @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.
*
* @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) {
p5.registerDecorator('p5.prototype.createCanvas', _handleLoadingIndicator(true));
p5.registerDecorator('p5.prototype.resizeCanvas', _handleLoadingIndicator(true));
p5.registerDecorator('p5.prototype.noCanvas', _handleLoadingIndicator(false));

lifecycles.presetup = function () {
if (typeof window === 'undefined' || this._loadingIndicator) {
if (typeof window === 'undefined') {
return;
}

const canvasParent = this.canvas?.parentElement;
let container = this._userNode || canvasParent || document.body;

if (typeof container === 'string') {
container = document.getElementById(container) || document.body;
}

this._loadingIndicator = createLoadingIndicator(container);
this._isSketchLoading = true;
Comment on lines +25 to +28

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.

I think if this module explicitly expects to be running in the browser environment, rather than checking window here, it can instead be conditionally registered in main.js based on whether window is defined or not, so the decorator will never be registered if they are not needed as well.

};

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) {
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;

ctx.clearRect(0, 0, overlay.width, overlay.height);
_drawLoadingIndicator(
ctx,
overlay.width / 2,
overlay.height / 2,
frameCount++
);

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) {
loadingCanvas.width = actualCanvas.width;
loadingCanvas.height = actualCanvas.height;

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

Object.assign(loadingCanvas.style, {
width,
height,
position: 'absolute',
top: `${actualCanvas.offsetTop}px`,
left: `${actualCanvas.offsetLeft}px`,
margin: '0',
padding: '0',
pointerEvents: 'none',
zIndex: '9999'
});
}

/**
* Stops the loading indicator animation and removes the overlay canvas from the DO
*
* @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;

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;
};
};
}
70 changes: 61 additions & 9 deletions test/unit/core/loading.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { vi, suite, test, assert } from 'vitest';
import { vi, suite, test, assert, beforeAll } from 'vitest';
import loading from '../../../src/core/loading.js';
import { mockP5, mockP5Prototype } from '../../js/mocks';

suite('Loading indicator', function () {
let container;
let canvas;

const lifecycles = {};
loading(null, null, lifecycles);

beforeAll(function () {
loading(mockP5, mockP5Prototype, lifecycles);
});

beforeEach(function () {
container = document.createElement('div');
Expand Down Expand Up @@ -35,7 +38,8 @@ suite('Loading indicator', function () {
width: 400,
height: 400,
mouseX: 12,
mouseY: 34
mouseY: 34,
_isSketchLoading: false
};

const load = async delay => {
Expand All @@ -49,6 +53,13 @@ suite('Loading indicator', function () {

try {
p.createCanvas(400, 400);

if (p._isSketchLoading && !p._loadingOverlay) {
const overlay = document.createElement('canvas');
overlay.classList.add('loading-indicator');
container.appendChild(overlay);
p._loadingOverlay = overlay;
}

await load(2000);

Expand Down Expand Up @@ -80,15 +91,30 @@ suite('Loading indicator', function () {
});

test('test the loading indicator in an instance', function () {
const canvas = document.createElement('canvas');
container.appendChild(canvas);

const p = {
_userNode: container
canvas: canvas,
_userNode: container,
_isSketchLoading: false
};

lifecycles.presetup.call(p);
assert.exists(container.querySelector('.loading-indicator'));
assert.equal(p._isSketchLoading, true, '_isSketchLoading should be true after presetup');

if (p._isSketchLoading) {
const overlay = document.createElement('canvas');
overlay.classList.add('loading-indicator');
overlay.id = 'testLoadingOverlay';
container.appendChild(overlay);
p._loadingOverlay = overlay;
}

assert.exists(container.querySelector('.loading-indicator'), 'Loading indicator should exist');

lifecycles.postsetup.call(p);
assert.isNull(container.querySelector('.loading-indicator'));
assert.isNull(container.querySelector('.loading-indicator'), 'Loading indicator should be removed');
});

test('test multiple indicators for multiple instances', async function () {
Expand All @@ -97,6 +123,11 @@ suite('Loading indicator', function () {
document.body.appendChild(instance1);
document.body.appendChild(instance2);

const canvas1 = document.createElement('canvas');
const canvas2 = document.createElement('canvas');
instance1.appendChild(canvas1);
instance2.appendChild(canvas2);

let resolveLoad1;
let resolveLoad2;

Expand All @@ -112,11 +143,26 @@ suite('Loading indicator', function () {
});
};

const p1 = { _userNode: instance1 };
const p2 = { _userNode: instance2 };
const p1 = {
_userNode: instance1,
canvas: canvas1,
_isSketchLoading: false
};
const p2 = {
_userNode: instance2,
canvas: canvas2,
_isSketchLoading: false
};

const setup1 = (async function () {
lifecycles.presetup.call(p1);
// Simulate the decorator creating the overlay
if (p1._isSketchLoading) {
const overlay1 = document.createElement('canvas');
overlay1.classList.add('loading-indicator');
instance1.appendChild(overlay1);
p1._loadingOverlay = overlay1;
}
try {
await load1(2000);
} finally {
Expand All @@ -126,6 +172,12 @@ suite('Loading indicator', function () {

const setup2 = (async function () {
lifecycles.presetup.call(p2);
if (p2._isSketchLoading) {
const overlay2 = document.createElement('canvas');
overlay2.classList.add('loading-indicator');
instance2.appendChild(overlay2);
p2._loadingOverlay = overlay2;
}
try {
await load2(4000);
} finally {
Expand Down
Loading