1
0
Fork 0
mirror of https://github.com/codex-team/codex.docs.git synced 2025-07-18 20:59:42 +02:00

Table of content (#199)

* Create nodemon.json

* Add table of content

* update view

* remove logs

* update tags var

* update layout

* Revert "update layout"

This reverts commit 18aad62257.

* update layout

* Update layout.pcss

* update from master

* Update sidebar.twig

* remove non valued changes

* Update table-of-content.js

* Update table-of-content.pcss

* Update table-of-content.pcss

* Update layout.pcss

* Update table-of-content.js

* remove unused styles

* not module

* rename var

* remove log

* update structure

* Update table-of-content.js

* Update table-of-content.js

* Update layout.pcss

* Update table-of-content.js

* try not to use intersection observer

* Update table-of-content.js

* fix scroll padding

* fix header component layout

* update logic

* fix click area

* Update table-of-content.js

* Update table-of-content.js

* small fixes

* remove unused

* Update table-of-content.js

* Update decorators.js

* Update table-of-content.js

* Update table-of-content.js

* Update table-of-content.js

* Update table-of-content.js

* Update table-of-content.js

* fix scroll issues, resolve eslit ts/js conflicts

* add some todos

* handle up-direction scroll as well

* optimization

* update offsets

* Update header.pcss

Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
This commit is contained in:
Taly 2022-07-26 18:49:30 +03:00 committed by GitHub
parent 13cc53e4ae
commit 213f9d89a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 630 additions and 999 deletions

13
nodemon.json Normal file
View file

@ -0,0 +1,13 @@
{
"verbose": true,
"ignore": [
".git",
"node_modules",
"public",
"src/frontend"
],
"watch": [
"**/*"
],
"ext": "js,twig"
}

View file

@ -27,9 +27,7 @@
{% block body %}{% endblock %}
</div>
<aside class="docs__aside-right">
<div style="width: 100%; height: 100px; background: grey"/>
</aside>
<aside class="docs__aside-right" id="layout-sidebar-right"></aside>
</div>
</div>
<script src="/dist/main.bundle.js"></script>

View file

@ -1,5 +1,4 @@
<a name="{{ text | urlify }}" style="display: inline-block; position: absolute; margin-top: -20px;"></a>
<h{{ level }} class="block-header block-header--{{ level }} block-header--anchor">
<h{{ level }} id="{{ text | urlify }}" class="block-header block-header--{{ level }} block-header--anchor">
<a href="#{{ text | urlify }}">
{{ text }}
</a>

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

@ -0,0 +1,405 @@
import * as Decorators from '../utils/decorators';
import * as $ from '../utils/dom';
/**
* Generate dynamic table of content
*/
export default class TableOfContent {
/**
* Initialize table of content
*
* @param {object} options - constructor params
* @param {string} options.tagSelector - selector for tags to observe
* @param {HTMLElement} options.appendTo - element for appending of the table of content
*/
constructor({ tagSelector, appendTo }) {
/**
* Array of tags to observe
*/
this.tags = [];
this.tagsSectionsMap = [];
/**
* Selector for tags to observe
*/
this.tagSelector = tagSelector || 'h2,h3,h4';
/**
* Element to append the Table of Content
*/
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 = {
tocContainer: 'table-of-content',
tocHeader: 'table-of-content__header',
tocElement: 'table-of-content__list',
tocElementItem: 'table-of-content__list-item',
tocElementItemActive: 'table-of-content__list-item--active',
tocElementItemIndent: number => `table-of-content__list-item--indent-${number}x`,
};
this.init();
}
/**
* Initialize table of content
*/
init() {
this.tags = this.getSectionTagsOnThePage();
/**
* Check if no tags found then table of content is not needed
*/
if (this.tags.length === 0) {
return;
}
/**
* Initialize table of content element
*/
this.createTableOfContent();
this.addTableOfContent();
/**
* Calculate bounds for each tag and watch active section
*/
this.calculateBounds();
this.watchActiveSection();
}
/**
* Find all section tags on the page
*
* @returns {HTMLElement[]}
*/
getSectionTagsOnThePage() {
return Array.from(document.querySelectorAll(this.tagSelector));
}
/**
* Calculate top line position for each tag
*/
calculateBounds() {
this.tagsSectionsMap = this.tags.map((tag) => {
const rect = tag.getBoundingClientRect();
const top = Math.floor(rect.top + window.scrollY);
return {
top,
tag,
};
});
}
/**
* Watch active section while scrolling
*/
watchActiveSection() {
/**
* Where content zone starts in document
*/
const contentTopOffset = this.getScrollPadding();
/**
* Additional offset for correct calculation of active section
*
* Cause opening page with anchor could scroll almost
* near to the target section and we need to add 1px to calculations
*/
const activationOffset = 1;
const detectSection = () => {
/**
* Calculate scroll position
*
* @todo research how not to use magic number
*/
const scrollPosition = contentTopOffset + window.scrollY + activationOffset;
/**
* Find the nearest section above the scroll position
*/
const section = this.tagsSectionsMap.filter((tag) => {
return tag.top <= scrollPosition;
}).pop();
/**
* If section found then set it as active
*/
if (section) {
const targetLink = section.tag.querySelector('a').getAttribute('href');
this.setActiveItem(targetLink);
} else {
/**
* Otherwise no active link will be highlighted
*/
this.setActiveItem(null);
}
};
/**
* Define a flag to reduce number of calls to detectSection function
*/
const throttledDetectSectionFunction = Decorators.throttle(() => {
detectSection();
}, 400);
/**
* Scroll listener
*/
document.addEventListener('scroll', throttledDetectSectionFunction, {
passive: true,
});
}
/**
* Create table of content
*
* <section>
* <header>On this page</header>
* <ul>
* <li><a href="#"></a></li>
* ...
* </ul>
* </section>
*/
createTableOfContent() {
this.tocElement = $.make('section', this.CSS.tocElement);
this.tags.forEach((tag) => {
const linkTarget = tag.querySelector('a').getAttribute('href');
const linkWrapper = $.make('li', this.CSS.tocElementItem);
const linkBlock = $.make('a', null, {
innerText: tag.innerText,
href: `${linkTarget}`,
});
/**
* Additional indent for h3-h6 headers
*/
switch (tag.tagName.toLowerCase()) {
case 'h3':
linkWrapper.classList.add(this.CSS.tocElementItemIndent(1));
break;
case 'h4':
linkWrapper.classList.add(this.CSS.tocElementItemIndent(2));
break;
case 'h5':
linkWrapper.classList.add(this.CSS.tocElementItemIndent(3));
break;
case 'h6':
linkWrapper.classList.add(this.CSS.tocElementItemIndent(4));
break;
}
linkWrapper.appendChild(linkBlock);
this.tocElement.appendChild(linkWrapper);
this.nodes.items.push(linkWrapper);
});
}
/**
* Add table of content to the page
*/
addTableOfContent() {
this.nodes.wrapper = $.make('section', this.CSS.tocContainer);
const header = $.make('header', this.CSS.tocHeader, {
textContent: 'On this page',
});
this.nodes.wrapper.appendChild(header);
this.nodes.wrapper.appendChild(this.tocElement);
this.tocParentElement.appendChild(this.nodes.wrapper);
}
/**
* Highlight link's item with a given href
*
* @param {string|null} targetLink - href of the link. Null if we need to clear all highlights
*/
setActiveItem(targetLink) {
/**
* Clear current highlight
*/
if (this.activeItem) {
this.activeItem.classList.remove(this.CSS.tocElementItemActive);
}
/**
* If targetLink is null, that means we reached top, nothing to highlight
*/
if (targetLink === null) {
this.activeItem = null;
return;
}
/**
* Looking for a target link
*
* @todo do not fire DOM search, use saved map instead
*/
const targetElement = this.tocElement.querySelector(`a[href="${targetLink}"]`);
/**
* Getting link's wrapper
*/
const listItem = targetElement.parentNode;
/**
* Highlight and save current item
*/
listItem.classList.add(this.CSS.tocElementItemActive);
this.activeItem = listItem;
/**
* If need, scroll parent to active item
*/
this.scrollToActiveItemIfNeeded();
}
/**
* Document scroll ending callback
*
* @returns {void}
*/
scrollToActiveItemIfNeeded() {
/**
* Do nothing if the Table of Content has no internal scroll at this page
*
* @todo compute it once
*/
const hasScroll = this.nodes.wrapper.scrollHeight > this.nodes.wrapper.clientHeight;
if (!hasScroll) {
return;
}
/**
* 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
*
* @todo use memoization for calculating of the itemBottomCoordWithPadding
*/
const itemOffsetTop = this.activeItem.offsetTop;
const itemHeight = this.activeItem.offsetHeight;
const itemBottomCoord = itemOffsetTop + itemHeight;
const scrollPadding = 10; // scroll offset below/above item
const itemBottomCoordWithPadding = itemBottomCoord + scrollPadding;
const itemTopCoordWithPadding = itemOffsetTop - scrollPadding;
const scrollableParentHeight = this.nodes.wrapper.offsetHeight; // @todo compute it once
const scrollableParentScrolledDistance = this.nodes.wrapper.scrollTop;
/**
* Scroll bottom required if item ends below the parent bottom boundary
*/
const isScrollDownRequired = itemBottomCoordWithPadding > scrollableParentHeight + scrollableParentScrolledDistance;
/**
* Scroll upward required when item starts above the visible parent zone
*/
const isScrollUpRequired = itemTopCoordWithPadding < scrollableParentScrolledDistance;
/**
* If item is fully visible, scroll is not required
*/
const isScrollRequired = isScrollDownRequired || isScrollUpRequired;
if (isScrollRequired === false) {
/**
* Item is visible, scroll is not needed
*/
return;
}
/**
* Now compute the scroll distance to make item visible
*/
let distanceToMakeItemFullyVisible;
if (isScrollDownRequired) {
distanceToMakeItemFullyVisible = itemBottomCoordWithPadding - scrollableParentHeight;
} else { // scrollUpRequired=true
distanceToMakeItemFullyVisible = itemTopCoordWithPadding;
}
/**
* Change the scroll
* Using RAF to prevent overloading of regular scroll animation FPS
*/
window.requestAnimationFrame(() => {
this.nodes.wrapper.scrollTop = distanceToMakeItemFullyVisible;
});
}
}
/**
* Get scroll padding top value from HTML element
*
* @returns {number}
*/
getScrollPadding() {
const defaultScrollPaddingValue = 0;
/**
* Try to get calculated value or fallback to default value
*/
try {
/**
* Getting the HTML element
*/
const htmlElement = document.documentElement;
/**
* Getting css scroll padding value
*/
const scrollPaddingTopValue = window.getComputedStyle(htmlElement)
.getPropertyValue('scroll-padding-top');
/**
* Parse value to number
*/
return parseInt(scrollPaddingTopValue, 10);
} catch (e) {}
/**
* On any errors return default value
*/
return defaultScrollPaddingValue;
}
}

View file

@ -1,27 +1,23 @@
/**
* @typedef {object} pageModuleSettings
*/
/**
* @class Page
* @classdesc Class for page module
*/
export default class Writing {
export default class Page {
/**
* Creates base properties
*/
constructor() {
this.codeStyler = null;
this.tableOfContent = null;
}
/**
* 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.tableOfContent = this.createTableOfContent();
}
/**
* Init code highlighting
@ -29,8 +25,35 @@ export default class Writing {
async createCodeStyling() {
const { default: CodeStyler } = await import(/* webpackChunkName: "code-styling" */ './../classes/codeStyler');
return new CodeStyler({
selector: '.block-code__content'
});
};
try {
// 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
*
* @returns {Promise<TableOfContent>}
*/
async createTableOfContent() {
const { default: TableOfContent } = await import(/* webpackChunkName: "table-of-content" */ '../classes/table-of-content');
try {
// eslint-disable-next-line no-new
new TableOfContent({
tagSelector:
'h2.block-header--anchor,' +
'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

@ -0,0 +1,62 @@
/**
* A few useful utility functions
*/
/**
* Throttle decorator function
*
* @param {Function} func - function to throttle
* @param {number} ms - milliseconds to throttle
*
* @returns {Function}
*/
export function throttle(func, ms) {
let isThrottled = false,
savedArgs,
savedThis;
// eslint-disable-next-line jsdoc/require-jsdoc
function wrapper() {
if (isThrottled) {
savedArgs = arguments;
savedThis = this;
return;
}
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
*
* @returns {(function(): void)|*}
*/
export function debounce(f, ms) {
let timeoutId = null;
return function () {
if (timeoutId) {
clearTimeout(timeoutId);
}
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

@ -1,4 +1,3 @@
/**
* Utility class to handle interaction with local storage
*/
@ -27,4 +26,4 @@ export class Storage {
get() {
return localStorage.getItem(this.key);
}
}
}

View file

@ -1,3 +1,7 @@
html {
scroll-padding-top: calc(var(--layout-height-header) + 50px);
}
.docs-header {
display: flex;
justify-content: space-between;
@ -6,7 +10,6 @@
border-bottom: 1px solid var(--color-line-gray);
font-size: 18px;
flex-wrap: wrap;
position: relative;
height: var(--layout-height-header);
box-sizing: border-box;
position: sticky;

View file

@ -1,6 +1,6 @@
.docs-sidebar {
width: 100vw;
@media (--desktop) {
width: var(--layout-sidebar-width);
}
@ -14,7 +14,7 @@
display: flex;
flex-direction: column;
overflow: auto;
@media (--desktop) {
height: calc(100vh - var(--layout-height-header));
border-right: 1px solid var(--color-line-gray);
@ -157,7 +157,7 @@
height: 24px;
transition-property: background-color;
transition-duration: 0.1s;
@apply --squircle;
@media (--can-hover) {
@ -178,7 +178,7 @@
color: var(--color-text-second);
padding: 20px 15px;
border-bottom: 1px solid var(--color-line-gray);
@media (--desktop) {
display: none;
}
@ -217,4 +217,4 @@
}
}
}
}

View file

@ -0,0 +1,58 @@
.table-of-content {
border-left: 1px solid var(--color-line-gray);
padding-left: var(--layout-padding-horizontal);
height: 100%;
overflow: auto;
padding: var(--layout-padding-vertical) var(--layout-padding-horizontal);
box-sizing: border-box;
&__header {
font-size: 16px;
font-weight: 600;
line-height: 21px;
letter-spacing: -0.01em;
margin-bottom: 12px;
padding: 0 6px;
}
&__list {
margin: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0;
list-style: none;
gap: 6px;
&-item {
@apply --squircle;
&:hover {
background-color: var(--color-link-hover);
cursor: pointer;
}
&--active {
background-color: var(--color-link-hover);
}
&--indent-1x { margin-left: 6px; }
&--indent-2x { margin-left: 12px; }
&--indent-3x { margin-left: 18px; }
&--indent-4x { margin-left: 24px; }
& > a {
padding: 4px 8px;
display: block;
font-size: 14px;
letter-spacing: -0.01em;
line-height: 150%;
}
}
}
}

View file

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

View file

@ -1,4 +1,5 @@
@import 'normalize.css';
@import './vars.pcss';
@import './layout.pcss';
@import './carbon.pcss';
@ -9,6 +10,7 @@
@import './components/auth.pcss';
@import './components/button.pcss';
@import './components/sidebar.pcss';
@import './components/table-of-content.pcss';
body {
font-family: system-ui, Helvetica, Arial, Verdana;

970
yarn.lock

File diff suppressed because it is too large Load diff