From 268e3d0a0a62f726b25ec7ad8e105056ab5dae24 Mon Sep 17 00:00:00 2001 From: Matthias Metelka <62722460+kleinerpirat@users.noreply.github.com> Date: Sun, 19 Feb 2023 23:36:24 +0100 Subject: [PATCH] Add missing callback returns to lifecycle.onMount uses --- src/ts/src/editor/index.ts | 206 +++++++++++++++++++++---------------- 1 file changed, 120 insertions(+), 86 deletions(-) diff --git a/src/ts/src/editor/index.ts b/src/ts/src/editor/index.ts index 20fb6c5..ff3a7b2 100644 --- a/src/ts/src/editor/index.ts +++ b/src/ts/src/editor/index.ts @@ -46,6 +46,7 @@ import type { NoteEditorAPI } from "@anki/editor/NoteEditor.svelte"; import type { EditorFieldAPI } from "@anki/editor/EditorField.svelte"; import type { RichTextInputAPI } from "@anki/editor/rich-text-input"; import type { PlainTextInputAPI } from "@anki/editor/plain-text-input"; +import type { Callback } from "@anki/lib/typing"; const addonPackage = addonPackageFromScript( document.currentScript as HTMLScriptElement, @@ -67,21 +68,37 @@ const surrounder = legacy ? require("anki/surround").Surrounder.make() : require("anki/RichTextInput").surrounder; -NoteEditor.lifecycle.onMount(({ toolbar }: NoteEditorAPI): void => { - toolbar.templateButtons.append({ +NoteEditor.lifecycle.onMount(({ toolbar }: NoteEditorAPI): Callback => { + /** + * DefaultSlotInterface.append returns a callback to destroy the added component, + * which we use as our return value for lifecycle.onMount here: + */ + const callback = toolbar.templateButtons.append({ component: TooltipButton, // profile-specific shortcut set in qt/webview.py props: { surrounder, keyCombination: globalThis.tooltipShortcut }, id: "tooltipButton", }); + + /** + * The return value must be a synchronous callback, but the destroy callback from + * append needs to be awaited. An async IIFE solves this: + */ + return () => { + (async () => { + (await callback).destroy(); + })(); + }; }); /** - * lifecycle.onMount is called when the respective component is mounted to the DOM. + * The callback passed to lifecycle.onMount is called when the respective component is mounted to the DOM. * We can use this to add CSS and EventListeners to . * * @see {@link https://github.com/ankitects/anki/blob/main/ts/editor/rich-text-input/RichTextInput.svelte} * for all available API properties. + * + * The callback must return a cleanup function. */ if (pointVersion && pointVersion >= 54) { // richTextInput is available since 2.1.54 @@ -94,7 +111,7 @@ if (pointVersion && pointVersion >= 54) { * the fields (`
`) and iterate over * `NoteEditor.instances[0].fields` whenever an element is added/removed. */ - NoteEditor.lifecycle.onMount(async (noteEditor: NoteEditorAPI) => { + NoteEditor.lifecycle.onMount((noteEditor: NoteEditorAPI): Callback => { const observer = new MutationObserver(() => { noteEditor.fields.forEach(async (field: EditorFieldAPI): Promise => { const inputs = get(field.editingArea.editingInputs) as [ @@ -110,106 +127,123 @@ if (pointVersion && pointVersion >= 54) { editable.setAttribute("tooltipsInitialized", ""); }); }); - observer.observe(document.querySelector(".fields")!, {childList: true}); + observer.observe(document.querySelector(".fields")!, { childList: true }); + + return function cleanup() { + observer.disconnect(); + }; }); } /** * Add styles and EventListeners to */ -async function setupRichTextInput(api: RichTextInputAPI) { +function setupRichTextInput(api: RichTextInputAPI) : Callback { const { customStyles, element, preventResubscription } = api; - // - const editable = await element; - - if (legacy && !surrounder.richText) { - surrounder.richText = api; - } - - insertStyles( - customStyles, - editable, - `./${addonPackage}/web/editor/index.css`, - "tooltipStyles", - ); + const callback = (async () => { + // + const editable = await element; - /** - * Event delegation to works, but - * EventListeners added to elements inside will not (!) - * - * That's because the resubscription process between PlainTextInput and RichTextInput - * sets the innerHTML of , i.e. destroys all elements and creates new ones. - * - * @see {@link https://forums.ankiweb.net/t/tip-dynamic-html-js-inside-anki-editable/} - */ - editable.addEventListener("click", onTrigger); - - /** - * Instead of querying the API via require("anki/NoteEditor").instances[0].fields etc. at runtime, - * it's best to keep the resubscription logic inside RichTextInput.lifecycle.onMount. - * - * We do so by attaching an EventListener for a custom event "newTooltip": - */ - editable.addEventListener("newTooltip", onTrigger); - - /** - * @deprecated Required for versions below 2.1.55 - */ - if (legacy) { - editable.addEventListener("focusin", () => { + if (legacy && !surrounder.richText) { surrounder.richText = api; - }); - editable.addEventListener("focusout", () => { - surrounder.disable(); - }); - } - - /** - * Replace "dumb" `` tag with dynamic Svelte component - */ - function onTrigger(e: Event) { - if ( - !e.target || - !(e.target instanceof HTMLAnchorElement) || - !e.target.hasAttribute("data-tippy-content") || - e.target.classList.contains("active") - ) { - return; } + insertStyles( + customStyles, + editable, + `./${addonPackage}/web/editor/index.css`, + "tooltipStyles", + ); + /** - * Function returned from preventResubscription to enable resubscription again. - * @see {@link https://forums.ankiweb.net/t/tip-dynamic-html-js-inside-anki-editable/} + * Event delegation to works, but + * EventListeners added to elements inside will not (!) + * + * That's because the resubscription process between PlainTextInput and RichTextInput + * sets the innerHTML of , i.e. destroys all elements and creates new ones. + * + * @see {@link https://forums.ankiweb.net/t/tip-dynamic-html-js-inside-anki-editable/} */ - const callback = preventResubscription(); - - const anchor = e.target; - const previousSibling = anchor.previousElementSibling; - - const svelteAnchor = new TooltipAnchor({ - target: anchor.parentElement ?? editable, - anchor, - props: { - editable, - anchorContent: anchor.innerHTML, - tooltipContent: decodeAttribute(anchor.dataset.tippyContent!), - }, - }); + editable.addEventListener("click", onTrigger); - anchor.remove(); + /** + * Instead of querying the API via require("anki/NoteEditor").instances[0].fields etc. at runtime, + * it's best to keep the resubscription logic inside RichTextInput.lifecycle.onMount. + * + * We do so by attaching an EventListener for a custom event "newTooltip": + */ + editable.addEventListener("newTooltip", onTrigger); /** - * Svelte components can't directly self-destruct, so we listen - * for a message from TooltipAnchor to destroy it from outside. + * @deprecated Required for versions below 2.1.55 */ - svelteAnchor.$on("destroyComponent", () => { - svelteAnchor.$destroy(); - setTimeout(() => { - // Reenable resubscription - callback(); - restoreCaretPosition(editable, previousSibling); + if (legacy) { + editable.addEventListener("focusin", () => { + surrounder.richText = api; }); - }); + editable.addEventListener("focusout", () => { + surrounder.disable(); + }); + } + + /** + * Replace "dumb" `` tag with dynamic Svelte component + */ + function onTrigger(e: Event) { + if ( + !e.target || + !(e.target instanceof HTMLAnchorElement) || + !e.target.hasAttribute("data-tippy-content") || + e.target.classList.contains("active") + ) { + return; + } + + /** + * Function returned from preventResubscription to enable resubscription again. + * @see {@link https://forums.ankiweb.net/t/tip-dynamic-html-js-inside-anki-editable/} + */ + const callback = preventResubscription(); + + const anchor = e.target; + const previousSibling = anchor.previousElementSibling; + + const svelteAnchor = new TooltipAnchor({ + target: anchor.parentElement ?? editable, + anchor, + props: { + editable, + anchorContent: anchor.innerHTML, + tooltipContent: decodeAttribute(anchor.dataset.tippyContent!), + }, + }); + + anchor.remove(); + + /** + * Svelte components can't directly self-destruct, so we listen + * for a message from TooltipAnchor to destroy it from outside. + */ + svelteAnchor.$on("destroyComponent", () => { + svelteAnchor.$destroy(); + setTimeout(() => { + // Reenable resubscription + callback(); + restoreCaretPosition(editable, previousSibling); + }); + }); + } + + return () => { + editable.removeEventListener("click", onTrigger); + editable.removeEventListener("newTooltip", onTrigger); + }; + })(); + + return () => { + (async () => { + (await callback)(); + }) } }