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