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