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
|
@ -228,6 +228,8 @@
|
||||||
34B6A90B218BA1D1007C4606 /* typing-animation.gif in Resources */ = {isa = PBXBuildFile; fileRef = 34B6A90A218BA1D0007C4606 /* typing-animation.gif */; };
|
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, ); }; };
|
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 */; };
|
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 */; };
|
34BECE2B1F74C12700D7438D /* DebugUIStress.m in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2A1F74C12700D7438D /* DebugUIStress.m */; };
|
||||||
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; };
|
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; };
|
||||||
34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2F1F7ABCF800D7438D /* GifPickerLayout.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1866,6 +1870,8 @@
|
||||||
children = (
|
children = (
|
||||||
34BEDB1821C82AC5007B0EAE /* ImageEditorGestureRecognizer.swift */,
|
34BEDB1821C82AC5007B0EAE /* ImageEditorGestureRecognizer.swift */,
|
||||||
34BEDB0D21C405B0007B0EAE /* ImageEditorModel.swift */,
|
34BEDB0D21C405B0007B0EAE /* ImageEditorModel.swift */,
|
||||||
|
34BBC84C220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift */,
|
||||||
|
34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */,
|
||||||
34BEDB1221C43F69007B0EAE /* ImageEditorView.swift */,
|
34BEDB1221C43F69007B0EAE /* ImageEditorView.swift */,
|
||||||
);
|
);
|
||||||
path = ImageEditor;
|
path = ImageEditor;
|
||||||
|
@ -3340,6 +3346,7 @@
|
||||||
347850691FD9B78A007B8332 /* AppSetup.m in Sources */,
|
347850691FD9B78A007B8332 /* AppSetup.m in Sources */,
|
||||||
346941A3215D2EE400B5BFAD /* Theme.m in Sources */,
|
346941A3215D2EE400B5BFAD /* Theme.m in Sources */,
|
||||||
4C23A5F2215C4ADE00534937 /* SheetViewController.swift in Sources */,
|
4C23A5F2215C4ADE00534937 /* SheetViewController.swift in Sources */,
|
||||||
|
34BBC84D220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift in Sources */,
|
||||||
34AC0A14211B39EA00997B47 /* ContactCellView.m in Sources */,
|
34AC0A14211B39EA00997B47 /* ContactCellView.m in Sources */,
|
||||||
34AC0A15211B39EA00997B47 /* ContactsViewHelper.m in Sources */,
|
34AC0A15211B39EA00997B47 /* ContactsViewHelper.m in Sources */,
|
||||||
346129FF1FD5F31400532771 /* OWS103EnableVideoCalling.m in Sources */,
|
346129FF1FD5F31400532771 /* OWS103EnableVideoCalling.m in Sources */,
|
||||||
|
@ -3361,6 +3368,7 @@
|
||||||
4598198F204E2F28009414F2 /* OWS108CallLoggingPreference.m in Sources */,
|
4598198F204E2F28009414F2 /* OWS108CallLoggingPreference.m in Sources */,
|
||||||
34AC09F3211B39B100997B47 /* NewNonContactConversationViewController.m in Sources */,
|
34AC09F3211B39B100997B47 /* NewNonContactConversationViewController.m in Sources */,
|
||||||
4C3E245C21F29FCE000AE092 /* Toast.swift in Sources */,
|
4C3E245C21F29FCE000AE092 /* Toast.swift in Sources */,
|
||||||
|
34BBC84B220B2CB200857249 /* ImageEditorTextViewController.swift in Sources */,
|
||||||
34AC09FA211B39B100997B47 /* SharingThreadPickerViewController.m in Sources */,
|
34AC09FA211B39B100997B47 /* SharingThreadPickerViewController.m in Sources */,
|
||||||
45F59A082028E4FB00E8D2B0 /* OWSAudioSession.swift in Sources */,
|
45F59A082028E4FB00E8D2B0 /* OWSAudioSession.swift in Sources */,
|
||||||
34612A071FD7238600532771 /* OWSSyncManager.m in Sources */,
|
34612A071FD7238600532771 /* OWSSyncManager.m in Sources */,
|
||||||
|
|
|
@ -1317,6 +1317,8 @@ extension AttachmentPrepViewController: UIScrollViewDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: -
|
||||||
|
|
||||||
class BottomToolView: UIView {
|
class BottomToolView: UIView {
|
||||||
let mediaMessageTextToolbar: MediaMessageTextToolbar
|
let mediaMessageTextToolbar: MediaMessageTextToolbar
|
||||||
let galleryRailView: GalleryRailView
|
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
|
import UIKit
|
||||||
|
|
||||||
class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
public class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public var shouldAllowOutsideView = true
|
public var shouldAllowOutsideView = true
|
||||||
|
@ -13,42 +13,56 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
||||||
public weak var canvasView: UIView?
|
public weak var canvasView: UIView?
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
|
public var startLocationInView: CGPoint = .zero
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
override func canBePrevented(by: UIGestureRecognizer) -> Bool {
|
public override func canBePrevented(by: UIGestureRecognizer) -> Bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
override func shouldRequireFailure(of: UIGestureRecognizer) -> Bool {
|
public override func shouldRequireFailure(of: UIGestureRecognizer) -> Bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
override func shouldBeRequiredToFail(by: UIGestureRecognizer) -> Bool {
|
public override func shouldBeRequiredToFail(by: UIGestureRecognizer) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Touch Handling
|
// MARK: - Touch Handling
|
||||||
|
|
||||||
@objc
|
@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)
|
super.touchesBegan(touches, with: event)
|
||||||
|
|
||||||
if state == .possible,
|
if state == .possible,
|
||||||
touchType(for: touches, with: event) == .valid {
|
touchType(for: touches, with: event) == .valid {
|
||||||
// If a gesture starts with a valid touch, begin stroke.
|
// If a gesture starts with a valid touch, begin stroke.
|
||||||
state = .began
|
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 {
|
} else {
|
||||||
state = .failed
|
state = .failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@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)
|
super.touchesMoved(touches, with: event)
|
||||||
|
|
||||||
switch state {
|
switch state {
|
||||||
|
@ -70,7 +84,7 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@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)
|
super.touchesEnded(touches, with: event)
|
||||||
|
|
||||||
switch state {
|
switch state {
|
||||||
|
@ -88,7 +102,7 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@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)
|
super.touchesCancelled(touches, with: event)
|
||||||
|
|
||||||
state = .cancelled
|
state = .cancelled
|
||||||
|
|
|
@ -13,10 +13,30 @@ import UIKit
|
||||||
public enum ImageEditorItemType: Int {
|
public enum ImageEditorItemType: Int {
|
||||||
case test
|
case test
|
||||||
case stroke
|
case stroke
|
||||||
|
case text
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -
|
// 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
|
// Instances of ImageEditorItem should be treated
|
||||||
// as immutable, once configured.
|
// as immutable, once configured.
|
||||||
@objc
|
@objc
|
||||||
|
@ -44,11 +64,13 @@ public class ImageEditorItem: NSObject {
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
public typealias PointConversionFunction = (CGPoint) -> CGPoint
|
public func clone(withImageEditorConversion conversion: ImageEditorConversion) -> ImageEditorItem {
|
||||||
|
|
||||||
public func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem {
|
|
||||||
return ImageEditorItem(itemId: itemId, itemType: itemType)
|
return ImageEditorItem(itemId: itemId, itemType: itemType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func outputScale() -> CGFloat {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
@ -60,20 +82,7 @@ public class ImageEditorStrokeItem: ImageEditorItem {
|
||||||
@objc
|
@objc
|
||||||
public let color: UIColor
|
public let color: UIColor
|
||||||
|
|
||||||
// Represented in a "ULO unit" coordinate system
|
public typealias StrokeSample = ImageEditorSample
|
||||||
// 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
|
@objc
|
||||||
public let unitSamples: [StrokeSample]
|
public let unitSamples: [StrokeSample]
|
||||||
|
@ -117,7 +126,7 @@ public class ImageEditorStrokeItem: ImageEditorItem {
|
||||||
return CGFloatClamp01(unitStrokeWidth) * min(dstSize.width, dstSize.height)
|
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.
|
// TODO: We might want to convert the unitStrokeWidth too.
|
||||||
let convertedUnitSamples = unitSamples.map { (sample) in
|
let convertedUnitSamples = unitSamples.map { (sample) in
|
||||||
conversion(sample)
|
conversion(sample)
|
||||||
|
@ -131,6 +140,159 @@ public class ImageEditorStrokeItem: ImageEditorItem {
|
||||||
|
|
||||||
// MARK: -
|
// 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 class OrderedDictionary<ValueType>: NSObject {
|
||||||
|
|
||||||
public typealias KeyType = String
|
public typealias KeyType = String
|
||||||
|
@ -422,6 +584,11 @@ public class ImageEditorModel: NSObject {
|
||||||
return contents.items()
|
return contents.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public func has(itemForId itemId: String) -> Bool {
|
||||||
|
return item(forId: itemId) != nil
|
||||||
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public func item(forId itemId: String) -> ImageEditorItem? {
|
public func item(forId itemId: String) -> ImageEditorItem? {
|
||||||
return contents.item(forId: itemId)
|
return contents.item(forId: itemId)
|
||||||
|
@ -559,7 +726,7 @@ public class ImageEditorModel: NSObject {
|
||||||
let right = unitCropRect.origin.x + unitCropRect.size.width
|
let right = unitCropRect.origin.x + unitCropRect.size.width
|
||||||
let top = unitCropRect.origin.y
|
let top = unitCropRect.origin.y
|
||||||
let bottom = unitCropRect.origin.y + unitCropRect.size.height
|
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
|
// Convert from the pre-crop unit coordinate system
|
||||||
// to post-crop unit coordinate system using inverse
|
// to post-crop unit coordinate system using inverse
|
||||||
// lerp.
|
// lerp.
|
||||||
|
@ -580,7 +747,7 @@ public class ImageEditorModel: NSObject {
|
||||||
let newContents = ImageEditorContents(imagePath: croppedImagePath,
|
let newContents = ImageEditorContents(imagePath: croppedImagePath,
|
||||||
imageSizePixels: croppedImageSizePixels)
|
imageSizePixels: croppedImageSizePixels)
|
||||||
for oldItem in oldContents.items() {
|
for oldItem in oldContents.items() {
|
||||||
let newItem = oldItem.clone(withPointConversionFunction: conversion)
|
let newItem = oldItem.clone(withImageEditorConversion: conversion)
|
||||||
newContents.append(item: newItem)
|
newContents.append(item: newItem)
|
||||||
}
|
}
|
||||||
return newContents
|
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
|
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.
|
// A view for editing outgoing image attachments.
|
||||||
// It can also be used to render the final output.
|
// It can also be used to render the final output.
|
||||||
@objc
|
@objc
|
||||||
public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
public class ImageEditorView: UIView, ImageEditorModelDelegate, ImageEditorTextViewControllerDelegate, UIGestureRecognizerDelegate {
|
||||||
|
|
||||||
private let model: ImageEditorModel
|
private let model: ImageEditorModel
|
||||||
|
|
||||||
enum EditorMode: String {
|
enum EditorMode: String {
|
||||||
|
// This is the default mode. It is used for interacting with text items.
|
||||||
case none
|
case none
|
||||||
case brush
|
case brush
|
||||||
case crop
|
case crop
|
||||||
|
@ -20,23 +68,11 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
didSet {
|
didSet {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
switch editorMode {
|
updateGestureState()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO:
|
private static let defaultColor = UIColor.white
|
||||||
private static let defaultColor = UIColor.ows_signalBlue
|
|
||||||
private var currentColor = ImageEditorView.defaultColor
|
private var currentColor = ImageEditorView.defaultColor
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
|
@ -59,6 +95,8 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
private var imageViewConstraints = [NSLayoutConstraint]()
|
private var imageViewConstraints = [NSLayoutConstraint]()
|
||||||
private let layersView = OWSLayerView()
|
private let layersView = OWSLayerView()
|
||||||
private var editorGestureRecognizer: ImageEditorGestureRecognizer?
|
private var editorGestureRecognizer: ImageEditorGestureRecognizer?
|
||||||
|
private var tapGestureRecognizer: UITapGestureRecognizer?
|
||||||
|
private var pinchGestureRecognizer: ImageEditorPinchGestureRecognizer?
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public func configureSubviews() -> Bool {
|
public func configureSubviews() -> Bool {
|
||||||
|
@ -77,18 +115,50 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
|
|
||||||
self.isUserInteractionEnabled = true
|
self.isUserInteractionEnabled = true
|
||||||
layersView.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.canvasView = layersView
|
||||||
|
editorGestureRecognizer.delegate = self
|
||||||
self.addGestureRecognizer(editorGestureRecognizer)
|
self.addGestureRecognizer(editorGestureRecognizer)
|
||||||
self.editorGestureRecognizer = 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
|
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
|
@objc
|
||||||
public func updateImageView() -> Bool {
|
public func updateImageView() -> Bool {
|
||||||
Logger.verbose("")
|
|
||||||
|
|
||||||
guard let image = UIImage(contentsOfFile: model.currentImagePath) else {
|
guard let image = UIImage(contentsOfFile: model.currentImagePath) else {
|
||||||
owsFailDebug("Could not load image")
|
owsFailDebug("Could not load image")
|
||||||
|
@ -129,6 +199,8 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
private let redoButton = UIButton(type: .custom)
|
private let redoButton = UIButton(type: .custom)
|
||||||
private let brushButton = UIButton(type: .custom)
|
private let brushButton = UIButton(type: .custom)
|
||||||
private let cropButton = UIButton(type: .custom)
|
private let cropButton = UIButton(type: .custom)
|
||||||
|
private let newTextButton = UIButton(type: .custom)
|
||||||
|
private var allButtons = [UIButton]()
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public func addControls(to containerView: UIView) {
|
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."),
|
label: NSLocalizedString("IMAGE_EDITOR_CROP_BUTTON", comment: "Label for crop button in image editor."),
|
||||||
selector: #selector(didTapCrop(sender:)))
|
selector: #selector(didTapCrop(sender:)))
|
||||||
|
|
||||||
|
configure(button: newTextButton,
|
||||||
|
label: "Text",
|
||||||
|
selector: #selector(didTapNewText(sender:)))
|
||||||
|
|
||||||
let redButton = colorButton(color: UIColor.red)
|
let redButton = colorButton(color: UIColor.red)
|
||||||
let whiteButton = colorButton(color: UIColor.white)
|
let whiteButton = colorButton(color: UIColor.white)
|
||||||
let blackButton = colorButton(color: UIColor.black)
|
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.axis = .vertical
|
||||||
stackView.alignment = .center
|
stackView.alignment = .center
|
||||||
stackView.spacing = 10
|
stackView.spacing = 10
|
||||||
|
@ -191,6 +269,11 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
redoButton.isEnabled = model.canRedo()
|
redoButton.isEnabled = model.canRedo()
|
||||||
brushButton.isSelected = editorMode == .brush
|
brushButton.isSelected = editorMode == .brush
|
||||||
cropButton.isSelected = editorMode == .crop
|
cropButton.isSelected = editorMode == .crop
|
||||||
|
newTextButton.isSelected = false
|
||||||
|
|
||||||
|
for button in allButtons {
|
||||||
|
button.isHidden = isEditingTextItem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
@ -225,6 +308,14 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
toggle(editorMode: .crop)
|
toggle(editorMode: .crop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func didTapNewText(sender: UIButton) {
|
||||||
|
Logger.verbose("")
|
||||||
|
|
||||||
|
let textItem = ImageEditorTextItem.empty(withColor: currentColor)
|
||||||
|
|
||||||
|
edit(textItem: textItem)
|
||||||
|
}
|
||||||
|
|
||||||
func toggle(editorMode: EditorMode) {
|
func toggle(editorMode: EditorMode) {
|
||||||
if self.editorMode == editorMode {
|
if self.editorMode == editorMode {
|
||||||
self.editorMode = .none
|
self.editorMode = .none
|
||||||
|
@ -240,12 +331,160 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
currentColor = color
|
currentColor = color
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
// MARK: - Gestures
|
||||||
public func handleTouchGesture(_ gestureRecognizer: UIGestureRecognizer) {
|
|
||||||
|
private func updateGestureState() {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
switch editorMode {
|
switch editorMode {
|
||||||
case .none:
|
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
|
break
|
||||||
case .brush:
|
case .brush:
|
||||||
handleBrushGesture(gestureRecognizer)
|
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
|
// MARK: - Brush
|
||||||
|
|
||||||
// These properties are non-empty while drawing a stroke.
|
// These properties are non-empty while drawing a stroke.
|
||||||
|
@ -274,7 +572,7 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
let tryToAppendStrokeSample = {
|
let tryToAppendStrokeSample = {
|
||||||
let newSample = self.unitSampleForGestureLocation(gestureRecognizer, shouldClamp: false)
|
let newSample = self.unitSampleForGestureLocation(gestureRecognizer, shouldClamp: false)
|
||||||
if let prevSample = self.currentStrokeSamples.last,
|
if let prevSample = self.currentStrokeSamples.last,
|
||||||
prevSample == newSample {
|
prevSample == newSample {
|
||||||
// Ignore duplicate samples.
|
// Ignore duplicate samples.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -320,13 +618,22 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var unitReferenceView: UIView {
|
||||||
|
return layersView
|
||||||
|
}
|
||||||
|
|
||||||
private func unitSampleForGestureLocation(_ gestureRecognizer: UIGestureRecognizer,
|
private func unitSampleForGestureLocation(_ gestureRecognizer: UIGestureRecognizer,
|
||||||
shouldClamp: Bool) -> CGPoint {
|
shouldClamp: Bool) -> CGPoint {
|
||||||
let referenceView = layersView
|
|
||||||
// TODO: Smooth touch samples before converting into stroke samples.
|
// TODO: Smooth touch samples before converting into stroke samples.
|
||||||
let location = gestureRecognizer.location(in: referenceView)
|
let location = gestureRecognizer.location(in: unitReferenceView)
|
||||||
var x = CGFloatInverseLerp(location.x, 0, referenceView.bounds.width)
|
return convertToUnit(location: location,
|
||||||
var y = CGFloatInverseLerp(location.y, 0, referenceView.bounds.height)
|
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 {
|
if shouldClamp {
|
||||||
x = CGFloatClamp01(x)
|
x = CGFloatClamp01(x)
|
||||||
y = CGFloatClamp01(y)
|
y = CGFloatClamp01(y)
|
||||||
|
@ -565,6 +872,12 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return strokeLayerForItem(item: strokeItem, viewSize: viewSize)
|
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
|
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.
|
// We apply more than one kind of smoothing.
|
||||||
//
|
//
|
||||||
// This (simple) smoothing reduces jitter from the touch sensor.
|
// 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.
|
// Render output at same size as source image.
|
||||||
let dstSizePixels = model.srcImageSizePixels
|
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)
|
let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.currentImagePath)
|
||||||
|
|
||||||
|
@ -708,37 +1066,94 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points.
|
// We use an UIImageView + UIView.renderAsImage() instead of a CGGraphicsContext
|
||||||
UIGraphicsBeginImageContextWithOptions(dstSizePixels, !hasAlpha, dstScale)
|
// Because CALayer.renderInContext() doesn't honor CALayer properties like frame,
|
||||||
defer { UIGraphicsEndImageContext() }
|
// transform, etc.
|
||||||
|
let imageView = UIImageView(image: srcImage)
|
||||||
guard let context = UIGraphicsGetCurrentContext() else {
|
imageView.frame = CGRect(origin: .zero, size: dstSizePixels)
|
||||||
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() {
|
for item in model.items() {
|
||||||
guard let layer = layerForItem(item: item,
|
guard let layer = layerForItem(item: item,
|
||||||
viewSize: dstSizePixels) else {
|
viewSize: dstSizePixels) else {
|
||||||
Logger.error("Couldn't create layer for item.")
|
Logger.error("Couldn't create layer for item.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
layer.contentsScale = dstScale * item.outputScale()
|
||||||
|
imageView.layer.addSublayer(layer)
|
||||||
|
}
|
||||||
|
let image = imageView.renderAsImage(opaque: !hasAlpha, scale: dstScale)
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
// This might be superfluous, but ensure that the layer renders
|
if textLayer.hitTest(point) != nil {
|
||||||
// at "point=pixel" scale.
|
return textLayer
|
||||||
layer.contentsScale = 1.0
|
}
|
||||||
|
|
||||||
layer.render(in: context)
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
let scaledImage = UIGraphicsGetImageFromCurrentImageContext()
|
|
||||||
if scaledImage == nil {
|
|
||||||
owsFailDebug("could not generate dst image.")
|
|
||||||
}
|
|
||||||
return scaledImage
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -117,6 +117,8 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value);
|
||||||
|
|
||||||
+ (UIView *)verticalStackWithSubviews:(NSArray<UIView *> *)subviews spacing:(int)spacing;
|
+ (UIView *)verticalStackWithSubviews:(NSArray<UIView *> *)subviews spacing:(int)spacing;
|
||||||
|
|
||||||
|
- (nullable UIViewController *)containingViewController;
|
||||||
|
|
||||||
#pragma mark - Debugging
|
#pragma mark - Debugging
|
||||||
|
|
||||||
- (void)addBorderWithColor:(UIColor *)color;
|
- (void)addBorderWithColor:(UIColor *)color;
|
||||||
|
|
|
@ -448,6 +448,19 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value)
|
||||||
return container;
|
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
|
#pragma mark - Debugging
|
||||||
|
|
||||||
- (void)addBorderWithColor:(UIColor *)color
|
- (void)addBorderWithColor:(UIColor *)color
|
||||||
|
|
Loading…
Reference in a new issue