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};