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