Skip to content

Commit 78891f1

Browse files
committed
feat: add image preview functionality in MarkdownEditor
1 parent 45c8db0 commit 78891f1

File tree

1 file changed

+135
-0
lines changed

1 file changed

+135
-0
lines changed

custom/MarkdownEditor.vue

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,128 @@ let removePasteListenerSecondary: (() => void) | null = null;
241241
let removeGlobalPasteListener: (() => void) | null = null;
242242
let removeGlobalKeydownListener: (() => void) | null = null;
243243
244+
type MarkdownImageRef = {
245+
lineNumber: number;
246+
src: string;
247+
};
248+
249+
let imageViewZoneIds: string[] = [];
250+
let imagePreviewUpdateTimer: number | null = null;
251+
252+
function normalizeMarkdownImageSrc(raw: string): string {
253+
let src = raw.trim();
254+
if (src.startsWith('<') && src.endsWith('>')) src = src.slice(1, -1).trim();
255+
return src;
256+
}
257+
258+
function findImagesInModel(textModel: monaco.editor.ITextModel): MarkdownImageRef[] {
259+
const images: MarkdownImageRef[] = [];
260+
const lineCount = textModel.getLineCount();
261+
262+
// Minimal image syntax: ![alt](src) or ![alt](src "title")
263+
// This intentionally keeps parsing simple and line-based.
264+
const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
265+
266+
for (let lineNumber = 1; lineNumber <= lineCount; lineNumber += 1) {
267+
const line = textModel.getLineContent(lineNumber);
268+
re.lastIndex = 0;
269+
let match: RegExpExecArray | null;
270+
// Allow multiple images on the same line.
271+
while ((match = re.exec(line))) {
272+
const src = normalizeMarkdownImageSrc(match[1] ?? '');
273+
if (!src) continue;
274+
images.push({ lineNumber, src });
275+
}
276+
}
277+
278+
return images;
279+
}
280+
281+
function clearImagePreviews() {
282+
if (!editor) return;
283+
if (!imageViewZoneIds.length) return;
284+
285+
editor.changeViewZones((accessor) => {
286+
for (const zoneId of imageViewZoneIds) accessor.removeZone(zoneId);
287+
});
288+
imageViewZoneIds = [];
289+
}
290+
291+
function updateImagePreviews() {
292+
if (!editor || !model) return;
293+
const images = findImagesInModel(model);
294+
295+
clearImagePreviews();
296+
297+
// View zones reserve vertical space and thus shift lines down.
298+
// We keep the implementation minimal: one zone per image ref.
299+
editor.changeViewZones((accessor) => {
300+
const newZoneIds: string[] = [];
301+
302+
images.forEach((img) => {
303+
const wrapper = document.createElement('div');
304+
wrapper.style.padding = '6px 0';
305+
wrapper.style.pointerEvents = 'none';
306+
307+
const preview = document.createElement('div');
308+
preview.style.display = 'inline-block';
309+
preview.style.borderRadius = '6px';
310+
preview.style.overflow = 'hidden';
311+
preview.style.maxWidth = '220px';
312+
preview.style.maxHeight = '140px';
313+
314+
const imageEl = document.createElement('img');
315+
imageEl.src = img.src;
316+
imageEl.style.maxWidth = '220px';
317+
imageEl.style.maxHeight = '140px';
318+
imageEl.style.display = 'block';
319+
imageEl.style.opacity = '0.95';
320+
321+
preview.appendChild(imageEl);
322+
wrapper.appendChild(preview);
323+
324+
const zone: monaco.editor.IViewZone = {
325+
afterLineNumber: img.lineNumber,
326+
heightInPx: 160,
327+
domNode: wrapper,
328+
};
329+
330+
const zoneId = accessor.addZone(zone);
331+
newZoneIds.push(zoneId);
332+
333+
// Once image loads, adjust zone height to the rendered node.
334+
imageEl.onload = () => {
335+
if (!editor) return;
336+
const measured = wrapper.offsetHeight;
337+
const nextHeight = Math.max(40, Math.min(200, measured || 160));
338+
if (zone.heightInPx !== nextHeight) {
339+
zone.heightInPx = nextHeight;
340+
editor.changeViewZones((a) => a.layoutZone(zoneId));
341+
}
342+
};
343+
344+
imageEl.onerror = () => {
345+
// Keep the zone small if the image can't be loaded.
346+
if (!editor) return;
347+
zone.heightInPx = 40;
348+
editor.changeViewZones((a) => a.layoutZone(zoneId));
349+
};
350+
});
351+
352+
imageViewZoneIds = newZoneIds;
353+
});
354+
}
355+
356+
function scheduleImagePreviewUpdate() {
357+
if (imagePreviewUpdateTimer !== null) {
358+
window.clearTimeout(imagePreviewUpdateTimer);
359+
}
360+
imagePreviewUpdateTimer = window.setTimeout(() => {
361+
imagePreviewUpdateTimer = null;
362+
updateImagePreviews();
363+
}, 120);
364+
}
365+
244366
function isDarkMode(): boolean {
245367
return document.documentElement.classList.contains('dark');
246368
}
@@ -286,6 +408,9 @@ onMounted(async () => {
286408
const markdown = model?.getValue() ?? '';
287409
content.value = markdown;
288410
emit('update:value', markdown);
411+
412+
// Keep image previews in sync with markdown edits.
413+
scheduleImagePreviewUpdate();
289414
}),
290415
);
291416
@@ -428,6 +553,9 @@ onMounted(async () => {
428553
removeGlobalKeydownListener = () => {
429554
document.removeEventListener('keydown', onGlobalKeydown, true);
430555
};
556+
557+
// Initial render of previews.
558+
scheduleImagePreviewUpdate();
431559
} catch (error) {
432560
console.error('Failed to initialize editor:', error);
433561
}
@@ -480,6 +608,13 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
480608
}
481609
482610
onBeforeUnmount(() => {
611+
if (imagePreviewUpdateTimer !== null) {
612+
window.clearTimeout(imagePreviewUpdateTimer);
613+
imagePreviewUpdateTimer = null;
614+
}
615+
616+
clearImagePreviews();
617+
483618
removePasteListener?.();
484619
removePasteListener = null;
485620

0 commit comments

Comments
 (0)