Skip to content

Commit

Permalink
Add callback returns to lifecycle.onMount functions
Browse files Browse the repository at this point in the history
This fixes crashes in the browser caused by missing callbacks.
  • Loading branch information
kleinerpirat authored Feb 19, 2023
2 parents 65a4f87 + 89601a1 commit ad8349e
Show file tree
Hide file tree
Showing 2 changed files with 121 additions and 100 deletions.
15 changes: 1 addition & 14 deletions src/ts/src/editor/components/TooltipButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
A button with shortcut that uses a Surrounder (class imported from "anki/surround")
to wrap selected content with `<a>` tags and changes its state depending on the current selection.
Components such as this one can be appended to the editor toolbar the following way:
@example
```js
import TooltipButton from "./TooltipButton.svelte"
require("anki/NoteEditor").lifecycle.onMount(({ toolbar }) => {
toolbar.templateButtons.append({
component: TooltipButton,
props: { keyCombination: "Control+T" }, // optional
id: "tooltipButton", // optional
});
});
```
Components such as this one can be appended to the editor toolbar (@see index.ts)
@see {@link https://github.com/ankitects/anki/tree/main/ts/sveltelib/dynamic-slotting.ts}
-->
<script lang="ts">
Expand Down
206 changes: 120 additions & 86 deletions src/ts/src/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <anki-editable>.
*
* @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
Expand All @@ -94,7 +111,7 @@ if (pointVersion && pointVersion >= 54) {
* the fields (`<div class=".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<void> => {
const inputs = get(field.editingArea.editingInputs) as [
Expand All @@ -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 <anki-editable>
*/
async function setupRichTextInput(api: RichTextInputAPI) {
function setupRichTextInput(api: RichTextInputAPI) : Callback {
const { customStyles, element, preventResubscription } = api;

// <anki-editable>
const editable = await element;

if (legacy && !surrounder.richText) {
surrounder.richText = api;
}

insertStyles(
customStyles,
editable,
`./${addonPackage}/web/editor/index.css`,
"tooltipStyles",
);
const callback = (async () => {
// <anki-editable>
const editable = await element;

/**
* Event delegation to <anki-editable> works, but
* EventListeners added to elements inside <anki-editable> will not (!)
*
* That's because the resubscription process between PlainTextInput and RichTextInput
* sets the innerHTML of <anki-editable>, 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" `<a>` 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 <anki-editable> works, but
* EventListeners added to elements inside <anki-editable> will not (!)
*
* That's because the resubscription process between PlainTextInput and RichTextInput
* sets the innerHTML of <anki-editable>, 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" `<a>` 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)();
})
}
}

0 comments on commit ad8349e

Please sign in to comment.