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