Fix some issues with new surround buttons (#1505)

* Add a store to indicate whether input trigger is active

Button state is then indicated by: caretIsInBold XOR boldTriggerActive

* Fix surrounding where normalization is tripped up by empty text nodes

* Add failing test for unsurrounding

* Fix failing test

* prohibitOverlapse does not need to be active, if aboveEnd is null

* Reinsert Italic and Underline button

* Refactor find-adjacent to use sum types

* Simplify return value of normalizeAdjacent
This commit is contained in:
Henrik Giesel 2021-11-24 01:33:14 +01:00 committed by GitHub
parent 4886f5772d
commit 97b28398ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 287 additions and 228 deletions

View File

@ -18,13 +18,13 @@ describe("in a simple search", () => {
describe("findBefore", () => {
test("finds an element", () => {
const { matches } = findBefore(range, matchTagName("b"));
const matches = findBefore(range, matchTagName("b"));
expect(matches).toHaveLength(1);
});
test("does not find non-existing element", () => {
const { matches } = findBefore(range, matchTagName("i"));
const matches = findBefore(range, matchTagName("i"));
expect(matches).toHaveLength(0);
});
@ -32,13 +32,13 @@ describe("in a simple search", () => {
describe("findAfter", () => {
test("finds an element", () => {
const { matches } = findAfter(range, matchTagName("i"));
const matches = findAfter(range, matchTagName("i"));
expect(matches).toHaveLength(1);
});
test("does not find non-existing element", () => {
const { matches } = findAfter(range, matchTagName("b"));
const matches = findAfter(range, matchTagName("b"));
expect(matches).toHaveLength(0);
});
@ -51,7 +51,7 @@ describe("in a nested search", () => {
describe("findBefore", () => {
test("finds a nested element", () => {
const { matches } = findBefore(rangeNested, matchTagName("b"));
const matches = findBefore(rangeNested, matchTagName("b"));
expect(matches).toHaveLength(1);
});
@ -59,7 +59,7 @@ describe("in a nested search", () => {
describe("findAfter", () => {
test("finds a nested element", () => {
const { matches } = findAfter(rangeNested, matchTagName("b"));
const matches = findAfter(rangeNested, matchTagName("b"));
expect(matches).toHaveLength(1);
});

View File

@ -1,11 +1,11 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { nodeIsElement, elementIsEmpty } from "../../lib/dom";
import { nodeIsElement, nodeIsText, elementIsEmpty } from "../../lib/dom";
import { hasOnlyChild } from "../../lib/node";
import type { ChildNodeRange } from "./child-node-range";
import { MatchResult } from "./matcher";
import type { ElementMatcher } from "./matcher";
import type { ElementMatcher, FoundAlong, FoundAdjacent } from "./matcher";
/**
* These functions will not ascend on the starting node, but will descend on the neighbor node
@ -13,91 +13,68 @@ import type { ElementMatcher } from "./matcher";
function adjacentNodeInner(getter: (node: Node) => ChildNode | null) {
function findAdjacentNodeInner(
node: Node,
matches: Element[],
keepMatches: Element[],
along: Element[],
matches: FoundAdjacent[],
matcher: ElementMatcher,
): void {
const adjacent = getter(node);
let current = getter(node);
if (adjacent && nodeIsElement(adjacent)) {
let current: Element | null = adjacent;
const maybeAlong: (Element | Text)[] = [];
while (
current &&
((nodeIsElement(current) && elementIsEmpty(current)) ||
(nodeIsText(current) && current.length === 0))
) {
maybeAlong.push(current);
current = getter(current);
}
const maybeAlong: Element[] = [];
while (nodeIsElement(current) && elementIsEmpty(current)) {
const adjacentNext = getter(current);
maybeAlong.push(current);
while (current && nodeIsElement(current)) {
const element: Element = current;
const matchResult = matcher(element);
if (!adjacentNext || !nodeIsElement(adjacentNext)) {
return;
} else {
current = adjacentNext;
}
if (matchResult) {
matches.push(
...maybeAlong.map(
(along: Element | Text): FoundAlong => ({
element: along,
matchType: MatchResult.ALONG,
}),
),
);
matches.push({
element,
matchType: matchResult,
});
return findAdjacentNodeInner(element, matches, matcher);
}
while (current) {
const matchResult = matcher(current);
if (matchResult) {
along.push(...maybeAlong);
switch (matchResult) {
case MatchResult.MATCH:
matches.push(current);
break;
case MatchResult.KEEP:
keepMatches.push(current);
break;
}
return findAdjacentNodeInner(
current,
matches,
keepMatches,
along,
matcher,
);
}
// descend down into element
current =
hasOnlyChild(current) && nodeIsElement(current.firstChild!)
? current.firstChild
: null;
}
// descend down into element
current =
hasOnlyChild(current) && nodeIsElement(element.firstChild!)
? element.firstChild
: null;
}
}
return findAdjacentNodeInner;
}
interface FindAdjacentResult {
/* elements adjacent which match matcher */
matches: Element[];
keepMatches: Element[];
/* element adjacent between found elements, which can
* be safely skipped (e.g. empty elements) */
along: Element[];
}
const findBeforeNodeInner = adjacentNodeInner(
(node: Node): ChildNode | null => node.previousSibling,
);
function findBeforeNode(node: Node, matcher: ElementMatcher): FindAdjacentResult {
const matches: Element[] = [];
const keepMatches: Element[] = [];
const along: Element[] = [];
findBeforeNodeInner(node, matches, keepMatches, along, matcher);
return { matches, keepMatches, along };
function findBeforeNode(node: Node, matcher: ElementMatcher): FoundAdjacent[] {
const matches: FoundAdjacent[] = [];
findBeforeNodeInner(node, matches, matcher);
return matches;
}
export function findBefore(
childNodeRange: ChildNodeRange,
matcher: ElementMatcher,
): FindAdjacentResult {
): FoundAdjacent[] {
const { parent, startIndex } = childNodeRange;
return findBeforeNode(parent.childNodes[startIndex], matcher);
}
@ -106,20 +83,16 @@ const findAfterNodeInner = adjacentNodeInner(
(node: Node): ChildNode | null => node.nextSibling,
);
function findAfterNode(node: Node, matcher: ElementMatcher): FindAdjacentResult {
const matches: Element[] = [];
const keepMatches: Element[] = [];
const along: Element[] = [];
findAfterNodeInner(node, matches, keepMatches, along, matcher);
return { matches, keepMatches, along };
function findAfterNode(node: Node, matcher: ElementMatcher): FoundAdjacent[] {
const matches: FoundAdjacent[] = [];
findAfterNodeInner(node, matches, matcher);
return matches;
}
export function findAfter(
childNodeRange: ChildNodeRange,
matcher: ElementMatcher,
): FindAdjacentResult {
): FoundAdjacent[] {
const { parent, endIndex } = childNodeRange;
return findAfterNode(parent.childNodes[endIndex - 1], matcher);
}

View File

@ -9,12 +9,16 @@ export enum MatchResult {
/* Element matches the predicate, but may not be removed
* This typically means that the element has other properties which prevent it from being removed */
KEEP,
/* Element (or Text) is situated adjacent to a match */
ALONG,
}
/**
* Should be pure
*/
export type ElementMatcher = (element: Element) => MatchResult;
export type ElementMatcher = (
element: Element,
) => Exclude<MatchResult, MatchResult.ALONG>;
/**
* Is applied to values that match with KEEP
@ -23,12 +27,19 @@ export type ElementMatcher = (element: Element) => MatchResult;
export type ElementClearer = (element: Element) => boolean;
export const matchTagName =
(tagName: string) =>
(element: Element): MatchResult => {
(tagName: string): ElementMatcher =>
(element: Element) => {
return element.matches(tagName) ? MatchResult.MATCH : MatchResult.NO_MATCH;
};
export interface FoundMatch {
element: Element;
matchType: Exclude<MatchResult, MatchResult.NO_MATCH>;
matchType: Exclude<MatchResult, MatchResult.NO_MATCH | MatchResult.ALONG>;
}
export interface FoundAlong {
element: Element | Text;
matchType: MatchResult.ALONG;
}
export type FoundAdjacent = FoundMatch | FoundAlong;

View File

@ -332,8 +332,23 @@ describe("skips over empty elements", () => {
test("normalize nodes", () => {
const range = new Range();
range.setStartBefore(body.firstChild!);
range.setEndAfter(body.firstChild!);
range.selectNode(body.firstChild!);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty("innerHTML", "<b>before<br>after</b>");
expect(surroundedRange.toString()).toEqual("before");
});
test("normalize node contents", () => {
const range = new Range();
range.selectNodeContents(body.firstChild!);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,

View File

@ -4,7 +4,12 @@
import { findBefore, findAfter } from "./find-adjacent";
import { findWithin, findWithinNode } from "./find-within";
import { MatchResult } from "./matcher";
import type { FoundMatch, ElementMatcher, ElementClearer } from "./matcher";
import type {
FoundMatch,
ElementMatcher,
ElementClearer,
FoundAdjacent,
} from "./matcher";
import type { ChildNodeRange } from "./child-node-range";
function countChildNodesRespectiveToParent(parent: Node, element: Element): number {
@ -24,8 +29,8 @@ function normalizeWithinInner(
clearer: ElementClearer,
) {
const matches = findWithinNode(node, matcher);
const processFoundMatches = (match: FoundMatch) =>
match.matchType === MatchResult.MATCH ?? clearer(match.element);
const processFoundMatches = ({ element, matchType }: FoundMatch) =>
matchType === MatchResult.MATCH ?? clearer(element);
for (const { element: found } of matches.filter(processFoundMatches)) {
removedNodes.push(found);
@ -41,52 +46,55 @@ function normalizeWithinInner(
}
function normalizeAdjacent(
matches: Element[],
keepMatches: Element[],
along: Element[],
matches: FoundAdjacent[],
parent: Node,
removedNodes: Element[],
matcher: ElementMatcher,
clearer: ElementClearer,
): [length: number, shift: number] {
// const { matches, keepMatches, along } = findBefore(normalizedRange, matcher);
let childCount = along.length;
): number {
let childCount = 0;
let keepChildCount = 0;
for (const match of matches) {
childCount += normalizeWithinInner(
match,
parent,
removedNodes,
matcher,
clearer,
);
for (const { element, matchType } of matches) {
switch (matchType) {
case MatchResult.MATCH:
childCount += normalizeWithinInner(
element as Element,
parent,
removedNodes,
matcher,
clearer,
);
removedNodes.push(match);
match.replaceWith(...match.childNodes);
}
removedNodes.push(element as Element);
element.replaceWith(...element.childNodes);
break;
for (const match of keepMatches) {
const keepChildCount = normalizeWithinInner(
match,
parent,
removedNodes,
matcher,
clearer,
);
case MatchResult.KEEP:
keepChildCount = normalizeWithinInner(
element as Element,
parent,
removedNodes,
matcher,
clearer,
);
if (clearer(match)) {
removedNodes.push(match);
match.replaceWith(...match.childNodes);
childCount += keepChildCount;
} else {
childCount += 1;
if (clearer(element as Element)) {
removedNodes.push(element as Element);
element.replaceWith(...element.childNodes);
childCount += keepChildCount;
} else {
childCount++;
}
break;
case MatchResult.ALONG:
childCount++;
break;
}
}
const length = matches.length + keepMatches.length + along.length;
const shift = childCount - length;
return [length, shift];
return childCount;
}
function normalizeWithin(
@ -136,21 +144,16 @@ export function normalizeInsertionRanges(
*/
if (index === 0) {
const { matches, keepMatches, along } = findBefore(
normalizedRange,
matcher,
);
const [length, shift] = normalizeAdjacent(
const matches = findBefore(normalizedRange, matcher);
const count = normalizeAdjacent(
matches,
keepMatches,
along,
parent,
removedNodes,
matcher,
clearer,
);
normalizedRange.startIndex -= length;
normalizedRange.endIndex += shift;
normalizedRange.startIndex -= matches.length;
normalizedRange.endIndex += count - matches.length;
}
const matches = findWithin(normalizedRange, matcher);
@ -158,17 +161,15 @@ export function normalizeInsertionRanges(
normalizedRange.endIndex += withinShift;
if (index === insertionRanges.length - 1) {
const { matches, keepMatches, along } = findAfter(normalizedRange, matcher);
const [length, shift] = normalizeAdjacent(
const matches = findAfter(normalizedRange, matcher);
const count = normalizeAdjacent(
matches,
keepMatches,
along,
parent,
removedNodes,
matcher,
clearer,
);
normalizedRange.endIndex += length + shift;
normalizedRange.endIndex += count;
}
normalizedRanges.push(normalizedRange);

View File

@ -34,6 +34,31 @@ describe("unsurround text", () => {
});
});
describe("unsurround element and text", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("<b>before</b>after");
});
test("normalizes nodes", () => {
const range = new Range();
range.setStartBefore(body.childNodes[0].firstChild!);
range.setEndAfter(body.childNodes[1]);
const { addedNodes, removedNodes, surroundedRange } = unsurround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(0);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty("innerHTML", "beforeafter");
expect(surroundedRange.toString()).toEqual("beforeafter");
});
});
describe("unsurround element with surrounding text", () => {
let body: HTMLBodyElement;

View File

@ -65,11 +65,10 @@ function findAndClearWithin(
return toRemove;
}
function prohibitOverlapse(range: AbstractRange): (node: Node) => boolean {
function prohibitOverlapse(withNode: Node): (node: Node) => boolean {
/* otherwise, they will be added to nodesToRemove twice
* and will also be cleared twice */
return (node: Node) =>
!node.contains(range.endContainer) && !range.endContainer.contains(node);
return (node: Node) => !node.contains(withNode) && !withNode.contains(node);
}
interface FindNodesToRemoveResult {
@ -107,7 +106,7 @@ function findNodesToRemove(
aboveStart,
matcher,
clearer,
prohibitOverlapse(range),
aboveEnd ? prohibitOverlapse(aboveEnd.element) : () => true,
);
nodesToRemove.push(...matches);
}

View File

@ -9,12 +9,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithState from "../components/WithState.svelte";
import { MatchResult } from "../domlib/surround";
import { getPlatformString } from "../lib/shortcuts";
import { isSurrounded, surroundCommand } from "./surround";
import { getSurrounder } from "./surround";
import { boldIcon } from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "./RichTextInput.svelte";
function matchBold(element: Element): MatchResult {
function matchBold(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return MatchResult.NO_MATCH;
}
@ -46,20 +46,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: input = $activeInput;
$: disabled = !$focusInRichText;
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI);
function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text"
? Promise.resolve(false)
: isSurrounded(input, matchBold);
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(matchBold);
}
const element = document.createElement("strong");
function makeBold(): void {
surroundCommand(
input as RichTextInputAPI,
document.createElement("strong"),
matchBold,
clearBold,
);
surrounder?.surroundCommand(element, matchBold, clearBold);
}
const keyCombination = "Control+B";
@ -68,12 +63,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<WithState
key="bold"
update={updateStateFromActiveInput}
let:state={active}
let:state={isBold}
let:updateState
>
<IconButton
tooltip="{tr.editingBoldText()} ({getPlatformString(keyCombination)})"
{active}
active={isBold}
{disabled}
on:click={(event) => {
makeBold();

View File

@ -9,12 +9,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithState from "../components/WithState.svelte";
import { MatchResult } from "../domlib/surround";
import { getPlatformString } from "../lib/shortcuts";
import { isSurrounded, surroundCommand } from "./surround";
import { getSurrounder } from "./surround";
import { italicIcon } from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "./RichTextInput.svelte";
function matchItalic(element: Element): MatchResult {
function matchItalic(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return MatchResult.NO_MATCH;
}
@ -45,20 +45,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: input = $activeInput;
$: disabled = !$focusInRichText;
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI);
function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text"
? Promise.resolve(false)
: isSurrounded(input, matchItalic);
: surrounder!.isSurrounded(matchItalic);
}
const element = document.createElement("em");
function makeItalic(): void {
surroundCommand(
input as RichTextInputAPI,
document.createElement("em"),
matchItalic,
clearItalic,
);
surrounder!.surroundCommand(element, matchItalic, clearItalic);
}
const keyCombination = "Control+I";

View File

@ -6,7 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type CustomStyles from "./CustomStyles.svelte";
import type { EditingInputAPI } from "./EditingArea.svelte";
import contextProperty from "../sveltelib/context-property";
import type { OnInsertCallback } from "../sveltelib/input-manager";
import type { OnNextInsertTrigger } from "../sveltelib/input-manager";
export interface RichTextInputAPI extends EditingInputAPI {
name: "rich-text";
@ -17,7 +17,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
toggle(): boolean;
surround(before: string, after: string): void;
preventResubscription(): () => void;
triggerOnInsert(callback: OnInsertCallback): () => void;
getTriggerOnNextInsert(): OnNextInsertTrigger;
}
export interface RichTextInputContextAPI {
@ -161,7 +161,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import getInputManager from "../sveltelib/input-manager";
const { mirror, preventResubscription } = getDOMMirror();
const { manager, triggerOnInsert } = getInputManager();
const { manager, getTriggerOnNextInsert } = getInputManager();
function moveCaretToEnd() {
richTextPromise.then(caretToEnd);
@ -210,7 +210,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
);
},
preventResubscription,
triggerOnInsert,
getTriggerOnNextInsert,
};
function pushUpdate(): void {

View File

@ -9,12 +9,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithState from "../components/WithState.svelte";
import { MatchResult } from "../domlib/surround";
import { getPlatformString } from "../lib/shortcuts";
import { isSurrounded, surroundCommand } from "./surround";
import { getSurrounder } from "./surround";
import { underlineIcon } from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "./RichTextInput.svelte";
function matchUnderline(element: Element): MatchResult {
function matchUnderline(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return MatchResult.NO_MATCH;
}
@ -30,19 +30,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: input = $activeInput;
$: disabled = !$focusInRichText;
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI);
function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text"
? Promise.resolve(false)
: isSurrounded(input, matchUnderline);
: surrounder!.isSurrounded(matchUnderline);
}
const element = document.createElement("u");
function makeUnderline(): void {
surroundCommand(
input as RichTextInputAPI,
document.createElement("u"),
matchUnderline,
);
surrounder!.surroundCommand(element, matchUnderline);
}
const keyCombination = "Control+U";

View File

@ -1,12 +1,13 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { RichTextInputAPI } from "./RichTextInput.svelte";
import { get } from "svelte/store";
import { getSelection } from "../lib/cross-browser";
import { surroundNoSplitting, unsurround, findClosest } from "../domlib/surround";
import type { ElementMatcher, ElementClearer } from "../domlib/surround";
import type { RichTextInputAPI } from "./RichTextInput.svelte";
export function isSurroundedInner(
function isSurroundedInner(
range: AbstractRange,
base: HTMLElement,
matcher: ElementMatcher,
@ -17,17 +18,6 @@ export function isSurroundedInner(
);
}
export async function isSurrounded(
input: RichTextInputAPI,
matcher: ElementMatcher,
): Promise<boolean> {
const base = await input.element;
const selection = getSelection(base)!;
const range = selection.getRangeAt(0);
return isSurroundedInner(range, base, matcher);
}
function surroundAndSelect(
matches: boolean,
range: Range,
@ -45,21 +35,59 @@ function surroundAndSelect(
selection.addRange(surroundedRange);
}
export async function surroundCommand(
input: RichTextInputAPI,
surroundElement: Element,
matcher: ElementMatcher,
clearer: ElementClearer = () => false,
): Promise<void> {
const base = await input.element;
const selection = getSelection(base)!;
const range = selection.getRangeAt(0);
export interface GetSurrounderResult {
surroundCommand(
surroundElement: Element,
matcher: ElementMatcher,
clearer?: ElementClearer,
): Promise<void>;
isSurrounded(matcher: ElementMatcher): Promise<boolean>;
}
if (range.collapsed) {
input.triggerOnInsert(async ({ node }): Promise<void> => {
range.selectNode(node);
export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderResult {
const { add, remove, active } = richTextInput.getTriggerOnNextInsert();
const matches = Boolean(findClosest(node, base, matcher));
async function isSurrounded(matcher: ElementMatcher): Promise<boolean> {
const base = await richTextInput.element;
const selection = getSelection(base)!;
const range = selection.getRangeAt(0);
const isSurrounded = isSurroundedInner(range, base, matcher);
return get(active) ? !isSurrounded : isSurrounded;
}
async function surroundCommand(
surroundElement: Element,
matcher: ElementMatcher,
clearer: ElementClearer = () => false,
): Promise<void> {
const base = await richTextInput.element;
const selection = getSelection(base)!;
const range = selection.getRangeAt(0);
if (range.collapsed) {
if (get(active)) {
remove();
} else {
add(async ({ node }: { node: Node }) => {
range.selectNode(node);
const matches = Boolean(findClosest(node, base, matcher));
surroundAndSelect(
matches,
range,
selection,
surroundElement,
base,
matcher,
clearer,
);
selection.collapseToEnd();
});
}
} else {
const matches = isSurroundedInner(range, base, matcher);
surroundAndSelect(
matches,
range,
@ -69,19 +97,11 @@ export async function surroundCommand(
matcher,
clearer,
);
selection.collapseToEnd();
});
} else {
const matches = isSurroundedInner(range, base, matcher);
surroundAndSelect(
matches,
range,
selection,
surroundElement,
base,
matcher,
clearer,
);
}
}
return {
surroundCommand,
isSurrounded,
};
}

View File

@ -1,19 +1,27 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { writable } from "svelte/store";
import type { Writable } from "svelte/store";
import { on } from "../lib/events";
import { nodeIsText } from "../lib/dom";
import { getSelection } from "../lib/cross-browser";
export type OnInsertCallback = ({ node: Node }) => Promise<void>;
export type OnInsertCallback = ({ node }: { node: Node }) => Promise<void>;
export interface OnNextInsertTrigger {
add: (callback: OnInsertCallback) => void;
remove: () => void;
active: Writable<boolean>;
}
interface InputManager {
manager(element: HTMLElement): { destroy(): void };
triggerOnInsert(callback: OnInsertCallback): () => void;
getTriggerOnNextInsert(): OnNextInsertTrigger;
}
export function getInputManager(): InputManager {
const onInsertText: OnInsertCallback[] = [];
function getInputManager(): InputManager {
const onInsertText: { callback: OnInsertCallback; remove: () => void }[] = [];
function cancelInsertText(): void {
onInsertText.length = 0;
@ -52,8 +60,9 @@ export function getInputManager(): InputManager {
range.selectNode(node);
range.collapse(false);
for (const callback of onInsertText) {
for (const { callback, remove } of onInsertText) {
await callback({ node });
remove();
}
event.preventDefault();
@ -82,19 +91,35 @@ export function getInputManager(): InputManager {
};
}
function triggerOnInsert(callback: OnInsertCallback): () => void {
onInsertText.push(callback);
return () => {
const index = onInsertText.indexOf(callback);
if (index > 0) {
onInsertText.splice(index, 1);
function getTriggerOnNextInsert(): OnNextInsertTrigger {
const active = writable(false);
let index = NaN;
function remove() {
if (!Number.isNaN(index)) {
delete onInsertText[index];
active.set(false);
index = NaN;
}
}
function add(callback: OnInsertCallback): void {
if (Number.isNaN(index)) {
index = onInsertText.push({ callback, remove });
active.set(true);
}
}
return {
add,
remove,
active,
};
}
return {
manager,
triggerOnInsert,
getTriggerOnNextInsert,
};
}