🚧 Added support for markdown preview table
This commit is contained in:
@@ -64,6 +64,12 @@
|
|||||||
|
|
||||||
/* Markdown 高亮样式 */
|
/* Markdown 高亮样式 */
|
||||||
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
||||||
|
|
||||||
|
/* Markdown 表格样式 - 暗色主题 */
|
||||||
|
--cm-table-bg: rgba(35, 40, 52, 0.5);
|
||||||
|
--cm-table-header-bg: rgba(46, 51, 69, 0.7);
|
||||||
|
--cm-table-border: rgba(75, 85, 99, 0.35);
|
||||||
|
--cm-table-row-hover: rgba(55, 62, 78, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 亮色主题 */
|
/* 亮色主题 */
|
||||||
@@ -125,6 +131,12 @@
|
|||||||
|
|
||||||
/* Markdown 高亮样式 */
|
/* Markdown 高亮样式 */
|
||||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||||
|
|
||||||
|
/* Markdown 表格样式 - 亮色主题 */
|
||||||
|
--cm-table-bg: oklch(97.5% 0.006 255);
|
||||||
|
--cm-table-header-bg: oklch(94% 0.01 255);
|
||||||
|
--cm-table-border: oklch(88% 0.008 255);
|
||||||
|
--cm-table-row-hover: oklch(95% 0.008 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 跟随系统的浅色偏好 */
|
/* 跟随系统的浅色偏好 */
|
||||||
@@ -187,5 +199,11 @@
|
|||||||
|
|
||||||
/* Markdown 高亮样式 */
|
/* Markdown 高亮样式 */
|
||||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||||
|
|
||||||
|
/* Markdown 表格样式 - 亮色主题 */
|
||||||
|
--cm-table-bg: oklch(97.5% 0.006 255);
|
||||||
|
--cm-table-header-bg: oklch(94% 0.01 255);
|
||||||
|
--cm-table-border: oklch(88% 0.008 255);
|
||||||
|
--cm-table-row-hover: oklch(95% 0.008 255);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { highlight } from './plugins/highlight';
|
|||||||
import { insert } from './plugins/insert';
|
import { insert } from './plugins/insert';
|
||||||
import { math } from './plugins/math';
|
import { math } from './plugins/math';
|
||||||
import { footnote } from './plugins/footnote';
|
import { footnote } from './plugins/footnote';
|
||||||
|
import table from "./plugins/table";
|
||||||
|
import {htmlBlockExtension} from "./plugins/html";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* markdown extensions
|
* markdown extensions
|
||||||
@@ -36,6 +38,8 @@ export const markdownExtensions: Extension = [
|
|||||||
insert(),
|
insert(),
|
||||||
math(),
|
math(),
|
||||||
footnote(),
|
footnote(),
|
||||||
|
table(),
|
||||||
|
htmlBlockExtension
|
||||||
];
|
];
|
||||||
|
|
||||||
export default markdownExtensions;
|
export default markdownExtensions;
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
ViewUpdate
|
ViewUpdate
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { Range } from '@codemirror/state';
|
import { RangeSetBuilder } from '@codemirror/state';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Pre-computed line decoration */
|
||||||
|
const LINE_DECO = Decoration.line({ class: 'cm-blockquote' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blockquote plugin.
|
* Blockquote plugin.
|
||||||
@@ -22,22 +25,69 @@ export function blockquote() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build blockquote decorations.
|
* Collect blockquote ranges in visible viewport.
|
||||||
*/
|
*/
|
||||||
function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
|
function collectBlockquoteRanges(view: EditorView): RangeTuple[] {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const ranges: RangeTuple[] = [];
|
||||||
const processedLines = new Set<number>();
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
enter(node) {
|
enter(node) {
|
||||||
if (node.type.name !== 'Blockquote') return;
|
if (node.type.name !== 'Blockquote') return;
|
||||||
|
if (seen.has(node.from)) return;
|
||||||
|
seen.add(node.from);
|
||||||
|
ranges.push([node.from, node.to]);
|
||||||
|
return false; // Don't recurse into nested
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]);
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
// Only add decorations when cursor is outside the blockquote
|
/**
|
||||||
// This allows selection highlighting to be visible when editing
|
* Get cursor's blockquote position (-1 if not in any).
|
||||||
if (!cursorInBlockquote) {
|
*/
|
||||||
// Add line decoration for each line in the blockquote
|
function getCursorBlockquotePos(view: EditorView, ranges: RangeTuple[]): number {
|
||||||
|
const sel = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [sel.from, sel.to];
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(selRange, range)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build blockquote decorations for visible viewport.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { pos: number; endPos?: number; deco: Decoration }[] = [];
|
||||||
|
const processedLines = new Set<number>();
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter(node) {
|
||||||
|
if (node.type.name !== 'Blockquote') return;
|
||||||
|
if (seen.has(node.from)) return;
|
||||||
|
seen.add(node.from);
|
||||||
|
|
||||||
|
const inBlock = checkRangeOverlap(
|
||||||
|
[node.from, node.to],
|
||||||
|
[view.state.selection.main.from, view.state.selection.main.to]
|
||||||
|
);
|
||||||
|
if (inBlock) return false;
|
||||||
|
|
||||||
|
// Line decorations
|
||||||
const startLine = view.state.doc.lineAt(node.from).number;
|
const startLine = view.state.doc.lineAt(node.from).number;
|
||||||
const endLine = view.state.doc.lineAt(node.to).number;
|
const endLine = view.state.doc.lineAt(node.to).number;
|
||||||
|
|
||||||
@@ -45,44 +95,67 @@ function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
|
|||||||
if (!processedLines.has(i)) {
|
if (!processedLines.has(i)) {
|
||||||
processedLines.add(i);
|
processedLines.add(i);
|
||||||
const line = view.state.doc.line(i);
|
const line = view.state.doc.line(i);
|
||||||
decorations.push(
|
items.push({ pos: line.from, deco: LINE_DECO });
|
||||||
Decoration.line({ class: 'cm-blockquote' }).range(line.from)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide quote marks when cursor is outside
|
// Hide quote marks
|
||||||
const cursor = node.node.cursor();
|
const cursor = node.node.cursor();
|
||||||
cursor.iterate((child) => {
|
cursor.iterate((child) => {
|
||||||
if (child.type.name === 'QuoteMark') {
|
if (child.type.name === 'QuoteMark') {
|
||||||
decorations.push(
|
items.push({ pos: child.from, endPos: child.to, deco: invisibleDecoration });
|
||||||
invisibleDecoration.range(child.from, child.to)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Don't recurse into nested blockquotes (handled by outer iteration)
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
// Sort and build
|
||||||
|
items.sort((a, b) => a.pos - b.pos);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.endPos !== undefined) {
|
||||||
|
builder.add(item.pos, item.endPos, item.deco);
|
||||||
|
} else {
|
||||||
|
builder.add(item.pos, item.pos, item.deco);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blockquote plugin class.
|
* Blockquote plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
class BlockQuotePlugin {
|
class BlockQuotePlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
|
private blockRanges: RangeTuple[] = [];
|
||||||
|
private cursorBlockPos = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = buildBlockQuoteDecorations(view);
|
this.blockRanges = collectBlockquoteRanges(view);
|
||||||
|
this.cursorBlockPos = getCursorBlockquotePos(view, this.blockRanges);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
this.decorations = buildBlockQuoteDecorations(update.view);
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.blockRanges = collectBlockquoteRanges(update.view);
|
||||||
|
this.cursorBlockPos = getCursorBlockquotePos(update.view, this.blockRanges);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const newPos = getCursorBlockquotePos(update.view, this.blockRanges);
|
||||||
|
if (newPos !== this.cursorBlockPos) {
|
||||||
|
this.cursorBlockPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Extension, Range } from '@codemirror/state';
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
@@ -8,21 +8,26 @@ import {
|
|||||||
WidgetType
|
WidgetType
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { isCursorInRange } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
/** Code block node types in syntax tree */
|
/** Code block node types in syntax tree */
|
||||||
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
|
const CODE_BLOCK_TYPES = new Set(['FencedCode', 'CodeBlock']);
|
||||||
|
|
||||||
/** Copy button icon SVGs (size controlled by CSS) */
|
/** Copy button icon SVGs */
|
||||||
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
|
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
|
||||||
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||||
|
|
||||||
/** Cache for code block metadata */
|
/** Pre-computed line decoration classes */
|
||||||
interface CodeBlockData {
|
const LINE_DECO_NORMAL = Decoration.line({ class: 'cm-codeblock' });
|
||||||
|
const LINE_DECO_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' });
|
||||||
|
const LINE_DECO_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' });
|
||||||
|
const LINE_DECO_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' });
|
||||||
|
|
||||||
|
/** Code block metadata for widget */
|
||||||
|
interface CodeBlockMeta {
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
content: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,36 +37,32 @@ interface CodeBlockData {
|
|||||||
* - Adds background styling to code blocks
|
* - Adds background styling to code blocks
|
||||||
* - Shows language label + copy button when language is specified
|
* - Shows language label + copy button when language is specified
|
||||||
* - Hides markers when cursor is outside block
|
* - Hides markers when cursor is outside block
|
||||||
* - Optimized with viewport-only rendering
|
* - Optimized with viewport-only rendering and minimal rebuilds
|
||||||
*/
|
*/
|
||||||
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Widget for displaying language label and copy button.
|
* Widget for displaying language label and copy button.
|
||||||
* Handles click events directly on the button element.
|
* Content is computed lazily on copy action.
|
||||||
*/
|
*/
|
||||||
class CodeBlockInfoWidget extends WidgetType {
|
class CodeBlockInfoWidget extends WidgetType {
|
||||||
constructor(
|
constructor(readonly meta: CodeBlockMeta) {
|
||||||
readonly data: CodeBlockData,
|
|
||||||
readonly view: EditorView
|
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
eq(other: CodeBlockInfoWidget): boolean {
|
eq(other: CodeBlockInfoWidget): boolean {
|
||||||
return other.data.from === this.data.from &&
|
return other.meta.from === this.meta.from &&
|
||||||
other.data.language === this.data.language;
|
other.meta.language === this.meta.language;
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(view: EditorView): HTMLElement {
|
||||||
const container = document.createElement('span');
|
const container = document.createElement('span');
|
||||||
container.className = 'cm-code-block-info';
|
container.className = 'cm-code-block-info';
|
||||||
|
|
||||||
// Only show language label if specified
|
if (this.meta.language) {
|
||||||
if (this.data.language) {
|
|
||||||
const lang = document.createElement('span');
|
const lang = document.createElement('span');
|
||||||
lang.className = 'cm-code-block-lang';
|
lang.className = 'cm-code-block-lang';
|
||||||
lang.textContent = this.data.language;
|
lang.textContent = this.meta.language;
|
||||||
container.append(lang);
|
container.append(lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,14 +71,12 @@ class CodeBlockInfoWidget extends WidgetType {
|
|||||||
btn.title = 'Copy';
|
btn.title = 'Copy';
|
||||||
btn.innerHTML = ICON_COPY;
|
btn.innerHTML = ICON_COPY;
|
||||||
|
|
||||||
// Direct click handler - more reliable than eventHandlers
|
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.handleCopy(btn);
|
this.copyContent(view, btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prevent mousedown from affecting editor
|
|
||||||
btn.addEventListener('mousedown', (e) => {
|
btn.addEventListener('mousedown', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -87,8 +86,13 @@ class CodeBlockInfoWidget extends WidgetType {
|
|||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCopy(btn: HTMLButtonElement): void {
|
/** Lazy content extraction and copy */
|
||||||
const content = getCodeContent(this.view, this.data.from, this.data.to);
|
private copyContent(view: EditorView, btn: HTMLButtonElement): void {
|
||||||
|
const { from, to } = this.meta;
|
||||||
|
const text = view.state.doc.sliceString(from, to);
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
||||||
|
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
navigator.clipboard.writeText(content).then(() => {
|
navigator.clipboard.writeText(content).then(() => {
|
||||||
@@ -99,134 +103,205 @@ class CodeBlockInfoWidget extends WidgetType {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore events to prevent editor focus changes
|
|
||||||
ignoreEvent(): boolean {
|
ignoreEvent(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Parsed code block info from single tree traversal */
|
||||||
* Extract language from code block node.
|
interface ParsedBlock {
|
||||||
*/
|
from: number;
|
||||||
function getLanguage(view: EditorView, node: any, offset: number): string | null {
|
to: number;
|
||||||
let lang: string | null = null;
|
language: string | null;
|
||||||
node.toTree().iterate({
|
marks: RangeTuple[]; // CodeMark and CodeInfo positions to hide
|
||||||
enter: ({ type, from, to }) => {
|
|
||||||
if (type.name === 'CodeInfo') {
|
|
||||||
lang = view.state.doc.sliceString(offset + from, offset + to).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return lang;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract code content (without fence markers).
|
* Parse a code block node in a single traversal.
|
||||||
|
* Extracts language and mark positions together.
|
||||||
*/
|
*/
|
||||||
function getCodeContent(view: EditorView, from: number, to: number): string {
|
function parseCodeBlock(view: EditorView, nodeFrom: number, nodeTo: number, node: any): ParsedBlock {
|
||||||
const lines = view.state.doc.sliceString(from, to).split('\n');
|
let language: string | null = null;
|
||||||
return lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
const marks: RangeTuple[] = [];
|
||||||
|
|
||||||
|
node.toTree().iterate({
|
||||||
|
enter: ({ type, from, to }) => {
|
||||||
|
const absFrom = nodeFrom + from;
|
||||||
|
const absTo = nodeFrom + to;
|
||||||
|
|
||||||
|
if (type.name === 'CodeInfo') {
|
||||||
|
language = view.state.doc.sliceString(absFrom, absTo).trim();
|
||||||
|
marks.push([absFrom, absTo]);
|
||||||
|
} else if (type.name === 'CodeMark') {
|
||||||
|
marks.push([absFrom, absTo]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { from: nodeFrom, to: nodeTo, language, marks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find which code block the cursor is in (returns block start position, or -1 if not in any).
|
||||||
|
*/
|
||||||
|
function getCursorBlockPosition(view: EditorView, blocks: RangeTuple[]): number {
|
||||||
|
const { ranges } = view.state.selection;
|
||||||
|
for (const sel of ranges) {
|
||||||
|
const selRange: RangeTuple = [sel.from, sel.to];
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (checkRangeOverlap(selRange, block)) {
|
||||||
|
return block[0]; // Return the block's start position as identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all code block ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectCodeBlockRanges(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: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (!CODE_BLOCK_TYPES.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build decorations for visible code blocks.
|
* Build decorations for visible code blocks.
|
||||||
|
* Uses RangeSetBuilder for efficient sorted construction.
|
||||||
*/
|
*/
|
||||||
function buildDecorations(view: EditorView): { decorations: DecorationSet; blocks: Map<number, CodeBlockData> } {
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
const blocks = new Map<number, CodeBlockData>();
|
const items: { pos: number; endPos?: number; deco: Decoration; isWidget?: boolean; isReplace?: boolean }[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<number>();
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
if (!CODE_BLOCK_TYPES.includes(type.name as any)) return;
|
if (!CODE_BLOCK_TYPES.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
const key = `${nodeFrom}:${nodeTo}`;
|
// Check if cursor is in this block
|
||||||
if (seen.has(key)) return;
|
const inBlock = checkRangeOverlap(
|
||||||
seen.add(key);
|
[nodeFrom, nodeTo],
|
||||||
|
[view.state.selection.main.from, view.state.selection.main.to]
|
||||||
const inBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
);
|
||||||
if (inBlock) return;
|
if (inBlock) return;
|
||||||
|
|
||||||
const language = getLanguage(view, node, nodeFrom);
|
// Parse block in single traversal
|
||||||
|
const block = parseCodeBlock(view, nodeFrom, nodeTo, node);
|
||||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||||
const endLine = view.state.doc.lineAt(nodeTo);
|
const endLine = view.state.doc.lineAt(nodeTo);
|
||||||
|
|
||||||
|
// Add line decorations
|
||||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||||
const line = view.state.doc.line(num);
|
const line = view.state.doc.line(num);
|
||||||
const pos: string[] = ['cm-codeblock'];
|
let deco: Decoration;
|
||||||
if (num === startLine.number) pos.push('cm-codeblock-begin');
|
|
||||||
if (num === endLine.number) pos.push('cm-codeblock-end');
|
|
||||||
|
|
||||||
decorations.push(
|
if (startLine.number === endLine.number) {
|
||||||
Decoration.line({ class: pos.join(' ') }).range(line.from)
|
deco = LINE_DECO_SINGLE;
|
||||||
);
|
} else if (num === startLine.number) {
|
||||||
|
deco = LINE_DECO_BEGIN;
|
||||||
|
} else if (num === endLine.number) {
|
||||||
|
deco = LINE_DECO_END;
|
||||||
|
} else {
|
||||||
|
deco = LINE_DECO_NORMAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info widget with copy button (always show, language label only if specified)
|
items.push({ pos: line.from, deco });
|
||||||
const content = getCodeContent(view, nodeFrom, nodeTo);
|
}
|
||||||
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
|
|
||||||
blocks.set(nodeFrom, data);
|
|
||||||
|
|
||||||
decorations.push(
|
// Add info widget
|
||||||
Decoration.widget({
|
const meta: CodeBlockMeta = {
|
||||||
widget: new CodeBlockInfoWidget(data, view),
|
from: nodeFrom,
|
||||||
|
to: nodeTo,
|
||||||
|
language: block.language
|
||||||
|
};
|
||||||
|
items.push({
|
||||||
|
pos: startLine.to,
|
||||||
|
deco: Decoration.widget({
|
||||||
|
widget: new CodeBlockInfoWidget(meta),
|
||||||
side: 1
|
side: 1
|
||||||
}).range(startLine.to)
|
}),
|
||||||
);
|
isWidget: true
|
||||||
|
});
|
||||||
|
|
||||||
// Hide markers
|
// Hide marks
|
||||||
node.toTree().iterate({
|
for (const [mFrom, mTo] of block.marks) {
|
||||||
enter: ({ type: t, from: f, to: t2 }) => {
|
items.push({ pos: mFrom, endPos: mTo, deco: invisibleDecoration, isReplace: true });
|
||||||
if (t.name === 'CodeInfo' || t.name === 'CodeMark') {
|
|
||||||
decorations.push(Decoration.replace({}).range(nodeFrom + f, nodeFrom + t2));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by position and add to builder
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.pos !== b.pos) return a.pos - b.pos;
|
||||||
|
// Widgets should come after line decorations at same position
|
||||||
|
return (a.isWidget ? 1 : 0) - (b.isWidget ? 1 : 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.isReplace && item.endPos !== undefined) {
|
||||||
|
builder.add(item.pos, item.endPos, item.deco);
|
||||||
|
} else {
|
||||||
|
builder.add(item.pos, item.pos, item.deco);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { decorations: Decoration.set(decorations, true), blocks };
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Code block plugin with optimized updates.
|
* Code block plugin with optimized update detection.
|
||||||
*/
|
*/
|
||||||
class CodeBlockPluginClass {
|
class CodeBlockPluginClass {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
blocks: Map<number, CodeBlockData>;
|
private blockRanges: RangeTuple[] = [];
|
||||||
private lastHead = -1;
|
private cursorBlockPos = -1; // Which block the cursor is in (-1 = none)
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
const result = buildDecorations(view);
|
this.blockRanges = collectCodeBlockRanges(view);
|
||||||
this.decorations = result.decorations;
|
this.cursorBlockPos = getCursorBlockPosition(view, this.blockRanges);
|
||||||
this.blocks = result.blocks;
|
this.decorations = buildDecorations(view);
|
||||||
this.lastHead = view.state.selection.main.head;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate): void {
|
update(update: ViewUpdate): void {
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
// Skip rebuild if cursor stayed on same line
|
// Always rebuild on doc or viewport change
|
||||||
if (selectionSet && !docChanged && !viewportChanged) {
|
if (docChanged || viewportChanged) {
|
||||||
const newHead = update.state.selection.main.head;
|
this.blockRanges = collectCodeBlockRanges(update.view);
|
||||||
const oldLine = update.startState.doc.lineAt(this.lastHead).number;
|
this.cursorBlockPos = getCursorBlockPosition(update.view, this.blockRanges);
|
||||||
const newLine = update.state.doc.lineAt(newHead).number;
|
this.decorations = buildDecorations(update.view);
|
||||||
|
|
||||||
if (oldLine === newLine) {
|
|
||||||
this.lastHead = newHead;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (docChanged || viewportChanged || selectionSet) {
|
// For selection changes, only rebuild if cursor moves to a different block
|
||||||
const result = buildDecorations(update.view);
|
if (selectionSet) {
|
||||||
this.decorations = result.decorations;
|
const newBlockPos = getCursorBlockPosition(update.view, this.blockRanges);
|
||||||
this.blocks = result.blocks;
|
|
||||||
this.lastHead = update.state.selection.main.head;
|
if (newBlockPos !== this.cursorBlockPos) {
|
||||||
|
this.cursorBlockPos = newBlockPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,18 +315,17 @@ const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
|
|||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-codeblock': {
|
'.cm-codeblock': {
|
||||||
backgroundColor: 'var(--cm-codeblock-bg)'
|
backgroundColor: 'var(--cm-codeblock-bg)',
|
||||||
|
fontFamily: 'inherit',
|
||||||
},
|
},
|
||||||
'.cm-codeblock-begin': {
|
'.cm-codeblock-begin': {
|
||||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||||
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
boxShadow: 'inset 0 1px 0 var(--text-primary)'
|
|
||||||
},
|
},
|
||||||
'.cm-codeblock-end': {
|
'.cm-codeblock-end': {
|
||||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
|
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
|
||||||
boxShadow: 'inset 0 -1px 0 var(--text-primary)'
|
|
||||||
},
|
},
|
||||||
'.cm-code-block-info': {
|
'.cm-code-block-info': {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
ViewUpdate,
|
ViewUpdate,
|
||||||
WidgetType
|
WidgetType
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { isCursorInRange } from '../util';
|
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||||
import { emojies } from '@/common/constant/emojies';
|
import { emojies } from '@/common/constant/emojies';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,14 +17,11 @@ import { emojies } from '@/common/constant/emojies';
|
|||||||
* - Detects emoji patterns like :smile:, :heart:, etc.
|
* - Detects emoji patterns like :smile:, :heart:, etc.
|
||||||
* - Replaces them with actual emoji characters
|
* - Replaces them with actual emoji characters
|
||||||
* - Shows the original text when cursor is nearby
|
* - Shows the original text when cursor is nearby
|
||||||
* - Uses RangeSetBuilder for optimal performance
|
* - Optimized with cached matches and minimal rebuilds
|
||||||
* - Supports 1900+ emojis from the comprehensive emoji dictionary
|
|
||||||
*/
|
*/
|
||||||
export const emoji = (): Extension => [emojiPlugin, baseTheme];
|
export const emoji = (): Extension => [emojiPlugin, baseTheme];
|
||||||
|
|
||||||
/**
|
/** Non-global regex for matchAll (more efficient than global with lastIndex reset) */
|
||||||
* Emoji regex pattern for matching :emoji_name: syntax.
|
|
||||||
*/
|
|
||||||
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
|
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +49,7 @@ class EmojiWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match result for emoji patterns.
|
* Cached emoji match.
|
||||||
*/
|
*/
|
||||||
interface EmojiMatch {
|
interface EmojiMatch {
|
||||||
from: number;
|
from: number;
|
||||||
@@ -62,46 +59,59 @@ interface EmojiMatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all emoji matches in a text range.
|
* Find all emoji matches in visible ranges.
|
||||||
*/
|
*/
|
||||||
function findEmojiMatches(text: string, offset: number): EmojiMatch[] {
|
function findAllEmojiMatches(view: EditorView): EmojiMatch[] {
|
||||||
const matches: EmojiMatch[] = [];
|
const matches: EmojiMatch[] = [];
|
||||||
|
const doc = view.state.doc;
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
const text = doc.sliceString(from, to);
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
// Reset regex state
|
|
||||||
EMOJI_REGEX.lastIndex = 0;
|
EMOJI_REGEX.lastIndex = 0;
|
||||||
|
|
||||||
while ((match = EMOJI_REGEX.exec(text)) !== null) {
|
while ((match = EMOJI_REGEX.exec(text)) !== null) {
|
||||||
const name = match[1].toLowerCase();
|
const name = match[1].toLowerCase();
|
||||||
const emoji = emojies[name];
|
const emojiChar = emojies[name];
|
||||||
|
|
||||||
if (emoji) {
|
if (emojiChar) {
|
||||||
matches.push({
|
matches.push({
|
||||||
from: offset + match.index,
|
from: from + match.index,
|
||||||
to: offset + match.index + match[0].length,
|
to: from + match.index + match[0].length,
|
||||||
name,
|
name,
|
||||||
emoji
|
emoji: emojiChar
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build emoji decorations using RangeSetBuilder.
|
* Get which emoji the cursor is in (-1 if none).
|
||||||
*/
|
*/
|
||||||
function buildEmojiDecorations(view: EditorView): DecorationSet {
|
function getCursorEmojiIndex(matches: EmojiMatch[], selFrom: number, selTo: number): number {
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
const doc = view.state.doc;
|
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (let i = 0; i < matches.length; i++) {
|
||||||
const text = doc.sliceString(from, to);
|
if (checkRangeOverlap([matches[i].from, matches[i].to], selRange)) {
|
||||||
const matches = findEmojiMatches(text, from);
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations from cached matches.
|
||||||
|
*/
|
||||||
|
function buildDecorations(matches: EmojiMatch[], selFrom: number, selTo: number): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
// Skip if cursor is in this range
|
// Skip if cursor overlaps this emoji
|
||||||
if (isCursorInRange(view.state, [match.from, match.to])) {
|
if (checkRangeOverlap([match.from, match.to], selRange)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,39 +123,45 @@ function buildEmojiDecorations(view: EditorView): DecorationSet {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return builder.finish();
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emoji plugin with optimized update detection.
|
* Emoji plugin with cached matches and optimized updates.
|
||||||
*/
|
*/
|
||||||
class EmojiPlugin {
|
class EmojiPlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
private lastSelectionHead: number = -1;
|
private matches: EmojiMatch[] = [];
|
||||||
|
private cursorEmojiIdx = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = buildEmojiDecorations(view);
|
this.matches = findAllEmojiMatches(view);
|
||||||
this.lastSelectionHead = view.state.selection.main.head;
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to);
|
||||||
|
this.decorations = buildDecorations(this.matches, from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
// Always rebuild on doc or viewport change
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
if (update.docChanged || update.viewportChanged) {
|
|
||||||
this.decorations = buildEmojiDecorations(update.view);
|
// Rebuild matches on doc or viewport change
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
if (docChanged || viewportChanged) {
|
||||||
|
this.matches = findAllEmojiMatches(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to);
|
||||||
|
this.decorations = buildDecorations(this.matches, from, to);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For selection changes, check if we moved significantly
|
// For selection changes, only rebuild if cursor enters/leaves an emoji
|
||||||
if (update.selectionSet) {
|
if (selectionSet) {
|
||||||
const newHead = update.state.selection.main.head;
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newIdx = getCursorEmojiIndex(this.matches, from, to);
|
||||||
|
|
||||||
// Only rebuild if cursor moved to a different position
|
if (newIdx !== this.cursorEmojiIdx) {
|
||||||
if (newHead !== this.lastSelectionHead) {
|
this.cursorEmojiIdx = newIdx;
|
||||||
this.decorations = buildEmojiDecorations(update.view);
|
this.decorations = buildDecorations(this.matches, from, to);
|
||||||
this.lastSelectionHead = newHead;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +173,6 @@ const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for emoji.
|
* Base theme for emoji.
|
||||||
* Inherits font size and line height from parent element.
|
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-emoji': {
|
'.cm-emoji': {
|
||||||
|
|||||||
@@ -7,14 +7,9 @@
|
|||||||
* - Shows footnote content on hover (tooltip)
|
* - Shows footnote content on hover (tooltip)
|
||||||
* - Click to jump between reference and definition
|
* - Click to jump between reference and definition
|
||||||
* - Hides syntax marks when cursor is outside
|
* - Hides syntax marks when cursor is outside
|
||||||
*
|
|
||||||
* Syntax (MultiMarkdown/PHP Markdown Extra):
|
|
||||||
* - Reference: [^id] → renders as superscript
|
|
||||||
* - Definition: [^id]: content
|
|
||||||
* - Inline footnote: ^[content] → renders as superscript with embedded content
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Extension, Range, StateField, EditorState } from '@codemirror/state';
|
import { Extension, RangeSetBuilder, EditorState } from '@codemirror/state';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import {
|
import {
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
@@ -26,84 +21,72 @@ import {
|
|||||||
hoverTooltip,
|
hoverTooltip,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Information about a footnote definition.
|
|
||||||
*/
|
|
||||||
interface FootnoteDefinition {
|
interface FootnoteDefinition {
|
||||||
/** The footnote identifier (e.g., "1", "note") */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** The content of the footnote */
|
|
||||||
content: string;
|
content: string;
|
||||||
/** Start position in document */
|
|
||||||
from: number;
|
from: number;
|
||||||
/** End position in document */
|
|
||||||
to: number;
|
to: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Information about a footnote reference.
|
|
||||||
*/
|
|
||||||
interface FootnoteReference {
|
interface FootnoteReference {
|
||||||
/** The footnote identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** Start position in document */
|
|
||||||
from: number;
|
from: number;
|
||||||
/** End position in document */
|
|
||||||
to: number;
|
to: number;
|
||||||
/** Numeric index (1-based, for display) */
|
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Information about an inline footnote.
|
|
||||||
*/
|
|
||||||
interface InlineFootnoteInfo {
|
interface InlineFootnoteInfo {
|
||||||
/** The content of the inline footnote */
|
|
||||||
content: string;
|
content: string;
|
||||||
/** Start position in document */
|
|
||||||
from: number;
|
from: number;
|
||||||
/** End position in document */
|
|
||||||
to: number;
|
to: number;
|
||||||
/** Numeric index (1-based, for display) */
|
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collected footnote data from the document.
|
* Collected footnote data with O(1) lookup indexes.
|
||||||
* Uses Maps for O(1) lookup by position and id.
|
|
||||||
*/
|
*/
|
||||||
interface FootnoteData {
|
interface FootnoteData {
|
||||||
definitions: Map<string, FootnoteDefinition>;
|
definitions: Map<string, FootnoteDefinition>;
|
||||||
references: FootnoteReference[];
|
references: FootnoteReference[];
|
||||||
inlineFootnotes: InlineFootnoteInfo[];
|
inlineFootnotes: InlineFootnoteInfo[];
|
||||||
// Index maps for O(1) lookup
|
|
||||||
referencesByPos: Map<number, FootnoteReference>;
|
referencesByPos: Map<number, FootnoteReference>;
|
||||||
inlineByPos: Map<number, InlineFootnoteInfo>;
|
inlineByPos: Map<number, InlineFootnoteInfo>;
|
||||||
|
definitionByPos: Map<number, FootnoteDefinition>; // For position-based lookup
|
||||||
firstRefById: Map<string, FootnoteReference>;
|
firstRefById: Map<string, FootnoteReference>;
|
||||||
|
// All footnote ranges for cursor detection
|
||||||
|
allRanges: RangeTuple[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Footnote Collection
|
// Footnote Collection (cached via closure)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
let cachedData: FootnoteData | null = null;
|
||||||
|
let cachedDocLength = -1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all footnote definitions, references, and inline footnotes from the document.
|
* Collect all footnote data from the document.
|
||||||
* Builds index maps for O(1) lookup during decoration and tooltip handling.
|
|
||||||
*/
|
*/
|
||||||
function collectFootnotes(state: EditorState): FootnoteData {
|
function collectFootnotes(state: EditorState): FootnoteData {
|
||||||
|
// Simple cache invalidation based on doc length
|
||||||
|
if (cachedData && cachedDocLength === state.doc.length) {
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
|
||||||
const definitions = new Map<string, FootnoteDefinition>();
|
const definitions = new Map<string, FootnoteDefinition>();
|
||||||
const references: FootnoteReference[] = [];
|
const references: FootnoteReference[] = [];
|
||||||
const inlineFootnotes: InlineFootnoteInfo[] = [];
|
const inlineFootnotes: InlineFootnoteInfo[] = [];
|
||||||
// Index maps for fast lookup
|
|
||||||
const referencesByPos = new Map<number, FootnoteReference>();
|
const referencesByPos = new Map<number, FootnoteReference>();
|
||||||
const inlineByPos = new Map<number, InlineFootnoteInfo>();
|
const inlineByPos = new Map<number, InlineFootnoteInfo>();
|
||||||
|
const definitionByPos = new Map<number, FootnoteDefinition>();
|
||||||
const firstRefById = new Map<string, FootnoteReference>();
|
const firstRefById = new Map<string, FootnoteReference>();
|
||||||
|
const allRanges: RangeTuple[] = [];
|
||||||
const seenIds = new Map<string, number>();
|
const seenIds = new Map<string, number>();
|
||||||
let inlineIndex = 0;
|
let inlineIndex = 0;
|
||||||
|
|
||||||
@@ -119,7 +102,10 @@ function collectFootnotes(state: EditorState): FootnoteData {
|
|||||||
? state.sliceDoc(contentNode.from, contentNode.to).trim()
|
? state.sliceDoc(contentNode.from, contentNode.to).trim()
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
definitions.set(id, { id, content, from, to });
|
const def: FootnoteDefinition = { id, content, from, to };
|
||||||
|
definitions.set(id, def);
|
||||||
|
definitionByPos.set(from, def);
|
||||||
|
allRanges.push([from, to]);
|
||||||
}
|
}
|
||||||
} else if (type.name === 'FootnoteReference') {
|
} else if (type.name === 'FootnoteReference') {
|
||||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||||
@@ -140,8 +126,8 @@ function collectFootnotes(state: EditorState): FootnoteData {
|
|||||||
|
|
||||||
references.push(ref);
|
references.push(ref);
|
||||||
referencesByPos.set(from, ref);
|
referencesByPos.set(from, ref);
|
||||||
|
allRanges.push([from, to]);
|
||||||
|
|
||||||
// Track first reference for each id
|
|
||||||
if (!firstRefById.has(id)) {
|
if (!firstRefById.has(id)) {
|
||||||
firstRefById.set(id, ref);
|
firstRefById.set(id, ref);
|
||||||
}
|
}
|
||||||
@@ -162,48 +148,31 @@ function collectFootnotes(state: EditorState): FootnoteData {
|
|||||||
|
|
||||||
inlineFootnotes.push(info);
|
inlineFootnotes.push(info);
|
||||||
inlineByPos.set(from, info);
|
inlineByPos.set(from, info);
|
||||||
|
allRanges.push([from, to]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
cachedData = {
|
||||||
definitions,
|
definitions,
|
||||||
references,
|
references,
|
||||||
inlineFootnotes,
|
inlineFootnotes,
|
||||||
referencesByPos,
|
referencesByPos,
|
||||||
inlineByPos,
|
inlineByPos,
|
||||||
|
definitionByPos,
|
||||||
firstRefById,
|
firstRefById,
|
||||||
|
allRanges,
|
||||||
};
|
};
|
||||||
|
cachedDocLength = state.doc.length;
|
||||||
|
|
||||||
|
return cachedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// State Field
|
// Widgets
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* State field to track footnote data across the document.
|
|
||||||
* This allows efficient lookup for tooltips and navigation.
|
|
||||||
*/
|
|
||||||
export const footnoteDataField = StateField.define<FootnoteData>({
|
|
||||||
create(state) {
|
|
||||||
return collectFootnotes(state);
|
|
||||||
},
|
|
||||||
update(value, tr) {
|
|
||||||
if (tr.docChanged) {
|
|
||||||
return collectFootnotes(tr.state);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Widget
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget to display footnote reference as superscript.
|
|
||||||
*/
|
|
||||||
class FootnoteRefWidget extends WidgetType {
|
class FootnoteRefWidget extends WidgetType {
|
||||||
constructor(
|
constructor(
|
||||||
readonly id: string,
|
readonly id: string,
|
||||||
@@ -235,9 +204,6 @@ class FootnoteRefWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget to display inline footnote as superscript.
|
|
||||||
*/
|
|
||||||
class InlineFootnoteWidget extends WidgetType {
|
class InlineFootnoteWidget extends WidgetType {
|
||||||
constructor(
|
constructor(
|
||||||
readonly content: string,
|
readonly content: string,
|
||||||
@@ -265,9 +231,6 @@ class InlineFootnoteWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget to display footnote definition label.
|
|
||||||
*/
|
|
||||||
class FootnoteDefLabelWidget extends WidgetType {
|
class FootnoteDefLabelWidget extends WidgetType {
|
||||||
constructor(readonly id: string) {
|
constructor(readonly id: string) {
|
||||||
super();
|
super();
|
||||||
@@ -290,25 +253,46 @@ class FootnoteDefLabelWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cursor Detection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which footnote range the cursor is in (returns start position, -1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorFootnotePos(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 -1;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Decorations
|
// Decorations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build decorations for footnote references and inline footnotes.
|
* Build decorations using RangeSetBuilder.
|
||||||
*/
|
*/
|
||||||
function buildDecorations(view: EditorView): DecorationSet {
|
function buildDecorations(view: EditorView, data: FootnoteData): DecorationSet {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
const data = view.state.field(footnoteDataField);
|
const items: { pos: number; endPos?: number; deco: Decoration; priority?: number }[] = [];
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
// Handle footnote references
|
const inCursor = checkRangeOverlap([nodeFrom, nodeTo], selRange);
|
||||||
|
|
||||||
|
// Footnote References
|
||||||
if (type.name === 'FootnoteReference') {
|
if (type.name === 'FootnoteReference') {
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
|
||||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||||
const marks = node.getChildren('FootnoteReferenceMark');
|
const marks = node.getChildren('FootnoteReferenceMark');
|
||||||
|
|
||||||
@@ -317,51 +301,41 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
const ref = data.referencesByPos.get(nodeFrom);
|
const ref = data.referencesByPos.get(nodeFrom);
|
||||||
|
|
||||||
if (!cursorInRange && ref && ref.id === id) {
|
if (!inCursor && ref && ref.id === id) {
|
||||||
// Hide the entire syntax and show widget
|
items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration });
|
||||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
items.push({
|
||||||
|
pos: nodeTo,
|
||||||
// Add widget at the end
|
deco: Decoration.widget({
|
||||||
const widget = new FootnoteRefWidget(
|
widget: new FootnoteRefWidget(id, ref.index, data.definitions.has(id)),
|
||||||
id,
|
|
||||||
ref.index,
|
|
||||||
data.definitions.has(id)
|
|
||||||
);
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget({
|
|
||||||
widget,
|
|
||||||
side: 1,
|
side: 1,
|
||||||
}).range(nodeTo)
|
}),
|
||||||
);
|
priority: 1
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle footnote definitions
|
// Footnote Definitions
|
||||||
if (type.name === 'FootnoteDefinition') {
|
if (type.name === 'FootnoteDefinition') {
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
|
||||||
const marks = node.getChildren('FootnoteDefinitionMark');
|
const marks = node.getChildren('FootnoteDefinitionMark');
|
||||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||||
|
|
||||||
if (!cursorInRange && marks.length >= 2 && labelNode) {
|
if (!inCursor && marks.length >= 2 && labelNode) {
|
||||||
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
|
||||||
// Hide the entire [^id]: part
|
items.push({ pos: marks[0].from, endPos: marks[1].to, deco: invisibleDecoration });
|
||||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to));
|
items.push({
|
||||||
|
pos: marks[1].to,
|
||||||
// Add widget to show [id]
|
deco: Decoration.widget({
|
||||||
const widget = new FootnoteDefLabelWidget(id);
|
widget: new FootnoteDefLabelWidget(id),
|
||||||
decorations.push(
|
|
||||||
Decoration.widget({
|
|
||||||
widget,
|
|
||||||
side: 1,
|
side: 1,
|
||||||
}).range(marks[1].to)
|
}),
|
||||||
);
|
priority: 1
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle inline footnotes
|
// Inline Footnotes
|
||||||
if (type.name === 'InlineFootnote') {
|
if (type.name === 'InlineFootnote') {
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
|
||||||
const contentNode = node.getChild('InlineFootnoteContent');
|
const contentNode = node.getChild('InlineFootnoteContent');
|
||||||
const marks = node.getChildren('InlineFootnoteMark');
|
const marks = node.getChildren('InlineFootnoteMark');
|
||||||
|
|
||||||
@@ -369,58 +343,80 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
|
|
||||||
const inlineNote = data.inlineByPos.get(nodeFrom);
|
const inlineNote = data.inlineByPos.get(nodeFrom);
|
||||||
|
|
||||||
if (!cursorInRange && inlineNote) {
|
if (!inCursor && inlineNote) {
|
||||||
// Hide the entire syntax and show widget
|
items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration });
|
||||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
items.push({
|
||||||
|
pos: nodeTo,
|
||||||
// Add widget at the end
|
deco: Decoration.widget({
|
||||||
const widget = new InlineFootnoteWidget(
|
widget: new InlineFootnoteWidget(inlineNote.content, inlineNote.index),
|
||||||
inlineNote.content,
|
|
||||||
inlineNote.index
|
|
||||||
);
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget({
|
|
||||||
widget,
|
|
||||||
side: 1,
|
side: 1,
|
||||||
}).range(nodeTo)
|
}),
|
||||||
);
|
priority: 1
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
// Sort by position, widgets after replace at same position
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.pos !== b.pos) return a.pos - b.pos;
|
||||||
|
return (a.priority || 0) - (b.priority || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.endPos !== undefined) {
|
||||||
|
builder.add(item.pos, item.endPos, item.deco);
|
||||||
|
} else {
|
||||||
|
builder.add(item.pos, item.pos, item.deco);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Plugin Class
|
// Plugin
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Footnote view plugin with optimized update detection.
|
|
||||||
*/
|
|
||||||
class FootnotePlugin {
|
class FootnotePlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
private lastSelectionHead: number = -1;
|
private data: FootnoteData;
|
||||||
|
private cursorFootnotePos = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = buildDecorations(view);
|
this.data = collectFootnotes(view.state);
|
||||||
this.lastSelectionHead = view.state.selection.main.head;
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorFootnotePos = getCursorFootnotePos(this.data.allRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view, this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.viewportChanged) {
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
if (docChanged) {
|
||||||
|
// Invalidate cache on doc change
|
||||||
|
cachedData = null;
|
||||||
|
this.data = collectFootnotes(update.state);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorFootnotePos = getCursorFootnotePos(this.data.allRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view, this.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.selectionSet) {
|
if (viewportChanged) {
|
||||||
const newHead = update.state.selection.main.head;
|
this.decorations = buildDecorations(update.view, this.data);
|
||||||
if (newHead !== this.lastSelectionHead) {
|
return;
|
||||||
this.decorations = buildDecorations(update.view);
|
}
|
||||||
this.lastSelectionHead = newHead;
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorFootnotePos(this.data.allRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorFootnotePos) {
|
||||||
|
this.cursorFootnotePos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view, this.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,18 +430,14 @@ const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
|
|||||||
// Hover Tooltip
|
// Hover Tooltip
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Hover tooltip that shows footnote content.
|
|
||||||
*/
|
|
||||||
const footnoteHoverTooltip = hoverTooltip(
|
const footnoteHoverTooltip = hoverTooltip(
|
||||||
(view, pos): Tooltip | null => {
|
(view, pos): Tooltip | null => {
|
||||||
const data = view.state.field(footnoteDataField);
|
const data = collectFootnotes(view.state);
|
||||||
|
|
||||||
// Check if hovering over a footnote reference widget
|
// Check widget elements first
|
||||||
const target = document.elementFromPoint(
|
const coords = view.coordsAtPos(pos);
|
||||||
view.coordsAtPos(pos)?.left ?? 0,
|
if (coords) {
|
||||||
view.coordsAtPos(pos)?.top ?? 0
|
const target = document.elementFromPoint(coords.left, coords.top) as HTMLElement | null;
|
||||||
) as HTMLElement | null;
|
|
||||||
|
|
||||||
if (target?.classList.contains('cm-footnote-ref')) {
|
if (target?.classList.contains('cm-footnote-ref')) {
|
||||||
const id = target.dataset.footnoteId;
|
const id = target.dataset.footnoteId;
|
||||||
@@ -462,7 +454,6 @@ const footnoteHoverTooltip = hoverTooltip(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if hovering over an inline footnote widget
|
|
||||||
if (target?.classList.contains('cm-inline-footnote-ref')) {
|
if (target?.classList.contains('cm-inline-footnote-ref')) {
|
||||||
const content = target.dataset.footnoteContent;
|
const content = target.dataset.footnoteContent;
|
||||||
const index = target.dataset.footnoteIndex;
|
const index = target.dataset.footnoteIndex;
|
||||||
@@ -475,71 +466,63 @@ const footnoteHoverTooltip = hoverTooltip(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if position is within a footnote reference node
|
// Check by position using indexed data
|
||||||
let foundId: string | null = null;
|
const ref = data.referencesByPos.get(pos);
|
||||||
let foundPos: number = pos;
|
if (ref) {
|
||||||
let foundInlineContent: string | null = null;
|
const def = data.definitions.get(ref.id);
|
||||||
let foundInlineIndex: number | null = null;
|
|
||||||
|
|
||||||
syntaxTree(view.state).iterate({
|
|
||||||
from: pos,
|
|
||||||
to: pos,
|
|
||||||
enter: ({ type, from, to, node }) => {
|
|
||||||
if (type.name === 'FootnoteReference') {
|
|
||||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
|
||||||
if (labelNode && pos >= from && pos <= to) {
|
|
||||||
foundId = view.state.sliceDoc(labelNode.from, labelNode.to);
|
|
||||||
foundPos = to;
|
|
||||||
}
|
|
||||||
} else if (type.name === 'InlineFootnote') {
|
|
||||||
const contentNode = node.getChild('InlineFootnoteContent');
|
|
||||||
if (contentNode && pos >= from && pos <= to) {
|
|
||||||
foundInlineContent = view.state.sliceDoc(contentNode.from, contentNode.to);
|
|
||||||
const inlineNote = data.inlineByPos.get(from);
|
|
||||||
if (inlineNote) {
|
|
||||||
foundInlineIndex = inlineNote.index;
|
|
||||||
}
|
|
||||||
foundPos = to;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (foundId) {
|
|
||||||
const def = data.definitions.get(foundId);
|
|
||||||
if (def) {
|
if (def) {
|
||||||
const tooltipId = foundId;
|
|
||||||
const tooltipPos = foundPos;
|
|
||||||
return {
|
return {
|
||||||
pos: tooltipPos,
|
pos: ref.to,
|
||||||
above: true,
|
above: true,
|
||||||
arrow: true,
|
arrow: true,
|
||||||
create: () => createTooltipDom(tooltipId, def.content),
|
create: () => createTooltipDom(ref.id, def.content),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundInlineContent && foundInlineIndex !== null) {
|
const inline = data.inlineByPos.get(pos);
|
||||||
const tooltipContent = foundInlineContent;
|
if (inline) {
|
||||||
const tooltipIndex = foundInlineIndex;
|
|
||||||
const tooltipPos = foundPos;
|
|
||||||
return {
|
return {
|
||||||
pos: tooltipPos,
|
pos: inline.to,
|
||||||
above: true,
|
above: true,
|
||||||
arrow: true,
|
arrow: true,
|
||||||
create: () => createInlineTooltipDom(tooltipIndex, tooltipContent),
|
create: () => createInlineTooltipDom(inline.index, inline.content),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: check if pos is within any footnote range
|
||||||
|
for (const ref of data.references) {
|
||||||
|
if (pos >= ref.from && pos <= ref.to) {
|
||||||
|
const def = data.definitions.get(ref.id);
|
||||||
|
if (def) {
|
||||||
|
return {
|
||||||
|
pos: ref.to,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createTooltipDom(ref.id, def.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inline of data.inlineFootnotes) {
|
||||||
|
if (pos >= inline.from && pos <= inline.to) {
|
||||||
|
return {
|
||||||
|
pos: inline.to,
|
||||||
|
above: true,
|
||||||
|
arrow: true,
|
||||||
|
create: () => createInlineTooltipDom(inline.index, inline.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
{ hoverTime: 300 }
|
{ hoverTime: 300 }
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Create tooltip DOM element for regular footnote.
|
|
||||||
*/
|
|
||||||
function createTooltipDom(id: string, content: string): { dom: HTMLElement } {
|
function createTooltipDom(id: string, content: string): { dom: HTMLElement } {
|
||||||
const dom = document.createElement('div');
|
const dom = document.createElement('div');
|
||||||
dom.className = 'cm-footnote-tooltip';
|
dom.className = 'cm-footnote-tooltip';
|
||||||
@@ -558,9 +541,6 @@ function createTooltipDom(id: string, content: string): { dom: HTMLElement } {
|
|||||||
return { dom };
|
return { dom };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create tooltip DOM element for inline footnote.
|
|
||||||
*/
|
|
||||||
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
|
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
|
||||||
const dom = document.createElement('div');
|
const dom = document.createElement('div');
|
||||||
dom.className = 'cm-footnote-tooltip';
|
dom.className = 'cm-footnote-tooltip';
|
||||||
@@ -583,26 +563,18 @@ function createInlineTooltipDom(index: number, content: string): { dom: HTMLElem
|
|||||||
// Click Handler
|
// Click Handler
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Click handler for footnote navigation.
|
|
||||||
* Uses mousedown to intercept before editor moves cursor.
|
|
||||||
* - Click on reference → jump to definition
|
|
||||||
* - Click on definition label → jump to first reference
|
|
||||||
*/
|
|
||||||
const footnoteClickHandler = EditorView.domEventHandlers({
|
const footnoteClickHandler = EditorView.domEventHandlers({
|
||||||
mousedown(event, view) {
|
mousedown(event, view) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
// Handle click on footnote reference widget
|
// Click on footnote reference → jump to definition
|
||||||
if (target.classList.contains('cm-footnote-ref')) {
|
if (target.classList.contains('cm-footnote-ref')) {
|
||||||
const id = target.dataset.footnoteId;
|
const id = target.dataset.footnoteId;
|
||||||
if (id) {
|
if (id) {
|
||||||
const data = view.state.field(footnoteDataField);
|
const data = collectFootnotes(view.state);
|
||||||
const def = data.definitions.get(id);
|
const def = data.definitions.get(id);
|
||||||
if (def) {
|
if (def) {
|
||||||
// Prevent default to stop cursor from moving to widget position
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// Use setTimeout to dispatch after mousedown completes
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
selection: { anchor: def.from },
|
selection: { anchor: def.from },
|
||||||
@@ -615,16 +587,11 @@ const footnoteClickHandler = EditorView.domEventHandlers({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle click on definition label
|
// Click on definition label → jump to first reference
|
||||||
if (target.classList.contains('cm-footnote-def-label')) {
|
if (target.classList.contains('cm-footnote-def-label')) {
|
||||||
const pos = view.posAtDOM(target);
|
const id = target.dataset.footnoteId;
|
||||||
if (pos !== null) {
|
if (id) {
|
||||||
const data = view.state.field(footnoteDataField);
|
const data = collectFootnotes(view.state);
|
||||||
|
|
||||||
// Find which definition this belongs to
|
|
||||||
for (const [id, def] of data.definitions) {
|
|
||||||
if (pos >= def.from && pos <= def.to) {
|
|
||||||
// O(1) lookup for first reference
|
|
||||||
const firstRef = data.firstRefById.get(id);
|
const firstRef = data.firstRefById.get(id);
|
||||||
if (firstRef) {
|
if (firstRef) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -637,9 +604,6 @@ const footnoteClickHandler = EditorView.domEventHandlers({
|
|||||||
}, 0);
|
}, 0);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,11 +615,7 @@ const footnoteClickHandler = EditorView.domEventHandlers({
|
|||||||
// Theme
|
// Theme
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Base theme for footnotes.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
// Footnote reference (superscript)
|
|
||||||
'.cm-footnote-ref': {
|
'.cm-footnote-ref': {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -684,7 +644,6 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))',
|
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Inline footnote reference (superscript) - uses distinct color
|
|
||||||
'.cm-inline-footnote-ref': {
|
'.cm-inline-footnote-ref': {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -709,7 +668,6 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))',
|
backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Footnote definition label
|
|
||||||
'.cm-footnote-def-label': {
|
'.cm-footnote-def-label': {
|
||||||
color: 'var(--cm-footnote-def-color, #1a73e8)',
|
color: 'var(--cm-footnote-def-color, #1a73e8)',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -719,7 +677,6 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tooltip
|
|
||||||
'.cm-footnote-tooltip': {
|
'.cm-footnote-tooltip': {
|
||||||
maxWidth: '400px',
|
maxWidth: '400px',
|
||||||
padding: '0',
|
padding: '0',
|
||||||
@@ -746,7 +703,6 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tooltip animation
|
|
||||||
'.cm-tooltip:has(.cm-footnote-tooltip)': {
|
'.cm-tooltip:has(.cm-footnote-tooltip)': {
|
||||||
animation: 'cm-footnote-fade-in 0.15s ease-out',
|
animation: 'cm-footnote-fade-in 0.15s ease-out',
|
||||||
},
|
},
|
||||||
@@ -762,16 +718,8 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Footnote extension.
|
* Footnote extension.
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Parses footnote references [^id] and definitions [^id]: content
|
|
||||||
* - Parses inline footnotes ^[content]
|
|
||||||
* - Renders references and inline footnotes as superscript numbers
|
|
||||||
* - Shows definition/content on hover
|
|
||||||
* - Click to navigate between reference and definition
|
|
||||||
*/
|
*/
|
||||||
export const footnote = (): Extension => [
|
export const footnote = (): Extension => [
|
||||||
footnoteDataField,
|
|
||||||
footnotePlugin,
|
footnotePlugin,
|
||||||
footnoteHoverTooltip,
|
footnoteHoverTooltip,
|
||||||
footnoteClickHandler,
|
footnoteClickHandler,
|
||||||
@@ -780,3 +728,9 @@ export const footnote = (): Extension => [
|
|||||||
|
|
||||||
export default footnote;
|
export default footnote;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get footnote data for external use.
|
||||||
|
*/
|
||||||
|
export function getFootnoteData(state: EditorState): FootnoteData {
|
||||||
|
return collectFootnotes(state);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,88 +1,160 @@
|
|||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { EditorState, StateField, Range } from '@codemirror/state';
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||||
|
|
||||||
/**
|
/** Hidden mark decoration */
|
||||||
* Hidden mark decoration - uses visibility: hidden to hide content
|
|
||||||
*/
|
|
||||||
const hiddenMarkDecoration = Decoration.mark({
|
const hiddenMarkDecoration = Decoration.mark({
|
||||||
class: 'cm-heading-mark-hidden'
|
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 {
|
function collectHeadingRanges(view: EditorView): RangeTuple[] {
|
||||||
return state.selection.ranges.some(
|
const ranges: RangeTuple[] = [];
|
||||||
(range) => from <= range.to && to >= range.from
|
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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build heading decorations.
|
* Get which heading the cursor is in (-1 if none).
|
||||||
* Hides # marks when cursor is not on the heading line.
|
|
||||||
*/
|
*/
|
||||||
function buildHeadingDecorations(state: EditorState): DecorationSet {
|
function getCursorHeadingPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
|
|
||||||
syntaxTree(state).iterate({
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
enter(node) {
|
||||||
// Skip if cursor is in this node's range
|
// Skip if cursor is in this heading
|
||||||
if (isSelectionInRange(state, node.from, node.to)) return;
|
if (checkRangeOverlap([node.from, node.to], selRange)) return;
|
||||||
|
|
||||||
// Handle ATX headings (# Heading)
|
// ATX headings (# Heading)
|
||||||
if (node.type.name.startsWith('ATXHeading')) {
|
if (node.type.name.startsWith('ATXHeading')) {
|
||||||
|
if (seen.has(node.from)) return;
|
||||||
|
seen.add(node.from);
|
||||||
|
|
||||||
const header = node.node.firstChild;
|
const header = node.node.firstChild;
|
||||||
if (header && header.type.name === 'HeaderMark') {
|
if (header && header.type.name === 'HeaderMark') {
|
||||||
const from = header.from;
|
const markFrom = header.from;
|
||||||
// Include the space after #
|
// Include the space after #
|
||||||
const to = Math.min(header.to + 1, node.to);
|
const markTo = Math.min(header.to + 1, node.to);
|
||||||
decorations.push(hiddenMarkDecoration.range(from, to));
|
items.push({ from: markFrom, to: markTo });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle Setext headings (underline style)
|
// Setext headings (underline style)
|
||||||
else if (node.type.name.startsWith('SetextHeading')) {
|
else if (node.type.name.startsWith('SetextHeading')) {
|
||||||
// Hide the underline marks (=== or ---)
|
if (seen.has(node.from)) return;
|
||||||
|
seen.add(node.from);
|
||||||
|
|
||||||
const cursor = node.node.cursor();
|
const cursor = node.node.cursor();
|
||||||
cursor.iterate((child) => {
|
cursor.iterate((child) => {
|
||||||
if (child.type.name === 'HeaderMark') {
|
if (child.type.name === 'HeaderMark') {
|
||||||
decorations.push(
|
items.push({ from: child.from, to: child.to });
|
||||||
hiddenMarkDecoration.range(child.from, child.to)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
// 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 StateField - manages # mark visibility.
|
* Heading plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
const headingField = StateField.define<DecorationSet>({
|
class HeadingPlugin {
|
||||||
create(state) {
|
decorations: DecorationSet;
|
||||||
return buildHeadingDecorations(state);
|
private headingRanges: RangeTuple[] = [];
|
||||||
},
|
private cursorHeadingPos = -1;
|
||||||
|
|
||||||
update(deco, tr) {
|
constructor(view: EditorView) {
|
||||||
if (tr.docChanged || tr.selection) {
|
this.headingRanges = collectHeadingRanges(view);
|
||||||
return buildHeadingDecorations(tr.state);
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
}
|
}
|
||||||
return deco.map(tr.changes);
|
|
||||||
},
|
|
||||||
|
|
||||||
provide: (f) => EditorView.decorations.from(f)
|
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.
|
* 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({
|
const headingTheme = EditorView.baseTheme({
|
||||||
'.cm-heading-mark-hidden': {
|
'.cm-heading-mark-hidden': {
|
||||||
@@ -93,4 +165,4 @@ const headingTheme = EditorView.baseTheme({
|
|||||||
/**
|
/**
|
||||||
* Headings plugin.
|
* Headings plugin.
|
||||||
*/
|
*/
|
||||||
export const headings = () => [headingField, headingTheme];
|
export const headings = (): Extension => [headingPlugin, headingTheme];
|
||||||
|
|||||||
@@ -5,26 +5,25 @@ import {
|
|||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
ViewUpdate
|
ViewUpdate
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { RangeSetBuilder } from '@codemirror/state';
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { checkRangeOverlap, isCursorInRange } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node types that contain markers as child elements.
|
* Node types that contain markers to hide.
|
||||||
|
* Note: InlineCode is handled by inline-code.ts
|
||||||
*/
|
*/
|
||||||
const TYPES_WITH_MARKS = new Set([
|
const TYPES_WITH_MARKS = new Set([
|
||||||
'Emphasis',
|
'Emphasis',
|
||||||
'StrongEmphasis',
|
'StrongEmphasis',
|
||||||
'InlineCode',
|
|
||||||
'Strikethrough'
|
'Strikethrough'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node types that are markers themselves.
|
* Marker node types to hide.
|
||||||
*/
|
*/
|
||||||
const MARK_TYPES = new Set([
|
const MARK_TYPES = new Set([
|
||||||
'EmphasisMark',
|
'EmphasisMark',
|
||||||
'CodeMark',
|
|
||||||
'StrikethroughMark'
|
'StrikethroughMark'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -33,14 +32,51 @@ export const typesWithMarks = Array.from(TYPES_WITH_MARKS);
|
|||||||
export const markTypes = Array.from(MARK_TYPES);
|
export const markTypes = Array.from(MARK_TYPES);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build mark hiding decorations using RangeSetBuilder for optimal performance.
|
* Collect all mark ranges in visible viewport.
|
||||||
*/
|
*/
|
||||||
function buildHideMarkDecorations(view: EditorView): DecorationSet {
|
function collectMarkRanges(view: EditorView): RangeTuple[] {
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
const ranges: RangeTuple[] = [];
|
||||||
const replaceDecoration = Decoration.replace({});
|
const seen = new Set<number>();
|
||||||
|
|
||||||
// Track processed ranges to avoid duplicate processing of nested marks
|
for (const { from, to } of view.visibleRanges) {
|
||||||
let currentParentRange: [number, number] | null = null;
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (!TYPES_WITH_MARKS.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which mark range the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorMarkPos(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 -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build mark hiding decorations.
|
||||||
|
*/
|
||||||
|
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) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
@@ -48,92 +84,83 @@ function buildHideMarkDecorations(view: EditorView): DecorationSet {
|
|||||||
to,
|
to,
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
if (!TYPES_WITH_MARKS.has(type.name)) return;
|
if (!TYPES_WITH_MARKS.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
// Skip if this is a nested element within a parent we're already processing
|
seen.add(nodeFrom);
|
||||||
if (currentParentRange && checkRangeOverlap([nodeFrom, nodeTo], currentParentRange)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update current parent range
|
|
||||||
currentParentRange = [nodeFrom, nodeTo];
|
|
||||||
|
|
||||||
// Skip if cursor is in this range
|
// Skip if cursor is in this range
|
||||||
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
// Iterate through child marks
|
// Collect mark positions
|
||||||
const innerTree = node.toTree();
|
const innerTree = node.toTree();
|
||||||
innerTree.iterate({
|
innerTree.iterate({
|
||||||
enter({ type: markType, from: markFrom, to: markTo }) {
|
enter({ type: markType, from: markFrom, to: markTo }) {
|
||||||
if (!MARK_TYPES.has(markType.name)) return;
|
if (!MARK_TYPES.has(markType.name)) return;
|
||||||
|
items.push({
|
||||||
|
from: nodeFrom + markFrom,
|
||||||
|
to: nodeFrom + markTo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add decoration to hide the mark
|
// Sort and add to builder
|
||||||
builder.add(
|
items.sort((a, b) => a.from - b.from);
|
||||||
nodeFrom + markFrom,
|
|
||||||
nodeFrom + markTo,
|
for (const item of items) {
|
||||||
replaceDecoration
|
builder.add(item.from, item.to, invisibleDecoration);
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.finish();
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hide marks plugin with optimized update detection.
|
* Hide marks plugin with optimized updates.
|
||||||
*
|
*
|
||||||
* This plugin:
|
* Hides emphasis marks (*, **, ~~) when cursor is outside.
|
||||||
* - Hides emphasis marks (*, **, ~~ etc.) when cursor is outside
|
* Note: InlineCode backticks are handled by inline-code.ts
|
||||||
* - Uses RangeSetBuilder for efficient decoration construction
|
|
||||||
* - Optimizes selection change detection
|
|
||||||
*/
|
*/
|
||||||
class HideMarkPlugin {
|
class HideMarkPlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
private lastSelectionRanges: string = '';
|
private markRanges: RangeTuple[] = [];
|
||||||
|
private cursorMarkPos = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = buildHideMarkDecorations(view);
|
this.markRanges = collectMarkRanges(view);
|
||||||
this.lastSelectionRanges = this.serializeSelection(view);
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorMarkPos = getCursorMarkPos(this.markRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
// Always rebuild on doc or viewport change
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
if (update.docChanged || update.viewportChanged) {
|
|
||||||
this.decorations = buildHideMarkDecorations(update.view);
|
if (docChanged || viewportChanged) {
|
||||||
this.lastSelectionRanges = this.serializeSelection(update.view);
|
this.markRanges = collectMarkRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorMarkPos = getCursorMarkPos(this.markRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For selection changes, check if selection actually changed positions
|
if (selectionSet) {
|
||||||
if (update.selectionSet) {
|
const { from, to } = update.state.selection.main;
|
||||||
const newRanges = this.serializeSelection(update.view);
|
const newPos = getCursorMarkPos(this.markRanges, from, to);
|
||||||
if (newRanges !== this.lastSelectionRanges) {
|
|
||||||
this.decorations = buildHideMarkDecorations(update.view);
|
|
||||||
this.lastSelectionRanges = newRanges;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if (newPos !== this.cursorMarkPos) {
|
||||||
* Serialize selection ranges for comparison.
|
this.cursorMarkPos = newPos;
|
||||||
*/
|
this.decorations = buildDecorations(update.view);
|
||||||
private serializeSelection(view: EditorView): string {
|
}
|
||||||
return view.state.selection.ranges
|
}
|
||||||
.map(r => `${r.from}:${r.to}`)
|
|
||||||
.join(',');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hide marks plugin.
|
* Hide marks plugin.
|
||||||
*
|
* Hides marks for emphasis, strong, and strikethrough.
|
||||||
* This plugin:
|
|
||||||
* - Hides marks when they are not in the editor selection
|
|
||||||
* - Supports emphasis, strong, inline code, and strikethrough
|
|
||||||
*/
|
*/
|
||||||
export const hideMarks = () => [
|
export const hideMarks = (): Extension => [
|
||||||
ViewPlugin.fromClass(HideMarkPlugin, {
|
ViewPlugin.fromClass(HideMarkPlugin, {
|
||||||
decorations: (v) => v.decorations
|
decorations: (v) => v.decorations
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Extension, Range } from '@codemirror/state';
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import {
|
import {
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
@@ -7,104 +7,150 @@ import {
|
|||||||
EditorView,
|
EditorView,
|
||||||
ViewUpdate
|
ViewUpdate
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Mark decoration for highlighted content */
|
||||||
|
const highlightMarkDecoration = Decoration.mark({ class: 'cm-highlight' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlight plugin using syntax tree.
|
* Highlight plugin using syntax tree.
|
||||||
*
|
*
|
||||||
* Uses the custom Highlight extension to detect:
|
* Detects ==text== and renders as highlighted text.
|
||||||
* - Highlight: ==text== → renders as highlighted text
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* - This is ==important== text → This is <mark>important</mark> text
|
|
||||||
* - Please ==review this section== carefully
|
|
||||||
*/
|
*/
|
||||||
export const highlight = (): Extension => [
|
export const highlight = (): Extension => [highlightPlugin, baseTheme];
|
||||||
highlightPlugin,
|
|
||||||
baseTheme
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build decorations for highlight using syntax tree.
|
* Collect all highlight ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectHighlightRanges(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: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'Highlight') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which highlight the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorHighlightPos(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 -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build highlight decorations.
|
||||||
*/
|
*/
|
||||||
function buildDecorations(view: EditorView): DecorationSet {
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration }[] = [];
|
||||||
|
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) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
// Handle Highlight nodes
|
if (type.name !== 'Highlight') return;
|
||||||
if (type.name === 'Highlight') {
|
if (seen.has(nodeFrom)) return;
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is in this highlight
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
// Get the mark nodes (the == characters)
|
|
||||||
const marks = node.getChildren('HighlightMark');
|
const marks = node.getChildren('HighlightMark');
|
||||||
|
if (marks.length < 2) return;
|
||||||
|
|
||||||
if (!cursorInRange && marks.length >= 2) {
|
// Hide opening ==
|
||||||
// Hide the opening and closing == marks
|
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
|
||||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
|
||||||
|
|
||||||
// Apply highlight style to the content between marks
|
// Apply highlight style to content
|
||||||
const contentStart = marks[0].to;
|
const contentStart = marks[0].to;
|
||||||
const contentEnd = marks[marks.length - 1].from;
|
const contentEnd = marks[marks.length - 1].from;
|
||||||
if (contentStart < contentEnd) {
|
if (contentStart < contentEnd) {
|
||||||
decorations.push(
|
items.push({ from: contentStart, to: contentEnd, deco: highlightMarkDecoration });
|
||||||
Decoration.mark({
|
|
||||||
class: 'cm-highlight'
|
|
||||||
}).range(contentStart, contentEnd)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide closing ==
|
||||||
|
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin class with optimized update detection.
|
* Highlight plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
class HighlightPlugin {
|
class HighlightPlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
private lastSelectionHead: number = -1;
|
private highlightRanges: RangeTuple[] = [];
|
||||||
|
private cursorHighlightPos = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
|
this.highlightRanges = collectHighlightRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorHighlightPos = getCursorHighlightPos(this.highlightRanges, from, to);
|
||||||
this.decorations = buildDecorations(view);
|
this.decorations = buildDecorations(view);
|
||||||
this.lastSelectionHead = view.state.selection.main.head;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.viewportChanged) {
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.highlightRanges = collectHighlightRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorHighlightPos = getCursorHighlightPos(this.highlightRanges, from, to);
|
||||||
this.decorations = buildDecorations(update.view);
|
this.decorations = buildDecorations(update.view);
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.selectionSet) {
|
if (selectionSet) {
|
||||||
const newHead = update.state.selection.main.head;
|
const { from, to } = update.state.selection.main;
|
||||||
if (newHead !== this.lastSelectionHead) {
|
const newPos = getCursorHighlightPos(this.highlightRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorHighlightPos) {
|
||||||
|
this.cursorHighlightPos = newPos;
|
||||||
this.decorations = buildDecorations(update.view);
|
this.decorations = buildDecorations(update.view);
|
||||||
this.lastSelectionHead = newHead;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightPlugin = ViewPlugin.fromClass(
|
const highlightPlugin = ViewPlugin.fromClass(HighlightPlugin, {
|
||||||
HighlightPlugin,
|
|
||||||
{
|
|
||||||
decorations: (v) => v.decorations
|
decorations: (v) => v.decorations
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for highlight.
|
* Base theme for highlight.
|
||||||
* Uses mark decoration with a subtle background color.
|
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-highlight': {
|
'.cm-highlight': {
|
||||||
@@ -112,4 +158,3 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
import { Extension, StateField, EditorState, Range } from '@codemirror/state';
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
Decoration,
|
Decoration,
|
||||||
EditorView,
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
WidgetType
|
WidgetType
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { isCursorInRange } from '../util';
|
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal rule plugin that renders beautiful horizontal lines.
|
* Horizontal rule plugin that renders beautiful horizontal lines.
|
||||||
*
|
*
|
||||||
* This plugin:
|
* Features:
|
||||||
* - Replaces markdown horizontal rules (---, ***, ___) with styled <hr> elements
|
* - Replaces markdown horizontal rules (---, ***, ___) with styled <hr> elements
|
||||||
* - Shows the original text when cursor is on the line
|
* - Shows the original text when cursor is on the line
|
||||||
* - Uses inline widget to avoid affecting block system boundaries
|
* - Uses inline widget to avoid affecting block system boundaries
|
||||||
*/
|
*/
|
||||||
export const horizontalRule = (): Extension => [
|
export const horizontalRule = (): Extension => [horizontalRulePlugin, baseTheme];
|
||||||
horizontalRuleField,
|
|
||||||
baseTheme
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Widget to display a horizontal rule (inline version).
|
* Widget to display a horizontal rule.
|
||||||
*/
|
*/
|
||||||
class HorizontalRuleWidget extends WidgetType {
|
class HorizontalRuleWidget extends WidgetType {
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
@@ -45,54 +44,127 @@ class HorizontalRuleWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared widget instance (all HR widgets are identical) */
|
||||||
|
const hrWidget = new HorizontalRuleWidget();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build horizontal rule decorations.
|
* Collect all horizontal rule ranges in visible viewport.
|
||||||
* Uses Decoration.replace WITHOUT block: true to avoid affecting block system.
|
|
||||||
*/
|
*/
|
||||||
function buildHorizontalRuleDecorations(state: EditorState): DecorationSet {
|
function collectHRRanges(view: EditorView): RangeTuple[] {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
syntaxTree(state).iterate({
|
for (const { from, to } of view.visibleRanges) {
|
||||||
enter: ({ type, from, to }) => {
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
if (type.name !== 'HorizontalRule') return;
|
if (type.name !== 'HorizontalRule') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
// Skip if cursor is on this line
|
seen.add(nodeFrom);
|
||||||
if (isCursorInRange(state, [from, to])) return;
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
|
||||||
// Replace the entire horizontal rule with a styled widget
|
|
||||||
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
|
|
||||||
decorations.push(
|
|
||||||
Decoration.replace({
|
|
||||||
widget: new HorizontalRuleWidget()
|
|
||||||
}).range(from, to)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
return ranges;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StateField for horizontal rule decorations.
|
* Get which HR the cursor is in (-1 if none).
|
||||||
*/
|
*/
|
||||||
const horizontalRuleField = StateField.define<DecorationSet>({
|
function getCursorHRPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
create(state) {
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
return buildHorizontalRuleDecorations(state);
|
|
||||||
},
|
for (const range of ranges) {
|
||||||
update(value, tx) {
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
if (tx.docChanged || tx.selection) {
|
return range[0];
|
||||||
return buildHorizontalRuleDecorations(tx.state);
|
|
||||||
}
|
}
|
||||||
return value.map(tx.changes);
|
|
||||||
},
|
|
||||||
provide(field) {
|
|
||||||
return EditorView.decorations.from(field);
|
|
||||||
}
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build horizontal rule decorations.
|
||||||
|
*/
|
||||||
|
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: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'HorizontalRule') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is on this HR
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
|
items.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, Decoration.replace({ widget: hrWidget }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal rule plugin with optimized updates.
|
||||||
|
*/
|
||||||
|
class HorizontalRulePlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private hrRanges: RangeTuple[] = [];
|
||||||
|
private cursorHRPos = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.hrRanges = collectHRRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorHRPos = getCursorHRPos(this.hrRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.hrRanges = collectHRRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorHRPos = getCursorHRPos(this.hrRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorHRPos(this.hrRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorHRPos) {
|
||||||
|
this.cursorHRPos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizontalRulePlugin = ViewPlugin.fromClass(HorizontalRulePlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for horizontal rules.
|
* Base theme for horizontal rules.
|
||||||
* Uses inline-block display to render properly without block: true.
|
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-horizontal-rule-widget': {
|
'.cm-horizontal-rule-widget': {
|
||||||
|
|||||||
@@ -1,208 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* HTML plugin for CodeMirror.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Identifies HTML blocks and tags (excluding those inside tables)
|
||||||
|
* - Shows indicator icon at the end
|
||||||
|
* - Click to preview rendered HTML
|
||||||
|
*/
|
||||||
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { EditorState, Range } from '@codemirror/state';
|
import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
Decoration,
|
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
WidgetType,
|
||||||
EditorView,
|
EditorView,
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
ViewUpdate,
|
ViewUpdate,
|
||||||
WidgetType
|
showTooltip,
|
||||||
|
Tooltip
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { isCursorInRange } from '../util';
|
import { LruCache } from '@/common/utils/lruCache';
|
||||||
|
|
||||||
interface EmbedBlockData {
|
interface HTMLBlockInfo {
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
content: string;
|
content: string;
|
||||||
|
sanitized: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML5 official logo
|
||||||
|
const HTML_ICON = `<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M89.088 59.392l62.464 803.84c1.024 12.288 9.216 22.528 20.48 25.6L502.784 993.28c6.144 2.048 12.288 2.048 18.432 0l330.752-104.448c11.264-4.096 19.456-14.336 20.48-25.6l62.464-803.84c1.024-17.408-12.288-31.744-29.696-31.744H118.784c-17.408 0-31.744 14.336-29.696 31.744z" fill="#FC490B"/><path d="M774.144 309.248h-409.6l12.288 113.664h388.096l-25.6 325.632-227.328 71.68-227.328-71.68-13.312-169.984h118.784v82.944l124.928 33.792 123.904-33.792 10.24-132.096H267.264L241.664 204.8h540.672z" fill="#FFFFFF"/></svg>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LRU cache for DOMPurify sanitize results.
|
||||||
|
*/
|
||||||
|
const sanitizeCache = new LruCache<string, string>(100);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize HTML content with caching for performance.
|
||||||
|
*/
|
||||||
|
function sanitizeHTML(html: string): string {
|
||||||
|
const cached = sanitizeCache.get(html);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = DOMPurify.sanitize(html, {
|
||||||
|
ADD_TAGS: ['img'],
|
||||||
|
ADD_ATTR: ['src', 'alt', 'width', 'height', 'style', 'class', 'loading'],
|
||||||
|
ALLOW_DATA_ATTR: true
|
||||||
|
});
|
||||||
|
|
||||||
|
sanitizeCache.set(html, sanitized);
|
||||||
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all HTML blocks from the document (both HTMLBlock and HTMLTag).
|
* Check if document changes affect any of the given regions.
|
||||||
* Returns all blocks regardless of cursor position.
|
|
||||||
*/
|
*/
|
||||||
function extractAllHTMLBlocks(state: EditorState): EmbedBlockData[] {
|
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
|
||||||
const blocks = new Array<EmbedBlockData>();
|
if (regions.length === 0) return true;
|
||||||
syntaxTree(state).iterate({
|
|
||||||
enter({ from, to, name }) {
|
|
||||||
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
|
|
||||||
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
|
|
||||||
const html = state.sliceDoc(from, to);
|
|
||||||
const content = DOMPurify.sanitize(html);
|
|
||||||
|
|
||||||
// Skip empty content after sanitization
|
let affected = false;
|
||||||
if (!content.trim()) return;
|
changes.iterChanges((fromA, toA) => {
|
||||||
|
if (affected) return;
|
||||||
blocks.push({ from, to, content });
|
for (const region of regions) {
|
||||||
|
if (fromA <= region.to && toA >= region.from) {
|
||||||
|
affected = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return blocks;
|
return affected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build decorations for HTML blocks.
|
* Check if a node is inside a table.
|
||||||
* Only shows preview for blocks where cursor is not inside.
|
|
||||||
*/
|
*/
|
||||||
function buildDecorations(state: EditorState, blocks: EmbedBlockData[]): DecorationSet {
|
function isInsideTable(node: { parent: { type: { name: string }; parent: unknown } | null }): boolean {
|
||||||
const decorations: Range<Decoration>[] = [];
|
let current = node.parent;
|
||||||
|
while (current) {
|
||||||
for (const block of blocks) {
|
const name = current.type.name;
|
||||||
// Skip if cursor is in range
|
if (name === 'Table' || name === 'TableHeader' || name === 'TableRow' || name === 'TableCell') {
|
||||||
if (isCursorInRange(state, [block.from, block.to])) continue;
|
return true;
|
||||||
|
|
||||||
// Hide the original HTML source code
|
|
||||||
decorations.push(Decoration.replace({}).range(block.from, block.to));
|
|
||||||
|
|
||||||
// Add the preview widget at the end
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget({
|
|
||||||
widget: new HTMLBlockWidget(block),
|
|
||||||
side: 1
|
|
||||||
}).range(block.to)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
current = current.parent as typeof current;
|
||||||
return Decoration.set(decorations, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if selection affects any HTML block (cursor moved in/out of a block).
|
|
||||||
*/
|
|
||||||
function selectionAffectsBlocks(
|
|
||||||
state: EditorState,
|
|
||||||
prevState: EditorState,
|
|
||||||
blocks: EmbedBlockData[]
|
|
||||||
): boolean {
|
|
||||||
for (const block of blocks) {
|
|
||||||
const wasInRange = isCursorInRange(prevState, [block.from, block.to]);
|
|
||||||
const isInRange = isCursorInRange(state, [block.from, block.to]);
|
|
||||||
if (wasInRange !== isInRange) return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ViewPlugin for HTML block preview.
|
* Extract all HTML blocks from visible ranges.
|
||||||
* Uses smart caching to avoid unnecessary updates during text selection.
|
* Excludes HTML inside tables (tables have their own rendering).
|
||||||
|
*/
|
||||||
|
function extractHTMLBlocks(view: EditorView): HTMLBlockInfo[] {
|
||||||
|
const result: HTMLBlockInfo[] = [];
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: (nodeRef) => {
|
||||||
|
const { name, from: f, to: t, node } = nodeRef;
|
||||||
|
|
||||||
|
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
|
||||||
|
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
|
||||||
|
|
||||||
|
// Skip HTML inside tables
|
||||||
|
if (isInsideTable(node)) return;
|
||||||
|
|
||||||
|
const content = view.state.sliceDoc(f, t);
|
||||||
|
const sanitized = sanitizeHTML(content);
|
||||||
|
|
||||||
|
// Skip empty content after sanitization
|
||||||
|
if (!sanitized.trim()) return;
|
||||||
|
|
||||||
|
result.push({ from: f, to: t, content, sanitized });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effect to toggle tooltip visibility */
|
||||||
|
const toggleHTMLTooltip = StateEffect.define<HTMLBlockInfo | null>();
|
||||||
|
|
||||||
|
/** Effect to close tooltip */
|
||||||
|
const closeHTMLTooltip = StateEffect.define<null>();
|
||||||
|
|
||||||
|
/** StateField to track active tooltip */
|
||||||
|
const htmlTooltipState = StateField.define<HTMLBlockInfo | null>({
|
||||||
|
create: () => null,
|
||||||
|
update(value, tr) {
|
||||||
|
for (const effect of tr.effects) {
|
||||||
|
if (effect.is(toggleHTMLTooltip)) {
|
||||||
|
// Toggle: if same block, close; otherwise open new
|
||||||
|
if (value && effect.value && value.from === effect.value.from) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return effect.value;
|
||||||
|
}
|
||||||
|
if (effect.is(closeHTMLTooltip)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Close tooltip on document changes
|
||||||
|
if (tr.docChanged) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
provide: (field) =>
|
||||||
|
showTooltip.from(field, (block): Tooltip | null => {
|
||||||
|
if (!block) return null;
|
||||||
|
return {
|
||||||
|
pos: block.to,
|
||||||
|
above: true,
|
||||||
|
create: () => {
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.className = 'cm-html-tooltip';
|
||||||
|
dom.innerHTML = block.sanitized;
|
||||||
|
|
||||||
|
// Prevent clicks inside tooltip from closing it
|
||||||
|
dom.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Indicator widget shown at the end of HTML blocks.
|
||||||
|
* Clicking toggles the tooltip.
|
||||||
|
*/
|
||||||
|
class HTMLIndicatorWidget extends WidgetType {
|
||||||
|
constructor(readonly info: HTMLBlockInfo) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(view: EditorView): HTMLElement {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.className = 'cm-html-indicator';
|
||||||
|
el.innerHTML = HTML_ICON;
|
||||||
|
el.title = 'Click to preview HTML';
|
||||||
|
|
||||||
|
// Click handler to toggle tooltip
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
view.dispatch({
|
||||||
|
effects: toggleHTMLTooltip.of(this.info)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: HTMLIndicatorWidget): boolean {
|
||||||
|
return this.info.from === other.info.from && this.info.content === other.info.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin to manage HTML block decorations.
|
||||||
|
* Optimized with incremental updates when changes don't affect HTML regions.
|
||||||
*/
|
*/
|
||||||
class HTMLBlockPlugin {
|
class HTMLBlockPlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
blocks: EmbedBlockData[];
|
blocks: HTMLBlockInfo[] = [];
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.blocks = extractAllHTMLBlocks(view.state);
|
this.blocks = extractHTMLBlocks(view);
|
||||||
this.decorations = buildDecorations(view.state, this.blocks);
|
this.decorations = this.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
// If document changed, re-extract all blocks
|
// Always rebuild on viewport change
|
||||||
if (update.docChanged) {
|
if (update.viewportChanged) {
|
||||||
this.blocks = extractAllHTMLBlocks(update.state);
|
this.blocks = extractHTMLBlocks(update.view);
|
||||||
this.decorations = buildDecorations(update.state, this.blocks);
|
this.decorations = this.build();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If selection changed, only rebuild if cursor moved in/out of a block
|
// For document changes, only rebuild if changes affect HTML regions
|
||||||
if (update.selectionSet) {
|
if (update.docChanged) {
|
||||||
if (selectionAffectsBlocks(update.state, update.startState, this.blocks)) {
|
const needsRebuild = changesAffectRegions(update.changes, this.blocks);
|
||||||
this.decorations = buildDecorations(update.state, this.blocks);
|
|
||||||
|
if (needsRebuild) {
|
||||||
|
this.blocks = extractHTMLBlocks(update.view);
|
||||||
|
this.decorations = this.build();
|
||||||
|
} else {
|
||||||
|
// Just update positions of existing decorations
|
||||||
|
this.decorations = this.decorations.map(update.changes);
|
||||||
|
this.blocks = this.blocks.map(block => ({
|
||||||
|
...block,
|
||||||
|
from: update.changes.mapPos(block.from),
|
||||||
|
to: update.changes.mapPos(block.to)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private build(): DecorationSet {
|
||||||
|
const deco: Range<Decoration>[] = [];
|
||||||
|
for (const block of this.blocks) {
|
||||||
|
deco.push(
|
||||||
|
Decoration.widget({
|
||||||
|
widget: new HTMLIndicatorWidget(block),
|
||||||
|
side: 1
|
||||||
|
}).range(block.to)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Decoration.set(deco, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
|
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
|
||||||
decorations: (v) => v.decorations
|
decorations: (v) => v.decorations
|
||||||
});
|
});
|
||||||
|
|
||||||
class HTMLBlockWidget extends WidgetType {
|
|
||||||
constructor(public data: EmbedBlockData) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(view: EditorView): HTMLElement {
|
|
||||||
const wrapper = document.createElement('span');
|
|
||||||
wrapper.className = 'cm-html-block-widget';
|
|
||||||
|
|
||||||
// Content container
|
|
||||||
const content = document.createElement('span');
|
|
||||||
content.className = 'cm-html-block-content';
|
|
||||||
// This is sanitized!
|
|
||||||
content.innerHTML = this.data.content;
|
|
||||||
|
|
||||||
// Edit button
|
|
||||||
const editBtn = document.createElement('button');
|
|
||||||
editBtn.className = 'cm-html-block-edit-btn';
|
|
||||||
editBtn.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
||||||
</svg>`;
|
|
||||||
editBtn.title = 'Edit HTML';
|
|
||||||
|
|
||||||
editBtn.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
view.dispatch({
|
|
||||||
selection: { anchor: this.data.from }
|
|
||||||
});
|
|
||||||
view.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.appendChild(content);
|
|
||||||
wrapper.appendChild(editBtn);
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(widget: HTMLBlockWidget): boolean {
|
|
||||||
return JSON.stringify(widget.data) === JSON.stringify(this.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for HTML blocks.
|
* Close tooltip when clicking outside.
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const clickOutsideHandler = EditorView.domEventHandlers({
|
||||||
'.cm-html-block-widget': {
|
click(event, view) {
|
||||||
display: 'inline-block',
|
const target = event.target as HTMLElement;
|
||||||
position: 'relative',
|
|
||||||
maxWidth: '100%',
|
// Don't close if clicking on indicator or inside tooltip
|
||||||
overflow: 'auto',
|
if (target.closest('.cm-html-indicator') || target.closest('.cm-html-tooltip')) {
|
||||||
verticalAlign: 'middle'
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tooltip if one is open
|
||||||
|
const currentTooltip = view.state.field(htmlTooltipState);
|
||||||
|
if (currentTooltip) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: closeHTMLTooltip.of(null)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const theme = EditorView.baseTheme({
|
||||||
|
// Indicator icon
|
||||||
|
'.cm-html-indicator': {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: '4px',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: '0.5',
|
||||||
|
color: 'var(--cm-html-color, #e44d26)',
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
'& svg': { width: '14px', height: '14px' }
|
||||||
},
|
},
|
||||||
'.cm-html-block-content': {
|
'.cm-html-indicator:hover': {
|
||||||
display: 'inline-block'
|
opacity: '1'
|
||||||
},
|
},
|
||||||
// Ensure images are properly sized
|
|
||||||
'.cm-html-block-content img': {
|
// Tooltip content
|
||||||
|
'.cm-html-tooltip': {
|
||||||
|
padding: '8px 12px',
|
||||||
|
maxWidth: '60vw',
|
||||||
|
maxHeight: '50vh',
|
||||||
|
overflow: 'auto'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Images inside tooltip
|
||||||
|
'.cm-html-tooltip img': {
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
display: 'block'
|
display: 'block'
|
||||||
},
|
},
|
||||||
'.cm-html-block-edit-btn': {
|
|
||||||
position: 'absolute',
|
// Style the parent tooltip container
|
||||||
top: '4px',
|
'.cm-tooltip:has(.cm-html-tooltip)': {
|
||||||
right: '4px',
|
background: 'var(--bg-primary, #fff)',
|
||||||
padding: '4px',
|
border: '1px solid var(--border-color, #ddd)',
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
background: 'rgba(128, 128, 128, 0.2)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
|
||||||
color: 'inherit',
|
|
||||||
cursor: 'pointer',
|
|
||||||
opacity: '0',
|
|
||||||
transition: 'opacity 0.2s, background 0.2s',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: '10'
|
|
||||||
},
|
|
||||||
'.cm-html-block-widget:hover .cm-html-block-edit-btn': {
|
|
||||||
opacity: '1'
|
|
||||||
},
|
|
||||||
'.cm-html-block-edit-btn:hover': {
|
|
||||||
background: 'rgba(128, 128, 128, 0.4)'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export the extension with theme
|
/**
|
||||||
export const htmlBlockExtension = [htmlBlockPlugin, baseTheme];
|
* HTML block extension.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Identifies HTML blocks and tags (excluding those inside tables)
|
||||||
|
* - Shows indicator icon at the end
|
||||||
|
* - Click to preview rendered HTML
|
||||||
|
*/
|
||||||
|
export const htmlBlockExtension: Extension = [
|
||||||
|
htmlBlockPlugin,
|
||||||
|
htmlTooltipState,
|
||||||
|
clickOutsideHandler,
|
||||||
|
theme
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Image plugin for CodeMirror.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Identifies markdown images
|
||||||
|
* - Shows indicator icon at the end
|
||||||
|
* - Click to preview image
|
||||||
|
*/
|
||||||
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { Extension, Range } from '@codemirror/state';
|
import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
Decoration,
|
Decoration,
|
||||||
@@ -7,7 +16,7 @@ import {
|
|||||||
EditorView,
|
EditorView,
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
ViewUpdate,
|
ViewUpdate,
|
||||||
hoverTooltip,
|
showTooltip,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
|
|
||||||
@@ -26,6 +35,25 @@ function isImageUrl(url: string): boolean {
|
|||||||
return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/');
|
return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if document changes affect any of the given regions.
|
||||||
|
*/
|
||||||
|
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
|
||||||
|
if (regions.length === 0) return true;
|
||||||
|
|
||||||
|
let affected = false;
|
||||||
|
changes.iterChanges((fromA, toA) => {
|
||||||
|
if (affected) return;
|
||||||
|
for (const region of regions) {
|
||||||
|
if (fromA <= region.to && toA >= region.from) {
|
||||||
|
affected = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return affected;
|
||||||
|
}
|
||||||
|
|
||||||
function extractImages(view: EditorView): ImageInfo[] {
|
function extractImages(view: EditorView): ImageInfo[] {
|
||||||
const result: ImageInfo[] = [];
|
const result: ImageInfo[] = [];
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
@@ -47,73 +75,40 @@ function extractImages(view: EditorView): ImageInfo[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
class IndicatorWidget extends WidgetType {
|
/** Effect to toggle tooltip visibility */
|
||||||
constructor(readonly info: ImageInfo) {
|
const toggleImageTooltip = StateEffect.define<ImageInfo | null>();
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
/** Effect to close tooltip */
|
||||||
const el = document.createElement('span');
|
const closeImageTooltip = StateEffect.define<null>();
|
||||||
el.className = 'cm-image-indicator';
|
|
||||||
el.innerHTML = ICON;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(other: IndicatorWidget): boolean {
|
/** StateField to track active tooltip */
|
||||||
return this.info.from === other.info.from && this.info.src === other.info.src;
|
const imageTooltipState = StateField.define<ImageInfo | null>({
|
||||||
}
|
create: () => null,
|
||||||
}
|
update(value, tr) {
|
||||||
|
for (const effect of tr.effects) {
|
||||||
class ImagePlugin {
|
if (effect.is(toggleImageTooltip)) {
|
||||||
decorations: DecorationSet;
|
// Toggle: if same image, close; otherwise open new
|
||||||
images: ImageInfo[] = [];
|
if (value && effect.value && value.from === effect.value.from) {
|
||||||
|
return null;
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.images = extractImages(view);
|
|
||||||
this.decorations = this.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged || update.viewportChanged) {
|
|
||||||
this.images = extractImages(update.view);
|
|
||||||
this.decorations = this.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private build(): DecorationSet {
|
|
||||||
const deco: Range<Decoration>[] = [];
|
|
||||||
for (const img of this.images) {
|
|
||||||
deco.push(Decoration.widget({ widget: new IndicatorWidget(img), side: 1 }).range(img.to));
|
|
||||||
}
|
|
||||||
return Decoration.set(deco, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
getImageAt(pos: number): ImageInfo | null {
|
|
||||||
for (const img of this.images) {
|
|
||||||
if (pos >= img.to && pos <= img.to + 1) {
|
|
||||||
return img;
|
|
||||||
}
|
}
|
||||||
|
return effect.value;
|
||||||
}
|
}
|
||||||
|
if (effect.is(closeImageTooltip)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Close tooltip on document changes
|
||||||
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
|
if (tr.docChanged) {
|
||||||
decorations: (v) => v.decorations
|
return null;
|
||||||
});
|
}
|
||||||
|
return value;
|
||||||
const imageHoverTooltip = hoverTooltip(
|
},
|
||||||
(view, pos): Tooltip | null => {
|
provide: (field) =>
|
||||||
const plugin = view.plugin(imagePlugin);
|
showTooltip.from(field, (img): Tooltip | null => {
|
||||||
if (!plugin) return null;
|
|
||||||
|
|
||||||
const img = plugin.getImageAt(pos);
|
|
||||||
if (!img) return null;
|
if (!img) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pos: img.to,
|
pos: img.to,
|
||||||
above: true,
|
above: true,
|
||||||
arrow: true,
|
|
||||||
create: () => {
|
create: () => {
|
||||||
const dom = document.createElement('div');
|
const dom = document.createElement('div');
|
||||||
dom.className = 'cm-image-tooltip cm-image-loading';
|
dom.className = 'cm-image-tooltip cm-image-loading';
|
||||||
@@ -137,12 +132,130 @@ const imageHoverTooltip = hoverTooltip(
|
|||||||
};
|
};
|
||||||
|
|
||||||
dom.append(spinner, imgEl);
|
dom.append(spinner, imgEl);
|
||||||
|
|
||||||
|
// Prevent clicks inside tooltip from closing it
|
||||||
|
dom.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
return { dom };
|
return { dom };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
})
|
||||||
{ hoverTime: 300 }
|
});
|
||||||
);
|
|
||||||
|
/**
|
||||||
|
* Indicator widget shown at the end of images.
|
||||||
|
* Clicking toggles the tooltip.
|
||||||
|
*/
|
||||||
|
class IndicatorWidget extends WidgetType {
|
||||||
|
constructor(readonly info: ImageInfo) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(view: EditorView): HTMLElement {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.className = 'cm-image-indicator';
|
||||||
|
el.innerHTML = ICON;
|
||||||
|
el.title = 'Click to preview image';
|
||||||
|
|
||||||
|
// Click handler to toggle tooltip
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
view.dispatch({
|
||||||
|
effects: toggleImageTooltip.of(this.info)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: IndicatorWidget): boolean {
|
||||||
|
return this.info.from === other.info.from && this.info.src === other.info.src;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin to manage image decorations.
|
||||||
|
* Optimized with incremental updates when changes don't affect image regions.
|
||||||
|
*/
|
||||||
|
class ImagePlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
images: ImageInfo[] = [];
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.images = extractImages(view);
|
||||||
|
this.decorations = this.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
// Always rebuild on viewport change
|
||||||
|
if (update.viewportChanged) {
|
||||||
|
this.images = extractImages(update.view);
|
||||||
|
this.decorations = this.build();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For document changes, only rebuild if changes affect image regions
|
||||||
|
if (update.docChanged) {
|
||||||
|
const needsRebuild = changesAffectRegions(update.changes, this.images);
|
||||||
|
|
||||||
|
if (needsRebuild) {
|
||||||
|
this.images = extractImages(update.view);
|
||||||
|
this.decorations = this.build();
|
||||||
|
} else {
|
||||||
|
// Just update positions of existing decorations
|
||||||
|
this.decorations = this.decorations.map(update.changes);
|
||||||
|
this.images = this.images.map(img => ({
|
||||||
|
...img,
|
||||||
|
from: update.changes.mapPos(img.from),
|
||||||
|
to: update.changes.mapPos(img.to)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private build(): DecorationSet {
|
||||||
|
const deco: Range<Decoration>[] = [];
|
||||||
|
for (const img of this.images) {
|
||||||
|
deco.push(Decoration.widget({ widget: new IndicatorWidget(img), side: 1 }).range(img.to));
|
||||||
|
}
|
||||||
|
return Decoration.set(deco, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close tooltip when clicking outside.
|
||||||
|
*/
|
||||||
|
const clickOutsideHandler = EditorView.domEventHandlers({
|
||||||
|
click(event, view) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
// Don't close if clicking on indicator or inside tooltip
|
||||||
|
if (target.closest('.cm-image-indicator') || target.closest('.cm-image-tooltip')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tooltip if one is open
|
||||||
|
const currentTooltip = view.state.field(imageTooltipState);
|
||||||
|
if (currentTooltip) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: closeImageTooltip.of(null)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const theme = EditorView.baseTheme({
|
const theme = EditorView.baseTheme({
|
||||||
'.cm-image-indicator': {
|
'.cm-image-indicator': {
|
||||||
@@ -157,6 +270,7 @@ const theme = EditorView.baseTheme({
|
|||||||
'& svg': { width: '14px', height: '14px' }
|
'& svg': { width: '14px', height: '14px' }
|
||||||
},
|
},
|
||||||
'.cm-image-indicator:hover': { opacity: '1' },
|
'.cm-image-indicator:hover': { opacity: '1' },
|
||||||
|
|
||||||
'.cm-image-tooltip': {
|
'.cm-image-tooltip': {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
background: `
|
background: `
|
||||||
@@ -205,16 +319,13 @@ const theme = EditorView.baseTheme({
|
|||||||
'.cm-image-tooltip-error': {
|
'.cm-image-tooltip-error': {
|
||||||
padding: '16px 24px',
|
padding: '16px 24px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: 'var(--text-muted)'
|
color: 'red'
|
||||||
},
|
|
||||||
'.cm-tooltip-arrow:before': {
|
|
||||||
borderTopColor: 'var(--border-color) !important',
|
|
||||||
borderBottomColor: 'var(--border-color) !important'
|
|
||||||
},
|
|
||||||
'.cm-tooltip-arrow:after': {
|
|
||||||
borderTopColor: '#fff !important',
|
|
||||||
borderBottomColor: '#fff !important'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme];
|
export const image = (): Extension => [
|
||||||
|
imagePlugin,
|
||||||
|
imageTooltipState,
|
||||||
|
clickOutsideHandler,
|
||||||
|
theme
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Extension, Range } from '@codemirror/state';
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
Decoration,
|
Decoration,
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
@@ -7,23 +7,26 @@ import {
|
|||||||
ViewUpdate
|
ViewUpdate
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { isCursorInRange } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Mark decoration for code content */
|
||||||
|
const codeMarkDecoration = Decoration.mark({ class: 'cm-inline-code' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline code styling plugin.
|
* Inline code styling plugin.
|
||||||
*
|
*
|
||||||
* This plugin adds visual styling to inline code (`code`):
|
* Features:
|
||||||
* - Background color
|
* - Adds background color, border radius, padding to code content
|
||||||
* - Border radius
|
* - Hides backtick markers when cursor is outside
|
||||||
* - Padding effect via marks
|
|
||||||
*/
|
*/
|
||||||
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
|
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build inline code decorations.
|
* Collect all inline code ranges in visible viewport.
|
||||||
*/
|
*/
|
||||||
function buildInlineCodeDecorations(view: EditorView): DecorationSet {
|
function collectCodeRanges(view: EditorView): RangeTuple[] {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
@@ -31,63 +34,134 @@ function buildInlineCodeDecorations(view: EditorView): DecorationSet {
|
|||||||
to,
|
to,
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
if (type.name !== 'InlineCode') return;
|
if (type.name !== 'InlineCode') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip background decoration when cursor is in the code
|
/**
|
||||||
// This allows selection highlighting to be visible when editing
|
* Get which inline code the cursor is in (-1 if none).
|
||||||
if (cursorInCode) return;
|
*/
|
||||||
|
function getCursorCodePos(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 -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build inline code decorations.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration }[] = [];
|
||||||
|
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: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'InlineCode') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip when cursor is in this code
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
// Get the actual code content (excluding backticks)
|
|
||||||
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
|
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
|
||||||
|
|
||||||
// Find backtick positions
|
// Find backtick boundaries
|
||||||
let codeStart = nodeFrom;
|
let codeStart = nodeFrom;
|
||||||
let codeEnd = nodeTo;
|
let codeEnd = nodeTo;
|
||||||
|
|
||||||
// Skip opening backticks
|
// Count opening backticks
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < text.length && text[i] === '`') {
|
while (i < text.length && text[i] === '`') {
|
||||||
codeStart++;
|
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
codeStart = nodeFrom + i;
|
||||||
|
|
||||||
// Skip closing backticks
|
// Count closing backticks
|
||||||
let j = text.length - 1;
|
let j = text.length - 1;
|
||||||
while (j >= 0 && text[j] === '`') {
|
while (j >= 0 && text[j] === '`') {
|
||||||
codeEnd--;
|
|
||||||
j--;
|
j--;
|
||||||
}
|
}
|
||||||
|
codeEnd = nodeFrom + j + 1;
|
||||||
|
|
||||||
// Only add decoration if there's actual content
|
// Hide opening backticks
|
||||||
|
if (nodeFrom < codeStart) {
|
||||||
|
items.push({ from: nodeFrom, to: codeStart, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add style to code content
|
||||||
if (codeStart < codeEnd) {
|
if (codeStart < codeEnd) {
|
||||||
// Add mark decoration for the code content
|
items.push({ from: codeStart, to: codeEnd, deco: codeMarkDecoration });
|
||||||
decorations.push(
|
}
|
||||||
Decoration.mark({
|
|
||||||
class: 'cm-inline-code'
|
// Hide closing backticks
|
||||||
}).range(codeStart, codeEnd)
|
if (codeEnd < nodeTo) {
|
||||||
);
|
items.push({ from: codeEnd, to: nodeTo, deco: invisibleDecoration });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline code plugin class.
|
* Inline code plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
class InlineCodePlugin {
|
class InlineCodePlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
|
private codeRanges: RangeTuple[] = [];
|
||||||
|
private cursorCodePos = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = buildInlineCodeDecorations(view);
|
this.codeRanges = collectCodeRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorCodePos = getCursorCodePos(this.codeRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
this.decorations = buildInlineCodeDecorations(update.view);
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.codeRanges = collectCodeRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorCodePos = getCursorCodePos(this.codeRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorCodePos(this.codeRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorCodePos) {
|
||||||
|
this.cursorCodePos = newPos;
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +172,6 @@ const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for inline code.
|
* Base theme for inline code.
|
||||||
* Uses CSS variables from variables.css for consistent theming.
|
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-inline-code': {
|
'.cm-inline-code': {
|
||||||
@@ -108,4 +181,3 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
fontFamily: 'var(--voidraft-font-mono)'
|
fontFamily: 'var(--voidraft-font-mono)'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Extension, Range } from '@codemirror/state';
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import {
|
import {
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
@@ -7,108 +7,153 @@ import {
|
|||||||
EditorView,
|
EditorView,
|
||||||
ViewUpdate
|
ViewUpdate
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Mark decoration for inserted content */
|
||||||
|
const insertMarkDecoration = Decoration.mark({ class: 'cm-insert' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert plugin using syntax tree.
|
* Insert plugin using syntax tree.
|
||||||
*
|
*
|
||||||
* Uses the custom Insert extension to detect:
|
* Detects ++text++ and renders as inserted text (underline).
|
||||||
* - Insert: ++text++ → renders as inserted text (underline)
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* - This is ++inserted++ text → This is <ins>inserted</ins> text
|
|
||||||
* - Please ++review this section++ carefully
|
|
||||||
*/
|
*/
|
||||||
export const insert = (): Extension => [
|
export const insert = (): Extension => [insertPlugin, baseTheme];
|
||||||
insertPlugin,
|
|
||||||
baseTheme
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build decorations for insert using syntax tree.
|
* Collect all insert ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectInsertRanges(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: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (type.name !== 'Insert') return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which insert the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorInsertPos(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 -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build insert decorations.
|
||||||
*/
|
*/
|
||||||
function buildDecorations(view: EditorView): DecorationSet {
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration }[] = [];
|
||||||
|
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) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
// Handle Insert nodes
|
if (type.name !== 'Insert') return;
|
||||||
if (type.name === 'Insert') {
|
if (seen.has(nodeFrom)) return;
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is in this insert
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
// Get the mark nodes (the ++ characters)
|
|
||||||
const marks = node.getChildren('InsertMark');
|
const marks = node.getChildren('InsertMark');
|
||||||
|
if (marks.length < 2) return;
|
||||||
|
|
||||||
if (!cursorInRange && marks.length >= 2) {
|
// Hide opening ++
|
||||||
// Hide the opening and closing ++ marks
|
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
|
||||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
|
||||||
|
|
||||||
// Apply insert style to the content between marks
|
// Apply insert style to content
|
||||||
const contentStart = marks[0].to;
|
const contentStart = marks[0].to;
|
||||||
const contentEnd = marks[marks.length - 1].from;
|
const contentEnd = marks[marks.length - 1].from;
|
||||||
if (contentStart < contentEnd) {
|
if (contentStart < contentEnd) {
|
||||||
decorations.push(
|
items.push({ from: contentStart, to: contentEnd, deco: insertMarkDecoration });
|
||||||
Decoration.mark({
|
|
||||||
class: 'cm-insert'
|
|
||||||
}).range(contentStart, contentEnd)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide closing ++
|
||||||
|
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin class with optimized update detection.
|
* Insert plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
class InsertPlugin {
|
class InsertPlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
private lastSelectionHead: number = -1;
|
private insertRanges: RangeTuple[] = [];
|
||||||
|
private cursorInsertPos = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
|
this.insertRanges = collectInsertRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorInsertPos = getCursorInsertPos(this.insertRanges, from, to);
|
||||||
this.decorations = buildDecorations(view);
|
this.decorations = buildDecorations(view);
|
||||||
this.lastSelectionHead = view.state.selection.main.head;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.viewportChanged) {
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.insertRanges = collectInsertRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorInsertPos = getCursorInsertPos(this.insertRanges, from, to);
|
||||||
this.decorations = buildDecorations(update.view);
|
this.decorations = buildDecorations(update.view);
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.selectionSet) {
|
if (selectionSet) {
|
||||||
const newHead = update.state.selection.main.head;
|
const { from, to } = update.state.selection.main;
|
||||||
if (newHead !== this.lastSelectionHead) {
|
const newPos = getCursorInsertPos(this.insertRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorInsertPos) {
|
||||||
|
this.cursorInsertPos = newPos;
|
||||||
this.decorations = buildDecorations(update.view);
|
this.decorations = buildDecorations(update.view);
|
||||||
this.lastSelectionHead = newHead;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertPlugin = ViewPlugin.fromClass(
|
const insertPlugin = ViewPlugin.fromClass(InsertPlugin, {
|
||||||
InsertPlugin,
|
|
||||||
{
|
|
||||||
decorations: (v) => v.decorations
|
decorations: (v) => v.decorations
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for insert.
|
* Base theme for insert.
|
||||||
* Uses underline decoration for inserted text.
|
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-insert': {
|
'.cm-insert': {
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { Range } from '@codemirror/state';
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
Decoration,
|
Decoration,
|
||||||
DecorationSet,
|
DecorationSet,
|
||||||
@@ -7,17 +7,12 @@ import {
|
|||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
ViewUpdate
|
ViewUpdate
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
/**
|
|
||||||
* Pattern for auto-link markers (< and >).
|
|
||||||
*/
|
|
||||||
const AUTO_LINK_MARK_RE = /^<|>$/g;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parent node types that should not process.
|
* Parent node types that should not process.
|
||||||
* - Image: handled by image plugin
|
* - Image: handled by image plugin
|
||||||
* - LinkReference: reference link definitions like [label]: url should be fully visible
|
* - LinkReference: reference link definitions should be fully visible
|
||||||
*/
|
*/
|
||||||
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
|
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
|
||||||
|
|
||||||
@@ -28,16 +23,71 @@ const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
|
|||||||
* - Hides link markup when cursor is outside
|
* - Hides link markup when cursor is outside
|
||||||
* - Link icons and click events are handled by hyperlink extension
|
* - Link icons and click events are handled by hyperlink extension
|
||||||
*/
|
*/
|
||||||
export const links = () => [goToLinkPlugin];
|
export const links = (): Extension => [goToLinkPlugin];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link info for tracking.
|
||||||
|
*/
|
||||||
|
interface LinkInfo {
|
||||||
|
parentFrom: number;
|
||||||
|
parentTo: number;
|
||||||
|
urlFrom: number;
|
||||||
|
urlTo: number;
|
||||||
|
marks: { from: number; to: number }[];
|
||||||
|
linkTitle: { from: number; to: number } | null;
|
||||||
|
isAutoLink: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all link ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectLinkRanges(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: ({ type, node }) => {
|
||||||
|
if (type.name !== 'URL') return;
|
||||||
|
|
||||||
|
const parent = node.parent;
|
||||||
|
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
|
||||||
|
if (seen.has(parent.from)) return;
|
||||||
|
seen.add(parent.from);
|
||||||
|
|
||||||
|
ranges.push([parent.from, parent.to]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which link the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorLinkPos(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 -1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build link decorations.
|
* Build link decorations.
|
||||||
* Only hides markdown syntax marks, no icons added.
|
|
||||||
* Uses array + Decoration.set() for automatic sorting.
|
|
||||||
*/
|
*/
|
||||||
function buildLinkDecorations(view: EditorView): DecorationSet {
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
const selectionRanges = view.state.selection.ranges;
|
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) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
@@ -49,94 +99,104 @@ function buildLinkDecorations(view: EditorView): DecorationSet {
|
|||||||
const parent = node.parent;
|
const parent = node.parent;
|
||||||
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
|
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
|
||||||
|
|
||||||
|
// Use parent.from as unique key to handle multiple URLs in same link
|
||||||
|
if (seen.has(parent.from)) return;
|
||||||
|
seen.add(parent.from);
|
||||||
|
|
||||||
const marks = parent.getChildren('LinkMark');
|
const marks = parent.getChildren('LinkMark');
|
||||||
const linkTitle = parent.getChild('LinkTitle');
|
const linkTitle = parent.getChild('LinkTitle');
|
||||||
|
|
||||||
// Find the ']' mark position to distinguish between link text and link target
|
// Find the ']' mark to distinguish link text from URL
|
||||||
// Link structure: [display text](url)
|
|
||||||
// We should only hide the URL in the () part, not in the [] part
|
|
||||||
const closeBracketMark = marks.find((mark) => {
|
const closeBracketMark = marks.find((mark) => {
|
||||||
const text = view.state.sliceDoc(mark.from, mark.to);
|
const text = view.state.sliceDoc(mark.from, mark.to);
|
||||||
return text === ']';
|
return text === ']';
|
||||||
});
|
});
|
||||||
|
|
||||||
// If URL is before ']', it's part of the display text, don't hide it
|
// If URL is before ']', it's part of display text, don't hide
|
||||||
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
|
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if cursor overlaps with the link
|
// Check if cursor overlaps with the parent link
|
||||||
const cursorOverlaps = selectionRanges.some((range) =>
|
if (checkRangeOverlap([parent.from, parent.to], selRange)) {
|
||||||
checkRangeOverlap([range.from, range.to], [parent.from, parent.to])
|
return;
|
||||||
);
|
|
||||||
|
|
||||||
// Hide link marks and URL when cursor is outside
|
|
||||||
if (!cursorOverlaps && marks.length > 0) {
|
|
||||||
for (const mark of marks) {
|
|
||||||
decorations.push(invisibleDecoration.range(mark.from, mark.to));
|
|
||||||
}
|
}
|
||||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
|
||||||
|
// Hide link marks and URL
|
||||||
|
if (marks.length > 0) {
|
||||||
|
for (const mark of marks) {
|
||||||
|
items.push({ from: mark.from, to: mark.to });
|
||||||
|
}
|
||||||
|
items.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
|
||||||
if (linkTitle) {
|
if (linkTitle) {
|
||||||
decorations.push(invisibleDecoration.range(linkTitle.from, linkTitle.to));
|
items.push({ from: linkTitle.from, to: linkTitle.to });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get link content
|
|
||||||
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
|
|
||||||
|
|
||||||
// Handle auto-links with < > markers
|
// Handle auto-links with < > markers
|
||||||
if (AUTO_LINK_MARK_RE.test(linkContent)) {
|
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||||
if (!isCursorInRange(view.state, [node.from, node.to])) {
|
if (linkContent.startsWith('<') && linkContent.endsWith('>')) {
|
||||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1));
|
// Already hidden the whole URL above, no extra handling needed
|
||||||
decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Decoration.set with sort=true to handle unsorted ranges
|
// Sort and add to builder
|
||||||
return Decoration.set(decorations, true);
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
// Deduplicate overlapping ranges
|
||||||
|
let lastTo = -1;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.from >= lastTo) {
|
||||||
|
builder.add(item.from, item.to, invisibleDecoration);
|
||||||
|
lastTo = item.to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Link plugin with optimized update detection.
|
* Link plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
class LinkPlugin {
|
class LinkPlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
private lastSelectionRanges: string = '';
|
private linkRanges: RangeTuple[] = [];
|
||||||
|
private cursorLinkPos = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = buildLinkDecorations(view);
|
this.linkRanges = collectLinkRanges(view);
|
||||||
this.lastSelectionRanges = this.serializeSelection(view);
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorLinkPos = getCursorLinkPos(this.linkRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
// Always rebuild on doc or viewport change
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
if (update.docChanged || update.viewportChanged) {
|
|
||||||
this.decorations = buildLinkDecorations(update.view);
|
if (docChanged || viewportChanged) {
|
||||||
this.lastSelectionRanges = this.serializeSelection(update.view);
|
this.linkRanges = collectLinkRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorLinkPos = getCursorLinkPos(this.linkRanges, from, to);
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For selection changes, check if selection actually changed
|
if (selectionSet) {
|
||||||
if (update.selectionSet) {
|
const { from, to } = update.state.selection.main;
|
||||||
const newRanges = this.serializeSelection(update.view);
|
const newPos = getCursorLinkPos(this.linkRanges, from, to);
|
||||||
if (newRanges !== this.lastSelectionRanges) {
|
|
||||||
this.decorations = buildLinkDecorations(update.view);
|
|
||||||
this.lastSelectionRanges = newRanges;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private serializeSelection(view: EditorView): string {
|
if (newPos !== this.cursorLinkPos) {
|
||||||
return view.state.selection.ranges
|
this.cursorLinkPos = newPos;
|
||||||
.map((r) => `${r.from}:${r.to}`)
|
this.decorations = buildDecorations(update.view);
|
||||||
.join(',');
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
|
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
|
||||||
decorations: (v) => v.decorations
|
decorations: (v) => v.decorations
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import {
|
|||||||
ViewUpdate,
|
ViewUpdate,
|
||||||
WidgetType
|
WidgetType
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { Range, StateField, Transaction } from '@codemirror/state';
|
import { Range, RangeSetBuilder, EditorState } from '@codemirror/state';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import { isCursorInRange } from '../util';
|
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||||
|
|
||||||
/**
|
/** Bullet list marker pattern */
|
||||||
* Pattern for bullet list markers.
|
|
||||||
*/
|
|
||||||
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
|
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,15 +20,12 @@ const BULLET_LIST_MARKER_RE = /^[-+*]$/;
|
|||||||
* - Custom bullet mark rendering (- → •)
|
* - Custom bullet mark rendering (- → •)
|
||||||
* - Interactive task list checkboxes
|
* - Interactive task list checkboxes
|
||||||
*/
|
*/
|
||||||
export const lists = () => [listBulletPlugin, taskListField, baseTheme];
|
export const lists = () => [listBulletPlugin, taskListPlugin, baseTheme];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// List Bullet Plugin
|
// List Bullet Plugin
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget to render list bullet mark.
|
|
||||||
*/
|
|
||||||
class ListBulletWidget extends WidgetType {
|
class ListBulletWidget extends WidgetType {
|
||||||
constructor(readonly bullet: string) {
|
constructor(readonly bullet: string) {
|
||||||
super();
|
super();
|
||||||
@@ -49,10 +44,11 @@ class ListBulletWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build list bullet decorations.
|
* Collect all list mark ranges in visible viewport.
|
||||||
*/
|
*/
|
||||||
function buildListBulletDecorations(view: EditorView): DecorationSet {
|
function collectBulletRanges(view: EditorView): RangeTuple[] {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
@@ -61,59 +57,119 @@ function buildListBulletDecorations(view: EditorView): DecorationSet {
|
|||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
if (type.name !== 'ListMark') return;
|
if (type.name !== 'ListMark') return;
|
||||||
|
|
||||||
// Skip if this is part of a task list (has Task sibling)
|
// Skip task list items
|
||||||
const parent = node.parent;
|
const parent = node.parent;
|
||||||
if (parent) {
|
if (parent?.getChild('Task')) return;
|
||||||
const task = parent.getChild('Task');
|
|
||||||
if (task) return;
|
// Only bullet markers
|
||||||
|
const text = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||||
|
if (!BULLET_LIST_MARKER_RE.test(text)) return;
|
||||||
|
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if cursor is in this range
|
return ranges;
|
||||||
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
|
}
|
||||||
|
|
||||||
const listMark = view.state.sliceDoc(nodeFrom, nodeTo);
|
/**
|
||||||
if (BULLET_LIST_MARKER_RE.test(listMark)) {
|
* Get which bullet the cursor is in (-1 if none).
|
||||||
decorations.push(
|
*/
|
||||||
Decoration.replace({
|
function getCursorBulletPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
||||||
widget: new ListBulletWidget(listMark)
|
const selRange: RangeTuple = [selFrom, selTo];
|
||||||
}).range(nodeFrom, nodeTo)
|
|
||||||
);
|
for (const range of ranges) {
|
||||||
|
if (checkRangeOverlap(range, selRange)) {
|
||||||
|
return range[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build list bullet decorations.
|
||||||
|
*/
|
||||||
|
function buildBulletDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; bullet: string }[] = [];
|
||||||
|
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: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'ListMark') return;
|
||||||
|
|
||||||
|
// Skip task list items
|
||||||
|
const parent = node.parent;
|
||||||
|
if (parent?.getChild('Task')) return;
|
||||||
|
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
|
||||||
|
// Skip if cursor is in this mark
|
||||||
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
|
const bullet = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||||
|
if (BULLET_LIST_MARKER_RE.test(bullet)) {
|
||||||
|
items.push({ from: nodeFrom, to: nodeTo, bullet });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, Decoration.replace({
|
||||||
|
widget: new ListBulletWidget(item.bullet)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List bullet plugin.
|
* List bullet plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
class ListBulletPlugin {
|
class ListBulletPlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
private lastSelectionHead: number = -1;
|
private bulletRanges: RangeTuple[] = [];
|
||||||
|
private cursorBulletPos = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = buildListBulletDecorations(view);
|
this.bulletRanges = collectBulletRanges(view);
|
||||||
this.lastSelectionHead = view.state.selection.main.head;
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorBulletPos = getCursorBulletPos(this.bulletRanges, from, to);
|
||||||
|
this.decorations = buildBulletDecorations(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.viewportChanged) {
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
this.decorations = buildListBulletDecorations(update.view);
|
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
if (docChanged || viewportChanged) {
|
||||||
|
this.bulletRanges = collectBulletRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorBulletPos = getCursorBulletPos(this.bulletRanges, from, to);
|
||||||
|
this.decorations = buildBulletDecorations(update.view);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.selectionSet) {
|
if (selectionSet) {
|
||||||
const newHead = update.state.selection.main.head;
|
const { from, to } = update.state.selection.main;
|
||||||
const oldLine = update.startState.doc.lineAt(this.lastSelectionHead);
|
const newPos = getCursorBulletPos(this.bulletRanges, from, to);
|
||||||
const newLine = update.state.doc.lineAt(newHead);
|
|
||||||
|
|
||||||
if (oldLine.number !== newLine.number) {
|
if (newPos !== this.cursorBulletPos) {
|
||||||
this.decorations = buildListBulletDecorations(update.view);
|
this.cursorBulletPos = newPos;
|
||||||
|
this.decorations = buildBulletDecorations(update.view);
|
||||||
}
|
}
|
||||||
this.lastSelectionHead = newHead;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,16 +179,13 @@ const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Task List Plugin (using StateField to avoid flickering)
|
// Task List Plugin
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget to render checkbox for a task list item.
|
|
||||||
*/
|
|
||||||
class TaskCheckboxWidget extends WidgetType {
|
class TaskCheckboxWidget extends WidgetType {
|
||||||
constructor(
|
constructor(
|
||||||
readonly checked: boolean,
|
readonly checked: boolean,
|
||||||
readonly pos: number // Position of the checkbox character in document
|
readonly pos: number
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -151,7 +204,6 @@ class TaskCheckboxWidget extends WidgetType {
|
|||||||
checkbox.checked = this.checked;
|
checkbox.checked = this.checked;
|
||||||
checkbox.tabIndex = -1;
|
checkbox.tabIndex = -1;
|
||||||
|
|
||||||
// Handle click directly in the widget
|
|
||||||
checkbox.addEventListener('mousedown', (e) => {
|
checkbox.addEventListener('mousedown', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -176,12 +228,68 @@ class TaskCheckboxWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build task list decorations from state.
|
* Collect all task ranges in visible viewport.
|
||||||
*/
|
*/
|
||||||
function buildTaskListDecorations(state: import('@codemirror/state').EditorState): DecorationSet {
|
function collectTaskRanges(view: EditorView): RangeTuple[] {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const ranges: RangeTuple[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
syntaxTree(state).iterate({
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'Task') return;
|
||||||
|
|
||||||
|
const listItem = node.parent;
|
||||||
|
if (!listItem || listItem.type.name !== 'ListItem') return;
|
||||||
|
|
||||||
|
const listMark = listItem.getChild('ListMark');
|
||||||
|
if (!listMark) return;
|
||||||
|
|
||||||
|
if (seen.has(listMark.from)) return;
|
||||||
|
seen.add(listMark.from);
|
||||||
|
|
||||||
|
// Track the full range from ListMark to TaskMarker
|
||||||
|
const taskMarker = node.getChild('TaskMarker');
|
||||||
|
if (taskMarker) {
|
||||||
|
ranges.push([listMark.from, taskMarker.to]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which task the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorTaskPos(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 -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build task list decorations.
|
||||||
|
*/
|
||||||
|
function buildTaskDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration; priority: 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: ({ type, from: taskFrom, to: taskTo, node }) => {
|
enter: ({ type, from: taskFrom, to: taskTo, node }) => {
|
||||||
if (type.name !== 'Task') return;
|
if (type.name !== 'Task') return;
|
||||||
|
|
||||||
@@ -190,66 +298,104 @@ function buildTaskListDecorations(state: import('@codemirror/state').EditorState
|
|||||||
|
|
||||||
const listMark = listItem.getChild('ListMark');
|
const listMark = listItem.getChild('ListMark');
|
||||||
const taskMarker = node.getChild('TaskMarker');
|
const taskMarker = node.getChild('TaskMarker');
|
||||||
|
|
||||||
if (!listMark || !taskMarker) return;
|
if (!listMark || !taskMarker) return;
|
||||||
|
|
||||||
|
if (seen.has(listMark.from)) return;
|
||||||
|
seen.add(listMark.from);
|
||||||
|
|
||||||
const replaceFrom = listMark.from;
|
const replaceFrom = listMark.from;
|
||||||
const replaceTo = taskMarker.to;
|
const replaceTo = taskMarker.to;
|
||||||
|
|
||||||
// Check if cursor is in this range
|
// Skip if cursor is in this range
|
||||||
if (isCursorInRange(state, [replaceFrom, replaceTo])) return;
|
if (checkRangeOverlap([replaceFrom, replaceTo], selRange)) return;
|
||||||
|
|
||||||
// Check if task is checked - position of x or space is taskMarker.from + 1
|
// Check if task is checked
|
||||||
const markerText = state.sliceDoc(taskMarker.from, taskMarker.to);
|
const markerText = view.state.sliceDoc(taskMarker.from, taskMarker.to);
|
||||||
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
||||||
const checkboxPos = taskMarker.from + 1; // Position of the x or space
|
const checkboxPos = taskMarker.from + 1;
|
||||||
|
|
||||||
|
// Add strikethrough for checked items
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
decorations.push(
|
items.push({
|
||||||
Decoration.mark({ class: 'cm-task-checked' }).range(taskFrom, taskTo)
|
from: taskFrom,
|
||||||
);
|
to: taskTo,
|
||||||
|
deco: Decoration.mark({ class: 'cm-task-checked' }),
|
||||||
|
priority: 0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace "- [x]" or "- [ ]" with checkbox widget
|
// Replace "- [x]" or "- [ ]" with checkbox widget
|
||||||
decorations.push(
|
items.push({
|
||||||
Decoration.replace({
|
from: replaceFrom,
|
||||||
|
to: replaceTo,
|
||||||
|
deco: Decoration.replace({
|
||||||
widget: new TaskCheckboxWidget(isChecked, checkboxPos)
|
widget: new TaskCheckboxWidget(isChecked, checkboxPos)
|
||||||
}).range(replaceFrom, replaceTo)
|
}),
|
||||||
);
|
priority: 1
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
// Sort by position, then priority
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.from !== b.from) return a.from - b.from;
|
||||||
|
return a.priority - b.priority;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task list StateField - uses incremental updates to avoid flickering.
|
* Task list plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
const taskListField = StateField.define<DecorationSet>({
|
class TaskListPlugin {
|
||||||
create(state) {
|
decorations: DecorationSet;
|
||||||
return buildTaskListDecorations(state);
|
private taskRanges: RangeTuple[] = [];
|
||||||
},
|
private cursorTaskPos = -1;
|
||||||
|
|
||||||
update(value, tr: Transaction) {
|
constructor(view: EditorView) {
|
||||||
// Only rebuild when document or selection changes
|
this.taskRanges = collectTaskRanges(view);
|
||||||
if (tr.docChanged || tr.selection) {
|
const { from, to } = view.state.selection.main;
|
||||||
return buildTaskListDecorations(tr.state);
|
this.cursorTaskPos = getCursorTaskPos(this.taskRanges, from, to);
|
||||||
|
this.decorations = buildTaskDecorations(view);
|
||||||
}
|
}
|
||||||
return value;
|
|
||||||
},
|
|
||||||
|
|
||||||
provide(field) {
|
update(update: ViewUpdate) {
|
||||||
return EditorView.decorations.from(field);
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.taskRanges = collectTaskRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorTaskPos = getCursorTaskPos(this.taskRanges, from, to);
|
||||||
|
this.decorations = buildTaskDecorations(update.view);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const newPos = getCursorTaskPos(this.taskRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorTaskPos) {
|
||||||
|
this.cursorTaskPos = newPos;
|
||||||
|
this.decorations = buildTaskDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskListPlugin = ViewPlugin.fromClass(TaskListPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Theme
|
// Theme
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Base theme for lists.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-list-bullet': {
|
'.cm-list-bullet': {
|
||||||
color: 'var(--cm-list-bullet-color, inherit)'
|
color: 'var(--cm-list-bullet-color, inherit)'
|
||||||
|
|||||||
@@ -21,41 +21,72 @@ import {
|
|||||||
import katex from 'katex';
|
import katex from 'katex';
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||||
|
import { LruCache } from '@/common/utils/lruCache';
|
||||||
|
|
||||||
// ============================================================================
|
interface KatexCacheValue {
|
||||||
// Inline Math Widget
|
html: string;
|
||||||
// ============================================================================
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LRU cache for KaTeX rendering results.
|
||||||
|
* Key format: "inline:latex" or "block:latex"
|
||||||
|
*/
|
||||||
|
const katexCache = new LruCache<string, KatexCacheValue>(200);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached KaTeX render result or render and cache it.
|
||||||
|
*/
|
||||||
|
function renderKatex(latex: string, displayMode: boolean): KatexCacheValue {
|
||||||
|
const cacheKey = `${displayMode ? 'block' : 'inline'}:${latex}`;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = katexCache.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render and cache
|
||||||
|
let result: KatexCacheValue;
|
||||||
|
try {
|
||||||
|
const html = katex.renderToString(latex, {
|
||||||
|
throwOnError: !displayMode, // inline throws, block doesn't
|
||||||
|
displayMode,
|
||||||
|
output: 'html'
|
||||||
|
});
|
||||||
|
result = { html, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
result = {
|
||||||
|
html: '',
|
||||||
|
error: e instanceof Error ? e.message : 'Render error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
katexCache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Widget to display inline math formula.
|
* Widget to display inline math formula.
|
||||||
|
* Uses cached KaTeX rendering for performance.
|
||||||
*/
|
*/
|
||||||
class InlineMathWidget extends WidgetType {
|
class InlineMathWidget extends WidgetType {
|
||||||
private html: string;
|
|
||||||
private error: string | null = null;
|
|
||||||
|
|
||||||
constructor(readonly latex: string) {
|
constructor(readonly latex: string) {
|
||||||
super();
|
super();
|
||||||
try {
|
|
||||||
this.html = katex.renderToString(latex, {
|
|
||||||
throwOnError: true,
|
|
||||||
displayMode: false,
|
|
||||||
output: 'html'
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this.error = e instanceof Error ? e.message : 'Render error';
|
|
||||||
this.html = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-inline-math';
|
span.className = 'cm-inline-math';
|
||||||
|
|
||||||
if (this.error) {
|
// Use cached render
|
||||||
|
const { html, error } = renderKatex(this.latex, false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
span.textContent = this.latex;
|
span.textContent = this.latex;
|
||||||
span.title = this.error;
|
span.title = error;
|
||||||
} else {
|
} else {
|
||||||
span.innerHTML = this.html;
|
span.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
return span;
|
return span;
|
||||||
@@ -70,34 +101,17 @@ class InlineMathWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Block Math Widget
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Widget to display block math formula.
|
* Widget to display block math formula.
|
||||||
* Uses absolute positioning to overlay on source lines.
|
* Uses absolute positioning to overlay on source lines.
|
||||||
*/
|
*/
|
||||||
class BlockMathWidget extends WidgetType {
|
class BlockMathWidget extends WidgetType {
|
||||||
private html: string;
|
|
||||||
private error: string | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly latex: string,
|
readonly latex: string,
|
||||||
readonly lineCount: number = 1,
|
readonly lineCount: number = 1,
|
||||||
readonly lineHeight: number = 22
|
readonly lineHeight: number = 22
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
try {
|
|
||||||
this.html = katex.renderToString(latex, {
|
|
||||||
throwOnError: false,
|
|
||||||
displayMode: true,
|
|
||||||
output: 'html'
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this.error = e instanceof Error ? e.message : 'Render error';
|
|
||||||
this.html = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
@@ -110,11 +124,14 @@ class BlockMathWidget extends WidgetType {
|
|||||||
const inner = document.createElement('div');
|
const inner = document.createElement('div');
|
||||||
inner.className = 'cm-block-math';
|
inner.className = 'cm-block-math';
|
||||||
|
|
||||||
if (this.error) {
|
// Use cached render
|
||||||
|
const { html, error } = renderKatex(this.latex, true);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
inner.textContent = this.latex;
|
inner.textContent = this.latex;
|
||||||
inner.title = this.error;
|
inner.title = error;
|
||||||
} else {
|
} else {
|
||||||
inner.innerHTML = this.html;
|
inner.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.appendChild(inner);
|
container.appendChild(inner);
|
||||||
@@ -130,15 +147,42 @@ class BlockMathWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
/**
|
||||||
// Decorations
|
* Represents a math region in the document.
|
||||||
// ============================================================================
|
*/
|
||||||
|
interface MathRegion {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of building decorations, includes math regions for cursor tracking.
|
||||||
|
*/
|
||||||
|
interface BuildResult {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
mathRegions: MathRegion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the math region containing the given position.
|
||||||
|
* Returns the region index or -1 if not in any region.
|
||||||
|
*/
|
||||||
|
function findMathRegionIndex(pos: number, regions: MathRegion[]): number {
|
||||||
|
for (let i = 0; i < regions.length; i++) {
|
||||||
|
if (pos >= regions[i].from && pos <= regions[i].to) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build decorations for math formulas.
|
* Build decorations for math formulas.
|
||||||
|
* Also collects math regions for cursor tracking optimization.
|
||||||
*/
|
*/
|
||||||
function buildDecorations(view: EditorView): DecorationSet {
|
function buildDecorations(view: EditorView): BuildResult {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
const mathRegions: MathRegion[] = [];
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
@@ -147,6 +191,9 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
// Handle inline math
|
// Handle inline math
|
||||||
if (type.name === 'InlineMath') {
|
if (type.name === 'InlineMath') {
|
||||||
|
// Collect math region for cursor tracking
|
||||||
|
mathRegions.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
const marks = node.getChildren('InlineMathMark');
|
const marks = node.getChildren('InlineMathMark');
|
||||||
|
|
||||||
@@ -169,6 +216,9 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
|
|
||||||
// Handle block math ($$...$$)
|
// Handle block math ($$...$$)
|
||||||
if (type.name === 'BlockMath') {
|
if (type.name === 'BlockMath') {
|
||||||
|
// Collect math region for cursor tracking
|
||||||
|
mathRegions.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||||
const marks = node.getChildren('BlockMathMark');
|
const marks = node.getChildren('BlockMathMark');
|
||||||
|
|
||||||
@@ -225,36 +275,58 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
return {
|
||||||
|
decorations: Decoration.set(decorations, true),
|
||||||
|
mathRegions
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Plugin
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Math plugin with optimized update detection.
|
* Math plugin with optimized update detection.
|
||||||
*/
|
*/
|
||||||
class MathPlugin {
|
class MathPlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
|
private mathRegions: MathRegion[] = [];
|
||||||
private lastSelectionHead: number = -1;
|
private lastSelectionHead: number = -1;
|
||||||
|
private lastMathRegionIndex: number = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = buildDecorations(view);
|
const result = buildDecorations(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.mathRegions = result.mathRegions;
|
||||||
this.lastSelectionHead = view.state.selection.main.head;
|
this.lastSelectionHead = view.state.selection.main.head;
|
||||||
|
this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
|
// Always rebuild on document change or viewport change
|
||||||
if (update.docChanged || update.viewportChanged) {
|
if (update.docChanged || update.viewportChanged) {
|
||||||
this.decorations = buildDecorations(update.view);
|
const result = buildDecorations(update.view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.mathRegions = result.mathRegions;
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
this.lastSelectionHead = update.state.selection.main.head;
|
||||||
|
this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For selection changes, only rebuild if cursor changes math region context
|
||||||
if (update.selectionSet) {
|
if (update.selectionSet) {
|
||||||
const newHead = update.state.selection.main.head;
|
const newHead = update.state.selection.main.head;
|
||||||
|
|
||||||
if (newHead !== this.lastSelectionHead) {
|
if (newHead !== this.lastSelectionHead) {
|
||||||
this.decorations = buildDecorations(update.view);
|
const newRegionIndex = findMathRegionIndex(newHead, this.mathRegions);
|
||||||
|
|
||||||
|
// Only rebuild if:
|
||||||
|
// 1. Cursor entered a math region (was outside, now inside)
|
||||||
|
// 2. Cursor left a math region (was inside, now outside)
|
||||||
|
// 3. Cursor moved to a different math region
|
||||||
|
if (newRegionIndex !== this.lastMathRegionIndex) {
|
||||||
|
const result = buildDecorations(update.view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.mathRegions = result.mathRegions;
|
||||||
|
this.lastMathRegionIndex = findMathRegionIndex(newHead, this.mathRegions);
|
||||||
|
}
|
||||||
|
|
||||||
this.lastSelectionHead = newHead;
|
this.lastSelectionHead = newHead;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,10 +340,6 @@ const mathPlugin = ViewPlugin.fromClass(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Theme
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for math.
|
* Base theme for math.
|
||||||
*/
|
*/
|
||||||
@@ -336,10 +404,6 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Export
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Math extension.
|
* Math extension.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Extension, Range } from '@codemirror/state';
|
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import {
|
import {
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
@@ -7,120 +7,153 @@ import {
|
|||||||
EditorView,
|
EditorView,
|
||||||
ViewUpdate
|
ViewUpdate
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
|
||||||
|
/** Pre-computed mark decorations */
|
||||||
|
const superscriptMarkDecoration = Decoration.mark({ class: 'cm-superscript' });
|
||||||
|
const subscriptMarkDecoration = Decoration.mark({ class: 'cm-subscript' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscript and Superscript plugin using syntax tree.
|
* Subscript and Superscript plugin using syntax tree.
|
||||||
*
|
*
|
||||||
* Uses lezer-markdown's Subscript and Superscript extensions to detect:
|
|
||||||
* - Superscript: ^text^ → renders as superscript
|
* - Superscript: ^text^ → renders as superscript
|
||||||
* - Subscript: ~text~ → renders as subscript
|
* - Subscript: ~text~ → renders as subscript
|
||||||
*
|
*
|
||||||
* Note: Inline footnotes ^[content] are handled by the Footnote extension
|
* Note: Inline footnotes ^[content] are handled by the Footnote extension.
|
||||||
* which parses InlineFootnote before Superscript in the syntax tree.
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* - 19^th^ → 19ᵗʰ (superscript)
|
|
||||||
* - H~2~O → H₂O (subscript)
|
|
||||||
*/
|
*/
|
||||||
export const subscriptSuperscript = (): Extension => [
|
export const subscriptSuperscript = (): Extension => [
|
||||||
subscriptSuperscriptPlugin,
|
subscriptSuperscriptPlugin,
|
||||||
baseTheme
|
baseTheme
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Node types to handle */
|
||||||
|
const SCRIPT_TYPES = new Set(['Superscript', 'Subscript']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build decorations for subscript and superscript using syntax tree.
|
* Collect all superscript/subscript ranges in visible viewport.
|
||||||
|
*/
|
||||||
|
function collectScriptRanges(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: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||||
|
if (!SCRIPT_TYPES.has(type.name)) return;
|
||||||
|
if (seen.has(nodeFrom)) return;
|
||||||
|
seen.add(nodeFrom);
|
||||||
|
ranges.push([nodeFrom, nodeTo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which script element the cursor is in (-1 if none).
|
||||||
|
*/
|
||||||
|
function getCursorScriptPos(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 -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations for subscript and superscript.
|
||||||
*/
|
*/
|
||||||
function buildDecorations(view: EditorView): DecorationSet {
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
const decorations: Range<Decoration>[] = [];
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
const items: { from: number; to: number; deco: Decoration }[] = [];
|
||||||
|
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) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(view.state).iterate({
|
syntaxTree(view.state).iterate({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
// Handle Superscript nodes
|
if (!SCRIPT_TYPES.has(type.name)) return;
|
||||||
// Note: InlineFootnote ^[content] is parsed before Superscript,
|
if (seen.has(nodeFrom)) return;
|
||||||
// so we don't need to check for bracket patterns here
|
seen.add(nodeFrom);
|
||||||
if (type.name === 'Superscript') {
|
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
|
||||||
|
|
||||||
// Get the mark nodes (the ^ characters)
|
// Skip if cursor is in this element
|
||||||
const marks = node.getChildren('SuperscriptMark');
|
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
||||||
|
|
||||||
if (!cursorInRange && marks.length >= 2) {
|
const isSuperscript = type.name === 'Superscript';
|
||||||
// Hide the opening and closing ^ marks
|
const markName = isSuperscript ? 'SuperscriptMark' : 'SubscriptMark';
|
||||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
const contentDeco = isSuperscript ? superscriptMarkDecoration : subscriptMarkDecoration;
|
||||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
|
||||||
|
|
||||||
// Apply superscript style to the content between marks
|
const marks = node.getChildren(markName);
|
||||||
|
if (marks.length < 2) return;
|
||||||
|
|
||||||
|
// Hide opening mark
|
||||||
|
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||||
|
|
||||||
|
// Apply style to content
|
||||||
const contentStart = marks[0].to;
|
const contentStart = marks[0].to;
|
||||||
const contentEnd = marks[marks.length - 1].from;
|
const contentEnd = marks[marks.length - 1].from;
|
||||||
if (contentStart < contentEnd) {
|
if (contentStart < contentEnd) {
|
||||||
decorations.push(
|
items.push({ from: contentStart, to: contentEnd, deco: contentDeco });
|
||||||
Decoration.mark({
|
|
||||||
class: 'cm-superscript'
|
|
||||||
}).range(contentStart, contentEnd)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Subscript nodes
|
// Hide closing mark
|
||||||
if (type.name === 'Subscript') {
|
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
|
||||||
|
|
||||||
// Get the mark nodes (the ~ characters)
|
|
||||||
const marks = node.getChildren('SubscriptMark');
|
|
||||||
|
|
||||||
if (!cursorInRange && marks.length >= 2) {
|
|
||||||
// Hide the opening and closing ~ marks
|
|
||||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
|
||||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
|
||||||
|
|
||||||
// Apply subscript style to the content between marks
|
|
||||||
const contentStart = marks[0].to;
|
|
||||||
const contentEnd = marks[marks.length - 1].from;
|
|
||||||
if (contentStart < contentEnd) {
|
|
||||||
decorations.push(
|
|
||||||
Decoration.mark({
|
|
||||||
class: 'cm-subscript'
|
|
||||||
}).range(contentStart, contentEnd)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Decoration.set(decorations, true);
|
// Sort and add to builder
|
||||||
|
items.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
builder.add(item.from, item.to, item.deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin class with optimized update detection.
|
* Subscript/Superscript plugin with optimized updates.
|
||||||
*/
|
*/
|
||||||
class SubscriptSuperscriptPlugin {
|
class SubscriptSuperscriptPlugin {
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
private lastSelectionHead: number = -1;
|
private scriptRanges: RangeTuple[] = [];
|
||||||
|
private cursorScriptPos = -1;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
|
this.scriptRanges = collectScriptRanges(view);
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.cursorScriptPos = getCursorScriptPos(this.scriptRanges, from, to);
|
||||||
this.decorations = buildDecorations(view);
|
this.decorations = buildDecorations(view);
|
||||||
this.lastSelectionHead = view.state.selection.main.head;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.docChanged || update.viewportChanged) {
|
const { docChanged, viewportChanged, selectionSet } = update;
|
||||||
|
|
||||||
|
if (docChanged || viewportChanged) {
|
||||||
|
this.scriptRanges = collectScriptRanges(update.view);
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.cursorScriptPos = getCursorScriptPos(this.scriptRanges, from, to);
|
||||||
this.decorations = buildDecorations(update.view);
|
this.decorations = buildDecorations(update.view);
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.selectionSet) {
|
if (selectionSet) {
|
||||||
const newHead = update.state.selection.main.head;
|
const { from, to } = update.state.selection.main;
|
||||||
if (newHead !== this.lastSelectionHead) {
|
const newPos = getCursorScriptPos(this.scriptRanges, from, to);
|
||||||
|
|
||||||
|
if (newPos !== this.cursorScriptPos) {
|
||||||
|
this.cursorScriptPos = newPos;
|
||||||
this.decorations = buildDecorations(update.view);
|
this.decorations = buildDecorations(update.view);
|
||||||
this.lastSelectionHead = newHead;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,8 +168,6 @@ const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for subscript and superscript.
|
* Base theme for subscript and superscript.
|
||||||
* Uses mark decoration instead of widget to avoid layout issues.
|
|
||||||
* fontSize uses smaller value as subscript/superscript are naturally smaller.
|
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
const baseTheme = EditorView.baseTheme({
|
||||||
'.cm-superscript': {
|
'.cm-superscript': {
|
||||||
@@ -150,3 +181,4 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
color: 'var(--cm-subscript-color, inherit)'
|
color: 'var(--cm-subscript-color, inherit)'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
833
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
833
frontend/src/views/editor/extensions/markdown/plugins/table.ts
Normal file
@@ -0,0 +1,833 @@
|
|||||||
|
/**
|
||||||
|
* Table plugin for CodeMirror.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Renders markdown tables as beautiful HTML tables
|
||||||
|
* - Lines remain, content hidden, table overlays on top (same as math.ts)
|
||||||
|
* - Shows source when cursor is inside
|
||||||
|
* - Supports alignment (left, center, right)
|
||||||
|
*
|
||||||
|
* Table syntax tree structure from @lezer/markdown:
|
||||||
|
* - Table (root)
|
||||||
|
* - TableHeader (first row)
|
||||||
|
* - TableDelimiter (|)
|
||||||
|
* - TableCell (content)
|
||||||
|
* - TableDelimiter (separator row |---|---|)
|
||||||
|
* - TableRow (data rows)
|
||||||
|
* - TableCell (content)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Extension, Range } from '@codemirror/state';
|
||||||
|
import { syntaxTree, foldedRanges } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { SyntaxNode } from '@lezer/common';
|
||||||
|
import { isCursorInRange } from '../util';
|
||||||
|
import { LruCache } from '@/common/utils/lruCache';
|
||||||
|
import { generateContentHash } from '@/common/utils/hashUtils';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types and Interfaces
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Cell alignment type */
|
||||||
|
type CellAlign = 'left' | 'center' | 'right';
|
||||||
|
|
||||||
|
/** Parsed table data */
|
||||||
|
interface TableData {
|
||||||
|
headers: string[];
|
||||||
|
alignments: CellAlign[];
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Table range info for tracking */
|
||||||
|
interface TableRange {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cache using LruCache from utils
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** LRU cache for parsed table data - keyed by position for fast lookup */
|
||||||
|
const tableCacheByPos = new LruCache<string, { hash: string; data: TableData }>(50);
|
||||||
|
|
||||||
|
/** LRU cache for inline markdown rendering */
|
||||||
|
const inlineRenderCache = new LruCache<string, string>(200);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or parse table data with two-level caching.
|
||||||
|
* First checks position, then verifies content hash only if position matches.
|
||||||
|
* This avoids expensive hash computation on cache miss.
|
||||||
|
*/
|
||||||
|
function getCachedTableData(
|
||||||
|
state: import('@codemirror/state').EditorState,
|
||||||
|
tableNode: SyntaxNode
|
||||||
|
): TableData | null {
|
||||||
|
const posKey = `${tableNode.from}-${tableNode.to}`;
|
||||||
|
|
||||||
|
// First level: check if we have data for this position
|
||||||
|
const cached = tableCacheByPos.get(posKey);
|
||||||
|
if (cached) {
|
||||||
|
// Second level: verify content hash matches (lazy hash computation)
|
||||||
|
const content = state.sliceDoc(tableNode.from, tableNode.to);
|
||||||
|
const contentHash = generateContentHash(content);
|
||||||
|
if (cached.hash === contentHash) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - parse and cache
|
||||||
|
const content = state.sliceDoc(tableNode.from, tableNode.to);
|
||||||
|
const data = parseTableData(state, tableNode);
|
||||||
|
if (data) {
|
||||||
|
tableCacheByPos.set(posKey, {
|
||||||
|
hash: generateContentHash(content),
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Parsing Functions (Optimized)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse alignment from delimiter row.
|
||||||
|
* Optimized: early returns, minimal string operations.
|
||||||
|
*/
|
||||||
|
function parseAlignment(delimiterText: string): CellAlign {
|
||||||
|
const len = delimiterText.length;
|
||||||
|
if (len === 0) return 'left';
|
||||||
|
|
||||||
|
// Find first and last non-space characters
|
||||||
|
let start = 0;
|
||||||
|
let end = len - 1;
|
||||||
|
while (start < len && delimiterText.charCodeAt(start) === 32) start++;
|
||||||
|
while (end > start && delimiterText.charCodeAt(end) === 32) end--;
|
||||||
|
|
||||||
|
if (start > end) return 'left';
|
||||||
|
|
||||||
|
const hasLeftColon = delimiterText.charCodeAt(start) === 58; // ':'
|
||||||
|
const hasRightColon = delimiterText.charCodeAt(end) === 58;
|
||||||
|
|
||||||
|
if (hasLeftColon && hasRightColon) return 'center';
|
||||||
|
if (hasRightColon) return 'right';
|
||||||
|
return 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a row text into cells by splitting on |
|
||||||
|
* Optimized: single-pass parsing without multiple string operations.
|
||||||
|
*/
|
||||||
|
function parseRowText(rowText: string): string[] {
|
||||||
|
const cells: string[] = [];
|
||||||
|
const len = rowText.length;
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let end = len;
|
||||||
|
|
||||||
|
// Skip leading whitespace
|
||||||
|
while (start < len && rowText.charCodeAt(start) <= 32) start++;
|
||||||
|
// Skip trailing whitespace
|
||||||
|
while (end > start && rowText.charCodeAt(end - 1) <= 32) end--;
|
||||||
|
|
||||||
|
// Skip leading |
|
||||||
|
if (start < end && rowText.charCodeAt(start) === 124) start++;
|
||||||
|
// Skip trailing |
|
||||||
|
if (end > start && rowText.charCodeAt(end - 1) === 124) end--;
|
||||||
|
|
||||||
|
// Parse cells in single pass
|
||||||
|
let cellStart = start;
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
if (i === end || rowText.charCodeAt(i) === 124) {
|
||||||
|
// Extract and trim cell
|
||||||
|
let cs = cellStart;
|
||||||
|
let ce = i;
|
||||||
|
while (cs < ce && rowText.charCodeAt(cs) <= 32) cs++;
|
||||||
|
while (ce > cs && rowText.charCodeAt(ce - 1) <= 32) ce--;
|
||||||
|
cells.push(rowText.substring(cs, ce));
|
||||||
|
cellStart = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse table data from syntax tree node.
|
||||||
|
*
|
||||||
|
* Table syntax tree structure from @lezer/markdown:
|
||||||
|
* - Table (root)
|
||||||
|
* - TableHeader (contains TableCell children)
|
||||||
|
* - TableDelimiter (the |---|---| line)
|
||||||
|
* - TableRow (contains TableCell children)
|
||||||
|
*/
|
||||||
|
function parseTableData(state: import('@codemirror/state').EditorState, tableNode: SyntaxNode): TableData | null {
|
||||||
|
const headers: string[] = [];
|
||||||
|
const alignments: CellAlign[] = [];
|
||||||
|
const rows: string[][] = [];
|
||||||
|
|
||||||
|
// Get TableHeader
|
||||||
|
const headerNode = tableNode.getChild('TableHeader');
|
||||||
|
if (!headerNode) return null;
|
||||||
|
|
||||||
|
// Get TableCell children from header
|
||||||
|
const headerCells = headerNode.getChildren('TableCell');
|
||||||
|
|
||||||
|
if (headerCells.length > 0) {
|
||||||
|
// Parse from TableCell nodes
|
||||||
|
for (const cell of headerCells) {
|
||||||
|
const text = state.sliceDoc(cell.from, cell.to).trim();
|
||||||
|
headers.push(text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: parse the entire header row text
|
||||||
|
const headerText = state.sliceDoc(headerNode.from, headerNode.to);
|
||||||
|
const parsedHeaders = parseRowText(headerText);
|
||||||
|
headers.push(...parsedHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headers.length === 0) return null;
|
||||||
|
|
||||||
|
// Find delimiter row to get alignments
|
||||||
|
// The delimiter is a direct child of Table
|
||||||
|
let child = tableNode.firstChild;
|
||||||
|
while (child) {
|
||||||
|
if (child.type.name === 'TableDelimiter') {
|
||||||
|
const delimText = state.sliceDoc(child.from, child.to);
|
||||||
|
// Check if this contains --- (alignment row)
|
||||||
|
if (delimText.includes('-')) {
|
||||||
|
const parts = parseRowText(delimText);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.includes('-')) {
|
||||||
|
alignments.push(parseAlignment(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child = child.nextSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill missing alignments with 'left'
|
||||||
|
while (alignments.length < headers.length) {
|
||||||
|
alignments.push('left');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse data rows
|
||||||
|
const rowNodes = tableNode.getChildren('TableRow');
|
||||||
|
|
||||||
|
for (const rowNode of rowNodes) {
|
||||||
|
const rowData: string[] = [];
|
||||||
|
const cells = rowNode.getChildren('TableCell');
|
||||||
|
|
||||||
|
if (cells.length > 0) {
|
||||||
|
// Parse from TableCell nodes
|
||||||
|
for (const cell of cells) {
|
||||||
|
const text = state.sliceDoc(cell.from, cell.to).trim();
|
||||||
|
rowData.push(text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: parse the entire row text
|
||||||
|
const rowText = state.sliceDoc(rowNode.from, rowNode.to);
|
||||||
|
const parsedCells = parseRowText(rowText);
|
||||||
|
rowData.push(...parsedCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill missing cells with empty string
|
||||||
|
while (rowData.length < headers.length) {
|
||||||
|
rowData.push('');
|
||||||
|
}
|
||||||
|
rows.push(rowData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers, alignments, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Pre-compiled regex patterns for better performance
|
||||||
|
const BOLD_STAR_RE = /\*\*(.+?)\*\*/g;
|
||||||
|
const BOLD_UNDER_RE = /__(.+?)__/g;
|
||||||
|
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
|
||||||
|
const ITALIC_UNDER_RE = /(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g;
|
||||||
|
const CODE_RE = /`([^`]+)`/g;
|
||||||
|
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
const STRIKE_RE = /~~(.+?)~~/g;
|
||||||
|
|
||||||
|
// Regex to detect HTML tags (opening, closing, or self-closing)
|
||||||
|
const HTML_TAG_RE = /<[a-zA-Z][^>]*>|<\/[a-zA-Z][^>]*>/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize HTML content with DOMPurify.
|
||||||
|
*/
|
||||||
|
function sanitizeHTML(html: string): string {
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ADD_TAGS: ['code', 'strong', 'em', 'del', 'a', 'img', 'br', 'span'],
|
||||||
|
ADD_ATTR: ['href', 'target', 'src', 'alt', 'class', 'style'],
|
||||||
|
ALLOW_DATA_ATTR: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert inline markdown syntax to HTML.
|
||||||
|
* Handles: **bold**, *italic*, `code`, [link](url), ~~strikethrough~~, and HTML tags
|
||||||
|
* Optimized with pre-compiled regex and LRU caching.
|
||||||
|
*/
|
||||||
|
function renderInlineMarkdown(text: string): string {
|
||||||
|
// Check cache first
|
||||||
|
const cached = inlineRenderCache.get(text);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
let html = text;
|
||||||
|
|
||||||
|
// Check if text contains HTML tags
|
||||||
|
const hasHTMLTags = HTML_TAG_RE.test(text);
|
||||||
|
|
||||||
|
if (hasHTMLTags) {
|
||||||
|
// If contains HTML tags, process markdown first without escaping < >
|
||||||
|
// Bold: **text** or __text__
|
||||||
|
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>');
|
||||||
|
html = html.replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic: *text* or _text_ (but not inside words for _)
|
||||||
|
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>');
|
||||||
|
html = html.replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Inline code: `code` - but don't double-process if already has <code>
|
||||||
|
if (!html.includes('<code>')) {
|
||||||
|
html = html.replace(CODE_RE, '<code>$1</code>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links: [text](url)
|
||||||
|
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>');
|
||||||
|
|
||||||
|
// Strikethrough: ~~text~~
|
||||||
|
html = html.replace(STRIKE_RE, '<del>$1</del>');
|
||||||
|
|
||||||
|
// Sanitize HTML for security
|
||||||
|
html = sanitizeHTML(html);
|
||||||
|
} else {
|
||||||
|
// No HTML tags - escape < > and process markdown
|
||||||
|
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Bold: **text** or __text__
|
||||||
|
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>');
|
||||||
|
html = html.replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic: *text* or _text_ (but not inside words for _)
|
||||||
|
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>');
|
||||||
|
html = html.replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Inline code: `code`
|
||||||
|
html = html.replace(CODE_RE, '<code>$1</code>');
|
||||||
|
|
||||||
|
// Links: [text](url)
|
||||||
|
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>');
|
||||||
|
|
||||||
|
// Strikethrough: ~~text~~
|
||||||
|
html = html.replace(STRIKE_RE, '<del>$1</del>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache result using LRU cache
|
||||||
|
inlineRenderCache.set(text, html);
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget to display rendered table.
|
||||||
|
* Uses absolute positioning to overlay on source lines.
|
||||||
|
* Optimized with innerHTML for faster DOM creation.
|
||||||
|
*/
|
||||||
|
class TableWidget extends WidgetType {
|
||||||
|
// Cache the generated HTML to avoid regenerating on each toDOM call
|
||||||
|
private cachedHTML: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly tableData: TableData,
|
||||||
|
readonly lineCount: number,
|
||||||
|
readonly lineHeight: number,
|
||||||
|
readonly visualHeight: number,
|
||||||
|
readonly contentWidth: number
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build table HTML string (much faster than DOM API for large tables).
|
||||||
|
*/
|
||||||
|
private buildTableHTML(): string {
|
||||||
|
if (this.cachedHTML) return this.cachedHTML;
|
||||||
|
|
||||||
|
// Calculate row heights
|
||||||
|
const headerRatio = 2 / this.lineCount;
|
||||||
|
const dataRowRatio = 1 / this.lineCount;
|
||||||
|
const headerHeight = this.visualHeight * headerRatio;
|
||||||
|
const dataRowHeight = this.visualHeight * dataRowRatio;
|
||||||
|
|
||||||
|
// Build header cells
|
||||||
|
const headerCells = this.tableData.headers.map((header, idx) => {
|
||||||
|
const align = this.tableData.alignments[idx] || 'left';
|
||||||
|
const escapedTitle = header.replace(/"/g, '"');
|
||||||
|
return `<th class="cm-table-align-${align}" title="${escapedTitle}">${renderInlineMarkdown(header)}</th>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Build body rows
|
||||||
|
const bodyRows = this.tableData.rows.map(row => {
|
||||||
|
const cells = row.map((cell, idx) => {
|
||||||
|
const align = this.tableData.alignments[idx] || 'left';
|
||||||
|
const escapedTitle = cell.replace(/"/g, '"');
|
||||||
|
return `<td class="cm-table-align-${align}" title="${escapedTitle}">${renderInlineMarkdown(cell)}</td>`;
|
||||||
|
}).join('');
|
||||||
|
return `<tr style="height:${dataRowHeight}px">${cells}</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
this.cachedHTML = `<table class="cm-table"><thead><tr style="height:${headerHeight}px">${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
|
||||||
|
return this.cachedHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'cm-table-container';
|
||||||
|
container.style.height = `${this.visualHeight}px`;
|
||||||
|
|
||||||
|
const tableWrapper = document.createElement('div');
|
||||||
|
tableWrapper.className = 'cm-table-wrapper';
|
||||||
|
tableWrapper.style.maxWidth = `${this.contentWidth}px`;
|
||||||
|
tableWrapper.style.maxHeight = `${this.visualHeight}px`;
|
||||||
|
|
||||||
|
// Use innerHTML for faster DOM creation (single parse vs many createElement calls)
|
||||||
|
tableWrapper.innerHTML = this.buildTableHTML();
|
||||||
|
|
||||||
|
container.appendChild(tableWrapper);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: TableWidget): boolean {
|
||||||
|
// Quick dimension checks first (most likely to differ)
|
||||||
|
if (this.visualHeight !== other.visualHeight ||
|
||||||
|
this.contentWidth !== other.contentWidth ||
|
||||||
|
this.lineCount !== other.lineCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use reference equality for tableData if same object
|
||||||
|
if (this.tableData === other.tableData) return true;
|
||||||
|
|
||||||
|
// Quick length checks
|
||||||
|
const headers1 = this.tableData.headers;
|
||||||
|
const headers2 = other.tableData.headers;
|
||||||
|
const rows1 = this.tableData.rows;
|
||||||
|
const rows2 = other.tableData.rows;
|
||||||
|
|
||||||
|
if (headers1.length !== headers2.length || rows1.length !== rows2.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare headers (usually short)
|
||||||
|
for (let i = 0, len = headers1.length; i < len; i++) {
|
||||||
|
if (headers1[i] !== headers2[i]) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare rows
|
||||||
|
for (let i = 0, rowLen = rows1.length; i < rowLen; i++) {
|
||||||
|
const row1 = rows1[i];
|
||||||
|
const row2 = rows2[i];
|
||||||
|
if (row1.length !== row2.length) return false;
|
||||||
|
for (let j = 0, cellLen = row1.length; j < cellLen; j++) {
|
||||||
|
if (row1[j] !== row2[j]) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Decorations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a range overlaps with any folded region.
|
||||||
|
*/
|
||||||
|
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
|
||||||
|
const folded = foldedRanges(view.state);
|
||||||
|
const cursor = folded.iter();
|
||||||
|
while (cursor.value) {
|
||||||
|
// Check if ranges overlap
|
||||||
|
if (cursor.from < to && cursor.to > from) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cursor.next();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of building decorations - includes both decorations and table ranges */
|
||||||
|
interface BuildResult {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
tableRanges: TableRange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build decorations for tables and collect table ranges in a single pass.
|
||||||
|
* Optimized: single syntax tree traversal instead of two separate ones.
|
||||||
|
*/
|
||||||
|
function buildDecorationsAndRanges(view: EditorView): BuildResult {
|
||||||
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
const tableRanges: TableRange[] = [];
|
||||||
|
const contentWidth = view.contentDOM.clientWidth - 10;
|
||||||
|
const lineHeight = view.defaultLineHeight;
|
||||||
|
|
||||||
|
// Pre-create the line decoration to reuse (same class for all hidden lines)
|
||||||
|
const hiddenLineDecoration = Decoration.line({ class: 'cm-table-line-hidden' });
|
||||||
|
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||||
|
if (type.name !== 'Table') return;
|
||||||
|
|
||||||
|
// Always collect table ranges for selection tracking
|
||||||
|
tableRanges.push({ from: nodeFrom, to: nodeTo });
|
||||||
|
|
||||||
|
// Skip rendering if table is in a folded region
|
||||||
|
if (isInFoldedRange(view, nodeFrom, nodeTo)) return;
|
||||||
|
|
||||||
|
// Skip rendering if cursor/selection is in table range
|
||||||
|
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
|
||||||
|
|
||||||
|
// Get cached or parse table data
|
||||||
|
const tableData = getCachedTableData(view.state, node);
|
||||||
|
if (!tableData) return;
|
||||||
|
|
||||||
|
// Calculate line info
|
||||||
|
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||||
|
const endLine = view.state.doc.lineAt(nodeTo);
|
||||||
|
const lineCount = endLine.number - startLine.number + 1;
|
||||||
|
|
||||||
|
// Get visual height using lineBlockAt (includes wrapped lines)
|
||||||
|
const startBlock = view.lineBlockAt(nodeFrom);
|
||||||
|
const endBlock = view.lineBlockAt(nodeTo);
|
||||||
|
const visualHeight = endBlock.bottom - startBlock.top;
|
||||||
|
|
||||||
|
// Add line decorations to hide content (reuse decoration object)
|
||||||
|
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
||||||
|
const line = view.state.doc.line(lineNum);
|
||||||
|
decorations.push(hiddenLineDecoration.range(line.from));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add widget on the first line (positioned absolutely)
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget({
|
||||||
|
widget: new TableWidget(tableData, lineCount, lineHeight, visualHeight, contentWidth),
|
||||||
|
side: -1
|
||||||
|
}).range(startLine.from)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorations: Decoration.set(decorations, true),
|
||||||
|
tableRanges
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find which table the selection is in (if any).
|
||||||
|
* Returns table index or -1 if not in any table.
|
||||||
|
* Optimized: early exit on first match.
|
||||||
|
*/
|
||||||
|
function findSelectionTableIndex(
|
||||||
|
selectionRanges: readonly { from: number; to: number }[],
|
||||||
|
tableRanges: TableRange[]
|
||||||
|
): number {
|
||||||
|
// Early exit if no tables
|
||||||
|
if (tableRanges.length === 0) return -1;
|
||||||
|
|
||||||
|
for (const sel of selectionRanges) {
|
||||||
|
const selFrom = sel.from;
|
||||||
|
const selTo = sel.to;
|
||||||
|
for (let i = 0; i < tableRanges.length; i++) {
|
||||||
|
const table = tableRanges[i];
|
||||||
|
// Inline overlap check (avoid function call overhead)
|
||||||
|
if (selFrom <= table.to && table.from <= selTo) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table plugin with optimized update detection.
|
||||||
|
*
|
||||||
|
* Performance optimizations:
|
||||||
|
* - Single syntax tree traversal (buildDecorationsAndRanges)
|
||||||
|
* - Tracks table ranges to minimize unnecessary rebuilds
|
||||||
|
* - Only rebuilds when selection enters/exits table OR switches between tables
|
||||||
|
* - Detects both cursor position AND selection range changes
|
||||||
|
*/
|
||||||
|
class TablePlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private tableRanges: TableRange[] = [];
|
||||||
|
private lastContentWidth: number = 0;
|
||||||
|
// Track last selection state for comparison
|
||||||
|
private lastSelectionFrom: number = -1;
|
||||||
|
private lastSelectionTo: number = -1;
|
||||||
|
// Track which table the selection is in (-1 = not in any table)
|
||||||
|
private lastTableIndex: number = -1;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
const result = buildDecorationsAndRanges(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.tableRanges = result.tableRanges;
|
||||||
|
this.lastContentWidth = view.contentDOM.clientWidth;
|
||||||
|
// Initialize selection tracking
|
||||||
|
const mainSel = view.state.selection.main;
|
||||||
|
this.lastSelectionFrom = mainSel.from;
|
||||||
|
this.lastSelectionTo = mainSel.to;
|
||||||
|
this.lastTableIndex = findSelectionTableIndex(view.state.selection.ranges, this.tableRanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const view = update.view;
|
||||||
|
const currentContentWidth = view.contentDOM.clientWidth;
|
||||||
|
|
||||||
|
// Check if content width changed (requires rebuild for proper sizing)
|
||||||
|
const widthChanged = Math.abs(currentContentWidth - this.lastContentWidth) > 1;
|
||||||
|
if (widthChanged) {
|
||||||
|
this.lastContentWidth = currentContentWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full rebuild needed for:
|
||||||
|
// - Document changes (table content may have changed)
|
||||||
|
// - Viewport changes (new tables may be visible)
|
||||||
|
// - Geometry changes (folding, line height changes)
|
||||||
|
// - Width changes (table needs resizing)
|
||||||
|
if (update.docChanged || update.viewportChanged || update.geometryChanged || widthChanged) {
|
||||||
|
const result = buildDecorationsAndRanges(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.tableRanges = result.tableRanges;
|
||||||
|
// Update selection tracking
|
||||||
|
const mainSel = update.state.selection.main;
|
||||||
|
this.lastSelectionFrom = mainSel.from;
|
||||||
|
this.lastSelectionTo = mainSel.to;
|
||||||
|
this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For selection changes, check if selection moved in/out of a table OR between tables
|
||||||
|
if (update.selectionSet) {
|
||||||
|
const mainSel = update.state.selection.main;
|
||||||
|
const selectionChanged = mainSel.from !== this.lastSelectionFrom ||
|
||||||
|
mainSel.to !== this.lastSelectionTo;
|
||||||
|
|
||||||
|
if (selectionChanged) {
|
||||||
|
// Find which table (if any) the selection is now in
|
||||||
|
const currentTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
|
||||||
|
|
||||||
|
// Rebuild if selection moved to a different table (including in/out)
|
||||||
|
if (currentTableIndex !== this.lastTableIndex) {
|
||||||
|
const result = buildDecorationsAndRanges(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.tableRanges = result.tableRanges;
|
||||||
|
// Re-check after rebuild (table ranges may have changed)
|
||||||
|
this.lastTableIndex = findSelectionTableIndex(update.state.selection.ranges, this.tableRanges);
|
||||||
|
} else {
|
||||||
|
this.lastTableIndex = currentTableIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tracking state
|
||||||
|
this.lastSelectionFrom = mainSel.from;
|
||||||
|
this.lastSelectionTo = mainSel.to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tablePlugin = ViewPlugin.fromClass(
|
||||||
|
TablePlugin,
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base theme for tables.
|
||||||
|
*/
|
||||||
|
const baseTheme = EditorView.baseTheme({
|
||||||
|
// Table container - same as math.ts
|
||||||
|
'.cm-table-container': {
|
||||||
|
position: 'absolute',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: '2',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Table wrapper - scrollable when needed
|
||||||
|
'.cm-table-wrapper': {
|
||||||
|
display: 'inline-block',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
backgroundColor: 'var(--bg-primary)',
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'auto',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Table styles - use inset box-shadow for outer border (not clipped by overflow)
|
||||||
|
'.cm-table': {
|
||||||
|
borderCollapse: 'separate',
|
||||||
|
borderSpacing: '0',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
lineHeight: 'inherit',
|
||||||
|
backgroundColor: 'var(--cm-table-bg)',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
|
||||||
|
color: 'var(--text-primary) !important',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table th, .cm-table td': {
|
||||||
|
padding: '0 8px',
|
||||||
|
border: 'none',
|
||||||
|
color: 'inherit !important',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
lineHeight: 'inherit',
|
||||||
|
// Prevent text wrapping to maintain row height
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '300px',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data cells: left divider + bottom divider
|
||||||
|
'.cm-table td': {
|
||||||
|
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// First column data cells: only bottom divider
|
||||||
|
'.cm-table td:first-child': {
|
||||||
|
boxShadow: '0 1px 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Last row data cells: only left divider (no bottom)
|
||||||
|
'.cm-table tbody tr:last-child td': {
|
||||||
|
boxShadow: '-1px 0 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Last row first column: no dividers
|
||||||
|
'.cm-table tbody tr:last-child td:first-child': {
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table th': {
|
||||||
|
backgroundColor: 'var(--cm-table-header-bg)',
|
||||||
|
fontWeight: '600',
|
||||||
|
// Header cells: left divider + bottom divider
|
||||||
|
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table th:first-child': {
|
||||||
|
// First header cell: only bottom divider
|
||||||
|
boxShadow: '0 1px 0 var(--cm-table-border)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table tbody tr:hover': {
|
||||||
|
backgroundColor: 'var(--cm-table-row-hover)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Alignment classes - use higher specificity to override default
|
||||||
|
'.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': {
|
||||||
|
textAlign: 'left',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Inline elements in table cells
|
||||||
|
'.cm-table code': {
|
||||||
|
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'var(--voidraft-font-mono)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table a': {
|
||||||
|
color: 'var(--selection-text)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
|
||||||
|
'.cm-table a:hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hidden line content for table (text transparent but line preserved)
|
||||||
|
// Use high specificity to override rainbow brackets and other plugins
|
||||||
|
'.cm-line.cm-table-line-hidden': {
|
||||||
|
color: 'transparent !important',
|
||||||
|
caretColor: 'transparent',
|
||||||
|
},
|
||||||
|
'.cm-line.cm-table-line-hidden span': {
|
||||||
|
color: 'transparent !important',
|
||||||
|
},
|
||||||
|
// Override rainbow brackets in hidden table lines
|
||||||
|
'.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': {
|
||||||
|
color: 'transparent !important',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table extension.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Parses markdown tables using syntax tree
|
||||||
|
* - Renders tables as beautiful HTML tables
|
||||||
|
* - Table preserves line structure, overlays rendered table
|
||||||
|
* - Shows source when cursor is inside
|
||||||
|
*/
|
||||||
|
export const table = (): Extension => [
|
||||||
|
tablePlugin,
|
||||||
|
baseTheme
|
||||||
|
];
|
||||||
|
|
||||||
|
export default table;
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ export const defaultLightColors: ThemeColors = {
|
|||||||
dark: false,
|
dark: false,
|
||||||
|
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
backgroundSecondary: '#f4f7fb',
|
backgroundSecondary: '#f1faf1',
|
||||||
|
|
||||||
foreground: '#24292e',
|
foreground: '#24292e',
|
||||||
cursor: '#000000',
|
cursor: '#000000',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const config: ThemeColors = {
|
|||||||
dark: false,
|
dark: false,
|
||||||
|
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
backgroundSecondary: '#f1faf1',
|
backgroundSecondary: '##f4f7fb ',
|
||||||
|
|
||||||
foreground: '#444d56',
|
foreground: '#444d56',
|
||||||
cursor: '#044289',
|
cursor: '#044289',
|
||||||
|
|||||||
Reference in New Issue
Block a user