a tool for shared writing and social publishing
at update/wider-page 280 lines 9.5 kB view raw
1"use client"; 2 3import { useEntity, useReplicache } from "src/replicache"; 4import { BlockProps } from "./Block"; 5import { useUIState } from "src/useUIState"; 6import Image from "next/image"; 7import { v7 } from "uuid"; 8import { useEntitySetContext } from "components/EntitySetProvider"; 9import { generateKeyBetween } from "fractional-indexing"; 10import { addImage, localImages } from "src/utils/addImage"; 11import { elementId } from "src/utils/elementId"; 12import { createContext, useContext, useEffect, useState } from "react"; 13import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 14import { Popover } from "components/Popover"; 15import { theme } from "tailwind.config"; 16import { EditTiny } from "components/Icons/EditTiny"; 17import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 18import { set } from "colorjs.io/fn"; 19import { ImageAltSmall } from "components/Icons/ImageAlt"; 20import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21import { useSubscribe } from "src/replicache/useSubscribe"; 22import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 23 24export function ImageBlock(props: BlockProps & { preview?: boolean }) { 25 let { rep } = useReplicache(); 26 let image = useEntity(props.value, "block/image"); 27 let entity_set = useEntitySetContext(); 28 let isSelected = useUIState((s) => 29 s.selectedBlocks.find((b) => b.value === props.value), 30 ); 31 let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 32 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 33 let isFirst = props.previousBlock === null; 34 let isLast = props.nextBlock === null; 35 36 let altText = useEntity(props.value, "image/alt")?.data.value; 37 38 let nextIsFullBleed = useEntity( 39 props.nextBlock && props.nextBlock.value, 40 "image/full-bleed", 41 )?.data.value; 42 let prevIsFullBleed = useEntity( 43 props.previousBlock && props.previousBlock.value, 44 "image/full-bleed", 45 )?.data.value; 46 47 useEffect(() => { 48 if (props.preview) return; 49 let input = document.getElementById(elementId.block(props.entityID).input); 50 if (isSelected) { 51 input?.focus(); 52 } else { 53 input?.blur(); 54 } 55 }, [isSelected, props.preview, props.entityID]); 56 57 const handleImageUpload = async (file: File) => { 58 if (!rep) return; 59 let entity = props.entityID; 60 if (!entity) { 61 entity = v7(); 62 await rep?.mutate.addBlock({ 63 parent: props.parent, 64 factID: v7(), 65 permission_set: entity_set.set, 66 type: "text", 67 position: generateKeyBetween( 68 props.position, 69 props.nextPosition, 70 ), 71 newEntityID: entity, 72 }); 73 } 74 await rep.mutate.assertFact({ 75 entity, 76 attribute: "block/type", 77 data: { type: "block-type-union", value: "image" }, 78 }); 79 await addImage(file, rep, { 80 entityID: entity, 81 attribute: "block/image", 82 }); 83 }; 84 85 if (!image) { 86 if (!entity_set.permissions.write) return null; 87 return ( 88 <div className="grow w-full"> 89 <label 90 className={` 91 group/image-block 92 w-full h-[104px] hover:cursor-pointer p-2 93 text-tertiary hover:text-accent-contrast hover:font-bold 94 flex flex-col items-center justify-center 95 hover:border-2 border-dashed hover:border-accent-contrast rounded-lg 96 ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 97 ${props.pageType === "canvas" && "bg-bg-page"}`} 98 onMouseDown={(e) => e.preventDefault()} 99 onDragOver={(e) => { 100 e.preventDefault(); 101 e.stopPropagation(); 102 }} 103 onDrop={async (e) => { 104 e.preventDefault(); 105 e.stopPropagation(); 106 if (isLocked) return; 107 const files = e.dataTransfer.files; 108 if (files && files.length > 0) { 109 const file = files[0]; 110 if (file.type.startsWith('image/')) { 111 await handleImageUpload(file); 112 } 113 } 114 }} 115 > 116 <div className="flex gap-2"> 117 <BlockImageSmall 118 className={`shrink-0 group-hover/image-block:text-accent-contrast ${isSelected ? "text-tertiary" : "text-border"}`} 119 /> 120 Upload An Image 121 </div> 122 <input 123 disabled={isLocked} 124 className="h-0 w-0 hidden" 125 type="file" 126 accept="image/*" 127 onChange={async (e) => { 128 let file = e.currentTarget.files?.[0]; 129 if (!file) return; 130 await handleImageUpload(file); 131 }} 132 /> 133 </label> 134 </div> 135 ); 136 } 137 138 let className = isFullBleed 139 ? "" 140 : isSelected 141 ? "block-border-selected border-transparent! " 142 : "block-border border-transparent!"; 143 144 let isLocalUpload = localImages.get(image.data.src); 145 146 return ( 147 <div 148 className={`relative group/image 149 ${className} 150 ${isFullBleed && "-mx-3 sm:-mx-4"} 151 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 152 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} `} 153 > 154 {isFullBleed && isSelected ? <FullBleedSelectionIndicator /> : null} 155 {isLocalUpload || image.data.local ? ( 156 <img 157 loading="lazy" 158 decoding="async" 159 alt={altText} 160 src={isLocalUpload ? image.data.src + "?local" : image.data.fallback} 161 height={image?.data.height} 162 width={image?.data.width} 163 /> 164 ) : ( 165 <Image 166 alt={altText || ""} 167 src={ 168 "/" + new URL(image.data.src).pathname.split("/").slice(5).join("/") 169 } 170 height={image?.data.height} 171 width={image?.data.width} 172 className={className} 173 /> 174 )} 175 {altText !== undefined && !props.preview ? ( 176 <ImageAlt entityID={props.value} /> 177 ) : null} 178 {!props.preview ? <CoverImageButton entityID={props.value} /> : null} 179 </div> 180 ); 181} 182 183export const FullBleedSelectionIndicator = () => { 184 return ( 185 <div 186 className={`absolute top-3 sm:top-4 bottom-3 sm:bottom-4 left-3 sm:left-4 right-3 sm:right-4 border-2 border-bg-page rounded-lg outline-offset-1 outline-solid outline-2 outline-tertiary`} 187 /> 188 ); 189}; 190 191export const ImageBlockContext = createContext({ 192 altEditorOpen: false, 193 setAltEditorOpen: (s: boolean) => {}, 194}); 195 196const CoverImageButton = (props: { entityID: string }) => { 197 let { rep } = useReplicache(); 198 let entity_set = useEntitySetContext(); 199 let { data: pubData } = useLeafletPublicationData(); 200 let coverImage = useSubscribe(rep, (tx) => 201 tx.get<string | null>("publication_cover_image"), 202 ); 203 let isFocused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 204 205 // Only show if focused, in a publication, has write permissions, and no cover image is set 206 if (!isFocused || !pubData?.publications || !entity_set.permissions.write || coverImage) return null; 207 208 return ( 209 <div className="absolute top-2 left-2"> 210 <button 211 className="flex items-center gap-1 text-xs bg-bg-page/80 hover:bg-bg-page text-secondary hover:text-primary px-2 py-1 rounded-md border border-border hover:border-primary transition-colors" 212 onClick={async (e) => { 213 e.preventDefault(); 214 e.stopPropagation(); 215 await rep?.mutate.updatePublicationDraft({ 216 cover_image: props.entityID, 217 }); 218 }} 219 > 220 <span className="w-4 h-4 flex items-center justify-center"> 221 <ImageCoverImage /> 222 </span> 223 Set as Cover 224 </button> 225 </div> 226 ); 227}; 228 229const ImageAlt = (props: { entityID: string }) => { 230 let { rep } = useReplicache(); 231 let altText = useEntity(props.entityID, "image/alt")?.data.value; 232 let entity_set = useEntitySetContext(); 233 234 let setAltEditorOpen = useUIState((s) => s.setOpenPopover); 235 let altEditorOpen = useUIState((s) => s.openPopover === props.entityID); 236 237 if (!entity_set.permissions.write && altText === "") return null; 238 return ( 239 <div className="absolute bottom-0 right-2 h-max"> 240 <Popover 241 open={altEditorOpen} 242 className="text-sm max-w-xs min-w-0" 243 side="left" 244 asChild 245 trigger={ 246 <button 247 onClick={() => 248 setAltEditorOpen(altEditorOpen ? null : props.entityID) 249 } 250 > 251 <ImageAltSmall fillColor={theme.colors["bg-page"]} /> 252 </button> 253 } 254 > 255 {entity_set.permissions.write ? ( 256 <AsyncValueAutosizeTextarea 257 className="text-sm text-secondary outline-hidden bg-transparent min-w-0" 258 value={altText} 259 onFocus={(e) => { 260 e.currentTarget.setSelectionRange( 261 e.currentTarget.value.length, 262 e.currentTarget.value.length, 263 ); 264 }} 265 onChange={async (e) => { 266 await rep?.mutate.assertFact({ 267 entity: props.entityID, 268 attribute: "image/alt", 269 data: { type: "string", value: e.currentTarget.value }, 270 }); 271 }} 272 placeholder="add alt text..." 273 /> 274 ) : ( 275 <div className="text-sm text-secondary w-full"> {altText}</div> 276 )} 277 </Popover> 278 </div> 279 ); 280};