Merge pull request #1324 from hgiesel/mathjaxboxv4

MathJax Preview + In-line editor
This commit is contained in:
Damien Elmes 2021-09-16 10:27:08 +10:00 committed by GitHub
commit 22e8ce3eb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 1482 additions and 413 deletions

View File

@ -21,11 +21,19 @@ copy_files_into_group(
name = "editor", name = "editor",
srcs = [ srcs = [
"editor.css", "editor.css",
"editable.css",
], ],
package = "//ts/editor", package = "//ts/editor",
) )
copy_files_into_group(
name = "editable",
srcs = [
"editable-build.css",
],
package = "//ts/editable",
)
copy_files_into_group( copy_files_into_group(
name = "reviewer", name = "reviewer",
srcs = [ srcs = [
@ -39,6 +47,7 @@ filegroup(
srcs = [ srcs = [
"css_local", "css_local",
"editor", "editor",
"editable",
"reviewer", "reviewer",
], ],
visibility = ["//qt:__subpackages__"], visibility = ["//qt:__subpackages__"],

View File

@ -80,10 +80,10 @@ audio = (
_html = """ _html = """
<div id="fields"></div> <div id="fields"></div>
<div id="dupes" class="is-inactive"> <div id="dupes" class="d-none">
<a href="#" onclick="pycmd('dupes');return false;">%s</a> <a href="#" onclick="pycmd('dupes');return false;">%s</a>
</div> </div>
<div id="cloze-hint"></div> <div id="cloze-hint" class="d-none"></div>
<div id="tag-editor-anchor" class="d-none"></div> <div id="tag-editor-anchor" class="d-none"></div>
""" """

View File

@ -5,7 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import type { ChangeNotetypeState } from "./lib"; import type { ChangeNotetypeState } from "./lib";
import StickyBar from "components/StickyBar.svelte"; import StickyHeader from "components/StickyHeader.svelte";
import ButtonToolbar from "components/ButtonToolbar.svelte"; import ButtonToolbar from "components/ButtonToolbar.svelte";
import Item from "components/Item.svelte"; import Item from "components/Item.svelte";
import ButtonGroup from "components/ButtonGroup.svelte"; import ButtonGroup from "components/ButtonGroup.svelte";
@ -25,7 +25,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
</script> </script>
<StickyBar> <StickyHeader>
<ButtonToolbar class="justify-content-between" size={2.3} wrap={false}> <ButtonToolbar class="justify-content-between" size={2.3} wrap={false}>
<Item> <Item>
<ButtonGroup class="flex-grow-1"> <ButtonGroup class="flex-grow-1">
@ -48,4 +48,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<SaveButton {state} /> <SaveButton {state} />
</Item> </Item>
</ButtonToolbar> </ButtonToolbar>
</StickyBar> </StickyHeader>

View File

@ -19,14 +19,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonToolbar <ButtonToolbar
{id} {id}
class={`dropdown-menu btn-dropdown-menu ${className}`} class="dropdown-menu btn-dropdown-menu {className}"
wrap={false} wrap={false}
{api} {api}
> >
<slot /> <div on:mousedown|preventDefault|stopPropagation>
<slot />
</div>
</ButtonToolbar> </ButtonToolbar>
<style lang="scss"> <style lang="scss">
div {
display: contents;
}
:global(.dropdown-menu.btn-dropdown-menu) { :global(.dropdown-menu.btn-dropdown-menu) {
display: none; display: none;
min-width: 0; min-width: 0;

View File

@ -16,7 +16,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setContext(dropdownKey, null); setContext(dropdownKey, null);
</script> </script>
<div {id} class="dropdown-menu" class:show aria-labelledby={labelledby}> <div
{id}
class="dropdown-menu"
class:show
aria-labelledby={labelledby}
on:mousedown|preventDefault|stopPropagation
>
<div class="dropdown-content {className}"> <div class="dropdown-content {className}">
<slot /> <slot />
</div> </div>
@ -24,7 +30,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss"> <style lang="scss">
.dropdown-menu { .dropdown-menu {
border-radius: 5px;
background-color: var(--frame-bg); background-color: var(--frame-bg);
border-color: var(--medium-border); border-color: var(--medium-border);
min-width: 1rem;
} }
</style> </style>

View File

@ -7,16 +7,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let className: string = ""; let className: string = "";
export { className as class }; export { className as class };
export let height: number; export let height: number = 0;
</script> </script>
<footer {id} bind:offsetHeight={height} class={`container-fluid pt-1 ${className}`}> <footer {id} bind:offsetHeight={height} class="container-fluid pt-1 {className}">
<slot /> <slot />
</footer> </footer>
<style lang="scss"> <style lang="scss">
footer { footer {
position: fixed; position: sticky;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;

View File

@ -8,12 +8,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { className as class }; export { className as class };
</script> </script>
<nav {id} class={`container-fluid py-1 ${className}`}> <header {id} class="container-fluid pb-1 {className}">
<slot /> <slot />
</nav> </header>
<style lang="scss"> <style lang="scss">
nav { header {
position: sticky; position: sticky;
top: 0; top: 0;
left: 0; left: 0;

View File

@ -46,6 +46,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: dropClass = `drop${drop}`; $: dropClass = `drop${drop}`;
export let skidding = 0;
export let distance = 2;
setContext(dropdownKey, { setContext(dropdownKey, {
dropdown: true, dropdown: true,
"data-bs-toggle": "dropdown", "data-bs-toggle": "dropdown",
@ -72,6 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
dropdown = new Dropdown(toggle, { dropdown = new Dropdown(toggle, {
autoClose, autoClose,
offset: [skidding, distance],
popperConfig: { placement }, popperConfig: { placement },
} as any); } as any);
@ -79,7 +83,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
dropdown.show(); dropdown.show();
} }
let api = { api = {
show: dropdown.show.bind(dropdown), show: dropdown.show.bind(dropdown),
toggle: dropdown.toggle.bind(dropdown), toggle: dropdown.toggle.bind(dropdown),
hide: dropdown.hide.bind(dropdown), hide: dropdown.hide.bind(dropdown),

View File

@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
import TextInputModal from "./TextInputModal.svelte"; import TextInputModal from "./TextInputModal.svelte";
import StickyBar from "components/StickyBar.svelte"; import StickyHeader from "components/StickyHeader.svelte";
import ButtonToolbar from "components/ButtonToolbar.svelte"; import ButtonToolbar from "components/ButtonToolbar.svelte";
import Item from "components/Item.svelte"; import Item from "components/Item.svelte";
import ButtonGroup from "components/ButtonGroup.svelte"; import ButtonGroup from "components/ButtonGroup.svelte";
@ -87,7 +87,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bind:modalKey bind:modalKey
/> />
<StickyBar class="g-1"> <StickyHeader class="g-1">
<ButtonToolbar class="justify-content-between" size={2.3} wrap={false}> <ButtonToolbar class="justify-content-between" size={2.3} wrap={false}>
<Item> <Item>
<ButtonGroup class="flex-grow-1"> <ButtonGroup class="flex-grow-1">
@ -115,4 +115,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/> />
</Item> </Item>
</ButtonToolbar> </ButtonToolbar>
</StickyBar> </StickyHeader>

114
ts/editable/BUILD.bazel Normal file
View File

@ -0,0 +1,114 @@
load("@npm//@bazel/typescript:index.bzl", "ts_library")
load("//ts/svelte:svelte.bzl", "compile_svelte", "svelte_check")
load("//ts:prettier.bzl", "prettier_test")
load("//ts:eslint.bzl", "eslint_test")
load("//ts:esbuild.bzl", "esbuild")
load("//ts:vendor.bzl", "copy_mdi_icons")
load("//ts:compile_sass.bzl", "compile_sass")
svelte_files = glob(["*.svelte"])
svelte_names = [f.replace(".svelte", "") for f in svelte_files]
filegroup(
name = "svelte_components",
srcs = svelte_names,
visibility = ["//visibility:public"],
)
compile_svelte(
name = "svelte",
srcs = svelte_files,
visibility = ["//visibility:public"],
deps = [
"//ts/components",
],
)
compile_sass(
srcs = [
"editable-base.scss",
],
group = "editable_scss",
visibility = ["//visibility:public"],
deps = [
"//ts/sass:scrollbar_lib",
"//ts/sass/codemirror",
],
)
copy_mdi_icons(
name = "mdi-icons",
icons = [
"math-integral-box.svg",
],
visibility = ["//visibility:public"],
)
ts_library(
name = "editable",
srcs = glob(["*.ts"]),
module_name = "editable",
tsconfig = "//ts:tsconfig.json",
visibility = ["//visibility:public"],
deps = [
"//ts/lib",
"//ts/sveltelib",
"//ts/components",
"//ts:image_module_support",
"@npm//svelte",
"@npm//mathjax-full",
"@npm//mathjax",
] + svelte_names,
)
esbuild(
name = "editable-build",
args = [
"--loader:.svg=text",
"--resolve-extensions=.mjs,.js",
"--log-level=warning",
],
entry_point = "index.ts",
output_css = "editable-build.css",
visibility = ["//visibility:public"],
deps = [
"mdi-icons",
"editable",
"editable_scss",
"svelte_components",
"//ts/components",
"//ts/components:svelte_components",
"@npm//protobufjs",
],
)
# Tests
################
prettier_test(
name = "format_check",
srcs = glob([
"*.ts",
"*.svelte",
]),
)
eslint_test(
name = "eslint",
srcs = glob(
[
"*.ts",
],
),
)
svelte_check(
name = "svelte_check",
srcs = glob([
"*.ts",
"*.svelte",
]) + [
"//ts/components",
],
)

View File

@ -0,0 +1,64 @@
<!--
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, getContext } from "svelte";
import { nightModeKey } from "components/context-keys";
import { convertMathjax } from "./mathjax";
export let mathjax: string;
export let block: boolean;
export let autofocus = false;
/* have fixed fontSize for normal */
export const fontSize: number = 20;
const nightMode = getContext<boolean>(nightModeKey);
$: [converted, title] = convertMathjax(mathjax, nightMode, fontSize);
$: empty = title === "MathJax";
let encoded: string;
let imageHeight: number;
$: {
encoded = encodeURIComponent(converted);
setTimeout(() => (imageHeight = image.getBoundingClientRect().height));
}
let image: HTMLImageElement;
onMount(() => {
if (autofocus) {
image.click();
}
});
</script>
<img
bind:this={image}
src="data:image/svg+xml,{encoded}"
class:block
class:empty
style="--vertical-center: {-imageHeight / 2 + fontSize / 4}px;"
alt="Mathjax"
{title}
data-anki="mathjax"
on:dragstart|preventDefault
/>
<style lang="scss">
img {
vertical-align: var(--vertical-center);
}
.block {
display: block;
margin: auto;
}
.empty {
vertical-align: sub;
}
</style>

45
ts/editable/decorated.ts Normal file
View File

@ -0,0 +1,45 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/**
* decorated elements know three states:
* - stored, which is stored in the DB, e.g. `\(\alpha + \beta\)`
* - undecorated, which is displayed to the user in Codable, e.g. `<anki-mathjax>\alpha + \beta</anki-mathjax>`
* - decorated, which is displayed to the user in Editable, e.g. `<anki-mathjax data-mathjax="\alpha + \beta"><img src="data:..."></anki-mathjax>`
*/
export interface DecoratedElement extends HTMLElement {
/**
* Transforms itself from undecorated to decorated state.
* Should be called in connectedCallback.
*/
decorate(): void;
/**
* Transforms itself from decorated to undecorated state.
*/
undecorate(): void;
}
export interface DecoratedElementConstructor extends CustomElementConstructor {
prototype: DecoratedElement;
tagName: string;
/**
* Transforms elements in input HTML from undecorated to stored state.
*/
toStored(undecorated: string): string;
/**
* Transforms elements in input HTML from stored to undecorated state.
*/
toUndecorated(stored: string): string;
}
class DefineArray extends Array {
push(...elements: DecoratedElementConstructor[]) {
for (const element of elements) {
customElements.define(element.tagName, element);
}
return super.push(...elements);
}
}
export const decoratedComponents: DecoratedElementConstructor[] = new DefineArray();

View File

@ -15,6 +15,10 @@ anki-editable {
} }
} }
anki-mathjax {
white-space: pre;
}
p { p {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@ -0,0 +1,96 @@
// 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",
*/
function loadStyleLink(container: Node, href: string): Promise<void> {
const rootStyle = document.createElement("link");
rootStyle.setAttribute("rel", "stylesheet");
rootStyle.setAttribute("href", href);
let styleResolve: () => void;
const stylePromise = new Promise<void>((resolve) => (styleResolve = resolve));
rootStyle.addEventListener("load", () => styleResolve());
container.appendChild(rootStyle);
return stylePromise;
}
function loadStyleTag(container: Node): [HTMLStyleElement, Promise<void>] {
const style = document.createElement("style");
style.setAttribute("rel", "stylesheet");
let styleResolve: () => void;
const stylePromise = new Promise<void>((resolve) => (styleResolve = resolve));
style.addEventListener("load", () => styleResolve());
container.appendChild(style);
return [style, stylePromise];
}
export class EditableContainer extends HTMLDivElement {
baseStyle: HTMLStyleElement;
imageStyle: HTMLStyleElement;
imagePromise: Promise<void>;
stylePromise: Promise<void>;
baseRule?: CSSStyleRule;
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
if (document.documentElement.classList.contains("night-mode")) {
this.classList.add("night-mode");
}
const rootPromise = loadStyleLink(shadow, "./_anki/css/editable-build.css");
const [baseStyle, basePromise] = loadStyleTag(shadow);
const [imageStyle, imagePromise] = loadStyleTag(shadow);
this.baseStyle = baseStyle;
this.imageStyle = imageStyle;
this.imagePromise = imagePromise;
this.stylePromise = Promise.all([
rootPromise,
basePromise,
imagePromise,
]) as unknown as Promise<void>;
}
connectedCallback(): void {
const sheet = this.baseStyle.sheet as CSSStyleSheet;
const baseIndex = sheet.insertRule("anki-editable {}");
this.baseRule = sheet.cssRules[baseIndex] as CSSStyleRule;
}
initialize(color: string): void {
this.setBaseColor(color);
}
setBaseColor(color: string): void {
if (this.baseRule) {
this.baseRule.style.color = color;
}
}
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
if (this.baseRule) {
this.baseRule.style.fontFamily = fontFamily;
this.baseRule.style.fontSize = fontSize;
this.baseRule.style.direction = direction;
}
}
isRightToLeft(): boolean {
return this.baseRule!.style.direction === "rtl";
}
}
customElements.define("anki-editable-container", EditableContainer, { extends: "div" });

View File

@ -1,10 +1,24 @@
// 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
import { bridgeCommand } from "./lib"; /* eslint
import { elementIsBlock, caretToEnd, getBlockElement } from "./helpers"; @typescript-eslint/no-non-null-assertion: "off",
import { inCodable } from "./toolbar"; */
import { wrap } from "./wrap";
import type { DecoratedElement } from "./decorated";
import { decoratedComponents } from "./decorated";
import { bridgeCommand } from "lib/bridgecommand";
import { elementIsBlock, getBlockElement } from "lib/dom";
import { wrapInternal } from "lib/wrap";
export function caretToEnd(node: Node): void {
const range = document.createRange();
range.selectNodeContents(node);
range.collapse(false);
const selection = (node.getRootNode() as Document | ShadowRoot).getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
function containsInlineContent(element: Element): boolean { function containsInlineContent(element: Element): boolean {
for (const child of element.children) { for (const child of element.children) {
@ -26,26 +40,32 @@ export class Editable extends HTMLElement {
} }
get fieldHTML(): string { get fieldHTML(): string {
return containsInlineContent(this) && this.innerHTML.endsWith("<br>") const clone = this.cloneNode(true) as Element;
? this.innerHTML.slice(0, -4) // trim trailing <br>
: this.innerHTML; for (const component of decoratedComponents) {
for (const element of clone.getElementsByTagName(component.tagName)) {
(element as DecoratedElement).undecorate();
}
}
const result =
containsInlineContent(clone) && clone.innerHTML.endsWith("<br>")
? clone.innerHTML.slice(0, -4) // trim trailing <br>
: clone.innerHTML;
return result;
} }
connectedCallback(): void { connectedCallback(): void {
this.setAttribute("contenteditable", ""); this.setAttribute("contenteditable", "");
} }
focus(): void {
super.focus();
inCodable.set(false);
}
caretToEnd(): void { caretToEnd(): void {
caretToEnd(this); caretToEnd(this);
} }
surroundSelection(before: string, after: string): void { surroundSelection(before: string, after: string): void {
wrap(before, after); wrapInternal(this.getRootNode() as ShadowRoot, before, after, false);
} }
onEnter(event: KeyboardEvent): void { onEnter(event: KeyboardEvent): void {
@ -63,3 +83,5 @@ export class Editable extends HTMLElement {
event.preventDefault(); event.preventDefault();
} }
} }
customElements.define("anki-editable", Editable);

4
ts/editable/icons.ts Normal file
View File

@ -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 mathIcon } from "./math-integral-box.svg";

7
ts/editable/index.ts Normal file
View File

@ -0,0 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import "./editable-base.css";
import "./editable-container";
import "./editable";
import "./mathjax-component";

View File

@ -0,0 +1,196 @@
// 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 "mathjax/es5/tex-svg-full";
import type { DecoratedElement, DecoratedElementConstructor } from "./decorated";
import { decoratedComponents } from "./decorated";
import { nodeIsElement } from "lib/dom";
import { nightModeKey } from "components/context-keys";
import Mathjax_svelte from "./Mathjax.svelte";
function moveNodeOutOfElement(
element: Element,
node: Node,
placement: "beforebegin" | "afterend"
): Node {
element.removeChild(node);
let referenceNode: Node;
if (nodeIsElement(node)) {
referenceNode = element.insertAdjacentElement(placement, node)!;
} else {
element.insertAdjacentText(placement, (node as Text).wholeText);
referenceNode =
placement === "beforebegin"
? element.previousSibling!
: element.nextSibling!;
}
return referenceNode;
}
function placeCaretAfter(node: Node): void {
const range = document.createRange();
range.setStartAfter(node);
range.collapse(false);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
function moveNodesInsertedOutside(element: Element, allowedChild: Node): () => void {
const observer = new MutationObserver(() => {
if (element.childNodes.length === 1) {
return;
}
const childNodes = [...element.childNodes];
const allowedIndex = childNodes.findIndex((child) => child === allowedChild);
const beforeChildren = childNodes.slice(0, allowedIndex);
const afterChildren = childNodes.slice(allowedIndex + 1);
// Special treatment for pressing return after mathjax block
if (
afterChildren.length === 2 &&
afterChildren.every((child) => (child as Element).tagName === "BR")
) {
const first = afterChildren.pop();
element.removeChild(first!);
}
let lastNode: Node | null = null;
for (const node of beforeChildren) {
lastNode = moveNodeOutOfElement(element, node, "beforebegin");
}
for (const node of afterChildren) {
lastNode = moveNodeOutOfElement(element, node, "afterend");
}
if (lastNode) {
placeCaretAfter(lastNode);
}
});
observer.observe(element, { childList: true, characterData: true });
return () => observer.disconnect();
}
const mathjaxTagPattern =
/<anki-mathjax(?:[^>]*?block="(.*?)")?[^>]*?>(.*?)<\/anki-mathjax>/gsu;
const mathjaxBlockDelimiterPattern = /\\\[(.*?)\\\]/gsu;
const mathjaxInlineDelimiterPattern = /\\\((.*?)\\\)/gsu;
export const Mathjax: DecoratedElementConstructor = class Mathjax
extends HTMLElement
implements DecoratedElement
{
static tagName = "anki-mathjax";
static toStored(undecorated: string): string {
return undecorated.replace(
mathjaxTagPattern,
(_match: string, block: string | undefined, text: string) => {
return typeof block === "string" && block !== "false"
? `\\[${text}\\]`
: `\\(${text}\\)`;
}
);
}
static toUndecorated(stored: string): string {
return stored
.replace(
mathjaxBlockDelimiterPattern,
(_match: string, text: string) =>
`<anki-mathjax block="true">${text}</anki-mathjax>`
)
.replace(
mathjaxInlineDelimiterPattern,
(_match: string, text: string) => `<anki-mathjax>${text}</anki-mathjax>`
);
}
block = false;
disconnect: () => void = () => {
/* noop */
};
component?: Mathjax_svelte;
static get observedAttributes(): string[] {
return ["block", "data-mathjax"];
}
connectedCallback(): void {
this.decorate();
this.disconnect = moveNodesInsertedOutside(this, this.children[0]);
}
disconnectedCallback(): void {
this.disconnect();
}
attributeChangedCallback(name: string, _old: string, newValue: string): void {
switch (name) {
case "block":
this.block = newValue !== "false";
this.component?.$set({ block: this.block });
break;
case "data-mathjax":
this.component?.$set({ mathjax: newValue });
break;
}
}
decorate(): void {
const mathjax = (this.dataset.mathjax = this.innerText);
this.innerHTML = "";
this.style.whiteSpace = "normal";
const context = new Map();
context.set(
nightModeKey,
document.documentElement.classList.contains("night-mode")
);
this.component = new Mathjax_svelte({
target: this,
props: {
mathjax,
block: this.block,
autofocus: this.hasAttribute("focusonmount"),
},
context,
} as any);
}
undecorate(): void {
this.innerHTML = this.dataset.mathjax ?? "";
delete this.dataset.mathjax;
this.removeAttribute("style");
this.removeAttribute("focusonmount");
this.component?.$destroy();
this.component = undefined;
if (this.block) {
this.setAttribute("block", "true");
} else {
this.removeAttribute("block");
}
}
};
decoratedComponents.push(Mathjax);

57
ts/editable/mathjax.ts Normal file
View File

@ -0,0 +1,57 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { mathIcon } from "./icons";
const parser = new DOMParser();
function getCSS(nightMode: boolean, fontSize: number): string {
const color = nightMode ? "white" : "black";
/* color is set for Maths, fill for the empty icon */
return `svg { color: ${color}; fill: ${color}; font-size: ${fontSize}px; };`;
}
function getStyle(css: string): HTMLStyleElement {
const style = document.createElement("style") as HTMLStyleElement;
style.appendChild(document.createTextNode(css));
return style;
}
function getEmptyIcon(style: HTMLStyleElement): [string, string] {
const icon = parser.parseFromString(mathIcon, "image/svg+xml");
const svg = icon.children[0];
svg.insertBefore(style, svg.children[0]);
return [svg.outerHTML, "MathJax"];
}
export function convertMathjax(
input: string,
nightMode: boolean,
fontSize: number
): [string, string] {
const style = getStyle(getCSS(nightMode, fontSize));
if (input.trim().length === 0) {
return getEmptyIcon(style);
}
const output = globalThis.MathJax.tex2svg(input);
const svg = output.children[0];
if (svg.viewBox.baseVal.height === 16) {
return getEmptyIcon(style);
}
let title = "";
if (svg.innerHTML.includes("data-mjx-error")) {
svg.querySelector("rect").setAttribute("fill", "yellow");
svg.querySelector("text").setAttribute("color", "red");
title = svg.querySelector("title").innerHTML;
} else {
svg.insertBefore(style, svg.children[0]);
}
return [svg.outerHTML, title];
}

View File

@ -22,18 +22,8 @@ compile_svelte(
visibility = ["//visibility:public"], visibility = ["//visibility:public"],
deps = [ deps = [
"//ts/components", "//ts/components",
], "@npm//@types/codemirror",
) "@npm//codemirror",
compile_sass(
srcs = [
"editable.scss",
],
group = "editable_scss",
visibility = ["//visibility:public"],
deps = [
"//ts/sass:scrollbar_lib",
"//ts/sass/codemirror",
], ],
) )
@ -45,8 +35,9 @@ compile_sass(
visibility = ["//visibility:public"], visibility = ["//visibility:public"],
deps = [ deps = [
"//ts/sass:base_lib", "//ts/sass:base_lib",
"//ts/sass:buttons_lib",
"//ts/sass:scrollbar_lib", "//ts/sass:scrollbar_lib",
"//ts/sass:buttons_lib",
"//ts/sass:button_mixins_lib",
], ],
) )
@ -71,6 +62,7 @@ ts_library(
"//ts/lib", "//ts/lib",
"//ts/sveltelib", "//ts/sveltelib",
"//ts/components", "//ts/components",
"//ts/editable",
"//ts/html-filter", "//ts/html-filter",
"//ts:image_module_support", "//ts:image_module_support",
"@npm//svelte", "@npm//svelte",
@ -130,6 +122,10 @@ copy_mdi_icons(
"image-size-select-large.svg", "image-size-select-large.svg",
"image-size-select-actual.svg", "image-size-select-actual.svg",
# mathjax handle
"format-wrap-square.svg",
"format-wrap-top-bottom.svg",
# tag editor # tag editor
"tag-outline.svg", "tag-outline.svg",
"tag.svg", "tag.svg",
@ -156,8 +152,11 @@ esbuild(
"bootstrap-icons", "bootstrap-icons",
"mdi-icons", "mdi-icons",
"svelte_components", "svelte_components",
"//ts/editable",
"//ts/editable:mdi-icons",
"//ts/components", "//ts/components",
"//ts/components:svelte_components", "//ts/components:svelte_components",
"//ts/editable:svelte_components",
"@npm//protobufjs", "@npm//protobufjs",
], ],
) )
@ -192,5 +191,7 @@ svelte_check(
"//ts/sass/bootstrap", "//ts/sass/bootstrap",
"@npm//@types/bootstrap", "@npm//@types/bootstrap",
"//ts/components", "//ts/components",
"@npm//@types/codemirror",
"@npm//codemirror",
], ],
) )

View File

@ -27,7 +27,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="typescript"> <script lang="typescript">
import { isApplePlatform } from "lib/platform"; import { isApplePlatform } from "lib/platform";
import StickyBar from "components/StickyBar.svelte"; import StickyHeader from "components/StickyHeader.svelte";
import ButtonToolbar from "components/ButtonToolbar.svelte"; import ButtonToolbar from "components/ButtonToolbar.svelte";
import Item from "components/Item.svelte"; import Item from "components/Item.svelte";
@ -51,7 +51,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const templateButtons = {}; export const templateButtons = {};
</script> </script>
<StickyBar> <StickyHeader>
<ButtonToolbar {size} {wrap} api={toolbar}> <ButtonToolbar {size} {wrap} api={toolbar}>
<Item id="notetype"> <Item id="notetype">
<NoteTypeButtons api={notetypeButtons} /> <NoteTypeButtons api={notetypeButtons} />
@ -73,4 +73,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<TemplateButtons api={templateButtons} /> <TemplateButtons api={templateButtons} />
</Item> </Item>
</ButtonToolbar> </ButtonToolbar>
</StickyBar> </StickyHeader>

View File

@ -14,7 +14,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import OnlyEditable from "./OnlyEditable.svelte"; import OnlyEditable from "./OnlyEditable.svelte";
import CommandIconButton from "./CommandIconButton.svelte"; import CommandIconButton from "./CommandIconButton.svelte";
import { getCurrentField, getListItem } from "./helpers"; import { getListItem } from "lib/dom";
import { getCurrentField } from "./helpers";
import { import {
ulIcon, ulIcon,
olIcon, olIcon,
@ -31,7 +32,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function outdentListItem() { function outdentListItem() {
const currentField = getCurrentField(); const currentField = getCurrentField();
if (getListItem(currentField.editableContainer.shadowRoot!)) { if (getListItem(currentField!.editableContainer.shadowRoot!)) {
document.execCommand("outdent"); document.execCommand("outdent");
} else { } else {
alert("Indent/unindent currently only works with lists."); alert("Indent/unindent currently only works with lists.");
@ -40,7 +41,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function indentListItem() { function indentListItem() {
const currentField = getCurrentField(); const currentField = getCurrentField();
if (getListItem(currentField.editableContainer.shadowRoot!)) { if (getListItem(currentField!.editableContainer.shadowRoot!)) {
document.execCommand("indent"); document.execCommand("indent");
} else { } else {
alert("Indent/unindent currently only works with lists."); alert("Indent/unindent currently only works with lists.");

View File

@ -5,13 +5,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import { onMount, createEventDispatcher } from "svelte"; import { onMount, createEventDispatcher } from "svelte";
export let tooltip: string | undefined = undefined;
let background: HTMLDivElement; let background: HTMLDivElement;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
onMount(() => dispatch("mount", { background })); onMount(() => dispatch("mount", { background }));
</script> </script>
<div bind:this={background} on:mousedown|preventDefault on:dblclick /> <div
bind:this={background}
title={tooltip}
on:mousedown|preventDefault
on:click|stopPropagation
on:dblclick
/>
<style lang="scss"> <style lang="scss">
div { div {

View File

@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let dimensions: HTMLDivElement; let dimensions: HTMLDivElement;
let overflowFix = 0; let overflowFix = 0;
function updateOverflow(dimensions: HTMLDivElement) { function updateOverflow(dimensions: HTMLDivElement): void {
const boundingClientRect = dimensions.getBoundingClientRect(); const boundingClientRect = dimensions.getBoundingClientRect();
const overflow = isRtl const overflow = isRtl
? window.innerWidth - boundingClientRect.x - boundingClientRect.width ? window.innerWidth - boundingClientRect.x - boundingClientRect.width
@ -19,12 +19,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
overflowFix = Math.min(0, overflowFix + overflow, overflow); overflowFix = Math.min(0, overflowFix + overflow, overflow);
} }
function updateOverflowAsync(dimensions: HTMLDivElement) { afterUpdate(() => updateOverflow(dimensions));
function updateOverflowAsync(dimensions: HTMLDivElement): void {
setTimeout(() => updateOverflow(dimensions)); setTimeout(() => updateOverflow(dimensions));
} }
afterUpdate(() => updateOverflow(dimensions));
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
onMount(() => dispatch("mount")); onMount(() => dispatch("mount"));

View File

@ -15,7 +15,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let width: number; let width: number;
let height: number; let height: number;
export function updateSelection(_div: HTMLDivElement): void { function setSelection(_selection?: HTMLDivElement): void {
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const imageRect = image!.getBoundingClientRect(); const imageRect = image!.getBoundingClientRect();
@ -28,6 +28,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
height = image!.clientHeight; height = image!.clientHeight;
} }
export function updateSelection(): Promise<void> {
let updateResolve: () => void;
const afterUpdate: Promise<void> = new Promise((resolve) => {
updateResolve = resolve;
});
setSelection();
setTimeout(() => updateResolve());
return afterUpdate;
}
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let selection: HTMLDivElement; let selection: HTMLDivElement;
@ -36,7 +48,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div <div
bind:this={selection} bind:this={selection}
use:updateSelection use:setSelection
on:click={(event) => on:click={(event) =>
/* prevent triggering Bootstrap dropdown */ event.stopImmediatePropagation()} /* prevent triggering Bootstrap dropdown */ event.stopImmediatePropagation()}
style="--left: {left}px; --top: {top}px; --width: {width}px; --height: {height}px; --offsetX: {offsetX}px; --offsetY: {offsetY}px;" style="--left: {left}px; --top: {top}px; --width: {width}px; --height: {height}px; --offsetX: {offsetX}px; --offsetY: {offsetY}px;"

View File

@ -17,9 +17,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
export let activeImage: HTMLImageElement | null = null;
export let container: HTMLElement; export let container: HTMLElement;
export let sheet: CSSStyleSheet; export let sheet: CSSStyleSheet;
export let activeImage: HTMLImageElement | null = null;
export let isRtl: boolean = false; export let isRtl: boolean = false;
$: naturalWidth = activeImage?.naturalWidth; $: naturalWidth = activeImage?.naturalWidth;
@ -53,10 +53,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
let updateSelection: () => void; let updateSelection: () => Promise<void>;
async function updateSizesWithDimensions() { async function updateSizesWithDimensions() {
updateSelection(); await updateSelection();
updateDimensions(); updateDimensions();
} }
@ -135,9 +135,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{#if sheet} {#if sheet}
<WithDropdown <WithDropdown
placement="bottom" drop="down"
autoOpen={true} autoOpen={true}
autoClose={false} autoClose={false}
distance={3}
let:createDropdown let:createDropdown
let:dropdownObject let:dropdownObject
> >

View File

@ -41,7 +41,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
active={image.style.float === "" || image.style.float === "none"} active={image.style.float === "" || image.style.float === "none"}
flipX={isRtl} flipX={isRtl}
on:click={() => { on:click={() => {
image.style.float = ""; image.style.removeProperty("float");
if (image.getAttribute("style")?.length === 0) {
image.removeAttribute("style");
}
setTimeout(() => dispatch("update")); setTimeout(() => dispatch("update"));
}}>{@html floatNoneIcon}</IconButton }}>{@html floatNoneIcon}</IconButton
> >

View File

@ -0,0 +1,104 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import WithDropdown from "components/WithDropdown.svelte";
import ButtonToolbar from "components/ButtonToolbar.svelte";
import DropdownMenu from "components/DropdownMenu.svelte";
import Item from "components/Item.svelte";
import HandleSelection from "./HandleSelection.svelte";
import HandleBackground from "./HandleBackground.svelte";
import HandleControl from "./HandleControl.svelte";
import MathjaxHandleInlineBlock from "./MathjaxHandleInlineBlock.svelte";
import MathjaxHandleEditor from "./MathjaxHandleEditor.svelte";
export let activeImage: HTMLImageElement | null = null;
export let container: HTMLElement;
export let isRtl: boolean;
let dropdownApi: any;
const resizeObserver = new ResizeObserver(async () => {
if (activeImage) {
await updateSelection();
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) {
getComponent(activeImage!).dataset.mathjax = event.detail.mathjax;
let selectionResolve: (value: void) => void;
const afterSelectionUpdate = new Promise((resolve) => {
selectionResolve = resolve;
});
setTimeout(async () => {
errorMessage = activeImage!.title;
await updateSelection();
selectionResolve();
});
return afterSelectionUpdate;
}
</script>
<WithDropdown
drop="down"
autoOpen={true}
autoClose={false}
distance={4}
let:createDropdown
let:dropdownObject
>
{#if activeImage}
<HandleSelection
image={activeImage}
{container}
bind:updateSelection
on:mount={(event) => (dropdownApi = createDropdown(event.detail.selection))}
>
<HandleBackground tooltip={errorMessage} />
<HandleControl offsetX={1} offsetY={1} />
</HandleSelection>
<DropdownMenu>
<MathjaxHandleEditor
initialValue={getComponent(activeImage).dataset.mathjax ?? ""}
on:update={async (event) => {
await onEditorUpdate(event);
dropdownObject.update();
}}
/>
<div class="margin-x">
<ButtonToolbar>
<Item>
<MathjaxHandleInlineBlock
{activeImage}
{isRtl}
on:click={updateSelection}
/>
</Item>
</ButtonToolbar>
</div>
</DropdownMenu>
{/if}
</WithDropdown>
<style lang="scss">
.margin-x {
margin: 0 0.125rem;
}
</style>

View File

@ -0,0 +1,70 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import { onMount, createEventDispatcher } from "svelte";
import { ChangeTimer } from "./change-timer";
import { CodeMirror, latex, baseOptions } from "./codeMirror";
export let initialValue: string;
const codeMirrorOptions = {
mode: latex,
...baseOptions,
};
let codeMirror: CodeMirror.EditorFromTextArea;
const changeTimer = new ChangeTimer();
function onInput() {
changeTimer.schedule(
() => dispatch("update", { mathjax: codeMirror.getValue() }),
400
);
}
function openCodemirror(textarea: HTMLTextAreaElement): void {
codeMirror = CodeMirror.fromTextArea(textarea, codeMirrorOptions);
codeMirror.on("change", onInput);
}
const dispatch = createEventDispatcher();
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">
/* TODO there is global CSS in fields.scss */
div :global(.mathjax-editor) {
border-radius: 0;
border-width: 0 1px;
border-color: var(--medium-border);
}
</style>

View File

@ -0,0 +1,38 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import * as tr from "lib/i18n";
import ButtonGroup from "components/ButtonGroup.svelte";
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
import IconButton from "components/IconButton.svelte";
import { inlineIcon, blockIcon } from "./icons";
export let activeImage: HTMLImageElement;
export let isRtl: boolean;
$: mathjaxElement = activeImage.parentElement!;
</script>
<ButtonGroup size={1.6} wrap={false} reverse={isRtl}>
<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

@ -6,8 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { tick } from "svelte"; import { tick } from "svelte";
import { isApplePlatform } from "lib/platform"; import { isApplePlatform } from "lib/platform";
import { bridgeCommand } from "lib/bridgecommand"; import { bridgeCommand } from "lib/bridgecommand";
import Spacer from "components/Spacer.svelte"; import StickyFooter from "components/StickyFooter.svelte";
import StickyBottom from "components/StickyBottom.svelte";
import TagOptionsBadge from "./TagOptionsBadge.svelte"; import TagOptionsBadge from "./TagOptionsBadge.svelte";
import TagEditMode from "./TagEditMode.svelte"; import TagEditMode from "./TagEditMode.svelte";
import TagInput from "./TagInput.svelte"; import TagInput from "./TagInput.svelte";
@ -65,7 +64,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return response.tags; return response.tags;
} }
const colonAtStartOrEnd = /^:?|:?$/g; const withoutSingleColonAtStartOrEnd = /^:?([^:].*?[^:]):?$/;
function updateSuggestions(): void { function updateSuggestions(): void {
const activeTag = tags[active!]; const activeTag = tags[active!];
@ -76,11 +75,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
if (autocompleteDisabled) { if (autocompleteDisabled) {
suggestionsPromise = noSuggestions; suggestionsPromise = noSuggestions;
} else { } else {
const cleanedName = replaceWithColons(activeName).replace( const withColons = replaceWithColons(activeName);
colonAtStartOrEnd, const withoutSingleColons = withoutSingleColonAtStartOrEnd.test(withColons)
"" ? withColons.replace(withoutSingleColonAtStartOrEnd, "$1")
); : withColons;
suggestionsPromise = fetchSuggestions(cleanedName).then(
suggestionsPromise = fetchSuggestions(withoutSingleColons).then(
(names: string[]): string[] => { (names: string[]): string[] => {
autocompleteDisabled = names.length === 0; autocompleteDisabled = names.length === 0;
return names.map(replaceWithUnicodeSeparator); return names.map(replaceWithUnicodeSeparator);
@ -390,9 +390,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: shortenTags = shortenTags || assumedRows > 2; $: shortenTags = shortenTags || assumedRows > 2;
</script> </script>
<Spacer --height="{height}px" /> <StickyFooter bind:height class="d-flex">
<StickyBottom class="d-flex" bind:height>
{#if !wrap} {#if !wrap}
<TagOptionsBadge <TagOptionsBadge
--buttons-size="{size}rem" --buttons-size="{size}rem"
@ -505,7 +503,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<Tag>SPACER</Tag> <Tag>SPACER</Tag>
</div> </div>
</ButtonToolbar> </ButtonToolbar>
</StickyBottom> </StickyFooter>
<style lang="scss"> <style lang="scss">
.tag-spacer { .tag-spacer {

View File

@ -132,6 +132,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
await tick(); await tick();
setPosition(positionStart); setPosition(positionStart);
dispatch("taginput");
return; return;
} else if (after.startsWith(":")) { } else if (after.startsWith(":")) {
event.preventDefault(); event.preventDefault();

View File

@ -101,7 +101,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:shortcutLabel let:shortcutLabel
> >
<DropdownItem <DropdownItem
on:click={() => wrapCurrent("\\(", "\\)")} on:click={() =>
wrapCurrent(
"<anki-mathjax focusonmount>",
"</anki-mathjax>"
)}
on:mount={withButton(createShortcut)} on:mount={withButton(createShortcut)}
> >
{tr.editingMathjaxInline()} {tr.editingMathjaxInline()}
@ -115,7 +119,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:shortcutLabel let:shortcutLabel
> >
<DropdownItem <DropdownItem
on:click={() => wrapCurrent("\\[", "\\]")} on:click={() =>
wrapCurrent(
'<anki-mathjax block="true" focusonmount>',
"</anki-matjax>"
)}
on:mount={withButton(createShortcut)} on:mount={withButton(createShortcut)}
> >
{tr.editingMathjaxBlock()} {tr.editingMathjaxBlock()}
@ -129,7 +137,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:shortcutLabel let:shortcutLabel
> >
<DropdownItem <DropdownItem
on:click={() => wrapCurrent("\\(\\ce{", "}\\)")} on:click={() =>
wrapCurrent(
"<anki-mathjax focusonmount>\\ce{",
"}</anki-mathjax>"
)}
on:mount={withButton(createShortcut)} on:mount={withButton(createShortcut)}
> >
{tr.editingMathjaxChemistry()} {tr.editingMathjaxChemistry()}

View File

@ -4,12 +4,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="typescript"> <script lang="typescript">
import { createEventDispatcher, onDestroy } from "svelte"; import { createEventDispatcher, onDestroy } from "svelte";
import { nodeIsElement } from "./helpers"; import { nodeIsElement } from "lib/dom";
export let activeImage: HTMLImageElement | null;
export let container: HTMLElement; export let container: HTMLElement;
export let sheet: CSSStyleSheet; export let sheet: CSSStyleSheet;
export let activeImage: HTMLImageElement | null;
let active: boolean = false; let active: boolean = false;
$: { $: {
@ -70,7 +70,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
continue; continue;
} }
if (node.tagName === "IMG") { if (node.tagName === "IMG" && !(node as HTMLElement).dataset.anki) {
result.push(node as HTMLImageElement); result.push(node as HTMLImageElement);
} else { } else {
result.push(...filterImages(node.children)); result.push(...filterImages(node.children));

View File

@ -1,47 +1,18 @@
// 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
import type { EditingArea } from "./editing-area"; export class ChangeTimer {
private value: number | null = null;
import { getCurrentField } from "./helpers"; schedule(action: () => void, delay: number): void {
import { bridgeCommand } from "./lib"; this.clear();
import { getNoteId } from "./note-id"; this.value = setTimeout(action, delay);
let changeTimer: number | null = null;
export function triggerChangeTimer(currentField: EditingArea): void {
clearChangeTimer();
changeTimer = setTimeout(() => saveField(currentField, "key"), 600);
}
function clearChangeTimer(): void {
if (changeTimer) {
clearTimeout(changeTimer);
changeTimer = null;
}
}
export function saveField(currentField: EditingArea, type: "blur" | "key"): void {
clearChangeTimer();
const command = `${type}:${currentField.ord}:${getNoteId()}:${
currentField.fieldHTML
}`;
bridgeCommand(command);
}
export function saveNow(keepFocus: boolean): void {
const currentField = getCurrentField();
if (!currentField) {
return;
} }
clearChangeTimer(); clear(): void {
if (this.value) {
if (keepFocus) { clearTimeout(this.value);
saveField(currentField, "key"); this.value = null;
} else { }
// triggers onBlur, which saves
currentField.blur();
} }
} }

View File

@ -1,39 +1,29 @@
// 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
import * as CodeMirror from "codemirror/lib/codemirror"; /* eslint
import "codemirror/mode/htmlmixed/htmlmixed"; @typescript-eslint/no-non-null-assertion: "off",
import "codemirror/addon/fold/foldcode"; */
import "codemirror/addon/fold/foldgutter";
import "codemirror/addon/fold/xml-fold";
import "codemirror/addon/edit/matchtags.js";
import "codemirror/addon/edit/closetag.js";
import { CodeMirror, htmlanki, baseOptions, gutterOptions } from "./codeMirror";
import { inCodable } from "./toolbar"; import { inCodable } from "./toolbar";
const codeMirrorOptions = { const codeMirrorOptions = {
mode: "htmlmixed", mode: htmlanki,
theme: "monokai", ...baseOptions,
lineNumbers: true, ...gutterOptions,
lineWrapping: true,
foldGutter: true,
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
matchTags: { bothTags: true },
autoCloseTags: true,
extraKeys: { Tab: false, "Shift-Tab": false },
viewportMargin: Infinity,
lineWiseCopyCut: false,
}; };
const parser = new DOMParser(); const parser = new DOMParser();
const parseStyle = "<style>anki-mathjax { white-space: pre; }</style>";
function parseHTML(html: string): string { function parseHTML(html: string): string {
const doc = parser.parseFromString(html, "text/html"); const doc = parser.parseFromString(`${parseStyle}${html}`, "text/html");
return doc.body.innerHTML; return doc.body.innerHTML;
} }
export class Codable extends HTMLTextAreaElement { export class Codable extends HTMLTextAreaElement {
codeMirror: CodeMirror | undefined; codeMirror: CodeMirror.EditorFromTextArea | undefined;
get active(): boolean { get active(): boolean {
return Boolean(this.codeMirror); return Boolean(this.codeMirror);
@ -41,14 +31,14 @@ export class Codable extends HTMLTextAreaElement {
set fieldHTML(content: string) { set fieldHTML(content: string) {
if (this.active) { if (this.active) {
this.codeMirror.setValue(content); this.codeMirror?.setValue(content);
} else { } else {
this.value = content; this.value = content;
} }
} }
get fieldHTML(): string { get fieldHTML(): string {
return parseHTML(this.active ? this.codeMirror.getValue() : this.value); return parseHTML(this.active ? this.codeMirror!.getValue() : this.value);
} }
connectedCallback(): void { connectedCallback(): void {
@ -58,26 +48,27 @@ export class Codable extends HTMLTextAreaElement {
setup(html: string): void { setup(html: string): void {
this.fieldHTML = html; this.fieldHTML = html;
this.codeMirror = CodeMirror.fromTextArea(this, codeMirrorOptions); this.codeMirror = CodeMirror.fromTextArea(this, codeMirrorOptions);
this.codeMirror.on("blur", () => inCodable.set(false));
} }
teardown(): string { teardown(): string {
this.codeMirror.toTextArea(); this.codeMirror!.toTextArea();
this.codeMirror = undefined; this.codeMirror = undefined;
return this.fieldHTML; return this.fieldHTML;
} }
focus(): void { focus(): void {
this.codeMirror.focus(); this.codeMirror!.focus();
inCodable.set(true); inCodable.set(true);
} }
caretToEnd(): void { caretToEnd(): void {
this.codeMirror.setCursor(this.codeMirror.lineCount(), 0); this.codeMirror!.setCursor(this.codeMirror!.lineCount(), 0);
} }
surroundSelection(before: string, after: string): void { surroundSelection(before: string, after: string): void {
const selection = this.codeMirror.getSelection(); const selection = this.codeMirror!.getSelection();
this.codeMirror.replaceSelection(before + selection + after); this.codeMirror!.replaceSelection(before + selection + after);
} }
onEnter(): void { onEnter(): void {
@ -88,3 +79,5 @@ export class Codable extends HTMLTextAreaElement {
/* default */ /* default */
} }
} }
customElements.define("anki-codable", Codable, { extends: "textarea" });

45
ts/editor/codeMirror.ts Normal file
View File

@ -0,0 +1,45 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import CodeMirror from "codemirror";
import "codemirror/mode/htmlmixed/htmlmixed";
import "codemirror/mode/stex/stex";
import "codemirror/addon/fold/foldcode";
import "codemirror/addon/fold/foldgutter";
import "codemirror/addon/fold/xml-fold";
import "codemirror/addon/edit/matchtags.js";
import "codemirror/addon/edit/closetag.js";
export { CodeMirror };
export const latex = {
name: "stex",
inMathMode: true,
};
export const htmlanki = {
name: "htmlmixed",
tags: {
"anki-mathjax": [[null, null, latex]],
},
};
const noop = (): void => {
/* noop */
};
export const baseOptions = {
theme: "monokai",
lineWrapping: true,
matchTags: { bothTags: true },
autoCloseTags: true,
extraKeys: { Tab: noop, "Shift-Tab": noop },
viewportMargin: Infinity,
lineWiseCopyCut: false,
};
export const gutterOptions = {
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
lineNumbers: true,
foldGutter: true,
};

View File

@ -1,59 +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",
*/
export class EditableContainer extends HTMLDivElement {
baseStyle: HTMLStyleElement;
baseRule?: CSSStyleRule;
imageStyle?: HTMLStyleElement;
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
if (document.documentElement.classList.contains("night-mode")) {
this.classList.add("night-mode");
}
const rootStyle = document.createElement("link");
rootStyle.setAttribute("rel", "stylesheet");
rootStyle.setAttribute("href", "./_anki/css/editable.css");
shadow.appendChild(rootStyle);
this.baseStyle = document.createElement("style");
this.baseStyle.setAttribute("rel", "stylesheet");
this.baseStyle.id = "baseStyle";
shadow.appendChild(this.baseStyle);
}
connectedCallback(): void {
const sheet = this.baseStyle.sheet as CSSStyleSheet;
const baseIndex = sheet.insertRule("anki-editable {}");
this.baseRule = sheet.cssRules[baseIndex] as CSSStyleRule;
}
initialize(color: string): void {
this.setBaseColor(color);
}
setBaseColor(color: string): void {
if (this.baseRule) {
this.baseRule.style.color = color;
}
}
setBaseStyling(fontFamily: string, fontSize: string, direction: string): void {
if (this.baseRule) {
this.baseRule.style.fontFamily = fontFamily;
this.baseRule.style.fontSize = fontSize;
this.baseRule.style.direction = direction;
}
}
isRightToLeft(): boolean {
return this.baseRule!.style.direction === "rtl";
}
}

View File

@ -7,16 +7,18 @@
*/ */
import ImageHandle from "./ImageHandle.svelte"; import ImageHandle from "./ImageHandle.svelte";
import MathjaxHandle from "./MathjaxHandle.svelte";
import type { EditableContainer } from "./editable-container"; import type { EditableContainer } from "editable/editable-container";
import type { Editable } from "./editable"; import type { Editable } from "editable/editable";
import type { Codable } from "./codable"; import type { Codable } from "./codable";
import { updateActiveButtons } from "./toolbar"; import { updateActiveButtons } from "./toolbar";
import { bridgeCommand } from "./lib"; import { bridgeCommand } from "./lib";
import { onInput, onKey, onKeyUp } from "./input-handlers"; import { onInput, onKey, onKeyUp } from "./input-handlers";
import { onFocus, onBlur } from "./focus-handlers"; import { deferFocusDown, saveFieldIfFieldChanged } from "./focus-handlers";
import { nightModeKey } from "components/context-keys"; import { nightModeKey } from "components/context-keys";
import { decoratedComponents } from "editable/decorated";
function onCutOrCopy(): void { function onCutOrCopy(): void {
bridgeCommand("cutOrCopy"); bridgeCommand("cutOrCopy");
@ -24,6 +26,7 @@ function onCutOrCopy(): void {
export class EditingArea extends HTMLDivElement { export class EditingArea extends HTMLDivElement {
imageHandle: Promise<ImageHandle>; imageHandle: Promise<ImageHandle>;
mathjaxHandle: MathjaxHandle;
editableContainer: EditableContainer; editableContainer: EditableContainer;
editable: Editable; editable: Editable;
codable: Codable; codable: Codable;
@ -36,11 +39,9 @@ export class EditingArea extends HTMLDivElement {
is: "anki-editable-container", is: "anki-editable-container",
}) as EditableContainer; }) as EditableContainer;
const imageStyle = document.createElement("style");
imageStyle.setAttribute("rel", "stylesheet");
imageStyle.id = "imageHandleStyle";
this.editable = document.createElement("anki-editable") as Editable; this.editable = document.createElement("anki-editable") as Editable;
this.editableContainer.shadowRoot!.appendChild(this.editable);
this.appendChild(this.editableContainer);
const context = new Map(); const context = new Map();
context.set( context.set(
@ -49,27 +50,32 @@ export class EditingArea extends HTMLDivElement {
); );
let imageHandleResolve: (value: ImageHandle) => void; let imageHandleResolve: (value: ImageHandle) => void;
this.imageHandle = new Promise<ImageHandle>((resolve) => { this.imageHandle = new Promise<ImageHandle>(
imageHandleResolve = resolve; (resolve) => (imageHandleResolve = resolve)
}); );
imageStyle.addEventListener("load", () => this.editableContainer.imagePromise.then(() =>
imageHandleResolve( imageHandleResolve(
new ImageHandle({ new ImageHandle({
target: this, target: this,
anchor: this.editableContainer, anchor: this.editableContainer,
props: { props: {
container: this.editable, container: this.editable,
sheet: imageStyle.sheet, sheet: this.editableContainer.imageStyle.sheet,
}, },
context, context,
} as any) } as any)
) )
); );
this.editableContainer.shadowRoot!.appendChild(imageStyle); this.mathjaxHandle = new MathjaxHandle({
this.editableContainer.shadowRoot!.appendChild(this.editable); target: this,
this.appendChild(this.editableContainer); anchor: this.editableContainer,
props: {
container: this.editable,
},
context,
} as any);
this.codable = document.createElement("textarea", { this.codable = document.createElement("textarea", {
is: "anki-codable", is: "anki-codable",
@ -80,7 +86,7 @@ export class EditingArea extends HTMLDivElement {
this.onBlur = this.onBlur.bind(this); this.onBlur = this.onBlur.bind(this);
this.onKey = this.onKey.bind(this); this.onKey = this.onKey.bind(this);
this.onPaste = this.onPaste.bind(this); this.onPaste = this.onPaste.bind(this);
this.showImageHandle = this.showImageHandle.bind(this); this.showHandles = this.showHandles.bind(this);
} }
get activeInput(): Editable | Codable { get activeInput(): Editable | Codable {
@ -92,11 +98,24 @@ export class EditingArea extends HTMLDivElement {
} }
set fieldHTML(content: string) { set fieldHTML(content: string) {
this.imageHandle.then(() => (this.activeInput.fieldHTML = content)); this.imageHandle.then(() => {
let result = content;
for (const component of decoratedComponents) {
result = component.toUndecorated(result);
}
this.activeInput.fieldHTML = result;
});
} }
get fieldHTML(): string { get fieldHTML(): string {
return this.activeInput.fieldHTML; let result = this.activeInput.fieldHTML;
for (const component of decoratedComponents) {
result = component.toStored(result);
}
return result;
} }
connectedCallback(): void { connectedCallback(): void {
@ -109,7 +128,7 @@ export class EditingArea extends HTMLDivElement {
this.addEventListener("copy", onCutOrCopy); this.addEventListener("copy", onCutOrCopy);
this.addEventListener("oncut", onCutOrCopy); this.addEventListener("oncut", onCutOrCopy);
this.addEventListener("mouseup", updateActiveButtons); this.addEventListener("mouseup", updateActiveButtons);
this.editable.addEventListener("click", this.showImageHandle); this.editable.addEventListener("click", this.showHandles);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
@ -122,12 +141,14 @@ export class EditingArea extends HTMLDivElement {
this.removeEventListener("copy", onCutOrCopy); this.removeEventListener("copy", onCutOrCopy);
this.removeEventListener("oncut", onCutOrCopy); this.removeEventListener("oncut", onCutOrCopy);
this.removeEventListener("mouseup", updateActiveButtons); this.removeEventListener("mouseup", updateActiveButtons);
this.editable.removeEventListener("click", this.showImageHandle); this.editable.removeEventListener("click", this.showHandles);
} }
initialize(color: string, content: string): void { initialize(color: string, content: string): void {
this.setBaseColor(color); this.editableContainer.stylePromise.then(() => {
this.fieldHTML = content; this.setBaseColor(color);
this.fieldHTML = content;
});
} }
setBaseColor(color: string): void { setBaseColor(color: string): void {
@ -179,13 +200,12 @@ export class EditingArea extends HTMLDivElement {
this.activeInput.surroundSelection(before, after); this.activeInput.surroundSelection(before, after);
} }
onFocus(event: FocusEvent): void { onFocus(): void {
onFocus(event); deferFocusDown(this);
} }
onBlur(event: FocusEvent): void { onBlur(event: FocusEvent): void {
this.resetImageHandle(); saveFieldIfFieldChanged(this, event.relatedTarget as Element);
onBlur(event);
} }
onEnter(event: KeyboardEvent): void { onEnter(event: KeyboardEvent): void {
@ -193,33 +213,49 @@ export class EditingArea extends HTMLDivElement {
} }
onKey(event: KeyboardEvent): void { onKey(event: KeyboardEvent): void {
this.resetImageHandle(); this.resetHandles();
onKey(event); onKey(event);
} }
onPaste(event: ClipboardEvent): void { onPaste(event: ClipboardEvent): void {
this.resetImageHandle(); this.resetHandles();
this.activeInput.onPaste(event); this.activeInput.onPaste(event);
} }
resetImageHandle(): void { resetHandles(): Promise<void> {
this.imageHandle.then((imageHandle) => const promise = this.imageHandle.then((imageHandle) =>
(imageHandle as any).$set({ (imageHandle as any).$set({
activeImage: null, activeImage: null,
}) })
); );
(this.mathjaxHandle as any).$set({
activeImage: null,
});
return promise;
} }
showImageHandle(event: MouseEvent): void { async showHandles(event: MouseEvent): Promise<void> {
if (event.target instanceof HTMLImageElement) { if (event.target instanceof HTMLImageElement) {
this.imageHandle.then((imageHandle) => const image = event.target as HTMLImageElement;
(imageHandle as any).$set({ await this.resetHandles();
activeImage: event.target,
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(), isRtl: this.isRightToLeft(),
}) });
); }
} else { } else {
this.resetImageHandle(); await this.resetHandles();
} }
} }
@ -230,7 +266,7 @@ export class EditingArea extends HTMLDivElement {
this.fieldHTML = this.codable.teardown(); this.fieldHTML = this.codable.teardown();
this.editable.hidden = false; this.editable.hidden = false;
} else { } else {
this.resetImageHandle(); this.resetHandles();
this.editable.hidden = true; this.editable.hidden = true;
this.codable.setup(this.editable.fieldHTML); this.codable.setup(this.editable.fieldHTML);
} }
@ -254,3 +290,5 @@ export class EditingArea extends HTMLDivElement {
blur(); blur();
} }
} }
customElements.define("anki-editing-area", EditingArea, { extends: "div" });

View File

@ -10,7 +10,7 @@ export class EditorField extends HTMLDivElement {
constructor() { constructor() {
super(); super();
this.classList.add("editor-field"); this.className = "editorfield";
this.labelContainer = document.createElement("div", { this.labelContainer = document.createElement("div", {
is: "anki-label-container", is: "anki-label-container",
@ -65,3 +65,5 @@ export class EditorField extends HTMLDivElement {
this.editingArea.setBaseStyling(fontFamily, fontSize, direction); this.editingArea.setBaseStyling(fontFamily, fontSize, direction);
} }
} }
customElements.define("anki-editor-field", EditorField, { extends: "div" });

View File

@ -3,6 +3,17 @@
@use 'base'; @use 'base';
@use 'scrollbar'; @use 'scrollbar';
@use 'button-mixins';
html,
body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
}
.nightMode { .nightMode {
@include scrollbar.night-mode; @include scrollbar.night-mode;
@ -10,13 +21,15 @@
#fields { #fields {
display: flex; display: flex;
overflow-x: hidden;
flex-direction: column; flex-direction: column;
flex-grow: 1;
overflow-x: hidden;
margin: 3px 0; margin: 3px 0;
} }
.editor-field { .editorfield {
margin: 3px; margin: 3px;
border-radius: 5px; border-radius: 5px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -29,6 +42,7 @@
} }
} }
/* editing-area */
.field { .field {
position: relative; position: relative;
@ -63,10 +77,6 @@
text-align: center; text-align: center;
background-color: var(--window-bg); background-color: var(--window-bg);
&.is-inactive {
display: none;
}
a { a {
color: var(--link); color: var(--link);
} }

View File

@ -8,23 +8,34 @@
import { fieldFocused } from "./toolbar"; import { fieldFocused } from "./toolbar";
import type { EditingArea } from "./editing-area"; import type { EditingArea } from "./editing-area";
import { saveField } from "./change-timer"; import { saveField } from "./saving";
import { bridgeCommand } from "./lib"; import { bridgeCommand } from "./lib";
import { getCurrentField } from "./helpers"; import { getCurrentField } from "./helpers";
export function onFocus(evt: FocusEvent): void { export function deferFocusDown(editingArea: EditingArea): void {
const currentField = evt.currentTarget as EditingArea; editingArea.focus();
currentField.focus(); editingArea.caretToEnd();
currentField.caretToEnd();
bridgeCommand(`focus:${currentField.ord}`); if (editingArea.getSelection().anchorNode === null) {
// selection is not inside editable after focusing
editingArea.caretToEnd();
}
bridgeCommand(`focus:${editingArea.ord}`);
fieldFocused.set(true); fieldFocused.set(true);
} }
export function onBlur(evt: FocusEvent): void { export function saveFieldIfFieldChanged(
const previousFocus = evt.currentTarget as EditingArea; editingArea: EditingArea,
const currentFieldUnchanged = previousFocus === getCurrentField(); focusTo: Element | null
): void {
const fieldChanged =
editingArea !== getCurrentField() && !editingArea.contains(focusTo);
saveField(previousFocus, currentFieldUnchanged ? "key" : "blur"); saveField(editingArea, fieldChanged ? "blur" : "key");
fieldFocused.set(false); fieldFocused.set(false);
if (fieldChanged) {
editingArea.resetHandles();
}
} }

View File

@ -1,103 +1,12 @@
// 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
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import type { EditingArea } from "./editing-area"; import type { EditingArea } from "./editing-area";
export function getCurrentField(): EditingArea | null { export function getCurrentField(): EditingArea | null {
return document.activeElement?.closest(".field") ?? null; return document.activeElement?.closest(".field") ?? null;
} }
export function nodeIsElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
const BLOCK_TAGS = [
"ADDRESS",
"ARTICLE",
"ASIDE",
"BLOCKQUOTE",
"DETAILS",
"DIALOG",
"DD",
"DIV",
"DL",
"DT",
"FIELDSET",
"FIGCAPTION",
"FIGURE",
"FOOTER",
"FORM",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"HEADER",
"HGROUP",
"HR",
"LI",
"MAIN",
"NAV",
"OL",
"P",
"PRE",
"SECTION",
"TABLE",
"UL",
];
export function elementIsBlock(element: Element): boolean {
return BLOCK_TAGS.includes(element.tagName);
}
export function caretToEnd(node: Node): void {
const range = document.createRange();
range.selectNodeContents(node);
range.collapse(false);
const selection = (node.getRootNode() as Document | ShadowRoot).getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
const getAnchorParent =
<T extends Element>(predicate: (element: Element) => element is T) =>
(currentField: DocumentOrShadowRoot): T | null => {
const anchor = currentField.getSelection()?.anchorNode;
if (!anchor) {
return null;
}
let anchorParent: T | null = null;
let element = nodeIsElement(anchor) ? anchor : anchor.parentElement;
while (element) {
anchorParent = anchorParent || (predicate(element) ? element : null);
element = element.parentElement;
}
return anchorParent;
};
const isListItem = (element: Element): element is HTMLLIElement =>
window.getComputedStyle(element).display === "list-item";
const isParagraph = (element: Element): element is HTMLParamElement =>
element.tagName === "P";
const isBlockElement = (
element: Element
): element is HTMLLIElement & HTMLParamElement =>
isListItem(element) || isParagraph(element);
export const getListItem = getAnchorParent(isListItem);
export const getParagraph = getAnchorParent(isParagraph);
export const getBlockElement = getAnchorParent(isBlockElement);
export function appendInParentheses(text: string, appendix: string): string { export function appendInParentheses(text: string, appendix: string): string {
return `${text} (${appendix})`; return `${text} (${appendix})`;
} }

View File

@ -46,3 +46,6 @@ export { default as floatRightIcon } from "./format-float-right.svg";
export { default as sizeActual } from "./image-size-select-actual.svg"; export { default as sizeActual } from "./image-size-select-actual.svg";
export { default as sizeMinimized } from "./image-size-select-large.svg"; export { default as sizeMinimized } from "./image-size-select-large.svg";
export { default as inlineIcon } from "./format-wrap-square.svg";
export { default as blockIcon } from "./format-wrap-top-bottom.svg";

View File

@ -13,28 +13,30 @@ import type EditorToolbar from "./EditorToolbar.svelte";
import type TagEditor from "./TagEditor.svelte"; import type TagEditor from "./TagEditor.svelte";
import { filterHTML } from "html-filter"; import { filterHTML } from "html-filter";
import { updateActiveButtons } from "./toolbar";
import { setupI18n, ModuleName } from "lib/i18n"; import { setupI18n, ModuleName } from "lib/i18n";
import { isApplePlatform } from "lib/platform"; import { isApplePlatform } from "lib/platform";
import { registerShortcut } from "lib/shortcuts"; import { registerShortcut } from "lib/shortcuts";
import { bridgeCommand } from "lib/bridgecommand"; import { bridgeCommand } from "lib/bridgecommand";
import { updateActiveButtons } from "./toolbar";
import { saveField } from "./saving";
import "./fields.css"; import "./fields.css";
import { saveField } from "./change-timer"; import "./label-container";
import "./codable";
import { EditorField } from "./editor-field"; import "./editor-field";
import { LabelContainer } from "./label-container"; import type { EditorField } from "./editor-field";
import { EditingArea } from "./editing-area"; import { EditingArea } from "./editing-area";
import { EditableContainer } from "./editable-container"; import "editable/editable-container";
import { Editable } from "./editable"; import "editable/editable";
import { Codable } from "./codable"; import "editable/mathjax-component";
import { initToolbar, fieldFocused } from "./toolbar"; import { initToolbar, fieldFocused } from "./toolbar";
import { initTagEditor } from "./tag-editor"; import { initTagEditor } from "./tag-editor";
import { getCurrentField } from "./helpers"; import { getCurrentField } from "./helpers";
export { setNoteId, getNoteId } from "./note-id"; export { setNoteId, getNoteId } from "./note-id";
export { saveNow } from "./change-timer"; export { saveNow } from "./saving";
export { wrap, wrapIntoText } from "./wrap"; export { wrap, wrapIntoText } from "./wrap";
export { editorToolbar } from "./toolbar"; export { editorToolbar } from "./toolbar";
export { activateStickyShortcuts } from "./label-container"; export { activateStickyShortcuts } from "./label-container";
@ -50,13 +52,6 @@ declare global {
} }
} }
customElements.define("anki-editable", Editable);
customElements.define("anki-editable-container", EditableContainer, { extends: "div" });
customElements.define("anki-codable", Codable, { extends: "textarea" });
customElements.define("anki-editing-area", EditingArea, { extends: "div" });
customElements.define("anki-label-container", LabelContainer, { extends: "div" });
customElements.define("anki-editor-field", EditorField, { extends: "div" });
if (isApplePlatform()) { if (isApplePlatform()) {
registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V"); registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V");
} }
@ -157,11 +152,14 @@ export function setBackgrounds(cols: ("dupe" | "")[]): void {
); );
document document
.getElementById("dupes")! .getElementById("dupes")!
.classList.toggle("is-inactive", !cols.includes("dupe")); .classList.toggle("d-none", !cols.includes("dupe"));
} }
export function setClozeHint(cloze_hint: string): void { export function setClozeHint(hint: string): void {
document.getElementById("cloze-hint")!.innerHTML = cloze_hint; 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 { export function setFonts(fonts: [string, number, boolean][]): void {

View File

@ -5,10 +5,10 @@
@typescript-eslint/no-non-null-assertion: "off", @typescript-eslint/no-non-null-assertion: "off",
*/ */
import { nodeIsElement } from "lib/dom";
import { updateActiveButtons } from "./toolbar"; import { updateActiveButtons } from "./toolbar";
import { EditingArea } from "./editing-area"; import { EditingArea } from "./editing-area";
import { nodeIsElement } from "./helpers"; import { triggerChangeTimer } from "./saving";
import { triggerChangeTimer } from "./change-timer";
import { registerShortcut } from "lib/shortcuts"; import { registerShortcut } from "lib/shortcuts";
export function onInput(event: Event): void { export function onInput(event: Event): void {

View File

@ -7,7 +7,7 @@ import * as tr from "lib/i18n";
import { registerShortcut } from "lib/shortcuts"; import { registerShortcut } from "lib/shortcuts";
import { bridgeCommand } from "./lib"; import { bridgeCommand } from "./lib";
import { appendInParentheses } from "./helpers"; import { appendInParentheses } from "./helpers";
import { saveField } from "./change-timer"; import { saveField } from "./saving";
import { getCurrentField, forEditorField, i18n } from "."; import { getCurrentField, forEditorField, i18n } from ".";
import pinIcon from "./pin-angle.svg"; import pinIcon from "./pin-angle.svg";
@ -127,3 +127,5 @@ export class LabelContainer extends HTMLDivElement {
this.toggleSticky(); this.toggleSticky();
} }
} }
customElements.define("anki-label-container", LabelContainer, { extends: "div" });

40
ts/editor/saving.ts Normal file
View File

@ -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 { 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;
}
saveFieldTimer.clear();
if (keepFocus) {
saveField(currentField, "key");
} else {
// triggers onBlur, which saves
currentField.blur();
}
}

View File

@ -5,45 +5,15 @@
@typescript-eslint/no-non-null-assertion: "off", @typescript-eslint/no-non-null-assertion: "off",
*/ */
import { getCurrentField } from "./helpers"; import { wrapInternal } from "lib/wrap";
import { setFormat } from "."; import { getCurrentField } from ".";
function wrappedExceptForWhitespace(text: string, front: string, back: string): string {
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;
return match[1] + front + match[2] + back + match[3];
}
function moveCursorPastPostfix(selection: Selection, postfix: string): void {
const range = selection.getRangeAt(0);
range.setStart(range.startContainer, range.startOffset - postfix.length);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
function wrapInternal(front: string, back: string, plainText: boolean): void {
const currentField = getCurrentField()!;
const selection = currentField.getSelection();
const range = selection.getRangeAt(0);
const content = range.cloneContents();
const span = document.createElement("span");
span.appendChild(content);
if (plainText) {
const new_ = wrappedExceptForWhitespace(span.innerText, front, back);
setFormat("inserttext", new_);
} else {
const new_ = wrappedExceptForWhitespace(span.innerHTML, front, back);
setFormat("inserthtml", new_);
}
if (!span.innerHTML) {
moveCursorPastPostfix(selection, back);
}
}
export function wrap(front: string, back: string): void { export function wrap(front: string, back: string): void {
wrapInternal(front, back, false); const editingArea = getCurrentField();
if (editingArea) {
wrapInternal(editingArea.editableContainer.shadowRoot!, front, back, false);
}
} }
export function wrapCurrent(front: string, back: string): void { export function wrapCurrent(front: string, back: string): void {
@ -53,5 +23,9 @@ export function wrapCurrent(front: string, back: string): void {
/* currently unused */ /* currently unused */
export function wrapIntoText(front: string, back: string): void { export function wrapIntoText(front: string, back: string): void {
wrapInternal(front, back, true); const editingArea = getCurrentField();
if (editingArea) {
wrapInternal(editingArea.editableContainer.shadowRoot!, front, back, false);
}
} }

93
ts/lib/dom.ts Normal file
View File

@ -0,0 +1,93 @@
// 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 nodeIsElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
const BLOCK_TAGS = [
"ADDRESS",
"ARTICLE",
"ASIDE",
"BLOCKQUOTE",
"DETAILS",
"DIALOG",
"DD",
"DIV",
"DL",
"DT",
"FIELDSET",
"FIGCAPTION",
"FIGURE",
"FOOTER",
"FORM",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"HEADER",
"HGROUP",
"HR",
"LI",
"MAIN",
"NAV",
"OL",
"P",
"PRE",
"SECTION",
"TABLE",
"UL",
];
export function elementIsBlock(element: Element): boolean {
return BLOCK_TAGS.includes(element.tagName);
}
export function caretToEnd(node: Node): void {
const range = document.createRange();
range.selectNodeContents(node);
range.collapse(false);
const selection = (node.getRootNode() as Document | ShadowRoot).getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
const getAnchorParent =
<T extends Element>(predicate: (element: Element) => element is T) =>
(currentField: DocumentOrShadowRoot): T | null => {
const anchor = currentField.getSelection()?.anchorNode;
if (!anchor) {
return null;
}
let anchorParent: T | null = null;
let element = nodeIsElement(anchor) ? anchor : anchor.parentElement;
while (element) {
anchorParent = anchorParent || (predicate(element) ? element : null);
element = element.parentElement;
}
return anchorParent;
};
const isListItem = (element: Element): element is HTMLLIElement =>
window.getComputedStyle(element).display === "list-item";
const isParagraph = (element: Element): element is HTMLParamElement =>
element.tagName === "P";
const isBlockElement = (
element: Element
): element is HTMLLIElement & HTMLParamElement =>
isListItem(element) || isParagraph(element);
export const getListItem = getAnchorParent(isListItem);
export const getParagraph = getAnchorParent(isParagraph);
export const getBlockElement = getAnchorParent(isBlockElement);

44
ts/lib/wrap.ts Normal file
View File

@ -0,0 +1,44 @@
// 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",
*/
function wrappedExceptForWhitespace(text: string, front: string, back: string): string {
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;
return match[1] + front + match[2] + back + match[3];
}
function moveCursorPastPostfix(selection: Selection, postfix: string): void {
const range = selection.getRangeAt(0);
range.setStart(range.startContainer, range.startOffset - postfix.length);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
export function wrapInternal(
root: Document | ShadowRoot,
front: string,
back: string,
plainText: boolean
): void {
const selection = root.getSelection()!;
const range = selection.getRangeAt(0);
const content = range.cloneContents();
const span = document.createElement("span");
span.appendChild(content);
if (plainText) {
const new_ = wrappedExceptForWhitespace(span.innerText, front, back);
document.execCommand("inserttext", false, new_);
} else {
const new_ = wrappedExceptForWhitespace(span.innerHTML, front, back);
document.execCommand("inserthtml", false, new_);
}
if (!span.innerHTML) {
moveCursorPastPostfix(selection, back);
}
}

View File

@ -164,6 +164,14 @@
"path": "node_modules/commander", "path": "node_modules/commander",
"licenseFile": "node_modules/commander/LICENSE" "licenseFile": "node_modules/commander/LICENSE"
}, },
"commander@8.1.0": {
"licenses": "MIT",
"repository": "https://github.com/tj/commander.js",
"publisher": "TJ Holowaychuk",
"email": "tj@vision-media.ca",
"path": "node_modules/speech-rule-engine/node_modules/commander",
"licenseFile": "node_modules/speech-rule-engine/node_modules/commander/LICENSE"
},
"css-browser-selector@0.6.5": { "css-browser-selector@0.6.5": {
"licenses": "CC-BY-SA-2.5", "licenses": "CC-BY-SA-2.5",
"repository": "https://github.com/verbatim/css_browser_selector", "repository": "https://github.com/verbatim/css_browser_selector",
@ -426,6 +434,14 @@
"path": "node_modules/delaunator", "path": "node_modules/delaunator",
"licenseFile": "node_modules/delaunator/LICENSE" "licenseFile": "node_modules/delaunator/LICENSE"
}, },
"esm@3.2.25": {
"licenses": "MIT",
"repository": "https://github.com/standard-things/esm",
"publisher": "John-David Dalton",
"email": "john.david.dalton@gmail.com",
"path": "node_modules/esm",
"licenseFile": "node_modules/esm/LICENSE"
},
"iconv-lite@0.6.3": { "iconv-lite@0.6.3": {
"licenses": "MIT", "licenses": "MIT",
"repository": "https://github.com/ashtuchkin/iconv-lite", "repository": "https://github.com/ashtuchkin/iconv-lite",
@ -489,12 +505,31 @@
"path": "node_modules/marked", "path": "node_modules/marked",
"licenseFile": "node_modules/marked/LICENSE.md" "licenseFile": "node_modules/marked/LICENSE.md"
}, },
"mathjax-full@3.2.0": {
"licenses": "Apache-2.0",
"repository": "https://github.com/mathjax/Mathjax-src",
"path": "node_modules/mathjax-full",
"licenseFile": "node_modules/mathjax-full/LICENSE"
},
"mathjax@3.1.4": { "mathjax@3.1.4": {
"licenses": "Apache-2.0", "licenses": "Apache-2.0",
"repository": "https://github.com/mathjax/MathJax", "repository": "https://github.com/mathjax/MathJax",
"path": "node_modules/mathjax", "path": "node_modules/mathjax",
"licenseFile": "node_modules/mathjax/LICENSE" "licenseFile": "node_modules/mathjax/LICENSE"
}, },
"mhchemparser@4.1.1": {
"licenses": "Apache-2.0",
"repository": "https://github.com/mhchem/mhchemParser",
"publisher": "Martin Hensel",
"path": "node_modules/mhchemparser",
"licenseFile": "node_modules/mhchemparser/LICENSE.txt"
},
"mj-context-menu@0.6.1": {
"licenses": "Apache-2.0",
"repository": "https://github.com/zorkow/context-menu",
"path": "node_modules/mj-context-menu",
"licenseFile": "node_modules/mj-context-menu/README.md"
},
"protobufjs@6.11.2": { "protobufjs@6.11.2": {
"licenses": "BSD-3-Clause", "licenses": "BSD-3-Clause",
"repository": "https://github.com/protobufjs/protobuf.js", "repository": "https://github.com/protobufjs/protobuf.js",
@ -526,6 +561,28 @@
"url": "https://github.com/ChALkeR", "url": "https://github.com/ChALkeR",
"path": "node_modules/safer-buffer", "path": "node_modules/safer-buffer",
"licenseFile": "node_modules/safer-buffer/LICENSE" "licenseFile": "node_modules/safer-buffer/LICENSE"
},
"speech-rule-engine@3.3.3": {
"licenses": "Apache-2.0",
"repository": "https://github.com/zorkow/speech-rule-engine",
"path": "node_modules/speech-rule-engine",
"licenseFile": "node_modules/speech-rule-engine/LICENSE"
},
"wicked-good-xpath@1.3.0": {
"licenses": "MIT",
"repository": "https://github.com/google/wicked-good-xpath",
"publisher": "Google Inc.",
"path": "node_modules/wicked-good-xpath",
"licenseFile": "node_modules/wicked-good-xpath/LICENSE"
},
"xmldom-sre@0.1.31": {
"licenses": "MIT*",
"repository": "https://github.com/zorkow/xmldom",
"publisher": "jindw",
"email": "jindw@xidea.org",
"url": "http://www.xidea.org",
"path": "node_modules/xmldom-sre",
"licenseFile": "node_modules/xmldom-sre/LICENSE"
} }
} }

View File

@ -72,6 +72,7 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "=2.0.5", "marked": "=2.0.5",
"mathjax": "^3.1.2", "mathjax": "^3.1.2",
"mathjax-full": "^3.2.0",
"protobufjs": "^6.10.2" "protobufjs": "^6.10.2"
}, },
"resolutions": { "resolutions": {

View File

@ -112,7 +112,7 @@ $btn-base-color-night: #666;
} }
// should be similar to -webkit-focus-ring-color // should be similar to -webkit-focus-ring-color
$focus-color: $blue; $focus-color: rgba(21 97 174);
@mixin impressed-shadow($intensity) { @mixin impressed-shadow($intensity) {
box-shadow: inset 0 calc(var(--buttons-size) / 15) calc(var(--buttons-size) / 5) box-shadow: inset 0 calc(var(--buttons-size) / 15) calc(var(--buttons-size) / 5)

View File

@ -14,6 +14,7 @@
], ],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"editable/*": ["../bazel-bin/ts/editable/*"],
"lib/*": ["../bazel-bin/ts/lib/*"], "lib/*": ["../bazel-bin/ts/lib/*"],
"html-filter/*": ["../bazel-bin/ts/html-filter/*"] "html-filter/*": ["../bazel-bin/ts/html-filter/*"]
/* "sveltelib/*": ["../bazel-bin/ts/sveltelib/*"], */ /* "sveltelib/*": ["../bazel-bin/ts/sveltelib/*"], */

View File

@ -1596,6 +1596,11 @@ commander@7:
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
commander@>=7.0.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-8.1.0.tgz#db36e3e66edf24ff591d639862c6ab2c52664362"
integrity sha512-mf45ldcuHSYShkplHHGKWb4TrmwQadxOn7v4WuhDJy0ZVoY5JFajaRDKD0PNe5qXzBX0rhovjTnP6Kz9LETcuA==
concat-map@0.0.1: concat-map@0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@ -2154,6 +2159,11 @@ eslint@^7.24.0:
text-table "^0.2.0" text-table "^0.2.0"
v8-compile-cache "^2.0.3" v8-compile-cache "^2.0.3"
esm@^3.2.25:
version "3.2.25"
resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10"
integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==
espree@^7.3.0, espree@^7.3.1: espree@^7.3.0, espree@^7.3.1:
version "7.3.1" version "7.3.1"
resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6"
@ -3356,6 +3366,16 @@ marked@=2.0.5, marked@^2.0.3:
resolved "https://registry.yarnpkg.com/marked/-/marked-2.0.5.tgz#2d15c759b9497b0e7b5b57f4c2edabe1002ef9e7" resolved "https://registry.yarnpkg.com/marked/-/marked-2.0.5.tgz#2d15c759b9497b0e7b5b57f4c2edabe1002ef9e7"
integrity sha512-yfCEUXmKhBPLOzEC7c+tc4XZdIeTdGoRCZakFMkCxodr7wDXqoapIME4wjcpBPJLNyUnKJ3e8rb8wlAgnLnaDw== integrity sha512-yfCEUXmKhBPLOzEC7c+tc4XZdIeTdGoRCZakFMkCxodr7wDXqoapIME4wjcpBPJLNyUnKJ3e8rb8wlAgnLnaDw==
mathjax-full@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/mathjax-full/-/mathjax-full-3.2.0.tgz#e53269842a943d4df10502937518991268996c5c"
integrity sha512-D2EBNvUG+mJyhn+M1C858k0f2Fc4KxXvbEX2WCMXroV10212JwfYqaBJ336ECBSz5X9L5LRoamxb7AJtg3KaJA==
dependencies:
esm "^3.2.25"
mhchemparser "^4.1.0"
mj-context-menu "^0.6.1"
speech-rule-engine "^3.3.3"
mathjax@^3.1.2: mathjax@^3.1.2:
version "3.1.4" version "3.1.4"
resolved "https://registry.yarnpkg.com/mathjax/-/mathjax-3.1.4.tgz#4e8932d12845c0abae8b7f1976ea98cb505e8420" resolved "https://registry.yarnpkg.com/mathjax/-/mathjax-3.1.4.tgz#4e8932d12845c0abae8b7f1976ea98cb505e8420"
@ -3376,6 +3396,11 @@ merge2@^1.3.0:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
mhchemparser@^4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/mhchemparser/-/mhchemparser-4.1.1.tgz#a2142fdab37a02ec8d1b48a445059287790becd5"
integrity sha512-R75CUN6O6e1t8bgailrF1qPq+HhVeFTM3XQ0uzI+mXTybmphy3b6h4NbLOYhemViQ3lUs+6CKRkC3Ws1TlYREA==
micromatch@^4.0.2, micromatch@^4.0.4: micromatch@^4.0.2, micromatch@^4.0.4:
version "4.0.4" version "4.0.4"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
@ -3418,6 +3443,11 @@ minimist@^1.2.0, minimist@^1.2.5:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
mj-context-menu@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/mj-context-menu/-/mj-context-menu-0.6.1.tgz#a043c5282bf7e1cf3821de07b13525ca6f85aa69"
integrity sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==
mkdirp@^1.0.3, mkdirp@^1.0.4: mkdirp@^1.0.3, mkdirp@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
@ -4094,6 +4124,15 @@ spdx-satisfies@^5.0.0:
spdx-expression-parse "^3.0.0" spdx-expression-parse "^3.0.0"
spdx-ranges "^2.0.0" spdx-ranges "^2.0.0"
speech-rule-engine@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/speech-rule-engine/-/speech-rule-engine-3.3.3.tgz#781ed03cbcf3279f94d1d80241025ea954c6d571"
integrity sha512-0exWw+0XauLjat+f/aFeo5T8SiDsO1JtwpY3qgJE4cWt+yL/Stl0WP4VNDWdh7lzGkubUD9lWP4J1ASnORXfyQ==
dependencies:
commander ">=7.0.0"
wicked-good-xpath "^1.3.0"
xmldom-sre "^0.1.31"
sprintf-js@~1.0.2: sprintf-js@~1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@ -4505,6 +4544,11 @@ which@^2.0.1:
dependencies: dependencies:
isexe "^2.0.0" isexe "^2.0.0"
wicked-good-xpath@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz#81b0e95e8650e49c94b22298fff8686b5553cf6c"
integrity sha1-gbDpXoZQ5JyUsiKY//hoa1VTz2w=
word-wrap@^1.2.3, word-wrap@~1.2.3: word-wrap@^1.2.3, word-wrap@~1.2.3:
version "1.2.3" version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
@ -4554,6 +4598,11 @@ xmlcreate@^2.0.3:
resolved "https://registry.yarnpkg.com/xmlcreate/-/xmlcreate-2.0.3.tgz#df9ecd518fd3890ab3548e1b811d040614993497" resolved "https://registry.yarnpkg.com/xmlcreate/-/xmlcreate-2.0.3.tgz#df9ecd518fd3890ab3548e1b811d040614993497"
integrity sha512-HgS+X6zAztGa9zIK3Y3LXuJes33Lz9x+YyTxgrkIdabu2vqcGOWwdfCpf1hWLRrd553wd4QCDf6BBO6FfdsRiQ== integrity sha512-HgS+X6zAztGa9zIK3Y3LXuJes33Lz9x+YyTxgrkIdabu2vqcGOWwdfCpf1hWLRrd553wd4QCDf6BBO6FfdsRiQ==
xmldom-sre@^0.1.31:
version "0.1.31"
resolved "https://registry.yarnpkg.com/xmldom-sre/-/xmldom-sre-0.1.31.tgz#10860d5bab2c603144597d04bf2c4980e98067f4"
integrity sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==
y18n@^5.0.5: y18n@^5.0.5:
version "5.0.8" version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"