diff --git a/lib/components/canvas/canvas_image.dart b/lib/components/canvas/canvas_image.dart index 39fc2a90f..b0388f186 100644 --- a/lib/components/canvas/canvas_image.dart +++ b/lib/components/canvas/canvas_image.dart @@ -11,6 +11,35 @@ import 'package:saber/data/extensions/change_notifier_extensions.dart'; import 'package:saber/data/prefs.dart'; import 'package:saber/i18n/strings.g.dart'; +// type of activity with image +enum Active { + none, // is not active any image manipulation + destination, // setting destination rect + crop, // setting crop rect +} + +/// crop image by given rect - used to crop image +class ImgClipper extends CustomClipper { + ImgClipper( + this._clipRect, + ); + Rect _clipRect; // rectangle used to crop Image + Rect get clipRect => _clipRect; + set clipRect(Rect clipRect) { + _clipRect = clipRect; + } + + @override + Rect getClip(Size size) {// + return _clipRect; + } + + @override + bool shouldReclip(ImgClipper oldClipper) { + return (clipRect != oldClipper.clipRect); // when clipping Rectangle changes force repaint + } +} + class CanvasImage extends StatefulWidget { CanvasImage({ required this.filePath, @@ -33,6 +62,7 @@ class CanvasImage extends StatefulWidget { final bool readOnly; final bool selected; + /// When notified, all [CanvasImages] will have their [active] property set to false. static ChangeNotifier activeListener = ChangeNotifier(); @@ -47,20 +77,29 @@ class CanvasImage extends StatefulWidget { } class _CanvasImageState extends State { - bool _active = false; + Active _activeType = Active.none; // activity with image - /// Whether this image can be dragged - bool get active => _active; - set active(bool value) { - if (active == value) return; + bool isActive(){ + /// return True if is active. + /// only if active type is not none + return(_activeType!=Active.none); + } + + Active get activeType => _activeType; + set activeType(Active value) { + if (activeType == value) return; // no change in activity - if (value) { + if (!isActive() && value!=Active.none) { + // activating Active - deactivate listeners CanvasImage.activeListener - .notifyListenersPlease(); // de-activate all other images + .notifyListenersPlease(); // de-activate all other images (even this image) + } + _activeType = value; // set active state + widget.image.showCroppedImage=_activeType!=Active.crop; // if active state is not crop, then show cropped image + if (widget.isBackground){ + // calculate dstFullRect for background image + getClipRect(); } - - _active = value; - if (mounted) { try { setState(() {}); @@ -70,12 +109,52 @@ class _CanvasImageState extends State { } } + /// provide next active type to the current one + Active setNextActive(){ + switch(_activeType) { + case Active.none: + return(Active.destination); + case Active.destination: + return(Active.crop); + case Active.crop: + return(Active.none); // not active + } + } + Brightness imageBrightness = Brightness.light; late ui.FragmentShader shader = InvertShader.create(); + // used when dragging or resizing image Rect panStartRect = Rect.zero; Offset panStartPosition = Offset.zero; + // used when cropping image + Rect cropRect = Rect.zero; + Rect cropStartRect = Rect.zero; + + /// return clip rectangle used to crop image + Rect getClipRect(){ + if (widget.isBackground) { + // image is background image, must fit image to full page -> recalculate dstFullRect + Rect fullRect = Offset.zero & Size(widget.pageSize.width, widget.pageSize.height); + // now calculate size of cropped image to fit page Rectangle + final FittedSizes sizes = applyBoxFit(BoxFit.contain, widget.image.dstRect.size, fullRect.size); + final Rect dstBackgroundRect = Alignment.center.inscribe(sizes.destination, fullRect); // determine dstRect + widget.image.dstRect = dstBackgroundRect; + widget.image.dstFullRect =widget.image.getDstFullRect(); // update full rect + } + if (widget.image.showCroppedImage) { + // clipRectangle is used + Offset o = widget.image.dstRect.topLeft-widget.image.dstFullRect.topLeft; + Rect clipRect=o&widget.image.dstRect.size; // offset dstRect by its distance from topLeft of full image + return (clipRect); + } + else { + // show the whole image during clipping rectangle selection + return (Offset.zero & widget.image.dstFullRect.size); + } + } + @override void initState() { @@ -83,7 +162,7 @@ class _CanvasImageState extends State { if (widget.image.newImage) { // if the image is new, make it [active] - active = true; + activeType = Active.destination; // set active to destination rect widget.image.newImage = false; } @@ -94,7 +173,7 @@ class _CanvasImageState extends State { } void disableActive() { - active = false; + activeType = Active.none; } void imageListener() { @@ -103,8 +182,8 @@ class _CanvasImageState extends State { @override void didUpdateWidget(covariant CanvasImage oldWidget) { - if (widget.readOnly && active) { - active = false; + if (widget.readOnly && isActive()) { + activeType = Active.none; } if (widget.image != oldWidget.image) { oldWidget.image.removeListener(imageListener); @@ -130,20 +209,21 @@ class _CanvasImageState extends State { fit: StackFit.expand, children: [ MouseRegion( - cursor: active ? SystemMouseCursors.grab : MouseCursor.defer, + cursor: isActive() ? SystemMouseCursors.grab : MouseCursor.defer, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { - active = !active; + activeType = setNextActive(); // set next type of activity with image }, - onLongPress: active ? showModal : null, - onSecondaryTap: active ? showModal : null, - onPanStart: active + onLongPress: isActive() ? showModal : null, + onSecondaryTap: isActive() ? showModal : null, + onPanStart: isActive() ? (details) { panStartRect = widget.image.dstRect; + cropRect= widget.image.dstRect; } : null, - onPanUpdate: active + onPanUpdate: isActive() ? (details) { setState(() { double fivePercent = min(widget.pageSize.width * 0.05, @@ -164,10 +244,11 @@ class _CanvasImageState extends State { widget.image.dstRect.width, widget.image.dstRect.height, ); + widget.image.dstFullRect=widget.image.getDstFullRect(); // update full rect }); } : null, - onPanEnd: active + onPanEnd: isActive() ? (details) { if (panStartRect == widget.image.dstRect) return; widget.image.onMoveImage?.call( @@ -183,26 +264,27 @@ class _CanvasImageState extends State { : null, child: DecoratedBox( decoration: BoxDecoration( - border: Border.all( - color: - active ? colorScheme.onBackground : Colors.transparent, - width: 2, + border: Border.all( + color: + isActive() ? colorScheme.onBackground : Colors.transparent, + width: 2, ), ), - child: Center( - child: SizedBox( - width: widget.isBackground - ? widget.pageSize.width - : max(widget.image.dstRect.width, - CanvasImage.minImageSize), - height: widget.isBackground - ? widget.pageSize.height - : max(widget.image.dstRect.height, - CanvasImage.minImageSize), + child: SizedBox( // size of image box is allways image.dstFullRect. Cropping is limiting image size in ClipRect + // offset of SizedBox to dstFullRect.topleft is done in AnimatedPositioned later + width: max(widget.image.dstFullRect.width, + CanvasImage.minImageSize), + height: max(widget.image.dstFullRect.height, + CanvasImage.minImageSize), + child: ClipRect( // cliping is done in the sized box. I means that full image topLeft is (0,0) + clipper: ImgClipper( + getClipRect() // return clip rectangle according to actual action + ), child: SizedOverflowBox( - size: widget.image.srcRect.size, - child: Transform.translate( - offset: -widget.image.srcRect.topLeft, + size: widget.image.naturalSize, // size of full image + child: SizedBox( + height: widget.image.dstFullRect.height, + width: widget.image.dstFullRect.width, child: widget.image.buildImageWidget( context: context, overrideBoxFit: widget.overrideBoxFit, @@ -227,47 +309,105 @@ class _CanvasImageState extends State { color: colorScheme.primary.withOpacity(0.5), ), if (!widget.readOnly) - for (double x = -20; x <= 20; x += 20) - for (double y = -20; y <= 20; y += 20) - if (x != 0 || y != 0) // ignore (0,0) - _CanvasImageResizeHandle( - active: active, - position: Offset(x, y), - image: widget.image, - parent: this, - afterDrag: () => setState(() {}), - ), + ...buildResizeHandles(_activeType), // add set of handles manipulating image to Stack using spread separator ... ], ), ); if (widget.isBackground) { - return AnimatedPositioned( + return AnimatedPositioned(// duration: const Duration(milliseconds: 300), curve: Curves.fastLinearToSlowEaseIn, - left: 0, - top: 0, - right: 0, - bottom: 0, + left: widget.image.dstFullRect.left, + top: widget.image.dstFullRect.top, + width: widget.image.dstFullRect.width, + height: widget.image.dstFullRect.height, + child: unpositioned, + ); + } + if (_activeType!=Active.crop) { + return AnimatedPositioned( + // no animation if the image is being dragged or it's selected + duration: (panStartRect != Rect.zero || widget.selected) + ? Duration.zero + : const Duration(milliseconds: 300), + curve: Curves.fastLinearToSlowEaseIn, + left: widget.image.dstFullRect.left, + top: widget.image.dstFullRect.top, + width: max(widget.image.dstFullRect.width, CanvasImage.minInteractiveSize), + height: max( + widget.image.dstFullRect.height, CanvasImage.minInteractiveSize), + child: unpositioned, + ); + } + else { + // image is cropped, actualize image FullRect + widget.image.dstFullRect=widget.image.getDstFullRect(); + // and set that animated handles can move over Full image rectangle + return AnimatedPositioned( + // no animation if the image is being dragged or it's selected + duration: (panStartRect != Rect.zero || widget.selected) + ? Duration.zero + : const Duration(milliseconds: 300), + curve: Curves.fastLinearToSlowEaseIn, + left: widget.image.dstFullRect.left, + top: widget.image.dstFullRect.top, + width: max(widget.image.dstFullRect.width, CanvasImage.minInteractiveSize), + height: max( + widget.image.dstFullRect.height, CanvasImage.minInteractiveSize), + child: unpositioned, ); } - return AnimatedPositioned( - // no animation if the image is being dragged or it's selected - duration: (panStartRect != Rect.zero || widget.selected) - ? Duration.zero - : const Duration(milliseconds: 300), - curve: Curves.fastLinearToSlowEaseIn, - - left: widget.image.dstRect.left, - top: widget.image.dstRect.top, - width: max(widget.image.dstRect.width, CanvasImage.minInteractiveSize), - height: max(widget.image.dstRect.height, CanvasImage.minInteractiveSize), - - child: unpositioned, - ); } + + /// create a set of handles to manipulate image depending of type of Active + List buildResizeHandles(Active activeType){ + /// create "handles" to resize image or crop image + List handles = []; + switch(activeType) { + case Active.none: + break; + case Active.destination: + // create handles in corners and centers of edges + for (double x = -20; x <= 20; x += 20){ + for (double y = -20; y <= 20; y += 20){ + if (x != 0 || y != 0){ // ignore (0,0) + handles.add( + _CanvasImageResizeHandle( + active: isActive(), + position: Offset (x, y), + image: widget.image, + parent: this, + afterDrag: () => setState(() {}), + ) + ); + } + } + } + case Active.crop: + // first record of dstRect to auxiliary cropRect + cropRect= widget.image.dstRect; + // create handles in corners + for (double x = -20; x <= 20; x += 40){ + for (double y = -20; y <= 20; y += 40){ + handles.add( + _CanvasImageCropHandle( + active: isActive(), + position: Offset (x, y), + image: widget.image, + parent: this, + afterDrag: () => setState(() {}), // force repaint + ) + ); + } + } + } + return(handles); + } + + @override void dispose() { widget.image.loadOut(); @@ -317,8 +457,8 @@ class _CanvasImageResizeHandle extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Positioned( - left: (position.dx.sign + 1) / 2 * image.dstRect.width - 20, - top: (position.dy.sign + 1) / 2 * image.dstRect.height - 20, + left: (image.dstRect.topLeft-image.dstFullRect.topLeft).dx+ (position.dx.sign + 1) / 2 * image.dstRect.width - 20,// calculate position of handle according to current dstRect + top: (image.dstRect.topLeft-image.dstFullRect.topLeft).dy+ (position.dy.sign + 1) / 2 * image.dstRect.height - 20,// dstRect is recalculated when moving handles child: DeferPointer( paintOnTop: true, child: MouseRegion( @@ -405,7 +545,8 @@ class _CanvasImageResizeHandle extends StatelessWidget { newWidth, newHeight, ); - afterDrag(); + image.dstFullRect=image.getDstFullRect(); // update image full rect according new dstRect +// afterDrag(); } : null, onPanEnd: active @@ -444,3 +585,156 @@ class _CanvasImageResizeHandle extends StatelessWidget { ); } } + +class _CanvasImageCropHandle extends StatelessWidget { + // handles in corners of image handling croprect + const _CanvasImageCropHandle({ + required this.active, + required this.position, + required this.image, + required this.parent, + required this.afterDrag, + }); + + final bool active; + final Offset position; + final EditorImage image; + final _CanvasImageState parent; + final void Function() afterDrag; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Positioned( + left: (image.dstRect.topLeft-image.dstFullRect.topLeft).dx+ (position.dx.sign + 1) / 2 * parent.cropRect.width - 20, // use positions of handles according to cropRect stored in parent + top: (image.dstRect.topLeft-image.dstFullRect.topLeft).dy+ (position.dy.sign + 1) / 2 * parent.cropRect.height - 20, + child: DeferPointer( + paintOnTop: true, + child: MouseRegion( + cursor: () { + if (!active) return MouseCursor.defer; + + if (position.dx < 0 && position.dy < 0) + return SystemMouseCursors.resizeUpLeft; + if (position.dx < 0 && position.dy > 0) + return SystemMouseCursors.resizeDownLeft; + if (position.dx > 0 && position.dy < 0) + return SystemMouseCursors.resizeUpRight; + if (position.dx > 0 && position.dy > 0) + return SystemMouseCursors.resizeDownRight; + + return MouseCursor.defer; + }(), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: active + ? (details) { + parent.panStartRect = parent.widget.image.dstRect; + parent.panStartPosition = details.localPosition; + } + : null, + onPanUpdate: active + ? (details) { + final Offset delta = + details.localPosition - parent.panStartPosition; + + double newWidth; + if (position.dx < 0) { + newWidth = parent.panStartRect.width - delta.dx; + } else if (position.dx > 0) { + newWidth = parent.panStartRect.width + delta.dx; + } else { + newWidth = parent.panStartRect.width; + } + + double newHeight; + if (position.dy < 0) { + newHeight = parent.panStartRect.height - delta.dy; + } else if (position.dy > 0) { + newHeight = parent.panStartRect.height + delta.dy; + } else { + newHeight = parent.panStartRect.height; + } + + if (newWidth <= 0 || newHeight <= 0) return; + + // resize from the correct corner + double left = parent.cropRect.left, top = parent.cropRect.top; + if (position.dx < 0) { + left = parent.cropRect.right - newWidth; + } + if (position.dy < 0) { + top = parent.cropRect.bottom - newHeight; + } + double right=left+newWidth; + double bottom=top+newHeight; + + // check if crop rect is not over full rect of image in destination coordinates + Rect fullImageRect=image.getDstFullRect(); + if (leftfullImageRect.right) { + newWidth=fullImageRect.right-left; + } + if (bottom>fullImageRect.bottom) { + newHeight=fullImageRect.bottom-top; + } + parent.cropRect=Rect.fromLTWH( // this is rectangle given by handles is cropRect and also dstRect + left, + top, + newWidth, + newHeight, + ); + // recalculate given crop rect given by current selection to image source coordinates + image.srcRect=image.transformRectFromDstToSrcDuringCrop(parent.cropRect); + image.dstRect=parent.cropRect; + // image dstFullRect is not changed, because we are not scaling image + afterDrag(); + } + : null, + onPanEnd: active + ? (details) { + if (parent.panStartRect == parent.cropRect) return; + // now parent.cropRect is new image.dstRect + // image.cropRect should be recalculated with respect to rectangle of image.srcRect + image.srcRect=image.transformRectFromDstToSrcDuringCrop(parent.cropRect); // this is full rect + image.dstRect=parent.cropRect; // and set destination rect to size of cropRect + image.dstFullRect=image.getDstFullRect(); // update full rect + image.onMoveImage?.call( + image, + Rect.fromLTRB( + image.dstRect.left - parent.panStartRect.left, + image.dstRect.top - parent.panStartRect.top, + image.dstRect.right - parent.panStartRect.right, + image.dstRect.bottom - parent.panStartRect.bottom, + )); + parent.panStartRect = Rect.zero; + } + : null, + child: AnimatedOpacity( + opacity: active ? 1 : 0, + duration: const Duration(milliseconds: 100), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.onBackground, + shape: BoxShape.rectangle, + border: Border.all( + color: colorScheme.background, + width: 2, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/components/canvas/image/editor_image.dart b/lib/components/canvas/image/editor_image.dart index f2fbead77..748dd27f9 100644 --- a/lib/components/canvas/image/editor_image.dart +++ b/lib/components/canvas/image/editor_image.dart @@ -50,8 +50,10 @@ sealed class EditorImage extends ChangeNotifier { void Function()? onMiscChange; final VoidCallback? onLoad; + /// rectangle used to display image (image crop region to be displayed). Full image dimension is given by naturalSize Rect srcRect = Rect.zero; + /// rectangle used to display image on canvas (area of cropped image) Rect _dstRect = Rect.zero; Rect get dstRect => _dstRect; set dstRect(Rect dstRect) { @@ -59,7 +61,18 @@ sealed class EditorImage extends ChangeNotifier { notifyListeners(); } - /// Defines the aspect ratio of the image. + /// rectangle used to display full size of image (without cropping it) + Rect _dstFullRect = Rect.zero; + Rect get dstFullRect => _dstFullRect; + set dstFullRect(Rect dstFullRect) { + _dstFullRect = dstFullRect; + notifyListeners(); + } + + /// show only cropped image, if false then show full image (it is used to select crop rect) + bool showCroppedImage=true; + + /// Defines the aspect ratio of the original image - image size Size naturalSize; /// The size of the page this image is on, @@ -250,4 +263,54 @@ sealed class EditorImage extends ChangeNotifier { return Size(width, height); } + + // image cropping functions + + /// function returning rectangle in destination coordinates (canvas) to be used to draw full image + Rect getDstFullRect(){ + double scaleX; + double scaleY; + if (srcRect.width!=0 && srcRect.height!=0){ + scaleX= dstRect.width/srcRect.width; + scaleY= dstRect.height/srcRect.height; + } + else { + // src rect is not set. Assume it is naturalSize + // srcrect is zero for pdf images + scaleX= dstRect.width/naturalSize.width; + scaleY= dstRect.height/naturalSize.height; + } + Offset cs=srcRect.topLeft; // offset of crop origin (topleft) from image origin (0,0) + Offset srcOriginInDest=-Offset(cs.dx*scaleX,cs.dy*scaleY); // offset of image origin (0,0) with respect to cropped origin in canvas dst coordinates + dstFullRect=Rect.fromLTWH(srcOriginInDest.dx,srcOriginInDest.dy,naturalSize.width*scaleX,naturalSize.height*scaleY).shift(dstRect.topLeft); // this is Rect in dst coordinates of full size image + return(dstFullRect); + } + + /// recalculates rectangle from destination coordinates given by dstR to image source coordinates given by srcRect + /// function is called during defining cropped part of image - dstR is rectangle in canvas coordinates + /// and represents a part of dstFullRect. From their difference we calculate srcRect - part of image to be displayed + Rect transformRectFromDstToSrcDuringCrop(Rect dstR){ + double scaleX; + double scaleY; + if (srcRect.width!=0 && srcRect.height!=0){ + scaleX= dstRect.width/srcRect.width; + scaleY= dstRect.height/srcRect.height; + } + else { + // src rect is not set. Assume it is naturalSize + // srcrect is zero for pdf images + scaleX= dstRect.width/naturalSize.width; + scaleY= dstRect.height/naturalSize.height; + } + Rect rct=dstR.shift(-dstFullRect.topLeft); // remove destination rect position - position 0,0 is top left corner cropRect + double dx=rct.left/scaleX; // offset of dstR from src topleft + double dy=rct.top/scaleY; + double width=rct.width/scaleX; + double height=rct.height/scaleY; + Rect crp=Rect.fromLTWH(0+dx,0+dy,width,height); + return(crp); // this is new srcRect of image + } + + + } diff --git a/lib/components/canvas/image/pdf_editor_image.dart b/lib/components/canvas/image/pdf_editor_image.dart index 36fd8baa5..1580bd404 100644 --- a/lib/components/canvas/image/pdf_editor_image.dart +++ b/lib/components/canvas/image/pdf_editor_image.dart @@ -135,6 +135,8 @@ class PdfEditorImage extends EditorImage { : naturalSize; dstRect = dstRect.topLeft & dstSize; } + // each image should have set dstFullRect. + dstFullRect=getDstFullRect(); // calculate full image rect } @override diff --git a/lib/components/canvas/image/png_editor_image.dart b/lib/components/canvas/image/png_editor_image.dart index 5893d0979..7dceacf45 100644 --- a/lib/components/canvas/image/png_editor_image.dart +++ b/lib/components/canvas/image/png_editor_image.dart @@ -169,11 +169,22 @@ class PngEditorImage extends EditorImage { dstRect = dstRect.topLeft & dstSize; } } +// if (srcRect.topLeft==Offset.zero) { +// srcRect = Rect.fromLTWH(324 / 4, 162 / 4, 3 / 4 * 324, 3 / 4 * 162); +// dstRect=Rect.fromLTWH(srcRect.left,srcRect.top,srcRect.width*2,srcRect.height*2).shift(Offset(100,100)); +// } + + dstFullRect=getDstFullRect(); // calculate full image rect +// if (dstFullRect.left<0){ +// dstRect=dstRect.shift(-dstFullRect.topLeft); +// dstFullRect=dstFullRect.shift(-dstFullRect.topLeft); +// } if (naturalSize.shortestSide == 0) { naturalSize = Size(srcRect.width, srcRect.height); } + if (isThumbnail) { isThumbnail = true; // updates bytes and srcRect }