+
diff --git a/ts/editor/helpers.ts b/ts/editor/helpers.ts
index 9f1c79f2a..048f0953d 100644
--- a/ts/editor/helpers.ts
+++ b/ts/editor/helpers.ts
@@ -1,7 +1,9 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-/// trivial wrapper to silence Svelte deprecation warnings
+/**
+ * Trivial wrapper to silence Svelte deprecation warnings
+ */
export function execCommand(
command: string,
showUI?: boolean | undefined,
@@ -10,7 +12,28 @@ export function execCommand(
document.execCommand(command, showUI, value);
}
-/// trivial wrapper to silence Svelte deprecation warnings
+/**
+ * Trivial wrappers to silence Svelte deprecation warnings
+ */
export function queryCommandState(command: string): boolean {
return document.queryCommandState(command);
}
+
+function isFontElement(element: Element): element is HTMLFontElement {
+ return element.tagName === "FONT";
+}
+
+/**
+ * Avoid both HTMLFontElement and .color, as they are both deprecated
+ */
+export function withFontColor(
+ element: Element,
+ callback: (color: string) => void,
+): boolean {
+ if (isFontElement(element)) {
+ callback(element.color);
+ return true;
+ }
+
+ return false;
+}
diff --git a/ts/editor/rich-text-input/RichTextInput.svelte b/ts/editor/rich-text-input/RichTextInput.svelte
index 4a94c76ed..42e687c80 100644
--- a/ts/editor/rich-text-input/RichTextInput.svelte
+++ b/ts/editor/rich-text-input/RichTextInput.svelte
@@ -79,8 +79,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const { content, editingInputs } = editingAreaContext.get();
const decoratedElements = decoratedElementsContext.get();
- const range = document.createRange();
-
function normalizeFragment(fragment: DocumentFragment): void {
fragment.normalize();
@@ -110,8 +108,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function writeFromEditingArea(html: string): void {
- /* we need createContextualFragment so that customElements are initialized */
- const fragment = range.createContextualFragment(adjustInputHTML(html));
+ /* We need .createContextualFragment so that customElements are initialized */
+ const fragment = document
+ .createRange()
+ .createContextualFragment(adjustInputHTML(html));
adjustInputFragment(fragment);
nodes.setUnprocessed(fragment);
}
diff --git a/ts/editor/surround.ts b/ts/editor/surround.ts
index f47fed9ae..66c35da5f 100644
--- a/ts/editor/surround.ts
+++ b/ts/editor/surround.ts
@@ -3,15 +3,19 @@
import { get } from "svelte/store";
-import type { ElementClearer, ElementMatcher } from "../domlib/surround";
-import { findClosest, surroundNoSplitting, unsurround } from "../domlib/surround";
+import type { Matcher } from "../domlib/find-above";
+import { findClosest } from "../domlib/find-above";
+import type { SurroundFormat } from "../domlib/surround";
+import { boolMatcher, reformat, surround, unsurround } from "../domlib/surround";
import { getRange, getSelection } from "../lib/cross-browser";
+import { registerPackage } from "../lib/runtime-require";
+import type { OnInsertCallback, Trigger } from "../sveltelib/input-manager";
import type { RichTextInputAPI } from "./rich-text-input";
function isSurroundedInner(
range: AbstractRange,
base: HTMLElement,
- matcher: ElementMatcher,
+ matcher: Matcher,
): boolean {
return Boolean(
findClosest(range.startContainer, base, matcher) ||
@@ -19,37 +23,155 @@ function isSurroundedInner(
);
}
-function surroundAndSelect(
+function surroundAndSelect
(
matches: boolean,
range: Range,
- selection: Selection,
- surroundElement: Element,
base: HTMLElement,
- matcher: ElementMatcher,
- clearer: ElementClearer,
+ format: SurroundFormat,
+ selection: Selection,
): void {
- const { surroundedRange } = matches
- ? unsurround(range, surroundElement, base, matcher, clearer)
- : surroundNoSplitting(range, surroundElement, base, matcher, clearer);
+ const surroundedRange = matches
+ ? unsurround(range, base, format)
+ : surround(range, base, format);
selection.removeAllRanges();
selection.addRange(surroundedRange);
}
-export interface GetSurrounderResult {
- surroundCommand(
- surroundElement: Element,
- matcher: ElementMatcher,
- clearer?: ElementClearer,
- ): Promise;
- isSurrounded(matcher: ElementMatcher): Promise;
+function removeFormats(
+ range: Range,
+ base: Element,
+ formats: SurroundFormat[],
+ reformats: SurroundFormat[] = [],
+): Range {
+ let surroundRange = range;
+
+ for (const format of formats) {
+ surroundRange = unsurround(surroundRange, base, format);
+ }
+
+ for (const format of reformats) {
+ surroundRange = reformat(surroundRange, base, format);
+ }
+
+ return surroundRange;
}
-export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderResult {
- const { add, remove, active } = richTextInput.getTriggerOnNextInsert();
+export class Surrounder {
+ static make(): Surrounder {
+ return new Surrounder();
+ }
- async function isSurrounded(matcher: ElementMatcher): Promise {
- const base = await richTextInput.element;
+ private api: RichTextInputAPI | null = null;
+ private trigger: Trigger | null = null;
+
+ set richText(api: RichTextInputAPI) {
+ this.api = api;
+ this.trigger = api.getTriggerOnNextInsert();
+ }
+
+ /**
+ * After calling disable, using any of the surrounding methods will throw an
+ * exception. Make sure to set the rich text before trying to use them again.
+ */
+ disable(): void {
+ this.api = null;
+ this.trigger = null;
+ }
+
+ private async _assert_base(): Promise {
+ if (!this.api) {
+ throw new Error("No rich text set");
+ }
+
+ return await this.api.element;
+ }
+
+ private _toggleTrigger(
+ base: HTMLElement,
+ selection: Selection,
+ matcher: Matcher,
+ format: SurroundFormat,
+ exclusive: SurroundFormat[] = [],
+ ): void {
+ if (get(this.trigger!.active)) {
+ this.trigger!.remove();
+ } else {
+ this.trigger!.add(async ({ node }: { node: Node }) => {
+ const range = new Range();
+ range.selectNode(node);
+
+ const matches = Boolean(findClosest(node, base, matcher));
+ const clearedRange = removeFormats(range, base, exclusive);
+ surroundAndSelect(matches, clearedRange, base, format, selection);
+ selection.collapseToEnd();
+ });
+ }
+ }
+
+ /**
+ * Use the surround command on the current range of the RichTextInput.
+ * If the range is already surrounded, it will unsurround instead.
+ */
+ async surround(
+ format: SurroundFormat,
+ exclusive: SurroundFormat[] = [],
+ ): Promise {
+ const base = await this._assert_base();
+ const selection = getSelection(base)!;
+ const range = getRange(selection);
+ const matcher = boolMatcher(format);
+
+ if (!range) {
+ return;
+ }
+
+ if (range.collapsed) {
+ return this._toggleTrigger(base, selection, matcher, format, exclusive);
+ }
+
+ const clearedRange = removeFormats(range, base, exclusive);
+ const matches = isSurroundedInner(clearedRange, base, matcher);
+ surroundAndSelect(matches, clearedRange, base, format, selection);
+ }
+
+ /**
+ * Use the surround command on the current range of the RichTextInput.
+ * If the range is already surrounded, it will overwrite the format.
+ * This might be better suited if the surrounding is parameterized (like
+ * text color).
+ */
+ async overwriteSurround(
+ format: SurroundFormat,
+ exclusive: SurroundFormat[] = [],
+ ): Promise {
+ const base = await this._assert_base();
+ const selection = getSelection(base)!;
+ const range = getRange(selection);
+ const matcher = boolMatcher(format);
+
+ if (!range) {
+ return;
+ }
+
+ if (range.collapsed) {
+ return this._toggleTrigger(base, selection, matcher, format, exclusive);
+ }
+
+ const clearedRange = removeFormats(range, base, exclusive);
+ const surroundedRange = surround(clearedRange, base, format);
+ selection.removeAllRanges();
+ selection.addRange(surroundedRange);
+ }
+
+ /**
+ * Check if the current selection is surrounded. A selection will count as
+ * provided if either the start or the end boundary point are within the
+ * provided format, OR if a surround trigger is active (surround on next
+ * text insert).
+ */
+ async isSurrounded(format: SurroundFormat): Promise {
+ const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
@@ -57,58 +179,44 @@ export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderRes
return false;
}
- const isSurrounded = isSurroundedInner(range, base, matcher);
- return get(active) ? !isSurrounded : isSurrounded;
+ const isSurrounded = isSurroundedInner(range, base, boolMatcher(format));
+ return get(this.trigger!.active) ? !isSurrounded : isSurrounded;
}
- async function surroundCommand(
- surroundElement: Element,
- matcher: ElementMatcher,
- clearer: ElementClearer = () => false,
+ /**
+ * Clear/Reformat the provided formats in the current range.
+ */
+ async remove(
+ formats: SurroundFormat[],
+ reformats: SurroundFormat[] = [],
): Promise {
- const base = await richTextInput.element;
+ const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
- if (!range) {
+ if (!range || range.collapsed) {
return;
- } else if (range.collapsed) {
- if (get(active)) {
- remove();
- } else {
- add(async ({ node }: { node: Node }) => {
- range.selectNode(node);
-
- const matches = Boolean(findClosest(node, base, matcher));
- surroundAndSelect(
- matches,
- range,
- selection,
- surroundElement,
- base,
- matcher,
- clearer,
- );
-
- selection.collapseToEnd();
- });
- }
- } else {
- const matches = isSurroundedInner(range, base, matcher);
- surroundAndSelect(
- matches,
- range,
- selection,
- surroundElement,
- base,
- matcher,
- clearer,
- );
}
+
+ const surroundedRange = removeFormats(range, base, formats, reformats);
+ selection.removeAllRanges();
+ selection.addRange(surroundedRange);
+ }
+}
+
+/**
+ * @returns True, if element has no style attribute (anymore).
+ */
+export function removeEmptyStyle(element: HTMLElement | SVGElement): boolean {
+ if (element.style.cssText.length === 0) {
+ element.removeAttribute("style");
+ // Calling `.hasAttribute` right after `.removeAttribute` might return true.
+ return true;
}
- return {
- surroundCommand,
- isSurrounded,
- };
+ return false;
}
+
+registerPackage("anki/surround", {
+ Surrounder,
+});
diff --git a/ts/lib/dom.ts b/ts/lib/dom.ts
index 0163dfddb..1cf599050 100644
--- a/ts/lib/dom.ts
+++ b/ts/lib/dom.ts
@@ -7,10 +7,22 @@ export function nodeIsElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}
+/**
+ * In the web this is probably equivalent to `nodeIsElement`, but this is
+ * convenient to convince Typescript.
+ */
+export function nodeIsCommonElement(node: Node): node is HTMLElement | SVGElement {
+ return node instanceof HTMLElement || node instanceof SVGElement;
+}
+
export function nodeIsText(node: Node): node is Text {
return node.nodeType === Node.TEXT_NODE;
}
+export function nodeIsComment(node: Node): node is Comment {
+ return node.nodeType === Node.COMMENT_NODE;
+}
+
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
export const BLOCK_ELEMENTS = [
"ADDRESS",
diff --git a/ts/lib/keys.ts b/ts/lib/keys.ts
index 9c60666fe..7beda3508 100644
--- a/ts/lib/keys.ts
+++ b/ts/lib/keys.ts
@@ -56,6 +56,7 @@ const modifierPressed =
export const controlPressed = modifierPressed("Control");
export const shiftPressed = modifierPressed("Shift");
+export const altPressed = modifierPressed("Alt");
export function modifiersToPlatformString(modifiers: string[]): string {
const displayModifiers = isApplePlatform()