mirror of
https://github.com/codex-team/codex.docs.git
synced 2025-07-31 02:59:43 +02:00
Add table of content
This commit is contained in:
parent
f5d1a73b26
commit
20747407f7
8 changed files with 327 additions and 5 deletions
|
@ -25,6 +25,7 @@
|
|||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<aside class="docs__aside-right" id="layout-right-column"></aside>
|
||||
</div>
|
||||
<script src="/dist/main.bundle.js"></script>
|
||||
{% if config.yandexMetrikaId is not empty %}
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
{% extends 'layout.twig' %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{# Remove after testing #}
|
||||
<div class="page-intersection-field"></div>
|
||||
|
||||
<article class="page" data-module="page">
|
||||
<header class="page__header">
|
||||
<a href="/" class="page__header-nav">
|
||||
|
|
|
@ -16,6 +16,7 @@ import ModuleDispatcher from 'module-dispatcher';
|
|||
import Writing from './modules/writing';
|
||||
import Page from './modules/page';
|
||||
import Extensions from './modules/extensions';
|
||||
import TableOfContent from "./modules/table-of-content";
|
||||
|
||||
/**
|
||||
* Main app class
|
||||
|
|
|
@ -6,12 +6,13 @@
|
|||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -21,7 +22,8 @@ export default class Writing {
|
|||
*/
|
||||
init(settings = {}, moduleEl) {
|
||||
this.codeStyler = this.createCodeStyling();
|
||||
};
|
||||
this.tableOfContent = this.createTableOfContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Init code highlighting
|
||||
|
@ -30,7 +32,17 @@ export default class Writing {
|
|||
const { default: CodeStyler } = await import(/* webpackChunkName: "code-styling" */ './../classes/codeStyler');
|
||||
|
||||
return new CodeStyler({
|
||||
selector: '.block-code__content'
|
||||
selector: '.block-code__content',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Init table of content
|
||||
* @return {Promise<TableOfContent>}
|
||||
*/
|
||||
async createTableOfContent() {
|
||||
const { default: TableOfContent } = await import(/* webpackChunkName: "table-of-content" */ './table-of-content');
|
||||
|
||||
return new TableOfContent();
|
||||
}
|
||||
}
|
||||
|
|
216
src/frontend/js/modules/table-of-content.js
Normal file
216
src/frontend/js/modules/table-of-content.js
Normal file
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Generate dynamic table of content
|
||||
*/
|
||||
export default class TableOfContent {
|
||||
/**
|
||||
* Initialize table of content
|
||||
*/
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize table of content
|
||||
*/
|
||||
init() {
|
||||
this.findTagsOnThePage();
|
||||
this.createTableOfContent();
|
||||
this.addTableOfContent();
|
||||
this.initIntersectionObserver();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all tags on the page
|
||||
*/
|
||||
findTagsOnThePage() {
|
||||
const tags = document.querySelectorAll(`.block-header--anchor`);
|
||||
|
||||
const allowedTags = [
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
];
|
||||
|
||||
this.tags = Array.prototype.filter.call(tags, (tag) => {
|
||||
console.log(tag.tagName.toLowerCase());
|
||||
|
||||
return allowedTags.includes(tag.tagName.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create table of content
|
||||
*
|
||||
* <section>
|
||||
* <header>On this page</header>
|
||||
* <ul>
|
||||
* <li><a href="#"></a></li>
|
||||
* ...
|
||||
* </ul>
|
||||
* </section>
|
||||
*/
|
||||
createTableOfContent() {
|
||||
this.tocElement = document.createElement('section');
|
||||
|
||||
this.tocElement.classList.add('table-of-content__list');
|
||||
|
||||
this.tags.forEach((tag) => {
|
||||
const linkTarget = tag.querySelector('a').getAttribute('href');
|
||||
|
||||
const linkWrapper = document.createElement('li');
|
||||
const linkBlock = document.createElement('a');
|
||||
|
||||
linkBlock.innerText = tag.innerText;
|
||||
linkBlock.href = `${linkTarget}`;
|
||||
|
||||
linkWrapper.classList.add('table-of-content__list-item');
|
||||
|
||||
// additional indent for h3-h6
|
||||
linkWrapper.classList.add(`table-of-content__list-item--${tag.tagName.toLowerCase()}`);
|
||||
|
||||
linkWrapper.appendChild(linkBlock);
|
||||
this.tocElement.appendChild(linkWrapper);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add table of content to the page
|
||||
*/
|
||||
addTableOfContent() {
|
||||
const header = document.createElement('header');
|
||||
const container = document.createElement('section');
|
||||
|
||||
header.innerText = 'On this page';
|
||||
header.classList.add('table-of-content__header');
|
||||
container.appendChild(header);
|
||||
|
||||
container.classList.add('table-of-content');
|
||||
container.appendChild(this.tocElement);
|
||||
|
||||
document.getElementById('layout-right-column')
|
||||
.appendChild(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Init intersection observer
|
||||
*/
|
||||
initIntersectionObserver() {
|
||||
const options = {
|
||||
rootMargin: '-5% 0 -80%',
|
||||
};
|
||||
|
||||
const callback = (entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const target = entry.target;
|
||||
const targetLink = target.querySelector('a').getAttribute('href');
|
||||
|
||||
// @todo remove after testing
|
||||
document.querySelector('.page-intersection-field').style.top = `${entry.rootBounds.top}px`;
|
||||
document.querySelector('.page-intersection-field').style.height = `${entry.rootBounds.height}px`;
|
||||
|
||||
/**
|
||||
* Intersection state of block
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
const isVisible = entry.isIntersecting;
|
||||
|
||||
/**
|
||||
* Calculate scroll direction whith the following logic:
|
||||
*
|
||||
* DOWN: if block top is BELOW (coordinate value is greater) the intersection root top
|
||||
* and block is NOT VISIBLE
|
||||
*
|
||||
* DOWN: if block top is ABOVE (coordinate value is less) the intersection root top
|
||||
* and block is VISIBLE
|
||||
*
|
||||
* UP: if block top is ABOVE (coordinate value is less) the intersection root top
|
||||
* and block is visible
|
||||
*
|
||||
* UP: if block top is BELOW (coordinate value is greater) the intersection root top
|
||||
* and block is NOT VISIBLE
|
||||
*
|
||||
* Therefore we can use XOR operator for
|
||||
* - is block's top is above root's top
|
||||
* - is block visible
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const scrollDirection = ((entry.boundingClientRect.top < entry.rootBounds.top) ^ (entry.isIntersecting)) ? 'down' : 'up';
|
||||
|
||||
/**
|
||||
* If a header becomes VISIBLE on scroll DOWN
|
||||
* then highlight its link
|
||||
*
|
||||
* = moving to the new chapter
|
||||
*/
|
||||
if (isVisible && scrollDirection === 'down') {
|
||||
this.setActiveLink(targetLink);
|
||||
}
|
||||
|
||||
/**
|
||||
* If a header becomes NOT VISIBLE on scroll UP
|
||||
* then highlight previous link
|
||||
*
|
||||
* = moving to the previous chapter
|
||||
*/
|
||||
if (!isVisible && scrollDirection === 'up') {
|
||||
this.setActiveLink(targetLink, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create intersection observer
|
||||
*/
|
||||
this.observer = new IntersectionObserver(callback, options);
|
||||
|
||||
/**
|
||||
* Add observer to found tags
|
||||
*/
|
||||
this.tags.reverse().forEach((tag) => {
|
||||
this.observer.observe(tag);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight link's item with a given href
|
||||
*
|
||||
* @param {string} targetLink - href of the link
|
||||
* @param {boolean} [needHighlightPrevious=false] - need to highlight previous link
|
||||
*/
|
||||
setActiveLink(targetLink, needHighlightPrevious = false) {
|
||||
/**
|
||||
* Clear all links
|
||||
*/
|
||||
this.tocElement.querySelectorAll('li').forEach((link) => {
|
||||
link.classList.remove('table-of-content__list-item--active');
|
||||
});
|
||||
|
||||
/**
|
||||
* Looking for a target link
|
||||
*/
|
||||
const targetElement = this.tocElement.querySelector(`a[href="${targetLink}"]`);
|
||||
|
||||
/**
|
||||
* Getting link's wrapper
|
||||
*/
|
||||
let listItem = targetElement.parentNode;
|
||||
|
||||
/**
|
||||
* Change target list item if it is needed
|
||||
*/
|
||||
if (needHighlightPrevious) {
|
||||
listItem = listItem.previousSibling;
|
||||
}
|
||||
|
||||
/**
|
||||
* If target list item is found then highlight it
|
||||
*/
|
||||
if (listItem) {
|
||||
listItem.classList.add('table-of-content__list-item--active');
|
||||
}
|
||||
}
|
||||
}
|
76
src/frontend/styles/components/table-of-content.pcss
Normal file
76
src/frontend/styles/components/table-of-content.pcss
Normal file
|
@ -0,0 +1,76 @@
|
|||
.table-of-content {
|
||||
position: sticky;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
|
||||
margin-top: 30px;
|
||||
top: 30px;
|
||||
width: 300px;
|
||||
border-left: 1px solid #E8E8EB;
|
||||
padding-left: 22px;
|
||||
|
||||
&__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 {
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 14px;
|
||||
line-height: 150%;
|
||||
|
||||
&:hover {
|
||||
background-color: #F3F6F8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: #F3F6F8;
|
||||
}
|
||||
|
||||
&--h3 { margin-left: 6px; }
|
||||
&--h4 { margin-left: 12px; }
|
||||
&--h5 { margin-left: 18px; }
|
||||
&--h6 { margin-left: 24px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.page-intersection-field {
|
||||
//background-color: rgba(255,0,0,.1);
|
||||
position: fixed;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
h2, h3, h4, h5, h6 {
|
||||
//background-color: #699a50;
|
||||
}
|
|
@ -31,10 +31,21 @@
|
|||
max-width: var(--layout-width-main-col);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media not (--mobile) {
|
||||
margin-bottom: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
&__aside-right {
|
||||
@media (--mobile) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__aside,
|
||||
&__content {
|
||||
&__content,
|
||||
&__aside-right {
|
||||
padding: var(--layout-padding-vertical) 0;
|
||||
|
||||
@media (--mobile) {
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
@import './components/landing.pcss';
|
||||
@import './components/auth.pcss';
|
||||
@import './components/button.pcss';
|
||||
@import './components/table-of-content.pcss';
|
||||
|
||||
body {
|
||||
font-family: system-ui, Helvetica, Arial, Verdana;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue