diff --git a/resources/assets/js/components/markdown-editor.js b/resources/assets/js/components/markdown-editor.js index 5fa16ef6e..b0e4d693a 100644 --- a/resources/assets/js/components/markdown-editor.js +++ b/resources/assets/js/components/markdown-editor.js @@ -1,6 +1,7 @@ import MarkdownIt from "markdown-it"; import mdTasksLists from 'markdown-it-task-lists'; import code from '../services/code'; +import {debounce} from "../services/util"; import DrawIO from "../services/drawio"; @@ -104,14 +105,11 @@ class MarkdownEditor { } onMarkdownScroll(lineCount) { - let elems = this.display.children; + const elems = this.display.children; if (elems.length <= lineCount) return; - let topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount]; - // TODO - Replace jQuery - $(this.display).animate({ - scrollTop: topElem.offsetTop - }, {queue: false, duration: 200, easing: 'linear'}); + const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount]; + topElem.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth'}); } codeMirrorSetup() { @@ -160,8 +158,7 @@ class MarkdownEditor { this.updateAndRender(); }); - // Handle scroll to sync display view - cm.on('scroll', instance => { + const onScrollDebounced = debounce((instance) => { // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html let scroll = instance.getScrollInfo(); let atEnd = scroll.top + scroll.clientHeight === scroll.height; @@ -176,6 +173,11 @@ class MarkdownEditor { let doc = parser.parseFromString(this.markdown.render(range), 'text/html'); let totalLines = doc.documentElement.querySelectorAll('body > *'); this.onMarkdownScroll(totalLines.length); + }, 100); + + // Handle scroll to sync display view + cm.on('scroll', instance => { + onScrollDebounced(instance); }); // Handle image paste diff --git a/resources/assets/js/components/page-display.js b/resources/assets/js/components/page-display.js index 513a07b8d..a3879d006 100644 --- a/resources/assets/js/components/page-display.js +++ b/resources/assets/js/components/page-display.js @@ -1,5 +1,6 @@ import Clipboard from "clipboard/dist/clipboard.min"; import Code from "../services/code"; +import * as DOM from "../services/dom"; class PageDisplay { @@ -9,7 +10,6 @@ class PageDisplay { Code.highlight(); this.setupPointer(); - this.setupStickySidebar(); this.setupNavHighlighting(); // Check the hash on load @@ -19,167 +19,130 @@ class PageDisplay { } // Sidebar page nav click event - $('.sidebar-page-nav').on('click', 'a', event => { + const sidebarPageNav = document.querySelector('.sidebar-page-nav'); + DOM.onChildEvent(sidebarPageNav, 'a', 'click', (event, child) => { window.components['tri-layout'][0].showContent(); - this.goToText(event.target.getAttribute('href').substr(1)); + this.goToText(child.getAttribute('href').substr(1)); }); } goToText(text) { - let idElem = document.getElementById(text); - $('.page-content [data-highlighted]').attr('data-highlighted', '').css('background-color', ''); + const idElem = document.getElementById(text); + + DOM.forEach('.page-content [data-highlighted]', elem => { + elem.removeAttribute('data-highlighted'); + elem.style.backgroundColor = null; + }); + if (idElem !== null) { window.scrollAndHighlight(idElem); } else { - $('.page-content').find(':contains("' + text + '")').smoothScrollTo(); + const textElem = DOM.findText('.page-content > div > *', text); + if (textElem) { + window.scrollAndHighlight(textElem); + } } } setupPointer() { - if (document.getElementById('pointer') === null) return; + let pointer = document.getElementById('pointer'); + if (!pointer) { + return; + } + // Set up pointer - let $pointer = $('#pointer').detach(); + pointer = pointer.parentNode.removeChild(pointer); + const pointerInner = pointer.querySelector('div.pointer'); + + // Instance variables let pointerShowing = false; - let $pointerInner = $pointer.children('div.pointer').first(); let isSelection = false; let pointerModeLink = true; let pointerSectionId = ''; // Select all contents on input click - $pointer.on('click', 'input', event => { - $(this).select(); + DOM.onChildEvent(pointer, 'input', 'click', (event, input) => { + input.select(); event.stopPropagation(); }); - $pointer.on('click focus', event => { + // Prevent closing pointer when clicked or focused + DOM.onEvents(pointer, ['click', 'focus'], event => { event.stopPropagation(); }); // Pointer mode toggle - $pointer.on('click', 'span.icon', event => { + DOM.onChildEvent(pointer, 'span.icon', 'click', (event, icon) => { event.stopPropagation(); - let $icon = $(event.currentTarget); pointerModeLink = !pointerModeLink; - $icon.find('[data-icon="include"]').toggle(!pointerModeLink); - $icon.find('[data-icon="link"]').toggle(pointerModeLink); + icon.querySelector('[data-icon="include"]').style.display = (!pointerModeLink) ? 'inline' : 'none'; + icon.querySelector('[data-icon="link"]').style.display = (pointerModeLink) ? 'inline' : 'none'; updatePointerContent(); }); // Set up clipboard - let clipboard = new Clipboard($pointer[0].querySelector('button')); + new Clipboard(pointer.querySelector('button')); // Hide pointer when clicking away - $(document.body).find('*').on('click focus', event => { + DOM.onEvents(document.body, ['click', 'focus'], event => { if (!pointerShowing || isSelection) return; - $pointer.detach(); + pointer = pointer.parentElement.removeChild(pointer); pointerShowing = false; }); - let updatePointerContent = ($elem) => { + let updatePointerContent = (element) => { let inputText = pointerModeLink ? window.baseUrl(`/link/${this.pageId}#${pointerSectionId}`) : `{{@${this.pageId}#${pointerSectionId}}}`; - if (pointerModeLink && inputText.indexOf('http') !== 0) inputText = window.location.protocol + "//" + window.location.host + inputText; + if (pointerModeLink && !inputText.startsWith('http')) { + inputText = window.location.protocol + "//" + window.location.host + inputText; + } - $pointer.find('input').val(inputText); + pointer.querySelector('input').value = inputText; - // update anchor if present - const $editAnchor = $pointer.find('#pointer-edit'); - if ($editAnchor.length !== 0 && $elem) { - const editHref = $editAnchor.data('editHref'); - const element = $elem[0]; + // Update anchor if present + const editAnchor = pointer.querySelector('#pointer-edit'); + if (editAnchor && element) { + const editHref = editAnchor.dataset.editHref; const elementId = element.id; // get the first 50 characters. - let queryContent = element.textContent && element.textContent.substring(0, 50); - $editAnchor[0].href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`; + const queryContent = element.textContent && element.textContent.substring(0, 50); + editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`; } }; // Show pointer when selecting a single block of tagged content - $('.page-content [id^="bkmrk"]').on('mouseup keyup', function (e) { - e.stopPropagation(); - let selection = window.getSelection(); - if (selection.toString().length === 0) return; + DOM.forEach('.page-content [id^="bkmrk"]', bookMarkElem => { + DOM.onEvents(bookMarkElem, ['mouseup', 'keyup'], event => { + event.stopPropagation(); + let selection = window.getSelection(); + if (selection.toString().length === 0) return; - // Show pointer and set link - let $elem = $(this); - pointerSectionId = $elem.attr('id'); - updatePointerContent($elem); + // Show pointer and set link + pointerSectionId = bookMarkElem.id; + updatePointerContent(bookMarkElem); - $elem.before($pointer); - $pointer.show(); - pointerShowing = true; + bookMarkElem.parentNode.insertBefore(pointer, bookMarkElem); + pointer.style.display = 'block'; + pointerShowing = true; + isSelection = true; - // Set pointer to sit near mouse-up position - let pointerLeftOffset = (e.pageX - $elem.offset().left - ($pointerInner.width() / 2)); - if (pointerLeftOffset < 0) pointerLeftOffset = 0; - let pointerLeftOffsetPercent = (pointerLeftOffset / $elem.width()) * 100; - $pointerInner.css('left', pointerLeftOffsetPercent + '%'); + // Set pointer to sit near mouse-up position + requestAnimationFrame(() => { + const bookMarkBounds = bookMarkElem.getBoundingClientRect(); + let pointerLeftOffset = (event.pageX - bookMarkBounds.left - 164); + if (pointerLeftOffset < 0) { + pointerLeftOffset = 0 + } + const pointerLeftOffsetPercent = (pointerLeftOffset / bookMarkBounds.width) * 100; - isSelection = true; - setTimeout(() => { - isSelection = false; - }, 100); - }); - } + pointerInner.style.left = pointerLeftOffsetPercent + '%'; - setupStickySidebar() { - // Make the sidebar stick in view on scroll - const $window = $(window); - const $sidebar = $("#sidebar .scroll-body"); - const $sidebarContainer = $sidebar.parent(); - const sidebarHeight = $sidebar.height() + 32; + setTimeout(() => { + isSelection = false; + }, 100); + }); - // Check the page is scrollable and the content is taller than the tree - const pageScrollable = ($(document).height() > ($window.height() + 40)) && (sidebarHeight < $('.page-content').height()); - - // Get current tree's width and header height - const headerHeight = $("#header").height() + $(".toolbar").height(); - let isFixed = $window.scrollTop() > headerHeight; - - // Fix the tree as a sidebar - function stickTree() { - $sidebar.width($sidebarContainer.width() + 15); - $sidebar.addClass("fixed"); - isFixed = true; - } - - // Un-fix the tree back into position - function unstickTree() { - $sidebar.css('width', 'auto'); - $sidebar.removeClass("fixed"); - isFixed = false; - } - - // Checks if the tree stickiness state should change - function checkTreeStickiness(skipCheck) { - let shouldBeFixed = $window.scrollTop() > headerHeight; - if (shouldBeFixed && (!isFixed || skipCheck)) { - stickTree(); - } else if (!shouldBeFixed && (isFixed || skipCheck)) { - unstickTree(); - } - } - // The event ran when the window scrolls - function windowScrollEvent() { - checkTreeStickiness(false); - } - - // If the page is scrollable and the window is wide enough listen to scroll events - // and evaluate tree stickiness. - if (pageScrollable && $window.width() > 1000) { - $window.on('scroll', windowScrollEvent); - checkTreeStickiness(true); - } - - // Handle window resizing and switch between desktop/mobile views - $window.on('resize', event => { - if (pageScrollable && $window.width() > 1000) { - $window.on('scroll', windowScrollEvent); - checkTreeStickiness(true); - } else { - $window.off('scroll', windowScrollEvent); - unstickTree(); - } + }); }); } @@ -222,10 +185,9 @@ class PageDisplay { } function toggleAnchorHighlighting(elementId, shouldHighlight) { - const anchorsToHighlight = pageNav.querySelectorAll('a[href="#' + elementId + '"]'); - for (let anchor of anchorsToHighlight) { + DOM.forEach('a[href="#' + elementId + '"]', anchor => { anchor.closest('li').classList.toggle('current-heading', shouldHighlight); - } + }); } } } diff --git a/resources/assets/js/services/dom.js b/resources/assets/js/services/dom.js new file mode 100644 index 000000000..797effd98 --- /dev/null +++ b/resources/assets/js/services/dom.js @@ -0,0 +1,59 @@ +/** + * Run the given callback against each element that matches the given selector. + * @param {String} selector + * @param {Function} callback + */ +export function forEach(selector, callback) { + const elements = document.querySelectorAll(selector); + for (let element of elements) { + callback(element); + } +} + +/** + * Helper to listen to multiple DOM events + * @param {Element} listenerElement + * @param {Array} events + * @param {Function} callback + */ +export function onEvents(listenerElement, events, callback) { + for (let eventName of events) { + listenerElement.addEventListener(eventName, callback); + } +} + +/** + * Set a listener on an element for an event emitted by a child + * matching the given childSelector param. + * Used in a similar fashion to jQuery's $('listener').on('eventName', 'childSelector', callback) + * @param {Element} listenerElement + * @param {String} childSelector + * @param {String} eventName + * @param {Function} callback + */ +export function onChildEvent(listenerElement, childSelector, eventName, callback) { + listenerElement.addEventListener(eventName, function(event) { + const matchingChild = event.target.closest(childSelector); + if (matchingChild) { + callback.call(matchingChild, event, matchingChild); + } + }); +} + +/** + * Look for elements that match the given selector and contain the given text. + * Is case insensitive and returns the first result or null if nothing is found. + * @param {String} selector + * @param {String} text + * @returns {Element} + */ +export function findText(selector, text) { + const elements = document.querySelectorAll(selector); + text = text.toLowerCase(); + for (let element of elements) { + if (element.textContent.toLowerCase().includes(text)) { + return element; + } + } + return null; +} \ No newline at end of file diff --git a/resources/assets/js/services/util.js b/resources/assets/js/services/util.js new file mode 100644 index 000000000..727c723c6 --- /dev/null +++ b/resources/assets/js/services/util.js @@ -0,0 +1,27 @@ + + +/** + * Returns a function, that, as long as it continues to be invoked, will not + * be triggered. The function will be called after it stops being called for + * N milliseconds. If `immediate` is passed, trigger the function on the + * leading edge, instead of the trailing. + * @attribution https://davidwalsh.name/javascript-debounce-function + * @param func + * @param wait + * @param immediate + * @returns {Function} + */ +export function debounce(func, wait, immediate) { + let timeout; + return function() { + const context = this, args = arguments; + const later = function() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + const callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; +}; \ No newline at end of file