1
0
Fork 0
mirror of https://github.com/codex-team/codex.docs.git synced 2025-08-10 07:55:24 +02:00

fix scroll issues, resolve eslit ts/js conflicts

This commit is contained in:
Peter Savchenko 2022-07-20 23:53:39 +03:00
parent 23be283421
commit 1a100938b0
No known key found for this signature in database
GPG key ID: E68306B1AB0F727C
9 changed files with 251 additions and 1105 deletions

13
src/frontend/.eslintrc Normal file
View file

@ -0,0 +1,13 @@
{
"extends": [
"codex"
],
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
"allowImportExportEverywhere": true
},
"globals": {
"HTMLElement": true
}
}

View file

@ -1,4 +1,5 @@
import { Decorators } from '../utils/decorators'; import * as Decorators from '../utils/decorators';
import * as $ from '../utils/dom';
/** /**
* Generate dynamic table of content * Generate dynamic table of content
@ -7,10 +8,11 @@ export default class TableOfContent {
/** /**
* Initialize table of content * Initialize table of content
* *
* @param {string} tagSelector - selector for tags to observe * @param {object} options - constructor params
* @param {string} tocParentElement - selector for table of content wrapper * @param {string} options.tagSelector - selector for tags to observe
* @param {HTMLElement} options.appendTo - element for appending of the table of content
*/ */
constructor({ tagSelector, tocParentElement}) { constructor({ tagSelector, appendTo }) {
/** /**
* Array of tags to observe * Array of tags to observe
*/ */
@ -23,9 +25,30 @@ export default class TableOfContent {
this.tagSelector = tagSelector || 'h2,h3,h4'; this.tagSelector = tagSelector || 'h2,h3,h4';
/** /**
* Selector for table of content wrapper * Element to append the Table of Content
*/ */
this.tocParentElement = tocParentElement; this.tocParentElement = appendTo;
if (!this.tocParentElement) {
throw new Error('Table of Content wrapper not found');
}
this.nodes = {
/**
* Main Table of Content element
*/
wrapper: null,
/**
* List of Table of Content links
*/
items: [],
};
/**
* Currently highlighted element of ToC
*/
this.activeItem = null;
this.CSS = { this.CSS = {
tocContainer: 'table-of-content', tocContainer: 'table-of-content',
@ -59,16 +82,16 @@ export default class TableOfContent {
this.addTableOfContent(); this.addTableOfContent();
/** /**
* Calculate boundings for each tag and watch active section * Calculate bounds for each tag and watch active section
*/ */
this.calculateBoundings(); this.calculateBounds();
this.watchActiveSection(); this.watchActiveSection();
} }
/** /**
* Find all section tags on the page * Find all section tags on the page
* *
* @return {HTMLElement[]} * @returns {HTMLElement[]}
*/ */
getSectionTagsOnThePage() { getSectionTagsOnThePage() {
return Array.from(document.querySelectorAll(this.tagSelector)); return Array.from(document.querySelectorAll(this.tagSelector));
@ -77,7 +100,7 @@ export default class TableOfContent {
/** /**
* Calculate top line position for each tag * Calculate top line position for each tag
*/ */
calculateBoundings() { calculateBounds() {
this.tagsSectionsMap = this.tags.map((tag) => { this.tagsSectionsMap = this.tags.map((tag) => {
const rect = tag.getBoundingClientRect(); const rect = tag.getBoundingClientRect();
const top = Math.floor(rect.top + window.scrollY); const top = Math.floor(rect.top + window.scrollY);
@ -93,13 +116,24 @@ export default class TableOfContent {
* Watch active section while scrolling * Watch active section while scrolling
*/ */
watchActiveSection() { watchActiveSection() {
/**
* Where content zone starts in document
*/
const contentTopOffset = this.getScrollPadding();
/**
* Treat section as active if it reaches the 1/5 of viewport from top
* For example, for a window with 1006px height it will be 219px
*/
const activationOffset = window.innerHeight / 5;
const detectSection = () => { const detectSection = () => {
/** /**
* Calculate scroll position * Calculate scroll position
* *
* @todo research how not to use magic number * @todo research how not to use magic number
*/ */
let scrollPosition = this.getScrollPadding() + window.scrollY + 1; const scrollPosition = contentTopOffset + window.scrollY + activationOffset;
/** /**
* Find the nearest section above the scroll position * Find the nearest section above the scroll position
@ -114,12 +148,12 @@ export default class TableOfContent {
if (section) { if (section) {
const targetLink = section.tag.querySelector('a').getAttribute('href'); const targetLink = section.tag.querySelector('a').getAttribute('href');
this.setActiveLink(targetLink); this.setActiveItem(targetLink);
} else { } else {
/** /**
* Otherwise no active link will be highlighted * Otherwise no active link will be highlighted
*/ */
this.setActiveLink(); this.setActiveItem(null);
} }
}; };
@ -128,12 +162,14 @@ export default class TableOfContent {
*/ */
const throttledDetectSectionFunction = Decorators.throttle(() => { const throttledDetectSectionFunction = Decorators.throttle(() => {
detectSection(); detectSection();
}, 200); }, 400);
/** /**
* Scroll listener * Scroll listener
*/ */
document.addEventListener('scroll', throttledDetectSectionFunction); document.addEventListener('scroll', throttledDetectSectionFunction, {
passive: true,
});
} }
/** /**
@ -148,19 +184,16 @@ export default class TableOfContent {
* </section> * </section>
*/ */
createTableOfContent() { createTableOfContent() {
this.tocElement = document.createElement('section'); this.tocElement = $.make('section', this.CSS.tocElement);
this.tocElement.classList.add(this.CSS.tocElement);
this.tags.forEach((tag) => { this.tags.forEach((tag) => {
const linkTarget = tag.querySelector('a').getAttribute('href'); const linkTarget = tag.querySelector('a').getAttribute('href');
const linkWrapper = document.createElement('li'); const linkWrapper = $.make('li', this.CSS.tocElementItem);
const linkBlock = document.createElement('a'); const linkBlock = $.make('a', null, {
innerText: tag.innerText,
linkBlock.innerText = tag.innerText; href: `${linkTarget}`,
linkBlock.href = `${linkTarget}`; });
linkWrapper.classList.add(this.CSS.tocElementItem);
/** /**
* Additional indent for h3-h6 headers * Additional indent for h3-h6 headers
@ -182,6 +215,8 @@ export default class TableOfContent {
linkWrapper.appendChild(linkBlock); linkWrapper.appendChild(linkBlock);
this.tocElement.appendChild(linkWrapper); this.tocElement.appendChild(linkWrapper);
this.nodes.items.push(linkWrapper);
}); });
} }
@ -189,74 +224,110 @@ export default class TableOfContent {
* Add table of content to the page * Add table of content to the page
*/ */
addTableOfContent() { addTableOfContent() {
const header = document.createElement('header'); this.nodes.wrapper = $.make('section', this.CSS.tocContainer);
const container = document.createElement('section');
header.innerText = 'On this page'; const header = $.make('header', this.CSS.tocHeader, {
header.classList.add(this.CSS.tocHeader); textContent: 'On this page',
container.appendChild(header); });
container.classList.add(this.CSS.tocContainer); this.nodes.wrapper.appendChild(header);
container.appendChild(this.tocElement); this.nodes.wrapper.appendChild(this.tocElement);
const tocWrapper = document.querySelector(this.tocParentElement); this.tocParentElement.appendChild(this.nodes.wrapper);
if (!tocWrapper) {
throw new Error('Table of content wrapper not found');
}
tocWrapper.appendChild(container);
} }
/** /**
* Highlight link's item with a given href * Highlight link's item with a given href
* *
* @param {string} targetLink - href of the link * @param {string|null} targetLink - href of the link. Null if we need to clear all highlights
* @param {boolean} [needHighlightPrevious=false] - need to highlight previous link instead of current
*/ */
setActiveLink(targetLink, needHighlightPrevious = false) { setActiveItem(targetLink) {
/** /**
* Clear all links * Clear current highlight
*/ */
this.tocElement.querySelectorAll(`.${this.CSS.tocElementItem}`).forEach((link) => { if (this.activeItem) {
link.classList.remove(this.CSS.tocElementItemActive); this.activeItem.classList.remove(this.CSS.tocElementItemActive);
}); }
/** /**
* If targetLink is not defined then do nothing * If targetLink is null, that means we reached top, nothing to highlight
*/ */
if (!targetLink) { if (targetLink === null) {
/**
* Show the top of table of content
*/
document.querySelector(`.${this.CSS.tocHeader}`).scrollIntoViewIfNeeded();
return; return;
} }
/** /**
* Looking for a target link * Looking for a target link
*
* @todo do not fire DOM search, use saved map instead
*/ */
const targetElement = this.tocElement.querySelector(`a[href="${targetLink}"]`); const targetElement = this.tocElement.querySelector(`a[href="${targetLink}"]`);
/** /**
* Getting link's wrapper * Getting link's wrapper
*/ */
let listItem = targetElement.parentNode; const listItem = targetElement.parentNode;
/** /**
* Change target list item if it is needed * Highlight and save current item
*/ */
if (needHighlightPrevious) { listItem.classList.add(this.CSS.tocElementItemActive);
listItem = listItem.previousSibling; this.activeItem = listItem;
}
/** /**
* If target list item is found then highlight it * If need, scroll parent to active item
*/ */
if (listItem) { this.scrollToActiveItemIfNeeded();
listItem.classList.add(this.CSS.tocElementItemActive); }
listItem.scrollIntoViewIfNeeded();
/**
* Document scroll ending callback
*
* @returns {void}
*/
scrollToActiveItemIfNeeded() {
console.log('computations! )))');
/**
* If some item is highlighted, check whether we need to scroll to it or not
*/
if (this.activeItem) {
/**
* First, check do we need to scroll to item?
* We need to scroll in case when:
* item bottom coord is bigger than parent height + current parent scroll
*/
const itemOffsetTop = this.activeItem.offsetTop;
const itemHeight = this.activeItem.offsetHeight;
const itemBottomCoord = itemOffsetTop + itemHeight;
const additionalOffsetBelowItem = 10; // padding below item
const itemBottomCoordWithPadding = itemBottomCoord + additionalOffsetBelowItem;
const scrollableParentHeight = this.nodes.wrapper.offsetHeight;
const scrollableParentScrolledDistance = this.nodes.wrapper.scrollTop;
const isScrollRequired = itemBottomCoordWithPadding > scrollableParentHeight + scrollableParentScrolledDistance;
if (isScrollRequired === false) {
/**
* Item is visible, scroll is not needed
*/
return;
}
/**
* Now compute the scroll distance to make item visible
*/
const distanceToMakeItemFullyVisible = itemBottomCoordWithPadding - scrollableParentHeight;
/**
* Change the scroll
* Using RAF to prevent overloading of regular scroll animation FPS
*/
window.requestAnimationFrame(() => {
this.nodes.wrapper.scrollTop = distanceToMakeItemFullyVisible;
});
} }
} }
@ -280,7 +351,7 @@ export default class TableOfContent {
/** /**
* Getting css scroll padding value * Getting css scroll padding value
*/ */
const scrollPaddingTopValue = getComputedStyle(htmlElement) const scrollPaddingTopValue = window.getComputedStyle(htmlElement)
.getPropertyValue('scroll-padding-top'); .getPropertyValue('scroll-padding-top');
/** /**

View file

@ -1,7 +1,3 @@
/**
* @typedef {object} pageModuleSettings
*/
/** /**
* @class Page * @class Page
* @classdesc Class for page module * @classdesc Class for page module
@ -17,10 +13,8 @@ export default class Page {
/** /**
* Called by ModuleDispatcher to initialize module from DOM * Called by ModuleDispatcher to initialize module from DOM
* @param {pageModuleSettings} settings - module settings
* @param {HTMLElement} moduleEl - module element
*/ */
init(settings = {}, moduleEl) { init() {
this.codeStyler = this.createCodeStyling(); this.codeStyler = this.createCodeStyling();
this.tableOfContent = this.createTableOfContent(); this.tableOfContent = this.createTableOfContent();
} }
@ -31,24 +25,35 @@ export default class Page {
async createCodeStyling() { async createCodeStyling() {
const { default: CodeStyler } = await import(/* webpackChunkName: "code-styling" */ './../classes/codeStyler'); const { default: CodeStyler } = await import(/* webpackChunkName: "code-styling" */ './../classes/codeStyler');
return new CodeStyler({ try {
selector: '.block-code__content', // eslint-disable-next-line no-new
}); new CodeStyler({
selector: '.block-code__content',
});
} catch (error) {
console.error(error); // @todo send to Hawk
}
} }
/** /**
* Init table of content * Init table of content
* @return {Promise<TableOfContent>} *
* @returns {Promise<TableOfContent>}
*/ */
async createTableOfContent() { async createTableOfContent() {
const { default: TableOfContent } = await import(/* webpackChunkName: "table-of-content" */ '../classes/table-of-content'); const { default: TableOfContent } = await import(/* webpackChunkName: "table-of-content" */ '../classes/table-of-content');
return new TableOfContent({ try {
tagSelector: // eslint-disable-next-line no-new
'h2.block-header--anchor,' + new TableOfContent({
'h3.block-header--anchor,' + tagSelector:
'h4.block-header--anchor', 'h2.block-header--anchor,' +
tocParentElement: '#layout-sidebar-right' 'h3.block-header--anchor,' +
}); 'h4.block-header--anchor',
appendTo: document.getElementById('layout-sidebar-right'),
});
} catch (error) {
console.error(error); // @todo send to Hawk
}
} }
} }

View file

@ -1,63 +1,62 @@
/** /**
* A few useful utility functions * A few useful utility functions
*/ */
export class Decorators {
/**
* Throttle decorator function
*
* @param {Function} func - function to throttle
* @param {number} ms - milliseconds to throttle
*
* @returns {wrapper}
*/
static throttle(func, ms) {
let isThrottled = false,
savedArgs,
savedThis;
function wrapper() { /**
if (isThrottled) { * Throttle decorator function
savedArgs = arguments; *
savedThis = this; * @param {Function} func - function to throttle
return; * @param {number} ms - milliseconds to throttle
} *
* @returns {Function}
*/
export function throttle(func, ms) {
let isThrottled = false,
savedArgs,
savedThis;
func.apply(this, arguments); // eslint-disable-next-line jsdoc/require-jsdoc
function wrapper() {
if (isThrottled) {
savedArgs = arguments;
savedThis = this;
isThrottled = true; return;
setTimeout(function() {
isThrottled = false;
if (savedArgs) {
wrapper.apply(savedThis, savedArgs);
savedArgs = savedThis = null;
}
}, ms);
} }
return wrapper; func.apply(this, arguments);
isThrottled = true;
setTimeout(function () {
isThrottled = false;
if (savedArgs) {
wrapper.apply(savedThis, savedArgs);
savedArgs = savedThis = null;
}
}, ms);
} }
/** return wrapper;
* Debounce decorator function }
*
* @param {Function} f - function to debounce /**
* @param {number} ms - milliseconds to debounce * Debounce decorator function
* *
* @returns {(function(): void)|*} * @param {Function} f - function to debounce
*/ * @param {number} ms - milliseconds to debounce
static debounce(f, ms) { *
let isCooldown = false; * @returns {(function(): void)|*}
*/
return function () { export function debounce(f, ms) {
if (isCooldown) return; let timeoutId = null;
f.apply(this, arguments); return function () {
if (timeoutId) {
isCooldown = true; clearTimeout(timeoutId);
}
setTimeout(() => isCooldown = false, ms);
}; timeoutId = setTimeout(() => f.apply(this, arguments), ms);
} };
} }

View file

@ -0,0 +1,23 @@
/**
* Helper method for elements creation
*
* @param {string} tagName - name of tag to create
* @param {string | string[]} classNames - list of CSS classes
* @param {object} attributes - any properties to add
* @returns {HTMLElement}
*/
export function make(tagName, classNames = null, attributes = {}) {
const el = document.createElement(tagName);
if (Array.isArray(classNames)) {
el.classList.add(...classNames);
} else if (classNames) {
el.classList.add(classNames);
}
for (const attrName in attributes) {
el[attrName] = attributes[attrName];
}
return el;
}

View file

@ -2,6 +2,11 @@
border-left: 1px solid var(--color-line-gray); border-left: 1px solid var(--color-line-gray);
padding-left: var(--layout-padding-horizontal); padding-left: var(--layout-padding-horizontal);
height: 100%;
overflow: auto;
padding: var(--layout-padding-vertical) var(--layout-padding-horizontal);
box-sizing: border-box;
&__header { &__header {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;

View file

@ -20,15 +20,15 @@
margin: 0 auto; margin: 0 auto;
@media (--desktop) { @media (--desktop) {
margin-right: var(--layout-padding-horizontal);
margin-left: 0; margin-left: 0;
padding: var(--layout-padding-vertical) var(--layout-padding-horizontal);
} }
} }
} }
&__content { &__content {
--max-space-between-cols: 160px; --max-space-between-cols: 160px;
padding: var(--layout-padding-vertical) var(--layout-padding-horizontal);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
max-width: calc(var(--layout-width-main-col) + var(--max-space-between-cols) + var(--layout-sidebar-width)); max-width: calc(var(--layout-width-main-col) + var(--max-space-between-cols) + var(--layout-sidebar-width));
@ -49,7 +49,7 @@
overflow: auto; overflow: auto;
height: calc(100vh - var(--layout-height-header)); height: calc(100vh - var(--layout-height-header));
top: calc(var(--layout-height-header) + var(--layout-padding-vertical)); top: calc(var(--layout-height-header));
align-self: flex-start; align-self: flex-start;
@media (--desktop) { @media (--desktop) {

970
yarn.lock

File diff suppressed because it is too large Load diff