diff --git a/RichEditorSwiftUI.podspec b/RichEditorSwiftUI.podspec index 0c50a63..e8afa4e 100644 --- a/RichEditorSwiftUI.podspec +++ b/RichEditorSwiftUI.podspec @@ -7,25 +7,24 @@ Pod::Spec.new do |s| Wrapper around UITextView to support Rich text editing in SwiftUI. DESC - s.homepage = 'https://github.com/canopas/RichEditorSwiftUI' - s.license = { :type => 'MIT', :file => 'LICENSE.md' } - s.author = { 'Jimmy' => 'jimmy@canopas.com' } - s.source = { :git => 'https://github.com/canopas/rich-editor-swiftui.git', :tag => s.version.to_s } - s.social_media_url = 'https://x.com/canopas_eng' + s.homepage = "https://github.com/canopas/RichEditorSwiftUI" + s.license = { :type => "MIT", :file => "LICENSE.md" } + s.author = { "Jimmy" => "jimmy@canopas.com" } + s.source = { :git => "https://github.com/canopas/rich-editor-swiftui.git", :tag => s.version.to_s } + s.social_media_url = "https://x.com/canopas_eng" - s.source_files = 'Sources/**/*.swift' + s.source_files = "Sources/**/*.swift" - s.module_name = 'RichEditorSwiftUI' + s.module_name = "RichEditorSwiftUI" s.requires_arc = true - s.swift_version = '5.9' + s.swift_version = "5.9" - s.ios.deployment_target = '15.0' - s.osx.deployment_target = '12.0' - s.tvos.deployment_target = '17.0' - s.visionos.deployment_target = '1.0' - s.watchos.deployment_target = '8.0' + s.ios.deployment_target = "15.0" + s.osx.deployment_target = "12.0" + s.tvos.deployment_target = "17.0" + s.watchos.deployment_target = "8.0" - s.preserve_paths = 'README.md' + s.preserve_paths = "README.md" end diff --git a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter+List.swift b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter+List.swift deleted file mode 100644 index 948bbdc..0000000 --- a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter+List.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// RichTextAttributeWriter+List.swift -// -// -// Created by Divyesh Vekariya on 09/05/24. -// - -import Foundation - -#if canImport(UIKit) - import UIKit -#endif - -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - import AppKit -#endif - -extension RichTextAttributeWriter { - - /** - Set the text alignment at a certain range. - - Unlike some other attributes, this value applies to the - entire paragraph, not just the selected range. - */ - public func setRichTextListStyle( - _ listType: ListType, - to newValue: Bool, - at range: NSRange - ) { - setListStyle(listType, to: newValue, at: range) - } -} - -extension RichTextAttributeWriter { - - fileprivate func setListStyle( - _ listType: ListType, - to newValue: Bool, - at range: NSRange - ) { - guard let string = mutableRichText else { return } - let safeRange = safeRange(for: range) - - let searchRange = NSRange( - location: max(0, (range.location - 1)), - length: min(string.string.utf16Length, (range.length + 1))) - var previousRang: NSRange? = nil - - var attributesWithRange: - [Int: (range: NSRange, paragraphStyle: NSMutableParagraphStyle)] = - [:] - string.beginEditing() - var previousStyle: NSMutableParagraphStyle? = nil - string.enumerateAttribute(.paragraphStyle, in: searchRange) { - (attribute, range, _) in - - if let style = attribute as? NSMutableParagraphStyle, - !style.textLists.isEmpty - { - if newValue { - /// For add style - attributesWithRange[attributesWithRange.count] = ( - range: range, paragraphStyle: style - ) - - if safeRange.location <= range.location - && safeRange.upperBound >= range.upperBound - { - string.removeAttribute(.paragraphStyle, range: range) - } - - if let oldRange = previousRang, - let previousStyle = previousStyle, - previousStyle.textLists.count == listType.getIndent() - { - let location = min(oldRange.location, range.location) - let length = - max(oldRange.upperBound, range.upperBound) - - location - let combinedRange = NSRange( - location: location, length: length) - - string.addAttribute( - .paragraphStyle, value: previousStyle, - range: combinedRange) - previousRang = combinedRange - } else { - let location = min(safeRange.location, range.location) - let length = - max(safeRange.upperBound, range.upperBound) - - location - let combinedRange = NSRange( - location: location, length: length) - - string.addAttribute( - .paragraphStyle, value: style, range: combinedRange) - previousRang = combinedRange - } - previousStyle = style - } else { - /// Fore Remove Style - if safeRange.closedRange.overlaps(range.closedRange) { - if style.textLists.count == listType.getIndent() { - string.removeAttribute( - .paragraphStyle, range: safeRange) - previousRang = nil - previousStyle = nil - } - } - } - } - } - - ///Add style if not already added - if attributesWithRange.isEmpty { - - let paragraphStyle = NSMutableParagraphStyle() - - paragraphStyle.alignment = .left - let listItem = TextList( - markerFormat: listType.getMarkerFormat(), options: 0) - - if paragraphStyle.textLists.isEmpty && newValue { - paragraphStyle.textLists.append(listItem) - } else { - paragraphStyle.textLists.removeAll() - } - - if !paragraphStyle.textLists.isEmpty { - string.addAttributes( - [.paragraphStyle: paragraphStyle], range: safeRange) - } else { - string.removeAttribute(.paragraphStyle, range: safeRange) - } - } - - string.fixAttributes(in: range) - string.endEditing() - } -} diff --git a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter+Style.swift b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter+Style.swift deleted file mode 100644 index b1198f2..0000000 --- a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter+Style.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// RichTextAttributeWriter+Style.swift -// -// -// Created by Divyesh Vekariya on 28/12/23. -// - -import Foundation - -extension NSMutableAttributedString { - - /** - Set a rich text style at a certain range. - - The function uses `safeRange(for:)` to handle incorrect - ranges, which is not handled by the native functions. - - - Parameters: - - style: The style to set. - - newValue: The new value to set the attribute to. - - range: The range to affect, by default the entire text. - */ - public func setRichTextStyle( - _ style: RichTextSpanStyle, - to newValue: Bool, - at range: NSRange? = nil - ) { - let rangeValue = range ?? richTextRange - let range = safeRange(for: rangeValue) - - if style.isList, let style = style.listType { - setRichTextListStyle(style, to: newValue, at: range) - } - - guard !style.isList else { return } - - let attributeValue = newValue ? 1 : 0 - if style == .underline { - return setRichTextAttribute( - .underlineStyle, to: attributeValue, at: range) - } - if style == .strikethrough { - return setRichTextAttribute( - .strikethroughStyle, to: attributeValue, at: range) - } - let font = richTextFont(at: range) ?? .standardRichTextFont - let styles = richTextStyles(at: range) - let shouldAdd = newValue && !styles.hasStyle(style) - let shouldRemove = !newValue && styles.hasStyle(style) - guard shouldAdd || shouldRemove || style.isHeaderStyle else { return } - var descriptor = font.fontDescriptor - if let richTextStyle = style.richTextStyle, - !style.isDefault && !style.isHeaderStyle - { - descriptor = descriptor.byTogglingStyle(richTextStyle) - } - let newFont: FontRepresentable? = FontRepresentable( - descriptor: descriptor, - size: byTogglingFontSizeFor( - style: style, font: font, shouldAdd: newValue)) - guard let newFont = newFont else { return } - setRichTextFont(newFont, at: range) - } - - /** - This will reset font size before multiplying new size - */ - private func byTogglingFontSizeFor( - style: RichTextSpanStyle, font: FontRepresentable, shouldAdd: Bool - ) -> CGFloat { - guard style.isHeaderStyle || style.isDefault else { - return font.pointSize - } - - let cleanFont = style.getFontAfterRemovingStyle(font: font) - if shouldAdd { - return cleanFont.pointSize * style.fontSizeMultiplier - } else { - return font.pointSize / style.fontSizeMultiplier - } - } -} diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift index c35f957..cbda37b 100644 --- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift +++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift @@ -5,56 +5,56 @@ // Created by Divyesh Vekariya on 21/10/24. // #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) - import Combine - import SwiftUI + import Combine + import SwiftUI - /// This coordinator is used to keep a ``RichTextView`` in sync - /// with a ``RichEditorState``. - /// - /// This is used by ``RichTextEditor`` to coordinate changes in - /// its context and the underlying text view. - /// - /// The coordinator sets itself as the text view's delegate. It - /// updates the context when things change in the text view and - /// syncs to context changes to the text view. - open class RichTextCoordinator: NSObject { + /// This coordinator is used to keep a ``RichTextView`` in sync + /// with a ``RichEditorState``. + /// + /// This is used by ``RichTextEditor`` to coordinate changes in + /// its context and the underlying text view. + /// + /// The coordinator sets itself as the text view's delegate. It + /// updates the context when things change in the text view and + /// syncs to context changes to the text view. + open class RichTextCoordinator: NSObject { - // MARK: - Properties + // MARK: - Properties - /// The rich text context to coordinate with. - public let context: RichEditorState + /// The rich text context to coordinate with. + public let context: RichEditorState - /// The rich text to edit. - public var text: Binding + /// The rich text to edit. + public var text: Binding - /// The text view for which the coordinator is used. - public private(set) var textView: RichTextView + /// The text view for which the coordinator is used. + public private(set) var textView: RichTextView - /// This set is used to store context observations. - public var cancellables = Set() + /// This set is used to store context observations. + public var cancellables = Set() - /// This flag is used to avoid delaying context sync. - var shouldDelaySyncContextWithTextView = false + /// This flag is used to avoid delaying context sync. + var shouldDelaySyncContextWithTextView = false - // MARK: - Internal Properties + // MARK: - Internal Properties - /** + /** The background color that was used before the currently highlighted range was set. */ - var highlightedRangeOriginalBackgroundColor: ColorRepresentable? + var highlightedRangeOriginalBackgroundColor: ColorRepresentable? - /** + /** The foreground color that was used before the currently highlighted range was set. */ - var highlightedRangeOriginalForegroundColor: ColorRepresentable? + var highlightedRangeOriginalForegroundColor: ColorRepresentable? - private var cancellable: Set = [] + private var cancellable: Set = [] - // MARK: - Initialization + // MARK: - Initialization - /** + /** Create a rich text coordinator. - Parameters: @@ -62,211 +62,211 @@ - textView: The rich text view to keep in sync. - richEditorState: The context to keep in sync. */ - public init( - text: Binding, - textView: RichTextView, - richTextContext: RichEditorState - ) { - textView.attributedString = text.wrappedValue - self.text = text - self.textView = textView - self.context = richTextContext - super.init() - self.textView.delegate = self - subscribeToUserActions() - } - #if canImport(UIKit) - - // MARK: - UITextViewDelegate - - open func textViewDidBeginEditing(_ textView: UITextView) { - context.onTextViewEvent( - .didBeginEditing( - selectedRange: textView.selectedRange, - text: textView.attributedText - ) - ) - context.isEditingText = true - } - - open func textViewDidChange(_ textView: UITextView) { - context.onTextViewEvent( - .didChange( - selectedRange: textView.selectedRange, - text: textView.attributedText - ) - ) - syncWithTextView() - } - - open func textViewDidChangeSelection(_ textView: UITextView) { - context.onTextViewEvent( - .didChangeSelection( - selectedRange: textView.selectedRange, - text: textView.attributedText - ) - ) - syncWithTextView() - } - - open func textViewDidEndEditing(_ textView: UITextView) { - context.onTextViewEvent( - .didEndEditing( - selectedRange: textView.selectedRange, - text: textView.attributedText - ) - ) - context.isEditingText = false - syncWithTextView() - } - #endif - - #if canImport(AppKit) && !targetEnvironment(macCatalyst) - - // MARK: - NSTextViewDelegate - - open func textDidBeginEditing(_ notification: Notification) { - context.onTextViewEvent( - .didBeginEditing( - selectedRange: textView.selectedRange, - text: textView.attributedString() - ) - ) - context.isEditingText = true - } - - open func textDidChange(_ notification: Notification) { - context.onTextViewEvent( - .didChange( - selectedRange: textView.selectedRange, - text: textView.attributedString() - ) - ) - syncWithTextView() - } - - open func textViewDidChangeSelection(_ notification: Notification) { - replaceCurrentAttributesIfNeeded() - context.onTextViewEvent( - .didChangeSelection( - selectedRange: textView.selectedRange, - text: textView.attributedString() - ) - ) - syncWithTextView() - } - - open func textDidEndEditing(_ notification: Notification) { - context.onTextViewEvent( - .didEndEditing( - selectedRange: textView.selectedRange, - text: textView.attributedString() - ) - ) - context.isEditingText = false - } - #endif + public init( + text: Binding, + textView: RichTextView, + richTextContext: RichEditorState + ) { + textView.attributedString = text.wrappedValue + self.text = text + self.textView = textView + self.context = richTextContext + super.init() + self.textView.delegate = self + subscribeToUserActions() } + #if canImport(UIKit) + + // MARK: - UITextViewDelegate + + open func textViewDidBeginEditing(_ textView: UITextView) { + context.onTextViewEvent( + .didBeginEditing( + selectedRange: textView.selectedRange, + text: textView.attributedText + ) + ) + context.isEditingText = true + } + + open func textViewDidChange(_ textView: UITextView) { + context.onTextViewEvent( + .didChange( + selectedRange: textView.selectedRange, + text: textView.attributedText + ) + ) + syncWithTextView() + } + + open func textViewDidChangeSelection(_ textView: UITextView) { + context.onTextViewEvent( + .didChangeSelection( + selectedRange: textView.selectedRange, + text: textView.attributedText + ) + ) + syncWithTextView() + } + + open func textViewDidEndEditing(_ textView: UITextView) { + context.onTextViewEvent( + .didEndEditing( + selectedRange: textView.selectedRange, + text: textView.attributedText + ) + ) + context.isEditingText = false + syncWithTextView() + } + #endif - #if os(iOS) || os(tvOS) || os(visionOS) - import UIKit + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + + // MARK: - NSTextViewDelegate + + open func textDidBeginEditing(_ notification: Notification) { + context.onTextViewEvent( + .didBeginEditing( + selectedRange: textView.selectedRange, + text: textView.attributedString() + ) + ) + context.isEditingText = true + } + + open func textDidChange(_ notification: Notification) { + context.onTextViewEvent( + .didChange( + selectedRange: textView.selectedRange, + text: textView.attributedString() + ) + ) + syncWithTextView() + } + + open func textViewDidChangeSelection(_ notification: Notification) { + replaceCurrentAttributesIfNeeded() + context.onTextViewEvent( + .didChangeSelection( + selectedRange: textView.selectedRange, + text: textView.attributedString() + ) + ) + syncWithTextView() + } + + open func textDidEndEditing(_ notification: Notification) { + context.onTextViewEvent( + .didEndEditing( + selectedRange: textView.selectedRange, + text: textView.attributedString() + ) + ) + context.isEditingText = false + } + #endif + } - extension RichTextCoordinator: UITextViewDelegate {} + #if os(iOS) || os(tvOS) || os(visionOS) + import UIKit - #elseif macOS - import AppKit + extension RichTextCoordinator: UITextViewDelegate {} - extension RichTextCoordinator: NSTextViewDelegate {} - #endif + #elseif os(macOS) + import AppKit - // MARK: - Public Extensions + extension RichTextCoordinator: NSTextViewDelegate {} + #endif - extension RichTextCoordinator { + // MARK: - Public Extensions - /// Reset appearance for the currently highlighted range. - public func resetHighlightedRangeAppearance() { - guard - let range = context.highlightedRange, - let background = highlightedRangeOriginalBackgroundColor, - let foreground = highlightedRangeOriginalForegroundColor - else { return } - textView.setRichTextColor(.background, to: background, at: range) - textView.setRichTextColor(.foreground, to: foreground, at: range) - } + extension RichTextCoordinator { + + /// Reset appearance for the currently highlighted range. + public func resetHighlightedRangeAppearance() { + guard + let range = context.highlightedRange, + let background = highlightedRangeOriginalBackgroundColor, + let foreground = highlightedRangeOriginalForegroundColor + else { return } + textView.setRichTextColor(.background, to: background, at: range) + textView.setRichTextColor(.foreground, to: foreground, at: range) } + } - // MARK: - Internal Extensions + // MARK: - Internal Extensions - extension RichTextCoordinator { + extension RichTextCoordinator { - /// Sync state from the text view's current state. - func syncWithTextView() { - syncContextWithTextView() - syncTextWithTextView() - } + /// Sync state from the text view's current state. + func syncWithTextView() { + syncContextWithTextView() + syncTextWithTextView() + } - /// Sync the rich text context with the text view. - func syncContextWithTextView() { - if shouldDelaySyncContextWithTextView { - DispatchQueue.main.async { - self.syncContextWithTextViewAfterDelay() - } - } else { - syncContextWithTextViewAfterDelay() - } + /// Sync the rich text context with the text view. + func syncContextWithTextView() { + if shouldDelaySyncContextWithTextView { + DispatchQueue.main.async { + self.syncContextWithTextViewAfterDelay() } + } else { + syncContextWithTextViewAfterDelay() + } + } - func sync(_ prop: inout T, with value: T) { - if prop == value { return } - prop = value - } + func sync(_ prop: inout T, with value: T) { + if prop == value { return } + prop = value + } - /// Sync the rich text context with the text view. - func syncContextWithTextViewAfterDelay() { - let font = textView.richTextFont ?? .standardRichTextFont - sync(&context.attributedString, with: textView.attributedString) - sync(&context.selectedRange, with: textView.selectedRange) - sync(&context.canCopy, with: textView.hasSelectedRange) - sync( - &context.canRedoLatestChange, - with: textView.undoManager?.canRedo ?? false) - sync( - &context.canUndoLatestChange, - with: textView.undoManager?.canUndo ?? false) - sync(&context.fontName, with: font.fontName) - sync(&context.fontSize, with: font.pointSize) - sync(&context.isEditingText, with: textView.isFirstResponder) - sync( - &context.paragraphStyle, - with: textView.richTextParagraphStyle ?? .default) - sync( - &context.textAlignment, - with: textView.richTextAlignment ?? .left) - sync(&context.link, with: textView.richTextLink) - - RichTextColor.allCases.forEach { - if let color = textView.richTextColor($0) { - context.setColor($0, to: color) - } - } - - let styles = textView.richTextStyles - RichTextStyle.allCases.forEach { - let style = styles.hasStyle($0) - context.setStyleInternal($0, to: style) - } - - updateTextViewAttributesIfNeeded() + /// Sync the rich text context with the text view. + func syncContextWithTextViewAfterDelay() { + let font = textView.richTextFont ?? .standardRichTextFont + sync(&context.attributedString, with: textView.attributedString) + sync(&context.selectedRange, with: textView.selectedRange) + sync(&context.canCopy, with: textView.hasSelectedRange) + sync( + &context.canRedoLatestChange, + with: textView.undoManager?.canRedo ?? false) + sync( + &context.canUndoLatestChange, + with: textView.undoManager?.canUndo ?? false) + sync(&context.fontName, with: font.fontName) + sync(&context.fontSize, with: font.pointSize) + sync(&context.isEditingText, with: textView.isFirstResponder) + sync( + &context.paragraphStyle, + with: textView.richTextParagraphStyle ?? .default) + sync( + &context.textAlignment, + with: textView.richTextAlignment ?? .left) + sync(&context.link, with: textView.richTextLink) + + RichTextColor.allCases.forEach { + if let color = textView.richTextColor($0) { + context.setColor($0, to: color) } + } - /// Sync the text binding with the text view. - func syncTextWithTextView() { - DispatchQueue.main.async { - self.text.wrappedValue = self.textView.attributedString - } - } + let styles = textView.richTextStyles + RichTextStyle.allCases.forEach { + let style = styles.hasStyle($0) + context.setStyleInternal($0, to: style) + } - /** + updateTextViewAttributesIfNeeded() + } + + /// Sync the text binding with the text view. + func syncTextWithTextView() { + DispatchQueue.main.async { + self.text.wrappedValue = self.textView.attributedString + } + } + + /** On macOS, we have to update the font and colors when we move the text input cursor and there's no selected text. @@ -280,25 +280,25 @@ the presented information will be correct, but when you type, the last selected font, colors etc. will be used. */ - func updateTextViewAttributesIfNeeded() { - #if macOS - if textView.hasSelectedRange { return } - let attributes = textView.richTextAttributes - textView.setRichTextAttributes(attributes) - #endif - } + func updateTextViewAttributesIfNeeded() { + #if os(macOS) + if textView.hasSelectedRange { return } + let attributes = textView.richTextAttributes + textView.setRichTextAttributes(attributes) + #endif + } - /** + /** On macOS, we have to update the typingAttributes when we move the text input cursor and there's no selected text. So that the current attributes will set again for updated location. */ - func replaceCurrentAttributesIfNeeded() { - #if macOS - if textView.hasSelectedRange { return } - let attributes = textView.richTextAttributes - textView.setNewRichTextAttributes(attributes) - #endif - } + func replaceCurrentAttributesIfNeeded() { + #if os(macOS) + if textView.hasSelectedRange { return } + let attributes = textView.richTextAttributes + textView.setNewRichTextAttributes(attributes) + #endif } + } #endif diff --git a/Sources/RichEditorSwiftUI/Colors/ColorRepresentable.swift b/Sources/RichEditorSwiftUI/Colors/ColorRepresentable.swift index e517ee1..df195a6 100644 --- a/Sources/RichEditorSwiftUI/Colors/ColorRepresentable.swift +++ b/Sources/RichEditorSwiftUI/Colors/ColorRepresentable.swift @@ -5,27 +5,27 @@ // Created by Divyesh Vekariya on 21/10/24. // -#if macOS - import AppKit +#if os(macOS) + import AppKit - /// This typealias bridges platform-specific colors to simplify - /// multi-platform support. - public typealias ColorRepresentable = NSColor + /// This typealias bridges platform-specific colors to simplify + /// multi-platform support. + public typealias ColorRepresentable = NSColor #endif #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) - import UIKit + import UIKit - /// This typealias bridges platform-specific colors to simplify - /// multi-platform support. - public typealias ColorRepresentable = UIColor + /// This typealias bridges platform-specific colors to simplify + /// multi-platform support. + public typealias ColorRepresentable = UIColor #endif #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) - extension ColorRepresentable { + extension ColorRepresentable { - #if os(iOS) || os(tvOS) || os(visionOS) - public static var textColor: ColorRepresentable { .label } - #endif - } + #if os(iOS) || os(tvOS) || os(visionOS) + public static var textColor: ColorRepresentable { .label } + #endif + } #endif diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Attributes.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Attributes.swift index 3c3f119..f27cff5 100644 --- a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Attributes.swift +++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Attributes.swift @@ -9,72 +9,72 @@ import Foundation extension RichTextViewComponent { - /// Get all attributes. - public var richTextAttributes: RichTextAttributes { - if hasSelectedRange { - return richTextAttributes(at: selectedRange) - } - - #if macOS - let range = NSRange(location: selectedRange.location - 1, length: 1) - let safeRange = safeRange(for: range) - return richTextAttributes(at: safeRange) - #else - return typingAttributes - #endif + /// Get all attributes. + public var richTextAttributes: RichTextAttributes { + if hasSelectedRange { + return richTextAttributes(at: selectedRange) } - /// Get a certain attribute. - public func richTextAttribute( - _ attribute: RichTextAttribute - ) -> Value? { - richTextAttributes[attribute] as? Value - } + #if os(macOS) + let range = NSRange(location: selectedRange.location - 1, length: 1) + let safeRange = safeRange(for: range) + return richTextAttributes(at: safeRange) + #else + return typingAttributes + #endif + } - /// Set a certain attribute. - public func setRichTextAttribute( - _ attribute: RichTextAttribute, - to value: Any - ) { - if hasSelectedRange { - setRichTextAttribute(attribute, to: value, at: selectedRange) - } else { - typingAttributes[attribute] = value - } - } + /// Get a certain attribute. + public func richTextAttribute( + _ attribute: RichTextAttribute + ) -> Value? { + richTextAttributes[attribute] as? Value + } - /// Set certain attributes. - public func setRichTextAttributes( - _ attributes: RichTextAttributes - ) { - attributes.forEach { attribute, value in - setRichTextAttribute(attribute, to: value) - } + /// Set a certain attribute. + public func setRichTextAttribute( + _ attribute: RichTextAttribute, + to value: Any + ) { + if hasSelectedRange { + setRichTextAttribute(attribute, to: value, at: selectedRange) + } else { + typingAttributes[attribute] = value } + } - public func setNewRichTextAttributes( - _ attributes: RichTextAttributes - ) { - typingAttributes = attributes + /// Set certain attributes. + public func setRichTextAttributes( + _ attributes: RichTextAttributes + ) { + attributes.forEach { attribute, value in + setRichTextAttribute(attribute, to: value) } + } + + public func setNewRichTextAttributes( + _ attributes: RichTextAttributes + ) { + typingAttributes = attributes + } - /// Remove a certain attribute. - public func removeRichTextAttribute( - _ attribute: RichTextAttribute - ) { - if hasSelectedRange { - removeRichTextAttribute(attribute, at: selectedRange) - } else { - typingAttributes[attribute] = nil - } + /// Remove a certain attribute. + public func removeRichTextAttribute( + _ attribute: RichTextAttribute + ) { + if hasSelectedRange { + removeRichTextAttribute(attribute, at: selectedRange) + } else { + typingAttributes[attribute] = nil } + } - /// Remove certain attributes. - public func removeRichTextAttributes( - _ attributes: RichTextAttributes - ) { - attributes.forEach { attribute, value in - removeRichTextAttribute(attribute) - } + /// Remove certain attributes. + public func removeRichTextAttributes( + _ attributes: RichTextAttributes + ) { + attributes.forEach { attribute, value in + removeRichTextAttribute(attribute) } + } } diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift index 484947f..e618167 100644 --- a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift +++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift @@ -9,407 +9,408 @@ import SwiftUI // MARK: - RichAttributes public struct RichAttributes: Codable { - // public let id: String - public let bold: Bool? - public let italic: Bool? - public let underline: Bool? - public let strike: Bool? - public let header: HeaderType? - public let list: ListType? - public let indent: Int? - public let size: Int? - public let font: String? - public let color: String? - public let background: String? - public let align: RichTextAlignment? - public let link: String? + // public let id: String + public let bold: Bool? + public let italic: Bool? + public let underline: Bool? + public let strike: Bool? + public let header: HeaderType? + // public let list: ListType? + public let indent: Int? + public let size: Int? + public let font: String? + public let color: String? + public let background: String? + public let align: RichTextAlignment? + public let link: String? - public init( - // id: String = UUID().uuidString, - bold: Bool? = nil, - italic: Bool? = nil, - underline: Bool? = nil, - strike: Bool? = nil, - header: HeaderType? = nil, - list: ListType? = nil, - indent: Int? = nil, - size: Int? = nil, - font: String? = nil, - color: String? = nil, - background: String? = nil, - align: RichTextAlignment? = nil, - link: String? = nil - ) { - // self.id = id - self.bold = bold - self.italic = italic - self.underline = underline - self.strike = strike - self.header = header - self.list = list - self.indent = indent - self.size = size - self.font = font - self.color = color - self.background = background - self.align = align - self.link = link - } + public init( + // id: String = UUID().uuidString, + bold: Bool? = nil, + italic: Bool? = nil, + underline: Bool? = nil, + strike: Bool? = nil, + header: HeaderType? = nil, + // list: ListType? = nil, + indent: Int? = nil, + size: Int? = nil, + font: String? = nil, + color: String? = nil, + background: String? = nil, + align: RichTextAlignment? = nil, + link: String? = nil + ) { + // self.id = id + self.bold = bold + self.italic = italic + self.underline = underline + self.strike = strike + self.header = header + // self.list = list + self.indent = indent + self.size = size + self.font = font + self.color = color + self.background = background + self.align = align + self.link = link + } - enum CodingKeys: String, CodingKey { - case bold = "bold" - case italic = "italic" - case underline = "underline" - case strike = "strike" - case header = "header" - case list = "list" - case indent = "indent" - case size = "size" - case font = "font" - case color = "color" - case background = "background" - case align = "align" - case link = "link" - } + enum CodingKeys: String, CodingKey { + case bold = "bold" + case italic = "italic" + case underline = "underline" + case strike = "strike" + case header = "header" + // case list = "list" + case indent = "indent" + case size = "size" + case font = "font" + case color = "color" + case background = "background" + case align = "align" + case link = "link" + } - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - // self.id = UUID().uuidString - self.bold = try values.decodeIfPresent(Bool.self, forKey: .bold) - self.italic = try values.decodeIfPresent(Bool.self, forKey: .italic) - self.underline = try values.decodeIfPresent( - Bool.self, forKey: .underline) - self.strike = try values.decodeIfPresent(Bool.self, forKey: .strike) - self.header = try values.decodeIfPresent( - HeaderType.self, forKey: .header) - self.list = try values.decodeIfPresent(ListType.self, forKey: .list) - self.indent = try values.decodeIfPresent(Int.self, forKey: .indent) - self.size = try values.decodeIfPresent(Int.self, forKey: .size) - 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) - self.link = try values.decodeIfPresent(String.self, forKey: .link) - } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + // self.id = UUID().uuidString + self.bold = try values.decodeIfPresent(Bool.self, forKey: .bold) + self.italic = try values.decodeIfPresent(Bool.self, forKey: .italic) + self.underline = try values.decodeIfPresent( + Bool.self, forKey: .underline) + self.strike = try values.decodeIfPresent(Bool.self, forKey: .strike) + self.header = try values.decodeIfPresent( + HeaderType.self, forKey: .header) + // self.list = try values.decodeIfPresent(ListType.self, forKey: .list) + self.indent = try values.decodeIfPresent(Int.self, forKey: .indent) + self.size = try values.decodeIfPresent(Int.self, forKey: .size) + 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) + self.link = try values.decodeIfPresent(String.self, forKey: .link) + } } extension RichAttributes: Hashable { - public func hash(into hasher: inout Hasher) { - // hasher.combine(id) - hasher.combine(bold) - hasher.combine(italic) - hasher.combine(underline) - hasher.combine(strike) - hasher.combine(header) - hasher.combine(list) - hasher.combine(indent) - hasher.combine(size) - hasher.combine(font) - hasher.combine(color) - hasher.combine(background) - hasher.combine(align) - hasher.combine(link) - } + public func hash(into hasher: inout Hasher) { + // hasher.combine(id) + hasher.combine(bold) + hasher.combine(italic) + hasher.combine(underline) + hasher.combine(strike) + hasher.combine(header) + // hasher.combine(list) + hasher.combine(indent) + hasher.combine(size) + hasher.combine(font) + hasher.combine(color) + hasher.combine(background) + hasher.combine(align) + hasher.combine(link) + } } extension RichAttributes: Equatable { - public static func == ( - lhs: RichAttributes, - rhs: RichAttributes - ) -> Bool { - return ( - // lhs.id == rhs.id - lhs.bold == rhs.bold - && lhs.italic == rhs.italic - && lhs.underline == rhs.underline - && lhs.strike == rhs.strike - && lhs.header == rhs.header - && lhs.list == rhs.list - && lhs.indent == rhs.indent - && lhs.size == rhs.size - && lhs.font == rhs.font - && lhs.color == rhs.color - && lhs.background == rhs.background - && lhs.align == rhs.align - && lhs.link == rhs.link) - } + public static func == ( + lhs: RichAttributes, + rhs: RichAttributes + ) -> Bool { + return ( + // lhs.id == rhs.id + lhs.bold == rhs.bold + && lhs.italic == rhs.italic + && lhs.underline == rhs.underline + && lhs.strike == rhs.strike + && lhs.header == rhs.header + // && lhs.list == rhs.list + && lhs.indent == rhs.indent + && lhs.size == rhs.size + && lhs.font == rhs.font + && lhs.color == rhs.color + && lhs.background == rhs.background + && lhs.align == rhs.align + && lhs.link == rhs.link) + } } extension RichAttributes { - public func copy( - bold: Bool? = nil, - header: HeaderType? = nil, - italic: Bool? = nil, - underline: Bool? = nil, - strike: Bool? = nil, - list: ListType? = nil, - indent: Int? = nil, - size: Int? = nil, - font: String? = nil, - color: String? = nil, - background: String? = nil, - align: RichTextAlignment? = nil, - link: String? = nil - ) -> RichAttributes { - return RichAttributes( - bold: (bold != nil ? bold! : self.bold), - italic: (italic != nil ? italic! : self.italic), - underline: (underline != nil ? underline! : self.underline), - strike: (strike != nil ? strike! : self.strike), - header: (header != nil ? header! : self.header), - list: (list != nil ? list! : self.list), - indent: (indent != nil ? indent! : self.indent), - size: (size != nil ? size! : self.size), - font: (font != nil ? font! : self.font), - color: (color != nil ? color! : self.color), - background: (background != nil ? background! : self.background), - align: (align != nil ? align! : self.align), - link: (link != nil ? link! : self.link) - ) - } + public func copy( + bold: Bool? = nil, + header: HeaderType? = nil, + italic: Bool? = nil, + underline: Bool? = nil, + strike: Bool? = nil, + // list: ListType? = nil, + indent: Int? = nil, + size: Int? = nil, + font: String? = nil, + color: String? = nil, + background: String? = nil, + align: RichTextAlignment? = nil, + link: String? = nil + ) -> RichAttributes { + return RichAttributes( + bold: (bold != nil ? bold! : self.bold), + italic: (italic != nil ? italic! : self.italic), + underline: (underline != nil ? underline! : self.underline), + strike: (strike != nil ? strike! : self.strike), + header: (header != nil ? header! : self.header), + // list: (list != nil ? list! : self.list), + indent: (indent != nil ? indent! : self.indent), + size: (size != nil ? size! : self.size), + font: (font != nil ? font! : self.font), + color: (color != nil ? color! : self.color), + background: (background != nil ? background! : self.background), + align: (align != nil ? align! : self.align), + link: (link != nil ? link! : self.link) + ) + } - public func copy(with style: RichTextSpanStyle, byAdding: Bool = true) - -> RichAttributes - { - return copy(with: [style], byAdding: byAdding) - } + public func copy(with style: RichTextSpanStyle, byAdding: Bool = true) + -> RichAttributes + { + return copy(with: [style], byAdding: byAdding) + } - public func copy(with styles: [RichTextSpanStyle], byAdding: Bool = true) - -> RichAttributes - { - let att = getRichAttributesFor(styles: styles) - return RichAttributes( - bold: (att.bold != nil ? (byAdding ? att.bold! : nil) : self.bold), - italic: (att.italic != nil - ? (byAdding ? att.italic! : nil) : self.italic), - underline: (att.underline != nil - ? (byAdding ? att.underline! : nil) : self.underline), - strike: (att.strike != nil - ? (byAdding ? att.strike! : nil) : self.strike), - header: (att.header != nil - ? (byAdding ? att.header! : nil) : self.header), - list: (att.list != nil ? (byAdding ? att.list! : nil) : self.list), - indent: (att.indent != nil - ? (byAdding ? att.indent! : nil) : self.indent), - 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), - align: (att.align != nil - ? (byAdding ? att.align! : nil) : self.align), - ///nil link indicates removal as well so removing link if `byAdding == false && att.link == nil` - link: (att.link != nil ? (byAdding ? att.link! : nil) : (att.link == nil && !byAdding) ? nil : self.link) - ) - } + public func copy(with styles: [RichTextSpanStyle], byAdding: Bool = true) + -> RichAttributes + { + let att = getRichAttributesFor(styles: styles) + return RichAttributes( + bold: (att.bold != nil ? (byAdding ? att.bold! : nil) : self.bold), + italic: (att.italic != nil + ? (byAdding ? att.italic! : nil) : self.italic), + underline: (att.underline != nil + ? (byAdding ? att.underline! : nil) : self.underline), + strike: (att.strike != nil + ? (byAdding ? att.strike! : nil) : self.strike), + header: (att.header != nil + ? (byAdding ? att.header! : nil) : self.header), + // list: (att.list != nil ? (byAdding ? att.list! : nil) : self.list), + indent: (att.indent != nil + ? (byAdding ? att.indent! : nil) : self.indent), + 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), + align: (att.align != nil + ? (byAdding ? att.align! : nil) : self.align), + ///nil link indicates removal as well so removing link if `byAdding == false && att.link == nil` + link: (att.link != nil + ? (byAdding ? att.link! : nil) : (att.link == nil && !byAdding) ? nil : self.link) + ) + } } extension RichAttributes { - public func styles() -> [RichTextSpanStyle] { - var styles: [RichTextSpanStyle] = [] - if let bold = bold, bold { - styles.append(.bold) - } - if let italic = italic, italic { - styles.append(.italic) - } - if let underline = underline, underline { - styles.append(.underline) - } - if let strike = strike, strike { - styles.append(.strikethrough) - } - if let header = header { - styles.append(header.getTextSpanStyle()) - } - if let list = list { - styles.append(list.getTextSpanStyle()) - } - if let size = size { - styles.append(.size(size)) - } - if let font = font { - styles.append(.font(font)) - } - if let color = color { - styles.append(.color(.init(hex: color))) - } - if let background = background { - styles.append(.background(.init(hex: background))) - } - if let align = align { - styles.append(align.getTextSpanStyle()) - } - if let link = link { - styles.append(.link(link)) - } - return styles + public func styles() -> [RichTextSpanStyle] { + var styles: [RichTextSpanStyle] = [] + if let bold = bold, bold { + styles.append(.bold) + } + if let italic = italic, italic { + styles.append(.italic) + } + if let underline = underline, underline { + styles.append(.underline) + } + if let strike = strike, strike { + styles.append(.strikethrough) + } + if let header = header { + styles.append(header.getTextSpanStyle()) + } + // if let list = list { + // styles.append(list.getTextSpanStyle()) + // } + if let size = size { + styles.append(.size(size)) } + if let font = font { + styles.append(.font(font)) + } + if let color = color { + styles.append(.color(.init(hex: color))) + } + if let background = background { + styles.append(.background(.init(hex: background))) + } + if let align = align { + styles.append(align.getTextSpanStyle()) + } + if let link = link { + styles.append(.link(link)) + } + return styles + } - public func stylesSet() -> Set { - var styles: Set = [] - if let bold = bold, bold { - styles.insert(.bold) - } - if let italic = italic, italic { - styles.insert(.italic) - } - if let underline = underline, underline { - styles.insert(.underline) - } - if let strike = strike, strike { - styles.insert(.strikethrough) - } - if let header = header { - styles.insert(header.getTextSpanStyle()) - } - if let list = list { - styles.insert(list.getTextSpanStyle()) - } - if let size = size { - styles.insert(.size(size)) - } - if let font = font { - styles.insert(.font(font)) - } - if let color = color { - styles.insert(.color(Color(hex: color))) - } - if let background = background { - styles.insert(.background(Color(hex: background))) - } - if let align = align { - styles.insert(align.getTextSpanStyle()) - } - if let link = link { - styles.insert(.link(link)) - } - return styles + public func stylesSet() -> Set { + var styles: Set = [] + if let bold = bold, bold { + styles.insert(.bold) + } + if let italic = italic, italic { + styles.insert(.italic) + } + if let underline = underline, underline { + styles.insert(.underline) + } + if let strike = strike, strike { + styles.insert(.strikethrough) + } + if let header = header { + styles.insert(header.getTextSpanStyle()) } + // if let list = list { + // styles.insert(list.getTextSpanStyle()) + // } + if let size = size { + styles.insert(.size(size)) + } + if let font = font { + styles.insert(.font(font)) + } + if let color = color { + styles.insert(.color(Color(hex: color))) + } + if let background = background { + styles.insert(.background(Color(hex: background))) + } + if let align = align { + styles.insert(align.getTextSpanStyle()) + } + if let link = link { + styles.insert(.link(link)) + } + return styles + } } extension RichAttributes { - public func hasStyle(style: RichTextSpanStyle) -> Bool { - switch style { - case .default: - return true - case .bold: - return bold ?? false - case .italic: - return italic ?? false - case .underline: - return underline ?? false - case .strikethrough: - return strike ?? false - case .h1: - return header == .h1 - case .h2: - return header == .h2 - case .h3: - return header == .h3 - case .h4: - return header == .h4 - case .h5: - return header == .h5 - case .h6: - return header == .h6 - case .bullet: - return list == .bullet(indent) - case .size(let size): - return size == size - case .font(let name): - return font == name - case .color(let colorItem): - return color == colorItem?.hexString - case .background(let color): - return background == color?.hexString - case .align(let alignment): - return align == alignment - case .link(let linkItem): - return link == linkItem - } + public func hasStyle(style: RichTextSpanStyle) -> Bool { + switch style { + case .default: + return true + case .bold: + return bold ?? false + case .italic: + return italic ?? false + case .underline: + return underline ?? false + case .strikethrough: + return strike ?? false + case .h1: + return header == .h1 + case .h2: + return header == .h2 + case .h3: + return header == .h3 + case .h4: + return header == .h4 + case .h5: + return header == .h5 + case .h6: + return header == .h6 + // case .bullet: + // return list == .bullet(indent) + case .size(let size): + return size == size + case .font(let name): + return font == name + case .color(let colorItem): + return color == colorItem?.hexString + case .background(let color): + return background == color?.hexString + case .align(let alignment): + return align == alignment + case .link(let linkItem): + return link == linkItem } + } } internal func getRichAttributesFor(style: RichTextSpanStyle) -> RichAttributes { - return getRichAttributesFor(styles: [style]) + return getRichAttributesFor(styles: [style]) } internal func getRichAttributesFor(styles: [RichTextSpanStyle]) - -> RichAttributes + -> RichAttributes { - guard !styles.isEmpty else { return RichAttributes() } - var bold: Bool? = nil - var italic: Bool? = nil - var underline: Bool? = nil - var strike: Bool? = nil - var header: HeaderType? = nil - var list: ListType? = nil - var indent: Int? = nil - var size: Int? = nil - var font: String? = nil - var color: String? = nil - var background: String? = nil - var align: RichTextAlignment? = nil - var link: String? = nil + guard !styles.isEmpty else { return RichAttributes() } + var bold: Bool? = nil + var italic: Bool? = nil + var underline: Bool? = nil + var strike: Bool? = nil + var header: HeaderType? = nil + // var list: ListType? = nil + // var indent: Int? = nil + var size: Int? = nil + var font: String? = nil + var color: String? = nil + var background: String? = nil + var align: RichTextAlignment? = nil + var link: String? = nil - for style in styles { - switch style { - case .bold: - bold = true - case .italic: - italic = true - case .underline: - underline = true - case .strikethrough: - strike = true - case .h1: - header = .h1 - case .h2: - header = .h2 - case .h3: - header = .h3 - case .h4: - header = .h4 - case .h5: - header = .h5 - case .h6: - header = .h6 - case .bullet(let indentIndex): - list = .bullet(indentIndex) - indent = indentIndex - case .default: - header = .default - case .size(let fontSize): - size = fontSize - case .font(let name): - font = name - case .color(let textColor): - color = textColor?.hexString - case .background(let backgroundColor): - background = backgroundColor?.hexString - case .align(let alignment): - align = alignment - case .link(let linkItem): - link = linkItem - } + for style in styles { + switch style { + case .bold: + bold = true + case .italic: + italic = true + case .underline: + underline = true + case .strikethrough: + strike = true + case .h1: + header = .h1 + case .h2: + header = .h2 + case .h3: + header = .h3 + case .h4: + header = .h4 + case .h5: + header = .h5 + case .h6: + header = .h6 + // case .bullet(let indentIndex): + // list = .bullet(indentIndex) + // indent = indentIndex + case .default: + header = .default + case .size(let fontSize): + size = fontSize + case .font(let name): + font = name + case .color(let textColor): + color = textColor?.hexString + case .background(let backgroundColor): + background = backgroundColor?.hexString + case .align(let alignment): + align = alignment + case .link(let linkItem): + link = linkItem } - return RichAttributes( - bold: bold, - italic: italic, - underline: underline, - strike: strike, - header: header, - list: list, - indent: indent, - size: size, - font: font, - color: color, - background: background, - align: align, - link: link - ) + } + return RichAttributes( + bold: bold, + italic: italic, + underline: underline, + strike: strike, + header: header, + // list: list, + // indent: indent, + size: size, + font: font, + color: color, + background: background, + align: align, + link: link + ) } diff --git a/Sources/RichEditorSwiftUI/ExportData/NSAttributedString+Init.swift b/Sources/RichEditorSwiftUI/ExportData/NSAttributedString+Init.swift index 105fc7e..d856055 100644 --- a/Sources/RichEditorSwiftUI/ExportData/NSAttributedString+Init.swift +++ b/Sources/RichEditorSwiftUI/ExportData/NSAttributedString+Init.swift @@ -9,126 +9,126 @@ import Foundation extension NSAttributedString { - /** + /** Try to parse ``RichTextDataFormat`` formatted data. - Parameters: - data: The data to initialize the string with. - format: The data format to use. */ - public convenience init( - data: Data, - format: RichTextDataFormat - ) throws { - switch format { - case .archivedData: try self.init(archivedData: data) - case .plainText: try self.init(plainTextData: data) - case .rtf: try self.init(rtfData: data) - case .vendorArchivedData: try self.init(archivedData: data) - } + public convenience init( + data: Data, + format: RichTextDataFormat + ) throws { + switch format { + case .archivedData: try self.init(archivedData: data) + case .plainText: try self.init(plainTextData: data) + case .rtf: try self.init(rtfData: data) + case .vendorArchivedData: try self.init(archivedData: data) } + } } extension NSAttributedString { - /// Try to parse ``RichTextDataFormat/archivedData``. - fileprivate convenience init(archivedData data: Data) throws { - let unarchived = try NSKeyedUnarchiver.unarchivedObject( - ofClass: NSAttributedString.self, - from: data) - guard let string = unarchived else { - throw RichTextDataError.invalidArchivedData(in: data) - } - self.init(attributedString: string) + /// Try to parse ``RichTextDataFormat/archivedData``. + fileprivate convenience init(archivedData data: Data) throws { + let unarchived = try NSKeyedUnarchiver.unarchivedObject( + ofClass: NSAttributedString.self, + from: data) + guard let string = unarchived else { + throw RichTextDataError.invalidArchivedData(in: data) } - - /// Try to parse ``RichTextDataFormat/plainText`` data. - fileprivate convenience init(plainTextData data: Data) throws { - let decoded = String(data: data, encoding: .utf8) - guard let string = decoded else { - throw RichTextDataError.invalidPlainTextData(in: data) - } - let attributed = NSAttributedString(string: string) - self.init(attributedString: attributed) + self.init(attributedString: string) + } + + /// Try to parse ``RichTextDataFormat/plainText`` data. + fileprivate convenience init(plainTextData data: Data) throws { + let decoded = String(data: data, encoding: .utf8) + guard let string = decoded else { + throw RichTextDataError.invalidPlainTextData(in: data) } - - /// Try to parse ``RichTextDataFormat/rtf`` data. - fileprivate convenience init(rtfData data: Data) throws { - var attributes = Self.rtfDataAttributes as NSDictionary? - try self.init( - data: data, - options: [.characterEncoding: Self.utf8], - documentAttributes: &attributes - ) + let attributed = NSAttributedString(string: string) + self.init(attributedString: attributed) + } + + /// Try to parse ``RichTextDataFormat/rtf`` data. + fileprivate convenience init(rtfData data: Data) throws { + var attributes = Self.rtfDataAttributes as NSDictionary? + try self.init( + data: data, + options: [.characterEncoding: Self.utf8], + documentAttributes: &attributes + ) + } + + /// Try to parse ``RichTextDataFormat/rtfd`` data. + fileprivate convenience init(rtfdData data: Data) throws { + var attributes = Self.rtfdDataAttributes as NSDictionary? + try self.init( + data: data, + options: [.characterEncoding: Self.utf8], + documentAttributes: &attributes + ) + } + + #if os(macOS) + /// Try to parse ``RichTextDataFormat/word`` data. + convenience init(wordData data: Data) throws { + var attributes = Self.wordDataAttributes as NSDictionary? + try self.init( + data: data, + options: [.characterEncoding: Self.utf8], + documentAttributes: &attributes + ) } + #endif - /// Try to parse ``RichTextDataFormat/rtfd`` data. - fileprivate convenience init(rtfdData data: Data) throws { - var attributes = Self.rtfdDataAttributes as NSDictionary? - try self.init( - data: data, - options: [.characterEncoding: Self.utf8], - documentAttributes: &attributes - ) + fileprivate convenience init(jsonData data: Data) throws { + let decoder = JSONDecoder() + let richText = try? decoder.decode(RichText.self, from: data) + guard let richText = richText else { + throw RichTextDataError.invalidPlainTextData(in: data) } - #if macOS - /// Try to parse ``RichTextDataFormat/word`` data. - convenience init(wordData data: Data) throws { - var attributes = Self.wordDataAttributes as NSDictionary? - try self.init( - data: data, - options: [.characterEncoding: Self.utf8], - documentAttributes: &attributes - ) - } - #endif - - fileprivate convenience init(jsonData data: Data) throws { - let decoder = JSONDecoder() - let richText = try? decoder.decode(RichText.self, from: data) - guard let richText = richText else { - throw RichTextDataError.invalidPlainTextData(in: data) - } - - var tempSpans: [RichTextSpanInternal] = [] - var text = "" - richText.spans.forEach({ - let span = RichTextSpanInternal( - from: text.utf16Length, - to: (text.utf16Length + $0.insert.utf16Length - 1), - attributes: $0.attributes) - tempSpans.append(span) - text += $0.insert - }) - - let attributedString = NSMutableAttributedString(string: text) - - tempSpans.forEach { span in - attributedString.addAttributes( - span.attributes?.toAttributes() ?? [:], range: span.spanRange) - } - self.init(attributedString: attributedString) + var tempSpans: [RichTextSpanInternal] = [] + var text = "" + richText.spans.forEach({ + let span = RichTextSpanInternal( + from: text.utf16Length, + to: (text.utf16Length + $0.insert.utf16Length - 1), + attributes: $0.attributes) + tempSpans.append(span) + text += $0.insert + }) + + let attributedString = NSMutableAttributedString(string: text) + + tempSpans.forEach { span in + attributedString.addAttributes( + span.attributes?.toAttributes() ?? [:], range: span.spanRange) } + self.init(attributedString: attributedString) + } } extension NSAttributedString { - fileprivate static var utf8: UInt { - String.Encoding.utf8.rawValue - } + fileprivate static var utf8: UInt { + String.Encoding.utf8.rawValue + } - fileprivate static var rtfDataAttributes: [DocumentAttributeKey: Any] { - [.documentType: NSAttributedString.DocumentType.rtf] - } + fileprivate static var rtfDataAttributes: [DocumentAttributeKey: Any] { + [.documentType: NSAttributedString.DocumentType.rtf] + } - fileprivate static var rtfdDataAttributes: [DocumentAttributeKey: Any] { - [.documentType: NSAttributedString.DocumentType.rtfd] - } + fileprivate static var rtfdDataAttributes: [DocumentAttributeKey: Any] { + [.documentType: NSAttributedString.DocumentType.rtfd] + } - #if macOS - static var wordDataAttributes: [DocumentAttributeKey: Any] { - [.documentType: NSAttributedString.DocumentType.docFormat] - } - #endif + #if os(macOS) + static var wordDataAttributes: [DocumentAttributeKey: Any] { + [.documentType: NSAttributedString.DocumentType.docFormat] + } + #endif } diff --git a/Sources/RichEditorSwiftUI/ExportData/RichTextDataReader.swift b/Sources/RichEditorSwiftUI/ExportData/RichTextDataReader.swift index d4d341c..c94f411 100644 --- a/Sources/RichEditorSwiftUI/ExportData/RichTextDataReader.swift +++ b/Sources/RichEditorSwiftUI/ExportData/RichTextDataReader.swift @@ -18,80 +18,80 @@ extension NSAttributedString: RichTextDataReader {} extension RichTextDataReader { - /** + /** Generate rich text data from the current rich text. - Parameters: - format: The data format to use. */ - public func richTextData( - for format: RichTextDataFormat - ) throws -> Data { - switch format { - case .archivedData: try richTextArchivedData() - case .plainText: try richTextPlainTextData() - case .rtf: try richTextRtfData() - case .vendorArchivedData: try richTextArchivedData() - } + public func richTextData( + for format: RichTextDataFormat + ) throws -> Data { + switch format { + case .archivedData: try richTextArchivedData() + case .plainText: try richTextPlainTextData() + case .rtf: try richTextRtfData() + case .vendorArchivedData: try richTextArchivedData() } + } } extension RichTextDataReader { - /// The full text range. - fileprivate var textRange: NSRange { - NSRange(location: 0, length: richText.length) - } + /// The full text range. + fileprivate var textRange: NSRange { + NSRange(location: 0, length: richText.length) + } - /// The full text range. - fileprivate func documentAttributes( - for documentType: NSAttributedString.DocumentType - ) -> [NSAttributedString.DocumentAttributeKey: Any] { - [.documentType: documentType] - } + /// The full text range. + fileprivate func documentAttributes( + for documentType: NSAttributedString.DocumentType + ) -> [NSAttributedString.DocumentAttributeKey: Any] { + [.documentType: documentType] + } - /// Generate archived formatted data. - fileprivate func richTextArchivedData() throws -> Data { - try NSKeyedArchiver.archivedData( - withRootObject: richText, - requiringSecureCoding: false - ) - } + /// Generate archived formatted data. + fileprivate func richTextArchivedData() throws -> Data { + try NSKeyedArchiver.archivedData( + withRootObject: richText, + requiringSecureCoding: false + ) + } - /// Generate plain text formatted data. - fileprivate func richTextPlainTextData() throws -> Data { - let string = richText.string - guard let data = string.data(using: .utf8) else { - throw - RichTextDataError - .invalidData(in: string) - } - return data + /// Generate plain text formatted data. + fileprivate func richTextPlainTextData() throws -> Data { + let string = richText.string + guard let data = string.data(using: .utf8) else { + throw + RichTextDataError + .invalidData(in: string) } + return data + } - /// Generate RTF formatted data. - fileprivate func richTextRtfData() throws -> Data { - try richText.data( - from: textRange, - documentAttributes: documentAttributes(for: .rtf) - ) - } + /// Generate RTF formatted data. + fileprivate func richTextRtfData() throws -> Data { + try richText.data( + from: textRange, + documentAttributes: documentAttributes(for: .rtf) + ) + } - /// Generate RTFD formatted data. - fileprivate func richTextRtfdData() throws -> Data { - try richText.data( - from: textRange, - documentAttributes: documentAttributes(for: .rtfd) - ) - } + /// Generate RTFD formatted data. + fileprivate func richTextRtfdData() throws -> Data { + try richText.data( + from: textRange, + documentAttributes: documentAttributes(for: .rtfd) + ) + } - #if macOS - /// Generate Word formatted data. - func richTextWordData() throws -> Data { - try richText.data( - from: textRange, - documentAttributes: documentAttributes(for: .docFormat) - ) - } - #endif + #if os(macOS) + /// Generate Word formatted data. + func richTextWordData() throws -> Data { + try richText.data( + from: textRange, + documentAttributes: documentAttributes(for: .docFormat) + ) + } + #endif } diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift index d6ef3fc..6686f1d 100644 --- a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift @@ -9,7 +9,7 @@ import SwiftUI extension RichTextFont { - /** + /** This font picker can be used to pick a font from a list, using ``RichTextFont/PickerFont/all`` as default fonts. @@ -31,88 +31,87 @@ extension RichTextFont { Note that this picker will not apply all configurations. */ - public struct Picker: View { + public struct Picker: View { - /** + /** Create a font picker. - Parameters: - selection: The selected font name. */ - public init( - selection: Binding - ) { - self._selection = selection - self.selectedFont = Config.Font.all.first - } + public init( + selection: Binding + ) { + self._selection = selection + self.selectedFont = Config.Font.all.first + } + + public typealias Config = RichTextFont.PickerConfig + public typealias Font = Config.Font + public typealias FontName = Config.FontName + + @State + private var selectedFont: Font? + + @Binding + private var selection: FontName - public typealias Config = RichTextFont.PickerConfig - public typealias Font = Config.Font - public typealias FontName = Config.FontName - - @State - private var selectedFont: Font? - - @Binding - private var selection: FontName - - @Environment(\.richTextFontPickerConfig) - private var config - - public var body: some View { - SwiftUI.Picker(selection: $selection) { - ForEach(config.fonts) { font in - RichTextFont.PickerItem( - font: font, - fontSize: config.fontSize, - isSelected: false - ) - .tag(font.fontName) - } - } label: { - EmptyView() - } + @Environment(\.richTextFontPickerConfig) + private var config + + public var body: some View { + SwiftUI.Picker(selection: $selection) { + ForEach(config.fonts) { font in + RichTextFont.PickerItem( + font: font, + fontSize: config.fontSize, + isSelected: false + ) + .tag(font.fontName) } + } label: { + EmptyView() + } } + } } extension RichTextFont.PickerFont { - /** + /** A system font has a font name that may be resolved to a different name when picked. We must thus try to pattern match, using the currently selected font name. */ - fileprivate func matches(_ fontName: String) -> Bool { - let compare = fontName.lowercased() - let fontName = self.fontName.lowercased() - if fontName == compare { return true } - if compare.hasPrefix(fontName.replacingOccurrences(of: " ", with: "")) { - return true - } - if compare.hasPrefix(fontName.replacingOccurrences(of: " ", with: "-")) - { - return true - } - return false + fileprivate func matches(_ fontName: String) -> Bool { + let compare = fontName.lowercased() + let fontName = self.fontName.lowercased() + if fontName == compare { return true } + if compare.hasPrefix(fontName.replacingOccurrences(of: " ", with: "")) { + return true } + if compare.hasPrefix(fontName.replacingOccurrences(of: " ", with: "-")) { + return true + } + return false + } - /** + /** Use the selected font name as tag for the selected font. */ - fileprivate func tag(for selectedFont: Self?, selectedName: String) - -> String - { - let isSelected = fontName == selectedFont?.fontName - return isSelected ? selectedName : fontName - } + fileprivate func tag(for selectedFont: Self?, selectedName: String) + -> String + { + let isSelected = fontName == selectedFont?.fontName + return isSelected ? selectedName : fontName + } } //extension View { // // func withPreviewPickerStyles() -> some View { // NavigationView { -// #if macOS +// #if os(macOS) // Color.clear // #endif // ScrollView { @@ -120,7 +119,7 @@ extension RichTextFont.PickerFont { // self.label("Default") // self.pickerStyle(.automatic).label(".automatic") // self.pickerStyle(.inline).label(".inline") -// #if os(iOS) || macOS +// #if os(iOS) || os(macOS) // self.pickerStyle(.menu).label(".menu") // #endif // #if iOS @@ -128,7 +127,7 @@ extension RichTextFont.PickerFont { // pickerStyle(.navigationLink).label(".navigationLink") // } // #endif -// #if os(iOS) || macOS +// #if os(iOS) || os(macOS) // if #available(iOS 17.0, os(macOS) 14.0, watchOS 10.0, *) { // pickerStyle(.palette).label(".palette") // } diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFontPickerFont.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFontPickerFont.swift index 98219d7..b4e5823 100644 --- a/Sources/RichEditorSwiftUI/Fonts/RichTextFontPickerFont.swift +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFontPickerFont.swift @@ -8,16 +8,16 @@ import Foundation #if canImport(AppKit) && !targetEnvironment(macCatalyst) - import AppKit + import AppKit #endif #if canImport(UIKit) - import UIKit + import UIKit #endif extension RichTextFont { - /** + /** This struct defines picker-specific fonts that are used by the various font pickers. @@ -34,125 +34,125 @@ extension RichTextFont { to another value. To edit how fonts are detected by the system, use the ``systemFontNamePrefix``. */ - public struct PickerFont: Identifiable, Equatable { - - public init( - fontName: String - ) { - self.fontName = fontName - self.fontDisplayName = "" - self.fontDisplayName = displayName - } + public struct PickerFont: Identifiable, Equatable { + + public init( + fontName: String + ) { + self.fontName = fontName + self.fontDisplayName = "" + self.fontDisplayName = displayName + } - public let fontName: String - public private(set) var fontDisplayName: String + public let fontName: String + public private(set) var fontDisplayName: String - /// Get the unique font id. - public var id: String { - fontName.lowercased() - } + /// Get the unique font id. + public var id: String { + fontName.lowercased() } + } } // MARK: - Static Properties extension RichTextFont.PickerFont { - /// Get all available system fonts. - public static var all: [Self] { - let all = systemFonts - let system = Self.init( - fontName: Self.systemFontNamePrefix - ) - var sorted = all.sorted { $0.fontDisplayName < $1.fontDisplayName } - sorted.insert(system, at: 0) - return sorted - } - - /// The display name for the standard system font. - public static var standardSystemFontDisplayName: String { - #if macOS - return "Standard" - #else - return "San Francisco" - #endif - } - - /// The font name prefix for the standard system font. - public static var systemFontNamePrefix: String { - #if macOS - return ".AppleSystemUIFont" - #else - return ".SFUI" - #endif - } + /// Get all available system fonts. + public static var all: [Self] { + let all = systemFonts + let system = Self.init( + fontName: Self.systemFontNamePrefix + ) + var sorted = all.sorted { $0.fontDisplayName < $1.fontDisplayName } + sorted.insert(system, at: 0) + return sorted + } + + /// The display name for the standard system font. + public static var standardSystemFontDisplayName: String { + #if os(macOS) + return "Standard" + #else + return "San Francisco" + #endif + } + + /// The font name prefix for the standard system font. + public static var systemFontNamePrefix: String { + #if os(macOS) + return ".AppleSystemUIFont" + #else + return ".SFUI" + #endif + } } // MARK: - Public Properties extension RichTextFont.PickerFont { - /// Get the font display name. - public var displayName: String { - let isSystemFont = isStandardSystemFont - let systemName = Self.standardSystemFontDisplayName - return isSystemFont ? systemName : fontName - } - - /// Check if the a font name represents the system font. - public var isStandardSystemFont: Bool { - let name = fontName.trimmingCharacters(in: .whitespaces) - let prefix = Self.systemFontNamePrefix - return name.hasPrefix(prefix) - } + /// Get the font display name. + public var displayName: String { + let isSystemFont = isStandardSystemFont + let systemName = Self.standardSystemFontDisplayName + return isSystemFont ? systemName : fontName + } + + /// Check if the a font name represents the system font. + public var isStandardSystemFont: Bool { + let name = fontName.trimmingCharacters(in: .whitespaces) + let prefix = Self.systemFontNamePrefix + return name.hasPrefix(prefix) + } } // MARK: - Collection Extensions extension Collection where Element == RichTextFont.PickerFont { - /// Get all available system fonts. - public static var all: [Element] { - Element.all - } + /// Get all available system fonts. + public static var all: [Element] { + Element.all + } - /// Move a certain font topmost in the list. - public func moveTopmost(_ topmost: String) -> [Element] { - let topmost = topmost.trimmingCharacters(in: .whitespaces) - let exists = contains { - $0.fontName.lowercased() == topmost.lowercased() - } - guard exists else { return Array(self) } - var filtered = filter { - $0.fontName.lowercased() != topmost.lowercased() - } - let new = Element(fontName: topmost) - filtered.insert(new, at: 0) - return filtered + /// Move a certain font topmost in the list. + public func moveTopmost(_ topmost: String) -> [Element] { + let topmost = topmost.trimmingCharacters(in: .whitespaces) + let exists = contains { + $0.fontName.lowercased() == topmost.lowercased() + } + guard exists else { return Array(self) } + var filtered = filter { + $0.fontName.lowercased() != topmost.lowercased() } + let new = Element(fontName: topmost) + filtered.insert(new, at: 0) + return filtered + } } // MARK: - System Fonts extension RichTextFont.PickerFont { - /** + /** Get all available font picker fonts. */ - fileprivate static var systemFonts: [RichTextFont.PickerFont] { - #if canImport(AppKit) && !targetEnvironment(macCatalyst) - return NSFontManager.shared - .availableFontFamilies - .map { - RichTextFont.PickerFont(fontName: $0) - } - #endif - - #if canImport(UIKit) - return UIFont.familyNames - .map { - RichTextFont.PickerFont(fontName: $0) - } - #endif - } + fileprivate static var systemFonts: [RichTextFont.PickerFont] { + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + return NSFontManager.shared + .availableFontFamilies + .map { + RichTextFont.PickerFont(fontName: $0) + } + #endif + + #if canImport(UIKit) + return UIFont.familyNames + .map { + RichTextFont.PickerFont(fontName: $0) + } + #endif + } } diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextViewComponent+Font.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextViewComponent+Font.swift index f63ab3a..bbd1842 100644 --- a/Sources/RichEditorSwiftUI/Fonts/RichTextViewComponent+Font.swift +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextViewComponent+Font.swift @@ -26,118 +26,118 @@ import Foundation extension RichTextViewComponent { - /// Get the rich text font at current range. - public var richTextFont: FontRepresentable? { - richTextAttributes[.font] as? FontRepresentable ?? typingAttributes[ - .font] as? FontRepresentable - } + /// Get the rich text font at current range. + public var richTextFont: FontRepresentable? { + richTextAttributes[.font] as? FontRepresentable ?? typingAttributes[ + .font] as? FontRepresentable + } - /// Set the rich text font at current range. - public func setRichTextFont(_ font: FontRepresentable) { - setRichTextAttribute(.font, to: font) - } + /// Set the rich text font at current range. + public func setRichTextFont(_ font: FontRepresentable) { + setRichTextAttribute(.font, to: font) + } - /// Set the rich text font name at current range. - public func setRichTextFontName(_ name: String) { - if richTextFont?.fontName == name { return } - if hasSelectedRange { - setFontName(name, at: selectedRange) - } else { - setFontNameAtCurrentPosition(to: name) - } + /// Set the rich text font name at current range. + public func setRichTextFontName(_ name: String) { + if richTextFont?.fontName == name { return } + if hasSelectedRange { + setFontName(name, at: selectedRange) + } else { + setFontNameAtCurrentPosition(to: name) } + } - /// Set the rich text font size at current range. - public func setRichTextFontSize(_ size: CGFloat) { - if size == richTextFont?.pointSize { return } - #if macOS - setFontSize(size, at: selectedRange) - setFontSizeAtCurrentPosition(size) - #else - if hasSelectedRange { - setFontSize(size, at: selectedRange) - } else { - setFontSizeAtCurrentPosition(size) - } - #endif - } + /// Set the rich text font size at current range. + public func setRichTextFontSize(_ size: CGFloat) { + if size == richTextFont?.pointSize { return } + #if os(macOS) + setFontSize(size, at: selectedRange) + setFontSizeAtCurrentPosition(size) + #else + if hasSelectedRange { + setFontSize(size, at: selectedRange) + } else { + setFontSizeAtCurrentPosition(size) + } + #endif + } - /// Step the rich text font size at current range. - public func stepRichTextFontSize(points: Int) { - let old = richTextFont?.pointSize ?? .standardRichTextFontSize - let new = max(0, old + CGFloat(points)) - setRichTextFontSize(new) - } + /// Step the rich text font size at current range. + public func stepRichTextFontSize(points: Int) { + let old = richTextFont?.pointSize ?? .standardRichTextFontSize + let new = max(0, old + CGFloat(points)) + setRichTextFontSize(new) + } } extension RichTextViewComponent { - /// Set the font at the current position. - fileprivate func setFontNameAtCurrentPosition(to name: String) { - var attributes = typingAttributes - let oldFont = - attributes[.font] as? FontRepresentable ?? .standardRichTextFont - let size = oldFont.pointSize - let newFont = FontRepresentable(name: name, size: size) - attributes[.font] = newFont - typingAttributes = attributes - } + /// Set the font at the current position. + fileprivate func setFontNameAtCurrentPosition(to name: String) { + var attributes = typingAttributes + let oldFont = + attributes[.font] as? FontRepresentable ?? .standardRichTextFont + let size = oldFont.pointSize + let newFont = FontRepresentable(name: name, size: size) + attributes[.font] = newFont + typingAttributes = attributes + } - /// Set the font size at the current position. - fileprivate func setFontSizeAtCurrentPosition(_ size: CGFloat) { - var attributes = typingAttributes - let oldFont = - attributes[.font] as? FontRepresentable ?? .standardRichTextFont - let newFont = oldFont.withSize(size) - attributes[.font] = newFont - typingAttributes = attributes - } + /// Set the font size at the current position. + fileprivate func setFontSizeAtCurrentPosition(_ size: CGFloat) { + var attributes = typingAttributes + let oldFont = + attributes[.font] as? FontRepresentable ?? .standardRichTextFont + let newFont = oldFont.withSize(size) + attributes[.font] = newFont + typingAttributes = attributes + } - /// Set the font name at a certain range. - fileprivate func setFontName(_ name: String, at range: NSRange) { - guard let text = mutableRichText else { return } - guard text.length > 0 else { return } - let fontName = settableFontName(for: name) - text.beginEditing() - text.enumerateAttribute(.font, in: range, options: .init()) { - value, range, _ in - let oldFont = value as? FontRepresentable ?? .standardRichTextFont - let size = oldFont.pointSize - let newFont = - FontRepresentable(name: fontName, size: size) - ?? .standardRichTextFont - text.removeAttribute(.font, range: range) - text.addAttribute(.font, value: newFont, range: range) - text.fixAttributes(in: range) - } - text.endEditing() + /// Set the font name at a certain range. + fileprivate func setFontName(_ name: String, at range: NSRange) { + guard let text = mutableRichText else { return } + guard text.length > 0 else { return } + let fontName = settableFontName(for: name) + text.beginEditing() + text.enumerateAttribute(.font, in: range, options: .init()) { + value, range, _ in + let oldFont = value as? FontRepresentable ?? .standardRichTextFont + let size = oldFont.pointSize + let newFont = + FontRepresentable(name: fontName, size: size) + ?? .standardRichTextFont + text.removeAttribute(.font, range: range) + text.addAttribute(.font, value: newFont, range: range) + text.fixAttributes(in: range) } + text.endEditing() + } - /// Set the font size at a certain range. - fileprivate func setFontSize(_ size: CGFloat, at range: NSRange) { - guard let text = mutableRichText else { return } - guard text.length > 0 else { return } - text.beginEditing() - text.enumerateAttribute(.font, in: range, options: .init()) { - value, range, _ in - let oldFont = value as? FontRepresentable ?? .standardRichTextFont - let newFont = oldFont.withSize(size) - text.removeAttribute(.font, range: range) - text.addAttribute(.font, value: newFont, range: range) - text.fixAttributes(in: range) - } - text.endEditing() + /// Set the font size at a certain range. + fileprivate func setFontSize(_ size: CGFloat, at range: NSRange) { + guard let text = mutableRichText else { return } + guard text.length > 0 else { return } + text.beginEditing() + text.enumerateAttribute(.font, in: range, options: .init()) { + value, range, _ in + let oldFont = value as? FontRepresentable ?? .standardRichTextFont + let newFont = oldFont.withSize(size) + text.removeAttribute(.font, range: range) + text.addAttribute(.font, value: newFont, range: range) + text.fixAttributes(in: range) } + text.endEditing() + } } extension RichTextAttributeWriter { - /// We must adjust empty font names on some platforms. - fileprivate func settableFontName(for fontName: String) -> String { - #if macOS - fontName - #else - fontName - #endif - } + /// We must adjust empty font names on some platforms. + fileprivate func settableFontName(for fontName: String) -> String { + #if os(macOS) + fontName + #else + fontName + #endif + } } diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift index 25a23f7..13ace86 100644 --- a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift @@ -6,11 +6,11 @@ // #if os(iOS) || os(macOS) || os(visionOS) - import SwiftUI + import SwiftUI - extension RichTextFormat { + extension RichTextFormat { - /** + /** This horizontal toolbar provides text format controls. This toolbar adapts the layout based on horizontal size @@ -28,116 +28,116 @@ .richTextFormatToolbarConfig(...) ``` */ - public struct Toolbar: RichTextFormatToolbarBase { + public struct Toolbar: RichTextFormatToolbarBase { - /** + /** Create a rich text format sheet. - Parameters: - context: The context to apply changes to. */ - public init( - context: RichEditorState - ) { - self._context = ObservedObject(wrappedValue: context) - } - - @ObservedObject - private var context: RichEditorState - - @Environment(\.richTextFormatToolbarConfig) - var config - - @Environment(\.richTextFormatToolbarStyle) - var style - - @Environment(\.horizontalSizeClass) - private var horizontalSizeClass - - public var body: some View { - VStack(spacing: style.spacing) { - controls - if hasColorPickers { - Divider() - colorPickers(for: context) - } - } - .labelsHidden() - .padding(.vertical, style.padding) - .environment(\.sizeCategory, .medium) - // .background(background) - #if macOS - .frame(minWidth: 650) - #endif - } + public init( + context: RichEditorState + ) { + self._context = ObservedObject(wrappedValue: context) + } + + @ObservedObject + private var context: RichEditorState + + @Environment(\.richTextFormatToolbarConfig) + var config + + @Environment(\.richTextFormatToolbarStyle) + var style + + @Environment(\.horizontalSizeClass) + private var horizontalSizeClass + + public var body: some View { + VStack(spacing: style.spacing) { + controls + if hasColorPickers { + Divider() + colorPickers(for: context) + } } + .labelsHidden() + .padding(.vertical, style.padding) + .environment(\.sizeCategory, .medium) + // .background(background) + #if os(macOS) + .frame(minWidth: 650) + #endif + } } + } - // MARK: - Views + // MARK: - Views - extension RichTextFormat.Toolbar { + extension RichTextFormat.Toolbar { - fileprivate var useSingleLine: Bool { - #if macOS - true - #else - horizontalSizeClass == .regular - #endif - } + fileprivate var useSingleLine: Bool { + #if os(macOS) + true + #else + horizontalSizeClass == .regular + #endif } + } - extension RichTextFormat.Toolbar { + extension RichTextFormat.Toolbar { - fileprivate var background: some View { - Color.clear - .overlay(Color.primary.opacity(0.1)) - .shadow(color: .black.opacity(0.1), radius: 5) - .edgesIgnoringSafeArea(.all) - } + fileprivate var background: some View { + Color.clear + .overlay(Color.primary.opacity(0.1)) + .shadow(color: .black.opacity(0.1), radius: 5) + .edgesIgnoringSafeArea(.all) + } - @ViewBuilder - fileprivate var controls: some View { - if useSingleLine { - HStack { - controlsContent - } - .padding(.horizontal, style.padding) - } else { - VStack(spacing: style.spacing) { - controlsContent - } - .padding(.horizontal, style.padding) - } + @ViewBuilder + fileprivate var controls: some View { + if useSingleLine { + HStack { + controlsContent + } + .padding(.horizontal, style.padding) + } else { + VStack(spacing: style.spacing) { + controlsContent } + .padding(.horizontal, style.padding) + } + } - @ViewBuilder - fileprivate var controlsContent: some View { - HStack { - #if macOS - headerPicker(context: context) - fontPicker(value: $context.fontName) - .onChangeBackPort(of: context.fontName) { newValue in - context.updateStyle(style: .font(newValue)) - } - #endif - styleToggleGroup(for: context) - otherMenuToggleGroup(for: context) - if !useSingleLine { - Spacer() - } - fontSizePicker(for: context) - if horizontalSizeClass == .regular { - Spacer() - } - } - HStack { - #if !macOS - headerPicker(context: context) - #endif - alignmentPicker(context: context) - // superscriptButtons(for: context, greedy: false) - // indentButtons(for: context, greedy: false) + @ViewBuilder + fileprivate var controlsContent: some View { + HStack { + #if os(macOS) + headerPicker(context: context) + fontPicker(value: $context.fontName) + .onChangeBackPort(of: context.fontName) { newValue in + context.updateStyle(style: .font(newValue)) } + #endif + styleToggleGroup(for: context) + otherMenuToggleGroup(for: context) + if !useSingleLine { + Spacer() + } + fontSizePicker(for: context) + if horizontalSizeClass == .regular { + Spacer() } + } + HStack { + #if !macOS + headerPicker(context: context) + #endif + alignmentPicker(context: context) + // superscriptButtons(for: context, greedy: false) + // indentButtons(for: context, greedy: false) + } } + } #endif diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarConfig.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarConfig.swift index 25ab92d..b4ecdb9 100644 --- a/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarConfig.swift +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarConfig.swift @@ -6,89 +6,89 @@ // #if os(iOS) || os(macOS) || os(visionOS) - import SwiftUI + import SwiftUI - extension RichTextFormat { + extension RichTextFormat { - /// This type can be used to configure a format toolbar. - public struct ToolbarConfig { + /// This type can be used to configure a format toolbar. + public struct ToolbarConfig { - public init( - headers: [HeaderType] = .all, - alignments: [RichTextAlignment] = .all, - colorPickers: [RichTextColor] = [.foreground], - colorPickersDisclosed: [RichTextColor] = [], - fontPicker: Bool = true, - fontSizePicker: Bool = true, - indentButtons: Bool = true, - lineSpacingPicker: Bool = false, - styles: [RichTextStyle] = .all, - otherMenu: [RichTextOtherMenu] = .all, - superscriptButtons: Bool = true - ) { - self.headers = headers - self.alignments = alignments - self.colorPickers = colorPickers - self.colorPickersDisclosed = colorPickersDisclosed - self.fontPicker = fontPicker - self.fontSizePicker = fontSizePicker - self.indentButtons = indentButtons - self.lineSpacingPicker = lineSpacingPicker - self.styles = styles - self.otherMenu = otherMenu - #if macOS - self.superscriptButtons = superscriptButtons - #else - self.superscriptButtons = false - #endif - } + public init( + headers: [HeaderType] = .all, + alignments: [RichTextAlignment] = .all, + colorPickers: [RichTextColor] = [.foreground], + colorPickersDisclosed: [RichTextColor] = [], + fontPicker: Bool = true, + fontSizePicker: Bool = true, + indentButtons: Bool = true, + lineSpacingPicker: Bool = false, + styles: [RichTextStyle] = .all, + otherMenu: [RichTextOtherMenu] = .all, + superscriptButtons: Bool = true + ) { + self.headers = headers + self.alignments = alignments + self.colorPickers = colorPickers + self.colorPickersDisclosed = colorPickersDisclosed + self.fontPicker = fontPicker + self.fontSizePicker = fontSizePicker + self.indentButtons = indentButtons + self.lineSpacingPicker = lineSpacingPicker + self.styles = styles + self.otherMenu = otherMenu + #if os(macOS) + self.superscriptButtons = superscriptButtons + #else + self.superscriptButtons = false + #endif + } - public var headers: [HeaderType] - public var alignments: [RichTextAlignment] - public var colorPickers: [RichTextColor] - public var colorPickersDisclosed: [RichTextColor] - public var fontPicker: Bool - public var fontSizePicker: Bool - public var indentButtons: Bool - public var lineSpacingPicker: Bool - public var styles: [RichTextStyle] - public var otherMenu: [RichTextOtherMenu] - public var superscriptButtons: Bool - } + public var headers: [HeaderType] + public var alignments: [RichTextAlignment] + public var colorPickers: [RichTextColor] + public var colorPickersDisclosed: [RichTextColor] + public var fontPicker: Bool + public var fontSizePicker: Bool + public var indentButtons: Bool + public var lineSpacingPicker: Bool + public var styles: [RichTextStyle] + public var otherMenu: [RichTextOtherMenu] + public var superscriptButtons: Bool } + } - extension RichTextFormat.ToolbarConfig { + extension RichTextFormat.ToolbarConfig { - /// The standard rich text format toolbar configuration. - public static var standard: Self { .init() } - } + /// The standard rich text format toolbar configuration. + public static var standard: Self { .init() } + } - extension View { + extension View { - /// Apply a rich text format toolbar style. - public func richTextFormatToolbarConfig( - _ value: RichTextFormat.ToolbarConfig - ) -> some View { - self.environment(\.richTextFormatToolbarConfig, value) - } + /// Apply a rich text format toolbar style. + public func richTextFormatToolbarConfig( + _ value: RichTextFormat.ToolbarConfig + ) -> some View { + self.environment(\.richTextFormatToolbarConfig, value) } + } - extension RichTextFormat.ToolbarConfig { + extension RichTextFormat.ToolbarConfig { - fileprivate struct Key: EnvironmentKey { + fileprivate struct Key: EnvironmentKey { - public static var defaultValue: RichTextFormat.ToolbarConfig { - .init() - } - } + public static var defaultValue: RichTextFormat.ToolbarConfig { + .init() + } } + } - extension EnvironmentValues { + extension EnvironmentValues { - /// This value can bind to a format toolbar config. - public var richTextFormatToolbarConfig: RichTextFormat.ToolbarConfig { - get { self[RichTextFormat.ToolbarConfig.Key.self] } - set { self[RichTextFormat.ToolbarConfig.Key.self] = newValue } - } + /// This value can bind to a format toolbar config. + public var richTextFormatToolbarConfig: RichTextFormat.ToolbarConfig { + get { self[RichTextFormat.ToolbarConfig.Key.self] } + set { self[RichTextFormat.ToolbarConfig.Key.self] = newValue } } + } #endif diff --git a/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift b/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift index 41a016f..e098c91 100644 --- a/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift +++ b/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift @@ -7,65 +7,65 @@ // #if os(iOS) || os(macOS) || os(visionOS) - import SwiftUI - - /// This toolbar can be added above an iOS keyboard, to provide - /// rich text formatting in a compact form. - /// - /// This toolbar is needed since the ``RichTextEditor`` can not - /// use a `toolbar` modifier with `.keyboard` placement: - /// - /// ```swift - /// RichTextEditor(text: $text, context: context) - /// .toolbar { - /// ToolbarItemGroup(placement: .keyboard) { - /// .... - /// } - /// } - /// ``` - /// - /// Instead, add this toolbar below a ``RichTextEditor`` to let - /// it automatically show when the text editor is edited in iOS. - /// - /// You can inject additional leading and trailing buttons, and - /// customize the format sheet that is presented when users tap - /// format button: - /// - /// ```swift - /// VStack { - /// RichTextEditor(...) - /// RichTextKeyboardToolbar( - /// context: context, - /// leadingButtons: {}, - /// trailingButtons: {}, - /// formatSheet: { $0 } - /// ) - /// } - /// ``` - /// - /// These view builders provide you with standard views. Return - /// `$0` to use these standard views, or return any custom view - /// that you want to use instead. - /// - /// You can configure and style the view by applying its config - /// and style view modifiers to your view hierarchy: - /// - /// ```swift - /// VStack { - /// RichTextEditor(...) - /// RichTextKeyboardToolbar(...) - /// } - /// .richTextKeyboardToolbarStyle(...) - /// .richTextKeyboardToolbarConfig(...) - /// ``` - /// - /// For more information, see ``RichTextKeyboardToolbarConfig`` - /// and ``RichTextKeyboardToolbarStyle``. - public struct RichTextKeyboardToolbar< - LeadingButtons: View, TrailingButtons: View, FormatSheet: View - >: View { - - /** + import SwiftUI + + /// This toolbar can be added above an iOS keyboard, to provide + /// rich text formatting in a compact form. + /// + /// This toolbar is needed since the ``RichTextEditor`` can not + /// use a `toolbar` modifier with `.keyboard` placement: + /// + /// ```swift + /// RichTextEditor(text: $text, context: context) + /// .toolbar { + /// ToolbarItemGroup(placement: .keyboard) { + /// .... + /// } + /// } + /// ``` + /// + /// Instead, add this toolbar below a ``RichTextEditor`` to let + /// it automatically show when the text editor is edited in iOS. + /// + /// You can inject additional leading and trailing buttons, and + /// customize the format sheet that is presented when users tap + /// format button: + /// + /// ```swift + /// VStack { + /// RichTextEditor(...) + /// RichTextKeyboardToolbar( + /// context: context, + /// leadingButtons: {}, + /// trailingButtons: {}, + /// formatSheet: { $0 } + /// ) + /// } + /// ``` + /// + /// These view builders provide you with standard views. Return + /// `$0` to use these standard views, or return any custom view + /// that you want to use instead. + /// + /// You can configure and style the view by applying its config + /// and style view modifiers to your view hierarchy: + /// + /// ```swift + /// VStack { + /// RichTextEditor(...) + /// RichTextKeyboardToolbar(...) + /// } + /// .richTextKeyboardToolbarStyle(...) + /// .richTextKeyboardToolbarConfig(...) + /// ``` + /// + /// For more information, see ``RichTextKeyboardToolbarConfig`` + /// and ``RichTextKeyboardToolbarStyle``. + public struct RichTextKeyboardToolbar< + LeadingButtons: View, TrailingButtons: View, FormatSheet: View + >: View { + + /** Create a rich text keyboard toolbar. - Parameters: @@ -74,175 +74,174 @@ - trailingButtons: The trailing buttons to place before the trailing actions. - formatSheet: The rich text format sheet to use, by default ``RichTextFormat/Sheet``. */ - public init( - context: RichEditorState, - @ViewBuilder leadingButtons: @escaping (StandardLeadingButtons) -> - LeadingButtons, - @ViewBuilder trailingButtons: @escaping (StandardTrailingButtons) -> - TrailingButtons, - @ViewBuilder formatSheet: @escaping (StandardFormatSheet) -> - FormatSheet - ) { - self._context = ObservedObject(wrappedValue: context) - self.leadingButtons = leadingButtons - self.trailingButtons = trailingButtons - self.formatSheet = formatSheet - } + public init( + context: RichEditorState, + @ViewBuilder leadingButtons: @escaping (StandardLeadingButtons) -> + LeadingButtons, + @ViewBuilder trailingButtons: @escaping (StandardTrailingButtons) -> + TrailingButtons, + @ViewBuilder formatSheet: @escaping (StandardFormatSheet) -> + FormatSheet + ) { + self._context = ObservedObject(wrappedValue: context) + self.leadingButtons = leadingButtons + self.trailingButtons = trailingButtons + self.formatSheet = formatSheet + } + + public typealias StandardLeadingButtons = EmptyView + public typealias StandardTrailingButtons = EmptyView + public typealias StandardFormatSheet = RichTextFormat.Sheet + + private let leadingButtons: (StandardLeadingButtons) -> LeadingButtons + private let trailingButtons: (StandardTrailingButtons) -> TrailingButtons + private let formatSheet: (StandardFormatSheet) -> FormatSheet + + @ObservedObject + private var context: RichEditorState - public typealias StandardLeadingButtons = EmptyView - public typealias StandardTrailingButtons = EmptyView - public typealias StandardFormatSheet = RichTextFormat.Sheet - - private let leadingButtons: (StandardLeadingButtons) -> LeadingButtons - private let trailingButtons: - (StandardTrailingButtons) -> TrailingButtons - private let formatSheet: (StandardFormatSheet) -> FormatSheet - - @ObservedObject - private var context: RichEditorState - - @State - private var isFormatSheetPresented = false - - @Environment(\.horizontalSizeClass) - private var horizontalSizeClass - - @Environment(\.richTextKeyboardToolbarConfig) - private var config - - @Environment(\.richTextKeyboardToolbarStyle) - private var style - - public var body: some View { - VStack(spacing: 0) { - HStack(spacing: style.itemSpacing) { - leadingViews - Spacer() - .frame(minWidth: 0, maxWidth: .infinity) - trailingViews - } - .padding(10) - } - .environment(\.sizeCategory, .medium) - .frame(height: style.toolbarHeight) - .overlay(Divider(), alignment: .bottom) - .accentColor(.primary) - .background( - Color.primary.colorInvert() - .overlay(Color.white.opacity(0.2)) - .shadow( - color: style.shadowColor, radius: style.shadowRadius, - x: 0, y: 0) - ) - .opacity(shouldDisplayToolbar ? 1 : 0) - .offset(y: shouldDisplayToolbar ? 0 : style.toolbarHeight) - .frame(height: shouldDisplayToolbar ? nil : 0) - .sheet(isPresented: $isFormatSheetPresented) { - formatSheet( - .init(context: context) - ) - .prefersMediumSize() - } + @State + private var isFormatSheetPresented = false + + @Environment(\.horizontalSizeClass) + private var horizontalSizeClass + + @Environment(\.richTextKeyboardToolbarConfig) + private var config + + @Environment(\.richTextKeyboardToolbarStyle) + private var style + + public var body: some View { + VStack(spacing: 0) { + HStack(spacing: style.itemSpacing) { + leadingViews + Spacer() + .frame(minWidth: 0, maxWidth: .infinity) + trailingViews } + .padding(10) + } + .environment(\.sizeCategory, .medium) + .frame(height: style.toolbarHeight) + .overlay(Divider(), alignment: .bottom) + .accentColor(.primary) + .background( + Color.primary.colorInvert() + .overlay(Color.white.opacity(0.2)) + .shadow( + color: style.shadowColor, radius: style.shadowRadius, + x: 0, y: 0) + ) + .opacity(shouldDisplayToolbar ? 1 : 0) + .offset(y: shouldDisplayToolbar ? 0 : style.toolbarHeight) + .frame(height: shouldDisplayToolbar ? nil : 0) + .sheet(isPresented: $isFormatSheetPresented) { + formatSheet( + .init(context: context) + ) + .prefersMediumSize() + } } - - extension View { - - @ViewBuilder - fileprivate func prefersMediumSize() -> some View { - #if macOS - self - #else - if #available(iOS 16, *) { - self.presentationDetents([.medium]) - } else { - self - } - #endif + } + + extension View { + + @ViewBuilder + fileprivate func prefersMediumSize() -> some View { + #if os(macOS) + self + #else + if #available(iOS 16, *) { + self.presentationDetents([.medium]) + } else { + self } + #endif } + } - extension RichTextKeyboardToolbar { + extension RichTextKeyboardToolbar { - fileprivate var isCompact: Bool { - horizontalSizeClass == .compact - } + fileprivate var isCompact: Bool { + horizontalSizeClass == .compact } + } - extension RichTextKeyboardToolbar { + extension RichTextKeyboardToolbar { - fileprivate var divider: some View { - Divider() - .frame(height: 25) - } + fileprivate var divider: some View { + Divider() + .frame(height: 25) + } - @ViewBuilder - fileprivate var leadingViews: some View { - RichTextAction.ButtonStack( - context: context, - actions: config.leadingActions, - spacing: style.itemSpacing - ) + @ViewBuilder + fileprivate var leadingViews: some View { + RichTextAction.ButtonStack( + context: context, + actions: config.leadingActions, + spacing: style.itemSpacing + ) - leadingButtons(StandardLeadingButtons()) + leadingButtons(StandardLeadingButtons()) - divider + divider - Button(action: presentFormatSheet) { - Image.richTextFormat - .contentShape(Rectangle()) - } + Button(action: presentFormatSheet) { + Image.richTextFormat + .contentShape(Rectangle()) + } - RichTextStyle.ToggleStack(context: context) - .keyboardShortcutsOnly(if: isCompact) + RichTextStyle.ToggleStack(context: context) + .keyboardShortcutsOnly(if: isCompact) - RichTextFont.SizePickerStack(context: context) - .keyboardShortcutsOnly() - } + RichTextFont.SizePickerStack(context: context) + .keyboardShortcutsOnly() + } - @ViewBuilder - fileprivate var trailingViews: some View { - RichTextAlignment.Picker(selection: $context.textAlignment) - .pickerStyle(.segmented) - .frame(maxWidth: 200) - .keyboardShortcutsOnly(if: isCompact) + @ViewBuilder + fileprivate var trailingViews: some View { + RichTextAlignment.Picker(selection: $context.textAlignment) + .pickerStyle(.segmented) + .frame(maxWidth: 200) + .keyboardShortcutsOnly(if: isCompact) - trailingButtons(StandardTrailingButtons()) + trailingButtons(StandardTrailingButtons()) - RichTextAction.ButtonStack( - context: context, - actions: config.trailingActions, - spacing: style.itemSpacing - ) - } + RichTextAction.ButtonStack( + context: context, + actions: config.trailingActions, + spacing: style.itemSpacing + ) } - - extension View { - - @ViewBuilder - fileprivate func keyboardShortcutsOnly( - if condition: Bool = true - ) -> some View { - if condition { - self.hidden() - .frame(width: 0) - } else { - self - } - } + } + + extension View { + + @ViewBuilder + fileprivate func keyboardShortcutsOnly( + if condition: Bool = true + ) -> some View { + if condition { + self.hidden() + .frame(width: 0) + } else { + self + } } + } - extension RichTextKeyboardToolbar { + extension RichTextKeyboardToolbar { - fileprivate var shouldDisplayToolbar: Bool { - context.isEditingText || config.alwaysDisplayToolbar - } + fileprivate var shouldDisplayToolbar: Bool { + context.isEditingText || config.alwaysDisplayToolbar } + } - extension RichTextKeyboardToolbar { + extension RichTextKeyboardToolbar { - fileprivate func presentFormatSheet() { - isFormatSheetPresented = true - } + fileprivate func presentFormatSheet() { + isFormatSheetPresented = true } + } #endif diff --git a/Sources/RichEditorSwiftUI/ListStyle/ListType.swift b/Sources/RichEditorSwiftUI/ListStyle/ListType.swift index cb7d80c..94589f1 100644 --- a/Sources/RichEditorSwiftUI/ListStyle/ListType.swift +++ b/Sources/RichEditorSwiftUI/ListStyle/ListType.swift @@ -7,89 +7,89 @@ import Foundation -public enum ListType: Codable, Identifiable, CaseIterable, Hashable { - public static var allCases: [ListType] = [.bullet()] - - public var id: String { - return key - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(key) - hasher.combine(getIndent()) - } - - case bullet(_ indent: Int? = nil) - // case ordered(_ indent: Int? = nil) - - enum CodingKeys: String, CodingKey { - case bullet = "bullet" - // case ordered = "ordered" - } - - var key: String { - switch self { - case .bullet: - return "bullet" - // case .ordered: - // return "ordered" - } - } -} - -extension ListType { - func getTextSpanStyle() -> RichTextSpanStyle { - switch self { - case .bullet(let indent): - return .bullet(indent) - // case .ordered: - // return .ordered - } - } - - func getMarkerFormat() -> TextList.MarkerFormat { - switch self { - case .bullet: - return .disc - // case .ordered: - // return .decimal - } - } - - func getIndent() -> Int { - switch self { - case .bullet(let indent): - return indent ?? 0 - // case .ordered(let indent): - // return indent ?? 0 - } - } - - // func moveIndentForward() -> ListType { - // switch self { - // case .bullet(let indent): - // let newIndent = (indent ?? 0) + 1 - // return .bullet(newIndent) - // } - // } - // - // func moveIndentBackward() -> ListType { - // switch self { - // case .bullet(let indent): - // let newIndent = max(0, ((indent ?? 0) - 1)) - // return .bullet(newIndent) - // } - // } -} - -#if canImport(UIKit) - import UIKit - - typealias TextList = NSTextList -#endif - -#if canImport(AppKit) - import AppKit - - typealias TextList = NSTextList -#endif +//public enum ListType: Codable, Identifiable, CaseIterable, Hashable { +// public static var allCases: [ListType] = [.bullet()] +// +// public var id: String { +// return key +// } +// +// public func hash(into hasher: inout Hasher) { +// hasher.combine(key) +// hasher.combine(getIndent()) +// } +// +// case bullet(_ indent: Int? = nil) +// // case ordered(_ indent: Int? = nil) +// +// enum CodingKeys: String, CodingKey { +// case bullet = "bullet" +// // case ordered = "ordered" +// } +// +// var key: String { +// switch self { +// case .bullet: +// return "bullet" +// // case .ordered: +// // return "ordered" +// } +// } +//} +// +//extension ListType { +// func getTextSpanStyle() -> RichTextSpanStyle { +// switch self { +// case .bullet(let indent): +// return .bullet(indent) +// // case .ordered: +// // return .ordered +// } +// } +// +// func getMarkerFormat() -> TextList.MarkerFormat { +// switch self { +// case .bullet: +// return .disc +// // case .ordered: +// // return .decimal +// } +// } +// +// func getIndent() -> Int { +// switch self { +// case .bullet(let indent): +// return indent ?? 0 +// // case .ordered(let indent): +// // return indent ?? 0 +// } +// } +// +// // func moveIndentForward() -> ListType { +// // switch self { +// // case .bullet(let indent): +// // let newIndent = (indent ?? 0) + 1 +// // return .bullet(newIndent) +// // } +// // } +// // +// // func moveIndentBackward() -> ListType { +// // switch self { +// // case .bullet(let indent): +// // let newIndent = max(0, ((indent ?? 0) - 1)) +// // return .bullet(newIndent) +// // } +// // } +//} +// +//#if canImport(UIKit) +// import UIKit +// +// typealias TextList = NSTextList +//#endif +// +//#if canImport(AppKit) +// import AppKit +// +// typealias TextList = NSTextList +//#endif diff --git a/Sources/RichEditorSwiftUI/Pdf/RichTextPdfDataReader.swift b/Sources/RichEditorSwiftUI/Pdf/RichTextPdfDataReader.swift index 4d1d8e7..3a37b84 100644 --- a/Sources/RichEditorSwiftUI/Pdf/RichTextPdfDataReader.swift +++ b/Sources/RichEditorSwiftUI/Pdf/RichTextPdfDataReader.swift @@ -19,133 +19,133 @@ extension NSAttributedString: RichTextPdfDataReader {} extension RichTextPdfDataReader { - /** + /** Generate PDF data from the current rich text. This is currently only supported on iOS and macOS. When calling this function on other platforms, it will throw a ``PdfDataError/unsupportedPlatform`` error. */ - public func richTextPdfData(configuration: PdfPageConfiguration = .standard) - throws -> Data + public func richTextPdfData(configuration: PdfPageConfiguration = .standard) + throws -> Data + { + #if os(iOS) || os(visionOS) + try richText.iosPdfData(for: configuration) + #elseif os(macOS) + try richText.macosPdfData(for: configuration) + #else + throw PdfDataError.unsupportedPlatform + #endif + } +} + +#if os(macOS) + import AppKit + + @MainActor + extension NSAttributedString { + + fileprivate func macosPdfData(for configuration: PdfPageConfiguration) + throws -> Data { - #if os(iOS) || os(visionOS) - try richText.iosPdfData(for: configuration) - #elseif macOS - try richText.macosPdfData(for: configuration) - #else - throw PdfDataError.unsupportedPlatform - #endif + do { + let fileUrl = try macosPdfFileUrl() + let printInfo = try macosPdfPrintInfo( + for: configuration, + fileUrl: fileUrl) + + let scrollView = NSTextView.scrollableTextView() + scrollView.frame = configuration.paperRect + let textView = + scrollView.documentView as? NSTextView ?? NSTextView() + sleepToPrepareTextView() + textView.textStorage?.setAttributedString(self) + + let printOperation = NSPrintOperation( + view: textView, printInfo: printInfo) + printOperation.showsPrintPanel = false + printOperation.showsProgressPanel = false + printOperation.run() + + return try Data(contentsOf: fileUrl) + } catch { + throw (error) + } + } + + fileprivate func macosPdfFileUrl() throws -> URL { + let manager = FileManager.default + let cacheUrl = try manager.url( + for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, + create: true) + return + cacheUrl + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("pdf") } -} -#if macOS - import AppKit - - @MainActor - extension NSAttributedString { - - fileprivate func macosPdfData(for configuration: PdfPageConfiguration) - throws -> Data - { - do { - let fileUrl = try macosPdfFileUrl() - let printInfo = try macosPdfPrintInfo( - for: configuration, - fileUrl: fileUrl) - - let scrollView = NSTextView.scrollableTextView() - scrollView.frame = configuration.paperRect - let textView = - scrollView.documentView as? NSTextView ?? NSTextView() - sleepToPrepareTextView() - textView.textStorage?.setAttributedString(self) - - let printOperation = NSPrintOperation( - view: textView, printInfo: printInfo) - printOperation.showsPrintPanel = false - printOperation.showsProgressPanel = false - printOperation.run() - - return try Data(contentsOf: fileUrl) - } catch { - throw (error) - } - } - - fileprivate func macosPdfFileUrl() throws -> URL { - let manager = FileManager.default - let cacheUrl = try manager.url( - for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, - create: true) - return - cacheUrl - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("pdf") - } - - fileprivate func macosPdfPrintInfo( - for configuration: PdfPageConfiguration, - fileUrl: URL - ) throws -> NSPrintInfo { - let printOpts: [NSPrintInfo.AttributeKey: Any] = [ - .jobDisposition: NSPrintInfo.JobDisposition.save, - .jobSavingURL: fileUrl, - ] - let printInfo = NSPrintInfo(dictionary: printOpts) - printInfo.horizontalPagination = .fit - printInfo.verticalPagination = .automatic - printInfo.topMargin = configuration.pageMargins.top - printInfo.leftMargin = configuration.pageMargins.left - printInfo.rightMargin = configuration.pageMargins.right - printInfo.bottomMargin = configuration.pageMargins.bottom - printInfo.isHorizontallyCentered = false - printInfo.isVerticallyCentered = false - return printInfo - } - - fileprivate func sleepToPrepareTextView() { - Thread.sleep(forTimeInterval: 0.1) - } + fileprivate func macosPdfPrintInfo( + for configuration: PdfPageConfiguration, + fileUrl: URL + ) throws -> NSPrintInfo { + let printOpts: [NSPrintInfo.AttributeKey: Any] = [ + .jobDisposition: NSPrintInfo.JobDisposition.save, + .jobSavingURL: fileUrl, + ] + let printInfo = NSPrintInfo(dictionary: printOpts) + printInfo.horizontalPagination = .fit + printInfo.verticalPagination = .automatic + printInfo.topMargin = configuration.pageMargins.top + printInfo.leftMargin = configuration.pageMargins.left + printInfo.rightMargin = configuration.pageMargins.right + printInfo.bottomMargin = configuration.pageMargins.bottom + printInfo.isHorizontallyCentered = false + printInfo.isVerticallyCentered = false + return printInfo } + + fileprivate func sleepToPrepareTextView() { + Thread.sleep(forTimeInterval: 0.1) + } + } #endif #if os(iOS) || os(visionOS) - import UIKit - - @MainActor - extension NSAttributedString { - - fileprivate func iosPdfData(for configuration: PdfPageConfiguration) - throws -> Data - { - let pageRenderer = iosPdfPageRenderer(for: configuration) - let paperRect = configuration.paperRect - let pdfData = NSMutableData() - UIGraphicsBeginPDFContextToData(pdfData, paperRect, nil) - let range = NSRange(location: 0, length: pageRenderer.numberOfPages) - pageRenderer.prepare(forDrawingPages: range) - let bounds = UIGraphicsGetPDFContextBounds() - for i in 0.. UIPrintPageRenderer { - let printFormatter = UISimpleTextPrintFormatter( - attributedText: self) - let paperRect = NSValue(cgRect: configuration.paperRect) - let printableRect = NSValue(cgRect: configuration.printableRect) - let pageRenderer = UIPrintPageRenderer() - pageRenderer.addPrintFormatter(printFormatter, startingAtPageAt: 0) - pageRenderer.setValue(paperRect, forKey: "paperRect") - pageRenderer.setValue(printableRect, forKey: "printableRect") - return pageRenderer - } + import UIKit + + @MainActor + extension NSAttributedString { + + fileprivate func iosPdfData(for configuration: PdfPageConfiguration) + throws -> Data + { + let pageRenderer = iosPdfPageRenderer(for: configuration) + let paperRect = configuration.paperRect + let pdfData = NSMutableData() + UIGraphicsBeginPDFContextToData(pdfData, paperRect, nil) + let range = NSRange(location: 0, length: pageRenderer.numberOfPages) + pageRenderer.prepare(forDrawingPages: range) + let bounds = UIGraphicsGetPDFContextBounds() + for i in 0.. UIPrintPageRenderer { + let printFormatter = UISimpleTextPrintFormatter( + attributedText: self) + let paperRect = NSValue(cgRect: configuration.paperRect) + let printableRect = NSValue(cgRect: configuration.printableRect) + let pageRenderer = UIPrintPageRenderer() + pageRenderer.addPrintFormatter(printFormatter, startingAtPageAt: 0) + pageRenderer.setValue(paperRect, forKey: "paperRect") + pageRenderer.setValue(printableRect, forKey: "printableRect") + return pageRenderer } + } #endif diff --git a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Button.swift b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Button.swift index e089b3b..00b8eef 100644 --- a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Button.swift +++ b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Button.swift @@ -7,7 +7,8 @@ import SwiftUI -extension RichTextOtherMenu { +#if os(iOS) || os(macOS) || os(visionOS) + extension RichTextOtherMenu { /** This button can be used to toggle a ``RichTextOtherMenu``. @@ -17,7 +18,7 @@ extension RichTextOtherMenu { */ public struct Button: View { - /** + /** Create a rich text style button. - Parameters: @@ -25,17 +26,17 @@ extension RichTextOtherMenu { - value: The value to bind to. - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. */ - public init( - style: RichTextOtherMenu, - value: Binding, - fillVertically: Bool = false - ) { - self.style = style - self.value = value - self.fillVertically = fillVertically - } + public init( + style: RichTextOtherMenu, + value: Binding, + fillVertically: Bool = false + ) { + self.style = style + self.value = value + self.fillVertically = fillVertically + } - /** + /** Create a rich text style button. - Parameters: @@ -43,44 +44,45 @@ extension RichTextOtherMenu { - context: The context to affect. - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. */ - public init( - style: RichTextOtherMenu, - context: RichEditorState, - fillVertically: Bool = false - ) { - self.init( - style: style, - value: context.binding(for: style), - fillVertically: fillVertically - ) - } + public init( + style: RichTextOtherMenu, + context: RichEditorState, + fillVertically: Bool = false + ) { + self.init( + style: style, + value: context.bindingForManu(for: style), + fillVertically: fillVertically + ) + } - private let style: RichTextOtherMenu - private let value: Binding - private let fillVertically: Bool + private let style: RichTextOtherMenu + private let value: Binding + private let fillVertically: Bool - public var body: some View { - SwiftUI.Button(action: toggle) { - style.label - .labelStyle(.iconOnly) - .frame(maxHeight: fillVertically ? .infinity : nil) - .contentShape(Rectangle()) - } - .tint(.accentColor, if: isOn) - .foreground(.accentColor, if: isOn) - // .keyboardShortcut(for: style) - .accessibilityLabel(style.title) + public var body: some View { + SwiftUI.Button(action: toggle) { + style.label + .labelStyle(.iconOnly) + .frame(maxHeight: fillVertically ? .infinity : nil) + .contentShape(Rectangle()) } + .tint(.accentColor, if: isOn) + .foreground(.accentColor, if: isOn) + // .keyboardShortcut(for: style) + .accessibilityLabel(style.title) + } } -} + } -extension RichTextOtherMenu.Button { + extension RichTextOtherMenu.Button { fileprivate var isOn: Bool { - value.wrappedValue + value.wrappedValue } fileprivate func toggle() { - value.wrappedValue.toggle() + value.wrappedValue.toggle() } -} + } +#endif diff --git a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Toggle.swift b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Toggle.swift index 0edf84c..e154323 100644 --- a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Toggle.swift +++ b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Toggle.swift @@ -7,7 +7,8 @@ import SwiftUI -extension RichTextOtherMenu { +#if os(iOS) || os(macOS) || os(visionOS) + extension RichTextOtherMenu { /** This toggle can be used to toggle a ``RichTextOtherMenu``. @@ -18,7 +19,7 @@ extension RichTextOtherMenu { */ public struct Toggle: View { - /** + /** Create a rich text style toggle toggle. - Parameters: @@ -26,17 +27,17 @@ extension RichTextOtherMenu { - value: The value to bind to. - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. */ - public init( - style: RichTextOtherMenu, - value: Binding, - fillVertically: Bool = false - ) { - self.style = style - self.value = value - self.fillVertically = fillVertically - } + public init( + style: RichTextOtherMenu, + value: Binding, + fillVertically: Bool = false + ) { + self.style = style + self.value = value + self.fillVertically = fillVertically + } - /** + /** Create a rich text style toggle. - Parameters: @@ -44,44 +45,45 @@ extension RichTextOtherMenu { - context: The context to affect. - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. */ - public init( - style: RichTextOtherMenu, - context: RichEditorState, - fillVertically: Bool = false - ) { - self.init( - style: style, - value: context.binding(for: style), - fillVertically: fillVertically - ) - } + public init( + style: RichTextOtherMenu, + context: RichEditorState, + fillVertically: Bool = false + ) { + self.init( + style: style, + value: context.bindingForManu(for: style), + fillVertically: fillVertically + ) + } - private let style: RichTextOtherMenu - private let value: Binding - private let fillVertically: Bool + private let style: RichTextOtherMenu + private let value: Binding + private let fillVertically: Bool - public var body: some View { - #if os(tvOS) || os(watchOS) - toggle - #else - toggle.toggleStyle(.button) - #endif - } + public var body: some View { + #if os(tvOS) || os(watchOS) + toggle + #else + toggle.toggleStyle(.button) + #endif + } - private var toggle: some View { - SwiftUI.Toggle(isOn: value) { - style.icon - .frame(maxHeight: fillVertically ? .infinity : nil) - } - // .keyboardShortcut(for: style) - .accessibilityLabel(style.title) + private var toggle: some View { + SwiftUI.Toggle(isOn: value) { + style.icon + .frame(maxHeight: fillVertically ? .infinity : nil) } + // .keyboardShortcut(for: style) + .accessibilityLabel(style.title) + } } -} + } -extension RichTextOtherMenu.Toggle { + extension RichTextOtherMenu.Toggle { fileprivate var isOn: Bool { - value.wrappedValue + value.wrappedValue } -} + } +#endif diff --git a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleGroup.swift b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleGroup.swift index a0f2124..13cac44 100644 --- a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleGroup.swift +++ b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleGroup.swift @@ -6,11 +6,11 @@ // #if os(iOS) || os(macOS) || os(visionOS) - import SwiftUI + import SwiftUI - extension RichTextOtherMenu { + extension RichTextOtherMenu { - /** + /** This view can list ``RichTextOtherMenu/Toggle``s for a list of ``RichTextOtherMenu`` values, in a bordered button group. @@ -20,9 +20,9 @@ > Important: Since the `ControlGroup` doesn't highlight buttons in iOS, we use a `ToggleStack` for iOS. */ - public struct ToggleGroup: View { + public struct ToggleGroup: View { - /** + /** Create a rich text style toggle button group. - Parameters: @@ -30,51 +30,51 @@ - styles: The styles to list, by default ``RichTextOtherMenu/all``. - greedy: Whether or not the group is horizontally greedy, by default `true`. */ - public init( - context: RichEditorState, - styles: [RichTextOtherMenu] = .all, - greedy: Bool = true - ) { - self._context = ObservedObject(wrappedValue: context) - self.isGreedy = greedy - self.styles = styles - } + public init( + context: RichEditorState, + styles: [RichTextOtherMenu] = .all, + greedy: Bool = true + ) { + self._context = ObservedObject(wrappedValue: context) + self.isGreedy = greedy + self.styles = styles + } - private let styles: [RichTextOtherMenu] - private let isGreedy: Bool + private let styles: [RichTextOtherMenu] + private let isGreedy: Bool - private var groupWidth: CGFloat? { - if isGreedy { return nil } - let count = Double(styles.count) - #if macOS - return 30 * count - #else - return 50 * count - #endif - } + private var groupWidth: CGFloat? { + if isGreedy { return nil } + let count = Double(styles.count) + #if os(macOS) + return 30 * count + #else + return 50 * count + #endif + } - @ObservedObject - private var context: RichEditorState + @ObservedObject + private var context: RichEditorState - public var body: some View { - #if macOS - ControlGroup { - ForEach(styles) { - RichTextOtherMenu.Toggle( - style: $0, - context: context, - fillVertically: true - ) - } - } - .frame(width: groupWidth) - #else - RichTextOtherMenu.ToggleStack( - context: context, - styles: styles - ) - #endif + public var body: some View { + #if os(macOS) + ControlGroup { + ForEach(styles) { + RichTextOtherMenu.Toggle( + style: $0, + context: context, + fillVertically: true + ) } - } + } + .frame(width: groupWidth) + #else + RichTextOtherMenu.ToggleStack( + context: context, + styles: styles + ) + #endif + } } + } #endif diff --git a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleStack.swift b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleStack.swift index e4da70d..c205ee3 100644 --- a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleStack.swift +++ b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleStack.swift @@ -7,7 +7,9 @@ import SwiftUI -extension RichTextOtherMenu { +#if os(iOS) || os(macOS) || os(visionOS) + + extension RichTextOtherMenu { /** This view can list ``RichTextOtherMenu/Toggle``s for a list @@ -18,7 +20,7 @@ extension RichTextOtherMenu { */ public struct ToggleStack: View { - /** + /** Create a rich text style toggle button group. - Parameters: @@ -26,33 +28,34 @@ extension RichTextOtherMenu { - styles: The styles to list, by default ``RichTextOtherMenu/all``. - spacing: The spacing to apply to stack items, by default `5`. */ - public init( - context: RichEditorState, - styles: [RichTextOtherMenu] = .all, - spacing: Double = 5 - ) { - self._context = ObservedObject(wrappedValue: context) - self.styles = styles - self.spacing = spacing - } - - private let styles: [RichTextOtherMenu] - private let spacing: Double - - @ObservedObject - private var context: RichEditorState - - public var body: some View { - HStack(spacing: spacing) { - ForEach(styles) { - RichTextOtherMenu.Toggle( - style: $0, - context: context, - fillVertically: true - ) - } - } - .fixedSize(horizontal: false, vertical: true) + public init( + context: RichEditorState, + styles: [RichTextOtherMenu] = .all, + spacing: Double = 5 + ) { + self._context = ObservedObject(wrappedValue: context) + self.styles = styles + self.spacing = spacing + } + + private let styles: [RichTextOtherMenu] + private let spacing: Double + + @ObservedObject + private var context: RichEditorState + + public var body: some View { + HStack(spacing: spacing) { + ForEach(styles) { + RichTextOtherMenu.Toggle( + style: $0, + context: context, + fillVertically: true + ) + } } + .fixedSize(horizontal: false, vertical: true) + } } -} + } +#endif diff --git a/Sources/RichEditorSwiftUI/Styles/RichTextStyle+ToggleGroup.swift b/Sources/RichEditorSwiftUI/Styles/RichTextStyle+ToggleGroup.swift index 292c1c7..8da98a2 100644 --- a/Sources/RichEditorSwiftUI/Styles/RichTextStyle+ToggleGroup.swift +++ b/Sources/RichEditorSwiftUI/Styles/RichTextStyle+ToggleGroup.swift @@ -6,11 +6,11 @@ // #if os(iOS) || os(macOS) || os(visionOS) - import SwiftUI + import SwiftUI - extension RichTextStyle { + extension RichTextStyle { - /** + /** This view can list ``RichTextStyle/Toggle``s for a list of ``RichTextStyle`` values, in a bordered button group. @@ -20,9 +20,9 @@ > Important: Since the `ControlGroup` doesn't highlight buttons in iOS, we use a `ToggleStack` for iOS. */ - public struct ToggleGroup: View { + public struct ToggleGroup: View { - /** + /** Create a rich text style toggle button group. - Parameters: @@ -30,51 +30,51 @@ - styles: The styles to list, by default ``RichTextStyle/all``. - greedy: Whether or not the group is horizontally greedy, by default `true`. */ - public init( - context: RichEditorState, - styles: [RichTextStyle] = .all, - greedy: Bool = true - ) { - self._context = ObservedObject(wrappedValue: context) - self.isGreedy = greedy - self.styles = styles - } + public init( + context: RichEditorState, + styles: [RichTextStyle] = .all, + greedy: Bool = true + ) { + self._context = ObservedObject(wrappedValue: context) + self.isGreedy = greedy + self.styles = styles + } - private let styles: [RichTextStyle] - private let isGreedy: Bool + private let styles: [RichTextStyle] + private let isGreedy: Bool - private var groupWidth: CGFloat? { - if isGreedy { return nil } - let count = Double(styles.count) - #if macOS - return 30 * count - #else - return 50 * count - #endif - } + private var groupWidth: CGFloat? { + if isGreedy { return nil } + let count = Double(styles.count) + #if os(macOS) + return 30 * count + #else + return 50 * count + #endif + } - @ObservedObject - private var context: RichEditorState + @ObservedObject + private var context: RichEditorState - public var body: some View { - #if macOS - ControlGroup { - ForEach(styles) { - RichTextStyle.Toggle( - style: $0, - context: context, - fillVertically: true - ) - } - } - .frame(width: groupWidth) - #else - RichTextStyle.ToggleStack( - context: context, - styles: styles - ) - #endif + public var body: some View { + #if os(macOS) + ControlGroup { + ForEach(styles) { + RichTextStyle.Toggle( + style: $0, + context: context, + fillVertically: true + ) } - } + } + .frame(width: groupWidth) + #else + RichTextStyle.ToggleStack( + context: context, + styles: styles + ) + #endif + } } + } #endif diff --git a/Sources/RichEditorSwiftUI/Styles/RichTextStyle.swift b/Sources/RichEditorSwiftUI/Styles/RichTextStyle.swift index d1779d0..879efb8 100644 --- a/Sources/RichEditorSwiftUI/Styles/RichTextStyle.swift +++ b/Sources/RichEditorSwiftUI/Styles/RichTextStyle.swift @@ -8,57 +8,57 @@ import SwiftUI public enum RichTextStyle: String, CaseIterable, Identifiable, - RichTextLabelValue + RichTextLabelValue { - case bold - case italic - case underline - case strikethrough + case bold + case italic + case underline + case strikethrough } extension RichTextStyle { - /// All available rich text styles. - public static var all: [Self] { allCases } + /// All available rich text styles. + public static var all: [Self] { allCases } } extension Collection where Element == RichTextStyle { - /// All available rich text styles. - public static var all: [RichTextStyle] { RichTextStyle.allCases } + /// All available rich text styles. + public static var all: [RichTextStyle] { RichTextStyle.allCases } } extension RichTextStyle { - public var id: String { rawValue } + public var id: String { rawValue } - /// The standard icon to use for the trait. - public var icon: Image { - switch self { - case .bold: .richTextStyleBold - case .italic: .richTextStyleItalic - case .strikethrough: .richTextStyleStrikethrough - case .underline: .richTextStyleUnderline - } + /// The standard icon to use for the trait. + public var icon: Image { + switch self { + case .bold: .richTextStyleBold + case .italic: .richTextStyleItalic + case .strikethrough: .richTextStyleStrikethrough + case .underline: .richTextStyleUnderline } - - /// The localized style title. - public var title: String { - titleKey.text - } - - /// The localized style title key. - public var titleKey: RTEL10n { - switch self { - case .bold: .styleBold - case .italic: .styleItalic - case .underline: .styleUnderlined - case .strikethrough: .styleStrikethrough - } + } + + /// The localized style title. + public var title: String { + titleKey.text + } + + /// The localized style title key. + public var titleKey: RTEL10n { + switch self { + case .bold: .styleBold + case .italic: .styleItalic + case .underline: .styleUnderlined + case .strikethrough: .styleStrikethrough } + } - /** + /** Get the rich text styles that are enabled in a provided set of traits and attributes. @@ -66,72 +66,72 @@ extension RichTextStyle { - traits: The symbolic traits to inspect. - attributes: The rich text attributes to inspect. */ - public static func styles( - in traits: FontTraitsRepresentable?, - attributes: RichTextAttributes? - ) -> [RichTextStyle] { - var styles = traits?.enabledRichTextStyles ?? [] - if attributes?.isStrikethrough == true { styles.append(.strikethrough) } - if attributes?.isUnderlined == true { styles.append(.underline) } - return styles - } + public static func styles( + in traits: FontTraitsRepresentable?, + attributes: RichTextAttributes? + ) -> [RichTextStyle] { + var styles = traits?.enabledRichTextStyles ?? [] + if attributes?.isStrikethrough == true { styles.append(.strikethrough) } + if attributes?.isUnderlined == true { styles.append(.underline) } + return styles + } } extension Collection where Element == RichTextStyle { - /// Check if the collection contains a certain style. - public func hasStyle(_ style: RichTextStyle) -> Bool { - contains(style) - } - - /// Check if a certain style change should be applied. - public func shouldAddOrRemove( - _ style: RichTextStyle, - _ newValue: Bool - ) -> Bool { - let shouldAdd = newValue && !hasStyle(style) - let shouldRemove = !newValue && hasStyle(style) - return shouldAdd || shouldRemove - } + /// Check if the collection contains a certain style. + public func hasStyle(_ style: RichTextStyle) -> Bool { + contains(style) + } + + /// Check if a certain style change should be applied. + public func shouldAddOrRemove( + _ style: RichTextStyle, + _ newValue: Bool + ) -> Bool { + let shouldAdd = newValue && !hasStyle(style) + let shouldRemove = !newValue && hasStyle(style) + return shouldAdd || shouldRemove + } } #if canImport(UIKit) - extension RichTextStyle { - - /// The symbolic font traits for the style, if any. - public var symbolicTraits: UIFontDescriptor.SymbolicTraits? { - switch self { - case .bold: .traitBold - case .italic: .traitItalic - case .strikethrough: nil - case .underline: nil - } - } + extension RichTextStyle { + + /// The symbolic font traits for the style, if any. + public var symbolicTraits: UIFontDescriptor.SymbolicTraits? { + switch self { + case .bold: .traitBold + case .italic: .traitItalic + case .strikethrough: nil + case .underline: nil + } } + } #endif -#if macOS - extension RichTextStyle { - - /// The symbolic font traits for the trait, if any. - public var symbolicTraits: NSFontDescriptor.SymbolicTraits? { - switch self { - case .bold: .bold - case .italic: .italic - case .strikethrough: nil - case .underline: nil - } - } +#if os(macOS) + extension RichTextStyle { + + /// The symbolic font traits for the trait, if any. + public var symbolicTraits: NSFontDescriptor.SymbolicTraits? { + switch self { + case .bold: .bold + case .italic: .italic + case .strikethrough: nil + case .underline: nil + } } + } #endif extension RichTextStyle { - var richTextSpanStyle: RichTextSpanStyle { - switch self { - case .bold: .bold - case .italic: .italic - case .strikethrough: .strikethrough - case .underline: .underline - } + var richTextSpanStyle: RichTextSpanStyle { + switch self { + case .bold: .bold + case .italic: .italic + case .strikethrough: .strikethrough + case .underline: .underline } + } } diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Link.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Link.swift index 615e94f..1f109d6 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Link.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Link.swift @@ -7,50 +7,52 @@ import SwiftUI -extension RichEditorState { +#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + extension RichEditorState { func insertLink(value: Bool) { - if link != nil { - alertController.showAlert( - title: "Remove link", message: "It will remove link from selected text", - onOk: { [weak self] in - guard let self else { return } - self.updateStyle(style: .link()) - }, - onCancel: { - return - }) - } else { - alertController.showAlert( - title: "Enter url", message: "", placeholder: "Enter link", - defaultText: "", - onTextChange: { text in - }, - completion: { [weak self] finalText in - self?.updateStyle(style: .link(finalText)) - }) - } + if link != nil { + alertController.showAlert( + title: "Remove link", message: "It will remove link from selected text", + onOk: { [weak self] in + guard let self else { return } + self.updateStyle(style: .link()) + }, + onCancel: { + return + }) + } else { + alertController.showAlert( + title: "Enter url", message: "", placeholder: "Enter link", + defaultText: "", + onTextChange: { text in + }, + completion: { [weak self] finalText in + self?.updateStyle(style: .link(finalText)) + }) + } } -} + } -extension RichEditorState { + extension RichEditorState { /// Get a binding for a certain style. - public func binding(for style: RichTextOtherMenu) -> Binding { - Binding( - get: { Bool(self.hasStyle(style)) }, - set: { self.setLink(to: $0) } - ) + public func bindingForManu(for menu: RichTextOtherMenu) -> Binding { + Binding( + get: { Bool(self.hasStyle(menu)) }, + set: { self.setLink(to: $0) } + ) } /// Check whether or not the context has a certain style. public func hasStyle(_ style: RichTextOtherMenu) -> Bool { - link != nil + link != nil } /// Set whether or not the context has a certain style. public func setLink( - to val: Bool + to val: Bool ) { - insertLink(value: val) + insertLink(value: val) } -} + } +#endif diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift index 85c0b46..d08d097 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift @@ -18,12 +18,12 @@ import SwiftUI /// context with focus in a multi-windowed app. public class RichEditorState: ObservableObject { - /// Create a new rich text context instance. - public init() {} + /// Create a new rich text context instance. + public init() {} - // MARK: - Not yet observable properties + // MARK: - Not yet observable properties - /** + /** The currently attributed string, if any. Note that the property is read-only and not `@Published` @@ -36,248 +36,249 @@ public class RichEditorState: ObservableObject { Until then, use `setAttributedString(to:)` to change it. */ - public internal(set) var attributedString = NSAttributedString() + public internal(set) var attributedString = NSAttributedString() - /// The currently selected range, if any. - public internal(set) var selectedRange = NSRange() + /// The currently selected range, if any. + public internal(set) var selectedRange = NSRange() - // MARK: - Bindable & Settable Properies + // MARK: - Bindable & Settable Properies - /// Whether or not the rich text editor is editable. - @Published - public var isEditable = true + /// Whether or not the rich text editor is editable. + @Published + public var isEditable = true - /// Whether or not the text is currently being edited. - @Published - public var isEditingText = false + /// Whether or not the text is currently being edited. + @Published + public var isEditingText = false - @Published - public var headerType: HeaderType = .default + @Published + public var headerType: HeaderType = .default - /// The current text alignment, if any. - @Published - public var textAlignment: RichTextAlignment = .left + /// The current text alignment, if any. + @Published + public var textAlignment: RichTextAlignment = .left - /// The current font name. - @Published - public var fontName = RichTextFont.PickerFont.all.first?.fontName ?? "" + /// The current font name. + @Published + public var fontName = RichTextFont.PickerFont.all.first?.fontName ?? "" - /// The current font size. - @Published - public var fontSize = CGFloat.standardRichTextFontSize + /// The current font size. + @Published + public var fontSize = CGFloat.standardRichTextFontSize - /// The current line spacing. - @Published - public var lineSpacing: CGFloat = 10.0 + /// The current line spacing. + @Published + public var lineSpacing: CGFloat = 10.0 - // MARK: - Observable Properties + // MARK: - Observable Properties - /// Whether or not the current rich text can be copied. - @Published - public internal(set) var canCopy = false + /// Whether or not the current rich text can be copied. + @Published + public internal(set) var canCopy = false - /// Whether or not the latest undo can be redone. - @Published - public internal(set) var canRedoLatestChange = false + /// Whether or not the latest undo can be redone. + @Published + public internal(set) var canRedoLatestChange = false - /// Whether or not the latest change can be undone. - @Published - public internal(set) var canUndoLatestChange = false + /// Whether or not the latest change can be undone. + @Published + public internal(set) var canUndoLatestChange = false - /// The current color values. - @Published - public internal(set) var colors = [RichTextColor: ColorRepresentable]() + /// The current color values. + @Published + public internal(set) var colors = [RichTextColor: ColorRepresentable]() - /// The style to apply when highlighting a range. - @Published - public internal(set) var highlightingStyle = RichTextHighlightingStyle - .standard + /// The style to apply when highlighting a range. + @Published + public internal(set) var highlightingStyle = RichTextHighlightingStyle + .standard - /// The current paragraph style. - @Published - public internal(set) var paragraphStyle = NSParagraphStyle.default + /// The current paragraph style. + @Published + public internal(set) var paragraphStyle = NSParagraphStyle.default - /// The current rich text styles. - @Published - public internal(set) var styles = [RichTextStyle: Bool]() + /// The current rich text styles. + @Published + public internal(set) var styles = [RichTextStyle: Bool]() - @Published - public internal(set) var link: String? = nil + @Published + public internal(set) var link: String? = nil - // MARK: - Properties + // MARK: - Properties - /// This publisher can emit actions to the coordinator. - public let actionPublisher = RichTextAction.Publisher() + /// This publisher can emit actions to the coordinator. + public let actionPublisher = RichTextAction.Publisher() - /// The currently highlighted range, if any. - public var highlightedRange: NSRange? + /// The currently highlighted range, if any. + public var highlightedRange: NSRange? - //MARK: - Variables To Handle JSON - internal var adapter: EditorAdapter = DefaultAdapter() + //MARK: - Variables To Handle JSON + internal var adapter: EditorAdapter = DefaultAdapter() - @Published internal var activeStyles: Set = [] - @Published internal var activeAttributes: [NSAttributedString.Key: Any]? = - [:] + @Published internal var activeStyles: Set = [] + @Published internal var activeAttributes: [NSAttributedString.Key: Any]? = + [:] - internal var internalSpans: [RichTextSpanInternal] = [] + internal var internalSpans: [RichTextSpanInternal] = [] - internal var rawText: String = "" + internal var rawText: String = "" - internal var updateAttributesQueue: - [(span: RichTextSpanInternal, shouldApply: Bool)] = [] - internal let alertController: AlertController = AlertController() + internal var updateAttributesQueue: [(span: RichTextSpanInternal, shouldApply: Bool)] = [] + #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) + internal let alertController: RichTextAlertController = RichTextAlertController() + #endif - /** + /** This will provide encoded text which is of type RichText */ - public var richText: RichText { - return getRichText() - } - - internal var spans: RichTextSpans { - return internalSpans.map({ - .init( - insert: getStringWith(from: $0.from, to: $0.to), - attributes: $0.attributes) - }) - } - - var internalRichText: RichText = .init() - //MARK: - Initializers - /** + public var richText: RichText { + return getRichText() + } + + internal var spans: RichTextSpans { + return internalSpans.map({ + .init( + insert: getStringWith(from: $0.from, to: $0.to), + attributes: $0.attributes) + }) + } + + var internalRichText: RichText = .init() + //MARK: - Initializers + /** Init with richText which is of type RichText */ - public init(richText: RichText) { - internalRichText = richText - let input = richText.spans.map({ $0.insert }).joined() - var tempSpans: [RichTextSpanInternal] = [] - var text = "" - richText.spans.forEach({ - let span = RichTextSpanInternal( - from: text.utf16Length, - to: (text.utf16Length + $0.insert.utf16Length - 1), - attributes: $0.attributes) - tempSpans.append(span) - text += $0.insert - }) - - let str = NSMutableAttributedString(string: text) - - tempSpans.forEach { span in - str.addAttributes( - span.attributes?.toAttributes(font: .standardRichTextFont) - ?? [:], range: span.spanRange) - if span.attributes?.color == nil { - var color: ColorRepresentable = .clear - #if os(watchOS) - color = .black - #else - color = RichTextView.Theme.standard.fontColor - #endif - str.addAttributes( - [.foregroundColor: color], range: span.spanRange) - } - } - - self.attributedString = str - - self.internalSpans = tempSpans - - selectedRange = NSRange(location: 0, length: 0) - activeStyles = [] - - rawText = input + public init(richText: RichText) { + internalRichText = richText + let input = richText.spans.map({ $0.insert }).joined() + var tempSpans: [RichTextSpanInternal] = [] + var text = "" + richText.spans.forEach({ + let span = RichTextSpanInternal( + from: text.utf16Length, + to: (text.utf16Length + $0.insert.utf16Length - 1), + attributes: $0.attributes) + tempSpans.append(span) + text += $0.insert + }) + + let str = NSMutableAttributedString(string: text) + + tempSpans.forEach { span in + str.addAttributes( + span.attributes?.toAttributes(font: .standardRichTextFont) + ?? [:], range: span.spanRange) + if span.attributes?.color == nil { + var color: ColorRepresentable = .clear + #if os(watchOS) + color = .black + #else + color = RichTextView.Theme.standard.fontColor + #endif + str.addAttributes( + [.foregroundColor: color], range: span.spanRange) + } } - /** + self.attributedString = str + + self.internalSpans = tempSpans + + selectedRange = NSRange(location: 0, length: 0) + activeStyles = [] + + rawText = input + } + + /** Init with input which is of type String */ - public init(input: String) { - let adapter = DefaultAdapter() + public init(input: String) { + let adapter = DefaultAdapter() - self.adapter = adapter + self.adapter = adapter - let str = NSMutableAttributedString(string: input) + let str = NSMutableAttributedString(string: input) - str.addAttributes( - [.font: FontRepresentable.standardRichTextFont], - range: str.richTextRange) - self.attributedString = str + str.addAttributes( + [.font: FontRepresentable.standardRichTextFont], + range: str.richTextRange) + self.attributedString = str - self.internalSpans = [ - .init( - from: 0, to: input.utf16Length > 0 ? input.utf16Length - 1 : 0, - attributes: RichAttributes()) - ] + self.internalSpans = [ + .init( + from: 0, to: input.utf16Length > 0 ? input.utf16Length - 1 : 0, + attributes: RichAttributes()) + ] - selectedRange = NSRange(location: 0, length: 0) - activeStyles = [] + selectedRange = NSRange(location: 0, length: 0) + activeStyles = [] - rawText = input - } + rawText = input + } } extension RichEditorState { - /// Whether or not the context has a selected range. - public var hasHighlightedRange: Bool { - highlightedRange != nil - } + /// Whether or not the context has a selected range. + public var hasHighlightedRange: Bool { + highlightedRange != nil + } - /// Whether or not the context has a selected range. - public var hasSelectedRange: Bool { - selectedRange.length > 0 - } + /// Whether or not the context has a selected range. + public var hasSelectedRange: Bool { + selectedRange.length > 0 + } } extension RichEditorState { - /// Set ``highlightedRange`` to a new, optional range. - public func highlightRange(_ range: NSRange?) { - actionPublisher.send(.setHighlightedRange(range)) - highlightedRange = range - } - - /// Reset the attributed string. - public func resetAttributedString() { - setAttributedString(to: "") - } - - /// Reset the ``highlightedRange``. - public func resetHighlightedRange() { - guard hasHighlightedRange else { return } - highlightedRange = nil - } - - /// Reset the ``selectedRange``. - public func resetSelectedRange() { - selectedRange = NSRange() - } - - /// Set a new range and start editing. - public func selectRange(_ range: NSRange) { - isEditingText = true - actionPublisher.send(.selectRange(range)) - } - - /// Set the attributed string to a new plain text. - public func setAttributedString(to text: String) { - setAttributedString(to: NSAttributedString(string: text)) - } - - /// Set the attributed string to a new rich text. - public func setAttributedString(to string: NSAttributedString) { - let mutable = NSMutableAttributedString(attributedString: string) - actionPublisher.send(.setAttributedString(mutable)) - } - - /// Set ``isEditingText`` to `false`. - public func stopEditingText() { - isEditingText = false - } - - /// Toggle whether or not the text is being edited. - public func toggleIsEditing() { - isEditingText.toggle() - } + /// Set ``highlightedRange`` to a new, optional range. + public func highlightRange(_ range: NSRange?) { + actionPublisher.send(.setHighlightedRange(range)) + highlightedRange = range + } + + /// Reset the attributed string. + public func resetAttributedString() { + setAttributedString(to: "") + } + + /// Reset the ``highlightedRange``. + public func resetHighlightedRange() { + guard hasHighlightedRange else { return } + highlightedRange = nil + } + + /// Reset the ``selectedRange``. + public func resetSelectedRange() { + selectedRange = NSRange() + } + + /// Set a new range and start editing. + public func selectRange(_ range: NSRange) { + isEditingText = true + actionPublisher.send(.selectRange(range)) + } + + /// Set the attributed string to a new plain text. + public func setAttributedString(to text: String) { + setAttributedString(to: NSAttributedString(string: text)) + } + + /// Set the attributed string to a new rich text. + public func setAttributedString(to string: NSAttributedString) { + let mutable = NSMutableAttributedString(attributedString: string) + actionPublisher.send(.setAttributedString(mutable)) + } + + /// Set ``isEditingText`` to `false`. + public func stopEditingText() { + isEditingText = false + } + + /// Toggle whether or not the text is being edited. + public func toggleIsEditing() { + isEditingText.toggle() + } } diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift index 22f4c76..76def11 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift @@ -6,163 +6,163 @@ // #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) - import SwiftUI - import Combine - - /// This view can be used to view and edit rich text in SwiftUI. - /// - /// The view uses a platform-specific ``RichTextView`` together - /// with a ``RichEditorState`` and a ``RichTextCoordinator`` to - /// keep the view and context in sync. - /// - /// You can use the provided context to trigger and observe any - /// changes to the text editor. Note that changing the value of - /// the `text` binding will not yet update the editor. Until it - /// is fixed, use `setAttributedString(to:)`. - /// - /// Since the view wraps a native `UIKit` or `AppKit` text view, - /// you can't apply `.toolbar` modifiers to it, like you can do - /// with other SwiftUI views. This means that this doesn't work: - /// - /// ```swift - /// RichTextEditor(text: $text, context: context) - /// .toolbar { - /// ToolbarItemGroup(placement: .keyboard) { - /// .... - /// } - /// } - /// ``` + import SwiftUI + import Combine + + /// This view can be used to view and edit rich text in SwiftUI. + /// + /// The view uses a platform-specific ``RichTextView`` together + /// with a ``RichEditorState`` and a ``RichTextCoordinator`` to + /// keep the view and context in sync. + /// + /// You can use the provided context to trigger and observe any + /// changes to the text editor. Note that changing the value of + /// the `text` binding will not yet update the editor. Until it + /// is fixed, use `setAttributedString(to:)`. + /// + /// Since the view wraps a native `UIKit` or `AppKit` text view, + /// you can't apply `.toolbar` modifiers to it, like you can do + /// with other SwiftUI views. This means that this doesn't work: + /// + /// ```swift + /// RichTextEditor(text: $text, context: context) + /// .toolbar { + /// ToolbarItemGroup(placement: .keyboard) { + /// .... + /// } + /// } + /// ``` + /// + /// This will not show anything. To work around this limitation, + /// use a ``RichTextKeyboardToolbar`` instead. + /// + /// You can configure and style the view by applying its config + /// and style view modifiers to your view hierarchy: + /// + /// ```swift + /// VStack { + /// RichTextEditor(...) + /// ... + /// } + /// .richTextEditorStyle(...) + /// .richTextEditorConfig(...) + /// ``` + /// + /// For more information, see ``RichTextKeyboardToolbarConfig`` + /// and ``RichTextKeyboardToolbarStyle``. + public struct RichTextEditor: ViewRepresentable { + + @State var cancellable: Set = [] + + /// Create a rich text editor with a rich text value and + /// a certain rich text data format. /// - /// This will not show anything. To work around this limitation, - /// use a ``RichTextKeyboardToolbar`` instead. - /// - /// You can configure and style the view by applying its config - /// and style view modifiers to your view hierarchy: - /// - /// ```swift - /// VStack { - /// RichTextEditor(...) - /// ... - /// } - /// .richTextEditorStyle(...) - /// .richTextEditorConfig(...) - /// ``` - /// - /// For more information, see ``RichTextKeyboardToolbarConfig`` - /// and ``RichTextKeyboardToolbarStyle``. - public struct RichTextEditor: ViewRepresentable { - - @State var cancellable: Set = [] - - /// Create a rich text editor with a rich text value and - /// a certain rich text data format. - /// - /// - Parameters: - /// - context: The rich editor state to use. - /// - viewConfiguration: A platform-specific view configuration, if any. - public init( - context: ObservedObject, - format: RichTextDataFormat = .archivedData, - viewConfiguration: @escaping ViewConfiguration = { _ in } - ) { - self._context = context - self.format = format - self.viewConfiguration = viewConfiguration - } - - public typealias ViewConfiguration = (RichTextViewComponent) -> Void - - @ObservedObject - private var context: RichEditorState - - private var viewConfiguration: ViewConfiguration - - private var format: RichTextDataFormat - - @Environment(\.richTextEditorConfig) - private var config - - @Environment(\.richTextEditorStyle) - private var style - - #if os(iOS) || os(tvOS) || os(visionOS) - public let textView = RichTextView() - #endif - - #if macOS - public let scrollView = RichTextView.scrollableTextView() - - public var textView: RichTextView { - scrollView.documentView as? RichTextView ?? RichTextView() - } - #endif - - public func makeCoordinator() -> RichTextCoordinator { - RichTextCoordinator( - text: $context.attributedString, - textView: textView, - richTextContext: context - ) - } - - #if os(iOS) || os(tvOS) || os(visionOS) - public func makeUIView(context: Context) -> some UIView { - textView.setup( - with: self.context.attributedString, format: format) - textView.configuration = config - textView.theme = style - viewConfiguration(textView) - return textView - } - - public func updateUIView(_ view: UIViewType, context: Context) { - // if !(self.context.activeAttributes? - // .contains(where: { $0.key == .font }) ?? false) { - // self.textView.typingAttributes = [.font: style.font] - // } - } - #else - - public func makeNSView(context: Context) -> some NSView { - textView.setup( - with: self.context.attributedString, format: format) - textView.configuration = config - textView.theme = style - viewConfiguration(textView) - return scrollView - } - - public func updateNSView(_ view: NSViewType, context: Context) {} - #endif + /// - Parameters: + /// - context: The rich editor state to use. + /// - viewConfiguration: A platform-specific view configuration, if any. + public init( + context: ObservedObject, + format: RichTextDataFormat = .archivedData, + viewConfiguration: @escaping ViewConfiguration = { _ in } + ) { + self._context = context + self.format = format + self.viewConfiguration = viewConfiguration } - // MARK: RichTextPresenter + public typealias ViewConfiguration = (RichTextViewComponent) -> Void + + @ObservedObject + private var context: RichEditorState - extension RichTextEditor { + private var viewConfiguration: ViewConfiguration + + private var format: RichTextDataFormat + + @Environment(\.richTextEditorConfig) + private var config + + @Environment(\.richTextEditorStyle) + private var style + + #if os(iOS) || os(tvOS) || os(visionOS) + public let textView = RichTextView() + #endif + + #if os(macOS) + public let scrollView = RichTextView.scrollableTextView() + + public var textView: RichTextView { + scrollView.documentView as? RichTextView ?? RichTextView() + } + #endif + + public func makeCoordinator() -> RichTextCoordinator { + RichTextCoordinator( + text: $context.attributedString, + textView: textView, + richTextContext: context + ) + } - /// Get the currently selected range. - public var selectedRange: NSRange { - textView.selectedRange - } + #if os(iOS) || os(tvOS) || os(visionOS) + public func makeUIView(context: Context) -> some UIView { + textView.setup( + with: self.context.attributedString, format: format) + textView.configuration = config + textView.theme = style + viewConfiguration(textView) + return textView + } + + public func updateUIView(_ view: UIViewType, context: Context) { + // if !(self.context.activeAttributes? + // .contains(where: { $0.key == .font }) ?? false) { + // self.textView.typingAttributes = [.font: style.font] + // } + } + #else + + public func makeNSView(context: Context) -> some NSView { + textView.setup( + with: self.context.attributedString, format: format) + textView.configuration = config + textView.theme = style + viewConfiguration(textView) + return scrollView + } + + public func updateNSView(_ view: NSViewType, context: Context) {} + #endif + } + + // MARK: RichTextPresenter + + extension RichTextEditor { + + /// Get the currently selected range. + public var selectedRange: NSRange { + textView.selectedRange } + } - // MARK: RichTextReader + // MARK: RichTextReader - extension RichTextEditor { + extension RichTextEditor { - /// Get the string that is managed by the editor. - public var attributedString: NSAttributedString { - context.attributedString - } + /// Get the string that is managed by the editor. + public var attributedString: NSAttributedString { + context.attributedString } + } - // MARK: RichTextWriter + // MARK: RichTextWriter - extension RichTextEditor { + extension RichTextEditor { - /// Get the mutable string that is managed by the editor. - public var mutableAttributedString: NSMutableAttributedString? { - textView.mutableAttributedString - } + /// Get the mutable string that is managed by the editor. + public var mutableAttributedString: NSMutableAttributedString? { + textView.mutableAttributedString } + } #endif diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift index 0218566..a59ddff 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift @@ -10,358 +10,358 @@ import SwiftUI //MARK: - Public Methods extension RichEditorState { - func getStringWith(from: Int, to: Int) -> String { - guard (to - from) >= 0 else { return "" } - return attributedString.string.substring( - from: .init(location: from, length: (to - from))) - } + func getStringWith(from: Int, to: Int) -> String { + guard (to - from) >= 0 else { return "" } + return attributedString.string.substring( + from: .init(location: from, length: (to - from))) + } - /** + /** This will provide RichText which is encoded from input and editor text */ - internal func getRichText() -> RichText { - return attributedString.string.isEmpty - ? RichText() : RichText(spans: spans) - } + internal func getRichText() -> RichText { + return attributedString.string.isEmpty + ? RichText() : RichText(spans: spans) + } - /** + /** This will provide String value from editor */ - public func outputAsString() -> String { - return (try? adapter.encodeToString(type: richText)) ?? "" - } + public func outputAsString() -> String { + return (try? adapter.encodeToString(type: richText)) ?? "" + } - public func output() throws -> Data { - return try adapter.encode(type: richText) - } + public func output() throws -> Data { + return try adapter.encode(type: richText) + } - /** + /** This will export editor text as JSON string */ - public func export() -> String? { - return (try? adapter.encodeToString(type: richText)) - } + public func export() -> String? { + return (try? adapter.encodeToString(type: richText)) + } - /** + /** This will toggle the style - Parameters: - style: is of type RichTextSpanStyle */ - public func toggleStyle(style: RichTextSpanStyle) { - if activeStyles.contains(style) { - setInternalStyles(style: style, add: false) - removeStyle(style) - } else { - setInternalStyles(style: style) - addStyle(style) - } + public func toggleStyle(style: RichTextSpanStyle) { + if activeStyles.contains(style) { + setInternalStyles(style: style, add: false) + removeStyle(style) + } else { + setInternalStyles(style: style) + addStyle(style) } + } - /** + /** This will update the style - Parameters: - style: is of type RichTextSpanStyle */ - public func updateStyle(style: RichTextSpanStyle) { - setInternalStyles(style: style) - setStyle(style) - } + public func updateStyle(style: RichTextSpanStyle) { + setInternalStyles(style: style) + setStyle(style) + } } //MARK: - TextView Helper Methods extension RichEditorState { - /** + /** Handle UITextView's delegate methods calles - Parameters: - event: is of type TextViewEvents This will switch on event and call respective method */ - internal func onTextViewEvent(_ event: TextViewEvents) { - switch event { - case .didChangeSelection(let range, let text): - selectedRange = range - guard - rawText.count == text.string.count && selectedRange.isCollapsed - else { - return - } - onSelectionDidChanged() - case .didBeginEditing(let range, _): - selectedRange = range - case .didChange: - onTextFieldValueChange( - newText: attributedString, selection: selectedRange) - case .didEndEditing: - selectedRange = .init(location: 0, length: 0) - } - } - - /** + internal func onTextViewEvent(_ event: TextViewEvents) { + switch event { + case .didChangeSelection(let range, let text): + selectedRange = range + guard + rawText.count == text.string.count && selectedRange.isCollapsed + else { + return + } + onSelectionDidChanged() + case .didBeginEditing(let range, _): + selectedRange = range + case .didChange: + onTextFieldValueChange( + newText: attributedString, selection: selectedRange) + case .didEndEditing: + selectedRange = .init(location: 0, length: 0) + } + } + + /** This will decide whether Character is added or removed and perform accordingly - Parameters: - newText: is updated NSMutableAttributedString - selection: is the range of the selected text */ - private func onTextFieldValueChange( - newText: NSAttributedString, selection: NSRange - ) { - self.selectedRange = selection - - if newText.string.count > rawText.count { - handleAddingCharacters(newText) - } else if newText.string.count < rawText.count { - handleRemovingCharacters(newText) - } + private func onTextFieldValueChange( + newText: NSAttributedString, selection: NSRange + ) { + self.selectedRange = selection - rawText = newText.string - updateCurrentSpanStyle() + if newText.string.count > rawText.count { + handleAddingCharacters(newText) + } else if newText.string.count < rawText.count { + handleRemovingCharacters(newText) } - /** + rawText = newText.string + updateCurrentSpanStyle() + } + + /** Update the selection - Parameters: - range: is the range of the selected text - newText: is updated NSMutableAttributedString */ - internal func onSelectionDidChanged() { - updateCurrentSpanStyle() - } + internal func onSelectionDidChanged() { + updateCurrentSpanStyle() + } - /** + /** Set the activeStyles - Parameters: - style: is of type RichTextSpanStyle This will set the activeStyle according to style passed */ - private func setStyle(_ style: RichTextSpanStyle) { - activeStyles.removeAll() - activeAttributes = [:] - activeStyles.insert(style) + private func setStyle(_ style: RichTextSpanStyle) { + activeStyles.removeAll() + activeAttributes = [:] + activeStyles.insert(style) - if style.isHeaderStyle || style.isDefault || style.isList - || style.isAlignmentStyle - { - handleAddOrRemoveStyleToLine( - in: selectedRange, style: style, byAdding: !style.isDefault) - } else if !selectedRange.isCollapsed { - let addStyle = checkIfStyleIsActiveWithSameAttributes(style) - processSpansFor(new: style, in: selectedRange, addStyle: addStyle) - } - - updateCurrentSpanStyle() - } - - func checkIfStyleIsActiveWithSameAttributes(_ style: RichTextSpanStyle) - -> Bool + if style.isHeaderStyle || style.isDefault //|| style.isList + || style.isAlignmentStyle { - var addStyle: Bool = true - switch style { - case .size(let size): - if let size { - addStyle = CGFloat(size) != CGFloat.standardRichTextFontSize - } - case .font(let fontName): - if let fontName { - addStyle = fontName == self.fontName - } - case .color(let color): - if let color, color.toHex() != Color.primary.toHex() { - if let internalColor = self.color(for: .foreground) { - addStyle = Color(internalColor) != color - } else { - addStyle = true - } - } else { - addStyle = false - } - case .background(let bgColor): - if let color = bgColor, color.toHex() != Color.clear.toHex() { - if let internalColor = self.color(for: .background) { - addStyle = Color(internalColor) != color - } else { - addStyle = true - } - } else { - addStyle = false - } - case .align(let alignment): - if let alignment { - addStyle = alignment != self.textAlignment || alignment != .left - } - case .link(let link): - addStyle = link != nil - default: - return addStyle + handleAddOrRemoveStyleToLine( + in: selectedRange, style: style, byAdding: !style.isDefault) + } else if !selectedRange.isCollapsed { + let addStyle = checkIfStyleIsActiveWithSameAttributes(style) + processSpansFor(new: style, in: selectedRange, addStyle: addStyle) + } + + updateCurrentSpanStyle() + } + + func checkIfStyleIsActiveWithSameAttributes(_ style: RichTextSpanStyle) + -> Bool + { + var addStyle: Bool = true + switch style { + case .size(let size): + if let size { + addStyle = CGFloat(size) != CGFloat.standardRichTextFontSize + } + case .font(let fontName): + if let fontName { + addStyle = fontName == self.fontName + } + case .color(let color): + if let color, color.toHex() != Color.primary.toHex() { + if let internalColor = self.color(for: .foreground) { + addStyle = Color(internalColor) != color + } else { + addStyle = true } - - return addStyle - } - - /** + } else { + addStyle = false + } + case .background(let bgColor): + if let color = bgColor, color.toHex() != Color.clear.toHex() { + if let internalColor = self.color(for: .background) { + addStyle = Color(internalColor) != color + } else { + addStyle = true + } + } else { + addStyle = false + } + case .align(let alignment): + if let alignment { + addStyle = alignment != self.textAlignment || alignment != .left + } + case .link(let link): + addStyle = link != nil + default: + return addStyle + } + + return addStyle + } + + /** Update the activeStyles and activeAttributes */ - internal func updateCurrentSpanStyle() { - guard !attributedString.string.isEmpty else { return } - var newStyles: Set = [] + internal func updateCurrentSpanStyle() { + guard !attributedString.string.isEmpty else { return } + var newStyles: Set = [] - if selectedRange.isCollapsed { - newStyles = getRichSpanStyleByTextIndex(selectedRange.location - 1) - } else { - newStyles = Set(getRichSpanStyleListByTextRange(selectedRange)) - } + if selectedRange.isCollapsed { + newStyles = getRichSpanStyleByTextIndex(selectedRange.location - 1) + } else { + newStyles = Set(getRichSpanStyleListByTextRange(selectedRange)) + } - guard activeStyles != newStyles && selectedRange.location != 0 else { - return - } - activeStyles = newStyles - var attributes: [NSAttributedString.Key: Any] = [:] - activeStyles.forEach({ - attributes[$0.attributedStringKey] = $0.defaultAttributeValue( - font: FontRepresentable.standardRichTextFont) - }) - - headerType = - activeStyles.first(where: { $0.isHeaderStyle })?.headerType - ?? .default - - activeAttributes = attributes + guard activeStyles != newStyles && selectedRange.location != 0 else { + return } + activeStyles = newStyles + var attributes: [NSAttributedString.Key: Any] = [:] + activeStyles.forEach({ + attributes[$0.attributedStringKey] = $0.defaultAttributeValue( + font: FontRepresentable.standardRichTextFont) + }) + + headerType = + activeStyles.first(where: { $0.isHeaderStyle })?.headerType + ?? .default + + activeAttributes = attributes + } } //MARK: - Add styles extension RichEditorState { - /** + /** This will add style to the selected text - Parameters: - style: which is of type RichTextSpanStyle It will add style to the selected text if needed and set activeAttributes and activeStyle accordingly. */ - private func addStyle(_ style: RichTextSpanStyle) { - guard !activeStyles.contains(style) else { return } - activeStyles.insert(style) + private func addStyle(_ style: RichTextSpanStyle) { + guard !activeStyles.contains(style) else { return } + activeStyles.insert(style) - if style.isHeaderStyle || style.isDefault || style.isList - || style.isAlignmentStyle - { - handleAddOrRemoveStyleToLine(in: selectedRange, style: style) - } else if !selectedRange.isCollapsed { - processSpansFor(new: style, in: selectedRange) - } + if style.isHeaderStyle || style.isDefault //|| style.isList + || style.isAlignmentStyle + { + handleAddOrRemoveStyleToLine(in: selectedRange, style: style) + } else if !selectedRange.isCollapsed { + processSpansFor(new: style, in: selectedRange) } + } - //MARK: - Remove Style - /** + //MARK: - Remove Style + /** This will remove style from active style if it contains it - Parameters: - style: which is of type RichTextSpanStyle This will remove typing attributes as well for style. */ - private func removeStyle(_ style: RichTextSpanStyle) { - guard activeStyles.contains(style) || style.isDefault else { return } - activeStyles.remove(style) - updateTypingAttributes() - - if style.isHeaderStyle || style.isDefault || style.isList { - handleAddOrRemoveStyleToLine( - in: selectedRange, style: style, byAdding: false) - } else if !selectedRange.isCollapsed { - processSpansFor(new: style, in: selectedRange, addStyle: false) - } + private func removeStyle(_ style: RichTextSpanStyle) { + guard activeStyles.contains(style) || style.isDefault else { return } + activeStyles.remove(style) + updateTypingAttributes() + + if style.isHeaderStyle || style.isDefault { //|| style.isList { + handleAddOrRemoveStyleToLine( + in: selectedRange, style: style, byAdding: false) + } else if !selectedRange.isCollapsed { + processSpansFor(new: style, in: selectedRange, addStyle: false) } + } - /** + /** This will update the typing attribute according to active style */ - private func updateTypingAttributes() { - var attributes: [NSAttributedString.Key: Any] = [:] + private func updateTypingAttributes() { + var attributes: [NSAttributedString.Key: Any] = [:] - activeStyles.forEach({ - attributes[$0.attributedStringKey] = $0.defaultAttributeValue( - font: FontRepresentable.standardRichTextFont) - }) + activeStyles.forEach({ + attributes[$0.attributedStringKey] = $0.defaultAttributeValue( + font: FontRepresentable.standardRichTextFont) + }) - activeAttributes = attributes - } + activeAttributes = attributes + } } //MARK: - Add character extension RichEditorState { - /** + /** This will handle the newly added character in editor - Parameters: - newValue: is of type NSMutableAttributedString This will generate break the span according to requirement to avoid duplication of the span. */ - private func handleAddingCharacters(_ newValue: NSAttributedString) { - let typedChars = newValue.string.utf16Length - rawText.utf16Length - let startTypeIndex = selectedRange.location - typedChars - let startTypeChar = newValue.string.utf16.map({ $0 })[startTypeIndex] - - if startTypeChar == "\n".utf16.first - && startTypeChar == "\n".utf16.last, - activeStyles.contains(where: { $0.isHeaderStyle }) - { - activeStyles.removeAll() - } + private func handleAddingCharacters(_ newValue: NSAttributedString) { + let typedChars = newValue.string.utf16Length - rawText.utf16Length + let startTypeIndex = selectedRange.location - typedChars + let startTypeChar = newValue.string.utf16.map({ $0 })[startTypeIndex] + + if startTypeChar == "\n".utf16.first + && startTypeChar == "\n".utf16.last, + activeStyles.contains(where: { $0.isHeaderStyle }) + { + activeStyles.removeAll() + } - var selectedStyles = activeStyles + var selectedStyles = activeStyles - moveSpansForward(startTypeIndex: startTypeIndex, by: typedChars) + moveSpansForward(startTypeIndex: startTypeIndex, by: typedChars) - let startParts = internalSpans.filter { - $0.closedRange.contains(startTypeIndex - 1) - } - let endParts = internalSpans.filter { - $0.closedRange.contains(startTypeIndex) - } - let commonParts = Set(startParts).intersection(Set(endParts)) - - var addedInFirstPart: Bool = false - - startParts.filter { !commonParts.contains($0) }.forEach { part in - if selectedStyles == part.attributes?.stylesSet() { - if let index = internalSpans.firstIndex(of: part) { - internalSpans[index] = part.copy(to: part.to + typedChars) - selectedStyles.removeAll() - addedInFirstPart = true - } - } - } + let startParts = internalSpans.filter { + $0.closedRange.contains(startTypeIndex - 1) + } + let endParts = internalSpans.filter { + $0.closedRange.contains(startTypeIndex) + } + let commonParts = Set(startParts).intersection(Set(endParts)) - if !addedInFirstPart { - endParts.filter { !commonParts.contains($0) }.forEach { part in - processSpan( - part, typedChars: typedChars, - startTypeIndex: startTypeIndex, - selectedStyles: &selectedStyles, forward: true) - } - } + var addedInFirstPart: Bool = false - commonParts.forEach { part in - processSpan( - part, typedChars: typedChars, startTypeIndex: startTypeIndex, - selectedStyles: &selectedStyles) + startParts.filter { !commonParts.contains($0) }.forEach { part in + if selectedStyles == part.attributes?.stylesSet() { + if let index = internalSpans.firstIndex(of: part) { + internalSpans[index] = part.copy(to: part.to + typedChars) + selectedStyles.removeAll() + addedInFirstPart = true } + } + } + + if !addedInFirstPart { + endParts.filter { !commonParts.contains($0) }.forEach { part in + processSpan( + part, typedChars: typedChars, + startTypeIndex: startTypeIndex, + selectedStyles: &selectedStyles, forward: true) + } + } - internalSpans = mergeSameStyledSpans(internalSpans) - - guard - !internalSpans.contains(where: { - $0.closedRange.contains(startTypeIndex) - }) - else { return } - let toIndex = - typedChars > 1 ? (startTypeIndex + typedChars - 1) : startTypeIndex - let span = RichTextSpanInternal( - from: startTypeIndex, to: toIndex, - attributes: getRichAttributesFor(styles: Array(selectedStyles))) - internalSpans.append(span) + commonParts.forEach { part in + processSpan( + part, typedChars: typedChars, startTypeIndex: startTypeIndex, + selectedStyles: &selectedStyles) } - /** + internalSpans = mergeSameStyledSpans(internalSpans) + + guard + !internalSpans.contains(where: { + $0.closedRange.contains(startTypeIndex) + }) + else { return } + let toIndex = + typedChars > 1 ? (startTypeIndex + typedChars - 1) : startTypeIndex + let span = RichTextSpanInternal( + from: startTypeIndex, to: toIndex, + attributes: getRichAttributesFor(styles: Array(selectedStyles))) + internalSpans.append(span) + } + + /** This will handle the newly added character in editor - Parameters: - startTypeIndex: is of type Int @@ -369,62 +369,62 @@ extension RichEditorState { This will update the span according to requirement, like break, remove, merge or extend. */ - private func processSpan( - _ richTextSpan: RichTextSpanInternal, typedChars: Int, - startTypeIndex: Int, selectedStyles: inout Set, - forward: Bool = false - ) { - let newFromIndex = richTextSpan.from + typedChars - let newToIndex = richTextSpan.to + typedChars - - if let index = internalSpans.firstIndex(of: richTextSpan) { - if selectedStyles == richTextSpan.attributes?.stylesSet() { - internalSpans[index] = richTextSpan.copy(to: newToIndex) - selectedStyles.removeAll() - } else { - if forward { - internalSpans[index] = richTextSpan.copy( - from: newFromIndex, to: newToIndex) - } else { - divideSpanAndAddTextWithCurrentStyle( - span: richTextSpan, typedChars: typedChars, - startTypeIndex: startTypeIndex, with: &selectedStyles) - selectedStyles.removeAll() - } - } - } - } - - func divideSpanAndAddTextWithCurrentStyle( - span: RichTextSpanInternal, typedChars: Int, startTypeIndex: Int, - with styles: inout Set - ) { - guard let index = internalSpans.firstIndex(of: span) else { return } - let extendedSpan = span.copy(to: span.to + typedChars) - - let startIndex = startTypeIndex - let endIndex = startTypeIndex + typedChars - 1 - - var spansToAdd: [RichTextSpanInternal] = [] - spansToAdd.append( - RichTextSpanInternal( - from: startIndex, to: endIndex, - attributes: getRichAttributesFor(styles: Array(styles)))) - - if startTypeIndex == extendedSpan.from { - spansToAdd.append(extendedSpan.copy(from: endIndex)) - } else if endIndex == extendedSpan.to { - spansToAdd.append(extendedSpan.copy(to: startIndex - 1)) + private func processSpan( + _ richTextSpan: RichTextSpanInternal, typedChars: Int, + startTypeIndex: Int, selectedStyles: inout Set, + forward: Bool = false + ) { + let newFromIndex = richTextSpan.from + typedChars + let newToIndex = richTextSpan.to + typedChars + + if let index = internalSpans.firstIndex(of: richTextSpan) { + if selectedStyles == richTextSpan.attributes?.stylesSet() { + internalSpans[index] = richTextSpan.copy(to: newToIndex) + selectedStyles.removeAll() + } else { + if forward { + internalSpans[index] = richTextSpan.copy( + from: newFromIndex, to: newToIndex) } else { - spansToAdd.append(extendedSpan.copy(to: startIndex - 1)) - spansToAdd.append(extendedSpan.copy(from: endIndex + 1)) + divideSpanAndAddTextWithCurrentStyle( + span: richTextSpan, typedChars: typedChars, + startTypeIndex: startTypeIndex, with: &selectedStyles) + selectedStyles.removeAll() } - internalSpans.removeAll(where: { $0 == span }) - internalSpans.insert( - contentsOf: spansToAdd.sorted(by: { $0.from < $1.from }), at: index) - } - - /** + } + } + } + + func divideSpanAndAddTextWithCurrentStyle( + span: RichTextSpanInternal, typedChars: Int, startTypeIndex: Int, + with styles: inout Set + ) { + guard let index = internalSpans.firstIndex(of: span) else { return } + let extendedSpan = span.copy(to: span.to + typedChars) + + let startIndex = startTypeIndex + let endIndex = startTypeIndex + typedChars - 1 + + var spansToAdd: [RichTextSpanInternal] = [] + spansToAdd.append( + RichTextSpanInternal( + from: startIndex, to: endIndex, + attributes: getRichAttributesFor(styles: Array(styles)))) + + if startTypeIndex == extendedSpan.from { + spansToAdd.append(extendedSpan.copy(from: endIndex)) + } else if endIndex == extendedSpan.to { + spansToAdd.append(extendedSpan.copy(to: startIndex - 1)) + } else { + spansToAdd.append(extendedSpan.copy(to: startIndex - 1)) + spansToAdd.append(extendedSpan.copy(from: endIndex + 1)) + } + internalSpans.removeAll(where: { $0 == span }) + internalSpans.insert( + contentsOf: spansToAdd.sorted(by: { $0.from < $1.from }), at: index) + } + + /** This will handle the newly added character in editor - Parameters: - startTypeIndex: is of type Int @@ -432,83 +432,83 @@ extension RichEditorState { This will move the span according to it's position if it is after the typed character then it will move forward by number or typed character which is step. */ - private func moveSpansForward(startTypeIndex: Int, by step: Int) { - let filteredSpans = internalSpans.filter { $0.from >= startTypeIndex } - - filteredSpans.forEach { part in - if let index = internalSpans.firstIndex(of: part) { - internalSpans[index] = part.copy( - from: part.from + step, to: part.to + step) - } - } + private func moveSpansForward(startTypeIndex: Int, by step: Int) { + let filteredSpans = internalSpans.filter { $0.from >= startTypeIndex } + + filteredSpans.forEach { part in + if let index = internalSpans.firstIndex(of: part) { + internalSpans[index] = part.copy( + from: part.from + step, to: part.to + step) + } } + } } //MARK: - Remove Character extension RichEditorState { - /** + /** This will handle the removing character in editor and from relative span - Parameters: - newText: is of type NsMutableAttributedString This will generate, break and remove the span according to requirement to avoid duplication and untracked span. */ - private func handleRemovingCharacters(_ newText: NSAttributedString) { - guard !newText.string.isEmpty else { - internalSpans.removeAll() - activeStyles.removeAll() - return - } + private func handleRemovingCharacters(_ newText: NSAttributedString) { + guard !newText.string.isEmpty else { + internalSpans.removeAll() + activeStyles.removeAll() + return + } + + let removedCharsCount = rawText.utf16Length - newText.string.utf16Length + let startRemoveIndex = selectedRange.location + let endRemoveIndex = selectedRange.location + removedCharsCount - 1 + let removeRange = startRemoveIndex...endRemoveIndex + let start = rawText.utf16.index( + rawText.startIndex, offsetBy: startRemoveIndex) + let end = rawText.utf16.index( + rawText.startIndex, offsetBy: endRemoveIndex) + + if startRemoveIndex != endRemoveIndex, + let newLineIndex = String(rawText[start...end]).map({ $0 }) + .lastIndex(of: "\n"), newLineIndex >= 0 + { + handleRemoveHeaderStyle( + newText: newText.string, at: removeRange.nsRange, + newLineIndex: newLineIndex) + } - let removedCharsCount = rawText.utf16Length - newText.string.utf16Length - let startRemoveIndex = selectedRange.location - let endRemoveIndex = selectedRange.location + removedCharsCount - 1 - let removeRange = startRemoveIndex...endRemoveIndex - let start = rawText.utf16.index( - rawText.startIndex, offsetBy: startRemoveIndex) - let end = rawText.utf16.index( - rawText.startIndex, offsetBy: endRemoveIndex) - - if startRemoveIndex != endRemoveIndex, - let newLineIndex = String(rawText[start...end]).map({ $0 }) - .lastIndex(of: "\n"), newLineIndex >= 0 - { - handleRemoveHeaderStyle( - newText: newText.string, at: removeRange.nsRange, - newLineIndex: newLineIndex) - } + let partsCopy = internalSpans + + let lowerBound = removeRange.lowerBound //- (selectedRange.length < removedCharsCount ? 1 : 0) - let partsCopy = internalSpans - - let lowerBound = removeRange.lowerBound //- (selectedRange.length < removedCharsCount ? 1 : 0) - - for part in partsCopy { - if let index = internalSpans.firstIndex(of: part) { - if removeRange.upperBound < part.from { - internalSpans[index] = part.copy( - from: part.from - (removedCharsCount), - to: part.to - (removedCharsCount)) - } else if lowerBound <= part.from - && removeRange.upperBound >= part.to - { - internalSpans.removeAll(where: { $0 == part }) - } else if lowerBound <= part.from { - internalSpans[index] = part.copy( - from: max(0, lowerBound), - to: min( - newText.string.utf16Length, - part.to - removedCharsCount)) - } else if removeRange.upperBound <= part.to { - internalSpans[index] = part.copy( - to: part.to - removedCharsCount) - } else if lowerBound < part.to { - internalSpans[index] = part.copy(to: lowerBound) - } - } + for part in partsCopy { + if let index = internalSpans.firstIndex(of: part) { + if removeRange.upperBound < part.from { + internalSpans[index] = part.copy( + from: part.from - (removedCharsCount), + to: part.to - (removedCharsCount)) + } else if lowerBound <= part.from + && removeRange.upperBound >= part.to + { + internalSpans.removeAll(where: { $0 == part }) + } else if lowerBound <= part.from { + internalSpans[index] = part.copy( + from: max(0, lowerBound), + to: min( + newText.string.utf16Length, + part.to - removedCharsCount)) + } else if removeRange.upperBound <= part.to { + internalSpans[index] = part.copy( + to: part.to - removedCharsCount) + } else if lowerBound < part.to { + internalSpans[index] = part.copy(to: lowerBound) } + } } + } - /** + /** This will handle the newly added character in editor - Parameters: - startTypeIndex: is of type Int @@ -516,384 +516,384 @@ extension RichEditorState { This will move the span according to it's position if it is after the typed character then it will move forward by number or typed character which is step. */ - private func moveSpansBackward(endTypeIndex: Int, by step: Int) { - let filteredSpans = internalSpans.filter { $0.to > endTypeIndex } - - filteredSpans.forEach { part in - if let index = internalSpans.firstIndex(of: part) { - internalSpans[index] = part.copy( - from: part.from - step, to: part.to - step) - } - } + private func moveSpansBackward(endTypeIndex: Int, by step: Int) { + let filteredSpans = internalSpans.filter { $0.to > endTypeIndex } + + filteredSpans.forEach { part in + if let index = internalSpans.firstIndex(of: part) { + internalSpans[index] = part.copy( + from: part.from - step, to: part.to - step) + } } + } } //MARK: - Header style's related methods extension RichEditorState { - //MARK: - Remove Header style - /** + //MARK: - Remove Header style + /** This will handle the adding header style in editor and to relative span - Parameters: - style: is of type RichTextSpanStyle */ - private func handleAddOrRemoveStyleToLine( - in range: NSRange, style: RichTextSpanStyle, byAdding: Bool = true - ) { - guard !rawText.isEmpty else { return } - - let range = - style.isList - ? getListRangeFor(range, in: rawText) - : rawText.getHeaderRangeFor(range) - processSpansFor(new: style, in: range, addStyle: byAdding) - } - - /** + private func handleAddOrRemoveStyleToLine( + in range: NSRange, style: RichTextSpanStyle, byAdding: Bool = true + ) { + guard !rawText.isEmpty else { return } + + let range = rawText.getHeaderRangeFor(range) + // style.isList + // ? getListRangeFor(range, in: rawText) + // : rawText.getHeaderRangeFor(range) + processSpansFor(new: style, in: range, addStyle: byAdding) + } + + /** This will remove header style form selected range of text - Parameters: - newText: it's NSMutableAttributedString - range: is the NSRange - newLineIndex: is string index of new line where is it located */ - private func handleRemoveHeaderStyle( - newText: String? = nil, at range: NSRange, newLineIndex: Int - ) { - let text = newText ?? rawText - let startIndex = max(0, text.map({ $0 }).index(before: newLineIndex)) + private func handleRemoveHeaderStyle( + newText: String? = nil, at range: NSRange, newLineIndex: Int + ) { + let text = newText ?? rawText + let startIndex = max(0, text.map({ $0 }).index(before: newLineIndex)) - let endIndex = text.map({ $0 }).index(after: newLineIndex) + let endIndex = text.map({ $0 }).index(after: newLineIndex) - let selectedParts = internalSpans.filter({ - ($0.from < endIndex && $0.to >= startIndex - && $0.attributes?.header != nil) - }) + let selectedParts = internalSpans.filter({ + ($0.from < endIndex && $0.to >= startIndex + && $0.attributes?.header != nil) + }) - internalSpans.removeAll(where: { selectedParts.contains($0) }) - } + internalSpans.removeAll(where: { selectedParts.contains($0) }) + } - //MARK: - Add Header style - /** + //MARK: - Add Header style + /** This will create span for selected text with provided style - Parameters: - styles: is of type [RichTextSpanStyle] - range: is of type NSRange */ - private func processSpansFor( - new style: RichTextSpanStyle, in range: NSRange, addStyle: Bool = true - ) { - guard !range.isCollapsed else { - return + private func processSpansFor( + new style: RichTextSpanStyle, in range: NSRange, addStyle: Bool = true + ) { + guard !range.isCollapsed else { + return + } + + var processedSpans: [RichTextSpanInternal] = [] + + let completeOverlap = getCompleteOverlappingSpans(for: range) + var partialOverlap = getPartialOverlappingSpans(for: range) + var sameSpans = getSameSpans(for: range) + + partialOverlap.removeAll(where: { completeOverlap.contains($0) }) + sameSpans.removeAll(where: { completeOverlap.contains($0) }) + + let partialOverlapSpan = processPartialOverlappingSpans( + partialOverlap, range: range, style: style, addStyle: addStyle) + let completeOverlapSpan = processCompleteOverlappingSpans( + completeOverlap, range: range, style: style, addStyle: addStyle) + let sameSpan = processSameSpans( + sameSpans, range: range, style: style, addStyle: addStyle) + + processedSpans.append(contentsOf: partialOverlapSpan) + processedSpans.append(contentsOf: completeOverlapSpan) + processedSpans.append(contentsOf: sameSpan) + + processedSpans = mergeSameStyledSpans(processedSpans) + + internalSpans.removeAll(where: { + $0.closedRange.overlaps(range.closedRange) + }) + internalSpans.append(contentsOf: processedSpans) + internalSpans = mergeSameStyledSpans(internalSpans) + internalSpans.sort(by: { $0.from < $1.from }) + } + + private func processCompleteOverlappingSpans( + _ spans: [RichTextSpanInternal], range: NSRange, + style: RichTextSpanStyle, addStyle: Bool = true + ) -> [RichTextSpanInternal] { + var processedSpans: [RichTextSpanInternal] = [] + + for span in spans { + if span.closedRange.isInRange(range.closedRange) { + processedSpans.append( + span.copy( + attributes: span.attributes?.copy( + with: style, byAdding: addStyle))) + } else { + if span.from < range.lowerBound { + let leftPart = span.copy(to: range.lowerBound - 1) + processedSpans.append(leftPart) } - var processedSpans: [RichTextSpanInternal] = [] - - let completeOverlap = getCompleteOverlappingSpans(for: range) - var partialOverlap = getPartialOverlappingSpans(for: range) - var sameSpans = getSameSpans(for: range) - - partialOverlap.removeAll(where: { completeOverlap.contains($0) }) - sameSpans.removeAll(where: { completeOverlap.contains($0) }) - - let partialOverlapSpan = processPartialOverlappingSpans( - partialOverlap, range: range, style: style, addStyle: addStyle) - let completeOverlapSpan = processCompleteOverlappingSpans( - completeOverlap, range: range, style: style, addStyle: addStyle) - let sameSpan = processSameSpans( - sameSpans, range: range, style: style, addStyle: addStyle) - - processedSpans.append(contentsOf: partialOverlapSpan) - processedSpans.append(contentsOf: completeOverlapSpan) - processedSpans.append(contentsOf: sameSpan) - - processedSpans = mergeSameStyledSpans(processedSpans) - - internalSpans.removeAll(where: { - $0.closedRange.overlaps(range.closedRange) - }) - internalSpans.append(contentsOf: processedSpans) - internalSpans = mergeSameStyledSpans(internalSpans) - internalSpans.sort(by: { $0.from < $1.from }) - } - - private func processCompleteOverlappingSpans( - _ spans: [RichTextSpanInternal], range: NSRange, - style: RichTextSpanStyle, addStyle: Bool = true - ) -> [RichTextSpanInternal] { - var processedSpans: [RichTextSpanInternal] = [] - - for span in spans { - if span.closedRange.isInRange(range.closedRange) { - processedSpans.append( - span.copy( - attributes: span.attributes?.copy( - with: style, byAdding: addStyle))) - } else { - if span.from < range.lowerBound { - let leftPart = span.copy(to: range.lowerBound - 1) - processedSpans.append(leftPart) - } - - if span.from <= (range.lowerBound) - && span.to >= (range.upperBound - 1) - { - let centerPart = span.copy( - from: range.lowerBound, to: range.upperBound - 1, - attributes: span.attributes?.copy( - with: style, byAdding: addStyle)) - processedSpans.append(centerPart) - } - - if span.to > (range.upperBound - 1) { - let rightPart = span.copy(from: range.upperBound) - processedSpans.append(rightPart) - } - } + if span.from <= (range.lowerBound) + && span.to >= (range.upperBound - 1) + { + let centerPart = span.copy( + from: range.lowerBound, to: range.upperBound - 1, + attributes: span.attributes?.copy( + with: style, byAdding: addStyle)) + processedSpans.append(centerPart) } - processedSpans = mergeSameStyledSpans(processedSpans) - - return processedSpans - } - - private func processPartialOverlappingSpans( - _ spans: [RichTextSpanInternal], range: NSRange, - style: RichTextSpanStyle, addStyle: Bool = true - ) -> [RichTextSpanInternal] { - var processedSpans: [RichTextSpanInternal] = [] - - for span in spans { - if span.from < range.location { - let leftPart = span.copy(to: range.lowerBound - 1) - let rightPart = span.copy( - from: range.lowerBound, - attributes: span.attributes?.copy( - with: style, byAdding: addStyle)) - processedSpans.append(leftPart) - processedSpans.append(rightPart) - } else { - let leftPart = span.copy( - to: min(span.to, range.upperBound), - attributes: span.attributes?.copy( - with: style, byAdding: addStyle)) - let rightPart = span.copy(from: range.location) - processedSpans.append(leftPart) - processedSpans.append(rightPart) - } + if span.to > (range.upperBound - 1) { + let rightPart = span.copy(from: range.upperBound) + processedSpans.append(rightPart) } - - processedSpans = mergeSameStyledSpans(processedSpans) - return processedSpans + } + } + + processedSpans = mergeSameStyledSpans(processedSpans) + + return processedSpans + } + + private func processPartialOverlappingSpans( + _ spans: [RichTextSpanInternal], range: NSRange, + style: RichTextSpanStyle, addStyle: Bool = true + ) -> [RichTextSpanInternal] { + var processedSpans: [RichTextSpanInternal] = [] + + for span in spans { + if span.from < range.location { + let leftPart = span.copy(to: range.lowerBound - 1) + let rightPart = span.copy( + from: range.lowerBound, + attributes: span.attributes?.copy( + with: style, byAdding: addStyle)) + processedSpans.append(leftPart) + processedSpans.append(rightPart) + } else { + let leftPart = span.copy( + to: min(span.to, range.upperBound), + attributes: span.attributes?.copy( + with: style, byAdding: addStyle)) + let rightPart = span.copy(from: range.location) + processedSpans.append(leftPart) + processedSpans.append(rightPart) + } + } + + processedSpans = mergeSameStyledSpans(processedSpans) + return processedSpans + } + + private func processSameSpans( + _ spans: [RichTextSpanInternal], range: NSRange, + style: RichTextSpanStyle, addStyle: Bool = true + ) -> [RichTextSpanInternal] { + var processedSpans: [RichTextSpanInternal] = [] + + processedSpans = spans.map({ + $0.copy( + attributes: $0.attributes?.copy(with: style, byAdding: addStyle) + ) + }) + + processedSpans = mergeSameStyledSpans(processedSpans) + return processedSpans + } + + // merge adjacent spans with same style + private func mergeSameStyledSpans(_ spans: [RichTextSpanInternal]) + -> [RichTextSpanInternal] + { + guard !spans.isEmpty else { return [] } + var mergedSpans: [RichTextSpanInternal] = [] + var previousSpan: RichTextSpanInternal? + + for span in spans.sorted(by: { $0.from < $1.from }) { + if let current = previousSpan { + if span.attributes?.stylesSet() + == current.attributes?.stylesSet() + { + // Merge overlapping spans + previousSpan = current.copy(to: max(current.to, span.to)) + } else { + // Add merged span and start a new span + mergedSpans.append(current) + previousSpan = span + } + } else { + previousSpan = span + } } - private func processSameSpans( - _ spans: [RichTextSpanInternal], range: NSRange, - style: RichTextSpanStyle, addStyle: Bool = true - ) -> [RichTextSpanInternal] { - var processedSpans: [RichTextSpanInternal] = [] - - processedSpans = spans.map({ - $0.copy( - attributes: $0.attributes?.copy(with: style, byAdding: addStyle) - ) - }) - - processedSpans = mergeSameStyledSpans(processedSpans) - return processedSpans + // Append the last current span + if let lastSpan = previousSpan { + mergedSpans.append(lastSpan) } - // merge adjacent spans with same style - private func mergeSameStyledSpans(_ spans: [RichTextSpanInternal]) - -> [RichTextSpanInternal] - { - guard !spans.isEmpty else { return [] } - var mergedSpans: [RichTextSpanInternal] = [] - var previousSpan: RichTextSpanInternal? - - for span in spans.sorted(by: { $0.from < $1.from }) { - if let current = previousSpan { - if span.attributes?.stylesSet() - == current.attributes?.stylesSet() - { - // Merge overlapping spans - previousSpan = current.copy(to: max(current.to, span.to)) - } else { - // Add merged span and start a new span - mergedSpans.append(current) - previousSpan = span - } - } else { - previousSpan = span - } - } - - // Append the last current span - if let lastSpan = previousSpan { - mergedSpans.append(lastSpan) - } - - return mergedSpans.sorted(by: { $0.from < $1.from }) - } + return mergedSpans.sorted(by: { $0.from < $1.from }) + } } //MARK: - Add Bullet list extension RichEditorState { - private func getListRangeFor(_ range: NSRange, in text: String) -> NSRange { - guard !text.isEmpty else { return range } - let lineRange = currentLine.lineRange + private func getListRangeFor(_ range: NSRange, in text: String) -> NSRange { + guard !text.isEmpty else { return range } + let lineRange = currentLine.lineRange - guard !range.isCollapsed else { return lineRange } + guard !range.isCollapsed else { return lineRange } - let fromIndex = range.lowerBound - let toIndex = range.isCollapsed ? fromIndex : range.upperBound + let fromIndex = range.lowerBound + let toIndex = range.isCollapsed ? fromIndex : range.upperBound - let newLineStartIndex = - text.utf16.prefix(fromIndex).map({ $0 }).lastIndex( - of: "\n".utf16.last) ?? 0 - let newLineEndIndex = text.utf16.suffix( - from: text.utf16.index(text.utf16.startIndex, offsetBy: toIndex - 1) - ).map({ $0 }).firstIndex(of: "\n".utf16.last) + let newLineStartIndex = + text.utf16.prefix(fromIndex).map({ $0 }).lastIndex( + of: "\n".utf16.last) ?? 0 + let newLineEndIndex = text.utf16.suffix( + from: text.utf16.index(text.utf16.startIndex, offsetBy: toIndex - 1) + ).map({ $0 }).firstIndex(of: "\n".utf16.last) - ///Added +1 to start new line after \n otherwise it will create bullets for previous line as well - let startIndex = max(0, (newLineStartIndex + 1)) - var endIndex = (toIndex - 1) + (newLineEndIndex ?? 0) - - if newLineEndIndex == nil { - endIndex = (text.utf16Length) - } + ///Added +1 to start new line after \n otherwise it will create bullets for previous line as well + let startIndex = max(0, (newLineStartIndex + 1)) + var endIndex = (toIndex - 1) + (newLineEndIndex ?? 0) - let range = startIndex...endIndex - return range.nsRange + if newLineEndIndex == nil { + endIndex = (text.utf16Length) } + + let range = startIndex...endIndex + return range.nsRange + } } //MARK: - RichTextSpanInternal Helper extension RichEditorState { - /** + /** This will provide overlapping span for range - Parameters: - selectedRange: is of type NSRange */ - private func getOverlappingSpans(for selectedRange: NSRange) - -> [RichTextSpanInternal] - { - return internalSpans.filter { - $0.closedRange.overlaps(selectedRange.closedRange) - } + private func getOverlappingSpans(for selectedRange: NSRange) + -> [RichTextSpanInternal] + { + return internalSpans.filter { + $0.closedRange.overlaps(selectedRange.closedRange) } + } - /** + /** This will provide partial overlapping span for range - Parameters: - selectedRange: selectedRange is of type NSRange */ - func getPartialOverlappingSpans(for selectedRange: NSRange) - -> [RichTextSpanInternal] - { - return getOverlappingSpans(for: selectedRange).filter({ - $0.closedRange.isPartialOverlap(selectedRange.closedRange) - }) - } - - /** + func getPartialOverlappingSpans(for selectedRange: NSRange) + -> [RichTextSpanInternal] + { + return getOverlappingSpans(for: selectedRange).filter({ + $0.closedRange.isPartialOverlap(selectedRange.closedRange) + }) + } + + /** This will provide complete overlapping span for range - Parameters: - selectedRange: selectedRange is of type NSRange */ - func getCompleteOverlappingSpans(for selectedRange: NSRange) - -> [RichTextSpanInternal] - { - return getOverlappingSpans(for: selectedRange).filter({ - $0.closedRange.isInRange(selectedRange.closedRange) - || selectedRange.closedRange.isInRange($0.closedRange) - }) - } - - /** + func getCompleteOverlappingSpans(for selectedRange: NSRange) + -> [RichTextSpanInternal] + { + return getOverlappingSpans(for: selectedRange).filter({ + $0.closedRange.isInRange(selectedRange.closedRange) + || selectedRange.closedRange.isInRange($0.closedRange) + }) + } + + /** This will provide same span for range - Parameters: - selectedRange: selectedRange is of type NSRange */ - func getSameSpans(for selectedRange: NSRange) -> [RichTextSpanInternal] { - return getOverlappingSpans(for: selectedRange).filter({ - $0.closedRange.isSameAs(selectedRange.closedRange) - }) - } + func getSameSpans(for selectedRange: NSRange) -> [RichTextSpanInternal] { + return getOverlappingSpans(for: selectedRange).filter({ + $0.closedRange.isSameAs(selectedRange.closedRange) + }) + } } //MARK: - Helper Methods extension RichEditorState { - /** + /** This will reset the editor. It will remove all the text form the editor. */ - public func reset() { - internalSpans.removeAll() - rawText = "" - attributedString = NSMutableAttributedString(string: "") - } + public func reset() { + internalSpans.removeAll() + rawText = "" + attributedString = NSMutableAttributedString(string: "") + } - /** + /** This will provide Set of RichTextSpanStyle applied on given index - Parameters: - index: index or location of text */ - private func getRichSpanStyleByTextIndex(_ index: Int) -> Set< - RichTextSpanStyle - > { - let styles = Set( - internalSpans.filter { index >= $0.from && index <= $0.to }.map { - $0.attributes?.styles() ?? [] - }.flatMap({ $0 })) - return styles - } - - /** + private func getRichSpanStyleByTextIndex(_ index: Int) -> Set< + RichTextSpanStyle + > { + let styles = Set( + internalSpans.filter { index >= $0.from && index <= $0.to }.map { + $0.attributes?.styles() ?? [] + }.flatMap({ $0 })) + return styles + } + + /** This will provide Array of RichTextSpanStyle applied on given range - Parameters: - range: range of text which is of type NSRange */ - private func getRichSpanStyleListByTextRange(_ range: NSRange) - -> [RichTextSpanStyle] - { - return internalSpans.filter({ - range.closedRange.overlaps($0.closedRange) - }).map { $0.attributes?.styles() ?? [] }.flatMap({ $0 }) - } + private func getRichSpanStyleListByTextRange(_ range: NSRange) + -> [RichTextSpanStyle] + { + return internalSpans.filter({ + range.closedRange.overlaps($0.closedRange) + }).map { $0.attributes?.styles() ?? [] }.flatMap({ $0 }) + } } extension RichEditorState { - func setInternalStyles(style: RichTextSpanStyle, add: Bool = true) { - switch style { - case .bold, .italic, .underline, .strikethrough: - if let style = style.richTextStyle { - setStyle(style, to: add) - } - case .h1, .h2, .h3, .h4, .h5, .h6, .default: - actionPublisher.send(.setHeaderStyle(style)) - case .bullet(_): - return - case .size(let size): - if let size, fontSize != CGFloat(size) { - self.fontSize = CGFloat(size) - } - case .font(let fontName): - if let fontName { - self.fontName = fontName - } - case .color(let color): - if let color { - setColor(.foreground, to: .init(color)) - } - case .background(let color): - if let color { - setColor(.background, to: .init(color)) - } - case .align(let alignment): - if let alignment, alignment != self.textAlignment { - actionPublisher.send(.setAlignment(alignment)) - } - case .link(let link): - actionPublisher.send(.setLink(link)) - } - } + func setInternalStyles(style: RichTextSpanStyle, add: Bool = true) { + switch style { + case .bold, .italic, .underline, .strikethrough: + if let style = style.richTextStyle { + setStyle(style, to: add) + } + case .h1, .h2, .h3, .h4, .h5, .h6, .default: + actionPublisher.send(.setHeaderStyle(style)) + // case .bullet(_): + // return + case .size(let size): + if let size, fontSize != CGFloat(size) { + self.fontSize = CGFloat(size) + } + case .font(let fontName): + if let fontName { + self.fontName = fontName + } + case .color(let color): + if let color { + setColor(.foreground, to: .init(color)) + } + case .background(let color): + if let color { + setColor(.background, to: .init(color)) + } + case .align(let alignment): + if let alignment, alignment != self.textAlignment { + actionPublisher.send(.setAlignment(alignment)) + } + case .link(let link): + actionPublisher.send(.setLink(link)) + } + } } diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift index fb44093..0d8a472 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift @@ -9,440 +9,439 @@ import SwiftUI public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable { - public static var allCases: [RichTextSpanStyle] = [ - .default, - .bold, - .italic, - .underline, - .strikethrough, - .h1, - .h2, - .h3, - .h4, - .h5, - .h6, - .bullet(), - .size(), - .font(), - .color(), - .background(), - .align(), - ] - - public func hash(into hasher: inout Hasher) { - hasher.combine(key) - - if case .bullet(let indent) = self { - hasher.combine(indent) - } - if case .align(let alignment) = self { - hasher.combine(alignment) - } + public static var allCases: [RichTextSpanStyle] = [ + .default, + .bold, + .italic, + .underline, + .strikethrough, + .h1, + .h2, + .h3, + .h4, + .h5, + .h6, + // .bullet(), + .size(), + .font(), + .color(), + .background(), + .align(), + ] + + public func hash(into hasher: inout Hasher) { + hasher.combine(key) + + // if case .bullet(let indent) = self { + // hasher.combine(indent) + // } + if case .align(let alignment) = self { + hasher.combine(alignment) } - - case `default` - case bold - case italic - case underline - case strikethrough - case h1 - case h2 - case h3 - case h4 - case h5 - case h6 - case bullet(_ indent: Int? = nil) - // case ordered(_ indent: Int? = nil) - case size(Int? = nil) - case font(String? = nil) - case color(Color? = nil) - case background(Color? = nil) - case align(RichTextAlignment? = nil) - case link(String? = nil) - - var key: String { - switch self { - case .default: - return "default" - case .bold: - return "bold" - case .italic: - return "italic" - case .underline: - return "underline" - case .strikethrough: - return "strikethrough" - case .h1: - return "h1" - case .h2: - return "h2" - case .h3: - return "h3" - case .h4: - return "h4" - case .h5: - return "h5" - case .h6: - return "h6" - case .bullet: - return "bullet" - // case .ordered: - // return "ordered" - case .size: - return "size" - case .font: - return "font" - case .color: - return "color" - case .background: - return "background" - case .align(let alignment): - return "align" + "\(alignment?.rawValue ?? "")" - case .link(let link): - return "link" + (link ?? "") - } + } + + case `default` + case bold + case italic + case underline + case strikethrough + case h1 + case h2 + case h3 + case h4 + case h5 + case h6 + // case bullet(_ indent: Int? = nil) + // case ordered(_ indent: Int? = nil) + case size(Int? = nil) + case font(String? = nil) + case color(Color? = nil) + case background(Color? = nil) + case align(RichTextAlignment? = nil) + case link(String? = nil) + + var key: String { + switch self { + case .default: + return "default" + case .bold: + return "bold" + case .italic: + return "italic" + case .underline: + return "underline" + case .strikethrough: + return "strikethrough" + case .h1: + return "h1" + case .h2: + return "h2" + case .h3: + return "h3" + case .h4: + return "h4" + case .h5: + return "h5" + case .h6: + return "h6" + // case .bullet: + // return "bullet" + // case .ordered: + // return "ordered" + case .size: + return "size" + case .font: + return "font" + case .color: + return "color" + case .background: + return "background" + case .align(let alignment): + return "align" + "\(alignment?.rawValue ?? "")" + case .link(let link): + return "link" + (link ?? "") } - - func defaultAttributeValue(font: FontRepresentable? = nil) -> Any { - let font = font ?? .systemFont(ofSize: .standardRichTextFontSize) - switch self { - case .underline: - return NSUnderlineStyle.single.rawValue - case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6: - return getFontWithUpdating(font: font) - case .bullet(let indent): - return getListStyleAttributeValue( - listType ?? .bullet(), indent: indent) - case .strikethrough: - return NSUnderlineStyle.single.rawValue - case .size(let size): - if let fontSize = size { - return getFontWithUpdating(font: font) - .withSize(CGFloat(fontSize)) - } else { - return getFontWithUpdating(font: font) - } - case .font(let name): - if let name { - return FontRepresentable( - name: name, - size: .standardRichTextFontSize - ) ?? font - } else { - #if os(watchOS) - return CGFloat.standardRichTextFontSize - #else - return RichTextView.Theme.standard.font - #endif - } - case .color: - #if os(watchOS) - return Color.primary - #else - return RichTextView.Theme.standard.fontColor - #endif - case .background: - return ColorRepresentable.white - case .align: - return RichTextAlignment.left.nativeAlignment - case .link(let link): - return link ?? "" - } + } + + func defaultAttributeValue(font: FontRepresentable? = nil) -> Any { + let font = font ?? .systemFont(ofSize: .standardRichTextFontSize) + switch self { + case .underline: + return NSUnderlineStyle.single.rawValue + case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6: + return getFontWithUpdating(font: font) + // case .bullet(let indent): + // return getListStyleAttributeValue( + // listType ?? .bullet(), indent: indent) + case .strikethrough: + return NSUnderlineStyle.single.rawValue + case .size(let size): + if let fontSize = size { + return getFontWithUpdating(font: font) + .withSize(CGFloat(fontSize)) + } else { + return getFontWithUpdating(font: font) + } + case .font(let name): + if let name { + return FontRepresentable( + name: name, + size: .standardRichTextFontSize + ) ?? font + } else { + #if os(watchOS) + return CGFloat.standardRichTextFontSize + #else + return RichTextView.Theme.standard.font + #endif + } + case .color: + #if os(watchOS) + return Color.primary + #else + return RichTextView.Theme.standard.fontColor + #endif + case .background: + return ColorRepresentable.white + case .align: + return RichTextAlignment.left.nativeAlignment + case .link(let link): + return link ?? "" } - - var attributedStringKey: NSAttributedString.Key { - switch self { - case .underline: - return .underlineStyle - case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6, .size, - .font: - return .font - case .bullet, .align: - return .paragraphStyle - case .strikethrough: - return .strikethroughStyle - case .color: - return .foregroundColor - case .background: - return .backgroundColor - case .link: - return .link - } + } + + var attributedStringKey: NSAttributedString.Key { + switch self { + case .underline: + return .underlineStyle + case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6, .size, + .font: + return .font + case .align: //, .bullet + return .paragraphStyle + case .strikethrough: + return .strikethroughStyle + case .color: + return .foregroundColor + case .background: + return .backgroundColor + case .link: + return .link } - - public static func == (lhs: RichTextSpanStyle, rhs: RichTextSpanStyle) - -> Bool - { - return lhs.key == rhs.key + } + + public static func == (lhs: RichTextSpanStyle, rhs: RichTextSpanStyle) + -> Bool + { + return lhs.key == rhs.key + } + + /// The standard icon to use for the trait. + var icon: Image { + switch self { + case .bold: .richTextStyleBold + case .italic: .richTextStyleItalic + case .strikethrough: .richTextStyleStrikethrough + case .underline: .richTextStyleUnderline + default: .richTextPrint } - - /// The standard icon to use for the trait. - var icon: Image { - switch self { - case .bold: .richTextStyleBold - case .italic: .richTextStyleItalic - case .strikethrough: .richTextStyleStrikethrough - case .underline: .richTextStyleUnderline - default: .richTextPrint - } + } + + /// The localized style title key. + var titleKey: RTEL10n { + switch self { + case .bold: .styleBold + case .italic: .styleItalic + case .underline: .styleUnderlined + case .strikethrough: .styleStrikethrough + default: .done } - - /// The localized style title key. - var titleKey: RTEL10n { - switch self { - case .bold: .styleBold - case .italic: .styleItalic - case .underline: .styleUnderlined - case .strikethrough: .styleStrikethrough - default: .done - } + } + + var richTextStyle: RichTextStyle? { + switch self { + case .bold: .bold + case .italic: .italic + case .underline: .underline + case .strikethrough: .strikethrough + default: nil } - - var richTextStyle: RichTextStyle? { - switch self { - case .bold: .bold - case .italic: .italic - case .underline: .underline - case .strikethrough: .strikethrough - default: nil - } + } + + // var listType: ListType? { + // switch self { + // case .bullet(let indent): + // return .bullet(indent) + // default: + // return nil + // } + // } + + var headerType: HeaderType { + switch self { + case .h1: + return .h1 + case .h2: + return .h2 + case .h3: + return .h3 + case .h4: + return .h4 + case .h5: + return .h5 + case .h6: + return .h6 + default: + return .default } - - var listType: ListType? { - switch self { - case .bullet(let indent): - return .bullet(indent) - default: - return nil - } + } + + var isHeaderStyle: Bool { + switch self { + case .h1, .h2, .h3, .h4, .h5, .h6: + return true + default: + return false } - - var headerType: HeaderType { - switch self { - case .h1: - return .h1 - case .h2: - return .h2 - case .h3: - return .h3 - case .h4: - return .h4 - case .h5: - return .h5 - case .h6: - return .h6 - default: - return .default - } + } + + var isAlignmentStyle: Bool { + switch self { + case .align: + return true + default: + return false } - - var isHeaderStyle: Bool { - switch self { - case .h1, .h2, .h3, .h4, .h5, .h6: - return true - default: - return false - } + } + + // var isList: Bool { + // switch self { + // case .bullet: + // return true + // default: + // return false + // } + // } + + var isDefault: Bool { + switch self { + case .default: + return true + case .align(let alignment): + return alignment == .left + default: + return false } - - var isAlignmentStyle: Bool { - switch self { - case .align: - return true - default: - return false - } + } + + func getFontWithUpdating(font: FontRepresentable) -> FontRepresentable { + switch self { + case .default: + return font + case .bold, .italic: + return font.addFontStyle(self) + case .underline, .strikethrough, .color, .background, .align, + .link: //, .bullet + return font + case .h1: + return font.updateFontSize(multiple: 1.5) + case .h2: + return font.updateFontSize(multiple: 1.4) + case .h3: + return font.updateFontSize(multiple: 1.3) + case .h4: + return font.updateFontSize(multiple: 1.2) + case .h5: + return font.updateFontSize(multiple: 1.1) + case .h6: + return font.updateFontSize(multiple: 1) + case .size(let size): + if let size { + return font.updateFontSize(size: CGFloat(size)) + } else { + return font + } + case .font(let name): + if let name { + return FontRepresentable(name: name, size: font.pointSize) + ?? font + } else { + return font + } } - - var isList: Bool { - switch self { - case .bullet: - return true - default: - return false - } + } + + var fontSizeMultiplier: CGFloat { + switch self { + case .h1: + return 1.5 + case .h2: + return 1.4 + case .h3: + return 1.3 + case .h4: + return 1.2 + case .h5: + return 1.1 + default: + return 1 } - - var isDefault: Bool { - switch self { - case .default: - return true - case .align(let alignment): - return alignment == .left - default: - return false - } - } - - func getFontWithUpdating(font: FontRepresentable) -> FontRepresentable { - switch self { - case .default: - return font - case .bold, .italic: - return font.addFontStyle(self) - case .underline, .bullet, .strikethrough, .color, .background, .align, - .link: - return font - case .h1: - return font.updateFontSize(multiple: 1.5) - case .h2: - return font.updateFontSize(multiple: 1.4) - case .h3: - return font.updateFontSize(multiple: 1.3) - case .h4: - return font.updateFontSize(multiple: 1.2) - case .h5: - return font.updateFontSize(multiple: 1.1) - case .h6: - return font.updateFontSize(multiple: 1) - case .size(let size): - if let size { - return font.updateFontSize(size: CGFloat(size)) - } else { - return font - } - case .font(let name): - if let name { - return FontRepresentable(name: name, size: font.pointSize) - ?? font - } else { - return font - } - } - } - - var fontSizeMultiplier: CGFloat { - switch self { - case .h1: - return 1.5 - case .h2: - return 1.4 - case .h3: - return 1.3 - case .h4: - return 1.2 - case .h5: - return 1.1 - default: - return 1 - } - } - - func getFontAfterRemovingStyle(font: FontRepresentable) -> FontRepresentable - { - switch self { - case .bold, .italic, .bullet: - return font.removeFontStyle(self) - case .underline, .strikethrough, .color, .background, .align, .link: - return font - case .default, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font: - return font.updateFontSize(size: .standardRichTextFontSize) - } - } - - func getListStyleAttributeValue(_ listType: ListType, indent: Int? = nil) - -> NSMutableParagraphStyle - { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = .left - let listItem = TextList( - markerFormat: listType.getMarkerFormat(), options: 0) - paragraphStyle.textLists = Array( - repeating: listItem, count: (indent ?? 0) + 1) - return paragraphStyle + } + + func getFontAfterRemovingStyle(font: FontRepresentable) -> FontRepresentable { + switch self { + case .bold, .italic: //, .bullet: + return font.removeFontStyle(self) + case .underline, .strikethrough, .color, .background, .align, .link: + return font + case .default, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font: + return font.updateFontSize(size: .standardRichTextFontSize) } + } + + // func getListStyleAttributeValue(_ listType: ListType, indent: Int? = nil) + // -> NSMutableParagraphStyle + // { + // let paragraphStyle = NSMutableParagraphStyle() + // paragraphStyle.alignment = .left + // let listItem = TextList( + // markerFormat: listType.getMarkerFormat(), options: 0) + // paragraphStyle.textLists = Array( + // repeating: listItem, count: (indent ?? 0) + 1) + // return paragraphStyle + // } } #if canImport(UIKit) - extension RichTextSpanStyle { - - /// The symbolic font traits for the style, if any. - public var symbolicTraits: UIFontDescriptor.SymbolicTraits? { - switch self { - case .bold: .traitBold - case .italic: .traitItalic - default: nil - } - } + extension RichTextSpanStyle { + + /// The symbolic font traits for the style, if any. + public var symbolicTraits: UIFontDescriptor.SymbolicTraits? { + switch self { + case .bold: .traitBold + case .italic: .traitItalic + default: nil + } } + } #endif -#if macOS - extension RichTextSpanStyle { - - /// The symbolic font traits for the trait, if any. - public var symbolicTraits: NSFontDescriptor.SymbolicTraits? { - switch self { - case .bold: .bold - case .italic: .italic - default: nil - } - } +#if os(macOS) + extension RichTextSpanStyle { + + /// The symbolic font traits for the trait, if any. + public var symbolicTraits: NSFontDescriptor.SymbolicTraits? { + switch self { + case .bold: .bold + case .italic: .italic + default: nil + } } + } #endif extension RichTextSpanStyle { - func getRichAttribute() -> RichAttributes? { - switch self { - case .default: - return nil - case .bold: - return RichAttributes(bold: true) - case .italic: - return RichAttributes(italic: true) - case .underline: - return RichAttributes(underline: true) - case .strikethrough: - return RichAttributes(strike: true) - case .bullet: - return RichAttributes(list: .bullet()) - case .h1: - return RichAttributes(header: .h1) - case .h2: - return RichAttributes(header: .h2) - case .h3: - return RichAttributes(header: .h3) - case .h4: - return RichAttributes(header: .h4) - case .h5: - return RichAttributes(header: .h5) - case .h6: - return RichAttributes(header: .h6) - case .size(let size): - return RichAttributes(size: size) - case .font(let font): - return RichAttributes(font: font) - case .color(let color): - return RichAttributes(color: color?.hexString) - case .background(let background): - return RichAttributes(background: background?.hexString) - case .align(let alignment): - return RichAttributes(align: alignment) - case .link(let link): - return RichAttributes(link: link) - } + func getRichAttribute() -> RichAttributes? { + switch self { + case .default: + return nil + case .bold: + return RichAttributes(bold: true) + case .italic: + return RichAttributes(italic: true) + case .underline: + return RichAttributes(underline: true) + case .strikethrough: + return RichAttributes(strike: true) + // case .bullet: + // return RichAttributes(list: .bullet()) + case .h1: + return RichAttributes(header: .h1) + case .h2: + return RichAttributes(header: .h2) + case .h3: + return RichAttributes(header: .h3) + case .h4: + return RichAttributes(header: .h4) + case .h5: + return RichAttributes(header: .h5) + case .h6: + return RichAttributes(header: .h6) + case .size(let size): + return RichAttributes(size: size) + case .font(let font): + return RichAttributes(font: font) + case .color(let color): + return RichAttributes(color: color?.hexString) + case .background(let background): + return RichAttributes(background: background?.hexString) + case .align(let alignment): + return RichAttributes(align: alignment) + case .link(let link): + return RichAttributes(link: link) } + } } extension Collection where Element == RichTextSpanStyle { - /** + /** Check if the collection contains a certain style. - Parameters: - style: The style to look for. */ - public func hasStyle(_ style: RichTextSpanStyle) -> Bool { - contains(style) - } - - /// Check if a certain style change should be applied. - public func shouldAddOrRemove( - _ style: RichTextSpanStyle, - _ newValue: Bool - ) -> Bool { - let shouldAdd = newValue && !hasStyle(style) - let shouldRemove = !newValue && hasStyle(style) - return shouldAdd || shouldRemove - } + public func hasStyle(_ style: RichTextSpanStyle) -> Bool { + contains(style) + } + + /// Check if a certain style change should be applied. + public func shouldAddOrRemove( + _ style: RichTextSpanStyle, + _ newValue: Bool + ) -> Bool { + let shouldAdd = newValue && !hasStyle(style) + let shouldRemove = !newValue && hasStyle(style) + return shouldAdd || shouldRemove + } } diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Config_AppKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Config_AppKit.swift index 12c07f8..746c4da 100644 --- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Config_AppKit.swift +++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Config_AppKit.swift @@ -5,34 +5,34 @@ // Created by Divyesh Vekariya on 21/10/24. // -#if macOS - import Foundation +#if os(macOS) + import Foundation - extension RichTextView { + extension RichTextView { - /** + /** This type can be used to configure a ``RichTextEditor``. */ - public struct Configuration { + public struct Configuration { - /// Create a custom configuration - /// - Parameters: - /// - isScrollingEnabled: Whether or not the editor should scroll, by default `true`. - /// - isContinuousSpellCheckingEnabled: Whether the editor spell-checks in realtime. Defaults to `true`. - public init( - isScrollingEnabled: Bool = true, - isContinuousSpellCheckingEnabled: Bool = true - ) { - self.isScrollingEnabled = isScrollingEnabled - self.isContinuousSpellCheckingEnabled = - isContinuousSpellCheckingEnabled - } + /// Create a custom configuration + /// - Parameters: + /// - isScrollingEnabled: Whether or not the editor should scroll, by default `true`. + /// - isContinuousSpellCheckingEnabled: Whether the editor spell-checks in realtime. Defaults to `true`. + public init( + isScrollingEnabled: Bool = true, + isContinuousSpellCheckingEnabled: Bool = true + ) { + self.isScrollingEnabled = isScrollingEnabled + self.isContinuousSpellCheckingEnabled = + isContinuousSpellCheckingEnabled + } - /// Whether or not the editor should scroll. - public var isScrollingEnabled: Bool + /// Whether or not the editor should scroll. + public var isScrollingEnabled: Bool - /// Whether the editor spell-checks in realtime. - public var isContinuousSpellCheckingEnabled: Bool - } + /// Whether the editor spell-checks in realtime. + public var isContinuousSpellCheckingEnabled: Bool } + } #endif diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextViewRepresentable.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextViewRepresentable.swift index 580b8e2..639f97e 100644 --- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextViewRepresentable.swift +++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextViewRepresentable.swift @@ -5,16 +5,16 @@ // Created by Divyesh Vekariya on 21/10/24. // -#if macOS - import AppKit +#if os(macOS) + import AppKit - /// This typealias bridges UIKit & AppKit native text views. - public typealias RichTextViewRepresentable = NSTextView + /// This typealias bridges UIKit & AppKit native text views. + public typealias RichTextViewRepresentable = NSTextView #endif #if os(iOS) || os(tvOS) || os(visionOS) - import UIKit + import UIKit - /// This typealias bridges UIKit & AppKit native text views. - public typealias RichTextViewRepresentable = UITextView + /// This typealias bridges UIKit & AppKit native text views. + public typealias RichTextViewRepresentable = UITextView #endif diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift index b12db04..4b5d8a1 100644 --- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift +++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift @@ -5,98 +5,98 @@ // Created by Divyesh Vekariya on 19/10/24. // -#if macOS - import AppKit - - /// This is a platform-agnostic rich text view that can be used - /// in both UIKit and AppKit. - /// - /// The view inherits `NSTextView` in AppKit and `UITextView` - /// in UIKit. It aims to make these views behave more alike and - /// make them implement ``RichTextViewComponent``, which is the - /// protocol that is used within this library. - /// - /// The view will apply a ``RichTextImageConfiguration/disabled`` - /// image config by default. You can change this by setting the - /// property manually or by using a ``RichTextDataFormat`` that - /// supports images. - open class RichTextView: NSTextView, RichTextViewComponent { - - // MARK: - Properties - - /// The configuration to use by the rich text view. - public var configuration: Configuration = .standard - - /// The theme for coloring and setting style to text view. - public var theme: Theme = .standard { - didSet { setup(theme) } - } - - /// The style to use when highlighting text in the view. - public var highlightingStyle: RichTextHighlightingStyle = .standard - - /// The image configuration to use by the rich text view. - // public var imageConfiguration: RichTextImageConfiguration = .disabled - - // MARK: - Overrides - - /// Paste the current pasteboard content into the view. - open override func paste(_ sender: Any?) { - // let pasteboard = NSPasteboard.general - // if let image = pasteboard.image { - // return pasteImage(image, at: selectedRange.location) - // } - super.paste(sender) - } - - /** +#if os(macOS) + import AppKit + + /// This is a platform-agnostic rich text view that can be used + /// in both UIKit and AppKit. + /// + /// The view inherits `NSTextView` in AppKit and `UITextView` + /// in UIKit. It aims to make these views behave more alike and + /// make them implement ``RichTextViewComponent``, which is the + /// protocol that is used within this library. + /// + /// The view will apply a ``RichTextImageConfiguration/disabled`` + /// image config by default. You can change this by setting the + /// property manually or by using a ``RichTextDataFormat`` that + /// supports images. + open class RichTextView: NSTextView, RichTextViewComponent { + + // MARK: - Properties + + /// The configuration to use by the rich text view. + public var configuration: Configuration = .standard + + /// The theme for coloring and setting style to text view. + public var theme: Theme = .standard { + didSet { setup(theme) } + } + + /// The style to use when highlighting text in the view. + public var highlightingStyle: RichTextHighlightingStyle = .standard + + /// The image configuration to use by the rich text view. + // public var imageConfiguration: RichTextImageConfiguration = .disabled + + // MARK: - Overrides + + /// Paste the current pasteboard content into the view. + open override func paste(_ sender: Any?) { + // let pasteboard = NSPasteboard.general + // if let image = pasteboard.image { + // return pasteImage(image, at: selectedRange.location) + // } + super.paste(sender) + } + + /** Try to perform a certain drag operation, which will get and paste images from the drag info into the text. */ - // open override func performDragOperation(_ draggingInfo: NSDraggingInfo) -> Bool { - // let pasteboard = draggingInfo.draggingPasteboard - // if let images = pasteboard.images, images.count > 0 { - // pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true) - // return true - // } - // // Handle fileURLs that contain known image types - // let images = pasteboard.pasteboardItems?.compactMap { - // if let str = $0.string(forType: NSPasteboard.PasteboardType.fileURL), - // let url = URL(string: str), let image = ImageRepresentable(contentsOf: url) { - // let fileExtension = url.pathExtension.lowercased() - // let imageExtensions = ["jpg", "jpeg", "png", "gif", "tiff", "bmp", "heic"] - // if imageExtensions.contains(fileExtension) { - // return image - // } - // } - // return nil - // } ?? [ImageRepresentable]() - // if images.count > 0 { - // pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true) - // return true - // } - // - // return super.performDragOperation(draggingInfo) - // } - - open override func scrollWheel(with event: NSEvent) { - - if configuration.isScrollingEnabled { - return super.scrollWheel(with: event) - } - - // 1st nextResponder is NSClipView - // 2nd nextResponder is NSScrollView - // 3rd nextResponder is NSResponder SwiftUIPlatformViewHost - self.nextResponder? - .nextResponder? - .nextResponder? - .scrollWheel(with: event) - } - - // MARK: - Setup - - /** + // open override func performDragOperation(_ draggingInfo: NSDraggingInfo) -> Bool { + // let pasteboard = draggingInfo.draggingPasteboard + // if let images = pasteboard.images, images.count > 0 { + // pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true) + // return true + // } + // // Handle fileURLs that contain known image types + // let images = pasteboard.pasteboardItems?.compactMap { + // if let str = $0.string(forType: NSPasteboard.PasteboardType.fileURL), + // let url = URL(string: str), let image = ImageRepresentable(contentsOf: url) { + // let fileExtension = url.pathExtension.lowercased() + // let imageExtensions = ["jpg", "jpeg", "png", "gif", "tiff", "bmp", "heic"] + // if imageExtensions.contains(fileExtension) { + // return image + // } + // } + // return nil + // } ?? [ImageRepresentable]() + // if images.count > 0 { + // pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true) + // return true + // } + // + // return super.performDragOperation(draggingInfo) + // } + + open override func scrollWheel(with event: NSEvent) { + + if configuration.isScrollingEnabled { + return super.scrollWheel(with: event) + } + + // 1st nextResponder is NSClipView + // 2nd nextResponder is NSScrollView + // 3rd nextResponder is NSResponder SwiftUIPlatformViewHost + self.nextResponder? + .nextResponder? + .nextResponder? + .scrollWheel(with: event) + } + + // MARK: - Setup + + /** Setup the rich text view with a rich text and a certain ``RichTextDataFormat``. @@ -104,46 +104,46 @@ - text: The text to edit with the text view. - format: The rich text format to edit. */ - open func setup( - with text: NSAttributedString, - format: RichTextDataFormat? - ) { - setupSharedBehavior(with: text, format) - allowsImageEditing = true - allowsUndo = true - layoutManager?.defaultAttachmentScaling = - NSImageScaling.scaleProportionallyDown - isContinuousSpellCheckingEnabled = - configuration.isContinuousSpellCheckingEnabled - setup(theme) - } - - public func setup(with richText: RichText) { - var tempSpans: [RichTextSpanInternal] = [] - var text = "" - richText.spans.forEach({ - let span = RichTextSpanInternal( - from: text.utf16Length, - to: (text.utf16Length + $0.insert.utf16Length - 1), - attributes: $0.attributes) - tempSpans.append(span) - text += $0.insert - }) - - let str = NSMutableAttributedString(string: text) - - tempSpans.forEach { span in - str.addAttributes( - span.attributes?.toAttributes(font: .standardRichTextFont) - ?? [:], range: span.spanRange) - } - - setup(with: str, format: .archivedData) - } - - // MARK: - Open Functionality - - /** + open func setup( + with text: NSAttributedString, + format: RichTextDataFormat? + ) { + setupSharedBehavior(with: text, format) + allowsImageEditing = true + allowsUndo = true + layoutManager?.defaultAttachmentScaling = + NSImageScaling.scaleProportionallyDown + isContinuousSpellCheckingEnabled = + configuration.isContinuousSpellCheckingEnabled + setup(theme) + } + + public func setup(with richText: RichText) { + var tempSpans: [RichTextSpanInternal] = [] + var text = "" + richText.spans.forEach({ + let span = RichTextSpanInternal( + from: text.utf16Length, + to: (text.utf16Length + $0.insert.utf16Length - 1), + attributes: $0.attributes) + tempSpans.append(span) + text += $0.insert + }) + + let str = NSMutableAttributedString(string: text) + + tempSpans.forEach { span in + str.addAttributes( + span.attributes?.toAttributes(font: .standardRichTextFont) + ?? [:], range: span.spanRange) + } + + setup(with: str, format: .archivedData) + } + + // MARK: - Open Functionality + + /** Alert a certain title and message. - Parameters: @@ -151,108 +151,106 @@ - message: The alert message. - buttonTitle: The alert button title. */ - open func alert(title: String, message: String, buttonTitle: String) { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.alertStyle = NSAlert.Style.warning - alert.addButton(withTitle: buttonTitle) - alert.runModal() - } - - /// Copy the current selection. - open func copySelection() { - let pasteboard = NSPasteboard.general - let range = safeRange(for: selectedRange) - let text = richText(at: range) - pasteboard.clearContents() - pasteboard.setString(text.string, forType: .string) - } - - /// Try to redo the latest undone change. - open func redoLatestChange() { - undoManager?.redo() - } - - /// Scroll to a certain range. - open func scroll(to range: NSRange) { - scrollRangeToVisible(range) - } - - /// Set the rich text in the text view. - open func setRichText(_ text: NSAttributedString) { - attributedString = text - } - - /// Undo the latest change. - open func undoLatestChange() { - undoManager?.undo() - } + open func alert(title: String, message: String, buttonTitle: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = NSAlert.Style.warning + alert.addButton(withTitle: buttonTitle) + alert.runModal() + } + + /// Copy the current selection. + open func copySelection() { + let pasteboard = NSPasteboard.general + let range = safeRange(for: selectedRange) + let text = richText(at: range) + pasteboard.clearContents() + pasteboard.setString(text.string, forType: .string) + } + + /// Try to redo the latest undone change. + open func redoLatestChange() { + undoManager?.redo() + } + + /// Scroll to a certain range. + open func scroll(to range: NSRange) { + scrollRangeToVisible(range) } - // MARK: - Public Extensions + /// Set the rich text in the text view. + open func setRichText(_ text: NSAttributedString) { + attributedString = text + } - extension RichTextView { + /// Undo the latest change. + open func undoLatestChange() { + undoManager?.undo() + } + } - /// The text view's layout manager, if any. - public var layoutManagerWrapper: NSLayoutManager? { - layoutManager - } + // MARK: - Public Extensions - /// The spacing between the text view edges and its text. - public var textContentInset: CGSize { - get { textContainerInset } - set { textContainerInset = newValue } - } + extension RichTextView { - /// The text view's text storage, if any. - public var textStorageWrapper: NSTextStorage? { - textStorage - } + /// The text view's layout manager, if any. + public var layoutManagerWrapper: NSLayoutManager? { + layoutManager } - // MARK: - RichTextProvider + /// The spacing between the text view edges and its text. + public var textContentInset: CGSize { + get { textContainerInset } + set { textContainerInset = newValue } + } - extension RichTextView { + /// The text view's text storage, if any. + public var textStorageWrapper: NSTextStorage? { + textStorage + } + } - /// Get the rich text that is managed by the view. - public var attributedString: NSAttributedString { - get { attributedString() } - set { textStorage?.setAttributedString(newValue) } - } + // MARK: - RichTextProvider + + extension RichTextView { + + /// Get the rich text that is managed by the view. + public var attributedString: NSAttributedString { + get { attributedString() } + set { textStorage?.setAttributedString(newValue) } + } - /// Whether or not the text view is the first responder. - public var isFirstResponder: Bool { - window?.firstResponder == self - } + /// Whether or not the text view is the first responder. + public var isFirstResponder: Bool { + window?.firstResponder == self } + } - // MARK: - RichTextWriter + // MARK: - RichTextWriter - extension RichTextView { + extension RichTextView { - // Get the rich text that is managed by the view. - public var mutableAttributedString: NSMutableAttributedString? { - textStorage - } + // Get the rich text that is managed by the view. + public var mutableAttributedString: NSMutableAttributedString? { + textStorage } + } - // MARK: - Additional Pasteboard Types + // MARK: - Additional Pasteboard Types - extension RichTextView { - public override var readablePasteboardTypes: - [NSPasteboard.PasteboardType] - { - var pasteboardTypes = super.readablePasteboardTypes - pasteboardTypes.append(.png) - return pasteboardTypes - } + extension RichTextView { + public override var readablePasteboardTypes: [NSPasteboard.PasteboardType] { + var pasteboardTypes = super.readablePasteboardTypes + pasteboardTypes.append(.png) + return pasteboardTypes } + } - extension RichTextView { - var textString: String { - return self.string - } + extension RichTextView { + var textString: String { + return self.string } + } #endif diff --git a/Sources/RichEditorSwiftUI/UI/Views/AlertController.swift b/Sources/RichEditorSwiftUI/UI/Views/AlertController.swift deleted file mode 100644 index 21146c5..0000000 --- a/Sources/RichEditorSwiftUI/UI/Views/AlertController.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// AlertController.swift -// RichEditorSwiftUI -// -// Created by Divyesh Vekariya on 19/12/24. -// - -#if canImport(UIKit) - import UIKit - - public class AlertController { - - private var onTextChange: ((String) -> Void)? - - internal var rootController: UIViewController? { - guard - let scene = UIApplication.shared.connectedScenes.first(where: { - $0.activationState == .foregroundActive - }) as? UIWindowScene, - let window = scene.windows.first(where: { $0.isKeyWindow }) - else { - return nil - } - - var root = window.rootViewController - while let presentedViewController = root?.presentedViewController { - root = presentedViewController - } - return root - } - - func showAlert( - title: String, - message: String, - placeholder: String? = nil, - defaultText: String? = nil, - onTextChange: ((String) -> Void)? = nil, - completion: @escaping (String?) -> Void - ) { - // Store the onTextChange closure - self.onTextChange = onTextChange - - // Create the alert controller - let alert = UIAlertController( - title: title, message: message, preferredStyle: .alert) - - // Add a text field - alert.addTextField { textField in - textField.placeholder = placeholder - textField.text = defaultText - - // Add a target to handle text changes - if onTextChange != nil { - textField.addTarget( - self, action: #selector(self.textFieldDidChange(_:)), - for: .editingChanged) - } - } - - alert.addAction( - UIAlertAction( - title: "OK", style: .default, - handler: { [weak alert] _ in - let textFieldText = alert?.textFields?.first?.text - completion(textFieldText) - })) - alert.addAction( - UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) - - // Present the alert - rootController?.present(alert, animated: true) - } - - @objc private func textFieldDidChange(_ textField: UITextField) { - onTextChange?(textField.text ?? "") - } - - func showAlert( - title: String, - message: String, - onOk: (() -> Void)? = nil, - onCancel: (() -> Void)? = nil - ) { - // Create the alert controller - let alert = UIAlertController( - title: title, message: message, preferredStyle: .alert) - - alert.addAction( - UIAlertAction( - title: "OK", style: .default, - handler: { _ in - onOk?() - })) - alert.addAction( - UIAlertAction( - title: "Cancel", style: .cancel, - handler: { _ in - onCancel?() - })) - - // Present the alert - rootController?.present(alert, animated: true) - } - } -#endif - -#if canImport(AppKit) - import AppKit - public class AlertController { - - private var onTextChange: ((String) -> Void)? - - // Root Window or ViewController - internal var rootWindow: NSWindow? { - return NSApp.mainWindow - } - - // Show alert with a text field and real-time text change closure - func showAlert( - title: String, - message: String, - placeholder: String? = nil, - defaultText: String? = nil, - onTextChange: ((String) -> Void)? = nil, - completion: @escaping (String?) -> Void - ) { - // Store the onTextChange closure - self.onTextChange = onTextChange - - // Create the alert - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.alertStyle = .informational - - // Create an input text field - let textField = NSTextField( - frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - textField.placeholderString = placeholder - textField.stringValue = defaultText ?? "" - alert.accessoryView = textField - - // Show real-time text updates - if let onTextChange = onTextChange { - textField.target = self - textField.action = #selector(self.textFieldDidChange(_:)) - } - - // Add the OK and Cancel buttons - alert.addButton(withTitle: "OK") - alert.addButton(withTitle: "Cancel") - - // Show the alert - let response = alert.runModal() - - // Handle completion based on the response - if response == .alertFirstButtonReturn { - completion(textField.stringValue) - } else { - completion(nil) - } - } - - @objc private func textFieldDidChange(_ textField: NSTextField) { - // Call the closure with the updated text - onTextChange?(textField.stringValue) - } - - // Show a simple alert with OK and Cancel actions - func showAlert( - title: String, - message: String, - onOk: (() -> Void)? = nil, - onCancel: (() -> Void)? = nil - ) { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.alertStyle = .informational - alert.addButton(withTitle: "OK") - alert.addButton(withTitle: "Cancel") - - // Show the alert - let response = alert.runModal() - - // Handle actions based on the response - if response == .alertFirstButtonReturn { - onOk?() - } else { - onCancel?() - } - } - } -#endif diff --git a/Sources/RichEditorSwiftUI/UI/Views/RichTextAlertController.swift b/Sources/RichEditorSwiftUI/UI/Views/RichTextAlertController.swift new file mode 100644 index 0000000..67fdad2 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Views/RichTextAlertController.swift @@ -0,0 +1,211 @@ +// +// RichTextAlertController.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 19/12/24. +// + +#if os(macOS) +import AppKit + +/// This typealias bridges platform-specific colors to simplify +/// multi-platform support. +public typealias RichTextAlertController = AlertControllerAppKitImpl +#endif + +#if os(iOS) || os(tvOS) || os(visionOS) +import UIKit + +/// This typealias bridges platform-specific colors to simplify +/// multi-platform support. +public typealias RichTextAlertController = AlertControllerUIKitImpl +#endif + + +#if os(iOS) || os(tvOS) || os(visionOS) + import UIKit + + public class AlertControllerUIKitImpl { + + private var onTextChange: ((String) -> Void)? + + internal var rootController: UIViewController? { + guard + let scene = UIApplication.shared.connectedScenes.first(where: { + $0.activationState == .foregroundActive + }) as? UIWindowScene, + let window = scene.windows.first(where: { $0.isKeyWindow }) + else { + return nil + } + + var root = window.rootViewController + while let presentedViewController = root?.presentedViewController { + root = presentedViewController + } + return root + } + + func showAlert( + title: String, + message: String, + placeholder: String? = nil, + defaultText: String? = nil, + onTextChange: ((String) -> Void)? = nil, + completion: @escaping (String?) -> Void + ) { + // Store the onTextChange closure + self.onTextChange = onTextChange + + // Create the alert controller + let alert = UIAlertController( + title: title, message: message, preferredStyle: .alert) + + // Add a text field + alert.addTextField { textField in + textField.placeholder = placeholder + textField.text = defaultText + + // Add a target to handle text changes + if onTextChange != nil { + textField.addTarget( + self, action: #selector(self.textFieldDidChange(_:)), + for: .editingChanged) + } + } + + alert.addAction( + UIAlertAction( + title: "OK", style: .default, + handler: { [weak alert] _ in + let textFieldText = alert?.textFields?.first?.text + completion(textFieldText) + })) + alert.addAction( + UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + + // Present the alert + rootController?.present(alert, animated: true) + } + + @objc private func textFieldDidChange(_ textField: UITextField) { + onTextChange?(textField.text ?? "") + } + + func showAlert( + title: String, + message: String, + onOk: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + // Create the alert controller + let alert = UIAlertController( + title: title, message: message, preferredStyle: .alert) + + alert.addAction( + UIAlertAction( + title: "OK", style: .default, + handler: { _ in + onOk?() + })) + alert.addAction( + UIAlertAction( + title: "Cancel", style: .cancel, + handler: { _ in + onCancel?() + })) + + // Present the alert + rootController?.present(alert, animated: true) + } + } +#endif + +#if os(macOS) + import AppKit + public class AlertControllerAppKitImpl { + + private var onTextChange: ((String) -> Void)? + + // Root Window or ViewController + internal var rootWindow: NSWindow? { + return NSApp.mainWindow + } + + // Show alert with a text field and real-time text change closure + func showAlert( + title: String, + message: String, + placeholder: String? = nil, + defaultText: String? = nil, + onTextChange: ((String) -> Void)? = nil, + completion: @escaping (String?) -> Void + ) { + // Store the onTextChange closure + self.onTextChange = onTextChange + + // Create the alert + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + + // Create an input text field + let textField = NSTextField( + frame: NSRect(x: 0, y: 0, width: 200, height: 24)) + textField.placeholderString = placeholder + textField.stringValue = defaultText ?? "" + alert.accessoryView = textField + + // Show real-time text updates + if onTextChange != nil { + textField.target = self + textField.action = #selector(self.textFieldDidChange(_:)) + } + + // Add the OK and Cancel buttons + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + // Show the alert + let response = alert.runModal() + + // Handle completion based on the response + if response == .alertFirstButtonReturn { + completion(textField.stringValue) + } else { + completion(nil) + } + } + + @objc private func textFieldDidChange(_ textField: NSTextField) { + // Call the closure with the updated text + onTextChange?(textField.stringValue) + } + + // Show a simple alert with OK and Cancel actions + func showAlert( + title: String, + message: String, + onOk: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + // Show the alert + let response = alert.runModal() + + // Handle actions based on the response + if response == .alertFirstButtonReturn { + onOk?() + } else { + onCancel?() + } + } + } +#endif diff --git a/Sources/RichEditorSwiftUI/UI/Views/ViewRepresentable.swift b/Sources/RichEditorSwiftUI/UI/Views/ViewRepresentable.swift index 63ea1a7..ff15bec 100644 --- a/Sources/RichEditorSwiftUI/UI/Views/ViewRepresentable.swift +++ b/Sources/RichEditorSwiftUI/UI/Views/ViewRepresentable.swift @@ -8,17 +8,17 @@ import SwiftUI #if os(iOS) || os(tvOS) || os(visionOS) - import UIKit + import UIKit - /// This typealias bridges platform-specific view representable - /// types to simplify multi-platform support. - typealias ViewRepresentable = UIViewRepresentable + /// This typealias bridges platform-specific view representable + /// types to simplify multi-platform support. + typealias ViewRepresentable = UIViewRepresentable #endif -#if macOS - import AppKit +#if os(macOS) + import AppKit - /// This typealias bridges platform-specific view representable - /// types to simplify multi-platform support. - typealias ViewRepresentable = NSViewRepresentable + /// This typealias bridges platform-specific view representable + /// types to simplify multi-platform support. + typealias ViewRepresentable = NSViewRepresentable #endif