a tool for shared writing and social publishing
at feature/recommend 621 lines 21 kB view raw
1import { useEntity, useReplicache } from "src/replicache"; 2import { useEntitySetContext } from "./EntitySetProvider"; 3import { v7 } from "uuid"; 4import { BaseBlock } from "./Blocks/Block"; 5import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 6import { useDrag } from "src/hooks/useDrag"; 7import { useLongPress } from "src/hooks/useLongPress"; 8import { focusBlock } from "src/utils/focusBlock"; 9import { elementId } from "src/utils/elementId"; 10import { useUIState } from "src/useUIState"; 11import useMeasure from "react-use-measure"; 12import { useIsMobile } from "src/hooks/isMobile"; 13import { Media } from "./Media"; 14import { TooltipButton } from "./Buttons"; 15import { useBlockKeyboardHandlers } from "./Blocks/useBlockKeyboardHandlers"; 16import { AddSmall } from "./Icons/AddSmall"; 17import { InfoSmall } from "./Icons/InfoSmall"; 18import { Popover } from "./Popover"; 19import { Separator } from "./Layout"; 20import { CommentTiny } from "./Icons/CommentTiny"; 21import { QuoteTiny } from "./Icons/QuoteTiny"; 22import { AddTags, PublicationMetadata } from "./Pages/PublicationMetadata"; 23import { useLeafletPublicationData } from "./PageSWRDataProvider"; 24import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 25import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; 26import { RecommendTinyEmpty } from "./Icons/RecommendTiny"; 27 28export function Canvas(props: { 29 entityID: string; 30 preview?: boolean; 31 first?: boolean; 32}) { 33 let entity_set = useEntitySetContext(); 34 let ref = useRef<HTMLDivElement>(null); 35 useEffect(() => { 36 let abort = new AbortController(); 37 let isTouch = false; 38 let startX: number, startY: number, scrollLeft: number, scrollTop: number; 39 let el = ref.current; 40 ref.current?.addEventListener( 41 "wheel", 42 (e) => { 43 if (!el) return; 44 if ( 45 (e.deltaX > 0 && el.scrollLeft >= el.scrollWidth - el.clientWidth) || 46 (e.deltaX < 0 && el.scrollLeft <= 0) || 47 (e.deltaY > 0 && el.scrollTop >= el.scrollHeight - el.clientHeight) || 48 (e.deltaY < 0 && el.scrollTop <= 0) 49 ) { 50 return; 51 } 52 e.preventDefault(); 53 el.scrollLeft += e.deltaX; 54 el.scrollTop += e.deltaY; 55 }, 56 { passive: false, signal: abort.signal }, 57 ); 58 return () => abort.abort(); 59 }); 60 61 return ( 62 <div 63 ref={ref} 64 id={elementId.page(props.entityID).canvasScrollArea} 65 className={` 66 canvasWrapper 67 h-full w-fit 68 overflow-y-scroll 69 `} 70 > 71 <AddCanvasBlockButton entityID={props.entityID} entity_set={entity_set} /> 72 73 <CanvasMetadata isSubpage={!props.first} /> 74 75 <CanvasContent {...props} /> 76 </div> 77 ); 78} 79 80export function CanvasContent(props: { entityID: string; preview?: boolean }) { 81 let blocks = useEntity(props.entityID, "canvas/block"); 82 let { rep } = useReplicache(); 83 let entity_set = useEntitySetContext(); 84 let height = Math.max(...blocks.map((f) => f.data.position.y), 0); 85 let handleDrop = useHandleCanvasDrop(props.entityID); 86 87 return ( 88 <div 89 onClick={async (e) => { 90 if (e.currentTarget !== e.target) return; 91 useUIState.setState(() => ({ 92 selectedBlocks: [], 93 focusedEntity: { entityType: "page", entityID: props.entityID }, 94 })); 95 useUIState.setState({ 96 focusedEntity: { entityType: "page", entityID: props.entityID }, 97 }); 98 document 99 .getElementById(elementId.page(props.entityID).container) 100 ?.scrollIntoView({ 101 behavior: "smooth", 102 inline: "nearest", 103 }); 104 if (e.detail === 2 || e.ctrlKey || e.metaKey) { 105 let parentRect = e.currentTarget.getBoundingClientRect(); 106 let newEntityID = v7(); 107 await rep?.mutate.addCanvasBlock({ 108 newEntityID, 109 parent: props.entityID, 110 position: { 111 x: Math.max(e.clientX - parentRect.left, 0), 112 y: Math.max(e.clientY - parentRect.top - 12, 0), 113 }, 114 factID: v7(), 115 type: "text", 116 permission_set: entity_set.set, 117 }); 118 focusBlock( 119 { type: "text", parent: props.entityID, value: newEntityID }, 120 { type: "start" }, 121 ); 122 } 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 style={{ 136 minHeight: height + 512, 137 contain: "size layout paint", 138 }} 139 className="relative h-full w-[1272px]" 140 > 141 <CanvasBackground entityID={props.entityID} /> 142 {blocks 143 .sort((a, b) => { 144 if (a.data.position.y === b.data.position.y) { 145 return a.data.position.x - b.data.position.x; 146 } 147 return a.data.position.y - b.data.position.y; 148 }) 149 .map((b) => { 150 return ( 151 <CanvasBlock 152 preview={props.preview} 153 parent={props.entityID} 154 entityID={b.data.value} 155 position={b.data.position} 156 factID={b.id} 157 key={b.id} 158 /> 159 ); 160 })} 161 </div> 162 ); 163} 164 165const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 166 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 167 if (!pub || !pub.publications) return null; 168 169 if (!normalizedPublication) return null; 170 let showComments = normalizedPublication.preferences?.showComments !== false; 171 let showMentions = normalizedPublication.preferences?.showMentions !== false; 172 let showRecommends = 173 normalizedPublication.preferences?.showRecommends !== false; 174 175 return ( 176 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> 177 {showRecommends && ( 178 <div className="flex gap-1 text-tertiary items-center"> 179 <RecommendTinyEmpty className="text-border" /> 180 </div> 181 )} 182 {showComments && ( 183 <div className="flex gap-1 text-tertiary items-center"> 184 <CommentTiny className="text-border" /> 185 </div> 186 )} 187 {showMentions && ( 188 <div className="flex gap-1 text-tertiary items-center"> 189 <QuoteTiny className="text-border" /> 190 </div> 191 )} 192 193 {showMentions !== false || 194 showComments !== false || 195 showRecommends === false ? ( 196 <Separator classname="h-4!" /> 197 ) : null} 198 <AddTags /> 199 200 {!props.isSubpage && ( 201 <> 202 <Separator classname="h-5" /> 203 <Popover 204 side="left" 205 align="start" 206 className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 207 trigger={<InfoSmall />} 208 > 209 <PublicationMetadata noInteractions /> 210 </Popover> 211 </> 212 )} 213 </div> 214 ); 215}; 216 217const AddCanvasBlockButton = (props: { 218 entityID: string; 219 entity_set: { set: string }; 220}) => { 221 let { rep } = useReplicache(); 222 let { permissions } = useEntitySetContext(); 223 let blocks = useEntity(props.entityID, "canvas/block"); 224 225 if (!permissions.write) return null; 226 return ( 227 <div className="absolute right-2 sm:bottom-4 sm:right-4 bottom-2 sm:top-auto z-10 flex flex-col gap-1 justify-center"> 228 <TooltipButton 229 side="left" 230 open={blocks.length === 0 ? true : undefined} 231 tooltipContent={ 232 <div className="flex flex-col justify-end text-center px-1 leading-snug "> 233 <div>Add a Block!</div> 234 <div className="font-normal">or double click anywhere</div> 235 </div> 236 } 237 className="w-fit p-2 rounded-full bg-accent-1 border-2 outline-solid outline-transparent hover:outline-1 hover:outline-accent-1 border-accent-1 text-accent-2" 238 onMouseDown={() => { 239 let page = document.getElementById( 240 elementId.page(props.entityID).canvasScrollArea, 241 ); 242 if (!page) return; 243 let newEntityID = v7(); 244 rep?.mutate.addCanvasBlock({ 245 newEntityID, 246 parent: props.entityID, 247 position: { 248 x: page?.clientWidth + page?.scrollLeft - 468, 249 y: 32 + page.scrollTop, 250 }, 251 factID: v7(), 252 type: "text", 253 permission_set: props.entity_set.set, 254 }); 255 setTimeout(() => { 256 focusBlock( 257 { type: "text", value: newEntityID, parent: props.entityID }, 258 { type: "start" }, 259 ); 260 }, 20); 261 }} 262 > 263 <AddSmall /> 264 </TooltipButton> 265 </div> 266 ); 267}; 268 269function CanvasBlock(props: { 270 preview?: boolean; 271 entityID: string; 272 parent: string; 273 position: { x: number; y: number }; 274 factID: string; 275}) { 276 let width = 277 useEntity(props.entityID, "canvas/block/width")?.data.value || 360; 278 let rotation = 279 useEntity(props.entityID, "canvas/block/rotation")?.data.value || 0; 280 let [ref, rect] = useMeasure(); 281 let type = useEntity(props.entityID, "block/type"); 282 let { rep } = useReplicache(); 283 let isMobile = useIsMobile(); 284 285 let { permissions } = useEntitySetContext(); 286 let onDragEnd = useCallback( 287 (dragPosition: { x: number; y: number }) => { 288 if (!permissions.write) return; 289 rep?.mutate.assertFact({ 290 id: props.factID, 291 entity: props.parent, 292 attribute: "canvas/block", 293 data: { 294 type: "spatial-reference", 295 value: props.entityID, 296 position: { 297 x: props.position.x + dragPosition.x, 298 y: props.position.y + dragPosition.y, 299 }, 300 }, 301 }); 302 }, 303 [props, rep, permissions], 304 ); 305 let { dragDelta, handlers: dragHandlers } = useDrag({ 306 onDragEnd, 307 }); 308 309 let widthOnDragEnd = useCallback( 310 (dragPosition: { x: number; y: number }) => { 311 rep?.mutate.assertFact({ 312 entity: props.entityID, 313 attribute: "canvas/block/width", 314 data: { 315 type: "number", 316 value: width + dragPosition.x, 317 }, 318 }); 319 }, 320 [props, rep, width], 321 ); 322 let widthHandle = useDrag({ onDragEnd: widthOnDragEnd }); 323 324 let RotateOnDragEnd = useCallback( 325 (dragDelta: { x: number; y: number }) => { 326 let originX = rect.x + rect.width / 2; 327 let originY = rect.y + rect.height / 2; 328 329 let angle = 330 find_angle( 331 { x: rect.x + rect.width, y: rect.y + rect.height }, 332 { x: originX, y: originY }, 333 { 334 x: rect.x + rect.width + dragDelta.x, 335 y: rect.y + rect.height + dragDelta.y, 336 }, 337 ) * 338 (180 / Math.PI); 339 340 rep?.mutate.assertFact({ 341 entity: props.entityID, 342 attribute: "canvas/block/rotation", 343 data: { 344 type: "number", 345 value: (rotation + angle) % 360, 346 }, 347 }); 348 }, 349 [props, rep, rect, rotation], 350 ); 351 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); 352 353 let { isLongPress, longPressHandlers: longPressHandlers } = useLongPress( 354 () => { 355 if (isLongPress.current && permissions.write) { 356 focusBlock( 357 { 358 type: type?.data.value || "text", 359 value: props.entityID, 360 parent: props.parent, 361 }, 362 { type: "start" }, 363 ); 364 } 365 }, 366 ); 367 let angle = 0; 368 if (rotateHandle.dragDelta) { 369 let originX = rect.x + rect.width / 2; 370 let originY = rect.y + rect.height / 2; 371 372 angle = 373 find_angle( 374 { x: rect.x + rect.width, y: rect.y + rect.height }, 375 { x: originX, y: originY }, 376 { 377 x: rect.x + rect.width + rotateHandle.dragDelta.x, 378 y: rect.y + rect.height + rotateHandle.dragDelta.y, 379 }, 380 ) * 381 (180 / Math.PI); 382 } 383 let x = props.position.x + (dragDelta?.x || 0); 384 let y = props.position.y + (dragDelta?.y || 0); 385 let transform = `translate(${x}px, ${y}px) rotate(${rotation + angle}deg) scale(${!dragDelta ? "1.0" : "1.02"})`; 386 let [areYouSure, setAreYouSure] = useState(false); 387 let blockProps = useMemo(() => { 388 return { 389 pageType: "canvas" as const, 390 preview: props.preview, 391 type: type?.data.value || "text", 392 value: props.entityID, 393 factID: props.factID, 394 position: "", 395 nextPosition: "", 396 entityID: props.entityID, 397 parent: props.parent, 398 nextBlock: null, 399 previousBlock: null, 400 }; 401 }, [props, type?.data.value]); 402 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); 403 let mouseHandlers = useBlockMouseHandlers(blockProps); 404 405 let isList = useEntity(props.entityID, "block/is-list"); 406 let isFocused = useUIState( 407 (s) => s.focusedEntity?.entityID === props.entityID, 408 ); 409 410 return ( 411 <div 412 ref={ref} 413 {...(!props.preview ? { ...longPressHandlers, ...mouseHandlers } : {})} 414 id={props.preview ? undefined : elementId.block(props.entityID).container} 415 className={`canvasBlockWrapper absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 416 style={{ 417 top: 0, 418 left: 0, 419 zIndex: dragDelta || isFocused ? 10 : undefined, 420 width: width + (widthHandle.dragDelta?.x || 0), 421 transform, 422 }} 423 > 424 {!props.preview && permissions.write && ( 425 <Gripper isFocused={isFocused} {...dragHandlers} /> 426 )} 427 428 <div 429 className={` w-full ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 430 > 431 <BaseBlock 432 {...blockProps} 433 listData={ 434 isList?.data.value 435 ? { path: [], parent: props.parent, depth: 1 } 436 : undefined 437 } 438 areYouSure={areYouSure} 439 setAreYouSure={setAreYouSure} 440 /> 441 </div> 442 443 {!props.preview && permissions.write && ( 444 <div 445 className={`resizeHandle 446 cursor-e-resize shrink-0 z-10 447 group-hover/canvas-block:block 448 sm:w-[5px] w-3 sm:h-6 h-8 449 absolute top-1/2 sm:right-2 right-1 -translate-y-1/2 450 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 451 ${isFocused ? "block" : "hidden"} 452 453 `} 454 {...widthHandle.handlers} 455 /> 456 )} 457 458 {!props.preview && permissions.write && ( 459 <div 460 className={`rotateHandle 461 cursor-grab shrink-0 z-10 462 group-hover/canvas-block:block 463 sm:w-[8px] sm:h-[8px] w-4 h-4 464 absolute sm:bottom-0 sm:right-0 -bottom-1 -right-1 465 -translate-y-1/2 -translate-x-1/2 466 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 467 ${isFocused ? "block" : "hidden"} 468`} 469 {...rotateHandle.handlers} 470 /> 471 )} 472 </div> 473 ); 474} 475 476export const CanvasBackground = (props: { entityID: string }) => { 477 let cardBackgroundImage = useEntity( 478 props.entityID, 479 "theme/card-background-image", 480 ); 481 let cardBackgroundImageRepeat = useEntity( 482 props.entityID, 483 "theme/card-background-image-repeat", 484 ); 485 let cardBackgroundImageOpacity = 486 useEntity(props.entityID, "theme/card-background-image-opacity")?.data 487 .value || 1; 488 489 let canvasPattern = 490 useEntity(props.entityID, "canvas/background-pattern")?.data.value || 491 "grid"; 492 return ( 493 <div 494 className="w-full h-full pointer-events-none" 495 style={{ 496 backgroundImage: cardBackgroundImage 497 ? `url(${cardBackgroundImage.data.src}), url(${cardBackgroundImage.data.fallback})` 498 : undefined, 499 backgroundRepeat: "repeat", 500 backgroundPosition: "center", 501 backgroundSize: cardBackgroundImageRepeat?.data.value || 500, 502 opacity: cardBackgroundImage?.data.src ? cardBackgroundImageOpacity : 1, 503 }} 504 > 505 <CanvasBackgroundPattern pattern={canvasPattern} /> 506 </div> 507 ); 508}; 509 510export const CanvasBackgroundPattern = (props: { 511 pattern: "grid" | "dot" | "plain"; 512 scale?: number; 513}) => { 514 if (props.pattern === "plain") return null; 515 let patternID = `canvasPattern-${props.pattern}-${props.scale}`; 516 if (props.pattern === "grid") 517 return ( 518 <svg 519 width="100%" 520 height="100%" 521 xmlns="http://www.w3.org/2000/svg" 522 className="pointer-events-none text-border-light" 523 > 524 <defs> 525 <pattern 526 id={patternID} 527 x="0" 528 y="0" 529 width={props.scale ? 32 * props.scale : 32} 530 height={props.scale ? 32 * props.scale : 32} 531 viewBox={`${props.scale ? 16 * props.scale : 0} ${props.scale ? 16 * props.scale : 0} ${props.scale ? 32 * props.scale : 32} ${props.scale ? 32 * props.scale : 32}`} 532 patternUnits="userSpaceOnUse" 533 > 534 <path 535 fillRule="evenodd" 536 clipRule="evenodd" 537 d="M16.5 0H15.5L15.5 2.06061C15.5 2.33675 15.7239 2.56061 16 2.56061C16.2761 2.56061 16.5 2.33675 16.5 2.06061V0ZM0 16.5V15.5L2.06061 15.5C2.33675 15.5 2.56061 15.7239 2.56061 16C2.56061 16.2761 2.33675 16.5 2.06061 16.5L0 16.5ZM16.5 32H15.5V29.9394C15.5 29.6633 15.7239 29.4394 16 29.4394C16.2761 29.4394 16.5 29.6633 16.5 29.9394V32ZM32 15.5V16.5L29.9394 16.5C29.6633 16.5 29.4394 16.2761 29.4394 16C29.4394 15.7239 29.6633 15.5 29.9394 15.5H32ZM5.4394 16C5.4394 15.7239 5.66325 15.5 5.93939 15.5H10.0606C10.3367 15.5 10.5606 15.7239 10.5606 16C10.5606 16.2761 10.3368 16.5 10.0606 16.5H5.9394C5.66325 16.5 5.4394 16.2761 5.4394 16ZM13.4394 16C13.4394 15.7239 13.6633 15.5 13.9394 15.5H15.5V13.9394C15.5 13.6633 15.7239 13.4394 16 13.4394C16.2761 13.4394 16.5 13.6633 16.5 13.9394V15.5H18.0606C18.3367 15.5 18.5606 15.7239 18.5606 16C18.5606 16.2761 18.3367 16.5 18.0606 16.5H16.5V18.0606C16.5 18.3367 16.2761 18.5606 16 18.5606C15.7239 18.5606 15.5 18.3367 15.5 18.0606V16.5H13.9394C13.6633 16.5 13.4394 16.2761 13.4394 16ZM21.4394 16C21.4394 15.7239 21.6633 15.5 21.9394 15.5H26.0606C26.3367 15.5 26.5606 15.7239 26.5606 16C26.5606 16.2761 26.3367 16.5 26.0606 16.5H21.9394C21.6633 16.5 21.4394 16.2761 21.4394 16ZM16 5.4394C16.2761 5.4394 16.5 5.66325 16.5 5.93939V10.0606C16.5 10.3367 16.2761 10.5606 16 10.5606C15.7239 10.5606 15.5 10.3368 15.5 10.0606V5.9394C15.5 5.66325 15.7239 5.4394 16 5.4394ZM16 21.4394C16.2761 21.4394 16.5 21.6633 16.5 21.9394V26.0606C16.5 26.3367 16.2761 26.5606 16 26.5606C15.7239 26.5606 15.5 26.3367 15.5 26.0606V21.9394C15.5 21.6633 15.7239 21.4394 16 21.4394Z" 538 fill="currentColor" 539 /> 540 </pattern> 541 </defs> 542 <rect 543 width="100%" 544 height="100%" 545 x="0" 546 y="0" 547 fill={`url(#${patternID})`} 548 /> 549 </svg> 550 ); 551 552 if (props.pattern === "dot") { 553 return ( 554 <svg 555 width="100%" 556 height="100%" 557 xmlns="http://www.w3.org/2000/svg" 558 className={`pointer-events-none text-border`} 559 > 560 <defs> 561 <pattern 562 id={patternID} 563 x="0" 564 y="0" 565 width={props.scale ? 24 * props.scale : 24} 566 height={props.scale ? 24 * props.scale : 24} 567 patternUnits="userSpaceOnUse" 568 > 569 <circle 570 cx={props.scale ? 12 * props.scale : 12} 571 cy={props.scale ? 12 * props.scale : 12} 572 r="1" 573 fill="currentColor" 574 /> 575 </pattern> 576 </defs> 577 <rect 578 width="100%" 579 height="100%" 580 x="0" 581 y="0" 582 fill={`url(#${patternID})`} 583 /> 584 </svg> 585 ); 586 } 587}; 588 589const Gripper = (props: { 590 onMouseDown: (e: React.MouseEvent) => void; 591 isFocused: boolean; 592}) => { 593 return ( 594 <div 595 onMouseDown={props.onMouseDown} 596 onPointerDown={props.onMouseDown} 597 className="gripper w-[9px] shrink-0 py-1 mr-1 cursor-grab touch-none" 598 > 599 <div className="h-full grid grid-cols-1 grid-rows-1 "> 600 {/* the gripper is two svg's stacked on top of each other. 601 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} 602 <div 603 className={`h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page group-hover/canvas-block:block ${props.isFocused ? "block" : "hidden"}`} 604 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} 605 /> 606 <div 607 className={`h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary group-hover/canvas-block:block ${props.isFocused ? "block" : "hidden"}`} 608 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} 609 /> 610 </div> 611 </div> 612 ); 613}; 614 615type P = { x: number; y: number }; 616function find_angle(P2: P, P1: P, P3: P) { 617 if (P1.x === P3.x && P1.y === P3.y) return 0; 618 let a = Math.atan2(P3.y - P1.y, P3.x - P1.x); 619 let b = Math.atan2(P2.y - P1.y, P2.x - P1.x); 620 return a - b; 621}