Move all buttons to our custom inline surrounding (#1682)
* Clarify some comments * Don't destructure insertion trigger * Make superscript and subscript use domlib/surround * Create new {Text,Highlight}ColorButton * Use domlib/surround for textcolor - However there's still a crucial bug, when you're breaking existing colored span when unsurrounding, their color is not restored * Add underline format to removeFormats * Simplify type of ElementMatcher and ElementClearer for end users * Add some comments for normalize-insertion-ranges * Split normalize-insertion-ranges into remove-adjacent and remove-within * Factor out find-remove from unsurround.ts * Rename merge-mach, simplify remove-within * Clarify some comments * Refactor first reduce * Refactor reduceRight * Flatten functions in merge-ranges * Move some functionality to merge-ranges and do not export * Refactor merge-ranges * Remove createInitialMergeMatch * Finish refactoring of merge-ranges * Refactor merge-ranges to minimal-ranges and add some unit testing * Move more logic into text-node * Remove most most of the logic from remove-adjacent - remove-adjacent is still part of the "merging" logic, as it increases the scope of the child node ranges * Add some tests for edge cases * Merge remove-adjacent logic into minimal-ranges * Refactor unnecessary list destructuring * Add some TODOs * Put removing nodes and adding new nodes into sequence * Refactor MatchResult to MatchType and return clear from matcher * Inline surround/helpers * Shorten name of param * Add another edge case test * Add an example where commonAncestorContainer != normalization level * Fix bug in find-adjacent when find more than one nested nodes * Allow comments for Along type * Simplify find-adjacent by removing intermediate and/or curried functions * Remove extend-adjacent * Add more tests when find-adjacent finds by descension * Fix find-adjacent descending into block-level elements * Add clarifying comment to refusing to descend into block-level elements * Move shifting logic into find-adjacent * Rename file matcher to match-type * Give a first implemention of TreeVertex * Remove MatchType.ALONG - findAdjacent now directly modifies the range * Rename MatchType.MATCH into MatchType.REMOVE * Implement a version of find-within that utilizies match-tree * Turn child node range into a class * Fix bug in new find-adjacent function * Make all find-adjacent tests test for ranges * Surrounding within farthestMatchingAncestor when available * Fix an issue with negligable elements - also rename "along" elements to "negligable" * Add two TODOs to SurroundFormat interface * Have a messy first implementation of the new tree-node algorithm * Maintain whether formatting nodes are covered or within user selection * Move covered and insideRange into TreeNode superclass * Reimplement findAdjacent logic * Add extension logic * Add an evaluate method to nodes * Introduce BlockNode * Add a first evaluate implementation * Add left shift and inner shift logic * Implement SurroundFormatUser * Allow pass in formatter, ascender and merger from outside * Fix insideRange and covered switch-up * Fix MatchNode.prototype.isAscendable * Fix another switch-up of covered and insideRange... * Remove a lot of old code * Have surround functions only return the range - I still cannot think of a good reason why we should return addedNodes and removedNodes, except for testing. * Create formatting-tree directory * Create build-tree directory + Move find-above up to /domlib * Remove range-anchors * Move unsurround logic into no-splitting * Fix extend-merge * Fix inner shift being eroneusly returned as left shift * Fix oversight in SplitRange * Redefine how ranges are recreated * Rename covered to insideMatch and put as fourth parameter instead of third * Keep track of match holes and match leaves * Rename ChildNodeRange to FlatRange * Change signature of matcher * Fix bug in extend-merge * Improve Match class * Utilize cache in TextColorButton * Implement getBaseSurrounder for TextColorButton * Add matchAncestors field to FormattingNode * Introduce matchAncestors and getCache * Do clearing during parsing already - This way, you know whether elements will be removed before getting to Formatting nodes * Make HighlightColorButton use our surround mechanism * Fix a bug with calling .removeAttribute and .hasAttribute * Add side button to RemoveFormat button * Add disabled to remove format side button * Expose remove formats on RemoveFormat button * Reinvent editor/surround as Surrounder class * Fix split-text when working with insert trigger * Try counteracting the contenteditable's auto surrounding * Remove matching elements before normalizing * Rewrite match-type * Move setting match leaves into build * Change editing strings - So that color strings match bold/italic strings better * Fix border radius of List options menu * Implement extensions functionality * Remove some unnecessary code * Fix split range endOffset * Type MatchType * Reformat MatchType + add docs * Fix domlib/surround/apply * Satisfy last tests * Register Surrounder as package * Clarify some comments * Correctly implement reformat * Reformat with inactive eraser formats * Clear empty spans with RemoveFormatButton * Fix Super/Subscript button * Use ftl string for hardcoded tooltip * Adjust wording
This commit is contained in:
parent
eafe426622
commit
8b84368e3a
@ -36,12 +36,13 @@ editing-outdent = Decrease indent
|
||||
editing-paste = Paste
|
||||
editing-record-audio = Record audio
|
||||
editing-remove-formatting = Remove formatting
|
||||
editing-set-text-color = Set text color
|
||||
editing-set-text-highlight-color = Set text highlight color
|
||||
editing-select-remove-formatting = Select formatting to remove
|
||||
editing-show-duplicates = Show Duplicates
|
||||
editing-subscript = Subscript
|
||||
editing-superscript = Superscript
|
||||
editing-tags = Tags
|
||||
editing-text-color = Text color
|
||||
editing-text-highlight-color = Text highlight color
|
||||
editing-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type'
|
||||
editing-toggle-html-editor = Toggle HTML Editor
|
||||
editing-toggle-sticky = Toggle sticky
|
||||
@ -53,3 +54,5 @@ editing-warning-cloze-deletions-will-not-work = Warning, cloze deletions will no
|
||||
## You don't need to translate these strings, as they will be replaced with different ones soon.
|
||||
|
||||
editing-html-editor = HTML Editor
|
||||
editing-set-text-color = Set text color
|
||||
editing-set-text-highlight-color = Set text highlight color
|
||||
|
@ -49,12 +49,12 @@ button {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* We use .focus to recreate the highlight on the good button
|
||||
* while the actual focus is actually in the main webview
|
||||
*/
|
||||
:focus, .focus {
|
||||
:focus,
|
||||
.focus {
|
||||
outline: 1px auto var(--focus-color);
|
||||
|
||||
.nightMode & {
|
||||
|
@ -6,4 +6,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
export let value: boolean;
|
||||
</script>
|
||||
|
||||
<label> <input type="checkbox" bind:checked={value} /> <slot /> </label>
|
||||
<label><input type="checkbox" bind:checked={value} /><slot /></label>
|
||||
|
||||
<style lang="scss">
|
||||
label {
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
@ -28,10 +28,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
class:btn-day={!$pageTheme.isDark}
|
||||
class:btn-night={$pageTheme.isDark}
|
||||
title={tooltip}
|
||||
on:click
|
||||
on:mouseenter
|
||||
on:focus
|
||||
on:keydown
|
||||
on:click
|
||||
on:mousedown|preventDefault
|
||||
>
|
||||
<slot />
|
||||
@ -42,13 +42,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: start;
|
||||
|
||||
font-size: calc(var(--base-font-size) * 0.8);
|
||||
|
||||
background: none;
|
||||
box-shadow: none !important;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
|
@ -37,7 +37,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
updateAllStateWithCallback((): Promise<boolean> => Promise.resolve(state));
|
||||
}
|
||||
|
||||
function updateStateByKey(key: KeyType, event: Event): void {
|
||||
export function updateStateByKey(key: KeyType, event: Event): void {
|
||||
stateStore.update((map: StateMap): StateMap => {
|
||||
map.set(key, updaterMap.get(key)!(event));
|
||||
return map;
|
||||
|
69
ts/domlib/find-above.ts
Normal file
69
ts/domlib/find-above.ts
Normal file
@ -0,0 +1,69 @@
|
||||
// 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";
|
||||
|
||||
export type Matcher = (element: Element) => boolean;
|
||||
|
||||
function findParent(current: Node, base: Element): Element | null {
|
||||
if (current === base) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return current.parentElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to element.closest(), but allows you to pass in a predicate
|
||||
* function, instead of a selector
|
||||
*
|
||||
* @remarks
|
||||
* Unlike element.closest, this will not match against `node`, but will start
|
||||
* at `node.parentElement`.
|
||||
*/
|
||||
export function findClosest(
|
||||
node: Node,
|
||||
base: Element,
|
||||
matcher: Matcher,
|
||||
): Element | null {
|
||||
if (nodeIsElement(node) && matcher(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
let current = findParent(node, base);
|
||||
|
||||
while (current) {
|
||||
if (matcher(current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
current = findParent(current, base);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to `findClosest`, but will go as far as possible.
|
||||
*/
|
||||
export function findFarthest(
|
||||
node: Node,
|
||||
base: Element,
|
||||
matcher: Matcher,
|
||||
): Element | null {
|
||||
let farthest: Element | null = null;
|
||||
let current: Node | null = node;
|
||||
|
||||
while (current) {
|
||||
const next = findClosest(current, base, matcher);
|
||||
|
||||
if (next) {
|
||||
farthest = next;
|
||||
current = findParent(next, base);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return farthest;
|
||||
}
|
@ -6,10 +6,6 @@ import { findNodeFromCoordinates } from "./node";
|
||||
import type { SelectionLocation, SelectionLocationContent } from "./selection";
|
||||
import { getSelectionLocation } from "./selection";
|
||||
|
||||
export function saveSelection(base: Node): SelectionLocation | null {
|
||||
return getSelectionLocation(base);
|
||||
}
|
||||
|
||||
function unselect(selection: Selection): void {
|
||||
selection.empty();
|
||||
}
|
||||
@ -33,6 +29,10 @@ function setSelectionToLocationContent(
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSelection(base: Node): SelectionLocation | null {
|
||||
return getSelectionLocation(base);
|
||||
}
|
||||
|
||||
export function restoreSelection(base: Node, location: SelectionLocation): void {
|
||||
const selection = getSelection(base)!;
|
||||
unselect(selection);
|
||||
|
@ -4,12 +4,22 @@
|
||||
import { registerPackage } from "../../lib/runtime-require";
|
||||
import { restoreSelection, saveSelection } from "./document";
|
||||
import { Position } from "./location";
|
||||
import { findNodeFromCoordinates, getNodeCoordinates } from "./node";
|
||||
import { getRangeCoordinates } from "./range";
|
||||
|
||||
registerPackage("anki/location", {
|
||||
saveSelection,
|
||||
restoreSelection,
|
||||
Position,
|
||||
restoreSelection,
|
||||
saveSelection,
|
||||
});
|
||||
|
||||
export { Position, restoreSelection, saveSelection };
|
||||
export {
|
||||
findNodeFromCoordinates,
|
||||
getNodeCoordinates,
|
||||
getRangeCoordinates,
|
||||
Position,
|
||||
restoreSelection,
|
||||
saveSelection,
|
||||
};
|
||||
export type { RangeCoordinates } from "./range";
|
||||
export type { SelectionLocation } from "./selection";
|
||||
|
@ -12,7 +12,9 @@ export enum Position {
|
||||
After,
|
||||
}
|
||||
|
||||
/* first is positioned {} second */
|
||||
/**
|
||||
* @returns: Whether first is positioned {before,equal to,after} second
|
||||
*/
|
||||
export function compareLocations(
|
||||
first: CaretLocation,
|
||||
second: CaretLocation,
|
||||
|
@ -9,7 +9,7 @@ interface RangeCoordinatesCollapsed {
|
||||
readonly collapsed: true;
|
||||
}
|
||||
|
||||
interface RangeCoordinatesContent {
|
||||
export interface RangeCoordinatesContent {
|
||||
readonly start: CaretLocation;
|
||||
readonly end: CaretLocation;
|
||||
readonly collapsed: false;
|
||||
@ -17,7 +17,7 @@ interface RangeCoordinatesContent {
|
||||
|
||||
export type RangeCoordinates = RangeCoordinatesCollapsed | RangeCoordinatesContent;
|
||||
|
||||
export function getRangeCoordinates(base: Node, range: Range): RangeCoordinates {
|
||||
export function getRangeCoordinates(range: Range, base: Node): RangeCoordinates {
|
||||
const startCoordinates = getNodeCoordinates(base, range.startContainer);
|
||||
const start = { coordinates: startCoordinates, offset: range.startOffset };
|
||||
const collapsed = range.collapsed;
|
||||
|
42
ts/domlib/surround/apply/format.ts
Normal file
42
ts/domlib/surround/apply/format.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { SurroundFormat } from "../surround-format";
|
||||
import type { FormattingNode } from "../tree";
|
||||
|
||||
export class ApplyFormat<T> {
|
||||
constructor(protected readonly format: SurroundFormat<T>) {}
|
||||
|
||||
applyFormat(node: FormattingNode<T>): boolean {
|
||||
if (this.format.surroundElement) {
|
||||
node.range
|
||||
.toDOMRange()
|
||||
.surroundContents(this.format.surroundElement.cloneNode(false));
|
||||
return true;
|
||||
} else if (this.format.formatter) {
|
||||
return this.format.formatter(node);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsurroundApplyFormat<T> extends ApplyFormat<T> {
|
||||
applyFormat(node: FormattingNode<T>): boolean {
|
||||
if (node.insideRange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return super.applyFormat(node);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReformatApplyFormat<T> extends ApplyFormat<T> {
|
||||
applyFormat(node: FormattingNode<T>): boolean {
|
||||
if (!node.hasMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return super.applyFormat(node);
|
||||
}
|
||||
}
|
45
ts/domlib/surround/apply/index.ts
Normal file
45
ts/domlib/surround/apply/index.ts
Normal file
@ -0,0 +1,45 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { TreeNode } from "../tree";
|
||||
import { FormattingNode } from "../tree";
|
||||
import type { ApplyFormat } from "./format";
|
||||
|
||||
function iterate<T>(node: TreeNode, format: ApplyFormat<T>, leftShift: number): number {
|
||||
let innerShift = 0;
|
||||
|
||||
for (const child of node.children) {
|
||||
innerShift += iterate(child, format, innerShift);
|
||||
}
|
||||
|
||||
return node instanceof FormattingNode
|
||||
? applyFormat(node, format, leftShift, innerShift)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Inner shift.
|
||||
*/
|
||||
function applyFormat<T>(
|
||||
node: FormattingNode<T>,
|
||||
format: ApplyFormat<T>,
|
||||
leftShift: number,
|
||||
innerShift: number,
|
||||
): number {
|
||||
node.range.startIndex += leftShift;
|
||||
node.range.endIndex += leftShift + innerShift;
|
||||
|
||||
return format.applyFormat(node)
|
||||
? node.range.startIndex - node.range.endIndex + 1
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function apply<T>(nodes: TreeNode[], format: ApplyFormat<T>): void {
|
||||
let innerShift = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
innerShift += iterate(node, format, innerShift);
|
||||
}
|
||||
}
|
||||
|
||||
export { ApplyFormat, ReformatApplyFormat, UnsurroundApplyFormat } from "./format";
|
@ -1,22 +0,0 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { elementIsBlock } from "../../lib/dom";
|
||||
import { ascend, isOnlyChild } from "../../lib/node";
|
||||
|
||||
export function ascendWhileSingleInline(node: Node, base: Node): Node {
|
||||
if (node === base) {
|
||||
return node;
|
||||
}
|
||||
|
||||
while (
|
||||
isOnlyChild(node) &&
|
||||
node.parentElement &&
|
||||
!elementIsBlock(node.parentElement) &&
|
||||
node.parentElement !== base
|
||||
) {
|
||||
node = ascend(node);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
80
ts/domlib/surround/build/add-merge.ts
Normal file
80
ts/domlib/surround/build/add-merge.ts
Normal file
@ -0,0 +1,80 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { TreeNode } from "../tree";
|
||||
import { FormattingNode } from "../tree";
|
||||
import type { BuildFormat } from "./format";
|
||||
|
||||
function mergeAppendNode<T>(
|
||||
initial: TreeNode[],
|
||||
last: FormattingNode<T>,
|
||||
format: BuildFormat<T>,
|
||||
): TreeNode[] {
|
||||
const minimized: TreeNode[] = [last];
|
||||
|
||||
for (let i = initial.length - 1; i >= 0; i--) {
|
||||
const next = initial[i];
|
||||
|
||||
let merged: FormattingNode<T> | null;
|
||||
if (next instanceof FormattingNode && (merged = format.tryMerge(next, last))) {
|
||||
minimized[0] = merged;
|
||||
} else {
|
||||
minimized.unshift(...initial.slice(0, i + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return minimized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to merge `last`, into the end of `initial`.
|
||||
*/
|
||||
export function appendNode<T>(
|
||||
initial: TreeNode[],
|
||||
last: TreeNode,
|
||||
format: BuildFormat<T>,
|
||||
): TreeNode[] {
|
||||
if (last instanceof FormattingNode) {
|
||||
return mergeAppendNode(initial, last, format);
|
||||
} else {
|
||||
return [...initial, last];
|
||||
}
|
||||
}
|
||||
|
||||
function mergeInsertNode<T>(
|
||||
first: FormattingNode<T>,
|
||||
tail: TreeNode[],
|
||||
format: BuildFormat<T>,
|
||||
): TreeNode[] {
|
||||
const minimized: TreeNode[] = [first];
|
||||
|
||||
for (let i = 0; i <= tail.length; i++) {
|
||||
const next = tail[i];
|
||||
|
||||
let merged: FormattingNode<T> | null;
|
||||
if (next instanceof FormattingNode && (merged = format.tryMerge(first, next))) {
|
||||
minimized[0] = merged;
|
||||
} else {
|
||||
minimized.push(...tail.slice(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return minimized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to merge `first`, into the start of `tail`.
|
||||
*/
|
||||
export function insertNode<T>(
|
||||
first: TreeNode,
|
||||
tail: TreeNode[],
|
||||
format: BuildFormat<T>,
|
||||
): TreeNode[] {
|
||||
if (first instanceof FormattingNode) {
|
||||
return mergeInsertNode(first, tail, format);
|
||||
} else {
|
||||
return [first, ...tail];
|
||||
}
|
||||
}
|
116
ts/domlib/surround/build/build-tree.ts
Normal file
116
ts/domlib/surround/build/build-tree.ts
Normal file
@ -0,0 +1,116 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { elementIsEmpty, nodeIsElement, nodeIsText } from "../../../lib/dom";
|
||||
import type { Match } from "../match-type";
|
||||
import type { TreeNode } from "../tree";
|
||||
import { BlockNode, ElementNode, FormattingNode } from "../tree";
|
||||
import { appendNode } from "./add-merge";
|
||||
import type { BuildFormat } from "./format";
|
||||
|
||||
function buildFromElement<T>(
|
||||
element: Element,
|
||||
format: BuildFormat<T>,
|
||||
matchAncestors: Match<T>[],
|
||||
): TreeNode[] {
|
||||
const match = format.createMatch(element);
|
||||
|
||||
if (match.matches) {
|
||||
matchAncestors = [...matchAncestors, match];
|
||||
}
|
||||
|
||||
let children: TreeNode[] = [];
|
||||
for (const child of [...element.childNodes]) {
|
||||
const nodes = buildFromNode(child, format, matchAncestors);
|
||||
|
||||
for (const node of nodes) {
|
||||
children = appendNode(children, node, format);
|
||||
}
|
||||
}
|
||||
|
||||
if (match.shouldRemove()) {
|
||||
const parent = element.parentElement!;
|
||||
const childIndex = Array.prototype.indexOf.call(parent.childNodes, element);
|
||||
|
||||
for (const child of children) {
|
||||
if (child instanceof FormattingNode) {
|
||||
if (child.hasMatchHoles) {
|
||||
child.matchLeaves.push(match);
|
||||
child.hasMatchHoles = false;
|
||||
}
|
||||
|
||||
child.range.parent = parent;
|
||||
child.range.startIndex += childIndex;
|
||||
child.range.endIndex += childIndex;
|
||||
}
|
||||
}
|
||||
|
||||
element.replaceWith(...element.childNodes);
|
||||
return children;
|
||||
}
|
||||
|
||||
const matchNode = ElementNode.make(
|
||||
element,
|
||||
children.every((node: TreeNode): boolean => node.insideRange),
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
// This means there are no non-negligible children
|
||||
return [];
|
||||
} else if (children.length === 1) {
|
||||
const [only] = children;
|
||||
|
||||
if (
|
||||
// blocking
|
||||
only instanceof BlockNode ||
|
||||
// ascension
|
||||
(only instanceof FormattingNode && format.tryAscend(only, matchNode))
|
||||
) {
|
||||
return [only];
|
||||
}
|
||||
}
|
||||
|
||||
matchNode.replaceChildren(...children);
|
||||
return [matchNode];
|
||||
}
|
||||
|
||||
function buildFromText<T>(
|
||||
text: Text,
|
||||
format: BuildFormat<T>,
|
||||
matchAncestors: Match<T>[],
|
||||
): FormattingNode<T> | BlockNode {
|
||||
const insideRange = format.isInsideRange(text);
|
||||
|
||||
if (!insideRange && matchAncestors.length === 0) {
|
||||
return BlockNode.make();
|
||||
}
|
||||
|
||||
return FormattingNode.fromText(text, insideRange, matchAncestors);
|
||||
}
|
||||
|
||||
function elementIsNegligible(element: Element): boolean {
|
||||
return elementIsEmpty(element);
|
||||
}
|
||||
|
||||
function textIsNegligible(text: Text): boolean {
|
||||
return text.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a formatting tree starting at node.
|
||||
*
|
||||
* @returns root of the formatting tree
|
||||
*/
|
||||
export function buildFromNode<T>(
|
||||
node: Node,
|
||||
format: BuildFormat<T>,
|
||||
matchAncestors: Match<T>[],
|
||||
): TreeNode[] {
|
||||
if (nodeIsText(node) && !textIsNegligible(node)) {
|
||||
return [buildFromText(node, format, matchAncestors)];
|
||||
} else if (nodeIsElement(node) && !elementIsNegligible(node)) {
|
||||
return buildFromElement(node, format, matchAncestors);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
76
ts/domlib/surround/build/extend-merge.ts
Normal file
76
ts/domlib/surround/build/extend-merge.ts
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
|
||||
|
||||
import type { BuildFormat } from "../build";
|
||||
import type { TreeNode } from "../tree";
|
||||
import { FormattingNode } from "../tree";
|
||||
import { appendNode, insertNode } from "./add-merge";
|
||||
import { buildFromNode } from "./build-tree";
|
||||
|
||||
function mergePreviousTrees<T>(forest: TreeNode[], format: BuildFormat<T>): TreeNode[] {
|
||||
const [first, ...tail] = forest;
|
||||
|
||||
if (!(first instanceof FormattingNode)) {
|
||||
return forest;
|
||||
}
|
||||
|
||||
let merged: TreeNode[] = [first];
|
||||
let sibling = first.range.firstChild.previousSibling;
|
||||
|
||||
while (sibling && merged.length === 1) {
|
||||
const nodes = buildFromNode(sibling, format, []);
|
||||
|
||||
for (const node of nodes) {
|
||||
merged = insertNode(node, merged, format);
|
||||
}
|
||||
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
|
||||
return [...merged, ...tail];
|
||||
}
|
||||
|
||||
function mergeNextTrees<T>(forest: TreeNode[], format: BuildFormat<T>): TreeNode[] {
|
||||
const initial = forest.slice(0, -1);
|
||||
const last = forest[forest.length - 1];
|
||||
|
||||
if (!(last instanceof FormattingNode)) {
|
||||
return forest;
|
||||
}
|
||||
|
||||
let merged: TreeNode[] = [last];
|
||||
let sibling = last.range.lastChild.nextSibling;
|
||||
|
||||
while (sibling && merged.length === 1) {
|
||||
const nodes = buildFromNode(sibling, format, []);
|
||||
|
||||
for (const node of nodes) {
|
||||
merged = appendNode(merged, node, format);
|
||||
}
|
||||
|
||||
sibling = sibling.nextSibling;
|
||||
}
|
||||
|
||||
return [...initial, ...merged];
|
||||
}
|
||||
|
||||
export function extendAndMerge<T>(
|
||||
forest: TreeNode[],
|
||||
format: BuildFormat<T>,
|
||||
): TreeNode[] {
|
||||
const merged = mergeNextTrees(mergePreviousTrees(forest, format), format);
|
||||
|
||||
if (merged.length === 1) {
|
||||
const [only] = merged;
|
||||
|
||||
if (only instanceof FormattingNode) {
|
||||
const elementNode = only.getExtension();
|
||||
|
||||
if (elementNode && format.tryAscend(only, elementNode)) {
|
||||
return extendAndMerge(merged, format);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
96
ts/domlib/surround/build/format.ts
Normal file
96
ts/domlib/surround/build/format.ts
Normal file
@ -0,0 +1,96 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { elementIsBlock } from "../../../lib/dom";
|
||||
import { Position } from "../../location";
|
||||
import { Match } from "../match-type";
|
||||
import type { SplitRange } from "../split-text";
|
||||
import type { SurroundFormat } from "../surround-format";
|
||||
import { ElementNode, FormattingNode } from "../tree";
|
||||
|
||||
function nodeWithinRange(node: Node, range: Range): boolean {
|
||||
const nodeRange = new Range();
|
||||
nodeRange.selectNodeContents(node);
|
||||
|
||||
return (
|
||||
range.compareBoundaryPoints(Range.START_TO_START, nodeRange) !==
|
||||
Position.After &&
|
||||
range.compareBoundaryPoints(Range.END_TO_END, nodeRange) !== Position.Before
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes user-provided functions as input, to modify certain parts of the algorithm.
|
||||
*/
|
||||
export class BuildFormat<T> {
|
||||
constructor(
|
||||
public readonly format: SurroundFormat<T>,
|
||||
public readonly base: Element,
|
||||
public readonly range: Range,
|
||||
public readonly splitRange: SplitRange,
|
||||
) {}
|
||||
|
||||
createMatch(element: Element): Match<T> {
|
||||
const match = new Match<T>();
|
||||
this.format.matcher(element as HTMLElement | SVGElement, match);
|
||||
return match;
|
||||
}
|
||||
|
||||
tryMerge(
|
||||
before: FormattingNode<T>,
|
||||
after: FormattingNode<T>,
|
||||
): FormattingNode<T> | null {
|
||||
if (!this.format.merger || this.format.merger(before, after)) {
|
||||
return FormattingNode.merge(before, after);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
tryAscend(node: FormattingNode<T>, elementNode: ElementNode): boolean {
|
||||
if (!elementIsBlock(elementNode.element) && elementNode.element !== this.base) {
|
||||
node.ascendAbove(elementNode);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
isInsideRange(node: Node): boolean {
|
||||
return nodeWithinRange(node, this.range);
|
||||
}
|
||||
|
||||
announceElementRemoval(element: Element): void {
|
||||
this.splitRange.adjustRange(element);
|
||||
}
|
||||
|
||||
recreateRange(): Range {
|
||||
return this.splitRange.toDOMRange();
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsurroundBuildFormat<T> extends BuildFormat<T> {
|
||||
tryMerge(
|
||||
before: FormattingNode<T>,
|
||||
after: FormattingNode<T>,
|
||||
): FormattingNode<T> | null {
|
||||
if (before.insideRange !== after.insideRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return super.tryMerge(before, after);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReformatBuildFormat<T> extends BuildFormat<T> {
|
||||
tryMerge(
|
||||
before: FormattingNode<T>,
|
||||
after: FormattingNode<T>,
|
||||
): FormattingNode<T> | null {
|
||||
if (before.hasMatch !== after.hasMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return super.tryMerge(before, after);
|
||||
}
|
||||
}
|
22
ts/domlib/surround/build/index.ts
Normal file
22
ts/domlib/surround/build/index.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 type { TreeNode } from "../tree";
|
||||
import { buildFromNode } from "./build-tree";
|
||||
import { extendAndMerge } from "./extend-merge";
|
||||
import type { BuildFormat } from "./format";
|
||||
|
||||
/**
|
||||
* Builds a TreeNode forest structure from an input node.
|
||||
*
|
||||
* @remarks
|
||||
* This will remove matching elements from the DOM. This is necessary to make
|
||||
* some normalizations.
|
||||
*
|
||||
* @param node: This node should have no matching ancestors.
|
||||
*/
|
||||
export function build<T>(node: Node, build: BuildFormat<T>): TreeNode[] {
|
||||
return extendAndMerge(buildFromNode(node, build, []), build);
|
||||
}
|
||||
|
||||
export { BuildFormat, ReformatBuildFormat, UnsurroundBuildFormat } from "./format";
|
@ -1,111 +0,0 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { elementIsEmpty, nodeIsElement } from "../../lib/dom";
|
||||
import { ascend } from "../../lib/node";
|
||||
|
||||
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);
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
// 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, FoundMatch } 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 === 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 === base || !current.parentElement ? null : current.parentElement;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { nodeToChildNodeRange } from "./child-node-range";
|
||||
import { findAfter, findBefore } from "./find-adjacent";
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,98 +0,0 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { elementIsEmpty, nodeIsElement, nodeIsText } from "../../lib/dom";
|
||||
import { hasOnlyChild } from "../../lib/node";
|
||||
import type { ChildNodeRange } from "./child-node-range";
|
||||
import type { ElementMatcher, FoundAdjacent, FoundAlong } from "./matcher";
|
||||
import { MatchResult } 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: FoundAdjacent[],
|
||||
matcher: ElementMatcher,
|
||||
): void {
|
||||
let current = getter(node);
|
||||
|
||||
const maybeAlong: (Element | Text)[] = [];
|
||||
while (
|
||||
current &&
|
||||
((nodeIsElement(current) && elementIsEmpty(current)) ||
|
||||
(nodeIsText(current) && current.length === 0))
|
||||
) {
|
||||
maybeAlong.push(current);
|
||||
current = getter(current);
|
||||
}
|
||||
|
||||
while (current && nodeIsElement(current)) {
|
||||
const element: Element = current;
|
||||
const matchResult = matcher(element);
|
||||
|
||||
if (matchResult) {
|
||||
matches.push(
|
||||
...maybeAlong.map(
|
||||
(along: Element | Text): FoundAlong => ({
|
||||
element: along,
|
||||
matchType: MatchResult.ALONG,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
matches.push({
|
||||
element,
|
||||
matchType: matchResult,
|
||||
});
|
||||
|
||||
return findAdjacentNodeInner(element, matches, matcher);
|
||||
}
|
||||
|
||||
// descend down into element
|
||||
current =
|
||||
hasOnlyChild(current) && nodeIsElement(element.firstChild!)
|
||||
? element.firstChild
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
return findAdjacentNodeInner;
|
||||
}
|
||||
|
||||
const findBeforeNodeInner = adjacentNodeInner(
|
||||
(node: Node): ChildNode | null => node.previousSibling,
|
||||
);
|
||||
|
||||
function findBeforeNode(node: Node, matcher: ElementMatcher): FoundAdjacent[] {
|
||||
const matches: FoundAdjacent[] = [];
|
||||
findBeforeNodeInner(node, matches, matcher);
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function findBefore(
|
||||
childNodeRange: ChildNodeRange,
|
||||
matcher: ElementMatcher,
|
||||
): FoundAdjacent[] {
|
||||
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): FoundAdjacent[] {
|
||||
const matches: FoundAdjacent[] = [];
|
||||
findAfterNodeInner(node, matches, matcher);
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function findAfter(
|
||||
childNodeRange: ChildNodeRange,
|
||||
matcher: ElementMatcher,
|
||||
): FoundAdjacent[] {
|
||||
const { parent, endIndex } = childNodeRange;
|
||||
return findAfterNode(parent.childNodes[endIndex - 1], matcher);
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
// 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 { ChildNodeRange } from "./child-node-range";
|
||||
import type { ElementMatcher, FoundMatch } from "./matcher";
|
||||
import { nodeWithinRange } from "./within-range";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
121
ts/domlib/surround/flat-range.ts
Normal file
121
ts/domlib/surround/flat-range.ts
Normal file
@ -0,0 +1,121 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { nodeIsComment, nodeIsElement, nodeIsText } from "../../lib/dom";
|
||||
import { ascend } from "../../lib/node";
|
||||
|
||||
/**
|
||||
* Represents a subset of DOM ranges which can be called with `.surroundContents()`.
|
||||
*/
|
||||
export class FlatRange {
|
||||
private constructor(
|
||||
public parent: Node,
|
||||
public startIndex: number,
|
||||
public endIndex: number,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The new flat range does not represent the range itself but
|
||||
* rather a possible new node that surrounds the boundary points
|
||||
* (node, start) till (node, end).
|
||||
*
|
||||
* @remarks
|
||||
* Indices should be >= 0 and startIndex <= endIndex.
|
||||
*/
|
||||
static make(node: Node, startIndex: number, endIndex = startIndex + 1): FlatRange {
|
||||
return new FlatRange(node, startIndex, endIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @remarks
|
||||
* Must be sibling flat ranges.
|
||||
*/
|
||||
static merge(before: FlatRange, after: FlatRange): FlatRange {
|
||||
return FlatRange.make(before.parent, before.startIndex, after.endIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @remarks
|
||||
*/
|
||||
static fromNode(node: Node): FlatRange {
|
||||
const parent = ascend(node);
|
||||
const index = Array.prototype.indexOf.call(parent.childNodes, node);
|
||||
|
||||
return FlatRange.make(parent, index);
|
||||
}
|
||||
|
||||
get firstChild(): ChildNode {
|
||||
return this.parent.childNodes[this.startIndex];
|
||||
}
|
||||
|
||||
get lastChild(): ChildNode {
|
||||
return this.parent.childNodes[this.endIndex - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @see `fromNode`
|
||||
*/
|
||||
select(node: Node): void {
|
||||
this.parent = ascend(node);
|
||||
this.startIndex = Array.prototype.indexOf.call(this.parent.childNodes, node);
|
||||
this.endIndex = this.startIndex + 1;
|
||||
}
|
||||
|
||||
toDOMRange(): Range {
|
||||
const range = new Range();
|
||||
range.setStart(this.parent, this.startIndex);
|
||||
range.setEnd(this.parent, this.endIndex);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<ChildNode, null, unknown> {
|
||||
const parent = this.parent;
|
||||
const end = this.endIndex;
|
||||
let step = this.startIndex;
|
||||
|
||||
return {
|
||||
next(): IteratorResult<ChildNode, null> {
|
||||
if (step >= end) {
|
||||
return { value: null, done: true };
|
||||
}
|
||||
|
||||
return { value: parent.childNodes[step++], done: false };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Amount of contained nodes
|
||||
*/
|
||||
get length(): number {
|
||||
return this.endIndex - this.startIndex;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
let output = "";
|
||||
|
||||
for (const node of [...this]) {
|
||||
if (nodeIsText(node)) {
|
||||
output += node.data;
|
||||
} else if (nodeIsComment(node)) {
|
||||
output += `<!--${node.data}-->`;
|
||||
} else if (nodeIsElement(node)) {
|
||||
output += node.outerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
@ -1,19 +1,8 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { registerPackage } from "../../lib/runtime-require";
|
||||
import { findClosest } from "./find-above";
|
||||
import { MatchResult, matchTagName } from "./matcher";
|
||||
import { surroundNoSplitting } from "./no-splitting";
|
||||
import { unsurround } from "./unsurround";
|
||||
|
||||
registerPackage("anki/surround", {
|
||||
surroundNoSplitting,
|
||||
unsurround,
|
||||
findClosest,
|
||||
MatchResult,
|
||||
matchTagName,
|
||||
});
|
||||
|
||||
export { findClosest, MatchResult, matchTagName, surroundNoSplitting, unsurround };
|
||||
export type { ElementClearer, ElementMatcher } from "./matcher";
|
||||
export type { MatchType } from "./match-type";
|
||||
export { boolMatcher } from "./match-type";
|
||||
export { reformat, surround, unsurround } from "./surround";
|
||||
export type { SurroundFormat } from "./surround-format";
|
||||
export type { FormattingNode } from "./tree";
|
||||
|
91
ts/domlib/surround/match-type.ts
Normal file
91
ts/domlib/surround/match-type.ts
Normal file
@ -0,0 +1,91 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { SurroundFormat } from "./surround-format";
|
||||
|
||||
export interface MatchType<T = never> {
|
||||
/**
|
||||
* The element represented by the match will be removed from the document.
|
||||
*/
|
||||
remove(): void;
|
||||
/**
|
||||
* If the element has some styling applied that matches the format, but
|
||||
* might contain some styling above that, you should use clear and do the
|
||||
* modifying in the callback.
|
||||
*
|
||||
* @remarks
|
||||
* You can still call `match.remove()` in the callback
|
||||
*
|
||||
* @example
|
||||
* If you want to match bold elements, `<span class="myclass" style="font-weight:bold"/>
|
||||
* should match via `clear`, but should not be removed, because it still
|
||||
* has a class applied, even if the `style` attribute is removed.
|
||||
*/
|
||||
clear(callback: () => void): void;
|
||||
/**
|
||||
* Used to sustain a value that is needed to recreate the surrounding.
|
||||
* Can be retrieved from the FormattingNode interface via `.getCache`.
|
||||
*/
|
||||
setCache(value: T): void;
|
||||
}
|
||||
|
||||
type Callback = () => void;
|
||||
|
||||
export class Match<T> implements MatchType<T> {
|
||||
private _shouldRemove = false;
|
||||
remove(): void {
|
||||
this._shouldRemove = true;
|
||||
}
|
||||
|
||||
private _callback: Callback | null = null;
|
||||
clear(callback: Callback): void {
|
||||
this._callback = callback;
|
||||
}
|
||||
|
||||
get matches(): boolean {
|
||||
return Boolean(this._callback) || this._shouldRemove;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
shouldRemove(): boolean {
|
||||
this._callback?.();
|
||||
this._callback = null;
|
||||
return this._shouldRemove;
|
||||
}
|
||||
|
||||
cache: T | null = null;
|
||||
setCache(value: T): void {
|
||||
this.cache = value;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMatch implements MatchType<never> {
|
||||
public value = false;
|
||||
|
||||
remove(): void {
|
||||
this.value = true;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.value = true;
|
||||
}
|
||||
|
||||
setCache(): void {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the format.matcher into a function that can be used with `findAbove`.
|
||||
*/
|
||||
export function boolMatcher<T>(
|
||||
format: SurroundFormat<T>,
|
||||
): (element: Element) => boolean {
|
||||
return function (element: Element): boolean {
|
||||
const fake = new FakeMatch();
|
||||
format.matcher(element as HTMLElement | SVGElement, fake);
|
||||
return fake.value;
|
||||
};
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
// 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,
|
||||
/* Element (or Text) is situated adjacent to a match */
|
||||
ALONG,
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be pure
|
||||
*/
|
||||
export type ElementMatcher = (
|
||||
element: Element,
|
||||
) => Exclude<MatchResult, MatchResult.ALONG>;
|
||||
|
||||
/**
|
||||
* Is applied to values that match with KEEP
|
||||
* Should be idempotent
|
||||
*/
|
||||
export type ElementClearer = (element: Element) => boolean;
|
||||
|
||||
export const matchTagName =
|
||||
(tagName: string): ElementMatcher =>
|
||||
(element: Element) => {
|
||||
return element.matches(tagName) ? MatchResult.MATCH : MatchResult.NO_MATCH;
|
||||
};
|
||||
|
||||
export interface FoundMatch {
|
||||
element: Element;
|
||||
matchType: Exclude<MatchResult, MatchResult.NO_MATCH | MatchResult.ALONG>;
|
||||
}
|
||||
|
||||
export interface FoundAlong {
|
||||
element: Element | Text;
|
||||
matchType: MatchResult.ALONG;
|
||||
}
|
||||
|
||||
export type FoundAdjacent = FoundMatch | FoundAlong;
|
@ -1,101 +0,0 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { ascendWhileSingleInline } from "./ascend";
|
||||
import type { ChildNodeRange } from "./child-node-range";
|
||||
import {
|
||||
areSiblingChildNodeRanges,
|
||||
coversWholeParent,
|
||||
mergeChildNodeRanges,
|
||||
nodeToChildNodeRange,
|
||||
} from "./child-node-range";
|
||||
|
||||
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 === base */
|
||||
) {
|
||||
const mergedChildNodeRange = mergeChildNodeRanges(
|
||||
childNodeRange,
|
||||
nextChildNodeRange,
|
||||
);
|
||||
|
||||
const newChildNodeRange =
|
||||
coversWholeParent(mergedChildNodeRange) &&
|
||||
mergedChildNodeRange.parent !== 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), []);
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { ascendWhileSingleInline } from "./ascend";
|
||||
import {
|
||||
nodeToChildNodeRange,
|
||||
surroundChildNodeRangeWithNode,
|
||||
} from "./child-node-range";
|
||||
import type { ElementClearer, ElementMatcher } from "./matcher";
|
||||
import { matchTagName } from "./matcher";
|
||||
import { mergeMatchChildNodeRanges } from "./merge-match";
|
||||
import { normalizeInsertionRanges } from "./normalize-insertion-ranges";
|
||||
import { getRangeAnchors } from "./range-anchors";
|
||||
import { findTextNodesWithin } from "./text-node";
|
||||
import { nodeWithinRange } from "./within-range";
|
||||
|
||||
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 };
|
||||
}
|
@ -1,182 +0,0 @@
|
||||
// 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 { findAfter, findBefore } from "./find-adjacent";
|
||||
import { findWithin, findWithinNode } from "./find-within";
|
||||
import type {
|
||||
ElementClearer,
|
||||
ElementMatcher,
|
||||
FoundAdjacent,
|
||||
FoundMatch,
|
||||
} from "./matcher";
|
||||
import { MatchResult } from "./matcher";
|
||||
|
||||
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 = ({ element, matchType }: FoundMatch) =>
|
||||
matchType === MatchResult.MATCH ?? clearer(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: FoundAdjacent[],
|
||||
parent: Node,
|
||||
removedNodes: Element[],
|
||||
matcher: ElementMatcher,
|
||||
clearer: ElementClearer,
|
||||
): number {
|
||||
let childCount = 0;
|
||||
let keepChildCount = 0;
|
||||
|
||||
for (const { element, matchType } of matches) {
|
||||
switch (matchType) {
|
||||
case MatchResult.MATCH:
|
||||
childCount += normalizeWithinInner(
|
||||
element as Element,
|
||||
parent,
|
||||
removedNodes,
|
||||
matcher,
|
||||
clearer,
|
||||
);
|
||||
|
||||
removedNodes.push(element as Element);
|
||||
element.replaceWith(...element.childNodes);
|
||||
break;
|
||||
|
||||
case MatchResult.KEEP:
|
||||
keepChildCount = normalizeWithinInner(
|
||||
element as Element,
|
||||
parent,
|
||||
removedNodes,
|
||||
matcher,
|
||||
clearer,
|
||||
);
|
||||
|
||||
if (clearer(element as Element)) {
|
||||
removedNodes.push(element as Element);
|
||||
element.replaceWith(...element.childNodes);
|
||||
childCount += keepChildCount;
|
||||
} else {
|
||||
childCount++;
|
||||
}
|
||||
break;
|
||||
|
||||
case MatchResult.ALONG:
|
||||
childCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return childCount;
|
||||
}
|
||||
|
||||
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 = findBefore(normalizedRange, matcher);
|
||||
const count = normalizeAdjacent(
|
||||
matches,
|
||||
parent,
|
||||
removedNodes,
|
||||
matcher,
|
||||
clearer,
|
||||
);
|
||||
normalizedRange.startIndex -= matches.length;
|
||||
normalizedRange.endIndex += count - matches.length;
|
||||
}
|
||||
|
||||
const matches = findWithin(normalizedRange, matcher);
|
||||
const withinShift = normalizeWithin(matches, parent, removedNodes, clearer);
|
||||
normalizedRange.endIndex += withinShift;
|
||||
|
||||
if (index === insertionRanges.length - 1) {
|
||||
const matches = findAfter(normalizedRange, matcher);
|
||||
const count = normalizeAdjacent(
|
||||
matches,
|
||||
parent,
|
||||
removedNodes,
|
||||
matcher,
|
||||
clearer,
|
||||
);
|
||||
normalizedRange.endIndex += count;
|
||||
}
|
||||
|
||||
normalizedRanges.push(normalizedRange);
|
||||
}
|
||||
|
||||
return {
|
||||
normalizedRanges,
|
||||
removedNodes,
|
||||
};
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
// 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),
|
||||
),
|
||||
};
|
||||
}
|
94
ts/domlib/surround/split-text.ts
Normal file
94
ts/domlib/surround/split-text.ts
Normal file
@ -0,0 +1,94 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { nodeIsText } from "../../lib/dom";
|
||||
|
||||
/**
|
||||
* @link https://dom.spec.whatwg.org/#concept-node-length
|
||||
*/
|
||||
function length(node: Node): number {
|
||||
if (node instanceof CharacterData) {
|
||||
return node.length;
|
||||
} else if (
|
||||
node.nodeType === Node.DOCUMENT_TYPE_NODE ||
|
||||
node.nodeType === Node.ATTRIBUTE_NODE
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return node.childNodes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around DOM ranges that are passed into evaluation and are adjusted,
|
||||
* if its start or end nodes are to be removed
|
||||
*/
|
||||
export class SplitRange {
|
||||
constructor(protected start: Node, protected end: Node) {}
|
||||
|
||||
private adjustStart(): void {
|
||||
if (this.start.firstChild) {
|
||||
this.start = this.start.firstChild;
|
||||
} else if (this.start.nextSibling) {
|
||||
this.start = this.start.nextSibling!;
|
||||
}
|
||||
}
|
||||
|
||||
private adjustEnd(): void {
|
||||
if (this.end.lastChild) {
|
||||
this.end = this.end.lastChild!;
|
||||
} else if (this.end.previousSibling) {
|
||||
this.end = this.end.previousSibling;
|
||||
}
|
||||
}
|
||||
|
||||
adjustRange(element: Element): void {
|
||||
if (this.start === element) {
|
||||
this.adjustStart();
|
||||
} else if (this.end === element) {
|
||||
this.adjustEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a range with boundary points `(start, 0)` and `(end, end.length)`.
|
||||
*/
|
||||
toDOMRange(): Range {
|
||||
const range = new Range();
|
||||
range.setStart(this.start, 0);
|
||||
range.setEnd(this.end, length(this.end));
|
||||
|
||||
return range;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Split text node to end direction or text itself if a split is
|
||||
* not necessary
|
||||
*/
|
||||
function splitTextIfNecessary(text: Text, offset: number): Text {
|
||||
if (offset === 0 || offset === text.length) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return text.splitText(offset);
|
||||
}
|
||||
|
||||
export function splitPartiallySelected(range: Range): SplitRange {
|
||||
let start: Node;
|
||||
if (nodeIsText(range.startContainer)) {
|
||||
start = splitTextIfNecessary(range.startContainer, range.startOffset);
|
||||
} else {
|
||||
start = range.startContainer.childNodes[range.startOffset];
|
||||
}
|
||||
|
||||
let end: Node;
|
||||
if (nodeIsText(range.endContainer)) {
|
||||
end = range.endContainer;
|
||||
splitTextIfNecessary(range.endContainer, range.endOffset);
|
||||
} else {
|
||||
end = range.endContainer.childNodes[range.endOffset - 1];
|
||||
}
|
||||
|
||||
return new SplitRange(start, end);
|
||||
}
|
29
ts/domlib/surround/surround-format.ts
Normal file
29
ts/domlib/surround/surround-format.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { MatchType } from "./match-type";
|
||||
import type { FormattingNode } from "./tree";
|
||||
|
||||
export interface SurroundFormat<T = never> {
|
||||
/**
|
||||
* Determine whether element matches the format. Confirm by calling
|
||||
* `match.remove` or `match.clear`. Sustain parameters provided to the format
|
||||
* by calling `match.setCache`.
|
||||
*/
|
||||
matcher: (element: HTMLElement | SVGElement, match: MatchType<T>) => void;
|
||||
/**
|
||||
* @returns Whehter before or after are allowed to merge to a single
|
||||
* FormattingNode range
|
||||
*/
|
||||
merger?: (before: FormattingNode<T>, after: FormattingNode<T>) => boolean;
|
||||
/**
|
||||
* Apply according to this formatter.
|
||||
*
|
||||
* @returns Whether formatter added a new element around the range.
|
||||
*/
|
||||
formatter?: (node: FormattingNode<T>) => boolean;
|
||||
/**
|
||||
* Surround with this node as formatting. Shorthand alternative to `formatter`.
|
||||
*/
|
||||
surroundElement?: Element;
|
||||
}
|
@ -1,14 +1,8 @@
|
||||
// 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;
|
||||
}
|
||||
import { surround } from "./surround";
|
||||
import { easyBold, easyUnderline, p } from "./test-utils";
|
||||
|
||||
describe("surround text", () => {
|
||||
let body: HTMLBodyElement;
|
||||
@ -21,14 +15,8 @@ describe("surround text", () => {
|
||||
const range = new Range();
|
||||
range.selectNode(body.firstChild!);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
const surroundedRange = surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(0);
|
||||
expect(body).toHaveProperty("innerHTML", "<b>111222</b>");
|
||||
expect(surroundedRange.toString()).toEqual("111222");
|
||||
});
|
||||
@ -38,14 +26,8 @@ describe("surround text", () => {
|
||||
range.setStart(body.firstChild!, 0);
|
||||
range.setEnd(body.firstChild!, 3);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
const surroundedRange = surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(0);
|
||||
expect(body).toHaveProperty("innerHTML", "<b>111</b>222");
|
||||
expect(surroundedRange.toString()).toEqual("111");
|
||||
});
|
||||
@ -55,14 +37,8 @@ describe("surround text", () => {
|
||||
range.setStart(body.firstChild!, 3);
|
||||
range.setEnd(body.firstChild!, 6);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
const surroundedRange = surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(0);
|
||||
expect(body).toHaveProperty("innerHTML", "111<b>222</b>");
|
||||
expect(surroundedRange.toString()).toEqual("222");
|
||||
});
|
||||
@ -79,31 +55,19 @@ describe("surround text next to nested", () => {
|
||||
test("enlarges bottom tag of nested", () => {
|
||||
const range = new Range();
|
||||
range.selectNode(body.firstChild!);
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("u"),
|
||||
body,
|
||||
);
|
||||
surround(range, body, easyUnderline);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty("innerHTML", "<u>before<b>after</b></u>");
|
||||
expect(surroundedRange.toString()).toEqual("before");
|
||||
// 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,
|
||||
);
|
||||
surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty("innerHTML", "<b>before<u>after</u></b>");
|
||||
expect(surroundedRange.toString()).toEqual("before");
|
||||
// expect(surroundedRange.toString()).toEqual("before");
|
||||
});
|
||||
});
|
||||
|
||||
@ -117,31 +81,36 @@ describe("surround text next to nested", () => {
|
||||
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,
|
||||
);
|
||||
surround(range, body, easyUnderline);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty("innerHTML", "<u><b>before</b>after</u>");
|
||||
expect(surroundedRange.toString()).toEqual("after");
|
||||
// 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,
|
||||
);
|
||||
surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty("innerHTML", "<b><u>before</u>after</b>");
|
||||
expect(surroundedRange.toString()).toEqual("after");
|
||||
// expect(surroundedRange.toString()).toEqual("after");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two nested", () => {
|
||||
let body: HTMLBodyElement;
|
||||
|
||||
beforeEach(() => {
|
||||
body = p("aaa<i><b>bbb</b></i><i><b>ccc</b></i>");
|
||||
});
|
||||
|
||||
test("extends to both", () => {
|
||||
const range = new Range();
|
||||
range.selectNode(body.firstChild!);
|
||||
surround(range, body, easyBold);
|
||||
|
||||
expect(body).toHaveProperty("innerHTML", "<b>aaa<i>bbb</i><i>ccc</i></b>");
|
||||
// expect(surroundedRange.toString()).toEqual("aaa");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -157,14 +126,8 @@ describe("surround across block element", () => {
|
||||
const range = new Range();
|
||||
range.setStartBefore(body.firstChild!);
|
||||
range.setEndAfter(body.lastChild!);
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
const surroundedRange = surround(range, body, easyBold);
|
||||
|
||||
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>",
|
||||
@ -183,14 +146,8 @@ describe("next to nested", () => {
|
||||
test("surround after", () => {
|
||||
const range = new Range();
|
||||
range.selectNode(body.lastChild!);
|
||||
const { addedNodes, removedNodes } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(3);
|
||||
expect(body).toHaveProperty("innerHTML", "111<b>222333444555</b>");
|
||||
// expect(surroundedRange.toString()).toEqual("555");
|
||||
});
|
||||
@ -206,14 +163,8 @@ describe("next to element with nested non-matching", () => {
|
||||
test("surround after", () => {
|
||||
const range = new Range();
|
||||
range.selectNode(body.lastChild!);
|
||||
const { addedNodes, removedNodes } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty(
|
||||
"innerHTML",
|
||||
"111<b>222<i>333<i>444</i></i>555</b>",
|
||||
@ -232,14 +183,8 @@ describe("next to element with text element text", () => {
|
||||
test("surround after", () => {
|
||||
const range = new Range();
|
||||
range.selectNode(body.lastChild!);
|
||||
const { addedNodes, removedNodes } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(2);
|
||||
expect(body).toHaveProperty("innerHTML", "111<b>222333444555</b>");
|
||||
// expect(surroundedRange.toString()).toEqual("555");
|
||||
});
|
||||
@ -256,14 +201,8 @@ describe("surround elements that already have nested block", () => {
|
||||
const range = new Range();
|
||||
range.selectNode(body.children[0]);
|
||||
|
||||
const { addedNodes, removedNodes } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(2);
|
||||
expect(body).toHaveProperty("innerHTML", "<b>12</b><br>");
|
||||
// expect(surroundedRange.toString()).toEqual("12");
|
||||
});
|
||||
@ -281,14 +220,8 @@ describe("surround complicated nested structure", () => {
|
||||
range.setStartBefore(body.firstElementChild!.firstChild!);
|
||||
range.setEndAfter(body.lastElementChild!.firstChild!);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
const surroundedRange = surround(range, body, easyBold);
|
||||
|
||||
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>",
|
||||
@ -310,14 +243,8 @@ describe("skips over empty elements", () => {
|
||||
range.setStartBefore(body.firstChild!);
|
||||
range.setEndAfter(body.childNodes[2]!);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
const surroundedRange = surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(0);
|
||||
expect(body).toHaveProperty("innerHTML", "<b>before<br>after</b>");
|
||||
expect(surroundedRange.toString()).toEqual("beforeafter");
|
||||
});
|
||||
@ -334,32 +261,93 @@ describe("skips over empty elements", () => {
|
||||
const range = new Range();
|
||||
range.selectNode(body.firstChild!);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty("innerHTML", "<b>before<br>after</b>");
|
||||
expect(surroundedRange.toString()).toEqual("before");
|
||||
// expect(surroundedRange.toString()).toEqual("before");
|
||||
});
|
||||
|
||||
test("normalize node contents", () => {
|
||||
const range = new Range();
|
||||
range.selectNodeContents(body.firstChild!);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
const surroundedRange = surround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(1);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty("innerHTML", "<b>before<br>after</b>");
|
||||
expect(surroundedRange.toString()).toEqual("before");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// TODO
|
||||
// describe("special cases when surrounding within range.commonAncestor", () => {
|
||||
// // these are not vital but rather define how the algorithm works in edge cases
|
||||
|
||||
// test("does not normalize beyond level of contained text nodes", () => {
|
||||
// const body = p("<b>before<u>nested</u>after</b>");
|
||||
// const range = new Range();
|
||||
// range.selectNode(body.firstChild!.childNodes[1].firstChild!);
|
||||
|
||||
// const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
// range,
|
||||
// body,
|
||||
// easyBold,
|
||||
// );
|
||||
|
||||
// expect(addedNodes).toHaveLength(1);
|
||||
// expect(removedNodes).toHaveLength(0);
|
||||
// expect(body).toHaveProperty(
|
||||
// "innerHTML",
|
||||
// "<b>before<b><u>nested</u></b>after</b>",
|
||||
// );
|
||||
// expect(surroundedRange.toString()).toEqual("nested");
|
||||
// });
|
||||
|
||||
// test("does not normalize beyond level of contained text nodes 2", () => {
|
||||
// const body = p("<b>aaa<b>bbb</b><b>ccc</b></b>");
|
||||
// const range = new Range();
|
||||
// range.setStartBefore(body.firstChild!.firstChild!);
|
||||
// range.setEndAfter(body.firstChild!.childNodes[1].firstChild!);
|
||||
|
||||
// const { addedNodes, removedNodes } = surround(range, body, easyBold);
|
||||
|
||||
// expect(body).toHaveProperty("innerHTML", "<b><b>aaabbbccc</b></b>");
|
||||
// expect(addedNodes).toHaveLength(1);
|
||||
// expect(removedNodes).toHaveLength(2);
|
||||
// // expect(surroundedRange.toString()).toEqual("aaabbb"); // is aaabbbccc instead
|
||||
// });
|
||||
|
||||
// test("does normalize beyond level of contained text nodes", () => {
|
||||
// const body = p("<b><b>aaa</b><b><b>bbb</b><b>ccc</b></b></b>");
|
||||
// const range = new Range();
|
||||
// range.setStartBefore(body.firstChild!.childNodes[1].firstChild!.firstChild!);
|
||||
// range.setEndAfter(body.firstChild!.childNodes[1].childNodes[1].firstChild!);
|
||||
|
||||
// const { addedNodes, removedNodes } = surround(range, body, easyBold);
|
||||
|
||||
// expect(body).toHaveProperty("innerHTML", "<b><b>aaabbbccc</b></b>");
|
||||
// expect(addedNodes).toHaveLength(1);
|
||||
// expect(removedNodes).toHaveLength(4);
|
||||
// // expect(surroundedRange.toString()).toEqual("aaabbb"); // is aaabbbccc instead
|
||||
// });
|
||||
|
||||
// test("does remove even if there is already equivalent surrounding in place", () => {
|
||||
// const body = p("<b>before<b><u>nested</u></b>after</b>");
|
||||
// const range = new Range();
|
||||
// range.selectNode(body.firstChild!.childNodes[1].firstChild!.firstChild!);
|
||||
|
||||
// const { addedNodes, removedNodes, surroundedRange } = surround(
|
||||
// range,
|
||||
// body,
|
||||
// easyBold,
|
||||
// );
|
||||
|
||||
// expect(addedNodes).toHaveLength(1);
|
||||
// expect(removedNodes).toHaveLength(1);
|
||||
// expect(body).toHaveProperty(
|
||||
// "innerHTML",
|
||||
// "<b>before<b><u>nested</u></b>after</b>",
|
||||
// );
|
||||
// expect(surroundedRange.toString()).toEqual("nested");
|
||||
// });
|
||||
// });
|
91
ts/domlib/surround/surround.ts
Normal file
91
ts/domlib/surround/surround.ts
Normal file
@ -0,0 +1,91 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { Matcher } from "../find-above";
|
||||
import { findFarthest } from "../find-above";
|
||||
import {
|
||||
apply,
|
||||
ApplyFormat,
|
||||
ReformatApplyFormat,
|
||||
UnsurroundApplyFormat,
|
||||
} from "./apply";
|
||||
import {
|
||||
build,
|
||||
BuildFormat,
|
||||
ReformatBuildFormat,
|
||||
UnsurroundBuildFormat,
|
||||
} from "./build";
|
||||
import { boolMatcher } from "./match-type";
|
||||
import { splitPartiallySelected } from "./split-text";
|
||||
import type { SurroundFormat } from "./surround-format";
|
||||
|
||||
function surroundInner<T>(
|
||||
node: Node,
|
||||
buildFormat: BuildFormat<T>,
|
||||
applyFormat: ApplyFormat<T>,
|
||||
): Range {
|
||||
const forest = build(node, buildFormat);
|
||||
apply(forest, applyFormat);
|
||||
return buildFormat.recreateRange();
|
||||
}
|
||||
|
||||
function reformatInner<T>(
|
||||
range: Range,
|
||||
base: Element,
|
||||
build: BuildFormat<T>,
|
||||
apply: ApplyFormat<T>,
|
||||
matcher: Matcher,
|
||||
): Range {
|
||||
const farthestMatchingAncestor = findFarthest(
|
||||
range.commonAncestorContainer,
|
||||
base,
|
||||
matcher,
|
||||
);
|
||||
|
||||
if (farthestMatchingAncestor) {
|
||||
return surroundInner(farthestMatchingAncestor, build, apply);
|
||||
} else {
|
||||
return surroundInner(range.commonAncestorContainer, build, apply);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assumes that there are no matching ancestor elements above
|
||||
* `range.commonAncestorContainer`. Make sure that the range is not placed
|
||||
* inside the format before using this.
|
||||
**/
|
||||
export function surround<T>(
|
||||
range: Range,
|
||||
base: Element,
|
||||
format: SurroundFormat<T>,
|
||||
): Range {
|
||||
const splitRange = splitPartiallySelected(range);
|
||||
const build = new BuildFormat(format, base, range, splitRange);
|
||||
const apply = new ApplyFormat(format);
|
||||
return surroundInner(range.commonAncestorContainer, build, apply);
|
||||
}
|
||||
|
||||
/**
|
||||
* Will not surround any unsurrounded text nodes in the range.
|
||||
*/
|
||||
export function reformat<T>(
|
||||
range: Range,
|
||||
base: Element,
|
||||
format: SurroundFormat<T>,
|
||||
): Range {
|
||||
const splitRange = splitPartiallySelected(range);
|
||||
const build = new ReformatBuildFormat(format, base, range, splitRange);
|
||||
const apply = new ReformatApplyFormat(format);
|
||||
return reformatInner(range, base, build, apply, boolMatcher(format));
|
||||
}
|
||||
|
||||
export function unsurround<T>(
|
||||
range: Range,
|
||||
base: Element,
|
||||
format: SurroundFormat<T>,
|
||||
): Range {
|
||||
const splitRange = splitPartiallySelected(range);
|
||||
const build = new UnsurroundBuildFormat(format, base, range, splitRange);
|
||||
const apply = new UnsurroundApplyFormat(format);
|
||||
return reformatInner(range, base, build, apply, boolMatcher(format));
|
||||
}
|
52
ts/domlib/surround/test-utils.ts
Normal file
52
ts/domlib/surround/test-utils.ts
Normal file
@ -0,0 +1,52 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import type { MatchType } from "./match-type";
|
||||
|
||||
export const matchTagName =
|
||||
(tagName: string) =>
|
||||
<T>(element: Element, match: MatchType<T>): void => {
|
||||
if (element.matches(tagName)) {
|
||||
match.remove();
|
||||
}
|
||||
};
|
||||
|
||||
export const easyBold = {
|
||||
surroundElement: document.createElement("b"),
|
||||
matcher: matchTagName("b"),
|
||||
};
|
||||
|
||||
export const easyItalic = {
|
||||
surroundElement: document.createElement("i"),
|
||||
matcher: matchTagName("i"),
|
||||
};
|
||||
|
||||
export const easyUnderline = {
|
||||
surroundElement: document.createElement("u"),
|
||||
matcher: matchTagName("u"),
|
||||
};
|
||||
|
||||
const parser = new DOMParser();
|
||||
|
||||
export function p(html: string): HTMLBodyElement {
|
||||
const parsed = parser.parseFromString(html, "text/html");
|
||||
return parsed.body as HTMLBodyElement;
|
||||
}
|
||||
|
||||
export function t(data: string): Text {
|
||||
return document.createTextNode(data);
|
||||
}
|
||||
|
||||
function element(tagName: string): (...childNodes: Node[]) => HTMLElement {
|
||||
return function (...childNodes: Node[]): HTMLElement {
|
||||
const element = document.createElement(tagName);
|
||||
element.append(...childNodes);
|
||||
return element;
|
||||
};
|
||||
}
|
||||
|
||||
export const b = element("b");
|
||||
export const i = element("i");
|
||||
export const u = element("u");
|
||||
export const span = element("span");
|
||||
export const div = element("div");
|
@ -1,64 +0,0 @@
|
||||
// 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 [];
|
||||
}
|
||||
}
|
18
ts/domlib/surround/tree/block-node.ts
Normal file
18
ts/domlib/surround/tree/block-node.ts
Normal file
@ -0,0 +1,18 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { TreeNode } from "./tree-node";
|
||||
|
||||
/**
|
||||
* Its purpose is to block adjacent FormattingNodes from merging, or single
|
||||
* FormattingNodes from trying to ascend.
|
||||
*/
|
||||
export class BlockNode extends TreeNode {
|
||||
private constructor() {
|
||||
super(false);
|
||||
}
|
||||
|
||||
static make(): BlockNode {
|
||||
return new BlockNode();
|
||||
}
|
||||
}
|
17
ts/domlib/surround/tree/element-node.ts
Normal file
17
ts/domlib/surround/tree/element-node.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { TreeNode } from "./tree-node";
|
||||
|
||||
export class ElementNode extends TreeNode {
|
||||
private constructor(
|
||||
public readonly element: Element,
|
||||
public readonly insideRange: boolean,
|
||||
) {
|
||||
super(insideRange);
|
||||
}
|
||||
|
||||
static make(element: Element, insideRange: boolean): ElementNode {
|
||||
return new ElementNode(element, insideRange);
|
||||
}
|
||||
}
|
212
ts/domlib/surround/tree/formatting-node.ts
Normal file
212
ts/domlib/surround/tree/formatting-node.ts
Normal file
@ -0,0 +1,212 @@
|
||||
// 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 { FlatRange } from "../flat-range";
|
||||
import type { Match } from "../match-type";
|
||||
import { ElementNode } from "./element-node";
|
||||
import { TreeNode } from "./tree-node";
|
||||
|
||||
/**
|
||||
* Represents a potential insertion point for a tag or, more generally, a point for starting a format procedure.
|
||||
*/
|
||||
export class FormattingNode<T = never> extends TreeNode {
|
||||
private constructor(
|
||||
public readonly range: FlatRange,
|
||||
public readonly insideRange: boolean,
|
||||
/**
|
||||
* Match ancestors are all matching matches that are direct ancestors
|
||||
* of `this`. This is important for deciding whether a text node is
|
||||
* turned into a FormattingNode or into a BlockNode, if it is outside
|
||||
* the initial DOM range.
|
||||
*/
|
||||
public readonly matchAncestors: Match<T>[],
|
||||
) {
|
||||
super(insideRange);
|
||||
}
|
||||
|
||||
private static make<T>(
|
||||
range: FlatRange,
|
||||
insideRange: boolean,
|
||||
matchAncestors: Match<T>[],
|
||||
): FormattingNode<T> {
|
||||
return new FormattingNode(range, insideRange, matchAncestors);
|
||||
}
|
||||
|
||||
static fromText<T>(
|
||||
text: Text,
|
||||
insideRange: boolean,
|
||||
matchAncestors: Match<T>[],
|
||||
): FormattingNode<T> {
|
||||
return FormattingNode.make(
|
||||
FlatRange.fromNode(text),
|
||||
insideRange,
|
||||
matchAncestors,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A merge is combinging two formatting nodes into a single one.
|
||||
* The merged node will take over their children, their match leaves, and
|
||||
* their match holes, but will drop their extensions.
|
||||
*
|
||||
* @example
|
||||
* Practically speaking, it is what happens, when you combine:
|
||||
* `<b>before</b><b>after</b>` into `<b>beforeafter</b>`, or
|
||||
* `<b>before</b><img src="image.jpg"><b>after</b>` into
|
||||
* `<b>before<img src="image.jpg">after</b>` (negligible nodes inbetween).
|
||||
*/
|
||||
static merge<T>(
|
||||
before: FormattingNode<T>,
|
||||
after: FormattingNode<T>,
|
||||
): FormattingNode<T> {
|
||||
const node = FormattingNode.make(
|
||||
FlatRange.merge(before.range, after.range),
|
||||
before.insideRange && after.insideRange,
|
||||
before.matchAncestors,
|
||||
);
|
||||
|
||||
node.replaceChildren(...before.children, ...after.children);
|
||||
node.matchLeaves.push(...before.matchLeaves, ...after.matchLeaves);
|
||||
node.hasMatchHoles = before.hasMatchHoles || after.hasMatchHoles;
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.range.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* An ascent is placing a FormattingNode above an ElementNode.
|
||||
* This happens, when the element node is an extension to the formatting node.
|
||||
*
|
||||
* @param elementNode: Its children will be discarded in favor of `this`s
|
||||
* children.
|
||||
*
|
||||
* @example
|
||||
* Practically speaking, it is what happens, when you turn:
|
||||
* `<u><b>inside</b></u>` into `<b><u>inside</u></b>`, or
|
||||
* `<u><b>inside</b><img src="image.jpg"></u>` into `<b><u>inside<img src="image.jpg"></u></b>
|
||||
*/
|
||||
ascendAbove(elementNode: ElementNode): void {
|
||||
this.range.select(elementNode.element);
|
||||
this.extensions.push(elementNode.element as HTMLElement | SVGElement);
|
||||
|
||||
if (!this.hasChildren()) {
|
||||
// Drop elementNode, as it has no effect
|
||||
return;
|
||||
}
|
||||
|
||||
elementNode.replaceChildren(...this.replaceChildren(elementNode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extending only makes sense, if it is following by a FormattingNode
|
||||
* ascending above it.
|
||||
* Which is why if the match node is not ascendable, we might as well
|
||||
* stop extending.
|
||||
*
|
||||
* @returns Whether formatting node ascended at least one level
|
||||
*/
|
||||
getExtension(): ElementNode | null {
|
||||
const node = this.range.parent;
|
||||
|
||||
if (nodeIsElement(node)) {
|
||||
return ElementNode.make(node, this.insideRange);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// The following methods are meant for users when specifying their surround
|
||||
// formats and is not vital to the algorithm itself
|
||||
|
||||
/**
|
||||
* Match leaves are the matching elements that are/were descendants of
|
||||
* `this`. This makes them the element nodes, which actually affect text
|
||||
* nodes located inside `this`.
|
||||
*
|
||||
* @example
|
||||
* If we are surrounding with bold, then in this case:
|
||||
* `<b><b>first</b><b>second</b></b>
|
||||
* The inner b tags are match leaves, but the outer b tag is not, because
|
||||
* it does affect any text nodes.
|
||||
*
|
||||
* @remarks
|
||||
* These are important for mergers.
|
||||
*/
|
||||
matchLeaves: Match<T>[] = [];
|
||||
|
||||
get firstLeaf(): Match<T> | null {
|
||||
if (this.matchLeaves.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.matchLeaves[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Match holes are text nodes which are descendants of `this`, but are not
|
||||
* descendants of any match leaves of `this`.
|
||||
*/
|
||||
hasMatchHoles = true;
|
||||
|
||||
get closestAncestor(): Match<T> | null {
|
||||
if (this.matchAncestors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.matchAncestors[this.matchAncestors.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions of formatting nodes with a single element contained in their
|
||||
* range are direct exclusive descendant elements of this element.
|
||||
* Extensions are sorted in tree order.
|
||||
*
|
||||
* @example
|
||||
* When surrounding "inside" with a bold format in the following case:
|
||||
* `<span class="myclass"><em>inside</em></span>`
|
||||
* The formatting node would sit above the span (it ascends above both
|
||||
* the span and the em tag), and both tags are extensions to this node.
|
||||
*
|
||||
* @example
|
||||
* When a format only wants to add a class, it would typically look for an
|
||||
* extension first. When applying class="myclass" to "inside" in the
|
||||
* following case:
|
||||
* `<em><span style="color: rgb(255, 0, 0)"><b>inside</b></span></em>`
|
||||
* It would typically become:
|
||||
* `<em><span class="myclass" style="color: rgb(255, 0, 0)"><b>inside</b></span></em>`
|
||||
*/
|
||||
extensions: (HTMLElement | SVGElement)[] = [];
|
||||
|
||||
/**
|
||||
* @param insideValue: The value that should be returned, if the formatting
|
||||
* node is inside the original range. If the node is not inside the original
|
||||
* range, the cache of the first leaf, or the closest match ancestor will be
|
||||
* returned.
|
||||
*/
|
||||
getCache(insideValue: T): T | null {
|
||||
if (this.insideRange) {
|
||||
return insideValue;
|
||||
} else if (this.firstLeaf) {
|
||||
return this.firstLeaf.cache;
|
||||
} else if (this.closestAncestor) {
|
||||
return this.closestAncestor.cache;
|
||||
}
|
||||
|
||||
// Should never happen, as a formatting node is always either
|
||||
// inside a range or inside a match
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the text nodes in this formatting node are affected by any match.
|
||||
* This can only be false, if `insideRange` is true (otherwise it would have
|
||||
* become a BlockNode).
|
||||
*/
|
||||
get hasMatch(): boolean {
|
||||
return this.matchLeaves.length > 0 || this.matchAncestors.length > 0;
|
||||
}
|
||||
}
|
7
ts/domlib/surround/tree/index.ts
Normal file
7
ts/domlib/surround/tree/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
export { BlockNode } from "./block-node";
|
||||
export { ElementNode } from "./element-node";
|
||||
export { FormattingNode } from "./formatting-node";
|
||||
export type { TreeNode } from "./tree-node";
|
28
ts/domlib/surround/tree/tree-node.ts
Normal file
28
ts/domlib/surround/tree/tree-node.ts
Normal file
@ -0,0 +1,28 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
export abstract class TreeNode {
|
||||
readonly children: TreeNode[] = [];
|
||||
|
||||
protected constructor(
|
||||
/**
|
||||
* Whether all text nodes within this node are inside the initial DOM range.
|
||||
*/
|
||||
public insideRange: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @returns Children which were replaced.
|
||||
*/
|
||||
replaceChildren(...newChildren: TreeNode[]): TreeNode[] {
|
||||
return this.children.splice(0, this.length, ...newChildren);
|
||||
}
|
||||
|
||||
hasChildren(): boolean {
|
||||
return this.children.length > 0;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.children.length;
|
||||
}
|
||||
}
|
@ -1,14 +1,8 @@
|
||||
// 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;
|
||||
}
|
||||
import { unsurround } from "./surround";
|
||||
import { easyBold, p } from "./test-utils";
|
||||
|
||||
describe("unsurround text", () => {
|
||||
let body: HTMLBodyElement;
|
||||
@ -21,43 +15,30 @@ describe("unsurround text", () => {
|
||||
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);
|
||||
unsurround(range, body, easyBold);
|
||||
expect(body).toHaveProperty("innerHTML", "test");
|
||||
expect(surroundedRange.toString()).toEqual("test");
|
||||
// expect(surroundedRange.toString()).toEqual("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsurround element and text", () => {
|
||||
let body: HTMLBodyElement;
|
||||
// describe("unsurround element and text", () => {
|
||||
// let body: HTMLBodyElement;
|
||||
|
||||
beforeEach(() => {
|
||||
body = p("<b>before</b>after");
|
||||
});
|
||||
// beforeEach(() => {
|
||||
// body = p("<b>before</b>after");
|
||||
// });
|
||||
|
||||
test("normalizes nodes", () => {
|
||||
const range = new Range();
|
||||
range.setStartBefore(body.childNodes[0].firstChild!);
|
||||
range.setEndAfter(body.childNodes[1]);
|
||||
// test("normalizes nodes", () => {
|
||||
// const range = new Range();
|
||||
// range.setStartBefore(body.childNodes[0].firstChild!);
|
||||
// range.setEndAfter(body.childNodes[1]);
|
||||
|
||||
const { addedNodes, removedNodes, surroundedRange } = unsurround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
// const surroundedRange = unsurround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(0);
|
||||
expect(removedNodes).toHaveLength(1);
|
||||
expect(body).toHaveProperty("innerHTML", "beforeafter");
|
||||
expect(surroundedRange.toString()).toEqual("beforeafter");
|
||||
});
|
||||
});
|
||||
// expect(body).toHaveProperty("innerHTML", "beforeafter");
|
||||
// expect(surroundedRange.toString()).toEqual("beforeafter");
|
||||
// });
|
||||
// });
|
||||
|
||||
describe("unsurround element with surrounding text", () => {
|
||||
let body: HTMLBodyElement;
|
||||
@ -70,43 +51,31 @@ describe("unsurround element with surrounding text", () => {
|
||||
const range = new Range();
|
||||
range.selectNode(body.firstElementChild!);
|
||||
|
||||
const { addedNodes, removedNodes } = unsurround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
unsurround(range, body, easyBold);
|
||||
|
||||
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;
|
||||
// describe("unsurround from one element to another", () => {
|
||||
// let body: HTMLBodyElement;
|
||||
|
||||
beforeEach(() => {
|
||||
body = p("<b>111</b>222<b>333</b>");
|
||||
});
|
||||
// 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!);
|
||||
// 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,
|
||||
);
|
||||
// unsurround(range, body, easyBold);
|
||||
|
||||
expect(addedNodes).toHaveLength(0);
|
||||
expect(removedNodes).toHaveLength(2);
|
||||
expect(body).toHaveProperty("innerHTML", "111222333");
|
||||
// expect(surroundedRange.toString()).toEqual("22");
|
||||
});
|
||||
});
|
||||
// expect(body).toHaveProperty("innerHTML", "111222333");
|
||||
// // expect(surroundedRange.toString()).toEqual("22");
|
||||
// });
|
||||
// });
|
||||
|
||||
// describe("unsurround text portion of element", () => {
|
||||
// let body: HTMLBodyElement;
|
||||
@ -146,15 +115,38 @@ describe("with bold around block item", () => {
|
||||
body.firstChild!.childNodes[2].firstChild!.firstChild!,
|
||||
);
|
||||
|
||||
const { addedNodes, removedNodes } = unsurround(
|
||||
range,
|
||||
document.createElement("b"),
|
||||
body,
|
||||
);
|
||||
unsurround(range, body, easyBold);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with two double nested and one single nested", () => {
|
||||
// test("unsurround one double and single nested", () => {
|
||||
// const body = p("<b><b>aaa</b><b>bbb</b>ccc</b>");
|
||||
// const range = new Range();
|
||||
// range.setStartBefore(body.firstChild!.childNodes[1].firstChild!);
|
||||
// range.setEndAfter(body.firstChild!.childNodes[2]);
|
||||
|
||||
// const surroundedRange = unsurround(
|
||||
// range,
|
||||
// body,
|
||||
// easyBold,
|
||||
// );
|
||||
|
||||
// expect(body).toHaveProperty("innerHTML", "<b>aaa</b>bbbccc");
|
||||
// expect(surroundedRange.toString()).toEqual("bbbccc");
|
||||
// });
|
||||
|
||||
test("unsurround single and one double nested", () => {
|
||||
const body = p("<b>aaa<b>bbb</b><b>ccc</b></b>");
|
||||
const range = new Range();
|
||||
range.setStartBefore(body.firstChild!.firstChild!);
|
||||
range.setEndAfter(body.firstChild!.childNodes[1].firstChild!);
|
||||
|
||||
const surroundedRange = unsurround(range, body, easyBold);
|
||||
expect(body).toHaveProperty("innerHTML", "aaabbb<b>ccc</b>");
|
||||
expect(surroundedRange.toString()).toEqual("aaabbb");
|
||||
});
|
||||
});
|
||||
|
@ -1,226 +0,0 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
import { findFarthest } from "./find-above";
|
||||
import { findWithinNode, findWithinRange } from "./find-within";
|
||||
import type { ElementClearer, ElementMatcher, FoundMatch } from "./matcher";
|
||||
import { MatchResult, matchTagName } from "./matcher";
|
||||
import type { NodesResult, SurroundNoSplittingResult } from "./no-splitting";
|
||||
import { surround } from "./no-splitting";
|
||||
import { getRangeAnchors } from "./range-anchors";
|
||||
|
||||
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(withNode: Node): (node: Node) => boolean {
|
||||
/* otherwise, they will be added to nodesToRemove twice
|
||||
* and will also be cleared twice */
|
||||
return (node: Node) => !node.contains(withNode) && !withNode.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,
|
||||
aboveEnd ? prohibitOverlapse(aboveEnd.element) : () => true,
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
// 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,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["*", "location/*", "surround/*"],
|
||||
"include": [
|
||||
"*",
|
||||
"location/*",
|
||||
"surround/*",
|
||||
"surround/apply/*",
|
||||
"surround/build/*",
|
||||
"surround/tree/*"
|
||||
],
|
||||
"references": [{ "path": "../lib" }],
|
||||
"compilerOptions": {
|
||||
"types": ["jest"]
|
||||
|
@ -14,10 +14,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import type { InputManagerAction } from "../sveltelib/input-manager";
|
||||
import type { MirrorAction } from "../sveltelib/mirror-dom";
|
||||
import type { ContentEditableAPI } from "./content-editable";
|
||||
import {
|
||||
customFocusHandling,
|
||||
preventBuiltinContentEditableShortcuts,
|
||||
} from "./content-editable";
|
||||
import { customFocusHandling, preventBuiltinShortcuts } from "./content-editable";
|
||||
|
||||
export let resolve: (editable: HTMLElement) => void;
|
||||
|
||||
@ -42,7 +39,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
contenteditable="true"
|
||||
use:resolve
|
||||
use:setupFocusHandling
|
||||
use:preventBuiltinContentEditableShortcuts
|
||||
use:preventBuiltinShortcuts
|
||||
use:mirrorAction={mirrorOptions}
|
||||
use:managerAction={{}}
|
||||
on:focus
|
||||
|
@ -88,7 +88,7 @@ if (isApplePlatform()) {
|
||||
registerShortcut(() => bridgeCommand("paste"), "Control+Shift+V");
|
||||
}
|
||||
|
||||
export function preventBuiltinContentEditableShortcuts(editable: HTMLElement): void {
|
||||
export function preventBuiltinShortcuts(editable: HTMLElement): void {
|
||||
for (const keyCombination of ["Control+B", "Control+U", "Control+I"]) {
|
||||
registerShortcut(preventDefault, keyCombination, editable);
|
||||
}
|
||||
|
@ -139,13 +139,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
});
|
||||
}
|
||||
|
||||
let textColor: string = "black";
|
||||
let highlightColor: string = "black";
|
||||
export function setColorButtons([textClr, highlightClr]: [string, string]): void {
|
||||
textColor = textClr;
|
||||
highlightColor = highlightClr;
|
||||
}
|
||||
|
||||
const tags = writable<string[]>([]);
|
||||
export function setTags(ts: string[]): void {
|
||||
$tags = ts;
|
||||
@ -248,7 +241,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
setDescriptions,
|
||||
setFonts,
|
||||
focusField,
|
||||
setColorButtons,
|
||||
setTags,
|
||||
setBackgrounds,
|
||||
setClozeHint,
|
||||
@ -283,7 +275,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
<div class="note-editor">
|
||||
<FieldsEditor>
|
||||
<EditorToolbar {size} {wrap} {textColor} {highlightColor} api={toolbar}>
|
||||
<EditorToolbar {size} {wrap} api={toolbar}>
|
||||
<slot slot="notetypeButtons" name="notetypeButtons" />
|
||||
</EditorToolbar>
|
||||
|
||||
|
@ -98,6 +98,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
key="justifyLeft"
|
||||
tooltip={tr.editingAlignLeft()}
|
||||
--border-left-radius="5px"
|
||||
--border-right-radius="0px"
|
||||
>{@html justifyLeftIcon}</CommandIconButton
|
||||
>
|
||||
|
||||
@ -131,6 +132,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
{disabled}
|
||||
on:click={outdentListItem}
|
||||
--border-left-radius="5px"
|
||||
--border-right-radius="0px"
|
||||
>
|
||||
{@html outdentIcon}
|
||||
</IconButton>
|
@ -6,56 +6,67 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import Shortcut from "../../components/Shortcut.svelte";
|
||||
import WithState from "../../components/WithState.svelte";
|
||||
import { MatchResult } from "../../domlib/surround";
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import type { RichTextInputAPI } from "../rich-text-input";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { getSurrounder } from "../surround";
|
||||
import { removeEmptyStyle, Surrounder } from "../surround";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { boldIcon } from "./icons";
|
||||
|
||||
function matchBold(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
|
||||
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
|
||||
return MatchResult.NO_MATCH;
|
||||
}
|
||||
const surroundElement = document.createElement("strong");
|
||||
|
||||
function matcher(element: HTMLElement | SVGElement, match: MatchType): void {
|
||||
if (element.tagName === "B" || element.tagName === "STRONG") {
|
||||
return MatchResult.MATCH;
|
||||
return match.remove();
|
||||
}
|
||||
|
||||
const fontWeight = element.style.fontWeight;
|
||||
if (fontWeight === "bold" || Number(fontWeight) >= 400) {
|
||||
return MatchResult.KEEP;
|
||||
return match.clear((): void => {
|
||||
element.style.removeProperty("font-weight");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
match.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return MatchResult.NO_MATCH;
|
||||
}
|
||||
const format = {
|
||||
surroundElement,
|
||||
matcher,
|
||||
};
|
||||
|
||||
function clearBold(element: Element): boolean {
|
||||
const htmlElement = element as HTMLElement | SVGElement;
|
||||
htmlElement.style.removeProperty("font-weight");
|
||||
const namedFormat = {
|
||||
name: tr.editingBoldText(),
|
||||
show: true,
|
||||
active: true,
|
||||
format,
|
||||
};
|
||||
|
||||
if (htmlElement.style.cssText.length === 0) {
|
||||
htmlElement.removeAttribute("style");
|
||||
}
|
||||
|
||||
return !htmlElement.hasAttribute("style") && element.className.length === 0;
|
||||
}
|
||||
const { removeFormats } = editorToolbarContext.get();
|
||||
removeFormats.update((formats) => [...formats, namedFormat]);
|
||||
|
||||
const { focusedInput } = noteEditorContext.get();
|
||||
const surrounder = Surrounder.make();
|
||||
let disabled: boolean;
|
||||
|
||||
$: input = $focusedInput as RichTextInputAPI;
|
||||
$: disabled = !editingInputIsRichText($focusedInput);
|
||||
$: surrounder = disabled ? null : getSurrounder(input);
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(matchBold);
|
||||
$: if (editingInputIsRichText($focusedInput)) {
|
||||
surrounder.richText = $focusedInput;
|
||||
disabled = false;
|
||||
} else {
|
||||
surrounder.disable();
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return disabled ? Promise.resolve(false) : surrounder.isSurrounded(format);
|
||||
}
|
||||
|
||||
const element = document.createElement("strong");
|
||||
function makeBold(): void {
|
||||
surrounder?.surroundCommand(element, matchBold, clearBold);
|
||||
surrounder.surround(format);
|
||||
}
|
||||
|
||||
const keyCombination = "Control+B";
|
||||
@ -64,12 +75,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
<WithState
|
||||
key="bold"
|
||||
update={updateStateFromActiveInput}
|
||||
let:state={isBold}
|
||||
let:state={active}
|
||||
let:updateState
|
||||
>
|
||||
<IconButton
|
||||
tooltip="{tr.editingBoldText()} ({getPlatformString(keyCombination)})"
|
||||
active={isBold}
|
||||
{active}
|
||||
{disabled}
|
||||
on:click={(event) => {
|
||||
makeBold();
|
||||
|
@ -1,134 +0,0 @@
|
||||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import ButtonGroup from "../../components/ButtonGroup.svelte";
|
||||
import ButtonGroupItem, {
|
||||
createProps,
|
||||
setSlotHostContext,
|
||||
updatePropsList,
|
||||
} from "../../components/ButtonGroupItem.svelte";
|
||||
import ColorPicker from "../../components/ColorPicker.svelte";
|
||||
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import Shortcut from "../../components/Shortcut.svelte";
|
||||
import { bridgeCommand } from "../../lib/bridgecommand";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { execCommand } from "../helpers";
|
||||
import { context } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { arrowIcon, highlightColorIcon, textColorIcon } from "./icons";
|
||||
import WithColorHelper from "./WithColorHelper.svelte";
|
||||
|
||||
export let api = {};
|
||||
export let textColor: string;
|
||||
export let highlightColor: string;
|
||||
|
||||
const forecolorKeyCombination = "F7";
|
||||
$: forecolorWrap = wrapWithForecolor(textColor);
|
||||
|
||||
const backcolorKeyCombination = "F8";
|
||||
$: backcolorWrap = wrapWithBackcolor(highlightColor);
|
||||
|
||||
const wrapWithForecolor = (color: string) => () => {
|
||||
execCommand("forecolor", false, color);
|
||||
};
|
||||
|
||||
const wrapWithBackcolor = (color: string) => () => {
|
||||
execCommand("backcolor", false, color);
|
||||
};
|
||||
|
||||
const { focusedInput } = context.get();
|
||||
$: disabled = !editingInputIsRichText($focusedInput);
|
||||
</script>
|
||||
|
||||
<ButtonGroup>
|
||||
<DynamicallySlottable
|
||||
slotHost={ButtonGroupItem}
|
||||
{createProps}
|
||||
{updatePropsList}
|
||||
{setSlotHostContext}
|
||||
{api}
|
||||
>
|
||||
<WithColorHelper color={textColor} let:colorHelperIcon let:setColor>
|
||||
<ButtonGroupItem>
|
||||
<IconButton
|
||||
tooltip="{tr.editingSetTextColor()} ({getPlatformString(
|
||||
forecolorKeyCombination,
|
||||
)})"
|
||||
{disabled}
|
||||
on:click={forecolorWrap}
|
||||
>
|
||||
{@html textColorIcon}
|
||||
{@html colorHelperIcon}
|
||||
</IconButton>
|
||||
<Shortcut
|
||||
keyCombination={forecolorKeyCombination}
|
||||
on:action={forecolorWrap}
|
||||
/>
|
||||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<IconButton
|
||||
tooltip="{tr.editingChangeColor()} ({getPlatformString(
|
||||
backcolorKeyCombination,
|
||||
)})"
|
||||
{disabled}
|
||||
widthMultiplier={0.5}
|
||||
>
|
||||
{@html arrowIcon}
|
||||
<ColorPicker
|
||||
on:change={(event) => {
|
||||
const textColor = setColor(event);
|
||||
bridgeCommand(`lastTextColor:${textColor}`);
|
||||
forecolorWrap = wrapWithForecolor(setColor(event));
|
||||
forecolorWrap();
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<Shortcut
|
||||
keyCombination={backcolorKeyCombination}
|
||||
on:action={(event) => {
|
||||
const textColor = setColor(event);
|
||||
bridgeCommand(`lastTextColor:${textColor}`);
|
||||
forecolorWrap = wrapWithForecolor(setColor(event));
|
||||
forecolorWrap();
|
||||
}}
|
||||
/>
|
||||
</ButtonGroupItem>
|
||||
</WithColorHelper>
|
||||
|
||||
<WithColorHelper color={highlightColor} let:colorHelperIcon let:setColor>
|
||||
<ButtonGroupItem>
|
||||
<IconButton
|
||||
tooltip={tr.editingSetTextHighlightColor()}
|
||||
{disabled}
|
||||
on:click={backcolorWrap}
|
||||
>
|
||||
{@html highlightColorIcon}
|
||||
{@html colorHelperIcon}
|
||||
</IconButton>
|
||||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<IconButton
|
||||
tooltip={tr.editingChangeColor()}
|
||||
widthMultiplier={0.5}
|
||||
{disabled}
|
||||
>
|
||||
{@html arrowIcon}
|
||||
<ColorPicker
|
||||
on:change={(event) => {
|
||||
const highlightColor = setColor(event);
|
||||
bridgeCommand(`lastHighlightColor:${highlightColor}`);
|
||||
backcolorWrap = wrapWithBackcolor(highlightColor);
|
||||
backcolorWrap();
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</ButtonGroupItem>
|
||||
</WithColorHelper>
|
||||
</DynamicallySlottable>
|
||||
</ButtonGroup>
|
@ -3,7 +3,10 @@ Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script context="module" lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
|
||||
import { resetAllState, updateAllState } from "../../components/WithState.svelte";
|
||||
import type { SurroundFormat } from "../../domlib/surround";
|
||||
import type { DefaultSlotInterface } from "../../sveltelib/dynamic-slotting";
|
||||
|
||||
export function updateActiveButtons(event: Event) {
|
||||
@ -14,13 +17,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
resetAllState(false);
|
||||
}
|
||||
|
||||
export interface RemoveFormat<T> {
|
||||
name: string;
|
||||
show: boolean;
|
||||
active: boolean;
|
||||
format: SurroundFormat<T>;
|
||||
}
|
||||
|
||||
export interface EditorToolbarAPI {
|
||||
toolbar: DefaultSlotInterface;
|
||||
notetypeButtons: DefaultSlotInterface;
|
||||
formatInlineButtons: DefaultSlotInterface;
|
||||
formatBlockButtons: DefaultSlotInterface;
|
||||
colorButtons: DefaultSlotInterface;
|
||||
inlineButtons: DefaultSlotInterface;
|
||||
blockButtons: DefaultSlotInterface;
|
||||
templateButtons: DefaultSlotInterface;
|
||||
removeFormats: Writable<RemoveFormat<any>[]>;
|
||||
}
|
||||
|
||||
/* Our dynamic components */
|
||||
@ -29,42 +39,50 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
export const editorToolbar = {
|
||||
AddonButtons,
|
||||
};
|
||||
|
||||
import contextProperty from "../../sveltelib/context-property";
|
||||
|
||||
const key = Symbol("editorToolbar");
|
||||
const [context, setContextProperty] = contextProperty<EditorToolbarAPI>(key);
|
||||
|
||||
export { context };
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
import ButtonToolbar from "../../components/ButtonToolbar.svelte";
|
||||
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
|
||||
import Item from "../../components/Item.svelte";
|
||||
import StickyContainer from "../../components/StickyContainer.svelte";
|
||||
import ColorButtons from "./ColorButtons.svelte";
|
||||
import FormatBlockButtons from "./FormatBlockButtons.svelte";
|
||||
import FormatInlineButtons from "./FormatInlineButtons.svelte";
|
||||
import BlockButtons from "./BlockButtons.svelte";
|
||||
import InlineButtons from "./InlineButtons.svelte";
|
||||
import NotetypeButtons from "./NotetypeButtons.svelte";
|
||||
import TemplateButtons from "./TemplateButtons.svelte";
|
||||
|
||||
export let size: number;
|
||||
export let wrap: boolean;
|
||||
|
||||
export let textColor: string;
|
||||
export let highlightColor: string;
|
||||
const toolbar = {} as DefaultSlotInterface;
|
||||
const notetypeButtons = {} as DefaultSlotInterface;
|
||||
const inlineButtons = {} as DefaultSlotInterface;
|
||||
const blockButtons = {} as DefaultSlotInterface;
|
||||
const templateButtons = {} as DefaultSlotInterface;
|
||||
const removeFormats = writable<RemoveFormat<any>[]>([]);
|
||||
|
||||
const toolbar = {};
|
||||
const notetypeButtons = {};
|
||||
const formatInlineButtons = {};
|
||||
const formatBlockButtons = {};
|
||||
const colorButtons = {};
|
||||
const templateButtons = {};
|
||||
let apiPartial: Partial<EditorToolbarAPI> = {};
|
||||
export { apiPartial as api };
|
||||
|
||||
export let api: Partial<EditorToolbarAPI> = {};
|
||||
|
||||
Object.assign(api, {
|
||||
const api: EditorToolbarAPI = Object.assign(apiPartial, {
|
||||
toolbar,
|
||||
notetypeButtons,
|
||||
formatInlineButtons,
|
||||
formatBlockButtons,
|
||||
colorButtons,
|
||||
inlineButtons,
|
||||
blockButtons,
|
||||
templateButtons,
|
||||
removeFormats,
|
||||
} as EditorToolbarAPI);
|
||||
|
||||
setContextProperty(api);
|
||||
</script>
|
||||
|
||||
<StickyContainer --gutter-block="0.1rem" --sticky-borders="0 0 1px">
|
||||
@ -77,15 +95,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
</Item>
|
||||
|
||||
<Item id="inlineFormatting">
|
||||
<FormatInlineButtons api={formatInlineButtons} />
|
||||
<InlineButtons api={inlineButtons} />
|
||||
</Item>
|
||||
|
||||
<Item id="blockFormatting">
|
||||
<FormatBlockButtons api={formatBlockButtons} />
|
||||
</Item>
|
||||
|
||||
<Item id="color">
|
||||
<ColorButtons {textColor} {highlightColor} api={colorButtons} />
|
||||
<BlockButtons api={blockButtons} />
|
||||
</Item>
|
||||
|
||||
<Item id="template">
|
||||
|
@ -1,69 +0,0 @@
|
||||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import ButtonGroup from "../../components/ButtonGroup.svelte";
|
||||
import ButtonGroupItem, {
|
||||
createProps,
|
||||
setSlotHostContext,
|
||||
updatePropsList,
|
||||
} from "../../components/ButtonGroupItem.svelte";
|
||||
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import BoldButton from "./BoldButton.svelte";
|
||||
import CommandIconButton from "./CommandIconButton.svelte";
|
||||
import { eraserIcon, subscriptIcon, superscriptIcon } from "./icons";
|
||||
import ItalicButton from "./ItalicButton.svelte";
|
||||
import UnderlineButton from "./UnderlineButton.svelte";
|
||||
|
||||
export let api = {};
|
||||
</script>
|
||||
|
||||
<ButtonGroup>
|
||||
<DynamicallySlottable
|
||||
slotHost={ButtonGroupItem}
|
||||
{createProps}
|
||||
{updatePropsList}
|
||||
{setSlotHostContext}
|
||||
{api}
|
||||
>
|
||||
<ButtonGroupItem>
|
||||
<BoldButton />
|
||||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<ItalicButton />
|
||||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<UnderlineButton />
|
||||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<CommandIconButton
|
||||
key="superscript"
|
||||
shortcut="Control+="
|
||||
tooltip={tr.editingSuperscript()}
|
||||
>{@html superscriptIcon}</CommandIconButton
|
||||
>
|
||||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<CommandIconButton
|
||||
key="subscript"
|
||||
shortcut="Control+Shift+="
|
||||
tooltip={tr.editingSubscript()}>{@html subscriptIcon}</CommandIconButton
|
||||
>
|
||||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<CommandIconButton
|
||||
key="removeFormat"
|
||||
shortcut="Control+R"
|
||||
tooltip={tr.editingRemoveFormatting()}
|
||||
withoutState>{@html eraserIcon}</CommandIconButton
|
||||
>
|
||||
</ButtonGroupItem>
|
||||
</DynamicallySlottable>
|
||||
</ButtonGroup>
|
138
ts/editor/editor-toolbar/HighlightColorButton.svelte
Normal file
138
ts/editor/editor-toolbar/HighlightColorButton.svelte
Normal file
@ -0,0 +1,138 @@
|
||||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import ColorPicker from "../../components/ColorPicker.svelte";
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import type {
|
||||
FormattingNode,
|
||||
MatchType,
|
||||
SurroundFormat,
|
||||
} from "../../domlib/surround";
|
||||
import { bridgeCommand } from "../../lib/bridgecommand";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { removeEmptyStyle, Surrounder } from "../surround";
|
||||
import type { RemoveFormat } from "./EditorToolbar.svelte";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { arrowIcon, highlightColorIcon } from "./icons";
|
||||
import WithColorHelper from "./WithColorHelper.svelte";
|
||||
|
||||
export let color: string;
|
||||
|
||||
$: transformedColor = transformColor(color);
|
||||
|
||||
/**
|
||||
* The DOM will transform colors such as "#ff0000" to "rgb(256, 0, 0)".
|
||||
*/
|
||||
function transformColor(color: string): string {
|
||||
const span = document.createElement("span");
|
||||
span.style.setProperty("background-color", color);
|
||||
return span.style.getPropertyValue("background-color");
|
||||
}
|
||||
|
||||
function matcher(
|
||||
element: HTMLElement | SVGElement,
|
||||
match: MatchType<string>,
|
||||
): void {
|
||||
const value = element.style.getPropertyValue("background-color");
|
||||
|
||||
if (value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
match.setCache(value);
|
||||
match.clear((): void => {
|
||||
element.style.removeProperty("background-color");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
match.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function merger(
|
||||
before: FormattingNode<string>,
|
||||
after: FormattingNode<string>,
|
||||
): boolean {
|
||||
return before.getCache(transformedColor) === after.getCache(transformedColor);
|
||||
}
|
||||
|
||||
function formatter(node: FormattingNode<string>): boolean {
|
||||
const extension = node.extensions.find(
|
||||
(element: HTMLElement | SVGElement): boolean => element.tagName === "SPAN",
|
||||
);
|
||||
const color = node.getCache(transformedColor);
|
||||
|
||||
if (extension) {
|
||||
extension.style.setProperty("background-color", color);
|
||||
return false;
|
||||
}
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.style.setProperty("background-color", color);
|
||||
node.range.toDOMRange().surroundContents(span);
|
||||
return true;
|
||||
}
|
||||
|
||||
const format: SurroundFormat<string> = {
|
||||
matcher,
|
||||
merger,
|
||||
formatter,
|
||||
};
|
||||
|
||||
const namedFormat: RemoveFormat<string> = {
|
||||
name: tr.editingTextHighlightColor(),
|
||||
show: true,
|
||||
active: true,
|
||||
format,
|
||||
};
|
||||
|
||||
const { removeFormats } = editorToolbarContext.get();
|
||||
removeFormats.update((formats) => [...formats, namedFormat]);
|
||||
|
||||
const { focusedInput } = noteEditorContext.get();
|
||||
const surrounder = Surrounder.make();
|
||||
let disabled: boolean;
|
||||
|
||||
$: if (editingInputIsRichText($focusedInput)) {
|
||||
disabled = false;
|
||||
surrounder.richText = $focusedInput;
|
||||
} else {
|
||||
disabled = true;
|
||||
surrounder.disable();
|
||||
}
|
||||
|
||||
function setTextColor(): void {
|
||||
surrounder.overwriteSurround(format);
|
||||
}
|
||||
</script>
|
||||
|
||||
<WithColorHelper {color} let:colorHelperIcon let:setColor>
|
||||
<IconButton
|
||||
tooltip={tr.editingTextHighlightColor()}
|
||||
{disabled}
|
||||
on:click={setTextColor}
|
||||
>
|
||||
{@html highlightColorIcon}
|
||||
{@html colorHelperIcon}
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
tooltip={tr.editingChangeColor()}
|
||||
{disabled}
|
||||
widthMultiplier={0.5}
|
||||
--border-right-radius="5px"
|
||||
>
|
||||
{@html arrowIcon}
|
||||
<ColorPicker
|
||||
on:change={(event) => {
|
||||
color = setColor(event);
|
||||
bridgeCommand(`lastHighlightColor:${color}`);
|
||||
setTextColor();
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</WithColorHelper>
|
58
ts/editor/editor-toolbar/InlineButtons.svelte
Normal file
58
ts/editor/editor-toolbar/InlineButtons.svelte
Normal file
@ -0,0 +1,58 @@
|
||||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import ButtonGroup from "../../components/ButtonGroup.svelte";
|
||||
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
|
||||
import Item from "../../components/Item.svelte";
|
||||
import BoldButton from "./BoldButton.svelte";
|
||||
import HighlightColorButton from "./HighlightColorButton.svelte";
|
||||
import ItalicButton from "./ItalicButton.svelte";
|
||||
import RemoveFormatButton from "./RemoveFormatButton.svelte";
|
||||
import SubscriptButton from "./SubscriptButton.svelte";
|
||||
import SuperscriptButton from "./SuperscriptButton.svelte";
|
||||
import TextColorButton from "./TextColorButton.svelte";
|
||||
import UnderlineButton from "./UnderlineButton.svelte";
|
||||
|
||||
export let api = {};
|
||||
|
||||
let textColor: string = "black";
|
||||
let highlightColor: string = "black";
|
||||
export function setColorButtons([textClr, highlightClr]: [string, string]): void {
|
||||
textColor = textClr;
|
||||
highlightColor = highlightClr;
|
||||
}
|
||||
|
||||
Object.assign(globalThis, { setColorButtons });
|
||||
</script>
|
||||
|
||||
<DynamicallySlottable slotHost={Item} {api}>
|
||||
<Item>
|
||||
<ButtonGroup>
|
||||
<BoldButton --border-left-radius="5px" />
|
||||
<ItalicButton />
|
||||
<UnderlineButton --border-right-radius="5px" />
|
||||
</ButtonGroup>
|
||||
</Item>
|
||||
|
||||
<Item>
|
||||
<ButtonGroup>
|
||||
<SuperscriptButton --border-left-radius="5px" />
|
||||
<SubscriptButton --border-right-radius="5px" />
|
||||
</ButtonGroup>
|
||||
</Item>
|
||||
|
||||
<Item>
|
||||
<ButtonGroup>
|
||||
<TextColorButton color={textColor} />
|
||||
<HighlightColorButton color={highlightColor} />
|
||||
</ButtonGroup>
|
||||
</Item>
|
||||
|
||||
<Item>
|
||||
<ButtonGroup>
|
||||
<RemoveFormatButton />
|
||||
</ButtonGroup>
|
||||
</Item>
|
||||
</DynamicallySlottable>
|
@ -6,57 +6,66 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import Shortcut from "../../components/Shortcut.svelte";
|
||||
import WithState from "../../components/WithState.svelte";
|
||||
import { MatchResult } from "../../domlib/surround";
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import type { RichTextInputAPI } from "../rich-text-input";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { getSurrounder } from "../surround";
|
||||
import { removeEmptyStyle, Surrounder } from "../surround";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { italicIcon } from "./icons";
|
||||
|
||||
function matchItalic(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
|
||||
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
|
||||
return MatchResult.NO_MATCH;
|
||||
}
|
||||
const surroundElement = document.createElement("em");
|
||||
|
||||
function matcher(element: HTMLElement | SVGElement, match: MatchType): void {
|
||||
if (element.tagName === "I" || element.tagName === "EM") {
|
||||
return MatchResult.MATCH;
|
||||
return match.remove();
|
||||
}
|
||||
|
||||
if (["italic", "oblique"].includes(element.style.fontStyle)) {
|
||||
return MatchResult.KEEP;
|
||||
return match.clear((): void => {
|
||||
element.style.removeProperty("font-style");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
return match.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return MatchResult.NO_MATCH;
|
||||
}
|
||||
const format = {
|
||||
surroundElement,
|
||||
matcher,
|
||||
};
|
||||
|
||||
function clearItalic(element: Element): boolean {
|
||||
const htmlElement = element as HTMLElement | SVGElement;
|
||||
htmlElement.style.removeProperty("font-style");
|
||||
const namedFormat = {
|
||||
name: tr.editingItalicText(),
|
||||
show: true,
|
||||
active: true,
|
||||
format,
|
||||
};
|
||||
|
||||
if (htmlElement.style.cssText.length === 0) {
|
||||
htmlElement.removeAttribute("style");
|
||||
}
|
||||
|
||||
return !htmlElement.hasAttribute("style") && element.className.length === 0;
|
||||
}
|
||||
const { removeFormats } = editorToolbarContext.get();
|
||||
removeFormats.update((formats) => [...formats, namedFormat]);
|
||||
|
||||
const { focusedInput } = noteEditorContext.get();
|
||||
const surrounder = Surrounder.make();
|
||||
let disabled: boolean;
|
||||
|
||||
$: input = $focusedInput as RichTextInputAPI;
|
||||
$: disabled = !editingInputIsRichText($focusedInput);
|
||||
$: surrounder = disabled ? null : getSurrounder(input);
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return disabled
|
||||
? Promise.resolve(false)
|
||||
: surrounder!.isSurrounded(matchItalic);
|
||||
$: if (editingInputIsRichText($focusedInput)) {
|
||||
surrounder.richText = $focusedInput;
|
||||
disabled = false;
|
||||
} else {
|
||||
surrounder.disable();
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(format);
|
||||
}
|
||||
|
||||
const element = document.createElement("em");
|
||||
function makeItalic(): void {
|
||||
surrounder!.surroundCommand(element, matchItalic, clearItalic);
|
||||
surrounder.surround(format);
|
||||
}
|
||||
|
||||
const keyCombination = "Control+I";
|
||||
|
@ -72,9 +72,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
{#each dropdownItems as [callback, keyCombination, label]}
|
||||
<DropdownItem on:click={callback}>
|
||||
{label}
|
||||
<span class="ps-1 float-end">{getPlatformString(keyCombination)}</span>
|
||||
<span class="ms-auto ps-2 shortcut"
|
||||
>{getPlatformString(keyCombination)}</span
|
||||
>
|
||||
</DropdownItem>
|
||||
<Shortcut {keyCombination} on:action={callback} />
|
||||
{/each}
|
||||
</DropdownMenu>
|
||||
</WithDropdown>
|
||||
|
||||
<style lang="scss">
|
||||
.shortcut {
|
||||
font: Verdana;
|
||||
}
|
||||
</style>
|
||||
|
135
ts/editor/editor-toolbar/RemoveFormatButton.svelte
Normal file
135
ts/editor/editor-toolbar/RemoveFormatButton.svelte
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
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Checkbox from "../../components/CheckBox.svelte";
|
||||
import DropdownItem from "../../components/DropdownItem.svelte";
|
||||
import DropdownMenu from "../../components/DropdownMenu.svelte";
|
||||
import { withButton } from "../../components/helpers";
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import Shortcut from "../../components/Shortcut.svelte";
|
||||
import WithDropdown from "../../components/WithDropdown.svelte";
|
||||
import type { SurroundFormat } from "../../domlib/surround";
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { altPressed } from "../../lib/keys";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { Surrounder } from "../surround";
|
||||
import type { RemoveFormat } from "./EditorToolbar.svelte";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { eraserIcon } from "./icons";
|
||||
import { arrowIcon } from "./icons";
|
||||
|
||||
const { focusedInput } = noteEditorContext.get();
|
||||
const surrounder = Surrounder.make();
|
||||
let disabled: boolean;
|
||||
|
||||
$: if (editingInputIsRichText($focusedInput)) {
|
||||
surrounder.richText = $focusedInput;
|
||||
disabled = false;
|
||||
} else {
|
||||
surrounder.disable();
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
const { removeFormats } = editorToolbarContext.get();
|
||||
|
||||
removeFormats.update((formats) =>
|
||||
formats.concat({
|
||||
name: "simple spans",
|
||||
show: false,
|
||||
active: true,
|
||||
format: {
|
||||
matcher: (
|
||||
element: HTMLElement | SVGElement,
|
||||
match: MatchType<never>,
|
||||
): void => {
|
||||
if (
|
||||
element.tagName === "SPAN" &&
|
||||
element.className.length === 0 &&
|
||||
element.style.cssText.length === 0
|
||||
) {
|
||||
match.remove();
|
||||
}
|
||||
},
|
||||
surroundElement: document.createElement("span"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let activeFormats: SurroundFormat<any>[];
|
||||
$: activeFormats = $removeFormats
|
||||
.filter((format) => format.active)
|
||||
.map((format) => format.format);
|
||||
|
||||
let inactiveFormats: SurroundFormat<any>[];
|
||||
$: inactiveFormats = $removeFormats
|
||||
.filter((format) => !format.active)
|
||||
.map((format) => format.format);
|
||||
|
||||
let showFormats: RemoveFormat<any>[];
|
||||
$: showFormats = $removeFormats.filter((format) => format.show);
|
||||
|
||||
function remove(): void {
|
||||
surrounder.remove(activeFormats, inactiveFormats);
|
||||
}
|
||||
|
||||
function onItemClick<T>(event: MouseEvent, format: RemoveFormat<T>): void {
|
||||
if (altPressed(event)) {
|
||||
for (const format of showFormats) {
|
||||
format.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
format.active = !format.active;
|
||||
$removeFormats = $removeFormats;
|
||||
}
|
||||
|
||||
const keyCombination = "Control+R";
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
tooltip="{tr.editingRemoveFormatting()} ({getPlatformString(keyCombination)})"
|
||||
{disabled}
|
||||
on:click={remove}
|
||||
--border-left-radius="5px"
|
||||
>
|
||||
{@html eraserIcon}
|
||||
</IconButton>
|
||||
|
||||
<Shortcut {keyCombination} on:action={remove} />
|
||||
|
||||
<div class="hide-after">
|
||||
<WithDropdown autoClose="outside" let:createDropdown --border-right-radius="5px">
|
||||
<IconButton
|
||||
tooltip={tr.editingSelectRemoveFormatting()}
|
||||
{disabled}
|
||||
widthMultiplier={0.5}
|
||||
on:mount={withButton(createDropdown)}
|
||||
>
|
||||
{@html arrowIcon}
|
||||
</IconButton>
|
||||
|
||||
<DropdownMenu on:mousedown={(event) => event.preventDefault()}>
|
||||
{#each showFormats as format (format.name)}
|
||||
<DropdownItem on:click={(event) => onItemClick(event, format)}>
|
||||
<Checkbox bind:value={format.active} />
|
||||
<span class="d-flex-inline ps-3">{format.name}</span>
|
||||
</DropdownItem>
|
||||
{/each}
|
||||
</DropdownMenu>
|
||||
</WithDropdown>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.hide-after {
|
||||
display: contents;
|
||||
|
||||
:global(.dropdown-toggle::after) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
107
ts/editor/editor-toolbar/SubscriptButton.svelte
Normal file
107
ts/editor/editor-toolbar/SubscriptButton.svelte
Normal file
@ -0,0 +1,107 @@
|
||||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script context="module" lang="ts">
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import { removeEmptyStyle } from "../surround";
|
||||
|
||||
const surroundElement = document.createElement("sub");
|
||||
|
||||
export function matcher(element: HTMLElement | SVGElement, match: MatchType): void {
|
||||
if (element.tagName === "SUB") {
|
||||
return match.remove();
|
||||
}
|
||||
|
||||
if (element.style.verticalAlign === "sub") {
|
||||
return match.clear((): void => {
|
||||
element.style.removeProperty("vertical-align");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
return match.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const format = {
|
||||
surroundElement,
|
||||
matcher,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import Shortcut from "../../components/Shortcut.svelte";
|
||||
import WithState from "../../components/WithState.svelte";
|
||||
import { updateStateByKey } from "../../components/WithState.svelte";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { Surrounder } from "../surround";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { subscriptIcon } from "./icons";
|
||||
import { format as superscript } from "./SuperscriptButton.svelte";
|
||||
|
||||
const namedFormat = {
|
||||
name: tr.editingSubscript(),
|
||||
show: true,
|
||||
active: true,
|
||||
format,
|
||||
};
|
||||
|
||||
const { removeFormats } = editorToolbarContext.get();
|
||||
removeFormats.update((formats) => [...formats, namedFormat]);
|
||||
|
||||
const { focusedInput } = noteEditorContext.get();
|
||||
const surrounder = Surrounder.make();
|
||||
let disabled: boolean;
|
||||
|
||||
$: if (editingInputIsRichText($focusedInput)) {
|
||||
surrounder.richText = $focusedInput;
|
||||
disabled = false;
|
||||
} else {
|
||||
surrounder.disable();
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(format);
|
||||
}
|
||||
|
||||
function makeSub(): void {
|
||||
surrounder.surround(format, [superscript]);
|
||||
}
|
||||
|
||||
const keyCombination = "Control+Shift+=";
|
||||
</script>
|
||||
|
||||
<WithState
|
||||
key="sub"
|
||||
update={updateStateFromActiveInput}
|
||||
let:state={active}
|
||||
let:updateState
|
||||
>
|
||||
<IconButton
|
||||
tooltip="{tr.editingSubscript()} ({getPlatformString(keyCombination)})"
|
||||
{active}
|
||||
{disabled}
|
||||
on:click={(event) => {
|
||||
makeSub();
|
||||
updateState(event);
|
||||
updateStateByKey("super", event);
|
||||
}}
|
||||
>
|
||||
{@html subscriptIcon}
|
||||
</IconButton>
|
||||
|
||||
<Shortcut
|
||||
{keyCombination}
|
||||
on:action={(event) => {
|
||||
makeSub();
|
||||
updateState(event);
|
||||
updateStateByKey("super", event);
|
||||
}}
|
||||
/>
|
||||
</WithState>
|
107
ts/editor/editor-toolbar/SuperscriptButton.svelte
Normal file
107
ts/editor/editor-toolbar/SuperscriptButton.svelte
Normal file
@ -0,0 +1,107 @@
|
||||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script context="module" lang="ts">
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import { removeEmptyStyle } from "../surround";
|
||||
|
||||
const surroundElement = document.createElement("sup");
|
||||
|
||||
export function matcher(element: HTMLElement | SVGElement, match: MatchType): void {
|
||||
if (element.tagName === "SUP") {
|
||||
return match.remove();
|
||||
}
|
||||
|
||||
if (element.style.verticalAlign === "super") {
|
||||
return match.clear((): void => {
|
||||
element.style.removeProperty("vertical-align");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
return match.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const format = {
|
||||
surroundElement,
|
||||
matcher,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import Shortcut from "../../components/Shortcut.svelte";
|
||||
import WithState from "../../components/WithState.svelte";
|
||||
import { updateStateByKey } from "../../components/WithState.svelte";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { Surrounder } from "../surround";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { superscriptIcon } from "./icons";
|
||||
import { format as subscript } from "./SubscriptButton.svelte";
|
||||
|
||||
const namedFormat = {
|
||||
name: tr.editingSuperscript(),
|
||||
show: true,
|
||||
active: true,
|
||||
format,
|
||||
};
|
||||
|
||||
const { removeFormats } = editorToolbarContext.get();
|
||||
removeFormats.update((formats) => [...formats, namedFormat]);
|
||||
|
||||
const { focusedInput } = noteEditorContext.get();
|
||||
const surrounder = Surrounder.make();
|
||||
let disabled: boolean;
|
||||
|
||||
$: if (editingInputIsRichText($focusedInput)) {
|
||||
surrounder.richText = $focusedInput;
|
||||
disabled = false;
|
||||
} else {
|
||||
surrounder.disable();
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(format);
|
||||
}
|
||||
|
||||
function makeSuper(): void {
|
||||
surrounder.surround(format, [subscript]);
|
||||
}
|
||||
|
||||
const keyCombination = "Control+=";
|
||||
</script>
|
||||
|
||||
<WithState
|
||||
key="super"
|
||||
update={updateStateFromActiveInput}
|
||||
let:state={active}
|
||||
let:updateState
|
||||
>
|
||||
<IconButton
|
||||
tooltip="{tr.editingSuperscript()} ({getPlatformString(keyCombination)})"
|
||||
{active}
|
||||
{disabled}
|
||||
on:click={(event) => {
|
||||
makeSuper();
|
||||
updateState(event);
|
||||
updateStateByKey("sub", event);
|
||||
}}
|
||||
>
|
||||
{@html superscriptIcon}
|
||||
</IconButton>
|
||||
|
||||
<Shortcut
|
||||
{keyCombination}
|
||||
on:action={(event) => {
|
||||
makeSuper();
|
||||
updateState(event);
|
||||
updateStateByKey("sub", event);
|
||||
}}
|
||||
/>
|
||||
</WithState>
|
@ -23,12 +23,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
const { focusedInput } = context.get();
|
||||
|
||||
const attachmentKeyCombination = "F7";
|
||||
const attachmentKeyCombination = "F3";
|
||||
function onAttachment(): void {
|
||||
bridgeCommand("attach");
|
||||
}
|
||||
|
||||
const recordKeyCombination = "F8";
|
||||
const recordKeyCombination = "F5";
|
||||
function onRecord(): void {
|
||||
bridgeCommand("record");
|
||||
}
|
||||
|
164
ts/editor/editor-toolbar/TextColorButton.svelte
Normal file
164
ts/editor/editor-toolbar/TextColorButton.svelte
Normal file
@ -0,0 +1,164 @@
|
||||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import ColorPicker from "../../components/ColorPicker.svelte";
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import Shortcut from "../../components/Shortcut.svelte";
|
||||
import type {
|
||||
FormattingNode,
|
||||
MatchType,
|
||||
SurroundFormat,
|
||||
} from "../../domlib/surround";
|
||||
import { bridgeCommand } from "../../lib/bridgecommand";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { withFontColor } from "../helpers";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { removeEmptyStyle, Surrounder } from "../surround";
|
||||
import type { RemoveFormat } from "./EditorToolbar.svelte";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { arrowIcon, textColorIcon } from "./icons";
|
||||
import WithColorHelper from "./WithColorHelper.svelte";
|
||||
|
||||
export let color: string;
|
||||
|
||||
$: transformedColor = transformColor(color);
|
||||
|
||||
/**
|
||||
* The DOM will transform colors such as "#ff0000" to "rgb(255, 0, 0)".
|
||||
*/
|
||||
function transformColor(color: string): string {
|
||||
const span = document.createElement("span");
|
||||
span.style.setProperty("color", color);
|
||||
return span.style.getPropertyValue("color");
|
||||
}
|
||||
|
||||
function matcher(
|
||||
element: HTMLElement | SVGElement,
|
||||
match: MatchType<string>,
|
||||
): void {
|
||||
if (
|
||||
withFontColor(element, (color: string): void => {
|
||||
if (color) {
|
||||
match.setCache(color);
|
||||
match.remove();
|
||||
}
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = element.style.getPropertyValue("color");
|
||||
|
||||
if (value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
match.setCache(value);
|
||||
match.clear((): void => {
|
||||
element.style.removeProperty("color");
|
||||
|
||||
if (removeEmptyStyle(element) && element.className.length === 0) {
|
||||
match.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function merger(
|
||||
before: FormattingNode<string>,
|
||||
after: FormattingNode<string>,
|
||||
): boolean {
|
||||
return before.getCache(transformedColor) === after.getCache(transformedColor);
|
||||
}
|
||||
|
||||
function formatter(node: FormattingNode<string>): boolean {
|
||||
const extension = node.extensions.find(
|
||||
(element: HTMLElement | SVGElement): boolean => element.tagName === "SPAN",
|
||||
);
|
||||
const color = node.getCache(transformedColor);
|
||||
|
||||
if (extension) {
|
||||
extension.style.setProperty("color", color);
|
||||
return false;
|
||||
}
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.style.setProperty("color", color);
|
||||
node.range.toDOMRange().surroundContents(span);
|
||||
return true;
|
||||
}
|
||||
|
||||
const format: SurroundFormat<string> = {
|
||||
matcher,
|
||||
merger,
|
||||
formatter,
|
||||
};
|
||||
|
||||
const namedFormat: RemoveFormat<string> = {
|
||||
name: tr.editingTextColor(),
|
||||
show: true,
|
||||
active: true,
|
||||
format,
|
||||
};
|
||||
|
||||
const { removeFormats } = editorToolbarContext.get();
|
||||
removeFormats.update((formats) => [...formats, namedFormat]);
|
||||
|
||||
const { focusedInput } = noteEditorContext.get();
|
||||
const surrounder = Surrounder.make();
|
||||
let disabled: boolean;
|
||||
|
||||
$: if (editingInputIsRichText($focusedInput)) {
|
||||
surrounder.richText = $focusedInput;
|
||||
disabled = false;
|
||||
} else {
|
||||
surrounder.disable();
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
function setTextColor(): void {
|
||||
surrounder.overwriteSurround(format);
|
||||
}
|
||||
|
||||
const setCombination = "F7";
|
||||
const pickCombination = "F8";
|
||||
</script>
|
||||
|
||||
<WithColorHelper {color} let:colorHelperIcon let:setColor>
|
||||
<IconButton
|
||||
tooltip="{tr.editingTextColor()} ({getPlatformString(setCombination)})"
|
||||
{disabled}
|
||||
on:click={setTextColor}
|
||||
--border-left-radius="5px"
|
||||
>
|
||||
{@html textColorIcon}
|
||||
{@html colorHelperIcon}
|
||||
</IconButton>
|
||||
<Shortcut keyCombination={setCombination} on:action={setTextColor} />
|
||||
|
||||
<IconButton
|
||||
tooltip="{tr.editingChangeColor()} ({getPlatformString(pickCombination)})"
|
||||
{disabled}
|
||||
widthMultiplier={0.5}
|
||||
>
|
||||
{@html arrowIcon}
|
||||
<ColorPicker
|
||||
on:change={(event) => {
|
||||
color = setColor(event);
|
||||
bridgeCommand(`lastTextColor:${color}`);
|
||||
setTextColor();
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<Shortcut
|
||||
keyCombination={pickCombination}
|
||||
on:action={(event) => {
|
||||
color = setColor(event);
|
||||
bridgeCommand(`lastTextColor:${color}`);
|
||||
setTextColor();
|
||||
}}
|
||||
/>
|
||||
</WithColorHelper>
|
@ -6,42 +6,59 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
import IconButton from "../../components/IconButton.svelte";
|
||||
import Shortcut from "../../components/Shortcut.svelte";
|
||||
import WithState from "../../components/WithState.svelte";
|
||||
import { MatchResult } from "../../domlib/surround";
|
||||
import type { MatchType } from "../../domlib/surround";
|
||||
import * as tr from "../../lib/ftl";
|
||||
import { getPlatformString } from "../../lib/shortcuts";
|
||||
import { context } from "../NoteEditor.svelte";
|
||||
import type { RichTextInputAPI } from "../rich-text-input";
|
||||
import { context as noteEditorContext } from "../NoteEditor.svelte";
|
||||
import { editingInputIsRichText } from "../rich-text-input";
|
||||
import { getSurrounder } from "../surround";
|
||||
import { Surrounder } from "../surround";
|
||||
import { context as editorToolbarContext } from "./EditorToolbar.svelte";
|
||||
import { underlineIcon } from "./icons";
|
||||
|
||||
function matchUnderline(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
|
||||
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
|
||||
return MatchResult.NO_MATCH;
|
||||
}
|
||||
const surroundElement = document.createElement("u");
|
||||
|
||||
function matcher(element: HTMLElement | SVGElement, match: MatchType): void {
|
||||
if (element.tagName === "U") {
|
||||
return MatchResult.MATCH;
|
||||
return match.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return MatchResult.NO_MATCH;
|
||||
const clearer = () => false;
|
||||
|
||||
const format = {
|
||||
surroundElement,
|
||||
matcher,
|
||||
clearer,
|
||||
};
|
||||
|
||||
const namedFormat = {
|
||||
name: tr.editingUnderlineText(),
|
||||
show: true,
|
||||
active: true,
|
||||
format,
|
||||
};
|
||||
|
||||
const { removeFormats } = editorToolbarContext.get();
|
||||
removeFormats.update((formats) => [...formats, namedFormat]);
|
||||
|
||||
const { focusedInput } = noteEditorContext.get();
|
||||
const surrounder = Surrounder.make();
|
||||
let disabled: boolean;
|
||||
|
||||
$: if (editingInputIsRichText($focusedInput)) {
|
||||
surrounder.richText = $focusedInput;
|
||||
disabled = false;
|
||||
} else {
|
||||
surrounder.disable();
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
const { focusedInput } = context.get();
|
||||
|
||||
$: input = $focusedInput as RichTextInputAPI;
|
||||
$: disabled = !editingInputIsRichText($focusedInput);
|
||||
$: surrounder = disabled ? null : getSurrounder(input);
|
||||
|
||||
function updateStateFromActiveInput(): Promise<boolean> {
|
||||
return disabled
|
||||
? Promise.resolve(false)
|
||||
: surrounder!.isSurrounded(matchUnderline);
|
||||
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(format);
|
||||
}
|
||||
|
||||
const element = document.createElement("u");
|
||||
function makeUnderline(): void {
|
||||
surrounder!.surroundCommand(element, matchUnderline);
|
||||
surrounder.surround(format);
|
||||
}
|
||||
|
||||
const keyCombination = "Control+U";
|
||||
|
@ -12,7 +12,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style={`--color-helper-color: ${color}`}>
|
||||
<div style="--color-helper-color: {color}">
|
||||
<slot {colorHelperIcon} {setColor} />
|
||||
</div>
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
/// trivial wrapper to silence Svelte deprecation warnings
|
||||
/**
|
||||
* Trivial wrapper to silence Svelte deprecation warnings
|
||||
*/
|
||||
export function execCommand(
|
||||
command: string,
|
||||
showUI?: boolean | undefined,
|
||||
@ -10,7 +12,28 @@ export function execCommand(
|
||||
document.execCommand(command, showUI, value);
|
||||
}
|
||||
|
||||
/// trivial wrapper to silence Svelte deprecation warnings
|
||||
/**
|
||||
* Trivial wrappers to silence Svelte deprecation warnings
|
||||
*/
|
||||
export function queryCommandState(command: string): boolean {
|
||||
return document.queryCommandState(command);
|
||||
}
|
||||
|
||||
function isFontElement(element: Element): element is HTMLFontElement {
|
||||
return element.tagName === "FONT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Avoid both HTMLFontElement and .color, as they are both deprecated
|
||||
*/
|
||||
export function withFontColor(
|
||||
element: Element,
|
||||
callback: (color: string) => void,
|
||||
): boolean {
|
||||
if (isFontElement(element)) {
|
||||
callback(element.color);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
@ -79,8 +79,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
const { content, editingInputs } = editingAreaContext.get();
|
||||
const decoratedElements = decoratedElementsContext.get();
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
function normalizeFragment(fragment: DocumentFragment): void {
|
||||
fragment.normalize();
|
||||
|
||||
@ -110,8 +108,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
}
|
||||
|
||||
function writeFromEditingArea(html: string): void {
|
||||
/* we need createContextualFragment so that customElements are initialized */
|
||||
const fragment = range.createContextualFragment(adjustInputHTML(html));
|
||||
/* We need .createContextualFragment so that customElements are initialized */
|
||||
const fragment = document
|
||||
.createRange()
|
||||
.createContextualFragment(adjustInputHTML(html));
|
||||
adjustInputFragment(fragment);
|
||||
nodes.setUnprocessed(fragment);
|
||||
}
|
||||
|
@ -3,15 +3,19 @@
|
||||
|
||||
import { get } from "svelte/store";
|
||||
|
||||
import type { ElementClearer, ElementMatcher } from "../domlib/surround";
|
||||
import { findClosest, surroundNoSplitting, unsurround } from "../domlib/surround";
|
||||
import type { Matcher } from "../domlib/find-above";
|
||||
import { findClosest } from "../domlib/find-above";
|
||||
import type { SurroundFormat } from "../domlib/surround";
|
||||
import { boolMatcher, reformat, surround, unsurround } from "../domlib/surround";
|
||||
import { getRange, getSelection } from "../lib/cross-browser";
|
||||
import { registerPackage } from "../lib/runtime-require";
|
||||
import type { OnInsertCallback, Trigger } from "../sveltelib/input-manager";
|
||||
import type { RichTextInputAPI } from "./rich-text-input";
|
||||
|
||||
function isSurroundedInner(
|
||||
range: AbstractRange,
|
||||
base: HTMLElement,
|
||||
matcher: ElementMatcher,
|
||||
matcher: Matcher,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
findClosest(range.startContainer, base, matcher) ||
|
||||
@ -19,37 +23,155 @@ function isSurroundedInner(
|
||||
);
|
||||
}
|
||||
|
||||
function surroundAndSelect(
|
||||
function surroundAndSelect<T>(
|
||||
matches: boolean,
|
||||
range: Range,
|
||||
selection: Selection,
|
||||
surroundElement: Element,
|
||||
base: HTMLElement,
|
||||
matcher: ElementMatcher,
|
||||
clearer: ElementClearer,
|
||||
format: SurroundFormat<T>,
|
||||
selection: Selection,
|
||||
): void {
|
||||
const { surroundedRange } = matches
|
||||
? unsurround(range, surroundElement, base, matcher, clearer)
|
||||
: surroundNoSplitting(range, surroundElement, base, matcher, clearer);
|
||||
const surroundedRange = matches
|
||||
? unsurround(range, base, format)
|
||||
: surround(range, base, format);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(surroundedRange);
|
||||
}
|
||||
|
||||
export interface GetSurrounderResult {
|
||||
surroundCommand(
|
||||
surroundElement: Element,
|
||||
matcher: ElementMatcher,
|
||||
clearer?: ElementClearer,
|
||||
): Promise<void>;
|
||||
isSurrounded(matcher: ElementMatcher): Promise<boolean>;
|
||||
function removeFormats(
|
||||
range: Range,
|
||||
base: Element,
|
||||
formats: SurroundFormat[],
|
||||
reformats: SurroundFormat[] = [],
|
||||
): Range {
|
||||
let surroundRange = range;
|
||||
|
||||
for (const format of formats) {
|
||||
surroundRange = unsurround(surroundRange, base, format);
|
||||
}
|
||||
|
||||
for (const format of reformats) {
|
||||
surroundRange = reformat(surroundRange, base, format);
|
||||
}
|
||||
|
||||
return surroundRange;
|
||||
}
|
||||
|
||||
export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderResult {
|
||||
const { add, remove, active } = richTextInput.getTriggerOnNextInsert();
|
||||
export class Surrounder {
|
||||
static make(): Surrounder {
|
||||
return new Surrounder();
|
||||
}
|
||||
|
||||
async function isSurrounded(matcher: ElementMatcher): Promise<boolean> {
|
||||
const base = await richTextInput.element;
|
||||
private api: RichTextInputAPI | null = null;
|
||||
private trigger: Trigger<OnInsertCallback> | null = null;
|
||||
|
||||
set richText(api: RichTextInputAPI) {
|
||||
this.api = api;
|
||||
this.trigger = api.getTriggerOnNextInsert();
|
||||
}
|
||||
|
||||
/**
|
||||
* After calling disable, using any of the surrounding methods will throw an
|
||||
* exception. Make sure to set the rich text before trying to use them again.
|
||||
*/
|
||||
disable(): void {
|
||||
this.api = null;
|
||||
this.trigger = null;
|
||||
}
|
||||
|
||||
private async _assert_base(): Promise<HTMLElement> {
|
||||
if (!this.api) {
|
||||
throw new Error("No rich text set");
|
||||
}
|
||||
|
||||
return await this.api.element;
|
||||
}
|
||||
|
||||
private _toggleTrigger<T>(
|
||||
base: HTMLElement,
|
||||
selection: Selection,
|
||||
matcher: Matcher,
|
||||
format: SurroundFormat<T>,
|
||||
exclusive: SurroundFormat<T>[] = [],
|
||||
): void {
|
||||
if (get(this.trigger!.active)) {
|
||||
this.trigger!.remove();
|
||||
} else {
|
||||
this.trigger!.add(async ({ node }: { node: Node }) => {
|
||||
const range = new Range();
|
||||
range.selectNode(node);
|
||||
|
||||
const matches = Boolean(findClosest(node, base, matcher));
|
||||
const clearedRange = removeFormats(range, base, exclusive);
|
||||
surroundAndSelect(matches, clearedRange, base, format, selection);
|
||||
selection.collapseToEnd();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the surround command on the current range of the RichTextInput.
|
||||
* If the range is already surrounded, it will unsurround instead.
|
||||
*/
|
||||
async surround<T>(
|
||||
format: SurroundFormat<T>,
|
||||
exclusive: SurroundFormat<T>[] = [],
|
||||
): Promise<void> {
|
||||
const base = await this._assert_base();
|
||||
const selection = getSelection(base)!;
|
||||
const range = getRange(selection);
|
||||
const matcher = boolMatcher(format);
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (range.collapsed) {
|
||||
return this._toggleTrigger(base, selection, matcher, format, exclusive);
|
||||
}
|
||||
|
||||
const clearedRange = removeFormats(range, base, exclusive);
|
||||
const matches = isSurroundedInner(clearedRange, base, matcher);
|
||||
surroundAndSelect(matches, clearedRange, base, format, selection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the surround command on the current range of the RichTextInput.
|
||||
* If the range is already surrounded, it will overwrite the format.
|
||||
* This might be better suited if the surrounding is parameterized (like
|
||||
* text color).
|
||||
*/
|
||||
async overwriteSurround<T>(
|
||||
format: SurroundFormat<T>,
|
||||
exclusive: SurroundFormat<T>[] = [],
|
||||
): Promise<void> {
|
||||
const base = await this._assert_base();
|
||||
const selection = getSelection(base)!;
|
||||
const range = getRange(selection);
|
||||
const matcher = boolMatcher(format);
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (range.collapsed) {
|
||||
return this._toggleTrigger(base, selection, matcher, format, exclusive);
|
||||
}
|
||||
|
||||
const clearedRange = removeFormats(range, base, exclusive);
|
||||
const surroundedRange = surround(clearedRange, base, format);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(surroundedRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current selection is surrounded. A selection will count as
|
||||
* provided if either the start or the end boundary point are within the
|
||||
* provided format, OR if a surround trigger is active (surround on next
|
||||
* text insert).
|
||||
*/
|
||||
async isSurrounded<T>(format: SurroundFormat<T>): Promise<boolean> {
|
||||
const base = await this._assert_base();
|
||||
const selection = getSelection(base)!;
|
||||
const range = getRange(selection);
|
||||
|
||||
@ -57,58 +179,44 @@ export function getSurrounder(richTextInput: RichTextInputAPI): GetSurrounderRes
|
||||
return false;
|
||||
}
|
||||
|
||||
const isSurrounded = isSurroundedInner(range, base, matcher);
|
||||
return get(active) ? !isSurrounded : isSurrounded;
|
||||
const isSurrounded = isSurroundedInner(range, base, boolMatcher(format));
|
||||
return get(this.trigger!.active) ? !isSurrounded : isSurrounded;
|
||||
}
|
||||
|
||||
async function surroundCommand(
|
||||
surroundElement: Element,
|
||||
matcher: ElementMatcher,
|
||||
clearer: ElementClearer = () => false,
|
||||
/**
|
||||
* Clear/Reformat the provided formats in the current range.
|
||||
*/
|
||||
async remove<T>(
|
||||
formats: SurroundFormat<T>[],
|
||||
reformats: SurroundFormat<T>[] = [],
|
||||
): Promise<void> {
|
||||
const base = await richTextInput.element;
|
||||
const base = await this._assert_base();
|
||||
const selection = getSelection(base)!;
|
||||
const range = getRange(selection);
|
||||
|
||||
if (!range) {
|
||||
if (!range || range.collapsed) {
|
||||
return;
|
||||
} else if (range.collapsed) {
|
||||
if (get(active)) {
|
||||
remove();
|
||||
} else {
|
||||
add(async ({ node }: { node: Node }) => {
|
||||
range.selectNode(node);
|
||||
|
||||
const matches = Boolean(findClosest(node, base, matcher));
|
||||
surroundAndSelect(
|
||||
matches,
|
||||
range,
|
||||
selection,
|
||||
surroundElement,
|
||||
base,
|
||||
matcher,
|
||||
clearer,
|
||||
);
|
||||
|
||||
selection.collapseToEnd();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const matches = isSurroundedInner(range, base, matcher);
|
||||
surroundAndSelect(
|
||||
matches,
|
||||
range,
|
||||
selection,
|
||||
surroundElement,
|
||||
base,
|
||||
matcher,
|
||||
clearer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
surroundCommand,
|
||||
isSurrounded,
|
||||
};
|
||||
const surroundedRange = removeFormats(range, base, formats, reformats);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(surroundedRange);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns True, if element has no style attribute (anymore).
|
||||
*/
|
||||
export function removeEmptyStyle(element: HTMLElement | SVGElement): boolean {
|
||||
if (element.style.cssText.length === 0) {
|
||||
element.removeAttribute("style");
|
||||
// Calling `.hasAttribute` right after `.removeAttribute` might return true.
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
registerPackage("anki/surround", {
|
||||
Surrounder,
|
||||
});
|
||||
|
@ -7,10 +7,22 @@ export function nodeIsElement(node: Node): node is Element {
|
||||
return node.nodeType === Node.ELEMENT_NODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* In the web this is probably equivalent to `nodeIsElement`, but this is
|
||||
* convenient to convince Typescript.
|
||||
*/
|
||||
export function nodeIsCommonElement(node: Node): node is HTMLElement | SVGElement {
|
||||
return node instanceof HTMLElement || node instanceof SVGElement;
|
||||
}
|
||||
|
||||
export function nodeIsText(node: Node): node is Text {
|
||||
return node.nodeType === Node.TEXT_NODE;
|
||||
}
|
||||
|
||||
export function nodeIsComment(node: Node): node is Comment {
|
||||
return node.nodeType === Node.COMMENT_NODE;
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
|
||||
export const BLOCK_ELEMENTS = [
|
||||
"ADDRESS",
|
||||
|
@ -56,6 +56,7 @@ const modifierPressed =
|
||||
|
||||
export const controlPressed = modifierPressed("Control");
|
||||
export const shiftPressed = modifierPressed("Shift");
|
||||
export const altPressed = modifierPressed("Alt");
|
||||
|
||||
export function modifiersToPlatformString(modifiers: string[]): string {
|
||||
const displayModifiers = isApplePlatform()
|
||||
|
Loading…
Reference in New Issue
Block a user