diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 39d19a619..1b468ef21 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -681,7 +681,7 @@ "noSelection": "Select an entry on the left side to show the instructions." }, "categories": { - "basic": "Basics", + "basics": "Basics", "other": "Other", "embedding": "Embedding", "charts": "Charts & Diagrams" diff --git a/frontend/package.json b/frontend/package.json index 11c10d4b1..9cf333e89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,9 +19,9 @@ "test:e2e:open": "cypress open", "test:e2e": "cypress run --browser chrome", "test:e2e:ci": "cypress run --browser chrome --record true --parallel --group \"chrome\"", - "test:watch": "cross-env NODE_ENV=test jest --watch", - "test:ci": "cross-env NODE_ENV=test jest --coverage", - "test": "cross-env NODE_ENV=test jest" + "test:watch": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_ENV=test jest --watch", + "test:ci": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_ENV=test jest --coverage", + "test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_ENV=test jest" }, "browserslist": { "production": [ @@ -53,6 +53,7 @@ "@hedgedoc/html-to-react": "workspace:html-to-react", "@hedgedoc/markdown-it-plugins": "workspace:markdown-it-plugins", "@mrdrogdrog/optional": "1.2.1", + "@orama/orama": "2.0.0-beta.12", "@react-hook/resize-observer": "1.2.6", "@redux-devtools/core": "3.13.3", "@reduxjs/toolkit": "1.9.7", @@ -75,7 +76,6 @@ "eventemitter2": "6.4.9", "fast-deep-equal": "3.1.3", "firacode": "6.2.0", - "flexsearch-ts": "0.7.35", "flowchart.js": "1.17.1", "highlight.js": "11.9.0", "htmlparser2": "9.0.0", diff --git a/frontend/src/components/cheatsheet/cheatsheet-content.tsx b/frontend/src/components/cheatsheet/cheatsheet-content.tsx index e042e6e37..424fc8e00 100644 --- a/frontend/src/components/cheatsheet/cheatsheet-content.tsx +++ b/frontend/src/components/cheatsheet/cheatsheet-content.tsx @@ -5,8 +5,8 @@ */ import { CategoryAccordion } from './category-accordion' import { CheatsheetEntryPane } from './cheatsheet-entry-pane' -import type { CheatsheetSingleEntry, CheatsheetExtension } from './cheatsheet-extension' -import { hasCheatsheetTopics } from './cheatsheet-extension' +import type { CheatsheetExtension, CheatsheetEntry } from './cheatsheet-extension' +import { isCheatsheetMultiEntry } from './cheatsheet-extension' import { CheatsheetSearch } from './cheatsheet-search' import styles from './cheatsheet.module.scss' import { TopicSelection } from './topic-selection' @@ -20,11 +20,11 @@ import { Trans } from 'react-i18next' export const CheatsheetContent: React.FC = () => { const [visibleExtensions, setVisibleExtensions] = useState([]) const [selectedExtension, setSelectedExtension] = useState() - const [selectedEntry, setSelectedEntry] = useState() + const [selectedEntry, setSelectedEntry] = useState() const changeExtension = useCallback((value: CheatsheetExtension) => { setSelectedExtension(value) - setSelectedEntry(hasCheatsheetTopics(value) ? value.topics[0] : value) + setSelectedEntry(isCheatsheetMultiEntry(value) ? value.topics[0] : value) }, []) return ( @@ -44,10 +44,10 @@ export const CheatsheetContent: React.FC = () => { selectedEntry={selectedEntry} setSelectedEntry={setSelectedEntry} /> - {selectedEntry !== undefined ? ( + {selectedEntry !== undefined && selectedExtension !== undefined ? ( ) : ( diff --git a/frontend/src/components/cheatsheet/cheatsheet-entry-pane.tsx b/frontend/src/components/cheatsheet/cheatsheet-entry-pane.tsx index 8e1624324..2dacbc904 100644 --- a/frontend/src/components/cheatsheet/cheatsheet-entry-pane.tsx +++ b/frontend/src/components/cheatsheet/cheatsheet-entry-pane.tsx @@ -9,7 +9,7 @@ import { RendererIframe } from '../common/renderer-iframe/renderer-iframe' import { EditorToRendererCommunicatorContextProvider } from '../editor-page/render-context/editor-to-renderer-communicator-context-provider' import { ExtensionEventEmitterProvider } from '../markdown-renderer/hooks/use-extension-event-emitter' import { RendererType } from '../render-page/window-post-message-communicator/rendering-message' -import type { CheatsheetSingleEntry } from './cheatsheet-extension' +import type { CheatsheetEntry } from './cheatsheet-extension' import { ReadMoreLinkItem } from './read-more-link-item' import { useComponentsFromAppExtensions } from './use-components-from-app-extensions' import MarkdownIt from 'markdown-it' @@ -18,8 +18,8 @@ import { ListGroupItem } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' interface CheatsheetRendererProps { - rootI18nKey?: string - extension: CheatsheetSingleEntry + i18nKeyPrefix?: string + entry: CheatsheetEntry } /** @@ -28,7 +28,7 @@ interface CheatsheetRendererProps { * @param extension The extension to render * @param rootI18nKey An additional i18n namespace */ -export const CheatsheetEntryPane: React.FC = ({ extension, rootI18nKey }) => { +export const CheatsheetEntryPane: React.FC = ({ entry, i18nKeyPrefix }) => { const { t } = useTranslation() const [content, setContent] = useState('') @@ -36,13 +36,13 @@ export const CheatsheetEntryPane: React.FC = ({ extensi const lines = useMemo(() => content.split('\n'), [content]) const i18nPrefix = useMemo( - () => `cheatsheet.${rootI18nKey ? `${rootI18nKey}.` : ''}${extension.i18nKey}.`, - [extension.i18nKey, rootI18nKey] + () => `cheatsheet.${i18nKeyPrefix !== undefined ? i18nKeyPrefix + '.' : ''}${entry.i18nKey}.`, + [entry.i18nKey, i18nKeyPrefix] ) useEffect(() => { setContent(t(`${i18nPrefix}example`) ?? '') - }, [extension, i18nPrefix, t]) + }, [entry, i18nPrefix, t]) const cheatsheetExtensionComponents = useComponentsFromAppExtensions(setContent) @@ -62,7 +62,7 @@ export const CheatsheetEntryPane: React.FC = ({ extensi {descriptionElements} - +

diff --git a/frontend/src/components/cheatsheet/cheatsheet-extension.ts b/frontend/src/components/cheatsheet/cheatsheet-extension.ts index 02b1a4b35..5cbd22f4a 100644 --- a/frontend/src/components/cheatsheet/cheatsheet-extension.ts +++ b/frontend/src/components/cheatsheet/cheatsheet-extension.ts @@ -9,29 +9,28 @@ export interface CheatsheetExtensionComponentProps { setContent: (dispatcher: string | ((prevState: string) => string)) => void } -export type CheatsheetExtension = CheatsheetSingleEntry | CheatsheetEntryWithTopics +export type CheatsheetExtension = CheatsheetSingleEntry | CheatsheetMultiEntry /** - * Determine if a given {@link CheatsheetExtension} is a {@link CheatsheetEntryWithTopics} or just a {@link CheatsheetSingleEntry}. + * Determine if a given {@link CheatsheetExtension} is a {@link CheatsheetSingleEntry} or a {@link CheatsheetMultiEntry}. * * @param extension The extension in question * @return boolean */ -export const hasCheatsheetTopics = ( +export const isCheatsheetMultiEntry = ( extension: CheatsheetExtension | undefined -): extension is CheatsheetEntryWithTopics => { - return (extension as CheatsheetEntryWithTopics)?.topics !== undefined +): extension is CheatsheetMultiEntry => { + return extension !== undefined && typeof (extension as CheatsheetMultiEntry).topics === 'object' } -/** - * This is an entry with just a name and a bunch of different topics to discuss. - * - * e.g 'basics.headlines' with the topics 'hashtag' and 'equal' - */ -export interface CheatsheetEntryWithTopics { +export interface CheatsheetMultiEntry { i18nKey: string - categoryI18nKey?: string - topics: CheatsheetSingleEntry[] + categoryI18nKey: string + topics: CheatsheetEntry[] +} + +export interface CheatsheetSingleEntry extends CheatsheetEntry { + categoryI18nKey: string } /** @@ -42,10 +41,8 @@ export interface CheatsheetEntryWithTopics { * * e.g 'basics.basicFormatting' */ -export interface CheatsheetSingleEntry { +export interface CheatsheetEntry { i18nKey: string - categoryI18nKey?: string cheatsheetExtensionComponent?: React.FC - readMoreUrl?: URL } diff --git a/frontend/src/components/cheatsheet/cheatsheet-search.tsx b/frontend/src/components/cheatsheet/cheatsheet-search.tsx index e0cfd61c6..c935ca252 100644 --- a/frontend/src/components/cheatsheet/cheatsheet-search.tsx +++ b/frontend/src/components/cheatsheet/cheatsheet-search.tsx @@ -4,33 +4,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { allAppExtensions } from '../../extensions/all-app-extensions' -import type { SearchIndexEntry } from '../../hooks/common/use-document-search' -import { useDocumentSearch } from '../../hooks/common/use-document-search' +import { useCheatsheetSearch } from '../../hooks/common/use-cheatsheet-search' import { useOnInputChange } from '../../hooks/common/use-on-input-change' import { useTranslatedText } from '../../hooks/common/use-translated-text' import { UiIcon } from '../common/icons/ui-icon' -import type { CheatsheetSingleEntry, CheatsheetExtension } from './cheatsheet-extension' -import { hasCheatsheetTopics } from './cheatsheet-extension' +import type { CheatsheetExtension } from './cheatsheet-extension' +import { isCheatsheetMultiEntry } from './cheatsheet-extension' import styles from './cheatsheet.module.scss' -import type { IndexOptionsForDocumentSearch, StoreOption } from 'flexsearch-ts' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { FormControl, InputGroup } from 'react-bootstrap' import { X } from 'react-bootstrap-icons' import { useTranslation } from 'react-i18next' -interface CheatsheetSearchIndexEntry extends SearchIndexEntry { - title: string - description: string - example: string -} - -const searchOptions: IndexOptionsForDocumentSearch = { - document: { - id: 'id', - field: ['title', 'description', 'example'] - } -} - export interface CheatsheetSearchProps { setVisibleExtensions: React.Dispatch> } @@ -48,38 +33,43 @@ export const CheatsheetSearch: React.FC = ({ setVisibleEx () => allAppExtensions.flatMap((extension) => extension.buildCheatsheetExtensions()), [] ) - const buildSearchIndexEntry = useCallback( - (entry: CheatsheetSingleEntry, rootI18nKey: string | undefined = undefined): CheatsheetSearchIndexEntry => { - const rootI18nKeyWithDot = rootI18nKey ? `${rootI18nKey}.` : '' + const placeholderText = useTranslatedText('cheatsheet.search') + + const buildEntry = useCallback( + (i18nKey: string, extensionId: string) => { return { - id: rootI18nKey ? rootI18nKey : entry.i18nKey, - title: t(`cheatsheet.${rootI18nKeyWithDot}${entry.i18nKey}.title`), - description: t(`cheatsheet.${rootI18nKeyWithDot}${entry.i18nKey}.description`), - example: t(`cheatsheet.${rootI18nKeyWithDot}${entry.i18nKey}.example`) + id: i18nKey, + extensionId: extensionId, + title: t(`cheatsheet.${i18nKey}.title`), + description: t(`cheatsheet.${i18nKey}.description`), + example: t(`cheatsheet.${i18nKey}.example`) } }, [t] ) - const placeholderText = useTranslatedText('cheatsheet.search') + const cheatsheetSearchIndexEntries = useMemo( () => - allCheatsheetExtensions.flatMap((entry) => { - if (hasCheatsheetTopics(entry)) { - return entry.topics.map((innerEntry) => buildSearchIndexEntry(innerEntry, entry.i18nKey)) + allCheatsheetExtensions.flatMap((extension) => { + if (isCheatsheetMultiEntry(extension)) { + return extension.topics.map((entry) => { + return buildEntry(extension.i18nKey + '.' + entry.i18nKey, extension.i18nKey) + }) + } else { + return buildEntry(extension.i18nKey, extension.i18nKey) } - return buildSearchIndexEntry(entry) }), - [buildSearchIndexEntry, allCheatsheetExtensions] + [allCheatsheetExtensions, buildEntry] ) - const searchResults = useDocumentSearch(cheatsheetSearchIndexEntries, searchOptions, searchTerm) + + const searchResults = useCheatsheetSearch(cheatsheetSearchIndexEntries, searchTerm) useEffect(() => { - if (searchTerm.trim() === '') { - setVisibleExtensions(allCheatsheetExtensions) - return - } - const mappedResults = searchResults.flatMap((result) => result.result) const extensionResults = allCheatsheetExtensions.filter((extension) => { - return mappedResults.includes(extension.i18nKey) + return ( + searchResults.find((result) => { + return result.extensionId === extension.i18nKey + }) !== undefined + ) }) setVisibleExtensions(extensionResults) }, [allCheatsheetExtensions, searchResults, searchTerm, setVisibleExtensions]) diff --git a/frontend/src/components/cheatsheet/topic-selection.tsx b/frontend/src/components/cheatsheet/topic-selection.tsx index 91fc12706..4324d0914 100644 --- a/frontend/src/components/cheatsheet/topic-selection.tsx +++ b/frontend/src/components/cheatsheet/topic-selection.tsx @@ -3,16 +3,16 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { CheatsheetSingleEntry, CheatsheetExtension } from './cheatsheet-extension' -import { hasCheatsheetTopics } from './cheatsheet-extension' +import type { CheatsheetExtension, CheatsheetEntry } from './cheatsheet-extension' +import { isCheatsheetMultiEntry } from './cheatsheet-extension' import React, { useMemo } from 'react' import { Button, ButtonGroup, ListGroupItem } from 'react-bootstrap' import { Trans } from 'react-i18next' interface EntrySelectionProps { extension: CheatsheetExtension | undefined - selectedEntry: CheatsheetSingleEntry | undefined - setSelectedEntry: (value: CheatsheetSingleEntry) => void + selectedEntry: CheatsheetEntry | undefined + setSelectedEntry: (value: CheatsheetEntry) => void } /** @@ -25,7 +25,7 @@ interface EntrySelectionProps { */ export const TopicSelection: React.FC = ({ extension, selectedEntry, setSelectedEntry }) => { const listItems = useMemo(() => { - if (!hasCheatsheetTopics(extension)) { + if (!isCheatsheetMultiEntry(extension)) { return null } return extension.topics.map((entry) => ( diff --git a/frontend/src/components/cheatsheet/use-components-from-app-extensions.tsx b/frontend/src/components/cheatsheet/use-components-from-app-extensions.tsx index e550fe8f7..0385a47dd 100644 --- a/frontend/src/components/cheatsheet/use-components-from-app-extensions.tsx +++ b/frontend/src/components/cheatsheet/use-components-from-app-extensions.tsx @@ -5,7 +5,7 @@ */ import { allAppExtensions } from '../../extensions/all-app-extensions' import type { CheatsheetExtensionComponentProps } from './cheatsheet-extension' -import { hasCheatsheetTopics } from './cheatsheet-extension' +import { isCheatsheetMultiEntry } from './cheatsheet-extension' import type { ReactElement } from 'react' import React, { Fragment, useMemo } from 'react' @@ -20,7 +20,7 @@ export const useComponentsFromAppExtensions = ( {allAppExtensions .flatMap((extension) => extension.buildCheatsheetExtensions()) - .flatMap((extension) => (hasCheatsheetTopics(extension) ? extension.topics : extension)) + .flatMap((extension) => (isCheatsheetMultiEntry(extension) ? extension.topics : extension)) .map((extension) => { if (extension.cheatsheetExtensionComponent) { return React.createElement(extension.cheatsheetExtensionComponent, { key: extension.i18nKey, setContent }) diff --git a/frontend/src/extensions/essential-app-extensions/alert/alert-app-extension.ts b/frontend/src/extensions/essential-app-extensions/alert/alert-app-extension.ts index c2ba8cc57..344c0df52 100644 --- a/frontend/src/extensions/essential-app-extensions/alert/alert-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/alert/alert-app-extension.ts @@ -22,7 +22,7 @@ export class AlertAppExtension extends AppExtension { } buildCheatsheetExtensions(): CheatsheetExtension[] { - return [{ i18nKey: 'alert' }] + return [{ i18nKey: 'alert', categoryI18nKey: 'other' }] } buildAutocompletion(): CompletionSource[] { diff --git a/frontend/src/extensions/essential-app-extensions/basic-markdown-syntax/basic-markdown-syntax-app-extension.ts b/frontend/src/extensions/essential-app-extensions/basic-markdown-syntax/basic-markdown-syntax-app-extension.ts index 30bc990a8..800a0a365 100644 --- a/frontend/src/extensions/essential-app-extensions/basic-markdown-syntax/basic-markdown-syntax-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/basic-markdown-syntax/basic-markdown-syntax-app-extension.ts @@ -20,19 +20,19 @@ export class BasicMarkdownSyntaxAppExtension extends AppExtension { return [ { i18nKey: 'basics.basicFormatting', - categoryI18nKey: 'basic' + categoryI18nKey: 'basics' }, { i18nKey: 'basics.abbreviation', - categoryI18nKey: 'basic' + categoryI18nKey: 'basics' }, { i18nKey: 'basics.footnote', - categoryI18nKey: 'basic' + categoryI18nKey: 'basics' }, { i18nKey: 'basics.headlines', - categoryI18nKey: 'basic', + categoryI18nKey: 'basics', topics: [ { i18nKey: 'hashtag' @@ -44,22 +44,43 @@ export class BasicMarkdownSyntaxAppExtension extends AppExtension { }, { i18nKey: 'basics.code', - categoryI18nKey: 'basic', - topics: [{ i18nKey: 'inline' }, { i18nKey: 'block' }] + categoryI18nKey: 'basics', + topics: [ + { + i18nKey: 'inline' + }, + { + i18nKey: 'block' + } + ] }, { i18nKey: 'basics.lists', - categoryI18nKey: 'basic', - topics: [{ i18nKey: 'unordered' }, { i18nKey: 'ordered' }] + categoryI18nKey: 'basics', + topics: [ + { + i18nKey: 'unordered' + }, + { + i18nKey: 'ordered' + } + ] }, { i18nKey: 'basics.images', - categoryI18nKey: 'basic', - topics: [{ i18nKey: 'basic' }, { i18nKey: 'size' }] + categoryI18nKey: 'basics', + topics: [ + { + i18nKey: 'basic' + }, + { + i18nKey: 'size' + } + ] }, { i18nKey: 'basics.links', - categoryI18nKey: 'basic' + categoryI18nKey: 'basics' } ] } diff --git a/frontend/src/extensions/essential-app-extensions/blockquote/blockquote-app-extension.ts b/frontend/src/extensions/essential-app-extensions/blockquote/blockquote-app-extension.ts index 0bb7b2119..d29e35d24 100644 --- a/frontend/src/extensions/essential-app-extensions/blockquote/blockquote-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/blockquote/blockquote-app-extension.ts @@ -22,7 +22,13 @@ export class BlockquoteAppExtension extends AppExtension { } buildCheatsheetExtensions(): CheatsheetExtension[] { - return [{ i18nKey: 'blockquoteTags', topics: [{ i18nKey: 'name' }, { i18nKey: 'color' }, { i18nKey: 'time' }] }] + return [ + { + i18nKey: 'blockquoteTags', + categoryI18nKey: 'other', + topics: [{ i18nKey: 'name' }, { i18nKey: 'color' }, { i18nKey: 'time' }] + } + ] } buildAutocompletion(): CompletionSource[] { diff --git a/frontend/src/extensions/essential-app-extensions/bootstrap-icons/bootstrap-icon-app-extension.ts b/frontend/src/extensions/essential-app-extensions/bootstrap-icons/bootstrap-icon-app-extension.ts index 8af42bdc0..b76bf2b73 100644 --- a/frontend/src/extensions/essential-app-extensions/bootstrap-icons/bootstrap-icon-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/bootstrap-icons/bootstrap-icon-app-extension.ts @@ -20,7 +20,9 @@ export class BootstrapIconAppExtension extends AppExtension { } buildCheatsheetExtensions(): CheatsheetExtension[] { - return [{ i18nKey: 'bootstrapIcon', readMoreUrl: new URL('https://icons.getbootstrap.com/') }] + return [ + { i18nKey: 'bootstrapIcon', categoryI18nKey: 'other', readMoreUrl: new URL('https://icons.getbootstrap.com/') } + ] } buildAutocompletion(): CompletionSource[] { diff --git a/frontend/src/extensions/essential-app-extensions/csv/csv-table-app-extension.ts b/frontend/src/extensions/essential-app-extensions/csv/csv-table-app-extension.ts index 918cfc532..a7fe750ba 100644 --- a/frontend/src/extensions/essential-app-extensions/csv/csv-table-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/csv/csv-table-app-extension.ts @@ -22,7 +22,7 @@ export class CsvTableAppExtension extends AppExtension { } buildCheatsheetExtensions(): CheatsheetExtension[] { - return [{ i18nKey: 'csv', topics: [{ i18nKey: 'table' }, { i18nKey: 'header' }] }] + return [{ i18nKey: 'csv', categoryI18nKey: 'other', topics: [{ i18nKey: 'table' }, { i18nKey: 'header' }] }] } buildAutocompletion(): CompletionSource[] { diff --git a/frontend/src/extensions/essential-app-extensions/emoji/emoji-app-extension.ts b/frontend/src/extensions/essential-app-extensions/emoji/emoji-app-extension.ts index c86f8c400..dfb9a04f2 100644 --- a/frontend/src/extensions/essential-app-extensions/emoji/emoji-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/emoji/emoji-app-extension.ts @@ -21,6 +21,7 @@ export class EmojiAppExtension extends AppExtension { return [ { i18nKey: 'emoji', + categoryI18nKey: 'other', readMoreUrl: new URL('https://twemoji.twitter.com/') } ] diff --git a/frontend/src/extensions/essential-app-extensions/highlighted-code-fence/highlighted-code-fence-app-extension.ts b/frontend/src/extensions/essential-app-extensions/highlighted-code-fence/highlighted-code-fence-app-extension.ts index fb75cc30b..357da00cb 100644 --- a/frontend/src/extensions/essential-app-extensions/highlighted-code-fence/highlighted-code-fence-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/highlighted-code-fence/highlighted-code-fence-app-extension.ts @@ -24,6 +24,7 @@ export class HighlightedCodeFenceAppExtension extends AppExtension { return [ { i18nKey: 'codeHighlighting', + categoryI18nKey: 'other', topics: [{ i18nKey: 'language' }, { i18nKey: 'lineNumbers' }, { i18nKey: 'lineWrapping' }] } ] diff --git a/frontend/src/extensions/essential-app-extensions/image-placeholder/image-placeholder-app-extension.ts b/frontend/src/extensions/essential-app-extensions/image-placeholder/image-placeholder-app-extension.ts index 98c212679..b4645bd65 100644 --- a/frontend/src/extensions/essential-app-extensions/image-placeholder/image-placeholder-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/image-placeholder/image-placeholder-app-extension.ts @@ -19,7 +19,8 @@ export class ImagePlaceholderAppExtension extends AppExtension { buildCheatsheetExtensions(): CheatsheetExtension[] { return [ { - i18nKey: 'imagePlaceholder' + i18nKey: 'imagePlaceholder', + categoryI18nKey: 'other' } ] } diff --git a/frontend/src/extensions/essential-app-extensions/spoiler/spoiler-app-extension.ts b/frontend/src/extensions/essential-app-extensions/spoiler/spoiler-app-extension.ts index 3a0e39883..119b6813c 100644 --- a/frontend/src/extensions/essential-app-extensions/spoiler/spoiler-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/spoiler/spoiler-app-extension.ts @@ -24,7 +24,7 @@ export class SpoilerAppExtension extends AppExtension { } buildCheatsheetExtensions(): CheatsheetExtension[] { - return [{ i18nKey: 'spoiler' }] + return [{ i18nKey: 'spoiler', categoryI18nKey: 'other' }] } buildAutocompletion(): CompletionSource[] { diff --git a/frontend/src/extensions/essential-app-extensions/table-of-contents/table-of-contents-app-extension.ts b/frontend/src/extensions/essential-app-extensions/table-of-contents/table-of-contents-app-extension.ts index 867a2c0dd..2c9689110 100644 --- a/frontend/src/extensions/essential-app-extensions/table-of-contents/table-of-contents-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/table-of-contents/table-of-contents-app-extension.ts @@ -21,6 +21,7 @@ export class TableOfContentsAppExtension extends AppExtension { return [ { i18nKey: 'toc', + categoryI18nKey: 'other', topics: [ { i18nKey: 'basic' diff --git a/frontend/src/extensions/essential-app-extensions/task-list/task-list-checkbox-app-extension.ts b/frontend/src/extensions/essential-app-extensions/task-list/task-list-checkbox-app-extension.ts index 06abcf3a5..f527a3cc1 100644 --- a/frontend/src/extensions/essential-app-extensions/task-list/task-list-checkbox-app-extension.ts +++ b/frontend/src/extensions/essential-app-extensions/task-list/task-list-checkbox-app-extension.ts @@ -26,6 +26,6 @@ export class TaskListCheckboxAppExtension extends AppExtension { } buildCheatsheetExtensions(): CheatsheetExtension[] { - return [{ i18nKey: 'taskList', cheatsheetExtensionComponent: SetCheckboxInCheatsheet }] + return [{ i18nKey: 'taskList', categoryI18nKey: 'other', cheatsheetExtensionComponent: SetCheckboxInCheatsheet }] } } diff --git a/frontend/src/extensions/external-lib-app-extensions/katex/katex-app-extension.ts b/frontend/src/extensions/external-lib-app-extensions/katex/katex-app-extension.ts index bfd02fc6a..f77c4aa66 100644 --- a/frontend/src/extensions/external-lib-app-extensions/katex/katex-app-extension.ts +++ b/frontend/src/extensions/external-lib-app-extensions/katex/katex-app-extension.ts @@ -19,6 +19,6 @@ export class KatexAppExtension extends AppExtension { } buildCheatsheetExtensions(): CheatsheetExtension[] { - return [{ i18nKey: 'katex', readMoreUrl: new URL('https://katex.org/') }] + return [{ i18nKey: 'katex', categoryI18nKey: 'other', readMoreUrl: new URL('https://katex.org/') }] } } diff --git a/frontend/src/hooks/common/use-cheatsheet-search.spec.ts b/frontend/src/hooks/common/use-cheatsheet-search.spec.ts new file mode 100644 index 000000000..90c8761dd --- /dev/null +++ b/frontend/src/hooks/common/use-cheatsheet-search.spec.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { CheatsheetSearchIndexEntry } from './use-cheatsheet-search' +import { useCheatsheetSearch } from './use-cheatsheet-search' +import { renderHook, waitFor } from '@testing-library/react' + +describe('useDocumentSearch', () => { + const searchEntries: CheatsheetSearchIndexEntry[] = [ + { + id: 'test1', + extensionId: 'test1', + title: 'title1', + description: 'description1', + example: 'example1' + }, + { + id: 'test2', + extensionId: 'test2', + title: 'title2 sub', + description: 'description2', + example: 'example2' + }, + { + id: 'test3', + extensionId: 'test3', + title: 'title3 sub', + description: 'description3', + example: 'example3' + } + ] + + it('returns all entries if no search term is given', async () => { + const { result } = renderHook((searchTerm: string) => useCheatsheetSearch(searchEntries, searchTerm), { + initialProps: '' + }) + + await waitFor(() => expect(result.current).toStrictEqual(searchEntries)) + }) + + it('results no entries if nothing has been found', async () => { + const { result } = renderHook((searchTerm: string) => useCheatsheetSearch(searchEntries, searchTerm), { + initialProps: 'Foo' + }) + + await waitFor(() => expect(result.current).toHaveLength(0)) + }) + + it('returns multiple entries if matching', async () => { + const { result } = renderHook((searchTerm: string) => useCheatsheetSearch(searchEntries, searchTerm), { + initialProps: 'sub' + }) + + await waitFor(() => expect(result.current).toHaveLength(2)) + }) +}) diff --git a/frontend/src/hooks/common/use-cheatsheet-search.ts b/frontend/src/hooks/common/use-cheatsheet-search.ts new file mode 100644 index 000000000..9aa05e5b8 --- /dev/null +++ b/frontend/src/hooks/common/use-cheatsheet-search.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useState } from 'react' +import { create, insert, search } from '@orama/orama' +import { useAsync } from 'react-use' +import { Logger } from '../../utils/logger' + +export interface CheatsheetSearchIndexEntry { + readonly id: string + readonly title: string + readonly description: string + readonly example: string + readonly extensionId: string +} + +const logger = new Logger('Cheatsheet Search') + +/** + * Generate document search index and provide functions to search. + * + * @param entries The list of entries to build the search index from + * @param searchTerm What to search for + * @return An array of the search results + */ +export const useCheatsheetSearch = ( + entries: CheatsheetSearchIndexEntry[], + searchTerm: string +): CheatsheetSearchIndexEntry[] => { + const [results, setResults] = useState([]) + + const { + value: searchIndex, + loading: searchIndexLoading, + error: searchIndexError + } = useAsync(async () => { + const db = await create({ + schema: { + id: 'string', + title: 'string', + description: 'string', + example: 'string', + extensionId: 'string' + } as const + }) + const adds = entries.map((entry) => { + logger.debug('Add to search entry:', entry) + return insert(db, entry) + }) + await Promise.all(adds) + return db + }, [entries]) + + useEffect(() => { + if (searchIndexLoading || searchIndexError !== undefined || searchIndex === undefined || searchTerm === '') { + return setResults(entries) + } + search(searchIndex, { + term: searchTerm, + tolerance: 1, + properties: ['title', 'description', 'example'], + boost: { + title: 3, + description: 2, + example: 1 + } + }) + .then((results) => { + setResults(results.hits.map((entry) => entry.document)) + }) + .catch((error) => { + logger.error(error) + }) + }, [entries, searchIndexError, searchIndexLoading, searchIndex, searchTerm]) + + return results +} diff --git a/frontend/src/hooks/common/use-document-search.spec.ts b/frontend/src/hooks/common/use-document-search.spec.ts deleted file mode 100644 index a0f514f54..000000000 --- a/frontend/src/hooks/common/use-document-search.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { SearchIndexEntry } from './use-document-search' -import { useDocumentSearch } from './use-document-search' -import { renderHook } from '@testing-library/react' - -describe('useDocumentSearch', () => { - interface TestSearchIndexEntry extends SearchIndexEntry { - name: string - text: string - } - - const searchOptions = { - document: { - id: 'id', - field: ['name', 'text'] - } - } - const searchEntries: TestSearchIndexEntry[] = [ - { - id: 1, - name: 'Foo', - text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean fermentum odio in bibendum venenatis. Cras aliquet ultrices finibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit.' - }, - { - id: 2, - name: 'Bar', - text: 'Vivamus sed mauris eget magna sodales blandit. Aliquam tincidunt nunc et sapien scelerisque placerat. Pellentesque a orci ac risus molestie suscipit id vel arcu.' - }, - { - id: 3, - name: 'Cras', - text: 'Cras consectetur sit amet tortor eget sollicitudin. Ut convallis orci ipsum, eget dignissim nibh dignissim eget. Nunc commodo est neque, eget venenatis urna condimentum eget. Suspendisse dapibus ligula et enim venenatis hendrerit. ' - } - ] - it('results get populated', () => { - const { result, rerender } = renderHook( - (searchTerm: string) => useDocumentSearch(searchEntries, searchOptions, searchTerm), - { - initialProps: '' - } - ) - rerender('Foo') - - expect(result.current).toHaveLength(1) - expect(result.current[0]).toEqual({ field: 'name', result: [1] }) - }) - it('finds in multiple fields', () => { - const { result, rerender } = renderHook( - (searchTerm: string) => useDocumentSearch(searchEntries, searchOptions, searchTerm), - { - initialProps: '' - } - ) - rerender('Cras') - - expect(result.current).toHaveLength(2) - expect(result.current[0]).toEqual({ field: 'name', result: [3] }) - expect(result.current[1]).toEqual({ field: 'text', result: [3, 1] }) - }) -}) diff --git a/frontend/src/hooks/common/use-document-search.ts b/frontend/src/hooks/common/use-document-search.ts deleted file mode 100644 index 10d8cd168..000000000 --- a/frontend/src/hooks/common/use-document-search.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { IndexOptionsForDocumentSearch, Id, SimpleDocumentSearchResultSetUnit, StoreOption } from 'flexsearch-ts' -import { Document } from 'flexsearch-ts' -import { useEffect, useMemo, useState } from 'react' - -export interface SearchIndexEntry { - id: Id -} - -/** - * Generate document search index and provide functions to search. - * - * @param entries The list of entries to built the search index from - * @param options Options for the search index - * @param searchTerm What to search for - * @return An array of the search results - */ -export const useDocumentSearch = ( - entries: Array, - options: IndexOptionsForDocumentSearch, - searchTerm: string -): SimpleDocumentSearchResultSetUnit[] => { - const [results, setResults] = useState([]) - const searchIndex = useMemo(() => { - const index = new Document({ - tokenize: 'full', - ...options - }) - entries.forEach((entry) => { - index.add(entry) - }) - return index - }, [entries, options]) - useEffect(() => { - setResults(searchIndex.search(searchTerm)) - }, [searchIndex, searchTerm]) - - return results -} diff --git a/yarn.lock b/yarn.lock index 6c8764ecb..dac0121e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2441,6 +2441,7 @@ __metadata: "@hedgedoc/markdown-it-plugins": "workspace:markdown-it-plugins" "@mrdrogdrog/optional": "npm:1.2.1" "@next/bundle-analyzer": "npm:13.4.19" + "@orama/orama": "npm:2.0.0-beta.12" "@react-hook/resize-observer": "npm:1.2.6" "@redux-devtools/core": "npm:3.13.3" "@reduxjs/toolkit": "npm:1.9.7" @@ -2501,7 +2502,6 @@ __metadata: eventemitter2: "npm:6.4.9" fast-deep-equal: "npm:3.1.3" firacode: "npm:6.2.0" - flexsearch-ts: "npm:0.7.35" flowchart.js: "npm:1.17.1" highlight.js: "npm:11.9.0" htmlparser2: "npm:9.0.0" @@ -3786,6 +3786,13 @@ __metadata: languageName: node linkType: hard +"@orama/orama@npm:2.0.0-beta.12": + version: 2.0.0-beta.12 + resolution: "@orama/orama@npm:2.0.0-beta.12" + checksum: 666a1d2326d181c384a302d77c45635e68ba26260b89ab022af264bfbd6bef4b1213f691fc9020fe570dd272be19208685707c588260072d46a633ccf5e0b73c + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -10020,13 +10027,6 @@ __metadata: languageName: node linkType: hard -"flexsearch-ts@npm:0.7.35": - version: 0.7.35 - resolution: "flexsearch-ts@npm:0.7.35" - checksum: 0fc80ddecd016adbd477955d057f58fe3082aa57afe68c7274e4878a87995fae6fcb29287864e6895bdb012b81c02cff99e7894ef398a798eb2028abfa95e760 - languageName: node - linkType: hard - "flowchart.js@npm:1.17.1": version: 1.17.1 resolution: "flowchart.js@npm:1.17.1"