// Forked from https://codeberg.org/retronav/ixora // Original author: Pranav Karawale // License: Apache License 2.0. import { type EditorState, StateField, type Transaction, } from "@codemirror/state"; import type { DecorationSet } from "@codemirror/view"; import { Decoration, EditorView, WidgetType } from "@codemirror/view"; import type { Client } from "../client.ts"; type LinkOptions = { text: string; href?: string; title: string; cssClass: string; from: number; callback: (e: MouseEvent) => void; }; export class LinkWidget extends WidgetType { constructor( readonly options: LinkOptions, ) { super(); } toDOM(): HTMLElement { const anchor = document.createElement("a"); anchor.className = this.options.cssClass; anchor.textContent = this.options.text; // Mouse handling anchor.addEventListener("click", (e) => { if (e.button !== 0) { return; } e.preventDefault(); e.stopPropagation(); try { this.options.callback(e); } catch (e) { console.error("Error handling wiki link click", e); } }); // Touch handling let touchCount = 0; anchor.addEventListener("touchmove", () => { touchCount++; }); anchor.addEventListener("touchend", (e) => { if (touchCount === 0) { e.preventDefault(); e.stopPropagation(); this.options.callback(new MouseEvent("click", e)); } touchCount = 0; }); anchor.setAttribute("title", this.options.title); anchor.href = this.options.href || "#"; return anchor; } eq(other: WidgetType): boolean { return other instanceof LinkWidget && this.options.from === other.options.from && this.options.text === other.options.text && this.options.href === other.options.href && this.options.title === other.options.title; } } export class HtmlWidget extends WidgetType { constructor( readonly html: string, readonly className?: string, readonly onClick?: (e: MouseEvent) => void, ) { super(); } toDOM(): HTMLElement { const el = document.createElement("span"); if (this.className) { el.className = this.className; } if (this.onClick) { el.addEventListener("click", this.onClick); } el.innerHTML = this.html; return el; } } export function decoratorStateField( stateToDecoratorMapper: (state: EditorState) => DecorationSet, ) { return StateField.define({ create(state: EditorState) { return stateToDecoratorMapper(state); }, update(value: DecorationSet, tr: Transaction) { if (tr.isUserEvent("select.pointer")) return value; return stateToDecoratorMapper(tr.state); }, provide: (f) => EditorView.decorations.from(f), }); } export class ButtonWidget extends WidgetType { constructor( readonly text: string, readonly title: string, readonly cssClass: string, readonly callback: (e: MouseEvent) => void, ) { super(); } toDOM(): HTMLElement { const anchor = document.createElement("button"); anchor.className = this.cssClass; anchor.textContent = this.text; anchor.addEventListener("mouseup", (e) => { e.preventDefault(); e.stopPropagation(); this.callback(e); }); anchor.setAttribute("title", this.title); return anchor; } } /** * Check if two ranges overlap * Based on the visual diagram on https://stackoverflow.com/a/25369187 * @param range1 - Range 1 * @param range2 - Range 2 * @returns True if the ranges overlap */ export function checkRangeOverlap( range1: [number, number], range2: [number, number], ) { return range1[0] <= range2[1] && range2[0] <= range1[1]; } /** * Check if a range is inside another range * @param parent - Parent (bigger) range * @param child - Child (smaller) range * @returns True if child is inside parent */ export function checkRangeSubset( parent: [number, number], child: [number, number], ) { return child[0] >= parent[0] && child[1] <= parent[1]; } /** * Check if any of the editor cursors is in the given range * @param state - Editor state * @param range - Range to check * @returns True if the cursor is in the range */ export function isCursorInRange(state: EditorState, range: [number, number]) { return state.selection.ranges.some((selection) => checkRangeOverlap(range, [selection.from, selection.to]) ); } /** * Decoration to simply hide anything. */ export const invisibleDecoration = Decoration.replace({}); export function shouldRenderWidgets(client: Client) { const currentPageMeta = client.ui.viewState.currentPageMeta; return !currentPageMeta?.tags?.includes("template") && currentPageMeta?.pageDecoration?.renderWidgets !== false; }