diff --git a/.github/workflows/build_package.yml b/.github/workflows/build_package.yml
new file mode 100644
index 0000000..14ca170
--- /dev/null
+++ b/.github/workflows/build_package.yml
@@ -0,0 +1,20 @@
+name: build_package
+
+run-name: build
+
+on: [push]
+
+jobs:
+ build:
+ name: Swift ${{ matrix.swift }} on ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: swift-actions/setup-swift@65540b95f51493d65f5e59e97dcef9629ddf11bf
+ - uses: actions/checkout@v4
+ - name: Build
+ run: swift build
+ - name: Run tests
+ run: swift test
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..4f9d9e7
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,20 @@
+
+Copyright (c) 2022 Canopas Software LLP
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/Package.swift b/Package.swift
index 209e124..e6c6963 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,23 +1,27 @@
-// swift-tools-version: 5.9
+// swift-tools-version: 5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
- name: "RichEditorSwiftui",
+ name: "RichEditorSwiftUI",
+ platforms: [
+ //Add supported platforms here
+ .iOS(.v14),
+ ],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
- name: "RichEditorSwiftui",
- targets: ["RichEditorSwiftui"]),
+ name: "RichEditorSwiftUI",
+ targets: ["RichEditorSwiftUI"]),
],
targets: [
- // Targets are the basic building blocks of a package, defining a module or a test suite.
+ // Targets are the basic building blocks of a pack.age, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
- name: "RichEditorSwiftui"),
+ name: "RichEditorSwiftUI"),
.testTarget(
- name: "RichEditorSwiftuiTests",
- dependencies: ["RichEditorSwiftui"]),
+ name: "RichEditorSwiftUITests",
+ dependencies: ["RichEditorSwiftUI"]),
]
)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b021c3a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,67 @@
+# RichEditorSwiftUI
+
+iOS WYSIWYG Rich editor for SwiftUI.
+
+
+
+
+
+
+## Features
+
+The editor offers the following options:
+
+- [x] **Bold**
+- [x] *Italic*
+- [x] Underline
+- [x] Different Heading
+
+## How to add in your project
+
+Add the dependency
+
+```
+ import XYZRichEditor
+```
+
+## How to use ?
+
+```
+struct EditorView: View {
+ @ObservedObject var state: RichEditorState = .ini(input: "Hello World")
+
+ var body: some View {
+ RichEditor(state: _state)
+ .padding(10)
+ }
+}
+```
+# Demo
+[Sample](https://github.com/canopas/rich-editor-swiftui/tree/main/RichEditorDemo) app demonstrates how simple the usage of the library actually is.
+
+# Bugs and Feedback
+For bugs, questions and discussions please use the [Github Issues](https://github.com/canopas/rich-editor-swiftui/issues).
+
+
+## Credits
+RichEditor for SwiftUI is owned and maintained by the [Canopas team](https://canopas.com/). For project updates and releases, you can follow them on Twitter at [@canopassoftware](https://twitter.com/canopassoftware).
+
+RichTextKit: https://github.com/danielsaidi/RichTextKit
+
+# Licence
+
+```
+Copyright 2023 Canopas Software LLP
+
+Licensed under the Apache License, Version 2.0 (the "License");
+You won't be using this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+```
diff --git a/RichEditorDemo/RichEditorDemo.xcodeproj/project.pbxproj b/RichEditorDemo/RichEditorDemo.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..e67a600
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo.xcodeproj/project.pbxproj
@@ -0,0 +1,392 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 2A0D579A2AD66C7200BCF488 /* RichEditorDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0D57992AD66C7200BCF488 /* RichEditorDemoApp.swift */; };
+ 2A0D579C2AD66C7200BCF488 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0D579B2AD66C7200BCF488 /* ContentView.swift */; };
+ 2A0D579E2AD66C7200BCF488 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A0D579D2AD66C7200BCF488 /* Assets.xcassets */; };
+ 2A0D57A22AD66C7200BCF488 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A0D57A12AD66C7200BCF488 /* Preview Assets.xcassets */; };
+ 2A0D57AD2AD6A4D400BCF488 /* RichEditorSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 2A0D57AC2AD6A4D400BCF488 /* RichEditorSwiftUI */; };
+ 2A89F2232B48179400F35DBF /* Sample_json.json in Resources */ = {isa = PBXBuildFile; fileRef = 2A89F2222B48179400F35DBF /* Sample_json.json */; };
+ 2A89F2262B4817DC00F35DBF /* JsonUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A89F2252B4817DC00F35DBF /* JsonUtils.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 2A0D57962AD66C7200BCF488 /* RichEditorDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RichEditorDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 2A0D57992AD66C7200BCF488 /* RichEditorDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RichEditorDemoApp.swift; sourceTree = ""; };
+ 2A0D579B2AD66C7200BCF488 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 2A0D579D2AD66C7200BCF488 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 2A0D579F2AD66C7200BCF488 /* RichEditorDemo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RichEditorDemo.entitlements; sourceTree = ""; };
+ 2A0D57A12AD66C7200BCF488 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
+ 2A0D57A82AD66C9C00BCF488 /* RichEditorSwiftui */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = RichEditorSwiftui; path = ..; sourceTree = ""; };
+ 2A89F2222B48179400F35DBF /* Sample_json.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = Sample_json.json; sourceTree = ""; };
+ 2A89F2252B4817DC00F35DBF /* JsonUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonUtils.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 2A0D57932AD66C7200BCF488 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 2A0D57AD2AD6A4D400BCF488 /* RichEditorSwiftUI in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 2A0D578D2AD66C7200BCF488 = {
+ isa = PBXGroup;
+ children = (
+ 2A0D57A82AD66C9C00BCF488 /* RichEditorSwiftui */,
+ 2A0D57982AD66C7200BCF488 /* RichEditorDemo */,
+ 2A0D57972AD66C7200BCF488 /* Products */,
+ 2A0D57A92AD66CA800BCF488 /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 2A0D57972AD66C7200BCF488 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 2A0D57962AD66C7200BCF488 /* RichEditorDemo.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 2A0D57982AD66C7200BCF488 /* RichEditorDemo */ = {
+ isa = PBXGroup;
+ children = (
+ 2A89F2222B48179400F35DBF /* Sample_json.json */,
+ 2A0D57992AD66C7200BCF488 /* RichEditorDemoApp.swift */,
+ 2A0D579B2AD66C7200BCF488 /* ContentView.swift */,
+ 2A89F2252B4817DC00F35DBF /* JsonUtils.swift */,
+ 2A0D579D2AD66C7200BCF488 /* Assets.xcassets */,
+ 2A0D579F2AD66C7200BCF488 /* RichEditorDemo.entitlements */,
+ 2A0D57A02AD66C7200BCF488 /* Preview Content */,
+ );
+ path = RichEditorDemo;
+ sourceTree = "";
+ };
+ 2A0D57A02AD66C7200BCF488 /* Preview Content */ = {
+ isa = PBXGroup;
+ children = (
+ 2A0D57A12AD66C7200BCF488 /* Preview Assets.xcassets */,
+ );
+ path = "Preview Content";
+ sourceTree = "";
+ };
+ 2A0D57A92AD66CA800BCF488 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 2A0D57952AD66C7200BCF488 /* RichEditorDemo */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 2A0D57A52AD66C7200BCF488 /* Build configuration list for PBXNativeTarget "RichEditorDemo" */;
+ buildPhases = (
+ 2A0D57922AD66C7200BCF488 /* Sources */,
+ 2A0D57932AD66C7200BCF488 /* Frameworks */,
+ 2A0D57942AD66C7200BCF488 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = RichEditorDemo;
+ packageProductDependencies = (
+ 2A0D57AC2AD6A4D400BCF488 /* RichEditorSwiftUI */,
+ );
+ productName = RichEditorDemo;
+ productReference = 2A0D57962AD66C7200BCF488 /* RichEditorDemo.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 2A0D578E2AD66C7200BCF488 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1500;
+ LastUpgradeCheck = 1500;
+ TargetAttributes = {
+ 2A0D57952AD66C7200BCF488 = {
+ CreatedOnToolsVersion = 15.0;
+ };
+ };
+ };
+ buildConfigurationList = 2A0D57912AD66C7200BCF488 /* Build configuration list for PBXProject "RichEditorDemo" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 2A0D578D2AD66C7200BCF488;
+ productRefGroup = 2A0D57972AD66C7200BCF488 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 2A0D57952AD66C7200BCF488 /* RichEditorDemo */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 2A0D57942AD66C7200BCF488 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 2A89F2232B48179400F35DBF /* Sample_json.json in Resources */,
+ 2A0D57A22AD66C7200BCF488 /* Preview Assets.xcassets in Resources */,
+ 2A0D579E2AD66C7200BCF488 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 2A0D57922AD66C7200BCF488 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 2A0D579C2AD66C7200BCF488 /* ContentView.swift in Sources */,
+ 2A0D579A2AD66C7200BCF488 /* RichEditorDemoApp.swift in Sources */,
+ 2A89F2262B4817DC00F35DBF /* JsonUtils.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 2A0D57A32AD66C7200BCF488 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 2A0D57A42AD66C7200BCF488 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ };
+ name = Release;
+ };
+ 2A0D57A62AD66C7200BCF488 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = RichEditorDemo/RichEditorDemo.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"RichEditorDemo/Preview Content\"";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.canopas.nolonely.RichEditorDemo;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 2A0D57A72AD66C7200BCF488 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = RichEditorDemo/RichEditorDemo.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"RichEditorDemo/Preview Content\"";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
+ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
+ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.canopas.nolonely.RichEditorDemo;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 2A0D57912AD66C7200BCF488 /* Build configuration list for PBXProject "RichEditorDemo" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 2A0D57A32AD66C7200BCF488 /* Debug */,
+ 2A0D57A42AD66C7200BCF488 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 2A0D57A52AD66C7200BCF488 /* Build configuration list for PBXNativeTarget "RichEditorDemo" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 2A0D57A62AD66C7200BCF488 /* Debug */,
+ 2A0D57A72AD66C7200BCF488 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 2A0D57AC2AD6A4D400BCF488 /* RichEditorSwiftUI */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = RichEditorSwiftUI;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 2A0D578E2AD66C7200BCF488 /* Project object */;
+}
diff --git a/RichEditorDemo/RichEditorDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/RichEditorDemo/RichEditorDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/RichEditorDemo/RichEditorDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/RichEditorDemo/RichEditorDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/RichEditorDemo/RichEditorDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/RichEditorDemo/RichEditorDemo/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/RichEditorDemo/RichEditorDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/RichEditorDemo/RichEditorDemo/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..532cd72
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,63 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/RichEditorDemo/RichEditorDemo/Assets.xcassets/Contents.json b/RichEditorDemo/RichEditorDemo/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/RichEditorDemo/RichEditorDemo/ContentView.swift b/RichEditorDemo/RichEditorDemo/ContentView.swift
new file mode 100644
index 0000000..cd4708e
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/ContentView.swift
@@ -0,0 +1,46 @@
+//
+// ContentView.swift
+// RichEditorDemo
+//
+// Created by Divyesh Vekariya on 11/10/23.
+//
+
+import SwiftUI
+import RichEditorSwiftUI
+
+struct ContentView: View {
+ @ObservedObject var state: RichEditorState
+
+ init(state: RichEditorState? = nil) {
+ if let state {
+ self.state = state
+ } else {
+ if let richText = readJSONFromFile(fileName: "Sample_json", type: RichText.self) {
+ self.state = .init(input: richText.text, spans: richText.spans)
+ } else {
+ self.state = .init(input: "Hello World!")
+ }
+ }
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack {
+ RichEditor(state: _state)
+ }
+ .padding(10)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button(action: {
+ print("Export JSON")
+ }, label: {
+ Image(systemName: "checkmark")
+ .padding()
+ })
+ }
+ }
+ .navigationTitle("Rich Editor")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+ }
+}
diff --git a/RichEditorDemo/RichEditorDemo/JsonUtils.swift b/RichEditorDemo/RichEditorDemo/JsonUtils.swift
new file mode 100644
index 0000000..ef7a1ba
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/JsonUtils.swift
@@ -0,0 +1,32 @@
+//
+// JsonUtils.swift
+// RichEditorDemo
+//
+// Created by Divyesh Vekariya on 05/01/24.
+//
+
+import Foundation
+
+
+internal func readJSONFromFile(fileName: String, type: T.Type, bundle: Bundle? = nil) -> T? {
+ if let url = (bundle ?? Bundle.main).url(forResource: fileName, withExtension: "json") {
+ do {
+ let data = try Data(contentsOf: url)
+ let decoder = JSONDecoder()
+ let jsonData = try decoder.decode(T.self, from: data)
+ return jsonData
+ } catch {
+ print("JSONUtils: error - \(error)")
+ }
+ }
+ return nil
+}
+
+
+internal class RichBundleFakeClass {}
+
+internal extension Bundle {
+ static var richBundle: Bundle {
+ return Bundle(for: RichBundleFakeClass.self)
+ }
+}
diff --git a/RichEditorDemo/RichEditorDemo/Preview Content/Preview Assets.xcassets/Contents.json b/RichEditorDemo/RichEditorDemo/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/RichEditorDemo/RichEditorDemo/RichEditorDemo.entitlements b/RichEditorDemo/RichEditorDemo/RichEditorDemo.entitlements
new file mode 100644
index 0000000..f2ef3ae
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/RichEditorDemo.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.files.user-selected.read-only
+
+
+
diff --git a/RichEditorDemo/RichEditorDemo/RichEditorDemoApp.swift b/RichEditorDemo/RichEditorDemo/RichEditorDemoApp.swift
new file mode 100644
index 0000000..3d83771
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/RichEditorDemoApp.swift
@@ -0,0 +1,17 @@
+//
+// RichEditorDemoApp.swift
+// RichEditorDemo
+//
+// Created by Divyesh Vekariya on 11/10/23.
+//
+
+import SwiftUI
+
+@main
+struct RichEditorDemoApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/RichEditorDemo/RichEditorDemo/Sample_json.json b/RichEditorDemo/RichEditorDemo/Sample_json.json
new file mode 100644
index 0000000..02aa3ab
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/Sample_json.json
@@ -0,0 +1,110 @@
+{
+ "spans": [
+ {
+ "from": 0,
+ "to": 10,
+ "style": "h1"
+ },
+ {
+ "from": 0,
+ "to": 10,
+ "style": "bold"
+ },
+ {
+ "from": 27,
+ "to": 35,
+ "style": "bold"
+ },
+ {
+ "from": 52,
+ "to": 61,
+ "style": "bold"
+ },
+ {
+ "from": 52,
+ "to": 59,
+ "style": "italic"
+ },
+ {
+ "from": 52,
+ "to": 61,
+ "style": "underline"
+ },
+ {
+ "from": 61,
+ "to": 69,
+ "style": "h3"
+ },
+ {
+ "from": 62,
+ "to": 69,
+ "style": "bold"
+ },
+ {
+ "from": 103,
+ "to": 118,
+ "style": "bold"
+ },
+ {
+ "from": 119,
+ "to": 126,
+ "style": "italic"
+ },
+ {
+ "from": 130,
+ "to": 138,
+ "style": "underline"
+ },
+ {
+ "from": 160,
+ "to": 167,
+ "style": "h3"
+ },
+ {
+ "from": 161,
+ "to": 167,
+ "style": "bold"
+ },
+ {
+ "from": 169,
+ "to": 180,
+ "style": "bold"
+ },
+ {
+ "from": 224,
+ "to": 235,
+ "style": "bold"
+ },
+ {
+ "from": 224,
+ "to": 235,
+ "style": "italic"
+ },
+ {
+ "from": 224,
+ "to": 235,
+ "style": "underline"
+ },
+ {
+ "from": 237,
+ "to": 250,
+ "style": "h4"
+ },
+ {
+ "from": 238,
+ "to": 250,
+ "style": "bold"
+ },
+ {
+ "from": 238,
+ "to": 250,
+ "style": "italic"
+ },
+ {
+ "from": 238,
+ "to": 250,
+ "style": "underline"
+ }
+ ],
+ "text": "Rich Editor\nApple iOS/macOS WYSIWYG Rich editor for SwiftUI.\n\nFeatures\nThe editor offers the following options\n\n- Bold\n- Italic\n- Underline\n- Different headers\n\nCredits\nRich Editor for swiftUI is owned and maintained by the canopas team\n\nThanks You ☺️\n"
+}
diff --git a/RichEditorSwiftUI.podspec b/RichEditorSwiftUI.podspec
new file mode 100644
index 0000000..018a0ed
--- /dev/null
+++ b/RichEditorSwiftUI.podspec
@@ -0,0 +1,24 @@
+Pod::Spec.new do |s|
+ s.name = "RichEditorSwiftUI"
+ s.version = "1.0.0"
+ s.summary = "Rich text editing, SwiftUI rich text editor library."
+
+ s.description = <<-DESC
+ Wrapper around UITextView to support Rich text editing in SwiftUI.
+ DESC
+
+ s.homepage = "https://github.com/canopas/RichEditorSwiftUI"
+ s.license = { :type => "MIT", :file => "LICENSE.md" }
+ s.author = { "Jimmy" => "jimmy@canopas.com" }
+ s.source = { :git => "https://github.com/canopas/rich-editor-swiftui.git", :tag => s.version.to_s }
+ s.source_files = "Sources/RichEditorSwiftUI/*.swift"
+ s.social_media_url = 'https://twitter.com/canopassoftware'
+
+ s.module_name = 'RichEditorSwiftUI'
+ s.requires_arc = true
+ s.swift_version = '5.5'
+
+ s.preserve_paths = 'README.md'
+
+ s.ios.deployment_target = '14.0'
+end
diff --git a/Sources/RichEditorSwiftUI/Attributes/RichTextAttribute.swift b/Sources/RichEditorSwiftUI/Attributes/RichTextAttribute.swift
new file mode 100644
index 0000000..c461032
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Attributes/RichTextAttribute.swift
@@ -0,0 +1,20 @@
+//
+// RichTextAttribute.swift
+//
+//
+// Created by Divyesh Vekariya on 28/12/23.
+//
+
+import Foundation
+
+/**
+ This typealias represents a rich text dictionary key.
+ */
+
+public typealias RichTextAttribute = NSAttributedString.Key
+
+/**
+ This typealias represents a ``RichTextAttribute`` keyed and
+ `Any` valued dictionary.
+ */
+public typealias RichTextAttributes = [RichTextAttribute: Any]
diff --git a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeReader.swift b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeReader.swift
new file mode 100644
index 0000000..c26c163
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeReader.swift
@@ -0,0 +1,67 @@
+//
+// RichTextAttributeReader.swift
+//
+//
+// Created by Divyesh Vekariya on 28/12/23.
+//
+
+import Foundation
+
+/**
+ This protocol extends ``RichTextReader`` with functionality
+ for reading rich text attributes for the current rich text.
+
+ The protocol is implemented by `NSAttributedString` as well
+ as other types in the library.
+ */
+public protocol RichTextAttributeReader: RichTextReader {}
+
+extension NSAttributedString: RichTextAttributeReader {}
+
+public extension RichTextAttributeReader {
+
+ /// Get a rich text attribute at a certain range.
+ func richTextAttribute(
+ _ attribute: RichTextAttribute,
+ at range: NSRange
+ ) -> Value? {
+ richTextAttributes(at: range)[attribute] as? Value
+ }
+
+ /// Get all rich text attributes at a certain range.
+ func richTextAttributes(
+ at range: NSRange
+ ) -> RichTextAttributes {
+ if richText.string.count == 0 { return [:] }
+ let range = safeRange(for: range, isAttributeOperation: true)
+ return richText.attributes(at: range.location, effectiveRange: nil)
+ }
+}
+
+// RichTextAttributeReader+Font
+public extension RichTextAttributeReader {
+
+ /// Get the font at a certain range.
+ func richTextFont(at range: NSRange) -> FontRepresentable? {
+ richTextAttribute(.font, at: range)
+ }
+
+ /// Get the font size (in points) at a certain range.
+ func richTextFontSize(at range: NSRange) -> CGFloat? {
+ richTextFont(at: range)?.pointSize
+ }
+}
+
+// RichTextAttributeReader+Style
+public extension RichTextAttributeReader {
+
+ /// Get the text styles at a certain range.
+ func richTextStyles(at range: NSRange) -> [RichTextStyle] {
+ let attributes = richTextAttributes(at: range)
+ let traits = richTextFont(at: range)?.fontDescriptor.symbolicTraits
+ var styles = traits?.enabledRichTextStyles ?? []
+ // if attributes.isStrikethrough { styles.append(.strikethrough) }
+ if attributes.isUnderlined { styles.append(.underline) }
+ return styles
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter+Style.swift b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter+Style.swift
new file mode 100644
index 0000000..f78d089
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter+Style.swift
@@ -0,0 +1,61 @@
+//
+// RichTextAttributeWriter+Style.swift
+//
+//
+// Created by Divyesh Vekariya on 28/12/23.
+//
+
+import Foundation
+
+public extension NSMutableAttributedString {
+
+ /**
+ Set a rich text style at a certain range.
+
+ The function uses `safeRange(for:)` to handle incorrect
+ ranges, which is not handled by the native functions.
+
+ - Parameters:
+ - style: The style to set.
+ - newValue: The new value to set the attribute to.
+ - range: The range to affect, by default the entire text.
+ */
+ func setRichTextStyle(
+ _ style: RichTextStyle,
+ to newValue: Bool,
+ at range: NSRange? = nil
+ ) {
+ let rangeValue = range ?? richTextRange
+ let range = safeRange(for: rangeValue)
+ let attributeValue = newValue ? 1 : 0
+ if style == .underline { return setRichTextAttribute(.underlineStyle, to: attributeValue, at: range) }
+ guard let font = richTextFont(at: range) else { return }
+ let styles = richTextStyles(at: range)
+ let shouldAdd = newValue && !styles.hasStyle(style)
+ let shouldRemove = !newValue && styles.hasStyle(style)
+ guard shouldAdd || shouldRemove || style.isHeaderStyle else { return }
+ var descriptor = font.fontDescriptor
+ if !style.isDefault && !style.isHeaderStyle {
+ descriptor = descriptor.byTogglingStyle(style)
+ }
+ let newFont: FontRepresentable? = FontRepresentable(
+ descriptor: descriptor,
+ size: byTogglingFontSizeFor(style: style, font: font, shouldAdd: newValue))
+ guard let newFont = newFont else { return }
+ setRichTextFont(newFont, at: range)
+ }
+
+ /**
+ This will reset font size befor multiplying new size
+ */
+ private func byTogglingFontSizeFor(style: TextSpanStyle, font: FontRepresentable, shouldAdd: Bool) -> CGFloat {
+ guard style.isHeaderStyle || style.isDefault else { return font.pointSize }
+
+ let cleanFont = style.getFontAfterRemovingStyle(font: font)
+ if shouldAdd {
+ return cleanFont.pointSize * style.fontSizeMultiplier
+ } else {
+ return font.pointSize / style.fontSizeMultiplier
+ }
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter.swift b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter.swift
new file mode 100644
index 0000000..be096cb
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributeWriter.swift
@@ -0,0 +1,82 @@
+//
+// RichTextAttributeWriter.swift
+//
+//
+// Created by Divyesh Vekariya on 28/12/23.
+//
+
+import Foundation
+
+/**
+ This protocol extends ``RichTextWriter`` with functionality
+ for writing rich text attributes to the current rich text.
+
+ This protocol is implemented by `NSMutableAttributedString`
+ as well as other types in the library.
+ */
+public protocol RichTextAttributeWriter: RichTextWriter, RichTextAttributeReader {}
+
+extension NSMutableAttributedString: RichTextAttributeWriter {}
+
+public extension RichTextAttributeWriter {
+
+ /**
+ Set a certain rich text attribute to a certain value at
+ the provided range.
+
+ The function uses `safeRange(for:)` to handle incorrect
+ ranges, which is not handled by the native functions.
+
+ - Parameters:
+ - attribute: The attribute to set.
+ - newValue: The new value to set the attribute to.
+ - range: The range to affect, by default the entire text.
+ */
+ func setRichTextAttribute(
+ _ attribute: RichTextAttribute,
+ to newValue: Any,
+ at range: NSRange? = nil
+ ) {
+ setRichTextAttributes([attribute: newValue], at: range)
+ }
+
+ /**
+ Set a set of rich text attributes at the provided range.
+
+ The function uses `safeRange(for:)` to handle incorrect
+ ranges, which is not handled by the native functions.
+
+ - Parameters:
+ - attributes: The attributes to set.
+ - range: The range to affect, by default the entire text.
+ */
+ func setRichTextAttributes(
+ _ attributes: RichTextAttributes,
+ at range: NSRange? = nil
+ ) {
+ let rangeValue = range ?? richTextRange
+ let range = safeRange(for: rangeValue)
+ guard let string = mutableRichText else { return }
+ string.beginEditing()
+ attributes.forEach { attribute, newValue in
+ string.enumerateAttribute(attribute, in: range, options: .init()) { _, range, _ in
+ string.removeAttribute(attribute, range: range)
+ string.addAttribute(attribute, value: newValue, range: range)
+ string.fixAttributes(in: range)
+ }
+ }
+ string.endEditing()
+ }
+}
+
+// RichTextAttributeWriter+Font
+public extension RichTextAttributeWriter {
+
+ /// Set the font at a certain range.
+ func setRichTextFont(
+ _ font: FontRepresentable,
+ at range: NSRange? = nil
+ ) {
+ setRichTextAttribute(.font, to: font, at: range)
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributes+RichTextStyle.swift b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributes+RichTextStyle.swift
new file mode 100644
index 0000000..cb02b17
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributes+RichTextStyle.swift
@@ -0,0 +1,27 @@
+//
+// RichTextAttributes+RichTextStyle.swift
+//
+//
+// Created by Divyesh Vekariya on 28/12/23.
+//
+
+import Foundation
+
+public extension RichTextAttributes {
+
+// /**
+// Whether or not the attributes has a strikethrough style.
+// */
+// var isStrikethrough: Bool {
+// get { self[.strikethroughStyle] as? Int == 1 }
+// set { self[.strikethroughStyle] = newValue ? 1 : 0 }
+// }
+//
+ /**
+ Whether or not the attributes has an underline style.
+ */
+ var isUnderlined: Bool {
+ get { self[.underlineStyle] as? Int == 1 }
+ set { self[.underlineStyle] = newValue ? 1 : 0 }
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Attributes/RichTextWriter.swift b/Sources/RichEditorSwiftUI/Attributes/RichTextWriter.swift
new file mode 100644
index 0000000..2cdc2c1
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Attributes/RichTextWriter.swift
@@ -0,0 +1,66 @@
+//
+// RichTextWriter.swift
+//
+//
+// Created by Divyesh Vekariya on 28/12/23.
+//
+
+import Foundation
+
+/**
+ This protocol extends ``RichTextReader`` and is implemented
+ by types that can provide a writable rich text string.
+
+ This protocol is implemented by `NSMutableAttributedString`
+ as well as other types in the library.
+ */
+public protocol RichTextWriter: RichTextReader {
+
+ /// Get the writable attributed string for the type.
+ var mutableAttributedString: NSMutableAttributedString? { get }
+}
+
+extension NSMutableAttributedString: RichTextWriter {
+
+ /// This type returns itself as the attributed string.
+ public var mutableAttributedString: NSMutableAttributedString? {
+ self
+ }
+}
+
+public extension RichTextWriter {
+
+ /**
+ Get the writable rich text provided by the implementing
+ type.
+
+ This is an alias for ``mutableAttributedString`` and is
+ used to get a property that uses the rich text naming.
+ */
+ var mutableRichText: NSMutableAttributedString? {
+ mutableAttributedString
+ }
+
+ /**
+ Replace the text in a certain range with a new string.
+
+ - Parameters:
+ - range: The range to replace text in.
+ - string: The string to replace the current text with.
+ */
+ func replaceText(in range: NSRange, with string: String) {
+ mutableRichText?.replaceCharacters(in: range, with: string)
+ }
+
+ /**
+ Replace the text in a certain range with a new string.
+
+ - Parameters:
+ - range: The range to replace text in.
+ - string: The string to replace the current text with.
+ */
+ func replaceText(in range: NSRange, with string: NSAttributedString) {
+ mutableRichText?.replaceCharacters(in: range, with: string)
+ }
+}
+
diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichText.swift b/Sources/RichEditorSwiftUI/Data/Models/RichText.swift
new file mode 100644
index 0000000..75d1ed7
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Data/Models/RichText.swift
@@ -0,0 +1,20 @@
+//
+// RichText.swift
+//
+//
+// Created by Divyesh Vekariya on 24/10/23.
+//
+
+import Foundation
+
+public struct RichText: Codable {
+ public let text: String
+ public let spans: [RichTextSpan]
+
+ public init(text: String = "", spans: [RichTextSpan] = []) {
+ self.text = text
+ self.spans = spans
+ }
+}
+
+
diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichTextSpan.swift b/Sources/RichEditorSwiftUI/Data/Models/RichTextSpan.swift
new file mode 100644
index 0000000..0cebe4b
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Data/Models/RichTextSpan.swift
@@ -0,0 +1,56 @@
+//
+// RichTextSpan.swift
+//
+//
+// Created by Divyesh Vekariya on 12/10/23.
+//
+
+import Foundation
+
+public struct RichTextSpan: Codable {
+ public let from: Int
+ public let to: Int
+ public let style: TextSpanStyle
+
+ public init(from: Int, to: Int, style: TextSpanStyle) {
+ self.from = from
+ self.to = to
+ self.style = style
+ }
+}
+
+extension RichTextSpan: Equatable {
+ public static func == (lhs: RichTextSpan, rhs: RichTextSpan) -> Bool {
+ return lhs.from == rhs.from && lhs.to == rhs.to && lhs.style == rhs.style
+ }
+}
+
+extension RichTextSpan: Hashable {
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(from)
+ hasher.combine(to)
+ hasher.combine(style)
+ }
+}
+
+extension RichTextSpan {
+ public var spanRange: NSRange {
+ let range = NSRange(location: from, length: (to - from) + 1)
+ guard range.length > 0 else { return .init(location: range.location, length: 0)}
+ return range
+ }
+
+ public var closedRange: ClosedRange {
+ return from...to
+ }
+
+ public var length: Int {
+ return to - from
+ }
+}
+
+extension RichTextSpan {
+ public func copy(from: Int? = nil, to: Int? = nil, style: TextSpanStyle? = nil) -> RichTextSpan {
+ return RichTextSpan(from: (from != nil ? from! : self.from), to: (to != nil ? to! : self.to), style: (style != nil ? style! : self.style))
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Fonts/FontMetricsRepresentable.swift b/Sources/RichEditorSwiftUI/Fonts/FontMetricsRepresentable.swift
new file mode 100644
index 0000000..ee27c3e
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Fonts/FontMetricsRepresentable.swift
@@ -0,0 +1,34 @@
+//
+// File.swift
+//
+//
+// Created by Divyesh Vekariya on 17/01/24.
+//
+
+import Foundation
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+import AppKit
+
+/**
+ This typealias bridges platform-specific font matrics to
+ simplify multi-platform support.
+
+ The typealias also defines additional functionality as type
+ extensions for the platform-specific types.
+ */
+public typealias FontMetricsRepresentable = NSFont.NSFontMetrics
+#endif
+
+#if canImport(UIKit)
+import UIKit
+
+/**
+ This typealias bridges platform-specific font matrics to
+ simplify multi-platform support.
+
+ The typealias also defines additional functionality as type
+ extensions for the platform-specific types.
+ */
+public typealias FontMetricsRepresentable = UIFontMetrics
+#endif
diff --git a/Sources/RichEditorSwiftUI/Fonts/FontRepresentable.swift b/Sources/RichEditorSwiftUI/Fonts/FontRepresentable.swift
new file mode 100644
index 0000000..5dfe0bf
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Fonts/FontRepresentable.swift
@@ -0,0 +1,37 @@
+//
+// FontRepresentable.swift
+//
+//
+// Created by Divyesh Vekariya on 28/12/23.
+//
+
+#if canImport(UIKit)
+import UIKit
+
+/**
+ This typealias bridges platform-specific fonts, to simplify
+ multi-platform support.
+ */
+public typealias FontRepresentable = UIFont
+#endif
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+import AppKit
+
+/**
+ This typealias bridges platform-specific fonts, to simplify
+ multi-platform support.
+ */
+public typealias FontRepresentable = NSFont
+#endif
+
+public extension FontRepresentable {
+
+ /**
+ The standard font to use for rich text.
+
+ You can change this value to affect all types that make
+ use of the value.
+ */
+ static var standardRichTextFont = systemFont(ofSize: .standardRichTextFontSize)
+}
diff --git a/Sources/RichEditorSwiftUI/Fonts/FontTraitsRepresentable.swift b/Sources/RichEditorSwiftUI/Fonts/FontTraitsRepresentable.swift
new file mode 100644
index 0000000..dadf06a
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Fonts/FontTraitsRepresentable.swift
@@ -0,0 +1,48 @@
+//
+// FontTraitsRepresentable.swift
+//
+//
+// Created by Divyesh Vekariya on 28/12/23.
+//
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+import AppKit
+
+/**
+ This typealias bridges platform-specific symbolic traits to
+ simplify multi-platform support.
+
+ The typealias also defines additional functionality as type
+ extensions for the platform-specific types.
+ */
+public typealias FontTraitsRepresentable = NSFontDescriptor.SymbolicTraits
+#endif
+
+#if canImport(UIKit)
+import UIKit
+
+/**
+ This typealias bridges platform-specific symbolic traits to
+ simplify multi-platform support.
+
+ The typealias also defines additional functionality as type
+ extensions for the platform-specific types.
+ */
+public typealias FontTraitsRepresentable = UIFontDescriptor.SymbolicTraits
+#endif
+
+public extension FontTraitsRepresentable {
+
+ /**
+ Get the rich text styles that are enabled in the traits.
+
+ Note that the traits only contain some of the available
+ rich text styles.
+ */
+ var enabledRichTextStyles: [RichTextStyle] {
+ RichTextStyle.allCases.filter {
+ guard let trait = $0.symbolicTraits else { return false }
+ return contains(trait)
+ }
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Fonts/StandardFontSizeProvider.swift b/Sources/RichEditorSwiftUI/Fonts/StandardFontSizeProvider.swift
new file mode 100644
index 0000000..48bde1f
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Fonts/StandardFontSizeProvider.swift
@@ -0,0 +1,41 @@
+//
+// StandardFontSizeProvider.swift
+//
+//
+// Created by Divyesh Vekariya on 12/01/24.
+//
+
+import Foundation
+
+public protocol StandardFontSizeProvider {}
+
+extension CGFloat: StandardFontSizeProvider {}
+
+extension Double: StandardFontSizeProvider {}
+
+extension RichEditorState: StandardFontSizeProvider {}
+
+#if iOS || macOS || os(tvOS)
+extension RichTextEditor: StandardFontSizeProvider {}
+
+extension RichTextView: StandardFontSizeProvider {}
+#endif
+
+public extension StandardFontSizeProvider {
+
+ /**
+ The standard font size to use for rich text.
+
+ You can change this value to affect all types that make
+ use of this value.
+ */
+ static var standardRichTextFontSize: CGFloat {
+ get { StandardFontSizeProviderStorage.standardRichTextFontSize }
+ set { StandardFontSizeProviderStorage.standardRichTextFontSize = newValue }
+ }
+}
+
+private class StandardFontSizeProviderStorage {
+
+ static var standardRichTextFontSize: CGFloat = 16
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift
new file mode 100644
index 0000000..4c0d346
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift
@@ -0,0 +1,28 @@
+//
+// RichEditor.swift
+//
+//
+// Created by Divyesh Vekariya on 24/10/23.
+//
+
+import SwiftUI
+
+public struct RichEditor: View {
+ @ObservedObject var state: RichEditorState
+
+ public init(state: ObservedObject) {
+ self._state = state
+ }
+
+ public var body: some View {
+ VStack(content: {
+ EditorToolBarView(state: state)
+
+ TextViewWrapper(state: _state,
+ attributesToApply: $state.attributesToApply,
+ isScrollEnabled: true,
+ fontStyle: state.curretFont,
+ onTextViewEvent: state.onTextViewEvent(_:))
+ })
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState.swift
new file mode 100644
index 0000000..7ed572e
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState.swift
@@ -0,0 +1,629 @@
+//
+// RichEditorState.swift
+//
+//
+// Created by Divyesh Vekariya on 11/12/23.
+//
+
+
+import Foundation
+import UIKit
+
+public class RichEditorState: ObservableObject {
+ private let input: String
+ private var adapter: EditorAdapter = DefaultAdapter()
+
+ @Published internal var editableText: NSMutableAttributedString
+ @Published internal var activeStyles: Set = []
+ @Published internal var activeAttributes: [NSAttributedString.Key: Any]? = [:]
+ internal var curretFont: FontRepresentable = .systemFont(ofSize: .standardRichTextFontSize)
+
+ @Published internal var attributesToApply: ((spans: [(span:RichTextSpan, shouldApply: Bool)], onCompletion: () -> Void))? = nil
+
+ private var spans: [RichTextSpan]
+ private var highlightedRange: NSRange
+ private var rawText: String
+
+ private var updateAttributesQueue: [(span:RichTextSpan, shouldApply: Bool)] = []
+
+ /**
+ This will provide encoded text which is of type RichText
+ */
+ public var richText: RichText {
+ return getRichText()
+ }
+
+ public init(input: String, spans: [RichTextSpan] = []) {
+ let adapter = DefaultAdapter()
+ self.input = input
+ self.adapter = adapter
+
+ let richText = input.isEmpty ? RichText() : adapter.encode(input: input, spans: spans)
+ editableText = NSMutableAttributedString(string: richText.text)
+ self.spans = richText.spans
+
+ highlightedRange = NSRange(location: 0, length: 0)
+ activeStyles = []
+
+ rawText = richText.text
+ setUpSpans()
+ }
+
+ /**
+ This will provide RichText which is encoded from input and editor text
+ */
+ private func getRichText() -> RichText {
+ return input.isEmpty ? RichText() : adapter.encode(input: input, spans: spans)
+ }
+
+ /**
+ This will provide String value from editor
+ */
+ public func output() -> String {
+ return adapter.decode(editorValue: richText)
+ }
+
+ /**
+ This will toggle the style
+ - Parameters:
+ - style: is of type TextSpanStyle
+ */
+ public func toggleStyle(style: TextSpanStyle) {
+ toggleStyle(style)
+ }
+
+ /**
+ This will update the style
+ - Parameters:
+ - style: is of type TextSpanStyle
+ */
+ public func updateStyle(style: TextSpanStyle) {
+ setStyle(style)
+ }
+
+ /**
+ This will setup edittor according to pasns provided in init
+ */
+ private func setUpSpans() {
+ applyStylesToSelectedText(spans)
+ }
+}
+
+
+extension RichEditorState {
+ /**
+ Handle UITextView's delegate methods calles
+ - Parameters:
+ - event: is of type TextVIewEvents
+ This will switch on event and call respective method
+ */
+ internal func onTextViewEvent(_ event: TextViewEvents) {
+ switch event {
+ case .didChangeSelection(let textView):
+ highlightedRange = textView.selectedRange
+ guard rawText.count == textView.attributedText.string.count && highlightedRange.isCollapsed else { return }
+ onSelectionChanged(textView.selectedRange, newText: NSMutableAttributedString(attributedString: textView.attributedText))
+ case .didBeginEditing(let textView):
+ highlightedRange = textView.selectedRange
+ case .didChange:
+ onTextFieldValueChange(newText: editableText, selection: highlightedRange)
+ case .didEndEditing:
+ highlightedRange = .init(location: 0, length: 0)
+ }
+ }
+
+ /**
+ This medo will decide whether Charater is added or removed and perfom accordingly
+ - Parameters:
+ - newText: is updated NSMutableAttributedString
+ - selection: is the range of the selected text
+ */
+ private func onTextFieldValueChange(newText: NSMutableAttributedString, selection: NSRange) {
+ self.highlightedRange = selection
+
+ if newText.string.count > rawText.count {
+ handleAddingCharacters(newText)
+ } else if newText.string.count < rawText.count {
+ handleRemovingCharacters(newText)
+ }
+
+ rawText = newText.string
+ updateCurrentSpanStyle()
+ }
+
+ /**
+ Update the selection
+ - Parameters:
+ - range: is the range of the selected text
+ - newText: is updated NSMutableAttributedString
+ */
+ internal func onSelectionChanged(_ range: NSRange, newText: NSMutableAttributedString) {
+ highlightedRange = range
+ updateCurrentSpanStyle()
+ }
+
+ /**
+ Set the activeStyles
+ - Parameters:
+ - style: is of type TextSpanStyle
+ This will set the activeStyle accordig to style passed
+ */
+ private func setStyle(_ style: TextSpanStyle) {
+ activeStyles.removeAll()
+ activeStyles.insert(style)
+
+ if style.isHeaderStyle || style.isDefault {
+ handleAddHeaderStyle(style)
+ } else if !highlightedRange.isCollapsed {
+ let span = RichTextSpan(from: highlightedRange.lowerBound, to: highlightedRange.upperBound - 1, style: style)
+ applyStylesToSelectedText([span])
+ if !style.isDefault {
+ createSpanForSelectedText(style)
+ }
+ }
+
+ updateCurrentSpanStyle()
+ }
+
+ /**
+ Update the activeStyles and activeAttibutes
+ */
+ private func updateCurrentSpanStyle() {
+ guard !editableText.string.isEmpty else { return }
+ var newStyles: Set = []
+
+ if highlightedRange.isCollapsed {
+ newStyles = getRichSpanStyleByTextIndex(highlightedRange.location - 1)
+ } else {
+ newStyles = Set(getRichSpanStyleListByTextRange(highlightedRange))
+ }
+
+ guard activeStyles != newStyles && highlightedRange.location != 0 else { return }
+ activeStyles = newStyles
+ var attributes: [NSAttributedString.Key: Any] = [:]
+ activeStyles.forEach({
+ attributes[$0.attributedStringKey] = $0.defaultAttributeValue(font: curretFont)
+ })
+
+ activeAttributes = attributes
+ }
+
+ /**
+ This will take style in argument and Toggle it
+ - Parameters:
+ - style: which is of type TextSpanStyle
+ It will add style if not in activeStyle or remove is it is.
+ */
+ private func toggleStyle(_ style: TextSpanStyle) {
+ if activeStyles.contains(style) {
+ removeStyle(style)
+ } else {
+ addStyle(style)
+ }
+ }
+
+ //MARK: - Add styles
+
+ /**
+ This will add style to the selected text
+ - Parameters:
+ - style: which is of type TextSpanStyle
+ It will add style to the selected text if needed and set activeAttributes and activeStyle accordingly.
+ */
+ private func addStyle(_ style: TextSpanStyle) {
+ guard !activeStyles.contains(style) else { return }
+ activeStyles.insert(style)
+
+ if (style.isHeaderStyle || style.isDefault) {
+ handleAddHeaderStyle(style)
+ } else if !highlightedRange.isCollapsed {
+ let fromIndex = highlightedRange.location
+ let toIdex = highlightedRange.upperBound - 1
+ applyStylesToSelectedText([RichTextSpan(from: fromIndex, to: toIdex, style: style)])
+ createSpanForSelectedText(style)
+ }
+ }
+
+ /**
+ This will add style to the range of text
+ - Parameters:
+ - style: which is of type TextSpanStyle
+ - range: is the range of the text on which you want to apply the style
+ */
+ private func applyStylesToSelectedText(_ spans: [RichTextSpan]) {
+ updateAttributes(spans: spans.map({ ($0, true) }))
+ }
+
+ /**
+ This will update editor text according to span provided in argument
+ - Parameters:
+ - spans: Which is of type Tuple of RichTextSpan and Bool
+
+ Where Bool is indicate wheter this style is need to add or remove.
+ */
+ private func updateAttributes(spans: [(RichTextSpan, shouldApply: Bool)]) {
+ if attributesToApply == nil {
+ attributesToApply = (spans: spans, onCompletion: { [weak self] in
+ self?.attributesToApply = nil
+ if let updateQueue = self?.updateAttributesQueue, !updateQueue.isEmpty {
+ self?.updateAttributes(spans: updateQueue)
+ self?.updateAttributesQueue.removeAll(where: { item in updateQueue.contains(where: { $0.span == item.span && $0.shouldApply == item.shouldApply })})
+ }
+ })
+ } else {
+ updateAttributesQueue.append(contentsOf: spans)
+ }
+ }
+
+ //MARK: - Remove Style
+ /**
+ This will remove style from active sytyle if it contains it
+ - Parameters:
+ - style: which is of type TextSpanStyle
+
+ This will remove typing attributes as well fot style.
+ */
+ private func removeStyle(_ style: TextSpanStyle) {
+ guard activeStyles.contains(style) else { return }
+ activeStyles.remove(style)
+ updateTypingAttributes()
+
+
+ guard !highlightedRange.isCollapsed else {
+ return
+ }
+
+ let fromIndex = highlightedRange.lowerBound
+ let toIndex = highlightedRange.upperBound
+
+ let span = RichTextSpan(from: fromIndex, to: toIndex - 1, style: style)
+ removeAttributes([span])
+
+ let selectedParts = spans.filter({ $0.from < toIndex && $0.to >= fromIndex && $0.style == style })
+
+ removeSpanForSelectedText(selectedParts, fromIndex: fromIndex, toIndex: toIndex)
+ }
+
+ /**
+ This will update the typing attribute according to active style
+ */
+ private func updateTypingAttributes() {
+ var attributes: [NSAttributedString.Key: Any] = [:]
+
+ activeStyles.forEach({
+ attributes[$0.attributedStringKey] = $0.defaultAttributeValue(font: curretFont)
+ })
+
+ activeAttributes = attributes
+ }
+
+ /**
+ This will remove the attributes from text for style
+ - Parameters:
+ - style: which is of type of TextSpanStyle
+ */
+ private func removeAttributes(_ spans: [RichTextSpan]) {
+ updateAttributes(spans: spans.map({ ($0, false) }))
+ }
+}
+
+//MARK: - Helper Methods
+extension RichEditorState {
+ /**
+ This will reset the editor. It will remove all the text form the editor.
+ */
+ public func reset() {
+ spans.removeAll()
+ rawText = ""
+ editableText = NSMutableAttributedString(string: "")
+ }
+
+ /**
+ This will allow you to set the editable text of editor
+ */
+ private func setEditable(editable: NSMutableAttributedString) {
+ editable.append(NSMutableAttributedString(string: editable.string))
+ self.editableText = editable
+ }
+
+ /**
+ This will provide Set of TextSpanStyle applied on given index
+ - Parameters:
+ - index: index or location of text
+ */
+ private func getRichSpanStyleByTextIndex(_ index: Int) -> Set {
+ let styles = Set(spans.filter { index >= $0.from && index <= $0.to }.map { $0.style })
+ return styles
+ }
+
+ /**
+ This will provide Array of TextSpanStyle applied on given range
+ - Parameters:
+ - rnage: range of text which is of type NSRange
+ */
+ private func getRichSpanStyleListByTextRange(_ range: NSRange) -> [TextSpanStyle] {
+ return spans.filter({ range.closedRange.overlaps($0.closedRange) }).map { $0.style }
+ }
+
+ /**
+ This will provide Array of RichTextSpan applied on given index
+ - Parameters:
+ - index: index or location of text
+ */
+ private func getRichSpansByTextIndex(_ index: Int) -> [RichTextSpan] {
+ return spans.filter({ index >= $0.from && index <= $0.to })
+ }
+
+ /**
+ This will provide Array of RichTextSpan applied on given range
+ - Parameters:
+ - rnage: range of text which is of type NSRange
+ */
+ private func getRichSpanListByTextRange(_ range: NSRange) -> [RichTextSpan] {
+ return spans.filter({ range.closedRange.overlaps($0.closedRange) })
+ }
+}
+
+//MARK: Span helper methods
+extension RichEditorState {
+ /**
+ This will handle the newlly added character in editor
+ - Parameters:
+ - newValue: is of type NSMutableAttributedString
+
+ This will generete break the span according to requirement to avoid duplication of the span.
+ */
+ private func handleAddingCharacters(_ newValue: NSMutableAttributedString) {
+ let typedChars = newValue.string.utf16.count - rawText.utf16.count
+ let startTypeIndex = highlightedRange.location - typedChars
+ let startTypeChar = newValue.string.utf16.map({ $0 })[startTypeIndex]
+
+ if startTypeChar == "\n".utf16.first && startTypeChar == "\n".utf16.last,
+ activeStyles.contains(where: { $0.isHeaderStyle }) {
+ activeStyles.removeAll()
+ }
+
+ var selectedStyles = activeStyles
+
+ moveSpans(startTypeIndex: startTypeIndex, by: typedChars)
+
+ let startParts = spans.filter { $0.closedRange.contains(startTypeIndex - 1) }
+ let endParts = spans.filter { $0.closedRange.contains(startTypeIndex) }
+ let commonParts = Set(startParts).intersection(Set(endParts))
+
+ startParts.filter { !commonParts.contains($0) }.forEach { part in
+ if selectedStyles.contains(part.style) {
+ if let index = spans.firstIndex(of: part) {
+ spans[index] = part.copy(to: part.to + typedChars)
+ selectedStyles.remove(part.style)
+ }
+ }
+ }
+
+ endParts.filter { !commonParts.contains($0) }.forEach { part in
+ processSpan(part, typedChars: typedChars, startTypeIndex: startTypeIndex, selectedStyles: &selectedStyles, forward: true)
+ }
+
+ commonParts.forEach { part in
+ processSpan(part, typedChars: typedChars, startTypeIndex: startTypeIndex, selectedStyles: &selectedStyles)
+ }
+
+ selectedStyles.forEach { style in
+ spans.append(RichTextSpan(from: startTypeIndex, to: startTypeIndex + typedChars - 1, style: style))
+ }
+ }
+
+ /**
+ This will handle the newlly added character in editor
+ - Parameters:
+ - startTypeIndex: is of type Int
+ - by: is of type Int
+
+ This will update the span according to requirement, like breake, removed, merge or extend.
+ */
+ private func processSpan(_ richTextSpan: RichTextSpan, typedChars: Int, startTypeIndex: Int, selectedStyles: inout Set, forward: Bool = false) {
+ let newFromIndex = richTextSpan.from + typedChars
+ let newToIndex = richTextSpan.to + typedChars
+
+ if let index = spans.firstIndex(of: richTextSpan) {
+ if selectedStyles.contains(richTextSpan.style) {
+ spans[index] = richTextSpan.copy(to: newToIndex)
+ selectedStyles.remove(richTextSpan.style)
+ } else {
+ if forward {
+ spans[index] = richTextSpan.copy(from: newFromIndex, to: newToIndex)
+ } else {
+ spans[index] = richTextSpan.copy(to: startTypeIndex - 1)
+ spans.insert(richTextSpan.copy(from: startTypeIndex + typedChars, to: newToIndex), at: index + 1)
+ selectedStyles.remove(richTextSpan.style)
+ }
+ }
+ }
+ }
+
+ /**
+ This will handle the newlly added character in editor
+ - Parameters:
+ - startTypeIndex: is of type Int
+ - step: is of type Int
+
+ This will move the span according to it's position if it is after the typed character then it will move forward by number or typed character which is step.
+ */
+ private func moveSpans(startTypeIndex: Int, by step: Int) {
+ let filteredSpans = spans.filter { $0.from > startTypeIndex }
+
+ filteredSpans.forEach { part in
+ if let index = spans.firstIndex(of: part) {
+ spans[index] = RichTextSpan(from: part.from + step, to: part.to + step, style: part.style)
+ }
+ }
+ }
+
+ /**
+ This will handle the removing character in editor and from relative span
+ - Parameters:
+ - newText: is of type NsMutableAttributedString
+
+ This will generete, break and remove the span according to requirement to avoid duplication and untracked span.
+ */
+ private func handleRemovingCharacters(_ newText: NSMutableAttributedString) {
+ guard !newText.string.isEmpty else {
+ spans.removeAll()
+ activeStyles.removeAll()
+ return
+ }
+
+ let removedCharsCount = rawText.count - newText.string.count
+ let startRemoveIndex = highlightedRange.location
+ let endRemoveIndex = highlightedRange.location + removedCharsCount - 1
+ let removeRange = startRemoveIndex...endRemoveIndex
+ let start = rawText.utf16.index(rawText.startIndex, offsetBy: startRemoveIndex)
+ let end = rawText.utf16.index(rawText.startIndex, offsetBy: endRemoveIndex)
+
+ if startRemoveIndex != endRemoveIndex, let newLineIndex = String(rawText[start...end]).map({ $0 }).lastIndex(of: "\n"), newLineIndex >= 0 {
+ handleRemoveHeaderStyle(newText: newText.string, at: removeRange.nsRange, newLineIndex: newLineIndex)
+ }
+
+ let partsCopy = spans
+
+ for part in partsCopy {
+ if let index = spans.firstIndex(of: part) {
+ if removeRange.upperBound < part.from {
+ spans[index] = RichTextSpan(from: part.from - removedCharsCount, to: part.to - removedCharsCount, style: part.style)
+ } else if removeRange.lowerBound <= part.from && removeRange.upperBound >= part.to {
+ // Remove the element from the copy.
+ spans.removeAll(where: { $0 == part })
+ } else if removeRange.lowerBound <= part.from {
+ spans[index] = RichTextSpan(from: max(0, removeRange.lowerBound), to: min(newText.string.count, part.to - removedCharsCount), style: part.style)
+ } else if removeRange.upperBound <= part.to {
+ spans[index] = RichTextSpan(from: part.from, to: part.to - removedCharsCount, style: part.style)
+ } else if removeRange.lowerBound < part.to {
+ spans[index] = RichTextSpan(from: part.from, to: removeRange.lowerBound, style: part.style)
+ }
+ }
+ }
+ }
+}
+
+//MARK: - Header style's relatated methods
+extension RichEditorState {
+
+ /**
+ This will handle the adding header style in editor and to relative span
+ - Parameters:
+ - style: is of type TextSpanStyle
+ */
+ private func handleAddHeaderStyle(_ style: TextSpanStyle) {
+ guard !rawText.isEmpty else {
+ return
+ }
+
+ let fromIndex = highlightedRange.lowerBound
+ let toIndex = highlightedRange.isCollapsed ? fromIndex : highlightedRange.upperBound
+ let startIndex = max(0, rawText.utf16.prefix(fromIndex).map({ $0 }).lastIndex(of: "\n".utf16.last) ?? 0)
+ let newLineAfterToIndex = rawText.utf16.suffix(from: rawText.utf16.index(rawText.utf16.startIndex, offsetBy: toIndex - 1)).map({ $0 }).firstIndex(of: "\n".utf16.last)
+ var endIndex = (toIndex - 1 ) + (newLineAfterToIndex ?? 0)
+
+ if newLineAfterToIndex == nil {
+ endIndex = (rawText.count - 1)
+ }
+
+ let range = startIndex...endIndex
+ let selectedParts = spans.filter { ($0.closedRange.overlaps(range))
+ && $0.style.isHeaderStyle }
+
+ spans.removeAll(where: { selectedParts.contains($0) })
+ let span = RichTextSpan(from: startIndex, to: endIndex, style: style)
+ if !style.isDefault{
+ spans.append(span)
+ }
+
+ applyStylesToSelectedText([span])
+
+ /// Fonts are update for header which removes older style which is applyed to it so need to apply again.
+ let spansToReapply = spans.filter({ $0.closedRange.overlaps(range) && !$0.style.isHeaderStyle })
+
+ if !spansToReapply.isEmpty {
+ applyStylesToSelectedText(spansToReapply)
+ }
+ }
+
+ /**
+ This will remove header style form selected range of text
+ - Parameters:
+ - newText: it's NSmutableAttributedString
+ - range: is the NSRange
+ - newLineIndex: is string index of new line where is it located
+ */
+ private func handleRemoveHeaderStyle(newText: String? = nil, at range: NSRange, newLineIndex: Int) {
+ let text = newText ?? rawText
+ let startIndex = max(0, text.map({ $0 }).index(before: newLineIndex))
+
+ let endIndex = text.map({ $0 }).index(after: newLineIndex)
+
+ let selectedParts = spans.filter({ ($0.from < endIndex && $0.to >= startIndex && $0.style.isHeaderStyle) })
+
+ spans.removeAll(where: { selectedParts.contains($0) })
+ }
+
+ /**
+ This will create span for selected text with provided style
+ - Parameters:
+ - style: is of type TextSpanStyle
+ */
+ private func createSpanForSelectedText(_ style: TextSpanStyle) {
+ guard !highlightedRange.isCollapsed else {
+ return
+ }
+
+ let fromIndex = highlightedRange.lowerBound
+ let toIndex = highlightedRange.upperBound
+
+ let selectedParts = spans.filter({ $0.from < toIndex && $0.to >= fromIndex })
+
+ let startParts = spans.filter { $0.from == fromIndex }
+ let endParts = spans.filter { $0.to == toIndex}
+
+ if startParts.isEmpty && endParts.isEmpty && !selectedParts.isEmpty {
+ spans.append(RichTextSpan(from: fromIndex, to: toIndex - 1, style: style))
+ } else if startParts.contains(where: { $0.style == style }) {
+ startParts.filter { $0.style == style }.forEach { part in
+ if let index = spans.firstIndex(of: part) {
+ spans[index] = part.copy(to: toIndex - 1)
+ }
+ }
+ } else if endParts.contains(where: { $0.style == style }) {
+ endParts.filter { $0.style == style }.forEach { part in
+ if let index = spans.firstIndex(of: part) {
+ spans[index] = part.copy(from: fromIndex)
+ }
+ }
+ } else {
+ spans.append(RichTextSpan(from: fromIndex, to: toIndex - 1, style: style))
+ }
+ }
+
+ /**
+ This will remove span for selected text
+ - Parameters:
+ - selectedParts: is of type [RichTextSpan]
+ - fromIndex: is of type Int and it's lower bound of selection range
+ - toIndex: is of type Int and it's upper bound of selection range
+ */
+ private func removeSpanForSelectedText(_ selectedParts: [RichTextSpan], fromIndex: Int, toIndex: Int) {
+ selectedParts.forEach { part in
+ if let index = spans.firstIndex(of: part) {
+ if part.from < fromIndex && part.to >= toIndex {
+ spans[index] = part.copy(to: fromIndex - 1)
+ spans.insert(part.copy(from: toIndex), at: index + 1)
+ } else if part.from < fromIndex {
+ spans[index] = part.copy(to: fromIndex - 1)
+ } else if part.to > toIndex {
+ spans[index] = part.copy(from: toIndex)
+ } else {
+ spans.remove(at: index)
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift b/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift
new file mode 100644
index 0000000..e6ed837
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift
@@ -0,0 +1,171 @@
+//
+// TextSpanStyle.swift
+//
+//
+// Created by Divyesh Vekariya on 11/10/23.
+//
+
+import SwiftUI
+
+public typealias RichTextStyle = TextSpanStyle
+
+public enum TextSpanStyle: String, Equatable, Codable, CaseIterable {
+ case `default` = "default"
+ case bold = "bold"
+ case italic = "italic"
+ case underline = "underline"
+ case h1 = "h1"
+ case h2 = "h2"
+ case h3 = "h3"
+ case h4 = "h4"
+ case h5 = "h5"
+ case h6 = "h6"
+
+ var key: String {
+ return self.rawValue
+ }
+
+ func defaultAttributeValue(font: FontRepresentable? = nil) -> Any {
+ let font = font ?? .systemFont(ofSize: .standardRichTextFontSize)
+ switch self {
+ case .underline:
+ return 1
+ case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6:
+ return getFontWithUpdating(font: font)
+ }
+ }
+
+ var attributedStringKey: NSAttributedString.Key {
+ switch self {
+ case .underline: return .underlineStyle
+ case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6:
+ return .font
+ }
+ }
+
+ public static func == (lhs: TextSpanStyle, rhs: TextSpanStyle) -> Bool {
+ return lhs.key == rhs.key
+ }
+
+ var editorTools: EditorTool? {
+ switch self {
+ case .default:
+ return .none
+ case .bold:
+ return .bold
+ case .italic:
+ return .italic
+ case .underline:
+ return .underline
+ case .h1:
+ return .header(.h1)
+ case .h2:
+ return .header(.h2)
+ case .h3:
+ return .header(.h3)
+ case .h4:
+ return .header(.h4)
+ case .h5:
+ return .header(.h5)
+ case .h6:
+ return .header(.h6)
+ }
+ }
+
+ var isHeaderStyle: Bool {
+ switch self {
+ case .h1, .h2, .h3, .h4, .h5, .h6:
+ return true
+ default:
+ return false
+ }
+ }
+
+ var isDefault: Bool {
+ switch self {
+ case .default:
+ return true
+ default:
+ return false
+ }
+ }
+
+ func getFontWithUpdating(font: FontRepresentable) -> FontRepresentable {
+ switch self {
+ case .default:
+ return font
+ case .bold,.italic:
+ return font.addFontStyle(self)
+ case .underline:
+ return font
+ case .h1:
+ return font.updateFontSize(multiple: 1.5)
+ case .h2:
+ return font.updateFontSize(multiple: 1.4)
+ case .h3:
+ return font.updateFontSize(multiple: 1.3)
+ case .h4:
+ return font.updateFontSize(multiple: 1.2)
+ case .h5:
+ return font.updateFontSize(multiple: 1.1)
+ case .h6:
+ return font.updateFontSize(multiple: 1)
+ }
+ }
+
+ var fontSizeMultiplier: CGFloat {
+ switch self {
+ case .h1:
+ return 1.5
+ case .h2:
+ return 1.4
+ case .h3:
+ return 1.3
+ case .h4:
+ return 1.2
+ case .h5:
+ return 1.1
+ default:
+ return 1
+ }
+ }
+
+ func getFontAfterRemovingStyle(font: FontRepresentable) -> FontRepresentable {
+ switch self {
+ case .bold, .italic:
+ return font.removeFontStyle(self)
+ case .underline:
+ return font
+ case .default, .h1, .h2, .h3, .h4, .h5, .h6:
+ return font.updateFontSize(size: .standardRichTextFontSize)
+ }
+ }
+}
+
+extension TextSpanStyle {
+
+ /// The symbolic font traits for the style, if any.
+ var symbolicTraits: UIFontDescriptor.SymbolicTraits? {
+ switch self {
+ case .bold:
+ return .traitBold
+ case .italic:
+ return .traitItalic
+ default:
+ return nil
+ }
+ }
+}
+
+public extension Collection where Element == RichTextStyle {
+
+ /**
+ Whether or not the collection contains a certain style.
+
+ - Parameters:
+ - style: The style to look for.
+ */
+ func hasStyle(_ style: RichTextStyle) -> Bool {
+ contains(style)
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorTool.swift b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorTool.swift
new file mode 100644
index 0000000..6aae7de
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorTool.swift
@@ -0,0 +1,118 @@
+//
+// EditorTool.swift
+//
+//
+// Created by Divyesh Vekariya on 19/12/23.
+//
+
+import SwiftUI
+
+enum EditorTool: CaseIterable, Hashable {
+ static var allCases: [EditorTool] {
+ return [.header(), .bold, .italic, .underline]
+ }
+
+ case header(HeaderOptions? = nil), bold, italic, underline
+
+ var systemImageName: String {
+ switch self {
+ case .header: return "textformat.size"
+ case .bold: return "bold"
+ case .italic: return "italic"
+ case .underline: return "underline"
+ }
+ }
+
+ var isContainManu: Bool {
+ switch self {
+ case .header:
+ return true
+ default:
+ return false
+ }
+ }
+
+ func getTextSpanStyle() -> TextSpanStyle {
+ switch self {
+ case .header(let headerOptions):
+ switch headerOptions {
+ case .default: return .default
+ case .h1: return .h1
+ case .h2: return .h2
+ case .h3: return .h3
+ case .h4: return .h4
+ case .h5: return .h5
+ case .h6: return .h6
+ case .none:
+ return .default
+ }
+ case .bold:
+ return .bold
+ case .italic:
+ return .italic
+ case .underline:
+ return .underline
+ }
+ }
+
+ func isSelected(_ currentStyle: Set) -> Bool {
+ switch self {
+ case .header:
+ return currentStyle.contains(.h1) || currentStyle.contains(.h2) || currentStyle.contains(.h3) || currentStyle.contains(.h4) || currentStyle.contains(.h5) || currentStyle.contains(.h6)
+ case .bold:
+ return currentStyle.contains(.bold)
+ case .italic:
+ return currentStyle.contains(.italic)
+ case .underline:
+ return currentStyle.contains(.underline)
+ }
+ }
+
+ var key: String {
+ switch self {
+ case .header:
+ return "header"
+ case .bold:
+ return "bold"
+ case .italic:
+ return "italic"
+ case .underline:
+ return "underline"
+ }
+ }
+}
+
+enum HeaderOptions: CaseIterable {
+ case `default`, h1, h2, h3, h4, h5, h6
+
+ var title: String {
+ switch self {
+ case .default:
+ return "Normal Text"
+ case .h1:
+ return "Header 1"
+ case .h2:
+ return "Header 2"
+ case .h3:
+ return "Header 3"
+ case .h4:
+ return "Header 4"
+ case .h5:
+ return "Header 5"
+ case .h6:
+ return "Header 6"
+ }
+ }
+
+ func getTextSpanStyle() -> TextSpanStyle {
+ switch self {
+ case .default: return .default
+ case .h1: return .h1
+ case .h2: return .h2
+ case .h3: return .h3
+ case .h4: return .h4
+ case .h5: return .h5
+ case .h6: return .h6
+ }
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift
new file mode 100644
index 0000000..44b4854
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift
@@ -0,0 +1,105 @@
+//
+// EditorToolBarView.swift
+//
+//
+// Created by Divyesh Vekariya on 11/10/23.
+//
+
+import SwiftUI
+
+struct EditorToolBarView: View {
+ @ObservedObject var state: RichEditorState
+
+ var body: some View {
+ LazyHStack(spacing: 5, content: {
+ ForEach(EditorTool.allCases, id: \.self) { tool in
+ if tool.isContainManu {
+ TitleStyleButton(tool: tool, appliedTools: state.activeStyles, setStyle: state.updateStyle(style:))
+ } else {
+ ToggleStyleButton(tool: tool, appliedTools: state.activeStyles, onToolSelect: state.toggleStyle(style:))
+ }
+ }
+ })
+ .frame(height: 50)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.gray.opacity(0.1))
+ }
+}
+
+private struct ToggleStyleButton: View {
+
+ let tool: EditorTool
+ let appliedTools: Set
+ let onToolSelect: (TextSpanStyle) -> Void
+
+ private var isSelected: Bool {
+ tool.isSelected(appliedTools)
+ }
+
+
+ var body: some View {
+ Button(action: {
+ onToolSelect(tool.getTextSpanStyle())
+ }, label: {
+ HStack(alignment: .center, spacing: 4, content: {
+ Image(systemName: tool.systemImageName)
+ .font(.title)
+ })
+ .foregroundColor(isSelected ? .blue : .black)
+ .frame(width: 45, height: 50, alignment: .center)
+ .padding(.horizontal, 3)
+ .background(isSelected ? Color.gray.opacity(0.1) : Color.clear)
+ })
+ }
+}
+
+struct TitleStyleButton: View {
+ let tool: EditorTool
+ let appliedTools: Set
+ let setStyle: (TextSpanStyle) -> Void
+
+ private var isSelected: Bool {
+ tool.isSelected(appliedTools)
+ }
+
+ @State var isExpanded: Bool = false
+
+ var body: some View {
+
+ Menu(content: {
+ ForEach(HeaderOptions.allCases, id: \.self) { header in
+ Button(action: {
+ isExpanded = false
+ setStyle(EditorTool.header(header).getTextSpanStyle())
+ }, label: {
+ if hasStayle(header.getTextSpanStyle()) {
+ Label(header.title, systemImage:"checkmark")
+ .foregroundColor(.blue)
+ } else {
+ Text(header.title)
+ }
+ })
+ }
+ }, label: {
+ HStack(alignment: .center, spacing: 4, content: {
+ Image(systemName: tool.systemImageName)
+ .font(.title)
+
+ Image(systemName: "chevron.down")
+ .font(.subheadline)
+ })
+ .foregroundColor(isSelected ? .blue : .black)
+ .frame(width: 60, height: 50, alignment: .center)
+ .padding(.horizontal, 3)
+ .background(isSelected ? Color.gray.opacity(0.1) : Color.clear)
+ })
+ .onTapGesture {
+ isExpanded.toggle()
+ }
+
+ }
+
+ func hasStayle(_ style: TextSpanStyle) -> Bool {
+ return appliedTools.contains(where: { $0.key == style.key })
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/FontRepresentable+Extension.swift b/Sources/RichEditorSwiftUI/UI/Extensions/FontRepresentable+Extension.swift
new file mode 100644
index 0000000..3196286
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Extensions/FontRepresentable+Extension.swift
@@ -0,0 +1,139 @@
+//
+// FontRepresentable+Extension.swift
+//
+//
+// Created by Divyesh Vekariya on 26/12/23.
+//
+
+import SwiftUI
+
+extension FontRepresentable {
+ /// Check if font is bold
+ var isBold: Bool {
+ return fontDescriptor.symbolicTraits.contains(.traitBold)
+ }
+
+ /// Check if font is italic
+ var isItalic: Bool {
+ return fontDescriptor.symbolicTraits.contains(.traitItalic)
+ }
+
+ /// Make font **Bold**
+ func makeBold() -> FontRepresentable {
+ if isBold {
+ return self
+ } else {
+ let fontDesc = fontDescriptor.byTogglingStyle(.bold)
+ fontDesc.withFamily(familyName)
+ return FontRepresentable(descriptor: fontDesc, size: pointSize)
+ }
+ }
+
+ /// Make font **Italic**
+ func makeItalic() -> FontRepresentable {
+ if isItalic {
+ return self
+ } else {
+ let fontDesc = fontDescriptor.byTogglingStyle(.italic)
+ fontDesc.withFamily(familyName)
+ return FontRepresentable(descriptor: fontDesc, size: pointSize)
+ }
+ }
+
+ /// Make font **Bold** and **Italic**
+ func setBoldItalicStyles() -> FontRepresentable {
+ return makeBold().makeItalic()
+ }
+
+ /// Remove **Bold** style from font
+ func removeBoldStyle() -> FontRepresentable {
+ if !isBold {
+ return self
+ } else {
+ let fontDesc = fontDescriptor.byTogglingStyle(.bold)
+ fontDesc.withFamily(familyName)
+ return FontRepresentable(descriptor: fontDesc, size: pointSize)
+ }
+ }
+
+ /// Remove **Italic** style from font
+ func removeItalicStyle() -> FontRepresentable {
+ if !isItalic {
+ return self
+ } else {
+ let fontDesc = fontDescriptor.byTogglingStyle(.italic)
+ fontDesc.withFamily(familyName)
+ return FontRepresentable(descriptor: fontDesc, size: pointSize)
+ }
+ }
+
+ /// Remove **Bold** and **Italic** style from font
+ func makeNormal() -> FontRepresentable {
+ return removeBoldStyle().removeItalicStyle()
+ }
+
+ /// Toggle **Bold** style of font
+ func toggleBoldTrait() -> FontRepresentable {
+ if isBold {
+ return removeBoldStyle()
+ } else {
+ return makeBold()
+ }
+ }
+
+ /// Toggle **Italic** style of font
+ func toggleItalicStyle() -> FontRepresentable {
+ if isItalic {
+ return removeItalicStyle()
+ } else {
+ return makeItalic()
+ }
+ }
+
+ /// Get a new font with updated font size by **size**
+ func updateFontSize(size: CGFloat) -> FontRepresentable {
+ if pointSize != size {
+ let fontDesc = fontDescriptor
+ fontDesc.withFamily(familyName)
+ return FontRepresentable(descriptor: fontDesc, size: size)
+ } else {
+ return self
+ }
+ }
+
+ func updateFontSize(multiple: CGFloat) -> FontRepresentable {
+ if pointSize != multiple * pointSize {
+ let size = multiple * pointSize
+ let fontDesc = fontDescriptor
+ fontDesc.withFamily(familyName)
+ return FontRepresentable(descriptor: fontDesc, size: size)
+ } else {
+ return self
+ }
+ }
+}
+
+public extension FontRepresentable {
+ /// Get a new font by adding a text style.
+ func addFontStyle(_ style: TextSpanStyle) -> FontRepresentable {
+ guard let trait = style.symbolicTraits, !fontDescriptor.symbolicTraits.contains(trait) else { return self }
+ let fontDesc = fontDescriptor.byTogglingStyle(style)
+ fontDesc.withFamily(familyName)
+ return FontRepresentable(descriptor: fontDesc, size: pointSize)
+ }
+
+ ///Get a new font by removing a text style.
+ func removeFontStyle(_ style: TextSpanStyle) -> FontRepresentable {
+ guard let trait = style.symbolicTraits, fontDescriptor.symbolicTraits.contains(trait) else { return self }
+ let fontDesc = fontDescriptor.byTogglingStyle(style)
+ fontDesc.withFamily(familyName)
+ return FontRepresentable(descriptor: fontDesc, size: pointSize)
+ }
+
+ /// Get a new font by toggling a text style.
+ func byToggalingFontStyle(_ style: TextSpanStyle) -> FontRepresentable {
+ let fontDesc = fontDescriptor.byTogglingStyle(style)
+ fontDesc.withFamily(familyName)
+ return FontRepresentable(descriptor: fontDesc, size: pointSize)
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/NSRange+Extension.swift b/Sources/RichEditorSwiftUI/UI/Extensions/NSRange+Extension.swift
new file mode 100644
index 0000000..aac4c46
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Extensions/NSRange+Extension.swift
@@ -0,0 +1,30 @@
+//
+// NSRange+Extension.swift
+//
+//
+// Created by Divyesh Vekariya on 11/12/23.
+//
+
+import Foundation
+
+extension NSRange {
+ var isCollapsed: Bool {
+ return self.length == 0 || self.upperBound == self.lowerBound
+ }
+
+ var closedRange: ClosedRange {
+ return lowerBound...(upperBound - (length > 0 ? 1 : 0))
+ }
+}
+
+extension ClosedRange {
+ var nsRange: NSRange {
+ return NSRange(location: lowerBound, length: upperBound - lowerBound)
+ }
+}
+
+extension Range {
+ var nsRange: NSRange {
+ return NSRange(location: lowerBound, length: upperBound - lowerBound)
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/String+Characters.swift b/Sources/RichEditorSwiftUI/UI/Extensions/String+Characters.swift
new file mode 100644
index 0000000..dea11c6
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Extensions/String+Characters.swift
@@ -0,0 +1,38 @@
+//
+// String+Characters.swift
+//
+//
+// Created by Divyesh Vekariya on 01/01/24.
+//
+
+import Foundation
+
+public extension String.Element {
+
+ /// Get the string element for a `\r` carriage return.
+ static var carriageReturn: String.Element { "\r" }
+
+ /// Get the string element for a `\n` newline.
+ static var newLine: String.Element { "\n" }
+
+ /// Get the string element for a `\t` tab.
+ static var tab: String.Element { "\t" }
+
+ /// Get the string element for a ` ` space.
+ static var space: String.Element { " " }
+}
+
+public extension String {
+
+ /// Get the string for a `\r` carriage return.
+ static let carriageReturn = String(.carriageReturn)
+
+ /// Get the string for a `\n` newline.
+ static let newLine = String(.newLine)
+
+ /// Get the string for a `\t` tab.
+ static let tab = String(.tab)
+
+ /// Get the string for a ` ` space.
+ static let space = String(.space)
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/String+Extension.swift b/Sources/RichEditorSwiftUI/UI/Extensions/String+Extension.swift
new file mode 100644
index 0000000..5d45089
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Extensions/String+Extension.swift
@@ -0,0 +1,129 @@
+//
+// String+Extension.swift
+//
+//
+// Created by Divyesh Vekariya on 18/10/23.
+//
+
+import SwiftUI
+
+extension String {
+ subscript (i: Int) -> String {
+ return self[i ..< i + 1]
+ }
+
+ func substring(fromIndex: Int) -> String {
+ return self[min(fromIndex, count) ..< count]
+ }
+
+ func substring(toIndex: Int) -> String {
+ return self[0 ..< max(0, toIndex)]
+ }
+
+ subscript (r: Range) -> String {
+ let range = Range(uncheckedBounds: (lower: max(0, min(count, r.lowerBound)),
+ upper: min(count, max(0, r.upperBound))))
+ let start = index(startIndex, offsetBy: range.lowerBound)
+ let end = index(start, offsetBy: range.upperBound - range.lowerBound)
+ return String(self[start ..< end])
+ }
+}
+
+internal struct NSFontTraitMask: OptionSet {
+ internal let rawValue: Int
+ internal static let boldFontMask = NSFontTraitMask(rawValue: 1 << 0)
+ internal static let unboldFontMask = NSFontTraitMask(rawValue: 1 << 1)
+ internal static let italicFontMask = NSFontTraitMask(rawValue: 1 << 2)
+ internal static let unitalicFontMask = NSFontTraitMask(rawValue: 1 << 3)
+ internal static let all: NSFontTraitMask = [.boldFontMask, .unboldFontMask, .italicFontMask, .unitalicFontMask]
+ internal init(rawValue: Int) {
+ self.rawValue = rawValue
+ }
+}
+
+extension NSMutableAttributedString {
+ internal func applyFontTraits(_ traitMask: NSFontTraitMask, range: NSRange) {
+ enumerateAttribute(.font, in: range, options: [.longestEffectiveRangeNotRequired]) { (attr, attrRange, stop) in
+ guard let font = attr as? FontRepresentable else { return }
+ let descriptor = font.fontDescriptor
+ var symbolicTraits = descriptor.symbolicTraits
+ if traitMask.contains(.boldFontMask) {
+ symbolicTraits.insert(.traitBold)
+ }
+ if symbolicTraits.contains(.traitBold) && traitMask.contains(.unboldFontMask) {
+ symbolicTraits.remove(.traitBold)
+ }
+ if traitMask.contains(.italicFontMask) {
+ symbolicTraits.insert(.traitItalic)
+ }
+ if symbolicTraits.contains(.traitItalic) && traitMask.contains(.unitalicFontMask) {
+ symbolicTraits.remove(.traitItalic)
+ }
+ guard let newDescriptor = descriptor.withSymbolicTraits(symbolicTraits) else { return }
+ let newFont = FontRepresentable(descriptor: newDescriptor, size: font.pointSize)
+ self.addAttribute(.font, value: newFont, range: attrRange)
+ }
+ }
+}
+
+
+/**
+ This extension makes it possible to fetch characters from a
+ string, as discussed here:
+
+ https://stackoverflow.com/questions/24092884/get-nth-character-of-a-string-in-swift-programming-language
+ */
+public extension StringProtocol {
+
+ func character(at index: Int) -> String.Element? {
+ if index < 0 { return nil }
+ guard count > index else { return nil }
+ return self[index]
+ }
+
+ func character(at index: UInt) -> String.Element? {
+ character(at: Int(index))
+ }
+
+ subscript(_ offset: Int) -> Element {
+ self[index(startIndex, offsetBy: offset)]
+ }
+
+ subscript(_ range: Range) -> SubSequence {
+ prefix(range.lowerBound+range.count).suffix(range.count)
+ }
+
+ subscript(_ range: ClosedRange) -> SubSequence {
+ prefix(range.lowerBound+range.count).suffix(range.count)
+ }
+
+ subscript(_ range: PartialRangeThrough) -> SubSequence {
+ prefix(range.upperBound.advanced(by: 1))
+ }
+
+ subscript(_ range: PartialRangeUpTo) -> SubSequence {
+ prefix(range.upperBound)
+ }
+
+ subscript(_ range: PartialRangeFrom) -> SubSequence {
+ suffix(Swift.max(0, count-range.lowerBound))
+ }
+}
+
+private extension LosslessStringConvertible {
+
+ var string: String { .init(self) }
+}
+
+private extension BidirectionalCollection {
+
+ subscript(safe offset: Int) -> Element? {
+ if isEmpty { return nil }
+ guard let index = index(
+ startIndex,
+ offsetBy: offset,
+ limitedBy: index(before: endIndex))
+ else { return nil }
+ return self[index]
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Helper/RichTextReader.swift b/Sources/RichEditorSwiftUI/UI/Helper/RichTextReader.swift
new file mode 100644
index 0000000..af37be4
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Helper/RichTextReader.swift
@@ -0,0 +1,89 @@
+//
+// RichTextReader.swift
+//
+//
+// Created by Divyesh Vekariya on 28/12/23.
+//
+
+import Foundation
+
+/**
+ This protocol can be implemented any types that can provide
+ a rich text string.
+
+ The protocol is implemented by `NSAttributedString` as well
+ as other types in the library.
+ */
+public protocol RichTextReader {
+
+ /// The attributed string to use as rich text.
+ var attributedString: NSAttributedString { get }
+}
+
+extension NSAttributedString: RichTextReader {
+
+ /// This type returns itself as the attributed string.
+ public var attributedString: NSAttributedString { self }
+}
+
+public extension RichTextReader {
+
+ /**
+ The rich text to use.
+
+ This is a convenience name alias for ``attributedString``
+ to provide this type with a property that uses the rich
+ text naming convention.
+ */
+ var richText: NSAttributedString {
+ attributedString
+ }
+
+ /**
+ Get the range of the entire ``richText``.
+
+ This uses `safeRange(for:)` to return a range that willö
+ always be valid for the current rich text.
+ */
+ var richTextRange: NSRange {
+ let range = NSRange(location: 0, length: richText.string.count)
+ let safeRange = safeRange(for: range)
+ return safeRange
+ }
+
+ /**
+ Get the rich text at a certain range.
+
+ Since this function uses `safeRange(for:)` to not crash
+ for invalid ranges, always use this function instead of
+ the unsafe `attributedSubstring`.
+
+ - Parameters:
+ - range: The range for which to get the rich text.
+ */
+ func richText(at range: NSRange) -> NSAttributedString {
+ let range = safeRange(for: range)
+ return attributedString.attributedSubstring(from: range)
+ }
+
+ /**
+ Get a safe range for the provided range.
+
+ A safe range is limited to the bounds of the attributed
+ string and helps protecting against range overflow.
+
+ - Parameters:
+ - range: The range for which to get a safe range.
+ - isAttributeOperation: Set this to `true` to avoid last position.
+ */
+ func safeRange(
+ for range: NSRange,
+ isAttributeOperation: Bool = false
+ ) -> NSRange {
+ let length = attributedString.string.count
+ let subtract = isAttributeOperation ? 1 : 0
+ return NSRange(
+ location: max(0, min(length - subtract, range.location)),
+ length: min(range.length, max(0, length - range.location)))
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Parser/EditorAdapter.swift b/Sources/RichEditorSwiftUI/UI/Parser/EditorAdapter.swift
new file mode 100644
index 0000000..068f5c5
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Parser/EditorAdapter.swift
@@ -0,0 +1,23 @@
+//
+// EditorAdapter.swift
+//
+//
+// Created by Divyesh Vekariya on 11/12/23.
+//
+
+import Foundation
+
+protocol EditorAdapter {
+ func encode(input: String, spans: [RichTextSpan]) -> RichText
+ func decode(editorValue: RichText) -> String
+}
+
+class DefaultAdapter: EditorAdapter {
+ func encode(input: String, spans: [RichTextSpan] = []) -> RichText {
+ return RichText(text: input, spans: spans)
+ }
+
+ func decode(editorValue: RichText) -> String {
+ return editorValue.text
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Utils/TextViewWrapper.swift b/Sources/RichEditorSwiftUI/UI/Utils/TextViewWrapper.swift
new file mode 100644
index 0000000..0a1348b
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Utils/TextViewWrapper.swift
@@ -0,0 +1,192 @@
+//
+// TextViewWrapper.swift
+//
+//
+// Created by Divyesh Vekariya on 12/10/23.
+//
+
+import SwiftUI
+
+internal struct TextViewWrapper: UIViewRepresentable {
+ @ObservedObject var state: RichEditorState
+
+ @Binding private var typingAttributes: [NSAttributedString.Key: Any]?
+ @Binding private var attributesToApply: ((spans: [(span:RichTextSpan, shouldApply: Bool)], onCompletion: () -> Void))?
+
+ private let isEditable: Bool
+ private let isUserInteractionEnabled: Bool
+ private let isScrollEnabled: Bool
+ private let linelimit: Int?
+ private let fontStyle: FontRepresentable?
+ private let fontColor: Color
+ private let backGroundColor: UIColor
+ private let tag: Int?
+ private let onTextViewEvent: ((TextViewEvents) -> Void)?
+
+ public init(state: ObservedObject,
+ typingAttributes: Binding<[NSAttributedString.Key: Any]?>? = nil,
+ attributesToApply: Binding<(spans: [(span:RichTextSpan, shouldApply: Bool)], onCompletion: () -> Void)?>? = nil,
+ isEditable: Bool = true,
+ isUserInteractionEnabled: Bool = true,
+ isScrollEnabled: Bool = false,
+ linelimit: Int? = nil,
+ fontStyle: FontRepresentable? = nil,
+ fontColor: Color = .black,
+ backGroundColor: UIColor = .clear,
+ tag: Int? = nil,
+ onTextViewEvent: ((TextViewEvents) -> Void)?) {
+ self._state = state
+ self._typingAttributes = typingAttributes != nil ? typingAttributes! : .constant(nil)
+ self._attributesToApply = attributesToApply != nil ? attributesToApply! : .constant(nil)
+
+ self.isEditable = isEditable
+ self.isUserInteractionEnabled = isUserInteractionEnabled
+ self.isScrollEnabled = isScrollEnabled
+ self.linelimit = linelimit
+ self.fontStyle = fontStyle
+ self.fontColor = fontColor
+ self.backGroundColor = backGroundColor
+ self.tag = tag
+ self.onTextViewEvent = onTextViewEvent
+ }
+
+ public func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ public func makeUIView(context: Context) -> TextViewOverRidden {
+ let textView = TextViewOverRidden()
+ textView.delegate = context.coordinator
+ textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ textView.isScrollEnabled = isScrollEnabled
+ textView.isEditable = isEditable
+ textView.isUserInteractionEnabled = isUserInteractionEnabled
+ textView.backgroundColor = UIColor.clear
+ if let fontStyle {
+ textView.typingAttributes = [.font: fontStyle]
+ }
+ if let fontStyle {
+ let string = NSMutableAttributedString(string: state.editableText.string, attributes: [.font: fontStyle])
+ textView.attributedText = string
+ } else {
+ textView.attributedText = state.editableText
+ }
+
+ textView.textColor = UIColor(fontColor)
+ textView.textContainer.lineFragmentPadding = 0
+ textView.showsVerticalScrollIndicator = false
+ textView.showsHorizontalScrollIndicator = false
+ textView.isSelectable = true
+
+ if let attributes = typingAttributes {
+ textView.typingAttributes = attributes
+ }
+
+ if let tag = tag {
+ textView.tag = tag
+ }
+
+ if let linelimit = linelimit {
+ textView.textContainer.maximumNumberOfLines = linelimit
+ }
+
+ if let fontStyle = fontStyle {
+ let scaledFontSize = UIFontMetrics.default.scaledValue(for: fontStyle.pointSize)
+ let scaledFont = fontStyle.withSize(scaledFontSize)
+ textView.font = scaledFont
+ }
+
+ return textView
+ }
+
+ public func updateUIView(_ textView: TextViewOverRidden, context: Context) {
+ textView.textColor = UIColor(fontColor)
+ ///Set typing attributes
+ var attributes = getTypingAttributesForStyles(state.activeStyles)
+ if !attributes.contains(where: { $0.key == .font }) {
+ attributes[.font] = fontStyle
+ }
+ textView.typingAttributes = attributes
+
+ //Update attributes in textStorage
+ if let data = attributesToApply {
+ applyAttributesToSelectedRange(textView, spans: data.spans, onCompletion: data.onCompletion)
+ }
+
+ textView.reloadInputViews()
+ }
+
+ internal class Coordinator: NSObject, UITextViewDelegate {
+ var parent: TextViewWrapper
+
+ init(_ uiTextView: TextViewWrapper) {
+ self.parent = uiTextView
+ }
+
+ func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
+ return true
+ }
+
+ func textViewDidChangeSelection(_ textView: UITextView) {
+ //Invoked when selection change whether it is text lelected or pointer moved any were
+ parent.onTextViewEvent?(.didChangeSelection(textView))
+ }
+
+ func textViewDidChange(_ textView: UITextView) {
+ if textView.markedTextRange == nil {
+ parent.state.editableText = NSMutableAttributedString(attributedString: textView.attributedText)
+ }
+ parent.onTextViewEvent?(.didChange(textView))
+ }
+
+ func textViewDidBeginEditing(_ textView: UITextView) {
+ //Invoked when text view start editing (TextView get focuse or become first responder)
+ parent.onTextViewEvent?(.didBeginEditing(textView))
+ }
+
+ func textViewDidEndEditing(_ textView: UITextView) {
+ parent.onTextViewEvent?(.didEndEditing(textView))
+ }
+ }
+
+ internal func getTypingAttributesForStyles(_ styles : Set) -> RichTextAttributes {
+ var font = fontStyle
+ var attributes: RichTextAttributes = [:]
+
+ Set(styles).forEach({
+ if $0.attributedStringKey == .font {
+ font = $0.getFontWithUpdating(font: font ?? .systemFont(ofSize: .standardRichTextFontSize))
+ attributes[$0.attributedStringKey] = font
+ } else {
+ attributes[$0.attributedStringKey] = $0.defaultAttributeValue(font: fontStyle)
+ }
+ })
+ return attributes
+ }
+
+ internal func applyAttributesToSelectedRange(_ textView: TextViewOverRidden, spans: [(span:RichTextSpan, shouldApply: Bool)], onCompletion: (() -> Void)? = nil) {
+ var spansToUpdate = spans.filter({ $0.span.style.isHeaderStyle })
+ spansToUpdate.append(contentsOf: spans.filter({ !$0.span.style.isHeaderStyle }))
+ spansToUpdate.forEach {
+ textView.textStorage.setRichTextStyle($0.span.style, to: $0.shouldApply, at: $0.span.spanRange)
+ }
+ onCompletion?()
+ }
+}
+
+//MARK: - TextViewOverRidden
+class TextViewOverRidden: UITextView {
+ ///https://developer.apple.com/forums/thread/115445
+ ///To disable system manu for edit text on text selection
+ override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
+ return false //To disable clipboard Manu on text selection
+ }
+}
+
+//MARK: - TextView Events
+public enum TextViewEvents {
+ case didChangeSelection(_ textView: UITextView)
+ case didBeginEditing(_ textView: UITextView)
+ case didChange(_ textView: UITextView)
+ case didEndEditing(_ textView: UITextView)
+}
diff --git a/Sources/RichEditorSwiftui/RichEditorSwiftui.swift b/Sources/RichEditorSwiftui/RichEditorSwiftui.swift
deleted file mode 100644
index 08b22b8..0000000
--- a/Sources/RichEditorSwiftui/RichEditorSwiftui.swift
+++ /dev/null
@@ -1,2 +0,0 @@
-// The Swift Programming Language
-// https://docs.swift.org/swift-book
diff --git a/Tests/RichEditorSwiftuiTests/RichEditorSwiftuiTests.swift b/Tests/RichEditorSwiftUITests/RichEditorSwiftUITests.swift
similarity index 77%
rename from Tests/RichEditorSwiftuiTests/RichEditorSwiftuiTests.swift
rename to Tests/RichEditorSwiftUITests/RichEditorSwiftUITests.swift
index 7fa81e7..fdccc25 100644
--- a/Tests/RichEditorSwiftuiTests/RichEditorSwiftuiTests.swift
+++ b/Tests/RichEditorSwiftUITests/RichEditorSwiftUITests.swift
@@ -1,7 +1,7 @@
import XCTest
-@testable import RichEditorSwiftui
+@testable import RichEditorSwiftUI
-final class RichEditorSwiftuiTests: XCTestCase {
+final class RichEditorSwiftUITests: XCTestCase {
func testExample() throws {
// XCTest Documentation
// https://developer.apple.com/documentation/xctest
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..552cbfb
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,49 @@
+# RichEditorSwiftUI
+
+iOS WYSIWYG Rich editor for SwiftUI.
+
+
+
+
+
+## Features
+
+The editor offers the following options:
+
+- [x] **Bold**
+- [x] *Italic*
+- [x] Underline
+- [x] Different Heading
+
+## How to add in your project
+
+Add the dependency
+
+```
+ import XYZRichEditor
+```
+
+## How to use ?
+
+```
+struct EditorView: View {
+ @ObservedObject var state: RichEditorState = .ini(input: "Hello World")
+
+ var body: some View {
+ RichEditor(state: _state)
+ .padding(10)
+ }
+}
+```
+# Demo
+[Sample](https://github.com/canopas/rich-editor-swiftui/tree/main/RichEditorDemo) app demonstrates how simple the usage of the library actually is.
+
+# Bugs and Feedback
+For bugs, questions and discussions please use the [Github Issues](https://github.com/canopas/rich-editor-swiftui/issues).
+
+
+## Credits
+RichEditor for SwiftUI is owned and maintained by the [Canopas team](https://canopas.com/). For project updates and releases, you can follow them on Twitter at [@canopassoftware](https://twitter.com/canopassoftware).
+
+RichTextKit: https://github.com/danielsaidi/RichTextKit
+
diff --git a/docs/sample.gif b/docs/sample.gif
new file mode 100644
index 0000000..a6732d8
Binary files /dev/null and b/docs/sample.gif differ