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
This commit is contained in:
Henrik Giesel 2022-09-10 10:46:59 +02:00 committed by GitHub
parent 414ff5db1c
commit 8f8f3bd465
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 614 additions and 97 deletions

View file

@ -64,12 +64,14 @@
"@types/marked": "^4.0.1",
"bootstrap": "=5.0.2",
"bootstrap-icons": "^1.4.0",
"character-entities": "^2.0.2",
"codemirror": "^5.63.1",
"css-browser-selector": "^0.6.5",
"d3": "^7.0.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-svelte3": "^3.4.0",
"gemoji": "^7.1.0",
"intl-pluralrules": "^1.2.2",
"jquery": "^3.5.1",
"jquery-ui-dist": "^1.12.1",

View file

@ -533,7 +533,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
setNoteId({});
setColorButtons({});
setTags({});
setMathjaxEnabled({});
setMathjaxEnabled({});
""".format(
json.dumps(data),
json.dumps(collapsed),
@ -551,6 +551,9 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
sticky = [field["sticky"] for field in self.note.note_type()["flds"]]
js += " setSticky(%s);" % json.dumps(sticky)
if os.getenv("ANKI_EDITOR_INSERT_SYMBOLS"):
js += " setInsertSymbolsEnabled();"
js = gui_hooks.editor_will_load_note(js, self.note, self)
self.web.evalWithCallback(
f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback

View file

@ -3,10 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { createEventDispatcher, getContext, onMount } from "svelte";
import { createEventDispatcher, onMount } from "svelte";
import { dropdownKey } from "./context-keys";
import type { DropdownProps } from "./dropdown";
import IconConstrain from "./IconConstrain.svelte";
let className = "";
@ -21,8 +19,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let spanRef: HTMLSpanElement;
const dropdownProps = getContext<DropdownProps>(dropdownKey) ?? { dropdown: false };
onMount(() => {
dispatch("mount", { span: spanRef });
});
@ -32,8 +28,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bind:this={spanRef}
title={tooltip}
class="badge {className}"
class:dropdown-toggle={dropdownProps.dropdown}
{...dropdownProps}
on:click
on:mouseenter
on:mouseleave

View file

@ -9,14 +9,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let className = "";
export { className as class };
let buttonRef: HTMLButtonElement;
export let tooltip: string | undefined = undefined;
export let tabbable: boolean = false;
export let active = false;
$: if (buttonRef && active) {
/* buttonRef.scrollIntoView({ behavior: "smooth", block: "start" }); */
/* TODO will not work on Gecko */
(buttonRef as any).scrollIntoViewIfNeeded({
behavior: "smooth",
block: "start",
});
}
export let tabbable = false;
</script>
<button
bind:this={buttonRef}
{id}
tabindex={tabbable ? 0 : -1}
class="dropdown-item btn {className}"
class="dropdown-item {className}"
class:active
class:btn-day={!$pageTheme.isDark}
class:btn-night={$pageTheme.isDark}
title={tooltip}
@ -36,7 +52,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
display: flex;
justify-content: start;
font-size: calc(var(--base-font-size) * 0.8);
font-size: var(--dropdown-font-size, calc(0.8 * var(--base-font-size)));
background: none;
box-shadow: none !important;

View file

@ -3,11 +3,9 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { createEventDispatcher, getContext, onMount } from "svelte";
import { createEventDispatcher, onMount } from "svelte";
import { pageTheme } from "../sveltelib/theme";
import { dropdownKey } from "./context-keys";
import type { DropdownProps } from "./dropdown";
export let id: string | undefined = undefined;
let className: string = "";
@ -23,8 +21,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let disabled = false;
export let tabbable = false;
const dropdownProps = getContext<DropdownProps>(dropdownKey) ?? { dropdown: false };
let buttonRef: HTMLButtonElement;
const dispatch = createEventDispatcher();
@ -36,11 +32,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{id}
class="label-button {extendClassName(className, theme)}"
class:active
class:dropdown-toggle={dropdownProps.dropdown}
class:btn-day={theme === "anki" && !$pageTheme.isDark}
class:btn-night={theme === "anki" && $pageTheme.isDark}
title={tooltip}
{...dropdownProps}
{disabled}
tabindex={tabbable ? 0 : -1}
on:click

View file

@ -17,8 +17,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min-width: 1rem;
max-width: 95vw;
/* Needs enough space for FloatingArrow to be positioned */
padding: 6px;
/* Needs this much space for FloatingArrow to be positioned */
padding: var(--popover-padding-block, 6px) var(--popover-padding-inline, 6px);
font-size: 1rem;
color: var(--text-fg);

View file

@ -3,7 +3,11 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { FloatingElement, Placement } from "@floating-ui/dom";
import type {
FloatingElement,
Placement,
ReferenceElement,
} from "@floating-ui/dom";
import { createEventDispatcher, onDestroy } from "svelte";
import type { ActionReturn } from "svelte/action";
@ -21,9 +25,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import subscribeToUpdates from "../sveltelib/subscribe-updates";
import FloatingArrow from "./FloatingArrow.svelte";
export let portalTarget: HTMLElement | null = null;
export let portalTarget: HTMLElement | undefined = undefined;
export let placement: Placement | "auto" = "bottom";
export let placement: Placement | Placement[] | "auto" = "bottom";
export let offset = 5;
export let shift = 5;
export let inline = false;
@ -58,11 +62,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let closeOnInsideClick = false;
export let keepOnKeyup = false;
export let reference: HTMLElement | undefined = undefined;
export let reference: ReferenceElement | undefined = undefined;
let floating: FloatingElement;
function applyPosition(
reference: HTMLElement,
reference: ReferenceElement,
floating: FloatingElement,
position: PositionAlgorithm,
): Promise<void> {
@ -71,7 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
async function position(
callback: (
reference: HTMLElement,
reference: ReferenceElement,
floating: FloatingElement,
position: PositionAlgorithm,
) => Promise<void> = applyPosition,
@ -81,12 +85,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
function asReference(referenceArgument: HTMLElement) {
function asReference(referenceArgument: Element) {
reference = referenceArgument;
}
function positioningCallback(
reference: HTMLElement,
reference: ReferenceElement,
callback: PositioningCallback,
): Callback {
const innerFloating = floating;
@ -98,7 +102,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let cleanup: Callback | null = null;
function updateFloating(
reference: HTMLElement | undefined,
reference: ReferenceElement | undefined,
floating: FloatingElement,
isShowing: boolean,
) {
@ -109,6 +113,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return;
}
autoAction = autoUpdate(reference, positioningCallback);
// For virtual references, we cannot provide any
// default closing behavior
if (!(reference instanceof EventTarget)) {
cleanup = autoAction.destroy!;
return;
}
const closingClick = isClosingClick(documentClick, {
reference,
floating,
@ -135,7 +148,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
);
}
autoAction = autoUpdate(reference, positioningCallback);
cleanup = singleCallback(...subscribers, autoAction.destroy!);
}

View file

@ -1,7 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export interface DropdownProps {
dropdown: boolean;
"data-bs-toggle"?: string;
"aria-expanded"?: boolean | "true" | "false";
}

View file

@ -44,6 +44,10 @@ export interface FocusHandlerAPI {
* Executed upon focus event of editable.
*/
focus: HandlerList<{ event: FocusEvent }>;
/**
* Executed upon blur event of editable.
*/
blur: HandlerList<{ event: FocusEvent }>;
}
export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
@ -57,6 +61,7 @@ export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
}
const focus = new HandlerList<{ event: FocusEvent }>();
const blur = new HandlerList<{ event: FocusEvent }>();
function prepareFocusHandling(
editable: HTMLElement,
@ -79,6 +84,7 @@ export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
},
{ once: true },
);
offPointerDown?.();
offPointerDown = on(
editable,
@ -94,8 +100,9 @@ export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
/**
* Must execute before DOMMirror.
*/
function onBlur(this: HTMLElement): void {
function onBlur(this: HTMLElement, event: FocusEvent): void {
prepareFocusHandling(this, saveSelection(this));
blur.dispatch({ event });
}
function setupFocusHandler(editable: HTMLElement): { destroy(): void } {
@ -115,6 +122,7 @@ export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
{
flushCaret,
focus,
blur,
},
setupFocusHandler,
];

View file

@ -31,6 +31,8 @@ _ts_deps = [
"@npm//@types/codemirror",
"@npm//codemirror",
"@npm//svelte",
"@npm//character-entities",
"@npm//gemoji",
]
compile_svelte(

View file

@ -67,6 +67,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import PlainTextBadge from "./PlainTextBadge.svelte";
import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
import RichTextBadge from "./RichTextBadge.svelte";
import SymbolsOverlay from "./symbols-overlay";
function quoteFontFamily(fontFamily: string): string {
// generic families (e.g. sans-serif) must not be quoted
@ -175,6 +176,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
noteId = ntid;
}
let insertSymbols = false;
function setInsertSymbolsEnabled() {
insertSymbols = true;
}
function getNoteId(): number | null {
return noteId;
}
@ -277,6 +284,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setNoteId,
wrap,
setMathjaxEnabled,
setInsertSymbolsEnabled,
...oldEditorAdapter,
});
@ -448,6 +456,9 @@ the AddCards dialog) should be implemented in the user of this component.
>
<ImageHandle maxWidth={250} maxHeight={125} />
<MathjaxHandle />
{#if insertSymbols}
<SymbolsOverlay />
{/if}
<FieldDescription>
{field.description}
</FieldDescription>

View file

@ -88,6 +88,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem>
<WithFloating
show={showFloating && !disabled}
placement="bottom"
inline
on:close={() => (showFloating = false)}
let:asReference
@ -101,7 +102,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</IconButton>
</span>
<Popover slot="floating">
<Popover slot="floating" --popover-padding-inline="0">
<ButtonToolbar wrap={false}>
<ButtonGroup>
<CommandIconButton

View file

@ -116,6 +116,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<WithFloating
show={showFloating && !disabled}
placement="bottom"
inline
on:close={() => (showFloating = false)}
let:asReference
@ -133,7 +134,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</IconButton>
</span>
<Popover slot="floating">
<Popover slot="floating" --popover-padding-inline="0">
{#each showFormats as format (format.name)}
<DropdownItem on:click={(event) => onItemClick(event, format)}>
<CheckBox bind:value={format.active} />

View file

@ -9,6 +9,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "../../lib/ftl";
import { noop } from "../../lib/functional";
import { isArrowLeft, isArrowRight } from "../../lib/keys";
import { getPlatformString } from "../../lib/shortcuts";
import { pageTheme } from "../../sveltelib/theme";
import { baseOptions, focusAndSetCaret, latex } from "../code-mirror";
@ -50,9 +51,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
editor.on(
"keydown",
(_instance: CodeMirrorLib.Editor, event: KeyboardEvent): void => {
if (event.key === "ArrowLeft") {
if (isArrowLeft(event)) {
direction = "start";
} else if (event.key === "ArrowRight") {
} else if (isArrowRight(event)) {
direction = "end";
}
},

View file

@ -0,0 +1,297 @@
<!--
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>

View file

@ -0,0 +1,60 @@
// 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<string, string[]> = {};
// 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<SymbolsTable> {
return symbolsTable.filter(
({ names, tags }) =>
names.some((name) => name.includes(query)) ||
tags.some((tag) => tag.includes(query)),
);
}
export async function getSymbolExact(query: string): Promise<string | null> {
const found = symbolsTable.find(({ names }) => names.includes(query));
return found ? found.symbol : null;
}

View file

@ -0,0 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import SymbolsOverlay from "./SymbolsOverlay.svelte";
export default SymbolsOverlay;

View file

@ -4,6 +4,7 @@
"*",
"image-overlay/*",
"mathjax-overlay/*",
"symbols-overlay/*",
"plain-text-input/*",
"rich-text-input/*",
"editor-toolbar/*"

View file

@ -62,6 +62,7 @@ const modifierPressed =
export const controlPressed = modifierPressed("Control");
export const shiftPressed = modifierPressed("Shift");
export const altPressed = modifierPressed("Alt");
export const metaPressed = modifierPressed("Meta");
export function modifiersToPlatformString(modifiers: string[]): string {
const displayModifiers = isApplePlatform()
@ -90,3 +91,35 @@ export function keyToPlatformString(key: string): string {
return key;
}
}
export function isArrowLeft(event: KeyboardEvent): boolean {
if (event.code === "ArrowLeft") {
return true;
}
return isApplePlatform() && metaPressed(event) && event.code === "KeyB";
}
export function isArrowRight(event: KeyboardEvent): boolean {
if (event.code === "ArrowRight") {
return true;
}
return isApplePlatform() && metaPressed(event) && event.code === "KeyF";
}
export function isArrowUp(event: KeyboardEvent): boolean {
if (event.code === "ArrowUp") {
return true;
}
return isApplePlatform() && metaPressed(event) && event.code === "KeyP";
}
export function isArrowDown(event: KeyboardEvent): boolean {
if (event.code === "ArrowDown") {
return true;
}
return isApplePlatform() && metaPressed(event) && event.code === "KeyN";
}

View file

@ -361,6 +361,15 @@
"path": "node_modules/chalk",
"licenseFile": "node_modules/chalk/license"
},
"character-entities@2.0.2": {
"licenses": "MIT",
"repository": "https://github.com/wooorm/character-entities",
"publisher": "Titus Wormer",
"email": "tituswormer@gmail.com",
"url": "https://wooorm.com",
"path": "node_modules/character-entities",
"licenseFile": "node_modules/character-entities/license"
},
"codemirror@5.65.2": {
"licenses": "MIT",
"repository": "https://github.com/codemirror/CodeMirror",
@ -996,6 +1005,15 @@
"path": "node_modules/functional-red-black-tree",
"licenseFile": "node_modules/functional-red-black-tree/LICENSE"
},
"gemoji@7.1.0": {
"licenses": "MIT",
"repository": "https://github.com/wooorm/gemoji",
"publisher": "Titus Wormer",
"email": "tituswormer@gmail.com",
"url": "https://wooorm.com",
"path": "node_modules/gemoji",
"licenseFile": "node_modules/gemoji/license"
},
"get-intrinsic@1.1.1": {
"licenses": "MIT",
"repository": "https://github.com/ljharb/get-intrinsic",

View file

@ -51,6 +51,11 @@ interface HandlerOptions {
export class HandlerList<T> {
#list: HandlerAccess<T>[] = [];
/**
* Returns a `TriggerItem`, which can be used to attach event handlers.
* This TriggerItem exposes an additional `active` store. This can be
* useful, if other components need to react to the input handler being active.
*/
trigger(options?: Partial<HandlerOptions>): TriggerItem<T> {
const once = options?.once ?? false;
let handler: Handler<T> | null = null;
@ -84,6 +89,11 @@ export class HandlerList<T> {
);
}
/**
* Attaches an event handler.
* @returns a callback, which removes the event handler. Alternatively,
* you can call `off` on the HandlerList.
*/
on(handler: Handler<T>, options?: Partial<HandlerOptions>): Callback {
const once = options?.once ?? false;
let offHandler: Handler<T> | null = null;

View file

@ -3,6 +3,8 @@
import { getRange, getSelection } from "../lib/cross-browser";
import { on } from "../lib/events";
import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp } from "../lib/keys";
import { singleCallback } from "../lib/typing";
import { HandlerList } from "./handler-list";
const nbsp = "\xa0";
@ -12,14 +14,30 @@ export type SetupInputHandlerAction = (element: HTMLElement) => { destroy(): voi
export interface InputEventParams {
event: InputEvent;
}
export interface InsertTextParams {
event: InputEvent;
text: Text;
}
type SpecialKeyAction =
| "caretUp"
| "caretDown"
| "caretLeft"
| "caretRight"
| "enter"
| "tab";
export interface SpecialKeyParams {
event: KeyboardEvent;
action: SpecialKeyAction;
}
export interface InputHandlerAPI {
readonly beforeInput: HandlerList<InputEventParams>;
readonly insertText: HandlerList<InsertTextParams>;
readonly pointerDown: HandlerList<{ event: PointerEvent }>;
readonly specialKey: HandlerList<SpecialKeyParams>;
}
/**
@ -57,31 +75,53 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] {
range.commonAncestorContainer.normalize();
}
const pointerDown = new HandlerList<{ event: PointerEvent }>();
function clearInsertText(): void {
insertText.clear();
}
function onPointerDown(event: PointerEvent): void {
pointerDown.dispatch({ event });
clearInsertText();
}
const specialKey = new HandlerList<SpecialKeyParams>();
async function onKeyDown(this: Element, event: KeyboardEvent): Promise<void> {
if (isArrowDown(event)) {
specialKey.dispatch({ event, action: "caretDown" });
} else if (isArrowUp(event)) {
specialKey.dispatch({ event, action: "caretUp" });
} else if (isArrowRight(event)) {
specialKey.dispatch({ event, action: "caretRight" });
} else if (isArrowLeft(event)) {
specialKey.dispatch({ event, action: "caretLeft" });
} else if (event.code === "Enter" || event.code === "NumpadEnter") {
specialKey.dispatch({ event, action: "enter" });
} else if (event.code === "Tab") {
specialKey.dispatch({ event, action: "tab" });
}
}
function setupHandler(element: HTMLElement): { destroy(): void } {
const beforeInputOff = on(element, "beforeinput", onBeforeInput);
const destroy = singleCallback(
on(element, "beforeinput", onBeforeInput),
on(element, "blur", clearInsertText),
on(element, "pointerdown", onPointerDown),
on(element, "keydown", onKeyDown),
on(document, "selectionchange", clearInsertText),
);
const blurOff = on(element, "blur", clearInsertText);
const pointerDownOff = on(element, "pointerdown", clearInsertText);
const selectionChangeOff = on(document, "selectionchange", clearInsertText);
return {
destroy() {
beforeInputOff();
blurOff();
pointerDownOff();
selectionChangeOff();
},
};
return { destroy };
}
return [
{
beforeInput,
insertText,
specialKey,
pointerDown,
},
setupHandler,
];

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { FloatingElement } from "@floating-ui/dom";
import type { FloatingElement, ReferenceElement } from "@floating-ui/dom";
import { autoUpdate as floatingUiAutoUpdate } from "@floating-ui/dom";
import type { ActionReturn } from "svelte/action";
@ -18,7 +18,7 @@ import type { Callback } from "../../lib/typing";
* })`
*/
export type PositioningCallback = (
reference: HTMLElement,
reference: ReferenceElement,
floating: FloatingElement,
position: Callback,
) => Callback;
@ -27,12 +27,12 @@ export type PositioningCallback = (
* The interface of a function that calls `computePosition` of floating-ui.
*/
export type PositionFunc = (
reference: HTMLElement,
reference: ReferenceElement,
callback: PositioningCallback,
) => Callback;
function autoUpdate(
reference: HTMLElement,
reference: ReferenceElement,
/**
* The method to position the floating element.
*/

View file

@ -1,12 +1,12 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { FloatingElement } from "@floating-ui/dom";
import type { FloatingElement, ReferenceElement } from "@floating-ui/dom";
/**
* The interface of a function that calls `computePosition` of floating-ui.
*/
export type PositionAlgorithm = (
reference: HTMLElement,
reference: ReferenceElement,
floating: FloatingElement,
) => Promise<void>;

View file

@ -6,6 +6,7 @@ import type {
FloatingElement,
Middleware,
Placement,
ReferenceElement,
} from "@floating-ui/dom";
import {
arrow,
@ -20,7 +21,7 @@ import {
import type { PositionAlgorithm } from "./position-algorithm";
export interface PositionFloatingArgs {
placement: Placement | "auto";
placement: Placement | Placement[] | "auto";
arrow: HTMLElement;
shift: number;
offset: number;
@ -41,7 +42,7 @@ function positionFloating({
hideCallback,
}: PositionFloatingArgs): PositionAlgorithm {
return async function (
reference: HTMLElement,
reference: ReferenceElement,
floating: FloatingElement,
): Promise<void> {
const middleware: Middleware[] = [
@ -58,10 +59,14 @@ function positionFloating({
middleware,
};
if (placement !== "auto") {
computeArgs.placement = placement;
} else {
if (Array.isArray(placement)) {
const allowedPlacements = placement;
middleware.push(autoPlacement({ allowedPlacements }));
} else if (placement === "auto") {
middleware.push(autoPlacement());
} else {
computeArgs.placement = placement;
}
if (hideIfEscaped) {

View file

@ -5,6 +5,7 @@ import type {
ComputePositionConfig,
FloatingElement,
Middleware,
ReferenceElement,
} from "@floating-ui/dom";
import { computePosition, inline, offset } from "@floating-ui/dom";
@ -22,7 +23,7 @@ function positionOverlay({
hideCallback,
}: PositionOverlayArgs): PositionAlgorithm {
return async function (
reference: HTMLElement,
reference: ReferenceElement,
floating: FloatingElement,
): Promise<void> {
const middleware: Middleware[] = inlineArg ? [inline()] : [];

View file

@ -8,6 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { writable } from "svelte/store";
import { execCommand } from "../domlib";
import { isArrowDown, isArrowUp } from "../lib/keys";
import { Tags, tags as tagsService } from "../lib/proto";
import { TagOptionsButton } from "./tag-options-button";
import TagEditMode from "./TagEditMode.svelte";
@ -257,17 +258,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function onKeydown(event: KeyboardEvent): void {
if (isArrowUp(event)) {
autocomplete.selectPrevious();
event.preventDefault();
return;
} else if (isArrowDown(event)) {
autocomplete.selectNext();
event.preventDefault();
return;
}
switch (event.code) {
case "ArrowUp":
autocomplete.selectPrevious();
event.preventDefault();
break;
case "ArrowDown":
autocomplete.selectNext();
event.preventDefault();
break;
case "Tab":
if (!$show) {
break;

View file

@ -5,6 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts">
import { createEventDispatcher, onMount, tick } from "svelte";
import { isArrowLeft, isArrowRight } from "../lib/keys";
import { registerShortcut } from "../lib/shortcuts";
import {
delimChar,
@ -168,27 +169,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onDelete(event);
}
break;
case "ArrowLeft":
if (isEmpty()) {
joinWithPreviousTag(event);
} else if (caretAtStart()) {
dispatch("tagmoveprevious");
event.preventDefault();
}
break;
case "ArrowRight":
if (isEmpty()) {
joinWithNextTag(event);
} else if (caretAtEnd()) {
dispatch("tagmovenext");
event.preventDefault();
}
break;
}
if (event.key === " ") {
if (isArrowLeft(event)) {
if (isEmpty()) {
joinWithPreviousTag(event);
} else if (caretAtStart()) {
dispatch("tagmoveprevious");
event.preventDefault();
}
} else if (isArrowRight(event)) {
if (isEmpty()) {
joinWithNextTag(event);
} else if (caretAtEnd()) {
dispatch("tagmovenext");
event.preventDefault();
}
} else if (event.key === " ") {
onDelimiter(event, false);
} else if (event.key === ":") {
onDelimiter(event, true);

View file

@ -136,7 +136,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<slot {createAutocomplete} />
</span>
<Popover slot="floating">
<Popover slot="floating" --popover-padding-inline="0">
<div class="autocomplete-menu">
{#each suggestionsItems as suggestion, index}
{#if index === selected}

View file

@ -1609,6 +1609,11 @@ char-regex@^1.0.2:
resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"
integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==
character-entities@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22"
integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
@ -2614,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=
gemoji@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/gemoji/-/gemoji-7.1.0.tgz#165403777681a9690d649aabd104da037bdd7739"
integrity sha512-wI0YWDIfQraQMDs0yXAVQiVBZeMm/rIYssf8LZlMDdssKF19YqJKOHkv4zvwtVQTBJ0LNmErv1S+DqlVUudz8g==
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"