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