From 739e286b0b240a229734216a2226bd0fe26ad199 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Sat, 8 Jan 2022 02:46:01 +0100 Subject: [PATCH] Fix some Mathjax issues (#1547) * Move move-nodes logic into domlib Refactor input-manager Refactor out FocusTrap from EditingArea Remove unnecessary selecting of node from surround Add onInput interface to input-manager Create MathjaxElement.svelte - This should contain all the setup necessary for displaying elements in the rich text input - Does not contain setup necessary for Mathjax Overlay Deal with backwards deletion, when caret inside anki-mathjax Set mathjax elements contenteditable=false Do not undecorate mathjaxx element on disconnect - Fixes issues, where Mathjax might undecorate when it is moved into a different div Add framed element custom element Introduce iterateActions to allow global hooks for RichTextInput Remove some old code Deal with deletion of frame handles Make Anki frame and frame handles restore each other Make FrameElement restore its structure upon modification Frame and strip off framing from MathjaxElement automatically Move FrameHandle to separate FrameStart/FrameEnd Refactor FrameHandle Set data-frames on FrameElement Fix logic error connected to FrameElement Communicate frameHandle move{in,out} to anki-mathjax Clear selection when blurring ContentEditable Make sure frame is destroyed when undecorating Mathjax Use Hairline space instead of zeroWidth - it has better behavior in the contenteditable when placing the caret via clicking Allow detection of block elements with `block` attribute - This way, anki-mathjax block="true" will make field a field be recognized to have block content Make block frame element operater without handles - Clicking on the left/right side of a block mathjax will insert a br element to that side When deleting, remove mathajax-element not just image Update MathjaxButtons to correctly show block state SelectAll when moving into inline anki mathjax Remove CodeMirror autofocus functionality Move it to Mathjaxeditor directly Fix getRangeAt throwing error Update older code to use cross-browser Fix issue with FrameHandles not being properyly removed Satisfy formatting Use === instead of node.isSameNode() Fix issue of focusTrap not initialized * Fix after rebasing * Fix focus not being moved to first field * Add documentation for input-manager and iterate-actions * Export logic of ContentEditable to content-editable * Fix issue with inserting newline right next to inline Mathjax * Fix reframing issue of Mathjax Svelte component * Allow for deletion of Inline Mathjax again * Rename iterate-actions to action-list * Add copyright header * Split off frame-handle from frame-element * Add some comments for framing process * Add mising return types --- ts/domlib/location/selection.ts | 10 +- ts/domlib/move-nodes.ts | 66 ++++ ts/domlib/place-caret.ts | 30 +- ts/domlib/surround/ascend.ts | 2 +- ts/domlib/surround/find-above.ts | 8 +- ts/domlib/surround/merge-match.ts | 4 +- ts/editable/ContentEditable.svelte | 89 ++---- ts/editable/Mathjax.svelte | 34 ++- ts/editable/content-editable.ts | 79 +++++ ts/editable/frame-element.ts | 272 +++++++++++++++++ ts/editable/frame-handle.ts | 284 ++++++++++++++++++ ts/editable/mathjax-element.ts | 164 +++++----- ts/editor/CodeMirror.svelte | 10 +- ts/editor/DecoratedElements.svelte | 2 - ts/editor/EditingArea.svelte | 38 +-- ts/editor/FocusTrap.svelte | 37 +++ ts/editor/FrameElement.svelte | 20 ++ ts/editor/MathjaxElement.svelte | 15 + ts/editor/OldEditorAdapter.svelte | 79 ++--- ts/editor/PlainTextInput.svelte | 23 +- ts/editor/RichTextInput.svelte | 133 ++++---- ts/editor/TagStickyBadge.svelte | 15 + ts/editor/code-mirror.ts | 5 + .../mathjax-overlay/MathjaxButtons.svelte | 24 +- .../mathjax-overlay/MathjaxEditor.svelte | 55 +++- .../mathjax-overlay/MathjaxHandle.svelte | 97 +++--- ts/editor/mathjax-overlay/MathjaxMenu.svelte | 32 +- ts/editor/surround.ts | 14 +- ts/lib/cross-browser.ts | 18 +- ts/lib/dom.ts | 37 ++- ts/lib/functional.ts | 12 +- ts/lib/keys.ts | 4 + ts/lib/wrap.ts | 19 +- ts/sveltelib/action-list.ts | 39 +++ ts/sveltelib/input-manager.ts | 176 ++++++----- ts/sveltelib/mirror-dom.ts | 41 ++- 36 files changed, 1498 insertions(+), 489 deletions(-) create mode 100644 ts/domlib/move-nodes.ts create mode 100644 ts/editable/content-editable.ts create mode 100644 ts/editable/frame-element.ts create mode 100644 ts/editable/frame-handle.ts create mode 100644 ts/editor/FocusTrap.svelte create mode 100644 ts/editor/FrameElement.svelte create mode 100644 ts/editor/MathjaxElement.svelte create mode 100644 ts/editor/TagStickyBadge.svelte create mode 100644 ts/sveltelib/action-list.ts diff --git a/ts/domlib/location/selection.ts b/ts/domlib/location/selection.ts index ce18866dc..948b9e248 100644 --- a/ts/domlib/location/selection.ts +++ b/ts/domlib/location/selection.ts @@ -4,7 +4,7 @@ import { getNodeCoordinates } from "./node"; import type { CaretLocation } from "./location"; import { compareLocations, Position } from "./location"; -import { getSelection } from "../../lib/cross-browser"; +import { getSelection, getRange } from "../../lib/cross-browser"; export interface SelectionLocationCollapsed { readonly anchor: CaretLocation; @@ -20,19 +20,17 @@ export interface SelectionLocationContent { export type SelectionLocation = SelectionLocationCollapsed | SelectionLocationContent; -/* Gecko can have multiple ranges in the selection -/* this function will get the coordinates of the latest one created */ export function getSelectionLocation(base: Node): SelectionLocation | null { const selection = getSelection(base)!; + const range = getRange(selection); - if (selection.rangeCount === 0) { + if (!range) { return null; } + const collapsed = range.collapsed; const anchorCoordinates = getNodeCoordinates(selection.anchorNode!, base); const anchor = { coordinates: anchorCoordinates, offset: selection.anchorOffset }; - /* selection.isCollapsed will always return true in shadow root in Gecko */ - const collapsed = selection.getRangeAt(selection.rangeCount - 1).collapsed; if (collapsed) { return { anchor, collapsed }; diff --git a/ts/domlib/move-nodes.ts b/ts/domlib/move-nodes.ts new file mode 100644 index 000000000..3c072ab7c --- /dev/null +++ b/ts/domlib/move-nodes.ts @@ -0,0 +1,66 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { nodeIsElement, nodeIsText } from "../lib/dom"; +import { placeCaretAfter } from "./place-caret"; + +export function moveChildOutOfElement( + element: Element, + child: Node, + placement: "beforebegin" | "afterend", +): Node { + if (child.isConnected) { + child.parentNode!.removeChild(child); + } + + let referenceNode: Node; + + if (nodeIsElement(child)) { + referenceNode = element.insertAdjacentElement(placement, child)!; + } else if (nodeIsText(child)) { + element.insertAdjacentText(placement, child.wholeText); + referenceNode = + placement === "beforebegin" + ? element.previousSibling! + : element.nextSibling!; + } else { + throw "moveChildOutOfElement: unsupported"; + } + + return referenceNode; +} + +export function moveNodesInsertedOutside(element: Element, allowedChild: Node): void { + 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 = moveChildOutOfElement(element, node, "beforebegin"); + } + + for (const node of afterChildren) { + lastNode = moveChildOutOfElement(element, node, "afterend"); + } + + if (lastNode) { + placeCaretAfter(lastNode); + } +} diff --git a/ts/domlib/place-caret.ts b/ts/domlib/place-caret.ts index a5698c4b8..6a2b9a2f5 100644 --- a/ts/domlib/place-caret.ts +++ b/ts/domlib/place-caret.ts @@ -3,12 +3,32 @@ import { getSelection } from "../lib/cross-browser"; -export function placeCaretAfter(node: Node): void { - const range = new Range(); - range.setStartAfter(node); - range.collapse(false); - +function placeCaret(node: Node, range: Range): void { const selection = getSelection(node)!; selection.removeAllRanges(); selection.addRange(range); } + +export function placeCaretBefore(node: Node): void { + const range = new Range(); + range.setStartBefore(node); + range.collapse(true); + + placeCaret(node, range); +} + +export function placeCaretAfter(node: Node): void { + const range = new Range(); + range.setStartAfter(node); + range.collapse(true); + + placeCaret(node, range); +} + +export function placeCaretAfterContent(node: Node): void { + const range = new Range(); + range.selectNodeContents(node); + range.collapse(false); + + placeCaret(node, range); +} diff --git a/ts/domlib/surround/ascend.ts b/ts/domlib/surround/ascend.ts index 4874e74c0..8d86b34a9 100644 --- a/ts/domlib/surround/ascend.ts +++ b/ts/domlib/surround/ascend.ts @@ -5,7 +5,7 @@ import { ascend, isOnlyChild } from "../../lib/node"; import { elementIsBlock } from "../../lib/dom"; export function ascendWhileSingleInline(node: Node, base: Node): Node { - if (node.isSameNode(base)) { + if (node === base) { return node; } diff --git a/ts/domlib/surround/find-above.ts b/ts/domlib/surround/find-above.ts index ab9afb5e5..f3625bf4b 100644 --- a/ts/domlib/surround/find-above.ts +++ b/ts/domlib/surround/find-above.ts @@ -23,9 +23,7 @@ export function findClosest( } current = - current.isSameNode(base) || !current.parentElement - ? null - : current.parentElement; + current === base || !current.parentElement ? null : current.parentElement; } return current; @@ -51,9 +49,7 @@ export function findFarthest( } current = - current.isSameNode(base) || !current.parentElement - ? null - : current.parentElement; + current === base || !current.parentElement ? null : current.parentElement; } return found; diff --git a/ts/domlib/surround/merge-match.ts b/ts/domlib/surround/merge-match.ts index cc7792474..d8a1de7ea 100644 --- a/ts/domlib/surround/merge-match.ts +++ b/ts/domlib/surround/merge-match.ts @@ -48,7 +48,7 @@ const tryMergingTillMismatch = areSiblingChildNodeRanges( childNodeRange, nextChildNodeRange, - ) /* && !childNodeRange.parent.isSameNode(base)*/ + ) /* && !childNodeRange.parent === base */ ) { const mergedChildNodeRange = mergeChildNodeRanges( childNodeRange, @@ -57,7 +57,7 @@ const tryMergingTillMismatch = const newChildNodeRange = coversWholeParent(mergedChildNodeRange) && - !mergedChildNodeRange.parent.isSameNode(base) + mergedChildNodeRange.parent !== base ? nodeToChildNodeRange( ascendWhileSingleInline( mergedChildNodeRange.parent, diff --git a/ts/editable/ContentEditable.svelte b/ts/editable/ContentEditable.svelte index 8ae96d89c..3a4b39415 100644 --- a/ts/editable/ContentEditable.svelte +++ b/ts/editable/ContentEditable.svelte @@ -2,83 +2,48 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> + + anki-editable { display: block; - overflow-wrap: break-word; - overflow: auto; padding: 6px; + overflow: auto; + overflow-wrap: break-word; &:focus { outline: none; diff --git a/ts/editable/Mathjax.svelte b/ts/editable/Mathjax.svelte index 30f62f10e..fbf74cbbb 100644 --- a/ts/editable/Mathjax.svelte +++ b/ts/editable/Mathjax.svelte @@ -26,9 +26,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let mathjax: string; export let block: boolean; - - export let autofocus = false; - export let fontSize = 20; + export let fontSize: number; $: [converted, title] = convertMathjax(mathjax, $pageTheme.isDark, fontSize); $: empty = title === "MathJax"; @@ -40,19 +38,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: verticalCenter = -$imageHeight / 2 + fontSize / 4; - function maybeAutofocus(image: Element): void { - if (!autofocus) { - return; - } + let image: HTMLImageElement; + export function moveCaretAfter(): void { // This should trigger a focusing of the Mathjax Handle - const focusEvent = new CustomEvent("focusmathjax", { - detail: image, - bubbles: true, - composed: true, - }); + image.dispatchEvent( + new CustomEvent("movecaretafter", { + detail: image, + bubbles: true, + composed: true, + }), + ); + } - image.dispatchEvent(focusEvent); + export function selectAll(): void { + image.dispatchEvent( + new CustomEvent("selectall", { + detail: image, + bubbles: true, + composed: true, + }), + ); } function observe(image: Element) { @@ -69,6 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/ts/editable/content-editable.ts b/ts/editable/content-editable.ts new file mode 100644 index 000000000..6c755aa9b --- /dev/null +++ b/ts/editable/content-editable.ts @@ -0,0 +1,79 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { on, preventDefault } from "../lib/events"; +import { registerShortcut } from "../lib/shortcuts"; +import { placeCaretAfterContent } from "../domlib/place-caret"; +import { saveSelection, restoreSelection } from "../domlib/location"; +import type { SelectionLocation } from "../domlib/location"; + +const locationEvents: (() => void)[] = []; + +function flushLocation(): void { + let removeEvent: (() => void) | undefined; + + while ((removeEvent = locationEvents.pop())) { + removeEvent(); + } +} + +let latestLocation: SelectionLocation | null = null; + +function onFocus(this: HTMLElement): void { + if (!latestLocation) { + placeCaretAfterContent(this); + return; + } + + try { + restoreSelection(this, latestLocation); + } catch { + placeCaretAfterContent(this); + } +} + +function onBlur(this: HTMLElement): void { + prepareFocusHandling(this); + latestLocation = saveSelection(this); +} + +let removeOnFocus: () => void; + +export function prepareFocusHandling(editable: HTMLElement): void { + removeOnFocus = on(editable, "focus", onFocus, { once: true }); + + locationEvents.push( + removeOnFocus, + on(editable, "pointerdown", removeOnFocus, { once: true }), + ); +} + +/* Must execute before DOMMirror */ +export function saveLocation(editable: HTMLElement): { destroy(): void } { + const removeOnBlur = on(editable, "blur", onBlur); + + return { + destroy() { + removeOnBlur(); + flushLocation(); + }, + }; +} + +export function preventBuiltinContentEditableShortcuts(editable: HTMLElement): void { + for (const keyCombination of ["Control+B", "Control+U", "Control+I", "Control+R"]) { + registerShortcut(preventDefault, keyCombination, editable); + } +} + +/** API */ + +export interface ContentEditableAPI { + flushLocation(): void; +} + +const contentEditableApi: ContentEditableAPI = { + flushLocation, +}; + +export default contentEditableApi; diff --git a/ts/editable/frame-element.ts b/ts/editable/frame-element.ts new file mode 100644 index 000000000..5142528a2 --- /dev/null +++ b/ts/editable/frame-element.ts @@ -0,0 +1,272 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { + nodeIsText, + nodeIsElement, + elementIsBlock, + hasBlockAttribute, +} from "../lib/dom"; +import { on } from "../lib/events"; +import { getSelection } from "../lib/cross-browser"; +import { moveChildOutOfElement } from "../domlib/move-nodes"; +import { placeCaretBefore, placeCaretAfter } from "../domlib/place-caret"; +import { + frameElementTagName, + isFrameHandle, + checkWhetherMovingIntoHandle, + FrameStart, + FrameEnd, +} from "./frame-handle"; +import type { FrameHandle } from "./frame-handle"; + +function restoreFrameHandles(mutations: MutationRecord[]): void { + let referenceNode: Node | null = null; + + for (const mutation of mutations) { + const frameElement = mutation.target as FrameElement; + const framed = frameElement.querySelector(frameElement.frames!) as HTMLElement; + + for (const node of mutation.addedNodes) { + if (node === framed || isFrameHandle(node)) { + continue; + } + + /** + * In some rare cases, nodes might be inserted into the frame itself. + * For example after using execCommand. + */ + const placement = node.compareDocumentPosition(framed); + + if (placement & Node.DOCUMENT_POSITION_FOLLOWING) { + referenceNode = moveChildOutOfElement(frameElement, node, "afterend"); + continue; + } else if (placement & Node.DOCUMENT_POSITION_PRECEDING) { + referenceNode = moveChildOutOfElement( + frameElement, + node, + "beforebegin", + ); + continue; + } + } + + for (const node of mutation.removedNodes) { + if ( + /* avoid triggering when (un)mounting whole frame */ + mutations.length === 1 && + nodeIsElement(node) && + isFrameHandle(node) + ) { + /* When deleting from _outer_ position in FrameHandle to _inner_ position */ + frameElement.remove(); + continue; + } + + if ( + nodeIsElement(node) && + isFrameHandle(node) && + frameElement.isConnected && + !frameElement.block + ) { + frameElement.refreshHandles(); + continue; + } + } + } + + if (referenceNode) { + placeCaretAfter(referenceNode); + } +} + +const frameObserver = new MutationObserver(restoreFrameHandles); +const frameElements = new Set(); + +export class FrameElement extends HTMLElement { + static tagName = frameElementTagName; + + static get observedAttributes(): string[] { + return ["data-frames", "block"]; + } + + get framedElement(): HTMLElement | null { + return this.frames ? this.querySelector(this.frames) : null; + } + + frames?: string; + block: boolean; + + handleStart?: FrameStart; + handleEnd?: FrameEnd; + + constructor() { + super(); + this.block = hasBlockAttribute(this); + frameObserver.observe(this, { childList: true }); + } + + attributeChangedCallback(name: string, old: string, newValue: string): void { + if (newValue === old) { + return; + } + + switch (name) { + case "data-frames": + this.frames = newValue; + + if (!this.framedElement) { + this.remove(); + return; + } + break; + + case "block": + this.block = newValue !== "false"; + + if (!this.block) { + this.refreshHandles(); + } else { + this.removeHandles(); + } + + break; + } + } + + getHandleFrom(node: Element | null, start: boolean): FrameHandle { + const handle = isFrameHandle(node) + ? node + : (document.createElement( + start ? FrameStart.tagName : FrameEnd.tagName, + ) as FrameHandle); + + handle.dataset.frames = this.frames; + + return handle; + } + + refreshHandles(): void { + customElements.upgrade(this); + + this.handleStart = this.getHandleFrom(this.firstElementChild, true); + this.handleEnd = this.getHandleFrom(this.lastElementChild, false); + + if (!this.handleStart.isConnected) { + this.prepend(this.handleStart); + } + + if (!this.handleEnd.isConnected) { + this.append(this.handleEnd); + } + } + + removeHandles(): void { + this.handleStart?.remove(); + this.handleStart = undefined; + + this.handleEnd?.remove(); + this.handleEnd = undefined; + } + + removeStart?: () => void; + removeEnd?: () => void; + + addEventListeners(): void { + this.removeStart = on(this, "moveinstart" as keyof HTMLElementEventMap, () => + this.framedElement?.dispatchEvent(new Event("moveinstart")), + ); + + this.removeEnd = on(this, "moveinend" as keyof HTMLElementEventMap, () => + this.framedElement?.dispatchEvent(new Event("moveinend")), + ); + } + + removeEventListeners(): void { + this.removeStart?.(); + this.removeStart = undefined; + + this.removeEnd?.(); + this.removeEnd = undefined; + } + + connectedCallback(): void { + frameElements.add(this); + this.addEventListeners(); + } + + disconnectedCallback(): void { + frameElements.delete(this); + this.removeEventListeners(); + } + + insertLineBreak(offset: number): void { + const lineBreak = document.createElement("br"); + + if (offset === 0) { + const previous = this.previousSibling; + const focus = + previous && + (nodeIsText(previous) || + (nodeIsElement(previous) && !elementIsBlock(previous))) + ? previous + : this.insertAdjacentElement( + "beforebegin", + document.createElement("br"), + ); + + placeCaretAfter(focus ?? this); + } else if (offset === 1) { + const next = this.nextSibling; + + const focus = + next && + (nodeIsText(next) || (nodeIsElement(next) && !elementIsBlock(next))) + ? next + : this.insertAdjacentElement("afterend", lineBreak); + + placeCaretBefore(focus ?? this); + } + } +} + +function checkIfInsertingLineBreakAdjacentToBlockFrame() { + for (const frame of frameElements) { + if (!frame.block) { + continue; + } + + const selection = getSelection(frame)!; + + if (selection.anchorNode === frame.framedElement && selection.isCollapsed) { + frame.insertLineBreak(selection.anchorOffset); + } + } +} + +function onSelectionChange() { + checkWhetherMovingIntoHandle(); + checkIfInsertingLineBreakAdjacentToBlockFrame(); +} + +document.addEventListener("selectionchange", onSelectionChange); + +/** + * This function wraps an element into a "frame", which looks like this: + * + * + * + * + * + */ +export function frameElement(element: HTMLElement, block: boolean): FrameElement { + const frame = document.createElement(FrameElement.tagName) as FrameElement; + frame.setAttribute("block", String(block)); + frame.dataset.frames = element.tagName.toLowerCase(); + + const range = new Range(); + range.selectNode(element); + range.surroundContents(frame); + + return frame; +} diff --git a/ts/editable/frame-handle.ts b/ts/editable/frame-handle.ts new file mode 100644 index 000000000..5a5fca240 --- /dev/null +++ b/ts/editable/frame-handle.ts @@ -0,0 +1,284 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { nodeIsText, nodeIsElement, elementIsEmpty } from "../lib/dom"; +import { on } from "../lib/events"; +import { getSelection } from "../lib/cross-browser"; +import { moveChildOutOfElement } from "../domlib/move-nodes"; +import { placeCaretAfter } from "../domlib/place-caret"; +import type { FrameElement } from "./frame-element"; + +/** + * The frame handle also needs some awareness that it's hosted below + * the frame + */ +export const frameElementTagName = "anki-frame"; + +/** + * I originally used a zero width space, however, in contentEditable, if + * a line ends in a zero width space, and you click _after_ the line, + * the caret will be placed _before_ the zero width space. + * Instead I use a hairline space. + */ +const spaceCharacter = "\u200a"; +const spaceRegex = /[\u200a]/g; + +export function isFrameHandle(node: unknown): node is FrameHandle { + return node instanceof FrameHandle; +} + +function skippableNode(handleElement: FrameHandle, node: Node): boolean { + /** + * We only want to move nodes, which are direct descendants of the FrameHandle + * MutationRecords however might include nodes which were directly removed again + */ + return ( + (nodeIsText(node) && + (node.data === spaceCharacter || node.data.length === 0)) || + !Array.prototype.includes.call(handleElement.childNodes, node) + ); +} + +function restoreHandleContent(mutations: MutationRecord[]): void { + let referenceNode: Node | null = null; + + for (const mutation of mutations) { + const target = mutation.target; + + if (mutation.type === "childList") { + if (!isFrameHandle(target)) { + /* nested insertion */ + continue; + } + + const handleElement = target; + const placement = + handleElement instanceof FrameStart ? "beforebegin" : "afterend"; + const frameElement = handleElement.parentElement as FrameElement; + + for (const node of mutation.addedNodes) { + if (skippableNode(handleElement, node)) { + continue; + } + + if ( + nodeIsElement(node) && + !elementIsEmpty(node) && + (node.textContent === spaceCharacter || + node.textContent?.length === 0) + ) { + /** + * When we surround the spaceCharacter of the frame handle + */ + node.replaceWith(new Text(spaceCharacter)); + } else { + referenceNode = moveChildOutOfElement( + frameElement, + node, + placement, + ); + } + } + } else if (mutation.type === "characterData") { + if ( + !nodeIsText(target) || + !isFrameHandle(target.parentElement) || + skippableNode(target.parentElement, target) + ) { + continue; + } + + const handleElement = target.parentElement; + const placement = + handleElement instanceof FrameStart ? "beforebegin" : "afterend"; + const frameElement = handleElement.parentElement! as FrameElement; + + const cleaned = target.data.replace(spaceRegex, ""); + const text = new Text(cleaned); + + if (placement === "beforebegin") { + frameElement.before(text); + } else { + frameElement.after(text); + } + + handleElement.refreshSpace(); + referenceNode = text; + } + } + + if (referenceNode) { + placeCaretAfter(referenceNode); + } +} + +const handleObserver = new MutationObserver(restoreHandleContent); +const handles: Set = new Set(); + +export abstract class FrameHandle extends HTMLElement { + static get observedAttributes(): string[] { + return ["data-frames"]; + } + + frames?: string; + + constructor() { + super(); + handleObserver.observe(this, { + childList: true, + subtree: true, + characterData: true, + }); + } + + attributeChangedCallback(name: string, old: string, newValue: string): void { + if (newValue === old) { + return; + } + + switch (name) { + case "data-frames": + this.frames = newValue; + break; + } + } + + abstract getFrameRange(): Range; + + invalidSpace(): boolean { + return ( + !this.firstChild || + !(nodeIsText(this.firstChild) && this.firstChild.data === spaceCharacter) + ); + } + + refreshSpace(): void { + while (this.firstChild) { + this.removeChild(this.firstChild); + } + + this.append(new Text(spaceCharacter)); + } + + hostedUnderFrame(): boolean { + return this.parentElement!.tagName === frameElementTagName.toUpperCase(); + } + + connectedCallback(): void { + if (this.invalidSpace()) { + this.refreshSpace(); + } + + if (!this.hostedUnderFrame()) { + const range = this.getFrameRange(); + + const frameElement = document.createElement( + frameElementTagName, + ) as FrameElement; + frameElement.dataset.frames = this.frames; + + range.surroundContents(frameElement); + } + + handles.add(this); + } + + removeMoveIn?: () => void; + + disconnectedCallback(): void { + handles.delete(this); + + this.removeMoveIn?.(); + this.removeMoveIn = undefined; + } + + abstract notifyMoveIn(offset: number): void; +} + +export class FrameStart extends FrameHandle { + static tagName = "frame-start"; + + getFrameRange(): Range { + const range = new Range(); + range.setStartBefore(this); + + const maybeFramed = this.nextElementSibling; + + if (maybeFramed?.matches(this.frames ?? ":not(*)")) { + const maybeHandleEnd = maybeFramed.nextElementSibling; + + range.setEndAfter( + maybeHandleEnd?.tagName.toLowerCase() === FrameStart.tagName + ? maybeHandleEnd + : maybeFramed, + ); + } else { + range.setEndAfter(this); + } + + return range; + } + + notifyMoveIn(offset: number): void { + if (offset === 1) { + this.dispatchEvent(new Event("movein")); + } + } + + connectedCallback(): void { + super.connectedCallback(); + + this.removeMoveIn = on(this, "movein" as keyof HTMLElementEventMap, () => + this.parentElement?.dispatchEvent(new Event("moveinstart")), + ); + } +} + +export class FrameEnd extends FrameHandle { + static tagName = "frame-end"; + + getFrameRange(): Range { + const range = new Range(); + range.setEndAfter(this); + + const maybeFramed = this.previousElementSibling; + + if (maybeFramed?.matches(this.frames ?? ":not(*)")) { + const maybeHandleStart = maybeFramed.previousElementSibling; + + range.setEndAfter( + maybeHandleStart?.tagName.toLowerCase() === FrameEnd.tagName + ? maybeHandleStart + : maybeFramed, + ); + } else { + range.setStartBefore(this); + } + + return range; + } + + notifyMoveIn(offset: number): void { + if (offset === 0) { + this.dispatchEvent(new Event("movein")); + } + } + + connectedCallback(): void { + super.connectedCallback(); + + this.removeMoveIn = on(this, "movein" as keyof HTMLElementEventMap, () => + this.parentElement?.dispatchEvent(new Event("moveinend")), + ); + } +} + +export function checkWhetherMovingIntoHandle(): void { + for (const handle of handles) { + const selection = getSelection(handle)!; + + if (selection.anchorNode === handle.firstChild && selection.isCollapsed) { + handle.notifyMoveIn(selection.anchorOffset); + } + } +} diff --git a/ts/editable/mathjax-element.ts b/ts/editable/mathjax-element.ts index 91e5e9715..627af513e 100644 --- a/ts/editable/mathjax-element.ts +++ b/ts/editable/mathjax-element.ts @@ -1,81 +1,15 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -/* eslint -@typescript-eslint/no-explicit-any: "off", - */ - import "mathjax/es5/tex-svg-full"; +import { on } from "../lib/events"; +import { placeCaretBefore, placeCaretAfter } from "../domlib/place-caret"; import type { DecoratedElement, DecoratedElementConstructor } from "./decorated"; -import { nodeIsElement } from "../lib/dom"; -import { noop } from "../lib/functional"; -import { placeCaretAfter } from "../domlib/place-caret"; +import { FrameElement, frameElement } from "./frame-element"; 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 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 = /]*?block="(.*?)")?[^>]*?>(.*?)<\/anki-mathjax>/gsu; @@ -104,17 +38,17 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax .replace( mathjaxBlockDelimiterPattern, (_match: string, text: string) => - `${text}`, + `<${Mathjax.tagName} block="true">${text}`, ) .replace( mathjaxInlineDelimiterPattern, (_match: string, text: string) => - `${text}`, + `<${Mathjax.tagName}>${text}`, ); } block = false; - disconnect = noop; + frame?: FrameElement; component?: Mathjax_svelte; static get observedAttributes(): string[] { @@ -123,19 +57,25 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax connectedCallback(): void { this.decorate(); - this.disconnect = moveNodesInsertedOutside(this, this.children[0]); + this.addEventListeners(); } disconnectedCallback(): void { - this.disconnect(); + this.removeEventListeners(); } - attributeChangedCallback(name: string, _old: string, newValue: string): void { + attributeChangedCallback(name: string, old: string, newValue: string): void { + if (newValue === old) { + return; + } + switch (name) { case "block": this.block = newValue !== "false"; this.component?.$set({ block: this.block }); + this.frame?.setAttribute("block", String(this.block)); break; + case "data-mathjax": this.component?.$set({ mathjax: newValue }); break; @@ -143,33 +83,93 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax } decorate(): void { - const mathjax = (this.dataset.mathjax = this.innerText); + if (this.hasAttribute("decorated")) { + this.undecorate(); + } + + if (this.parentElement?.tagName === FrameElement.tagName.toUpperCase()) { + this.frame = this.parentElement as FrameElement; + } else { + frameElement(this, this.block); + /* Framing will place this element inside of an anki-frame element, + * causing the connectedCallback to be called again. + * If we'd continue decorating at this point, we'd loose all the information */ + return; + } + + this.dataset.mathjax = this.innerText; this.innerHTML = ""; this.style.whiteSpace = "normal"; this.component = new Mathjax_svelte({ target: this, props: { - mathjax, + mathjax: this.dataset.mathjax, block: this.block, - autofocus: this.hasAttribute("focusonmount"), + fontSize: 20, }, - } as any); + }); + + if (this.hasAttribute("focusonmount")) { + this.component.moveCaretAfter(); + } + + this.setAttribute("contentEditable", "false"); + this.setAttribute("decorated", "true"); } undecorate(): void { + if (this.parentElement?.tagName === FrameElement.tagName.toUpperCase()) { + this.parentElement.replaceWith(this); + } + 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"); } + + this.removeAttribute("contentEditable"); + this.removeAttribute("decorated"); + } + + removeMoveInStart?: () => void; + removeMoveInEnd?: () => void; + + addEventListeners(): void { + this.removeMoveInStart = on( + this, + "moveinstart" as keyof HTMLElementEventMap, + () => this.component!.selectAll(), + ); + + this.removeMoveInEnd = on(this, "moveinend" as keyof HTMLElementEventMap, () => + this.component!.selectAll(), + ); + } + + removeEventListeners(): void { + this.removeMoveInStart?.(); + this.removeMoveInStart = undefined; + + this.removeMoveInEnd?.(); + this.removeMoveInEnd = undefined; + } + + placeCaretBefore(): void { + if (this.frame) { + placeCaretBefore(this.frame); + } + } + + placeCaretAfter(): void { + if (this.frame) { + placeCaretAfter(this.frame); + } } }; diff --git a/ts/editor/CodeMirror.svelte b/ts/editor/CodeMirror.svelte index b4dfd7b1e..000251c67 100644 --- a/ts/editor/CodeMirror.svelte +++ b/ts/editor/CodeMirror.svelte @@ -11,14 +11,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
diff --git a/ts/editor/DecoratedElements.svelte b/ts/editor/DecoratedElements.svelte index f95a488ca..fa1b3c476 100644 --- a/ts/editor/DecoratedElements.svelte +++ b/ts/editor/DecoratedElements.svelte @@ -7,11 +7,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html CustomElementArray, DecoratedElementConstructor, } from "../editable/decorated"; - import { Mathjax } from "../editable/mathjax-element"; import contextProperty from "../sveltelib/context-property"; const decoratedElements = new CustomElementArray(); - decoratedElements.push(Mathjax); const key = Symbol("decoratedElements"); const [set, getDecoratedElements, hasDecoratedElements] = diff --git a/ts/editor/EditingArea.svelte b/ts/editor/EditingArea.svelte index 94241c0ae..168fe06ac 100644 --- a/ts/editor/EditingArea.svelte +++ b/ts/editor/EditingArea.svelte @@ -28,6 +28,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - +
@@ -144,6 +144,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/ts/editor/FocusTrap.svelte b/ts/editor/FocusTrap.svelte new file mode 100644 index 000000000..2b9b4e792 --- /dev/null +++ b/ts/editor/FocusTrap.svelte @@ -0,0 +1,37 @@ + + + + + + diff --git a/ts/editor/FrameElement.svelte b/ts/editor/FrameElement.svelte new file mode 100644 index 000000000..15b08adcd --- /dev/null +++ b/ts/editor/FrameElement.svelte @@ -0,0 +1,20 @@ + + diff --git a/ts/editor/MathjaxElement.svelte b/ts/editor/MathjaxElement.svelte new file mode 100644 index 000000000..8e6794248 --- /dev/null +++ b/ts/editor/MathjaxElement.svelte @@ -0,0 +1,15 @@ + + diff --git a/ts/editor/OldEditorAdapter.svelte b/ts/editor/OldEditorAdapter.svelte index 306b86b3a..fd88db316 100644 --- a/ts/editor/OldEditorAdapter.svelte +++ b/ts/editor/OldEditorAdapter.svelte @@ -57,6 +57,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { MathjaxHandle } from "./mathjax-overlay"; import { ImageHandle } from "./image-overlay"; import PlainTextInput from "./PlainTextInput.svelte"; + import MathjaxElement from "./MathjaxElement.svelte"; + import FrameElement from "./FrameElement.svelte"; import RichTextBadge from "./RichTextBadge.svelte"; import PlainTextBadge from "./PlainTextBadge.svelte"; @@ -279,39 +281,41 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {/if} - {#each fieldsData as field, index} - { - $currentField = fields[index]; - bridgeCommand(`focus:${index}`); - }} - on:focusout={() => { - $currentField = null; - bridgeCommand( - `blur:${index}:${getNoteId()}:${get(fieldStores[index])}`, - ); - }} - --label-color={cols[index] === "dupe" - ? "var(--flag1-bg)" - : "transparent"} - > - - {#if cols[index] === "dupe"} - - {/if} - - - {#if stickies} - - {/if} - + + {#each fieldsData as field, index} + { + $currentField = fields[index]; + bridgeCommand(`focus:${index}`); + }} + on:focusout={() => { + $currentField = null; + bridgeCommand( + `blur:${index}:${getNoteId()}:${get( + fieldStores[index], + )}`, + ); + }} + --label-color={cols[index] === "dupe" + ? "var(--flag1-bg)" + : "transparent"} + > + + {#if cols[index] === "dupe"} + + {/if} + + + {#if stickies} + + {/if} + - - + - - - {/each} + + + {/each} + + + + diff --git a/ts/editor/PlainTextInput.svelte b/ts/editor/PlainTextInput.svelte index efb0474e4..146d62393 100644 --- a/ts/editor/PlainTextInput.svelte +++ b/ts/editor/PlainTextInput.svelte @@ -12,6 +12,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html toggle(): boolean; getEditor(): CodeMirror.Editor; } + + export const parsingInstructions: string[] = []; - -
+
+ +
-
- {#await Promise.all([richTextPromise, stylesPromise]) then [container, styles]} - - - - {/await} -
- +
+ {#await Promise.all( [richTextPromise, stylesPromise], ) then [container, styles]} + + + + {/await} +
+ +