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

178 lines
4.4 KiB
TypeScript

import { syntaxTree } from '@codemirror/language';
import { Extension, Range } from '@codemirror/state';
import {
DecorationSet,
Decoration,
WidgetType,
EditorView,
ViewPlugin,
ViewUpdate,
hoverTooltip,
Tooltip
} from '@codemirror/view';
interface ImageInfo {
src: string;
from: number;
to: number;
alt: string;
}
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>`;
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();
}
}
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 };
}
};
},
{ hoverTime: 300 }
);
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];