//
// Copyright 2023 DXOS.org
//
import { syntaxTree } from '@codemirror/language';
import { type EditorState, type Extension, type Range, StateField, type Transaction } from '@codemirror/state';
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
import { focusField } from '../../state/focus.ts';
import { isWidgetLink } from '../../widgets/link-widgets.ts';
export type ImageNodeData = { name: 'Image'; url: string };
export type ImageOptions = {
/**
* Predicate that returns true to suppress rendering of an image node.
* When skipped, the markdown link source is left visible to the user instead of being
* replaced by an `
` widget.
*/
skip?: (node: ImageNodeData) => boolean;
};
/**
* Create image decorations.
*/
export const image = (options: ImageOptions = {}): Extension => {
return [
StateField.define({
create: (state) => {
return Decoration.set(buildDecorations(state, 0, state.doc.length, options));
},
update: (value: DecorationSet, tr: Transaction) => {
let from = Number.POSITIVE_INFINITY;
let to = Number.NEGATIVE_INFINITY;
if (tr.docChanged || tr.selection) {
// The changed ranges and both cursor positions, since an image's source is shown only under the cursor.
const cursor = tr.state.selection.main.head;
const oldCursor = tr.changes.mapPos(tr.startState.selection.main.head);
from = Math.min(cursor, oldCursor);
to = Math.max(cursor, oldCursor);
tr.changes.iterChangedRanges((_fromA, _toA, fromB, toB) => {
from = Math.min(from, fromB);
to = Math.max(to, toB);
});
}
// The parser extends the tree in transactions of its own as the viewport reaches unparsed content, so images
// there are decorated as they are parsed rather than by rebuilding the whole document on every scroll.
const parsedBefore = tr.changes.mapPos(syntaxTree(tr.startState).length);
const parsedAfter = syntaxTree(tr.state).length;
if (parsedAfter > parsedBefore) {
from = Math.min(from, parsedBefore);
to = Math.max(to, parsedAfter);
}
if (from > to) {
return value.map(tr.changes);
}
// Expand to cover lines.
from = tr.state.doc.lineAt(from).from;
to = tr.state.doc.lineAt(to).to;
return value.map(tr.changes).update({
filterFrom: from,
filterTo: to,
filter: () => false,
add: buildDecorations(tr.state, from, to, options),
});
},
provide: (field) => EditorView.decorations.from(field),
}),
];
};
const buildDecorations = (state: EditorState, from: number, to: number, options: ImageOptions = {}) => {
const decorations: Range[] = [];
const cursor = state.selection.main.head;
syntaxTree(state).iterate({
enter: (node) => {
if (node.name === 'Image') {
const urlNode = node.node.getChild('URL');
if (urlNode) {
const hide = state.readOnly || cursor < node.from || cursor > node.to || !state.field(focusField);
const url = state.sliceDoc(urlNode.from, urlNode.to);
// Some plugins might be using custom URLs; avoid attempts to render those URLs.
if (url.match(/^https?:\/\//) === null && url.match(/^file?:\/\//) === null) {
return;
}
// A registered link widget's image is the widget's to render; otherwise the consumer's
// filter (e.g., disable remote-image rendering by setting).
if (isWidgetLink(state, url) || options.skip?.({ name: 'Image', url })) {
return;
}
preloadImage(url);
decorations.push(
Decoration.replace({
block: true, // Prevent cursor from entering.
widget: new ImageWidget(url),
}).range(hide ? node.from : node.to, node.to),
);
}
}
},
from,
to,
});
return decorations;
};
const preloaded = new Set();
const preloadImage = (url: string) => {
if (!preloaded.has(url)) {
const img = document.createElement('img');
img.src = url;
preloaded.add(url);
}
};
class ImageWidget extends WidgetType {
constructor(readonly _url: string) {
super();
}
override eq(other: this) {
return this._url === other._url;
}
override toDOM(view: EditorView) {
const img = document.createElement('img');
img.setAttribute('src', this._url);
img.setAttribute('class', 'cm-image');
const focused = view.state.field(focusField);
// If focused, hide image until successfully loaded to avoid flickering effects.
if (focused) {
img.onload = () => {
img.classList.add('cm-loaded-image');
collapseIfTrackingPixel(img);
};
} else {
img.classList.add('cm-loaded-image');
img.onload = () => collapseIfTrackingPixel(img);
}
// Error (e.g., blocked tracker URL): also collapse so we don't leave a hole.
img.onerror = () => collapseLine(img);
return img;
}
}
/**
* Tracking pixels are commonly 1×1 (or 0×0) transparent images embedded by mail senders.
* They add no visual value, so hide them once their natural dimensions are known.
*/
const collapseIfTrackingPixel = (img: HTMLImageElement) => {
if (img.naturalWidth <= 1 && img.naturalHeight <= 1) {
collapseLine(img);
}
};
const collapseLine = (img: HTMLImageElement) => {
img.style.display = 'none';
};