[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/
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>