From 64d46ca638b7b68e53d6915d0471fa67cbc8a569 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Thu, 18 Nov 2021 10:18:39 +0100 Subject: [PATCH] Reverse-engineer surrounding with execCommand (#1377) * Add utility functions for saving and restoring the caret location Implement surroundNoSplitting Clarify surroundNoSplitting comments Start implementing surroundSplitting and triggerIfSimpleInput Fix after rebase Implement findBefore / findAfter in lib/surround * to merge adjacent nodes into the surrounding nodes Use new prettier settings with lib/{location,surround} Fix imports that I missed to rename Add some tests for find-adjacent Split find-within from find-adjacent Normalize nodes after insertion in surroundNoSplitting Do not deep clone surroundNode -> no intention of supporting deep nodes, as normalization would be impossible Add some tests concerning nested surrounding nested nodes Select surroundedRange after surrounding Fix ascendWhileSingleInline A flawed first surround/trigger implementation Move trigger out of lib/surround Implement Input Manager as a way to handle bold on empty selection Switch bold button away from execCommand Pass in Matcher instead of selector to find-adjacent and surroundNoSplitting * Also adds a failing test for no-splitting Refactor find-adjacent * add failing test when findBefore's nodes have different amounts of child nodes Change type signature of find-adjacent methods to more single-concern Add test for surrounding where adjacent block becomes three Text elements Make nodes found within surrounded range extend the ranges endOffset Add base parameter to surroundNoSplitting to stop ascending beyond container Stop surrounding from bubbling beyond base in merge-match Make all tests pass Add some failing tests to point to future development Add empty elements as constant Implement a broken version of unsurround Even split text if it creates zero-length texts -> they are still valid, despite what Chromium says Rename {start,end} to {start,end}Container Add more unit tests with surround after a nested element Set endOffset after split-off possibly zero length text nodes Deal with empty elements when surrounding Only include split off end text if zero length Use range anchors instead off calcluating surroundedRange from offsets * this approach allows for removal of base elements when unsurrounding Comment out test which fail because of jsdom bugs We'll be able to enable them again after Jest 28 Make the first unsurround tests pass Add new failing test for unsurround text within tag Fix unsurround Test is deactivated until Jest 28 Rewrite input-manager and trigger callback after insertion Avoid creating zero length text nodes by using insertBefore when appropriate Implement matches vs keepMatches Make shadow root and editable element available on component tree Make WithState work with asynchronous updater functions Add new Bold/Italic/UnderlineButton using our logic Add failing test for unsurrounding * Move surround/ to domlib * Add jest dependency * Make find-within return a sum type array rather than two arrays * Use FoundMatch sum-type for find-above (and find-within) * Fix issue where elements could be cleared twice * if they are IN the range.endContainer * Pass remaining test * Add another failing test * Fix empty text nodes being considered for surrounding * Satisfy svelte check * Make on more type correct * Satisfy remaining tests * Add missing copyright header --- ts/components/WithState.svelte | 36 +- ts/domlib/BUILD.bazel | 5 + ts/domlib/index.ts | 1 + ts/domlib/location/index.ts | 4 +- ts/domlib/location/location.ts | 26 +- ts/domlib/location/selection.ts | 4 +- ts/domlib/surround/ascend.ts | 22 ++ ts/domlib/surround/child-node-range.ts | 111 ++++++ ts/domlib/surround/find-above.ts | 60 +++ ts/domlib/surround/find-adjacent.test.ts | 67 ++++ ts/domlib/surround/find-adjacent.ts | 125 +++++++ ts/domlib/surround/find-within.ts | 70 ++++ ts/domlib/surround/index.ts | 20 + ts/domlib/surround/matcher.ts | 34 ++ ts/domlib/surround/merge-match.ts | 101 +++++ ts/domlib/surround/no-splitting.test.ts | 350 ++++++++++++++++++ ts/domlib/surround/no-splitting.ts | 100 +++++ .../surround/normalize-insertion-ranges.ts | 181 +++++++++ ts/domlib/surround/range-anchors.ts | 92 +++++ ts/domlib/surround/text-node.ts | 64 ++++ ts/domlib/surround/unsurround.test.ts | 135 +++++++ ts/domlib/surround/unsurround.ts | 227 ++++++++++++ ts/domlib/surround/within-range.ts | 16 + ts/domlib/tsconfig.json | 2 +- ts/editable/ContentEditable.svelte | 18 +- ts/editor/BUILD.bazel | 1 + ts/editor/BoldButton.svelte | 93 +++++ ts/editor/CommandIconButton.svelte | 5 +- ts/editor/FormatInlineButtons.svelte | 30 +- ts/editor/ItalicButton.svelte | 92 +++++ ts/editor/RichTextInput.svelte | 14 +- ts/editor/UnderlineButton.svelte | 76 ++++ ts/editor/index.ts | 1 + ts/editor/surround.ts | 87 +++++ ts/editor/tsconfig.json | 1 + ts/jest.bzl | 2 +- ts/lib/BUILD.bazel | 1 + ts/lib/cross-browser.ts | 2 +- ts/lib/dom.ts | 36 +- ts/lib/events.ts | 33 +- ts/lib/keys.ts | 7 + ts/lib/node.ts | 14 + ts/lib/shadow-dom.d.ts | 2 +- ts/lib/shortcuts.ts | 15 +- ts/lib/tsconfig.json | 2 +- ts/sveltelib/input-manager.ts | 101 +++++ 46 files changed, 2413 insertions(+), 73 deletions(-) create mode 100644 ts/domlib/surround/ascend.ts create mode 100644 ts/domlib/surround/child-node-range.ts create mode 100644 ts/domlib/surround/find-above.ts create mode 100644 ts/domlib/surround/find-adjacent.test.ts create mode 100644 ts/domlib/surround/find-adjacent.ts create mode 100644 ts/domlib/surround/find-within.ts create mode 100644 ts/domlib/surround/index.ts create mode 100644 ts/domlib/surround/matcher.ts create mode 100644 ts/domlib/surround/merge-match.ts create mode 100644 ts/domlib/surround/no-splitting.test.ts create mode 100644 ts/domlib/surround/no-splitting.ts create mode 100644 ts/domlib/surround/normalize-insertion-ranges.ts create mode 100644 ts/domlib/surround/range-anchors.ts create mode 100644 ts/domlib/surround/text-node.ts create mode 100644 ts/domlib/surround/unsurround.test.ts create mode 100644 ts/domlib/surround/unsurround.ts create mode 100644 ts/domlib/surround/within-range.ts create mode 100644 ts/editor/BoldButton.svelte create mode 100644 ts/editor/ItalicButton.svelte create mode 100644 ts/editor/UnderlineButton.svelte create mode 100644 ts/editor/surround.ts create mode 100644 ts/lib/node.ts create mode 100644 ts/sveltelib/input-manager.ts diff --git a/ts/components/WithState.svelte b/ts/components/WithState.svelte index d77ac6ccc..4f14af126 100644 --- a/ts/components/WithState.svelte +++ b/ts/components/WithState.svelte @@ -6,14 +6,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { writable } from "svelte/store"; type KeyType = Symbol | string; - type UpdaterMap = Map boolean>; - type StateMap = Map; + type UpdaterMap = Map Promise>; + type StateMap = Map>; - const updaterMap = new Map() as UpdaterMap; - const stateMap = new Map() as StateMap; + const updaterMap: UpdaterMap = new Map(); + const stateMap: StateMap = new Map(); const stateStore = writable(stateMap); - function updateAllStateWithCallback(callback: (key: KeyType) => boolean): void { + function updateAllStateWithCallback( + callback: (key: KeyType) => Promise, + ): void { stateStore.update((map: StateMap): StateMap => { const newMap = new Map() as StateMap; @@ -26,13 +28,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } export function updateAllState(event: Event): void { - updateAllStateWithCallback((key: KeyType): boolean => - updaterMap.get(key)!(event), + updateAllStateWithCallback( + (key: KeyType): Promise => updaterMap.get(key)!(event), ); } export function resetAllState(state: boolean): void { - updateAllStateWithCallback((): boolean => state); + updateAllStateWithCallback((): Promise => Promise.resolve(state)); } function updateStateByKey(key: KeyType, event: Event): void { @@ -45,18 +47,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/ts/editor/BUILD.bazel b/ts/editor/BUILD.bazel index 615971dc4..49dfffe89 100644 --- a/ts/editor/BUILD.bazel +++ b/ts/editor/BUILD.bazel @@ -25,6 +25,7 @@ _ts_deps = [ "//ts/editable:editable_ts", "//ts/html-filter", "//ts/lib", + "//ts/domlib", "//ts/sveltelib", "@npm//@fluent", "@npm//@types/codemirror", diff --git a/ts/editor/BoldButton.svelte b/ts/editor/BoldButton.svelte new file mode 100644 index 000000000..c804af439 --- /dev/null +++ b/ts/editor/BoldButton.svelte @@ -0,0 +1,93 @@ + + + + + { + makeBold(); + updateState(event); + }} + > + {@html boldIcon} + + + { + makeBold(); + updateState(event); + }} + /> + diff --git a/ts/editor/CommandIconButton.svelte b/ts/editor/CommandIconButton.svelte index 40be0c403..0a9f6c2c2 100644 --- a/ts/editor/CommandIconButton.svelte +++ b/ts/editor/CommandIconButton.svelte @@ -19,6 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let withoutState = false; const { focusInRichText } = getNoteEditor(); + $: disabled = !$focusInRichText; @@ -29,7 +30,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {:else if withoutShortcut} queryCommandState(key)} + update={async () => queryCommandState(key)} let:state={active} let:updateState > @@ -60,7 +61,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html queryCommandState(key)} + update={async () => queryCommandState(key)} let:state={active} let:updateState > diff --git a/ts/editor/FormatInlineButtons.svelte b/ts/editor/FormatInlineButtons.svelte index eb85580b5..748c92a43 100644 --- a/ts/editor/FormatInlineButtons.svelte +++ b/ts/editor/FormatInlineButtons.svelte @@ -6,43 +6,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import ButtonGroup from "../components/ButtonGroup.svelte"; import ButtonGroupItem from "../components/ButtonGroupItem.svelte"; import CommandIconButton from "./CommandIconButton.svelte"; + import BoldButton from "./BoldButton.svelte"; + import ItalicButton from "./ItalicButton.svelte"; + import UnderlineButton from "./UnderlineButton.svelte"; import * as tr from "../lib/ftl"; - import { - boldIcon, - italicIcon, - underlineIcon, - superscriptIcon, - subscriptIcon, - eraserIcon, - } from "./icons"; + import { superscriptIcon, subscriptIcon, eraserIcon } from "./icons"; export let api = {}; - {@html boldIcon} + - {@html italicIcon} + - {@html underlineIcon} + diff --git a/ts/editor/ItalicButton.svelte b/ts/editor/ItalicButton.svelte new file mode 100644 index 000000000..d23574823 --- /dev/null +++ b/ts/editor/ItalicButton.svelte @@ -0,0 +1,92 @@ + + + + + { + makeItalic(); + updateState(event); + }} + > + {@html italicIcon} + + + { + makeItalic(); + updateState(event); + }} + /> + diff --git a/ts/editor/RichTextInput.svelte b/ts/editor/RichTextInput.svelte index d164bdcba..201e2cf4c 100644 --- a/ts/editor/RichTextInput.svelte +++ b/ts/editor/RichTextInput.svelte @@ -6,14 +6,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type CustomStyles from "./CustomStyles.svelte"; import type { EditingInputAPI } from "./EditingArea.svelte"; import contextProperty from "../sveltelib/context-property"; + import type { OnInsertCallback } from "../sveltelib/input-manager"; export interface RichTextInputAPI extends EditingInputAPI { name: "rich-text"; + shadowRoot: Promise; + element: Promise; moveCaretToEnd(): void; refocus(): void; toggle(): boolean; surround(before: string, after: string): void; preventResubscription(): () => void; + triggerOnInsert(callback: OnInsertCallback): () => void; } export interface RichTextInputContextAPI { @@ -120,8 +124,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html content.set(output); } + const [shadowPromise, shadowResolve] = promiseWithResolver(); + function attachShadow(element: Element): void { - element.attachShadow({ mode: "open" }); + shadowResolve(element.attachShadow({ mode: "open" })); } const [richTextPromise, richTextResolve] = promiseWithResolver(); @@ -151,8 +157,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } import getDOMMirror from "../sveltelib/mirror-dom"; + import getInputManager from "../sveltelib/input-manager"; const { mirror, preventResubscription } = getDOMMirror(); + const { manager, triggerOnInsert } = getInputManager(); function moveCaretToEnd() { richTextPromise.then(caretToEnd); @@ -169,6 +177,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html nodes, resolve, mirror, + inputManager: manager, }, context: allContexts, }), @@ -177,6 +186,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export const api: RichTextInputAPI = { name: "rich-text", + shadowRoot: shadowPromise, + element: richTextPromise, focus() { richTextPromise.then((richText) => richText.focus()); }, @@ -198,6 +209,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ); }, preventResubscription, + triggerOnInsert, }; function pushUpdate(): void { diff --git a/ts/editor/UnderlineButton.svelte b/ts/editor/UnderlineButton.svelte new file mode 100644 index 000000000..efdb6f685 --- /dev/null +++ b/ts/editor/UnderlineButton.svelte @@ -0,0 +1,76 @@ + + + + + { + makeUnderline(); + updateState(event); + }} + > + {@html underlineIcon} + + + { + makeUnderline(); + updateState(event); + }} + /> + diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 2c639b1fa..26a23e605 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -10,6 +10,7 @@ import "./editor-base.css"; import "../sveltelib/export-runtime"; import "../lib/register-package"; +import "../domlib/surround"; import { filterHTML } from "../html-filter"; import { execCommand } from "./helpers"; diff --git a/ts/editor/surround.ts b/ts/editor/surround.ts new file mode 100644 index 000000000..df7532b75 --- /dev/null +++ b/ts/editor/surround.ts @@ -0,0 +1,87 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import type { RichTextInputAPI } from "./RichTextInput.svelte"; +import { getSelection } from "../lib/cross-browser"; +import { surroundNoSplitting, unsurround, findClosest } from "../domlib/surround"; +import type { ElementMatcher, ElementClearer } from "../domlib/surround"; + +export function isSurroundedInner( + range: AbstractRange, + base: HTMLElement, + matcher: ElementMatcher, +): boolean { + return Boolean( + findClosest(range.startContainer, base, matcher) || + findClosest(range.endContainer, base, matcher), + ); +} + +export async function isSurrounded( + input: RichTextInputAPI, + matcher: ElementMatcher, +): Promise { + const base = await input.element; + const selection = getSelection(base)!; + const range = selection.getRangeAt(0); + + return isSurroundedInner(range, base, matcher); +} + +function surroundAndSelect( + matches: boolean, + range: Range, + selection: Selection, + surroundElement: Element, + base: HTMLElement, + matcher: ElementMatcher, + clearer: ElementClearer, +): void { + const { surroundedRange } = matches + ? unsurround(range, surroundElement, base, matcher, clearer) + : surroundNoSplitting(range, surroundElement, base, matcher, clearer); + + selection.removeAllRanges(); + selection.addRange(surroundedRange); +} + +export async function surroundCommand( + input: RichTextInputAPI, + surroundElement: Element, + matcher: ElementMatcher, + clearer: ElementClearer = () => false, +): Promise { + const base = await input.element; + const selection = getSelection(base)!; + const range = selection.getRangeAt(0); + + if (range.collapsed) { + input.triggerOnInsert(async ({ node }): Promise => { + range.selectNode(node); + + const matches = Boolean(findClosest(node, base, matcher)); + surroundAndSelect( + matches, + range, + selection, + surroundElement, + base, + matcher, + clearer, + ); + + selection.collapseToEnd(); + }); + } else { + const matches = isSurroundedInner(range, base, matcher); + surroundAndSelect( + matches, + range, + selection, + surroundElement, + base, + matcher, + clearer, + ); + } +} diff --git a/ts/editor/tsconfig.json b/ts/editor/tsconfig.json index b3f6b5498..830ad7ccc 100644 --- a/ts/editor/tsconfig.json +++ b/ts/editor/tsconfig.json @@ -3,6 +3,7 @@ "include": ["*", "image-overlay/*", "mathjax-overlay/*"], "references": [ { "path": "../components" }, + { "path": "../domlib" }, { "path": "../lib" }, { "path": "../sveltelib" }, { "path": "../editable" }, diff --git a/ts/jest.bzl b/ts/jest.bzl index 8753b7d85..142530c7c 100644 --- a/ts/jest.bzl +++ b/ts/jest.bzl @@ -2,7 +2,7 @@ load("@npm//@bazel/esbuild:index.bzl", "esbuild") load("@npm//jest-cli:index.bzl", _jest_test = "jest_test") def jest_test(deps, name = "jest", protobuf = False, env = "node"): - ts_sources = native.glob(["*.test.ts"]) + ts_sources = native.glob(["**/*.test.ts"]) # bundle each test file up with its dependencies for jest bundled_srcs = [] diff --git a/ts/lib/BUILD.bazel b/ts/lib/BUILD.bazel index 9992053cc..1e4badde2 100644 --- a/ts/lib/BUILD.bazel +++ b/ts/lib/BUILD.bazel @@ -64,6 +64,7 @@ prettier_test() eslint_test() jest_test( + env = "jsdom", deps = [ ":lib", ], diff --git a/ts/lib/cross-browser.ts b/ts/lib/cross-browser.ts index fed436223..c6e8baa01 100644 --- a/ts/lib/cross-browser.ts +++ b/ts/lib/cross-browser.ts @@ -11,5 +11,5 @@ export function getSelection(element: Node): Selection | null { return root.getSelection(); } - return null; + return document.getSelection(); } diff --git a/ts/lib/dom.ts b/ts/lib/dom.ts index 0a9b26c32..81f3b5f43 100644 --- a/ts/lib/dom.ts +++ b/ts/lib/dom.ts @@ -5,8 +5,12 @@ export function nodeIsElement(node: Node): node is Element { return node.nodeType === Node.ELEMENT_NODE; } +export function nodeIsText(node: Node): node is Text { + return node.nodeType === Node.TEXT_NODE; +} + // https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements -const BLOCK_TAGS = [ +const BLOCK_ELEMENTS = [ "ADDRESS", "ARTICLE", "ASIDE", @@ -43,7 +47,29 @@ const BLOCK_TAGS = [ ]; export function elementIsBlock(element: Element): boolean { - return BLOCK_TAGS.includes(element.tagName); + return BLOCK_ELEMENTS.includes(element.tagName); +} + +// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element +const EMPTY_ELEMENTS = [ + "AREA", + "BASE", + "BR", + "COL", + "EMBED", + "HR", + "IMG", + "INPUT", + "LINK", + "META", + "PARAM", + "SOURCE", + "TRACK", + "WBR", +]; + +export function elementIsEmpty(element: Element): boolean { + return EMPTY_ELEMENTS.includes(element.tagName); } export function nodeContainsInlineContent(node: Node): boolean { @@ -68,6 +94,12 @@ export function fragmentToString(fragment: DocumentFragment): string { return html; } +export const NO_SPLIT_TAGS = ["RUBY"]; + +export function elementShouldNotBeSplit(element: Element): boolean { + return elementIsBlock(element) || NO_SPLIT_TAGS.includes(element.tagName); +} + export function caretToEnd(node: Node): void { const range = new Range(); range.selectNodeContents(node); diff --git a/ts/lib/events.ts b/ts/lib/events.ts index a63f7ca86..37cbbd751 100644 --- a/ts/lib/events.ts +++ b/ts/lib/events.ts @@ -1,12 +1,33 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -export function on( +type EventTargetToMap = A extends HTMLElement + ? HTMLElementEventMap + : A extends Document + ? DocumentEventMap + : A extends Window + ? WindowEventMap + : A extends FileReader + ? FileReaderEventMap + : A extends Element + ? ElementEventMap + : A extends Animation + ? AnimationEventMap + : A extends EventSource + ? EventSourceEventMap + : A extends AbortSignal + ? AbortSignalEventMap + : A extends AbstractWorker + ? AbstractWorkerEventMap + : never; + +export function on>( target: T, - eventType: string, - listener: L, - options: AddEventListenerOptions = {}, + eventType: Exclude, + handler: (this: T, event: EventTargetToMap[K]) => void, + options?: AddEventListenerOptions, ): () => void { - target.addEventListener(eventType, listener, options); - return () => target.removeEventListener(eventType, listener, options); + target.addEventListener(eventType, handler as EventListener, options); + return () => + target.removeEventListener(eventType, handler as EventListener, options); } diff --git a/ts/lib/keys.ts b/ts/lib/keys.ts index ae7f0579e..c74fdd5da 100644 --- a/ts/lib/keys.ts +++ b/ts/lib/keys.ts @@ -16,6 +16,13 @@ function translateModifierToPlatform(modifier: Modifier): string { return platformModifiers[allModifiers.indexOf(modifier)]; } +const GENERAL_KEY = 0; +const NUMPAD_KEY = 3; + +export function checkIfInputKey(event: KeyboardEvent): boolean { + return event.location === GENERAL_KEY || event.location === NUMPAD_KEY; +} + export const checkModifiers = (required: Modifier[], optional: Modifier[] = []) => (event: KeyboardEvent): boolean => { diff --git a/ts/lib/node.ts b/ts/lib/node.ts new file mode 100644 index 000000000..b984f79ed --- /dev/null +++ b/ts/lib/node.ts @@ -0,0 +1,14 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +export function isOnlyChild(node: Node): boolean { + return node.parentNode!.childNodes.length === 1; +} + +export function hasOnlyChild(node: Node): boolean { + return node.childNodes.length === 1; +} + +export function ascend(node: Node): Node { + return node.parentNode!; +} diff --git a/ts/lib/shadow-dom.d.ts b/ts/lib/shadow-dom.d.ts index 54afa8757..efe062d4d 100644 --- a/ts/lib/shadow-dom.d.ts +++ b/ts/lib/shadow-dom.d.ts @@ -6,6 +6,6 @@ declare global { } interface Node { - getRootNode(options?: GetRootNodeOptions): DocumentOrShadowRoot; + getRootNode(options?: GetRootNodeOptions): Document | ShadowRoot; } } diff --git a/ts/lib/shortcuts.ts b/ts/lib/shortcuts.ts index fad8d8cdf..d2bd1a46f 100644 --- a/ts/lib/shortcuts.ts +++ b/ts/lib/shortcuts.ts @@ -4,7 +4,12 @@ import type { Modifier } from "./keys"; import { registerPackage } from "./register-package"; -import { modifiersToPlatformString, keyToPlatformString, checkModifiers } from "./keys"; +import { + modifiersToPlatformString, + keyToPlatformString, + checkModifiers, + checkIfInputKey, +} from "./keys"; const keyCodeLookup = { Backspace: 8, @@ -112,9 +117,6 @@ function keyCombinationToCheck( return check(keyCode, modifiers); } -const GENERAL_KEY = 0; -const NUMPAD_KEY = 3; - function innerShortcut( target: EventTarget | Document, lastEvent: KeyboardEvent, @@ -131,10 +133,7 @@ function innerShortcut( if (nextCheck(event)) { innerShortcut(target, event, callback, ...restChecks); clearTimeout(interval); - } else if ( - event.location === GENERAL_KEY || - event.location === NUMPAD_KEY - ) { + } else if (checkIfInputKey(event)) { // Any non-modifier key will cancel the shortcut sequence document.removeEventListener("keydown", handler); } diff --git a/ts/lib/tsconfig.json b/ts/lib/tsconfig.json index 39f73a570..210e7bdd4 100644 --- a/ts/lib/tsconfig.json +++ b/ts/lib/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../tsconfig.json", - "include": ["*", "i18n/*"], + "include": ["*", "i18n/*", "location/*", "surround/*"], "references": [], "compilerOptions": { "types": ["jest"] diff --git a/ts/sveltelib/input-manager.ts b/ts/sveltelib/input-manager.ts new file mode 100644 index 000000000..f94a3722b --- /dev/null +++ b/ts/sveltelib/input-manager.ts @@ -0,0 +1,101 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { on } from "../lib/events"; +import { nodeIsText } from "../lib/dom"; +import { getSelection } from "../lib/cross-browser"; + +export type OnInsertCallback = ({ node: Node }) => Promise; + +interface InputManager { + manager(element: HTMLElement): { destroy(): void }; + triggerOnInsert(callback: OnInsertCallback): () => void; +} + +export function getInputManager(): InputManager { + const onInsertText: OnInsertCallback[] = []; + + function cancelInsertText(): void { + onInsertText.length = 0; + } + + function cancelIfInsertText(event: KeyboardEvent): void { + if (event.key.length !== 1) { + cancelInsertText(); + } + } + + async function onBeforeInput(event: InputEvent): Promise { + if (event.inputType === "insertText" && onInsertText.length > 0) { + const nbsp = " "; + const textContent = event.data === " " ? nbsp : event.data ?? nbsp; + const node = new Text(textContent); + + const selection = getSelection(event.target! as Node)!; + const range = selection.getRangeAt(0); + + range.deleteContents(); + + if (nodeIsText(range.startContainer) && range.startOffset === 0) { + const parent = range.startContainer.parentNode!; + parent.insertBefore(node, range.startContainer); + } else if ( + nodeIsText(range.endContainer) && + range.endOffset === range.endContainer.length + ) { + const parent = range.endContainer.parentNode!; + parent.insertBefore(node, range.endContainer.nextSibling!); + } else { + range.insertNode(node); + } + + range.selectNode(node); + range.collapse(false); + + for (const callback of onInsertText) { + await callback({ node }); + } + + event.preventDefault(); + } + + cancelInsertText(); + } + + function manager(element: HTMLElement): { destroy(): void } { + const removeBeforeInput = on(element, "beforeinput", onBeforeInput); + const removePointerDown = on(element, "pointerdown", cancelInsertText); + const removeBlur = on(element, "blur", cancelInsertText); + const removeKeyDown = on( + element, + "keydown", + cancelIfInsertText as EventListener, + ); + + return { + destroy() { + removeBeforeInput(); + removePointerDown(); + removeBlur(); + removeKeyDown(); + }, + }; + } + + function triggerOnInsert(callback: OnInsertCallback): () => void { + onInsertText.push(callback); + return () => { + const index = onInsertText.indexOf(callback); + if (index > 0) { + onInsertText.splice(index, 1); + } + }; + } + + return { + manager, + triggerOnInsert, + }; +} + +export default getInputManager;