Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
10 kB · 305 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306"use client";
import { useEntity, useReplicache } from "src/replicache";import { BlockProps, BlockLayout } from "./Block";import { useIsBlockSelected } from "src/useUIState";import Image from "next/image";import { useEntitySetContext } from "components/EntitySetProvider";import { addImage, localImages } from "src/utils/addImage";import { useImageLoadStatus } from "components/ImageLoadState";import { useCanvasImage } from "src/canvasZoom/CanvasZoomProvider";import { ImageStatusOverlay } from "./ImageStatusOverlay";import { useImageUploadStatus } from "src/utils/imageUploadStatus";import { addBlockBelow } from "src/utils/addBlockBelow";import { elementId } from "src/utils/elementId";import { useEffect, useState } from "react";import { BlockImageSmall } from "components/Icons/BlockImageSmall";import { ImageAltButton } from "./ImageAltButton";import { ImageOptions } from "./ImageOptions";import { ImageGalleryLightbox, EditorLightboxSlide,} from "./ImageGalleryBlock/ImageGalleryLightbox";import { getPostImageEntities } from "./ImageGalleryBlock/getPostImages";import { useCanOpenLightbox } from "./ImageGalleryBlock/useCanOpenLightbox";import { addGalleryImages } from "./ImageGalleryBlock/addGalleryImages";import { useBlockImagePaste } from "./useBlockImagePaste";
export function ImageBlock(props: BlockProps & { preview?: boolean }) { let { rep, undoManager } = useReplicache(); let image = useEntity(props.entityID, "block/image"); let entity_set = useEntitySetContext(); let isSelected = useIsBlockSelected(props.entityID); let isFullBleed = useEntity(props.entityID, "image/full-bleed")?.data.value; let maxWidth = useEntity(props.entityID, "image/max-width")?.data.value; // Live width while the settings slider is being dragged, before it commits to // the fact; null hands rendering back to the committed value. let [previewWidth, setPreviewWidth] = useState<number | null>(null); let isFirst = props.previousBlock === null; let isLast = props.nextBlock === null;
let altText = useEntity(props.entityID, "image/alt")?.data.value;
let imageSrc = image?.data.src; let localSrc = imageSrc ? localImages.get(imageSrc) : undefined; // Editor previews are inert; leaving uploadSrc unset keeps their overlays off. let uploadSrc = !props.preview ? imageSrc : undefined; let uploadFailed = useImageUploadStatus((s) => uploadSrc ? s.uploads[uploadSrc]?.state === "failed" : false, ); let [reloads, setReloads] = useState(0); let { decoding, className: canvasImageClass } = useCanvasImage(imageSrc); let { status: loadStatus, imgProps, reset: resetLoadStatus, } = useImageLoadStatus(localSrc ?? imageSrc);
let retryImage = () => { resetLoadStatus(); setReloads((r) => r + 1); };
// Snapshot of every image in the post, taken the first time the lightbox // opens, so it can page through them all (gallery images included) starting on // the one that was opened. let [lightbox, setLightbox] = useState<{ ids: string[]; index: number; altExpanded?: boolean; } | null>(null);
let { canOpenLightbox, clickOpensLightbox } = useCanOpenLightbox({ entityID: props.entityID, isSelected: !!isSelected, preview: props.preview, });
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 // canvas, or before the write has settled). if (index === -1) { ids = [props.entityID]; index = 0; } setLightbox({ ids, index, altExpanded: opts?.altExpanded }); };
let nextIsFullBleed = useEntity( props.nextBlock && props.nextBlock.entityID, "image/full-bleed", )?.data.value; let prevIsFullBleed = useEntity( props.previousBlock && props.previousBlock.entityID, "image/full-bleed", )?.data.value;
useEffect(() => { if (props.preview) return; let input = document.getElementById(elementId.block(props.entityID).input); if (isSelected) { input?.focus(); } else { input?.blur(); } }, [isSelected, props.preview, props.entityID]);
const handleImageUpload = async (file: File) => { if (!rep) return; await undoManager.withUndoGroup(async () => { let entity = props.entityID; if (!entity) { entity = await addBlockBelow(rep, { parent: props.parent, position: props.position, nextPosition: props.nextPosition, permission_set: entity_set.set, type: "text", }); } await rep.mutate.assertFact({ entity, attribute: "block/type", data: { type: "block-type-union", value: "image" }, }); await addImage(file, rep, { entityID: entity, attribute: "block/image", }); }); };
const handleImageFiles = async (files: File[]) => { let images = files.filter((f) => f.type.startsWith("image/")); if (images.length === 0 || !rep) return; if (images.length === 1) return handleImageUpload(images[0]); await undoManager.withUndoGroup(() => rep.mutate.assertFact({ entity: props.entityID, attribute: "block/type", data: { type: "block-type-union", value: "image-gallery" }, }), ); await addGalleryImages(rep, { galleryEntity: props.entityID, permission_set: entity_set.set, files: images, }); };
useBlockImagePaste( props.entityID, !image && !props.preview && !!isSelected && entity_set.permissions.write, handleImageFiles, );
if (!image) { if (!entity_set.permissions.write) return null; return ( <BlockLayout hasBackground="accent" isSelected={!!isSelected} borderOnHover className={` group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg `} > <label className={` w-full h-full hover:cursor-pointer flex flex-col items-center justify-center `} onMouseDown={(e) => e.preventDefault()} onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }} onDrop={async (e) => { e.preventDefault(); e.stopPropagation(); await handleImageFiles(Array.from(e.dataTransfer.files)); }} > <div className="flex gap-2"> <BlockImageSmall className={`shrink-0 group-hover/image-block:text-accent-contrast ${isSelected ? "text-tertiary" : "text-border"}`} /> Upload An Image </div> <input className="h-0 w-0 hidden" type="file" accept="image/*" multiple onChange={async (e) => { await handleImageFiles(Array.from(e.currentTarget.files ?? [])); }} /> </label> </BlockLayout> ); }
let blockClassName = ` relative group/image border-transparent! p-0! ${ isFullBleed ? "w-[calc(100%+28px)]! sm:w-[calc(100%+36px)]! -mx-[14px] sm:-mx-[18px] rounded-[0px]! sm:outline-offset-[-16px]! -outline-offset[-12px]!" : "w-fit!" } ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-[5px]" : "") : ""} ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-[9px]" : "") : ""} `;
let displayWidth = previewWidth ?? maxWidth; let imageStyle = displayWidth && !isFullBleed ? { width: displayWidth, maxWidth: "100%", height: "auto" as const } : undefined;
return ( <BlockLayout hasAlignment={!isFullBleed} isSelected={!!isSelected} className={blockClassName} optionsClassName={isFullBleed ? "top-[-8px]! border-none!" : ""} extraOptions={ !props.preview ? ( <ImageOptions entityID={props.entityID} onPreviewWidth={setPreviewWidth} /> ) : undefined } > <div className={`relative ${isFullBleed ? "w-full" : "w-fit"} ${loadStatus === "error" || uploadFailed ? "min-w-40 min-h-24" : ""}`} > <button type="button" data-block-body className={`block ${isFullBleed ? "w-full" : "w-fit"} ${canOpenLightbox ? "cursor-zoom-in" : ""}`} onClick={() => { if (clickOpensLightbox()) openLightbox(); }} > {localSrc || image.data.local ? ( <img {...imgProps} key={reloads} loading="lazy" decoding={decoding} alt={altText} src={localSrc ?? image.data.fallback} height={image?.data.height} width={image?.data.width} className={`${isFullBleed ? "w-full" : ""} ${canvasImageClass}`} style={imageStyle} /> ) : ( <Image {...imgProps} key={reloads} alt={altText || ""} src={ "/" + new URL(image.data.src).pathname.split("/").slice(5).join("/") } height={image?.data.height} width={image?.data.width} className={isFullBleed ? "w-full" : undefined} style={imageStyle} /> )} </button> <ImageStatusOverlay uploadSrc={uploadSrc} loadStatus={loadStatus} onReload={retryImage} className={isFullBleed ? "rounded-none!" : "rounded-lg!"} /> </div> {!props.preview && ( <ImageGalleryLightbox count={lightbox?.ids.length ?? 0} index={lightbox?.index ?? null} altExpanded={lightbox?.altExpanded} onIndexChange={(i) => { if (i === null) setLightbox(null); }} renderSlide={(i) => lightbox ? <EditorLightboxSlide entityID={lightbox.ids[i]} /> : null } /> )} {!props.preview ? ( <ImageAltButton entityID={props.entityID} selected={!!isSelected} canEdit={entity_set.permissions.write} onSeeMore={() => openLightbox({ altExpanded: true })} /> ) : null} </BlockLayout> );}