anki/ts/editor/symbols-overlay/SymbolsOverlay.svelte
2022-11-28 09:17:39 +10:00

422 lines
13 KiB
Svelte

<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { fontFamilyKey } from "@tslib/context-keys";
import { getRange, getSelection } from "@tslib/cross-browser";
import { createDummyDoc } from "@tslib/parsing";
import type { Callback } from "@tslib/typing";
import { singleCallback } from "@tslib/typing";
import { getContext } from "svelte";
import type { Readable } from "svelte/store";
import DropdownItem from "../../components/DropdownItem.svelte";
import Popover from "../../components/Popover.svelte";
import WithFloating from "../../components/WithFloating.svelte";
import type { SpecialKeyParams } from "../../sveltelib/input-handler";
import type { EditingInputAPI } from "../EditingArea.svelte";
import { context } from "../NoteEditor.svelte";
import type { RichTextInputAPI } from "../rich-text-input/RichTextInput.svelte";
import { editingInputIsRichText } from "../rich-text-input/RichTextInput.svelte";
import { findSymbols, getAutoInsertSymbol, getExactSymbol } from "./symbols-table";
import type {
SymbolsEntry as SymbolsEntryType,
SymbolsTable,
} from "./symbols-types";
import SymbolsEntry from "./SymbolsEntry.svelte";
const symbolsDelimiter = ":";
const queryMinLength = 2;
const autoInsertQueryMaxLength = 5;
const whitespaceCharacters = [" ", "\u00a0"];
const { focusedInput } = context.get();
let cleanup: Callback;
let richTextInput: RichTextInputAPI | null = null;
async function initialize(input: EditingInputAPI | null): Promise<void> {
cleanup?.();
if (!input || !editingInputIsRichText(input)) {
richTextInput = null;
return;
}
cleanup = input.inputHandler.beforeInput.on(
async (input: { event: Event }): Promise<void> => onBeforeInput(input),
);
richTextInput = input;
}
$: initialize($focusedInput);
const fontFamily = getContext<Readable<string>>(fontFamilyKey);
let foundSymbols: SymbolsTable = [];
let referenceRange: Range | null = null;
let activeItem: number | null = null;
let cleanupReferenceRange: Callback;
function unsetReferenceRange() {
referenceRange = null;
activeItem = null;
cleanupReferenceRange?.();
}
function replaceText(selection: Selection, text: Text, nodes: Node[]): void {
text.deleteData(0, text.length);
text.after(...nodes);
unsetReferenceRange();
// Place caret behind it
const range = new Range();
range.setEndAfter(nodes[nodes.length - 1]);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
const parser = new DOMParser();
function symbolsEntryToReplacement(entry: SymbolsEntryType): Node[] {
if (entry.containsHTML) {
const doc = parser.parseFromString(
createDummyDoc(entry.symbol),
"text/html",
);
return [...doc.body.childNodes];
} else {
return [new Text(entry.symbol)];
}
}
function tryAutoInsert(selection: Selection, range: Range, query: string): boolean {
if (
query.length >= queryMinLength &&
query.length <= autoInsertQueryMaxLength
) {
const symbolEntry = getAutoInsertSymbol(query);
if (symbolEntry) {
const commonAncestor = range.commonAncestorContainer as Text;
const replacementLength = query.length;
commonAncestor.parentElement?.normalize();
commonAncestor.deleteData(
range.endOffset - replacementLength + 1,
replacementLength,
);
richTextInput!.inputHandler.insertText.on(
async ({ text }) => {
replaceText(
selection,
text,
symbolsEntryToReplacement(symbolEntry),
);
},
{
once: true,
},
);
return true;
}
}
return false;
}
function findValidSearchQuery(
selection: Selection,
range: Range,
startQuery = "",
shouldFinishEarly: (
selection: Selection,
range: Range,
query: string,
) => boolean = () => false,
): string | null {
if (
whitespaceCharacters.includes(startQuery) ||
startQuery === symbolsDelimiter
) {
return null;
}
const offset = range.endOffset;
if (!(range.commonAncestorContainer instanceof Text)) {
return null;
}
if (range.commonAncestorContainer.parentElement) {
// This call can change range.commonAncestor
range.commonAncestorContainer.parentElement.normalize();
}
const commonAncestorContainer = range.commonAncestorContainer;
let query = startQuery;
for (let index = offset - 1; index >= 0; index--) {
const currentCharacter = commonAncestorContainer.wholeText[index];
if (whitespaceCharacters.includes(currentCharacter)) {
return null;
} else if (currentCharacter === symbolsDelimiter) {
if (query.length < queryMinLength) {
return null;
}
return query;
}
query = currentCharacter + query;
if (shouldFinishEarly(selection, range, query)) {
return null;
}
}
return null;
}
function onSpecialKey({ event, action }): void {
if (!activeItem) {
return;
}
if (["caretLeft", "caretRight"].includes(action)) {
return unsetReferenceRange();
}
event.preventDefault();
if (action === "caretUp") {
if (activeItem === 0) {
activeItem = foundSymbols.length - 1;
} else {
activeItem--;
}
} else if (action === "caretDown") {
if (activeItem >= foundSymbols.length - 1) {
activeItem = 0;
} else {
activeItem++;
}
} else if (action === "enter" || action === "tab") {
replaceTextOnDemand(foundSymbols[activeItem]);
} else if (action === "escape") {
unsetReferenceRange();
}
}
function maybeShowOverlay(selection: Selection, event: InputEvent): void {
if (!event.data) {
return;
}
const currentRange = getRange(selection)!;
// The input event opening the overlay or triggering the auto-insert
// must be an insertion, so event.data must be a string.
// If the inputType is insertCompositionText, the event.data will
// contain the current composition, but the document will also
// contain the whole composition except the last character.
// So we only take the last character from event.data and retrieve the
// rest from the document
const startQuery = event.data[event.data.length - 1];
const query = findValidSearchQuery(
selection,
currentRange,
startQuery,
tryAutoInsert,
);
if (query) {
foundSymbols = findSymbols(query);
if (foundSymbols.length > 0) {
referenceRange = currentRange;
activeItem = 0;
cleanupReferenceRange = singleCallback(
richTextInput!.editable.focusHandler.blur.on(
async () => unsetReferenceRange(),
{
once: true,
},
),
richTextInput!.inputHandler.pointerDown.on(async () =>
unsetReferenceRange(),
),
richTextInput!.inputHandler.specialKey.on(
async (input: SpecialKeyParams) => onSpecialKey(input),
),
);
}
}
}
function replaceTextOnDemand(entry: SymbolsEntryType): void {
const commonAncestor = referenceRange!.commonAncestorContainer as Text;
const selection = getSelection(commonAncestor)!;
const replacementLength =
commonAncestor.data
.substring(0, referenceRange!.endOffset)
.split("")
.reverse()
.join("")
.indexOf(symbolsDelimiter) + 1;
commonAncestor.deleteData(
referenceRange!.endOffset - replacementLength,
replacementLength + 1,
);
const nodes = symbolsEntryToReplacement(entry);
commonAncestor.after(...nodes);
const range = new Range();
range.setEndAfter(nodes[nodes.length - 1]);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
unsetReferenceRange();
}
function prepareInsertion(selection: Selection, query: string): void {
const symbolEntry = getExactSymbol(query);
if (!symbolEntry) {
return unsetReferenceRange();
}
const currentRange = getRange(selection)!;
const offset = currentRange.endOffset;
if (
!(currentRange.commonAncestorContainer instanceof Text) ||
offset < queryMinLength
) {
return unsetReferenceRange();
}
const commonAncestor = currentRange.commonAncestorContainer;
const replacementLength =
commonAncestor.data
.substring(0, currentRange.endOffset)
.split("")
.reverse()
.join("")
.indexOf(symbolsDelimiter) + 1;
commonAncestor.deleteData(
currentRange.endOffset - replacementLength,
replacementLength,
);
richTextInput!.inputHandler.insertText.on(
async ({ text }) =>
replaceText(selection, text, symbolsEntryToReplacement(symbolEntry)),
{
once: true,
},
);
}
function updateOverlay(selection: Selection, event: InputEvent): void {
if (event.data === symbolsDelimiter) {
const query = findValidSearchQuery(selection, getRange(selection)!);
if (query) {
prepareInsertion(selection, query);
} else {
unsetReferenceRange();
}
}
// We have to wait for afterInput to update the symbols, because we also
// want to update in the case of a deletion
richTextInput!.inputHandler.afterInput.on(
async (): Promise<void> => {
const currentRange = getRange(selection)!;
const query = findValidSearchQuery(selection, currentRange);
if (!query) {
return unsetReferenceRange();
}
foundSymbols = findSymbols(query);
if (foundSymbols.length === 0) {
unsetReferenceRange();
} else {
referenceRange = currentRange;
}
},
{ once: true },
);
}
function onBeforeInput({ event }): void {
const selection = getSelection(event.target)!;
if (referenceRange) {
updateOverlay(selection, event);
} else {
maybeShowOverlay(selection, event);
}
}
</script>
<div class="symbols-overlay">
{#if referenceRange}
<WithFloating reference={referenceRange} preferredPlacement="top" offset={10}>
<Popover slot="floating" --popover-padding-inline="0">
<div class="symbols-menu">
{#each foundSymbols as found, index (found.symbol)}
<DropdownItem
active={index === activeItem}
on:click={() => replaceTextOnDemand(found)}
>
<SymbolsEntry
let:symbolName
symbol={found.symbol}
names={found.names}
containsHTML={Boolean(found.containsHTML)}
fontFamily={$fontFamily}
>
{symbolsDelimiter}{symbolName}{symbolsDelimiter}
</SymbolsEntry>
</DropdownItem>
{/each}
</div>
</Popover>
</WithFloating>
{/if}
</div>
<style lang="scss">
.symbols-menu {
display: flex;
flex-flow: column nowrap;
min-width: 140px;
max-height: 15rem;
font-size: 12px;
overflow: hidden auto;
text-overflow: ellipsis;
}
</style>