a tool for shared writing and social publishing
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"; 20 21export function ImageBlock(props: BlockProps & { preview?: boolean }) { 22 let { rep } = useReplicache(); 23 let image = useEntity(props.value, "block/image"); 24 let entity_set = useEntitySetContext(); 25 let isSelected = useUIState((s) => 26 s.selectedBlocks.find((b) => b.value === props.value), 27 ); 28 let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 29 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 30 let isFirst = props.previousBlock === null; 31 let isLast = props.nextBlock === null; 32 33 let altText = useEntity(props.value, "image/alt")?.data.value; 34 35 let nextIsFullBleed = useEntity( 36 props.nextBlock && props.nextBlock.value, 37 "image/full-bleed", 38 )?.data.value; 39 let prevIsFullBleed = useEntity( 40 props.previousBlock && props.previousBlock.value, 41 "image/full-bleed", 42 )?.data.value; 43 44 useEffect(() => { 45 if (props.preview) return; 46 let input = document.getElementById(elementId.block(props.entityID).input); 47 if (isSelected) { 48 input?.focus(); 49 } else { 50 input?.blur(); 51 } 52 }, [isSelected, props.preview, props.entityID]); 53 54 const handleImageUpload = async (file: File) => { 55 if (!rep) return; 56 let entity = props.entityID; 57 if (!entity) { 58 entity = v7(); 59 await rep?.mutate.addBlock({ 60 parent: props.parent, 61 factID: v7(), 62 permission_set: entity_set.set, 63 type: "text", 64 position: generateKeyBetween( 65 props.position, 66 props.nextPosition, 67 ), 68 newEntityID: entity, 69 }); 70 } 71 await rep.mutate.assertFact({ 72 entity, 73 attribute: "block/type", 74 data: { type: "block-type-union", value: "image" }, 75 }); 76 await addImage(file, rep, { 77 entityID: entity, 78 attribute: "block/image", 79 }); 80 }; 81 82 if (!image) { 83 if (!entity_set.permissions.write) return null; 84 return ( 85 <div className="grow w-full"> 86 <label 87 className={` 88 group/image-block 89 w-full h-[104px] hover:cursor-pointer p-2 90 text-tertiary hover:text-accent-contrast hover:font-bold 91 flex flex-col items-center justify-center 92 hover:border-2 border-dashed hover:border-accent-contrast rounded-lg 93 ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 94 ${props.pageType === "canvas" && "bg-bg-page"}`} 95 onMouseDown={(e) => e.preventDefault()} 96 onDragOver={(e) => { 97 e.preventDefault(); 98 e.stopPropagation(); 99 }} 100 onDrop={async (e) => { 101 e.preventDefault(); 102 e.stopPropagation(); 103 if (isLocked) return; 104 const files = e.dataTransfer.files; 105 if (files && files.length > 0) { 106 const file = files[0]; 107 if (file.type.startsWith('image/')) { 108 await handleImageUpload(file); 109 } 110 } 111 }} 112 > 113 <div className="flex gap-2"> 114 <BlockImageSmall 115 className={`shrink-0 group-hover/image-block:text-accent-contrast ${isSelected ? "text-tertiary" : "text-border"}`} 116 /> 117 Upload An Image 118 </div> 119 <input 120 disabled={isLocked} 121 className="h-0 w-0 hidden" 122 type="file" 123 accept="image/*" 124 onChange={async (e) => { 125 let file = e.currentTarget.files?.[0]; 126 if (!file) return; 127 await handleImageUpload(file); 128 }} 129 /> 130 </label> 131 </div> 132 ); 133 } 134 135 let className = isFullBleed 136 ? "" 137 : isSelected 138 ? "block-border-selected border-transparent! " 139 : "block-border border-transparent!"; 140 141 let isLocalUpload = localImages.get(image.data.src); 142 143 return ( 144 <div 145 className={`relative group/image 146 ${className} 147 ${isFullBleed && "-mx-3 sm:-mx-4"} 148 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} `} 150 > 151 {isFullBleed && isSelected ? <FullBleedSelectionIndicator /> : null} 152 {isLocalUpload || image.data.local ? ( 153 <img 154 loading="lazy" 155 decoding="async" 156 alt={altText} 157 src={isLocalUpload ? image.data.src + "?local" : image.data.fallback} 158 height={image?.data.height} 159 width={image?.data.width} 160 /> 161 ) : ( 162 <Image 163 alt={altText || ""} 164 src={ 165 "/" + new URL(image.data.src).pathname.split("/").slice(5).join("/") 166 } 167 height={image?.data.height} 168 width={image?.data.width} 169 className={className} 170 /> 171 )} 172 {altText !== undefined && !props.preview ? ( 173 <ImageAlt entityID={props.value} /> 174 ) : null} 175 </div> 176 ); 177} 178 179export const FullBleedSelectionIndicator = () => { 180 return ( 181 <div 182 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`} 183 /> 184 ); 185}; 186 187export const ImageBlockContext = createContext({ 188 altEditorOpen: false, 189 setAltEditorOpen: (s: boolean) => {}, 190}); 191 192const ImageAlt = (props: { entityID: string }) => { 193 let { rep } = useReplicache(); 194 let altText = useEntity(props.entityID, "image/alt")?.data.value; 195 let entity_set = useEntitySetContext(); 196 197 let setAltEditorOpen = useUIState((s) => s.setOpenPopover); 198 let altEditorOpen = useUIState((s) => s.openPopover === props.entityID); 199 200 if (!entity_set.permissions.write && altText === "") return null; 201 return ( 202 <div className="absolute bottom-0 right-2 h-max"> 203 <Popover 204 open={altEditorOpen} 205 className="text-sm max-w-xs min-w-0" 206 side="left" 207 asChild 208 trigger={ 209 <button 210 onClick={() => 211 setAltEditorOpen(altEditorOpen ? null : props.entityID) 212 } 213 > 214 <ImageAltSmall fillColor={theme.colors["bg-page"]} /> 215 </button> 216 } 217 > 218 {entity_set.permissions.write ? ( 219 <AsyncValueAutosizeTextarea 220 className="text-sm text-secondary outline-hidden bg-transparent min-w-0" 221 value={altText} 222 onFocus={(e) => { 223 e.currentTarget.setSelectionRange( 224 e.currentTarget.value.length, 225 e.currentTarget.value.length, 226 ); 227 }} 228 onChange={async (e) => { 229 await rep?.mutate.assertFact({ 230 entity: props.entityID, 231 attribute: "image/alt", 232 data: { type: "string", value: e.currentTarget.value }, 233 }); 234 }} 235 placeholder="add alt text..." 236 /> 237 ) : ( 238 <div className="text-sm text-secondary w-full"> {altText}</div> 239 )} 240 </Popover> 241 </div> 242 ); 243};