diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift index e01f41a0c..2add9675b 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift @@ -43,6 +43,12 @@ public class ImageEditorItem: NSObject { super.init() } + + public typealias PointConversionFunction = (CGPoint) -> CGPoint + + public func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem { + return ImageEditorItem(itemId: itemId, itemType: itemType) + } } // MARK: - @@ -110,6 +116,17 @@ public class ImageEditorStrokeItem: ImageEditorItem { dstSize: CGSize) -> CGFloat { return CGFloatClamp01(unitStrokeWidth) * min(dstSize.width, dstSize.height) } + + public override func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem { + // TODO: We might want to convert the unitStrokeWidth too. + let convertedUnitSamples = unitSamples.map { (sample) in + conversion(sample) + } + return ImageEditorStrokeItem(itemId: itemId, + color: color, + unitSamples: convertedUnitSamples, + unitStrokeWidth: unitStrokeWidth) + } } // MARK: - @@ -222,6 +239,12 @@ public class OrderedDictionary: NSObject { // as immutable, once configured. public class ImageEditorContents: NSObject { + @objc + public let imagePath: String + + @objc + public let imageSizePixels: CGSize + public typealias ItemMapType = OrderedDictionary // This represents the current state of each item, @@ -229,18 +252,27 @@ public class ImageEditorContents: NSObject { var itemMap = ItemMapType() // Used to create an initial, empty instances of this class. - public override init() { + public init(imagePath: String, + imageSizePixels: CGSize) { + self.imagePath = imagePath + self.imageSizePixels = imageSizePixels } // Used to clone copies of instances of this class. - public init(itemMap: ItemMapType) { + public init(imagePath: String, + imageSizePixels: CGSize, + itemMap: ItemMapType) { + self.imagePath = imagePath + self.imageSizePixels = imageSizePixels self.itemMap = itemMap } // Since the contents are immutable, we only modify copies // made with this method. public func clone() -> ImageEditorContents { - return ImageEditorContents(itemMap: itemMap.clone()) + return ImageEditorContents(imagePath: imagePath, + imageSizePixels: imageSizePixels, + itemMap: itemMap.clone()) } @objc @@ -308,7 +340,8 @@ private class ImageEditorOperation: NSObject { @objc public protocol ImageEditorModelDelegate: class { - func imageEditorModelDidChange() + func imageEditorModelDidChange(before: ImageEditorContents, + after: ImageEditorContents) func imageEditorModelDidChange(changedItemIds: [String]) } @@ -325,7 +358,7 @@ public class ImageEditorModel: NSObject { @objc public let srcImageSizePixels: CGSize - private var contents = ImageEditorContents() + private var contents: ImageEditorContents private var undoStack = [ImageEditorOperation]() private var redoStack = [ImageEditorOperation]() @@ -357,9 +390,17 @@ public class ImageEditorModel: NSObject { } self.srcImageSizePixels = srcImageSizePixels + self.contents = ImageEditorContents(imagePath: srcImagePath, + imageSizePixels: srcImageSizePixels) + super.init() } + @objc + public var currentImagePath: String { + return contents.imagePath + } + @objc public func itemCount() -> Int { return contents.itemCount() @@ -395,10 +436,12 @@ public class ImageEditorModel: NSObject { let redoOperation = ImageEditorOperation(contents: contents) redoStack.append(redoOperation) + let oldContents = self.contents self.contents = undoOperation.contents // We could diff here and yield a more narrow change event. - delegate?.imageEditorModelDidChange() + delegate?.imageEditorModelDidChange(before: oldContents, + after: self.contents) } @objc @@ -411,37 +454,98 @@ public class ImageEditorModel: NSObject { let undoOperation = ImageEditorOperation(contents: contents) undoStack.append(undoOperation) + let oldContents = self.contents self.contents = redoOperation.contents // We could diff here and yield a more narrow change event. - delegate?.imageEditorModelDidChange() + delegate?.imageEditorModelDidChange(before: oldContents, + after: self.contents) } @objc public func append(item: ImageEditorItem) { - performAction({ (newContents) in + performAction({ (oldContents) in + let newContents = oldContents.clone() newContents.append(item: item) + return newContents }, changedItemIds: [item.itemId]) } @objc public func replace(item: ImageEditorItem, suppressUndo: Bool = false) { - performAction({ (newContents) in + performAction({ (oldContents) in + let newContents = oldContents.clone() newContents.replace(item: item) + return newContents }, changedItemIds: [item.itemId], suppressUndo: suppressUndo) } @objc public func remove(item: ImageEditorItem) { - performAction({ (newContents) in + performAction({ (oldContents) in + let newContents = oldContents.clone() newContents.remove(item: item) + return newContents }, changedItemIds: [item.itemId]) } - private func performAction(_ action: (ImageEditorContents) -> Void, - changedItemIds: [String], + @objc + public func crop(unitCropRect: CGRect) { + guard let croppedImage = ImageEditorModel.crop(imagePath: contents.imagePath, + unitCropRect: unitCropRect) else { + owsFailDebug("Could not crop image.") + return + } + // Use PNG for temp files; PNG is lossless. + guard let croppedImageData = UIImagePNGRepresentation(croppedImage) else { + owsFailDebug("Could not convert cropped image to PNG.") + return + } + let croppedImagePath = OWSFileSystem.temporaryFilePath(withFileExtension: "png") + do { + try croppedImageData.write(to: NSURL.fileURL(withPath: croppedImagePath), options: .atomicWrite) + } catch let error as NSError { + owsFailDebug("File write failed: \(error)") + return + } + let croppedImageSizePixels = CGSizeScale(croppedImage.size, croppedImage.scale) + + let left = unitCropRect.origin.x + let right = unitCropRect.origin.x + unitCropRect.size.width + let top = unitCropRect.origin.y + let bottom = unitCropRect.origin.y + unitCropRect.size.height + let conversion: ImageEditorItem.PointConversionFunction = { (point) in + // Convert from the pre-crop unit coordinate system + // to post-crop unit coordinate system using inverse + // lerp. + // + // NOTE: Some post-conversion unit values will _NOT_ + // be clamped. e.g. strokes outside the crop + // are that < 0 or > 1. This is fine. + // We could hypothethically discard any items + // whose bounding box is entirely outside the + // new unit rectangle (e.g. have been completely + // cropped) but it doesn't seem worthwhile. + let converted = CGPoint(x: CGFloatInverseLerp(point.x, left, right), + y: CGFloatInverseLerp(point.y, top, bottom)) + return converted + } + + performAction({ (oldContents) in + let newContents = ImageEditorContents(imagePath: croppedImagePath, + imageSizePixels: croppedImageSizePixels) + for oldItem in oldContents.items() { + let newItem = oldItem.clone(withPointConversionFunction: conversion) + newContents.append(item: newItem) + } + return newContents + }, changedItemIds: nil) + } + + private func performAction(_ action: (ImageEditorContents) -> ImageEditorContents, + changedItemIds: [String]?, suppressUndo: Bool = false) { if !suppressUndo { let undoOperation = ImageEditorOperation(contents: contents) @@ -449,10 +553,69 @@ public class ImageEditorModel: NSObject { redoStack.removeAll() } - let newContents = contents.clone() - action(newContents) + let oldContents = self.contents + let newContents = action(oldContents) contents = newContents - delegate?.imageEditorModelDidChange(changedItemIds: changedItemIds) + if let changedItemIds = changedItemIds { + delegate?.imageEditorModelDidChange(changedItemIds: changedItemIds) + } else { + delegate?.imageEditorModelDidChange(before: oldContents, + after: self.contents) + } + } + + // MARK: - Utilities + + // Returns nil on error. + private class func crop(imagePath: String, + unitCropRect: CGRect) -> UIImage? { + // TODO: Do we want to render off the main thread? + AssertIsOnMainThread() + + guard let srcImage = UIImage(contentsOfFile: imagePath) else { + owsFailDebug("Could not load image") + return nil + } + let srcImageSize = srcImage.size + // Convert from unit coordinates to src image coordinates. + let cropRect = CGRect(x: unitCropRect.origin.x * srcImageSize.width, + y: unitCropRect.origin.y * srcImageSize.height, + width: unitCropRect.size.width * srcImageSize.width, + height: unitCropRect.size.height * srcImageSize.height) + + guard cropRect.origin.x >= 0, + cropRect.origin.y >= 0, + cropRect.origin.x + cropRect.size.width <= srcImageSize.width, + cropRect.origin.y + cropRect.size.height <= srcImageSize.height else { + owsFailDebug("Invalid crop rectangle.") + return nil + } + guard cropRect.size.width > 0, + cropRect.size.height > 0 else { + owsFailDebug("Empty crop rectangle.") + return nil + } + + let hasAlpha = NSData.hasAlpha(forValidImageFilePath: imagePath) + + UIGraphicsBeginImageContextWithOptions(cropRect.size, !hasAlpha, srcImage.scale) + defer { UIGraphicsEndImageContext() } + + guard let context = UIGraphicsGetCurrentContext() else { + owsFailDebug("Could not create output context.") + return nil + } + context.interpolationQuality = .high + + // Draw source image. + let dstFrame = CGRect(origin: CGPointInvert(cropRect.origin), size: srcImageSize) + srcImage.draw(in: dstFrame) + + let dstImage = UIGraphicsGetImageFromCurrentImageContext() + if dstImage == nil { + owsFailDebug("could not generate dst image.") + } + return dstImage } } diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift index 4f09e5003..6cca0cf63 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift @@ -33,28 +33,22 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { // MARK: - Views - private var imageView: UIImageView? - private let layersView = UIView() + private let imageView = UIImageView() + private var imageViewConstraints = [NSLayoutConstraint]() + private let layersView = OWSLayerView() @objc public func createImageView() -> Bool { - guard let image = UIImage(contentsOfFile: model.srcImagePath) else { - // TODO: - owsFailDebug("Could not load image") - return false - } - guard image.size.width > 0 && image.size.height > 0 else { - // TODO: - owsFailDebug("Could not load image") + self.addSubview(imageView) + + guard updateImageView() else { return false } - let imageView = UIImageView(image: image) - imageView.layer.minificationFilter = kCAFilterTrilinear - imageView.layer.magnificationFilter = kCAFilterTrilinear - let aspectRatio = image.size.width / image.size.height - addSubviewWithScaleAspectFitLayout(view: imageView, aspectRatio: aspectRatio) - + layersView.clipsToBounds = true + layersView.layoutCallback = { [weak self] (_) in + self?.updateAllContent() + } self.addSubview(layersView) layersView.autoPin(toEdgesOf: imageView) @@ -63,23 +57,46 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { let anyTouchGesture = ImageEditorGestureRecognizer(target: self, action: #selector(handleTouchGesture(_:))) layersView.addGestureRecognizer(anyTouchGesture) - self.imageView = imageView - return true } - private func addSubviewWithScaleAspectFitLayout(view: UIView, aspectRatio: CGFloat) { - self.addSubview(view) + @objc + public func updateImageView() -> Bool { + Logger.verbose("") + + guard let image = UIImage(contentsOfFile: model.currentImagePath) else { + owsFailDebug("Could not load image") + return false + } + guard image.size.width > 0 && image.size.height > 0 else { + owsFailDebug("Could not load image") + return false + } + + imageView.image = image + imageView.layer.minificationFilter = kCAFilterTrilinear + imageView.layer.magnificationFilter = kCAFilterTrilinear + let aspectRatio = image.size.width / image.size.height + for constraint in imageViewConstraints { + constraint.autoRemove() + } + imageViewConstraints = applyScaleAspectFitLayout(view: imageView, aspectRatio: aspectRatio) + return true + } + + private func applyScaleAspectFitLayout(view: UIView, aspectRatio: CGFloat) -> [NSLayoutConstraint] { // This emulates the behavior of contentMode = .scaleAspectFit using // iOS auto layout constraints. // // This allows ConversationInputToolbar to place the "cancel" button // in the upper-right hand corner of the preview content. - view.autoCenterInSuperview() - view.autoPin(toAspectRatio: aspectRatio) - view.autoMatch(.width, to: .width, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual) - view.autoMatch(.height, to: .height, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual) + var constraints = [NSLayoutConstraint]() + constraints.append(contentsOf: view.autoCenterInSuperview()) + constraints.append(view.autoPin(toAspectRatio: aspectRatio)) + constraints.append(view.autoMatch(.width, to: .width, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)) + constraints.append(view.autoMatch(.height, to: .height, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)) + return constraints } private let undoButton = UIButton(type: .custom) @@ -321,6 +338,16 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { cropLayer.removeFromSuperlayer() cropLayer.removeAllAnimations() } + + // Find the upper-left and bottom-right corners of the + // crop rectangle, in unit coordinates. + let unitMin = CGPointClamp01(CGPointMin(self.cropStartUnit, self.cropEndUnit)) + let unitMax = CGPointClamp01(CGPointMax(self.cropStartUnit, self.cropEndUnit)) + let unitCropRect = CGRect(x: unitMin.x, + y: unitMin.y, + width: unitMax.x - unitMin.x, + height: unitMax.y - unitMin.y) + self.model.crop(unitCropRect: unitCropRect) } switch gestureRecognizer.state { @@ -343,7 +370,13 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { // MARK: - ImageEditorModelDelegate - public func imageEditorModelDidChange() { + public func imageEditorModelDidChange(before: ImageEditorContents, + after: ImageEditorContents) { + + if before.imagePath != after.imagePath { + _ = updateImageView() + } + updateAllContent() updateButtons() @@ -393,8 +426,9 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { bounds.height > 0 { for item in model.items() { + let viewSize = layersView.bounds.size guard let layer = ImageEditorView.layerForItem(item: item, - viewSize: bounds.size) else { + viewSize: viewSize) else { continue } @@ -467,7 +501,7 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { viewSize: CGSize) -> CALayer? { AssertIsOnMainThread() - Logger.verbose("\(item.itemId)") + Logger.verbose("\(item.itemId), viewSize: \(viewSize)") let strokeWidth = ImageEditorStrokeItem.strokeWidth(forUnitStrokeWidth: item.unitStrokeWidth, dstSize: viewSize) @@ -591,9 +625,9 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { // Render output at same size as source image. let dstSizePixels = model.srcImageSizePixels - let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.srcImagePath) + let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.currentImagePath) - guard let srcImage = UIImage(contentsOfFile: model.srcImagePath) else { + guard let srcImage = UIImage(contentsOfFile: model.currentImagePath) else { owsFailDebug("Could not load src image.") return nil } diff --git a/SignalMessaging/Views/OWSLayerView.swift b/SignalMessaging/Views/OWSLayerView.swift index 7f26a6aaa..b62b7ec19 100644 --- a/SignalMessaging/Views/OWSLayerView.swift +++ b/SignalMessaging/Views/OWSLayerView.swift @@ -6,10 +6,17 @@ import Foundation @objc public class OWSLayerView: UIView { - let layoutCallback: ((UIView) -> Void) + public var layoutCallback: ((UIView) -> Void) @objc - public required init(frame: CGRect, layoutCallback : @escaping (UIView) -> Void) { + public init() { + self.layoutCallback = { (_) in + } + super.init(frame: .zero) + } + + @objc + public init(frame: CGRect, layoutCallback : @escaping (UIView) -> Void) { self.layoutCallback = layoutCallback super.init(frame: frame) } diff --git a/SignalMessaging/categories/UIView+OWS.h b/SignalMessaging/categories/UIView+OWS.h index 0d397644e..e109c78db 100644 --- a/SignalMessaging/categories/UIView+OWS.h +++ b/SignalMessaging/categories/UIView+OWS.h @@ -2,6 +2,7 @@ // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // +#import "OWSMath.h" #import #import @@ -153,75 +154,6 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value); #pragma mark - Macros -CG_INLINE CGSize CGSizeCeil(CGSize size) -{ - return CGSizeMake((CGFloat)ceil(size.width), (CGFloat)ceil(size.height)); -} - -CG_INLINE CGSize CGSizeFloor(CGSize size) -{ - return CGSizeMake((CGFloat)floor(size.width), (CGFloat)floor(size.height)); -} - -CG_INLINE CGSize CGSizeRound(CGSize size) -{ - return CGSizeMake((CGFloat)round(size.width), (CGFloat)round(size.height)); -} - -CG_INLINE CGSize CGSizeMax(CGSize size1, CGSize size2) -{ - return CGSizeMake(MAX(size1.width, size2.width), MAX(size1.height, size2.height)); -} - -CG_INLINE CGPoint CGPointAdd(CGPoint left, CGPoint right) -{ - return CGPointMake(left.x + right.x, left.y + right.y); -} - -CG_INLINE CGPoint CGPointSubtract(CGPoint left, CGPoint right) -{ - return CGPointMake(left.x - right.x, left.y - right.y); -} - -CG_INLINE CGPoint CGPointScale(CGPoint point, CGFloat factor) -{ - return CGPointMake(point.x * factor, point.y * factor); -} - -CG_INLINE CGFloat CGPointDistance(CGPoint left, CGPoint right) -{ - CGPoint delta = CGPointSubtract(left, right); - return sqrt(delta.x * delta.x + delta.y * delta.y); -} - -CG_INLINE CGPoint CGPointMin(CGPoint left, CGPoint right) -{ - return CGPointMake(MIN(left.x, right.x), MIN(left.y, right.y)); -} - -CG_INLINE CGPoint CGPointMax(CGPoint left, CGPoint right) -{ - return CGPointMake(MAX(left.x, right.x), MAX(left.y, right.y)); -} - -CG_INLINE CGSize CGSizeScale(CGSize size, CGFloat factor) -{ - return CGSizeMake(size.width * factor, size.height * factor); -} - -CG_INLINE CGSize CGSizeAdd(CGSize left, CGSize right) -{ - return CGSizeMake(left.width + right.width, left.height + right.height); -} - -CG_INLINE CGRect CGRectScale(CGRect rect, CGFloat factor) -{ - CGRect result; - result.origin = CGPointScale(rect.origin, factor); - result.size = CGSizeScale(rect.size, factor); - return result; -} - CGFloat CGHairlineWidth(void); NS_ASSUME_NONNULL_END diff --git a/SignalMessaging/utils/OWSMath.h b/SignalMessaging/utils/OWSMath.h index ea8963d88..6bdfaa617 100644 --- a/SignalMessaging/utils/OWSMath.h +++ b/SignalMessaging/utils/OWSMath.h @@ -5,32 +5,111 @@ NS_ASSUME_NONNULL_BEGIN // TODO: We'll eventually want to promote these into an OWSMath.h header. -static inline CGFloat CGFloatClamp(CGFloat value, CGFloat minValue, CGFloat maxValue) +CG_INLINE CGFloat CGFloatClamp(CGFloat value, CGFloat minValue, CGFloat maxValue) { return MAX(minValue, MIN(maxValue, value)); } -static inline CGFloat CGFloatClamp01(CGFloat value) +CG_INLINE CGFloat CGFloatClamp01(CGFloat value) { return CGFloatClamp(value, 0.f, 1.f); } -static inline CGFloat CGFloatLerp(CGFloat left, CGFloat right, CGFloat alpha) +CG_INLINE CGFloat CGFloatLerp(CGFloat left, CGFloat right, CGFloat alpha) { alpha = CGFloatClamp01(alpha); return (left * (1.f - alpha)) + (right * alpha); } -static inline CGFloat CGFloatInverseLerp(CGFloat value, CGFloat minValue, CGFloat maxValue) +CG_INLINE CGFloat CGFloatInverseLerp(CGFloat value, CGFloat minValue, CGFloat maxValue) { return (value - minValue) / (maxValue - minValue); } // Ceil to an even number -static inline CGFloat CeilEven(CGFloat value) +CG_INLINE CGFloat CeilEven(CGFloat value) { return 2.f * (CGFloat)ceil(value * 0.5f); } +CG_INLINE CGSize CGSizeCeil(CGSize size) +{ + return CGSizeMake((CGFloat)ceil(size.width), (CGFloat)ceil(size.height)); +} + +CG_INLINE CGSize CGSizeFloor(CGSize size) +{ + return CGSizeMake((CGFloat)floor(size.width), (CGFloat)floor(size.height)); +} + +CG_INLINE CGSize CGSizeRound(CGSize size) +{ + return CGSizeMake((CGFloat)round(size.width), (CGFloat)round(size.height)); +} + +CG_INLINE CGSize CGSizeMax(CGSize size1, CGSize size2) +{ + return CGSizeMake(MAX(size1.width, size2.width), MAX(size1.height, size2.height)); +} + +CG_INLINE CGPoint CGPointAdd(CGPoint left, CGPoint right) +{ + return CGPointMake(left.x + right.x, left.y + right.y); +} + +CG_INLINE CGPoint CGPointSubtract(CGPoint left, CGPoint right) +{ + return CGPointMake(left.x - right.x, left.y - right.y); +} + +CG_INLINE CGPoint CGPointScale(CGPoint point, CGFloat factor) +{ + return CGPointMake(point.x * factor, point.y * factor); +} + +CG_INLINE CGFloat CGPointDistance(CGPoint left, CGPoint right) +{ + CGPoint delta = CGPointSubtract(left, right); + return sqrt(delta.x * delta.x + delta.y * delta.y); +} + +CG_INLINE CGPoint CGPointMin(CGPoint left, CGPoint right) +{ + return CGPointMake(MIN(left.x, right.x), MIN(left.y, right.y)); +} + +CG_INLINE CGPoint CGPointMax(CGPoint left, CGPoint right) +{ + return CGPointMake(MAX(left.x, right.x), MAX(left.y, right.y)); +} + +CG_INLINE CGPoint CGPointClamp01(CGPoint point) +{ + return CGPointMake(CGFloatClamp01(point.x), CGFloatClamp01(point.y)); +} + +CG_INLINE CGPoint CGPointInvert(CGPoint point) +{ + return CGPointMake(-point.x, -point.y); +} + +CG_INLINE CGSize CGSizeScale(CGSize size, CGFloat factor) +{ + return CGSizeMake(size.width * factor, size.height * factor); +} + +CG_INLINE CGSize CGSizeAdd(CGSize left, CGSize right) +{ + return CGSizeMake(left.width + right.width, left.height + right.height); +} + +CG_INLINE CGRect CGRectScale(CGRect rect, CGFloat factor) +{ + CGRect result; + result.origin = CGPointScale(rect.origin, factor); + result.size = CGSizeScale(rect.size, factor); + return result; +} + NS_ASSUME_NONNULL_END