mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
First draft of image editor's text tool.
This commit is contained in:
parent
4e172fe8b6
commit
3f8ea271b4
9 changed files with 1118 additions and 82 deletions
|
@ -228,6 +228,8 @@
|
|||
34B6A90B218BA1D1007C4606 /* typing-animation.gif in Resources */ = {isa = PBXBuildFile; fileRef = 34B6A90A218BA1D0007C4606 /* typing-animation.gif */; };
|
||||
34B6D27420F664C900765BE2 /* OWSUnreadIndicator.h in Headers */ = {isa = PBXBuildFile; fileRef = 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
34B6D27520F664C900765BE2 /* OWSUnreadIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */; };
|
||||
34BBC84B220B2CB200857249 /* ImageEditorTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */; };
|
||||
34BBC84D220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BBC84C220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift */; };
|
||||
34BECE2B1F74C12700D7438D /* DebugUIStress.m in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2A1F74C12700D7438D /* DebugUIStress.m */; };
|
||||
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; };
|
||||
34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */; };
|
||||
|
@ -902,6 +904,8 @@
|
|||
34B6A90A218BA1D0007C4606 /* typing-animation.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = "typing-animation.gif"; sourceTree = "<group>"; };
|
||||
34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSUnreadIndicator.h; sourceTree = "<group>"; };
|
||||
34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSUnreadIndicator.m; sourceTree = "<group>"; };
|
||||
34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorTextViewController.swift; sourceTree = "<group>"; };
|
||||
34BBC84C220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorPinchGestureRecognizer.swift; sourceTree = "<group>"; };
|
||||
34BECE291F74C12700D7438D /* DebugUIStress.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIStress.h; sourceTree = "<group>"; };
|
||||
34BECE2A1F74C12700D7438D /* DebugUIStress.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DebugUIStress.m; sourceTree = "<group>"; };
|
||||
34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -1866,6 +1870,8 @@
|
|||
children = (
|
||||
34BEDB1821C82AC5007B0EAE /* ImageEditorGestureRecognizer.swift */,
|
||||
34BEDB0D21C405B0007B0EAE /* ImageEditorModel.swift */,
|
||||
34BBC84C220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift */,
|
||||
34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */,
|
||||
34BEDB1221C43F69007B0EAE /* ImageEditorView.swift */,
|
||||
);
|
||||
path = ImageEditor;
|
||||
|
@ -3340,6 +3346,7 @@
|
|||
347850691FD9B78A007B8332 /* AppSetup.m in Sources */,
|
||||
346941A3215D2EE400B5BFAD /* Theme.m in Sources */,
|
||||
4C23A5F2215C4ADE00534937 /* SheetViewController.swift in Sources */,
|
||||
34BBC84D220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift in Sources */,
|
||||
34AC0A14211B39EA00997B47 /* ContactCellView.m in Sources */,
|
||||
34AC0A15211B39EA00997B47 /* ContactsViewHelper.m in Sources */,
|
||||
346129FF1FD5F31400532771 /* OWS103EnableVideoCalling.m in Sources */,
|
||||
|
@ -3361,6 +3368,7 @@
|
|||
4598198F204E2F28009414F2 /* OWS108CallLoggingPreference.m in Sources */,
|
||||
34AC09F3211B39B100997B47 /* NewNonContactConversationViewController.m in Sources */,
|
||||
4C3E245C21F29FCE000AE092 /* Toast.swift in Sources */,
|
||||
34BBC84B220B2CB200857249 /* ImageEditorTextViewController.swift in Sources */,
|
||||
34AC09FA211B39B100997B47 /* SharingThreadPickerViewController.m in Sources */,
|
||||
45F59A082028E4FB00E8D2B0 /* OWSAudioSession.swift in Sources */,
|
||||
34612A071FD7238600532771 /* OWSSyncManager.m in Sources */,
|
||||
|
|
|
@ -1317,6 +1317,8 @@ extension AttachmentPrepViewController: UIScrollViewDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
class BottomToolView: UIView {
|
||||
let mediaMessageTextToolbar: MediaMessageTextToolbar
|
||||
let galleryRailView: GalleryRailView
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
||||
public class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
||||
|
||||
@objc
|
||||
public var shouldAllowOutsideView = true
|
||||
|
@ -13,42 +13,56 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
|||
public weak var canvasView: UIView?
|
||||
|
||||
@objc
|
||||
override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
public var startLocationInView: CGPoint = .zero
|
||||
|
||||
@objc
|
||||
public override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
override func canBePrevented(by: UIGestureRecognizer) -> Bool {
|
||||
public override func canBePrevented(by: UIGestureRecognizer) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
override func shouldRequireFailure(of: UIGestureRecognizer) -> Bool {
|
||||
public override func shouldRequireFailure(of: UIGestureRecognizer) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
override func shouldBeRequiredToFail(by: UIGestureRecognizer) -> Bool {
|
||||
public override func shouldBeRequiredToFail(by: UIGestureRecognizer) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Touch Handling
|
||||
|
||||
@objc
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
public 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
|
||||
startLocationInView = .zero
|
||||
|
||||
guard let view = view else {
|
||||
owsFailDebug("Missing view.")
|
||||
return
|
||||
}
|
||||
guard let touch = touches.randomElement() else {
|
||||
owsFailDebug("Missing touch.")
|
||||
return
|
||||
}
|
||||
startLocationInView = touch.location(in: view)
|
||||
} else {
|
||||
state = .failed
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesMoved(touches, with: event)
|
||||
|
||||
switch state {
|
||||
|
@ -70,7 +84,7 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
|||
}
|
||||
|
||||
@objc
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
public override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
|
||||
switch state {
|
||||
|
@ -88,7 +102,7 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
|||
}
|
||||
|
||||
@objc
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
|
||||
state = .cancelled
|
||||
|
|
|
@ -13,10 +13,30 @@ import UIKit
|
|||
public enum ImageEditorItemType: Int {
|
||||
case test
|
||||
case stroke
|
||||
case text
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// 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 ImageEditorSample = CGPoint
|
||||
|
||||
public typealias ImageEditorConversion = (ImageEditorSample) -> ImageEditorSample
|
||||
|
||||
// MARK: -
|
||||
|
||||
// Instances of ImageEditorItem should be treated
|
||||
// as immutable, once configured.
|
||||
@objc
|
||||
|
@ -44,11 +64,13 @@ public class ImageEditorItem: NSObject {
|
|||
super.init()
|
||||
}
|
||||
|
||||
public typealias PointConversionFunction = (CGPoint) -> CGPoint
|
||||
|
||||
public func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem {
|
||||
public func clone(withImageEditorConversion conversion: ImageEditorConversion) -> ImageEditorItem {
|
||||
return ImageEditorItem(itemId: itemId, itemType: itemType)
|
||||
}
|
||||
|
||||
public func outputScale() -> CGFloat {
|
||||
return 1.0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
@ -60,20 +82,7 @@ public class ImageEditorStrokeItem: ImageEditorItem {
|
|||
@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
|
||||
public typealias StrokeSample = ImageEditorSample
|
||||
|
||||
@objc
|
||||
public let unitSamples: [StrokeSample]
|
||||
|
@ -117,7 +126,7 @@ public class ImageEditorStrokeItem: ImageEditorItem {
|
|||
return CGFloatClamp01(unitStrokeWidth) * min(dstSize.width, dstSize.height)
|
||||
}
|
||||
|
||||
public override func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem {
|
||||
public override func clone(withImageEditorConversion conversion: ImageEditorConversion) -> ImageEditorItem {
|
||||
// TODO: We might want to convert the unitStrokeWidth too.
|
||||
let convertedUnitSamples = unitSamples.map { (sample) in
|
||||
conversion(sample)
|
||||
|
@ -131,6 +140,159 @@ public class ImageEditorStrokeItem: ImageEditorItem {
|
|||
|
||||
// MARK: -
|
||||
|
||||
@objc
|
||||
public class ImageEditorTextItem: ImageEditorItem {
|
||||
// Until we need to serialize these items,
|
||||
// just use UIColor.
|
||||
@objc
|
||||
public let color: UIColor
|
||||
|
||||
@objc
|
||||
public let font: UIFont
|
||||
|
||||
@objc
|
||||
public let text: String
|
||||
|
||||
@objc
|
||||
public let unitCenter: ImageEditorSample
|
||||
|
||||
// Leave some margins against the edge of the image.
|
||||
@objc
|
||||
public static let kDefaultUnitWidth: CGFloat = 0.9
|
||||
|
||||
// The max width of the text as a fraction of the image width.
|
||||
@objc
|
||||
public let unitWidth: CGFloat
|
||||
|
||||
// 0 = no rotation.
|
||||
// CGFloat.pi * 0.5 = rotation 90 degrees clockwise.
|
||||
@objc
|
||||
public let rotationRadians: CGFloat
|
||||
|
||||
@objc
|
||||
public static let kMaxScaling: CGFloat = 4.0
|
||||
@objc
|
||||
public static let kMinScaling: CGFloat = 0.5
|
||||
@objc
|
||||
public let scaling: CGFloat
|
||||
|
||||
// This might be nil while the item is a "draft" item.
|
||||
// Once the item has been "committed" to the model, it
|
||||
// should always be non-nil,
|
||||
@objc
|
||||
public let imagePath: String?
|
||||
|
||||
@objc
|
||||
public init(color: UIColor,
|
||||
font: UIFont,
|
||||
text: String,
|
||||
unitCenter: ImageEditorSample = CGPoint(x: 0.5, y: 0.5),
|
||||
unitWidth: CGFloat = ImageEditorTextItem.kDefaultUnitWidth,
|
||||
rotationRadians: CGFloat = 0.0,
|
||||
scaling: CGFloat = 1.0,
|
||||
imagePath: String? = nil) {
|
||||
self.color = color
|
||||
self.font = font
|
||||
self.text = text
|
||||
self.unitCenter = unitCenter
|
||||
self.unitWidth = unitWidth
|
||||
self.rotationRadians = rotationRadians
|
||||
self.scaling = scaling
|
||||
self.imagePath = imagePath
|
||||
|
||||
super.init(itemType: .text)
|
||||
}
|
||||
|
||||
private init(itemId: String,
|
||||
color: UIColor,
|
||||
font: UIFont,
|
||||
text: String,
|
||||
unitCenter: ImageEditorSample,
|
||||
unitWidth: CGFloat,
|
||||
rotationRadians: CGFloat,
|
||||
scaling: CGFloat,
|
||||
imagePath: String?) {
|
||||
self.color = color
|
||||
self.font = font
|
||||
self.text = text
|
||||
self.unitCenter = unitCenter
|
||||
self.unitWidth = unitWidth
|
||||
self.rotationRadians = rotationRadians
|
||||
self.scaling = scaling
|
||||
self.imagePath = imagePath
|
||||
|
||||
super.init(itemId: itemId, itemType: .text)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func empty(withColor color: UIColor) -> ImageEditorTextItem {
|
||||
let font = UIFont.boldSystemFont(ofSize: 30.0)
|
||||
return ImageEditorTextItem(color: color, font: font, text: "")
|
||||
}
|
||||
|
||||
@objc
|
||||
public func copy(withText newText: String) -> ImageEditorTextItem {
|
||||
return ImageEditorTextItem(itemId: itemId,
|
||||
color: color,
|
||||
font: font,
|
||||
text: newText,
|
||||
unitCenter: unitCenter,
|
||||
unitWidth: unitWidth,
|
||||
rotationRadians: rotationRadians,
|
||||
scaling: scaling,
|
||||
imagePath: imagePath)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func copy(withUnitCenter newUnitCenter: CGPoint) -> ImageEditorTextItem {
|
||||
return ImageEditorTextItem(itemId: itemId,
|
||||
color: color,
|
||||
font: font,
|
||||
text: text,
|
||||
unitCenter: newUnitCenter,
|
||||
unitWidth: unitWidth,
|
||||
rotationRadians: rotationRadians,
|
||||
scaling: scaling,
|
||||
imagePath: imagePath)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func copy(withUnitCenter newUnitCenter: CGPoint,
|
||||
scaling newScaling: CGFloat,
|
||||
rotationRadians newRotationRadians: CGFloat) -> ImageEditorTextItem {
|
||||
return ImageEditorTextItem(itemId: itemId,
|
||||
color: color,
|
||||
font: font,
|
||||
text: text,
|
||||
unitCenter: newUnitCenter,
|
||||
unitWidth: unitWidth,
|
||||
rotationRadians: newRotationRadians,
|
||||
scaling: newScaling,
|
||||
imagePath: imagePath)
|
||||
}
|
||||
|
||||
public override func clone(withImageEditorConversion conversion: ImageEditorConversion) -> ImageEditorItem {
|
||||
let convertedUnitCenter = conversion(unitCenter)
|
||||
let convertedUnitWidth = conversion(CGPoint(x: unitWidth, y: 0)).x
|
||||
|
||||
return ImageEditorTextItem(itemId: itemId,
|
||||
color: color,
|
||||
font: font,
|
||||
text: text,
|
||||
unitCenter: convertedUnitCenter,
|
||||
unitWidth: convertedUnitWidth,
|
||||
rotationRadians: rotationRadians,
|
||||
scaling: scaling,
|
||||
imagePath: imagePath)
|
||||
}
|
||||
|
||||
public override func outputScale() -> CGFloat {
|
||||
return scaling
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
public class OrderedDictionary<ValueType>: NSObject {
|
||||
|
||||
public typealias KeyType = String
|
||||
|
@ -422,6 +584,11 @@ public class ImageEditorModel: NSObject {
|
|||
return contents.items()
|
||||
}
|
||||
|
||||
@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)
|
||||
|
@ -559,7 +726,7 @@ public class ImageEditorModel: NSObject {
|
|||
let right = unitCropRect.origin.x + unitCropRect.size.width
|
||||
let top = unitCropRect.origin.y
|
||||
let bottom = unitCropRect.origin.y + unitCropRect.size.height
|
||||
let conversion: ImageEditorItem.PointConversionFunction = { (point) in
|
||||
let conversion: ImageEditorConversion = { (point) in
|
||||
// Convert from the pre-crop unit coordinate system
|
||||
// to post-crop unit coordinate system using inverse
|
||||
// lerp.
|
||||
|
@ -580,7 +747,7 @@ public class ImageEditorModel: NSObject {
|
|||
let newContents = ImageEditorContents(imagePath: croppedImagePath,
|
||||
imageSizePixels: croppedImageSizePixels)
|
||||
for oldItem in oldContents.items() {
|
||||
let newItem = oldItem.clone(withPointConversionFunction: conversion)
|
||||
let newItem = oldItem.clone(withImageEditorConversion: conversion)
|
||||
newContents.append(item: newItem)
|
||||
}
|
||||
return newContents
|
||||
|
|
|
@ -0,0 +1,202 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public struct ImageEditorPinchState {
|
||||
public let centroid: CGPoint
|
||||
public let distance: CGFloat
|
||||
public let angleRadians: CGFloat
|
||||
|
||||
init(centroid: CGPoint,
|
||||
distance: CGFloat,
|
||||
angleRadians: CGFloat) {
|
||||
self.centroid = centroid
|
||||
self.distance = distance
|
||||
self.angleRadians = angleRadians
|
||||
}
|
||||
|
||||
static func empty() -> ImageEditorPinchState {
|
||||
return ImageEditorPinchState(centroid: .zero, distance: 1.0, angleRadians: 0)
|
||||
}
|
||||
}
|
||||
|
||||
public class ImageEditorPinchGestureRecognizer: UIGestureRecognizer {
|
||||
|
||||
public var pinchStateStart = ImageEditorPinchState.empty()
|
||||
|
||||
public var pinchStateLast = ImageEditorPinchState.empty()
|
||||
|
||||
// MARK: - Touch Handling
|
||||
|
||||
private var gestureBeganLocation: CGPoint?
|
||||
|
||||
private func failAndReset() {
|
||||
state = .failed
|
||||
gestureBeganLocation = nil
|
||||
}
|
||||
|
||||
@objc
|
||||
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
||||
if state == .possible {
|
||||
if gestureBeganLocation == nil {
|
||||
gestureBeganLocation = centroid(forTouches: event.allTouches)
|
||||
}
|
||||
|
||||
switch touchState(for: event) {
|
||||
case .possible:
|
||||
// Do nothing
|
||||
break
|
||||
case .invalid:
|
||||
failAndReset()
|
||||
case .valid(let pinchState):
|
||||
state = .began
|
||||
pinchStateStart = pinchState
|
||||
pinchStateLast = pinchState
|
||||
}
|
||||
} else {
|
||||
failAndReset()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesMoved(touches, with: event)
|
||||
|
||||
switch state {
|
||||
case .began, .changed:
|
||||
switch touchState(for: event) {
|
||||
case .possible:
|
||||
if let gestureBeganLocation = gestureBeganLocation {
|
||||
let location = centroid(forTouches: event.allTouches)
|
||||
|
||||
// If the initial touch moves too much without a second touch,
|
||||
// this GR needs to fail - the gesture looks like a pan/swipe/etc.,
|
||||
// not a pinch.
|
||||
let distance = CGPointDistance(location, gestureBeganLocation)
|
||||
let maxDistance: CGFloat = 10.0
|
||||
guard distance <= maxDistance else {
|
||||
failAndReset()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Do nothing
|
||||
break
|
||||
case .invalid:
|
||||
failAndReset()
|
||||
case .valid(let pinchState):
|
||||
state = .changed
|
||||
pinchStateLast = pinchState
|
||||
}
|
||||
default:
|
||||
failAndReset()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
|
||||
switch state {
|
||||
case .began, .changed:
|
||||
switch touchState(for: event) {
|
||||
case .possible:
|
||||
failAndReset()
|
||||
case .invalid:
|
||||
failAndReset()
|
||||
case .valid(let pinchState):
|
||||
state = .ended
|
||||
pinchStateLast = pinchState
|
||||
}
|
||||
default:
|
||||
failAndReset()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
|
||||
state = .cancelled
|
||||
}
|
||||
|
||||
public enum TouchState {
|
||||
case possible
|
||||
case valid(pinchState : ImageEditorPinchState)
|
||||
case invalid
|
||||
}
|
||||
|
||||
private func touchState(for event: UIEvent) -> TouchState {
|
||||
guard let allTouches = event.allTouches else {
|
||||
owsFailDebug("Missing allTouches")
|
||||
return .invalid
|
||||
}
|
||||
// Note that we use _all_ touches.
|
||||
if allTouches.count < 2 {
|
||||
return .possible
|
||||
}
|
||||
guard let pinchState = pinchState(for: allTouches) else {
|
||||
return .invalid
|
||||
}
|
||||
return .valid(pinchState:pinchState)
|
||||
}
|
||||
|
||||
private func pinchState(for touches: Set<UITouch>) -> ImageEditorPinchState? {
|
||||
guard let view = self.view else {
|
||||
owsFailDebug("Missing view")
|
||||
return nil
|
||||
}
|
||||
guard touches.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
let touchList = Array<UITouch>(touches).sorted { (left, right) -> Bool in
|
||||
// TODO: Will timestamp yield stable sort?
|
||||
left.timestamp < right.timestamp
|
||||
}
|
||||
guard let touch0 = touchList.first else {
|
||||
return nil
|
||||
}
|
||||
guard let touch1 = touchList.last else {
|
||||
return nil
|
||||
}
|
||||
let location0 = touch0.location(in: view)
|
||||
let location1 = touch1.location(in: view)
|
||||
|
||||
let centroid = CGPointScale(CGPointAdd(location0, location1), 0.5)
|
||||
let distance = CGPointDistance(location0, location1)
|
||||
|
||||
// The valence of the angle doesn't matter; we're only going to be using
|
||||
// changes to the angle.
|
||||
let delta = CGPointSubtract(location1, location0)
|
||||
let angleRadians = atan2(delta.y, delta.x)
|
||||
|
||||
return ImageEditorPinchState(centroid: centroid,
|
||||
distance: distance,
|
||||
angleRadians: angleRadians)
|
||||
}
|
||||
|
||||
private func centroid(forTouches touches: Set<UITouch>?) -> CGPoint {
|
||||
guard let view = self.view else {
|
||||
owsFailDebug("Missing view")
|
||||
return .zero
|
||||
}
|
||||
guard let touches = touches else {
|
||||
return .zero
|
||||
}
|
||||
guard touches.count > 0 else {
|
||||
return .zero
|
||||
}
|
||||
var sum = CGPoint.zero
|
||||
for touch in touches {
|
||||
let location = touch.location(in: view)
|
||||
sum = CGPointAdd(sum, location)
|
||||
}
|
||||
|
||||
let centroid = CGPointScale(sum, 1 / CGFloat(touches.count))
|
||||
return centroid
|
||||
}
|
||||
}
|
|
@ -0,0 +1,213 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
public protocol VAlignTextViewDelegate: class {
|
||||
func textViewDidComplete()
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private class VAlignTextView: UITextView {
|
||||
fileprivate weak var textViewDelegate: VAlignTextViewDelegate?
|
||||
|
||||
enum Alignment: String {
|
||||
case top
|
||||
case center
|
||||
case bottom
|
||||
}
|
||||
private let alignment: Alignment
|
||||
|
||||
@objc public override var bounds: CGRect {
|
||||
didSet {
|
||||
if oldValue != bounds {
|
||||
updateInsets()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public override var frame: CGRect {
|
||||
didSet {
|
||||
if oldValue != frame {
|
||||
updateInsets()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public init(alignment: Alignment) {
|
||||
self.alignment = alignment
|
||||
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
|
||||
self.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "use other init() instead.")
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.removeObserver(self, forKeyPath: "contentSize")
|
||||
}
|
||||
|
||||
private func updateInsets() {
|
||||
let topOffset: CGFloat
|
||||
switch alignment {
|
||||
case .top:
|
||||
topOffset = 0
|
||||
case .center:
|
||||
topOffset = max(0, (self.height() - contentSize.height) * 0.5)
|
||||
case .bottom:
|
||||
topOffset = max(0, self.height() - contentSize.height)
|
||||
}
|
||||
contentInset = UIEdgeInsets(top: topOffset, leading: 0, bottom: 0, trailing: 0)
|
||||
}
|
||||
|
||||
open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
|
||||
updateInsets()
|
||||
}
|
||||
|
||||
// MARK: - Key Commands
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
return [
|
||||
UIKeyCommand(input: "\r", modifierFlags: .command, action: #selector(self.modifiedReturnPressed(sender:)), discoverabilityTitle: "Send Message"),
|
||||
UIKeyCommand(input: "\r", modifierFlags: .alternate, action: #selector(self.modifiedReturnPressed(sender:)), discoverabilityTitle: "Send Message")
|
||||
]
|
||||
}
|
||||
|
||||
@objc
|
||||
public func modifiedReturnPressed(sender: UIKeyCommand) {
|
||||
Logger.verbose("")
|
||||
|
||||
self.textViewDelegate?.textViewDidComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@objc
|
||||
public protocol ImageEditorTextViewControllerDelegate: class {
|
||||
func textEditDidComplete(textItem: ImageEditorTextItem, text: String?)
|
||||
func textEditDidCancel()
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// A view for editing text item in image editor.
|
||||
class ImageEditorTextViewController: OWSViewController, VAlignTextViewDelegate {
|
||||
private weak var delegate: ImageEditorTextViewControllerDelegate?
|
||||
|
||||
private let textItem: ImageEditorTextItem
|
||||
|
||||
private let maxTextWidthPoints: CGFloat
|
||||
|
||||
private let textView = VAlignTextView(alignment: .bottom)
|
||||
|
||||
init(delegate: ImageEditorTextViewControllerDelegate,
|
||||
textItem: ImageEditorTextItem,
|
||||
maxTextWidthPoints: CGFloat) {
|
||||
self.delegate = delegate
|
||||
self.textItem = textItem
|
||||
self.maxTextWidthPoints = maxTextWidthPoints
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.textView.textViewDelegate = self
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "use other init() instead.")
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
// MARK: - View Lifecycle
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
textView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
textView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
self.view = UIView()
|
||||
self.view.backgroundColor = UIColor(white: 0.5, alpha: 0.5)
|
||||
|
||||
configureTextView()
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop,
|
||||
target: self,
|
||||
action: #selector(didTapBackButton))
|
||||
|
||||
self.view.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20)
|
||||
self.view.addSubview(textView)
|
||||
textView.autoPinTopToSuperviewMargin()
|
||||
textView.autoHCenterInSuperview()
|
||||
// In order to having text wrapping be as WYSIWYG as possible, we limit the text view
|
||||
// to the max text width on the image.
|
||||
let maxTextWidthPoints = max(self.maxTextWidthPoints, 200)
|
||||
textView.autoSetDimension(.width, toSize: maxTextWidthPoints, relation: .lessThanOrEqual)
|
||||
self.autoPinView(toBottomOfViewControllerOrKeyboard: textView, avoidNotch: true)
|
||||
}
|
||||
|
||||
private func configureTextView() {
|
||||
textView.text = textItem.text
|
||||
textView.font = textItem.font
|
||||
textView.textColor = textItem.color
|
||||
|
||||
textView.isEditable = true
|
||||
textView.backgroundColor = .clear
|
||||
textView.isOpaque = false
|
||||
// We use a white cursor since we use a dark background.
|
||||
textView.tintColor = .white
|
||||
textView.returnKeyType = .done
|
||||
// TODO:
|
||||
// textView.delegate = self
|
||||
textView.isScrollEnabled = true
|
||||
textView.scrollsToTop = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.textAlignment = .center
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
textView.contentInset = .zero
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
@objc public func didTapBackButton() {
|
||||
completeAndDismiss()
|
||||
}
|
||||
|
||||
private func completeAndDismiss() {
|
||||
|
||||
// Before we take a screenshot, make sure selection state
|
||||
// auto-complete suggestions, cursor don't affect screenshot.
|
||||
textView.resignFirstResponder()
|
||||
if textView.isFirstResponder {
|
||||
owsFailDebug("Text view is still first responder.")
|
||||
}
|
||||
textView.selectedTextRange = nil
|
||||
|
||||
self.delegate?.textEditDidComplete(textItem: textItem, text: textView.text)
|
||||
|
||||
self.dismiss(animated: true) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VAlignTextViewDelegate
|
||||
|
||||
func textViewDidComplete() {
|
||||
completeAndDismiss()
|
||||
}
|
||||
}
|
|
@ -4,13 +4,61 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
extension UIView {
|
||||
public func renderAsImage() -> UIImage? {
|
||||
return renderAsImage(opaque: false, scale: UIScreen.main.scale)
|
||||
}
|
||||
|
||||
public func renderAsImage(opaque: Bool, scale: CGFloat) -> UIImage? {
|
||||
if #available(iOS 10, *) {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = scale
|
||||
format.opaque = opaque
|
||||
let renderer = UIGraphicsImageRenderer(bounds: self.bounds,
|
||||
format: format)
|
||||
return renderer.image { (context) in
|
||||
self.layer.render(in: context.cgContext)
|
||||
}
|
||||
} else {
|
||||
UIGraphicsBeginImageContextWithOptions(bounds.size, opaque, scale)
|
||||
if let _ = UIGraphicsGetCurrentContext() {
|
||||
drawHierarchy(in: bounds, afterScreenUpdates: true)
|
||||
let image = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return image
|
||||
}
|
||||
owsFailDebug("Could not create graphics context.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EditorTextLayer: CATextLayer {
|
||||
let itemId: String
|
||||
|
||||
public init(itemId: String) {
|
||||
self.itemId = itemId
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "use other init() instead.")
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// A view for editing outgoing image attachments.
|
||||
// It can also be used to render the final output.
|
||||
@objc
|
||||
public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||
public class ImageEditorView: UIView, ImageEditorModelDelegate, ImageEditorTextViewControllerDelegate, UIGestureRecognizerDelegate {
|
||||
|
||||
private let model: ImageEditorModel
|
||||
|
||||
enum EditorMode: String {
|
||||
// This is the default mode. It is used for interacting with text items.
|
||||
case none
|
||||
case brush
|
||||
case crop
|
||||
|
@ -20,23 +68,11 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
didSet {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
switch editorMode {
|
||||
case .none:
|
||||
editorGestureRecognizer?.isEnabled = false
|
||||
case .brush:
|
||||
// Brush strokes can start and end (and return from) outside the view.
|
||||
editorGestureRecognizer?.shouldAllowOutsideView = true
|
||||
editorGestureRecognizer?.isEnabled = true
|
||||
case .crop:
|
||||
// Crop gestures can start and end (and return from) outside the view.
|
||||
editorGestureRecognizer?.shouldAllowOutsideView = true
|
||||
editorGestureRecognizer?.isEnabled = true
|
||||
}
|
||||
updateGestureState()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO:
|
||||
private static let defaultColor = UIColor.ows_signalBlue
|
||||
private static let defaultColor = UIColor.white
|
||||
private var currentColor = ImageEditorView.defaultColor
|
||||
|
||||
@objc
|
||||
|
@ -59,6 +95,8 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
private var imageViewConstraints = [NSLayoutConstraint]()
|
||||
private let layersView = OWSLayerView()
|
||||
private var editorGestureRecognizer: ImageEditorGestureRecognizer?
|
||||
private var tapGestureRecognizer: UITapGestureRecognizer?
|
||||
private var pinchGestureRecognizer: ImageEditorPinchGestureRecognizer?
|
||||
|
||||
@objc
|
||||
public func configureSubviews() -> Bool {
|
||||
|
@ -77,18 +115,50 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
|
||||
self.isUserInteractionEnabled = true
|
||||
layersView.isUserInteractionEnabled = true
|
||||
let editorGestureRecognizer = ImageEditorGestureRecognizer(target: self, action: #selector(handleTouchGesture(_:)))
|
||||
|
||||
let editorGestureRecognizer = ImageEditorGestureRecognizer(target: self, action: #selector(handleEditorGesture(_:)))
|
||||
editorGestureRecognizer.canvasView = layersView
|
||||
editorGestureRecognizer.delegate = self
|
||||
self.addGestureRecognizer(editorGestureRecognizer)
|
||||
self.editorGestureRecognizer = editorGestureRecognizer
|
||||
editorGestureRecognizer.isEnabled = false
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:)))
|
||||
self.addGestureRecognizer(tapGestureRecognizer)
|
||||
self.tapGestureRecognizer = tapGestureRecognizer
|
||||
|
||||
let pinchGestureRecognizer = ImageEditorPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
|
||||
self.addGestureRecognizer(pinchGestureRecognizer)
|
||||
self.pinchGestureRecognizer = pinchGestureRecognizer
|
||||
|
||||
// De-conflict the GRs.
|
||||
editorGestureRecognizer.require(toFail: tapGestureRecognizer)
|
||||
editorGestureRecognizer.require(toFail: pinchGestureRecognizer)
|
||||
|
||||
updateGestureState()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func commitTextEditingChanges(textItem: ImageEditorTextItem, textView: UITextView) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let text = textView.text?.ows_stripped(),
|
||||
text.count > 0 else {
|
||||
model.remove(item: textItem)
|
||||
return
|
||||
}
|
||||
|
||||
// Model items are immutable; we _replace_ the item rather than modify it.
|
||||
let newItem = textItem.copy(withText: text)
|
||||
if model.has(itemForId: textItem.itemId) {
|
||||
model.replace(item: newItem, suppressUndo: false)
|
||||
} else {
|
||||
model.append(item: newItem)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func updateImageView() -> Bool {
|
||||
Logger.verbose("")
|
||||
|
||||
guard let image = UIImage(contentsOfFile: model.currentImagePath) else {
|
||||
owsFailDebug("Could not load image")
|
||||
|
@ -129,6 +199,8 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
private let redoButton = UIButton(type: .custom)
|
||||
private let brushButton = UIButton(type: .custom)
|
||||
private let cropButton = UIButton(type: .custom)
|
||||
private let newTextButton = UIButton(type: .custom)
|
||||
private var allButtons = [UIButton]()
|
||||
|
||||
@objc
|
||||
public func addControls(to containerView: UIView) {
|
||||
|
@ -148,11 +220,17 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
label: NSLocalizedString("IMAGE_EDITOR_CROP_BUTTON", comment: "Label for crop button in image editor."),
|
||||
selector: #selector(didTapCrop(sender:)))
|
||||
|
||||
configure(button: newTextButton,
|
||||
label: "Text",
|
||||
selector: #selector(didTapNewText(sender:)))
|
||||
|
||||
let redButton = colorButton(color: UIColor.red)
|
||||
let whiteButton = colorButton(color: UIColor.white)
|
||||
let blackButton = colorButton(color: UIColor.black)
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [brushButton, cropButton, undoButton, redoButton, redButton, whiteButton, blackButton])
|
||||
allButtons = [brushButton, cropButton, undoButton, redoButton, newTextButton, redButton, whiteButton, blackButton]
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: allButtons)
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = 10
|
||||
|
@ -191,6 +269,11 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
redoButton.isEnabled = model.canRedo()
|
||||
brushButton.isSelected = editorMode == .brush
|
||||
cropButton.isSelected = editorMode == .crop
|
||||
newTextButton.isSelected = false
|
||||
|
||||
for button in allButtons {
|
||||
button.isHidden = isEditingTextItem
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
@ -225,6 +308,14 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
toggle(editorMode: .crop)
|
||||
}
|
||||
|
||||
@objc func didTapNewText(sender: UIButton) {
|
||||
Logger.verbose("")
|
||||
|
||||
let textItem = ImageEditorTextItem.empty(withColor: currentColor)
|
||||
|
||||
edit(textItem: textItem)
|
||||
}
|
||||
|
||||
func toggle(editorMode: EditorMode) {
|
||||
if self.editorMode == editorMode {
|
||||
self.editorMode = .none
|
||||
|
@ -240,12 +331,160 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
currentColor = color
|
||||
}
|
||||
|
||||
@objc
|
||||
public func handleTouchGesture(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
// MARK: - Gestures
|
||||
|
||||
private func updateGestureState() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
switch editorMode {
|
||||
case .none:
|
||||
editorGestureRecognizer?.shouldAllowOutsideView = true
|
||||
editorGestureRecognizer?.isEnabled = true
|
||||
tapGestureRecognizer?.isEnabled = true
|
||||
pinchGestureRecognizer?.isEnabled = true
|
||||
case .brush:
|
||||
// Brush strokes can start and end (and return from) outside the view.
|
||||
editorGestureRecognizer?.shouldAllowOutsideView = true
|
||||
editorGestureRecognizer?.isEnabled = true
|
||||
tapGestureRecognizer?.isEnabled = false
|
||||
pinchGestureRecognizer?.isEnabled = false
|
||||
case .crop:
|
||||
// Crop gestures can start and end (and return from) outside the view.
|
||||
editorGestureRecognizer?.shouldAllowOutsideView = true
|
||||
editorGestureRecognizer?.isEnabled = true
|
||||
tapGestureRecognizer?.isEnabled = false
|
||||
pinchGestureRecognizer?.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tap Gesture
|
||||
|
||||
@objc
|
||||
public func handleTapGesture(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard gestureRecognizer.state == .recognized else {
|
||||
owsFailDebug("Unexpected state.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let textLayer = textLayer(forGestureRecognizer: gestureRecognizer) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else {
|
||||
owsFailDebug("Missing or invalid text item.")
|
||||
return
|
||||
}
|
||||
|
||||
edit(textItem: textItem)
|
||||
}
|
||||
|
||||
private var isEditingTextItem = false {
|
||||
didSet {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
updateButtons()
|
||||
}
|
||||
}
|
||||
|
||||
private func edit(textItem: ImageEditorTextItem) {
|
||||
Logger.verbose("")
|
||||
|
||||
toggle(editorMode: .none)
|
||||
|
||||
guard let viewController = self.containingViewController() else {
|
||||
owsFailDebug("Can't find view controller.")
|
||||
return
|
||||
}
|
||||
|
||||
isEditingTextItem = true
|
||||
|
||||
let maxTextWidthPoints = imageView.width() * ImageEditorTextItem.kDefaultUnitWidth
|
||||
|
||||
let textEditor = ImageEditorTextViewController(delegate: self, textItem: textItem, maxTextWidthPoints: maxTextWidthPoints)
|
||||
let navigationController = OWSNavigationController(rootViewController: textEditor)
|
||||
navigationController.modalPresentationStyle = .overFullScreen
|
||||
viewController.present(navigationController, animated: true) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
// 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 gestureRecognizerView = gestureRecognizer.view else {
|
||||
owsFailDebug("Missing gestureRecognizer.view.")
|
||||
return
|
||||
}
|
||||
let location = gestureRecognizerView.convert(pinchState.centroid, to: unitReferenceView)
|
||||
guard let textLayer = textLayer(forLocation: location) 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 locationDelta = CGPointSubtract(gestureRecognizer.pinchStateLast.centroid,
|
||||
gestureRecognizer.pinchStateStart.centroid)
|
||||
let unitLocationDelta = convertToUnit(location: locationDelta, shouldClamp: false)
|
||||
let unitCenter = CGPointClamp01(CGPointAdd(textItem.unitCenter, unitLocationDelta))
|
||||
|
||||
// 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
|
||||
|
||||
@objc
|
||||
public func handleEditorGesture(_ gestureRecognizer: ImageEditorGestureRecognizer) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
switch editorMode {
|
||||
case .none:
|
||||
handleDefaultGesture(gestureRecognizer)
|
||||
break
|
||||
case .brush:
|
||||
handleBrushGesture(gestureRecognizer)
|
||||
|
@ -254,6 +493,65 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// These properties are valid while moving a text item.
|
||||
private var movingTextItem: ImageEditorTextItem?
|
||||
private var movingTextStartUnitLocation = CGPoint.zero
|
||||
private var movingTextStartUnitCenter = CGPoint.zero
|
||||
private var movingTextHasMoved = false
|
||||
|
||||
@objc
|
||||
public func handleDefaultGesture(_ gestureRecognizer: ImageEditorGestureRecognizer) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
// We could undo an in-progress move if the gesture is cancelled, but it seems gratuitous.
|
||||
|
||||
switch gestureRecognizer.state {
|
||||
case .began:
|
||||
guard let gestureRecognizerView = gestureRecognizer.view else {
|
||||
owsFailDebug("Missing gestureRecognizer.view.")
|
||||
return
|
||||
}
|
||||
let location = gestureRecognizerView.convert(gestureRecognizer.startLocationInView, to: unitReferenceView)
|
||||
guard let textLayer = textLayer(forLocation: location) else {
|
||||
owsFailDebug("No text layer")
|
||||
return
|
||||
}
|
||||
guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else {
|
||||
owsFailDebug("Missing or invalid text item.")
|
||||
return
|
||||
}
|
||||
movingTextStartUnitLocation = convertToUnit(location: location,
|
||||
shouldClamp: false)
|
||||
|
||||
movingTextItem = textItem
|
||||
movingTextStartUnitCenter = textItem.unitCenter
|
||||
movingTextHasMoved = false
|
||||
|
||||
case .changed, .ended:
|
||||
guard let textItem = movingTextItem else {
|
||||
return
|
||||
}
|
||||
|
||||
let unitLocation = unitSampleForGestureLocation(gestureRecognizer, shouldClamp: false)
|
||||
let unitLocationDelta = CGPointSubtract(unitLocation, movingTextStartUnitLocation)
|
||||
let unitCenter = CGPointClamp01(CGPointAdd(movingTextStartUnitCenter, unitLocationDelta))
|
||||
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.
|
||||
|
@ -320,13 +618,22 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
private var unitReferenceView: UIView {
|
||||
return layersView
|
||||
}
|
||||
|
||||
private func unitSampleForGestureLocation(_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldClamp: Bool) -> CGPoint {
|
||||
let referenceView = layersView
|
||||
// TODO: Smooth touch samples before converting into stroke samples.
|
||||
let location = gestureRecognizer.location(in: referenceView)
|
||||
var x = CGFloatInverseLerp(location.x, 0, referenceView.bounds.width)
|
||||
var y = CGFloatInverseLerp(location.y, 0, referenceView.bounds.height)
|
||||
let location = gestureRecognizer.location(in: unitReferenceView)
|
||||
return convertToUnit(location: location,
|
||||
shouldClamp: shouldClamp)
|
||||
}
|
||||
|
||||
private func convertToUnit(location: CGPoint,
|
||||
shouldClamp: Bool) -> CGPoint {
|
||||
var x = CGFloatInverseLerp(location.x, 0, unitReferenceView.bounds.width)
|
||||
var y = CGFloatInverseLerp(location.y, 0, unitReferenceView.bounds.height)
|
||||
if shouldClamp {
|
||||
x = CGFloatClamp01(x)
|
||||
y = CGFloatClamp01(y)
|
||||
|
@ -565,6 +872,12 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
return nil
|
||||
}
|
||||
return strokeLayerForItem(item: strokeItem, viewSize: viewSize)
|
||||
case .text:
|
||||
guard let textItem = item as? ImageEditorTextItem else {
|
||||
owsFailDebug("Item has unexpected type: \(type(of: item)).")
|
||||
return nil
|
||||
}
|
||||
return textLayerForItem(item: textItem, viewSize: viewSize)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -658,6 +971,50 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
return shapeLayer
|
||||
}
|
||||
|
||||
private class func textLayerForItem(item: ImageEditorTextItem,
|
||||
viewSize: CGSize) -> CALayer? {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let layer = EditorTextLayer(itemId: item.itemId)
|
||||
layer.string = item.text
|
||||
layer.foregroundColor = item.color.cgColor
|
||||
layer.font = CGFont(item.font.fontName as CFString)
|
||||
layer.fontSize = item.font.pointSize
|
||||
layer.isWrapped = true
|
||||
layer.alignmentMode = kCAAlignmentCenter
|
||||
// I don't think we need to enable allowsFontSubpixelQuantization
|
||||
// or set truncationMode.
|
||||
|
||||
// This text needs to be rendered at a scale that reflects the scaling.
|
||||
layer.contentsScale = UIScreen.main.scale * item.scaling
|
||||
|
||||
// TODO: Min with measured width.
|
||||
let maxWidth = viewSize.width * item.unitWidth
|
||||
let maxSize = CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)
|
||||
// TODO: Is there a more accurate way to measure text in a CATextLayer?
|
||||
// CoreText?
|
||||
let textBounds = (item.text as NSString).boundingRect(with: maxSize,
|
||||
options: [
|
||||
.usesLineFragmentOrigin,
|
||||
.usesFontLeading
|
||||
],
|
||||
attributes: [
|
||||
.font: item.font
|
||||
],
|
||||
context: nil)
|
||||
let center = CGPoint(x: viewSize.width * item.unitCenter.x,
|
||||
y: viewSize.height * item.unitCenter.y)
|
||||
let layerSize = CGSizeCeil(textBounds.size)
|
||||
layer.frame = CGRect(origin: CGPoint(x: center.x - layerSize.width * 0.5,
|
||||
y: center.y - layerSize.height * 0.5),
|
||||
size: layerSize)
|
||||
|
||||
let transform = CGAffineTransform.identity.scaledBy(x: item.scaling, y: item.scaling).rotated(by: item.rotationRadians)
|
||||
layer.setAffineTransform(transform)
|
||||
|
||||
return layer
|
||||
}
|
||||
|
||||
// We apply more than one kind of smoothing.
|
||||
//
|
||||
// This (simple) smoothing reduces jitter from the touch sensor.
|
||||
|
@ -700,6 +1057,7 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
|
||||
// Render output at same size as source image.
|
||||
let dstSizePixels = model.srcImageSizePixels
|
||||
let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points.
|
||||
|
||||
let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.currentImagePath)
|
||||
|
||||
|
@ -708,37 +1066,94 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
|||
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)
|
||||
|
||||
// We use an UIImageView + UIView.renderAsImage() instead of a CGGraphicsContext
|
||||
// Because CALayer.renderInContext() doesn't honor CALayer properties like frame,
|
||||
// transform, etc.
|
||||
let imageView = UIImageView(image: srcImage)
|
||||
imageView.frame = CGRect(origin: .zero, size: dstSizePixels)
|
||||
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)
|
||||
layer.contentsScale = dstScale * item.outputScale()
|
||||
imageView.layer.addSublayer(layer)
|
||||
}
|
||||
let image = imageView.renderAsImage(opaque: !hasAlpha, scale: dstScale)
|
||||
return image
|
||||
}
|
||||
|
||||
let scaledImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||
if scaledImage == nil {
|
||||
owsFailDebug("could not generate dst image.")
|
||||
// MARK: - ImageEditorTextViewControllerDelegate
|
||||
|
||||
public func textEditDidComplete(textItem: ImageEditorTextItem, text: String?) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
isEditingTextItem = false
|
||||
|
||||
guard let text = text?.ows_stripped(),
|
||||
text.count > 0 else {
|
||||
if model.has(itemForId: textItem.itemId) {
|
||||
model.remove(item: textItem)
|
||||
}
|
||||
return scaledImage
|
||||
return
|
||||
}
|
||||
|
||||
// Model items are immutable; we _replace_ the item rather than modify it.
|
||||
let newItem = textItem.copy(withText: text)
|
||||
if model.has(itemForId: textItem.itemId) {
|
||||
model.replace(item: newItem, suppressUndo: false)
|
||||
} else {
|
||||
model.append(item: newItem)
|
||||
}
|
||||
}
|
||||
|
||||
public func textEditDidCancel() {
|
||||
isEditingTextItem = false
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
@objc public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard let editorGestureRecognizer = editorGestureRecognizer else {
|
||||
owsFailDebug("Missing editorGestureRecognizer.")
|
||||
return false
|
||||
}
|
||||
guard editorGestureRecognizer == gestureRecognizer else {
|
||||
owsFailDebug("Unexpected gesture.")
|
||||
return false
|
||||
}
|
||||
guard editorMode == .none else {
|
||||
// We only filter touches when in default mode.
|
||||
return true
|
||||
}
|
||||
|
||||
let isInTextArea = textLayer(forTouch: touch) != nil
|
||||
return isInTextArea
|
||||
}
|
||||
|
||||
private func textLayer(forTouch touch: UITouch) -> EditorTextLayer? {
|
||||
let point = touch.location(in: layersView)
|
||||
return textLayer(forLocation: point)
|
||||
}
|
||||
|
||||
private func textLayer(forGestureRecognizer gestureRecognizer: UIGestureRecognizer) -> EditorTextLayer? {
|
||||
let point = gestureRecognizer.location(in: layersView)
|
||||
return textLayer(forLocation: point)
|
||||
}
|
||||
|
||||
private func textLayer(forLocation point: CGPoint) -> EditorTextLayer? {
|
||||
guard let sublayers = layersView.layer.sublayers else {
|
||||
return nil
|
||||
}
|
||||
for layer in sublayers {
|
||||
guard let textLayer = layer as? EditorTextLayer else {
|
||||
continue
|
||||
}
|
||||
if textLayer.hitTest(point) != nil {
|
||||
return textLayer
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -117,6 +117,8 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value);
|
|||
|
||||
+ (UIView *)verticalStackWithSubviews:(NSArray<UIView *> *)subviews spacing:(int)spacing;
|
||||
|
||||
- (nullable UIViewController *)containingViewController;
|
||||
|
||||
#pragma mark - Debugging
|
||||
|
||||
- (void)addBorderWithColor:(UIColor *)color;
|
||||
|
|
|
@ -448,6 +448,19 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value)
|
|||
return container;
|
||||
}
|
||||
|
||||
- (nullable UIViewController *)containingViewController
|
||||
{
|
||||
UIResponder *responder = self;
|
||||
while (responder) {
|
||||
if ([responder isKindOfClass:[UIViewController class]]) {
|
||||
UIViewController *viewController = (UIViewController *)responder;
|
||||
return viewController;
|
||||
}
|
||||
responder = responder.nextResponder;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
#pragma mark - Debugging
|
||||
|
||||
- (void)addBorderWithColor:(UIColor *)color
|
||||
|
|
Loading…
Reference in a new issue