mirror of https://github.com/oxen-io/session-ios
Merge branch 'charlesmchen/imageEditor'
commit
98137e9ddf
@ -0,0 +1,111 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Signal
|
||||
@testable import SignalMessaging
|
||||
|
||||
extension ImageEditorModel {
|
||||
func itemIds() -> [String] {
|
||||
return items().map { (item) in
|
||||
item.itemId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ImageEditorTest: SignalBaseTest {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testImageEditorContents() {
|
||||
let contents = ImageEditorContents()
|
||||
XCTAssertEqual(0, contents.itemMap.count)
|
||||
|
||||
let item = ImageEditorItem(itemType: .test)
|
||||
contents.append(item: item)
|
||||
XCTAssertEqual(1, contents.itemMap.count)
|
||||
|
||||
let contentsCopy = contents.clone()
|
||||
XCTAssertEqual(1, contents.itemMap.count)
|
||||
XCTAssertEqual(1, contentsCopy.itemMap.count)
|
||||
|
||||
contentsCopy.remove(item: item)
|
||||
XCTAssertEqual(1, contents.itemMap.count)
|
||||
XCTAssertEqual(0, contentsCopy.itemMap.count)
|
||||
|
||||
let modifiedItem = ImageEditorItem(itemId: item.itemId, itemType: item.itemType)
|
||||
contents.replace(item: modifiedItem)
|
||||
XCTAssertEqual(1, contents.itemMap.count)
|
||||
XCTAssertEqual(0, contentsCopy.itemMap.count)
|
||||
}
|
||||
|
||||
private func writeDummyImage() -> String {
|
||||
let image = UIImage.init(color: .red, size: CGSize(width: 1, height: 1))
|
||||
guard let data = UIImagePNGRepresentation(image) else {
|
||||
owsFail("Couldn't export dummy image.")
|
||||
}
|
||||
let filePath = OWSFileSystem.temporaryFilePath(withFileExtension: "png")
|
||||
try! data.write(to: URL(fileURLWithPath: filePath))
|
||||
return filePath
|
||||
}
|
||||
|
||||
func testImageEditor() {
|
||||
let imagePath = writeDummyImage()
|
||||
|
||||
let imageEditor = try! ImageEditorModel(srcImagePath: imagePath)
|
||||
XCTAssertFalse(imageEditor.canUndo())
|
||||
XCTAssertFalse(imageEditor.canRedo())
|
||||
XCTAssertEqual(0, imageEditor.itemCount())
|
||||
|
||||
let itemA = ImageEditorItem(itemType: .test)
|
||||
imageEditor.append(item: itemA)
|
||||
XCTAssertTrue(imageEditor.canUndo())
|
||||
XCTAssertFalse(imageEditor.canRedo())
|
||||
XCTAssertEqual(1, imageEditor.itemCount())
|
||||
XCTAssertEqual([itemA.itemId], imageEditor.itemIds())
|
||||
|
||||
imageEditor.undo()
|
||||
XCTAssertFalse(imageEditor.canUndo())
|
||||
XCTAssertTrue(imageEditor.canRedo())
|
||||
XCTAssertEqual(0, imageEditor.itemCount())
|
||||
|
||||
imageEditor.redo()
|
||||
XCTAssertTrue(imageEditor.canUndo())
|
||||
XCTAssertFalse(imageEditor.canRedo())
|
||||
XCTAssertEqual(1, imageEditor.itemCount())
|
||||
XCTAssertEqual([itemA.itemId], imageEditor.itemIds())
|
||||
|
||||
imageEditor.undo()
|
||||
XCTAssertFalse(imageEditor.canUndo())
|
||||
XCTAssertTrue(imageEditor.canRedo())
|
||||
XCTAssertEqual(0, imageEditor.itemCount())
|
||||
|
||||
let itemB = ImageEditorItem(itemType: .test)
|
||||
imageEditor.append(item: itemB)
|
||||
XCTAssertTrue(imageEditor.canUndo())
|
||||
XCTAssertFalse(imageEditor.canRedo())
|
||||
XCTAssertEqual(1, imageEditor.itemCount())
|
||||
XCTAssertEqual([itemB.itemId], imageEditor.itemIds())
|
||||
|
||||
let itemC = ImageEditorItem(itemType: .test)
|
||||
imageEditor.append(item: itemC)
|
||||
XCTAssertTrue(imageEditor.canUndo())
|
||||
XCTAssertFalse(imageEditor.canRedo())
|
||||
XCTAssertEqual(2, imageEditor.itemCount())
|
||||
XCTAssertEqual([itemB.itemId, itemC.itemId], imageEditor.itemIds())
|
||||
|
||||
imageEditor.undo()
|
||||
XCTAssertTrue(imageEditor.canUndo())
|
||||
XCTAssertTrue(imageEditor.canRedo())
|
||||
XCTAssertEqual(1, imageEditor.itemCount())
|
||||
XCTAssertEqual([itemB.itemId], imageEditor.itemIds())
|
||||
}
|
||||
}
|
@ -0,0 +1,179 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
||||
|
||||
@objc
|
||||
override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
override func canBePrevented(by: UIGestureRecognizer) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
override func shouldRequireFailure(of: UIGestureRecognizer) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
override func shouldBeRequiredToFail(by: UIGestureRecognizer) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Touch Handling
|
||||
|
||||
@objc
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
||||
if state == .possible,
|
||||
touchType(for: touches, with: event) == .valid {
|
||||
// If a gesture starts with a valid touch, begin stroke.
|
||||
state = .began
|
||||
} else {
|
||||
state = .failed
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesMoved(touches, with: event)
|
||||
|
||||
switch state {
|
||||
case .began, .changed:
|
||||
switch touchType(for: touches, with: event) {
|
||||
case .valid:
|
||||
// If a gesture continues with a valid touch, continue stroke.
|
||||
state = .changed
|
||||
case .invalid:
|
||||
state = .failed
|
||||
case .outside:
|
||||
// If a gesture continues with a valid touch _outside the canvas_,
|
||||
// end stroke.
|
||||
state = .ended
|
||||
}
|
||||
default:
|
||||
state = .failed
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
|
||||
switch state {
|
||||
case .began, .changed:
|
||||
switch touchType(for: touches, with: event) {
|
||||
case .valid, .outside:
|
||||
// If a gesture ends with a valid touch, end stroke.
|
||||
state = .ended
|
||||
case .invalid:
|
||||
state = .failed
|
||||
}
|
||||
default:
|
||||
state = .failed
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
|
||||
state = .cancelled
|
||||
}
|
||||
|
||||
public enum TouchType {
|
||||
case invalid
|
||||
case valid
|
||||
case outside
|
||||
}
|
||||
|
||||
private func touchType(for touches: Set<UITouch>, with event: UIEvent) -> TouchType {
|
||||
guard let view = self.view else {
|
||||
owsFailDebug("Missing view")
|
||||
return .invalid
|
||||
}
|
||||
guard let allTouches = event.allTouches else {
|
||||
owsFailDebug("Missing allTouches")
|
||||
return .invalid
|
||||
}
|
||||
guard allTouches.count <= 1 else {
|
||||
return .invalid
|
||||
}
|
||||
guard touches.count == 1 else {
|
||||
return .invalid
|
||||
}
|
||||
guard let firstTouch: UITouch = touches.first else {
|
||||
return .invalid
|
||||
}
|
||||
let location = firstTouch.location(in: view)
|
||||
|
||||
let isNewTouch = firstTouch.phase == .began
|
||||
if isNewTouch {
|
||||
// Reject new touches that are inside a control subview.
|
||||
if subviewControl(ofView: view, contains: firstTouch) {
|
||||
return .invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Reject new touches outside this GR's view's bounds.
|
||||
guard view.bounds.contains(location) else {
|
||||
return isNewTouch ? .invalid : .outside
|
||||
}
|
||||
|
||||
if isNewTouch {
|
||||
// Ignore touches that start near the top or bottom edge of the screen;
|
||||
// they may be a system edge swipe gesture.
|
||||
let rootView = self.rootView(of: view)
|
||||
let rootLocation = firstTouch.location(in: rootView)
|
||||
let distanceToTopEdge = max(0, rootLocation.y)
|
||||
let distanceToBottomEdge = max(0, rootView.bounds.size.height - rootLocation.y)
|
||||
let distanceToNearestEdge = min(distanceToTopEdge, distanceToBottomEdge)
|
||||
let kSystemEdgeSwipeTolerance: CGFloat = 50
|
||||
if (distanceToNearestEdge < kSystemEdgeSwipeTolerance) {
|
||||
return .invalid
|
||||
}
|
||||
}
|
||||
|
||||
return .valid
|
||||
}
|
||||
|
||||
private func subviewControl(ofView superview: UIView, contains touch: UITouch) -> Bool {
|
||||
for subview in superview.subviews {
|
||||
guard !subview.isHidden, subview.isUserInteractionEnabled else {
|
||||
continue
|
||||
}
|
||||
let location = touch.location(in: subview)
|
||||
guard subview.bounds.contains(location) else {
|
||||
continue
|
||||
}
|
||||
if subview as? UIControl != nil {
|
||||
return true
|
||||
}
|
||||
if subviewControl(ofView: subview, contains: touch) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func rootView(of view: UIView) -> UIView {
|
||||
var responder: UIResponder? = view
|
||||
var lastView: UIView = view
|
||||
while true {
|
||||
guard let currentResponder = responder else {
|
||||
return lastView
|
||||
}
|
||||
if let currentView = currentResponder as? UIView {
|
||||
lastView = currentView
|
||||
}
|
||||
responder = currentResponder.next
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,435 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc public enum ImageEditorError: Int, Error {
|
||||
case assertionError
|
||||
case invalidInput
|
||||
}
|
||||
|
||||
@objc
|
||||
public enum ImageEditorItemType: Int {
|
||||
case test
|
||||
case stroke
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// Instances of ImageEditorItem should be treated
|
||||
// as immutable, once configured.
|
||||
@objc
|
||||
public class ImageEditorItem: NSObject {
|
||||
@objc
|
||||
public let itemId: String
|
||||
|
||||
@objc
|
||||
public let itemType: ImageEditorItemType
|
||||
|
||||
@objc
|
||||
public init(itemType: ImageEditorItemType) {
|
||||
self.itemId = UUID().uuidString
|
||||
self.itemType = itemType
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
@objc
|
||||
public init(itemId: String,
|
||||
itemType: ImageEditorItemType) {
|
||||
self.itemId = itemId
|
||||
self.itemType = itemType
|
||||
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@objc
|
||||
public class ImageEditorStrokeItem: ImageEditorItem {
|
||||
// Until we need to serialize these items,
|
||||
// just use UIColor.
|
||||
@objc
|
||||
public let color: UIColor
|
||||
|
||||
// Represented in a "ULO unit" coordinate system
|
||||
// for source image.
|
||||
//
|
||||
// "ULO" coordinate system is "upper-left-origin".
|
||||
//
|
||||
// "Unit" coordinate system means values are expressed
|
||||
// in terms of some other values, in this case the
|
||||
// width and height of the source image.
|
||||
//
|
||||
// * 0.0 = left edge
|
||||
// * 1.0 = right edge
|
||||
// * 0.0 = top edge
|
||||
// * 1.0 = bottom edge
|
||||
public typealias StrokeSample = CGPoint
|
||||
|
||||
@objc
|
||||
public let unitSamples: [StrokeSample]
|
||||
|
||||
// Expressed as a "Unit" value as a fraction of
|
||||
// min(width, height) of the destination viewport.
|
||||
@objc
|
||||
public let unitStrokeWidth: CGFloat
|
||||
|
||||
@objc
|
||||
public init(color: UIColor,
|
||||
unitSamples: [StrokeSample],
|
||||
unitStrokeWidth: CGFloat) {
|
||||
self.color = color
|
||||
self.unitSamples = unitSamples
|
||||
self.unitStrokeWidth = unitStrokeWidth
|
||||
|
||||
super.init(itemType: .stroke)
|
||||
}
|
||||
|
||||
@objc
|
||||
public init(itemId: String,
|
||||
color: UIColor,
|
||||
unitSamples: [StrokeSample],
|
||||
unitStrokeWidth: CGFloat) {
|
||||
self.color = color
|
||||
self.unitSamples = unitSamples
|
||||
self.unitStrokeWidth = unitStrokeWidth
|
||||
|
||||
super.init(itemId: itemId, itemType: .stroke)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func defaultUnitStrokeWidth() -> CGFloat {
|
||||
return 0.02
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func strokeWidth(forUnitStrokeWidth unitStrokeWidth: CGFloat,
|
||||
dstSize: CGSize) -> CGFloat {
|
||||
return CGFloatClamp01(unitStrokeWidth) * min(dstSize.width, dstSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
public class OrderedDictionary<ValueType>: NSObject {
|
||||
|
||||
public typealias KeyType = String
|
||||
|
||||
var keyValueMap = [KeyType: ValueType]()
|
||||
|
||||
var orderedKeys = [KeyType]()
|
||||
|
||||
public override init() {
|
||||
}
|
||||
|
||||
// Used to clone copies of instances of this class.
|
||||
public init(keyValueMap: [KeyType: ValueType],
|
||||
orderedKeys: [KeyType]) {
|
||||
|
||||
self.keyValueMap = keyValueMap
|
||||
self.orderedKeys = orderedKeys
|
||||
}
|
||||
|
||||
// Since the contents are immutable, we only modify copies
|
||||
// made with this method.
|
||||
public func clone() -> OrderedDictionary<ValueType> {
|
||||
return OrderedDictionary(keyValueMap: keyValueMap, orderedKeys: orderedKeys)
|
||||
}
|
||||
|
||||
public func append(key: KeyType, value: ValueType) {
|
||||
if keyValueMap[key] != nil {
|
||||
owsFailDebug("Unexpected duplicate key in key map: \(key)")
|
||||
}
|
||||
keyValueMap[key] = value
|
||||
|
||||
if orderedKeys.contains(key) {
|
||||
owsFailDebug("Unexpected duplicate key in key list: \(key)")
|
||||
} else {
|
||||
orderedKeys.append(key)
|
||||
}
|
||||
|
||||
if orderedKeys.count != keyValueMap.count {
|
||||
owsFailDebug("Invalid contents.")
|
||||
}
|
||||
}
|
||||
|
||||
public func replace(key: KeyType, value: ValueType) {
|
||||
if keyValueMap[key] == nil {
|
||||
owsFailDebug("Missing key in key map: \(key)")
|
||||
}
|
||||
keyValueMap[key] = value
|
||||
|
||||
if !orderedKeys.contains(key) {
|
||||
owsFailDebug("Missing key in key list: \(key)")
|
||||
}
|
||||
|
||||
if orderedKeys.count != keyValueMap.count {
|
||||
owsFailDebug("Invalid contents.")
|
||||
}
|
||||
}
|
||||
|
||||
public func remove(key: KeyType) {
|
||||
if keyValueMap[key] == nil {
|
||||
owsFailDebug("Missing key in key map: \(key)")
|
||||
} else {
|
||||
keyValueMap.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
if !orderedKeys.contains(key) {
|
||||
owsFailDebug("Missing key in key list: \(key)")
|
||||
} else {
|
||||
orderedKeys = orderedKeys.filter { $0 != key }
|
||||
}
|
||||
|
||||
if orderedKeys.count != keyValueMap.count {
|
||||
owsFailDebug("Invalid contents.")
|
||||
}
|
||||
}
|
||||
|
||||
public var count: Int {
|
||||
if orderedKeys.count != keyValueMap.count {
|
||||
owsFailDebug("Invalid contents.")
|
||||
}
|
||||
return orderedKeys.count
|
||||
}
|
||||
|
||||
public func orderedValues() -> [ValueType] {
|
||||
var values = [ValueType]()
|
||||
for key in orderedKeys {
|
||||
guard let value = self.keyValueMap[key] else {
|
||||
owsFailDebug("Missing value")
|
||||
continue
|
||||
}
|
||||
values.append(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// ImageEditorContents represents a snapshot of canvas
|
||||
// state.
|
||||
//
|
||||
// Instances of ImageEditorContents should be treated
|
||||
// as immutable, once configured.
|
||||
public class ImageEditorContents: NSObject {
|
||||
|
||||
public typealias ItemMapType = OrderedDictionary<ImageEditorItem>
|
||||
|
||||
// This represents the current state of each item,
|
||||
// a mapping of [itemId : item].
|
||||
var itemMap = ItemMapType()
|
||||
|
||||
// Used to create an initial, empty instances of this class.
|
||||
public override init() {
|
||||
}
|
||||
|
||||
// Used to clone copies of instances of this class.
|
||||
public init(itemMap: ItemMapType) {
|
||||
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())
|
||||
}
|
||||
|
||||
@objc
|
||||
public func append(item: ImageEditorItem) {
|
||||
Logger.verbose("\(item.itemId)")
|
||||
|
||||
itemMap.append(key: item.itemId, value: item)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func replace(item: ImageEditorItem) {
|
||||
Logger.verbose("\(item.itemId)")
|
||||
|
||||
itemMap.replace(key: item.itemId, value: item)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func remove(item: ImageEditorItem) {
|
||||
Logger.verbose("\(item.itemId)")
|
||||
|
||||
itemMap.remove(key: item.itemId)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func remove(itemId: String) {
|
||||
Logger.verbose("\(itemId)")
|
||||
|
||||
itemMap.remove(key: itemId)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func itemCount() -> Int {
|
||||
return itemMap.count
|
||||
}
|
||||
|
||||
@objc
|
||||
public func items() -> [ImageEditorItem] {
|
||||
return itemMap.orderedValues()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// Used to represent undo/redo operations.
|
||||
//
|
||||
// Because the image editor's "contents" and "items"
|
||||
// are immutable, these operations simply take a
|
||||
// snapshot of the current contents which can be used
|
||||
// (multiple times) to preserve/restore editor state.
|
||||
private class ImageEditorOperation: NSObject {
|
||||
|
||||
let contents: ImageEditorContents
|
||||
|
||||
required init(contents: ImageEditorContents) {
|
||||
self.contents = contents
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@objc
|
||||
public protocol ImageEditorModelDelegate: class {
|
||||
func imageEditorModelDidChange()
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@objc
|
||||
public class ImageEditorModel: NSObject {
|
||||
@objc
|
||||
public weak var delegate: ImageEditorModelDelegate?
|
||||
|
||||
@objc
|
||||
public let srcImagePath: String
|
||||
|
||||
@objc
|
||||
public let srcImageSizePixels: CGSize
|
||||
|
||||
private var contents = ImageEditorContents()
|
||||
|
||||
private var undoStack = [ImageEditorOperation]()
|
||||
private var redoStack = [ImageEditorOperation]()
|
||||
|
||||
// We don't want to allow editing of images if:
|
||||
//
|
||||
// * They are invalid.
|
||||
// * We can't determine their size / aspect-ratio.
|
||||
@objc
|
||||
public required init(srcImagePath: String) throws {
|
||||
self.srcImagePath = srcImagePath
|
||||
|
||||
let srcFileName = (srcImagePath as NSString).lastPathComponent
|
||||
let srcFileExtension = (srcFileName as NSString).pathExtension
|
||||
guard let mimeType = MIMETypeUtil.mimeType(forFileExtension: srcFileExtension) else {
|
||||
Logger.error("Couldn't determine MIME type for file.")
|
||||
throw ImageEditorError.invalidInput
|
||||
}
|
||||
guard MIMETypeUtil.isImage(mimeType),
|
||||
!MIMETypeUtil.isAnimated(mimeType) else {
|
||||
Logger.error("Invalid MIME type: \(mimeType).")
|
||||
throw ImageEditorError.invalidInput
|
||||
}
|
||||
|
||||
let srcImageSizePixels = NSData.imageSize(forFilePath: srcImagePath, mimeType: mimeType)
|
||||
guard srcImageSizePixels.width > 0, srcImageSizePixels.height > 0 else {
|
||||
Logger.error("Couldn't determine image size.")
|
||||
throw ImageEditorError.invalidInput
|
||||
}
|
||||
self.srcImageSizePixels = srcImageSizePixels
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
@objc
|
||||
public func itemCount() -> Int {
|
||||
return contents.itemCount()
|
||||
}
|
||||
|
||||
@objc
|
||||
public func items() -> [ImageEditorItem] {
|
||||
return contents.items()
|
||||
}
|
||||
|
||||
@objc
|
||||
public func canUndo() -> Bool {
|
||||
return !undoStack.isEmpty
|
||||
}
|
||||
|
||||
@objc
|
||||
public func canRedo() -> Bool {
|
||||
return !redoStack.isEmpty
|
||||
}
|
||||
|
||||
@objc
|
||||
public func undo() {
|
||||
guard let undoOperation = undoStack.popLast() else {
|
||||
owsFailDebug("Cannot undo.")
|
||||
return
|
||||
}
|
||||
|
||||
let redoOperation = ImageEditorOperation(contents: contents)
|
||||
redoStack.append(redoOperation)
|
||||
|
||||
self.contents = undoOperation.contents
|
||||
|
||||
delegate?.imageEditorModelDidChange()
|
||||
}
|
||||
|
||||
@objc
|
||||
public func redo() {
|
||||
guard let redoOperation = redoStack.popLast() else {
|
||||
owsFailDebug("Cannot redo.")
|
||||
return
|
||||
}
|
||||
|
||||
let undoOperation = ImageEditorOperation(contents: contents)
|
||||
undoStack.append(undoOperation)
|
||||
|
||||
self.contents = redoOperation.contents
|
||||
|
||||
delegate?.imageEditorModelDidChange()
|
||||
}
|
||||
|
||||
@objc
|
||||
public func append(item: ImageEditorItem) {
|
||||
performAction { (newContents) in
|
||||
newContents.append(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func replace(item: ImageEditorItem) {
|
||||
performAction { (newContents) in
|
||||
newContents.replace(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func remove(item: ImageEditorItem) {
|
||||
performAction { (newContents) in
|
||||
newContents.remove(item: item)
|
||||
}
|
||||
}
|
||||
|
||||
private func performAction(action: (ImageEditorContents) -> Void) {
|
||||
let undoOperation = ImageEditorOperation(contents: contents)
|
||||
undoStack.append(undoOperation)
|
||||
redoStack.removeAll()
|
||||
|
||||
let newContents = contents.clone()
|
||||
action(newContents)
|
||||
contents = newContents
|
||||
|
||||
delegate?.imageEditorModelDidChange()
|
||||
}
|
||||
}
|
@ -0,0 +1,344 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// A view for editing outgoing image attachments.
|
||||
// It can also be used to render the final output.
|
||||
@objc
|
||||
public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||
private let model: ImageEditorModel
|
||||
|
||||
@objc
|
||||
public required init(model: ImageEditorModel) {
|
||||
self.model = model
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
model.delegate = self
|
||||
|
||||
self.isUserInteractionEnabled = true
|
||||
|
||||
let anyTouchGesture = ImageEditorGestureRecognizer(target: self, action: #selector(handleTouchGesture(_:)))
|
||||
self.addGestureRecognizer(anyTouchGesture)
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "use other init() instead.")
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
// These properties are non-empty while drawing a stroke.
|
||||
private var currentStroke: ImageEditorStrokeItem?
|
||||
private var currentStrokeSamples = [ImageEditorStrokeItem.StrokeSample]()
|
||||
|
||||
@objc
|
||||
public func handleTouchGesture(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
Logger.verbose("\(NSStringForUIGestureRecognizerState(gestureRecognizer.state))")
|
||||
|
||||
let removeCurrentStroke = {
|
||||
if let stroke = self.currentStroke {
|
||||
self.model.remove(item: stroke)
|
||||
}
|
||||
self.currentStroke = nil
|
||||
self.currentStrokeSamples.removeAll()
|
||||
}
|
||||
|
||||
let referenceView = self
|
||||
let unitSampleForGestureLocation = { () -> CGPoint in
|
||||
// TODO: Smooth touch samples before converting into stroke samples.
|
||||
let location = gestureRecognizer.location(in: referenceView)
|
||||
let x = CGFloatClamp01(CGFloatInverseLerp(location.x, 0, referenceView.bounds.width))
|
||||
let y = CGFloatClamp01(CGFloatInverseLerp(location.y, 0, referenceView.bounds.height))
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
// TODO: Color picker.
|
||||
let strokeColor = UIColor.blue
|
||||
// TODO: Tune stroke width.
|
||||
let unitStrokeWidth = ImageEditorStrokeItem.defaultUnitStrokeWidth()
|
||||
|
||||
switch gestureRecognizer.state {
|
||||
case .began:
|
||||
removeCurrentStroke()
|
||||
|
||||
currentStrokeSamples.append(unitSampleForGestureLocation())
|
||||
|
||||
let stroke = ImageEditorStrokeItem(color: strokeColor, unitSamples: self.currentStrokeSamples, unitStrokeWidth: unitStrokeWidth)
|
||||
self.model.append(item: stroke)
|
||||
self.currentStroke = stroke
|
||||
|
||||
case .changed, .ended:
|
||||
currentStrokeSamples.append(unitSampleForGestureLocation())
|
||||
|
||||
guard let lastStroke = self.currentStroke else {
|
||||
owsFailDebug("Missing last stroke.")
|
||||
removeCurrentStroke()
|
||||
return
|
||||
}
|
||||
|
||||
// Model items are immutable; we _replace_ the
|
||||
// stroke item rather than modify it.
|
||||
let stroke = ImageEditorStrokeItem(itemId: lastStroke.itemId, color: strokeColor, unitSamples: self.currentStrokeSamples, unitStrokeWidth: unitStrokeWidth)
|
||||
self.model.replace(item: stroke)
|
||||
self.currentStroke = stroke
|
||||
|
||||
if gestureRecognizer.state == .ended {
|
||||
self.currentStroke = nil
|
||||
self.currentStrokeSamples.removeAll()
|
||||
}
|
||||
default:
|
||||
removeCurrentStroke()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImageEditorModelDelegate
|
||||
|
||||
public func imageEditorModelDidChange() {
|
||||
// TODO: We eventually want to narrow our change events
|
||||
// to reflect the specific item(s) which changed.
|
||||
updateAllContent()
|
||||
}
|
||||
|
||||
// MARK: - Accessor Overrides
|
||||
|
||||
@objc public override var bounds: CGRect {
|
||||
didSet {
|
||||
if oldValue != bounds {
|
||||
updateAllContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public override var frame: CGRect {
|
||||
didSet {
|
||||
if oldValue != frame {
|
||||
updateAllContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
var contentLayers = [CALayer]()
|
||||
|
||||
internal func updateAllContent() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
for layer in contentLayers {
|
||||
layer.removeFromSuperlayer()
|
||||
}
|
||||
contentLayers.removeAll()
|
||||
|
||||
guard bounds.width > 0,
|
||||
bounds.height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't animate changes.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
|
||||
for item in model.items() {
|
||||
guard let layer = ImageEditorView.layerForItem(item: item,
|
||||
viewSize: bounds.size) else {
|
||||
continue
|
||||
}
|
||||
|
||||
self.layer.addSublayer(layer)
|
||||
contentLayers.append(layer)
|
||||
}
|
||||
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private class func layerForItem(item: ImageEditorItem,
|
||||
viewSize: CGSize) -> CALayer? {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
switch item.itemType {
|
||||
case .test:
|
||||
owsFailDebug("Unexpected test item.")
|
||||
return nil
|
||||
case .stroke:
|
||||
guard let strokeItem = item as? ImageEditorStrokeItem else {
|
||||
owsFailDebug("Item has unexpected type: \(type(of: item)).")
|
||||
return nil
|
||||
}
|
||||
return strokeLayerForItem(item: strokeItem, viewSize: viewSize)
|
||||
}
|
||||
}
|
||||
|
||||
private class func strokeLayerForItem(item: ImageEditorStrokeItem,
|
||||
viewSize: CGSize) -> CALayer? {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
Logger.verbose("\(item.itemId)")
|
||||
|
||||
let strokeWidth = ImageEditorStrokeItem.strokeWidth(forUnitStrokeWidth: item.unitStrokeWidth,
|
||||
dstSize: viewSize)
|
||||
let unitSamples = item.unitSamples
|
||||
guard unitSamples.count > 1 else {
|
||||
// Not an error; the stroke doesn't have enough samples to render yet.
|
||||
return nil
|
||||
}
|
||||
|
||||
let shapeLayer = CAShapeLayer()
|
||||
shapeLayer.lineWidth = strokeWidth
|
||||
shapeLayer.strokeColor = item.color.cgColor
|
||||
shapeLayer.frame = CGRect(origin: .zero, size: viewSize)
|
||||
|
||||
let transformSampleToPoint = { (unitSample: CGPoint) -> CGPoint in
|
||||
return CGPoint(x: viewSize.width * unitSample.x,
|
||||
y: viewSize.height * unitSample.y)
|
||||
}
|
||||
|
||||
// TODO: Use bezier curves to smooth stroke.
|
||||
let bezierPath = UIBezierPath()
|
||||
|
||||
let points = applySmoothing(to: unitSamples.map { (unitSample) in
|
||||
transformSampleToPoint(unitSample)
|
||||
})
|
||||
var previousForwardVector = CGPoint.zero
|
||||
for index in 0..<points.count {
|
||||
let point = points[index]
|
||||
|
||||
let forwardVector: CGPoint
|
||||
if index == 0 {
|
||||
// First sample.
|
||||
let nextPoint = points[index + 1]
|
||||
forwardVector = CGPointSubtract(nextPoint, point)
|
||||
} else if index == points.count - 1 {
|
||||
// Last sample.
|
||||
let previousPoint = points[index - 1]
|
||||
forwardVector = CGPointSubtract(point, previousPoint)
|
||||
} else {
|
||||
// Middle samples.
|
||||
let previousPoint = points[index - 1]
|
||||
let previousPointForwardVector = CGPointSubtract(point, previousPoint)
|
||||
let nextPoint = points[index + 1]
|
||||
let nextPointForwardVector = CGPointSubtract(nextPoint, point)
|
||||
forwardVector = CGPointScale(CGPointAdd(previousPointForwardVector, nextPointForwardVector), 0.5)
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
// First sample.
|
||||
bezierPath.move(to: point)
|
||||
} else {
|
||||
let previousPoint = points[index - 1]
|
||||
// We apply more than one kind of smoothing.
|
||||
// This smoothing avoids rendering "angled segments"
|
||||
// by drawing the stroke as a series of curves.
|
||||
// We use bezier curves and infer the control points
|
||||
// from the "next" and "prev" points.
|
||||
//
|
||||
// This factor controls how much we're smoothing.
|
||||
//
|
||||
// * 0.0 = No smoothing.
|
||||
//
|
||||
// TODO: Tune this variable once we have stroke input.
|
||||
let controlPointFactor: CGFloat = 0.25
|
||||
let controlPoint1 = CGPointAdd(previousPoint, CGPointScale(previousForwardVector, +controlPointFactor))
|
||||
let controlPoint2 = CGPointAdd(point, CGPointScale(forwardVector, -controlPointFactor))
|
||||
// We're using Cubic curves.
|
||||
bezierPath.addCurve(to: point, controlPoint1: controlPoint1, controlPoint2: controlPoint2)
|
||||
}
|
||||
previousForwardVector = forwardVector
|
||||
}
|
||||
|
||||
shapeLayer.path = bezierPath.cgPath
|
||||
shapeLayer.fillColor = nil
|
||||
shapeLayer.lineCap = kCALineCapRound
|
||||
|
||||
return shapeLayer
|
||||
}
|
||||
|
||||
// We apply more than one kind of smoothing.
|
||||
//
|
||||
// This (simple) smoothing reduces jitter from the touch sensor.
|
||||
private class func applySmoothing(to points: [CGPoint]) -> [CGPoint] {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
var result = [CGPoint]()
|
||||
|
||||
for index in 0..<points.count {
|
||||
let point = points[index]
|
||||
|
||||
if index == 0 {
|
||||
// First sample.
|
||||
result.append(point)
|
||||
} else if index == points.count - 1 {
|
||||
// Last sample.
|
||||
result.append(point)
|
||||
} else {
|
||||
// Middle samples.
|
||||
let lastPoint = points[index - 1]
|
||||
let nextPoint = points[index + 1]
|
||||
let alpha: CGFloat = 0.1
|
||||
let smoothedPoint = CGPointAdd(CGPointScale(point, 1.0 - 2.0 * alpha),
|
||||
CGPointAdd(CGPointScale(lastPoint, alpha),
|
||||
CGPointScale(nextPoint, alpha)))
|
||||
result.append(smoothedPoint)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
// Returns nil on error.
|
||||
@objc
|
||||
public class func renderForOutput(model: ImageEditorModel) -> UIImage? {
|
||||
// TODO: Do we want to render off the main thread?
|
||||
AssertIsOnMainThread()
|
||||
|
||||
// Render output at same size as source image.
|
||||
let dstSizePixels = model.srcImageSizePixels
|
||||
|
||||
let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.srcImagePath)
|
||||
|
||||
guard let srcImage = UIImage(contentsOfFile: model.srcImagePath) else {
|
||||
owsFailDebug("Could not load src image.")
|
||||
return nil
|
||||
}
|
||||
|
||||
let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points.
|
||||
UIGraphicsBeginImageContextWithOptions(dstSizePixels, !hasAlpha, dstScale)
|
||||
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: .zero, size: model.srcImageSizePixels)
|
||||
srcImage.draw(in: dstFrame)
|
||||
|
||||
for item in model.items() {
|
||||
guard let layer = layerForItem(item: item,
|
||||
viewSize: dstSizePixels) else {
|
||||
Logger.error("Couldn't create layer for item.")
|
||||
continue
|
||||
}
|
||||
// This might be superfluous, but ensure that the layer renders
|
||||
// at "point=pixel" scale.
|
||||
layer.contentsScale = 1.0
|
||||
|
||||
layer.render(in: context)
|
||||
}
|
||||
|
||||
let scaledImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||
if scaledImage == nil {
|
||||
owsFailDebug("could not generate dst image.")
|
||||
}
|
||||
return scaledImage
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue