a tool for shared writing and social publishing
1"use client";
2
3import { Fact, useEntity, useReplicache } from "src/replicache";
4import { memo, useEffect, useState } from "react";
5import { useUIState } from "src/useUIState";
6import { useBlockMouseHandlers } from "./useBlockMouseHandlers";
7import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers";
8import { useLongPress } from "src/hooks/useLongPress";
9import { focusBlock } from "src/utils/focusBlock";
10
11import { TextBlock } from "components/Blocks/TextBlock";
12import { ImageBlock } from "./ImageBlock";
13import { PageLinkBlock } from "./PageLinkBlock";
14import { ExternalLinkBlock } from "./ExternalLinkBlock";
15import { EmbedBlock } from "./EmbedBlock";
16import { MailboxBlock } from "./MailboxBlock";
17import { AreYouSure } from "./DeleteBlock";
18import { useEntitySetContext } from "components/EntitySetProvider";
19import { useIsMobile } from "src/hooks/isMobile";
20import { DateTimeBlock } from "./DateTimeBlock";
21import { RSVPBlock } from "./RSVPBlock";
22import { elementId } from "src/utils/elementId";
23import { ButtonBlock } from "./ButtonBlock";
24import { PollBlock } from "./PollBlock";
25import { BlueskyPostBlock } from "./BlueskyPostBlock";
26import { CheckboxChecked } from "components/Icons/CheckboxChecked";
27import { CheckboxEmpty } from "components/Icons/CheckboxEmpty";
28import { LockTiny } from "components/Icons/LockTiny";
29import { MathBlock } from "./MathBlock";
30import { CodeBlock } from "./CodeBlock";
31import { HorizontalRule } from "./HorizontalRule";
32import { deepEquals } from "src/utils/deepEquals";
33import { isTextBlock } from "src/utils/isTextBlock";
34
35export type Block = {
36 factID: string;
37 parent: string;
38 position: string;
39 value: string;
40 type: Fact<"block/type">["data"]["value"];
41 listData?: {
42 checklist?: boolean;
43 path: { depth: number; entity: string }[];
44 parent: string;
45 depth: number;
46 };
47};
48export type BlockProps = {
49 pageType: Fact<"page/type">["data"]["value"];
50 entityID: string;
51 parent: string;
52 position: string;
53 nextBlock: Block | null;
54 previousBlock: Block | null;
55 nextPosition: string | null;
56} & Block;
57
58export const Block = memo(function Block(
59 props: BlockProps & { preview?: boolean },
60) {
61 // Block handles all block level events like
62 // mouse events, keyboard events and longPress, and setting AreYouSure state
63 // and shared styling like padding and flex for list layouting
64
65 let mouseHandlers = useBlockMouseHandlers(props);
66
67 let { isLongPress, handlers } = useLongPress(() => {
68 if (isTextBlock[props.type]) return;
69 if (isLongPress.current) {
70 focusBlock(
71 { type: props.type, value: props.entityID, parent: props.parent },
72 { type: "start" },
73 );
74 }
75 });
76
77 let selected = useUIState(
78 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID),
79 );
80
81 let [areYouSure, setAreYouSure] = useState(false);
82 useEffect(() => {
83 if (!selected) {
84 setAreYouSure(false);
85 }
86 }, [selected]);
87
88 // THIS IS WHERE YOU SET WHETHER OR NOT AREYOUSURE IS TRIGGERED ON THE DELETE KEY
89 useBlockKeyboardHandlers(props, areYouSure, setAreYouSure);
90
91 return (
92 <div
93 {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})}
94 id={
95 !props.preview ? elementId.block(props.entityID).container : undefined
96 }
97 className={`
98 blockWrapper relative
99 flex flex-row gap-2
100 px-3 sm:px-4
101 ${
102 !props.nextBlock
103 ? "pb-3 sm:pb-4"
104 : props.type === "heading" ||
105 (props.listData && props.nextBlock?.listData)
106 ? "pb-0"
107 : "pb-2"
108 }
109 ${props.type === "blockquote" && props.previousBlock?.type === "blockquote" ? (!props.listData ? "-mt-3" : "-mt-1") : ""}
110 ${
111 !props.previousBlock
112 ? props.type === "heading" || props.type === "text"
113 ? "pt-2 sm:pt-3"
114 : "pt-3 sm:pt-4"
115 : "pt-1"
116 }`}
117 >
118 {!props.preview && <BlockMultiselectIndicator {...props} />}
119 <BaseBlock
120 {...props}
121 areYouSure={areYouSure}
122 setAreYouSure={setAreYouSure}
123 />
124 </div>
125 );
126}, deepEqualsBlockProps);
127
128function deepEqualsBlockProps(
129 prevProps: BlockProps & { preview?: boolean },
130 nextProps: BlockProps & { preview?: boolean },
131): boolean {
132 // Compare primitive fields
133 if (
134 prevProps.pageType !== nextProps.pageType ||
135 prevProps.entityID !== nextProps.entityID ||
136 prevProps.parent !== nextProps.parent ||
137 prevProps.position !== nextProps.position ||
138 prevProps.factID !== nextProps.factID ||
139 prevProps.value !== nextProps.value ||
140 prevProps.type !== nextProps.type ||
141 prevProps.nextPosition !== nextProps.nextPosition ||
142 prevProps.preview !== nextProps.preview
143 ) {
144 return false;
145 }
146
147 // Compare listData if present
148 if (prevProps.listData !== nextProps.listData) {
149 if (!prevProps.listData || !nextProps.listData) {
150 return false; // One is undefined, the other isn't
151 }
152
153 if (
154 prevProps.listData.checklist !== nextProps.listData.checklist ||
155 prevProps.listData.parent !== nextProps.listData.parent ||
156 prevProps.listData.depth !== nextProps.listData.depth
157 ) {
158 return false;
159 }
160
161 // Compare path array
162 if (prevProps.listData.path.length !== nextProps.listData.path.length) {
163 return false;
164 }
165
166 for (let i = 0; i < prevProps.listData.path.length; i++) {
167 if (
168 prevProps.listData.path[i].depth !== nextProps.listData.path[i].depth ||
169 prevProps.listData.path[i].entity !== nextProps.listData.path[i].entity
170 ) {
171 return false;
172 }
173 }
174 }
175
176 // Compare nextBlock
177 if (prevProps.nextBlock !== nextProps.nextBlock) {
178 if (!prevProps.nextBlock || !nextProps.nextBlock) {
179 return false; // One is null, the other isn't
180 }
181
182 if (
183 prevProps.nextBlock.factID !== nextProps.nextBlock.factID ||
184 prevProps.nextBlock.parent !== nextProps.nextBlock.parent ||
185 prevProps.nextBlock.position !== nextProps.nextBlock.position ||
186 prevProps.nextBlock.value !== nextProps.nextBlock.value ||
187 prevProps.nextBlock.type !== nextProps.nextBlock.type
188 ) {
189 return false;
190 }
191
192 // Compare nextBlock's listData (using deepEquals for simplicity)
193 if (
194 !deepEquals(prevProps.nextBlock.listData, nextProps.nextBlock.listData)
195 ) {
196 return false;
197 }
198 }
199
200 // Compare previousBlock
201 if (prevProps.previousBlock !== nextProps.previousBlock) {
202 if (!prevProps.previousBlock || !nextProps.previousBlock) {
203 return false; // One is null, the other isn't
204 }
205
206 if (
207 prevProps.previousBlock.factID !== nextProps.previousBlock.factID ||
208 prevProps.previousBlock.parent !== nextProps.previousBlock.parent ||
209 prevProps.previousBlock.position !== nextProps.previousBlock.position ||
210 prevProps.previousBlock.value !== nextProps.previousBlock.value ||
211 prevProps.previousBlock.type !== nextProps.previousBlock.type
212 ) {
213 return false;
214 }
215
216 // Compare previousBlock's listData (using deepEquals for simplicity)
217 if (
218 !deepEquals(
219 prevProps.previousBlock.listData,
220 nextProps.previousBlock.listData,
221 )
222 ) {
223 return false;
224 }
225 }
226
227 return true;
228}
229
230export const BaseBlock = (
231 props: BlockProps & {
232 preview?: boolean;
233 areYouSure?: boolean;
234 setAreYouSure?: (value: boolean) => void;
235 },
236) => {
237 // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers
238 let BlockTypeComponent = BlockTypeComponents[props.type];
239 let alignment = useEntity(props.value, "block/text-alignment")?.data.value;
240
241 let alignmentStyle =
242 props.type === "button" || props.type === "image"
243 ? "justify-center"
244 : "justify-start";
245
246 if (alignment)
247 alignmentStyle = {
248 left: "justify-start",
249 right: "justify-end",
250 center: "justify-center",
251 justify: "justify-start",
252 }[alignment];
253
254 if (!BlockTypeComponent) return <div>unknown block</div>;
255 return (
256 <div
257 className={`blockContentWrapper w-full grow flex gap-2 z-1 ${alignmentStyle}`}
258 >
259 {props.listData && <ListMarker {...props} />}
260 {props.areYouSure ? (
261 <AreYouSure
262 closeAreYouSure={() =>
263 props.setAreYouSure && props.setAreYouSure(false)
264 }
265 type={props.type}
266 entityID={props.entityID}
267 />
268 ) : (
269 <BlockTypeComponent {...props} preview={props.preview} />
270 )}
271 </div>
272 );
273};
274
275const BlockTypeComponents: {
276 [K in Fact<"block/type">["data"]["value"]]: React.ComponentType<
277 BlockProps & { preview?: boolean }
278 >;
279} = {
280 code: CodeBlock,
281 math: MathBlock,
282 card: PageLinkBlock,
283 text: TextBlock,
284 blockquote: TextBlock,
285 heading: TextBlock,
286 image: ImageBlock,
287 link: ExternalLinkBlock,
288 embed: EmbedBlock,
289 mailbox: MailboxBlock,
290 datetime: DateTimeBlock,
291 rsvp: RSVPBlock,
292 button: ButtonBlock,
293 poll: PollBlock,
294 "bluesky-post": BlueskyPostBlock,
295 "horizontal-rule": HorizontalRule,
296};
297
298export const BlockMultiselectIndicator = (props: BlockProps) => {
299 let { rep } = useReplicache();
300 let isMobile = useIsMobile();
301
302 let first = props.previousBlock === null;
303
304 let isMultiselected = useUIState(
305 (s) =>
306 !!s.selectedBlocks.find((b) => b.value === props.entityID) &&
307 s.selectedBlocks.length > 1,
308 );
309
310 let isSelected = useUIState((s) =>
311 s.selectedBlocks.find((b) => b.value === props.entityID),
312 );
313 let isLocked = useEntity(props.value, "block/is-locked");
314
315 let nextBlockSelected = useUIState((s) =>
316 s.selectedBlocks.find((b) => b.value === props.nextBlock?.value),
317 );
318 let prevBlockSelected = useUIState((s) =>
319 s.selectedBlocks.find((b) => b.value === props.previousBlock?.value),
320 );
321
322 if (isMultiselected || (isLocked?.data.value && isSelected))
323 // not sure what multiselected and selected classes are doing (?)
324 // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected
325
326 return (
327 <>
328 <div
329 className={`
330 blockSelectionBG multiselected selected
331 pointer-events-none
332 bg-border-light
333 absolute right-2 left-2 bottom-0
334 ${first ? "top-2" : "top-0"}
335 ${!prevBlockSelected && "rounded-t-md"}
336 ${!nextBlockSelected && "rounded-b-md"}
337 `}
338 style={
339 isLocked?.data.value
340 ? {
341 maskImage: "var(--hatchSVG)",
342 maskRepeat: "repeat repeat",
343 }
344 : {}
345 }
346 ></div>
347 {isLocked?.data.value && (
348 <div
349 className={`
350 blockSelectionLockIndicator z-10
351 flex items-center
352 text-border rounded-full
353 absolute right-3
354
355 ${
356 props.type === "heading" || props.type === "text"
357 ? "top-[6px]"
358 : "top-0"
359 }`}
360 >
361 <LockTiny className="bg-bg-page p-0.5 rounded-full w-5 h-5" />
362 </div>
363 )}
364 </>
365 );
366};
367
368export const ListMarker = (
369 props: Block & {
370 previousBlock?: Block | null;
371 nextBlock?: Block | null;
372 } & {
373 className?: string;
374 },
375) => {
376 let isMobile = useIsMobile();
377 let checklist = useEntity(props.value, "block/check-list");
378 let headingLevel = useEntity(props.value, "block/heading-level")?.data.value;
379 let children = useEntity(props.value, "card/block");
380 let folded =
381 useUIState((s) => s.foldedBlocks.includes(props.value)) &&
382 children.length > 0;
383
384 let depth = props.listData?.depth;
385 let { permissions } = useEntitySetContext();
386 let { rep } = useReplicache();
387 return (
388 <div
389 className={`shrink-0 flex justify-end items-center h-3 z-1
390 ${props.className}
391 ${
392 props.type === "heading"
393 ? headingLevel === 3
394 ? "pt-[12px]"
395 : headingLevel === 2
396 ? "pt-[15px]"
397 : "pt-[20px]"
398 : "pt-[12px]"
399 }
400 `}
401 style={{
402 width:
403 depth &&
404 `calc(${depth} * ${`var(--list-marker-width) ${checklist ? " + 20px" : ""} - 6px`} `,
405 }}
406 >
407 <button
408 onClick={() => {
409 if (children.length > 0)
410 useUIState.getState().toggleFold(props.value);
411 }}
412 className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`}
413 >
414 <div
415 className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-1 outline-offset-1
416 ${
417 folded
418 ? "outline-secondary"
419 : ` ${children.length > 0 ? "sm:group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}`
420 }`}
421 />
422 </button>
423 {checklist && (
424 <button
425 onClick={() => {
426 if (permissions.write)
427 rep?.mutate.assertFact({
428 entity: props.value,
429 attribute: "block/check-list",
430 data: { type: "boolean", value: !checklist.data.value },
431 });
432 }}
433 className={`pr-2 ${checklist?.data.value ? "text-accent-contrast" : "text-border"} ${permissions.write ? "cursor-default" : ""}`}
434 >
435 {checklist?.data.value ? <CheckboxChecked /> : <CheckboxEmpty />}
436 </button>
437 )}
438 </div>
439 );
440};