tokimeki-image-editor 0.4.2 → 0.4.4
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 +20 -5
- package/dist/components/BlurTool.svelte +80 -20
- package/dist/components/Canvas.svelte +2 -6
- package/dist/shaders/image-editor.d.ts +1 -1
- package/dist/shaders/image-editor.js +10 -10
- package/dist/utils/editor-core.js +0 -6
- package/dist/utils/editor-interaction.d.ts +6 -10
- package/dist/utils/editor-interaction.js +36 -13
- package/dist/utils/webgpu-render.js +227 -455
- package/package.json +1 -1
|
@@ -736,21 +736,36 @@ function handleTouchMove(event) {
|
|
|
736
736
|
const touch1 = event.touches[0];
|
|
737
737
|
const touch2 = event.touches[1];
|
|
738
738
|
const distance = Math.hypot(touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY);
|
|
739
|
+
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
|
740
|
+
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
|
739
741
|
if (interactionState.initialPinchDistance === 0) {
|
|
740
|
-
interactionState = { ...interactionState, initialPinchDistance: distance, initialPinchZoom: viewport.zoom };
|
|
742
|
+
interactionState = { ...interactionState, initialPinchDistance: distance, initialPinchZoom: viewport.zoom, lastPinchCenter: { x: centerX, y: centerY } };
|
|
741
743
|
}
|
|
742
744
|
else {
|
|
743
745
|
const scale = distance / interactionState.initialPinchDistance;
|
|
744
746
|
const newZoom = Math.max(0.1, Math.min(10, interactionState.initialPinchZoom * scale));
|
|
745
747
|
const delta = Math.log(newZoom / viewport.zoom);
|
|
746
|
-
const
|
|
747
|
-
const
|
|
748
|
+
const panDeltaX = centerX - interactionState.lastPinchCenter.x;
|
|
749
|
+
const panDeltaY = centerY - interactionState.lastPinchCenter.y;
|
|
750
|
+
// Compute zoom-at-point then add pan offset
|
|
748
751
|
const canvasRect = canvas.getBoundingClientRect();
|
|
749
|
-
const
|
|
750
|
-
onViewportChange({ zoom:
|
|
752
|
+
const zoomedViewport = calculateZoomViewport(viewport, delta, canvas.width, canvas.height, centerX, centerY, canvasRect);
|
|
753
|
+
onViewportChange({ zoom: zoomedViewport.zoom, offsetX: zoomedViewport.offsetX + panDeltaX, offsetY: zoomedViewport.offsetY + panDeltaY });
|
|
754
|
+
interactionState = { ...interactionState, lastPinchCenter: { x: centerX, y: centerY } };
|
|
751
755
|
}
|
|
752
756
|
return;
|
|
753
757
|
}
|
|
758
|
+
// Single finger after two-finger gesture: pan instead of draw
|
|
759
|
+
if (event.touches.length === 1 && interactionState.isTwoFingerTouch && interactionState.isPanning && onViewportChange && canvas && image) {
|
|
760
|
+
event.preventDefault();
|
|
761
|
+
const touch = event.touches[0];
|
|
762
|
+
const deltaX = touch.clientX - interactionState.lastPanPosition.x;
|
|
763
|
+
const deltaY = touch.clientY - interactionState.lastPanPosition.y;
|
|
764
|
+
const result = calculatePanOffset(viewport, deltaX, deltaY, image.width, image.height, canvas.width, canvas.height, cropArea);
|
|
765
|
+
onViewportChange(result);
|
|
766
|
+
interactionState = { ...interactionState, lastPanPosition: { x: touch.clientX, y: touch.clientY } };
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
754
769
|
// Single finger drawing (only if not in two-finger mode)
|
|
755
770
|
if (event.touches.length === 1 && !interactionState.isTwoFingerTouch) {
|
|
756
771
|
handleMouseMove(event);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { _ } from 'svelte-i18n';
|
|
3
3
|
import { X, Trash2, Droplet, Info } from 'lucide-svelte';
|
|
4
4
|
import { screenToImageCoords, imageToCanvasCoords } from '../utils/canvas';
|
|
5
|
+
import { calculateZoomViewport, calculatePanOffset } from '../utils/editor-interaction';
|
|
5
6
|
import FloatingRail from './FloatingRail.svelte';
|
|
6
7
|
import RailButton from './RailButton.svelte';
|
|
7
8
|
import Popover from './Popover.svelte';
|
|
@@ -61,6 +62,11 @@ let initialArea = $state(null);
|
|
|
61
62
|
// Viewport panning
|
|
62
63
|
let isPanning = $state(false);
|
|
63
64
|
let lastPanPosition = $state({ x: 0, y: 0 });
|
|
65
|
+
// Two-finger pinch/pan gesture state
|
|
66
|
+
let isTwoFingerTouch = $state(false);
|
|
67
|
+
let initialPinchDistance = $state(0);
|
|
68
|
+
let initialPinchZoom = $state(1);
|
|
69
|
+
let lastPinchCenter = $state({ x: 0, y: 0 });
|
|
64
70
|
// Convert blur areas to canvas coordinates for rendering
|
|
65
71
|
let canvasBlurAreas = $derived.by(() => {
|
|
66
72
|
if (!canvas || !image)
|
|
@@ -136,26 +142,12 @@ function handleMouseMove(event) {
|
|
|
136
142
|
const rect = canvas.getBoundingClientRect();
|
|
137
143
|
const mouseX = coords.clientX - rect.left;
|
|
138
144
|
const mouseY = coords.clientY - rect.top;
|
|
139
|
-
// Handle viewport panning
|
|
145
|
+
// Handle viewport panning (using shared utility)
|
|
140
146
|
if (isPanning && onViewportChange) {
|
|
141
147
|
const deltaX = coords.clientX - lastPanPosition.x;
|
|
142
148
|
const deltaY = coords.clientY - lastPanPosition.y;
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
const totalScale = viewport.scale * viewport.zoom;
|
|
146
|
-
const scaledWidth = imgWidth * totalScale;
|
|
147
|
-
const scaledHeight = imgHeight * totalScale;
|
|
148
|
-
const overflowMargin = 0.2;
|
|
149
|
-
const maxOffsetX = (scaledWidth / 2) - (canvas.width / 2) + (canvas.width * overflowMargin);
|
|
150
|
-
const maxOffsetY = (scaledHeight / 2) - (canvas.height / 2) + (canvas.height * overflowMargin);
|
|
151
|
-
const newOffsetX = viewport.offsetX + deltaX;
|
|
152
|
-
const newOffsetY = viewport.offsetY + deltaY;
|
|
153
|
-
const clampedOffsetX = Math.max(-maxOffsetX, Math.min(maxOffsetX, newOffsetX));
|
|
154
|
-
const clampedOffsetY = Math.max(-maxOffsetY, Math.min(maxOffsetY, newOffsetY));
|
|
155
|
-
onViewportChange({
|
|
156
|
-
offsetX: clampedOffsetX,
|
|
157
|
-
offsetY: clampedOffsetY
|
|
158
|
-
});
|
|
149
|
+
const result = calculatePanOffset(viewport, deltaX, deltaY, image.width, image.height, canvas.width, canvas.height, cropArea);
|
|
150
|
+
onViewportChange(result);
|
|
159
151
|
lastPanPosition = { x: coords.clientX, y: coords.clientY };
|
|
160
152
|
event.preventDefault();
|
|
161
153
|
return;
|
|
@@ -321,13 +313,81 @@ function handleBlurStrengthChange(value) {
|
|
|
321
313
|
onUpdate(updatedAreas);
|
|
322
314
|
}
|
|
323
315
|
const selectedArea = $derived(blurAreas.find(area => area.id === selectedAreaId));
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
316
|
+
// Touch handlers with 2-finger pinch/pan support
|
|
317
|
+
function handleContainerTouchStart(event) {
|
|
318
|
+
if (event.touches.length === 2) {
|
|
319
|
+
event.preventDefault();
|
|
320
|
+
// Cancel any in-progress creation
|
|
321
|
+
if (isCreating) {
|
|
322
|
+
isCreating = false;
|
|
323
|
+
createStart = null;
|
|
324
|
+
createEnd = null;
|
|
325
|
+
}
|
|
326
|
+
isTwoFingerTouch = true;
|
|
327
|
+
initialPinchDistance = 0;
|
|
328
|
+
initialPinchZoom = viewport.zoom;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (event.touches.length === 1 && !isTwoFingerTouch) {
|
|
332
|
+
handleContainerMouseDown(event);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function handleTouchMove(event) {
|
|
336
|
+
// Two-finger pinch zoom + pan
|
|
337
|
+
if (event.touches.length === 2 && onViewportChange && canvas) {
|
|
338
|
+
event.preventDefault();
|
|
339
|
+
const touch1 = event.touches[0];
|
|
340
|
+
const touch2 = event.touches[1];
|
|
341
|
+
const distance = Math.hypot(touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY);
|
|
342
|
+
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
|
343
|
+
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
|
344
|
+
if (initialPinchDistance === 0) {
|
|
345
|
+
initialPinchDistance = distance;
|
|
346
|
+
initialPinchZoom = viewport.zoom;
|
|
347
|
+
lastPinchCenter = { x: centerX, y: centerY };
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const scale = distance / initialPinchDistance;
|
|
351
|
+
const newZoom = Math.max(0.1, Math.min(10, initialPinchZoom * scale));
|
|
352
|
+
const delta = Math.log(newZoom / viewport.zoom);
|
|
353
|
+
const panDeltaX = centerX - lastPinchCenter.x;
|
|
354
|
+
const panDeltaY = centerY - lastPinchCenter.y;
|
|
355
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
356
|
+
const zoomedViewport = calculateZoomViewport(viewport, delta, canvas.width, canvas.height, centerX, centerY, canvasRect);
|
|
357
|
+
onViewportChange({ zoom: zoomedViewport.zoom, offsetX: zoomedViewport.offsetX + panDeltaX, offsetY: zoomedViewport.offsetY + panDeltaY });
|
|
358
|
+
lastPinchCenter = { x: centerX, y: centerY };
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
// Single finger after two-finger gesture: pan
|
|
363
|
+
if (event.touches.length === 1 && isTwoFingerTouch && onViewportChange && canvas && image) {
|
|
364
|
+
event.preventDefault();
|
|
365
|
+
const touch = event.touches[0];
|
|
366
|
+
const deltaX = touch.clientX - lastPanPosition.x;
|
|
367
|
+
const deltaY = touch.clientY - lastPanPosition.y;
|
|
368
|
+
const result = calculatePanOffset(viewport, deltaX, deltaY, image.width, image.height, canvas.width, canvas.height, cropArea);
|
|
369
|
+
onViewportChange(result);
|
|
370
|
+
lastPanPosition = { x: touch.clientX, y: touch.clientY };
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// Single finger: normal tool operation
|
|
374
|
+
if (event.touches.length === 1 && !isTwoFingerTouch) {
|
|
375
|
+
handleMouseMove(event);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
327
378
|
function handleTouchEnd(event) {
|
|
328
379
|
if (event.touches.length === 0) {
|
|
380
|
+
if (isTwoFingerTouch) {
|
|
381
|
+
isTwoFingerTouch = false;
|
|
382
|
+
initialPinchDistance = 0;
|
|
383
|
+
}
|
|
329
384
|
handleMouseUp();
|
|
330
385
|
}
|
|
386
|
+
else if (event.touches.length === 1 && isTwoFingerTouch) {
|
|
387
|
+
// Transition from 2→1 finger: enable pan with remaining finger
|
|
388
|
+
lastPanPosition = { x: event.touches[0].clientX, y: event.touches[0].clientY };
|
|
389
|
+
initialPinchDistance = 0;
|
|
390
|
+
}
|
|
331
391
|
}
|
|
332
392
|
</script>
|
|
333
393
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script lang="ts">import { onMount, onDestroy } from 'svelte';
|
|
2
2
|
import { drawImage, preloadStampImage, applyStamps, applyAnnotations } from '../utils/canvas';
|
|
3
3
|
import { initWebGPUCanvas, uploadImageToGPU, renderWithAdjustments, cleanupWebGPU, setCanvasClearColor, updateCurveLUT } from '../utils/webgpu-render';
|
|
4
|
-
import { isToneCurveDefault } from '../utils/adjustments';
|
|
5
4
|
import { createEditorInteractionState, handlePureMouseDown, handlePureMouseMove, handlePureMouseUp, handlePureTouchStart, handlePureTouchMove, handlePureTouchEnd, calculateZoomViewport } from '../utils/editor-interaction';
|
|
6
5
|
import { haptic } from '../utils/haptics';
|
|
7
6
|
let { canvas = $bindable(null), width, height, image, viewport, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], annotations = [], skipAnnotations = false, theme = 'dark', onZoom, onViewportChange } = $props();
|
|
@@ -99,9 +98,7 @@ $effect(() => {
|
|
|
99
98
|
if (useWebGPU && webgpuReady && currentImage && canvasElement) {
|
|
100
99
|
renderWebGPU();
|
|
101
100
|
}
|
|
102
|
-
adjustments.
|
|
103
|
-
adjustments.contrast;
|
|
104
|
-
adjustments.saturation;
|
|
101
|
+
adjustments; // Track all adjustment properties (blur, grain, sharpen, denoise, vignette, etc.)
|
|
105
102
|
viewport.offsetX;
|
|
106
103
|
viewport.offsetY;
|
|
107
104
|
viewport.zoom;
|
|
@@ -120,6 +117,7 @@ $effect(() => {
|
|
|
120
117
|
annotations;
|
|
121
118
|
width;
|
|
122
119
|
height;
|
|
120
|
+
theme;
|
|
123
121
|
});
|
|
124
122
|
// 2D Canvas: Render when parameters change
|
|
125
123
|
$effect(() => {
|
|
@@ -271,8 +269,6 @@ function handleTouchMove(e) {
|
|
|
271
269
|
interactionState = result.state;
|
|
272
270
|
if (result.viewportUpdate && onViewportChange)
|
|
273
271
|
onViewportChange(result.viewportUpdate);
|
|
274
|
-
if (result.zoomInfo && onZoom)
|
|
275
|
-
onZoom(result.zoomInfo.delta, result.zoomInfo.centerX, result.zoomInfo.centerY);
|
|
276
272
|
}
|
|
277
273
|
}
|
|
278
274
|
function handleTouchEnd(e) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const IMAGE_EDITOR_SHADER_CODE = "struct VertexOutput {\n @builtin(position) position: vec4<f32>,\n @location(0) uv: vec2<f32>,\n};\n\nstruct Uniforms {\n // Adjustments (11 params)\n brightness: f32,\n contrast: f32,\n exposure: f32,\n highlights: f32,\n shadows: f32,\n saturation: f32,\n temperature: f32,\n sepia: f32,\n grayscale: f32,\n vignette: f32,\n grain: f32,\n\n // Viewport (4 params)\n viewportZoom: f32,\n viewportOffsetX: f32,\n viewportOffsetY: f32,\n viewportScale: f32,\n\n // Transform (4 params)\n rotation: f32, // in radians\n flipHorizontal: f32, // 1.0 or -1.0\n flipVertical: f32, // 1.0 or -1.0\n transformScale: f32,\n\n // Canvas dimensions (2 params)\n canvasWidth: f32,\n canvasHeight: f32,\n\n // Image dimensions (2 params)\n imageWidth: f32,\n imageHeight: f32,\n\n // Crop area (4 params)\n cropX: f32,\n cropY: f32,\n cropWidth: f32,\n cropHeight: f32,\n\n // Padding to align HSL block to 16-byte boundary\n // After cropHeight we're at offset 27*4=108, need to reach offset 128 (32*4) for vec4 alignment\n _padAlign0: f32,\n _padAlign1: f32,\n _padAlign2: f32,\n _padAlign3: f32,\n _padAlign4: f32,\n\n // HSL per-color adjustment (8 colors \u00D7 vec4 = 32 floats, starting at offset 128)\n // Each vec4: (hue_shift, saturation_adj, luminance_adj, unused)\n hslRed: vec4<f32>,\n hslOrange: vec4<f32>,\n hslYellow: vec4<f32>,\n hslGreen: vec4<f32>,\n hslAqua: vec4<f32>,\n hslBlue: vec4<f32>,\n hslPurple: vec4<f32>,\n hslMagenta: vec4<f32>,\n};\n\n@group(0) @binding(0) var mySampler: sampler;\n@group(0) @binding(1) var myTexture: texture_2d<f32>;\n@group(0) @binding(2) var<uniform> params: Uniforms;\n@group(0) @binding(3) var curveLUTSampler: sampler;\n@group(0) @binding(4) var curveLUTTexture: texture_2d<f32>;\n\n// Full-screen triangle vertex shader\n@vertex\nfn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {\n var pos = array<vec2<f32>, 3>(\n vec2<f32>(-1.0, -1.0),\n vec2<f32>( 3.0, -1.0),\n vec2<f32>(-1.0, 3.0)\n );\n var output: VertexOutput;\n output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);\n\n // Convert NDC (-1 to 1) to canvas coordinates centered at origin\n // NDC: -1 \u2192 -canvasWidth/2, 0 \u2192 0, 1 \u2192 canvasWidth/2\n // Note: NDC Y increases upward, but canvas Y increases downward, so flip Y\n var coord = pos[VertexIndex] * vec2<f32>(params.canvasWidth, params.canvasHeight) * 0.5;\n coord.y = -coord.y;\n\n // Reverse the 2D canvas transformations:\n // In 2D: translate(center + offset) \u2192 scale(zoom) \u2192 rotate \u2192 flip \u2192 draw(-w/2, -h/2)\n // In WebGPU (reverse): screen \u2192 un-translate \u2192 un-scale \u2192 un-rotate \u2192 un-flip \u2192 texture\n\n // 1. Subtract viewport offset (inverse of translate)\n coord = coord - vec2<f32>(params.viewportOffsetX, params.viewportOffsetY);\n\n // 2. Inverse zoom/scale\n let totalScale = params.viewportScale * params.viewportZoom * params.transformScale;\n coord = coord / totalScale;\n\n // 3. Inverse rotation\n if (params.rotation != 0.0) {\n let cos_r = cos(-params.rotation);\n let sin_r = sin(-params.rotation);\n coord = vec2<f32>(\n coord.x * cos_r - coord.y * sin_r,\n coord.x * sin_r + coord.y * cos_r\n );\n }\n\n // 4. Inverse flip\n coord.x = coord.x * params.flipHorizontal;\n coord.y = coord.y * params.flipVertical;\n\n // 5. Convert to texture coordinates\n // After inverse transformations, coord is in drawing space (centered at origin)\n\n // When there's a crop, viewport.scale is adjusted to fit crop area to canvas\n // This means coord values are scaled according to crop dimensions\n // We need to account for this when mapping to texture coordinates\n\n // ALWAYS use crop-aware logic\n if (params.cropWidth > 0.0 && params.cropHeight > 0.0) {\n // coord is in crop-centered space (units: pixels in crop area after inverse transforms)\n // The 2D canvas draws: drawImage(img, cropX, cropY, cropW, cropH, -cropW/2, -cropH/2, cropW, cropH)\n // This means the crop region is drawn centered, from (-cropW/2, -cropH/2) to (cropW/2, cropH/2)\n\n // Map from drawing space to texture coordinates:\n // Drawing space: coord ranges from (-cropW/2, -cropH/2) to (cropW/2, cropH/2)\n // Texture space: we want to read from (cropX, cropY) to (cropX+cropW, cropY+cropH)\n\n // Convert from centered coordinates to 0-based coordinates within crop region\n let cropLocalX = coord.x + params.cropWidth * 0.5;\n let cropLocalY = coord.y + params.cropHeight * 0.5;\n\n // Convert to texture coordinates by adding crop offset and normalizing by image size\n output.uv = vec2<f32>(\n (params.cropX + cropLocalX) / params.imageWidth,\n (params.cropY + cropLocalY) / params.imageHeight\n );\n } else {\n // No crop - standard transformation\n // Convert from image-centered space to top-left origin\n coord = coord + vec2<f32>(params.imageWidth * 0.5, params.imageHeight * 0.5);\n\n // Normalize to 0-1 range\n output.uv = coord / vec2<f32>(params.imageWidth, params.imageHeight);\n }\n\n return output;\n}\n\n// Helper functions\nfn getLuminance(color: vec3<f32>) -> f32 {\n return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));\n}\n\n// Improved 2D hash function for better randomness\nfn hash2d(p: vec2<f32>) -> f32 {\n let p3 = fract(vec3<f32>(p.x, p.y, p.x) * vec3<f32>(0.1031, 0.1030, 0.0973));\n let dot_p3 = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);\n return fract((p3.x + p3.y) * p3.z + dot_p3);\n}\n\nfn rgbToHsl(rgb: vec3<f32>) -> vec3<f32> {\n let maxVal = max(rgb.r, max(rgb.g, rgb.b));\n let minVal = min(rgb.r, min(rgb.g, rgb.b));\n var h = 0.0;\n var s = 0.0;\n let l = (maxVal + minVal) / 2.0;\n\n if (maxVal != minVal) {\n let d = maxVal - minVal;\n s = select(d / (maxVal + minVal), d / (2.0 - maxVal - minVal), l > 0.5);\n\n if (maxVal == rgb.r) {\n h = ((rgb.g - rgb.b) / d + select(0.0, 6.0, rgb.g < rgb.b)) / 6.0;\n } else if (maxVal == rgb.g) {\n h = ((rgb.b - rgb.r) / d + 2.0) / 6.0;\n } else {\n h = ((rgb.r - rgb.g) / d + 4.0) / 6.0;\n }\n }\n\n return vec3<f32>(h, s, l);\n}\n\nfn hslToRgb(hsl: vec3<f32>) -> vec3<f32> {\n let h = hsl.x;\n let s = hsl.y;\n let l = hsl.z;\n\n if (s == 0.0) {\n return vec3<f32>(l, l, l);\n }\n\n let q = select(l + s - l * s, l * (1.0 + s), l < 0.5);\n let p = 2.0 * l - q;\n\n let r = hue2rgb(p, q, h + 1.0 / 3.0);\n let g = hue2rgb(p, q, h);\n let b = hue2rgb(p, q, h - 1.0 / 3.0);\n\n return vec3<f32>(r, g, b);\n}\n\nfn hue2rgb(p: f32, q: f32, t_: f32) -> f32 {\n var t = t_;\n if (t < 0.0) { t += 1.0; }\n if (t > 1.0) { t -= 1.0; }\n if (t < 1.0 / 6.0) { return p + (q - p) * 6.0 * t; }\n if (t < 1.0 / 2.0) { return q; }\n if (t < 2.0 / 3.0) { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }\n return p;\n}\n\n// \u2500\u2500 Lightroom-style HSL per-color adjustment \u2500\u2500\n//\n// 8 hue centers (degrees): Red=0, Orange=30, Yellow=60, Green=120,\n// Aqua=180, Blue=240, Purple=270, Magenta=300\n//\n// For any hue, finds the two adjacent centers and linearly interpolates\n// their adjustment values. Weights always sum to 1.0 \u2014 no dead zones,\n// no double-application, perfectly smooth transitions.\n//\nfn blendHSLAdjustments(hueDeg: f32,\n r: vec3<f32>, o: vec3<f32>, y: vec3<f32>, g: vec3<f32>,\n a: vec3<f32>, b: vec3<f32>, p: vec3<f32>, m: vec3<f32>\n) -> vec3<f32> {\n var h = hueDeg;\n // Normalize to [0, 360)\n h = h - floor(h / 360.0) * 360.0;\n\n // Piecewise linear interpolation between adjacent centers.\n // Each segment blends the two neighboring bands' adjustments.\n if (h < 30.0) {\n return mix(r, o, h / 30.0);\n } else if (h < 60.0) {\n return mix(o, y, (h - 30.0) / 30.0);\n } else if (h < 120.0) {\n return mix(y, g, (h - 60.0) / 60.0);\n } else if (h < 180.0) {\n return mix(g, a, (h - 120.0) / 60.0);\n } else if (h < 240.0) {\n return mix(a, b, (h - 180.0) / 60.0);\n } else if (h < 270.0) {\n return mix(b, p, (h - 240.0) / 30.0);\n } else if (h < 300.0) {\n return mix(p, m, (h - 270.0) / 30.0);\n } else {\n return mix(m, r, (h - 300.0) / 60.0);\n }\n}\n\n@fragment\nfn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {\n // Sample texture FIRST (must be in uniform control flow before any branching)\n var color = textureSample(myTexture, mySampler, clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)));\n\n // Sample curve LUT (must also be in uniform control flow)\n // We'll use it later \u2014 sample at a neutral point first to satisfy control flow rules\n let curveSampleR = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.r, 0.0, 1.0), 0.5));\n let curveSampleG = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.g, 0.0, 1.0), 0.5));\n let curveSampleB = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.b, 0.0, 1.0), 0.5));\n\n var rgb = color.rgb;\n\n // Determine pixel visibility (in-bounds and in-crop)\n var isVisible = uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0;\n\n if (params.cropWidth > 0.0) {\n let cropMinU = params.cropX / params.imageWidth;\n let cropMaxU = (params.cropX + params.cropWidth) / params.imageWidth;\n let cropMinV = params.cropY / params.imageHeight;\n let cropMaxV = (params.cropY + params.cropHeight) / params.imageHeight;\n\n if (uv.x < cropMinU || uv.x > cropMaxU || uv.y < cropMinV || uv.y > cropMaxV) {\n isVisible = false;\n }\n }\n\n // Apply tone curve for visible pixels; black for out-of-bounds/crop\n if (isVisible) {\n rgb = vec3<f32>(curveSampleR.r, curveSampleG.g, curveSampleB.b);\n } else {\n rgb = vec3<f32>(0.0);\n }\n\n // 1. Brightness\n if (params.brightness != 0.0) {\n let factor = 1.0 + (params.brightness / 200.0);\n rgb = rgb * factor;\n }\n\n // 2. Contrast\n if (params.contrast != 0.0) {\n let factor = 1.0 + (params.contrast / 200.0);\n rgb = (rgb - 0.5) * factor + 0.5;\n }\n\n // 3. Exposure\n if (params.exposure != 0.0) {\n rgb = rgb * exp2(params.exposure / 100.0);\n }\n\n // 4. Shadows and Highlights\n if (params.shadows != 0.0 || params.highlights != 0.0) {\n let luma = getLuminance(rgb);\n\n if (params.shadows != 0.0) {\n let shadowMask = pow(1.0 - luma, 2.0);\n rgb = rgb - rgb * (params.shadows / 100.0) * shadowMask * 0.5;\n }\n\n if (params.highlights != 0.0) {\n let highlightMask = pow(luma, 2.0);\n rgb = rgb + rgb * (params.highlights / 100.0) * highlightMask * 0.5;\n }\n }\n\n // 4.5. HSL Per-Color Adjustment (Lightroom-style)\n // Blend adjustment values from the two adjacent hue bands, then apply once.\n {\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n var hsl = rgbToHsl(rgb);\n let hueDeg = hsl.x * 360.0;\n\n // Get blended adjustment: vec3(hueShift, satAdj, lumAdj)\n let adj = blendHSLAdjustments(hueDeg,\n params.hslRed.xyz, params.hslOrange.xyz, params.hslYellow.xyz, params.hslGreen.xyz,\n params.hslAqua.xyz, params.hslBlue.xyz, params.hslPurple.xyz, params.hslMagenta.xyz\n );\n\n // Apply only if there's any adjustment\n if (adj.x != 0.0 || adj.y != 0.0 || adj.z != 0.0) {\n // Hue shift (additive, wrapping)\n hsl.x = hsl.x + adj.x / 360.0;\n if (hsl.x < 0.0) { hsl.x = hsl.x + 1.0; }\n if (hsl.x > 1.0) { hsl.x = hsl.x - 1.0; }\n\n // Saturation (proportional \u2014 preserves relative saturation like Lightroom)\n hsl.y = clamp(hsl.y * (1.0 + adj.y / 100.0), 0.0, 1.0);\n\n // Luminance (weighted additive \u2014 stronger in midtones, gentler near extremes)\n let lumWeight = 4.0 * hsl.z * (1.0 - hsl.z); // peaks at L=0.5, zero at L=0 and L=1\n hsl.z = clamp(hsl.z + (adj.z / 100.0) * max(lumWeight, 0.15), 0.0, 1.0);\n\n rgb = hslToRgb(hsl);\n }\n }\n\n // 5. Saturation\n if (params.saturation != 0.0) {\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n var hsl = rgbToHsl(rgb);\n hsl.y = clamp(hsl.y * (1.0 + params.saturation / 100.0), 0.0, 1.0);\n rgb = hslToRgb(hsl);\n }\n\n // 5.5. Color Temperature\n if (params.temperature != 0.0) {\n let temp = params.temperature / 100.0;\n rgb.r = rgb.r + temp * 0.1;\n rgb.b = rgb.b - temp * 0.1;\n }\n\n // 6. Sepia\n if (params.sepia != 0.0) {\n let sepiaAmount = params.sepia / 100.0;\n let tr = 0.393 * rgb.r + 0.769 * rgb.g + 0.189 * rgb.b;\n let tg = 0.349 * rgb.r + 0.686 * rgb.g + 0.168 * rgb.b;\n let tb = 0.272 * rgb.r + 0.534 * rgb.g + 0.131 * rgb.b;\n rgb = mix(rgb, vec3<f32>(tr, tg, tb), sepiaAmount);\n }\n\n // 7. Grayscale\n if (params.grayscale != 0.0) {\n let gray = getLuminance(rgb);\n rgb = mix(rgb, vec3<f32>(gray), params.grayscale / 100.0);\n }\n\n // 8. Vignette\n if (params.vignette != 0.0) {\n let center = vec2<f32>(0.5, 0.5);\n let dist = distance(uv, center);\n let vignetteFactor = params.vignette / 100.0;\n let vignetteAmount = pow(dist * 1.4, 2.0);\n rgb = rgb * (1.0 + vignetteFactor * vignetteAmount * 1.5);\n }\n\n // Clamp final result\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n\n return vec4<f32>(rgb, color.a);\n}\n";
|
|
1
|
+
export declare const IMAGE_EDITOR_SHADER_CODE = "struct VertexOutput {\n @builtin(position) position: vec4<f32>,\n @location(0) uv: vec2<f32>,\n};\n\nstruct Uniforms {\n // Adjustments (11 params)\n brightness: f32,\n contrast: f32,\n exposure: f32,\n highlights: f32,\n shadows: f32,\n saturation: f32,\n temperature: f32,\n sepia: f32,\n grayscale: f32,\n vignette: f32,\n grain: f32,\n\n // Viewport (4 params)\n viewportZoom: f32,\n viewportOffsetX: f32,\n viewportOffsetY: f32,\n viewportScale: f32,\n\n // Transform (4 params)\n rotation: f32, // in radians\n flipHorizontal: f32, // 1.0 or -1.0\n flipVertical: f32, // 1.0 or -1.0\n transformScale: f32,\n\n // Canvas dimensions (2 params)\n canvasWidth: f32,\n canvasHeight: f32,\n\n // Image dimensions (2 params)\n imageWidth: f32,\n imageHeight: f32,\n\n // Crop area (4 params)\n cropX: f32,\n cropY: f32,\n cropWidth: f32,\n cropHeight: f32,\n\n // Canvas clear color (3 params) + padding to align HSL vec4 block\n clearR: f32,\n clearG: f32,\n clearB: f32,\n _padAlign0: f32,\n _padAlign1: f32,\n\n // HSL per-color adjustment (8 colors \u00D7 vec4 = 32 floats, starting at offset 128)\n // Each vec4: (hue_shift, saturation_adj, luminance_adj, unused)\n hslRed: vec4<f32>,\n hslOrange: vec4<f32>,\n hslYellow: vec4<f32>,\n hslGreen: vec4<f32>,\n hslAqua: vec4<f32>,\n hslBlue: vec4<f32>,\n hslPurple: vec4<f32>,\n hslMagenta: vec4<f32>,\n};\n\n@group(0) @binding(0) var mySampler: sampler;\n@group(0) @binding(1) var myTexture: texture_2d<f32>;\n@group(0) @binding(2) var<uniform> params: Uniforms;\n@group(0) @binding(3) var curveLUTSampler: sampler;\n@group(0) @binding(4) var curveLUTTexture: texture_2d<f32>;\n\n// Full-screen triangle vertex shader\n@vertex\nfn vs_main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {\n var pos = array<vec2<f32>, 3>(\n vec2<f32>(-1.0, -1.0),\n vec2<f32>( 3.0, -1.0),\n vec2<f32>(-1.0, 3.0)\n );\n var output: VertexOutput;\n output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);\n\n // Convert NDC (-1 to 1) to canvas coordinates centered at origin\n // NDC: -1 \u2192 -canvasWidth/2, 0 \u2192 0, 1 \u2192 canvasWidth/2\n // Note: NDC Y increases upward, but canvas Y increases downward, so flip Y\n var coord = pos[VertexIndex] * vec2<f32>(params.canvasWidth, params.canvasHeight) * 0.5;\n coord.y = -coord.y;\n\n // Reverse the 2D canvas transformations:\n // In 2D: translate(center + offset) \u2192 scale(zoom) \u2192 rotate \u2192 flip \u2192 draw(-w/2, -h/2)\n // In WebGPU (reverse): screen \u2192 un-translate \u2192 un-scale \u2192 un-rotate \u2192 un-flip \u2192 texture\n\n // 1. Subtract viewport offset (inverse of translate)\n coord = coord - vec2<f32>(params.viewportOffsetX, params.viewportOffsetY);\n\n // 2. Inverse zoom/scale\n let totalScale = params.viewportScale * params.viewportZoom * params.transformScale;\n coord = coord / totalScale;\n\n // 3. Inverse rotation\n if (params.rotation != 0.0) {\n let cos_r = cos(-params.rotation);\n let sin_r = sin(-params.rotation);\n coord = vec2<f32>(\n coord.x * cos_r - coord.y * sin_r,\n coord.x * sin_r + coord.y * cos_r\n );\n }\n\n // 4. Inverse flip\n coord.x = coord.x * params.flipHorizontal;\n coord.y = coord.y * params.flipVertical;\n\n // 5. Convert to texture coordinates\n // After inverse transformations, coord is in drawing space (centered at origin)\n\n // When there's a crop, viewport.scale is adjusted to fit crop area to canvas\n // This means coord values are scaled according to crop dimensions\n // We need to account for this when mapping to texture coordinates\n\n // ALWAYS use crop-aware logic\n if (params.cropWidth > 0.0 && params.cropHeight > 0.0) {\n // coord is in crop-centered space (units: pixels in crop area after inverse transforms)\n // The 2D canvas draws: drawImage(img, cropX, cropY, cropW, cropH, -cropW/2, -cropH/2, cropW, cropH)\n // This means the crop region is drawn centered, from (-cropW/2, -cropH/2) to (cropW/2, cropH/2)\n\n // Map from drawing space to texture coordinates:\n // Drawing space: coord ranges from (-cropW/2, -cropH/2) to (cropW/2, cropH/2)\n // Texture space: we want to read from (cropX, cropY) to (cropX+cropW, cropY+cropH)\n\n // Convert from centered coordinates to 0-based coordinates within crop region\n let cropLocalX = coord.x + params.cropWidth * 0.5;\n let cropLocalY = coord.y + params.cropHeight * 0.5;\n\n // Convert to texture coordinates by adding crop offset and normalizing by image size\n output.uv = vec2<f32>(\n (params.cropX + cropLocalX) / params.imageWidth,\n (params.cropY + cropLocalY) / params.imageHeight\n );\n } else {\n // No crop - standard transformation\n // Convert from image-centered space to top-left origin\n coord = coord + vec2<f32>(params.imageWidth * 0.5, params.imageHeight * 0.5);\n\n // Normalize to 0-1 range\n output.uv = coord / vec2<f32>(params.imageWidth, params.imageHeight);\n }\n\n return output;\n}\n\n// Helper functions\nfn getLuminance(color: vec3<f32>) -> f32 {\n return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));\n}\n\n// Improved 2D hash function for better randomness\nfn hash2d(p: vec2<f32>) -> f32 {\n let p3 = fract(vec3<f32>(p.x, p.y, p.x) * vec3<f32>(0.1031, 0.1030, 0.0973));\n let dot_p3 = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);\n return fract((p3.x + p3.y) * p3.z + dot_p3);\n}\n\nfn rgbToHsl(rgb: vec3<f32>) -> vec3<f32> {\n let maxVal = max(rgb.r, max(rgb.g, rgb.b));\n let minVal = min(rgb.r, min(rgb.g, rgb.b));\n var h = 0.0;\n var s = 0.0;\n let l = (maxVal + minVal) / 2.0;\n\n if (maxVal != minVal) {\n let d = maxVal - minVal;\n s = select(d / (maxVal + minVal), d / (2.0 - maxVal - minVal), l > 0.5);\n\n if (maxVal == rgb.r) {\n h = ((rgb.g - rgb.b) / d + select(0.0, 6.0, rgb.g < rgb.b)) / 6.0;\n } else if (maxVal == rgb.g) {\n h = ((rgb.b - rgb.r) / d + 2.0) / 6.0;\n } else {\n h = ((rgb.r - rgb.g) / d + 4.0) / 6.0;\n }\n }\n\n return vec3<f32>(h, s, l);\n}\n\nfn hslToRgb(hsl: vec3<f32>) -> vec3<f32> {\n let h = hsl.x;\n let s = hsl.y;\n let l = hsl.z;\n\n if (s == 0.0) {\n return vec3<f32>(l, l, l);\n }\n\n let q = select(l + s - l * s, l * (1.0 + s), l < 0.5);\n let p = 2.0 * l - q;\n\n let r = hue2rgb(p, q, h + 1.0 / 3.0);\n let g = hue2rgb(p, q, h);\n let b = hue2rgb(p, q, h - 1.0 / 3.0);\n\n return vec3<f32>(r, g, b);\n}\n\nfn hue2rgb(p: f32, q: f32, t_: f32) -> f32 {\n var t = t_;\n if (t < 0.0) { t += 1.0; }\n if (t > 1.0) { t -= 1.0; }\n if (t < 1.0 / 6.0) { return p + (q - p) * 6.0 * t; }\n if (t < 1.0 / 2.0) { return q; }\n if (t < 2.0 / 3.0) { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }\n return p;\n}\n\n// \u2500\u2500 Lightroom-style HSL per-color adjustment \u2500\u2500\n//\n// 8 hue centers (degrees): Red=0, Orange=30, Yellow=60, Green=120,\n// Aqua=180, Blue=240, Purple=270, Magenta=300\n//\n// For any hue, finds the two adjacent centers and linearly interpolates\n// their adjustment values. Weights always sum to 1.0 \u2014 no dead zones,\n// no double-application, perfectly smooth transitions.\n//\nfn blendHSLAdjustments(hueDeg: f32,\n r: vec3<f32>, o: vec3<f32>, y: vec3<f32>, g: vec3<f32>,\n a: vec3<f32>, b: vec3<f32>, p: vec3<f32>, m: vec3<f32>\n) -> vec3<f32> {\n var h = hueDeg;\n // Normalize to [0, 360)\n h = h - floor(h / 360.0) * 360.0;\n\n // Piecewise linear interpolation between adjacent centers.\n // Each segment blends the two neighboring bands' adjustments.\n if (h < 30.0) {\n return mix(r, o, h / 30.0);\n } else if (h < 60.0) {\n return mix(o, y, (h - 30.0) / 30.0);\n } else if (h < 120.0) {\n return mix(y, g, (h - 60.0) / 60.0);\n } else if (h < 180.0) {\n return mix(g, a, (h - 120.0) / 60.0);\n } else if (h < 240.0) {\n return mix(a, b, (h - 180.0) / 60.0);\n } else if (h < 270.0) {\n return mix(b, p, (h - 240.0) / 30.0);\n } else if (h < 300.0) {\n return mix(p, m, (h - 270.0) / 30.0);\n } else {\n return mix(m, r, (h - 300.0) / 60.0);\n }\n}\n\n@fragment\nfn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {\n // Sample texture FIRST (must be in uniform control flow before any branching)\n var color = textureSample(myTexture, mySampler, clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)));\n\n // Sample curve LUT (must also be in uniform control flow)\n // We'll use it later \u2014 sample at a neutral point first to satisfy control flow rules\n let curveSampleR = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.r, 0.0, 1.0), 0.5));\n let curveSampleG = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.g, 0.0, 1.0), 0.5));\n let curveSampleB = textureSample(curveLUTTexture, curveLUTSampler, vec2<f32>(clamp(color.b, 0.0, 1.0), 0.5));\n\n var rgb = color.rgb;\n\n // Determine pixel visibility (in-bounds and in-crop)\n var isVisible = uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0;\n\n if (params.cropWidth > 0.0) {\n let cropMinU = params.cropX / params.imageWidth;\n let cropMaxU = (params.cropX + params.cropWidth) / params.imageWidth;\n let cropMinV = params.cropY / params.imageHeight;\n let cropMaxV = (params.cropY + params.cropHeight) / params.imageHeight;\n\n if (uv.x < cropMinU || uv.x > cropMaxU || uv.y < cropMinV || uv.y > cropMaxV) {\n isVisible = false;\n }\n }\n\n // Out-of-bounds: output the canvas clear color (theme-aware)\n if (!isVisible) {\n return vec4<f32>(params.clearR, params.clearG, params.clearB, 1.0);\n }\n\n // Apply tone curve for visible pixels\n rgb = vec3<f32>(curveSampleR.r, curveSampleG.g, curveSampleB.b);\n\n // 1. Brightness\n if (params.brightness != 0.0) {\n let factor = 1.0 + (params.brightness / 200.0);\n rgb = rgb * factor;\n }\n\n // 2. Contrast\n if (params.contrast != 0.0) {\n let factor = 1.0 + (params.contrast / 200.0);\n rgb = (rgb - 0.5) * factor + 0.5;\n }\n\n // 3. Exposure\n if (params.exposure != 0.0) {\n rgb = rgb * exp2(params.exposure / 100.0);\n }\n\n // 4. Shadows and Highlights\n if (params.shadows != 0.0 || params.highlights != 0.0) {\n let luma = getLuminance(rgb);\n\n if (params.shadows != 0.0) {\n let shadowMask = pow(1.0 - luma, 2.0);\n rgb = rgb - rgb * (params.shadows / 100.0) * shadowMask * 0.5;\n }\n\n if (params.highlights != 0.0) {\n let highlightMask = pow(luma, 2.0);\n rgb = rgb + rgb * (params.highlights / 100.0) * highlightMask * 0.5;\n }\n }\n\n // 4.5. HSL Per-Color Adjustment (Lightroom-style)\n // Blend adjustment values from the two adjacent hue bands, then apply once.\n {\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n var hsl = rgbToHsl(rgb);\n let hueDeg = hsl.x * 360.0;\n\n // Get blended adjustment: vec3(hueShift, satAdj, lumAdj)\n let adj = blendHSLAdjustments(hueDeg,\n params.hslRed.xyz, params.hslOrange.xyz, params.hslYellow.xyz, params.hslGreen.xyz,\n params.hslAqua.xyz, params.hslBlue.xyz, params.hslPurple.xyz, params.hslMagenta.xyz\n );\n\n // Apply only if there's any adjustment\n if (adj.x != 0.0 || adj.y != 0.0 || adj.z != 0.0) {\n // Hue shift (additive, wrapping)\n hsl.x = hsl.x + adj.x / 360.0;\n if (hsl.x < 0.0) { hsl.x = hsl.x + 1.0; }\n if (hsl.x > 1.0) { hsl.x = hsl.x - 1.0; }\n\n // Saturation (proportional \u2014 preserves relative saturation like Lightroom)\n hsl.y = clamp(hsl.y * (1.0 + adj.y / 100.0), 0.0, 1.0);\n\n // Luminance (weighted additive \u2014 stronger in midtones, gentler near extremes)\n let lumWeight = 4.0 * hsl.z * (1.0 - hsl.z); // peaks at L=0.5, zero at L=0 and L=1\n hsl.z = clamp(hsl.z + (adj.z / 100.0) * max(lumWeight, 0.15), 0.0, 1.0);\n\n rgb = hslToRgb(hsl);\n }\n }\n\n // 5. Saturation\n if (params.saturation != 0.0) {\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n var hsl = rgbToHsl(rgb);\n hsl.y = clamp(hsl.y * (1.0 + params.saturation / 100.0), 0.0, 1.0);\n rgb = hslToRgb(hsl);\n }\n\n // 5.5. Color Temperature\n if (params.temperature != 0.0) {\n let temp = params.temperature / 100.0;\n rgb.r = rgb.r + temp * 0.1;\n rgb.b = rgb.b - temp * 0.1;\n }\n\n // 6. Sepia\n if (params.sepia != 0.0) {\n let sepiaAmount = params.sepia / 100.0;\n let tr = 0.393 * rgb.r + 0.769 * rgb.g + 0.189 * rgb.b;\n let tg = 0.349 * rgb.r + 0.686 * rgb.g + 0.168 * rgb.b;\n let tb = 0.272 * rgb.r + 0.534 * rgb.g + 0.131 * rgb.b;\n rgb = mix(rgb, vec3<f32>(tr, tg, tb), sepiaAmount);\n }\n\n // 7. Grayscale\n if (params.grayscale != 0.0) {\n let gray = getLuminance(rgb);\n rgb = mix(rgb, vec3<f32>(gray), params.grayscale / 100.0);\n }\n\n // 8. Vignette\n if (params.vignette != 0.0) {\n let center = vec2<f32>(0.5, 0.5);\n let dist = distance(uv, center);\n let vignetteFactor = params.vignette / 100.0;\n let vignetteAmount = pow(dist * 1.4, 2.0);\n rgb = rgb * (1.0 + vignetteFactor * vignetteAmount * 1.5);\n }\n\n // Clamp final result\n rgb = clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0));\n\n return vec4<f32>(rgb, color.a);\n}\n";
|
|
@@ -43,13 +43,12 @@ struct Uniforms {
|
|
|
43
43
|
cropWidth: f32,
|
|
44
44
|
cropHeight: f32,
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
|
|
46
|
+
// Canvas clear color (3 params) + padding to align HSL vec4 block
|
|
47
|
+
clearR: f32,
|
|
48
|
+
clearG: f32,
|
|
49
|
+
clearB: f32,
|
|
48
50
|
_padAlign0: f32,
|
|
49
51
|
_padAlign1: f32,
|
|
50
|
-
_padAlign2: f32,
|
|
51
|
-
_padAlign3: f32,
|
|
52
|
-
_padAlign4: f32,
|
|
53
52
|
|
|
54
53
|
// HSL per-color adjustment (8 colors × vec4 = 32 floats, starting at offset 128)
|
|
55
54
|
// Each vec4: (hue_shift, saturation_adj, luminance_adj, unused)
|
|
@@ -278,13 +277,14 @@ fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
|
|
278
277
|
}
|
|
279
278
|
}
|
|
280
279
|
|
|
281
|
-
//
|
|
282
|
-
if (isVisible) {
|
|
283
|
-
|
|
284
|
-
} else {
|
|
285
|
-
rgb = vec3<f32>(0.0);
|
|
280
|
+
// Out-of-bounds: output the canvas clear color (theme-aware)
|
|
281
|
+
if (!isVisible) {
|
|
282
|
+
return vec4<f32>(params.clearR, params.clearG, params.clearB, 1.0);
|
|
286
283
|
}
|
|
287
284
|
|
|
285
|
+
// Apply tone curve for visible pixels
|
|
286
|
+
rgb = vec3<f32>(curveSampleR.r, curveSampleG.g, curveSampleB.b);
|
|
287
|
+
|
|
288
288
|
// 1. Brightness
|
|
289
289
|
if (params.brightness != 0.0) {
|
|
290
290
|
let factor = 1.0 + (params.brightness / 200.0);
|
|
@@ -549,12 +549,6 @@ export function handleQuickDrawTouchMove(state, event, ctx, tool, strokeWidth, c
|
|
|
549
549
|
if (result.viewportUpdate) {
|
|
550
550
|
newState = { ...newState, viewport: updateViewport(newState.viewport, result.viewportUpdate) };
|
|
551
551
|
}
|
|
552
|
-
if (result.zoomInfo && canvasRect) {
|
|
553
|
-
newState = {
|
|
554
|
-
...newState,
|
|
555
|
-
viewport: calculateZoomViewport(newState.viewport, result.zoomInfo.delta, canvasWidth, canvasHeight, result.zoomInfo.centerX, result.zoomInfo.centerY, canvasRect)
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
552
|
return newState;
|
|
559
553
|
}
|
|
560
554
|
return state;
|
|
@@ -17,6 +17,10 @@ export interface EditorInteractionState {
|
|
|
17
17
|
};
|
|
18
18
|
initialPinchDistance: number;
|
|
19
19
|
initialPinchZoom: number;
|
|
20
|
+
lastPinchCenter: {
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
};
|
|
20
24
|
isSpaceHeld: boolean;
|
|
21
25
|
isTwoFingerTouch: boolean;
|
|
22
26
|
isDrawing: boolean;
|
|
@@ -77,14 +81,10 @@ export declare function handlePureTouchStart(event: TouchEvent, state: EditorInt
|
|
|
77
81
|
export declare function handlePureTouchMove(event: TouchEvent, state: EditorInteractionState, ctx: EditorContext): {
|
|
78
82
|
state: EditorInteractionState;
|
|
79
83
|
viewportUpdate?: {
|
|
84
|
+
zoom?: number;
|
|
80
85
|
offsetX: number;
|
|
81
86
|
offsetY: number;
|
|
82
87
|
};
|
|
83
|
-
zoomInfo?: {
|
|
84
|
-
delta: number;
|
|
85
|
-
centerX: number;
|
|
86
|
-
centerY: number;
|
|
87
|
-
};
|
|
88
88
|
} | null;
|
|
89
89
|
/**
|
|
90
90
|
* Handle touch end for pure pan mode
|
|
@@ -144,14 +144,10 @@ export declare function handleOverlayTouchStart(event: TouchEvent, state: Editor
|
|
|
144
144
|
export declare function handleOverlayTouchMove(event: TouchEvent, state: EditorInteractionState, ctx: EditorContext, tool: 'pen' | 'brush' | 'fill', strokeWidth: number): {
|
|
145
145
|
state: EditorInteractionState;
|
|
146
146
|
viewportUpdate?: {
|
|
147
|
+
zoom?: number;
|
|
147
148
|
offsetX: number;
|
|
148
149
|
offsetY: number;
|
|
149
150
|
};
|
|
150
|
-
zoomInfo?: {
|
|
151
|
-
delta: number;
|
|
152
|
-
centerX: number;
|
|
153
|
-
centerY: number;
|
|
154
|
-
};
|
|
155
151
|
} | null;
|
|
156
152
|
/**
|
|
157
153
|
* Handle touch end for drawing overlay
|
|
@@ -16,6 +16,7 @@ export function createEditorInteractionState() {
|
|
|
16
16
|
lastPanPosition: { x: 0, y: 0 },
|
|
17
17
|
initialPinchDistance: 0,
|
|
18
18
|
initialPinchZoom: 1,
|
|
19
|
+
lastPinchCenter: { x: 0, y: 0 },
|
|
19
20
|
isSpaceHeld: false,
|
|
20
21
|
isTwoFingerTouch: false,
|
|
21
22
|
isDrawing: false,
|
|
@@ -162,12 +163,15 @@ export function handlePureTouchMove(event, state, ctx) {
|
|
|
162
163
|
const touch1 = event.touches[0];
|
|
163
164
|
const touch2 = event.touches[1];
|
|
164
165
|
const distance = Math.hypot(touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY);
|
|
166
|
+
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
|
167
|
+
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
|
165
168
|
if (state.initialPinchDistance === 0) {
|
|
166
169
|
return {
|
|
167
170
|
state: {
|
|
168
171
|
...state,
|
|
169
172
|
initialPinchDistance: distance,
|
|
170
173
|
initialPinchZoom: ctx.viewport.zoom,
|
|
174
|
+
lastPinchCenter: { x: centerX, y: centerY },
|
|
171
175
|
isPanning: false
|
|
172
176
|
}
|
|
173
177
|
};
|
|
@@ -176,12 +180,20 @@ export function handlePureTouchMove(event, state, ctx) {
|
|
|
176
180
|
const scale = distance / state.initialPinchDistance;
|
|
177
181
|
const newZoom = Math.max(0.1, Math.min(10, state.initialPinchZoom * scale));
|
|
178
182
|
const delta = Math.log(newZoom / ctx.viewport.zoom);
|
|
183
|
+
const panDeltaX = centerX - state.lastPinchCenter.x;
|
|
184
|
+
const panDeltaY = centerY - state.lastPinchCenter.y;
|
|
185
|
+
// Compute zoom-at-point then add pan offset
|
|
186
|
+
const canvasRect = ctx.canvas.getBoundingClientRect();
|
|
187
|
+
const zoomedViewport = calculateZoomViewport(ctx.viewport, delta, ctx.canvas.width, ctx.canvas.height, centerX, centerY, canvasRect);
|
|
179
188
|
return {
|
|
180
|
-
state
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
189
|
+
state: {
|
|
190
|
+
...state,
|
|
191
|
+
lastPinchCenter: { x: centerX, y: centerY }
|
|
192
|
+
},
|
|
193
|
+
viewportUpdate: {
|
|
194
|
+
zoom: zoomedViewport.zoom,
|
|
195
|
+
offsetX: zoomedViewport.offsetX + panDeltaX,
|
|
196
|
+
offsetY: zoomedViewport.offsetY + panDeltaY
|
|
185
197
|
}
|
|
186
198
|
};
|
|
187
199
|
}
|
|
@@ -481,18 +493,21 @@ export function handleOverlayTouchStart(event, state, ctx, tool, color, strokeWi
|
|
|
481
493
|
export function handleOverlayTouchMove(event, state, ctx, tool, strokeWidth) {
|
|
482
494
|
if (!ctx.canvas || !ctx.image)
|
|
483
495
|
return null;
|
|
484
|
-
// Two-finger pinch zoom
|
|
496
|
+
// Two-finger pinch zoom + pan
|
|
485
497
|
if (event.touches.length === 2 && state.isPanning) {
|
|
486
498
|
event.preventDefault();
|
|
487
499
|
const touch1 = event.touches[0];
|
|
488
500
|
const touch2 = event.touches[1];
|
|
489
501
|
const distance = Math.hypot(touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY);
|
|
502
|
+
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
|
503
|
+
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
|
490
504
|
if (state.initialPinchDistance === 0) {
|
|
491
505
|
return {
|
|
492
506
|
state: {
|
|
493
507
|
...state,
|
|
494
508
|
initialPinchDistance: distance,
|
|
495
|
-
initialPinchZoom: ctx.viewport.zoom
|
|
509
|
+
initialPinchZoom: ctx.viewport.zoom,
|
|
510
|
+
lastPinchCenter: { x: centerX, y: centerY }
|
|
496
511
|
}
|
|
497
512
|
};
|
|
498
513
|
}
|
|
@@ -500,19 +515,27 @@ export function handleOverlayTouchMove(event, state, ctx, tool, strokeWidth) {
|
|
|
500
515
|
const scale = distance / state.initialPinchDistance;
|
|
501
516
|
const newZoom = Math.max(0.1, Math.min(10, state.initialPinchZoom * scale));
|
|
502
517
|
const delta = Math.log(newZoom / ctx.viewport.zoom);
|
|
518
|
+
const panDeltaX = centerX - state.lastPinchCenter.x;
|
|
519
|
+
const panDeltaY = centerY - state.lastPinchCenter.y;
|
|
520
|
+
// Compute zoom-at-point then add pan offset
|
|
521
|
+
const canvasRect = ctx.canvas.getBoundingClientRect();
|
|
522
|
+
const zoomedViewport = calculateZoomViewport(ctx.viewport, delta, ctx.canvas.width, ctx.canvas.height, centerX, centerY, canvasRect);
|
|
503
523
|
return {
|
|
504
|
-
state
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
524
|
+
state: {
|
|
525
|
+
...state,
|
|
526
|
+
lastPinchCenter: { x: centerX, y: centerY }
|
|
527
|
+
},
|
|
528
|
+
viewportUpdate: {
|
|
529
|
+
zoom: zoomedViewport.zoom,
|
|
530
|
+
offsetX: zoomedViewport.offsetX + panDeltaX,
|
|
531
|
+
offsetY: zoomedViewport.offsetY + panDeltaY
|
|
509
532
|
}
|
|
510
533
|
};
|
|
511
534
|
}
|
|
512
535
|
}
|
|
513
536
|
// Single finger
|
|
514
537
|
if (event.touches.length === 1) {
|
|
515
|
-
// Panning
|
|
538
|
+
// Panning (including after two-finger gesture)
|
|
516
539
|
if (state.isPanning || shouldPan(state)) {
|
|
517
540
|
event.preventDefault();
|
|
518
541
|
const touch = event.touches[0];
|