diff --git a/frontend/src/lib/markdown.ts b/frontend/src/lib/markdown.ts index dd01406..2258e3e 100644 --- a/frontend/src/lib/markdown.ts +++ b/frontend/src/lib/markdown.ts @@ -92,9 +92,45 @@ function injectDimensions(html: string, dims?: ImageDims): string { }); } +// Marked emits `

` because the image renderer returns +// a block element from an inline slot. Strip the `

` wrapper when the +// paragraph contains nothing but figures (and whitespace /
), so the +// figure-grouping step below can see contiguous runs. +function unwrapFiguresFromParagraphs(html: string): string { + return html.replace(/

([\s\S]*?)<\/p>/g, (match, inner: string) => { + const stripped = inner.replace(//g, '').trim(); + if (!stripped) return match; + if (/^(?:\s*

[\s\S]*?<\/figure>\s*)+$/.test(stripped)) { + return stripped; + } + return match; + }); +} + +// Wrap runs of 2+ consecutive
elements in a `.figure-row` flex +// container. Each figure gets `flex: ` so widths divide the +// row proportionally and the final heights match. +function groupFigures(html: string): string { + return html.replace( + /(?:
[\s\S]*?<\/figure>\s*){2,}/g, + (run) => { + const figures = run.match(/
[\s\S]*?<\/figure>/g) ?? []; + const items = figures.map((fig) => { + const m = fig.match(/]*\swidth="(\d+)"[^>]*\sheight="(\d+)"/); + const ratio = m ? Number(m[1]) / Number(m[2]) : 1; + const safe = Number.isFinite(ratio) && ratio > 0 ? ratio : 1; + return fig.replace('
', `
`); + }); + return `
${items.join('')}
`; + }, + ); +} + export function renderMarkdown(src: string, dims?: ImageDims): string { let html = renderer.parse(src, { async: false }) as string; html = injectDimensions(html, dims); + html = unwrapFiguresFromParagraphs(html); + html = groupFigures(html); return DOMPurify.sanitize(html, { ADD_TAGS: [...KATEX_TAGS, 'figure', 'figcaption'], ADD_ATTR: ['aria-hidden', 'style', 'id', 'class', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel', 'loading'], diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 3ad2665..e9c1d9a 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -544,6 +544,50 @@ code, pre, kbd, samp { letter-spacing: 0.02em; line-height: 1.4; } +/* Multi-image rows. Consecutive markdown images auto-collapse into a flex + * row; each figure gets `flex: ` inline so widths divide + * proportionally and heights line up. Wraps to a column on narrow screens. */ +.prose .figure-row { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; + align-items: flex-start; + margin: 2.5rem 0; + /* Let the row breathe past the column when it gets dense. */ + width: calc(100% + min(8vw, 4rem)); + margin-left: calc(min(8vw, 4rem) / -2); +} +.prose .figure-row figure { + margin: 0; + min-width: 0; /* allow flex children to shrink below content width */ + flex-basis: 0; +} +.prose .figure-row figure img { + width: 100%; + max-width: 100%; + height: auto; + margin: 0; +} +.prose .figure-row figure figcaption { + text-align: left; + margin-top: 0.55rem; + font-size: 0.82rem; +} +@media (max-width: 640px) { + .prose .figure-row { + flex-direction: column; + width: 100%; + margin-left: 0; + gap: 1.4rem; + } + .prose .figure-row figure { + flex: 1 1 100% !important; + } + .prose .figure-row figure figcaption { + text-align: center; + } +} + .prose figure figcaption::before { content: "— "; color: var(--mauve);