Files
voidraft/frontend/src/views/editor/extensions/markdown/state/image.ts

210 lines
5.1 KiB
TypeScript

import { syntaxTree } from '@codemirror/language';
import { Extension, Range } from '@codemirror/state';
import {
DecorationSet,
Decoration,
WidgetType,
EditorView,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import { image as classes } from '../classes';
/**
* Representation of image data extracted from the syntax tree.
*/
export interface ImageInfo {
/** The source URL of the image. */
src: string;
/** The starting position of the image element in the document. */
from: number;
/** The end position of the image element in the document. */
to: number;
/** The alt text of the image. */
alt: string;
}
/**
* Capture everything in square brackets of a markdown image, after
* the exclamation mark.
*/
const IMAGE_TEXT_RE = /(?:!\[)(.*?)(?:\])/;
/**
* 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 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(
private readonly info: ImageInfo,
private readonly isLoaded: boolean,
private readonly loadedImages: Set<string>
) {
super();
}
toDOM(view: EditorView): HTMLElement {
const wrapper = document.createElement('span');
wrapper.className = 'cm-image-preview-wrapper';
const img = new Image();
img.classList.add(classes.widget);
img.src = this.info.src;
img.alt = this.info.alt;
if (!this.isLoaded) {
img.addEventListener('load', () => {
this.loadedImages.add(this.info.src);
view.dispatch({});
});
}
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;
}
eq(widget: ImagePreviewWidget): boolean {
return (
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'
}
});