tokimeki-image-editor 0.4.14 → 0.5.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.
|
@@ -54,6 +54,7 @@ function swapOrientation() {
|
|
|
54
54
|
lockedAspectRatio = 1 / lockedAspectRatio;
|
|
55
55
|
}
|
|
56
56
|
haptic('selection');
|
|
57
|
+
autoFit(300);
|
|
57
58
|
}
|
|
58
59
|
function resetCrop() {
|
|
59
60
|
if (!image)
|
|
@@ -62,6 +63,7 @@ function resetCrop() {
|
|
|
62
63
|
aspectLocked = false;
|
|
63
64
|
lockedAspectRatio = null;
|
|
64
65
|
haptic('warning');
|
|
66
|
+
autoFit(300);
|
|
65
67
|
}
|
|
66
68
|
// Display helpers
|
|
67
69
|
let displayWidth = $derived(Math.round(cropArea.width));
|
|
@@ -99,17 +101,15 @@ let isResizing = $state(false);
|
|
|
99
101
|
let dragStart = $state({ x: 0, y: 0 });
|
|
100
102
|
let resizeHandle = $state(null);
|
|
101
103
|
let initialCropArea = $state(null);
|
|
102
|
-
// Viewport panning state.
|
|
103
|
-
// 'frame' — drag inside frame: image scrolls under stationary frame (iOS Photos)
|
|
104
|
-
// 'canvas' — drag outside frame: viewport itself moves (image + frame together)
|
|
104
|
+
// Viewport panning state — all drags move the image under a stationary frame (Lightroom style).
|
|
105
105
|
let isPanning = $state(false);
|
|
106
|
-
let panType = $state('frame');
|
|
107
106
|
let lastPanPosition = $state({ x: 0, y: 0 });
|
|
108
|
-
// Touch pinch zoom state — pinch
|
|
107
|
+
// Touch pinch zoom state — pinch changes cropArea (not viewport.zoom) for crop-contextual zoom.
|
|
109
108
|
let initialPinchDistance = $state(0);
|
|
110
|
-
let
|
|
109
|
+
let initialPinchCropArea = $state(null);
|
|
110
|
+
let initialPinchFocusRel = $state({ x: 0.5, y: 0.5 });
|
|
111
111
|
// True when any drag-like interaction is in progress (used to brighten the grid)
|
|
112
|
-
let isInteracting = $derived(isResizing || isPanning);
|
|
112
|
+
let isInteracting = $derived(isResizing || isPanning || initialPinchDistance > 0);
|
|
113
113
|
// SVG render constants — placed up here, derived geometry placed after canvasCoords below
|
|
114
114
|
const cornerLen = 22;
|
|
115
115
|
const cornerThickness = 4;
|
|
@@ -154,6 +154,7 @@ onMount(() => {
|
|
|
154
154
|
containerElement.addEventListener('touchend', handleContainerTouchEndUnified, { passive: false });
|
|
155
155
|
}
|
|
156
156
|
return () => {
|
|
157
|
+
cancelAnimation();
|
|
157
158
|
if (containerElement) {
|
|
158
159
|
containerElement.removeEventListener('touchstart', handleContainerTouchStartUnified);
|
|
159
160
|
containerElement.removeEventListener('touchmove', handleContainerTouchMoveUnified);
|
|
@@ -183,11 +184,14 @@ $effect(() => {
|
|
|
183
184
|
height: image.height
|
|
184
185
|
};
|
|
185
186
|
}
|
|
187
|
+
// Auto-fit viewport to center and fill the crop frame (after state settles)
|
|
188
|
+
queueMicrotask(() => autoFit(400));
|
|
186
189
|
}
|
|
187
190
|
});
|
|
188
191
|
function handleMouseDown(event, handle) {
|
|
189
192
|
if (!canvas || !image)
|
|
190
193
|
return;
|
|
194
|
+
cancelAnimation();
|
|
191
195
|
event.preventDefault();
|
|
192
196
|
event.stopPropagation();
|
|
193
197
|
const coords = getEventCoords(event);
|
|
@@ -201,10 +205,9 @@ function handleMouseDown(event, handle) {
|
|
|
201
205
|
haptic('selection');
|
|
202
206
|
}
|
|
203
207
|
else {
|
|
204
|
-
//
|
|
208
|
+
// Drag → pan the underlying image (Lightroom style).
|
|
205
209
|
// The frame stays put on screen; the image moves beneath it.
|
|
206
210
|
isPanning = true;
|
|
207
|
-
panType = 'frame';
|
|
208
211
|
isDragging = false;
|
|
209
212
|
lastPanPosition = { x: coords.clientX, y: coords.clientY };
|
|
210
213
|
}
|
|
@@ -237,15 +240,15 @@ function isNearResizeHandle(mouseX, mouseY, handleRadius) {
|
|
|
237
240
|
return false;
|
|
238
241
|
}
|
|
239
242
|
// Container mousedown — frame interior and handles stop propagation in their
|
|
240
|
-
//
|
|
241
|
-
//
|
|
243
|
+
// Container mousedown — fires when user pressed outside the frame.
|
|
244
|
+
// Same behavior as inside: image scrolls under stationary frame (Lightroom style).
|
|
242
245
|
function handleContainerMouseDown(event) {
|
|
243
246
|
if (event.button !== 0)
|
|
244
247
|
return;
|
|
248
|
+
cancelAnimation();
|
|
245
249
|
event.preventDefault();
|
|
246
250
|
event.stopPropagation();
|
|
247
251
|
isPanning = true;
|
|
248
|
-
panType = 'canvas';
|
|
249
252
|
isResizing = false;
|
|
250
253
|
resizeHandle = null;
|
|
251
254
|
initialCropArea = null;
|
|
@@ -255,27 +258,11 @@ function handleMouseMove(event) {
|
|
|
255
258
|
if (!canvas || !image)
|
|
256
259
|
return;
|
|
257
260
|
const coords = getEventCoords(event);
|
|
258
|
-
// ── Pan —
|
|
261
|
+
// ── Pan — image scrolls under stationary frame (Lightroom style) ──
|
|
259
262
|
if (isPanning) {
|
|
260
263
|
const deltaX = coords.clientX - lastPanPosition.x;
|
|
261
264
|
const deltaY = coords.clientY - lastPanPosition.y;
|
|
262
265
|
event.preventDefault();
|
|
263
|
-
if (panType === 'canvas') {
|
|
264
|
-
// Drag started outside the frame → move the viewport itself.
|
|
265
|
-
// Both image and frame translate together (cropArea is untouched).
|
|
266
|
-
if (onViewportChange) {
|
|
267
|
-
onViewportChange({
|
|
268
|
-
offsetX: viewport.offsetX + deltaX,
|
|
269
|
-
offsetY: viewport.offsetY + deltaY
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
lastPanPosition = { x: coords.clientX, y: coords.clientY };
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
// panType === 'frame'
|
|
276
|
-
// Drag inside the frame → image scrolls under a stationary frame.
|
|
277
|
-
// cropArea moves in image space by the inverse of the finger motion, and
|
|
278
|
-
// viewport.offset is counter-adjusted so the frame's canvas position stays put.
|
|
279
266
|
const scale = viewport.scale * viewport.zoom;
|
|
280
267
|
const imageDeltaX = deltaX / scale;
|
|
281
268
|
const imageDeltaY = deltaY / scale;
|
|
@@ -394,12 +381,73 @@ function handleMouseMove(event) {
|
|
|
394
381
|
cropArea = next;
|
|
395
382
|
}
|
|
396
383
|
}
|
|
384
|
+
// ── Auto-fit: calculate viewport that centers and fills the crop frame ──
|
|
385
|
+
const AUTO_FIT_PADDING = 0.80; // crop frame fills 80% of canvas
|
|
386
|
+
function calculateAutoFitViewport(crop, img, cvs, baseScale) {
|
|
387
|
+
const targetW = cvs.width * AUTO_FIT_PADDING;
|
|
388
|
+
const targetH = cvs.height * AUTO_FIT_PADDING;
|
|
389
|
+
const fitZoom = Math.min(targetW / (crop.width * baseScale), targetH / (crop.height * baseScale));
|
|
390
|
+
const totalScale = baseScale * fitZoom;
|
|
391
|
+
const cropCenterX = crop.x + crop.width / 2;
|
|
392
|
+
const cropCenterY = crop.y + crop.height / 2;
|
|
393
|
+
const offsetX = -(cropCenterX - img.width / 2) * totalScale;
|
|
394
|
+
const offsetY = -(cropCenterY - img.height / 2) * totalScale;
|
|
395
|
+
return { zoom: fitZoom, offsetX, offsetY };
|
|
396
|
+
}
|
|
397
|
+
// ── Smooth viewport animation (ease-out cubic) ──
|
|
398
|
+
let animationFrameId = null;
|
|
399
|
+
function cancelAnimation() {
|
|
400
|
+
if (animationFrameId !== null) {
|
|
401
|
+
cancelAnimationFrame(animationFrameId);
|
|
402
|
+
animationFrameId = null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function animateViewportTo(target, duration = 300) {
|
|
406
|
+
cancelAnimation();
|
|
407
|
+
if (!onViewportChange)
|
|
408
|
+
return;
|
|
409
|
+
const start = { zoom: viewport.zoom, offsetX: viewport.offsetX, offsetY: viewport.offsetY };
|
|
410
|
+
const startTime = performance.now();
|
|
411
|
+
function tick(now) {
|
|
412
|
+
const elapsed = now - startTime;
|
|
413
|
+
const t = Math.min(1, elapsed / duration);
|
|
414
|
+
const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
|
|
415
|
+
onViewportChange({
|
|
416
|
+
zoom: start.zoom + (target.zoom - start.zoom) * eased,
|
|
417
|
+
offsetX: start.offsetX + (target.offsetX - start.offsetX) * eased,
|
|
418
|
+
offsetY: start.offsetY + (target.offsetY - start.offsetY) * eased
|
|
419
|
+
});
|
|
420
|
+
if (t < 1) {
|
|
421
|
+
animationFrameId = requestAnimationFrame(tick);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
animationFrameId = null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
animationFrameId = requestAnimationFrame(tick);
|
|
428
|
+
}
|
|
429
|
+
function autoFit(duration = 300) {
|
|
430
|
+
if (!canvas || !image)
|
|
431
|
+
return;
|
|
432
|
+
const target = calculateAutoFitViewport(cropArea, image, canvas, viewport.scale);
|
|
433
|
+
if (duration > 0) {
|
|
434
|
+
animateViewportTo(target, duration);
|
|
435
|
+
}
|
|
436
|
+
else if (onViewportChange) {
|
|
437
|
+
onViewportChange(target);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
397
440
|
function handleMouseUp() {
|
|
441
|
+
const wasResizing = isResizing;
|
|
398
442
|
isDragging = false;
|
|
399
443
|
isResizing = false;
|
|
400
444
|
isPanning = false;
|
|
401
445
|
resizeHandle = null;
|
|
402
446
|
initialCropArea = null;
|
|
447
|
+
// Snap-back: auto-fit after resize to re-center the new crop frame
|
|
448
|
+
if (wasResizing) {
|
|
449
|
+
autoFit(300);
|
|
450
|
+
}
|
|
403
451
|
}
|
|
404
452
|
// These will be defined below after pinch zoom handlers are renamed
|
|
405
453
|
function apply() {
|
|
@@ -431,12 +479,14 @@ function setAspectRatio(ratio) {
|
|
|
431
479
|
width: newWidth,
|
|
432
480
|
height: newHeight
|
|
433
481
|
};
|
|
482
|
+
autoFit(300);
|
|
434
483
|
}
|
|
435
|
-
// Wheel zoom —
|
|
436
|
-
//
|
|
484
|
+
// Wheel zoom — crop-contextual: changes cropArea size, not viewport.zoom.
|
|
485
|
+
// Scroll up = zoom in = cropArea shrinks. Cursor-focused.
|
|
437
486
|
function handleWheel(event) {
|
|
438
487
|
if (!canvas || !image)
|
|
439
488
|
return;
|
|
489
|
+
cancelAnimation();
|
|
440
490
|
event.preventDefault();
|
|
441
491
|
event.stopPropagation();
|
|
442
492
|
let dy = event.deltaY;
|
|
@@ -446,11 +496,31 @@ function handleWheel(event) {
|
|
|
446
496
|
dy *= 100; // page
|
|
447
497
|
const coarseness = event.shiftKey ? 0.0048 : 0.002;
|
|
448
498
|
const factor = Math.exp(-dy * coarseness);
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
499
|
+
// factor > 1 = scroll up = zoom in = crop shrinks
|
|
500
|
+
const inverseRatio = 1 / factor;
|
|
501
|
+
const minSize = 50;
|
|
502
|
+
let newWidth = cropArea.width * inverseRatio;
|
|
503
|
+
let newHeight = cropArea.height * inverseRatio;
|
|
504
|
+
newWidth = Math.max(minSize, Math.min(image.width, newWidth));
|
|
505
|
+
newHeight = Math.max(minSize, Math.min(image.height, newHeight));
|
|
506
|
+
if (lockedAspectRatio !== null) {
|
|
507
|
+
if (newWidth / newHeight > lockedAspectRatio) {
|
|
508
|
+
newWidth = newHeight * lockedAspectRatio;
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
newHeight = newWidth / lockedAspectRatio;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// Re-center around cursor position in image coords
|
|
515
|
+
const imgCoords = screenToImageCoords(event.clientX, event.clientY, canvas, image, viewport, transform);
|
|
516
|
+
const relX = Math.max(0, Math.min(1, (imgCoords.x - cropArea.x) / cropArea.width));
|
|
517
|
+
const relY = Math.max(0, Math.min(1, (imgCoords.y - cropArea.y) / cropArea.height));
|
|
518
|
+
let newX = imgCoords.x - relX * newWidth;
|
|
519
|
+
let newY = imgCoords.y - relY * newHeight;
|
|
520
|
+
newX = Math.max(0, Math.min(image.width - newWidth, newX));
|
|
521
|
+
newY = Math.max(0, Math.min(image.height - newHeight, newY));
|
|
522
|
+
cropArea = { x: newX, y: newY, width: newWidth, height: newHeight };
|
|
523
|
+
autoFit(0); // instant re-center
|
|
454
524
|
}
|
|
455
525
|
// Keyboard shortcuts — Enter / Esc / Arrow keys (1px), Shift+Arrow (10px)
|
|
456
526
|
function handleKeyDown(event) {
|
|
@@ -492,70 +562,72 @@ function handleKeyDown(event) {
|
|
|
492
562
|
fitToScreen();
|
|
493
563
|
}
|
|
494
564
|
}
|
|
495
|
-
// ── Cursor-focused canvas zoom ─────────────────────────────────────────
|
|
496
|
-
// This is the *standard* zoom: both the image and the frame scale together
|
|
497
|
-
// (the frame is drawn relative to cropArea in image space, so as viewport.zoom
|
|
498
|
-
// changes, the frame naturally follows the image on screen). cropArea is
|
|
499
|
-
// NOT modified — the "which part of the image is cropped" selection is
|
|
500
|
-
// preserved across zooms, only its visual size on canvas changes.
|
|
501
|
-
//
|
|
502
|
-
// Pan (frame-stationary) is still handled separately in handleMouseMove,
|
|
503
|
-
// where both cropArea and viewport.offset update together.
|
|
504
|
-
const MIN_ZOOM = 0.25;
|
|
505
|
-
const MAX_ZOOM = 10;
|
|
506
|
-
function applyCursorFocusedZoom(targetZoom, focusCanvasX, focusCanvasY) {
|
|
507
|
-
if (!canvas || !onViewportChange)
|
|
508
|
-
return false;
|
|
509
|
-
const oldZoom = viewport.zoom;
|
|
510
|
-
const clampedZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, targetZoom));
|
|
511
|
-
if (Math.abs(clampedZoom - oldZoom) < 1e-4)
|
|
512
|
-
return false;
|
|
513
|
-
// Keep the image point under the focus stationary on screen.
|
|
514
|
-
const cxRel = focusCanvasX - canvas.width / 2;
|
|
515
|
-
const cyRel = focusCanvasY - canvas.height / 2;
|
|
516
|
-
const zoomRatio = clampedZoom / oldZoom;
|
|
517
|
-
const newOffsetX = cxRel - (cxRel - viewport.offsetX) * zoomRatio;
|
|
518
|
-
const newOffsetY = cyRel - (cyRel - viewport.offsetY) * zoomRatio;
|
|
519
|
-
onViewportChange({
|
|
520
|
-
zoom: clampedZoom,
|
|
521
|
-
offsetX: newOffsetX,
|
|
522
|
-
offsetY: newOffsetY
|
|
523
|
-
});
|
|
524
|
-
return true;
|
|
525
|
-
}
|
|
526
565
|
function fitToScreen() {
|
|
527
|
-
if (!onViewportChange)
|
|
528
|
-
return;
|
|
529
566
|
haptic('light');
|
|
530
|
-
|
|
567
|
+
autoFit(300);
|
|
531
568
|
}
|
|
532
569
|
function handlePinchZoomStart(event) {
|
|
533
|
-
if (event.touches.length !== 2)
|
|
570
|
+
if (event.touches.length !== 2 || !canvas || !image)
|
|
534
571
|
return;
|
|
572
|
+
cancelAnimation();
|
|
535
573
|
event.preventDefault();
|
|
536
574
|
const t1 = event.touches[0];
|
|
537
575
|
const t2 = event.touches[1];
|
|
538
576
|
initialPinchDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
|
|
539
|
-
|
|
577
|
+
initialPinchCropArea = { ...cropArea };
|
|
578
|
+
// Calculate pinch midpoint's relative position within the crop area
|
|
579
|
+
const midX = (t1.clientX + t2.clientX) / 2;
|
|
580
|
+
const midY = (t1.clientY + t2.clientY) / 2;
|
|
581
|
+
const imgCoords = screenToImageCoords(midX, midY, canvas, image, viewport, transform);
|
|
582
|
+
initialPinchFocusRel = {
|
|
583
|
+
x: Math.max(0, Math.min(1, (imgCoords.x - cropArea.x) / cropArea.width)),
|
|
584
|
+
y: Math.max(0, Math.min(1, (imgCoords.y - cropArea.y) / cropArea.height))
|
|
585
|
+
};
|
|
540
586
|
}
|
|
541
587
|
function handlePinchZoomMove(event) {
|
|
542
|
-
if (event.touches.length !== 2 || initialPinchDistance === 0)
|
|
588
|
+
if (event.touches.length !== 2 || initialPinchDistance === 0 || !initialPinchCropArea)
|
|
543
589
|
return;
|
|
544
|
-
if (!canvas)
|
|
590
|
+
if (!canvas || !image)
|
|
545
591
|
return;
|
|
546
592
|
event.preventDefault();
|
|
547
593
|
const t1 = event.touches[0];
|
|
548
594
|
const t2 = event.touches[1];
|
|
549
595
|
const distance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
|
|
550
596
|
const scaleRatio = distance / initialPinchDistance;
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
597
|
+
// Pinch out (scaleRatio > 1) = zoom in = crop area shrinks
|
|
598
|
+
const inverseRatio = 1 / scaleRatio;
|
|
599
|
+
const minSize = 50;
|
|
600
|
+
let newWidth = initialPinchCropArea.width * inverseRatio;
|
|
601
|
+
let newHeight = initialPinchCropArea.height * inverseRatio;
|
|
602
|
+
// Enforce minimum size
|
|
603
|
+
newWidth = Math.max(minSize, Math.min(image.width, newWidth));
|
|
604
|
+
newHeight = Math.max(minSize, Math.min(image.height, newHeight));
|
|
605
|
+
// If aspect locked, enforce ratio
|
|
606
|
+
if (lockedAspectRatio !== null) {
|
|
607
|
+
if (newWidth / newHeight > lockedAspectRatio) {
|
|
608
|
+
newWidth = newHeight * lockedAspectRatio;
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
newHeight = newWidth / lockedAspectRatio;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// Re-center around the pinch focus point
|
|
615
|
+
const focusImgX = initialPinchCropArea.x + initialPinchFocusRel.x * initialPinchCropArea.width;
|
|
616
|
+
const focusImgY = initialPinchCropArea.y + initialPinchFocusRel.y * initialPinchCropArea.height;
|
|
617
|
+
let newX = focusImgX - initialPinchFocusRel.x * newWidth;
|
|
618
|
+
let newY = focusImgY - initialPinchFocusRel.y * newHeight;
|
|
619
|
+
// Clamp to image bounds
|
|
620
|
+
newX = Math.max(0, Math.min(image.width - newWidth, newX));
|
|
621
|
+
newY = Math.max(0, Math.min(image.height - newHeight, newY));
|
|
622
|
+
cropArea = { x: newX, y: newY, width: newWidth, height: newHeight };
|
|
623
|
+
// Auto-fit viewport to keep frame centered (instant, no animation during pinch)
|
|
624
|
+
autoFit(0);
|
|
556
625
|
}
|
|
557
626
|
function handlePinchZoomEnd() {
|
|
558
627
|
initialPinchDistance = 0;
|
|
628
|
+
initialPinchCropArea = null;
|
|
629
|
+
// Smooth snap-back after pinch ends
|
|
630
|
+
autoFit(300);
|
|
559
631
|
}
|
|
560
632
|
// Unified touch handlers — 1 finger pan (frame or canvas based on start position),
|
|
561
633
|
// 2 fingers = pinch zoom.
|
|
@@ -570,13 +642,10 @@ function handleContainerTouchStartUnified(event) {
|
|
|
570
642
|
return;
|
|
571
643
|
}
|
|
572
644
|
if (event.touches.length === 1) {
|
|
573
|
-
//
|
|
574
|
-
// touchstart, so if we reach here the touch is *outside* the frame.
|
|
575
|
-
// That maps to canvas pan (move viewport, keep cropArea in place).
|
|
645
|
+
// Outside frame touch — same as inside: image scrolls under frame (Lightroom style).
|
|
576
646
|
event.preventDefault();
|
|
577
647
|
const touch = event.touches[0];
|
|
578
648
|
isPanning = true;
|
|
579
|
-
panType = 'canvas';
|
|
580
649
|
isResizing = false;
|
|
581
650
|
lastPanPosition = { x: touch.clientX, y: touch.clientY };
|
|
582
651
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">import { _ } from 'svelte-i18n';
|
|
2
2
|
import { FILTER_PRESETS, applyFilterPreset, matchesFilterPreset } from '../utils/filters';
|
|
3
|
-
import { applyGaussianBlur } from '../utils/adjustments';
|
|
3
|
+
import { applyGaussianBlur, generateCurveLUT, isToneCurveDefault, isHSLDefault } from '../utils/adjustments';
|
|
4
4
|
import ToolPanel from './ToolPanel.svelte';
|
|
5
5
|
let { image, adjustments, transform, cropArea, onChange, onClose } = $props();
|
|
6
6
|
// Find currently selected filter (if any)
|
|
@@ -35,6 +35,38 @@ function createSimpleThumb() {
|
|
|
35
35
|
return null;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
// Lightroom-style HSL per-color blending (matches shader blendHSLAdjustments)
|
|
39
|
+
function blendHSLAdjustmentsJS(hueDeg, hslAdj) {
|
|
40
|
+
let h = ((hueDeg % 360) + 360) % 360;
|
|
41
|
+
const r = [hslAdj.red.hue, hslAdj.red.saturation, hslAdj.red.luminance];
|
|
42
|
+
const o = [hslAdj.orange.hue, hslAdj.orange.saturation, hslAdj.orange.luminance];
|
|
43
|
+
const y = [hslAdj.yellow.hue, hslAdj.yellow.saturation, hslAdj.yellow.luminance];
|
|
44
|
+
const g = [hslAdj.green.hue, hslAdj.green.saturation, hslAdj.green.luminance];
|
|
45
|
+
const a = [hslAdj.aqua.hue, hslAdj.aqua.saturation, hslAdj.aqua.luminance];
|
|
46
|
+
const b = [hslAdj.blue.hue, hslAdj.blue.saturation, hslAdj.blue.luminance];
|
|
47
|
+
const p = [hslAdj.purple.hue, hslAdj.purple.saturation, hslAdj.purple.luminance];
|
|
48
|
+
const m = [hslAdj.magenta.hue, hslAdj.magenta.saturation, hslAdj.magenta.luminance];
|
|
49
|
+
const mix3 = (a, b, t) => [
|
|
50
|
+
a[0] + (b[0] - a[0]) * t,
|
|
51
|
+
a[1] + (b[1] - a[1]) * t,
|
|
52
|
+
a[2] + (b[2] - a[2]) * t,
|
|
53
|
+
];
|
|
54
|
+
if (h < 30)
|
|
55
|
+
return mix3(r, o, h / 30);
|
|
56
|
+
if (h < 60)
|
|
57
|
+
return mix3(o, y, (h - 30) / 30);
|
|
58
|
+
if (h < 120)
|
|
59
|
+
return mix3(y, g, (h - 60) / 60);
|
|
60
|
+
if (h < 180)
|
|
61
|
+
return mix3(g, a, (h - 120) / 60);
|
|
62
|
+
if (h < 240)
|
|
63
|
+
return mix3(a, b, (h - 180) / 60);
|
|
64
|
+
if (h < 270)
|
|
65
|
+
return mix3(b, p, (h - 240) / 30);
|
|
66
|
+
if (h < 300)
|
|
67
|
+
return mix3(p, m, (h - 270) / 30);
|
|
68
|
+
return mix3(m, r, (h - 300) / 60);
|
|
69
|
+
}
|
|
38
70
|
// Apply adjustments to canvas via pixel manipulation (Safari-compatible)
|
|
39
71
|
// Must match the shader order and calculations EXACTLY
|
|
40
72
|
function applyAdjustmentsToCanvas(ctx, canvas, adjustments, sourceImageSize) {
|
|
@@ -50,11 +82,21 @@ function applyAdjustmentsToCanvas(ctx, canvas, adjustments, sourceImageSize) {
|
|
|
50
82
|
adjustments.grayscale === 0 &&
|
|
51
83
|
adjustments.vignette === 0 &&
|
|
52
84
|
adjustments.blur === 0 &&
|
|
53
|
-
adjustments.grain === 0
|
|
85
|
+
adjustments.grain === 0 &&
|
|
86
|
+
(!adjustments.toneCurve || isToneCurveDefault(adjustments.toneCurve)) &&
|
|
87
|
+
(!adjustments.hsl || isHSLDefault(adjustments.hsl))) {
|
|
54
88
|
return;
|
|
55
89
|
}
|
|
56
90
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
57
91
|
const data = imageData.data;
|
|
92
|
+
// Tone curve LUT (applied before all other adjustments, matching shader)
|
|
93
|
+
const hasToneCurve = !!adjustments.toneCurve && !isToneCurveDefault(adjustments.toneCurve);
|
|
94
|
+
let curveLUT = null;
|
|
95
|
+
if (hasToneCurve) {
|
|
96
|
+
curveLUT = generateCurveLUT(adjustments.toneCurve);
|
|
97
|
+
}
|
|
98
|
+
// HSL per-color
|
|
99
|
+
const hasHSL = !!adjustments.hsl && !isHSLDefault(adjustments.hsl);
|
|
58
100
|
// Pre-calculate adjustment factors
|
|
59
101
|
const hasBrightness = adjustments.brightness !== 0;
|
|
60
102
|
const hasContrast = adjustments.contrast !== 0;
|
|
@@ -85,6 +127,15 @@ function applyAdjustmentsToCanvas(ctx, canvas, adjustments, sourceImageSize) {
|
|
|
85
127
|
let r = data[i] / 255;
|
|
86
128
|
let g = data[i + 1] / 255;
|
|
87
129
|
let b = data[i + 2] / 255;
|
|
130
|
+
// 0. Tone curve (before all adjustments, matching shader)
|
|
131
|
+
if (hasToneCurve && curveLUT) {
|
|
132
|
+
const ri = Math.max(0, Math.min(255, Math.round(r * 255)));
|
|
133
|
+
const gi = Math.max(0, Math.min(255, Math.round(g * 255)));
|
|
134
|
+
const bi = Math.max(0, Math.min(255, Math.round(b * 255)));
|
|
135
|
+
r = curveLUT[ri * 4 + 0] / 255;
|
|
136
|
+
g = curveLUT[gi * 4 + 1] / 255;
|
|
137
|
+
b = curveLUT[bi * 4 + 2] / 255;
|
|
138
|
+
}
|
|
88
139
|
// 1. Brightness (FIRST, like shader)
|
|
89
140
|
if (hasBrightness) {
|
|
90
141
|
r *= brightnessFactor;
|
|
@@ -119,6 +170,35 @@ function applyAdjustmentsToCanvas(ctx, canvas, adjustments, sourceImageSize) {
|
|
|
119
170
|
b = b + b * (adjustments.highlights / 100) * highlightMask * 0.5;
|
|
120
171
|
}
|
|
121
172
|
}
|
|
173
|
+
// 4.5. HSL per-color adjustment (Lightroom-style, matching shader)
|
|
174
|
+
if (hasHSL && adjustments.hsl) {
|
|
175
|
+
r = Math.max(0, Math.min(1, r));
|
|
176
|
+
g = Math.max(0, Math.min(1, g));
|
|
177
|
+
b = Math.max(0, Math.min(1, b));
|
|
178
|
+
// rgbToHsl returns [h: 0-360, s: 0-100, l: 0-100]
|
|
179
|
+
const [hDeg, sVal, lVal] = rgbToHsl(r * 255, g * 255, b * 255);
|
|
180
|
+
let hNorm = hDeg / 360;
|
|
181
|
+
let sNorm = sVal / 100;
|
|
182
|
+
let lNorm = lVal / 100;
|
|
183
|
+
const adj = blendHSLAdjustmentsJS(hDeg, adjustments.hsl);
|
|
184
|
+
if (adj[0] !== 0 || adj[1] !== 0 || adj[2] !== 0) {
|
|
185
|
+
// Hue shift (additive, wrapping)
|
|
186
|
+
hNorm = hNorm + adj[0] / 360;
|
|
187
|
+
if (hNorm < 0)
|
|
188
|
+
hNorm += 1;
|
|
189
|
+
if (hNorm > 1)
|
|
190
|
+
hNorm -= 1;
|
|
191
|
+
// Saturation (proportional)
|
|
192
|
+
sNorm = Math.max(0, Math.min(1, sNorm * (1 + adj[1] / 100)));
|
|
193
|
+
// Luminance (weighted additive — stronger in midtones)
|
|
194
|
+
const lumWeight = 4 * lNorm * (1 - lNorm);
|
|
195
|
+
lNorm = Math.max(0, Math.min(1, lNorm + (adj[2] / 100) * Math.max(lumWeight, 0.15)));
|
|
196
|
+
[r, g, b] = hslToRgb(hNorm * 360, sNorm * 100, lNorm * 100);
|
|
197
|
+
r /= 255;
|
|
198
|
+
g /= 255;
|
|
199
|
+
b /= 255;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
122
202
|
// 5. Saturation (via HSL)
|
|
123
203
|
if (hasSaturation) {
|
|
124
204
|
// Clamp before HSL conversion
|
|
@@ -490,7 +570,7 @@ function handleWheel(e) {
|
|
|
490
570
|
rgba(0, 0, 0, 0.4) 60%,
|
|
491
571
|
transparent 100%
|
|
492
572
|
);
|
|
493
|
-
color:
|
|
573
|
+
color: #ffffff;
|
|
494
574
|
padding: var(--tk-space-3) var(--tk-space-2) var(--tk-space-1);
|
|
495
575
|
font-size: var(--tk-text-2xs);
|
|
496
576
|
font-weight: var(--tk-weight-semibold);
|