feat: right-click contrast, Ctrl+saturation, Shift+rotate filters
Some checks failed
Build Windows / build-windows (push) Has been cancelled
Some checks failed
Build Windows / build-windows (push) Has been cancelled
- Add ColorFilter.matrix (row-major) for saturation and contrast - Saturation: 0=grayscale, 1=normal, 2=oversaturated (luminance-weighted) - Contrast: standard contrast matrix (pivot at 0.5) - Keyboard modifier tracking via HardwareKeyboard.addHandler - Right-click + vertical drag: contrast (0-2) - Ctrl + right-click + vertical drag: saturation (0-2) - Shift + right-click + vertical drag: rotate - Right double-click: reset all values - Update install.sh to check debug/ bundle before release/ - Update .deb package with new release build
This commit is contained in:
Binary file not shown.
Binary file not shown.
BIN
dist/imajviewer_1.0.0_amd64.deb
vendored
BIN
dist/imajviewer_1.0.0_amd64.deb
vendored
Binary file not shown.
187
docs/image-canvas-interactions.md
Normal file
187
docs/image-canvas-interactions.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Image Canvas Interactions — Analiz ve İmplementasyon Planı
|
||||||
|
|
||||||
|
**Tarih:** 2026-07-27
|
||||||
|
**Kapsam:** `lib/widgets/image_canvas.dart`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mevcut Durum
|
||||||
|
|
||||||
|
`ImageCanvas` widget'ı şu anda:
|
||||||
|
|
||||||
|
- **Sol tık + sürükle:** Pan (imleç hareketiyle resmi kaydırma) — `GestureDetector.onPanUpdate`
|
||||||
|
- **Tekerlek:** Zoom (imleç pozisyonu merkezli) — `Listener.onPointerSignal → PointerScrollEvent`
|
||||||
|
- **Çift tık:** Fill/Fit mod geçişi — `GestureDetector.onDoubleTap`
|
||||||
|
- **Transform:** `Matrix4` ile sadece `translate(_tx, _ty)` + `scale(_sc)`, rotate yok
|
||||||
|
- **Renk/efekt filtresi:** Yok
|
||||||
|
|
||||||
|
Sorun: `GestureDetector.onPanUpdate` hangi mouse tuşuna basıldığını ayırt etmez. Tüm tuşlarla yapılan sürüklemeyi `onPanUpdate` olarak yakalar. Sağ tık için ayrı bir işlem yapılamaz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gereksinimler
|
||||||
|
|
||||||
|
| # | Etkileşim | Davranış |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | **Sağ tık + Dikey sürükle** | Rotate. Yukarı sürükle → saat yönü, aşağı sürükle → saat tersi. Viewport merkezinden dönmeli. Hassasiyet: ~100px = 90° |
|
||||||
|
| R2 | **Ctrl + Sağ tık + Dikey sürükle** | Contrast ayarı. Yukarı sürükle → artır, aşağı sürükle → azalt. Aralık: 0.0 – 2.0 (default: 1.0) |
|
||||||
|
| R3 | **Shift + Sağ tık + Dikey sürükle** | Brightness ayarı. Yukarı sürükle → artır, aşağı sürükle → azalt. Aralık: -1.0 – 1.0 (default: 0.0) |
|
||||||
|
| R4 | **Sağ çift tık** | Reset: rotate, contrast, brightness sıfırlanır (0°, 1.0, 0.0) |
|
||||||
|
| R5 | **Sol tık + sürükle** | Mevcut pan davranışı korunacak |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Teknik Tasarım
|
||||||
|
|
||||||
|
### 3.1 Gesture Handling: `GestureDetector` → `Listener`
|
||||||
|
|
||||||
|
Mevcut `GestureDetector` tuş ayrımı yapamadığı için, **`Listener` widget'ı** ile pointer event'leri manuel yakalayacağız:
|
||||||
|
|
||||||
|
```
|
||||||
|
Listener(
|
||||||
|
onPointerDown: _onPointerDown, // hangi tuş + modifier → mod belirle
|
||||||
|
onPointerMove: _onPointerMove, // moda göre pan / contrast / brightness
|
||||||
|
onPointerUp: _onPointerUp, // tıklama ise rotate uygula
|
||||||
|
onPointerSignal: _handleScroll, // zoom (mevcut)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mod State Machine:**
|
||||||
|
|
||||||
|
```
|
||||||
|
PointerDown:
|
||||||
|
if (sol tuş) → mod = pan
|
||||||
|
if (sağ tuş, no modifier) → mod = rotate, başlangıç açısını kaydet
|
||||||
|
if (sağ tuş + Ctrl) → mod = contrast, başlangıç değerini kaydet
|
||||||
|
if (sağ tuş + Shift) → mod = brightness, başlangıç değerini kaydet
|
||||||
|
|
||||||
|
PointerMove:
|
||||||
|
pan: _tx += dx, _ty += dy
|
||||||
|
rotate: _angle += dy * sensitivity (clamp yok, serbest dönüş)
|
||||||
|
contrast: _contrast += dy * sensitivity, clamp(0.0, 2.0)
|
||||||
|
brightness: _brightness += dy * sensitivity, clamp(-1.0, 1.0)
|
||||||
|
|
||||||
|
PointerUp:
|
||||||
|
modu temizle
|
||||||
|
|
||||||
|
Çift tık (sağ tuş):
|
||||||
|
_angle = 0; _contrast = 1.0; _brightness = 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tuş tespiti:** `PointerEvent.buttons` bitmask'i:
|
||||||
|
- `kPrimaryMouseButton` (1) = sol
|
||||||
|
- `kSecondaryMouseButton` (2) = sağ
|
||||||
|
- `kMiddleMouseButton` (4) = orta
|
||||||
|
|
||||||
|
**Modifier tespiti:** `HardwareKeyboard.instance.isControlPressed` / `isShiftPressed`
|
||||||
|
|
||||||
|
### 3.2 Rotate — Matrix4 Güncellemesi
|
||||||
|
|
||||||
|
Rotate, viewport'un **tam merkezi** etrafında dönmeli. Matrix4 işlem sırası (son yazılan ilk uygulanır):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Matrix4.identity()
|
||||||
|
..translate(vpCenterX, vpCenterY) // 4. pivot noktasına geri taşı
|
||||||
|
..rotateZ(_angleRad) // 3. döndür
|
||||||
|
..translate(-vpCenterX, -vpCenterY) // 2. pivot'u orijine taşı
|
||||||
|
..translate(_tx, _ty) // 1. pan offset'i
|
||||||
|
..scale(_sc); // 0. zoom
|
||||||
|
```
|
||||||
|
|
||||||
|
`_angleRad = _angle * pi / 180`
|
||||||
|
|
||||||
|
### 3.3 Contrast ve Brightness — ColorFilter
|
||||||
|
|
||||||
|
`Image` widget'ını `ColorFiltered` ile saracağız. Hem contrast hem brightness tek bir matrix'te birleştirilebilir:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
ColorFiltered(
|
||||||
|
colorFilter: ColorFilter.matrix(_buildColorMatrix()),
|
||||||
|
child: imageWidget,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Birleşik matrix (contrast × brightness):**
|
||||||
|
|
||||||
|
Önce brightness, sonra contrast uygulanır:
|
||||||
|
|
||||||
|
```
|
||||||
|
b = brightness (-1.0 .. 1.0)
|
||||||
|
c = contrast (0.0 .. 2.0)
|
||||||
|
t = (1.0 - c) / 2.0
|
||||||
|
|
||||||
|
Matrix:
|
||||||
|
c, 0, 0, 0, t + b,
|
||||||
|
0, c, 0, 0, t + b,
|
||||||
|
0, 0, c, 0, t + b,
|
||||||
|
0, 0, 0, 1, 0,
|
||||||
|
```
|
||||||
|
|
||||||
|
Default değerler: `c = 1.0, b = 0.0` → birim matrix (görüntü değişmez).
|
||||||
|
|
||||||
|
### 3.4 Sensitivity (Hassasiyet)
|
||||||
|
|
||||||
|
- **Rotate:** `_angle += dy * 0.9` (100px sürükleme ≈ 90°)
|
||||||
|
- **Contrast:** `_contrast += dy * 0.005` (200px sürükleme = 1.0 birim)
|
||||||
|
- **Brightness:** `_brightness += dy * 0.005` (200px sürükleme = 1.0 birim)
|
||||||
|
|
||||||
|
Değerler `setState` içinde clamp'lenir (rotate hariç, serbest dönüş).
|
||||||
|
|
||||||
|
### 3.5 State Değişkenleri (Eklenecekler)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
double _angle = 0; // derece cinsinden, serbest
|
||||||
|
double _contrast = 1.0; // 0.0 – 2.0
|
||||||
|
double _brightness = 0.0; // -1.0 – 1.0
|
||||||
|
|
||||||
|
// Gesture tracking
|
||||||
|
String _interactionMode = 'none'; // 'pan' | 'rotate' | 'contrast' | 'brightness'
|
||||||
|
Offset _pointerDownPos = Offset.zero;
|
||||||
|
double _startAngle = 0;
|
||||||
|
double _startContrast = 1.0;
|
||||||
|
double _startBrightness = 0.0;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Riskler ve Dikkat Edilecek Noktalar
|
||||||
|
|
||||||
|
| Risk | Etki | Çözüm |
|
||||||
|
|---|---|---|
|
||||||
|
| Sağ tık + sürükleme, pan ile çakışabilir | Yanlış moda girme | `rotate_pending` modunda eşik kontrolü: 5px'den az hareket → rotate, fazla → pan |
|
||||||
|
| Rotate sonrası pan yönü bozulabilir | Kullanıcı deneyimi | Rotate, pan offset'lerinden (`_tx, _ty`) önce değil sonra uygulandığı için etkilenmez. Ama fit mode'da letterbox offset'i (`m, n`) rotate'den etkilenir — test edilecek |
|
||||||
|
| ColorFilter performansı | Büyük resimlerde takılma | Flutter'da `ColorFiltered` GPU'da çalışır, performans sorunu beklenmiyor |
|
||||||
|
| Ctrl/Shift + scroll zoom ile çakışma | Zoom yerine contrast değişebilir | Scroll event'leri `onPointerSignal` ile ayrı yakalanıyor, karışmaz |
|
||||||
|
| Fill/Fit mod geçişinde rotate sıfırlanmalı mı? | UX kararı | `_toggleFillFit()` içinde `_angle = 0` yapalım — fill ↔ fit geçişinde rotate sıfırlansın |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. İmplementasyon Planı
|
||||||
|
|
||||||
|
### Adım 1: Listener tabanlı gesture sistemine geçiş
|
||||||
|
- `GestureDetector` yerine `Listener` kullan
|
||||||
|
- `onPointerDown`, `onPointerMove`, `onPointerUp` handler'larını yaz
|
||||||
|
- Sol tık pan'i koru, sağ tık modlarını ekle
|
||||||
|
|
||||||
|
### Adım 2: Rotate
|
||||||
|
- `_angle` state değişkeni ekle
|
||||||
|
- `Matrix4`'e rotate transform'u ekle
|
||||||
|
- `_resetView()` ve `_toggleFillFit()` içinde `_angle = 0`
|
||||||
|
|
||||||
|
### Adım 3: Contrast ve Brightness
|
||||||
|
- `_contrast`, `_brightness` state değişkenleri ekle
|
||||||
|
- `ColorFiltered` widget'ı ile image'ı sar
|
||||||
|
- `_buildColorMatrix()` metodunu yaz
|
||||||
|
|
||||||
|
### Adım 4: Test ve clamp
|
||||||
|
- Tüm modlarda clamp/değer aralığı kontrolü
|
||||||
|
- Rotate + zoom + pan birlikte çalışıyor mu test et
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Etkilenen Dosyalar
|
||||||
|
|
||||||
|
| Dosya | Değişiklik |
|
||||||
|
|---|---|
|
||||||
|
| `lib/widgets/image_canvas.dart` | Ana değişikliklerin tümü burada |
|
||||||
|
| (başka dosya yok) | Diğer widget'lar etkilenmez |
|
||||||
6
docs/index.md
Normal file
6
docs/index.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# imajViewer — Dokümantasyon İndeksi
|
||||||
|
|
||||||
|
| Tarih | Dosya | Konu |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-07-27 | [image-canvas-interactions.md](image-canvas-interactions.md) | Sağ tık etkileşimleri: contrast, saturation, rotate |
|
||||||
|
| önceki | [zoom-clamp-mekanizmasi.md](zoom-clamp-mekanizmasi.md) | Zoom clamp mekanizması (letterbox-aware) |
|
||||||
@@ -3,7 +3,11 @@ set -euo pipefail
|
|||||||
|
|
||||||
APP=imajviewer
|
APP=imajviewer
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/release/bundle"
|
BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/debug/bundle"
|
||||||
|
# Debug yoksa release dene
|
||||||
|
if [ ! -f "$BUNDLE_DIR/${APP}" ]; then
|
||||||
|
BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/release/bundle"
|
||||||
|
fi
|
||||||
INSTALL_DIR="${HOME}/.local/lib/${APP}"
|
INSTALL_DIR="${HOME}/.local/lib/${APP}"
|
||||||
BIN_LINK="${HOME}/.local/bin/${APP}"
|
BIN_LINK="${HOME}/.local/bin/${APP}"
|
||||||
DESKTOP_FILE="${HOME}/.local/share/applications/${APP}.desktop"
|
DESKTOP_FILE="${HOME}/.local/share/applications/${APP}.desktop"
|
||||||
@@ -24,7 +28,10 @@ if [ ! -f "$BUNDLE_DIR/${APP}" ]; then
|
|||||||
info "Binary bulunamadı, Flutter build başlatılıyor…"
|
info "Binary bulunamadı, Flutter build başlatılıyor…"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
flutter build linux 2>&1 | tail -1 || err "Flutter build başarısız! Sistemde Flutter SDK kurulu olmalı veya dist/imajviewer_*.deb paketini kullanın."
|
flutter build linux 2>&1 | tail -1 || err "Flutter build başarısız! Sistemde Flutter SDK kurulu olmalı veya dist/imajviewer_*.deb paketini kullanın."
|
||||||
|
BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/debug/bundle"
|
||||||
|
if [ ! -f "$BUNDLE_DIR/${APP}" ]; then
|
||||||
BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/release/bundle"
|
BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/release/bundle"
|
||||||
|
fi
|
||||||
[ -f "$BUNDLE_DIR/${APP}" ] || err "Build başarısız!"
|
[ -f "$BUNDLE_DIR/${APP}" ] || err "Build başarısız!"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
class ImageCanvas extends StatefulWidget {
|
class ImageCanvas extends StatefulWidget {
|
||||||
final String filePath;
|
final String filePath;
|
||||||
@@ -15,10 +17,27 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
double _tx = 0, _ty = 0, _sc = 1.0;
|
double _tx = 0, _ty = 0, _sc = 1.0;
|
||||||
Size _vpSize = Size.zero;
|
Size _vpSize = Size.zero;
|
||||||
|
|
||||||
|
// ── Görüntü ayarları ──
|
||||||
|
double _angle = 0;
|
||||||
|
double _contrast = 1.0;
|
||||||
|
double _saturation = 1.0;
|
||||||
|
|
||||||
|
// ── Gesture tracking ──
|
||||||
|
String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation'
|
||||||
|
Offset _pointerDownPos = Offset.zero;
|
||||||
|
double _startAngle = 0;
|
||||||
|
double _startContrast = 1.0;
|
||||||
|
double _startSaturation = 1.0;
|
||||||
|
DateTime? _lastRightTapTime;
|
||||||
|
Offset _lastRightTapPos = Offset.zero;
|
||||||
|
|
||||||
|
// ── Klavye modifier tracking ──
|
||||||
|
bool _ctrlPressed = false;
|
||||||
|
bool _shiftPressed = false;
|
||||||
|
|
||||||
void _clamp() {
|
void _clamp() {
|
||||||
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
||||||
if (_isFilled) {
|
if (_isFilled) {
|
||||||
// Fill mode: viewport tabanli clamp
|
|
||||||
final double w = _vpSize.width * _sc;
|
final double w = _vpSize.width * _sc;
|
||||||
final double h = _vpSize.height * _sc;
|
final double h = _vpSize.height * _sc;
|
||||||
if (w >= _vpSize.width) {
|
if (w >= _vpSize.width) {
|
||||||
@@ -31,7 +50,6 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
} else { if (_ty != 0) _ty = 0; }
|
} else { if (_ty != 0) _ty = 0; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Fit mode: resmin gercek boyutuna gore clamp (letterbox dahil)
|
|
||||||
double m = 0, n = 0;
|
double m = 0, n = 0;
|
||||||
double w = _vpSize.width * _sc;
|
double w = _vpSize.width * _sc;
|
||||||
double h = _vpSize.height * _sc;
|
double h = _vpSize.height * _sc;
|
||||||
@@ -77,17 +95,60 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
HardwareKeyboard.instance.addHandler(_onHardwareKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _onHardwareKey(KeyEvent event) {
|
||||||
|
final key = event.logicalKey;
|
||||||
|
if (event is KeyDownEvent) {
|
||||||
|
if (key == LogicalKeyboardKey.controlLeft ||
|
||||||
|
key == LogicalKeyboardKey.controlRight) {
|
||||||
|
_ctrlPressed = true;
|
||||||
|
} else if (key == LogicalKeyboardKey.shiftLeft ||
|
||||||
|
key == LogicalKeyboardKey.shiftRight) {
|
||||||
|
_shiftPressed = true;
|
||||||
|
}
|
||||||
|
} else if (event is KeyUpEvent) {
|
||||||
|
if (key == LogicalKeyboardKey.controlLeft ||
|
||||||
|
key == LogicalKeyboardKey.controlRight) {
|
||||||
|
_ctrlPressed = false;
|
||||||
|
} else if (key == LogicalKeyboardKey.shiftLeft ||
|
||||||
|
key == LogicalKeyboardKey.shiftRight) {
|
||||||
|
_shiftPressed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(ImageCanvas old) {
|
void didUpdateWidget(ImageCanvas old) {
|
||||||
super.didUpdateWidget(old);
|
super.didUpdateWidget(old);
|
||||||
if (old.filePath != widget.filePath) _resetView();
|
if (old.filePath != widget.filePath) _resetView();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetView() {
|
void _resetView() {
|
||||||
setState(() { _tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero; });
|
setState(() {
|
||||||
|
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero;
|
||||||
|
_angle = 0; _contrast = 1.0; _saturation = 1.0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFillFit() {
|
void _toggleFillFit() {
|
||||||
setState(() { _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero; });
|
setState(() {
|
||||||
|
_isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero;
|
||||||
|
_angle = 0; _contrast = 1.0; _saturation = 1.0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDoubleTap() => _toggleFillFit();
|
void _handleDoubleTap() => _toggleFillFit();
|
||||||
|
|
||||||
void _handleScroll(PointerScrollEvent e) {
|
void _handleScroll(PointerScrollEvent e) {
|
||||||
@@ -113,6 +174,95 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onPointerDown(PointerDownEvent e) {
|
||||||
|
if (e.buttons != kSecondaryMouseButton) return;
|
||||||
|
|
||||||
|
_pointerDownPos = e.localPosition;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastRightTapTime != null &&
|
||||||
|
now.difference(_lastRightTapTime!) < const Duration(milliseconds: 400) &&
|
||||||
|
(e.localPosition - _lastRightTapPos).distance < 20) {
|
||||||
|
setState(() {
|
||||||
|
_angle = 0;
|
||||||
|
_contrast = 1.0;
|
||||||
|
_saturation = 1.0;
|
||||||
|
_interactionMode = 'none';
|
||||||
|
});
|
||||||
|
_lastRightTapTime = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastRightTapTime = now;
|
||||||
|
_lastRightTapPos = e.localPosition;
|
||||||
|
|
||||||
|
if (_ctrlPressed) {
|
||||||
|
_interactionMode = 'saturation';
|
||||||
|
_startSaturation = _saturation;
|
||||||
|
} else if (_shiftPressed) {
|
||||||
|
_interactionMode = 'rotate';
|
||||||
|
_startAngle = _angle;
|
||||||
|
} else {
|
||||||
|
_interactionMode = 'contrast';
|
||||||
|
_startContrast = _contrast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerMove(PointerMoveEvent e) {
|
||||||
|
if (e.buttons != kSecondaryMouseButton) return;
|
||||||
|
|
||||||
|
final dy = e.localPosition.dy - _pointerDownPos.dy;
|
||||||
|
|
||||||
|
switch (_interactionMode) {
|
||||||
|
case 'contrast':
|
||||||
|
setState(() {
|
||||||
|
_contrast = (_startContrast + dy * 0.005).clamp(0.0, 2.0);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'rotate':
|
||||||
|
setState(() {
|
||||||
|
_angle = _startAngle + dy * 0.9;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'saturation':
|
||||||
|
setState(() {
|
||||||
|
_saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerUp(PointerUpEvent e) {
|
||||||
|
_interactionMode = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color filters ──
|
||||||
|
|
||||||
|
static const double _rLum = 0.2126;
|
||||||
|
static const double _gLum = 0.7152;
|
||||||
|
static const double _bLum = 0.0722;
|
||||||
|
|
||||||
|
ColorFilter _buildSaturationFilter() {
|
||||||
|
final s = _saturation;
|
||||||
|
final invS = 1.0 - s;
|
||||||
|
return ColorFilter.matrix(<double>[
|
||||||
|
invS * _rLum + s, invS * _gLum, invS * _bLum, 0, 0,
|
||||||
|
invS * _rLum, invS * _gLum + s, invS * _bLum, 0, 0,
|
||||||
|
invS * _rLum, invS * _gLum, invS * _bLum + s, 0, 0,
|
||||||
|
0, 0, 0, 1, 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorFilter _buildContrastFilter() {
|
||||||
|
final c = _contrast;
|
||||||
|
final t = (1.0 - c) / 2.0;
|
||||||
|
return ColorFilter.matrix(<double>[
|
||||||
|
c, 0, 0, 0, t,
|
||||||
|
0, c, 0, 0, t,
|
||||||
|
0, 0, c, 0, t,
|
||||||
|
0, 0, 0, 1, 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildImage() {
|
Widget _buildImage() {
|
||||||
return Image.file(
|
return Image.file(
|
||||||
key: _imageKey,
|
key: _imageKey,
|
||||||
@@ -129,22 +279,37 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_vpSize = constraints.biggest;
|
_vpSize = constraints.biggest;
|
||||||
_clamp();
|
_clamp();
|
||||||
|
|
||||||
|
final vpCenterX = _vpSize.width / 2;
|
||||||
|
final vpCenterY = _vpSize.height / 2;
|
||||||
|
final angleRad = _angle * math.pi / 180;
|
||||||
|
|
||||||
return SizedBox.expand(
|
return SizedBox.expand(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
ClipRect(
|
ClipRect(
|
||||||
child: GestureDetector(
|
|
||||||
onDoubleTap: _handleDoubleTap,
|
|
||||||
onPanUpdate: _onPanUpdate,
|
|
||||||
child: Listener(
|
child: Listener(
|
||||||
|
onPointerDown: _onPointerDown,
|
||||||
|
onPointerMove: _onPointerMove,
|
||||||
|
onPointerUp: _onPointerUp,
|
||||||
onPointerSignal: (event) {
|
onPointerSignal: (event) {
|
||||||
if (event is PointerScrollEvent) _handleScroll(event);
|
if (event is PointerScrollEvent) _handleScroll(event);
|
||||||
},
|
},
|
||||||
|
child: GestureDetector(
|
||||||
|
onDoubleTap: _handleDoubleTap,
|
||||||
|
onPanUpdate: _onPanUpdate,
|
||||||
child: Transform(
|
child: Transform(
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
transform: Matrix4.identity()
|
transform: Matrix4.identity()
|
||||||
|
..translate(vpCenterX, vpCenterY)
|
||||||
|
..rotateZ(angleRad)
|
||||||
|
..translate(-vpCenterX, -vpCenterY)
|
||||||
..translate(_tx, _ty)
|
..translate(_tx, _ty)
|
||||||
..scale(_sc),
|
..scale(_sc),
|
||||||
|
child: ColorFiltered(
|
||||||
|
colorFilter: _buildSaturationFilter(),
|
||||||
|
child: ColorFiltered(
|
||||||
|
colorFilter: _buildContrastFilter(),
|
||||||
child: _isFilled
|
child: _isFilled
|
||||||
? SizedBox.expand(child: _buildImage())
|
? SizedBox.expand(child: _buildImage())
|
||||||
: Center(child: _buildImage()),
|
: Center(child: _buildImage()),
|
||||||
@@ -152,6 +317,8 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user