session-ios/SignalUtilitiesKit/UI/Image Editing/ImageEditorModel.swift

361 lines
11 KiB
Swift
Raw Normal View History

2018-12-14 18:05:08 +01:00
//
2019-01-04 17:57:08 +01:00
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
2018-12-14 18:05:08 +01:00
//
import UIKit
2018-12-14 19:33:38 +01:00
// Used to represent undo/redo operations.
2018-12-14 19:58:45 +01:00
//
// 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.
2018-12-14 19:33:38 +01:00
private class ImageEditorOperation: NSObject {
let operationId: String
2018-12-14 19:33:38 +01:00
let contents: ImageEditorContents
required init(contents: ImageEditorContents) {
self.operationId = UUID().uuidString
2018-12-14 19:33:38 +01:00
self.contents = contents
2018-12-14 18:31:55 +01:00
}
}
2018-12-14 19:33:38 +01:00
// MARK: -
@objc
2019-02-06 22:00:22 +01:00
public protocol ImageEditorModelObserver: class {
2018-12-19 21:08:28 +01:00
// Used for large changes to the model, when the entire
// model should be reloaded.
2018-12-19 17:25:41 +01:00
func imageEditorModelDidChange(before: ImageEditorContents,
after: ImageEditorContents)
2018-12-19 21:08:28 +01:00
// Used for small narrow changes to the model, usually
// to a single item.
func imageEditorModelDidChange(changedItemIds: [String])
}
// MARK: -
2018-12-14 18:05:08 +01:00
@objc
public class ImageEditorModel: NSObject {
2019-01-04 17:57:08 +01:00
@objc
public static var isFeatureEnabled: Bool {
2019-03-01 22:49:10 +01:00
return true
2019-01-04 17:57:08 +01:00
}
2018-12-14 18:31:55 +01:00
@objc
public let srcImagePath: String
@objc
public let srcImageSizePixels: CGSize
2018-12-14 18:31:55 +01:00
2018-12-19 17:25:41 +01:00
private var contents: ImageEditorContents
2018-12-14 18:05:08 +01:00
2019-02-06 22:00:22 +01:00
private var transform: ImageEditorTransform
2018-12-14 19:33:38 +01:00
private var undoStack = [ImageEditorOperation]()
private var redoStack = [ImageEditorOperation]()
2018-12-14 19:58:45 +01:00
// We don't want to allow editing of images if:
//
// * They are invalid.
// * We can't determine their size / aspect-ratio.
2018-12-14 18:05:08 +01:00
@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
}
2018-12-14 21:01:02 +01:00
guard MIMETypeUtil.isImage(mimeType),
!MIMETypeUtil.isAnimated(mimeType) else {
2019-02-06 22:00:22 +01:00
Logger.error("Invalid MIME type: \(mimeType).")
throw ImageEditorError.invalidInput
2018-12-14 18:05:08 +01:00
}
let srcImageSizePixels = NSData.imageSize(forFilePath: srcImagePath, mimeType: mimeType)
guard srcImageSizePixels.width > 0, srcImageSizePixels.height > 0 else {
2018-12-14 18:05:08 +01:00
Logger.error("Couldn't determine image size.")
throw ImageEditorError.invalidInput
}
self.srcImageSizePixels = srcImageSizePixels
2018-12-14 18:05:08 +01:00
2019-02-06 22:00:22 +01:00
self.contents = ImageEditorContents()
self.transform = ImageEditorTransform.defaultTransform(srcImageSizePixels: srcImageSizePixels)
2018-12-19 17:25:41 +01:00
2018-12-14 18:05:08 +01:00
super.init()
}
2018-12-14 19:33:38 +01:00
2019-02-06 22:00:22 +01:00
public func currentTransform() -> ImageEditorTransform {
return transform
}
2018-12-19 17:25:41 +01:00
@objc
2019-02-06 22:00:22 +01:00
public func isDirty() -> Bool {
if itemCount() > 0 {
return true
}
return transform != ImageEditorTransform.defaultTransform(srcImageSizePixels: srcImageSizePixels)
2018-12-19 17:25:41 +01:00
}
2018-12-14 19:33:38 +01:00
@objc
public func itemCount() -> Int {
return contents.itemCount()
}
2018-12-14 19:58:45 +01:00
@objc
public func items() -> [ImageEditorItem] {
return contents.items()
}
@objc
public func itemIds() -> [String] {
return contents.itemIds()
}
@objc
public func has(itemForId itemId: String) -> Bool {
return item(forId: itemId) != nil
}
@objc
public func item(forId itemId: String) -> ImageEditorItem? {
return contents.item(forId: itemId)
}
2018-12-14 19:33:38 +01:00
@objc
public func canUndo() -> Bool {
return !undoStack.isEmpty
}
@objc
public func canRedo() -> Bool {
return !redoStack.isEmpty
}
@objc
public func currentUndoOperationId() -> String? {
guard let operation = undoStack.last else {
return nil
}
return operation.operationId
}
2019-02-06 22:00:22 +01:00
// MARK: - Observers
private var observers = [Weak<ImageEditorModelObserver>]()
@objc
public func add(observer: ImageEditorModelObserver) {
observers.append(Weak(value: observer))
}
private func fireModelDidChange(before: ImageEditorContents,
after: ImageEditorContents) {
// We could diff here and yield a more narrow change event.
for weakObserver in observers {
guard let observer = weakObserver.value else {
continue
}
observer.imageEditorModelDidChange(before: before,
after: after)
}
}
private func fireModelDidChange(changedItemIds: [String]) {
// We could diff here and yield a more narrow change event.
for weakObserver in observers {
guard let observer = weakObserver.value else {
continue
}
observer.imageEditorModelDidChange(changedItemIds: changedItemIds)
}
}
// MARK: -
2018-12-14 19:33:38 +01:00
@objc
public func undo() {
guard let undoOperation = undoStack.popLast() else {
owsFailDebug("Cannot undo.")
return
}
let redoOperation = ImageEditorOperation(contents: contents)
redoStack.append(redoOperation)
2018-12-19 17:25:41 +01:00
let oldContents = self.contents
2018-12-14 19:33:38 +01:00
self.contents = undoOperation.contents
// We could diff here and yield a more narrow change event.
2019-02-06 22:00:22 +01:00
fireModelDidChange(before: oldContents, after: self.contents)
2018-12-14 19:33:38 +01:00
}
@objc
public func redo() {
guard let redoOperation = redoStack.popLast() else {
owsFailDebug("Cannot redo.")
return
}
let undoOperation = ImageEditorOperation(contents: contents)
undoStack.append(undoOperation)
2018-12-19 17:25:41 +01:00
let oldContents = self.contents
2018-12-14 19:33:38 +01:00
self.contents = redoOperation.contents
// We could diff here and yield a more narrow change event.
2019-02-06 22:00:22 +01:00
fireModelDidChange(before: oldContents, after: self.contents)
2018-12-14 19:33:38 +01:00
}
@objc
public func append(item: ImageEditorItem) {
2018-12-19 17:25:41 +01:00
performAction({ (oldContents) in
let newContents = oldContents.clone()
2018-12-14 19:33:38 +01:00
newContents.append(item: item)
2018-12-19 17:25:41 +01:00
return newContents
}, changedItemIds: [item.itemId])
2018-12-14 19:33:38 +01:00
}
@objc
2018-12-18 15:12:50 +01:00
public func replace(item: ImageEditorItem,
2018-12-18 15:16:32 +01:00
suppressUndo: Bool = false) {
2018-12-19 17:25:41 +01:00
performAction({ (oldContents) in
let newContents = oldContents.clone()
2018-12-14 19:33:38 +01:00
newContents.replace(item: item)
2018-12-19 17:25:41 +01:00
return newContents
2018-12-18 20:03:42 +01:00
}, changedItemIds: [item.itemId],
suppressUndo: suppressUndo)
2018-12-14 19:33:38 +01:00
}
@objc
public func remove(item: ImageEditorItem) {
2018-12-19 17:25:41 +01:00
performAction({ (oldContents) in
let newContents = oldContents.clone()
2018-12-14 19:33:38 +01:00
newContents.remove(item: item)
2018-12-19 17:25:41 +01:00
return newContents
}, changedItemIds: [item.itemId])
2018-12-14 19:33:38 +01:00
}
2019-02-06 22:00:22 +01:00
@objc
public func replace(transform: ImageEditorTransform) {
self.transform = transform
// The contents haven't changed, but this event prods the
// observers to reload everything, which is necessary if
// the transform changes.
fireModelDidChange(before: self.contents, after: self.contents)
}
2018-12-20 15:42:28 +01:00
// MARK: - Temp Files
private var temporaryFilePaths = [String]()
@objc
public func temporaryFilePath(withFileExtension fileExtension: String) -> String {
AssertIsOnMainThread()
let filePath = OWSFileSystem.temporaryFilePath(withFileExtension: fileExtension)
temporaryFilePaths.append(filePath)
return filePath
}
deinit {
AssertIsOnMainThread()
let temporaryFilePaths = self.temporaryFilePaths
DispatchQueue.global(qos: .background).async {
for filePath in temporaryFilePaths {
guard OWSFileSystem.deleteFile(filePath) else {
Logger.error("Could not delete temp file: \(filePath)")
continue
}
}
}
}
2018-12-19 17:25:41 +01:00
private func performAction(_ action: (ImageEditorContents) -> ImageEditorContents,
changedItemIds: [String]?,
2018-12-18 15:16:32 +01:00
suppressUndo: Bool = false) {
if !suppressUndo {
2018-12-18 15:12:50 +01:00
let undoOperation = ImageEditorOperation(contents: contents)
undoStack.append(undoOperation)
redoStack.removeAll()
}
2018-12-14 19:33:38 +01:00
2018-12-19 17:25:41 +01:00
let oldContents = self.contents
let newContents = action(oldContents)
2018-12-14 19:33:38 +01:00
contents = newContents
2018-12-19 17:25:41 +01:00
if let changedItemIds = changedItemIds {
2019-02-06 22:00:22 +01:00
fireModelDidChange(changedItemIds: changedItemIds)
2018-12-19 17:25:41 +01:00
} else {
2019-02-06 22:00:22 +01:00
fireModelDidChange(before: oldContents,
after: self.contents)
2018-12-19 17:25:41 +01:00
}
}
// 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.
2018-12-19 20:39:48 +01:00
let cropRect = CGRect(x: round(unitCropRect.origin.x * srcImageSize.width),
y: round(unitCropRect.origin.y * srcImageSize.height),
width: round(unitCropRect.size.width * srcImageSize.width),
height: round(unitCropRect.size.height * srcImageSize.height))
2018-12-19 17:25:41 +01:00
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 {
2018-12-19 20:39:48 +01:00
// Not an error; indicates that the user tapped rather
// than dragged.
Logger.warn("Empty crop rectangle.")
2018-12-19 17:25:41 +01:00
return nil
}
let hasAlpha = NSData.hasAlpha(forValidImageFilePath: imagePath)
UIGraphicsBeginImageContextWithOptions(cropRect.size, !hasAlpha, srcImage.scale)
defer { UIGraphicsEndImageContext() }
guard let context = UIGraphicsGetCurrentContext() else {
2019-02-07 16:42:50 +01:00
owsFailDebug("context was unexpectedly nil")
2018-12-19 17:25:41 +01:00
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
2018-12-14 19:33:38 +01:00
}
2018-12-14 18:05:08 +01:00
}