4 Commits

Author SHA1 Message Date
281f53c049 Optimized markdown preview performance 2025-12-07 00:09:52 +08:00
71ca541f78 🚧 Added support for markdown preview table 2025-12-04 00:47:51 +08:00
91f4f4afac Merge branch 'markdown'
# Conflicts:
#	frontend/package-lock.json
2025-12-03 00:46:17 +08:00
dependabot[bot]
6668c11846 ⬆️ Bump mdast-util-to-hast from 13.2.0 to 13.2.1 in /frontend
Bumps [mdast-util-to-hast](https://github.com/syntax-tree/mdast-util-to-hast) from 13.2.0 to 13.2.1.
- [Release notes](https://github.com/syntax-tree/mdast-util-to-hast/releases)
- [Commits](https://github.com/syntax-tree/mdast-util-to-hast/compare/13.2.0...13.2.1)

---
updated-dependencies:
- dependency-name: mdast-util-to-hast
  dependency-version: 13.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 04:14:35 +00:00
28 changed files with 1972 additions and 2820 deletions

View File

@@ -64,6 +64,12 @@
/* Markdown 高亮样式 */ /* Markdown 高亮样式 */
--cm-highlight-background: rgba(250, 204, 21, 0.35); --cm-highlight-background: rgba(250, 204, 21, 0.35);
/* Markdown 表格样式 - 暗色主题 */
--cm-table-bg: rgba(35, 40, 52, 0.5);
--cm-table-header-bg: rgba(46, 51, 69, 0.7);
--cm-table-border: rgba(75, 85, 99, 0.35);
--cm-table-row-hover: rgba(55, 62, 78, 0.5);
} }
/* 亮色主题 */ /* 亮色主题 */
@@ -113,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 内联代码样式 */
@@ -125,6 +131,12 @@
/* Markdown 高亮样式 */ /* Markdown 高亮样式 */
--cm-highlight-background: rgba(253, 224, 71, 0.45); --cm-highlight-background: rgba(253, 224, 71, 0.45);
/* Markdown 表格样式 - 亮色主题 */
--cm-table-bg: oklch(97.5% 0.006 255);
--cm-table-header-bg: oklch(94% 0.01 255);
--cm-table-border: oklch(88% 0.008 255);
--cm-table-row-hover: oklch(95% 0.008 255);
} }
/* 跟随系统的浅色偏好 */ /* 跟随系统的浅色偏好 */
@@ -187,5 +199,11 @@
/* Markdown 高亮样式 */ /* Markdown 高亮样式 */
--cm-highlight-background: rgba(253, 224, 71, 0.45); --cm-highlight-background: rgba(253, 224, 71, 0.45);
/* Markdown 表格样式 - 亮色主题 */
--cm-table-bg: oklch(97.5% 0.006 255);
--cm-table-header-bg: oklch(94% 0.01 255);
--cm-table-border: oklch(88% 0.008 255);
--cm-table-row-hover: oklch(95% 0.008 255);
} }
} }

View File

@@ -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({

View File

@@ -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]) {

View File

@@ -1,41 +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';
/** /**
* 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(),
]; ];
export default markdownExtensions; export default markdownExtensions;

View File

@@ -1,100 +1,56 @@
import { /**
Decoration, * Blockquote handler and theme.
DecorationSet, */
EditorView,
ViewPlugin, import { Decoration, EditorView } from '@codemirror/view';
ViewUpdate import { invisibleDecoration, RangeTuple } from '../util';
} from '@codemirror/view'; import { SyntaxNode } from '@lezer/common';
import { Range } from '@codemirror/state'; import { BuildContext } from './types';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange, invisibleDecoration } from '../util'; 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;
* Build blockquote decorations. const endLine = ctx.view.state.doc.lineAt(nt).number;
*/ for (let i = startLine; i <= endLine; i++) {
function buildBlockQuoteDecorations(view: EditorView): DecorationSet { if (!ctx.processedLines.has(i)) {
const decorations: Range<Decoration>[] = []; ctx.processedLines.add(i);
const processedLines = 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 });
syntaxTree(view.state).iterate({
enter(node) {
if (node.type.name !== 'Blockquote') return;
const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]);
// Only add decorations when cursor is outside the blockquote
// This allows selection highlighting to be visible when editing
if (!cursorInBlockquote) {
// Add line decoration for each line in the blockquote
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);
decorations.push(
Decoration.line({ class: 'cm-blockquote' }).range(line.from)
);
}
}
// Hide quote marks when cursor is outside
const cursor = node.node.cursor();
cursor.iterate((child) => {
if (child.type.name === 'QuoteMark') {
decorations.push(
invisibleDecoration.range(child.from, child.to)
);
}
});
}
// Don't recurse into nested blockquotes (handled by outer iteration)
return false;
}
});
return Decoration.set(decorations, true);
}
/**
* Blockquote plugin class.
*/
class BlockQuotePlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildBlockQuoteDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = buildBlockQuoteDecorations(update.view);
} }
} }
// 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;
} }
const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
decorations: (v) => v.decorations
});
/** /**
* Base theme for blockquotes. * Theme for blockquotes.
*/ */
const baseTheme = EditorView.baseTheme({ export const blockquoteTheme = 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)'

View File

@@ -1,257 +1,107 @@
import { Extension, Range } from '@codemirror/state'; /**
import { * Code block handler and theme.
ViewPlugin, */
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
/** Code block node types in syntax tree */ import { Decoration, EditorView, WidgetType } from '@codemirror/view';
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const; 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 (size controlled by CSS) */
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`; const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`; const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
/** Cache for code block metadata */
interface CodeBlockData {
from: number;
to: number;
language: string | null;
content: string;
}
/**
* 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
*/
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
/**
* Widget for displaying language label and copy button.
* Handles click events directly on the button element.
*/
class CodeBlockInfoWidget extends WidgetType { class CodeBlockInfoWidget extends WidgetType {
constructor( constructor(readonly from: number, readonly to: number, readonly language: string | null) { super(); }
readonly data: CodeBlockData, eq(other: CodeBlockInfoWidget) { return other.from === this.from && other.language === this.language; }
readonly view: EditorView toDOM(view: EditorView): HTMLElement {
) {
super();
}
eq(other: CodeBlockInfoWidget): boolean {
return other.data.from === this.data.from &&
other.data.language === this.data.language;
}
toDOM(): 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) {
// Only show language label if specified
if (this.data.language) {
const lang = document.createElement('span'); const lang = document.createElement('span');
lang.className = 'cm-code-block-lang'; lang.className = 'cm-code-block-lang';
lang.textContent = this.data.language; lang.textContent = this.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;
// Direct click handler - more reliable than eventHandlers
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.handleCopy(btn); 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') : '';
// Prevent mousedown from affecting editor if (content) {
btn.addEventListener('mousedown', (e) => { navigator.clipboard.writeText(content).then(() => {
e.preventDefault(); btn.innerHTML = ICON_CHECK;
e.stopPropagation(); setTimeout(() => { btn.innerHTML = ICON_COPY; }, 1500);
});
container.append(btn);
return container;
}
private handleCopy(btn: HTMLButtonElement): void {
const content = getCodeContent(this.view, this.data.from, this.data.to);
if (!content) return;
navigator.clipboard.writeText(content).then(() => {
btn.innerHTML = ICON_CHECK;
setTimeout(() => {
btn.innerHTML = ICON_COPY;
}, 1500);
});
}
// Ignore events to prevent editor focus changes
ignoreEvent(): boolean {
return true;
}
}
/**
* Extract language from code block node.
*/
function getLanguage(view: EditorView, node: any, offset: number): string | null {
let lang: string | null = null;
node.toTree().iterate({
enter: ({ type, from, to }) => {
if (type.name === 'CodeInfo') {
lang = view.state.doc.sliceString(offset + from, offset + to).trim();
}
}
});
return lang;
}
/**
* Extract code content (without fence markers).
*/
function getCodeContent(view: EditorView, from: number, to: number): string {
const lines = view.state.doc.sliceString(from, to).split('\n');
return lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
}
/**
* Build decorations for visible code blocks.
*/
function buildDecorations(view: EditorView): { decorations: DecorationSet; blocks: Map<number, CodeBlockData> } {
const decorations: Range<Decoration>[] = [];
const blocks = new Map<number, CodeBlockData>();
const seen = new Set<string>();
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (!CODE_BLOCK_TYPES.includes(type.name as any)) return;
const key = `${nodeFrom}:${nodeTo}`;
if (seen.has(key)) return;
seen.add(key);
const inBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
if (inBlock) return;
const language = getLanguage(view, node, nodeFrom);
const startLine = view.state.doc.lineAt(nodeFrom);
const endLine = view.state.doc.lineAt(nodeTo);
for (let num = startLine.number; num <= endLine.number; num++) {
const line = view.state.doc.line(num);
const pos: string[] = ['cm-codeblock'];
if (num === startLine.number) pos.push('cm-codeblock-begin');
if (num === endLine.number) pos.push('cm-codeblock-end');
decorations.push(
Decoration.line({ class: pos.join(' ') }).range(line.from)
);
}
// Info widget with copy button (always show, language label only if specified)
const content = getCodeContent(view, nodeFrom, nodeTo);
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
blocks.set(nodeFrom, data);
decorations.push(
Decoration.widget({
widget: new CodeBlockInfoWidget(data, view),
side: 1
}).range(startLine.to)
);
// Hide markers
node.toTree().iterate({
enter: ({ type: t, from: f, to: t2 }) => {
if (t.name === 'CodeInfo' || t.name === 'CodeMark') {
decorations.push(Decoration.replace({}).range(nodeFrom + f, nodeFrom + t2));
}
}
}); });
} }
}); });
btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
container.append(btn);
return container;
} }
ignoreEvent() { return true; }
return { decorations: Decoration.set(decorations, true), blocks };
} }
/** /**
* Code block plugin with optimized updates. * Handle FencedCode / CodeBlock node.
*/ */
class CodeBlockPluginClass { export function handleCodeBlock(
decorations: DecorationSet; ctx: BuildContext,
blocks: Map<number, CodeBlockData>; nf: number,
private lastHead = -1; nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
constructor(view: EditorView) { const startLine = ctx.view.state.doc.lineAt(nf);
const result = buildDecorations(view); const endLine = ctx.view.state.doc.lineAt(nt);
this.decorations = result.decorations; for (let num = startLine.number; num <= endLine.number; num++) {
this.blocks = result.blocks; const line = ctx.view.state.doc.line(num);
this.lastHead = view.state.selection.main.head; let deco = DECO_CODEBLOCK_LINE;
if (startLine.number === endLine.number) deco = DECO_CODEBLOCK_SINGLE;
else if (num === startLine.number) deco = DECO_CODEBLOCK_BEGIN;
else if (num === endLine.number) deco = DECO_CODEBLOCK_END;
ctx.items.push({ from: line.from, to: line.from, deco });
} }
if (!inCursor) {
update(update: ViewUpdate): void { const codeInfo = node.getChild('CodeInfo');
const { docChanged, viewportChanged, selectionSet } = update; const codeMarks = node.getChildren('CodeMark');
const language = codeInfo ? ctx.view.state.doc.sliceString(codeInfo.from, codeInfo.to).trim() : null;
// Skip rebuild if cursor stayed on same line ctx.items.push({ from: startLine.to, to: startLine.to, deco: Decoration.widget({ widget: new CodeBlockInfoWidget(nf, nt, language), side: 1 }), priority: 1 });
if (selectionSet && !docChanged && !viewportChanged) { if (codeInfo) ctx.items.push({ from: codeInfo.from, to: codeInfo.to, deco: invisibleDecoration });
const newHead = update.state.selection.main.head; for (const mark of codeMarks) ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
const oldLine = update.startState.doc.lineAt(this.lastHead).number;
const newLine = update.state.doc.lineAt(newHead).number;
if (oldLine === newLine) {
this.lastHead = newHead;
return;
}
}
if (docChanged || viewportChanged || selectionSet) {
const result = buildDecorations(update.view);
this.decorations = result.decorations;
this.blocks = result.blocks;
this.lastHead = update.state.selection.main.head;
}
} }
} }
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'
}, },
'.cm-codeblock-begin': { '.cm-codeblock-begin': {
borderTopLeftRadius: 'var(--cm-codeblock-radius)', borderTopLeftRadius: 'var(--cm-codeblock-radius)',
borderTopRightRadius: 'var(--cm-codeblock-radius)', borderTopRightRadius: 'var(--cm-codeblock-radius)',
position: 'relative', position: 'relative'
boxShadow: 'inset 0 1px 0 var(--text-primary)'
}, },
'.cm-codeblock-end': { '.cm-codeblock-end': {
borderBottomLeftRadius: 'var(--cm-codeblock-radius)', borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
borderBottomRightRadius: 'var(--cm-codeblock-radius)', borderBottomRightRadius: 'var(--cm-codeblock-radius)'
boxShadow: 'inset 0 -1px 0 var(--text-primary)'
}, },
'.cm-code-block-info': { '.cm-code-block-info': {
position: 'absolute', position: 'absolute',
@@ -265,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',
@@ -286,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'
}
}); });

View File

@@ -1,47 +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 { isCursorInRange } 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
* - Uses RangeSetBuilder for optimal performance
* - Supports 1900+ emojis from the comprehensive emoji dictionary
*/
export const emoji = (): Extension => [emojiPlugin, baseTheme];
/**
* Emoji regex pattern for matching :emoji_name: syntax.
*/
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';
@@ -52,130 +21,37 @@ class EmojiWidget extends WidgetType {
} }
/** /**
* Match result for emoji patterns. * 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 a text range. if (!nameNode) return;
*/ const name = ctx.view.state.sliceDoc(nameNode.from, nameNode.to).toLowerCase();
function findEmojiMatches(text: string, offset: number): EmojiMatch[] { const emojiChar = emojies[name];
const matches: EmojiMatch[] = []; if (emojiChar) {
let match: RegExpExecArray | null; ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new EmojiWidget(emojiChar, name) }) });
// Reset regex state
EMOJI_REGEX.lastIndex = 0;
while ((match = EMOJI_REGEX.exec(text)) !== null) {
const name = match[1].toLowerCase();
const emoji = emojies[name];
if (emoji) {
matches.push({
from: offset + match.index,
to: offset + match.index + match[0].length,
name,
emoji
});
}
}
return matches;
}
/**
* Build emoji decorations using RangeSetBuilder.
*/
function buildEmojiDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc;
for (const { from, to } of view.visibleRanges) {
const text = doc.sliceString(from, to);
const matches = findEmojiMatches(text, from);
for (const match of matches) {
// Skip if cursor is in this range
if (isCursorInRange(view.state, [match.from, match.to])) {
continue;
}
builder.add(
match.from,
match.to,
Decoration.replace({
widget: new EmojiWidget(match.emoji, match.name)
})
);
}
}
return builder.finish();
}
/**
* Emoji plugin with optimized update detection.
*/
class EmojiPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildEmojiDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
// Always rebuild on doc or viewport change
if (update.docChanged || update.viewportChanged) {
this.decorations = buildEmojiDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
// For selection changes, check if we moved significantly
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
// Only rebuild if cursor moved to a different position
if (newHead !== this.lastSelectionHead) {
this.decorations = buildEmojiDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
} }
} }
const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
decorations: (v) => v.decorations
});
/** /**
* Base theme for emoji. * Theme for emoji.
* Inherits font size and line height from parent element.
*/ */
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()];
}

View File

@@ -1,661 +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
*
* Syntax (MultiMarkdown/PHP Markdown Extra):
* - Reference: [^id] → renders as superscript
* - Definition: [^id]: content
* - Inline footnote: ^[content] → renders as superscript with embedded content
*/ */
import { Extension, Range, StateField, EditorState } from '@codemirror/state'; import { 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 { isCursorInRange, invisibleDecoration } from '../util';
// ============================================================================ /** Extended context for footnotes */
// Types export interface FootnoteContext extends BuildContext {
// ============================================================================ definitionIds: Set<string>;
pendingRefs: { from: number; to: number; id: string; index: number }[];
/** pendingInlines: { from: number; to: number; index: number }[];
* Information about a footnote definition. seenIds: Map<string, number>;
*/ inlineFootnoteIdx: number;
interface FootnoteDefinition {
/** The footnote identifier (e.g., "1", "note") */
id: string;
/** The content of the footnote */
content: string;
/** Start position in document */
from: number;
/** End position in document */
to: number;
} }
/**
* Information about a footnote reference.
*/
interface FootnoteReference {
/** The footnote identifier */
id: string;
/** Start position in document */
from: number;
/** End position in document */
to: number;
/** Numeric index (1-based, for display) */
index: number;
}
/**
* Information about an inline footnote.
*/
interface InlineFootnoteInfo {
/** The content of the inline footnote */
content: string;
/** Start position in document */
from: number;
/** End position in document */
to: number;
/** Numeric index (1-based, for display) */
index: number;
}
/**
* Collected footnote data from the document.
* Uses Maps for O(1) lookup by position and id.
*/
interface FootnoteData {
definitions: Map<string, FootnoteDefinition>;
references: FootnoteReference[];
inlineFootnotes: InlineFootnoteInfo[];
// Index maps for O(1) lookup
referencesByPos: Map<number, FootnoteReference>;
inlineByPos: Map<number, InlineFootnoteInfo>;
firstRefById: Map<string, FootnoteReference>;
}
// ============================================================================
// Footnote Collection
// ============================================================================
/**
* Collect all footnote definitions, references, and inline footnotes from the document.
* Builds index maps for O(1) lookup during decoration and tooltip handling.
*/
function collectFootnotes(state: EditorState): FootnoteData {
const definitions = new Map<string, FootnoteDefinition>();
const references: FootnoteReference[] = [];
const inlineFootnotes: InlineFootnoteInfo[] = [];
// Index maps for fast lookup
const referencesByPos = new Map<number, FootnoteReference>();
const inlineByPos = new Map<number, InlineFootnoteInfo>();
const firstRefById = new Map<string, FootnoteReference>();
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()
: '';
definitions.set(id, { id, content, 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);
// Track first reference for each id
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);
}
}
},
});
return {
definitions,
references,
inlineFootnotes,
referencesByPos,
inlineByPos,
firstRefById,
};
}
// ============================================================================
// State Field
// ============================================================================
/**
* State field to track footnote data across the document.
* This allows efficient lookup for tooltips and navigation.
*/
export const footnoteDataField = StateField.define<FootnoteData>({
create(state) {
return collectFootnotes(state);
},
update(value, tr) {
if (tr.docChanged) {
return collectFootnotes(tr.state);
}
return value;
},
});
// ============================================================================
// Widget
// ============================================================================
/**
* Widget to display footnote reference as superscript.
*/
class FootnoteRefWidget extends WidgetType { class FootnoteRefWidget extends WidgetType {
constructor( constructor(readonly 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;
}
} }
/**
* Widget to display inline footnote as superscript.
*/
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;
}
} }
/**
* Widget to display footnote definition label.
*/
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;
}
}
// ============================================================================
// Decorations
// ============================================================================
/**
* Build decorations for footnote references and inline footnotes.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const data = view.state.field(footnoteDataField);
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle footnote references
if (type.name === 'FootnoteReference') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
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 (!cursorInRange && ref && ref.id === id) {
// Hide the entire syntax and show widget
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
// Add widget at the end
const widget = new FootnoteRefWidget(
id,
ref.index,
data.definitions.has(id)
);
decorations.push(
Decoration.widget({
widget,
side: 1,
}).range(nodeTo)
);
}
}
// Handle footnote definitions
if (type.name === 'FootnoteDefinition') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const marks = node.getChildren('FootnoteDefinitionMark');
const labelNode = node.getChild('FootnoteDefinitionLabel');
if (!cursorInRange && marks.length >= 2 && labelNode) {
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
// Hide the entire [^id]: part
decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to));
// Add widget to show [id]
const widget = new FootnoteDefLabelWidget(id);
decorations.push(
Decoration.widget({
widget,
side: 1,
}).range(marks[1].to)
);
}
}
// Handle inline footnotes
if (type.name === 'InlineFootnote') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
const contentNode = node.getChild('InlineFootnoteContent');
const marks = node.getChildren('InlineFootnoteMark');
if (!contentNode || marks.length < 2) return;
const inlineNote = data.inlineByPos.get(nodeFrom);
if (!cursorInRange && inlineNote) {
// Hide the entire syntax and show widget
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
// Add widget at the end
const widget = new InlineFootnoteWidget(
inlineNote.content,
inlineNote.index
);
decorations.push(
Decoration.widget({
widget,
side: 1,
}).range(nodeTo)
);
}
}
},
});
}
return Decoration.set(decorations, true);
}
// ============================================================================
// Plugin Class
// ============================================================================
/**
* Footnote view plugin with optimized update detection.
*/
class FootnotePlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
decorations: (v) => v.decorations,
});
// ============================================================================
// Hover Tooltip
// ============================================================================
/**
* Hover tooltip that shows footnote content.
*/
const footnoteHoverTooltip = hoverTooltip(
(view, pos): Tooltip | null => {
const data = view.state.field(footnoteDataField);
// Check if hovering over a footnote reference widget
const target = document.elementFromPoint(
view.coordsAtPos(pos)?.left ?? 0,
view.coordsAtPos(pos)?.top ?? 0
) 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),
};
}
}
}
// Check if hovering over an inline footnote widget
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 if position is within a footnote reference node
let foundId: string | null = null;
let foundPos: number = pos;
let foundInlineContent: string | null = null;
let foundInlineIndex: number | null = null;
syntaxTree(view.state).iterate({
from: pos,
to: pos,
enter: ({ type, from, to, node }) => {
if (type.name === 'FootnoteReference') {
const labelNode = node.getChild('FootnoteReferenceLabel');
if (labelNode && pos >= from && pos <= to) {
foundId = view.state.sliceDoc(labelNode.from, labelNode.to);
foundPos = to;
}
} else if (type.name === 'InlineFootnote') {
const contentNode = node.getChild('InlineFootnoteContent');
if (contentNode && pos >= from && pos <= to) {
foundInlineContent = view.state.sliceDoc(contentNode.from, contentNode.to);
const inlineNote = data.inlineByPos.get(from);
if (inlineNote) {
foundInlineIndex = inlineNote.index;
}
foundPos = to;
}
}
},
});
if (foundId) {
const def = data.definitions.get(foundId);
if (def) {
const tooltipId = foundId;
const tooltipPos = foundPos;
return {
pos: tooltipPos,
above: true,
arrow: true,
create: () => createTooltipDom(tooltipId, def.content),
};
}
}
if (foundInlineContent && foundInlineIndex !== null) {
const tooltipContent = foundInlineContent;
const tooltipIndex = foundInlineIndex;
const tooltipPos = foundPos;
return {
pos: tooltipPos,
above: true,
arrow: true,
create: () => createInlineTooltipDom(tooltipIndex, tooltipContent),
};
}
return null;
},
{ hoverTime: 300 }
);
/**
* Create tooltip DOM element for regular footnote.
*/
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 };
} }
/** /**
* Create tooltip DOM element for inline footnote. * Handle FootnoteDefinition node.
*/ */
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } { export function handleFootnoteDefinition(
const dom = document.createElement('div'); ctx: FootnoteContext,
dom.className = 'cm-footnote-tooltip'; 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 header = document.createElement('div'); const marks = node.getChildren('FootnoteDefinitionMark');
header.className = 'cm-footnote-tooltip-header'; const labelNode = node.getChild('FootnoteDefinitionLabel');
header.textContent = `Inline Footnote [${index}]`; if (marks.length >= 2 && labelNode) {
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
const body = document.createElement('div'); ctx.definitionIds.add(id);
body.className = 'cm-footnote-tooltip-body'; ctx.items.push({ from: marks[0].from, to: marks[1].to, deco: invisibleDecoration });
body.textContent = content || '(Empty footnote)'; ctx.items.push({ from: marks[1].to, to: marks[1].to, deco: Decoration.widget({ widget: new FootnoteDefLabelWidget(id), side: 1 }), priority: 1 });
}
dom.appendChild(header);
dom.appendChild(body);
return { dom };
} }
// ============================================================================ /**
// Click Handler * Handle FootnoteReference node.
// ============================================================================ */
export function handleFootnoteReference(
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 labelNode = node.getChild('FootnoteReferenceLabel');
const marks = node.getChildren('FootnoteReferenceMark');
if (labelNode && marks.length >= 2) {
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
if (!ctx.seenIds.has(id)) ctx.seenIds.set(id, ctx.seenIds.size + 1);
ctx.pendingRefs.push({ from: nf, to: nt, id, index: ctx.seenIds.get(id)! });
}
}
/** /**
* Click handler for footnote navigation. * Handle InlineFootnote node.
* Uses mousedown to intercept before editor moves cursor.
* - Click on reference → jump to definition
* - Click on definition label → jump to first reference
*/ */
const footnoteClickHandler = EditorView.domEventHandlers({ export function handleInlineFootnote(
mousedown(event, view) { ctx: FootnoteContext,
const target = event.target as HTMLElement; 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;
// Handle click on footnote reference widget const contentNode = node.getChild('InlineFootnoteContent');
if (target.classList.contains('cm-footnote-ref')) { const marks = node.getChildren('InlineFootnoteMark');
const id = target.dataset.footnoteId; if (contentNode && marks.length >= 2) {
if (id) { ctx.inlineFootnoteIdx++;
const data = view.state.field(footnoteDataField); ctx.pendingInlines.push({ from: nf, to: nt, index: ctx.inlineFootnoteIdx });
const def = data.definitions.get(id); }
if (def) { }
// Prevent default to stop cursor from moving to widget position
event.preventDefault();
// Use setTimeout to dispatch after mousedown completes
setTimeout(() => {
view.dispatch({
selection: { anchor: def.from },
scrollIntoView: true,
});
view.focus();
}, 0);
return true;
}
}
}
// Handle click on definition label
if (target.classList.contains('cm-footnote-def-label')) {
const pos = view.posAtDOM(target);
if (pos !== null) {
const data = view.state.field(footnoteDataField);
// Find which definition this belongs to
for (const [id, def] of data.definitions) {
if (pos >= def.from && pos <= def.to) {
// O(1) lookup for first reference
const firstRef = data.firstRefById.get(id);
if (firstRef) {
event.preventDefault();
setTimeout(() => {
view.dispatch({
selection: { anchor: firstRef.from },
scrollIntoView: true,
});
view.focus();
}, 0);
return true;
}
break;
}
}
}
}
return false;
},
});
// ============================================================================
// Theme
// ============================================================================
/** /**
* Base theme for footnotes. * Process pending footnote refs after all definitions are collected.
*/ */
const baseTheme = EditorView.baseTheme({ export function processPendingFootnotes(ctx: FootnoteContext): void {
// Footnote reference (superscript) for (const ref of ctx.pendingRefs) {
ctx.items.push({ from: ref.from, to: ref.to, deco: invisibleDecoration });
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 });
}
for (const inline of ctx.pendingInlines) {
ctx.items.push({ from: inline.from, to: inline.to, deco: invisibleDecoration });
ctx.items.push({ from: inline.to, to: inline.to, deco: Decoration.widget({ widget: new InlineFootnoteWidget(inline.index), side: 1 }), priority: 1 });
}
}
/**
* Theme for footnotes.
*/
export const footnoteTheme = EditorView.baseTheme({
'.cm-footnote-ref': { '.cm-footnote-ref': {
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
@@ -670,21 +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))'
}, },
// Inline footnote reference (superscript) - uses distinct color
'.cm-inline-footnote-ref': { '.cm-inline-footnote-ref': {
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
@@ -699,84 +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))',
},
// Footnote definition label
'.cm-footnote-def-label': { '.cm-footnote-def-label': {
color: 'var(--cm-footnote-def-color, #1a73e8)', color: 'var(--cm-footnote-def-color, #1a73e8)',
fontWeight: '600', fontWeight: '600'
cursor: 'pointer', }
},
'.cm-footnote-def-label:hover': {
textDecoration: 'underline',
},
// Tooltip
'.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',
},
// Tooltip animation
'.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.
*
* Features:
* - Parses footnote references [^id] and definitions [^id]: content
* - Parses inline footnotes ^[content]
* - Renders references and inline footnotes as superscript numbers
* - Shows definition/content on hover
* - Click to navigate between reference and definition
*/
export const footnote = (): Extension => [
footnoteDataField,
footnotePlugin,
footnoteHoverTooltip,
footnoteClickHandler,
baseTheme,
];
export default footnote;

View File

@@ -1,96 +1,63 @@
import { syntaxTree } from '@codemirror/language'; /**
import { EditorState, StateField, Range } from '@codemirror/state'; * Heading handler and theme.
import { Decoration, DecorationSet, EditorView } from '@codemirror/view'; */
import { Decoration, EditorView } from '@codemirror/view';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const DECO_HEADING_HIDDEN = Decoration.mark({ class: 'cm-heading-mark-hidden' });
/** /**
* Hidden mark decoration - uses visibility: hidden to hide content * Handle ATXHeading node (# Heading).
*/ */
const hiddenMarkDecoration = Decoration.mark({ export function handleATXHeading(
class: 'cm-heading-mark-hidden' 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 header = node.firstChild;
* Check if selection overlaps with a range. if (header && header.type.name === 'HeaderMark') {
*/ ctx.items.push({ from: header.from, to: Math.min(header.to + 1, nt), deco: DECO_HEADING_HIDDEN });
function isSelectionInRange(state: EditorState, from: number, to: number): boolean {
return state.selection.ranges.some(
(range) => from <= range.to && to >= range.from
);
} }
/**
* Build heading decorations.
* Hides # marks when cursor is not on the heading line.
*/
function buildHeadingDecorations(state: EditorState): DecorationSet {
const decorations: Range<Decoration>[] = [];
syntaxTree(state).iterate({
enter(node) {
// Skip if cursor is in this node's range
if (isSelectionInRange(state, node.from, node.to)) return;
// Handle ATX headings (# Heading)
if (node.type.name.startsWith('ATXHeading')) {
const header = node.node.firstChild;
if (header && header.type.name === 'HeaderMark') {
const from = header.from;
// Include the space after #
const to = Math.min(header.to + 1, node.to);
decorations.push(hiddenMarkDecoration.range(from, to));
}
}
// Handle Setext headings (underline style)
else if (node.type.name.startsWith('SetextHeading')) {
// Hide the underline marks (=== or ---)
const cursor = node.node.cursor();
cursor.iterate((child) => {
if (child.type.name === 'HeaderMark') {
decorations.push(
hiddenMarkDecoration.range(child.from, child.to)
);
}
});
}
}
});
return Decoration.set(decorations, true);
} }
/** /**
* Heading StateField - manages # mark visibility. * Handle SetextHeading node (underline style).
*/ */
const headingField = StateField.define<DecorationSet>({ export function handleSetextHeading(
create(state) { ctx: BuildContext,
return buildHeadingDecorations(state); 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;
update(deco, tr) { const headerMarks = node.getChildren('HeaderMark');
if (tr.docChanged || tr.selection) { for (const mark of headerMarks) {
return buildHeadingDecorations(tr.state); ctx.items.push({ from: mark.from, to: mark.to, deco: DECO_HEADING_HIDDEN });
} }
return deco.map(tr.changes); }
},
provide: (f) => EditorView.decorations.from(f)
});
/** /**
* Theme for hidden heading marks. * Theme for headings.
*
* Uses fontSize: 0 to hide the # mark without leaving whitespace.
* This works correctly now because blockLayer uses lineBlockAt()
* which calculates coordinates based on the entire line, not
* individual characters, so fontSize: 0 doesn't affect boundaries.
*/ */
const headingTheme = EditorView.baseTheme({ export const headingTheme = EditorView.baseTheme({
'.cm-heading-mark-hidden': { '.cm-heading-mark-hidden': {
fontSize: '0' fontSize: '0'
} }
}); });
/**
* Headings plugin.
*/
export const headings = () => [headingField, headingTheme];

View File

@@ -1,140 +0,0 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { checkRangeOverlap, isCursorInRange } from '../util';
/**
* Node types that contain markers as child elements.
*/
const TYPES_WITH_MARKS = new Set([
'Emphasis',
'StrongEmphasis',
'InlineCode',
'Strikethrough'
]);
/**
* Node types that are markers themselves.
*/
const MARK_TYPES = new Set([
'EmphasisMark',
'CodeMark',
'StrikethroughMark'
]);
// Export for external use
export const typesWithMarks = Array.from(TYPES_WITH_MARKS);
export const markTypes = Array.from(MARK_TYPES);
/**
* Build mark hiding decorations using RangeSetBuilder for optimal performance.
*/
function buildHideMarkDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const replaceDecoration = Decoration.replace({});
// Track processed ranges to avoid duplicate processing of nested marks
let currentParentRange: [number, number] | null = null;
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;
// Skip if this is a nested element within a parent we're already processing
if (currentParentRange && checkRangeOverlap([nodeFrom, nodeTo], currentParentRange)) {
return;
}
// Update current parent range
currentParentRange = [nodeFrom, nodeTo];
// Skip if cursor is in this range
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
// Iterate through child marks
const innerTree = node.toTree();
innerTree.iterate({
enter({ type: markType, from: markFrom, to: markTo }) {
if (!MARK_TYPES.has(markType.name)) return;
// Add decoration to hide the mark
builder.add(
nodeFrom + markFrom,
nodeFrom + markTo,
replaceDecoration
);
}
});
}
});
}
return builder.finish();
}
/**
* Hide marks plugin with optimized update detection.
*
* This plugin:
* - Hides emphasis marks (*, **, ~~ etc.) when cursor is outside
* - Uses RangeSetBuilder for efficient decoration construction
* - Optimizes selection change detection
*/
class HideMarkPlugin {
decorations: DecorationSet;
private lastSelectionRanges: string = '';
constructor(view: EditorView) {
this.decorations = buildHideMarkDecorations(view);
this.lastSelectionRanges = this.serializeSelection(view);
}
update(update: ViewUpdate) {
// Always rebuild on doc or viewport change
if (update.docChanged || update.viewportChanged) {
this.decorations = buildHideMarkDecorations(update.view);
this.lastSelectionRanges = this.serializeSelection(update.view);
return;
}
// For selection changes, check if selection actually changed positions
if (update.selectionSet) {
const newRanges = this.serializeSelection(update.view);
if (newRanges !== this.lastSelectionRanges) {
this.decorations = buildHideMarkDecorations(update.view);
this.lastSelectionRanges = newRanges;
}
}
}
/**
* Serialize selection ranges for comparison.
*/
private serializeSelection(view: EditorView): string {
return view.state.selection.ranges
.map(r => `${r.from}:${r.to}`)
.join(',');
}
}
/**
* Hide marks plugin.
*
* This plugin:
* - Hides marks when they are not in the editor selection
* - Supports emphasis, strong, inline code, and strikethrough
*/
export const hideMarks = () => [
ViewPlugin.fromClass(HideMarkPlugin, {
decorations: (v) => v.decorations
})
];

View File

@@ -1,115 +0,0 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Highlight plugin using syntax tree.
*
* Uses the custom Highlight extension to detect:
* - Highlight: ==text== → renders as highlighted text
*
* Examples:
* - This is ==important== text → This is <mark>important</mark> text
* - Please ==review this section== carefully
*/
export const highlight = (): Extension => [
highlightPlugin,
baseTheme
];
/**
* Build decorations for highlight using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Highlight nodes
if (type.name === 'Highlight') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the == characters)
const marks = node.getChildren('HighlightMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing == marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply highlight style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-highlight'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class HighlightPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const highlightPlugin = ViewPlugin.fromClass(
HighlightPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for highlight.
* Uses mark decoration with a subtle background color.
*/
const baseTheme = EditorView.baseTheme({
'.cm-highlight': {
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
borderRadius: '2px',
}
});

View File

@@ -1,100 +1,48 @@
import { Extension, StateField, EditorState, Range } from '@codemirror/state';
import {
DecorationSet,
Decoration,
EditorView,
WidgetType
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import { syntaxTree } from '@codemirror/language';
/** /**
* Horizontal rule plugin that renders beautiful horizontal lines. * Horizontal rule handler and theme.
*
* This plugin:
* - 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 => [
horizontalRuleField,
baseTheme
];
/** import { Decoration, EditorView, WidgetType } from '@codemirror/view';
* Widget to display a horizontal rule (inline version). 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; }
ignoreEvent() { return false; }
}
eq(_other: HorizontalRuleWidget) { const hrWidget = new HorizontalRuleWidget();
return true;
}
ignoreEvent(): boolean { /**
return false; * Handle HorizontalRule node.
*/
export function handleHorizontalRule(
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) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: hrWidget }) });
} }
} }
/** /**
* Build horizontal rule decorations. * Theme for horizontal rules.
* Uses Decoration.replace WITHOUT block: true to avoid affecting block system.
*/ */
function buildHorizontalRuleDecorations(state: EditorState): DecorationSet { export const horizontalRuleTheme = EditorView.baseTheme({
const decorations: Range<Decoration>[] = [];
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (type.name !== 'HorizontalRule') return;
// Skip if cursor is on this line
if (isCursorInRange(state, [from, to])) return;
// Replace the entire horizontal rule with a styled widget
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
decorations.push(
Decoration.replace({
widget: new HorizontalRuleWidget()
}).range(from, to)
);
}
});
return Decoration.set(decorations, true);
}
/**
* StateField for horizontal rule decorations.
*/
const horizontalRuleField = StateField.define<DecorationSet>({
create(state) {
return buildHorizontalRuleDecorations(state);
},
update(value, tx) {
if (tx.docChanged || tx.selection) {
return buildHorizontalRuleDecorations(tx.state);
}
return value.map(tx.changes);
},
provide(field) {
return EditorView.decorations.from(field);
}
});
/**
* Base theme for horizontal rules.
* Uses inline-block display to render properly without block: true.
*/
const baseTheme = EditorView.baseTheme({
'.cm-horizontal-rule-widget': { '.cm-horizontal-rule-widget': {
display: 'inline-block', display: 'inline-block',
width: '100%', width: '100%',

View File

@@ -1,208 +1,348 @@
/**
* HTML plugin for CodeMirror.
*
* Features:
* - Identifies HTML blocks and tags (excluding those inside tables)
* - Shows indicator icon at the end
* - Click to preview rendered HTML
*/
import { syntaxTree } from '@codemirror/language'; import { syntaxTree } from '@codemirror/language';
import { EditorState, Range } from '@codemirror/state'; import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
import { import {
Decoration,
DecorationSet, DecorationSet,
Decoration,
WidgetType,
EditorView, EditorView,
ViewPlugin, ViewPlugin,
ViewUpdate, ViewUpdate,
WidgetType showTooltip,
Tooltip
} from '@codemirror/view'; } from '@codemirror/view';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { isCursorInRange } from '../util'; import { LruCache } from '@/common/utils/lruCache';
interface EmbedBlockData { interface HTMLBlockInfo {
from: number; from: number;
to: number; to: number;
content: string; content: string;
sanitized: string;
} }
/** // HTML5 official logo
* Extract all HTML blocks from the document (both HTMLBlock and HTMLTag). const HTML_ICON = `<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M89.088 59.392l62.464 803.84c1.024 12.288 9.216 22.528 20.48 25.6L502.784 993.28c6.144 2.048 12.288 2.048 18.432 0l330.752-104.448c11.264-4.096 19.456-14.336 20.48-25.6l62.464-803.84c1.024-17.408-12.288-31.744-29.696-31.744H118.784c-17.408 0-31.744 14.336-29.696 31.744z" fill="#FC490B"/><path d="M774.144 309.248h-409.6l12.288 113.664h388.096l-25.6 325.632-227.328 71.68-227.328-71.68-13.312-169.984h118.784v82.944l124.928 33.792 123.904-33.792 10.24-132.096H267.264L241.664 204.8h540.672z" fill="#FFFFFF"/></svg>`;
* Returns all blocks regardless of cursor position.
*/
function extractAllHTMLBlocks(state: EditorState): EmbedBlockData[] {
const blocks = new Array<EmbedBlockData>();
syntaxTree(state).iterate({
enter({ from, to, name }) {
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
const html = state.sliceDoc(from, to);
const content = DOMPurify.sanitize(html);
// Skip empty content after sanitization
if (!content.trim()) return;
blocks.push({ from, to, content });
}
});
return blocks;
}
/** /**
* Build decorations for HTML blocks. * LRU cache for DOMPurify sanitize results.
* Only shows preview for blocks where cursor is not inside.
*/ */
function buildDecorations(state: EditorState, blocks: EmbedBlockData[]): DecorationSet { const sanitizeCache = new LruCache<string, string>(100);
const decorations: Range<Decoration>[] = [];
/**
for (const block of blocks) { * Sanitize HTML content with caching for performance.
// Skip if cursor is in range */
if (isCursorInRange(state, [block.from, block.to])) continue; function sanitizeHTML(html: string): string {
const cached = sanitizeCache.get(html);
// Hide the original HTML source code if (cached !== undefined) {
decorations.push(Decoration.replace({}).range(block.from, block.to)); return cached;
// Add the preview widget at the end
decorations.push(
Decoration.widget({
widget: new HTMLBlockWidget(block),
side: 1
}).range(block.to)
);
} }
return Decoration.set(decorations, true); const sanitized = DOMPurify.sanitize(html, {
ADD_TAGS: ['img'],
ADD_ATTR: ['src', 'alt', 'width', 'height', 'style', 'class', 'loading'],
ALLOW_DATA_ATTR: true
});
sanitizeCache.set(html, sanitized);
return sanitized;
} }
/** /**
* Check if selection affects any HTML block (cursor moved in/out of a block). * Check if document changes affect any of the given regions.
*/ */
function selectionAffectsBlocks( function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
state: EditorState, if (regions.length === 0) return true;
prevState: EditorState,
blocks: EmbedBlockData[] let affected = false;
): boolean { changes.iterChanges((fromA, toA) => {
for (const block of blocks) { if (affected) return;
const wasInRange = isCursorInRange(prevState, [block.from, block.to]); for (const region of regions) {
const isInRange = isCursorInRange(state, [block.from, block.to]); if (fromA <= region.to && toA >= region.from) {
if (wasInRange !== isInRange) return true; affected = true;
return;
}
}
});
return affected;
}
/**
* Check if a node is inside a table.
*/
function isInsideTable(node: { parent: { type: { name: string }; parent: unknown } | null }): boolean {
let current = node.parent;
while (current) {
const name = current.type.name;
if (name === 'Table' || name === 'TableHeader' || name === 'TableRow' || name === 'TableCell') {
return true;
}
current = current.parent as typeof current;
} }
return false; return false;
} }
/** /**
* ViewPlugin for HTML block preview. * Extract all HTML blocks from visible ranges.
* Uses smart caching to avoid unnecessary updates during text selection. * Excludes HTML inside tables (tables have their own rendering).
*/
function extractHTMLBlocks(view: EditorView): HTMLBlockInfo[] {
const result: HTMLBlockInfo[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (nodeRef) => {
const { name, from: f, to: t, node } = nodeRef;
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
// Skip HTML inside tables
if (isInsideTable(node)) return;
const content = view.state.sliceDoc(f, t);
const sanitized = sanitizeHTML(content);
// Skip empty content after sanitization
if (!sanitized.trim()) return;
result.push({ from: f, to: t, content, sanitized });
}
});
}
return result;
}
/** Effect to toggle tooltip visibility */
const toggleHTMLTooltip = StateEffect.define<HTMLBlockInfo | null>();
/** Effect to close tooltip */
const closeHTMLTooltip = StateEffect.define<null>();
/** StateField to track active tooltip */
const htmlTooltipState = StateField.define<HTMLBlockInfo | null>({
create: () => null,
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(toggleHTMLTooltip)) {
// Toggle: if same block, close; otherwise open new
if (value && effect.value && value.from === effect.value.from) {
return null;
}
return effect.value;
}
if (effect.is(closeHTMLTooltip)) {
return null;
}
}
// Close tooltip on document changes
if (tr.docChanged) {
return null;
}
return value;
},
provide: (field) =>
showTooltip.from(field, (block): Tooltip | null => {
if (!block) return null;
return {
pos: block.to,
above: true,
create: () => {
const dom = document.createElement('div');
dom.className = 'cm-html-tooltip';
dom.innerHTML = block.sanitized;
// Prevent clicks inside tooltip from closing it
dom.addEventListener('click', (e) => {
e.stopPropagation();
});
return { dom };
}
};
})
});
/**
* Indicator widget shown at the end of HTML blocks.
* Clicking toggles the tooltip.
*/
class HTMLIndicatorWidget extends WidgetType {
constructor(readonly info: HTMLBlockInfo) {
super();
}
toDOM(view: EditorView): HTMLElement {
const el = document.createElement('span');
el.className = 'cm-html-indicator';
el.innerHTML = HTML_ICON;
el.title = 'Click to preview HTML';
// Click handler to toggle tooltip
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
effects: toggleHTMLTooltip.of(this.info)
});
});
return el;
}
eq(other: HTMLIndicatorWidget): boolean {
return this.info.from === other.info.from && this.info.content === other.info.content;
}
ignoreEvent(): boolean {
return false;
}
}
/**
* Plugin to manage HTML block decorations.
* Optimized with incremental updates when changes don't affect HTML regions.
*/ */
class HTMLBlockPlugin { class HTMLBlockPlugin {
decorations: DecorationSet; decorations: DecorationSet;
blocks: EmbedBlockData[]; blocks: HTMLBlockInfo[] = [];
constructor(view: EditorView) { constructor(view: EditorView) {
this.blocks = extractAllHTMLBlocks(view.state); this.blocks = extractHTMLBlocks(view);
this.decorations = buildDecorations(view.state, this.blocks); this.decorations = this.build();
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
// If document changed, re-extract all blocks // Always rebuild on viewport change
if (update.docChanged) { if (update.viewportChanged) {
this.blocks = extractAllHTMLBlocks(update.state); this.blocks = extractHTMLBlocks(update.view);
this.decorations = buildDecorations(update.state, this.blocks); this.decorations = this.build();
return; return;
} }
// If selection changed, only rebuild if cursor moved in/out of a block // For document changes, only rebuild if changes affect HTML regions
if (update.selectionSet) { if (update.docChanged) {
if (selectionAffectsBlocks(update.state, update.startState, this.blocks)) { const needsRebuild = changesAffectRegions(update.changes, this.blocks);
this.decorations = buildDecorations(update.state, this.blocks);
if (needsRebuild) {
this.blocks = extractHTMLBlocks(update.view);
this.decorations = this.build();
} else {
// Just update positions of existing decorations
this.decorations = this.decorations.map(update.changes);
this.blocks = this.blocks.map(block => ({
...block,
from: update.changes.mapPos(block.from),
to: update.changes.mapPos(block.to)
}));
} }
} }
} }
private build(): DecorationSet {
const deco: Range<Decoration>[] = [];
for (const block of this.blocks) {
deco.push(
Decoration.widget({
widget: new HTMLIndicatorWidget(block),
side: 1
}).range(block.to)
);
}
return Decoration.set(deco, true);
}
} }
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, { const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
decorations: (v) => v.decorations decorations: (v) => v.decorations
}); });
class HTMLBlockWidget extends WidgetType {
constructor(public data: EmbedBlockData) {
super();
}
toDOM(view: EditorView): HTMLElement {
const wrapper = document.createElement('span');
wrapper.className = 'cm-html-block-widget';
// Content container
const content = document.createElement('span');
content.className = 'cm-html-block-content';
// This is sanitized!
content.innerHTML = this.data.content;
// Edit button
const editBtn = document.createElement('button');
editBtn.className = 'cm-html-block-edit-btn';
editBtn.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>`;
editBtn.title = 'Edit HTML';
editBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
selection: { anchor: this.data.from }
});
view.focus();
});
wrapper.appendChild(content);
wrapper.appendChild(editBtn);
return wrapper;
}
eq(widget: HTMLBlockWidget): boolean {
return JSON.stringify(widget.data) === JSON.stringify(this.data);
}
}
/** /**
* Base theme for HTML blocks. * Close tooltip when clicking outside.
*/ */
const baseTheme = EditorView.baseTheme({ const clickOutsideHandler = EditorView.domEventHandlers({
'.cm-html-block-widget': { click(event, view) {
display: 'inline-block', const target = event.target as HTMLElement;
position: 'relative',
maxWidth: '100%', // Don't close if clicking on indicator or inside tooltip
overflow: 'auto', if (target.closest('.cm-html-indicator') || target.closest('.cm-html-tooltip')) {
verticalAlign: 'middle' return false;
}
// Close tooltip if one is open
const currentTooltip = view.state.field(htmlTooltipState);
if (currentTooltip) {
view.dispatch({
effects: closeHTMLTooltip.of(null)
});
}
return false;
}
});
const theme = EditorView.baseTheme({
// Indicator icon
'.cm-html-indicator': {
display: 'inline-flex',
alignItems: 'center',
marginLeft: '4px',
verticalAlign: 'middle',
cursor: 'pointer',
opacity: '0.5',
color: 'var(--cm-html-color, #e44d26)',
transition: 'opacity 0.15s',
'& svg': { width: '14px', height: '14px' }
}, },
'.cm-html-block-content': { '.cm-html-indicator:hover': {
display: 'inline-block' opacity: '1'
}, },
// Ensure images are properly sized
'.cm-html-block-content img': { // Tooltip content
'.cm-html-tooltip': {
padding: '8px 12px',
maxWidth: '60vw',
maxHeight: '50vh',
overflow: 'auto'
},
// Images inside tooltip
'.cm-html-tooltip img': {
maxWidth: '100%', maxWidth: '100%',
height: 'auto', height: 'auto',
display: 'block' display: 'block'
}, },
'.cm-html-block-edit-btn': {
position: 'absolute', // Style the parent tooltip container
top: '4px', '.cm-tooltip:has(.cm-html-tooltip)': {
right: '4px', background: 'var(--bg-primary, #fff)',
padding: '4px', border: '1px solid var(--border-color, #ddd)',
border: 'none',
borderRadius: '4px', borderRadius: '4px',
background: 'rgba(128, 128, 128, 0.2)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
color: 'inherit',
cursor: 'pointer',
opacity: '0',
transition: 'opacity 0.2s, background 0.2s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: '10'
},
'.cm-html-block-widget:hover .cm-html-block-edit-btn': {
opacity: '1'
},
'.cm-html-block-edit-btn:hover': {
background: 'rgba(128, 128, 128, 0.4)'
} }
}); });
// Export the extension with theme /**
export const htmlBlockExtension = [htmlBlockPlugin, baseTheme]; * HTML block extension.
*
* Features:
* - Identifies HTML blocks and tags (excluding those inside tables)
* - Shows indicator icon at the end
* - Click to preview rendered HTML
*/
export const html = (): Extension => [
htmlBlockPlugin,
htmlTooltipState,
clickOutsideHandler,
theme
];

View File

@@ -1,5 +1,14 @@
/**
* Image plugin for CodeMirror.
*
* Features:
* - Identifies markdown images
* - Shows indicator icon at the end
* - Click to preview image
*/
import { syntaxTree } from '@codemirror/language'; import { syntaxTree } from '@codemirror/language';
import { Extension, Range } from '@codemirror/state'; import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
import { import {
DecorationSet, DecorationSet,
Decoration, Decoration,
@@ -7,7 +16,7 @@ import {
EditorView, EditorView,
ViewPlugin, ViewPlugin,
ViewUpdate, ViewUpdate,
hoverTooltip, showTooltip,
Tooltip Tooltip
} from '@codemirror/view'; } from '@codemirror/view';
@@ -26,6 +35,25 @@ function isImageUrl(url: string): boolean {
return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/'); return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/');
} }
/**
* Check if document changes affect any of the given regions.
*/
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
if (regions.length === 0) return true;
let affected = false;
changes.iterChanges((fromA, toA) => {
if (affected) return;
for (const region of regions) {
if (fromA <= region.to && toA >= region.from) {
affected = true;
return;
}
}
});
return affected;
}
function extractImages(view: EditorView): ImageInfo[] { function extractImages(view: EditorView): ImageInfo[] {
const result: ImageInfo[] = []; const result: ImageInfo[] = [];
for (const { from, to } of view.visibleRanges) { for (const { from, to } of view.visibleRanges) {
@@ -47,23 +75,115 @@ function extractImages(view: EditorView): ImageInfo[] {
return result; return result;
} }
/** Effect to toggle tooltip visibility */
const toggleImageTooltip = StateEffect.define<ImageInfo | null>();
/** Effect to close tooltip */
const closeImageTooltip = StateEffect.define<null>();
/** StateField to track active tooltip */
const imageTooltipState = StateField.define<ImageInfo | null>({
create: () => null,
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(toggleImageTooltip)) {
// Toggle: if same image, close; otherwise open new
if (value && effect.value && value.from === effect.value.from) {
return null;
}
return effect.value;
}
if (effect.is(closeImageTooltip)) {
return null;
}
}
// Close tooltip on document changes
if (tr.docChanged) {
return null;
}
return value;
},
provide: (field) =>
showTooltip.from(field, (img): Tooltip | null => {
if (!img) return null;
return {
pos: img.to,
above: true,
create: () => {
const dom = document.createElement('div');
dom.className = 'cm-image-tooltip cm-image-loading';
const spinner = document.createElement('span');
spinner.className = 'cm-image-spinner';
const imgEl = document.createElement('img');
imgEl.src = img.src;
imgEl.alt = img.alt;
imgEl.onload = () => {
dom.classList.remove('cm-image-loading');
};
imgEl.onerror = () => {
spinner.remove();
imgEl.remove();
dom.textContent = 'Failed to load image';
dom.classList.remove('cm-image-loading');
dom.classList.add('cm-image-tooltip-error');
};
dom.append(spinner, imgEl);
// Prevent clicks inside tooltip from closing it
dom.addEventListener('click', (e) => {
e.stopPropagation();
});
return { dom };
}
};
})
});
/**
* Indicator widget shown at the end of images.
* Clicking toggles the tooltip.
*/
class IndicatorWidget extends WidgetType { class IndicatorWidget extends WidgetType {
constructor(readonly info: ImageInfo) { constructor(readonly info: ImageInfo) {
super(); super();
} }
toDOM(): HTMLElement { toDOM(view: EditorView): HTMLElement {
const el = document.createElement('span'); const el = document.createElement('span');
el.className = 'cm-image-indicator'; el.className = 'cm-image-indicator';
el.innerHTML = ICON; el.innerHTML = ICON;
el.title = 'Click to preview image';
// Click handler to toggle tooltip
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
effects: toggleImageTooltip.of(this.info)
});
});
return el; return el;
} }
eq(other: IndicatorWidget): boolean { eq(other: IndicatorWidget): boolean {
return this.info.from === other.info.from && this.info.src === other.info.src; return this.info.from === other.info.from && this.info.src === other.info.src;
} }
ignoreEvent(): boolean {
return false;
}
} }
/**
* Plugin to manage image decorations.
* Optimized with incremental updates when changes don't affect image regions.
*/
class ImagePlugin { class ImagePlugin {
decorations: DecorationSet; decorations: DecorationSet;
images: ImageInfo[] = []; images: ImageInfo[] = [];
@@ -74,9 +194,29 @@ class ImagePlugin {
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) { // Always rebuild on viewport change
if (update.viewportChanged) {
this.images = extractImages(update.view); this.images = extractImages(update.view);
this.decorations = this.build(); this.decorations = this.build();
return;
}
// For document changes, only rebuild if changes affect image regions
if (update.docChanged) {
const needsRebuild = changesAffectRegions(update.changes, this.images);
if (needsRebuild) {
this.images = extractImages(update.view);
this.decorations = this.build();
} else {
// Just update positions of existing decorations
this.decorations = this.decorations.map(update.changes);
this.images = this.images.map(img => ({
...img,
from: update.changes.mapPos(img.from),
to: update.changes.mapPos(img.to)
}));
}
} }
} }
@@ -87,62 +227,35 @@ class ImagePlugin {
} }
return Decoration.set(deco, true); return Decoration.set(deco, true);
} }
getImageAt(pos: number): ImageInfo | null {
for (const img of this.images) {
if (pos >= img.to && pos <= img.to + 1) {
return img;
}
}
return null;
}
} }
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, { const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
decorations: (v) => v.decorations decorations: (v) => v.decorations
}); });
const imageHoverTooltip = hoverTooltip( /**
(view, pos): Tooltip | null => { * Close tooltip when clicking outside.
const plugin = view.plugin(imagePlugin); */
if (!plugin) return null; const clickOutsideHandler = EditorView.domEventHandlers({
click(event, view) {
const target = event.target as HTMLElement;
const img = plugin.getImageAt(pos); // Don't close if clicking on indicator or inside tooltip
if (!img) return null; if (target.closest('.cm-image-indicator') || target.closest('.cm-image-tooltip')) {
return false;
}
return { // Close tooltip if one is open
pos: img.to, const currentTooltip = view.state.field(imageTooltipState);
above: true, if (currentTooltip) {
arrow: true, view.dispatch({
create: () => { effects: closeImageTooltip.of(null)
const dom = document.createElement('div'); });
dom.className = 'cm-image-tooltip cm-image-loading'; }
const spinner = document.createElement('span'); return false;
spinner.className = 'cm-image-spinner'; }
});
const imgEl = document.createElement('img');
imgEl.src = img.src;
imgEl.alt = img.alt;
imgEl.onload = () => {
dom.classList.remove('cm-image-loading');
};
imgEl.onerror = () => {
spinner.remove();
imgEl.remove();
dom.textContent = 'Failed to load image';
dom.classList.remove('cm-image-loading');
dom.classList.add('cm-image-tooltip-error');
};
dom.append(spinner, imgEl);
return { dom };
}
};
},
{ hoverTime: 300 }
);
const theme = EditorView.baseTheme({ const theme = EditorView.baseTheme({
'.cm-image-indicator': { '.cm-image-indicator': {
@@ -157,6 +270,7 @@ const theme = EditorView.baseTheme({
'& svg': { width: '14px', height: '14px' } '& svg': { width: '14px', height: '14px' }
}, },
'.cm-image-indicator:hover': { opacity: '1' }, '.cm-image-indicator:hover': { opacity: '1' },
'.cm-image-tooltip': { '.cm-image-tooltip': {
position: 'relative', position: 'relative',
background: ` background: `
@@ -205,16 +319,13 @@ const theme = EditorView.baseTheme({
'.cm-image-tooltip-error': { '.cm-image-tooltip-error': {
padding: '16px 24px', padding: '16px 24px',
fontSize: '12px', fontSize: '12px',
color: 'var(--text-muted)' color: 'red'
},
'.cm-tooltip-arrow:before': {
borderTopColor: 'var(--border-color) !important',
borderBottomColor: 'var(--border-color) !important'
},
'.cm-tooltip-arrow:after': {
borderTopColor: '#fff !important',
borderBottomColor: '#fff !important'
} }
}); });
export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme]; export const image = (): Extension => [
imagePlugin,
imageTooltipState,
clickOutsideHandler,
theme
];

View File

@@ -1,111 +0,0 @@
import { Extension, Range } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
/**
* Inline code styling plugin.
*
* This plugin adds visual styling to inline code (`code`):
* - Background color
* - Border radius
* - Padding effect via marks
*/
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
/**
* Build inline code decorations.
*/
function buildInlineCodeDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
if (type.name !== 'InlineCode') return;
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Skip background decoration when cursor is in the code
// This allows selection highlighting to be visible when editing
if (cursorInCode) return;
// Get the actual code content (excluding backticks)
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
// Find backtick positions
let codeStart = nodeFrom;
let codeEnd = nodeTo;
// Skip opening backticks
let i = 0;
while (i < text.length && text[i] === '`') {
codeStart++;
i++;
}
// Skip closing backticks
let j = text.length - 1;
while (j >= 0 && text[j] === '`') {
codeEnd--;
j--;
}
// Only add decoration if there's actual content
if (codeStart < codeEnd) {
// Add mark decoration for the code content
decorations.push(
Decoration.mark({
class: 'cm-inline-code'
}).range(codeStart, codeEnd)
);
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Inline code plugin class.
*/
class InlineCodePlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildInlineCodeDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = buildInlineCodeDecorations(update.view);
}
}
}
const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for inline code.
* Uses CSS variables from variables.css for consistent theming.
*/
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)'
}
});

View File

@@ -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)'
}
});

View File

@@ -1,114 +0,0 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Insert plugin using syntax tree.
*
* Uses the custom Insert extension to detect:
* - Insert: ++text++ → renders as inserted text (underline)
*
* Examples:
* - This is ++inserted++ text → This is <ins>inserted</ins> text
* - Please ++review this section++ carefully
*/
export const insert = (): Extension => [
insertPlugin,
baseTheme
];
/**
* Build decorations for insert using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Insert nodes
if (type.name === 'Insert') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ++ characters)
const marks = node.getChildren('InsertMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing ++ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply insert style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-insert'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class InsertPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const insertPlugin = ViewPlugin.fromClass(
InsertPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for insert.
* Uses underline decoration for inserted text.
*/
const baseTheme = EditorView.baseTheme({
'.cm-insert': {
textDecoration: 'underline',
}
});

View File

@@ -1,142 +1,111 @@
import { syntaxTree } from '@codemirror/language'; /**
import { Range } from '@codemirror/state'; * Link handler with underline and clickable icon.
import { */
Decoration,
DecorationSet, import { Decoration, EditorView, WidgetType } from '@codemirror/view';
EditorView, import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
ViewPlugin, import { SyntaxNode } from '@lezer/common';
ViewUpdate import { BuildContext } from './types';
} from '@codemirror/view'; import * as runtime from "@wailsio/runtime";
import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util';
const BLACKLISTED_LINK_PARENTS = new Set(['Image', 'LinkReference']);
/** Link text decoration with underline */
const linkTextDecoration = Decoration.mark({ class: 'cm-md-link-text' });
/** Link icon widget - clickable to open URL */
class LinkIconWidget extends WidgetType {
constructor(readonly url: string) { super(); }
eq(other: LinkIconWidget) { return this.url === other.url; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-md-link-icon';
span.textContent = '🔗';
span.title = this.url;
span.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
runtime.Browser.OpenURL(this.url);
};
return span;
}
ignoreEvent(e: Event) { return e.type === 'mousedown'; }
}
/** /**
* Pattern for auto-link markers (< and >). * Handle URL node (within Link).
*/ */
const AUTO_LINK_MARK_RE = /^<|>$/g; export function handleURL(
ctx: BuildContext,
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;
/** // Get link text node (content between first [ and ])
* Parent node types that should not process. const linkText = parent.getChild('LinkLabel');
* - Image: handled by image plugin const marks = parent.getChildren('LinkMark');
* - LinkReference: reference link definitions like [label]: url should be fully visible const linkTitle = parent.getChild('LinkTitle');
*/ const closeBracket = marks.find(m => ctx.view.state.sliceDoc(m.from, m.to) === ']');
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
if (closeBracket && nf < closeBracket.from) return;
/** // Get URL for the icon
* Links plugin. const url = ctx.view.state.sliceDoc(nf, nt);
*
* Features:
* - Hides link markup when cursor is outside
* - Link icons and click events are handled by hyperlink extension
*/
export const links = () => [goToLinkPlugin];
/** // Add underline decoration to link text
* Build link decorations. if (linkText) {
* Only hides markdown syntax marks, no icons added. ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
* Uses array + Decoration.set() for automatic sorting. }
*/
function buildLinkDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const selectionRanges = view.state.selection.ranges;
for (const { from, to } of view.visibleRanges) { // Hide markdown syntax marks
syntaxTree(view.state).iterate({ for (const m of marks) {
from, ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
to, }
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
if (type.name !== 'URL') return; // 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 });
}
const parent = node.parent; // Add clickable icon widget after link text (at close bracket position)
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return; if (closeBracket) {
ctx.items.push({
const marks = parent.getChildren('LinkMark'); from: closeBracket.from,
const linkTitle = parent.getChild('LinkTitle'); to: closeBracket.from,
deco: Decoration.widget({ widget: new LinkIconWidget(url), side: 1 }),
// Find the ']' mark position to distinguish between link text and link target priority: 1
// Link structure: [display text](url)
// We should only hide the URL in the () part, not in the [] part
const closeBracketMark = marks.find((mark) => {
const text = view.state.sliceDoc(mark.from, mark.to);
return text === ']';
});
// If URL is before ']', it's part of the display text, don't hide it
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
return;
}
// Check if cursor overlaps with the link
const cursorOverlaps = selectionRanges.some((range) =>
checkRangeOverlap([range.from, range.to], [parent.from, parent.to])
);
// Hide link marks and URL when cursor is outside
if (!cursorOverlaps && marks.length > 0) {
for (const mark of marks) {
decorations.push(invisibleDecoration.range(mark.from, mark.to));
}
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
if (linkTitle) {
decorations.push(invisibleDecoration.range(linkTitle.from, linkTitle.to));
}
}
// Get link content
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
// Handle auto-links with < > markers
if (AUTO_LINK_MARK_RE.test(linkContent)) {
if (!isCursorInRange(view.state, [node.from, node.to])) {
decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1));
decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo));
}
}
}
}); });
} }
// Use Decoration.set with sort=true to handle unsorted ranges
return Decoration.set(decorations, true);
} }
/** /**
* Link plugin with optimized update detection. * Theme for markdown links.
*/ */
class LinkPlugin { export const linkTheme = EditorView.baseTheme({
decorations: DecorationSet; '.cm-md-link-text': {
private lastSelectionRanges: string = ''; color: 'var(--cm-link-color, #0969da)',
textDecoration: 'underline',
constructor(view: EditorView) { textUnderlineOffset: '2px',
this.decorations = buildLinkDecorations(view); cursor: 'text'
this.lastSelectionRanges = this.serializeSelection(view); },
} '.cm-md-link-icon': {
cursor: 'pointer',
update(update: ViewUpdate) { marginLeft: '0.2em',
// Always rebuild on doc or viewport change opacity: '0.7',
if (update.docChanged || update.viewportChanged) { transition: 'opacity 0.15s ease',
this.decorations = buildLinkDecorations(update.view); '&:hover': {
this.lastSelectionRanges = this.serializeSelection(update.view); opacity: '1'
return;
}
// For selection changes, check if selection actually changed
if (update.selectionSet) {
const newRanges = this.serializeSelection(update.view);
if (newRanges !== this.lastSelectionRanges) {
this.decorations = buildLinkDecorations(update.view);
this.lastSelectionRanges = newRanges;
}
} }
} }
private serializeSelection(view: EditorView): string {
return view.state.selection.ranges
.map((r) => `${r.from}:${r.to}`)
.join(',');
}
}
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
decorations: (v) => v.decorations
}); });

View File

@@ -1,45 +1,18 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { Range, StateField, Transaction } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { isCursorInRange } from '../util';
/** /**
* Pattern for bullet list markers. * List handlers and theme.
* Handles: ListMark (bullets), Task (checkboxes)
*/ */
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
/** import { Decoration, EditorView, WidgetType } from '@codemirror/view';
* Lists plugin. import { checkRangeOverlap, RangeTuple } from '../util';
* import { SyntaxNode } from '@lezer/common';
* Features: import { BuildContext } from './types';
* - Custom bullet mark rendering (- → •)
* - Interactive task list checkboxes
*/
export const lists = () => [listBulletPlugin, taskListField, baseTheme];
// ============================================================================ const BULLET_RE = /^[-+*]$/;
// List Bullet Plugin
// ============================================================================
/**
* Widget to render list bullet mark.
*/
class ListBulletWidget extends WidgetType { class ListBulletWidget extends WidgetType {
constructor(readonly bullet: string) { constructor(readonly bullet: string) { super(); }
super(); 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';
@@ -48,209 +21,84 @@ class ListBulletWidget extends WidgetType {
} }
} }
/**
* Build list bullet decorations.
*/
function buildListBulletDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
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 if this is part of a task list (has Task sibling)
const parent = node.parent;
if (parent) {
const task = parent.getChild('Task');
if (task) return;
}
// Skip if cursor is in this range
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
const listMark = view.state.sliceDoc(nodeFrom, nodeTo);
if (BULLET_LIST_MARKER_RE.test(listMark)) {
decorations.push(
Decoration.replace({
widget: new ListBulletWidget(listMark)
}).range(nodeFrom, nodeTo)
);
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* List bullet plugin.
*/
class ListBulletPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildListBulletDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildListBulletDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
const oldLine = update.startState.doc.lineAt(this.lastSelectionHead);
const newLine = update.state.doc.lineAt(newHead);
if (oldLine.number !== newLine.number) {
this.decorations = buildListBulletDecorations(update.view);
}
this.lastSelectionHead = newHead;
}
}
}
const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
decorations: (v) => v.decorations
});
// ============================================================================
// Task List Plugin (using StateField to avoid flickering)
// ============================================================================
/**
* Widget to render checkbox for a task list item.
*/
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 // Position of the checkbox character in document
) {
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;
// Handle click directly in the widget
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) }) });
} }
} }
/** /**
* Build task list decorations from state. * Handle Task node (checkboxes).
*/ */
function buildTaskListDecorations(state: import('@codemirror/state').EditorState): DecorationSet { export function handleTask(
const decorations: Range<Decoration>[] = []; ctx: BuildContext,
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;
syntaxTree(state).iterate({ const markerText = ctx.view.state.sliceDoc(taskMarker.from, taskMarker.to);
enter: ({ type, from: taskFrom, to: taskTo, node }) => { const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
if (type.name !== 'Task') return; if (isChecked) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.mark({ class: 'cm-task-checked' }), priority: 0 });
const listItem = node.parent; }
if (!listItem || listItem.type.name !== 'ListItem') return; ctx.items.push({ from: listMark.from, to: taskMarker.to, deco: Decoration.replace({ widget: new TaskCheckboxWidget(isChecked, taskMarker.from + 1) }), priority: 1 });
const listMark = listItem.getChild('ListMark');
const taskMarker = node.getChild('TaskMarker');
if (!listMark || !taskMarker) return;
const replaceFrom = listMark.from;
const replaceTo = taskMarker.to;
// Check if cursor is in this range
if (isCursorInRange(state, [replaceFrom, replaceTo])) return;
// Check if task is checked - position of x or space is taskMarker.from + 1
const markerText = state.sliceDoc(taskMarker.from, taskMarker.to);
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
const checkboxPos = taskMarker.from + 1; // Position of the x or space
if (isChecked) {
decorations.push(
Decoration.mark({ class: 'cm-task-checked' }).range(taskFrom, taskTo)
);
}
// Replace "- [x]" or "- [ ]" with checkbox widget
decorations.push(
Decoration.replace({
widget: new TaskCheckboxWidget(isChecked, checkboxPos)
}).range(replaceFrom, replaceTo)
);
}
});
return Decoration.set(decorations, true);
} }
/** /**
* Task list StateField - uses incremental updates to avoid flickering. * Theme for lists.
*/ */
const taskListField = StateField.define<DecorationSet>({ export const listTheme = EditorView.baseTheme({
create(state) {
return buildTaskListDecorations(state);
},
update(value, tr: Transaction) {
// Only rebuild when document or selection changes
if (tr.docChanged || tr.selection) {
return buildTaskListDecorations(tr.state);
}
return value;
},
provide(field) {
return EditorView.decorations.from(field);
}
});
// ============================================================================
// Theme
// ============================================================================
/**
* Base theme for lists.
*/
const baseTheme = EditorView.baseTheme({
'.cm-list-bullet': { '.cm-list-bullet': {
color: 'var(--cm-list-bullet-color, inherit)' color: 'var(--cm-list-bullet-color, inherit)'
}, },

View File

@@ -1,291 +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';
// ============================================================================
// Inline Math Widget
// ============================================================================
/**
* Widget to display inline math formula.
*/
class InlineMathWidget extends WidgetType { class InlineMathWidget extends WidgetType {
private html: string; constructor(readonly latex: string) { super(); }
private error: string | null = null; eq(other: InlineMathWidget) { return this.latex === other.latex; }
constructor(readonly latex: string) {
super();
try {
this.html = katex.renderToString(latex, {
throwOnError: true,
displayMode: false,
output: 'html'
});
} catch (e) {
this.error = e instanceof Error ? e.message : 'Render error';
this.html = '';
}
}
toDOM(): HTMLElement { toDOM(): HTMLElement {
const span = document.createElement('span'); const span = document.createElement('span');
span.className = 'cm-inline-math'; span.className = 'cm-inline-math';
try {
if (this.error) { span.innerHTML = katex.renderToString(this.latex, { throwOnError: true, displayMode: false, output: 'html' });
} catch (e) {
span.textContent = this.latex; span.textContent = this.latex;
span.title = this.error; span.title = e instanceof Error ? e.message : 'Render error';
} else {
span.innerHTML = this.html;
} }
return span; return span;
} }
ignoreEvent() { return false; }
eq(other: InlineMathWidget): boolean {
return this.latex === other.latex;
}
ignoreEvent(): boolean {
return false;
}
} }
// ============================================================================
// Block Math Widget
// ============================================================================
/**
* Widget to display block math formula.
* Uses absolute positioning to overlay on source lines.
*/
class BlockMathWidget extends WidgetType { class BlockMathWidget extends WidgetType {
private html: string; constructor(readonly latex: string, readonly lineCount: number, readonly lineHeight: number) { super(); }
private error: string | null = null; eq(other: BlockMathWidget) { return this.latex === other.latex && this.lineCount === other.lineCount; }
constructor(
readonly latex: string,
readonly lineCount: number = 1,
readonly lineHeight: number = 22
) {
super();
try {
this.html = katex.renderToString(latex, {
throwOnError: false,
displayMode: true,
output: 'html'
});
} catch (e) {
this.error = e instanceof Error ? e.message : 'Render error';
this.html = '';
}
}
toDOM(): HTMLElement { toDOM(): HTMLElement {
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 {
if (this.error) { inner.innerHTML = katex.renderToString(this.latex, { throwOnError: false, displayMode: true, output: 'html' });
} catch (e) {
inner.textContent = this.latex; inner.textContent = this.latex;
inner.title = this.error; inner.title = e instanceof Error ? e.message : 'Render error';
} else {
inner.innerHTML = this.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 });
} }
} }
// ============================================================================
// Decorations
// ============================================================================
/** /**
* Build decorations for math formulas. * Handle BlockMath node ($$...$$).
*/ */
function buildDecorations(view: EditorView): DecorationSet { export function handleBlockMath(
const decorations: Range<Decoration>[] = []; 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 { from, to } of view.visibleRanges) { const marks = node.getChildren('BlockMathMark');
syntaxTree(view.state).iterate({ if (marks.length >= 2) {
from, const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
to, const startLine = ctx.view.state.doc.lineAt(nf);
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => { const endLine = ctx.view.state.doc.lineAt(nt);
// Handle inline math const lineCount = endLine.number - startLine.number + 1;
if (type.name === 'InlineMath') { if (lineCount > 1) {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]); for (let num = startLine.number; num <= endLine.number; num++) {
const marks = node.getChildren('InlineMathMark'); ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_BLOCK_MATH_LINE });
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') {
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 Decoration.set(decorations, true);
}
// ============================================================================
// Plugin
// ============================================================================
/**
* Math plugin with optimized update detection.
*/
class MathPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
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
}
);
// ============================================================================
// Theme
// ============================================================================
/** /**
* 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',
@@ -294,65 +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'
}, }
}); });
// ============================================================================
// Export
// ============================================================================
/**
* 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;

View 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];

View File

@@ -1,152 +0,0 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Subscript and Superscript plugin using syntax tree.
*
* Uses lezer-markdown's Subscript and Superscript extensions to detect:
* - Superscript: ^text^ → renders as superscript
* - Subscript: ~text~ → renders as subscript
*
* Note: Inline footnotes ^[content] are handled by the Footnote extension
* which parses InlineFootnote before Superscript in the syntax tree.
*
* Examples:
* - 19^th^ → 19ᵗʰ (superscript)
* - H~2~O → H₂O (subscript)
*/
export const subscriptSuperscript = (): Extension => [
subscriptSuperscriptPlugin,
baseTheme
];
/**
* Build decorations for subscript and superscript using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Superscript nodes
// Note: InlineFootnote ^[content] is parsed before Superscript,
// so we don't need to check for bracket patterns here
if (type.name === 'Superscript') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ^ characters)
const marks = node.getChildren('SuperscriptMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing ^ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply superscript style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-superscript'
}).range(contentStart, contentEnd)
);
}
}
}
// Handle Subscript nodes
if (type.name === 'Subscript') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ~ characters)
const marks = node.getChildren('SubscriptMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing ~ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply subscript style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-subscript'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class SubscriptSuperscriptPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
SubscriptSuperscriptPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for subscript and superscript.
* Uses mark decoration instead of widget to avoid layout issues.
* fontSize uses smaller value as subscript/superscript are naturally smaller.
*/
const baseTheme = EditorView.baseTheme({
'.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)'
}
});

View File

@@ -0,0 +1,251 @@
/**
* Table handler and theme.
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { foldedRanges } from '@codemirror/language';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
import DOMPurify from 'dompurify';
type CellAlign = 'left' | 'center' | 'right';
interface TableData { headers: string[]; alignments: CellAlign[]; rows: string[][]; }
const DECO_TABLE_LINE_HIDDEN = Decoration.line({ class: 'cm-table-line-hidden' });
const BOLD_STAR_RE = /\*\*(.+?)\*\*/g;
const BOLD_UNDER_RE = /__(.+?)__/g;
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
const ITALIC_UNDER_RE = /(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g;
const CODE_RE = /`([^`]+)`/g;
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
const STRIKE_RE = /~~(.+?)~~/g;
const HTML_TAG_RE = /<[a-zA-Z][^>]*>|<\/[a-zA-Z][^>]*>/;
function renderInlineMarkdown(text: string): string {
let html = text;
if (HTML_TAG_RE.test(text)) {
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
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>');
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
html = DOMPurify.sanitize(html, { ADD_TAGS: ['code', 'strong', 'em', 'del', 'a'], ADD_ATTR: ['href', 'target'] });
} else {
html = html.replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
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>');
}
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';
}
class TableWidget extends WidgetType {
constructor(readonly data: TableData, readonly lineCount: number, readonly visualHeight: number, readonly contentWidth: number) { super(); }
eq(other: TableWidget) {
if (this.visualHeight !== other.visualHeight || this.contentWidth !== other.contentWidth) return false;
if (this.data === other.data) return true;
if (this.data.headers.length !== other.data.headers.length || this.data.rows.length !== other.data.rows.length) return false;
for (let i = 0; i < this.data.headers.length; i++) if (this.data.headers[i] !== other.data.headers[i]) return false;
for (let i = 0; i < this.data.rows.length; i++) {
if (this.data.rows[i].length !== other.data.rows[i].length) return false;
for (let j = 0; j < this.data.rows[i].length; j++) if (this.data.rows[i][j] !== other.data.rows[i][j]) return false;
}
return true;
}
toDOM(): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-table-container';
container.style.height = `${this.visualHeight}px`;
const wrapper = document.createElement('div');
wrapper.className = 'cm-table-wrapper';
wrapper.style.maxWidth = `${this.contentWidth}px`;
wrapper.style.maxHeight = `${this.visualHeight}px`;
const headerRatio = 2 / this.lineCount, dataRowRatio = 1 / this.lineCount;
const headerHeight = this.visualHeight * headerRatio, dataRowHeight = this.visualHeight * dataRowRatio;
const headerCells = this.data.headers.map((h, i) => `<th class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${h.replace(/"/g, '&quot;')}">${renderInlineMarkdown(h)}</th>`).join('');
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, '&quot;')}">${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(wrapper);
return container;
}
ignoreEvent() { return false; }
}
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
const folded = foldedRanges(view.state);
const cursor = folded.iter();
while (cursor.value) {
if (cursor.from < to && cursor.to > from) return true;
cursor.next();
}
return false;
}
/**
* Handle Table node.
*/
export function handleTable(
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 (isInFoldedRange(ctx.view, nf, nt) || inCursor) return;
const headerNode = node.getChild('TableHeader');
if (!headerNode) return;
const headers: string[] = [];
const alignments: CellAlign[] = [];
const rows: string[][] = [];
const headerCells = headerNode.getChildren('TableCell');
if (headerCells.length > 0) {
for (const cell of headerCells) headers.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim());
} else {
headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to)));
}
if (headers.length === 0) return;
let child = node.firstChild;
while (child) {
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;
}
}
child = child.nextSibling;
}
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 });
}
/**
* Theme for tables.
*/
export const tableTheme = EditorView.baseTheme({
'.cm-table-container': {
position: 'absolute',
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'flex-start',
pointerEvents: 'none',
zIndex: '2',
overflow: 'hidden'
},
'.cm-table-wrapper': {
display: 'inline-block',
pointerEvents: 'auto',
backgroundColor: 'var(--bg-primary)',
overflowX: 'auto',
overflowY: 'auto'
},
'.cm-table': {
borderCollapse: 'separate',
borderSpacing: '0',
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
backgroundColor: 'var(--cm-table-bg)',
border: 'none',
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
color: 'var(--text-primary) !important'
},
'.cm-table th, .cm-table td': {
padding: '0 8px',
border: 'none',
color: 'inherit !important',
verticalAlign: 'middle',
boxSizing: 'border-box',
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '300px'
},
'.cm-table td': { boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)' },
'.cm-table td:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
'.cm-table tbody tr:last-child td': { boxShadow: '-1px 0 0 var(--cm-table-border)' },
'.cm-table tbody tr:last-child td:first-child': { boxShadow: 'none' },
'.cm-table th': {
backgroundColor: 'var(--cm-table-header-bg)',
fontWeight: '600',
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 tbody tr:hover': { backgroundColor: 'var(--cm-table-row-hover)' },
'.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' },
'.cm-table code': {
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
padding: '1px 4px',
borderRadius: '3px',
fontSize: 'inherit',
fontFamily: 'var(--voidraft-font-mono)'
},
'.cm-table a': { color: 'var(--selection-text)', textDecoration: 'none' },
'.cm-table a:hover': { textDecoration: 'underline' },
'.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' }
});

View File

@@ -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
];

View File

@@ -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;

View 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;

View File

@@ -6,7 +6,7 @@ export const defaultLightColors: ThemeColors = {
dark: false, dark: false,
background: '#ffffff', background: '#ffffff',
backgroundSecondary: '#f4f7fb', backgroundSecondary: '#f1faf1',
foreground: '#24292e', foreground: '#24292e',
cursor: '#000000', cursor: '#000000',

View File

@@ -7,7 +7,7 @@ export const config: ThemeColors = {
dark: false, dark: false,
background: '#ffffff', background: '#ffffff',
backgroundSecondary: '#f1faf1', backgroundSecondary: '##f4f7fb ',
foreground: '#444d56', foreground: '#444d56',
cursor: '#044289', cursor: '#044289',