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'
1010
1111const MIN_WIDTH = 64
1212
13+ /** A bare pixel count (`"640"`) that needs a `px` suffix, vs. an already-unit'd width (`"50%"`). */
14+ const BARE_PIXEL_WIDTH = / ^ \d + $ /
15+
1316/**
1417 * Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging
1518 * commits the new pixel width to the `width` attribute, which serializes to `<img width>`.
@@ -24,6 +27,11 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
2427 const [ dragWidth , setDragWidth ] = useState < number | null > ( null )
2528 /** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
2629 const [ failed , setFailed ] = useState ( false )
30+ /**
31+ * Intrinsic dimensions measured from the loaded image — holds the aspect-ratio box for THIS view when
32+ * the content source has no stored dimensions yet (the first-ever view of an image). Reset on src change.
33+ */
34+ const [ measuredDimensions , setMeasuredDimensions ] = useState < ImageDimensions | null > ( null )
2735 const attrs = node . attrs as {
2836 src ?: string
2937 alt ?: string
@@ -33,7 +41,16 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
3341 }
3442
3543 useEffect ( ( ) => ( ) => dragAbortRef . current ?. abort ( ) , [ ] )
36- useEffect ( ( ) => setFailed ( false ) , [ attrs . src ] )
44+
45+ // Reset the load-failure flag and this-session measurement when the src changes — adjusted during
46+ // render (not in an effect) so the previous image's aspect-ratio box never paints for a frame. A `key`
47+ // remount isn't available here: TipTap owns this node view's instantiation.
48+ const [ prevSrc , setPrevSrc ] = useState ( attrs . src )
49+ if ( prevSrc !== attrs . src ) {
50+ setPrevSrc ( attrs . src )
51+ setFailed ( false )
52+ setMeasuredDimensions ( null )
53+ }
3754
3855 const startResize = ( event : React . PointerEvent ) => {
3956 event . preventDefault ( )
@@ -69,16 +86,34 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
6986 }
7087
7188 const committedWidth = attrs . width
72- ? / ^ \d + $ / . test ( attrs . width )
89+ ? BARE_PIXEL_WIDTH . test ( attrs . width )
7390 ? `${ attrs . width } px`
7491 : attrs . width
7592 : undefined
76- const widthStyle =
93+ // Stored intrinsic dimensions reserve the box on the very first render. Memoized on the src (not the
94+ // live drag width) so a resize drag never re-scans the file list. Falls back to what we measured on
95+ // load this session for a first-ever view the metadata hasn't caught up on.
96+ const storedDimensions = useMemo (
97+ ( ) => source . getImageDimensions ?.( attrs . src ) ?? null ,
98+ [ source , attrs . src ]
99+ )
100+ // The browser's post-load measurement is authoritative — EXIF-corrected, and correct even when the
101+ // stored value is stale (e.g. left over after the file's content was replaced) — so it wins once
102+ // available; stored metadata only reserves the box pre-load. Equal in the common case, so no shift.
103+ const intrinsicDimensions = measuredDimensions ?? storedDimensions
104+ const displayWidth =
77105 dragWidth !== null
78- ? { width : `${ dragWidth } px` }
79- : committedWidth
80- ? { width : committedWidth }
81- : undefined
106+ ? `${ dragWidth } px`
107+ : ( committedWidth ?? ( intrinsicDimensions ? `${ intrinsicDimensions . width } px` : undefined ) )
108+ // width + aspect-ratio (with `max-w-full`/`h-auto` from the class list) reserves a responsive box the
109+ // image can't reflow into, per the CLS-avoidance pattern for known-ratio responsive images. React drops
110+ // the undefined keys, so an unmeasured image simply gets no reservation (its prior behavior).
111+ const imageStyle : CSSProperties = {
112+ width : displayWidth ,
113+ aspectRatio : intrinsicDimensions
114+ ? `${ intrinsicDimensions . width } / ${ intrinsicDimensions . height } `
115+ : undefined ,
116+ }
82117
83118 // Sanitize the linked-image target before rendering the anchor — a parsed markdown href is
84119 // untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only).
@@ -99,11 +134,28 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
99134 // the resize button sits outside this element, so it keeps its own pointer behavior.)
100135 draggable = { editable }
101136 data-drag-handle = { editable ? '' : undefined }
102- style = { widthStyle }
137+ style = { imageStyle }
103138 onError = { ( ) => setFailed ( true ) }
104- onLoad = { ( ) => setFailed ( false ) }
139+ onLoad = { ( event ) => {
140+ setFailed ( false )
141+ const { naturalWidth, naturalHeight } = event . currentTarget
142+ if ( naturalWidth <= 0 || naturalHeight <= 0 ) return
143+ // The browser's measurement is authoritative. Reserve from it and persist whenever the stored
144+ // metadata is absent or disagrees (EXIF-rotated, or stale after a content swap), so a wrong value
145+ // self-corrects instead of sticking. Compare the memoized `storedDimensions` the render uses, NOT
146+ // a fresh cache read — the memo is non-reactive, and this keeps the guard consistent with render.
147+ if (
148+ storedDimensions &&
149+ storedDimensions . width === naturalWidth &&
150+ storedDimensions . height === naturalHeight
151+ ) {
152+ return
153+ }
154+ setMeasuredDimensions ( { width : naturalWidth , height : naturalHeight } )
155+ source . reportImageDimensions ?.( attrs . src , { width : naturalWidth , height : naturalHeight } )
156+ } }
105157 className = { cn (
106- 'block max-w-full rounded-lg border border-[var(--border)]' ,
158+ 'block h-auto max-w-full rounded-lg border border-[var(--border)]' ,
107159 editable && 'cursor-grab' ,
108160 failed &&
109161 'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
0 commit comments