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

View File

@ -1,11 +1,11 @@
// 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 { nodeIsElement, elementIsEmpty } from "../../lib/dom"; import { nodeIsElement, nodeIsText, elementIsEmpty } from "../../lib/dom";
import { hasOnlyChild } from "../../lib/node"; import { hasOnlyChild } from "../../lib/node";
import type { ChildNodeRange } from "./child-node-range"; import type { ChildNodeRange } from "./child-node-range";
import { MatchResult } from "./matcher"; 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 * 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 adjacentNodeInner(getter: (node: Node) => ChildNode | null) {
function findAdjacentNodeInner( function findAdjacentNodeInner(
node: Node, node: Node,
matches: Element[], matches: FoundAdjacent[],
keepMatches: Element[],
along: Element[],
matcher: ElementMatcher, matcher: ElementMatcher,
): void { ): void {
const adjacent = getter(node); let current = getter(node);
if (adjacent && nodeIsElement(adjacent)) { const maybeAlong: (Element | Text)[] = [];
let current: Element | null = adjacent; while (
current &&
const maybeAlong: Element[] = []; ((nodeIsElement(current) && elementIsEmpty(current)) ||
while (nodeIsElement(current) && elementIsEmpty(current)) { (nodeIsText(current) && current.length === 0))
const adjacentNext = getter(current); ) {
maybeAlong.push(current); maybeAlong.push(current);
current = getter(current);
if (!adjacentNext || !nodeIsElement(adjacentNext)) {
return;
} else {
current = adjacentNext;
}
} }
while (current) { while (current && nodeIsElement(current)) {
const matchResult = matcher(current); const element: Element = current;
const matchResult = matcher(element);
if (matchResult) { if (matchResult) {
along.push(...maybeAlong); matches.push(
...maybeAlong.map(
switch (matchResult) { (along: Element | Text): FoundAlong => ({
case MatchResult.MATCH: element: along,
matches.push(current); matchType: MatchResult.ALONG,
break; }),
case MatchResult.KEEP: ),
keepMatches.push(current);
break;
}
return findAdjacentNodeInner(
current,
matches,
keepMatches,
along,
matcher,
); );
matches.push({
element,
matchType: matchResult,
});
return findAdjacentNodeInner(element, matches, matcher);
} }
// descend down into element // descend down into element
current = current =
hasOnlyChild(current) && nodeIsElement(current.firstChild!) hasOnlyChild(current) && nodeIsElement(element.firstChild!)
? current.firstChild ? element.firstChild
: null; : null;
} }
} }
}
return findAdjacentNodeInner; 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( const findBeforeNodeInner = adjacentNodeInner(
(node: Node): ChildNode | null => node.previousSibling, (node: Node): ChildNode | null => node.previousSibling,
); );
function findBeforeNode(node: Node, matcher: ElementMatcher): FindAdjacentResult { function findBeforeNode(node: Node, matcher: ElementMatcher): FoundAdjacent[] {
const matches: Element[] = []; const matches: FoundAdjacent[] = [];
const keepMatches: Element[] = []; findBeforeNodeInner(node, matches, matcher);
const along: Element[] = []; return matches;
findBeforeNodeInner(node, matches, keepMatches, along, matcher);
return { matches, keepMatches, along };
} }
export function findBefore( export function findBefore(
childNodeRange: ChildNodeRange, childNodeRange: ChildNodeRange,
matcher: ElementMatcher, matcher: ElementMatcher,
): FindAdjacentResult { ): FoundAdjacent[] {
const { parent, startIndex } = childNodeRange; const { parent, startIndex } = childNodeRange;
return findBeforeNode(parent.childNodes[startIndex], matcher); return findBeforeNode(parent.childNodes[startIndex], matcher);
} }
@ -106,20 +83,16 @@ const findAfterNodeInner = adjacentNodeInner(
(node: Node): ChildNode | null => node.nextSibling, (node: Node): ChildNode | null => node.nextSibling,
); );
function findAfterNode(node: Node, matcher: ElementMatcher): FindAdjacentResult { function findAfterNode(node: Node, matcher: ElementMatcher): FoundAdjacent[] {
const matches: Element[] = []; const matches: FoundAdjacent[] = [];
const keepMatches: Element[] = []; findAfterNodeInner(node, matches, matcher);
const along: Element[] = []; return matches;
findAfterNodeInner(node, matches, keepMatches, along, matcher);
return { matches, keepMatches, along };
} }
export function findAfter( export function findAfter(
childNodeRange: ChildNodeRange, childNodeRange: ChildNodeRange,
matcher: ElementMatcher, matcher: ElementMatcher,
): FindAdjacentResult { ): FoundAdjacent[] {
const { parent, endIndex } = childNodeRange; const { parent, endIndex } = childNodeRange;
return findAfterNode(parent.childNodes[endIndex - 1], matcher); 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 /* Element matches the predicate, but may not be removed
* This typically means that the element has other properties which prevent it from being removed */ * This typically means that the element has other properties which prevent it from being removed */
KEEP, KEEP,
/* Element (or Text) is situated adjacent to a match */
ALONG,
} }
/** /**
* Should be pure * 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 * 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 type ElementClearer = (element: Element) => boolean;
export const matchTagName = export const matchTagName =
(tagName: string) => (tagName: string): ElementMatcher =>
(element: Element): MatchResult => { (element: Element) => {
return element.matches(tagName) ? MatchResult.MATCH : MatchResult.NO_MATCH; return element.matches(tagName) ? MatchResult.MATCH : MatchResult.NO_MATCH;
}; };
export interface FoundMatch { export interface FoundMatch {
element: Element; 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", () => { test("normalize nodes", () => {
const range = new Range(); const range = new Range();
range.setStartBefore(body.firstChild!); range.selectNode(body.firstChild!);
range.setEndAfter(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( const { addedNodes, removedNodes, surroundedRange } = surround(
range, range,

View File

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

View File

@ -65,11 +65,10 @@ function findAndClearWithin(
return toRemove; return toRemove;
} }
function prohibitOverlapse(range: AbstractRange): (node: Node) => boolean { function prohibitOverlapse(withNode: Node): (node: Node) => boolean {
/* otherwise, they will be added to nodesToRemove twice /* otherwise, they will be added to nodesToRemove twice
* and will also be cleared twice */ * and will also be cleared twice */
return (node: Node) => return (node: Node) => !node.contains(withNode) && !withNode.contains(node);
!node.contains(range.endContainer) && !range.endContainer.contains(node);
} }
interface FindNodesToRemoveResult { interface FindNodesToRemoveResult {
@ -107,7 +106,7 @@ function findNodesToRemove(
aboveStart, aboveStart,
matcher, matcher,
clearer, clearer,
prohibitOverlapse(range), aboveEnd ? prohibitOverlapse(aboveEnd.element) : () => true,
); );
nodesToRemove.push(...matches); 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 WithState from "../components/WithState.svelte";
import { MatchResult } from "../domlib/surround"; import { MatchResult } from "../domlib/surround";
import { getPlatformString } from "../lib/shortcuts"; import { getPlatformString } from "../lib/shortcuts";
import { isSurrounded, surroundCommand } from "./surround"; import { getSurrounder } from "./surround";
import { boldIcon } from "./icons"; import { boldIcon } from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte"; import { getNoteEditor } from "./OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "./RichTextInput.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)) { if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return MatchResult.NO_MATCH; return MatchResult.NO_MATCH;
} }
@ -46,20 +46,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: input = $activeInput; $: input = $activeInput;
$: disabled = !$focusInRichText; $: disabled = !$focusInRichText;
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI);
function updateStateFromActiveInput(): Promise<boolean> { function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text" return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(matchBold);
? Promise.resolve(false)
: isSurrounded(input, matchBold);
} }
const element = document.createElement("strong");
function makeBold(): void { function makeBold(): void {
surroundCommand( surrounder?.surroundCommand(element, matchBold, clearBold);
input as RichTextInputAPI,
document.createElement("strong"),
matchBold,
clearBold,
);
} }
const keyCombination = "Control+B"; const keyCombination = "Control+B";
@ -68,12 +63,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<WithState <WithState
key="bold" key="bold"
update={updateStateFromActiveInput} update={updateStateFromActiveInput}
let:state={active} let:state={isBold}
let:updateState let:updateState
> >
<IconButton <IconButton
tooltip="{tr.editingBoldText()} ({getPlatformString(keyCombination)})" tooltip="{tr.editingBoldText()} ({getPlatformString(keyCombination)})"
{active} active={isBold}
{disabled} {disabled}
on:click={(event) => { on:click={(event) => {
makeBold(); 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 WithState from "../components/WithState.svelte";
import { MatchResult } from "../domlib/surround"; import { MatchResult } from "../domlib/surround";
import { getPlatformString } from "../lib/shortcuts"; import { getPlatformString } from "../lib/shortcuts";
import { isSurrounded, surroundCommand } from "./surround"; import { getSurrounder } from "./surround";
import { italicIcon } from "./icons"; import { italicIcon } from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte"; import { getNoteEditor } from "./OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "./RichTextInput.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)) { if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return MatchResult.NO_MATCH; return MatchResult.NO_MATCH;
} }
@ -45,20 +45,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: input = $activeInput; $: input = $activeInput;
$: disabled = !$focusInRichText; $: disabled = !$focusInRichText;
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI);
function updateStateFromActiveInput(): Promise<boolean> { function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text" return !input || input.name === "plain-text"
? Promise.resolve(false) ? Promise.resolve(false)
: isSurrounded(input, matchItalic); : surrounder!.isSurrounded(matchItalic);
} }
const element = document.createElement("em");
function makeItalic(): void { function makeItalic(): void {
surroundCommand( surrounder!.surroundCommand(element, matchItalic, clearItalic);
input as RichTextInputAPI,
document.createElement("em"),
matchItalic,
clearItalic,
);
} }
const keyCombination = "Control+I"; 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 CustomStyles from "./CustomStyles.svelte";
import type { EditingInputAPI } from "./EditingArea.svelte"; import type { EditingInputAPI } from "./EditingArea.svelte";
import contextProperty from "../sveltelib/context-property"; 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 { export interface RichTextInputAPI extends EditingInputAPI {
name: "rich-text"; name: "rich-text";
@ -17,7 +17,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
toggle(): boolean; toggle(): boolean;
surround(before: string, after: string): void; surround(before: string, after: string): void;
preventResubscription(): () => void; preventResubscription(): () => void;
triggerOnInsert(callback: OnInsertCallback): () => void; getTriggerOnNextInsert(): OnNextInsertTrigger;
} }
export interface RichTextInputContextAPI { 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"; import getInputManager from "../sveltelib/input-manager";
const { mirror, preventResubscription } = getDOMMirror(); const { mirror, preventResubscription } = getDOMMirror();
const { manager, triggerOnInsert } = getInputManager(); const { manager, getTriggerOnNextInsert } = getInputManager();
function moveCaretToEnd() { function moveCaretToEnd() {
richTextPromise.then(caretToEnd); richTextPromise.then(caretToEnd);
@ -210,7 +210,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
); );
}, },
preventResubscription, preventResubscription,
triggerOnInsert, getTriggerOnNextInsert,
}; };
function pushUpdate(): void { 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 WithState from "../components/WithState.svelte";
import { MatchResult } from "../domlib/surround"; import { MatchResult } from "../domlib/surround";
import { getPlatformString } from "../lib/shortcuts"; import { getPlatformString } from "../lib/shortcuts";
import { isSurrounded, surroundCommand } from "./surround"; import { getSurrounder } from "./surround";
import { underlineIcon } from "./icons"; import { underlineIcon } from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte"; import { getNoteEditor } from "./OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "./RichTextInput.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)) { if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return MatchResult.NO_MATCH; return MatchResult.NO_MATCH;
} }
@ -30,19 +30,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: input = $activeInput; $: input = $activeInput;
$: disabled = !$focusInRichText; $: disabled = !$focusInRichText;
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI);
function updateStateFromActiveInput(): Promise<boolean> { function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text" return !input || input.name === "plain-text"
? Promise.resolve(false) ? Promise.resolve(false)
: isSurrounded(input, matchUnderline); : surrounder!.isSurrounded(matchUnderline);
} }
const element = document.createElement("u");
function makeUnderline(): void { function makeUnderline(): void {
surroundCommand( surrounder!.surroundCommand(element, matchUnderline);
input as RichTextInputAPI,
document.createElement("u"),
matchUnderline,
);
} }
const keyCombination = "Control+U"; const keyCombination = "Control+U";

View File

@ -1,12 +1,13 @@
// 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 type { RichTextInputAPI } from "./RichTextInput.svelte"; import { get } from "svelte/store";
import { getSelection } from "../lib/cross-browser"; import { getSelection } 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";
export function isSurroundedInner( function isSurroundedInner(
range: AbstractRange, range: AbstractRange,
base: HTMLElement, base: HTMLElement,
matcher: ElementMatcher, 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( function surroundAndSelect(
matches: boolean, matches: boolean,
range: Range, range: Range,
@ -45,18 +35,41 @@ function surroundAndSelect(
selection.addRange(surroundedRange); selection.addRange(surroundedRange);
} }
export async function surroundCommand( export interface GetSurrounderResult {
input: RichTextInputAPI, surroundCommand(
surroundElement: Element,
matcher: ElementMatcher,
clearer?: ElementClearer,
): Promise<void>;
isSurrounded(matcher: ElementMatcher): Promise<boolean>;
}
export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderResult {
const { add, remove, active } = richTextInput.getTriggerOnNextInsert();
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, surroundElement: Element,
matcher: ElementMatcher, matcher: ElementMatcher,
clearer: ElementClearer = () => false, clearer: ElementClearer = () => false,
): Promise<void> { ): Promise<void> {
const base = await input.element; const base = await richTextInput.element;
const selection = getSelection(base)!; const selection = getSelection(base)!;
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
if (range.collapsed) { if (range.collapsed) {
input.triggerOnInsert(async ({ node }): Promise<void> => { if (get(active)) {
remove();
} else {
add(async ({ node }: { node: Node }) => {
range.selectNode(node); range.selectNode(node);
const matches = Boolean(findClosest(node, base, matcher)); const matches = Boolean(findClosest(node, base, matcher));
@ -72,6 +85,7 @@ export async function surroundCommand(
selection.collapseToEnd(); selection.collapseToEnd();
}); });
}
} else { } else {
const matches = isSurroundedInner(range, base, matcher); const matches = isSurroundedInner(range, base, matcher);
surroundAndSelect( surroundAndSelect(
@ -85,3 +99,9 @@ export async function surroundCommand(
); );
} }
} }
return {
surroundCommand,
isSurrounded,
};
}

View File

@ -1,19 +1,27 @@
// 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 { writable } from "svelte/store";
import type { Writable } from "svelte/store";
import { on } from "../lib/events"; import { on } from "../lib/events";
import { nodeIsText } from "../lib/dom"; import { nodeIsText } from "../lib/dom";
import { getSelection } from "../lib/cross-browser"; 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 { interface InputManager {
manager(element: HTMLElement): { destroy(): void }; manager(element: HTMLElement): { destroy(): void };
triggerOnInsert(callback: OnInsertCallback): () => void; getTriggerOnNextInsert(): OnNextInsertTrigger;
} }
export function getInputManager(): InputManager { function getInputManager(): InputManager {
const onInsertText: OnInsertCallback[] = []; const onInsertText: { callback: OnInsertCallback; remove: () => void }[] = [];
function cancelInsertText(): void { function cancelInsertText(): void {
onInsertText.length = 0; onInsertText.length = 0;
@ -52,8 +60,9 @@ export function getInputManager(): InputManager {
range.selectNode(node); range.selectNode(node);
range.collapse(false); range.collapse(false);
for (const callback of onInsertText) { for (const { callback, remove } of onInsertText) {
await callback({ node }); await callback({ node });
remove();
} }
event.preventDefault(); event.preventDefault();
@ -82,19 +91,35 @@ export function getInputManager(): InputManager {
}; };
} }
function triggerOnInsert(callback: OnInsertCallback): () => void { function getTriggerOnNextInsert(): OnNextInsertTrigger {
onInsertText.push(callback); const active = writable(false);
return () => { let index = NaN;
const index = onInsertText.indexOf(callback);
if (index > 0) { function remove() {
onInsertText.splice(index, 1); 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,
triggerOnInsert, getTriggerOnNextInsert,
}; };
} }