"use client";
import { Fact, useEntity, useReplicache } from "src/replicache";
import { memo, useEffect, useState } from "react";
import { useUIState } from "src/useUIState";
import { useBlockMouseHandlers } from "./useBlockMouseHandlers";
import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers";
import { useLongPress } from "src/hooks/useLongPress";
import { focusBlock } from "src/utils/focusBlock";
import { useHandleDrop } from "./useHandleDrop";
import { useEntitySetContext } from "components/EntitySetProvider";
import { TextBlock } from "components/Blocks/TextBlock";
import { ImageBlock } from "./ImageBlock";
import { PageLinkBlock } from "./PageLinkBlock";
import { ExternalLinkBlock } from "./ExternalLinkBlock";
import { EmbedBlock } from "./EmbedBlock";
import { MailboxBlock } from "./MailboxBlock";
import { AreYouSure } from "./DeleteBlock";
import { useIsMobile } from "src/hooks/isMobile";
import { DateTimeBlock } from "./DateTimeBlock";
import { RSVPBlock } from "./RSVPBlock";
import { elementId } from "src/utils/elementId";
import { ButtonBlock } from "./ButtonBlock";
import { PollBlock } from "./PollBlock";
import { BlueskyPostBlock } from "./BlueskyPostBlock";
import { CheckboxChecked } from "components/Icons/CheckboxChecked";
import { CheckboxEmpty } from "components/Icons/CheckboxEmpty";
import { LockTiny } from "components/Icons/LockTiny";
import { MathBlock } from "./MathBlock";
import { CodeBlock } from "./CodeBlock";
import { HorizontalRule } from "./HorizontalRule";
import { deepEquals } from "src/utils/deepEquals";
import { isTextBlock } from "src/utils/isTextBlock";
export type Block = {
factID: string;
parent: string;
position: string;
value: string;
type: Fact<"block/type">["data"]["value"];
listData?: {
checklist?: boolean;
path: { depth: number; entity: string }[];
parent: string;
depth: number;
};
};
export type BlockProps = {
pageType: Fact<"page/type">["data"]["value"];
entityID: string;
parent: string;
position: string;
nextBlock: Block | null;
previousBlock: Block | null;
nextPosition: string | null;
} & Block;
export const Block = memo(function Block(
props: BlockProps & { preview?: boolean },
) {
// Block handles all block level events like
// mouse events, keyboard events and longPress, and setting AreYouSure state
// and shared styling like padding and flex for list layouting
let mouseHandlers = useBlockMouseHandlers(props);
let handleDrop = useHandleDrop({
parent: props.parent,
position: props.position,
nextPosition: props.nextPosition,
});
let entity_set = useEntitySetContext();
let { isLongPress, handlers } = useLongPress(() => {
if (isTextBlock[props.type]) return;
if (isLongPress.current) {
focusBlock(
{ type: props.type, value: props.entityID, parent: props.parent },
{ type: "start" },
);
}
});
let selected = useUIState(
(s) => !!s.selectedBlocks.find((b) => b.value === props.entityID),
);
let [areYouSure, setAreYouSure] = useState(false);
useEffect(() => {
if (!selected) {
setAreYouSure(false);
}
}, [selected]);
// THIS IS WHERE YOU SET WHETHER OR NOT AREYOUSURE IS TRIGGERED ON THE DELETE KEY
useBlockKeyboardHandlers(props, areYouSure, setAreYouSure);
return (
{
e.preventDefault();
e.stopPropagation();
}
: undefined
}
onDrop={
!props.preview && entity_set.permissions.write ? handleDrop : undefined
}
className={`
blockWrapper relative
flex flex-row gap-2
px-3 sm:px-4
${
!props.nextBlock
? "pb-3 sm:pb-4"
: props.type === "heading" ||
(props.listData && props.nextBlock?.listData)
? "pb-0"
: "pb-2"
}
${props.type === "blockquote" && props.previousBlock?.type === "blockquote" ? (!props.listData ? "-mt-3" : "-mt-1") : ""}
${
!props.previousBlock
? props.type === "heading" || props.type === "text"
? "pt-2 sm:pt-3"
: "pt-3 sm:pt-4"
: "pt-1"
}`}
>
{!props.preview && }
);
}, deepEqualsBlockProps);
function deepEqualsBlockProps(
prevProps: BlockProps & { preview?: boolean },
nextProps: BlockProps & { preview?: boolean },
): boolean {
// Compare primitive fields
if (
prevProps.pageType !== nextProps.pageType ||
prevProps.entityID !== nextProps.entityID ||
prevProps.parent !== nextProps.parent ||
prevProps.position !== nextProps.position ||
prevProps.factID !== nextProps.factID ||
prevProps.value !== nextProps.value ||
prevProps.type !== nextProps.type ||
prevProps.nextPosition !== nextProps.nextPosition ||
prevProps.preview !== nextProps.preview
) {
return false;
}
// Compare listData if present
if (prevProps.listData !== nextProps.listData) {
if (!prevProps.listData || !nextProps.listData) {
return false; // One is undefined, the other isn't
}
if (
prevProps.listData.checklist !== nextProps.listData.checklist ||
prevProps.listData.parent !== nextProps.listData.parent ||
prevProps.listData.depth !== nextProps.listData.depth
) {
return false;
}
// Compare path array
if (prevProps.listData.path.length !== nextProps.listData.path.length) {
return false;
}
for (let i = 0; i < prevProps.listData.path.length; i++) {
if (
prevProps.listData.path[i].depth !== nextProps.listData.path[i].depth ||
prevProps.listData.path[i].entity !== nextProps.listData.path[i].entity
) {
return false;
}
}
}
// Compare nextBlock
if (prevProps.nextBlock !== nextProps.nextBlock) {
if (!prevProps.nextBlock || !nextProps.nextBlock) {
return false; // One is null, the other isn't
}
if (
prevProps.nextBlock.factID !== nextProps.nextBlock.factID ||
prevProps.nextBlock.parent !== nextProps.nextBlock.parent ||
prevProps.nextBlock.position !== nextProps.nextBlock.position ||
prevProps.nextBlock.value !== nextProps.nextBlock.value ||
prevProps.nextBlock.type !== nextProps.nextBlock.type
) {
return false;
}
// Compare nextBlock's listData (using deepEquals for simplicity)
if (
!deepEquals(prevProps.nextBlock.listData, nextProps.nextBlock.listData)
) {
return false;
}
}
// Compare previousBlock
if (prevProps.previousBlock !== nextProps.previousBlock) {
if (!prevProps.previousBlock || !nextProps.previousBlock) {
return false; // One is null, the other isn't
}
if (
prevProps.previousBlock.factID !== nextProps.previousBlock.factID ||
prevProps.previousBlock.parent !== nextProps.previousBlock.parent ||
prevProps.previousBlock.position !== nextProps.previousBlock.position ||
prevProps.previousBlock.value !== nextProps.previousBlock.value ||
prevProps.previousBlock.type !== nextProps.previousBlock.type
) {
return false;
}
// Compare previousBlock's listData (using deepEquals for simplicity)
if (
!deepEquals(
prevProps.previousBlock.listData,
nextProps.previousBlock.listData,
)
) {
return false;
}
}
return true;
}
export const BaseBlock = (
props: BlockProps & {
preview?: boolean;
areYouSure?: boolean;
setAreYouSure?: (value: boolean) => void;
},
) => {
// BaseBlock renders the actual block content, delete states, controls spacing between block and list markers
let BlockTypeComponent = BlockTypeComponents[props.type];
let alignment = useEntity(props.value, "block/text-alignment")?.data.value;
let alignmentStyle =
props.type === "button" || props.type === "image"
? "justify-center"
: "justify-start";
if (alignment)
alignmentStyle = {
left: "justify-start",
right: "justify-end",
center: "justify-center",
justify: "justify-start",
}[alignment];
if (!BlockTypeComponent) return unknown block
;
return (
{props.listData &&
}
{props.areYouSure ? (
props.setAreYouSure && props.setAreYouSure(false)
}
type={props.type}
entityID={props.entityID}
/>
) : (
)}
);
};
const BlockTypeComponents: {
[K in Fact<"block/type">["data"]["value"]]: React.ComponentType<
BlockProps & { preview?: boolean }
>;
} = {
code: CodeBlock,
math: MathBlock,
card: PageLinkBlock,
text: TextBlock,
blockquote: TextBlock,
heading: TextBlock,
image: ImageBlock,
link: ExternalLinkBlock,
embed: EmbedBlock,
mailbox: MailboxBlock,
datetime: DateTimeBlock,
rsvp: RSVPBlock,
button: ButtonBlock,
poll: PollBlock,
"bluesky-post": BlueskyPostBlock,
"horizontal-rule": HorizontalRule,
};
export const BlockMultiselectIndicator = (props: BlockProps) => {
let { rep } = useReplicache();
let isMobile = useIsMobile();
let first = props.previousBlock === null;
let isMultiselected = useUIState(
(s) =>
!!s.selectedBlocks.find((b) => b.value === props.entityID) &&
s.selectedBlocks.length > 1,
);
let isSelected = useUIState((s) =>
s.selectedBlocks.find((b) => b.value === props.entityID),
);
let isLocked = useEntity(props.value, "block/is-locked");
let nextBlockSelected = useUIState((s) =>
s.selectedBlocks.find((b) => b.value === props.nextBlock?.value),
);
let prevBlockSelected = useUIState((s) =>
s.selectedBlocks.find((b) => b.value === props.previousBlock?.value),
);
if (isMultiselected || (isLocked?.data.value && isSelected))
// not sure what multiselected and selected classes are doing (?)
// use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected
return (
<>
{isLocked?.data.value && (
)}
>
);
};
export const ListMarker = (
props: Block & {
previousBlock?: Block | null;
nextBlock?: Block | null;
} & {
className?: string;
},
) => {
let isMobile = useIsMobile();
let checklist = useEntity(props.value, "block/check-list");
let headingLevel = useEntity(props.value, "block/heading-level")?.data.value;
let children = useEntity(props.value, "card/block");
let folded =
useUIState((s) => s.foldedBlocks.includes(props.value)) &&
children.length > 0;
let depth = props.listData?.depth;
let { permissions } = useEntitySetContext();
let { rep } = useReplicache();
return (