Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
23 kB · 706 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707"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 ( <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 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 && <BlockMultiselectIndicator {...props} />} <BaseBlock {...props} areYouSure={areYouSure} setAreYouSure={setAreYouSure} /> </div> );}, 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 <div>unknown block</div>; return ( <> {props.type === "heading" && props.headingFoldable && ( <HeadingFoldButton {...props} /> )} {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, "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 ( <div className={` blockSelectionBG multiselected selected pointer-events-none bg-border-light absolute right-2 left-2 bottom-0 ${first ? "top-1" : "top-0"} ${!multiselectState.includes("p") && "rounded-t-md"} ${!multiselectState.includes("n") && "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; extraOptions?: React.ReactNode;}) => { // 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!"} ${ props.hasBackground === "accent" ? "bg-[var(--accent-light)]" : props.hasBackground === "page" ? "bg-page" : "transparent" } `} > {props.children} </div> {props.isSelected && ( <NonTextBlockOptions optionsClassName={props.optionsClassName} areYouSure={props.areYouSure} setAreYouSure={props.setAreYouSure} extraOptions={props.extraOptions} /> )} </div> );};
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 ( <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" /> </> )} {props.extraOptions && ( <> {props.extraOptions}{" "} <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, undoManager); } } else { await deleteBlock([focusedEntity.entityID], rep, undoManager); } }} > <DeleteTiny /> </button> </div> );};
// 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 ( <button className={`headingFoldButton absolute -left-1 ${top} p-0.5 pl-[3px] rounded-r-full text-bg-page transition-opacity ${ folded ? "opacity-100 bg-accent-contrast" : "opacity-0 sm:group-hover/blockWrapper:opacity-100 bg-border " }`} onClick={() => useUIState.getState().toggleFold(props.entityID)} > <ArrowDownTiny className={`transition-transform ${folded ? "-rotate-90" : ""}`} width={12} height={12} /> </button> );};
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 ( <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.entityID); }} 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.entityID, 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> );};