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