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