🚧 Added support for markdown preview footnotes

This commit is contained in:
2025-12-02 00:22:22 +08:00
parent dd3dd4ddb2
commit 17f3351cea
15 changed files with 1306 additions and 637 deletions

View File

@@ -1,73 +1,177 @@
import { syntaxTree } from '@codemirror/language';
import { Extension, Range } from '@codemirror/state';
import { EditorView } from 'codemirror';
import { imagePreview } from '../state/image';
import { image as classes } from '../classes';
import {
Decoration,
DecorationSet,
Decoration,
WidgetType,
EditorView,
ViewPlugin,
ViewUpdate
ViewUpdate,
hoverTooltip,
Tooltip
} from '@codemirror/view';
import {
iterateTreeInVisibleRanges,
isCursorInRange,
invisibleDecoration
} from '../util';
/**
* 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) {
if (
node.name === 'Image' &&
!isCursorInRange(view.state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
}
}
});
return Decoration.set(widgets, true);
interface ImageInfo {
src: string;
from: number;
to: number;
alt: string;
}
/**
* Plugin to hide image markdown syntax when cursor is outside.
*/
const hideImageNodePlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|apng|tiff?)(\?.*)?$/i;
const IMAGE_ALT_RE = /(?:!\[)(.*?)(?:\])/;
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
constructor(view: EditorView) {
this.decorations = hideImageNodes(view);
function isImageUrl(url: string): boolean {
return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/');
}
function extractImages(view: EditorView): ImageInfo[] {
const result: ImageInfo[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ name, node, from: f, to: t }) => {
if (name !== 'Image') return;
const urlNode = node.getChild('URL');
if (!urlNode) return;
const src = view.state.sliceDoc(urlNode.from, urlNode.to);
if (!isImageUrl(src)) return;
const text = view.state.sliceDoc(f, t);
const alt = text.match(IMAGE_ALT_RE)?.[1] ?? '';
result.push({ src, from: f, to: t, alt });
}
});
}
return result;
}
class IndicatorWidget extends WidgetType {
constructor(readonly info: ImageInfo) {
super();
}
toDOM(): HTMLElement {
const el = document.createElement('span');
el.className = 'cm-image-indicator';
el.innerHTML = ICON;
return el;
}
eq(other: IndicatorWidget): boolean {
return this.info.from === other.info.from && this.info.src === other.info.src;
}
}
class ImagePlugin {
decorations: DecorationSet;
images: ImageInfo[] = [];
constructor(view: EditorView) {
this.images = extractImages(view);
this.decorations = this.build();
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.images = extractImages(update.view);
this.decorations = this.build();
}
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = hideImageNodes(update.view);
private build(): DecorationSet {
const deco: Range<Decoration>[] = [];
for (const img of this.images) {
deco.push(Decoration.widget({ widget: new IndicatorWidget(img), side: 1 }).range(img.to));
}
return Decoration.set(deco, true);
}
getImageAt(pos: number): ImageInfo | null {
for (const img of this.images) {
if (pos >= img.to && pos <= img.to + 1) {
return img;
}
}
return null;
}
}
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
decorations: (v) => v.decorations
});
const imageHoverTooltip = hoverTooltip(
(view, pos): Tooltip | null => {
const plugin = view.plugin(imagePlugin);
if (!plugin) return null;
const img = plugin.getImageAt(pos);
if (!img) return null;
return {
pos: img.to,
above: true,
arrow: true,
create: () => {
const dom = document.createElement('div');
dom.className = 'cm-image-tooltip';
const imgEl = document.createElement('img');
imgEl.src = img.src;
imgEl.alt = img.alt;
imgEl.onerror = () => {
imgEl.remove();
dom.textContent = 'Failed to load image';
dom.classList.add('cm-image-tooltip-error');
};
dom.append(imgEl);
return { dom };
}
};
},
{ decorations: (v) => v.decorations }
{ hoverTime: 300 }
);
/**
* Image plugin.
*/
export const image = (): Extension => [
imagePreview(),
hideImageNodePlugin,
baseTheme
];
const baseTheme = EditorView.baseTheme({
['.' + classes.widget]: {
display: 'block',
objectFit: 'contain',
maxWidth: '100%',
maxHeight: '100%',
userSelect: 'none'
const theme = EditorView.baseTheme({
'.cm-image-indicator': {
display: 'inline-flex',
alignItems: 'center',
marginLeft: '4px',
verticalAlign: 'middle',
cursor: 'pointer',
opacity: '0.5',
color: 'var(--cm-link-color, #1a73e8)',
transition: 'opacity 0.15s',
'& svg': { width: '14px', height: '14px' }
},
'.cm-image-indicator:hover': { opacity: '1' },
'.cm-image-tooltip': {
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
'& img': {
display: 'block',
maxWidth: '60vw',
maxHeight: '50vh'
}
},
'.cm-image-tooltip-error': {
padding: '16px 24px',
fontSize: '12px',
color: 'var(--text-muted)'
},
'.cm-tooltip-arrow:before': {
borderTopColor: 'var(--border-color) !important',
borderBottomColor: 'var(--border-color) !important'
},
'.cm-tooltip-arrow:after': {
borderTopColor: 'var(--bg-secondary) !important',
borderBottomColor: 'var(--bg-secondary) !important'
}
});
export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme];