Mathjax editor improvements (#1502)

* Remove unnecessary stopPropagation of mathjax-overlay events

* Use CodeMirror component for MathjaxHandle

* Refactor ResizeObserver code in MathjaxHandle

* Wrap setRange in CodeMirror in try/catch

* Add Mathjax Editor bottom margin

* Add custom Enter and Shift+Enter shortcuts for the MathjaxHandle

* Format

* Move placeCaretAfter to domlib

* Move focus back to field after editing Mathjax

* Put Cursor after Mathjax after accepting

* Add delete button for Mathjax

* Change border color of mathjax menu

* Refactor into MathjaxMenu

* Put caretKeyword in variable

* Use one ResizeObserver for all Mathjax images

* Add minmimum width for Mathjax editor

* is still smaller than minimal window width

* Add bazel directories to .prettierignore and format from root

* exclude ftl/usage (dae)

the json files that live there are output from our tooling, and
formatting them means an extra step each time we want to update them

also exclude .mypy_cache, which is output by scripts/mypy*

* minor ftl tweak: newline -> new line  (dae)
This commit is contained in:
Henrik Giesel 2021-11-23 01:27:32 +01:00 committed by GitHub
parent 3db3ae28af
commit 2778b9220c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 447 additions and 326 deletions

View file

@ -1,2 +1,6 @@
licenses.json licenses.json
vendor vendor
node_modules
bazel-*
ftl/usage
.mypy_cache

View file

@ -2,5 +2,5 @@
"trailingComma": "all", "trailingComma": "all",
"printWidth": 88, "printWidth": 88,
"tabWidth": 4, "tabWidth": 4,
"semi": true, "semi": true
} }

View file

@ -30,6 +30,7 @@ editing-latex-math-env = LaTeX math env.
editing-mathjax-block = MathJax block editing-mathjax-block = MathJax block
editing-mathjax-chemistry = MathJax chemistry editing-mathjax-chemistry = MathJax chemistry
editing-mathjax-inline = MathJax inline editing-mathjax-inline = MathJax inline
editing-mathjax-placeholder = Press { $accept } to accept, { $newline } for new line.
editing-media = Media editing-media = Media
editing-ordered-list = Ordered list editing-ordered-list = Ordered list
editing-outdent = Decrease indent editing-outdent = Decrease indent

View file

@ -3,14 +3,7 @@
@use "sass:list"; @use "sass:list";
@use "sass:map"; @use "sass:map";
$bps: ( $bps: ("xs", "sm", "md", "lg", "xl", "xxl");
"xs",
"sm",
"md",
"lg",
"xl",
"xxl",
);
$breakpoints: ( $breakpoints: (
list.nth($bps, 2): 576px, list.nth($bps, 2): 576px,
@ -28,7 +21,7 @@ $breakpoints: (
} @else { } @else {
@content; @content;
} }
}; }
@mixin with-breakpoints($prefix, $dict) { @mixin with-breakpoints($prefix, $dict) {
@each $property, $values in $dict { @each $property, $values in $dict {
@ -46,7 +39,7 @@ $breakpoints: (
} }
} }
} }
}; }
@function breakpoints-upto($upto) { @function breakpoints-upto($upto) {
$result: (); $result: ();
@ -66,7 +59,7 @@ $breakpoints: (
$result: (); $result: ();
@each $bp in breakpoints-upto($upto) { @each $bp in breakpoints-upto($upto) {
$result: list.append($result, ".#{$prefix}-#{$bp}", $separator: comma) $result: list.append($result, ".#{$prefix}-#{$bp}", $separator: comma);
} }
@return $result; @return $result;

14
ts/domlib/place-caret.ts Normal file
View file

@ -0,0 +1,14 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getSelection } from "../lib/cross-browser";
export function placeCaretAfter(node: Node): void {
const range = new Range();
range.setStartAfter(node);
range.collapse(false);
const selection = getSelection(node)!;
selection.removeAllRanges();
selection.addRange(range);
}

View file

@ -6,7 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { updateAllState } from "../components/WithState.svelte"; import { updateAllState } from "../components/WithState.svelte";
import { saveSelection, restoreSelection } from "../domlib/location"; import { saveSelection, restoreSelection } from "../domlib/location";
import { on } from "../lib/events"; import { on, preventDefault } from "../lib/events";
import { registerShortcut } from "../lib/shortcuts"; import { registerShortcut } from "../lib/shortcuts";
export let nodes: Writable<DocumentFragment>; export let nodes: Writable<DocumentFragment>;
@ -18,12 +18,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let inputManager: (editable: HTMLElement) => void; export let inputManager: (editable: HTMLElement) => void;
/* must execute before DOMMirror */
function saveLocation(editable: HTMLElement) {
let removeOnFocus: () => void; let removeOnFocus: () => void;
let removeOnPointerdown: () => void; let removeOnPointerdown: () => void;
const removeOnBlur = on(editable, "blur", () => { function onBlur(): void {
const location = saveSelection(editable); const location = saveSelection(editable);
removeOnFocus = on( removeOnFocus = on(
@ -40,7 +38,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
removeOnPointerdown = on(editable, "pointerdown", () => removeOnFocus?.(), { removeOnPointerdown = on(editable, "pointerdown", () => removeOnFocus?.(), {
once: true, once: true,
}); });
}); }
/* must execute before DOMMirror */
function saveLocation(editable: HTMLElement) {
const removeOnBlur = on(editable, "blur", onBlur);
return { return {
destroy() { destroy() {
@ -54,10 +56,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let editable: HTMLElement; let editable: HTMLElement;
$: if (editable) { $: if (editable) {
registerShortcut((event) => event.preventDefault(), "Control+B", editable); for (const keyCombination of [
registerShortcut((event) => event.preventDefault(), "Control+U", editable); "Control+B",
registerShortcut((event) => event.preventDefault(), "Control+I", editable); "Control+U",
registerShortcut((event) => event.preventDefault(), "Control+R", editable); "Control+I",
"Control+R",
]) {
registerShortcut(preventDefault, keyCombination, editable);
}
} }
</script> </script>

View file

@ -2,40 +2,50 @@
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
--> -->
<script context="module" lang="ts">
import type { Writable } from "svelte/store";
const imageToHeightMap = new Map<string, Writable<number>>();
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
const image = entry.target as HTMLImageElement;
const store = imageToHeightMap.get(image.dataset.uuid!)!;
store.set(entry.contentRect.height);
setTimeout(() => entry.target.dispatchEvent(new Event("resize")));
}
});
</script>
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, getContext, tick } from "svelte"; import { onDestroy, getContext } from "svelte";
import { nightModeKey } from "../components/context-keys"; import { nightModeKey } from "../components/context-keys";
import { convertMathjax } from "./mathjax"; import { convertMathjax } from "./mathjax";
import { randomUUID } from "../lib/uuid";
import { writable } from "svelte/store";
export let mathjax: string; export let mathjax: string;
export let block: boolean; export let block: boolean;
export let autofocus = false;
/* have fixed fontSize for normal */ export let autofocus = false;
export const fontSize: number = 20; export let fontSize = 20;
const nightMode = getContext<boolean>(nightModeKey); const nightMode = getContext<boolean>(nightModeKey);
$: [converted, title] = convertMathjax(mathjax, nightMode, fontSize); $: [converted, title] = convertMathjax(mathjax, nightMode, fontSize);
$: empty = title === "MathJax"; $: empty = title === "MathJax";
let encoded: string;
let imageHeight: number;
$: encoded = encodeURIComponent(converted); $: encoded = encodeURIComponent(converted);
let image: HTMLImageElement; const uuid = randomUUID();
const imageHeight = writable(0);
imageToHeightMap.set(uuid, imageHeight);
const observer = new ResizeObserver(async () => { $: verticalCenter = -$imageHeight / 2 + fontSize / 4;
imageHeight = image.getBoundingClientRect().height;
await tick();
setTimeout(() => image.dispatchEvent(new Event("resize")));
});
onMount(() => { function maybeAutofocus(image: Element): void {
observer.observe(image); if (!autofocus) {
return;
}
if (autofocus) {
// This should trigger a focusing of the Mathjax Handle // This should trigger a focusing of the Mathjax Handle
const focusEvent = new CustomEvent("focusmathjax", { const focusEvent = new CustomEvent("focusmathjax", {
detail: image, detail: image,
@ -45,24 +55,32 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
image.dispatchEvent(focusEvent); image.dispatchEvent(focusEvent);
} }
});
onDestroy(() => { function observe(image: Element) {
observer.observe(image);
return {
destroy() {
observer.unobserve(image); observer.unobserve(image);
observer.disconnect(); },
}); };
}
onDestroy(() => imageToHeightMap.delete(uuid));
</script> </script>
<img <img
bind:this={image}
src="data:image/svg+xml,{encoded}" src="data:image/svg+xml,{encoded}"
class:block class:block
class:empty class:empty
style="--vertical-center: {-imageHeight / 2 + fontSize / 4}px;" style="--vertical-center: {verticalCenter}px;"
alt="Mathjax" alt="Mathjax"
{title} {title}
data-anki="mathjax" data-anki="mathjax"
data-uuid={uuid}
on:dragstart|preventDefault on:dragstart|preventDefault
use:maybeAutofocus
use:observe
/> />
<style lang="scss"> <style lang="scss">
@ -76,7 +94,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.block { .block {
display: block; display: block;
margin: auto; margin: 1rem auto;
} }
.empty { .empty {

View file

@ -9,6 +9,8 @@ import "mathjax/es5/tex-svg-full";
import type { DecoratedElement, DecoratedElementConstructor } from "./decorated"; import type { DecoratedElement, DecoratedElementConstructor } from "./decorated";
import { nodeIsElement } from "../lib/dom"; import { nodeIsElement } from "../lib/dom";
import { noop } from "../lib/functional";
import { placeCaretAfter } from "../domlib/place-caret";
import { nightModeKey } from "../components/context-keys"; import { nightModeKey } from "../components/context-keys";
import Mathjax_svelte from "./Mathjax.svelte"; import Mathjax_svelte from "./Mathjax.svelte";
@ -35,16 +37,6 @@ function moveNodeOutOfElement(
return referenceNode; return referenceNode;
} }
function placeCaretAfter(node: Node): void {
const range = new Range();
range.setStartAfter(node);
range.collapse(false);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
function moveNodesInsertedOutside(element: Element, allowedChild: Node): () => void { function moveNodesInsertedOutside(element: Element, allowedChild: Node): () => void {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
if (element.childNodes.length === 1) { if (element.childNodes.length === 1) {
@ -123,9 +115,7 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
} }
block = false; block = false;
disconnect: () => void = () => { disconnect = noop;
/* noop */
};
component?: Mathjax_svelte; component?: Mathjax_svelte;
static get observedAttributes(): string[] { static get observedAttributes(): string[] {

View file

@ -11,13 +11,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, getContext } from "svelte"; import { createEventDispatcher, getContext, onMount } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import storeSubscribe from "../sveltelib/store-subscribe"; import storeSubscribe from "../sveltelib/store-subscribe";
import { directionKey } from "../lib/context-keys"; import { directionKey } from "../lib/context-keys";
export let configuration: CodeMirror.EditorConfiguration; export let configuration: CodeMirror.EditorConfiguration;
export let code: Writable<string>; export let code: Writable<string>;
export let autofocus = false;
const direction = getContext<Writable<"ltr" | "rtl">>(directionKey); const direction = getContext<Writable<"ltr" | "rtl">>(directionKey);
const defaultConfiguration = { const defaultConfiguration = {
@ -52,13 +53,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}); });
codeMirror.on("focus", () => { codeMirror.on("focus", () => {
if (ranges) { if (ranges) {
try {
codeMirror.setSelections(ranges); codeMirror.setSelections(ranges);
} catch {
ranges = null;
}
} }
unsubscribe(); unsubscribe();
dispatch("focus");
}); });
codeMirror.on("blur", () => { codeMirror.on("blur", () => {
ranges = codeMirror.listSelections(); ranges = codeMirror.listSelections();
subscribe(); subscribe();
dispatch("blur");
}); });
subscribe(); subscribe();
@ -70,6 +77,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
editor: { get: () => codeMirror }, editor: { get: () => codeMirror },
}, },
) as CodeMirrorAPI; ) as CodeMirrorAPI;
onMount(() => {
if (autofocus) {
codeMirror.focus();
codeMirror.setCursor(codeMirror.lineCount(), 0);
}
});
</script> </script>
<div class="code-mirror"> <div class="code-mirror">
@ -79,7 +93,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss"> <style lang="scss">
.code-mirror :global(.CodeMirror) { .code-mirror :global(.CodeMirror) {
height: auto; height: auto;
border-radius: 0 0 5px 5px;
padding: 6px 0; padding: 6px 0;
} }
</style> </style>

View file

@ -16,7 +16,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import CodeMirror from "./CodeMirror.svelte"; import CodeMirror from "./CodeMirror.svelte";
import type { CodeMirrorAPI } from "./CodeMirror.svelte"; import type { CodeMirrorAPI } from "./CodeMirror.svelte";
import { tick, onMount } from "svelte"; import { tick, onMount } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { getDecoratedElements } from "./DecoratedElements.svelte"; import { getDecoratedElements } from "./DecoratedElements.svelte";
@ -143,7 +142,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}); });
</script> </script>
<div class:hidden on:focusin on:focusout> <div class="plain-text-input" class:hidden on:focusin on:focusout>
<CodeMirror <CodeMirror
{configuration} {configuration}
{code} {code}
@ -153,6 +152,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</div> </div>
<style lang="scss"> <style lang="scss">
.plain-text-input :global(.CodeMirror) {
border-radius: 0 0 5px 5px;
}
.hidden { .hidden {
display: none; display: none;
} }

View file

@ -50,6 +50,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { promiseWithResolver } from "../lib/promise"; import { promiseWithResolver } from "../lib/promise";
import { bridgeCommand } from "../lib/bridgecommand"; import { bridgeCommand } from "../lib/bridgecommand";
import { wrapInternal } from "../lib/wrap"; import { wrapInternal } from "../lib/wrap";
import { on } from "../lib/events";
import { nodeStore } from "../sveltelib/node-store"; import { nodeStore } from "../sveltelib/node-store";
import type { DecoratedElement } from "../editable/decorated"; import type { DecoratedElement } from "../editable/decorated";
import { nightModeKey } from "../components/context-keys"; import { nightModeKey } from "../components/context-keys";
@ -142,16 +143,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bridgeCommand("cutOrCopy"); bridgeCommand("cutOrCopy");
} }
richTextInput.addEventListener("paste", onPaste); const removePaste = on(richTextInput, "paste", onPaste);
richTextInput.addEventListener("copy", onCutOrCopy); const removeCopy = on(richTextInput, "copy", onCutOrCopy);
richTextInput.addEventListener("cut", onCutOrCopy); const removeCut = on(richTextInput, "cut", onCutOrCopy);
richTextResolve(richTextInput); richTextResolve(richTextInput);
return { return {
destroy() { destroy() {
richTextInput.removeEventListener("paste", onPaste); removePaste();
richTextInput.removeEventListener("copy", onCutOrCopy); removeCopy();
richTextInput.removeEventListener("cut", onCutOrCopy); removeCut();
}, },
}; };
} }

View file

@ -11,8 +11,9 @@ import "codemirror/mode/stex/stex";
import "codemirror/addon/fold/foldcode"; import "codemirror/addon/fold/foldcode";
import "codemirror/addon/fold/foldgutter"; import "codemirror/addon/fold/foldgutter";
import "codemirror/addon/fold/xml-fold"; import "codemirror/addon/fold/xml-fold";
import "codemirror/addon/edit/matchtags.js"; import "codemirror/addon/edit/matchtags";
import "codemirror/addon/edit/closetag.js"; import "codemirror/addon/edit/closetag";
import "codemirror/addon/display/placeholder";
export { CodeMirror }; export { CodeMirror };

View file

@ -1,81 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { onMount, createEventDispatcher } from "svelte";
import { ChangeTimer } from "../change-timer";
import { CodeMirror, latex, baseOptions } from "../code-mirror";
export let initialValue: string;
const codeMirrorOptions = {
mode: latex,
...baseOptions,
};
let codeMirror: CodeMirror.EditorFromTextArea;
const changeTimer = new ChangeTimer();
const dispatch = createEventDispatcher();
function onInput() {
dispatch("update", { mathjax: codeMirror.getValue() });
/* changeTimer.schedule( */
/* () => dispatch("update", { mathjax: codeMirror.getValue() }), */
/* 400 */
/* ); */
}
function onBlur() {
changeTimer.fireImmediately();
dispatch("codemirrorblur");
}
function openCodemirror(textarea: HTMLTextAreaElement): void {
codeMirror = CodeMirror.fromTextArea(textarea, codeMirrorOptions);
codeMirror.on("change", onInput);
codeMirror.on("blur", onBlur);
}
let textarea: HTMLTextAreaElement;
onMount(() => {
codeMirror.focus();
codeMirror.setCursor(codeMirror.lineCount(), 0);
const codeMirrorElement = textarea.nextElementSibling!;
codeMirrorElement.classList.add("mathjax-editor");
});
</script>
<div
on:click|stopPropagation
on:focus|stopPropagation
on:focusin|stopPropagation
on:keydown|stopPropagation
on:keyup|stopPropagation
on:mousedown|preventDefault|stopPropagation
on:mouseup|stopPropagation
on:paste|stopPropagation
>
<!-- TODO no focusin for now, as EditingArea will defer to Editable/Codable -->
<textarea
bind:this={textarea}
value={initialValue}
on:input={onInput}
use:openCodemirror
/>
</div>
<style lang="scss">
div :global(.mathjax-editor) {
border-radius: 0;
border-width: 0 1px;
border-color: var(--medium-border);
height: auto;
border-radius: 0 0 5px 5px;
padding: 6px 0;
}
</style>

View file

@ -1,36 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte";
import * as tr from "../../lib/ftl";
import { inlineIcon, blockIcon } from "./icons";
export let activeImage: HTMLImageElement;
$: mathjaxElement = activeImage.parentElement!;
</script>
<ButtonGroup size={1.6} wrap={false}>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingMathjaxInline()}
active={activeImage.getAttribute("block") === "true"}
on:click={() => mathjaxElement.setAttribute("block", "false")}
on:click>{@html inlineIcon}</IconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingMathjaxBlock()}
active={activeImage.getAttribute("block") === "false"}
on:click={() => mathjaxElement.setAttribute("block", "true")}
on:click>{@html blockIcon}</IconButton
>
</ButtonGroupItem>
</ButtonGroup>

View file

@ -0,0 +1,54 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ButtonToolbar from "../../components/ButtonToolbar.svelte";
import Item from "../../components/Item.svelte";
import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte";
import * as tr from "../../lib/ftl";
import { inlineIcon, blockIcon, deleteIcon } from "./icons";
import { createEventDispatcher } from "svelte";
export let activeImage: HTMLImageElement;
export let mathjaxElement: HTMLElement;
const dispatch = createEventDispatcher();
</script>
<ButtonToolbar size={1.6} wrap={false}>
<Item>
<ButtonGroup>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingMathjaxInline()}
active={activeImage.getAttribute("block") === "true"}
on:click={() => mathjaxElement.setAttribute("block", "false")}
on:click>{@html inlineIcon}</IconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingMathjaxBlock()}
active={activeImage.getAttribute("block") === "false"}
on:click={() => mathjaxElement.setAttribute("block", "true")}
on:click>{@html blockIcon}</IconButton
>
</ButtonGroupItem>
</ButtonGroup>
</Item>
<Item>
<ButtonGroup>
<ButtonGroupItem>
<IconButton
tooltip={tr.actionsDelete()}
on:click={() => dispatch("delete")}>{@html deleteIcon}</IconButton
>
</ButtonGroupItem>
</ButtonGroup>
</Item>
</ButtonToolbar>

View file

@ -0,0 +1,59 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import CodeMirror from "../CodeMirror.svelte";
import type { Writable } from "svelte/store";
import { baseOptions, latex } from "../code-mirror";
import { getPlatformString } from "../../lib/shortcuts";
import { noop } from "../../lib/functional";
import * as tr from "../../lib/ftl";
export let acceptShortcut: string;
export let newlineShortcut: string;
export let code: Writable<string>;
const configuration = {
...Object.assign({}, baseOptions, {
extraKeys: {
...(baseOptions.extraKeys as CodeMirror.KeyMap),
[acceptShortcut]: noop,
[newlineShortcut]: noop,
},
}),
placeholder: tr.editingMathjaxPlaceholder({
accept: getPlatformString(acceptShortcut),
newline: getPlatformString(newlineShortcut),
}),
mode: latex,
};
</script>
<div class="mathjax-editor">
<CodeMirror
{code}
{configuration}
on:change={({ detail }) => code.set(detail)}
on:blur
autofocus
/>
</div>
<style lang="scss">
.mathjax-editor {
:global(.CodeMirror) {
max-width: 28rem;
min-width: 14rem;
margin-bottom: 0.25rem;
}
:global(.CodeMirror-placeholder) {
font-family: sans-serif;
font-size: 55%;
text-align: center;
color: var(--slightly-grey-text);
}
}
</style>

View file

@ -4,35 +4,71 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import WithDropdown from "../../components/WithDropdown.svelte"; import WithDropdown from "../../components/WithDropdown.svelte";
import ButtonToolbar from "../../components/ButtonToolbar.svelte"; import MathjaxMenu from "./MathjaxMenu.svelte";
import DropdownMenu from "../../components/DropdownMenu.svelte"; import { onMount, onDestroy, tick } from "svelte";
import Item from "../../components/Item.svelte"; import { writable } from "svelte/store";
import HandleSelection from "../HandleSelection.svelte";
import HandleBackground from "../HandleBackground.svelte";
import HandleControl from "../HandleControl.svelte";
import InlineBlock from "./InlineBlock.svelte";
import Editor from "./Editor.svelte";
import { onMount, tick } from "svelte";
import { getRichTextInput } from "../RichTextInput.svelte"; import { getRichTextInput } from "../RichTextInput.svelte";
import { placeCaretAfter } from "../../domlib/place-caret";
import { noop } from "../../lib/functional"; import { noop } from "../../lib/functional";
import { on } from "../../lib/events";
const { container, api } = getRichTextInput(); const { container, api } = getRichTextInput();
const code = writable("");
let activeImage: HTMLImageElement | null = null; let activeImage: HTMLImageElement | null = null;
let allow: () => void; let mathjaxElement: HTMLElement | null = null;
let allow = noop;
let unsubscribe = noop;
const caretKeyword = "caretAfter";
function showHandle(image: HTMLImageElement): void { function showHandle(image: HTMLImageElement): void {
allow = api.preventResubscription(); allow = api.preventResubscription();
activeImage = image; activeImage = image;
image.setAttribute(caretKeyword, "true");
mathjaxElement = activeImage.closest("anki-mathjax")!;
code.set(mathjaxElement.dataset.mathjax ?? "");
unsubscribe = code.subscribe((value: string) => {
mathjaxElement!.dataset.mathjax = value;
});
} }
async function maybeShowHandle(event: Event): Promise<void> { async function clearImage(): Promise<void> {
await resetHandle(); if (activeImage && mathjaxElement) {
const target = event.target; unsubscribe();
activeImage = null;
mathjaxElement = null;
}
await tick();
container.focus();
}
function placeCaret(image: HTMLImageElement): void {
placeCaretAfter(image);
image.removeAttribute(caretKeyword);
}
async function resetHandle(deletes: boolean = false): Promise<void> {
await clearImage();
const image = container.querySelector(`[${caretKeyword}]`);
if (image) {
placeCaret(image as HTMLImageElement);
if (deletes) {
image.remove();
}
}
allow();
}
async function maybeShowHandle({ target }: Event): Promise<void> {
await resetHandle();
if (target instanceof HTMLImageElement && target.dataset.anki === "mathjax") { if (target instanceof HTMLImageElement && target.dataset.anki === "mathjax") {
showHandle(target); showHandle(target);
} }
@ -45,71 +81,45 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
showHandle(detail); showHandle(detail);
} }
async function resetHandle(): Promise<void> {
if (activeImage) {
allow();
activeImage = null;
await tick();
}
}
onMount(() => { onMount(() => {
container.addEventListener("click", maybeShowHandle); const removeClick = on(container, "click", maybeShowHandle);
container.addEventListener("focusmathjax" as any, showAutofocusHandle); const removeFocus = on(container, "focusmathjax" as any, showAutofocusHandle);
container.addEventListener("key", resetHandle);
container.addEventListener("paste", resetHandle);
return () => { return () => {
container.removeEventListener("click", maybeShowHandle); removeClick();
container.removeEventListener("focusmathjax" as any, showAutofocusHandle); removeFocus();
container.removeEventListener("key", resetHandle);
container.removeEventListener("paste", resetHandle);
}; };
}); });
let updateSelection: () => Promise<void>;
let errorMessage: string;
let dropdownApi: any; let dropdownApi: any;
async function onImageResize(): Promise<void> { async function onImageResize(): Promise<void> {
if (activeImage) { errorMessage = activeImage!.title;
errorMessage = activeImage.title;
await updateSelection(); await updateSelection();
dropdownApi.update(); dropdownApi.update();
} }
}
const resizeObserver = new ResizeObserver(onImageResize);
let clearResize = noop; let clearResize = noop;
function handleImageResizing(activeImage: HTMLImageElement | null) { function handleImageResizing(activeImage: HTMLImageElement | null) {
if (activeImage) { if (activeImage) {
activeImage.addEventListener("resize", onImageResize); resizeObserver.observe(container);
clearResize = on(activeImage, "resize", onImageResize);
const lastImage = activeImage;
clearResize = () => lastImage.removeEventListener("resize", onImageResize);
} else { } else {
resizeObserver.unobserve(container);
clearResize(); clearResize();
} }
} }
$: handleImageResizing(activeImage); $: handleImageResizing(activeImage);
const resizeObserver = new ResizeObserver(async () => { onDestroy(() => {
if (activeImage) { resizeObserver.disconnect();
await updateSelection(); clearResize();
dropdownApi.update();
}
}); });
resizeObserver.observe(container);
let updateSelection: () => Promise<void>;
let errorMessage: string;
function getComponent(image: HTMLImageElement): HTMLElement {
return image.closest("anki-mathjax")! as HTMLElement;
}
function onEditorUpdate(event: CustomEvent): void {
/* this updates the image in Mathjax.svelte */
getComponent(activeImage!).dataset.mathjax = event.detail.mathjax;
}
</script> </script>
<WithDropdown <WithDropdown
@ -119,37 +129,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
distance={4} distance={4}
let:createDropdown let:createDropdown
> >
{#if activeImage} {#if activeImage && mathjaxElement}
<HandleSelection <MathjaxMenu
image={activeImage} {activeImage}
{mathjaxElement}
{container} {container}
{errorMessage}
{code}
bind:updateSelection bind:updateSelection
on:mount={(event) => (dropdownApi = createDropdown(event.detail.selection))} on:mount={(event) => (dropdownApi = createDropdown(event.detail.selection))}
> on:reset={() => resetHandle(false)}
<HandleBackground tooltip={errorMessage} /> on:delete={() => resetHandle(true)}
<HandleControl offsetX={1} offsetY={1} />
</HandleSelection>
<DropdownMenu>
<Editor
initialValue={getComponent(activeImage).dataset.mathjax ?? ""}
on:update={onEditorUpdate}
on:codemirrorblur={resetHandle}
/> />
<div class="margin-x">
<ButtonToolbar>
<Item>
<InlineBlock {activeImage} on:click={updateSelection} />
</Item>
</ButtonToolbar>
</div>
</DropdownMenu>
{/if} {/if}
</WithDropdown> </WithDropdown>
<style lang="scss">
.margin-x {
margin: 0 0.125rem;
}
</style>

View file

@ -0,0 +1,69 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Shortcut from "../../components/Shortcut.svelte";
import DropdownMenu from "../../components/DropdownMenu.svelte";
import HandleSelection from "../HandleSelection.svelte";
import HandleBackground from "../HandleBackground.svelte";
import HandleControl from "../HandleControl.svelte";
import MathjaxEditor from "./MathjaxEditor.svelte";
import MathjaxButtons from "./MathjaxButtons.svelte";
import type { Writable } from "svelte/store";
import { createEventDispatcher } from "svelte";
export let activeImage: HTMLImageElement;
export let mathjaxElement: HTMLElement;
export let container: HTMLElement;
export let errorMessage: string;
export let code: Writable<string>;
const acceptShortcut = "Enter";
const newlineShortcut = "Shift+Enter";
function appendNewline(): void {
code.update((value) => `${value}\n`);
}
export let updateSelection: () => Promise<void>;
let dropdownApi: any;
export async function update() {
await updateSelection?.();
dropdownApi.update();
}
const dispatch = createEventDispatcher();
</script>
<div class="mathjax-menu">
<HandleSelection image={activeImage} {container} bind:updateSelection on:mount>
<HandleBackground tooltip={errorMessage} />
<HandleControl offsetX={1} offsetY={1} />
</HandleSelection>
<DropdownMenu>
<MathjaxEditor
{acceptShortcut}
{newlineShortcut}
{code}
on:blur={() => dispatch("reset")}
/>
<Shortcut keyCombination={acceptShortcut} on:action={() => dispatch("reset")} />
<Shortcut keyCombination={newlineShortcut} on:action={appendNewline} />
<MathjaxButtons
{activeImage}
{mathjaxElement}
on:delete={() => dispatch("delete")}
/>
</DropdownMenu>
</div>
<style lang="scss">
.mathjax-menu :global(.dropdown-menu) {
border-color: var(--border);
}
</style>

View file

@ -3,3 +3,4 @@
export { default as inlineIcon } from "@mdi/svg/svg/format-wrap-square.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 blockIcon } from "@mdi/svg/svg/format-wrap-top-bottom.svg";
export { default as deleteIcon } from "@mdi/svg/svg/delete.svg";

View file

@ -31,3 +31,7 @@ export function on<T extends EventTarget, K extends keyof EventTargetToMap<T>>(
return () => return () =>
target.removeEventListener(eventType, handler as EventListener, options); target.removeEventListener(eventType, handler as EventListener, options);
} }
export function preventDefault(event: Event): void {
event.preventDefault();
}

17
ts/lib/uuid.ts Normal file
View file

@ -0,0 +1,17 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/**
* TODO replace with crypto.randomUUID
*/
export function randomUUID(): string {
const value = `${1e7}-${1e3}-${4e3}-${8e3}-${1e11}`;
return value.replace(/[018]/g, (character: string): string =>
(
Number(character) ^
(crypto.getRandomValues(new Uint8Array(1))[0] &
(15 >> (Number(character) / 4)))
).toString(16),
);
}