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