anki/ts/editor/surround.ts
Henrik Giesel 8b84368e3a
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
2022-02-22 22:17:22 +10:00

223 lines
6.7 KiB
TypeScript

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { get } from "svelte/store";
import type { Matcher } from "../domlib/find-above";
import { findClosest } from "../domlib/find-above";
import type { SurroundFormat } from "../domlib/surround";
import { boolMatcher, reformat, surround, unsurround } from "../domlib/surround";
import { getRange, getSelection } from "../lib/cross-browser";
import { registerPackage } from "../lib/runtime-require";
import type { OnInsertCallback, Trigger } from "../sveltelib/input-manager";
import type { RichTextInputAPI } from "./rich-text-input";
function isSurroundedInner(
range: AbstractRange,
base: HTMLElement,
matcher: Matcher,
): boolean {
return Boolean(
findClosest(range.startContainer, base, matcher) ||
findClosest(range.endContainer, base, matcher),
);
}
function surroundAndSelect<T>(
matches: boolean,
range: Range,
base: HTMLElement,
format: SurroundFormat<T>,
selection: Selection,
): void {
const surroundedRange = matches
? unsurround(range, base, format)
: surround(range, base, format);
selection.removeAllRanges();
selection.addRange(surroundedRange);
}
function removeFormats(
range: Range,
base: Element,
formats: SurroundFormat[],
reformats: SurroundFormat[] = [],
): Range {
let surroundRange = range;
for (const format of formats) {
surroundRange = unsurround(surroundRange, base, format);
}
for (const format of reformats) {
surroundRange = reformat(surroundRange, base, format);
}
return surroundRange;
}
export class Surrounder {
static make(): Surrounder {
return new Surrounder();
}
private api: RichTextInputAPI | null = null;
private trigger: Trigger<OnInsertCallback> | null = null;
set richText(api: RichTextInputAPI) {
this.api = api;
this.trigger = api.getTriggerOnNextInsert();
}
/**
* After calling disable, using any of the surrounding methods will throw an
* exception. Make sure to set the rich text before trying to use them again.
*/
disable(): void {
this.api = null;
this.trigger = null;
}
private async _assert_base(): Promise<HTMLElement> {
if (!this.api) {
throw new Error("No rich text set");
}
return await this.api.element;
}
private _toggleTrigger<T>(
base: HTMLElement,
selection: Selection,
matcher: Matcher,
format: SurroundFormat<T>,
exclusive: SurroundFormat<T>[] = [],
): void {
if (get(this.trigger!.active)) {
this.trigger!.remove();
} else {
this.trigger!.add(async ({ node }: { node: Node }) => {
const range = new Range();
range.selectNode(node);
const matches = Boolean(findClosest(node, base, matcher));
const clearedRange = removeFormats(range, base, exclusive);
surroundAndSelect(matches, clearedRange, base, format, selection);
selection.collapseToEnd();
});
}
}
/**
* Use the surround command on the current range of the RichTextInput.
* If the range is already surrounded, it will unsurround instead.
*/
async surround<T>(
format: SurroundFormat<T>,
exclusive: SurroundFormat<T>[] = [],
): Promise<void> {
const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
const matcher = boolMatcher(format);
if (!range) {
return;
}
if (range.collapsed) {
return this._toggleTrigger(base, selection, matcher, format, exclusive);
}
const clearedRange = removeFormats(range, base, exclusive);
const matches = isSurroundedInner(clearedRange, base, matcher);
surroundAndSelect(matches, clearedRange, base, format, selection);
}
/**
* Use the surround command on the current range of the RichTextInput.
* If the range is already surrounded, it will overwrite the format.
* This might be better suited if the surrounding is parameterized (like
* text color).
*/
async overwriteSurround<T>(
format: SurroundFormat<T>,
exclusive: SurroundFormat<T>[] = [],
): Promise<void> {
const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
const matcher = boolMatcher(format);
if (!range) {
return;
}
if (range.collapsed) {
return this._toggleTrigger(base, selection, matcher, format, exclusive);
}
const clearedRange = removeFormats(range, base, exclusive);
const surroundedRange = surround(clearedRange, base, format);
selection.removeAllRanges();
selection.addRange(surroundedRange);
}
/**
* Check if the current selection is surrounded. A selection will count as
* provided if either the start or the end boundary point are within the
* provided format, OR if a surround trigger is active (surround on next
* text insert).
*/
async isSurrounded<T>(format: SurroundFormat<T>): Promise<boolean> {
const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
if (!range) {
return false;
}
const isSurrounded = isSurroundedInner(range, base, boolMatcher(format));
return get(this.trigger!.active) ? !isSurrounded : isSurrounded;
}
/**
* Clear/Reformat the provided formats in the current range.
*/
async remove<T>(
formats: SurroundFormat<T>[],
reformats: SurroundFormat<T>[] = [],
): Promise<void> {
const base = await this._assert_base();
const selection = getSelection(base)!;
const range = getRange(selection);
if (!range || range.collapsed) {
return;
}
const surroundedRange = removeFormats(range, base, formats, reformats);
selection.removeAllRanges();
selection.addRange(surroundedRange);
}
}
/**
* @returns True, if element has no style attribute (anymore).
*/
export function removeEmptyStyle(element: HTMLElement | SVGElement): boolean {
if (element.style.cssText.length === 0) {
element.removeAttribute("style");
// Calling `.hasAttribute` right after `.removeAttribute` might return true.
return true;
}
return false;
}
registerPackage("anki/surround", {
Surrounder,
});