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
  <anki-mathjax> 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
This commit is contained in:
Henrik Giesel 2022-01-08 02:46:01 +01:00 committed by GitHub
parent a6c65efd36
commit 739e286b0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1498 additions and 489 deletions

View file

@ -4,7 +4,7 @@
import { getNodeCoordinates } from "./node"; import { getNodeCoordinates } from "./node";
import type { CaretLocation } from "./location"; import type { CaretLocation } from "./location";
import { compareLocations, Position } from "./location"; import { compareLocations, Position } from "./location";
import { getSelection } from "../../lib/cross-browser"; import { getSelection, getRange } from "../../lib/cross-browser";
export interface SelectionLocationCollapsed { export interface SelectionLocationCollapsed {
readonly anchor: CaretLocation; readonly anchor: CaretLocation;
@ -20,19 +20,17 @@ export interface SelectionLocationContent {
export type SelectionLocation = SelectionLocationCollapsed | 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 { export function getSelectionLocation(base: Node): SelectionLocation | null {
const selection = getSelection(base)!; const selection = getSelection(base)!;
const range = getRange(selection);
if (selection.rangeCount === 0) { if (!range) {
return null; return null;
} }
const collapsed = range.collapsed;
const anchorCoordinates = getNodeCoordinates(selection.anchorNode!, base); const anchorCoordinates = getNodeCoordinates(selection.anchorNode!, base);
const anchor = { coordinates: anchorCoordinates, offset: selection.anchorOffset }; 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) { if (collapsed) {
return { anchor, collapsed }; return { anchor, collapsed };

66
ts/domlib/move-nodes.ts Normal file
View file

@ -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);
}
}

View file

@ -3,12 +3,32 @@
import { getSelection } from "../lib/cross-browser"; import { getSelection } from "../lib/cross-browser";
export function placeCaretAfter(node: Node): void { function placeCaret(node: Node, range: Range): void {
const range = new Range();
range.setStartAfter(node);
range.collapse(false);
const selection = getSelection(node)!; const selection = getSelection(node)!;
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); 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);
}

View file

@ -5,7 +5,7 @@ import { ascend, isOnlyChild } from "../../lib/node";
import { elementIsBlock } from "../../lib/dom"; import { elementIsBlock } from "../../lib/dom";
export function ascendWhileSingleInline(node: Node, base: Node): Node { export function ascendWhileSingleInline(node: Node, base: Node): Node {
if (node.isSameNode(base)) { if (node === base) {
return node; return node;
} }

View file

@ -23,9 +23,7 @@ export function findClosest(
} }
current = current =
current.isSameNode(base) || !current.parentElement current === base || !current.parentElement ? null : current.parentElement;
? null
: current.parentElement;
} }
return current; return current;
@ -51,9 +49,7 @@ export function findFarthest(
} }
current = current =
current.isSameNode(base) || !current.parentElement current === base || !current.parentElement ? null : current.parentElement;
? null
: current.parentElement;
} }
return found; return found;

View file

@ -48,7 +48,7 @@ const tryMergingTillMismatch =
areSiblingChildNodeRanges( areSiblingChildNodeRanges(
childNodeRange, childNodeRange,
nextChildNodeRange, nextChildNodeRange,
) /* && !childNodeRange.parent.isSameNode(base)*/ ) /* && !childNodeRange.parent === base */
) { ) {
const mergedChildNodeRange = mergeChildNodeRanges( const mergedChildNodeRange = mergeChildNodeRanges(
childNodeRange, childNodeRange,
@ -57,7 +57,7 @@ const tryMergingTillMismatch =
const newChildNodeRange = const newChildNodeRange =
coversWholeParent(mergedChildNodeRange) && coversWholeParent(mergedChildNodeRange) &&
!mergedChildNodeRange.parent.isSameNode(base) mergedChildNodeRange.parent !== base
? nodeToChildNodeRange( ? nodeToChildNodeRange(
ascendWhileSingleInline( ascendWhileSingleInline(
mergedChildNodeRange.parent, mergedChildNodeRange.parent,

View file

@ -2,83 +2,48 @@
Copyright: Ankitects Pty Ltd and contributors Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script context="module" lang="ts">
export type { ContentEditableAPI } from "./content-editable";
</script>
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { updateAllState } from "../components/WithState.svelte"; import { updateAllState } from "../components/WithState.svelte";
import { saveSelection, restoreSelection } from "../domlib/location"; import actionList from "../sveltelib/action-list";
import { on, preventDefault } from "../lib/events"; import contentEditableAPI, {
import { caretToEnd } from "../lib/dom"; saveLocation,
import { registerShortcut } from "../lib/shortcuts"; prepareFocusHandling,
preventBuiltinContentEditableShortcuts,
} from "./content-editable";
import type { ContentEditableAPI } from "./content-editable";
import type { MirrorAction } from "../sveltelib/mirror-dom";
import type { InputManagerAction } from "../sveltelib/input-manager";
export let nodes: Writable<DocumentFragment>;
export let resolve: (editable: HTMLElement) => void; export let resolve: (editable: HTMLElement) => void;
export let mirror: (
editable: HTMLElement,
params: { store: Writable<DocumentFragment> },
) => void;
export let inputManager: (editable: HTMLElement) => void; export let mirrors: MirrorAction[];
export let nodes: Writable<DocumentFragment>;
let removeOnFocus: () => void; const mirrorAction = actionList(mirrors);
let removeOnPointerdown: () => void; const mirrorOptions = { store: nodes };
function onBlur(): void { export let managers: InputManagerAction[];
const location = saveSelection(editable);
removeOnFocus = on( const managerAction = actionList(managers);
editable,
"focus",
() => {
if (location) {
try {
restoreSelection(editable, location);
} catch {
caretToEnd(editable);
}
}
},
{ once: true },
);
removeOnPointerdown = on(editable, "pointerdown", () => removeOnFocus?.(), { export let api: Partial<ContentEditableAPI>;
once: true,
});
}
/* must execute before DOMMirror */ Object.assign(api, contentEditableAPI);
function saveLocation(editable: HTMLElement) {
const removeOnBlur = on(editable, "blur", onBlur);
return {
destroy() {
removeOnBlur();
removeOnFocus?.();
removeOnPointerdown?.();
},
};
}
let editable: HTMLElement;
$: if (editable) {
for (const keyCombination of [
"Control+B",
"Control+U",
"Control+I",
"Control+R",
]) {
registerShortcut(preventDefault, keyCombination, editable);
}
}
</script> </script>
<anki-editable <anki-editable
contenteditable="true" contenteditable="true"
bind:this={editable}
use:resolve use:resolve
use:saveLocation use:saveLocation
use:mirror={{ store: nodes }} use:prepareFocusHandling
use:inputManager use:preventBuiltinContentEditableShortcuts
use:mirrorAction={mirrorOptions}
use:managerAction={{}}
on:focus on:focus
on:blur on:blur
on:click={updateAllState} on:click={updateAllState}
@ -88,9 +53,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss"> <style lang="scss">
anki-editable { anki-editable {
display: block; display: block;
overflow-wrap: break-word;
overflow: auto;
padding: 6px; padding: 6px;
overflow: auto;
overflow-wrap: break-word;
&:focus { &:focus {
outline: none; outline: none;

View file

@ -26,9 +26,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let mathjax: string; export let mathjax: string;
export let block: boolean; export let block: boolean;
export let fontSize: number;
export let autofocus = false;
export let fontSize = 20;
$: [converted, title] = convertMathjax(mathjax, $pageTheme.isDark, fontSize); $: [converted, title] = convertMathjax(mathjax, $pageTheme.isDark, fontSize);
$: empty = title === "MathJax"; $: 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; $: verticalCenter = -$imageHeight / 2 + fontSize / 4;
function maybeAutofocus(image: Element): void { let image: HTMLImageElement;
if (!autofocus) {
return;
}
export function moveCaretAfter(): void {
// This should trigger a focusing of the Mathjax Handle // This should trigger a focusing of the Mathjax Handle
const focusEvent = new CustomEvent("focusmathjax", { image.dispatchEvent(
detail: image, new CustomEvent("movecaretafter", {
bubbles: true, detail: image,
composed: true, 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) { function observe(image: Element) {
@ -69,6 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<img <img
bind:this={image}
src="data:image/svg+xml,{encoded}" src="data:image/svg+xml,{encoded}"
class:block class:block
class:empty class:empty
@ -78,7 +85,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
data-anki="mathjax" data-anki="mathjax"
data-uuid={uuid} data-uuid={uuid}
on:dragstart|preventDefault on:dragstart|preventDefault
use:maybeAutofocus
use:observe use:observe
/> />

View file

@ -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;

View file

@ -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<FrameElement>();
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:
* <anki-frame>
* <frame-handle-start> </frame-handle-start>
* <your-element ... />
* <frame-handle-end> </frame-handle-start>
* </anki-frame>
*/
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;
}

284
ts/editable/frame-handle.ts Normal file
View file

@ -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<FrameHandle> = 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);
}
}
}

View file

@ -1,81 +1,15 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // 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 "mathjax/es5/tex-svg-full";
import { on } from "../lib/events";
import { placeCaretBefore, placeCaretAfter } from "../domlib/place-caret";
import type { DecoratedElement, DecoratedElementConstructor } from "./decorated"; import type { DecoratedElement, DecoratedElementConstructor } from "./decorated";
import { nodeIsElement } from "../lib/dom"; import { FrameElement, frameElement } from "./frame-element";
import { noop } from "../lib/functional";
import { placeCaretAfter } from "../domlib/place-caret";
import Mathjax_svelte from "./Mathjax.svelte"; 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 = const mathjaxTagPattern =
/<anki-mathjax(?:[^>]*?block="(.*?)")?[^>]*?>(.*?)<\/anki-mathjax>/gsu; /<anki-mathjax(?:[^>]*?block="(.*?)")?[^>]*?>(.*?)<\/anki-mathjax>/gsu;
@ -104,17 +38,17 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
.replace( .replace(
mathjaxBlockDelimiterPattern, mathjaxBlockDelimiterPattern,
(_match: string, text: string) => (_match: string, text: string) =>
`<anki-mathjax block="true">${text}</anki-mathjax>`, `<${Mathjax.tagName} block="true">${text}</${Mathjax.tagName}>`,
) )
.replace( .replace(
mathjaxInlineDelimiterPattern, mathjaxInlineDelimiterPattern,
(_match: string, text: string) => (_match: string, text: string) =>
`<anki-mathjax>${text}</anki-mathjax>`, `<${Mathjax.tagName}>${text}</${Mathjax.tagName}>`,
); );
} }
block = false; block = false;
disconnect = noop; frame?: FrameElement;
component?: Mathjax_svelte; component?: Mathjax_svelte;
static get observedAttributes(): string[] { static get observedAttributes(): string[] {
@ -123,19 +57,25 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
connectedCallback(): void { connectedCallback(): void {
this.decorate(); this.decorate();
this.disconnect = moveNodesInsertedOutside(this, this.children[0]); this.addEventListeners();
} }
disconnectedCallback(): void { 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) { switch (name) {
case "block": case "block":
this.block = newValue !== "false"; this.block = newValue !== "false";
this.component?.$set({ block: this.block }); this.component?.$set({ block: this.block });
this.frame?.setAttribute("block", String(this.block));
break; break;
case "data-mathjax": case "data-mathjax":
this.component?.$set({ mathjax: newValue }); this.component?.$set({ mathjax: newValue });
break; break;
@ -143,33 +83,93 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
} }
decorate(): void { 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.innerHTML = "";
this.style.whiteSpace = "normal"; this.style.whiteSpace = "normal";
this.component = new Mathjax_svelte({ this.component = new Mathjax_svelte({
target: this, target: this,
props: { props: {
mathjax, mathjax: this.dataset.mathjax,
block: this.block, 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 { undecorate(): void {
if (this.parentElement?.tagName === FrameElement.tagName.toUpperCase()) {
this.parentElement.replaceWith(this);
}
this.innerHTML = this.dataset.mathjax ?? ""; this.innerHTML = this.dataset.mathjax ?? "";
delete this.dataset.mathjax; delete this.dataset.mathjax;
this.removeAttribute("style"); this.removeAttribute("style");
this.removeAttribute("focusonmount"); this.removeAttribute("focusonmount");
this.component?.$destroy();
this.component = undefined;
if (this.block) { if (this.block) {
this.setAttribute("block", "true"); this.setAttribute("block", "true");
} else { } else {
this.removeAttribute("block"); 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);
}
} }
}; };

View file

@ -11,14 +11,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, getContext, onMount } from "svelte"; import { createEventDispatcher, getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import storeSubscribe from "../sveltelib/store-subscribe"; import storeSubscribe from "../sveltelib/store-subscribe";
import { directionKey } from "../lib/context-keys"; import { directionKey } from "../lib/context-keys";
export let configuration: CodeMirror.EditorConfiguration; export let configuration: CodeMirror.EditorConfiguration;
export let code: Writable<string>; export let code: Writable<string>;
export let autofocus = false;
const direction = getContext<Writable<"ltr" | "rtl">>(directionKey); const direction = getContext<Writable<"ltr" | "rtl">>(directionKey);
const defaultConfiguration = { const defaultConfiguration = {
@ -78,13 +77,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
editor: { get: () => codeMirror }, editor: { get: () => codeMirror },
}, },
) as CodeMirrorAPI; ) as CodeMirrorAPI;
onMount(() => {
if (autofocus) {
codeMirror.focus();
codeMirror.setCursor(codeMirror.lineCount(), 0);
}
});
</script> </script>
<div class="code-mirror"> <div class="code-mirror">

View file

@ -7,11 +7,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
CustomElementArray, CustomElementArray,
DecoratedElementConstructor, DecoratedElementConstructor,
} from "../editable/decorated"; } from "../editable/decorated";
import { Mathjax } from "../editable/mathjax-element";
import contextProperty from "../sveltelib/context-property"; import contextProperty from "../sveltelib/context-property";
const decoratedElements = new CustomElementArray<DecoratedElementConstructor>(); const decoratedElements = new CustomElementArray<DecoratedElementConstructor>();
decoratedElements.push(Mathjax);
const key = Symbol("decoratedElements"); const key = Symbol("decoratedElements");
const [set, getDecoratedElements, hasDecoratedElements] = const [set, getDecoratedElements, hasDecoratedElements] =

View file

@ -28,6 +28,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<script lang="ts"> <script lang="ts">
import FocusTrap from "./FocusTrap.svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { onMount, setContext as svelteSetContext } from "svelte"; import { onMount, setContext as svelteSetContext } from "svelte";
import { fontFamilyKey, fontSizeKey } from "../lib/context-keys"; import { fontFamilyKey, fontSizeKey } from "../lib/context-keys";
@ -46,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let autofocus = false; export let autofocus = false;
let editingArea: HTMLElement; let editingArea: HTMLElement;
let focusTrap: HTMLInputElement; let focusTrap: FocusTrap;
const inputsStore = writable<EditingInputAPI[]>([]); const inputsStore = writable<EditingInputAPI[]>([]);
$: editingInputs = $inputsStore; $: editingInputs = $inputsStore;
@ -67,13 +68,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
function focusEditingInputIfFocusTrapFocused(): void { function focusEditingInputIfFocusTrapFocused(): void {
if (document.activeElement === focusTrap) { if (focusTrap && focusTrap.isFocusTrap(document.activeElement!)) {
focusEditingInputIfAvailable(); focusEditingInputIfAvailable();
} }
} }
$: { $: {
$inputsStore; $inputsStore;
/**
* Triggers when all editing inputs are hidden,
* the editor field has focus, and then some
* editing input is shown
*/
focusEditingInputIfFocusTrapFocused(); focusEditingInputIfFocusTrapFocused();
} }
@ -111,7 +117,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
export let api: Partial<EditingAreaAPI> = {}; export let api: Partial<EditingAreaAPI>;
Object.assign( Object.assign(
api, api,
@ -130,13 +136,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}); });
</script> </script>
<input <FocusTrap bind:this={focusTrap} on:focus={focusEditingInputInsteadIfAvailable} />
bind:this={focusTrap}
readonly
tabindex="-1"
class="focus-trap"
on:focus={focusEditingInputInsteadIfAvailable}
/>
<div bind:this={editingArea} class="editing-area" on:focusout={trapFocusOnBlurOut}> <div bind:this={editingArea} class="editing-area" on:focusout={trapFocusOnBlurOut}>
<slot /> <slot />
@ -144,6 +144,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss"> <style lang="scss">
.editing-area { .editing-area {
display: grid;
/* TODO allow configuration of grid #1503 */
/* grid-template-columns: repeat(2, 1fr); */
position: relative; position: relative;
background: var(--frame-bg); background: var(--frame-bg);
border-radius: 0 0 5px 5px; border-radius: 0 0 5px 5px;
@ -152,18 +156,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
outline: none; outline: none;
} }
} }
.focus-trap {
display: block;
width: 0px;
height: 0;
padding: 0;
margin: 0;
border: none;
outline: none;
-webkit-appearance: none;
background: none;
resize: none;
appearance: none;
}
</style> </style>

View file

@ -0,0 +1,37 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
let input: HTMLInputElement;
export function focus(): void {
input.focus();
}
export function blur(): void {
input.blur();
}
export function isFocusTrap(element: Element): boolean {
return element === input;
}
</script>
<input bind:this={input} class="focus-trap" readonly tabindex="-1" on:focus />
<style lang="scss">
.focus-trap {
display: block;
width: 0px;
height: 0;
padding: 0;
margin: 0;
border: none;
outline: none;
-webkit-appearance: none;
background: none;
resize: none;
appearance: none;
}
</style>

View file

@ -0,0 +1,20 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { FrameElement } from "../editable/frame-element";
customElements.define(FrameElement.tagName, FrameElement);
import { FrameStart, FrameEnd } from "../editable/frame-handle";
customElements.define(FrameStart.tagName, FrameStart);
customElements.define(FrameEnd.tagName, FrameEnd);
import { BLOCK_ELEMENTS } from "../lib/dom";
/* This will ensure that they are not targeted by surrounding algorithms */
BLOCK_ELEMENTS.push(FrameStart.tagName.toUpperCase());
BLOCK_ELEMENTS.push(FrameEnd.tagName.toUpperCase());
</script>

View file

@ -0,0 +1,15 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { getDecoratedElements } from "./DecoratedElements.svelte";
import { Mathjax } from "../editable/mathjax-element";
const decoratedElements = getDecoratedElements();
decoratedElements.push(Mathjax);
import { parsingInstructions } from "./PlainTextInput.svelte";
parsingInstructions.push("<style>anki-mathjax { white-space: pre; }</style>");
</script>

View file

@ -57,6 +57,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { MathjaxHandle } from "./mathjax-overlay"; import { MathjaxHandle } from "./mathjax-overlay";
import { ImageHandle } from "./image-overlay"; import { ImageHandle } from "./image-overlay";
import PlainTextInput from "./PlainTextInput.svelte"; import PlainTextInput from "./PlainTextInput.svelte";
import MathjaxElement from "./MathjaxElement.svelte";
import FrameElement from "./FrameElement.svelte";
import RichTextBadge from "./RichTextBadge.svelte"; import RichTextBadge from "./RichTextBadge.svelte";
import PlainTextBadge from "./PlainTextBadge.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} {/if}
<Fields> <Fields>
{#each fieldsData as field, index} <DecoratedElements>
<EditorField {#each fieldsData as field, index}
{field} <EditorField
content={fieldStores[index]} {field}
autofocus={index === focusTo} content={fieldStores[index]}
api={fields[index]} autofocus={index === focusTo}
on:focusin={() => { api={fields[index]}
$currentField = fields[index]; on:focusin={() => {
bridgeCommand(`focus:${index}`); $currentField = fields[index];
}} bridgeCommand(`focus:${index}`);
on:focusout={() => { }}
$currentField = null; on:focusout={() => {
bridgeCommand( $currentField = null;
`blur:${index}:${getNoteId()}:${get(fieldStores[index])}`, bridgeCommand(
); `blur:${index}:${getNoteId()}:${get(
}} fieldStores[index],
--label-color={cols[index] === "dupe" )}`,
? "var(--flag1-bg)" );
: "transparent"} }}
> --label-color={cols[index] === "dupe"
<svelte:fragment slot="field-state"> ? "var(--flag1-bg)"
{#if cols[index] === "dupe"} : "transparent"}
<DuplicateLink /> >
{/if} <svelte:fragment slot="field-state">
<RichTextBadge bind:off={richTextsHidden[index]} /> {#if cols[index] === "dupe"}
<PlainTextBadge bind:off={plainTextsHidden[index]} /> <DuplicateLink />
{#if stickies} {/if}
<StickyBadge active={stickies[index]} {index} /> <RichTextBadge bind:off={richTextsHidden[index]} />
{/if} <PlainTextBadge bind:off={plainTextsHidden[index]} />
</svelte:fragment> {#if stickies}
<StickyBadge active={stickies[index]} {index} />
{/if}
</svelte:fragment>
<svelte:fragment slot="editing-inputs"> <svelte:fragment slot="editing-inputs">
<DecoratedElements>
<RichTextInput <RichTextInput
hidden={richTextsHidden[index]} hidden={richTextsHidden[index]}
on:focusin={() => { on:focusin={() => {
@ -340,10 +344,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}} }}
bind:this={plainTextInputs[index]} bind:this={plainTextInputs[index]}
/> />
</DecoratedElements> </svelte:fragment>
</svelte:fragment> </EditorField>
</EditorField> {/each}
{/each}
<MathjaxElement />
<FrameElement />
</DecoratedElements>
</Fields> </Fields>
</FieldsEditor> </FieldsEditor>

View file

@ -12,6 +12,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
toggle(): boolean; toggle(): boolean;
getEditor(): CodeMirror.Editor; getEditor(): CodeMirror.Editor;
} }
export const parsingInstructions: string[] = [];
</script> </script>
<script lang="ts"> <script lang="ts">
@ -43,11 +45,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
const parser = new DOMParser(); const parser = new DOMParser();
// TODO Expose this somehow
const parseStyle = "<style>anki-mathjax { white-space: pre; }</style>";
function parseAsHTML(html: string): string { function parseAsHTML(html: string): string {
const doc = parser.parseFromString(parseStyle + html, "text/html"); const doc = parser.parseFromString(
parsingInstructions.join("") + html,
"text/html",
);
const body = doc.body; const body = doc.body;
for (const script of body.getElementsByTagName("script")) { for (const script of body.getElementsByTagName("script")) {
@ -153,11 +156,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</div> </div>
<style lang="scss"> <style lang="scss">
.plain-text-input :global(.CodeMirror) { .plain-text-input {
border-radius: 0 0 5px 5px; overflow-x: hidden;
}
.hidden { :global(.CodeMirror) {
display: none; border-radius: 0 0 5px 5px;
}
&.hidden {
display: none;
}
} }
</style> </style>

View file

@ -5,11 +5,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script context="module" lang="ts"> <script context="module" lang="ts">
import type CustomStyles from "./CustomStyles.svelte"; import type CustomStyles from "./CustomStyles.svelte";
import type { EditingInputAPI } from "./EditingArea.svelte"; import type { EditingInputAPI } from "./EditingArea.svelte";
import type { ContentEditableAPI } from "../editable/ContentEditable.svelte";
import contextProperty from "../sveltelib/context-property"; import contextProperty from "../sveltelib/context-property";
import type { OnNextInsertTrigger } from "../sveltelib/input-manager"; import type {
Trigger,
OnInsertCallback,
OnInputCallback,
} from "../sveltelib/input-manager";
import { pageTheme } from "../sveltelib/theme"; import { pageTheme } from "../sveltelib/theme";
export interface RichTextInputAPI extends EditingInputAPI { export interface RichTextInputAPI extends EditingInputAPI, ContentEditableAPI {
name: "rich-text"; name: "rich-text";
shadowRoot: Promise<ShadowRoot>; shadowRoot: Promise<ShadowRoot>;
element: Promise<HTMLElement>; element: Promise<HTMLElement>;
@ -17,7 +22,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
refocus(): void; refocus(): void;
toggle(): boolean; toggle(): boolean;
preventResubscription(): () => void; preventResubscription(): () => void;
getTriggerOnNextInsert(): OnNextInsertTrigger; getTriggerOnNextInsert(): Trigger<OnInsertCallback>;
getTriggerOnInput(): Trigger<OnInputCallback>;
getTriggerAfterInput(): Trigger<OnInputCallback>;
} }
export interface RichTextInputContextAPI { export interface RichTextInputContextAPI {
@ -31,20 +38,31 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
contextProperty<RichTextInputContextAPI>(key); contextProperty<RichTextInputContextAPI>(key);
export { getRichTextInput, hasRichTextInput }; export { getRichTextInput, hasRichTextInput };
import getDOMMirror from "../sveltelib/mirror-dom";
import getInputManager from "../sveltelib/input-manager";
const {
manager: globalInputManager,
getTriggerAfterInput,
getTriggerOnInput,
getTriggerOnNextInsert,
} = getInputManager();
export { getTriggerAfterInput, getTriggerOnInput, getTriggerOnNextInsert };
</script> </script>
<script lang="ts"> <script lang="ts">
import RichTextStyles from "./RichTextStyles.svelte"; import RichTextStyles from "./RichTextStyles.svelte";
import SetContext from "./SetContext.svelte"; import SetContext from "./SetContext.svelte";
import ContentEditable from "../editable/ContentEditable.svelte"; import ContentEditable from "../editable/ContentEditable.svelte";
import { onMount, getAllContexts } from "svelte"; import { onMount, getAllContexts } from "svelte";
import { import {
nodeIsElement, nodeIsElement,
nodeContainsInlineContent, nodeContainsInlineContent,
fragmentToString, fragmentToString,
caretToEnd,
} from "../lib/dom"; } from "../lib/dom";
import { placeCaretAfterContent } from "../domlib/place-caret";
import { getDecoratedElements } from "./DecoratedElements.svelte"; import { getDecoratedElements } from "./DecoratedElements.svelte";
import { getEditingArea } from "./EditingArea.svelte"; import { getEditingArea } from "./EditingArea.svelte";
import { promiseWithResolver } from "../lib/promise"; import { promiseWithResolver } from "../lib/promise";
@ -155,40 +173,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}; };
} }
import getDOMMirror from "../sveltelib/mirror-dom";
import getInputManager from "../sveltelib/input-manager";
const { mirror, preventResubscription } = getDOMMirror(); const { mirror, preventResubscription } = getDOMMirror();
const { manager, getTriggerOnNextInsert } = getInputManager(); const localInputManager = getInputManager();
function moveCaretToEnd() { function moveCaretToEnd() {
richTextPromise.then(caretToEnd); richTextPromise.then(placeCaretAfterContent);
} }
const allContexts = getAllContexts(); export const api = {
function attachContentEditable(element: Element, { stylesDidLoad }): void {
stylesDidLoad.then(
() =>
new ContentEditable({
target: element.shadowRoot!,
props: {
nodes,
resolve,
mirror,
inputManager: manager,
},
context: allContexts,
}),
);
}
export const api: RichTextInputAPI = {
name: "rich-text", name: "rich-text",
shadowRoot: shadowPromise, shadowRoot: shadowPromise,
element: richTextPromise, element: richTextPromise,
focus() { focus() {
richTextPromise.then((richText) => richText.focus()); richTextPromise.then((richText) => {
richText.focus();
});
}, },
refocus() { refocus() {
richTextPromise.then((richText) => { richTextPromise.then((richText) => {
@ -203,8 +202,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}, },
moveCaretToEnd, moveCaretToEnd,
preventResubscription, preventResubscription,
getTriggerOnNextInsert, getTriggerOnNextInsert: localInputManager.getTriggerOnNextInsert,
}; getTriggerOnInput: localInputManager.getTriggerOnInput,
getTriggerAfterInput: localInputManager.getTriggerAfterInput,
} as RichTextInputAPI;
const allContexts = getAllContexts();
function attachContentEditable(element: Element, { stylesDidLoad }): void {
stylesDidLoad.then(
() =>
new ContentEditable({
target: element.shadowRoot!,
props: {
nodes,
resolve,
mirrors: [mirror],
managers: [globalInputManager, localInputManager.manager],
api,
},
context: allContexts,
}),
);
}
function pushUpdate(): void { function pushUpdate(): void {
api.focusable = !hidden; api.focusable = !hidden;
@ -230,30 +250,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}); });
</script> </script>
<RichTextStyles <div class="rich-text-input">
color={$pageTheme.isDark ? "white" : "black"} <RichTextStyles
let:attachToShadow={attachStyles} color={$pageTheme.isDark ? "white" : "black"}
let:promise={stylesPromise} let:attachToShadow={attachStyles}
let:stylesDidLoad let:promise={stylesPromise}
> let:stylesDidLoad
<div >
class:hidden <div
class:night-mode={$pageTheme.isDark} class="rich-text-editable"
use:attachShadow class:hidden
use:attachStyles class:night-mode={$pageTheme.isDark}
use:attachContentEditable={{ stylesDidLoad }} use:attachShadow
on:focusin use:attachStyles
on:focusout use:attachContentEditable={{ stylesDidLoad }}
/> on:focusin
on:focusout
/>
<div class="editable-widgets"> <div class="rich-text-widgets">
{#await Promise.all([richTextPromise, stylesPromise]) then [container, styles]} {#await Promise.all( [richTextPromise, stylesPromise], ) then [container, styles]}
<SetContext setter={set} value={{ container, styles, api }}> <SetContext setter={set} value={{ container, styles, api }}>
<slot /> <slot />
</SetContext> </SetContext>
{/await} {/await}
</div> </div>
</RichTextStyles> </RichTextStyles>
</div>
<style lang="scss"> <style lang="scss">
.hidden { .hidden {

View file

@ -0,0 +1,15 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Badge from "../components/Badge.svelte";
import { deleteIcon } from "./icons";
let className: string = "";
export { className as class };
</script>
<Badge class="d-flex align-items-center ms-1 {className}" on:click iconSize={80}
>{@html deleteIcon}</Badge
>

View file

@ -45,3 +45,8 @@ export const gutterOptions: CodeMirror.EditorConfiguration = {
lineNumbers: true, lineNumbers: true,
foldGutter: true, foldGutter: true,
}; };
export function focusAndCaretAfter(editor: CodeMirror.Editor): void {
editor.focus();
editor.setCursor(editor.lineCount(), 0);
}

View file

@ -11,9 +11,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "../../lib/ftl"; import * as tr from "../../lib/ftl";
import { inlineIcon, blockIcon, deleteIcon } from "./icons"; import { inlineIcon, blockIcon, deleteIcon } from "./icons";
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { hasBlockAttribute } from "../../lib/dom";
export let activeImage: HTMLImageElement; export let element: Element;
export let mathjaxElement: HTMLElement;
$: isBlock = hasBlockAttribute(element);
function updateBlock() {
element.setAttribute("block", String(isBlock));
}
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
</script> </script>
@ -24,8 +30,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem> <ButtonGroupItem>
<IconButton <IconButton
tooltip={tr.editingMathjaxInline()} tooltip={tr.editingMathjaxInline()}
active={activeImage.getAttribute("block") === "true"} active={!isBlock}
on:click={() => mathjaxElement.setAttribute("block", "false")} on:click={() => {
isBlock = false;
updateBlock();
}}
on:click>{@html inlineIcon}</IconButton on:click>{@html inlineIcon}</IconButton
> >
</ButtonGroupItem> </ButtonGroupItem>
@ -33,8 +42,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem> <ButtonGroupItem>
<IconButton <IconButton
tooltip={tr.editingMathjaxBlock()} tooltip={tr.editingMathjaxBlock()}
active={activeImage.getAttribute("block") === "false"} active={isBlock}
on:click={() => mathjaxElement.setAttribute("block", "true")} on:click={() => {
isBlock = true;
updateBlock();
}}
on:click>{@html blockIcon}</IconButton on:click>{@html blockIcon}</IconButton
> >
</ButtonGroupItem> </ButtonGroupItem>

View file

@ -3,18 +3,20 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import { onMount, createEventDispatcher } from "svelte";
import CodeMirror from "../CodeMirror.svelte"; import CodeMirror from "../CodeMirror.svelte";
import type { CodeMirrorAPI } from "../CodeMirror.svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { baseOptions, latex } from "../code-mirror"; import { baseOptions, latex, focusAndCaretAfter } from "../code-mirror";
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { noop } from "../../lib/functional"; import { noop } from "../../lib/functional";
import * as tr from "../../lib/ftl"; import * as tr from "../../lib/ftl";
export let code: Writable<string>;
export let acceptShortcut: string; export let acceptShortcut: string;
export let newlineShortcut: string; export let newlineShortcut: string;
export let code: Writable<string>;
const configuration = { const configuration = {
...Object.assign({}, baseOptions, { ...Object.assign({}, baseOptions, {
extraKeys: { extraKeys: {
@ -29,15 +31,60 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}), }),
mode: latex, mode: latex,
}; };
export let selectAll: boolean;
const dispatch = createEventDispatcher();
let codeMirror: CodeMirrorAPI = {} as CodeMirrorAPI;
onMount(() => {
focusAndCaretAfter(codeMirror.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";
}
});
codeMirror.editor.on(
"beforeSelectionChange",
(instance, obj: CodeMirror.EditorSelectionChange) => {
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;
},
);
});
</script> </script>
<div class="mathjax-editor"> <div class="mathjax-editor">
<CodeMirror <CodeMirror
{code} {code}
{configuration} {configuration}
bind:api={codeMirror}
on:change={({ detail }) => code.set(detail)} on:change={({ detail }) => code.set(detail)}
on:blur on:blur
autofocus
/> />
</div> </div>

View file

@ -5,14 +5,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import WithDropdown from "../../components/WithDropdown.svelte"; import WithDropdown from "../../components/WithDropdown.svelte";
import MathjaxMenu from "./MathjaxMenu.svelte"; import MathjaxMenu from "./MathjaxMenu.svelte";
import HandleSelection from "../HandleSelection.svelte";
import HandleBackground from "../HandleBackground.svelte";
import HandleControl from "../HandleControl.svelte";
import { onMount, onDestroy, tick } from "svelte"; import { onMount, onDestroy, tick } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { getRichTextInput } from "../RichTextInput.svelte"; import { getRichTextInput } from "../RichTextInput.svelte";
import { placeCaretAfter } from "../../domlib/place-caret";
import { noop } from "../../lib/functional"; import { noop } from "../../lib/functional";
import { on } from "../../lib/events"; import { on } from "../../lib/events";
import { Mathjax } from "../../editable/mathjax-element";
const { container, api } = getRichTextInput(); const { container, api } = getRichTextInput();
const { flushLocation, preventResubscription } = api;
const code = writable(""); const code = writable("");
@ -21,14 +25,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let allow = noop; let allow = noop;
let unsubscribe = noop; let unsubscribe = noop;
const caretKeyword = "caretAfter";
function showHandle(image: HTMLImageElement): void { function showHandle(image: HTMLImageElement): void {
allow = api.preventResubscription(); allow = preventResubscription();
activeImage = image; activeImage = image;
image.setAttribute(caretKeyword, "true"); mathjaxElement = activeImage.closest(Mathjax.tagName)!;
mathjaxElement = activeImage.closest("anki-mathjax")!;
code.set(mathjaxElement.dataset.mathjax ?? ""); code.set(mathjaxElement.dataset.mathjax ?? "");
unsubscribe = code.subscribe((value: string) => { unsubscribe = code.subscribe((value: string) => {
@ -36,7 +37,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}); });
} }
async function clearImage(): Promise<void> { let selectAll = false;
function placeHandle(after: boolean): void {
if (after) {
(mathjaxElement as any).placeCaretAfter();
} else {
(mathjaxElement as any).placeCaretBefore();
}
}
async function resetHandle(): Promise<void> {
flushLocation();
selectAll = false;
if (activeImage && mathjaxElement) { if (activeImage && mathjaxElement) {
unsubscribe(); unsubscribe();
activeImage = null; activeImage = null;
@ -44,26 +58,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
await tick(); await tick();
container.focus();
}
function placeCaret(image: HTMLImageElement): void {
placeCaretAfter(image);
image.removeAttribute(caretKeyword);
}
async function resetHandle(deletes: boolean = false): Promise<void> {
await clearImage();
const image = container.querySelector(`[${caretKeyword}]`);
if (image) {
placeCaret(image as HTMLImageElement);
if (deletes) {
image.remove();
}
}
allow(); allow();
} }
@ -81,13 +75,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
showHandle(detail); showHandle(detail);
} }
async function showSelectAll({
detail,
}: CustomEvent<HTMLImageElement>): Promise<void> {
await resetHandle();
selectAll = true;
showHandle(detail);
}
onMount(() => { onMount(() => {
const removeClick = on(container, "click", maybeShowHandle); const removeClick = on(container, "click", maybeShowHandle);
const removeFocus = on(container, "focusmathjax" as any, showAutofocusHandle); const removeCaretAfter = on(
container,
"movecaretafter" as any,
showAutofocusHandle,
);
const removeSelectAll = on(container, "selectall" as any, showSelectAll);
return () => { return () => {
removeClick(); removeClick();
removeFocus(); removeCaretAfter();
removeSelectAll();
}; };
}); });
@ -131,15 +139,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
> >
{#if activeImage && mathjaxElement} {#if activeImage && mathjaxElement}
<MathjaxMenu <MathjaxMenu
{activeImage} element={mathjaxElement}
{mathjaxElement}
{container}
{errorMessage}
{code} {code}
{selectAll}
bind:updateSelection bind:updateSelection
on:mount={(event) => (dropdownApi = createDropdown(event.detail.selection))} on:reset={resetHandle}
on:reset={() => resetHandle(false)} on:moveoutstart={() => {
on:delete={() => resetHandle(true)} placeHandle(false);
/> resetHandle();
}}
on:moveoutend={() => {
placeHandle(true);
resetHandle();
}}
>
<HandleSelection
image={activeImage}
{container}
bind:updateSelection
on:mount={(event) =>
(dropdownApi = createDropdown(event.detail.selection))}
>
<HandleBackground tooltip={errorMessage} />
<HandleControl offsetX={1} offsetY={1} />
</HandleSelection>
</MathjaxMenu>
{/if} {/if}
</WithDropdown> </WithDropdown>

View file

@ -5,19 +5,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import Shortcut from "../../components/Shortcut.svelte"; import Shortcut from "../../components/Shortcut.svelte";
import DropdownMenu from "../../components/DropdownMenu.svelte"; import DropdownMenu from "../../components/DropdownMenu.svelte";
import HandleSelection from "../HandleSelection.svelte";
import HandleBackground from "../HandleBackground.svelte";
import HandleControl from "../HandleControl.svelte";
import MathjaxEditor from "./MathjaxEditor.svelte"; import MathjaxEditor from "./MathjaxEditor.svelte";
import MathjaxButtons from "./MathjaxButtons.svelte"; import MathjaxButtons from "./MathjaxButtons.svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { placeCaretAfter } from "../../domlib/place-caret";
export let activeImage: HTMLImageElement; export let element: Element;
export let mathjaxElement: HTMLElement;
export let container: HTMLElement;
export let errorMessage: string;
export let code: Writable<string>; export let code: Writable<string>;
export let selectAll: boolean;
const acceptShortcut = "Enter"; const acceptShortcut = "Enter";
const newlineShortcut = "Shift+Enter"; const newlineShortcut = "Shift+Enter";
@ -38,26 +34,32 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<div class="mathjax-menu"> <div class="mathjax-menu">
<HandleSelection image={activeImage} {container} bind:updateSelection on:mount> <slot />
<HandleBackground tooltip={errorMessage} />
<HandleControl offsetX={1} offsetY={1} />
</HandleSelection>
<DropdownMenu> <DropdownMenu>
<MathjaxEditor <MathjaxEditor
{acceptShortcut} {acceptShortcut}
{newlineShortcut} {newlineShortcut}
{code} {code}
{selectAll}
on:blur={() => dispatch("reset")} on:blur={() => dispatch("reset")}
on:moveoutstart
on:moveoutend
/> />
<Shortcut keyCombination={acceptShortcut} on:action={() => dispatch("reset")} /> <Shortcut
keyCombination={acceptShortcut}
on:action={() => dispatch("moveoutend")}
/>
<Shortcut keyCombination={newlineShortcut} on:action={appendNewline} /> <Shortcut keyCombination={newlineShortcut} on:action={appendNewline} />
<MathjaxButtons <MathjaxButtons
{activeImage} {element}
{mathjaxElement} on:delete={() => {
on:delete={() => dispatch("delete")} placeCaretAfter(element);
element.remove();
dispatch("reset");
}}
/> />
</DropdownMenu> </DropdownMenu>
</div> </div>

View file

@ -2,7 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { get } from "svelte/store"; import { get } from "svelte/store";
import { getSelection } from "../lib/cross-browser"; import { getSelection, getRange } from "../lib/cross-browser";
import { surroundNoSplitting, unsurround, findClosest } from "../domlib/surround"; import { surroundNoSplitting, unsurround, findClosest } from "../domlib/surround";
import type { ElementMatcher, ElementClearer } from "../domlib/surround"; import type { ElementMatcher, ElementClearer } from "../domlib/surround";
import type { RichTextInputAPI } from "./RichTextInput.svelte"; import type { RichTextInputAPI } from "./RichTextInput.svelte";
@ -50,7 +50,11 @@ export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderRes
async function isSurrounded(matcher: ElementMatcher): Promise<boolean> { async function isSurrounded(matcher: ElementMatcher): Promise<boolean> {
const base = await richTextInput.element; const base = await richTextInput.element;
const selection = getSelection(base)!; const selection = getSelection(base)!;
const range = selection.getRangeAt(0); const range = getRange(selection);
if (!range) {
return false;
}
const isSurrounded = isSurroundedInner(range, base, matcher); const isSurrounded = isSurroundedInner(range, base, matcher);
return get(active) ? !isSurrounded : isSurrounded; return get(active) ? !isSurrounded : isSurrounded;
@ -63,9 +67,11 @@ export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderRes
): Promise<void> { ): Promise<void> {
const base = await richTextInput.element; const base = await richTextInput.element;
const selection = getSelection(base)!; const selection = getSelection(base)!;
const range = selection.getRangeAt(0); const range = getRange(selection);
if (range.collapsed) { if (!range) {
return;
} else if (range.collapsed) {
if (get(active)) { if (get(active)) {
remove(); remove();
} else { } else {

View file

@ -2,7 +2,12 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/** /**
* Firefox has no .getSelection on ShadowRoot, only .activeElement * NOTES:
* - Avoid using selection.isCollapsed: will always return true in shadow root in Gecko
*/
/**
* Gecko has no .getSelection on ShadowRoot, only .activeElement
*/ */
export function getSelection(element: Node): Selection | null { export function getSelection(element: Node): Selection | null {
const root = element.getRootNode(); const root = element.getRootNode();
@ -13,3 +18,14 @@ export function getSelection(element: Node): Selection | null {
return document.getSelection(); return document.getSelection();
} }
/**
* Browser has potential support for multiple ranges per selection built in,
* but in reality only Gecko supports it.
* If there are multiple ranges, the latest range is the _main_ one.
*/
export function getRange(selection: Selection): Range | null {
const rangeCount = selection.rangeCount;
return rangeCount === 0 ? null : selection.getRangeAt(rangeCount - 1);
}

View file

@ -1,6 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getSelection } from "./cross-browser";
export function nodeIsElement(node: Node): node is Element { export function nodeIsElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE; return node.nodeType === Node.ELEMENT_NODE;
} }
@ -10,7 +12,7 @@ export function nodeIsText(node: Node): node is Text {
} }
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements // https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
const BLOCK_ELEMENTS = [ export const BLOCK_ELEMENTS = [
"ADDRESS", "ADDRESS",
"ARTICLE", "ARTICLE",
"ASIDE", "ASIDE",
@ -46,12 +48,22 @@ const BLOCK_ELEMENTS = [
"UL", "UL",
]; ];
export function hasBlockAttribute(element: Element): boolean {
return element.hasAttribute("block") && element.getAttribute("block") !== "false";
}
export function elementIsBlock(element: Element): boolean { export function elementIsBlock(element: Element): boolean {
return BLOCK_ELEMENTS.includes(element.tagName); return BLOCK_ELEMENTS.includes(element.tagName) || hasBlockAttribute(element);
}
export const NO_SPLIT_TAGS = ["RUBY"];
export function elementShouldNotBeSplit(element: Element): boolean {
return elementIsBlock(element) || NO_SPLIT_TAGS.includes(element.tagName);
} }
// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element // https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
const EMPTY_ELEMENTS = [ export const EMPTY_ELEMENTS = [
"AREA", "AREA",
"BASE", "BASE",
"BR", "BR",
@ -94,25 +106,10 @@ export function fragmentToString(fragment: DocumentFragment): string {
return html; return html;
} }
export const NO_SPLIT_TAGS = ["RUBY"];
export function elementShouldNotBeSplit(element: Element): boolean {
return elementIsBlock(element) || NO_SPLIT_TAGS.includes(element.tagName);
}
export function caretToEnd(node: Node): void {
const range = new Range();
range.selectNodeContents(node);
range.collapse(false);
const selection = (node.getRootNode() as Document | ShadowRoot).getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
const getAnchorParent = const getAnchorParent =
<T extends Element>(predicate: (element: Element) => element is T) => <T extends Element>(predicate: (element: Element) => element is T) =>
(root: DocumentOrShadowRoot): T | null => { (root: Node): T | null => {
const anchor = root.getSelection()?.anchorNode; const anchor = getSelection(root)?.anchorNode;
if (!anchor) { if (!anchor) {
return null; return null;

View file

@ -1,6 +1,14 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const noop: () => void = () => { export function noop(): void {
/* noop */ /* noop */
}; }
export function id<T>(t: T): T {
return t;
}
export function truthy<T>(t: T | void | undefined | null): t is T {
return Boolean(t);
}

View file

@ -23,6 +23,10 @@ export function checkIfInputKey(event: KeyboardEvent): boolean {
return event.location === GENERAL_KEY || event.location === NUMPAD_KEY; return event.location === GENERAL_KEY || event.location === NUMPAD_KEY;
} }
export function keyboardEventIsPrintableKey(event: KeyboardEvent): boolean {
return event.key.length === 1;
}
export const checkModifiers = export const checkModifiers =
(required: Modifier[], optional: Modifier[] = []) => (required: Modifier[], optional: Modifier[] = []) =>
(event: KeyboardEvent): boolean => { (event: KeyboardEvent): boolean => {

View file

@ -1,17 +1,21 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getSelection } from "./cross-browser"; import { getSelection, getRange } from "./cross-browser";
function wrappedExceptForWhitespace(text: string, front: string, back: string): string { function wrappedExceptForWhitespace(text: string, front: string, back: string): string {
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!; const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;
return match[1] + front + match[2] + back + match[3]; return match[1] + front + match[2] + back + match[3];
} }
function moveCursorPastPostfix(selection: Selection, postfix: string): void { function moveCursorPastPostfix(
const range = selection.getRangeAt(0); selection: Selection,
range: Range,
postfix: string,
): void {
range.setStart(range.startContainer, range.startOffset - postfix.length); range.setStart(range.startContainer, range.startOffset - postfix.length);
range.collapse(true); range.collapse(true);
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);
} }
@ -23,7 +27,12 @@ export function wrapInternal(
plainText: boolean, plainText: boolean,
): void { ): void {
const selection = getSelection(base)!; const selection = getSelection(base)!;
const range = selection.getRangeAt(0); const range = getRange(selection);
if (!range) {
return;
}
const content = range.cloneContents(); const content = range.cloneContents();
const span = document.createElement("span"); const span = document.createElement("span");
span.appendChild(content); span.appendChild(content);
@ -42,6 +51,6 @@ export function wrapInternal(
"<anki-mathjax", "<anki-mathjax",
) )
) { ) {
moveCursorPastPostfix(selection, back); moveCursorPastPostfix(selection, range, back);
} }
} }

View file

@ -0,0 +1,39 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { truthy } from "../lib/functional";
interface ActionReturn<P> {
destroy?(): void;
update?(params: P): void;
}
type Action<E extends HTMLElement, P> = (
element: E,
params: P,
) => ActionReturn<P> | void;
/**
* A helper function for treating a list of Svelte actions as a single Svelte action
* and use it with a single `use:` directive
*/
function actionList<E extends HTMLElement, P>(actions: Action<E, P>[]): Action<E, P> {
return function action(element: E, params: P): ActionReturn<P> | void {
const results = actions.map((action) => action(element, params)).filter(truthy);
return {
update(params: P) {
for (const { update } of results) {
update?.(params);
}
},
destroy() {
for (const { destroy } of results) {
destroy?.();
}
},
};
};
}
export default actionList;

View file

@ -3,72 +3,122 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { keyboardEventIsPrintableKey } from "../lib/keys";
import { on } from "../lib/events"; import { on } from "../lib/events";
import { nodeIsText } from "../lib/dom"; import { id } from "../lib/functional";
import { getSelection } from "../lib/cross-browser"; import { getSelection } from "../lib/cross-browser";
export type OnInsertCallback = ({ node }: { node: Node }) => Promise<void>; export type OnInsertCallback = ({
node,
event,
}: {
node: Node;
event: InputEvent;
}) => Promise<void>;
export interface OnNextInsertTrigger { export type OnInputCallback = ({ event }: { event: InputEvent }) => Promise<void>;
add: (callback: OnInsertCallback) => void;
remove: () => void; export interface Trigger<C> {
add(callback: C): void;
remove(): void;
active: Writable<boolean>; active: Writable<boolean>;
} }
export type Managed<C> = Pick<Trigger<C>, "remove"> & { callback: C };
export type InputManagerAction = (element: HTMLElement) => { destroy(): void };
interface InputManager { interface InputManager {
manager(element: HTMLElement): { destroy(): void }; manager: InputManagerAction;
getTriggerOnNextInsert(): OnNextInsertTrigger; getTriggerOnNextInsert(): Trigger<OnInsertCallback>;
getTriggerOnInput(): Trigger<OnInputCallback>;
getTriggerAfterInput(): Trigger<OnInputCallback>;
} }
function getInputManager(): InputManager { function trigger<C>(list: Managed<C>[]) {
const onInsertText: { callback: OnInsertCallback; remove: () => void }[] = []; return function getTrigger(): Trigger<C> {
const index = list.length++;
const active = writable(false);
function cancelInsertText(): void { function remove() {
onInsertText.length = 0; delete list[index];
} active.set(false);
function cancelIfInsertText(event: KeyboardEvent): void {
if (event.key.length !== 1) {
cancelInsertText();
} }
}
function add(callback: C): void {
list[index] = { callback, remove };
active.set(true);
}
return {
add,
remove,
active,
};
};
}
const nbsp = "\xa0";
/**
* An interface that allows Svelte components to attach event listeners via triggers.
* They will be attached to the component(s) that install the manager.
* Prevents that too many event listeners are attached and allows for some
* coordination between them.
*/
function getInputManager(): InputManager {
const beforeInput: Managed<OnInputCallback>[] = [];
const beforeInsertText: Managed<OnInsertCallback>[] = [];
async function onBeforeInput(event: InputEvent): Promise<void> { async function onBeforeInput(event: InputEvent): Promise<void> {
if (event.inputType === "insertText" && onInsertText.length > 0) { const selection = getSelection(event.target! as Node)!;
const nbsp = " "; const range = selection.getRangeAt(0);
for (const { callback } of beforeInput.filter(id)) {
await callback({ event });
}
const filteredBeforeInsertText = beforeInsertText.filter(id);
if (event.inputType === "insertText" && filteredBeforeInsertText.length > 0) {
event.preventDefault();
const textContent = event.data === " " ? nbsp : event.data ?? nbsp; const textContent = event.data === " " ? nbsp : event.data ?? nbsp;
const node = new Text(textContent); const node = new Text(textContent);
const selection = getSelection(event.target! as Node)!;
const range = selection.getRangeAt(0);
range.deleteContents(); range.deleteContents();
range.insertNode(node);
if (nodeIsText(range.startContainer) && range.startOffset === 0) {
const parent = range.startContainer.parentNode!;
parent.insertBefore(node, range.startContainer);
} else if (
nodeIsText(range.endContainer) &&
range.endOffset === range.endContainer.length
) {
const parent = range.endContainer.parentNode!;
parent.insertBefore(node, range.endContainer.nextSibling!);
} else {
range.insertNode(node);
}
range.selectNode(node); range.selectNode(node);
range.collapse(false); range.collapse(false);
for (const { callback, remove } of onInsertText) { for (const { callback, remove } of filteredBeforeInsertText) {
await callback({ node }); await callback({ node, event });
remove(); remove();
} }
event.preventDefault(); /* we call explicitly because we prevented default */
callAfterInputHooks(event);
} }
}
cancelInsertText(); const afterInput: Managed<OnInputCallback>[] = [];
async function callAfterInputHooks(event: InputEvent): Promise<void> {
for (const { callback } of afterInput.filter(id)) {
await callback({ event });
}
}
function clearInsertText(): void {
for (const { remove } of beforeInsertText.filter(id)) {
remove();
}
}
function clearInsertTextIfUnprintableKey(event: KeyboardEvent): void {
/* using arrow keys should cancel */
if (!keyboardEventIsPrintableKey(event)) {
clearInsertText();
}
} }
function onInput(event: Event): void { function onInput(event: Event): void {
@ -92,55 +142,33 @@ function getInputManager(): InputManager {
function manager(element: HTMLElement): { destroy(): void } { function manager(element: HTMLElement): { destroy(): void } {
const removeBeforeInput = on(element, "beforeinput", onBeforeInput); const removeBeforeInput = on(element, "beforeinput", onBeforeInput);
const removePointerDown = on(element, "pointerdown", cancelInsertText); const removeInput = on(
const removeBlur = on(element, "blur", cancelInsertText);
const removeKeyDown = on(
element, element,
"keydown", "input",
cancelIfInsertText as EventListener, onInput as unknown as (event: Event) => void,
); );
const removeInput = on(element, "input", onInput);
const removeBlur = on(element, "blur", clearInsertText);
const removePointerDown = on(element, "pointerdown", clearInsertText);
const removeKeyDown = on(element, "keydown", clearInsertTextIfUnprintableKey);
return { return {
destroy() { destroy() {
removeInput();
removeBeforeInput(); removeBeforeInput();
removePointerDown();
removeBlur(); removeBlur();
removePointerDown();
removeKeyDown(); removeKeyDown();
removeInput(); removeInput();
}, },
}; };
} }
function getTriggerOnNextInsert(): OnNextInsertTrigger {
const active = writable(false);
let index = NaN;
function remove() {
if (!Number.isNaN(index)) {
delete onInsertText[index];
active.set(false);
index = NaN;
}
}
function add(callback: OnInsertCallback): void {
if (Number.isNaN(index)) {
index = onInsertText.push({ callback, remove });
active.set(true);
}
}
return {
add,
remove,
active,
};
}
return { return {
manager, manager,
getTriggerOnNextInsert, getTriggerOnNextInsert: trigger(beforeInsertText),
getTriggerOnInput: trigger(beforeInput),
getTriggerAfterInput: trigger(afterInput),
}; };
} }

View file

@ -4,6 +4,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import storeSubscribe from "./store-subscribe"; import storeSubscribe from "./store-subscribe";
// import { getSelection } from "../lib/cross-browser";
const config = { const config = {
childList: true, childList: true,
@ -12,15 +13,22 @@ const config = {
characterData: true, characterData: true,
}; };
interface DOMMirror { export type MirrorAction = (
mirror( element: Element,
element: Element, params: { store: Writable<DocumentFragment> },
params: { store: Writable<DocumentFragment> }, ) => { destroy(): void };
): { destroy(): void };
interface DOMMirrorAPI {
mirror: MirrorAction;
preventResubscription(): () => void; preventResubscription(): () => void;
} }
function getDOMMirror(): DOMMirror { /**
* Allows you to keep an element's inner HTML bidirectionally
* in sync with a store containing a DocumentFragment.
* While the element has focus, this connection is tethered.
*/
function getDOMMirror(): DOMMirrorAPI {
const allowResubscription = writable(true); const allowResubscription = writable(true);
function preventResubscription() { function preventResubscription() {
@ -64,6 +72,20 @@ function getDOMMirror(): DOMMirror {
} }
const { subscribe, unsubscribe } = storeSubscribe(store, mirrorToNode); const { subscribe, unsubscribe } = storeSubscribe(store, mirrorToNode);
// const selection = getSelection(element)!;
function doSubscribe(): void {
// Might not be needed after all:
// /**
// * Focused element and caret are two independent things in the browser.
// * When the ContentEditable calls blur, it will still have the selection inside of it.
// * Some elements (e.g. FrameElement) need to figure whether the intended focus is still
// * in the contenteditable or elsewhere because they might change the selection.
// */
// selection.removeAllRanges();
subscribe();
}
/* do not update when focused as it will reset caret */ /* do not update when focused as it will reset caret */
element.addEventListener("focus", unsubscribe); element.addEventListener("focus", unsubscribe);
@ -71,14 +93,15 @@ function getDOMMirror(): DOMMirror {
const unsubResubscription = allowResubscription.subscribe( const unsubResubscription = allowResubscription.subscribe(
(allow: boolean): void => { (allow: boolean): void => {
if (allow) { if (allow) {
element.addEventListener("blur", subscribe); element.addEventListener("blur", doSubscribe);
const root = element.getRootNode() as Document | ShadowRoot; const root = element.getRootNode() as Document | ShadowRoot;
if (root.activeElement !== element) { if (root.activeElement !== element) {
subscribe(); doSubscribe();
} }
} else { } else {
element.removeEventListener("blur", subscribe); element.removeEventListener("blur", doSubscribe);
} }
}, },
); );