diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 1804286c9..fbad19f4a 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -311,9 +311,15 @@ /* Label for generic done button. */ "BUTTON_DONE" = "Done"; +/* Label for redo button. */ +"BUTTON_REDO" = "Redo"; + /* Button text to enable batch selection mode */ "BUTTON_SELECT" = "Select"; +/* Label for undo button. */ +"BUTTON_UNDO" = "Undo"; + /* Label for button that lets users call a contact again. */ "CALL_AGAIN_BUTTON_TITLE" = "Call Again"; diff --git a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift index 44a505589..ee866096b 100644 --- a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift +++ b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift @@ -857,8 +857,6 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD private(set) var contentContainer: UIView! private(set) var playVideoButton: UIView? - private var imageEditorView: ImageEditorView? - // MARK: - Initializers init(attachmentItem: SignalAttachmentItem) { @@ -954,7 +952,8 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD imageMediaView.isUserInteractionEnabled = true imageMediaView.addSubview(imageEditorView) imageEditorView.autoPinEdgesToSuperviewEdges() - self.imageEditorView = imageEditorView + + imageEditorView.addControls(to: self.mediaMessageView) } #endif diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift index 9aa1d5896..e01f41a0c 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift @@ -139,6 +139,10 @@ public class OrderedDictionary: NSObject { return OrderedDictionary(keyValueMap: keyValueMap, orderedKeys: orderedKeys) } + public func value(forKey key: KeyType) -> ValueType? { + return keyValueMap[key] + } + public func append(key: KeyType, value: ValueType) { if keyValueMap[key] != nil { owsFailDebug("Unexpected duplicate key in key map: \(key)") @@ -239,6 +243,11 @@ public class ImageEditorContents: NSObject { return ImageEditorContents(itemMap: itemMap.clone()) } + @objc + public func item(forId itemId: String) -> ImageEditorItem? { + return itemMap.value(forKey: itemId) + } + @objc public func append(item: ImageEditorItem) { Logger.verbose("\(item.itemId)") @@ -300,6 +309,7 @@ private class ImageEditorOperation: NSObject { @objc public protocol ImageEditorModelDelegate: class { func imageEditorModelDidChange() + func imageEditorModelDidChange(changedItemIds: [String]) } // MARK: - @@ -360,6 +370,11 @@ public class ImageEditorModel: NSObject { return contents.items() } + @objc + public func item(forId itemId: String) -> ImageEditorItem? { + return contents.item(forId: itemId) + } + @objc public func canUndo() -> Bool { return !undoStack.isEmpty @@ -382,6 +397,7 @@ public class ImageEditorModel: NSObject { self.contents = undoOperation.contents + // We could diff here and yield a more narrow change event. delegate?.imageEditorModelDidChange() } @@ -397,39 +413,46 @@ public class ImageEditorModel: NSObject { self.contents = redoOperation.contents + // We could diff here and yield a more narrow change event. delegate?.imageEditorModelDidChange() } @objc public func append(item: ImageEditorItem) { - performAction { (newContents) in + performAction({ (newContents) in newContents.append(item: item) - } + }, changedItemIds: [item.itemId]) } @objc - public func replace(item: ImageEditorItem) { - performAction { (newContents) in + public func replace(item: ImageEditorItem, + suppressUndo: Bool = false) { + performAction({ (newContents) in newContents.replace(item: item) - } + }, changedItemIds: [item.itemId], + suppressUndo: suppressUndo) } @objc public func remove(item: ImageEditorItem) { - performAction { (newContents) in + performAction({ (newContents) in newContents.remove(item: item) - } + }, changedItemIds: [item.itemId]) } - private func performAction(action: (ImageEditorContents) -> Void) { - let undoOperation = ImageEditorOperation(contents: contents) - undoStack.append(undoOperation) - redoStack.removeAll() + private func performAction(_ action: (ImageEditorContents) -> Void, + changedItemIds: [String], + suppressUndo: Bool = false) { + if !suppressUndo { + let undoOperation = ImageEditorOperation(contents: contents) + undoStack.append(undoOperation) + redoStack.removeAll() + } let newContents = contents.clone() action(newContents) contents = newContents - delegate?.imageEditorModelDidChange() + delegate?.imageEditorModelDidChange(changedItemIds: changedItemIds) } } diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift index a6001f1c5..c14a638ee 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift @@ -29,8 +29,70 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { notImplemented() } + // MARK: - Buttons + + private let undoButton = UIButton(type: .custom) + private let redoButton = UIButton(type: .custom) + + @objc + public func addControls(to containerView: UIView) { + configure(button: undoButton, + label: NSLocalizedString("BUTTON_UNDO", comment: "Label for undo button."), + selector: #selector(didTapUndo(sender:))) + + configure(button: redoButton, + label: NSLocalizedString("BUTTON_REDO", comment: "Label for redo button."), + selector: #selector(didTapRedo(sender:))) + + let stackView = UIStackView(arrangedSubviews: [undoButton, redoButton]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = 10 + + containerView.addSubview(stackView) + stackView.autoAlignAxis(toSuperviewAxis: .horizontal) + stackView.autoPinTrailingToSuperviewMargin(withInset: 10) + + updateButtons() + } + + private func configure(button: UIButton, + label: String, + selector: Selector) { + button.setTitle(label, for: .normal) + button.setTitleColor(.white, + for: .normal) + button.setTitleColor(.gray, + for: .disabled) + button.titleLabel?.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight() + button.addTarget(self, action: selector, for: .touchUpInside) + } + + private func updateButtons() { + undoButton.isEnabled = model.canUndo() + redoButton.isEnabled = model.canRedo() + } + // MARK: - Actions + @objc func didTapUndo(sender: UIButton) { + Logger.verbose("") + guard model.canUndo() else { + owsFailDebug("Can't undo.") + return + } + model.undo() + } + + @objc func didTapRedo(sender: UIButton) { + Logger.verbose("") + guard model.canRedo() else { + owsFailDebug("Can't redo.") + return + } + model.redo() + } + // These properties are non-empty while drawing a stroke. private var currentStroke: ImageEditorStrokeItem? private var currentStrokeSamples = [ImageEditorStrokeItem.StrokeSample]() @@ -39,8 +101,6 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { public func handleTouchGesture(_ gestureRecognizer: UIGestureRecognizer) { AssertIsOnMainThread() - Logger.verbose("\(NSStringForUIGestureRecognizerState(gestureRecognizer.state))") - let removeCurrentStroke = { if let stroke = self.currentStroke { self.model.remove(item: stroke) @@ -69,9 +129,9 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { currentStrokeSamples.append(unitSampleForGestureLocation()) - let stroke = ImageEditorStrokeItem(color: strokeColor, unitSamples: self.currentStrokeSamples, unitStrokeWidth: unitStrokeWidth) - self.model.append(item: stroke) - self.currentStroke = stroke + let stroke = ImageEditorStrokeItem(color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth) + model.append(item: stroke) + currentStroke = stroke case .changed, .ended: currentStrokeSamples.append(unitSampleForGestureLocation()) @@ -84,13 +144,14 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { // Model items are immutable; we _replace_ the // stroke item rather than modify it. - let stroke = ImageEditorStrokeItem(itemId: lastStroke.itemId, color: strokeColor, unitSamples: self.currentStrokeSamples, unitStrokeWidth: unitStrokeWidth) - self.model.replace(item: stroke) - self.currentStroke = stroke + let stroke = ImageEditorStrokeItem(itemId: lastStroke.itemId, color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth) + model.replace(item: stroke, suppressUndo: true) if gestureRecognizer.state == .ended { - self.currentStroke = nil - self.currentStrokeSamples.removeAll() + currentStroke = nil + currentStrokeSamples.removeAll() + } else { + currentStroke = stroke } default: removeCurrentStroke() @@ -100,9 +161,15 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { // MARK: - ImageEditorModelDelegate public func imageEditorModelDidChange() { - // TODO: We eventually want to narrow our change events - // to reflect the specific item(s) which changed. updateAllContent() + + updateButtons() + } + + public func imageEditorModelDidChange(changedItemIds: [String]) { + updateContent(changedItemIds: changedItemIds) + + updateButtons() } // MARK: - Accessor Overrides @@ -125,33 +192,71 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { // MARK: - Content - var contentLayers = [CALayer]() + var contentLayerMap = [String: CALayer]() internal func updateAllContent() { AssertIsOnMainThread() - for layer in contentLayers { + // Don't animate changes. + CATransaction.begin() + CATransaction.setDisableActions(true) + + for layer in contentLayerMap.values { layer.removeFromSuperlayer() } - contentLayers.removeAll() + contentLayerMap.removeAll() - guard bounds.width > 0, - bounds.height > 0 else { - return + if bounds.width > 0, + bounds.height > 0 { + + for item in model.items() { + guard let layer = ImageEditorView.layerForItem(item: item, + viewSize: bounds.size) else { + continue + } + + self.layer.addSublayer(layer) + contentLayerMap[item.itemId] = layer + } } + CATransaction.commit() + } + + internal func updateContent(changedItemIds: [String]) { + AssertIsOnMainThread() + // Don't animate changes. CATransaction.begin() CATransaction.setDisableActions(true) - for item in model.items() { - guard let layer = ImageEditorView.layerForItem(item: item, - viewSize: bounds.size) else { - continue + // Remove all changed items. + for itemId in changedItemIds { + if let layer = contentLayerMap[itemId] { + layer.removeFromSuperlayer() } + contentLayerMap.removeValue(forKey: itemId) + } + + if bounds.width > 0, + bounds.height > 0 { + + // Create layers for inserted and updated items. + for itemId in changedItemIds { + guard let item = model.item(forId: itemId) else { + // Item was deleted. + continue + } - self.layer.addSublayer(layer) - contentLayers.append(layer) + // Item was inserted or updated. + guard let layer = ImageEditorView.layerForItem(item: item, + viewSize: bounds.size) else { + continue + } + + self.layer.addSublayer(layer) + contentLayerMap[item.itemId] = layer + } } CATransaction.commit()