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 now zooms the underlying image, not the crop area.
107
+ // Touch pinch zoom state — pinch changes cropArea (not viewport.zoom) for crop-contextual zoom.
109
108
  let initialPinchDistance = $state(0);
110
- let initialPinchZoom = $state(1);
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
- // Frame interior drag → pan the underlying image (iOS Photos / Lightroom style).
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
- // own handlers, so this only fires when the user pressed *outside* the frame.
241
- // That case = canvas pan (viewport moves, image and frame both follow the finger).
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 — two flavours depending on where the drag started ──
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 — exponential (log-scale feel), cursor-focused.
436
- // Normalizes trackpad line-mode / pixel-mode. Shift = coarse step.
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
- const targetZoom = viewport.zoom * factor;
450
- const rect = canvas.getBoundingClientRect();
451
- const focusX = event.clientX - rect.left;
452
- const focusY = event.clientY - rect.top;
453
- applyCursorFocusedZoom(targetZoom, focusX, focusY);
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
- onViewportChange({ zoom: 1, offsetX: 0, offsetY: 0 });
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
- initialPinchZoom = viewport.zoom;
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
- const targetZoom = initialPinchZoom * scaleRatio;
552
- const rect = canvas.getBoundingClientRect();
553
- const focusX = (t1.clientX + t2.clientX) / 2 - rect.left;
554
- const focusY = (t1.clientY + t2.clientY) / 2 - rect.top;
555
- applyCursorFocusedZoom(targetZoom, focusX, focusY);
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
- // Child elements (frame border / handles) stopPropagation in their own
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: var(--tk-text-primary);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokimeki-image-editor",
3
- "version": "0.4.14",
3
+ "version": "0.5.0",
4
4
  "description": "A image editor for svelte.",
5
5
  "type": "module",
6
6
  "license": "MIT",