⚡ Optimized markdown preview performance
This commit is contained in:
@@ -119,7 +119,7 @@
|
|||||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||||
|
|
||||||
/* Markdown 代码块样式 - 亮色主题 */
|
/* Markdown 代码块样式 - 亮色主题 */
|
||||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
--cm-codeblock-bg: #f3f3f3;
|
||||||
--cm-codeblock-radius: 0.4rem;
|
--cm-codeblock-radius: 0.4rem;
|
||||||
|
|
||||||
/* Markdown 内联代码样式 */
|
/* Markdown 内联代码样式 */
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
|||||||
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
|
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
|
||||||
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
|
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
|
||||||
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
|
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
|
||||||
|
import {Emoji} from "@/views/editor/extensions/markdown/syntax/emoji";
|
||||||
import {javaLanguage} from "@codemirror/lang-java";
|
import {javaLanguage} from "@codemirror/lang-java";
|
||||||
import {phpLanguage} from "@codemirror/lang-php";
|
import {phpLanguage} from "@codemirror/lang-php";
|
||||||
import {cssLanguage} from "@codemirror/lang-css";
|
import {cssLanguage} from "@codemirror/lang-css";
|
||||||
@@ -118,7 +119,7 @@ export const LANGUAGES: LanguageInfo[] = [
|
|||||||
}),
|
}),
|
||||||
new LanguageInfo("md", "Markdown", markdown({
|
new LanguageInfo("md", "Markdown", markdown({
|
||||||
base: markdownLanguage,
|
base: markdownLanguage,
|
||||||
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table],
|
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table, Emoji],
|
||||||
completeHTMLTags: true,
|
completeHTMLTags: true,
|
||||||
pasteURLAsLink: true,
|
pasteURLAsLink: true,
|
||||||
htmlTagLanguage: html({
|
htmlTagLanguage: html({
|
||||||
|
|||||||
@@ -8,10 +8,36 @@ import {
|
|||||||
ViewUpdate,
|
ViewUpdate,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { Extension, Range } from '@codemirror/state';
|
import { Extension, Range } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
import * as runtime from "@wailsio/runtime";
|
import * as runtime from "@wailsio/runtime";
|
||||||
const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`;
|
const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`;
|
||||||
const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/gi;
|
const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/gi;
|
||||||
|
|
||||||
|
// Markdown link parent nodes that should be excluded from hyperlink decoration
|
||||||
|
const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a position is inside a markdown link syntax node.
|
||||||
|
* This prevents hyperlink decorations from conflicting with markdown rendering.
|
||||||
|
*/
|
||||||
|
function isInMarkdownLink(view: EditorView, from: number, to: number): boolean {
|
||||||
|
const tree = syntaxTree(view.state);
|
||||||
|
let inLink = false;
|
||||||
|
|
||||||
|
tree.iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: (node) => {
|
||||||
|
if (MARKDOWN_LINK_PARENTS.has(node.name)) {
|
||||||
|
inLink = true;
|
||||||
|
return false; // Stop iteration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return inLink;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HyperLinkState {
|
export interface HyperLinkState {
|
||||||
at: number;
|
at: number;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -53,6 +79,11 @@ function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptio
|
|||||||
const from = match.index;
|
const from = match.index;
|
||||||
const to = from + match[0].length;
|
const to = from + match[0].length;
|
||||||
|
|
||||||
|
// Skip URLs that are inside markdown link syntax
|
||||||
|
if (isInMarkdownLink(view, from, to)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const linkMark = Decoration.mark({
|
const linkMark = Decoration.mark({
|
||||||
class: 'cm-hyper-link-text'
|
class: 'cm-hyper-link-text'
|
||||||
});
|
});
|
||||||
@@ -80,7 +111,12 @@ const linkDecorator = (
|
|||||||
) =>
|
) =>
|
||||||
new MatchDecorator({
|
new MatchDecorator({
|
||||||
regexp: regexp || defaultRegexp,
|
regexp: regexp || defaultRegexp,
|
||||||
decorate: (add, from, to, match, _view) => {
|
decorate: (add, from, to, match, view) => {
|
||||||
|
// Skip URLs that are inside markdown link syntax
|
||||||
|
if (isInMarkdownLink(view, from, to)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = match[0];
|
const url = match[0];
|
||||||
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
||||||
if (matchData && matchData[url]) {
|
if (matchData && matchData[url]) {
|
||||||
|
|||||||
@@ -1,45 +1,19 @@
|
|||||||
import { Extension } from '@codemirror/state';
|
import { Extension } from '@codemirror/state';
|
||||||
import { blockquote } from './plugins/blockquote';
|
|
||||||
import { codeblock } from './plugins/code-block';
|
|
||||||
import { headings } from './plugins/heading';
|
|
||||||
import { hideMarks } from './plugins/hide-mark';
|
|
||||||
import { image } from './plugins/image';
|
import { image } from './plugins/image';
|
||||||
import { links } from './plugins/link';
|
|
||||||
import { lists } from './plugins/list';
|
|
||||||
import { headingSlugField } from './state/heading-slug';
|
import { headingSlugField } from './state/heading-slug';
|
||||||
import { emoji } from './plugins/emoji';
|
import {html} from './plugins/html';
|
||||||
import { horizontalRule } from './plugins/horizontal-rule';
|
import { render } from './plugins/render';
|
||||||
import { inlineCode } from './plugins/inline-code';
|
import { Theme } from './plugins/theme';
|
||||||
import { subscriptSuperscript } from './plugins/subscript-superscript';
|
|
||||||
import { highlight } from './plugins/highlight';
|
|
||||||
import { insert } from './plugins/insert';
|
|
||||||
import { math } from './plugins/math';
|
|
||||||
import { footnote } from './plugins/footnote';
|
|
||||||
import table from "./plugins/table";
|
|
||||||
import {htmlBlockExtension} from "./plugins/html";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* markdown extensions
|
* Markdown extensions.
|
||||||
*/
|
*/
|
||||||
export const markdownExtensions: Extension = [
|
export const markdownExtensions: Extension = [
|
||||||
headingSlugField,
|
headingSlugField,
|
||||||
blockquote(),
|
render(),
|
||||||
codeblock(),
|
Theme,
|
||||||
headings(),
|
|
||||||
hideMarks(),
|
|
||||||
lists(),
|
|
||||||
links(),
|
|
||||||
image(),
|
image(),
|
||||||
emoji(),
|
html()
|
||||||
horizontalRule(),
|
|
||||||
inlineCode(),
|
|
||||||
subscriptSuperscript(),
|
|
||||||
highlight(),
|
|
||||||
insert(),
|
|
||||||
math(),
|
|
||||||
footnote(),
|
|
||||||
table(),
|
|
||||||
htmlBlockExtension
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default markdownExtensions;
|
export default markdownExtensions;
|
||||||
|
|||||||
@@ -1,173 +1,56 @@
|
|||||||
import {
|
/**
|
||||||
Decoration,
|
* Blockquote handler and theme.
|
||||||
DecorationSet,
|
*/
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { RangeSetBuilder } from '@codemirror/state';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
|
||||||
|
|
||||||
/** Pre-computed line decoration */
|
import { Decoration, EditorView } from '@codemirror/view';
|
||||||
const LINE_DECO = Decoration.line({ class: 'cm-blockquote' });
|
import { invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
import { SyntaxNode } from '@lezer/common';
|
||||||
|
import { BuildContext } from './types';
|
||||||
|
|
||||||
|
const DECO_BLOCKQUOTE_LINE = Decoration.line({ class: 'cm-blockquote' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blockquote plugin.
|
* Handle Blockquote node.
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Decorates blockquote with left border
|
|
||||||
* - Hides quote marks (>) when cursor is outside
|
|
||||||
* - Supports nested blockquotes
|
|
||||||
*/
|
*/
|
||||||
export function blockquote() {
|
export function handleBlockquote(
|
||||||
return [blockQuotePlugin, baseTheme];
|
ctx: BuildContext,
|
||||||
}
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): boolean {
|
||||||
|
if (ctx.seen.has(nf)) return false;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return false;
|
||||||
|
|
||||||
/**
|
const startLine = ctx.view.state.doc.lineAt(nf).number;
|
||||||
* Collect blockquote ranges in visible viewport.
|
const endLine = ctx.view.state.doc.lineAt(nt).number;
|
||||||
*/
|
for (let i = startLine; i <= endLine; i++) {
|
||||||
function collectBlockquoteRanges(view: EditorView): RangeTuple[] {
|
if (!ctx.processedLines.has(i)) {
|
||||||
const ranges: RangeTuple[] = [];
|
ctx.processedLines.add(i);
|
||||||
const seen = new Set<number>();
|
ctx.items.push({ from: ctx.view.state.doc.line(i).from, to: ctx.view.state.doc.line(i).from, deco: DECO_BLOCKQUOTE_LINE });
|
||||||
|
|
||||||
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);
|
|
||||||
ranges.push([node.from, node.to]);
|
|
||||||
return false; // Don't recurse into nested
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return ranges;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cursor's blockquote position (-1 if not in any).
|
|
||||||
*/
|
|
||||||
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 endLine = view.state.doc.lineAt(node.to).number;
|
|
||||||
|
|
||||||
for (let i = startLine; i <= endLine; i++) {
|
|
||||||
if (!processedLines.has(i)) {
|
|
||||||
processedLines.add(i);
|
|
||||||
const line = view.state.doc.line(i);
|
|
||||||
items.push({ pos: line.from, deco: LINE_DECO });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide quote marks
|
|
||||||
const cursor = node.node.cursor();
|
|
||||||
cursor.iterate((child) => {
|
|
||||||
if (child.type.name === 'QuoteMark') {
|
|
||||||
items.push({ pos: child.from, endPos: child.to, deco: invisibleDecoration });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
// Use TreeCursor to traverse all descendant QuoteMarks
|
||||||
|
// getChildren() only returns direct children, but QuoteMarks may be nested
|
||||||
|
// deeper in the syntax tree (e.g., in nested blockquotes for empty lines)
|
||||||
|
// cursor.next() is the official Lezer API for depth-first tree traversal
|
||||||
|
const cursor = node.cursor();
|
||||||
|
while (cursor.next() && cursor.to <= nt) {
|
||||||
|
if (cursor.name === 'QuoteMark') {
|
||||||
|
ctx.items.push({ from: cursor.from, to: cursor.to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blockquote plugin with optimized updates.
|
* Theme for blockquotes.
|
||||||
*/
|
*/
|
||||||
class BlockQuotePlugin {
|
export const blockquoteTheme = EditorView.baseTheme({
|
||||||
decorations: DecorationSet;
|
|
||||||
private blockRanges: RangeTuple[] = [];
|
|
||||||
private cursorBlockPos = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.blockRanges = collectBlockquoteRanges(view);
|
|
||||||
this.cursorBlockPos = getCursorBlockquotePos(view, this.blockRanges);
|
|
||||||
this.decorations = buildDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base theme for blockquotes.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-blockquote': {
|
'.cm-blockquote': {
|
||||||
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
|
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
|
||||||
color: 'var(--cm-blockquote-color, #666)'
|
color: 'var(--cm-blockquote-color, #666)'
|
||||||
|
|||||||
@@ -1,331 +1,107 @@
|
|||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
/**
|
||||||
import {
|
* Code block handler and theme.
|
||||||
ViewPlugin,
|
*/
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate,
|
|
||||||
WidgetType
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
|
||||||
|
|
||||||
/** Code block node types in syntax tree */
|
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||||
const CODE_BLOCK_TYPES = new Set(['FencedCode', 'CodeBlock']);
|
import { invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
import { SyntaxNode } from '@lezer/common';
|
||||||
|
import { BuildContext } from './types';
|
||||||
|
|
||||||
|
const DECO_CODEBLOCK_LINE = Decoration.line({ class: 'cm-codeblock' });
|
||||||
|
const DECO_CODEBLOCK_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' });
|
||||||
|
const DECO_CODEBLOCK_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' });
|
||||||
|
const DECO_CODEBLOCK_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' });
|
||||||
|
|
||||||
/** 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>`;
|
||||||
|
|
||||||
/** Pre-computed line decoration classes */
|
|
||||||
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;
|
|
||||||
to: number;
|
|
||||||
language: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Code block extension with language label and copy button.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Adds background styling to code blocks
|
|
||||||
* - Shows language label + copy button when language is specified
|
|
||||||
* - Hides markers when cursor is outside block
|
|
||||||
* - Optimized with viewport-only rendering and minimal rebuilds
|
|
||||||
*/
|
|
||||||
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget for displaying language label and copy button.
|
|
||||||
* Content is computed lazily on copy action.
|
|
||||||
*/
|
|
||||||
class CodeBlockInfoWidget extends WidgetType {
|
class CodeBlockInfoWidget extends WidgetType {
|
||||||
constructor(readonly meta: CodeBlockMeta) {
|
constructor(readonly from: number, readonly to: number, readonly language: string | null) { super(); }
|
||||||
super();
|
eq(other: CodeBlockInfoWidget) { return other.from === this.from && other.language === this.language; }
|
||||||
}
|
|
||||||
|
|
||||||
eq(other: CodeBlockInfoWidget): boolean {
|
|
||||||
return other.meta.from === this.meta.from &&
|
|
||||||
other.meta.language === this.meta.language;
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(view: EditorView): 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';
|
||||||
|
if (this.language) {
|
||||||
if (this.meta.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.meta.language;
|
lang.textContent = this.language;
|
||||||
container.append(lang);
|
container.append(lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'cm-code-block-copy-btn';
|
btn.className = 'cm-code-block-copy-btn';
|
||||||
btn.title = 'Copy';
|
btn.title = 'Copy';
|
||||||
btn.innerHTML = ICON_COPY;
|
btn.innerHTML = ICON_COPY;
|
||||||
|
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.copyContent(view, btn);
|
const text = view.state.doc.sliceString(this.from, this.to);
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
||||||
|
if (content) {
|
||||||
|
navigator.clipboard.writeText(content).then(() => {
|
||||||
|
btn.innerHTML = ICON_CHECK;
|
||||||
|
setTimeout(() => { btn.innerHTML = ICON_COPY; }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
|
||||||
btn.addEventListener('mousedown', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
container.append(btn);
|
container.append(btn);
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
ignoreEvent() { return true; }
|
||||||
/** Lazy content extraction and copy */
|
|
||||||
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;
|
|
||||||
|
|
||||||
navigator.clipboard.writeText(content).then(() => {
|
|
||||||
btn.innerHTML = ICON_CHECK;
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.innerHTML = ICON_COPY;
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parsed code block info from single tree traversal */
|
|
||||||
interface ParsedBlock {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
language: string | null;
|
|
||||||
marks: RangeTuple[]; // CodeMark and CodeInfo positions to hide
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a code block node in a single traversal.
|
* Handle FencedCode / CodeBlock node.
|
||||||
* Extracts language and mark positions together.
|
|
||||||
*/
|
*/
|
||||||
function parseCodeBlock(view: EditorView, nodeFrom: number, nodeTo: number, node: any): ParsedBlock {
|
export function handleCodeBlock(
|
||||||
let language: string | null = null;
|
ctx: BuildContext,
|
||||||
const marks: RangeTuple[] = [];
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
|
||||||
node.toTree().iterate({
|
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||||
enter: ({ type, from, to }) => {
|
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||||
const absFrom = nodeFrom + from;
|
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||||
const absTo = nodeFrom + to;
|
const line = ctx.view.state.doc.line(num);
|
||||||
|
let deco = DECO_CODEBLOCK_LINE;
|
||||||
if (type.name === 'CodeInfo') {
|
if (startLine.number === endLine.number) deco = DECO_CODEBLOCK_SINGLE;
|
||||||
language = view.state.doc.sliceString(absFrom, absTo).trim();
|
else if (num === startLine.number) deco = DECO_CODEBLOCK_BEGIN;
|
||||||
marks.push([absFrom, absTo]);
|
else if (num === endLine.number) deco = DECO_CODEBLOCK_END;
|
||||||
} else if (type.name === 'CodeMark') {
|
ctx.items.push({ from: line.from, to: line.from, deco });
|
||||||
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;
|
if (!inCursor) {
|
||||||
}
|
const codeInfo = node.getChild('CodeInfo');
|
||||||
|
const codeMarks = node.getChildren('CodeMark');
|
||||||
/**
|
const language = codeInfo ? ctx.view.state.doc.sliceString(codeInfo.from, codeInfo.to).trim() : null;
|
||||||
* Collect all code block ranges in visible viewport.
|
ctx.items.push({ from: startLine.to, to: startLine.to, deco: Decoration.widget({ widget: new CodeBlockInfoWidget(nf, nt, language), side: 1 }), priority: 1 });
|
||||||
*/
|
if (codeInfo) ctx.items.push({ from: codeInfo.from, to: codeInfo.to, deco: invisibleDecoration });
|
||||||
function collectCodeBlockRanges(view: EditorView): RangeTuple[] {
|
for (const mark of codeMarks) ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
|
||||||
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.
|
|
||||||
* Uses RangeSetBuilder for efficient sorted construction.
|
|
||||||
*/
|
|
||||||
function buildDecorations(view: EditorView): DecorationSet {
|
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
|
||||||
const items: { pos: number; endPos?: number; deco: Decoration; isWidget?: boolean; isReplace?: boolean }[] = [];
|
|
||||||
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 (!CODE_BLOCK_TYPES.has(type.name)) return;
|
|
||||||
if (seen.has(nodeFrom)) return;
|
|
||||||
seen.add(nodeFrom);
|
|
||||||
|
|
||||||
// Check if cursor is in this block
|
|
||||||
const inBlock = checkRangeOverlap(
|
|
||||||
[nodeFrom, nodeTo],
|
|
||||||
[view.state.selection.main.from, view.state.selection.main.to]
|
|
||||||
);
|
|
||||||
if (inBlock) return;
|
|
||||||
|
|
||||||
// Parse block in single traversal
|
|
||||||
const block = parseCodeBlock(view, nodeFrom, nodeTo, node);
|
|
||||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
|
||||||
const endLine = view.state.doc.lineAt(nodeTo);
|
|
||||||
|
|
||||||
// Add line decorations
|
|
||||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
|
||||||
const line = view.state.doc.line(num);
|
|
||||||
let deco: Decoration;
|
|
||||||
|
|
||||||
if (startLine.number === endLine.number) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({ pos: line.from, deco });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add info widget
|
|
||||||
const meta: CodeBlockMeta = {
|
|
||||||
from: nodeFrom,
|
|
||||||
to: nodeTo,
|
|
||||||
language: block.language
|
|
||||||
};
|
|
||||||
items.push({
|
|
||||||
pos: startLine.to,
|
|
||||||
deco: Decoration.widget({
|
|
||||||
widget: new CodeBlockInfoWidget(meta),
|
|
||||||
side: 1
|
|
||||||
}),
|
|
||||||
isWidget: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide marks
|
|
||||||
for (const [mFrom, mTo] of block.marks) {
|
|
||||||
items.push({ pos: mFrom, endPos: mTo, deco: invisibleDecoration, isReplace: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 builder.finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Code block plugin with optimized update detection.
|
|
||||||
*/
|
|
||||||
class CodeBlockPluginClass {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private blockRanges: RangeTuple[] = [];
|
|
||||||
private cursorBlockPos = -1; // Which block the cursor is in (-1 = none)
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.blockRanges = collectCodeBlockRanges(view);
|
|
||||||
this.cursorBlockPos = getCursorBlockPosition(view, this.blockRanges);
|
|
||||||
this.decorations = buildDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate): void {
|
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
|
||||||
|
|
||||||
// Always rebuild on doc or viewport change
|
|
||||||
if (docChanged || viewportChanged) {
|
|
||||||
this.blockRanges = collectCodeBlockRanges(update.view);
|
|
||||||
this.cursorBlockPos = getCursorBlockPosition(update.view, this.blockRanges);
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For selection changes, only rebuild if cursor moves to a different block
|
|
||||||
if (selectionSet) {
|
|
||||||
const newBlockPos = getCursorBlockPosition(update.view, this.blockRanges);
|
|
||||||
|
|
||||||
if (newBlockPos !== this.cursorBlockPos) {
|
|
||||||
this.cursorBlockPos = newBlockPos;
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for code blocks.
|
* Theme for code blocks.
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
export const codeBlockTheme = EditorView.baseTheme({
|
||||||
'.cm-codeblock': {
|
'.cm-codeblock': {
|
||||||
backgroundColor: 'var(--cm-codeblock-bg)',
|
backgroundColor: 'var(--cm-codeblock-bg)',
|
||||||
fontFamily: 'inherit',
|
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'
|
||||||
},
|
},
|
||||||
'.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)'
|
||||||
},
|
},
|
||||||
'.cm-code-block-info': {
|
'.cm-code-block-info': {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -339,9 +115,7 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
opacity: '0.5',
|
opacity: '0.5',
|
||||||
transition: 'opacity 0.15s'
|
transition: 'opacity 0.15s'
|
||||||
},
|
},
|
||||||
'.cm-code-block-info:hover': {
|
'.cm-code-block-info:hover': { opacity: '1' },
|
||||||
opacity: '1'
|
|
||||||
},
|
|
||||||
'.cm-code-block-lang': {
|
'.cm-code-block-lang': {
|
||||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||||
textTransform: 'lowercase',
|
textTransform: 'lowercase',
|
||||||
@@ -360,12 +134,6 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
opacity: '0.7',
|
opacity: '0.7',
|
||||||
transition: 'opacity 0.15s, background 0.15s'
|
transition: 'opacity 0.15s, background 0.15s'
|
||||||
},
|
},
|
||||||
'.cm-code-block-copy-btn:hover': {
|
'.cm-code-block-copy-btn:hover': { opacity: '1', background: 'rgba(128, 128, 128, 0.2)' },
|
||||||
opacity: '1',
|
'.cm-code-block-copy-btn svg': { width: '1em', height: '1em' }
|
||||||
background: 'rgba(128, 128, 128, 0.2)'
|
|
||||||
},
|
|
||||||
'.cm-code-block-copy-btn svg': {
|
|
||||||
width: '1em',
|
|
||||||
height: '1em'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,44 +1,16 @@
|
|||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
/**
|
||||||
import {
|
* Emoji handler and theme.
|
||||||
ViewPlugin,
|
*/
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||||
EditorView,
|
import { RangeTuple } from '../util';
|
||||||
ViewUpdate,
|
import { SyntaxNode } from '@lezer/common';
|
||||||
WidgetType
|
import { BuildContext } from './types';
|
||||||
} from '@codemirror/view';
|
|
||||||
import { checkRangeOverlap, RangeTuple } from '../util';
|
|
||||||
import { emojies } from '@/common/constant/emojies';
|
import { emojies } from '@/common/constant/emojies';
|
||||||
|
|
||||||
/**
|
|
||||||
* Emoji plugin that converts :emoji_name: to actual emoji characters.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Detects emoji patterns like :smile:, :heart:, etc.
|
|
||||||
* - Replaces them with actual emoji characters
|
|
||||||
* - Shows the original text when cursor is nearby
|
|
||||||
* - Optimized with cached matches and minimal rebuilds
|
|
||||||
*/
|
|
||||||
export const emoji = (): Extension => [emojiPlugin, baseTheme];
|
|
||||||
|
|
||||||
/** Non-global regex for matchAll (more efficient than global with lastIndex reset) */
|
|
||||||
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emoji widget with optimized rendering.
|
|
||||||
*/
|
|
||||||
class EmojiWidget extends WidgetType {
|
class EmojiWidget extends WidgetType {
|
||||||
constructor(
|
constructor(readonly emoji: string, readonly name: string) { super(); }
|
||||||
readonly emoji: string,
|
eq(other: EmojiWidget) { return other.emoji === this.emoji; }
|
||||||
readonly name: string
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(other: EmojiWidget): boolean {
|
|
||||||
return other.emoji === this.emoji;
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-emoji';
|
span.className = 'cm-emoji';
|
||||||
@@ -49,148 +21,37 @@ class EmojiWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached emoji match.
|
* Handle Emoji node (:emoji:).
|
||||||
*/
|
*/
|
||||||
interface EmojiMatch {
|
export function handleEmoji(
|
||||||
from: number;
|
ctx: BuildContext,
|
||||||
to: number;
|
nf: number,
|
||||||
name: string;
|
nt: number,
|
||||||
emoji: string;
|
node: SyntaxNode,
|
||||||
}
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
/**
|
const nameNode = node.getChild('EmojiName');
|
||||||
* Find all emoji matches in visible ranges.
|
if (!nameNode) return;
|
||||||
*/
|
const name = ctx.view.state.sliceDoc(nameNode.from, nameNode.to).toLowerCase();
|
||||||
function findAllEmojiMatches(view: EditorView): EmojiMatch[] {
|
const emojiChar = emojies[name];
|
||||||
const matches: EmojiMatch[] = [];
|
if (emojiChar) {
|
||||||
const doc = view.state.doc;
|
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new EmojiWidget(emojiChar, name) }) });
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
|
||||||
const text = doc.sliceString(from, to);
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
EMOJI_REGEX.lastIndex = 0;
|
|
||||||
while ((match = EMOJI_REGEX.exec(text)) !== null) {
|
|
||||||
const name = match[1].toLowerCase();
|
|
||||||
const emojiChar = emojies[name];
|
|
||||||
|
|
||||||
if (emojiChar) {
|
|
||||||
matches.push({
|
|
||||||
from: from + match.index,
|
|
||||||
to: from + match.index + match[0].length,
|
|
||||||
name,
|
|
||||||
emoji: emojiChar
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get which emoji the cursor is in (-1 if none).
|
|
||||||
*/
|
|
||||||
function getCursorEmojiIndex(matches: EmojiMatch[], selFrom: number, selTo: number): number {
|
|
||||||
const selRange: RangeTuple = [selFrom, selTo];
|
|
||||||
|
|
||||||
for (let i = 0; i < matches.length; i++) {
|
|
||||||
if (checkRangeOverlap([matches[i].from, matches[i].to], selRange)) {
|
|
||||||
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) {
|
|
||||||
// Skip if cursor overlaps this emoji
|
|
||||||
if (checkRangeOverlap([match.from, match.to], selRange)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.add(
|
|
||||||
match.from,
|
|
||||||
match.to,
|
|
||||||
Decoration.replace({
|
|
||||||
widget: new EmojiWidget(match.emoji, match.name)
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emoji plugin with cached matches and optimized updates.
|
|
||||||
*/
|
|
||||||
class EmojiPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private matches: EmojiMatch[] = [];
|
|
||||||
private cursorEmojiIdx = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.matches = findAllEmojiMatches(view);
|
|
||||||
const { from, to } = view.state.selection.main;
|
|
||||||
this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to);
|
|
||||||
this.decorations = buildDecorations(this.matches, from, to);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
|
||||||
|
|
||||||
// Rebuild matches on doc or viewport change
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For selection changes, only rebuild if cursor enters/leaves an emoji
|
|
||||||
if (selectionSet) {
|
|
||||||
const { from, to } = update.state.selection.main;
|
|
||||||
const newIdx = getCursorEmojiIndex(this.matches, from, to);
|
|
||||||
|
|
||||||
if (newIdx !== this.cursorEmojiIdx) {
|
|
||||||
this.cursorEmojiIdx = newIdx;
|
|
||||||
this.decorations = buildDecorations(this.matches, from, to);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for emoji.
|
* Theme for emoji.
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
export const emojiTheme = EditorView.baseTheme({
|
||||||
'.cm-emoji': {
|
'.cm-emoji': {
|
||||||
verticalAlign: 'middle',
|
cursor: 'default',
|
||||||
cursor: 'default'
|
fontSize: 'inherit',
|
||||||
|
lineHeight: 'inherit'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all available emoji names.
|
|
||||||
*/
|
|
||||||
export function getEmojiNames(): string[] {
|
|
||||||
return Object.keys(emojies);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get emoji by name.
|
|
||||||
*/
|
|
||||||
export function getEmoji(name: string): string | undefined {
|
|
||||||
return emojies[name.toLowerCase()];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,621 +1,152 @@
|
|||||||
/**
|
/**
|
||||||
* Footnote plugin for CodeMirror.
|
* Footnote handlers and theme.
|
||||||
*
|
* Handles: FootnoteDefinition, FootnoteReference, InlineFootnote
|
||||||
* Features:
|
|
||||||
* - Renders footnote references as superscript numbers/labels
|
|
||||||
* - Renders inline footnotes as superscript numbers with embedded content
|
|
||||||
* - Shows footnote content on hover (tooltip)
|
|
||||||
* - Click to jump between reference and definition
|
|
||||||
* - Hides syntax marks when cursor is outside
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Extension, RangeSetBuilder, EditorState } from '@codemirror/state';
|
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { invisibleDecoration, RangeTuple } from '../util';
|
||||||
import {
|
import { SyntaxNode } from '@lezer/common';
|
||||||
ViewPlugin,
|
import { BuildContext } from './types';
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate,
|
|
||||||
WidgetType,
|
|
||||||
hoverTooltip,
|
|
||||||
Tooltip,
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
|
||||||
|
|
||||||
// ============================================================================
|
/** Extended context for footnotes */
|
||||||
// Types
|
export interface FootnoteContext extends BuildContext {
|
||||||
// ============================================================================
|
definitionIds: Set<string>;
|
||||||
|
pendingRefs: { from: number; to: number; id: string; index: number }[];
|
||||||
interface FootnoteDefinition {
|
pendingInlines: { from: number; to: number; index: number }[];
|
||||||
id: string;
|
seenIds: Map<string, number>;
|
||||||
content: string;
|
inlineFootnoteIdx: number;
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FootnoteReference {
|
|
||||||
id: string;
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InlineFootnoteInfo {
|
|
||||||
content: string;
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collected footnote data with O(1) lookup indexes.
|
|
||||||
*/
|
|
||||||
interface FootnoteData {
|
|
||||||
definitions: Map<string, FootnoteDefinition>;
|
|
||||||
references: FootnoteReference[];
|
|
||||||
inlineFootnotes: InlineFootnoteInfo[];
|
|
||||||
referencesByPos: Map<number, FootnoteReference>;
|
|
||||||
inlineByPos: Map<number, InlineFootnoteInfo>;
|
|
||||||
definitionByPos: Map<number, FootnoteDefinition>; // For position-based lookup
|
|
||||||
firstRefById: Map<string, FootnoteReference>;
|
|
||||||
// All footnote ranges for cursor detection
|
|
||||||
allRanges: RangeTuple[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Footnote Collection (cached via closure)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
let cachedData: FootnoteData | null = null;
|
|
||||||
let cachedDocLength = -1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect all footnote data from the document.
|
|
||||||
*/
|
|
||||||
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 references: FootnoteReference[] = [];
|
|
||||||
const inlineFootnotes: InlineFootnoteInfo[] = [];
|
|
||||||
const referencesByPos = new Map<number, FootnoteReference>();
|
|
||||||
const inlineByPos = new Map<number, InlineFootnoteInfo>();
|
|
||||||
const definitionByPos = new Map<number, FootnoteDefinition>();
|
|
||||||
const firstRefById = new Map<string, FootnoteReference>();
|
|
||||||
const allRanges: RangeTuple[] = [];
|
|
||||||
const seenIds = new Map<string, number>();
|
|
||||||
let inlineIndex = 0;
|
|
||||||
|
|
||||||
syntaxTree(state).iterate({
|
|
||||||
enter: ({ type, from, to, node }) => {
|
|
||||||
if (type.name === 'FootnoteDefinition') {
|
|
||||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
|
||||||
const contentNode = node.getChild('FootnoteDefinitionContent');
|
|
||||||
|
|
||||||
if (labelNode) {
|
|
||||||
const id = state.sliceDoc(labelNode.from, labelNode.to);
|
|
||||||
const content = contentNode
|
|
||||||
? state.sliceDoc(contentNode.from, contentNode.to).trim()
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const def: FootnoteDefinition = { id, content, from, to };
|
|
||||||
definitions.set(id, def);
|
|
||||||
definitionByPos.set(from, def);
|
|
||||||
allRanges.push([from, to]);
|
|
||||||
}
|
|
||||||
} else if (type.name === 'FootnoteReference') {
|
|
||||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
|
||||||
|
|
||||||
if (labelNode) {
|
|
||||||
const id = state.sliceDoc(labelNode.from, labelNode.to);
|
|
||||||
|
|
||||||
if (!seenIds.has(id)) {
|
|
||||||
seenIds.set(id, seenIds.size + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ref: FootnoteReference = {
|
|
||||||
id,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
index: seenIds.get(id)!,
|
|
||||||
};
|
|
||||||
|
|
||||||
references.push(ref);
|
|
||||||
referencesByPos.set(from, ref);
|
|
||||||
allRanges.push([from, to]);
|
|
||||||
|
|
||||||
if (!firstRefById.has(id)) {
|
|
||||||
firstRefById.set(id, ref);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (type.name === 'InlineFootnote') {
|
|
||||||
const contentNode = node.getChild('InlineFootnoteContent');
|
|
||||||
|
|
||||||
if (contentNode) {
|
|
||||||
const content = state.sliceDoc(contentNode.from, contentNode.to).trim();
|
|
||||||
inlineIndex++;
|
|
||||||
|
|
||||||
const info: InlineFootnoteInfo = {
|
|
||||||
content,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
index: inlineIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
inlineFootnotes.push(info);
|
|
||||||
inlineByPos.set(from, info);
|
|
||||||
allRanges.push([from, to]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
cachedData = {
|
|
||||||
definitions,
|
|
||||||
references,
|
|
||||||
inlineFootnotes,
|
|
||||||
referencesByPos,
|
|
||||||
inlineByPos,
|
|
||||||
definitionByPos,
|
|
||||||
firstRefById,
|
|
||||||
allRanges,
|
|
||||||
};
|
|
||||||
cachedDocLength = state.doc.length;
|
|
||||||
|
|
||||||
return cachedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Widgets
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
class FootnoteRefWidget extends WidgetType {
|
class FootnoteRefWidget extends WidgetType {
|
||||||
constructor(
|
constructor(readonly index: number, readonly hasDefinition: boolean) { super(); }
|
||||||
readonly id: string,
|
eq(other: FootnoteRefWidget) { return this.index === other.index && this.hasDefinition === other.hasDefinition; }
|
||||||
readonly index: number,
|
|
||||||
readonly hasDefinition: boolean
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-footnote-ref';
|
span.className = 'cm-footnote-ref';
|
||||||
span.textContent = `[${this.index}]`;
|
span.textContent = `[${this.index}]`;
|
||||||
span.dataset.footnoteId = this.id;
|
if (!this.hasDefinition) span.classList.add('cm-footnote-ref-undefined');
|
||||||
|
|
||||||
if (!this.hasDefinition) {
|
|
||||||
span.classList.add('cm-footnote-ref-undefined');
|
|
||||||
}
|
|
||||||
|
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
ignoreEvent() { return false; }
|
||||||
eq(other: FootnoteRefWidget): boolean {
|
|
||||||
return this.id === other.id && this.index === other.index;
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class InlineFootnoteWidget extends WidgetType {
|
class InlineFootnoteWidget extends WidgetType {
|
||||||
constructor(
|
constructor(readonly index: number) { super(); }
|
||||||
readonly content: string,
|
eq(other: InlineFootnoteWidget) { return this.index === other.index; }
|
||||||
readonly index: number
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-inline-footnote-ref';
|
span.className = 'cm-inline-footnote-ref';
|
||||||
span.textContent = `[${this.index}]`;
|
span.textContent = `[${this.index}]`;
|
||||||
span.dataset.footnoteContent = this.content;
|
|
||||||
span.dataset.footnoteIndex = String(this.index);
|
|
||||||
|
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
ignoreEvent() { return false; }
|
||||||
eq(other: InlineFootnoteWidget): boolean {
|
|
||||||
return this.content === other.content && this.index === other.index;
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FootnoteDefLabelWidget extends WidgetType {
|
class FootnoteDefLabelWidget extends WidgetType {
|
||||||
constructor(readonly id: string) {
|
constructor(readonly id: string) { super(); }
|
||||||
super();
|
eq(other: FootnoteDefLabelWidget) { return this.id === other.id; }
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-footnote-def-label';
|
span.className = 'cm-footnote-def-label';
|
||||||
span.textContent = `[${this.id}]`;
|
span.textContent = `[${this.id}]`;
|
||||||
span.dataset.footnoteId = this.id;
|
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
ignoreEvent() { return false; }
|
||||||
eq(other: FootnoteDefLabelWidget): boolean {
|
|
||||||
return this.id === other.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Cursor Detection
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get which footnote range the cursor is in (returns start position, -1 if none).
|
* Handle FootnoteDefinition node.
|
||||||
*/
|
*/
|
||||||
function getCursorFootnotePos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
export function handleFootnoteDefinition(
|
||||||
const selRange: RangeTuple = [selFrom, selTo];
|
ctx: FootnoteContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
for (const range of ranges) {
|
const marks = node.getChildren('FootnoteDefinitionMark');
|
||||||
if (checkRangeOverlap(range, selRange)) {
|
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||||
return range[0];
|
if (marks.length >= 2 && labelNode) {
|
||||||
}
|
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
|
ctx.definitionIds.add(id);
|
||||||
|
ctx.items.push({ from: marks[0].from, to: marks[1].to, deco: invisibleDecoration });
|
||||||
|
ctx.items.push({ from: marks[1].to, to: marks[1].to, deco: Decoration.widget({ widget: new FootnoteDefLabelWidget(id), side: 1 }), priority: 1 });
|
||||||
}
|
}
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Decorations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build decorations using RangeSetBuilder.
|
* Handle FootnoteReference node.
|
||||||
*/
|
*/
|
||||||
function buildDecorations(view: EditorView, data: FootnoteData): DecorationSet {
|
export function handleFootnoteReference(
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
ctx: FootnoteContext,
|
||||||
const items: { pos: number; endPos?: number; deco: Decoration; priority?: number }[] = [];
|
nf: number,
|
||||||
const { from: selFrom, to: selTo } = view.state.selection.main;
|
nt: number,
|
||||||
const selRange: RangeTuple = [selFrom, selTo];
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||||
syntaxTree(view.state).iterate({
|
const marks = node.getChildren('FootnoteReferenceMark');
|
||||||
from,
|
if (labelNode && marks.length >= 2) {
|
||||||
to,
|
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
if (!ctx.seenIds.has(id)) ctx.seenIds.set(id, ctx.seenIds.size + 1);
|
||||||
const inCursor = checkRangeOverlap([nodeFrom, nodeTo], selRange);
|
ctx.pendingRefs.push({ from: nf, to: nt, id, index: ctx.seenIds.get(id)! });
|
||||||
|
|
||||||
// Footnote References
|
|
||||||
if (type.name === 'FootnoteReference') {
|
|
||||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
|
||||||
const marks = node.getChildren('FootnoteReferenceMark');
|
|
||||||
|
|
||||||
if (!labelNode || marks.length < 2) return;
|
|
||||||
|
|
||||||
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
|
||||||
const ref = data.referencesByPos.get(nodeFrom);
|
|
||||||
|
|
||||||
if (!inCursor && ref && ref.id === id) {
|
|
||||||
items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration });
|
|
||||||
items.push({
|
|
||||||
pos: nodeTo,
|
|
||||||
deco: Decoration.widget({
|
|
||||||
widget: new FootnoteRefWidget(id, ref.index, data.definitions.has(id)),
|
|
||||||
side: 1,
|
|
||||||
}),
|
|
||||||
priority: 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footnote Definitions
|
|
||||||
if (type.name === 'FootnoteDefinition') {
|
|
||||||
const marks = node.getChildren('FootnoteDefinitionMark');
|
|
||||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
|
||||||
|
|
||||||
if (!inCursor && marks.length >= 2 && labelNode) {
|
|
||||||
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
|
||||||
|
|
||||||
items.push({ pos: marks[0].from, endPos: marks[1].to, deco: invisibleDecoration });
|
|
||||||
items.push({
|
|
||||||
pos: marks[1].to,
|
|
||||||
deco: Decoration.widget({
|
|
||||||
widget: new FootnoteDefLabelWidget(id),
|
|
||||||
side: 1,
|
|
||||||
}),
|
|
||||||
priority: 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline Footnotes
|
|
||||||
if (type.name === 'InlineFootnote') {
|
|
||||||
const contentNode = node.getChild('InlineFootnoteContent');
|
|
||||||
const marks = node.getChildren('InlineFootnoteMark');
|
|
||||||
|
|
||||||
if (!contentNode || marks.length < 2) return;
|
|
||||||
|
|
||||||
const inlineNote = data.inlineByPos.get(nodeFrom);
|
|
||||||
|
|
||||||
if (!inCursor && inlineNote) {
|
|
||||||
items.push({ pos: nodeFrom, endPos: nodeTo, deco: invisibleDecoration });
|
|
||||||
items.push({
|
|
||||||
pos: nodeTo,
|
|
||||||
deco: Decoration.widget({
|
|
||||||
widget: new InlineFootnoteWidget(inlineNote.content, inlineNote.index),
|
|
||||||
side: 1,
|
|
||||||
}),
|
|
||||||
priority: 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 FootnotePlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private data: FootnoteData;
|
|
||||||
private cursorFootnotePos = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.data = collectFootnotes(view.state);
|
|
||||||
const { from, to } = view.state.selection.main;
|
|
||||||
this.cursorFootnotePos = getCursorFootnotePos(this.data.allRanges, from, to);
|
|
||||||
this.decorations = buildDecorations(view, this.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewportChanged) {
|
|
||||||
this.decorations = buildDecorations(update.view, this.data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
|
/**
|
||||||
decorations: (v) => v.decorations,
|
* Handle InlineFootnote node.
|
||||||
});
|
*/
|
||||||
|
export function handleInlineFootnote(
|
||||||
|
ctx: FootnoteContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
// ============================================================================
|
const contentNode = node.getChild('InlineFootnoteContent');
|
||||||
// Hover Tooltip
|
const marks = node.getChildren('InlineFootnoteMark');
|
||||||
// ============================================================================
|
if (contentNode && marks.length >= 2) {
|
||||||
|
ctx.inlineFootnoteIdx++;
|
||||||
const footnoteHoverTooltip = hoverTooltip(
|
ctx.pendingInlines.push({ from: nf, to: nt, index: ctx.inlineFootnoteIdx });
|
||||||
(view, pos): Tooltip | null => {
|
}
|
||||||
const data = collectFootnotes(view.state);
|
|
||||||
|
|
||||||
// Check widget elements first
|
|
||||||
const coords = view.coordsAtPos(pos);
|
|
||||||
if (coords) {
|
|
||||||
const target = document.elementFromPoint(coords.left, coords.top) as HTMLElement | null;
|
|
||||||
|
|
||||||
if (target?.classList.contains('cm-footnote-ref')) {
|
|
||||||
const id = target.dataset.footnoteId;
|
|
||||||
if (id) {
|
|
||||||
const def = data.definitions.get(id);
|
|
||||||
if (def) {
|
|
||||||
return {
|
|
||||||
pos,
|
|
||||||
above: true,
|
|
||||||
arrow: true,
|
|
||||||
create: () => createTooltipDom(id, def.content),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target?.classList.contains('cm-inline-footnote-ref')) {
|
|
||||||
const content = target.dataset.footnoteContent;
|
|
||||||
const index = target.dataset.footnoteIndex;
|
|
||||||
if (content && index) {
|
|
||||||
return {
|
|
||||||
pos,
|
|
||||||
above: true,
|
|
||||||
arrow: true,
|
|
||||||
create: () => createInlineTooltipDom(parseInt(index), content),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check by position using indexed data
|
|
||||||
const ref = data.referencesByPos.get(pos);
|
|
||||||
if (ref) {
|
|
||||||
const def = data.definitions.get(ref.id);
|
|
||||||
if (def) {
|
|
||||||
return {
|
|
||||||
pos: ref.to,
|
|
||||||
above: true,
|
|
||||||
arrow: true,
|
|
||||||
create: () => createTooltipDom(ref.id, def.content),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inline = data.inlineByPos.get(pos);
|
|
||||||
if (inline) {
|
|
||||||
return {
|
|
||||||
pos: inline.to,
|
|
||||||
above: true,
|
|
||||||
arrow: true,
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
{ hoverTime: 300 }
|
|
||||||
);
|
|
||||||
|
|
||||||
function createTooltipDom(id: string, content: string): { dom: HTMLElement } {
|
|
||||||
const dom = document.createElement('div');
|
|
||||||
dom.className = 'cm-footnote-tooltip';
|
|
||||||
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.className = 'cm-footnote-tooltip-header';
|
|
||||||
header.textContent = `[^${id}]`;
|
|
||||||
|
|
||||||
const body = document.createElement('div');
|
|
||||||
body.className = 'cm-footnote-tooltip-body';
|
|
||||||
body.textContent = content || '(Empty footnote)';
|
|
||||||
|
|
||||||
dom.appendChild(header);
|
|
||||||
dom.appendChild(body);
|
|
||||||
|
|
||||||
return { dom };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
|
/**
|
||||||
const dom = document.createElement('div');
|
* Process pending footnote refs after all definitions are collected.
|
||||||
dom.className = 'cm-footnote-tooltip';
|
*/
|
||||||
|
export function processPendingFootnotes(ctx: FootnoteContext): void {
|
||||||
const header = document.createElement('div');
|
for (const ref of ctx.pendingRefs) {
|
||||||
header.className = 'cm-footnote-tooltip-header';
|
ctx.items.push({ from: ref.from, to: ref.to, deco: invisibleDecoration });
|
||||||
header.textContent = `Inline Footnote [${index}]`;
|
ctx.items.push({ from: ref.to, to: ref.to, deco: Decoration.widget({ widget: new FootnoteRefWidget(ref.index, ctx.definitionIds.has(ref.id)), side: 1 }), priority: 1 });
|
||||||
|
}
|
||||||
const body = document.createElement('div');
|
for (const inline of ctx.pendingInlines) {
|
||||||
body.className = 'cm-footnote-tooltip-body';
|
ctx.items.push({ from: inline.from, to: inline.to, deco: invisibleDecoration });
|
||||||
body.textContent = content || '(Empty footnote)';
|
ctx.items.push({ from: inline.to, to: inline.to, deco: Decoration.widget({ widget: new InlineFootnoteWidget(inline.index), side: 1 }), priority: 1 });
|
||||||
|
}
|
||||||
dom.appendChild(header);
|
|
||||||
dom.appendChild(body);
|
|
||||||
|
|
||||||
return { dom };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
/**
|
||||||
// Click Handler
|
* Theme for footnotes.
|
||||||
// ============================================================================
|
*/
|
||||||
|
export const footnoteTheme = EditorView.baseTheme({
|
||||||
const footnoteClickHandler = EditorView.domEventHandlers({
|
|
||||||
mousedown(event, view) {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
|
|
||||||
// Click on footnote reference → jump to definition
|
|
||||||
if (target.classList.contains('cm-footnote-ref')) {
|
|
||||||
const id = target.dataset.footnoteId;
|
|
||||||
if (id) {
|
|
||||||
const data = collectFootnotes(view.state);
|
|
||||||
const def = data.definitions.get(id);
|
|
||||||
if (def) {
|
|
||||||
event.preventDefault();
|
|
||||||
setTimeout(() => {
|
|
||||||
view.dispatch({
|
|
||||||
selection: { anchor: def.from },
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
view.focus();
|
|
||||||
}, 0);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click on definition label → jump to first reference
|
|
||||||
if (target.classList.contains('cm-footnote-def-label')) {
|
|
||||||
const id = target.dataset.footnoteId;
|
|
||||||
if (id) {
|
|
||||||
const data = collectFootnotes(view.state);
|
|
||||||
const firstRef = data.firstRefById.get(id);
|
|
||||||
if (firstRef) {
|
|
||||||
event.preventDefault();
|
|
||||||
setTimeout(() => {
|
|
||||||
view.dispatch({
|
|
||||||
selection: { anchor: firstRef.from },
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
view.focus();
|
|
||||||
}, 0);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Theme
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-footnote-ref': {
|
'.cm-footnote-ref': {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -630,20 +161,12 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
verticalAlign: 'super',
|
verticalAlign: 'super',
|
||||||
color: 'var(--cm-footnote-color, #1a73e8)',
|
color: 'var(--cm-footnote-color, #1a73e8)',
|
||||||
backgroundColor: 'var(--cm-footnote-bg, rgba(26, 115, 232, 0.1))',
|
backgroundColor: 'var(--cm-footnote-bg, rgba(26, 115, 232, 0.1))',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px'
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
textDecoration: 'none',
|
|
||||||
},
|
|
||||||
'.cm-footnote-ref:hover': {
|
|
||||||
color: 'var(--cm-footnote-hover-color, #1557b0)',
|
|
||||||
backgroundColor: 'var(--cm-footnote-hover-bg, rgba(26, 115, 232, 0.2))',
|
|
||||||
},
|
},
|
||||||
'.cm-footnote-ref-undefined': {
|
'.cm-footnote-ref-undefined': {
|
||||||
color: 'var(--cm-footnote-undefined-color, #d93025)',
|
color: 'var(--cm-footnote-undefined-color, #d93025)',
|
||||||
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))',
|
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))'
|
||||||
},
|
},
|
||||||
|
|
||||||
'.cm-inline-footnote-ref': {
|
'.cm-inline-footnote-ref': {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -658,79 +181,10 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
verticalAlign: 'super',
|
verticalAlign: 'super',
|
||||||
color: 'var(--cm-inline-footnote-color, #e67e22)',
|
color: 'var(--cm-inline-footnote-color, #e67e22)',
|
||||||
backgroundColor: 'var(--cm-inline-footnote-bg, rgba(230, 126, 34, 0.1))',
|
backgroundColor: 'var(--cm-inline-footnote-bg, rgba(230, 126, 34, 0.1))',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px'
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
textDecoration: 'none',
|
|
||||||
},
|
},
|
||||||
'.cm-inline-footnote-ref:hover': {
|
|
||||||
color: 'var(--cm-inline-footnote-hover-color, #d35400)',
|
|
||||||
backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))',
|
|
||||||
},
|
|
||||||
|
|
||||||
'.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'
|
||||||
cursor: 'pointer',
|
}
|
||||||
},
|
|
||||||
'.cm-footnote-def-label:hover': {
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
|
|
||||||
'.cm-footnote-tooltip': {
|
|
||||||
maxWidth: '400px',
|
|
||||||
padding: '0',
|
|
||||||
backgroundColor: 'var(--bg-secondary)',
|
|
||||||
border: '1px solid var(--border-color)',
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
'.cm-footnote-tooltip-header': {
|
|
||||||
padding: '6px 12px',
|
|
||||||
fontSize: '0.8em',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: 'var(--cm-footnote-color, #1a73e8)',
|
|
||||||
backgroundColor: 'var(--bg-tertiary, rgba(0, 0, 0, 0.05))',
|
|
||||||
borderBottom: '1px solid var(--border-color)',
|
|
||||||
},
|
|
||||||
'.cm-footnote-tooltip-body': {
|
|
||||||
padding: '10px 12px',
|
|
||||||
fontSize: '0.9em',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
},
|
|
||||||
|
|
||||||
'.cm-tooltip:has(.cm-footnote-tooltip)': {
|
|
||||||
animation: 'cm-footnote-fade-in 0.15s ease-out',
|
|
||||||
},
|
|
||||||
'@keyframes cm-footnote-fade-in': {
|
|
||||||
from: { opacity: '0', transform: 'translateY(4px)' },
|
|
||||||
to: { opacity: '1', transform: 'translateY(0)' },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Export
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Footnote extension.
|
|
||||||
*/
|
|
||||||
export const footnote = (): Extension => [
|
|
||||||
footnotePlugin,
|
|
||||||
footnoteHoverTooltip,
|
|
||||||
footnoteClickHandler,
|
|
||||||
baseTheme,
|
|
||||||
];
|
|
||||||
|
|
||||||
export default footnote;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get footnote data for external use.
|
|
||||||
*/
|
|
||||||
export function getFootnoteData(state: EditorState): FootnoteData {
|
|
||||||
return collectFootnotes(state);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,168 +1,63 @@
|
|||||||
import { syntaxTree } from '@codemirror/language';
|
/**
|
||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
* Heading handler and theme.
|
||||||
import {
|
*/
|
||||||
Decoration,
|
|
||||||
DecorationSet,
|
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { checkRangeOverlap, RangeTuple } from '../util';
|
|
||||||
|
|
||||||
/** Hidden mark decoration */
|
import { Decoration, EditorView } from '@codemirror/view';
|
||||||
const hiddenMarkDecoration = Decoration.mark({
|
import { RangeTuple } from '../util';
|
||||||
class: 'cm-heading-mark-hidden'
|
import { SyntaxNode } from '@lezer/common';
|
||||||
});
|
import { BuildContext } from './types';
|
||||||
|
|
||||||
|
const DECO_HEADING_HIDDEN = Decoration.mark({ class: 'cm-heading-mark-hidden' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all heading ranges in visible viewport.
|
* Handle ATXHeading node (# Heading).
|
||||||
*/
|
*/
|
||||||
function collectHeadingRanges(view: EditorView): RangeTuple[] {
|
export function handleATXHeading(
|
||||||
const ranges: RangeTuple[] = [];
|
ctx: BuildContext,
|
||||||
const seen = new Set<number>();
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
const header = node.firstChild;
|
||||||
syntaxTree(view.state).iterate({
|
if (header && header.type.name === 'HeaderMark') {
|
||||||
from,
|
ctx.items.push({ from: header.from, to: Math.min(header.to + 1, nt), deco: DECO_HEADING_HIDDEN });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get which heading the cursor is in (-1 if none).
|
* Handle SetextHeading node (underline style).
|
||||||
*/
|
*/
|
||||||
function getCursorHeadingPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
export function handleSetextHeading(
|
||||||
const selRange: RangeTuple = [selFrom, selTo];
|
ctx: BuildContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
for (const range of ranges) {
|
const headerMarks = node.getChildren('HeaderMark');
|
||||||
if (checkRangeOverlap(range, selRange)) {
|
for (const mark of headerMarks) {
|
||||||
return range[0];
|
ctx.items.push({ from: mark.from, to: mark.to, deco: DECO_HEADING_HIDDEN });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build heading decorations using RangeSetBuilder.
|
* Theme for headings.
|
||||||
*/
|
*/
|
||||||
function buildDecorations(view: EditorView): DecorationSet {
|
export const headingTheme = EditorView.baseTheme({
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
|
||||||
const items: { from: number; to: number }[] = [];
|
|
||||||
const { from: selFrom, to: selTo } = view.state.selection.main;
|
|
||||||
const selRange: RangeTuple = [selFrom, selTo];
|
|
||||||
const seen = new Set<number>();
|
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
|
||||||
syntaxTree(view.state).iterate({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
enter(node) {
|
|
||||||
// Skip if cursor is in this heading
|
|
||||||
if (checkRangeOverlap([node.from, node.to], selRange)) return;
|
|
||||||
|
|
||||||
// ATX headings (# Heading)
|
|
||||||
if (node.type.name.startsWith('ATXHeading')) {
|
|
||||||
if (seen.has(node.from)) return;
|
|
||||||
seen.add(node.from);
|
|
||||||
|
|
||||||
const header = node.node.firstChild;
|
|
||||||
if (header && header.type.name === 'HeaderMark') {
|
|
||||||
const markFrom = header.from;
|
|
||||||
// Include the space after #
|
|
||||||
const markTo = Math.min(header.to + 1, node.to);
|
|
||||||
items.push({ from: markFrom, to: markTo });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Setext headings (underline style)
|
|
||||||
else if (node.type.name.startsWith('SetextHeading')) {
|
|
||||||
if (seen.has(node.from)) return;
|
|
||||||
seen.add(node.from);
|
|
||||||
|
|
||||||
const cursor = node.node.cursor();
|
|
||||||
cursor.iterate((child) => {
|
|
||||||
if (child.type.name === 'HeaderMark') {
|
|
||||||
items.push({ from: child.from, to: child.to });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by position and add to builder
|
|
||||||
items.sort((a, b) => a.from - b.from);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
builder.add(item.from, item.to, hiddenMarkDecoration);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Heading plugin with optimized updates.
|
|
||||||
*/
|
|
||||||
class HeadingPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private headingRanges: RangeTuple[] = [];
|
|
||||||
private cursorHeadingPos = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.headingRanges = collectHeadingRanges(view);
|
|
||||||
const { from, to } = view.state.selection.main;
|
|
||||||
this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to);
|
|
||||||
this.decorations = buildDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
|
||||||
|
|
||||||
if (docChanged || viewportChanged) {
|
|
||||||
this.headingRanges = collectHeadingRanges(update.view);
|
|
||||||
const { from, to } = update.state.selection.main;
|
|
||||||
this.cursorHeadingPos = getCursorHeadingPos(this.headingRanges, from, to);
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionSet) {
|
|
||||||
const { from, to } = update.state.selection.main;
|
|
||||||
const newPos = getCursorHeadingPos(this.headingRanges, from, to);
|
|
||||||
|
|
||||||
if (newPos !== this.cursorHeadingPos) {
|
|
||||||
this.cursorHeadingPos = newPos;
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const headingPlugin = ViewPlugin.fromClass(HeadingPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Theme for hidden heading marks.
|
|
||||||
*/
|
|
||||||
const headingTheme = EditorView.baseTheme({
|
|
||||||
'.cm-heading-mark-hidden': {
|
'.cm-heading-mark-hidden': {
|
||||||
fontSize: '0'
|
fontSize: '0'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Headings plugin.
|
|
||||||
*/
|
|
||||||
export const headings = (): Extension => [headingPlugin, headingTheme];
|
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import {
|
|
||||||
Decoration,
|
|
||||||
DecorationSet,
|
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Node types that contain markers to hide.
|
|
||||||
* Note: InlineCode is handled by inline-code.ts
|
|
||||||
*/
|
|
||||||
const TYPES_WITH_MARKS = new Set([
|
|
||||||
'Emphasis',
|
|
||||||
'StrongEmphasis',
|
|
||||||
'Strikethrough'
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marker node types to hide.
|
|
||||||
*/
|
|
||||||
const MARK_TYPES = new Set([
|
|
||||||
'EmphasisMark',
|
|
||||||
'StrikethroughMark'
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Export for external use
|
|
||||||
export const typesWithMarks = Array.from(TYPES_WITH_MARKS);
|
|
||||||
export const markTypes = Array.from(MARK_TYPES);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect all mark ranges in visible viewport.
|
|
||||||
*/
|
|
||||||
function collectMarkRanges(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 (!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) {
|
|
||||||
syntaxTree(view.state).iterate({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
|
||||||
if (!TYPES_WITH_MARKS.has(type.name)) return;
|
|
||||||
if (seen.has(nodeFrom)) return;
|
|
||||||
seen.add(nodeFrom);
|
|
||||||
|
|
||||||
// Skip if cursor is in this range
|
|
||||||
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
|
||||||
|
|
||||||
// Collect mark positions
|
|
||||||
const innerTree = node.toTree();
|
|
||||||
innerTree.iterate({
|
|
||||||
enter({ type: markType, from: markFrom, to: markTo }) {
|
|
||||||
if (!MARK_TYPES.has(markType.name)) return;
|
|
||||||
items.push({
|
|
||||||
from: nodeFrom + markFrom,
|
|
||||||
to: nodeFrom + markTo
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort and add to builder
|
|
||||||
items.sort((a, b) => a.from - b.from);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
builder.add(item.from, item.to, invisibleDecoration);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide marks plugin with optimized updates.
|
|
||||||
*
|
|
||||||
* Hides emphasis marks (*, **, ~~) when cursor is outside.
|
|
||||||
* Note: InlineCode backticks are handled by inline-code.ts
|
|
||||||
*/
|
|
||||||
class HideMarkPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private markRanges: RangeTuple[] = [];
|
|
||||||
private cursorMarkPos = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.markRanges = collectMarkRanges(view);
|
|
||||||
const { from, to } = view.state.selection.main;
|
|
||||||
this.cursorMarkPos = getCursorMarkPos(this.markRanges, from, to);
|
|
||||||
this.decorations = buildDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
|
||||||
|
|
||||||
if (docChanged || viewportChanged) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionSet) {
|
|
||||||
const { from, to } = update.state.selection.main;
|
|
||||||
const newPos = getCursorMarkPos(this.markRanges, from, to);
|
|
||||||
|
|
||||||
if (newPos !== this.cursorMarkPos) {
|
|
||||||
this.cursorMarkPos = newPos;
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide marks plugin.
|
|
||||||
* Hides marks for emphasis, strong, and strikethrough.
|
|
||||||
*/
|
|
||||||
export const hideMarks = (): Extension => [
|
|
||||||
ViewPlugin.fromClass(HideMarkPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
})
|
|
||||||
];
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import {
|
|
||||||
ViewPlugin,
|
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
|
||||||
|
|
||||||
/** Mark decoration for highlighted content */
|
|
||||||
const highlightMarkDecoration = Decoration.mark({ class: 'cm-highlight' });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Highlight plugin using syntax tree.
|
|
||||||
*
|
|
||||||
* Detects ==text== and renders as highlighted text.
|
|
||||||
*/
|
|
||||||
export const highlight = (): Extension => [highlightPlugin, baseTheme];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
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, node }) => {
|
|
||||||
if (type.name !== 'Highlight') return;
|
|
||||||
if (seen.has(nodeFrom)) return;
|
|
||||||
seen.add(nodeFrom);
|
|
||||||
|
|
||||||
// Skip if cursor is in this highlight
|
|
||||||
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
|
||||||
|
|
||||||
const marks = node.getChildren('HighlightMark');
|
|
||||||
if (marks.length < 2) return;
|
|
||||||
|
|
||||||
// Hide opening ==
|
|
||||||
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
|
||||||
|
|
||||||
// Apply highlight style to content
|
|
||||||
const contentStart = marks[0].to;
|
|
||||||
const contentEnd = marks[marks.length - 1].from;
|
|
||||||
if (contentStart < contentEnd) {
|
|
||||||
items.push({ from: contentStart, to: contentEnd, deco: highlightMarkDecoration });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide closing ==
|
|
||||||
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Highlight plugin with optimized updates.
|
|
||||||
*/
|
|
||||||
class HighlightPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private highlightRanges: RangeTuple[] = [];
|
|
||||||
private cursorHighlightPos = -1;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
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);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionSet) {
|
|
||||||
const { from, to } = update.state.selection.main;
|
|
||||||
const newPos = getCursorHighlightPos(this.highlightRanges, from, to);
|
|
||||||
|
|
||||||
if (newPos !== this.cursorHighlightPos) {
|
|
||||||
this.cursorHighlightPos = newPos;
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightPlugin = ViewPlugin.fromClass(HighlightPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base theme for highlight.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-highlight': {
|
|
||||||
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,172 +1,48 @@
|
|||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
|
||||||
import {
|
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate,
|
|
||||||
WidgetType
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { checkRangeOverlap, RangeTuple } from '../util';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal rule plugin that renders beautiful horizontal lines.
|
* Horizontal rule handler and theme.
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Replaces markdown horizontal rules (---, ***, ___) with styled <hr> elements
|
|
||||||
* - Shows the original text when cursor is on the line
|
|
||||||
* - Uses inline widget to avoid affecting block system boundaries
|
|
||||||
*/
|
*/
|
||||||
export const horizontalRule = (): Extension => [horizontalRulePlugin, baseTheme];
|
|
||||||
|
|
||||||
/**
|
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||||
* Widget to display a horizontal rule.
|
import { RangeTuple } from '../util';
|
||||||
*/
|
import { BuildContext } from './types';
|
||||||
|
|
||||||
class HorizontalRuleWidget extends WidgetType {
|
class HorizontalRuleWidget extends WidgetType {
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-horizontal-rule-widget';
|
span.className = 'cm-horizontal-rule-widget';
|
||||||
|
|
||||||
const hr = document.createElement('hr');
|
const hr = document.createElement('hr');
|
||||||
hr.className = 'cm-horizontal-rule';
|
hr.className = 'cm-horizontal-rule';
|
||||||
span.appendChild(hr);
|
span.appendChild(hr);
|
||||||
|
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
eq() { return true; }
|
||||||
eq(_other: HorizontalRuleWidget) {
|
ignoreEvent() { return false; }
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared widget instance (all HR widgets are identical) */
|
|
||||||
const hrWidget = new HorizontalRuleWidget();
|
const hrWidget = new HorizontalRuleWidget();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all horizontal rule ranges in visible viewport.
|
* Handle HorizontalRule node.
|
||||||
*/
|
*/
|
||||||
function collectHRRanges(view: EditorView): RangeTuple[] {
|
export function handleHorizontalRule(
|
||||||
const ranges: RangeTuple[] = [];
|
ctx: BuildContext,
|
||||||
const seen = new Set<number>();
|
nf: number,
|
||||||
|
nt: number,
|
||||||
for (const { from, to } of view.visibleRanges) {
|
inCursor: boolean,
|
||||||
syntaxTree(view.state).iterate({
|
ranges: RangeTuple[]
|
||||||
from,
|
): void {
|
||||||
to,
|
if (ctx.seen.has(nf)) return;
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
ctx.seen.add(nf);
|
||||||
if (type.name !== 'HorizontalRule') return;
|
ranges.push([nf, nt]);
|
||||||
if (seen.has(nodeFrom)) return;
|
if (!inCursor) {
|
||||||
seen.add(nodeFrom);
|
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: hrWidget }) });
|
||||||
ranges.push([nodeFrom, nodeTo]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ranges;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get which HR the cursor is in (-1 if none).
|
* Theme for horizontal rules.
|
||||||
*/
|
*/
|
||||||
function getCursorHRPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
export const horizontalRuleTheme = EditorView.baseTheme({
|
||||||
const selRange: RangeTuple = [selFrom, selTo];
|
|
||||||
|
|
||||||
for (const range of ranges) {
|
|
||||||
if (checkRangeOverlap(range, selRange)) {
|
|
||||||
return range[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-horizontal-rule-widget': {
|
'.cm-horizontal-rule-widget': {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ const theme = EditorView.baseTheme({
|
|||||||
* - Shows indicator icon at the end
|
* - Shows indicator icon at the end
|
||||||
* - Click to preview rendered HTML
|
* - Click to preview rendered HTML
|
||||||
*/
|
*/
|
||||||
export const htmlBlockExtension: Extension = [
|
export const html = (): Extension => [
|
||||||
htmlBlockPlugin,
|
htmlBlockPlugin,
|
||||||
htmlTooltipState,
|
htmlTooltipState,
|
||||||
clickOutsideHandler,
|
clickOutsideHandler,
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
|
||||||
import {
|
|
||||||
Decoration,
|
|
||||||
DecorationSet,
|
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
|
||||||
|
|
||||||
/** Mark decoration for code content */
|
|
||||||
const codeMarkDecoration = Decoration.mark({ class: 'cm-inline-code' });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inline code styling plugin.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Adds background color, border radius, padding to code content
|
|
||||||
* - Hides backtick markers when cursor is outside
|
|
||||||
*/
|
|
||||||
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect all inline code ranges in visible viewport.
|
|
||||||
*/
|
|
||||||
function collectCodeRanges(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 !== 'InlineCode') return;
|
|
||||||
if (seen.has(nodeFrom)) return;
|
|
||||||
seen.add(nodeFrom);
|
|
||||||
ranges.push([nodeFrom, nodeTo]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return ranges;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get which inline code the cursor is in (-1 if none).
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
|
|
||||||
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
|
|
||||||
|
|
||||||
// Find backtick boundaries
|
|
||||||
let codeStart = nodeFrom;
|
|
||||||
let codeEnd = nodeTo;
|
|
||||||
|
|
||||||
// Count opening backticks
|
|
||||||
let i = 0;
|
|
||||||
while (i < text.length && text[i] === '`') {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
codeStart = nodeFrom + i;
|
|
||||||
|
|
||||||
// Count closing backticks
|
|
||||||
let j = text.length - 1;
|
|
||||||
while (j >= 0 && text[j] === '`') {
|
|
||||||
j--;
|
|
||||||
}
|
|
||||||
codeEnd = nodeFrom + j + 1;
|
|
||||||
|
|
||||||
// Hide opening backticks
|
|
||||||
if (nodeFrom < codeStart) {
|
|
||||||
items.push({ from: nodeFrom, to: codeStart, deco: invisibleDecoration });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add style to code content
|
|
||||||
if (codeStart < codeEnd) {
|
|
||||||
items.push({ from: codeStart, to: codeEnd, deco: codeMarkDecoration });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide closing backticks
|
|
||||||
if (codeEnd < nodeTo) {
|
|
||||||
items.push({ from: codeEnd, to: nodeTo, deco: invisibleDecoration });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 with optimized updates.
|
|
||||||
*/
|
|
||||||
class InlineCodePlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private codeRanges: RangeTuple[] = [];
|
|
||||||
private cursorCodePos = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
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) {
|
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base theme for inline code.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-inline-code': {
|
|
||||||
backgroundColor: 'var(--cm-inline-code-bg)',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
padding: '0.1rem 0.3rem',
|
|
||||||
fontFamily: 'var(--voidraft-font-mono)'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Inline styles handlers and theme.
|
||||||
|
* Handles: Highlight, InlineCode, Emphasis, StrongEmphasis, Strikethrough, Insert, Superscript, Subscript
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Decoration, EditorView } from '@codemirror/view';
|
||||||
|
import { invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
import { SyntaxNode } from '@lezer/common';
|
||||||
|
import { BuildContext } from './types';
|
||||||
|
|
||||||
|
const DECO_HIGHLIGHT = Decoration.mark({ class: 'cm-highlight' });
|
||||||
|
const DECO_INLINE_CODE = Decoration.mark({ class: 'cm-inline-code' });
|
||||||
|
const DECO_INSERT = Decoration.mark({ class: 'cm-insert' });
|
||||||
|
const DECO_SUPERSCRIPT = Decoration.mark({ class: 'cm-superscript' });
|
||||||
|
const DECO_SUBSCRIPT = Decoration.mark({ class: 'cm-subscript' });
|
||||||
|
|
||||||
|
const MARK_TYPES: Record<string, string> = {
|
||||||
|
'Emphasis': 'EmphasisMark',
|
||||||
|
'StrongEmphasis': 'EmphasisMark',
|
||||||
|
'Strikethrough': 'StrikethroughMark'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Highlight node (==text==).
|
||||||
|
*/
|
||||||
|
export function handleHighlight(
|
||||||
|
ctx: BuildContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
|
const marks = node.getChildren('HighlightMark');
|
||||||
|
if (marks.length >= 2) {
|
||||||
|
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||||
|
if (marks[0].to < marks[marks.length - 1].from) {
|
||||||
|
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_HIGHLIGHT });
|
||||||
|
}
|
||||||
|
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle InlineCode node (`code`).
|
||||||
|
*/
|
||||||
|
export function handleInlineCode(
|
||||||
|
ctx: BuildContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
|
const text = ctx.view.state.doc.sliceString(nf, nt);
|
||||||
|
let i = 0; while (i < text.length && text[i] === '`') i++;
|
||||||
|
let j = text.length - 1; while (j >= 0 && text[j] === '`') j--;
|
||||||
|
const codeStart = nf + i, codeEnd = nf + j + 1;
|
||||||
|
if (nf < codeStart) ctx.items.push({ from: nf, to: codeStart, deco: invisibleDecoration });
|
||||||
|
if (codeStart < codeEnd) ctx.items.push({ from: codeStart, to: codeEnd, deco: DECO_INLINE_CODE });
|
||||||
|
if (codeEnd < nt) ctx.items.push({ from: codeEnd, to: nt, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Emphasis, StrongEmphasis, Strikethrough nodes.
|
||||||
|
*/
|
||||||
|
export function handleEmphasis(
|
||||||
|
ctx: BuildContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
typeName: string,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
|
const markType = MARK_TYPES[typeName];
|
||||||
|
if (markType) {
|
||||||
|
const marks = node.getChildren(markType);
|
||||||
|
for (const mark of marks) {
|
||||||
|
ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Insert node (++text++).
|
||||||
|
*/
|
||||||
|
export function handleInsert(
|
||||||
|
ctx: BuildContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
|
const marks = node.getChildren('InsertMark');
|
||||||
|
if (marks.length >= 2) {
|
||||||
|
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||||
|
if (marks[0].to < marks[marks.length - 1].from) {
|
||||||
|
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_INSERT });
|
||||||
|
}
|
||||||
|
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Superscript / Subscript nodes.
|
||||||
|
*/
|
||||||
|
export function handleScript(
|
||||||
|
ctx: BuildContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
typeName: string,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
|
const isSuper = typeName === 'Superscript';
|
||||||
|
const markName = isSuper ? 'SuperscriptMark' : 'SubscriptMark';
|
||||||
|
const marks = node.getChildren(markName);
|
||||||
|
if (marks.length >= 2) {
|
||||||
|
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
||||||
|
if (marks[0].to < marks[marks.length - 1].from) {
|
||||||
|
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: isSuper ? DECO_SUPERSCRIPT : DECO_SUBSCRIPT });
|
||||||
|
}
|
||||||
|
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme for inline styles.
|
||||||
|
*/
|
||||||
|
export const inlineStylesTheme = EditorView.baseTheme({
|
||||||
|
'.cm-highlight': {
|
||||||
|
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
|
||||||
|
borderRadius: '2px'
|
||||||
|
},
|
||||||
|
'.cm-inline-code': {
|
||||||
|
backgroundColor: 'var(--cm-inline-code-bg)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
padding: '0.1rem 0.3rem',
|
||||||
|
fontFamily: 'var(--voidraft-font-mono)'
|
||||||
|
},
|
||||||
|
'.cm-insert': {
|
||||||
|
textDecoration: 'underline'
|
||||||
|
},
|
||||||
|
'.cm-superscript': {
|
||||||
|
verticalAlign: 'super',
|
||||||
|
fontSize: '0.75em',
|
||||||
|
color: 'var(--cm-superscript-color, inherit)'
|
||||||
|
},
|
||||||
|
'.cm-subscript': {
|
||||||
|
verticalAlign: 'sub',
|
||||||
|
fontSize: '0.75em',
|
||||||
|
color: 'var(--cm-subscript-color, inherit)'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import {
|
|
||||||
ViewPlugin,
|
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
|
||||||
|
|
||||||
/** Mark decoration for inserted content */
|
|
||||||
const insertMarkDecoration = Decoration.mark({ class: 'cm-insert' });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert plugin using syntax tree.
|
|
||||||
*
|
|
||||||
* Detects ++text++ and renders as inserted text (underline).
|
|
||||||
*/
|
|
||||||
export const insert = (): Extension => [insertPlugin, baseTheme];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
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, node }) => {
|
|
||||||
if (type.name !== 'Insert') return;
|
|
||||||
if (seen.has(nodeFrom)) return;
|
|
||||||
seen.add(nodeFrom);
|
|
||||||
|
|
||||||
// Skip if cursor is in this insert
|
|
||||||
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
|
||||||
|
|
||||||
const marks = node.getChildren('InsertMark');
|
|
||||||
if (marks.length < 2) return;
|
|
||||||
|
|
||||||
// Hide opening ++
|
|
||||||
items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
|
|
||||||
|
|
||||||
// Apply insert style to content
|
|
||||||
const contentStart = marks[0].to;
|
|
||||||
const contentEnd = marks[marks.length - 1].from;
|
|
||||||
if (contentStart < contentEnd) {
|
|
||||||
items.push({ from: contentStart, to: contentEnd, deco: insertMarkDecoration });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide closing ++
|
|
||||||
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert plugin with optimized updates.
|
|
||||||
*/
|
|
||||||
class InsertPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private insertRanges: RangeTuple[] = [];
|
|
||||||
private cursorInsertPos = -1;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
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);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionSet) {
|
|
||||||
const { from, to } = update.state.selection.main;
|
|
||||||
const newPos = getCursorInsertPos(this.insertRanges, from, to);
|
|
||||||
|
|
||||||
if (newPos !== this.cursorInsertPos) {
|
|
||||||
this.cursorInsertPos = newPos;
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertPlugin = ViewPlugin.fromClass(InsertPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base theme for insert.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-insert': {
|
|
||||||
textDecoration: 'underline',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,202 +1,111 @@
|
|||||||
import { syntaxTree } from '@codemirror/language';
|
/**
|
||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
* Link handler with underline and clickable icon.
|
||||||
import {
|
*/
|
||||||
Decoration,
|
|
||||||
DecorationSet,
|
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
import { SyntaxNode } from '@lezer/common';
|
||||||
|
import { BuildContext } from './types';
|
||||||
|
import * as runtime from "@wailsio/runtime";
|
||||||
|
|
||||||
/**
|
const BLACKLISTED_LINK_PARENTS = new Set(['Image', 'LinkReference']);
|
||||||
* Parent node types that should not process.
|
|
||||||
* - Image: handled by image plugin
|
|
||||||
* - LinkReference: reference link definitions should be fully visible
|
|
||||||
*/
|
|
||||||
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
|
|
||||||
|
|
||||||
/**
|
/** Link text decoration with underline */
|
||||||
* Links plugin.
|
const linkTextDecoration = Decoration.mark({ class: 'cm-md-link-text' });
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Hides link markup when cursor is outside
|
|
||||||
* - Link icons and click events are handled by hyperlink extension
|
|
||||||
*/
|
|
||||||
export const links = (): Extension => [goToLinkPlugin];
|
|
||||||
|
|
||||||
/**
|
/** Link icon widget - clickable to open URL */
|
||||||
* Link info for tracking.
|
class LinkIconWidget extends WidgetType {
|
||||||
*/
|
constructor(readonly url: string) { super(); }
|
||||||
interface LinkInfo {
|
eq(other: LinkIconWidget) { return this.url === other.url; }
|
||||||
parentFrom: number;
|
toDOM(): HTMLElement {
|
||||||
parentTo: number;
|
const span = document.createElement('span');
|
||||||
urlFrom: number;
|
span.className = 'cm-md-link-icon';
|
||||||
urlTo: number;
|
span.textContent = '🔗';
|
||||||
marks: { from: number; to: number }[];
|
span.title = this.url;
|
||||||
linkTitle: { from: number; to: number } | null;
|
span.onmousedown = (e) => {
|
||||||
isAutoLink: boolean;
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
runtime.Browser.OpenURL(this.url);
|
||||||
|
};
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
ignoreEvent(e: Event) { return e.type === 'mousedown'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all link ranges in visible viewport.
|
* Handle URL node (within Link).
|
||||||
*/
|
*/
|
||||||
function collectLinkRanges(view: EditorView): RangeTuple[] {
|
export function handleURL(
|
||||||
const ranges: RangeTuple[] = [];
|
ctx: BuildContext,
|
||||||
const seen = new Set<number>();
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
const parent = node.parent;
|
||||||
|
if (!parent || BLACKLISTED_LINK_PARENTS.has(parent.name)) return;
|
||||||
|
if (ctx.seen.has(parent.from)) return;
|
||||||
|
ctx.seen.add(parent.from);
|
||||||
|
ranges.push([parent.from, parent.to]);
|
||||||
|
if (checkRangeOverlap([parent.from, parent.to], ctx.selRange)) return;
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
// Get link text node (content between first [ and ])
|
||||||
syntaxTree(view.state).iterate({
|
const linkText = parent.getChild('LinkLabel');
|
||||||
from,
|
const marks = parent.getChildren('LinkMark');
|
||||||
to,
|
const linkTitle = parent.getChild('LinkTitle');
|
||||||
enter: ({ type, node }) => {
|
const closeBracket = marks.find(m => ctx.view.state.sliceDoc(m.from, m.to) === ']');
|
||||||
if (type.name !== 'URL') return;
|
|
||||||
|
|
||||||
const parent = node.parent;
|
if (closeBracket && nf < closeBracket.from) return;
|
||||||
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
|
|
||||||
if (seen.has(parent.from)) return;
|
|
||||||
seen.add(parent.from);
|
|
||||||
|
|
||||||
ranges.push([parent.from, parent.to]);
|
// Get URL for the icon
|
||||||
}
|
const url = ctx.view.state.sliceDoc(nf, nt);
|
||||||
|
|
||||||
|
// Add underline decoration to link text
|
||||||
|
if (linkText) {
|
||||||
|
ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide markdown syntax marks
|
||||||
|
for (const m of marks) {
|
||||||
|
ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide URL
|
||||||
|
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
|
||||||
|
|
||||||
|
// Hide link title if present
|
||||||
|
if (linkTitle) {
|
||||||
|
ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add clickable icon widget after link text (at close bracket position)
|
||||||
|
if (closeBracket) {
|
||||||
|
ctx.items.push({
|
||||||
|
from: closeBracket.from,
|
||||||
|
to: closeBracket.from,
|
||||||
|
deco: Decoration.widget({ widget: new LinkIconWidget(url), side: 1 }),
|
||||||
|
priority: 1
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return ranges;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get which link the cursor is in (-1 if none).
|
* Theme for markdown links.
|
||||||
*/
|
*/
|
||||||
function getCursorLinkPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
export const linkTheme = EditorView.baseTheme({
|
||||||
const selRange: RangeTuple = [selFrom, selTo];
|
'.cm-md-link-text': {
|
||||||
|
color: 'var(--cm-link-color, #0969da)',
|
||||||
for (const range of ranges) {
|
textDecoration: 'underline',
|
||||||
if (checkRangeOverlap(range, selRange)) {
|
textUnderlineOffset: '2px',
|
||||||
return range[0];
|
cursor: 'text'
|
||||||
|
},
|
||||||
|
'.cm-md-link-icon': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginLeft: '0.2em',
|
||||||
|
opacity: '0.7',
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
'&:hover': {
|
||||||
|
opacity: '1'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build link 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, node }) => {
|
|
||||||
if (type.name !== 'URL') return;
|
|
||||||
|
|
||||||
const parent = node.parent;
|
|
||||||
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 linkTitle = parent.getChild('LinkTitle');
|
|
||||||
|
|
||||||
// Find the ']' mark to distinguish link text from URL
|
|
||||||
const closeBracketMark = marks.find((mark) => {
|
|
||||||
const text = view.state.sliceDoc(mark.from, mark.to);
|
|
||||||
return text === ']';
|
|
||||||
});
|
|
||||||
|
|
||||||
// If URL is before ']', it's part of display text, don't hide
|
|
||||||
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if cursor overlaps with the parent link
|
|
||||||
if (checkRangeOverlap([parent.from, parent.to], selRange)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
items.push({ from: linkTitle.from, to: linkTitle.to });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle auto-links with < > markers
|
|
||||||
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
|
|
||||||
if (linkContent.startsWith('<') && linkContent.endsWith('>')) {
|
|
||||||
// Already hidden the whole URL above, no extra handling needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort and add to builder
|
|
||||||
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 updates.
|
|
||||||
*/
|
|
||||||
class LinkPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private linkRanges: RangeTuple[] = [];
|
|
||||||
private cursorLinkPos = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.linkRanges = collectLinkRanges(view);
|
|
||||||
const { from, to } = view.state.selection.main;
|
|
||||||
this.cursorLinkPos = getCursorLinkPos(this.linkRanges, from, to);
|
|
||||||
this.decorations = buildDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
|
||||||
|
|
||||||
if (docChanged || viewportChanged) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionSet) {
|
|
||||||
const { from, to } = update.state.selection.main;
|
|
||||||
const newPos = getCursorLinkPos(this.linkRanges, from, to);
|
|
||||||
|
|
||||||
if (newPos !== this.cursorLinkPos) {
|
|
||||||
this.cursorLinkPos = newPos;
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,18 @@
|
|||||||
import {
|
|
||||||
Decoration,
|
|
||||||
DecorationSet,
|
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate,
|
|
||||||
WidgetType
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { Range, RangeSetBuilder, EditorState } from '@codemirror/state';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import { checkRangeOverlap, RangeTuple } from '../util';
|
|
||||||
|
|
||||||
/** Bullet list marker pattern */
|
|
||||||
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists plugin.
|
* List handlers and theme.
|
||||||
*
|
* Handles: ListMark (bullets), Task (checkboxes)
|
||||||
* Features:
|
|
||||||
* - Custom bullet mark rendering (- → •)
|
|
||||||
* - Interactive task list checkboxes
|
|
||||||
*/
|
*/
|
||||||
export const lists = () => [listBulletPlugin, taskListPlugin, baseTheme];
|
|
||||||
|
|
||||||
// ============================================================================
|
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||||
// List Bullet Plugin
|
import { checkRangeOverlap, RangeTuple } from '../util';
|
||||||
// ============================================================================
|
import { SyntaxNode } from '@lezer/common';
|
||||||
|
import { BuildContext } from './types';
|
||||||
|
|
||||||
|
const BULLET_RE = /^[-+*]$/;
|
||||||
|
|
||||||
class ListBulletWidget extends WidgetType {
|
class ListBulletWidget extends WidgetType {
|
||||||
constructor(readonly bullet: string) {
|
constructor(readonly bullet: string) { super(); }
|
||||||
super();
|
eq(other: ListBulletWidget) { return other.bullet === this.bullet; }
|
||||||
}
|
|
||||||
|
|
||||||
eq(other: ListBulletWidget): boolean {
|
|
||||||
return other.bullet === this.bullet;
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-list-bullet';
|
span.className = 'cm-list-bullet';
|
||||||
@@ -43,360 +21,84 @@ class ListBulletWidget extends WidgetType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect all list mark ranges in visible viewport.
|
|
||||||
*/
|
|
||||||
function collectBulletRanges(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, node }) => {
|
|
||||||
if (type.name !== 'ListMark') return;
|
|
||||||
|
|
||||||
// Skip task list items
|
|
||||||
const parent = node.parent;
|
|
||||||
if (parent?.getChild('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]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return ranges;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get which bullet the cursor is in (-1 if none).
|
|
||||||
*/
|
|
||||||
function getCursorBulletPos(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 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 with optimized updates.
|
|
||||||
*/
|
|
||||||
class ListBulletPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private bulletRanges: RangeTuple[] = [];
|
|
||||||
private cursorBulletPos = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.bulletRanges = collectBulletRanges(view);
|
|
||||||
const { from, to } = view.state.selection.main;
|
|
||||||
this.cursorBulletPos = getCursorBulletPos(this.bulletRanges, from, to);
|
|
||||||
this.decorations = buildBulletDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
const { docChanged, viewportChanged, selectionSet } = update;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionSet) {
|
|
||||||
const { from, to } = update.state.selection.main;
|
|
||||||
const newPos = getCursorBulletPos(this.bulletRanges, from, to);
|
|
||||||
|
|
||||||
if (newPos !== this.cursorBulletPos) {
|
|
||||||
this.cursorBulletPos = newPos;
|
|
||||||
this.decorations = buildBulletDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Task List Plugin
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
class TaskCheckboxWidget extends WidgetType {
|
class TaskCheckboxWidget extends WidgetType {
|
||||||
constructor(
|
constructor(readonly checked: boolean, readonly pos: number) { super(); }
|
||||||
readonly checked: boolean,
|
eq(other: TaskCheckboxWidget) { return other.checked === this.checked && other.pos === this.pos; }
|
||||||
readonly pos: number
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(other: TaskCheckboxWidget): boolean {
|
|
||||||
return other.checked === this.checked && other.pos === this.pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(view: EditorView): HTMLElement {
|
toDOM(view: EditorView): HTMLElement {
|
||||||
const wrap = document.createElement('span');
|
const wrap = document.createElement('span');
|
||||||
wrap.setAttribute('aria-hidden', 'true');
|
wrap.setAttribute('aria-hidden', 'true');
|
||||||
wrap.className = 'cm-task-checkbox';
|
wrap.className = 'cm-task-checkbox';
|
||||||
|
|
||||||
const checkbox = document.createElement('input');
|
const checkbox = document.createElement('input');
|
||||||
checkbox.type = 'checkbox';
|
checkbox.type = 'checkbox';
|
||||||
checkbox.checked = this.checked;
|
checkbox.checked = this.checked;
|
||||||
checkbox.tabIndex = -1;
|
checkbox.tabIndex = -1;
|
||||||
|
|
||||||
checkbox.addEventListener('mousedown', (e) => {
|
checkbox.addEventListener('mousedown', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
view.dispatch({ changes: { from: this.pos, to: this.pos + 1, insert: this.checked ? ' ' : 'x' } });
|
||||||
const newValue = !this.checked;
|
|
||||||
view.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: this.pos,
|
|
||||||
to: this.pos + 1,
|
|
||||||
insert: newValue ? 'x' : ' '
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.appendChild(checkbox);
|
wrap.appendChild(checkbox);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
ignoreEvent() { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
/**
|
||||||
return false;
|
* Handle ListMark node (bullet markers).
|
||||||
|
*/
|
||||||
|
export function handleListMark(
|
||||||
|
ctx: BuildContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
const parent = node.parent;
|
||||||
|
if (parent?.getChild('Task')) return;
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
|
const bullet = ctx.view.state.sliceDoc(nf, nt);
|
||||||
|
if (BULLET_RE.test(bullet)) {
|
||||||
|
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new ListBulletWidget(bullet) }) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all task ranges in visible viewport.
|
* Handle Task node (checkboxes).
|
||||||
*/
|
*/
|
||||||
function collectTaskRanges(view: EditorView): RangeTuple[] {
|
export function handleTask(
|
||||||
const ranges: RangeTuple[] = [];
|
ctx: BuildContext,
|
||||||
const seen = new Set<number>();
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
const listItem = node.parent;
|
||||||
|
if (!listItem || listItem.type.name !== 'ListItem') return;
|
||||||
|
const listMark = listItem.getChild('ListMark');
|
||||||
|
const taskMarker = node.getChild('TaskMarker');
|
||||||
|
if (!listMark || !taskMarker) return;
|
||||||
|
if (ctx.seen.has(listMark.from)) return;
|
||||||
|
ctx.seen.add(listMark.from);
|
||||||
|
ranges.push([listMark.from, taskMarker.to]);
|
||||||
|
if (checkRangeOverlap([listMark.from, taskMarker.to], ctx.selRange)) return;
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
const markerText = ctx.view.state.sliceDoc(taskMarker.from, taskMarker.to);
|
||||||
syntaxTree(view.state).iterate({
|
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
||||||
from,
|
if (isChecked) {
|
||||||
to,
|
ctx.items.push({ from: nf, to: nt, deco: Decoration.mark({ class: 'cm-task-checked' }), priority: 0 });
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
ctx.items.push({ from: listMark.from, to: taskMarker.to, deco: Decoration.replace({ widget: new TaskCheckboxWidget(isChecked, taskMarker.from + 1) }), priority: 1 });
|
||||||
return ranges;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get which task the cursor is in (-1 if none).
|
* Theme for lists.
|
||||||
*/
|
*/
|
||||||
function getCursorTaskPos(ranges: RangeTuple[], selFrom: number, selTo: number): number {
|
export const listTheme = EditorView.baseTheme({
|
||||||
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 }) => {
|
|
||||||
if (type.name !== 'Task') return;
|
|
||||||
|
|
||||||
const listItem = node.parent;
|
|
||||||
if (!listItem || listItem.type.name !== 'ListItem') return;
|
|
||||||
|
|
||||||
const listMark = listItem.getChild('ListMark');
|
|
||||||
const taskMarker = node.getChild('TaskMarker');
|
|
||||||
if (!listMark || !taskMarker) return;
|
|
||||||
|
|
||||||
if (seen.has(listMark.from)) return;
|
|
||||||
seen.add(listMark.from);
|
|
||||||
|
|
||||||
const replaceFrom = listMark.from;
|
|
||||||
const replaceTo = taskMarker.to;
|
|
||||||
|
|
||||||
// Skip if cursor is in this range
|
|
||||||
if (checkRangeOverlap([replaceFrom, replaceTo], selRange)) return;
|
|
||||||
|
|
||||||
// Check if task is checked
|
|
||||||
const markerText = view.state.sliceDoc(taskMarker.from, taskMarker.to);
|
|
||||||
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
|
||||||
const checkboxPos = taskMarker.from + 1;
|
|
||||||
|
|
||||||
// Add strikethrough for checked items
|
|
||||||
if (isChecked) {
|
|
||||||
items.push({
|
|
||||||
from: taskFrom,
|
|
||||||
to: taskTo,
|
|
||||||
deco: Decoration.mark({ class: 'cm-task-checked' }),
|
|
||||||
priority: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace "- [x]" or "- [ ]" with checkbox widget
|
|
||||||
items.push({
|
|
||||||
from: replaceFrom,
|
|
||||||
to: replaceTo,
|
|
||||||
deco: Decoration.replace({
|
|
||||||
widget: new TaskCheckboxWidget(isChecked, checkboxPos)
|
|
||||||
}),
|
|
||||||
priority: 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 plugin with optimized updates.
|
|
||||||
*/
|
|
||||||
class TaskListPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private taskRanges: RangeTuple[] = [];
|
|
||||||
private cursorTaskPos = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.taskRanges = collectTaskRanges(view);
|
|
||||||
const { from, to } = view.state.selection.main;
|
|
||||||
this.cursorTaskPos = getCursorTaskPos(this.taskRanges, from, to);
|
|
||||||
this.decorations = buildTaskDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
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
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-list-bullet': {
|
'.cm-list-bullet': {
|
||||||
color: 'var(--cm-list-bullet-color, inherit)'
|
color: 'var(--cm-list-bullet-color, inherit)'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,359 +1,125 @@
|
|||||||
/**
|
/**
|
||||||
* Math plugin for CodeMirror using KaTeX.
|
* Math handlers and theme.
|
||||||
*
|
* Handles: InlineMath, BlockMath
|
||||||
* Features:
|
|
||||||
* - Renders inline math $...$ as inline formula
|
|
||||||
* - Renders block math $$...$$ as block formula
|
|
||||||
* - Block math: lines remain, content hidden, formula overlays on top
|
|
||||||
* - Shows source when cursor is inside
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Extension, Range } from '@codemirror/state';
|
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||||
import { syntaxTree } from '@codemirror/language';
|
import { invisibleDecoration, RangeTuple } from '../util';
|
||||||
import {
|
import { SyntaxNode } from '@lezer/common';
|
||||||
ViewPlugin,
|
import { BuildContext } from './types';
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate,
|
|
||||||
WidgetType
|
|
||||||
} from '@codemirror/view';
|
|
||||||
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 { LruCache } from '@/common/utils/lruCache';
|
|
||||||
|
|
||||||
interface KatexCacheValue {
|
|
||||||
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.
|
|
||||||
* Uses cached KaTeX rendering for performance.
|
|
||||||
*/
|
|
||||||
class InlineMathWidget extends WidgetType {
|
class InlineMathWidget extends WidgetType {
|
||||||
constructor(readonly latex: string) {
|
constructor(readonly latex: string) { super(); }
|
||||||
super();
|
eq(other: InlineMathWidget) { return this.latex === other.latex; }
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'cm-inline-math';
|
span.className = 'cm-inline-math';
|
||||||
|
try {
|
||||||
// Use cached render
|
span.innerHTML = katex.renderToString(this.latex, { throwOnError: true, displayMode: false, output: 'html' });
|
||||||
const { html, error } = renderKatex(this.latex, false);
|
} catch (e) {
|
||||||
|
|
||||||
if (error) {
|
|
||||||
span.textContent = this.latex;
|
span.textContent = this.latex;
|
||||||
span.title = error;
|
span.title = e instanceof Error ? e.message : 'Render error';
|
||||||
} else {
|
|
||||||
span.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
ignoreEvent() { return false; }
|
||||||
eq(other: InlineMathWidget): boolean {
|
|
||||||
return this.latex === other.latex;
|
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget to display block math formula.
|
|
||||||
* Uses absolute positioning to overlay on source lines.
|
|
||||||
*/
|
|
||||||
class BlockMathWidget extends WidgetType {
|
class BlockMathWidget extends WidgetType {
|
||||||
constructor(
|
constructor(readonly latex: string, readonly lineCount: number, readonly lineHeight: number) { super(); }
|
||||||
readonly latex: string,
|
eq(other: BlockMathWidget) { return this.latex === other.latex && this.lineCount === other.lineCount; }
|
||||||
readonly lineCount: number = 1,
|
|
||||||
readonly lineHeight: number = 22
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
toDOM(): HTMLElement {
|
toDOM(): HTMLElement {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'cm-block-math-container';
|
container.className = 'cm-block-math-container';
|
||||||
// Set height to cover all source lines
|
container.style.height = `${this.lineCount * this.lineHeight}px`;
|
||||||
const height = this.lineCount * this.lineHeight;
|
|
||||||
container.style.height = `${height}px`;
|
|
||||||
|
|
||||||
const inner = document.createElement('div');
|
const inner = document.createElement('div');
|
||||||
inner.className = 'cm-block-math';
|
inner.className = 'cm-block-math';
|
||||||
|
try {
|
||||||
// Use cached render
|
inner.innerHTML = katex.renderToString(this.latex, { throwOnError: false, displayMode: true, output: 'html' });
|
||||||
const { html, error } = renderKatex(this.latex, true);
|
} catch (e) {
|
||||||
|
|
||||||
if (error) {
|
|
||||||
inner.textContent = this.latex;
|
inner.textContent = this.latex;
|
||||||
inner.title = error;
|
inner.title = e instanceof Error ? e.message : 'Render error';
|
||||||
} else {
|
|
||||||
inner.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container.appendChild(inner);
|
container.appendChild(inner);
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
ignoreEvent() { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
eq(other: BlockMathWidget): boolean {
|
const DECO_BLOCK_MATH_LINE = Decoration.line({ class: 'cm-block-math-line' });
|
||||||
return this.latex === other.latex && this.lineCount === other.lineCount;
|
const DECO_BLOCK_MATH_HIDDEN = Decoration.mark({ class: 'cm-block-math-content-hidden' });
|
||||||
}
|
|
||||||
|
|
||||||
ignoreEvent(): boolean {
|
/**
|
||||||
return false;
|
* Handle InlineMath node ($...$).
|
||||||
|
*/
|
||||||
|
export function handleInlineMath(
|
||||||
|
ctx: BuildContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
|
const marks = node.getChildren('InlineMathMark');
|
||||||
|
if (marks.length >= 2) {
|
||||||
|
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
|
||||||
|
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
|
||||||
|
ctx.items.push({ from: nt, to: nt, deco: Decoration.widget({ widget: new InlineMathWidget(latex), side: 1 }), priority: 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a math region in the document.
|
* Handle BlockMath node ($$...$$).
|
||||||
*/
|
*/
|
||||||
interface MathRegion {
|
export function handleBlockMath(
|
||||||
from: number;
|
ctx: BuildContext,
|
||||||
to: number;
|
nf: number,
|
||||||
}
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (inCursor) return;
|
||||||
|
|
||||||
/**
|
const marks = node.getChildren('BlockMathMark');
|
||||||
* Result of building decorations, includes math regions for cursor tracking.
|
if (marks.length >= 2) {
|
||||||
*/
|
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
|
||||||
interface BuildResult {
|
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||||
decorations: DecorationSet;
|
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||||
mathRegions: MathRegion[];
|
const lineCount = endLine.number - startLine.number + 1;
|
||||||
}
|
if (lineCount > 1) {
|
||||||
|
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||||
/**
|
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_BLOCK_MATH_LINE });
|
||||||
* 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.
|
|
||||||
* Also collects math regions for cursor tracking optimization.
|
|
||||||
*/
|
|
||||||
function buildDecorations(view: EditorView): BuildResult {
|
|
||||||
const decorations: Range<Decoration>[] = [];
|
|
||||||
const mathRegions: MathRegion[] = [];
|
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
|
||||||
syntaxTree(view.state).iterate({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
|
||||||
// Handle inline math
|
|
||||||
if (type.name === 'InlineMath') {
|
|
||||||
// Collect math region for cursor tracking
|
|
||||||
mathRegions.push({ from: nodeFrom, to: nodeTo });
|
|
||||||
|
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
|
||||||
const marks = node.getChildren('InlineMathMark');
|
|
||||||
|
|
||||||
if (!cursorInRange && marks.length >= 2) {
|
|
||||||
// Get latex content (without $ marks)
|
|
||||||
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
|
|
||||||
|
|
||||||
// Hide the entire syntax
|
|
||||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
|
||||||
|
|
||||||
// Add widget at the end
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget({
|
|
||||||
widget: new InlineMathWidget(latex),
|
|
||||||
side: 1
|
|
||||||
}).range(nodeTo)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle block math ($$...$$)
|
|
||||||
if (type.name === 'BlockMath') {
|
|
||||||
// Collect math region for cursor tracking
|
|
||||||
mathRegions.push({ from: nodeFrom, to: nodeTo });
|
|
||||||
|
|
||||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
|
||||||
const marks = node.getChildren('BlockMathMark');
|
|
||||||
|
|
||||||
if (!cursorInRange && marks.length >= 2) {
|
|
||||||
// Get latex content (without $$ marks)
|
|
||||||
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
|
|
||||||
|
|
||||||
// Calculate line info
|
|
||||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
|
||||||
const endLine = view.state.doc.lineAt(nodeTo);
|
|
||||||
const lineCount = endLine.number - startLine.number + 1;
|
|
||||||
const lineHeight = view.defaultLineHeight;
|
|
||||||
|
|
||||||
// Check if block math spans multiple lines
|
|
||||||
const hasLineBreak = lineCount > 1;
|
|
||||||
|
|
||||||
if (hasLineBreak) {
|
|
||||||
// For multi-line: use line decorations to hide content
|
|
||||||
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
|
||||||
const line = view.state.doc.line(lineNum);
|
|
||||||
decorations.push(
|
|
||||||
Decoration.line({
|
|
||||||
class: 'cm-block-math-line'
|
|
||||||
}).range(line.from)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add widget on the first line (positioned absolutely)
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget({
|
|
||||||
widget: new BlockMathWidget(latex, lineCount, lineHeight),
|
|
||||||
side: -1
|
|
||||||
}).range(startLine.from)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Single line: make content transparent, overlay widget
|
|
||||||
decorations.push(
|
|
||||||
Decoration.mark({
|
|
||||||
class: 'cm-block-math-content-hidden'
|
|
||||||
}).range(nodeFrom, nodeTo)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add widget at the start (positioned absolutely)
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget({
|
|
||||||
widget: new BlockMathWidget(latex, 1, lineHeight),
|
|
||||||
side: -1
|
|
||||||
}).range(nodeFrom)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
decorations: Decoration.set(decorations, true),
|
|
||||||
mathRegions
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Math plugin with optimized update detection.
|
|
||||||
*/
|
|
||||||
class MathPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private mathRegions: MathRegion[] = [];
|
|
||||||
private lastSelectionHead: number = -1;
|
|
||||||
private lastMathRegionIndex: number = -1;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
const result = buildDecorations(view);
|
|
||||||
this.decorations = result.decorations;
|
|
||||||
this.mathRegions = result.mathRegions;
|
|
||||||
this.lastSelectionHead = view.state.selection.main.head;
|
|
||||||
this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
// Always rebuild on document change or viewport change
|
|
||||||
if (update.docChanged || update.viewportChanged) {
|
|
||||||
const result = buildDecorations(update.view);
|
|
||||||
this.decorations = result.decorations;
|
|
||||||
this.mathRegions = result.mathRegions;
|
|
||||||
this.lastSelectionHead = update.state.selection.main.head;
|
|
||||||
this.lastMathRegionIndex = findMathRegionIndex(this.lastSelectionHead, this.mathRegions);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For selection changes, only rebuild if cursor changes math region context
|
|
||||||
if (update.selectionSet) {
|
|
||||||
const newHead = update.state.selection.main.head;
|
|
||||||
|
|
||||||
if (newHead !== this.lastSelectionHead) {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new BlockMathWidget(latex, lineCount, ctx.lineHeight), side: -1 }), priority: -1 });
|
||||||
|
} else {
|
||||||
|
ctx.items.push({ from: nf, to: nt, deco: DECO_BLOCK_MATH_HIDDEN });
|
||||||
|
ctx.items.push({ from: nf, to: nf, deco: Decoration.widget({ widget: new BlockMathWidget(latex, 1, ctx.lineHeight), side: -1 }), priority: -1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mathPlugin = ViewPlugin.fromClass(
|
|
||||||
MathPlugin,
|
|
||||||
{
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base theme for math.
|
* Theme for math.
|
||||||
*/
|
*/
|
||||||
const baseTheme = EditorView.baseTheme({
|
export const mathTheme = EditorView.baseTheme({
|
||||||
// Inline math
|
|
||||||
'.cm-inline-math': {
|
'.cm-inline-math': {
|
||||||
display: 'inline',
|
display: 'inline',
|
||||||
verticalAlign: 'baseline',
|
verticalAlign: 'baseline'
|
||||||
},
|
},
|
||||||
'.cm-inline-math .katex': {
|
'.cm-inline-math .katex': {
|
||||||
fontSize: 'inherit',
|
fontSize: 'inherit'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Block math container - absolute positioned to overlay on source
|
|
||||||
'.cm-block-math-container': {
|
'.cm-block-math-container': {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '0',
|
left: '0',
|
||||||
@@ -362,61 +128,36 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: '1',
|
zIndex: '1'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Block math inner
|
|
||||||
'.cm-block-math': {
|
'.cm-block-math': {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto'
|
||||||
},
|
},
|
||||||
'.cm-block-math .katex-display': {
|
'.cm-block-math .katex-display': {
|
||||||
margin: '0',
|
margin: '0'
|
||||||
},
|
},
|
||||||
'.cm-block-math .katex': {
|
'.cm-block-math .katex': {
|
||||||
fontSize: '1.1em',
|
fontSize: '1.1em'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Hidden line content for block math (text transparent but line preserved)
|
|
||||||
// Use high specificity to override rainbow brackets and other plugins
|
|
||||||
'.cm-line.cm-block-math-line': {
|
'.cm-line.cm-block-math-line': {
|
||||||
color: 'transparent !important',
|
color: 'transparent !important',
|
||||||
caretColor: 'transparent',
|
caretColor: 'transparent'
|
||||||
},
|
},
|
||||||
'.cm-line.cm-block-math-line span': {
|
'.cm-line.cm-block-math-line span': {
|
||||||
color: 'transparent !important',
|
color: 'transparent !important'
|
||||||
},
|
},
|
||||||
// Override rainbow brackets in hidden math lines
|
|
||||||
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
|
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
|
||||||
color: 'transparent !important',
|
color: 'transparent !important'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Hidden content for single-line block math
|
|
||||||
'.cm-block-math-content-hidden': {
|
'.cm-block-math-content-hidden': {
|
||||||
color: 'transparent !important',
|
color: 'transparent !important'
|
||||||
},
|
},
|
||||||
'.cm-block-math-content-hidden span': {
|
'.cm-block-math-content-hidden span': {
|
||||||
color: 'transparent !important',
|
color: 'transparent !important'
|
||||||
},
|
},
|
||||||
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
|
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
|
||||||
color: 'transparent !important',
|
color: 'transparent !important'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Math extension.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Parses inline math $...$ and block math $$...$$
|
|
||||||
* - Renders formulas using KaTeX
|
|
||||||
* - Block math preserves line structure, overlays rendered formula
|
|
||||||
* - Shows source when cursor is inside
|
|
||||||
*/
|
|
||||||
export const math = (): Extension => [
|
|
||||||
mathPlugin,
|
|
||||||
baseTheme
|
|
||||||
];
|
|
||||||
|
|
||||||
export default math;
|
|
||||||
|
|
||||||
|
|||||||
253
frontend/src/views/editor/extensions/markdown/plugins/render.ts
Normal file
253
frontend/src/views/editor/extensions/markdown/plugins/render.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
DecorationSet,
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewUpdate
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { SyntaxNodeRef } from '@lezer/common';
|
||||||
|
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
|
||||||
|
import { DecoItem } from './types';
|
||||||
|
import { blockState } from '@/views/editor/extensions/codeblock/state';
|
||||||
|
import { Block } from '@/views/editor/extensions/codeblock/types';
|
||||||
|
|
||||||
|
import { handleBlockquote } from './blockquote';
|
||||||
|
import { handleCodeBlock } from './code-block';
|
||||||
|
import { handleATXHeading, handleSetextHeading } from './heading';
|
||||||
|
import { handleHorizontalRule } from './horizontal-rule';
|
||||||
|
import { handleHighlight, handleInlineCode, handleEmphasis, handleInsert, handleScript } from './inline-styles';
|
||||||
|
import { handleURL } from './link';
|
||||||
|
import { handleListMark, handleTask } from './list';
|
||||||
|
import { handleFootnoteDefinition, handleFootnoteReference, handleInlineFootnote, processPendingFootnotes, FootnoteContext } from './footnote';
|
||||||
|
import { handleInlineMath, handleBlockMath } from './math';
|
||||||
|
import { handleEmoji } from './emoji';
|
||||||
|
import { handleTable } from './table';
|
||||||
|
|
||||||
|
|
||||||
|
interface BuildResult {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
trackedRanges: RangeTuple[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get markdown block ranges from visible ranges.
|
||||||
|
* Only returns ranges that are within 'md' language blocks.
|
||||||
|
*/
|
||||||
|
function getMdBlockRanges(view: EditorView): { from: number; to: number }[] {
|
||||||
|
const blocks = view.state.field(blockState, false);
|
||||||
|
if (!blocks || blocks.length === 0) {
|
||||||
|
// No blocks, treat entire document as md
|
||||||
|
return view.visibleRanges.map(r => ({ from: r.from, to: r.to }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter md blocks
|
||||||
|
const mdBlocks = blocks.filter((b: Block) => b.language.name === 'md');
|
||||||
|
if (mdBlocks.length === 0) return [];
|
||||||
|
|
||||||
|
// Intersect visible ranges with md block content ranges
|
||||||
|
const result: { from: number; to: number }[] = [];
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
for (const block of mdBlocks) {
|
||||||
|
const intersectFrom = Math.max(from, block.content.from);
|
||||||
|
const intersectTo = Math.min(to, block.content.to);
|
||||||
|
if (intersectFrom < intersectTo) {
|
||||||
|
result.push({ from: intersectFrom, to: intersectTo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function buildDecorationsAndRanges(view: EditorView): BuildResult {
|
||||||
|
const { from: selFrom, to: selTo } = view.state.selection.main;
|
||||||
|
|
||||||
|
// Create context with footnote extensions
|
||||||
|
const ctx: FootnoteContext = {
|
||||||
|
view,
|
||||||
|
items: [],
|
||||||
|
selRange: [selFrom, selTo],
|
||||||
|
seen: new Set(),
|
||||||
|
processedLines: new Set(),
|
||||||
|
contentWidth: view.contentDOM.clientWidth - 10,
|
||||||
|
lineHeight: view.defaultLineHeight,
|
||||||
|
// Footnote state
|
||||||
|
definitionIds: new Set(),
|
||||||
|
pendingRefs: [],
|
||||||
|
pendingInlines: [],
|
||||||
|
seenIds: new Map(),
|
||||||
|
inlineFootnoteIdx: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackedRanges: RangeTuple[] = [];
|
||||||
|
|
||||||
|
// Only traverse md blocks (not other language blocks like js, py, etc.)
|
||||||
|
const mdRanges = getMdBlockRanges(view);
|
||||||
|
|
||||||
|
// Single traversal - dispatch to all handlers
|
||||||
|
for (const { from, to } of mdRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from, to,
|
||||||
|
enter: (nodeRef: SyntaxNodeRef) => {
|
||||||
|
const { type, from: nf, to: nt, node } = nodeRef;
|
||||||
|
const typeName = type.name;
|
||||||
|
const inCursor = checkRangeOverlap([nf, nt], ctx.selRange);
|
||||||
|
|
||||||
|
// Dispatch to handlers
|
||||||
|
if (typeName === 'Blockquote') return handleBlockquote(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'FencedCode' || typeName === 'CodeBlock') return handleCodeBlock(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName.startsWith('ATXHeading')) return handleATXHeading(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName.startsWith('SetextHeading')) return handleSetextHeading(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'HorizontalRule') return handleHorizontalRule(ctx, nf, nt, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'Highlight') return handleHighlight(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'InlineCode') return handleInlineCode(ctx, nf, nt, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'Emphasis' || typeName === 'StrongEmphasis' || typeName === 'Strikethrough') return handleEmphasis(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'Insert') return handleInsert(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'Superscript' || typeName === 'Subscript') return handleScript(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'URL') return handleURL(ctx, nf, nt, node, trackedRanges);
|
||||||
|
if (typeName === 'ListMark') return handleListMark(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'Task') return handleTask(ctx, nf, nt, node, trackedRanges);
|
||||||
|
if (typeName === 'FootnoteDefinition') return handleFootnoteDefinition(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'FootnoteReference') return handleFootnoteReference(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'InlineFootnote') return handleInlineFootnote(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'InlineMath') return handleInlineMath(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'BlockMath') return handleBlockMath(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'Emoji') return handleEmoji(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
if (typeName === 'Table') return handleTable(ctx, nf, nt, node, inCursor, trackedRanges);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process pending footnotes
|
||||||
|
processPendingFootnotes(ctx);
|
||||||
|
|
||||||
|
// Sort and filter
|
||||||
|
ctx.items.sort((a, b) => {
|
||||||
|
if (a.from !== b.from) return a.from - b.from;
|
||||||
|
if (a.to !== b.to) return a.to - b.to;
|
||||||
|
return (a.priority || 0) - (b.priority || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: DecoItem[] = [];
|
||||||
|
let replaceMaxTo = -1;
|
||||||
|
for (const item of ctx.items) {
|
||||||
|
const isReplace = item.deco.spec?.widget !== undefined || item.deco === invisibleDecoration;
|
||||||
|
if (item.from === item.to) {
|
||||||
|
result.push(item);
|
||||||
|
} else if (isReplace) {
|
||||||
|
if (item.from >= replaceMaxTo) {
|
||||||
|
result.push(item);
|
||||||
|
replaceMaxTo = item.to;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorations: Decoration.set(result.map(r => r.deco.range(r.from, r.to)), true),
|
||||||
|
trackedRanges
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownRenderPlugin {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
private trackedRanges: RangeTuple[] = [];
|
||||||
|
private lastSelFrom = -1;
|
||||||
|
private lastSelTo = -1;
|
||||||
|
private lastWidth = 0;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
const result = buildDecorationsAndRanges(view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.trackedRanges = result.trackedRanges;
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
this.lastSelFrom = from;
|
||||||
|
this.lastSelTo = to;
|
||||||
|
this.lastWidth = view.contentDOM.clientWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const { docChanged, viewportChanged, selectionSet, geometryChanged } = update;
|
||||||
|
const widthChanged = Math.abs(update.view.contentDOM.clientWidth - this.lastWidth) > 1;
|
||||||
|
if (widthChanged) this.lastWidth = update.view.contentDOM.clientWidth;
|
||||||
|
|
||||||
|
// Full rebuild for structural changes
|
||||||
|
if (docChanged || viewportChanged || geometryChanged || widthChanged) {
|
||||||
|
const result = buildDecorationsAndRanges(update.view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.trackedRanges = result.trackedRanges;
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
this.lastSelFrom = from;
|
||||||
|
this.lastSelTo = to;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection change handling with fine-grained detection
|
||||||
|
if (selectionSet) {
|
||||||
|
const { from, to } = update.state.selection.main;
|
||||||
|
const isPointCursor = from === to;
|
||||||
|
const wasPointCursor = this.lastSelFrom === this.lastSelTo;
|
||||||
|
|
||||||
|
// Optimization: Point cursor moving within same tracked range - no rebuild needed
|
||||||
|
if (isPointCursor && wasPointCursor) {
|
||||||
|
const oldRange = this.findContainingRange(this.lastSelFrom);
|
||||||
|
const newRange = this.findContainingRange(from);
|
||||||
|
|
||||||
|
if (this.rangeSame(oldRange, newRange)) {
|
||||||
|
this.lastSelFrom = from;
|
||||||
|
this.lastSelTo = to;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if overlapping ranges changed
|
||||||
|
const oldOverlaps = this.getOverlappingRanges(this.lastSelFrom, this.lastSelTo);
|
||||||
|
const newOverlaps = this.getOverlappingRanges(from, to);
|
||||||
|
|
||||||
|
this.lastSelFrom = from;
|
||||||
|
this.lastSelTo = to;
|
||||||
|
|
||||||
|
if (!this.rangesSame(oldOverlaps, newOverlaps)) {
|
||||||
|
const result = buildDecorationsAndRanges(update.view);
|
||||||
|
this.decorations = result.decorations;
|
||||||
|
this.trackedRanges = result.trackedRanges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findContainingRange(pos: number): RangeTuple | null {
|
||||||
|
for (const range of this.trackedRanges) {
|
||||||
|
if (pos >= range[0] && pos <= range[1]) return range;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rangeSame(a: RangeTuple | null, b: RangeTuple | null): boolean {
|
||||||
|
if (a === null && b === null) return true;
|
||||||
|
if (a === null || b === null) return false;
|
||||||
|
return a[0] === b[0] && a[1] === b[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOverlappingRanges(from: number, to: number): RangeTuple[] {
|
||||||
|
const selRange: RangeTuple = [from, to];
|
||||||
|
return this.trackedRanges.filter(r => checkRangeOverlap(r, selRange));
|
||||||
|
}
|
||||||
|
|
||||||
|
private rangesSame(a: RangeTuple[], b: RangeTuple[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPlugin = ViewPlugin.fromClass(MarkdownRenderPlugin, {
|
||||||
|
decorations: (v) => v.decorations
|
||||||
|
});
|
||||||
|
|
||||||
|
export const render = (): Extension => [renderPlugin];
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
|
||||||
import { syntaxTree } from '@codemirror/language';
|
|
||||||
import {
|
|
||||||
ViewPlugin,
|
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate
|
|
||||||
} from '@codemirror/view';
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* - Superscript: ^text^ → renders as superscript
|
|
||||||
* - Subscript: ~text~ → renders as subscript
|
|
||||||
*
|
|
||||||
* Note: Inline footnotes ^[content] are handled by the Footnote extension.
|
|
||||||
*/
|
|
||||||
export const subscriptSuperscript = (): Extension => [
|
|
||||||
subscriptSuperscriptPlugin,
|
|
||||||
baseTheme
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Node types to handle */
|
|
||||||
const SCRIPT_TYPES = new Set(['Superscript', 'Subscript']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
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, node }) => {
|
|
||||||
if (!SCRIPT_TYPES.has(type.name)) return;
|
|
||||||
if (seen.has(nodeFrom)) return;
|
|
||||||
seen.add(nodeFrom);
|
|
||||||
|
|
||||||
// Skip if cursor is in this element
|
|
||||||
if (checkRangeOverlap([nodeFrom, nodeTo], selRange)) return;
|
|
||||||
|
|
||||||
const isSuperscript = type.name === 'Superscript';
|
|
||||||
const markName = isSuperscript ? 'SuperscriptMark' : 'SubscriptMark';
|
|
||||||
const contentDeco = isSuperscript ? superscriptMarkDecoration : subscriptMarkDecoration;
|
|
||||||
|
|
||||||
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 contentEnd = marks[marks.length - 1].from;
|
|
||||||
if (contentStart < contentEnd) {
|
|
||||||
items.push({ from: contentStart, to: contentEnd, deco: contentDeco });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide closing mark
|
|
||||||
items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscript/Superscript plugin with optimized updates.
|
|
||||||
*/
|
|
||||||
class SubscriptSuperscriptPlugin {
|
|
||||||
decorations: DecorationSet;
|
|
||||||
private scriptRanges: RangeTuple[] = [];
|
|
||||||
private cursorScriptPos = -1;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
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);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectionSet) {
|
|
||||||
const { from, to } = update.state.selection.main;
|
|
||||||
const newPos = getCursorScriptPos(this.scriptRanges, from, to);
|
|
||||||
|
|
||||||
if (newPos !== this.cursorScriptPos) {
|
|
||||||
this.cursorScriptPos = newPos;
|
|
||||||
this.decorations = buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
|
|
||||||
SubscriptSuperscriptPlugin,
|
|
||||||
{
|
|
||||||
decorations: (v) => v.decorations
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base theme for subscript and superscript.
|
|
||||||
*/
|
|
||||||
const baseTheme = EditorView.baseTheme({
|
|
||||||
'.cm-superscript': {
|
|
||||||
verticalAlign: 'super',
|
|
||||||
fontSize: '0.75em',
|
|
||||||
color: 'var(--cm-superscript-color, inherit)'
|
|
||||||
},
|
|
||||||
'.cm-subscript': {
|
|
||||||
verticalAlign: 'sub',
|
|
||||||
fontSize: '0.75em',
|
|
||||||
color: 'var(--cm-subscript-color, inherit)'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,262 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Table plugin for CodeMirror.
|
* Table handler and theme.
|
||||||
*
|
|
||||||
* 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 { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||||
import { syntaxTree, foldedRanges } from '@codemirror/language';
|
import { foldedRanges } from '@codemirror/language';
|
||||||
import {
|
import { RangeTuple } from '../util';
|
||||||
ViewPlugin,
|
|
||||||
DecorationSet,
|
|
||||||
Decoration,
|
|
||||||
EditorView,
|
|
||||||
ViewUpdate,
|
|
||||||
WidgetType
|
|
||||||
} from '@codemirror/view';
|
|
||||||
import { SyntaxNode } from '@lezer/common';
|
import { SyntaxNode } from '@lezer/common';
|
||||||
import { isCursorInRange } from '../util';
|
import { BuildContext } from './types';
|
||||||
import { LruCache } from '@/common/utils/lruCache';
|
|
||||||
import { generateContentHash } from '@/common/utils/hashUtils';
|
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Types and Interfaces
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/** Cell alignment type */
|
|
||||||
type CellAlign = 'left' | 'center' | 'right';
|
type CellAlign = 'left' | 'center' | 'right';
|
||||||
|
interface TableData { headers: string[]; alignments: CellAlign[]; rows: string[][]; }
|
||||||
|
|
||||||
/** Parsed table data */
|
const DECO_TABLE_LINE_HIDDEN = Decoration.line({ class: 'cm-table-line-hidden' });
|
||||||
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_STAR_RE = /\*\*(.+?)\*\*/g;
|
||||||
const BOLD_UNDER_RE = /__(.+?)__/g;
|
const BOLD_UNDER_RE = /__(.+?)__/g;
|
||||||
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
|
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
|
||||||
@@ -264,426 +21,166 @@ const ITALIC_UNDER_RE = /(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g;
|
|||||||
const CODE_RE = /`([^`]+)`/g;
|
const CODE_RE = /`([^`]+)`/g;
|
||||||
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
|
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
const STRIKE_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][^>]*>/;
|
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 {
|
function renderInlineMarkdown(text: string): string {
|
||||||
// Check cache first
|
|
||||||
const cached = inlineRenderCache.get(text);
|
|
||||||
if (cached !== undefined) return cached;
|
|
||||||
|
|
||||||
let html = text;
|
let html = text;
|
||||||
|
if (HTML_TAG_RE.test(text)) {
|
||||||
// Check if text contains HTML tags
|
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||||
const hasHTMLTags = HTML_TAG_RE.test(text);
|
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||||
|
if (!html.includes('<code>')) html = html.replace(CODE_RE, '<code>$1</code>');
|
||||||
if (hasHTMLTags) {
|
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
|
||||||
// If contains HTML tags, process markdown first without escaping < >
|
html = DOMPurify.sanitize(html, { ADD_TAGS: ['code', 'strong', 'em', 'del', 'a'], ADD_ATTR: ['href', 'target'] });
|
||||||
// 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 {
|
} else {
|
||||||
// No HTML tags - escape < > and process markdown
|
|
||||||
html = html.replace(/</g, '<').replace(/>/g, '>');
|
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
|
||||||
// Bold: **text** or __text__
|
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
|
||||||
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>');
|
html = html.replace(CODE_RE, '<code>$1</code>');
|
||||||
|
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
|
||||||
// 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;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRowText(rowText: string): string[] {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let start = 0, end = rowText.length;
|
||||||
|
while (start < end && rowText.charCodeAt(start) <= 32) start++;
|
||||||
|
while (end > start && rowText.charCodeAt(end - 1) <= 32) end--;
|
||||||
|
if (start < end && rowText.charCodeAt(start) === 124) start++;
|
||||||
|
if (end > start && rowText.charCodeAt(end - 1) === 124) end--;
|
||||||
|
let cellStart = start;
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
if (i === end || rowText.charCodeAt(i) === 124) {
|
||||||
|
let cs = cellStart, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAlignment(text: string): CellAlign {
|
||||||
|
const len = text.length;
|
||||||
|
if (len === 0) return 'left';
|
||||||
|
let start = 0, end = len - 1;
|
||||||
|
while (start < len && text.charCodeAt(start) === 32) start++;
|
||||||
|
while (end > start && text.charCodeAt(end) === 32) end--;
|
||||||
|
if (start > end) return 'left';
|
||||||
|
const hasLeft = text.charCodeAt(start) === 58;
|
||||||
|
const hasRight = text.charCodeAt(end) === 58;
|
||||||
|
if (hasLeft && hasRight) return 'center';
|
||||||
|
if (hasRight) return 'right';
|
||||||
|
return 'left';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Widget to display rendered table.
|
|
||||||
* Uses absolute positioning to overlay on source lines.
|
|
||||||
* Optimized with innerHTML for faster DOM creation.
|
|
||||||
*/
|
|
||||||
class TableWidget extends WidgetType {
|
class TableWidget extends WidgetType {
|
||||||
// Cache the generated HTML to avoid regenerating on each toDOM call
|
constructor(readonly data: TableData, readonly lineCount: number, readonly visualHeight: number, readonly contentWidth: number) { super(); }
|
||||||
private cachedHTML: string | null = null;
|
eq(other: TableWidget) {
|
||||||
|
if (this.visualHeight !== other.visualHeight || this.contentWidth !== other.contentWidth) return false;
|
||||||
constructor(
|
if (this.data === other.data) return true;
|
||||||
readonly tableData: TableData,
|
if (this.data.headers.length !== other.data.headers.length || this.data.rows.length !== other.data.rows.length) return false;
|
||||||
readonly lineCount: number,
|
for (let i = 0; i < this.data.headers.length; i++) if (this.data.headers[i] !== other.data.headers[i]) return false;
|
||||||
readonly lineHeight: number,
|
for (let i = 0; i < this.data.rows.length; i++) {
|
||||||
readonly visualHeight: number,
|
if (this.data.rows[i].length !== other.data.rows[i].length) return false;
|
||||||
readonly contentWidth: number
|
for (let j = 0; j < this.data.rows[i].length; j++) if (this.data.rows[i][j] !== other.data.rows[i][j]) return false;
|
||||||
) {
|
}
|
||||||
super();
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
toDOM(): HTMLElement {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'cm-table-container';
|
container.className = 'cm-table-container';
|
||||||
container.style.height = `${this.visualHeight}px`;
|
container.style.height = `${this.visualHeight}px`;
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
const tableWrapper = document.createElement('div');
|
wrapper.className = 'cm-table-wrapper';
|
||||||
tableWrapper.className = 'cm-table-wrapper';
|
wrapper.style.maxWidth = `${this.contentWidth}px`;
|
||||||
tableWrapper.style.maxWidth = `${this.contentWidth}px`;
|
wrapper.style.maxHeight = `${this.visualHeight}px`;
|
||||||
tableWrapper.style.maxHeight = `${this.visualHeight}px`;
|
const headerRatio = 2 / this.lineCount, dataRowRatio = 1 / this.lineCount;
|
||||||
|
const headerHeight = this.visualHeight * headerRatio, dataRowHeight = this.visualHeight * dataRowRatio;
|
||||||
// Use innerHTML for faster DOM creation (single parse vs many createElement calls)
|
const headerCells = this.data.headers.map((h, i) => `<th class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${h.replace(/"/g, '"')}">${renderInlineMarkdown(h)}</th>`).join('');
|
||||||
tableWrapper.innerHTML = this.buildTableHTML();
|
const bodyRows = this.data.rows.map(row => `<tr style="height:${dataRowHeight}px">${row.map((c, i) => `<td class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${c.replace(/"/g, '"')}">${renderInlineMarkdown(c)}</td>`).join('')}</tr>`).join('');
|
||||||
|
wrapper.innerHTML = `<table class="cm-table"><thead><tr style="height:${headerHeight}px">${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
|
||||||
container.appendChild(tableWrapper);
|
container.appendChild(wrapper);
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
ignoreEvent() { return false; }
|
||||||
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 {
|
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
|
||||||
const folded = foldedRanges(view.state);
|
const folded = foldedRanges(view.state);
|
||||||
const cursor = folded.iter();
|
const cursor = folded.iter();
|
||||||
while (cursor.value) {
|
while (cursor.value) {
|
||||||
// Check if ranges overlap
|
if (cursor.from < to && cursor.to > from) return true;
|
||||||
if (cursor.from < to && cursor.to > from) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
cursor.next();
|
cursor.next();
|
||||||
}
|
}
|
||||||
return false;
|
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.
|
* Handle Table node.
|
||||||
* Optimized: single syntax tree traversal instead of two separate ones.
|
|
||||||
*/
|
*/
|
||||||
function buildDecorationsAndRanges(view: EditorView): BuildResult {
|
export function handleTable(
|
||||||
const decorations: Range<Decoration>[] = [];
|
ctx: BuildContext,
|
||||||
const tableRanges: TableRange[] = [];
|
nf: number,
|
||||||
const contentWidth = view.contentDOM.clientWidth - 10;
|
nt: number,
|
||||||
const lineHeight = view.defaultLineHeight;
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
): void {
|
||||||
|
if (ctx.seen.has(nf)) return;
|
||||||
|
ctx.seen.add(nf);
|
||||||
|
ranges.push([nf, nt]);
|
||||||
|
if (isInFoldedRange(ctx.view, nf, nt) || inCursor) return;
|
||||||
|
|
||||||
// Pre-create the line decoration to reuse (same class for all hidden lines)
|
const headerNode = node.getChild('TableHeader');
|
||||||
const hiddenLineDecoration = Decoration.line({ class: 'cm-table-line-hidden' });
|
if (!headerNode) return;
|
||||||
|
const headers: string[] = [];
|
||||||
for (const { from, to } of view.visibleRanges) {
|
const alignments: CellAlign[] = [];
|
||||||
syntaxTree(view.state).iterate({
|
const rows: string[][] = [];
|
||||||
from,
|
const headerCells = headerNode.getChildren('TableCell');
|
||||||
to,
|
if (headerCells.length > 0) {
|
||||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
for (const cell of headerCells) headers.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim());
|
||||||
if (type.name !== 'Table') return;
|
} else {
|
||||||
|
headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to)));
|
||||||
// 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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (headers.length === 0) return;
|
||||||
return {
|
let child = node.firstChild;
|
||||||
decorations: Decoration.set(decorations, true),
|
while (child) {
|
||||||
tableRanges
|
if (child.type.name === 'TableDelimiter') {
|
||||||
};
|
const delimText = ctx.view.state.sliceDoc(child.from, child.to);
|
||||||
}
|
if (delimText.includes('-')) {
|
||||||
|
for (const part of parseRowText(delimText)) if (part.includes('-')) alignments.push(parseAlignment(part));
|
||||||
// ============================================================================
|
break;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
child = child.nextSibling;
|
||||||
}
|
}
|
||||||
return -1;
|
while (alignments.length < headers.length) alignments.push('left');
|
||||||
|
for (const rowNode of node.getChildren('TableRow')) {
|
||||||
|
const rowData: string[] = [];
|
||||||
|
const cells = rowNode.getChildren('TableCell');
|
||||||
|
if (cells.length > 0) { for (const cell of cells) rowData.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim()); }
|
||||||
|
else { rowData.push(...parseRowText(ctx.view.state.sliceDoc(rowNode.from, rowNode.to))); }
|
||||||
|
while (rowData.length < headers.length) rowData.push('');
|
||||||
|
rows.push(rowData);
|
||||||
|
}
|
||||||
|
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||||
|
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||||
|
const lineCount = endLine.number - startLine.number + 1;
|
||||||
|
const startBlock = ctx.view.lineBlockAt(nf);
|
||||||
|
const endBlock = ctx.view.lineBlockAt(nt);
|
||||||
|
const visualHeight = endBlock.bottom - startBlock.top;
|
||||||
|
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||||
|
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_TABLE_LINE_HIDDEN });
|
||||||
|
}
|
||||||
|
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new TableWidget({ headers, alignments, rows }, lineCount, visualHeight, ctx.contentWidth), side: -1 }), priority: -1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table plugin with optimized update detection.
|
* Theme for tables.
|
||||||
*
|
|
||||||
* 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 {
|
export const tableTheme = EditorView.baseTheme({
|
||||||
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': {
|
'.cm-table-container': {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -691,19 +188,15 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: '2',
|
zIndex: '2',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Table wrapper - scrollable when needed
|
|
||||||
'.cm-table-wrapper': {
|
'.cm-table-wrapper': {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
backgroundColor: 'var(--bg-primary)',
|
backgroundColor: 'var(--bg-primary)',
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Table styles - use inset box-shadow for outer border (not clipped by overflow)
|
|
||||||
'.cm-table': {
|
'.cm-table': {
|
||||||
borderCollapse: 'separate',
|
borderCollapse: 'separate',
|
||||||
borderSpacing: '0',
|
borderSpacing: '0',
|
||||||
@@ -713,9 +206,8 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
backgroundColor: 'var(--cm-table-bg)',
|
backgroundColor: 'var(--cm-table-bg)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
|
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
|
||||||
color: 'var(--text-primary) !important',
|
color: 'var(--text-primary) !important'
|
||||||
},
|
},
|
||||||
|
|
||||||
'.cm-table th, .cm-table td': {
|
'.cm-table th, .cm-table td': {
|
||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@@ -725,109 +217,35 @@ const baseTheme = EditorView.baseTheme({
|
|||||||
fontSize: 'inherit',
|
fontSize: 'inherit',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
lineHeight: 'inherit',
|
lineHeight: 'inherit',
|
||||||
// Prevent text wrapping to maintain row height
|
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
maxWidth: '300px',
|
maxWidth: '300px'
|
||||||
},
|
},
|
||||||
|
'.cm-table td': { boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)' },
|
||||||
// Data cells: left divider + bottom divider
|
'.cm-table td:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
|
||||||
'.cm-table td': {
|
'.cm-table tbody tr:last-child td': { boxShadow: '-1px 0 0 var(--cm-table-border)' },
|
||||||
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
|
'.cm-table tbody tr:last-child td:first-child': { boxShadow: 'none' },
|
||||||
},
|
|
||||||
|
|
||||||
// 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': {
|
'.cm-table th': {
|
||||||
backgroundColor: 'var(--cm-table-header-bg)',
|
backgroundColor: 'var(--cm-table-header-bg)',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
// Header cells: left divider + bottom divider
|
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)'
|
||||||
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)',
|
|
||||||
},
|
},
|
||||||
|
'.cm-table th:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
|
||||||
'.cm-table th:first-child': {
|
'.cm-table tbody tr:hover': { backgroundColor: 'var(--cm-table-row-hover)' },
|
||||||
// First header cell: only bottom divider
|
'.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': { textAlign: 'left' },
|
||||||
boxShadow: '0 1px 0 var(--cm-table-border)',
|
'.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' },
|
||||||
|
|
||||||
'.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': {
|
'.cm-table code': {
|
||||||
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
|
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
|
||||||
padding: '1px 4px',
|
padding: '1px 4px',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
fontSize: 'inherit',
|
fontSize: 'inherit',
|
||||||
fontFamily: 'var(--voidraft-font-mono)',
|
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',
|
|
||||||
},
|
},
|
||||||
|
'.cm-table a': { color: 'var(--selection-text)', textDecoration: 'none' },
|
||||||
|
'.cm-table a:hover': { textDecoration: 'underline' },
|
||||||
|
'.cm-line.cm-table-line-hidden': { color: 'transparent !important', caretColor: 'transparent' },
|
||||||
|
'.cm-line.cm-table-line-hidden span': { color: 'transparent !important' },
|
||||||
|
'.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;
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Unified theme - combines all markdown plugin themes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
import { blockquoteTheme } from './blockquote';
|
||||||
|
import { codeBlockTheme } from './code-block';
|
||||||
|
import { headingTheme } from './heading';
|
||||||
|
import { horizontalRuleTheme } from './horizontal-rule';
|
||||||
|
import { inlineStylesTheme } from './inline-styles';
|
||||||
|
import { linkTheme } from './link';
|
||||||
|
import { listTheme } from './list';
|
||||||
|
import { footnoteTheme } from './footnote';
|
||||||
|
import { mathTheme } from './math';
|
||||||
|
import { emojiTheme } from './emoji';
|
||||||
|
import { tableTheme } from './table';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All markdown themes combined.
|
||||||
|
*/
|
||||||
|
export const Theme: Extension = [
|
||||||
|
blockquoteTheme,
|
||||||
|
codeBlockTheme,
|
||||||
|
headingTheme,
|
||||||
|
horizontalRuleTheme,
|
||||||
|
inlineStylesTheme,
|
||||||
|
linkTheme,
|
||||||
|
listTheme,
|
||||||
|
footnoteTheme,
|
||||||
|
mathTheme,
|
||||||
|
emojiTheme,
|
||||||
|
tableTheme
|
||||||
|
];
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for unified markdown plugin handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Decoration, EditorView } from '@codemirror/view';
|
||||||
|
import { RangeTuple } from '../util';
|
||||||
|
import { SyntaxNode } from '@lezer/common';
|
||||||
|
|
||||||
|
/** Decoration item to be added */
|
||||||
|
export interface DecoItem {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
deco: Decoration;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared build context passed to all handlers */
|
||||||
|
export interface BuildContext {
|
||||||
|
view: EditorView;
|
||||||
|
items: DecoItem[];
|
||||||
|
selRange: RangeTuple;
|
||||||
|
seen: Set<number>;
|
||||||
|
processedLines: Set<number>;
|
||||||
|
contentWidth: number;
|
||||||
|
lineHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handler function type */
|
||||||
|
export type NodeHandler = (
|
||||||
|
ctx: BuildContext,
|
||||||
|
nf: number,
|
||||||
|
nt: number,
|
||||||
|
node: SyntaxNode,
|
||||||
|
inCursor: boolean,
|
||||||
|
ranges: RangeTuple[]
|
||||||
|
) => void | boolean;
|
||||||
127
frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
Normal file
127
frontend/src/views/editor/extensions/markdown/syntax/emoji.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Emoji extension for Lezer Markdown parser.
|
||||||
|
*
|
||||||
|
* Parses :emoji_name: syntax for emoji shortcodes.
|
||||||
|
*
|
||||||
|
* Syntax: :emoji_name: → renders as actual emoji character
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* - :smile: → 😄
|
||||||
|
* - :heart: → ❤️
|
||||||
|
* - :+1: → 👍
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
|
||||||
|
import { CharCode } from '../util';
|
||||||
|
import { emojies } from '@/common/constant/emojies';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-computed lookup table for emoji name characters.
|
||||||
|
* Valid characters: a-z, 0-9, _, +, -
|
||||||
|
* Uses Uint8Array for memory efficiency and O(1) lookup.
|
||||||
|
*/
|
||||||
|
const EMOJI_NAME_CHARS = new Uint8Array(128);
|
||||||
|
// Initialize lookup table
|
||||||
|
for (let i = 48; i <= 57; i++) EMOJI_NAME_CHARS[i] = 1; // 0-9
|
||||||
|
for (let i = 97; i <= 122; i++) EMOJI_NAME_CHARS[i] = 1; // a-z
|
||||||
|
EMOJI_NAME_CHARS[95] = 1; // _
|
||||||
|
EMOJI_NAME_CHARS[43] = 1; // +
|
||||||
|
EMOJI_NAME_CHARS[45] = 1; // -
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O(1) check if a character is valid for emoji name.
|
||||||
|
* @param code - ASCII character code
|
||||||
|
* @returns True if valid emoji name character
|
||||||
|
*/
|
||||||
|
function isEmojiNameChar(code: number): boolean {
|
||||||
|
return code < 128 && EMOJI_NAME_CHARS[code] === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse emoji :name: syntax.
|
||||||
|
*
|
||||||
|
* @param cx - Inline context
|
||||||
|
* @param pos - Start position (at :)
|
||||||
|
* @returns Position after element, or -1 if no match
|
||||||
|
*/
|
||||||
|
function parseEmoji(cx: InlineContext, pos: number): number {
|
||||||
|
const end = cx.end;
|
||||||
|
|
||||||
|
// Minimum: : + name + : = at least 3 chars, name must be non-empty
|
||||||
|
if (end < pos + 2) return -1;
|
||||||
|
|
||||||
|
// Track content for validation
|
||||||
|
let hasContent = false;
|
||||||
|
const contentStart = pos + 1;
|
||||||
|
|
||||||
|
// Search for closing :
|
||||||
|
for (let i = contentStart; i < end; i++) {
|
||||||
|
const char = cx.char(i);
|
||||||
|
|
||||||
|
// Found closing :
|
||||||
|
if (char === CharCode.Colon) {
|
||||||
|
// Must have content
|
||||||
|
if (!hasContent) return -1;
|
||||||
|
|
||||||
|
// Extract and validate emoji name
|
||||||
|
const name = cx.slice(contentStart, i).toLowerCase();
|
||||||
|
|
||||||
|
// Check if this is a valid emoji
|
||||||
|
if (!emojies[name]) return -1;
|
||||||
|
|
||||||
|
// Create element with marks and name
|
||||||
|
return cx.addElement(cx.elt('Emoji', pos, i + 1, [
|
||||||
|
cx.elt('EmojiMark', pos, contentStart),
|
||||||
|
cx.elt('EmojiName', contentStart, i),
|
||||||
|
cx.elt('EmojiMark', i, i + 1)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newline not allowed in emoji
|
||||||
|
if (char === CharCode.Newline) return -1;
|
||||||
|
|
||||||
|
// Space not allowed in emoji name
|
||||||
|
if (char === CharCode.Space || char === CharCode.Tab) return -1;
|
||||||
|
|
||||||
|
// Validate name character using O(1) lookup table
|
||||||
|
// Also check for uppercase A-Z (65-90) and convert mentally
|
||||||
|
const lowerChar = char >= 65 && char <= 90 ? char + 32 : char;
|
||||||
|
if (isEmojiNameChar(lowerChar)) {
|
||||||
|
hasContent = true;
|
||||||
|
} else {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emoji extension for Lezer Markdown.
|
||||||
|
*
|
||||||
|
* Defines:
|
||||||
|
* - Emoji: The container node for emoji shortcode
|
||||||
|
* - EmojiMark: The : delimiter marks
|
||||||
|
* - EmojiName: The emoji name part
|
||||||
|
*/
|
||||||
|
export const Emoji: MarkdownConfig = {
|
||||||
|
defineNodes: [
|
||||||
|
{ name: 'Emoji' },
|
||||||
|
{ name: 'EmojiMark' },
|
||||||
|
{ name: 'EmojiName' }
|
||||||
|
],
|
||||||
|
parseInline: [
|
||||||
|
{
|
||||||
|
name: 'Emoji',
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
// Fast path: must start with :
|
||||||
|
if (next !== CharCode.Colon) return -1;
|
||||||
|
return parseEmoji(cx, pos);
|
||||||
|
},
|
||||||
|
// Parse after emphasis to avoid conflicts with other syntax
|
||||||
|
after: 'Emphasis'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Emoji;
|
||||||
Reference in New Issue
Block a user