From 15f508eb2f04a67451fd6dcec4c9728465dee288 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Mon, 30 Jun 2025 20:58:19 -0400 Subject: [PATCH] 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 --- actions/publishToPublication.ts | 2 + .../[publication]/[rkey]/PostContent.tsx | 34 +++++-- components/Blocks/ImageBlock.tsx | 68 ++++++++++++-- components/Popover.tsx | 2 + components/Toolbar/BlockToolbar.1.tsx | 0 components/Toolbar/BlockToolbar.tsx | 7 +- components/Toolbar/ImageToolbar.tsx | 90 +++++++++++++++++++ src/replicache/attributes.ts | 4 + src/useUIState.ts | 4 + 9 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 components/Toolbar/BlockToolbar.1.tsx diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index eb8e2041..c183e0d7 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -249,6 +249,7 @@ function blockToRecord( if (b.type == "image") { let [image] = scan.eav(b.value, "block/image"); if (!image) return; + let [altText] = scan.eav(b.value, "image/alt"); let blobref = imageMap.get(image.data.src); if (!blobref) return; let block: $Typed = { @@ -258,6 +259,7 @@ function blockToRecord( height: image.data.height, width: image.data.width, }, + alt: altText ? altText.data.value : undefined, }; return block; } diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 6ec4f0b3..3d9f8034 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -1,3 +1,4 @@ +"use client"; import { PubLeafletBlocksHeader, PubLeafletBlocksImage, @@ -9,6 +10,9 @@ import { } from "lexicons/api"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { TextBlock } from "./TextBlock"; +import { Popover } from "components/Popover"; +import { ImageAltSmall } from "components/Toolbar/ImageToolbar"; +import { theme } from "tailwind.config"; export function PostContent({ blocks, @@ -100,7 +104,7 @@ let Block = ({ {b.block.previewImage && (
+
+ {b.block.alt} + {b.block.alt && ( +
+ } + > +
+ {b.block.alt} +
+
+
+ )} +
); } case PubLeafletBlocksText.isMain(b.block): diff --git a/components/Blocks/ImageBlock.tsx b/components/Blocks/ImageBlock.tsx index 423e1d22..bd706017 100644 --- a/components/Blocks/ImageBlock.tsx +++ b/components/Blocks/ImageBlock.tsx @@ -1,7 +1,7 @@ "use client"; import { useEntity, useReplicache } from "src/replicache"; -import { Block, BlockProps } from "./Block"; +import { BlockProps } from "./Block"; import { useUIState } from "src/useUIState"; import Image from "next/image"; import { v7 } from "uuid"; @@ -9,9 +9,14 @@ import { useEntitySetContext } from "components/EntitySetProvider"; import { generateKeyBetween } from "fractional-indexing"; import { addImage, localImages } from "src/utils/addImage"; import { elementId } from "src/utils/elementId"; -import { useEffect } from "react"; -import { deleteBlock } from "./DeleteBlock"; +import { createContext, useContext, useEffect, useState } from "react"; import { BlockImageSmall } from "components/Icons/BlockImageSmall"; +import { Popover } from "components/Popover"; +import { ImageAltSmall } from "components/Toolbar/ImageToolbar"; +import { theme } from "tailwind.config"; +import { EditTiny } from "components/Icons/EditTiny"; +import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; +import { set } from "colorjs.io/fn"; export function ImageBlock(props: BlockProps & { preview?: boolean }) { let { rep } = useReplicache(); @@ -25,6 +30,8 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { let isFirst = props.previousBlock === null; let isLast = props.nextBlock === null; + let altText = useEntity(props.value, "image/alt")?.data.value; + let nextIsFullBleed = useEntity( props.nextBlock && props.nextBlock.value, "image/full-bleed", @@ -125,20 +132,21 @@ export function ImageBlock(props: BlockProps & { preview?: boolean }) { {""} ) : ( )} + {altText !== undefined ? : null}
); } @@ -150,3 +158,53 @@ export const FullBleedSelectionIndicator = () => { /> ); }; + +export const ImageBlockContext = createContext({ + altEditorOpen: false, + setAltEditorOpen: (s: boolean) => {}, +}); + +const ImageAlt = (props: { entityID: string }) => { + let { rep } = useReplicache(); + let altText = useEntity(props.entityID, "image/alt")?.data.value; + let entity_set = useEntitySetContext(); + + let setAltEditorOpen = useUIState((s) => s.setOpenPopover); + let altEditorOpen = useUIState((s) => s.openPopover === props.entityID); + + if (!entity_set.permissions.write && altText === "") return null; + return ( +
+ setAltEditorOpen(o ? props.entityID : null)} + className="text-sm max-w-xs min-w-0" + side="left" + trigger={} + > + {entity_set.permissions.write ? ( + { + e.currentTarget.setSelectionRange( + e.currentTarget.value.length, + e.currentTarget.value.length, + ); + }} + onChange={async (e) => { + await rep?.mutate.assertFact({ + entity: props.entityID, + attribute: "image/alt", + data: { type: "string", value: e.currentTarget.value }, + }); + }} + placeholder="add alt text..." + /> + ) : ( +
{altText}
+ )} +
+
+ ); +}; diff --git a/components/Popover.tsx b/components/Popover.tsx index e59dbb93..00907931 100644 --- a/components/Popover.tsx +++ b/components/Popover.tsx @@ -10,6 +10,7 @@ export const Popover = (props: { disabled?: boolean; children: React.ReactNode; align?: "start" | "end" | "center"; + side?: "top" | "bottom" | "left" | "right"; background?: string; border?: string; className?: string; @@ -43,6 +44,7 @@ export const Popover = (props: { overflow-y-scroll no-scrollbar ${props.className} `} + side={props.side} align={props.align ? props.align : "center"} sideOffset={4} collisionPadding={16} diff --git a/components/Toolbar/BlockToolbar.1.tsx b/components/Toolbar/BlockToolbar.1.tsx new file mode 100644 index 00000000..e69de29b diff --git a/components/Toolbar/BlockToolbar.tsx b/components/Toolbar/BlockToolbar.tsx index a7beb385..65be182f 100644 --- a/components/Toolbar/BlockToolbar.tsx +++ b/components/Toolbar/BlockToolbar.tsx @@ -6,11 +6,13 @@ import { getBlocksWithType } from "src/hooks/queries/useBlocks"; import { useUIState } from "src/useUIState"; import { LockBlockButton } from "./LockBlockButton"; import { TextAlignmentButton } from "./TextAlignmentToolbar"; -import { ImageFullBleedButton } from "./ImageToolbar"; +import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar"; import { DeleteSmall } from "components/Icons/DeleteSmall"; export const BlockToolbar = (props: { - setToolbarState: (state: "areYouSure" | "block" | "text-alignment") => void; + setToolbarState: ( + state: "areYouSure" | "block" | "text-alignment" | "img-alt-text", + ) => void; }) => { let focusedEntity = useUIState((s) => s.focusedEntity); let focusedEntityType = useEntity( @@ -41,6 +43,7 @@ export const BlockToolbar = (props: { <> + {focusedEntityType?.data.value !== "canvas" && ( )} diff --git a/components/Toolbar/ImageToolbar.tsx b/components/Toolbar/ImageToolbar.tsx index 946f2551..ad18cc05 100644 --- a/components/Toolbar/ImageToolbar.tsx +++ b/components/Toolbar/ImageToolbar.tsx @@ -2,6 +2,9 @@ import { ToolbarButton } from "."; import { useEntity, useReplicache } from "src/replicache"; import { useUIState } from "src/useUIState"; import { Props } from "components/Icons/Props"; +import { useContext, useEffect } from "react"; +import { ImageBlockContext } from "components/Blocks/ImageBlock"; +import { set } from "colorjs.io/fn"; export const ImageFullBleedButton = (props: {}) => { let { rep } = useReplicache(); @@ -29,6 +32,51 @@ export const ImageFullBleedButton = (props: {}) => { ); }; +export const ImageAltTextButton = (props: { + setToolbarState: (s: "img-alt-text") => void; +}) => { + let { rep } = useReplicache(); + let focusedBlock = useUIState((s) => s.focusedEntity)?.entityID || null; + + let altText = useEntity(focusedBlock, "image/alt")?.data.value; + + let setAltEditorOpen = useUIState((s) => s.setOpenPopover); + let altEditorOpen = useUIState((s) => s.openPopover === focusedBlock); + + return ( + { + e.preventDefault(); + if (!focusedBlock) return; + if (!altText) { + await rep?.mutate.assertFact({ + entity: focusedBlock, + attribute: "image/alt", + data: { type: "string", value: "" }, + }); + setAltEditorOpen(focusedBlock); + } else { + await rep?.mutate.retractAttribute({ + entity: focusedBlock, + attribute: "image/alt", + }); + setAltEditorOpen(null); + } + }} + tooltipContent={ +
{altText === undefined ? "Add " : "Remove "}Alt Text
+ } + > + {altText === undefined ? ( + + ) : ( + + )} +
+ ); +}; + const ImageFullBleedOffSmall = (props: Props) => { return ( { ); }; + +export const ImageAltSmall = (props: { fillColor: string }) => { + return ( + + + + + ); +}; + +const ImageRemoveAltSmall = () => { + return ( + + + + ); +}; diff --git a/src/replicache/attributes.ts b/src/replicache/attributes.ts index d0aa377f..a07ab232 100644 --- a/src/replicache/attributes.ts +++ b/src/replicache/attributes.ts @@ -153,6 +153,10 @@ const ImageBlockAttributes = { type: "boolean", cardinality: "one", }, + "image/alt": { + type: "string", + cardinality: "one", + }, } as const; const PollBlockAttributes = { diff --git a/src/useUIState.ts b/src/useUIState.ts index fd9c8dbb..a205c794 100644 --- a/src/useUIState.ts +++ b/src/useUIState.ts @@ -14,8 +14,12 @@ export const useUIState = create( foldedBlocks: [] as string[], openPages: [] as string[], selectedBlocks: [] as SelectedBlock[], + openPopover: null as string | null, }, (set) => ({ + setOpenPopover: (id: string | null) => { + set({ openPopover: id }); + }, toggleFold: (entityID: string) => { set((state) => { return { -- 2.51.2