diff --git a/package-lock.json b/package-lock.json index 52401e1ea..892286cbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,7 @@ "opencollective-postinstall": "^2.0.2", "prismjs": "^1.29.0", "strip-indent": "^3.0.0", - "tinydate": "^1.3.0", - "tweezer.js": "^1.4.0" + "tinydate": "^1.3.0" }, "devDependencies": { "@babel/core": "^7.11.6", @@ -14739,11 +14738,6 @@ "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, - "node_modules/tweezer.js": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/tweezer.js/-/tweezer.js-1.5.0.tgz", - "integrity": "sha512-aSiJz7rGWNAQq7hjMK9ZYDuEawXupcCWgl3woQQSoDP2Oh8O4srWb/uO1PzzHIsrPEOqrjJ2sUb9FERfzuBabQ==" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 54efbff52..a55f4325a 100644 --- a/package.json +++ b/package.json @@ -28,19 +28,18 @@ "build:css": "mkdirp lib/themes && node build/css -o lib/themes", "build:emoji": "node ./build/emoji.js", "build:js": "cross-env NODE_ENV=production node build/build.js", - "build:test": "npm run build && npm test", - "build": "rimraf lib themes && run-s build:js build:css build:css:min build:cover build:emoji", + "build": "run-s clean build:js build:css build:css:min build:cover build:emoji", + "clean": "rimraf lib themes _playwright*", "dev": "run-p serve:dev watch:*", "docker:build:test": "npm run docker:cli -- build:test", "docker:build": "docker build -f Dockerfile -t docsify-test:local .", "docker:clean": "docker rmi docsify-test:local", "docker:cli": "docker run --rm -it --ipc=host --mount type=bind,source=$(pwd)/test,target=/app/test docsify-test:local", - "docker:rebuild": "npm run docker:clean && npm run docker:build", + "docker:rebuild": "run-s docker:clean docker:build", "docker:test:e2e": "npm run docker:cli -- test:e2e", "docker:test:integration": "npm run docker:cli -- test:integration", "docker:test:unit": "npm run docker:cli -- test:unit", "docker:test": "npm run docker:cli -- test", - "jest": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", "lint:fix": "eslint . --fix", "lint": "prettier . --check && eslint .", "postinstall": "opencollective-postinstall && npx husky install", @@ -51,9 +50,12 @@ "serve:dev": "npm run serve -- --dev", "serve": "node server", "test:e2e": "playwright test", - "test:integration": "npm run jest -- --selectProjects integration", - "test:unit": "npm run jest -- --selectProjects unit", - "test": "npm run jest && run-s test:e2e", + "test:e2e:chromium": "playwright test --project='chromium'", + "test:e2e:ui": "playwright test --ui", + "test:integration": "npm run test:jest -- --selectProjects integration", + "test:jest": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", + "test:unit": "npm run test:jest -- --selectProjects unit", + "test": "run-s test:jest test:e2e", "watch:css": "npm run build:css -- -w", "watch:js": "node build/build.js" }, @@ -66,8 +68,7 @@ "opencollective-postinstall": "^2.0.2", "prismjs": "^1.29.0", "strip-indent": "^3.0.0", - "tinydate": "^1.3.0", - "tweezer.js": "^1.4.0" + "tinydate": "^1.3.0" }, "devDependencies": { "@babel/core": "^7.11.6", diff --git a/src/core/event/index.js b/src/core/event/index.js index aa6ddae7e..a01d750f3 100644 --- a/src/core/event/index.js +++ b/src/core/event/index.js @@ -1,9 +1,5 @@ -import Tweezer from 'tweezer.js'; import { isMobile } from '../util/env.js'; -import { body, on } from '../util/dom.js'; import * as dom from '../util/dom.js'; -import { removeParams } from '../router/util.js'; -import config from '../config.js'; /** @typedef {import('../Docsify.js').Constructor} Constructor */ @@ -13,50 +9,128 @@ import config from '../config.js'; */ export function Events(Base) { return class Events extends Base { - $resetEvents(source) { - const { auto2top, loadNavbar } = this.config; - const { path, query } = this.route; + #intersectionObserver; + #isScrolling; + #title = dom.$.title; - // Note: Scroll position set by browser on forward/back (i.e. "history") - if (source !== 'history') { - // Scroll to ID if specified - if (query.id) { - this.#scrollIntoView(path, query.id, true); - } - // Scroll to top if a link was clicked and auto2top is enabled - else if (source === 'navigate') { - auto2top && this.#scroll2Top(auto2top); - } - } + // Initialization + // ========================================================================= + /** + * Initialize Docsify events + * One-time setup of listeners, observers, and tasks. + * @void + */ + initEvent() { + const { topMargin } = this.config; - // Move focus to content - if (query.id || source === 'navigate') { - this.#focusContent(); + // Apply topMargin to scrolled content + if (topMargin) { + document.documentElement.style.setProperty( + 'scroll-padding-top', + `${topMargin}px` + ); } - if (loadNavbar) { - this.__getAndActive(this.router, 'nav'); + this.#initCover(); + this.#initSkipToContent('#skip-to-content'); + this.#initSidebarCollapse('.sidebar'); + this.#initSidebarToggle('button.sidebar-toggle'); + this.#initKeyBindings(); + } + + // Sub-Initializations + // ========================================================================= + /** + * Initialize cover observer + * Toggles sticky behavior when when cover is not in view + * @void + */ + #initCover() { + const coverElm = dom.find('section.cover'); + + if (!coverElm) { + dom.toggleClass(dom.body, 'add', 'sticky'); + return; } + + const observer = new IntersectionObserver(entries => { + const isIntersecting = entries[0].isIntersecting; + const op = isIntersecting ? 'remove' : 'add'; + + dom.toggleClass(dom.body, op, 'sticky'); + }); + + observer.observe(coverElm); } - initEvent() { - const { coverpage, keyBindings } = this.config; - const modifierKeys = ['alt', 'ctrl', 'meta', 'shift']; + /** + * Initialize heading observer + * Toggles sidebar active item based on top viewport edge intersection + * @void + */ + #initHeadings() { + const headingElms = dom.findAll('#main :where(h1, h2, h3, h4, h5)'); + const headingsInView = new Set(); + + // Mark sidebar active item on heading intersection + this.#intersectionObserver?.disconnect(); + this.#intersectionObserver = new IntersectionObserver( + entries => { + if (this.#isScrolling) { + return; + } - // Bind skip link - this.#skipLink('#skip-to-content'); + for (const entry of entries) { + const op = entry.isIntersecting ? 'add' : 'delete'; - // Bind toggle button - this.#btn('button.sidebar-toggle', this.router); - this.#collapse('.sidebar', this.router); + headingsInView[op](entry.target); + } + + const activeHeading = + headingsInView.size > 1 + ? // Sort headings by proximity to viewport top and select first + Array.from(headingsInView).sort((a, b) => + a.compareDocumentPosition(b) & + Node.DOCUMENT_POSITION_FOLLOWING + ? -1 + : 1 + )[0] + : // Get first and only item in set. + // May be undefined if no headings are in view. + headingsInView.values().next().value; + + if (activeHeading) { + const id = activeHeading.getAttribute('id'); + const href = this.router.toURL(this.router.getCurrentPath(), { + id, + }); + const newSidebarActiveElm = this.#markSidebarActiveElm(href); + + newSidebarActiveElm?.scrollIntoView({ + behavior: 'instant', + block: 'nearest', + inline: 'nearest', + }); + } + }, + { + rootMargin: '0% 0% -50% 0%', // Top half of viewport + } + ); + + headingElms.forEach(elm => { + this.#intersectionObserver.observe(elm); + }); + } + + /** + * Initialize keyboard bindings + * @void + */ + #initKeyBindings() { + const { keyBindings } = this.config; + const modifierKeys = ['alt', 'ctrl', 'meta', 'shift']; - // Bind sticky effect - if (coverpage) { - !isMobile && on('scroll', this.__sticky); - } else { - body.classList.add('sticky'); - } - // Bind keyboard shortcuts if (keyBindings && keyBindings.constructor === Object) { // Prepare key binding configurations Object.values(keyBindings || []).forEach(bindingConfig => { @@ -96,7 +170,7 @@ export function Events(Base) { }); // Handle keyboard events - on('keydown', e => { + dom.on('keydown', e => { const isTextEntry = document.activeElement.matches( 'input, select, textarea' ); @@ -131,315 +205,318 @@ export function Events(Base) { } } - /** @readonly */ - #nav = {}; - - #hoverOver = false; - #scroller = null; - #enableScrollEvent = true; - #coverHeight = 0; - - #skipLink(elm) { - elm = typeof elm === 'string' ? dom.find(elm) : elm; - - elm?.addEventListener('click', evt => { - evt.preventDefault(); - dom.getNode('main')?.scrollIntoView({ - behavior: 'smooth', - }); - this.#focusContent({ preventScroll: true }); - }); - } + /** + * Initialize sidebar content expand/collapse toggle behavior + * + * @param {Element|string} elm Sidebar Element or CSS selector + * @void + */ + #initSidebarCollapse(elm) { + elm = typeof elm === 'string' ? document.querySelector(elm) : elm; - #scrollTo(el, offset = 0) { - if (this.#scroller) { - this.#scroller.stop(); + if (!elm) { + return; } - this.#enableScrollEvent = false; - this.#scroller = new Tweezer({ - start: window.pageYOffset, - end: - Math.round(el.getBoundingClientRect().top) + - window.pageYOffset - - offset, - duration: 500, - }) - .on('tick', v => window.scrollTo(0, v)) - .on('done', () => { - this.#enableScrollEvent = true; - this.#scroller = null; - }) - .begin(); + dom.on(elm, 'click', ({ target }) => { + if ( + target.nodeName === 'A' && + target.nextSibling && + target.nextSibling.classList && + target.nextSibling.classList.contains('app-sub-sidebar') + ) { + dom.toggleClass(target.parentNode, 'collapse'); + } + }); } - #focusContent(options = {}) { - const { query } = this.route; - const focusEl = query.id - ? // Heading ID - dom.find(`#${query.id}`) - : // First heading - dom.find('#main :where(h1, h2, h3, h4, h5, h6)') || - // Content container - dom.find('#main'); - - // Move focus to content area - focusEl && focusEl.focus(options); - } + /** + * Initialize sidebar show/hide toggle behavior + * + * @param {Element|string} elm Toggle Element or CSS selector + * @void + */ + #initSidebarToggle(elm) { + elm = typeof elm === 'string' ? document.querySelector(elm) : elm; - #highlight(path) { - if (!this.#enableScrollEvent) { + if (!elm) { return; } - const sidebar = dom.getNode('.sidebar'); - const anchors = dom.findAll('.anchor'); - const wrap = dom.find(sidebar, '.sidebar-nav'); - let active = dom.find(sidebar, 'li.active'); - const doc = document.documentElement; - const top = - ((doc && doc.scrollTop) || document.body.scrollTop) - this.#coverHeight; - let last; - - for (const node of anchors) { - if (node.offsetTop > top) { - if (!last) { - last = node; - } + const toggle = () => { + dom.body.classList.toggle('close'); - break; - } else { - last = node; - } - } + const isClosed = isMobile + ? dom.body.classList.contains('close') + : !dom.body.classList.contains('close'); - if (!last) { - return; - } + elm.setAttribute('aria-expanded', isClosed); + }; - const li = this.#nav[this.#getNavKey(path, last.getAttribute('data-id'))]; + dom.on(elm, 'click', e => { + e.stopPropagation(); + toggle(); + }); - if (!li || li === active) { + isMobile && + dom.on( + dom.body, + 'click', + () => dom.body.classList.contains('close') && toggle() + ); + } + + /** + * Initialize skip to content behavior + * + * @param {Element|string} elm Skip link Element or CSS selector + * @void + */ + #initSkipToContent(elm) { + elm = typeof elm === 'string' ? document.querySelector(elm) : elm; + + if (!elm) { return; } - active && active.classList.remove('active'); - li.classList.add('active'); - active = li; - - // Scroll into view - // https://github.com/vuejs/vuejs.org/blob/master/themes/vue/source/js/common.js#L282-L297 - if (!this.#hoverOver && dom.body.classList.contains('sticky')) { - const height = sidebar.clientHeight; - const curOffset = 0; - const cur = active.offsetTop + active.clientHeight + 40; - const isInView = - active.offsetTop >= wrap.scrollTop && cur <= wrap.scrollTop + height; - const notThan = cur - curOffset < height; - - sidebar.scrollTop = isInView - ? wrap.scrollTop - : notThan - ? curOffset - : cur - height; - } + elm.addEventListener('click', evt => { + evt.preventDefault(); + dom.find('main')?.scrollIntoView({ + behavior: 'smooth', + }); + this.#focusContent(); + }); } - #getNavKey(path, id) { - return `${decodeURIComponent(path)}?id=${decodeURIComponent(id)}`; + // Callbacks + // ========================================================================= + /** + * Handle rendering UI element updates and new content + * @void + */ + onRender() { + const currentPath = this.router.toURL(this.router.getCurrentPath()); + const currentTitle = dom.find( + `.sidebar a[href='${currentPath}']` + )?.innerText; + + // Update page title + dom.$.title = currentTitle || this.#title; + + this.#markAppNavActiveElm(); + this.#markSidebarCurrentPage(); + this.#initHeadings(); } - __scrollActiveSidebar(router) { - const cover = dom.find('.cover.show'); - this.#coverHeight = cover ? cover.offsetHeight : 0; - - const sidebar = dom.getNode('.sidebar'); - let lis = []; - if (sidebar !== null && sidebar !== undefined) { - lis = dom.findAll(sidebar, 'li'); - } + /** + * Handle navigation events + * + * @param {undefined|"history"|"navigate"} source Type of navigation where + * undefined is initial load, "history" is forward/back, and "navigate" is + * user click/tap + * @void + */ + onNavigate(source) { + const { auto2top, topMargin } = this.config; + const { query } = this.route; - for (const li of lis) { - const a = li.querySelector('a'); - if (!a) { - continue; - } + this.#markSidebarActiveElm(); - let href = a.getAttribute('href'); + // Note: Scroll position set by browser on forward/back (i.e. "history") + if (source !== 'history') { + // Anchor link + if (query.id) { + const headingElm = dom.find( + `.markdown-section :where(h1, h2, h3, h4, h5)[id="${query.id}"]` + ); - if (href !== '/') { - const { - query: { id }, - path, - } = router.parse(href); - if (id) { - href = this.#getNavKey(path, id); + if (headingElm) { + this.#watchNextScroll(); + headingElm.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); } } - - if (href) { - this.#nav[decodeURIComponent(href)] = li; + // User click/tap + else if (source === 'navigate') { + // Scroll to top + if (auto2top) { + document.scrollingElement.scrollTop = topMargin ?? 0; + } } } - if (isMobile) { - return; + // Move focus to content + if (query.id || source === 'navigate') { + this.#focusContent(); } + } - const path = removeParams(router.getCurrentPath()); - dom.off('scroll', () => this.#highlight(path)); - dom.on('scroll', () => this.#highlight(path)); - dom.on(sidebar, 'mouseover', () => { - this.#hoverOver = true; - }); - dom.on(sidebar, 'mouseleave', () => { - this.#hoverOver = false; - }); + // Functions + // ========================================================================= + /** + * Set focus on the main content area: current route ID, first heading, or + * the main content container + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus + * @param {Object} options HTMLElement focus() method options + * @void + */ + #focusContent(options = {}) { + const settings = { + preventScroll: true, + ...options, + }; + const { query } = this.route; + const focusEl = query.id + ? // Heading ID + dom.find(`#${query.id}`) + : // First heading + dom.find('#main :where(h1, h2, h3, h4, h5, h6)') || + // Content container + dom.find('#main'); + + // Move focus to content area + focusEl?.focus(settings); } - #scrollIntoView(path, id) { - if (!id) { + /** + * Marks the active app nav item + * + * @param {string} [href] Matching element HREF value. If unspecified, + * defaults to the current path (without query params) + * @returns Element|undefined + */ + #markAppNavActiveElm() { + const href = decodeURIComponent(this.router.toURL(this.route.path)); + const navElm = dom.find('nav.app-nav'); + + if (!navElm) { return; } - const topMargin = config().topMargin; - // Use [id='1234'] instead of #id to handle special cases such as reserved characters and pure number id - // https://stackoverflow.com/questions/37270787/uncaught-syntaxerror-failed-to-execute-queryselector-on-document - const section = dom.find(`[id="${id}"]`); - section && this.#scrollTo(section, topMargin); - const sidebar = dom.getNode('.sidebar'); - const oldActive = dom.find(sidebar, 'li.active'); - const oldPage = dom.find(sidebar, `[aria-current]`); - const newActive = this.#nav[this.#getNavKey(path, id)]; - const newPage = dom.find(sidebar, `[href$="${path}"]`)?.parentNode; - oldActive?.classList.remove('active'); - oldPage?.removeAttribute('aria-current'); - newActive?.classList.add('active'); - newPage?.setAttribute('aria-current', 'page'); - } + const newActive = dom + .findAll(navElm, 'a') + .sort((a, b) => b.href.length - a.href.length) + .find( + a => + href.includes(a.getAttribute('href')) || + href.includes(decodeURI(a.getAttribute('href'))) + ); + const oldActive = dom.find(navElm, 'li.active'); - #scrollEl = dom.$.scrollingElement || dom.$.documentElement; + if (newActive && newActive !== oldActive) { + oldActive?.classList.remove('active'); + newActive.classList.add('active'); + } - #scroll2Top(offset = 0) { - this.#scrollEl.scrollTop = offset === true ? 0 : Number(offset); + return newActive; } - /** @readonly */ - #title = dom.$.title; - /** - * Toggle button - * @param {Element} el Button to be toggled - * @void + * Marks the active sidebar item + * + * @param {string} [href] Matching element HREF value. If unspecified, + * defaults to the current path (with query params) + * @returns Element|undefined */ - #btn(el) { - const toggle = _ => { - dom.body.classList.toggle('close'); - - const isClosed = isMobile - ? dom.body.classList.contains('close') - : !dom.body.classList.contains('close'); + #markSidebarActiveElm(href) { + href ??= this.router.toURL(this.router.getCurrentPath()); - el.setAttribute('aria-expanded', isClosed); - }; + const sidebar = dom.find('.sidebar'); - el = dom.getNode(el); - if (el === null || el === undefined) { + if (!sidebar) { return; } - dom.on(el, 'click', e => { - e.stopPropagation(); - toggle(); - }); + const oldActive = dom.find(sidebar, 'li.active'); + const newActive = dom + .find( + sidebar, + `a[href="${href}"], a[href="${decodeURIComponent(href)}"]` + ) + ?.closest('li'); + + if (newActive && newActive !== oldActive) { + oldActive?.classList.remove('active'); + newActive.classList.add('active'); + } - isMobile && - dom.on( - dom.body, - 'click', - _ => dom.body.classList.contains('close') && toggle() - ); + return newActive; } - #collapse(el) { - el = dom.getNode(el); - if (el === null || el === undefined) { - return; - } + /** + * Marks the current page in the sidebar + * + * @param {string} [href] Matching sidebar element HREF value. If + * unspecified, defaults to the current path (without query params) + * @returns Element|undefined + */ + #markSidebarCurrentPage(href) { + href ??= this.router.toURL(this.route.path); - dom.on(el, 'click', ({ target }) => { - if ( - target.nodeName === 'A' && - target.nextSibling && - target.nextSibling.classList && - target.nextSibling.classList.contains('app-sub-sidebar') - ) { - dom.toggleClass(target.parentNode, 'collapse'); - } - }); - } + const sidebar = dom.find('.sidebar'); - __sticky = () => { - const cover = dom.getNode('section.cover'); - if (!cover) { + if (!sidebar) { return; } - const coverHeight = cover.getBoundingClientRect().height; - - if ( - window.pageYOffset >= coverHeight || - cover.classList.contains('hidden') - ) { - dom.toggleClass(dom.body, 'add', 'sticky'); - } else { - dom.toggleClass(dom.body, 'remove', 'sticky'); + const path = href?.split('?')[0]; + const oldPage = dom.find(sidebar, 'li[aria-current]'); + const newPage = dom + .find( + sidebar, + `a[href="${path}"], a[href="${decodeURIComponent(path)}"]` + ) + ?.closest('li'); + + if (newPage && newPage !== oldPage) { + oldPage?.removeAttribute('aria-current'); + newPage.setAttribute('aria-current', 'page'); } - }; + + return newPage; + } /** - * Get and active link - * @param {Object} router Router - * @param {String|Element} el Target element - * @param {Boolean} isParent Active parent - * @param {Boolean} autoTitle Automatically set title - * @return {Element} Active element + * Monitor next scroll start/end and set #isScrolling to true/false + * accordingly. Listeners are removed after the start/end events are fired. + * @void */ - __getAndActive(router, el, isParent, autoTitle) { - el = dom.getNode(el); - let links = []; - if (el !== null && el !== undefined) { - links = dom.findAll(el, 'a'); - } - - const hash = decodeURI(router.toURL(router.getCurrentPath())); - let target; - - links - .sort((a, b) => b.href.length - a.href.length) - .forEach(a => { - const href = decodeURI(a.getAttribute('href')); - const node = isParent ? a.parentNode : a; - - a.title = a.title || a.innerText; - - if (hash.indexOf(href) === 0 && !target) { - target = a; - dom.toggleClass(node, 'add', 'active'); - node.setAttribute('aria-current', 'page'); - } else { - dom.toggleClass(node, 'remove', 'active'); - node.removeAttribute('aria-current'); + #watchNextScroll() { + // Scroll start + document.addEventListener( + 'scroll', + () => { + this.#isScrolling = true; + + // Scroll end + if ('onscrollend' in window) { + document.addEventListener( + 'scrollend', + () => (this.#isScrolling = false), + { once: true } + ); } - }); + // Browsers w/o native scrollend event support (Safari) + else { + const callback = () => { + clearTimeout(scrollTimer); - if (autoTitle) { - dom.$.title = target - ? target.title || `${target.innerText} - ${this.#title}` - : this.#title; - } + scrollTimer = setTimeout(() => { + document.removeEventListener('scroll', callback); + this.#isScrolling = false; + }, 100); + }; + + let scrollTimer; - return target; + document.addEventListener('scroll', callback, false); + } + }, + { once: true } + ); } }; } diff --git a/src/core/fetch/index.js b/src/core/fetch/index.js index c4212be94..52c777ff9 100644 --- a/src/core/fetch/index.js +++ b/src/core/fetch/index.js @@ -184,7 +184,7 @@ export function Fetch(Base) { } } - $fetch(cb = noop, $resetEvents = this.$resetEvents.bind(this)) { + $fetch(cb = noop, onNavigate = this.onNavigate.bind(this)) { const done = () => { this.callHook('doneEach'); cb(); @@ -196,7 +196,7 @@ export function Fetch(Base) { done(); } else { this._fetch(() => { - $resetEvents(); + onNavigate(); done(); }); } @@ -262,25 +262,7 @@ export function Fetch(Base) { initFetch() { const { loadSidebar } = this.config; - // Server-Side Rendering - if (this.rendered) { - const activeEl = this.__getAndActive( - this.router, - '.sidebar-nav', - true, - true - ); - if (loadSidebar && activeEl) { - activeEl.parentNode.innerHTML += window.__SUB_SIDEBAR__; - } - - this._bindEventOnRendered(activeEl); - this.$resetEvents(); - this.callHook('doneEach'); - this.callHook('ready'); - } else { - this.$fetch(_ => this.callHook('ready')); - } + this.$fetch(_ => this.callHook('ready')); } }; } diff --git a/src/core/render/index.js b/src/core/render/index.js index 0dcd0ed1d..34802d820 100644 --- a/src/core/render/index.js +++ b/src/core/render/index.js @@ -18,6 +18,14 @@ export function Render(Base) { return class Render extends Base { #vueGlobalData; + #addTextAsTitleAttribute(cssSelector) { + dom.findAll(cssSelector).forEach(elm => { + if (!elm.title && elm.innerText) { + elm.title = elm.innerText; + } + }); + } + #executeScript() { const script = dom .findAll('.markdown-section>script') @@ -293,12 +301,10 @@ export function Render(Base) { this._renderTo('.sidebar-nav', this.compiler.sidebar(text, maxLevel)); sidebarToggleEl.setAttribute('aria-expanded', !isMobile); - const activeEl = this.__getAndActive( - this.router, - '.sidebar-nav', - true, - true - ); + const activeElmHref = this.router.toURL(this.route.path); + const activeEl = dom.find(`.sidebar-nav a[href="${activeElmHref}"]`); + + this.#addTextAsTitleAttribute('.sidebar-nav a'); if (loadSidebar && activeEl) { activeEl.parentNode.innerHTML += @@ -315,7 +321,7 @@ export function Render(Base) { _bindEventOnRendered(activeEl) { const { autoHeader } = this.config; - this.__scrollActiveSidebar(this.router); + this.onRender(); if (autoHeader && activeEl) { const main = dom.getNode('#main'); @@ -330,9 +336,7 @@ export function Render(Base) { _renderNav(text) { text && this._renderTo('nav', this.compiler.compile(text)); - if (this.config.loadNavbar) { - this.__getAndActive(this.router, 'nav'); - } + this.#addTextAsTitleAttribute('nav a'); } _renderMain(text, opt = {}, next) { @@ -420,7 +424,6 @@ export function Render(Base) { } this._renderTo('.cover-main', html); - this.__sticky(); } _updateRender() { diff --git a/src/core/router/index.js b/src/core/router/index.js index 76f692776..2ab50db17 100644 --- a/src/core/router/index.js +++ b/src/core/router/index.js @@ -49,11 +49,11 @@ export function Router(Base) { this._updateRender(); if (lastRoute.path === this.route.path) { - this.$resetEvents(params.source); + this.onNavigate(params.source); return; } - this.$fetch(noop, this.$resetEvents.bind(this, params.source)); + this.$fetch(noop, this.onNavigate.bind(this, params.source)); lastRoute = this.route; }); } diff --git a/src/themes/basic/_layout.styl b/src/themes/basic/_layout.styl index aea9fb230..36a2af7c9 100644 --- a/src/themes/basic/_layout.styl +++ b/src/themes/basic/_layout.styl @@ -273,6 +273,9 @@ main.hidden line-height 2em padding-bottom 40px + li + scroll-margin-bottom 40px + li.collapse .app-sub-sidebar display none diff --git a/test/config/jest.setup-tests.js b/test/config/jest.setup-tests.js index 3fbeaaa99..f72ed4787 100644 --- a/test/config/jest.setup-tests.js +++ b/test/config/jest.setup-tests.js @@ -21,8 +21,32 @@ const sideEffects = { }, }; +class IntersectionObserver { + constructor() {} + + root = null; + rootMargin = ''; + thresholds = []; + + disconnect() { + return null; + } + + observe() { + return null; + } + + takeRecords() { + return []; + } + + unobserve() { + return null; + } +} + // Lifecycle Hooks -// ----------------------------------------------------------------------------- +// ============================================================================= beforeAll(async () => { // Spy addEventListener ['document', 'window'].forEach(obj => { @@ -44,13 +68,16 @@ beforeAll(async () => { }); }); -// Reset JSDOM. This attempts to remove side effects from tests, however it does -// not reset all changes made to globals like the window and document -// objects. Tests requiring a full JSDOM reset should be stored in separate -// files, which is only way to do a complete JSDOM reset with Jest. beforeEach(async () => { const rootElm = document.documentElement; + // Reset JSDOM + // ----------------------------------------------------------------------------- + // This attempts to remove side effects from tests, however it does not reset + // all changes made to globals like the window and document objects. Tests + // requiring a full JSDOM reset should be stored in separate files, which is + // only way to do a complete JSDOM reset with Jest. + // Remove attributes on root element [...rootElm.attributes].forEach(attr => rootElm.removeAttribute(attr.name)); @@ -79,6 +106,12 @@ beforeEach(async () => { // Restore base elements rootElm.innerHTML = ''; + + // Mock IntersectionObserver + // ----------------------------------------------------------------------------- + [global, window].forEach( + obj => (obj.IntersectionObserver = IntersectionObserver) + ); }); afterEach(async () => { diff --git a/test/e2e/sidebar.test.js b/test/e2e/sidebar.test.js index 077826f54..d41fe95cd 100644 --- a/test/e2e/sidebar.test.js +++ b/test/e2e/sidebar.test.js @@ -44,6 +44,10 @@ test.describe('Sidebar Tests', () => { await docsifyInit(docsifyInitConfig); + await page.click('a[href="#/test"]'); + await expect(activeLinkElm).toHaveText('Test'); + expect(page.url()).toMatch(/\/test$/); + await page.click('a[href="#/test%20space"]'); await expect(activeLinkElm).toHaveText('Test Space'); expect(page.url()).toMatch(/\/test%20space$/); @@ -57,15 +61,11 @@ test.describe('Sidebar Tests', () => { expect(page.url()).toMatch(/\/test-foo$/); await page.click('a[href="#/test.foo"]'); - expect(page.url()).toMatch(/\/test.foo$/); await expect(activeLinkElm).toHaveText('Test .'); + expect(page.url()).toMatch(/\/test.foo$/); await page.click('a[href="#/test>foo"]'); await expect(activeLinkElm).toHaveText('Test >'); expect(page.url()).toMatch(/\/test%3Efoo$/); - - await page.click('a[href="#/test"]'); - await expect(activeLinkElm).toHaveText('Test'); - expect(page.url()).toMatch(/\/test$/); }); }); diff --git a/test/integration/__snapshots__/docs.test.js.snap b/test/integration/__snapshots__/docs.test.js.snap index 03b667895..42ab9a377 100644 --- a/test/integration/__snapshots__/docs.test.js.snap +++ b/test/integration/__snapshots__/docs.test.js.snap @@ -16,11 +16,11 @@ exports[`Docs Site coverpage renders and is unchanged 1`] = ` " `; -exports[`Docs Site navbar renders and is unchanged 1`] = `""`; +exports[`Docs Site navbar renders and is unchanged 1`] = `""`; exports[`Docs Site sidebar renders and is unchanged 1`] = ` "" `; diff --git a/test/integration/docsify.test.js b/test/integration/docsify.test.js index 06d32d2ba..438030314 100644 --- a/test/integration/docsify.test.js +++ b/test/integration/docsify.test.js @@ -11,7 +11,6 @@ describe('Docsify', function () { expect(vm).toBeInstanceOf(Object); expect(vm.constructor.name).toBe('Docsify'); expect(vm.$fetch).toBeInstanceOf(Function); - expect(vm.$resetEvents).toBeInstanceOf(Function); expect(vm.route).toBeInstanceOf(Object); });