a tool for shared writing and social publishing
at feature/page-blocks 222 lines 7.6 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"; 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={ 144 "/" + new URL(image.data.src).pathname.split("/").slice(5).join("/") 145 } 146 height={image?.data.height} 147 width={image?.data.width} 148 className={className} 149 /> 150 )} 151 {altText !== undefined && !props.preview ? ( 152 <ImageAlt entityID={props.value} /> 153 ) : null} 154 </div> 155 ); 156} 157 158export const FullBleedSelectionIndicator = () => { 159 return ( 160 <div 161 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`} 162 /> 163 ); 164}; 165 166export const ImageBlockContext = createContext({ 167 altEditorOpen: false, 168 setAltEditorOpen: (s: boolean) => {}, 169}); 170 171const ImageAlt = (props: { entityID: string }) => { 172 let { rep } = useReplicache(); 173 let altText = useEntity(props.entityID, "image/alt")?.data.value; 174 let entity_set = useEntitySetContext(); 175 176 let setAltEditorOpen = useUIState((s) => s.setOpenPopover); 177 let altEditorOpen = useUIState((s) => s.openPopover === props.entityID); 178 179 if (!entity_set.permissions.write && altText === "") return null; 180 return ( 181 <div className="absolute bottom-0 right-2 h-max"> 182 <Popover 183 open={altEditorOpen} 184 className="text-sm max-w-xs min-w-0" 185 side="left" 186 asChild 187 trigger={ 188 <button 189 onClick={() => 190 setAltEditorOpen(altEditorOpen ? null : props.entityID) 191 } 192 > 193 <ImageAltSmall fillColor={theme.colors["bg-page"]} /> 194 </button> 195 } 196 > 197 {entity_set.permissions.write ? ( 198 <AsyncValueAutosizeTextarea 199 className="text-sm text-secondary outline-hidden bg-transparent min-w-0" 200 value={altText} 201 onFocus={(e) => { 202 e.currentTarget.setSelectionRange( 203 e.currentTarget.value.length, 204 e.currentTarget.value.length, 205 ); 206 }} 207 onChange={async (e) => { 208 await rep?.mutate.assertFact({ 209 entity: props.entityID, 210 attribute: "image/alt", 211 data: { type: "string", value: e.currentTarget.value }, 212 }); 213 }} 214 placeholder="add alt text..." 215 /> 216 ) : ( 217 <div className="text-sm text-secondary w-full"> {altText}</div> 218 )} 219 </Popover> 220 </div> 221 ); 222};