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,