diff --git a/ftl/core/editing.ftl b/ftl/core/editing.ftl index 41084e61f..910669166 100644 --- a/ftl/core/editing.ftl +++ b/ftl/core/editing.ftl @@ -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 diff --git a/qt/aqt/data/web/css/reviewer-bottom.scss b/qt/aqt/data/web/css/reviewer-bottom.scss index 8271fad83..e124ce05f 100644 --- a/qt/aqt/data/web/css/reviewer-bottom.scss +++ b/qt/aqt/data/web/css/reviewer-bottom.scss @@ -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 & { diff --git a/ts/deck-options/CheckBox.svelte b/ts/components/CheckBox.svelte similarity index 57% rename from ts/deck-options/CheckBox.svelte rename to ts/components/CheckBox.svelte index cd707ed49..c88a5f5f4 100644 --- a/ts/deck-options/CheckBox.svelte +++ b/ts/components/CheckBox.svelte @@ -6,4 +6,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let value: boolean; - + + + diff --git a/ts/components/DropdownItem.svelte b/ts/components/DropdownItem.svelte index 376d382df..0c8325a5e 100644 --- a/ts/components/DropdownItem.svelte +++ b/ts/components/DropdownItem.svelte @@ -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 > @@ -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 { diff --git a/ts/components/WithState.svelte b/ts/components/WithState.svelte index 4f14af126..a4c058a4d 100644 --- a/ts/components/WithState.svelte +++ b/ts/components/WithState.svelte @@ -37,7 +37,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html updateAllStateWithCallback((): Promise => 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; diff --git a/ts/domlib/find-above.ts b/ts/domlib/find-above.ts new file mode 100644 index 000000000..4a7372701 --- /dev/null +++ b/ts/domlib/find-above.ts @@ -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; +} diff --git a/ts/domlib/location/document.ts b/ts/domlib/location/document.ts index fae485cce..918684b63 100644 --- a/ts/domlib/location/document.ts +++ b/ts/domlib/location/document.ts @@ -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); diff --git a/ts/domlib/location/index.ts b/ts/domlib/location/index.ts index 3434d5812..16552391a 100644 --- a/ts/domlib/location/index.ts +++ b/ts/domlib/location/index.ts @@ -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"; diff --git a/ts/domlib/location/location.ts b/ts/domlib/location/location.ts index f683ac3f2..7c1c1dc27 100644 --- a/ts/domlib/location/location.ts +++ b/ts/domlib/location/location.ts @@ -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, diff --git a/ts/domlib/location/range.ts b/ts/domlib/location/range.ts index 8bf02c001..fe94617fe 100644 --- a/ts/domlib/location/range.ts +++ b/ts/domlib/location/range.ts @@ -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; diff --git a/ts/domlib/surround/apply/format.ts b/ts/domlib/surround/apply/format.ts new file mode 100644 index 000000000..3f3b470e7 --- /dev/null +++ b/ts/domlib/surround/apply/format.ts @@ -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 { + constructor(protected readonly format: SurroundFormat) {} + + applyFormat(node: FormattingNode): 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 extends ApplyFormat { + applyFormat(node: FormattingNode): boolean { + if (node.insideRange) { + return false; + } + + return super.applyFormat(node); + } +} + +export class ReformatApplyFormat extends ApplyFormat { + applyFormat(node: FormattingNode): boolean { + if (!node.hasMatch) { + return false; + } + + return super.applyFormat(node); + } +} diff --git a/ts/domlib/surround/apply/index.ts b/ts/domlib/surround/apply/index.ts new file mode 100644 index 000000000..c46908a78 --- /dev/null +++ b/ts/domlib/surround/apply/index.ts @@ -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(node: TreeNode, format: ApplyFormat, 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( + node: FormattingNode, + format: ApplyFormat, + 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(nodes: TreeNode[], format: ApplyFormat): void { + let innerShift = 0; + + for (const node of nodes) { + innerShift += iterate(node, format, innerShift); + } +} + +export { ApplyFormat, ReformatApplyFormat, UnsurroundApplyFormat } from "./format"; diff --git a/ts/domlib/surround/ascend.ts b/ts/domlib/surround/ascend.ts deleted file mode 100644 index 02ed8b4c0..000000000 --- a/ts/domlib/surround/ascend.ts +++ /dev/null @@ -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; -} diff --git a/ts/domlib/surround/build/add-merge.ts b/ts/domlib/surround/build/add-merge.ts new file mode 100644 index 000000000..00b3fb1d6 --- /dev/null +++ b/ts/domlib/surround/build/add-merge.ts @@ -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( + initial: TreeNode[], + last: FormattingNode, + format: BuildFormat, +): TreeNode[] { + const minimized: TreeNode[] = [last]; + + for (let i = initial.length - 1; i >= 0; i--) { + const next = initial[i]; + + let merged: FormattingNode | 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( + initial: TreeNode[], + last: TreeNode, + format: BuildFormat, +): TreeNode[] { + if (last instanceof FormattingNode) { + return mergeAppendNode(initial, last, format); + } else { + return [...initial, last]; + } +} + +function mergeInsertNode( + first: FormattingNode, + tail: TreeNode[], + format: BuildFormat, +): TreeNode[] { + const minimized: TreeNode[] = [first]; + + for (let i = 0; i <= tail.length; i++) { + const next = tail[i]; + + let merged: FormattingNode | 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( + first: TreeNode, + tail: TreeNode[], + format: BuildFormat, +): TreeNode[] { + if (first instanceof FormattingNode) { + return mergeInsertNode(first, tail, format); + } else { + return [first, ...tail]; + } +} diff --git a/ts/domlib/surround/build/build-tree.ts b/ts/domlib/surround/build/build-tree.ts new file mode 100644 index 000000000..ace00d8af --- /dev/null +++ b/ts/domlib/surround/build/build-tree.ts @@ -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( + element: Element, + format: BuildFormat, + matchAncestors: Match[], +): 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( + text: Text, + format: BuildFormat, + matchAncestors: Match[], +): FormattingNode | 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( + node: Node, + format: BuildFormat, + matchAncestors: Match[], +): TreeNode[] { + if (nodeIsText(node) && !textIsNegligible(node)) { + return [buildFromText(node, format, matchAncestors)]; + } else if (nodeIsElement(node) && !elementIsNegligible(node)) { + return buildFromElement(node, format, matchAncestors); + } else { + return []; + } +} diff --git a/ts/domlib/surround/build/extend-merge.ts b/ts/domlib/surround/build/extend-merge.ts new file mode 100644 index 000000000..a39a53645 --- /dev/null +++ b/ts/domlib/surround/build/extend-merge.ts @@ -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(forest: TreeNode[], format: BuildFormat): 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(forest: TreeNode[], format: BuildFormat): 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( + forest: TreeNode[], + format: BuildFormat, +): 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; +} diff --git a/ts/domlib/surround/build/format.ts b/ts/domlib/surround/build/format.ts new file mode 100644 index 000000000..414d2b2bc --- /dev/null +++ b/ts/domlib/surround/build/format.ts @@ -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 { + constructor( + public readonly format: SurroundFormat, + public readonly base: Element, + public readonly range: Range, + public readonly splitRange: SplitRange, + ) {} + + createMatch(element: Element): Match { + const match = new Match(); + this.format.matcher(element as HTMLElement | SVGElement, match); + return match; + } + + tryMerge( + before: FormattingNode, + after: FormattingNode, + ): FormattingNode | null { + if (!this.format.merger || this.format.merger(before, after)) { + return FormattingNode.merge(before, after); + } + + return null; + } + + tryAscend(node: FormattingNode, 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 extends BuildFormat { + tryMerge( + before: FormattingNode, + after: FormattingNode, + ): FormattingNode | null { + if (before.insideRange !== after.insideRange) { + return null; + } + + return super.tryMerge(before, after); + } +} + +export class ReformatBuildFormat extends BuildFormat { + tryMerge( + before: FormattingNode, + after: FormattingNode, + ): FormattingNode | null { + if (before.hasMatch !== after.hasMatch) { + return null; + } + + return super.tryMerge(before, after); + } +} diff --git a/ts/domlib/surround/build/index.ts b/ts/domlib/surround/build/index.ts new file mode 100644 index 000000000..37f57ac56 --- /dev/null +++ b/ts/domlib/surround/build/index.ts @@ -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(node: Node, build: BuildFormat): TreeNode[] { + return extendAndMerge(buildFromNode(node, build, []), build); +} + +export { BuildFormat, ReformatBuildFormat, UnsurroundBuildFormat } from "./format"; diff --git a/ts/domlib/surround/child-node-range.ts b/ts/domlib/surround/child-node-range.ts deleted file mode 100644 index cf8863661..000000000 --- a/ts/domlib/surround/child-node-range.ts +++ /dev/null @@ -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); -} diff --git a/ts/domlib/surround/find-above.ts b/ts/domlib/surround/find-above.ts deleted file mode 100644 index 98c067040..000000000 --- a/ts/domlib/surround/find-above.ts +++ /dev/null @@ -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; -} diff --git a/ts/domlib/surround/find-adjacent.test.ts b/ts/domlib/surround/find-adjacent.test.ts deleted file mode 100644 index d18925ad5..000000000 --- a/ts/domlib/surround/find-adjacent.test.ts +++ /dev/null @@ -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("BeforeThis is a testAfter"); - 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("beforewithinafter"); - 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); - }); - }); -}); diff --git a/ts/domlib/surround/find-adjacent.ts b/ts/domlib/surround/find-adjacent.ts deleted file mode 100644 index 3e3ab7f0c..000000000 --- a/ts/domlib/surround/find-adjacent.ts +++ /dev/null @@ -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); -} diff --git a/ts/domlib/surround/find-within.ts b/ts/domlib/surround/find-within.ts deleted file mode 100644 index 789b4fca7..000000000 --- a/ts/domlib/surround/find-within.ts +++ /dev/null @@ -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; -} diff --git a/ts/domlib/surround/flat-range.ts b/ts/domlib/surround/flat-range.ts new file mode 100644 index 000000000..e4f7a47f2 --- /dev/null +++ b/ts/domlib/surround/flat-range.ts @@ -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 { + const parent = this.parent; + const end = this.endIndex; + let step = this.startIndex; + + return { + next(): IteratorResult { + 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 += ``; + } else if (nodeIsElement(node)) { + output += node.outerHTML; + } + } + + return output; + } +} diff --git a/ts/domlib/surround/index.ts b/ts/domlib/surround/index.ts index fec230ddc..2fa0d74b2 100644 --- a/ts/domlib/surround/index.ts +++ b/ts/domlib/surround/index.ts @@ -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"; diff --git a/ts/domlib/surround/match-type.ts b/ts/domlib/surround/match-type.ts new file mode 100644 index 000000000..06dc62073 --- /dev/null +++ b/ts/domlib/surround/match-type.ts @@ -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 { + /** + * 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, ` + * 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 implements MatchType { + 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 { + 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( + format: SurroundFormat, +): (element: Element) => boolean { + return function (element: Element): boolean { + const fake = new FakeMatch(); + format.matcher(element as HTMLElement | SVGElement, fake); + return fake.value; + }; +} diff --git a/ts/domlib/surround/matcher.ts b/ts/domlib/surround/matcher.ts deleted file mode 100644 index e4b235ef9..000000000 --- a/ts/domlib/surround/matcher.ts +++ /dev/null @@ -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; - -/** - * 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; -} - -export interface FoundAlong { - element: Element | Text; - matchType: MatchResult.ALONG; -} - -export type FoundAdjacent = FoundMatch | FoundAlong; diff --git a/ts/domlib/surround/merge-match.ts b/ts/domlib/surround/merge-match.ts deleted file mode 100644 index 97510d22d..000000000 --- a/ts/domlib/surround/merge-match.ts +++ /dev/null @@ -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 - * Hello World will be merged to - * Hello World - */ -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), []); -} diff --git a/ts/domlib/surround/no-splitting.ts b/ts/domlib/surround/no-splitting.ts deleted file mode 100644 index 7bfcc2cb5..000000000 --- a/ts/domlib/surround/no-splitting.ts +++ /dev/null @@ -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. , or - * @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 }; -} diff --git a/ts/domlib/surround/normalize-insertion-ranges.ts b/ts/domlib/surround/normalize-insertion-ranges.ts deleted file mode 100644 index 64fa8ddff..000000000 --- a/ts/domlib/surround/normalize-insertion-ranges.ts +++ /dev/null @@ -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: - * `singledoublesingle` or `beforeafter` - */ - - 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, - }; -} diff --git a/ts/domlib/surround/range-anchors.ts b/ts/domlib/surround/range-anchors.ts deleted file mode 100644 index ff082bc84..000000000 --- a/ts/domlib/surround/range-anchors.ts +++ /dev/null @@ -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), - ), - }; -} diff --git a/ts/domlib/surround/split-text.ts b/ts/domlib/surround/split-text.ts new file mode 100644 index 000000000..98bb1b986 --- /dev/null +++ b/ts/domlib/surround/split-text.ts @@ -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); +} diff --git a/ts/domlib/surround/surround-format.ts b/ts/domlib/surround/surround-format.ts new file mode 100644 index 000000000..ec5222960 --- /dev/null +++ b/ts/domlib/surround/surround-format.ts @@ -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 { + /** + * 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) => void; + /** + * @returns Whehter before or after are allowed to merge to a single + * FormattingNode range + */ + merger?: (before: FormattingNode, after: FormattingNode) => boolean; + /** + * Apply according to this formatter. + * + * @returns Whether formatter added a new element around the range. + */ + formatter?: (node: FormattingNode) => boolean; + /** + * Surround with this node as formatting. Shorthand alternative to `formatter`. + */ + surroundElement?: Element; +} diff --git a/ts/domlib/surround/no-splitting.test.ts b/ts/domlib/surround/surround.test.ts similarity index 57% rename from ts/domlib/surround/no-splitting.test.ts rename to ts/domlib/surround/surround.test.ts index 16dbc7c7c..7a1d3894d 100644 --- a/ts/domlib/surround/no-splitting.test.ts +++ b/ts/domlib/surround/surround.test.ts @@ -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", "111222"); 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", "111222"); 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", "111222"); 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", "beforeafter"); - 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", "beforeafter"); - 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", "beforeafter"); - 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", "beforeafter"); - expect(surroundedRange.toString()).toEqual("after"); + // expect(surroundedRange.toString()).toEqual("after"); + }); + }); + + describe("two nested", () => { + let body: HTMLBodyElement; + + beforeEach(() => { + body = p("aaabbbccc"); + }); + + test("extends to both", () => { + const range = new Range(); + range.selectNode(body.firstChild!); + surround(range, body, easyBold); + + expect(body).toHaveProperty("innerHTML", "aaabbbccc"); + // 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", "Before
  • First
  • Second
", @@ -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", "111222333444555"); // 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", "111222333444555", @@ -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", "111222333444555"); // 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", "12
"); // 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", "12345", @@ -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", "before
after
"); 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", "before
after
"); - 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", "before
after
"); 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("beforenestedafter"); +// 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", +// "beforenestedafter", +// ); +// expect(surroundedRange.toString()).toEqual("nested"); +// }); + +// test("does not normalize beyond level of contained text nodes 2", () => { +// const body = p("aaabbbccc"); +// 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", "aaabbbccc"); +// 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("aaabbbccc"); +// 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", "aaabbbccc"); +// 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("beforenestedafter"); +// 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", +// "beforenestedafter", +// ); +// expect(surroundedRange.toString()).toEqual("nested"); +// }); +// }); diff --git a/ts/domlib/surround/surround.ts b/ts/domlib/surround/surround.ts new file mode 100644 index 000000000..781083b88 --- /dev/null +++ b/ts/domlib/surround/surround.ts @@ -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( + node: Node, + buildFormat: BuildFormat, + applyFormat: ApplyFormat, +): Range { + const forest = build(node, buildFormat); + apply(forest, applyFormat); + return buildFormat.recreateRange(); +} + +function reformatInner( + range: Range, + base: Element, + build: BuildFormat, + apply: ApplyFormat, + 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( + range: Range, + base: Element, + format: SurroundFormat, +): 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( + range: Range, + base: Element, + format: SurroundFormat, +): 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( + range: Range, + base: Element, + format: SurroundFormat, +): 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)); +} diff --git a/ts/domlib/surround/test-utils.ts b/ts/domlib/surround/test-utils.ts new file mode 100644 index 000000000..6160cbb97 --- /dev/null +++ b/ts/domlib/surround/test-utils.ts @@ -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) => + (element: Element, match: MatchType): 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"); diff --git a/ts/domlib/surround/text-node.ts b/ts/domlib/surround/text-node.ts deleted file mode 100644 index 4553edec1..000000000 --- a/ts/domlib/surround/text-node.ts +++ /dev/null @@ -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 []; - } -} diff --git a/ts/domlib/surround/tree/block-node.ts b/ts/domlib/surround/tree/block-node.ts new file mode 100644 index 000000000..3d195caa7 --- /dev/null +++ b/ts/domlib/surround/tree/block-node.ts @@ -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(); + } +} diff --git a/ts/domlib/surround/tree/element-node.ts b/ts/domlib/surround/tree/element-node.ts new file mode 100644 index 000000000..bdea9412f --- /dev/null +++ b/ts/domlib/surround/tree/element-node.ts @@ -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); + } +} diff --git a/ts/domlib/surround/tree/formatting-node.ts b/ts/domlib/surround/tree/formatting-node.ts new file mode 100644 index 000000000..6250026c6 --- /dev/null +++ b/ts/domlib/surround/tree/formatting-node.ts @@ -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 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[], + ) { + super(insideRange); + } + + private static make( + range: FlatRange, + insideRange: boolean, + matchAncestors: Match[], + ): FormattingNode { + return new FormattingNode(range, insideRange, matchAncestors); + } + + static fromText( + text: Text, + insideRange: boolean, + matchAncestors: Match[], + ): FormattingNode { + 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: + * `beforeafter` into `beforeafter`, or + * `beforeafter` into + * `beforeafter` (negligible nodes inbetween). + */ + static merge( + before: FormattingNode, + after: FormattingNode, + ): FormattingNode { + 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: + * `inside` into `inside`, or + * `inside` into `inside + */ + 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: + * `firstsecond + * 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[] = []; + + get firstLeaf(): Match | 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 | 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: + * `inside` + * 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: + * `inside` + * It would typically become: + * `inside` + */ + 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; + } +} diff --git a/ts/domlib/surround/tree/index.ts b/ts/domlib/surround/tree/index.ts new file mode 100644 index 000000000..28f2d729d --- /dev/null +++ b/ts/domlib/surround/tree/index.ts @@ -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"; diff --git a/ts/domlib/surround/tree/tree-node.ts b/ts/domlib/surround/tree/tree-node.ts new file mode 100644 index 000000000..6edd607bd --- /dev/null +++ b/ts/domlib/surround/tree/tree-node.ts @@ -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; + } +} diff --git a/ts/domlib/surround/unsurround.test.ts b/ts/domlib/surround/unsurround.test.ts index fcc4be908..cda738e2e 100644 --- a/ts/domlib/surround/unsurround.test.ts +++ b/ts/domlib/surround/unsurround.test.ts @@ -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("beforeafter"); - }); +// beforeEach(() => { +// body = p("beforeafter"); +// }); - 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("111222333"); - }); +// beforeEach(() => { +// body = p("111222333"); +// }); - 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", "111
  • 222
"); // expect(surroundedRange.toString()).toEqual("222"); }); }); + +describe("with two double nested and one single nested", () => { + // test("unsurround one double and single nested", () => { + // const body = p("aaabbbccc"); + // 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", "aaabbbccc"); + // expect(surroundedRange.toString()).toEqual("bbbccc"); + // }); + + test("unsurround single and one double nested", () => { + const body = p("aaabbbccc"); + 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", "aaabbbccc"); + expect(surroundedRange.toString()).toEqual("aaabbb"); + }); +}); diff --git a/ts/domlib/surround/unsurround.ts b/ts/domlib/surround/unsurround.ts deleted file mode 100644 index 8d564b907..000000000 --- a/ts/domlib/surround/unsurround.ts +++ /dev/null @@ -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. , or - * @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, - }; -} diff --git a/ts/domlib/surround/within-range.ts b/ts/domlib/surround/within-range.ts deleted file mode 100644 index d4a24ba2c..000000000 --- a/ts/domlib/surround/within-range.ts +++ /dev/null @@ -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 - ); -} diff --git a/ts/domlib/tsconfig.json b/ts/domlib/tsconfig.json index 597b6fb78..0ae246fe5 100644 --- a/ts/domlib/tsconfig.json +++ b/ts/domlib/tsconfig.json @@ -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"] diff --git a/ts/editable/ContentEditable.svelte b/ts/editable/ContentEditable.svelte index 861d141f3..a3bd7ba57 100644 --- a/ts/editable/ContentEditable.svelte +++ b/ts/editable/ContentEditable.svelte @@ -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 diff --git a/ts/editable/content-editable.ts b/ts/editable/content-editable.ts index 0b5b438a3..803bb4f09 100644 --- a/ts/editable/content-editable.ts +++ b/ts/editable/content-editable.ts @@ -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); } diff --git a/ts/editor/NoteEditor.svelte b/ts/editor/NoteEditor.svelte index 9fa22d74f..19e7b1ba2 100644 --- a/ts/editor/NoteEditor.svelte +++ b/ts/editor/NoteEditor.svelte @@ -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([]); 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
- + diff --git a/ts/editor/editor-toolbar/FormatBlockButtons.svelte b/ts/editor/editor-toolbar/BlockButtons.svelte similarity index 98% rename from ts/editor/editor-toolbar/FormatBlockButtons.svelte rename to ts/editor/editor-toolbar/BlockButtons.svelte index f3fd765ec..1f0e93845 100644 --- a/ts/editor/editor-toolbar/FormatBlockButtons.svelte +++ b/ts/editor/editor-toolbar/BlockButtons.svelte @@ -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} @@ -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} diff --git a/ts/editor/editor-toolbar/BoldButton.svelte b/ts/editor/editor-toolbar/BoldButton.svelte index f12792b9a..68b4fcfad 100644 --- a/ts/editor/editor-toolbar/BoldButton.svelte +++ b/ts/editor/editor-toolbar/BoldButton.svelte @@ -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 { - 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"); - return MatchResult.NO_MATCH; + if (removeEmptyStyle(element) && element.className.length === 0) { + match.remove(); + } + }); + } } - function clearBold(element: Element): boolean { - const htmlElement = element as HTMLElement | SVGElement; - htmlElement.style.removeProperty("font-weight"); + const format = { + surroundElement, + matcher, + }; - if (htmlElement.style.cssText.length === 0) { - htmlElement.removeAttribute("style"); - } + const namedFormat = { + name: tr.editingBoldText(), + show: true, + active: true, + format, + }; - 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 { - return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(matchBold); + $: if (editingInputIsRichText($focusedInput)) { + surrounder.richText = $focusedInput; + disabled = false; + } else { + surrounder.disable(); + disabled = true; + } + + function updateStateFromActiveInput(): Promise { + 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 { makeBold(); diff --git a/ts/editor/editor-toolbar/ColorButtons.svelte b/ts/editor/editor-toolbar/ColorButtons.svelte deleted file mode 100644 index 381983a76..000000000 --- a/ts/editor/editor-toolbar/ColorButtons.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - - - - {@html textColorIcon} - {@html colorHelperIcon} - - - - - - - {@html arrowIcon} - { - const textColor = setColor(event); - bridgeCommand(`lastTextColor:${textColor}`); - forecolorWrap = wrapWithForecolor(setColor(event)); - forecolorWrap(); - }} - /> - - { - const textColor = setColor(event); - bridgeCommand(`lastTextColor:${textColor}`); - forecolorWrap = wrapWithForecolor(setColor(event)); - forecolorWrap(); - }} - /> - - - - - - - {@html highlightColorIcon} - {@html colorHelperIcon} - - - - - - {@html arrowIcon} - { - const highlightColor = setColor(event); - bridgeCommand(`lastHighlightColor:${highlightColor}`); - backcolorWrap = wrapWithBackcolor(highlightColor); - backcolorWrap(); - }} - /> - - - - - diff --git a/ts/editor/editor-toolbar/EditorToolbar.svelte b/ts/editor/editor-toolbar/EditorToolbar.svelte index af55c00b0..455b3f7d9 100644 --- a/ts/editor/editor-toolbar/EditorToolbar.svelte +++ b/ts/editor/editor-toolbar/EditorToolbar.svelte @@ -3,7 +3,10 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> @@ -77,15 +95,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - + - - - - - + diff --git a/ts/editor/editor-toolbar/FormatInlineButtons.svelte b/ts/editor/editor-toolbar/FormatInlineButtons.svelte deleted file mode 100644 index 84c001a71..000000000 --- a/ts/editor/editor-toolbar/FormatInlineButtons.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - {@html superscriptIcon} - - - - {@html subscriptIcon} - - - - {@html eraserIcon} - - - diff --git a/ts/editor/editor-toolbar/HighlightColorButton.svelte b/ts/editor/editor-toolbar/HighlightColorButton.svelte new file mode 100644 index 000000000..eb09fbca9 --- /dev/null +++ b/ts/editor/editor-toolbar/HighlightColorButton.svelte @@ -0,0 +1,138 @@ + + + + + + {@html highlightColorIcon} + {@html colorHelperIcon} + + + + {@html arrowIcon} + { + color = setColor(event); + bridgeCommand(`lastHighlightColor:${color}`); + setTextColor(); + }} + /> + + diff --git a/ts/editor/editor-toolbar/InlineButtons.svelte b/ts/editor/editor-toolbar/InlineButtons.svelte new file mode 100644 index 000000000..ba8625f4b --- /dev/null +++ b/ts/editor/editor-toolbar/InlineButtons.svelte @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ts/editor/editor-toolbar/ItalicButton.svelte b/ts/editor/editor-toolbar/ItalicButton.svelte index 61fff80c2..372ff7842 100644 --- a/ts/editor/editor-toolbar/ItalicButton.svelte +++ b/ts/editor/editor-toolbar/ItalicButton.svelte @@ -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 { - 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"); - return MatchResult.NO_MATCH; + if (removeEmptyStyle(element) && element.className.length === 0) { + return match.remove(); + } + }); + } } - function clearItalic(element: Element): boolean { - const htmlElement = element as HTMLElement | SVGElement; - htmlElement.style.removeProperty("font-style"); + const format = { + surroundElement, + matcher, + }; - if (htmlElement.style.cssText.length === 0) { - htmlElement.removeAttribute("style"); - } + const namedFormat = { + name: tr.editingItalicText(), + show: true, + active: true, + format, + }; - 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 { - return disabled - ? Promise.resolve(false) - : surrounder!.isSurrounded(matchItalic); + $: if (editingInputIsRichText($focusedInput)) { + surrounder.richText = $focusedInput; + disabled = false; + } else { + surrounder.disable(); + disabled = true; + } + + function updateStateFromActiveInput(): Promise { + 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"; diff --git a/ts/editor/editor-toolbar/LatexButton.svelte b/ts/editor/editor-toolbar/LatexButton.svelte index ba5b4f4e5..d70a0fa70 100644 --- a/ts/editor/editor-toolbar/LatexButton.svelte +++ b/ts/editor/editor-toolbar/LatexButton.svelte @@ -72,9 +72,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {#each dropdownItems as [callback, keyCombination, label]} {label} - {getPlatformString(keyCombination)} + {getPlatformString(keyCombination)} {/each} + + diff --git a/ts/editor/editor-toolbar/RemoveFormatButton.svelte b/ts/editor/editor-toolbar/RemoveFormatButton.svelte new file mode 100644 index 000000000..8998b60f8 --- /dev/null +++ b/ts/editor/editor-toolbar/RemoveFormatButton.svelte @@ -0,0 +1,135 @@ + + + + + {@html eraserIcon} + + + + +
+ + + {@html arrowIcon} + + + event.preventDefault()}> + {#each showFormats as format (format.name)} + onItemClick(event, format)}> + + {format.name} + + {/each} + + +
+ + diff --git a/ts/editor/editor-toolbar/SubscriptButton.svelte b/ts/editor/editor-toolbar/SubscriptButton.svelte new file mode 100644 index 000000000..df1316a20 --- /dev/null +++ b/ts/editor/editor-toolbar/SubscriptButton.svelte @@ -0,0 +1,107 @@ + + + + + + + { + makeSub(); + updateState(event); + updateStateByKey("super", event); + }} + > + {@html subscriptIcon} + + + { + makeSub(); + updateState(event); + updateStateByKey("super", event); + }} + /> + diff --git a/ts/editor/editor-toolbar/SuperscriptButton.svelte b/ts/editor/editor-toolbar/SuperscriptButton.svelte new file mode 100644 index 000000000..f8764b3d1 --- /dev/null +++ b/ts/editor/editor-toolbar/SuperscriptButton.svelte @@ -0,0 +1,107 @@ + + + + + + + { + makeSuper(); + updateState(event); + updateStateByKey("sub", event); + }} + > + {@html superscriptIcon} + + + { + makeSuper(); + updateState(event); + updateStateByKey("sub", event); + }} + /> + diff --git a/ts/editor/editor-toolbar/TemplateButtons.svelte b/ts/editor/editor-toolbar/TemplateButtons.svelte index 1c4161535..58a80b576 100644 --- a/ts/editor/editor-toolbar/TemplateButtons.svelte +++ b/ts/editor/editor-toolbar/TemplateButtons.svelte @@ -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"); } diff --git a/ts/editor/editor-toolbar/TextColorButton.svelte b/ts/editor/editor-toolbar/TextColorButton.svelte new file mode 100644 index 000000000..7d5bf2c48 --- /dev/null +++ b/ts/editor/editor-toolbar/TextColorButton.svelte @@ -0,0 +1,164 @@ + + + + + + {@html textColorIcon} + {@html colorHelperIcon} + + + + + {@html arrowIcon} + { + color = setColor(event); + bridgeCommand(`lastTextColor:${color}`); + setTextColor(); + }} + /> + + { + color = setColor(event); + bridgeCommand(`lastTextColor:${color}`); + setTextColor(); + }} + /> + diff --git a/ts/editor/editor-toolbar/UnderlineButton.svelte b/ts/editor/editor-toolbar/UnderlineButton.svelte index 3aaf1de4a..47ea38ff7 100644 --- a/ts/editor/editor-toolbar/UnderlineButton.svelte +++ b/ts/editor/editor-toolbar/UnderlineButton.svelte @@ -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 { - 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 { focusedInput } = context.get(); + const clearer = () => false; - $: input = $focusedInput as RichTextInputAPI; - $: disabled = !editingInputIsRichText($focusedInput); - $: surrounder = disabled ? null : getSurrounder(input); + 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; + } function updateStateFromActiveInput(): Promise { - 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"; diff --git a/ts/editor/editor-toolbar/WithColorHelper.svelte b/ts/editor/editor-toolbar/WithColorHelper.svelte index 1b6c838e4..074448fc3 100644 --- a/ts/editor/editor-toolbar/WithColorHelper.svelte +++ b/ts/editor/editor-toolbar/WithColorHelper.svelte @@ -12,7 +12,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } -
+
diff --git a/ts/editor/helpers.ts b/ts/editor/helpers.ts index 9f1c79f2a..048f0953d 100644 --- a/ts/editor/helpers.ts +++ b/ts/editor/helpers.ts @@ -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; +} diff --git a/ts/editor/rich-text-input/RichTextInput.svelte b/ts/editor/rich-text-input/RichTextInput.svelte index 4a94c76ed..42e687c80 100644 --- a/ts/editor/rich-text-input/RichTextInput.svelte +++ b/ts/editor/rich-text-input/RichTextInput.svelte @@ -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); } diff --git a/ts/editor/surround.ts b/ts/editor/surround.ts index f47fed9ae..66c35da5f 100644 --- a/ts/editor/surround.ts +++ b/ts/editor/surround.ts @@ -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( matches: boolean, range: Range, - selection: Selection, - surroundElement: Element, base: HTMLElement, - matcher: ElementMatcher, - clearer: ElementClearer, + format: SurroundFormat, + 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; - isSurrounded(matcher: ElementMatcher): Promise; +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 { - const base = await richTextInput.element; + private api: RichTextInputAPI | null = null; + private trigger: Trigger | 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 { + if (!this.api) { + throw new Error("No rich text set"); + } + + return await this.api.element; + } + + private _toggleTrigger( + base: HTMLElement, + selection: Selection, + matcher: Matcher, + format: SurroundFormat, + exclusive: SurroundFormat[] = [], + ): 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( + format: SurroundFormat, + exclusive: SurroundFormat[] = [], + ): Promise { + 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( + format: SurroundFormat, + exclusive: SurroundFormat[] = [], + ): Promise { + 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(format: SurroundFormat): Promise { + 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( + formats: SurroundFormat[], + reformats: SurroundFormat[] = [], ): Promise { - 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, - ); } + + 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 { - surroundCommand, - isSurrounded, - }; + return false; } + +registerPackage("anki/surround", { + Surrounder, +}); diff --git a/ts/lib/dom.ts b/ts/lib/dom.ts index 0163dfddb..1cf599050 100644 --- a/ts/lib/dom.ts +++ b/ts/lib/dom.ts @@ -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", diff --git a/ts/lib/keys.ts b/ts/lib/keys.ts index 9c60666fe..7beda3508 100644 --- a/ts/lib/keys.ts +++ b/ts/lib/keys.ts @@ -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()