mirror of
https://github.com/codex-team/codex.docs.git
synced 2025-08-07 14:35:26 +02:00
Solved conflicts, merged with branch main
This commit is contained in:
commit
acdf744b6b
33 changed files with 28392 additions and 5218 deletions
|
@ -1,2 +1,8 @@
|
|||
# codex-docs password.
|
||||
PASSWORD=
|
||||
PASSWORD=
|
||||
|
||||
# Backend errors tracking Integraton Token
|
||||
HAWK_TOKEN_BACKEND=
|
||||
|
||||
# Client errors tracking Integraton Token
|
||||
HAWK_TOKEN_CLIENT=
|
||||
|
|
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
|
@ -17,10 +17,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
@ -28,18 +28,18 @@ jobs:
|
|||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=raw,value={{branch}}-{{sha}}-{{date 'X'}}
|
||||
type=raw,value={{branch}}-{{sha}}-{{date 'X'}},enable=${{ startsWith(github.ref, 'refs/heads') }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.prod
|
||||
|
|
15
README.md
15
README.md
|
@ -83,6 +83,21 @@ yarn lint
|
|||
yarn test
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
You can configure application using configs in <code>/config</code> directory.
|
||||
|
||||
| Property | Role |
|
||||
|----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| <code>port</code> | to set port of application |
|
||||
| <code>database</code> | to name directory with data |
|
||||
| <code>rcFile</code> | to set destination of codexdocsrc config file |
|
||||
| <code>uploads</code> | to set destination of directory to save uploads |
|
||||
| <code>secret</code> | to set secret |
|
||||
| <code>favicon</code> | to set url or favicon path (favicon need to be in /public directory), like `/myFavicon.png`, to get favicon. Server uploads file by url and saves it to temporary directory. And you can get favicon by /favicon static route of application |
|
||||
|
||||
You can configure application using configs in <code>/config</code> directory.
|
||||
|
||||
### Authentication
|
||||
|
||||
To manage pages you need to authorize (available on `/auth`).
|
||||
|
|
|
@ -3,5 +3,6 @@
|
|||
"database": ".db",
|
||||
"rcFile": "./.codexdocsrc",
|
||||
"uploads": "public/uploads",
|
||||
"secret": "iamasecretstring"
|
||||
"secret": "iamasecretstring",
|
||||
"favicon": ""
|
||||
}
|
||||
|
|
|
@ -3,5 +3,6 @@
|
|||
"database": ".db",
|
||||
"rcFile": "./.codexdocsrc",
|
||||
"uploads": "/uploads",
|
||||
"secret": "iamasecretstring"
|
||||
"secret": "iamasecretstring",
|
||||
"favicon": ""
|
||||
}
|
||||
|
|
|
@ -3,5 +3,6 @@
|
|||
"database": ".testdb",
|
||||
"rcFile": "./src/test/.codexdocsrc",
|
||||
"uploads": "public/uploads_test",
|
||||
"secret": "iamasecretstring"
|
||||
"secret": "iamasecretstring",
|
||||
"favicon": ""
|
||||
}
|
||||
|
|
13
nodemon.json
Normal file
13
nodemon.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"verbose": true,
|
||||
"ignore": [
|
||||
".git",
|
||||
"node_modules",
|
||||
"public",
|
||||
"src/frontend"
|
||||
],
|
||||
"watch": [
|
||||
"**/*"
|
||||
],
|
||||
"ext": "js,twig"
|
||||
}
|
23384
package-lock.json
generated
Normal file
23384
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -17,6 +17,8 @@
|
|||
"editor-upgrade": "yarn add -D @editorjs/{editorjs,header,code,delimiter,list,link,image,table,inline-code,marker,warning,checklist,raw}@latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hawk.so/javascript": "^3.0.1",
|
||||
"@hawk.so/nodejs": "^3.1.2",
|
||||
"config": "^3.3.6",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"csurf": "^1.11.0",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import express, { NextFunction, Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import morgan from 'morgan';
|
||||
|
@ -7,35 +7,84 @@ import routes from './routes';
|
|||
import HttpException from './exceptions/httpException';
|
||||
import * as dotenv from 'dotenv';
|
||||
import config from 'config';
|
||||
import HawkCatcher from '@hawk.so/nodejs';
|
||||
import os from 'os';
|
||||
import appConfig from 'config';
|
||||
import { downloadFavicon, FaviconData } from './utils/downloadFavicon';
|
||||
|
||||
|
||||
dotenv.config();
|
||||
const app = express();
|
||||
const localConfig = rcParser.getConfiguration();
|
||||
|
||||
// Initialize the backend error tracking catcher.
|
||||
if (process.env.HAWK_TOKEN_BACKEND) {
|
||||
HawkCatcher.init(process.env.HAWK_TOKEN_BACKEND);
|
||||
}
|
||||
|
||||
// Get url to upload favicon from config
|
||||
const favicon: string = appConfig.get('favicon');
|
||||
|
||||
app.locals.config = localConfig;
|
||||
// Set client error tracking token as app local.
|
||||
if (process.env.HAWK_TOKEN_CLIENT) {
|
||||
app.locals.config.hawkClientToken = process.env.HAWK_TOKEN_CLIENT;
|
||||
}
|
||||
|
||||
// view engine setup
|
||||
app.set('views', path.join(__dirname, './', 'views'));
|
||||
app.set('view engine', 'twig');
|
||||
require('./utils/twig');
|
||||
|
||||
const downloadedFaviconFolder = os.tmpdir();
|
||||
|
||||
// Check if favicon is not empty
|
||||
if (favicon) {
|
||||
// Upload favicon by url, it's path on server is '/temp/favicon.{format}'
|
||||
downloadFavicon(favicon, downloadedFaviconFolder).then((res) => {
|
||||
app.locals.favicon = res;
|
||||
console.log('Favicon successfully uploaded');
|
||||
})
|
||||
.catch( (err) => {
|
||||
console.log(err);
|
||||
console.log('Favicon has not uploaded');
|
||||
});
|
||||
} else {
|
||||
console.log('Favicon is empty, using default path');
|
||||
app.locals.favicon = {
|
||||
destination: '/favicon.png',
|
||||
type: 'image/png',
|
||||
} as FaviconData;
|
||||
}
|
||||
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.join(__dirname, '../../public')));
|
||||
app.use('/uploads', express.static(config.get('uploads')));
|
||||
app.use('/favicon', express.static(downloadedFaviconFolder));
|
||||
|
||||
app.use('/', routes);
|
||||
|
||||
// error handler
|
||||
app.use(function (err: HttpException, req: Request, res: Response) {
|
||||
// set locals, only providing error in development
|
||||
res.locals.message = err.message;
|
||||
res.locals.error = req.app.get('env') === 'development' ? err : {};
|
||||
|
||||
// render the error page
|
||||
res.status(err.status || 500);
|
||||
res.render('error');
|
||||
// global error handler
|
||||
app.use(function (err: unknown, req: Request, res: Response, next: NextFunction) {
|
||||
// send any type of error to hawk server.
|
||||
if (process.env.HAWK_TOKEN_BACKEND && err instanceof Error) {
|
||||
HawkCatcher.send(err);
|
||||
}
|
||||
// only send Http based exception to client.
|
||||
if (err instanceof HttpException) {
|
||||
// set locals, only providing error in development
|
||||
res.locals.message = err.message;
|
||||
res.locals.error = req.app.get('env') === 'development' ? err : {};
|
||||
// render the error page
|
||||
res.status(err.status || 500);
|
||||
res.render('error');
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
|
||||
|
||||
export default app;
|
||||
|
|
85
src/backend/utils/downloadFavicon.ts
Normal file
85
src/backend/utils/downloadFavicon.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
/**
|
||||
* Uploaded favicon data
|
||||
*/
|
||||
export interface FaviconData {
|
||||
// Uploaded favicon path
|
||||
destination: string;
|
||||
|
||||
// File type
|
||||
type: string;
|
||||
}
|
||||
|
||||
// Initiate controller for aborting request
|
||||
const controller = new AbortController();
|
||||
|
||||
/**
|
||||
* Check if string is url
|
||||
*
|
||||
* @param str - string to check
|
||||
*/
|
||||
function checkIsUrl(str: string): boolean {
|
||||
const re = new RegExp('https?://');
|
||||
|
||||
return re.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload favicon by url, or get it by path
|
||||
*
|
||||
* @param destination - url or path of favicon
|
||||
* @param faviconFolder - folder to save favicon
|
||||
* @returns { Promise<FaviconData> } - Promise with data about favicon
|
||||
*/
|
||||
export async function downloadFavicon(destination: string, faviconFolder: string): Promise<FaviconData> {
|
||||
// Check of destination is empty
|
||||
if (!destination) {
|
||||
throw Error('Favicon destination is empty');
|
||||
}
|
||||
|
||||
// Get file name by destination
|
||||
const filename = destination.substring(destination.lastIndexOf('/')+1);
|
||||
|
||||
// Get file format
|
||||
const format = filename.split('.')[1];
|
||||
|
||||
// Check if string is url
|
||||
if (!checkIsUrl(destination)) {
|
||||
return {
|
||||
destination: `/${filename}`,
|
||||
type: `image/${format}`,
|
||||
} as FaviconData;
|
||||
}
|
||||
|
||||
// Create timeout to abort request
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
console.log('Favicon request has timed out.');
|
||||
}, 5000);
|
||||
|
||||
// Make get request to url
|
||||
const res = await fetch(destination, { signal: controller.signal });
|
||||
// Get buffer data from response
|
||||
const fileData = await res.buffer();
|
||||
|
||||
// Clear timeout, if data was got
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Get file path in temporary directory
|
||||
const filePath = path.join(faviconFolder, `favicon.${format}`);
|
||||
|
||||
// Save file
|
||||
await fs.writeFile(filePath, fileData, (err) => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
destination: `/favicon/favicon.${format}`,
|
||||
type: `image/${format}`,
|
||||
} as FaviconData;
|
||||
}
|
|
@ -7,28 +7,27 @@
|
|||
<meta property="og:title" content="{{ page.title | striptags }}" />
|
||||
<meta property="article:modified_time" content="{{ (page.body.time / 1000) | date("c") }}" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="icon" type="{{ favicon.type }}" href="{{ favicon.destination }}">
|
||||
</head>
|
||||
<script>
|
||||
window.config = {
|
||||
misprintsChatId: "{{ config.misprintsChatId }}"
|
||||
misprintsChatId: "{{ config.misprintsChatId }}",
|
||||
hawkClientToken:"{{ config.hawkClientToken }}",
|
||||
};
|
||||
</script>
|
||||
<body>
|
||||
{% include "components/header.twig" with res.locals.isAuthorized %}
|
||||
<div class="docs">
|
||||
|
||||
|
||||
{% include "components/sidebar.twig" %}
|
||||
|
||||
|
||||
<div class="docs__content">
|
||||
|
||||
|
||||
<div class="docs__content-inner">
|
||||
{% 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>
|
||||
|
|
|
@ -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,14 +13,18 @@
|
|||
}
|
||||
</textarea>
|
||||
<header class="writing-header">
|
||||
<span class="writing-header__left">
|
||||
<span>
|
||||
New Page at the
|
||||
<div class="writing-header__inner-container">
|
||||
{% set currentPageId = 0 %}
|
||||
{% if page is not empty %}
|
||||
{% set currentPageId = page._id %}
|
||||
{% endif %}
|
||||
<select name="parent">
|
||||
<div class="select-wrapper">
|
||||
{% if parentsChildrenOrdered is not empty %}
|
||||
<label for="parent">Parent Page</label>
|
||||
{% else %}
|
||||
<label for="parent">New Page at the</label>
|
||||
{% endif %}
|
||||
<select id="parent" name="parent">
|
||||
<option value="0">Root</option>
|
||||
{% for _page in pagesAvailableGrouped %}
|
||||
{% if _page._id != currentPageId %}
|
||||
|
@ -34,24 +38,25 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
{% if parentsChildrenOrdered is not empty %}
|
||||
<span>
|
||||
Put Above
|
||||
<select name="above">
|
||||
<div class="select-wrapper">
|
||||
<label for="above">Put Above</label>
|
||||
<select id="above" name="above">
|
||||
<option value="0">—</option>
|
||||
{% for _page in parentsChildrenOrdered %}
|
||||
<option value="{{ _page._id }}">{{ _page.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% if page is not empty %}
|
||||
<p><input type="text" class="uri-input" name="uri-input" placeholder="URI (Optional)" value="{{ page.uri }}"></p>
|
||||
{% endif %}
|
||||
{% if page is not empty %}
|
||||
<div class="uri-input-wrapper">
|
||||
<label for="uri-input">Alias</label>
|
||||
<input type="text" id="uri-input" class="uri-input" name="uri-input" placeholder="URI (Optional)" value="{{ page.uri }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<div class="writing-editor">
|
||||
<div id="editorjs"></div>
|
||||
|
|
|
@ -4,12 +4,14 @@
|
|||
<title>{{ config.title }}</title>
|
||||
<link rel="stylesheet" href="/dist/main.css" />
|
||||
<link rel="preload" href="{{ config.landingFrameSrc }}" as="document">
|
||||
<link rel="icon" type="image/png" href="/favicon.png?v=2">
|
||||
<link rel="icon" type="{{ favicon.type }}" href="{{ favicon.destination }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta property="og:title" content="{{ config.title }}" />
|
||||
<meta property="og:site_name" content="{{ config.title }}" />
|
||||
<meta name="description" property="og:description" content="{{ config.description }}">
|
||||
</head>
|
||||
<script>
|
||||
</script>
|
||||
<body class="landing-body">
|
||||
{% include "components/header.twig" %}
|
||||
<div class="landing-loader" id="frame-loader">
|
||||
|
|
13
src/frontend/.eslintrc
Normal file
13
src/frontend/.eslintrc
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": [
|
||||
"codex"
|
||||
],
|
||||
"parser": "babel-eslint",
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"allowImportExportEverywhere": true
|
||||
},
|
||||
"globals": {
|
||||
"HTMLElement": true
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@ import Writing from './modules/writing';
|
|||
import Page from './modules/page';
|
||||
import Extensions from './modules/extensions';
|
||||
import Sidebar from './modules/sidebar';
|
||||
import HawkCatcher from '@hawk.so/javascript';
|
||||
|
||||
/**
|
||||
* Main app class
|
||||
|
@ -30,6 +31,9 @@ class Docs {
|
|||
this.page = new Page();
|
||||
this.extensions = new Extensions();
|
||||
this.sidebar = new Sidebar();
|
||||
if (window.config.hawkClientToken) {
|
||||
this.hawk = new HawkCatcher(window.config.hawkClientToken);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', (event) => {
|
||||
this.docReady();
|
||||
|
|
405
src/frontend/js/classes/table-of-content.js
Normal file
405
src/frontend/js/classes/table-of-content.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
62
src/frontend/js/utils/decorators.js
Normal file
62
src/frontend/js/utils/decorators.js
Normal 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);
|
||||
};
|
||||
}
|
23
src/frontend/js/utils/dom.js
Normal file
23
src/frontend/js/utils/dom.js
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
58
src/frontend/styles/components/table-of-content.pcss
Normal file
58
src/frontend/styles/components/table-of-content.pcss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
.writing-header {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 15px 0;
|
||||
|
@ -15,18 +14,32 @@
|
|||
margin: auto;
|
||||
}
|
||||
|
||||
&__left {
|
||||
&__inner-container {
|
||||
margin: auto 0;
|
||||
display: flex;
|
||||
color: var(--color-text-second);
|
||||
gap: 10px;
|
||||
|
||||
& span {
|
||||
margin-right: 10px;
|
||||
@media(--mobile) {
|
||||
flex-flow: column;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
max-width: 100px;
|
||||
vertical-align: bottom;
|
||||
& > * {
|
||||
flex: 0 1 33.3%;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
select {
|
||||
@apply --select;
|
||||
}
|
||||
|
||||
input {
|
||||
@apply --input;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -38,16 +51,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.uri-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(201, 201, 204, 0.48);
|
||||
box-shadow: inset 0 1px 2px 0 rgba(35, 44, 72, 0.06);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.writing-editor {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@import 'normalize.css';
|
||||
|
||||
@import './vars.pcss';
|
||||
@import './layout.pcss';
|
||||
@import './carbon.pcss';
|
||||
|
@ -10,6 +11,7 @@
|
|||
@import './components/button.pcss';
|
||||
@import './components/sidebar.pcss';
|
||||
@import './components/navigator.pcss';
|
||||
@import './components/table-of-content.pcss';
|
||||
|
||||
body {
|
||||
font-family: system-ui, Helvetica, Arial, Verdana;
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
--color-link-hover: #F3F6F8;
|
||||
--color-bg-light: #f8f7fa;
|
||||
--color-page-active: #ff1767;
|
||||
--color-input-primary: #F3F6F8;
|
||||
--color-input-border: #477CFF;
|
||||
|
||||
--color-button-primary: #3389FF;
|
||||
--color-button-primary-hover: #2E7AE6;
|
||||
|
@ -78,6 +80,49 @@
|
|||
}
|
||||
}
|
||||
|
||||
--select {
|
||||
position: relative;
|
||||
padding: 10px 30px 10px 10px;
|
||||
width: 100%;
|
||||
border: solid 1px transparent;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-input-primary);
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
line-height: 18px;
|
||||
background-image: url("../svg/arrow-down.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 15px center;
|
||||
|
||||
&:focus {
|
||||
border: solid 1px var(--color-input-border);
|
||||
box-shadow: 0 0 0 3px rgba(18, 155, 255, 0.33);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
--input {
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
line-height: 18px;
|
||||
border: solid 1px transparent;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-input-primary);
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border: solid 1px var(--color-input-border);
|
||||
box-shadow: 0 0 0 3px rgba(18, 155, 255, 0.33);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
--squircle {
|
||||
border-radius: 8px;
|
||||
|
||||
|
@ -96,4 +141,4 @@
|
|||
@custom-media --tablet all and (min-width: 980px) and (max-width: 1050px);
|
||||
@custom-media --mobile all and (max-width: 980px);
|
||||
@custom-media --retina all and (-webkit-min-device-pixel-ratio: 1.5);
|
||||
@custom-media --can-hover all and (hover:hover)
|
||||
@custom-media --can-hover all and (hover:hover);
|
||||
|
|
3
src/frontend/svg/arrow-down.svg
Normal file
3
src/frontend/svg/arrow-down.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="8" height="5" viewBox="0 0 8 5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.83053 0.193862C6.70562 0.0696944 6.53666 0 6.36053 0C6.18441 0 6.01544 0.0696944 5.89053 0.193862L3.4972 2.55386L1.1372 0.193862C1.01229 0.0696944 0.843323 0 0.667199 0C0.491075 0 0.322107 0.0696944 0.197199 0.193862C0.134713 0.255837 0.0851169 0.329571 0.0512711 0.410811C0.0174253 0.49205 0 0.579187 0 0.667195C0 0.755203 0.0174253 0.84234 0.0512711 0.92358C0.0851169 1.00482 0.134713 1.07855 0.197199 1.14053L3.02387 3.9672C3.08584 4.02968 3.15957 4.07928 3.24081 4.11312C3.32205 4.14697 3.40919 4.16439 3.4972 4.16439C3.58521 4.16439 3.67234 4.14697 3.75358 4.11312C3.83482 4.07928 3.90856 4.02968 3.97053 3.9672L6.83053 1.14053C6.89302 1.07855 6.94261 1.00482 6.97646 0.92358C7.0103 0.84234 7.02773 0.755203 7.02773 0.667195C7.02773 0.579187 7.0103 0.49205 6.97646 0.410811C6.94261 0.329571 6.89302 0.255837 6.83053 0.193862Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 958 B |
|
@ -40,7 +40,7 @@ module.exports = () => {
|
|||
options: {
|
||||
// you can specify a publicPath here
|
||||
// by default it use publicPath in webpackOptions.output
|
||||
publicPath: '../',
|
||||
// publicPath: '../',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue