Skip to content
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