├── docs ├── editor_mac_dark.png ├── editor_mac_light.png ├── editor_iphone_dark.png ├── editor_iphone_light.png ├── editor_toolbar_iphone_dark.png └── editor_toolbar_iphone_light.png ├── RichEditorDemo ├── RichEditorDemo │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── RichEditorDemoApp.swift │ ├── RichEditorDemo.entitlements │ └── JsonUtils.swift └── RichEditorDemo.xcodeproj │ └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── .gitignore ├── Sources └── RichEditorSwiftUI │ ├── Fonts │ ├── RichTextFont.swift │ ├── StandardFontSizeProvider.swift │ ├── RichTextFont+PickerItem.swift │ ├── FontRepresentable.swift │ ├── FontTraitsRepresentable.swift │ ├── RichTextFont+SizePickerConfig.swift │ ├── FontDescriptorRepresentable.swift │ ├── RichTextFont+ListPicker.swift │ ├── RichTextFont+SizePicker.swift │ ├── RichTextFontSizePickerStack.swift │ └── RichTextFont+PickerConfig.swift │ ├── Format │ ├── RichTextFormat.swift │ ├── RichTextFormat+ToolbarStyle.swift │ ├── RichTextFormat+ToolbarConfig.swift │ └── RichTextFormat+Toolbar.swift │ ├── Headers │ ├── RichTextHeader.swift │ └── RichTextHeader+Picker.swift │ ├── Pdf │ ├── PdfDataError.swift │ ├── PdfPageMargins.swift │ └── PdfPageConfiguration.swift │ ├── UI │ ├── Extensions │ │ ├── Array+Extension.swift │ │ ├── Character+Extension.swift │ │ ├── NSAttributedString+Empty.swift │ │ ├── Image+Label.swift │ │ ├── View+BackportSupportExtension.swift │ │ ├── NSMutableParagraphStyle+Custom.swift │ │ ├── NSRange+Extension.swift │ │ └── String+Characters.swift │ ├── TextViewUI │ │ ├── RichTextView+Config.swift │ │ ├── TextViewEvents.swift │ │ ├── RichTextViewRepresentable.swift │ │ ├── RichTextView+Setup.swift │ │ ├── RichTextEditor+Style.swift │ │ ├── RichTextView+Config_AppKit.swift │ │ ├── RichTextEditor+Config.swift │ │ ├── RichEditorStateFocusedValueKey.swift │ │ ├── RichTextView+Theme.swift │ │ └── RichTextView+Config_UIKit.swift │ ├── Views │ │ ├── ViewRepresentable.swift │ │ ├── ListPickerItem.swift │ │ ├── RichTextLabelValue.swift │ │ ├── ListPickerSection.swift │ │ ├── ListPicker.swift │ │ └── ForEachPicker.swift │ ├── Context │ │ ├── RichEditorState+Header.swift │ │ ├── RichTextContext+Actions.swift │ │ ├── RichEditorState+TextAlignment.swift │ │ ├── RichEditorState+Styles.swift │ │ ├── RichEditorState+Link.swift │ │ └── RichTextContext+Color.swift │ ├── Parser │ │ └── EditorAdapter.swift │ └── Helper │ │ ├── RichTextReader.swift │ │ └── RichEditorState+LineInfo.swift │ ├── ExportData │ ├── RichTextDataError.swift │ ├── UTType+RichText.swift │ ├── RichTextDataFormat+Menu.swift │ ├── RichTextDataReader.swift │ └── NSAttributedString+Init.swift │ ├── Attributes │ ├── RichTextAttribute.swift │ ├── RichTextAttributes+RichTextStyle.swift │ ├── RichTextWriter.swift │ └── RichTextAttributeReader.swift │ ├── Export │ ├── RichTextExportError.swift │ ├── NSAttributedString+Export.swift │ ├── RichTextExportOption.swift │ ├── RichTextExportMenu.swift │ ├── RichTextExportUrlResolver.swift │ ├── RichTextExportService.swift │ └── RichTextExportUrlResolver+FileManager.swift │ ├── Helper │ └── Buildable.swift │ ├── Colors │ ├── ColorRepresentable.swift │ └── RichTextColor.swift │ ├── Data │ └── Models │ │ ├── RichText.swift │ │ ├── RichTextSpanInternal.swift │ │ ├── HeaderType.swift │ │ └── RichAttributes+RichTextAttributes.swift │ ├── Styles │ ├── View+RichTextStyle.swift │ ├── RichTextHighlightingStyle.swift │ ├── RichTextAction+ButtonStack.swift │ ├── RichTextStyle+ToggleStack.swift │ ├── RichTextStyle+ToggleGroup.swift │ ├── RichTextStyle+Toggle.swift │ ├── RichTextStyle+Button.swift │ └── RichTextStyle.swift │ ├── Components │ ├── RichTextViewComponent+Alignment.swift │ ├── RichTextViewComponent+Paragraph.swift │ ├── RichTextViewComponent+Colors.swift │ ├── RichTextViewComponent+Styles.swift │ ├── RichTextViewComponent+Link.swift │ ├── RichTextViewComponent+Pasting.swift │ ├── RichTextViewComponent+Ranges.swift │ └── RichTextViewComponent+Attributes.swift │ ├── Alignment │ ├── RichTextAlignment+Picker.swift │ └── RichTextAlignment.swift │ ├── RichTextOtherMenu │ ├── RichTextOtherMenu+ToggleStack.swift │ ├── RichTextOtherMenu.swift │ ├── RichTextOtherMenu+ToggleGroup.swift │ ├── RichTextOtherMenu+Button.swift │ └── RichTextOtherMenu+Toggle.swift │ ├── Actions │ ├── RichTextActionButton.swift │ └── RichTextAction+KeyboardShortcutModifier.swift │ ├── BaseFoundation │ ├── RichTextPresenter.swift │ └── RichTextCoordinator+Subscriptions.swift │ ├── ListStyle │ └── ListType.swift │ ├── Keyboard │ ├── RichTextKeyboardToolbar+Config.swift │ └── RichTextKeyboardToolbar+Style.swift │ └── Localization │ └── RTEL10n.swift ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ ├── RichEditorSwiftUITests.xcscheme │ └── RichEditorSwiftUI.xcscheme ├── .github ├── pull_request_template.md └── workflows │ ├── release.yaml │ ├── build.yaml │ └── codeql.yaml ├── Tests └── RichEditorSwiftUITests │ └── RichEditorSwiftUITests.swift ├── .pre-commit-config.yaml ├── RichEditorSwiftUI.podspec ├── LICENSE.md ├── Package.swift └── CONTRIBUTING.md /docs/editor_mac_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canopas/rich-editor-swiftui/HEAD/docs/editor_mac_dark.png -------------------------------------------------------------------------------- /docs/editor_mac_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canopas/rich-editor-swiftui/HEAD/docs/editor_mac_light.png -------------------------------------------------------------------------------- /docs/editor_iphone_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canopas/rich-editor-swiftui/HEAD/docs/editor_iphone_dark.png -------------------------------------------------------------------------------- /docs/editor_iphone_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canopas/rich-editor-swiftui/HEAD/docs/editor_iphone_light.png -------------------------------------------------------------------------------- /docs/editor_toolbar_iphone_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canopas/rich-editor-swiftui/HEAD/docs/editor_toolbar_iphone_dark.png -------------------------------------------------------------------------------- /docs/editor_toolbar_iphone_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canopas/rich-editor-swiftui/HEAD/docs/editor_toolbar_iphone_light.png -------------------------------------------------------------------------------- /RichEditorDemo/RichEditorDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RichEditorDemo/RichEditorDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /RichEditorDemo/RichEditorDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /RichEditorDemo/RichEditorDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/RichTextFont.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFont.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This is a namespace for font-related types and views. 11 | public struct RichTextFont {} 12 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Format/RichTextFormat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextLine.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This is a namespace for format-related types and views. 11 | public struct RichTextFormat {} 12 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Headers/RichTextHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextHeader.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 25/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This is a namespace for header-related types and views. 11 | public struct RichTextHeader {} 12 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /RichEditorDemo/RichEditorDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /RichEditorDemo/RichEditorDemo/RichEditorDemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorDemoApp.swift 3 | // RichEditorDemo 4 | // 5 | // Created by Divyesh Vekariya on 11/10/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct RichEditorDemoApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Pdf/PdfDataError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PdfDataError.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This error can be thrown when creating PDF data. 11 | public enum PdfDataError: Error { 12 | 13 | /// The platform is not supported 14 | case unsupportedPlatform 15 | } 16 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Extensions/Array+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Extension.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 04/04/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array where Element: Comparable { 11 | func containsSameElements(as other: [Element]) -> Bool { 12 | return self.count == other.count && self.sorted() == other.sorted() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Extensions/Character+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Character+Extension.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 09/05/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Character { 11 | 12 | /// Check if a character is a new line separator. 13 | var isNewLineSeparator: Bool { 14 | self == .newLine || self == .carriageReturn 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /RichEditorDemo/RichEditorDemo/RichEditorDemo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Extensions/NSAttributedString+Empty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+Empty.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 13/12/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSAttributedString { 11 | 12 | /// Create an empty attributed string. 13 | public static var empty: NSAttributedString { 14 | .init(string: "") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | [Quick description and/or mentions to other PRs] 4 | 5 | ## Breaking Changes 6 | 7 | - [List breaking changes] 8 | 9 | ## New Features 10 | 11 | - [List what was introduced] 12 | 13 | ## Bug Fixes 14 | 15 | - [List of issues fixed] 16 | 17 | ## Enhancements 18 | 19 | - [List non-breaking changes] 20 | 21 | ## Maintenance 22 | 23 | - [General refactoring, deployment related changes, etc.] 24 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Extensions/Image+Label.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image+Label.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Image { 11 | 12 | /// Create a label from the icon. 13 | func label(_ title: String) -> some View { 14 | Label { 15 | Text(title) 16 | } icon: { 17 | self 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/ExportData/RichTextDataError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextDataError.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This enum represents rich text data-related errors. 11 | public enum RichTextDataError: Error { 12 | 13 | case invalidArchivedData(in: Data) 14 | case invalidPlainTextData(in: Data) 15 | case invalidData(in: String) 16 | } 17 | -------------------------------------------------------------------------------- /Tests/RichEditorSwiftUITests/RichEditorSwiftUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import RichEditorSwiftUI 3 | 4 | final class RichEditorSwiftUITests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | // -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Attributes/RichTextAttribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextAttribute.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 28/12/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /** 11 | This typealias represents a rich text dictionary key. 12 | */ 13 | 14 | public typealias RichTextAttribute = NSAttributedString.Key 15 | 16 | /// This typealias represents a ``RichTextAttribute`` keyed and 17 | /// `Any` valued dictionary. 18 | public typealias RichTextAttributes = [RichTextAttribute: Any] 19 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextView+Config.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) 11 | extension RichTextView.Configuration { 12 | 13 | /// The standard rich text view configuration. 14 | /// 15 | /// You can set a new value to change the global default. 16 | public static var standard = Self() 17 | } 18 | #endif 19 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/TextViewEvents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewEvents.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 23/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | //MARK: - TextView Events 11 | public enum TextViewEvents { 12 | case didChangeSelection(selectedRange: NSRange, text: NSAttributedString) 13 | case didBeginEditing(selectedRange: NSRange, text: NSAttributedString) 14 | case didChange(selectedRange: NSRange, text: NSAttributedString) 15 | case didEndEditing(selectedRange: NSRange, text: NSAttributedString) 16 | } 17 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextViewRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextViewRepresentable.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | #if os(macOS) 9 | import AppKit 10 | 11 | /// This typealias bridges UIKit & AppKit native text views. 12 | public typealias RichTextViewRepresentable = NSTextView 13 | #endif 14 | 15 | #if os(iOS) || os(tvOS) || os(visionOS) 16 | import UIKit 17 | 18 | /// This typealias bridges UIKit & AppKit native text views. 19 | public typealias RichTextViewRepresentable = UITextView 20 | #endif 21 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Export/RichTextExportError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextExportError.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This enum defines errors that can be thrown when failing to 11 | /// export rich text. 12 | public enum RichTextExportError: Error { 13 | 14 | /// This error occurs when no file could be generated at a certain url. 15 | case cantCreateFile(at: URL) 16 | 17 | /// This error occurs when no file could be generated in a certain directory. 18 | case cantCreateFileUrl(in: FileManager.SearchPathDirectory) 19 | } 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: swiftformat 5 | name: Swift Format 6 | description: Enforces formatting guidelines for Swift files before committing. 7 | language: system 8 | entry: swiftformat --swiftversion 5 9 | stages: 10 | - pre-commit 11 | 12 | - id: swiftlint 13 | name: Swift Linter 14 | description: Runs a linter before committing to ensure code quality. 15 | language: system 16 | always_run: true 17 | entry: swiftlint lint --lenient --config .swiftlint.yml 18 | stages: 19 | - pre-commit 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This workflow release package on Cocoapods. 2 | 3 | name: Release 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: macOS-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | 16 | - name: Install CocoaPods 17 | run: gem install cocoapods 18 | 19 | - name: Prepare and Publish 20 | env: 21 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} 22 | run: | 23 | set -eo pipefail 24 | pod lib lint --allow-warnings 25 | pod trunk push RichEditorSwiftUI.podspec --allow-warnings 26 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Views/ViewRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewRepresentable.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(iOS) || os(tvOS) || os(visionOS) 11 | import UIKit 12 | 13 | /// This typealias bridges platform-specific view representable 14 | /// types to simplify multi-platform support. 15 | typealias ViewRepresentable = UIViewRepresentable 16 | #endif 17 | 18 | #if os(macOS) 19 | import AppKit 20 | 21 | /// This typealias bridges platform-specific view representable 22 | /// types to simplify multi-platform support. 23 | typealias ViewRepresentable = NSViewRepresentable 24 | #endif 25 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Export/NSAttributedString+Export.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+Export.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | extension NSAttributedString { 12 | 13 | /// Make all text black to account for dark mode. 14 | func withBlackText() -> NSAttributedString { 15 | let mutable = NSMutableAttributedString(attributedString: self) 16 | let range = mutable.safeRange( 17 | for: NSRange(location: 0, length: mutable.length)) 18 | mutable.setRichTextAttribute( 19 | .foregroundColor, to: ColorRepresentable.black, at: range) 20 | return mutable 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Views/ListPickerItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListPickerItem.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This is an internal version of the original that is defined 11 | /// and available in https://github.com/danielsaidi/swiftuikit. 12 | /// This will not be made public or documented for this library. 13 | protocol ListPickerItem: View { 14 | 15 | associatedtype Item: Equatable 16 | 17 | var item: Item { get } 18 | var isSelected: Bool { get } 19 | } 20 | 21 | extension ListPickerItem { 22 | 23 | var checkmark: some View { 24 | Image(systemName: "checkmark") 25 | .opacity(isSelected ? 1 : 0) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Attributes/RichTextAttributes+RichTextStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextAttributes+RichTextStyle.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 28/12/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RichTextAttributes { 11 | /** 12 | Whether or not the attributes has a strikethrough style. 13 | */ 14 | public var isStrikethrough: Bool { 15 | get { self[.strikethroughStyle] as? Int == 1 } 16 | set { self[.strikethroughStyle] = newValue ? 1 : 0 } 17 | } 18 | 19 | /** 20 | Whether or not the attributes has an underline style. 21 | */ 22 | public var isUnderlined: Bool { 23 | get { self[.underlineStyle] as? Int == 1 } 24 | set { self[.underlineStyle] = newValue ? 1 : 0 } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Views/RichTextLabelValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextLabelValue.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 30/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This protocol can be implemented by any rich text values 11 | /// that can be represented as a label. 12 | public protocol RichTextLabelValue: Hashable { 13 | 14 | /// The value icon. 15 | var icon: Image { get } 16 | 17 | /// The value display title. 18 | var title: String { get } 19 | } 20 | 21 | extension RichTextLabelValue { 22 | 23 | /// The standard label to use for the value. 24 | public var label: some View { 25 | Label( 26 | title: { Text(title) }, 27 | icon: { icon } 28 | ) 29 | .tag(self) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/ExportData/UTType+RichText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UTType+RichText.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import UniformTypeIdentifiers 9 | 10 | extension UTType { 11 | 12 | /// Uniform rich text types that RichTextKit supports. 13 | public static let richTextTypes: [UTType] = [ 14 | .archivedData, 15 | .rtf, 16 | .text, 17 | .plainText, 18 | .data, 19 | ] 20 | 21 | /// The uniform type for ``RichTextDataFormat/archivedData``. 22 | public static let archivedData = UTType( 23 | exportedAs: "com.richtextkit.archiveddata") 24 | } 25 | 26 | extension Collection where Element == UTType { 27 | 28 | /// The uniforum types that rich text documents support. 29 | public static var richTextTypes: [UTType] { UTType.richTextTypes } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Helper/Buildable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Buildable.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 24/09/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Adds a helper function to mutate a properties and help implement _Builder_ pattern 11 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 12 | protocol Buildable {} 13 | 14 | @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) 15 | extension Buildable { 16 | /// Mutates a property of the instance 17 | /// 18 | /// - Parameter keyPath: `WritableKeyPath` to the instance property to be modified 19 | /// - Parameter value: value to overwrite the instance property 20 | func mutating(keyPath: WritableKeyPath, value: T) -> Self { 21 | var newSelf = self 22 | newSelf[keyPath: keyPath] = value 23 | return newSelf 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Colors/ColorRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorRepresentable.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | #if os(macOS) 9 | import AppKit 10 | 11 | /// This typealias bridges platform-specific colors to simplify 12 | /// multi-platform support. 13 | public typealias ColorRepresentable = NSColor 14 | #endif 15 | 16 | #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) 17 | import UIKit 18 | 19 | /// This typealias bridges platform-specific colors to simplify 20 | /// multi-platform support. 21 | public typealias ColorRepresentable = UIColor 22 | #endif 23 | 24 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) 25 | extension ColorRepresentable { 26 | 27 | #if os(iOS) || os(tvOS) || os(visionOS) 28 | public static var textColor: ColorRepresentable { .label } 29 | #endif 30 | } 31 | #endif 32 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Views/ListPickerSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListPickerSection.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This is an internal version of the original that is defined 11 | /// and available in https://github.com/danielsaidi/swiftuikit. 12 | /// This will not be made public or documented for this library. 13 | struct ListPickerSection: Identifiable { 14 | 15 | init(title: String, items: [Item]) { 16 | self.id = UUID() 17 | self.title = title 18 | self.items = items 19 | } 20 | 21 | let id: UUID 22 | let title: String 23 | let items: [Item] 24 | 25 | @ViewBuilder 26 | var header: some View { 27 | if title.trimmingCharacters(in: .whitespaces).isEmpty { 28 | EmptyView() 29 | } else { 30 | Text(title) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Data/Models/RichText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichText.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 12/02/24. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias RichTextSpans = [RichTextSpan] 11 | 12 | public struct RichText: Codable { 13 | public let spans: [RichTextSpan] 14 | 15 | public init(spans: [RichTextSpan] = []) { 16 | self.spans = spans 17 | } 18 | 19 | func encodeToData() throws -> Data { 20 | return try JSONEncoder().encode(self) 21 | } 22 | } 23 | 24 | public struct RichTextSpan: Codable { 25 | // public var id: String = UUID().uuidString 26 | public let insert: String 27 | public let attributes: RichAttributes? 28 | 29 | public init( 30 | insert: String, 31 | attributes: RichAttributes? = nil 32 | ) { 33 | // self.id = id 34 | self.insert = insert 35 | self.attributes = attributes 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Styles/View+RichTextStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+RichTextStyle.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | 12 | /** 13 | Add a keyboard shortcut that toggles a certain style. 14 | 15 | This modifier only has effect on platforms that support 16 | keyboard shortcuts. 17 | */ 18 | @ViewBuilder 19 | public func keyboardShortcut(for style: RichTextStyle) -> some View { 20 | #if os(iOS) || os(macOS) || os(visionOS) 21 | switch style { 22 | case .bold: keyboardShortcut("b", modifiers: .command) 23 | case .italic: keyboardShortcut("i", modifiers: .command) 24 | case .strikethrough: self 25 | case .underline: keyboardShortcut("u", modifiers: .command) 26 | } 27 | #else 28 | self 29 | #endif 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Setup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextView+Setup.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextView { 12 | 13 | func setupSharedBehavior( 14 | with text: NSAttributedString, 15 | _ format: RichTextDataFormat? 16 | ) { 17 | attributedString = .empty 18 | attributedString = text 19 | 20 | setContentCompressionResistancePriority( 21 | .defaultLow, for: .horizontal) 22 | } 23 | 24 | func setup(_ theme: RichTextView.Theme) { 25 | guard richText.string.isEmpty else { return } 26 | font = theme.font 27 | textColor = theme.fontColor 28 | backgroundColor = theme.backgroundColor 29 | } 30 | } 31 | #endif 32 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Header.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorState+Header.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichEditorState { 11 | 12 | /// Get a binding for a certain style. 13 | public func headerBinding() -> Binding { 14 | Binding( 15 | get: { self.currentHeader() }, 16 | set: { self.setHeaderStyle($0) } 17 | ) 18 | } 19 | 20 | /// Check whether or not the context has a certain header style. 21 | public func currentHeader() -> HeaderType { 22 | return headerType 23 | } 24 | 25 | /// Set whether or not the context has a certain header style. 26 | public func setHeaderStyle( 27 | _ header: HeaderType 28 | ) { 29 | guard header != headerType else { return } 30 | updateStyle(style: header.getTextSpanStyle()) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Extensions/View+BackportSupportExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+BackportSupportExtension.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 30/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | nonisolated public func onChangeBackPort( 12 | of value: V, _ action: @escaping (_ newValue: V) -> Void 13 | ) -> some View where V: Equatable { 14 | Group { 15 | if #available(iOS 17.0, macOS 14.0, watchOS 10.0, *) { 16 | self 17 | //iOS17~ 18 | .onChange(of: value) { oldValue, newValue in 19 | action(newValue) 20 | } 21 | } else { 22 | //up to iOS16 23 | self 24 | .onChange(of: value) { newValue in 25 | action(newValue) 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Export/RichTextExportOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextExportOption.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 27/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum RichTextExportOption: CaseIterable, Equatable, Identifiable { 11 | case pdf 12 | case json 13 | } 14 | 15 | extension RichTextExportOption { 16 | /// The format's unique identifier. 17 | public var id: String { 18 | switch self { 19 | case .pdf: "pdf" 20 | case .json: "json" 21 | } 22 | } 23 | 24 | /// The format's file format display text. 25 | public var fileFormatText: String { 26 | switch self { 27 | case .pdf: RTEL10n.fileFormatPdf.text 28 | case .json: RTEL10n.fileFormatJson.text 29 | } 30 | } 31 | } 32 | 33 | extension Collection where Element == RichTextExportOption { 34 | 35 | public static var all: [Element] { RichTextExportOption.allCases } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Extensions/NSMutableParagraphStyle+Custom.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSMutableParagraphStyle+Custom.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 25/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | #if canImport(UIKit) 11 | import UIKit 12 | #elseif canImport(AppKit) && !targetEnvironment(macCatalyst) 13 | import AppKit 14 | #endif 15 | 16 | extension NSMutableParagraphStyle { 17 | 18 | convenience init( 19 | from style: NSMutableParagraphStyle? = nil, 20 | alignment: RichTextAlignment? = nil, 21 | indent: CGFloat? = nil, 22 | lineSpacing: CGFloat? = nil 23 | ) { 24 | let style = style ?? .init() 25 | self.init() 26 | self.alignment = alignment?.nativeAlignment ?? style.alignment 27 | self.lineSpacing = lineSpacing ?? style.lineSpacing 28 | self.headIndent = indent ?? style.headIndent 29 | self.firstLineHeadIndent = indent ?? style.headIndent 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Alignment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextViewComponent+Alignment.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 25/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | #if canImport(UIKit) 11 | import UIKit 12 | #elseif canImport(AppKit) && !targetEnvironment(macCatalyst) 13 | import AppKit 14 | #endif 15 | 16 | extension RichTextViewComponent { 17 | 18 | /// Get the text alignment. 19 | public var richTextAlignment: RichTextAlignment? { 20 | guard let style = richTextParagraphStyle else { return nil } 21 | return RichTextAlignment(style.alignment) 22 | } 23 | 24 | /// Set the text alignment. 25 | public func setRichTextAlignment(_ alignment: RichTextAlignment) { 26 | // if richTextAlignment == alignment { return } 27 | let style = NSMutableParagraphStyle( 28 | from: richTextParagraphStyle, 29 | alignment: alignment 30 | ) 31 | setRichTextParagraphStyle(style) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /RichEditorSwiftUI.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "RichEditorSwiftUI" 3 | s.version = "1.1.1" 4 | s.summary = "Rich text editing, SwiftUI rich text editor library." 5 | 6 | s.description = <<-DESC 7 | Wrapper around UITextView to support Rich text editing in SwiftUI. 8 | DESC 9 | 10 | s.homepage = "https://github.com/canopas/RichEditorSwiftUI" 11 | s.license = { :type => "MIT", :file => "LICENSE.md" } 12 | s.author = { "Jimmy" => "jimmy@canopas.com" } 13 | s.source = { :git => "https://github.com/canopas/rich-editor-swiftui.git", :tag => s.version.to_s } 14 | s.social_media_url = "https://x.com/canopas_eng" 15 | 16 | s.source_files = "Sources/**/*.swift" 17 | 18 | s.module_name = "RichEditorSwiftUI" 19 | s.requires_arc = true 20 | 21 | s.swift_version = "5.9" 22 | 23 | s.ios.deployment_target = "15.0" 24 | s.osx.deployment_target = "12.0" 25 | s.tvos.deployment_target = "17.0" 26 | s.watchos.deployment_target = "8.0" 27 | 28 | s.preserve_paths = "README.md" 29 | 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) 2022 Canopas Software LLP 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "RichEditorSwiftUI", 7 | platforms: [ 8 | .iOS(.v15), 9 | .macOS(.v12), 10 | .tvOS(.v17), 11 | .watchOS(.v8), 12 | .visionOS(.v1) 13 | ], 14 | products: [ 15 | .library( 16 | name: "RichEditorSwiftUI", 17 | targets: ["RichEditorSwiftUI"]), 18 | ], 19 | dependencies: [], 20 | targets: [ 21 | .target( 22 | name: "RichEditorSwiftUI", 23 | dependencies: [], 24 | resources: [], 25 | swiftSettings: [ 26 | .define("macOS", .when(platforms: [.macOS])), 27 | .define("iOS", .when(platforms: [.iOS, .macCatalyst])) 28 | ] 29 | ), 30 | .testTarget( 31 | name: "RichEditorSwiftUITests", 32 | dependencies: ["RichEditorSwiftUI"], 33 | swiftSettings: [ 34 | .define("macOS", .when(platforms: [.macOS])), 35 | .define("iOS", .when(platforms: [.iOS, .macCatalyst])) 36 | ] 37 | ) 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/StandardFontSizeProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StandardFontSizeProvider.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 12/01/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol StandardFontSizeProvider {} 11 | 12 | extension CGFloat: StandardFontSizeProvider {} 13 | 14 | extension Double: StandardFontSizeProvider {} 15 | 16 | extension RichEditorState: StandardFontSizeProvider {} 17 | 18 | #if os(iOS) || os(macOS) || os(tvOS) 19 | extension RichTextEditor: StandardFontSizeProvider {} 20 | 21 | extension RichTextView: StandardFontSizeProvider {} 22 | #endif 23 | 24 | extension StandardFontSizeProvider { 25 | 26 | /** 27 | The standard font size to use for rich text. 28 | 29 | You can change this value to affect all types that make 30 | use of this value. 31 | */ 32 | public static var standardRichTextFontSize: CGFloat { 33 | get { StandardFontSizeProviderStorage.standardRichTextFontSize } 34 | set { 35 | StandardFontSizeProviderStorage.standardRichTextFontSize = newValue 36 | } 37 | } 38 | } 39 | 40 | private class StandardFontSizeProviderStorage { 41 | 42 | static var standardRichTextFontSize: CGFloat = 16 43 | } 44 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFont+PickerItem.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextFont { 11 | 12 | /** 13 | This struct is used by the various library font pickers. 14 | */ 15 | struct PickerItem: View, ListPickerItem { 16 | 17 | init( 18 | font: Item, 19 | fontSize: CGFloat = 20, 20 | isSelected: Bool 21 | ) { 22 | self.font = font 23 | self.fontSize = fontSize 24 | self.isSelected = isSelected 25 | } 26 | 27 | typealias Item = RichTextFont.PickerFont 28 | 29 | let font: Item 30 | let fontSize: CGFloat 31 | let isSelected: Bool 32 | 33 | var item: Item { font } 34 | 35 | var body: some View { 36 | HStack { 37 | Text(font.fontDisplayName) 38 | .font(.custom(font.fontName, size: fontSize)) 39 | Spacer() 40 | if isSelected { 41 | checkmark 42 | } 43 | }.contentShape(Rectangle()) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextEditor+Style.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextEditor+Style.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | /// This struct can be used to style a ``RichTextEditor``. 12 | public typealias RichTextEditorStyle = RichTextView.Theme 13 | 14 | extension View { 15 | 16 | /// Apply a ``RichTextEditor`` style. 17 | public func richTextEditorStyle( 18 | _ style: RichTextEditorStyle 19 | ) -> some View { 20 | self.environment(\.richTextEditorStyle, style) 21 | } 22 | } 23 | 24 | extension RichTextEditorStyle { 25 | 26 | fileprivate struct Key: EnvironmentKey { 27 | 28 | static var defaultValue: RichTextEditorStyle = .standard 29 | } 30 | } 31 | 32 | extension EnvironmentValues { 33 | 34 | /// This value can bind to a rich text editor style. 35 | public var richTextEditorStyle: RichTextEditorStyle { 36 | get { self[RichTextEditorStyle.Key.self] } 37 | set { self[RichTextEditorStyle.Key.self] = newValue } 38 | } 39 | } 40 | #endif 41 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Config_AppKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextView+Config_AppKit.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | #if os(macOS) 9 | import Foundation 10 | 11 | extension RichTextView { 12 | 13 | /** 14 | This type can be used to configure a ``RichTextEditor``. 15 | */ 16 | public struct Configuration { 17 | 18 | /// Create a custom configuration 19 | /// - Parameters: 20 | /// - isScrollingEnabled: Whether or not the editor should scroll, by default `true`. 21 | /// - isContinuousSpellCheckingEnabled: Whether the editor spell-checks in realtime. Defaults to `true`. 22 | public init( 23 | isScrollingEnabled: Bool = true, 24 | isContinuousSpellCheckingEnabled: Bool = true 25 | ) { 26 | self.isScrollingEnabled = isScrollingEnabled 27 | self.isContinuousSpellCheckingEnabled = 28 | isContinuousSpellCheckingEnabled 29 | } 30 | 31 | /// Whether or not the editor should scroll. 32 | public var isScrollingEnabled: Bool 33 | 34 | /// Whether the editor spell-checks in realtime. 35 | public var isContinuousSpellCheckingEnabled: Bool 36 | } 37 | } 38 | #endif 39 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/FontRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontRepresentable.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 28/12/23. 6 | // 7 | 8 | #if canImport(UIKit) 9 | import UIKit 10 | 11 | /// This typealias bridges platform-specific fonts, to simplify 12 | /// multi-platform support. 13 | public typealias FontRepresentable = UIFont 14 | #endif 15 | 16 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 17 | import AppKit 18 | 19 | /// This typealias bridges platform-specific fonts, to simplify 20 | /// multi-platform support. 21 | public typealias FontRepresentable = NSFont 22 | #endif 23 | 24 | extension FontRepresentable { 25 | 26 | /** 27 | The standard font to use for rich text. 28 | 29 | You can change this value to affect all types that make 30 | use of the value. 31 | */ 32 | public static var standardRichTextFont = systemFont( 33 | ofSize: .standardRichTextFontSize) 34 | 35 | /// Create a new font by toggling a certain style. 36 | public func toggling( 37 | _ style: RichTextStyle 38 | ) -> FontRepresentable? { 39 | .init( 40 | descriptor: fontDescriptor.byTogglingStyle(style), 41 | size: pointSize 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextEditor+Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextEditor+Config.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | /// This struct be used to configure a ``RichTextEditor``. 12 | public typealias RichTextEditorConfig = RichTextView.Configuration 13 | 14 | extension View { 15 | 16 | /// Apply a ``RichTextEditor`` configuration. 17 | public func richTextEditorConfig( 18 | _ config: RichTextEditorConfig 19 | ) -> some View { 20 | self.environment(\.richTextEditorConfig, config) 21 | } 22 | } 23 | 24 | extension RichTextEditorConfig { 25 | 26 | fileprivate struct Key: EnvironmentKey { 27 | 28 | public static var defaultValue: RichTextEditorConfig = .standard 29 | } 30 | } 31 | 32 | extension EnvironmentValues { 33 | 34 | /// This value can bind to a rich text editor config. 35 | public var richTextEditorConfig: RichTextEditorConfig { 36 | get { self[RichTextEditorConfig.Key.self] } 37 | set { self[RichTextEditorConfig.Key.self] = newValue } 38 | } 39 | } 40 | #endif 41 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Paragraph.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextViewComponent+Paragraph.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 25/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | #if canImport(UIKit) 11 | import UIKit 12 | #endif 13 | 14 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 15 | import AppKit 16 | #endif 17 | 18 | extension RichTextViewComponent { 19 | 20 | /// Get the paragraph style. 21 | public var richTextParagraphStyle: NSMutableParagraphStyle? { 22 | richTextAttribute(.paragraphStyle) 23 | } 24 | 25 | /// Set the paragraph style. 26 | /// 27 | /// > Todo: The function currently can't handle multiple 28 | /// selected paragraphs. If many paragraphs are selected, 29 | /// it will only affect the first one. 30 | public func setRichTextParagraphStyle(_ style: NSParagraphStyle) { 31 | let range = lineRange(for: selectedRange) 32 | guard range.length > 0 else { return } 33 | #if os(watchOS) 34 | setRichTextAttribute(.paragraphStyle, to: style, at: range) 35 | #else 36 | textStorageWrapper?.addAttribute( 37 | .paragraphStyle, value: style, range: range) 38 | #endif 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Pdf/PdfPageMargins.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PdfPageMargins.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import CoreGraphics 9 | 10 | /// This error can be thrown when creating PDF data. 11 | public struct PdfPageMargins: Equatable { 12 | 13 | /// Create PDF page margins. 14 | public init(top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat) { 15 | self.top = top 16 | self.left = left 17 | self.bottom = bottom 18 | self.right = right 19 | } 20 | 21 | /// Create PDF page margins. 22 | public init(horizontal: CGFloat, vertical: CGFloat) { 23 | self.top = vertical 24 | self.left = horizontal 25 | self.bottom = vertical 26 | self.right = horizontal 27 | } 28 | 29 | /// Create PDF page margins. 30 | public init(all: CGFloat) { 31 | self.top = all 32 | self.left = all 33 | self.bottom = all 34 | self.right = all 35 | } 36 | 37 | /// The top margins. 38 | public var top: CGFloat 39 | 40 | /// The left margins. 41 | public var left: CGFloat 42 | 43 | /// The bottom margins. 44 | public var bottom: CGFloat 45 | 46 | /// The right margins. 47 | public var right: CGFloat 48 | } 49 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Styles/RichTextHighlightingStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextHighlightingStyle.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This struct can be used to style rich text highlighting. 11 | public struct RichTextHighlightingStyle: Equatable, Hashable { 12 | 13 | /** 14 | Create a style instance. 15 | 16 | - Parameters: 17 | - backgroundColor: The background color to use for highlighted text. 18 | - foregroundColor: The foreground color to use for highlighted text. 19 | */ 20 | public init( 21 | backgroundColor: Color = .clear, 22 | foregroundColor: Color = .accentColor 23 | ) { 24 | self.backgroundColor = backgroundColor 25 | self.foregroundColor = foregroundColor 26 | } 27 | 28 | /// The background color to use for highlighted text. 29 | public let backgroundColor: Color 30 | 31 | /// The foreground color to use for highlighted text. 32 | public let foregroundColor: Color 33 | } 34 | 35 | extension RichTextHighlightingStyle { 36 | 37 | /// The standard rich text highlighting style. 38 | /// 39 | /// You can set a new value to change the global default. 40 | public static var standard = Self() 41 | } 42 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Actions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextContext+Actions.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichEditorState { 11 | 12 | /// Handle a certain rich text action. 13 | public func handle(_ action: RichTextAction) { 14 | switch action { 15 | // case .stepFontSize(let size): 16 | // fontSize += CGFloat(size) 17 | // updateStyle(style: .size(Int(fontSize))) 18 | default: actionPublisher.send(action) 19 | } 20 | } 21 | 22 | /// Check if the context can handle a certain action. 23 | public func canHandle(_ action: RichTextAction) -> Bool { 24 | switch action { 25 | case .copy: canCopy 26 | // case .pasteImage: true 27 | // case .pasteImages: true 28 | // case .pasteText: true 29 | case .print: false 30 | case .redoLatestChange: canRedoLatestChange 31 | case .undoLatestChange: canUndoLatestChange 32 | default: true 33 | } 34 | } 35 | 36 | /// Trigger a certain rich text action. 37 | public func trigger(_ action: RichTextAction) { 38 | handle(action) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Parser/EditorAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorAdapter.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 11/12/23. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol EditorAdapter { 11 | func encodeToString(type model: T) throws -> String? 12 | func decode(from jsonString: String) throws -> T? 13 | func encode(type model: T) throws -> Data 14 | } 15 | 16 | class DefaultAdapter: EditorAdapter { 17 | func encodeToString(type model: T) throws -> String? { 18 | do { 19 | let jsonData = try JSONEncoder().encode(model) 20 | let jsonString = String(data: jsonData, encoding: .utf8) 21 | return jsonString 22 | } catch { 23 | throw error 24 | } 25 | } 26 | 27 | func decode(from jsonString: String) throws -> T? { 28 | guard let data = jsonString.data(using: .utf8) else { return nil } 29 | do { 30 | let content = try JSONDecoder().decode(T.self, from: data) 31 | return content 32 | } catch { 33 | throw error 34 | } 35 | } 36 | 37 | func encode(type model: T) throws -> Data where T: Encodable { 38 | return try JSONEncoder().encode(model) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/RichEditorStateFocusedValueKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorStateFocusedValueKey.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 25/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichEditorState { 11 | 12 | /// This key can be used to keep track of a context in a 13 | /// multi-windowed app. 14 | public struct FocusedValueKey: SwiftUI.FocusedValueKey { 15 | 16 | public typealias Value = RichEditorState 17 | } 18 | } 19 | 20 | extension FocusedValues { 21 | 22 | /// This value can be used to keep track of a context in 23 | /// a multi-windowed app. 24 | /// 25 | /// You can bind a context to a view with `focusedValue`: 26 | /// 27 | /// ```swift 28 | /// RichTextEditor(...) 29 | /// .focusedValue(\.richTextContext, richTextContext) 30 | /// ``` 31 | /// 32 | /// You can then access the context as a `@FocusedValue`: 33 | /// 34 | /// ```swift 35 | /// @FocusedValue(\.richEditorState) 36 | /// var richEditorState: RichEditorState? 37 | /// ``` 38 | public var richEditorState: RichEditorState.FocusedValueKey.Value? { 39 | get { self[RichEditorState.FocusedValueKey.self] } 40 | set { self[RichEditorState.FocusedValueKey.self] = newValue } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /RichEditorDemo/RichEditorDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Context/RichEditorState+TextAlignment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorState+TextAlignment.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichEditorState { 11 | 12 | /// Get a binding for a certain TextAlignment style. 13 | public func textAlignmentBinding() -> Binding { 14 | Binding( 15 | get: { self.currentTextAlignment() }, 16 | set: { self.setTextAlignmentStyle($0) } 17 | ) 18 | } 19 | 20 | /// Check whether or not the context has a certain TextAlignment style. 21 | public func currentTextAlignment() -> RichTextAlignment { 22 | return textAlignment 23 | } 24 | 25 | /// Set whether or not the context has a certain TextAlignment style. 26 | public func setTextAlignmentStyle( 27 | _ alignment: RichTextAlignment 28 | ) { 29 | guard alignment != textAlignment else { return } 30 | updateStyle(style: alignment.getTextSpanStyle()) 31 | setTextAlignmentInternal(alignment: alignment) 32 | } 33 | 34 | public func setTextAlignmentInternal( 35 | alignment: RichTextAlignment 36 | ) { 37 | guard alignment != textAlignment else { return } 38 | textAlignment = alignment 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Extensions/NSRange+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSRange+Extension.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 11/12/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSRange { 11 | var isCollapsed: Bool { 12 | return self.length == 0 || self.upperBound == self.lowerBound 13 | } 14 | 15 | var closedRange: ClosedRange { 16 | return lowerBound...(upperBound - (length > 0 ? 1 : 0)) 17 | } 18 | } 19 | 20 | extension ClosedRange { 21 | var nsRange: NSRange { 22 | return NSRange(location: lowerBound, length: upperBound - lowerBound) 23 | } 24 | 25 | func isInRange(_ range: ClosedRange) -> Bool { 26 | return range.contains(self.lowerBound) 27 | && range.contains(self.upperBound) 28 | } 29 | 30 | func isPartialOverlap(_ range: ClosedRange) -> Bool { 31 | return self.contains(range.lowerBound) 32 | != self.contains(range.upperBound) 33 | } 34 | 35 | func isSameAs(_ range: ClosedRange) -> Bool { 36 | return (self.lowerBound == range.lowerBound) 37 | && (self.upperBound == range.upperBound) 38 | } 39 | } 40 | 41 | extension Range { 42 | var nsRange: NSRange { 43 | return NSRange(location: lowerBound, length: upperBound - lowerBound) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Alignment/RichTextAlignment+Picker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextAlignment+Picker.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextAlignment { 11 | 12 | /// This picker can be used to pick a text alignment. 13 | /// 14 | /// This view returns a plain SwiftUI `Picker` view that 15 | /// can be styled and configured with a `PickerStyle`. 16 | public struct Picker: View { 17 | 18 | /// Create a rich text alignment picker. 19 | /// 20 | /// - Parameters: 21 | /// - selection: The binding to update with the picker. 22 | /// - values: The pickable alignments, by default `.allCases`. 23 | public init( 24 | selection: Binding, 25 | values: [RichTextAlignment] = RichTextAlignment.allCases 26 | ) { 27 | self._selection = selection 28 | self.values = values 29 | } 30 | 31 | let values: [RichTextAlignment] 32 | 33 | @Binding 34 | private var selection: RichTextAlignment 35 | 36 | public var body: some View { 37 | SwiftUI.Picker(RTEL10n.textAlignment.text, selection: $selection) { 38 | ForEach(values) { value in 39 | value.label 40 | .labelStyle(.iconOnly) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/FontTraitsRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontTraitsRepresentable.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 28/12/23. 6 | // 7 | 8 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 9 | import AppKit 10 | 11 | /// This typealias bridges platform-specific symbolic traits to 12 | /// simplify multi-platform support. 13 | /// 14 | /// The typealias also defines additional functionality as type 15 | /// extensions for the platform-specific types. 16 | public typealias FontTraitsRepresentable = NSFontDescriptor.SymbolicTraits 17 | #endif 18 | 19 | #if canImport(UIKit) 20 | import UIKit 21 | 22 | /// This typealias bridges platform-specific symbolic traits to 23 | /// simplify multi-platform support. 24 | /// 25 | /// The typealias also defines additional functionality as type 26 | /// extensions for the platform-specific types. 27 | public typealias FontTraitsRepresentable = UIFontDescriptor.SymbolicTraits 28 | #endif 29 | 30 | extension FontTraitsRepresentable { 31 | 32 | /** 33 | Get the rich text styles that are enabled in the traits. 34 | 35 | Note that the traits only contain some of the available 36 | rich text styles. 37 | */ 38 | public var enabledRichTextStyles: [RichTextStyle] { 39 | RichTextStyle.allCases.filter { 40 | guard let trait = $0.symbolicTraits else { return false } 41 | return contains(trait) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Export/RichTextExportMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextExportMenu.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | /// This menu can be used to trigger various export actions for 12 | /// a list of ``RichTextDataFormat`` values. 13 | /// 14 | /// This menu uses a ``RichTextDataFormat/Menu`` configured for 15 | /// exporting, with customizable actions and data formats. 16 | public struct RichTextExportMenu: View { 17 | 18 | public init( 19 | title: String = RTEL10n.menuExportAs.text, 20 | icon: Image = .richTextExport, 21 | formats: [RichTextDataFormat] = RichTextDataFormat.libraryFormats, 22 | otherFormats: [RichTextExportOption] = .all, 23 | formatAction: @escaping (RichTextDataFormat) -> Void, 24 | otherOptionAction: ((RichTextExportOption) -> Void)? = nil 25 | ) { 26 | self.menu = RichTextDataFormat.Menu( 27 | title: title, 28 | icon: icon, 29 | formats: formats, 30 | otherFormats: otherFormats, 31 | formatAction: formatAction, 32 | otherOptionAction: otherOptionAction 33 | ) 34 | } 35 | 36 | private let menu: RichTextDataFormat.Menu 37 | 38 | public var body: some View { 39 | menu 40 | } 41 | } 42 | #endif 43 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | # This workflow builds and tests the project. 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Build Runner 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | env: 13 | SCHEME: RichEditorSwiftUI 14 | TEST_SCHEME: RichEditorSwiftUITests 15 | 16 | jobs: 17 | build: 18 | runs-on: macos-15 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: maxim-lobanov/setup-xcode@v1 22 | with: 23 | xcode-version: '16.0' 24 | - name: Build iOS 25 | run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=iOS' | xcpretty --color; 26 | - name: Build macOS 27 | run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=OS X' | xcpretty --color; 28 | - name: Build tvOS 29 | run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=tvOS' | xcpretty --color; 30 | - name: Build watchOS 31 | run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=watchOS' | xcpretty --color; 32 | - name: Build visionOS 33 | run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=xrOS' | xcpretty --color; 34 | - name: Test iOS 35 | run: xcodebuild test -scheme $SCHEME -derivedDataPath .build -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.0' -enableCodeCoverage YES | xcpretty --color; 36 | -------------------------------------------------------------------------------- /RichEditorDemo/RichEditorDemo/JsonUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JsonUtils.swift 3 | // RichEditorDemo 4 | // 5 | // Created by Divyesh Vekariya on 05/01/24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal func readJSONFromFile( 11 | fileName: String, 12 | type: T.Type, 13 | bundle: Bundle? = nil 14 | ) -> T? { 15 | if let url = (bundle ?? Bundle.main) 16 | .url(forResource: fileName, withExtension: "json") 17 | { 18 | do { 19 | let data = try Data(contentsOf: url) 20 | let decoder = JSONDecoder() 21 | let jsonData = try decoder.decode(T.self, from: data) 22 | return jsonData 23 | } catch { 24 | print("JSONUtils: error - \(error)") 25 | } 26 | } 27 | return nil 28 | } 29 | 30 | internal class RichBundleFakeClass {} 31 | 32 | extension Bundle { 33 | static var richBundle: Bundle { 34 | return Bundle(for: RichBundleFakeClass.self) 35 | } 36 | } 37 | 38 | func encode(model: T?) throws -> String? { 39 | guard let model else { return nil } 40 | do { 41 | let jsonData = try JSONEncoder().encode(model) 42 | let jsonString = String(data: jsonData, encoding: .utf8) 43 | return jsonString 44 | } catch { 45 | throw error 46 | } 47 | } 48 | 49 | func decode(json string: String) throws -> T? { 50 | guard let data = string.data(using: .utf8) else { return nil } 51 | do { 52 | let content = try JSONDecoder().decode(T.self, from: data) 53 | return content 54 | } catch { 55 | throw error 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Colors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextViewComponent+Colors.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RichTextViewComponent { 11 | 12 | /// Get a certain color. 13 | public func richTextColor( 14 | _ color: RichTextColor 15 | ) -> ColorRepresentable? { 16 | guard let attribute = color.attribute else { return nil } 17 | return richTextAttribute(attribute) 18 | } 19 | 20 | /// Get a certain color at a certain range. 21 | public func richTextColor( 22 | _ color: RichTextColor, 23 | at range: NSRange 24 | ) -> ColorRepresentable? { 25 | guard let attribute = color.attribute else { return nil } 26 | return richTextAttribute(attribute, at: range) 27 | } 28 | 29 | /// Set a certain color. 30 | public func setRichTextColor( 31 | _ color: RichTextColor, 32 | to val: ColorRepresentable 33 | ) { 34 | if richTextColor(color) == val { return } 35 | guard let attribute = color.attribute else { return } 36 | setRichTextAttribute(attribute, to: val) 37 | } 38 | 39 | /// Set a certain colors at a certain range. 40 | public func setRichTextColor( 41 | _ color: RichTextColor, 42 | to val: ColorRepresentable, 43 | at range: NSRange 44 | ) { 45 | guard let attribute = color.attribute else { return } 46 | if richTextColor(color, at: range) == val { return } 47 | setRichTextAttribute(attribute, to: val, at: range) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Theme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextView+Theme.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextView { 12 | 13 | /** 14 | This type can be used to configure a ``RichTextEditor``'s current color properties. 15 | */ 16 | public struct Theme { 17 | 18 | /** 19 | Create a custom configuration. 20 | 21 | - Parameters: 22 | - font: default `.systemFont` of point size `16` (this differs on iOS and macOS). 23 | - fontColor: default `.textColor`. 24 | - backgroundColor: Color of whole textView default `.clear`. 25 | */ 26 | public init( 27 | font: FontRepresentable = .systemFont(ofSize: 16), 28 | fontColor: ColorRepresentable = .textColor, 29 | backgroundColor: ColorRepresentable = .clear 30 | ) { 31 | self.font = font 32 | self.fontColor = fontColor 33 | self.backgroundColor = backgroundColor 34 | } 35 | 36 | public let font: FontRepresentable 37 | public let fontColor: ColorRepresentable 38 | public let backgroundColor: ColorRepresentable 39 | } 40 | } 41 | 42 | extension RichTextView.Theme { 43 | 44 | /// The standard rich text view theme. 45 | /// 46 | /// You can set a new value to change the global default. 47 | public static var standard = Self() 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Pdf/PdfPageConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PdfPageConfiguration.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import CoreGraphics 9 | 10 | /// This error can be thrown when creating PDF data. 11 | public struct PdfPageConfiguration: Equatable { 12 | 13 | /** 14 | Create a PDF page configuration. 15 | 16 | - Parameters: 17 | - pageSize: The page size in points. 18 | - pageMargins: The page margins, by default `72`. 19 | */ 20 | public init( 21 | pageSize: CGSize = CGSize(width: 595.2, height: 841.8), 22 | pageMargins: PdfPageMargins = .init(all: 72) 23 | ) { 24 | self.pageSize = pageSize 25 | self.pageMargins = pageMargins 26 | } 27 | 28 | /// The page size in points. 29 | public var pageSize: CGSize 30 | 31 | /// The page margins. 32 | public var pageMargins: PdfPageMargins 33 | } 34 | 35 | extension PdfPageConfiguration { 36 | 37 | /// The standard PDF page configuration. 38 | public static var standard: Self { .init() } 39 | } 40 | 41 | extension PdfPageConfiguration { 42 | 43 | /// Get the paper rectangle. 44 | public var paperRect: CGRect { 45 | CGRect(x: 0, y: 0, width: pageSize.width, height: pageSize.height) 46 | } 47 | 48 | /// Get the printable rectangle. 49 | public var printableRect: CGRect { 50 | CGRect( 51 | x: pageMargins.left, 52 | y: pageMargins.top, 53 | width: pageSize.width - pageMargins.left - pageMargins.right, 54 | height: pageSize.height - pageMargins.top - pageMargins.bottom) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorState+Styles.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichEditorState { 11 | 12 | /// Get a binding for a certain style. 13 | public func binding(for style: RichTextStyle) -> Binding { 14 | Binding( 15 | get: { Bool(self.hasStyle(style)) }, 16 | set: { [weak self] _ in self?.setStyle(style) } 17 | ) 18 | } 19 | 20 | /// Check whether or not the context has a certain style. 21 | public func hasStyle(_ style: RichTextStyle) -> Bool { 22 | styles[style] == true 23 | } 24 | 25 | /// Set whether or not the context has a certain style. 26 | public func setStyle( 27 | _ style: RichTextStyle, 28 | to val: Bool 29 | ) { 30 | guard styles[style] != val else { return } 31 | actionPublisher.send(.setStyle(style, val)) 32 | setStyleInternal(style, to: val) 33 | } 34 | 35 | /// Toggle a certain style for the context. 36 | public func toggleStyle(_ style: RichTextStyle) { 37 | setStyle(style, to: !hasStyle(style)) 38 | } 39 | 40 | public func setStyle(_ style: RichTextStyle) { 41 | toggleStyle(style: style.richTextSpanStyle) 42 | } 43 | } 44 | 45 | extension RichEditorState { 46 | 47 | /// Set the value for a certain color, or remove it. 48 | func setStyleInternal( 49 | _ style: RichTextStyle, 50 | to val: Bool? 51 | ) { 52 | guard let val else { 53 | styles[style] = nil 54 | return 55 | } 56 | styles[style] = val 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Link.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorState+Link.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 19/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) 11 | extension RichEditorState { 12 | func insertLink(value: Bool) { 13 | if link != nil { 14 | alertController.showAlert( 15 | title: "Remove link", message: "It will remove link from selected text", 16 | onOk: { [weak self] in 17 | guard let self else { return } 18 | self.updateStyle(style: .link()) 19 | }, 20 | onCancel: { 21 | return 22 | }) 23 | } else { 24 | alertController.showAlert( 25 | title: "Enter url", message: "", placeholder: "Enter link", 26 | defaultText: "", 27 | onTextChange: { text in 28 | }, 29 | completion: { [weak self] finalText in 30 | self?.updateStyle(style: .link(finalText)) 31 | }) 32 | } 33 | } 34 | } 35 | 36 | extension RichEditorState { 37 | 38 | /// Get a binding for a certain style. 39 | public func bindingForManu(for menu: RichTextOtherMenu) -> Binding { 40 | Binding( 41 | get: { Bool(self.hasStyle(menu)) }, 42 | set: { self.setLink(to: $0) } 43 | ) 44 | } 45 | 46 | /// Check whether or not the context has a certain style. 47 | public func hasStyle(_ style: RichTextOtherMenu) -> Bool { 48 | link != nil 49 | } 50 | 51 | /// Set whether or not the context has a certain style. 52 | public func setLink( 53 | to val: Bool 54 | ) { 55 | insertLink(value: val) 56 | } 57 | } 58 | #endif 59 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Export/RichTextExportUrlResolver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextExportUrlResolver.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This protocol can be implemented by types that can generate 11 | /// file urls, for instance when exporting rich text files. 12 | /// 13 | /// The protocol is implemented by `FileManager`, which is used 14 | /// by default by the library. 15 | public protocol RichTextExportUrlResolver { 16 | 17 | /** 18 | Try to generate a file url in a certain directory. 19 | 20 | - Parameters: 21 | - fileName: The preferred file name. 22 | - extensions: The file extension. 23 | - directory: The directory in which to generate an url. 24 | */ 25 | func fileUrl( 26 | withName fileName: String, 27 | extension: String, 28 | in directory: FileManager.SearchPathDirectory 29 | ) throws -> URL 30 | 31 | /** 32 | Try to generate a unique file url in a certain directory. 33 | 34 | - Parameters: 35 | - fileName: The preferred file name. 36 | - extensions: The file extension. 37 | - directory: The directory in which to generate an url. 38 | */ 39 | func uniqueFileUrl( 40 | withName fileName: String, 41 | extension: String, 42 | in directory: FileManager.SearchPathDirectory 43 | ) throws -> URL 44 | 45 | /** 46 | Get a unique url for the provided url, to ensure that a 47 | file with the same name doesn't exist. 48 | 49 | - Parameters: 50 | - url: The url to generate a unique url for. 51 | - separator: The separator to use for separating the counter. 52 | */ 53 | func uniqueUrl(for url: URL) -> URL 54 | } 55 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 19/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(iOS) || os(macOS) || os(visionOS) 11 | 12 | extension RichTextOtherMenu { 13 | 14 | /** 15 | This view can list ``RichTextOtherMenu/Toggle``s for a list 16 | of ``RichTextOtherMenu`` values, in a horizontal stack. 17 | 18 | Since this view uses multiple styles, it binds directly 19 | to a ``RichTextContext`` instead of individual values. 20 | */ 21 | public struct ToggleStack: View { 22 | 23 | /** 24 | Create a rich text style toggle button group. 25 | 26 | - Parameters: 27 | - context: The context to affect. 28 | - styles: The styles to list, by default ``RichTextOtherMenu/all``. 29 | - spacing: The spacing to apply to stack items, by default `5`. 30 | */ 31 | public init( 32 | context: RichEditorState, 33 | styles: [RichTextOtherMenu] = .all, 34 | spacing: Double = 5 35 | ) { 36 | self._context = ObservedObject(wrappedValue: context) 37 | self.styles = styles 38 | self.spacing = spacing 39 | } 40 | 41 | private let styles: [RichTextOtherMenu] 42 | private let spacing: Double 43 | 44 | @ObservedObject 45 | private var context: RichEditorState 46 | 47 | public var body: some View { 48 | HStack(spacing: spacing) { 49 | ForEach(styles) { 50 | RichTextOtherMenu.Toggle( 51 | style: $0, 52 | context: context, 53 | fillVertically: true 54 | ) 55 | } 56 | } 57 | .fixedSize(horizontal: false, vertical: true) 58 | } 59 | } 60 | } 61 | #endif 62 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Styles/RichTextAction+ButtonStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextAction+ButtonStack.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextAction { 11 | 12 | /** 13 | This view lists ``RichTextAction`` buttons in a stack. 14 | 15 | Since this view uses multiple values, it binds directly 16 | to a ``RichTextContext`` instead of individual values. 17 | */ 18 | public struct ButtonStack: View { 19 | 20 | /** 21 | Create a rich text action button stack. 22 | 23 | - Parameters: 24 | - context: The context to affect. 25 | - actions: The actions to list, by default all non-size actions. 26 | - spacing: The spacing to apply to stack items, by default `5`. 27 | */ 28 | public init( 29 | context: RichEditorState, 30 | actions: [RichTextAction], 31 | spacing: Double = 5 32 | ) { 33 | self._context = ObservedObject(wrappedValue: context) 34 | self.actions = actions 35 | self.spacing = spacing 36 | } 37 | 38 | private let actions: [RichTextAction] 39 | private let spacing: Double 40 | 41 | @ObservedObject 42 | private var context: RichEditorState 43 | 44 | public var body: some View { 45 | HStack(spacing: spacing) { 46 | ForEach(actions) { 47 | RichTextAction.Button( 48 | action: $0, 49 | context: context, 50 | fillVertically: true 51 | ) 52 | .frame(maxHeight: .infinity) 53 | } 54 | } 55 | .fixedSize(horizontal: false, vertical: true) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Headers/RichTextHeader+Picker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextHeader+Picker.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 25/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextHeader { 11 | 12 | /** 13 | This picker can be used to pick a Header type. 14 | 15 | The view returns a plain SwiftUI `Picker` view that can 16 | be styled and configured with plain SwiftUI. 17 | 18 | You can configure this picker by applying a config view 19 | modifier to your view hierarchy: 20 | 21 | ```swift 22 | VStack { 23 | RichTextHeader.HeaderTypePicker(...) 24 | ... 25 | } 26 | ``` 27 | */ 28 | public struct Picker: View { 29 | 30 | /** 31 | Create a font size picker. 32 | 33 | - Parameters: 34 | - selection: The selected font size. 35 | */ 36 | public init( 37 | context: RichEditorState, 38 | values: [HeaderType] 39 | ) { 40 | self._selection = context.headerBinding() 41 | self.values = values 42 | } 43 | 44 | @Binding 45 | private var selection: HeaderType 46 | 47 | private let values: [HeaderType] 48 | 49 | public var body: some View { 50 | SwiftUI.Picker("", selection: $selection) { 51 | ForEach( 52 | values, 53 | id: \.self 54 | ) { 55 | text(for: $0) 56 | .tag($0) 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | extension RichTextHeader.Picker { 64 | 65 | fileprivate func text( 66 | for headerType: HeaderType 67 | ) -> some View { 68 | Text(headerType.titleLabel) 69 | .fixedSize(horizontal: true, vertical: false) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Styles/RichTextStyle+ToggleStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextStyle+ToggleStack.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextStyle { 11 | 12 | /** 13 | This view can list ``RichTextStyle/Toggle``s for a list 14 | of ``RichTextStyle`` values, in a horizontal stack. 15 | 16 | Since this view uses multiple styles, it binds directly 17 | to a ``RichTextContext`` instead of individual values. 18 | */ 19 | public struct ToggleStack: View { 20 | 21 | /** 22 | Create a rich text style toggle button group. 23 | 24 | - Parameters: 25 | - context: The context to affect. 26 | - styles: The styles to list, by default ``RichTextStyle/all``. 27 | - spacing: The spacing to apply to stack items, by default `5`. 28 | */ 29 | public init( 30 | context: RichEditorState, 31 | styles: [RichTextStyle] = .all, 32 | spacing: Double = 5 33 | ) { 34 | self._context = ObservedObject(wrappedValue: context) 35 | self.styles = styles 36 | self.spacing = spacing 37 | } 38 | 39 | private let styles: [RichTextStyle] 40 | private let spacing: Double 41 | 42 | @ObservedObject 43 | private var context: RichEditorState 44 | 45 | public var body: some View { 46 | HStack(spacing: spacing) { 47 | ForEach(styles) { 48 | RichTextStyle.Toggle( 49 | style: $0, 50 | context: context, 51 | fillVertically: true 52 | ) 53 | } 54 | } 55 | .fixedSize(horizontal: false, vertical: true) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Actions/RichTextActionButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextActionButton.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextAction { 11 | 12 | /** 13 | This button can be used to trigger a ``RichTextAction``. 14 | 15 | This renders a plain `Button`, which means that you can 16 | use and configure it as a normal button. 17 | */ 18 | public struct Button: View { 19 | /** 20 | Create a rich text action button. 21 | 22 | - Parameters: 23 | - action: The action to trigger. 24 | - context: The context to affect. 25 | - fillVertically: WhetherP or not fill up vertical space, by default `false`. 26 | */ 27 | public init( 28 | action: RichTextAction, 29 | context: RichEditorState, 30 | fillVertically: Bool = false 31 | ) { 32 | self.action = action 33 | self._context = ObservedObject(wrappedValue: context) 34 | self.fillVertically = fillVertically 35 | } 36 | 37 | private let action: RichTextAction 38 | private let fillVertically: Bool 39 | 40 | @ObservedObject 41 | private var context: RichEditorState 42 | 43 | public var body: some View { 44 | SwiftUI.Button(action: triggerAction) { 45 | action.label 46 | .labelStyle(.iconOnly) 47 | .frame(maxHeight: fillVertically ? .infinity : nil) 48 | .contentShape(Rectangle()) 49 | } 50 | .keyboardShortcut(for: action) 51 | .disabled(!context.canHandle(action)) 52 | } 53 | } 54 | } 55 | 56 | extension RichTextAction.Button { 57 | 58 | fileprivate func triggerAction() { 59 | context.handle(action) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFormat+ToolbarStyle.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextFormat { 12 | 13 | /// This type can be used to style a format toolbar. 14 | public struct ToolbarStyle { 15 | 16 | public init( 17 | padding: Double = 10, 18 | spacing: Double = 10 19 | ) { 20 | self.padding = padding 21 | self.spacing = spacing 22 | } 23 | 24 | public var padding: Double 25 | public var spacing: Double 26 | } 27 | } 28 | 29 | extension RichTextFormat.ToolbarStyle { 30 | 31 | /// The standard rich text format toolbar style. 32 | public static var standard: Self { .init() } 33 | } 34 | 35 | extension View { 36 | 37 | /// Apply a rich text format toolbar style. 38 | public func richTextFormatToolbarStyle( 39 | _ style: RichTextFormat.ToolbarStyle 40 | ) -> some View { 41 | self.environment(\.richTextFormatToolbarStyle, style) 42 | } 43 | } 44 | 45 | extension RichTextFormat.ToolbarStyle { 46 | 47 | fileprivate struct Key: EnvironmentKey { 48 | 49 | public static var defaultValue: RichTextFormat.ToolbarStyle { 50 | .standard 51 | } 52 | } 53 | } 54 | 55 | extension EnvironmentValues { 56 | 57 | /// This value can bind to a format toolbar style. 58 | public var richTextFormatToolbarStyle: RichTextFormat.ToolbarStyle { 59 | get { self[RichTextFormat.ToolbarStyle.Key.self] } 60 | set { self[RichTextFormat.ToolbarStyle.Key.self] = newValue } 61 | } 62 | } 63 | #endif 64 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextViewComponent+Styles.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RichTextViewComponent { 11 | 12 | /// Get all styles. 13 | public var richTextStyles: [RichTextStyle] { 14 | let attributes = richTextAttributes 15 | let traits = richTextFont?.fontDescriptor.symbolicTraits 16 | var styles = traits?.enabledRichTextStyles ?? [] 17 | if attributes.isStrikethrough { styles.append(.strikethrough) } 18 | if attributes.isUnderlined { styles.append(.underline) } 19 | return styles 20 | } 21 | 22 | /// Whether or not the current range has a certain style. 23 | public func hasRichTextStyle(_ style: RichTextStyle) -> Bool { 24 | richTextStyles.contains(style) 25 | } 26 | 27 | /// Set a certain style. 28 | public func setRichTextStyle( 29 | _ style: RichTextStyle, 30 | to newValue: Bool 31 | ) { 32 | let value = newValue ? 1 : 0 33 | switch style { 34 | case .bold, .italic: 35 | let styles = richTextStyles 36 | guard styles.shouldAddOrRemove(style, newValue) else { return } 37 | guard let font = richTextFont else { return } 38 | guard let newFont = font.toggling(style) else { return } 39 | setRichTextFont(newFont) 40 | case .underline: 41 | setRichTextAttribute(.underlineStyle, to: value) 42 | case .strikethrough: 43 | setRichTextAttribute(.strikethroughStyle, to: value) 44 | } 45 | } 46 | 47 | /// Toggle a certain style. 48 | public func toggleRichTextStyle( 49 | _ style: RichTextStyle 50 | ) { 51 | let hasStyle = hasRichTextStyle(style) 52 | setRichTextStyle(style, to: !hasStyle) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextContext+Color.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichEditorState { 11 | 12 | /// Get a binding for a certain color. 13 | public func binding(for color: RichTextColor) -> Binding { 14 | Binding( 15 | get: { Color(self.color(for: color) ?? .clear) }, 16 | set: { self.updateStyleFor(color, to: .init($0)) } 17 | ) 18 | } 19 | 20 | /// Get the value for a certain color. 21 | public func color(for color: RichTextColor) -> ColorRepresentable? { 22 | colors[color] 23 | } 24 | 25 | /// Set the value for a certain color. 26 | public func setColor( 27 | _ color: RichTextColor, 28 | to val: ColorRepresentable 29 | ) { 30 | guard self.color(for: color) != val else { return } 31 | actionPublisher.send(.setColor(color, val)) 32 | setColorInternal(color, to: val) 33 | } 34 | 35 | public func updateStyleFor( 36 | _ color: RichTextColor, to val: ColorRepresentable 37 | ) { 38 | let value = Color(val) 39 | switch color { 40 | case .foreground: 41 | updateStyle(style: .color(value)) 42 | case .background: 43 | updateStyle(style: .background(value)) 44 | case .strikethrough: 45 | return 46 | case .stroke: 47 | return 48 | case .underline: 49 | return 50 | } 51 | } 52 | } 53 | 54 | extension RichEditorState { 55 | 56 | /// Set the value for a certain color, or remove it. 57 | func setColorInternal( 58 | _ color: RichTextColor, 59 | to val: ColorRepresentable? 60 | ) { 61 | guard let val else { 62 | colors[color] = nil 63 | return 64 | } 65 | colors[color] = val 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Link.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextViewComponent+Link.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/12/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RichTextViewComponent { 11 | /// Get the paragraph style. 12 | public var richTextLink: String? { 13 | richTextAttribute(.link) 14 | } 15 | 16 | /// Get a certain link. 17 | public func richTextLink( 18 | _ style: RichTextSpanStyle 19 | ) -> String? { 20 | return richTextAttribute(style.attributedStringKey) 21 | } 22 | 23 | /// Get a certain link at a certain range. 24 | public func richTextLink( 25 | _ style: RichTextSpanStyle, 26 | at range: NSRange 27 | ) -> String? { 28 | return richTextAttribute(style.attributedStringKey, at: range) 29 | } 30 | 31 | /// Set a certain link. 32 | public func setRichTextLink( 33 | _ style: RichTextSpanStyle 34 | ) { 35 | guard let val = style.getRichAttribute()?.link, 36 | richTextLink(style) != val 37 | else { return } 38 | setRichTextAttribute(style.attributedStringKey, to: val) 39 | 40 | } 41 | 42 | /// Set a certain link at a certain range. 43 | public func setRichTextLink( 44 | _ style: RichTextSpanStyle, 45 | at range: NSRange 46 | ) { 47 | let val = style.getRichAttribute()?.link 48 | guard let val = val, richTextLink(style, at: range) != val else { 49 | return 50 | } 51 | setRichTextAttribute(style.attributedStringKey, to: val, at: range) 52 | } 53 | 54 | public func removeRichTextLink(_ style: RichTextSpanStyle) { 55 | removeRichTextAttribute(style.attributedStringKey) 56 | } 57 | 58 | public func removeRichTextLink( 59 | _ style: RichTextSpanStyle, at range: NSRange 60 | ) { 61 | removeRichTextAttribute(style.attributedStringKey, at: range) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextViewComponent+Pasting.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | #if canImport(UIKit) 11 | import UIKit 12 | #endif 13 | 14 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 15 | import AppKit 16 | #endif 17 | 18 | extension RichTextViewComponent { 19 | 20 | /** 21 | Paste text into the text view, at a certain index. 22 | 23 | - Parameters: 24 | - text: The text to paste. 25 | - index: The text index to paste at. 26 | - moveCursorToPastedContent: Whether or not the input 27 | cursor should be moved to the end of the pasted content, 28 | by default `false`. 29 | */ 30 | public func pasteText( 31 | _ text: String, 32 | at index: Int, 33 | moveCursorToPastedContent: Bool = false 34 | ) { 35 | let selected = selectedRange 36 | let isSelectedRange = (index == selected.location) 37 | let content = NSMutableAttributedString(attributedString: richText) 38 | let insertString = NSMutableAttributedString(string: text) 39 | let insertRange = NSRange(location: index, length: 0) 40 | let safeInsertRange = safeRange(for: insertRange) 41 | let safeMoveIndex = safeInsertRange.location + insertString.length 42 | let attributes = content.richTextAttributes(at: safeInsertRange) 43 | let attributeRange = NSRange(location: 0, length: insertString.length) 44 | let safeAttributeRange = safeRange(for: attributeRange) 45 | insertString.setRichTextAttributes(attributes, at: safeAttributeRange) 46 | content.insert(insertString, at: index) 47 | setRichText(content) 48 | if moveCursorToPastedContent { 49 | moveInputCursor(to: safeMoveIndex) 50 | } else if isSelectedRange { 51 | moveInputCursor(to: selected.location + text.count) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePickerConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFont+SizePickerConfig.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextFont { 11 | 12 | /// This type can configure a ``RichTextFont/SizePicker``. 13 | public struct SizePickerConfig { 14 | 15 | /// Create a custom font size picker config. 16 | /// 17 | /// - Parameters: 18 | /// - values: The values to display in the list, by default a standard list. 19 | public init( 20 | values: [CGFloat] = [ 21 | 10, 12, 14, 16, 18, 20, 22, 24, 28, 36, 48, 64, 72, 96, 144, 22 | ] 23 | ) { 24 | self.values = values 25 | } 26 | 27 | /// The values to display in the list. 28 | public var values: [CGFloat] 29 | } 30 | } 31 | 32 | extension RichTextFont.SizePickerConfig { 33 | 34 | /// The standard font size picker configuration. 35 | /// 36 | /// You can set a new value to change the global default. 37 | public static var standard = Self() 38 | } 39 | 40 | extension View { 41 | 42 | /// Apply a ``RichTextFont`` size picker configuration. 43 | public func richTextFontSizePickerConfig( 44 | _ config: RichTextFont.SizePickerConfig 45 | ) -> some View { 46 | self.environment(\.richTextFontSizePickerConfig, config) 47 | } 48 | } 49 | 50 | extension RichTextFont.SizePickerConfig { 51 | 52 | fileprivate struct Key: EnvironmentKey { 53 | 54 | public static var defaultValue: RichTextFont.SizePickerConfig = 55 | .standard 56 | } 57 | } 58 | 59 | extension EnvironmentValues { 60 | 61 | /// This value can bind to a font size picker config. 62 | public var richTextFontSizePickerConfig: RichTextFont.SizePickerConfig { 63 | get { self[RichTextFont.SizePickerConfig.Key.self] } 64 | set { self[RichTextFont.SizePickerConfig.Key.self] = newValue } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/BaseFoundation/RichTextPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextPresenter.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This protocol can be implemented any types that can present 11 | /// a rich text and provide a ``selectedRange``. 12 | /// 13 | /// This protocol is implemented by ``RichTextEditor`` since it 14 | /// can both present and select text. It is also implemented by 15 | /// the platform-specific ``RichTextView`` components. 16 | public protocol RichTextPresenter: RichTextReader { 17 | 18 | /// Get the currently selected range. 19 | var selectedRange: NSRange { get } 20 | } 21 | 22 | extension RichTextPresenter { 23 | 24 | /// Whether or not the presenter has a selected range. 25 | public var hasSelectedRange: Bool { 26 | selectedRange.length > 0 27 | } 28 | 29 | /// Whether or not the rich text contains trimmed text. 30 | public var hasTrimmedText: Bool { 31 | let string = richText.string 32 | let trimmed = string.trimmingCharacters(in: .whitespaces) 33 | return !trimmed.isEmpty 34 | } 35 | 36 | /// Get the range after the input cursor. 37 | public var rangeAfterInputCursor: NSRange { 38 | let location = selectedRange.location 39 | let length = richText.length - location 40 | return NSRange(location: location, length: length) 41 | } 42 | 43 | /// Get the range before the input cursor. 44 | public var rangeBeforeInputCursor: NSRange { 45 | let location = selectedRange.location 46 | return NSRange(location: 0, length: location) 47 | } 48 | 49 | /// Get the rich text after the input cursor. 50 | public var richTextAfterInputCursor: NSAttributedString { 51 | richText(at: rangeAfterInputCursor) 52 | } 53 | 54 | /// Get the rich text before the input cursor. 55 | public var richTextBeforeInputCursor: NSAttributedString { 56 | richText(at: rangeBeforeInputCursor) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextOtherMenu.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 19/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public enum RichTextOtherMenu: String, CaseIterable, Identifiable, 11 | RichTextLabelValue 12 | { 13 | case link 14 | 15 | } 16 | 17 | extension RichTextOtherMenu { 18 | 19 | /// All available rich text styles. 20 | public static var all: [Self] { allCases } 21 | } 22 | 23 | extension Collection where Element == RichTextOtherMenu { 24 | 25 | /// All available rich text styles. 26 | public static var all: [RichTextOtherMenu] { RichTextOtherMenu.allCases } 27 | } 28 | 29 | extension RichTextOtherMenu { 30 | 31 | public var id: String { rawValue } 32 | 33 | /// The standard icon to use for the trait. 34 | public var icon: Image { 35 | switch self { 36 | case .link: .richTextLink 37 | } 38 | } 39 | 40 | /// The localized style title. 41 | public var title: String { 42 | titleKey.text 43 | } 44 | 45 | /// The localized style title key. 46 | public var titleKey: RTEL10n { 47 | switch self { 48 | case .link: .link 49 | } 50 | } 51 | } 52 | 53 | extension Collection where Element == RichTextOtherMenu { 54 | 55 | /// Check if the collection contains a certain style. 56 | public func hasStyle(_ style: RichTextOtherMenu) -> Bool { 57 | contains(style) 58 | } 59 | 60 | /// Check if a certain style change should be applied. 61 | public func shouldAddOrRemove( 62 | _ style: RichTextOtherMenu, 63 | _ newValue: Bool 64 | ) -> Bool { 65 | let shouldAdd = newValue && !hasStyle(style) 66 | let shouldRemove = !newValue && hasStyle(style) 67 | return shouldAdd || shouldRemove 68 | } 69 | } 70 | 71 | extension RichTextOtherMenu { 72 | func richTextSpanStyle() -> RichTextSpanStyle { 73 | switch self { 74 | case .link: .link() 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Attributes/RichTextWriter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextWriter.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 28/12/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This protocol extends ``RichTextReader`` and is implemented 11 | /// by types that can provide a writable rich text string. 12 | /// 13 | /// This protocol is implemented by `NSMutableAttributedString` 14 | /// as well as other types in the library. 15 | public protocol RichTextWriter: RichTextReader { 16 | 17 | /// Get the writable attributed string for the type. 18 | var mutableAttributedString: NSMutableAttributedString? { get } 19 | } 20 | 21 | extension NSMutableAttributedString: RichTextWriter { 22 | 23 | /// This type returns itself as the attributed string. 24 | public var mutableAttributedString: NSMutableAttributedString? { 25 | self 26 | } 27 | } 28 | 29 | extension RichTextWriter { 30 | 31 | /** 32 | Get the writable rich text provided by the implementing 33 | type. 34 | 35 | This is an alias for ``mutableAttributedString`` and is 36 | used to get a property that uses the rich text naming. 37 | */ 38 | public var mutableRichText: NSMutableAttributedString? { 39 | mutableAttributedString 40 | } 41 | 42 | /** 43 | Replace the text in a certain range with a new string. 44 | 45 | - Parameters: 46 | - range: The range to replace text in. 47 | - string: The string to replace the current text with. 48 | */ 49 | public func replaceText(in range: NSRange, with string: String) { 50 | mutableRichText?.replaceCharacters(in: range, with: string) 51 | } 52 | 53 | /** 54 | Replace the text in a certain range with a new string. 55 | 56 | - Parameters: 57 | - range: The range to replace text in. 58 | - string: The string to replace the current text with. 59 | */ 60 | public func replaceText(in range: NSRange, with string: NSAttributedString) 61 | { 62 | mutableRichText?.replaceCharacters(in: range, with: string) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Config_UIKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextView+Config_UIKit.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | #if os(iOS) || os(tvOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextView { 12 | 13 | /** 14 | This type can be used to configure a ``RichTextEditor``. 15 | */ 16 | public struct Configuration { 17 | 18 | /** 19 | Create a custom configuration. 20 | 21 | - Parameters: 22 | - isScrollingEnabled: Whether or not the editor should scroll, by default `true`. 23 | - allowsEditingTextAttributes: If editor allows editing text attributes, by default `true`. 24 | - autocapitalizationType: Type of Auto capitalization, default is to `.sentences`. 25 | - spellCheckingType: Whether textView spell-Checks, default is `.no`. 26 | */ 27 | public init( 28 | isScrollingEnabled: Bool = true, 29 | allowsEditingTextAttributes: Bool = true, 30 | autocapitalizationType: UITextAutocapitalizationType = 31 | .sentences, 32 | spellCheckingType: UITextSpellCheckingType = .no 33 | ) { 34 | self.isScrollingEnabled = isScrollingEnabled 35 | self.allowsEditingTextAttributes = allowsEditingTextAttributes 36 | self.autocapitalizationType = autocapitalizationType 37 | self.spellCheckingType = spellCheckingType 38 | } 39 | 40 | /// Whether or not the editor should scroll. 41 | public var isScrollingEnabled: Bool 42 | 43 | /// Whether textView allows editting text attributes 44 | public var allowsEditingTextAttributes: Bool 45 | 46 | /// Kind of auto capitalization 47 | public var autocapitalizationType: UITextAutocapitalizationType 48 | 49 | /// If TextView spell-checks the text. 50 | public var spellCheckingType: UITextSpellCheckingType 51 | } 52 | } 53 | #endif 54 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/RichEditorSwiftUITests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 19 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 46 | 47 | 49 | 50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Ranges.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextViewComponent+Ranges.swift 3 | // RichTextKit 4 | // 5 | // Created by Dominik Bucher 6 | // 7 | 8 | import Foundation 9 | 10 | extension RichTextViewComponent { 11 | 12 | var notFoundRange: NSRange { 13 | .init(location: NSNotFound, length: 0) 14 | } 15 | 16 | /// Get the line range at a certain text location. 17 | func lineRange(at location: Int) -> NSRange { 18 | #if os(watchOS) 19 | return notFoundRange 20 | #else 21 | guard 22 | let manager = layoutManagerWrapper, 23 | let storage = textStorageWrapper 24 | else { return NSRange(location: NSNotFound, length: 0) } 25 | let string = storage.string as NSString 26 | let locationRange = NSRange(location: location, length: 0) 27 | let lineRange = string.lineRange(for: locationRange) 28 | return manager.characterRange( 29 | forGlyphRange: lineRange, actualGlyphRange: nil) 30 | #endif 31 | } 32 | 33 | /// Get the line range for a certain text range. 34 | func lineRange(for range: NSRange) -> NSRange { 35 | #if os(watchOS) 36 | return notFoundRange 37 | #else 38 | // Use the location-based logic if range is empty 39 | if range.length == 0 { 40 | return lineRange(at: range.location) 41 | } 42 | 43 | guard let manager = layoutManagerWrapper else { 44 | return NSRange(location: NSNotFound, length: 0) 45 | } 46 | 47 | var lineRange = NSRange(location: NSNotFound, length: 0) 48 | manager.enumerateLineFragments( 49 | forGlyphRange: range 50 | ) { (_, _, _, glyphRange, stop) in 51 | lineRange = glyphRange 52 | stop.pointee = true 53 | } 54 | 55 | // Convert glyph range to character range 56 | return manager.characterRange( 57 | forGlyphRange: lineRange, actualGlyphRange: nil) 58 | #endif 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Actions/RichTextAction+KeyboardShortcutModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextAction+KeyboardShortcutModifier.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 30/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextAction { 11 | 12 | /** 13 | This view modifier can apply keyboard shortcuts for any 14 | ``RichTextAction`` to any view. 15 | 16 | You can also apply it with the `.keyboardShortcut(for:)` 17 | view modifier. 18 | */ 19 | public struct KeyboardShortcutModifier: ViewModifier { 20 | 21 | public init(_ action: RichTextAction) { 22 | self.action = action 23 | } 24 | 25 | private let action: RichTextAction 26 | 27 | public func body(content: Content) -> some View { 28 | content.keyboardShortcut(for: action) 29 | } 30 | } 31 | } 32 | 33 | extension View { 34 | 35 | /// Apply a ``RichTextAction/KeyboardShortcutModifier``. 36 | @ViewBuilder 37 | public func keyboardShortcut(for action: RichTextAction) -> some View { 38 | #if os(iOS) || os(macOS) || os(visionOS) 39 | switch action { 40 | case .copy: keyboardShortcut("c", modifiers: .command) 41 | case .dismissKeyboard: self 42 | case .print: keyboardShortcut("p", modifiers: .command) 43 | case .redoLatestChange: 44 | keyboardShortcut("z", modifiers: [.command, .shift]) 45 | // case .setAlignment(let align): keyboardShortcut(for: align) 46 | case .stepFontSize(let points): 47 | keyboardShortcut(points < 0 ? "-" : "+", modifiers: .command) 48 | case .stepIndent(let steps): 49 | keyboardShortcut(steps < 0 ? "Ö" : "Ä", modifiers: .command) 50 | case .stepSuperscript: self 51 | // case .toggleStyle(let style): keyboardShortcut(for: style) 52 | case .undoLatestChange: keyboardShortcut("z", modifiers: .command) 53 | default: self // TODO: Probably not defined, object to discuss. 54 | } 55 | #else 56 | self 57 | #endif 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/FontDescriptorRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontDescriptorRepresentable.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 17/01/24. 6 | // 7 | 8 | import Foundation 9 | 10 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) 11 | import AppKit 12 | 13 | /// This typealias bridges platform-specific font descriptors. 14 | /// 15 | /// The typealias also defines additional functionality as type 16 | /// extensions for the platform-specific types. 17 | public typealias FontDescriptorRepresentable = NSFontDescriptor 18 | 19 | extension FontDescriptorRepresentable { 20 | 21 | /// Get a new font descriptor by toggling a text style. 22 | public func byTogglingStyle(_ style: RichTextStyle) 23 | -> FontDescriptorRepresentable 24 | { 25 | guard let traits = style.symbolicTraits else { return self } 26 | if symbolicTraits.contains(traits) { 27 | return withSymbolicTraits(symbolicTraits.subtracting(traits)) 28 | } else { 29 | return withSymbolicTraits(symbolicTraits.union(traits)) 30 | } 31 | } 32 | } 33 | #endif 34 | 35 | #if canImport(UIKit) 36 | import UIKit 37 | 38 | /// This typealias bridges platform-specific font descriptors. 39 | /// 40 | /// The typealias also defines additional functionality as type 41 | /// extensions for the platform-specific types. 42 | public typealias FontDescriptorRepresentable = UIFontDescriptor 43 | 44 | extension FontDescriptorRepresentable { 45 | 46 | /// Get a new font descriptor by toggling a text style. 47 | public func byTogglingStyle(_ style: RichTextStyle) 48 | -> FontDescriptorRepresentable 49 | { 50 | guard let traits = style.symbolicTraits else { return self } 51 | if symbolicTraits.contains(traits) { 52 | return withSymbolicTraits(symbolicTraits.subtracting(traits)) 53 | ?? self 54 | } else { 55 | return withSymbolicTraits(symbolicTraits.union(traits)) ?? self 56 | } 57 | } 58 | } 59 | #endif 60 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Export/RichTextExportService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextExportService.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This protocol can be implemented by any classes that can be 11 | /// used to export rich text to files. 12 | @preconcurrency @MainActor 13 | public protocol RichTextExportService: AnyObject { 14 | 15 | /** 16 | Generate an export file with a certain name and content, 17 | that uses a certain rich text data format. 18 | 19 | - Parameters: 20 | - fileName: The preferred file name. 21 | - content: The rich text content to export. 22 | - format: The rich text format to use when exporting. 23 | */ 24 | func generateExportFile( 25 | withName fileName: String, 26 | content: NSAttributedString, 27 | format: RichTextDataFormat 28 | ) throws -> URL 29 | 30 | /** 31 | Generate a PDF export file with a certain name and rich 32 | text content. 33 | 34 | - Parameters: 35 | - fileName: The preferred file name. 36 | - content: The rich text content to export. 37 | */ 38 | func generatePdfExportFile( 39 | withName fileName: String, 40 | content: NSAttributedString 41 | ) throws -> URL 42 | 43 | /** 44 | Generate a JSON export file with a certain name and rich 45 | text content. 46 | 47 | - Parameters: 48 | - fileName: The preferred file name. 49 | - content: The rich text (`RichText`) content to export. 50 | */ 51 | func generateJsonExportFile( 52 | withName fileName: String, 53 | content: RichText 54 | ) throws -> URL 55 | 56 | /** 57 | Get `Data` for with provided `RichTextDataFormat` 58 | */ 59 | func getDataFor(_ string: NSAttributedString, format: RichTextDataFormat) 60 | throws -> Data 61 | 62 | /** 63 | Get `Data` for `PDF` format. 64 | */ 65 | func getDataForPdfFormat(_ string: NSAttributedString) throws -> Data 66 | /** 67 | Get `Data` for `JSON` format. 68 | */ 69 | func getDataForJsonFormat(_ richText: RichText) throws -> Data 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '30 19 * * 3' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 26 | permissions: 27 | # required for all workflows 28 | security-events: write 29 | 30 | # required to fetch internal or private CodeQL packs 31 | packages: read 32 | 33 | # only required for workflows in private repositories 34 | actions: read 35 | contents: read 36 | 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | include: 41 | - language: swift 42 | build-mode: autobuild 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v4 46 | 47 | # Initializes the CodeQL tools for scanning. 48 | - name: Initialize CodeQL 49 | uses: github/codeql-action/init@v3 50 | with: 51 | languages: ${{ matrix.language }} 52 | build-mode: ${{ matrix.build-mode }} 53 | - if: matrix.build-mode == 'manual' 54 | shell: bash 55 | run: | 56 | echo 'If you are using a "manual" build mode for one or more of the' \ 57 | 'languages you are analyzing, replace this with the commands to build' \ 58 | 'your code, for example:' 59 | echo ' make bootstrap' 60 | echo ' make release' 61 | exit 1 62 | 63 | - name: Perform CodeQL Analysis 64 | uses: github/codeql-action/analyze@v3 65 | with: 66 | category: "/language:${{matrix.language}}" 67 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/RichTextFont+ListPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFont+ListPicker.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextFont { 11 | 12 | /** 13 | This view uses a `List` to list a set of fonts of which 14 | one can be selected. 15 | 16 | Unlike ``RichTextFont/Picker`` this picker presents all 17 | pickers with proper previews on all platforms. You must 18 | therefore add it ina way that gives it space. 19 | 20 | You can configure this picker by applying a config view 21 | modifier to your view hierarchy: 22 | 23 | ```swift 24 | VStack { 25 | RichTextFont.ListPicker(...) 26 | ... 27 | } 28 | .richTextFontPickerConfig(...) 29 | ``` 30 | */ 31 | public struct ListPicker: View { 32 | 33 | /** 34 | Create a font list picker. 35 | 36 | - Parameters: 37 | - selection: The selected font name. 38 | */ 39 | public init( 40 | selection: Binding 41 | ) { 42 | self._selection = selection 43 | } 44 | 45 | public typealias Config = RichTextFont.PickerConfig 46 | public typealias Font = Config.Font 47 | public typealias FontName = Config.FontName 48 | 49 | @Binding 50 | private var selection: FontName 51 | 52 | @Environment(\.richTextFontPickerConfig) 53 | private var config 54 | 55 | public var body: some View { 56 | let font = Binding( 57 | get: { Font(fontName: selection) }, 58 | set: { selection = $0.fontName } 59 | ) 60 | 61 | RichEditorSwiftUI.ListPicker( 62 | items: config.fontsToList(for: selection), 63 | selection: font, 64 | dismissAfterPick: config.dismissAfterPick 65 | ) { font, isSelected in 66 | RichTextFont.PickerItem( 67 | font: font, 68 | fontSize: config.fontSize, 69 | isSelected: isSelected 70 | ) 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Attributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextViewComponent+Attributes.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RichTextViewComponent { 11 | 12 | /// Get all attributes. 13 | public var richTextAttributes: RichTextAttributes { 14 | if hasSelectedRange { 15 | return richTextAttributes(at: selectedRange) 16 | } 17 | 18 | #if os(macOS) 19 | let range = NSRange(location: selectedRange.location - 1, length: 1) 20 | let safeRange = safeRange(for: range) 21 | return richTextAttributes(at: safeRange) 22 | #else 23 | return typingAttributes 24 | #endif 25 | } 26 | 27 | /// Get a certain attribute. 28 | public func richTextAttribute( 29 | _ attribute: RichTextAttribute 30 | ) -> Value? { 31 | richTextAttributes[attribute] as? Value 32 | } 33 | 34 | /// Set a certain attribute. 35 | public func setRichTextAttribute( 36 | _ attribute: RichTextAttribute, 37 | to value: Any 38 | ) { 39 | if hasSelectedRange { 40 | setRichTextAttribute(attribute, to: value, at: selectedRange) 41 | } else { 42 | typingAttributes[attribute] = value 43 | } 44 | } 45 | 46 | /// Set certain attributes. 47 | public func setRichTextAttributes( 48 | _ attributes: RichTextAttributes 49 | ) { 50 | attributes.forEach { attribute, value in 51 | setRichTextAttribute(attribute, to: value) 52 | } 53 | } 54 | 55 | public func setNewRichTextAttributes( 56 | _ attributes: RichTextAttributes 57 | ) { 58 | typingAttributes = attributes 59 | } 60 | 61 | /// Remove a certain attribute. 62 | public func removeRichTextAttribute( 63 | _ attribute: RichTextAttribute 64 | ) { 65 | if hasSelectedRange { 66 | removeRichTextAttribute(attribute, at: selectedRange) 67 | } else { 68 | typingAttributes[attribute] = nil 69 | } 70 | } 71 | 72 | /// Remove certain attributes. 73 | public func removeRichTextAttributes( 74 | _ attributes: RichTextAttributes 75 | ) { 76 | attributes.forEach { attribute, value in 77 | removeRichTextAttribute(attribute) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing 3 | Thank you so much for your interest in contributing! All types of contributions are encouraged and valued. The Project Team looks forward to your contributions. 4 | 5 | ## Filing issues 6 | When in doubt, file an issue. We'd rather close a few duplicate issues than let a problem go unnoticed. 7 | Similarly, if you support a particular feature request, please let us know by commenting on the issue or [subscribing](https://help.github.com/articles/subscribing-to-conversations/) to the issue. 8 | 9 | If you are reporting a bug, please help speed up problem diagnosis by providing as much information as possible. Ideally, that would include a small sample project (or gist) that reproduces the problem. 10 | 11 | 12 | ## Contributing code 13 | We actively welcome your pull requests. You can find instructions on building the project in [README.md](https://github.com/canopas/rich-editor-swiftui). 14 | 1. Fork the repo and create your branch from `main`. 15 | 2. If you've added code that should be tested, add tests 16 | 4. Make sure your code lints. 17 | 18 | ## Labels 19 | Labels on issues are managed by contributors, you don't have to worry about them. Here's a list of what they mean: 20 | 21 | * **bug**: feature that should work, but doesn't 22 | * **enhancement**: minor tweak/addition to existing behaviour 23 | * **feature**: new behaviour, bigger than enhancement 24 | * **question**: no need of any fix, usually a usage problem 25 | * **reproducible**: has enough information to very easily reproduce, mostly in the form of a small project in a GitHub repo 26 | * **repro-needed**: we need some code to be able to reproduce and debug locally, otherwise there's not much we can do 27 | * **duplicate**: there's another issue which already covers/tracks this 28 | * **wontfix**: working as intended, or won't be fixed due to compatibility or other reasons 29 | * **invalid**: there isn't enough information to make a verdict, or unrelated 30 | * **non-library**: issue is not in the core library code, but rather in documentation, samples, build process, releases 31 | 32 | ## License 33 | By contributing to RichEditorSwiftUI, you agree that your contributions will be licensed under its Apache License, Version 2.0. See LICENSE file for details. 34 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Extensions/String+Characters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Characters.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 01/01/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String.Element { 11 | 12 | /// Get the string element for a `\r` carriage return. 13 | static var carriageReturn: String.Element { "\r" } 14 | 15 | /// Get the string element for a `\n` newline. 16 | static var newLine: String.Element { "\n" } 17 | 18 | /// Get the string element for a `\t` tab. 19 | static var tab: String.Element { "\t" } 20 | 21 | /// Get the string element for a ` ` space. 22 | static var space: String.Element { " " } 23 | } 24 | 25 | extension String { 26 | 27 | /// Get the string for a `\r` carriage return. 28 | static let carriageReturn = String(.carriageReturn) 29 | 30 | /// Get the string for a `\n` newline. 31 | static let newLine = String(.newLine) 32 | 33 | /// Get the string for a `\t` tab. 34 | static let tab = String(.tab) 35 | 36 | /// Get the string for a ` ` space. 37 | static let space = String(.space) 38 | } 39 | 40 | extension String { 41 | func getHeaderRangeFor(_ range: NSRange) -> NSRange { 42 | let text = self 43 | guard !text.isEmpty else { return range } 44 | 45 | let fromIndex = range.lowerBound 46 | let toIndex = range.isCollapsed ? fromIndex : range.upperBound 47 | 48 | let newLineStartIndex = 49 | text.utf16.prefix(fromIndex).map({ $0 }).lastIndex( 50 | of: "\n".utf16.last) ?? 0 51 | let newLineEndIndex = text.utf16.suffix( 52 | from: text.utf16.index( 53 | text.utf16.startIndex, offsetBy: max(0, toIndex - 1)) 54 | ).map({ $0 }).firstIndex(of: "\n".utf16.last) 55 | 56 | let shouldAddOneIndex = newLineStartIndex != 0 57 | let startIndex = min( 58 | max(0, self.utf16Length), 59 | max(0, newLineStartIndex + (shouldAddOneIndex ? 1 : 0))) 60 | var endIndex = (toIndex) + (newLineEndIndex ?? 0) 61 | 62 | if newLineEndIndex == nil { 63 | endIndex = (text.utf16Length) 64 | } 65 | 66 | let range = startIndex...endIndex 67 | return range.nsRange 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFont+SizePicker.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextFont { 11 | 12 | /** 13 | This picker can be used to pick a font size. 14 | 15 | The view returns a plain SwiftUI `Picker` view that can 16 | be styled and configured with plain SwiftUI. 17 | 18 | You can configure this picker by applying a config view 19 | modifier to your view hierarchy: 20 | 21 | ```swift 22 | VStack { 23 | RichTextFont.SizePicker(...) 24 | ... 25 | } 26 | .richTextFontSizePickerConfig(...) 27 | ``` 28 | */ 29 | public struct SizePicker: View { 30 | 31 | /** 32 | Create a font size picker. 33 | 34 | - Parameters: 35 | - selection: The selected font size. 36 | */ 37 | public init( 38 | selection: Binding 39 | ) { 40 | self._selection = selection 41 | } 42 | 43 | @Binding 44 | private var selection: CGFloat 45 | 46 | @Environment(\.richTextFontSizePickerConfig) 47 | private var config 48 | 49 | public var body: some View { 50 | SwiftUI.Picker("", selection: $selection) { 51 | ForEach( 52 | values( 53 | for: config.values, 54 | selection: selection 55 | ), id: \.self 56 | ) { 57 | text(for: $0) 58 | .tag($0) 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | extension RichTextFont.SizePicker { 66 | 67 | /// Get a list of values for a certain selection. 68 | public func values( 69 | for values: [CGFloat], 70 | selection: CGFloat 71 | ) -> [CGFloat] { 72 | let values = values + [selection] 73 | return Array(Set(values)).sorted() 74 | } 75 | } 76 | 77 | extension RichTextFont.SizePicker { 78 | 79 | fileprivate func text( 80 | for fontSize: CGFloat 81 | ) -> some View { 82 | Text("\(Int(fontSize))") 83 | .fixedSize(horizontal: true, vertical: false) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Views/ListPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListPicker.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This is an internal version of the original that is defined 11 | /// and available in https://github.com/danielsaidi/swiftuikit. 12 | /// This will not be made public or documented for this library. 13 | struct ListPicker: View { 14 | 15 | init( 16 | items: [Item], 17 | selection: Binding, 18 | animatedSelection: Bool = false, 19 | dismissAfterPick: Bool = true, 20 | listItem: @escaping ItemViewBuilder 21 | ) { 22 | self.init( 23 | sections: [ListPickerSection(title: "", items: items)], 24 | selection: selection, 25 | animatedSelection: animatedSelection, 26 | dismissAfterPick: dismissAfterPick, 27 | listItem: listItem) 28 | } 29 | 30 | init( 31 | sections: [ListPickerSection], 32 | selection: Binding, 33 | animatedSelection: Bool = false, 34 | dismissAfterPick: Bool = true, 35 | listItem: @escaping ItemViewBuilder 36 | ) { 37 | self.sections = sections 38 | self.selection = selection 39 | self.animatedSelection = animatedSelection 40 | self.dismissAfterPick = dismissAfterPick 41 | self.listItem = listItem 42 | } 43 | 44 | private let sections: [ListPickerSection] 45 | private let selection: Binding 46 | private let animatedSelection: Bool 47 | private let dismissAfterPick: Bool 48 | private let listItem: ItemViewBuilder 49 | 50 | typealias ItemViewBuilder = (_ item: Item, _ isSelected: Bool) -> ItemView 51 | 52 | var body: some View { 53 | List { 54 | ForEach(sections) { section in 55 | Section(header: section.header) { 56 | ForEachPicker( 57 | items: section.items, 58 | selection: selection, 59 | animatedSelection: animatedSelection, 60 | dismissAfterPick: dismissAfterPick, 61 | listItem: listItem 62 | ) 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Attributes/RichTextAttributeReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextAttributeReader.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 28/12/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This protocol extends ``RichTextReader`` with functionality 11 | /// for reading rich text attributes for the current rich text. 12 | /// 13 | /// The protocol is implemented by `NSAttributedString` as well 14 | /// as other types in the library. 15 | public protocol RichTextAttributeReader: RichTextReader {} 16 | 17 | extension NSAttributedString: RichTextAttributeReader {} 18 | 19 | extension RichTextAttributeReader { 20 | 21 | /// Get a rich text attribute at a certain range. 22 | public func richTextAttribute( 23 | _ attribute: RichTextAttribute, 24 | at range: NSRange 25 | ) -> Value? { 26 | richTextAttributes(at: range)[attribute] as? Value 27 | } 28 | 29 | /// Get all rich text attributes at a certain range. 30 | public func richTextAttributes( 31 | at range: NSRange 32 | ) -> RichTextAttributes { 33 | if richText.string.utf16Length == 0 { return [:] } 34 | let range = safeRange(for: range, isAttributeOperation: true) 35 | return richText.attributes(at: range.location, effectiveRange: nil) 36 | } 37 | } 38 | 39 | // RichTextAttributeReader+Font 40 | extension RichTextAttributeReader { 41 | 42 | /// Get the font at a certain range. 43 | public func richTextFont(at range: NSRange) -> FontRepresentable? { 44 | richTextAttribute(.font, at: range) 45 | } 46 | 47 | /// Get the font size (in points) at a certain range. 48 | public func richTextFontSize(at range: NSRange) -> CGFloat? { 49 | richTextFont(at: range)?.pointSize 50 | } 51 | } 52 | 53 | // RichTextAttributeReader+Style 54 | extension RichTextAttributeReader { 55 | 56 | /// Get the text styles at a certain range. 57 | public func richTextStyles(at range: NSRange) -> [RichTextSpanStyle] { 58 | let attributes = richTextAttributes(at: range) 59 | let traits = richTextFont(at: range)?.fontDescriptor.symbolicTraits 60 | var styles = traits?.enabledRichTextStyles ?? [] 61 | if attributes.isStrikethrough { styles.append(.strikethrough) } 62 | if attributes.isUnderlined { styles.append(.underline) } 63 | return styles.map({ $0.richTextSpanStyle }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Styles/RichTextStyle+ToggleGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextStyle+ToggleGroup.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/11/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextStyle { 12 | 13 | /** 14 | This view can list ``RichTextStyle/Toggle``s for a list 15 | of ``RichTextStyle`` values, in a bordered button group. 16 | 17 | Since this view uses multiple styles, it binds directly 18 | to a ``RichTextContext`` instead of individual values. 19 | 20 | > Important: Since the `ControlGroup` doesn't highlight 21 | buttons in iOS, we use a `ToggleStack` for iOS. 22 | */ 23 | public struct ToggleGroup: View { 24 | 25 | /** 26 | Create a rich text style toggle button group. 27 | 28 | - Parameters: 29 | - context: The context to affect. 30 | - styles: The styles to list, by default ``RichTextStyle/all``. 31 | - greedy: Whether or not the group is horizontally greedy, by default `true`. 32 | */ 33 | public init( 34 | context: RichEditorState, 35 | styles: [RichTextStyle] = .all, 36 | greedy: Bool = true 37 | ) { 38 | self._context = ObservedObject(wrappedValue: context) 39 | self.isGreedy = greedy 40 | self.styles = styles 41 | } 42 | 43 | private let styles: [RichTextStyle] 44 | private let isGreedy: Bool 45 | 46 | private var groupWidth: CGFloat? { 47 | if isGreedy { return nil } 48 | let count = Double(styles.count) 49 | #if os(macOS) 50 | return 30 * count 51 | #else 52 | return 50 * count 53 | #endif 54 | } 55 | 56 | @ObservedObject 57 | private var context: RichEditorState 58 | 59 | public var body: some View { 60 | #if os(macOS) 61 | ControlGroup { 62 | ForEach(styles) { 63 | RichTextStyle.Toggle( 64 | style: $0, 65 | context: context, 66 | fillVertically: true 67 | ) 68 | } 69 | } 70 | .frame(width: groupWidth) 71 | #else 72 | RichTextStyle.ToggleStack( 73 | context: context, 74 | styles: styles 75 | ) 76 | #endif 77 | } 78 | } 79 | } 80 | #endif 81 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Data/Models/RichTextSpanInternal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextSpanInternal.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 12/10/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct RichTextSpanInternal { 11 | public let id: String 12 | public let from: Int 13 | public let to: Int 14 | // public let insert: String 15 | public let attributes: RichAttributes? 16 | 17 | public init( 18 | id: String = UUID().uuidString, 19 | from: Int, 20 | to: Int, 21 | // insert: String, 22 | attributes: RichAttributes? = RichAttributes() 23 | ) { 24 | self.id = id 25 | self.from = from 26 | self.to = to 27 | // self.insert = insert 28 | self.attributes = attributes 29 | } 30 | } 31 | 32 | extension RichTextSpanInternal: Equatable { 33 | public static func == ( 34 | lhs: RichTextSpanInternal, 35 | rhs: RichTextSpanInternal 36 | ) -> Bool { 37 | return lhs.from == rhs.from 38 | && lhs.to == rhs.to 39 | // && lhs.insert == rhs.insert 40 | && lhs.attributes == rhs.attributes 41 | } 42 | } 43 | 44 | extension RichTextSpanInternal: Hashable { 45 | public func hash(into hasher: inout Hasher) { 46 | hasher.combine(from) 47 | hasher.combine(to) 48 | // hasher.combine(insert) 49 | hasher.combine(attributes) 50 | } 51 | } 52 | 53 | extension RichTextSpanInternal { 54 | public var spanRange: NSRange { 55 | let range = NSRange(location: from, length: max(((to - from) + 1), 0)) 56 | return range 57 | } 58 | 59 | public var closedRange: ClosedRange { 60 | return from...to 61 | } 62 | 63 | public var length: Int { 64 | return to - from 65 | } 66 | } 67 | 68 | extension RichTextSpanInternal { 69 | public func copy( 70 | from: Int? = nil, 71 | to: Int? = nil, 72 | // insert: String? = nil, 73 | attributes: RichAttributes? = nil 74 | ) -> RichTextSpanInternal { 75 | return RichTextSpanInternal( 76 | from: (from != nil ? from! : self.from), 77 | to: (to != nil ? to! : self.to), 78 | // insert: (insert != nil ? insert! : self.insert), 79 | attributes: (attributes != nil ? attributes! : self.attributes) 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Views/ForEachPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForEachPicker.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This is an internal version of the original that is defined 11 | /// and available in https://github.com/danielsaidi/swiftuikit. 12 | /// This will not be made public or documented for this library. 13 | struct ForEachPicker: View { 14 | 15 | init( 16 | items: [Item], 17 | selection: Binding, 18 | animatedSelection: Bool = false, 19 | dismissAfterPick: Bool = false, 20 | listItem: @escaping ItemViewBuilder 21 | ) { 22 | self.items = items 23 | self.selection = selection 24 | self.animatedSelection = animatedSelection 25 | self.dismissAfterPick = dismissAfterPick 26 | self.listItem = listItem 27 | } 28 | 29 | private let items: [Item] 30 | private let selection: Binding 31 | private let animatedSelection: Bool 32 | private let dismissAfterPick: Bool 33 | private let listItem: ItemViewBuilder 34 | 35 | typealias ItemViewBuilder = (_ item: Item, _ isSelected: Bool) -> ItemView 36 | 37 | @Environment(\.dismiss) 38 | var dismiss 39 | 40 | var body: some View { 41 | ForEach(items) { item in 42 | Button { 43 | select(item) 44 | } label: { 45 | listItem(item, isSelected(item)) 46 | } 47 | .buttonStyle(.plain) 48 | } 49 | } 50 | } 51 | 52 | extension ForEachPicker { 53 | 54 | fileprivate var selectedId: Item.ID { 55 | selection.wrappedValue.id 56 | } 57 | } 58 | 59 | extension ForEachPicker { 60 | 61 | fileprivate func isSelected(_ item: Item) -> Bool { 62 | selectedId == item.id 63 | } 64 | 65 | fileprivate func select(_ item: Item) { 66 | if animatedSelection { 67 | selectWithAnimation(item) 68 | } else { 69 | selectWithoutAnimation(item) 70 | } 71 | } 72 | 73 | fileprivate func selectWithAnimation(_ item: Item) { 74 | withAnimation { 75 | selectWithoutAnimation(item) 76 | } 77 | } 78 | 79 | fileprivate func selectWithoutAnimation(_ item: Item) { 80 | selection.wrappedValue = item 81 | if dismissAfterPick { 82 | dismiss() 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Data/Models/HeaderType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeaderType.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/04/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public enum HeaderType: Int, CaseIterable, Codable, Equatable, Identifiable, 12 | RichTextLabelValue 13 | { 14 | case `default` = 0 15 | case h1 = 1 16 | case h2 = 2 17 | case h3 = 3 18 | case h4 = 4 19 | case h5 = 5 20 | case h6 = 6 21 | 22 | var titleLabel: String { 23 | switch self { 24 | case .default: 25 | return "Body" 26 | case .h1: 27 | return "H1" 28 | case .h2: 29 | return "H2" 30 | case .h3: 31 | return "H3" 32 | case .h4: 33 | return "H4" 34 | case .h5: 35 | return "H5" 36 | case .h6: 37 | return "H6" 38 | } 39 | } 40 | 41 | func getTextSpanStyle() -> RichTextSpanStyle { 42 | switch self { 43 | case .default: return .default 44 | case .h1: return .h1 45 | case .h2: return .h2 46 | case .h3: return .h3 47 | case .h4: return .h4 48 | case .h5: return .h5 49 | case .h6: return .h6 50 | } 51 | } 52 | } 53 | 54 | extension Collection where Element == HeaderType { 55 | 56 | public static var all: [Element] { HeaderType.allCases } 57 | } 58 | 59 | extension HeaderType { 60 | 61 | /// The unique header ID. 62 | public var id: String { "\(rawValue)" } 63 | 64 | /// The standard icon to use for the header. 65 | public var icon: Image { 66 | switch self { 67 | case .default: .richTextHeaderDefault 68 | case .h1: .richTextHeader1 69 | case .h2: .richTextHeader2 70 | case .h3: .richTextHeader3 71 | case .h4: .richTextHeader4 72 | case .h5: .richTextHeader5 73 | case .h6: .richTextHeader6 74 | } 75 | } 76 | 77 | /// standard title to use for the headers. 78 | public var title: String { titleKey.text } 79 | 80 | /// The standard title key to use for the header. 81 | public var titleKey: RTEL10n { 82 | switch self { 83 | case .default: .headerDefault 84 | case .h1: .header1 85 | case .h2: .header2 86 | case .h3: .header3 87 | case .h4: .header4 88 | case .h5: .header5 89 | case .h6: .header6 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextOtherMenu+ToggleGroup.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 19/12/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextOtherMenu { 12 | 13 | /** 14 | This view can list ``RichTextOtherMenu/Toggle``s for a list 15 | of ``RichTextOtherMenu`` values, in a bordered button group. 16 | 17 | Since this view uses multiple styles, it binds directly 18 | to a ``RichTextContext`` instead of individual values. 19 | 20 | > Important: Since the `ControlGroup` doesn't highlight 21 | buttons in iOS, we use a `ToggleStack` for iOS. 22 | */ 23 | public struct ToggleGroup: View { 24 | 25 | /** 26 | Create a rich text style toggle button group. 27 | 28 | - Parameters: 29 | - context: The context to affect. 30 | - styles: The styles to list, by default ``RichTextOtherMenu/all``. 31 | - greedy: Whether or not the group is horizontally greedy, by default `true`. 32 | */ 33 | public init( 34 | context: RichEditorState, 35 | styles: [RichTextOtherMenu] = .all, 36 | greedy: Bool = true 37 | ) { 38 | self._context = ObservedObject(wrappedValue: context) 39 | self.isGreedy = greedy 40 | self.styles = styles 41 | } 42 | 43 | private let styles: [RichTextOtherMenu] 44 | private let isGreedy: Bool 45 | 46 | private var groupWidth: CGFloat? { 47 | if isGreedy { return nil } 48 | let count = Double(styles.count) 49 | #if os(macOS) 50 | return 30 * count 51 | #else 52 | return 50 * count 53 | #endif 54 | } 55 | 56 | @ObservedObject 57 | private var context: RichEditorState 58 | 59 | public var body: some View { 60 | #if os(macOS) 61 | ControlGroup { 62 | ForEach(styles) { 63 | RichTextOtherMenu.Toggle( 64 | style: $0, 65 | context: context, 66 | fillVertically: true 67 | ) 68 | } 69 | } 70 | .frame(width: groupWidth) 71 | #else 72 | RichTextOtherMenu.ToggleStack( 73 | context: context, 74 | styles: styles 75 | ) 76 | #endif 77 | } 78 | } 79 | } 80 | #endif 81 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Colors/RichTextColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextColor.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This enum defines supported rich text color types. 11 | /// 12 | /// The enum makes the colors identifiable and diffable. 13 | public enum RichTextColor: String, CaseIterable, Codable, Equatable, 14 | Identifiable 15 | { 16 | 17 | /// Foreground color. 18 | case foreground 19 | 20 | /// Background color. 21 | case background 22 | 23 | /// Strikethrough color. 24 | case strikethrough 25 | 26 | /// Stroke color. 27 | case stroke 28 | 29 | /// Underline color. 30 | case underline 31 | } 32 | 33 | extension RichTextColor { 34 | 35 | /// The unique color ID. 36 | public var id: String { rawValue } 37 | 38 | /// The corresponding rich text attribute, if any. 39 | public var attribute: NSAttributedString.Key? { 40 | switch self { 41 | case .foreground: .foregroundColor 42 | case .background: .backgroundColor 43 | case .strikethrough: .strikethroughColor 44 | case .stroke: .strokeColor 45 | case .underline: .underlineColor 46 | } 47 | } 48 | 49 | /// The standard icon to use for the color. 50 | public var icon: Image { 51 | switch self { 52 | case .foreground: .richTextColorForeground 53 | case .background: .richTextColorBackground 54 | case .strikethrough: .richTextColorStrikethrough 55 | case .stroke: .richTextColorStroke 56 | case .underline: .richTextColorUnderline 57 | } 58 | } 59 | 60 | /// The localized color title key. 61 | public var titleKey: RTEL10n { 62 | switch self { 63 | case .foreground: .foregroundColor 64 | case .background: .backgroundColor 65 | case .strikethrough: .strikethroughColor 66 | case .stroke: .strokeColor 67 | case .underline: .underlineColor 68 | } 69 | } 70 | 71 | /// Adjust a `color` for a certain `colorScheme`. 72 | public func adjust( 73 | _ color: Color?, 74 | for scheme: ColorScheme 75 | ) -> Color { 76 | switch self { 77 | case .background: color ?? .clear 78 | default: color ?? .primary 79 | } 80 | } 81 | } 82 | 83 | extension Collection where Element == RichTextColor { 84 | 85 | public static var allCases: [RichTextColor] { Element.allCases } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Button.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextOtherMenu+Button.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 19/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(iOS) || os(macOS) || os(visionOS) 11 | extension RichTextOtherMenu { 12 | 13 | /** 14 | This button can be used to toggle a ``RichTextOtherMenu``. 15 | 16 | This view renders a plain `Button`, which means you can 17 | use and configure with plain SwiftUI. 18 | */ 19 | public struct Button: View { 20 | 21 | /** 22 | Create a rich text style button. 23 | 24 | - Parameters: 25 | - style: The style to toggle. 26 | - value: The value to bind to. 27 | - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. 28 | */ 29 | public init( 30 | style: RichTextOtherMenu, 31 | value: Binding, 32 | fillVertically: Bool = false 33 | ) { 34 | self.style = style 35 | self.value = value 36 | self.fillVertically = fillVertically 37 | } 38 | 39 | /** 40 | Create a rich text style button. 41 | 42 | - Parameters: 43 | - style: The style to toggle. 44 | - context: The context to affect. 45 | - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. 46 | */ 47 | public init( 48 | style: RichTextOtherMenu, 49 | context: RichEditorState, 50 | fillVertically: Bool = false 51 | ) { 52 | self.init( 53 | style: style, 54 | value: context.bindingForManu(for: style), 55 | fillVertically: fillVertically 56 | ) 57 | } 58 | 59 | private let style: RichTextOtherMenu 60 | private let value: Binding 61 | private let fillVertically: Bool 62 | 63 | public var body: some View { 64 | SwiftUI.Button(action: toggle) { 65 | style.label 66 | .labelStyle(.iconOnly) 67 | .frame(maxHeight: fillVertically ? .infinity : nil) 68 | .contentShape(Rectangle()) 69 | } 70 | .tint(.accentColor, if: isOn) 71 | .foreground(.accentColor, if: isOn) 72 | // .keyboardShortcut(for: style) 73 | .accessibilityLabel(style.title) 74 | } 75 | } 76 | } 77 | 78 | extension RichTextOtherMenu.Button { 79 | 80 | fileprivate var isOn: Bool { 81 | value.wrappedValue 82 | } 83 | 84 | fileprivate func toggle() { 85 | value.wrappedValue.toggle() 86 | } 87 | } 88 | #endif 89 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+Toggle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextOtherMenu+Toggle.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 19/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(iOS) || os(macOS) || os(visionOS) 11 | extension RichTextOtherMenu { 12 | 13 | /** 14 | This toggle can be used to toggle a ``RichTextOtherMenu``. 15 | 16 | This view renders a plain `Toggle`, which means you can 17 | use and configure with plain SwiftUI. The one exception 18 | is the tint color, which is set with a style. 19 | */ 20 | public struct Toggle: View { 21 | 22 | /** 23 | Create a rich text style toggle toggle. 24 | 25 | - Parameters: 26 | - style: The style to toggle. 27 | - value: The value to bind to. 28 | - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. 29 | */ 30 | public init( 31 | style: RichTextOtherMenu, 32 | value: Binding, 33 | fillVertically: Bool = false 34 | ) { 35 | self.style = style 36 | self.value = value 37 | self.fillVertically = fillVertically 38 | } 39 | 40 | /** 41 | Create a rich text style toggle. 42 | 43 | - Parameters: 44 | - style: The style to toggle. 45 | - context: The context to affect. 46 | - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. 47 | */ 48 | public init( 49 | style: RichTextOtherMenu, 50 | context: RichEditorState, 51 | fillVertically: Bool = false 52 | ) { 53 | self.init( 54 | style: style, 55 | value: context.bindingForManu(for: style), 56 | fillVertically: fillVertically 57 | ) 58 | } 59 | 60 | private let style: RichTextOtherMenu 61 | private let value: Binding 62 | private let fillVertically: Bool 63 | 64 | public var body: some View { 65 | #if os(tvOS) || os(watchOS) 66 | toggle 67 | #else 68 | toggle.toggleStyle(.button) 69 | #endif 70 | } 71 | 72 | private var toggle: some View { 73 | SwiftUI.Toggle(isOn: value) { 74 | style.icon 75 | .frame(maxHeight: fillVertically ? .infinity : nil) 76 | } 77 | // .keyboardShortcut(for: style) 78 | .accessibilityLabel(style.title) 79 | } 80 | } 81 | } 82 | 83 | extension RichTextOtherMenu.Toggle { 84 | 85 | fileprivate var isOn: Bool { 86 | value.wrappedValue 87 | } 88 | } 89 | #endif 90 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Styles/RichTextStyle+Toggle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextStyle+Toggle.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextStyle { 11 | 12 | /** 13 | This toggle can be used to toggle a ``RichTextStyle``. 14 | 15 | This view renders a plain `Toggle`, which means you can 16 | use and configure with plain SwiftUI. The one exception 17 | is the tint color, which is set with a style. 18 | */ 19 | public struct Toggle: View { 20 | 21 | /** 22 | Create a rich text style toggle toggle. 23 | 24 | - Parameters: 25 | - style: The style to toggle. 26 | - value: The value to bind to. 27 | - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. 28 | */ 29 | public init( 30 | style: RichTextStyle, 31 | value: Binding, 32 | fillVertically: Bool = false 33 | ) { 34 | self.style = style 35 | self.value = value 36 | self.fillVertically = fillVertically 37 | } 38 | 39 | /** 40 | Create a rich text style toggle. 41 | 42 | - Parameters: 43 | - style: The style to toggle. 44 | - context: The context to affect. 45 | - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. 46 | */ 47 | public init( 48 | style: RichTextStyle, 49 | context: RichEditorState, 50 | fillVertically: Bool = false 51 | ) { 52 | self.init( 53 | style: style, 54 | value: context.binding(for: style), 55 | fillVertically: fillVertically 56 | ) 57 | } 58 | 59 | private let style: RichTextStyle 60 | private let value: Binding 61 | private let fillVertically: Bool 62 | 63 | public var body: some View { 64 | #if os(tvOS) || os(watchOS) 65 | toggle 66 | #else 67 | toggle.toggleStyle(.button) 68 | #endif 69 | } 70 | 71 | private var toggle: some View { 72 | SwiftUI.Toggle(isOn: value) { 73 | style.icon 74 | .frame(maxHeight: fillVertically ? .infinity : nil) 75 | } 76 | .keyboardShortcut(for: style) 77 | .accessibilityLabel(style.title) 78 | } 79 | } 80 | } 81 | 82 | extension RichTextStyle.Toggle { 83 | 84 | fileprivate var isOn: Bool { 85 | value.wrappedValue 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/ListStyle/ListType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListType.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 29/04/24. 6 | // 7 | 8 | import Foundation 9 | 10 | //public enum ListType: Codable, Identifiable, CaseIterable, Hashable { 11 | // public static var allCases: [ListType] = [.bullet()] 12 | // 13 | // public var id: String { 14 | // return key 15 | // } 16 | // 17 | // public func hash(into hasher: inout Hasher) { 18 | // hasher.combine(key) 19 | // hasher.combine(getIndent()) 20 | // } 21 | // 22 | // case bullet(_ indent: Int? = nil) 23 | // // case ordered(_ indent: Int? = nil) 24 | // 25 | // enum CodingKeys: String, CodingKey { 26 | // case bullet = "bullet" 27 | // // case ordered = "ordered" 28 | // } 29 | // 30 | // var key: String { 31 | // switch self { 32 | // case .bullet: 33 | // return "bullet" 34 | // // case .ordered: 35 | // // return "ordered" 36 | // } 37 | // } 38 | //} 39 | // 40 | //extension ListType { 41 | // func getTextSpanStyle() -> RichTextSpanStyle { 42 | // switch self { 43 | // case .bullet(let indent): 44 | // return .bullet(indent) 45 | // // case .ordered: 46 | // // return .ordered 47 | // } 48 | // } 49 | // 50 | // func getMarkerFormat() -> TextList.MarkerFormat { 51 | // switch self { 52 | // case .bullet: 53 | // return .disc 54 | // // case .ordered: 55 | // // return .decimal 56 | // } 57 | // } 58 | // 59 | // func getIndent() -> Int { 60 | // switch self { 61 | // case .bullet(let indent): 62 | // return indent ?? 0 63 | // // case .ordered(let indent): 64 | // // return indent ?? 0 65 | // } 66 | // } 67 | // 68 | // // func moveIndentForward() -> ListType { 69 | // // switch self { 70 | // // case .bullet(let indent): 71 | // // let newIndent = (indent ?? 0) + 1 72 | // // return .bullet(newIndent) 73 | // // } 74 | // // } 75 | // // 76 | // // func moveIndentBackward() -> ListType { 77 | // // switch self { 78 | // // case .bullet(let indent): 79 | // // let newIndent = max(0, ((indent ?? 0) - 1)) 80 | // // return .bullet(newIndent) 81 | // // } 82 | // // } 83 | //} 84 | // 85 | //#if canImport(UIKit) 86 | // import UIKit 87 | // 88 | // typealias TextList = NSTextList 89 | //#endif 90 | // 91 | //#if canImport(AppKit) 92 | // import AppKit 93 | // 94 | // typealias TextList = NSTextList 95 | //#endif 96 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/ExportData/RichTextDataFormat+Menu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextDataFormat+Menu.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextDataFormat { 12 | 13 | /** 14 | This menu can be used to trigger custom actions for any 15 | list of ``RichTextDataFormat`` values. 16 | 17 | The menu uses customizable actions, which means that it 18 | can be used in toolbars, menu bar commands etc. It also 19 | has an optional `pdf` action, which for instance can be 20 | used when exporting or sharing rich text. 21 | */ 22 | public struct Menu: View { 23 | 24 | public init( 25 | title: String, 26 | icon: Image, 27 | formats: [Format] = Format.libraryFormats, 28 | otherFormats: [RichTextExportOption] = .all, 29 | formatAction: @escaping (Format) -> Void, 30 | otherOptionAction: ((RichTextExportOption) -> Void)? = nil 31 | ) { 32 | self.title = title 33 | self.icon = icon 34 | self.formats = formats 35 | self.otherFormats = otherFormats 36 | self.formatAction = formatAction 37 | self.otherOptionAction = otherOptionAction 38 | } 39 | 40 | public typealias Format = RichTextDataFormat 41 | 42 | private let title: String 43 | private let icon: Image 44 | private let formats: [Format] 45 | private let otherFormats: [RichTextExportOption] 46 | private let formatAction: (Format) -> Void 47 | private let otherOptionAction: ((RichTextExportOption) -> Void)? 48 | 49 | public var body: some View { 50 | SwiftUI.Menu { 51 | ForEach(formats) { format in 52 | Button { 53 | formatAction(format) 54 | } label: { 55 | icon.label(format.fileFormatText) 56 | } 57 | } 58 | if let action = otherOptionAction { 59 | ForEach(otherFormats) { format in 60 | Button { 61 | action(format) 62 | } label: { 63 | icon.label(format.fileFormatText) 64 | } 65 | } 66 | } 67 | } label: { 68 | icon.label(title) 69 | } 70 | } 71 | } 72 | } 73 | #endif 74 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Subscriptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextCoordinator+Subscriptions.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextCoordinator { 12 | 13 | /// Subscribe to observable context state changes. 14 | /// 15 | /// The coordinator subscribes to both actions triggered 16 | /// by various buttons via the context, but also to some 17 | /// context value that are changed through view bindings. 18 | func subscribeToUserActions() { 19 | context.actionPublisher.sink { [weak self] action in 20 | self?.handle(action) 21 | } 22 | .store(in: &cancellables) 23 | 24 | subscribeToAlignment() 25 | subscribeToFontName() 26 | subscribeToFontSize() 27 | subscribeToIsEditable() 28 | subscribeToIsEditingText() 29 | subscribeToLineSpacing() 30 | } 31 | } 32 | 33 | extension RichTextCoordinator { 34 | 35 | fileprivate func subscribe( 36 | to publisher: Published.Publisher, 37 | action: @escaping (T) -> Void 38 | ) { 39 | publisher 40 | .sink(receiveValue: action) 41 | .store(in: &cancellables) 42 | } 43 | 44 | fileprivate func subscribeToAlignment() { 45 | subscribe(to: context.$textAlignment) { [weak self] in 46 | self?.handle(.setAlignment($0)) 47 | } 48 | } 49 | 50 | fileprivate func subscribeToFontName() { 51 | subscribe(to: context.$fontName) { [weak self] in 52 | self?.textView.setRichTextFontName($0) 53 | } 54 | } 55 | 56 | fileprivate func subscribeToFontSize() { 57 | subscribe(to: context.$fontSize) { [weak self] in 58 | self?.textView.setRichTextFontSize($0) 59 | } 60 | } 61 | 62 | fileprivate func subscribeToIsEditable() { 63 | subscribe(to: context.$isEditable) { [weak self] in 64 | self?.setIsEditable(to: $0) 65 | } 66 | } 67 | 68 | fileprivate func subscribeToIsEditingText() { 69 | subscribe(to: context.$isEditingText) { [weak self] in 70 | self?.setIsEditing(to: $0) 71 | } 72 | } 73 | 74 | // TODO: Not done yet 75 | fileprivate func subscribeToLineSpacing() { 76 | // subscribe(to: context.$lineSpacing) { [weak self] in 77 | // self?.textView.setRichTextLineSpacing($0) 78 | // } 79 | } 80 | } 81 | #endif 82 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar+Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextKeyboardToolbar+Config.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/10/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | /// This struct can configure a ``RichTextKeyboardToolbar``. 12 | public struct RichTextKeyboardToolbarConfig { 13 | 14 | /// Create a custom keyboard toolbar configuration. 15 | /// 16 | /// - Parameters: 17 | /// - alwaysDisplayToolbar: Whether or not to always show the toolbar, by default `false`. 18 | /// - leadingActions: The leading actions, by default `.undo` and `.redo`. 19 | /// - trailingActions: The trailing actions, by default `.dismissKeyboard`. 20 | public init( 21 | alwaysDisplayToolbar: Bool = false, 22 | leadingActions: [RichTextAction] = [.undo, .redo], 23 | trailingActions: [RichTextAction] = [.dismissKeyboard] 24 | ) { 25 | self.alwaysDisplayToolbar = alwaysDisplayToolbar 26 | self.leadingActions = leadingActions 27 | self.trailingActions = trailingActions 28 | } 29 | 30 | /// Whether or not to always show the toolbar. 31 | public var alwaysDisplayToolbar: Bool 32 | 33 | /// The leading toolbar actions. 34 | public var leadingActions: [RichTextAction] 35 | 36 | /// The trailing toolbar actions. 37 | public var trailingActions: [RichTextAction] 38 | } 39 | 40 | extension RichTextKeyboardToolbarConfig { 41 | 42 | /// The standard rich text keyboard toolbar config. 43 | /// 44 | /// You can override this to change the global default. 45 | public static var standard = RichTextKeyboardToolbarConfig() 46 | } 47 | 48 | extension View { 49 | 50 | /// Apply a ``RichTextKeyboardToolbar`` configuration. 51 | public func richTextKeyboardToolbarConfig( 52 | _ config: RichTextKeyboardToolbarConfig 53 | ) -> some View { 54 | self.environment(\.richTextKeyboardToolbarConfig, config) 55 | } 56 | } 57 | 58 | extension RichTextKeyboardToolbarConfig { 59 | 60 | fileprivate struct Key: EnvironmentKey { 61 | 62 | public static var defaultValue: RichTextKeyboardToolbarConfig = 63 | .standard 64 | } 65 | } 66 | 67 | extension EnvironmentValues { 68 | 69 | /// This value can bind to a keyboard toolbar config. 70 | public var richTextKeyboardToolbarConfig: RichTextKeyboardToolbarConfig 71 | { 72 | get { self[RichTextKeyboardToolbarConfig.Key.self] } 73 | set { self[RichTextKeyboardToolbarConfig.Key.self] = newValue } 74 | } 75 | } 76 | #endif 77 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar+Style.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextKeyboardToolbar+Style.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/10/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | /// This struct can style a ``RichTextKeyboardToolbar``. 12 | public struct RichTextKeyboardToolbarStyle { 13 | 14 | /// Create a custom toolbar style 15 | /// 16 | /// - Parameters: 17 | /// - toolbarHeight: The height of the toolbar, by default `50`. 18 | /// - itemSpacing: The spacing between toolbar items, by default `15`. 19 | /// - shadowColor: The toolbar's shadow color, by default transparent black. 20 | /// - shadowRadius: The toolbar's shadow radius, by default `3`. 21 | public init( 22 | toolbarHeight: Double = 50, 23 | itemSpacing: Double = 15, 24 | shadowColor: Color = .black.opacity(0.1), 25 | shadowRadius: Double = 3 26 | ) { 27 | self.toolbarHeight = toolbarHeight 28 | self.itemSpacing = itemSpacing 29 | self.shadowColor = shadowColor 30 | self.shadowRadius = shadowRadius 31 | } 32 | 33 | /// The height of the toolbar. 34 | public var toolbarHeight: Double 35 | 36 | /// The spacing between toolbar items. 37 | public var itemSpacing: Double 38 | 39 | /// The toolbar's shadow color. 40 | public var shadowColor: Color 41 | 42 | /// The toolbar's shadow radius. 43 | public var shadowRadius: Double 44 | } 45 | 46 | extension RichTextKeyboardToolbarStyle { 47 | 48 | /// The standard rich text keyboard toolbar style. 49 | /// 50 | /// You can set a new value to change the global default. 51 | public static var standard = Self() 52 | } 53 | 54 | extension View { 55 | 56 | /// Apply a ``RichTextKeyboardToolbar`` style. 57 | public func richTextKeyboardToolbarStyle( 58 | _ style: RichTextKeyboardToolbarStyle 59 | ) -> some View { 60 | self.environment(\.richTextKeyboardToolbarStyle, style) 61 | } 62 | } 63 | 64 | extension RichTextKeyboardToolbarStyle { 65 | 66 | fileprivate struct Key: EnvironmentKey { 67 | 68 | static var defaultValue: RichTextKeyboardToolbarStyle = .standard 69 | } 70 | } 71 | 72 | extension EnvironmentValues { 73 | 74 | /// This value can bind to a keyboard toolbar style. 75 | public var richTextKeyboardToolbarStyle: RichTextKeyboardToolbarStyle { 76 | get { self[RichTextKeyboardToolbarStyle.Key.self] } 77 | set { self[RichTextKeyboardToolbarStyle.Key.self] = newValue } 78 | } 79 | } 80 | 81 | #endif 82 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Helper/RichTextReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextReader.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 28/12/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This protocol can be implemented any types that can provide 11 | /// a rich text string. 12 | /// 13 | /// The protocol is implemented by `NSAttributedString` as well 14 | /// as other types in the library. 15 | public protocol RichTextReader { 16 | 17 | /// The attributed string to use as rich text. 18 | var attributedString: NSAttributedString { get } 19 | } 20 | 21 | extension NSAttributedString: RichTextReader { 22 | 23 | /// This type returns itself as the attributed string. 24 | public var attributedString: NSAttributedString { self } 25 | } 26 | 27 | extension RichTextReader { 28 | 29 | /** 30 | The rich text to use. 31 | 32 | This is a convenience name alias for ``attributedString`` 33 | to provide this type with a property that uses the rich 34 | text naming convention. 35 | */ 36 | public var richText: NSAttributedString { 37 | attributedString 38 | } 39 | 40 | /** 41 | Get the range of the entire ``richText``. 42 | 43 | This uses `safeRange(for:)` to return a range that willö 44 | always be valid for the current rich text. 45 | */ 46 | public var richTextRange: NSRange { 47 | let range = NSRange(location: 0, length: richText.string.utf16Length) 48 | let safeRange = safeRange(for: range) 49 | return safeRange 50 | } 51 | 52 | /** 53 | Get the rich text at a certain range. 54 | 55 | Since this function uses `safeRange(for:)` to not crash 56 | for invalid ranges, always use this function instead of 57 | the unsafe `attributedSubstring`. 58 | 59 | - Parameters: 60 | - range: The range for which to get the rich text. 61 | */ 62 | public func richText(at range: NSRange) -> NSAttributedString { 63 | let range = safeRange(for: range) 64 | return attributedString.attributedSubstring(from: range) 65 | } 66 | 67 | /** 68 | Get a safe range for the provided range. 69 | 70 | A safe range is limited to the bounds of the attributed 71 | string and helps protecting against range overflow. 72 | 73 | - Parameters: 74 | - range: The range for which to get a safe range. 75 | - isAttributeOperation: Set this to `true` to avoid last position. 76 | */ 77 | public func safeRange( 78 | for range: NSRange, 79 | isAttributeOperation: Bool = false 80 | ) -> NSRange { 81 | let length = attributedString.string.utf16Length 82 | let subtract = isAttributeOperation ? 1 : 0 83 | return NSRange( 84 | location: max(0, min(length - subtract, range.location)), 85 | length: min(range.length, max(0, length - range.location))) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/ExportData/RichTextDataReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextDataReader.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This protocol extends ``RichTextReader`` with functionality 11 | /// for reading rich text data for the current rich text. 12 | /// 13 | /// The protocol is implemented by `NSAttributedString` as well 14 | /// as other types in the library. 15 | public protocol RichTextDataReader: RichTextReader {} 16 | 17 | extension NSAttributedString: RichTextDataReader {} 18 | 19 | extension RichTextDataReader { 20 | 21 | /** 22 | Generate rich text data from the current rich text. 23 | 24 | - Parameters: 25 | - format: The data format to use. 26 | */ 27 | public func richTextData( 28 | for format: RichTextDataFormat 29 | ) throws -> Data { 30 | switch format { 31 | case .archivedData: try richTextArchivedData() 32 | case .plainText: try richTextPlainTextData() 33 | case .rtf: try richTextRtfData() 34 | case .vendorArchivedData: try richTextArchivedData() 35 | } 36 | } 37 | } 38 | 39 | extension RichTextDataReader { 40 | 41 | /// The full text range. 42 | fileprivate var textRange: NSRange { 43 | NSRange(location: 0, length: richText.length) 44 | } 45 | 46 | /// The full text range. 47 | fileprivate func documentAttributes( 48 | for documentType: NSAttributedString.DocumentType 49 | ) -> [NSAttributedString.DocumentAttributeKey: Any] { 50 | [.documentType: documentType] 51 | } 52 | 53 | /// Generate archived formatted data. 54 | fileprivate func richTextArchivedData() throws -> Data { 55 | try NSKeyedArchiver.archivedData( 56 | withRootObject: richText, 57 | requiringSecureCoding: false 58 | ) 59 | } 60 | 61 | /// Generate plain text formatted data. 62 | fileprivate func richTextPlainTextData() throws -> Data { 63 | let string = richText.string 64 | guard let data = string.data(using: .utf8) else { 65 | throw 66 | RichTextDataError 67 | .invalidData(in: string) 68 | } 69 | return data 70 | } 71 | 72 | /// Generate RTF formatted data. 73 | fileprivate func richTextRtfData() throws -> Data { 74 | try richText.data( 75 | from: textRange, 76 | documentAttributes: documentAttributes(for: .rtf) 77 | ) 78 | } 79 | 80 | /// Generate RTFD formatted data. 81 | fileprivate func richTextRtfdData() throws -> Data { 82 | try richText.data( 83 | from: textRange, 84 | documentAttributes: documentAttributes(for: .rtfd) 85 | ) 86 | } 87 | 88 | #if os(macOS) 89 | /// Generate Word formatted data. 90 | func richTextWordData() throws -> Data { 91 | try richText.data( 92 | from: textRange, 93 | documentAttributes: documentAttributes(for: .docFormat) 94 | ) 95 | } 96 | #endif 97 | } 98 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Data/Models/RichAttributes+RichTextAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichAttributes+RichTextAttributes.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 24/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if canImport(UIKit) 11 | import UIKit 12 | #elseif canImport(AppKit) 13 | import AppKit 14 | #endif 15 | 16 | extension RichAttributes { 17 | func toAttributes(font: FontRepresentable? = nil) -> RichTextAttributes { 18 | var attributes: RichTextAttributes = [:] 19 | 20 | var defaultFont: FontRepresentable = .standardRichTextFont 21 | #if !os(watchOS) 22 | defaultFont = RichTextView.Theme.standard.font 23 | #endif 24 | // Set the font size and handle headers 25 | var font = font ?? defaultFont 26 | if let headerType = self.header?.getTextSpanStyle() { 27 | font = 28 | font 29 | .updateFontSize( 30 | size: font.pointSize * headerType.fontSizeMultiplier 31 | ) 32 | } 33 | 34 | if let size = size { 35 | font = font.updateFontSize(size: CGFloat(size)) 36 | } 37 | 38 | if let fontName = self.font { 39 | let size = font.pointSize 40 | let newFont = FontRepresentable(name: fontName, size: size) 41 | if let newFont { 42 | font = newFont 43 | } 44 | } 45 | 46 | // Apply bold and italic styles 47 | if let isBold = bold, isBold { 48 | font = font.makeBold() 49 | } 50 | 51 | if let isItalic = italic, isItalic { 52 | font = font.makeItalic() 53 | } 54 | 55 | attributes[.font] = font 56 | 57 | // Apply underline 58 | if let isUnderline = underline, isUnderline { 59 | attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue 60 | } 61 | 62 | // Apply strikethrough 63 | if let isStrike = strike, isStrike { 64 | attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue 65 | } 66 | 67 | if let color { 68 | attributes[.foregroundColor] = ColorRepresentable(Color(hex: color)) 69 | } 70 | 71 | if let background { 72 | attributes[.backgroundColor] = ColorRepresentable( 73 | Color(hex: background)) 74 | } 75 | 76 | if let align { 77 | let style = NSMutableParagraphStyle(from: nil, alignment: align) 78 | attributes[.paragraphStyle] = style 79 | } 80 | 81 | // Handle indent and paragraph styles 82 | // if let indentLevel = indent { 83 | // let paragraphStyle = NSMutableParagraphStyle() 84 | // paragraphStyle.headIndent = CGFloat(indentLevel * 10) // Adjust indentation as needed 85 | // attributes[.paragraphStyle] = paragraphStyle 86 | // } 87 | 88 | return attributes 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFormat+ToolbarConfig.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextFormat { 12 | 13 | /// This type can be used to configure a format toolbar. 14 | public struct ToolbarConfig { 15 | 16 | public init( 17 | headers: [HeaderType] = .all, 18 | alignments: [RichTextAlignment] = .all, 19 | colorPickers: [RichTextColor] = [.foreground], 20 | colorPickersDisclosed: [RichTextColor] = [], 21 | fontPicker: Bool = true, 22 | fontSizePicker: Bool = true, 23 | indentButtons: Bool = true, 24 | lineSpacingPicker: Bool = false, 25 | styles: [RichTextStyle] = .all, 26 | otherMenu: [RichTextOtherMenu] = .all, 27 | superscriptButtons: Bool = true 28 | ) { 29 | self.headers = headers 30 | self.alignments = alignments 31 | self.colorPickers = colorPickers 32 | self.colorPickersDisclosed = colorPickersDisclosed 33 | self.fontPicker = fontPicker 34 | self.fontSizePicker = fontSizePicker 35 | self.indentButtons = indentButtons 36 | self.lineSpacingPicker = lineSpacingPicker 37 | self.styles = styles 38 | self.otherMenu = otherMenu 39 | #if os(macOS) 40 | self.superscriptButtons = superscriptButtons 41 | #else 42 | self.superscriptButtons = false 43 | #endif 44 | } 45 | 46 | public var headers: [HeaderType] 47 | public var alignments: [RichTextAlignment] 48 | public var colorPickers: [RichTextColor] 49 | public var colorPickersDisclosed: [RichTextColor] 50 | public var fontPicker: Bool 51 | public var fontSizePicker: Bool 52 | public var indentButtons: Bool 53 | public var lineSpacingPicker: Bool 54 | public var styles: [RichTextStyle] 55 | public var otherMenu: [RichTextOtherMenu] 56 | public var superscriptButtons: Bool 57 | } 58 | } 59 | 60 | extension RichTextFormat.ToolbarConfig { 61 | 62 | /// The standard rich text format toolbar configuration. 63 | public static var standard: Self { .init() } 64 | } 65 | 66 | extension View { 67 | 68 | /// Apply a rich text format toolbar style. 69 | public func richTextFormatToolbarConfig( 70 | _ value: RichTextFormat.ToolbarConfig 71 | ) -> some View { 72 | self.environment(\.richTextFormatToolbarConfig, value) 73 | } 74 | } 75 | 76 | extension RichTextFormat.ToolbarConfig { 77 | 78 | fileprivate struct Key: EnvironmentKey { 79 | 80 | public static var defaultValue: RichTextFormat.ToolbarConfig { 81 | .init() 82 | } 83 | } 84 | } 85 | 86 | extension EnvironmentValues { 87 | 88 | /// This value can bind to a format toolbar config. 89 | public var richTextFormatToolbarConfig: RichTextFormat.ToolbarConfig { 90 | get { self[RichTextFormat.ToolbarConfig.Key.self] } 91 | set { self[RichTextFormat.ToolbarConfig.Key.self] = newValue } 92 | } 93 | } 94 | #endif 95 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/RichEditorSwiftUI.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 35 | 41 | 42 | 43 | 44 | 45 | 55 | 56 | 62 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Styles/RichTextStyle+Button.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextStyle+Button.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextStyle { 11 | 12 | /** 13 | This button can be used to toggle a ``RichTextStyle``. 14 | 15 | This view renders a plain `Button`, which means you can 16 | use and configure with plain SwiftUI. 17 | */ 18 | public struct Button: View { 19 | 20 | /** 21 | Create a rich text style button. 22 | 23 | - Parameters: 24 | - style: The style to toggle. 25 | - value: The value to bind to. 26 | - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. 27 | */ 28 | public init( 29 | style: RichTextStyle, 30 | value: Binding, 31 | fillVertically: Bool = false 32 | ) { 33 | self.style = style 34 | self.value = value 35 | self.fillVertically = fillVertically 36 | } 37 | 38 | /** 39 | Create a rich text style button. 40 | 41 | - Parameters: 42 | - style: The style to toggle. 43 | - context: The context to affect. 44 | - fillVertically: Whether or not fill up vertical space in a non-greedy way, by default `false`. 45 | */ 46 | public init( 47 | style: RichTextStyle, 48 | context: RichEditorState, 49 | fillVertically: Bool = false 50 | ) { 51 | self.init( 52 | style: style, 53 | value: context.binding(for: style), 54 | fillVertically: fillVertically 55 | ) 56 | } 57 | 58 | private let style: RichTextStyle 59 | private let value: Binding 60 | private let fillVertically: Bool 61 | 62 | public var body: some View { 63 | SwiftUI.Button(action: toggle) { 64 | style.label 65 | .labelStyle(.iconOnly) 66 | .frame(maxHeight: fillVertically ? .infinity : nil) 67 | .contentShape(Rectangle()) 68 | } 69 | .tint(.accentColor, if: isOn) 70 | .foreground(.accentColor, if: isOn) 71 | .keyboardShortcut(for: style) 72 | .accessibilityLabel(style.title) 73 | } 74 | } 75 | } 76 | 77 | extension View { 78 | 79 | @ViewBuilder 80 | func foreground(_ color: Color, if isOn: Bool) -> some View { 81 | if isOn { 82 | self.foregroundStyle(color) 83 | } else { 84 | self 85 | } 86 | } 87 | 88 | @ViewBuilder 89 | func tint(_ color: Color, if isOn: Bool) -> some View { 90 | if isOn { 91 | self.tint(color) 92 | } else { 93 | self 94 | } 95 | } 96 | } 97 | 98 | extension RichTextStyle.Button { 99 | 100 | fileprivate var isOn: Bool { 101 | value.wrappedValue 102 | } 103 | 104 | fileprivate func toggle() { 105 | value.wrappedValue.toggle() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextAlignment.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 21/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This enum defines supported rich text alignments, like left, 11 | /// right, center, and justified. 12 | public enum RichTextAlignment: String, CaseIterable, Codable, Equatable, 13 | Identifiable, RichTextLabelValue 14 | { 15 | 16 | /** 17 | Initialize a rich text alignment with a native alignment. 18 | 19 | - Parameters: 20 | - alignment: The native alignment to use. 21 | */ 22 | public init(_ alignment: NSTextAlignment) { 23 | switch alignment { 24 | case .left: self = .left 25 | case .right: self = .right 26 | case .center: self = .center 27 | case .justified: self = .justify 28 | default: self = .left 29 | } 30 | } 31 | 32 | /// Left text alignment. 33 | case left 34 | 35 | /// Center text alignment. 36 | case center 37 | 38 | /// Justified text alignment. 39 | case justify 40 | 41 | /// Right text alignment. 42 | case right 43 | } 44 | 45 | extension RichTextAlignment { 46 | public func getTextSpanStyle() -> RichTextSpanStyle { 47 | return .align(self) 48 | } 49 | } 50 | 51 | extension Collection where Element == RichTextAlignment { 52 | 53 | public static var all: [Element] { RichTextAlignment.allCases } 54 | } 55 | 56 | extension RichTextAlignment { 57 | 58 | /// The unique alignment ID. 59 | public var id: String { rawValue } 60 | 61 | /// The standard icon to use for the alignment. 62 | public var icon: Image { nativeAlignment.icon } 63 | 64 | /// The standard title to use for the alignment. 65 | public var title: String { nativeAlignment.title } 66 | 67 | /// The standard title key to use for the alignment. 68 | public var titleKey: RTEL10n { nativeAlignment.titleKey } 69 | 70 | /// The native alignment of the alignment. 71 | public var nativeAlignment: NSTextAlignment { 72 | switch self { 73 | case .left: .left 74 | case .right: .right 75 | case .center: .center 76 | case .justify: .justified 77 | } 78 | } 79 | } 80 | 81 | extension NSTextAlignment: RichTextLabelValue {} 82 | 83 | extension NSTextAlignment { 84 | 85 | /// The standard icon to use for the alignment. 86 | public var icon: Image { 87 | switch self { 88 | case .left: .richTextAlignmentLeft 89 | case .right: .richTextAlignmentRight 90 | case .center: .richTextAlignmentCenter 91 | case .justified: .richTextAlignmentJustified 92 | default: .richTextAlignmentLeft 93 | } 94 | } 95 | 96 | /// The standard title to use for the alignment. 97 | public var title: String { 98 | titleKey.text 99 | } 100 | 101 | /// The standard title key to use for the alignment. 102 | public var titleKey: RTEL10n { 103 | switch self { 104 | case .left: .textAlignmentLeft 105 | case .right: .textAlignmentRight 106 | case .center: .textAlignmentCentered 107 | case .justified: .textAlignmentJustified 108 | default: .textAlignmentLeft 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/RichTextFontSizePickerStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFontSizePickerStack.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/10/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextFont { 12 | 13 | /** 14 | This view uses a ``RichTextFont/SizePicker`` and button 15 | steppers to increment and a decrement the font size. 16 | 17 | You can configure this picker by applying a config view 18 | modifier to your view hierarchy: 19 | 20 | ```swift 21 | VStack { 22 | RichTextFont.SizePickerStack(...) 23 | ... 24 | } 25 | .richTextFontSizePickerConfig(...) 26 | ``` 27 | */ 28 | public struct SizePickerStack: View { 29 | 30 | /** 31 | Create a rich text font size picker stack. 32 | 33 | - Parameters: 34 | - context: The context to affect. 35 | */ 36 | public init( 37 | context: RichEditorState 38 | ) { 39 | self._context = ObservedObject(wrappedValue: context) 40 | } 41 | 42 | private let step = 1 43 | 44 | @ObservedObject 45 | private var context: RichEditorState 46 | 47 | public var body: some View { 48 | #if os(iOS) || os(visionOS) 49 | stack 50 | .fixedSize(horizontal: false, vertical: true) 51 | #else 52 | HStack(spacing: 3) { 53 | picker 54 | stepper 55 | } 56 | .overlay(macShortcutOverlay) 57 | #endif 58 | } 59 | } 60 | } 61 | 62 | extension RichTextFont.SizePickerStack { 63 | 64 | fileprivate var macShortcutOverlay: some View { 65 | stack 66 | .opacity(0) 67 | .allowsHitTesting(false) 68 | } 69 | 70 | fileprivate var stack: some View { 71 | HStack(spacing: 2) { 72 | stepButton(-step) 73 | picker 74 | stepButton(step) 75 | } 76 | } 77 | 78 | fileprivate func stepButton(_ points: Int) -> some View { 79 | RichTextAction.Button( 80 | action: .stepFontSize(points: points), 81 | context: context, 82 | fillVertically: true 83 | ) 84 | } 85 | 86 | fileprivate var picker: some View { 87 | RichTextFont.SizePicker( 88 | selection: $context.fontSize 89 | ) 90 | .onChangeBackPort(of: context.fontSize) { newValue in 91 | context.updateStyle(style: .size(Int(context.fontSize))) 92 | } 93 | } 94 | 95 | fileprivate var stepper: some View { 96 | Stepper( 97 | "", 98 | onIncrement: increment, 99 | onDecrement: decrement 100 | ) 101 | } 102 | 103 | fileprivate func decrement() { 104 | context.fontSize -= CGFloat(step) 105 | } 106 | 107 | fileprivate func increment() { 108 | context.fontSize += CGFloat(step) 109 | } 110 | } 111 | #endif 112 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Export/RichTextExportUrlResolver+FileManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StandardRichTextExportUrlResolver.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This is a typealias for the `FileManager` class, since it's 11 | /// the standard way to resolve export file urls. 12 | public typealias StandardRichTextExportUrlResolver = FileManager 13 | 14 | extension FileManager: RichTextExportUrlResolver {} 15 | 16 | extension FileManager { 17 | 18 | /** 19 | Try to generate a file url in a certain directory. 20 | 21 | - Parameters: 22 | - fileName: The preferred file name. 23 | - extensions: The file extension. 24 | - directory: The directory in which to generate an url. 25 | */ 26 | public func fileUrl( 27 | withName fileName: String, 28 | extension: String, 29 | in directory: FileManager.SearchPathDirectory 30 | ) throws -> URL { 31 | let url = 32 | self 33 | .urls(for: directory, in: .userDomainMask).first? 34 | .appendingPathComponent(fileName) 35 | .appendingPathExtension(`extension`) 36 | guard let fileUrl = url else { 37 | throw RichTextExportError.cantCreateFileUrl(in: directory) 38 | } 39 | return fileUrl 40 | } 41 | 42 | /** 43 | Try to generate a unique file url in a certain directory. 44 | 45 | If needed, the function appends a counter until the url 46 | is unique. This means that the resulting url for a file 47 | url that has the file name `myFile.txt` may result in a 48 | url that has the file name `myFile-1.txt`. 49 | 50 | - Parameters: 51 | - fileName: The preferred file name. 52 | - extensions: The file extension. 53 | - directory: The directory in which to generate an url. 54 | */ 55 | public func uniqueFileUrl( 56 | withName fileName: String, 57 | extension: String, 58 | in directory: FileManager.SearchPathDirectory 59 | ) throws -> URL { 60 | let url = try fileUrl( 61 | withName: fileName, extension: `extension`, in: directory) 62 | let uniqueUrl = uniqueUrl(for: url) 63 | return uniqueUrl 64 | } 65 | 66 | /** 67 | Get a unique url for the provided `url`, to ensure that 68 | no existing folder or file exists there. 69 | 70 | If needed, the function appends a counter until the url 71 | is unique. This means that the resulting url for a file 72 | url that has the file name `myFile.txt` may result in a 73 | url that has the file name `myFile-1.txt`. 74 | 75 | - Parameters: 76 | - url: The url to generate a unique url for. 77 | */ 78 | public func uniqueUrl(for url: URL) -> URL { 79 | if !fileExists(atPath: url.path) { return url } 80 | let fileExtension = url.pathExtension 81 | let noExtension = url.deletingPathExtension() 82 | let fileName = noExtension.lastPathComponent 83 | var counter = 1 84 | repeat { 85 | let newUrl = 86 | noExtension 87 | .deletingLastPathComponent() 88 | .appendingPathComponent(fileName.appending("-\(counter)")) 89 | .appendingPathExtension(fileExtension) 90 | if !fileExists(atPath: newUrl.path) { return newUrl } 91 | counter += 1 92 | } while true 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFont+PickerConfig.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension RichTextFont { 11 | 12 | /// This type can configure a ``RichTextFont/Picker``. 13 | /// 14 | /// This configuration contains configuration properties 15 | /// for many different font pickers types. Some of these 16 | /// properties are not used in some pickers. 17 | public struct PickerConfig { 18 | 19 | /// Create a custom font picker config. 20 | /// 21 | /// - Parameters: 22 | /// - fonts: The fonts to display in the list, by default `all`. 23 | /// - fontSize: The font size to use in the list items, by default `20`. 24 | /// - dismissAfterPick: Whether or not to dismiss the picker after a font is selected, by default `false`. 25 | /// - moveSelectionTopmost: Whether or not to place the selected font topmost, by default `true`. 26 | public init( 27 | fonts: [RichTextFont.PickerFont] = .all, 28 | fontSize: CGFloat = 20, 29 | dismissAfterPick: Bool = false, 30 | moveSelectionTopmost: Bool = true 31 | ) { 32 | self.fonts = fonts 33 | self.fontSize = fontSize 34 | self.dismissAfterPick = dismissAfterPick 35 | self.moveSelectionTopmost = moveSelectionTopmost 36 | } 37 | 38 | public typealias Font = RichTextFont.PickerFont 39 | public typealias FontName = String 40 | 41 | /// The fonts to display in the list. 42 | public var fonts: [RichTextFont.PickerFont] 43 | 44 | /// The font size to use in the list items. 45 | public var fontSize: CGFloat 46 | 47 | /// Whether or not to dismiss the picker after a font is selected. 48 | public var dismissAfterPick: Bool 49 | 50 | /// Whether or not to move the selected font topmost 51 | public var moveSelectionTopmost: Bool 52 | } 53 | } 54 | 55 | extension RichTextFont.PickerConfig { 56 | 57 | /// The standard font picker configuration. 58 | public static var standard: Self { .init() } 59 | } 60 | 61 | extension RichTextFont.PickerConfig { 62 | 63 | /// The fonts to list for a given selection. 64 | public func fontsToList(for selection: FontName) -> [Font] { 65 | if moveSelectionTopmost { 66 | return fonts.moveTopmost(selection) 67 | } else { 68 | return fonts 69 | } 70 | } 71 | } 72 | 73 | extension View { 74 | 75 | /// Apply a ``RichTextFont`` picker configuration. 76 | public func richTextFontPickerConfig( 77 | _ config: RichTextFont.PickerConfig 78 | ) -> some View { 79 | self.environment(\.richTextFontPickerConfig, config) 80 | } 81 | } 82 | 83 | extension RichTextFont.PickerConfig { 84 | 85 | fileprivate struct Key: EnvironmentKey { 86 | 87 | public static var defaultValue: RichTextFont.PickerConfig { 88 | .standard 89 | } 90 | } 91 | } 92 | 93 | extension EnvironmentValues { 94 | 95 | /// This value can bind to a font picker config. 96 | public var richTextFontPickerConfig: RichTextFont.PickerConfig { 97 | get { self[RichTextFont.PickerConfig.Key.self] } 98 | set { self[RichTextFont.PickerConfig.Key.self] = newValue } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Styles/RichTextStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextStyle.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 22/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public enum RichTextStyle: String, CaseIterable, Identifiable, 11 | RichTextLabelValue 12 | { 13 | 14 | case bold 15 | case italic 16 | case underline 17 | case strikethrough 18 | } 19 | 20 | extension RichTextStyle { 21 | 22 | /// All available rich text styles. 23 | public static var all: [Self] { allCases } 24 | } 25 | 26 | extension Collection where Element == RichTextStyle { 27 | 28 | /// All available rich text styles. 29 | public static var all: [RichTextStyle] { RichTextStyle.allCases } 30 | } 31 | 32 | extension RichTextStyle { 33 | 34 | public var id: String { rawValue } 35 | 36 | /// The standard icon to use for the trait. 37 | public var icon: Image { 38 | switch self { 39 | case .bold: .richTextStyleBold 40 | case .italic: .richTextStyleItalic 41 | case .strikethrough: .richTextStyleStrikethrough 42 | case .underline: .richTextStyleUnderline 43 | } 44 | } 45 | 46 | /// The localized style title. 47 | public var title: String { 48 | titleKey.text 49 | } 50 | 51 | /// The localized style title key. 52 | public var titleKey: RTEL10n { 53 | switch self { 54 | case .bold: .styleBold 55 | case .italic: .styleItalic 56 | case .underline: .styleUnderlined 57 | case .strikethrough: .styleStrikethrough 58 | } 59 | } 60 | 61 | /** 62 | Get the rich text styles that are enabled in a provided 63 | set of traits and attributes. 64 | 65 | - Parameters: 66 | - traits: The symbolic traits to inspect. 67 | - attributes: The rich text attributes to inspect. 68 | */ 69 | public static func styles( 70 | in traits: FontTraitsRepresentable?, 71 | attributes: RichTextAttributes? 72 | ) -> [RichTextStyle] { 73 | var styles = traits?.enabledRichTextStyles ?? [] 74 | if attributes?.isStrikethrough == true { styles.append(.strikethrough) } 75 | if attributes?.isUnderlined == true { styles.append(.underline) } 76 | return styles 77 | } 78 | } 79 | 80 | extension Collection where Element == RichTextStyle { 81 | 82 | /// Check if the collection contains a certain style. 83 | public func hasStyle(_ style: RichTextStyle) -> Bool { 84 | contains(style) 85 | } 86 | 87 | /// Check if a certain style change should be applied. 88 | public func shouldAddOrRemove( 89 | _ style: RichTextStyle, 90 | _ newValue: Bool 91 | ) -> Bool { 92 | let shouldAdd = newValue && !hasStyle(style) 93 | let shouldRemove = !newValue && hasStyle(style) 94 | return shouldAdd || shouldRemove 95 | } 96 | } 97 | 98 | #if canImport(UIKit) 99 | extension RichTextStyle { 100 | 101 | /// The symbolic font traits for the style, if any. 102 | public var symbolicTraits: UIFontDescriptor.SymbolicTraits? { 103 | switch self { 104 | case .bold: .traitBold 105 | case .italic: .traitItalic 106 | case .strikethrough: nil 107 | case .underline: nil 108 | } 109 | } 110 | } 111 | #endif 112 | 113 | #if os(macOS) 114 | extension RichTextStyle { 115 | 116 | /// The symbolic font traits for the trait, if any. 117 | public var symbolicTraits: NSFontDescriptor.SymbolicTraits? { 118 | switch self { 119 | case .bold: .bold 120 | case .italic: .italic 121 | case .strikethrough: nil 122 | case .underline: nil 123 | } 124 | } 125 | } 126 | #endif 127 | 128 | extension RichTextStyle { 129 | var richTextSpanStyle: RichTextSpanStyle { 130 | switch self { 131 | case .bold: .bold 132 | case .italic: .italic 133 | case .strikethrough: .strikethrough 134 | case .underline: .underline 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/UI/Helper/RichEditorState+LineInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorState+LineInfo.swift 3 | // 4 | // 5 | // Created by Divyesh Vekariya on 31/05/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RichEditorState { 11 | 12 | internal struct LineInfo { 13 | let lineNumber: Int 14 | let lineRange: NSRange 15 | let lineString: String 16 | let caretLocation: Int 17 | } 18 | 19 | /** 20 | Calculates which line number (using a 0 based index) our caret is on, the range of this line (in comparison to the whole string), and the string that makes up that line of text. 21 | Will return nil if there is no caret present, and a portion of text is highlighted instead. 22 | 23 | A pain point of this function is that it cannot return the current line number when it's found, but rather 24 | has to wait for every single line to be iterated through first. This is because the enumerateSubstrings() function 25 | on the String is not an actual loop, and as such we cannot return or break within it. 26 | 27 | - returns: The line number that the caret is on, the range of our line, and the string that makes up that line of text 28 | */ 29 | var currentLine: LineInfo { 30 | return getCurrentLineInfo(rawText, selectedRange: selectedRange) 31 | } 32 | 33 | internal func getCurrentLineInfo(_ string: String, selectedRange: NSRange) 34 | -> LineInfo 35 | { 36 | 37 | /// Determines if the user has selected (ie. highlighted) any text 38 | var hasSelectedText: Bool { 39 | !selectedRange.isCollapsed 40 | } 41 | 42 | /// The location of our caret within the textview 43 | var caretLocation: Int { 44 | selectedRange.location 45 | } 46 | 47 | //The line number that we're currently iterating on 48 | var lineNumber = 0 49 | 50 | //The line number & line of text that we believe the caret to be on 51 | var selectedLineNumber = 0 52 | var selectedLineRange = selectedRange 53 | var selectedLineOfText = "" 54 | var caretLocationInLine = 0 55 | 56 | var foundSelectedLine = false 57 | 58 | //Iterate over every line in our TextView 59 | string.enumerateSubstrings( 60 | in: string.startIndex..= startOfLine && caretLocation <= endOfLine { 71 | // MARK the line number 72 | selectedLineNumber = lineNumber 73 | selectedLineOfText = substring ?? "" 74 | selectedLineRange = range 75 | caretLocationInLine = caretLocation - startOfLine 76 | 77 | foundSelectedLine = true 78 | } 79 | 80 | lineNumber += 1 81 | } 82 | 83 | //If we're not at the starting point, and we didn't find a current line, then we're at the end of our TextView 84 | if caretLocation > 0 && !foundSelectedLine { 85 | selectedLineNumber = lineNumber 86 | selectedLineOfText = "" 87 | selectedLineRange = NSRange(location: caretLocation, length: 0) 88 | } 89 | return LineInfo( 90 | lineNumber: selectedLineNumber, lineRange: selectedLineRange, 91 | lineString: selectedLineOfText, caretLocation: caretLocationInLine) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextFormat+Toolbar.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 18/11/24. 6 | // 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | import SwiftUI 10 | 11 | extension RichTextFormat { 12 | 13 | /** 14 | This horizontal toolbar provides text format controls. 15 | 16 | This toolbar adapts the layout based on horizontal size 17 | class. The control row is split in two for compact size, 18 | while os(macOS) and regular sizes get a single row. 19 | 20 | You can configure and style the view by applying config 21 | and style view modifiers to your view hierarchy: 22 | 23 | ```swift 24 | VStack { 25 | ... 26 | } 27 | .richTextFormatToolbarStyle(...) 28 | .richTextFormatToolbarConfig(...) 29 | ``` 30 | */ 31 | public struct Toolbar: RichTextFormatToolbarBase { 32 | 33 | /** 34 | Create a rich text format sheet. 35 | 36 | - Parameters: 37 | - context: The context to apply changes to. 38 | */ 39 | public init( 40 | context: RichEditorState 41 | ) { 42 | self._context = ObservedObject(wrappedValue: context) 43 | } 44 | 45 | @ObservedObject 46 | private var context: RichEditorState 47 | 48 | @Environment(\.richTextFormatToolbarConfig) 49 | var config 50 | 51 | @Environment(\.richTextFormatToolbarStyle) 52 | var style 53 | 54 | @Environment(\.horizontalSizeClass) 55 | private var horizontalSizeClass 56 | 57 | public var body: some View { 58 | VStack(spacing: style.spacing) { 59 | controls 60 | if hasColorPickers { 61 | Divider() 62 | colorPickers(for: context) 63 | } 64 | } 65 | .labelsHidden() 66 | .padding(.vertical, style.padding) 67 | .environment(\.sizeCategory, .medium) 68 | // .background(background) 69 | #if os(macOS) 70 | .frame(minWidth: 650) 71 | #endif 72 | } 73 | } 74 | } 75 | 76 | // MARK: - Views 77 | 78 | extension RichTextFormat.Toolbar { 79 | 80 | fileprivate var useSingleLine: Bool { 81 | #if os(macOS) 82 | true 83 | #else 84 | horizontalSizeClass == .regular 85 | #endif 86 | } 87 | } 88 | 89 | extension RichTextFormat.Toolbar { 90 | 91 | fileprivate var background: some View { 92 | Color.clear 93 | .overlay(Color.primary.opacity(0.1)) 94 | .shadow(color: .black.opacity(0.1), radius: 5) 95 | .edgesIgnoringSafeArea(.all) 96 | } 97 | 98 | @ViewBuilder 99 | fileprivate var controls: some View { 100 | if useSingleLine { 101 | HStack { 102 | controlsContent 103 | } 104 | .padding(.horizontal, style.padding) 105 | } else { 106 | VStack(spacing: style.spacing) { 107 | controlsContent 108 | } 109 | .padding(.horizontal, style.padding) 110 | } 111 | } 112 | 113 | @ViewBuilder 114 | fileprivate var controlsContent: some View { 115 | HStack { 116 | #if os(macOS) 117 | headerPicker(context: context) 118 | fontPicker(value: $context.fontName) 119 | .onChangeBackPort(of: context.fontName) { newValue in 120 | context.updateStyle(style: .font(newValue)) 121 | } 122 | #endif 123 | styleToggleGroup(for: context) 124 | otherMenuToggleGroup(for: context) 125 | if !useSingleLine { 126 | Spacer() 127 | } 128 | fontSizePicker(for: context) 129 | if horizontalSizeClass == .regular { 130 | Spacer() 131 | } 132 | } 133 | HStack { 134 | #if !macOS 135 | headerPicker(context: context) 136 | #endif 137 | alignmentPicker(context: context) 138 | // superscriptButtons(for: context, greedy: false) 139 | // indentButtons(for: context, greedy: false) 140 | } 141 | } 142 | } 143 | #endif 144 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/Localization/RTEL10n.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RTEL10n.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 29/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This enum defines RichTextKit-specific, localized texts. 11 | public enum RTEL10n: String, CaseIterable, Identifiable { 12 | 13 | case 14 | done, 15 | more, 16 | 17 | font, 18 | fontSize, 19 | fontSizeIncrease, 20 | fontSizeIncreaseDescription, 21 | fontSizeDecrease, 22 | fontSizeDecreaseDescription, 23 | 24 | setHeaderStyle, 25 | 26 | color, 27 | foregroundColor, 28 | backgroundColor, 29 | underlineColor, 30 | strikethroughColor, 31 | strokeColor, 32 | 33 | actionCopy, 34 | actionDismissKeyboard, 35 | actionPrint, 36 | actionRedoLatestChange, 37 | actionUndoLatestChange, 38 | 39 | fileFormatRtk, 40 | fileFormatPdf, 41 | fileFormatRtf, 42 | fileFormatTxt, 43 | fileFormatJson, 44 | 45 | indent, 46 | indentIncrease, 47 | indentIncreaseDescription, 48 | indentDecrease, 49 | indentDecreaseDescription, 50 | 51 | lineSpacing, 52 | lineSpacingIncrease, 53 | lineSpacingIncreaseDescription, 54 | lineSpacingDecrease, 55 | lineSpacingDecreaseDescription, 56 | 57 | menuExport, 58 | menuExportAs, 59 | menuFormat, 60 | menuPrint, 61 | menuSave, 62 | menuSaveAs, 63 | menuShare, 64 | menuShareAs, 65 | menuText, 66 | 67 | highlightedRange, 68 | highlightingStyle, 69 | 70 | pasteImage, 71 | pasteImages, 72 | pasteText, 73 | selectRange, 74 | 75 | setAttributedString, 76 | 77 | styleBold, 78 | styleItalic, 79 | styleStrikethrough, 80 | styleUnderlined, 81 | 82 | superscript, 83 | superscriptIncrease, 84 | superscriptIncreaseDescription, 85 | superscriptDecrease, 86 | superscriptDecreaseDescription, 87 | 88 | textAlignment, 89 | textAlignmentLeft, 90 | textAlignmentRight, 91 | textAlignmentCentered, 92 | textAlignmentJustified, 93 | 94 | headerDefault, 95 | header1, 96 | header2, 97 | header3, 98 | header4, 99 | header5, 100 | header6, 101 | 102 | link, 103 | 104 | ignoreIt 105 | } 106 | 107 | extension RTEL10n { 108 | 109 | public static func actionStepFontSize( 110 | _ points: Int 111 | ) -> RTEL10n { 112 | points < 0 ? .fontSizeDecreaseDescription : .fontSizeIncreaseDescription 113 | } 114 | 115 | public static func actionStepIndent( 116 | _ points: Double 117 | ) -> RTEL10n { 118 | points < 0 ? .indentDecreaseDescription : .indentIncreaseDescription 119 | } 120 | 121 | public static func actionStepLineSpacing( 122 | _ points: CGFloat 123 | ) -> RTEL10n { 124 | points < 0 125 | ? .lineSpacingDecreaseDescription : .lineSpacingIncreaseDescription 126 | } 127 | 128 | public static func actionStepSuperscript( 129 | _ steps: Int 130 | ) -> RTEL10n { 131 | steps < 0 132 | ? .superscriptDecreaseDescription : .superscriptIncreaseDescription 133 | } 134 | 135 | public static func menuIndent(_ points: Double) -> RTEL10n { 136 | points < 0 ? .indentDecrease : .indentIncrease 137 | } 138 | } 139 | 140 | extension RTEL10n { 141 | 142 | /// The item's unique identifier. 143 | public var id: String { rawValue } 144 | 145 | /// The item's localization key. 146 | public var key: String { rawValue } 147 | 148 | /// The item's localized text. 149 | public var text: String { 150 | rawValue 151 | } 152 | 153 | /// Get the localized text for a certain `Locale`. 154 | // func text(for locale: Locale) -> String { 155 | // guard let bundle = Bundle.module.bundle(for: locale) else { return "" } 156 | // return NSLocalizedString(key, bundle: bundle, comment: "") 157 | // } 158 | } 159 | -------------------------------------------------------------------------------- /Sources/RichEditorSwiftUI/ExportData/NSAttributedString+Init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+Init.swift 3 | // RichEditorSwiftUI 4 | // 5 | // Created by Divyesh Vekariya on 26/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSAttributedString { 11 | 12 | /** 13 | Try to parse ``RichTextDataFormat`` formatted data. 14 | 15 | - Parameters: 16 | - data: The data to initialize the string with. 17 | - format: The data format to use. 18 | */ 19 | public convenience init( 20 | data: Data, 21 | format: RichTextDataFormat 22 | ) throws { 23 | switch format { 24 | case .archivedData: try self.init(archivedData: data) 25 | case .plainText: try self.init(plainTextData: data) 26 | case .rtf: try self.init(rtfData: data) 27 | case .vendorArchivedData: try self.init(archivedData: data) 28 | } 29 | } 30 | } 31 | 32 | extension NSAttributedString { 33 | 34 | /// Try to parse ``RichTextDataFormat/archivedData``. 35 | fileprivate convenience init(archivedData data: Data) throws { 36 | let unarchived = try NSKeyedUnarchiver.unarchivedObject( 37 | ofClass: NSAttributedString.self, 38 | from: data) 39 | guard let string = unarchived else { 40 | throw RichTextDataError.invalidArchivedData(in: data) 41 | } 42 | self.init(attributedString: string) 43 | } 44 | 45 | /// Try to parse ``RichTextDataFormat/plainText`` data. 46 | fileprivate convenience init(plainTextData data: Data) throws { 47 | let decoded = String(data: data, encoding: .utf8) 48 | guard let string = decoded else { 49 | throw RichTextDataError.invalidPlainTextData(in: data) 50 | } 51 | let attributed = NSAttributedString(string: string) 52 | self.init(attributedString: attributed) 53 | } 54 | 55 | /// Try to parse ``RichTextDataFormat/rtf`` data. 56 | fileprivate convenience init(rtfData data: Data) throws { 57 | var attributes = Self.rtfDataAttributes as NSDictionary? 58 | try self.init( 59 | data: data, 60 | options: [.characterEncoding: Self.utf8], 61 | documentAttributes: &attributes 62 | ) 63 | } 64 | 65 | /// Try to parse ``RichTextDataFormat/rtfd`` data. 66 | fileprivate convenience init(rtfdData data: Data) throws { 67 | var attributes = Self.rtfdDataAttributes as NSDictionary? 68 | try self.init( 69 | data: data, 70 | options: [.characterEncoding: Self.utf8], 71 | documentAttributes: &attributes 72 | ) 73 | } 74 | 75 | #if os(macOS) 76 | /// Try to parse ``RichTextDataFormat/word`` data. 77 | convenience init(wordData data: Data) throws { 78 | var attributes = Self.wordDataAttributes as NSDictionary? 79 | try self.init( 80 | data: data, 81 | options: [.characterEncoding: Self.utf8], 82 | documentAttributes: &attributes 83 | ) 84 | } 85 | #endif 86 | 87 | fileprivate convenience init(jsonData data: Data) throws { 88 | let decoder = JSONDecoder() 89 | let richText = try? decoder.decode(RichText.self, from: data) 90 | guard let richText = richText else { 91 | throw RichTextDataError.invalidPlainTextData(in: data) 92 | } 93 | 94 | var tempSpans: [RichTextSpanInternal] = [] 95 | var text = "" 96 | richText.spans.forEach({ 97 | let span = RichTextSpanInternal( 98 | from: text.utf16Length, 99 | to: (text.utf16Length + $0.insert.utf16Length - 1), 100 | attributes: $0.attributes) 101 | tempSpans.append(span) 102 | text += $0.insert 103 | }) 104 | 105 | let attributedString = NSMutableAttributedString(string: text) 106 | 107 | tempSpans.forEach { span in 108 | attributedString.addAttributes( 109 | span.attributes?.toAttributes() ?? [:], range: span.spanRange) 110 | } 111 | self.init(attributedString: attributedString) 112 | } 113 | } 114 | 115 | extension NSAttributedString { 116 | 117 | fileprivate static var utf8: UInt { 118 | String.Encoding.utf8.rawValue 119 | } 120 | 121 | fileprivate static var rtfDataAttributes: [DocumentAttributeKey: Any] { 122 | [.documentType: NSAttributedString.DocumentType.rtf] 123 | } 124 | 125 | fileprivate static var rtfdDataAttributes: [DocumentAttributeKey: Any] { 126 | [.documentType: NSAttributedString.DocumentType.rtfd] 127 | } 128 | 129 | #if os(macOS) 130 | static var wordDataAttributes: [DocumentAttributeKey: Any] { 131 | [.documentType: NSAttributedString.DocumentType.docFormat] 132 | } 133 | #endif 134 | } 135 | --------------------------------------------------------------------------------