feat: letterbox-aware clamp, full-frame image, fit/fill zoom fix
Build Windows / build-windows (push) Has been cancelled
Build Windows / build-windows (push) Has been cancelled
- image_canvas.dart: - Fill mod: viewport tabanli clamp (_tx=0'da kilit) - Fit mod: RenderBox ile resmin gercek render boyutuna gore clamp - Letterbox offset (offX/offY) hesaba katilarak resmin kenarlarina kadar pan - Zoom-to-cursor dogru calisiyor (alignment: topLeft ile) - Min scale 1.0 (her iki modda da) - Overflow durumunda lo/hi reversed clamp (goruntu disari kacmaz) - viewer_screen.dart: image alani top:24 -> top:0 (tam cerceve) - install.sh: release -> debug bundle path (gelistirme)
This commit is contained in:
1 parent
9a0e89bcea
commit
4dae4234ba
3 files changed
+144
-125
No files matched your search
+3
-3
@@ -3,7 +3,7 @@ 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"
|
||||||
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"
|
||||||
@@ -29,8 +29,8 @@ if [ ! -f "$BUNDLE_DIR/${APP}" ]; then
|
|||||||
else
|
else
|
||||||
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 yoksa önce git clone yapıp './install.sh' çalıştırın."
|
flutter build linux --debug 2>&1 | tail -1 || err "Flutter build başarısız! Sistemde Flutter SDK yoksa önce git clone yapıp './install.sh' çalıştırın."
|
||||||
BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/release/bundle"
|
BUNDLE_DIR="${SCRIPT_DIR}/build/linux/x64/debug/bundle"
|
||||||
[ -f "$BUNDLE_DIR/${APP}" ] || err "Build başarısız!"
|
[ -f "$BUNDLE_DIR/${APP}" ] || err "Build başarısız!"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -119,16 +119,23 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 24,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Container(color: const Color(0xFF1a1a1a)),
|
child: Container(color: const Color(0xFF1a1a1a)),
|
||||||
),
|
),
|
||||||
Container(
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: _hasImages
|
child: _hasImages
|
||||||
? ImageCanvas(filePath: ImageManager.instance.currentImagePath)
|
? ImageCanvas(
|
||||||
|
filePath: ImageManager.instance.currentImagePath,
|
||||||
|
)
|
||||||
: const Center(
|
: const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -142,6 +149,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const CustomTitleBar(),
|
const CustomTitleBar(),
|
||||||
if (_isDragging)
|
if (_isDragging)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
|||||||
+130
-119
@@ -4,160 +4,171 @@ import 'package:flutter/gestures.dart';
|
|||||||
|
|
||||||
class ImageCanvas extends StatefulWidget {
|
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> {
|
||||||
late TransformationController _controller;
|
bool _isFilled = true;
|
||||||
double _currentScale = 1.0;
|
final GlobalKey _imageKey = GlobalKey();
|
||||||
bool _isFilled = true; // varsayılan: ekranı doldur
|
double _tx = 0, _ty = 0, _sc = 1.0;
|
||||||
|
Size _vpSize = Size.zero;
|
||||||
|
|
||||||
@override
|
void _clamp() {
|
||||||
void initState() {
|
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
||||||
super.initState();
|
if (_isFilled) {
|
||||||
_controller = TransformationController();
|
// Fill mode: viewport tabanli clamp
|
||||||
_controller.addListener(_onTransformChanged);
|
final double w = _vpSize.width * _sc;
|
||||||
|
final double h = _vpSize.height * _sc;
|
||||||
|
if (w >= _vpSize.width) {
|
||||||
|
if (_tx > 0) _tx = 0;
|
||||||
|
else if (_tx + w < _vpSize.width) _tx = _vpSize.width - w;
|
||||||
|
} else { if (_tx != 0) _tx = 0; }
|
||||||
|
if (h >= _vpSize.height) {
|
||||||
|
if (_ty > 0) _ty = 0;
|
||||||
|
else if (_ty + h < _vpSize.height) _ty = _vpSize.height - h;
|
||||||
|
} else { if (_ty != 0) _ty = 0; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fit mode: resmin gercek boyutuna gore clamp (letterbox dahil)
|
||||||
|
double m = 0, n = 0;
|
||||||
|
double w = _vpSize.width * _sc;
|
||||||
|
double h = _vpSize.height * _sc;
|
||||||
|
final box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (box != null && box.hasSize && box.size.width > 0) {
|
||||||
|
final Size img = box.size;
|
||||||
|
m = (_vpSize.width - img.width) / 2;
|
||||||
|
n = (_vpSize.height - img.height) / 2;
|
||||||
|
w = img.width * _sc;
|
||||||
|
h = img.height * _sc;
|
||||||
|
}
|
||||||
|
if (w >= _vpSize.width) {
|
||||||
|
final double lo = -m * _sc;
|
||||||
|
final double hi = _vpSize.width - m * _sc - w;
|
||||||
|
if (lo <= hi) {
|
||||||
|
if (_tx < lo) _tx = lo;
|
||||||
|
if (_tx > hi) _tx = hi;
|
||||||
|
} else {
|
||||||
|
if (_tx < hi) _tx = hi;
|
||||||
|
if (_tx > lo) _tx = lo;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final double lo = -m * _sc;
|
||||||
|
final double hi = _vpSize.width - m * _sc - w;
|
||||||
|
if (_tx < lo) _tx = lo;
|
||||||
|
if (_tx > hi) _tx = hi;
|
||||||
|
}
|
||||||
|
if (h >= _vpSize.height) {
|
||||||
|
final double lo = -n * _sc;
|
||||||
|
final double hi = _vpSize.height - n * _sc - h;
|
||||||
|
if (lo <= hi) {
|
||||||
|
if (_ty < lo) _ty = lo;
|
||||||
|
if (_ty > hi) _ty = hi;
|
||||||
|
} else {
|
||||||
|
if (_ty < hi) _ty = hi;
|
||||||
|
if (_ty > lo) _ty = lo;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final double lo = -n * _sc;
|
||||||
|
final double hi = _vpSize.height - n * _sc - h;
|
||||||
|
if (_ty < lo) _ty = lo;
|
||||||
|
if (_ty > hi) _ty = hi;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(ImageCanvas oldWidget) {
|
void didUpdateWidget(ImageCanvas old) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(old);
|
||||||
if (oldWidget.filePath != widget.filePath) {
|
if (old.filePath != widget.filePath) _resetView();
|
||||||
_resetView();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void _resetView() {
|
void _resetView() {
|
||||||
_controller.value = Matrix4.identity();
|
setState(() { _tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero; });
|
||||||
_currentScale = 1.0;
|
|
||||||
_isFilled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTransformChanged() {
|
|
||||||
_currentScale = _controller.value.getMaxScaleOnAxis();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleFillFit() {
|
void _toggleFillFit() {
|
||||||
setState(() {
|
setState(() { _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero; });
|
||||||
_isFilled = !_isFilled;
|
}
|
||||||
_controller.value = Matrix4.identity();
|
void _handleDoubleTap() => _toggleFillFit();
|
||||||
_currentScale = 1.0;
|
|
||||||
});
|
void _handleScroll(PointerScrollEvent e) {
|
||||||
|
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
||||||
|
final double vx = e.localPosition.dx;
|
||||||
|
final double vy = e.localPosition.dy;
|
||||||
|
final double cx = (vx - _tx) / _sc;
|
||||||
|
final double cy = (vy - _ty) / _sc;
|
||||||
|
const double factor = 1.1;
|
||||||
|
final double rawSc = _sc * (e.scrollDelta.dy < 0 ? factor : 1.0 / factor);
|
||||||
|
final double newSc = rawSc.clamp(1.0, 10.0);
|
||||||
|
_tx = _tx + cx * _sc - cx * newSc;
|
||||||
|
_ty = _ty + cy * _sc - cy * newSc;
|
||||||
|
_sc = newSc;
|
||||||
|
_clamp();
|
||||||
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDoubleTap() {
|
void _onPanUpdate(DragUpdateDetails d) {
|
||||||
_toggleFillFit();
|
_tx += d.delta.dx;
|
||||||
|
_ty += d.delta.dy;
|
||||||
|
_clamp();
|
||||||
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleScroll(PointerScrollEvent event) {
|
Widget _buildImage() {
|
||||||
final renderBox = context.findRenderObject() as RenderBox?;
|
return Image.file(
|
||||||
if (renderBox == null) return;
|
key: _imageKey,
|
||||||
|
File(widget.filePath),
|
||||||
final localPosition = renderBox.globalToLocal(event.position);
|
fit: _isFilled ? BoxFit.cover : BoxFit.contain,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
final delta = event.scrollDelta.dy;
|
errorBuilder: (_, _, _) => _errorWidget(),
|
||||||
final scaleFactor = delta < 0 ? 1.1 : 1 / 1.1;
|
);
|
||||||
|
|
||||||
final newScale = (_currentScale * scaleFactor).clamp(0.1, 10.0);
|
|
||||||
if (newScale <= 0.1 || newScale >= 10.0) return;
|
|
||||||
|
|
||||||
final actualScale = newScale / _currentScale;
|
|
||||||
|
|
||||||
// Fare altındaki child-space noktayı hesapla
|
|
||||||
final matrix = _controller.value;
|
|
||||||
final tx = matrix.getTranslation().x;
|
|
||||||
final ty = matrix.getTranslation().y;
|
|
||||||
|
|
||||||
final childX = (localPosition.dx - tx) / _currentScale;
|
|
||||||
final childY = (localPosition.dy - ty) / _currentScale;
|
|
||||||
|
|
||||||
// controller * zoomAroundPoint sırasıyla uygula
|
|
||||||
final newMatrix = Matrix4.copy(_controller.value)
|
|
||||||
// ignore: deprecated_member_use
|
|
||||||
..translate(childX, childY)
|
|
||||||
// ignore: deprecated_member_use
|
|
||||||
..scale(actualScale)
|
|
||||||
// ignore: deprecated_member_use
|
|
||||||
..translate(-childX, -childY);
|
|
||||||
|
|
||||||
_controller.value = newMatrix;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.removeListener(_onTransformChanged);
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return Listener(
|
_vpSize = constraints.biggest;
|
||||||
onPointerSignal: (event) {
|
_clamp();
|
||||||
if (event is PointerScrollEvent) {
|
return SizedBox.expand(
|
||||||
_handleScroll(event);
|
child: Stack(
|
||||||
}
|
children: [
|
||||||
},
|
ClipRect(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onDoubleTap: _handleDoubleTap,
|
onDoubleTap: _handleDoubleTap,
|
||||||
child: InteractiveViewer(
|
onPanUpdate: _onPanUpdate,
|
||||||
transformationController: _controller,
|
child: Listener(
|
||||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
onPointerSignal: (event) {
|
||||||
minScale: 0.1,
|
if (event is PointerScrollEvent) _handleScroll(event);
|
||||||
maxScale: 10.0,
|
},
|
||||||
panEnabled: true,
|
child: Transform(
|
||||||
scaleEnabled: false,
|
alignment: Alignment.topLeft,
|
||||||
|
transform: Matrix4.identity()
|
||||||
|
..translate(_tx, _ty)
|
||||||
|
..scale(_sc),
|
||||||
child: _isFilled
|
child: _isFilled
|
||||||
? SizedBox.expand(
|
? SizedBox.expand(child: _buildImage())
|
||||||
child: Image.file(
|
: Center(child: _buildImage()),
|
||||||
File(widget.filePath),
|
),
|
||||||
fit: BoxFit.cover,
|
),
|
||||||
filterQuality: FilterQuality.high,
|
),
|
||||||
errorBuilder: (context, error, stackTrace) {
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _errorWidget() {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.broken_image, size: 48, color: Colors.white38),
|
Icon(Icons.broken_image, size: 48, color: Colors.white38),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text('Resim yüklenemedi',
|
Text('Resim yüklenemedi', style: TextStyle(color: Colors.white38)),
|
||||||
style: TextStyle(color: Colors.white38)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Center(
|
|
||||||
child: Image.file(
|
|
||||||
File(widget.filePath),
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
filterQuality: FilterQuality.high,
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return const Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.broken_image, size: 48, color: Colors.white38),
|
|
||||||
SizedBox(height: 8),
|
|
||||||
Text('Resim yüklenemedi',
|
|
||||||
style: TextStyle(color: Colors.white38)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user