From 840f5dcb12d939f5636151952eacd8324ab0a6e2 Mon Sep 17 00:00:00 2001 From: Frode Petterson Date: Fri, 1 Mar 2019 15:24:46 +0100 Subject: [PATCH] HFP-2541 Fix UX improvements when reusing content (Moved Toast from Editor to Core) --- h5p.classes.php | 1 + js/h5p.js | 220 +++++++++++++++++++++++++++++++++++++++++++++++- styles/h5p.css | 21 +++++ 3 files changed, 241 insertions(+), 1 deletion(-) diff --git a/h5p.classes.php b/h5p.classes.php index 2292471..c628fbc 100644 --- a/h5p.classes.php +++ b/h5p.classes.php @@ -3354,6 +3354,7 @@ class H5PCore { 'contentType' => $this->h5pF->t('Content Type'), 'licenseExtras' => $this->h5pF->t('License Extras'), 'changes' => $this->h5pF->t('Changelog'), + 'contentCopied' => $this->h5pF->t('Content is copied to the clipboard'), ); } } diff --git a/js/h5p.js b/js/h5p.js index cc3a55b..6ba349f 100644 --- a/js/h5p.js +++ b/js/h5p.js @@ -1161,18 +1161,31 @@ H5P.openReuseDialog = function ($element, contentData, library, instance, conten // Selecting embed code when dialog is opened H5P.jQuery(dialog).on('dialog-opened', function (e, $dialog) { - H5P.jQuery('More Info').click(function (e) { + H5P.jQuery('More Info').click(function (e) { e.stopPropagation(); }).appendTo($dialog.find('h2')); $dialog.find('.h5p-download-button').click(function () { window.location.href = contentData.exportUrl; instance.triggerXAPI('downloaded'); + dialog.close(); }); $dialog.find('.h5p-copy-button').click(function () { const item = new H5P.ClipboardItem(library); item.contentId = contentId; H5P.setClipboard(item); instance.triggerXAPI('copied'); + dialog.close(); + H5P.attachToastTo( + H5P.jQuery('.h5p-content:first')[0], + H5P.t('contentCopied'), + { + position: { + horizontal: 'centered', + vertical: 'centered', + noOverflowX: true + } + } + ); }); H5P.trigger(instance, 'resize'); }).on('dialog-closed', function () { @@ -1265,6 +1278,211 @@ H5P.openEmbedDialog = function ($element, embedCode, resizeCode, size, instance) dialog.open(); }; +/** + * Show a toast message. + * + * The reference element could be dom elements the toast should be attached to, + * or e.g. the document body for general toast messages. + * + * @param {DOM} element Reference element to show toast message for. + * @param {string} message Message to show. + * @param {object} [config] Configuration. + * @param {string} [config.style=h5p-toast] Style name for the tooltip. + * @param {number} [config.duration=3000] Toast message length in ms. + * @param {object} [config.position] Relative positioning of the toast. + * @param {string} [config.position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [config.position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [config.position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [config.position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [config.position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [config.position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [config.position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [config.position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [config.position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [config.position.noOverflowY=false] True to prevent overflow top and bottom. + * @param {object} [config.position.overflowReference=document.body] DOM reference for overflow. + */ +H5P.attachToastTo = function (element, message, config) { + if (element === undefined || message === undefined) { + return; + } + + const eventPath = function (evt) { + var path = (evt.composedPath && evt.composedPath()) || evt.path; + var target = evt.target; + + if (path != null) { + // Safari doesn't include Window, but it should. + return (path.indexOf(window) < 0) ? path.concat(window) : path; + } + + if (target === window) { + return [window]; + } + + function getParents(node, memo) { + memo = memo || []; + var parentNode = node.parentNode; + + if (!parentNode) { + return memo; + } + else { + return getParents(parentNode, memo.concat(parentNode)); + } + } + + return [target].concat(getParents(target), window); + }; + + /** + * Handle click while toast is showing. + */ + const clickHandler = function (event) { + /* + * A common use case will be to attach toasts to buttons that are clicked. + * The click would remove the toast message instantly without this check. + * Children of the clicked element are also ignored. + */ + var path = eventPath(event); + if (path.indexOf(element) !== -1) { + return; + } + clearTimeout(timer); + removeToast(); + }; + + + + /** + * Remove the toast message. + */ + const removeToast = function () { + document.removeEventListener('click', clickHandler); + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }; + + /** + * Get absolute coordinates for the toast. + * + * @param {DOM} element Reference element to show toast message for. + * @param {DOM} toast Toast element. + * @param {object} [position={}] Relative positioning of the toast message. + * @param {string} [position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [position.noOverflowY=false] True to prevent overflow top and bottom. + * @return {object} + */ + const getToastCoordinates = function (element, toast, position) { + position = position || {}; + position.offsetHorizontal = position.offsetHorizontal || 0; + position.offsetVertical = position.offsetVertical || 0; + + const toastRect = toast.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + let left = 0; + let top = 0; + + // Compute horizontal position + switch (position.horizontal) { + case 'before': + left = elementRect.left - toastRect.width - position.offsetHorizontal; + break; + case 'after': + left = elementRect.left + elementRect.width + position.offsetHorizontal; + break; + case 'left': + left = elementRect.left + position.offsetHorizontal; + break; + case 'right': + left = elementRect.left + elementRect.width - toastRect.width - position.offsetHorizontal; + break; + case 'centered': + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + break; + default: + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + } + + // Compute vertical position + switch (position.vertical) { + case 'above': + top = elementRect.top - toastRect.height - position.offsetVertical; + break; + case 'below': + top = elementRect.top + elementRect.height + position.offsetVertical; + break; + case 'top': + top = elementRect.top + position.offsetVertical; + break; + case 'bottom': + top = elementRect.top + elementRect.height - toastRect.height - position.offsetVertical; + break; + case 'centered': + top = elementRect.top + elementRect.height / 2 - toastRect.height / 2 + position.offsetVertical; + break; + default: + top = elementRect.top + elementRect.height + position.offsetVertical; + } + + // Prevent overflow + const overflowElement = document.body; + const bounds = overflowElement.getBoundingClientRect(); + if ((position.noOverflowLeft || position.noOverflowX) && (left < bounds.x)) { + left = bounds.x; + } + if ((position.noOverflowRight || position.noOverflowX) && ((left + toastRect.width) > (bounds.x + bounds.width))) { + left = bounds.x + bounds.width - toastRect.width; + } + if ((position.noOverflowTop || position.noOverflowY) && (top < bounds.y)) { + top = bounds.y; + } + if ((position.noOverflowBottom || position.noOverflowY) && ((top + toastRect.height) > (bounds.y + bounds.height))) { + left = bounds.y + bounds.height - toastRect.height; + } + + return {left: left, top: top}; + }; + + // Sanitization + config = config || {}; + config.style = config.style || 'h5p-toast'; + config.duration = config.duration || 3000; + + // Build toast + const toast = document.createElement('div'); + toast.setAttribute('id', config.style); + toast.classList.add('h5p-toast-disabled'); + toast.classList.add(config.style); + + const msg = document.createElement('span'); + msg.innerHTML = message; + toast.appendChild(msg); + + document.body.appendChild(toast); + + // The message has to be set before getting the coordinates + const coordinates = getToastCoordinates(element, toast, config.position); + toast.style.left = Math.round(coordinates.left) + 'px'; + toast.style.top = Math.round(coordinates.top) + 'px'; + + toast.classList.remove('h5p-toast-disabled'); + const timer = setTimeout(removeToast, config.duration); + + // The toast can also be removed by clicking somewhere + document.addEventListener('click', clickHandler); +}; + /** * Copyrights for a H5P Content Library. * diff --git a/styles/h5p.css b/styles/h5p.css index ef0d054..11d15d0 100644 --- a/styles/h5p.css +++ b/styles/h5p.css @@ -522,6 +522,27 @@ div.h5p-fullscreen { left: 50%; transform: translateX(-50%); } +.h5p-toast { + font-size: 0.75em; + background-color: rgba(0, 0, 0, 0.9); + color: #fff; + z-index: 110; + position: absolute; + padding: 0 0.5em; + line-height: 2; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + top: 0; + opacity: 1; + visibility: visible; + transition: opacity 1s; +} +.h5p-toast-disabled { + opacity: 0; + visibility: hidden; +} + /* This is loaded as part of Core and not Editor since this needs to be outside the editor iframe */ .h5peditor-semi-fullscreen {