tokimeki-image-editor 0.2.3 → 0.2.5
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/dist/components/AnnotationTool.svelte +450 -10
- package/dist/components/ImageEditor.svelte +9 -1
- package/dist/components/ImageEditor.svelte.d.ts +2 -0
- package/dist/components/ToolPanel.svelte +3 -2
- package/dist/i18n/locales/en.json +1 -0
- package/dist/i18n/locales/ja.json +1 -0
- package/dist/types.d.ts +2 -1
- package/dist/utils/canvas.js +178 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">import { onMount } from 'svelte';
|
|
2
2
|
import { _ } from 'svelte-i18n';
|
|
3
3
|
import { screenToImageCoords } from '../utils/canvas';
|
|
4
|
-
import { Pencil, Eraser, ArrowRight, Square } from 'lucide-svelte';
|
|
4
|
+
import { Pencil, Eraser, ArrowRight, Square, Brush } from 'lucide-svelte';
|
|
5
5
|
import ToolPanel from './ToolPanel.svelte';
|
|
6
6
|
let { canvas, image, viewport, transform, annotations, cropArea, onUpdate, onClose, onViewportChange } = $props();
|
|
7
7
|
let containerElement = $state(null);
|
|
@@ -15,10 +15,16 @@ const colorPresets = ['#FF6B6B', '#FFA94D', '#FFD93D', '#6BCB77', '#4D96FF', '#9
|
|
|
15
15
|
// Drawing state
|
|
16
16
|
let isDrawing = $state(false);
|
|
17
17
|
let currentAnnotation = $state(null);
|
|
18
|
-
//
|
|
18
|
+
// Brush state for speed-based width calculation
|
|
19
|
+
let lastPointTime = $state(0);
|
|
20
|
+
let lastPointPos = $state(null);
|
|
21
|
+
let recentSpeeds = $state([]); // Track recent speeds for exit stroke analysis
|
|
22
|
+
let strokeStartTime = $state(0); // Track when stroke started
|
|
23
|
+
// Panning state (Space + drag on desktop, 2-finger drag on mobile)
|
|
19
24
|
let isSpaceHeld = $state(false);
|
|
20
25
|
let isPanning = $state(false);
|
|
21
26
|
let panStart = $state(null);
|
|
27
|
+
let isTwoFingerTouch = $state(false);
|
|
22
28
|
// Helper to get coordinates from mouse or touch event
|
|
23
29
|
function getEventCoords(event) {
|
|
24
30
|
if ('touches' in event && event.touches.length > 0) {
|
|
@@ -151,6 +157,25 @@ function handleMouseDown(event) {
|
|
|
151
157
|
shadow: shadowEnabled
|
|
152
158
|
};
|
|
153
159
|
}
|
|
160
|
+
else if (currentTool === 'brush') {
|
|
161
|
+
// Initialize brush with time tracking for speed-based width
|
|
162
|
+
const now = performance.now();
|
|
163
|
+
lastPointTime = now;
|
|
164
|
+
strokeStartTime = now;
|
|
165
|
+
lastPointPos = { x: imagePoint.x, y: imagePoint.y };
|
|
166
|
+
recentSpeeds = [];
|
|
167
|
+
// Start with a very thin width (entry stroke - 入り)
|
|
168
|
+
// This creates the characteristic thin entry of calligraphy
|
|
169
|
+
const initialWidth = strokeWidth * 0.15;
|
|
170
|
+
currentAnnotation = {
|
|
171
|
+
id: `annotation-${Date.now()}`,
|
|
172
|
+
type: 'brush',
|
|
173
|
+
color: currentColor,
|
|
174
|
+
strokeWidth: strokeWidth,
|
|
175
|
+
points: [{ ...imagePoint, width: initialWidth }],
|
|
176
|
+
shadow: shadowEnabled
|
|
177
|
+
};
|
|
178
|
+
}
|
|
154
179
|
else if (currentTool === 'arrow' || currentTool === 'rectangle') {
|
|
155
180
|
currentAnnotation = {
|
|
156
181
|
id: `annotation-${Date.now()}`,
|
|
@@ -198,6 +223,56 @@ function handleMouseMove(event) {
|
|
|
198
223
|
};
|
|
199
224
|
}
|
|
200
225
|
}
|
|
226
|
+
else if (currentTool === 'brush') {
|
|
227
|
+
// Calculate speed-based width for brush
|
|
228
|
+
const now = performance.now();
|
|
229
|
+
const timeDelta = now - lastPointTime;
|
|
230
|
+
const strokeAge = now - strokeStartTime; // How long since stroke started
|
|
231
|
+
if (lastPointPos && timeDelta > 0) {
|
|
232
|
+
const dx = imagePoint.x - lastPointPos.x;
|
|
233
|
+
const dy = imagePoint.y - lastPointPos.y;
|
|
234
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
235
|
+
// Calculate speed (pixels per millisecond)
|
|
236
|
+
const speed = distance / timeDelta;
|
|
237
|
+
// Adaptive minimum distance: slower drawing requires larger gaps to reduce zigzag
|
|
238
|
+
// Fast drawing (speed > 0.5): use smaller threshold for detail
|
|
239
|
+
// Slow drawing (speed < 0.2): use larger threshold to prevent zigzag
|
|
240
|
+
const baseMinDistance = MIN_POINT_DISTANCE;
|
|
241
|
+
const slowSpeedFactor = Math.max(0, 1 - speed * 2); // 1 at speed=0, 0 at speed>=0.5
|
|
242
|
+
const adaptiveMinDistance = baseMinDistance * (1 + slowSpeedFactor * 2); // 1x to 3x base distance
|
|
243
|
+
if (distance >= adaptiveMinDistance) {
|
|
244
|
+
// Track recent speeds for exit stroke analysis (とめ/はね detection)
|
|
245
|
+
recentSpeeds = [...recentSpeeds.slice(-9), speed];
|
|
246
|
+
// Map speed to width: faster = thinner, slower = thicker
|
|
247
|
+
const minWidth = strokeWidth * 0.2;
|
|
248
|
+
const maxWidth = strokeWidth * 2.5;
|
|
249
|
+
// Inverse relationship: high speed = low width
|
|
250
|
+
// Use exponential decay for more natural feel
|
|
251
|
+
const speedFactor = Math.exp(-speed * 2.5);
|
|
252
|
+
let targetWidth = minWidth + (maxWidth - minWidth) * speedFactor;
|
|
253
|
+
// Entry stroke enhancement (入り): gradually increase width for first ~100ms
|
|
254
|
+
// This creates smoother entry
|
|
255
|
+
if (strokeAge < 150) {
|
|
256
|
+
const entryFactor = Math.min(1, strokeAge / 150);
|
|
257
|
+
// Use easing function for smooth entry
|
|
258
|
+
const easedEntry = 1 - Math.pow(1 - entryFactor, 3); // Cubic ease-out
|
|
259
|
+
const entryMinWidth = strokeWidth * 0.15;
|
|
260
|
+
targetWidth = entryMinWidth + (targetWidth - entryMinWidth) * easedEntry;
|
|
261
|
+
}
|
|
262
|
+
// Smooth width transition - use stronger smoothing for slow drawing
|
|
263
|
+
// This prevents rapid width changes that cause zigzag
|
|
264
|
+
const lastWidth = currentAnnotation.points[currentAnnotation.points.length - 1].width || strokeWidth;
|
|
265
|
+
const smoothingFactor = 0.3 + slowSpeedFactor * 0.4; // 0.3 (fast) to 0.7 (slow)
|
|
266
|
+
const smoothedWidth = lastWidth * (1 - smoothingFactor) + targetWidth * smoothingFactor;
|
|
267
|
+
currentAnnotation = {
|
|
268
|
+
...currentAnnotation,
|
|
269
|
+
points: [...currentAnnotation.points, { ...imagePoint, width: smoothedWidth }]
|
|
270
|
+
};
|
|
271
|
+
lastPointTime = now;
|
|
272
|
+
lastPointPos = { x: imagePoint.x, y: imagePoint.y };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
201
276
|
else if (currentTool === 'arrow' || currentTool === 'rectangle') {
|
|
202
277
|
currentAnnotation = {
|
|
203
278
|
...currentAnnotation,
|
|
@@ -216,11 +291,73 @@ function handleMouseUp(event) {
|
|
|
216
291
|
isDrawing = false;
|
|
217
292
|
return;
|
|
218
293
|
}
|
|
294
|
+
// Apply exit stroke (抜き) for brush - differentiate とめ (tome) vs はね (hane)
|
|
295
|
+
if (currentAnnotation.type === 'brush' && currentAnnotation.points.length >= 2) {
|
|
296
|
+
const points = [...currentAnnotation.points];
|
|
297
|
+
// Analyze exit velocity to determine stroke ending type
|
|
298
|
+
// とめ (tome): slow ending = deliberate stop, maintain width
|
|
299
|
+
// はね (hane): fast ending = flicking motion, taper sharply
|
|
300
|
+
const avgExitSpeed = recentSpeeds.length > 0
|
|
301
|
+
? recentSpeeds.reduce((a, b) => a + b, 0) / recentSpeeds.length
|
|
302
|
+
: 0.5;
|
|
303
|
+
// Speed threshold: below = とめ, above = はね
|
|
304
|
+
// Typical speeds: 0.1-0.3 (slow), 0.3-0.8 (medium), 0.8+ (fast)
|
|
305
|
+
const isHane = avgExitSpeed > 0.5; // Fast exit = はね
|
|
306
|
+
const isTome = avgExitSpeed < 0.25; // Slow exit = とめ
|
|
307
|
+
if (isTome) {
|
|
308
|
+
// とめ (stopping stroke): maintain width at the end, slight rounding
|
|
309
|
+
// Apply minimal tapering to create a deliberate stop appearance
|
|
310
|
+
const taperCount = Math.min(2, Math.floor(points.length * 0.15));
|
|
311
|
+
for (let i = 0; i < taperCount; i++) {
|
|
312
|
+
const idx = points.length - 1 - i;
|
|
313
|
+
if (idx >= 0 && points[idx].width !== undefined) {
|
|
314
|
+
const taperFactor = (i + 1) / (taperCount + 1);
|
|
315
|
+
// Very subtle taper - maintain most of the width
|
|
316
|
+
points[idx] = {
|
|
317
|
+
...points[idx],
|
|
318
|
+
width: points[idx].width * (1 - taperFactor * 0.2)
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
else if (isHane) {
|
|
324
|
+
// はね (flicking stroke): sharp taper for dynamic flick appearance
|
|
325
|
+
const taperCount = Math.min(8, Math.floor(points.length * 0.4));
|
|
326
|
+
for (let i = 0; i < taperCount; i++) {
|
|
327
|
+
const idx = points.length - 1 - i;
|
|
328
|
+
if (idx >= 0 && points[idx].width !== undefined) {
|
|
329
|
+
const taperFactor = (i + 1) / taperCount;
|
|
330
|
+
// Strong taper with exponential curve for sharp flick
|
|
331
|
+
const easedTaper = Math.pow(taperFactor, 1.5);
|
|
332
|
+
points[idx] = {
|
|
333
|
+
...points[idx],
|
|
334
|
+
width: points[idx].width * (1 - easedTaper * 0.9)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
// Medium speed: normal taper
|
|
341
|
+
const taperCount = Math.min(5, Math.floor(points.length * 0.3));
|
|
342
|
+
for (let i = 0; i < taperCount; i++) {
|
|
343
|
+
const idx = points.length - 1 - i;
|
|
344
|
+
if (idx >= 0 && points[idx].width !== undefined) {
|
|
345
|
+
const taperFactor = (i + 1) / taperCount;
|
|
346
|
+
points[idx] = {
|
|
347
|
+
...points[idx],
|
|
348
|
+
width: points[idx].width * (1 - taperFactor * 0.6)
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
currentAnnotation = { ...currentAnnotation, points };
|
|
354
|
+
}
|
|
219
355
|
// Only save if annotation has enough points
|
|
220
356
|
if (currentAnnotation.points.length >= 2 ||
|
|
221
|
-
(currentAnnotation.type === 'pen' && currentAnnotation.points.length >= 1)
|
|
357
|
+
(currentAnnotation.type === 'pen' && currentAnnotation.points.length >= 1) ||
|
|
358
|
+
(currentAnnotation.type === 'brush' && currentAnnotation.points.length >= 1)) {
|
|
222
359
|
// For arrow/rectangle, ensure start and end are different
|
|
223
|
-
if (currentAnnotation.type !== 'pen') {
|
|
360
|
+
if (currentAnnotation.type !== 'pen' && currentAnnotation.type !== 'brush') {
|
|
224
361
|
const start = currentAnnotation.points[0];
|
|
225
362
|
const end = currentAnnotation.points[1];
|
|
226
363
|
const distance = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
|
|
@@ -234,6 +371,10 @@ function handleMouseUp(event) {
|
|
|
234
371
|
}
|
|
235
372
|
isDrawing = false;
|
|
236
373
|
currentAnnotation = null;
|
|
374
|
+
lastPointTime = 0;
|
|
375
|
+
lastPointPos = null;
|
|
376
|
+
recentSpeeds = [];
|
|
377
|
+
strokeStartTime = 0;
|
|
237
378
|
}
|
|
238
379
|
function findAnnotationAtPoint(canvasX, canvasY) {
|
|
239
380
|
const hitRadius = 10;
|
|
@@ -289,12 +430,79 @@ function pointToSegmentDistance(px, py, a, b) {
|
|
|
289
430
|
function handleClearAll() {
|
|
290
431
|
onUpdate([]);
|
|
291
432
|
}
|
|
292
|
-
|
|
293
|
-
|
|
433
|
+
function handleTouchStart(event) {
|
|
434
|
+
// Two-finger touch starts panning
|
|
435
|
+
if (event.touches.length === 2) {
|
|
436
|
+
event.preventDefault();
|
|
437
|
+
isTwoFingerTouch = true;
|
|
438
|
+
// Use the midpoint of the two touches
|
|
439
|
+
const touch1 = event.touches[0];
|
|
440
|
+
const touch2 = event.touches[1];
|
|
441
|
+
const midX = (touch1.clientX + touch2.clientX) / 2;
|
|
442
|
+
const midY = (touch1.clientY + touch2.clientY) / 2;
|
|
443
|
+
// Cancel any current drawing
|
|
444
|
+
if (isDrawing) {
|
|
445
|
+
isDrawing = false;
|
|
446
|
+
currentAnnotation = null;
|
|
447
|
+
}
|
|
448
|
+
isPanning = true;
|
|
449
|
+
panStart = {
|
|
450
|
+
x: midX,
|
|
451
|
+
y: midY,
|
|
452
|
+
offsetX: viewport.offsetX,
|
|
453
|
+
offsetY: viewport.offsetY
|
|
454
|
+
};
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Single finger - normal drawing (only if not already in two-finger mode)
|
|
458
|
+
if (event.touches.length === 1 && !isTwoFingerTouch) {
|
|
459
|
+
handleMouseDown(event);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function handleTouchMove(event) {
|
|
463
|
+
// Two-finger panning
|
|
464
|
+
if (event.touches.length === 2 && isPanning && panStart && onViewportChange) {
|
|
465
|
+
event.preventDefault();
|
|
466
|
+
const touch1 = event.touches[0];
|
|
467
|
+
const touch2 = event.touches[1];
|
|
468
|
+
const midX = (touch1.clientX + touch2.clientX) / 2;
|
|
469
|
+
const midY = (touch1.clientY + touch2.clientY) / 2;
|
|
470
|
+
const dx = midX - panStart.x;
|
|
471
|
+
const dy = midY - panStart.y;
|
|
472
|
+
onViewportChange({
|
|
473
|
+
offsetX: panStart.offsetX + dx,
|
|
474
|
+
offsetY: panStart.offsetY + dy
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
// Single finger drawing (only if not in two-finger mode)
|
|
479
|
+
if (event.touches.length === 1 && !isTwoFingerTouch) {
|
|
480
|
+
handleMouseMove(event);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
294
483
|
function handleTouchEnd(event) {
|
|
484
|
+
// When all fingers are lifted
|
|
295
485
|
if (event.touches.length === 0) {
|
|
486
|
+
if (isPanning) {
|
|
487
|
+
isPanning = false;
|
|
488
|
+
panStart = null;
|
|
489
|
+
}
|
|
490
|
+
isTwoFingerTouch = false;
|
|
296
491
|
handleMouseUp();
|
|
297
492
|
}
|
|
493
|
+
// When going from 2 fingers to 1, stay in pan mode but don't draw
|
|
494
|
+
else if (event.touches.length === 1 && isTwoFingerTouch) {
|
|
495
|
+
// Update pan start to the remaining finger position
|
|
496
|
+
if (isPanning && onViewportChange) {
|
|
497
|
+
const touch = event.touches[0];
|
|
498
|
+
panStart = {
|
|
499
|
+
x: touch.clientX,
|
|
500
|
+
y: touch.clientY,
|
|
501
|
+
offsetX: viewport.offsetX,
|
|
502
|
+
offsetY: viewport.offsetY
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
}
|
|
298
506
|
}
|
|
299
507
|
// Generate smooth SVG path using quadratic bezier curves
|
|
300
508
|
function generateSmoothPath(points) {
|
|
@@ -330,11 +538,216 @@ function generateSmoothPath(points) {
|
|
|
330
538
|
path += ` L ${lastPoint.x} ${lastPoint.y}`;
|
|
331
539
|
return path;
|
|
332
540
|
}
|
|
333
|
-
//
|
|
541
|
+
// Smooth a series of points using moving average
|
|
542
|
+
function smoothPoints(points, windowSize = 3) {
|
|
543
|
+
if (points.length < 3)
|
|
544
|
+
return points;
|
|
545
|
+
const result = [];
|
|
546
|
+
const halfWindow = Math.floor(windowSize / 2);
|
|
547
|
+
for (let i = 0; i < points.length; i++) {
|
|
548
|
+
// Keep first and last points unchanged for proper shape
|
|
549
|
+
if (i < halfWindow || i >= points.length - halfWindow) {
|
|
550
|
+
result.push(points[i]);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
let sumX = 0, sumY = 0, count = 0;
|
|
554
|
+
for (let j = -halfWindow; j <= halfWindow; j++) {
|
|
555
|
+
const idx = i + j;
|
|
556
|
+
if (idx >= 0 && idx < points.length) {
|
|
557
|
+
sumX += points[idx].x;
|
|
558
|
+
sumY += points[idx].y;
|
|
559
|
+
count++;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
result.push({ x: sumX / count, y: sumY / count });
|
|
563
|
+
}
|
|
564
|
+
return result;
|
|
565
|
+
}
|
|
566
|
+
// Interpolate points for smoother brush strokes
|
|
567
|
+
function interpolateBrushPoints(points) {
|
|
568
|
+
if (points.length < 2)
|
|
569
|
+
return points;
|
|
570
|
+
const result = [];
|
|
571
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
572
|
+
const p1 = points[i];
|
|
573
|
+
const p2 = points[i + 1];
|
|
574
|
+
const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
|
|
575
|
+
result.push(p1);
|
|
576
|
+
// Add interpolated points if distance is large
|
|
577
|
+
const interpolateCount = Math.floor(dist / 5); // Add point every 5 pixels
|
|
578
|
+
for (let j = 1; j < interpolateCount; j++) {
|
|
579
|
+
const t = j / interpolateCount;
|
|
580
|
+
// Use smoothstep for width interpolation
|
|
581
|
+
const smoothT = t * t * (3 - 2 * t);
|
|
582
|
+
result.push({
|
|
583
|
+
x: p1.x + (p2.x - p1.x) * t,
|
|
584
|
+
y: p1.y + (p2.y - p1.y) * t,
|
|
585
|
+
width: p1.width !== undefined && p2.width !== undefined
|
|
586
|
+
? p1.width + (p2.width - p1.width) * smoothT
|
|
587
|
+
: undefined
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
result.push(points[points.length - 1]);
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
594
|
+
// Generate a filled SVG path for variable-width brush strokes
|
|
595
|
+
function generateBrushPath(points, baseWidth, scale) {
|
|
596
|
+
// Handle single point - create an elliptical brush mark (点)
|
|
597
|
+
if (points.length === 1) {
|
|
598
|
+
const p = points[0];
|
|
599
|
+
const width = ((p.width ?? baseWidth) * scale) / 2;
|
|
600
|
+
// Create a slightly elongated ellipse for brush-like appearance
|
|
601
|
+
const rx = width * 0.8;
|
|
602
|
+
const ry = width * 1.2;
|
|
603
|
+
return `M ${p.x} ${p.y - ry}
|
|
604
|
+
C ${p.x + rx * 0.55} ${p.y - ry} ${p.x + rx} ${p.y - ry * 0.55} ${p.x + rx} ${p.y}
|
|
605
|
+
C ${p.x + rx} ${p.y + ry * 0.55} ${p.x + rx * 0.55} ${p.y + ry} ${p.x} ${p.y + ry}
|
|
606
|
+
C ${p.x - rx * 0.55} ${p.y + ry} ${p.x - rx} ${p.y + ry * 0.55} ${p.x - rx} ${p.y}
|
|
607
|
+
C ${p.x - rx} ${p.y - ry * 0.55} ${p.x - rx * 0.55} ${p.y - ry} ${p.x} ${p.y - ry} Z`;
|
|
608
|
+
}
|
|
609
|
+
// Handle 2 points - create a teardrop/brush stroke shape
|
|
610
|
+
if (points.length === 2) {
|
|
611
|
+
const p1 = points[0];
|
|
612
|
+
const p2 = points[1];
|
|
613
|
+
const w1 = ((p1.width ?? baseWidth * 0.3) * scale) / 2;
|
|
614
|
+
const w2 = ((p2.width ?? baseWidth * 0.5) * scale) / 2;
|
|
615
|
+
// Direction vector
|
|
616
|
+
const dx = p2.x - p1.x;
|
|
617
|
+
const dy = p2.y - p1.y;
|
|
618
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
619
|
+
if (len === 0)
|
|
620
|
+
return '';
|
|
621
|
+
// Perpendicular
|
|
622
|
+
const nx = -dy / len;
|
|
623
|
+
const ny = dx / len;
|
|
624
|
+
// Create teardrop shape
|
|
625
|
+
const startLeft = { x: p1.x + nx * w1, y: p1.y + ny * w1 };
|
|
626
|
+
const startRight = { x: p1.x - nx * w1, y: p1.y - ny * w1 };
|
|
627
|
+
const endLeft = { x: p2.x + nx * w2, y: p2.y + ny * w2 };
|
|
628
|
+
const endRight = { x: p2.x - nx * w2, y: p2.y - ny * w2 };
|
|
629
|
+
// Extend the tip slightly for brush-like appearance
|
|
630
|
+
const tipExtend = w2 * 0.3;
|
|
631
|
+
const tipX = p2.x + (dx / len) * tipExtend;
|
|
632
|
+
const tipY = p2.y + (dy / len) * tipExtend;
|
|
633
|
+
return `M ${startLeft.x} ${startLeft.y}
|
|
634
|
+
Q ${(startLeft.x + endLeft.x) / 2 + nx * w2 * 0.3} ${(startLeft.y + endLeft.y) / 2 + ny * w2 * 0.3} ${endLeft.x} ${endLeft.y}
|
|
635
|
+
Q ${tipX + nx * w2 * 0.2} ${tipY + ny * w2 * 0.2} ${tipX} ${tipY}
|
|
636
|
+
Q ${tipX - nx * w2 * 0.2} ${tipY - ny * w2 * 0.2} ${endRight.x} ${endRight.y}
|
|
637
|
+
Q ${(startRight.x + endRight.x) / 2 - nx * w2 * 0.3} ${(startRight.y + endRight.y) / 2 - ny * w2 * 0.3} ${startRight.x} ${startRight.y}
|
|
638
|
+
Z`;
|
|
639
|
+
}
|
|
640
|
+
// For 3+ points, interpolate for smoother curves
|
|
641
|
+
const interpolated = interpolateBrushPoints(points);
|
|
642
|
+
let leftSide = [];
|
|
643
|
+
let rightSide = [];
|
|
644
|
+
for (let i = 0; i < interpolated.length; i++) {
|
|
645
|
+
const curr = interpolated[i];
|
|
646
|
+
const width = ((curr.width ?? baseWidth) * scale) / 2;
|
|
647
|
+
// Calculate direction using central difference when possible
|
|
648
|
+
let dx, dy;
|
|
649
|
+
if (i === 0) {
|
|
650
|
+
dx = interpolated[1].x - curr.x;
|
|
651
|
+
dy = interpolated[1].y - curr.y;
|
|
652
|
+
}
|
|
653
|
+
else if (i === interpolated.length - 1) {
|
|
654
|
+
dx = curr.x - interpolated[i - 1].x;
|
|
655
|
+
dy = curr.y - interpolated[i - 1].y;
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
// Use wider window for smoother direction calculation
|
|
659
|
+
const lookback = Math.min(i, 3);
|
|
660
|
+
const lookforward = Math.min(interpolated.length - 1 - i, 3);
|
|
661
|
+
dx = interpolated[i + lookforward].x - interpolated[i - lookback].x;
|
|
662
|
+
dy = interpolated[i + lookforward].y - interpolated[i - lookback].y;
|
|
663
|
+
}
|
|
664
|
+
// Normalize
|
|
665
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
666
|
+
if (len === 0)
|
|
667
|
+
continue;
|
|
668
|
+
const nx = -dy / len; // Perpendicular
|
|
669
|
+
const ny = dx / len;
|
|
670
|
+
leftSide.push({ x: curr.x + nx * width, y: curr.y + ny * width });
|
|
671
|
+
rightSide.push({ x: curr.x - nx * width, y: curr.y - ny * width });
|
|
672
|
+
}
|
|
673
|
+
if (leftSide.length < 2)
|
|
674
|
+
return '';
|
|
675
|
+
// Apply smoothing to both sides to reduce zigzag
|
|
676
|
+
leftSide = smoothPoints(leftSide, 5);
|
|
677
|
+
rightSide = smoothPoints(rightSide, 5);
|
|
678
|
+
// Build path: left side forward, right side backward
|
|
679
|
+
let path = `M ${leftSide[0].x} ${leftSide[0].y}`;
|
|
680
|
+
// Smooth left side with quadratic curves
|
|
681
|
+
for (let i = 1; i < leftSide.length - 1; i++) {
|
|
682
|
+
const curr = leftSide[i];
|
|
683
|
+
const next = leftSide[i + 1];
|
|
684
|
+
const endX = (curr.x + next.x) / 2;
|
|
685
|
+
const endY = (curr.y + next.y) / 2;
|
|
686
|
+
path += ` Q ${curr.x} ${curr.y} ${endX} ${endY}`;
|
|
687
|
+
}
|
|
688
|
+
path += ` L ${leftSide[leftSide.length - 1].x} ${leftSide[leftSide.length - 1].y}`;
|
|
689
|
+
// End cap - smooth connection at stroke tip
|
|
690
|
+
const lastLeft = leftSide[leftSide.length - 1];
|
|
691
|
+
const lastRight = rightSide[rightSide.length - 1];
|
|
692
|
+
const lastPoint = interpolated[interpolated.length - 1];
|
|
693
|
+
const lastWidth = ((lastPoint.width ?? baseWidth) * scale) / 2;
|
|
694
|
+
// Create rounded end cap using bezier curve
|
|
695
|
+
const tipExtend = lastWidth * 0.4;
|
|
696
|
+
const lastDx = interpolated.length > 1
|
|
697
|
+
? interpolated[interpolated.length - 1].x - interpolated[interpolated.length - 2].x
|
|
698
|
+
: 0;
|
|
699
|
+
const lastDy = interpolated.length > 1
|
|
700
|
+
? interpolated[interpolated.length - 1].y - interpolated[interpolated.length - 2].y
|
|
701
|
+
: 0;
|
|
702
|
+
const lastLen = Math.sqrt(lastDx * lastDx + lastDy * lastDy);
|
|
703
|
+
if (lastLen > 0) {
|
|
704
|
+
const tipX = lastPoint.x + (lastDx / lastLen) * tipExtend;
|
|
705
|
+
const tipY = lastPoint.y + (lastDy / lastLen) * tipExtend;
|
|
706
|
+
path += ` Q ${tipX} ${tipY} ${lastRight.x} ${lastRight.y}`;
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
path += ` L ${lastRight.x} ${lastRight.y}`;
|
|
710
|
+
}
|
|
711
|
+
// Smooth right side backward
|
|
712
|
+
for (let i = rightSide.length - 2; i > 0; i--) {
|
|
713
|
+
const curr = rightSide[i];
|
|
714
|
+
const prev = rightSide[i - 1];
|
|
715
|
+
const endX = (curr.x + prev.x) / 2;
|
|
716
|
+
const endY = (curr.y + prev.y) / 2;
|
|
717
|
+
path += ` Q ${curr.x} ${curr.y} ${endX} ${endY}`;
|
|
718
|
+
}
|
|
719
|
+
path += ` L ${rightSide[0].x} ${rightSide[0].y}`;
|
|
720
|
+
// Start cap - smooth connection at stroke start
|
|
721
|
+
const firstLeft = leftSide[0];
|
|
722
|
+
const firstRight = rightSide[0];
|
|
723
|
+
const firstPoint = interpolated[0];
|
|
724
|
+
const firstWidth = ((firstPoint.width ?? baseWidth) * scale) / 2;
|
|
725
|
+
const startExtend = firstWidth * 0.3;
|
|
726
|
+
const firstDx = interpolated.length > 1
|
|
727
|
+
? interpolated[0].x - interpolated[1].x
|
|
728
|
+
: 0;
|
|
729
|
+
const firstDy = interpolated.length > 1
|
|
730
|
+
? interpolated[0].y - interpolated[1].y
|
|
731
|
+
: 0;
|
|
732
|
+
const firstLen = Math.sqrt(firstDx * firstDx + firstDy * firstDy);
|
|
733
|
+
if (firstLen > 0) {
|
|
734
|
+
const startTipX = firstPoint.x + (firstDx / firstLen) * startExtend;
|
|
735
|
+
const startTipY = firstPoint.y + (firstDy / firstLen) * startExtend;
|
|
736
|
+
path += ` Q ${startTipX} ${startTipY} ${firstLeft.x} ${firstLeft.y}`;
|
|
737
|
+
}
|
|
738
|
+
path += ' Z'; // Close path
|
|
739
|
+
return path;
|
|
740
|
+
}
|
|
741
|
+
// Get current annotation canvas coordinates with width data for brush
|
|
334
742
|
let currentAnnotationCanvas = $derived.by(() => {
|
|
335
743
|
if (!currentAnnotation)
|
|
336
744
|
return null;
|
|
337
|
-
const points = currentAnnotation.points.map(p =>
|
|
745
|
+
const points = currentAnnotation.points.map(p => {
|
|
746
|
+
const canvasCoords = toCanvasCoords(p);
|
|
747
|
+
if (!canvasCoords)
|
|
748
|
+
return null;
|
|
749
|
+
return { ...canvasCoords, width: p.width };
|
|
750
|
+
}).filter(Boolean);
|
|
338
751
|
return { ...currentAnnotation, canvasPoints: points };
|
|
339
752
|
});
|
|
340
753
|
</script>
|
|
@@ -350,7 +763,7 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
350
763
|
<div
|
|
351
764
|
bind:this={containerElement}
|
|
352
765
|
class="annotation-tool-overlay"
|
|
353
|
-
class:panning={isSpaceHeld}
|
|
766
|
+
class:panning={isSpaceHeld || isTwoFingerTouch}
|
|
354
767
|
onmousedown={handleMouseDown}
|
|
355
768
|
role="button"
|
|
356
769
|
tabindex="-1"
|
|
@@ -379,6 +792,18 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
379
792
|
stroke-linejoin="round"
|
|
380
793
|
filter={shadowFilter}
|
|
381
794
|
/>
|
|
795
|
+
{:else if annotation.type === 'brush' && points.length >= 1}
|
|
796
|
+
{@const brushPoints = annotation.points.map(p => {
|
|
797
|
+
const canvasCoords = toCanvasCoords(p);
|
|
798
|
+
if (!canvasCoords) return null;
|
|
799
|
+
return { ...canvasCoords, width: p.width };
|
|
800
|
+
}).filter(Boolean) as { x: number; y: number; width?: number }[]}
|
|
801
|
+
<path
|
|
802
|
+
d={generateBrushPath(brushPoints, annotation.strokeWidth, totalScale)}
|
|
803
|
+
fill={annotation.color}
|
|
804
|
+
stroke="none"
|
|
805
|
+
filter={shadowFilter}
|
|
806
|
+
/>
|
|
382
807
|
{:else if annotation.type === 'arrow' && points.length >= 2}
|
|
383
808
|
{@const start = points[0]}
|
|
384
809
|
{@const end = points[1]}
|
|
@@ -442,6 +867,13 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
442
867
|
stroke-linejoin="round"
|
|
443
868
|
filter={currentShadowFilter}
|
|
444
869
|
/>
|
|
870
|
+
{:else if currentAnnotationCanvas.type === 'brush' && points.length >= 1}
|
|
871
|
+
<path
|
|
872
|
+
d={generateBrushPath(points, currentAnnotationCanvas.strokeWidth, totalScale)}
|
|
873
|
+
fill={currentAnnotationCanvas.color}
|
|
874
|
+
stroke="none"
|
|
875
|
+
filter={currentShadowFilter}
|
|
876
|
+
/>
|
|
445
877
|
{:else if currentAnnotationCanvas.type === 'arrow' && points.length >= 2}
|
|
446
878
|
{@const start = points[0]}
|
|
447
879
|
{@const end = points[1]}
|
|
@@ -506,6 +938,14 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
506
938
|
>
|
|
507
939
|
<Pencil size={20} />
|
|
508
940
|
</button>
|
|
941
|
+
<button
|
|
942
|
+
class="tool-btn"
|
|
943
|
+
class:active={currentTool === 'brush'}
|
|
944
|
+
onclick={() => currentTool = 'brush'}
|
|
945
|
+
title={$_('annotate.brush')}
|
|
946
|
+
>
|
|
947
|
+
<Brush size={20} />
|
|
948
|
+
</button>
|
|
509
949
|
<button
|
|
510
950
|
class="tool-btn"
|
|
511
951
|
class:active={currentTool === 'eraser'}
|
|
@@ -565,7 +1005,7 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
565
1005
|
id="stroke-width"
|
|
566
1006
|
type="range"
|
|
567
1007
|
min="5"
|
|
568
|
-
max="
|
|
1008
|
+
max="100"
|
|
569
1009
|
bind:value={strokeWidth}
|
|
570
1010
|
/>
|
|
571
1011
|
</div>
|
|
@@ -13,7 +13,7 @@ import BlurTool from './BlurTool.svelte';
|
|
|
13
13
|
import StampTool from './StampTool.svelte';
|
|
14
14
|
import AnnotationTool from './AnnotationTool.svelte';
|
|
15
15
|
import ExportTool from './ExportTool.svelte';
|
|
16
|
-
let { initialImage, width = 800, height = 600, isStandalone = false, onComplete, onCancel, onExport } = $props();
|
|
16
|
+
let { initialImage, initialMode = null, width = 800, height = 600, isStandalone = false, onComplete, onCancel, onExport } = $props();
|
|
17
17
|
let state = $state({
|
|
18
18
|
mode: null,
|
|
19
19
|
imageData: {
|
|
@@ -86,6 +86,10 @@ $effect(() => {
|
|
|
86
86
|
// Reset history and save initial state
|
|
87
87
|
state.history = createEmptyHistory();
|
|
88
88
|
saveToHistory();
|
|
89
|
+
// Apply initial mode if specified
|
|
90
|
+
if (initialMode) {
|
|
91
|
+
state.mode = initialMode;
|
|
92
|
+
}
|
|
89
93
|
};
|
|
90
94
|
img.onerror = (error) => {
|
|
91
95
|
console.error('Failed to load initial image:', error);
|
|
@@ -127,6 +131,10 @@ async function handleFileUpload(file) {
|
|
|
127
131
|
// Reset history and save initial state
|
|
128
132
|
state.history = createEmptyHistory();
|
|
129
133
|
saveToHistory();
|
|
134
|
+
// Apply initial mode if specified
|
|
135
|
+
if (initialMode) {
|
|
136
|
+
state.mode = initialMode;
|
|
137
|
+
}
|
|
130
138
|
}
|
|
131
139
|
catch (error) {
|
|
132
140
|
console.error('Failed to load image:', error);
|
|
@@ -103,7 +103,7 @@ function handleSheetDragEnd() {
|
|
|
103
103
|
|
|
104
104
|
@media (max-width: 767px) {
|
|
105
105
|
.tool-panel {
|
|
106
|
-
position:
|
|
106
|
+
position: fixed;
|
|
107
107
|
left: 0;
|
|
108
108
|
right: 0;
|
|
109
109
|
top: auto;
|
|
@@ -112,10 +112,11 @@ function handleSheetDragEnd() {
|
|
|
112
112
|
min-width: auto;
|
|
113
113
|
height: var(--sheet-max-height, 400px);
|
|
114
114
|
border-radius: 16px 16px 0 0;
|
|
115
|
-
z-index:
|
|
115
|
+
z-index: 9999;
|
|
116
116
|
overflow-y: auto;
|
|
117
117
|
overscroll-behavior: contain;
|
|
118
118
|
padding-top: 0;
|
|
119
|
+
padding-bottom: 1.5rem;
|
|
119
120
|
transform: translateY(var(--sheet-offset, 0px));
|
|
120
121
|
will-change: transform;
|
|
121
122
|
backdrop-filter: none
|
package/dist/types.d.ts
CHANGED
|
@@ -37,10 +37,11 @@ export interface StampArea {
|
|
|
37
37
|
stampType: StampType;
|
|
38
38
|
stampContent: string;
|
|
39
39
|
}
|
|
40
|
-
export type AnnotationType = 'pen' | 'arrow' | 'rectangle';
|
|
40
|
+
export type AnnotationType = 'pen' | 'brush' | 'arrow' | 'rectangle';
|
|
41
41
|
export interface AnnotationPoint {
|
|
42
42
|
x: number;
|
|
43
43
|
y: number;
|
|
44
|
+
width?: number;
|
|
44
45
|
}
|
|
45
46
|
export interface Annotation {
|
|
46
47
|
id: string;
|
package/dist/utils/canvas.js
CHANGED
|
@@ -427,6 +427,184 @@ export function applyAnnotations(canvas, img, viewport, annotations, cropArea) {
|
|
|
427
427
|
}
|
|
428
428
|
ctx.stroke();
|
|
429
429
|
}
|
|
430
|
+
else if (annotation.type === 'brush' && annotation.points.length >= 1) {
|
|
431
|
+
// Draw brush stroke with variable width
|
|
432
|
+
const rawPoints = annotation.points.map(p => ({
|
|
433
|
+
...toCanvasCoords(p.x, p.y),
|
|
434
|
+
width: p.width ?? annotation.strokeWidth
|
|
435
|
+
}));
|
|
436
|
+
// Handle single point - create an elliptical brush mark (点)
|
|
437
|
+
if (rawPoints.length === 1) {
|
|
438
|
+
const p = rawPoints[0];
|
|
439
|
+
const width = (p.width * totalScale) / 2;
|
|
440
|
+
const rx = width * 0.8;
|
|
441
|
+
const ry = width * 1.2;
|
|
442
|
+
ctx.beginPath();
|
|
443
|
+
ctx.ellipse(p.x, p.y, rx, ry, 0, 0, Math.PI * 2);
|
|
444
|
+
ctx.fill();
|
|
445
|
+
// Handle 2 points - create a teardrop/brush stroke shape
|
|
446
|
+
}
|
|
447
|
+
else if (rawPoints.length === 2) {
|
|
448
|
+
const p1 = rawPoints[0];
|
|
449
|
+
const p2 = rawPoints[1];
|
|
450
|
+
const w1 = ((p1.width ?? annotation.strokeWidth * 0.3) * totalScale) / 2;
|
|
451
|
+
const w2 = ((p2.width ?? annotation.strokeWidth * 0.5) * totalScale) / 2;
|
|
452
|
+
const dx = p2.x - p1.x;
|
|
453
|
+
const dy = p2.y - p1.y;
|
|
454
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
455
|
+
if (len > 0) {
|
|
456
|
+
const nx = -dy / len;
|
|
457
|
+
const ny = dx / len;
|
|
458
|
+
const tipExtend = w2 * 0.3;
|
|
459
|
+
const tipX = p2.x + (dx / len) * tipExtend;
|
|
460
|
+
const tipY = p2.y + (dy / len) * tipExtend;
|
|
461
|
+
ctx.beginPath();
|
|
462
|
+
ctx.moveTo(p1.x + nx * w1, p1.y + ny * w1);
|
|
463
|
+
ctx.quadraticCurveTo((p1.x + nx * w1 + p2.x + nx * w2) / 2 + nx * w2 * 0.3, (p1.y + ny * w1 + p2.y + ny * w2) / 2 + ny * w2 * 0.3, p2.x + nx * w2, p2.y + ny * w2);
|
|
464
|
+
ctx.quadraticCurveTo(tipX + nx * w2 * 0.2, tipY + ny * w2 * 0.2, tipX, tipY);
|
|
465
|
+
ctx.quadraticCurveTo(tipX - nx * w2 * 0.2, tipY - ny * w2 * 0.2, p2.x - nx * w2, p2.y - ny * w2);
|
|
466
|
+
ctx.quadraticCurveTo((p1.x - nx * w1 + p2.x - nx * w2) / 2 - nx * w2 * 0.3, (p1.y - ny * w1 + p2.y - ny * w2) / 2 - ny * w2 * 0.3, p1.x - nx * w1, p1.y - ny * w1);
|
|
467
|
+
ctx.closePath();
|
|
468
|
+
ctx.fill();
|
|
469
|
+
}
|
|
470
|
+
// Handle 3+ points with interpolation for smoother curves
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
// Interpolate points for smoother curves
|
|
474
|
+
const interpolated = [];
|
|
475
|
+
for (let i = 0; i < rawPoints.length - 1; i++) {
|
|
476
|
+
const p1 = rawPoints[i];
|
|
477
|
+
const p2 = rawPoints[i + 1];
|
|
478
|
+
const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
|
|
479
|
+
interpolated.push(p1);
|
|
480
|
+
const interpolateCount = Math.floor(dist / 5);
|
|
481
|
+
for (let j = 1; j < interpolateCount; j++) {
|
|
482
|
+
const t = j / interpolateCount;
|
|
483
|
+
const smoothT = t * t * (3 - 2 * t);
|
|
484
|
+
interpolated.push({
|
|
485
|
+
x: p1.x + (p2.x - p1.x) * t,
|
|
486
|
+
y: p1.y + (p2.y - p1.y) * t,
|
|
487
|
+
width: p1.width + (p2.width - p1.width) * smoothT
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
interpolated.push(rawPoints[rawPoints.length - 1]);
|
|
492
|
+
// Generate outline for variable-width path
|
|
493
|
+
let leftSide = [];
|
|
494
|
+
let rightSide = [];
|
|
495
|
+
for (let i = 0; i < interpolated.length; i++) {
|
|
496
|
+
const curr = interpolated[i];
|
|
497
|
+
const width = (curr.width * totalScale) / 2;
|
|
498
|
+
let dx, dy;
|
|
499
|
+
if (i === 0) {
|
|
500
|
+
dx = interpolated[1].x - curr.x;
|
|
501
|
+
dy = interpolated[1].y - curr.y;
|
|
502
|
+
}
|
|
503
|
+
else if (i === interpolated.length - 1) {
|
|
504
|
+
dx = curr.x - interpolated[i - 1].x;
|
|
505
|
+
dy = curr.y - interpolated[i - 1].y;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
const lookback = Math.min(i, 3);
|
|
509
|
+
const lookforward = Math.min(interpolated.length - 1 - i, 3);
|
|
510
|
+
dx = interpolated[i + lookforward].x - interpolated[i - lookback].x;
|
|
511
|
+
dy = interpolated[i + lookforward].y - interpolated[i - lookback].y;
|
|
512
|
+
}
|
|
513
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
514
|
+
if (len === 0)
|
|
515
|
+
continue;
|
|
516
|
+
const nx = -dy / len;
|
|
517
|
+
const ny = dx / len;
|
|
518
|
+
leftSide.push({ x: curr.x + nx * width, y: curr.y + ny * width });
|
|
519
|
+
rightSide.push({ x: curr.x - nx * width, y: curr.y - ny * width });
|
|
520
|
+
}
|
|
521
|
+
// Smooth the outline points to reduce zigzag
|
|
522
|
+
const smoothOutline = (pts, windowSize = 5) => {
|
|
523
|
+
if (pts.length < 3)
|
|
524
|
+
return pts;
|
|
525
|
+
const result = [];
|
|
526
|
+
const halfWindow = Math.floor(windowSize / 2);
|
|
527
|
+
for (let i = 0; i < pts.length; i++) {
|
|
528
|
+
if (i < halfWindow || i >= pts.length - halfWindow) {
|
|
529
|
+
result.push(pts[i]);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
let sumX = 0, sumY = 0, count = 0;
|
|
533
|
+
for (let j = -halfWindow; j <= halfWindow; j++) {
|
|
534
|
+
const idx = i + j;
|
|
535
|
+
if (idx >= 0 && idx < pts.length) {
|
|
536
|
+
sumX += pts[idx].x;
|
|
537
|
+
sumY += pts[idx].y;
|
|
538
|
+
count++;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
result.push({ x: sumX / count, y: sumY / count });
|
|
542
|
+
}
|
|
543
|
+
return result;
|
|
544
|
+
};
|
|
545
|
+
leftSide = smoothOutline(leftSide);
|
|
546
|
+
rightSide = smoothOutline(rightSide);
|
|
547
|
+
if (leftSide.length >= 2) {
|
|
548
|
+
ctx.beginPath();
|
|
549
|
+
ctx.moveTo(leftSide[0].x, leftSide[0].y);
|
|
550
|
+
// Left side with smooth curves
|
|
551
|
+
for (let i = 1; i < leftSide.length - 1; i++) {
|
|
552
|
+
const curr = leftSide[i];
|
|
553
|
+
const next = leftSide[i + 1];
|
|
554
|
+
const endX = (curr.x + next.x) / 2;
|
|
555
|
+
const endY = (curr.y + next.y) / 2;
|
|
556
|
+
ctx.quadraticCurveTo(curr.x, curr.y, endX, endY);
|
|
557
|
+
}
|
|
558
|
+
ctx.lineTo(leftSide[leftSide.length - 1].x, leftSide[leftSide.length - 1].y);
|
|
559
|
+
// End cap with rounded connection
|
|
560
|
+
const lastPoint = interpolated[interpolated.length - 1];
|
|
561
|
+
const lastWidth = (lastPoint.width * totalScale) / 2;
|
|
562
|
+
const tipExtend = lastWidth * 0.4;
|
|
563
|
+
const lastDx = interpolated.length > 1
|
|
564
|
+
? interpolated[interpolated.length - 1].x - interpolated[interpolated.length - 2].x
|
|
565
|
+
: 0;
|
|
566
|
+
const lastDy = interpolated.length > 1
|
|
567
|
+
? interpolated[interpolated.length - 1].y - interpolated[interpolated.length - 2].y
|
|
568
|
+
: 0;
|
|
569
|
+
const lastLen = Math.sqrt(lastDx * lastDx + lastDy * lastDy);
|
|
570
|
+
if (lastLen > 0) {
|
|
571
|
+
const tipX = lastPoint.x + (lastDx / lastLen) * tipExtend;
|
|
572
|
+
const tipY = lastPoint.y + (lastDy / lastLen) * tipExtend;
|
|
573
|
+
ctx.quadraticCurveTo(tipX, tipY, rightSide[rightSide.length - 1].x, rightSide[rightSide.length - 1].y);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
ctx.lineTo(rightSide[rightSide.length - 1].x, rightSide[rightSide.length - 1].y);
|
|
577
|
+
}
|
|
578
|
+
// Right side backward with smooth curves
|
|
579
|
+
for (let i = rightSide.length - 2; i > 0; i--) {
|
|
580
|
+
const curr = rightSide[i];
|
|
581
|
+
const prev = rightSide[i - 1];
|
|
582
|
+
const endX = (curr.x + prev.x) / 2;
|
|
583
|
+
const endY = (curr.y + prev.y) / 2;
|
|
584
|
+
ctx.quadraticCurveTo(curr.x, curr.y, endX, endY);
|
|
585
|
+
}
|
|
586
|
+
ctx.lineTo(rightSide[0].x, rightSide[0].y);
|
|
587
|
+
// Start cap with rounded connection
|
|
588
|
+
const firstPoint = interpolated[0];
|
|
589
|
+
const firstWidth = (firstPoint.width * totalScale) / 2;
|
|
590
|
+
const startExtend = firstWidth * 0.3;
|
|
591
|
+
const firstDx = interpolated.length > 1
|
|
592
|
+
? interpolated[0].x - interpolated[1].x
|
|
593
|
+
: 0;
|
|
594
|
+
const firstDy = interpolated.length > 1
|
|
595
|
+
? interpolated[0].y - interpolated[1].y
|
|
596
|
+
: 0;
|
|
597
|
+
const firstLen = Math.sqrt(firstDx * firstDx + firstDy * firstDy);
|
|
598
|
+
if (firstLen > 0) {
|
|
599
|
+
const startTipX = firstPoint.x + (firstDx / firstLen) * startExtend;
|
|
600
|
+
const startTipY = firstPoint.y + (firstDy / firstLen) * startExtend;
|
|
601
|
+
ctx.quadraticCurveTo(startTipX, startTipY, leftSide[0].x, leftSide[0].y);
|
|
602
|
+
}
|
|
603
|
+
ctx.closePath();
|
|
604
|
+
ctx.fill();
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
430
608
|
else if (annotation.type === 'arrow' && annotation.points.length >= 2) {
|
|
431
609
|
// Draw arrow
|
|
432
610
|
const start = toCanvasCoords(annotation.points[0].x, annotation.points[0].y);
|