From 610ef8f04396db124909975b6842178f1116e77f Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Tue, 9 Nov 2021 03:53:39 +0100 Subject: [PATCH] Save and restore location on ContentEditable (#1481) * Add utility functions for saving and restoring the caret location * Implement cross-browser.getSelection * Save and restore location on ContentEditable * Fix refocus by clicking on a field that had a non-collapsed selection --- sass/base.scss | 5 ++- ts/domlib/BUILD.bazel | 24 +++++++++++++ ts/domlib/index.ts | 4 +++ ts/domlib/location/document.ts | 55 ++++++++++++++++++++++++++++++ ts/domlib/location/index.ts | 14 ++++++++ ts/domlib/location/location.ts | 40 ++++++++++++++++++++++ ts/domlib/location/node.ts | 41 ++++++++++++++++++++++ ts/domlib/location/range.ts | 33 ++++++++++++++++++ ts/domlib/location/selection.ts | 53 ++++++++++++++++++++++++++++ ts/domlib/tsconfig.json | 8 +++++ ts/editable/BUILD.bazel | 2 ++ ts/editable/ContentEditable.svelte | 38 +++++++++++++++++++-- ts/editable/tsconfig.json | 1 + ts/editor/RichTextInput.svelte | 25 +++++++------- ts/editor/index.ts | 8 ++--- ts/lib/cross-browser.ts | 15 ++++++++ ts/lib/events.ts | 12 +++++++ ts/tsconfig.json | 5 +-- 18 files changed, 360 insertions(+), 23 deletions(-) create mode 100644 ts/domlib/BUILD.bazel create mode 100644 ts/domlib/index.ts create mode 100644 ts/domlib/location/document.ts create mode 100644 ts/domlib/location/index.ts create mode 100644 ts/domlib/location/location.ts create mode 100644 ts/domlib/location/node.ts create mode 100644 ts/domlib/location/range.ts create mode 100644 ts/domlib/location/selection.ts create mode 100644 ts/domlib/tsconfig.json create mode 100644 ts/lib/cross-browser.ts create mode 100644 ts/lib/events.ts diff --git a/sass/base.scss b/sass/base.scss index 27c8612cf..e6de55a92 100644 --- a/sass/base.scss +++ b/sass/base.scss @@ -50,7 +50,10 @@ button { transition: color 0.15s ease-in-out, box-shadow 0.15s ease-in-out !important; } -pre, code, kbd, samp { +pre, +code, +kbd, +samp { unicode-bidi: normal !important; } diff --git a/ts/domlib/BUILD.bazel b/ts/domlib/BUILD.bazel new file mode 100644 index 000000000..6f5b455a4 --- /dev/null +++ b/ts/domlib/BUILD.bazel @@ -0,0 +1,24 @@ +load("//ts:typescript.bzl", "typescript") +load("//ts:prettier.bzl", "prettier_test") +load("//ts:eslint.bzl", "eslint_test") +load("//ts:jest.bzl", "jest_test") + +typescript( + name = "domlib", + deps = [ + "//ts/lib", + "@npm//@fluent/bundle", + "@npm//@types/jest", + "@npm//@types/long", + "@npm//intl-pluralrules", + "@npm//protobufjs", + "@npm//tslib", + ], +) + +# Tests +################ + +prettier_test() + +eslint_test() diff --git a/ts/domlib/index.ts b/ts/domlib/index.ts new file mode 100644 index 000000000..889dc9e40 --- /dev/null +++ b/ts/domlib/index.ts @@ -0,0 +1,4 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +export * as location from "./location"; diff --git a/ts/domlib/location/document.ts b/ts/domlib/location/document.ts new file mode 100644 index 000000000..f16586d4e --- /dev/null +++ b/ts/domlib/location/document.ts @@ -0,0 +1,55 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import type { SelectionLocation, SelectionLocationContent } from "./selection"; +import { getSelectionLocation } from "./selection"; +import { findNodeFromCoordinates } from "./node"; +import { getSelection } from "../../lib/cross-browser"; + +export function saveSelection(base: Node): SelectionLocation | null { + return getSelectionLocation(base); +} + +function unselect(selection: Selection): void { + selection.empty(); +} + +function setSelectionToLocationContent( + node: Node, + selection: Selection, + range: Range, + location: SelectionLocationContent, +) { + const focusLocation = location.focus; + const focusOffset = focusLocation.offset; + const focusNode = findNodeFromCoordinates(node, focusLocation.coordinates); + + if (location.direction === "forward") { + range.setEnd(focusNode!, focusOffset!); + selection.addRange(range); + } /* location.direction === "backward" */ else { + selection.addRange(range); + selection.extend(focusNode!, focusOffset!); + } +} + +export function restoreSelection(base: Node, location: SelectionLocation): void { + const selection = getSelection(base)!; + unselect(selection); + + const range = new Range(); + const anchorNode = findNodeFromCoordinates(base, location.anchor.coordinates); + range.setStart(anchorNode!, location.anchor.offset!); + + if (location.collapsed) { + range.collapse(true); + selection.addRange(range); + } else { + setSelectionToLocationContent( + base, + selection, + range, + location as SelectionLocationContent, + ); + } +} diff --git a/ts/domlib/location/index.ts b/ts/domlib/location/index.ts new file mode 100644 index 000000000..38d207e0f --- /dev/null +++ b/ts/domlib/location/index.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 + +import { registerPackage } from "../../lib/register-package"; + +import { saveSelection, restoreSelection } from "./document"; + +registerPackage("anki/location", { + saveSelection, + restoreSelection, +}); + +export { saveSelection, restoreSelection }; +export type { SelectionLocation } from "./selection"; diff --git a/ts/domlib/location/location.ts b/ts/domlib/location/location.ts new file mode 100644 index 000000000..fb4badffb --- /dev/null +++ b/ts/domlib/location/location.ts @@ -0,0 +1,40 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +export interface CaretLocation { + coordinates: number[]; + offset: number; +} + +export enum Order { + LessThan, + Equal, + GreaterThan, +} + +export function compareLocations(first: CaretLocation, second: CaretLocation): Order { + const smallerLength = Math.min(first.coordinates.length, second.coordinates.length); + + for (let i = 0; i <= smallerLength; i++) { + if (first.coordinates.length === i) { + if (second.coordinates.length === i) { + if (first.offset < second.offset) { + return Order.LessThan; + } else if (first.offset > second.offset) { + return Order.GreaterThan; + } else { + return Order.Equal; + } + } + return Order.LessThan; + } else if (second.coordinates.length === i) { + return Order.GreaterThan; + } else if (first.coordinates[i] < second.coordinates[i]) { + return Order.LessThan; + } else if (first.coordinates[i] > second.coordinates[i]) { + return Order.GreaterThan; + } + } + + throw new Error("compareLocations: Should never happen"); +} diff --git a/ts/domlib/location/node.ts b/ts/domlib/location/node.ts new file mode 100644 index 000000000..d39ed8bfd --- /dev/null +++ b/ts/domlib/location/node.ts @@ -0,0 +1,41 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +function getNodeCoordinatesRecursive( + node: Node, + base: Node, + coordinates: number[], +): number[] { + /* parentNode: Element | Document | DocumentFragment */ + if (!node.parentNode || node === base) { + return coordinates; + } else { + const parent = node.parentNode; + const newCoordinates = [ + Array.prototype.indexOf.call(node.parentNode.childNodes, node), + ...coordinates, + ]; + return getNodeCoordinatesRecursive(parent, base, newCoordinates); + } +} + +export function getNodeCoordinates(node: Node, base: Node): number[] { + return getNodeCoordinatesRecursive(node, base, []); +} + +export function findNodeFromCoordinates( + base: Node, + coordinates: number[], +): Node | null { + if (coordinates.length === 0) { + return base; + } else if (!base.childNodes[coordinates[0]]) { + return null; + } else { + const [firstCoordinate, ...restCoordinates] = coordinates; + return findNodeFromCoordinates( + base.childNodes[firstCoordinate], + restCoordinates, + ); + } +} diff --git a/ts/domlib/location/range.ts b/ts/domlib/location/range.ts new file mode 100644 index 000000000..38796ca34 --- /dev/null +++ b/ts/domlib/location/range.ts @@ -0,0 +1,33 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { getNodeCoordinates } from "./node"; +import type { CaretLocation } from "./location"; + +interface RangeCoordinatesCollapsed { + readonly start: CaretLocation; + readonly collapsed: true; +} + +interface RangeCoordinatesContent { + readonly start: CaretLocation; + readonly end: CaretLocation; + readonly collapsed: false; +} + +export type RangeCoordinates = RangeCoordinatesCollapsed | RangeCoordinatesContent; + +export function getRangeCoordinates(base: Node, range: Range): RangeCoordinates { + const startCoordinates = getNodeCoordinates(base, range.startContainer); + const start = { coordinates: startCoordinates, offset: range.startOffset }; + const collapsed = range.collapsed; + + if (collapsed) { + return { start, collapsed }; + } + + const endCoordinates = getNodeCoordinates(base, range.endContainer); + const end = { coordinates: endCoordinates, offset: range.endOffset }; + + return { start, end, collapsed }; +} diff --git a/ts/domlib/location/selection.ts b/ts/domlib/location/selection.ts new file mode 100644 index 000000000..8346efe8d --- /dev/null +++ b/ts/domlib/location/selection.ts @@ -0,0 +1,53 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { getNodeCoordinates } from "./node"; +import type { CaretLocation } from "./location"; +import { compareLocations, Order } from "./location"; +import { getSelection } from "../../lib/cross-browser"; + +export interface SelectionLocationCollapsed { + readonly anchor: CaretLocation; + readonly collapsed: true; +} + +export interface SelectionLocationContent { + readonly anchor: CaretLocation; + readonly focus: CaretLocation; + readonly collapsed: false; + readonly direction: "forward" | "backward"; +} + +export type SelectionLocation = SelectionLocationCollapsed | SelectionLocationContent; + +/* Gecko can have multiple ranges in the selection +/* this function will get the coordinates of the latest one created */ +export function getSelectionLocation(base: Node): SelectionLocation | null { + const selection = getSelection(base)!; + + if (selection.rangeCount === 0) { + return null; + } + + const anchorCoordinates = getNodeCoordinates(selection.anchorNode!, base); + const anchor = { coordinates: anchorCoordinates, offset: selection.anchorOffset }; + /* selection.isCollapsed will always return true in shadow root in Gecko */ + const collapsed = selection.getRangeAt(selection.rangeCount - 1).collapsed; + + if (collapsed) { + return { anchor, collapsed }; + } + + const focusCoordinates = getNodeCoordinates(selection.focusNode!, base); + const focus = { coordinates: focusCoordinates, offset: selection.focusOffset }; + const order = compareLocations(anchor, focus); + + const direction = order === Order.GreaterThan ? "backward" : "forward"; + + return { + anchor, + focus, + collapsed, + direction, + }; +} diff --git a/ts/domlib/tsconfig.json b/ts/domlib/tsconfig.json new file mode 100644 index 000000000..db63ff9ea --- /dev/null +++ b/ts/domlib/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "include": ["*", "location/*"], + "references": [{ "path": "../lib" }], + "compilerOptions": { + "types": ["jest"] + } +} diff --git a/ts/editable/BUILD.bazel b/ts/editable/BUILD.bazel index ffa301bd0..d8498a4d2 100644 --- a/ts/editable/BUILD.bazel +++ b/ts/editable/BUILD.bazel @@ -21,6 +21,7 @@ compile_sass( _ts_deps = [ "//ts/components", "//ts/lib", + "//ts/domlib", "//ts/sveltelib", "@npm//mathjax", "@npm//mathjax-full", @@ -67,5 +68,6 @@ svelte_check( "*.svelte", ]) + [ "//ts/components", + "//ts/domlib", ], ) diff --git a/ts/editable/ContentEditable.svelte b/ts/editable/ContentEditable.svelte index 7e2f0e5dd..4547e8316 100644 --- a/ts/editable/ContentEditable.svelte +++ b/ts/editable/ContentEditable.svelte @@ -5,6 +5,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/ts/editable/tsconfig.json b/ts/editable/tsconfig.json index a3fb6b2dc..2a86c8fcc 100644 --- a/ts/editable/tsconfig.json +++ b/ts/editable/tsconfig.json @@ -4,6 +4,7 @@ "references": [ { "path": "../components" }, { "path": "../lib" }, + { "path": "../domlib" }, { "path": "../sveltelib" } ] } diff --git a/ts/editor/RichTextInput.svelte b/ts/editor/RichTextInput.svelte index bdcb31153..d164bdcba 100644 --- a/ts/editor/RichTextInput.svelte +++ b/ts/editor/RichTextInput.svelte @@ -161,19 +161,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const allContexts = getAllContexts(); function attachContentEditable(element: Element, { stylesDidLoad }): void { - stylesDidLoad.then(() => { - const contentEditable = new ContentEditable({ - target: element.shadowRoot!, - props: { - nodes, - resolve, - mirror, - }, - context: allContexts, - }); - - contentEditable.$on("focus", moveCaretToEnd); - }); + stylesDidLoad.then( + () => + new ContentEditable({ + target: element.shadowRoot!, + props: { + nodes, + resolve, + mirror, + }, + context: allContexts, + }), + ); } export const api: RichTextInputAPI = { diff --git a/ts/editor/index.ts b/ts/editor/index.ts index 6b1005897..2c639b1fa 100644 --- a/ts/editor/index.ts +++ b/ts/editor/index.ts @@ -8,6 +8,9 @@ import "./editor-base.css"; @typescript-eslint/no-explicit-any: "off", */ +import "../sveltelib/export-runtime"; +import "../lib/register-package"; + import { filterHTML } from "../html-filter"; import { execCommand } from "./helpers"; import { updateAllState } from "../components/WithState.svelte"; @@ -29,11 +32,6 @@ export function setFormat(cmd: string, arg?: string, _nosave = false): void { updateAllState(new Event(cmd)); } -export { editorToolbar } from "./EditorToolbar.svelte"; - -import "../sveltelib/export-runtime"; -import "../lib/register-package"; - import { setupI18n, ModuleName } from "../lib/i18n"; import { isApplePlatform } from "../lib/platform"; import { registerShortcut } from "../lib/shortcuts"; diff --git a/ts/lib/cross-browser.ts b/ts/lib/cross-browser.ts new file mode 100644 index 000000000..fed436223 --- /dev/null +++ b/ts/lib/cross-browser.ts @@ -0,0 +1,15 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/** + * Firefox has no .getSelection on ShadowRoot, only .activeElement + */ +export function getSelection(element: Node): Selection | null { + const root = element.getRootNode(); + + if (root.getSelection) { + return root.getSelection(); + } + + return null; +} diff --git a/ts/lib/events.ts b/ts/lib/events.ts new file mode 100644 index 000000000..a63f7ca86 --- /dev/null +++ b/ts/lib/events.ts @@ -0,0 +1,12 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +export function on( + target: T, + eventType: string, + listener: L, + options: AddEventListenerOptions = {}, +): () => void { + target.addEventListener(eventType, listener, options); + return () => target.removeEventListener(eventType, listener, options); +} diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 636dcbbf9..802508893 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -9,9 +9,10 @@ { "path": "editor" }, { "path": "graphs" }, { "path": "html-filter" }, - { "path": "lib" }, { "path": "reviewer" }, - { "path": "sveltelib" } + { "path": "lib" }, + { "path": "domlib" }, + { "path": "sveltelib" }, ], "compilerOptions": { "declaration": true,