tokimeki-image-editor 0.2.4 → 0.2.6
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 +381 -9
- package/dist/components/AnnotationTool.svelte.d.ts +2 -0
- package/dist/components/ImageEditor.svelte +11 -1
- package/dist/components/ImageEditor.svelte.d.ts +4 -0
- 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,20 +1,25 @@
|
|
|
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
|
-
let { canvas, image, viewport, transform, annotations, cropArea, onUpdate, onClose, onViewportChange } = $props();
|
|
6
|
+
let { canvas, image, viewport, transform, annotations, cropArea, initialStrokeWidth, initialColor, onUpdate, onClose, onViewportChange } = $props();
|
|
7
7
|
let containerElement = $state(null);
|
|
8
8
|
// Tool settings
|
|
9
9
|
let currentTool = $state('pen');
|
|
10
|
-
let currentColor = $state('#FF6B6B');
|
|
11
|
-
let strokeWidth = $state(10);
|
|
10
|
+
let currentColor = $state(initialColor ?? '#FF6B6B');
|
|
11
|
+
let strokeWidth = $state(initialStrokeWidth ?? 10);
|
|
12
12
|
let shadowEnabled = $state(false);
|
|
13
13
|
// Preset colors - modern bright tones
|
|
14
14
|
const colorPresets = ['#FF6B6B', '#FFA94D', '#FFD93D', '#6BCB77', '#4D96FF', '#9B72F2', '#F8F9FA', '#495057'];
|
|
15
15
|
// Drawing state
|
|
16
16
|
let isDrawing = $state(false);
|
|
17
17
|
let currentAnnotation = $state(null);
|
|
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
|
|
18
23
|
// Panning state (Space + drag on desktop, 2-finger drag on mobile)
|
|
19
24
|
let isSpaceHeld = $state(false);
|
|
20
25
|
let isPanning = $state(false);
|
|
@@ -152,6 +157,25 @@ function handleMouseDown(event) {
|
|
|
152
157
|
shadow: shadowEnabled
|
|
153
158
|
};
|
|
154
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
|
+
}
|
|
155
179
|
else if (currentTool === 'arrow' || currentTool === 'rectangle') {
|
|
156
180
|
currentAnnotation = {
|
|
157
181
|
id: `annotation-${Date.now()}`,
|
|
@@ -199,6 +223,56 @@ function handleMouseMove(event) {
|
|
|
199
223
|
};
|
|
200
224
|
}
|
|
201
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
|
+
}
|
|
202
276
|
else if (currentTool === 'arrow' || currentTool === 'rectangle') {
|
|
203
277
|
currentAnnotation = {
|
|
204
278
|
...currentAnnotation,
|
|
@@ -217,11 +291,73 @@ function handleMouseUp(event) {
|
|
|
217
291
|
isDrawing = false;
|
|
218
292
|
return;
|
|
219
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
|
+
}
|
|
220
355
|
// Only save if annotation has enough points
|
|
221
356
|
if (currentAnnotation.points.length >= 2 ||
|
|
222
|
-
(currentAnnotation.type === 'pen' && currentAnnotation.points.length >= 1)
|
|
357
|
+
(currentAnnotation.type === 'pen' && currentAnnotation.points.length >= 1) ||
|
|
358
|
+
(currentAnnotation.type === 'brush' && currentAnnotation.points.length >= 1)) {
|
|
223
359
|
// For arrow/rectangle, ensure start and end are different
|
|
224
|
-
if (currentAnnotation.type !== 'pen') {
|
|
360
|
+
if (currentAnnotation.type !== 'pen' && currentAnnotation.type !== 'brush') {
|
|
225
361
|
const start = currentAnnotation.points[0];
|
|
226
362
|
const end = currentAnnotation.points[1];
|
|
227
363
|
const distance = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
|
|
@@ -235,6 +371,10 @@ function handleMouseUp(event) {
|
|
|
235
371
|
}
|
|
236
372
|
isDrawing = false;
|
|
237
373
|
currentAnnotation = null;
|
|
374
|
+
lastPointTime = 0;
|
|
375
|
+
lastPointPos = null;
|
|
376
|
+
recentSpeeds = [];
|
|
377
|
+
strokeStartTime = 0;
|
|
238
378
|
}
|
|
239
379
|
function findAnnotationAtPoint(canvasX, canvasY) {
|
|
240
380
|
const hitRadius = 10;
|
|
@@ -398,11 +538,216 @@ function generateSmoothPath(points) {
|
|
|
398
538
|
path += ` L ${lastPoint.x} ${lastPoint.y}`;
|
|
399
539
|
return path;
|
|
400
540
|
}
|
|
401
|
-
//
|
|
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
|
|
402
742
|
let currentAnnotationCanvas = $derived.by(() => {
|
|
403
743
|
if (!currentAnnotation)
|
|
404
744
|
return null;
|
|
405
|
-
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);
|
|
406
751
|
return { ...currentAnnotation, canvasPoints: points };
|
|
407
752
|
});
|
|
408
753
|
</script>
|
|
@@ -447,6 +792,18 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
447
792
|
stroke-linejoin="round"
|
|
448
793
|
filter={shadowFilter}
|
|
449
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
|
+
/>
|
|
450
807
|
{:else if annotation.type === 'arrow' && points.length >= 2}
|
|
451
808
|
{@const start = points[0]}
|
|
452
809
|
{@const end = points[1]}
|
|
@@ -510,6 +867,13 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
510
867
|
stroke-linejoin="round"
|
|
511
868
|
filter={currentShadowFilter}
|
|
512
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
|
+
/>
|
|
513
877
|
{:else if currentAnnotationCanvas.type === 'arrow' && points.length >= 2}
|
|
514
878
|
{@const start = points[0]}
|
|
515
879
|
{@const end = points[1]}
|
|
@@ -574,6 +938,14 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
574
938
|
>
|
|
575
939
|
<Pencil size={20} />
|
|
576
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>
|
|
577
949
|
<button
|
|
578
950
|
class="tool-btn"
|
|
579
951
|
class:active={currentTool === 'eraser'}
|
|
@@ -633,7 +1005,7 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
633
1005
|
id="stroke-width"
|
|
634
1006
|
type="range"
|
|
635
1007
|
min="5"
|
|
636
|
-
max="
|
|
1008
|
+
max="100"
|
|
637
1009
|
bind:value={strokeWidth}
|
|
638
1010
|
/>
|
|
639
1011
|
</div>
|
|
@@ -6,6 +6,8 @@ interface Props {
|
|
|
6
6
|
transform: TransformState;
|
|
7
7
|
annotations: Annotation[];
|
|
8
8
|
cropArea?: CropArea | null;
|
|
9
|
+
initialStrokeWidth?: number;
|
|
10
|
+
initialColor?: string;
|
|
9
11
|
onUpdate: (annotations: Annotation[]) => void;
|
|
10
12
|
onClose: () => void;
|
|
11
13
|
onViewportChange?: (viewport: Partial<Viewport>) => void;
|
|
@@ -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, initialStrokeWidth, initialColor, 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);
|
|
@@ -478,6 +486,8 @@ function handleKeyDown(event) {
|
|
|
478
486
|
transform={state.transform}
|
|
479
487
|
annotations={state.annotations}
|
|
480
488
|
cropArea={state.cropArea}
|
|
489
|
+
{initialStrokeWidth}
|
|
490
|
+
{initialColor}
|
|
481
491
|
onUpdate={handleAnnotationsChange}
|
|
482
492
|
onClose={() => state.mode = null}
|
|
483
493
|
onViewportChange={handleViewportChange}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import '../i18n';
|
|
2
|
+
import type { EditorMode } from '../types';
|
|
2
3
|
interface Props {
|
|
3
4
|
initialImage?: File | string;
|
|
5
|
+
initialMode?: EditorMode;
|
|
6
|
+
initialStrokeWidth?: number;
|
|
7
|
+
initialColor?: string;
|
|
4
8
|
width?: number;
|
|
5
9
|
height?: number;
|
|
6
10
|
isStandalone?: boolean;
|
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);
|