From 46db750b52aeab2aebae8d11af739cfacfd53b92 Mon Sep 17 00:00:00 2001 From: Rajdeep Kwatra Date: Sun, 22 Sep 2024 09:14:17 +1000 Subject: [PATCH] Added ability to prevent textProcessors from running on initial assignment of attributedText in Editor (#341) --- .../TextProcessorExampleViewController.swift | 2 + .../TypeaheadTextProcessor.swift | 4 + .../Swift/Core/RichTextEditorContext.swift | 6 +- Proton/Sources/Swift/Editor/EditorView.swift | 5 +- .../Swift/TextProcessors/TextProcessing.swift | 4 + .../Swift/TextProcessors/TextProcessor.swift | 22 +++-- .../Mocks/MockTextProcessor.swift | 1 + .../TextProcessors/TextProcessorTests.swift | 90 +++++++++++++++++-- 8 files changed, 120 insertions(+), 14 deletions(-) diff --git a/ExampleApp/ExampleApp/AdvancedFeatures/TextProcessorExampleViewController.swift b/ExampleApp/ExampleApp/AdvancedFeatures/TextProcessorExampleViewController.swift index 435b9809..91fbf942 100644 --- a/ExampleApp/ExampleApp/AdvancedFeatures/TextProcessorExampleViewController.swift +++ b/ExampleApp/ExampleApp/AdvancedFeatures/TextProcessorExampleViewController.swift @@ -51,6 +51,8 @@ class TextProcessorExampleViewController: ExamplesBaseViewController { ]) registerTextProcessors() + + editor.attributedText = NSAttributedString(string: "test") } private func registerTextProcessors() { diff --git a/ExampleApp/ExampleApp/TextProcessors/TypeaheadTextProcessor.swift b/ExampleApp/ExampleApp/TextProcessors/TypeaheadTextProcessor.swift index 372fa071..4ae355ab 100644 --- a/ExampleApp/ExampleApp/TextProcessors/TypeaheadTextProcessor.swift +++ b/ExampleApp/ExampleApp/TextProcessors/TypeaheadTextProcessor.swift @@ -45,6 +45,10 @@ class TypeaheadTextProcessor: TextProcessing { return .exclusive } + var isRunOnSettingText: Bool { + true + } + weak var delegate: TypeaheadTextProcessorDelegate? var triggerDeleted = false diff --git a/Proton/Sources/Swift/Core/RichTextEditorContext.swift b/Proton/Sources/Swift/Core/RichTextEditorContext.swift index ee112467..8551b519 100644 --- a/Proton/Sources/Swift/Core/RichTextEditorContext.swift +++ b/Proton/Sources/Swift/Core/RichTextEditorContext.swift @@ -122,7 +122,8 @@ class RichTextEditorContext: RichTextViewContext { updateTypingAttributes(editor: editor, editedRange: range) - for processor in richTextView.textProcessor?.sortedProcessors ?? [] { + let executableProcessors = richTextView.textProcessor?.filteringExecutableOn(editor: editor) ?? [] + for processor in executableProcessors { let shouldProcess = processor.shouldProcess(editor, shouldProcessTextIn: range, replacementText: replacementText) if shouldProcess == false { return false @@ -168,7 +169,8 @@ class RichTextEditorContext: RichTextViewContext { private func invokeDidProcessIfRequired(_ richTextView: RichTextView) { guard let editor = richTextView.superview as? EditorView else { return } - for processor in richTextView.textProcessor?.sortedProcessors ?? [] { + let executableProcessors = richTextView.textProcessor?.filteringExecutableOn(editor: editor) ?? [] + for processor in executableProcessors { processor.didProcess(editor: editor) } } diff --git a/Proton/Sources/Swift/Editor/EditorView.swift b/Proton/Sources/Swift/Editor/EditorView.swift index d8147e07..8db06e14 100644 --- a/Proton/Sources/Swift/Editor/EditorView.swift +++ b/Proton/Sources/Swift/Editor/EditorView.swift @@ -252,7 +252,7 @@ open class EditorView: UIView { public var asyncTextResolvers: [AsyncTextResolving] = [] /// Low-tech lock mechanism to know when `attributedText` is being set - private var isSettingAttributedText = false + private(set) var isSettingAttributedText = false // Making this a convenience init fails the test `testRendersWidthRangeAttachment` as the init of a class subclassed from @@ -1491,7 +1491,8 @@ extension EditorView: RichTextViewDelegate { } func richTextView(_ richTextView: RichTextView, selectedRangeChangedFrom oldRange: NSRange?, to newRange: NSRange?) { - textProcessor?.activeProcessors.forEach { $0.selectedRangeChanged(editor: self, oldRange: oldRange, newRange: newRange) } + let executableProcessors = textProcessor?.filteringExecutableOn(editor: self) ?? [] + executableProcessors.forEach { $0.selectedRangeChanged(editor: self, oldRange: oldRange, newRange: newRange) } } func richTextView(_ richTextView: RichTextView, didTapAtLocation location: CGPoint, characterRange: NSRange?) { diff --git a/Proton/Sources/Swift/TextProcessors/TextProcessing.swift b/Proton/Sources/Swift/TextProcessors/TextProcessing.swift index 4fbf0bf5..2452ebe9 100644 --- a/Proton/Sources/Swift/TextProcessors/TextProcessing.swift +++ b/Proton/Sources/Swift/TextProcessors/TextProcessing.swift @@ -44,6 +44,8 @@ public protocol TextProcessing { /// executed. It is responsibility of these `TextProcessors` to do any cleanup/rollback if that needs to be done. var priority: TextProcessingPriority { get } + var isRunOnSettingText: Bool { get } + /// Determines if the text should be changed in the editor. /// - Note: /// This function is invoked just before making the changes in the `EditorView`. Besides preventing changing text in Editor in certain cases, @@ -124,6 +126,8 @@ public protocol TextProcessing { } public extension TextProcessing { + var isRunOnSettingText: Bool { true } + func handleKeyWithModifiers(editor: EditorView, key: EditorKey, modifierFlags: UIKeyModifierFlags, range editedRange: NSRange) { } func selectedRangeChanged(editor: EditorView, oldRange: NSRange?, newRange: NSRange?) { } func didProcess(editor: EditorView) { } diff --git a/Proton/Sources/Swift/TextProcessors/TextProcessor.swift b/Proton/Sources/Swift/TextProcessors/TextProcessor.swift index 7bd19d2b..e36c94c3 100644 --- a/Proton/Sources/Swift/TextProcessors/TextProcessor.swift +++ b/Proton/Sources/Swift/TextProcessors/TextProcessor.swift @@ -27,7 +27,8 @@ class TextProcessor: NSObject, NSTextStorageDelegate { sortedProcessors = activeProcessors.sorted { $0.priority > $1.priority } } } - private(set) var sortedProcessors = [TextProcessing]() + private var sortedProcessors = [TextProcessing]() + weak var editor: EditorView? init(editor: EditorView) { @@ -52,6 +53,11 @@ class TextProcessor: NSObject, NSTextStorageDelegate { } } + func filteringExecutableOn(editor: EditorView) -> [TextProcessing] { + guard editor.isSettingAttributedText else { return sortedProcessors } + return sortedProcessors.filter { $0.isRunOnSettingText } + } + func textStorage(_ textStorage: NSTextStorage, willProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { guard let editor = editor else { return } var executedProcessors = [TextProcessing]() @@ -59,7 +65,10 @@ class TextProcessor: NSObject, NSTextStorageDelegate { let changedText = textStorage.substring(from: editedRange) let editedMask = getEditedMask(delta: delta) - sortedProcessors.forEach { + + let executableProcessors = filteringExecutableOn(editor: editor) + + executableProcessors.forEach { $0.willProcessEditing(editor: editor, editedMask: editedMask, range: editedRange, changeInLength: delta) } @@ -67,7 +76,7 @@ class TextProcessor: NSObject, NSTextStorageDelegate { // fired only when there is actual change in content guard delta != 0 else { return } - for processor in sortedProcessors { + for processor in executableProcessors { if changedText == "\n" { processor.handleKeyWithModifiers(editor: editor, key: .enter, modifierFlags: [], range: editedRange) } else if changedText == "\t" { @@ -89,14 +98,17 @@ class TextProcessor: NSObject, NSTextStorageDelegate { func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { guard let editor = editor else { return } let editedMask = getEditedMask(delta: delta) - sortedProcessors.forEach { + let executableProcessors = filteringExecutableOn(editor: editor) + + executableProcessors.forEach { $0.didProcessEditing(editor: editor, editedMask: editedMask, range: editedRange, changeInLength: delta) } } func textStorage(_ textStorage: NSTextStorage, willProcessDeletedText deletedText: NSAttributedString, insertedText: NSAttributedString, range: NSRange) { guard let editor else { return } - for processor in sortedProcessors { + let executableProcessors = filteringExecutableOn(editor: editor) + for processor in executableProcessors { processor.willProcess(editor: editor, deletedText: deletedText, insertedText: insertedText, range: range) } } diff --git a/Proton/Tests/TextProcessors/Mocks/MockTextProcessor.swift b/Proton/Tests/TextProcessors/Mocks/MockTextProcessor.swift index 6fe8661d..46446e79 100644 --- a/Proton/Tests/TextProcessors/Mocks/MockTextProcessor.swift +++ b/Proton/Tests/TextProcessors/Mocks/MockTextProcessor.swift @@ -26,6 +26,7 @@ import Proton class MockTextProcessor: TextProcessing { let name: String var priority: TextProcessingPriority = .medium + var isRunOnSettingText: Bool = true var onWillProcess: ((EditorView, NSAttributedString, NSAttributedString, NSRange) -> Void)? var onProcess: ((EditorView, NSRange, Int) -> Void)? diff --git a/Proton/Tests/TextProcessors/TextProcessorTests.swift b/Proton/Tests/TextProcessors/TextProcessorTests.swift index e4437be0..07f1bfd7 100644 --- a/Proton/Tests/TextProcessors/TextProcessorTests.swift +++ b/Proton/Tests/TextProcessors/TextProcessorTests.swift @@ -25,24 +25,26 @@ import XCTest class TextProcessorTests: XCTestCase { func testRegistersTextProcessor() { - let textProcessor = TextProcessor(editor: EditorView()) + let editor = EditorView() + let textProcessor = TextProcessor(editor: editor) let name = "TextProcessorTest" let mockProcessor = MockTextProcessor(name: name) textProcessor.register(mockProcessor) - XCTAssertEqual(textProcessor.sortedProcessors.count, 1) - XCTAssertEqual(textProcessor.sortedProcessors[0].name, name) + XCTAssertEqual(textProcessor.filteringExecutableOn(editor: editor).count, 1) + XCTAssertEqual(textProcessor.activeProcessors[0].name, name) } func testUnregistersTextProcessor() { - let textProcessor = TextProcessor(editor: EditorView()) + let editor = EditorView() + let textProcessor = TextProcessor(editor: editor) let name = "TextProcessorTest" let mockProcessor = MockTextProcessor(name: name) textProcessor.register(mockProcessor) textProcessor.unregister(mockProcessor) - XCTAssertEqual(textProcessor.sortedProcessors.count, 0) + XCTAssertEqual(textProcessor.filteringExecutableOn(editor: editor).count, 0) } func testInvokesWillProcess() throws { @@ -73,6 +75,69 @@ class TextProcessorTests: XCTestCase { waitForExpectations(timeout: 1.0) } + func testExecutesWillProcessOnSetAttributedText() throws { + let expectation = expectation(description: "Wait for willProcess to be invoked") + try assertProcessorInvocationOnSetAttributedText(expectation, isRunOnSettingText: true) { mockProcessor in + mockProcessor.onWillProcess = { _, _, _, _ in + expectation.fulfill() + } + } + waitForExpectations(timeout: 1.0) + } + + func testExecutesWillProcessEditingOnSetAttributedText() throws { + let expectation = expectation(description: "Wait for willProcess to be invoked") + try assertProcessorInvocationOnSetAttributedText(expectation, isRunOnSettingText: true) { mockProcessor in + mockProcessor.willProcessEditing = { _, _, _, _ in + expectation.fulfill() + } + } + waitForExpectations(timeout: 1.0) + } + + func testExecutesDidProcessEditingOnSetAttributedText() throws { + let expectation = expectation(description: "Wait for didProcess to be invoked") + try assertProcessorInvocationOnSetAttributedText(expectation, isRunOnSettingText: true) { mockProcessor in + mockProcessor.didProcessEditing = { _, _, _, _ in + expectation.fulfill() + } + } + waitForExpectations(timeout: 1.0) + } + + func testPreventsWillProcessOnSetAttributedText() throws { + let expectation = expectation(description: "Should not wait for WillProcess to be invoked") + expectation.isInverted = true + try assertProcessorInvocationOnSetAttributedText(expectation, isRunOnSettingText: false) { mockProcessor in + mockProcessor.onWillProcess = { _, _, _, _ in + expectation.fulfill() + } + } + waitForExpectations(timeout: 1.0) + } + + func testPreventsWillProcessEditingOnSetAttributedText() throws { + let expectation = expectation(description: "Should not wait for WillProcess to be invoked") + expectation.isInverted = true + try assertProcessorInvocationOnSetAttributedText(expectation, isRunOnSettingText: false) { mockProcessor in + mockProcessor.willProcessEditing = { _, _, _, _ in + expectation.fulfill() + } + } + waitForExpectations(timeout: 1.0) + } + + func testPreventsDidProcessEditingOnSetAttributedText() throws { + let expectation = expectation(description: "Should not wait for DidProcess to be invoked") + expectation.isInverted = true + try assertProcessorInvocationOnSetAttributedText(expectation, isRunOnSettingText: false) { mockProcessor in + mockProcessor.didProcessEditing = { _, _, _, _ in + expectation.fulfill() + } + } + waitForExpectations(timeout: 1.0) + } + func testInvokesTextProcessor() { let testExpectation = functionExpectation() let editor = EditorView() @@ -461,6 +526,21 @@ class TextProcessorTests: XCTestCase { waitForExpectations(timeout: 1.0) } + + private func assertProcessorInvocationOnSetAttributedText(_ expectation: XCTestExpectation, isRunOnSettingText: Bool, file: StaticString = #file, line: UInt = #line, assertion: (MockTextProcessor) -> Void) throws { + let editor = EditorView() + editor.forceApplyAttributedText = true + + let name = "TextProcessorTest" + let mockProcessor = MockTextProcessor(name: name) + mockProcessor.isRunOnSettingText = isRunOnSettingText + assertion(mockProcessor) + + let testString = NSAttributedString(string: "test some text") + editor.registerProcessor(mockProcessor) + + editor.attributedText = testString + } } extension EditorView {