From cfc73c459194b7d9b42c5ad8c55b11f119b4ccbb Mon Sep 17 00:00:00 2001 From: Rajdeep Kwatra Date: Fri, 12 Jul 2024 10:09:45 +1000 Subject: [PATCH] =?UTF-8?q?Added=20additional=20delegate=20callbacks=20on?= =?UTF-8?q?=20TextProcessing=20to=20inform=20consum=E2=80=A6=20(#326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added additional delegate callbacks on TextProcesing to inform consumers of changes to tex, attributes or both * Fixed editmask to include characters only if delta is not 0 * Reverted back PRTextStorage change --- .../Swift/TextProcessors/TextProcessing.swift | 18 +++ .../Swift/TextProcessors/TextProcessor.swift | 25 ++++ .../Mocks/MockTextProcessor.swift | 11 ++ .../TextProcessors/TextProcessorTests.swift | 136 ++++++++++++++++++ 4 files changed, 190 insertions(+) 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..7bd19d2b 100644 --- a/Proton/Sources/Swift/TextProcessors/TextProcessor.swift +++ b/Proton/Sources/Swift/TextProcessors/TextProcessor.swift @@ -58,6 +58,11 @@ class TextProcessor: NSObject, NSTextStorageDelegate { var processed = false let changedText = textStorage.substring(from: editedRange) + let editedMask = getEditedMask(delta: delta) + 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 } @@ -81,6 +86,14 @@ class TextProcessor: NSObject, NSTextStorageDelegate { editor.editorContextDelegate?.editor(editor, didExecuteProcessors: executedProcessors, at: editedRange) } + 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 { + $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 { @@ -88,6 +101,18 @@ class TextProcessor: NSObject, NSTextStorageDelegate { } } + // The editedMask is computed here as fixing the actual bug in PRTextStorage.replaceCharacter ([self edited:]) + // causing incorrect editedMask coming-in in this delegate causes TableViewAttachmentSnapshotTests.testRendersTableViewAttachmentInViewportRotation + // to hang, possibly due to persistent layout invalidations. This can be fixed if cell has foreApplyAttributedText on + // which ensures TextStorage to always be consistent state. However, given that there is some unknown, the proper fix + // in PRTextStorage will be added at a later time. It may include dropping need for forceApplyAttributedText. + private func getEditedMask(delta: Int) -> NSTextStorage.EditActions { + guard delta != 0 else { + return .editedAttributes + } + return [.editedCharacters, .editedAttributes] + } + 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..e4437be0 100644 --- a/Proton/Tests/TextProcessors/TextProcessorTests.swift +++ b/Proton/Tests/TextProcessors/TextProcessorTests.swift @@ -325,6 +325,142 @@ 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 testInvokesDidProcessEditingContentOnPartialDelete() { + 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(delta, -2) + testExpectation.fulfill() + } + let testString = NSAttributedString(string: "test") + editor.replaceCharacters(in: .zero, with: testString) + editor.registerProcessor(mockProcessor) + let processRange = NSRange(location: 2, length: 2) + editor.replaceCharacters(in: processRange, with: NSAttributedString()) + waitForExpectations(timeout: 1.0) + } + + func testInvokesDidProcessEditingContentOnFullDelete() { + 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(delta, -4) + testExpectation.fulfill() + } + let testString = NSAttributedString(string: "test") + editor.replaceCharacters(in: .zero, with: testString) + editor.registerProcessor(mockProcessor) + editor.replaceCharacters(in: editor.attributedText.fullRange, with: NSAttributedString()) + 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 testInvokesDidProcessAttributeChanges() { + 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 {