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"; 10 11import { TextBlock } from "components/Blocks/TextBlock"; 12import { ImageBlock } from "./ImageBlock"; 13import { PageLinkBlock } from "./PageLinkBlock"; 14import { ExternalLinkBlock } from "./ExternalLinkBlock"; 15import { EmbedBlock } from "./EmbedBlock"; 16import { MailboxBlock } from "./MailboxBlock"; 17import { AreYouSure } from "./DeleteBlock"; 18import { useEntitySetContext } from "components/EntitySetProvider"; 19import { useIsMobile } from "src/hooks/isMobile"; 20import { DateTimeBlock } from "./DateTimeBlock"; 21import { RSVPBlock } from "./RSVPBlock"; 22import { elementId } from "src/utils/elementId"; 23import { ButtonBlock } from "./ButtonBlock"; 24import { PollBlock } from "./PollBlock"; 25import { BlueskyPostBlock } from "./BlueskyPostBlock"; 26import { CheckboxChecked } from "components/Icons/CheckboxChecked"; 27import { CheckboxEmpty } from "components/Icons/CheckboxEmpty"; 28import { LockTiny } from "components/Icons/LockTiny"; 29import { MathBlock } from "./MathBlock"; 30import { CodeBlock } from "./CodeBlock"; 31import { HorizontalRule } from "./HorizontalRule"; 32import { deepEquals } from "src/utils/deepEquals"; 33 34export type Block = { 35 factID: string; 36 parent: string; 37 position: string; 38 value: string; 39 type: Fact<"block/type">["data"]["value"]; 40 listData?: { 41 checklist?: boolean; 42 path: { depth: number; entity: string }[]; 43 parent: string; 44 depth: number; 45 }; 46}; 47export type BlockProps = { 48 pageType: Fact<"page/type">["data"]["value"]; 49 entityID: string; 50 parent: string; 51 position: string; 52 nextBlock: Block | null; 53 previousBlock: Block | null; 54 nextPosition: string | null; 55} & Block; 56 57export const Block = memo(function Block( 58 props: BlockProps & { preview?: boolean }, 59) { 60 // Block handles all block level events like 61 // mouse events, keyboard events and longPress, and setting AreYouSure state 62 // and shared styling like padding and flex for list layouting 63 64 let mouseHandlers = useBlockMouseHandlers(props); 65 66 // focus block on longpress, shouldnt the type be based on the block type (?) 67 let { isLongPress, handlers } = useLongPress(() => { 68 if (isLongPress.current) { 69 focusBlock( 70 { type: props.type, value: props.entityID, parent: props.parent }, 71 { type: "start" }, 72 ); 73 } 74 }); 75 76 let selected = useUIState( 77 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 78 ); 79 80 let [areYouSure, setAreYouSure] = useState(false); 81 useEffect(() => { 82 if (!selected) { 83 setAreYouSure(false); 84 } 85 }, [selected]); 86 87 // THIS IS WHERE YOU SET WHETHER OR NOT AREYOUSURE IS TRIGGERED ON THE DELETE KEY 88 useBlockKeyboardHandlers(props, areYouSure, setAreYouSure); 89 90 return ( 91 <div 92 {...(!props.preview ? { ...mouseHandlers, ...handlers } : {})} 93 id={ 94 !props.preview ? elementId.block(props.entityID).container : undefined 95 } 96 className={` 97 blockWrapper relative 98 flex flex-row gap-2 99 px-3 sm:px-4 100 ${ 101 !props.nextBlock 102 ? "pb-3 sm:pb-4" 103 : props.type === "heading" || 104 (props.listData && props.nextBlock?.listData) 105 ? "pb-0" 106 : "pb-2" 107 } 108 ${props.type === "blockquote" && props.previousBlock?.type === "blockquote" ? (!props.listData ? "-mt-3" : "-mt-1") : ""} 109 ${ 110 !props.previousBlock 111 ? props.type === "heading" || props.type === "text" 112 ? "pt-2 sm:pt-3" 113 : "pt-3 sm:pt-4" 114 : "pt-1" 115 }`} 116 > 117 {!props.preview && <BlockMultiselectIndicator {...props} />} 118 <BaseBlock 119 {...props} 120 areYouSure={areYouSure} 121 setAreYouSure={setAreYouSure} 122 /> 123 </div> 124 ); 125}, deepEqualsBlockProps); 126 127function deepEqualsBlockProps( 128 prevProps: BlockProps & { preview?: boolean }, 129 nextProps: BlockProps & { preview?: boolean }, 130): boolean { 131 // Compare primitive fields 132 if ( 133 prevProps.pageType !== nextProps.pageType || 134 prevProps.entityID !== nextProps.entityID || 135 prevProps.parent !== nextProps.parent || 136 prevProps.position !== nextProps.position || 137 prevProps.factID !== nextProps.factID || 138 prevProps.value !== nextProps.value || 139 prevProps.type !== nextProps.type || 140 prevProps.nextPosition !== nextProps.nextPosition || 141 prevProps.preview !== nextProps.preview 142 ) { 143 return false; 144 } 145 146 // Compare listData if present 147 if (prevProps.listData !== nextProps.listData) { 148 if (!prevProps.listData || !nextProps.listData) { 149 return false; // One is undefined, the other isn't 150 } 151 152 if ( 153 prevProps.listData.checklist !== nextProps.listData.checklist || 154 prevProps.listData.parent !== nextProps.listData.parent || 155 prevProps.listData.depth !== nextProps.listData.depth 156 ) { 157 return false; 158 } 159 160 // Compare path array 161 if (prevProps.listData.path.length !== nextProps.listData.path.length) { 162 return false; 163 } 164 165 for (let i = 0; i < prevProps.listData.path.length; i++) { 166 if ( 167 prevProps.listData.path[i].depth !== nextProps.listData.path[i].depth || 168 prevProps.listData.path[i].entity !== nextProps.listData.path[i].entity 169 ) { 170 return false; 171 } 172 } 173 } 174 175 // Compare nextBlock 176 if (prevProps.nextBlock !== nextProps.nextBlock) { 177 if (!prevProps.nextBlock || !nextProps.nextBlock) { 178 return false; // One is null, the other isn't 179 } 180 181 if ( 182 prevProps.nextBlock.factID !== nextProps.nextBlock.factID || 183 prevProps.nextBlock.parent !== nextProps.nextBlock.parent || 184 prevProps.nextBlock.position !== nextProps.nextBlock.position || 185 prevProps.nextBlock.value !== nextProps.nextBlock.value || 186 prevProps.nextBlock.type !== nextProps.nextBlock.type 187 ) { 188 return false; 189 } 190 191 // Compare nextBlock's listData (using deepEquals for simplicity) 192 if ( 193 !deepEquals(prevProps.nextBlock.listData, nextProps.nextBlock.listData) 194 ) { 195 return false; 196 } 197 } 198 199 // Compare previousBlock 200 if (prevProps.previousBlock !== nextProps.previousBlock) { 201 if (!prevProps.previousBlock || !nextProps.previousBlock) { 202 return false; // One is null, the other isn't 203 } 204 205 if ( 206 prevProps.previousBlock.factID !== nextProps.previousBlock.factID || 207 prevProps.previousBlock.parent !== nextProps.previousBlock.parent || 208 prevProps.previousBlock.position !== nextProps.previousBlock.position || 209 prevProps.previousBlock.value !== nextProps.previousBlock.value || 210 prevProps.previousBlock.type !== nextProps.previousBlock.type 211 ) { 212 return false; 213 } 214 215 // Compare previousBlock's listData (using deepEquals for simplicity) 216 if ( 217 !deepEquals( 218 prevProps.previousBlock.listData, 219 nextProps.previousBlock.listData, 220 ) 221 ) { 222 return false; 223 } 224 } 225 226 return true; 227} 228 229export const BaseBlock = ( 230 props: BlockProps & { 231 preview?: boolean; 232 areYouSure?: boolean; 233 setAreYouSure?: (value: boolean) => void; 234 }, 235) => { 236 // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers 237 let BlockTypeComponent = BlockTypeComponents[props.type]; 238 let alignment = useEntity(props.value, "block/text-alignment")?.data.value; 239 240 let alignmentStyle = 241 props.type === "button" || props.type === "image" 242 ? "justify-center" 243 : "justify-start"; 244 245 if (alignment) 246 alignmentStyle = { 247 left: "justify-start", 248 right: "justify-end", 249 center: "justify-center", 250 justify: "justify-start", 251 }[alignment]; 252 253 if (!BlockTypeComponent) return <div>unknown block</div>; 254 return ( 255 <div 256 className={`blockContentWrapper w-full grow flex gap-2 z-1 ${alignmentStyle}`} 257 > 258 {props.listData && <ListMarker {...props} />} 259 {props.areYouSure ? ( 260 <AreYouSure 261 closeAreYouSure={() => 262 props.setAreYouSure && props.setAreYouSure(false) 263 } 264 type={props.type} 265 entityID={props.entityID} 266 /> 267 ) : ( 268 <BlockTypeComponent {...props} preview={props.preview} /> 269 )} 270 </div> 271 ); 272}; 273 274const BlockTypeComponents: { 275 [K in Fact<"block/type">["data"]["value"]]: React.ComponentType< 276 BlockProps & { preview?: boolean } 277 >; 278} = { 279 code: CodeBlock, 280 math: MathBlock, 281 card: PageLinkBlock, 282 text: TextBlock, 283 blockquote: TextBlock, 284 heading: TextBlock, 285 image: ImageBlock, 286 link: ExternalLinkBlock, 287 embed: EmbedBlock, 288 mailbox: MailboxBlock, 289 datetime: DateTimeBlock, 290 rsvp: RSVPBlock, 291 button: ButtonBlock, 292 poll: PollBlock, 293 "bluesky-post": BlueskyPostBlock, 294 "horizontal-rule": HorizontalRule, 295}; 296 297export const BlockMultiselectIndicator = (props: BlockProps) => { 298 let { rep } = useReplicache(); 299 let isMobile = useIsMobile(); 300 301 let first = props.previousBlock === null; 302 303 let isMultiselected = useUIState( 304 (s) => 305 !!s.selectedBlocks.find((b) => b.value === props.entityID) && 306 s.selectedBlocks.length > 1, 307 ); 308 309 let isSelected = useUIState((s) => 310 s.selectedBlocks.find((b) => b.value === props.entityID), 311 ); 312 let isLocked = useEntity(props.value, "block/is-locked"); 313 314 let nextBlockSelected = useUIState((s) => 315 s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), 316 ); 317 let prevBlockSelected = useUIState((s) => 318 s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), 319 ); 320 321 if (isMultiselected || (isLocked?.data.value && isSelected)) 322 // not sure what multiselected and selected classes are doing (?) 323 // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected 324 325 return ( 326 <> 327 <div 328 className={` 329 blockSelectionBG multiselected selected 330 pointer-events-none 331 bg-border-light 332 absolute right-2 left-2 bottom-0 333 ${first ? "top-2" : "top-0"} 334 ${!prevBlockSelected && "rounded-t-md"} 335 ${!nextBlockSelected && "rounded-b-md"} 336 `} 337 style={ 338 isLocked?.data.value 339 ? { 340 maskImage: "var(--hatchSVG)", 341 maskRepeat: "repeat repeat", 342 } 343 : {} 344 } 345 ></div> 346 {isLocked?.data.value && ( 347 <div 348 className={` 349 blockSelectionLockIndicator z-10 350 flex items-center 351 text-border rounded-full 352 absolute right-3 353 354 ${ 355 props.type === "heading" || props.type === "text" 356 ? "top-[6px]" 357 : "top-0" 358 }`} 359 > 360 <LockTiny className="bg-bg-page p-0.5 rounded-full w-5 h-5" /> 361 </div> 362 )} 363 </> 364 ); 365}; 366 367export const ListMarker = ( 368 props: Block & { 369 previousBlock?: Block | null; 370 nextBlock?: Block | null; 371 } & { 372 className?: string; 373 }, 374) => { 375 let isMobile = useIsMobile(); 376 let checklist = useEntity(props.value, "block/check-list"); 377 let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; 378 let children = useEntity(props.value, "card/block"); 379 let folded = 380 useUIState((s) => s.foldedBlocks.includes(props.value)) && 381 children.length > 0; 382 383 let depth = props.listData?.depth; 384 let { permissions } = useEntitySetContext(); 385 let { rep } = useReplicache(); 386 return ( 387 <div 388 className={`shrink-0 flex justify-end items-center h-3 z-1 389 ${props.className} 390 ${ 391 props.type === "heading" 392 ? headingLevel === 3 393 ? "pt-[12px]" 394 : headingLevel === 2 395 ? "pt-[15px]" 396 : "pt-[20px]" 397 : "pt-[12px]" 398 } 399 `} 400 style={{ 401 width: 402 depth && 403 `calc(${depth} * ${`var(--list-marker-width) ${checklist ? " + 20px" : ""} - 6px`} `, 404 }} 405 > 406 <button 407 onClick={() => { 408 if (children.length > 0) 409 useUIState.getState().toggleFold(props.value); 410 }} 411 className={`listMarker group/list-marker p-2 ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 412 > 413 <div 414 className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-1 outline-offset-1 415 ${ 416 folded 417 ? "outline-secondary" 418 : ` ${children.length > 0 ? "sm:group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}` 419 }`} 420 /> 421 </button> 422 {checklist && ( 423 <button 424 onClick={() => { 425 if (permissions.write) 426 rep?.mutate.assertFact({ 427 entity: props.value, 428 attribute: "block/check-list", 429 data: { type: "boolean", value: !checklist.data.value }, 430 }); 431 }} 432 className={`pr-2 ${checklist?.data.value ? "text-accent-contrast" : "text-border"} ${permissions.write ? "cursor-default" : ""}`} 433 > 434 {checklist?.data.value ? <CheckboxChecked /> : <CheckboxEmpty />} 435 </button> 436 )} 437 </div> 438 ); 439};