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:
Henrik Giesel 2022-01-11 23:39:41 +01:00 committed by GitHub
parent 4e4d12a62a
commit 489eadb352
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 90 additions and 77 deletions

View file

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

View file

@ -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={{}}

View file

@ -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) {
safePlaceCaretAfterContent(this);
return; 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);

View file

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

View file

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

View file

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

View file

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

View file

@ -15,14 +15,15 @@ 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;
}
node = newNode;
for (const subscriber of subscribers) { for (const subscriber of subscribers) {
subscriber(node); subscriber(node);
} }
} }
}
function set(newNode: T): void { function set(newNode: T): void {
preprocess(newNode); preprocess(newNode);

View file

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