diff --git a/Simplenote.xcodeproj/project.pbxproj b/Simplenote.xcodeproj/project.pbxproj index 605d06472..294f04ff0 100644 --- a/Simplenote.xcodeproj/project.pbxproj +++ b/Simplenote.xcodeproj/project.pbxproj @@ -472,6 +472,7 @@ BA55B06325F068650042582B /* NoticeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA55B06225F068650042582B /* NoticeController.swift */; }; BA5768EC269BE4D0008B510E /* AccountDeletionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5768EB269BE4D0008B510E /* AccountDeletionController.swift */; }; BA5C1C0725BF9D6C006E3820 /* SPDragBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5C1C0625BF9D6C006E3820 /* SPDragBar.swift */; }; + BA5ED24C2D1F7B4C005ECF89 /* SearchHighlightableTextParagraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5ED24B2D1F7B40005ECF89 /* SearchHighlightableTextParagraph.swift */; }; BA608EF526BB6E0200A9D94E /* ListWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA608EF426BB6E0200A9D94E /* ListWidget.swift */; }; BA608EF726BB6E7400A9D94E /* ListWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA608EF626BB6E7400A9D94E /* ListWidgetProvider.swift */; }; BA608EF926BB6F4C00A9D94E /* ListWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA608EF826BB6F4C00A9D94E /* ListWidgetView.swift */; }; @@ -520,6 +521,7 @@ BAA63C3325EEDA83001589D7 /* NoteLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA63C3225EEDA83001589D7 /* NoteLinkTests.swift */; }; BAADC8A426C634DB004CAAA9 /* WidgetConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAADC8A326C634DB004CAAA9 /* WidgetConstants.swift */; }; BAADC8A526C634DB004CAAA9 /* WidgetConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAADC8A326C634DB004CAAA9 /* WidgetConstants.swift */; }; + BAAE76BB2D21FCD800D04273 /* NSTextContentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAE76BA2D21FCD200D04273 /* NSTextContentManager.swift */; }; BAB017722609456D007A9CC3 /* PublishController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB017712609456D007A9CC3 /* PublishController.swift */; }; BAB01792260AAE93007A9CC3 /* NoticeFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB01791260AAE93007A9CC3 /* NoticeFactory.swift */; }; BAB0179B260AD591007A9CC3 /* PublishControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB0179A260AD591007A9CC3 /* PublishControllerTests.swift */; }; @@ -1176,6 +1178,7 @@ BA55B06225F068650042582B /* NoticeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeController.swift; sourceTree = ""; }; BA5768EB269BE4D0008B510E /* AccountDeletionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionController.swift; sourceTree = ""; }; BA5C1C0625BF9D6C006E3820 /* SPDragBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SPDragBar.swift; sourceTree = ""; }; + BA5ED24B2D1F7B40005ECF89 /* SearchHighlightableTextParagraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHighlightableTextParagraph.swift; sourceTree = ""; }; BA608EF426BB6E0200A9D94E /* ListWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWidget.swift; sourceTree = ""; }; BA608EF626BB6E7400A9D94E /* ListWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWidgetProvider.swift; sourceTree = ""; }; BA608EF826BB6F4C00A9D94E /* ListWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWidgetView.swift; sourceTree = ""; }; @@ -1229,6 +1232,7 @@ BAA59E78269F9FE30068BD3D /* Date+Simplenote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Simplenote.swift"; sourceTree = ""; }; BAA63C3225EEDA83001589D7 /* NoteLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteLinkTests.swift; sourceTree = ""; }; BAADC8A326C634DB004CAAA9 /* WidgetConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetConstants.swift; sourceTree = ""; }; + BAAE76BA2D21FCD200D04273 /* NSTextContentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSTextContentManager.swift; sourceTree = ""; }; BAB017712609456D007A9CC3 /* PublishController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishController.swift; sourceTree = ""; }; BAB01791260AAE93007A9CC3 /* NoticeFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeFactory.swift; sourceTree = ""; }; BAB0179A260AD591007A9CC3 /* PublishControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishControllerTests.swift; sourceTree = ""; }; @@ -1885,6 +1889,8 @@ B52E2B142537480A0074509A /* SPEditorTapRecognizerDelegate.swift */, BABB22E02D162E6200FCF47D /* NSTextLayoutManager+Simplenote.swift */, BABB22E22D162E7D00FCF47D /* NSTextLayoutFragment.swift */, + BAAE76BA2D21FCD200D04273 /* NSTextContentManager.swift */, + BA5ED24B2D1F7B40005ECF89 /* SearchHighlightableTextParagraph.swift */, ); name = Editor; sourceTree = ""; @@ -3585,6 +3591,7 @@ 46A3C98617DFA81A002865AE /* SPEntryListViewController.m in Sources */, A6ABB689256D95EB00E2A076 /* PinLockProgressView.swift in Sources */, B56A696322F9D53400B90398 /* SPAuthViewController.swift in Sources */, + BAAE76BB2D21FCD800D04273 /* NSTextContentManager.swift in Sources */, B50789FE1C1F5517009F097A /* SPInteractivePushPopAnimationController.m in Sources */, B5D3FCD0201F96AC00A813B7 /* StatusChecker.m in Sources */, B57DE27825013C6600B4D435 /* Simperium+Simplenote.swift in Sources */, @@ -3674,6 +3681,7 @@ B5C2EDF0255AFB6C00C09B32 /* PassthruView.swift in Sources */, A6F4882325A8889E0050CFA8 /* UITextField+Tag.swift in Sources */, A64DE6F1255D1CD9001D0526 /* NoteContentHelper.swift in Sources */, + BA5ED24C2D1F7B4C005ECF89 /* SearchHighlightableTextParagraph.swift in Sources */, A628BEB625ECD97900121B64 /* SignupVerificationViewController.swift in Sources */, B550F93322BA6A3300091939 /* ShortcutsHandler.swift in Sources */, A6C0589424AD2B8F006BC572 /* SPNoteHistoryViewController.swift in Sources */, diff --git a/Simplenote/Classes/SPNoteEditorViewController+Extensions.swift b/Simplenote/Classes/SPNoteEditorViewController+Extensions.swift index 48fc05943..e8a62727b 100644 --- a/Simplenote/Classes/SPNoteEditorViewController+Extensions.swift +++ b/Simplenote/Classes/SPNoteEditorViewController+Extensions.swift @@ -870,7 +870,13 @@ extension SPNoteEditorViewController { } private func textContainerHeightForSearchMap() -> CGFloat { - var textContainerHeight = noteEditorTextView.layoutManager.usedRect(for: noteEditorTextView.textContainer).size.height + var textContainerHeight: CGFloat = 0 + + if #available (iOS 17.0, *) { + textContainerHeight = noteEditorTextView.textLayoutManager?.usageBoundsForTextContainer.size.height ?? CGFloat.leastNormalMagnitude + } else { + textContainerHeight = noteEditorTextView.layoutManager.usedRect(for: noteEditorTextView.textContainer).size.height + } textContainerHeight = textContainerHeight + noteEditorTextView.textContainerInset.top + noteEditorTextView.textContainerInset.bottom let textContainerMinHeight = noteEditorTextView.editingRectInWindow().size.height @@ -1059,6 +1065,141 @@ private enum Metrics { static let additionalTagViewAndEditorCollisionDistance: CGFloat = 16.0 } +// MARK: - TextKit 2 +// +extension SPNoteEditorViewController { + @objc + func makeTextView() -> SPEditorTextView { + let textStorage = SPInteractiveTextStorage() + let textContainer = setupTextContainer(with: textStorage) + + return SPEditorTextView(frame: .zero, textContainer: textContainer) + } + + @objc + func setupTextContainer(with textStorage: SPInteractiveTextStorage) -> NSTextContainer { + let container = NSTextContainer(size: .zero) + container.widthTracksTextView = true + container.heightTracksTextView = true + + if #available(iOS 16.0, *) { + let textLayoutManager = NSTextLayoutManager() + let contentStorage = NSTextContentStorage() + contentStorage.delegate = self + textLayoutManager.delegate = self + contentStorage.addTextLayoutManager(textLayoutManager) + textLayoutManager.textContainer = container + + } else { + let layoutManager = NSLayoutManager() + layoutManager.addTextContainer(container) + textStorage.addLayoutManager(layoutManager) + } + + return container + } + + @objc + func highlight(range: NSRange) { + if #available(iOS 17.0, *) { + guard let textLayoutManager = noteEditorTextView.textLayoutManager, + let nsTextRange = textLayoutManager.textContentManager?.textRangeInDocument(for: range) else { + return + } + + textLayoutManager.replaceContents(in: nsTextRange, with: NSAttributedString(string: "This is a string")) + + // textLayoutManager.invalidateLayout(for: nsTextRange) + + // textLayoutManager.ensureLayout(for: nsTextRange) + + // textLayoutManager.textContentManager?.performEditingTransaction({ + // let newString = NSAttributedString(string: "new string") + // (textLayoutManager.textContentManager as! NSTextContentStorage).textStorage!.insert(newString, at: 0) + // }) + } else { + noteEditorTextView.highlight(range, animated: true) { highlightFrame in + self.noteEditorTextView.scrollRectToVisible(highlightFrame, animated: true) + } + } + } +} + +// MARK: NSTextContentStorageDelegate +// + +extension SPNoteEditorViewController: NSTextLayoutManagerDelegate { + public func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: any NSTextLocation, in textElement: NSTextElement) -> NSTextLayoutFragment { + NSTextLayoutFragment(textElement: textElement, range: textElement.elementRange) + } +} + +extension SPNoteEditorViewController: NSTextContentStorageDelegate { + public func textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph? { + guard let originalText = textContentStorage.textStorage?.attributedSubstring(from: range).mutableCopy() as? NSMutableAttributedString else { + return nil + } + + let style = textInRangeIsHeader(range) ? headlineStyle : defaultStyle + originalText.addAttributes(style, range: originalText.fullRange) + + guard searching, + let searchQuery = searchQueryText(), + searchQuery.isEmpty == false, + let searchResultRanges else { + return NSTextParagraph(attributedString: originalText) + } + + return SearchHighlightableTextParagraph(attributedString: originalText, searchText: searchQuery, isSelected: rangeIsSelected(range)) + } + + func textInRangeIsHeader(_ range: NSRange) -> Bool { + range.location == .zero + } + + func rangeIsSelected(_ range: NSRange) -> Bool { + guard let searchResultRanges, + let selected = searchResultRanges[highlightedSearchResultIndex] as? NSRange else { + return false + } + + return NSIntersectionRange(range, selected).length > .zero + } + + // MARK: Styles + // + var headlineFont: UIFont { + UIFont.preferredFont(for: .title1, weight: .bold) + } + + var defaultFont: UIFont { + UIFont.preferredFont(forTextStyle: .body) + } + + var defaultTextColor: UIColor { + UIColor.simplenoteNoteHeadlineColor + } + + var lineSpacing: CGFloat { + defaultFont.lineHeight * Metrics.lineSpacingMultipler + } + + var defaultStyle: [NSAttributedString.Key: Any] { + [ + .font: defaultFont, + .foregroundColor: defaultTextColor, + .paragraphStyle: NSMutableParagraphStyle(lineSpacing: lineSpacing) + ] + } + + var headlineStyle: [NSAttributedString.Key: Any] { + [ + .font: headlineFont, + .foregroundColor: defaultTextColor, + ] + } +} + // MARK: - Localization // private enum Localization { diff --git a/Simplenote/Classes/SPNoteEditorViewController.h b/Simplenote/Classes/SPNoteEditorViewController.h index 1de296fa0..170958db9 100644 --- a/Simplenote/Classes/SPNoteEditorViewController.h +++ b/Simplenote/Classes/SPNoteEditorViewController.h @@ -53,6 +53,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) BOOL searching; @property (nonatomic, assign) NSInteger highlightedSearchResultIndex; +// Search +@property (nonatomic, strong, nullable) NSArray *searchResultRanges; + @property (nonatomic, strong) NoteScrollPositionCache *scrollPositionCache; - (instancetype)initWithNote:(Note *)note; @@ -78,6 +81,7 @@ NS_ASSUME_NONNULL_BEGIN // TODO: We can't use `SearchQuery` as a type here because it doesn't work from swift code (because of SPM) :-( - (void)updateWithSearchQuery:(id _Nullable )query; +- (nullable NSString *)searchQueryText; @end diff --git a/Simplenote/Classes/SPNoteEditorViewController.m b/Simplenote/Classes/SPNoteEditorViewController.m index 57c4f9ab0..8804ed527 100644 --- a/Simplenote/Classes/SPNoteEditorViewController.m +++ b/Simplenote/Classes/SPNoteEditorViewController.m @@ -55,7 +55,6 @@ @interface SPNoteEditorViewController () CGRect { - let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) - let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + var rect: CGRect = .zero + if #available(iOS 17.0, *) { + guard let textLayoutManager, + let contentManager = textLayoutManager.textContentManager, + let startLocation = contentManager.location(contentManager.documentRange.location, + offsetBy: range.location) else { + return .zero + } + + textLayoutManager.enumerateTextLayoutFragments(from: startLocation, using: { fragment in + // We want the frame of the first layout fragment at the given location, so we can return false + rect = fragment.layoutFragmentFrame + return false + }) + } else { + let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + } return rect.offsetBy(dx: textContainerInset.left, dy: textContainerInset.top) } diff --git a/Simplenote/NSTextContentManager.swift b/Simplenote/NSTextContentManager.swift new file mode 100644 index 000000000..f032567fb --- /dev/null +++ b/Simplenote/NSTextContentManager.swift @@ -0,0 +1,13 @@ +import Foundation + +extension NSTextContentManager { + func textRangeInDocument(for range: NSRange) -> NSTextRange? { + guard let startLocation = location(documentRange.location, offsetBy: range.location) else { + return nil + } + + let endLocation = location(startLocation, offsetBy: range.length) + + return NSTextRange(location: startLocation, end: endLocation) + } +} diff --git a/Simplenote/SPTextView.swift b/Simplenote/SPTextView.swift index 70f3d01c2..92796acd3 100644 --- a/Simplenote/SPTextView.swift +++ b/Simplenote/SPTextView.swift @@ -16,7 +16,6 @@ extension SPTextView { if #available(iOS 16.0, *) { let textLayoutManager = NSTextLayoutManager() let contentStorage = NSTextContentStorage() - contentStorage.delegate = self contentStorage.addTextLayoutManager(textLayoutManager) textLayoutManager.textContainer = container @@ -29,66 +28,3 @@ extension SPTextView { return container } } - -// MARK: NSTextContentStorageDelegate -// -extension SPTextView: NSTextContentStorageDelegate { - public func textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph? { - guard let originalText = textContentStorage.textStorage?.attributedSubstring(from: range).mutableCopy() as? NSMutableAttributedString else { - return nil - } - - let style = textInRangeIsHeader(range) ? headlineStyle : defaultStyle - originalText.addAttributes(style, range: originalText.fullRange) - - return NSTextParagraph(attributedString: originalText) - } - - func textInRangeIsHeader(_ range: NSRange) -> Bool { - range.location == .zero - } - - // MARK: Styles - // - var headlineFont: UIFont { - UIFont.preferredFont(for: .title1, weight: .bold) - } - - var defaultFont: UIFont { - UIFont.preferredFont(forTextStyle: .body) - } - - var defaultTextColor: UIColor { - UIColor.simplenoteNoteHeadlineColor - } - - var lineSpacing: CGFloat { - defaultFont.lineHeight * Metrics.lineSpacingMultipler - } - - var defaultStyle: [NSAttributedString.Key: Any] { - [ - .font: defaultFont, - .foregroundColor: defaultTextColor, - .paragraphStyle: NSMutableParagraphStyle(lineSpacing: lineSpacing) - ] - } - - var headlineStyle: [NSAttributedString.Key: Any] { - [ - .font: headlineFont, - .foregroundColor: defaultTextColor, - ] - } -} - -// MARK: - Metrics -// -private enum Metrics { - static let lineSpacingMultiplerPad: CGFloat = 0.40 - static let lineSpacingMultiplerPhone: CGFloat = 0.20 - - static var lineSpacingMultipler: CGFloat { - UIDevice.isPad ? lineSpacingMultiplerPad : lineSpacingMultiplerPhone - } -} diff --git a/Simplenote/SearchHighlightableTextParagraph.swift b/Simplenote/SearchHighlightableTextParagraph.swift new file mode 100644 index 000000000..f6ce45974 --- /dev/null +++ b/Simplenote/SearchHighlightableTextParagraph.swift @@ -0,0 +1,24 @@ +import Foundation + +class SearchHighlightableTextParagraph: NSTextParagraph { + + init(attributedString: NSAttributedString, searchText: String?, isSelected: Bool) { + let plainText = attributedString.string.lowercased() + guard let searchText = searchText?.lowercased(), + plainText.contains(searchText), + let mutableString = attributedString.mutableCopy() as? NSMutableAttributedString else { + super.init(attributedString: attributedString) + return + } + + let range = plainText.nsString.range(of: searchText) + mutableString.addAttributes([ + .backgroundColor: isSelected ? + UIColor.simplenoteEditorSearchHighlightSelectedColor: + UIColor.simplenoteEditorSearchHighlightColor, + .foregroundColor: UIColor.simplenoteEditorSearchHighlightTextColor + ], range: range) + + super.init(attributedString: mutableString) + } +}