From a87d87708266a956f36cc3f4f7f32cebe6f2bbc7 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Tue, 13 Sep 2022 06:19:19 +0200 Subject: [PATCH] Fuzzy search in symbol insertion overlay (#2059) * Add flag for enabling insert symbols feature * Add symbols overlay directory * Detect if :xy is inserted into editable * Allow naive updating of overlay, and special handling of ':' * First step towards better Virtual Element support * Update floating to reference range on insert text * Position SymbolsOverlay always on top or bottom * Add a data-provider to emulate API * Show correct suggestions in symbols overlay * Rename to replacementLength * Allow replacing via clicking in menu * Optionally remove inline padding of Popover * Hide Symbols overlay on blur of content editable * Add specialKey to inputHandler and generalize how arrow movement is detected - This way macOS users can use Ctrl-N to mean down, etc. * Detect special key from within SymbolsOverlay * Implement full backwards search while typing * Allow navigating symbol menu and accepting with enter * Add some entries to data-provider * Satisfy eslint * Generate symbolsTable from sources * Use other github source, allow multiple names In return, symbol must be unique * Automatically scroll in symbols dropdown * Use from npm packages rather than downloading from URL * Remove console.log * Remove print Co-authored-by: Damien Elmes * Add pointerDown event to input-handler - so that SymbolsOverlay can reset on field click * Make tab do the same as enter * Make font a bit smaller but increase relative icon size * Satisfy type requirement of handlerlist * Revert changing default size of DropdownItems * Remove some now unused code for bootstrap dropdowns * Use fuse to allow fuzzy searching of symbols * Remove unnecessary async handling in data-provider I did that because at first I was still expecting to fetch the symbols from the backend * Apply field font family in symbol preview * Remove inline padding from latex popover * Rename data-provier to symbols-table * Add some explaining comments to interface * Allow for auto insertion symbols * Use deleteData and after instead of replaceData * Allow using html in symbols * Show html symbols as html * Add SymbolsEntry component * Also include containshtml at low search precedence * Put character entities and gemoji into their own files * Factor out prepareInsertion method * Allow deletion while searching for correct symbol * Respect insertCompositionText * Delete data-provider * Restrict auto insert queries to max 5 characters * Satisfy svelte check * Fix the overlay sometimes not showing This will make sure to always normalize text nodes before searching. However it adjacent text is partially formatted, this will still not find the whole query. For example, currently, entering `:foral` and then inputting `l`, will not trigger a search for `forall`, because of the formatting * Add empty line * Do not trigger overlay, when last character is whitespace or colon * Add missing fuse license --- package.json | 1 + ts/editor/BUILD.bazel | 1 + ts/editor/editor-toolbar/LatexButton.svelte | 2 +- ts/editor/symbols-overlay/SymbolsEntry.svelte | 58 +++ .../symbols-overlay/SymbolsOverlay.svelte | 482 +++++++++++------- .../symbols-overlay/character-entities.ts | 33 ++ ts/editor/symbols-overlay/data-provider.ts | 60 --- ts/editor/symbols-overlay/gemoji.ts | 18 + ts/editor/symbols-overlay/symbols-table.ts | 54 ++ ts/editor/symbols-overlay/symbols-types.d.ts | 27 + ts/licenses.json | 9 + ts/sveltelib/input-handler.ts | 22 +- yarn.lock | 5 + 13 files changed, 520 insertions(+), 252 deletions(-) create mode 100644 ts/editor/symbols-overlay/SymbolsEntry.svelte create mode 100644 ts/editor/symbols-overlay/character-entities.ts delete mode 100644 ts/editor/symbols-overlay/data-provider.ts create mode 100644 ts/editor/symbols-overlay/gemoji.ts create mode 100644 ts/editor/symbols-overlay/symbols-table.ts create mode 100644 ts/editor/symbols-overlay/symbols-types.d.ts diff --git a/package.json b/package.json index d453004f2..ac07bd9d3 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "eslint-plugin-import": "^2.25.4", "eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-svelte3": "^3.4.0", + "fuse.js": "^6.6.2", "gemoji": "^7.1.0", "intl-pluralrules": "^1.2.2", "jquery": "^3.5.1", diff --git a/ts/editor/BUILD.bazel b/ts/editor/BUILD.bazel index a5f2da696..c875be05a 100644 --- a/ts/editor/BUILD.bazel +++ b/ts/editor/BUILD.bazel @@ -33,6 +33,7 @@ _ts_deps = [ "@npm//svelte", "@npm//character-entities", "@npm//gemoji", + "@npm//fuse.js", ] compile_svelte( diff --git a/ts/editor/editor-toolbar/LatexButton.svelte b/ts/editor/editor-toolbar/LatexButton.svelte index 5911c2d0e..7f6335233 100644 --- a/ts/editor/editor-toolbar/LatexButton.svelte +++ b/ts/editor/editor-toolbar/LatexButton.svelte @@ -84,7 +84,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - + {#each dropdownItems as [callback, keyCombination, label]} {label} diff --git a/ts/editor/symbols-overlay/SymbolsEntry.svelte b/ts/editor/symbols-overlay/SymbolsEntry.svelte new file mode 100644 index 000000000..d4bd0fce6 --- /dev/null +++ b/ts/editor/symbols-overlay/SymbolsEntry.svelte @@ -0,0 +1,58 @@ + + + +
+
+ {#if containsHTML} + {@html symbol} + {:else} + {symbol} + {/if} +
+ +
+ {#each names as name} + + + + {/each} +
+
+ + diff --git a/ts/editor/symbols-overlay/SymbolsOverlay.svelte b/ts/editor/symbols-overlay/SymbolsOverlay.svelte index 1a51c027c..49d8e3d58 100644 --- a/ts/editor/symbols-overlay/SymbolsOverlay.svelte +++ b/ts/editor/symbols-overlay/SymbolsOverlay.svelte @@ -3,205 +3,168 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -->
- {#if showSymbolsOverlay} + {#if referenceRange} replaceTextOnDemand(found.symbol)} + on:click={() => replaceTextOnDemand(found)} > -
{found.symbol}
-
- {#each found.names as name} - - {SYMBOLS_DELIMITER}{name}{SYMBOLS_DELIMITER} - - {/each} -
+ + {symbolsDelimiter}{symbolName}{symbolsDelimiter} + {/each}
@@ -279,19 +396,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html text-overflow: ellipsis; overflow-y: auto; } - - .symbol { - transform: scale(1.1); - font-size: 150%; - /* The widest emojis I could find were couple_with_heart_ */ - width: 38px; - } - - .description { - align-self: center; - } - - .name { - margin-left: 3px; - } diff --git a/ts/editor/symbols-overlay/character-entities.ts b/ts/editor/symbols-overlay/character-entities.ts new file mode 100644 index 000000000..286963f12 --- /dev/null +++ b/ts/editor/symbols-overlay/character-entities.ts @@ -0,0 +1,33 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { characterEntities } from "character-entities"; + +import type { SymbolsTable } from "./symbols-types"; + +// Not all characters work well in the editor field +delete characterEntities["Tab"]; + +// A single character entity can be present under differnet names +// So we change the mapping to symbol => name[] +const characterTable: Record = {}; + +for (const [name, character] of Object.entries(characterEntities)) { + if (character in characterTable) { + characterTable[character].push(name); + } else { + characterTable[character] = [name]; + } +} + +const characterSymbolsTable: SymbolsTable = []; + +for (const [character, names] of Object.entries(characterTable)) { + characterSymbolsTable.push({ + symbol: character, + names, + tags: [], + }); +} + +export default characterSymbolsTable; diff --git a/ts/editor/symbols-overlay/data-provider.ts b/ts/editor/symbols-overlay/data-provider.ts deleted file mode 100644 index 12e722000..000000000 --- a/ts/editor/symbols-overlay/data-provider.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -import { characterEntities } from "character-entities"; -import { gemoji } from "gemoji"; - -interface SymbolsEntry { - symbol: string; - names: string[]; - tags: string[]; - containsHTML?: boolean; - autoInsert?: boolean; -} - -export type SymbolsTable = SymbolsEntry[]; - -const symbolsTable: SymbolsTable = []; - -const characterTable: Record = {}; - -// Not all characters work well in the editor field -delete characterEntities["Tab"]; - -for (const [name, character] of Object.entries(characterEntities)) { - if (character in characterTable) { - characterTable[character].push(name); - } else { - characterTable[character] = [name]; - } -} - -for (const [character, names] of Object.entries(characterTable)) { - symbolsTable.push({ - symbol: character, - names, - tags: [], - }); -} - -for (const { emoji, names, tags } of gemoji) { - symbolsTable.push({ - symbol: emoji, - names, - tags, - }); -} - -export async function getSymbols(query: string): Promise { - return symbolsTable.filter( - ({ names, tags }) => - names.some((name) => name.includes(query)) || - tags.some((tag) => tag.includes(query)), - ); -} - -export async function getSymbolExact(query: string): Promise { - const found = symbolsTable.find(({ names }) => names.includes(query)); - - return found ? found.symbol : null; -} diff --git a/ts/editor/symbols-overlay/gemoji.ts b/ts/editor/symbols-overlay/gemoji.ts new file mode 100644 index 000000000..de7db4b11 --- /dev/null +++ b/ts/editor/symbols-overlay/gemoji.ts @@ -0,0 +1,18 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { gemoji } from "gemoji"; + +import type { SymbolsTable } from "./symbols-types"; + +const gemojiSymbolsTable: SymbolsTable = []; + +for (const { emoji, names, tags } of gemoji) { + gemojiSymbolsTable.push({ + symbol: emoji, + names, + tags, + }); +} + +export default gemojiSymbolsTable; diff --git a/ts/editor/symbols-overlay/symbols-table.ts b/ts/editor/symbols-overlay/symbols-table.ts new file mode 100644 index 000000000..51f185c53 --- /dev/null +++ b/ts/editor/symbols-overlay/symbols-table.ts @@ -0,0 +1,54 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import Fuse from "fuse.js"; + +import characterEntities from "./character-entities"; +import gemoji from "./gemoji"; +import type { SymbolsEntry, SymbolsTable } from "./symbols-types"; + +const symbolsTable: SymbolsTable = [...characterEntities, ...gemoji]; + +const symbolsFuse = new Fuse(symbolsTable, { + threshold: 0.2, + minMatchCharLength: 2, + useExtendedSearch: true, + isCaseSensitive: true, + keys: [ + { + name: "names", + weight: 7, + }, + { + name: "tags", + weight: 3, + }, + { + name: "autoInsert", + weight: 0.1, + }, + { + name: "containsHTML", + weight: 0.1, + }, + ], +}); + +export function findSymbols(query: string): SymbolsTable { + return symbolsFuse.search(query).map(({ item }) => item); +} + +export function getExactSymbol(query: string): SymbolsEntry | null { + const [found] = symbolsFuse.search({ names: `="${query}"` }, { limit: 1 }); + + return found ? found.item : null; +} + +export function getAutoInsertSymbol(query: string): SymbolsEntry | null { + const [found] = symbolsFuse.search({ + names: `="${query}"`, + autoInsert: "=autoInsert", + }); + + return found ? found.item : null; +} diff --git a/ts/editor/symbols-overlay/symbols-types.d.ts b/ts/editor/symbols-overlay/symbols-types.d.ts new file mode 100644 index 000000000..9004f14c7 --- /dev/null +++ b/ts/editor/symbols-overlay/symbols-types.d.ts @@ -0,0 +1,27 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +export interface SymbolsEntry { + symbol: string; + /** + * Used for searching and direct insertion + */ + names: string[]; + /** + * Used for searching + */ + tags: string[]; + /** + * If symbols contain HTML, they need to be treated specially. + */ + containsHTML?: "containsHTML"; + /** + * Symbols can be automak inserted, when you enter a full name within delimiters. + * If you enable auto insertion, you can use direction insertion without + * using the delimiter and triggering the search dropdown. + * To falicitate interacting with fuse.js this is a string value rather than a boolean. + */ + autoInsert?: "autoInsert"; +} + +export type SymbolsTable = SymbolsEntry[]; diff --git a/ts/licenses.json b/ts/licenses.json index def81b125..4c809a2b0 100644 --- a/ts/licenses.json +++ b/ts/licenses.json @@ -1005,6 +1005,15 @@ "path": "node_modules/functional-red-black-tree", "licenseFile": "node_modules/functional-red-black-tree/LICENSE" }, + "fuse.js@6.6.2": { + "licenses": "Apache-2.0", + "repository": "https://github.com/krisk/Fuse", + "publisher": "Kiro Risk", + "email": "kirollos@gmail.com", + "url": "http://kiro.me", + "path": "node_modules/fuse.js", + "licenseFile": "node_modules/fuse.js/LICENSE" + }, "gemoji@7.1.0": { "licenses": "MIT", "repository": "https://github.com/wooorm/gemoji", diff --git a/ts/sveltelib/input-handler.ts b/ts/sveltelib/input-handler.ts index 0cf1a1fe0..2f0501873 100644 --- a/ts/sveltelib/input-handler.ts +++ b/ts/sveltelib/input-handler.ts @@ -15,6 +15,10 @@ export interface InputEventParams { event: InputEvent; } +export interface EventParams { + event: Event; +} + export interface InsertTextParams { event: InputEvent; text: Text; @@ -36,6 +40,7 @@ export interface SpecialKeyParams { export interface InputHandlerAPI { readonly beforeInput: HandlerList; readonly insertText: HandlerList; + readonly afterInput: HandlerList; readonly pointerDown: HandlerList<{ event: PointerEvent }>; readonly specialKey: HandlerList; } @@ -49,6 +54,7 @@ export interface InputHandlerAPI { function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { const beforeInput = new HandlerList(); const insertText = new HandlerList(); + const afterInput = new HandlerList(); async function onBeforeInput(this: Element, event: InputEvent): Promise { const selection = getSelection(this)!; @@ -56,7 +62,11 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { await beforeInput.dispatch({ event }); - if (!range || event.inputType !== "insertText" || insertText.length === 0) { + if ( + !range || + !event.inputType.startsWith("insert") || + insertText.length === 0 + ) { return; } @@ -73,6 +83,14 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { await insertText.dispatch({ event, text }); range.commonAncestorContainer.normalize(); + + // We emulate the after input event here, because we prevent + // the default behavior earlier + await afterInput.dispatch({ event }); + } + + async function onInput(this: Element, event: Event): Promise { + await afterInput.dispatch({ event }); } const pointerDown = new HandlerList<{ event: PointerEvent }>(); @@ -107,6 +125,7 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { function setupHandler(element: HTMLElement): { destroy(): void } { const destroy = singleCallback( on(element, "beforeinput", onBeforeInput), + on(element, "input", onInput), on(element, "blur", clearInsertText), on(element, "pointerdown", onPointerDown), on(element, "keydown", onKeyDown), @@ -120,6 +139,7 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { { beforeInput, insertText, + afterInput, specialKey, pointerDown, }, diff --git a/yarn.lock b/yarn.lock index 5e5c19d85..a5292f5d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2619,6 +2619,11 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +fuse.js@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111" + integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA== + gemoji@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/gemoji/-/gemoji-7.1.0.tgz#165403777681a9690d649aabd104da037bdd7739"