diff --git a/Proton/Sources/ObjC/PRTextStorage.m b/Proton/Sources/ObjC/PRTextStorage.m index 5ab1eeed..ebafe7e0 100644 --- a/Proton/Sources/ObjC/PRTextStorage.m +++ b/Proton/Sources/ObjC/PRTextStorage.m @@ -141,7 +141,7 @@ - (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)str { NSInteger delta = str.length - range.length; [_storage replaceCharactersInRange:range withString:str]; [_storage fixAttributesInRange:NSMakeRange(0, _storage.length)]; - [self edited:NSTextStorageEditedCharacters & NSTextStorageEditedAttributes range:range changeInLength:delta]; + [self edited:NSTextStorageEditedCharacters | NSTextStorageEditedAttributes range:range changeInLength:delta]; [self endEditing]; } diff --git a/Proton/Sources/Swift/TextProcessors/TextProcessing.swift b/Proton/Sources/Swift/TextProcessors/TextProcessing.swift index 8f53711d..4fbf0bf5 100644 --- a/Proton/Sources/Swift/TextProcessors/TextProcessing.swift +++ b/Proton/Sources/Swift/TextProcessors/TextProcessing.swift @@ -105,6 +105,22 @@ public protocol TextProcessing { /// Invoked after the text has been processed in the `Editor`. /// - Parameter editor: EditorView in which text is changed. func didProcess(editor: EditorView) + + /// Invoked when `editor` is about to process editing changes. The delegate can use this method to perform any necessary preparations before the changes are applied. + /// - Parameters: + /// - editor: The `EditorView` instance that is about to process editing changes. + /// - editedMask: `NSTextStorage.EditActions` indicating the types of edits that are about to be processed. This parameter can contain `.editedCharacters`, `.editedAttributes`, or both, indicating whether the changes involve modifications to the text characters, text attributes, or both. + /// - editedRange: Range of text that is affected by the editing changes. This range is specified in the coordinate system of the text storage's string. + /// - delta: Indicates the change in length of the text as a result of the editing. A positive value indicates an increase in length, while a negative value indicates a decrease. This may be used to adjust any related data structures that depend on the text length. + func willProcessEditing(editor: EditorView, editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) + + /// Invoked after `editor` has processed editing changes. The delegate can use this method to perform any necessary actions after the content in Editor has been committed following current edit action. + /// - Parameters: + /// - editor: The `EditorView` instance that is about to process editing changes. + /// - editedMask: `NSTextStorage.EditActions` indicating the types of edits that are about to be processed. This parameter can contain `.editedCharacters`, `.editedAttributes`, or both, indicating whether the changes involve modifications to the text characters, text attributes, or both. + /// - editedRange: Range of text that is affected by the editing changes. This range is specified in the coordinate system of the text storage's string. + /// - delta: Indicates the change in length of the text as a result of the editing. A positive value indicates an increase in length, while a negative value indicates a decrease. This may be used to adjust any related data structures that depend on the text length. + func didProcessEditing(editor: EditorView, editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) } public extension TextProcessing { @@ -112,4 +128,6 @@ public extension TextProcessing { func selectedRangeChanged(editor: EditorView, oldRange: NSRange?, newRange: NSRange?) { } func didProcess(editor: EditorView) { } func shouldProcess(_ editorView: EditorView, shouldProcessTextIn range: NSRange, replacementText text: String) -> Bool { return true } + func willProcessEditing(editor: EditorView, editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { } + func didProcessEditing(editor: EditorView, editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { } } diff --git a/Proton/Sources/Swift/TextProcessors/TextProcessor.swift b/Proton/Sources/Swift/TextProcessors/TextProcessor.swift index ff485aba..c0955014 100644 --- a/Proton/Sources/Swift/TextProcessors/TextProcessor.swift +++ b/Proton/Sources/Swift/TextProcessors/TextProcessor.swift @@ -58,6 +58,10 @@ class TextProcessor: NSObject, NSTextStorageDelegate { var processed = false let changedText = textStorage.substring(from: editedRange) + sortedProcessors.forEach { + $0.willProcessEditing(editor: editor, editedMask: editedMask, range: editedRange, changeInLength: delta) + } + // This func is invoked even when selected range changes without change in text. Guard the code so that delegate call backs are // fired only when there is actual change in content guard delta != 0 else { return } @@ -88,6 +92,14 @@ class TextProcessor: NSObject, NSTextStorageDelegate { } } + func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { + guard let editor = editor else { return } + + sortedProcessors.forEach { + $0.didProcessEditing(editor: editor, editedMask: editedMask, range: editedRange, changeInLength: delta) + } + } + private func notifyInterruption(by processor: TextProcessing, editor: EditorView, at range: NSRange) { let processors = activeProcessors.filter { $0.name != processor.name } processors.forEach { $0.processInterrupted(editor: editor, at: range) } diff --git a/Proton/Tests/TextProcessors/Mocks/MockTextProcessor.swift b/Proton/Tests/TextProcessors/Mocks/MockTextProcessor.swift index 98269488..6fe8661d 100644 --- a/Proton/Tests/TextProcessors/Mocks/MockTextProcessor.swift +++ b/Proton/Tests/TextProcessors/Mocks/MockTextProcessor.swift @@ -35,6 +35,9 @@ class MockTextProcessor: TextProcessing { var onDidProcess: ((EditorView) -> Void)? var onShouldProcess: ((EditorView, NSRange, String) -> Bool)? + var willProcessEditing: ((EditorView, NSTextStorage.EditActions, NSRange, Int) -> Void)? + var didProcessEditing: ((EditorView, NSTextStorage.EditActions, NSRange, Int) -> Void)? + var processorCondition: (EditorView, NSRange) -> Bool init(name: String = "MockTextProcessor", processorCondition: @escaping (EditorView, NSRange) -> Bool = { _, _ in true }) { @@ -75,4 +78,12 @@ class MockTextProcessor: TextProcessing { func shouldProcess(_ editorView: EditorView, shouldProcessTextIn range: NSRange, replacementText text: String) -> Bool { return onShouldProcess?(editorView, range, text) ?? true } + + func willProcessEditing(editor: EditorView, editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { + willProcessEditing?(editor, editedMask, editedRange, delta) + } + + func didProcessEditing(editor: EditorView, editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { + didProcessEditing?(editor, editedMask, editedRange, delta) + } } diff --git a/Proton/Tests/TextProcessors/TextProcessorTests.swift b/Proton/Tests/TextProcessors/TextProcessorTests.swift index 75c65a07..c8baa0c9 100644 --- a/Proton/Tests/TextProcessors/TextProcessorTests.swift +++ b/Proton/Tests/TextProcessors/TextProcessorTests.swift @@ -325,6 +325,100 @@ class TextProcessorTests: XCTestCase { waitForExpectations(timeout: 1.0) } + + func testInvokesWillProcessEditingContent() { + let testExpectation = functionExpectation() + + let editor = EditorView() + + let name = "TextProcessorTest" + let mockProcessor = MockTextProcessor(name: name) + mockProcessor.willProcessEditing = { processedEditor, editingMask, range, delta in + XCTAssertEqual(processedEditor, editor) + XCTAssertTrue(editingMask.contains(.editedAttributes)) + XCTAssertTrue(editingMask.contains(.editedCharacters)) + XCTAssertEqual(range, editor.attributedText.fullRange) + XCTAssertEqual(delta, editor.contentLength) + testExpectation.fulfill() + } + let testString = NSAttributedString(string: "test") + editor.registerProcessor(mockProcessor) + editor.replaceCharacters(in: .zero, with: testString) + waitForExpectations(timeout: 1.0) + } + + func testInvokesDidProcessEditingContent() { + let testExpectation = functionExpectation() + + let editor = EditorView() + + let name = "TextProcessorTest" + let mockProcessor = MockTextProcessor(name: name) + mockProcessor.didProcessEditing = { processedEditor, editingMask, range, delta in + XCTAssertEqual(processedEditor, editor) + XCTAssertTrue(editingMask.contains(.editedAttributes)) + XCTAssertTrue(editingMask.contains(.editedCharacters)) + XCTAssertEqual(range, editor.attributedText.fullRange) + XCTAssertEqual(delta, editor.contentLength) + testExpectation.fulfill() + } + let testString = NSAttributedString(string: "test") + editor.registerProcessor(mockProcessor) + editor.replaceCharacters(in: .zero, with: testString) + waitForExpectations(timeout: 1.0) + } + + func testInvokesWillProcessAttributeChanges() { + let testExpectation = functionExpectation() + + let editor = EditorView() + + let name = "TextProcessorTest" + let mockProcessor = MockTextProcessor(name: name) + + let testString = NSAttributedString(string: "test") + editor.registerProcessor(mockProcessor) + editor.replaceCharacters(in: .zero, with: testString) + + let processRange = NSRange(location: 2, length: 2) + mockProcessor.willProcessEditing = { processedEditor, editingMask, range, delta in + XCTAssertTrue(editingMask.contains(.editedAttributes)) + XCTAssertFalse(editingMask.contains(.editedCharacters)) + XCTAssertEqual(range, processRange) + testExpectation.fulfill() + } + + editor.selectedRange = processRange + BoldCommand().execute(on: editor) + + waitForExpectations(timeout: 1.0) + } + + func testInvokesDidlProcessAttributeChanges() { + let testExpectation = functionExpectation() + + let editor = EditorView() + + let name = "TextProcessorTest" + let mockProcessor = MockTextProcessor(name: name) + + let testString = NSAttributedString(string: "test") + editor.registerProcessor(mockProcessor) + editor.replaceCharacters(in: .zero, with: testString) + + let processRange = NSRange(location: 2, length: 2) + mockProcessor.didProcessEditing = { processedEditor, editingMask, range, delta in + XCTAssertTrue(editingMask.contains(.editedAttributes)) + XCTAssertFalse(editingMask.contains(.editedCharacters)) + XCTAssertEqual(range, processRange) + testExpectation.fulfill() + } + + editor.selectedRange = processRange + BoldCommand().execute(on: editor) + + waitForExpectations(timeout: 1.0) + } } extension EditorView {