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 if (!image) { 55 if (!entity_set.permissions.write) return null; 56 return ( 57 <div className="grow w-full"> 58 <label 59 className={` 60 group/image-block 61 w-full h-[104px] hover:cursor-pointer p-2 62 text-tertiary hover:text-accent-contrast hover:font-bold 63 flex flex-col items-center justify-center 64 hover:border-2 border-dashed hover:border-accent-contrast rounded-lg 65 ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"} 66 ${props.pageType === "canvas" && "bg-bg-page"}`} 67 onMouseDown={(e) => e.preventDefault()} 68 > 69 <div className="flex gap-2"> 70 <BlockImageSmall 71 className={`shrink-0 group-hover/image-block:text-accent-contrast ${isSelected ? "text-tertiary" : "text-border"}`} 72 /> 73 Upload An Image 74 </div> 75 <input 76 disabled={isLocked} 77 className="h-0 w-0 hidden" 78 type="file" 79 accept="image/*" 80 onChange={async (e) => { 81 let file = e.currentTarget.files?.[0]; 82 if (!file || !rep) return; 83 let entity = props.entityID; 84 if (!entity) { 85 entity = v7(); 86 await rep?.mutate.addBlock({ 87 parent: props.parent, 88 factID: v7(), 89 permission_set: entity_set.set, 90 type: "text", 91 position: generateKeyBetween( 92 props.position, 93 props.nextPosition, 94 ), 95 newEntityID: entity, 96 }); 97 } 98 await rep.mutate.assertFact({ 99 entity, 100 attribute: "block/type", 101 data: { type: "block-type-union", value: "image" }, 102 }); 103 await addImage(file, rep, { 104 entityID: entity, 105 attribute: "block/image", 106 }); 107 }} 108 /> 109 </label> 110 </div> 111 ); 112 } 113 114 let className = isFullBleed 115 ? "" 116 : isSelected 117 ? "block-border-selected border-transparent! " 118 : "block-border border-transparent!"; 119 120 let isLocalUpload = localImages.get(image.data.src); 121 122 return ( 123 <div 124 className={`relative group/image 125 ${className} 126 ${isFullBleed && "-mx-3 sm:-mx-4"} 127 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 128 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} `} 129 > 130 {isFullBleed && isSelected ? <FullBleedSelectionIndicator /> : null} 131 {isLocalUpload || image.data.local ? ( 132 <img 133 loading="lazy" 134 decoding="async" 135 alt={altText} 136 src={isLocalUpload ? image.data.src + "?local" : image.data.fallback} 137 height={image?.data.height} 138 width={image?.data.width} 139 /> 140 ) : ( 141 <Image 142 alt={altText || ""} 143 src={new URL(image.data.src).pathname.split("/").slice(5).join("/")} 144 height={image?.data.height} 145 width={image?.data.width} 146 className={className} 147 /> 148 )} 149 {altText !== undefined && !props.preview ? ( 150 <ImageAlt entityID={props.value} /> 151 ) : null} 152 </div> 153 ); 154} 155 156export const FullBleedSelectionIndicator = () => { 157 return ( 158 <div 159 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`} 160 /> 161 ); 162}; 163 164export const ImageBlockContext = createContext({ 165 altEditorOpen: false, 166 setAltEditorOpen: (s: boolean) => {}, 167}); 168 169const ImageAlt = (props: { entityID: string }) => { 170 let { rep } = useReplicache(); 171 let altText = useEntity(props.entityID, "image/alt")?.data.value; 172 let entity_set = useEntitySetContext(); 173 174 let setAltEditorOpen = useUIState((s) => s.setOpenPopover); 175 let altEditorOpen = useUIState((s) => s.openPopover === props.entityID); 176 177 if (!entity_set.permissions.write && altText === "") return null; 178 return ( 179 <div className="absolute bottom-0 right-2 h-max"> 180 <Popover 181 open={altEditorOpen} 182 className="text-sm max-w-xs min-w-0" 183 side="left" 184 asChild 185 trigger={ 186 <button 187 onClick={() => 188 setAltEditorOpen(altEditorOpen ? null : props.entityID) 189 } 190 > 191 <ImageAltSmall fillColor={theme.colors["bg-page"]} /> 192 </button> 193 } 194 > 195 {entity_set.permissions.write ? ( 196 <AsyncValueAutosizeTextarea 197 className="text-sm text-secondary outline-hidden bg-transparent min-w-0" 198 value={altText} 199 onFocus={(e) => { 200 e.currentTarget.setSelectionRange( 201 e.currentTarget.value.length, 202 e.currentTarget.value.length, 203 ); 204 }} 205 onChange={async (e) => { 206 await rep?.mutate.assertFact({ 207 entity: props.entityID, 208 attribute: "image/alt", 209 data: { type: "string", value: e.currentTarget.value }, 210 }); 211 }} 212 placeholder="add alt text..." 213 /> 214 ) : ( 215 <div className="text-sm text-secondary w-full"> {altText}</div> 216 )} 217 </Popover> 218 </div> 219 ); 220};