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"