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