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