UNPKG

pencil.js

Version:

Nice modular interactive 2D drawing library.

303 lines (280 loc) 9.05 kB
import Component from "@pencil.js/component"; import OffscreenCanvas from "@pencil.js/offscreen-canvas"; import KeyboardEvent from "@pencil.js/keyboard-event"; import MouseEvent from "@pencil.js/mouse-event"; import Position from "@pencil.js/position"; /** * @module Scene */ const listenForEventsKey = Symbol("_listenForEvents"); /** * Scene class * @class * @extends {module:OffscreenCanvas} */ export default class Scene extends OffscreenCanvas { /** * Scene constructor * @param {HTMLElement} [container=document.body] - Container of the renderer * @param {SceneOptions} [options] - Specific options */ constructor (container = window.document.body, options) { if (container === window.document.body) { container.style.margin = 0; container.style.height = `${window.innerHeight}px`; } const measures = container.getBoundingClientRect(); if (container instanceof window.HTMLCanvasElement) { super(null, 0, options); this.ctx = container.getContext("2d"); } else { super(container, 0, options); container.appendChild(this.ctx.canvas); } /** * @type {Position} */ this.cursorPosition = new Position(); /** * @type {Position} */ this.containerPosition = new Position(measures.left + window.scrollX, measures.top + window.scrollY); /** * @type {Boolean} */ this.isScene = true; /** * @type {Boolean} */ this.isLooped = false; /** * @type {Boolean} */ this.isClicked = false; /** * @type {Number} */ this.fps = 0; /** * @type {Number} */ this.lastTick = null; this[listenForEventsKey](container); } /** * * @param {String} [cursor=Component.cursors.default] - Cursor string * @return {Scene} Itself */ setCursor (cursor = Component.cursors.default) { this.ctx.canvas.style.cursor = cursor; return this; } /** * Draw the whole scene * @return {Scene} Itself */ render () { const animationId = this.isLooped ? window.requestAnimationFrame(this.render.bind(this, undefined)) : null; try { super.render(this.ctx); } catch (error) { window.cancelAnimationFrame(animationId); this.stopLoop(); throw error; } const now = window.performance.now(); if (this.isLooped && this.lastTick) { this.fps = 1000 / (now - this.lastTick); } this.lastTick = now; return this; } /** * Define if is hovered * @return {Boolean} */ isHover () { return this.options.shown; } /** * Start to render the scene each frame * @return {Scene} Itself */ startLoop () { this.isLooped = true; this.render(); return this; } /** * Stop scene from being rendered * @return {Scene} Itself */ stopLoop () { this.isLooped = false; this.fps = 0; return this; } /** * @inheritDoc * @return {Scene} Itself */ hide () { this.ctx.canvas.style.visibility = "hidden"; return super.hide(); } /** * @inheritDoc * @return {Scene} Itself */ show () { this.ctx.canvas.style.visibility = ""; return super.show(); } /** * @inheritDoc * @param {Object} definition - Scene definition * @return {Scene} */ static from (definition) { return new Scene(undefined, definition.options); } /** * Build a canvas and set it to fill the entire document.body * @param {HTMLElement} [container=window.document.body] - Element holding the canvas * @inheritDoc */ static getDrawingContext (container = window.document.body) { if (container) { const { scrollWidth, scrollHeight } = container; const ctx = super.getDrawingContext(scrollWidth, scrollHeight); ctx.canvas.style.display = "block"; ctx.canvas.style.position = "absolute"; return ctx; } return null; } /** * @typedef {Object} SceneOptions * @extends OffscreenCanvasOptions * @prop {String} [cursor=Component.cursors.defaultOptions] - Cursor on hover */ /** * @type {SceneOptions} */ static get defaultOptions () { return { ...super.defaultOptions, cursor: Component.cursors.default, }; } /** * @typedef {Object} SceneEvents * @extends ContainerEvent * @prop {String} change - */ /** * @type {SceneEvents} */ static get events () { return { ...super.events, change: "change-scene", }; } } /** * Bind events and call them on targets (can't be called twice) * @param {HTMLElement} container - Container to bind event to * @memberOf Scene# */ Scene.prototype[listenForEventsKey] = function listenForEvents (container) { if (this.isReady) { throw new EvalError("Can't rebind event a second time."); } let hovered = null; let startPosition = null; const mouseListeners = { [MouseEvent.events.down]: (target, eventPosition) => { target.isClicked = true; startPosition = eventPosition; }, [MouseEvent.events.move]: (target, eventPosition) => { if (startPosition) { target.isClicked = target.isClicked && eventPosition.distance(startPosition) < 10; } if (target !== hovered) { if (hovered) { hovered.isHovered = false; if (!hovered.isAncestorOf(target)) { hovered.fire(new MouseEvent(MouseEvent.events.leave, hovered, eventPosition)); } } hovered = target; } if (!target.isHovered) { target.isHovered = true; target.fire(new MouseEvent(MouseEvent.events.hover, target, eventPosition)); } this.setCursor(target.options.cursor); this.cursorPosition.set(eventPosition); }, [MouseEvent.events.up]: (target, eventPosition, event) => { startPosition = null; if (target.isClicked) { target.fire(new MouseEvent(MouseEvent.events.click, target, eventPosition, event)); target.isClicked = false; } }, [MouseEvent.events.wheel]: (target, eventPosition, event) => { const mouseEvents = MouseEvent.events; const events = event.deltaY > 0 ? [mouseEvents.scrollDown, mouseEvents.zoomOut] : [mouseEvents.scrollUp, mouseEvents.zoomIn]; target.fire(new MouseEvent(events[0], target, eventPosition, event)) .fire(new MouseEvent(events[1], target, eventPosition, event)); }, mouseout: (target, eventPosition, event) => { target.fire(new MouseEvent(MouseEvent.events.leave, target, eventPosition, event)); }, mouseenter: (target, eventPosition, event) => { target.fire(new MouseEvent(MouseEvent.events.hover, target, eventPosition, event)); }, [MouseEvent.events.contextMenu]: null, [MouseEvent.events.doubleClick]: null, }; Object.keys(mouseListeners).forEach((eventName) => { container.addEventListener(eventName, (event) => { if (this.options.shown) { const eventPosition = (new Position(event.clientX, event.clientY)) .subtract(this.containerPosition) .add(window.scrollX, window.scrollY); const target = this.getTarget(eventPosition, this.ctx); if (target) { target.fire(new MouseEvent(eventName, target, eventPosition, event)); if (mouseListeners[eventName] instanceof Function) { mouseListeners[eventName](target, eventPosition, event); } } } }, { passive: false, }); }); const keyboardListener = { [KeyboardEvent.events.down]: null, [KeyboardEvent.events.up]: null, }; Object.keys(keyboardListener).forEach((eventName) => { window.document.addEventListener(eventName, (event) => { if (this.options.shown) { this.fire(new KeyboardEvent(eventName, this, event)); if (keyboardListener[eventName] instanceof Function) { keyboardListener[eventName](event); } } }); }); };