diff --git a/ExampleApp/ExampleApp/AdvancedFeatures/CommandsExampleViewController.swift b/ExampleApp/ExampleApp/AdvancedFeatures/CommandsExampleViewController.swift index a400e68b..ecb62b43 100644 --- a/ExampleApp/ExampleApp/AdvancedFeatures/CommandsExampleViewController.swift +++ b/ExampleApp/ExampleApp/AdvancedFeatures/CommandsExampleViewController.swift @@ -240,18 +240,21 @@ class CommandsExampleViewController: ExamplesBaseViewController { @objc func encodeContents(sender: UIButton) { - let value = editor.transformContents(using: JSONEncoder()) - let data = try! JSONSerialization.data(withJSONObject: value, options: .prettyPrinted) - let jsonString = String(data: data, encoding: .utf8)! - self.encodedContents = ["contents": value] + editor.addAttribute(.asyncTextResolver, value: "dummy", at: editor.selectedRange) + editor.setNeedsAsyncTextResolution() - let printableContents = """ - { "contents": \(jsonString) } - """ - - print(printableContents) - - editor.attributedText = NSAttributedString() +// let value = editor.transformContents(using: JSONEncoder()) +// let data = try! JSONSerialization.data(withJSONObject: value, options: .prettyPrinted) +// let jsonString = String(data: data, encoding: .utf8)! +// self.encodedContents = ["contents": value] +// +// let printableContents = """ +// { "contents": \(jsonString) } +// """ +// +// print(printableContents) +// +// editor.attributedText = NSAttributedString() } @objc diff --git a/Proton/Proton.xcodeproj/project.pbxproj b/Proton/Proton.xcodeproj/project.pbxproj index 6a06bf38..b5b94454 100644 --- a/Proton/Proton.xcodeproj/project.pbxproj +++ b/Proton/Proton.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 1B004EC723C1C287007893AA /* EditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B004EC623C1C287007893AA /* EditorView.swift */; }; + 1B0861D12A2D5AC000A84934 /* AsyncTextResolverSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B0861CF2A2D5ABD00A84934 /* AsyncTextResolverSnapshotTests.swift */; }; + 1B140B182A26999B006CF4C3 /* AsyncTextResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B140B172A26999B006CF4C3 /* AsyncTextResolver.swift */; }; 1B164F1023D1BE9900A0869A /* AttributesDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B164F0F23D1BE9900A0869A /* AttributesDecoding.swift */; }; 1B164F4123D2A4AB00A0869A /* String+NSRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B164F4023D2A4AB00A0869A /* String+NSRange.swift */; }; 1B164F5223D2B9D400A0869A /* TestTextProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B164F5123D2B9D400A0869A /* TestTextProcessor.swift */; }; @@ -166,6 +168,8 @@ /* Begin PBXFileReference section */ 1B004EC623C1C287007893AA /* EditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorView.swift; sourceTree = ""; }; + 1B0861CF2A2D5ABD00A84934 /* AsyncTextResolverSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTextResolverSnapshotTests.swift; sourceTree = ""; }; + 1B140B172A26999B006CF4C3 /* AsyncTextResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTextResolver.swift; sourceTree = ""; }; 1B164F0F23D1BE9900A0869A /* AttributesDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributesDecoding.swift; sourceTree = ""; }; 1B164F4023D2A4AB00A0869A /* String+NSRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+NSRange.swift"; sourceTree = ""; }; 1B164F5123D2B9D400A0869A /* TestTextProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTextProcessor.swift; sourceTree = ""; }; @@ -323,6 +327,22 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1B0861CE2A2D5AA900A84934 /* AsyncTextResolver */ = { + isa = PBXGroup; + children = ( + 1B0861CF2A2D5ABD00A84934 /* AsyncTextResolverSnapshotTests.swift */, + ); + path = AsyncTextResolver; + sourceTree = ""; + }; + 1B140B162A269984006CF4C3 /* AsyncTextResolver */ = { + isa = PBXGroup; + children = ( + 1B140B172A26999B006CF4C3 /* AsyncTextResolver.swift */, + ); + path = AsyncTextResolver; + sourceTree = ""; + }; 1B164F5023D2B99900A0869A /* Processors */ = { isa = PBXGroup; children = ( @@ -535,7 +555,6 @@ 1B82570723C567370033A0A9 /* EditorCommand */ = { isa = PBXGroup; children = ( - 1B4B60CB247FC698002B63CF /* TextProcessors */, 1B4B60C8247FC502002B63CF /* Commands */, 1B82570823C5674E0033A0A9 /* EditorCommand.swift */, 1B82570A23C567B90033A0A9 /* EditorCommandExecutor.swift */, @@ -562,6 +581,7 @@ 1B91F4AF23C72D66003F9B55 /* TextProcessors */ = { isa = PBXGroup; children = ( + 1B4B60CB247FC698002B63CF /* TextProcessors */, 1B91F4B023C72D87003F9B55 /* TextProcessor.swift */, 1B91F4B223C72DC3003F9B55 /* TextProcessing.swift */, ); @@ -619,6 +639,7 @@ 1BB214D323BB2778008B84A0 /* Tests */ = { isa = PBXGroup; children = ( + 1B0861CE2A2D5AA900A84934 /* AsyncTextResolver */, 1BD185BD284C2A51001F4FBC /* Grid */, 1B5E2581240F5AED00163E74 /* ExtensionTests */, 1B45CDE923C16816001EB196 /* Attachments */, @@ -750,6 +771,7 @@ 1BE3305B26057E1100B15E67 /* Swift */ = { isa = PBXGroup; children = ( + 1B140B162A269984006CF4C3 /* AsyncTextResolver */, 1BD185B8284C29DB001F4FBC /* Grid */, 1B2BC0DA23CF18A900407DEE /* Decoding */, 1B183D8A23CEA8F000AE83E5 /* Encoding */, @@ -978,6 +1000,7 @@ 1BD21554246951090000BCE2 /* LayoutManager.swift in Sources */, 1BD185BC284C29EF001F4FBC /* GridCell.swift in Sources */, 1B44165328E5706A00836C71 /* GradientView.swift in Sources */, + 1B140B182A26999B006CF4C3 /* AsyncTextResolver.swift in Sources */, 1B91E7B526AA697C0002DF45 /* UnderlineCommand.swift in Sources */, 1B91F4B323C72DC3003F9B55 /* TextProcessing.swift in Sources */, 1BD185C2284C3082001F4FBC /* GridView.swift in Sources */, @@ -1073,6 +1096,7 @@ 1B576089245ADDDA00D92AE8 /* EditorViewMenuTests.swift in Sources */, 1B617A98283711D700095FEE /* AttachmentUpdateSnapshotTests.swift in Sources */, 1BFFEF1C23C334D200D2BA35 /* InlineEditorView.swift in Sources */, + 1B0861D12A2D5AC000A84934 /* AsyncTextResolverSnapshotTests.swift in Sources */, 1B45CDF123C16EF4001EB196 /* RichTextAttachmentView.swift in Sources */, 1BFFEF1F23C3366200D2BA35 /* MockAttachmentOffsetProvider.swift in Sources */, 1B45CD9423BECFAF001EB196 /* AutogrowingTextViewSnapshotTests.swift in Sources */, diff --git a/Proton/Sources/Swift/AsyncTextResolver/AsyncTextResolver.swift b/Proton/Sources/Swift/AsyncTextResolver/AsyncTextResolver.swift new file mode 100644 index 00000000..0a788c05 --- /dev/null +++ b/Proton/Sources/Swift/AsyncTextResolver/AsyncTextResolver.swift @@ -0,0 +1,45 @@ +// +// AsyncTextResolver.swift +// Proton +// +// Created by Rajdeep Kwatra on 31/5/2023. +// Copyright © 2023 Rajdeep Kwatra. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +import Foundation + +/// Result type for async text resolution +public enum AsyncTextResolvingResult { + case apply(NSAttributedString, range: NSRange) + case discard +} + +/// An object capable of resolving text asynchronously to another representation. New representation may contain change in attributes or the string itself. +public protocol AsyncTextResolving { + /// Name of the Resolver. This name must be applied to the range of text that requires async resolution with attribute key: `.asyncTextResolver` + var name: String { get } + + /// Resolves the string to a different representation + /// - Parameters: + /// - editor: Editor containing the attributed string + /// - range: Range of attributesString containing `.asyncTextResolver` attribute + /// - string: Substring of attributesString containing `.asyncTextResolver` attribute + /// - completion: Transformed result to be applied. `.apply` will replace the `range` in `Editor` with provided `attributedString`. + /// .`.discard` discards the operation. + /// - Important: As part of resolution, `.asyncTextResolver` is removed from original range. If the content in original range has changed in + /// the time it took for resolution, it is responsibility of consumer to cleanup dangling attribute, if any. + func resolve(using editor: EditorView, range: NSRange, string: NSAttributedString, completion: @escaping (AsyncTextResolvingResult) -> Void) +} diff --git a/Proton/Sources/Swift/Base/NSAttributedString+ContentTypes.swift b/Proton/Sources/Swift/Base/NSAttributedString+ContentTypes.swift index 689850f7..d2ca8196 100644 --- a/Proton/Sources/Swift/Base/NSAttributedString+ContentTypes.swift +++ b/Proton/Sources/Swift/Base/NSAttributedString+ContentTypes.swift @@ -79,4 +79,7 @@ public extension NSAttributedString.Key { /// `.lockedAttributes: [NSAttributedString.Key.backgroundStyle]` /// `], at: editor.selectedRange)` static let lockedAttributes = NSAttributedString.Key("_lockedAttributes") + + + static let asyncTextResolver = NSAttributedString.Key("_asyncTextResolver") } diff --git a/Proton/Sources/Swift/Editor/EditorView.swift b/Proton/Sources/Swift/Editor/EditorView.swift index 2587da77..7f07ded7 100644 --- a/Proton/Sources/Swift/Editor/EditorView.swift +++ b/Proton/Sources/Swift/Editor/EditorView.swift @@ -107,6 +107,7 @@ open class EditorView: UIView { var textProcessor: TextProcessor? let richTextView: RichTextView let context: RichTextViewContext + var needsAsyncTextResolution = false var editorContextDelegate: EditorViewDelegate? { get { editorViewContext.delegate } @@ -156,6 +157,9 @@ open class EditorView: UIView { /// * To prevent any command to be executed, set value to be an empty array. public var registeredCommands: [EditorCommand]? + /// Async Text Resolvers supported by the Editor. + public var asyncTextResolvers: [AsyncTextResolving] = [] + /// Low-tech lock mechanism to know when `attributedText` is being set private var isSettingAttributedText = false @@ -645,6 +649,21 @@ open class EditorView: UIView { // } } + /// Sets async text resolution to resolve on next text layout pass. + /// - Note: Changing attributes also causes layout pass to be performed, and this any applicable `AsyncTextResolvers` will be executed. + public func setNeedsAsyncTextResolution() { + needsAsyncTextResolution = true + } + + /// Invokes async text resolution to resolve on demand. + public func resolveAsyncTextIfNeeded() { + needsAsyncTextResolution = true + resolveAsyncText() + } + + /// Returns the range of character at the given point + /// - Parameter point: Point to get range from + /// - Returns: Character range if available, else nil public func rangeOfCharacter(at point: CGPoint) -> NSRange? { let location = richTextView.convert(point, from: self) return richTextView.rangeOfCharacter(at: location) @@ -1076,6 +1095,7 @@ extension EditorView: RichTextViewDelegate { func richTextView(_ richTextView: RichTextView, didFinishLayout finished: Bool) { guard finished else { return } relayoutAttachments() + resolveAsyncText() } func richTextView(_ richTextView: RichTextView, selectedRangeChangedFrom oldRange: NSRange?, to newRange: NSRange?) { @@ -1133,6 +1153,29 @@ extension EditorView { } } +public extension EditorView { + func resolveAsyncText() { + guard needsAsyncTextResolution else { return } + richTextView.enumerateAttribute(.asyncTextResolver, in: attributedText.fullRange, options: [.reverse]) { [weak self] (resolverName, range, stop) in + guard let self else { + stop.pointee = true + return + } + + if let resolver = self.asyncTextResolvers.first(where: { $0.name == resolverName as? String }) { + let string = NSMutableAttributedString(attributedString: self.attributedText.attributedSubstring(from: range)) + resolver.resolve(using: self, range: range, string: string) { result in + self.removeAttribute(.asyncTextResolver, at: range) + if case let AsyncTextResolvingResult.apply(newString, newRange) = result { + self.richTextView.replaceCharacters(in: newRange, with: newString) + } + } + } + } + needsAsyncTextResolution = false + } +} + public extension EditorView { /// Determines if the given command can be executed on the current editor. The command is allowed to be executed if /// `requiresSupportedCommandsRegistration` is false or if the command has been registered with the editor. diff --git a/Proton/Sources/Swift/EditorCommand/TextProcessors/ListTextProcessor.swift b/Proton/Sources/Swift/TextProcessors/TextProcessors/ListTextProcessor.swift similarity index 100% rename from Proton/Sources/Swift/EditorCommand/TextProcessors/ListTextProcessor.swift rename to Proton/Sources/Swift/TextProcessors/TextProcessors/ListTextProcessor.swift diff --git a/Proton/Tests/AsyncTextResolver/AsyncTextResolverSnapshotTests.swift b/Proton/Tests/AsyncTextResolver/AsyncTextResolverSnapshotTests.swift new file mode 100644 index 00000000..a11c0146 --- /dev/null +++ b/Proton/Tests/AsyncTextResolver/AsyncTextResolverSnapshotTests.swift @@ -0,0 +1,74 @@ +// +// AsyncTextResolverSnapshotTests.swift +// Proton +// +// Created by Rajdeep Kwatra on 4/6/2023. +// Copyright © 2023 Rajdeep Kwatra. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +import SnapshotTesting + +@testable import Proton + +class AsyncTextResolverSnapshotTests: SnapshotTestCase { + func testIgnoresTextAsyncAttributeUntilInvoked() { + let expectation = functionExpectation() + expectation.expectedFulfillmentCount = 2 + let viewController = EditorTestViewController(height: 80) + let editor = viewController.editor + editor.asyncTextResolvers = [DummyResolver()] + editor.attributedText = NSAttributedString(string: "This is some text") + editor.addAttribute(.asyncTextResolver, value: "dummy", at: NSRange(location: 8, length: 4)) + + viewController.render(size: CGSize(width: 300, height: 150)) + assertSnapshot(matching: viewController.view, as: .image, record: recordMode) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + viewController.render(size: CGSize(width: 300, height: 150)) + assertSnapshot(matching: viewController.view, as: Snapshotting.image, record: self.recordMode) + expectation.fulfill() + // Invoke async text resolution + editor.resolveAsyncTextIfNeeded() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewController.render(size: CGSize(width: 300, height: 150)) + assertSnapshot(matching: viewController.view, as: Snapshotting.image, record: self.recordMode) + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + } +} + +class DummyResolver: AsyncTextResolving { + var name: String { "dummy" } + + func resolve(using editor: EditorView, range: NSRange, string: NSAttributedString, completion: @escaping (AsyncTextResolvingResult) -> Void) { + let mutableString = NSMutableAttributedString(attributedString: string) + + mutableString.addAttribute(.foregroundColor, value: UIColor.red, range: mutableString.fullRange) + mutableString.removeAttribute(.asyncTextResolver, range: mutableString.fullRange) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if editor.attributedText.attributedSubstring(from: range).string == string.string { + completion(.apply(mutableString, range: range)) + } else { + completion(.discard) + } + } + } +} diff --git a/Proton/Tests/AsyncTextResolver/__Snapshots__/AsyncTextResolverSnapshotTests/testIgnoresTextAsyncAttributeUntilInvoked.1.png b/Proton/Tests/AsyncTextResolver/__Snapshots__/AsyncTextResolverSnapshotTests/testIgnoresTextAsyncAttributeUntilInvoked.1.png new file mode 100644 index 00000000..4874d57f Binary files /dev/null and b/Proton/Tests/AsyncTextResolver/__Snapshots__/AsyncTextResolverSnapshotTests/testIgnoresTextAsyncAttributeUntilInvoked.1.png differ diff --git a/Proton/Tests/AsyncTextResolver/__Snapshots__/AsyncTextResolverSnapshotTests/testIgnoresTextAsyncAttributeUntilInvoked.2.png b/Proton/Tests/AsyncTextResolver/__Snapshots__/AsyncTextResolverSnapshotTests/testIgnoresTextAsyncAttributeUntilInvoked.2.png new file mode 100644 index 00000000..4874d57f Binary files /dev/null and b/Proton/Tests/AsyncTextResolver/__Snapshots__/AsyncTextResolverSnapshotTests/testIgnoresTextAsyncAttributeUntilInvoked.2.png differ diff --git a/Proton/Tests/AsyncTextResolver/__Snapshots__/AsyncTextResolverSnapshotTests/testIgnoresTextAsyncAttributeUntilInvoked.3.png b/Proton/Tests/AsyncTextResolver/__Snapshots__/AsyncTextResolverSnapshotTests/testIgnoresTextAsyncAttributeUntilInvoked.3.png new file mode 100644 index 00000000..fb76ebed Binary files /dev/null and b/Proton/Tests/AsyncTextResolver/__Snapshots__/AsyncTextResolverSnapshotTests/testIgnoresTextAsyncAttributeUntilInvoked.3.png differ