a tool for shared writing and social publishing
at main 20 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 { 25 PubLeafletPublication, 26 PubLeafletPublicationRecord, 27} from "lexicons/api"; 28import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; 29 30export function Canvas(props: { 31 entityID: string; 32 preview?: boolean; 33 first?: boolean; 34}) { 35 let entity_set = useEntitySetContext(); 36 let ref = useRef<HTMLDivElement>(null); 37 useEffect(() => { 38 let abort = new AbortController(); 39 let isTouch = false; 40 let startX: number, startY: number, scrollLeft: number, scrollTop: number; 41 let el = ref.current; 42 ref.current?.addEventListener( 43 "wheel", 44 (e) => { 45 if (!el) return; 46 if ( 47 (e.deltaX > 0 && el.scrollLeft >= el.scrollWidth - el.clientWidth) || 48 (e.deltaX < 0 && el.scrollLeft <= 0) || 49 (e.deltaY > 0 && el.scrollTop >= el.scrollHeight - el.clientHeight) || 50 (e.deltaY < 0 && el.scrollTop <= 0) 51 ) { 52 return; 53 } 54 e.preventDefault(); 55 el.scrollLeft += e.deltaX; 56 el.scrollTop += e.deltaY; 57 }, 58 { passive: false, signal: abort.signal }, 59 ); 60 return () => abort.abort(); 61 }); 62 63 return ( 64 <div 65 ref={ref} 66 id={elementId.page(props.entityID).canvasScrollArea} 67 className={` 68 canvasWrapper 69 h-full w-fit 70 overflow-y-scroll 71 `} 72 > 73 <AddCanvasBlockButton entityID={props.entityID} entity_set={entity_set} /> 74 75 <CanvasMetadata isSubpage={!props.first} /> 76 77 <CanvasContent {...props} /> 78 </div> 79 ); 80} 81 82export function CanvasContent(props: { entityID: string; preview?: boolean }) { 83 let blocks = useEntity(props.entityID, "canvas/block"); 84 let { rep } = useReplicache(); 85 let entity_set = useEntitySetContext(); 86 let height = Math.max(...blocks.map((f) => f.data.position.y), 0); 87 let handleDrop = useHandleCanvasDrop(props.entityID); 88 89 return ( 90 <div 91 onClick={async (e) => { 92 if (e.currentTarget !== e.target) return; 93 useUIState.setState(() => ({ 94 selectedBlocks: [], 95 focusedEntity: { entityType: "page", entityID: props.entityID }, 96 })); 97 useUIState.setState({ 98 focusedEntity: { entityType: "page", entityID: props.entityID }, 99 }); 100 document 101 .getElementById(elementId.page(props.entityID).container) 102 ?.scrollIntoView({ 103 behavior: "smooth", 104 inline: "nearest", 105 }); 106 if (e.detail === 2 || e.ctrlKey || e.metaKey) { 107 let parentRect = e.currentTarget.getBoundingClientRect(); 108 let newEntityID = v7(); 109 await rep?.mutate.addCanvasBlock({ 110 newEntityID, 111 parent: props.entityID, 112 position: { 113 x: Math.max(e.clientX - parentRect.left, 0), 114 y: Math.max(e.clientY - parentRect.top - 12, 0), 115 }, 116 factID: v7(), 117 type: "text", 118 permission_set: entity_set.set, 119 }); 120 focusBlock( 121 { type: "text", parent: props.entityID, value: newEntityID }, 122 { type: "start" }, 123 ); 124 } 125 }} 126 onDragOver={ 127 !props.preview && entity_set.permissions.write 128 ? (e) => { 129 e.preventDefault(); 130 e.stopPropagation(); 131 } 132 : undefined 133 } 134 onDrop={ 135 !props.preview && entity_set.permissions.write ? handleDrop : undefined 136 } 137 style={{ 138 minHeight: height + 512, 139 contain: "size layout paint", 140 }} 141 className="relative h-full w-[1272px]" 142 > 143 <CanvasBackground entityID={props.entityID} /> 144 {blocks 145 .sort((a, b) => { 146 if (a.data.position.y === b.data.position.y) { 147 return a.data.position.x - b.data.position.x; 148 } 149 return a.data.position.y - b.data.position.y; 150 }) 151 .map((b) => { 152 return ( 153 <CanvasBlock 154 preview={props.preview} 155 parent={props.entityID} 156 entityID={b.data.value} 157 position={b.data.position} 158 factID={b.id} 159 key={b.id} 160 /> 161 ); 162 })} 163 </div> 164 ); 165} 166 167const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => { 168 let { data: pub } = useLeafletPublicationData(); 169 if (!pub || !pub.publications) return null; 170 171 let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 let showComments = pubRecord.preferences?.showComments; 173 174 return ( 175 <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"> 176 {showComments && ( 177 <div className="flex gap-1 text-tertiary items-center"> 178 <CommentTiny className="text-border" /> 179 </div> 180 )} 181 <div className="flex gap-1 text-tertiary items-center"> 182 <QuoteTiny className="text-border" /> 183 </div> 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 } = useDrag({ 291 onDragEnd, 292 delay: isMobile, 293 }); 294 295 let widthOnDragEnd = useCallback( 296 (dragPosition: { x: number; y: number }) => { 297 rep?.mutate.assertFact({ 298 entity: props.entityID, 299 attribute: "canvas/block/width", 300 data: { 301 type: "number", 302 value: width + dragPosition.x, 303 }, 304 }); 305 }, 306 [props, rep, width], 307 ); 308 let widthHandle = useDrag({ onDragEnd: widthOnDragEnd }); 309 310 let RotateOnDragEnd = useCallback( 311 (dragDelta: { x: number; y: number }) => { 312 let originX = rect.x + rect.width / 2; 313 let originY = rect.y + rect.height / 2; 314 315 let angle = 316 find_angle( 317 { x: rect.x + rect.width, y: rect.y + rect.height }, 318 { x: originX, y: originY }, 319 { 320 x: rect.x + rect.width + dragDelta.x, 321 y: rect.y + rect.height + dragDelta.y, 322 }, 323 ) * 324 (180 / Math.PI); 325 326 rep?.mutate.assertFact({ 327 entity: props.entityID, 328 attribute: "canvas/block/rotation", 329 data: { 330 type: "number", 331 value: (rotation + angle) % 360, 332 }, 333 }); 334 }, 335 [props, rep, rect, rotation], 336 ); 337 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); 338 339 let { isLongPress, handlers: longPressHandlers } = useLongPress(() => { 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 let angle = 0; 352 if (rotateHandle.dragDelta) { 353 let originX = rect.x + rect.width / 2; 354 let originY = rect.y + rect.height / 2; 355 356 angle = 357 find_angle( 358 { x: rect.x + rect.width, y: rect.y + rect.height }, 359 { x: originX, y: originY }, 360 { 361 x: rect.x + rect.width + rotateHandle.dragDelta.x, 362 y: rect.y + rect.height + rotateHandle.dragDelta.y, 363 }, 364 ) * 365 (180 / Math.PI); 366 } 367 let x = props.position.x + (dragDelta?.x || 0); 368 let y = props.position.y + (dragDelta?.y || 0); 369 let transform = `translate(${x}px, ${y}px) rotate(${rotation + angle}deg) scale(${!dragDelta ? "1.0" : "1.02"})`; 370 let [areYouSure, setAreYouSure] = useState(false); 371 let blockProps = useMemo(() => { 372 return { 373 pageType: "canvas" as const, 374 preview: props.preview, 375 type: type?.data.value || "text", 376 value: props.entityID, 377 factID: props.factID, 378 position: "", 379 nextPosition: "", 380 entityID: props.entityID, 381 parent: props.parent, 382 nextBlock: null, 383 previousBlock: null, 384 }; 385 }, [props, type?.data.value]); 386 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); 387 let isList = useEntity(props.entityID, "block/is-list"); 388 let isFocused = useUIState( 389 (s) => s.focusedEntity?.entityID === props.entityID, 390 ); 391 392 return ( 393 <div 394 ref={ref} 395 {...(!props.preview ? { ...longPressHandlers } : {})} 396 {...(isMobile && permissions.write ? { ...handlers } : {})} 397 id={props.preview ? undefined : elementId.block(props.entityID).container} 398 className={`absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 399 style={{ 400 top: 0, 401 left: 0, 402 zIndex: dragDelta || isFocused ? 10 : undefined, 403 width: width + (widthHandle.dragDelta?.x || 0), 404 transform, 405 }} 406 > 407 {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} 408 {!props.preview && permissions.write && <Gripper {...handlers} />} 409 <div 410 className={`contents ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 411 > 412 <BaseBlock 413 {...blockProps} 414 listData={ 415 isList?.data.value 416 ? { path: [], parent: props.parent, depth: 1 } 417 : undefined 418 } 419 areYouSure={areYouSure} 420 setAreYouSure={setAreYouSure} 421 /> 422 </div> 423 424 {!props.preview && permissions.write && ( 425 <div 426 className={`resizeHandle 427 cursor-e-resize shrink-0 z-10 428 hidden group-hover/canvas-block:block 429 w-[5px] h-6 -ml-[3px] 430 absolute top-1/2 right-3 -translate-y-1/2 translate-x-[2px] 431 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 432 {...widthHandle.handlers} 433 /> 434 )} 435 436 {!props.preview && permissions.write && ( 437 <div 438 className={`rotateHandle 439 cursor-grab shrink-0 z-10 440 hidden group-hover/canvas-block:block 441 w-[8px] h-[8px] 442 absolute bottom-0 -right-0 443 -translate-y-1/2 -translate-x-1/2 444 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 445 {...rotateHandle.handlers} 446 /> 447 )} 448 </div> 449 ); 450} 451 452export const CanvasBackground = (props: { entityID: string }) => { 453 let cardBackgroundImage = useEntity( 454 props.entityID, 455 "theme/card-background-image", 456 ); 457 let cardBackgroundImageRepeat = useEntity( 458 props.entityID, 459 "theme/card-background-image-repeat", 460 ); 461 let cardBackgroundImageOpacity = 462 useEntity(props.entityID, "theme/card-background-image-opacity")?.data 463 .value || 1; 464 465 let canvasPattern = 466 useEntity(props.entityID, "canvas/background-pattern")?.data.value || 467 "grid"; 468 return ( 469 <div 470 className="w-full h-full pointer-events-none" 471 style={{ 472 backgroundImage: cardBackgroundImage 473 ? `url(${cardBackgroundImage.data.src}), url(${cardBackgroundImage.data.fallback})` 474 : undefined, 475 backgroundRepeat: "repeat", 476 backgroundPosition: "center", 477 backgroundSize: cardBackgroundImageRepeat?.data.value || 500, 478 opacity: cardBackgroundImage?.data.src ? cardBackgroundImageOpacity : 1, 479 }} 480 > 481 <CanvasBackgroundPattern pattern={canvasPattern} /> 482 </div> 483 ); 484}; 485 486export const CanvasBackgroundPattern = (props: { 487 pattern: "grid" | "dot" | "plain"; 488 scale?: number; 489}) => { 490 if (props.pattern === "plain") return null; 491 let patternID = `canvasPattern-${props.pattern}-${props.scale}`; 492 if (props.pattern === "grid") 493 return ( 494 <svg 495 width="100%" 496 height="100%" 497 xmlns="http://www.w3.org/2000/svg" 498 className="pointer-events-none text-border-light" 499 > 500 <defs> 501 <pattern 502 id={patternID} 503 x="0" 504 y="0" 505 width={props.scale ? 32 * props.scale : 32} 506 height={props.scale ? 32 * props.scale : 32} 507 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}`} 508 patternUnits="userSpaceOnUse" 509 > 510 <path 511 fillRule="evenodd" 512 clipRule="evenodd" 513 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" 514 fill="currentColor" 515 /> 516 </pattern> 517 </defs> 518 <rect 519 width="100%" 520 height="100%" 521 x="0" 522 y="0" 523 fill={`url(#${patternID})`} 524 /> 525 </svg> 526 ); 527 528 if (props.pattern === "dot") { 529 return ( 530 <svg 531 width="100%" 532 height="100%" 533 xmlns="http://www.w3.org/2000/svg" 534 className={`pointer-events-none text-border`} 535 > 536 <defs> 537 <pattern 538 id={patternID} 539 x="0" 540 y="0" 541 width={props.scale ? 24 * props.scale : 24} 542 height={props.scale ? 24 * props.scale : 24} 543 patternUnits="userSpaceOnUse" 544 > 545 <circle 546 cx={props.scale ? 12 * props.scale : 12} 547 cy={props.scale ? 12 * props.scale : 12} 548 r="1" 549 fill="currentColor" 550 /> 551 </pattern> 552 </defs> 553 <rect 554 width="100%" 555 height="100%" 556 x="0" 557 y="0" 558 fill={`url(#${patternID})`} 559 /> 560 </svg> 561 ); 562 } 563}; 564 565const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => { 566 return ( 567 <div 568 onMouseDown={props.onMouseDown} 569 onPointerDown={props.onMouseDown} 570 className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none" 571 > 572 <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 "> 573 {/* the gripper is two svg's stacked on top of each other. 574 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} 575 <div 576 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block" 577 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} 578 /> 579 <div 580 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block" 581 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} 582 /> 583 </Media> 584 </div> 585 ); 586}; 587 588type P = { x: number; y: number }; 589function find_angle(P2: P, P1: P, P3: P) { 590 if (P1.x === P3.x && P1.y === P3.y) return 0; 591 let a = Math.atan2(P3.y - P1.y, P3.x - P1.x); 592 let b = Math.atan2(P2.y - P1.y, P2.x - P1.x); 593 return a - b; 594}