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:
parent
a6c65efd36
commit
739e286b0b
@ -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
66
ts/domlib/move-nodes.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
/>
|
||||
|
||||
|
79
ts/editable/content-editable.ts
Normal file
79
ts/editable/content-editable.ts
Normal 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;
|
272
ts/editable/frame-element.ts
Normal file
272
ts/editable/frame-element.ts
Normal 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
284
ts/editable/frame-handle.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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">
|
||||
|
@ -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] =
|
||||
|
@ -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>
|
||||
|
37
ts/editor/FocusTrap.svelte
Normal file
37
ts/editor/FocusTrap.svelte
Normal 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>
|
20
ts/editor/FrameElement.svelte
Normal file
20
ts/editor/FrameElement.svelte
Normal 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>
|
15
ts/editor/MathjaxElement.svelte
Normal file
15
ts/editor/MathjaxElement.svelte
Normal 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>
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
15
ts/editor/TagStickyBadge.svelte
Normal file
15
ts/editor/TagStickyBadge.svelte
Normal 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
|
||||
>
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 => {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
39
ts/sveltelib/action-list.ts
Normal file
39
ts/sveltelib/action-list.ts
Normal 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;
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user