a tool for shared writing and social publishing
at feature/post-options 597 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 { 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 let showMentions = pubRecord.preferences?.showMentions; 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 {showComments && ( 178 <div className="flex gap-1 text-tertiary items-center"> 179 <CommentTiny className="text-border" /> 180 </div> 181 )} 182 {showComments && ( 183 <div className="flex gap-1 text-tertiary items-center"> 184 <QuoteTiny className="text-border" /> 185 </div> 186 )} 187 188 {!props.isSubpage && ( 189 <> 190 <Separator classname="h-5" /> 191 <Popover 192 side="left" 193 align="start" 194 className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]" 195 trigger={<InfoSmall />} 196 > 197 <PublicationMetadata /> 198 </Popover> 199 </> 200 )} 201 </div> 202 ); 203}; 204 205const AddCanvasBlockButton = (props: { 206 entityID: string; 207 entity_set: { set: string }; 208}) => { 209 let { rep } = useReplicache(); 210 let { permissions } = useEntitySetContext(); 211 let blocks = useEntity(props.entityID, "canvas/block"); 212 213 if (!permissions.write) return null; 214 return ( 215 <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"> 216 <TooltipButton 217 side="left" 218 open={blocks.length === 0 ? true : undefined} 219 tooltipContent={ 220 <div className="flex flex-col justify-end text-center px-1 leading-snug "> 221 <div>Add a Block!</div> 222 <div className="font-normal">or double click anywhere</div> 223 </div> 224 } 225 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" 226 onMouseDown={() => { 227 let page = document.getElementById( 228 elementId.page(props.entityID).canvasScrollArea, 229 ); 230 if (!page) return; 231 let newEntityID = v7(); 232 rep?.mutate.addCanvasBlock({ 233 newEntityID, 234 parent: props.entityID, 235 position: { 236 x: page?.clientWidth + page?.scrollLeft - 468, 237 y: 32 + page.scrollTop, 238 }, 239 factID: v7(), 240 type: "text", 241 permission_set: props.entity_set.set, 242 }); 243 setTimeout(() => { 244 focusBlock( 245 { type: "text", value: newEntityID, parent: props.entityID }, 246 { type: "start" }, 247 ); 248 }, 20); 249 }} 250 > 251 <AddSmall /> 252 </TooltipButton> 253 </div> 254 ); 255}; 256 257function CanvasBlock(props: { 258 preview?: boolean; 259 entityID: string; 260 parent: string; 261 position: { x: number; y: number }; 262 factID: string; 263}) { 264 let width = 265 useEntity(props.entityID, "canvas/block/width")?.data.value || 360; 266 let rotation = 267 useEntity(props.entityID, "canvas/block/rotation")?.data.value || 0; 268 let [ref, rect] = useMeasure(); 269 let type = useEntity(props.entityID, "block/type"); 270 let { rep } = useReplicache(); 271 let isMobile = useIsMobile(); 272 273 let { permissions } = useEntitySetContext(); 274 let onDragEnd = useCallback( 275 (dragPosition: { x: number; y: number }) => { 276 if (!permissions.write) return; 277 rep?.mutate.assertFact({ 278 id: props.factID, 279 entity: props.parent, 280 attribute: "canvas/block", 281 data: { 282 type: "spatial-reference", 283 value: props.entityID, 284 position: { 285 x: props.position.x + dragPosition.x, 286 y: props.position.y + dragPosition.y, 287 }, 288 }, 289 }); 290 }, 291 [props, rep, permissions], 292 ); 293 let { dragDelta, handlers } = useDrag({ 294 onDragEnd, 295 delay: isMobile, 296 }); 297 298 let widthOnDragEnd = useCallback( 299 (dragPosition: { x: number; y: number }) => { 300 rep?.mutate.assertFact({ 301 entity: props.entityID, 302 attribute: "canvas/block/width", 303 data: { 304 type: "number", 305 value: width + dragPosition.x, 306 }, 307 }); 308 }, 309 [props, rep, width], 310 ); 311 let widthHandle = useDrag({ onDragEnd: widthOnDragEnd }); 312 313 let RotateOnDragEnd = useCallback( 314 (dragDelta: { x: number; y: number }) => { 315 let originX = rect.x + rect.width / 2; 316 let originY = rect.y + rect.height / 2; 317 318 let angle = 319 find_angle( 320 { x: rect.x + rect.width, y: rect.y + rect.height }, 321 { x: originX, y: originY }, 322 { 323 x: rect.x + rect.width + dragDelta.x, 324 y: rect.y + rect.height + dragDelta.y, 325 }, 326 ) * 327 (180 / Math.PI); 328 329 rep?.mutate.assertFact({ 330 entity: props.entityID, 331 attribute: "canvas/block/rotation", 332 data: { 333 type: "number", 334 value: (rotation + angle) % 360, 335 }, 336 }); 337 }, 338 [props, rep, rect, rotation], 339 ); 340 let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); 341 342 let { isLongPress, handlers: longPressHandlers } = useLongPress(() => { 343 if (isLongPress.current && permissions.write) { 344 focusBlock( 345 { 346 type: type?.data.value || "text", 347 value: props.entityID, 348 parent: props.parent, 349 }, 350 { type: "start" }, 351 ); 352 } 353 }); 354 let angle = 0; 355 if (rotateHandle.dragDelta) { 356 let originX = rect.x + rect.width / 2; 357 let originY = rect.y + rect.height / 2; 358 359 angle = 360 find_angle( 361 { x: rect.x + rect.width, y: rect.y + rect.height }, 362 { x: originX, y: originY }, 363 { 364 x: rect.x + rect.width + rotateHandle.dragDelta.x, 365 y: rect.y + rect.height + rotateHandle.dragDelta.y, 366 }, 367 ) * 368 (180 / Math.PI); 369 } 370 let x = props.position.x + (dragDelta?.x || 0); 371 let y = props.position.y + (dragDelta?.y || 0); 372 let transform = `translate(${x}px, ${y}px) rotate(${rotation + angle}deg) scale(${!dragDelta ? "1.0" : "1.02"})`; 373 let [areYouSure, setAreYouSure] = useState(false); 374 let blockProps = useMemo(() => { 375 return { 376 pageType: "canvas" as const, 377 preview: props.preview, 378 type: type?.data.value || "text", 379 value: props.entityID, 380 factID: props.factID, 381 position: "", 382 nextPosition: "", 383 entityID: props.entityID, 384 parent: props.parent, 385 nextBlock: null, 386 previousBlock: null, 387 }; 388 }, [props, type?.data.value]); 389 useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); 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 } : {})} 399 {...(isMobile && permissions.write ? { ...handlers } : {})} 400 id={props.preview ? undefined : elementId.block(props.entityID).container} 401 className={`absolute group/canvas-block will-change-transform rounded-lg flex items-stretch origin-center p-3 `} 402 style={{ 403 top: 0, 404 left: 0, 405 zIndex: dragDelta || isFocused ? 10 : undefined, 406 width: width + (widthHandle.dragDelta?.x || 0), 407 transform, 408 }} 409 > 410 {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} 411 {!props.preview && permissions.write && <Gripper {...handlers} />} 412 <div 413 className={`contents ${dragDelta || widthHandle.dragDelta || rotateHandle.dragDelta ? "pointer-events-none" : ""} `} 414 > 415 <BaseBlock 416 {...blockProps} 417 listData={ 418 isList?.data.value 419 ? { path: [], parent: props.parent, depth: 1 } 420 : undefined 421 } 422 areYouSure={areYouSure} 423 setAreYouSure={setAreYouSure} 424 /> 425 </div> 426 427 {!props.preview && permissions.write && ( 428 <div 429 className={`resizeHandle 430 cursor-e-resize shrink-0 z-10 431 hidden group-hover/canvas-block:block 432 w-[5px] h-6 -ml-[3px] 433 absolute top-1/2 right-3 -translate-y-1/2 translate-x-[2px] 434 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 435 {...widthHandle.handlers} 436 /> 437 )} 438 439 {!props.preview && permissions.write && ( 440 <div 441 className={`rotateHandle 442 cursor-grab shrink-0 z-10 443 hidden group-hover/canvas-block:block 444 w-[8px] h-[8px] 445 absolute bottom-0 -right-0 446 -translate-y-1/2 -translate-x-1/2 447 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]`} 448 {...rotateHandle.handlers} 449 /> 450 )} 451 </div> 452 ); 453} 454 455export const CanvasBackground = (props: { entityID: string }) => { 456 let cardBackgroundImage = useEntity( 457 props.entityID, 458 "theme/card-background-image", 459 ); 460 let cardBackgroundImageRepeat = useEntity( 461 props.entityID, 462 "theme/card-background-image-repeat", 463 ); 464 let cardBackgroundImageOpacity = 465 useEntity(props.entityID, "theme/card-background-image-opacity")?.data 466 .value || 1; 467 468 let canvasPattern = 469 useEntity(props.entityID, "canvas/background-pattern")?.data.value || 470 "grid"; 471 return ( 472 <div 473 className="w-full h-full pointer-events-none" 474 style={{ 475 backgroundImage: cardBackgroundImage 476 ? `url(${cardBackgroundImage.data.src}), url(${cardBackgroundImage.data.fallback})` 477 : undefined, 478 backgroundRepeat: "repeat", 479 backgroundPosition: "center", 480 backgroundSize: cardBackgroundImageRepeat?.data.value || 500, 481 opacity: cardBackgroundImage?.data.src ? cardBackgroundImageOpacity : 1, 482 }} 483 > 484 <CanvasBackgroundPattern pattern={canvasPattern} /> 485 </div> 486 ); 487}; 488 489export const CanvasBackgroundPattern = (props: { 490 pattern: "grid" | "dot" | "plain"; 491 scale?: number; 492}) => { 493 if (props.pattern === "plain") return null; 494 let patternID = `canvasPattern-${props.pattern}-${props.scale}`; 495 if (props.pattern === "grid") 496 return ( 497 <svg 498 width="100%" 499 height="100%" 500 xmlns="http://www.w3.org/2000/svg" 501 className="pointer-events-none text-border-light" 502 > 503 <defs> 504 <pattern 505 id={patternID} 506 x="0" 507 y="0" 508 width={props.scale ? 32 * props.scale : 32} 509 height={props.scale ? 32 * props.scale : 32} 510 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}`} 511 patternUnits="userSpaceOnUse" 512 > 513 <path 514 fillRule="evenodd" 515 clipRule="evenodd" 516 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" 517 fill="currentColor" 518 /> 519 </pattern> 520 </defs> 521 <rect 522 width="100%" 523 height="100%" 524 x="0" 525 y="0" 526 fill={`url(#${patternID})`} 527 /> 528 </svg> 529 ); 530 531 if (props.pattern === "dot") { 532 return ( 533 <svg 534 width="100%" 535 height="100%" 536 xmlns="http://www.w3.org/2000/svg" 537 className={`pointer-events-none text-border`} 538 > 539 <defs> 540 <pattern 541 id={patternID} 542 x="0" 543 y="0" 544 width={props.scale ? 24 * props.scale : 24} 545 height={props.scale ? 24 * props.scale : 24} 546 patternUnits="userSpaceOnUse" 547 > 548 <circle 549 cx={props.scale ? 12 * props.scale : 12} 550 cy={props.scale ? 12 * props.scale : 12} 551 r="1" 552 fill="currentColor" 553 /> 554 </pattern> 555 </defs> 556 <rect 557 width="100%" 558 height="100%" 559 x="0" 560 y="0" 561 fill={`url(#${patternID})`} 562 /> 563 </svg> 564 ); 565 } 566}; 567 568const Gripper = (props: { onMouseDown: (e: React.MouseEvent) => void }) => { 569 return ( 570 <div 571 onMouseDown={props.onMouseDown} 572 onPointerDown={props.onMouseDown} 573 className="w-[9px] shrink-0 py-1 mr-1 bg-bg-card cursor-grab touch-none" 574 > 575 <Media mobile={false} className="h-full grid grid-cols-1 grid-rows-1 "> 576 {/* the gripper is two svg's stacked on top of each other. 577 One for the actual gripper, the other is an outline to endure the gripper stays visible on image backgrounds */} 578 <div 579 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-bg-page hidden group-hover/canvas-block:block" 580 style={{ maskImage: "var(--gripperSVG2)", maskRepeat: "repeat" }} 581 /> 582 <div 583 className="h-full col-start-1 col-end-2 row-start-1 row-end-2 bg-tertiary hidden group-hover/canvas-block:block" 584 style={{ maskImage: "var(--gripperSVG)", maskRepeat: "repeat" }} 585 /> 586 </Media> 587 </div> 588 ); 589}; 590 591type P = { x: number; y: number }; 592function find_angle(P2: P, P1: P, P3: P) { 593 if (P1.x === P3.x && P1.y === P3.y) return 0; 594 let a = Math.atan2(P3.y - P1.y, P3.x - P1.x); 595 let b = Math.atan2(P2.y - P1.y, P2.x - P1.x); 596 return a - b; 597}