diff --git a/ts/domlib/surround/find-adjacent.test.ts b/ts/domlib/surround/find-adjacent.test.ts index 43eb21dc1..508c9106a 100644 --- a/ts/domlib/surround/find-adjacent.test.ts +++ b/ts/domlib/surround/find-adjacent.test.ts @@ -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); }); diff --git a/ts/domlib/surround/find-adjacent.ts b/ts/domlib/surround/find-adjacent.ts index e70c05fa8..1cb630090 100644 --- a/ts/domlib/surround/find-adjacent.ts +++ b/ts/domlib/surround/find-adjacent.ts @@ -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); } diff --git a/ts/domlib/surround/matcher.ts b/ts/domlib/surround/matcher.ts index 156a0c64a..e4b235ef9 100644 --- a/ts/domlib/surround/matcher.ts +++ b/ts/domlib/surround/matcher.ts @@ -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; /** * 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; + matchType: Exclude; } + +export interface FoundAlong { + element: Element | Text; + matchType: MatchResult.ALONG; +} + +export type FoundAdjacent = FoundMatch | FoundAlong; diff --git a/ts/domlib/surround/no-splitting.test.ts b/ts/domlib/surround/no-splitting.test.ts index e1c538cdf..16dbc7c7c 100644 --- a/ts/domlib/surround/no-splitting.test.ts +++ b/ts/domlib/surround/no-splitting.test.ts @@ -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", "before
after
"); + expect(surroundedRange.toString()).toEqual("before"); + }); + + test("normalize node contents", () => { + const range = new Range(); + range.selectNodeContents(body.firstChild!); const { addedNodes, removedNodes, surroundedRange } = surround( range, diff --git a/ts/domlib/surround/normalize-insertion-ranges.ts b/ts/domlib/surround/normalize-insertion-ranges.ts index b57e7628e..58a4a5136 100644 --- a/ts/domlib/surround/normalize-insertion-ranges.ts +++ b/ts/domlib/surround/normalize-insertion-ranges.ts @@ -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); diff --git a/ts/domlib/surround/unsurround.test.ts b/ts/domlib/surround/unsurround.test.ts index c5fbe8f1e..fcc4be908 100644 --- a/ts/domlib/surround/unsurround.test.ts +++ b/ts/domlib/surround/unsurround.test.ts @@ -34,6 +34,31 @@ describe("unsurround text", () => { }); }); +describe("unsurround element and text", () => { + let body: HTMLBodyElement; + + beforeEach(() => { + body = p("beforeafter"); + }); + + 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; diff --git a/ts/domlib/surround/unsurround.ts b/ts/domlib/surround/unsurround.ts index 91f645860..2432cdc26 100644 --- a/ts/domlib/surround/unsurround.ts +++ b/ts/domlib/surround/unsurround.ts @@ -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); } diff --git a/ts/editor/BoldButton.svelte b/ts/editor/BoldButton.svelte index c804af439..e7092b215 100644 --- a/ts/editor/BoldButton.svelte +++ b/ts/editor/BoldButton.svelte @@ -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 { 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 { - 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 { makeBold(); diff --git a/ts/editor/ItalicButton.svelte b/ts/editor/ItalicButton.svelte index d23574823..423ac90fc 100644 --- a/ts/editor/ItalicButton.svelte +++ b/ts/editor/ItalicButton.svelte @@ -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 { 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 { 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"; diff --git a/ts/editor/RichTextInput.svelte b/ts/editor/RichTextInput.svelte index 1336ffca6..8a439aefa 100644 --- a/ts/editor/RichTextInput.svelte +++ b/ts/editor/RichTextInput.svelte @@ -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 { diff --git a/ts/editor/UnderlineButton.svelte b/ts/editor/UnderlineButton.svelte index efdb6f685..607963c4f 100644 --- a/ts/editor/UnderlineButton.svelte +++ b/ts/editor/UnderlineButton.svelte @@ -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 { 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 { 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"; diff --git a/ts/editor/surround.ts b/ts/editor/surround.ts index df7532b75..1b965b8f7 100644 --- a/ts/editor/surround.ts +++ b/ts/editor/surround.ts @@ -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 { - 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 { - 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; + isSurrounded(matcher: ElementMatcher): Promise; +} - if (range.collapsed) { - input.triggerOnInsert(async ({ node }): Promise => { - 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 { + 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 { + 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, + }; } diff --git a/ts/sveltelib/input-manager.ts b/ts/sveltelib/input-manager.ts index f94a3722b..fd9061f8b 100644 --- a/ts/sveltelib/input-manager.ts +++ b/ts/sveltelib/input-manager.ts @@ -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; +export type OnInsertCallback = ({ node }: { node: Node }) => Promise; + +export interface OnNextInsertTrigger { + add: (callback: OnInsertCallback) => void; + remove: () => void; + active: Writable; +} 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, }; }