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", () => {
|
||||
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);
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -9,12 +9,16 @@ export enum MatchResult {
|
||||
/* Element matches the predicate, but may not be removed
|
||||
* This typically means that the element has other properties which prevent it from being removed */
|
||||
KEEP,
|
||||
/* Element (or Text) is situated adjacent to a match */
|
||||
ALONG,
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be pure
|
||||
*/
|
||||
export type ElementMatcher = (element: Element) => MatchResult;
|
||||
export type ElementMatcher = (
|
||||
element: Element,
|
||||
) => Exclude<MatchResult, MatchResult.ALONG>;
|
||||
|
||||
/**
|
||||
* Is applied to values that match with KEEP
|
||||
@ -23,12 +27,19 @@ export type ElementMatcher = (element: Element) => MatchResult;
|
||||
export type ElementClearer = (element: Element) => boolean;
|
||||
|
||||
export const matchTagName =
|
||||
(tagName: string) =>
|
||||
(element: Element): MatchResult => {
|
||||
(tagName: string): ElementMatcher =>
|
||||
(element: Element) => {
|
||||
return element.matches(tagName) ? MatchResult.MATCH : MatchResult.NO_MATCH;
|
||||
};
|
||||
|
||||
export interface FoundMatch {
|
||||
element: Element;
|
||||
matchType: Exclude<MatchResult, MatchResult.NO_MATCH>;
|
||||
matchType: Exclude<MatchResult, MatchResult.NO_MATCH | MatchResult.ALONG>;
|
||||
}
|
||||
|
||||
export interface FoundAlong {
|
||||
element: Element | Text;
|
||||
matchType: MatchResult.ALONG;
|
||||
}
|
||||
|
||||
export type FoundAdjacent = FoundMatch | FoundAlong;
|
||||
|
@ -332,8 +332,23 @@ describe("skips over empty elements", () => {
|
||||
|
||||
test("normalize nodes", () => {
|
||||
const range = new Range();
|
||||
range.setStartBefore(body.firstChild!);
|
||||
range.setEndAfter(body.firstChild!);
|
||||
range.selectNode(body.firstChild!);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty("innerHTML", "<b>before<br>after</b>");
|
||||
expect(surroundedRange.toString()).toEqual("before");
|
||||
});
|
||||
|
||||
test("normalize node contents", () => {
|
||||
const range = new Range();
|
||||
range.selectNodeContents(body.firstChild!);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
|
@ -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);
|
||||
|
@ -34,6 +34,31 @@ describe("unsurround text", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsurround element and text", () => {
|
||||
let body: HTMLBodyElement;
|
||||
|
||||
beforeEach(() => {
|
||||
body = p("<b>before</b>after");
|
||||
});
|
||||
|
||||
test("normalizes nodes", () => {
|
||||
const range = new Range();
|
||||
range.setStartBefore(body.childNodes[0].firstChild!);
|
||||
range.setEndAfter(body.childNodes[1]);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = unsurround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
|
||||
expect(addedNodes).toHaveLength(0);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty("innerHTML", "beforeafter");
|
||||
expect(surroundedRange.toString()).toEqual("beforeafter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsurround element with surrounding text", () => {
|
||||
let body: HTMLBodyElement;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -9,12 +9,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import WithState from "../components/WithState.svelte";
|
||||
import { MatchResult } from "../domlib/surround";
|
||||
import { getPlatformString } from "../lib/shortcuts";
|
||||
import { isSurrounded, surroundCommand } from "./surround";
|
||||
import { getSurrounder } from "./surround";
|
||||
import { boldIcon } from "./icons";
|
||||
import { getNoteEditor } from "./OldEditorAdapter.svelte";
|
||||
import type { RichTextInputAPI } from "./RichTextInput.svelte";
|
||||
|
||||
function matchBold(element: Element): MatchResult {
|
||||
function matchBold(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
|
||||
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
|
||||
return MatchResult.NO_MATCH;
|
||||
}
|
||||
@ -46,20 +46,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
$: input = $activeInput;
|
||||
$: disabled = !$focusInRichText;
|
||||
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI);
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return !input || input.name === "plain-text"
|
||||
? Promise.resolve(false)
|
||||
: isSurrounded(input, matchBold);
|
||||
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(matchBold);
|
||||
}
|
||||
|
||||
const element = document.createElement("strong");
|
||||
function makeBold(): void {
|
||||
surroundCommand(
|
||||
input as RichTextInputAPI,
|
||||
document.createElement("strong"),
|
||||
matchBold,
|
||||
clearBold,
|
||||
);
|
||||
surrounder?.surroundCommand(element, matchBold, clearBold);
|
||||
}
|
||||
|
||||
const keyCombination = "Control+B";
|
||||
@ -68,12 +63,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
<WithState
|
||||
key="bold"
|
||||
update={updateStateFromActiveInput}
|
||||
let:state={active}
|
||||
let:state={isBold}
|
||||
let:updateState
|
||||
>
|
||||
<IconButton
|
||||
tooltip="{tr.editingBoldText()} ({getPlatformString(keyCombination)})"
|
||||
{active}
|
||||
active={isBold}
|
||||
{disabled}
|
||||
on:click={(event) => {
|
||||
makeBold();
|
||||
|
@ -9,12 +9,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import WithState from "../components/WithState.svelte";
|
||||
import { MatchResult } from "../domlib/surround";
|
||||
import { getPlatformString } from "../lib/shortcuts";
|
||||
import { isSurrounded, surroundCommand } from "./surround";
|
||||
import { getSurrounder } from "./surround";
|
||||
import { italicIcon } from "./icons";
|
||||
import { getNoteEditor } from "./OldEditorAdapter.svelte";
|
||||
import type { RichTextInputAPI } from "./RichTextInput.svelte";
|
||||
|
||||
function matchItalic(element: Element): MatchResult {
|
||||
function matchItalic(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
|
||||
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
|
||||
return MatchResult.NO_MATCH;
|
||||
}
|
||||
@ -45,20 +45,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
$: input = $activeInput;
|
||||
$: disabled = !$focusInRichText;
|
||||
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI);
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return !input || input.name === "plain-text"
|
||||
? Promise.resolve(false)
|
||||
: isSurrounded(input, matchItalic);
|
||||
: surrounder!.isSurrounded(matchItalic);
|
||||
}
|
||||
|
||||
const element = document.createElement("em");
|
||||
function makeItalic(): void {
|
||||
surroundCommand(
|
||||
input as RichTextInputAPI,
|
||||
document.createElement("em"),
|
||||
matchItalic,
|
||||
clearItalic,
|
||||
);
|
||||
surrounder!.surroundCommand(element, matchItalic, clearItalic);
|
||||
}
|
||||
|
||||
const keyCombination = "Control+I";
|
||||
|
@ -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 {
|
||||
|
@ -9,12 +9,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import WithState from "../components/WithState.svelte";
|
||||
import { MatchResult } from "../domlib/surround";
|
||||
import { getPlatformString } from "../lib/shortcuts";
|
||||
import { isSurrounded, surroundCommand } from "./surround";
|
||||
import { getSurrounder } from "./surround";
|
||||
import { underlineIcon } from "./icons";
|
||||
import { getNoteEditor } from "./OldEditorAdapter.svelte";
|
||||
import type { RichTextInputAPI } from "./RichTextInput.svelte";
|
||||
|
||||
function matchUnderline(element: Element): MatchResult {
|
||||
function matchUnderline(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
|
||||
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
|
||||
return MatchResult.NO_MATCH;
|
||||
}
|
||||
@ -30,19 +30,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
$: input = $activeInput;
|
||||
$: disabled = !$focusInRichText;
|
||||
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI);
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return !input || input.name === "plain-text"
|
||||
? Promise.resolve(false)
|
||||
: isSurrounded(input, matchUnderline);
|
||||
: surrounder!.isSurrounded(matchUnderline);
|
||||
}
|
||||
|
||||
const element = document.createElement("u");
|
||||
function makeUnderline(): void {
|
||||
surroundCommand(
|
||||
input as RichTextInputAPI,
|
||||
document.createElement("u"),
|
||||
matchUnderline,
|
||||
);
|
||||
surrounder!.surroundCommand(element, matchUnderline);
|
||||
}
|
||||
|
||||
const keyCombination = "Control+U";
|
||||
|
@ -1,12 +1,13 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { RichTextInputAPI } from "./RichTextInput.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { getSelection } from "../lib/cross-browser";
|
||||
import { surroundNoSplitting, unsurround, findClosest } from "../domlib/surround";
|
||||
import type { ElementMatcher, ElementClearer } from "../domlib/surround";
|
||||
import type { RichTextInputAPI } from "./RichTextInput.svelte";
|
||||
|
||||
export function isSurroundedInner(
|
||||
function isSurroundedInner(
|
||||
range: AbstractRange,
|
||||
base: HTMLElement,
|
||||
matcher: ElementMatcher,
|
||||
@ -17,17 +18,6 @@ export function isSurroundedInner(
|
||||
);
|
||||
}
|
||||
|
||||
export async function isSurrounded(
|
||||
input: RichTextInputAPI,
|
||||
matcher: ElementMatcher,
|
||||
): Promise<boolean> {
|
||||
const base = await input.element;
|
||||
const selection = getSelection(base)!;
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
return isSurroundedInner(range, base, matcher);
|
||||
}
|
||||
|
||||
function surroundAndSelect(
|
||||
matches: boolean,
|
||||
range: Range,
|
||||
@ -45,21 +35,59 @@ function surroundAndSelect(
|
||||
selection.addRange(surroundedRange);
|
||||
}
|
||||
|
||||
export async function surroundCommand(
|
||||
input: RichTextInputAPI,
|
||||
surroundElement: Element,
|
||||
matcher: ElementMatcher,
|
||||
clearer: ElementClearer = () => false,
|
||||
): Promise<void> {
|
||||
const base = await input.element;
|
||||
const selection = getSelection(base)!;
|
||||
const range = selection.getRangeAt(0);
|
||||
export interface GetSurrounderResult {
|
||||
surroundCommand(
|
||||
surroundElement: Element,
|
||||
matcher: ElementMatcher,
|
||||
clearer?: ElementClearer,
|
||||
): Promise<void>;
|
||||
isSurrounded(matcher: ElementMatcher): Promise<boolean>;
|
||||
}
|
||||
|
||||
if (range.collapsed) {
|
||||
input.triggerOnInsert(async ({ node }): Promise<void> => {
|
||||
range.selectNode(node);
|
||||
export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderResult {
|
||||
const { add, remove, active } = richTextInput.getTriggerOnNextInsert();
|
||||
|
||||
const matches = Boolean(findClosest(node, base, matcher));
|
||||
async function isSurrounded(matcher: ElementMatcher): Promise<boolean> {
|
||||
const base = await richTextInput.element;
|
||||
const selection = getSelection(base)!;
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
const isSurrounded = isSurroundedInner(range, base, matcher);
|
||||
return get(active) ? !isSurrounded : isSurrounded;
|
||||
}
|
||||
|
||||
async function surroundCommand(
|
||||
surroundElement: Element,
|
||||
matcher: ElementMatcher,
|
||||
clearer: ElementClearer = () => false,
|
||||
): Promise<void> {
|
||||
const base = await richTextInput.element;
|
||||
const selection = getSelection(base)!;
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
if (range.collapsed) {
|
||||
if (get(active)) {
|
||||
remove();
|
||||
} else {
|
||||
add(async ({ node }: { node: Node }) => {
|
||||
range.selectNode(node);
|
||||
|
||||
const matches = Boolean(findClosest(node, base, matcher));
|
||||
surroundAndSelect(
|
||||
matches,
|
||||
range,
|
||||
selection,
|
||||
surroundElement,
|
||||
base,
|
||||
matcher,
|
||||
clearer,
|
||||
);
|
||||
|
||||
selection.collapseToEnd();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const matches = isSurroundedInner(range, base, matcher);
|
||||
surroundAndSelect(
|
||||
matches,
|
||||
range,
|
||||
@ -69,19 +97,11 @@ export async function surroundCommand(
|
||||
matcher,
|
||||
clearer,
|
||||
);
|
||||
|
||||
selection.collapseToEnd();
|
||||
});
|
||||
} else {
|
||||
const matches = isSurroundedInner(range, base, matcher);
|
||||
surroundAndSelect(
|
||||
matches,
|
||||
range,
|
||||
selection,
|
||||
surroundElement,
|
||||
base,
|
||||
matcher,
|
||||
clearer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
surroundCommand,
|
||||
isSurrounded,
|
||||
};
|
||||
}
|
||||
|
@ -1,19 +1,27 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { writable } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { on } from "../lib/events";
|
||||
import { nodeIsText } from "../lib/dom";
|
||||
import { getSelection } from "../lib/cross-browser";
|
||||
|
||||
export type OnInsertCallback = ({ node: Node }) => Promise<void>;
|
||||
export type OnInsertCallback = ({ node }: { node: Node }) => Promise<void>;
|
||||
|
||||
export interface OnNextInsertTrigger {
|
||||
add: (callback: OnInsertCallback) => void;
|
||||
remove: () => void;
|
||||
active: Writable<boolean>;
|
||||
}
|
||||
|
||||
interface InputManager {
|
||||
manager(element: HTMLElement): { destroy(): void };
|
||||
triggerOnInsert(callback: OnInsertCallback): () => void;
|
||||
getTriggerOnNextInsert(): OnNextInsertTrigger;
|
||||
}
|
||||
|
||||
export function getInputManager(): InputManager {
|
||||
const onInsertText: OnInsertCallback[] = [];
|
||||
function getInputManager(): InputManager {
|
||||
const onInsertText: { callback: OnInsertCallback; remove: () => void }[] = [];
|
||||
|
||||
function cancelInsertText(): void {
|
||||
onInsertText.length = 0;
|
||||
@ -52,8 +60,9 @@ export function getInputManager(): InputManager {
|
||||
range.selectNode(node);
|
||||
range.collapse(false);
|
||||
|
||||
for (const callback of onInsertText) {
|
||||
for (const { callback, remove } of onInsertText) {
|
||||
await callback({ node });
|
||||
remove();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
@ -82,19 +91,35 @@ export function getInputManager(): InputManager {
|
||||
};
|
||||
}
|
||||
|
||||
function triggerOnInsert(callback: OnInsertCallback): () => void {
|
||||
onInsertText.push(callback);
|
||||
return () => {
|
||||
const index = onInsertText.indexOf(callback);
|
||||
if (index > 0) {
|
||||
onInsertText.splice(index, 1);
|
||||
function getTriggerOnNextInsert(): OnNextInsertTrigger {
|
||||
const active = writable(false);
|
||||
let index = NaN;
|
||||
|
||||
function remove() {
|
||||
if (!Number.isNaN(index)) {
|
||||
delete onInsertText[index];
|
||||
active.set(false);
|
||||
index = NaN;
|
||||
}
|
||||
}
|
||||
|
||||
function add(callback: OnInsertCallback): void {
|
||||
if (Number.isNaN(index)) {
|
||||
index = onInsertText.push({ callback, remove });
|
||||
active.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
remove,
|
||||
active,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
manager,
|
||||
triggerOnInsert,
|
||||
getTriggerOnNextInsert,
|
||||
};
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user