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.
- package/index.mjs +142 -0
- package/package.json +27 -0
- package/templates/components/drawing-canvas.tsx +632 -0
- package/templates/components/penseat-bar.tsx +676 -0
- package/templates/components/penseat.tsx +121 -0
- package/templates/components/ui/button.tsx +60 -0
- package/templates/lib/capture.ts +71 -0
- package/templates/lib/utils.ts +6 -0
|
@@ -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;
|