// Forked from https://codeberg.org/retronav/ixora // Original author: Pranav Karawale // License: Apache License 2.0. import { type EditorState, type Range, 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; } override 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) { // Avoid full recomputation of decorations during IME composition // but map existing decoration ranges through the changes instead. if (tr.isUserEvent("input.type.compose")) { if (tr.docChanged) { return value.map(tr.changes); } return value; } 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]; } export const readOnlyCursorActive = StateField.define({ create(state) { const { from, to } = state.selection.main; return from !== 0 || to !== 0; }, update(value, tr) { if (value) { return true; } // Treat the cursor as active once a transaction *actually moves* the // selection. We compare start/end positions because widgets dispatch a // no-op `{ selection: currentSelection }` on render (to re-measure their // DOM) — that carries `tr.selection` but doesn't move the caret, and must // not count as a deliberate placement. return ( tr.selection !== undefined && !tr.startState.selection.eq(tr.newSelection) ); }, }); /** * 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]) { if (state.readOnly && !state.field(readOnlyCursorActive, false)) { return false; } return state.selection.ranges.some((selection) => checkRangeOverlap(range, [selection.from, selection.to]), ); } /** * Decoration to simply hide anything. */ export const invisibleDecoration = Decoration.replace({}); const hiddenLineDecoration = Decoration.line({ class: "sb-line-table-outside", }); /** * Hide a source range that may span multiple lines, line-by-line. * * A single `Decoration.replace` across a multi-line range is atomic in * CodeMirror — arrow-key entry from below snaps to the range start * rather than the last line. Hiding each line separately keeps each * line independently addressable while still rendering nothing. * * `widgetAt` indicates which end of the range hosts a point widget so * that line stays in the DOM via `Decoration.replace` (content erased * but the line still renders). Other lines use a `display: none` line * class so they take no vertical space. */ export function hideBlockSource( widgets: Range[], state: EditorState, from: number, to: number, widgetAt: "start" | "end" = "end", ) { const fromLine = state.doc.lineAt(from); const toLine = state.doc.lineAt(to); if (fromLine.number === toLine.number) { widgets.push(invisibleDecoration.range(from, to)); return; } if (widgetAt === "start") { widgets.push(invisibleDecoration.range(from, fromLine.to)); } else if (from === fromLine.from) { widgets.push(hiddenLineDecoration.range(fromLine.from)); } else { widgets.push(invisibleDecoration.range(from, fromLine.to)); } for (let n = fromLine.number + 1; n < toLine.number; n++) { widgets.push(hiddenLineDecoration.range(state.doc.line(n).from)); } if (widgetAt === "end") { widgets.push(invisibleDecoration.range(toLine.from, to)); } else if (to === toLine.to) { widgets.push(hiddenLineDecoration.range(toLine.from)); } else { widgets.push(invisibleDecoration.range(toLine.from, to)); } } export type WidgetRenderMode = "ready" | "loading" | "disabled"; /** * Returns the render mode for widgets on the current page: * - "disabled": user/page opted out of widget rendering — show raw source. * - "loading": widget rendering is wanted but required state isn't ready * yet (system ready, scripts loaded, full index, page list). * Callers should render LoadingWidget placeholders. * - "ready": render real widgets. */ export function widgetRenderMode(client: Client): WidgetRenderMode { if (client.currentPageMeta()?.pageDecoration?.renderWidgets === false) { return "disabled"; } if ( !client.systemReady || !client.clientSystem.scriptsLoaded || !client.fullIndexCompleted || !client.pageListLoaded ) { return "loading"; } return "ready"; }