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