mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 06:22:22 -04:00
Fix IME input after tab (#1584)
* Avoid initial call of mirror-dom * Disable updateFocus from OldEditorAdapter * fixes IME input duplication bug * Fix saving of latestLocation for ContentEditable * Fix IME after calling placeCaretAfterContent * Export other libraries from domlib/index.ts * Remove dead code + Uncomment code which was mistakenly left commmented
This commit is contained in:
parent
4e4d12a62a
commit
489eadb352
9 changed files with 90 additions and 77 deletions
|
@ -1,5 +1,7 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
export * as location from "./location";
|
export * from "./location";
|
||||||
export * as surround from "./surround";
|
export * from "./surround";
|
||||||
|
export * from "./move-nodes";
|
||||||
|
export * from "./place-caret";
|
||||||
|
|
|
@ -12,7 +12,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import actionList from "../sveltelib/action-list";
|
import actionList from "../sveltelib/action-list";
|
||||||
import contentEditableAPI, {
|
import contentEditableAPI, {
|
||||||
saveLocation,
|
saveLocation,
|
||||||
prepareFocusHandling,
|
initialFocusHandling,
|
||||||
preventBuiltinContentEditableShortcuts,
|
preventBuiltinContentEditableShortcuts,
|
||||||
} from "./content-editable";
|
} from "./content-editable";
|
||||||
import type { ContentEditableAPI } from "./content-editable";
|
import type { ContentEditableAPI } from "./content-editable";
|
||||||
|
@ -40,7 +40,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
use:resolve
|
use:resolve
|
||||||
use:saveLocation
|
use:saveLocation
|
||||||
use:prepareFocusHandling
|
use:initialFocusHandling
|
||||||
use:preventBuiltinContentEditableShortcuts
|
use:preventBuiltinContentEditableShortcuts
|
||||||
use:mirrorAction={mirrorOptions}
|
use:mirrorAction={mirrorOptions}
|
||||||
use:managerAction={{}}
|
use:managerAction={{}}
|
||||||
|
|
|
@ -17,30 +17,42 @@ function flushLocation(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let latestLocation: SelectionLocation | null = null;
|
function safePlaceCaretAfterContent(editable: HTMLElement): void {
|
||||||
|
/**
|
||||||
|
* Workaround: If you try to invoke an IME after calling
|
||||||
|
* `placeCaretAfterContent` on a cE element, the IME will immediately
|
||||||
|
* end and the input character will be duplicated
|
||||||
|
*/
|
||||||
|
placeCaretAfterContent(editable);
|
||||||
|
restoreSelection(editable, saveSelection(editable)!);
|
||||||
|
}
|
||||||
|
|
||||||
function onFocus(this: HTMLElement): void {
|
function onFocus(location: SelectionLocation | null): () => void {
|
||||||
if (!latestLocation) {
|
return function (this: HTMLElement): void {
|
||||||
placeCaretAfterContent(this);
|
if (!location) {
|
||||||
return;
|
safePlaceCaretAfterContent(this);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
restoreSelection(this, latestLocation);
|
restoreSelection(this, location);
|
||||||
} catch {
|
} catch {
|
||||||
placeCaretAfterContent(this);
|
safePlaceCaretAfterContent(this);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBlur(this: HTMLElement): void {
|
function onBlur(this: HTMLElement): void {
|
||||||
prepareFocusHandling(this);
|
prepareFocusHandling(this, saveSelection(this));
|
||||||
latestLocation = saveSelection(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let removeOnFocus: () => void;
|
function prepareFocusHandling(
|
||||||
|
editable: HTMLElement,
|
||||||
export function prepareFocusHandling(editable: HTMLElement): void {
|
latestLocation: SelectionLocation | null = null,
|
||||||
removeOnFocus = on(editable, "focus", onFocus, { once: true });
|
): void {
|
||||||
|
const removeOnFocus = on(editable, "focus", onFocus(latestLocation), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
|
||||||
locationEvents.push(
|
locationEvents.push(
|
||||||
removeOnFocus,
|
removeOnFocus,
|
||||||
|
@ -48,6 +60,10 @@ export function prepareFocusHandling(editable: HTMLElement): void {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initialFocusHandling(editable: HTMLElement): void {
|
||||||
|
prepareFocusHandling(editable);
|
||||||
|
}
|
||||||
|
|
||||||
/* Must execute before DOMMirror */
|
/* Must execute before DOMMirror */
|
||||||
export function saveLocation(editable: HTMLElement): { destroy(): void } {
|
export function saveLocation(editable: HTMLElement): { destroy(): void } {
|
||||||
const removeOnBlur = on(editable, "blur", onBlur);
|
const removeOnBlur = on(editable, "blur", onBlur);
|
||||||
|
|
|
@ -8,7 +8,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import type { PlainTextInputAPI } from "./PlainTextInput.svelte";
|
import type { PlainTextInputAPI } from "./PlainTextInput.svelte";
|
||||||
import type { EditorToolbarAPI } from "./EditorToolbar.svelte";
|
import type { EditorToolbarAPI } from "./EditorToolbar.svelte";
|
||||||
|
|
||||||
import { registerShortcut } from "../lib/shortcuts";
|
|
||||||
import contextProperty from "../sveltelib/context-property";
|
import contextProperty from "../sveltelib/context-property";
|
||||||
import { writable, get } from "svelte/store";
|
import { writable, get } from "svelte/store";
|
||||||
|
|
||||||
|
@ -27,15 +26,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
const activeInput = writable<RichTextInputAPI | PlainTextInputAPI | null>(null);
|
const activeInput = writable<RichTextInputAPI | PlainTextInputAPI | null>(null);
|
||||||
const currentField = writable<EditorFieldAPI | null>(null);
|
const currentField = writable<EditorFieldAPI | null>(null);
|
||||||
|
|
||||||
function updateFocus() {
|
|
||||||
get(activeInput)?.moveCaretToEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
registerShortcut(
|
|
||||||
() => document.addEventListener("focusin", updateFocus, { once: true }),
|
|
||||||
"Shift?+Tab",
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
@ -67,6 +57,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { bridgeCommand } from "../lib/bridgecommand";
|
import { bridgeCommand } from "../lib/bridgecommand";
|
||||||
|
import { registerShortcut } from "../lib/shortcuts";
|
||||||
import { isApplePlatform } from "../lib/platform";
|
import { isApplePlatform } from "../lib/platform";
|
||||||
import { ChangeTimer } from "./change-timer";
|
import { ChangeTimer } from "./change-timer";
|
||||||
import { alertIcon } from "./icons";
|
import { alertIcon } from "./icons";
|
||||||
|
|
|
@ -110,7 +110,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
/* we need createContextualFragment so that customElements are initialized */
|
/* we need createContextualFragment so that customElements are initialized */
|
||||||
const fragment = range.createContextualFragment(adjustInputHTML(html));
|
const fragment = range.createContextualFragment(adjustInputHTML(html));
|
||||||
adjustInputFragment(fragment);
|
adjustInputFragment(fragment);
|
||||||
|
|
||||||
nodes.setUnprocessed(fragment);
|
nodes.setUnprocessed(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let selectAll = false;
|
let selectAll = false;
|
||||||
|
|
||||||
function placeHandle(after: boolean): void {
|
function placeHandle(after: boolean): void {
|
||||||
|
flushLocation();
|
||||||
|
|
||||||
if (after) {
|
if (after) {
|
||||||
(mathjaxElement as any).placeCaretAfter();
|
(mathjaxElement as any).placeCaretAfter();
|
||||||
} else {
|
} else {
|
||||||
|
@ -48,7 +50,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetHandle(): Promise<void> {
|
async function resetHandle(): Promise<void> {
|
||||||
flushLocation();
|
|
||||||
selectAll = false;
|
selectAll = false;
|
||||||
|
|
||||||
if (activeImage && mathjaxElement) {
|
if (activeImage && mathjaxElement) {
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import storeSubscribe from "./store-subscribe";
|
import storeSubscribe from "./store-subscribe";
|
||||||
// import { getSelection } from "../lib/cross-browser";
|
import { on } from "../lib/events";
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
childList: true,
|
childList: true,
|
||||||
|
@ -14,7 +14,7 @@ const config = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MirrorAction = (
|
export type MirrorAction = (
|
||||||
element: Element,
|
element: HTMLElement,
|
||||||
params: { store: Writable<DocumentFragment> },
|
params: { store: Writable<DocumentFragment> },
|
||||||
) => { destroy(): void };
|
) => { destroy(): void };
|
||||||
|
|
||||||
|
@ -23,10 +23,22 @@ interface DOMMirrorAPI {
|
||||||
preventResubscription(): () => void;
|
preventResubscription(): () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneNode(node: Node): DocumentFragment {
|
||||||
|
/**
|
||||||
|
* Creates a deep clone
|
||||||
|
* This seems to be less buggy than node.cloneNode(true)
|
||||||
|
*/
|
||||||
|
const range = document.createRange();
|
||||||
|
|
||||||
|
range.selectNodeContents(node);
|
||||||
|
return range.cloneContents();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows you to keep an element's inner HTML bidirectionally
|
* Allows you to keep an element's inner HTML bidirectionally
|
||||||
* in sync with a store containing a DocumentFragment.
|
* in sync with a store containing a DocumentFragment.
|
||||||
* While the element has focus, this connection is tethered.
|
* While the element has focus, this connection is tethered.
|
||||||
|
* In practice, this will sync changes from PlainTextInput to RichTextInput.
|
||||||
*/
|
*/
|
||||||
function getDOMMirror(): DOMMirrorAPI {
|
function getDOMMirror(): DOMMirrorAPI {
|
||||||
const allowResubscription = writable(true);
|
const allowResubscription = writable(true);
|
||||||
|
@ -40,68 +52,58 @@ function getDOMMirror(): DOMMirrorAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mirror(
|
function mirror(
|
||||||
element: Element,
|
element: HTMLElement,
|
||||||
{ store }: { store: Writable<DocumentFragment> },
|
{ store }: { store: Writable<DocumentFragment> },
|
||||||
): { destroy(): void } {
|
): { destroy(): void } {
|
||||||
function saveHTMLToStore(): void {
|
function saveHTMLToStore(): void {
|
||||||
const range = document.createRange();
|
store.set(cloneNode(element));
|
||||||
|
|
||||||
range.selectNodeContents(element);
|
|
||||||
const contents = range.cloneContents();
|
|
||||||
|
|
||||||
store.set(contents);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new MutationObserver(saveHTMLToStore);
|
const observer = new MutationObserver(saveHTMLToStore);
|
||||||
observer.observe(element, config);
|
observer.observe(element, config);
|
||||||
|
|
||||||
function mirrorToNode(node: Node): void {
|
function mirrorToElement(node: Node): void {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
const clone = node.cloneNode(true);
|
// element.replaceChildren(...node.childNodes); // TODO use once available
|
||||||
|
|
||||||
/* TODO use Element.replaceChildren */
|
|
||||||
while (element.firstChild) {
|
while (element.firstChild) {
|
||||||
element.firstChild.remove();
|
element.firstChild.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
while (clone.firstChild) {
|
while (node.firstChild) {
|
||||||
element.appendChild(clone.firstChild);
|
element.appendChild(node.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
observer.observe(element, config);
|
observer.observe(element, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subscribe, unsubscribe } = storeSubscribe(store, mirrorToNode);
|
function mirrorFromFragment(fragment: DocumentFragment): void {
|
||||||
// const selection = getSelection(element)!;
|
mirrorToElement(cloneNode(fragment));
|
||||||
|
|
||||||
function doSubscribe(): void {
|
|
||||||
// Might not be needed after all:
|
|
||||||
// /**
|
|
||||||
// * Focused element and caret are two independent things in the browser.
|
|
||||||
// * When the ContentEditable calls blur, it will still have the selection inside of it.
|
|
||||||
// * Some elements (e.g. FrameElement) need to figure whether the intended focus is still
|
|
||||||
// * in the contenteditable or elsewhere because they might change the selection.
|
|
||||||
// */
|
|
||||||
// selection.removeAllRanges();
|
|
||||||
|
|
||||||
subscribe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { subscribe, unsubscribe } = storeSubscribe(
|
||||||
|
store,
|
||||||
|
mirrorFromFragment,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
/* do not update when focused as it will reset caret */
|
/* do not update when focused as it will reset caret */
|
||||||
element.addEventListener("focus", unsubscribe);
|
const removeFocus = on(element, "focus", unsubscribe);
|
||||||
|
let removeBlur: (() => void) | undefined;
|
||||||
|
|
||||||
const unsubResubscription = allowResubscription.subscribe(
|
const unsubResubscription = allowResubscription.subscribe(
|
||||||
(allow: boolean): void => {
|
(allow: boolean): void => {
|
||||||
if (allow) {
|
if (allow) {
|
||||||
element.addEventListener("blur", doSubscribe);
|
if (!removeBlur) {
|
||||||
|
removeBlur = on(element, "blur", subscribe);
|
||||||
|
}
|
||||||
|
|
||||||
const root = element.getRootNode() as Document | ShadowRoot;
|
const root = element.getRootNode() as Document | ShadowRoot;
|
||||||
|
|
||||||
if (root.activeElement !== element) {
|
if (root.activeElement !== element) {
|
||||||
doSubscribe();
|
subscribe();
|
||||||
}
|
}
|
||||||
} else {
|
} else if (removeBlur) {
|
||||||
element.removeEventListener("blur", doSubscribe);
|
removeBlur();
|
||||||
|
removeBlur = undefined;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -110,9 +112,8 @@ function getDOMMirror(): DOMMirrorAPI {
|
||||||
destroy() {
|
destroy() {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
|
|
||||||
// removes blur event listener
|
removeFocus();
|
||||||
allowResubscription.set(false);
|
removeBlur?.();
|
||||||
element.removeEventListener("focus", unsubscribe);
|
|
||||||
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
unsubResubscription();
|
unsubResubscription();
|
||||||
|
|
|
@ -15,12 +15,13 @@ export function nodeStore<T extends Node>(
|
||||||
const subscribers: Set<Subscriber<T>> = new Set();
|
const subscribers: Set<Subscriber<T>> = new Set();
|
||||||
|
|
||||||
function setUnprocessed(newNode: T): void {
|
function setUnprocessed(newNode: T): void {
|
||||||
if (!node || !node.isEqualNode(newNode)) {
|
if (node && node.isEqualNode(newNode)) {
|
||||||
node = newNode;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const subscriber of subscribers) {
|
node = newNode;
|
||||||
subscriber(node);
|
for (const subscriber of subscribers) {
|
||||||
}
|
subscriber(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,9 @@ interface StoreAccessors {
|
||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent double subscriptions / unsubscriptions
|
/**
|
||||||
|
* Helper function to prevent double (un)subscriptions
|
||||||
|
*/
|
||||||
function storeSubscribe<T>(
|
function storeSubscribe<T>(
|
||||||
store: Readable<T>,
|
store: Readable<T>,
|
||||||
callback: (value: T) => void,
|
callback: (value: T) => void,
|
||||||
|
|
Loading…
Reference in a new issue