Files
voidraft/frontend/src/views/editor/extensions/markdown/util.ts

353 lines
8.7 KiB
TypeScript

import { foldedRanges, syntaxTree } from '@codemirror/language';
import type { SyntaxNodeRef, TreeCursor } from '@lezer/common';
import { Decoration, EditorView } from '@codemirror/view';
import {
EditorState,
SelectionRange,
CharCategory,
findClusterBreak
} from '@codemirror/state';
// ============================================================================
// Type Definitions (ProseMark style)
// ============================================================================
/**
* A range-like object with from and to properties.
*/
export interface RangeLike {
from: number;
to: number;
}
/**
* Tuple representation of a range [from, to].
*/
export type RangeTuple = [number, number];
// ============================================================================
// Range Utilities
// ============================================================================
/**
* Check if two ranges overlap (touch or intersect).
* Based on the visual diagram on https://stackoverflow.com/a/25369187
*
* @param range1 - First range
* @param range2 - Second range
* @returns True if the ranges overlap
*/
export function checkRangeOverlap(
range1: RangeTuple,
range2: RangeTuple
): boolean {
return range1[0] <= range2[1] && range2[0] <= range1[1];
}
/**
* Check if two range-like objects touch or overlap.
* ProseMark-style range comparison.
*
* @param a - First range
* @param b - Second range
* @returns True if ranges touch
*/
export function rangeTouchesRange(a: RangeLike, b: RangeLike): boolean {
return a.from <= b.to && b.from <= a.to;
}
/**
* Check if a selection touches a range.
*
* @param selection - Array of selection ranges
* @param range - Range to check against
* @returns True if any selection touches the range
*/
export function selectionTouchesRange(
selection: readonly SelectionRange[],
range: RangeLike
): boolean {
return selection.some((sel) => rangeTouchesRange(sel, range));
}
/**
* Check if a range is inside another range (subset).
*
* @param parent - Parent (bigger) range
* @param child - Child (smaller) range
* @returns True if child is inside parent
*/
export function checkRangeSubset(
parent: RangeTuple,
child: RangeTuple
): boolean {
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: RangeTuple
): boolean {
return state.selection.ranges.some((selection) =>
checkRangeOverlap(range, [selection.from, selection.to])
);
}
// ============================================================================
// Tree Iteration Utilities
// ============================================================================
/**
* Iterate over the syntax tree in the visible ranges of the document.
*
* @param view - Editor view
* @param iterateFns - Object with `enter` and `leave` iterate function
*/
export function iterateTreeInVisibleRanges(
view: EditorView,
iterateFns: {
enter(node: SyntaxNodeRef): boolean | void;
leave?(node: SyntaxNodeRef): void;
}
): void {
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({ ...iterateFns, from, to });
}
}
/**
* Iterate through child nodes of a cursor.
* ProseMark-style tree traversal.
*
* @param cursor - Tree cursor to iterate
* @param enter - Callback function, return true to stop iteration
*/
export function iterChildren(
cursor: TreeCursor,
enter: (cursor: TreeCursor) => boolean | undefined
): void {
if (!cursor.firstChild()) return;
do {
if (enter(cursor)) break;
} while (cursor.nextSibling());
cursor.parent();
}
// ============================================================================
// Line Utilities
// ============================================================================
/**
* Returns the lines of the editor that are in the given range and not folded.
* This function is useful for adding line decorations to each line of a block node.
*
* @param view - Editor view
* @param from - Start of the range
* @param to - End of the range
* @returns A list of line blocks that are in the range
*/
export function editorLines(
view: EditorView,
from: number,
to: number
) {
let lines = view.viewportLineBlocks.filter((block) =>
checkRangeOverlap([block.from, block.to], [from, to])
);
const folded = foldedRanges(view.state).iter();
while (folded.value) {
lines = lines.filter(
(line) =>
!checkRangeOverlap(
[folded.from, folded.to],
[line.from, line.to]
)
);
folded.next();
}
return lines;
}
/**
* Get line numbers for a range.
*
* @param state - Editor state
* @param from - Start position
* @param to - End position
* @returns Array of line numbers
*/
export function getLineNumbers(
state: EditorState,
from: number,
to: number
): number[] {
const startLine = state.doc.lineAt(from).number;
const endLine = state.doc.lineAt(to).number;
const lines: number[] = [];
for (let i = startLine; i <= endLine; i++) {
lines.push(i);
}
return lines;
}
// ============================================================================
// Word Utilities (ProseMark style)
// ============================================================================
/**
* Get the "WORD" at a position (vim-style WORD, including non-whitespace).
*
* @param state - Editor state
* @param pos - Position in document
* @returns Selection range of the WORD, or null if at whitespace
*/
export function stateWORDAt(
state: EditorState,
pos: number
): SelectionRange | null {
const { text, from, length } = state.doc.lineAt(pos);
const cat = state.charCategorizer(pos);
let start = pos - from;
let end = pos - from;
while (start > 0) {
const prev = findClusterBreak(text, start, false);
if (cat(text.slice(prev, start)) === CharCategory.Space) break;
start = prev;
}
while (end < length) {
const next = findClusterBreak(text, end);
if (cat(text.slice(end, next)) === CharCategory.Space) break;
end = next;
}
return start === end
? null
: { from: start + from, to: end + from } as SelectionRange;
}
// ============================================================================
// Decoration Utilities
// ============================================================================
/**
* Decoration to simply hide anything (replace with nothing).
*/
export const invisibleDecoration = Decoration.replace({});
/**
* Decoration to hide inline content (font-size: 0).
*/
export const hideInlineDecoration = Decoration.mark({
class: 'cm-hidden-token'
});
/**
* Decoration to make content transparent but preserve space.
*/
export const hideInlineKeepSpaceDecoration = Decoration.mark({
class: 'cm-transparent-token'
});
// ============================================================================
// Slug Generation
// ============================================================================
/**
* Class for generating unique slugs from heading contents.
*/
export class Slugger {
/** Occurrences for each slug. */
private occurrences: Map<string, number> = new Map();
/**
* Generate a slug from the given content.
*
* @param text - Content to generate the slug from
* @returns The generated slug
*/
public slug(text: string): string {
let slug = text
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '');
const count = this.occurrences.get(slug) || 0;
if (count > 0) {
slug += '-' + count;
}
this.occurrences.set(slug, count + 1);
return slug;
}
/**
* Reset the slugger state.
*/
public reset(): void {
this.occurrences.clear();
}
}
// ============================================================================
// Performance Utilities
// ============================================================================
/**
* Create a debounced version of a function.
*
* @param fn - Function to debounce
* @param delay - Delay in milliseconds
* @returns Debounced function
*/
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: unknown[]) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
}) as T;
}
/**
* Create a throttled version of a function.
*
* @param fn - Function to throttle
* @param limit - Minimum time between calls in milliseconds
* @returns Throttled function
*/
export function throttle<T extends (...args: unknown[]) => void>(
fn: T,
limit: number
): T {
let lastCall = 0;
return ((...args: unknown[]) => {
const now = Date.now();
if (now - lastCall >= limit) {
lastCall = now;
fn(...args);
}
}) as T;
}