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
This commit is contained in:
Henrik Giesel 2021-11-09 03:53:39 +01:00 committed by GitHub
parent fc93a3d4d7
commit 610ef8f043
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 360 additions and 23 deletions

View File

@ -50,7 +50,10 @@ button {
transition: color 0.15s ease-in-out, box-shadow 0.15s ease-in-out !important; 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; unicode-bidi: normal !important;
} }

24
ts/domlib/BUILD.bazel Normal file
View File

@ -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()

4
ts/domlib/index.ts Normal file
View File

@ -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";

View File

@ -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,
);
}
}

View File

@ -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";

View File

@ -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");
}

View File

@ -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,
);
}
}

View File

@ -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 };
}

View File

@ -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,
};
}

8
ts/domlib/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"include": ["*", "location/*"],
"references": [{ "path": "../lib" }],
"compilerOptions": {
"types": ["jest"]
}
}

View File

@ -21,6 +21,7 @@ compile_sass(
_ts_deps = [ _ts_deps = [
"//ts/components", "//ts/components",
"//ts/lib", "//ts/lib",
"//ts/domlib",
"//ts/sveltelib", "//ts/sveltelib",
"@npm//mathjax", "@npm//mathjax",
"@npm//mathjax-full", "@npm//mathjax-full",
@ -67,5 +68,6 @@ svelte_check(
"*.svelte", "*.svelte",
]) + [ ]) + [
"//ts/components", "//ts/components",
"//ts/domlib",
], ],
) )

View File

@ -5,6 +5,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { updateAllState } from "../components/WithState.svelte"; import { updateAllState } from "../components/WithState.svelte";
import { saveSelection, restoreSelection } from "../domlib/location";
import { on } from "../lib/events";
export let nodes: Writable<DocumentFragment>; export let nodes: Writable<DocumentFragment>;
export let resolve: (editable: HTMLElement) => void; export let resolve: (editable: HTMLElement) => void;
@ -12,14 +14,46 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
editable: HTMLElement, editable: HTMLElement,
params: { store: Writable<DocumentFragment> }, params: { store: Writable<DocumentFragment> },
) => void; ) => void;
/* must execute before DOMMirror */
function saveLocation(editable: Element) {
let removeOnFocus: () => void;
let removeOnPointerdown: () => void;
const removeOnBlur = on(editable, "blur", () => {
const location = saveSelection(editable);
removeOnFocus = on(
editable,
"focus",
() => {
if (location) {
restoreSelection(editable, location);
}
},
{ once: true },
);
removeOnPointerdown = on(editable, "pointerdown", () => removeOnFocus?.(), {
once: true,
});
});
return {
destroy() {
removeOnBlur();
removeOnFocus?.();
removeOnPointerdown?.();
},
};
}
</script> </script>
<anki-editable <anki-editable
contenteditable="true" contenteditable="true"
use:resolve use:resolve
use:saveLocation
use:mirror={{ store: nodes }} use:mirror={{ store: nodes }}
on:focus
on:blur
on:click={updateAllState} on:click={updateAllState}
on:keyup={updateAllState} on:keyup={updateAllState}
/> />

View File

@ -4,6 +4,7 @@
"references": [ "references": [
{ "path": "../components" }, { "path": "../components" },
{ "path": "../lib" }, { "path": "../lib" },
{ "path": "../domlib" },
{ "path": "../sveltelib" } { "path": "../sveltelib" }
] ]
} }

View File

@ -161,19 +161,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const allContexts = getAllContexts(); const allContexts = getAllContexts();
function attachContentEditable(element: Element, { stylesDidLoad }): void { function attachContentEditable(element: Element, { stylesDidLoad }): void {
stylesDidLoad.then(() => { stylesDidLoad.then(
const contentEditable = new ContentEditable({ () =>
target: element.shadowRoot!, new ContentEditable({
props: { target: element.shadowRoot!,
nodes, props: {
resolve, nodes,
mirror, resolve,
}, mirror,
context: allContexts, },
}); context: allContexts,
}),
contentEditable.$on("focus", moveCaretToEnd); );
});
} }
export const api: RichTextInputAPI = { export const api: RichTextInputAPI = {

View File

@ -8,6 +8,9 @@ import "./editor-base.css";
@typescript-eslint/no-explicit-any: "off", @typescript-eslint/no-explicit-any: "off",
*/ */
import "../sveltelib/export-runtime";
import "../lib/register-package";
import { filterHTML } from "../html-filter"; import { filterHTML } from "../html-filter";
import { execCommand } from "./helpers"; import { execCommand } from "./helpers";
import { updateAllState } from "../components/WithState.svelte"; import { updateAllState } from "../components/WithState.svelte";
@ -29,11 +32,6 @@ export function setFormat(cmd: string, arg?: string, _nosave = false): void {
updateAllState(new Event(cmd)); updateAllState(new Event(cmd));
} }
export { editorToolbar } from "./EditorToolbar.svelte";
import "../sveltelib/export-runtime";
import "../lib/register-package";
import { setupI18n, ModuleName } from "../lib/i18n"; import { setupI18n, ModuleName } from "../lib/i18n";
import { isApplePlatform } from "../lib/platform"; import { isApplePlatform } from "../lib/platform";
import { registerShortcut } from "../lib/shortcuts"; import { registerShortcut } from "../lib/shortcuts";

15
ts/lib/cross-browser.ts Normal file
View File

@ -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;
}

12
ts/lib/events.ts Normal file
View File

@ -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<T extends EventTarget, L extends EventListener>(
target: T,
eventType: string,
listener: L,
options: AddEventListenerOptions = {},
): () => void {
target.addEventListener(eventType, listener, options);
return () => target.removeEventListener(eventType, listener, options);
}

View File

@ -9,9 +9,10 @@
{ "path": "editor" }, { "path": "editor" },
{ "path": "graphs" }, { "path": "graphs" },
{ "path": "html-filter" }, { "path": "html-filter" },
{ "path": "lib" },
{ "path": "reviewer" }, { "path": "reviewer" },
{ "path": "sveltelib" } { "path": "lib" },
{ "path": "domlib" },
{ "path": "sveltelib" },
], ],
"compilerOptions": { "compilerOptions": {
"declaration": true, "declaration": true,