From ef4ad748f29eebff80bcdbb3a0a08853325d0b5f Mon Sep 17 00:00:00 2001 From: gmpassos Date: Mon, 8 May 2023 02:09:51 -0300 Subject: [PATCH] v2.1.23 - New `UIDialogEditImage`. - `UICapture`: - Added field `photoEditor` and `editCapture`. - intl_messages: ^2.1.5 - intl: ^0.18.1 --- CHANGELOG.md | 8 + lib/bones_ui.dart | 1 + lib/src/bones_ui.dart | 2 +- lib/src/component/capture.dart | 148 +++----- lib/src/component/dialog_edit_image.dart | 453 +++++++++++++++++++++++ pubspec.yaml | 6 +- 6 files changed, 517 insertions(+), 101 deletions(-) create mode 100644 lib/src/component/dialog_edit_image.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b9c6a6..532afa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2.1.23 + +- New `UIDialogEditImage`. +- `UICapture`: + - Added field `photoEditor` and `editCapture`. +- intl_messages: ^2.1.5 +- intl: ^0.18.1 + ## 2.1.22 - Allow nullable `parent` in components: diff --git a/lib/bones_ui.dart b/lib/bones_ui.dart index d304214..c3c2788 100644 --- a/lib/bones_ui.dart +++ b/lib/bones_ui.dart @@ -26,6 +26,7 @@ export 'src/component/component_async.dart'; export 'src/component/controlled_component.dart'; export 'src/component/data_source.dart'; export 'src/component/dialog.dart'; +export 'src/component/dialog_edit_image.dart'; export 'src/component/infos_table.dart'; export 'src/component/input_config.dart'; export 'src/component/loading.dart'; diff --git a/lib/src/bones_ui.dart b/lib/src/bones_ui.dart index 755234d..f853483 100644 --- a/lib/src/bones_ui.dart +++ b/lib/src/bones_ui.dart @@ -1,3 +1,3 @@ class BonesUI { - static const String version = '2.1.22'; + static const String version = '2.1.23'; } diff --git a/lib/src/component/capture.dart b/lib/src/component/capture.dart index 6e434a6..6514fe6 100644 --- a/lib/src/component/capture.dart +++ b/lib/src/component/capture.dart @@ -1,16 +1,16 @@ +import 'dart:async'; import 'dart:convert' as data_convert; import 'dart:html'; import 'dart:math' as math; import 'dart:typed_data'; -import 'package:dom_builder/dom_builder.dart'; import 'package:dom_tools/dom_tools.dart'; import 'package:swiss_knife/swiss_knife.dart'; import '../bones_ui_base.dart'; import '../bones_ui_log.dart'; import 'button.dart'; -import 'dialog.dart'; +import 'dialog_edit_image.dart'; /// The capture type of an [UICapture]. enum CaptureType { @@ -62,6 +62,11 @@ enum CaptureDataFormat { url, } +typedef CapturePhotoEditor = FutureOr Function( + ImageElement image); + +/// Base class for capture components. +/// See [UIButtonCapture] and [UIButtonCapturePhoto]. abstract class UICapture extends UIButtonBase implements UIField { final CaptureType captureType; @@ -70,6 +75,8 @@ abstract class UICapture extends UIButtonBase implements UIField { final bool editCapture; + final CapturePhotoEditor? photoEditor; + UICapture(Element? container, this.captureType, {String? fieldName, this.captureAspectRatio, @@ -77,6 +84,7 @@ abstract class UICapture extends UIButtonBase implements UIField { this.captureMaxHeight, this.captureDataFormat = CaptureDataFormat.arrayBuffer, this.editCapture = false, + this.photoEditor, Object? selectedFileData, String? navigate, Map? navigateParameters, @@ -226,15 +234,6 @@ abstract class UICapture extends UIButtonBase implements UIField { void _callOnCapture(FileUploadInputElement input, Event event) async { await _readFile(input); - if (editCapture) { - if (captureType == CaptureType.photo || - captureType == CaptureType.photoFile || - captureType == CaptureType.photoSelfie) { - var dialogEdit = _DialogEditPhoto(this); - await dialogEdit.showAndWait(); - } - } - onCaptureFile(input, event); onCapture.add(this); } @@ -374,7 +373,8 @@ abstract class UICapture extends UIButtonBase implements UIField { Future<_CapturedData> _filterCapturedData(_CapturedData capturedData) async { if (captureMaxWidth == null && captureMaxHeight == null && - captureAspectRatio == null) { + captureAspectRatio == null && + !editCapture) { return capturedData; } @@ -406,10 +406,32 @@ abstract class UICapture extends UIButtonBase implements UIField { /// See [captureMaxWidth] and [captureMaxHeight]. double photoScaleQuality = 0.98; + // This is only called after load [image]. Future<_CapturedData> _filterCapturedPhoto( _CapturedData capturedData, ImageElement image) async { + if (editCapture) { + var photoEditor = this.photoEditor; + if (photoEditor != null) { + var imageEdited = await photoEditor(image); + if (imageEdited != null) { + image = imageEdited; + } + } else { + var dialogEdit = UIDialogEditImage(image); + var edited = await dialogEdit.showAndWait(); + if (edited) { + image = dialogEdit.editedImage ?? image; + } + } + } + + if (!(image.complete ?? false)) { + await image.onLoad.first; + } + var imgW = image.naturalWidth; var imgH = image.naturalHeight; + CanvasImageSource imgSrc = image; var aspectRatio = captureAspectRatio; @@ -434,7 +456,10 @@ abstract class UICapture extends UIButtonBase implements UIField { ctx.imageSmoothingQuality = 'high'; ctx.clearRect(0, 0, imgW2, imgH2); - ctx.drawImageScaled(image, 0, 0, imgW, imgH); + + var x = (imgW2 - imgW) ~/ 2; + var y = (imgH2 - imgH) ~/ 2; + ctx.drawImageScaled(image, x, y, imgW, imgH); imgW = imgW2; imgH = imgH2; @@ -456,11 +481,8 @@ abstract class UICapture extends UIButtonBase implements UIField { var rH = hLimit / imgH; var r = math.min(rW, rH); - var w2 = (imgW * r).toInt(); - var h2 = (imgH * r).toInt(); - - var canvasW = w2; - var canvasH = h2; + var canvasW = (imgW * r).toInt(); + var canvasH = (imgH * r).toInt(); var canvas = CanvasElement(width: canvasW, height: canvasH); @@ -469,8 +491,8 @@ abstract class UICapture extends UIButtonBase implements UIField { ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - ctx.clearRect(0, 0, w2, h2); - ctx.drawImageScaled(imgSrc, 0, 0, w2, h2); + ctx.clearRect(0, 0, canvasW, canvasH); + ctx.drawImageScaled(imgSrc, 0, 0, canvasW, canvasH); var photoScaleMimeType = this.photoScaleMimeType; @@ -560,82 +582,6 @@ abstract class UICapture extends UIButtonBase implements UIField { } } -class _DialogEditPhoto extends UIDialog { - final UICapture uiCapture; - - _DialogEditPhoto(this.uiCapture) - : super(null, - hideUIRoot: false, - showCloseButton: true, - backgroundGrey: 16, - backgroundAlpha: 0.75, - backgroundBlur: 2); - - _CanvasEditImage? _canvasEditImage; - - @override - dynamic renderContent() async { - var dataURL = uiCapture.selectedFileDataAsDataURLBase64; - if (dataURL == null) { - hide(); - return; - } - - if (_canvasEditImage == null) { - var img = ImageElement(src: dataURL); - await img.onLoad.first; - - _canvasEditImage = _CanvasEditImage(img); - } - - return [ - _canvasEditImage!, - $br(), - $button(style: 'btn btn-primary', content: '{{intl:btnSave}}'), - ]; - } -} - -class _CanvasEditImage extends ExternalElementNode { - final ImageElement img; - - _CanvasEditImage(this.img) : super(_buildCanvas(img)) { - render(); - } - - static CanvasElement _buildCanvas(ImageElement img) { - var imgW = img.naturalWidth; - var imgH = img.naturalHeight; - - var w = math.min(imgW, window.innerWidth ?? imgW); - var h = math.min(imgH, window.innerHeight ?? imgH); - - return CanvasElement(width: w, height: h) - ..style.boxShadow = '0px 1px 18px 5px rgba(0, 0, 0, 0.45)'; - } - - CanvasElement get canvas => externalElement as CanvasElement; - - int get imgWidth => img.naturalWidth; - - int get imgHeight => img.naturalHeight; - - int get imgX => (imgWidth - canvasWidth) ~/ 2; - - int get imgY => (imgHeight - canvasHeight) ~/ 2; - - int get canvasWidth => canvas.width!; - - int get canvasHeight => canvas.height!; - - void render() { - var canvas = this.canvas; - var context2d = canvas.context2D; - - context2d.drawImage(img, imgX, imgY); - } -} - /// The captured data with interchangeable formats. /// See [CaptureDataFormat]. class _CapturedData { @@ -988,6 +934,8 @@ class AudioFileReader extends URLFileReader { final EventStream onLoadAudio = EventStream(); } +/// A Button that captures a photo. +/// See [UICapture]. class UIButtonCapturePhoto extends UICapture { final String? text; final dynamic buttonContent; @@ -1003,6 +951,8 @@ class UIButtonCapturePhoto extends UICapture { super.captureMaxWidth, super.captureMaxHeight, super.captureDataFormat, + super.editCapture, + super.photoEditor, super.selectedFileData, super.navigate, super.navigateParameters, @@ -1128,13 +1078,17 @@ class UIButtonCapturePhoto extends UICapture { } } +/// A generic capture button. +/// See [UICapture]. class UIButtonCapture extends UICapture { final String text; final String? fontSize; UIButtonCapture(Element? parent, this.text, CaptureType captureType, - {String? fieldName, + {super.editCapture, + super.photoEditor, + String? fieldName, String? navigate, Map? navigateParameters, ParametersProvider? navigateParametersProvider, diff --git a/lib/src/component/dialog_edit_image.dart b/lib/src/component/dialog_edit_image.dart new file mode 100644 index 0000000..66c6891 --- /dev/null +++ b/lib/src/component/dialog_edit_image.dart @@ -0,0 +1,453 @@ +import 'dart:async'; +import 'dart:html'; +import 'dart:math' as math; + +import 'package:dom_builder/dom_builder.dart'; +import 'package:dom_tools/dom_tools.dart'; + +import 'dialog.dart'; + +/// An [UIDialog] that edits an [image]. +class UIDialogEditImage extends UIDialog { + /// The original image. + final ImageElement image; + + final int marginHorizontal; + final int marginVertical; + + final String? btnClasses; + + final String? btnStyle; + + UIDialogEditImage(this.image, + {this.btnClasses = 'btn btn-primary', + this.btnStyle = + 'background-color: rgba(0,0,0, 0.50); color: #ffffff; border-color: #ffffff;', + this.marginHorizontal = 8, + this.marginVertical = 48, + super.hideUIRoot = false}) + : super(null, + showCloseButton: true, + backgroundGrey: 16, + backgroundAlpha: 0.80, + backgroundBlur: 2) { + onClickListenOnlyForDialogButtonClass = true; + } + + _CanvasEditImage? _canvasEditImage; + + @override + dynamic renderContent() { + _canvasEditImage ??= _CanvasEditImage(image, image.naturalWidth, + image.naturalHeight, marginHorizontal, marginVertical); + + return [ + _canvasEditImage!, + $br(), + $button( + classes: btnClasses, + style: btnStyle, + content: $span(content: ' + ')) + ..onClick.listen((event) => _canvasEditImage?.zoomIn()), + $nbsp(), + $button( + classes: btnClasses, + style: btnStyle, + content: $span(content: ' - ')) + ..onClick.listen((event) => _canvasEditImage?.zoomOut()), + $nbsp(6), + $button(classes: btnClasses, style: btnStyle, content: 'OK') + ..onClick.listen((_) => hide()), + ]; + } + + /// The [editedImage] data URL. + String? get editedImageDataURL => _canvasEditImage?.imageDataURL; + + /// The edited image. + /// See [editedImageDataURL]. + ImageElement? get editedImage { + var imageDataURL = editedImageDataURL; + if (imageDataURL == null || !imageDataURL.startsWith('data:')) return null; + + //print(imageDataURL); + //downloadDataURL(DataURLBase64.parse(imageDataURL)!, 'edited-image.jpeg'); + + return ImageElement(src: imageDataURL); + } +} + +class _CanvasEditImage extends ExternalElementNode { + final CanvasImageSource img; + final int imgNaturalWidth; + final int imgNaturalHeight; + final int marginHorizontal; + final int marginVertical; + + late final TrackElementResize _elementResize; + + _CanvasEditImage(this.img, this.imgNaturalWidth, this.imgNaturalHeight, + this.marginHorizontal, this.marginVertical) + : super(_buildCanvas(img, imgNaturalWidth, imgNaturalHeight, + marginHorizontal, marginVertical)) { + _elementResize = TrackElementResize(); + + _resetZoom(); + render(); + + var canvas = this.canvas; + + _elementResize.track(canvas, (_) => _onResize(false)); + window.onResize.listen((_) => _onResize(true)); + + canvas.onMouseDown.listen((evt) => _onMouseDown1(evt.offset)); + canvas.onMouseMove.listen((evt) => _onMouseMove(evt.offset)); + canvas.onMouseUp.listen((evt) => _onMouseUp()); + canvas.onMouseLeave.listen((evt) => _onMouseUp()); + + canvas.onTouchStart.listen((evt) => _pointHandler(evt, _onMouseDown1)); + canvas.onTouchMove.listen((evt) => _pointHandler(evt, _onMouseMove)); + canvas.onTouchEnd.listen((evt) => _onMouseUp()); + canvas.onTouchLeave.listen((evt) => _onMouseUp()); + } + + int innerWidth = math.max(1, window.innerWidth ?? 1); + + int innerHeight = math.max(1, window.innerHeight ?? 1); + + void _onResize(bool windowResize) { + var innerWidth = window.innerWidth ?? 0; + var innerHeight = window.innerHeight ?? 0; + + if (innerWidth < 1 || innerHeight < 1) return; + + var wR = innerWidth / this.innerWidth; + var hR = innerHeight / this.innerHeight; + + var wRd = (1 - wR).abs(); + var hRd = (1 - hR).abs(); + + var r = math.max(wRd, hRd); + + //print('!!! _onResize[$window]> $wR x $hR'); + + _updateCanvasDimension(); + + if (r > 0.15) { + _resetZoom(); + } + } + + static void _pointHandler( + TouchEvent event, void Function(Point p1, [Point? p2]) f) { + var canvasTouches = + event.touches?.where((t) => t.target is CanvasElement).toList() ?? []; + + if (canvasTouches.isEmpty) { + return; + } else if (canvasTouches.length == 1) { + event.preventDefault(); + + var point1 = canvasTouches[0].client; + //print('!!! touch> $event > $point1 >> ${event.touches} > ${event.touches?.map((e) => e.target)} '); + + f(point1); + } else if (canvasTouches.length == 2) { + event.preventDefault(); + + var point1 = canvasTouches[0].client; + var point2 = canvasTouches[1].client; + //print('!!! touch> $event > $point1 & $point2 >> ${event.touches} > ${event.touches?.map((e) => e.target)} '); + + f(point1, point2); + } + } + + Point? _translateStart; + double? _zoomStart; + Point? _moveStart1; + Point? _moveStart2; + bool _moveScaling = false; + + void _onMouseDown1(Point point1, [Point? point2]) { + _translateStart = translate ?? Point(0, 0); + _zoomStart = zoom; + _moveStart1 = point1; + _moveStart2 = point2; + _moveScaling = point2 != null; + _showGrid = true; + } + + void _onMouseMove(Point point1, [Point? point2]) { + //print('!!! _onMouseMove> $point1'); + + var translateStart = _translateStart; + var zoomStart = _zoomStart; + var moveStart1 = _moveStart1; + var moveStart2 = _moveStart2; + var moveScaling = _moveScaling; + + if (moveStart1 == null || translateStart == null || zoomStart == null) { + return; + } + + if (moveScaling) { + if (moveStart2 == null) { + throw StateError("`moveScaling`: null `moveStart2`"); + } + if (point2 == null) return; + + var startDistance = moveStart1.distanceTo(moveStart2); + var pointDistance = point1.distanceTo(point2); + + var zoomRatio = pointDistance / startDistance; + var zoom2 = zoomStart * zoomRatio; + //print('!!! zoomRatio> $zoomStart * $zoomRatio = $zoom2'); + + zoom = zoom2; + } else { + var tx = point1.x - moveStart1.x; + var ty = point1.y - moveStart1.y; + + var x = translateStart.x + tx; + var y = translateStart.y + ty; + + translate = Point(x, y); + } + } + + void _onMouseUp() { + _translateStart = null; + _zoomStart = null; + _moveStart1 = null; + _moveStart2 = null; + _moveScaling = false; + _showGrid = false; + requestRender(); + } + + static CanvasElement _buildCanvas(CanvasImageSource img, int imgNaturalWidth, + int imgNaturalHeight, int marginHorizontal, int marginVertical) { + var d = _calcCanvasDimension( + imgNaturalWidth, imgNaturalHeight, marginHorizontal, marginVertical); + var w = d[0]; + var h = d[1]; + + return CanvasElement(width: w, height: h) + ..style.boxShadow = '0px 1px 18px 5px rgba(0, 0, 0, 0.65)' + ..style.borderRadius = '12px'; + } + + static double _calcZoom(int imgNaturalWidth, int imgNaturalHeight, + int canvasWidth, int canvasHeight) { + var zoomW = + imgNaturalWidth <= canvasWidth ? 1.0 : canvasWidth / imgNaturalWidth; + var zoomH = imgNaturalHeight <= canvasHeight + ? 1.0 + : canvasHeight / imgNaturalHeight; + var zoom = math.min(zoomW, zoomH); + return zoom; + } + + static List _calcCanvasDimension(int imgNaturalWidth, + int imgNaturalHeight, int marginHorizontal, int marginVertical) { + var innerWidth = window.innerWidth! - (marginHorizontal * 2); + var innerHeight = window.innerHeight! - (marginVertical * 2); + + var zoom = + _calcZoom(imgNaturalWidth, imgNaturalHeight, innerWidth, innerHeight); + + var imgW = (imgNaturalWidth * zoom).toInt(); + var imgH = (imgNaturalHeight * zoom).toInt(); + + var w = math.min(imgW, innerWidth); + var h = math.min(imgH, innerHeight); + + return [w, h]; + } + + void _updateCanvasDimension() { + var d = _calcCanvasDimension( + imgNaturalWidth, imgNaturalHeight, marginHorizontal, marginVertical); + var w = d[0]; + var h = d[1]; + + canvas + ..width = w + ..height = h; + + //print('!!! canvas dimension> $w x $h'); + + requestRender(); + } + + double _fitZoom = 1.0; + + double _zoom = 1.0; + + void _resetZoom() { + _fitZoom = _zoom = + _calcZoom(imgNaturalWidth, imgNaturalHeight, canvasWidth, canvasHeight); + + var t = _translate; + if (t != null) { + translate = Point(t.x + 1, t.y); + } + + requestRender(); + } + + double get zoom => _zoom; + + set zoom(double zoom) { + if (zoom > 0.99 && zoom < 1.01) { + zoom = 1.0; + } else if (zoom <= 0.01) { + zoom = 0.1; + } + if (_zoom == zoom) return; + + var prevZoom = _zoom; + var prevTranslate = _translate; + + if (zoom < _fitZoom) { + zoom = _fitZoom; + } + + _zoom = zoom; + + if (prevTranslate != null) { + var r = zoom / prevZoom; + translate = Point(prevTranslate.x * r, prevTranslate.y * r); + } + + requestRender(); + } + + void zoomIn([double amount = 0.02]) => zoom += amount; + + void zoomOut([double amount = 0.02]) => zoom -= amount; + + Point? _translate; + + Point? get translate => _translate; + + set translate(Point? translate) { + if (_translate == translate) return; + + if (translate == null) { + _translate = null; + return; + } + + var marginW = (renderWidth - canvasWidth) ~/ 2; + var marginH = (renderHeight - canvasHeight) ~/ 2; + + if (translate.x > marginW) { + translate = Point(marginW, translate.y); + } else if (translate.x < -marginW) { + translate = Point(-marginW, translate.y); + } + + if (translate.y > marginH) { + translate = Point(translate.x, marginH); + } else if (translate.y < -marginH) { + translate = Point(translate.x, -marginH); + } + + _translate = translate; + requestRender(); + } + + CanvasElement get canvas => externalElement as CanvasElement; + + int get renderWidth => (imgNaturalWidth * _zoom).toInt(); + + int get renderHeight => (imgNaturalHeight * _zoom).toInt(); + + int get renderX { + var x = (canvasWidth - renderWidth) ~/ 2; + var t = _translate; + return t != null ? x + t.x.toInt() : x; + } + + int get renderY { + var y = (canvasHeight - renderHeight) ~/ 2; + var t = _translate; + return t != null ? y + t.y.toInt() : y; + } + + int get canvasWidth => canvas.width!; + + int get canvasHeight => canvas.height!; + + Future? _rendering; + + void requestRender() { + if (_rendering != null) return; + _rendering = Future.microtask(render); + } + + bool _showGrid = false; + + void render() { + var canvas = this.canvas; + var context2d = canvas.context2D; + + context2d.clearRect(0, 0, canvasWidth, canvasHeight); + context2d.drawImageScaled(img, renderX, renderY, renderWidth, renderHeight); + + if (_showGrid) { + var wDiv4 = canvasWidth ~/ 3; + var hDiv4 = canvasHeight ~/ 3; + + context2d.fillStyle = 'rgba(0,0,0, 0.20)'; + + context2d.fillRect(wDiv4, 0, 2, canvasHeight); + context2d.fillRect(canvasWidth - wDiv4, 0, 2, canvasHeight); + + context2d.fillRect(0, hDiv4, canvasWidth, 2); + context2d.fillRect(0, canvasHeight - hDiv4, canvasWidth, 2); + } + + //print('!!! render[zoom: $zoom${translate != null ? ' ; translate: $translate' : ''}]> $imgX , $imgY ; $imgW x $imgH'); + + _rendering = null; + } + + String get imageDataURL { + var t = _translate; + var zoom = _zoom; + + final zoomFitReverse = 1 / _fitZoom; + + zoom = zoom * zoomFitReverse; + if (t != null) { + t = Point((t.x * zoomFitReverse).toInt(), (t.y * zoomFitReverse).toInt()); + } + + var canvasWidth = imgNaturalWidth; + var canvasHeight = imgNaturalHeight; + + var canvas = CanvasElement(width: canvasWidth, height: canvasHeight); + + var imgW = (imgNaturalWidth * zoom).toInt(); + var imgH = (imgNaturalHeight * zoom).toInt(); + + var x = (canvasWidth - imgW) ~/ 2; + var y = (canvasHeight - imgH) ~/ 2; + + if (t != null) { + x = x + t.x.toInt(); + y = y + t.y.toInt(); + } + + var context2d = canvas.context2D; + context2d.imageSmoothingEnabled = true; + context2d.imageSmoothingQuality = 'high'; + + context2d.drawImageScaled(img, x, y, imgW, imgH); + + return canvas.toDataUrl('image/jpeg', 0.98); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 72de8c3..6149998 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: bones_ui description: Bones_UI - Simple and easy Web User Interface Framework for Dart -version: 2.1.22 +version: 2.1.23 homepage: https://github.com/Colossus-Services/bones_ui environment: @@ -15,13 +15,13 @@ dependencies: swiss_knife: ^3.1.5 dynamic_call: ^2.0.1 mercury_client: ^2.1.8 - intl_messages: ^2.1.4 + intl_messages: ^2.1.5 dom_tools: ^2.1.15 dom_builder: ^2.1.7 json_render: ^2.0.5 json_object_mapper: ^2.0.1 html_unescape: ^2.0.0 - intl: ^0.18.0 + intl: ^0.18.1 enum_to_string: ^2.0.1 yaml: ^3.1.2 archive: ^3.3.7