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:
parent
4886f5772d
commit
97b28398ea
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -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";
|
||||||
|
@ -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 {
|
||||||
|
@ -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";
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user