// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { getRange, getSelection } from "@tslib/cross-browser"; import { asyncNoop } from "@tslib/functional"; import { registerPackage } from "@tslib/runtime-require"; import type { Readable } from "svelte/store"; import { derived, 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 type { TriggerItem } from "../sveltelib/handler-list"; import type { InputHandlerAPI } from "../sveltelib/input-handler"; function isValid(value: T | undefined): value is T { return Boolean(value); } function isSurroundedInner( range: AbstractRange, base: HTMLElement, matcher: Matcher, ): boolean { return Boolean( findClosest(range.startContainer, base, matcher) || findClosest(range.endContainer, base, matcher), ); } function surroundAndSelect( matches: boolean, range: Range, base: HTMLElement, format: SurroundFormat, 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 interface SurroundedAPI { element: Promise; inputHandler: InputHandlerAPI; } /** * After calling disable, using any of the surrounding methods will throw an * exception. Make sure to set the input before trying to use them again. */ export class Surrounder { #api?: SurroundedAPI; #triggers: Map> = new Map(); #formats: Map> = new Map(); active: Readable; private constructor(apiStore: Readable) { this.active = derived(apiStore, (api) => Boolean(api)); apiStore.subscribe((api: SurroundedAPI | null): void => { if (api) { this.#api = api; for (const key of this.#formats.keys()) { this.#triggers.set( key, api.inputHandler.insertText.trigger({ once: true }), ); } } else { this.#api = undefined; for (const [key, trigger] of this.#triggers) { trigger.off(); this.#triggers.delete(key); } } }); } static make(apiStore: Readable): Surrounder { return new Surrounder(apiStore); } #getBaseElement(): Promise { if (!this.#api) { throw new Error("Surrounder: No api set"); } return this.#api.element; } #toggleTrigger( base: HTMLElement, selection: Selection, matcher: Matcher, format: SurroundFormat, trigger: TriggerItem<{ event: InputEvent; text: Text }>, exclusive: SurroundFormat[] = [], ): void { if (get(trigger.active)) { trigger.off(); } else { trigger.on(async ({ text }) => { const range = new Range(); range.selectNode(text); const matches = Boolean(findClosest(text, base, matcher)); const clearedRange = removeFormats(range, base, exclusive); surroundAndSelect(matches, clearedRange, base, format, selection); selection.collapseToEnd(); }); } } #toggleTriggerOverwrite( base: HTMLElement, selection: Selection, format: SurroundFormat, trigger: TriggerItem<{ event: InputEvent; text: Text }>, exclusive: SurroundFormat[] = [], ): void { trigger.on(async ({ text }) => { const range = new Range(); range.selectNode(text); const clearedRange = removeFormats(range, base, exclusive); const surroundedRange = surround(clearedRange, base, format); selection.removeAllRanges(); selection.addRange(surroundedRange); selection.collapseToEnd(); }); } #toggleTriggerRemove( base: HTMLElement, selection: Selection, formats: { format: SurroundFormat; trigger: TriggerItem<{ event: InputEvent; text: Text }>; }[], reformat: SurroundFormat[] = [], ): void { const remainingFormats = formats .filter(({ trigger }) => { if (get(trigger.active)) { // Deactivate active triggers for active formats. trigger.off(); return false; } // Otherwise you are within the format. This is why we activate // the trigger, so that the active button is set to inactive. // We still need to remove the format however. trigger.on(asyncNoop); return true; }) .map(({ format }) => format); // Use an anonymous insertText handler instead of some trigger associated with a name this.#api!.inputHandler.insertText.on( async ({ text }) => { const range = new Range(); range.selectNode(text); const clearedRange = removeFormats( range, base, remainingFormats, reformat, ); selection.removeAllRanges(); selection.addRange(clearedRange); selection.collapseToEnd(); }, { once: true }, ); } /** * Check if a surround format under the given key is registered. */ hasFormat(key: string): boolean { return this.#formats.has(key); } /** * Register a surround format under a certain key. * This name is then used with the surround functions to actually apply or * remove the given format. */ registerFormat(key: string, format: SurroundFormat): () => void { this.#formats.set(key, format); if (this.#api) { this.#triggers.set( key, this.#api.inputHandler.insertText.trigger({ once: true }), ); } return () => this.#formats.delete(key); } /** * Update a surround format under a specific key. */ updateFormat( key: string, update: (format: SurroundFormat) => SurroundFormat, ): void { this.#formats.set(key, update(this.#formats.get(key)!)); } /** * Use the surround command on the current range of the input. * If the range is already surrounded, it will unsurround instead. */ async surround(formatName: string, exclusiveNames: string[] = []): Promise { const base = await this.#getBaseElement(); const selection = getSelection(base)!; const range = getRange(selection); const format = this.#formats.get(formatName); const trigger = this.#triggers.get(formatName); if (!format || !range || !trigger) { return; } const matcher = boolMatcher(format); const exclusives = exclusiveNames .map((name) => this.#formats.get(name)) .filter(isValid); if (range.collapsed) { return this.#toggleTrigger( base, selection, matcher, format, trigger, exclusives, ); } const clearedRange = removeFormats(range, base, exclusives); const matches = isSurroundedInner(clearedRange, base, matcher); surroundAndSelect(matches, clearedRange, base, format, selection); } /** * Use the surround command on the current range of the input. * 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( formatName: string, exclusiveNames: string[] = [], ): Promise { const base = await this.#getBaseElement(); const selection = getSelection(base)!; const range = getRange(selection); const format = this.#formats.get(formatName); const trigger = this.#triggers.get(formatName); if (!format || !range || !trigger) { return; } const exclusives = exclusiveNames .map((name) => this.#formats.get(name)) .filter(isValid); if (range.collapsed) { return this.#toggleTriggerOverwrite( base, selection, format, trigger, exclusives, ); } const clearedRange = removeFormats(range, base, exclusives); 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(formatName: string): Promise { const base = await this.#getBaseElement(); const selection = getSelection(base)!; const range = getRange(selection); const format = this.#formats.get(formatName); const trigger = this.#triggers.get(formatName); if (!range || !format || !trigger) { return false; } const isSurrounded = isSurroundedInner(range, base, boolMatcher(format)); return get(trigger.active) ? !isSurrounded : isSurrounded; } /** * Clear/Reformat the provided formats in the current range. */ async remove(formatNames: string[], reformatNames: string[] = []): Promise { const base = await this.#getBaseElement(); const selection = getSelection(base)!; const range = getRange(selection); if (!range) { return; } const activeFormats = formatNames .map((name: string) => ({ name, format: this.#formats.get(name)!, trigger: this.#triggers.get(name)!, })) .filter(({ format, trigger }): boolean => { if (!format || !trigger) { return false; } // This is confusing: when nothing is selected, we only // include currently-active buttons, as otherwise inactive // buttons get toggled on. But when something is selected, // we include everything, since we want to remove formatting // that may be in part of the selection, but not at the start/end. const isSurrounded = !range.collapsed || isSurroundedInner( range, base, boolMatcher(format), ); return get(trigger.active) ? !isSurrounded : isSurrounded; }); const reformats = reformatNames .map((name) => this.#formats.get(name)) .filter(isValid); if (range.collapsed) { return this.#toggleTriggerRemove(base, selection, activeFormats, reformats); } const surroundedRange = removeFormats( range, base, activeFormats.map(({ format }) => format), reformats, ); selection.removeAllRanges(); selection.addRange(surroundedRange); } } registerPackage("anki/surround", { Surrounder, });