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