🚧 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

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