feat: crop save (save visible area at original resolution) v1.4.0
- Save visible area at original resolution as JPEG q90 (title bar button or Ctrl+S) - Output name: original name + _HHmmss suffix; no overwrite, warning shown - Active color effects baked in; rotation saves bounding box - New: crop_save_service, l10n strings TR/EN, docs/plan-crop-save.md
This commit is contained in:
1 parent
73c1ff51d6
commit
168e087c8b
16 files changed
+399
-14
No files matched your search
+4
-1
@@ -49,4 +49,7 @@ app.*.map.json
|
|||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.exe
|
*.exe
|
||||||
deb/usr/lib/imajviewer/
|
deb/usr/lib/imajviewer/
|
||||||
release/
|
release/
|
||||||
|
|
||||||
|
# Local backup snapshots (see soul.md §5)
|
||||||
|
docs/.bak/
|
||||||
@@ -4,6 +4,15 @@ All notable changes to imajViewer are documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-10-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Crop save: saves the visible area at original resolution as JPEG quality 90 — title bar save button or Ctrl+S
|
||||||
|
- Output name: original name + `_HHmmss` time suffix (e.g. `photo.jpg` → `photo_143025.jpg`)
|
||||||
|
- If a file with the same name exists, nothing is written and a warning is shown
|
||||||
|
- Active color effects (contrast/saturation/brightness) are baked into the output
|
||||||
|
- With rotation active, the bounding box around the visible area is saved
|
||||||
|
|
||||||
## [1.3.2] - 2026-08-28
|
## [1.3.2] - 2026-08-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
| Tarih | Dosya | Konu |
|
| Tarih | Dosya | Konu |
|
||||||
||---|---|---|
|
||---|---|---|
|
||||||
| 2026-08-06 | [rapor-github-acik-kaynak.md](rapor-github-acik-kaynak.md) | **Rapor** — GitHub açık kaynak hazırlığı (gereksinimler, riskler, plan) |
|
| 2026-08-06 | [rapor-github-acik-kaynak.md](rapor-github-acik-kaynak.md) | **Rapor** — GitHub açık kaynak hazırlığı (gereksinimler, riskler, plan) |
|
||||||
|
| 2026-10-11 | [plan-crop-save.md](plan-crop-save.md) | **Plan (1.4.0)** — Crop Save: görünen alanı orijinal çözünürlükte JPEG q90 kaydetme (uygulandı) |
|
||||||
| 2026-08-06 | [Implementation.md](Implementation.md) | Teknik uygulama dokümanı (kökten taşındı) |
|
| 2026-08-06 | [Implementation.md](Implementation.md) | Teknik uygulama dokümanı (kökten taşındı) |
|
||||||
| 2026-08-06 | [PRD.md](PRD.md) | Product Requirements Document (kökten taşındı) |
|
| 2026-08-06 | [PRD.md](PRD.md) | Product Requirements Document (kökten taşındı) |
|
||||||
| 2026-08-06 | [update_plan_01.md](update_plan_01.md) | Pan sınırlama & zoom merkezi sorunu (kökten taşındı) |
|
| 2026-08-06 | [update_plan_01.md](update_plan_01.md) | Pan sınırlama & zoom merkezi sorunu (kökten taşındı) |
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Plan — Crop Save (görünen alanı kaydet)
|
||||||
|
|
||||||
|
Tarih: 2026-10-11 · Versiyon: 1.4.0 · Durum: UYGULANDI (2026-10-11) — manual test + commit/push bekleniyor
|
||||||
|
|
||||||
|
## Not — Uygulama Sırası
|
||||||
|
- `image` v3 kullanılamadı: `msix 3.18.0` `image ^4.0.0` gerektiriyor → v4 async API kullanıldı
|
||||||
|
(`decodeImage` Uint8List ile, `copyCrop(x:,y:,width:,height:)`, `JpegEncoder(quality:90)`,
|
||||||
|
`bakeOrientation`, pixel kanalları `r/g/b/a`).
|
||||||
|
- v4'te renk matrisi filtresi yok → matris `crop_save_service.dart:_applyMatrix` ile elle
|
||||||
|
uygulandı (aynı 0-255 alanı).
|
||||||
|
- Boyut okuma ayrı descriptor yerine tam decode (`load()`) üzerinden yapılıyor.
|
||||||
|
|
||||||
|
## Gereksinim + Kararlar
|
||||||
|
- Pencerenin görünen kısmı (image canvas viewport'u) → **orijinal çözünürlükte** crop
|
||||||
|
- JPEG **quality 90**
|
||||||
|
- Dosya adı: orijinal ismin sonuna **saat only** eki → `resim.jpg` → `resim_143025.jpg`
|
||||||
|
- **Çakışma:** dosya zaten varsa yazı YAPILMAZ, uyarı gösterilir (mevcut dosya korunur)
|
||||||
|
- **Renk efektleri:** kayda **GÖMÜLÜR** (contrast/saturation/brightness, ekrandaki gibi)
|
||||||
|
- **Rotasyon aktifken:** yapılsın — görünür bölgeyi saran dikdörtgen (bounding box) kaydedilir
|
||||||
|
- Title bar: Save butonu minimize'in SOLUNDA (sıralama: **save · minimize · x**)
|
||||||
|
- Klavye: **Ctrl+S** de tetikler
|
||||||
|
|
||||||
|
## Mimari Analiz
|
||||||
|
|
||||||
|
### 1) Görünen alan → kaynak piksel eşlemesi
|
||||||
|
`image_canvas.dart` render zinciri: `Transform(translate(_tx,_ty) scale(_sc))` içinde
|
||||||
|
opsiyonel `Transform(rotate(_angle), vpCenter)` → `Image(fit: cover|contain)`.
|
||||||
|
|
||||||
|
Canvas lokal koordinatındaki bir nokta `p` için geri dönüş:
|
||||||
|
1. `q = (p - (tx,ty)) / sc`
|
||||||
|
2. `_angle != 0` ise `r` = `q`'yi vpCenter ekseninde `-angle` kadar döndür (yoksa `r = q`)
|
||||||
|
3. **cover** (`_isFilled`): `k = max(vpW/iw, vpH/ih)`; **contain**: `k = min(vpW/iw, vpH/ih)`;
|
||||||
|
her ikisinde de `ox=(vpW-iw*k)/2`, `oy=(vpH-ih*k)/2` → `src = ((r.x-ox)/k, (r.y-oy)/k)`
|
||||||
|
4. Viewport'un 4 köşesi → `src` uzayda → min/max bounding box → `[0,iw]×[0,ih]`'a clamp → int
|
||||||
|
5. Rotasyonlu durumda bounding box, ekranda görünür bölgeyi kapsayan dikdörtgendir
|
||||||
|
(çıkış aspect/boyutu pencereden farklı olabilir — onaylı davranış)
|
||||||
|
|
||||||
|
- `iw,ih` = **gerçek** dosya boyutu (descriptor ile). `_imageSize` downsample'lı (2048)
|
||||||
|
olabilir → eşlemede kullanılmaz.
|
||||||
|
- Görünen alan = **tamamı canvas viewport** (title bar 24px bindirilmiş overlay, görsel
|
||||||
|
arkasında görünüyor → kesime dahil).
|
||||||
|
|
||||||
|
### 2) Encode zinciri (yeni bağımlılık: `image` ^4.x, saf Dart)
|
||||||
|
```
|
||||||
|
File → img.decodeImage(bytes, keepOrientation: true)
|
||||||
|
→ img.cropRect(rect)
|
||||||
|
→ aktif renk matrisi var ise img.ColorMatrixFilter(mat4x5).applyTo(crop)
|
||||||
|
→ img.encodeJpeg(crop, quality: 90)
|
||||||
|
→ hedef yol var ise → uyarı (yazılmaz); yoksa File.writeAsBytes
|
||||||
|
```
|
||||||
|
- **Renk matrisi:** `image_canvas.dart`'daki mevcut `_buildCombinedFilter()` 4x5 matrisi
|
||||||
|
(contrast×brightness×saturation) aynı zamanda `ColorFilter.matrix` olarak kullanılıyor.
|
||||||
|
State'e `List<double>? activeColorMatrix` getter'ı eklenir (filtre identity ise null).
|
||||||
|
Flutter ve `image` paketi aynı 4x5 row-major / 0-255 alanı kullanır → matris aynen
|
||||||
|
taşınır, yeniden türetilmez.
|
||||||
|
- **Tuzak — EXIF yönü:** Flutter engine JPEG'lerde EXIF orientation uygular;
|
||||||
|
`keepOrientation: true` verilmezse 90° döndürülmüş fotoğraflarda crop yanlış yerden
|
||||||
|
kesilir. Test listesinde zorunlu.
|
||||||
|
- **WBMP:** `image` WBMP çözer (uygulama destekliyor); decode hatası → "desteklenmiyor"
|
||||||
|
mesajı, çökme yok.
|
||||||
|
|
||||||
|
### 3) Bileşenler
|
||||||
|
| Dosya | Değişiklik |
|
||||||
|
|---|---|
|
||||||
|
| `pubspec.yaml` | `image: ^4.x`; versiyon `1.4.0+8` |
|
||||||
|
| `lib/services/crop_save_service.dart` | **YENİ** — `imageSize(path)` (descriptor), `saveCrop({srcPath, rect, matrix, outPath})`, ad üretimi (`_HHmmss`), çakışma kontrolü (exists → uyarı), hata mesajları |
|
||||||
|
| `lib/widgets/image_canvas.dart` | State → `ImageCanvasState` (public); `Rect? cropSourceRect(int w, int h)`; `List<double>? activeColorMatrix` getter |
|
||||||
|
| `lib/screens/viewer_screen.dart` | `GlobalKey<ImageCanvasState>`, `_cropSave()`, sonuç geri bildirimi (kısa overlay/Snackbar: yol veya hata) |
|
||||||
|
| `lib/widgets/custom_title_bar.dart` | `onSaveCrop` param; save ikonu minimize'in solunda; mevcut `_BarButton` stili (hover kırmızı) |
|
||||||
|
| `lib/shortcuts/app_shortcuts.dart` | `Ctrl+S` → crop save (callback param ile) |
|
||||||
|
| `lib/l10n/app_{en,tr}.arb` | `saveCrop` (tooltip), `cropSaved`, `cropFailed`, `cropFileExists`, `cropUnsupported` + `shortcutsList` güncelleme; `flutter gen-l10n` |
|
||||||
|
| `CHANGELOG.md` | `[1.4.0]` |
|
||||||
|
|
||||||
|
### 4) Etkileşim
|
||||||
|
- Buton yalnızca görsel yüklüyken aktif (yoksa null → pas geçilir).
|
||||||
|
- Başarıda: kaydedilen tam yol gösterilir; hata/çakışmada: hata metni.
|
||||||
|
- Tam decode kayıt anında, tek seferlik (downsample swap ile ilişkisi yok).
|
||||||
|
|
||||||
|
## Sınır Durumları
|
||||||
|
1. Görsel yok → buton pasif, Ctrl+S no-op
|
||||||
|
2. Crop rect boş/degenerate → hata mesajı
|
||||||
|
3. Yazılamayan klasör → hata mesajı, çökme yok
|
||||||
|
4. **Çakışma: dosya var → yazılmaz, uyarı** (karar)
|
||||||
|
5. WBMP/decode hatası → `cropUnsupported`
|
||||||
|
6. Rotasyonlu bounding box (karar: kaydedilir)
|
||||||
|
7. Büyük görsellerde tam decode RAM'i — tek seferlik, kabul edilebilir
|
||||||
|
|
||||||
|
## Doğrulama
|
||||||
|
1. `flutter analyze` (otomatik izinli)
|
||||||
|
2. `flutter build linux --debug` + `./install.sh` (otomatik izinli)
|
||||||
|
3. Kullanıcı manuel test: fit/cover, zoom, pan, rotasyon + kaydet, renk efektleri + kaydet,
|
||||||
|
EXIF 90° görsel, WBMP, çakışan isim (uyarı + dosya korunur), salt okunur dizin
|
||||||
|
4. Commit + push: yalnızca senin komutunla
|
||||||
|
|
||||||
|
## Kapsam Dışı
|
||||||
|
- Kullanıcı alanı seçerek crop (her zaman "görünen bölge")
|
||||||
|
- PNG/özel format seçimi (sabit JPEG q90)
|
||||||
|
- Gitea CI release / tag (senin komutunla)
|
||||||
+11
-1
@@ -6,9 +6,19 @@
|
|||||||
"sortByName": "By name",
|
"sortByName": "By name",
|
||||||
"appDescription": "Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.",
|
"appDescription": "Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.",
|
||||||
"shortcuts": "Shortcuts",
|
"shortcuts": "Shortcuts",
|
||||||
"shortcutsList": "• Left/Right arrow — previous/next image\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness",
|
"shortcutsList": "• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 90)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness",
|
||||||
"print": "Print",
|
"print": "Print",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"saveCrop": "Save visible area (JPEG 90)",
|
||||||
|
"cropSaved": "Saved: {path}",
|
||||||
|
"@cropSaved": {
|
||||||
|
"placeholders": {
|
||||||
|
"path": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cropFileExists": "A file with the same name already exists — nothing was written",
|
||||||
|
"cropUnsupported": "This image format cannot be saved as a crop",
|
||||||
|
"cropFailed": "Failed to save crop",
|
||||||
"dragDropHint": "Drag images here\nor press Ctrl+O to open",
|
"dragDropHint": "Drag images here\nor press Ctrl+O to open",
|
||||||
"dropHere": "Drop",
|
"dropHere": "Drop",
|
||||||
"imageLoadError": "Failed to load image"
|
"imageLoadError": "Failed to load image"
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @shortcutsList.
|
/// No description provided for @shortcutsList.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'• Left/Right arrow — previous/next image\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'**
|
/// **'• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 90)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'**
|
||||||
String get shortcutsList;
|
String get shortcutsList;
|
||||||
|
|
||||||
/// No description provided for @print.
|
/// No description provided for @print.
|
||||||
@@ -152,6 +152,36 @@ abstract class AppLocalizations {
|
|||||||
/// **'Close'**
|
/// **'Close'**
|
||||||
String get close;
|
String get close;
|
||||||
|
|
||||||
|
/// No description provided for @saveCrop.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save visible area (JPEG 90)'**
|
||||||
|
String get saveCrop;
|
||||||
|
|
||||||
|
/// No description provided for @cropSaved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Saved: {path}'**
|
||||||
|
String cropSaved(String path);
|
||||||
|
|
||||||
|
/// No description provided for @cropFileExists.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A file with the same name already exists — nothing was written'**
|
||||||
|
String get cropFileExists;
|
||||||
|
|
||||||
|
/// No description provided for @cropUnsupported.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This image format cannot be saved as a crop'**
|
||||||
|
String get cropUnsupported;
|
||||||
|
|
||||||
|
/// No description provided for @cropFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to save crop'**
|
||||||
|
String get cropFailed;
|
||||||
|
|
||||||
/// No description provided for @dragDropHint.
|
/// No description provided for @dragDropHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get shortcutsList =>
|
String get shortcutsList =>
|
||||||
'• Left/Right arrow — previous/next image\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness';
|
'• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 90)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get print => 'Print';
|
String get print => 'Print';
|
||||||
@@ -37,6 +37,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get close => 'Close';
|
String get close => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveCrop => 'Save visible area (JPEG 90)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cropSaved(String path) {
|
||||||
|
return 'Saved: $path';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropFileExists =>
|
||||||
|
'A file with the same name already exists — nothing was written';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropUnsupported => 'This image format cannot be saved as a crop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropFailed => 'Failed to save crop';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragDropHint => 'Drag images here\nor press Ctrl+O to open';
|
String get dragDropHint => 'Drag images here\nor press Ctrl+O to open';
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get shortcutsList =>
|
String get shortcutsList =>
|
||||||
'• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık';
|
'• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 90)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get print => 'Yazdır';
|
String get print => 'Yazdır';
|
||||||
@@ -37,6 +37,23 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get close => 'Kapat';
|
String get close => 'Kapat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveCrop => 'Görünen alanı kaydet (JPEG 90)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cropSaved(String path) {
|
||||||
|
return 'Kaydedildi: $path';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropFileExists => 'Aynı adlı dosya zaten var — yazılmadı';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropUnsupported => 'Bu resim formatı crop olarak kaydedilemiyor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cropFailed => 'Crop kaydedilemedi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragDropHint =>
|
String get dragDropHint =>
|
||||||
'Resimleri buraya sürükleyin\nveya Ctrl+O ile açın';
|
'Resimleri buraya sürükleyin\nveya Ctrl+O ile açın';
|
||||||
|
|||||||
+11
-1
@@ -6,9 +6,19 @@
|
|||||||
"sortByName": "İsme göre",
|
"sortByName": "İsme göre",
|
||||||
"appDescription": "Ultra hafif ve hızlı resim görüntüleyici. Görselleri açın, gezinti için okları veya klavye tuşlarını kullanın.",
|
"appDescription": "Ultra hafif ve hızlı resim görüntüleyici. Görselleri açın, gezinti için okları veya klavye tuşlarını kullanın.",
|
||||||
"shortcuts": "Kısayollar",
|
"shortcuts": "Kısayollar",
|
||||||
"shortcutsList": "• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık",
|
"shortcutsList": "• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 90)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık",
|
||||||
"print": "Yazdır",
|
"print": "Yazdır",
|
||||||
"close": "Kapat",
|
"close": "Kapat",
|
||||||
|
"saveCrop": "Görünen alanı kaydet (JPEG 90)",
|
||||||
|
"cropSaved": "Kaydedildi: {path}",
|
||||||
|
"@cropSaved": {
|
||||||
|
"placeholders": {
|
||||||
|
"path": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cropFileExists": "Aynı adlı dosya zaten var — yazılmadı",
|
||||||
|
"cropUnsupported": "Bu resim formatı crop olarak kaydedilemiyor",
|
||||||
|
"cropFailed": "Crop kaydedilemedi",
|
||||||
"dragDropHint": "Resimleri buraya sürükleyin\nveya Ctrl+O ile açın",
|
"dragDropHint": "Resimleri buraya sürükleyin\nveya Ctrl+O ile açın",
|
||||||
"dropHere": "Bırakın",
|
"dropHere": "Bırakın",
|
||||||
"imageLoadError": "Resim yüklenemedi"
|
"imageLoadError": "Resim yüklenemedi"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:pdf/widgets.dart' as pw;
|
|||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
import '../services/image_manager.dart';
|
import '../services/image_manager.dart';
|
||||||
import '../services/file_handler.dart';
|
import '../services/file_handler.dart';
|
||||||
|
import '../services/crop_save_service.dart';
|
||||||
import '../widgets/custom_title_bar.dart';
|
import '../widgets/custom_title_bar.dart';
|
||||||
import '../widgets/image_canvas.dart';
|
import '../widgets/image_canvas.dart';
|
||||||
import '../widgets/window_resize_zones.dart';
|
import '../widgets/window_resize_zones.dart';
|
||||||
@@ -28,6 +29,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
bool _controlsVisible = false;
|
bool _controlsVisible = false;
|
||||||
Timer? _saveDebounce;
|
Timer? _saveDebounce;
|
||||||
Rect _lastBounds = Rect.zero;
|
Rect _lastBounds = Rect.zero;
|
||||||
|
final GlobalKey<ImageCanvasState> _canvasKey = GlobalKey<ImageCanvasState>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -188,9 +190,53 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _cropSave() async {
|
||||||
|
final path = ImageManager.instance.currentImagePath;
|
||||||
|
final state = _canvasKey.currentState;
|
||||||
|
if (path.isEmpty || state == null) return;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
String? message;
|
||||||
|
try {
|
||||||
|
final source = await CropSaveService.load(path);
|
||||||
|
final rect = state.cropSourceRect(source.width, source.height);
|
||||||
|
if (rect == null || rect.isEmpty || !rect.isFinite) {
|
||||||
|
message = l10n.cropFailed;
|
||||||
|
} else {
|
||||||
|
final outPath = await CropSaveService.saveCrop(
|
||||||
|
srcPath: path,
|
||||||
|
source: source,
|
||||||
|
rect: rect,
|
||||||
|
matrix: state.activeColorMatrix,
|
||||||
|
);
|
||||||
|
message = l10n.cropSaved(outPath);
|
||||||
|
}
|
||||||
|
} on CropSaveError catch (e) {
|
||||||
|
switch (e.code) {
|
||||||
|
case 'file_exists':
|
||||||
|
message = l10n.cropFileExists;
|
||||||
|
case 'decode':
|
||||||
|
message = l10n.cropUnsupported;
|
||||||
|
default:
|
||||||
|
message = l10n.cropFailed;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
message = l10n.cropFailed;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(
|
||||||
|
content: Text(message, style: const TextStyle(fontSize: 13)),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
width: 520,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppShortcuts(
|
return AppShortcuts(
|
||||||
|
onCropSave: _hasImages ? _cropSave : null,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: WindowResizeZones(
|
body: WindowResizeZones(
|
||||||
@@ -223,6 +269,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: _hasImages
|
child: _hasImages
|
||||||
? ImageCanvas(
|
? ImageCanvas(
|
||||||
|
key: _canvasKey,
|
||||||
filePath: ImageManager.instance.currentImagePath,
|
filePath: ImageManager.instance.currentImagePath,
|
||||||
)
|
)
|
||||||
: Center(
|
: Center(
|
||||||
@@ -242,6 +289,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
),
|
),
|
||||||
CustomTitleBar(
|
CustomTitleBar(
|
||||||
onSettings: _showSettings,
|
onSettings: _showSettings,
|
||||||
|
onSaveCrop: _hasImages ? _cropSave : null,
|
||||||
visible: _controlsVisible,
|
visible: _controlsVisible,
|
||||||
),
|
),
|
||||||
if (_hasImages && _controlsVisible) ...[
|
if (_hasImages && _controlsVisible) ...[
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' show Rect;
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
class CropSaveError implements Exception {
|
||||||
|
final String code;
|
||||||
|
const CropSaveError(this.code);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'CropSaveError($code)';
|
||||||
|
}
|
||||||
|
|
||||||
|
class CropSaveService {
|
||||||
|
/// Tam çözünürlükte decode + EXIF yönünü gömme (engine görüntüsüyle tutarlı).
|
||||||
|
static Future<img.Image> load(String srcPath) async {
|
||||||
|
final bytes = await File(srcPath).readAsBytes();
|
||||||
|
final src = img.decodeImage(bytes);
|
||||||
|
if (src == null) throw const CropSaveError('decode');
|
||||||
|
return img.bakeOrientation(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hedef yol: `resim.jpg` -> `resim_143025.jpg` (çakışmada null).
|
||||||
|
static String? buildOutputPath(String srcPath) {
|
||||||
|
final ext = p.extension(srcPath);
|
||||||
|
final base = p.basenameWithoutExtension(srcPath);
|
||||||
|
final dir = p.dirname(srcPath);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final hh = now.hour.toString().padLeft(2, '0');
|
||||||
|
final mm = now.minute.toString().padLeft(2, '0');
|
||||||
|
final ss = now.second.toString().padLeft(2, '0');
|
||||||
|
final candidate = p.join(dir, '${base}_$hh$mm$ss$ext');
|
||||||
|
return File(candidate).existsSync() ? null : candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crop + aktif renk matrisi + JPEG q90.
|
||||||
|
/// [matrix] 4x5 row-major renk matrisi (image_canvas ile aynı form, 0-255 alanı);
|
||||||
|
/// null = gömme yok.
|
||||||
|
static Future<String> saveCrop({
|
||||||
|
required String srcPath,
|
||||||
|
required img.Image source,
|
||||||
|
required Rect rect,
|
||||||
|
List<double>? matrix,
|
||||||
|
}) async {
|
||||||
|
final outPath = buildOutputPath(srcPath);
|
||||||
|
if (outPath == null) throw const CropSaveError('file_exists');
|
||||||
|
|
||||||
|
final iw = source.width, ih = source.height;
|
||||||
|
final x = rect.left.round().clamp(0, iw - 1);
|
||||||
|
final y = rect.top.round().clamp(0, ih - 1);
|
||||||
|
final w = rect.right.round().clamp(x + 1, iw) - x;
|
||||||
|
final h = rect.bottom.round().clamp(y + 1, ih) - y;
|
||||||
|
if (w <= 0 || h <= 0) throw const CropSaveError('empty_rect');
|
||||||
|
|
||||||
|
final crop = img.copyCrop(source, x: x, y: y, width: w, height: h);
|
||||||
|
// Çıktıda yön fiziksel — EXIF tag'ini temizle (çift dönüşü önler).
|
||||||
|
crop.exif.imageIfd.orientation = null;
|
||||||
|
|
||||||
|
if (matrix != null) _applyMatrix(crop, matrix);
|
||||||
|
|
||||||
|
final jpeg = img.JpegEncoder(quality: 90).encode(crop);
|
||||||
|
await File(outPath).writeAsBytes(jpeg);
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flutter'la aynı 0-255 alan/semantiğindeki 4x5 matrisi uygular.
|
||||||
|
static void _applyMatrix(img.Image image, List<double> m) {
|
||||||
|
for (final pixel in image) {
|
||||||
|
final r = pixel.r.toDouble(), g = pixel.g.toDouble(), b = pixel.b.toDouble();
|
||||||
|
pixel.r = (m[0] * r + m[1] * g + m[2] * b + m[4]).clamp(0, 255).round();
|
||||||
|
pixel.g = (m[5] * r + m[6] * g + m[7] * b + m[9]).clamp(0, 255).round();
|
||||||
|
pixel.b = (m[10] * r + m[11] * g + m[12] * b + m[13]).clamp(0, 255).round();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import '../services/file_handler.dart';
|
|||||||
|
|
||||||
class AppShortcuts extends StatelessWidget {
|
class AppShortcuts extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
final VoidCallback? onCropSave;
|
||||||
|
|
||||||
const AppShortcuts({super.key, required this.child});
|
const AppShortcuts({super.key, required this.child, this.onCropSave});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -35,6 +36,14 @@ class AppShortcuts extends StatelessWidget {
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+S: görünen alanı kaydet (crop save)
|
||||||
|
if (ctrl && !shift && event.logicalKey == LogicalKeyboardKey.keyS) {
|
||||||
|
if (onCropSave != null) {
|
||||||
|
onCropSave!();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.logicalKey) {
|
switch (event.logicalKey) {
|
||||||
case LogicalKeyboardKey.arrowLeft:
|
case LogicalKeyboardKey.arrowLeft:
|
||||||
ImageManager.instance.previousImage();
|
ImageManager.instance.previousImage();
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import 'package:window_manager/window_manager.dart';
|
|||||||
|
|
||||||
class CustomTitleBar extends StatelessWidget {
|
class CustomTitleBar extends StatelessWidget {
|
||||||
final VoidCallback? onSettings;
|
final VoidCallback? onSettings;
|
||||||
|
final VoidCallback? onSaveCrop;
|
||||||
final bool visible;
|
final bool visible;
|
||||||
|
|
||||||
const CustomTitleBar({super.key, this.onSettings, this.visible = true});
|
const CustomTitleBar({super.key, this.onSettings, this.onSaveCrop, this.visible = true});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -43,6 +44,11 @@ class CustomTitleBar extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
if (onSaveCrop != null)
|
||||||
|
_BarButton(
|
||||||
|
icon: Icons.save_outlined,
|
||||||
|
onTap: onSaveCrop!,
|
||||||
|
),
|
||||||
_BarButton(
|
_BarButton(
|
||||||
icon: Icons.horizontal_rule,
|
icon: Icons.horizontal_rule,
|
||||||
onTap: () => windowManager.minimize(),
|
onTap: () => windowManager.minimize(),
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ class ImageCanvas extends StatefulWidget {
|
|||||||
final String filePath;
|
final String filePath;
|
||||||
const ImageCanvas({super.key, required this.filePath});
|
const ImageCanvas({super.key, required this.filePath});
|
||||||
@override
|
@override
|
||||||
State<ImageCanvas> createState() => _ImageCanvasState();
|
State<ImageCanvas> createState() => ImageCanvasState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ImageCanvasState extends State<ImageCanvas> {
|
class ImageCanvasState extends State<ImageCanvas> {
|
||||||
bool _isFilled = true;
|
bool _isFilled = true;
|
||||||
final GlobalKey _imageKey = GlobalKey();
|
final GlobalKey _imageKey = GlobalKey();
|
||||||
double _tx = 0, _ty = 0, _sc = 1.0;
|
double _tx = 0, _ty = 0, _sc = 1.0;
|
||||||
@@ -314,6 +314,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
static const double _bLum = 0.0722;
|
static const double _bLum = 0.0722;
|
||||||
|
|
||||||
ColorFilter? _cachedFilter;
|
ColorFilter? _cachedFilter;
|
||||||
|
List<double>? _cachedMatrix;
|
||||||
double _cachedSat = -1;
|
double _cachedSat = -1;
|
||||||
double _cachedCon = -1;
|
double _cachedCon = -1;
|
||||||
double _cachedBri = double.nan;
|
double _cachedBri = double.nan;
|
||||||
@@ -380,11 +381,60 @@ class _ImageCanvasState extends State<ImageCanvas> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// M_final = M_contrast * M_brightness * M_saturation
|
// M_final = M_contrast * M_brightness * M_saturation
|
||||||
_cachedFilter = ColorFilter.matrix(
|
final matrix =
|
||||||
_multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix));
|
_multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix);
|
||||||
|
_cachedMatrix = matrix;
|
||||||
|
_cachedFilter = ColorFilter.matrix(matrix);
|
||||||
return _cachedFilter!;
|
return _cachedFilter!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aktif renk matrisi (4x5 row-major); identity ise null.
|
||||||
|
/// CropSaveService'e aynen taşınır (aynı alan/semantik: 0-255).
|
||||||
|
List<double>? get activeColorMatrix {
|
||||||
|
if (_saturation == 1.0 && _contrast == 1.0 && _brightness == 0.0) return null;
|
||||||
|
_buildCombinedFilter();
|
||||||
|
return _cachedMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Viewport'un görünen bölgesini kaynak (tam çözünürlük) uzayına eşler.
|
||||||
|
/// [w]x[h] = gerçek dosya boyutu. Degrade durumda null.
|
||||||
|
Rect? cropSourceRect(int w, int h) {
|
||||||
|
if (_vpSize.width <= 0 || _vpSize.height <= 0 || w <= 0 || h <= 0) return null;
|
||||||
|
final cx = _vpSize.width / 2, cy = _vpSize.height / 2;
|
||||||
|
final rad = -_angle * math.pi / 180;
|
||||||
|
final cos = math.cos(rad), sin = math.sin(rad);
|
||||||
|
|
||||||
|
Offset toSrc(Offset p) {
|
||||||
|
final double qx = (p.dx - _tx) / _sc, qy = (p.dy - _ty) / _sc;
|
||||||
|
final double rx = (_angle == 0) ? qx : (cos * (qx - cx) - sin * (qy - cy) + cx);
|
||||||
|
final double ry = (_angle == 0) ? qy : (sin * (qx - cx) + cos * (qy - cy) + cy);
|
||||||
|
final double k = _isFilled
|
||||||
|
? math.max(_vpSize.width / w, _vpSize.height / h)
|
||||||
|
: math.min(_vpSize.width / w, _vpSize.height / h);
|
||||||
|
final double ox = (_vpSize.width - w * k) / 2, oy = (_vpSize.height - h * k) / 2;
|
||||||
|
return Offset((rx - ox) / k, (ry - oy) / k);
|
||||||
|
}
|
||||||
|
|
||||||
|
final corners = [
|
||||||
|
toSrc(Offset.zero),
|
||||||
|
toSrc(Offset(_vpSize.width, 0)),
|
||||||
|
toSrc(Offset(_vpSize.width, _vpSize.height)),
|
||||||
|
toSrc(Offset(0, _vpSize.height)),
|
||||||
|
];
|
||||||
|
double xmin = double.infinity, xmax = -double.infinity;
|
||||||
|
double ymin = double.infinity, ymax = -double.infinity;
|
||||||
|
for (final c in corners) {
|
||||||
|
xmin = math.min(xmin, c.dx); xmax = math.max(xmax, c.dx);
|
||||||
|
ymin = math.min(ymin, c.dy); ymax = math.max(ymax, c.dy);
|
||||||
|
}
|
||||||
|
return Rect.fromLTRB(
|
||||||
|
xmin.clamp(0.0, w.toDouble()),
|
||||||
|
ymin.clamp(0.0, h.toDouble()),
|
||||||
|
xmax.clamp(0.0, w.toDouble()),
|
||||||
|
ymax.clamp(0.0, h.toDouble()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
FileImage? _cachedFileImage;
|
FileImage? _cachedFileImage;
|
||||||
ImageProvider<Object>? _cachedProvider;
|
ImageProvider<Object>? _cachedProvider;
|
||||||
String? _cachedPath;
|
String? _cachedPath;
|
||||||
|
|||||||
+1
-1
@@ -198,7 +198,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
name: imajviewer
|
name: imajviewer
|
||||||
description: "Ultra-lightweight image viewer for Linux and Windows."
|
description: "Ultra-lightweight image viewer for Linux and Windows."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.3.2+7
|
version: 1.4.0+8
|
||||||
|
|
||||||
msix_config:
|
msix_config:
|
||||||
display_name: ImajViewer
|
display_name: ImajViewer
|
||||||
@@ -24,6 +24,7 @@ dependencies:
|
|||||||
file_picker: ^8.0.0
|
file_picker: ^8.0.0
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
desktop_drop: ^0.7.1
|
desktop_drop: ^0.7.1
|
||||||
|
image: ^4.0.0
|
||||||
msix: ^3.18.0
|
msix: ^3.18.0
|
||||||
printing: ^5.13.4
|
printing: ^5.13.4
|
||||||
pdf: ^3.11.1
|
pdf: ^3.11.1
|
||||||
|
|||||||
Reference in new issue
Block a user