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:
parent
fc93a3d4d7
commit
610ef8f043
@ -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;
|
||||
}
|
||||
|
||||
|
24
ts/domlib/BUILD.bazel
Normal file
24
ts/domlib/BUILD.bazel
Normal 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
4
ts/domlib/index.ts
Normal 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";
|
55
ts/domlib/location/document.ts
Normal file
55
ts/domlib/location/document.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
14
ts/domlib/location/index.ts
Normal file
14
ts/domlib/location/index.ts
Normal 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";
|
40
ts/domlib/location/location.ts
Normal file
40
ts/domlib/location/location.ts
Normal 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");
|
||||
}
|
41
ts/domlib/location/node.ts
Normal file
41
ts/domlib/location/node.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
33
ts/domlib/location/range.ts
Normal file
33
ts/domlib/location/range.ts
Normal 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 };
|
||||
}
|
53
ts/domlib/location/selection.ts
Normal file
53
ts/domlib/location/selection.ts
Normal 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
8
ts/domlib/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["*", "location/*"],
|
||||
"references": [{ "path": "../lib" }],
|
||||
"compilerOptions": {
|
||||
"types": ["jest"]
|
||||
}
|
||||
}
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
@ -5,6 +5,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import { updateAllState } from "../components/WithState.svelte";
|
||||
import { saveSelection, restoreSelection } from "../domlib/location";
|
||||
import { on } from "../lib/events";
|
||||
|
||||
export let nodes: Writable<DocumentFragment>;
|
||||
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,
|
||||
params: { store: Writable<DocumentFragment> },
|
||||
) => 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>
|
||||
|
||||
<anki-editable
|
||||
contenteditable="true"
|
||||
use:resolve
|
||||
use:saveLocation
|
||||
use:mirror={{ store: nodes }}
|
||||
on:focus
|
||||
on:blur
|
||||
on:click={updateAllState}
|
||||
on:keyup={updateAllState}
|
||||
/>
|
||||
|
@ -4,6 +4,7 @@
|
||||
"references": [
|
||||
{ "path": "../components" },
|
||||
{ "path": "../lib" },
|
||||
{ "path": "../domlib" },
|
||||
{ "path": "../sveltelib" }
|
||||
]
|
||||
}
|
||||
|
@ -161,8 +161,9 @@ 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({
|
||||
stylesDidLoad.then(
|
||||
() =>
|
||||
new ContentEditable({
|
||||
target: element.shadowRoot!,
|
||||
props: {
|
||||
nodes,
|
||||
@ -170,10 +171,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
mirror,
|
||||
},
|
||||
context: allContexts,
|
||||
});
|
||||
|
||||
contentEditable.$on("focus", moveCaretToEnd);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const api: RichTextInputAPI = {
|
||||
|
@ -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";
|
||||
|
15
ts/lib/cross-browser.ts
Normal file
15
ts/lib/cross-browser.ts
Normal 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
12
ts/lib/events.ts
Normal 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);
|
||||
}
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user