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;
|
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
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_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",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "../components" },
|
{ "path": "../components" },
|
||||||
{ "path": "../lib" },
|
{ "path": "../lib" },
|
||||||
|
{ "path": "../domlib" },
|
||||||
{ "path": "../sveltelib" }
|
{ "path": "../sveltelib" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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 = {
|
||||||
|
@ -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
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": "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,
|
||||||
|
Loading…
Reference in New Issue
Block a user