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