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