mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-03-04 22:38:51 +01:00
Crop an image in editor
This commit is contained in:
parent
48d3b75607
commit
75d05e767e
10 changed files with 710 additions and 14 deletions
|
@ -1346,6 +1346,10 @@
|
|||
"@imageEditTransformOrientationCounterclockwise": {
|
||||
"description": "Indicate a counterclockwise rotation. This text must be short as there's only minimal space"
|
||||
},
|
||||
"imageEditTransformCrop": "Crop",
|
||||
"@imageEditTransformCrop": {
|
||||
"description": "Crop the image"
|
||||
},
|
||||
"categoriesLabel": "Categories",
|
||||
"searchLandingPeopleListEmptyText": "Press help to learn how to setup",
|
||||
"@searchLandingPeopleListEmptyText": {
|
||||
|
|
|
@ -134,6 +134,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"categoriesLabel",
|
||||
"searchLandingPeopleListEmptyText",
|
||||
"searchLandingCategoryVideosLabel",
|
||||
|
@ -308,6 +309,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"categoriesLabel",
|
||||
"searchLandingPeopleListEmptyText",
|
||||
"searchLandingCategoryVideosLabel",
|
||||
|
@ -363,6 +365,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"categoriesLabel",
|
||||
"searchLandingPeopleListEmptyText",
|
||||
"searchLandingCategoryVideosLabel",
|
||||
|
@ -399,6 +402,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"showAllButtonLabel",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
|
@ -416,6 +420,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent",
|
||||
|
@ -477,6 +482,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"categoriesLabel",
|
||||
"searchLandingPeopleListEmptyText",
|
||||
"searchLandingCategoryVideosLabel",
|
||||
|
@ -573,6 +579,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"categoriesLabel",
|
||||
"searchLandingPeopleListEmptyText",
|
||||
"searchLandingCategoryVideosLabel",
|
||||
|
@ -648,6 +655,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"categoriesLabel",
|
||||
"searchLandingPeopleListEmptyText",
|
||||
"searchLandingCategoryVideosLabel",
|
||||
|
@ -723,6 +731,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"categoriesLabel",
|
||||
"searchLandingPeopleListEmptyText",
|
||||
"searchLandingCategoryVideosLabel",
|
||||
|
@ -798,6 +807,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"categoriesLabel",
|
||||
"searchLandingPeopleListEmptyText",
|
||||
"searchLandingCategoryVideosLabel",
|
||||
|
@ -873,6 +883,7 @@
|
|||
"imageEditTransformOrientation",
|
||||
"imageEditTransformOrientationClockwise",
|
||||
"imageEditTransformOrientationCounterclockwise",
|
||||
"imageEditTransformCrop",
|
||||
"categoriesLabel",
|
||||
"searchLandingPeopleListEmptyText",
|
||||
"searchLandingCategoryVideosLabel",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/api/api.dart';
|
||||
|
@ -15,6 +16,7 @@ import 'package:nc_photos/theme.dart';
|
|||
import 'package:nc_photos/url_launcher_util.dart';
|
||||
import 'package:nc_photos/widget/handler/permission_handler.dart';
|
||||
import 'package:nc_photos/widget/image_editor/color_toolbar.dart';
|
||||
import 'package:nc_photos/widget/image_editor/crop_controller.dart';
|
||||
import 'package:nc_photos/widget/image_editor/transform_toolbar.dart';
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
|
||||
|
@ -113,12 +115,23 @@ class _ImageEditorState extends State<ImageEditor> {
|
|||
_buildAppBar(context),
|
||||
Expanded(
|
||||
child: _isDoneInit
|
||||
? Image(
|
||||
image: (_dst ?? _src).run((obj) =>
|
||||
PixelImage(obj.pixel, obj.width, obj.height)),
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
? _isCropMode
|
||||
? CropController(
|
||||
// crop always work on the src, otherwise we'll be
|
||||
// cropping repeatedly
|
||||
image: _src,
|
||||
initialState: _cropFilter,
|
||||
onCropChanged: (cropFilter) {
|
||||
_cropFilter = cropFilter;
|
||||
_applyFilters();
|
||||
},
|
||||
)
|
||||
: Image(
|
||||
image: (_dst ?? _src).run((obj) =>
|
||||
PixelImage(obj.pixel, obj.width, obj.height)),
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
if (_activeTool == _ToolType.color)
|
||||
|
@ -136,6 +149,15 @@ class _ImageEditorState extends State<ImageEditor> {
|
|||
_transformFilters = transformFilters.toList();
|
||||
_applyFilters();
|
||||
},
|
||||
isCropModeChanged: (value) {
|
||||
setState(() {
|
||||
_isCropMode = value;
|
||||
});
|
||||
},
|
||||
onCropToolDeactivated: () {
|
||||
_cropFilter = null;
|
||||
_applyFilters();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildToolBar(context),
|
||||
|
@ -182,7 +204,7 @@ class _ImageEditorState extends State<ImageEditor> {
|
|||
isSelected: _activeTool == _ToolType.color,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_activeTool = _ToolType.color;
|
||||
_setActiveTool(_ToolType.color);
|
||||
});
|
||||
},
|
||||
),
|
||||
|
@ -192,7 +214,7 @@ class _ImageEditorState extends State<ImageEditor> {
|
|||
isSelected: _activeTool == _ToolType.transform,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_activeTool = _ToolType.transform;
|
||||
_setActiveTool(_ToolType.transform);
|
||||
});
|
||||
},
|
||||
),
|
||||
|
@ -249,9 +271,15 @@ class _ImageEditorState extends State<ImageEditor> {
|
|||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
void _setActiveTool(_ToolType tool) {
|
||||
_activeTool = tool;
|
||||
_isCropMode = false;
|
||||
}
|
||||
|
||||
List<ImageFilter> _buildFilterList() {
|
||||
return [
|
||||
..._transformFilters.map((f) => f.toImageFilter()),
|
||||
if (_cropFilter != null) _cropFilter!.toImageFilter()!,
|
||||
..._transformFilters.map((f) => f.toImageFilter()).whereNotNull(),
|
||||
..._colorFilters.map((f) => f.toImageFilter()),
|
||||
];
|
||||
}
|
||||
|
@ -264,15 +292,19 @@ class _ImageEditorState extends State<ImageEditor> {
|
|||
}
|
||||
|
||||
bool get _isModified =>
|
||||
_transformFilters.isNotEmpty || _colorFilters.isNotEmpty;
|
||||
_cropFilter != null ||
|
||||
_transformFilters.isNotEmpty ||
|
||||
_colorFilters.isNotEmpty;
|
||||
|
||||
bool _isDoneInit = false;
|
||||
late final Rgba8Image _src;
|
||||
Rgba8Image? _dst;
|
||||
var _activeTool = _ToolType.color;
|
||||
var _isCropMode = false;
|
||||
|
||||
var _colorFilters = <ColorArguments>[];
|
||||
var _transformFilters = <TransformArguments>[];
|
||||
TransformArguments? _cropFilter;
|
||||
}
|
||||
|
||||
enum _ToolType {
|
||||
|
|
494
app/lib/widget/image_editor/crop_controller.dart
Normal file
494
app/lib/widget/image_editor/crop_controller.dart
Normal file
|
@ -0,0 +1,494 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/pixel_image_provider.dart';
|
||||
import 'package:nc_photos/widget/image_editor/transform_toolbar.dart';
|
||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
|
||||
/// Crop editor
|
||||
///
|
||||
/// This widget only work when width == device width!
|
||||
class CropController extends StatefulWidget {
|
||||
const CropController({
|
||||
Key? key,
|
||||
required this.image,
|
||||
required this.initialState,
|
||||
this.onCropChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
createState() => _CropControllerState();
|
||||
|
||||
final Rgba8Image image;
|
||||
final TransformArguments? initialState;
|
||||
final ValueChanged<TransformArguments>? onCropChanged;
|
||||
}
|
||||
|
||||
class _CropControllerState extends State<CropController> {
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
if (widget.initialState?.getToolType() == TransformToolType.crop) {
|
||||
_initialState = widget.initialState as _CropArguments;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
_prevOrientation ??= orientation;
|
||||
if (_prevOrientation != orientation) {
|
||||
_onOrientationChanged(orientation);
|
||||
} else {
|
||||
_tryUpdateSize(context);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: .35,
|
||||
child: Image(
|
||||
image: PixelImage(widget.image.pixel, widget.image.width,
|
||||
widget.image.height),
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onPanStart: (_) {
|
||||
_canMoveRect = true;
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
if (!_canMoveRect) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (_size == null) return;
|
||||
final pos = details.localPosition;
|
||||
if (pos.dx > 0 &&
|
||||
pos.dx < _size!.width &&
|
||||
pos.dy > _offsetY &&
|
||||
pos.dy < _size!.height + _offsetY) {
|
||||
_moveRectBy(details.delta);
|
||||
} else {
|
||||
_canMoveRect = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
widget.onCropChanged?.call(_getCropArgs());
|
||||
},
|
||||
child: ClipRect(
|
||||
clipper: _CropClipper(
|
||||
_left,
|
||||
_top + _offsetY,
|
||||
_size == null ? double.infinity : _size!.width - _right,
|
||||
_size == null
|
||||
? double.infinity
|
||||
: _size!.height - _bottom + _offsetY,
|
||||
),
|
||||
child: Image(
|
||||
image: PixelImage(widget.image.pixel, widget.image.width,
|
||||
widget.image.height),
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_size != null) ...[
|
||||
Positioned(
|
||||
top: _top + _offsetY,
|
||||
left: _left,
|
||||
bottom: _bottom + _offsetY,
|
||||
right: _right,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: _top + _offsetY,
|
||||
left: _left,
|
||||
child: GestureDetector(
|
||||
onPanStart: (_) {
|
||||
_topDrain.reset();
|
||||
_leftDrain.reset();
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
if (_size == null) return;
|
||||
_moveTopByDy(details.delta.dy);
|
||||
_moveLeftByDx(details.delta.dx);
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
widget.onCropChanged?.call(_getCropArgs());
|
||||
},
|
||||
child: const _TouchDot(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: _top + _offsetY,
|
||||
right: _right,
|
||||
child: GestureDetector(
|
||||
onPanStart: (_) {
|
||||
_topDrain.reset();
|
||||
_rightDrain.reset();
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
if (_size == null) return;
|
||||
_moveTopByDy(details.delta.dy);
|
||||
_moveRightByDx(details.delta.dx);
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
widget.onCropChanged?.call(_getCropArgs());
|
||||
},
|
||||
child: const _TouchDot(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: _bottom + _offsetY,
|
||||
left: _left,
|
||||
child: GestureDetector(
|
||||
onPanStart: (_) {
|
||||
_bottomDrain.reset();
|
||||
_leftDrain.reset();
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
if (_size == null) return;
|
||||
_moveBottomByDy(details.delta.dy);
|
||||
_moveLeftByDx(details.delta.dx);
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
widget.onCropChanged?.call(_getCropArgs());
|
||||
},
|
||||
child: const _TouchDot(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: _bottom + _offsetY,
|
||||
right: _right,
|
||||
child: GestureDetector(
|
||||
onPanStart: (_) {
|
||||
_bottomDrain.reset();
|
||||
_rightDrain.reset();
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
if (_size == null) return;
|
||||
_moveBottomByDy(details.delta.dy);
|
||||
_moveRightByDx(details.delta.dx);
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
widget.onCropChanged?.call(_getCropArgs());
|
||||
},
|
||||
child: const _TouchDot(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onOrientationChanged(Orientation orientation) {
|
||||
_reset();
|
||||
_prevOrientation = orientation;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void _tryUpdateSize(BuildContext context) {
|
||||
if (_size == null) {
|
||||
final renderObj = context.findRenderObject() as RenderBox?;
|
||||
if (renderObj?.hasSize == true && renderObj!.size.width > 16) {
|
||||
// the renderbox height is always max
|
||||
if (renderObj.size.width == MediaQuery.of(context).size.width) {
|
||||
final height =
|
||||
renderObj.size.width / widget.image.width * widget.image.height;
|
||||
_size = Size(renderObj.size.width, height);
|
||||
_offsetY = (renderObj.size.height - height) / 2;
|
||||
} else {
|
||||
_size = renderObj.size;
|
||||
}
|
||||
}
|
||||
_log.info("[_tryUpdateSize] size = $_size, offsetY: $_offsetY");
|
||||
if (_size == null) {
|
||||
_log.info("[_tryUpdateSize] Schedule next");
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// apply initial state after getting size
|
||||
if (_initialState != null && !_isInitialRestored) {
|
||||
_restoreCropArgs(_initialState!);
|
||||
_isInitialRestored = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _moveTopByDy(double dy) {
|
||||
if (_topDrain.isAvailable) {
|
||||
dy = _topDrain.consume(dy);
|
||||
}
|
||||
// add distance outside of the view to drain
|
||||
_topDrain.add(_addTop(dy));
|
||||
}
|
||||
|
||||
double _addTop(double dy) {
|
||||
final old = _top;
|
||||
final upper = _size!.height - _bottom - _TouchDot.size * 2 - _threshold;
|
||||
// ignore if image is too small to allow cropping in this axis
|
||||
if (upper >= _top) {
|
||||
_top = (_top + dy).clamp(0, upper);
|
||||
} else if (dy < 0 && _size!.height - _bottom >= 0) {
|
||||
// allow expanding only
|
||||
_top = math.max(_top + dy, 0);
|
||||
}
|
||||
return (old + dy) - _top;
|
||||
}
|
||||
|
||||
void _moveLeftByDx(double dx) {
|
||||
if (_leftDrain.isAvailable) {
|
||||
dx = _leftDrain.consume(dx);
|
||||
}
|
||||
_leftDrain.add(_addLeft(dx));
|
||||
}
|
||||
|
||||
double _addLeft(double dx) {
|
||||
final old = _left;
|
||||
final upper = _size!.width - _right - _TouchDot.size * 2 - _threshold;
|
||||
if (upper >= _left) {
|
||||
_left = (_left + dx).clamp(0, upper);
|
||||
} else if (dx < 0 && _size!.width - _right >= 0) {
|
||||
_left = math.max(_left + dx, 0);
|
||||
}
|
||||
return (old + dx) - _left;
|
||||
}
|
||||
|
||||
void _moveBottomByDy(double dy) {
|
||||
if (_bottomDrain.isAvailable) {
|
||||
dy = _bottomDrain.consume(dy);
|
||||
}
|
||||
_bottomDrain.add(_addBottom(dy));
|
||||
}
|
||||
|
||||
double _addBottom(double dy) {
|
||||
final old = _bottom;
|
||||
final upper = _size!.height - _top - _TouchDot.size * 2 - _threshold;
|
||||
if (upper >= _bottom) {
|
||||
_bottom = (_bottom - dy).clamp(0, upper);
|
||||
} else if (dy > 0 && _size!.height - _top >= 0) {
|
||||
_bottom = math.max(_bottom - dy, 0);
|
||||
}
|
||||
return _bottom - (old - dy);
|
||||
}
|
||||
|
||||
void _moveRightByDx(double dx) {
|
||||
if (_rightDrain.isAvailable) {
|
||||
dx = _rightDrain.consume(dx);
|
||||
}
|
||||
_rightDrain.add(_addRight(dx));
|
||||
}
|
||||
|
||||
double _addRight(double dx) {
|
||||
final old = _right;
|
||||
final upper = _size!.width - _left - _TouchDot.size * 2 - _threshold;
|
||||
if (upper >= _right) {
|
||||
_right = (_right - dx).clamp(0, upper);
|
||||
} else if (dx > 0 && _size!.width - _left >= 0) {
|
||||
_right = math.max(_right - dx, 0);
|
||||
}
|
||||
return _right - (old - dx);
|
||||
}
|
||||
|
||||
void _moveRectBy(Offset offset) {
|
||||
if (offset.dy < 0) {
|
||||
// up
|
||||
final actual = math.min(_top, -offset.dy);
|
||||
_top -= actual;
|
||||
_bottom += actual;
|
||||
} else {
|
||||
// down
|
||||
final actual = math.min(_bottom, offset.dy);
|
||||
_top += actual;
|
||||
_bottom -= actual;
|
||||
}
|
||||
if (offset.dx < 0) {
|
||||
// left
|
||||
final actual = math.min(_left, -offset.dx);
|
||||
_left -= actual;
|
||||
_right += actual;
|
||||
} else {
|
||||
// right
|
||||
final actual = math.min(_right, offset.dx);
|
||||
_left += actual;
|
||||
_right -= actual;
|
||||
}
|
||||
}
|
||||
|
||||
_CropArguments _getCropArgs() {
|
||||
final topPercent = _top / _size!.height;
|
||||
final leftPercent = _left / _size!.width;
|
||||
final bottomPercent = (_size!.height - _bottom) / _size!.height;
|
||||
final rightPercent = (_size!.width - _right) / _size!.width;
|
||||
return _CropArguments(topPercent, leftPercent, bottomPercent, rightPercent);
|
||||
}
|
||||
|
||||
void _restoreCropArgs(_CropArguments args) {
|
||||
_top = args.top * _size!.height;
|
||||
_left = args.left * _size!.width;
|
||||
_bottom = _size!.height - args.bottom * _size!.height;
|
||||
_right = _size!.width - args.right * _size!.width;
|
||||
}
|
||||
|
||||
/// Reset state after orientation change
|
||||
void _reset() {
|
||||
_log.info("[reset] Reset state");
|
||||
if (_initialState != null) {
|
||||
// this is needed to also reset the state of the observer
|
||||
widget.onCropChanged?.call(_initialState!);
|
||||
}
|
||||
_isInitialRestored = false;
|
||||
_size = null;
|
||||
_offsetY = 0;
|
||||
_top = 0;
|
||||
_left = 0;
|
||||
_bottom = 0;
|
||||
_right = 0;
|
||||
}
|
||||
|
||||
_CropArguments? _initialState;
|
||||
bool _isInitialRestored = false;
|
||||
Size? _size;
|
||||
double _offsetY = 0;
|
||||
|
||||
var _top = 0.0;
|
||||
final _topDrain = _Drain();
|
||||
var _left = 0.0;
|
||||
final _leftDrain = _Drain();
|
||||
var _bottom = 0.0;
|
||||
final _bottomDrain = _Drain();
|
||||
var _right = 0.0;
|
||||
final _rightDrain = _Drain();
|
||||
// set this to false when pointer moved outside of the area, making user to
|
||||
// start a new pan session to move the rect
|
||||
var _canMoveRect = true;
|
||||
|
||||
Orientation? _prevOrientation;
|
||||
|
||||
static const _threshold = 24;
|
||||
static final _log =
|
||||
Logger("widget.image_editor.crop_controller._CropControllerState");
|
||||
}
|
||||
|
||||
class _TouchDot extends StatelessWidget {
|
||||
static const double size = 24;
|
||||
|
||||
const _TouchDot({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
color: Colors.white60,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CropClipper extends CustomClipper<Rect> {
|
||||
const _CropClipper(this.left, this.top, this.right, this.bottom);
|
||||
|
||||
@override
|
||||
getClip(Size size) => Rect.fromLTRB(left, top, right, bottom);
|
||||
|
||||
@override
|
||||
shouldReclip(CustomClipper oldClipper) {
|
||||
if (oldClipper is! _CropClipper) {
|
||||
return true;
|
||||
}
|
||||
return left != oldClipper.left ||
|
||||
top != oldClipper.top ||
|
||||
right != oldClipper.right ||
|
||||
bottom != oldClipper.bottom;
|
||||
}
|
||||
|
||||
final double left;
|
||||
final double top;
|
||||
final double right;
|
||||
final double bottom;
|
||||
}
|
||||
|
||||
/// Store exceeding values and consume them if needed
|
||||
class _Drain {
|
||||
void add(double v) {
|
||||
_drain += v;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_drain = 0;
|
||||
}
|
||||
|
||||
/// Consume by [v], and return whatever that remain in [v]
|
||||
double consume(double v) {
|
||||
if (_drain.sign == v.sign) {
|
||||
// add more to drain
|
||||
_drain += v;
|
||||
v = 0;
|
||||
} else {
|
||||
// consume from drain
|
||||
_drain += v;
|
||||
if (_drain.sign == v.sign) {
|
||||
// consumed all, dy = remaining
|
||||
v = _drain;
|
||||
_drain = 0;
|
||||
} else {
|
||||
v = 0;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
bool get isAvailable => _drain != 0;
|
||||
|
||||
double _drain = 0;
|
||||
}
|
||||
|
||||
class _CropArguments implements TransformArguments {
|
||||
const _CropArguments(this.top, this.left, this.bottom, this.right);
|
||||
|
||||
@override
|
||||
toImageFilter() => TransformCropFilter(top, left, bottom, right);
|
||||
|
||||
@override
|
||||
getToolType() => TransformToolType.crop;
|
||||
|
||||
final double top;
|
||||
final double left;
|
||||
final double bottom;
|
||||
final double right;
|
||||
}
|
|
@ -5,13 +5,14 @@ import 'package:nc_photos/widget/image_editor/toolbar_button.dart';
|
|||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||
|
||||
enum TransformToolType {
|
||||
crop,
|
||||
orientation,
|
||||
}
|
||||
|
||||
abstract class TransformArguments {
|
||||
ImageFilter toImageFilter();
|
||||
ImageFilter? toImageFilter();
|
||||
|
||||
TransformToolType _getToolType();
|
||||
TransformToolType getToolType();
|
||||
}
|
||||
|
||||
class TransformToolbar extends StatefulWidget {
|
||||
|
@ -19,6 +20,8 @@ class TransformToolbar extends StatefulWidget {
|
|||
Key? key,
|
||||
required this.initialState,
|
||||
required this.onActiveFiltersChanged,
|
||||
required this.isCropModeChanged,
|
||||
required this.onCropToolDeactivated,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -26,6 +29,8 @@ class TransformToolbar extends StatefulWidget {
|
|||
|
||||
final List<TransformArguments> initialState;
|
||||
final ValueChanged<Iterable<TransformArguments>> onActiveFiltersChanged;
|
||||
final ValueChanged<bool> isCropModeChanged;
|
||||
final VoidCallback onCropToolDeactivated;
|
||||
}
|
||||
|
||||
class _TransformToolbarState extends State<TransformToolbar> {
|
||||
|
@ -33,7 +38,7 @@ class _TransformToolbarState extends State<TransformToolbar> {
|
|||
initState() {
|
||||
super.initState();
|
||||
for (final s in widget.initialState) {
|
||||
_filters[s._getToolType()] = s;
|
||||
_filters[s.getToolType()] = s;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,6 +57,7 @@ class _TransformToolbarState extends State<TransformToolbar> {
|
|||
child = _buildOrientationOption(context);
|
||||
break;
|
||||
|
||||
case TransformToolType.crop:
|
||||
case null:
|
||||
child = null;
|
||||
break;
|
||||
|
@ -73,6 +79,14 @@ class _TransformToolbarState extends State<TransformToolbar> {
|
|||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
ToolbarButton(
|
||||
icon: Icons.crop_outlined,
|
||||
label: L10n.global().imageEditTransformCrop,
|
||||
onPressed: _onCropPressed,
|
||||
isSelected: _selectedFilter == TransformToolType.crop,
|
||||
activationOrder:
|
||||
_filters.containsKey(TransformToolType.crop) ? -1 : null,
|
||||
),
|
||||
ToolbarButton(
|
||||
icon: Icons.rotate_90_degrees_ccw_outlined,
|
||||
label: L10n.global().imageEditTransformOrientation,
|
||||
|
@ -165,15 +179,27 @@ class _TransformToolbarState extends State<TransformToolbar> {
|
|||
_selectedFilter = null;
|
||||
_filters.remove(type);
|
||||
});
|
||||
if (type == TransformToolType.crop) {
|
||||
widget.isCropModeChanged(false);
|
||||
widget.onCropToolDeactivated();
|
||||
}
|
||||
} else {
|
||||
if (_selectedFilter == TransformToolType.crop) {
|
||||
widget.isCropModeChanged(false);
|
||||
}
|
||||
setState(() {
|
||||
_selectedFilter = type;
|
||||
_filters[type] ??= defArgs;
|
||||
});
|
||||
if (type == TransformToolType.crop) {
|
||||
widget.isCropModeChanged(true);
|
||||
}
|
||||
}
|
||||
_notifyFiltersChanged();
|
||||
}
|
||||
|
||||
void _onCropPressed() =>
|
||||
_onFilterPressed(TransformToolType.crop, const _DummyCropArguments());
|
||||
void _onOrientationPressed() => _onFilterPressed(
|
||||
TransformToolType.orientation, const _OrientationArguments(0));
|
||||
|
||||
|
@ -192,6 +218,18 @@ class _TransformToolbarState extends State<TransformToolbar> {
|
|||
TransformToolType? _selectedFilter;
|
||||
}
|
||||
|
||||
// arguments for crop is handled by its controller, this is used to restore
|
||||
// state in the toolbar only
|
||||
class _DummyCropArguments implements TransformArguments {
|
||||
const _DummyCropArguments();
|
||||
|
||||
@override
|
||||
toImageFilter() => null;
|
||||
|
||||
@override
|
||||
getToolType() => TransformToolType.crop;
|
||||
}
|
||||
|
||||
class _OrientationArguments implements TransformArguments {
|
||||
const _OrientationArguments(this.value);
|
||||
|
||||
|
@ -199,7 +237,7 @@ class _OrientationArguments implements TransformArguments {
|
|||
toImageFilter() => TransformOrientationFilter(value);
|
||||
|
||||
@override
|
||||
_getToolType() => TransformToolType.orientation;
|
||||
getToolType() => TransformToolType.orientation;
|
||||
|
||||
final int value;
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ add_library( # Sets the name of the library.
|
|||
filter/brightness.cpp
|
||||
filter/color_levels.cpp
|
||||
filter/contrast.cpp
|
||||
filter/crop.cpp
|
||||
filter/curve.cpp
|
||||
filter/hslhsv.cpp
|
||||
filter/orientation.cpp
|
||||
|
|
69
plugin/android/src/main/cpp/filter/crop.cpp
Normal file
69
plugin/android/src/main/cpp/filter/crop.cpp
Normal file
|
@ -0,0 +1,69 @@
|
|||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <exception>
|
||||
#include <jni.h>
|
||||
#include <vector>
|
||||
|
||||
#include "../exception.h"
|
||||
#include "../log.h"
|
||||
#include "../util.h"
|
||||
|
||||
using namespace plugin;
|
||||
using namespace std;
|
||||
|
||||
namespace {
|
||||
|
||||
class Crop {
|
||||
public:
|
||||
std::vector<uint8_t> apply(const uint8_t *rgba8, const size_t width,
|
||||
const size_t height, const int top, const int left,
|
||||
const int dstWidth, const int dstHeight);
|
||||
|
||||
private:
|
||||
static constexpr const char *TAG = "Crop";
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" JNIEXPORT jbyteArray JNICALL
|
||||
Java_com_nkming_nc_1photos_plugin_image_1processor_Crop_applyNative(
|
||||
JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height,
|
||||
jint top, jint left, jint dstWidth, jint dstHeight) {
|
||||
try {
|
||||
initOpenMp();
|
||||
RaiiContainer<jbyte> cRgba8(
|
||||
[&]() { return env->GetByteArrayElements(rgba8, nullptr); },
|
||||
[&](jbyte *obj) {
|
||||
env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT);
|
||||
});
|
||||
const auto result =
|
||||
Crop().apply(reinterpret_cast<uint8_t *>(cRgba8.get()), width, height,
|
||||
top, left, dstWidth, dstHeight);
|
||||
auto resultAry = env->NewByteArray(result.size());
|
||||
env->SetByteArrayRegion(resultAry, 0, result.size(),
|
||||
reinterpret_cast<const int8_t *>(result.data()));
|
||||
return resultAry;
|
||||
} catch (const exception &e) {
|
||||
throwJavaException(env, e.what());
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
vector<uint8_t> Crop::apply(const uint8_t *rgba8, const size_t width,
|
||||
const size_t height, const int top, const int left,
|
||||
const int dstWidth, const int dstHeight) {
|
||||
LOGI(TAG, "[apply] top: %d, left: %d, width: %d, height: %d", top, left,
|
||||
dstWidth, dstHeight);
|
||||
vector<uint8_t> output(dstWidth * dstHeight * 4);
|
||||
#pragma omp parallel for
|
||||
for (size_t y = 0; y < dstHeight; ++y) {
|
||||
const auto srcY = y + top;
|
||||
memcpy(output.data() + (y * dstWidth * 4),
|
||||
rgba8 + (srcY * width + left) * 4, dstWidth * 4);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
} // namespace
|
|
@ -238,6 +238,10 @@ interface ImageFilter {
|
|||
"warmth" -> Warmth((json["weight"] as Double).toFloat())
|
||||
"tint" -> Tint((json["weight"] as Double).toFloat())
|
||||
"orientation" -> Orientation(json["degree"] as Int)
|
||||
"crop" -> Crop(
|
||||
json["top"] as Double, json["left"] as Double,
|
||||
json["bottom"] as Double, json["right"] as Double
|
||||
)
|
||||
else -> throw IllegalArgumentException(
|
||||
"Unknown type: ${json["type"]}"
|
||||
)
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package com.nkming.nc_photos.plugin.image_processor
|
||||
|
||||
import com.nkming.nc_photos.plugin.ImageFilter
|
||||
import java.lang.Integer.max
|
||||
|
||||
class Crop(
|
||||
val top: Double, val left: Double, val bottom: Double, val right: Double
|
||||
) : ImageFilter {
|
||||
override fun apply(rgba8: Rgba8Image): Rgba8Image {
|
||||
// prevent w/h == 0
|
||||
val width = max((rgba8.width * (right - left)).toInt(), 1)
|
||||
val height = max((rgba8.height * (bottom - top)).toInt(), 1)
|
||||
val top = (rgba8.height * top).toInt()
|
||||
val left = (rgba8.width * left).toInt()
|
||||
val data = applyNative(
|
||||
rgba8.pixel, rgba8.width, rgba8.height, top, left, width, height
|
||||
)
|
||||
return Rgba8Image(data, width, height)
|
||||
}
|
||||
|
||||
private external fun applyNative(
|
||||
rgba8: ByteArray, width: Int, height: Int, top: Int, left: Int,
|
||||
dstWidth: Int, dstHeight: Int
|
||||
): ByteArray
|
||||
}
|
|
@ -44,6 +44,24 @@ class ColorTintFilter extends _SingleWeightFilter {
|
|||
const ColorTintFilter(double weight) : super("tint", weight);
|
||||
}
|
||||
|
||||
class TransformCropFilter implements ImageFilter {
|
||||
const TransformCropFilter(this.top, this.left, this.bottom, this.right);
|
||||
|
||||
@override
|
||||
toJson() => {
|
||||
"type": "crop",
|
||||
"top": top,
|
||||
"left": left,
|
||||
"bottom": bottom,
|
||||
"right": right,
|
||||
};
|
||||
|
||||
final double top;
|
||||
final double left;
|
||||
final double bottom;
|
||||
final double right;
|
||||
}
|
||||
|
||||
class TransformOrientationFilter implements ImageFilter {
|
||||
const TransformOrientationFilter(this.degree);
|
||||
|
||||
|
|
Loading…
Reference in a new issue