UNPKG

pixi.js

Version:

<p align="center"> <a href="https://pixijs.com" target="_blank" rel="noopener noreferrer"> <img height="150" src="https://files.pixijs.download/branding/pixijs-logo-transparent-dark.svg?v=1" alt="PixiJS logo"> </a> </p> <br/> <p align="center">

557 lines (553 loc) 18.5 kB
'use strict'; var FederatedEvent = require('../events/FederatedEvent.js'); var Extensions = require('../extensions/Extensions.js'); var isMobile = require('../utils/browser/isMobile.js'); var removeItems = require('../utils/data/removeItems.js'); "use strict"; const KEY_CODE_TAB = 9; const DIV_TOUCH_SIZE = 100; const DIV_TOUCH_POS_X = 0; const DIV_TOUCH_POS_Y = 0; const DIV_TOUCH_ZINDEX = 2; const DIV_HOOK_SIZE = 1; const DIV_HOOK_POS_X = -1e3; const DIV_HOOK_POS_Y = -1e3; const DIV_HOOK_ZINDEX = 2; const _AccessibilitySystem = class _AccessibilitySystem { // 2fps // eslint-disable-next-line jsdoc/require-param /** * @param {WebGLRenderer|WebGPURenderer} renderer - A reference to the current renderer */ constructor(renderer, _mobileInfo = isMobile.isMobile) { this._mobileInfo = _mobileInfo; /** Whether accessibility divs are visible for debugging */ this.debug = false; /** Whether to activate on tab key press */ this._activateOnTab = true; /** Whether to deactivate accessibility when mouse moves */ this._deactivateOnMouseMove = true; /** Internal variable, see isActive getter. */ this._isActive = false; /** Internal variable, see isMobileAccessibility getter. */ this._isMobileAccessibility = false; /** This is the dom element that will sit over the PixiJS element. This is where the div overlays will go. */ this._div = null; /** A simple pool for storing divs. */ this._pool = []; /** This is a tick used to check if an object is no longer being rendered. */ this._renderId = 0; /** The array of currently active accessible items. */ this._children = []; /** Count to throttle div updates on android devices. */ this._androidUpdateCount = 0; /** The frequency to update the div elements. */ this._androidUpdateFrequency = 500; this._hookDiv = null; if (_mobileInfo.tablet || _mobileInfo.phone) { this._createTouchHook(); } this._renderer = renderer; } /** * Value of `true` if accessibility is currently active and accessibility layers are showing. * @type {boolean} * @readonly */ get isActive() { return this._isActive; } /** * Value of `true` if accessibility is enabled for touch devices. * @type {boolean} * @readonly */ get isMobileAccessibility() { return this._isMobileAccessibility; } /** * The DOM element that will sit over the PixiJS element. This is where the div overlays will go. * @readonly */ get hookDiv() { return this._hookDiv; } /** * Creates the touch hooks. * @private */ _createTouchHook() { const hookDiv = document.createElement("button"); hookDiv.style.width = `${DIV_HOOK_SIZE}px`; hookDiv.style.height = `${DIV_HOOK_SIZE}px`; hookDiv.style.position = "absolute"; hookDiv.style.top = `${DIV_HOOK_POS_X}px`; hookDiv.style.left = `${DIV_HOOK_POS_Y}px`; hookDiv.style.zIndex = DIV_HOOK_ZINDEX.toString(); hookDiv.style.backgroundColor = "#FF0000"; hookDiv.title = "select to enable accessibility for this content"; hookDiv.addEventListener("focus", () => { this._isMobileAccessibility = true; this._activate(); this._destroyTouchHook(); }); document.body.appendChild(hookDiv); this._hookDiv = hookDiv; } /** * Destroys the touch hooks. * @private */ _destroyTouchHook() { if (!this._hookDiv) { return; } document.body.removeChild(this._hookDiv); this._hookDiv = null; } /** * Activating will cause the Accessibility layer to be shown. * This is called when a user presses the tab key. * @private */ _activate() { if (this._isActive) { return; } this._isActive = true; if (!this._div) { this._div = document.createElement("div"); this._div.style.width = `${DIV_TOUCH_SIZE}px`; this._div.style.height = `${DIV_TOUCH_SIZE}px`; this._div.style.position = "absolute"; this._div.style.top = `${DIV_TOUCH_POS_X}px`; this._div.style.left = `${DIV_TOUCH_POS_Y}px`; this._div.style.zIndex = DIV_TOUCH_ZINDEX.toString(); this._div.style.pointerEvents = "none"; } if (this._activateOnTab) { this._onKeyDown = this._onKeyDown.bind(this); globalThis.addEventListener("keydown", this._onKeyDown, false); } if (this._deactivateOnMouseMove) { this._onMouseMove = this._onMouseMove.bind(this); globalThis.document.addEventListener("mousemove", this._onMouseMove, true); } const canvas = this._renderer.view.canvas; if (!canvas.parentNode) { const observer = new MutationObserver(() => { if (canvas.parentNode) { canvas.parentNode.appendChild(this._div); observer.disconnect(); this._initAccessibilitySetup(); } }); observer.observe(document.body, { childList: true, subtree: true }); } else { canvas.parentNode.appendChild(this._div); this._initAccessibilitySetup(); } } // New method to handle initialization after div is ready _initAccessibilitySetup() { this._renderer.runners.postrender.add(this); if (this._renderer.lastObjectRendered) { this._updateAccessibleObjects(this._renderer.lastObjectRendered); } } /** * Deactivates the accessibility system. Removes listeners and accessibility elements. * @private */ _deactivate() { if (!this._isActive || this._isMobileAccessibility) { return; } this._isActive = false; globalThis.document.removeEventListener("mousemove", this._onMouseMove, true); if (this._activateOnTab) { globalThis.addEventListener("keydown", this._onKeyDown, false); } this._renderer.runners.postrender.remove(this); for (const child of this._children) { if (child._accessibleDiv && child._accessibleDiv.parentNode) { child._accessibleDiv.parentNode.removeChild(child._accessibleDiv); child._accessibleDiv = null; } child._accessibleActive = false; } this._pool.forEach((div) => { if (div.parentNode) { div.parentNode.removeChild(div); } }); if (this._div && this._div.parentNode) { this._div.parentNode.removeChild(this._div); } this._pool = []; this._children = []; } /** * This recursive function will run through the scene graph and add any new accessible objects to the DOM layer. * @private * @param {Container} container - The Container to check. */ _updateAccessibleObjects(container) { if (!container.visible || !container.accessibleChildren) { return; } if (container.accessible) { if (!container._accessibleActive) { this._addChild(container); } container._renderId = this._renderId; } const children = container.children; if (children) { for (let i = 0; i < children.length; i++) { this._updateAccessibleObjects(children[i]); } } } /** * Runner init called, view is available at this point. * @ignore */ init(options) { const defaultOpts = _AccessibilitySystem.defaultOptions; const mergedOptions = { accessibilityOptions: { ...defaultOpts, ...options?.accessibilityOptions || {} } }; this.debug = mergedOptions.accessibilityOptions.debug; this._activateOnTab = mergedOptions.accessibilityOptions.activateOnTab; this._deactivateOnMouseMove = mergedOptions.accessibilityOptions.deactivateOnMouseMove; if (mergedOptions.accessibilityOptions.enabledByDefault) { this._activate(); } else if (this._activateOnTab) { this._onKeyDown = this._onKeyDown.bind(this); globalThis.addEventListener("keydown", this._onKeyDown, false); } this._renderer.runners.postrender.remove(this); } /** * Updates the accessibility layer during rendering. * - Removes divs for containers no longer in the scene * - Updates the position and dimensions of the root div * - Updates positions of active accessibility divs * Only fires while the accessibility system is active. * @ignore */ postrender() { const now = performance.now(); if (this._mobileInfo.android.device && now < this._androidUpdateCount) { return; } this._androidUpdateCount = now + this._androidUpdateFrequency; if (!this._renderer.renderingToScreen || !this._renderer.view.canvas) { return; } const activeIds = /* @__PURE__ */ new Set(); if (this._renderer.lastObjectRendered) { this._updateAccessibleObjects(this._renderer.lastObjectRendered); for (const child of this._children) { if (child._renderId === this._renderId) { activeIds.add(this._children.indexOf(child)); } } } for (let i = this._children.length - 1; i >= 0; i--) { const child = this._children[i]; if (!activeIds.has(i)) { if (child._accessibleDiv && child._accessibleDiv.parentNode) { child._accessibleDiv.parentNode.removeChild(child._accessibleDiv); this._pool.push(child._accessibleDiv); child._accessibleDiv = null; } child._accessibleActive = false; removeItems.removeItems(this._children, i, 1); } } if (this._renderer.renderingToScreen) { const { x, y, width: viewWidth, height: viewHeight } = this._renderer.screen; const div = this._div; div.style.left = `${x}px`; div.style.top = `${y}px`; div.style.width = `${viewWidth}px`; div.style.height = `${viewHeight}px`; } for (let i = 0; i < this._children.length; i++) { const child = this._children[i]; if (!child._accessibleActive || !child._accessibleDiv) { continue; } const div = child._accessibleDiv; const hitArea = child.hitArea || child.getBounds().rectangle; if (child.hitArea) { const wt = child.worldTransform; const sx = this._renderer.resolution; const sy = this._renderer.resolution; div.style.left = `${(wt.tx + hitArea.x * wt.a) * sx}px`; div.style.top = `${(wt.ty + hitArea.y * wt.d) * sy}px`; div.style.width = `${hitArea.width * wt.a * sx}px`; div.style.height = `${hitArea.height * wt.d * sy}px`; } else { this._capHitArea(hitArea); const sx = this._renderer.resolution; const sy = this._renderer.resolution; div.style.left = `${hitArea.x * sx}px`; div.style.top = `${hitArea.y * sy}px`; div.style.width = `${hitArea.width * sx}px`; div.style.height = `${hitArea.height * sy}px`; } } this._renderId++; } /** * private function that will visually add the information to the * accessibility div * @param {HTMLElement} div - */ _updateDebugHTML(div) { div.innerHTML = `type: ${div.type}</br> title : ${div.title}</br> tabIndex: ${div.tabIndex}`; } /** * Adjust the hit area based on the bounds of a display object * @param {Rectangle} hitArea - Bounds of the child */ _capHitArea(hitArea) { if (hitArea.x < 0) { hitArea.width += hitArea.x; hitArea.x = 0; } if (hitArea.y < 0) { hitArea.height += hitArea.y; hitArea.y = 0; } const { width: viewWidth, height: viewHeight } = this._renderer; if (hitArea.x + hitArea.width > viewWidth) { hitArea.width = viewWidth - hitArea.x; } if (hitArea.y + hitArea.height > viewHeight) { hitArea.height = viewHeight - hitArea.y; } } /** * Creates or reuses a div element for a Container and adds it to the accessibility layer. * Sets up ARIA attributes, event listeners, and positioning based on the container's properties. * @private * @param {Container} container - The child to make accessible. */ _addChild(container) { let div = this._pool.pop(); if (!div) { if (container.accessibleType === "button") { div = document.createElement("button"); } else { div = document.createElement(container.accessibleType); div.style.cssText = ` color: transparent; pointer-events: none; padding: 0; margin: 0; border: 0; outline: 0; background: transparent; box-sizing: border-box; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; `; if (container.accessibleText) { div.innerText = container.accessibleText; } } div.style.width = `${DIV_TOUCH_SIZE}px`; div.style.height = `${DIV_TOUCH_SIZE}px`; div.style.backgroundColor = this.debug ? "rgba(255,255,255,0.5)" : "transparent"; div.style.position = "absolute"; div.style.zIndex = DIV_TOUCH_ZINDEX.toString(); div.style.borderStyle = "none"; if (navigator.userAgent.toLowerCase().includes("chrome")) { div.setAttribute("aria-live", "off"); } else { div.setAttribute("aria-live", "polite"); } if (navigator.userAgent.match(/rv:.*Gecko\//)) { div.setAttribute("aria-relevant", "additions"); } else { div.setAttribute("aria-relevant", "text"); } div.addEventListener("click", this._onClick.bind(this)); div.addEventListener("focus", this._onFocus.bind(this)); div.addEventListener("focusout", this._onFocusOut.bind(this)); } div.style.pointerEvents = container.accessiblePointerEvents; div.type = container.accessibleType; if (container.accessibleTitle && container.accessibleTitle !== null) { div.title = container.accessibleTitle; } else if (!container.accessibleHint || container.accessibleHint === null) { div.title = `container ${container.tabIndex}`; } if (container.accessibleHint && container.accessibleHint !== null) { div.setAttribute("aria-label", container.accessibleHint); } if (this.debug) { this._updateDebugHTML(div); } container._accessibleActive = true; container._accessibleDiv = div; div.container = container; this._children.push(container); this._div.appendChild(container._accessibleDiv); if (container.interactive) { container._accessibleDiv.tabIndex = container.tabIndex; } } /** * Dispatch events with the EventSystem. * @param e * @param type * @private */ _dispatchEvent(e, type) { const { container: target } = e.target; const boundary = this._renderer.events.rootBoundary; const event = Object.assign(new FederatedEvent.FederatedEvent(boundary), { target }); boundary.rootTarget = this._renderer.lastObjectRendered; type.forEach((type2) => boundary.dispatchEvent(event, type2)); } /** * Maps the div button press to pixi's EventSystem (click) * @private * @param {MouseEvent} e - The click event. */ _onClick(e) { this._dispatchEvent(e, ["click", "pointertap", "tap"]); } /** * Maps the div focus events to pixi's EventSystem (mouseover) * @private * @param {FocusEvent} e - The focus event. */ _onFocus(e) { if (!e.target.getAttribute("aria-live")) { e.target.setAttribute("aria-live", "assertive"); } this._dispatchEvent(e, ["mouseover"]); } /** * Maps the div focus events to pixi's EventSystem (mouseout) * @private * @param {FocusEvent} e - The focusout event. */ _onFocusOut(e) { if (!e.target.getAttribute("aria-live")) { e.target.setAttribute("aria-live", "polite"); } this._dispatchEvent(e, ["mouseout"]); } /** * Is called when a key is pressed * @private * @param {KeyboardEvent} e - The keydown event. */ _onKeyDown(e) { if (e.keyCode !== KEY_CODE_TAB || !this._activateOnTab) { return; } this._activate(); } /** * Is called when the mouse moves across the renderer element * @private * @param {MouseEvent} e - The mouse event. */ _onMouseMove(e) { if (e.movementX === 0 && e.movementY === 0) { return; } this._deactivate(); } /** * Destroys the accessibility system. Removes all elements and listeners. * > [!IMPORTANT] This is typically called automatically when the {@link Application} is destroyed. * > A typically user should not need to call this method directly. */ destroy() { this._deactivate(); this._destroyTouchHook(); this._div = null; this._pool = null; this._children = null; this._renderer = null; if (this._activateOnTab) { globalThis.removeEventListener("keydown", this._onKeyDown); } } /** * Enables or disables the accessibility system. * @param enabled - Whether to enable or disable accessibility. * @example * ```js * app.renderer.accessibility.setAccessibilityEnabled(true); // Enable accessibility * app.renderer.accessibility.setAccessibilityEnabled(false); // Disable accessibility * ``` */ setAccessibilityEnabled(enabled) { if (enabled) { this._activate(); } else { this._deactivate(); } } }; /** @ignore */ _AccessibilitySystem.extension = { type: [ Extensions.ExtensionType.WebGLSystem, Extensions.ExtensionType.WebGPUSystem ], name: "accessibility" }; /** * The default options used by the system. * You can set these before initializing the {@link Application} to change the default behavior. * @example * ```js * import { AccessibilitySystem } from 'pixi.js'; * * AccessibilitySystem.defaultOptions.enabledByDefault = true; * * const app = new Application() * app.init() * ``` */ _AccessibilitySystem.defaultOptions = { /** * Whether to enable accessibility features on initialization * @default false */ enabledByDefault: false, /** * Whether to visually show the accessibility divs for debugging * @default false */ debug: false, /** * Whether to activate accessibility when tab key is pressed * @default true */ activateOnTab: true, /** * Whether to deactivate accessibility when mouse moves * @default true */ deactivateOnMouseMove: true }; let AccessibilitySystem = _AccessibilitySystem; exports.AccessibilitySystem = AccessibilitySystem; //# sourceMappingURL=AccessibilitySystem.js.map