"use client"; import { Fact, useEntity, useReplicache } from "src/replicache"; import { memo, useEffect, useState } from "react"; import { useIsBlockSelected, 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 { indent, outdent } from "src/utils/list-operations"; import { useDrag } from "@use-gesture/react"; import { TextBlock } from "./TextBlock/index"; import { ImageBlock } from "./ImageBlock"; import { ImageGalleryBlock } from "./ImageGalleryBlock"; import { PageLinkBlock } from "./PageLinkBlock"; import { ExternalLinkBlock } from "./ExternalLinkBlock"; import { EmbedBlock, HTMLBlock } 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 { StandardSitePostBlock } from "./StandardSitePostBlock"; import { StandardSitePublicationBlock } from "./StandardSitePublicationBlock"; import { CheckboxChecked } from "components/Icons/CheckboxChecked"; import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; import { MathBlock } from "./MathBlock"; import { CodeBlock } from "./CodeBlock"; import { HorizontalRule } from "./HorizontalRule"; import { MembersOnlyDelimiterBlock } from "./MembersOnlyDelimiterBlock"; import { PostsListBlock } from "./PostsListBlock"; import { SubscribeBlock } from "./SubscribeBlock"; import { deepEquals } from "src/utils/deepEquals"; import { isTextBlock } from "src/utils/isTextBlock"; import { DeleteTiny } from "components/Icons/DeleteTiny"; import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; import { Separator } from "components/Layout"; import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; import { deleteBlock } from "src/utils/deleteBlock"; const SWIPE_THRESHOLD = 50; export type Block = { factID: string; parent: string; position: string; entityID: string; type: Fact<"block/type">["data"]["value"]; headingLevel?: number; // Ancestor heading entity IDs whose sections contain this block (outermost // first). The heading-folding analog of listData.path; see computeHeadingSections. headingPath?: string[]; listData?: { checklist?: boolean; checked?: boolean; listStyle?: "ordered" | "unordered"; listStart?: number; displayNumber?: number; path: { depth: number; entity: string }[]; parent: string; depth: number; }; }; export type BlockProps = { pageType: Fact<"page/type">["data"]["value"]; nextBlock: Block | null; previousBlock: Block | null; nextPosition: string | null; headingFoldable?: boolean; } & 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 isMobile = useIsMobile(); let { rep, undoManager } = useReplicache(); let { isLongPress, longPressHandlers } = useLongPress(() => { if (isTextBlock[props.type]) return; if (isLongPress.current) { focusBlock( { type: props.type, entityID: props.entityID, parent: props.parent }, { type: "start" }, ); } }); let selected = useIsBlockSelected(props.entityID); let focused = useUIState( (s) => s.focusedEntity?.entityType === "block" && s.focusedEntity.entityID === props.entityID, ); let alignment = useEntity(props.entityID, "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]; 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); const bindSwipe = useDrag( ({ last, movement: [mx], event }) => { if (!last) return; if (event && "pointerType" in event && event.pointerType !== "touch") return; if (!rep || !props.listData || !entity_set.permissions.write) return; if (Math.abs(mx) < SWIPE_THRESHOLD) return; event?.preventDefault(); let { foldedBlocks, toggleFold } = useUIState.getState(); if (mx > 0) { if (props.previousBlock) { indent( props, props.previousBlock, rep, { foldedBlocks, toggleFold, }, undoManager, ); } } else { outdent( props, props.previousBlock, rep, { foldedBlocks, toggleFold, }, undefined, undoManager, ); } }, { axis: "x", filterTaps: true, pointer: { touch: true }, // Only the focused block can be swipe-indented, so that horizontal // swipes anywhere else pan the page carousel. Focus first, then swipe. enabled: isMobile && !!props.listData && focused, }, ); return (
{ e.preventDefault(); e.stopPropagation(); } : undefined } onDrop={ !props.preview && entity_set.permissions.write ? handleDrop : undefined } className={` blockWrapper group/blockWrapper relative flex flex-row gap-2 px-3 sm:px-4 pt-1 z-1 w-full ${props.listData && focused ? "touch-pan-y" : ""} ${alignmentStyle} ${ !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" ? "mt-1 sm:mt-2" : "mt-2 sm:mt-3" : "" }`} > {!props.preview && }
); }, deepEqualsBlockProps); // Everything the Block render tree reads off its props. headingLevel / // headingPath / listData.checked / listData.listStart are deliberately not // compared: they're re-fetched via useEntity or unused by this subtree. function deepEqualsBlockProps( prevProps: BlockProps & { preview?: boolean }, nextProps: BlockProps & { preview?: boolean }, ): boolean { const scalarKeys = [ "pageType", "entityID", "parent", "position", "factID", "type", "nextPosition", "headingFoldable", "preview", ] as const; if (scalarKeys.some((k) => prevProps[k] !== nextProps[k])) return false; if (!listDataEquals(prevProps.listData, nextProps.listData)) return false; return ( neighborEquals(prevProps.nextBlock, nextProps.nextBlock) && neighborEquals(prevProps.previousBlock, nextProps.previousBlock) ); } function listDataEquals(prev: Block["listData"], next: Block["listData"]) { if (prev === next) return true; if (!prev || !next) return false; return ( prev.checklist === next.checklist && prev.parent === next.parent && prev.depth === next.depth && prev.displayNumber === next.displayNumber && prev.listStyle === next.listStyle && prev.path.length === next.path.length && prev.path.every( (p, i) => p.depth === next.path[i].depth && p.entity === next.path[i].entity, ) ); } function neighborEquals(prev: Block | null, next: Block | null) { if (prev === next) return true; if (!prev || !next) return false; return ( prev.factID === next.factID && prev.parent === next.parent && prev.position === next.position && prev.entityID === next.entityID && prev.type === next.type && deepEquals(prev.listData, next.listData) ); } 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]; if (!BlockTypeComponent) return
unknown block
; return ( <> {props.type === "heading" && props.headingFoldable && ( )} {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, "image-gallery": ImageGalleryBlock, link: ExternalLinkBlock, embed: EmbedBlock, html: HTMLBlock, mailbox: MailboxBlock, datetime: DateTimeBlock, rsvp: RSVPBlock, button: ButtonBlock, poll: PollBlock, "bluesky-post": BlueskyPostBlock, "standard-site-post": StandardSitePostBlock, "standard-site-publication": StandardSitePublicationBlock, "horizontal-rule": HorizontalRule, "members-only-delimiter": MembersOnlyDelimiterBlock, "posts-list": PostsListBlock, signup: SubscribeBlock, }; const BlockMultiselectIndicator = (props: BlockProps) => { let first = props.previousBlock === null; // One packed primitive selector instead of three array scans: null when not // multiselected, otherwise which neighbors are also selected (for corner // rounding). let multiselectState = useUIState((s) => { if ( s.selectedBlocks.length <= 1 || !s.selectedBlocks.some((b) => b.entityID === props.entityID) ) return null; let next = s.selectedBlocks.some((b) => b.entityID === props.nextBlock?.entityID); let prev = s.selectedBlocks.some( (b) => b.entityID === props.previousBlock?.entityID, ); return `${next ? "n" : ""}${prev ? "p" : ""}`; }); if (multiselectState !== null) return (
); }; export const BlockLayout = (props: { isSelected: boolean; children: React.ReactNode; className?: string; optionsClassName?: string; hasBackground?: "accent" | "page"; borderOnHover?: boolean; hasAlignment?: boolean; areYouSure?: boolean; setAreYouSure?: (value: boolean) => void; extraOptions?: React.ReactNode; }) => { // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete return (
{props.children}
{props.isSelected && ( )}
); }; let debounced: null | number = null; const NonTextBlockOptions = (props: { areYouSure?: boolean; setAreYouSure?: (value: boolean) => void; optionsClassName?: string; extraOptions?: React.ReactNode; }) => { let { rep, undoManager } = useReplicache(); let entity_set = useEntitySetContext(); let focusedEntity = useUIState((s) => s.focusedEntity); let focusedEntityType = useEntity( focusedEntity?.entityType === "page" ? focusedEntity.entityID : focusedEntity?.parent || null, "page/type", ); let isMultiselected = useUIState((s) => s.selectedBlocks.length > 1); if (focusedEntity?.entityType === "page") return; if (isMultiselected) return; if (!entity_set.permissions.write) return null; return (
{focusedEntityType?.data.value !== "canvas" && ( <> )} {props.extraOptions && ( <> {props.extraOptions}{" "} )}
); }; // Disclosure triangle for folding a heading's section. Lives in the left gutter // (absolutely positioned in the wrapper padding) so it never shifts the heading // text, and only shows on hover unless the heading is currently folded. const HeadingFoldButton = (props: { entityID: string }) => { let folded = useUIState((s) => s.foldedBlocks.includes(props.entityID)); let headingLevel = useEntity(props.entityID, "block/heading-level")?.data .value; let top = headingLevel === 1 ? "top-[20px]" : headingLevel === 2 ? "top-[14px]" : "top-[11px]"; return ( ); }; export const ListMarker = ( props: Block & { previousBlock?: Block | null; nextBlock?: Block | null; } & { className?: string; }, ) => { let checklist = useEntity(props.entityID, "block/check-list"); let listStyle = useEntity(props.entityID, "block/list-style"); let headingLevel = useEntity(props.entityID, "block/heading-level")?.data.value; let children = useEntity(props.entityID, "card/block"); let folded = useUIState((s) => s.foldedBlocks.includes(props.entityID)) && children.length > 0; let depth = props.listData?.depth; let { permissions } = useEntitySetContext(); let { rep } = useReplicache(); let [editingNumber, setEditingNumber] = useState(false); let [numberInputValue, setNumberInputValue] = useState(""); useEffect(() => { if (!editingNumber) { setNumberInputValue(""); } }, [editingNumber]); const handleNumberSave = async () => { if (!rep || !props.listData) return; const newNumber = parseInt(numberInputValue, 10); if (isNaN(newNumber) || newNumber < 1) { setEditingNumber(false); return; } const currentDisplay = props.listData.displayNumber || 1; if (newNumber === currentDisplay) { // Remove override if it matches the computed number await rep.mutate.retractAttribute({ entity: props.entityID, attribute: "block/list-number", }); } else { await rep.mutate.assertFact({ entity: props.entityID, attribute: "block/list-number", data: { type: "number", value: newNumber }, }); } setEditingNumber(false); }; return (
{checklist && ( )}
); };