mirror of
https://github.com/codex-team/codex.docs.git
synced 2025-08-08 06:55:26 +02:00
Merge 86d26baa8e
into 51fb6655e2
This commit is contained in:
commit
f963f133f6
14 changed files with 771 additions and 6 deletions
|
@ -8,5 +8,5 @@
|
||||||
"watch": [
|
"watch": [
|
||||||
"**/*"
|
"**/*"
|
||||||
],
|
],
|
||||||
"ext": "js,twig"
|
"ext": "ts,js,twig"
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,6 +47,7 @@
|
||||||
"@babel/polyfill": "^7.12.1",
|
"@babel/polyfill": "^7.12.1",
|
||||||
"@babel/preset-env": "^7.16.11",
|
"@babel/preset-env": "^7.16.11",
|
||||||
"@codexteam/misprints": "^1.0.0",
|
"@codexteam/misprints": "^1.0.0",
|
||||||
|
"@codexteam/shortcuts": "^1.2.0",
|
||||||
"@editorjs/checklist": "^1.3.0",
|
"@editorjs/checklist": "^1.3.0",
|
||||||
"@editorjs/code": "^2.7.0",
|
"@editorjs/code": "^2.7.0",
|
||||||
"@editorjs/delimiter": "^1.2.0",
|
"@editorjs/delimiter": "^1.2.0",
|
||||||
|
@ -83,6 +84,7 @@
|
||||||
"@types/sinon": "^10.0.2",
|
"@types/sinon": "^10.0.2",
|
||||||
"@types/twig": "^1.12.6",
|
"@types/twig": "^1.12.6",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
|
"axios": "^0.27.2",
|
||||||
"babel": "^6.23.0",
|
"babel": "^6.23.0",
|
||||||
"babel-eslint": "^10.0.1",
|
"babel-eslint": "^10.0.1",
|
||||||
"babel-loader": "^8.2.3",
|
"babel-loader": "^8.2.3",
|
||||||
|
|
302
src/backend/controllers/search.ts
Normal file
302
src/backend/controllers/search.ts
Normal file
|
@ -0,0 +1,302 @@
|
||||||
|
import PageData from '../models/page.js';
|
||||||
|
import Pages from '../controllers/pages.js';
|
||||||
|
import urlify from '../utils/urlify.js';
|
||||||
|
import Page from '../models/page.js';
|
||||||
|
|
||||||
|
let globalWords: { [key: string]: {[key: string]: number} } = Object.create(null);
|
||||||
|
let globalPages: PageData[] = [];
|
||||||
|
|
||||||
|
class Search {
|
||||||
|
/**
|
||||||
|
* Initialize search
|
||||||
|
*/
|
||||||
|
public async init() {
|
||||||
|
if (globalWords && Object.keys(globalWords).length) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.syncDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all pages from DB and update globalWords
|
||||||
|
* Use this method when any page was updated
|
||||||
|
*/
|
||||||
|
public async syncDB() {
|
||||||
|
globalWords = Object.create(null);
|
||||||
|
globalPages = await this.getPages();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all pages
|
||||||
|
*/
|
||||||
|
for await (const page of globalPages) {
|
||||||
|
/**
|
||||||
|
* Read content blocks from page
|
||||||
|
*/
|
||||||
|
for await (const block of page.body.blocks) {
|
||||||
|
const blockRatio = this.getBlockRatio(block);
|
||||||
|
const blockContent = this.getCleanTextFromBlock(block);
|
||||||
|
const blockWords: string[] = this.splitTextToWords(blockContent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process list of words in a block
|
||||||
|
*/
|
||||||
|
for await (const word of blockWords) {
|
||||||
|
if (!globalWords[word]) {
|
||||||
|
globalWords[word] = Object.create(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page._id) {
|
||||||
|
if (!globalWords[word][page._id]) {
|
||||||
|
globalWords[word][page._id] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add page id to the list of pages with this word
|
||||||
|
*/
|
||||||
|
globalWords[word][page._id] += blockRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for pages by given query
|
||||||
|
* @param searchString
|
||||||
|
*/
|
||||||
|
public async query(searchString: string) {
|
||||||
|
await this.init();
|
||||||
|
|
||||||
|
const searchWords = this.splitTextToWords(searchString);
|
||||||
|
|
||||||
|
const goodPages = (await this.getPagesByWords(searchWords))
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const returnPages: {[key: string]: string|number, ratio: number}[] = [];
|
||||||
|
|
||||||
|
goodPages.forEach(({ pageId, ratio }) => {
|
||||||
|
const page = globalPages.filter(page => page._id === pageId).pop();
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let section = '';
|
||||||
|
|
||||||
|
page.body.blocks.forEach((block: any) => {
|
||||||
|
let koef = 1;
|
||||||
|
|
||||||
|
let blockContent = this.getCleanTextFromBlock(block);
|
||||||
|
|
||||||
|
let shortBody = blockContent;
|
||||||
|
|
||||||
|
if (block.type === 'header') {
|
||||||
|
section = blockContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchWords.forEach(word => {
|
||||||
|
if (blockContent.toLowerCase().indexOf(word) !== -1) {
|
||||||
|
koef *= 10;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
shortBody = this.highlightSubstring(shortBody, searchWords);
|
||||||
|
|
||||||
|
if (koef > 0) {
|
||||||
|
returnPages.push({
|
||||||
|
...page,
|
||||||
|
shortBody,
|
||||||
|
anchor: urlify(section),
|
||||||
|
section,
|
||||||
|
ratio: ratio * koef,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: ['description', 'about', 'contact'],
|
||||||
|
pages: returnPages
|
||||||
|
.sort((a, b) => b.ratio - a.ratio)
|
||||||
|
.slice(0, 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async getPages(): Promise<Page[]> {
|
||||||
|
return await Pages.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return list of pages with a given words
|
||||||
|
* @param words
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async getPagesByWords(words: string[]) {
|
||||||
|
const pagesList: {[key: string]: number} = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of words starting with a words from the search query
|
||||||
|
*/
|
||||||
|
const validWords = Object.keys(globalWords)
|
||||||
|
.filter(word => {
|
||||||
|
return !!words.filter(searchWord => word.indexOf(searchWord) !== -1).length
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each word get list of pages with this word
|
||||||
|
*/
|
||||||
|
validWords.forEach(word => {
|
||||||
|
Object.keys(globalWords[word])
|
||||||
|
.forEach(pageId => {
|
||||||
|
if (!pagesList[pageId]) {
|
||||||
|
pagesList[pageId] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesList[pageId] += globalWords[word][pageId]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort pages by frequency of given words
|
||||||
|
*/
|
||||||
|
const sortedPagesList = Object.keys(pagesList)
|
||||||
|
.map(pageId => {
|
||||||
|
return {
|
||||||
|
pageId,
|
||||||
|
ratio: pagesList[pageId]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.ratio - a.ratio);
|
||||||
|
|
||||||
|
return sortedPagesList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get block's ratio. It is used to calculate the weight of the words in the block
|
||||||
|
* @param block
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private getBlockRatio(block: any) {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'header':
|
||||||
|
if (block.data.level === 1) {
|
||||||
|
return 16;
|
||||||
|
} else {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'paragraph':
|
||||||
|
return 1.1;
|
||||||
|
|
||||||
|
case 'list':
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return clear text content from block without HTML tags and special characters
|
||||||
|
* @param block
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private getCleanTextFromBlock(block: any): string {
|
||||||
|
let blockContent = '';
|
||||||
|
|
||||||
|
switch (block.type) {
|
||||||
|
case 'header':
|
||||||
|
blockContent = block.data.text;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'paragraph':
|
||||||
|
blockContent = block.data.text
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'list':
|
||||||
|
blockContent = block.data.items.join(' ');
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return blockContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockContent = this.removeHTMLTags(blockContent);
|
||||||
|
blockContent = this.removeHTMLSpecialCharacters(blockContent);
|
||||||
|
|
||||||
|
return blockContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove HTML tags from string. Only content inside tags will be left
|
||||||
|
* @param text
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private removeHTMLTags(text: string) {
|
||||||
|
return text.replace(/<[^>]*>?/gm, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove special characters from text. For example: & " < >
|
||||||
|
* @param text
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private removeHTMLSpecialCharacters(text: string) {
|
||||||
|
return text.replace(/&[^;]*;?/gm, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split text to words
|
||||||
|
* @param text
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private splitTextToWords(text: string): string[] {
|
||||||
|
return text
|
||||||
|
// lowercase all words
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
// remove punctuation
|
||||||
|
.replace(/[.,;:]/gi, '')
|
||||||
|
|
||||||
|
// left only letters (+cyrillic) and numbers
|
||||||
|
.replace(/[^a-zа-я0-9]/gi, ' ')
|
||||||
|
|
||||||
|
// remove multiple spaces
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
|
||||||
|
// remove spaces at the beginning and at the end
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
// split to words by spaces
|
||||||
|
.split(' ')
|
||||||
|
|
||||||
|
// ignore words shorter than 3 chars
|
||||||
|
.filter(word => word.length >= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight substring in string with a span wrapper
|
||||||
|
*/
|
||||||
|
private highlightSubstring(text: string, words: string|string[]) {
|
||||||
|
if (typeof words === 'string') {
|
||||||
|
words = [words];
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordRegExp = new RegExp(words.join('|'), "ig");
|
||||||
|
const CLASS_STYLE = 'search-word';
|
||||||
|
|
||||||
|
return text.replace(wordRegExp, `<span class="${CLASS_STYLE}">$&</span>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export initialized instance
|
||||||
|
*/
|
||||||
|
export default new Search();
|
|
@ -1,12 +1,15 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import pagesAPI from './pages.js';
|
import pagesAPI from './pages.js';
|
||||||
import transportAPI from './transport.js';
|
import transportAPI from './transport.js';
|
||||||
import linksAPI from './links.js';
|
import linksAPI from './links.js';
|
||||||
|
import searchAPI from './search.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use('/', pagesAPI);
|
router.use('/', pagesAPI);
|
||||||
router.use('/', transportAPI);
|
router.use('/', transportAPI);
|
||||||
router.use('/', linksAPI);
|
router.use('/', linksAPI);
|
||||||
|
router.use('/', searchAPI);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -2,6 +2,7 @@ import express, { Request, Response } from 'express';
|
||||||
import multerFunc from 'multer';
|
import multerFunc from 'multer';
|
||||||
import Pages from '../../controllers/pages.js';
|
import Pages from '../../controllers/pages.js';
|
||||||
import PagesOrder from '../../controllers/pagesOrder.js';
|
import PagesOrder from '../../controllers/pagesOrder.js';
|
||||||
|
import Search from '../../controllers/search.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const multer = multerFunc();
|
const multer = multerFunc();
|
||||||
|
@ -70,6 +71,9 @@ router.put('/page', multer.none(), async (req: Request, res: Response) => {
|
||||||
/** push to the orders array */
|
/** push to the orders array */
|
||||||
await PagesOrder.push(parent, page._id);
|
await PagesOrder.push(parent, page._id);
|
||||||
|
|
||||||
|
/** Update search index */
|
||||||
|
await Search.syncDB();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: page,
|
result: page,
|
||||||
|
@ -127,6 +131,10 @@ router.post('/page/:id', multer.none(), async (req: Request, res: Response) => {
|
||||||
parent,
|
parent,
|
||||||
uri,
|
uri,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Update search index */
|
||||||
|
await Search.syncDB();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: page,
|
result: page,
|
||||||
|
@ -206,6 +214,9 @@ router.delete('/page/:id', async (req: Request, res: Response) => {
|
||||||
parentPageOrder.remove(req.params.id);
|
parentPageOrder.remove(req.params.id);
|
||||||
await parentPageOrder.save();
|
await parentPageOrder.save();
|
||||||
|
|
||||||
|
/** Update search index */
|
||||||
|
await Search.syncDB();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: pageToRedirect,
|
result: pageToRedirect,
|
||||||
|
|
66
src/backend/routes/api/search.ts
Normal file
66
src/backend/routes/api/search.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import Search from '../../controllers/search.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /search/:searchString
|
||||||
|
*
|
||||||
|
* Search given words in all documents
|
||||||
|
*/
|
||||||
|
router.get('/search', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* Get search string
|
||||||
|
*/
|
||||||
|
const searchString = req.query.text as string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get search results
|
||||||
|
*/
|
||||||
|
const searchResponse = await Search.query(searchString);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose response
|
||||||
|
*/
|
||||||
|
const compactedPages = searchResponse.pages.map(page => {
|
||||||
|
return {
|
||||||
|
/** Page id */
|
||||||
|
_id: page._id,
|
||||||
|
|
||||||
|
/** Page title */
|
||||||
|
title: page.title,
|
||||||
|
|
||||||
|
/** Page uri */
|
||||||
|
uri: page.uri,
|
||||||
|
|
||||||
|
/** Section heading name for the found fragment */
|
||||||
|
section: page.section,
|
||||||
|
|
||||||
|
/** Section's anchor */
|
||||||
|
anchor: page.anchor,
|
||||||
|
|
||||||
|
/** Page fragment with searched items */
|
||||||
|
shortBody: page.shortBody,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
/** Found pages */
|
||||||
|
pages: compactedPages,
|
||||||
|
|
||||||
|
/** Typing suggestions */
|
||||||
|
suggestions: searchResponse.suggestions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: (err as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
|
@ -15,7 +15,7 @@
|
||||||
hawkClientToken:"{{ config.hawkClientToken }}",
|
hawkClientToken:"{{ config.hawkClientToken }}",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<body>
|
<body data-module="search">
|
||||||
{% include "components/header.twig" with res.locals.isAuthorized %}
|
{% include "components/header.twig" with res.locals.isAuthorized %}
|
||||||
<div class="docs">
|
<div class="docs">
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
{% include 'components/button.twig' with {label: 'Edit', icon: 'pencil', size: 'small', url: '/page/edit/' ~ page._id, class: 'page__header-button'} %}
|
{% include 'components/button.twig' with {label: 'Edit', icon: 'pencil', size: 'small', url: '/page/edit/' ~ page._id, class: 'page__header-button'} %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
<h1 class="page__title">
|
<h1 class="page__title" id="{{ page.title | urlify }}">
|
||||||
{{ page.title }}
|
{{ page.title }}
|
||||||
</h1>
|
</h1>
|
||||||
{% if (config.carbon and config.carbon.placement and config.carbon.serve) %}
|
{% if (config.carbon and config.carbon.placement and config.carbon.serve) %}
|
||||||
|
|
|
@ -17,6 +17,7 @@ import Writing from './modules/writing';
|
||||||
import Page from './modules/page';
|
import Page from './modules/page';
|
||||||
import Extensions from './modules/extensions';
|
import Extensions from './modules/extensions';
|
||||||
import Sidebar from './modules/sidebar';
|
import Sidebar from './modules/sidebar';
|
||||||
|
import Search from './modules/search';
|
||||||
import HawkCatcher from '@hawk.so/javascript';
|
import HawkCatcher from '@hawk.so/javascript';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -31,6 +32,8 @@ class Docs {
|
||||||
this.page = new Page();
|
this.page = new Page();
|
||||||
this.extensions = new Extensions();
|
this.extensions = new Extensions();
|
||||||
this.sidebar = new Sidebar();
|
this.sidebar = new Sidebar();
|
||||||
|
this.search = new Search();
|
||||||
|
|
||||||
if (window.config.hawkClientToken) {
|
if (window.config.hawkClientToken) {
|
||||||
this.hawk = new HawkCatcher(window.config.hawkClientToken);
|
this.hawk = new HawkCatcher(window.config.hawkClientToken);
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ export default class Page {
|
||||||
init() {
|
init() {
|
||||||
this.codeStyler = this.createCodeStyling();
|
this.codeStyler = this.createCodeStyling();
|
||||||
this.tableOfContent = this.createTableOfContent();
|
this.tableOfContent = this.createTableOfContent();
|
||||||
|
this.search = this.createSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
238
src/frontend/js/modules/search.js
Normal file
238
src/frontend/js/modules/search.js
Normal file
|
@ -0,0 +1,238 @@
|
||||||
|
import { debounce } from '../utils/decorators';
|
||||||
|
import Shortcut from '@codexteam/shortcuts';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export default class Search {
|
||||||
|
constructor() {
|
||||||
|
this.nodes = {
|
||||||
|
overlay: null,
|
||||||
|
searchWrapper: null,
|
||||||
|
searchInput: null,
|
||||||
|
searchResultWrapper: null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.isVisible = false;
|
||||||
|
|
||||||
|
this.PLACEHOLDER = 'Search docs';
|
||||||
|
|
||||||
|
this.TOGGLER_SHORTCUT = 'CMD+SHIFT+F';
|
||||||
|
this.shortcut = null;
|
||||||
|
|
||||||
|
this.DEBOUNCE_TIME = 300;
|
||||||
|
this.debouncedSearch = null;
|
||||||
|
|
||||||
|
this.MIN_SEARCH_LENGTH = 1;
|
||||||
|
|
||||||
|
this.CSS = {
|
||||||
|
overlay: 'search-overlay',
|
||||||
|
overlayVisible: 'search-overlay--visible',
|
||||||
|
searchWrapper: 'search-wrapper',
|
||||||
|
searchInput: 'search-input',
|
||||||
|
searchResultWrapper: 'search-result-wrapper',
|
||||||
|
|
||||||
|
searchResultSuggestions: 'search-result-suggestions',
|
||||||
|
searchResultSuggestionItem: 'search-result-suggestions-item',
|
||||||
|
|
||||||
|
searchResultItem: 'search-result-item',
|
||||||
|
searchResultItemTitle: 'search-result-item__title',
|
||||||
|
searchResultItemSection: 'search-result-item__section',
|
||||||
|
searchResultItemDescription: 'search-result-item__description',
|
||||||
|
|
||||||
|
blurred: 'blurred',
|
||||||
|
noscroll: 'noscroll'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init(settings = {}, moduleEl) {
|
||||||
|
console.log('search init');
|
||||||
|
|
||||||
|
this.createSearchOverlay();
|
||||||
|
this.createDebouncedSearch();
|
||||||
|
this.enableShortcutListening();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only for development needs:
|
||||||
|
*
|
||||||
|
* - force open overlay
|
||||||
|
* - type a search string
|
||||||
|
* - fire search
|
||||||
|
*/
|
||||||
|
// const testString = 'codex descri';
|
||||||
|
// this.toggleSearchOverlay(true);
|
||||||
|
// this.nodes.searchInput.value = testString;
|
||||||
|
// this.debouncedSearch(testString);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSearchOverlay() {
|
||||||
|
this.nodes.overlay = document.createElement('div');
|
||||||
|
this.nodes.overlay.classList.add(this.CSS.overlay);
|
||||||
|
this.nodes.overlay.addEventListener('click', this.searchOverlayClickProcessor.bind(this));
|
||||||
|
|
||||||
|
this.nodes.searchWrapper = document.createElement('div');
|
||||||
|
this.nodes.searchWrapper.classList.add(this.CSS.searchWrapper);
|
||||||
|
|
||||||
|
this.nodes.searchInput = document.createElement('input');
|
||||||
|
this.nodes.searchInput.classList.add(this.CSS.searchInput);
|
||||||
|
this.nodes.searchInput.setAttribute('type', 'search');
|
||||||
|
this.nodes.searchInput.setAttribute('placeholder', this.PLACEHOLDER);
|
||||||
|
this.nodes.searchInput.setAttribute('autocomplete', 'off');
|
||||||
|
this.nodes.searchInput.addEventListener('input', this.searchInputOnchangeProcessor.bind(this));
|
||||||
|
this.nodes.searchWrapper.appendChild(this.nodes.searchInput);
|
||||||
|
|
||||||
|
this.nodes.searchResultWrapper = document.createElement('div');
|
||||||
|
this.nodes.searchResultWrapper.classList.add(this.CSS.searchResultWrapper);
|
||||||
|
this.nodes.searchWrapper.appendChild(this.nodes.searchResultWrapper);
|
||||||
|
|
||||||
|
this.nodes.overlay.appendChild(this.nodes.searchWrapper);
|
||||||
|
document.body.appendChild(this.nodes.overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchOverlayClickProcessor(event) {
|
||||||
|
if (event.target !== this.nodes.overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toggleSearchOverlay(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInputOnchangeProcessor(event) {
|
||||||
|
const text = event.target.value;
|
||||||
|
|
||||||
|
if (text.length < this.MIN_SEARCH_LENGTH) {
|
||||||
|
this.clearSearchResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debouncedSearch(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableShortcutListening() {
|
||||||
|
this.shortcut = new Shortcut({
|
||||||
|
name : this.TOGGLER_SHORTCUT,
|
||||||
|
on : document.body,
|
||||||
|
callback: (event) => {
|
||||||
|
this.toggleSearchOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSearchOverlay(force) {
|
||||||
|
this.isVisible = force || !this.isVisible;
|
||||||
|
|
||||||
|
this.nodes.overlay.classList.toggle(this.CSS.overlayVisible, this.isVisible);
|
||||||
|
document.body.classList.toggle(this.CSS.noscroll, this.isVisible);
|
||||||
|
|
||||||
|
// blur everything except search overlay
|
||||||
|
try {
|
||||||
|
document.getElementsByClassName('docs-header')[0].classList.toggle(this.CSS.blurred, this.isVisible);
|
||||||
|
document.getElementsByClassName('docs')[0].classList.toggle(this.CSS.blurred, this.isVisible);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
this.nodes.searchInput.focus();
|
||||||
|
this.nodes.searchInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
createDebouncedSearch() {
|
||||||
|
this.debouncedSearch = debounce(this.getSearchResults, this.DEBOUNCE_TIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSearchResults(text) {
|
||||||
|
|
||||||
|
axios.get('/api/search', {
|
||||||
|
params: {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(this.showSearchResult.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearchResults() {
|
||||||
|
this.nodes.searchResultWrapper.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
showSearchResult({ data }) {
|
||||||
|
this.clearSearchResults();
|
||||||
|
|
||||||
|
// if (data.result.suggestions.length) {
|
||||||
|
// this.showSuggestedWordCompletion(data.result.suggestions[0]);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const suggestionsWrapper = this.generateSearchSuggestions(data.result.suggestions);
|
||||||
|
//
|
||||||
|
// this.nodes.searchResultWrapper.appendChild(suggestionsWrapper);
|
||||||
|
|
||||||
|
data.result.pages.forEach(page => {
|
||||||
|
const url = `/${page.uri}` + (page.section ? `#${page.anchor}` : '');
|
||||||
|
|
||||||
|
const result = document.createElement('a');
|
||||||
|
result.classList.add(this.CSS.searchResultItem);
|
||||||
|
result.setAttribute('href', url);
|
||||||
|
result.addEventListener('click', this.toggleSearchOverlay.bind(this, false));
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.classList.add(this.CSS.searchResultItemTitle);
|
||||||
|
title.innerHTML = page.title;
|
||||||
|
result.appendChild(title);
|
||||||
|
|
||||||
|
if (page.section && page.section !== page.title) {
|
||||||
|
const section = document.createElement('span');
|
||||||
|
section.classList.add(this.CSS.searchResultItemSection);
|
||||||
|
section.innerHTML = `${page.section}`;
|
||||||
|
title.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = document.createElement('div');
|
||||||
|
description.classList.add(this.CSS.searchResultItemDescription);
|
||||||
|
description.innerHTML = `${page.shortBody}`;
|
||||||
|
result.appendChild(description);
|
||||||
|
|
||||||
|
this.nodes.searchResultWrapper.appendChild(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// showSuggestedWordCompletion(word) {
|
||||||
|
// const typedString = this.nodes.searchInput.value;
|
||||||
|
// const words = typedString.split(' ');
|
||||||
|
//
|
||||||
|
// words.pop();
|
||||||
|
// words.push(word);
|
||||||
|
//
|
||||||
|
// this.nodes.searchInput.value = words.join(' ');
|
||||||
|
//
|
||||||
|
// this.nodes.searchInput.select();
|
||||||
|
// this.nodes.searchInput.selectionStart = typedString.length;
|
||||||
|
// this.nodes.searchInput.selectionEnd = this.nodes.searchInput.value.length;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// generateSearchSuggestions(suggestions = []) {
|
||||||
|
// const suggestionsWrapper = document.createElement('div');
|
||||||
|
//
|
||||||
|
// suggestionsWrapper.classList.add(this.CSS.searchResultSuggestions);
|
||||||
|
//
|
||||||
|
// suggestions.forEach(suggestion => {
|
||||||
|
// const suggestionItem = document.createElement('span');
|
||||||
|
//
|
||||||
|
// suggestionItem.classList.add(this.CSS.searchResultSuggestionItem);
|
||||||
|
// suggestionItem.innerHTML = suggestion;
|
||||||
|
// suggestionItem.addEventListener('click', this.searchSuggestionClickProcessor.bind(this));
|
||||||
|
//
|
||||||
|
// suggestionsWrapper.appendChild(suggestionItem);
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return suggestionsWrapper;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// searchSuggestionClickProcessor(event) {
|
||||||
|
// const word = event.target.innerHTML;
|
||||||
|
//
|
||||||
|
// const searchString = this.nodes.searchInput.value;
|
||||||
|
// const searchStringWords = searchString.split(' ');
|
||||||
|
//
|
||||||
|
// searchStringWords.pop();
|
||||||
|
// searchStringWords.push(word);
|
||||||
|
//
|
||||||
|
// this.nodes.searchInput.value = searchStringWords.join(' ');
|
||||||
|
//
|
||||||
|
// this.debouncedSearch(this.nodes.searchInput.value);
|
||||||
|
// }
|
||||||
|
}
|
119
src/frontend/styles/components/search.pcss
Normal file
119
src/frontend/styles/components/search.pcss
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
.search {
|
||||||
|
&-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: none;
|
||||||
|
overflow-y: scroll;
|
||||||
|
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&--visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-wrapper {
|
||||||
|
background: #fff;
|
||||||
|
margin: 30vh auto 20vh;
|
||||||
|
max-width: var(--layout-width-main-col);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-input {
|
||||||
|
width: 100%;
|
||||||
|
background: #bbb;
|
||||||
|
padding: 15px 25px;
|
||||||
|
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-result {
|
||||||
|
&-wrapper {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&-suggestions {
|
||||||
|
padding: 5px 25px;
|
||||||
|
overflow-x: scroll;
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
margin-right: 12px;
|
||||||
|
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #ccedf6;
|
||||||
|
border-radius: 50px;
|
||||||
|
border: 1px solid #b5d8e2;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #aad4e0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
padding: 15px 25px;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
&:nth-child(odd) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: '•';
|
||||||
|
margin: 0 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-word {
|
||||||
|
color: var(--color-link-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.noscroll {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blurred {
|
||||||
|
filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
@import './components/sidebar.pcss';
|
@import './components/sidebar.pcss';
|
||||||
@import './components/navigator.pcss';
|
@import './components/navigator.pcss';
|
||||||
@import './components/table-of-content.pcss';
|
@import './components/table-of-content.pcss';
|
||||||
|
@import './components/search.pcss';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, Helvetica, Arial, Verdana;
|
font-family: system-ui, Helvetica, Arial, Verdana;
|
||||||
|
|
25
yarn.lock
25
yarn.lock
|
@ -1732,6 +1732,14 @@ axios@^0.21.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects "^1.14.0"
|
follow-redirects "^1.14.0"
|
||||||
|
|
||||||
|
axios@^0.27.2:
|
||||||
|
version "0.27.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972"
|
||||||
|
integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==
|
||||||
|
dependencies:
|
||||||
|
follow-redirects "^1.14.9"
|
||||||
|
form-data "^4.0.0"
|
||||||
|
|
||||||
babel-eslint@^10.0.1:
|
babel-eslint@^10.0.1:
|
||||||
version "10.1.0"
|
version "10.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
|
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
|
||||||
|
@ -2080,7 +2088,8 @@ clone-response@^1.0.2:
|
||||||
|
|
||||||
clone@2.x:
|
clone@2.x:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz"
|
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
|
||||||
|
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
|
||||||
|
|
||||||
code-point-at@^1.0.0:
|
code-point-at@^1.0.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
|
@ -3162,7 +3171,7 @@ flatten@^1.0.2:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b"
|
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b"
|
||||||
|
|
||||||
follow-redirects@^1.14.0:
|
follow-redirects@^1.14.0, follow-redirects@^1.14.9:
|
||||||
version "1.15.1"
|
version "1.15.1"
|
||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5"
|
||||||
|
|
||||||
|
@ -3193,6 +3202,15 @@ form-data@^3.0.0:
|
||||||
combined-stream "^1.0.8"
|
combined-stream "^1.0.8"
|
||||||
mime-types "^2.1.12"
|
mime-types "^2.1.12"
|
||||||
|
|
||||||
|
form-data@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
|
||||||
|
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
|
||||||
|
dependencies:
|
||||||
|
asynckit "^0.4.0"
|
||||||
|
combined-stream "^1.0.8"
|
||||||
|
mime-types "^2.1.12"
|
||||||
|
|
||||||
formdata-polyfill@^4.0.10:
|
formdata-polyfill@^4.0.10:
|
||||||
version "4.0.10"
|
version "4.0.10"
|
||||||
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
|
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
|
||||||
|
@ -4310,7 +4328,8 @@ nise@^5.1.1:
|
||||||
|
|
||||||
node-cache@^5.1.2:
|
node-cache@^5.1.2:
|
||||||
version "5.1.2"
|
version "5.1.2"
|
||||||
resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz"
|
resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d"
|
||||||
|
integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==
|
||||||
dependencies:
|
dependencies:
|
||||||
clone "2.x"
|
clone "2.x"
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue