298 lines
8.7 KiB
Svelte
298 lines
8.7 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 { 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>
|