UNPKG

@caspingus/lt

Version:

A utility library of helpers and tools for working with Learnosity APIs.

513 lines (445 loc) 15.6 kB
import * as app from '../../../core/app'; import * as items from '../../../core/items'; import logger from '../../../../utils/logger'; /** * Extensions add specific functionality to Items API. * They rely on modules within LT being available. * * -- * * Allows the end-user to launch a magnifier to move around * the screen and zoom in on whatever content they move it * on top of. * * TODO: * - make movable via keyboard * * <p><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/images/magnifier.png" alt="" width="800"></p> * @module Extensions/Assessment/magnifier */ const LOG_LEVEL = 'ERROR'; const state = { _initialised: false, magnifier: null, }; /** * Initialises the screen magnifier. * @example * import { LT } from '@caspingus/lt/src/assessment/index'; * * LT.init(itemsApp); // Set up LT with the Items API application instance variable * LT.extensions.magnifier.run(); * * Options argument to override defaults which are: * { * zoom: 4, * shape: 'square', * width: 310, * height: 310, * } * @param {object} options Optional config object to override defaults * @since 0.7.0 */ export function run(options) { if (!state._initialised) { if (!options) { options = { zoom: 4, shape: 'square', width: 350, height: 350, }; } state.magnifier = new HTMLMagnifier(options); state._initialised = true; } else { logger.debug('Magnifier already initialised, ignoring run();', LOG_LEVEL); } } /** * Sets up listeners on custom buttons to toggle the magnifier. * @param {string} classname CSS class value of the element to launch the magnifier * @since 2.16.0 */ export function setupButtons(classname = 'lrn__magnifier') { const elButtons = document.querySelectorAll(`.${classname}`); elButtons.forEach(btn => { btn.addEventListener('click', () => { state.magnifier.toggle(); }); }); app.appInstance().on('item:load', checkImageContent()); } /** * Toggle visibility of the magnifier. * @since 2.16.0 */ export function toggle() { state.magnifier.toggle(); } function HTMLMagnifier(options) { const _this = this; _this.options = Object.assign( { zoom: 2, shape: 'square', width: 200, height: 200, }, options ); const magnifierTemplate = `<div class="magnifier" style="display: none;position: fixed;overflow: hidden;background-color: white;border: 1px solid #555;border-radius: 4px;z-index:10000;"> <div class="magnifier-content" style="top: 0px;left: 0px;margin-left: 0px;margin-top: 0px;overflow: visible;position: absolute;display: block;transform-origin: left top;-moz-transform-origin: left top;-ms-transform-origin: left top;-webkit-transform-origin: left top;-o-transform-origin: left top;user-select: none;-moz-user-select: none;-webkit-user-select: none;padding-top: 0px"></div> <div class="magnifier-glass" style="position: absolute;top: 0px;left: 0px;width: 100%;height: 100%;opacity: 0.0;-ms-filter: alpha(opacity=0);background-color: white;cursor: move;"></div> </div>`; let magnifier, magnifierContent; let observerObj; let syncTimeout; let isVisible = false; let magnifierBody; const events = {}; document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (_this.isVisible()) { _this.hide(); } } }); function setPosition(element, left, top) { element.style.left = `${left}px`; element.style.top = `${top}px`; } function setDimensions(element, width, height) { element.style.width = `${width}px`; element.style.height = `${height}px`; } function setupMagnifier() { switch (_this.options.shape) { case 'square': setDimensions(magnifier, _this.options.width, _this.options.height); break; case 'circle': setDimensions(magnifier, _this.options.width, _this.options.height); magnifier.style.borderRadius = '50%'; break; } magnifierContent.style.WebkitTransform = magnifierContent.style.MozTransform = magnifierContent.style.OTransform = magnifierContent.style.MsTransform = magnifierContent.style.transform = `scale(${_this.options.zoom})`; } function isDescendant(parent, child) { let node = child; while (node != null) { if (node == parent) { return true; } node = node.parentNode; } return false; } function syncContent() { if (isVisible) { prepareContent(); syncViewport(); syncScrollBars(); } } function syncContentQueued() { if (isVisible) { window.clearTimeout(syncTimeout); syncTimeout = window.setTimeout(syncContent, 100); } } function domChanged() { if (isVisible) { syncContentQueued(); } } function unBindDOMObserver() { if (observerObj) { observerObj.disconnect(); observerObj = null; } if (document.removeEventListener) { document.removeEventListener('DOMNodeInserted', domChanged, false); document.removeEventListener('DOMNodeRemoved', domChanged, false); } } function triggerEvent(event, data) { const handlers = events[event]; if (handlers) { for (let i = 0; i < handlers.length; i++) { handlers[i].call(_this, data); } } } function syncViewport() { const x1 = magnifier.offsetLeft; const y1 = magnifier.offsetTop; const x2 = document.body.scrollLeft; const y2 = document.body.scrollTop; const left = -x1 * _this.options.zoom - x2 * _this.options.zoom; const top = -y1 * _this.options.zoom - y2 * _this.options.zoom; setPosition(magnifierContent, left, top); triggerEvent('viewPortChanged', magnifierBody); } function removeSelectors(container, selector) { const elements = container.querySelectorAll(selector); if (elements.length > 0) { for (let i = 0; i < elements.length; i++) { elements[i].parentNode.removeChild(elements[i]); } } } function prepareContent() { magnifierContent.innerHTML = ''; const bodyOriginal = document.body; const bodyCopy = bodyOriginal.cloneNode(true); const color = bodyOriginal.style.backgroundColor; if (color) { magnifier.css('background-color', color); } bodyCopy.style.cursor = 'auto'; bodyCopy.style.paddingTop = '0px'; bodyCopy.setAttribute('unselectable', 'on'); const canvasOriginal = bodyOriginal.querySelectorAll('canvas'); const canvasCopy = bodyCopy.querySelectorAll('canvas'); if (canvasOriginal.length > 0) { if (canvasOriginal.length === canvasCopy.length) { for (let i = 0; i < canvasOriginal.length; i++) { const ctx = canvasCopy[i].getContext('2d'); ctx.drawImage(canvasOriginal[i], 0, 0); } } } removeSelectors(bodyCopy, 'script'); removeSelectors(bodyCopy, 'audio'); removeSelectors(bodyCopy, 'video'); removeSelectors(bodyCopy, '.magnifier'); triggerEvent('prepareContent', bodyCopy); magnifierContent.appendChild(bodyCopy); const width = document.body.clientWidth; const height = document.body.clientHeight; setDimensions(magnifierContent, width, height); magnifierBody = magnifierContent.querySelector('body'); triggerEvent('contentUpdated', magnifierBody); } function initScrollBars() { triggerEvent('initScrollBars', magnifierBody); } function syncScroll(ctrl) { const selectors = []; if (ctrl.getAttribute) { if (ctrl.getAttribute('id')) { selectors.push('#' + ctrl.getAttribute('id')); } if (ctrl.className) { selectors.push('.' + ctrl.className.split(' ').join('.')); } for (let i = 0; i < selectors.length; i++) { const t = magnifierBody.querySelectorAll(selectors[i]); if (t.length == 1) { t[0].scrollTop = ctrl.scrollTop; t[0].scrollLeft = ctrl.scrollLeft; return true; } } } else if (ctrl == document) { syncViewport(); } return false; } function syncScrollBars(e) { if (isVisible) { if (e && e.target) { syncScroll(e.target); } else { const scrolled = []; const elements = document.querySelectorAll('div'); for (let i = 0; i < elements.length; i++) { if (elements[i].scrollTop > 0) { scrolled.push(elements[i]); } } for (let i = 0; i < scrolled.length; i++) { if (!isDescendant(magnifier, scrolled[i])) { syncScroll(scrolled[i]); } } } triggerEvent('syncScrollBars', magnifierBody); } } function makeDraggable(ctrl, options) { const _this = this; let dragObject = null; let dragHandler = null; options = options || {}; options.exclude = ['INPUT', 'TEXTAREA', 'SELECT', 'A', 'BUTTON']; if (options.handler) { dragHandler = ctrl.querySelector(options.handler); } else { dragHandler = ctrl; } function setPosition(element, left, top) { element.style.left = `${left}px`; element.style.top = `${top}px`; } let pos_y, pos_x, ofs_x, ofs_y; ctrl.style.cursor = 'move'; function downHandler(e) { const target = e.target || e.srcElement; const parent = target.parentNode; if (target && options.exclude.indexOf(target.tagName.toUpperCase()) == -1) { if (!parent || options.exclude.indexOf(parent.tagName.toUpperCase()) == -1) { // img in a dragObject = ctrl; const pageX = e.pageX || e.touches[0].pageX; const pageY = e.pageY || e.touches[0].pageY; ofs_x = dragObject.getBoundingClientRect().left - dragObject.offsetLeft; ofs_y = dragObject.getBoundingClientRect().top - dragObject.offsetTop; pos_x = pageX - (dragObject.getBoundingClientRect().left + document.body.scrollLeft); pos_y = pageY - (dragObject.getBoundingClientRect().top + document.body.scrollTop); e.preventDefault(); } } } function moveHandler(e) { if (dragObject !== null) { const pageX = e.pageX || e.touches[0].pageX; const pageY = e.pageY || e.touches[0].pageY; const left = pageX - pos_x - ofs_x - document.body.scrollLeft; const top = pageY - pos_y - ofs_y - document.body.scrollTop; setPosition(dragObject, left, top); if (options.ondrag) { options.ondrag.call(e); } } } function upHandler() { if (dragObject !== null) { dragObject = null; } } const events = [ { target: dragHandler, types: ['mousedown', 'touchstart'], handler: downHandler }, { target: window, types: ['mousemove', 'touchmove'], handler: moveHandler }, { target: window, types: ['mouseup', 'touchend'], handler: upHandler }, ]; events.forEach(({ target, types, handler }) => { types.forEach(type => target.addEventListener(type, handler)); }); return _this; } function init() { const div = document.createElement('div'); div.innerHTML = magnifierTemplate; magnifier = div.querySelector('.magnifier'); document.body.appendChild(magnifier); magnifierContent = magnifier.querySelector('.magnifier-content'); if (window.addEventListener) { window.addEventListener('resize', syncContent, false); window.addEventListener('scroll', syncScrollBars, true); } makeDraggable(magnifier, { ondrag: syncViewport, }); } _this.setZoom = value => { _this.options.zoom = value; setupMagnifier(); }; _this.setShape = (shape, width, height) => { _this.options.shape = shape; if (width) { _this.options.width = width; } if (height) { _this.options.height = height; } setupMagnifier(); }; _this.setWidth = value => { _this.options.width = value; setupMagnifier(); }; _this.setHeight = value => { _this.options.height = value; setupMagnifier(); }; _this.getZoom = () => { return _this.options.zoom; }; _this.getShape = () => { return _this.options.shape; }; _this.getWidth = () => { return _this.options.width; }; _this.getHeight = () => { return _this.options.height; }; _this.isVisible = () => { return isVisible; }; _this.on = (event, callback) => { events[event] = events[event] || []; events[event].push(callback); }; _this.syncScrollBars = () => { syncScrollBars(); }; _this.syncContent = () => { syncContentQueued(); }; _this.hide = () => { unBindDOMObserver(); magnifierContent.innerHTML = ''; magnifier.style.display = 'none'; isVisible = false; }; _this.show = event => { let left, top; if (event) { left = event.pageX - 175; top = event.pageY - 175; } else { left = 200; top = 200; } setupMagnifier(); prepareContent(); setPosition(magnifier, left, top); magnifier.style.display = ''; syncViewport(); syncScrollBars(); initScrollBars(); // bindDOMObserver(); isVisible = true; }; _this.toggle = () => { if (_this.isVisible()) { _this.hide(); } else { _this.show(); } }; init(); return _this; } function checkImageContent() { const elItem = items.itemElement(); const elImages = elItem.querySelectorAll('img'); if (elImages) { elImages.forEach(img => { img.addEventListener('click', e => { if (!state.magnifier.isVisible()) { state.magnifier.show(e); } }); }); } }