anki/ts/editor/symbols-overlay/SymbolsOverlay.svelte

298 lines
8.7 KiB
Svelte
Raw Normal View History

Insert symbols overlay (#2051) * 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 * 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
2022-09-10 10:46:59 +02:00
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { onMount } from "svelte";
import DropdownItem from "../../components/DropdownItem.svelte";
import Popover from "../../components/Popover.svelte";
import WithFloating from "../../components/WithFloating.svelte";
import {
getRange,
getSelection,
isSelectionCollapsed,
} from "../../lib/cross-browser";
import type { Callback } from "../../lib/typing";
import { singleCallback } from "../../lib/typing";
import { context } from "../rich-text-input";
import type { SymbolsTable } from "./data-provider";
import { getSymbolExact, getSymbols } from "./data-provider";
const SYMBOLS_DELIMITER = ":";
const { inputHandler, editable } = context.get();
let referenceRange: Range | undefined = undefined;
let cleanup: Callback;
let query: string = "";
let activeItem = 0;
let foundSymbols: SymbolsTable = [];
function unsetReferenceRange() {
referenceRange = undefined;
activeItem = 0;
cleanup?.();
}
async function maybeShowOverlay(
selection: Selection,
event: InputEvent,
): Promise<void> {
if (
event.inputType !== "insertText" ||
event.data === SYMBOLS_DELIMITER ||
!isSelectionCollapsed(selection)
) {
return unsetReferenceRange();
}
const currentRange = getRange(selection)!;
const offset = currentRange.endOffset;
if (!(currentRange.commonAncestorContainer instanceof Text) || offset < 2) {
return unsetReferenceRange();
}
const wholeText = currentRange.commonAncestorContainer.wholeText;
for (let index = offset - 1; index >= 0; index--) {
const currentCharacter = wholeText[index];
if (currentCharacter === " ") {
return unsetReferenceRange();
} else if (currentCharacter === SYMBOLS_DELIMITER) {
const possibleQuery =
wholeText.substring(index + 1, offset) + event.data;
if (possibleQuery.length < 2) {
return unsetReferenceRange();
}
query = possibleQuery;
referenceRange = currentRange;
foundSymbols = await getSymbols(query);
cleanup = editable.focusHandler.blur.on(
async () => unsetReferenceRange(),
{
once: true,
},
);
return;
}
}
}
async function replaceText(
selection: Selection,
text: Text,
symbolCharacter: string,
): Promise<void> {
text.replaceData(0, text.length, symbolCharacter);
unsetReferenceRange();
// Place caret behind it
const range = new Range();
range.selectNode(text);
selection.removeAllRanges();
selection.addRange(range);
selection.collapseToEnd();
}
function replaceTextOnDemand(symbolCharacter: string): void {
const commonAncestor = referenceRange!.commonAncestorContainer as Text;
const selection = getSelection(commonAncestor)!;
const replacementLength =
commonAncestor.data
.substring(0, referenceRange!.endOffset)
.split("")
.reverse()
.join("")
.indexOf(SYMBOLS_DELIMITER) + 1;
const newOffset = referenceRange!.endOffset - replacementLength + 1;
commonAncestor.replaceData(
referenceRange!.endOffset - replacementLength,
replacementLength + 1,
symbolCharacter,
);
// Place caret behind it
const range = new Range();
range.setEnd(commonAncestor, newOffset);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
unsetReferenceRange();
}
async function updateOverlay(
selection: Selection,
event: InputEvent,
): Promise<void> {
if (event.inputType !== "insertText") {
return unsetReferenceRange();
}
const data = event.data;
referenceRange = getRange(selection)!;
if (data === SYMBOLS_DELIMITER && query) {
const symbol = await getSymbolExact(query);
if (!symbol) {
return unsetReferenceRange();
}
const currentRange = getRange(selection)!;
const offset = currentRange.endOffset;
if (!(currentRange.commonAncestorContainer instanceof Text) || offset < 2) {
return unsetReferenceRange();
}
const commonAncestor = currentRange.commonAncestorContainer;
const replacementLength =
commonAncestor.data
.substring(0, currentRange.endOffset)
.split("")
.reverse()
.join("")
.indexOf(SYMBOLS_DELIMITER) + 1;
commonAncestor.deleteData(
currentRange.endOffset - replacementLength,
replacementLength,
);
inputHandler.insertText.on(
async ({ text }) => replaceText(selection, text, symbol),
{
once: true,
},
);
} else if (query) {
query += data!;
foundSymbols = await getSymbols(query);
}
}
async function onBeforeInput({ event }): Promise<void> {
const selection = getSelection(event.target)!;
if (referenceRange) {
await updateOverlay(selection, event);
} else {
await maybeShowOverlay(selection, event);
}
}
$: showSymbolsOverlay = referenceRange && foundSymbols.length > 0;
async function onSpecialKey({ event, action }): Promise<void> {
if (!showSymbolsOverlay) {
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].symbol);
} else if (action === "escape") {
unsetReferenceRange();
}
}
onMount(() =>
singleCallback(
inputHandler.beforeInput.on(onBeforeInput),
inputHandler.specialKey.on(onSpecialKey),
inputHandler.pointerDown.on(async () => unsetReferenceRange()),
),
);
</script>
<div class="symbols-overlay">
{#if showSymbolsOverlay}
<WithFloating
reference={referenceRange}
placement={["top", "bottom"]}
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.symbol)}
>
<div class="symbol">{found.symbol}</div>
<div class="description">
{#each found.names as name}
<span class="name">
{SYMBOLS_DELIMITER}{name}{SYMBOLS_DELIMITER}
</span>
{/each}
</div>
</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-x: hidden;
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;
}
</style>