diff --git a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageBlock.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageBlock.tsx index 2c566258..b59b35db 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageBlock.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageBlock.tsx @@ -13,6 +13,7 @@ export function PublishedImageBlock(props: { isFullBleed?: boolean; className?: string; onOpenLightbox?: () => void; + onOpenAltInLightbox?: () => void; }) { let [reloads, setReloads] = useState(0); let src = @@ -56,7 +57,12 @@ export function PublishedImageBlock(props: { className={props.isFullBleed ? "rounded-none!" : "rounded-lg!"} /> )} - {props.alt && } + {props.alt && ( + + )} ); } diff --git a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx index 2a01eb55..0be9d908 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx @@ -39,7 +39,11 @@ export function PublishedImageGallery(props: { ); let [lightboxIndex, setLightboxIndex] = useState(null); - let openLightbox = (index: number) => setLightboxIndex(index); + let [lightboxAltExpanded, setLightboxAltExpanded] = useState(false); + let openLightbox = (index: number, altExpanded?: boolean) => { + setLightboxAltExpanded(!!altExpanded); + setLightboxIndex(index); + }; let format = block.format ?? DEFAULT_FORMAT; let gap = block.gap ?? DEFAULT_GAP; @@ -51,7 +55,12 @@ export function PublishedImageGallery(props: { openLightbox(i)} - overlay={} + overlay={ + openLightbox(i, true)} + /> + } {...classes} /> ); @@ -78,6 +87,7 @@ export function PublishedImageGallery(props: { } /> diff --git a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx index e8102ba1..0b43f700 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx @@ -106,7 +106,7 @@ function DocLinkBlock(props: {
{title && (
void) | null + | (( + pageId: string | undefined, + cid: string, + opts?: { altExpanded?: boolean }, + ) => void) + | null >(null); // Null on surfaces without a GlobalImageLightbox (publication pages), which is @@ -31,17 +36,23 @@ export function GlobalImageLightbox(props: { let [lightbox, setLightbox] = useState<{ images: PostImage[]; index: number; + altExpanded?: boolean; } | null>(null); const { pages } = useLeafletContent(); let openAt = useCallback( - (pageId: string | undefined, cid: string) => { + ( + pageId: string | undefined, + cid: string, + opts?: { altExpanded?: boolean }, + ) => { let page = pageId ? (pages as Page[]).find((p) => p.id === pageId) : (pages[0] as Page | undefined); let images = page ? collectPostImages(page, props.did) : []; let index = images.findIndex((i) => i.cid === cid); - if (index !== -1) setLightbox({ images, index }); + if (index !== -1) + setLightbox({ images, index, altExpanded: opts?.altExpanded }); }, [pages, props.did], ); @@ -52,6 +63,7 @@ export function GlobalImageLightbox(props: { { if (i === null) setLightbox(null); }} diff --git a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx index 247ca53d..80fd121f 100644 --- a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -586,6 +586,11 @@ export let Block = ({ onOpenLightbox={ canOpenLightbox ? () => openLightbox?.(pageId, cid) : undefined } + onOpenAltInLightbox={ + canOpenLightbox + ? () => openLightbox?.(pageId, cid, { altExpanded: true }) + : undefined + } />
); @@ -601,7 +606,7 @@ export let Block = ({ return ( // all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding.
{ return (

@@ -680,7 +685,7 @@ export let Block = ({ if (block.level === 2) return (

@@ -690,7 +695,7 @@ export let Block = ({ if (block.level === 3) return (

@@ -699,7 +704,7 @@ export let Block = ({ ); return (

diff --git a/components/Blocks/ImageAltButton.tsx b/components/Blocks/ImageAltButton.tsx index 1c7631a7..be2462f7 100644 --- a/components/Blocks/ImageAltButton.tsx +++ b/components/Blocks/ImageAltButton.tsx @@ -4,7 +4,7 @@ import { EditTiny } from "components/Icons/EditTiny"; import { useState } from "react"; import { useSpring, animated } from "@react-spring/web"; import useMeasure from "react-use-measure"; -import { ReadOnlyAltText } from "./ReadOnlyAltText"; +import { AltTextBody, ReadOnlyAltText } from "./ReadOnlyAltText"; // Overlaid on an image; visible on hover (within a `group/image`), while the // containing block is selected, or whenever alt text already exists. Opens the @@ -16,6 +16,7 @@ export function ImageAltButton(props: { selected: boolean; canEdit?: boolean; className?: string; + onSeeMore?: () => void; }) { let canEdit = props.canEdit ?? true; let alt = useEntity(props.entityID, "image/alt")?.data.value; @@ -26,14 +27,20 @@ export function ImageAltButton(props: { let altStyle = useSpring({ height: showAlt ? altHeight : 0, opacity: showAlt ? 1 : 0, - config: { tension: 450, friction: 30 }, + config: { tension: 450, friction: 30, clamp: true }, }); // Readers have nothing to interact with when there's no alt text. if (!canEdit && !hasAlt) return null; // Readers get the plain ALT pill + preview, shared with published posts. if (!canEdit) - return ; + return ( + + ); return (
-
- {alt} -
+
diff --git a/components/Blocks/ImageBlock.tsx b/components/Blocks/ImageBlock.tsx index df512a33..fff0f1bd 100644 --- a/components/Blocks/ImageBlock.tsx +++ b/components/Blocks/ImageBlock.tsx @@ -61,6 +61,7 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { let [lightbox, setLightbox] = useState<{ ids: string[]; index: number; + altExpanded?: boolean; } | null>(null); // Writers select the block first; a second click on the image opens the @@ -68,7 +69,7 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { let canOpenLightbox = !props.preview && (!entity_set.permissions.write || !!isSelected); - let openLightbox = () => { + let openLightbox = (opts?: { altExpanded?: boolean }) => { let ids = rep ? getPostImageEntities(rep, props.parent) : []; let index = ids.indexOf(props.entityID); // Fall back to just this image if it isn't in the gathered list (e.g. on the @@ -77,7 +78,7 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { ids = [props.entityID]; index = 0; } - setLightbox({ ids, index }); + setLightbox({ ids, index, altExpanded: opts?.altExpanded }); }; let nextIsFullBleed = useEntity( @@ -258,6 +259,7 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { { if (i === null) setLightbox(null); }} @@ -271,6 +273,7 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { entityID={props.entityID} selected={!!isSelected} canEdit={entity_set.permissions.write} + onSeeMore={() => openLightbox({ altExpanded: true })} /> ) : null} diff --git a/components/Blocks/ImageGalleryBlock/GalleryImageItem.tsx b/components/Blocks/ImageGalleryBlock/GalleryImageItem.tsx index c79e41e9..f03f3d1b 100644 --- a/components/Blocks/ImageGalleryBlock/GalleryImageItem.tsx +++ b/components/Blocks/ImageGalleryBlock/GalleryImageItem.tsx @@ -50,6 +50,7 @@ export function EditorGalleryImageItem( editable: boolean; selected: boolean; onClick: () => void; + onSeeMoreAlt?: () => void; } & GalleryItemClasses, ) { let image = useGalleryImage(props.entityID); @@ -69,6 +70,7 @@ export function EditorGalleryImageItem( entityID={props.entityID} selected={props.selected} canEdit={props.editable} + onSeeMore={props.onSeeMoreAlt} /> } diff --git a/components/Blocks/ImageGalleryBlock/ImageGalleryLightbox.tsx b/components/Blocks/ImageGalleryBlock/ImageGalleryLightbox.tsx index 1f133dcf..c94ebe00 100644 --- a/components/Blocks/ImageGalleryBlock/ImageGalleryLightbox.tsx +++ b/components/Blocks/ImageGalleryBlock/ImageGalleryLightbox.tsx @@ -1,14 +1,24 @@ -import { ReactNode, useEffect, useRef, useState } from "react"; +import { + createContext, + ReactNode, + useContext, + useEffect, + useRef, + useState, +} from "react"; import * as Dialog from "@radix-ui/react-dialog"; import { CloseTiny } from "components/Icons/CloseTiny"; import { GalleryImage, useGalleryImage } from "./shared"; import { GoToArrowLined } from "components/Icons/GoToArrowLined"; +const AltExpandedContext = createContext(false); + export function ImageGalleryLightbox(props: { count: number; index: number | null; onIndexChange: (index: number | null) => void; renderSlide: (index: number) => ReactNode; + altExpanded?: boolean; }) { let open = props.index !== null; @@ -37,6 +47,7 @@ export function ImageGalleryLightbox(props: { props.onIndexChange(null)} /> @@ -50,6 +61,7 @@ export function ImageGalleryLightbox(props: { function LightboxContent(props: { count: number; initialIndex: number; + altExpanded: boolean; renderSlide: (index: number) => ReactNode; onClose: () => void; }) { @@ -103,7 +115,11 @@ function LightboxContent(props: { key={i} className="h-full snap-center snap-always flex flex-col items-center justify-center gap-3" > - {props.renderSlide(i)} + + {props.renderSlide(i)} +
))} @@ -151,7 +167,7 @@ function LightboxContent(props: { export function LightboxSlide(props: { image: GalleryImage }) { let { image } = props; return ( -
+
{image.alt} e.stopPropagation()} />
- {image.alt && ( + {image.alt && } +
+ ); +} + +function LightboxAltText(props: { alt: string }) { + let [expanded, setExpanded] = useState(useContext(AltExpandedContext)); + let [clamped, setClamped] = useState(false); + let clampedRef = useRef(null); + + // Only measure while collapsed — once expanded the clamp is gone and the + // element no longer overflows, which would hide the toggle. + useEffect(() => { + if (expanded) return; + let el = clampedRef.current; + if (!el) return; + let measure = () => setClamped(el.scrollHeight > el.clientHeight + 1); + measure(); + let observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [props.alt, expanded]); + + return ( +
e.stopPropagation()} + className="shrink-0 w-full max-w-prose flex flex-col items-center gap-1" + > +
+ {/* Kept mounted while expanded so the image doesn't shift under the overlay. */}
e.stopPropagation()} - className="shrink-0 whitespace-pre-wrap text-bg-page text-sm line-clamp-6 max-w-prose text-center" + ref={clampedRef} + className={`whitespace-pre-wrap text-bg-page text-sm line-clamp-3 text-center ${expanded ? "invisible" : ""}`} > - {image.alt} + {props.alt}
+ {expanded && ( +
+ {props.alt} +
+ )} +
+ {(clamped || expanded) && ( + )}
); diff --git a/components/Blocks/ImageGalleryBlock/index.tsx b/components/Blocks/ImageGalleryBlock/index.tsx index 9e9be87f..09b0c33a 100644 --- a/components/Blocks/ImageGalleryBlock/index.tsx +++ b/components/Blocks/ImageGalleryBlock/index.tsx @@ -36,6 +36,7 @@ export function ImageGalleryBlock(props: BlockProps & { preview?: boolean }) { let [editOpen, setEditOpen] = useState(false); let [lightboxIndex, setLightboxIndex] = useState(null); + let [lightboxAltExpanded, setLightboxAltExpanded] = useState(false); let imageEntities = imageFacts.map((f) => f.data.value); @@ -70,7 +71,16 @@ export function ImageGalleryBlock(props: BlockProps & { preview?: boolean }) { let canOpenLightbox = !props.preview && (!entity_set.permissions.write || !!isSelected); let openLightbox = (index: number) => { - if (canOpenLightbox) setLightboxIndex(index); + if (canOpenLightbox) { + setLightboxAltExpanded(false); + setLightboxIndex(index); + } + }; + // Alt text too long for the on-image box hands off to the lightbox, whether or + // not a plain click on the image would open it. + let openLightboxWithAlt = (index: number) => { + setLightboxAltExpanded(true); + setLightboxIndex(index); }; let editable = !props.preview && entity_set.permissions.write; @@ -117,6 +127,7 @@ export function ImageGalleryBlock(props: BlockProps & { preview?: boolean }) { editable={editable} selected={!!isSelected} onClick={() => openLightbox(i)} + onSeeMoreAlt={() => openLightboxWithAlt(i)} {...classes} /> )} @@ -131,6 +142,7 @@ export function ImageGalleryBlock(props: BlockProps & { preview?: boolean }) { editable={editable} selected={!!isSelected} onClick={() => openLightbox(i)} + onSeeMoreAlt={() => openLightboxWithAlt(i)} {...classes} /> )} @@ -146,6 +158,7 @@ export function ImageGalleryBlock(props: BlockProps & { preview?: boolean }) { editable={editable} selected={!!isSelected} onClick={() => openLightbox(i)} + onSeeMoreAlt={() => openLightboxWithAlt(i)} {...classes} /> )} @@ -165,6 +178,7 @@ export function ImageGalleryBlock(props: BlockProps & { preview?: boolean }) { } /> diff --git a/components/Blocks/ReadOnlyAltText.tsx b/components/Blocks/ReadOnlyAltText.tsx index ba614a4d..0d362bcf 100644 --- a/components/Blocks/ReadOnlyAltText.tsx +++ b/components/Blocks/ReadOnlyAltText.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useSpring, animated } from "@react-spring/web"; import useMeasure from "react-use-measure"; @@ -7,13 +7,17 @@ import useMeasure from "react-use-measure"; // editor (ImageAltButton's reader mode) and published posts so both render alt // text identically. Purely presentational — takes the alt string directly so it // has no replicache dependency and is safe to render on public post pages. -export function ReadOnlyAltText(props: { alt: string; className?: string }) { +export function ReadOnlyAltText(props: { + alt: string; + className?: string; + onSeeMore?: () => void; +}) { let [showAlt, setShowAlt] = useState(false); let [altRef, { height: altHeight }] = useMeasure(); let altStyle = useSpring({ height: showAlt ? altHeight : 0, opacity: showAlt ? 1 : 0, - config: { tension: 450, friction: 30 }, + config: { tension: 450, friction: 30, clamp: true }, }); if (!props.alt) return null; @@ -42,11 +46,49 @@ export function ReadOnlyAltText(props: { alt: string; className?: string }) {
-
- {props.alt} -
+
); } + +// The alt text itself, clamped on the image. Alt longer than the clamp gets a +// "See more" that hands off to the lightbox, which can give it the full height. +export function AltTextBody(props: { alt: string; onSeeMore?: () => void }) { + let [overflows, setOverflows] = useState(false); + let textRef = useRef(null); + + useEffect(() => { + let el = textRef.current; + if (!el) return; + let measure = () => setOverflows(el.scrollHeight > el.clientHeight + 1); + measure(); + let observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [props.alt]); + + return ( +
+
+
+ {props.alt} +
+ {overflows && props.onSeeMore && ( + // Sits on the clipped last line, over the text it truncates. + + )} +
+
+ ); +} diff --git a/components/Footnotes/FootnoteItemLayout.tsx b/components/Footnotes/FootnoteItemLayout.tsx index 0abfdf7b..4479c322 100644 --- a/components/Footnotes/FootnoteItemLayout.tsx +++ b/components/Footnotes/FootnoteItemLayout.tsx @@ -33,7 +33,7 @@ export function FootnoteItemLayout(props: { )}
{props.children} diff --git a/emails/shared.tsx b/emails/shared.tsx index d2ef7899..b9bf510b 100644 --- a/emails/shared.tsx +++ b/emails/shared.tsx @@ -153,14 +153,19 @@ export const makeEmailIconUrl = ( return `${base}/api/email-assets/${name}.png?${params.toString()}`; }; +// The last space of a run stays a regular space so the line can still wrap +// there; the rest become non-breaking so HTML doesn't collapse the run. +const preserveSpaceRuns = (text: string): string => + text.replace(/ {2,}/g, (run) => "\u00a0".repeat(run.length - 1) + " "); + // Email clients don't render `white-space: pre-wrap` reliably (Outlook // collapses it), so newlines in plaintext become explicit
elements. export const renderTextWithBreaks = ( text: string, key: string, ): React.ReactNode => { - const parts = text.split("\n"); - if (parts.length === 1) return text; + const parts = text.split("\n").map(preserveSpaceRuns); + if (parts.length === 1) return parts[0]; return parts.flatMap((part, i) => i < parts.length - 1 ? [part,
] : [part], );