Merge pull request #1324 from hgiesel/mathjaxboxv4
MathJax Preview + In-line editor
This commit is contained in:
commit
22e8ce3eb3
@ -21,11 +21,19 @@ copy_files_into_group(
|
||||
name = "editor",
|
||||
srcs = [
|
||||
"editor.css",
|
||||
"editable.css",
|
||||
],
|
||||
package = "//ts/editor",
|
||||
)
|
||||
|
||||
|
||||
copy_files_into_group(
|
||||
name = "editable",
|
||||
srcs = [
|
||||
"editable-build.css",
|
||||
],
|
||||
package = "//ts/editable",
|
||||
)
|
||||
|
||||
copy_files_into_group(
|
||||
name = "reviewer",
|
||||
srcs = [
|
||||
@ -39,6 +47,7 @@ filegroup(
|
||||
srcs = [
|
||||
"css_local",
|
||||
"editor",
|
||||
"editable",
|
||||
"reviewer",
|
||||
],
|
||||
visibility = ["//qt:__subpackages__"],
|
||||
|
@ -80,10 +80,10 @@ audio = (
|
||||
|
||||
_html = """
|
||||
<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>
|
||||
</div>
|
||||
<div id="cloze-hint"></div>
|
||||
<div id="cloze-hint" class="d-none"></div>
|
||||
<div id="tag-editor-anchor" class="d-none"></div>
|
||||
"""
|
||||
|
||||
|
@ -5,7 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
<script lang="ts">
|
||||
import type { ChangeNotetypeState } from "./lib";
|
||||
|
||||
import StickyBar from "components/StickyBar.svelte";
|
||||
import StickyHeader from "components/StickyHeader.svelte";
|
||||
import ButtonToolbar from "components/ButtonToolbar.svelte";
|
||||
import Item from "components/Item.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>
|
||||
|
||||
<StickyBar>
|
||||
<StickyHeader>
|
||||
<ButtonToolbar class="justify-content-between" size={2.3} wrap={false}>
|
||||
<Item>
|
||||
<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} />
|
||||
</Item>
|
||||
</ButtonToolbar>
|
||||
</StickyBar>
|
||||
</StickyHeader>
|
||||
|
@ -19,14 +19,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
<ButtonToolbar
|
||||
{id}
|
||||
class={`dropdown-menu btn-dropdown-menu ${className}`}
|
||||
class="dropdown-menu btn-dropdown-menu {className}"
|
||||
wrap={false}
|
||||
{api}
|
||||
>
|
||||
<slot />
|
||||
<div on:mousedown|preventDefault|stopPropagation>
|
||||
<slot />
|
||||
</div>
|
||||
</ButtonToolbar>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:global(.dropdown-menu.btn-dropdown-menu) {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
|
@ -16,7 +16,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
setContext(dropdownKey, null);
|
||||
</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}">
|
||||
<slot />
|
||||
</div>
|
||||
@ -24,7 +30,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
<style lang="scss">
|
||||
.dropdown-menu {
|
||||
border-radius: 5px;
|
||||
background-color: var(--frame-bg);
|
||||
border-color: var(--medium-border);
|
||||
min-width: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
@ -7,16 +7,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
let className: string = "";
|
||||
export { className as class };
|
||||
|
||||
export let height: number;
|
||||
export let height: number = 0;
|
||||
</script>
|
||||
|
||||
<footer {id} bind:offsetHeight={height} class={`container-fluid pt-1 ${className}`}>
|
||||
<footer {id} bind:offsetHeight={height} class="container-fluid pt-1 {className}">
|
||||
<slot />
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
footer {
|
||||
position: fixed;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
@ -8,12 +8,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<nav {id} class={`container-fluid py-1 ${className}`}>
|
||||
<header {id} class="container-fluid pb-1 {className}">
|
||||
<slot />
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
nav {
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
@ -46,6 +46,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
$: dropClass = `drop${drop}`;
|
||||
|
||||
export let skidding = 0;
|
||||
export let distance = 2;
|
||||
|
||||
setContext(dropdownKey, {
|
||||
dropdown: true,
|
||||
"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, {
|
||||
autoClose,
|
||||
offset: [skidding, distance],
|
||||
popperConfig: { placement },
|
||||
} as any);
|
||||
|
||||
@ -79,7 +83,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
dropdown.show();
|
||||
}
|
||||
|
||||
let api = {
|
||||
api = {
|
||||
show: dropdown.show.bind(dropdown),
|
||||
toggle: dropdown.toggle.bind(dropdown),
|
||||
hide: dropdown.hide.bind(dropdown),
|
||||
|
@ -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 TextInputModal from "./TextInputModal.svelte";
|
||||
import StickyBar from "components/StickyBar.svelte";
|
||||
import StickyHeader from "components/StickyHeader.svelte";
|
||||
import ButtonToolbar from "components/ButtonToolbar.svelte";
|
||||
import Item from "components/Item.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
|
||||
/>
|
||||
|
||||
<StickyBar class="g-1">
|
||||
<StickyHeader class="g-1">
|
||||
<ButtonToolbar class="justify-content-between" size={2.3} wrap={false}>
|
||||
<Item>
|
||||
<ButtonGroup class="flex-grow-1">
|
||||
@ -115,4 +115,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
/>
|
||||
</Item>
|
||||
</ButtonToolbar>
|
||||
</StickyBar>
|
||||
</StickyHeader>
|
||||
|
114
ts/editable/BUILD.bazel
Normal file
114
ts/editable/BUILD.bazel
Normal 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",
|
||||
],
|
||||
)
|
64
ts/editable/Mathjax.svelte
Normal file
64
ts/editable/Mathjax.svelte
Normal 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
45
ts/editable/decorated.ts
Normal 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();
|
@ -15,6 +15,10 @@ anki-editable {
|
||||
}
|
||||
}
|
||||
|
||||
anki-mathjax {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
96
ts/editable/editable-container.ts
Normal file
96
ts/editable/editable-container.ts
Normal 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" });
|
@ -1,10 +1,24 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { bridgeCommand } from "./lib";
|
||||
import { elementIsBlock, caretToEnd, getBlockElement } from "./helpers";
|
||||
import { inCodable } from "./toolbar";
|
||||
import { wrap } from "./wrap";
|
||||
/* eslint
|
||||
@typescript-eslint/no-non-null-assertion: "off",
|
||||
*/
|
||||
|
||||
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 {
|
||||
for (const child of element.children) {
|
||||
@ -26,26 +40,32 @@ export class Editable extends HTMLElement {
|
||||
}
|
||||
|
||||
get fieldHTML(): string {
|
||||
return containsInlineContent(this) && this.innerHTML.endsWith("<br>")
|
||||
? this.innerHTML.slice(0, -4) // trim trailing <br>
|
||||
: this.innerHTML;
|
||||
const clone = this.cloneNode(true) as Element;
|
||||
|
||||
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 {
|
||||
this.setAttribute("contenteditable", "");
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
super.focus();
|
||||
inCodable.set(false);
|
||||
}
|
||||
|
||||
caretToEnd(): void {
|
||||
caretToEnd(this);
|
||||
}
|
||||
|
||||
surroundSelection(before: string, after: string): void {
|
||||
wrap(before, after);
|
||||
wrapInternal(this.getRootNode() as ShadowRoot, before, after, false);
|
||||
}
|
||||
|
||||
onEnter(event: KeyboardEvent): void {
|
||||
@ -63,3 +83,5 @@ export class Editable extends HTMLElement {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-editable", Editable);
|
4
ts/editable/icons.ts
Normal file
4
ts/editable/icons.ts
Normal 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
7
ts/editable/index.ts
Normal 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";
|
196
ts/editable/mathjax-component.ts
Normal file
196
ts/editable/mathjax-component.ts
Normal 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
57
ts/editable/mathjax.ts
Normal 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];
|
||||
}
|
@ -22,18 +22,8 @@ compile_svelte(
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//ts/components",
|
||||
],
|
||||
)
|
||||
|
||||
compile_sass(
|
||||
srcs = [
|
||||
"editable.scss",
|
||||
],
|
||||
group = "editable_scss",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//ts/sass:scrollbar_lib",
|
||||
"//ts/sass/codemirror",
|
||||
"@npm//@types/codemirror",
|
||||
"@npm//codemirror",
|
||||
],
|
||||
)
|
||||
|
||||
@ -45,8 +35,9 @@ compile_sass(
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//ts/sass:base_lib",
|
||||
"//ts/sass:buttons_lib",
|
||||
"//ts/sass:scrollbar_lib",
|
||||
"//ts/sass:buttons_lib",
|
||||
"//ts/sass:button_mixins_lib",
|
||||
],
|
||||
)
|
||||
|
||||
@ -71,6 +62,7 @@ ts_library(
|
||||
"//ts/lib",
|
||||
"//ts/sveltelib",
|
||||
"//ts/components",
|
||||
"//ts/editable",
|
||||
"//ts/html-filter",
|
||||
"//ts:image_module_support",
|
||||
"@npm//svelte",
|
||||
@ -130,6 +122,10 @@ copy_mdi_icons(
|
||||
"image-size-select-large.svg",
|
||||
"image-size-select-actual.svg",
|
||||
|
||||
# mathjax handle
|
||||
"format-wrap-square.svg",
|
||||
"format-wrap-top-bottom.svg",
|
||||
|
||||
# tag editor
|
||||
"tag-outline.svg",
|
||||
"tag.svg",
|
||||
@ -156,8 +152,11 @@ esbuild(
|
||||
"bootstrap-icons",
|
||||
"mdi-icons",
|
||||
"svelte_components",
|
||||
"//ts/editable",
|
||||
"//ts/editable:mdi-icons",
|
||||
"//ts/components",
|
||||
"//ts/components:svelte_components",
|
||||
"//ts/editable:svelte_components",
|
||||
"@npm//protobufjs",
|
||||
],
|
||||
)
|
||||
@ -192,5 +191,7 @@ svelte_check(
|
||||
"//ts/sass/bootstrap",
|
||||
"@npm//@types/bootstrap",
|
||||
"//ts/components",
|
||||
"@npm//@types/codemirror",
|
||||
"@npm//codemirror",
|
||||
],
|
||||
)
|
||||
|
@ -27,7 +27,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
<script lang="typescript">
|
||||
import { isApplePlatform } from "lib/platform";
|
||||
import StickyBar from "components/StickyBar.svelte";
|
||||
import StickyHeader from "components/StickyHeader.svelte";
|
||||
import ButtonToolbar from "components/ButtonToolbar.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 = {};
|
||||
</script>
|
||||
|
||||
<StickyBar>
|
||||
<StickyHeader>
|
||||
<ButtonToolbar {size} {wrap} api={toolbar}>
|
||||
<Item id="notetype">
|
||||
<NoteTypeButtons api={notetypeButtons} />
|
||||
@ -73,4 +73,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
<TemplateButtons api={templateButtons} />
|
||||
</Item>
|
||||
</ButtonToolbar>
|
||||
</StickyBar>
|
||||
</StickyHeader>
|
||||
|
@ -14,7 +14,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import OnlyEditable from "./OnlyEditable.svelte";
|
||||
import CommandIconButton from "./CommandIconButton.svelte";
|
||||
|
||||
import { getCurrentField, getListItem } from "./helpers";
|
||||
import { getListItem } from "lib/dom";
|
||||
import { getCurrentField } from "./helpers";
|
||||
import {
|
||||
ulIcon,
|
||||
olIcon,
|
||||
@ -31,7 +32,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
function outdentListItem() {
|
||||
const currentField = getCurrentField();
|
||||
if (getListItem(currentField.editableContainer.shadowRoot!)) {
|
||||
if (getListItem(currentField!.editableContainer.shadowRoot!)) {
|
||||
document.execCommand("outdent");
|
||||
} else {
|
||||
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() {
|
||||
const currentField = getCurrentField();
|
||||
if (getListItem(currentField.editableContainer.shadowRoot!)) {
|
||||
if (getListItem(currentField!.editableContainer.shadowRoot!)) {
|
||||
document.execCommand("indent");
|
||||
} else {
|
||||
alert("Indent/unindent currently only works with lists.");
|
||||
|
@ -5,13 +5,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
<script lang="ts">
|
||||
import { onMount, createEventDispatcher } from "svelte";
|
||||
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
let background: HTMLDivElement;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(() => dispatch("mount", { background }));
|
||||
</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">
|
||||
div {
|
||||
|
@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
let dimensions: HTMLDivElement;
|
||||
let overflowFix = 0;
|
||||
|
||||
function updateOverflow(dimensions: HTMLDivElement) {
|
||||
function updateOverflow(dimensions: HTMLDivElement): void {
|
||||
const boundingClientRect = dimensions.getBoundingClientRect();
|
||||
const overflow = isRtl
|
||||
? 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);
|
||||
}
|
||||
|
||||
function updateOverflowAsync(dimensions: HTMLDivElement) {
|
||||
afterUpdate(() => updateOverflow(dimensions));
|
||||
|
||||
function updateOverflowAsync(dimensions: HTMLDivElement): void {
|
||||
setTimeout(() => updateOverflow(dimensions));
|
||||
}
|
||||
|
||||
afterUpdate(() => updateOverflow(dimensions));
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(() => dispatch("mount"));
|
||||
|
@ -15,7 +15,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
export function updateSelection(_div: HTMLDivElement): void {
|
||||
function setSelection(_selection?: HTMLDivElement): void {
|
||||
const containerRect = container.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;
|
||||
}
|
||||
|
||||
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();
|
||||
let selection: HTMLDivElement;
|
||||
|
||||
@ -36,7 +48,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
<div
|
||||
bind:this={selection}
|
||||
use:updateSelection
|
||||
use:setSelection
|
||||
on:click={(event) =>
|
||||
/* 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;"
|
||||
|
@ -17,9 +17,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
export let activeImage: HTMLImageElement | null = null;
|
||||
export let container: HTMLElement;
|
||||
export let sheet: CSSStyleSheet;
|
||||
export let activeImage: HTMLImageElement | null = null;
|
||||
export let isRtl: boolean = false;
|
||||
|
||||
$: 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() {
|
||||
updateSelection();
|
||||
await updateSelection();
|
||||
updateDimensions();
|
||||
}
|
||||
|
||||
@ -135,9 +135,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
{#if sheet}
|
||||
<WithDropdown
|
||||
placement="bottom"
|
||||
drop="down"
|
||||
autoOpen={true}
|
||||
autoClose={false}
|
||||
distance={3}
|
||||
let:createDropdown
|
||||
let:dropdownObject
|
||||
>
|
||||
|
@ -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"}
|
||||
flipX={isRtl}
|
||||
on:click={() => {
|
||||
image.style.float = "";
|
||||
image.style.removeProperty("float");
|
||||
|
||||
if (image.getAttribute("style")?.length === 0) {
|
||||
image.removeAttribute("style");
|
||||
}
|
||||
|
||||
setTimeout(() => dispatch("update"));
|
||||
}}>{@html floatNoneIcon}</IconButton
|
||||
>
|
||||
|
104
ts/editor/MathjaxHandle.svelte
Normal file
104
ts/editor/MathjaxHandle.svelte
Normal 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>
|
70
ts/editor/MathjaxHandleEditor.svelte
Normal file
70
ts/editor/MathjaxHandleEditor.svelte
Normal 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>
|
38
ts/editor/MathjaxHandleInlineBlock.svelte
Normal file
38
ts/editor/MathjaxHandleInlineBlock.svelte
Normal 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>
|
@ -6,8 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import { tick } from "svelte";
|
||||
import { isApplePlatform } from "lib/platform";
|
||||
import { bridgeCommand } from "lib/bridgecommand";
|
||||
import Spacer from "components/Spacer.svelte";
|
||||
import StickyBottom from "components/StickyBottom.svelte";
|
||||
import StickyFooter from "components/StickyFooter.svelte";
|
||||
import TagOptionsBadge from "./TagOptionsBadge.svelte";
|
||||
import TagEditMode from "./TagEditMode.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;
|
||||
}
|
||||
|
||||
const colonAtStartOrEnd = /^:?|:?$/g;
|
||||
const withoutSingleColonAtStartOrEnd = /^:?([^:].*?[^:]):?$/;
|
||||
|
||||
function updateSuggestions(): void {
|
||||
const activeTag = tags[active!];
|
||||
@ -76,11 +75,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
if (autocompleteDisabled) {
|
||||
suggestionsPromise = noSuggestions;
|
||||
} else {
|
||||
const cleanedName = replaceWithColons(activeName).replace(
|
||||
colonAtStartOrEnd,
|
||||
""
|
||||
);
|
||||
suggestionsPromise = fetchSuggestions(cleanedName).then(
|
||||
const withColons = replaceWithColons(activeName);
|
||||
const withoutSingleColons = withoutSingleColonAtStartOrEnd.test(withColons)
|
||||
? withColons.replace(withoutSingleColonAtStartOrEnd, "$1")
|
||||
: withColons;
|
||||
|
||||
suggestionsPromise = fetchSuggestions(withoutSingleColons).then(
|
||||
(names: string[]): string[] => {
|
||||
autocompleteDisabled = names.length === 0;
|
||||
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;
|
||||
</script>
|
||||
|
||||
<Spacer --height="{height}px" />
|
||||
|
||||
<StickyBottom class="d-flex" bind:height>
|
||||
<StickyFooter bind:height class="d-flex">
|
||||
{#if !wrap}
|
||||
<TagOptionsBadge
|
||||
--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>
|
||||
</div>
|
||||
</ButtonToolbar>
|
||||
</StickyBottom>
|
||||
</StickyFooter>
|
||||
|
||||
<style lang="scss">
|
||||
.tag-spacer {
|
||||
|
@ -132,6 +132,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
await tick();
|
||||
setPosition(positionStart);
|
||||
dispatch("taginput");
|
||||
return;
|
||||
} else if (after.startsWith(":")) {
|
||||
event.preventDefault();
|
||||
|
@ -101,7 +101,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
let:shortcutLabel
|
||||
>
|
||||
<DropdownItem
|
||||
on:click={() => wrapCurrent("\\(", "\\)")}
|
||||
on:click={() =>
|
||||
wrapCurrent(
|
||||
"<anki-mathjax focusonmount>",
|
||||
"</anki-mathjax>"
|
||||
)}
|
||||
on:mount={withButton(createShortcut)}
|
||||
>
|
||||
{tr.editingMathjaxInline()}
|
||||
@ -115,7 +119,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
let:shortcutLabel
|
||||
>
|
||||
<DropdownItem
|
||||
on:click={() => wrapCurrent("\\[", "\\]")}
|
||||
on:click={() =>
|
||||
wrapCurrent(
|
||||
'<anki-mathjax block="true" focusonmount>',
|
||||
"</anki-matjax>"
|
||||
)}
|
||||
on:mount={withButton(createShortcut)}
|
||||
>
|
||||
{tr.editingMathjaxBlock()}
|
||||
@ -129,7 +137,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
let:shortcutLabel
|
||||
>
|
||||
<DropdownItem
|
||||
on:click={() => wrapCurrent("\\(\\ce{", "}\\)")}
|
||||
on:click={() =>
|
||||
wrapCurrent(
|
||||
"<anki-mathjax focusonmount>\\ce{",
|
||||
"}</anki-mathjax>"
|
||||
)}
|
||||
on:mount={withButton(createShortcut)}
|
||||
>
|
||||
{tr.editingMathjaxChemistry()}
|
||||
|
@ -4,12 +4,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="typescript">
|
||||
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 sheet: CSSStyleSheet;
|
||||
|
||||
export let activeImage: HTMLImageElement | null;
|
||||
let active: boolean = false;
|
||||
|
||||
$: {
|
||||
@ -70,7 +70,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.tagName === "IMG") {
|
||||
if (node.tagName === "IMG" && !(node as HTMLElement).dataset.anki) {
|
||||
result.push(node as HTMLImageElement);
|
||||
} else {
|
||||
result.push(...filterImages(node.children));
|
||||
|
@ -1,47 +1,18 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { EditingArea } from "./editing-area";
|
||||
export class ChangeTimer {
|
||||
private value: number | null = null;
|
||||
|
||||
import { getCurrentField } from "./helpers";
|
||||
import { bridgeCommand } from "./lib";
|
||||
import { getNoteId } from "./note-id";
|
||||
|
||||
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;
|
||||
schedule(action: () => void, delay: number): void {
|
||||
this.clear();
|
||||
this.value = setTimeout(action, delay);
|
||||
}
|
||||
|
||||
clearChangeTimer();
|
||||
|
||||
if (keepFocus) {
|
||||
saveField(currentField, "key");
|
||||
} else {
|
||||
// triggers onBlur, which saves
|
||||
currentField.blur();
|
||||
clear(): void {
|
||||
if (this.value) {
|
||||
clearTimeout(this.value);
|
||||
this.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,39 +1,29 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import * as CodeMirror from "codemirror/lib/codemirror";
|
||||
import "codemirror/mode/htmlmixed/htmlmixed";
|
||||
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";
|
||||
/* eslint
|
||||
@typescript-eslint/no-non-null-assertion: "off",
|
||||
*/
|
||||
|
||||
import { CodeMirror, htmlanki, baseOptions, gutterOptions } from "./codeMirror";
|
||||
import { inCodable } from "./toolbar";
|
||||
|
||||
const codeMirrorOptions = {
|
||||
mode: "htmlmixed",
|
||||
theme: "monokai",
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
foldGutter: true,
|
||||
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
|
||||
matchTags: { bothTags: true },
|
||||
autoCloseTags: true,
|
||||
extraKeys: { Tab: false, "Shift-Tab": false },
|
||||
viewportMargin: Infinity,
|
||||
lineWiseCopyCut: false,
|
||||
mode: htmlanki,
|
||||
...baseOptions,
|
||||
...gutterOptions,
|
||||
};
|
||||
|
||||
const parser = new DOMParser();
|
||||
const parseStyle = "<style>anki-mathjax { white-space: pre; }</style>";
|
||||
|
||||
function parseHTML(html: string): string {
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const doc = parser.parseFromString(`${parseStyle}${html}`, "text/html");
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
export class Codable extends HTMLTextAreaElement {
|
||||
codeMirror: CodeMirror | undefined;
|
||||
codeMirror: CodeMirror.EditorFromTextArea | undefined;
|
||||
|
||||
get active(): boolean {
|
||||
return Boolean(this.codeMirror);
|
||||
@ -41,14 +31,14 @@ export class Codable extends HTMLTextAreaElement {
|
||||
|
||||
set fieldHTML(content: string) {
|
||||
if (this.active) {
|
||||
this.codeMirror.setValue(content);
|
||||
this.codeMirror?.setValue(content);
|
||||
} else {
|
||||
this.value = content;
|
||||
}
|
||||
}
|
||||
|
||||
get fieldHTML(): string {
|
||||
return parseHTML(this.active ? this.codeMirror.getValue() : this.value);
|
||||
return parseHTML(this.active ? this.codeMirror!.getValue() : this.value);
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
@ -58,26 +48,27 @@ export class Codable extends HTMLTextAreaElement {
|
||||
setup(html: string): void {
|
||||
this.fieldHTML = html;
|
||||
this.codeMirror = CodeMirror.fromTextArea(this, codeMirrorOptions);
|
||||
this.codeMirror.on("blur", () => inCodable.set(false));
|
||||
}
|
||||
|
||||
teardown(): string {
|
||||
this.codeMirror.toTextArea();
|
||||
this.codeMirror!.toTextArea();
|
||||
this.codeMirror = undefined;
|
||||
return this.fieldHTML;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.codeMirror.focus();
|
||||
this.codeMirror!.focus();
|
||||
inCodable.set(true);
|
||||
}
|
||||
|
||||
caretToEnd(): void {
|
||||
this.codeMirror.setCursor(this.codeMirror.lineCount(), 0);
|
||||
this.codeMirror!.setCursor(this.codeMirror!.lineCount(), 0);
|
||||
}
|
||||
|
||||
surroundSelection(before: string, after: string): void {
|
||||
const selection = this.codeMirror.getSelection();
|
||||
this.codeMirror.replaceSelection(before + selection + after);
|
||||
const selection = this.codeMirror!.getSelection();
|
||||
this.codeMirror!.replaceSelection(before + selection + after);
|
||||
}
|
||||
|
||||
onEnter(): void {
|
||||
@ -88,3 +79,5 @@ export class Codable extends HTMLTextAreaElement {
|
||||
/* default */
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-codable", Codable, { extends: "textarea" });
|
||||
|
45
ts/editor/codeMirror.ts
Normal file
45
ts/editor/codeMirror.ts
Normal 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,
|
||||
};
|
@ -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";
|
||||
}
|
||||
}
|
@ -7,16 +7,18 @@
|
||||
*/
|
||||
|
||||
import ImageHandle from "./ImageHandle.svelte";
|
||||
import MathjaxHandle from "./MathjaxHandle.svelte";
|
||||
|
||||
import type { EditableContainer } from "./editable-container";
|
||||
import type { Editable } from "./editable";
|
||||
import type { EditableContainer } from "editable/editable-container";
|
||||
import type { Editable } from "editable/editable";
|
||||
import type { Codable } from "./codable";
|
||||
|
||||
import { updateActiveButtons } from "./toolbar";
|
||||
import { bridgeCommand } from "./lib";
|
||||
import { onInput, onKey, onKeyUp } from "./input-handlers";
|
||||
import { onFocus, onBlur } from "./focus-handlers";
|
||||
import { deferFocusDown, saveFieldIfFieldChanged } from "./focus-handlers";
|
||||
import { nightModeKey } from "components/context-keys";
|
||||
import { decoratedComponents } from "editable/decorated";
|
||||
|
||||
function onCutOrCopy(): void {
|
||||
bridgeCommand("cutOrCopy");
|
||||
@ -24,6 +26,7 @@ function onCutOrCopy(): void {
|
||||
|
||||
export class EditingArea extends HTMLDivElement {
|
||||
imageHandle: Promise<ImageHandle>;
|
||||
mathjaxHandle: MathjaxHandle;
|
||||
editableContainer: EditableContainer;
|
||||
editable: Editable;
|
||||
codable: Codable;
|
||||
@ -36,11 +39,9 @@ export class EditingArea extends HTMLDivElement {
|
||||
is: "anki-editable-container",
|
||||
}) as EditableContainer;
|
||||
|
||||
const imageStyle = document.createElement("style");
|
||||
imageStyle.setAttribute("rel", "stylesheet");
|
||||
imageStyle.id = "imageHandleStyle";
|
||||
|
||||
this.editable = document.createElement("anki-editable") as Editable;
|
||||
this.editableContainer.shadowRoot!.appendChild(this.editable);
|
||||
this.appendChild(this.editableContainer);
|
||||
|
||||
const context = new Map();
|
||||
context.set(
|
||||
@ -49,27 +50,32 @@ export class EditingArea extends HTMLDivElement {
|
||||
);
|
||||
|
||||
let imageHandleResolve: (value: ImageHandle) => void;
|
||||
this.imageHandle = new Promise<ImageHandle>((resolve) => {
|
||||
imageHandleResolve = resolve;
|
||||
});
|
||||
this.imageHandle = new Promise<ImageHandle>(
|
||||
(resolve) => (imageHandleResolve = resolve)
|
||||
);
|
||||
|
||||
imageStyle.addEventListener("load", () =>
|
||||
this.editableContainer.imagePromise.then(() =>
|
||||
imageHandleResolve(
|
||||
new ImageHandle({
|
||||
target: this,
|
||||
anchor: this.editableContainer,
|
||||
props: {
|
||||
container: this.editable,
|
||||
sheet: imageStyle.sheet,
|
||||
sheet: this.editableContainer.imageStyle.sheet,
|
||||
},
|
||||
context,
|
||||
} as any)
|
||||
)
|
||||
);
|
||||
|
||||
this.editableContainer.shadowRoot!.appendChild(imageStyle);
|
||||
this.editableContainer.shadowRoot!.appendChild(this.editable);
|
||||
this.appendChild(this.editableContainer);
|
||||
this.mathjaxHandle = new MathjaxHandle({
|
||||
target: this,
|
||||
anchor: this.editableContainer,
|
||||
props: {
|
||||
container: this.editable,
|
||||
},
|
||||
context,
|
||||
} as any);
|
||||
|
||||
this.codable = document.createElement("textarea", {
|
||||
is: "anki-codable",
|
||||
@ -80,7 +86,7 @@ export class EditingArea extends HTMLDivElement {
|
||||
this.onBlur = this.onBlur.bind(this);
|
||||
this.onKey = this.onKey.bind(this);
|
||||
this.onPaste = this.onPaste.bind(this);
|
||||
this.showImageHandle = this.showImageHandle.bind(this);
|
||||
this.showHandles = this.showHandles.bind(this);
|
||||
}
|
||||
|
||||
get activeInput(): Editable | Codable {
|
||||
@ -92,11 +98,24 @@ export class EditingArea extends HTMLDivElement {
|
||||
}
|
||||
|
||||
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 {
|
||||
return this.activeInput.fieldHTML;
|
||||
let result = this.activeInput.fieldHTML;
|
||||
for (const component of decoratedComponents) {
|
||||
result = component.toStored(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
@ -109,7 +128,7 @@ export class EditingArea extends HTMLDivElement {
|
||||
this.addEventListener("copy", onCutOrCopy);
|
||||
this.addEventListener("oncut", onCutOrCopy);
|
||||
this.addEventListener("mouseup", updateActiveButtons);
|
||||
this.editable.addEventListener("click", this.showImageHandle);
|
||||
this.editable.addEventListener("click", this.showHandles);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@ -122,12 +141,14 @@ export class EditingArea extends HTMLDivElement {
|
||||
this.removeEventListener("copy", onCutOrCopy);
|
||||
this.removeEventListener("oncut", onCutOrCopy);
|
||||
this.removeEventListener("mouseup", updateActiveButtons);
|
||||
this.editable.removeEventListener("click", this.showImageHandle);
|
||||
this.editable.removeEventListener("click", this.showHandles);
|
||||
}
|
||||
|
||||
initialize(color: string, content: string): void {
|
||||
this.setBaseColor(color);
|
||||
this.fieldHTML = content;
|
||||
this.editableContainer.stylePromise.then(() => {
|
||||
this.setBaseColor(color);
|
||||
this.fieldHTML = content;
|
||||
});
|
||||
}
|
||||
|
||||
setBaseColor(color: string): void {
|
||||
@ -179,13 +200,12 @@ export class EditingArea extends HTMLDivElement {
|
||||
this.activeInput.surroundSelection(before, after);
|
||||
}
|
||||
|
||||
onFocus(event: FocusEvent): void {
|
||||
onFocus(event);
|
||||
onFocus(): void {
|
||||
deferFocusDown(this);
|
||||
}
|
||||
|
||||
onBlur(event: FocusEvent): void {
|
||||
this.resetImageHandle();
|
||||
onBlur(event);
|
||||
saveFieldIfFieldChanged(this, event.relatedTarget as Element);
|
||||
}
|
||||
|
||||
onEnter(event: KeyboardEvent): void {
|
||||
@ -193,33 +213,49 @@ export class EditingArea extends HTMLDivElement {
|
||||
}
|
||||
|
||||
onKey(event: KeyboardEvent): void {
|
||||
this.resetImageHandle();
|
||||
this.resetHandles();
|
||||
onKey(event);
|
||||
}
|
||||
|
||||
onPaste(event: ClipboardEvent): void {
|
||||
this.resetImageHandle();
|
||||
this.resetHandles();
|
||||
this.activeInput.onPaste(event);
|
||||
}
|
||||
|
||||
resetImageHandle(): void {
|
||||
this.imageHandle.then((imageHandle) =>
|
||||
resetHandles(): Promise<void> {
|
||||
const promise = this.imageHandle.then((imageHandle) =>
|
||||
(imageHandle as any).$set({
|
||||
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) {
|
||||
this.imageHandle.then((imageHandle) =>
|
||||
(imageHandle as any).$set({
|
||||
activeImage: event.target,
|
||||
const image = event.target as HTMLImageElement;
|
||||
await this.resetHandles();
|
||||
|
||||
if (!image.dataset.anki) {
|
||||
await this.imageHandle.then((imageHandle) =>
|
||||
(imageHandle as any).$set({
|
||||
activeImage: image,
|
||||
isRtl: this.isRightToLeft(),
|
||||
})
|
||||
);
|
||||
} else if (image.dataset.anki === "mathjax") {
|
||||
(this.mathjaxHandle as any).$set({
|
||||
activeImage: image,
|
||||
isRtl: this.isRightToLeft(),
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.resetImageHandle();
|
||||
await this.resetHandles();
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,7 +266,7 @@ export class EditingArea extends HTMLDivElement {
|
||||
this.fieldHTML = this.codable.teardown();
|
||||
this.editable.hidden = false;
|
||||
} else {
|
||||
this.resetImageHandle();
|
||||
this.resetHandles();
|
||||
this.editable.hidden = true;
|
||||
this.codable.setup(this.editable.fieldHTML);
|
||||
}
|
||||
@ -254,3 +290,5 @@ export class EditingArea extends HTMLDivElement {
|
||||
blur();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-editing-area", EditingArea, { extends: "div" });
|
||||
|
@ -10,7 +10,7 @@ export class EditorField extends HTMLDivElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.classList.add("editor-field");
|
||||
this.className = "editorfield";
|
||||
|
||||
this.labelContainer = document.createElement("div", {
|
||||
is: "anki-label-container",
|
||||
@ -65,3 +65,5 @@ export class EditorField extends HTMLDivElement {
|
||||
this.editingArea.setBaseStyling(fontFamily, fontSize, direction);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-editor-field", EditorField, { extends: "div" });
|
||||
|
@ -3,6 +3,17 @@
|
||||
|
||||
@use 'base';
|
||||
@use 'scrollbar';
|
||||
@use 'button-mixins';
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nightMode {
|
||||
@include scrollbar.night-mode;
|
||||
@ -10,13 +21,15 @@
|
||||
|
||||
#fields {
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow-x: hidden;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.editor-field {
|
||||
.editorfield {
|
||||
margin: 3px;
|
||||
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
@ -29,6 +42,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* editing-area */
|
||||
.field {
|
||||
position: relative;
|
||||
|
||||
@ -63,10 +77,6 @@
|
||||
text-align: center;
|
||||
background-color: var(--window-bg);
|
||||
|
||||
&.is-inactive {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link);
|
||||
}
|
||||
|
@ -8,23 +8,34 @@
|
||||
import { fieldFocused } from "./toolbar";
|
||||
import type { EditingArea } from "./editing-area";
|
||||
|
||||
import { saveField } from "./change-timer";
|
||||
import { saveField } from "./saving";
|
||||
import { bridgeCommand } from "./lib";
|
||||
import { getCurrentField } from "./helpers";
|
||||
|
||||
export function onFocus(evt: FocusEvent): void {
|
||||
const currentField = evt.currentTarget as EditingArea;
|
||||
currentField.focus();
|
||||
currentField.caretToEnd();
|
||||
export function deferFocusDown(editingArea: EditingArea): void {
|
||||
editingArea.focus();
|
||||
editingArea.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);
|
||||
}
|
||||
|
||||
export function onBlur(evt: FocusEvent): void {
|
||||
const previousFocus = evt.currentTarget as EditingArea;
|
||||
const currentFieldUnchanged = previousFocus === getCurrentField();
|
||||
export function saveFieldIfFieldChanged(
|
||||
editingArea: EditingArea,
|
||||
focusTo: Element | null
|
||||
): void {
|
||||
const fieldChanged =
|
||||
editingArea !== getCurrentField() && !editingArea.contains(focusTo);
|
||||
|
||||
saveField(previousFocus, currentFieldUnchanged ? "key" : "blur");
|
||||
saveField(editingArea, fieldChanged ? "blur" : "key");
|
||||
fieldFocused.set(false);
|
||||
|
||||
if (fieldChanged) {
|
||||
editingArea.resetHandles();
|
||||
}
|
||||
}
|
||||
|
@ -1,103 +1,12 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
/* eslint
|
||||
@typescript-eslint/no-non-null-assertion: "off",
|
||||
*/
|
||||
|
||||
import type { EditingArea } from "./editing-area";
|
||||
|
||||
export function getCurrentField(): EditingArea | 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 {
|
||||
return `${text} (${appendix})`;
|
||||
}
|
||||
|
@ -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 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";
|
||||
|
@ -13,28 +13,30 @@ import type EditorToolbar from "./EditorToolbar.svelte";
|
||||
import type TagEditor from "./TagEditor.svelte";
|
||||
|
||||
import { filterHTML } from "html-filter";
|
||||
import { updateActiveButtons } from "./toolbar";
|
||||
import { setupI18n, ModuleName } from "lib/i18n";
|
||||
import { isApplePlatform } from "lib/platform";
|
||||
import { registerShortcut } from "lib/shortcuts";
|
||||
import { bridgeCommand } from "lib/bridgecommand";
|
||||
import { updateActiveButtons } from "./toolbar";
|
||||
import { saveField } from "./saving";
|
||||
|
||||
import "./fields.css";
|
||||
|
||||
import { saveField } from "./change-timer";
|
||||
|
||||
import { EditorField } from "./editor-field";
|
||||
import { LabelContainer } from "./label-container";
|
||||
import "./label-container";
|
||||
import "./codable";
|
||||
import "./editor-field";
|
||||
import type { EditorField } from "./editor-field";
|
||||
import { EditingArea } from "./editing-area";
|
||||
import { EditableContainer } from "./editable-container";
|
||||
import { Editable } from "./editable";
|
||||
import { Codable } from "./codable";
|
||||
import "editable/editable-container";
|
||||
import "editable/editable";
|
||||
import "editable/mathjax-component";
|
||||
|
||||
import { initToolbar, fieldFocused } from "./toolbar";
|
||||
import { initTagEditor } from "./tag-editor";
|
||||
import { getCurrentField } from "./helpers";
|
||||
|
||||
export { setNoteId, getNoteId } from "./note-id";
|
||||
export { saveNow } from "./change-timer";
|
||||
export { saveNow } from "./saving";
|
||||
export { wrap, wrapIntoText } from "./wrap";
|
||||
export { editorToolbar } from "./toolbar";
|
||||
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()) {
|
||||
registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V");
|
||||
}
|
||||
@ -157,11 +152,14 @@ export function setBackgrounds(cols: ("dupe" | "")[]): void {
|
||||
);
|
||||
document
|
||||
.getElementById("dupes")!
|
||||
.classList.toggle("is-inactive", !cols.includes("dupe"));
|
||||
.classList.toggle("d-none", !cols.includes("dupe"));
|
||||
}
|
||||
|
||||
export function setClozeHint(cloze_hint: string): void {
|
||||
document.getElementById("cloze-hint")!.innerHTML = cloze_hint;
|
||||
export function setClozeHint(hint: string): void {
|
||||
const clozeHint = document.getElementById("cloze-hint")!;
|
||||
|
||||
clozeHint.innerHTML = hint;
|
||||
clozeHint.classList.toggle("d-none", hint.length === 0);
|
||||
}
|
||||
|
||||
export function setFonts(fonts: [string, number, boolean][]): void {
|
||||
|
@ -5,10 +5,10 @@
|
||||
@typescript-eslint/no-non-null-assertion: "off",
|
||||
*/
|
||||
|
||||
import { nodeIsElement } from "lib/dom";
|
||||
import { updateActiveButtons } from "./toolbar";
|
||||
import { EditingArea } from "./editing-area";
|
||||
import { nodeIsElement } from "./helpers";
|
||||
import { triggerChangeTimer } from "./change-timer";
|
||||
import { triggerChangeTimer } from "./saving";
|
||||
import { registerShortcut } from "lib/shortcuts";
|
||||
|
||||
export function onInput(event: Event): void {
|
||||
|
@ -7,7 +7,7 @@ import * as tr from "lib/i18n";
|
||||
import { registerShortcut } from "lib/shortcuts";
|
||||
import { bridgeCommand } from "./lib";
|
||||
import { appendInParentheses } from "./helpers";
|
||||
import { saveField } from "./change-timer";
|
||||
import { saveField } from "./saving";
|
||||
import { getCurrentField, forEditorField, i18n } from ".";
|
||||
import pinIcon from "./pin-angle.svg";
|
||||
|
||||
@ -127,3 +127,5 @@ export class LabelContainer extends HTMLDivElement {
|
||||
this.toggleSticky();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("anki-label-container", LabelContainer, { extends: "div" });
|
||||
|
40
ts/editor/saving.ts
Normal file
40
ts/editor/saving.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -5,45 +5,15 @@
|
||||
@typescript-eslint/no-non-null-assertion: "off",
|
||||
*/
|
||||
|
||||
import { getCurrentField } from "./helpers";
|
||||
import { setFormat } 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);
|
||||
}
|
||||
}
|
||||
import { wrapInternal } from "lib/wrap";
|
||||
import { getCurrentField } from ".";
|
||||
|
||||
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 {
|
||||
@ -53,5 +23,9 @@ export function wrapCurrent(front: string, back: string): void {
|
||||
|
||||
/* currently unused */
|
||||
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
93
ts/lib/dom.ts
Normal 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
44
ts/lib/wrap.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -164,6 +164,14 @@
|
||||
"path": "node_modules/commander",
|
||||
"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": {
|
||||
"licenses": "CC-BY-SA-2.5",
|
||||
"repository": "https://github.com/verbatim/css_browser_selector",
|
||||
@ -426,6 +434,14 @@
|
||||
"path": "node_modules/delaunator",
|
||||
"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": {
|
||||
"licenses": "MIT",
|
||||
"repository": "https://github.com/ashtuchkin/iconv-lite",
|
||||
@ -489,12 +505,31 @@
|
||||
"path": "node_modules/marked",
|
||||
"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": {
|
||||
"licenses": "Apache-2.0",
|
||||
"repository": "https://github.com/mathjax/MathJax",
|
||||
"path": "node_modules/mathjax",
|
||||
"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": {
|
||||
"licenses": "BSD-3-Clause",
|
||||
"repository": "https://github.com/protobufjs/protobuf.js",
|
||||
@ -526,6 +561,28 @@
|
||||
"url": "https://github.com/ChALkeR",
|
||||
"path": "node_modules/safer-buffer",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,6 +72,7 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "=2.0.5",
|
||||
"mathjax": "^3.1.2",
|
||||
"mathjax-full": "^3.2.0",
|
||||
"protobufjs": "^6.10.2"
|
||||
},
|
||||
"resolutions": {
|
||||
|
@ -112,7 +112,7 @@ $btn-base-color-night: #666;
|
||||
}
|
||||
|
||||
// should be similar to -webkit-focus-ring-color
|
||||
$focus-color: $blue;
|
||||
$focus-color: rgba(21 97 174);
|
||||
|
||||
@mixin impressed-shadow($intensity) {
|
||||
box-shadow: inset 0 calc(var(--buttons-size) / 15) calc(var(--buttons-size) / 5)
|
||||
|
@ -14,6 +14,7 @@
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"editable/*": ["../bazel-bin/ts/editable/*"],
|
||||
"lib/*": ["../bazel-bin/ts/lib/*"],
|
||||
"html-filter/*": ["../bazel-bin/ts/html-filter/*"]
|
||||
/* "sveltelib/*": ["../bazel-bin/ts/sveltelib/*"], */
|
||||
|
49
ts/yarn.lock
49
ts/yarn.lock
@ -1596,6 +1596,11 @@ commander@7:
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
|
||||
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:
|
||||
version "0.0.1"
|
||||
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"
|
||||
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:
|
||||
version "7.3.1"
|
||||
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"
|
||||
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:
|
||||
version "3.1.4"
|
||||
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"
|
||||
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:
|
||||
version "4.0.4"
|
||||
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"
|
||||
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:
|
||||
version "1.0.4"
|
||||
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-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:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
||||
@ -4505,6 +4544,11 @@ which@^2.0.1:
|
||||
dependencies:
|
||||
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:
|
||||
version "1.2.3"
|
||||
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"
|
||||
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:
|
||||
version "5.0.8"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
|
||||
|
Loading…
Reference in New Issue
Block a user