Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
21 kB · 673 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674"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 { indent, outdent } from "src/utils/list-operations";import { useDrag } from "@use-gesture/react";import { TextBlock } from "./TextBlock/index";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 { MathBlock } from "./MathBlock";import { CodeBlock } from "./CodeBlock";import { HorizontalRule } from "./HorizontalRule";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; value: string; type: Fact<"block/type">["data"]["value"]; 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"]; 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 isMobile = useIsMobile(); let { rep } = useReplicache();
let { isLongPress, longPressHandlers } = 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 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];
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 (!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, }); } } else { outdent(props, props.previousBlock, rep, { foldedBlocks, toggleFold, }); } }, { axis: "x", filterTaps: true, pointer: { touch: true }, enabled: isMobile && !!props.listData, }, );
return ( <div {...(!props.preview ? { ...mouseHandlers, ...longPressHandlers, ...bindSwipe() } : {})} id={ !props.preview ? elementId.block(props.entityID).container : undefined } onDragOver={ !props.preview && entity_set.permissions.write ? (e) => { 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 z-1 w-full ${props.listData ? "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" ? "pt-2 sm:pt-3" : "pt-3 sm:pt-4" : "pt-1" }`} > {!props.preview && <BlockMultiselectIndicator {...props} />} <BaseBlock {...props} areYouSure={areYouSure} setAreYouSure={setAreYouSure} /> </div> );}, 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 || prevProps.listData.displayNumber !== nextProps.listData.displayNumber || prevProps.listData.listStyle !== nextProps.listData.listStyle ) { 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];
if (!BlockTypeComponent) return <div>unknown block</div>; return ( <> {props.listData && <ListMarker {...props} />} {props.areYouSure ? ( <AreYouSure closeAreYouSure={() => props.setAreYouSure && props.setAreYouSure(false) } type={props.type} entityID={props.entityID} /> ) : ( <BlockTypeComponent {...props} preview={props.preview} /> )} </> );};
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 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) return ( <> <div className={` blockSelectionBG multiselected selected pointer-events-none bg-border-light absolute right-2 left-2 bottom-0 ${first ? "top-2" : "top-0"} ${!prevBlockSelected && "rounded-t-md"} ${!nextBlockSelected && "rounded-b-md"} `} /> </> );};
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;}) => { // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete return ( <div className={`nonTextBlockAndControls relative ${props.hasAlignment ? "w-fit" : "w-full"}`} > <div className={`nonTextBlock ${props.className} p-2 sm:p-3 overflow-hidden ${props.hasAlignment ? "w-fit" : "w-full"} ${props.isSelected ? "block-border-selected " : "block-border"} ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`} style={{ backgroundColor: props.hasBackground === "accent" ? "var(--accent-light)" : props.hasBackground === "page" ? "rgb(var(--bg-page))" : "transparent", }} > {props.children} </div> {props.isSelected && ( <NonTextBlockOptions optionsClassName={props.optionsClassName} areYouSure={props.areYouSure} setAreYouSure={props.setAreYouSure} /> )} </div> );};
let debounced: null | number = null;
const NonTextBlockOptions = (props: { areYouSure?: boolean; setAreYouSure?: (value: boolean) => void; optionsClassName?: string;}) => { let { rep } = 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 ( <div 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}`} > {focusedEntityType?.data.value !== "canvas" && ( <> <button onClick={async (e) => { e.stopPropagation();
if (!rep) return; await moveBlockDown(rep, entity_set.set); }} > <ArrowDownTiny /> </button> <button onClick={async (e) => { e.stopPropagation();
if (!rep) return; await moveBlockUp(rep); }} > <ArrowDownTiny className="rotate-180" /> </button> <Separator classname="border-bg-page! h-4! mx-0.5" /> </> )} <button onClick={async (e) => { e.stopPropagation(); if (!rep || !focusedEntity) return;
if (props.areYouSure !== undefined && props.setAreYouSure) { if (!props.areYouSure) { props.setAreYouSure(true); debounced = window.setTimeout(() => { debounced = null; }, 300); return; }
if (props.areYouSure) { if (debounced) { window.clearTimeout(debounced); debounced = window.setTimeout(() => { debounced = null; }, 300); return; } await deleteBlock([focusedEntity.entityID], rep); } } else { await deleteBlock([focusedEntity.entityID], rep); } }} > <DeleteTiny /> </button> </div> );};
export const ListMarker = ( props: Block & { previousBlock?: Block | null; nextBlock?: Block | null; } & { className?: string; },) => { let checklist = useEntity(props.value, "block/check-list"); let listStyle = useEntity(props.value, "block/list-style"); 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();
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.value, attribute: "block/list-number", }); } else { await rep.mutate.assertFact({ entity: props.value, attribute: "block/list-number", data: { type: "number", value: newNumber }, }); }
setEditingNumber(false); };
return ( <div className={`shrink-0 flex justify-end items-center h-3 z-1 ${props.className} ${ props.type === "heading" ? headingLevel === 3 ? "pt-[12px]" : headingLevel === 2 ? "pt-[15px]" : "pt-[20px]" : "pt-[12px]" } `} style={{ width: depth && `calc(${depth} * ${`var(--list-marker-width) ${checklist ? " + 20px" : ""} - 6px`} `, }} > <button onClick={() => { if (children.length > 0) useUIState.getState().toggleFold(props.value); }} className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} > {listStyle?.data.value === "ordered" ? ( editingNumber ? ( <input type="text" value={numberInputValue} onChange={(e) => setNumberInputValue(e.target.value)} onClick={(e) => e.stopPropagation()} onBlur={handleNumberSave} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleNumberSave(); } else if (e.key === "Escape") { setEditingNumber(false); } }} autoFocus 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" /> ) : ( <div className="text-secondary font-normal text-right w-[2rem] cursor-pointer hover:text-primary" onClick={(e) => { e.stopPropagation(); if (permissions.write && listStyle?.data.value === "ordered") { setNumberInputValue(String(props.listData?.displayNumber || 1)); setEditingNumber(true); } }} > {props.listData?.displayNumber || 1}. </div> ) ) : ( <div className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-offset-1 ${ folded ? "outline-secondary" : ` ${children.length > 0 ? "sm:group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}` }`} /> )} </button> {checklist && ( <button onClick={() => { if (permissions.write) rep?.mutate.assertFact({ entity: props.value, attribute: "block/check-list", data: { type: "boolean", value: !checklist.data.value }, }); }} className={`pr-2 ${checklist?.data.value ? "text-accent-contrast" : "text-border"} ${permissions.write ? "cursor-default" : ""}`} > {checklist?.data.value ? <CheckboxChecked /> : <CheckboxEmpty />} </button> )} </div> );};