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