feat: letterbox-aware clamp, full-frame image, fit/fill zoom fix
Some checks failed
Build Windows / build-windows (push) Has been cancelled
Some checks failed
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:
@@ -119,28 +119,36 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 24,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(color: const Color(0xFF1a1a1a)),
|
||||
),
|
||||
Container(
|
||||
color: Colors.transparent,
|
||||
child: _hasImages
|
||||
? ImageCanvas(filePath: ImageManager.instance.currentImagePath)
|
||||
: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.image, size: 64, color: Colors.white24),
|
||||
SizedBox(height: 16),
|
||||
Text('Resimleri buraya sürükleyin\nveya Ctrl+O ile açın',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white38, fontSize: 16)),
|
||||
],
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
child: _hasImages
|
||||
? ImageCanvas(
|
||||
filePath: ImageManager.instance.currentImagePath,
|
||||
)
|
||||
: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.image, size: 64, color: Colors.white24),
|
||||
SizedBox(height: 16),
|
||||
Text('Resimleri buraya sürükleyin\nveya Ctrl+O ile açın',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white38, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const CustomTitleBar(),
|
||||
if (_isDragging)
|
||||
|
||||
@@ -4,160 +4,171 @@ import 'package:flutter/gestures.dart';
|
||||
|
||||
class ImageCanvas extends StatefulWidget {
|
||||
final String filePath;
|
||||
|
||||
const ImageCanvas({super.key, required this.filePath});
|
||||
|
||||
@override
|
||||
State<ImageCanvas> createState() => _ImageCanvasState();
|
||||
}
|
||||
|
||||
class _ImageCanvasState extends State<ImageCanvas> {
|
||||
late TransformationController _controller;
|
||||
double _currentScale = 1.0;
|
||||
bool _isFilled = true; // varsayılan: ekranı doldur
|
||||
bool _isFilled = true;
|
||||
final GlobalKey _imageKey = GlobalKey();
|
||||
double _tx = 0, _ty = 0, _sc = 1.0;
|
||||
Size _vpSize = Size.zero;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TransformationController();
|
||||
_controller.addListener(_onTransformChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ImageCanvas oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filePath != widget.filePath) {
|
||||
_resetView();
|
||||
void _clamp() {
|
||||
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
||||
if (_isFilled) {
|
||||
// Fill mode: viewport tabanli clamp
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetView() {
|
||||
_controller.value = Matrix4.identity();
|
||||
_currentScale = 1.0;
|
||||
_isFilled = true;
|
||||
}
|
||||
|
||||
void _onTransformChanged() {
|
||||
_currentScale = _controller.value.getMaxScaleOnAxis();
|
||||
}
|
||||
|
||||
void _toggleFillFit() {
|
||||
setState(() {
|
||||
_isFilled = !_isFilled;
|
||||
_controller.value = Matrix4.identity();
|
||||
_currentScale = 1.0;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleDoubleTap() {
|
||||
_toggleFillFit();
|
||||
}
|
||||
|
||||
void _handleScroll(PointerScrollEvent event) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
final localPosition = renderBox.globalToLocal(event.position);
|
||||
|
||||
final delta = event.scrollDelta.dy;
|
||||
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();
|
||||
void didUpdateWidget(ImageCanvas old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.filePath != widget.filePath) _resetView();
|
||||
}
|
||||
void _resetView() {
|
||||
setState(() { _tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero; });
|
||||
}
|
||||
void _toggleFillFit() {
|
||||
setState(() { _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero; });
|
||||
}
|
||||
void _handleDoubleTap() => _toggleFillFit();
|
||||
|
||||
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 _onPanUpdate(DragUpdateDetails d) {
|
||||
_tx += d.delta.dx;
|
||||
_ty += d.delta.dy;
|
||||
_clamp();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Widget _buildImage() {
|
||||
return Image.file(
|
||||
key: _imageKey,
|
||||
File(widget.filePath),
|
||||
fit: _isFilled ? BoxFit.cover : BoxFit.contain,
|
||||
filterQuality: FilterQuality.high,
|
||||
errorBuilder: (_, _, _) => _errorWidget(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
_handleScroll(event);
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
onDoubleTap: _handleDoubleTap,
|
||||
child: InteractiveViewer(
|
||||
transformationController: _controller,
|
||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||
minScale: 0.1,
|
||||
maxScale: 10.0,
|
||||
panEnabled: true,
|
||||
scaleEnabled: false,
|
||||
child: _isFilled
|
||||
? SizedBox.expand(
|
||||
child: Image.file(
|
||||
File(widget.filePath),
|
||||
fit: BoxFit.cover,
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_vpSize = constraints.biggest;
|
||||
_clamp();
|
||||
return SizedBox.expand(
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRect(
|
||||
child: GestureDetector(
|
||||
onDoubleTap: _handleDoubleTap,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
child: Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) _handleScroll(event);
|
||||
},
|
||||
child: Transform(
|
||||
alignment: Alignment.topLeft,
|
||||
transform: Matrix4.identity()
|
||||
..translate(_tx, _ty)
|
||||
..scale(_sc),
|
||||
child: _isFilled
|
||||
? SizedBox.expand(child: _buildImage())
|
||||
: Center(child: _buildImage()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _errorWidget() {
|
||||
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