🚧 Added support for markdown preview table
This commit is contained in:
@@ -1,88 +1,160 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState, StateField, Range } from '@codemirror/state';
|
||||
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
|
||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||
|
||||
/**
|
||||
* Hidden mark decoration - uses visibility: hidden to hide content
|
||||
*/
|
||||
/** Hidden mark decoration */
|
||||
const hiddenMarkDecoration = Decoration.mark({
|
||||
class: 'cm-heading-mark-hidden'
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if selection overlaps with a range.
|
||||
* Collect all heading ranges in visible viewport.
|
||||
*/
|
||||
function isSelectionInRange(state: EditorState, from: number, to: number): boolean {
|
||||
return state.selection.ranges.some(
|
||||
(range) => from <= range.to && to >= range.from
|
||||
);
|
||||
function collectHeadingRanges(view: EditorView): RangeTuple[] {
|
||||
const ranges: RangeTuple[] = [];
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter(node) {
|
||||
if (!node.type.name.startsWith('ATXHeading') &&
|
||||
!node.type.name.startsWith('SetextHeading')) {
|
||||
return;
|
||||
}
|
||||
if (seen.has(node.from)) return;
|
||||
seen.add(node.from);
|
||||
ranges.push([node.from, node.to]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build heading decorations.
|
||||
* Hides # marks when cursor is not on the heading line.
|
||||
*/
|
||||
function buildHeadingDecorations(state: EditorState): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
enter(node) {
|
||||
// Skip if cursor is in this node's range
|
||||
if (isSelectionInRange(state, node.from, node.to)) return;
|
||||
|
||||
// Handle ATX headings (# Heading)
|
||||
if (node.type.name.startsWith('ATXHeading')) {
|
||||
const header = node.node.firstChild;
|
||||
if (header && header.type.name === 'HeaderMark') {
|
||||
const from = header.from;
|
||||
// Include the space after #
|
||||
const to = Math.min(header.to + 1, node.to);
|
||||
decorations.push(hiddenMarkDecoration.range(from, to));
|
||||
}
|
||||
}
|
||||
// Handle Setext headings (underline style)
|
||||
else if (node.type.name.startsWith('SetextHeading')) {
|
||||
// Hide the underline marks (=== or ---)
|
||||
const cursor = node.node.cursor();
|
||||
cursor.iterate((child) => {
|
||||
if (child.type.name === 'HeaderMark') {
|
||||
decorations.push(
|
||||
hiddenMarkDecoration.range(child.from, child.to)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading StateField - manages # mark visibility.
|
||||
* Get which heading the cursor is in (-1 if none).
|
||||
*/
|
||||
const headingField = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
return buildHeadingDecorations(state);
|
||||
},
|
||||
|
||||
update(deco, tr) {
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return buildHeadingDecorations(tr.state);
|
||||
function getCursorHeadingPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||
const selRange: RangeTuple = [selFrom, selTo];
|
||||
|
||||
for (const range of ranges) {
|
||||
if (checkRangeOverlap(range, selRange)) {
|
||||
return range[0];
|
||||
}
|
||||
return deco.map(tr.changes);
|
||||
},
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
provide: (f) => EditorView.decorations.from(f)
|
||||
/**
|
||||
* Build heading decorations using RangeSetBuilder.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const items: { from: number; to: number }[] = [];
|
||||
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||
const selRange: RangeTuple = [selFrom, selTo];
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter(node) {
|
||||
// Skip if cursor is in this heading
|
||||
if (checkRangeOverlap([node.from, node.to], selRange)) return;
|
||||
|
||||
// ATX headings (# Heading)
|
||||
if (node.type.name.startsWith('ATXHeading')) {
|
||||
if (seen.has(node.from)) return;
|
||||
seen.add(node.from);
|
||||
|
||||
const header = node.node.firstChild;
|
||||
if (header && header.type.name === 'HeaderMark') {
|
||||
const markFrom = header.from;
|
||||
// Include the space after #
|
||||
const markTo = Math.min(header.to + 1, node.to);
|
||||
items.push({ from: markFrom, to: markTo });
|
||||
}
|
||||
}
|
||||
// Setext headings (underline style)
|
||||
else if (node.type.name.startsWith('SetextHeading')) {
|
||||
if (seen.has(node.from)) return;
|
||||
seen.add(node.from);
|
||||
|
||||
const cursor = node.node.cursor();
|
||||
cursor.iterate((child) => {
|
||||
if (child.type.name === 'HeaderMark') {
|
||||
items.push({ from: child.from, to: child.to });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by position and add to builder
|
||||
items.sort((a, b) => a.from - b.from);
|
||||
|
||||
for (const item of items) {
|
||||
builder.add(item.from, item.to, hiddenMarkDecoration);
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading plugin with optimized updates.
|
||||
*/
|
||||
class HeadingPlugin {
|
||||
decorations: DecorationSet;
|
||||
private headingRanges: RangeTuple[] = [];
|
||||
private cursorHeadingPos = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.headingRanges = collectHeadingRanges(view);
|
||||
const { from, to } = view.state.selection.main;
|
||||
this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to);
|
||||
this.decorations = buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const { docChanged, viewportChanged, selectionSet } = update;
|
||||
|
||||
if (docChanged || viewportChanged) {
|
||||
this.headingRanges = collectHeadingRanges(update.view);
|
||||
const { from, to } = update.state.selection.main;
|
||||
this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to);
|
||||
this.decorations = buildDecorations(update.view);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionSet) {
|
||||
const { from, to } = update.state.selection.main;
|
||||
const newPos = getCursorHeadingPos(this.headingRanges, from, to);
|
||||
|
||||
if (newPos !== this.cursorHeadingPos) {
|
||||
this.cursorHeadingPos = newPos;
|
||||
this.decorations = buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headingPlugin = ViewPlugin.fromClass(HeadingPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Theme for hidden heading marks.
|
||||
*
|
||||
* Uses fontSize: 0 to hide the # mark without leaving whitespace.
|
||||
* This works correctly now because blockLayer uses lineBlockAt()
|
||||
* which calculates coordinates based on the entire line, not
|
||||
* individual characters, so fontSize: 0 doesn't affect boundaries.
|
||||
*/
|
||||
const headingTheme = EditorView.baseTheme({
|
||||
'.cm-heading-mark-hidden': {
|
||||
@@ -93,4 +165,4 @@ const headingTheme = EditorView.baseTheme({
|
||||
/**
|
||||
* Headings plugin.
|
||||
*/
|
||||
export const headings = () => [headingField, headingTheme];
|
||||
export const headings = (): Extension => [headingPlugin, headingTheme];
|
||||
|
||||
Reference in New Issue
Block a user