1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-04-24 19:55:13 +02:00
Files

291 lines
8.4 KiB
TypeScript

import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { SyntaxNodeRef } from '@lezer/common';
import { EditorState, StateEffect, Transaction } from '@codemirror/state';
import { RenderedContentContext } from './types';
import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
const imageClassName = 'cm-md-image';
class ImageHeightCache {
private readonly cache = new Map<string, number>();
private readonly maxEntries = 500;
public get(key: string): number | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Refresh recency
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
public set(key: string, height: number): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxEntries) {
const firstKey = this.cache.keys().next().value;
if (firstKey) this.cache.delete(firstKey);
}
this.cache.set(key, height);
}
}
const imageHeightCache = new ImageHeightCache();
class ImageWidget extends WidgetType {
private resolvedSrc_: string;
public constructor(
private readonly context_: RenderedContentContext,
private readonly src_: string,
private readonly alt_: string,
private readonly reloadCounter_ = 0,
private readonly width_: string | null = null,
private readonly sourceFrom_ = 0,
) {
super();
}
public eq(other: ImageWidget) {
return this.src_ === other.src_ && this.alt_ === other.alt_ && this.reloadCounter_ === other.reloadCounter_ && this.width_ === other.width_ && this.sourceFrom_ === other.sourceFrom_;
}
public updateDOM(dom: HTMLElement): boolean {
const image = dom.querySelector<HTMLImageElement>('img.image');
if (!image) return false;
dom.dataset.sourceFrom = String(this.sourceFrom_);
image.ariaLabel = this.alt_;
image.role = 'image';
// Apply width if specified, otherwise clear it
if (this.width_) {
image.style.width = `${this.width_}px`;
image.style.height = 'auto';
} else {
image.style.width = '';
image.style.height = '';
}
const updateImageUrl = () => {
if (this.resolvedSrc_) {
image.src = this.resolvedSrc_;
// When the image loads, measure and cache the height
image.onload = () => {
// Measure container height (what CodeMirror uses for scroll calculations).
if (dom.isConnected) {
imageHeightCache.set(this.cacheKey, dom.offsetHeight);
}
dom.style.minHeight = '';
};
}
};
if (!this.resolvedSrc_) {
void (async () => {
this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_, this.reloadCounter_);
updateImageUrl();
})();
} else {
updateImageUrl();
}
// Apply cached height as min-height to prevent collapse during load.
const cached = imageHeightCache.get(this.cacheKey);
if (cached) {
dom.style.minHeight = `${cached}px`;
}
return true;
}
public toDOM(view: EditorView) {
const container = document.createElement('div');
container.classList.add(imageClassName);
container.dataset.sourceFrom = String(this.sourceFrom_);
const image = document.createElement('img');
image.classList.add('image');
container.appendChild(image);
this.updateDOM(container);
container.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const pos = Math.min(view.posAtDOM(container), view.state.doc.length);
view.dispatch({
selection: { anchor: view.state.doc.lineAt(pos).from },
scrollIntoView: false,
});
});
return container;
}
private get cacheKey() {
return `${this.src_}_${this.width_ ?? ''}_${this.reloadCounter_}`;
}
public get estimatedHeight() {
return imageHeightCache.get(this.cacheKey) ?? -1;
}
public ignoreEvent() {
return true;
}
}
const getImageSrc = (node: SyntaxNodeRef, state: EditorState) => {
const nodeText = state.sliceDoc(node.from, node.to);
// For now, only render Joplin resource images (avoid auto-fetching images from
// the internet if just the Markdown editor is open).
const match = nodeText.match(/:\/[a-zA-Z0-9]{32}/);
if (match) {
return match[0];
} else {
return null;
}
};
const getImageAlt = (node: SyntaxNodeRef, state: EditorState) => {
const nodeText = state.sliceDoc(node.from, node.to);
const match = nodeText.match(/!\s*\[(.+)\]/);
if (match) {
return match[1];
} else {
return null;
}
};
interface HtmlImageInfo {
src: string;
alt: string | null;
width: string | null;
}
const parseHtmlImage = (node: SyntaxNodeRef, state: EditorState): HtmlImageInfo | null => {
const nodeText = state.sliceDoc(node.from, node.to);
// Check if this is an img tag (handles both /> and > closing styles)
if (!nodeText.match(/<img\s/i)) {
return null;
}
// Extract src (only Joplin resource images, accepts single or double quotes)
const srcMatch = nodeText.match(/src=(["'])(:\/[a-zA-Z0-9]{32})\1/i);
if (!srcMatch) {
return null;
}
// Extract alt attribute (optional, accepts single or double quotes)
const altMatch = nodeText.match(/alt=(["'])([^"']*)\1/i);
// Extract width attribute (optional, accepts single or double quotes)
const widthMatch = nodeText.match(/width=(["'])(\d+)\1/i);
return {
src: srcMatch[2],
alt: altMatch ? altMatch[2] : null,
width: widthMatch ? widthMatch[2] : null,
};
};
// In Electron: To work around browser caching, these counters should continue to increase even if an old
// editor is destroyed and a new one is created in the same window.
const imageToRefreshCounters = new Map<string, number>();
export const resetImageResourceEffect = StateEffect.define<{ id: string }>();
// Intended only for automated tests.
export const testing__resetImageRefreshCounterCache = () => {
imageToRefreshCounters.clear();
};
const renderBlockImages = (context: RenderedContentContext) => [
EditorView.theme({
[`& .${imageClassName} > .image`]: {
maxWidth: '100%',
minWidth: 0,
display: 'block',
// Center
marginLeft: 'auto',
marginRight: 'auto',
},
}),
makeBlockReplaceExtension({
createDecoration: (node, state) => {
// Handle both markdown images and HTML img tags
if (node.name === 'Image' || node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
const lineFrom = state.doc.lineAt(node.from);
const lineTo = state.doc.lineAt(node.to);
const textBefore = state.sliceDoc(lineFrom.from, node.from);
const textAfter = state.sliceDoc(node.to, lineTo.to);
// Only render images on their own line
if (textBefore.trim() === '' && textAfter.trim() === '') {
let src: string | null = null;
let alt: string | null = null;
let width: string | null = null;
// Parse image data based on node type
if (node.name === 'Image') {
// Markdown image: ![alt](src)
src = getImageSrc(node, state);
alt = getImageAlt(node, state);
} else {
// HTML img tag: <img src="..." alt="..." width="..." />
const imageInfo = parseHtmlImage(node, state);
if (imageInfo) {
src = imageInfo.src;
alt = imageInfo.alt;
width = imageInfo.width;
}
}
if (src) {
const isLastLine = lineTo.number === state.doc.lines;
return Decoration.widget({
widget: new ImageWidget(context, src, alt, imageToRefreshCounters.get(src) ?? 0, width, lineFrom.from),
// "side: -1": In general, when the cursor is at the widget's location, it should be at
// the start of the next line (and so "side" should be -1).
//
// "side: 1": However, when the widget is at the end of the document, the widget's
// position is **one index less** than when it isn't (to prevent the widget's
// position from being outside the document, which would break CodeMirror).
// This means that we need "side: 1" to put the cursor before the widget
// when at the end of the document.
side: isLastLine ? 1 : -1,
block: true,
});
}
}
}
return null;
},
getDecorationRange: (node, state) => {
const nodeLine = state.doc.lineAt(node.to);
return [Math.min(nodeLine.to + 1, state.doc.length)];
},
hideWhenContainsSelection: false,
shouldFullReRender: (transaction: Transaction) => {
let hadRefreshEffect = false;
for (const effect of transaction.effects) {
if (effect.is(resetImageResourceEffect)) {
const key = `:/${effect.value.id}`;
imageToRefreshCounters.set(key, (imageToRefreshCounters.get(key) ?? 0) + 1);
hadRefreshEffect = true;
}
}
return hadRefreshEffect;
},
}),
];
export default renderBlockImages;