Reverse-engineer surrounding with execCommand (#1377)

* Add utility functions for saving and restoring the caret location

Implement surroundNoSplitting

Clarify surroundNoSplitting comments

Start implementing surroundSplitting and triggerIfSimpleInput

Fix after rebase

Implement findBefore / findAfter in lib/surround

* to merge adjacent nodes into the surrounding nodes

Use new prettier settings with lib/{location,surround}

Fix imports that I missed to rename

Add some tests for find-adjacent

Split find-within from find-adjacent

Normalize nodes after insertion in surroundNoSplitting

Do not deep clone surroundNode

-> no intention of supporting deep nodes, as normalization would be impossible

Add some tests concerning nested surrounding nested nodes

Select surroundedRange after surrounding

Fix ascendWhileSingleInline

A flawed first surround/trigger implementation

Move trigger out of lib/surround

Implement Input Manager as a way to handle bold on empty selection

Switch bold button away from execCommand

Pass in Matcher instead of selector to find-adjacent and surroundNoSplitting

* Also adds a failing test for no-splitting

Refactor find-adjacent

* add failing test when findBefore's nodes have different amounts of
  child nodes

Change type signature of find-adjacent methods to more single-concern

Add test for surrounding where adjacent block becomes three Text elements

Make nodes found within surrounded range extend the ranges endOffset

Add base parameter to surroundNoSplitting to stop ascending beyond container

Stop surrounding from bubbling beyond base in merge-match

Make all tests pass

Add some failing tests to point to future development

Add empty elements as constant

Implement a broken version of unsurround

Even split text if it creates zero-length texts

-> they are still valid, despite what Chromium says

Rename {start,end} to {start,end}Container

Add more unit tests with surround after a nested element

Set endOffset after split-off possibly zero length text nodes

Deal with empty elements when surrounding

Only include split off end text if zero length

Use range anchors instead off calcluating surroundedRange from offsets

* this approach allows for removal of base elements when unsurrounding

Comment out test which fail because of jsdom bugs

We'll be able to enable them again after Jest 28

Make the first unsurround tests pass

Add new failing test for unsurround text within tag

Fix unsurround

Test is deactivated until Jest 28

Rewrite input-manager and trigger callback after insertion

Avoid creating zero length text nodes by using insertBefore when appropriate

Implement matches vs keepMatches

Make shadow root and editable element available on component tree

Make WithState work with asynchronous updater functions

Add new Bold/Italic/UnderlineButton using our logic

Add failing test for unsurrounding

* Move surround/ to domlib

* Add jest dependency

* Make find-within return a sum type array rather than two arrays

* Use FoundMatch sum-type for find-above (and find-within)

* Fix issue where elements could be cleared twice

* if they are IN the range.endContainer

* Pass remaining test

* Add another failing test

* Fix empty text nodes being considered for surrounding

* Satisfy svelte check

* Make on more type correct

* Satisfy remaining tests

* Add missing copyright header
This commit is contained in:
Henrik Giesel 2021-11-18 10:18:39 +01:00 committed by GitHub
parent be0fe849c5
commit 64d46ca638
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2413 additions and 73 deletions

View file

@ -6,14 +6,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { writable } from "svelte/store";
type KeyType = Symbol | string;
type UpdaterMap = Map<KeyType, (event: Event) => boolean>;
type StateMap = Map<KeyType, boolean>;
type UpdaterMap = Map<KeyType, (event: Event) => Promise<boolean>>;
type StateMap = Map<KeyType, Promise<boolean>>;
const updaterMap = new Map() as UpdaterMap;
const stateMap = new Map() as StateMap;
const updaterMap: UpdaterMap = new Map();
const stateMap: StateMap = new Map();
const stateStore = writable(stateMap);
function updateAllStateWithCallback(callback: (key: KeyType) => boolean): void {
function updateAllStateWithCallback(
callback: (key: KeyType) => Promise<boolean>,
): void {
stateStore.update((map: StateMap): StateMap => {
const newMap = new Map() as StateMap;
@ -26,13 +28,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
export function updateAllState(event: Event): void {
updateAllStateWithCallback((key: KeyType): boolean =>
updaterMap.get(key)!(event),
updateAllStateWithCallback(
(key: KeyType): Promise<boolean> => updaterMap.get(key)!(event),
);
}
export function resetAllState(state: boolean): void {
updateAllStateWithCallback((): boolean => state);
updateAllStateWithCallback((): Promise<boolean> => Promise.resolve(state));
}
function updateStateByKey(key: KeyType, event: Event): void {
@ -45,18 +47,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts">
export let key: KeyType;
export let update: (event: Event) => boolean;
export let update: (event: Event) => Promise<boolean>;
let state: boolean = false;
updaterMap.set(key, update);
stateStore.subscribe((map: StateMap): (() => void) => {
state = Boolean(map.get(key));
if (map.has(key)) {
const stateValue = map.get(key)!;
if (stateValue instanceof Promise) {
stateValue.then((value: boolean): void => {
state = value;
});
} else {
state = stateValue;
}
} else {
state = false;
}
return () => map.delete(key);
});
stateMap.set(key, state);
stateMap.set(key, Promise.resolve(state));
function updateState(event: Event): void {
updateStateByKey(key, event);

View file

@ -22,3 +22,8 @@ typescript(
prettier_test()
eslint_test()
jest_test(
env = "jsdom",
deps = [":domlib"],
)

View file

@ -2,3 +2,4 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export * as location from "./location";
export * as surround from "./surround";

View file

@ -4,11 +4,13 @@
import { registerPackage } from "../../lib/register-package";
import { saveSelection, restoreSelection } from "./document";
import { Position } from "./location";
registerPackage("anki/location", {
saveSelection,
restoreSelection,
Position,
});
export { saveSelection, restoreSelection };
export { saveSelection, restoreSelection, Position };
export type { SelectionLocation } from "./selection";

View file

@ -6,33 +6,37 @@ export interface CaretLocation {
offset: number;
}
export enum Order {
LessThan,
export enum Position {
Before = -1,
Equal,
GreaterThan,
After,
}
export function compareLocations(first: CaretLocation, second: CaretLocation): Order {
/* first is positioned {} second */
export function compareLocations(
first: CaretLocation,
second: CaretLocation,
): Position {
const smallerLength = Math.min(first.coordinates.length, second.coordinates.length);
for (let i = 0; i <= smallerLength; i++) {
if (first.coordinates.length === i) {
if (second.coordinates.length === i) {
if (first.offset < second.offset) {
return Order.LessThan;
return Position.Before;
} else if (first.offset > second.offset) {
return Order.GreaterThan;
return Position.After;
} else {
return Order.Equal;
return Position.Equal;
}
}
return Order.LessThan;
return Position.Before;
} else if (second.coordinates.length === i) {
return Order.GreaterThan;
return Position.After;
} else if (first.coordinates[i] < second.coordinates[i]) {
return Order.LessThan;
return Position.Before;
} else if (first.coordinates[i] > second.coordinates[i]) {
return Order.GreaterThan;
return Position.After;
}
}

View file

@ -3,7 +3,7 @@
import { getNodeCoordinates } from "./node";
import type { CaretLocation } from "./location";
import { compareLocations, Order } from "./location";
import { compareLocations, Position } from "./location";
import { getSelection } from "../../lib/cross-browser";
export interface SelectionLocationCollapsed {
@ -42,7 +42,7 @@ export function getSelectionLocation(base: Node): SelectionLocation | null {
const focus = { coordinates: focusCoordinates, offset: selection.focusOffset };
const order = compareLocations(anchor, focus);
const direction = order === Order.GreaterThan ? "backward" : "forward";
const direction = order === Position.After ? "backward" : "forward";
return {
anchor,

View file

@ -0,0 +1,22 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { ascend, isOnlyChild } from "../../lib/node";
import { elementIsBlock } from "../../lib/dom";
export function ascendWhileSingleInline(node: Node, base: Node): Node {
if (node.isSameNode(base)) {
return node;
}
while (
isOnlyChild(node) &&
node.parentElement &&
!elementIsBlock(node.parentElement) &&
node.parentElement !== base
) {
node = ascend(node);
}
return node;
}

View file

@ -0,0 +1,111 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { ascend } from "../../lib/node";
import { nodeIsElement, elementIsEmpty } from "../../lib/dom";
export interface ChildNodeRange {
parent: Node;
startIndex: number;
/* exclusive end */
endIndex: number;
}
/**
* Indices should be >= 0 and startIndex < endIndex
*/
function makeChildNodeRange(
node: Node,
startIndex: number,
endIndex = startIndex + 1,
): ChildNodeRange {
return {
parent: node,
startIndex,
endIndex,
};
}
/**
* Result does not indicate the node itself but a supposed new node that
* entirely surrounds the passed in node
*/
export function nodeToChildNodeRange(node: Node): ChildNodeRange {
const parent = ascend(node);
const index = Array.prototype.indexOf.call(parent.childNodes, node);
return makeChildNodeRange(parent, index);
}
function toDOMRange(childNodeRange: ChildNodeRange): Range {
const range = new Range();
range.setStart(childNodeRange.parent, childNodeRange.startIndex);
range.setEnd(childNodeRange.parent, childNodeRange.endIndex);
return range;
}
export function areSiblingChildNodeRanges(
before: ChildNodeRange,
after: ChildNodeRange,
): boolean {
if (before.parent !== after.parent || before.endIndex > after.startIndex) {
return false;
}
if (before.endIndex === after.startIndex) {
return true;
}
for (let index = before.endIndex; index < after.startIndex; index++) {
const node = before.parent.childNodes[index];
if (!nodeIsElement(node) || !elementIsEmpty(node)) {
return false;
}
}
return true;
}
export function coversWholeParent(childNodeRange: ChildNodeRange): boolean {
return (
childNodeRange.startIndex === 0 &&
childNodeRange.endIndex === childNodeRange.parent.childNodes.length
);
}
/**
* Precondition: must be sibling child node ranges
*/
export function mergeChildNodeRanges(
before: ChildNodeRange,
after: ChildNodeRange,
): ChildNodeRange {
return {
parent: before.parent,
startIndex: before.startIndex,
endIndex: after.endIndex,
};
}
export function surroundChildNodeRangeWithNode(
childNodeRange: ChildNodeRange,
node: Node,
): void {
const range = toDOMRange(childNodeRange);
if (range.collapsed) {
/**
* If the range is collapsed to a single element, move the range inside the element.
* This prevents putting the surround above the base element.
*/
const selected = range.commonAncestorContainer.childNodes[range.startOffset];
if (nodeIsElement(selected)) {
range.selectNode(selected);
}
}
range.surroundContents(node);
}

View file

@ -0,0 +1,60 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { nodeIsElement } from "../../lib/dom";
import type { FoundMatch, ElementMatcher } from "./matcher";
export function findClosest(
node: Node,
base: Element,
matcher: ElementMatcher,
): FoundMatch | null {
let current: Node | Element | null = node;
while (current) {
if (nodeIsElement(current)) {
const matchType = matcher(current);
if (matchType) {
return {
element: current,
matchType,
};
}
}
current =
current.isSameNode(base) || !current.parentElement
? null
: current.parentElement;
}
return current;
}
export function findFarthest(
node: Node,
base: Element,
matcher: ElementMatcher,
): FoundMatch | null {
let found: FoundMatch | null = null;
let current: Node | Element | null = node;
while (current) {
if (nodeIsElement(current)) {
const matchType = matcher(current);
if (matchType) {
found = {
element: current,
matchType,
};
}
}
current =
current.isSameNode(base) || !current.parentElement
? null
: current.parentElement;
}
return found;
}

View file

@ -0,0 +1,67 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { findBefore, findAfter } from "./find-adjacent";
import { nodeToChildNodeRange } from "./child-node-range";
import { matchTagName } from "./matcher";
const parser = new DOMParser();
function p(html: string): Element {
const parsed = parser.parseFromString(html, "text/html");
return parsed.body;
}
describe("in a simple search", () => {
const html = p("<b>Before</b><u>This is a test</u><i>After</i>");
const range = nodeToChildNodeRange(html.children[1]);
describe("findBefore", () => {
test("finds an element", () => {
const { matches } = findBefore(range, matchTagName("b"));
expect(matches).toHaveLength(1);
});
test("does not find non-existing element", () => {
const { matches } = findBefore(range, matchTagName("i"));
expect(matches).toHaveLength(0);
});
});
describe("findAfter", () => {
test("finds an element", () => {
const { matches } = findAfter(range, matchTagName("i"));
expect(matches).toHaveLength(1);
});
test("does not find non-existing element", () => {
const { matches } = findAfter(range, matchTagName("b"));
expect(matches).toHaveLength(0);
});
});
});
describe("in a nested search", () => {
const htmlNested = p("<u><b>before</b></u>within<u><b>after</b></u>");
const rangeNested = nodeToChildNodeRange(htmlNested.childNodes[1]);
describe("findBefore", () => {
test("finds a nested element", () => {
const { matches } = findBefore(rangeNested, matchTagName("b"));
expect(matches).toHaveLength(1);
});
});
describe("findAfter", () => {
test("finds a nested element", () => {
const { matches } = findAfter(rangeNested, matchTagName("b"));
expect(matches).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,125 @@
// 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 { hasOnlyChild } from "../../lib/node";
import type { ChildNodeRange } from "./child-node-range";
import { MatchResult } from "./matcher";
import type { ElementMatcher } from "./matcher";
/**
* These functions will not ascend on the starting node, but will descend on the neighbor node
*/
function adjacentNodeInner(getter: (node: Node) => ChildNode | null) {
function findAdjacentNodeInner(
node: Node,
matches: Element[],
keepMatches: Element[],
along: Element[],
matcher: ElementMatcher,
): void {
const adjacent = getter(node);
if (adjacent && nodeIsElement(adjacent)) {
let current: Element | null = adjacent;
const maybeAlong: Element[] = [];
while (nodeIsElement(current) && elementIsEmpty(current)) {
const adjacentNext = getter(current);
maybeAlong.push(current);
if (!adjacentNext || !nodeIsElement(adjacentNext)) {
return;
} else {
current = adjacentNext;
}
}
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;
}
}
}
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 };
}
export function findBefore(
childNodeRange: ChildNodeRange,
matcher: ElementMatcher,
): FindAdjacentResult {
const { parent, startIndex } = childNodeRange;
return findBeforeNode(parent.childNodes[startIndex], matcher);
}
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 };
}
export function findAfter(
childNodeRange: ChildNodeRange,
matcher: ElementMatcher,
): FindAdjacentResult {
const { parent, endIndex } = childNodeRange;
return findAfterNode(parent.childNodes[endIndex - 1], matcher);
}

View file

@ -0,0 +1,70 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { nodeIsElement } from "../../lib/dom";
import { nodeWithinRange } from "./within-range";
import type { ChildNodeRange } from "./child-node-range";
import type { FoundMatch, ElementMatcher } from "./matcher";
/**
* Elements returned should be in post-order
*/
function findWithinNodeInner(
node: Node,
matcher: ElementMatcher,
matches: FoundMatch[],
): void {
if (nodeIsElement(node)) {
for (const child of node.children) {
findWithinNodeInner(child, matcher, matches);
}
const matchType = matcher(node);
if (matchType) {
matches.push({ matchType, element: node });
}
}
}
/**
* Will not include parent node
*/
export function findWithinNode(node: Node, matcher: ElementMatcher): FoundMatch[] {
const matches: FoundMatch[] = [];
if (nodeIsElement(node)) {
for (const child of node.children) {
findWithinNodeInner(child, matcher, matches);
}
}
return matches;
}
export function findWithinRange(range: Range, matcher: ElementMatcher): FoundMatch[] {
const matches: FoundMatch[] = [];
findWithinNodeInner(range.commonAncestorContainer, matcher, matches);
return matches.filter((match: FoundMatch): boolean =>
nodeWithinRange(match.element, range),
);
}
export function findWithin(
childNodeRange: ChildNodeRange,
matcher: ElementMatcher,
): FoundMatch[] {
const { parent, startIndex, endIndex } = childNodeRange;
const matches: FoundMatch[] = [];
for (const node of Array.prototype.slice.call(
parent.childNodes,
startIndex,
endIndex,
)) {
findWithinNodeInner(node, matcher, matches);
}
return matches;
}

View file

@ -0,0 +1,20 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { registerPackage } from "../../lib/register-package";
import { surroundNoSplitting } from "./no-splitting";
import { unsurround } from "./unsurround";
import { findClosest } from "./find-above";
import { MatchResult, matchTagName } from "./matcher";
registerPackage("anki/surround", {
surroundNoSplitting,
unsurround,
findClosest,
MatchResult,
matchTagName,
});
export { surroundNoSplitting, unsurround, findClosest, MatchResult, matchTagName };
export type { ElementMatcher, ElementClearer } from "./matcher";

View file

@ -0,0 +1,34 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export enum MatchResult {
/* Having this be 0 allows for falsy tests */
NO_MATCH = 0,
/* Element matches the predicate and may be removed */
MATCH,
/* 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,
}
/**
* Should be pure
*/
export type ElementMatcher = (element: Element) => MatchResult;
/**
* Is applied to values that match with KEEP
* Should be idempotent
*/
export type ElementClearer = (element: Element) => boolean;
export const matchTagName =
(tagName: string) =>
(element: Element): MatchResult => {
return element.matches(tagName) ? MatchResult.MATCH : MatchResult.NO_MATCH;
};
export interface FoundMatch {
element: Element;
matchType: Exclude<MatchResult, MatchResult.NO_MATCH>;
}

View file

@ -0,0 +1,101 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { ChildNodeRange } from "./child-node-range";
import {
nodeToChildNodeRange,
areSiblingChildNodeRanges,
mergeChildNodeRanges,
coversWholeParent,
} from "./child-node-range";
import { ascendWhileSingleInline } from "./ascend";
interface MergeMatch {
mismatch: boolean;
minimized: ChildNodeRange[];
}
function createInitialMergeMatch(childNodeRange: ChildNodeRange): MergeMatch {
return {
mismatch: false,
minimized: [childNodeRange],
};
}
/**
* After an _inner match_, we right-reduce the existing matches
* to see if any existing inner matches can be matched to one bigger match
*
* @example When surround with <b>
* <b><u>Hello </u></b><b><i>World</i></b> will be merged to
* <b><u>Hello </u><i>World</i></b>
*/
const tryMergingTillMismatch =
(base: Element) =>
(
{ mismatch, minimized /* must be nonempty */ }: MergeMatch,
childNodeRange: ChildNodeRange,
): MergeMatch => {
if (mismatch) {
return {
mismatch,
minimized: [childNodeRange, ...minimized],
};
} else {
const [nextChildNodeRange, ...restChildNodeRanges] = minimized;
if (
areSiblingChildNodeRanges(
childNodeRange,
nextChildNodeRange,
) /* && !childNodeRange.parent.isSameNode(base)*/
) {
const mergedChildNodeRange = mergeChildNodeRanges(
childNodeRange,
nextChildNodeRange,
);
const newChildNodeRange =
coversWholeParent(mergedChildNodeRange) &&
!mergedChildNodeRange.parent.isSameNode(base)
? nodeToChildNodeRange(
ascendWhileSingleInline(
mergedChildNodeRange.parent,
base,
),
)
: mergedChildNodeRange;
return {
mismatch,
minimized: [newChildNodeRange, ...restChildNodeRanges],
};
} else {
return {
mismatch: true,
minimized: [childNodeRange, ...minimized],
};
}
}
};
function getMergeMatcher(base: Element) {
function mergeMatchInner(
accu: ChildNodeRange[],
childNodeRange: ChildNodeRange,
): ChildNodeRange[] {
return [...accu].reduceRight(
tryMergingTillMismatch(base),
createInitialMergeMatch(childNodeRange),
).minimized;
}
return mergeMatchInner;
}
export function mergeMatchChildNodeRanges(
ranges: ChildNodeRange[],
base: Element,
): ChildNodeRange[] {
return ranges.reduce(getMergeMatcher(base), []);
}

View file

@ -0,0 +1,350 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { surroundNoSplitting as surround } from "./no-splitting";
const parser = new DOMParser();
function p(html: string): HTMLBodyElement {
const parsed = parser.parseFromString(html, "text/html");
return parsed.body as HTMLBodyElement;
}
describe("surround text", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("111222");
});
test("all text", () => {
const range = new Range();
range.selectNode(body.firstChild!);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(0);
expect(body).toHaveProperty("innerHTML", "<b>111222</b>");
expect(surroundedRange.toString()).toEqual("111222");
});
test("first half", () => {
const range = new Range();
range.setStart(body.firstChild!, 0);
range.setEnd(body.firstChild!, 3);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(0);
expect(body).toHaveProperty("innerHTML", "<b>111</b>222");
expect(surroundedRange.toString()).toEqual("111");
});
test("second half", () => {
const range = new Range();
range.setStart(body.firstChild!, 3);
range.setEnd(body.firstChild!, 6);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(0);
expect(body).toHaveProperty("innerHTML", "111<b>222</b>");
expect(surroundedRange.toString()).toEqual("222");
});
});
describe("surround text next to nested", () => {
describe("before", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("before<u><b>after</b></u>");
});
test("enlarges bottom tag of nested", () => {
const range = new Range();
range.selectNode(body.firstChild!);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("u"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty("innerHTML", "<u>before<b>after</b></u>");
expect(surroundedRange.toString()).toEqual("before");
});
test("moves nested down", () => {
const range = new Range();
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<u>after</u></b>");
expect(surroundedRange.toString()).toEqual("before");
});
});
describe("after", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("<u><b>before</b></u>after");
});
test("enlarges bottom tag of nested", () => {
const range = new Range();
range.selectNode(body.childNodes[1]);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("u"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty("innerHTML", "<u><b>before</b>after</u>");
expect(surroundedRange.toString()).toEqual("after");
});
test("moves nested down", () => {
const range = new Range();
range.selectNode(body.childNodes[1]);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty("innerHTML", "<b><u>before</u>after</b>");
expect(surroundedRange.toString()).toEqual("after");
});
});
});
describe("surround across block element", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("Before<br><ul><li>First</li><li>Second</li></ul>");
});
test("does not insert empty elements", () => {
const range = new Range();
range.setStartBefore(body.firstChild!);
range.setEndAfter(body.lastChild!);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(3);
expect(removedNodes).toHaveLength(0);
expect(body).toHaveProperty(
"innerHTML",
"<b>Before</b><br><ul><li><b>First</b></li><li><b>Second</b></li></ul>",
);
expect(surroundedRange.toString()).toEqual("BeforeFirstSecond");
});
});
describe("next to nested", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("111<b>222<b>333<b>444</b></b></b>555");
});
test("surround after", () => {
const range = new Range();
range.selectNode(body.lastChild!);
const { addedNodes, removedNodes } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(3);
expect(body).toHaveProperty("innerHTML", "111<b>222333444555</b>");
// expect(surroundedRange.toString()).toEqual("555");
});
});
describe("next to element with nested non-matching", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("111<b>222<i>333<i>444</i></i></b>555");
});
test("surround after", () => {
const range = new Range();
range.selectNode(body.lastChild!);
const { addedNodes, removedNodes } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty(
"innerHTML",
"111<b>222<i>333<i>444</i></i>555</b>",
);
// expect(surroundedRange.toString()).toEqual("555");
});
});
describe("next to element with text element text", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("111<b>222<b>333</b>444</b>555");
});
test("surround after", () => {
const range = new Range();
range.selectNode(body.lastChild!);
const { addedNodes, removedNodes } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(2);
expect(body).toHaveProperty("innerHTML", "111<b>222333444555</b>");
// expect(surroundedRange.toString()).toEqual("555");
});
});
describe("surround elements that already have nested block", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("<b>1<b>2</b></b><br>");
});
test("normalizes nodes", () => {
const range = new Range();
range.selectNode(body.children[0]);
const { addedNodes, removedNodes } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(2);
expect(body).toHaveProperty("innerHTML", "<b>12</b><br>");
// expect(surroundedRange.toString()).toEqual("12");
});
});
describe("surround complicated nested structure", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("<i>1</i><b><i>2</i>3<i>4</i></b><i>5</i>");
});
test("normalize nodes", () => {
const range = new Range();
range.setStartBefore(body.firstElementChild!.firstChild!);
range.setEndAfter(body.lastElementChild!.firstChild!);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty(
"innerHTML",
"<b><i>1</i><i>2</i>3<i>4</i><i>5</i></b>",
);
expect(surroundedRange.toString()).toEqual("12345");
});
});
describe("skips over empty elements", () => {
describe("joins two newly created", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("before<br>after");
});
test("normalize nodes", () => {
const range = new Range();
range.setStartBefore(body.firstChild!);
range.setEndAfter(body.childNodes[2]!);
const { addedNodes, removedNodes, surroundedRange } = surround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(0);
expect(body).toHaveProperty("innerHTML", "<b>before<br>after</b>");
expect(surroundedRange.toString()).toEqual("beforeafter");
});
});
describe("joins with already existing", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("before<br><b>after</b>");
});
test("normalize nodes", () => {
const range = new Range();
range.setStartBefore(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");
});
});
});

View file

@ -0,0 +1,100 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getRangeAnchors } from "./range-anchors";
import { nodeWithinRange } from "./within-range";
import { findTextNodesWithin } from "./text-node";
import {
surroundChildNodeRangeWithNode,
nodeToChildNodeRange,
} from "./child-node-range";
import { mergeMatchChildNodeRanges } from "./merge-match";
import { ascendWhileSingleInline } from "./ascend";
import { normalizeInsertionRanges } from "./normalize-insertion-ranges";
import { matchTagName } from "./matcher";
import type { ElementMatcher, ElementClearer } from "./matcher";
export interface NodesResult {
addedNodes: Node[];
removedNodes: Node[];
}
export type SurroundNoSplittingResult = NodesResult & {
surroundedRange: Range;
};
export function surround(
range: Range,
surroundElement: Element,
base: Element,
matcher: ElementMatcher,
clearer: ElementClearer,
): NodesResult {
const containedTextNodes = findTextNodesWithin(
range.commonAncestorContainer,
).filter((text: Text): boolean => text.length > 0 && nodeWithinRange(text, range));
if (containedTextNodes.length === 0) {
return {
addedNodes: [],
removedNodes: [],
};
}
const containedRanges = containedTextNodes
.map((node: Node): Node => ascendWhileSingleInline(node, base))
.map(nodeToChildNodeRange);
/* First normalization step */
const insertionRanges = mergeMatchChildNodeRanges(containedRanges, base);
/* Second normalization step */
const { normalizedRanges, removedNodes } = normalizeInsertionRanges(
insertionRanges,
matcher,
clearer,
);
const addedNodes: Element[] = [];
for (const normalized of normalizedRanges) {
const surroundClone = surroundElement.cloneNode(false) as Element;
surroundChildNodeRangeWithNode(normalized, surroundClone);
addedNodes.push(surroundClone);
}
return { addedNodes, removedNodes };
}
/**
* Avoids splitting existing elements in the surrounded area
* might create multiple of the surrounding element and remove elements specified by matcher
* can be used for inline elements e.g. <b>, or <strong>
* @param range: The range to surround
* @param surroundNode: This node will be shallowly cloned for surrounding
* @param base: Surrounding will not ascent beyond this point; base.contains(range.commonAncestorContainer) should be true
* @param matcher: Used to detect elements will are similar to the surroundNode, and are included in normalization
**/
export function surroundNoSplitting(
range: Range,
surroundElement: Element,
base: Element,
matcher: ElementMatcher = matchTagName(surroundElement.tagName),
clearer: ElementClearer = () => false,
): SurroundNoSplittingResult {
const { start, end } = getRangeAnchors(range, matcher);
const { addedNodes, removedNodes } = surround(
range,
surroundElement,
base,
matcher,
clearer,
);
const surroundedRange = new Range();
surroundedRange.setStartBefore(start);
surroundedRange.setEndAfter(end);
base.normalize();
return { addedNodes, removedNodes, surroundedRange };
}

View file

@ -0,0 +1,181 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
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 { ChildNodeRange } from "./child-node-range";
function countChildNodesRespectiveToParent(parent: Node, element: Element): number {
return element.parentNode === parent ? element.childNodes.length : 1;
}
interface NormalizationResult {
normalizedRanges: ChildNodeRange[];
removedNodes: Element[];
}
function normalizeWithinInner(
node: Element,
parent: Node,
removedNodes: Element[],
matcher: ElementMatcher,
clearer: ElementClearer,
) {
const matches = findWithinNode(node, matcher);
const processFoundMatches = (match: FoundMatch) =>
match.matchType === MatchResult.MATCH ?? clearer(match.element);
for (const { element: found } of matches.filter(processFoundMatches)) {
removedNodes.push(found);
found.replaceWith(...found.childNodes);
}
/**
* Normalization here is vital so that the
* original range can selected afterwards
*/
node.normalize();
return countChildNodesRespectiveToParent(parent, node);
}
function normalizeAdjacent(
matches: Element[],
keepMatches: Element[],
along: Element[],
parent: Node,
removedNodes: Element[],
matcher: ElementMatcher,
clearer: ElementClearer,
): [length: number, shift: number] {
// const { matches, keepMatches, along } = findBefore(normalizedRange, matcher);
let childCount = along.length;
for (const match of matches) {
childCount += normalizeWithinInner(
match,
parent,
removedNodes,
matcher,
clearer,
);
removedNodes.push(match);
match.replaceWith(...match.childNodes);
}
for (const match of keepMatches) {
const keepChildCount = normalizeWithinInner(
match,
parent,
removedNodes,
matcher,
clearer,
);
if (clearer(match)) {
removedNodes.push(match);
match.replaceWith(...match.childNodes);
childCount += keepChildCount;
} else {
childCount += 1;
}
}
const length = matches.length + keepMatches.length + along.length;
const shift = childCount - length;
return [length, shift];
}
function normalizeWithin(
matches: FoundMatch[],
parent: Node,
removedNodes: Element[],
clearer: ElementClearer,
): number {
let childCount = 0;
for (const { matchType, element } of matches) {
if (matchType === MatchResult.MATCH) {
removedNodes.push(element);
childCount += countChildNodesRespectiveToParent(parent, element);
element.replaceWith(...element.childNodes);
} /* matchType === MatchResult.KEEP */ else {
if (clearer(element)) {
removedNodes.push(element);
childCount += countChildNodesRespectiveToParent(parent, element);
element.replaceWith(...element.childNodes);
} else {
childCount += 1;
}
}
}
const shift = childCount - matches.length;
return shift;
}
export function normalizeInsertionRanges(
insertionRanges: ChildNodeRange[],
matcher: ElementMatcher,
clearer: ElementClearer,
): NormalizationResult {
const removedNodes: Element[] = [];
const normalizedRanges: ChildNodeRange[] = [];
for (const [index, range] of insertionRanges.entries()) {
const normalizedRange = { ...range };
const parent = normalizedRange.parent;
/**
* This deals with the unnormalized state that would exist
* after surrounding and finds conflicting elements, for example cases like:
* `<b>single<b>double</b>single</b>` or `<i><b>before</b></i><b>after</b>`
*/
if (index === 0) {
const { matches, keepMatches, along } = findBefore(
normalizedRange,
matcher,
);
const [length, shift] = normalizeAdjacent(
matches,
keepMatches,
along,
parent,
removedNodes,
matcher,
clearer,
);
normalizedRange.startIndex -= length;
normalizedRange.endIndex += shift;
}
const matches = findWithin(normalizedRange, matcher);
const withinShift = normalizeWithin(matches, parent, removedNodes, clearer);
normalizedRange.endIndex += withinShift;
if (index === insertionRanges.length - 1) {
const { matches, keepMatches, along } = findAfter(normalizedRange, matcher);
const [length, shift] = normalizeAdjacent(
matches,
keepMatches,
along,
parent,
removedNodes,
matcher,
clearer,
);
normalizedRange.endIndex += length + shift;
}
normalizedRanges.push(normalizedRange);
}
return {
normalizedRanges,
removedNodes,
};
}

View file

@ -0,0 +1,92 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { nodeIsElement } from "../../lib/dom";
import type { ElementMatcher } from "./matcher";
import { MatchResult } from "./matcher";
import { splitPartiallySelectedTextNodes } from "./text-node";
function textOrMatches(node: Node, matcher: ElementMatcher): boolean {
return !nodeIsElement(node) || matcher(node as Element) === MatchResult.MATCH;
}
function findBelow(element: Element, matcher: ElementMatcher): Node | null {
while (element.hasChildNodes()) {
const node = element.childNodes[element.childNodes.length - 1];
if (textOrMatches(node, matcher)) {
return node;
}
element = node as Element;
}
return null;
}
function findAbove(element: Element, matcher: ElementMatcher): Node | null {
if (element.parentNode) {
const index = Array.prototype.indexOf.call(element.parentNode, element);
if (index > 0) {
const before = element.parentNode.childNodes[index - 1];
if (textOrMatches(before, matcher)) {
return before;
}
}
}
return null;
}
function findFittingNode(node: Node, matcher: ElementMatcher): Node {
if (textOrMatches(node, matcher)) {
return node;
}
return (
findBelow(node as Element, matcher) ??
findAbove(node as Element, matcher) ??
(console.log("anki: findFittingNode returns invalid node"), node)
);
}
function negate(matcher: ElementMatcher): ElementMatcher {
return (element: Element) => {
const matchResult = matcher(element);
switch (matchResult) {
case MatchResult.NO_MATCH:
return MatchResult.MATCH;
case MatchResult.MATCH:
return MatchResult.NO_MATCH;
default:
return matchResult;
}
};
}
interface RangeAnchors {
start: Node;
end: Node;
}
export function getRangeAnchors(range: Range, matcher: ElementMatcher): RangeAnchors {
const { start, end } = splitPartiallySelectedTextNodes(range);
return {
start:
start ??
findFittingNode(
range.startContainer.childNodes[range.startOffset],
negate(matcher),
),
end:
end ??
findFittingNode(
range.endContainer.childNodes[range.endOffset - 1],
negate(matcher),
),
};
}

View file

@ -0,0 +1,64 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { nodeIsElement, nodeIsText } from "../../lib/dom";
/**
* @returns Split text node to end direction
*/
function splitText(node: Text, offset: number): Text {
return node.splitText(offset);
}
interface SplitRange {
start: Text | null;
end: Text | null;
}
export function splitPartiallySelectedTextNodes(range: Range): SplitRange {
const startContainer = range.startContainer;
const startOffset = range.startOffset;
const start = nodeIsText(startContainer)
? splitText(startContainer, startOffset)
: null;
const endContainer = range.endContainer;
const endOffset = range.endOffset;
let end: Text | null = null;
if (nodeIsText(endContainer)) {
const splitOff = splitText(endContainer, endOffset);
if (splitOff.data.length === 0) {
/**
* Range should include split text if zero-length
* For the start container, this is done automatically
*/
end = splitOff;
range.setEndAfter(end);
} else {
end = endContainer;
}
}
return { start, end };
}
/* returned in source order */
export function findTextNodesWithin(node: Node): Text[] {
if (nodeIsText(node)) {
return [node];
} else if (nodeIsElement(node)) {
return Array.from(node.childNodes).reduce(
(accumulator: Text[], value) => [
...accumulator,
...findTextNodesWithin(value),
],
[],
);
} else {
return [];
}
}

View file

@ -0,0 +1,135 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { unsurround } from "./unsurround";
const parser = new DOMParser();
function p(html: string): HTMLBodyElement {
const parsed = parser.parseFromString(html, "text/html");
return parsed.body as HTMLBodyElement;
}
describe("unsurround text", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("<b>test</b>");
});
test("normalizes nodes", () => {
const range = new Range();
range.selectNode(body.firstChild!);
const { addedNodes, removedNodes, surroundedRange } = unsurround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(0);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty("innerHTML", "test");
expect(surroundedRange.toString()).toEqual("test");
});
});
describe("unsurround element with surrounding text", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("11<b>22</b>33");
});
test("normalizes nodes", () => {
const range = new Range();
range.selectNode(body.firstElementChild!);
const { addedNodes, removedNodes } = unsurround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(0);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty("innerHTML", "112233");
// expect(surroundedRange.toString()).toEqual("22");
});
});
describe("unsurround from one element to another", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("<b>111</b>222<b>333</b>");
});
test("unsurround whole", () => {
const range = new Range();
range.setStartBefore(body.children[0].firstChild!);
range.setEndAfter(body.children[1].firstChild!);
const { addedNodes, removedNodes } = unsurround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(0);
expect(removedNodes).toHaveLength(2);
expect(body).toHaveProperty("innerHTML", "111222333");
// expect(surroundedRange.toString()).toEqual("22");
});
});
// describe("unsurround text portion of element", () => {
// let body: HTMLBodyElement;
// beforeEach(() => {
// body = p("<b>112233</b>");
// });
// test("normalizes nodes", () => {
// const range = new Range();
// range.setStart(body.firstChild!, 2);
// range.setEnd(body.firstChild!, 4);
// const { addedNodes, removedNodes } = unsurround(
// range,
// document.createElement("b"),
// body,
// );
// expect(addedNodes).toHaveLength(2);
// expect(removedNodes).toHaveLength(1);
// expect(body).toHaveProperty("innerHTML", "<b>11</b>22<b>33</b>");
// // expect(surroundedRange.toString()).toEqual("22");
// });
// });
describe("with bold around block item", () => {
let body: HTMLBodyElement;
beforeEach(() => {
body = p("<b>111<br><ul><li>222</li></ul></b>");
});
test("unsurround list item", () => {
const range = new Range();
range.selectNodeContents(
body.firstChild!.childNodes[2].firstChild!.firstChild!,
);
const { addedNodes, removedNodes } = unsurround(
range,
document.createElement("b"),
body,
);
expect(addedNodes).toHaveLength(1);
expect(removedNodes).toHaveLength(1);
expect(body).toHaveProperty("innerHTML", "<b>111</b><br><ul><li>222</li></ul>");
// expect(surroundedRange.toString()).toEqual("222");
});
});

View file

@ -0,0 +1,227 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getRangeAnchors } from "./range-anchors";
import type { NodesResult, SurroundNoSplittingResult } from "./no-splitting";
import { MatchResult, matchTagName } from "./matcher";
import type { FoundMatch, ElementMatcher, ElementClearer } from "./matcher";
import { findFarthest } from "./find-above";
import { findWithinRange, findWithinNode } from "./find-within";
import { surround } from "./no-splitting";
function findBetween(
range: Range,
matcher: ElementMatcher,
aboveStart?: Element | undefined,
aboveEnd?: Element | undefined,
): FoundMatch[] {
const betweenRange = range.cloneRange();
if (aboveStart) {
betweenRange.setStartAfter(aboveStart);
}
if (aboveEnd) {
betweenRange.setEndBefore(aboveEnd);
}
return findWithinRange(betweenRange, matcher);
}
function findAndClearWithin(
match: FoundMatch,
matcher: ElementMatcher,
clearer: ElementClearer,
condition: (node: Node) => boolean = () => true,
): Element[] {
const toRemove: Element[] = [];
for (const { matchType, element } of findWithinNode(match.element, matcher)) {
if (matchType === MatchResult.MATCH) {
if (condition(element)) {
toRemove.push(element);
}
} /* matchType === MatchResult.KEEP */ else {
// order is very important here as `clearer` is idempotent!
if (condition(element) && clearer(element)) {
toRemove.push(element);
}
}
}
if (condition(match.element)) {
switch (match.matchType) {
case MatchResult.MATCH:
toRemove.push(match.element);
break;
case MatchResult.KEEP:
if (clearer(match.element)) {
toRemove.push(match.element);
}
break;
}
}
return toRemove;
}
function prohibitOverlapse(range: AbstractRange): (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);
}
interface FindNodesToRemoveResult {
nodesToRemove: Element[];
beforeRange: Range;
afterRange: Range;
}
/**
* @returns beforeRange: will start at the farthest any of the nodes to remove will
* extend in start direction till the start of the original range
* @return afterRange: will start at the end of the original range and will extend as
* far as any of the nodes to remove will extend in end direction
*/
function findNodesToRemove(
range: Range,
base: Element,
matcher: ElementMatcher,
clearer: ElementClearer,
): FindNodesToRemoveResult {
const nodesToRemove: Element[] = [];
const aboveStart = findFarthest(range.startContainer, base, matcher);
const aboveEnd = findFarthest(range.endContainer, base, matcher);
const between = findBetween(range, matcher, aboveStart?.element, aboveEnd?.element);
const beforeRange = new Range();
beforeRange.setEnd(range.startContainer, range.startOffset);
beforeRange.collapse(false);
if (aboveStart) {
beforeRange.setStartBefore(aboveStart.element);
const matches = findAndClearWithin(
aboveStart,
matcher,
clearer,
prohibitOverlapse(range),
);
nodesToRemove.push(...matches);
}
nodesToRemove.push(...between.map((match) => match.element));
const afterRange = new Range();
afterRange.setStart(range.endContainer, range.endOffset);
afterRange.collapse(true);
if (aboveEnd) {
afterRange.setEndAfter(aboveEnd.element);
const matches = findAndClearWithin(aboveEnd, matcher, clearer);
nodesToRemove.push(...matches);
}
return {
nodesToRemove,
beforeRange,
afterRange,
};
}
function resurroundAdjacent(
beforeRange: Range,
afterRange: Range,
surroundNode: Element,
base: Element,
matcher: ElementMatcher,
clearer: ElementClearer,
): NodesResult {
const addedNodes: Node[] = [];
const removedNodes: Node[] = [];
if (beforeRange.toString().length > 0) {
const { addedNodes: added, removedNodes: removed } = surround(
beforeRange,
surroundNode,
base,
matcher,
clearer,
);
addedNodes.push(...added);
removedNodes.push(...removed);
}
if (afterRange.toString().length > 0) {
const { addedNodes: added, removedNodes: removed } = surround(
afterRange,
surroundNode,
base,
matcher,
clearer,
);
addedNodes.push(...added);
removedNodes.push(...removed);
}
return { addedNodes, removedNodes };
}
/**
* Avoids splitting existing elements in the surrounded area
* might create multiple of the surrounding element and remove elements specified by matcher
* can be used for inline elements e.g. <b>, or <strong>
* @param range: The range to surround
* @param surroundNode: This node will be shallowly cloned for surrounding
* @param base: Surrounding will not ascent beyond this point; base.contains(range.commonAncestorContainer) should be true
* @param matcher: Used to detect elements will are similar to the surroundNode, and are included in normalization
* @param clearer: Used to clear elements which have unwanted properties
**/
export function unsurround(
range: Range,
surroundNode: Element,
base: Element,
matcher: ElementMatcher = matchTagName(surroundNode.tagName),
clearer: ElementClearer = () => false,
): SurroundNoSplittingResult {
const { start, end } = getRangeAnchors(range, matcher);
const { nodesToRemove, beforeRange, afterRange } = findNodesToRemove(
range,
base,
matcher,
clearer,
);
/**
* We cannot remove the nodes immediately, because they would make the ranges collapse
*/
const { addedNodes, removedNodes } = resurroundAdjacent(
beforeRange,
afterRange,
surroundNode,
base,
matcher,
clearer,
);
for (const node of nodesToRemove) {
if (node.isConnected) {
removedNodes.push(node);
node.replaceWith(...node.childNodes);
}
}
const surroundedRange = new Range();
surroundedRange.setStartBefore(start);
surroundedRange.setEndAfter(end);
base.normalize();
return {
addedNodes,
removedNodes,
surroundedRange,
};
}

View file

@ -0,0 +1,16 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { Position } from "../location";
export function nodeWithinRange(node: Node, range: Range): boolean {
const nodeRange = new Range();
/* range.startContainer and range.endContainer will be Text */
nodeRange.selectNodeContents(node);
return (
range.compareBoundaryPoints(Range.START_TO_START, nodeRange) !==
Position.After &&
range.compareBoundaryPoints(Range.END_TO_END, nodeRange) !== Position.Before
);
}

View file

@ -1,6 +1,6 @@
{
"extends": "../tsconfig.json",
"include": ["*", "location/*"],
"include": ["*", "location/*", "surround/*"],
"references": [{ "path": "../lib" }],
"compilerOptions": {
"types": ["jest"]

View file

@ -7,6 +7,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { updateAllState } from "../components/WithState.svelte";
import { saveSelection, restoreSelection } from "../domlib/location";
import { on } from "../lib/events";
import { registerShortcut } from "../lib/shortcuts";
export let nodes: Writable<DocumentFragment>;
export let resolve: (editable: HTMLElement) => void;
@ -15,8 +16,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
params: { store: Writable<DocumentFragment> },
) => void;
export let inputManager: (editable: HTMLElement) => void;
/* must execute before DOMMirror */
function saveLocation(editable: Element) {
function saveLocation(editable: HTMLElement) {
let removeOnFocus: () => void;
let removeOnPointerdown: () => void;
@ -47,13 +50,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
},
};
}
let editable: HTMLElement;
$: if (editable) {
registerShortcut((event) => event.preventDefault(), "Control+B", editable);
registerShortcut((event) => event.preventDefault(), "Control+U", editable);
registerShortcut((event) => event.preventDefault(), "Control+I", editable);
registerShortcut((event) => event.preventDefault(), "Control+R", editable);
}
</script>
<anki-editable
contenteditable="true"
bind:this={editable}
use:resolve
use:saveLocation
use:mirror={{ store: nodes }}
use:inputManager
on:focus
on:blur
on:click={updateAllState}
on:keyup={updateAllState}
/>

View file

@ -25,6 +25,7 @@ _ts_deps = [
"//ts/editable:editable_ts",
"//ts/html-filter",
"//ts/lib",
"//ts/domlib",
"//ts/sveltelib",
"@npm//@fluent",
"@npm//@types/codemirror",

View file

@ -0,0 +1,93 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "../lib/ftl";
import IconButton from "../components/IconButton.svelte";
import Shortcut from "../components/Shortcut.svelte";
import WithState from "../components/WithState.svelte";
import { MatchResult } from "../domlib/surround";
import { getPlatformString } from "../lib/shortcuts";
import { isSurrounded, surroundCommand } from "./surround";
import { boldIcon } from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "./RichTextInput.svelte";
function matchBold(element: Element): MatchResult {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return MatchResult.NO_MATCH;
}
if (element.tagName === "B" || element.tagName === "STRONG") {
return MatchResult.MATCH;
}
const fontWeight = element.style.fontWeight;
if (fontWeight === "bold" || Number(fontWeight) >= 400) {
return MatchResult.KEEP;
}
return MatchResult.NO_MATCH;
}
function clearBold(element: Element): boolean {
const htmlElement = element as HTMLElement | SVGElement;
htmlElement.style.removeProperty("font-weight");
if (htmlElement.style.cssText.length === 0) {
htmlElement.removeAttribute("style");
}
return !htmlElement.hasAttribute("style") && element.className.length === 0;
}
const { focusInRichText, activeInput } = getNoteEditor();
$: input = $activeInput;
$: disabled = !$focusInRichText;
function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text"
? Promise.resolve(false)
: isSurrounded(input, matchBold);
}
function makeBold(): void {
surroundCommand(
input as RichTextInputAPI,
document.createElement("strong"),
matchBold,
clearBold,
);
}
const keyCombination = "Control+B";
</script>
<WithState
key="bold"
update={updateStateFromActiveInput}
let:state={active}
let:updateState
>
<IconButton
tooltip="{tr.editingBoldText()} ({getPlatformString(keyCombination)})"
{active}
{disabled}
on:click={(event) => {
makeBold();
updateState(event);
}}
>
{@html boldIcon}
</IconButton>
<Shortcut
{keyCombination}
on:action={(event) => {
makeBold();
updateState(event);
}}
/>
</WithState>

View file

@ -19,6 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let withoutState = false;
const { focusInRichText } = getNoteEditor();
$: disabled = !$focusInRichText;
</script>
@ -29,7 +30,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{:else if withoutShortcut}
<WithState
{key}
update={() => queryCommandState(key)}
update={async () => queryCommandState(key)}
let:state={active}
let:updateState
>
@ -60,7 +61,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<WithShortcut {shortcut} let:createShortcut let:shortcutLabel>
<WithState
{key}
update={() => queryCommandState(key)}
update={async () => queryCommandState(key)}
let:state={active}
let:updateState
>

View file

@ -6,43 +6,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import CommandIconButton from "./CommandIconButton.svelte";
import BoldButton from "./BoldButton.svelte";
import ItalicButton from "./ItalicButton.svelte";
import UnderlineButton from "./UnderlineButton.svelte";
import * as tr from "../lib/ftl";
import {
boldIcon,
italicIcon,
underlineIcon,
superscriptIcon,
subscriptIcon,
eraserIcon,
} from "./icons";
import { superscriptIcon, subscriptIcon, eraserIcon } from "./icons";
export let api = {};
</script>
<ButtonGroup {api}>
<ButtonGroupItem>
<CommandIconButton
key="bold"
shortcut={"Control+B"}
tooltip={tr.editingBoldText()}>{@html boldIcon}</CommandIconButton
>
<BoldButton />
</ButtonGroupItem>
<ButtonGroupItem>
<CommandIconButton
key="italic"
shortcut={"Control+I"}
tooltip={tr.editingItalicText()}>{@html italicIcon}</CommandIconButton
>
<ItalicButton />
</ButtonGroupItem>
<ButtonGroupItem>
<CommandIconButton
key="underline"
shortcut={"Control+U"}
tooltip={tr.editingUnderlineText()}>{@html underlineIcon}</CommandIconButton
>
<UnderlineButton />
</ButtonGroupItem>
<ButtonGroupItem>

View file

@ -0,0 +1,92 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "../lib/ftl";
import IconButton from "../components/IconButton.svelte";
import Shortcut from "../components/Shortcut.svelte";
import WithState from "../components/WithState.svelte";
import { MatchResult } from "../domlib/surround";
import { getPlatformString } from "../lib/shortcuts";
import { isSurrounded, surroundCommand } from "./surround";
import { italicIcon } from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "./RichTextInput.svelte";
function matchItalic(element: Element): MatchResult {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return MatchResult.NO_MATCH;
}
if (element.tagName === "I" || element.tagName === "EM") {
return MatchResult.MATCH;
}
if (["italic", "oblique"].includes(element.style.fontStyle)) {
return MatchResult.KEEP;
}
return MatchResult.NO_MATCH;
}
function clearItalic(element: Element): boolean {
const htmlElement = element as HTMLElement | SVGElement;
htmlElement.style.removeProperty("font-style");
if (htmlElement.style.cssText.length === 0) {
htmlElement.removeAttribute("style");
}
return !htmlElement.hasAttribute("style") && element.className.length === 0;
}
const { focusInRichText, activeInput } = getNoteEditor();
$: input = $activeInput;
$: disabled = !$focusInRichText;
function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text"
? Promise.resolve(false)
: isSurrounded(input, matchItalic);
}
function makeItalic(): void {
surroundCommand(
input as RichTextInputAPI,
document.createElement("em"),
matchItalic,
clearItalic,
);
}
const keyCombination = "Control+I";
</script>
<WithState
key="italic"
update={updateStateFromActiveInput}
let:state={active}
let:updateState
>
<IconButton
tooltip="{tr.editingItalicText()} ({getPlatformString(keyCombination)})"
{active}
{disabled}
on:click={(event) => {
makeItalic();
updateState(event);
}}
>
{@html italicIcon}
</IconButton>
<Shortcut
{keyCombination}
on:action={(event) => {
makeItalic();
updateState(event);
}}
/>
</WithState>

View file

@ -6,14 +6,18 @@ 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";
export interface RichTextInputAPI extends EditingInputAPI {
name: "rich-text";
shadowRoot: Promise<ShadowRoot>;
element: Promise<HTMLElement>;
moveCaretToEnd(): void;
refocus(): void;
toggle(): boolean;
surround(before: string, after: string): void;
preventResubscription(): () => void;
triggerOnInsert(callback: OnInsertCallback): () => void;
}
export interface RichTextInputContextAPI {
@ -120,8 +124,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
content.set(output);
}
const [shadowPromise, shadowResolve] = promiseWithResolver<ShadowRoot>();
function attachShadow(element: Element): void {
element.attachShadow({ mode: "open" });
shadowResolve(element.attachShadow({ mode: "open" }));
}
const [richTextPromise, richTextResolve] = promiseWithResolver<HTMLElement>();
@ -151,8 +157,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
import getDOMMirror from "../sveltelib/mirror-dom";
import getInputManager from "../sveltelib/input-manager";
const { mirror, preventResubscription } = getDOMMirror();
const { manager, triggerOnInsert } = getInputManager();
function moveCaretToEnd() {
richTextPromise.then(caretToEnd);
@ -169,6 +177,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
nodes,
resolve,
mirror,
inputManager: manager,
},
context: allContexts,
}),
@ -177,6 +186,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const api: RichTextInputAPI = {
name: "rich-text",
shadowRoot: shadowPromise,
element: richTextPromise,
focus() {
richTextPromise.then((richText) => richText.focus());
},
@ -198,6 +209,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
);
},
preventResubscription,
triggerOnInsert,
};
function pushUpdate(): void {

View file

@ -0,0 +1,76 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "../lib/ftl";
import IconButton from "../components/IconButton.svelte";
import Shortcut from "../components/Shortcut.svelte";
import WithState from "../components/WithState.svelte";
import { MatchResult } from "../domlib/surround";
import { getPlatformString } from "../lib/shortcuts";
import { isSurrounded, surroundCommand } from "./surround";
import { underlineIcon } from "./icons";
import { getNoteEditor } from "./OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "./RichTextInput.svelte";
function matchUnderline(element: Element): MatchResult {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
return MatchResult.NO_MATCH;
}
if (element.tagName === "U") {
return MatchResult.MATCH;
}
return MatchResult.NO_MATCH;
}
const { focusInRichText, activeInput } = getNoteEditor();
$: input = $activeInput;
$: disabled = !$focusInRichText;
function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text"
? Promise.resolve(false)
: isSurrounded(input, matchUnderline);
}
function makeUnderline(): void {
surroundCommand(
input as RichTextInputAPI,
document.createElement("u"),
matchUnderline,
);
}
const keyCombination = "Control+U";
</script>
<WithState
key="underline"
update={updateStateFromActiveInput}
let:state={active}
let:updateState
>
<IconButton
tooltip="{tr.editingUnderlineText()} ({getPlatformString(keyCombination)})"
{active}
{disabled}
on:click={(event) => {
makeUnderline();
updateState(event);
}}
>
{@html underlineIcon}
</IconButton>
<Shortcut
{keyCombination}
on:action={(event) => {
makeUnderline();
updateState(event);
}}
/>
</WithState>

View file

@ -10,6 +10,7 @@ import "./editor-base.css";
import "../sveltelib/export-runtime";
import "../lib/register-package";
import "../domlib/surround";
import { filterHTML } from "../html-filter";
import { execCommand } from "./helpers";

87
ts/editor/surround.ts Normal file
View file

@ -0,0 +1,87 @@
// 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 { getSelection } from "../lib/cross-browser";
import { surroundNoSplitting, unsurround, findClosest } from "../domlib/surround";
import type { ElementMatcher, ElementClearer } from "../domlib/surround";
export function isSurroundedInner(
range: AbstractRange,
base: HTMLElement,
matcher: ElementMatcher,
): boolean {
return Boolean(
findClosest(range.startContainer, base, matcher) ||
findClosest(range.endContainer, base, matcher),
);
}
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,
selection: Selection,
surroundElement: Element,
base: HTMLElement,
matcher: ElementMatcher,
clearer: ElementClearer,
): void {
const { surroundedRange } = matches
? unsurround(range, surroundElement, base, matcher, clearer)
: surroundNoSplitting(range, surroundElement, base, matcher, clearer);
selection.removeAllRanges();
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);
if (range.collapsed) {
input.triggerOnInsert(async ({ node }): Promise<void> => {
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,
selection,
surroundElement,
base,
matcher,
clearer,
);
}
}

View file

@ -3,6 +3,7 @@
"include": ["*", "image-overlay/*", "mathjax-overlay/*"],
"references": [
{ "path": "../components" },
{ "path": "../domlib" },
{ "path": "../lib" },
{ "path": "../sveltelib" },
{ "path": "../editable" },

View file

@ -2,7 +2,7 @@ load("@npm//@bazel/esbuild:index.bzl", "esbuild")
load("@npm//jest-cli:index.bzl", _jest_test = "jest_test")
def jest_test(deps, name = "jest", protobuf = False, env = "node"):
ts_sources = native.glob(["*.test.ts"])
ts_sources = native.glob(["**/*.test.ts"])
# bundle each test file up with its dependencies for jest
bundled_srcs = []

View file

@ -64,6 +64,7 @@ prettier_test()
eslint_test()
jest_test(
env = "jsdom",
deps = [
":lib",
],

View file

@ -11,5 +11,5 @@ export function getSelection(element: Node): Selection | null {
return root.getSelection();
}
return null;
return document.getSelection();
}

View file

@ -5,8 +5,12 @@ export function nodeIsElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}
export function nodeIsText(node: Node): node is Text {
return node.nodeType === Node.TEXT_NODE;
}
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
const BLOCK_TAGS = [
const BLOCK_ELEMENTS = [
"ADDRESS",
"ARTICLE",
"ASIDE",
@ -43,7 +47,29 @@ const BLOCK_TAGS = [
];
export function elementIsBlock(element: Element): boolean {
return BLOCK_TAGS.includes(element.tagName);
return BLOCK_ELEMENTS.includes(element.tagName);
}
// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
const EMPTY_ELEMENTS = [
"AREA",
"BASE",
"BR",
"COL",
"EMBED",
"HR",
"IMG",
"INPUT",
"LINK",
"META",
"PARAM",
"SOURCE",
"TRACK",
"WBR",
];
export function elementIsEmpty(element: Element): boolean {
return EMPTY_ELEMENTS.includes(element.tagName);
}
export function nodeContainsInlineContent(node: Node): boolean {
@ -68,6 +94,12 @@ export function fragmentToString(fragment: DocumentFragment): string {
return html;
}
export const NO_SPLIT_TAGS = ["RUBY"];
export function elementShouldNotBeSplit(element: Element): boolean {
return elementIsBlock(element) || NO_SPLIT_TAGS.includes(element.tagName);
}
export function caretToEnd(node: Node): void {
const range = new Range();
range.selectNodeContents(node);

View file

@ -1,12 +1,33 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export function on<T extends EventTarget, L extends EventListener>(
type EventTargetToMap<A extends EventTarget> = A extends HTMLElement
? HTMLElementEventMap
: A extends Document
? DocumentEventMap
: A extends Window
? WindowEventMap
: A extends FileReader
? FileReaderEventMap
: A extends Element
? ElementEventMap
: A extends Animation
? AnimationEventMap
: A extends EventSource
? EventSourceEventMap
: A extends AbortSignal
? AbortSignalEventMap
: A extends AbstractWorker
? AbstractWorkerEventMap
: never;
export function on<T extends EventTarget, K extends keyof EventTargetToMap<T>>(
target: T,
eventType: string,
listener: L,
options: AddEventListenerOptions = {},
eventType: Exclude<K, symbol | number>,
handler: (this: T, event: EventTargetToMap<T>[K]) => void,
options?: AddEventListenerOptions,
): () => void {
target.addEventListener(eventType, listener, options);
return () => target.removeEventListener(eventType, listener, options);
target.addEventListener(eventType, handler as EventListener, options);
return () =>
target.removeEventListener(eventType, handler as EventListener, options);
}

View file

@ -16,6 +16,13 @@ function translateModifierToPlatform(modifier: Modifier): string {
return platformModifiers[allModifiers.indexOf(modifier)];
}
const GENERAL_KEY = 0;
const NUMPAD_KEY = 3;
export function checkIfInputKey(event: KeyboardEvent): boolean {
return event.location === GENERAL_KEY || event.location === NUMPAD_KEY;
}
export const checkModifiers =
(required: Modifier[], optional: Modifier[] = []) =>
(event: KeyboardEvent): boolean => {

14
ts/lib/node.ts Normal file
View file

@ -0,0 +1,14 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export function isOnlyChild(node: Node): boolean {
return node.parentNode!.childNodes.length === 1;
}
export function hasOnlyChild(node: Node): boolean {
return node.childNodes.length === 1;
}
export function ascend(node: Node): Node {
return node.parentNode!;
}

View file

@ -6,6 +6,6 @@ declare global {
}
interface Node {
getRootNode(options?: GetRootNodeOptions): DocumentOrShadowRoot;
getRootNode(options?: GetRootNodeOptions): Document | ShadowRoot;
}
}

View file

@ -4,7 +4,12 @@
import type { Modifier } from "./keys";
import { registerPackage } from "./register-package";
import { modifiersToPlatformString, keyToPlatformString, checkModifiers } from "./keys";
import {
modifiersToPlatformString,
keyToPlatformString,
checkModifiers,
checkIfInputKey,
} from "./keys";
const keyCodeLookup = {
Backspace: 8,
@ -112,9 +117,6 @@ function keyCombinationToCheck(
return check(keyCode, modifiers);
}
const GENERAL_KEY = 0;
const NUMPAD_KEY = 3;
function innerShortcut(
target: EventTarget | Document,
lastEvent: KeyboardEvent,
@ -131,10 +133,7 @@ function innerShortcut(
if (nextCheck(event)) {
innerShortcut(target, event, callback, ...restChecks);
clearTimeout(interval);
} else if (
event.location === GENERAL_KEY ||
event.location === NUMPAD_KEY
) {
} else if (checkIfInputKey(event)) {
// Any non-modifier key will cancel the shortcut sequence
document.removeEventListener("keydown", handler);
}

View file

@ -1,6 +1,6 @@
{
"extends": "../tsconfig.json",
"include": ["*", "i18n/*"],
"include": ["*", "i18n/*", "location/*", "surround/*"],
"references": [],
"compilerOptions": {
"types": ["jest"]

View file

@ -0,0 +1,101 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { on } from "../lib/events";
import { nodeIsText } from "../lib/dom";
import { getSelection } from "../lib/cross-browser";
export type OnInsertCallback = ({ node: Node }) => Promise<void>;
interface InputManager {
manager(element: HTMLElement): { destroy(): void };
triggerOnInsert(callback: OnInsertCallback): () => void;
}
export function getInputManager(): InputManager {
const onInsertText: OnInsertCallback[] = [];
function cancelInsertText(): void {
onInsertText.length = 0;
}
function cancelIfInsertText(event: KeyboardEvent): void {
if (event.key.length !== 1) {
cancelInsertText();
}
}
async function onBeforeInput(event: InputEvent): Promise<void> {
if (event.inputType === "insertText" && onInsertText.length > 0) {
const nbsp = " ";
const textContent = event.data === " " ? nbsp : event.data ?? nbsp;
const node = new Text(textContent);
const selection = getSelection(event.target! as Node)!;
const range = selection.getRangeAt(0);
range.deleteContents();
if (nodeIsText(range.startContainer) && range.startOffset === 0) {
const parent = range.startContainer.parentNode!;
parent.insertBefore(node, range.startContainer);
} else if (
nodeIsText(range.endContainer) &&
range.endOffset === range.endContainer.length
) {
const parent = range.endContainer.parentNode!;
parent.insertBefore(node, range.endContainer.nextSibling!);
} else {
range.insertNode(node);
}
range.selectNode(node);
range.collapse(false);
for (const callback of onInsertText) {
await callback({ node });
}
event.preventDefault();
}
cancelInsertText();
}
function manager(element: HTMLElement): { destroy(): void } {
const removeBeforeInput = on(element, "beforeinput", onBeforeInput);
const removePointerDown = on(element, "pointerdown", cancelInsertText);
const removeBlur = on(element, "blur", cancelInsertText);
const removeKeyDown = on(
element,
"keydown",
cancelIfInsertText as EventListener,
);
return {
destroy() {
removeBeforeInput();
removePointerDown();
removeBlur();
removeKeyDown();
},
};
}
function triggerOnInsert(callback: OnInsertCallback): () => void {
onInsertText.push(callback);
return () => {
const index = onInsertText.indexOf(callback);
if (index > 0) {
onInsertText.splice(index, 1);
}
};
}
return {
manager,
triggerOnInsert,
};
}
export default getInputManager;