:global(*) {
+ /* TODO replace with gap once available */
+ margin-right: 0.15rem;
+ margin-bottom: 0.15rem;
+ }
}
diff --git a/ts/components/IconButton.svelte b/ts/components/IconButton.svelte
index 4219bd41b..bb7e5b744 100644
--- a/ts/components/IconButton.svelte
+++ b/ts/components/IconButton.svelte
@@ -3,6 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
+
+
+
+
diff --git a/ts/components/Item.svelte b/ts/components/Item.svelte
index d8f6c1c40..38ce0b16e 100644
--- a/ts/components/Item.svelte
+++ b/ts/components/Item.svelte
@@ -28,14 +28,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
+
diff --git a/ts/components/Shortcut.svelte b/ts/components/Shortcut.svelte
new file mode 100644
index 000000000..a06395ea1
--- /dev/null
+++ b/ts/components/Shortcut.svelte
@@ -0,0 +1,21 @@
+
+
diff --git a/ts/components/StickyFooter.svelte b/ts/components/StickyFooter.svelte
index e9778096b..14c315878 100644
--- a/ts/components/StickyFooter.svelte
+++ b/ts/components/StickyFooter.svelte
@@ -17,12 +17,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
diff --git a/ts/components/StickyHeader.svelte b/ts/components/StickyHeader.svelte
index 173a2be0d..3afad60ee 100644
--- a/ts/components/StickyHeader.svelte
+++ b/ts/components/StickyHeader.svelte
@@ -15,12 +15,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
diff --git a/ts/components/WithTheming.svelte b/ts/components/WithTheming.svelte
deleted file mode 100644
index 6cb3b5603..000000000
--- a/ts/components/WithTheming.svelte
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/ts/editable/BUILD.bazel b/ts/editable/BUILD.bazel
index 2f30daf57..07bfe37d6 100644
--- a/ts/editable/BUILD.bazel
+++ b/ts/editable/BUILD.bazel
@@ -17,18 +17,22 @@ compile_sass(
],
)
-compile_svelte()
+
+_ts_deps = [
+ "//ts/components",
+ "//ts/lib",
+ "//ts/sveltelib",
+ "@npm//mathjax",
+ "@npm//mathjax-full",
+ "@npm//svelte",
+]
+
+compile_svelte(deps = _ts_deps)
typescript(
name = "editable",
- deps = [
+ deps = _ts_deps + [
":svelte",
- "//ts/components",
- "//ts/lib",
- "//ts/sveltelib",
- "@npm//mathjax",
- "@npm//mathjax-full",
- "@npm//svelte",
],
)
diff --git a/ts/editable/ContentEditable.svelte b/ts/editable/ContentEditable.svelte
new file mode 100644
index 000000000..9a61f8967
--- /dev/null
+++ b/ts/editable/ContentEditable.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+
+
diff --git a/ts/editable/Mathjax.svelte b/ts/editable/Mathjax.svelte
index 4df3c8a87..f66171bef 100644
--- a/ts/editable/Mathjax.svelte
+++ b/ts/editable/Mathjax.svelte
@@ -34,8 +34,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onMount(() => {
observer.observe(image);
+
if (autofocus) {
- image.click();
+ // This should trigger a focusing of the Mathjax Handle
+ const focusEvent = new CustomEvent("focusmathjax", {
+ detail: image,
+ bubbles: true,
+ composed: true,
+ });
+
+ image.dispatchEvent(focusEvent);
}
});
@@ -58,6 +66,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/>
diff --git a/ts/editor/EditorField.svelte b/ts/editor/EditorField.svelte
new file mode 100644
index 000000000..298bf8c9f
--- /dev/null
+++ b/ts/editor/EditorField.svelte
@@ -0,0 +1,104 @@
+
+
+
+
+
+
api.editingArea?.focus()}
+>
+
+ {field.name}
+
+
+
+
+
+
+
+
diff --git a/ts/editor/EditorToolbar.svelte b/ts/editor/EditorToolbar.svelte
index b6ea95035..f9f95024a 100644
--- a/ts/editor/EditorToolbar.svelte
+++ b/ts/editor/EditorToolbar.svelte
@@ -25,7 +25,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
@@ -67,14 +68,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
- createDropdown(event.detail.button)}
- >
- {@html listOptionsIcon}
-
-
+ createDropdown(event.detail.button)}
+ >
+ {@html listOptionsIcon}
+
-
@@ -120,27 +119,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-
-
- {@html outdentIcon}
-
-
+
+ {@html outdentIcon}
+
-
-
- {@html indentIcon}
-
-
+
+ {@html indentIcon}
+
diff --git a/ts/editor/HandleLabel.svelte b/ts/editor/HandleLabel.svelte
index 8454585ea..b802e6a04 100644
--- a/ts/editor/HandleLabel.svelte
+++ b/ts/editor/HandleLabel.svelte
@@ -3,18 +3,22 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
-
-
-
- {@html icon}
-
-
diff --git a/ts/editor/LabelContainer.svelte b/ts/editor/LabelContainer.svelte
new file mode 100644
index 000000000..26514fd41
--- /dev/null
+++ b/ts/editor/LabelContainer.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
diff --git a/ts/editor/LabelName.svelte b/ts/editor/LabelName.svelte
new file mode 100644
index 000000000..04a572364
--- /dev/null
+++ b/ts/editor/LabelName.svelte
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/ts/editor/MathjaxHandle.svelte b/ts/editor/MathjaxHandle.svelte
deleted file mode 100644
index 974d2dab8..000000000
--- a/ts/editor/MathjaxHandle.svelte
+++ /dev/null
@@ -1,109 +0,0 @@
-
-
-
-
- {#if activeImage}
- (dropdownApi = createDropdown(event.detail.selection))}
- >
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
- {/if}
-
-
-
diff --git a/ts/editor/NoteEditor.svelte b/ts/editor/NoteEditor.svelte
new file mode 100644
index 000000000..229a81e29
--- /dev/null
+++ b/ts/editor/NoteEditor.svelte
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/ts/editor/Notification.svelte b/ts/editor/Notification.svelte
new file mode 100644
index 000000000..297232856
--- /dev/null
+++ b/ts/editor/Notification.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/ts/editor/OldEditorAdapter.svelte b/ts/editor/OldEditorAdapter.svelte
new file mode 100644
index 000000000..8db3cf54f
--- /dev/null
+++ b/ts/editor/OldEditorAdapter.svelte
@@ -0,0 +1,323 @@
+
+
+
+
+
+
+
+
+
+ {#if hint}
+
+
+ {@html alertIcon}
+ {@html hint}
+
+
+ {/if}
+
+
+ {#each fieldsData as field, index}
+ {
+ $currentField = api.fields[index];
+ bridgeCommand(`focus:${index}`);
+ }}
+ on:focusout={() => {
+ $currentField = null;
+ bridgeCommand(
+ `blur:${index}:${getNoteId()}:${get(fieldStores[index])}`
+ );
+ }}
+ --label-color={cols[index] === "dupe"
+ ? "var(--flag1-bg)"
+ : "transparent"}
+ >
+
+
+
+ {#if stickies}
+
+ {/if}
+
+
+
+
+ {
+ $focusInRichText = true;
+ $activeInput = richTextInputs[index].api;
+ }}
+ on:focusout={() => {
+ $focusInRichText = false;
+ $activeInput = null;
+ saveFieldNow();
+ }}
+ bind:this={richTextInputs[index]}
+ >
+
+
+
+
+ {
+ $activeInput = plainTextInputs[index].api;
+ }}
+ on:focusout={() => {
+ $activeInput = null;
+ saveFieldNow();
+ }}
+ bind:this={plainTextInputs[index]}
+ />
+
+
+
+ {/each}
+
+
+
+
+
diff --git a/ts/editor/OnlyEditable.svelte b/ts/editor/OnlyEditable.svelte
deleted file mode 100644
index f4d6024e1..000000000
--- a/ts/editor/OnlyEditable.svelte
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/ts/editor/PlainTextBadge.svelte b/ts/editor/PlainTextBadge.svelte
new file mode 100644
index 000000000..4aa11bfd4
--- /dev/null
+++ b/ts/editor/PlainTextBadge.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+ {@html icon}
+
+
+
diff --git a/ts/editor/PlainTextInput.svelte b/ts/editor/PlainTextInput.svelte
new file mode 100644
index 000000000..ddba2c149
--- /dev/null
+++ b/ts/editor/PlainTextInput.svelte
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+ code.set(parseAsHTML(html))}
+ />
+
+
+
diff --git a/ts/editor/RichTextBadge.svelte b/ts/editor/RichTextBadge.svelte
new file mode 100644
index 000000000..836a2c343
--- /dev/null
+++ b/ts/editor/RichTextBadge.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+ {@html icon}
+
+
+
diff --git a/ts/editor/RichTextInput.svelte b/ts/editor/RichTextInput.svelte
new file mode 100644
index 000000000..535290303
--- /dev/null
+++ b/ts/editor/RichTextInput.svelte
@@ -0,0 +1,255 @@
+
+
+
+
+
+
+
+
+
+ {#await Promise.all([richTextPromise, stylesPromise]) then [container, styles]}
+
+
+
+ {/await}
+
+
+
+
diff --git a/ts/editor/RichTextStyles.svelte b/ts/editor/RichTextStyles.svelte
new file mode 100644
index 000000000..8a3d1d7cf
--- /dev/null
+++ b/ts/editor/RichTextStyles.svelte
@@ -0,0 +1,64 @@
+
+
+
+
diff --git a/ts/editor/SetContext.svelte b/ts/editor/SetContext.svelte
new file mode 100644
index 000000000..618dd1a6f
--- /dev/null
+++ b/ts/editor/SetContext.svelte
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/ts/editor/StickyBadge.svelte b/ts/editor/StickyBadge.svelte
new file mode 100644
index 000000000..216d2d3b4
--- /dev/null
+++ b/ts/editor/StickyBadge.svelte
@@ -0,0 +1,47 @@
+
+
+
+
+ {@html icon}
+
+
+
diff --git a/ts/editor/StyleLink.svelte b/ts/editor/StyleLink.svelte
new file mode 100644
index 000000000..0de316aa8
--- /dev/null
+++ b/ts/editor/StyleLink.svelte
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/ts/editor/StyleTag.svelte b/ts/editor/StyleTag.svelte
new file mode 100644
index 000000000..a7e756226
--- /dev/null
+++ b/ts/editor/StyleTag.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+{#if true}
+
+{/if}
diff --git a/ts/editor/TagEditor.svelte b/ts/editor/TagEditor.svelte
index 350f695fb..ddc2284c2 100644
--- a/ts/editor/TagEditor.svelte
+++ b/ts/editor/TagEditor.svelte
@@ -3,9 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
-
-
-
- {@html paperclipIcon}
-
-
+
+
+ {@html paperclipIcon}
+
-
-
-
- {@html micIcon}
-
-
+
+
+ {@html micIcon}
+
-
-
-
+
-
-
- {@html functionIcon}
-
-
+
+ {@html functionIcon}
+
- wrapCurrent(
+ $activeInput?.surround(
"",
""
)}
@@ -114,13 +95,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
- wrapCurrent(
+ $activeInput?.surround(
'',
""
)}
@@ -132,13 +113,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
- wrapCurrent(
+ $activeInput?.surround(
"\\ce{",
"}"
)}
@@ -150,12 +131,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
wrapCurrent("[latex]", "[/latex]")}
+ on:click={() => $activeInput?.surround("[latex]", "[/latex]")}
on:mount={withButton(createShortcut)}
>
{tr.editingLatex()}
@@ -164,12 +145,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
wrapCurrent("[$]", "[/$]")}
+ on:click={() => $activeInput?.surround("[$]", "[/$]")}
on:mount={withButton(createShortcut)}
>
{tr.editingLatexEquation()}
@@ -178,12 +159,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
wrapCurrent("[$$]", "[/$$]")}
+ on:click={() => $activeInput?.surround("[$$]", "[/$$]")}
on:mount={withButton(createShortcut)}
>
{tr.editingLatexMathEnv()}
@@ -193,30 +174,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-
-
-
-
-
- {@html xmlIcon}
-
-
-
-
-
diff --git a/ts/editor/codable.ts b/ts/editor/codable.ts
deleted file mode 100644
index df50d9ee3..000000000
--- a/ts/editor/codable.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-/* eslint
-@typescript-eslint/no-non-null-assertion: "off",
-*/
-
-import { CodeMirror, htmlanki, baseOptions, gutterOptions } from "./codeMirror";
-import { inCodable } from "./toolbar";
-
-const codeMirrorOptions = {
- mode: htmlanki,
- ...baseOptions,
- ...gutterOptions,
-};
-
-const parser = new DOMParser();
-const parseStyle = "";
-
-function parseHTML(html: string): string {
- const doc = parser.parseFromString(`${parseStyle}${html}`, "text/html");
- return doc.body.innerHTML;
-}
-
-export class Codable extends HTMLTextAreaElement {
- codeMirror: CodeMirror.EditorFromTextArea | undefined;
-
- get active(): boolean {
- return Boolean(this.codeMirror);
- }
-
- set fieldHTML(content: string) {
- if (this.active) {
- this.codeMirror?.setValue(content);
- } else {
- this.value = content;
- }
- }
-
- get fieldHTML(): string {
- return parseHTML(this.active ? this.codeMirror!.getValue() : this.value);
- }
-
- connectedCallback(): void {
- this.setAttribute("hidden", "");
- }
-
- setup(html: string): void {
- this.fieldHTML = html;
- this.codeMirror = CodeMirror.fromTextArea(this, codeMirrorOptions);
- this.codeMirror.on("blur", () => inCodable.set(false));
- }
-
- teardown(): string {
- this.codeMirror!.toTextArea();
- this.codeMirror = undefined;
- return this.fieldHTML;
- }
-
- focus(): void {
- this.codeMirror!.focus();
- inCodable.set(true);
- }
-
- caretToEnd(): void {
- this.codeMirror!.setCursor(this.codeMirror!.lineCount(), 0);
- }
-
- surroundSelection(before: string, after: string): void {
- const selection = this.codeMirror!.getSelection();
- this.codeMirror!.replaceSelection(before + selection + after);
- }
-
- onEnter(): void {
- /* default */
- }
-
- onPaste(): void {
- /* default */
- }
-}
-
-customElements.define("anki-codable", Codable, { extends: "textarea" });
diff --git a/ts/editor/codeMirror.ts b/ts/editor/code-mirror.ts
similarity index 83%
rename from ts/editor/codeMirror.ts
rename to ts/editor/code-mirror.ts
index dc7403415..9a0906f40 100644
--- a/ts/editor/codeMirror.ts
+++ b/ts/editor/code-mirror.ts
@@ -24,21 +24,18 @@ export const htmlanki = {
},
};
-const noop = (): void => {
- /* noop */
-};
-
-export const baseOptions = {
+export const baseOptions: CodeMirror.EditorConfiguration = {
theme: "monokai",
lineWrapping: true,
matchTags: { bothTags: true },
autoCloseTags: true,
- extraKeys: { Tab: noop, "Shift-Tab": noop },
+ extraKeys: { Tab: false, "Shift-Tab": false },
+ tabindex: 0,
viewportMargin: Infinity,
lineWiseCopyCut: false,
};
-export const gutterOptions = {
+export const gutterOptions: CodeMirror.EditorConfiguration = {
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
lineNumbers: true,
foldGutter: true,
diff --git a/ts/editor/editing-area.ts b/ts/editor/editing-area.ts
deleted file mode 100644
index 332c4c94b..000000000
--- a/ts/editor/editing-area.ts
+++ /dev/null
@@ -1,296 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-///
-
-/* eslint
-@typescript-eslint/no-non-null-assertion: "off",
-@typescript-eslint/no-explicit-any: "off",
- */
-
-import ImageHandle from "./ImageHandle.svelte";
-import MathjaxHandle from "./MathjaxHandle.svelte";
-
-import type { EditableContainer } from "../editable/editable-container";
-import type { Editable } from "../editable/editable";
-import type { Codable } from "./codable";
-
-import { updateActiveButtons } from "./toolbar";
-import { bridgeCommand } from "./lib";
-import { onInput, onKey, onKeyUp } from "./input-handlers";
-import { deferFocusDown, saveFieldIfFieldChanged } from "./focus-handlers";
-import { nightModeKey } from "../components/context-keys";
-import { decoratedComponents } from "../editable/decorated";
-
-function onCutOrCopy(): void {
- bridgeCommand("cutOrCopy");
-}
-
-export class EditingArea extends HTMLDivElement {
- imageHandle: Promise;
- mathjaxHandle: MathjaxHandle;
- editableContainer: EditableContainer;
- editable: Editable;
- codable: Codable;
-
- constructor() {
- super();
- this.className = "field";
-
- this.editableContainer = document.createElement("div", {
- is: "anki-editable-container",
- }) as EditableContainer;
-
- this.editable = document.createElement("anki-editable") as Editable;
- this.editableContainer.shadowRoot!.appendChild(this.editable);
- this.appendChild(this.editableContainer);
-
- const context = new Map();
- context.set(
- nightModeKey,
- document.documentElement.classList.contains("night-mode")
- );
-
- let imageHandleResolve: (value: ImageHandle) => void;
- this.imageHandle = new Promise(
- (resolve) => (imageHandleResolve = resolve)
- );
-
- this.editableContainer.imagePromise.then(() =>
- imageHandleResolve(
- new ImageHandle({
- target: this,
- anchor: this.editableContainer,
- props: {
- container: this.editable,
- sheet: this.editableContainer.imageStyle.sheet,
- },
- context,
- } as any)
- )
- );
-
- this.mathjaxHandle = new MathjaxHandle({
- target: this,
- anchor: this.editableContainer,
- props: {
- container: this.editable,
- },
- context,
- } as any);
-
- this.codable = document.createElement("textarea", {
- is: "anki-codable",
- }) as Codable;
- this.appendChild(this.codable);
-
- this.onFocus = this.onFocus.bind(this);
- this.onBlur = this.onBlur.bind(this);
- this.onKey = this.onKey.bind(this);
- this.onPaste = this.onPaste.bind(this);
- this.showHandles = this.showHandles.bind(this);
- }
-
- get activeInput(): Editable | Codable {
- return this.codable.active ? this.codable : this.editable;
- }
-
- get ord(): number {
- return Number(this.getAttribute("ord"));
- }
-
- set fieldHTML(content: string) {
- this.imageHandle.then(() => {
- let result = content;
-
- for (const component of decoratedComponents) {
- result = component.toUndecorated(result);
- }
-
- this.activeInput.fieldHTML = result;
- });
- }
-
- get fieldHTML(): string {
- let result = this.activeInput.fieldHTML;
- for (const component of decoratedComponents) {
- result = component.toStored(result);
- }
-
- return result;
- }
-
- connectedCallback(): void {
- this.addEventListener("keydown", this.onKey);
- this.addEventListener("keyup", onKeyUp);
- this.addEventListener("input", onInput);
- this.addEventListener("focusin", this.onFocus);
- this.addEventListener("focusout", this.onBlur);
- this.addEventListener("paste", this.onPaste);
- this.addEventListener("copy", onCutOrCopy);
- this.addEventListener("oncut", onCutOrCopy);
- this.addEventListener("mouseup", updateActiveButtons);
- this.editable.addEventListener("click", this.showHandles);
- }
-
- disconnectedCallback(): void {
- this.removeEventListener("keydown", this.onKey);
- this.removeEventListener("keyup", onKeyUp);
- this.removeEventListener("input", onInput);
- this.removeEventListener("focusin", this.onFocus);
- this.removeEventListener("focusout", this.onBlur);
- this.removeEventListener("paste", this.onPaste);
- this.removeEventListener("copy", onCutOrCopy);
- this.removeEventListener("oncut", onCutOrCopy);
- this.removeEventListener("mouseup", updateActiveButtons);
- this.editable.removeEventListener("click", this.showHandles);
- }
-
- initialize(color: string, content: string): void {
- this.editableContainer.stylePromise.then(() => {
- this.setBaseColor(color);
- this.fieldHTML = content;
- });
- }
-
- setBaseColor(color: string): void {
- this.editableContainer.setBaseColor(color);
- }
-
- quoteFontFamily(fontFamily: string): string {
- // generic families (e.g. sans-serif) must not be quoted
- if (!/^[-a-z]+$/.test(fontFamily)) {
- fontFamily = `"${fontFamily}"`;
- }
- return fontFamily;
- }
-
- setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
- this.editableContainer.setBaseStyling(
- this.quoteFontFamily(fontFamily),
- fontSize,
- direction
- );
- }
-
- isRightToLeft(): boolean {
- return this.editableContainer.isRightToLeft();
- }
-
- focus(): void {
- this.activeInput.focus();
- }
-
- blur(): void {
- this.activeInput.blur();
- }
-
- caretToEnd(): void {
- this.activeInput.caretToEnd();
- }
-
- hasFocus(): boolean {
- return document.activeElement?.closest(".field") === this;
- }
-
- getSelection(): Selection {
- const root = this.activeInput.getRootNode() as Document | ShadowRoot;
- return root.getSelection()!;
- }
-
- surroundSelection(before: string, after: string): void {
- this.activeInput.surroundSelection(before, after);
- }
-
- onFocus(): void {
- deferFocusDown(this);
- }
-
- onBlur(event: FocusEvent): void {
- saveFieldIfFieldChanged(this, event.relatedTarget as Element);
- }
-
- onEnter(event: KeyboardEvent): void {
- this.activeInput.onEnter(event);
- }
-
- onKey(event: KeyboardEvent): void {
- this.resetHandles();
- onKey(event);
- }
-
- onPaste(event: ClipboardEvent): void {
- this.resetHandles();
- this.activeInput.onPaste(event);
- }
-
- resetHandles(): Promise {
- const promise = this.imageHandle.then((imageHandle) =>
- (imageHandle as any).$set({
- activeImage: null,
- })
- );
-
- (this.mathjaxHandle as any).$set({
- activeImage: null,
- });
-
- return promise;
- }
-
- async showHandles(event: MouseEvent): Promise {
- if (event.target instanceof HTMLImageElement) {
- const image = event.target as HTMLImageElement;
- await this.resetHandles();
-
- if (!image.dataset.anki) {
- await this.imageHandle.then((imageHandle) =>
- (imageHandle as any).$set({
- activeImage: image,
- isRtl: this.isRightToLeft(),
- })
- );
- } else if (image.dataset.anki === "mathjax") {
- (this.mathjaxHandle as any).$set({
- activeImage: image,
- isRtl: this.isRightToLeft(),
- });
- }
- } else {
- await this.resetHandles();
- }
- }
-
- toggleHtmlEdit(): void {
- const hadFocus = this.hasFocus();
-
- if (this.codable.active) {
- this.fieldHTML = this.codable.teardown();
- this.editable.hidden = false;
- } else {
- this.resetHandles();
- this.editable.hidden = true;
- this.codable.setup(this.editable.fieldHTML);
- }
-
- if (hadFocus) {
- this.focus();
- this.caretToEnd();
- }
- }
-
- /**
- * @deprecated Use focus instead
- */
- focusEditable(): void {
- focus();
- }
- /**
- * @deprecated Use blur instead
- */
- blurEditable(): void {
- blur();
- }
-}
-
-customElements.define("anki-editing-area", EditingArea, { extends: "div" });
diff --git a/ts/editor/editor-base.scss b/ts/editor/editor-base.scss
new file mode 100644
index 000000000..1e4654941
--- /dev/null
+++ b/ts/editor/editor-base.scss
@@ -0,0 +1,55 @@
+/* Copyright: Ankitects Pty Ltd and contributors
+ * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
+
+@use "base";
+@use "scrollbar";
+@use "button-mixins";
+
+html,
+body {
+ height: 100%;
+}
+
+.nightMode {
+ @include scrollbar.night-mode;
+}
+
+#dupes,
+#cloze-hint {
+ position: sticky;
+ bottom: 0;
+
+ text-align: center;
+ background-color: var(--window-bg);
+
+ a {
+ color: var(--link);
+ }
+}
+
+.icon > svg {
+ fill: var(--text-fg);
+}
+
+.pin-icon {
+ cursor: pointer;
+
+ &.is-inactive {
+ opacity: 0.2;
+ }
+
+ &.icon--hover {
+ opacity: 0.5;
+ }
+}
+
+/* CodeMirror */
+@import "codemirror/lib/codemirror";
+@import "codemirror/theme/monokai";
+@import "codemirror/addon/fold/foldgutter";
+
+.CodeMirror {
+ height: auto;
+ border-radius: 0 0 5px 5px;
+ padding: 6px 0;
+}
diff --git a/ts/editor/editor-field.ts b/ts/editor/editor-field.ts
deleted file mode 100644
index dd62c6a48..000000000
--- a/ts/editor/editor-field.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-import type { EditingArea } from "./editing-area";
-import type { LabelContainer } from "./label-container";
-
-export class EditorField extends HTMLDivElement {
- labelContainer: LabelContainer;
- editingArea: EditingArea;
-
- constructor() {
- super();
- this.className = "editorfield";
-
- this.labelContainer = document.createElement("div", {
- is: "anki-label-container",
- }) as LabelContainer;
- this.appendChild(this.labelContainer);
-
- this.editingArea = document.createElement("div", {
- is: "anki-editing-area",
- }) as EditingArea;
- this.appendChild(this.editingArea);
-
- this.focusIfNotFocused = this.focusIfNotFocused.bind(this);
- }
-
- static get observedAttributes(): string[] {
- return ["ord"];
- }
-
- set ord(n: number) {
- this.setAttribute("ord", String(n));
- }
-
- focusIfNotFocused(): void {
- if (!this.editingArea.hasFocus()) {
- this.editingArea.focus();
- this.editingArea.caretToEnd();
- }
- }
-
- connectedCallback(): void {
- this.addEventListener("mousedown", this.focusIfNotFocused);
- }
-
- disconnectedCallback(): void {
- this.removeEventListener("mousedown", this.focusIfNotFocused);
- }
-
- attributeChangedCallback(name: string, _oldValue: string, newValue: string): void {
- switch (name) {
- case "ord":
- this.editingArea.setAttribute("ord", newValue);
- this.labelContainer.setAttribute("ord", newValue);
- }
- }
-
- initialize(label: string, color: string, content: string): void {
- this.labelContainer.initialize(label);
- this.editingArea.initialize(color, content);
- }
-
- setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
- this.editingArea.setBaseStyling(fontFamily, fontSize, direction);
- }
-}
-
-customElements.define("anki-editor-field", EditorField, { extends: "div" });
diff --git a/ts/editor/fields.scss b/ts/editor/fields.scss
deleted file mode 100644
index 069da651c..000000000
--- a/ts/editor/fields.scss
+++ /dev/null
@@ -1,109 +0,0 @@
-/* Copyright: Ankitects Pty Ltd and contributors
- * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
-
-@use "sass/base";
-@use "sass/scrollbar";
-@use "sass/button-mixins";
-
-html,
-body {
- height: 100%;
-}
-
-body {
- display: flex;
- flex-direction: column;
-}
-
-.nightMode {
- @include scrollbar.night-mode;
-}
-
-#fields {
- display: flex;
- flex-direction: column;
- flex-grow: 1;
- overflow-x: hidden;
- margin: 3px 0;
-}
-
-.editorfield {
- margin: 3px;
-
- border-radius: 5px;
- border: 1px solid var(--border-color);
-
- --border-color: var(--border);
-
- &:focus-within {
- box-shadow: 0 0 0 3px var(--focus-shadow);
-
- --border-color: var(--focus-border);
- }
-}
-
-/* editing-area */
-.field {
- position: relative;
-
- background: var(--frame-bg);
- border-radius: 0 0 5px 5px;
-
- &.dupe {
- // this works around the background colour persisting in copy+paste
- // (https://github.com/ankitects/anki/pull/1278)
- background-image: linear-gradient(var(--flag1-bg), var(--flag1-bg));
- }
-}
-
-.fname {
- border-width: 0 0 1px;
- border-style: dashed;
- border-color: var(--border-color);
- border-radius: 5px 5px 0 0;
-
- padding: 0px 6px;
-}
-
-.fieldname {
- user-select: none;
-}
-
-#dupes,
-#cloze-hint {
- position: sticky;
- bottom: 0;
-
- text-align: center;
- background-color: var(--window-bg);
-
- a {
- color: var(--link);
- }
-}
-
-.icon > svg {
- fill: var(--text-fg);
-}
-
-.pin-icon {
- cursor: pointer;
-
- &.is-inactive {
- opacity: 0.2;
- }
-
- &.icon--hover {
- opacity: 0.5;
- }
-}
-
-@import "codemirror/lib/codemirror";
-@import "codemirror/theme/monokai";
-@import "codemirror/addon/fold/foldgutter";
-
-.CodeMirror {
- height: auto;
- border-radius: 0 0 5px 5px;
- padding: 6px 0;
-}
diff --git a/ts/editor/focus-handlers.ts b/ts/editor/focus-handlers.ts
deleted file mode 100644
index 42ac55c65..000000000
--- a/ts/editor/focus-handlers.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-/* eslint
-@typescript-eslint/no-non-null-assertion: "off",
- */
-
-import { fieldFocused } from "./toolbar";
-import type { EditingArea } from "./editing-area";
-
-import { saveField } from "./saving";
-import { bridgeCommand } from "./lib";
-import { getCurrentField } from "./helpers";
-
-export function deferFocusDown(editingArea: EditingArea): void {
- editingArea.focus();
- editingArea.caretToEnd();
-
- if (editingArea.getSelection().anchorNode === null) {
- // selection is not inside editable after focusing
- editingArea.caretToEnd();
- }
-
- bridgeCommand(`focus:${editingArea.ord}`);
- fieldFocused.set(true);
-}
-
-export function saveFieldIfFieldChanged(
- editingArea: EditingArea,
- focusTo: Element | null
-): void {
- const fieldChanged =
- editingArea !== getCurrentField() && !editingArea.contains(focusTo);
-
- saveField(editingArea, fieldChanged ? "blur" : "key");
- fieldFocused.set(false);
-
- if (fieldChanged) {
- editingArea.resetHandles();
- }
-}
diff --git a/ts/editor/helpers.ts b/ts/editor/helpers.ts
index 3064aa6b8..7ffd387b6 100644
--- a/ts/editor/helpers.ts
+++ b/ts/editor/helpers.ts
@@ -1,12 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-import type { EditingArea } from "./editing-area";
-
-export function getCurrentField(): EditingArea | null {
- return document.activeElement?.closest(".field") ?? null;
-}
-
export function appendInParentheses(text: string, appendix: string): string {
return `${text} (${appendix})`;
}
diff --git a/ts/editor/icons.ts b/ts/editor/icons.ts
index 0abdc3240..4c4892b75 100644
--- a/ts/editor/icons.ts
+++ b/ts/editor/icons.ts
@@ -31,7 +31,6 @@ export { default as paperclipIcon } from "@mdi/svg/svg/paperclip.svg";
export { default as micIcon } from "bootstrap-icons/icons/mic.svg";
export { default as ellipseIcon } from "@mdi/svg/svg/contain.svg";
export { default as functionIcon } from "@mdi/svg/svg/function-variant.svg";
-export { default as xmlIcon } from "@mdi/svg/svg/xml.svg";
export { default as tagIcon } from "@mdi/svg/svg/tag.svg";
export { default as addTagIcon } from "@mdi/svg/svg/tag-plus.svg";
@@ -41,13 +40,13 @@ export { default as deleteIcon } from "bootstrap-icons/icons/x.svg";
export const arrowIcon =
'';
-// image handle
-export { default as floatNoneIcon } from "@mdi/svg/svg/format-float-none.svg";
-export { default as floatLeftIcon } from "@mdi/svg/svg/format-float-left.svg";
-export { default as floatRightIcon } from "@mdi/svg/svg/format-float-right.svg";
+export { default as alertIcon } from "@mdi/svg/svg/alert.svg";
-export { default as sizeActual } from "@mdi/svg/svg/image-size-select-actual.svg";
-export { default as sizeMinimized } from "@mdi/svg/svg/image-size-select-large.svg";
+export { default as richTextOn } from "@mdi/svg/svg/eye-outline.svg";
+export { default as richTextOff } from "@mdi/svg/svg/eye-off-outline.svg";
-export { default as inlineIcon } from "@mdi/svg/svg/format-wrap-square.svg";
-export { default as blockIcon } from "@mdi/svg/svg/format-wrap-top-bottom.svg";
+export { default as htmlOn } from "@mdi/svg/svg/code-tags.svg";
+export { default as htmlOff } from "@mdi/svg/svg/xml.svg";
+
+export { default as stickyOn } from "@mdi/svg/svg/pin-outline.svg";
+export { default as stickyOff } from "@mdi/svg/svg/pin-off-outline.svg";
diff --git a/ts/editor/ImageHandleFloatButtons.svelte b/ts/editor/image-overlay/FloatButtons.svelte
similarity index 68%
rename from ts/editor/ImageHandleFloatButtons.svelte
rename to ts/editor/image-overlay/FloatButtons.svelte
index 36540dd55..6b862fb40 100644
--- a/ts/editor/ImageHandleFloatButtons.svelte
+++ b/ts/editor/image-overlay/FloatButtons.svelte
@@ -3,21 +3,25 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
@@ -27,7 +31,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{
image.style.float = "left";
setTimeout(() => dispatch("update"));
@@ -39,7 +43,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{
image.style.removeProperty("float");
@@ -56,7 +60,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{
image.style.float = "right";
setTimeout(() => dispatch("update"));
diff --git a/ts/editor/ImageHandle.svelte b/ts/editor/image-overlay/ImageHandle.svelte
similarity index 67%
rename from ts/editor/ImageHandle.svelte
rename to ts/editor/image-overlay/ImageHandle.svelte
index e9178d8ce..6dc429a9e 100644
--- a/ts/editor/ImageHandle.svelte
+++ b/ts/editor/image-overlay/ImageHandle.svelte
@@ -3,24 +3,52 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
-{#if sheet}
-
+
+ {#await sheetPromise then sheet}
{
updateSizesWithDimensions();
dropdownObject.update();
@@ -162,7 +198,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
-
+
{actualWidth}×{actualHeight}
{#if customDimensions}
(Original: {naturalWidth}×{naturalHeight})
-
-
- -
-
-
- -
-
-
-
+
+ -
+
+
+ -
+
+
{/if}
-
-{/if}
+ {/await}
+
diff --git a/ts/editor/image-overlay/SizeSelect.svelte b/ts/editor/image-overlay/SizeSelect.svelte
new file mode 100644
index 000000000..17f3be4c0
--- /dev/null
+++ b/ts/editor/image-overlay/SizeSelect.svelte
@@ -0,0 +1,33 @@
+
+
+
+
+
+ {@html icon}
+
+
diff --git a/ts/editor/WithImageConstrained.svelte b/ts/editor/image-overlay/WithImageConstrained.svelte
similarity index 91%
rename from ts/editor/WithImageConstrained.svelte
rename to ts/editor/image-overlay/WithImageConstrained.svelte
index 4b29b3e5d..248ffaf6b 100644
--- a/ts/editor/WithImageConstrained.svelte
+++ b/ts/editor/image-overlay/WithImageConstrained.svelte
@@ -4,7 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
{#if activeImage}
diff --git a/ts/editor/image-overlay/icons.ts b/ts/editor/image-overlay/icons.ts
new file mode 100644
index 000000000..507b2c9af
--- /dev/null
+++ b/ts/editor/image-overlay/icons.ts
@@ -0,0 +1,9 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+export { default as floatNoneIcon } from "@mdi/svg/svg/format-float-none.svg";
+export { default as floatLeftIcon } from "@mdi/svg/svg/format-float-left.svg";
+export { default as floatRightIcon } from "@mdi/svg/svg/format-float-right.svg";
+
+export { default as sizeActual } from "@mdi/svg/svg/image-size-select-actual.svg";
+export { default as sizeMinimized } from "@mdi/svg/svg/image-size-select-large.svg";
diff --git a/ts/editor/context-keys.ts b/ts/editor/image-overlay/index.ts
similarity index 55%
rename from ts/editor/context-keys.ts
rename to ts/editor/image-overlay/index.ts
index f8c54a020..e91413b16 100644
--- a/ts/editor/context-keys.ts
+++ b/ts/editor/image-overlay/index.ts
@@ -1,5 +1,4 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-export const fieldFocusedKey = Symbol("fieldFocused");
-export const inCodableKey = Symbol("inCodable");
+export { default as ImageHandle } from "./ImageHandle.svelte";
diff --git a/ts/editor/index.ts b/ts/editor/index.ts
index 6101098ef..f6cf9af65 100644
--- a/ts/editor/index.ts
+++ b/ts/editor/index.ts
@@ -6,42 +6,36 @@
@typescript-eslint/no-explicit-any: "off",
*/
+import { filterHTML } from "../html-filter";
+import { updateAllState } from "../components/WithState.svelte";
+import { noop } from "../lib/functional";
+
+export const $editorToolbar = new Promise(noop);
+
+export function pasteHTML(
+ html: string,
+ internal: boolean,
+ extendedMode: boolean
+): void {
+ html = filterHTML(html, internal, extendedMode);
+
+ if (html !== "") {
+ setFormat("inserthtml", html);
+ }
+}
+
+export function setFormat(cmd: string, arg?: string, _nosave = false): void {
+ document.execCommand(cmd, false, arg);
+ updateAllState(new Event(cmd));
+}
+
import "../sveltelib/export-runtime";
import "../lib/register-package";
-import type EditorToolbar from "./EditorToolbar.svelte";
-import type TagEditor from "./TagEditor.svelte";
-
-import { filterHTML } from "../html-filter";
import { setupI18n, ModuleName } from "../lib/i18n";
import { isApplePlatform } from "../lib/platform";
import { registerShortcut } from "../lib/shortcuts";
import { bridgeCommand } from "../lib/bridgecommand";
-import { updateActiveButtons } from "./toolbar";
-import { saveField } from "./saving";
-
-import "./fields.css";
-
-import "./label-container";
-import "./codable";
-import "./editor-field";
-import type { EditorField } from "./editor-field";
-import { EditingArea } from "./editing-area";
-import "../editable/editable-container";
-import "../editable/editable";
-import "../editable/mathjax-component";
-
-import { initToolbar, fieldFocused } from "./toolbar";
-import { initTagEditor } from "./tag-editor";
-import { getCurrentField } from "./helpers";
-
-export { setNoteId, getNoteId } from "./note-id";
-export { saveNow } from "./saving";
-export { wrap, wrapIntoText } from "./wrap";
-export { editorToolbar } from "./toolbar";
-export { activateStickyShortcuts } from "./label-container";
-export { getCurrentField } from "./helpers";
-export { components } from "./Components.svelte";
declare global {
interface Selection {
@@ -56,148 +50,6 @@ if (isApplePlatform()) {
registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V");
}
-export function focusField(n: number): void {
- const field = getEditorField(n);
-
- if (field) {
- field.editingArea.focus();
- field.editingArea.caretToEnd();
- updateActiveButtons(new Event("manualfocus"));
- }
-}
-
-export function focusIfField(x: number, y: number): boolean {
- const elements = document.elementsFromPoint(x, y);
- for (let i = 0; i < elements.length; i++) {
- const elem = elements[i] as EditingArea;
- if (elem instanceof EditingArea) {
- elem.focus();
- return true;
- }
- }
- return false;
-}
-
-export function pasteHTML(
- html: string,
- internal: boolean,
- extendedMode: boolean
-): void {
- html = filterHTML(html, internal, extendedMode);
-
- if (html !== "") {
- setFormat("inserthtml", html);
- }
-}
-
-function adjustFieldAmount(amount: number): void {
- const fieldsContainer = document.getElementById("fields")!;
-
- while (fieldsContainer.childElementCount < amount) {
- const newField = document.createElement("div", {
- is: "anki-editor-field",
- }) as EditorField;
- newField.ord = fieldsContainer.childElementCount;
- fieldsContainer.appendChild(newField);
- }
-
- while (fieldsContainer.childElementCount > amount) {
- fieldsContainer.removeChild(fieldsContainer.lastElementChild as Node);
- }
-}
-
-export function getEditorField(n: number): EditorField | null {
- const fields = document.getElementById("fields")!.children;
- return (fields[n] as EditorField) ?? null;
-}
-
-/// forEachEditorFieldAndProvidedValue:
-/// Values should be a list with the same length as the
-/// number of fields. Func will be called with each field and
-/// value in turn.
-export function forEditorField(
- values: T[],
- func: (field: EditorField, value: T) => void
-): void {
- const fields = document.getElementById("fields")!.children;
- for (let i = 0; i < fields.length; i++) {
- const field = fields[i] as EditorField;
- func(field, values[i]);
- }
-}
-
-export function setFields(fields: [string, string][]): void {
- // webengine will include the variable after enter+backspace
- // if we don't convert it to a literal colour
- const color = window
- .getComputedStyle(document.documentElement)
- .getPropertyValue("--text-fg");
-
- adjustFieldAmount(fields.length);
- forEditorField(
- fields,
- (field: EditorField, [name, fieldContent]: [string, string]): void =>
- field.initialize(name, color, fieldContent)
- );
-
- if (!getCurrentField()) {
- // when initial focus of the window is not on editor (e.g. browser)
- fieldFocused.set(false);
- }
-}
-
-export function setBackgrounds(cols: ("dupe" | "")[]): void {
- forEditorField(cols, (field: EditorField, value: "dupe" | "") =>
- field.editingArea.classList.toggle("dupe", value === "dupe")
- );
- document
- .getElementById("dupes")!
- .classList.toggle("d-none", !cols.includes("dupe"));
-}
-
-export function setClozeHint(hint: string): void {
- const clozeHint = document.getElementById("cloze-hint")!;
-
- clozeHint.innerHTML = hint;
- clozeHint.classList.toggle("d-none", hint.length === 0);
-}
-
-export function setFonts(fonts: [string, number, boolean][]): void {
- forEditorField(
- fonts,
- (
- field: EditorField,
- [fontFamily, fontSize, isRtl]: [string, number, boolean]
- ) => {
- field.setBaseStyling(fontFamily, `${fontSize}px`, isRtl ? "rtl" : "ltr");
- }
- );
-}
-
-export function setColorButtons([textColor, highlightColor]: [string, string]): void {
- $editorToolbar.then((editorToolbar) =>
- (editorToolbar as any).$set({ textColor, highlightColor })
- );
-}
-
-export function setSticky(stickies: boolean[]): void {
- forEditorField(stickies, (field: EditorField, isSticky: boolean) => {
- field.labelContainer.activateSticky(isSticky);
- });
-}
-
-export function setFormat(cmd: string, arg?: string, nosave = false): void {
- document.execCommand(cmd, false, arg);
- if (!nosave) {
- saveField(getCurrentField() as EditingArea, "key");
- updateActiveButtons(new Event(cmd));
- }
-}
-
-export function setTags(tags: string[]): void {
- $tagEditor.then((tagEditor: TagEditor): void => tagEditor.resetTags(tags));
-}
-
export const i18n = setupI18n({
modules: [
ModuleName.EDITING,
@@ -207,5 +59,54 @@ export const i18n = setupI18n({
],
});
-export const $editorToolbar: Promise = initToolbar(i18n);
-export const $tagEditor: Promise = initTagEditor(i18n);
+import OldEditorAdapter from "./OldEditorAdapter.svelte";
+import { nightModeKey } from "../components/context-keys";
+
+import "./editor-base.css";
+import "./bootstrap.css";
+import "./legacy.css";
+
+function setupNoteEditor(i18n: Promise): Promise {
+ let editorResolve: (value: OldEditorAdapter) => void;
+ const editorPromise = new Promise((resolve) => {
+ editorResolve = resolve;
+ });
+
+ const context = new Map();
+
+ context.set(
+ nightModeKey,
+ document.documentElement.classList.contains("night-mode")
+ );
+
+ i18n.then(() => {
+ const noteEditor = new OldEditorAdapter({
+ target: document.body,
+ props: {
+ class: "h-100",
+ },
+ context,
+ } as any);
+
+ Object.assign(globalThis, {
+ setFields: noteEditor.setFields,
+ setFonts: noteEditor.setFonts,
+ focusField: noteEditor.focusField,
+ setColorButtons: noteEditor.setColorButtons,
+ setTags: noteEditor.setTags,
+ setSticky: noteEditor.setSticky,
+ setBackgrounds: noteEditor.setBackgrounds,
+ setClozeHint: noteEditor.setClozeHint,
+ saveNow: noteEditor.saveFieldNow,
+ activateStickyShortcuts: noteEditor.activateStickyShortcuts,
+ focusIfField: noteEditor.focusIfField,
+ setNoteId: noteEditor.setNoteId,
+ });
+
+ editorResolve(noteEditor);
+ });
+
+ return editorPromise;
+}
+
+export const noteEditorPromise = setupNoteEditor(i18n);
diff --git a/ts/editor/input-handlers.ts b/ts/editor/input-handlers.ts
deleted file mode 100644
index 43c09074e..000000000
--- a/ts/editor/input-handlers.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-/* eslint
-@typescript-eslint/no-non-null-assertion: "off",
- */
-
-import { nodeIsElement } from "../lib/dom";
-import { updateActiveButtons } from "./toolbar";
-import { EditingArea } from "./editing-area";
-import { triggerChangeTimer } from "./saving";
-import { registerShortcut } from "../lib/shortcuts";
-
-export function onInput(event: Event): void {
- // make sure IME changes get saved
- triggerChangeTimer(event.currentTarget as EditingArea);
- updateActiveButtons(event);
-}
-
-export function onKey(evt: KeyboardEvent): void {
- const currentField = evt.currentTarget as EditingArea;
-
- // esc clears focus, allowing dialog to close
- if (evt.code === "Escape") {
- return currentField.blur();
- }
-
- // prefer
instead of
- if (evt.code === "Enter") {
- return currentField.onEnter(evt);
- }
-
- // // fix Ctrl+right/left handling in RTL fields
- if (currentField.isRightToLeft()) {
- const selection = currentField.getSelection();
- const granularity = evt.ctrlKey ? "word" : "character";
- const alter = evt.shiftKey ? "extend" : "move";
-
- switch (evt.code) {
- case "ArrowRight":
- selection.modify(alter, "right", granularity);
- evt.preventDefault();
- return;
- case "ArrowLeft":
- selection.modify(alter, "left", granularity);
- evt.preventDefault();
- return;
- }
- }
-
- triggerChangeTimer(currentField);
-}
-
-function updateFocus(evt: FocusEvent) {
- const newFocusTarget = evt.target;
- if (newFocusTarget instanceof EditingArea) {
- newFocusTarget.caretToEnd();
- updateActiveButtons(evt);
- }
-}
-
-registerShortcut(
- () => document.addEventListener("focusin", updateFocus, { once: true }),
- "Shift?+Tab"
-);
-
-export function onKeyUp(evt: KeyboardEvent): void {
- const currentField = evt.currentTarget as EditingArea;
-
- // Avoid div element on remove
- if (evt.code === "Enter" || evt.code === "Backspace") {
- const anchor = currentField.getSelection().anchorNode as Node;
-
- if (
- nodeIsElement(anchor) &&
- anchor.tagName === "DIV" &&
- !(anchor instanceof EditingArea) &&
- anchor.childElementCount === 1 &&
- anchor.children[0].tagName === "BR"
- ) {
- anchor.replaceWith(anchor.children[0]);
- }
- }
-}
diff --git a/ts/editor/label-container.ts b/ts/editor/label-container.ts
deleted file mode 100644
index 48d15a92a..000000000
--- a/ts/editor/label-container.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-import type { EditorField } from "./editor-field";
-import * as tr from "../lib/ftl";
-import { registerShortcut } from "../lib/shortcuts";
-import { bridgeCommand } from "./lib";
-import { appendInParentheses } from "./helpers";
-import { saveField } from "./saving";
-import { getCurrentField, forEditorField, i18n } from ".";
-import pinIcon from "bootstrap-icons/icons/pin-angle.svg";
-
-function toggleStickyCurrentField(): void {
- const currentField = getCurrentField();
-
- if (currentField) {
- const labelContainer = (currentField.parentElement as EditorField)
- .labelContainer;
- labelContainer.toggleSticky();
- }
-}
-
-function toggleStickyAll(): void {
- bridgeCommand("toggleStickyAll", (values: boolean[]) =>
- forEditorField(values, (field, value) => field.labelContainer.setSticky(value))
- );
-}
-
-export function activateStickyShortcuts(): void {
- registerShortcut(toggleStickyCurrentField, "F9");
- registerShortcut(toggleStickyAll, "Shift+F9");
-}
-
-export class LabelContainer extends HTMLDivElement {
- label: HTMLSpanElement;
- fieldState: HTMLSpanElement;
-
- sticky: HTMLSpanElement;
-
- constructor() {
- super();
- this.className = "fname d-flex justify-content-between";
-
- this.label = document.createElement("span");
- this.label.className = "fieldname";
- this.appendChild(this.label);
-
- this.fieldState = document.createElement("span");
- this.fieldState.className = "field-state d-flex justify-content-between";
- this.appendChild(this.fieldState);
-
- this.sticky = document.createElement("span");
- this.sticky.className = "icon pin-icon";
- this.sticky.innerHTML = pinIcon;
- this.sticky.hidden = true;
-
- i18n.then(() => {
- this.sticky.title = appendInParentheses(tr.editingToggleSticky(), "F9");
- });
-
- this.fieldState.appendChild(this.sticky);
-
- this.setSticky = this.setSticky.bind(this);
- this.hoverIcon = this.hoverIcon.bind(this);
- this.removeHoverIcon = this.removeHoverIcon.bind(this);
- this.toggleSticky = this.toggleSticky.bind(this);
- this.toggleStickyEvent = this.toggleStickyEvent.bind(this);
- this.keepFocus = this.keepFocus.bind(this);
- this.stopPropagation = this.stopPropagation.bind(this);
- }
-
- keepFocus(event: Event): void {
- event.preventDefault();
- }
-
- stopPropagation(event: Event): void {
- this.keepFocus(event);
- event.stopPropagation();
- }
-
- connectedCallback(): void {
- this.addEventListener("mousedown", this.keepFocus);
- this.sticky.addEventListener("mousedown", this.stopPropagation);
- this.sticky.addEventListener("click", this.toggleStickyEvent);
- this.sticky.addEventListener("mouseenter", this.hoverIcon);
- this.sticky.addEventListener("mouseleave", this.removeHoverIcon);
- }
-
- disconnectedCallback(): void {
- this.removeEventListener("mousedown", this.keepFocus);
- this.sticky.removeEventListener("mousedown", this.stopPropagation);
- this.sticky.removeEventListener("click", this.toggleStickyEvent);
- this.sticky.removeEventListener("mouseenter", this.hoverIcon);
- this.sticky.removeEventListener("mouseleave", this.removeHoverIcon);
- }
-
- initialize(labelName: string): void {
- this.label.innerText = labelName;
- }
-
- activateSticky(initialState: boolean): void {
- this.setSticky(initialState);
- this.sticky.hidden = false;
- }
-
- setSticky(state: boolean): void {
- this.sticky.classList.toggle("is-inactive", !state);
- }
-
- hoverIcon(): void {
- this.sticky.classList.add("icon--hover");
- }
-
- removeHoverIcon(): void {
- this.sticky.classList.remove("icon--hover");
- }
-
- toggleSticky(): void {
- saveField((this.parentElement as EditorField).editingArea, "key");
- bridgeCommand(`toggleSticky:${this.getAttribute("ord")}`, this.setSticky);
- this.removeHoverIcon();
- }
-
- toggleStickyEvent(event: Event): void {
- event.stopPropagation();
- this.toggleSticky();
- }
-}
-
-customElements.define("anki-label-container", LabelContainer, { extends: "div" });
diff --git a/ts/editor/lib.ts b/ts/editor/lib.ts
deleted file mode 100644
index 49c2e0961..000000000
--- a/ts/editor/lib.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-declare global {
- interface Window {
- bridgeCommand(command: string, callback?: (value: T) => void): void;
- }
-}
-
-export function bridgeCommand(command: string, callback?: (value: T) => void): void {
- window.bridgeCommand(command, callback);
-}
diff --git a/ts/editor/MathjaxHandleEditor.svelte b/ts/editor/mathjax-overlay/Editor.svelte
similarity index 82%
rename from ts/editor/MathjaxHandleEditor.svelte
rename to ts/editor/mathjax-overlay/Editor.svelte
index 35568b8f8..c56525d03 100644
--- a/ts/editor/MathjaxHandleEditor.svelte
+++ b/ts/editor/mathjax-overlay/Editor.svelte
@@ -4,8 +4,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
- {#if isRtl}
-
- {/if}
+
+
+
+ {#if activeImage}
+ (dropdownApi = createDropdown(event.detail.selection))}
+ >
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/ts/editor/mathjax-overlay/icons.ts b/ts/editor/mathjax-overlay/icons.ts
new file mode 100644
index 000000000..99534e872
--- /dev/null
+++ b/ts/editor/mathjax-overlay/icons.ts
@@ -0,0 +1,5 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+export { default as inlineIcon } from "@mdi/svg/svg/format-wrap-square.svg";
+export { default as blockIcon } from "@mdi/svg/svg/format-wrap-top-bottom.svg";
diff --git a/ts/editor/mathjax-overlay/index.ts b/ts/editor/mathjax-overlay/index.ts
new file mode 100644
index 000000000..1da5eec07
--- /dev/null
+++ b/ts/editor/mathjax-overlay/index.ts
@@ -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 { default as MathjaxHandle } from "./MathjaxHandle.svelte";
diff --git a/ts/editor/note-id.ts b/ts/editor/note-id.ts
deleted file mode 100644
index fc10d7a99..000000000
--- a/ts/editor/note-id.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-let currentNoteId: number | null = null;
-
-export function setNoteId(id: number): void {
- currentNoteId = id;
-}
-
-export function getNoteId(): number | null {
- return currentNoteId;
-}
diff --git a/ts/editor/saving.ts b/ts/editor/saving.ts
deleted file mode 100644
index e00c6e839..000000000
--- a/ts/editor/saving.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-import type { EditingArea } from "./editing-area";
-
-import { ChangeTimer } from "./change-timer";
-import { getCurrentField } from "./helpers";
-import { bridgeCommand } from "./lib";
-import { getNoteId } from "./note-id";
-
-const saveFieldTimer = new ChangeTimer();
-
-export function triggerChangeTimer(currentField: EditingArea): void {
- saveFieldTimer.schedule(() => saveField(currentField, "key"), 600);
-}
-
-export function saveField(currentField: EditingArea, type: "blur" | "key"): void {
- saveFieldTimer.clear();
- const command = `${type}:${currentField.ord}:${getNoteId()}:${
- currentField.fieldHTML
- }`;
- bridgeCommand(command);
-}
-
-export function saveNow(keepFocus: boolean): void {
- const currentField = getCurrentField();
-
- if (!currentField) {
- return;
- }
-
- if (keepFocus) {
- saveFieldTimer.fireImmediately();
- } else {
- // triggers onBlur, which saves
- saveFieldTimer.clear();
- currentField.blur();
- }
-}
diff --git a/ts/editor/tag-editor.ts b/ts/editor/tag-editor.ts
deleted file mode 100644
index 3f65d27a0..000000000
--- a/ts/editor/tag-editor.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-/* eslint
-@typescript-eslint/no-non-null-assertion: "off",
-@typescript-eslint/no-explicit-any: "off",
- */
-
-import { nightModeKey } from "../components/context-keys";
-
-import TagEditor from "./TagEditor.svelte";
-import "./bootstrap.css";
-
-export function initTagEditor(i18n: Promise): Promise {
- let tagEditorResolve: (value: TagEditor) => void;
- const tagEditorPromise = new Promise((resolve) => {
- tagEditorResolve = resolve;
- });
-
- document.addEventListener("DOMContentLoaded", () =>
- i18n.then(() => {
- const target = document.body;
- const anchor = document.getElementById("tag-editor-anchor")!;
-
- const context = new Map();
- context.set(
- nightModeKey,
- document.documentElement.classList.contains("night-mode")
- );
-
- tagEditorResolve(new TagEditor({ target, anchor, context } as any));
- })
- );
-
- return tagEditorPromise;
-}
-
-export {} from "./TagEditor.svelte";
diff --git a/ts/editor/toolbar.ts b/ts/editor/toolbar.ts
deleted file mode 100644
index 15cd06781..000000000
--- a/ts/editor/toolbar.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-/* eslint
-@typescript-eslint/no-non-null-assertion: "off",
-@typescript-eslint/no-explicit-any: "off",
- */
-
-import { nightModeKey } from "../components/context-keys";
-import { fieldFocusedKey, inCodableKey } from "./context-keys";
-import { writable } from "svelte/store";
-
-import EditorToolbar from "./EditorToolbar.svelte";
-import "./bootstrap.css";
-
-export const fieldFocused = writable(false);
-export const inCodable = writable(false);
-
-export function initToolbar(i18n: Promise): Promise {
- let toolbarResolve: (value: EditorToolbar) => void;
- const toolbarPromise = new Promise((resolve) => {
- toolbarResolve = resolve;
- });
-
- document.addEventListener("DOMContentLoaded", () =>
- i18n.then(() => {
- const target = document.body;
- const anchor = document.getElementById("fields")!;
-
- const context = new Map();
- context.set(fieldFocusedKey, fieldFocused);
- context.set(inCodableKey, inCodable);
- context.set(
- nightModeKey,
- document.documentElement.classList.contains("night-mode")
- );
-
- toolbarResolve(new EditorToolbar({ target, anchor, context } as any));
- })
- );
-
- return toolbarPromise;
-}
-
-export {
- updateActiveButtons,
- clearActiveButtons,
- editorToolbar,
-} from "./EditorToolbar.svelte";
diff --git a/ts/editor/tsconfig.json b/ts/editor/tsconfig.json
index 28951ad86..b3f6b5498 100644
--- a/ts/editor/tsconfig.json
+++ b/ts/editor/tsconfig.json
@@ -1,6 +1,6 @@
{
"extends": "../tsconfig.json",
- "include": ["*"],
+ "include": ["*", "image-overlay/*", "mathjax-overlay/*"],
"references": [
{ "path": "../components" },
{ "path": "../lib" },
diff --git a/ts/editor/wrap.ts b/ts/editor/wrap.ts
deleted file mode 100644
index 905df6043..000000000
--- a/ts/editor/wrap.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright: Ankitects Pty Ltd and contributors
-// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-/* eslint
-@typescript-eslint/no-non-null-assertion: "off",
- */
-
-import { wrapInternal } from "../lib/wrap";
-import { getCurrentField } from ".";
-
-export function wrap(front: string, back: string): void {
- const editingArea = getCurrentField();
-
- if (editingArea) {
- wrapInternal(editingArea.editableContainer.shadowRoot!, front, back, false);
- }
-}
-
-export function wrapCurrent(front: string, back: string): void {
- const currentField = getCurrentField()!;
- currentField.surroundSelection(front, back);
-}
-
-/* currently unused */
-export function wrapIntoText(front: string, back: string): void {
- const editingArea = getCurrentField();
-
- if (editingArea) {
- wrapInternal(editingArea.editableContainer.shadowRoot!, front, back, false);
- }
-}
diff --git a/ts/lib/context-keys.ts b/ts/lib/context-keys.ts
new file mode 100644
index 000000000..34f27aaf7
--- /dev/null
+++ b/ts/lib/context-keys.ts
@@ -0,0 +1,6 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+export const fontFamilyKey = Symbol("fontFamily");
+export const fontSizeKey = Symbol("fontSize");
+export const directionKey = Symbol("direction");
diff --git a/ts/lib/dom.ts b/ts/lib/dom.ts
index a3e6a0c95..67c47bd99 100644
--- a/ts/lib/dom.ts
+++ b/ts/lib/dom.ts
@@ -50,8 +50,30 @@ export function elementIsBlock(element: Element): boolean {
return BLOCK_TAGS.includes(element.tagName);
}
+export function nodeContainsInlineContent(node: Node): boolean {
+ for (const child of node.childNodes) {
+ if (
+ (nodeIsElement(child) && elementIsBlock(child)) ||
+ !nodeContainsInlineContent(child)
+ ) {
+ return false;
+ }
+ }
+
+ // empty node is trivially inline
+ return true;
+}
+
+export function fragmentToString(fragment: DocumentFragment): string {
+ const fragmentDiv = document.createElement("div");
+ fragmentDiv.appendChild(fragment);
+ const html = fragmentDiv.innerHTML;
+
+ return html;
+}
+
export function caretToEnd(node: Node): void {
- const range = document.createRange();
+ const range = new Range();
range.selectNodeContents(node);
range.collapse(false);
const selection = (node.getRootNode() as Document | ShadowRoot).getSelection()!;
@@ -61,8 +83,8 @@ export function caretToEnd(node: Node): void {
const getAnchorParent =
(predicate: (element: Element) => element is T) =>
- (currentField: DocumentOrShadowRoot): T | null => {
- const anchor = currentField.getSelection()?.anchorNode;
+ (root: DocumentOrShadowRoot): T | null => {
+ const anchor = root.getSelection()?.anchorNode;
if (!anchor) {
return null;
diff --git a/ts/lib/functional.ts b/ts/lib/functional.ts
new file mode 100644
index 000000000..6081322ad
--- /dev/null
+++ b/ts/lib/functional.ts
@@ -0,0 +1,6 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+export const noop: () => void = () => {
+ /* noop */
+};
diff --git a/ts/lib/promise.ts b/ts/lib/promise.ts
new file mode 100644
index 000000000..f99e8169c
--- /dev/null
+++ b/ts/lib/promise.ts
@@ -0,0 +1,13 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+/* eslint
+@typescript-eslint/no-non-null-assertion: "off",
+ */
+
+export function promiseWithResolver(): [Promise, (value: T) => void] {
+ let resolve: (object: T) => void;
+ const promise = new Promise((res) => (resolve = res));
+
+ return [promise, resolve!];
+}
diff --git a/ts/lib/shadow-dom.d.ts b/ts/lib/shadow-dom.d.ts
index 45ba96990..54afa8757 100644
--- a/ts/lib/shadow-dom.d.ts
+++ b/ts/lib/shadow-dom.d.ts
@@ -4,4 +4,8 @@ declare global {
interface DocumentOrShadowRoot {
getSelection(): Selection | null;
}
+
+ interface Node {
+ getRootNode(options?: GetRootNodeOptions): DocumentOrShadowRoot;
+ }
}
diff --git a/ts/lib/shortcuts.ts b/ts/lib/shortcuts.ts
index b0d73a527..44d5a6899 100644
--- a/ts/lib/shortcuts.ts
+++ b/ts/lib/shortcuts.ts
@@ -116,6 +116,7 @@ const GENERAL_KEY = 0;
const NUMPAD_KEY = 3;
function innerShortcut(
+ target: EventTarget | Document,
lastEvent: KeyboardEvent,
callback: (event: KeyboardEvent) => void,
...checks: ((event: KeyboardEvent) => boolean)[]
@@ -128,7 +129,7 @@ function innerShortcut(
const [nextCheck, ...restChecks] = checks;
const handler = (event: KeyboardEvent): void => {
if (nextCheck(event)) {
- innerShortcut(event, callback, ...restChecks);
+ innerShortcut(target, event, callback, ...restChecks);
clearTimeout(interval);
} else if (
event.location === GENERAL_KEY ||
@@ -145,19 +146,20 @@ function innerShortcut(
export function registerShortcut(
callback: (event: KeyboardEvent) => void,
- keyCombinationString: string
+ keyCombinationString: string,
+ target: EventTarget | Document = document
): () => void {
const [check, ...restChecks] =
splitKeyCombinationString(keyCombinationString).map(keyCombinationToCheck);
const handler = (event: KeyboardEvent): void => {
if (check(event)) {
- innerShortcut(event, callback, ...restChecks);
+ innerShortcut(target, event, callback, ...restChecks);
}
};
- document.addEventListener("keydown", handler);
- return (): void => document.removeEventListener("keydown", handler);
+ target.addEventListener("keydown", handler as EventListener);
+ return (): void => target.removeEventListener("keydown", handler as EventListener);
}
registerPackage("anki/shortcuts", {
diff --git a/ts/lib/wrap.ts b/ts/lib/wrap.ts
index 18cc0916a..7f733e232 100644
--- a/ts/lib/wrap.ts
+++ b/ts/lib/wrap.ts
@@ -19,7 +19,7 @@ function moveCursorPastPostfix(selection: Selection, postfix: string): void {
}
export function wrapInternal(
- root: Document | ShadowRoot,
+ root: DocumentOrShadowRoot,
front: string,
back: string,
plainText: boolean
diff --git a/ts/licenses.json b/ts/licenses.json
index 1e6208bb7..44568dbc3 100644
--- a/ts/licenses.json
+++ b/ts/licenses.json
@@ -7,7 +7,7 @@
"path": "node_modules/@fluent/bundle",
"licenseFile": "node_modules/@fluent/bundle/README.md"
},
- "@mdi/svg@6.1.95": {
+ "@mdi/svg@6.2.95": {
"licenses": "Apache-2.0",
"repository": "https://github.com/Templarian/MaterialDesign-SVG",
"publisher": "Austin Andrews",
diff --git a/ts/sveltelib/context-property.ts b/ts/sveltelib/context-property.ts
new file mode 100644
index 000000000..d0e862eff
--- /dev/null
+++ b/ts/sveltelib/context-property.ts
@@ -0,0 +1,31 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+import { setContext, getContext, hasContext } from "svelte";
+
+type ContextProperty = [
+ (value: T) => T,
+ // this typing is a lie insofar that calling get
+ // outside of the component's context will return undefined
+ () => T,
+ () => boolean
+];
+
+function contextProperty(key: symbol): ContextProperty {
+ function set(context: T): T {
+ setContext(key, context);
+ return context;
+ }
+
+ function get(): T {
+ return getContext(key);
+ }
+
+ function has(): boolean {
+ return hasContext(key);
+ }
+
+ return [set, get, has];
+}
+
+export default contextProperty;
diff --git a/ts/sveltelib/mirror-dom.ts b/ts/sveltelib/mirror-dom.ts
new file mode 100644
index 000000000..35e98e3aa
--- /dev/null
+++ b/ts/sveltelib/mirror-dom.ts
@@ -0,0 +1,106 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+import { writable } from "svelte/store";
+import type { Writable } from "svelte/store";
+import storeSubscribe from "./store-subscribe";
+
+const config = {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ characterData: true,
+};
+
+interface DOMMirror {
+ mirror(
+ element: Element,
+ params: { store: Writable }
+ ): { destroy(): void };
+ preventResubscription(): () => void;
+}
+
+function getDOMMirror(): DOMMirror {
+ const allowResubscription = writable(true);
+
+ function preventResubscription() {
+ allowResubscription.set(false);
+
+ return () => {
+ allowResubscription.set(true);
+ };
+ }
+
+ function mirror(
+ element: Element,
+ { store }: { store: Writable }
+ ): { destroy(): void } {
+ function saveHTMLToStore(): void {
+ const range = document.createRange();
+
+ range.selectNodeContents(element);
+ const contents = range.cloneContents();
+
+ store.set(contents);
+ }
+
+ const observer = new MutationObserver(saveHTMLToStore);
+ observer.observe(element, config);
+
+ function mirrorToNode(node: Node): void {
+ observer.disconnect();
+ const clone = node.cloneNode(true);
+
+ /* TODO use Element.replaceChildren */
+ while (element.firstChild) {
+ element.firstChild.remove();
+ }
+
+ while (clone.firstChild) {
+ element.appendChild(clone.firstChild);
+ }
+
+ observer.observe(element, config);
+ }
+
+ const { subscribe, unsubscribe } = storeSubscribe(store, mirrorToNode);
+
+ /* do not update when focused as it will reset caret */
+ element.addEventListener("focus", unsubscribe);
+
+ const unsubResubscription = allowResubscription.subscribe(
+ (allow: boolean): void => {
+ if (allow) {
+ element.addEventListener("blur", subscribe);
+
+ const root = element.getRootNode() as Document | ShadowRoot;
+ if (root.activeElement !== element) {
+ subscribe();
+ }
+ } else {
+ element.removeEventListener("blur", subscribe);
+ }
+ }
+ );
+
+ return {
+ destroy() {
+ observer.disconnect();
+
+ // removes blur event listener
+ allowResubscription.set(false);
+ element.removeEventListener("focus", unsubscribe);
+
+ unsubscribe();
+ unsubResubscription();
+ },
+ };
+ }
+
+ return {
+ mirror,
+ preventResubscription,
+ };
+}
+
+export default getDOMMirror;
diff --git a/ts/sveltelib/node-store.ts b/ts/sveltelib/node-store.ts
new file mode 100644
index 000000000..da0da3b3a
--- /dev/null
+++ b/ts/sveltelib/node-store.ts
@@ -0,0 +1,53 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+/* eslint
+@typescript-eslint/no-non-null-assertion: "off",
+*/
+
+import type { Writable, Subscriber, Unsubscriber, Updater } from "svelte/store";
+import { noop } from "../lib/functional";
+
+export interface NodeStore extends Writable {
+ setUnprocessed(node: T): void;
+}
+
+export function nodeStore(
+ node?: T,
+ preprocess: (node: T) => void = noop
+): NodeStore {
+ const subscribers: Set> = new Set();
+
+ function setUnprocessed(newNode: T): void {
+ if (!node || !node.isEqualNode(newNode)) {
+ node = newNode;
+
+ for (const subscriber of subscribers) {
+ subscriber(node);
+ }
+ }
+ }
+
+ function set(newNode: T): void {
+ preprocess(newNode);
+ setUnprocessed(newNode);
+ }
+
+ function update(fn: Updater): void {
+ set(fn(node!));
+ }
+
+ function subscribe(subscriber: Subscriber): Unsubscriber {
+ subscribers.add(subscriber);
+
+ if (node) {
+ subscriber(node);
+ }
+
+ return () => subscribers.delete(subscriber);
+ }
+
+ return { set, setUnprocessed, update, subscribe };
+}
+
+export default nodeStore;
diff --git a/ts/sveltelib/shortcut.ts b/ts/sveltelib/shortcut.ts
new file mode 100644
index 000000000..12afbd03e
--- /dev/null
+++ b/ts/sveltelib/shortcut.ts
@@ -0,0 +1,27 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+import { registerShortcut } from "../lib/shortcuts";
+
+export function shortcut(
+ _node: Node,
+ {
+ action,
+ keyCombination,
+ target,
+ }: {
+ action: (event: KeyboardEvent) => void;
+ keyCombination: string;
+ target?: EventTarget;
+ }
+): { destroy: () => void } {
+ const deregister = registerShortcut(action, keyCombination, target ?? document);
+
+ return {
+ destroy() {
+ deregister();
+ },
+ };
+}
+
+export default shortcut;
diff --git a/ts/sveltelib/store-subscribe.ts b/ts/sveltelib/store-subscribe.ts
new file mode 100644
index 000000000..8e9a0b9ce
--- /dev/null
+++ b/ts/sveltelib/store-subscribe.ts
@@ -0,0 +1,40 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+import type { Readable, Unsubscriber } from "svelte/store";
+
+interface StoreAccessors {
+ subscribe: () => void;
+ unsubscribe: () => void;
+}
+
+// Prevent double subscriptions / unsubscriptions
+function storeSubscribe(
+ store: Readable,
+ callback: (value: T) => void,
+ start = true
+): StoreAccessors {
+ function subscribe(): Unsubscriber {
+ return store.subscribe(callback);
+ }
+
+ let unsubscribe: Unsubscriber | null = start ? subscribe() : null;
+
+ function resubscribe(): void {
+ if (!unsubscribe) {
+ unsubscribe = subscribe();
+ }
+ }
+
+ function doUnsubscribe() {
+ unsubscribe?.();
+ unsubscribe = null;
+ }
+
+ return {
+ subscribe: resubscribe,
+ unsubscribe: doUnsubscribe,
+ };
+}
+
+export default storeSubscribe;
diff --git a/yarn.lock b/yarn.lock
index 6575c9393..0e4f52a99 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -551,9 +551,9 @@
chalk "^4.0.0"
"@mdi/svg@^6.1.95":
- version "6.1.95"
- resolved "https://registry.yarnpkg.com/@mdi/svg/-/svg-6.1.95.tgz#86837e6335579236673ce2bd82727d2398c7b21f"
- integrity sha512-X3qD1LWJotRUMnkjuAdAJxb1fqRsa+uWh3UugEM2b66mj7NSC6jhn97W8b/UW/ukhDvdP/jRPrqz+2mJf2jN9w==
+ version "6.2.95"
+ resolved "https://registry.yarnpkg.com/@mdi/svg/-/svg-6.2.95.tgz#a8d4d94bb88d82dd60e370ba4ccebef107022347"
+ integrity sha512-VqIqv/c7BL4hOAjStHpBaVh6k1TAvXVrUCg7dJHdnQbHHudlu0w2h3OMx/pCp0V2LaJ73oW2jRDWUyby1eo+mA==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"