Skip to content

Commit 6b68881

Browse files
committed
Enhance image handling: cap hero photo height for better fold visibility, batch-insert media without extra lines, and add side-by-side image layout logic for consistency across editor and viewer.
1 parent e5de5e3 commit 6b68881

3 files changed

Lines changed: 71 additions & 19 deletions

File tree

web/components/editor/upload-extension.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,25 @@ export const useUploadMutation = (editor: Editor | null) =>
2222
{
2323
onSuccess(uploads) {
2424
if (!editor || !uploads.length) return
25-
let trans = editor.chain().focus()
26-
uploads.forEach(({src, isVideo}) => {
27-
trans = isVideo ? trans.setVideo({src}) : trans.setImage({src})
28-
trans = trans.createParagraphNear()
29-
})
30-
trans.run()
25+
// Built as one node list and inserted in a single step. Chaining `setImage` per upload
26+
// inserts each one at the current selection, which lands after the previous node's
27+
// paragraph — that's what kept putting a blank line between images. Here the nodes are
28+
// adjacent siblings by construction, which is what the editor styles lay out two per line.
29+
// Videos still get a paragraph after each: two side by side leaves neither watchable.
30+
const nodes = uploads.flatMap(({src, isVideo}) =>
31+
isVideo
32+
? [{type: 'video', attrs: {src}}, {type: 'paragraph'}]
33+
: [{type: 'image', attrs: {src}}],
34+
)
35+
editor.chain().focus().insertContent(nodes).run()
36+
37+
// Inserting mid-text already leaves the rest of the paragraph behind the batch; only add one
38+
// when the media ends the document, so there's somewhere to keep typing. Appending
39+
// unconditionally is what leaves a stray blank line at the end.
40+
const {doc} = editor.state
41+
if (doc.lastChild?.type.name !== 'paragraph') {
42+
editor.chain().insertContentAt(doc.content.size, {type: 'paragraph'}).focus('end').run()
43+
}
3144
},
3245
onError(error: any) {
3346
toast.error(error.message ?? error)

web/components/profile/profile-hero-photo.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ export default function ProfileHeroPhoto(props: {
4545
<div
4646
className={clsx(
4747
'border-canvas-300 relative aspect-square w-full flex-none overflow-hidden rounded-xl border',
48+
// Stacked above the text, a full-width square is as tall as the screen is wide, which on a
49+
// phone pushes everything that says who this is below the fold. Capping the width caps the
50+
// height with it (the square follows), so the photo never takes more than 40% of the
51+
// viewport. Lifted at `md`, where the width is driven by the text column instead.
52+
'max-w-[40dvh] md:max-w-none',
4853
'md:w-[var(--hero-photo-size)] md:self-start',
4954
onClick && 'cursor-pointer',
5055
className,

web/components/widgets/editor.tsx

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
84113
const 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

105135
export 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

Comments
 (0)