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} +
+ +