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