@@ -241,6 +241,128 @@ let removePasteListenerSecondary: (() => void) | null = null;
241241let removeGlobalPasteListener: (() => void ) | null = null ;
242242let 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:  or 
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+
244366function 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
482610onBeforeUnmount (() => {
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