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