@@ -81,6 +81,35 @@ const editorExtensions = (simple = false): Extensions =>
8181 Underline ,
8282 ] )
8383
84+ /** Wrapper class on every image node — the hook the side-by-side rules below select on. */
85+ export const IMAGE_NODE_CLASS = 'image'
86+
87+ /**
88+ * Every image gets the same height — a fraction of the viewport, so a picture is never so tall it
89+ * pushes the text off screen — and keeps its natural width at that height. The nodes are inline
90+ * blocks, so a run of images uploaded together fills the first line with as many as fit side by
91+ * side and wraps the rest onto the next line, the way a line of text does. Nothing is cropped and
92+ * nothing is stretched: a wide banner simply takes more of the line than a portrait photo.
93+ *
94+ * A run only flows this way when the images are adjacent siblings — a paragraph between two of them
95+ * still breaks the line (see the batch insert in `upload-extension`).
96+ *
97+ * The same rules serve the editor and the read-only renderer, so what you type is what gets shown.
98+ * In the editor there is one more element to flatten: tiptap wraps every React node view in its own
99+ * `div.react-renderer.node-image`, and that div being block-level is enough to put each image on its
100+ * own line no matter how the `.image` wrapper inside it is displayed.
101+ */
102+ const imageRunClass = ( size : 'sm' | 'md' | 'lg' ) =>
103+ clsx (
104+ '[&_.node-image]:my-0 [&_.node-image]:inline-block [&_.node-image]:align-top' ,
105+ '[&_.image]:my-0 [&_.image]:mb-1 [&_.image]:mr-1 [&_.image]:inline-block [&_.image]:align-top' ,
106+ '[&_.image>button]:cursor-pointer' ,
107+ '[&_.image_img]:my-0 [&_.image_img]:w-auto [&_.image_img]:max-w-full' ,
108+ '[&_.image_img]:object-contain' ,
109+ // The one place the height is set. Chat bubbles are narrow, so their images run shorter.
110+ size === 'sm' ? '[&_.image_img]:h-[15vh]' : '[&_.image_img]:h-[30vh]' ,
111+ )
112+
84113const proseClass = ( size : 'sm' | 'md' | 'lg' ) =>
85114 clsx (
86115 'prose dark:prose-invert max-w-none leading-relaxed' ,
@@ -100,6 +129,7 @@ const proseClass = (size: 'sm' | 'md' | 'lg') =>
100129 'prose-strong:font-bold prose-strong:text-ink-700' ,
101130 'text-ink-600 prose-blockquote:text-teal-700 ' ,
102131 'break-anywhere' ,
132+ imageRunClass ( size ) ,
103133 )
104134
105135export const getEditorLocalStorageKey = ( key : string ) => `text ${ key } `
@@ -146,6 +176,8 @@ export function useTextEditor(props: {
146176 proseClass ( size ) ,
147177 'outline-none py-[.5em] px-4' ,
148178 'prose-img:select-auto' ,
179+ // Image sizing and the side-by-side flow come from `proseClass` above, so the editor and the
180+ // read-only rendering can't drift apart.
149181 '[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-primary-300' , // selected img, embeds
150182 'dark:[&_.ProseMirror-gapcursor]:after:border-white' , // gap cursor
151183 className ,
@@ -545,20 +577,22 @@ function recurse(node: JSONContent, key: number, ctx: RenderCtx): ReactNode {
545577 case 'hardBreak' :
546578 return < br key = { key } />
547579 case 'image' :
580+ // The wrapper (not the button) is what `imageRunClass` matches on, so a run of images has to
581+ // be a run of `.image` siblings here exactly as it is in the editor. Sizing lives there too.
548582 return (
549- < button
550- key = { key }
551- type = "button"
552- onClick = { ( ) => ctx . onMediaClick ?.( node . attrs ?. src ?? '' ) }
553- className = "cursor-pointer"
554- >
555- < img
556- src = { node . attrs ?. src }
557- alt = { node . attrs ?. alt ?? '' }
558- title = { node . attrs ?. title ?? undefined }
559- className = { ctx . size === 'sm' ? 'max-h-32' : ctx . size === 'md' ? 'max-h-64' : undefined }
560- / >
561- </ button >
583+ < span key = { key } className = { IMAGE_NODE_CLASS } >
584+ < button
585+ type = "button"
586+ onClick = { ( ) => ctx . onMediaClick ?.( node . attrs ?. src ?? '' ) }
587+ className = "cursor-pointer"
588+ >
589+ < img
590+ src = { node . attrs ?. src }
591+ alt = { node . attrs ?. alt ?? '' }
592+ title = { node . attrs ?. title ?? undefined }
593+ />
594+ </ button >
595+ </ span >
562596 )
563597 case 'video' :
564598 return (
0 commit comments