mirror of
https://github.com/ankitects/anki.git
synced 2025-09-23 08:22:24 -04:00
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:
parent
be0fe849c5
commit
64d46ca638
46 changed files with 2413 additions and 73 deletions
|
@ -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);
|
||||
|
|
|
@ -22,3 +22,8 @@ typescript(
|
|||
prettier_test()
|
||||
|
||||
eslint_test()
|
||||
|
||||
jest_test(
|
||||
env = "jsdom",
|
||||
deps = [":domlib"],
|
||||
)
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
22
ts/domlib/surround/ascend.ts
Normal file
22
ts/domlib/surround/ascend.ts
Normal 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;
|
||||
}
|
111
ts/domlib/surround/child-node-range.ts
Normal file
111
ts/domlib/surround/child-node-range.ts
Normal 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);
|
||||
}
|
60
ts/domlib/surround/find-above.ts
Normal file
60
ts/domlib/surround/find-above.ts
Normal 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;
|
||||
}
|
67
ts/domlib/surround/find-adjacent.test.ts
Normal file
67
ts/domlib/surround/find-adjacent.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
125
ts/domlib/surround/find-adjacent.ts
Normal file
125
ts/domlib/surround/find-adjacent.ts
Normal 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);
|
||||
}
|
70
ts/domlib/surround/find-within.ts
Normal file
70
ts/domlib/surround/find-within.ts
Normal 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;
|
||||
}
|
20
ts/domlib/surround/index.ts
Normal file
20
ts/domlib/surround/index.ts
Normal 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";
|
34
ts/domlib/surround/matcher.ts
Normal file
34
ts/domlib/surround/matcher.ts
Normal 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>;
|
||||
}
|
101
ts/domlib/surround/merge-match.ts
Normal file
101
ts/domlib/surround/merge-match.ts
Normal 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), []);
|
||||
}
|
350
ts/domlib/surround/no-splitting.test.ts
Normal file
350
ts/domlib/surround/no-splitting.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
100
ts/domlib/surround/no-splitting.ts
Normal file
100
ts/domlib/surround/no-splitting.ts
Normal 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 };
|
||||
}
|
181
ts/domlib/surround/normalize-insertion-ranges.ts
Normal file
181
ts/domlib/surround/normalize-insertion-ranges.ts
Normal 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,
|
||||
};
|
||||
}
|
92
ts/domlib/surround/range-anchors.ts
Normal file
92
ts/domlib/surround/range-anchors.ts
Normal 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),
|
||||
),
|
||||
};
|
||||
}
|
64
ts/domlib/surround/text-node.ts
Normal file
64
ts/domlib/surround/text-node.ts
Normal 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 [];
|
||||
}
|
||||
}
|
135
ts/domlib/surround/unsurround.test.ts
Normal file
135
ts/domlib/surround/unsurround.test.ts
Normal 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");
|
||||
});
|
||||
});
|
227
ts/domlib/surround/unsurround.ts
Normal file
227
ts/domlib/surround/unsurround.ts
Normal 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,
|
||||
};
|
||||
}
|
16
ts/domlib/surround/within-range.ts
Normal file
16
ts/domlib/surround/within-range.ts
Normal 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
|
||||
);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["*", "location/*"],
|
||||
"include": ["*", "location/*", "surround/*"],
|
||||
"references": [{ "path": "../lib" }],
|
||||
"compilerOptions": {
|
||||
"types": ["jest"]
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -25,6 +25,7 @@ _ts_deps = [
|
|||
"//ts/editable:editable_ts",
|
||||
"//ts/html-filter",
|
||||
"//ts/lib",
|
||||
"//ts/domlib",
|
||||
"//ts/sveltelib",
|
||||
"@npm//@fluent",
|
||||
"@npm//@types/codemirror",
|
||||
|
|
93
ts/editor/BoldButton.svelte
Normal file
93
ts/editor/BoldButton.svelte
Normal 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>
|
|
@ -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
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
92
ts/editor/ItalicButton.svelte
Normal file
92
ts/editor/ItalicButton.svelte
Normal 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>
|
|
@ -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 {
|
||||
|
|
76
ts/editor/UnderlineButton.svelte
Normal file
76
ts/editor/UnderlineButton.svelte
Normal 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>
|
|
@ -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
87
ts/editor/surround.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
"include": ["*", "image-overlay/*", "mathjax-overlay/*"],
|
||||
"references": [
|
||||
{ "path": "../components" },
|
||||
{ "path": "../domlib" },
|
||||
{ "path": "../lib" },
|
||||
{ "path": "../sveltelib" },
|
||||
{ "path": "../editable" },
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -64,6 +64,7 @@ prettier_test()
|
|||
eslint_test()
|
||||
|
||||
jest_test(
|
||||
env = "jsdom",
|
||||
deps = [
|
||||
":lib",
|
||||
],
|
||||
|
|
|
@ -11,5 +11,5 @@ export function getSelection(element: Node): Selection | null {
|
|||
return root.getSelection();
|
||||
}
|
||||
|
||||
return null;
|
||||
return document.getSelection();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
14
ts/lib/node.ts
Normal 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!;
|
||||
}
|
2
ts/lib/shadow-dom.d.ts
vendored
2
ts/lib/shadow-dom.d.ts
vendored
|
@ -6,6 +6,6 @@ declare global {
|
|||
}
|
||||
|
||||
interface Node {
|
||||
getRootNode(options?: GetRootNodeOptions): DocumentOrShadowRoot;
|
||||
getRootNode(options?: GetRootNodeOptions): Document | ShadowRoot;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["*", "i18n/*"],
|
||||
"include": ["*", "i18n/*", "location/*", "surround/*"],
|
||||
"references": [],
|
||||
"compilerOptions": {
|
||||
"types": ["jest"]
|
||||
|
|
101
ts/sveltelib/input-manager.ts
Normal file
101
ts/sveltelib/input-manager.ts
Normal 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;
|
Loading…
Reference in a new issue