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