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