1- import { useEffect , useRef , useState } from 'react'
1+ import { type CSSProperties , useEffect , useMemo , useRef , useState } from 'react'
22import { cn } from '@sim/emcn'
33import { NodeSelection , Plugin } from '@tiptap/pm/state'
44import type { ReactNodeViewProps } from '@tiptap/react'
55import { NodeViewWrapper , ReactNodeViewRenderer } from '@tiptap/react'
6- import { useFileContentSource } from '@/hooks/use-file-content-source'
6+ import { type ImageDimensions , useFileContentSource } from '@/hooks/use-file-content-source'
77import { MarkdownImage } from './image-schema'
88import { normalizeLinkHref } from './markdown-fidelity'
99import { useEditorEditable } from './use-editor-editable'
@@ -24,6 +24,11 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
2424 const [ dragWidth , setDragWidth ] = useState < number | null > ( null )
2525 /** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
2626 const [ failed , setFailed ] = useState ( false )
27+ /**
28+ * Intrinsic dimensions measured from the loaded image — holds the aspect-ratio box for THIS view when
29+ * the content source has no stored dimensions yet (the first-ever view of an image). Reset on src change.
30+ */
31+ const [ measuredDimensions , setMeasuredDimensions ] = useState < ImageDimensions | null > ( null )
2732 const attrs = node . attrs as {
2833 src ?: string
2934 alt ?: string
@@ -33,7 +38,16 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
3338 }
3439
3540 useEffect ( ( ) => ( ) => dragAbortRef . current ?. abort ( ) , [ ] )
36- useEffect ( ( ) => setFailed ( false ) , [ attrs . src ] )
41+
42+ // Reset the load-failure flag and this-session measurement when the src changes — adjusted during
43+ // render (not in an effect) so the previous image's aspect-ratio box never paints for a frame. A `key`
44+ // remount isn't available here: TipTap owns this node view's instantiation.
45+ const [ prevSrc , setPrevSrc ] = useState ( attrs . src )
46+ if ( prevSrc !== attrs . src ) {
47+ setPrevSrc ( attrs . src )
48+ setFailed ( false )
49+ setMeasuredDimensions ( null )
50+ }
3751
3852 const startResize = ( event : React . PointerEvent ) => {
3953 event . preventDefault ( )
@@ -73,12 +87,27 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
7387 ? `${ attrs . width } px`
7488 : attrs . width
7589 : undefined
76- const widthStyle =
90+ // Stored intrinsic dimensions reserve the box on the very first render. Memoized on the src (not the
91+ // live drag width) so a resize drag never re-scans the file list. Falls back to what we measured on
92+ // load this session for a first-ever view the metadata hasn't caught up on.
93+ const storedDimensions = useMemo (
94+ ( ) => source . getImageDimensions ?.( attrs . src ) ?? null ,
95+ [ source , attrs . src ]
96+ )
97+ const intrinsicDimensions = storedDimensions ?? measuredDimensions
98+ const displayWidth =
7799 dragWidth !== null
78- ? { width : `${ dragWidth } px` }
79- : committedWidth
80- ? { width : committedWidth }
81- : undefined
100+ ? `${ dragWidth } px`
101+ : ( committedWidth ?? ( intrinsicDimensions ? `${ intrinsicDimensions . width } px` : undefined ) )
102+ // width + aspect-ratio (with `max-w-full`/`h-auto` from the class list) reserves a responsive box the
103+ // image can't reflow into, per the CLS-avoidance pattern for known-ratio responsive images. React drops
104+ // the undefined keys, so an unmeasured image simply gets no reservation (its prior behavior).
105+ const imageStyle : CSSProperties = {
106+ width : displayWidth ,
107+ aspectRatio : intrinsicDimensions
108+ ? `${ intrinsicDimensions . width } / ${ intrinsicDimensions . height } `
109+ : undefined ,
110+ }
82111
83112 // Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
84113 // untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
@@ -99,11 +128,21 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
99128 // the resize button sits outside this element, so it keeps its own pointer behavior.)
100129 draggable = { editable }
101130 data-drag-handle = { editable ? '' : undefined }
102- style = { widthStyle }
131+ style = { imageStyle }
103132 onError = { ( ) => setFailed ( true ) }
104- onLoad = { ( ) => setFailed ( false ) }
133+ onLoad = { ( event ) => {
134+ setFailed ( false )
135+ const { naturalWidth, naturalHeight } = event . currentTarget
136+ if ( naturalWidth <= 0 || naturalHeight <= 0 ) return
137+ // Already reserved from stored metadata (possibly just backfilled by a sibling view) — done.
138+ if ( source . getImageDimensions ?.( attrs . src ) ) return
139+ // Hold the box for this first-ever view, and persist so later views reserve before load. The
140+ // report is idempotent and de-duped downstream (stored-check + server `width IS NULL`).
141+ setMeasuredDimensions ( { width : naturalWidth , height : naturalHeight } )
142+ source . reportImageDimensions ?.( attrs . src , { width : naturalWidth , height : naturalHeight } )
143+ } }
105144 className = { cn (
106- 'block max-w-full rounded-lg border border-[var(--border)]' ,
145+ 'block h-auto max-w-full rounded-lg border border-[var(--border)]' ,
107146 editable && 'cursor-grab' ,
108147 failed &&
109148 'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
0 commit comments