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

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

View File

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

View File

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

View File

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

View File

@ -2,83 +2,48 @@
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script context="module" lang="ts">
export type { ContentEditableAPI } from "./content-editable";
</script>
<script lang="ts">
import type { Writable } from "svelte/store";
import { updateAllState } from "../components/WithState.svelte";
import { saveSelection, restoreSelection } from "../domlib/location";
import { on, preventDefault } from "../lib/events";
import { caretToEnd } from "../lib/dom";
import { registerShortcut } from "../lib/shortcuts";
import actionList from "../sveltelib/action-list";
import contentEditableAPI, {
saveLocation,
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 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;
let removeOnPointerdown: () => void;
const mirrorAction = actionList(mirrors);
const mirrorOptions = { store: nodes };
function onBlur(): void {
const location = saveSelection(editable);
export let managers: InputManagerAction[];
removeOnFocus = on(
editable,
"focus",
() => {
if (location) {
try {
restoreSelection(editable, location);
} catch {
caretToEnd(editable);
}
}
},
{ once: true },
);
const managerAction = actionList(managers);
removeOnPointerdown = on(editable, "pointerdown", () => removeOnFocus?.(), {
once: true,
});
}
export let api: Partial<ContentEditableAPI>;
/* must execute before DOMMirror */
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);
}
}
Object.assign(api, contentEditableAPI);
</script>
<anki-editable
contenteditable="true"
bind:this={editable}
use:resolve
use:saveLocation
use:mirror={{ store: nodes }}
use:inputManager
use:prepareFocusHandling
use:preventBuiltinContentEditableShortcuts
use:mirrorAction={mirrorOptions}
use:managerAction={{}}
on:focus
on:blur
on:click={updateAllState}
@ -88,9 +53,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
anki-editable {
display: block;
overflow-wrap: break-word;
overflow: auto;
padding: 6px;
overflow: auto;
overflow-wrap: break-word;
&:focus {
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 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
</script>
<img
bind:this={image}
src="data:image/svg+xml,{encoded}"
class:block
class:empty
@ -78,7 +85,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
data-anki="mathjax"
data-uuid={uuid}
on:dragstart|preventDefault
use:maybeAutofocus
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
// 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 =
/<anki-mathjax(?:[^>]*?block="(.*?)")?[^>]*?>(.*?)<\/anki-mathjax>/gsu;
@ -104,17 +38,17 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
.replace(
mathjaxBlockDelimiterPattern,
(_match: string, text: string) =>
`<anki-mathjax block="true">${text}</anki-mathjax>`,
`<${Mathjax.tagName} block="true">${text}</${Mathjax.tagName}>`,
)
.replace(
mathjaxInlineDelimiterPattern,
(_match: string, text: string) =>
`<anki-mathjax>${text}</anki-mathjax>`,
`<${Mathjax.tagName}>${text}</${Mathjax.tagName}>`,
);
}
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);
}
}
};

View File

@ -11,14 +11,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script>
<script lang="ts">
import { createEventDispatcher, getContext, onMount } from "svelte";
import { createEventDispatcher, getContext } from "svelte";
import type { Writable } from "svelte/store";
import storeSubscribe from "../sveltelib/store-subscribe";
import { directionKey } from "../lib/context-keys";
export let configuration: CodeMirror.EditorConfiguration;
export let code: Writable<string>;
export let autofocus = false;
const direction = getContext<Writable<"ltr" | "rtl">>(directionKey);
const defaultConfiguration = {
@ -78,13 +77,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
editor: { get: () => codeMirror },
},
) as CodeMirrorAPI;
onMount(() => {
if (autofocus) {
codeMirror.focus();
codeMirror.setCursor(codeMirror.lineCount(), 0);
}
});
</script>
<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,
DecoratedElementConstructor,
} from "../editable/decorated";
import { Mathjax } from "../editable/mathjax-element";
import contextProperty from "../sveltelib/context-property";
const decoratedElements = new CustomElementArray<DecoratedElementConstructor>();
decoratedElements.push(Mathjax);
const key = Symbol("decoratedElements");
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 lang="ts">
import FocusTrap from "./FocusTrap.svelte";
import { writable } from "svelte/store";
import { onMount, setContext as svelteSetContext } from "svelte";
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;
let editingArea: HTMLElement;
let focusTrap: HTMLInputElement;
let focusTrap: FocusTrap;
const inputsStore = writable<EditingInputAPI[]>([]);
$: editingInputs = $inputsStore;
@ -67,13 +68,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
function focusEditingInputIfFocusTrapFocused(): void {
if (document.activeElement === focusTrap) {
if (focusTrap && focusTrap.isFocusTrap(document.activeElement!)) {
focusEditingInputIfAvailable();
}
}
$: {
$inputsStore;
/**
* Triggers when all editing inputs are hidden,
* the editor field has focus, and then some
* editing input is shown
*/
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(
api,
@ -130,13 +136,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
});
</script>
<input
bind:this={focusTrap}
readonly
tabindex="-1"
class="focus-trap"
on:focus={focusEditingInputInsteadIfAvailable}
/>
<FocusTrap bind:this={focusTrap} on:focus={focusEditingInputInsteadIfAvailable} />
<div bind:this={editingArea} class="editing-area" on:focusout={trapFocusOnBlurOut}>
<slot />
@ -144,6 +144,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
.editing-area {
display: grid;
/* TODO allow configuration of grid #1503 */
/* grid-template-columns: repeat(2, 1fr); */
position: relative;
background: var(--frame-bg);
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;
}
}
.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,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 { 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}
<Fields>
{#each fieldsData as field, index}
<EditorField
{field}
content={fieldStores[index]}
autofocus={index === focusTo}
api={fields[index]}
on:focusin={() => {
$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"}
>
<svelte:fragment slot="field-state">
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
<RichTextBadge bind:off={richTextsHidden[index]} />
<PlainTextBadge bind:off={plainTextsHidden[index]} />
{#if stickies}
<StickyBadge active={stickies[index]} {index} />
{/if}
</svelte:fragment>
<DecoratedElements>
{#each fieldsData as field, index}
<EditorField
{field}
content={fieldStores[index]}
autofocus={index === focusTo}
api={fields[index]}
on:focusin={() => {
$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"}
>
<svelte:fragment slot="field-state">
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
<RichTextBadge bind:off={richTextsHidden[index]} />
<PlainTextBadge bind:off={plainTextsHidden[index]} />
{#if stickies}
<StickyBadge active={stickies[index]} {index} />
{/if}
</svelte:fragment>
<svelte:fragment slot="editing-inputs">
<DecoratedElements>
<svelte:fragment slot="editing-inputs">
<RichTextInput
hidden={richTextsHidden[index]}
on:focusin={() => {
@ -340,10 +344,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}}
bind:this={plainTextInputs[index]}
/>
</DecoratedElements>
</svelte:fragment>
</EditorField>
{/each}
</svelte:fragment>
</EditorField>
{/each}
<MathjaxElement />
<FrameElement />
</DecoratedElements>
</Fields>
</FieldsEditor>

View File

@ -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[] = [];
</script>
<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();
// TODO Expose this somehow
const parseStyle = "<style>anki-mathjax { white-space: pre; }</style>";
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;
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>
<style lang="scss">
.plain-text-input :global(.CodeMirror) {
border-radius: 0 0 5px 5px;
}
.plain-text-input {
overflow-x: hidden;
.hidden {
display: none;
:global(.CodeMirror) {
border-radius: 0 0 5px 5px;
}
&.hidden {
display: none;
}
}
</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">
import type CustomStyles from "./CustomStyles.svelte";
import type { EditingInputAPI } from "./EditingArea.svelte";
import type { ContentEditableAPI } from "../editable/ContentEditable.svelte";
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";
export interface RichTextInputAPI extends EditingInputAPI {
export interface RichTextInputAPI extends EditingInputAPI, ContentEditableAPI {
name: "rich-text";
shadowRoot: Promise<ShadowRoot>;
element: Promise<HTMLElement>;
@ -17,7 +22,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
refocus(): void;
toggle(): boolean;
preventResubscription(): () => void;
getTriggerOnNextInsert(): OnNextInsertTrigger;
getTriggerOnNextInsert(): Trigger<OnInsertCallback>;
getTriggerOnInput(): Trigger<OnInputCallback>;
getTriggerAfterInput(): Trigger<OnInputCallback>;
}
export interface RichTextInputContextAPI {
@ -31,20 +38,31 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
contextProperty<RichTextInputContextAPI>(key);
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 lang="ts">
import RichTextStyles from "./RichTextStyles.svelte";
import SetContext from "./SetContext.svelte";
import ContentEditable from "../editable/ContentEditable.svelte";
import { onMount, getAllContexts } from "svelte";
import {
nodeIsElement,
nodeContainsInlineContent,
fragmentToString,
caretToEnd,
} from "../lib/dom";
import { placeCaretAfterContent } from "../domlib/place-caret";
import { getDecoratedElements } from "./DecoratedElements.svelte";
import { getEditingArea } from "./EditingArea.svelte";
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 { manager, getTriggerOnNextInsert } = getInputManager();
const localInputManager = getInputManager();
function moveCaretToEnd() {
richTextPromise.then(caretToEnd);
richTextPromise.then(placeCaretAfterContent);
}
const allContexts = getAllContexts();
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 = {
export const api = {
name: "rich-text",
shadowRoot: shadowPromise,
element: richTextPromise,
focus() {
richTextPromise.then((richText) => richText.focus());
richTextPromise.then((richText) => {
richText.focus();
});
},
refocus() {
richTextPromise.then((richText) => {
@ -203,8 +202,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
},
moveCaretToEnd,
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 {
api.focusable = !hidden;
@ -230,30 +250,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
});
</script>
<RichTextStyles
color={$pageTheme.isDark ? "white" : "black"}
let:attachToShadow={attachStyles}
let:promise={stylesPromise}
let:stylesDidLoad
>
<div
class:hidden
class:night-mode={$pageTheme.isDark}
use:attachShadow
use:attachStyles
use:attachContentEditable={{ stylesDidLoad }}
on:focusin
on:focusout
/>
<div class="rich-text-input">
<RichTextStyles
color={$pageTheme.isDark ? "white" : "black"}
let:attachToShadow={attachStyles}
let:promise={stylesPromise}
let:stylesDidLoad
>
<div
class="rich-text-editable"
class:hidden
class:night-mode={$pageTheme.isDark}
use:attachShadow
use:attachStyles
use:attachContentEditable={{ stylesDidLoad }}
on:focusin
on:focusout
/>
<div class="editable-widgets">
{#await Promise.all([richTextPromise, stylesPromise]) then [container, styles]}
<SetContext setter={set} value={{ container, styles, api }}>
<slot />
</SetContext>
{/await}
</div>
</RichTextStyles>
<div class="rich-text-widgets">
{#await Promise.all( [richTextPromise, stylesPromise], ) then [container, styles]}
<SetContext setter={set} value={{ container, styles, api }}>
<slot />
</SetContext>
{/await}
</div>
</RichTextStyles>
</div>
<style lang="scss">
.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,
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 { inlineIcon, blockIcon, deleteIcon } from "./icons";
import { createEventDispatcher } from "svelte";
import { hasBlockAttribute } from "../../lib/dom";
export let activeImage: HTMLImageElement;
export let mathjaxElement: HTMLElement;
export let element: Element;
$: isBlock = hasBlockAttribute(element);
function updateBlock() {
element.setAttribute("block", String(isBlock));
}
const dispatch = createEventDispatcher();
</script>
@ -24,8 +30,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem>
<IconButton
tooltip={tr.editingMathjaxInline()}
active={activeImage.getAttribute("block") === "true"}
on:click={() => mathjaxElement.setAttribute("block", "false")}
active={!isBlock}
on:click={() => {
isBlock = false;
updateBlock();
}}
on:click>{@html inlineIcon}</IconButton
>
</ButtonGroupItem>
@ -33,8 +42,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem>
<IconButton
tooltip={tr.editingMathjaxBlock()}
active={activeImage.getAttribute("block") === "false"}
on:click={() => mathjaxElement.setAttribute("block", "true")}
active={isBlock}
on:click={() => {
isBlock = true;
updateBlock();
}}
on:click>{@html blockIcon}</IconButton
>
</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
-->
<script lang="ts">
import { onMount, createEventDispatcher } from "svelte";
import CodeMirror from "../CodeMirror.svelte";
import type { CodeMirrorAPI } from "../CodeMirror.svelte";
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 { noop } from "../../lib/functional";
import * as tr from "../../lib/ftl";
export let code: Writable<string>;
export let acceptShortcut: string;
export let newlineShortcut: string;
export let code: Writable<string>;
const configuration = {
...Object.assign({}, baseOptions, {
extraKeys: {
@ -29,15 +31,60 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}),
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>
<div class="mathjax-editor">
<CodeMirror
{code}
{configuration}
bind:api={codeMirror}
on:change={({ detail }) => code.set(detail)}
on:blur
autofocus
/>
</div>

View File

@ -5,14 +5,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts">
import WithDropdown from "../../components/WithDropdown.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 { writable } from "svelte/store";
import { getRichTextInput } from "../RichTextInput.svelte";
import { placeCaretAfter } from "../../domlib/place-caret";
import { noop } from "../../lib/functional";
import { on } from "../../lib/events";
import { Mathjax } from "../../editable/mathjax-element";
const { container, api } = getRichTextInput();
const { flushLocation, preventResubscription } = api;
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 unsubscribe = noop;
const caretKeyword = "caretAfter";
function showHandle(image: HTMLImageElement): void {
allow = api.preventResubscription();
allow = preventResubscription();
activeImage = image;
image.setAttribute(caretKeyword, "true");
mathjaxElement = activeImage.closest("anki-mathjax")!;
mathjaxElement = activeImage.closest(Mathjax.tagName)!;
code.set(mathjaxElement.dataset.mathjax ?? "");
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) {
unsubscribe();
activeImage = null;
@ -44,26 +58,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
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();
}
@ -81,13 +75,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
showHandle(detail);
}
async function showSelectAll({
detail,
}: CustomEvent<HTMLImageElement>): Promise<void> {
await resetHandle();
selectAll = true;
showHandle(detail);
}
onMount(() => {
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 () => {
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}
<MathjaxMenu
{activeImage}
{mathjaxElement}
{container}
{errorMessage}
element={mathjaxElement}
{code}
{selectAll}
bind:updateSelection
on:mount={(event) => (dropdownApi = createDropdown(event.detail.selection))}
on:reset={() => resetHandle(false)}
on:delete={() => resetHandle(true)}
/>
on:reset={resetHandle}
on:moveoutstart={() => {
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}
</WithDropdown>

View File

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

View File

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

View File

@ -2,7 +2,12 @@
// 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 {
const root = element.getRootNode();
@ -13,3 +18,14 @@ export function getSelection(element: Node): Selection | null {
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
// 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 {
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
const BLOCK_ELEMENTS = [
export const BLOCK_ELEMENTS = [
"ADDRESS",
"ARTICLE",
"ASIDE",
@ -46,12 +48,22 @@ const BLOCK_ELEMENTS = [
"UL",
];
export function hasBlockAttribute(element: Element): boolean {
return element.hasAttribute("block") && element.getAttribute("block") !== "false";
}
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
const EMPTY_ELEMENTS = [
export const EMPTY_ELEMENTS = [
"AREA",
"BASE",
"BR",
@ -94,25 +106,10 @@ export function fragmentToString(fragment: DocumentFragment): string {
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 =
<T extends Element>(predicate: (element: Element) => element is T) =>
(root: DocumentOrShadowRoot): T | null => {
const anchor = root.getSelection()?.anchorNode;
(root: Node): T | null => {
const anchor = getSelection(root)?.anchorNode;
if (!anchor) {
return null;

View File

@ -1,6 +1,14 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const noop: () => void = () => {
export function noop(): void {
/* 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;
}
export function keyboardEventIsPrintableKey(event: KeyboardEvent): boolean {
return event.key.length === 1;
}
export const checkModifiers =
(required: Modifier[], optional: Modifier[] = []) =>
(event: KeyboardEvent): boolean => {

View File

@ -1,17 +1,21 @@
// Copyright: Ankitects Pty Ltd and contributors
// 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 {
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;
return match[1] + front + match[2] + back + match[3];
}
function moveCursorPastPostfix(selection: Selection, postfix: string): void {
const range = selection.getRangeAt(0);
function moveCursorPastPostfix(
selection: Selection,
range: Range,
postfix: string,
): void {
range.setStart(range.startContainer, range.startOffset - postfix.length);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
@ -23,7 +27,12 @@ export function wrapInternal(
plainText: boolean,
): void {
const selection = getSelection(base)!;
const range = selection.getRangeAt(0);
const range = getRange(selection);
if (!range) {
return;
}
const content = range.cloneContents();
const span = document.createElement("span");
span.appendChild(content);
@ -42,6 +51,6 @@ export function wrapInternal(
"<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 type { Writable } from "svelte/store";
import { keyboardEventIsPrintableKey } from "../lib/keys";
import { on } from "../lib/events";
import { nodeIsText } from "../lib/dom";
import { id } from "../lib/functional";
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 {
add: (callback: OnInsertCallback) => void;
remove: () => void;
export type OnInputCallback = ({ event }: { event: InputEvent }) => Promise<void>;
export interface Trigger<C> {
add(callback: C): void;
remove(): void;
active: Writable<boolean>;
}
export type Managed<C> = Pick<Trigger<C>, "remove"> & { callback: C };
export type InputManagerAction = (element: HTMLElement) => { destroy(): void };
interface InputManager {
manager(element: HTMLElement): { destroy(): void };
getTriggerOnNextInsert(): OnNextInsertTrigger;
manager: InputManagerAction;
getTriggerOnNextInsert(): Trigger<OnInsertCallback>;
getTriggerOnInput(): Trigger<OnInputCallback>;
getTriggerAfterInput(): Trigger<OnInputCallback>;
}
function getInputManager(): InputManager {
const onInsertText: { callback: OnInsertCallback; remove: () => void }[] = [];
function trigger<C>(list: Managed<C>[]) {
return function getTrigger(): Trigger<C> {
const index = list.length++;
const active = writable(false);
function cancelInsertText(): void {
onInsertText.length = 0;
}
function cancelIfInsertText(event: KeyboardEvent): void {
if (event.key.length !== 1) {
cancelInsertText();
function remove() {
delete list[index];
active.set(false);
}
}
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> {
if (event.inputType === "insertText" && onInsertText.length > 0) {
const nbsp = " ";
const selection = getSelection(event.target! as Node)!;
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 node = new Text(textContent);
const selection = getSelection(event.target! as Node)!;
const range = selection.getRangeAt(0);
range.deleteContents();
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.insertNode(node);
range.selectNode(node);
range.collapse(false);
for (const { callback, remove } of onInsertText) {
await callback({ node });
for (const { callback, remove } of filteredBeforeInsertText) {
await callback({ node, event });
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 {
@ -92,55 +142,33 @@ function getInputManager(): InputManager {
function manager(element: HTMLElement): { destroy(): void } {
const removeBeforeInput = on(element, "beforeinput", onBeforeInput);
const removePointerDown = on(element, "pointerdown", cancelInsertText);
const removeBlur = on(element, "blur", cancelInsertText);
const removeKeyDown = on(
const removeInput = on(
element,
"keydown",
cancelIfInsertText as EventListener,
"input",
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 {
destroy() {
removeInput();
removeBeforeInput();
removePointerDown();
removeBlur();
removePointerDown();
removeKeyDown();
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 {
manager,
getTriggerOnNextInsert,
getTriggerOnNextInsert: trigger(beforeInsertText),
getTriggerOnInput: trigger(beforeInput),
getTriggerAfterInput: trigger(afterInput),
};
}

View File

@ -4,6 +4,7 @@
import { writable } from "svelte/store";
import type { Writable } from "svelte/store";
import storeSubscribe from "./store-subscribe";
// import { getSelection } from "../lib/cross-browser";
const config = {
childList: true,
@ -12,15 +13,22 @@ const config = {
characterData: true,
};
interface DOMMirror {
mirror(
element: Element,
params: { store: Writable<DocumentFragment> },
): { destroy(): void };
export type MirrorAction = (
element: Element,
params: { store: Writable<DocumentFragment> },
) => { destroy(): void };
interface DOMMirrorAPI {
mirror: MirrorAction;
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);
function preventResubscription() {
@ -64,6 +72,20 @@ function getDOMMirror(): DOMMirror {
}
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 */
element.addEventListener("focus", unsubscribe);
@ -71,14 +93,15 @@ function getDOMMirror(): DOMMirror {
const unsubResubscription = allowResubscription.subscribe(
(allow: boolean): void => {
if (allow) {
element.addEventListener("blur", subscribe);
element.addEventListener("blur", doSubscribe);
const root = element.getRootNode() as Document | ShadowRoot;
if (root.activeElement !== element) {
subscribe();
doSubscribe();
}
} else {
element.removeEventListener("blur", subscribe);
element.removeEventListener("blur", doSubscribe);
}
},
);