penseat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,632 @@
1
+ "use client";
2
+
3
+ import {
4
+ useRef,
5
+ useState,
6
+ useEffect,
7
+ useCallback,
8
+ useImperativeHandle,
9
+ forwardRef,
10
+ } from "react";
11
+
12
+ export interface Stroke {
13
+ points: { x: number; y: number; time: number }[];
14
+ color: string;
15
+ width: number;
16
+ }
17
+
18
+ export interface DrawingCanvasHandle {
19
+ undo: () => void;
20
+ clear: () => void;
21
+ getCanvas: () => HTMLCanvasElement | null;
22
+ hasStrokes: () => boolean;
23
+ }
24
+
25
+ interface DrawingCanvasProps {
26
+ active: boolean;
27
+ color: string;
28
+ strokeWidth?: number;
29
+ }
30
+
31
+ // ── Constants ──
32
+ const FIBERS = 5;
33
+ const DRY_DURATION = 1200;
34
+ const DRY_ALPHA = 1.0; // fully opaque settled marker
35
+ const WET_OVERLAY_ALPHA = 0.50; // extra density at peak wetness
36
+ const WET_WIDTH_BOOST = 0.12; // 12% wider when wet
37
+ const WET_COLOR_DARKEN = 0.10; // 10% darker when wet
38
+ const FIBER_CUT_ALPHA = 0; // fiber streaks disabled
39
+ const ERASER_COLOR = "#ffffff";
40
+ const ERASER_RADIUS = 12;
41
+
42
+ // Wetness band edges — 0 = fully dry, 1 = freshest
43
+ const WETNESS_BANDS = [0.00, 0.18, 0.40, 0.68, 0.88, 1.00];
44
+
45
+ // ── Helpers (outside component — no allocations per render) ──
46
+
47
+ // Smoothstep wetness: 1 = just drawn, 0 = fully dry
48
+ function wetness(pointTime: number, now: number): number {
49
+ const x = 1 - (now - pointTime) / DRY_DURATION;
50
+ const c = x < 0 ? 0 : x > 1 ? 1 : x;
51
+ return c * c * (3 - 2 * c);
52
+ }
53
+
54
+ // Darken hex color by factor (0 = original, 1 = WET_COLOR_DARKEN% darker)
55
+ function darkenHex(hex: string, factor: number): string {
56
+ const r = parseInt(hex.slice(1, 3), 16);
57
+ const g = parseInt(hex.slice(3, 5), 16);
58
+ const b = parseInt(hex.slice(5, 7), 16);
59
+ const f = 1 - factor * WET_COLOR_DARKEN;
60
+ return `rgb(${Math.round(r * f)},${Math.round(g * f)},${Math.round(b * f)})`;
61
+ }
62
+
63
+ // Binary search: first index where points[i].time >= t
64
+ function lowerBound(points: { time: number }[], t: number): number {
65
+ let lo = 0, hi = points.length;
66
+ while (lo < hi) {
67
+ const mid = (lo + hi) >>> 1;
68
+ if (points[mid].time < t) lo = mid + 1;
69
+ else hi = mid;
70
+ }
71
+ return lo;
72
+ }
73
+
74
+ const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
75
+ function DrawingCanvas({ active, color, strokeWidth = 5 }, ref) {
76
+ const canvasRef = useRef<HTMLCanvasElement>(null);
77
+ const cacheCanvasRef = useRef<HTMLCanvasElement | null>(null);
78
+ const strokesRef = useRef<Stroke[]>([]);
79
+ const currentStrokeRef = useRef<Stroke | null>(null);
80
+ const isDrawingRef = useRef(false);
81
+ const dryingRef = useRef<Stroke[]>([]);
82
+ const animRafRef = useRef<number>(0);
83
+ const eraserCursorRef = useRef<HTMLDivElement>(null);
84
+ const tmpCanvasRef = useRef<HTMLCanvasElement | null>(null);
85
+
86
+ function getCacheCanvas(): HTMLCanvasElement {
87
+ const main = canvasRef.current!;
88
+ if (
89
+ !cacheCanvasRef.current ||
90
+ cacheCanvasRef.current.width !== main.width ||
91
+ cacheCanvasRef.current.height !== main.height
92
+ ) {
93
+ const c = document.createElement("canvas");
94
+ c.width = main.width;
95
+ c.height = main.height;
96
+ cacheCanvasRef.current = c;
97
+ }
98
+ return cacheCanvasRef.current;
99
+ }
100
+
101
+ function getTmpCanvas(): HTMLCanvasElement {
102
+ const main = canvasRef.current!;
103
+ if (
104
+ !tmpCanvasRef.current ||
105
+ tmpCanvasRef.current.width !== main.width ||
106
+ tmpCanvasRef.current.height !== main.height
107
+ ) {
108
+ const c = document.createElement("canvas");
109
+ c.width = main.width;
110
+ c.height = main.height;
111
+ tmpCanvasRef.current = c;
112
+ }
113
+ return tmpCanvasRef.current;
114
+ }
115
+
116
+ const resizeCanvas = useCallback(() => {
117
+ const canvas = canvasRef.current;
118
+ if (!canvas) return;
119
+
120
+ const dpr = window.devicePixelRatio || 1;
121
+ const docWidth = Math.max(
122
+ document.documentElement.scrollWidth,
123
+ document.body.scrollWidth,
124
+ window.innerWidth
125
+ );
126
+ const docHeight = Math.max(
127
+ document.documentElement.scrollHeight,
128
+ document.body.scrollHeight,
129
+ window.innerHeight
130
+ );
131
+
132
+ canvas.width = docWidth * dpr;
133
+ canvas.height = docHeight * dpr;
134
+ canvas.style.width = `${docWidth}px`;
135
+ canvas.style.height = `${docHeight}px`;
136
+
137
+ cacheCanvasRef.current = null;
138
+ tmpCanvasRef.current = null;
139
+ rebuildCache();
140
+ compositeToScreen(performance.now());
141
+ }, []);
142
+
143
+ // ── Path tracing ──
144
+
145
+ function tracePath(
146
+ ctx: CanvasRenderingContext2D,
147
+ points: { x: number; y: number }[]
148
+ ) {
149
+ if (points.length < 2) return;
150
+ ctx.beginPath();
151
+ ctx.moveTo(points[0].x, points[0].y);
152
+ for (let i = 1; i < points.length - 1; i++) {
153
+ const curr = points[i];
154
+ const next = points[i + 1];
155
+ const midX = (curr.x + next.x) / 2;
156
+ const midY = (curr.y + next.y) / 2;
157
+ ctx.quadraticCurveTo(curr.x, curr.y, midX, midY);
158
+ }
159
+ ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
160
+ }
161
+
162
+ function traceOffsetPath(
163
+ ctx: CanvasRenderingContext2D,
164
+ points: { x: number; y: number }[],
165
+ offset: number
166
+ ) {
167
+ if (points.length < 2) return;
168
+ ctx.beginPath();
169
+ const p0 = points[0];
170
+ const p1 = points[1];
171
+ const angle0 = Math.atan2(p1.y - p0.y, p1.x - p0.x);
172
+ ctx.moveTo(
173
+ p0.x + Math.cos(angle0 + Math.PI / 2) * offset,
174
+ p0.y + Math.sin(angle0 + Math.PI / 2) * offset
175
+ );
176
+ for (let i = 1; i < points.length - 1; i++) {
177
+ const curr = points[i];
178
+ const next = points[i + 1];
179
+ const angle = Math.atan2(next.y - curr.y, next.x - curr.x);
180
+ const ox = Math.cos(angle + Math.PI / 2) * offset;
181
+ const oy = Math.sin(angle + Math.PI / 2) * offset;
182
+ const midX = (curr.x + next.x) / 2 + ox;
183
+ const midY = (curr.y + next.y) / 2 + oy;
184
+ ctx.quadraticCurveTo(curr.x + ox, curr.y + oy, midX, midY);
185
+ }
186
+ const last = points[points.length - 1];
187
+ const prev = points[points.length - 2];
188
+ const angleLast = Math.atan2(last.y - prev.y, last.x - prev.x);
189
+ ctx.lineTo(
190
+ last.x + Math.cos(angleLast + Math.PI / 2) * offset,
191
+ last.y + Math.sin(angleLast + Math.PI / 2) * offset
192
+ );
193
+ }
194
+
195
+ // ── Drawing primitives ──
196
+
197
+ // Core: draw base stroke + fiber cuts at given alpha
198
+ function drawFibersCore(
199
+ ctx: CanvasRenderingContext2D,
200
+ points: { x: number; y: number }[],
201
+ strokeColor: string,
202
+ w: number,
203
+ baseAlpha: number = DRY_ALPHA
204
+ ) {
205
+ if (points.length < 2) return;
206
+ ctx.save();
207
+
208
+ // Base stroke — round caps for smooth ends
209
+ ctx.globalAlpha = baseAlpha;
210
+ ctx.strokeStyle = strokeColor;
211
+ ctx.lineCap = "round";
212
+ ctx.lineJoin = "round";
213
+ ctx.lineWidth = w;
214
+ tracePath(ctx, points);
215
+ ctx.stroke();
216
+
217
+ // Fiber gaps — thin transparent lines cut through for streak texture
218
+ const fiberWidth = w / FIBERS;
219
+ for (let f = 1; f < FIBERS; f++) {
220
+ const offset = (f - FIBERS / 2) * fiberWidth;
221
+ ctx.globalCompositeOperation = "destination-out";
222
+ ctx.globalAlpha = FIBER_CUT_ALPHA;
223
+ ctx.lineCap = "butt";
224
+ ctx.lineJoin = "round";
225
+ ctx.lineWidth = fiberWidth * 0.3;
226
+ traceOffsetPath(ctx, points, offset);
227
+ ctx.stroke();
228
+ ctx.globalCompositeOperation = "source-over";
229
+ }
230
+
231
+ ctx.restore();
232
+ }
233
+
234
+ // Convenience: draw fibers for a full Stroke at dry alpha
235
+ function drawFibers(ctx: CanvasRenderingContext2D, stroke: Stroke) {
236
+ drawFibersCore(ctx, stroke.points, stroke.color, stroke.width);
237
+ }
238
+
239
+ // ── Cache management ──
240
+
241
+ function rebuildCache() {
242
+ const main = canvasRef.current;
243
+ if (!main) return;
244
+ const cache = getCacheCanvas();
245
+ const ctx = cache.getContext("2d")!;
246
+ const dpr = window.devicePixelRatio || 1;
247
+
248
+ ctx.clearRect(0, 0, cache.width, cache.height);
249
+ ctx.scale(dpr, dpr);
250
+ for (const stroke of strokesRef.current) {
251
+ if (!dryingRef.current.includes(stroke)) {
252
+ drawFibers(ctx, stroke);
253
+ }
254
+ }
255
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
256
+ }
257
+
258
+ function appendToCache(stroke: Stroke) {
259
+ const cache = getCacheCanvas();
260
+ const ctx = cache.getContext("2d")!;
261
+ const dpr = window.devicePixelRatio || 1;
262
+ ctx.scale(dpr, dpr);
263
+ drawFibers(ctx, stroke);
264
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
265
+ }
266
+
267
+ // ── Drying renderer ──
268
+ //
269
+ // Two-pass approach:
270
+ // 1. Draw dry base (with fibers) — the settled marker look
271
+ // 2. Layer wet overlay bands on top (no fibers, wider, darker)
272
+ //
273
+ // Fresh ink covers the fiber streaks naturally.
274
+ // As wetness recedes, fibers are gradually revealed — the "drying" wave.
275
+
276
+ function drawDryingStroke(
277
+ destCtx: CanvasRenderingContext2D,
278
+ stroke: Stroke,
279
+ now: number,
280
+ dpr: number
281
+ ) {
282
+ const pts = stroke.points;
283
+ if (pts.length < 2) return;
284
+
285
+ const tmp = getTmpCanvas();
286
+ const tmpCtx = tmp.getContext("2d")!;
287
+
288
+ // Bounding box for efficient clear/blit
289
+ let minX = Infinity,
290
+ minY = Infinity,
291
+ maxX = -Infinity,
292
+ maxY = -Infinity;
293
+ for (const p of pts) {
294
+ if (p.x < minX) minX = p.x;
295
+ if (p.y < minY) minY = p.y;
296
+ if (p.x > maxX) maxX = p.x;
297
+ if (p.y > maxY) maxY = p.y;
298
+ }
299
+ const maxW = stroke.width * (1 + WET_WIDTH_BOOST);
300
+ const pad = maxW * 2;
301
+ const bx = Math.max(0, Math.floor((minX - pad) * dpr));
302
+ const by = Math.max(0, Math.floor((minY - pad) * dpr));
303
+ const bx2 = Math.min(tmp.width, Math.ceil((maxX + pad) * dpr));
304
+ const by2 = Math.min(tmp.height, Math.ceil((maxY + pad) * dpr));
305
+ const bw = bx2 - bx;
306
+ const bh = by2 - by;
307
+ if (bw <= 0 || bh <= 0) return;
308
+
309
+ tmpCtx.clearRect(bx, by, bw, bh);
310
+ tmpCtx.save();
311
+ tmpCtx.scale(dpr, dpr);
312
+
313
+ // Pass 1: dry base with fibers
314
+ drawFibersCore(tmpCtx, pts, stroke.color, stroke.width);
315
+
316
+ // Pass 2: wet overlay bands — older to newer so the tip ends on top
317
+ for (let b = 0; b < WETNESS_BANDS.length - 1; b++) {
318
+ const uLo = WETNESS_BANDS[b];
319
+ const uHi = WETNESS_BANDS[b + 1];
320
+ const uMid = (uLo + uHi) / 2;
321
+ if (uMid < 0.01) continue;
322
+
323
+ // Convert wetness edges to time boundaries
324
+ // (linear approx for smoothstep — close enough for band splits)
325
+ const tLo = now - DRY_DURATION * (1 - uLo);
326
+ const tHi = now - DRY_DURATION * (1 - uHi);
327
+
328
+ // Find index range via binary search (timestamps are monotonic)
329
+ const idxLo = lowerBound(pts, tLo);
330
+ const idxHi = Math.min(pts.length - 1, lowerBound(pts, tHi));
331
+ if (idxHi <= idxLo) continue;
332
+
333
+ // Overlap by 1 sample on each side to hide seams
334
+ const bandFrom = Math.max(0, idxLo - 1);
335
+ const bandTo = Math.min(pts.length - 1, idxHi + 1);
336
+ if (bandTo - bandFrom < 1) continue;
337
+
338
+ const bandPoints = pts.slice(bandFrom, bandTo + 1);
339
+ if (bandPoints.length < 2) continue;
340
+
341
+ // Wet style: wider, darker, NO fibers — covers the dry streaks
342
+ tmpCtx.globalAlpha = WET_OVERLAY_ALPHA * uMid;
343
+ tmpCtx.globalCompositeOperation = "source-over";
344
+ tmpCtx.strokeStyle = darkenHex(stroke.color, uMid);
345
+ tmpCtx.lineCap = "round";
346
+ tmpCtx.lineJoin = "round";
347
+ tmpCtx.lineWidth = stroke.width * (1 + WET_WIDTH_BOOST * uMid);
348
+ tracePath(tmpCtx, bandPoints);
349
+ tmpCtx.stroke();
350
+ }
351
+
352
+ tmpCtx.restore();
353
+ tmpCtx.setTransform(1, 0, 0, 1, 0, 0);
354
+
355
+ // Blit scratch to destination
356
+ destCtx.drawImage(tmp, bx, by, bw, bh, bx, by, bw, bh);
357
+ }
358
+
359
+ // ── Compositing ──
360
+
361
+ function compositeToScreen(now: number) {
362
+ const canvas = canvasRef.current;
363
+ if (!canvas) return;
364
+ const ctx = canvas.getContext("2d")!;
365
+ const dpr = window.devicePixelRatio || 1;
366
+
367
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
368
+ ctx.drawImage(getCacheCanvas(), 0, 0);
369
+
370
+ for (const stroke of dryingRef.current) {
371
+ drawDryingStroke(ctx, stroke, now, dpr);
372
+ }
373
+
374
+ const live = currentStrokeRef.current;
375
+ if (live && live.points.length >= 2) {
376
+ drawDryingStroke(ctx, live, now, dpr);
377
+ }
378
+ }
379
+
380
+ // ── Animation loop ──
381
+
382
+ function startAnimLoop() {
383
+ if (animRafRef.current) return;
384
+
385
+ function tick() {
386
+ const now = performance.now();
387
+
388
+ const stillDrying: Stroke[] = [];
389
+ const fullyDried: Stroke[] = [];
390
+
391
+ for (const stroke of dryingRef.current) {
392
+ const newestAge = now - stroke.points[stroke.points.length - 1].time;
393
+ if (newestAge >= DRY_DURATION) {
394
+ fullyDried.push(stroke);
395
+ } else {
396
+ stillDrying.push(stroke);
397
+ }
398
+ }
399
+
400
+ for (const stroke of fullyDried) {
401
+ appendToCache(stroke);
402
+ }
403
+ dryingRef.current = stillDrying;
404
+
405
+ compositeToScreen(now);
406
+
407
+ if (stillDrying.length > 0 || isDrawingRef.current) {
408
+ animRafRef.current = requestAnimationFrame(tick);
409
+ } else {
410
+ animRafRef.current = 0;
411
+ }
412
+ }
413
+
414
+ animRafRef.current = requestAnimationFrame(tick);
415
+ }
416
+
417
+ // ── Input handling ──
418
+
419
+ function getDocCoords(e: PointerEvent): { x: number; y: number } {
420
+ return { x: e.pageX, y: e.pageY };
421
+ }
422
+
423
+ function updateEraserCursor(e: PointerEvent) {
424
+ if (!eraserCursorRef.current) return;
425
+ eraserCursorRef.current.style.left = `${e.clientX}px`;
426
+ eraserCursorRef.current.style.top = `${e.clientY}px`;
427
+ const bar = document.querySelector("[data-penseat='bar']");
428
+ if (bar) {
429
+ const rect = bar.getBoundingClientRect();
430
+ const overBar =
431
+ e.clientX >= rect.left &&
432
+ e.clientX <= rect.right &&
433
+ e.clientY >= rect.top &&
434
+ e.clientY <= rect.bottom;
435
+ eraserCursorRef.current.style.opacity = overBar ? "0" : "1";
436
+ }
437
+ }
438
+
439
+ function eraseAtPoint(x: number, y: number) {
440
+ const r = ERASER_RADIUS;
441
+ const rSq = r * r;
442
+ const before = strokesRef.current.length;
443
+ strokesRef.current = strokesRef.current.filter((stroke) => {
444
+ for (const p of stroke.points) {
445
+ const dx = p.x - x;
446
+ const dy = p.y - y;
447
+ if (dx * dx + dy * dy <= rSq) return false;
448
+ }
449
+ return true;
450
+ });
451
+ if (strokesRef.current.length < before) {
452
+ dryingRef.current = dryingRef.current.filter((s) =>
453
+ strokesRef.current.includes(s)
454
+ );
455
+ rebuildCache();
456
+ compositeToScreen(performance.now());
457
+ }
458
+ }
459
+
460
+ const isEraser = color === ERASER_COLOR;
461
+ const isEraserRef = useRef(isEraser);
462
+ isEraserRef.current = isEraser;
463
+
464
+ const handlePointerDown = useCallback(
465
+ (e: PointerEvent) => {
466
+ if (!active) return;
467
+ e.preventDefault();
468
+ isDrawingRef.current = true;
469
+
470
+ if (isEraserRef.current) {
471
+ updateEraserCursor(e);
472
+ const { x, y } = getDocCoords(e);
473
+ eraseAtPoint(x, y);
474
+ } else {
475
+ currentStrokeRef.current = {
476
+ points: [{ ...getDocCoords(e), time: performance.now() }],
477
+ color,
478
+ width: strokeWidth,
479
+ };
480
+ }
481
+ startAnimLoop();
482
+ },
483
+ [active, color, strokeWidth]
484
+ );
485
+
486
+ const handlePointerMove = useCallback((e: PointerEvent) => {
487
+ updateEraserCursor(e);
488
+
489
+ if (!isDrawingRef.current) return;
490
+ e.preventDefault();
491
+
492
+ if (isEraserRef.current) {
493
+ const { x, y } = getDocCoords(e);
494
+ eraseAtPoint(x, y);
495
+ } else if (currentStrokeRef.current) {
496
+ currentStrokeRef.current.points.push({
497
+ ...getDocCoords(e),
498
+ time: performance.now(),
499
+ });
500
+ }
501
+ }, []);
502
+
503
+ const handlePointerUp = useCallback(() => {
504
+ if (!isDrawingRef.current) return;
505
+ isDrawingRef.current = false;
506
+
507
+ if (
508
+ !isEraserRef.current &&
509
+ currentStrokeRef.current &&
510
+ currentStrokeRef.current.points.length >= 2
511
+ ) {
512
+ strokesRef.current = [...strokesRef.current, currentStrokeRef.current];
513
+ dryingRef.current = [...dryingRef.current, currentStrokeRef.current];
514
+ startAnimLoop();
515
+ }
516
+ currentStrokeRef.current = null;
517
+ }, []);
518
+
519
+ const handlePointerLeave = useCallback(() => {
520
+ if (eraserCursorRef.current) {
521
+ eraserCursorRef.current.style.opacity = "0";
522
+ }
523
+ handlePointerUp();
524
+ }, [handlePointerUp]);
525
+
526
+ const handlePointerEnter = useCallback((e: PointerEvent) => {
527
+ if (isEraserRef.current && eraserCursorRef.current) {
528
+ eraserCursorRef.current.style.opacity = "1";
529
+ eraserCursorRef.current.style.left = `${e.clientX}px`;
530
+ eraserCursorRef.current.style.top = `${e.clientY}px`;
531
+ }
532
+ }, []);
533
+
534
+ // ── Effects ──
535
+
536
+ useEffect(() => {
537
+ const canvas = canvasRef.current;
538
+ if (!canvas) return;
539
+
540
+ if (active) {
541
+ canvas.addEventListener("pointerdown", handlePointerDown);
542
+ canvas.addEventListener("pointermove", handlePointerMove);
543
+ canvas.addEventListener("pointerup", handlePointerUp);
544
+ canvas.addEventListener("pointerleave", handlePointerLeave);
545
+ canvas.addEventListener("pointerenter", handlePointerEnter);
546
+ }
547
+
548
+ return () => {
549
+ canvas.removeEventListener("pointerdown", handlePointerDown);
550
+ canvas.removeEventListener("pointermove", handlePointerMove);
551
+ canvas.removeEventListener("pointerup", handlePointerUp);
552
+ canvas.removeEventListener("pointerleave", handlePointerLeave);
553
+ canvas.removeEventListener("pointerenter", handlePointerEnter);
554
+ };
555
+ }, [
556
+ active,
557
+ handlePointerDown,
558
+ handlePointerMove,
559
+ handlePointerUp,
560
+ handlePointerLeave,
561
+ handlePointerEnter,
562
+ ]);
563
+
564
+ useEffect(() => {
565
+ resizeCanvas();
566
+ const observer = new ResizeObserver(() => resizeCanvas());
567
+ observer.observe(document.body);
568
+ window.addEventListener("resize", resizeCanvas);
569
+ return () => {
570
+ observer.disconnect();
571
+ window.removeEventListener("resize", resizeCanvas);
572
+ cancelAnimationFrame(animRafRef.current);
573
+ };
574
+ }, [resizeCanvas]);
575
+
576
+ useImperativeHandle(ref, () => ({
577
+ undo() {
578
+ const removed = strokesRef.current[strokesRef.current.length - 1];
579
+ strokesRef.current = strokesRef.current.slice(0, -1);
580
+ dryingRef.current = dryingRef.current.filter((s) => s !== removed);
581
+ rebuildCache();
582
+ compositeToScreen(performance.now());
583
+ },
584
+ clear() {
585
+ strokesRef.current = [];
586
+ dryingRef.current = [];
587
+ cacheCanvasRef.current = null;
588
+ getCacheCanvas();
589
+ compositeToScreen(performance.now());
590
+ },
591
+ getCanvas() {
592
+ return canvasRef.current;
593
+ },
594
+ hasStrokes() {
595
+ return strokesRef.current.length > 0;
596
+ },
597
+ }));
598
+
599
+ return (
600
+ <>
601
+ <canvas
602
+ ref={canvasRef}
603
+ data-penseat="canvas"
604
+ className="absolute top-0 left-0"
605
+ style={{
606
+ pointerEvents: active ? "auto" : "none",
607
+ cursor: active
608
+ ? color === ERASER_COLOR
609
+ ? "none"
610
+ : "crosshair"
611
+ : "default",
612
+ zIndex: active ? 9998 : -1,
613
+ touchAction: "none",
614
+ }}
615
+ />
616
+ {active && isEraser && (
617
+ <div
618
+ ref={eraserCursorRef}
619
+ className="fixed z-[99999] pointer-events-none -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white"
620
+ style={{
621
+ width: ERASER_RADIUS * 2,
622
+ height: ERASER_RADIUS * 2,
623
+ mixBlendMode: "difference",
624
+ }}
625
+ />
626
+ )}
627
+ </>
628
+ );
629
+ }
630
+ );
631
+
632
+ export default DrawingCanvas;