diff --git a/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift b/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift index 8bc247d..81567af 100644 --- a/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift +++ b/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift @@ -24,7 +24,7 @@ public enum RichTextAlignment: String, CaseIterable, Codable, Equatable, Identif case .left: self = .left case .right: self = .right case .center: self = .center - case .justified: self = .justified + case .justified: self = .justify default: self = .left } } @@ -36,7 +36,7 @@ public enum RichTextAlignment: String, CaseIterable, Codable, Equatable, Identif case center /// Justified text alignment. - case justified + case justify /// Right text alignment. case right @@ -67,7 +67,7 @@ public extension RichTextAlignment { case .left: .left case .right: .right case .center: .center - case .justified: .justified + case .justify: .justified } } } diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift index 3cc9da8..144ee43 100644 --- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift +++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift @@ -31,9 +31,8 @@ extension RichTextCoordinator { syncContextWithTextView() case .selectRange(let range): setSelectedRange(to: range) - case .setAlignment(_): - //// textView.setRichTextAlignment(alignment) - return + case .setAlignment(let alignment): + textView.setRichTextAlignment(alignment) case .setAttributedString(let string): setAttributedString(to: string) case .setColor(let color, let newValue): diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Alignment.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Alignment.swift new file mode 100644 index 0000000..87e1d71 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Alignment.swift @@ -0,0 +1,33 @@ +// +// RichTextViewComponent+Alignment.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 25/11/24. +// + +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +#endif + +public extension RichTextViewComponent { + + /// Get the text alignment. + var richTextAlignment: RichTextAlignment? { + guard let style = richTextParagraphStyle else { return nil } + return RichTextAlignment(style.alignment) + } + + /// Set the text alignment. + func setRichTextAlignment(_ alignment: RichTextAlignment) { + if richTextAlignment == alignment { return } + let style = NSMutableParagraphStyle( + from: richTextParagraphStyle, + alignment: alignment + ) + setRichTextParagraphStyle(style) + } +} diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Paragraph.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Paragraph.swift new file mode 100644 index 0000000..16fdebd --- /dev/null +++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Paragraph.swift @@ -0,0 +1,39 @@ +// +// RichTextViewComponent+Paragraph.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 25/11/24. +// + +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +#endif + +public extension RichTextViewComponent { + + /// Get the paragraph style. + var richTextParagraphStyle: NSMutableParagraphStyle? { + richTextAttribute(.paragraphStyle) + } + + /// Set the paragraph style. + /// + /// > Todo: The function currently can't handle multiple + /// selected paragraphs. If many paragraphs are selected, + /// it will only affect the first one. + func setRichTextParagraphStyle(_ style: NSParagraphStyle) { + let range = lineRange(for: selectedRange) + guard range.length > 0 else { return } + #if os(watchOS) + setRichTextAttribute(.paragraphStyle, to: style, at: range) + #else + textStorageWrapper?.addAttribute(.paragraphStyle, value: style, range: range) + #endif + } +} diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Ranges.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Ranges.swift new file mode 100644 index 0000000..68f283b --- /dev/null +++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Ranges.swift @@ -0,0 +1,58 @@ +// +// RichTextViewComponent+Ranges.swift +// RichTextKit +// +// Created by Dominik Bucher +// + +import Foundation + +extension RichTextViewComponent { + + var notFoundRange: NSRange { + .init(location: NSNotFound, length: 0) + } + + /// Get the line range at a certain text location. + func lineRange(at location: Int) -> NSRange { + #if os(watchOS) + return notFoundRange + #else + 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) + #endif + } + + /// Get the line range for a certain text range. + func lineRange(for range: NSRange) -> NSRange { + #if os(watchOS) + return notFoundRange + #else + // 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) + #endif + } +} diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes+RichTextAttributes.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes+RichTextAttributes.swift index 9c0b8cf..ccdc10e 100644 --- a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes+RichTextAttributes.swift +++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes+RichTextAttributes.swift @@ -11,6 +11,8 @@ import UIKit import AppKit #endif +import SwiftUI + extension RichAttributes { func toAttributes(font: FontRepresentable? = nil) -> RichTextAttributes { var attributes: RichTextAttributes = [:] @@ -24,6 +26,14 @@ extension RichAttributes { ) } + if let size = size { + font = font.updateFontSize(size: CGFloat(size)) + } + + if let fontName = self.font { + font = font.updateFontName(with: fontName) + } + // Apply bold and italic styles if let isBold = bold, isBold { font = font.makeBold() @@ -45,6 +55,19 @@ extension RichAttributes { attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } + if let color { + attributes[.foregroundColor] = ColorRepresentable(Color(hex: color)) + } + + if let background { + attributes[.backgroundColor] = ColorRepresentable(Color(hex: background)) + } + + if let align { + let style = NSMutableParagraphStyle(from: nil, alignment: align) + attributes[.paragraphStyle] = style + } + // Handle indent and paragraph styles // if let indentLevel = indent { // let paragraphStyle = NSMutableParagraphStyle() diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift index a9a3ff6..463e36b 100644 --- a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift +++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift @@ -21,6 +21,7 @@ public struct RichAttributes: Codable { public let font: String? public let color: String? public let background: String? + public let align: RichTextAlignment? public init( // id: String = UUID().uuidString, @@ -34,7 +35,8 @@ public struct RichAttributes: Codable { size: Int? = nil, font: String? = nil, color: String? = nil, - background: String? = nil + background: String? = nil, + align: RichTextAlignment? = nil ) { // self.id = id self.bold = bold @@ -48,6 +50,7 @@ public struct RichAttributes: Codable { self.font = font self.color = color self.background = background + self.align = align } enum CodingKeys: String, CodingKey { @@ -62,6 +65,7 @@ public struct RichAttributes: Codable { case font = "font" case color = "color" case background = "background" + case align = "align" } public init(from decoder: Decoder) throws { @@ -78,6 +82,7 @@ public struct RichAttributes: Codable { self.font = try values.decodeIfPresent(String.self, forKey: .font) self.color = try values.decodeIfPresent(String.self, forKey: .color) self.background = try values.decodeIfPresent(String.self, forKey: .background) + self.align = try values.decodeIfPresent(RichTextAlignment.self, forKey: .align) } } @@ -95,6 +100,7 @@ extension RichAttributes: Hashable { hasher.combine(font) hasher.combine(color) hasher.combine(background) + hasher.combine(align) } } @@ -114,6 +120,7 @@ extension RichAttributes: Equatable { && lhs.font == rhs.font && lhs.color == rhs.color && lhs.background == rhs.background + && lhs.align == rhs.align ) } } @@ -129,7 +136,8 @@ extension RichAttributes { size: Int? = nil, font: String? = nil, color: String? = nil, - background: String? = nil + background: String? = nil, + align: RichTextAlignment? = nil ) -> RichAttributes { return RichAttributes( bold: (bold != nil ? bold! : self.bold), @@ -142,7 +150,8 @@ extension RichAttributes { size: (size != nil ? size! : self.size), font: (font != nil ? font! : self.font), color: (color != nil ? color! : self.color), - background: (background != nil ? background! : self.background) + background: (background != nil ? background! : self.background), + align: (align != nil ? align! : self.align) ) } @@ -163,7 +172,8 @@ extension RichAttributes { size: (att.size != nil ? (byAdding ? att.size! : nil) : self.size), font: (att.font != nil ? (byAdding ? att.font! : nil) : self.font), color: (att.color != nil ? (byAdding ? att.color! : nil) : self.color), - background: (att.background != nil ? (byAdding ? att.background! : nil) : self.background) + background: (att.background != nil ? (byAdding ? att.background! : nil) : self.background), + align: (att.align != nil ? (byAdding ? att.align! : nil) : self.align) ) } } @@ -201,6 +211,9 @@ extension RichAttributes { if let background = background { styles.append(.background(.init(hex: background))) } + if let align = align { + styles.append(.align(align)) + } return styles } @@ -236,6 +249,9 @@ extension RichAttributes { if let background = background { styles.insert(.background(Color(hex: background))) } + if let align = align { + styles.insert(.align(align)) + } return styles } } @@ -275,6 +291,8 @@ extension RichAttributes { return color == colorItem?.hexString case .background(let color): return background == color?.hexString + case .align(let alignment): + return align == alignment } } } @@ -296,6 +314,7 @@ internal func getRichAttributesFor(styles: [RichTextSpanStyle]) -> RichAttribute var font: String? = nil var color: String? = nil var background: String? = nil + var align: RichTextAlignment? = nil for style in styles { switch style { @@ -332,6 +351,8 @@ internal func getRichAttributesFor(styles: [RichTextSpanStyle]) -> RichAttribute color = textColor?.hexString case .background(let backgroundColor): background = backgroundColor?.hexString + case .align(let alignment): + align = alignment } } return RichAttributes(bold: bold, @@ -344,6 +365,7 @@ internal func getRichAttributesFor(styles: [RichTextSpanStyle]) -> RichAttribute size: size, font: font, color: color, - background: background + background: background, + align: align ) } diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift index c9cc091..7b40820 100644 --- a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift @@ -71,8 +71,11 @@ public extension RichTextFormat { Divider() -// SidebarSection { -// alignmentPicker(value: $context.textAlignment) + SidebarSection { + alignmentPicker(value: $context.textAlignment) + .onChangeBackPort(of: context.textAlignment) { newValue in + context.updateStyle(style: .align(newValue)) + } // HStack { // lineSpacingPicker(for: context) // } @@ -80,9 +83,9 @@ public extension RichTextFormat { // indentButtons(for: context, greedy: true) // superscriptButtons(for: context, greedy: true) // } -// } -// -// Divider() + } + + Divider() if hasColorPickers { SidebarSection { diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift index 98f22b9..28e81ef 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift @@ -134,7 +134,7 @@ extension RichEditorState { activeAttributes = [:] activeStyles.insert(style) - if style.isHeaderStyle || style.isDefault || style.isList { + if style.isHeaderStyle || style.isDefault || style.isList || style.isAlignmentStyle { handleAddOrRemoveHeaderOrListStyle(in: selectedRange, style: style, byAdding: !style.isDefault) } else if !selectedRange.isCollapsed { let addStyle = checkIfStyleIsActiveWithSameAttributes(style) @@ -175,6 +175,10 @@ extension RichEditorState { } else { addStyle = false } + case .align(let alignment): + if let alignment { + addStyle = alignment != self.textAlignment || alignment != .left + } default: return addStyle } @@ -575,7 +579,7 @@ extension RichEditorState { if addStyle || style.isDefault { if style.isDefault { /// This will help to apply header style without loosing other style - let span = RichTextSpanInternal(from: fromIndex, to: toIndex, attributes: style == .default ? .init(header: .default) : getRichAttributesFor(style: style)) + let span = RichTextSpanInternal(from: fromIndex, to: toIndex, attributes: style == .default ? .init(header: .default, align: .left) : getRichAttributesFor(style: style)) spansToUpdate.insert(span) } else if !style.isHeaderStyle && !style.isList { ///When selected range's is surrounded with same styled text it helps to update selected text in editor @@ -807,6 +811,10 @@ extension RichEditorState { if let color { setColor(.background, to: .init(color)) } + case .align(let alignment): + if let alignment, alignment != self.textAlignment { + actionPublisher.send(.setAlignment(alignment)) + } } } } diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift index b235786..4ac4232 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift @@ -53,6 +53,7 @@ public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable { case font(String? = nil) case color(Color? = nil) case background(Color? = nil) + case align(RichTextAlignment? = nil) var key: String { switch self { @@ -90,6 +91,8 @@ public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable { return "color" case .background: return "background" + case .align: + return "align" } } @@ -124,6 +127,8 @@ public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable { return RichTextView.Theme.standard.fontColor case .background: return ColorRepresentable.white + case .align: + return RichTextAlignment.left.nativeAlignment } } @@ -133,7 +138,7 @@ public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable { return .underlineStyle case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font: return .font - case .bullet: + case .bullet, .align: return .paragraphStyle case .strikethrough: return .strikethroughStyle @@ -249,6 +254,15 @@ public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable { } } + var isAlignmentStyle: Bool { + switch self { + case .align: + return true + default: + return false + } + } + var isList: Bool { switch self { case .bullet: @@ -262,6 +276,8 @@ public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable { switch self { case .default: return true + case .align(let alignment): + return alignment == .left default: return false } @@ -273,7 +289,7 @@ public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable { return font case .bold,.italic: return font.addFontStyle(self) - case .underline, .bullet, .strikethrough, .color, .background: + case .underline, .bullet, .strikethrough, .color, .background, .align: return font case .h1: return font.updateFontSize(multiple: 1.5) @@ -323,7 +339,7 @@ public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable { switch self { case .bold, .italic, .bullet: return font.removeFontStyle(self) - case .underline, .strikethrough, .color, .background: + case .underline, .strikethrough, .color, .background, .align: return font case .default, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font: return font.updateFontSize(size: .standardRichTextFontSize) @@ -403,6 +419,8 @@ extension RichTextSpanStyle { return RichAttributes(color: color?.hexString) case .background(let background): return RichAttributes(background: background?.hexString) + case .align(let alignment): + return RichAttributes(align: alignment) } } } diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/FontRepresentable+Extension.swift b/Sources/RichEditorSwiftUI/UI/Extensions/FontRepresentable+Extension.swift index a59d602..6a9956c 100644 --- a/Sources/RichEditorSwiftUI/UI/Extensions/FontRepresentable+Extension.swift +++ b/Sources/RichEditorSwiftUI/UI/Extensions/FontRepresentable+Extension.swift @@ -159,7 +159,37 @@ extension FontRepresentable { return self } } - + + /// Get a new font with updated font size by **size** + func updateFontName(with name: String) -> FontRepresentable { + if fontName != name { + let fontDesc = fontDescriptor +#if os(macOS) + fontDesc.withFamily(name) + return FontRepresentable( + descriptor: fontDesc, + size: pointSize + ) ?? self +#else + fontDesc.withFamily(name) + return FontRepresentable(descriptor: fontDesc, size: pointSize) +#endif + } else { + return self + } + } + + func updateNameAndSize(with name: String? = nil, size: CGFloat? = nil) -> FontRepresentable { + var font = self + if let size { + font = font.updateFontSize(size: size) + } + if let name { + font = font.updateFontName(with: name) + } + return font + } + func updateFontSize(multiple: CGFloat) -> FontRepresentable { if pointSize != multiple * pointSize { let size = multiple * pointSize diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/NSMutableParagraphStyle+Custom.swift b/Sources/RichEditorSwiftUI/UI/Extensions/NSMutableParagraphStyle+Custom.swift new file mode 100644 index 0000000..f90c511 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Extensions/NSMutableParagraphStyle+Custom.swift @@ -0,0 +1,31 @@ +// +// NSMutableParagraphStyle+Custom.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 25/11/24. +// + +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +#endif + +extension NSMutableParagraphStyle { + + convenience init( + from style: NSMutableParagraphStyle? = nil, + alignment: RichTextAlignment? = nil, + indent: CGFloat? = nil, + lineSpacing: CGFloat? = nil + ) { + let style = style ?? .init() + self.init() + self.alignment = alignment?.nativeAlignment ?? style.alignment + self.lineSpacing = lineSpacing ?? style.lineSpacing + self.headIndent = indent ?? style.headIndent + self.firstLineHeadIndent = indent ?? style.headIndent + } +}