diff --git a/ExampleApp/ExampleApp/AdvancedFeatures/CommandsExampleViewController.swift b/ExampleApp/ExampleApp/AdvancedFeatures/CommandsExampleViewController.swift index 53890f86..aba88cd4 100644 --- a/ExampleApp/ExampleApp/AdvancedFeatures/CommandsExampleViewController.swift +++ b/ExampleApp/ExampleApp/AdvancedFeatures/CommandsExampleViewController.swift @@ -112,6 +112,7 @@ class CommandsExampleViewController: ExamplesBaseViewController { stackView.translatesAutoresizingMaskIntoConstraints = false editor.delegate = self + editor.asyncAttachmentRenderingDelegate = self EditorViewContext.shared.delegate = self editor.registerProcessor(ListTextProcessor()) @@ -305,7 +306,7 @@ extension CommandsExampleViewController: EditorViewDelegate { } func editor(_ editor: EditorView, didChangeSize currentSize: CGSize, previousSize: CGSize) { - print("Height changed from \(previousSize.height) to \(currentSize.height)") +// print("Height changed from \(previousSize.height) to \(currentSize.height)") } func editor(_ editor: EditorView, didTapAtLocation location: CGPoint, characterRange: NSRange?) { @@ -461,3 +462,13 @@ class ListFormattingProvider: EditorListFormattingProvider { return sequenceGenerator.value(at: index) } } + +extension CommandsExampleViewController: AsyncAttachmentRenderingDelegate { + func shouldRenderAsync(attachment: Proton.Attachment) -> Bool { + attachment is GridViewAttachment + } + + func didRenderAttachment(_ attachment: Proton.Attachment, in editor: Proton.EditorView) { + print("Render: \(attachment.id)") + } +} diff --git a/ExampleApp/ExampleApp/Commands/CreateGridViewCommand.swift b/ExampleApp/ExampleApp/Commands/CreateGridViewCommand.swift index ee1dc936..5d921ca4 100644 --- a/ExampleApp/ExampleApp/Commands/CreateGridViewCommand.swift +++ b/ExampleApp/ExampleApp/Commands/CreateGridViewCommand.swift @@ -33,13 +33,17 @@ public class CreateGridViewCommand: EditorCommand { self.delegate = delegate text.append(NSAttributedString(string: "Text before Grid")) - for i in 1...5 { - text.append(makeGridViewAttachment(id: i, numRows: 5, numColumns: 5).string) + timeEvent(label: "Create") + for i in 1..<25 { + text.append(makeGridViewAttachment(id: i, numRows: 25, numColumns: 5).string) + text.append(NSAttributedString(string: "\ntest middle\n")) } - text.append(NSAttributedString(string: "Text before Grid")) + + text.append(NSAttributedString(string: "Text After Grid")) } public func execute(on editor: EditorView) { + timeEvent(label: "Render") editor.attributedText = text } @@ -52,7 +56,7 @@ public class CreateGridViewCommand: EditorCommand { for col in 0.. CGPoint } +public protocol AsyncAttachmentRenderingDelegate: AnyObject { + func shouldRenderAsync(attachment: Attachment) -> Bool + func didRenderAttachment(_ attachment: Attachment, in editor: EditorView) +} + /// An attachment can be used as a container for any view object. Based on the `AttachmentSize` provided, the attachment automatically renders itself alongside the text in `EditorView`. /// `Attachment` also provides helper functions like `deleteFromContainer` and `rangeInContainer` open class Attachment: NSTextAttachment, BoundsObserving { - private var view: AttachmentContentView? = nil private var content: AttachmentContent = .image(UIImage()) private var size: AttachmentSize? = nil @@ -54,10 +58,20 @@ open class Attachment: NSTextAttachment, BoundsObserving { var cachedBounds: CGRect? + /// Identifier that uniquely identifies an attachment. Auto-generated. + public let id: String = UUID().uuidString /// Governs if the attachment should be selected before being deleted. When `true`, tapping the backspace key the first time on range containing `Attachment` will only /// select the attachment i.e. show as highlighted. Tapping the backspace again will delete the attachment. If the value is `false`, the attachment will be deleted on the first backspace itself. public var selectBeforeDelete = false + public var isBlockType: Bool { + isBlockAttachment + } + + public var isInlineType: Bool { + !isBlockAttachment + } + /// Attributed string representation of the `Attachment`. This can be used directly to replace a range of text in `EditorView` /// ### Usage Example ### /// ``` diff --git a/Proton/Sources/Swift/Core/AsyncTaskScheduler.swift b/Proton/Sources/Swift/Core/AsyncTaskScheduler.swift new file mode 100644 index 00000000..4899c477 --- /dev/null +++ b/Proton/Sources/Swift/Core/AsyncTaskScheduler.swift @@ -0,0 +1,73 @@ +// +// AsyncTaskScheduler.swift +// Proton +// +// Created by Rajdeep Kwatra on 11/9/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 + +class AsyncTaskScheduler { + typealias VoidTask = () -> Void + private var executing = false + + private var tasks = SynchronizedArray<(id: String, task: VoidTask)>() + private var scheduled = SynchronizedArray() + + var pending = false { + didSet { + guard pending == false else { return } + executeNext() + } + } + + func clear() { + tasks.removeAll() + } + + func enqueue(id: String, task: @escaping VoidTask) { + guard tasks.contains(where: { $0.id == id }) == false else { return } + self.tasks.append((id, task)) + } + + func dequeue(_ completion: @escaping (String, VoidTask?) -> Void) { + guard let task = self.tasks.remove(at: 0) else { + completion("", nil) + return + } + completion(task.id, task.task) + } + + func executeNext() { + guard !pending else { return } + dequeue { id, task in + if let task { + self.pending = true + // A delay is required so that tracking mode may be intercepted. + // Intercepting tracking allows handling of user interactions on UI + DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) { [weak self] in + guard let self else { return } + if RunLoop.current.currentMode != .tracking { + task() + } else { + self.tasks.insert((id: id, task: task), at: 0) + } + self.pending = false + } + } + } + } +} diff --git a/Proton/Sources/Swift/Editor/EditorView.swift b/Proton/Sources/Swift/Editor/EditorView.swift index 7fd846a7..b0e4a509 100644 --- a/Proton/Sources/Swift/Editor/EditorView.swift +++ b/Proton/Sources/Swift/Editor/EditorView.swift @@ -123,6 +123,7 @@ open class EditorView: UIView { let richTextView: RichTextView let context: RichTextViewContext var needsAsyncTextResolution = false + private let attachmentRenderingScheduler = AsyncTaskScheduler() // Holds `attributedText` until Editor move to a window // Setting attributed text without Editor being fully ready @@ -136,6 +137,8 @@ open class EditorView: UIView { /// Context for the current Editor public let editorViewContext: EditorViewContext + public weak var asyncAttachmentRenderingDelegate: AsyncAttachmentRenderingDelegate? + @available(iOS 13.0, *) public var textInteractions: [UITextInteraction] { richTextView.interactions.compactMap({ $0 as? UITextInteraction }) @@ -425,6 +428,7 @@ open class EditorView: UIView { pendingAttributedText = newValue return } + attachmentRenderingScheduler.clear() // Clear text before setting new value to avoid issues with formatting/layout when // editor is hosted in a scrollable container and content is set multiple times. richTextView.attributedText = NSAttributedString() @@ -1247,8 +1251,9 @@ extension EditorView { func relayoutAttachments(in range: NSRange? = nil) { let rangeToUse = range ?? NSRange(location: 0, length: contentLength) - richTextView.enumerateAttribute(.attachment, in: rangeToUse, options: .longestEffectiveRangeNotRequired) { (attach, range, _) in - guard let attachment = attach as? Attachment else { return } + richTextView.enumerateAttribute(.attachment, in: rangeToUse, options: .longestEffectiveRangeNotRequired) { [weak self] (attach, range, _) in + guard let self, + let attachment = attach as? Attachment else { return } if attachment.isImageBasedAttachment { attachment.setContainerEditor(self) @@ -1278,14 +1283,25 @@ extension EditorView { frame = CGRect(origin: adjustedOrigin, size: size) if attachment.isRendered == false { - attachment.render(in: self) - if !isSettingAttributedText, let focusable = attachment.contentView as? Focusable { - focusable.setFocus() + if self.asyncAttachmentRenderingDelegate?.shouldRenderAsync(attachment: attachment) == true { + self.attachmentRenderingScheduler.enqueue(id: attachment.id) { + // Because of async nature the attachment may get scheduled again to be rendered. + // ignore the attachments that are already rendered + guard attachment.isRendered == false else { return } + attachment.render(in: self) + self.asyncAttachmentRenderingDelegate?.didRenderAttachment(attachment, in: self) + } + } else { + attachment.render(in: self) + if !self.isSettingAttributedText, let focusable = attachment.contentView as? Focusable { + focusable.setFocus() + } } } attachment.frame = frame } + attachmentRenderingScheduler.executeNext() } } diff --git a/Proton/Sources/Swift/Grid/View/GridCell.swift b/Proton/Sources/Swift/Grid/View/GridCell.swift index f73edbad..f7b64845 100644 --- a/Proton/Sources/Swift/Grid/View/GridCell.swift +++ b/Proton/Sources/Swift/Grid/View/GridCell.swift @@ -118,6 +118,15 @@ public class GridCell { public internal(set) var frame: CGRect = .zero private var selectionView: SelectionView? + private let editorInitializer: EditorInitializer + + var isRunningTests: Bool { +#if DEBUG + return ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil +#else + return false +#endif + } /// Sets the cell selected public var isSelected: Bool { @@ -131,27 +140,17 @@ public class GridCell { } } - var isRunningTests: Bool { - #if DEBUG - return ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil - #else - return false - #endif - } - - private(set) var editorSetupComplete = false private var _editor: EditorView? /// Editor within the cell public var editor: EditorView { if !isRunningTests { assert(editorSetupComplete, - """ - Editor setup is not complete as Grid containing cell is not in a window. - Refer to initialiser documentation for additional details. - """) + """ + Editor setup is not complete as Grid containing cell is not in a window. + Refer to initialiser documentation for additional details. + """) } - if let _editor { return _editor } @@ -186,7 +185,6 @@ public class GridCell { let initialHeight: CGFloat - private let editorInitializer: EditorInitializer /// Initializes the cell /// - Parameters: @@ -214,6 +212,7 @@ public class GridCell { widthAnchorConstraint = contentView.widthAnchor.constraint(equalToConstant: 0) heightAnchorConstraint = contentView.heightAnchor.constraint(equalToConstant: 0) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(contentViewTapped)) contentView.addGestureRecognizer(tapGestureRecognizer) @@ -225,9 +224,7 @@ public class GridCell { } public convenience init(rowSpan: [Int], columnSpan: [Int], initialHeight: CGFloat = 40, style: GridCellStyle = .init(), gridStyle: GridStyle = .default) { - let editor = EditorView(allowAutogrowing: false) - self.init(editorInitializer: { editor }, rowSpan: rowSpan, columnSpan: columnSpan, initialHeight: initialHeight, style: style, gridStyle: gridStyle) - _editor = editor + self.init(editorInitializer: { EditorView(allowAutogrowing: false) }, rowSpan: rowSpan, columnSpan: columnSpan, initialHeight: initialHeight, style: style, gridStyle: gridStyle) } /// Sets the focus in the `Editor` within the cell. @@ -271,12 +268,9 @@ public class GridCell { ]) } - /// Sets up the editor for use. This function is called automatically as soon as the `GridView` moves to a window. - /// Calling this function directly is discouraged as it may result in performance issues when dealing with a Grid having - /// hundreds of cells. Refer to `GridCell` initializer documentation for further details. - public func setupEditor() { - guard editorSetupComplete == false else { return } + func setupEditor() { editorSetupComplete = true + applyStyle(style) editor.translatesAutoresizingMaskIntoConstraints = false editor.boundsObserver = self editor.delegate = self @@ -288,8 +282,6 @@ public class GridCell { editor.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), editor.heightAnchor.constraint(greaterThanOrEqualToConstant: initialHeight) ]) - - applyStyle(style) } func hideEditor() { diff --git a/Proton/Sources/Swift/Grid/View/GridContentView.swift b/Proton/Sources/Swift/Grid/View/GridContentView.swift index 18767ff3..3d97263a 100644 --- a/Proton/Sources/Swift/Grid/View/GridContentView.swift +++ b/Proton/Sources/Swift/Grid/View/GridContentView.swift @@ -60,6 +60,7 @@ class GridContentView: UIScrollView { weak var boundsObserver: BoundsObserving? var isFreeScrollingEnabled = false + var isRendered = false var cells: [GridCell] { grid.cells @@ -99,16 +100,6 @@ class GridContentView: UIScrollView { grid.delegate = self } - var isRendered = false - public override func willMove(toWindow newWindow: UIWindow?) { - guard isRendered == false, - newWindow != nil else { - return - } - isRendered = true - setup() - } - convenience init(config: GridConfiguration) { let cells = Self.generateCells(config: config) self.init(config: config, cells: cells) @@ -143,6 +134,15 @@ class GridContentView: UIScrollView { setupSelectionGesture() } + public override func willMove(toWindow newWindow: UIWindow?) { + guard isRendered == false, + newWindow != nil else { + return + } + isRendered = true + setup() + } + private func makeCells() { for cell in grid.cells { cell.setupEditor() @@ -329,7 +329,6 @@ class GridContentView: UIScrollView { cell.topAnchorConstraint?.constant = frame.minY cell.leadingAnchorConstraint?.constant = frame.minX } - freezeColumnCellIfRequired(cell) freezeRowCellIfRequired(cell) gridContentViewDelegate?.gridContentView(self, didLayoutCell: cell) diff --git a/Proton/Sources/Swift/Grid/View/GridView.swift b/Proton/Sources/Swift/Grid/View/GridView.swift index 9309d00b..1562584d 100644 --- a/Proton/Sources/Swift/Grid/View/GridView.swift +++ b/Proton/Sources/Swift/Grid/View/GridView.swift @@ -233,6 +233,7 @@ public class GridView: UIView { self.config = config super.init(frame: .zero) self.leadingShadowConstraint = leadingShadowView.leadingAnchor.constraint(equalTo: self.leadingAnchor) + setup() } diff --git a/Proton/Sources/Swift/Helpers/SynchronizedArray.swift b/Proton/Sources/Swift/Helpers/SynchronizedArray.swift new file mode 100644 index 00000000..ab052251 --- /dev/null +++ b/Proton/Sources/Swift/Helpers/SynchronizedArray.swift @@ -0,0 +1,78 @@ +// +// ThreadSafeArray.swift +// Proton +// +// Created by Rajdeep Kwatra on 13/9/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 + +final class SynchronizedArray: Sequence { + + private var array: [Element] + private let queue: DispatchQueue + + var count: Int { + return queue.sync { self.array.count } + } + + var isEmpty: Bool { + return queue.sync { self.array.isEmpty } + } + + var first: Element? { + return queue.sync { self.array.first } + } + + var last: Element? { + return queue.sync { self.array.last } + } + + init(array: [Element] = [], qos: DispatchQoS = .userInteractive) { + self.array = array + self.queue = DispatchQueue(label: "com.proton.synchronizedArray", qos: qos) + } + + @discardableResult + func remove(at index: Int) -> Element? { + return queue.sync { + guard self.array.isEmpty == false else { + return nil + } + return self.array.remove(at: index) + } + } + + func removeAll() { + queue.sync { self.array.removeAll() } + } + + func append(_ newElement: Element) { + queue.sync { self.array.append(newElement) } + } + + func insert(_ newElement: Element, at index: Int) { + queue.sync { self.array.insert(newElement, at: index) } + } + + func makeIterator() -> Array.Iterator { + return queue.sync { self.array.makeIterator() } + } + + func asArray() -> [Element] { + return queue.sync { self.array } + } +} diff --git a/Proton/Tests/Editor/EditorSnapshotTests.swift b/Proton/Tests/Editor/EditorSnapshotTests.swift index 11786265..53ba0466 100644 --- a/Proton/Tests/Editor/EditorSnapshotTests.swift +++ b/Proton/Tests/Editor/EditorSnapshotTests.swift @@ -248,6 +248,44 @@ class EditorSnapshotTests: SnapshotTestCase { assertSnapshot(matching: viewController.view, as: .image, record: recordMode) } + func testRendersAsyncAttachments() { + let ex = functionExpectation() + ex.expectedFulfillmentCount = 11 + let viewController = EditorTestViewController() + let editor = viewController.editor + let text = NSMutableAttributedString(string: "Text before panels") + + let delegate = MockAsyncAttachmentRenderingDelegate() + delegate.onDidRenderAttachment = { _, _ in + editor.render() + ex.fulfill() + } + editor.asyncAttachmentRenderingDelegate = delegate + + for i in 1...10 { + var panel = PanelView() + panel.editor.forceApplyAttributedText = true + panel.backgroundColor = .cyan + panel.layer.borderWidth = 1.0 + panel.layer.cornerRadius = 4.0 + panel.layer.borderColor = UIColor.black.cgColor + + let attachment = Attachment(panel, size: .fullWidth) + panel.boundsObserver = attachment + panel.attributedText = NSAttributedString(string: "Panel id: \(i): Some text in the panel") + text.append(attachment.string) + } + text.append(NSMutableAttributedString(string: "Text after panels")) + editor.attributedText = text + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + viewController.render(size: CGSize(width: 300, height: 720)) + assertSnapshot(matching: viewController.view, as: .image, record: self.recordMode) + ex.fulfill() + } + waitForExpectations(timeout: 2.0) + } + func testDeletesAttachments() { let viewController = EditorTestViewController() let editor = viewController.editor diff --git a/Proton/Tests/Editor/EditorViewContextSnapshotTests.swift b/Proton/Tests/Editor/EditorViewContextSnapshotTests.swift index 88c98298..89da5db2 100644 --- a/Proton/Tests/Editor/EditorViewContextSnapshotTests.swift +++ b/Proton/Tests/Editor/EditorViewContextSnapshotTests.swift @@ -38,7 +38,7 @@ class EditorViewContextSnapshotTests: SnapshotTestCase { textView.replaceCharacters(in: .zero, with: "In textView ") textView.insertAttachment(in: textView.textEndRange, attachment: attachment) - textView.selectedRange = NSRange(location: textView.textEndRange.location - 1, length: 0) + textView.selectedRange = NSRange(location: textView.textEndRange.location - 2, length: 1) viewController.render() _ = context.richTextViewContext.textView(textView.richTextView, shouldChangeTextIn: NSRange(location: textView.selectedRange.location - 1, length: 1), replacementText: "") addSelectionRects(at: textView.selectedTextRange!, in: textView, color: .cyan) diff --git a/Proton/Tests/Editor/Mocks/MockAsyncAttachmentRenderingDelegate.swift b/Proton/Tests/Editor/Mocks/MockAsyncAttachmentRenderingDelegate.swift new file mode 100644 index 00000000..8ec306ab --- /dev/null +++ b/Proton/Tests/Editor/Mocks/MockAsyncAttachmentRenderingDelegate.swift @@ -0,0 +1,37 @@ +// +// MockAsyncAttachmentRenderingDelegate.swift +// ProtonTests +// +// Created by Rajdeep Kwatra on 20/9/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 UIKit + +import Proton + +class MockAsyncAttachmentRenderingDelegate: AsyncAttachmentRenderingDelegate { + var onShouldRenderAsync: (Attachment) -> Bool = { _ in return true } + var onDidRenderAttachment: ((Attachment, EditorView) -> Void)? + + func shouldRenderAsync(attachment: Attachment) -> Bool { + onShouldRenderAsync(attachment) + } + + func didRenderAttachment(_ attachment: Attachment, in editor: EditorView) { + onDidRenderAttachment?(attachment, editor) + } +} diff --git a/Proton/Tests/Editor/__Snapshots__/EditorSnapshotTests/testRendersAsyncAttachments.1.png b/Proton/Tests/Editor/__Snapshots__/EditorSnapshotTests/testRendersAsyncAttachments.1.png new file mode 100644 index 00000000..c8d05383 Binary files /dev/null and b/Proton/Tests/Editor/__Snapshots__/EditorSnapshotTests/testRendersAsyncAttachments.1.png differ diff --git a/Proton/Tests/Grid/GridViewAttachmentSnapshotTests.swift b/Proton/Tests/Grid/GridViewAttachmentSnapshotTests.swift index e029619b..2f450a41 100644 --- a/Proton/Tests/Grid/GridViewAttachmentSnapshotTests.swift +++ b/Proton/Tests/Grid/GridViewAttachmentSnapshotTests.swift @@ -631,11 +631,11 @@ class GridViewAttachmentSnapshotTests: SnapshotTestCase { let gridView = attachment.view gridView.insertColumn(at: 0, configuration: GridColumnConfiguration(width: .fractional(0.20))) - // Editor shows caret for some reason - needs further investigation - gridView.cellAt(rowIndex: 0, columnIndex: 0)?.editor.isSelectable = false let newCell10 = try XCTUnwrap(gridView.cellAt(rowIndex: 1, columnIndex: 0)) newCell10.editor.replaceCharacters(in: .zero, with: "New cell") + // Editor shows caret for some reason - needs further investigation + gridView.cellAt(rowIndex: 0, columnIndex: 0)?.editor.isSelectable = false viewController.render(size: CGSize(width: 500, height: 300)) assertSnapshot(matching: viewController.view, as: .image, record: recordMode) } diff --git a/Proton/Tests/Grid/GridViewTests.swift b/Proton/Tests/Grid/GridViewTests.swift index d8b99444..f6b23d8b 100644 --- a/Proton/Tests/Grid/GridViewTests.swift +++ b/Proton/Tests/Grid/GridViewTests.swift @@ -52,7 +52,6 @@ class GridViewTests: XCTestCase { XCTAssertEqual(focusedCell, cell) expectation.fulfill() } - focusedCell.editor.replaceCharacters(in: .zero, with: "This is a test string") focusedCell.editor.selectedRange = rangeToSelect context.richTextViewContext.textViewDidBeginEditing(focusedCell.editor.richTextView)