mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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
36 changed files with 1498 additions and 489 deletions
|
@ -4,7 +4,7 @@
|
||||||
import { getNodeCoordinates } from "./node";
|
import { getNodeCoordinates } from "./node";
|
||||||
import type { CaretLocation } from "./location";
|
import type { CaretLocation } from "./location";
|
||||||
import { compareLocations, Position } from "./location";
|
import { compareLocations, Position } from "./location";
|
||||||
import { getSelection } from "../../lib/cross-browser";
|
import { getSelection, getRange } from "../../lib/cross-browser";
|
||||||
|
|
||||||
export interface SelectionLocationCollapsed {
|
export interface SelectionLocationCollapsed {
|
||||||
readonly anchor: CaretLocation;
|
readonly anchor: CaretLocation;
|
||||||
|
@ -20,19 +20,17 @@ export interface SelectionLocationContent {
|
||||||
|
|
||||||
export type SelectionLocation = SelectionLocationCollapsed | SelectionLocationContent;
|
export type SelectionLocation = SelectionLocationCollapsed | SelectionLocationContent;
|
||||||
|
|
||||||
/* Gecko can have multiple ranges in the selection
|
|
||||||
/* this function will get the coordinates of the latest one created */
|
|
||||||
export function getSelectionLocation(base: Node): SelectionLocation | null {
|
export function getSelectionLocation(base: Node): SelectionLocation | null {
|
||||||
const selection = getSelection(base)!;
|
const selection = getSelection(base)!;
|
||||||
|
const range = getRange(selection);
|
||||||
|
|
||||||
if (selection.rangeCount === 0) {
|
if (!range) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collapsed = range.collapsed;
|
||||||
const anchorCoordinates = getNodeCoordinates(selection.anchorNode!, base);
|
const anchorCoordinates = getNodeCoordinates(selection.anchorNode!, base);
|
||||||
const anchor = { coordinates: anchorCoordinates, offset: selection.anchorOffset };
|
const anchor = { coordinates: anchorCoordinates, offset: selection.anchorOffset };
|
||||||
/* selection.isCollapsed will always return true in shadow root in Gecko */
|
|
||||||
const collapsed = selection.getRangeAt(selection.rangeCount - 1).collapsed;
|
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return { anchor, collapsed };
|
return { anchor, collapsed };
|
||||||
|
|
66
ts/domlib/move-nodes.ts
Normal file
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";
|
import { getSelection } from "../lib/cross-browser";
|
||||||
|
|
||||||
export function placeCaretAfter(node: Node): void {
|
function placeCaret(node: Node, range: Range): void {
|
||||||
const range = new Range();
|
|
||||||
range.setStartAfter(node);
|
|
||||||
range.collapse(false);
|
|
||||||
|
|
||||||
const selection = getSelection(node)!;
|
const selection = getSelection(node)!;
|
||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
selection.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function placeCaretBefore(node: Node): void {
|
||||||
|
const range = new Range();
|
||||||
|
range.setStartBefore(node);
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
placeCaret(node, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeCaretAfter(node: Node): void {
|
||||||
|
const range = new Range();
|
||||||
|
range.setStartAfter(node);
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
placeCaret(node, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeCaretAfterContent(node: Node): void {
|
||||||
|
const range = new Range();
|
||||||
|
range.selectNodeContents(node);
|
||||||
|
range.collapse(false);
|
||||||
|
|
||||||
|
placeCaret(node, range);
|
||||||
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { ascend, isOnlyChild } from "../../lib/node";
|
||||||
import { elementIsBlock } from "../../lib/dom";
|
import { elementIsBlock } from "../../lib/dom";
|
||||||
|
|
||||||
export function ascendWhileSingleInline(node: Node, base: Node): Node {
|
export function ascendWhileSingleInline(node: Node, base: Node): Node {
|
||||||
if (node.isSameNode(base)) {
|
if (node === base) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,9 +23,7 @@ export function findClosest(
|
||||||
}
|
}
|
||||||
|
|
||||||
current =
|
current =
|
||||||
current.isSameNode(base) || !current.parentElement
|
current === base || !current.parentElement ? null : current.parentElement;
|
||||||
? null
|
|
||||||
: current.parentElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return current;
|
return current;
|
||||||
|
@ -51,9 +49,7 @@ export function findFarthest(
|
||||||
}
|
}
|
||||||
|
|
||||||
current =
|
current =
|
||||||
current.isSameNode(base) || !current.parentElement
|
current === base || !current.parentElement ? null : current.parentElement;
|
||||||
? null
|
|
||||||
: current.parentElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return found;
|
return found;
|
||||||
|
|
|
@ -48,7 +48,7 @@ const tryMergingTillMismatch =
|
||||||
areSiblingChildNodeRanges(
|
areSiblingChildNodeRanges(
|
||||||
childNodeRange,
|
childNodeRange,
|
||||||
nextChildNodeRange,
|
nextChildNodeRange,
|
||||||
) /* && !childNodeRange.parent.isSameNode(base)*/
|
) /* && !childNodeRange.parent === base */
|
||||||
) {
|
) {
|
||||||
const mergedChildNodeRange = mergeChildNodeRanges(
|
const mergedChildNodeRange = mergeChildNodeRanges(
|
||||||
childNodeRange,
|
childNodeRange,
|
||||||
|
@ -57,7 +57,7 @@ const tryMergingTillMismatch =
|
||||||
|
|
||||||
const newChildNodeRange =
|
const newChildNodeRange =
|
||||||
coversWholeParent(mergedChildNodeRange) &&
|
coversWholeParent(mergedChildNodeRange) &&
|
||||||
!mergedChildNodeRange.parent.isSameNode(base)
|
mergedChildNodeRange.parent !== base
|
||||||
? nodeToChildNodeRange(
|
? nodeToChildNodeRange(
|
||||||
ascendWhileSingleInline(
|
ascendWhileSingleInline(
|
||||||
mergedChildNodeRange.parent,
|
mergedChildNodeRange.parent,
|
||||||
|
|
|
@ -2,83 +2,48 @@
|
||||||
Copyright: Ankitects Pty Ltd and contributors
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
|
<script context="module" lang="ts">
|
||||||
|
export type { ContentEditableAPI } from "./content-editable";
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { updateAllState } from "../components/WithState.svelte";
|
import { updateAllState } from "../components/WithState.svelte";
|
||||||
import { saveSelection, restoreSelection } from "../domlib/location";
|
import actionList from "../sveltelib/action-list";
|
||||||
import { on, preventDefault } from "../lib/events";
|
import contentEditableAPI, {
|
||||||
import { caretToEnd } from "../lib/dom";
|
saveLocation,
|
||||||
import { registerShortcut } from "../lib/shortcuts";
|
prepareFocusHandling,
|
||||||
|
preventBuiltinContentEditableShortcuts,
|
||||||
|
} from "./content-editable";
|
||||||
|
import type { ContentEditableAPI } from "./content-editable";
|
||||||
|
import type { MirrorAction } from "../sveltelib/mirror-dom";
|
||||||
|
import type { InputManagerAction } from "../sveltelib/input-manager";
|
||||||
|
|
||||||
export let nodes: Writable<DocumentFragment>;
|
|
||||||
export let resolve: (editable: HTMLElement) => void;
|
export let resolve: (editable: HTMLElement) => void;
|
||||||
export let mirror: (
|
|
||||||
editable: HTMLElement,
|
|
||||||
params: { store: Writable<DocumentFragment> },
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export let inputManager: (editable: HTMLElement) => void;
|
export let mirrors: MirrorAction[];
|
||||||
|
export let nodes: Writable<DocumentFragment>;
|
||||||
|
|
||||||
let removeOnFocus: () => void;
|
const mirrorAction = actionList(mirrors);
|
||||||
let removeOnPointerdown: () => void;
|
const mirrorOptions = { store: nodes };
|
||||||
|
|
||||||
function onBlur(): void {
|
export let managers: InputManagerAction[];
|
||||||
const location = saveSelection(editable);
|
|
||||||
|
|
||||||
removeOnFocus = on(
|
const managerAction = actionList(managers);
|
||||||
editable,
|
|
||||||
"focus",
|
|
||||||
() => {
|
|
||||||
if (location) {
|
|
||||||
try {
|
|
||||||
restoreSelection(editable, location);
|
|
||||||
} catch {
|
|
||||||
caretToEnd(editable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
removeOnPointerdown = on(editable, "pointerdown", () => removeOnFocus?.(), {
|
export let api: Partial<ContentEditableAPI>;
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* must execute before DOMMirror */
|
Object.assign(api, contentEditableAPI);
|
||||||
function saveLocation(editable: HTMLElement) {
|
|
||||||
const removeOnBlur = on(editable, "blur", onBlur);
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
removeOnBlur();
|
|
||||||
removeOnFocus?.();
|
|
||||||
removeOnPointerdown?.();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let editable: HTMLElement;
|
|
||||||
|
|
||||||
$: if (editable) {
|
|
||||||
for (const keyCombination of [
|
|
||||||
"Control+B",
|
|
||||||
"Control+U",
|
|
||||||
"Control+I",
|
|
||||||
"Control+R",
|
|
||||||
]) {
|
|
||||||
registerShortcut(preventDefault, keyCombination, editable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<anki-editable
|
<anki-editable
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
bind:this={editable}
|
|
||||||
use:resolve
|
use:resolve
|
||||||
use:saveLocation
|
use:saveLocation
|
||||||
use:mirror={{ store: nodes }}
|
use:prepareFocusHandling
|
||||||
use:inputManager
|
use:preventBuiltinContentEditableShortcuts
|
||||||
|
use:mirrorAction={mirrorOptions}
|
||||||
|
use:managerAction={{}}
|
||||||
on:focus
|
on:focus
|
||||||
on:blur
|
on:blur
|
||||||
on:click={updateAllState}
|
on:click={updateAllState}
|
||||||
|
@ -88,9 +53,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
anki-editable {
|
anki-editable {
|
||||||
display: block;
|
display: block;
|
||||||
overflow-wrap: break-word;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
overflow: auto;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
|
@ -26,9 +26,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
export let mathjax: string;
|
export let mathjax: string;
|
||||||
export let block: boolean;
|
export let block: boolean;
|
||||||
|
export let fontSize: number;
|
||||||
export let autofocus = false;
|
|
||||||
export let fontSize = 20;
|
|
||||||
|
|
||||||
$: [converted, title] = convertMathjax(mathjax, $pageTheme.isDark, fontSize);
|
$: [converted, title] = convertMathjax(mathjax, $pageTheme.isDark, fontSize);
|
||||||
$: empty = title === "MathJax";
|
$: empty = title === "MathJax";
|
||||||
|
@ -40,19 +38,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
$: verticalCenter = -$imageHeight / 2 + fontSize / 4;
|
$: verticalCenter = -$imageHeight / 2 + fontSize / 4;
|
||||||
|
|
||||||
function maybeAutofocus(image: Element): void {
|
let image: HTMLImageElement;
|
||||||
if (!autofocus) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function moveCaretAfter(): void {
|
||||||
// This should trigger a focusing of the Mathjax Handle
|
// This should trigger a focusing of the Mathjax Handle
|
||||||
const focusEvent = new CustomEvent("focusmathjax", {
|
image.dispatchEvent(
|
||||||
detail: image,
|
new CustomEvent("movecaretafter", {
|
||||||
bubbles: true,
|
detail: image,
|
||||||
composed: true,
|
bubbles: true,
|
||||||
});
|
composed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
image.dispatchEvent(focusEvent);
|
export function selectAll(): void {
|
||||||
|
image.dispatchEvent(
|
||||||
|
new CustomEvent("selectall", {
|
||||||
|
detail: image,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function observe(image: Element) {
|
function observe(image: Element) {
|
||||||
|
@ -69,6 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
|
bind:this={image}
|
||||||
src="data:image/svg+xml,{encoded}"
|
src="data:image/svg+xml,{encoded}"
|
||||||
class:block
|
class:block
|
||||||
class:empty
|
class:empty
|
||||||
|
@ -78,7 +85,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
data-anki="mathjax"
|
data-anki="mathjax"
|
||||||
data-uuid={uuid}
|
data-uuid={uuid}
|
||||||
on:dragstart|preventDefault
|
on:dragstart|preventDefault
|
||||||
use:maybeAutofocus
|
|
||||||
use:observe
|
use:observe
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
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
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
/* eslint
|
|
||||||
@typescript-eslint/no-explicit-any: "off",
|
|
||||||
*/
|
|
||||||
|
|
||||||
import "mathjax/es5/tex-svg-full";
|
import "mathjax/es5/tex-svg-full";
|
||||||
|
|
||||||
|
import { on } from "../lib/events";
|
||||||
|
import { placeCaretBefore, placeCaretAfter } from "../domlib/place-caret";
|
||||||
import type { DecoratedElement, DecoratedElementConstructor } from "./decorated";
|
import type { DecoratedElement, DecoratedElementConstructor } from "./decorated";
|
||||||
import { nodeIsElement } from "../lib/dom";
|
import { FrameElement, frameElement } from "./frame-element";
|
||||||
import { noop } from "../lib/functional";
|
|
||||||
import { placeCaretAfter } from "../domlib/place-caret";
|
|
||||||
|
|
||||||
import Mathjax_svelte from "./Mathjax.svelte";
|
import Mathjax_svelte from "./Mathjax.svelte";
|
||||||
|
|
||||||
function moveNodeOutOfElement(
|
|
||||||
element: Element,
|
|
||||||
node: Node,
|
|
||||||
placement: "beforebegin" | "afterend",
|
|
||||||
): Node {
|
|
||||||
element.removeChild(node);
|
|
||||||
|
|
||||||
let referenceNode: Node;
|
|
||||||
|
|
||||||
if (nodeIsElement(node)) {
|
|
||||||
referenceNode = element.insertAdjacentElement(placement, node)!;
|
|
||||||
} else {
|
|
||||||
element.insertAdjacentText(placement, (node as Text).wholeText);
|
|
||||||
referenceNode =
|
|
||||||
placement === "beforebegin"
|
|
||||||
? element.previousSibling!
|
|
||||||
: element.nextSibling!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return referenceNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveNodesInsertedOutside(element: Element, allowedChild: Node): () => void {
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
if (element.childNodes.length === 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childNodes = [...element.childNodes];
|
|
||||||
const allowedIndex = childNodes.findIndex((child) => child === allowedChild);
|
|
||||||
|
|
||||||
const beforeChildren = childNodes.slice(0, allowedIndex);
|
|
||||||
const afterChildren = childNodes.slice(allowedIndex + 1);
|
|
||||||
|
|
||||||
// Special treatment for pressing return after mathjax block
|
|
||||||
if (
|
|
||||||
afterChildren.length === 2 &&
|
|
||||||
afterChildren.every((child) => (child as Element).tagName === "BR")
|
|
||||||
) {
|
|
||||||
const first = afterChildren.pop();
|
|
||||||
element.removeChild(first!);
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastNode: Node | null = null;
|
|
||||||
|
|
||||||
for (const node of beforeChildren) {
|
|
||||||
lastNode = moveNodeOutOfElement(element, node, "beforebegin");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const node of afterChildren) {
|
|
||||||
lastNode = moveNodeOutOfElement(element, node, "afterend");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastNode) {
|
|
||||||
placeCaretAfter(lastNode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(element, { childList: true, characterData: true });
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
const mathjaxTagPattern =
|
const mathjaxTagPattern =
|
||||||
/<anki-mathjax(?:[^>]*?block="(.*?)")?[^>]*?>(.*?)<\/anki-mathjax>/gsu;
|
/<anki-mathjax(?:[^>]*?block="(.*?)")?[^>]*?>(.*?)<\/anki-mathjax>/gsu;
|
||||||
|
|
||||||
|
@ -104,17 +38,17 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
|
||||||
.replace(
|
.replace(
|
||||||
mathjaxBlockDelimiterPattern,
|
mathjaxBlockDelimiterPattern,
|
||||||
(_match: string, text: string) =>
|
(_match: string, text: string) =>
|
||||||
`<anki-mathjax block="true">${text}</anki-mathjax>`,
|
`<${Mathjax.tagName} block="true">${text}</${Mathjax.tagName}>`,
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
mathjaxInlineDelimiterPattern,
|
mathjaxInlineDelimiterPattern,
|
||||||
(_match: string, text: string) =>
|
(_match: string, text: string) =>
|
||||||
`<anki-mathjax>${text}</anki-mathjax>`,
|
`<${Mathjax.tagName}>${text}</${Mathjax.tagName}>`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
block = false;
|
block = false;
|
||||||
disconnect = noop;
|
frame?: FrameElement;
|
||||||
component?: Mathjax_svelte;
|
component?: Mathjax_svelte;
|
||||||
|
|
||||||
static get observedAttributes(): string[] {
|
static get observedAttributes(): string[] {
|
||||||
|
@ -123,19 +57,25 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
this.decorate();
|
this.decorate();
|
||||||
this.disconnect = moveNodesInsertedOutside(this, this.children[0]);
|
this.addEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this.disconnect();
|
this.removeEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _old: string, newValue: string): void {
|
attributeChangedCallback(name: string, old: string, newValue: string): void {
|
||||||
|
if (newValue === old) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "block":
|
case "block":
|
||||||
this.block = newValue !== "false";
|
this.block = newValue !== "false";
|
||||||
this.component?.$set({ block: this.block });
|
this.component?.$set({ block: this.block });
|
||||||
|
this.frame?.setAttribute("block", String(this.block));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "data-mathjax":
|
case "data-mathjax":
|
||||||
this.component?.$set({ mathjax: newValue });
|
this.component?.$set({ mathjax: newValue });
|
||||||
break;
|
break;
|
||||||
|
@ -143,33 +83,93 @@ export const Mathjax: DecoratedElementConstructor = class Mathjax
|
||||||
}
|
}
|
||||||
|
|
||||||
decorate(): void {
|
decorate(): void {
|
||||||
const mathjax = (this.dataset.mathjax = this.innerText);
|
if (this.hasAttribute("decorated")) {
|
||||||
|
this.undecorate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.parentElement?.tagName === FrameElement.tagName.toUpperCase()) {
|
||||||
|
this.frame = this.parentElement as FrameElement;
|
||||||
|
} else {
|
||||||
|
frameElement(this, this.block);
|
||||||
|
/* Framing will place this element inside of an anki-frame element,
|
||||||
|
* causing the connectedCallback to be called again.
|
||||||
|
* If we'd continue decorating at this point, we'd loose all the information */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataset.mathjax = this.innerText;
|
||||||
this.innerHTML = "";
|
this.innerHTML = "";
|
||||||
this.style.whiteSpace = "normal";
|
this.style.whiteSpace = "normal";
|
||||||
|
|
||||||
this.component = new Mathjax_svelte({
|
this.component = new Mathjax_svelte({
|
||||||
target: this,
|
target: this,
|
||||||
props: {
|
props: {
|
||||||
mathjax,
|
mathjax: this.dataset.mathjax,
|
||||||
block: this.block,
|
block: this.block,
|
||||||
autofocus: this.hasAttribute("focusonmount"),
|
fontSize: 20,
|
||||||
},
|
},
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
|
if (this.hasAttribute("focusonmount")) {
|
||||||
|
this.component.moveCaretAfter();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setAttribute("contentEditable", "false");
|
||||||
|
this.setAttribute("decorated", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
undecorate(): void {
|
undecorate(): void {
|
||||||
|
if (this.parentElement?.tagName === FrameElement.tagName.toUpperCase()) {
|
||||||
|
this.parentElement.replaceWith(this);
|
||||||
|
}
|
||||||
|
|
||||||
this.innerHTML = this.dataset.mathjax ?? "";
|
this.innerHTML = this.dataset.mathjax ?? "";
|
||||||
delete this.dataset.mathjax;
|
delete this.dataset.mathjax;
|
||||||
this.removeAttribute("style");
|
this.removeAttribute("style");
|
||||||
this.removeAttribute("focusonmount");
|
this.removeAttribute("focusonmount");
|
||||||
|
|
||||||
this.component?.$destroy();
|
|
||||||
this.component = undefined;
|
|
||||||
|
|
||||||
if (this.block) {
|
if (this.block) {
|
||||||
this.setAttribute("block", "true");
|
this.setAttribute("block", "true");
|
||||||
} else {
|
} else {
|
||||||
this.removeAttribute("block");
|
this.removeAttribute("block");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.removeAttribute("contentEditable");
|
||||||
|
this.removeAttribute("decorated");
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMoveInStart?: () => void;
|
||||||
|
removeMoveInEnd?: () => void;
|
||||||
|
|
||||||
|
addEventListeners(): void {
|
||||||
|
this.removeMoveInStart = on(
|
||||||
|
this,
|
||||||
|
"moveinstart" as keyof HTMLElementEventMap,
|
||||||
|
() => this.component!.selectAll(),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.removeMoveInEnd = on(this, "moveinend" as keyof HTMLElementEventMap, () =>
|
||||||
|
this.component!.selectAll(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListeners(): void {
|
||||||
|
this.removeMoveInStart?.();
|
||||||
|
this.removeMoveInStart = undefined;
|
||||||
|
|
||||||
|
this.removeMoveInEnd?.();
|
||||||
|
this.removeMoveInEnd = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
placeCaretBefore(): void {
|
||||||
|
if (this.frame) {
|
||||||
|
placeCaretBefore(this.frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
placeCaretAfter(): void {
|
||||||
|
if (this.frame) {
|
||||||
|
placeCaretAfter(this.frame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,14 +11,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, getContext, onMount } from "svelte";
|
import { createEventDispatcher, getContext } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import storeSubscribe from "../sveltelib/store-subscribe";
|
import storeSubscribe from "../sveltelib/store-subscribe";
|
||||||
import { directionKey } from "../lib/context-keys";
|
import { directionKey } from "../lib/context-keys";
|
||||||
|
|
||||||
export let configuration: CodeMirror.EditorConfiguration;
|
export let configuration: CodeMirror.EditorConfiguration;
|
||||||
export let code: Writable<string>;
|
export let code: Writable<string>;
|
||||||
export let autofocus = false;
|
|
||||||
|
|
||||||
const direction = getContext<Writable<"ltr" | "rtl">>(directionKey);
|
const direction = getContext<Writable<"ltr" | "rtl">>(directionKey);
|
||||||
const defaultConfiguration = {
|
const defaultConfiguration = {
|
||||||
|
@ -78,13 +77,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
editor: { get: () => codeMirror },
|
editor: { get: () => codeMirror },
|
||||||
},
|
},
|
||||||
) as CodeMirrorAPI;
|
) as CodeMirrorAPI;
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (autofocus) {
|
|
||||||
codeMirror.focus();
|
|
||||||
codeMirror.setCursor(codeMirror.lineCount(), 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="code-mirror">
|
<div class="code-mirror">
|
||||||
|
|
|
@ -7,11 +7,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
CustomElementArray,
|
CustomElementArray,
|
||||||
DecoratedElementConstructor,
|
DecoratedElementConstructor,
|
||||||
} from "../editable/decorated";
|
} from "../editable/decorated";
|
||||||
import { Mathjax } from "../editable/mathjax-element";
|
|
||||||
import contextProperty from "../sveltelib/context-property";
|
import contextProperty from "../sveltelib/context-property";
|
||||||
|
|
||||||
const decoratedElements = new CustomElementArray<DecoratedElementConstructor>();
|
const decoratedElements = new CustomElementArray<DecoratedElementConstructor>();
|
||||||
decoratedElements.push(Mathjax);
|
|
||||||
|
|
||||||
const key = Symbol("decoratedElements");
|
const key = Symbol("decoratedElements");
|
||||||
const [set, getDecoratedElements, hasDecoratedElements] =
|
const [set, getDecoratedElements, hasDecoratedElements] =
|
||||||
|
|
|
@ -28,6 +28,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import FocusTrap from "./FocusTrap.svelte";
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import { onMount, setContext as svelteSetContext } from "svelte";
|
import { onMount, setContext as svelteSetContext } from "svelte";
|
||||||
import { fontFamilyKey, fontSizeKey } from "../lib/context-keys";
|
import { fontFamilyKey, fontSizeKey } from "../lib/context-keys";
|
||||||
|
@ -46,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export let autofocus = false;
|
export let autofocus = false;
|
||||||
|
|
||||||
let editingArea: HTMLElement;
|
let editingArea: HTMLElement;
|
||||||
let focusTrap: HTMLInputElement;
|
let focusTrap: FocusTrap;
|
||||||
|
|
||||||
const inputsStore = writable<EditingInputAPI[]>([]);
|
const inputsStore = writable<EditingInputAPI[]>([]);
|
||||||
$: editingInputs = $inputsStore;
|
$: editingInputs = $inputsStore;
|
||||||
|
@ -67,13 +68,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusEditingInputIfFocusTrapFocused(): void {
|
function focusEditingInputIfFocusTrapFocused(): void {
|
||||||
if (document.activeElement === focusTrap) {
|
if (focusTrap && focusTrap.isFocusTrap(document.activeElement!)) {
|
||||||
focusEditingInputIfAvailable();
|
focusEditingInputIfAvailable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
$inputsStore;
|
$inputsStore;
|
||||||
|
/**
|
||||||
|
* Triggers when all editing inputs are hidden,
|
||||||
|
* the editor field has focus, and then some
|
||||||
|
* editing input is shown
|
||||||
|
*/
|
||||||
focusEditingInputIfFocusTrapFocused();
|
focusEditingInputIfFocusTrapFocused();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,7 +117,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export let api: Partial<EditingAreaAPI> = {};
|
export let api: Partial<EditingAreaAPI>;
|
||||||
|
|
||||||
Object.assign(
|
Object.assign(
|
||||||
api,
|
api,
|
||||||
|
@ -130,13 +136,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<FocusTrap bind:this={focusTrap} on:focus={focusEditingInputInsteadIfAvailable} />
|
||||||
bind:this={focusTrap}
|
|
||||||
readonly
|
|
||||||
tabindex="-1"
|
|
||||||
class="focus-trap"
|
|
||||||
on:focus={focusEditingInputInsteadIfAvailable}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div bind:this={editingArea} class="editing-area" on:focusout={trapFocusOnBlurOut}>
|
<div bind:this={editingArea} class="editing-area" on:focusout={trapFocusOnBlurOut}>
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -144,6 +144,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.editing-area {
|
.editing-area {
|
||||||
|
display: grid;
|
||||||
|
/* TODO allow configuration of grid #1503 */
|
||||||
|
/* grid-template-columns: repeat(2, 1fr); */
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--frame-bg);
|
background: var(--frame-bg);
|
||||||
border-radius: 0 0 5px 5px;
|
border-radius: 0 0 5px 5px;
|
||||||
|
@ -152,18 +156,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.focus-trap {
|
|
||||||
display: block;
|
|
||||||
width: 0px;
|
|
||||||
height: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background: none;
|
|
||||||
resize: none;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
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 { MathjaxHandle } from "./mathjax-overlay";
|
||||||
import { ImageHandle } from "./image-overlay";
|
import { ImageHandle } from "./image-overlay";
|
||||||
import PlainTextInput from "./PlainTextInput.svelte";
|
import PlainTextInput from "./PlainTextInput.svelte";
|
||||||
|
import MathjaxElement from "./MathjaxElement.svelte";
|
||||||
|
import FrameElement from "./FrameElement.svelte";
|
||||||
|
|
||||||
import RichTextBadge from "./RichTextBadge.svelte";
|
import RichTextBadge from "./RichTextBadge.svelte";
|
||||||
import PlainTextBadge from "./PlainTextBadge.svelte";
|
import PlainTextBadge from "./PlainTextBadge.svelte";
|
||||||
|
@ -279,39 +281,41 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Fields>
|
<Fields>
|
||||||
{#each fieldsData as field, index}
|
<DecoratedElements>
|
||||||
<EditorField
|
{#each fieldsData as field, index}
|
||||||
{field}
|
<EditorField
|
||||||
content={fieldStores[index]}
|
{field}
|
||||||
autofocus={index === focusTo}
|
content={fieldStores[index]}
|
||||||
api={fields[index]}
|
autofocus={index === focusTo}
|
||||||
on:focusin={() => {
|
api={fields[index]}
|
||||||
$currentField = fields[index];
|
on:focusin={() => {
|
||||||
bridgeCommand(`focus:${index}`);
|
$currentField = fields[index];
|
||||||
}}
|
bridgeCommand(`focus:${index}`);
|
||||||
on:focusout={() => {
|
}}
|
||||||
$currentField = null;
|
on:focusout={() => {
|
||||||
bridgeCommand(
|
$currentField = null;
|
||||||
`blur:${index}:${getNoteId()}:${get(fieldStores[index])}`,
|
bridgeCommand(
|
||||||
);
|
`blur:${index}:${getNoteId()}:${get(
|
||||||
}}
|
fieldStores[index],
|
||||||
--label-color={cols[index] === "dupe"
|
)}`,
|
||||||
? "var(--flag1-bg)"
|
);
|
||||||
: "transparent"}
|
}}
|
||||||
>
|
--label-color={cols[index] === "dupe"
|
||||||
<svelte:fragment slot="field-state">
|
? "var(--flag1-bg)"
|
||||||
{#if cols[index] === "dupe"}
|
: "transparent"}
|
||||||
<DuplicateLink />
|
>
|
||||||
{/if}
|
<svelte:fragment slot="field-state">
|
||||||
<RichTextBadge bind:off={richTextsHidden[index]} />
|
{#if cols[index] === "dupe"}
|
||||||
<PlainTextBadge bind:off={plainTextsHidden[index]} />
|
<DuplicateLink />
|
||||||
{#if stickies}
|
{/if}
|
||||||
<StickyBadge active={stickies[index]} {index} />
|
<RichTextBadge bind:off={richTextsHidden[index]} />
|
||||||
{/if}
|
<PlainTextBadge bind:off={plainTextsHidden[index]} />
|
||||||
</svelte:fragment>
|
{#if stickies}
|
||||||
|
<StickyBadge active={stickies[index]} {index} />
|
||||||
|
{/if}
|
||||||
|
</svelte:fragment>
|
||||||
|
|
||||||
<svelte:fragment slot="editing-inputs">
|
<svelte:fragment slot="editing-inputs">
|
||||||
<DecoratedElements>
|
|
||||||
<RichTextInput
|
<RichTextInput
|
||||||
hidden={richTextsHidden[index]}
|
hidden={richTextsHidden[index]}
|
||||||
on:focusin={() => {
|
on:focusin={() => {
|
||||||
|
@ -340,10 +344,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}}
|
}}
|
||||||
bind:this={plainTextInputs[index]}
|
bind:this={plainTextInputs[index]}
|
||||||
/>
|
/>
|
||||||
</DecoratedElements>
|
</svelte:fragment>
|
||||||
</svelte:fragment>
|
</EditorField>
|
||||||
</EditorField>
|
{/each}
|
||||||
{/each}
|
|
||||||
|
<MathjaxElement />
|
||||||
|
<FrameElement />
|
||||||
|
</DecoratedElements>
|
||||||
</Fields>
|
</Fields>
|
||||||
</FieldsEditor>
|
</FieldsEditor>
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
toggle(): boolean;
|
toggle(): boolean;
|
||||||
getEditor(): CodeMirror.Editor;
|
getEditor(): CodeMirror.Editor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const parsingInstructions: string[] = [];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
@ -43,11 +45,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
// TODO Expose this somehow
|
|
||||||
const parseStyle = "<style>anki-mathjax { white-space: pre; }</style>";
|
|
||||||
|
|
||||||
function parseAsHTML(html: string): string {
|
function parseAsHTML(html: string): string {
|
||||||
const doc = parser.parseFromString(parseStyle + html, "text/html");
|
const doc = parser.parseFromString(
|
||||||
|
parsingInstructions.join("") + html,
|
||||||
|
"text/html",
|
||||||
|
);
|
||||||
const body = doc.body;
|
const body = doc.body;
|
||||||
|
|
||||||
for (const script of body.getElementsByTagName("script")) {
|
for (const script of body.getElementsByTagName("script")) {
|
||||||
|
@ -153,11 +156,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.plain-text-input :global(.CodeMirror) {
|
.plain-text-input {
|
||||||
border-radius: 0 0 5px 5px;
|
overflow-x: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
:global(.CodeMirror) {
|
||||||
display: none;
|
border-radius: 0 0 5px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -5,11 +5,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<script context="module" lang="ts">
|
<script context="module" lang="ts">
|
||||||
import type CustomStyles from "./CustomStyles.svelte";
|
import type CustomStyles from "./CustomStyles.svelte";
|
||||||
import type { EditingInputAPI } from "./EditingArea.svelte";
|
import type { EditingInputAPI } from "./EditingArea.svelte";
|
||||||
|
import type { ContentEditableAPI } from "../editable/ContentEditable.svelte";
|
||||||
import contextProperty from "../sveltelib/context-property";
|
import contextProperty from "../sveltelib/context-property";
|
||||||
import type { OnNextInsertTrigger } from "../sveltelib/input-manager";
|
import type {
|
||||||
|
Trigger,
|
||||||
|
OnInsertCallback,
|
||||||
|
OnInputCallback,
|
||||||
|
} from "../sveltelib/input-manager";
|
||||||
import { pageTheme } from "../sveltelib/theme";
|
import { pageTheme } from "../sveltelib/theme";
|
||||||
|
|
||||||
export interface RichTextInputAPI extends EditingInputAPI {
|
export interface RichTextInputAPI extends EditingInputAPI, ContentEditableAPI {
|
||||||
name: "rich-text";
|
name: "rich-text";
|
||||||
shadowRoot: Promise<ShadowRoot>;
|
shadowRoot: Promise<ShadowRoot>;
|
||||||
element: Promise<HTMLElement>;
|
element: Promise<HTMLElement>;
|
||||||
|
@ -17,7 +22,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
refocus(): void;
|
refocus(): void;
|
||||||
toggle(): boolean;
|
toggle(): boolean;
|
||||||
preventResubscription(): () => void;
|
preventResubscription(): () => void;
|
||||||
getTriggerOnNextInsert(): OnNextInsertTrigger;
|
getTriggerOnNextInsert(): Trigger<OnInsertCallback>;
|
||||||
|
getTriggerOnInput(): Trigger<OnInputCallback>;
|
||||||
|
getTriggerAfterInput(): Trigger<OnInputCallback>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RichTextInputContextAPI {
|
export interface RichTextInputContextAPI {
|
||||||
|
@ -31,20 +38,31 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
contextProperty<RichTextInputContextAPI>(key);
|
contextProperty<RichTextInputContextAPI>(key);
|
||||||
|
|
||||||
export { getRichTextInput, hasRichTextInput };
|
export { getRichTextInput, hasRichTextInput };
|
||||||
|
|
||||||
|
import getDOMMirror from "../sveltelib/mirror-dom";
|
||||||
|
import getInputManager from "../sveltelib/input-manager";
|
||||||
|
|
||||||
|
const {
|
||||||
|
manager: globalInputManager,
|
||||||
|
getTriggerAfterInput,
|
||||||
|
getTriggerOnInput,
|
||||||
|
getTriggerOnNextInsert,
|
||||||
|
} = getInputManager();
|
||||||
|
|
||||||
|
export { getTriggerAfterInput, getTriggerOnInput, getTriggerOnNextInsert };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import RichTextStyles from "./RichTextStyles.svelte";
|
import RichTextStyles from "./RichTextStyles.svelte";
|
||||||
import SetContext from "./SetContext.svelte";
|
import SetContext from "./SetContext.svelte";
|
||||||
import ContentEditable from "../editable/ContentEditable.svelte";
|
import ContentEditable from "../editable/ContentEditable.svelte";
|
||||||
|
|
||||||
import { onMount, getAllContexts } from "svelte";
|
import { onMount, getAllContexts } from "svelte";
|
||||||
import {
|
import {
|
||||||
nodeIsElement,
|
nodeIsElement,
|
||||||
nodeContainsInlineContent,
|
nodeContainsInlineContent,
|
||||||
fragmentToString,
|
fragmentToString,
|
||||||
caretToEnd,
|
|
||||||
} from "../lib/dom";
|
} from "../lib/dom";
|
||||||
|
import { placeCaretAfterContent } from "../domlib/place-caret";
|
||||||
import { getDecoratedElements } from "./DecoratedElements.svelte";
|
import { getDecoratedElements } from "./DecoratedElements.svelte";
|
||||||
import { getEditingArea } from "./EditingArea.svelte";
|
import { getEditingArea } from "./EditingArea.svelte";
|
||||||
import { promiseWithResolver } from "../lib/promise";
|
import { promiseWithResolver } from "../lib/promise";
|
||||||
|
@ -155,40 +173,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
import getDOMMirror from "../sveltelib/mirror-dom";
|
|
||||||
import getInputManager from "../sveltelib/input-manager";
|
|
||||||
|
|
||||||
const { mirror, preventResubscription } = getDOMMirror();
|
const { mirror, preventResubscription } = getDOMMirror();
|
||||||
const { manager, getTriggerOnNextInsert } = getInputManager();
|
const localInputManager = getInputManager();
|
||||||
|
|
||||||
function moveCaretToEnd() {
|
function moveCaretToEnd() {
|
||||||
richTextPromise.then(caretToEnd);
|
richTextPromise.then(placeCaretAfterContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allContexts = getAllContexts();
|
export const api = {
|
||||||
|
|
||||||
function attachContentEditable(element: Element, { stylesDidLoad }): void {
|
|
||||||
stylesDidLoad.then(
|
|
||||||
() =>
|
|
||||||
new ContentEditable({
|
|
||||||
target: element.shadowRoot!,
|
|
||||||
props: {
|
|
||||||
nodes,
|
|
||||||
resolve,
|
|
||||||
mirror,
|
|
||||||
inputManager: manager,
|
|
||||||
},
|
|
||||||
context: allContexts,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api: RichTextInputAPI = {
|
|
||||||
name: "rich-text",
|
name: "rich-text",
|
||||||
shadowRoot: shadowPromise,
|
shadowRoot: shadowPromise,
|
||||||
element: richTextPromise,
|
element: richTextPromise,
|
||||||
focus() {
|
focus() {
|
||||||
richTextPromise.then((richText) => richText.focus());
|
richTextPromise.then((richText) => {
|
||||||
|
richText.focus();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
refocus() {
|
refocus() {
|
||||||
richTextPromise.then((richText) => {
|
richTextPromise.then((richText) => {
|
||||||
|
@ -203,8 +202,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
},
|
},
|
||||||
moveCaretToEnd,
|
moveCaretToEnd,
|
||||||
preventResubscription,
|
preventResubscription,
|
||||||
getTriggerOnNextInsert,
|
getTriggerOnNextInsert: localInputManager.getTriggerOnNextInsert,
|
||||||
};
|
getTriggerOnInput: localInputManager.getTriggerOnInput,
|
||||||
|
getTriggerAfterInput: localInputManager.getTriggerAfterInput,
|
||||||
|
} as RichTextInputAPI;
|
||||||
|
|
||||||
|
const allContexts = getAllContexts();
|
||||||
|
|
||||||
|
function attachContentEditable(element: Element, { stylesDidLoad }): void {
|
||||||
|
stylesDidLoad.then(
|
||||||
|
() =>
|
||||||
|
new ContentEditable({
|
||||||
|
target: element.shadowRoot!,
|
||||||
|
props: {
|
||||||
|
nodes,
|
||||||
|
resolve,
|
||||||
|
mirrors: [mirror],
|
||||||
|
managers: [globalInputManager, localInputManager.manager],
|
||||||
|
api,
|
||||||
|
},
|
||||||
|
context: allContexts,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function pushUpdate(): void {
|
function pushUpdate(): void {
|
||||||
api.focusable = !hidden;
|
api.focusable = !hidden;
|
||||||
|
@ -230,30 +250,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<RichTextStyles
|
<div class="rich-text-input">
|
||||||
color={$pageTheme.isDark ? "white" : "black"}
|
<RichTextStyles
|
||||||
let:attachToShadow={attachStyles}
|
color={$pageTheme.isDark ? "white" : "black"}
|
||||||
let:promise={stylesPromise}
|
let:attachToShadow={attachStyles}
|
||||||
let:stylesDidLoad
|
let:promise={stylesPromise}
|
||||||
>
|
let:stylesDidLoad
|
||||||
<div
|
>
|
||||||
class:hidden
|
<div
|
||||||
class:night-mode={$pageTheme.isDark}
|
class="rich-text-editable"
|
||||||
use:attachShadow
|
class:hidden
|
||||||
use:attachStyles
|
class:night-mode={$pageTheme.isDark}
|
||||||
use:attachContentEditable={{ stylesDidLoad }}
|
use:attachShadow
|
||||||
on:focusin
|
use:attachStyles
|
||||||
on:focusout
|
use:attachContentEditable={{ stylesDidLoad }}
|
||||||
/>
|
on:focusin
|
||||||
|
on:focusout
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="editable-widgets">
|
<div class="rich-text-widgets">
|
||||||
{#await Promise.all([richTextPromise, stylesPromise]) then [container, styles]}
|
{#await Promise.all( [richTextPromise, stylesPromise], ) then [container, styles]}
|
||||||
<SetContext setter={set} value={{ container, styles, api }}>
|
<SetContext setter={set} value={{ container, styles, api }}>
|
||||||
<slot />
|
<slot />
|
||||||
</SetContext>
|
</SetContext>
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
</RichTextStyles>
|
</RichTextStyles>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.hidden {
|
.hidden {
|
||||||
|
|
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,
|
lineNumbers: true,
|
||||||
foldGutter: 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 * as tr from "../../lib/ftl";
|
||||||
import { inlineIcon, blockIcon, deleteIcon } from "./icons";
|
import { inlineIcon, blockIcon, deleteIcon } from "./icons";
|
||||||
import { createEventDispatcher } from "svelte";
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import { hasBlockAttribute } from "../../lib/dom";
|
||||||
|
|
||||||
export let activeImage: HTMLImageElement;
|
export let element: Element;
|
||||||
export let mathjaxElement: HTMLElement;
|
|
||||||
|
$: isBlock = hasBlockAttribute(element);
|
||||||
|
|
||||||
|
function updateBlock() {
|
||||||
|
element.setAttribute("block", String(isBlock));
|
||||||
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
</script>
|
</script>
|
||||||
|
@ -24,8 +30,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<ButtonGroupItem>
|
<ButtonGroupItem>
|
||||||
<IconButton
|
<IconButton
|
||||||
tooltip={tr.editingMathjaxInline()}
|
tooltip={tr.editingMathjaxInline()}
|
||||||
active={activeImage.getAttribute("block") === "true"}
|
active={!isBlock}
|
||||||
on:click={() => mathjaxElement.setAttribute("block", "false")}
|
on:click={() => {
|
||||||
|
isBlock = false;
|
||||||
|
updateBlock();
|
||||||
|
}}
|
||||||
on:click>{@html inlineIcon}</IconButton
|
on:click>{@html inlineIcon}</IconButton
|
||||||
>
|
>
|
||||||
</ButtonGroupItem>
|
</ButtonGroupItem>
|
||||||
|
@ -33,8 +42,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<ButtonGroupItem>
|
<ButtonGroupItem>
|
||||||
<IconButton
|
<IconButton
|
||||||
tooltip={tr.editingMathjaxBlock()}
|
tooltip={tr.editingMathjaxBlock()}
|
||||||
active={activeImage.getAttribute("block") === "false"}
|
active={isBlock}
|
||||||
on:click={() => mathjaxElement.setAttribute("block", "true")}
|
on:click={() => {
|
||||||
|
isBlock = true;
|
||||||
|
updateBlock();
|
||||||
|
}}
|
||||||
on:click>{@html blockIcon}</IconButton
|
on:click>{@html blockIcon}</IconButton
|
||||||
>
|
>
|
||||||
</ButtonGroupItem>
|
</ButtonGroupItem>
|
||||||
|
|
|
@ -3,18 +3,20 @@ Copyright: Ankitects Pty Ltd and contributors
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, createEventDispatcher } from "svelte";
|
||||||
import CodeMirror from "../CodeMirror.svelte";
|
import CodeMirror from "../CodeMirror.svelte";
|
||||||
|
import type { CodeMirrorAPI } from "../CodeMirror.svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { baseOptions, latex } from "../code-mirror";
|
import { baseOptions, latex, focusAndCaretAfter } from "../code-mirror";
|
||||||
import { getPlatformString } from "../../lib/shortcuts";
|
import { getPlatformString } from "../../lib/shortcuts";
|
||||||
import { noop } from "../../lib/functional";
|
import { noop } from "../../lib/functional";
|
||||||
import * as tr from "../../lib/ftl";
|
import * as tr from "../../lib/ftl";
|
||||||
|
|
||||||
|
export let code: Writable<string>;
|
||||||
|
|
||||||
export let acceptShortcut: string;
|
export let acceptShortcut: string;
|
||||||
export let newlineShortcut: string;
|
export let newlineShortcut: string;
|
||||||
|
|
||||||
export let code: Writable<string>;
|
|
||||||
|
|
||||||
const configuration = {
|
const configuration = {
|
||||||
...Object.assign({}, baseOptions, {
|
...Object.assign({}, baseOptions, {
|
||||||
extraKeys: {
|
extraKeys: {
|
||||||
|
@ -29,15 +31,60 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}),
|
}),
|
||||||
mode: latex,
|
mode: latex,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export let selectAll: boolean;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let codeMirror: CodeMirrorAPI = {} as CodeMirrorAPI;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
focusAndCaretAfter(codeMirror.editor);
|
||||||
|
|
||||||
|
if (selectAll) {
|
||||||
|
codeMirror.editor.execCommand("selectAll");
|
||||||
|
}
|
||||||
|
|
||||||
|
let direction: "start" | "end" | undefined = undefined;
|
||||||
|
|
||||||
|
codeMirror.editor.on("keydown", (_instance, event: KeyboardEvent) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
direction = "start";
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
direction = "end";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
codeMirror.editor.on(
|
||||||
|
"beforeSelectionChange",
|
||||||
|
(instance, obj: CodeMirror.EditorSelectionChange) => {
|
||||||
|
const { anchor } = obj.ranges[0];
|
||||||
|
|
||||||
|
if (anchor["hitSide"]) {
|
||||||
|
if (instance.getValue().length === 0) {
|
||||||
|
if (direction) {
|
||||||
|
dispatch(`moveout${direction}`);
|
||||||
|
}
|
||||||
|
} else if (anchor.line === 0 && anchor.ch === 0) {
|
||||||
|
dispatch("moveoutstart");
|
||||||
|
} else {
|
||||||
|
dispatch("moveoutend");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
direction = undefined;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mathjax-editor">
|
<div class="mathjax-editor">
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
{code}
|
{code}
|
||||||
{configuration}
|
{configuration}
|
||||||
|
bind:api={codeMirror}
|
||||||
on:change={({ detail }) => code.set(detail)}
|
on:change={({ detail }) => code.set(detail)}
|
||||||
on:blur
|
on:blur
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -5,14 +5,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import WithDropdown from "../../components/WithDropdown.svelte";
|
import WithDropdown from "../../components/WithDropdown.svelte";
|
||||||
import MathjaxMenu from "./MathjaxMenu.svelte";
|
import MathjaxMenu from "./MathjaxMenu.svelte";
|
||||||
|
import HandleSelection from "../HandleSelection.svelte";
|
||||||
|
import HandleBackground from "../HandleBackground.svelte";
|
||||||
|
import HandleControl from "../HandleControl.svelte";
|
||||||
import { onMount, onDestroy, tick } from "svelte";
|
import { onMount, onDestroy, tick } from "svelte";
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import { getRichTextInput } from "../RichTextInput.svelte";
|
import { getRichTextInput } from "../RichTextInput.svelte";
|
||||||
import { placeCaretAfter } from "../../domlib/place-caret";
|
|
||||||
import { noop } from "../../lib/functional";
|
import { noop } from "../../lib/functional";
|
||||||
import { on } from "../../lib/events";
|
import { on } from "../../lib/events";
|
||||||
|
import { Mathjax } from "../../editable/mathjax-element";
|
||||||
|
|
||||||
const { container, api } = getRichTextInput();
|
const { container, api } = getRichTextInput();
|
||||||
|
const { flushLocation, preventResubscription } = api;
|
||||||
|
|
||||||
const code = writable("");
|
const code = writable("");
|
||||||
|
|
||||||
|
@ -21,14 +25,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let allow = noop;
|
let allow = noop;
|
||||||
let unsubscribe = noop;
|
let unsubscribe = noop;
|
||||||
|
|
||||||
const caretKeyword = "caretAfter";
|
|
||||||
|
|
||||||
function showHandle(image: HTMLImageElement): void {
|
function showHandle(image: HTMLImageElement): void {
|
||||||
allow = api.preventResubscription();
|
allow = preventResubscription();
|
||||||
|
|
||||||
activeImage = image;
|
activeImage = image;
|
||||||
image.setAttribute(caretKeyword, "true");
|
mathjaxElement = activeImage.closest(Mathjax.tagName)!;
|
||||||
mathjaxElement = activeImage.closest("anki-mathjax")!;
|
|
||||||
|
|
||||||
code.set(mathjaxElement.dataset.mathjax ?? "");
|
code.set(mathjaxElement.dataset.mathjax ?? "");
|
||||||
unsubscribe = code.subscribe((value: string) => {
|
unsubscribe = code.subscribe((value: string) => {
|
||||||
|
@ -36,7 +37,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearImage(): Promise<void> {
|
let selectAll = false;
|
||||||
|
|
||||||
|
function placeHandle(after: boolean): void {
|
||||||
|
if (after) {
|
||||||
|
(mathjaxElement as any).placeCaretAfter();
|
||||||
|
} else {
|
||||||
|
(mathjaxElement as any).placeCaretBefore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetHandle(): Promise<void> {
|
||||||
|
flushLocation();
|
||||||
|
selectAll = false;
|
||||||
|
|
||||||
if (activeImage && mathjaxElement) {
|
if (activeImage && mathjaxElement) {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
activeImage = null;
|
activeImage = null;
|
||||||
|
@ -44,26 +58,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
container.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeCaret(image: HTMLImageElement): void {
|
|
||||||
placeCaretAfter(image);
|
|
||||||
image.removeAttribute(caretKeyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetHandle(deletes: boolean = false): Promise<void> {
|
|
||||||
await clearImage();
|
|
||||||
|
|
||||||
const image = container.querySelector(`[${caretKeyword}]`);
|
|
||||||
if (image) {
|
|
||||||
placeCaret(image as HTMLImageElement);
|
|
||||||
|
|
||||||
if (deletes) {
|
|
||||||
image.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allow();
|
allow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,13 +75,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
showHandle(detail);
|
showHandle(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showSelectAll({
|
||||||
|
detail,
|
||||||
|
}: CustomEvent<HTMLImageElement>): Promise<void> {
|
||||||
|
await resetHandle();
|
||||||
|
selectAll = true;
|
||||||
|
showHandle(detail);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const removeClick = on(container, "click", maybeShowHandle);
|
const removeClick = on(container, "click", maybeShowHandle);
|
||||||
const removeFocus = on(container, "focusmathjax" as any, showAutofocusHandle);
|
const removeCaretAfter = on(
|
||||||
|
container,
|
||||||
|
"movecaretafter" as any,
|
||||||
|
showAutofocusHandle,
|
||||||
|
);
|
||||||
|
const removeSelectAll = on(container, "selectall" as any, showSelectAll);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
removeClick();
|
removeClick();
|
||||||
removeFocus();
|
removeCaretAfter();
|
||||||
|
removeSelectAll();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -131,15 +139,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
>
|
>
|
||||||
{#if activeImage && mathjaxElement}
|
{#if activeImage && mathjaxElement}
|
||||||
<MathjaxMenu
|
<MathjaxMenu
|
||||||
{activeImage}
|
element={mathjaxElement}
|
||||||
{mathjaxElement}
|
|
||||||
{container}
|
|
||||||
{errorMessage}
|
|
||||||
{code}
|
{code}
|
||||||
|
{selectAll}
|
||||||
bind:updateSelection
|
bind:updateSelection
|
||||||
on:mount={(event) => (dropdownApi = createDropdown(event.detail.selection))}
|
on:reset={resetHandle}
|
||||||
on:reset={() => resetHandle(false)}
|
on:moveoutstart={() => {
|
||||||
on:delete={() => resetHandle(true)}
|
placeHandle(false);
|
||||||
/>
|
resetHandle();
|
||||||
|
}}
|
||||||
|
on:moveoutend={() => {
|
||||||
|
placeHandle(true);
|
||||||
|
resetHandle();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HandleSelection
|
||||||
|
image={activeImage}
|
||||||
|
{container}
|
||||||
|
bind:updateSelection
|
||||||
|
on:mount={(event) =>
|
||||||
|
(dropdownApi = createDropdown(event.detail.selection))}
|
||||||
|
>
|
||||||
|
<HandleBackground tooltip={errorMessage} />
|
||||||
|
<HandleControl offsetX={1} offsetY={1} />
|
||||||
|
</HandleSelection>
|
||||||
|
</MathjaxMenu>
|
||||||
{/if}
|
{/if}
|
||||||
</WithDropdown>
|
</WithDropdown>
|
||||||
|
|
|
@ -5,19 +5,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Shortcut from "../../components/Shortcut.svelte";
|
import Shortcut from "../../components/Shortcut.svelte";
|
||||||
import DropdownMenu from "../../components/DropdownMenu.svelte";
|
import DropdownMenu from "../../components/DropdownMenu.svelte";
|
||||||
import HandleSelection from "../HandleSelection.svelte";
|
|
||||||
import HandleBackground from "../HandleBackground.svelte";
|
|
||||||
import HandleControl from "../HandleControl.svelte";
|
|
||||||
import MathjaxEditor from "./MathjaxEditor.svelte";
|
import MathjaxEditor from "./MathjaxEditor.svelte";
|
||||||
import MathjaxButtons from "./MathjaxButtons.svelte";
|
import MathjaxButtons from "./MathjaxButtons.svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { createEventDispatcher } from "svelte";
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import { placeCaretAfter } from "../../domlib/place-caret";
|
||||||
|
|
||||||
export let activeImage: HTMLImageElement;
|
export let element: Element;
|
||||||
export let mathjaxElement: HTMLElement;
|
|
||||||
export let container: HTMLElement;
|
|
||||||
export let errorMessage: string;
|
|
||||||
export let code: Writable<string>;
|
export let code: Writable<string>;
|
||||||
|
export let selectAll: boolean;
|
||||||
|
|
||||||
const acceptShortcut = "Enter";
|
const acceptShortcut = "Enter";
|
||||||
const newlineShortcut = "Shift+Enter";
|
const newlineShortcut = "Shift+Enter";
|
||||||
|
@ -38,26 +34,32 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mathjax-menu">
|
<div class="mathjax-menu">
|
||||||
<HandleSelection image={activeImage} {container} bind:updateSelection on:mount>
|
<slot />
|
||||||
<HandleBackground tooltip={errorMessage} />
|
|
||||||
<HandleControl offsetX={1} offsetY={1} />
|
|
||||||
</HandleSelection>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<MathjaxEditor
|
<MathjaxEditor
|
||||||
{acceptShortcut}
|
{acceptShortcut}
|
||||||
{newlineShortcut}
|
{newlineShortcut}
|
||||||
{code}
|
{code}
|
||||||
|
{selectAll}
|
||||||
on:blur={() => dispatch("reset")}
|
on:blur={() => dispatch("reset")}
|
||||||
|
on:moveoutstart
|
||||||
|
on:moveoutend
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Shortcut keyCombination={acceptShortcut} on:action={() => dispatch("reset")} />
|
<Shortcut
|
||||||
|
keyCombination={acceptShortcut}
|
||||||
|
on:action={() => dispatch("moveoutend")}
|
||||||
|
/>
|
||||||
<Shortcut keyCombination={newlineShortcut} on:action={appendNewline} />
|
<Shortcut keyCombination={newlineShortcut} on:action={appendNewline} />
|
||||||
|
|
||||||
<MathjaxButtons
|
<MathjaxButtons
|
||||||
{activeImage}
|
{element}
|
||||||
{mathjaxElement}
|
on:delete={() => {
|
||||||
on:delete={() => dispatch("delete")}
|
placeCaretAfter(element);
|
||||||
|
element.remove();
|
||||||
|
dispatch("reset");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { getSelection } from "../lib/cross-browser";
|
import { getSelection, getRange } from "../lib/cross-browser";
|
||||||
import { surroundNoSplitting, unsurround, findClosest } from "../domlib/surround";
|
import { surroundNoSplitting, unsurround, findClosest } from "../domlib/surround";
|
||||||
import type { ElementMatcher, ElementClearer } from "../domlib/surround";
|
import type { ElementMatcher, ElementClearer } from "../domlib/surround";
|
||||||
import type { RichTextInputAPI } from "./RichTextInput.svelte";
|
import type { RichTextInputAPI } from "./RichTextInput.svelte";
|
||||||
|
@ -50,7 +50,11 @@ export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderRes
|
||||||
async function isSurrounded(matcher: ElementMatcher): Promise<boolean> {
|
async function isSurrounded(matcher: ElementMatcher): Promise<boolean> {
|
||||||
const base = await richTextInput.element;
|
const base = await richTextInput.element;
|
||||||
const selection = getSelection(base)!;
|
const selection = getSelection(base)!;
|
||||||
const range = selection.getRangeAt(0);
|
const range = getRange(selection);
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const isSurrounded = isSurroundedInner(range, base, matcher);
|
const isSurrounded = isSurroundedInner(range, base, matcher);
|
||||||
return get(active) ? !isSurrounded : isSurrounded;
|
return get(active) ? !isSurrounded : isSurrounded;
|
||||||
|
@ -63,9 +67,11 @@ export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderRes
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const base = await richTextInput.element;
|
const base = await richTextInput.element;
|
||||||
const selection = getSelection(base)!;
|
const selection = getSelection(base)!;
|
||||||
const range = selection.getRangeAt(0);
|
const range = getRange(selection);
|
||||||
|
|
||||||
if (range.collapsed) {
|
if (!range) {
|
||||||
|
return;
|
||||||
|
} else if (range.collapsed) {
|
||||||
if (get(active)) {
|
if (get(active)) {
|
||||||
remove();
|
remove();
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -2,7 +2,12 @@
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Firefox has no .getSelection on ShadowRoot, only .activeElement
|
* NOTES:
|
||||||
|
* - Avoid using selection.isCollapsed: will always return true in shadow root in Gecko
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gecko has no .getSelection on ShadowRoot, only .activeElement
|
||||||
*/
|
*/
|
||||||
export function getSelection(element: Node): Selection | null {
|
export function getSelection(element: Node): Selection | null {
|
||||||
const root = element.getRootNode();
|
const root = element.getRootNode();
|
||||||
|
@ -13,3 +18,14 @@ export function getSelection(element: Node): Selection | null {
|
||||||
|
|
||||||
return document.getSelection();
|
return document.getSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser has potential support for multiple ranges per selection built in,
|
||||||
|
* but in reality only Gecko supports it.
|
||||||
|
* If there are multiple ranges, the latest range is the _main_ one.
|
||||||
|
*/
|
||||||
|
export function getRange(selection: Selection): Range | null {
|
||||||
|
const rangeCount = selection.rangeCount;
|
||||||
|
|
||||||
|
return rangeCount === 0 ? null : selection.getRangeAt(rangeCount - 1);
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import { getSelection } from "./cross-browser";
|
||||||
|
|
||||||
export function nodeIsElement(node: Node): node is Element {
|
export function nodeIsElement(node: Node): node is Element {
|
||||||
return node.nodeType === Node.ELEMENT_NODE;
|
return node.nodeType === Node.ELEMENT_NODE;
|
||||||
}
|
}
|
||||||
|
@ -10,7 +12,7 @@ export function nodeIsText(node: Node): node is Text {
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
|
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
|
||||||
const BLOCK_ELEMENTS = [
|
export const BLOCK_ELEMENTS = [
|
||||||
"ADDRESS",
|
"ADDRESS",
|
||||||
"ARTICLE",
|
"ARTICLE",
|
||||||
"ASIDE",
|
"ASIDE",
|
||||||
|
@ -46,12 +48,22 @@ const BLOCK_ELEMENTS = [
|
||||||
"UL",
|
"UL",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function hasBlockAttribute(element: Element): boolean {
|
||||||
|
return element.hasAttribute("block") && element.getAttribute("block") !== "false";
|
||||||
|
}
|
||||||
|
|
||||||
export function elementIsBlock(element: Element): boolean {
|
export function elementIsBlock(element: Element): boolean {
|
||||||
return BLOCK_ELEMENTS.includes(element.tagName);
|
return BLOCK_ELEMENTS.includes(element.tagName) || hasBlockAttribute(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NO_SPLIT_TAGS = ["RUBY"];
|
||||||
|
|
||||||
|
export function elementShouldNotBeSplit(element: Element): boolean {
|
||||||
|
return elementIsBlock(element) || NO_SPLIT_TAGS.includes(element.tagName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
|
// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
|
||||||
const EMPTY_ELEMENTS = [
|
export const EMPTY_ELEMENTS = [
|
||||||
"AREA",
|
"AREA",
|
||||||
"BASE",
|
"BASE",
|
||||||
"BR",
|
"BR",
|
||||||
|
@ -94,25 +106,10 @@ export function fragmentToString(fragment: DocumentFragment): string {
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NO_SPLIT_TAGS = ["RUBY"];
|
|
||||||
|
|
||||||
export function elementShouldNotBeSplit(element: Element): boolean {
|
|
||||||
return elementIsBlock(element) || NO_SPLIT_TAGS.includes(element.tagName);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function caretToEnd(node: Node): void {
|
|
||||||
const range = new Range();
|
|
||||||
range.selectNodeContents(node);
|
|
||||||
range.collapse(false);
|
|
||||||
const selection = (node.getRootNode() as Document | ShadowRoot).getSelection()!;
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAnchorParent =
|
const getAnchorParent =
|
||||||
<T extends Element>(predicate: (element: Element) => element is T) =>
|
<T extends Element>(predicate: (element: Element) => element is T) =>
|
||||||
(root: DocumentOrShadowRoot): T | null => {
|
(root: Node): T | null => {
|
||||||
const anchor = root.getSelection()?.anchorNode;
|
const anchor = getSelection(root)?.anchorNode;
|
||||||
|
|
||||||
if (!anchor) {
|
if (!anchor) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
export const noop: () => void = () => {
|
export function noop(): void {
|
||||||
/* noop */
|
/* noop */
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export function id<T>(t: T): T {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truthy<T>(t: T | void | undefined | null): t is T {
|
||||||
|
return Boolean(t);
|
||||||
|
}
|
||||||
|
|
|
@ -23,6 +23,10 @@ export function checkIfInputKey(event: KeyboardEvent): boolean {
|
||||||
return event.location === GENERAL_KEY || event.location === NUMPAD_KEY;
|
return event.location === GENERAL_KEY || event.location === NUMPAD_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function keyboardEventIsPrintableKey(event: KeyboardEvent): boolean {
|
||||||
|
return event.key.length === 1;
|
||||||
|
}
|
||||||
|
|
||||||
export const checkModifiers =
|
export const checkModifiers =
|
||||||
(required: Modifier[], optional: Modifier[] = []) =>
|
(required: Modifier[], optional: Modifier[] = []) =>
|
||||||
(event: KeyboardEvent): boolean => {
|
(event: KeyboardEvent): boolean => {
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
import { getSelection } from "./cross-browser";
|
import { getSelection, getRange } from "./cross-browser";
|
||||||
|
|
||||||
function wrappedExceptForWhitespace(text: string, front: string, back: string): string {
|
function wrappedExceptForWhitespace(text: string, front: string, back: string): string {
|
||||||
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;
|
const match = text.match(/^(\s*)([^]*?)(\s*)$/)!;
|
||||||
return match[1] + front + match[2] + back + match[3];
|
return match[1] + front + match[2] + back + match[3];
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveCursorPastPostfix(selection: Selection, postfix: string): void {
|
function moveCursorPastPostfix(
|
||||||
const range = selection.getRangeAt(0);
|
selection: Selection,
|
||||||
|
range: Range,
|
||||||
|
postfix: string,
|
||||||
|
): void {
|
||||||
range.setStart(range.startContainer, range.startOffset - postfix.length);
|
range.setStart(range.startContainer, range.startOffset - postfix.length);
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
|
|
||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
selection.addRange(range);
|
selection.addRange(range);
|
||||||
}
|
}
|
||||||
|
@ -23,7 +27,12 @@ export function wrapInternal(
|
||||||
plainText: boolean,
|
plainText: boolean,
|
||||||
): void {
|
): void {
|
||||||
const selection = getSelection(base)!;
|
const selection = getSelection(base)!;
|
||||||
const range = selection.getRangeAt(0);
|
const range = getRange(selection);
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const content = range.cloneContents();
|
const content = range.cloneContents();
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.appendChild(content);
|
span.appendChild(content);
|
||||||
|
@ -42,6 +51,6 @@ export function wrapInternal(
|
||||||
"<anki-mathjax",
|
"<anki-mathjax",
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
moveCursorPastPostfix(selection, back);
|
moveCursorPastPostfix(selection, range, back);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
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 { writable } from "svelte/store";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
|
import { keyboardEventIsPrintableKey } from "../lib/keys";
|
||||||
import { on } from "../lib/events";
|
import { on } from "../lib/events";
|
||||||
import { nodeIsText } from "../lib/dom";
|
import { id } from "../lib/functional";
|
||||||
import { getSelection } from "../lib/cross-browser";
|
import { getSelection } from "../lib/cross-browser";
|
||||||
|
|
||||||
export type OnInsertCallback = ({ node }: { node: Node }) => Promise<void>;
|
export type OnInsertCallback = ({
|
||||||
|
node,
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
node: Node;
|
||||||
|
event: InputEvent;
|
||||||
|
}) => Promise<void>;
|
||||||
|
|
||||||
export interface OnNextInsertTrigger {
|
export type OnInputCallback = ({ event }: { event: InputEvent }) => Promise<void>;
|
||||||
add: (callback: OnInsertCallback) => void;
|
|
||||||
remove: () => void;
|
export interface Trigger<C> {
|
||||||
|
add(callback: C): void;
|
||||||
|
remove(): void;
|
||||||
active: Writable<boolean>;
|
active: Writable<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Managed<C> = Pick<Trigger<C>, "remove"> & { callback: C };
|
||||||
|
|
||||||
|
export type InputManagerAction = (element: HTMLElement) => { destroy(): void };
|
||||||
|
|
||||||
interface InputManager {
|
interface InputManager {
|
||||||
manager(element: HTMLElement): { destroy(): void };
|
manager: InputManagerAction;
|
||||||
getTriggerOnNextInsert(): OnNextInsertTrigger;
|
getTriggerOnNextInsert(): Trigger<OnInsertCallback>;
|
||||||
|
getTriggerOnInput(): Trigger<OnInputCallback>;
|
||||||
|
getTriggerAfterInput(): Trigger<OnInputCallback>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInputManager(): InputManager {
|
function trigger<C>(list: Managed<C>[]) {
|
||||||
const onInsertText: { callback: OnInsertCallback; remove: () => void }[] = [];
|
return function getTrigger(): Trigger<C> {
|
||||||
|
const index = list.length++;
|
||||||
|
const active = writable(false);
|
||||||
|
|
||||||
function cancelInsertText(): void {
|
function remove() {
|
||||||
onInsertText.length = 0;
|
delete list[index];
|
||||||
}
|
active.set(false);
|
||||||
|
|
||||||
function cancelIfInsertText(event: KeyboardEvent): void {
|
|
||||||
if (event.key.length !== 1) {
|
|
||||||
cancelInsertText();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
function add(callback: C): void {
|
||||||
|
list[index] = { callback, remove };
|
||||||
|
active.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
active,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nbsp = "\xa0";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface that allows Svelte components to attach event listeners via triggers.
|
||||||
|
* They will be attached to the component(s) that install the manager.
|
||||||
|
* Prevents that too many event listeners are attached and allows for some
|
||||||
|
* coordination between them.
|
||||||
|
*/
|
||||||
|
function getInputManager(): InputManager {
|
||||||
|
const beforeInput: Managed<OnInputCallback>[] = [];
|
||||||
|
const beforeInsertText: Managed<OnInsertCallback>[] = [];
|
||||||
|
|
||||||
async function onBeforeInput(event: InputEvent): Promise<void> {
|
async function onBeforeInput(event: InputEvent): Promise<void> {
|
||||||
if (event.inputType === "insertText" && onInsertText.length > 0) {
|
const selection = getSelection(event.target! as Node)!;
|
||||||
const nbsp = " ";
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
|
for (const { callback } of beforeInput.filter(id)) {
|
||||||
|
await callback({ event });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredBeforeInsertText = beforeInsertText.filter(id);
|
||||||
|
|
||||||
|
if (event.inputType === "insertText" && filteredBeforeInsertText.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
const textContent = event.data === " " ? nbsp : event.data ?? nbsp;
|
const textContent = event.data === " " ? nbsp : event.data ?? nbsp;
|
||||||
const node = new Text(textContent);
|
const node = new Text(textContent);
|
||||||
|
|
||||||
const selection = getSelection(event.target! as Node)!;
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
|
|
||||||
range.deleteContents();
|
range.deleteContents();
|
||||||
|
range.insertNode(node);
|
||||||
if (nodeIsText(range.startContainer) && range.startOffset === 0) {
|
|
||||||
const parent = range.startContainer.parentNode!;
|
|
||||||
parent.insertBefore(node, range.startContainer);
|
|
||||||
} else if (
|
|
||||||
nodeIsText(range.endContainer) &&
|
|
||||||
range.endOffset === range.endContainer.length
|
|
||||||
) {
|
|
||||||
const parent = range.endContainer.parentNode!;
|
|
||||||
parent.insertBefore(node, range.endContainer.nextSibling!);
|
|
||||||
} else {
|
|
||||||
range.insertNode(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
range.selectNode(node);
|
range.selectNode(node);
|
||||||
range.collapse(false);
|
range.collapse(false);
|
||||||
|
|
||||||
for (const { callback, remove } of onInsertText) {
|
for (const { callback, remove } of filteredBeforeInsertText) {
|
||||||
await callback({ node });
|
await callback({ node, event });
|
||||||
remove();
|
remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
/* we call explicitly because we prevented default */
|
||||||
|
callAfterInputHooks(event);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cancelInsertText();
|
const afterInput: Managed<OnInputCallback>[] = [];
|
||||||
|
|
||||||
|
async function callAfterInputHooks(event: InputEvent): Promise<void> {
|
||||||
|
for (const { callback } of afterInput.filter(id)) {
|
||||||
|
await callback({ event });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInsertText(): void {
|
||||||
|
for (const { remove } of beforeInsertText.filter(id)) {
|
||||||
|
remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInsertTextIfUnprintableKey(event: KeyboardEvent): void {
|
||||||
|
/* using arrow keys should cancel */
|
||||||
|
if (!keyboardEventIsPrintableKey(event)) {
|
||||||
|
clearInsertText();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInput(event: Event): void {
|
function onInput(event: Event): void {
|
||||||
|
@ -92,55 +142,33 @@ function getInputManager(): InputManager {
|
||||||
|
|
||||||
function manager(element: HTMLElement): { destroy(): void } {
|
function manager(element: HTMLElement): { destroy(): void } {
|
||||||
const removeBeforeInput = on(element, "beforeinput", onBeforeInput);
|
const removeBeforeInput = on(element, "beforeinput", onBeforeInput);
|
||||||
const removePointerDown = on(element, "pointerdown", cancelInsertText);
|
const removeInput = on(
|
||||||
const removeBlur = on(element, "blur", cancelInsertText);
|
|
||||||
const removeKeyDown = on(
|
|
||||||
element,
|
element,
|
||||||
"keydown",
|
"input",
|
||||||
cancelIfInsertText as EventListener,
|
onInput as unknown as (event: Event) => void,
|
||||||
);
|
);
|
||||||
const removeInput = on(element, "input", onInput);
|
|
||||||
|
const removeBlur = on(element, "blur", clearInsertText);
|
||||||
|
const removePointerDown = on(element, "pointerdown", clearInsertText);
|
||||||
|
const removeKeyDown = on(element, "keydown", clearInsertTextIfUnprintableKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
|
removeInput();
|
||||||
removeBeforeInput();
|
removeBeforeInput();
|
||||||
removePointerDown();
|
|
||||||
removeBlur();
|
removeBlur();
|
||||||
|
removePointerDown();
|
||||||
removeKeyDown();
|
removeKeyDown();
|
||||||
removeInput();
|
removeInput();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTriggerOnNextInsert(): OnNextInsertTrigger {
|
|
||||||
const active = writable(false);
|
|
||||||
let index = NaN;
|
|
||||||
|
|
||||||
function remove() {
|
|
||||||
if (!Number.isNaN(index)) {
|
|
||||||
delete onInsertText[index];
|
|
||||||
active.set(false);
|
|
||||||
index = NaN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function add(callback: OnInsertCallback): void {
|
|
||||||
if (Number.isNaN(index)) {
|
|
||||||
index = onInsertText.push({ callback, remove });
|
|
||||||
active.set(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
add,
|
|
||||||
remove,
|
|
||||||
active,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
manager,
|
manager,
|
||||||
getTriggerOnNextInsert,
|
getTriggerOnNextInsert: trigger(beforeInsertText),
|
||||||
|
getTriggerOnInput: trigger(beforeInput),
|
||||||
|
getTriggerAfterInput: trigger(afterInput),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import storeSubscribe from "./store-subscribe";
|
import storeSubscribe from "./store-subscribe";
|
||||||
|
// import { getSelection } from "../lib/cross-browser";
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
childList: true,
|
childList: true,
|
||||||
|
@ -12,15 +13,22 @@ const config = {
|
||||||
characterData: true,
|
characterData: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DOMMirror {
|
export type MirrorAction = (
|
||||||
mirror(
|
element: Element,
|
||||||
element: Element,
|
params: { store: Writable<DocumentFragment> },
|
||||||
params: { store: Writable<DocumentFragment> },
|
) => { destroy(): void };
|
||||||
): { destroy(): void };
|
|
||||||
|
interface DOMMirrorAPI {
|
||||||
|
mirror: MirrorAction;
|
||||||
preventResubscription(): () => void;
|
preventResubscription(): () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDOMMirror(): DOMMirror {
|
/**
|
||||||
|
* Allows you to keep an element's inner HTML bidirectionally
|
||||||
|
* in sync with a store containing a DocumentFragment.
|
||||||
|
* While the element has focus, this connection is tethered.
|
||||||
|
*/
|
||||||
|
function getDOMMirror(): DOMMirrorAPI {
|
||||||
const allowResubscription = writable(true);
|
const allowResubscription = writable(true);
|
||||||
|
|
||||||
function preventResubscription() {
|
function preventResubscription() {
|
||||||
|
@ -64,6 +72,20 @@ function getDOMMirror(): DOMMirror {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subscribe, unsubscribe } = storeSubscribe(store, mirrorToNode);
|
const { subscribe, unsubscribe } = storeSubscribe(store, mirrorToNode);
|
||||||
|
// const selection = getSelection(element)!;
|
||||||
|
|
||||||
|
function doSubscribe(): void {
|
||||||
|
// Might not be needed after all:
|
||||||
|
// /**
|
||||||
|
// * Focused element and caret are two independent things in the browser.
|
||||||
|
// * When the ContentEditable calls blur, it will still have the selection inside of it.
|
||||||
|
// * Some elements (e.g. FrameElement) need to figure whether the intended focus is still
|
||||||
|
// * in the contenteditable or elsewhere because they might change the selection.
|
||||||
|
// */
|
||||||
|
// selection.removeAllRanges();
|
||||||
|
|
||||||
|
subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
/* do not update when focused as it will reset caret */
|
/* do not update when focused as it will reset caret */
|
||||||
element.addEventListener("focus", unsubscribe);
|
element.addEventListener("focus", unsubscribe);
|
||||||
|
@ -71,14 +93,15 @@ function getDOMMirror(): DOMMirror {
|
||||||
const unsubResubscription = allowResubscription.subscribe(
|
const unsubResubscription = allowResubscription.subscribe(
|
||||||
(allow: boolean): void => {
|
(allow: boolean): void => {
|
||||||
if (allow) {
|
if (allow) {
|
||||||
element.addEventListener("blur", subscribe);
|
element.addEventListener("blur", doSubscribe);
|
||||||
|
|
||||||
const root = element.getRootNode() as Document | ShadowRoot;
|
const root = element.getRootNode() as Document | ShadowRoot;
|
||||||
|
|
||||||
if (root.activeElement !== element) {
|
if (root.activeElement !== element) {
|
||||||
subscribe();
|
doSubscribe();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
element.removeEventListener("blur", subscribe);
|
element.removeEventListener("blur", doSubscribe);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue