[READ-ONLY] Mirror of https://github.com/flo-bit/jazz-endless-canvas. infinite multiplayer drawing canvas in the browser, jazz, svelte, paper.js flo-bit.dev/jazz-endless-canvas/
0

Configure Feed

Select the types of activity you want to include in your feed.

jazz-endless-canvas / src / routes / Draw.svelte
15 kB 656 lines
1<script lang="ts"> 2 import { onDestroy, onMount } from 'svelte'; 3 import paper from 'paper'; 4 import { Group, type Loaded } from 'jazz-tools'; 5 import { ColorPicker } from '@fuxui/colors'; 6 import { 7 Box, 8 Button, 9 cn, 10 Popover, 11 SliderNumber, 12 Subheading, 13 ToggleGroup, 14 ToggleGroupItem 15 } from '@fuxui/base'; 16 import NumberFlow from '@number-flow/svelte'; 17 18 import { PinchGesture } from '@use-gesture/vanilla'; 19 import { cellSize, gridId, PaintingCell, PaintingPaths, Path, Painting } from '$lib/schema'; 20 21 let { painting }: { painting: Loaded<typeof Painting> } = $props(); 22 23 let moveTool: paper.Tool | null = null; 24 let drawTool: paper.Tool | null = null; 25 26 let path: paper.Path | null = null; 27 let scope: paper.PaperScope | null = null; 28 let currentColor = $state('#ec4899'); 29 30 let loaded = $state(false); 31 32 let gridData: Record< 33 string, 34 { 35 unsubscribe?: () => void; 36 drawnPaths?: Record<string, paper.Path>; 37 group?: paper.Group; 38 } 39 > = $state({}); 40 41 function addSubscription(id: string) { 42 if (!painting || !painting.cells || !painting.cells[id]?.paths || gridData[id]?.unsubscribe) { 43 return; 44 } 45 46 const sub = PaintingPaths.subscribe( 47 painting.cells[id]?.paths.id, 48 { 49 resolve: { 50 $each: true 51 } 52 }, 53 (painting) => { 54 addPath(painting, id); 55 } 56 ); 57 // console.log('added subscription', id); 58 59 gridData[id] ??= {}; 60 61 if (gridData[id].group) { 62 gridData[id].group.visible = true; 63 } 64 65 gridData[id].unsubscribe = sub; 66 } 67 68 function removeSubscription(id: string) { 69 if (!gridData[id]?.unsubscribe) return; 70 71 gridData[id].unsubscribe(); 72 // console.log('removed subscription', id); 73 gridData[id].unsubscribe = undefined; 74 75 if (gridData[id].group) { 76 gridData[id].group.visible = false; 77 } 78 } 79 80 function addPath(painting: Loaded<typeof PaintingPaths>, id: string) { 81 loaded = true; 82 83 gridData[id] ??= {}; 84 gridData[id].drawnPaths ??= {}; 85 gridData[id].group ??= new paper.Group(); 86 87 let drawnPaths = gridData[id].drawnPaths; 88 let group = gridData[id].group; 89 90 for (let path of painting ?? []) { 91 if (!path || drawnPaths[path.id]) continue; 92 93 drawnPaths[path.id] = new paper.Path(); 94 drawnPaths[path.id].strokeColor = path.strokeColor as unknown as paper.Color; 95 drawnPaths[path.id].strokeWidth = path.strokeWidth; 96 97 group.addChild(drawnPaths[path.id]); 98 99 for (let segment of path.segments ?? []) { 100 if (!segment) continue; 101 102 scope?.activate(); 103 104 let segmentPoint = new paper.Segment({ 105 point: new paper.Point(segment.point.x, segment.point.y), 106 handleIn: new paper.Point(segment.handleIn.x, segment.handleIn.y), 107 handleOut: new paper.Point(segment.handleOut.x, segment.handleOut.y) 108 }); 109 110 drawnPaths[path.id].add(segmentPoint); 111 } 112 } 113 } 114 115 function drawGrid() { 116 let color = '#27272a' as unknown as paper.Color; 117 for (let x = -500; x < 500; x++) { 118 let topPoint = new paper.Point((x * cellSize) / 5, cellSize * -100); 119 let bottomPoint = new paper.Point((x * cellSize) / 5, cellSize * 100); 120 let aLine = new paper.Path.Line(topPoint, bottomPoint); 121 aLine.strokeColor = color; 122 aLine.strokeWidth = x % 5 === 0 ? 4 : 1; 123 124 let leftPoint = new paper.Point(cellSize * -100, (x * cellSize) / 5); 125 let rightPoint = new paper.Point(cellSize * 100, (x * cellSize) / 5); 126 let bLine = new paper.Path.Line(leftPoint, rightPoint); 127 bLine.strokeColor = color; 128 bLine.strokeWidth = x % 5 === 0 ? 4 : 1; 129 } 130 } 131 132 $effect(() => { 133 if (!gridData[gridId(0, 0)] && painting && painting.cells) { 134 updatedPosition(); 135 } 136 }); 137 138 onDestroy(() => { 139 for (let id in gridData) { 140 if (gridData[id]?.unsubscribe) { 141 gridData[id].unsubscribe(); 142 delete gridData[id]; 143 } 144 } 145 }); 146 147 function getCellBounds() { 148 let bounds = scope?.view.bounds; 149 if (!bounds) return; 150 151 let topY = Math.floor(bounds.y / cellSize); 152 let bottomY = Math.floor((bounds.y + bounds.height) / cellSize); 153 let leftX = Math.floor(bounds.x / cellSize); 154 let rightX = Math.floor((bounds.x + bounds.width) / cellSize); 155 156 return { 157 top: topY, 158 bottom: bottomY, 159 left: leftX, 160 right: rightX 161 }; 162 } 163 164 function updatedPosition() { 165 // get visible bounds 166 let bounds = getCellBounds(); 167 if (!bounds) return; 168 169 // console.log('updated position', bounds); 170 171 let visibleCells = new Set<string>(); 172 for (let x = bounds.left; x <= bounds.right; x++) { 173 for (let y = bounds.top; y <= bounds.bottom; y++) { 174 addSubscription(gridId(x, y)); 175 visibleCells.add(gridId(x, y)); 176 } 177 } 178 179 for (let id in gridData) { 180 if (!visibleCells.has(id)) { 181 removeSubscription(id); 182 } 183 } 184 185 currentX = (scope?.view.center.x ?? 0) / cellSize; 186 currentY = (scope?.view.center.y ?? 0) / cellSize; 187 } 188 189 function onZoom(delta: number, origin: number[]) { 190 if (!scope) return; 191 192 let newZoom = scope.view.zoom; 193 let oldZoom = scope.view.zoom; 194 195 newZoom = scope.view.zoom + delta; 196 197 newZoom = Math.max(0.1, newZoom); 198 newZoom = Math.min(5, newZoom); 199 200 let beta = oldZoom / newZoom; 201 202 let mousePosition = new paper.Point(origin[0], origin[1]); 203 204 let viewPosition = scope.view.viewToProject(mousePosition); 205 206 let mpos = viewPosition; 207 let ctr = scope.view.center; 208 209 let pc = mpos.subtract(ctr); 210 let offset = mpos.subtract(pc.multiply(beta)).subtract(ctr); 211 212 scope.view.zoom = newZoom; 213 scope.view.center = scope.view.center.add(offset); 214 215 updatedPosition(); 216 } 217 218 onMount(async () => { 219 if (!canvas) { 220 return; 221 } 222 223 setInterval(() => { 224 updatedPosition(); 225 }, 2000); 226 227 document.addEventListener('gesturestart', (e) => e.preventDefault()); 228 document.addEventListener('gesturechange', (e) => e.preventDefault()); 229 230 scope = new paper.PaperScope(); 231 scope.setup(canvas); 232 drawTool = new paper.Tool(); 233 234 let move = 40; 235 window.addEventListener('keydown', (e) => { 236 // arrow keys for moving 237 if (e.key === 'ArrowUp') { 238 scope?.view.translate(new paper.Point(0, move)); 239 } else if (e.key === 'ArrowDown') { 240 scope?.view.translate(new paper.Point(0, -move)); 241 } else if (e.key === 'ArrowLeft') { 242 scope?.view.translate(new paper.Point(move, 0)); 243 } else if (e.key === 'ArrowRight') { 244 scope?.view.translate(new paper.Point(-move, 0)); 245 } 246 247 // same for w a s d 248 if (e.code === 'KeyW') { 249 scope?.view.translate(new paper.Point(0, move)); 250 } else if (e.code === 'KeyS') { 251 scope?.view.translate(new paper.Point(0, -move)); 252 } else if (e.code === 'KeyA') { 253 scope?.view.translate(new paper.Point(move, 0)); 254 } else if (e.code === 'KeyD') { 255 scope?.view.translate(new paper.Point(-move, 0)); 256 } 257 }); 258 259 const gesture = new PinchGesture(canvas, (e) => { 260 isOpen = false; 261 if (!scope) return; 262 onZoom(e.delta[0], e.origin); 263 }); 264 265 drawTool.onMouseDown = (event: paper.ToolEvent) => { 266 if (path) { 267 finishPath(); 268 return; 269 } 270 isOpen = false; 271 272 scope?.activate(); 273 274 path = new paper.Path(); 275 path.strokeColor = currentColor as unknown as paper.Color; 276 path.strokeWidth = strokeWidth; 277 278 path.add(event.point); 279 }; 280 281 drawTool.onMouseDrag = (event: paper.ToolEvent) => { 282 isOpen = false; 283 284 if (path === null) { 285 return; 286 } 287 scope?.activate(); 288 289 path.add(event.point); 290 }; 291 292 drawTool.onMouseUp = (event: paper.ToolEvent) => { 293 finishPath(); 294 }; 295 296 moveTool = new paper.Tool(); 297 298 moveTool.onMouseDrag = function (event: any) { 299 if (!scope) return; 300 301 var pan_offset = event.point.subtract(event.downPoint); 302 scope.view.center = scope.view.center.subtract(pan_offset); 303 304 updatedPosition(); 305 }; 306 307 scope.tool = moveTool; 308 309 drawGrid(); 310 }); 311 312 let canvas: HTMLCanvasElement | null = null; 313 314 let color = $state({ 315 r: 0.92, 316 g: 0.28, 317 b: 0.6 318 }); 319 320 let strokeWidth = $state(3); 321 322 let selectedTool = $state('move'); 323 324 let isOpen = $state(false); 325 326 function finishPath() { 327 if (!path) return; 328 329 scope?.activate(); 330 331 path.simplify(10); 332 333 const group = Group.create(); 334 group.addMember('everyone', 'writer'); 335 336 let coPath = Path.create( 337 { 338 segments: path.segments.map((segment) => { 339 return { 340 point: { 341 x: segment.point.x, 342 y: segment.point.y 343 }, 344 handleIn: { 345 x: segment.handleIn.x, 346 y: segment.handleIn.y 347 }, 348 handleOut: { 349 x: segment.handleOut.x, 350 y: segment.handleOut.y 351 } 352 }; 353 }), 354 strokeColor: currentColor, 355 strokeWidth: strokeWidth 356 }, 357 group 358 ); 359 360 let gridIdX = Math.floor(path.segments[0].point.x / cellSize); 361 let gridIdY = Math.floor(path.segments[0].point.y / cellSize); 362 363 let id = gridId(gridIdX, gridIdY); 364 365 gridData[id] ??= {}; 366 gridData[id].drawnPaths ??= {}; 367 gridData[id].drawnPaths[coPath.id] = path; 368 369 if (!painting.cells) return; 370 371 if (!painting.cells[id]) { 372 painting.cells[id] = PaintingCell.create( 373 { 374 paths: PaintingPaths.create([], group) 375 }, 376 group 377 ); 378 } 379 painting.cells[id]?.paths?.push(coPath); 380 381 path = null; 382 } 383 384 let currentX = $state(0.0001); 385 let currentY = $state(0.0001); 386</script> 387 388<div class="fixed right-2 bottom-2 z-20 flex flex-col gap-3"></div> 389 390<canvas 391 bind:this={canvas} 392 class={cn( 393 'fixed h-screen w-screen', 394 selectedTool === 'move' ? 'cursor-grab' : 'cursor-crosshair' 395 )} 396 data-paper-resize="true" 397></canvas> 398 399<div class="fixed top-2 left-2 z-20 hidden"> 400 <Button 401 onclick={() => { 402 const gridIds = Object.keys(gridData); 403 const randomGridId = gridIds[Math.floor(Math.random() * gridIds.length)]; 404 405 if (!gridData[randomGridId]) return; 406 407 let drawnPaths = gridData[randomGridId].drawnPaths; 408 if (!drawnPaths) return; 409 410 // jump to random drawing 411 const numDrawnPaths = Object.keys(drawnPaths).length; 412 const randomIndex = Math.floor(Math.random() * numDrawnPaths); 413 414 const randomPathId = Object.keys(drawnPaths)[randomIndex]; 415 const point = drawnPaths[randomPathId].segments[0].point; 416 417 // scope?.view.center.set(new paper.Point(point.x, point.y)); 418 // get current translation 419 const translation = scope?.view.center; 420 // console.log(translation); 421 if (translation) { 422 scope?.view.translate(new paper.Point(translation.x - point.x, translation.y - point.y)); 423 424 // scale to 1 425 // const currentScale = scope?.view.scaling ?? 1; 426 // scope?.view.scale(1 / currentScale, new paper.Point(point.x, point.y)); 427 } 428 }} 429 > 430 Random point 431 </Button> 432 433 <Button 434 onclick={() => { 435 // add 100 random paths 436 let paths = []; 437 for (let i = 0; i < 100000; i++) { 438 const path = new paper.Path(); 439 path.strokeColor = currentColor as unknown as paper.Color; 440 path.strokeWidth = strokeWidth; 441 let rx = Math.random() * 10000 - 5000; 442 let ry = Math.random() * 10000 - 5000; 443 path.add(new paper.Point(rx, ry)); 444 path.add(new paper.Point(rx + 50, ry + 50)); 445 paths.push(path); 446 447 continue; 448 449 const group = Group.create(); 450 group.addMember('everyone', 'writer'); 451 452 let coPath = Path.create( 453 { 454 segments: path.segments.map((segment) => { 455 return { 456 point: { 457 x: segment.point.x, 458 y: segment.point.y 459 }, 460 handleIn: { 461 x: segment.handleIn.x, 462 y: segment.handleIn.y 463 }, 464 handleOut: { 465 x: segment.handleOut.x, 466 y: segment.handleOut.y 467 } 468 }; 469 }), 470 strokeColor: currentColor, 471 strokeWidth: strokeWidth 472 }, 473 group 474 ); 475 476 painting?.paths?.push(coPath); 477 } 478 479 //create new group 480 const pGroup = new paper.Group(paths); 481 }} 482 > 483 Add lots 484 </Button> 485</div> 486 487<div class="fixed top-2 left-2 z-20 p-0"> 488 <Box class="px-1 py-1"> 489 <div class="bg-base-800/50 rounded-2xl px-2"> 490 <NumberFlow 491 value={currentX} 492 format={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }} 493 /> / 494 <NumberFlow 495 value={currentY} 496 format={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }} 497 /> 498 </div> 499 </Box> 500</div> 501{#if !painting || !loaded} 502 <div class="pointer-events-none fixed inset-0 flex h-full w-full items-center justify-center"> 503 <Subheading>Loading...</Subheading> 504 </div> 505{/if} 506 507<div class="absolute right-2 bottom-2"> 508 <div class="bg-base-900 flex items-center justify-center gap-4 rounded-2xl p-2"> 509 <ToggleGroup 510 type="single" 511 bind:value={ 512 () => { 513 return selectedTool; 514 }, 515 (value) => { 516 if (!value) return; 517 518 selectedTool = value; 519 if (!scope) return; 520 if (selectedTool === 'move') { 521 scope.tool = moveTool!; 522 } else { 523 scope.tool = drawTool!; 524 } 525 } 526 } 527 > 528 <!-- move --> 529 <ToggleGroupItem value="move"> 530 <svg 531 xmlns="http://www.w3.org/2000/svg" 532 fill="none" 533 viewBox="0 0 24 24" 534 stroke-width="1.5" 535 stroke="currentColor" 536 class="size-6" 537 > 538 <path 539 stroke-linecap="round" 540 stroke-linejoin="round" 541 d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" 542 /> 543 </svg> 544 </ToggleGroupItem> 545 546 <!-- rotate --> 547 <ToggleGroupItem value="paint"> 548 <svg 549 xmlns="http://www.w3.org/2000/svg" 550 fill="none" 551 viewBox="0 0 24 24" 552 stroke-width="1.5" 553 stroke="currentColor" 554 class="size-6" 555 > 556 <path 557 stroke-linecap="round" 558 stroke-linejoin="round" 559 d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" 560 /> 561 </svg> 562 </ToggleGroupItem> 563 </ToggleGroup> 564 565 <Popover side="bottom" bind:open={isOpen}> 566 {#snippet child({ props })} 567 <button 568 {...props} 569 class="bg-base-400/30 border-base-700/30 dark:border-base-400 flex size-8 cursor-pointer items-center justify-center overflow-hidden rounded-2xl border shadow-md backdrop-blur-sm" 570 > 571 <div 572 class="size-8 rounded-2xl" 573 style="background-color: rgb({color.r * 255}, {color.g * 255}, {color.b * 255})" 574 ></div> 575 </button> 576 {/snippet} 577 <Subheading class="mb-2 text-sm sm:text-base">Stroke Width</Subheading> 578 579 <SliderNumber 580 type="single" 581 bind:value={strokeWidth} 582 min={1} 583 max={10} 584 step={1} 585 class="min-w-36" 586 /> 587 588 <Subheading class="mt-6 mb-2 text-sm sm:text-base">Color</Subheading> 589 590 <ColorPicker 591 bind:rgb={color} 592 onchange={(color) => { 593 currentColor = color.hex; 594 }} 595 quickSelects={[ 596 { 597 label: 'white', 598 rgb: { 599 r: 1, 600 g: 1, 601 b: 1 602 } 603 }, 604 { 605 label: 'black', 606 rgb: { 607 r: 0, 608 g: 0, 609 b: 0 610 } 611 }, 612 { 613 label: 'red', 614 rgb: { 615 r: 0.93, 616 g: 0.26, 617 b: 0.26 618 } 619 }, 620 { 621 label: 'blue', 622 rgb: { 623 r: 0.23, 624 g: 0.5, 625 b: 0.96 626 } 627 }, 628 { 629 label: 'green', 630 rgb: { 631 r: 0.13, 632 g: 0.77, 633 b: 0.36 634 } 635 }, 636 { 637 label: 'yellow', 638 rgb: { 639 r: 0.91, 640 g: 0.7, 641 b: 0.03 642 } 643 }, 644 { 645 label: 'pink', 646 rgb: { 647 r: 0.92, 648 g: 0.28, 649 b: 0.6 650 } 651 } 652 ]} 653 /> 654 </Popover> 655 </div> 656</div>