UNPKG

scrawl-canvas

Version:

Responsive, interactive and more accessible HTML5 canvas elements. Scrawl-canvas is a JavaScript library designed to make using the HTML5 canvas element easier, and more fun

675 lines (477 loc) 27.5 kB
// # Core user interaction // A set of functions that are closely tied to the [core/document.js](./document.html) functionality, and a couple of additional coder convenience functions. // // Scrawl-canvas adds some event listeners (mouse movement, screen resize, scrolling) to the window object. These help maintain a centralizerd mouse/touch cursor tracking facility that updates here and then cascades and localizes to artefacts (stacks, canvases and element wrapper objects) which need to keep track of a __local, immediately updated mouse/touch coordinate__. // // Checks to see if events have occurred happen once per requestAnimationFrame tick - this is to choke the more eager event listeners which can, at times, fire thousands of times a second. // #### Imports import * as library from "./library.js"; import { isa_obj, λnull } from "../helper/utilities.js"; import { addListener } from "./events.js"; import { makeAnimation } from "../factory/animation.js"; import { getTrackMouse, setTrackMouse, getMouseChanged, setMouseChanged, getViewportChanged, setViewportChanged, getPrefersContrastChanged, setPrefersContrastChanged, getPrefersReducedMotionChanged, setPrefersReducedMotionChanged, getPrefersDarkColorSchemeChanged, setPrefersDarkColorSchemeChanged, getPrefersReduceTransparencyChanged, setPrefersReduceTransparencyChanged, getPrefersReduceDataChanged, setPrefersReduceDataChanged, getPrefersInvertedColorsChanged, setPrefersInvertedColorsChanged, getPrefersForcedColorsChanged, setPrefersForcedColorsChanged } from '../helper/system-flags.js'; // Shared constants import { _floor, _isFinite, _now, _round, _values, ADD_EVENT_LISTENER, DISPLAY_P3, FONT_USERS, MOUSE, MOUSE_DOWN, MOUSE_ENTER, MOUSE_LEAVE, MOUSE_MOVE, MOUSE_UP, MOVE, POINTER_DOWN, POINTER_ENTER, POINTER_LEAVE, POINTER_MOVE, POINTER_UP, REMOVE_EVENT_LISTENER, SRGB, T_CANVAS, TOUCH_CANCEL, TOUCH_END, TOUCH_MOVE, TOUCH_START } from '../helper/shared-vars.js' // Local constants const CHANGE = 'change', POINTER = 'pointer', RESIZE = 'resize', SCROLL = 'scroll', TOUCH = 'touch'; // `Exported array` (to modules). DOM element wrappers subscribe for updates by adding themselves to the __uiSubscribedElements__ array. When an event fires, the updated data will be pushed to them automatically export const uiSubscribedElements = []; // `Exported object` (to modules and the scrawl object). The __currentCorePosition__ object holds the __global__ mouse cursor position, alongside browser view dimensions and scroll position export const currentCorePosition = { x: 0, y: 0, scrollX: 0, scrollY: 0, w: 0, h: 0, type: MOUSE, prefersReducedMotion: false, prefersDarkColorScheme: false, prefersReduceTransparency: false, prefersContrast: false, prefersReduceData: false, prefersInvertedColors: false, prefersForcedColors: false, displaySupportsP3Color: false, canvasSupportsP3Color: false, devicePixelRatio: 0, rawTouches: [], }; // ### Accessibility preferences // __contrastMediaQuery__ - real-time check on the `prefers-contrast` user preference, as set for the device or OS const contrastMediaQuery = window.matchMedia("(prefers-contrast: more)"); contrastMediaQuery.addEventListener(CHANGE, () => { const res = contrastMediaQuery.matches; if (currentCorePosition.prefersContrast !== res) { currentCorePosition.prefersContrast = res; setPrefersContrastChanged(true); } }); currentCorePosition.prefersContrast = contrastMediaQuery.matches; // __reducedMotionMediaQuery__ - real-time check on the `prefers-reduced-motion` user preference, as set for the device or OS const reducedMotionMediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); reducedMotionMediaQuery.addEventListener(CHANGE, () => { const res = reducedMotionMediaQuery.matches; if (currentCorePosition.prefersReducedMotion !== res) { currentCorePosition.prefersReducedMotion = res; setPrefersReducedMotionChanged(true); } }); currentCorePosition.prefersReducedMotion = reducedMotionMediaQuery.matches; // __colorSchemeMediaQuery__ - real-time check on the `prefers-color-scheme` user preference, as set for the device or OS const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); colorSchemeMediaQuery.addEventListener(CHANGE, () => { const res = colorSchemeMediaQuery.matches; if (currentCorePosition.prefersDarkColorScheme !== res) { currentCorePosition.prefersDarkColorScheme = res; setPrefersDarkColorSchemeChanged(true); } }); currentCorePosition.prefersDarkColorScheme = colorSchemeMediaQuery.matches; // __reducedTransparencyMediaQuery__ - real-time check on the `prefers-reduced-transparency` user preference, as set for the device or OS const reducedTransparencyMediaQuery = window.matchMedia("(prefers-reduced-transparency: reduce)"); reducedTransparencyMediaQuery.addEventListener(CHANGE, () => { const res = reducedTransparencyMediaQuery.matches; if (currentCorePosition.prefersReduceTransparency !== res) { currentCorePosition.prefersReduceTransparency = res; setPrefersReduceTransparencyChanged(true); } }); currentCorePosition.prefersReduceTransparency = reducedTransparencyMediaQuery.matches; // __reducedDataMediaQuery__ - real-time check on the `prefers-reduced-data` user preference, as set for the device or OS const reducedDataMediaQuery = window.matchMedia("(prefers-reduced-data: reduce)"); reducedDataMediaQuery.addEventListener(CHANGE, () => { const res = reducedDataMediaQuery.matches; if (currentCorePosition.prefersReduceData !== res) { currentCorePosition.prefersReduceData = res; setPrefersReduceDataChanged(true); } }); currentCorePosition.prefersReduceData = reducedDataMediaQuery.matches; // __invertedColorsMediaQuery__ - real-time check on the `inverted-colors` user preference, as set for the device or OS const invertedColorsMediaQuery = window.matchMedia("(inverted-colors: inverted)"); invertedColorsMediaQuery.addEventListener(CHANGE, () => { const res = invertedColorsMediaQuery.matches; if (currentCorePosition.prefersInvertedColors !== res) { currentCorePosition.prefersInvertedColors = res; setPrefersInvertedColorsChanged(true); } }); currentCorePosition.prefersInvertedColors = invertedColorsMediaQuery.matches; // __forcedColorsMediaQuery__ - real-time check on the `forced-colors` user preference, as set for the device or OS const forcedColorsMediaQuery = window.matchMedia("(forced-colors: active)"); forcedColorsMediaQuery.addEventListener(CHANGE, () => { const res = forcedColorsMediaQuery.matches; if (currentCorePosition.prefersForcedColors !== res) { currentCorePosition.prefersForcedColors = res; setPrefersForcedColorsChanged(true); } }); currentCorePosition.prefersForcedColors = forcedColorsMediaQuery.matches; // ### Watch for changes when user drags browser window between screens // __displaySupportsP3ColorMediaQuery__ - real-time check on whether the browser is being displayed on a device screen which supports wide gamut colors const displaySupportsP3ColorMediaQuery = window.matchMedia("(color-gamut: p3)"); displaySupportsP3ColorMediaQuery.addEventListener(CHANGE, () => { const res = displaySupportsP3ColorMediaQuery.matches; if (currentCorePosition.displaySupportsP3Color !== res) { currentCorePosition.displaySupportsP3Color = res; } }); currentCorePosition.displaySupportsP3Color = displaySupportsP3ColorMediaQuery.matches; // Also check to see if the canvas supports DisplayP3 - this is a one-time check as support is device/screen independent const checkCanvasSupportsDisplayP3 = () => { const c = document.createElement("canvas"); // Needs to be done in try-catch because (apparently) Safari throws a fit if colorSpace option is supported by the canvas engine but the minimum macOS/iOS system requirements for display-p3 support are not met try { const e = c.getContext("2d", { colorSpace: DISPLAY_P3 }); return e.getContextAttributes().colorSpace === DISPLAY_P3; } catch { console.log('checkCanvasSupportsDisplayP3 errored')} return false; }; currentCorePosition.canvasSupportsP3Color = checkCanvasSupportsDisplayP3(); // #### Monitoring the device pixel ratio // DPR is detected here, but mainly handled in the `factory/cell.js` file // + We scale the cell by DPR - this should be the only time we touch native scale functionality! // + All the other scaling functionality in SC is handled by computiation - applying the scaling factor to dimensions, start, handle, offset etc values which then get saved in the `current` equivalent attributes // // __getPixelRatio__ - exported to the API export const getPixelRatio = () => currentCorePosition.devicePixelRatio; // __getIgnorePixelRatio__, __setIgnorePixelRatio__ - exported to the API let ignorePixelRatio = false; export const getIgnorePixelRatio = () => ignorePixelRatio; export const setIgnorePixelRatio = (val) => ignorePixelRatio = val; // __setPixelRatioChangeAction__ - exported to the API let pixelRatioChangeAction = λnull; export const setPixelRatioChangeAction = (func) => pixelRatioChangeAction = func; const updatePixelRatio = () => { const dpr = window.devicePixelRatio; currentCorePosition.devicePixelRatio = dpr; _values(library.canvas).forEach(v => v.dirtyDimensions = true); _values(library.cell).forEach(v => v.dirtyDimensions = true); _values(library.entity).forEach(v => v.dirtyHost = true); if (!ignorePixelRatio) pixelRatioChangeAction(); // We use a one-time media query for checking when the device pixel ratio changes // + unlike the user preferences media queries, device pixel ratio can be a number of different values // + we check to see if dpr changes away from the current dpr value // + then we create a replacement one-time media query to check for changes away from the new value matchMedia(`(resolution: ${dpr}dppx)`).addEventListener(CHANGE, updatePixelRatio, { once: true }); }; updatePixelRatio(); // ### Watch for browser window resize, or device rotation, which trigger changes in the viewport dimensions // __resizeAction__ function - to check if a view resize has occurred; if yes, flag that currentCorePosition object needs to be updated const resizeAction = function () { const w = document.documentElement.clientWidth, h = document.documentElement.clientHeight; if (currentCorePosition.w !== w || currentCorePosition.h !== h) { currentCorePosition.w = w; currentCorePosition.h = h; setMouseChanged(true); setViewportChanged(true); } }; // ### Watch for scrolling interactions // __scrollAction__ function - to check if a view scroll has occurred; if yes, flag that currentCorePosition object needs to be updated const scrollAction = function () { const x = window.pageXOffset, y = window.pageYOffset; if (currentCorePosition.scrollX !== x || currentCorePosition.scrollY !== y) { currentCorePosition.x += (x - currentCorePosition.scrollX); currentCorePosition.y += (y - currentCorePosition.scrollY); currentCorePosition.scrollX = x; currentCorePosition.scrollY = y; setMouseChanged(true); } }; // ### Watch for mouse, pointer and touch movement // __moveAction__ function - to check if mouse cursor position has changed; if yes, update currentCorePosition object and flag that the updated needs to cascade to subscribed elements at the next RAF invocation. // The events that trigger this function (pointermove, pointerup, pointerdown, pointerleave, pointerenter; or mousemove, mouseup, mousedown, mouseleave, mouseenter) are tied to the window object, not to any particular DOM element. const moveAction = function (e) { const x = _round(e.pageX), y = _round(e.pageY); if (currentCorePosition.x !== x || currentCorePosition.y !== y) { currentCorePosition.type = (navigator.pointerEnabled) ? POINTER : MOUSE; currentCorePosition.x = x; currentCorePosition.y = y; setMouseChanged(true); } }; // __touchAction__ function - maps touch coordinates to the mouse cursor position (via currentCorePosition) and then immediately invokes a cascade update action to all subscribed elements. // The events that trigger this function (touchstart, touchmove, touchend, touchcancel) are tied to the window object, not to any particular DOM element. // Note: this is different to mouse moveAction, which is choked via an animation object so update checks happen on each requestAnimationFrame. // // TODO: Need to keep an eye on how many times touchAction gets run, for example during a touch-driven drag-and-drop action. If necessary, add a Date.now mediated choke to the check (say minimum 15ms between checks?) to minimize impact on the wider Scrawl-canvas ecosystem. let touchActionLastChecked = 0, touchActionChoke = 16; export const getTouchActionChoke = function () { return touchActionChoke; }; export const setTouchActionChoke = function (val) { if (_isFinite(val)) touchActionChoke = val; }; export const touchAction = function (e, resetCoordsToZeroOnTouchEnd = true) { currentCorePosition.rawTouches.length = 0; if (e.touches && e.touches.length) { currentCorePosition.rawTouches.push(...e.touches); const touch = e.touches[0], x = _round(touch.pageX), y = _round(touch.pageY); if (currentCorePosition.x !== x || currentCorePosition.y !== y) { currentCorePosition.type = TOUCH; currentCorePosition.x = x; currentCorePosition.y = y; } } else { currentCorePosition.type = TOUCH; if (resetCoordsToZeroOnTouchEnd) { currentCorePosition.x = 0; currentCorePosition.y = 0; } } const now = _now(); if (now > touchActionLastChecked + touchActionChoke) { touchActionLastChecked = now; updateUiSubscribedElements(); } }; // ## Cascade interaction results down to subscribed elements // Functions to update uiSubscribedElements attached to specified DOM elements. Each stack or canvas element tracked by Scrawl-canvas will include a local __here__ object which includes details of the element's current dimensions, relative position, and the position of the mouse cursor in relation to its top-left corner. These all need to be updated whenever there's a resize, scroll or cursor movement. const updateUiSubscribedElements = function () { for (let i = 0, iz = uiSubscribedElements.length; i < iz; i++) { updateUiSubscribedElement(uiSubscribedElements[i]); } }; const updateUiSubscribedElement = function (art) { const dom = library.artefact[art]; if (dom) { const { here, domElement:el } = dom; // Accessibility here.prefersContrast = currentCorePosition.prefersContrast; here.prefersReducedMotion = currentCorePosition.prefersReducedMotion; here.prefersDarkColorScheme = currentCorePosition.prefersDarkColorScheme; here.prefersReduceTransparency = currentCorePosition.prefersReduceTransparency; here.prefersReduceData = currentCorePosition.prefersReduceData; here.prefersInvertedColors = currentCorePosition.prefersInvertedColors; here.prefersForcedColors = currentCorePosition.prefersForcedColors; here.devicePixelRatio = currentCorePosition.devicePixelRatio; if (getPrefersContrastChanged()) dom.contrastActions(); if (getPrefersReducedMotionChanged()) dom.reducedMotionActions(); if (getPrefersDarkColorSchemeChanged()) dom.colorSchemeActions(); if (getPrefersReduceTransparencyChanged()) dom.reducedTransparencyActions(); if (getPrefersReduceDataChanged()) dom.reducedDataActions(); if (getPrefersInvertedColorsChanged()) dom.invertedColorsActions(); if (getPrefersForcedColorsChanged()) dom.forcedColorsActions(); // DOM-element-dependant values if (el) { const dims = el.getBoundingClientRect(), dox = _round(dims.left + window.pageXOffset), doy = _round(dims.top + window.pageYOffset), dot = dims.top, doh = dims.height, wih = window.innerHeight; here.w = _round(dims.width); here.h = _round(doh); here.type = currentCorePosition.type; // Position of the artefact in the browser/device viewport const ivpt = dot / wih, ivpb = (dot + doh) / wih, ivpc = (ivpt + ivpb) / 2; here.inViewportTop = ivpt; here.inViewportBase = ivpb; here.inViewportCenter = ivpc; // Default mouse tracking behaviour if (!dom.localMouseListener) { here.localListener = false; here.active = true; here.x = _round(currentCorePosition.x - dox); here.y = _round(currentCorePosition.y - doy); here.normX = (here.w) ? here.x / here.w : false; here.normY = (here.h) ? here.y / here.h : false; here.offsetX = dox; here.offsetY = doy; if (here.normX < 0 || here.normX > 1 || here.normY < 0 || here.normY > 1) here.active = false; } // DOM-based artefacts have the option of creating a local mouse move event listener // + The listener better tracks mouse movements across the artefact when its element has been rotated in three dimensions. // + The listener will update the local here.x and here.y values else { here.localListener = true; here.active = false; here.normX = (here.originalWidth) ? here.x / here.originalWidth : false; here.normY = (here.originalHeight) ? here.y / here.originalHeight : false; here.offsetX = dox; here.offsetY = doy; if (here.x > dom.activePadding && here.x < here.originalWidth - dom.activePadding && here.y > 0 + dom.activePadding && here.y < here.originalHeight - dom.activePadding) here.active = true; } const touches = currentCorePosition.rawTouches; if (!here.touches) here.touches = []; if (touches.length) { here.touches.length = 0; for (let i = 0, iz = touches.length; i < iz; i++) { const touch = touches[i]; here.touches.push([_round(touch.pageX - dox), _round(touch.pageY - doy)]); } } // Canvas `fit` attribute adjustments if (dom.type === T_CANVAS) dom.updateBaseHere(here, dom.fit); // Automatically check for element resize // + The artefact's `checkForResize` flag needs to be set // + We ignore resizing actions while dimensions-related dirty flags are set (to prevent getting ourselves into a continuous feedback loop) if (dom.checkForResize && !dom.dirtyDimensions && !dom.dirtyDomDimensions) { const [w, h] = dom.currentDimensions; if (dom.type === T_CANVAS) { // Regardless of the setting of &lt;canvas> element's `boxSizing` style attribute: // + It will include padding and borders in its `getBoundingClientRect` object (and its `getComputedStyle` width/height values), but these are specifically excluded from the element's `width` and `height` attributes // + Which leads to the normal resize test - `if (w !== here.w || h !== here.h)` - triggering on every mouse/scroll/resize event, which in turn leads to the canvas dimensions increasing uncontrollably. // + Solved by subtracting padding/border values from the `getBoundingClientRect` dimension values before performing the test. const s = dom.computedStyles, hw = _floor(here.w - parseFloat(s.borderLeftWidth) - parseFloat(s.borderRightWidth) - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight)), hh = _floor(here.h - parseFloat(s.borderTopWidth) - parseFloat(s.borderBottomWidth) - parseFloat(s.paddingTop) - parseFloat(s.paddingBottom)); if (w !== hw || h !== hh) { dom.set({ dimensions: [hw, hh], }); } } else { // Stack and Element artefacts resize test. // + Tested in Demo [DOM-011](../../demo/dom-011.html). if (w !== here.w || h !== here.h) { dom.set({ dimensions: [here.w, here.h], }); } } } } } }; const updateTextBasedEntitys = function () { _values(library.entity).forEach(ent => { if (FONT_USERS.includes(ent.type)) ent.recalculateFont(true); }); }; // Internal functions that get triggered when setting a DOM-based artefact's `trackHere` attribute. They add/remove an event listener to the artefact's domElement. export const addLocalMouseMoveListener = function (wrapper) { if (isa_obj(wrapper)) { if (wrapper.localMouseListener) wrapper.localMouseListener(); if (!wrapper.here) wrapper.here = {}; wrapper.here.originalWidth = wrapper.currentDimensions[0]; wrapper.here.originalHeight = wrapper.currentDimensions[1]; wrapper.localMouseListener = addListener(MOVE, function (e) { if (wrapper.here) { wrapper.here.x = _round(parseFloat(e.offsetX)); wrapper.here.y = _round(parseFloat(e.offsetY)); } }, wrapper.domElement); } }; export const removeLocalMouseMoveListener = function (wrapper) { if (isa_obj(wrapper)) { if (wrapper.localMouseListener) wrapper.localMouseListener(); wrapper.localMouseListener = false; } }; // Animation object which checks whether any window event listeners have fired, and actions accordingly const coreListenersTracker = makeAnimation({ name: 'SC-core-listeners-tracker', order: 0, delay: true, fn: function () { const trackMouse = getTrackMouse(); const mouseChanged = getMouseChanged(); const viewportChanged = getViewportChanged(); const prefersContrastChanged = getPrefersContrastChanged(); const prefersReducedMotionChanged = getPrefersReducedMotionChanged(); const prefersDarkColorSchemeChanged = getPrefersDarkColorSchemeChanged(); const prefersReduceTransparencyChanged = getPrefersReduceTransparencyChanged(); const prefersReduceDataChanged = getPrefersReduceDataChanged(); const prefersInvertedColorsChanged = getPrefersInvertedColorsChanged(); const prefersForcedColorsChanged = getPrefersForcedColorsChanged(); if ((trackMouse && mouseChanged) || prefersReducedMotionChanged || prefersContrastChanged || prefersDarkColorSchemeChanged || prefersReduceTransparencyChanged || prefersReduceDataChanged || prefersInvertedColorsChanged || prefersForcedColorsChanged) updateUiSubscribedElements(); if (trackMouse && mouseChanged) setMouseChanged(false); if (prefersContrastChanged) setPrefersContrastChanged(false); if (prefersReducedMotionChanged) setPrefersReducedMotionChanged(false); if (prefersDarkColorSchemeChanged) setPrefersDarkColorSchemeChanged(false); if (prefersReduceTransparencyChanged) setPrefersReduceTransparencyChanged(false); if (prefersReduceDataChanged) setPrefersReduceDataChanged(false); if (prefersInvertedColorsChanged) setPrefersInvertedColorsChanged(false); if (prefersForcedColorsChanged) setPrefersForcedColorsChanged(false); if (viewportChanged) { setViewportChanged(false); // This is to capture changes in the browser viewport size which can affect Label-related text using a font size measured relative to the viewport size updateTextBasedEntitys(); } }, }); // `Exported functions` (to modules and the scrawl object). Event listeners can be a drain on web page efficiency. If a web page contains only static canvas (and/or stack) displays, with no requirement for user interaction, we can minimize Scrawl-canvas's impact on those pages by switching off the core listeners (and also the core animation loop). export const startCoreListeners = function () { actionCoreListeners(REMOVE_EVENT_LISTENER); actionCoreListeners(ADD_EVENT_LISTENER); setTrackMouse(true); setMouseChanged(true); coreListenersTracker.run(); }; export const stopCoreListeners = function () { setTrackMouse(false); setMouseChanged(false); coreListenersTracker.halt(); actionCoreListeners(REMOVE_EVENT_LISTENER); }; // Helper function const actionCoreListeners = function (action) { if (navigator.pointerEnabled || navigator.msPointerEnabled) { window[action](POINTER_MOVE, moveAction, false); window[action](POINTER_UP, moveAction, false); window[action](POINTER_DOWN, moveAction, false); window[action](POINTER_LEAVE, moveAction, false); window[action](POINTER_ENTER, moveAction, false); } else { window[action](MOUSE_MOVE, moveAction, false); window[action](MOUSE_UP, moveAction, false); window[action](MOUSE_DOWN, moveAction, false); window[action](MOUSE_LEAVE, moveAction, false); window[action](MOUSE_ENTER, moveAction, false); window[action](TOUCH_MOVE, touchAction, {passive: true}); window[action](TOUCH_START, touchAction, {passive: true}); window[action](TOUCH_END, touchAction, {passive: true}); window[action](TOUCH_CANCEL, touchAction, {passive: true}); } window[action](SCROLL, scrollAction, {passive: true}); window[action](RESIZE, resizeAction, false); }; // `Exported functions` (to modules). Invoke the resize and/or scroll event listeners once, outside the regular requestAnimationFrame tick. export const applyCoreResizeListener = function () { resizeAction(); setMouseChanged(true); setViewportChanged(true); }; export const applyCoreScrollListener = function () { scrollAction(); setMouseChanged(true); }; // Putting this small library-related function here as this file gets all `core/library` objects rather than selectively importing some of them export const purgeFontMetadata = function () { const { fontfamilymetadata, fontfamilymetadatanames } = library; fontfamilymetadatanames.forEach(f => delete fontfamilymetadata[f]); fontfamilymetadatanames.length = 0; }; // Wide gamut colors helper export const getCanvasColorSpace = (useP3) => { const { canvasSupportsP3Color, displaySupportsP3Color } = currentCorePosition; if (useP3 && canvasSupportsP3Color && displaySupportsP3Color) return DISPLAY_P3; return SRGB; };