Fix plain text (#1689)

* Move remove-prohibited-tags logic from PlainTextInput

* Make CodeMirror export a promise

* Offer lifecycle hooks for PlainTextInput for easily accessing code mirror instance

* Fix </> breaking Mathjax

* remove debug statement (dae)
This commit is contained in:
Henrik Giesel 2022-02-25 02:14:26 +01:00 committed by GitHub
parent 7c27159149
commit 50e36bc312
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 307 additions and 192 deletions

View File

@ -38,13 +38,37 @@ export interface DecoratedElementConstructor
toUndecorated(stored: string): string;
}
export class CustomElementArray<
T extends CustomElementConstructor & WithTagName,
> extends Array<T> {
push(...elements: T[]): number {
export class CustomElementArray extends Array<DecoratedElementConstructor> {
push(...elements: DecoratedElementConstructor[]): number {
for (const element of elements) {
customElements.define(element.tagName, element);
}
return super.push(...elements);
}
/**
* Transforms any decorated elements in input HTML from undecorated to stored state.
*/
toStored(html: string): string {
let result = html;
for (const element of this) {
result = element.toStored(result);
}
return result;
}
/**
* Transforms any decorated elements in input HTML from stored to undecorated state.
*/
toUndecorated(html: string): string {
let result = html;
for (const element of this) {
result = element.toUndecorated(result);
}
return result;
}
}

View File

@ -15,6 +15,14 @@ const mathjaxTagPattern =
const mathjaxBlockDelimiterPattern = /\\\[(.*?)\\\]/gsu;
const mathjaxInlineDelimiterPattern = /\\\((.*?)\\\)/gsu;
/**
* If the user enters the Mathjax with delimiters, "<" and ">" will
* be first translated to entities.
*/
function translateEntitiesToMathjax(value: string) {
return value.replace(/&lt;/g, "{\\lt}").replace(/&gt;/g, "{\\gt}");
}
export const Mathjax: DecoratedElementConstructor = class Mathjax
extends HTMLElement
implements DecoratedElement
@ -22,7 +30,7 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
static tagName = "anki-mathjax";
static toStored(undecorated: string): string {
return undecorated.replace(
const stored = undecorated.replace(
mathjaxTagPattern,
(_match: string, block: string | undefined, text: string) => {
return typeof block === "string" && block !== "false"
@ -30,20 +38,20 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
: `\\(${text}\\)`;
},
);
return stored;
}
static toUndecorated(stored: string): string {
return stored
.replace(
mathjaxBlockDelimiterPattern,
(_match: string, text: string) =>
`<${Mathjax.tagName} block="true">${text}</${Mathjax.tagName}>`,
)
.replace(
mathjaxInlineDelimiterPattern,
(_match: string, text: string) =>
`<${Mathjax.tagName}>${text}</${Mathjax.tagName}>`,
);
.replace(mathjaxBlockDelimiterPattern, (_match: string, text: string) => {
const escaped = translateEntitiesToMathjax(text);
return `<${Mathjax.tagName} block="true">${escaped}</${Mathjax.tagName}>`;
})
.replace(mathjaxInlineDelimiterPattern, (_match: string, text: string) => {
const escaped = translateEntitiesToMathjax(text);
return `<${Mathjax.tagName}>${escaped}</${Mathjax.tagName}>`;
});
}
block = false;
@ -76,6 +84,9 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
break;
case "data-mathjax":
if (!newValue) {
return;
}
this.component?.$set({ mathjax: newValue });
break;
}

View File

@ -3,92 +3,84 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script context="module" lang="ts">
import { CodeMirror as CodeMirrorLib } from "./code-mirror";
import type CodeMirrorLib from "codemirror";
export interface CodeMirrorAPI {
readonly editor: CodeMirrorLib.EditorFromTextArea;
readonly editor: Promise<CodeMirrorLib.Editor>;
setOption<T extends keyof CodeMirrorLib.EditorConfiguration>(
key: T,
value: CodeMirrorLib.EditorConfiguration[T],
): Promise<void>;
}
</script>
<script lang="ts">
import { createEventDispatcher, getContext } from "svelte";
import { createEventDispatcher, getContext, onMount } from "svelte";
import type { Writable } from "svelte/store";
import { directionKey } from "../lib/context-keys";
import storeSubscribe from "../sveltelib/store-subscribe";
import { promiseWithResolver } from "../lib/promise";
import { pageTheme } from "../sveltelib/theme";
import { darkCodeMirrorTheme, lightCodeMirrorTheme } from "./code-mirror";
import {
darkTheme,
lightTheme,
openCodeMirror,
setupCodeMirror,
} from "./code-mirror";
export let configuration: CodeMirrorLib.EditorConfiguration;
export let code: Writable<string>;
const direction = getContext<Writable<"ltr" | "rtl">>(directionKey);
const defaultConfiguration = {
direction: $direction,
rtlMoveVisually: true,
};
let codeMirror: CodeMirrorLib.EditorFromTextArea;
$: codeMirror?.setOption("direction", $direction);
const [editorPromise, resolve] = promiseWithResolver<CodeMirrorLib.Editor>();
function setValue(content: string): void {
codeMirror.setValue(content);
/**
* Convenience function for editor.setOption.
*/
function setOption<T extends keyof CodeMirrorLib.EditorConfiguration>(
key: T,
value: CodeMirrorLib.EditorConfiguration[T],
): void {
editorPromise.then((editor) => editor.setOption(key, value));
}
const { subscribe, unsubscribe } = storeSubscribe(code, setValue, false);
const direction = getContext<Writable<"ltr" | "rtl">>(directionKey);
$: setOption("direction", $direction);
$: setOption("theme", $pageTheme.isDark ? darkTheme : lightTheme);
let apiPartial: Partial<CodeMirrorAPI>;
export { apiPartial as api };
Object.assign(apiPartial, {
editor: editorPromise,
setOption,
});
const dispatch = createEventDispatcher();
function openCodeMirror(textarea: HTMLTextAreaElement): void {
codeMirror = CodeMirrorLib.fromTextArea(textarea, {
...defaultConfiguration,
...configuration,
});
// TODO passing in the tabindex option does not do anything: bug?
codeMirror.getInputField().tabIndex = 0;
let ranges: CodeMirrorLib.Range[] | null = null;
codeMirror.on("change", () => dispatch("change", codeMirror.getValue()));
codeMirror.on("mousedown", () => {
ranges = null;
});
codeMirror.on("focus", () => {
if (ranges) {
try {
codeMirror.setSelections(ranges);
} catch {
ranges = null;
codeMirror.setCursor(codeMirror.lineCount(), 0);
}
}
unsubscribe();
dispatch("focus");
});
codeMirror.on("blur", () => {
ranges = codeMirror.listSelections();
subscribe();
dispatch("blur");
});
subscribe();
}
$: codeMirror?.setOption(
"theme",
$pageTheme.isDark ? darkCodeMirrorTheme : lightCodeMirrorTheme,
onMount(() =>
editorPromise.then((editor) => {
setupCodeMirror(editor, code);
editor.on("change", () => dispatch("change", editor.getValue()));
editor.on("focus", () => dispatch("focus"));
editor.on("blur", () => dispatch("blur"));
}),
);
export const api = Object.create(
{},
{
editor: { get: () => codeMirror },
},
) as CodeMirrorAPI;
</script>
<div class="code-mirror">
<textarea tabindex="-1" hidden use:openCodeMirror />
<textarea
tabindex="-1"
hidden
use:openCodeMirror={{
configuration: { ...configuration, ...defaultConfiguration },
resolve,
}}
/>
</div>
<style lang="scss">

View File

@ -3,17 +3,13 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script context="module" lang="ts">
import {
CustomElementArray,
DecoratedElementConstructor,
} from "../editable/decorated";
import { CustomElementArray } from "../editable/decorated";
import contextProperty from "../sveltelib/context-property";
const decoratedElements = new CustomElementArray<DecoratedElementConstructor>();
const decoratedElements = new CustomElementArray();
const key = Symbol("decoratedElements");
const [context, setContextProperty] =
contextProperty<CustomElementArray<DecoratedElementConstructor>>(key);
const [context, setContextProperty] = contextProperty<CustomElementArray>(key);
export { context };
</script>

View File

@ -149,7 +149,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
// TODO this is a hack, because it requires the NoteEditor to know implementation details of the PlainTextInput.
// It should be refactored once we work on our own Undo stack
for (const pi of plainTextInputs) {
pi.api.getEditor().clearHistory();
pi.api.codeMirror.editor.then((editor) => editor.clearHistory());
}
noteId = ntid;
}

View File

@ -14,6 +14,9 @@ import "codemirror/addon/edit/closetag";
import "codemirror/addon/display/placeholder";
import CodeMirror from "codemirror";
import type { Readable } from "svelte/store";
import storeSubscribe from "../sveltelib/store-subscribe";
export { CodeMirror };
@ -29,11 +32,11 @@ export const htmlanki = {
},
};
export const lightCodeMirrorTheme = "default";
export const darkCodeMirrorTheme = "monokai";
export const lightTheme = "default";
export const darkTheme = "monokai";
export const baseOptions: CodeMirror.EditorConfiguration = {
theme: lightCodeMirrorTheme,
theme: lightTheme,
lineWrapping: true,
matchTags: { bothTags: true },
autoCloseTags: true,
@ -53,3 +56,73 @@ export function focusAndCaretAfter(editor: CodeMirror.Editor): void {
editor.focus();
editor.setCursor(editor.lineCount(), 0);
}
interface OpenCodeMirrorOptions {
configuration: CodeMirror.EditorConfiguration;
resolve(editor: CodeMirror.EditorFromTextArea): void;
}
export function openCodeMirror(
textarea: HTMLTextAreaElement,
{ configuration, resolve }: Partial<OpenCodeMirrorOptions>,
): { update: (options: Partial<OpenCodeMirrorOptions>) => void; destroy: () => void } {
const editor = CodeMirror.fromTextArea(textarea, configuration);
resolve?.(editor);
return {
update({ configuration }: Partial<OpenCodeMirrorOptions>): void {
for (const key in configuration) {
editor.setOption(
key as keyof CodeMirror.EditorConfiguration,
configuration[key],
);
}
},
destroy(): void {
editor.toTextArea();
},
};
}
/**
* Sets up the contract with the code store and location restoration.
*/
export function setupCodeMirror(
editor: CodeMirror.Editor,
code: Readable<string>,
): void {
const { subscribe, unsubscribe } = storeSubscribe(
code,
(value: string): void => editor.setValue(value),
false,
);
// TODO passing in the tabindex option does not do anything: bug?
editor.getInputField().tabIndex = 0;
let ranges: CodeMirror.Range[] | null = null;
editor.on("focus", () => {
if (ranges) {
try {
editor.setSelections(ranges);
} catch {
ranges = null;
editor.setCursor(editor.lineCount(), 0);
}
}
unsubscribe();
});
editor.on("mousedown", () => {
// Prevent focus restoring location
ranges = null;
});
editor.on("blur", () => {
ranges = editor.listSelections();
subscribe();
});
subscribe();
}

View File

@ -3,6 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type CodeMirrorLib from "codemirror";
import { createEventDispatcher, onMount } from "svelte";
import type { Writable } from "svelte/store";
@ -14,14 +15,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import CodeMirror from "../CodeMirror.svelte";
export let code: Writable<string>;
export let acceptShortcut: string;
export let newlineShortcut: string;
const configuration = {
...Object.assign({}, baseOptions, {
extraKeys: {
...(baseOptions.extraKeys as CodeMirror.KeyMap),
...(baseOptions.extraKeys as CodeMirrorLib.KeyMap),
[acceptShortcut]: noop,
[newlineShortcut]: noop,
},
@ -37,46 +37,61 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const dispatch = createEventDispatcher();
let codeMirror: CodeMirrorAPI = {} as CodeMirrorAPI;
let codeMirror = {} as CodeMirrorAPI;
onMount(() => {
focusAndCaretAfter(codeMirror.editor);
onMount(() =>
codeMirror.editor.then((editor) => {
focusAndCaretAfter(editor);
if (selectAll) {
codeMirror.editor.execCommand("selectAll");
}
let direction: "start" | "end" | undefined = undefined;
codeMirror.editor.on("keydown", (_instance, event: KeyboardEvent) => {
if (event.key === "ArrowLeft") {
direction = "start";
} else if (event.key === "ArrowRight") {
direction = "end";
if (selectAll) {
editor.execCommand("selectAll");
}
});
codeMirror.editor.on(
"beforeSelectionChange",
(instance, obj: CodeMirror.EditorSelectionChange) => {
const { anchor } = obj.ranges[0];
let direction: "start" | "end" | undefined = undefined;
if (anchor["hitSide"]) {
if (instance.getValue().length === 0) {
if (direction) {
dispatch(`moveout${direction}`);
}
} else if (anchor.line === 0 && anchor.ch === 0) {
dispatch("moveoutstart");
} else {
dispatch("moveoutend");
editor.on(
"keydown",
(_instance: CodeMirrorLib.Editor, event: KeyboardEvent): void => {
if (event.key === "ArrowLeft") {
direction = "start";
} else if (event.key === "ArrowRight") {
direction = "end";
}
}
},
);
direction = undefined;
},
);
});
editor.on(
"beforeSelectionChange",
(
instance: CodeMirrorLib.Editor,
obj: CodeMirrorLib.EditorSelectionChange,
): void => {
const { anchor } = obj.ranges[0];
if (anchor["hitSide"]) {
if (instance.getValue().length === 0) {
if (direction) {
dispatch(`moveout${direction}`);
}
} else if (anchor.line === 0 && anchor.ch === 0) {
dispatch("moveoutstart");
} else {
dispatch("moveoutend");
}
}
direction = undefined;
},
);
}),
);
/**
* Escape characters which are technically legal in Mathjax, but confuse HTML.
*/
export function escapeSomeEntities(value: string): string {
return value.replace(/</g, "{\\lt}").replace(/>/g, "{\\gt}");
}
</script>
<div class="mathjax-editor">
@ -84,7 +99,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{code}
{configuration}
bind:api={codeMirror}
on:change={({ detail }) => code.set(detail)}
on:change={({ detail }) => code.set(escapeSomeEntities(detail))}
on:blur
/>
</div>

View File

@ -132,13 +132,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
});
</script>
<WithDropdown
drop="down"
autoOpen={true}
autoClose={false}
distance={4}
let:createDropdown
>
<WithDropdown drop="down" autoOpen autoClose={false} distance={4} let:createDropdown>
{#if activeImage && mathjaxElement}
<MathjaxMenu
element={mathjaxElement}

View File

@ -3,17 +3,27 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script context="module" lang="ts">
import type { CodeMirror as CodeMirrorType } from "../code-mirror";
import { registerPackage } from "../../lib/runtime-require";
import lifecycleHooks from "../../sveltelib/lifecycle-hooks";
import type { CodeMirrorAPI } from "../CodeMirror.svelte";
import type { EditingInputAPI } from "../EditingArea.svelte";
export interface PlainTextInputAPI extends EditingInputAPI {
name: "plain-text";
moveCaretToEnd(): void;
toggle(): boolean;
getEditor(): CodeMirrorType.Editor;
codeMirror: CodeMirrorAPI;
}
export const parsingInstructions: string[] = [];
const [lifecycle, instances, setupLifecycleHooks] =
lifecycleHooks<PlainTextInputAPI>();
registerPackage("anki/PlainTextInput", {
lifecycle,
instances,
});
</script>
<script lang="ts">
@ -22,11 +32,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { pageTheme } from "../../sveltelib/theme";
import { baseOptions, gutterOptions, htmlanki } from "../code-mirror";
import type { CodeMirrorAPI } from "../CodeMirror.svelte";
import CodeMirror from "../CodeMirror.svelte";
import { context as decoratedElementsContext } from "../DecoratedElements.svelte";
import { context as editingAreaContext } from "../EditingArea.svelte";
import { context as noteEditorContext } from "../NoteEditor.svelte";
import removeProhibitedTags from "./remove-prohibited";
export let hidden = false;
@ -42,62 +52,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const decoratedElements = decoratedElementsContext.get();
const code = writable($content);
function adjustInputHTML(html: string): string {
for (const component of decoratedElements) {
html = component.toUndecorated(html);
}
return html;
}
const parser = new DOMParser();
function removeTag(element: HTMLElement, tagName: string): void {
for (const elem of element.getElementsByTagName(tagName)) {
elem.remove();
}
}
function createDummyDoc(html: string): string {
return (
"<html><head></head><body>" +
parsingInstructions.join("") +
html +
"</body>"
);
}
function parseAsHTML(html: string): string {
const doc = parser.parseFromString(createDummyDoc(html), "text/html");
const body = doc.body;
removeTag(body, "script");
removeTag(body, "link");
removeTag(body, "style");
return doc.body.innerHTML;
}
function adjustOutputHTML(html: string): string {
for (const component of decoratedElements) {
html = component.toStored(html);
}
return html;
}
let codeMirror: CodeMirrorAPI;
function focus(): void {
codeMirror?.editor.focus();
codeMirror.editor.then((editor) => editor.focus());
}
function moveCaretToEnd(): void {
codeMirror?.editor.setCursor(codeMirror.editor.lineCount(), 0);
codeMirror.editor.then((editor) => editor.setCursor(editor.lineCount(), 0));
}
function refocus(): void {
(codeMirror?.editor as any).display.input.blur();
codeMirror.editor.then((editor) => (editor as any).display.input.blur());
focus();
moveCaretToEnd();
}
@ -107,9 +71,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return hidden;
}
function getEditor(): CodeMirrorType.Editor {
return codeMirror?.editor;
}
let codeMirror = {} as CodeMirrorAPI;
export const api = {
name: "plain-text",
@ -118,7 +80,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
moveCaretToEnd,
refocus,
toggle,
getEditor,
codeMirror,
} as PlainTextInputAPI;
function pushUpdate(): void {
@ -126,8 +88,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$editingInputs = $editingInputs;
}
function refresh() {
codeMirror.editor.refresh();
function refresh(): void {
codeMirror.editor.then((editor) => editor.refresh());
}
$: {
@ -136,18 +98,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
pushUpdate();
}
function storedToUndecorated(html: string): string {
return decoratedElements.toUndecorated(html);
}
function undecoratedToStored(html: string): string {
return decoratedElements.toStored(html);
}
onMount(() => {
$editingInputs.push(api);
$editingInputs = $editingInputs;
const unsubscribeFromEditingArea = content.subscribe((value: string): void => {
const adjusted = adjustInputHTML(value);
code.set(adjusted);
code.set(storedToUndecorated(value));
});
const unsubscribeToEditingArea = code.subscribe((value: string): void => {
const parsed = parseAsHTML(value);
content.set(adjustOutputHTML(parsed));
content.set(removeProhibitedTags(undecoratedToStored(value)));
});
return () => {
@ -155,6 +123,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
unsubscribeToEditingArea();
};
});
setupLifecycleHooks(api);
</script>
<div
@ -167,7 +137,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{configuration}
{code}
bind:api={codeMirror}
on:change={({ detail: html }) => code.set(parseAsHTML(html))}
on:change={({ detail: html }) => code.set(removeProhibitedTags(html))}
/>
</div>

View File

@ -0,0 +1,38 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const parser = new DOMParser();
function createDummyDoc(html: string): string {
return (
"<html><head></head><body>" +
// parsingInstructions.join("") +
html +
"</body>"
);
}
function removeTag(element: HTMLElement, tagName: string): void {
for (const elem of element.getElementsByTagName(tagName)) {
elem.remove();
}
}
const prohibitedTags = ["script", "link", "style"];
/**
* The use cases for using those tags in the field html are slim to none.
* We want to make it easier to possibly display cards in an iframe in the future.
*/
function removeProhibitedTags(html: string): string {
const doc = parser.parseFromString(createDummyDoc(html), "text/html");
const body = doc.body;
for (const tag of prohibitedTags) {
removeTag(body, tag);
}
return doc.body.innerHTML;
}
export default removeProhibitedTags;

View File

@ -16,6 +16,7 @@
*/
type AnkiPackages =
| "anki/NoteEditor"
| "anki/PlainTextInput"
| "anki/TemplateButtons"
| "anki/packages"
| "anki/bridgecommand"

View File

@ -38,7 +38,8 @@ function lifecycleHooks<T>(): [LifecycleHooks<T>, T[], SetLifecycleHooksAction<T
}
}
instances.push(api);
// onMount seems to be called in reverse order
instances.unshift(api);
return () => {
for (const cleanup of cleanups) {