🚧 Refactor markdown preview extension

This commit is contained in:
2025-11-29 22:54:38 +08:00
parent 3521e5787b
commit 1ef5350b3f
17 changed files with 467 additions and 1888 deletions

View File

@@ -5,9 +5,9 @@
import {jsonLanguage} from "@codemirror/lang-json";
import {pythonLanguage} from "@codemirror/lang-python";
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
import {htmlLanguage} from "@codemirror/lang-html";
import {html, htmlLanguage} from "@codemirror/lang-html";
import {StandardSQL} from "@codemirror/lang-sql";
import {markdownLanguage} from "@codemirror/lang-markdown";
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
import {javaLanguage} from "@codemirror/lang-java";
import {phpLanguage} from "@codemirror/lang-php";
import {cssLanguage} from "@codemirror/lang-css";
@@ -22,9 +22,9 @@ import {wastLanguage} from "@codemirror/lang-wast";
import {sassLanguage} from "@codemirror/lang-sass";
import {lessLanguage} from "@codemirror/lang-less";
import {angularLanguage} from "@codemirror/lang-angular";
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
import { mermaidLanguage } from '@/views/editor/language/mermaid';
import {svelteLanguage} from "@replit/codemirror-lang-svelte";
import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language";
import {mermaidLanguage} from '@/views/editor/language/mermaid';
import {StreamLanguage} from "@codemirror/language";
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
import {shell} from "@codemirror/legacy-modes/mode/shell";
@@ -64,6 +64,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart";
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
import webPrettierPlugin from "@/common/prettier/plugins/web";
import * as prettierPluginEstree from "prettier/plugins/estree";
import {languages} from "@codemirror/language-data";
/**
* 语言信息类
@@ -110,7 +111,19 @@ export const LANGUAGES: LanguageInfo[] = [
parser: "sql",
plugins: [sqlPrettierPlugin]
}),
new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], {
new LanguageInfo("md", "Markdown", markdown({
base: markdownLanguage,
extensions: [],
completeHTMLTags: true,
pasteURLAsLink: true,
htmlTagLanguage: html({
matchClosingTags: true,
autoCloseTags: true
}),
addKeymap: true,
codeLanguages: languages,
}).language.parser, ["md"], {
parser: "markdown",
plugins: [markdownPrettierPlugin]
}),

View File

@@ -64,11 +64,5 @@ export const blockquote = {
emoji = {
/** Emoji widget */
widget: 'cm-emoji'
},
/** Classes for horizontal rule decorations. */
horizontalRule = {
/** Horizontal rule container */
container: 'cm-horizontal-rule-container',
/** Horizontal rule element */
rule: 'cm-horizontal-rule'
};
}

View File

@@ -8,49 +8,17 @@ import { image } from './plugins/image';
import { links } from './plugins/link';
import { lists } from './plugins/list';
import { headingSlugField } from './state/heading-slug';
import { imagePreview } from './state/image';
// New enhanced features
import { codeblockEnhanced } from './plugins/code-block-enhanced';
import { emoji } from './plugins/emoji';
import { horizontalRule } from './plugins/horizontal-rule';
import { revealOnArrow } from './plugins/reveal-on-arrow';
import { pasteRichText } from './plugins/paste-rich-text';
// State fields
export { headingSlugField } from './state/heading-slug';
export { imagePreview } from './state/image';
// Core Extensions
export { blockquote } from './plugins/blockquote';
export { codeblock } from './plugins/code-block';
export { frontmatter } from './plugins/frontmatter';
export { headings } from './plugins/heading';
export { hideMarks } from './plugins/hide-mark';
export { image } from './plugins/image';
export { htmlBlock, htmlBlockExtension } from './plugins/html';
export { links } from './plugins/link';
export { lists } from './plugins/list';
// Enhanced Extensions
export { codeblockEnhanced } from './plugins/code-block-enhanced';
export { emoji, addEmoji, getEmojiNames } from './plugins/emoji';
export { horizontalRule } from './plugins/horizontal-rule';
export { revealOnArrow } from './plugins/reveal-on-arrow';
export { pasteRichText } from './plugins/paste-rich-text';
// Classes
export * as classes from './classes';
import { inlineCode } from './plugins/inline-code';
/**
* markdown extensions (includes all ProseMark-inspired features).
* NOTE: All decorations avoid using block: true to prevent interfering
* with the codeblock system's boundary calculations.
* markdown extensions
*/
export const markdownExtensions: Extension = [
headingSlugField,
imagePreview,
blockquote(),
codeblock(),
headings(),
@@ -63,8 +31,7 @@ export const markdownExtensions: Extension = [
codeblockEnhanced(),
emoji(),
horizontalRule(),
revealOnArrow(),
pasteRichText()
inlineCode(),
];
export default markdownExtensions;

View File

@@ -53,7 +53,7 @@ class CodeBlockInfoWidget extends WidgetType {
// Copy button
const copyButton = document.createElement('button');
copyButton.className = 'cm-code-block-copy-btn';
copyButton.title = '复制代码';
copyButton.title = 'Copy';
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -162,46 +162,46 @@ const codeBlockEnhancedPlugin = ViewPlugin.fromClass(CodeBlockEnhancedPlugin, {
/**
* Enhanced theme for code blocks.
* Uses CSS variables from variables.css for consistent theming.
*/
const enhancedTheme = EditorView.baseTheme({
'.cm-code-block-info': {
float: 'right',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.2rem 0.4rem',
fontSize: '0.75rem',
opacity: '0.7',
transition: 'opacity 0.2s'
gap: '0.4rem',
padding: '0.15rem 0.3rem',
opacity: '0.6',
transition: 'opacity 0.15s ease'
},
'.cm-code-block-info:hover': {
opacity: '1'
},
'.cm-code-block-lang': {
fontFamily: 'monospace',
fontFamily: 'var(--voidraft-font-mono)',
fontSize: '0.7rem',
fontWeight: '600',
color: 'var(--cm-fg-muted, #888)'
fontWeight: '500',
letterSpacing: '0.02em',
color: 'var(--cm-codeblock-lang-color)'
},
'.cm-code-block-copy-btn': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
background: 'transparent',
padding: '0.2rem',
borderRadius: '0.25rem',
backgroundColor: 'transparent',
borderRadius: '0.2rem',
cursor: 'pointer',
color: 'var(--cm-fg-muted, #888)',
transition: 'background-color 0.2s, color 0.2s'
color: 'var(--cm-codeblock-btn-color)',
transition: 'background-color 0.15s ease, color 0.15s ease'
},
'.cm-code-block-copy-btn:hover': {
backgroundColor: 'var(--cm-bg-hover, rgba(0, 0, 0, 0.1))',
color: 'var(--cm-fg, inherit)'
// backgroundColor: 'var(--cm-codeblock-btn-hover-bg)',
color: 'var(--cm-codeblock-btn-hover-color)'
},
'.cm-code-block-copy-btn svg': {
width: '16px',
height: '16px'
width: '14px',
height: '14px'
}
});

View File

@@ -56,25 +56,26 @@ function buildCodeBlockDecorations(view: EditorView): DecorationSet {
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
const line = view.state.doc.line(lineNum);
// Determine line position class
let positionClass = '';
if (lineNum === startLine.number) {
positionClass = classes.widgetBegin;
} else if (lineNum === endLine.number) {
positionClass = classes.widgetEnd;
}
// Determine line position class(es)
const isFirst = lineNum === startLine.number;
const isLast = lineNum === endLine.number;
// Build class list - a single line block needs both begin and end classes
const positionClasses: string[] = [];
if (isFirst) positionClasses.push(classes.widgetBegin);
if (isLast) positionClasses.push(classes.widgetEnd);
decorations.push(
Decoration.line({
class: `${classes.widget} ${positionClass}`.trim()
class: `${classes.widget} ${positionClasses.join(' ')}`.trim()
}).range(line.from)
);
}
// Hide code markers when cursor is outside the block
if (!cursorInBlock) {
const codeBlock = node.toTree();
codeBlock.iterate({
const codeBlock = node.toTree();
codeBlock.iterate({
enter: ({ type: childType, from: childFrom, to: childTo }) => {
if (childType.name === 'CodeInfo' || childType.name === 'CodeMark') {
decorations.push(
@@ -83,12 +84,12 @@ function buildCodeBlockDecorations(view: EditorView): DecorationSet {
nodeFrom + childTo
)
);
}
}
});
}
}
});
}
}
});
});
}
// Use Decoration.set with sort=true to handle unsorted ranges
@@ -139,15 +140,18 @@ const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, {
/**
* Base theme for code blocks.
* Uses CSS variables from variables.css for consistent theming.
*/
const baseTheme = EditorView.baseTheme({
[`.${classes.widget}`]: {
backgroundColor: 'var(--cm-codeblock-bg, rgba(128, 128, 128, 0.1))'
backgroundColor: 'var(--cm-codeblock-bg)',
},
[`.${classes.widgetBegin}`]: {
borderRadius: '5px 5px 0 0'
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
borderTopRightRadius: 'var(--cm-codeblock-radius)'
},
[`.${classes.widgetEnd}`]: {
borderRadius: '0 0 5px 5px'
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
}
});

View File

@@ -16,9 +16,9 @@ function isSelectionInRange(state: EditorState, from: number, to: number): boole
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.
*/

View File

@@ -22,9 +22,7 @@ function extractHTMLBlocks(state: EditorState) {
if (name !== 'HTMLBlock') return;
if (isCursorInRange(state, [from, to])) return;
const html = state.sliceDoc(from, to);
const content = DOMPurify.sanitize(html, {
FORBID_ATTR: ['style']
});
const content = DOMPurify.sanitize(html);
blocks.push({
from,
@@ -36,14 +34,26 @@ function extractHTMLBlocks(state: EditorState) {
return blocks;
}
// Decoration to hide the original HTML source code
const hideDecoration = Decoration.replace({});
function blockToDecoration(blocks: EmbedBlockData[]): Range<Decoration>[] {
return blocks.map((block) =>
Decoration.widget({
widget: new HTMLBlockWidget(block),
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
side: 1
}).range(block.to)
);
const decorations: Range<Decoration>[] = [];
for (const block of blocks) {
// Hide the original HTML source code
decorations.push(hideDecoration.range(block.from, block.to));
// Add the preview widget at the end
decorations.push(
Decoration.widget({
widget: new HTMLBlockWidget(block),
side: 1
}).range(block.to)
);
}
return decorations;
}
export const htmlBlock = StateField.define<DecorationSet>({
@@ -69,12 +79,38 @@ class HTMLBlockWidget extends WidgetType {
super();
}
toDOM(): HTMLElement {
const dom = document.createElement('span');
dom.className = 'cm-html-block-widget';
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!
dom.innerHTML = this.data.content;
return dom;
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 {
@@ -87,9 +123,36 @@ class HTMLBlockWidget extends WidgetType {
*/
const baseTheme = EditorView.baseTheme({
'.cm-html-block-widget': {
display: 'inline-block',
display: 'block',
position: 'relative',
width: '100%',
overflow: 'auto'
},
'.cm-html-block-content': {
display: 'block'
},
'.cm-html-block-edit-btn': {
position: 'absolute',
top: '4px',
right: '4px',
padding: '4px',
border: 'none',
borderRadius: '4px',
background: 'rgba(128, 128, 128, 0.2)',
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)'
}
});

View File

@@ -14,7 +14,11 @@ import {
invisibleDecoration
} from '../util';
function hideNodes(view: EditorView) {
/**
* Build decorations to hide image markdown syntax.
* Only hides when cursor is outside the image range.
*/
function hideImageNodes(view: EditorView) {
const widgets = new Array<Range<Decoration>>();
iterateTreeInVisibleRanges(view, {
enter(node) {
@@ -29,32 +33,31 @@ function hideNodes(view: EditorView) {
return Decoration.set(widgets, true);
}
/**
* Plugin to hide image markdown syntax when cursor is outside.
*/
const hideImageNodePlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = hideNodes(view);
this.decorations = hideImageNodes(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet)
this.decorations = hideNodes(update.view);
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = hideImageNodes(update.view);
}
}
},
{ decorations: (v) => v.decorations }
);
/**
* Ixora Image plugin.
*
* This plugin allows to
* - Add a preview of an image in the document.
*
* @returns The image plugin.
* Image plugin.
*/
export const image = (): Extension => [
imagePreview,
imagePreview(),
hideImageNodePlugin,
baseTheme
];
@@ -64,7 +67,6 @@ const baseTheme = EditorView.baseTheme({
display: 'block',
objectFit: 'contain',
maxWidth: '100%',
paddingLeft: '4px',
maxHeight: '100%',
userSelect: 'none'
}

View File

@@ -0,0 +1,112 @@
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;
// 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) {
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Add mark decoration for the code content
decorations.push(
Decoration.mark({
class: cursorInCode ? 'cm-inline-code cm-inline-code-active' : '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)',
fontSize: '0.9em'
},
'.cm-inline-code-active': {
// Slightly different style when cursor is inside
backgroundColor: 'var(--cm-inline-code-bg)'
}
});

View File

@@ -5,12 +5,9 @@ import {
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
ViewUpdate
} from '@codemirror/view';
import { headingSlugField } from '../state/heading-slug';
import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util';
import { link as classes } from '../classes';
/**
* Pattern for auto-link markers (< and >).
@@ -18,7 +15,7 @@ import { link as classes } from '../classes';
const AUTO_LINK_MARK_RE = /^<|>$/g;
/**
* Parent node types that should not have link widgets.
* Parent node types that should not process.
*/
const BLACKLISTED_PARENTS = new Set(['Image']);
@@ -26,69 +23,14 @@ const BLACKLISTED_PARENTS = new Set(['Image']);
* Links plugin.
*
* Features:
* - Adds interactive link icon for navigation
* - Supports internal anchor links (#heading)
* - Hides link markup when cursor is outside
* - Link icons and click events are handled by hyperlink extension
*/
export const links = () => [goToLinkPlugin, baseTheme];
/**
* Link widget for external/internal navigation.
*/
export class GoToLinkWidget extends WidgetType {
constructor(
readonly link: string,
readonly title?: string
) {
super();
}
eq(other: GoToLinkWidget): boolean {
return other.link === this.link && other.title === this.title;
}
toDOM(view: EditorView): HTMLElement {
const anchor = document.createElement('a');
anchor.classList.add(classes.widget);
anchor.textContent = '🔗';
if (this.link.startsWith('#')) {
// Handle internal anchor links
anchor.href = 'javascript:void(0)';
anchor.addEventListener('click', (e) => {
e.preventDefault();
const slugs = view.state.field(headingSlugField);
const targetSlug = this.link.slice(1);
const pos = slugs.find((h) => h.slug === targetSlug)?.pos;
if (typeof pos !== 'undefined') {
view.dispatch({
selection: { anchor: pos },
scrollIntoView: true
});
}
});
} else {
// External links
anchor.href = this.link;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
}
if (this.title) {
anchor.title = this.title;
}
return anchor;
}
ignoreEvent(): boolean {
return false;
}
}
export const links = () => [goToLinkPlugin];
/**
* Build link decorations.
* Only hides markdown syntax marks, no icons added.
* Uses array + Decoration.set() for automatic sorting.
*/
function buildLinkDecorations(view: EditorView): DecorationSet {
@@ -126,30 +68,15 @@ function buildLinkDecorations(view: EditorView): DecorationSet {
}
// Get link content
let linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
// Handle auto-links with < > markers
if (AUTO_LINK_MARK_RE.test(linkContent)) {
linkContent = linkContent.replace(AUTO_LINK_MARK_RE, '');
if (!isCursorInRange(view.state, [node.from, node.to])) {
decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1));
decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo));
}
}
// Get link title content
const linkTitleContent = linkTitle
? view.state.sliceDoc(linkTitle.from, linkTitle.to)
: undefined;
// Add link widget
decorations.push(
Decoration.widget({
widget: new GoToLinkWidget(linkContent, linkTitleContent),
side: 1
}).range(nodeTo)
);
}
});
}
@@ -198,18 +125,3 @@ class LinkPlugin {
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for links.
*/
const baseTheme = EditorView.baseTheme({
[`.${classes.widget}`]: {
cursor: 'pointer',
textDecoration: 'none',
opacity: '0.7',
transition: 'opacity 0.2s'
},
[`.${classes.widget}:hover`]: {
opacity: '1'
}
});

View File

@@ -270,7 +270,6 @@ const baseTheme = EditorView.baseTheme({
[`.${classes.taskCheckbox} input`]: {
cursor: 'pointer',
margin: '0',
marginRight: '0.35em',
width: '1em',
height: '1em',
position: 'relative',

View File

@@ -1,201 +0,0 @@
import { Extension } from '@codemirror/state';
import { EditorView, keymap, type Command } from '@codemirror/view';
/**
* Paste rich text as markdown.
*
* This plugin:
* - Intercepts paste events containing HTML
* - Converts HTML to Markdown format
* - Supports common formatting: bold, italic, links, lists, etc.
* - Provides Ctrl/Cmd+Shift+V for plain text paste
*/
export const pasteRichText = (): Extension => [
pasteRichTextHandler,
pastePlainTextKeymap
];
/**
* Convert HTML to Markdown.
* Simplified implementation for common HTML elements.
*/
function htmlToMarkdown(html: string): string {
// Create a temporary DOM element to parse HTML
const temp = document.createElement('div');
temp.innerHTML = html;
return convertNode(temp);
}
/**
* Convert a DOM node to Markdown recursively.
*/
function convertNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || '';
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const element = node as HTMLElement;
const children = Array.from(element.childNodes)
.map(convertNode)
.join('');
switch (element.tagName.toLowerCase()) {
case 'strong':
case 'b':
return `**${children}**`;
case 'em':
case 'i':
return `*${children}*`;
case 'code':
return `\`${children}\``;
case 'pre':
return `\n\`\`\`\n${children}\n\`\`\`\n`;
case 'a': {
const href = element.getAttribute('href') || '';
return `[${children}](${href})`;
}
case 'img': {
const src = element.getAttribute('src') || '';
const alt = element.getAttribute('alt') || '';
return `![${alt}](${src})`;
}
case 'h1':
return `\n# ${children}\n`;
case 'h2':
return `\n## ${children}\n`;
case 'h3':
return `\n### ${children}\n`;
case 'h4':
return `\n#### ${children}\n`;
case 'h5':
return `\n##### ${children}\n`;
case 'h6':
return `\n###### ${children}\n`;
case 'p':
return `\n${children}\n`;
case 'br':
return '\n';
case 'hr':
return '\n---\n';
case 'blockquote':
return children
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
case 'ul':
case 'ol': {
const items = Array.from(element.children)
.filter((child) => child.tagName.toLowerCase() === 'li')
.map((li, index) => {
const content = convertNode(li).trim();
const marker =
element.tagName.toLowerCase() === 'ul'
? '-'
: `${index + 1}.`;
return `${marker} ${content}`;
})
.join('\n');
return `\n${items}\n`;
}
case 'li':
return children;
case 'table':
case 'thead':
case 'tbody':
case 'tr':
case 'th':
case 'td':
// Simple table handling - just extract text
return children;
case 'div':
case 'span':
case 'article':
case 'section':
return children;
default:
return children;
}
}
/**
* Handler for paste events with HTML content.
*/
const pasteRichTextHandler = EditorView.domEventHandlers({
paste(event, view) {
const html = event.clipboardData?.getData('text/html');
if (!html) {
// No HTML content, let default paste handler work
return false;
}
event.preventDefault();
// Convert HTML to Markdown
const markdown = htmlToMarkdown(html);
// Insert the markdown at cursor position
const from = view.state.selection.main.from;
const to = view.state.selection.main.to;
const newPos = from + markdown.length;
view.dispatch({
changes: { from, to, insert: markdown },
selection: { anchor: newPos },
scrollIntoView: true
});
return true;
}
});
/**
* Plain text paste command (Ctrl/Cmd+Shift+V).
*/
const pastePlainTextCommand: Command = (view: EditorView) => {
navigator.clipboard
.readText()
.then((text) => {
const from = view.state.selection.main.from;
const to = view.state.selection.main.to;
const newPos = from + text.length;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: newPos },
scrollIntoView: true
});
})
.catch((err: unknown) => {
console.error('Failed to paste plain text:', err);
});
return true;
};
/**
* Keymap for plain text paste.
*/
const pastePlainTextKeymap = keymap.of([
{ key: 'Mod-Shift-v', run: pastePlainTextCommand }
]);

View File

@@ -1,66 +0,0 @@
import { Extension, EditorSelection } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
/**
* Reveal block on arrow key navigation.
*
* This plugin:
* - Detects when arrow keys are pressed
* - Helps navigate through folded/hidden content
* - Provides better UX when navigating markdown documents
*
* Note: This is a simplified implementation that works with the
* standard CodeMirror navigation. For more advanced behavior,
* consider using the cursor position to detect nearby decorations.
*/
export const revealOnArrow = (): Extension => [revealOnArrowKeymap];
/**
* Check if we should adjust cursor position for better navigation.
* This is a basic implementation that lets CodeMirror handle most navigation.
*/
function maybeReveal(
view: EditorView,
direction: 'up' | 'down'
): boolean {
const { state } = view;
const cursorAt = state.selection.main.head;
const doc = state.doc;
// Basic navigation enhancement
// Let CodeMirror handle the navigation naturally
// This hook is here for future enhancements if needed
if (direction === 'down') {
// Moving down: check if we're at the end of a line
const line = doc.lineAt(cursorAt);
if (cursorAt === line.to && line.number < doc.lines) {
// Let CodeMirror handle moving to next line
return false;
}
} else {
// Moving up: check if we're at the start of a line
const line = doc.lineAt(cursorAt);
if (cursorAt === line.from && line.number > 1) {
// Let CodeMirror handle moving to previous line
return false;
}
}
return false;
}
/**
* Keymap for revealing blocks on arrow navigation.
*/
const revealOnArrowKeymap = keymap.of([
{
key: 'ArrowUp',
run: (view) => maybeReveal(view, 'up')
},
{
key: 'ArrowDown',
run: (view) => maybeReveal(view, 'down')
}
]);

View File

@@ -1,23 +1,21 @@
import { syntaxTree } from '@codemirror/language';
import {
StateField,
EditorState,
StateEffect,
TransactionSpec
} from '@codemirror/state';
import { Extension, Range } from '@codemirror/state';
import {
DecorationSet,
Decoration,
WidgetType,
EditorView
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import { image as classes } from '../classes';
/**
* Representation of the data held by the image URL state field.
* Representation of image data extracted from the syntax tree.
*/
export interface ImageInfo {
/** The source of the image. */
/** The source URL of the image. */
src: string;
/** The starting position of the image element in the document. */
from: number;
@@ -25,113 +23,74 @@ export interface ImageInfo {
to: number;
/** The alt text of the image. */
alt: string;
/** If image has already loaded. */
loaded?: true;
}
/**
* The current state of the image preview widget.
* Used to indicate to render a placeholder or the actual image.
*/
export enum WidgetState {
INITIAL,
LOADED
}
/**
* The state effect to dispatch when a image loads, regardless of the result.
*/
export const imageLoadedEffect = StateEffect.define<ImageInfo>();
/** State field to store image preview decorations. */
export const imagePreview = StateField.define<DecorationSet>({
create(state) {
const images = extractImages(state);
const decorations = images.map((img) =>
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
Decoration.widget({
widget: new ImagePreviewWidget(img, WidgetState.INITIAL),
info: img,
src: img.src,
side: 1
}).range(img.to)
);
return Decoration.set(decorations, true);
},
update(value, tx) {
const loadedImages = tx.effects.filter((effect) =>
effect.is(imageLoadedEffect)
) as StateEffect<ImageInfo>[];
if (tx.docChanged || loadedImages.length > 0) {
const images = extractImages(tx.state);
const previous = value.iter();
const previousSpecs = new Array<ImageInfo>();
while (previous.value !== null) {
previousSpecs.push(previous.value.spec.info);
previous.next();
}
const decorations = images.map((img) => {
const hasImageLoaded = Boolean(
loadedImages.find(
(effect) => effect.value.src === img.src
) ||
previousSpecs.find((spec) => spec.src === img.src)
?.loaded
);
return Decoration.widget({
widget: new ImagePreviewWidget(
img,
hasImageLoaded
? WidgetState.LOADED
: WidgetState.INITIAL
),
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
// Always use inline widget
src: img.src,
side: 1,
// This is important to keep track of loaded images
info: { ...img, loaded: hasImageLoaded }
}).range(img.to);
});
return Decoration.set(decorations, true);
}
return value.map(tx.changes);
},
provide(field) {
return EditorView.decorations.from(field);
}
});
/**
* Capture everything in square brackets of a markdown image, after
* the exclamation mark.
*/
const imageTextRE = /(?:!\[)(.*?)(?:\])/;
const IMAGE_TEXT_RE = /(?:!\[)(.*?)(?:\])/;
function extractImages(state: EditorState): ImageInfo[] {
const imageUrls: ImageInfo[] = [];
syntaxTree(state).iterate({
enter: ({ name, node, from, to }) => {
if (name !== 'Image') return;
const altMatch = state.sliceDoc(from, to).match(imageTextRE);
const alt: string = altMatch?.pop() ?? '';
const urlNode = node.getChild('URL');
if (urlNode) {
const url: string = state.sliceDoc(urlNode.from, urlNode.to);
imageUrls.push({ src: url, from, to, alt });
/**
* Extract images from the syntax tree.
*/
function extractImages(view: EditorView): ImageInfo[] {
const images: ImageInfo[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ name, node, from: nodeFrom, to: nodeTo }) => {
if (name !== 'Image') return;
const altMatch = view.state.sliceDoc(nodeFrom, nodeTo).match(IMAGE_TEXT_RE);
const alt: string = altMatch?.pop() ?? '';
const urlNode = node.getChild('URL');
if (urlNode) {
const url: string = view.state.sliceDoc(urlNode.from, urlNode.to);
images.push({ src: url, from: nodeFrom, to: nodeTo, alt });
}
}
}
});
return imageUrls;
});
}
return images;
}
/**
* Build image preview decorations.
* Only shows preview when cursor is outside the image syntax.
*/
function buildImageDecorations(view: EditorView, loadedImages: Set<string>): DecorationSet {
const decorations: Range<Decoration>[] = [];
const images = extractImages(view);
for (const img of images) {
const cursorInImage = isCursorInRange(view.state, [img.from, img.to]);
// Only show preview when cursor is outside
if (!cursorInImage) {
const isLoaded = loadedImages.has(img.src);
decorations.push(
Decoration.widget({
widget: new ImagePreviewWidget(img, isLoaded, loadedImages),
side: 1
}).range(img.to)
);
}
}
return Decoration.set(decorations, true);
}
/**
* Image preview widget that displays the actual image.
*/
class ImagePreviewWidget extends WidgetType {
constructor(
public readonly info: ImageInfo,
public readonly state: WidgetState
private readonly info: ImageInfo,
private readonly isLoaded: boolean,
private readonly loadedImages: Set<string>
) {
super();
}
@@ -145,32 +104,106 @@ class ImagePreviewWidget extends WidgetType {
img.src = this.info.src;
img.alt = this.info.alt;
img.addEventListener('load', () => {
const tx: TransactionSpec = {};
if (this.state === WidgetState.INITIAL) {
tx.effects = [
// Indicate image has loaded by setting the loaded value
imageLoadedEffect.of({ ...this.info, loaded: true })
];
}
// After this is dispatched, this widget will be updated,
// and since the image is already loaded, this will not change
// its height dynamically, hence prevent all sorts of weird
// mess related to other parts of the editor.
view.dispatch(tx);
});
if (!this.isLoaded) {
img.addEventListener('load', () => {
this.loadedImages.add(this.info.src);
view.dispatch({});
});
}
if (this.state === WidgetState.LOADED) {
if (this.isLoaded) {
wrapper.appendChild(img);
} else {
const placeholder = document.createElement('span');
placeholder.className = 'cm-image-loading';
placeholder.textContent = '🖼️';
wrapper.appendChild(placeholder);
img.style.display = 'none';
wrapper.appendChild(img);
}
// Return wrapper (empty for initial state, with img for loaded state)
return wrapper;
}
eq(widget: ImagePreviewWidget): boolean {
return (
JSON.stringify(widget.info) === JSON.stringify(this.info) &&
widget.state === this.state
widget.info.src === this.info.src &&
widget.info.from === this.info.from &&
widget.info.to === this.info.to &&
widget.isLoaded === this.isLoaded
);
}
ignoreEvent(): boolean {
return false;
}
}
/**
* Image preview plugin class.
*/
class ImagePreviewPlugin {
decorations: DecorationSet;
private loadedImages: Set<string> = new Set();
private lastSelectionRanges: string = '';
constructor(view: EditorView) {
this.decorations = buildImageDecorations(view, this.loadedImages);
this.lastSelectionRanges = this.serializeSelection(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildImageDecorations(update.view, this.loadedImages);
this.lastSelectionRanges = this.serializeSelection(update.view);
return;
}
if (update.selectionSet) {
const newRanges = this.serializeSelection(update.view);
if (newRanges !== this.lastSelectionRanges) {
this.decorations = buildImageDecorations(update.view, this.loadedImages);
this.lastSelectionRanges = newRanges;
}
return;
}
if (!update.docChanged && !update.selectionSet && !update.viewportChanged) {
this.decorations = buildImageDecorations(update.view, this.loadedImages);
}
}
private serializeSelection(view: EditorView): string {
return view.state.selection.ranges
.map((r) => `${r.from}:${r.to}`)
.join(',');
}
}
/**
* Image preview extension.
* Only handles displaying image preview widget.
*/
export const imagePreview = (): Extension => [
ViewPlugin.fromClass(ImagePreviewPlugin, {
decorations: (v) => v.decorations
}),
baseTheme
];
const baseTheme = EditorView.baseTheme({
'.cm-image-preview-wrapper': {
display: 'block',
margin: '0.5rem 0'
},
[`.${classes.widget}`]: {
maxWidth: '100%',
height: 'auto',
borderRadius: '0.25rem'
},
'.cm-image-loading': {
display: 'inline-block',
color: 'var(--cm-foreground)',
opacity: '0.6'
}
});

View File

@@ -247,19 +247,7 @@ export function stateWORDAt(
*/
export const invisibleDecoration = Decoration.replace({});
/**
* Decoration to hide inline content (font-size: 0).
*/
export const hideInlineDecoration = Decoration.mark({
class: 'cm-hidden-token'
});
/**
* Decoration to make content transparent but preserve space.
*/
export const hideInlineKeepSpaceDecoration = Decoration.mark({
class: 'cm-transparent-token'
});
// ============================================================================
// Slug Generation
@@ -301,52 +289,4 @@ export class Slugger {
}
}
// ============================================================================
// Performance Utilities
// ============================================================================
/**
* Create a debounced version of a function.
*
* @param fn - Function to debounce
* @param delay - Delay in milliseconds
* @returns Debounced function
*/
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: unknown[]) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
}) as T;
}
/**
* Create a throttled version of a function.
*
* @param fn - Function to throttle
* @param limit - Minimum time between calls in milliseconds
* @returns Throttled function
*/
export function throttle<T extends (...args: unknown[]) => void>(
fn: T,
limit: number
): T {
let lastCall = 0;
return ((...args: unknown[]) => {
const now = Date.now();
if (now - lastCall >= limit) {
lastCall = now;
fn(...args);
}
}) as T;
}