mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
540 lines
21 KiB
Swift
540 lines
21 KiB
Swift
//
|
|
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
@objc
|
|
public protocol ImageEditorViewDelegate: class {
|
|
func imageEditor(presentFullScreenView viewController: UIViewController,
|
|
isTransparent: Bool)
|
|
func imageEditorUpdateNavigationBar()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
// A view for editing outgoing image attachments.
|
|
// It can also be used to render the final output.
|
|
@objc
|
|
public class ImageEditorView: UIView {
|
|
|
|
weak var delegate: ImageEditorViewDelegate?
|
|
|
|
private let model: ImageEditorModel
|
|
|
|
private let canvasView: ImageEditorCanvasView
|
|
|
|
// TODO: We could hang this on the model or make this static
|
|
// if we wanted more color continuity.
|
|
private var currentColor = ImageEditorColor.defaultColor()
|
|
|
|
@objc
|
|
public required init(model: ImageEditorModel, delegate: ImageEditorViewDelegate) {
|
|
self.model = model
|
|
self.delegate = delegate
|
|
self.canvasView = ImageEditorCanvasView(model: model)
|
|
|
|
super.init(frame: .zero)
|
|
|
|
model.add(observer: self)
|
|
}
|
|
|
|
@available(*, unavailable, message: "use other init() instead.")
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
notImplemented()
|
|
}
|
|
|
|
// MARK: - Views
|
|
|
|
private var moveTextGestureRecognizer: ImageEditorPanGestureRecognizer?
|
|
private var tapGestureRecognizer: UITapGestureRecognizer?
|
|
private var pinchGestureRecognizer: ImageEditorPinchGestureRecognizer?
|
|
|
|
@objc
|
|
public func configureSubviews() -> Bool {
|
|
canvasView.configureSubviews()
|
|
self.addSubview(canvasView)
|
|
canvasView.autoPinEdgesToSuperviewEdges()
|
|
|
|
self.isUserInteractionEnabled = true
|
|
|
|
let moveTextGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleMoveTextGesture(_:)))
|
|
moveTextGestureRecognizer.maximumNumberOfTouches = 1
|
|
moveTextGestureRecognizer.referenceView = canvasView.gestureReferenceView
|
|
moveTextGestureRecognizer.delegate = self
|
|
self.addGestureRecognizer(moveTextGestureRecognizer)
|
|
self.moveTextGestureRecognizer = moveTextGestureRecognizer
|
|
|
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:)))
|
|
self.addGestureRecognizer(tapGestureRecognizer)
|
|
self.tapGestureRecognizer = tapGestureRecognizer
|
|
|
|
let pinchGestureRecognizer = ImageEditorPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
|
|
pinchGestureRecognizer.referenceView = canvasView.gestureReferenceView
|
|
self.addGestureRecognizer(pinchGestureRecognizer)
|
|
self.pinchGestureRecognizer = pinchGestureRecognizer
|
|
|
|
// De-conflict the GRs.
|
|
// editorGestureRecognizer.require(toFail: tapGestureRecognizer)
|
|
// editorGestureRecognizer.require(toFail: pinchGestureRecognizer)
|
|
|
|
return true
|
|
}
|
|
|
|
// TODO: Should this method be private?
|
|
@objc
|
|
public func addControls(to containerView: UIView,
|
|
viewController: UIViewController) {
|
|
delegate?.imageEditorUpdateNavigationBar()
|
|
}
|
|
|
|
// MARK: - Navigation Bar
|
|
|
|
public func navigationBarItems() -> [UIView] {
|
|
let undoButton = navigationBarButton(imageName: "image_editor_undo",
|
|
selector: #selector(didTapUndo(sender:)))
|
|
let brushButton = navigationBarButton(imageName: "image_editor_brush",
|
|
selector: #selector(didTapBrush(sender:)))
|
|
let cropButton = navigationBarButton(imageName: "image_editor_crop",
|
|
selector: #selector(didTapCrop(sender:)))
|
|
let newTextButton = navigationBarButton(imageName: "image_editor_text",
|
|
selector: #selector(didTapNewText(sender:)))
|
|
|
|
var buttons: [UIView]
|
|
if model.canUndo() {
|
|
buttons = [undoButton, newTextButton, brushButton, cropButton]
|
|
} else {
|
|
buttons = [newTextButton, brushButton, cropButton]
|
|
}
|
|
|
|
return buttons
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
@objc func didTapUndo(sender: UIButton) {
|
|
Logger.verbose("")
|
|
guard model.canUndo() else {
|
|
owsFailDebug("Can't undo.")
|
|
return
|
|
}
|
|
model.undo()
|
|
}
|
|
|
|
@objc func didTapBrush(sender: UIButton) {
|
|
Logger.verbose("")
|
|
|
|
let brushView = ImageEditorBrushViewController(delegate: self, model: model, currentColor: currentColor)
|
|
self.delegate?.imageEditor(presentFullScreenView: brushView,
|
|
isTransparent: false)
|
|
}
|
|
|
|
@objc func didTapCrop(sender: UIButton) {
|
|
Logger.verbose("")
|
|
|
|
presentCropTool()
|
|
}
|
|
|
|
@objc func didTapNewText(sender: UIButton) {
|
|
Logger.verbose("")
|
|
|
|
let viewSize = canvasView.gestureReferenceView.bounds.size
|
|
let imageSize = model.srcImageSizePixels
|
|
let imageFrame = ImageEditorCanvasView.imageFrame(forViewSize: viewSize, imageSize: imageSize,
|
|
transform: model.currentTransform())
|
|
|
|
let textWidthPoints = viewSize.width * ImageEditorTextItem.kDefaultUnitWidth
|
|
let textWidthUnit = textWidthPoints / imageFrame.size.width
|
|
|
|
// New items should be aligned "upright", so they should have the _opposite_
|
|
// of the current transform rotation.
|
|
let rotationRadians = -model.currentTransform().rotationRadians
|
|
// Similarly, the size of the text item shuo
|
|
let scaling = 1 / model.currentTransform().scaling
|
|
|
|
let textItem = ImageEditorTextItem.empty(withColor: currentColor,
|
|
unitWidth: textWidthUnit,
|
|
fontReferenceImageWidth: imageFrame.size.width,
|
|
scaling: scaling,
|
|
rotationRadians: rotationRadians)
|
|
|
|
edit(textItem: textItem, isNewItem: true)
|
|
}
|
|
|
|
@objc func didTapDone(sender: UIButton) {
|
|
Logger.verbose("")
|
|
}
|
|
|
|
// MARK: - Tap Gesture
|
|
|
|
@objc
|
|
public func handleTapGesture(_ gestureRecognizer: UIGestureRecognizer) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard gestureRecognizer.state == .recognized else {
|
|
owsFailDebug("Unexpected state.")
|
|
return
|
|
}
|
|
|
|
let location = gestureRecognizer.location(in: canvasView.gestureReferenceView)
|
|
guard let textLayer = self.textLayer(forLocation: location) else {
|
|
return
|
|
}
|
|
|
|
guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else {
|
|
owsFailDebug("Missing or invalid text item.")
|
|
return
|
|
}
|
|
|
|
edit(textItem: textItem, isNewItem: false)
|
|
}
|
|
|
|
// MARK: - Pinch Gesture
|
|
|
|
// These properties are valid while moving a text item.
|
|
private var pinchingTextItem: ImageEditorTextItem?
|
|
private var pinchHasChanged = false
|
|
|
|
@objc
|
|
public func handlePinchGesture(_ gestureRecognizer: ImageEditorPinchGestureRecognizer) {
|
|
AssertIsOnMainThread()
|
|
|
|
// We could undo an in-progress pinch if the gesture is cancelled, but it seems gratuitous.
|
|
|
|
switch gestureRecognizer.state {
|
|
case .began:
|
|
let pinchState = gestureRecognizer.pinchStateStart
|
|
guard let textLayer = self.textLayer(forLocation: pinchState.centroid) else {
|
|
// The pinch needs to start centered on a text item.
|
|
return
|
|
}
|
|
guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else {
|
|
owsFailDebug("Missing or invalid text item.")
|
|
return
|
|
}
|
|
pinchingTextItem = textItem
|
|
pinchHasChanged = false
|
|
case .changed, .ended:
|
|
guard let textItem = pinchingTextItem else {
|
|
return
|
|
}
|
|
|
|
let view = self.canvasView.gestureReferenceView
|
|
let viewBounds = view.bounds
|
|
let locationStart = gestureRecognizer.pinchStateStart.centroid
|
|
let locationNow = gestureRecognizer.pinchStateLast.centroid
|
|
let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart,
|
|
viewBounds: viewBounds,
|
|
model: self.model,
|
|
transform: self.model.currentTransform())
|
|
let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationNow,
|
|
viewBounds: viewBounds,
|
|
model: self.model,
|
|
transform: self.model.currentTransform())
|
|
let gestureDeltaImageUnit = gestureNowImageUnit.minus(gestureStartImageUnit)
|
|
let unitCenter = CGPointClamp01(textItem.unitCenter.plus(gestureDeltaImageUnit))
|
|
|
|
// NOTE: We use max(1, ...) to avoid divide-by-zero.
|
|
let newScaling = CGFloatClamp(textItem.scaling * gestureRecognizer.pinchStateLast.distance / max(1.0, gestureRecognizer.pinchStateStart.distance),
|
|
ImageEditorTextItem.kMinScaling,
|
|
ImageEditorTextItem.kMaxScaling)
|
|
|
|
let newRotationRadians = textItem.rotationRadians + gestureRecognizer.pinchStateLast.angleRadians - gestureRecognizer.pinchStateStart.angleRadians
|
|
|
|
let newItem = textItem.copy(withUnitCenter: unitCenter,
|
|
scaling: newScaling,
|
|
rotationRadians: newRotationRadians)
|
|
|
|
if pinchHasChanged {
|
|
model.replace(item: newItem, suppressUndo: true)
|
|
} else {
|
|
model.replace(item: newItem, suppressUndo: false)
|
|
pinchHasChanged = true
|
|
}
|
|
|
|
if gestureRecognizer.state == .ended {
|
|
pinchingTextItem = nil
|
|
}
|
|
default:
|
|
pinchingTextItem = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Editor Gesture
|
|
|
|
// These properties are valid while moving a text item.
|
|
private var movingTextItem: ImageEditorTextItem?
|
|
private var movingTextStartUnitCenter: CGPoint?
|
|
private var movingTextHasMoved = false
|
|
|
|
private func textLayer(forLocation locationInView: CGPoint) -> EditorTextLayer? {
|
|
let viewBounds = self.canvasView.gestureReferenceView.bounds
|
|
let affineTransform = self.model.currentTransform().affineTransform(viewSize: viewBounds.size)
|
|
let locationInCanvas = locationInView.minus(viewBounds.center).applyingInverse(affineTransform).plus(viewBounds.center)
|
|
return canvasView.textLayer(forLocation: locationInCanvas)
|
|
}
|
|
|
|
@objc
|
|
public func handleMoveTextGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) {
|
|
AssertIsOnMainThread()
|
|
|
|
// We could undo an in-progress move if the gesture is cancelled, but it seems gratuitous.
|
|
|
|
switch gestureRecognizer.state {
|
|
case .began:
|
|
guard let locationStart = gestureRecognizer.locationFirst else {
|
|
owsFailDebug("Missing locationStart.")
|
|
return
|
|
}
|
|
guard let textLayer = self.textLayer(forLocation: locationStart) else {
|
|
owsFailDebug("No text layer")
|
|
return
|
|
}
|
|
guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else {
|
|
owsFailDebug("Missing or invalid text item.")
|
|
return
|
|
}
|
|
movingTextItem = textItem
|
|
movingTextStartUnitCenter = textItem.unitCenter
|
|
movingTextHasMoved = false
|
|
|
|
case .changed, .ended:
|
|
guard let textItem = movingTextItem else {
|
|
return
|
|
}
|
|
guard let locationStart = gestureRecognizer.locationFirst else {
|
|
owsFailDebug("Missing locationStart.")
|
|
return
|
|
}
|
|
guard let movingTextStartUnitCenter = movingTextStartUnitCenter else {
|
|
owsFailDebug("Missing movingTextStartUnitCenter.")
|
|
return
|
|
}
|
|
|
|
let view = self.canvasView.gestureReferenceView
|
|
let viewBounds = view.bounds
|
|
let locationInView = gestureRecognizer.location(in: view)
|
|
let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart,
|
|
viewBounds: viewBounds,
|
|
model: self.model,
|
|
transform: self.model.currentTransform())
|
|
let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView,
|
|
viewBounds: viewBounds,
|
|
model: self.model,
|
|
transform: self.model.currentTransform())
|
|
let gestureDeltaImageUnit = gestureNowImageUnit.minus(gestureStartImageUnit)
|
|
let unitCenter = CGPointClamp01(movingTextStartUnitCenter.plus(gestureDeltaImageUnit))
|
|
let newItem = textItem.copy(withUnitCenter: unitCenter)
|
|
|
|
if movingTextHasMoved {
|
|
model.replace(item: newItem, suppressUndo: true)
|
|
} else {
|
|
model.replace(item: newItem, suppressUndo: false)
|
|
movingTextHasMoved = true
|
|
}
|
|
|
|
if gestureRecognizer.state == .ended {
|
|
movingTextItem = nil
|
|
}
|
|
default:
|
|
movingTextItem = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Brush
|
|
|
|
// These properties are non-empty while drawing a stroke.
|
|
private var currentStroke: ImageEditorStrokeItem?
|
|
private var currentStrokeSamples = [ImageEditorStrokeItem.StrokeSample]()
|
|
|
|
@objc
|
|
public func handleBrushGesture(_ gestureRecognizer: UIGestureRecognizer) {
|
|
AssertIsOnMainThread()
|
|
|
|
let removeCurrentStroke = {
|
|
if let stroke = self.currentStroke {
|
|
self.model.remove(item: stroke)
|
|
}
|
|
self.currentStroke = nil
|
|
self.currentStrokeSamples.removeAll()
|
|
}
|
|
let tryToAppendStrokeSample = {
|
|
let view = self.canvasView.gestureReferenceView
|
|
let viewBounds = view.bounds
|
|
let locationInView = gestureRecognizer.location(in: view)
|
|
let newSample = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView,
|
|
viewBounds: viewBounds,
|
|
model: self.model,
|
|
transform: self.model.currentTransform())
|
|
|
|
if let prevSample = self.currentStrokeSamples.last,
|
|
prevSample == newSample {
|
|
// Ignore duplicate samples.
|
|
return
|
|
}
|
|
self.currentStrokeSamples.append(newSample)
|
|
}
|
|
|
|
let strokeColor = currentColor.color
|
|
// TODO: Tune stroke width.
|
|
let unitStrokeWidth = ImageEditorStrokeItem.defaultUnitStrokeWidth()
|
|
|
|
switch gestureRecognizer.state {
|
|
case .began:
|
|
removeCurrentStroke()
|
|
|
|
tryToAppendStrokeSample()
|
|
|
|
let stroke = ImageEditorStrokeItem(color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth)
|
|
model.append(item: stroke)
|
|
currentStroke = stroke
|
|
|
|
case .changed, .ended:
|
|
tryToAppendStrokeSample()
|
|
|
|
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: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth)
|
|
model.replace(item: stroke, suppressUndo: true)
|
|
|
|
if gestureRecognizer.state == .ended {
|
|
currentStroke = nil
|
|
currentStrokeSamples.removeAll()
|
|
} else {
|
|
currentStroke = stroke
|
|
}
|
|
default:
|
|
removeCurrentStroke()
|
|
}
|
|
}
|
|
|
|
// MARK: - Edit Text Tool
|
|
|
|
private func edit(textItem: ImageEditorTextItem, isNewItem: Bool) {
|
|
Logger.verbose("")
|
|
|
|
// TODO:
|
|
let maxTextWidthPoints = model.srcImageSizePixels.width * ImageEditorTextItem.kDefaultUnitWidth
|
|
// let maxTextWidthPoints = canvasView.imageView.width() * ImageEditorTextItem.kDefaultUnitWidth
|
|
|
|
let textEditor = ImageEditorTextViewController(delegate: self,
|
|
model: model,
|
|
textItem: textItem,
|
|
isNewItem: isNewItem,
|
|
maxTextWidthPoints: maxTextWidthPoints)
|
|
self.delegate?.imageEditor(presentFullScreenView: textEditor,
|
|
isTransparent: false)
|
|
}
|
|
|
|
// MARK: - Crop Tool
|
|
|
|
private func presentCropTool() {
|
|
Logger.verbose("")
|
|
|
|
guard let srcImage = canvasView.loadSrcImage() else {
|
|
owsFailDebug("Couldn't load src image.")
|
|
return
|
|
}
|
|
|
|
// We want to render a preview image that "flattens" all of the brush strokes, text items,
|
|
// into the background image without applying the transform (e.g. rotating, etc.), so we
|
|
// use a default transform.
|
|
let previewTransform = ImageEditorTransform.defaultTransform(srcImageSizePixels: model.srcImageSizePixels)
|
|
guard let previewImage = ImageEditorCanvasView.renderForOutput(model: model, transform: previewTransform) else {
|
|
owsFailDebug("Couldn't generate preview image.")
|
|
return
|
|
}
|
|
|
|
let cropTool = ImageEditorCropViewController(delegate: self, model: model, srcImage: srcImage, previewImage: previewImage)
|
|
self.delegate?.imageEditor(presentFullScreenView: cropTool,
|
|
isTransparent: false)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ImageEditorView: UIGestureRecognizerDelegate {
|
|
|
|
@objc public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
guard moveTextGestureRecognizer == gestureRecognizer else {
|
|
owsFailDebug("Unexpected gesture.")
|
|
return false
|
|
}
|
|
|
|
let location = touch.location(in: canvasView.gestureReferenceView)
|
|
let isInTextArea = self.textLayer(forLocation: location) != nil
|
|
return isInTextArea
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ImageEditorView: ImageEditorModelObserver {
|
|
|
|
public func imageEditorModelDidChange(before: ImageEditorContents,
|
|
after: ImageEditorContents) {
|
|
delegate?.imageEditorUpdateNavigationBar()
|
|
}
|
|
|
|
public func imageEditorModelDidChange(changedItemIds: [String]) {
|
|
delegate?.imageEditorUpdateNavigationBar()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ImageEditorView: ImageEditorTextViewControllerDelegate {
|
|
|
|
public func textEditDidComplete(textItem: ImageEditorTextItem, text: String?, color: ImageEditorColor) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let text = text?.ows_stripped(),
|
|
text.count > 0 else {
|
|
if model.has(itemForId: textItem.itemId) {
|
|
model.remove(item: textItem)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Model items are immutable; we _replace_ the item rather than modify it.
|
|
let newItem = textItem.copy(withText: text, color: color)
|
|
if model.has(itemForId: textItem.itemId) {
|
|
model.replace(item: newItem, suppressUndo: false)
|
|
} else {
|
|
model.append(item: newItem)
|
|
}
|
|
|
|
self.currentColor = color
|
|
}
|
|
|
|
public func textEditDidCancel() {
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ImageEditorView: ImageEditorCropViewControllerDelegate {
|
|
public func cropDidComplete(transform: ImageEditorTransform) {
|
|
// TODO: Ignore no-change updates.
|
|
model.replace(transform: transform)
|
|
}
|
|
|
|
public func cropDidCancel() {
|
|
// TODO:
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ImageEditorView: ImageEditorBrushViewControllerDelegate {
|
|
public func brushDidComplete(currentColor: ImageEditorColor) {
|
|
self.currentColor = currentColor
|
|
}
|
|
}
|