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