// // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit protocol ImageEditorViewDelegate: AnyObject { func imageEditorView(_ imageEditorView: ImageEditorView, didRequestAddTextItem textItem: ImageEditorTextItem) func imageEditorView(_ imageEditorView: ImageEditorView, didTapTextItem textItem: ImageEditorTextItem) func imageEditorView(_ imageEditorView: ImageEditorView, didMoveTextItem textItem: ImageEditorTextItem) func imageEditorViewDidUpdateSelection(_ imageEditorView: ImageEditorView) func imageEditorDidRequestToolbarVisibilityUpdate(_ imageEditorView: ImageEditorView) } // MARK: - // A view for editing outgoing image attachments. // It can also be used to render the final output. class ImageEditorView: UIView { weak var delegate: ImageEditorViewDelegate? let model: ImageEditorModel let canvasView: ImageEditorCanvasView private let trashViewSize: CGFloat = 42 private lazy var trashView: UIView = { let backgroundView = UIView() backgroundView.layoutMargins = .init(margin: 9) let image = UIImage(named: "trash") let imageView = UIImageView(image: image) imageView.tintColor = .white imageView.contentMode = .scaleAspectFill imageView.isUserInteractionEnabled = false backgroundView.layer.cornerRadius = trashViewSize / 2 backgroundView.backgroundColor = .ows_blackAlpha40 backgroundView.isUserInteractionEnabled = false backgroundView.addSubview(imageView) imageView.autoPinEdgesToSuperviewMargins() return backgroundView }() private var isTrashShowing: Bool { get { trashView.alpha > 0 } set { trashView.alpha = newValue ? 1 : 0 } } private var isHoveringOverTrash = false { didSet { guard isHoveringOverTrash != oldValue else { return } updateTrash(isHoveringOverTrash: isHoveringOverTrash) } } init(model: ImageEditorModel, delegate: ImageEditorViewDelegate?) { self.model = model self.delegate = delegate self.canvasView = ImageEditorCanvasView(model: model) super.init(frame: .zero) model.add(observer: self) } @available(*, unavailable, message: "use other init() instead.") required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Views private lazy var moveTextGestureRecognizer: ImageEditorPanGestureRecognizer = { let gestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleMoveTextGesture(_:))) gestureRecognizer.maximumNumberOfTouches = 1 gestureRecognizer.referenceView = gestureReferenceView gestureRecognizer.delegate = self return gestureRecognizer }() private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) private lazy var pinchGestureRecognizer: ImageEditorPinchGestureRecognizer = { let gestureRecognizer = ImageEditorPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) gestureRecognizer.referenceView = gestureReferenceView return gestureRecognizer }() func configureSubviews() { canvasView.configureSubviews() addSubview(canvasView) canvasView.autoPinEdgesToSuperviewEdges() canvasView.contentView.addSubview(trashView) // Center trash view instead of aligning the bottom so that it // resizes from the center when hovering over it. // 20 spacing to bottom + half the height for the center point. let distanceFromCenterToBottom = 20 + trashViewSize / 2 trashView.centerYAnchor.constraint( equalTo: canvasView.contentView.bottomAnchor, constant: -distanceFromCenterToBottom ) .isActive = true trashView.autoHCenterInSuperview() trashView.autoSetDimensions(to: .square(trashViewSize)) trashView.layer.zPosition = ImageEditorCanvasView.trashLazerZ isTrashShowing = false addGestureRecognizer(moveTextGestureRecognizer) addGestureRecognizer(tapGestureRecognizer) addGestureRecognizer(pinchGestureRecognizer) updateGestureRecognizers() let doubleTapGesture = UITapGestureRecognizer(target: nil, action: nil) doubleTapGesture.numberOfTapsRequired = 2 addGestureRecognizer(doubleTapGesture) tapGestureRecognizer.require(toFail: doubleTapGesture) } private func updateGestureRecognizers() { // Remove all gesture recognizers when interaction with text objects is disabled // so that they don't interfere with gesture recognizers added in view controller. moveTextGestureRecognizer.isEnabled = textInteractionModes.contains(.move) tapGestureRecognizer.isEnabled = textInteractionModes.contains(.tap) pinchGestureRecognizer.isEnabled = textInteractionModes.contains(.resize) } final var gestureReferenceView: UIView { canvasView.gestureReferenceView } // MARK: - Navigation Bar private func updateControls() { delegate?.imageEditorDidRequestToolbarVisibilityUpdate(self) let shouldShowTrash: Bool switch movingItem { case is ImageEditorStickerItem, is ImageEditorTextItem: shouldShowTrash = true default: shouldShowTrash = false } guard shouldShowTrash != isTrashShowing else { return } UIView.animate(withDuration: 0.15) { self.isTrashShowing = shouldShowTrash } } private func updateTrash(isHoveringOverTrash: Bool) { canvasView.shouldFadeTransformableItem = isHoveringOverTrash UIView.animate(withDuration: 0.15) { self.trashView.transform = isHoveringOverTrash ? .scale(4/3) : .identity } if isHoveringOverTrash { ImpactHapticFeedback.impactOccurred(style: .light) } } var shouldHideControls: Bool { // Hide controls during "text item move". return movingItem != nil } struct TextInteractionModes: OptionSet { let rawValue: Int static let tap = TextInteractionModes(rawValue: 1 << 0) static let select = TextInteractionModes(rawValue: 1 << 1 | 1 << 0) // "select" requires "tap" to be supported static let move = TextInteractionModes(rawValue: 1 << 2) static let resize = TextInteractionModes(rawValue: 1 << 3) static let all: TextInteractionModes = [ .tap, .select, .move, .resize ] } var textInteractionModes: TextInteractionModes = [] { didSet { updateGestureRecognizers() } } // MARK: - Tap Gesture var selectedTransformableItemID: String? { get { canvasView.selectedTransformableItemID } set { let newValueIsDifferent = canvasView.selectedTransformableItemID != newValue canvasView.selectedTransformableItemID = newValue // Update the tooltip when a new item is selected. // Dragging a sticker hides the tooltip, so avoid // showing it if it was selected by a drag. if newValueIsDifferent && movingItem == nil { canvasView.updateTooltip() } } } func updateSelectedTextItem(withColor color: ColorPickerBarColor) { if let selectedTextItemId = selectedTransformableItemID, let textItem = model.item(forId: selectedTextItemId) as? ImageEditorTextItem { let newTextItem = textItem.copy(color: color) model.replace(item: newTextItem) } } func createNewTextItem(withColor color: ColorPickerBarColor = ColorPickerBarColor.white, textStyle: MediaTextView.TextStyle = .regular, decorationStyle: MediaTextView.DecorationStyle = .none) -> ImageEditorTextItem { let viewSize = canvasView.gestureReferenceView.bounds.size let imageSize = model.srcImageSizePixels let imageFrame = ImageEditorCanvasView.imageFrame(forViewSize: viewSize, imageSize: imageSize, transform: model.currentTransform()) let textWidthPoints = viewSize.width * ImageEditorTextItem.kDefaultUnitWidth let textWidthUnit = textWidthPoints / imageFrame.size.width // New items should be aligned "upright", so they should have the _opposite_ // of the current transform rotation. let rotationRadians = -model.currentTransform().rotationRadians // Similarly, the size of the text item shuo let scaling = 1 / model.currentTransform().scaling let textItem = ImageEditorTextItem.empty(withColor: color, textStyle: textStyle, decorationStyle: decorationStyle, unitWidth: textWidthUnit, fontReferenceImageWidth: imageFrame.size.width, scaling: scaling, rotationRadians: rotationRadians) return textItem } func createNewStickerItem(with sticker: EditorSticker) -> ImageEditorStickerItem { let viewSize = canvasView.gestureReferenceView.bounds.size let imageSize = model.srcImageSizePixels let imageFrame = ImageEditorCanvasView.imageFrame( forViewSize: viewSize, imageSize: imageSize, transform: model.currentTransform() ) let rotationRadians = -model.currentTransform().rotationRadians let scaling = 1 / model.currentTransform().scaling return ImageEditorStickerItem( sticker: sticker, referenceImageWidth: imageFrame.size.width, rotationRadians: rotationRadians, scaling: scaling ) } @objc private func handleTapGesture(_ gestureRecognizer: UIGestureRecognizer) { AssertIsOnMainThread() guard gestureRecognizer.state == .recognized else { owsFailDebug("Unexpected state.") return } guard textInteractionModes.contains(.tap) else { owsFailDebug("Unexpected text interaction mode [\(textInteractionModes)].") return } let location = gestureRecognizer.location(in: canvasView.gestureReferenceView) guard let textLayer = transformableLayer(forLocation: location) else { // Different behavior when user taps on an empty area. // 1. Text objects are selectable: deselect anything previously selected. if textInteractionModes.contains(.select) { if selectedTransformableItemID != nil { selectedTransformableItemID = nil delegate?.imageEditorViewDidUpdateSelection(self) } return } // 2. Text objects aren't selectable: add a new text object. let newTextItem = createNewTextItem() delegate?.imageEditorView(self, didRequestAddTextItem: newTextItem) return } guard let itemID = textLayer.name, let item = model.item(forId: itemID) as? ImageEditorTransformable else { owsFailDebug("Missing or invalid text item.") return } // Text objects are selectable: select object if not selected yet... if textInteractionModes.contains(.select) && item.itemId != selectedTransformableItemID { selectedTransformableItemID = item.itemId delegate?.imageEditorViewDidUpdateSelection(self) } // ..otherwise report tap to delegate (this includes taps on selected text objects). else if let textItem = item as? ImageEditorTextItem { delegate?.imageEditorView(self, didTapTextItem: textItem) } // Change special sticker style else if let stickerItem = item as? ImageEditorStickerItem, case .story(let storySticker) = stickerItem.sticker { switch storySticker { case .clockDigital(let clockStyle): let newSticker = clockStyle.stickerWithNextStyle() let newStickerItem = stickerItem.copy(sticker: newSticker) model.replace(item: newStickerItem) case .clockAnalog(let clockStyle): let newSticker = clockStyle.stickerWithNextStyle() let newStickerItem = stickerItem.copy(sticker: newSticker) model.replace(item: newStickerItem) } ImpactHapticFeedback.impactOccurred(style: .medium) canvasView.hideTooltip() } } // MARK: - Pinch Gesture // These properties are valid while moving a text item. private var pinchingItem: (any ImageEditorTransformable)? private var pinchHasChanged = false @objc private func handlePinchGesture(_ gestureRecognizer: ImageEditorPinchGestureRecognizer) { AssertIsOnMainThread() // We could undo an in-progress pinch if the gesture is cancelled, but it seems gratuitous. switch gestureRecognizer.state { case .began: let pinchState = gestureRecognizer.pinchStateStart guard let textLayer = transformableLayer(forLocation: pinchState.centroid), let itemID = textLayer.name, itemID == selectedTransformableItemID else { // The pinch needs to start centered on selected text item. return } guard let item = model.item(forId: itemID) as? ImageEditorTransformable else { owsFailDebug("Missing or invalid text item.") return } pinchingItem = item pinchHasChanged = false case .changed, .ended: guard let item = pinchingItem else { return } let view = canvasView.gestureReferenceView let viewBounds = view.bounds let locationStart = gestureRecognizer.pinchStateStart.centroid let locationNow = gestureRecognizer.pinchStateLast.centroid let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart, viewBounds: viewBounds, model: model, transform: model.currentTransform()) let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationNow, viewBounds: viewBounds, model: model, transform: model.currentTransform()) let gestureDeltaImageUnit = gestureNowImageUnit.minus(gestureStartImageUnit) let unitCenter = CGPoint.clamp01(item.unitCenter.plus(gestureDeltaImageUnit)) // NOTE: We use max(1, ...) to avoid divide-by-zero. let newScaling = CGFloat.clamp(item.scaling * gestureRecognizer.pinchStateLast.distance / max(1.0, gestureRecognizer.pinchStateStart.distance), min: ImageEditorTextItem.kMinScaling, max: ImageEditorTextItem.kMaxScaling) let newRotationRadians = item.rotationRadians + gestureRecognizer.pinchStateLast.angleRadians - gestureRecognizer.pinchStateStart.angleRadians let newItem = item.copy(unitCenter: unitCenter).copy(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 { pinchingItem = nil } default: pinchingItem = nil } } // MARK: - Pan Gesture // These properties are valid while moving a text item. private var movingItem: (any ImageEditorTransformable)? { didSet { updateControls() } } private var movingTextStartUnitCenter: CGPoint? private var movingTextHasMoved = false private func transformableLayer(forLocation locationInView: CGPoint) -> CALayer? { let viewBounds = self.canvasView.gestureReferenceView.bounds let affineTransform = self.model.currentTransform().affineTransform(viewSize: viewBounds.size) let locationInCanvas = locationInView.minus(viewBounds.center).applyingInverse(affineTransform).plus(viewBounds.center) return canvasView.transformableLayer(forLocation: locationInCanvas) } @objc private func handleMoveTextGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) { AssertIsOnMainThread() guard textInteractionModes.contains(.move) else { owsFailDebug("Unexpected text interaction mode [\(textInteractionModes)].") return } // We could undo an in-progress move if the gesture is cancelled, but it seems gratuitous. switch gestureRecognizer.state { case .began: guard let locationStart = gestureRecognizer.locationFirst else { owsFailDebug("Missing locationStart.") return } guard let textLayer = transformableLayer(forLocation: locationStart) else { owsFailDebug("No text layer") return } guard let itemID = textLayer.name, let item = model.item(forId: itemID) as? ImageEditorTransformable else { owsFailDebug("Missing or invalid text item.") return } movingItem = item movingTextStartUnitCenter = item.unitCenter movingTextHasMoved = false canvasView.hideTooltip() // Automatically make item selected if selections are allowed. if textInteractionModes.contains(.select) { selectedTransformableItemID = item.itemId } case .changed, .ended: guard let item = movingItem else { return } guard let locationStart = gestureRecognizer.locationFirst else { owsFailDebug("Missing locationStart.") return } guard let movingTextStartUnitCenter = movingTextStartUnitCenter else { owsFailDebug("Missing movingTextStartUnitCenter.") return } let view = canvasView.gestureReferenceView let viewBounds = view.bounds let locationInView = gestureRecognizer.location(in: view) let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart, viewBounds: viewBounds, model: model, transform: model.currentTransform()) let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView, viewBounds: viewBounds, model: model, transform: model.currentTransform()) let gestureDeltaImageUnit = gestureNowImageUnit.minus(gestureStartImageUnit) let unitCenter = CGPoint.clamp01(movingTextStartUnitCenter.plus(gestureDeltaImageUnit)) let newItem = item.copy(unitCenter: unitCenter) if movingTextHasMoved { model.replace(item: newItem, suppressUndo: true) } else { model.replace(item: newItem, suppressUndo: false) movingTextHasMoved = true } isHoveringOverTrash = trashView.containsGestureLocation(gestureRecognizer) if gestureRecognizer.state == .ended { // Report that text object was moved. if let movingTextItem = movingItem as? ImageEditorTextItem { delegate?.imageEditorView(self, didMoveTextItem: movingTextItem) } if isHoveringOverTrash, isTrashShowing { // The last operation was moving the image over the trash. // Pop that off the stack, so when the user presses undo // after trashing an item, it goes to the position before // the trash, instead of appearing over the trash. model.undo() model.remove(item: newItem) } movingItem = nil isHoveringOverTrash = false } default: movingItem = nil } } } // MARK: - Corner Radius extension ImageEditorView { static let defaultCornerRadius: CGFloat = 18 func setHasRoundCorners(_ roundCorners: Bool, animationDuration: TimeInterval = 0) { canvasView.setCornerRadius(roundCorners ? ImageEditorView.defaultCornerRadius : 0, animationDuration: animationDuration) } } // MARK: - extension ImageEditorView: UIGestureRecognizerDelegate { public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { guard moveTextGestureRecognizer == gestureRecognizer else { owsFailDebug("Unexpected gesture.") return false } let location = touch.location(in: canvasView.gestureReferenceView) let isInTextArea = self.transformableLayer(forLocation: location) != nil return isInTextArea } } // MARK: - extension ImageEditorView: ImageEditorModelObserver { func imageEditorModelDidChange(before: ImageEditorContents, after: ImageEditorContents) { } func imageEditorModelDidChange(changedItemIds: [String]) { } }