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