Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/ImageEditor/ImageEditorViewController+Text.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit

// MARK: - Sticker

extension ImageEditorViewController {
    func selectStickerItem(_ stickerItem: ImageEditorStickerItem) {
        mode = .sticker
        model.append(item: stickerItem)
        imageEditorView.selectedTransformableItemID = stickerItem.itemId
    }
}

// MARK: - Text

extension ImageEditorViewController {

    func selectTextItem(_ textItem: ImageEditorTextItem, isNewItem: Bool, startEditing: Bool) {
        mode = .text
        currentTextItem = (textItem, isNewItem)
        imageEditorView.selectedTransformableItemID = textItem.itemId
        if startEditing, isViewLoaded, view.window != nil {
            beginTextEditing()
        } else {
            startEditingTextOnViewAppear = startEditing
        }
    }

    var canBeginTextEditingOnViewAppear: Bool {
        guard mode == .text else {
            return false
        }
        return currentTextItem != nil
    }

    private func initializeTextUIIfNecessary() {
        guard !textUIInitialized else { return }

        let toolbarSize = textViewAccessoryToolbar.systemLayoutSizeFitting(
            CGSize(width: view.width, height: .greatestFiniteMagnitude),
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel,
        )
        textViewAccessoryToolbar.bounds.size = toolbarSize
        textView.inputAccessoryView = textViewAccessoryToolbar

        // Background view is necessary because animations of textViewContainer.frame
        // don't match animations of the keyboard and non-dimmed area was showing
        // in between the bottom edge of textViewContainer and the top of keyboard.
        let textContainerBackground = UIView()
        textContainerBackground.backgroundColor = .ows_blackAlpha40
        textViewContainer.addSubview(textContainerBackground)
        textContainerBackground.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
        textContainerBackground.autoPinEdge(toSuperviewEdge: .bottom, withInset: -300)

        textViewBackgroundView.layer.cornerRadius = 8
        textViewWrapperView.addSubview(textViewBackgroundView)
        textViewWrapperView.addSubview(textView)
        textViewBackgroundView.autoSetDimension(.width, toSize: 36, relation: .greaterThanOrEqual)
        textViewBackgroundView.autoSetDimension(.height, toSize: 36, relation: .greaterThanOrEqual)
        textViewBackgroundView.autoPinWidthToSuperviewMargins(relation: .lessThanOrEqual)
        textViewBackgroundView.autoPinHeightToSuperviewMargins(relation: .lessThanOrEqual)
        textViewBackgroundView.autoCenterInSuperview()
        // These inset values provide the best visual match with CATextLayer's bounds when background color is set.
        textView.autoPinEdges(toEdgesOf: textViewBackgroundView, with: UIEdgeInsets(top: -6, left: 6, bottom: -7, right: 6))

        textViewContainer.addSubview(textViewWrapperView)
        textViewWrapperView.autoVCenterInSuperview()
        textViewWrapperView.autoPinWidthToSuperviewMargins()
        textViewWrapperView.autoPinHeightToSuperviewMargins(relation: .lessThanOrEqual)

        view.addSubview(textViewContainer)
        textViewContainer.translatesAutoresizingMaskIntoConstraints = false
        textViewContainer.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
        NSLayoutConstraint.activate([
            textViewContainer.topAnchor.constraint(equalTo: view.topAnchor),
            textViewContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            textViewContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            textViewContainer.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor),
        ])

        textViewContainer.addGestureRecognizer(ImageEditorPinchGestureRecognizer(target: self, action: #selector(handleTextPinchGesture(_:))))
        textViewContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapDimmerView(_:))))

        UIView.performWithoutAnimation {
            self.view.setNeedsLayout()
            self.view.layoutIfNeeded()
        }

        textUIInitialized = true
    }

    func updateTextControlsVisibility() {
        // Nothing to update
    }

    /**
     * Load all UITextView's attributes from ImageEditorTextItem.
     * This method needs to be called when text item editing is about to begin.
     */
    private func updateTextViewAttributes(using textItem: ImageEditorTextItem) {
        textView.updateWith(
            textForegroundColor: textItem.textForegroundColor,
            font: textItem.font,
            textAlignment: .center,
            textDecorationColor: textItem.textDecorationColor,
            decorationStyle: textItem.decorationStyle,
        )
        textViewBackgroundView.backgroundColor = textItem.textBackgroundColor
    }

    // Update UITextView to use style (font, color, decoration) as selected in provided TextToolbar.
    // This method needs to be called whenever user changes text styling while UITextView is active
    // in order to reflect the changes right away.
    func updateTextViewAttributes(using textToolbar: TextStylingToolbar) {
        let fontPointSize = textView.font?.pointSize ?? ImageEditorTextItem.defaultFontSize
        textView.update(using: textToolbar, fontPointSize: fontPointSize)
        textViewBackgroundView.backgroundColor = textToolbar.textBackgroundColor
    }

    func updateTextUIVisibility() {
        switch mode {
        case .text:
            initializeTextUIIfNecessary()
            fallthrough
        case .sticker:
            imageEditorView.delegate = self
        case .draw, .blur:
            guard textUIInitialized else { break }
            imageEditorView.selectedTransformableItemID = nil
        }
    }

    func beginTextEditing() {
        guard let textItem = currentTextItem?.textItem else { return }

        bottomBar.setIsHidden(true, animated: true)

        textViewAccessoryToolbar.currentColorPickerValue = textItem.color
        textViewAccessoryToolbar.textStyle = textItem.textStyle
        textViewAccessoryToolbar.decorationStyle = textItem.decorationStyle

        textView.text = textItem.text
        updateTextViewAttributes(using: textItem)

        imageEditorView.canvasView.hiddenItemId = textItem.itemId

        UIView.animate(withDuration: 0.2) {
            self.textViewContainer.alpha = 1
        }
        textView.becomeFirstResponder()
    }

    func finishTextEditing(discardEdits: Bool = false) {
        guard textUIInitialized else { return }
        guard textView.isFirstResponder else { return }

        discardTextEditsOnEditingEnd = discardEdits

        textView.acceptAutocorrectSuggestion()
        textView.resignFirstResponder()
    }

    private func applyTextEdits() {
        guard let currentTextItem else { return }

        var textItem = currentTextItem.textItem

        // Update text's width.
        let view = imageEditorView.gestureReferenceView
        let viewBounds = view.bounds
        let imageFrame = ImageEditorCanvasView.imageFrame(
            forViewSize: viewBounds.size,
            imageSize: model.srcImageSizePixels,
            transform: model.currentTransform(),
        )
        // 12 is the sum of horizontal insets around textView as set in `initializeTextUIIfNecessary`.
        let unitWidth = (textViewWrapperView.width - 12) / imageFrame.width
        textItem = textItem.copy(unitWidth: unitWidth)

        // Ensure continuity of the new text item's location with its apparent location in this text editor.
        if currentTextItem.isNewItem {
            let locationInView = view.convert(textView.bounds.center, from: textView).clamp(view.bounds)
            let textCenterImageUnit = ImageEditorCanvasView.locationImageUnit(
                forLocationInView: locationInView,
                viewBounds: viewBounds,
                model: model,
                transform: model.currentTransform(),
            )
            textItem = textItem.copy(unitCenter: textCenterImageUnit)
        }

        // Update font size.
        if let textViewFont = textView.font {
            textItem = textItem.copy(fontSize: textViewFont.pointSize)
        }

        // Update text and decoration style.
        textItem = textItem.copy(
            textStyle: textViewAccessoryToolbar.textStyle,
            decorationStyle: textViewAccessoryToolbar.decorationStyle,
        )

        // Deleting all text results in text object being deleted.
        guard let text = textView.text?.ows_stripped(), !text.isEmpty else {
            if model.has(itemForId: textItem.itemId) {
                model.remove(item: textItem)
            }
            return
        }

        // Update text.
        textItem = textItem.copy(withText: text, color: textViewAccessoryToolbar.currentColorPickerValue)

        guard currentTextItem.textItem != textItem else {
            // No changes were made.  Cancel to avoid dirtying the undo stack.
            return
        }

        // Finally - update model with modified text item.
        if model.has(itemForId: textItem.itemId) {
            model.replace(item: textItem, suppressUndo: false)
        } else {
            model.append(item: textItem)
        }

        imageEditorView.selectedTransformableItemID = textItem.itemId
    }

    @objc
    private func handleTextPinchGesture(_ gestureRecognizer: ImageEditorPinchGestureRecognizer) {
        AssertIsOnMainThread()

        guard mode == .text else {
            owsFailDebug("Incorrect mode [\(mode)]")
            return
        }

        guard let textViewFont = textView.font else {
            owsFailDebug("Text View font is nil")
            return
        }

        switch gestureRecognizer.state {
        case .began:
            pinchFontSizeStart = textViewFont.pointSize

        case .changed, .ended:
            var pointSize = pinchFontSizeStart
            if gestureRecognizer.pinchStateLast.distance > 0 {
                pointSize *= gestureRecognizer.pinchStateLast.distance / gestureRecognizer.pinchStateStart.distance
            }
            textView.font = textViewFont.withSize(pointSize.clamp(12, 64))

        default:
            break
        }
    }

    @objc
    private func didTapDimmerView(_ gestureRecognizer: UITapGestureRecognizer) {
        finishTextEditing()
    }

    @objc
    func didTapTextStyleButton(sender: UIButton) {
        let textStyle = textViewAccessoryToolbar.textStyle.next()
        textViewAccessoryToolbar.textStyle = textStyle
        updateTextViewAttributes(using: textViewAccessoryToolbar)
    }

    @objc
    func didTapDecorationStyleButton(sender: UIButton) {
        var decorationStyle = textViewAccessoryToolbar.decorationStyle.next()
        if decorationStyle == .outline {
            decorationStyle = .none
        }
        textViewAccessoryToolbar.decorationStyle = decorationStyle
        updateTextViewAttributes(using: textViewAccessoryToolbar)
    }

    @objc
    func didTapTextEditingDoneButton(sender: UIButton) {
        finishTextEditing()
    }
}

// MARK: - UITextViewDelegate

extension ImageEditorViewController: UITextViewDelegate {

    func textViewDidBeginEditing(_ textView: UITextView) {
        // Reset each time user starts editing text.
        discardTextEditsOnEditingEnd = false
    }

    func textViewDidEndEditing(_ textView: UITextView) {
        bottomBar.setIsHidden(false, animated: true)

        // Save changes to the model unless we were told not to (eg when dismissing screen).
        if !discardTextEditsOnEditingEnd {
            applyTextEdits()
        }

        // Existing text is made hidden on the canvas while user is editing.
        // This is the time to make it visible.
        UIView.animate(withDuration: 0.2) {
            self.imageEditorView.canvasView.hiddenItemId = nil
            self.textViewContainer.alpha = 0
        }

        currentTextItem = nil
    }
}

// MARK: - ImageEditorViewDelegate

extension ImageEditorViewController: ImageEditorViewDelegate {

    func imageEditorView(_ imageEditorView: ImageEditorView, didRequestAddTextItem textItem: ImageEditorTextItem) {
        // No adding text via tap on image in this view controller.
        // Instead, tap on empty space deselects any selected text object
        // and switches the editor back to "draw" mode via `imageEditorViewDidUpdateSelection()`.
    }

    func imageEditorView(_ imageEditorView: ImageEditorView, didTapTextItem textItem: ImageEditorTextItem) {
        owsAssertDebug(imageEditorView.selectedTransformableItemID == textItem.itemId)
        currentTextItem = (textItem, false)
        beginTextEditing()
    }

    func imageEditorView(_ imageEditorView: ImageEditorView, didMoveTextItem textItem: ImageEditorTextItem) {

    }

    func imageEditorViewDidUpdateSelection(_ imageEditorView: ImageEditorView) {
        switch imageEditorView.selectedTransformableItemID {
        case .some(let selectedTransformableItemID):
            let selectedItem = model.item(forId: selectedTransformableItemID)
            if let textItem = selectedItem as? ImageEditorTextItem {
                mode = .text
                textViewAccessoryToolbar.currentColorPickerValue = textItem.color
                textViewAccessoryToolbar.textStyle = textItem.textStyle
                textViewAccessoryToolbar.decorationStyle = textItem.decorationStyle
            } else if selectedItem is ImageEditorStickerItem {
                mode = .sticker
            } else {
                fallthrough
            }
        case .none:
            mode = .draw
        }

        updateTextUIVisibility()
    }

    func imageEditorDidRequestToolbarVisibilityUpdate(_ imageEditorView: ImageEditorView) {
        updateControlsVisibility()
    }
}