a tool for shared writing and social publishing

Add alt text to image blocks! Update/image (#148)

* added alt text editor

* added styling to the read only and post view

* tried to add context to image alt editor to get it to open when alt toolbar button is clicked, but failed, gave up.

* i renamed shit to make it work but it didnt work

* put alt text popover state in zustand

* add alt attribute to rendered post images

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by awarm.space

celine and committed by
GitHub
15f508eb a554367e

+197 -14
+2
actions/publishToPublication.ts
··· 249 249 if (b.type == "image") { 250 250 let [image] = scan.eav(b.value, "block/image"); 251 251 if (!image) return; 252 + let [altText] = scan.eav(b.value, "image/alt"); 252 253 let blobref = imageMap.get(image.data.src); 253 254 if (!blobref) return; 254 255 let block: $Typed<PubLeafletBlocksImage.Main> = { ··· 258 259 height: image.data.height, 259 260 width: image.data.width, 260 261 }, 262 + alt: altText ? altText.data.value : undefined, 261 263 }; 262 264 return block; 263 265 }
+27 -7
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 1 + "use client"; 1 2 import { 2 3 PubLeafletBlocksHeader, 3 4 PubLeafletBlocksImage, ··· 9 10 } from "lexicons/api"; 10 11 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 12 import { TextBlock } from "./TextBlock"; 13 + import { Popover } from "components/Popover"; 14 + import { ImageAltSmall } from "components/Toolbar/ImageToolbar"; 15 + import { theme } from "tailwind.config"; 12 16 13 17 export function PostContent({ 14 18 blocks, ··· 100 104 </div> 101 105 {b.block.previewImage && ( 102 106 <div 103 - className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`} 107 + className={`imagePreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center relative`} 104 108 style={{ 105 109 backgroundImage: `url(${blobRefToSrc(b.block.previewImage?.ref, did)})`, 106 110 backgroundPosition: "center", ··· 112 116 } 113 117 case PubLeafletBlocksImage.isMain(b.block): { 114 118 return ( 115 - <img 116 - height={b.block.aspectRatio?.height} 117 - width={b.block.aspectRatio?.width} 118 - className={`!pt-3 sm:!pt-4 ${className}`} 119 - src={blobRefToSrc(b.block.image.ref, did)} 120 - /> 119 + <div className="relative"> 120 + <img 121 + alt={b.block.alt} 122 + height={b.block.aspectRatio?.height} 123 + width={b.block.aspectRatio?.width} 124 + className={`!pt-3 sm:!pt-4 ${className} rounded-md`} 125 + src={blobRefToSrc(b.block.image.ref, did)} 126 + /> 127 + {b.block.alt && ( 128 + <div className="absolute bottom-1.5 right-2 h-max"> 129 + <Popover 130 + className="text-sm max-w-xs min-w-0" 131 + side="left" 132 + trigger={<ImageAltSmall fillColor={theme.colors["bg-page"]} />} 133 + > 134 + <div className="text-sm text-secondary w-full"> 135 + {b.block.alt} 136 + </div> 137 + </Popover> 138 + </div> 139 + )} 140 + </div> 121 141 ); 122 142 } 123 143 case PubLeafletBlocksText.isMain(b.block):
+63 -5
components/Blocks/ImageBlock.tsx
··· 1 1 "use client"; 2 2 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 - import { Block, BlockProps } from "./Block"; 4 + import { BlockProps } from "./Block"; 5 5 import { useUIState } from "src/useUIState"; 6 6 import Image from "next/image"; 7 7 import { v7 } from "uuid"; ··· 9 9 import { generateKeyBetween } from "fractional-indexing"; 10 10 import { addImage, localImages } from "src/utils/addImage"; 11 11 import { elementId } from "src/utils/elementId"; 12 - import { useEffect } from "react"; 13 - import { deleteBlock } from "./DeleteBlock"; 12 + import { createContext, useContext, useEffect, useState } from "react"; 14 13 import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 14 + import { Popover } from "components/Popover"; 15 + import { ImageAltSmall } from "components/Toolbar/ImageToolbar"; 16 + import { theme } from "tailwind.config"; 17 + import { EditTiny } from "components/Icons/EditTiny"; 18 + import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 19 + import { set } from "colorjs.io/fn"; 15 20 16 21 export function ImageBlock(props: BlockProps & { preview?: boolean }) { 17 22 let { rep } = useReplicache(); ··· 24 29 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 25 30 let isFirst = props.previousBlock === null; 26 31 let isLast = props.nextBlock === null; 32 + 33 + let altText = useEntity(props.value, "image/alt")?.data.value; 27 34 28 35 let nextIsFullBleed = useEntity( 29 36 props.nextBlock && props.nextBlock.value, ··· 125 132 <img 126 133 loading="lazy" 127 134 decoding="async" 128 - alt={""} 135 + alt={altText} 129 136 src={isLocalUpload ? image.data.src + "?local" : image.data.fallback} 130 137 height={image?.data.height} 131 138 width={image?.data.width} 132 139 /> 133 140 ) : ( 134 141 <Image 135 - alt="" 142 + alt={altText || ""} 136 143 src={new URL(image.data.src).pathname.split("/").slice(5).join("/")} 137 144 height={image?.data.height} 138 145 width={image?.data.width} 139 146 className={className} 140 147 /> 141 148 )} 149 + {altText !== undefined ? <ImageAlt entityID={props.value} /> : null} 142 150 </div> 143 151 ); 144 152 } ··· 150 158 /> 151 159 ); 152 160 }; 161 + 162 + export const ImageBlockContext = createContext({ 163 + altEditorOpen: false, 164 + setAltEditorOpen: (s: boolean) => {}, 165 + }); 166 + 167 + const ImageAlt = (props: { entityID: string }) => { 168 + let { rep } = useReplicache(); 169 + let altText = useEntity(props.entityID, "image/alt")?.data.value; 170 + let entity_set = useEntitySetContext(); 171 + 172 + let setAltEditorOpen = useUIState((s) => s.setOpenPopover); 173 + let altEditorOpen = useUIState((s) => s.openPopover === props.entityID); 174 + 175 + if (!entity_set.permissions.write && altText === "") return null; 176 + return ( 177 + <div className="absolute bottom-0 right-2 h-max"> 178 + <Popover 179 + open={altEditorOpen} 180 + onOpenChange={(o) => setAltEditorOpen(o ? props.entityID : null)} 181 + className="text-sm max-w-xs min-w-0" 182 + side="left" 183 + trigger={<ImageAltSmall fillColor={theme.colors["bg-page"]} />} 184 + > 185 + {entity_set.permissions.write ? ( 186 + <AsyncValueAutosizeTextarea 187 + className="text-sm text-secondary outline-none bg-transparent min-w-0" 188 + value={altText} 189 + onFocus={(e) => { 190 + e.currentTarget.setSelectionRange( 191 + e.currentTarget.value.length, 192 + e.currentTarget.value.length, 193 + ); 194 + }} 195 + onChange={async (e) => { 196 + await rep?.mutate.assertFact({ 197 + entity: props.entityID, 198 + attribute: "image/alt", 199 + data: { type: "string", value: e.currentTarget.value }, 200 + }); 201 + }} 202 + placeholder="add alt text..." 203 + /> 204 + ) : ( 205 + <div className="text-sm text-secondary w-max"> {altText}</div> 206 + )} 207 + </Popover> 208 + </div> 209 + ); 210 + };
+2
components/Popover.tsx
··· 10 10 disabled?: boolean; 11 11 children: React.ReactNode; 12 12 align?: "start" | "end" | "center"; 13 + side?: "top" | "bottom" | "left" | "right"; 13 14 background?: string; 14 15 border?: string; 15 16 className?: string; ··· 43 44 overflow-y-scroll no-scrollbar 44 45 ${props.className} 45 46 `} 47 + side={props.side} 46 48 align={props.align ? props.align : "center"} 47 49 sideOffset={4} 48 50 collisionPadding={16}
components/Toolbar/BlockToolbar.1.tsx

This is a binary file and will not be displayed.

+5 -2
components/Toolbar/BlockToolbar.tsx
··· 6 6 import { useUIState } from "src/useUIState"; 7 7 import { LockBlockButton } from "./LockBlockButton"; 8 8 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 9 - import { ImageFullBleedButton } from "./ImageToolbar"; 9 + import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; 10 10 import { DeleteSmall } from "components/Icons/DeleteSmall"; 11 11 12 12 export const BlockToolbar = (props: { 13 - setToolbarState: (state: "areYouSure" | "block" | "text-alignment") => void; 13 + setToolbarState: ( 14 + state: "areYouSure" | "block" | "text-alignment" | "img-alt-text", 15 + ) => void; 14 16 }) => { 15 17 let focusedEntity = useUIState((s) => s.focusedEntity); 16 18 let focusedEntityType = useEntity( ··· 41 43 <> 42 44 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 43 45 <ImageFullBleedButton /> 46 + <ImageAltTextButton setToolbarState={props.setToolbarState} /> 44 47 {focusedEntityType?.data.value !== "canvas" && ( 45 48 <Separator classname="h-6" /> 46 49 )}
+90
components/Toolbar/ImageToolbar.tsx
··· 2 2 import { useEntity, useReplicache } from "src/replicache"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { Props } from "components/Icons/Props"; 5 + import { useContext, useEffect } from "react"; 6 + import { ImageBlockContext } from "components/Blocks/ImageBlock"; 7 + import { set } from "colorjs.io/fn"; 5 8 6 9 export const ImageFullBleedButton = (props: {}) => { 7 10 let { rep } = useReplicache(); ··· 29 32 ); 30 33 }; 31 34 35 + export const ImageAltTextButton = (props: { 36 + setToolbarState: (s: "img-alt-text") => void; 37 + }) => { 38 + let { rep } = useReplicache(); 39 + let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null; 40 + 41 + let altText = useEntity(focusedBlock, "image/alt")?.data.value; 42 + 43 + let setAltEditorOpen = useUIState((s) => s.setOpenPopover); 44 + let altEditorOpen = useUIState((s) => s.openPopover === focusedBlock); 45 + 46 + return ( 47 + <ToolbarButton 48 + active={altText !== undefined} 49 + onClick={async (e) => { 50 + e.preventDefault(); 51 + if (!focusedBlock) return; 52 + if (!altText) { 53 + await rep?.mutate.assertFact({ 54 + entity: focusedBlock, 55 + attribute: "image/alt", 56 + data: { type: "string", value: "" }, 57 + }); 58 + setAltEditorOpen(focusedBlock); 59 + } else { 60 + await rep?.mutate.retractAttribute({ 61 + entity: focusedBlock, 62 + attribute: "image/alt", 63 + }); 64 + setAltEditorOpen(null); 65 + } 66 + }} 67 + tooltipContent={ 68 + <div>{altText === undefined ? "Add " : "Remove "}Alt Text</div> 69 + } 70 + > 71 + {altText === undefined ? ( 72 + <ImageAltSmall fillColor="transparent" /> 73 + ) : ( 74 + <ImageRemoveAltSmall /> 75 + )} 76 + </ToolbarButton> 77 + ); 78 + }; 79 + 32 80 const ImageFullBleedOffSmall = (props: Props) => { 33 81 return ( 34 82 <svg ··· 67 115 </svg> 68 116 ); 69 117 }; 118 + 119 + export const ImageAltSmall = (props: { fillColor: string }) => { 120 + return ( 121 + <svg 122 + width="24" 123 + height="24" 124 + viewBox="0 0 24 24" 125 + fill="none" 126 + xmlns="http://www.w3.org/2000/svg" 127 + > 128 + <rect 129 + x="1.07886" 130 + y="5.237" 131 + width="21.8423" 132 + height="13.4703" 133 + rx="2.5" 134 + fill={props.fillColor} 135 + /> 136 + <path 137 + d="M21.0718 5C22.3323 5.12817 23.3159 6.19299 23.3159 7.4873V16.4443L23.3032 16.7002C23.1837 17.8766 22.2482 18.812 21.0718 18.9316L20.8159 18.9443H3.89014L2.92822 18.9316C1.752 18.8118 0.816287 17.8765 0.696777 16.7002L0.684082 7.4873C0.684082 6.19312 1.66793 5.12835 2.92822 5H21.0718ZM3.18408 6.2373C2.49395 6.23756 1.93408 6.7971 1.93408 7.4873V16.4443C1.93408 17.1345 2.49395 17.6941 3.18408 17.6943H20.8159C21.5062 17.6943 22.0659 17.1347 22.0659 16.4443V7.4873C22.0659 6.79698 21.5062 6.23736 20.8159 6.2373H3.18408ZM6.7876 8.3291C7.10561 8.32913 7.38885 8.53018 7.49463 8.83008L9.57275 14.7236C9.7236 15.1525 9.40533 15.6023 8.95068 15.6025C8.66735 15.6025 8.41495 15.4209 8.32471 15.1523L7.87354 13.8086H5.13916L4.68896 15.1523C4.5988 15.4211 4.34649 15.6025 4.06299 15.6025C3.6083 15.6023 3.29009 15.1526 3.44092 14.7236L5.51514 8.83008C5.62084 8.53004 5.90502 8.32913 6.22314 8.3291H6.7876ZM11.3452 8.3291C11.7089 8.32928 12.0034 8.62458 12.0034 8.98828V14.1777C12.0035 14.2329 12.0489 14.2773 12.104 14.2773H14.5444C14.9101 14.2774 15.2063 14.5739 15.2065 14.9395C15.2065 15.3052 14.9102 15.6025 14.5444 15.6025H11.686C11.1339 15.6025 10.6862 15.1546 10.686 14.6025V8.98828C10.686 8.62449 10.9814 8.32912 11.3452 8.3291ZM19.7026 8.3291C20.0076 8.3291 20.2554 8.57686 20.2554 8.88184C20.2553 9.18673 20.0076 9.43359 19.7026 9.43359H18.1079C18.0527 9.4336 18.0073 9.47895 18.0073 9.53418V14.9492C18.0071 15.3099 17.7147 15.6025 17.354 15.6025C16.9933 15.6025 16.7009 15.3099 16.7007 14.9492V9.53418C16.7007 9.4791 16.6561 9.43384 16.6011 9.43359H15.0054C14.7005 9.43358 14.4527 9.18672 14.4526 8.88184C14.4526 8.57687 14.7004 8.32911 15.0054 8.3291H19.7026ZM6.50537 9.82129C6.48845 9.82132 6.4727 9.83163 6.46729 9.84766L5.49365 12.751H7.51807L6.54248 9.84766C6.53703 9.83177 6.52217 9.82138 6.50537 9.82129Z" 138 + fill="currentColor" 139 + /> 140 + </svg> 141 + ); 142 + }; 143 + 144 + const ImageRemoveAltSmall = () => { 145 + return ( 146 + <svg 147 + width="24" 148 + height="24" 149 + viewBox="0 0 24 24" 150 + fill="none" 151 + xmlns="http://www.w3.org/2000/svg" 152 + > 153 + <path 154 + d="M19.7529 3.18659C20.0459 2.89419 20.5207 2.89386 20.8135 3.18659C21.1061 3.47932 21.1058 3.95423 20.8135 4.24713L4.24805 20.8126C3.95517 21.1054 3.48038 21.1053 3.1875 20.8126C2.89463 20.5197 2.89467 20.0449 3.1875 19.752L19.7529 3.18659ZM21.6631 5.1651C22.6263 5.51251 23.3164 6.43272 23.3164 7.51568V16.4727L23.3037 16.7286C23.184 17.9046 22.2483 18.8402 21.0723 18.96L20.8164 18.9727H7.85645L9.10645 17.7227H20.8164C21.5063 17.7224 22.0661 17.1626 22.0664 16.4727V7.51568C22.0664 6.82552 21.5065 6.266 20.8164 6.26569H20.5635L21.6631 5.1651ZM14.9072 6.26569H3.18457C2.49444 6.26594 1.93457 6.82548 1.93457 7.51568V16.4727C1.93488 17.1626 2.49463 17.7225 3.18457 17.7227H3.4502L2.3457 18.8262C1.45485 18.5088 0.796417 17.7021 0.697266 16.7286L0.683594 7.51568C0.683594 6.2215 1.66842 5.15576 2.92871 5.0274H16.1455L14.9072 6.26569ZM6.78809 8.35748C7.1059 8.35772 7.38939 8.55871 7.49512 8.85846L8.75098 12.421L7.33594 13.837H5.13965L4.68945 15.1807C4.59924 15.4491 4.34665 15.6307 4.06348 15.6309C3.60892 15.6307 3.29088 15.1809 3.44141 14.752L5.51562 8.85846C5.62133 8.55842 5.90551 8.35751 6.22363 8.35748H6.78809ZM14.5449 14.3057C14.9103 14.306 15.2068 14.6024 15.207 14.9678C15.2067 15.3332 14.9103 15.6306 14.5449 15.6309H11.6865C11.5431 15.6309 11.4065 15.5994 11.2832 15.545L12.5234 14.3057H14.5449ZM19.7031 8.35748C20.0079 8.35775 20.2559 8.60541 20.2559 8.91022C20.2555 9.21468 20.0076 9.46171 19.7031 9.46197H18.1084C18.0532 9.46198 18.0078 9.50733 18.0078 9.56256V14.9776C18.0073 15.3378 17.7148 15.6307 17.3545 15.6309C16.994 15.6309 16.7017 15.338 16.7012 14.9776V10.128L18.4717 8.35748H19.7031ZM6.50586 9.84967C6.48894 9.84969 6.47319 9.86 6.46777 9.87603L5.49414 12.7794H7.51855L6.54297 9.87603C6.53757 9.86031 6.52245 9.84996 6.50586 9.84967ZM11.3457 8.35748C11.7091 8.35793 12.0039 8.65313 12.0039 9.01666V9.169L10.6865 10.4864V9.01666C10.6865 8.65286 10.9819 8.3575 11.3457 8.35748Z" 155 + fill="currentColor" 156 + /> 157 + </svg> 158 + ); 159 + };
+4
src/replicache/attributes.ts
··· 153 153 type: "boolean", 154 154 cardinality: "one", 155 155 }, 156 + "image/alt": { 157 + type: "string", 158 + cardinality: "one", 159 + }, 156 160 } as const; 157 161 158 162 const PollBlockAttributes = {
+4
src/useUIState.ts
··· 14 14 foldedBlocks: [] as string[], 15 15 openPages: [] as string[], 16 16 selectedBlocks: [] as SelectedBlock[], 17 + openPopover: null as string | null, 17 18 }, 18 19 (set) => ({ 20 + setOpenPopover: (id: string | null) => { 21 + set({ openPopover: id }); 22 + }, 19 23 toggleFold: (entityID: string) => { 20 24 set((state) => { 21 25 return {