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.
@@ -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 centerX = (touch1.clientX + touch2.clientX) / 2;
747
- const centerY = (touch1.clientY + touch2.clientY) / 2;
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 newViewport = calculateZoomViewport(viewport, delta, canvas.width, canvas.height, centerX, centerY, canvasRect);
750
- onViewportChange({ zoom: newViewport.zoom, offsetX: newViewport.offsetX, offsetY: newViewport.offsetY });
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 imgWidth = image.width;
144
- const imgHeight = image.height;
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
- // Unified touch handlers
325
- const handleContainerTouchStart = handleContainerMouseDown;
326
- const handleTouchMove = handleMouseMove;
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.brightness;
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
- // Padding to align HSL block to 16-byte boundary
47
- // After cropHeight we're at offset 27*4=108, need to reach offset 128 (32*4) for vec4 alignment
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
- // Apply tone curve for visible pixels; black for out-of-bounds/crop
282
- if (isVisible) {
283
- rgb = vec3<f32>(curveSampleR.r, curveSampleG.g, curveSampleB.b);
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
- zoomInfo: {
182
- delta,
183
- centerX: (touch1.clientX + touch2.clientX) / 2,
184
- centerY: (touch1.clientY + touch2.clientY) / 2
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
- zoomInfo: {
506
- delta,
507
- centerX: (touch1.clientX + touch2.clientX) / 2,
508
- centerY: (touch1.clientY + touch2.clientY) / 2
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];