🚧 Refactor markdown preview extension
This commit is contained in:
@@ -1,49 +1,112 @@
|
||||
import { foldedRanges, syntaxTree } from '@codemirror/language';
|
||||
import type { SyntaxNodeRef } from '@lezer/common';
|
||||
import type { SyntaxNodeRef, TreeCursor } from '@lezer/common';
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import {
|
||||
EditorState,
|
||||
SelectionRange,
|
||||
CharCategory,
|
||||
findClusterBreak
|
||||
} from '@codemirror/state';
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions (ProseMark style)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if two ranges overlap
|
||||
* 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 - Range 1
|
||||
* @param range2 - Range 2
|
||||
*
|
||||
* @param range1 - First range
|
||||
* @param range2 - Second range
|
||||
* @returns True if the ranges overlap
|
||||
*/
|
||||
export function checkRangeOverlap(
|
||||
range1: [number, number],
|
||||
range2: [number, number]
|
||||
) {
|
||||
range1: RangeTuple,
|
||||
range2: RangeTuple
|
||||
): boolean {
|
||||
return range1[0] <= range2[1] && range2[0] <= range1[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a range is inside another range
|
||||
* 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: [number, number],
|
||||
child: [number, number]
|
||||
) {
|
||||
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
|
||||
* 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]) {
|
||||
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
|
||||
* 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
|
||||
*/
|
||||
@@ -53,30 +116,49 @@ export function iterateTreeInVisibleRanges(
|
||||
enter(node: SyntaxNodeRef): boolean | void;
|
||||
leave?(node: SyntaxNodeRef): void;
|
||||
}
|
||||
) {
|
||||
): void {
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({ ...iterateFns, from, to });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoration to simply hide anything.
|
||||
* 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 const invisibleDecoration = Decoration.replace({});
|
||||
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 of use when you need to get the lines of a particular
|
||||
* block node and add line decorations to each line of it.
|
||||
* 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) {
|
||||
export function editorLines(
|
||||
view: EditorView,
|
||||
from: number,
|
||||
to: number
|
||||
) {
|
||||
let lines = view.viewportLineBlocks.filter((block) =>
|
||||
// Keep lines that are in the range
|
||||
checkRangeOverlap([block.from, block.to], [from, to])
|
||||
);
|
||||
|
||||
@@ -96,28 +178,175 @@ export function editorLines(view: EditorView, from: number, to: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Class containing methods to generate slugs from heading contents.
|
||||
* 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 occurences: { [key: string]: number } = {};
|
||||
private occurrences: Map<string, number> = new Map();
|
||||
|
||||
/**
|
||||
* Generate a slug from the given content.
|
||||
*
|
||||
* @param text - Content to generate the slug from
|
||||
* @returns the slug
|
||||
* @returns The generated slug
|
||||
*/
|
||||
public slug(text: string) {
|
||||
public slug(text: string): string {
|
||||
let slug = text
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w-]+/g, '');
|
||||
|
||||
if (slug in this.occurences) {
|
||||
this.occurences[slug]++;
|
||||
slug += '-' + this.occurences[slug];
|
||||
} else {
|
||||
this.occurences[slug] = 1;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user