Skip to content

Commit

Permalink
Refactor the rich text alignment handling to not have any platform br…
Browse files Browse the repository at this point in the history
…anching
  • Loading branch information
danielsaidi committed Feb 14, 2024
1 parent 6710e4f commit 3c0a29e
Show file tree
Hide file tree
Showing 7 changed files with 119 additions and 149 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ public extension RichTextAttributeWriter {
#if os(macOS)
mutableRichText?.setAlignment(alignment, range: safeRange)
#else
let paragraph = richTextParagraphStyle(at: safeRange)
paragraph?.alignment = alignment
let paragraph = richTextParagraphStyle(at: safeRange) ?? .init()
paragraph.alignment = alignment
setRichTextAttribute(
.paragraphStyle,
to: paragraph,
Expand Down
146 changes: 18 additions & 128 deletions Sources/RichTextKit/Component/RichTextViewComponent+Alignment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,150 +10,40 @@ import Foundation

#if canImport(UIKit)
import UIKit
#endif

#if canImport(AppKit) && !targetEnvironment(macCatalyst)
#elseif canImport(AppKit) && !targetEnvironment(macCatalyst)
import AppKit
#endif

public extension RichTextViewComponent {

/// Get the rich text alignment at current range.
var richTextAlignment: RichTextAlignment? {
guard let style = richTextParagraphStyle else { return nil }
return RichTextAlignment(style.alignment)
}

/// Set the rich text alignment at current range.
///
/// > Todo: Something's currently off with alignment. It
/// spills over to other paragraphs when moving the input
/// cursor and inserting new text.
/// > Important: This function will affect the next line
/// of text if we grab `richTextParagraphStyle` and make
/// the alignment change to it, instead of creating this
/// brand new paragraph style.
func setRichTextAlignment(
_ alignment: RichTextAlignment
) {
if richTextAlignment == alignment { return }
setAlignment(alignment.nativeAlignment)
}

#if macOS
private func setAlignment(_ alignment: NSTextAlignment) {
guard let hey = (self as? NSTextView), let textStorage = hey.textStorage, let layoutManager = hey.layoutManager else {
return
}

var lineRange = NSRange(location: NSNotFound, length: 0)

// If there is a selection, find the entire line range based on the selected range
if selectedRange.length > 0 {
lineRange = lineRangeForSelectedRange(selectedRange)
} else {
// If no selection, find the line range for the current cursor position
let cursorLocation = selectedRange.location
lineRange = lineRangeForCursorLocation(cursorLocation)
}

if lineRange.length > 0 {
// Change alignment for the entire line
textStorage.addAttribute(.paragraphStyle, value: createParagraphStyle(alignment: alignment), range: lineRange)
}
}

private func lineRangeForCursorLocation(_ cursorLocation: Int) -> NSRange {
guard let hey = (self as? NSTextView), let textStorage = hey.textStorage, let layoutManager = hey.layoutManager else {
return NSRange(location: NSNotFound, length: 0)
}

let lineRange = (textStorage.string as NSString).lineRange(for: NSRange(location: cursorLocation, length: 0))

// Convert line range to character range
return layoutManager.characterRange(forGlyphRange: lineRange, actualGlyphRange: nil)
}

private func lineRangeForSelectedRange(_ selectedRange: NSRange) -> NSRange {
guard let hey = (self as? NSTextView), let textStorage = hey.textStorage, let layoutManager = hey.layoutManager else {
return NSRange(location: NSNotFound, length: 0)
}

var lineRange = NSRange(location: NSNotFound, length: 0)
layoutManager.enumerateLineFragments(forGlyphRange: selectedRange) { (rect, usedRect, textContainer, glyphRange, stop) in
lineRange = glyphRange
stop.pointee = true
}

// Convert glyph range to character range
return layoutManager.characterRange(forGlyphRange: lineRange, actualGlyphRange: nil)
}


private func createParagraphStyle(alignment: NSTextAlignment) -> NSParagraphStyle {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = alignment
return paragraphStyle
}
#else

private func setTypingAttributesAlignment(_ alignment: RichTextAlignment) {
let style = NSMutableParagraphStyle()
style.alignment = alignment.nativeAlignment
var attributes = richTextAttributes
attributes[.paragraphStyle] = style
typingAttributes = attributes
guard let storage = textStorageWrapper else { return }
let range = lineRange(for: selectedRange)
guard range.length > 0 else { return }
let style = NSMutableParagraphStyle(alignment)
storage.addAttribute(.paragraphStyle, value: style, range: range)
}
}

private extension NSMutableParagraphStyle {

private func setAlignment(_ alignment: NSTextAlignment) {
guard let hey = (self as? UITextView) else {
return
}

var lineRange = NSRange(location: NSNotFound, length: 0)

// If there is a selection, find the entire line range based on the selected range
if selectedRange.length > 0 {
lineRange = lineRangeForSelectedRange(selectedRange)
} else {
// If no selection, find the line range for the current cursor position
let cursorLocation = selectedRange.location
lineRange = lineRangeForCursorLocation(cursorLocation)
}

if lineRange.length > 0 {
// Change alignment for the entire line
hey.textStorage.addAttribute(.paragraphStyle, value: createParagraphStyle(alignment: alignment), range: lineRange)
}
}

private func lineRangeForCursorLocation(_ cursorLocation: Int) -> NSRange {
guard let hey = (self as? UITextView) else {
return NSRange(location: NSNotFound, length: 0)
}

let lineRange = (hey.textStorage.string as NSString).lineRange(for: NSRange(location: cursorLocation, length: 0))

// Convert line range to character range
return hey.layoutManager.characterRange(forGlyphRange: lineRange, actualGlyphRange: nil)
}

private func lineRangeForSelectedRange(_ selectedRange: NSRange) -> NSRange {
guard let hey = (self as? UITextView) else {
return NSRange(location: NSNotFound, length: 0)
}

var lineRange = NSRange(location: NSNotFound, length: 0)
hey.layoutManager.enumerateLineFragments(forGlyphRange: selectedRange) { (rect, usedRect, textContainer, glyphRange, stop) in
lineRange = glyphRange
stop.pointee = true
}

// Convert glyph range to character range
return hey.layoutManager.characterRange(forGlyphRange: lineRange, actualGlyphRange: nil)
}


private func createParagraphStyle(alignment: NSTextAlignment) -> NSParagraphStyle {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = alignment
return paragraphStyle
convenience init(_ alignment: RichTextAlignment) {
self.init()
self.alignment = alignment.nativeAlignment
}
#endif
}
47 changes: 47 additions & 0 deletions Sources/RichTextKit/Component/RichTextViewComponent+Ranges.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// RichTextViewComponent+Ranges.swift
// RichTextKit
//
// Created by Daniel Saidi on 2024-02-14.
//

import Foundation

extension RichTextViewComponent {

/// Get the line range at a certain text location.
func lineRange(at location: Int) -> NSRange {
guard
let manager = layoutManagerWrapper,
let storage = textStorageWrapper
else { return NSRange(location: NSNotFound, length: 0) }
let string = storage.string as NSString
let locationRange = NSRange(location: location, length: 0)
let lineRange = string.lineRange(for: locationRange)
return manager.characterRange(forGlyphRange: lineRange, actualGlyphRange: nil)
}

/// Get the line range for a certain text range.
func lineRange(for range: NSRange) -> NSRange {

// Use the location-based logic if range is empty
if range.length == 0 {
return lineRange(at: range.location)
}

guard let manager = layoutManagerWrapper else {
return NSRange(location: NSNotFound, length: 0)
}

var lineRange = NSRange(location: NSNotFound, length: 0)
manager.enumerateLineFragments(
forGlyphRange: range
) { (_, _, _, glyphRange, stop) in
lineRange = glyphRange
stop.pointee = true
}

// Convert glyph range to character range
return manager.characterRange(forGlyphRange: lineRange, actualGlyphRange: nil)
}
}
20 changes: 20 additions & 0 deletions Sources/RichTextKit/Component/RichTextViewComponent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
import CoreGraphics
import Foundation

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit) && !targetEnvironment(macCatalyst)
import AppKit
#endif


/**
This protocol defines a platform-agnostic api that's shared
by the UIKit and AppKit ``RichTextView`` components.
Expand Down Expand Up @@ -43,15 +50,28 @@ public protocol RichTextViewComponent: AnyObject,

/// Whether or not the text view is the first responder.
var isFirstResponder: Bool { get }

/// The text view's layout manager, if any.
///
/// This is optional and renamed since UIKit will have a
/// non-otional manager and AppKit an optional one.
var layoutManagerWrapper: NSLayoutManager? { get }

/// The text view's mutable attributed string, if any.
var mutableAttributedString: NSMutableAttributedString? { get }

/// The spacing between the text view's edge and its text.
var textContentInset: CGSize { get set }

/// The text view's text storage, if any.
///
/// This is optional and renamed since UIKit will have a
/// non-otional storage and AppKit an optional one.
var textStorageWrapper: NSTextStorage? { get }

/// The text view current typing attributes.
var typingAttributes: RichTextAttributes { get set }


// MARK: - Setup

Expand Down
6 changes: 4 additions & 2 deletions Sources/RichTextKit/Keyboard/RichTextKeyboardToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ private extension RichTextKeyboardToolbar {
.keyboardShortcutsOnly(if: isCompact)

RichTextFont.SizePickerStack(context: context)
.keyboardShortcutsOnly(if: true)
.keyboardShortcutsOnly()
}

@ViewBuilder
Expand Down Expand Up @@ -230,7 +230,9 @@ private extension RichTextKeyboardToolbar {
private extension View {

@ViewBuilder
func keyboardShortcutsOnly(if condition: Bool) -> some View {
func keyboardShortcutsOnly(
if condition: Bool = true
) -> some View {
if condition {
self.hidden()
.frame(width: 0)
Expand Down
17 changes: 11 additions & 6 deletions Sources/RichTextKit/RichTextView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,17 +184,22 @@ open class RichTextView: NSTextView, RichTextViewComponent {
// MARK: - Public Extensions

public extension RichTextView {

/// The text view's layout manager, if any.
var layoutManagerWrapper: NSLayoutManager? {
layoutManager
}

/**
The spacing between the text view's edge and its text.
This is an alias for `textContainerInset`, to make sure
that the text view has a platform-agnostic API.
*/
/// The spacing between the text view edges and its text.
var textContentInset: CGSize {
get { textContainerInset }
set { textContainerInset = newValue }
}

/// The text view's text storage, if any.
var textStorageWrapper: NSTextStorage? {
textStorage
}
}

// MARK: - RichTextProvider
Expand Down
28 changes: 17 additions & 11 deletions Sources/RichTextKit/RichTextView_UIKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ open class RichTextView: UITextView, RichTextViewComponent {
/// Keeps track of the data format used by the view.
private var richTextDataFormat: RichTextDataFormat = .archivedData


// MARK: - Overrides

/**
Expand Down Expand Up @@ -148,6 +149,7 @@ open class RichTextView: UITextView, RichTextViewComponent {
}
#endif


// MARK: - Setup

/**
Expand Down Expand Up @@ -339,7 +341,6 @@ open class RichTextView: UITextView, RichTextViewComponent {
addInteraction(imageDropInteraction)
}
}

#endif
}

Expand All @@ -363,28 +364,33 @@ private extension UIDropSession {
// MARK: - Public Extensions

public extension RichTextView {

/// The text view's layout manager, if any.
var layoutManagerWrapper: NSLayoutManager? {
layoutManager
}

/**
The spacing between the text view's edge and its text.
The reason why this only supports setting a `CGSize` is
that AppKit only supports a `CGSize`. You can still use
the `textContainerInset` of the underlying `UITextView`
if you want more control.
*/
/// The spacing between the text view edges and its text.
var textContentInset: CGSize {
get {
CGSize(
width: textContainerInset.left,
height: textContainerInset.top)
height: textContainerInset.top
)
} set {
textContainerInset = UIEdgeInsets(
top: newValue.height,
left: newValue.width,
bottom: newValue.height,
right: newValue.width)
right: newValue.width
)
}
}

/// The text view's text storage, if any.
var textStorageWrapper: NSTextStorage? {
textStorage
}
}

// MARK: - RichTextProvider
Expand Down

0 comments on commit 3c0a29e

Please sign in to comment.