Skip to content

WIP: textkit 2 round2 mk3 - Search #1692

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: feature/text-kit-2
Choose a base branch
from
8 changes: 8 additions & 0 deletions Simplenote.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1176,6 +1178,7 @@
BA55B06225F068650042582B /* NoticeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeController.swift; sourceTree = "<group>"; };
BA5768EB269BE4D0008B510E /* AccountDeletionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionController.swift; sourceTree = "<group>"; };
BA5C1C0625BF9D6C006E3820 /* SPDragBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SPDragBar.swift; sourceTree = "<group>"; };
BA5ED24B2D1F7B40005ECF89 /* SearchHighlightableTextParagraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHighlightableTextParagraph.swift; sourceTree = "<group>"; };
BA608EF426BB6E0200A9D94E /* ListWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWidget.swift; sourceTree = "<group>"; };
BA608EF626BB6E7400A9D94E /* ListWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWidgetProvider.swift; sourceTree = "<group>"; };
BA608EF826BB6F4C00A9D94E /* ListWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWidgetView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1229,6 +1232,7 @@
BAA59E78269F9FE30068BD3D /* Date+Simplenote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Simplenote.swift"; sourceTree = "<group>"; };
BAA63C3225EEDA83001589D7 /* NoteLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteLinkTests.swift; sourceTree = "<group>"; };
BAADC8A326C634DB004CAAA9 /* WidgetConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetConstants.swift; sourceTree = "<group>"; };
BAAE76BA2D21FCD200D04273 /* NSTextContentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSTextContentManager.swift; sourceTree = "<group>"; };
BAB017712609456D007A9CC3 /* PublishController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishController.swift; sourceTree = "<group>"; };
BAB01791260AAE93007A9CC3 /* NoticeFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeFactory.swift; sourceTree = "<group>"; };
BAB0179A260AD591007A9CC3 /* PublishControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishControllerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1885,6 +1889,8 @@
B52E2B142537480A0074509A /* SPEditorTapRecognizerDelegate.swift */,
BABB22E02D162E6200FCF47D /* NSTextLayoutManager+Simplenote.swift */,
BABB22E22D162E7D00FCF47D /* NSTextLayoutFragment.swift */,
BAAE76BA2D21FCD200D04273 /* NSTextContentManager.swift */,
BA5ED24B2D1F7B40005ECF89 /* SearchHighlightableTextParagraph.swift */,
);
name = Editor;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
143 changes: 142 additions & 1 deletion Simplenote/Classes/SPNoteEditorViewController+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions Simplenote/Classes/SPNoteEditorViewController.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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

Expand Down
17 changes: 11 additions & 6 deletions Simplenote/Classes/SPNoteEditorViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ @interface SPNoteEditorViewController ()<SPEditorTextViewDelegate,
@property (nonatomic, strong) NSMutableDictionary *noteVersionData;

// Search
@property (nonatomic, strong) NSArray *searchResultRanges;
@property (nonatomic, strong) SearchQuery *searchQuery;

@end
Expand All @@ -77,7 +76,7 @@ - (instancetype _Nonnull)initWithNote:(Note * _Nonnull)note {

- (void)configureTextView
{
_noteEditorTextView = [[SPEditorTextView alloc] init];
_noteEditorTextView = [self makeTextView];
_noteEditorTextView.delegate = self;
_noteEditorTextView.dataDetectorTypes = UIDataDetectorTypeAll;
_noteEditorTextView.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
Expand Down Expand Up @@ -541,6 +540,14 @@ - (void)updateWithSearchQuery:(SearchQuery *)searchQuery
[self.navigationController setToolbarHidden:NO animated:YES];
}

- (NSString *)searchQueryText
{
if (!_searchQuery) {
return nil;
}
return _searchQuery.searchText;
}

- (void)highlightNextSearchResult
{
[self highlightSearchResultAtIndex:(self.highlightedSearchResultIndex + 1) animated:YES];
Expand Down Expand Up @@ -568,13 +575,12 @@ - (void)highlightSearchResultAtIndex:(NSInteger)index animated:(BOOL)animated
self.nextSearchButton.enabled = index < searchResultCount - 1;

NSRange targetRange = [(NSValue *)self.searchResultRanges[index] rangeValue];
[_noteEditorTextView highlightRange:targetRange animated:YES withBlock:^(CGRect highlightFrame) {
[self.noteEditorTextView scrollRectToVisible:highlightFrame animated:animated];
}];
[self highlightWithRange:targetRange];
}

- (void)endSearching:(id)sender {
[self hideSearchMap];
self.searching = NO;

if ([sender isEqual:self.doneSearchButton])
[[SPAppDelegate sharedDelegate].noteListViewController endSearching];
Expand All @@ -587,7 +593,6 @@ - (void)endSearching:(id)sender {

[_noteEditorTextView clearHighlights:(sender ? YES : NO)];

self.searching = NO;

[self configureNavigationController];
[self configureNavigationControllerToolbar];
Expand Down
20 changes: 18 additions & 2 deletions Simplenote/Classes/UITextView+Simplenote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,24 @@ extension UITextView {
/// Returns the Bounding Rect for the specified NSRange
///
func boundingRect(for range: NSRange) -> 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)
}
Expand Down
13 changes: 13 additions & 0 deletions Simplenote/NSTextContentManager.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading