├── Sources └── RichEditor │ ├── Classes │ ├── .gitkeep │ ├── RichEditor │ │ ├── RichEditorDelegate.swift │ │ ├── RichEditor+KeyboardShortcuts.swift │ │ ├── RichEditor+Links.swift │ │ ├── RichEditor+Stack.swift │ │ ├── RichEditor+Attachments.swift │ │ ├── RichEditor+Styling.swift │ │ ├── RichEditor+Core.swift │ │ ├── RichEditor+BulletPoints.swift │ │ └── RichEditor.swift │ ├── RichTextView.swift │ ├── RichEditorToolbar │ │ ├── RichEditorToolbarButton.swift │ │ ├── RichEditorToolbar+UI.swift │ │ └── RichEditorToolbar.swift │ └── TextStyling.swift │ ├── Resources │ ├── Media.xcassets │ │ ├── Contents.json │ │ └── white-weight-bold.imageset │ │ │ ├── white-weight-bold@1x.png │ │ │ ├── white-weight-bold@2x.png │ │ │ ├── white-weight-bold@3x.png │ │ │ └── Contents.json │ ├── white-align-left@1x.png │ ├── white-align-left@2x.png │ ├── white-align-left@3x.png │ ├── white-text-color@1x.png │ ├── white-text-color@2x.png │ ├── white-text-color@3x.png │ ├── white-text-image@1x.png │ ├── white-text-image@2x.png │ ├── white-text-image@3x.png │ ├── white-text-link@1x.png │ ├── white-text-link@2x.png │ ├── white-text-link@3x.png │ ├── white-text-list@1x.png │ ├── white-text-list@2x.png │ ├── white-text-list@3x.png │ ├── white-align-centre@1x.png │ ├── white-align-centre@2x.png │ ├── white-align-centre@3x.png │ ├── white-align-justify@1x.png │ ├── white-align-justify@2x.png │ ├── white-align-justify@3x.png │ ├── white-align-right@1x.png │ ├── white-align-right@2x.png │ ├── white-align-right@3x.png │ ├── white-weight-bold@1x.png │ ├── white-weight-bold@2x.png │ ├── white-weight-bold@3x.png │ ├── white-weight-italic@1x.png │ ├── white-weight-italic@2x.png │ ├── white-weight-italic@3x.png │ ├── white-text-highlight@1x.png │ ├── white-text-highlight@2x.png │ ├── white-text-highlight@3x.png │ ├── white-text-strikethrough@1x.png │ ├── white-text-strikethrough@2x.png │ ├── white-text-strikethrough@3x.png │ ├── white-weight-underline@1x.png │ ├── white-weight-underline@2x.png │ └── white-weight-underline@3x.png │ └── Extensions │ ├── NSView+DarkMode.swift │ ├── CIImage+Conversion.swift │ ├── NSImage+Pods.swift │ ├── CGFloat+Formatting.swift │ ├── String+Convenience.swift │ ├── NSImage+ColourOverlay.swift │ ├── Dictionary+Merge.swift │ ├── NSFont+Traits.swift │ ├── NSImage+Inversion.swift │ ├── Dictionary+TypingAttributes.swift │ ├── URL+Images.swift │ ├── NSMenu+Convenience.swift │ ├── String+BulletPoints.swift │ ├── NSTextView+Convenience.swift │ └── NSAttributedString+Convenience.swift ├── RichEditor.png ├── .github ├── FUNDING.yml └── workflows │ └── BuildTests.yml ├── RichEditorExample ├── RichEditorExample │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── RichEditorExample.entitlements │ ├── RichEditor.entitlements │ ├── AppDelegate.swift │ ├── NSColor+Hex.swift │ ├── ViewControllers │ │ ├── PreviewTextViewController.swift │ │ ├── ViewController.swift │ │ ├── PreviewWebViewController.swift │ │ └── PreviewViewController.swift │ └── Base.lproj │ │ └── Main.storyboard └── RichEditorExample.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved │ └── project.pbxproj ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Package.resolved ├── .travis.yml ├── .gitignore ├── Tests └── RichEditorTests │ ├── NSFontTraitsTests.swift │ ├── CGFloatFormattingTests.swift │ ├── DictionaryMergeTests.swift │ ├── DictionaryTypingAttributesTests.swift │ ├── StringBulletPointsTests.swift │ └── NSAttributedStringConvenienceTests.swift ├── Package.swift ├── LICENSE.txt └── README.md /Sources/RichEditor/Classes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /RichEditor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/RichEditor.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [will-lumley] 4 | -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-left@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-left@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-left@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-left@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-left@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-left@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-color@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-color@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-color@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-color@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-color@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-color@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-image@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-image@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-image@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-image@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-image@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-image@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-link@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-link@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-link@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-link@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-link@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-link@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-list@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-list@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-list@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-list@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-list@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-list@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-centre@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-centre@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-centre@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-centre@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-centre@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-centre@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-justify@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-justify@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-justify@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-justify@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-justify@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-justify@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-right@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-right@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-right@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-right@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-align-right@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-align-right@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-weight-bold@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-weight-bold@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-weight-bold@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-weight-bold@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-weight-bold@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-weight-bold@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-weight-italic@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-weight-italic@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-weight-italic@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-weight-italic@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-weight-italic@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-weight-italic@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-highlight@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-highlight@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-highlight@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-highlight@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-highlight@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-highlight@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-strikethrough@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-strikethrough@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-strikethrough@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-strikethrough@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-text-strikethrough@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-text-strikethrough@3x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-weight-underline@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-weight-underline@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-weight-underline@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-weight-underline@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/white-weight-underline@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/white-weight-underline@3x.png -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/Media.xcassets/white-weight-bold.imageset/white-weight-bold@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/Media.xcassets/white-weight-bold.imageset/white-weight-bold@1x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/Media.xcassets/white-weight-bold.imageset/white-weight-bold@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/Media.xcassets/white-weight-bold.imageset/white-weight-bold@2x.png -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/Media.xcassets/white-weight-bold.imageset/white-weight-bold@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/RichEditor/HEAD/Sources/RichEditor/Resources/Media.xcassets/white-weight-bold.imageset/white-weight-bold@3x.png -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/NSView+DarkMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSView+DarkMode.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 17/1/2022. 6 | // 7 | 8 | import AppKit 9 | 10 | @available(macOS 10.14, *) 11 | internal extension NSView { 12 | 13 | var isDarkMode: Bool { 14 | self.effectiveAppearance.name == .darkAqua 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditor/RichEditorDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorDelegate.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 18/1/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol RichEditorDelegate { 11 | 12 | func fontStylingChanged(_ textStyling: TextStyling) 13 | func richEditorTextChanged(_ richEditor: RichEditor) 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/CIImage+Conversion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CIImage+Conversion.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 15/1/2022. 6 | // 7 | 8 | import Foundation 9 | import CoreImage 10 | 11 | internal extension CIImage { 12 | 13 | var cgImage: CGImage? { 14 | CIContext(options: nil).createCGImage(self, from: self.extent) 15 | } 16 | 17 | } 18 | 19 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/RichEditorExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "macColorPicker", 6 | "repositoryURL": "https://github.com/will-lumley/macColorPicker.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "5d8404511bd4e0cfd790739fc127a40890c06501", 10 | "version": "1.2.4" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "maccolorpicker", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/will-lumley/macColorPicker.git", 7 | "state" : { 8 | "revision" : "5d8404511bd4e0cfd790739fc127a40890c06501", 9 | "version" : "1.2.4" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/BuildTests.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | name: Build and Test 14 | runs-on: macos-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Force Xcode 13.2.1 20 | run: sudo xcode-select -switch /Applications/Xcode_13.2.1.app 21 | 22 | - name: Test 23 | run: swift test 24 | 25 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/RichEditor.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/NSImage+Pods.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Pods.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 22/6/21. 6 | // 7 | 8 | import AppKit 9 | 10 | extension NSImage { 11 | 12 | static func podImage(rawName: String, type: String = "png") -> NSImage? { 13 | let name = rawName + "@3x" 14 | 15 | guard let path = Bundle.module.path(forResource: name, ofType: type) else { 16 | return nil 17 | } 18 | 19 | let image = NSImage(contentsOfFile: path) 20 | return image 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Sources/RichEditor/Resources/Media.xcassets/white-weight-bold.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "white-weight-bold@1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "white-weight-bold@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "white-weight-bold@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # references: 2 | # * https://www.objc.io/issues/6-build-tools/travis-ci/ 3 | # * https://github.com/supermarin/xcpretty#usage 4 | 5 | osx_image: xcode7.3 6 | language: objective-c 7 | # cache: cocoapods 8 | # podfile: Example/Podfile 9 | # before_install: 10 | # - gem install cocoapods # Since Travis is not always on latest version 11 | # - pod install --project-directory=Example 12 | script: 13 | - set -o pipefail && xcodebuild test -enableCodeCoverage YES -workspace Example/RichEditor.xcworkspace -scheme RichEditor-Example -sdk iphonesimulator9.3 ONLY_ACTIVE_ARCH=NO | xcpretty 14 | - pod lib lint 15 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/CGFloat+Formatting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Formatting.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 2/4/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension CGFloat { 12 | 13 | /** 14 | Removes the decimal point if the value is equal to 0 15 | StackOverflow: https://stackoverflow.com/a/33996219 16 | */ 17 | var cleanValue: String { 18 | return self.truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", self) : String(Float(self)) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Xcode 5 | build/ 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata/ 15 | *.xccheckout 16 | profile 17 | *.moved-aside 18 | DerivedData 19 | 20 | # SPM 21 | build/* 22 | .build 23 | DerivedData 24 | /.previous-build 25 | xcuserdata 26 | .DS_Store 27 | *~ 28 | \#* 29 | .\#* 30 | .*.sw[nop] 31 | *.xcscmblueprint 32 | /default.profraw 33 | *.xcodeproj 34 | Utilities/Docker/*.tar.gz 35 | .swiftpm 36 | Package.resolved 37 | /build 38 | *.pyc 39 | .docc-build 40 | .vscode 41 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/String+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Generics.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 20/3/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | public extension String { 13 | 14 | var nsString: NSString { 15 | NSString(string: self) 16 | } 17 | 18 | /// Conveniently creates an NSRange that covers the very start of this string, to the very end of this string 19 | var fullRange: NSRange { 20 | return NSRange(location: 0, length: self.count) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/NSImage+ColourOverlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSImage+ColourOverlay.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 15/1/2022. 6 | // 7 | 8 | import AppKit 9 | 10 | public extension NSImage { 11 | 12 | func createOverlay(color: NSColor) -> NSImage? { 13 | guard let tinted = self.copy() as? NSImage else { 14 | return nil 15 | } 16 | tinted.lockFocus() 17 | color.set() 18 | 19 | let imageRect = NSRect(origin: NSZeroPoint, size: self.size) 20 | imageRect.fill(using: .sourceAtop) 21 | 22 | tinted.unlockFocus() 23 | return tinted 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // RichEditorExample 4 | // 5 | // Created by William Lumley on 19/1/2024. 6 | // 7 | 8 | import Cocoa 9 | 10 | @main 11 | class AppDelegate: NSObject, NSApplicationDelegate { 12 | 13 | func applicationDidFinishLaunching(_ aNotification: Notification) { 14 | // Insert code here to initialize your application 15 | } 16 | 17 | func applicationWillTerminate(_ aNotification: Notification) { 18 | // Insert code here to tear down your application 19 | } 20 | 21 | func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { 22 | return true 23 | } 24 | 25 | 26 | } 27 | 28 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditor/RichEditor+KeyboardShortcuts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditor+KeyboardShortcuts.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 7/12/20. 6 | // 7 | 8 | import AppKit 9 | 10 | extension RichEditor: KeyboardShortcutDelegate { 11 | 12 | func commandPressed(character: RichEditor.CommandShortcut) -> Bool { 13 | switch character { 14 | case .b: 15 | self.toggleBold() 16 | return true 17 | case .i: 18 | self.toggleItalic() 19 | return true 20 | case .u: 21 | self.toggleUnderline(.single) 22 | return true 23 | default: 24 | return false 25 | } 26 | } 27 | 28 | } 29 | 30 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/Dictionary+Merge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dictionary+Merge.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 21/3/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | public extension Dictionary { 13 | 14 | /** 15 | Merges the provided dictionary into this one. If the newDict and this dictionary have identical keys, 16 | the newDict's key/value pair overwrite the original one 17 | - parameter newDict: The dictionary which we'd like to merge 18 | */ 19 | mutating func merge(newDict: Dictionary) { 20 | for (key, value) in newDict { 21 | self[key] = value 22 | } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Tests/RichEditorTests/NSFontTraitsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSFontTraitsTests.swift 3 | // RichEditor_Tests 4 | // 5 | // Created by William Lumley on 23/1/2022. 6 | // Copyright © 2022 CocoaPods. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class NSFontTraitsTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | 19 | } 20 | 21 | func testContains() { 22 | let boldFont = NSFont.boldSystemFont(ofSize: 12) 23 | let font = NSFont.systemFont(ofSize: 15) 24 | 25 | XCTAssertFalse(font.contains(trait: .boldFontMask)) 26 | XCTAssertTrue(boldFont.contains(trait: .boldFontMask)) 27 | 28 | XCTAssertFalse(font.contains(trait: .italicFontMask)) 29 | XCTAssertFalse(boldFont.contains(trait: .italicFontMask)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/RichEditorTests/CGFloatFormattingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DictionaryTypingAttributesTests.swift 3 | // RichEditor_Tests 4 | // 5 | // Created by William Lumley on 22/1/2022. 6 | // Copyright © 2022 William Lumley. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import RichEditor 11 | 12 | class CGFloatFormattingTests: XCTestCase { 13 | 14 | var testSubject: CGFloat! 15 | 16 | override func setUpWithError() throws { 17 | try super.setUpWithError() 18 | } 19 | 20 | override func tearDown() { 21 | super.tearDown() 22 | self.testSubject = nil 23 | } 24 | 25 | func testCleanValue() { 26 | testSubject = CGFloat(3.0) 27 | 28 | XCTAssertEqual(testSubject.cleanValue, "3") 29 | } 30 | 31 | func testUncleanValue() { 32 | testSubject = CGFloat(3.22) 33 | 34 | XCTAssertEqual(testSubject.cleanValue, "3.22") 35 | } 36 | 37 | } 38 | 39 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "RichEditor", 8 | platforms: [ 9 | .macOS(.v10_15) // Replace with your minimum supported macOS version 10 | ], 11 | products: [ 12 | .library( 13 | name: "RichEditor", 14 | targets: [ 15 | "RichEditor" 16 | ] 17 | ), 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/will-lumley/macColorPicker.git", from: "1.2.3") 21 | ], 22 | targets: [ 23 | .target( 24 | name: "RichEditor", 25 | dependencies: [ 26 | "macColorPicker" 27 | ], 28 | resources: [.process("Resources")] 29 | ), 30 | .testTarget(name: "RichEditorTests", dependencies: ["RichEditor"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/NSColor+Hex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSColor+Hex.swift 3 | // RichEditor_Example 4 | // 5 | // Created by William Lumley on 31/1/20. 6 | // Copyright © 2020 William Lumley. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | extension NSColor { 12 | 13 | convenience init(hex: String) { 14 | // Trimming any non-alphanumeric characters (like '#') from the string 15 | let hexString = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 16 | let scanner = Scanner(string: hexString) 17 | 18 | var rgbValue: UInt64 = 0 19 | 20 | // Scans hex string into UInt64 21 | scanner.scanHexInt64(&rgbValue) 22 | 23 | let r = (rgbValue & 0xff0000) >> 16 24 | let g = (rgbValue & 0xff00) >> 8 25 | let b = rgbValue & 0xff 26 | 27 | self.init( 28 | red: CGFloat(r) / 0xff, 29 | green: CGFloat(g) / 0xff, 30 | blue: CGFloat(b) / 0xff, 31 | alpha: 1 32 | ) 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditor/RichEditor+Links.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditor+Links.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 7/12/20. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension RichEditor { 11 | 12 | /** 13 | Inserts a clickable link into the NSTextView 14 | - parameter link: The URI/URL that will be opened upon the clicking of this link 15 | - parameter name: The text that will be displayed to the user 16 | - parameter position: The location of the NSTextView's string where the link will be 17 | inserted. If nil, the cursors position is used instead. 18 | */ 19 | func insert(link: String, with name: String, at position: Int? = nil) { 20 | let attrString = NSMutableAttributedString(string: name) 21 | attrString.addAttribute(NSAttributedString.Key.link, value: link, range: name.fullRange) 22 | 23 | let insertionPosition = position ?? self.textView.selectedRange().location 24 | self.textView.textStorage!.insert(attrString, at: insertionPosition) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 William Lumley 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/NSFont+Traits.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSFont+Generics.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 20/3/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | public extension NSFont { 13 | 14 | /** 15 | Determines the FontTrait's that this font has 16 | StackOverflow: https://stackoverflow.com/a/38405084 17 | - returns: The NSFontTraitMask that will contain this font's traits 18 | */ 19 | private var fontTraits: NSFontTraitMask { 20 | let descriptor = self.fontDescriptor 21 | let symTraits = descriptor.symbolicTraits 22 | let traitSet = NSFontTraitMask(rawValue: UInt(symTraits.rawValue)) 23 | 24 | return traitSet 25 | } 26 | 27 | /** 28 | Checks if this font has the trait that we're searching for 29 | - trait: The NSFontTraitMask that we're looking for 30 | - returns: A boolean value, indicative of if this contains our desired trait 31 | */ 32 | func contains(trait: NSFontTraitMask) -> Bool { 33 | self.fontTraits.contains(trait) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/ViewControllers/PreviewTextViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewTextViewController.swift 3 | // RichEditor_Example 4 | // 5 | // Created by Will Lumley on 30/1/20. 6 | // Copyright © 2020 CocoaPods. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import RichEditor 11 | 12 | class PreviewTextViewController: NSViewController { 13 | 14 | @IBOutlet var previewTextView: NSTextView! 15 | 16 | override func viewDidLoad() { 17 | super.viewDidLoad() 18 | self.previewTextView.isEditable = false 19 | } 20 | 21 | func display(richEditor: RichEditor) { 22 | var htmlOpt: String? 23 | 24 | do { 25 | htmlOpt = try richEditor.html() 26 | } 27 | catch let error { 28 | print("Error creating HTML from NSAttributedString: \(error)") 29 | } 30 | 31 | guard var html = htmlOpt else { 32 | print("HTML from NSAttributedString was nil") 33 | return 34 | } 35 | 36 | html = html.replacingOccurrences(of: "\n", with: "") 37 | 38 | self.previewTextView.string = html 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/NSImage+Inversion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSImage+Inversion.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 15/1/2022. 6 | // 7 | 8 | import AppKit 9 | 10 | public extension NSImage { 11 | 12 | var inverted: NSImage? { 13 | guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 14 | print("Could not create CGImage from NSImage") 15 | return nil 16 | } 17 | 18 | guard let filter = CIFilter(name: "CIColorInvert") else { 19 | print("Could not create CIColorInvert filter") 20 | return nil 21 | } 22 | 23 | let ciImage = CIImage(cgImage: cgImage) 24 | filter.setValue(ciImage, forKey: kCIInputImageKey) 25 | guard let outputImage = filter.outputImage else { 26 | print("Could not obtain output CIImage from filter") 27 | return nil 28 | } 29 | 30 | guard let outputCgImage = outputImage.cgImage else { 31 | print("Could not create CGImage from CIImage") 32 | return nil 33 | } 34 | 35 | return NSImage(cgImage: outputCgImage, size: self.size) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/RichEditorTests/DictionaryMergeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DictionaryTypingAttributesTests.swift 3 | // RichEditor_Tests 4 | // 5 | // Created by William Lumley on 22/1/2022. 6 | // Copyright © 2022 William Lumley. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import RichEditor 11 | 12 | class DictionaryMergeTests: XCTestCase { 13 | 14 | var testSubject: [String: String]! 15 | 16 | override func setUpWithError() throws { 17 | try super.setUpWithError() 18 | 19 | self.testSubject = [ 20 | "foo": "bar", 21 | "key": "value" 22 | ] 23 | } 24 | 25 | override func tearDown() { 26 | super.tearDown() 27 | 28 | self.testSubject = nil 29 | } 30 | 31 | func testNewValue() { 32 | self.testSubject.merge(newDict: [ 33 | "new": "value" 34 | ]) 35 | 36 | XCTAssertEqual(self.testSubject.count, 3) 37 | XCTAssertEqual(self.testSubject["new"], "value") 38 | } 39 | 40 | func testOverriteValue() { 41 | self.testSubject.merge(newDict: [ 42 | "foo": "newValue" 43 | ]) 44 | 45 | XCTAssertEqual(self.testSubject.count, 2) 46 | XCTAssertEqual(self.testSubject["foo"], "newValue") 47 | } 48 | 49 | } 50 | 51 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/Dictionary+TypingAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dictionary+TypingAttributes.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 28/3/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | extension Dictionary where Key == NSAttributedString.Key { 13 | 14 | /** 15 | Determines if this dictionary contains any parts that have the provided NSAttributedString.Key or not 16 | - parameter key: The NSAttributedString.Key that we're looking for 17 | - parameter isNegativeAttr: This takes the provided `rawAttrValue` and will check if it matches 'off' value for the attribute. 18 | If `attribute` was NSUnderlineStyle, then the rawAttrValue would be checked against NSUnderlineStyle.styleNone.rawValue. If it 19 | did, a `true` boolean value would be returned 20 | - returns: A boolean value indicative of if the attribute was found or not 21 | */ 22 | public func check(attribute: NSAttributedString.Key, isNegativeAttr: (_ rawAttrValue: Int) -> Bool) -> Bool 23 | { 24 | guard let rawAttr = self[attribute] as? NSNumber else { 25 | return false 26 | } 27 | 28 | return isNegativeAttr(rawAttr.intValue) == false 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/URL+Images.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Generics.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 26/3/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | public extension URL { 13 | 14 | var icon: NSImage { 15 | let icon = NSWorkspace.shared.icon(forFile: self.path) 16 | return icon 17 | } 18 | 19 | var textAttachment: NSTextAttachment { 20 | //var data: Data? 21 | var fileWrapper: FileWrapper? 22 | 23 | do { 24 | //data = try Data(contentsOf: self) 25 | fileWrapper = try FileWrapper(url: self, options: FileWrapper.ReadingOptions.immediate) 26 | } 27 | catch let error { 28 | print("Failed to create Data or FileWrapper object from URL: \(self), error: \(error)") 29 | } 30 | 31 | let attachment = NSTextAttachment(fileWrapper: fileWrapper) 32 | return attachment 33 | } 34 | 35 | var imageType: NSBitmapImageRep.FileType { 36 | switch self.pathExtension.uppercased() { 37 | case "JPG", "JPEG": 38 | return .jpeg 39 | case "PNG": 40 | return .png 41 | case "TIFF": 42 | return .tiff 43 | case "GIF": 44 | return .gif 45 | case "BMP": 46 | return .bmp 47 | default: 48 | return .png 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/RichEditorTests/DictionaryTypingAttributesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DictionaryTypingAttributesTests.swift 3 | // RichEditor_Tests 4 | // 5 | // Created by William Lumley on 22/1/2022. 6 | // Copyright © 2022 William Lumley. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class DictionaryTypingAttributesTests: XCTestCase { 12 | 13 | var underlinedTestSubject: [NSAttributedString.Key: Any]! 14 | var strikeThroughTestSubject: [NSAttributedString.Key: Any]! 15 | 16 | override func setUpWithError() throws { 17 | self.underlinedTestSubject = [ 18 | NSAttributedString.Key.underlineStyle: NSNumber(value: NSUnderlineStyle.double.rawValue) 19 | ] 20 | 21 | self.strikeThroughTestSubject = [ 22 | NSAttributedString.Key.strikethroughStyle: NSNumber(value: NSUnderlineStyle.double.rawValue) 23 | ] 24 | } 25 | 26 | override func tearDownWithError() throws { 27 | self.underlinedTestSubject = nil 28 | self.strikeThroughTestSubject = nil 29 | } 30 | 31 | func testCheckCanFindPositiveCases() { 32 | let isUnderlined = self.underlinedTestSubject.check(attribute: .underlineStyle) { $0 == 0 } 33 | let isStriked = self.strikeThroughTestSubject.check(attribute: .strikethroughStyle) { $0 == 0 } 34 | 35 | XCTAssertTrue(isUnderlined) 36 | XCTAssertTrue(isStriked) 37 | } 38 | 39 | func testCheckCanFindNegativeCases() { 40 | let isUnderlined = self.strikeThroughTestSubject.check(attribute: .underlineStyle) { $0 == 0 } 41 | let isStriked = self.underlinedTestSubject.check(attribute: .strikethroughStyle) { $0 == 0 } 42 | 43 | XCTAssertFalse(isUnderlined) 44 | XCTAssertFalse(isStriked) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/NSMenu+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSMenu+Generics.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 1/4/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | public extension NSMenu { 13 | 14 | static func fontsMenu(with title: String = "Select a Font Family") -> NSMenu { 15 | let menu = NSMenu(title: title) 16 | 17 | let allFontFamilyNames = NSFontManager.shared.availableFontFamilies 18 | for fontName in allFontFamilyNames { 19 | 20 | let font = NSFont(name: fontName, size: NSFont.systemFontSize)! 21 | let attributes = [NSAttributedString.Key.font: font] 22 | let attrStr = NSAttributedString(string: fontName, attributes: attributes) 23 | 24 | let menuItem = NSMenuItem() 25 | menuItem.attributedTitle = attrStr 26 | 27 | menu.addItem(menuItem) 28 | } 29 | 30 | return menu 31 | } 32 | 33 | static func fontSizesMenu(with title: String = "Select a Font Size") -> NSMenu { 34 | let menu = NSMenu(title: title) 35 | 36 | let allFontSizes = ["9", "10", "11", "12", "13", "14", "18", "24", "36", "48", "64", "72", "96", "144", "288"] 37 | for fontSize in allFontSizes { 38 | 39 | let font = NSFont.systemFont(ofSize: 12) 40 | let attributes = [NSAttributedString.Key.font: font] 41 | let attrStr = NSAttributedString(string: fontSize, attributes: attributes) 42 | 43 | let menuItem = NSMenuItem() 44 | menuItem.attributedTitle = attrStr 45 | 46 | menu.addItem(menuItem) 47 | } 48 | 49 | return menu 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/ViewControllers/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // RichEditor_Example 4 | // 5 | // Created by Will Lumley on 30/1/20. 6 | // Copyright © 2020 William Lumley. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import RichEditor 11 | import macColorPicker 12 | 13 | class ViewController: NSViewController { 14 | 15 | @IBOutlet weak var richEditor: RichEditor! 16 | 17 | private var previewViewController: PreviewViewController? 18 | 19 | // MARK: - NSViewController 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | self.openPreviewWindow() 25 | self.richEditor.richEditorDelegate = self 26 | 27 | self.richEditor.configureToolbar() 28 | } 29 | 30 | override func viewDidAppear() { 31 | super.viewDidAppear() 32 | self.view.window?.title = "1. Rich Editor" 33 | } 34 | 35 | func openPreviewWindow() { 36 | let previewWindowController = self.storyboard!.instantiateController(withIdentifier: "PreviewWindowController") as! NSWindowController 37 | previewWindowController.window?.title = "Preview" 38 | previewWindowController.showWindow(self) 39 | 40 | self.previewViewController = previewWindowController.contentViewController as? PreviewViewController 41 | self.previewViewController?.richEditor = self.richEditor 42 | } 43 | 44 | } 45 | 46 | // MARK: - RichEditorDelegate 47 | 48 | extension ViewController: RichEditorDelegate { 49 | 50 | func fontStylingChanged(_ textStyling: TextStyling) { 51 | 52 | } 53 | 54 | func richEditorTextChanged(_ richEditor: RichEditor) { 55 | // Parse the HTML into a WebView and display the contents 56 | // self.previewWebViewController?.display(richEditor: richEditor) 57 | // 58 | // // Assign the raw HTML text so we can see the actual HTML content 59 | // self.previewTextViewController?.display(richEditor: richEditor) 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/ViewControllers/PreviewWebViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewWebViewController.swift 3 | // RichEditor_Example 4 | // 5 | // Created by Will Lumley on 30/1/20. 6 | // Copyright © 2020 CocoaPods. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import RichEditor 11 | import WebKit 12 | 13 | class PreviewWebViewController: NSViewController { 14 | 15 | @IBOutlet weak var webView: WKWebView! 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | self.webView.navigationDelegate = self 20 | } 21 | 22 | func display(richEditor: RichEditor) { 23 | var htmlOpt: String? 24 | 25 | do { 26 | htmlOpt = try richEditor.html() 27 | } 28 | catch let error { 29 | print("Error creating HTML from NSAttributedString: \(error)") 30 | } 31 | 32 | guard var html = htmlOpt else { 33 | print("HTML from NSAttributedString was nil") 34 | return 35 | } 36 | 37 | html = html.replacingOccurrences(of: "\n", with: "") 38 | 39 | guard let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { 40 | return 41 | } 42 | let directoryURL = documentDirectory.appendingPathComponent("com.lumley.richeditor") 43 | 44 | print("BaseURL: \(directoryURL)") 45 | self.webView.loadHTMLString(html, baseURL: directoryURL) 46 | } 47 | 48 | } 49 | 50 | // MARK: - WKNavigation Delegate 51 | 52 | extension PreviewWebViewController: WKNavigationDelegate { 53 | 54 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 55 | print("WKWebView did finish loading") 56 | } 57 | 58 | func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 59 | print("WKWebView Error: \(error)") 60 | } 61 | 62 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 63 | decisionHandler(.allow) 64 | } 65 | 66 | func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { 67 | print("WKWebView ContentProcessDidTerminate") 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextView.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 6/2/20. 6 | // 7 | 8 | import AppKit 9 | 10 | internal protocol KeyboardShortcutDelegate { 11 | 12 | func commandPressed(character: RichEditor.CommandShortcut) -> Bool 13 | 14 | } 15 | 16 | public class RichTextView: NSTextView { 17 | 18 | // MARK: - Properties 19 | 20 | /// Allows the RichEditor to be aware of keyboard presses 21 | internal private(set) var keyboardShortcutDelegate: KeyboardShortcutDelegate? 22 | 23 | // MARK: - NSTextView 24 | 25 | init(frame frameRect: NSRect, textContainer container: NSTextContainer?, delegate: KeyboardShortcutDelegate) { 26 | self.keyboardShortcutDelegate = delegate 27 | super.init(frame: frameRect, textContainer: container) 28 | } 29 | 30 | override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { 31 | super.init(frame: frameRect, textContainer: container) 32 | } 33 | 34 | required init?(coder: NSCoder) { 35 | super.init(coder: coder) 36 | } 37 | 38 | override init(frame frameRect: NSRect) { 39 | super.init(frame: frameRect) 40 | } 41 | 42 | fileprivate func setup() { 43 | if #available(OSX 10.14, *) { 44 | self.usesAdaptiveColorMappingForDarkAppearance = true 45 | } 46 | } 47 | 48 | override public func performKeyEquivalent(with event: NSEvent) -> Bool { 49 | // Only process our event if it's a keydown event 50 | if event.type != .keyDown { 51 | return super.performKeyEquivalent(with: event) 52 | } 53 | 54 | //If the command button was NOT pressed down 55 | if !event.modifierFlags.contains(.command) { 56 | return super.performKeyEquivalent(with: event) 57 | } 58 | 59 | guard let characters = event.charactersIgnoringModifiers else { 60 | return super.performKeyEquivalent(with: event) 61 | } 62 | 63 | guard let shortcut = RichEditor.CommandShortcut(rawValue: characters) else { 64 | return super.performKeyEquivalent(with: event) 65 | } 66 | 67 | guard let delegate = self.keyboardShortcutDelegate else { 68 | return super.performKeyEquivalent(with: event) 69 | } 70 | 71 | return delegate.commandPressed(character: shortcut) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditor/RichEditor+Stack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditor+Stack.swift 3 | // Nimble 4 | // 5 | // Created by Will Lumley on 30/1/20. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | // MARK: - NSTextView Stack 12 | /** 13 | StackOverflow: https://stackoverflow.com/a/45947994 14 | */ 15 | extension RichEditor { 16 | 17 | /** 18 | Creates and configures the NSTextView, NSTextContainer, NSTextStorage and NSLayoutManager objects 19 | - parameter isHorizontalScrollingEnabled: If true, the NSTextView will allow horizontal scrolling 20 | */ 21 | internal func configureTextView(isHorizontalScrollingEnabled: Bool) { 22 | let contentSize = self.scrollview.contentSize 23 | 24 | self.textStorage.addLayoutManager(self.layoutManager) 25 | self.layoutManager.addTextContainer(self.textContainer) 26 | 27 | if isHorizontalScrollingEnabled { 28 | self.textContainer.containerSize = CGSize( 29 | width: CGFloat.greatestFiniteMagnitude, 30 | height: CGFloat.greatestFiniteMagnitude 31 | ) 32 | self.textContainer.widthTracksTextView = false 33 | } 34 | else { 35 | self.textContainer.containerSize = CGSize( 36 | width: contentSize.width, 37 | height: CGFloat.greatestFiniteMagnitude 38 | ) 39 | self.textContainer.widthTracksTextView = true 40 | } 41 | 42 | self.textView.minSize = CGSize(width: 0, height: 0) 43 | self.textView.maxSize = CGSize( 44 | width: CGFloat.greatestFiniteMagnitude, 45 | height: CGFloat.greatestFiniteMagnitude 46 | ) 47 | self.textView.isVerticallyResizable = true 48 | self.textView.isHorizontallyResizable = isHorizontalScrollingEnabled 49 | self.textView.frame = CGRect( 50 | x: 0, 51 | y: 0, 52 | width: contentSize.width, 53 | height: contentSize.height 54 | ) 55 | 56 | if isHorizontalScrollingEnabled { 57 | textView.autoresizingMask = [.width, .height] 58 | } 59 | else { 60 | textView.autoresizingMask = [.width] 61 | } 62 | 63 | self.textView.allowsUndo = true 64 | 65 | self.scrollview.borderType = .noBorder 66 | self.scrollview.hasVerticalScroller = true 67 | self.scrollview.hasHorizontalScroller = isHorizontalScrollingEnabled 68 | self.scrollview.documentView = self.textView 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/String+BulletPoints.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Lines.swift 3 | // Pods-RichEditor_Example 4 | // 5 | // Created by William Lumley on 4/2/20. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension String { 11 | 12 | /// Indicative of if our string starts with a bullet point 13 | var isBulletPoint: Bool { 14 | let nsStr = NSString(string: self) 15 | let str = nsStr.replacingOccurrences(of: "\t", with: "") 16 | 17 | return str.hasPrefix(RichEditor.bulletPointMarker) 18 | } 19 | 20 | /// Indicative of if our string starts with a tab character 21 | var isPrefixedWithTab: Bool { 22 | self.hasPrefix("\t") 23 | } 24 | 25 | /// Returns an array of strings that is made up of all the "lines" in this string. 26 | var lines: [String] { 27 | var lines = [String]() 28 | 29 | self.enumerateSubstrings(in: self.startIndex.. String { 56 | String(repeating: self, count: count) 57 | } 58 | 59 | func prefixedStringCount(needle: Character, ignoring: [Character] = []) -> Int { 60 | // If we're not prefixed with a tab, bail out with a count of 0 61 | guard self.isPrefixedWithTab else { 62 | return 0 63 | } 64 | 65 | var count = 0 66 | for char in self { 67 | // If this is a tab, add it to our count 68 | if char == needle { 69 | count += 1 70 | } 71 | 72 | // Oops, we found a character that isn't a tab, we're done here 73 | else { 74 | // We're only done here, if we have not been told to ignoring this character 75 | if ignoring.contains(char) == false { 76 | break 77 | } 78 | } 79 | } 80 | 81 | return count 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditor/RichEditor+Attachments.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditor+Attachments.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 7/12/20. 6 | // 7 | 8 | import AppKit 9 | 10 | public extension RichEditor { 11 | 12 | static var attachmentsDirectory: URL { 13 | 14 | // Get the documents directory 15 | guard let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { 16 | fatalError() 17 | } 18 | 19 | // Create our own subdirectory within the documents directory 20 | let directoryURL = documentDirectory.appendingPathComponent("com.lumley.richeditor") 21 | 22 | // Create the directory, if it doesn't exist 23 | if FileManager.default.fileExists(atPath: directoryURL.path) == false { 24 | try! FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) 25 | } 26 | 27 | return directoryURL 28 | } 29 | 30 | func promptUserForAttachments(windowForModal: NSWindow?) { 31 | let openPanel = NSOpenPanel() 32 | openPanel.allowsMultipleSelection = true 33 | openPanel.canChooseDirectories = false 34 | openPanel.canCreateDirectories = false 35 | openPanel.canChooseFiles = true 36 | 37 | if let window = windowForModal { 38 | openPanel.beginSheetModal(for: window, completionHandler: {(modalResponse) in 39 | if modalResponse == NSApplication.ModalResponse.OK { 40 | let selectedURLs = openPanel.urls 41 | self.insertAttachments(at: selectedURLs) 42 | } 43 | }) 44 | } 45 | else { 46 | openPanel.begin(completionHandler: {(modalResponse) in 47 | if modalResponse == NSApplication.ModalResponse.OK { 48 | let selectedURLs = openPanel.urls 49 | self.insertAttachments(at: selectedURLs) 50 | } 51 | }) 52 | } 53 | } 54 | 55 | func insertAttachments(at urls: [URL]) { 56 | self.textView.layoutManager?.defaultAttachmentScaling = .scaleProportionallyDown 57 | 58 | print("Inserting attachments at URLs: \(urls)") 59 | 60 | //Iterate over every URL and create a NSTextAttachment from it 61 | for url in urls { 62 | 63 | //Get a copy of the data and insert it into our attachments folder 64 | /*----------------------------------------------------------------*/ 65 | let imageID = "\(UUID().uuidString).\(url.pathExtension)" 66 | let directoryURL = RichEditor.attachmentsDirectory 67 | 68 | let imageData = try! Data(contentsOf: url) 69 | let imageURL = directoryURL.appendingPathComponent(imageID) 70 | 71 | try! imageData.write(to: imageURL) 72 | /*----------------------------------------------------------------*/ 73 | 74 | let attachment = imageURL.textAttachment 75 | let attachmentAttrStr = NSAttributedString(attachment: attachment) 76 | 77 | self.textView.textStorage?.append(attachmentAttrStr) 78 | } 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditor/RichEditor+Styling.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditor+Styling.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 7/12/20. 6 | // 7 | 8 | import AppKit 9 | 10 | extension RichEditor { 11 | 12 | /** 13 | Toggles the bold attribute for the selected text, or the future text if no text is selected 14 | */ 15 | func toggleBold() { 16 | self.toggleTextView(with: .boldFontMask) 17 | } 18 | 19 | /** 20 | Toggles the italics attribute for the selected text, or the future text if no text is selected 21 | */ 22 | func toggleItalic() { 23 | self.toggleTextView(with: .italicFontMask) 24 | } 25 | 26 | /** 27 | Toggles the underline attribute for the selected text, or the future text if no text is selected 28 | - parameter style: The style of the underline that we want 29 | */ 30 | func toggleUnderline(_ style: NSUnderlineStyle) { 31 | self.toggleTextView(with: .underlineStyle, negativeValue: 0, positiveValue: style.rawValue) 32 | } 33 | 34 | /** 35 | Toggles the strikethrough attribute for the selected text, or the future text if no text is selected 36 | - parameter style: The style of the strikethrough that we want 37 | */ 38 | func toggleStrikethrough(_ style: NSUnderlineStyle) { 39 | self.toggleTextView(with: .strikethroughStyle, negativeValue: 0, positiveValue: style.rawValue) 40 | } 41 | 42 | /** 43 | Applies the text colour for the selected text, or the future text if no text is selected 44 | */ 45 | func apply(textColour: NSColor) { 46 | let colourAttr = [NSAttributedString.Key.foregroundColor: textColour] 47 | self.add(attributes: colourAttr, textApplicationType: self.textView.hasSelectedText ? .selected : .future) 48 | } 49 | 50 | /** 51 | Applies the highlight colour for the selected text, or the future text if no text is selected 52 | */ 53 | func apply(highlightColour: NSColor) { 54 | let colourAttr = [NSAttributedString.Key.backgroundColor: highlightColour] 55 | self.add(attributes: colourAttr, textApplicationType: self.textView.hasSelectedText ? .selected : .future) 56 | } 57 | 58 | /** 59 | Applies the font for the selected text, or the future text if no text is selected 60 | */ 61 | func apply(font: NSFont) { 62 | let fontAttr = [NSAttributedString.Key.font: font] 63 | self.add(attributes: fontAttr, textApplicationType: self.textView.hasSelectedText ? .selected : .future) 64 | } 65 | 66 | /** 67 | Applies the font for the selected text, or the future text if no text is selected 68 | */ 69 | func apply(alignment: NSTextAlignment) { 70 | let paragraphStyle = NSMutableParagraphStyle() 71 | paragraphStyle.alignment = alignment 72 | 73 | let paragraphStyleAttr = [NSAttributedString.Key.paragraphStyle: paragraphStyle] 74 | 75 | // Apply this alignment to the paragraph the user is in, or the paragraph the user is highlighting 76 | let paragraphRange = self.textView.string.nsString.paragraphRange(for: self.textView.selectedRange()) 77 | 78 | // Apply the text alignment to the current paragraph, and future paragraphs 79 | self.add(attributes: paragraphStyleAttr, textApplicationType: .range(range: paragraphRange)) 80 | self.add(attributes: paragraphStyleAttr, textApplicationType: .future) 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /Tests/RichEditorTests/StringBulletPointsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringBulletPointsTests.swift 3 | // RichEditor_Tests 4 | // 5 | // Created by William Lumley on 23/1/2022. 6 | // Copyright © 2022 CocoaPods. All rights reserved. 7 | // 8 | 9 | @testable import RichEditor 10 | import XCTest 11 | 12 | class StringBulletPointsTests: XCTestCase { 13 | 14 | override func setUpWithError() throws { 15 | 16 | } 17 | 18 | override func tearDownWithError() throws { 19 | 20 | } 21 | 22 | func testIsBulletPoint() { 23 | let testSubject1 = "\(RichEditor.bulletPointMarker) Hello there" 24 | let testSubject2 = "Hello there" 25 | let testSubject3 = "Hello \(RichEditor.bulletPointMarker) Hello there" 26 | 27 | XCTAssertTrue(testSubject1.isBulletPoint) 28 | XCTAssertFalse(testSubject2.isBulletPoint) 29 | XCTAssertFalse(testSubject3.isBulletPoint) 30 | } 31 | 32 | func testIsPrefixedWithTab() { 33 | let testSubject1 = "\tHello there" 34 | let testSubject2 = "Hello \t there \t" 35 | let testSubject3 = "Hello there" 36 | 37 | XCTAssertTrue(testSubject1.isPrefixedWithTab) 38 | XCTAssertFalse(testSubject2.isPrefixedWithTab) 39 | XCTAssertFalse(testSubject3.isPrefixedWithTab) 40 | } 41 | 42 | func testPrefixedStringCount() { 43 | let testSubject1 = "\tHello there" 44 | let testSubject2 = "\t\tHello there" 45 | let testSubject3 = "\t\t\t\tHello there\t\t" 46 | let testSubject4 = "Hello \t there \t" 47 | let testSubject5 = "Hello there" 48 | let testSubject6 = "\t\t=Hello there" 49 | 50 | XCTAssertEqual(testSubject1.prefixedStringCount(needle: "\t"), 1) 51 | XCTAssertEqual(testSubject2.prefixedStringCount(needle: "\t"), 2) 52 | XCTAssertEqual(testSubject3.prefixedStringCount(needle: "\t"), 4) 53 | XCTAssertEqual(testSubject4.prefixedStringCount(needle: "\t"), 0) 54 | XCTAssertEqual(testSubject5.prefixedStringCount(needle: "\t"), 0) 55 | XCTAssertEqual(testSubject6.prefixedStringCount(needle: "=", ignoring: [Character("\t")]), 1) 56 | } 57 | 58 | func testLines() { 59 | let testSubject = "Hello there\nHow now\nBrown Cow" 60 | 61 | XCTAssertEqual(testSubject.lines.count, 3) 62 | 63 | XCTAssertEqual(testSubject.lines[0], "Hello there") 64 | XCTAssertEqual(testSubject.lines[1], "How now") 65 | XCTAssertEqual(testSubject.lines[2], "Brown Cow") 66 | } 67 | 68 | func testRepeated() { 69 | let testSubject1 = "1" 70 | let testSubject2 = "2" 71 | let testSubject3 = "3" 72 | let testSubject4 = "4" 73 | 74 | XCTAssertEqual(testSubject1.repeated(1), "1") 75 | XCTAssertEqual(testSubject2.repeated(2), "22") 76 | XCTAssertEqual(testSubject3.repeated(3), "333") 77 | XCTAssertEqual(testSubject4.repeated(0), "") 78 | } 79 | 80 | func testRemoveFirst() { 81 | var testSubject1 = "Hello there Hello" 82 | var testSubject2 = "Hello Hello world" 83 | var testSubject3 = "Hello there foo" 84 | var testSubject4 = "\t\tTesting" 85 | 86 | testSubject1.removeFirst(needle: "Hello ") 87 | testSubject2.removeFirst(needle: "Hello ") 88 | testSubject3.removeFirst(needle: "foobar") 89 | testSubject4.removeFirst(needle: "\t") 90 | 91 | XCTAssertEqual(testSubject1, "there Hello") 92 | XCTAssertEqual(testSubject2, "Hello world") 93 | XCTAssertEqual(testSubject3, "Hello there foo") 94 | XCTAssertEqual(testSubject4, "\tTesting") 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditorToolbar/RichEditorToolbarButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorToolbarButton.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 22/6/21. 6 | // 7 | 8 | import Cocoa 9 | 10 | class RichEditorToolbarButton: NSButton { 11 | 12 | // MARK: - Types 13 | 14 | struct ButtonImage { 15 | let unselectedLight: NSImage 16 | let unselectedDark: NSImage 17 | let selected: NSImage 18 | } 19 | 20 | // MARK: - Properties 21 | 22 | private var imageName: String 23 | private let buttonImage: ButtonImage 24 | 25 | var selected: Bool { 26 | didSet { 27 | self.loadImage() 28 | } 29 | } 30 | 31 | // MARK: - NSButton 32 | 33 | init(imageName: String, selectedTint: NSColor = .systemBlue) { 34 | self.imageName = imageName 35 | self.selected = false 36 | 37 | // All images provided to us are dark by default 38 | guard let darkImage = NSImage.podImage(rawName: imageName) else { 39 | fatalError("Failed to create image for RichEditorToolbarButton with image name: \(imageName)") 40 | } 41 | 42 | // Create a light version 43 | guard let lightImage = darkImage.inverted else { 44 | fatalError("Failed to invert image for RichEditorToolbarButton with image name: \(imageName)") 45 | } 46 | 47 | // Create a selected version 48 | guard let selectedImage = darkImage.createOverlay(color: selectedTint) else { 49 | fatalError("Failed to overlay for image for RichEditorToolbarButton with image name: \(imageName)") 50 | } 51 | 52 | self.buttonImage = ButtonImage( 53 | unselectedLight: lightImage, 54 | unselectedDark: darkImage, 55 | selected: selectedImage 56 | ) 57 | 58 | super.init(frame: .zero) 59 | 60 | self.setup() 61 | self.loadImage() 62 | } 63 | 64 | required init?(coder: NSCoder) { 65 | fatalError("init(coder:) has not been implemented") 66 | } 67 | 68 | @available(macOS 10.14, *) 69 | override func viewDidChangeEffectiveAppearance() 70 | { 71 | super.viewDidChangeEffectiveAppearance() 72 | self.loadImage() 73 | } 74 | 75 | private func setup() { 76 | self.isBordered = false 77 | self.isTransparent = false 78 | 79 | self.wantsLayer = true 80 | self.layer?.backgroundColor = NSColor.clear.cgColor 81 | } 82 | 83 | internal func loadImage() { 84 | 85 | if #available(macOS 10.14, *) { 86 | // If we're selected, show the selected image 87 | if self.selected { 88 | self.image = self.buttonImage.selected 89 | } 90 | 91 | // We're not selected, show the light or dark mode image 92 | else if self.isDarkMode { 93 | self.image = self.buttonImage.unselectedDark 94 | } 95 | 96 | else if self.isDarkMode == false { 97 | self.image = self.buttonImage.unselectedLight 98 | } 99 | } 100 | else { 101 | // If we're selected, show the selected image 102 | if self.selected { 103 | self.image = self.buttonImage.selected 104 | } 105 | 106 | // We're not selected, show the light or dark mode image 107 | else { 108 | self.image = self.buttonImage.unselectedLight 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/ViewControllers/PreviewViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewViewController.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 13/7/21. 6 | // Copyright © 2021 William Lumley. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import RichEditor 11 | import WebKit 12 | 13 | class PreviewViewController: NSViewController { 14 | 15 | enum ContentType { 16 | case webView 17 | case textView 18 | 19 | var otherType: ContentType { 20 | switch self { 21 | case .textView: return .webView 22 | case .webView: return .textView 23 | } 24 | } 25 | } 26 | 27 | @IBOutlet weak var webView: WKWebView! 28 | @IBOutlet var textView: NSTextView! 29 | 30 | @IBOutlet weak var loadHtmlButton: NSButton! 31 | @IBOutlet weak var toggleContentTypeButton: NSButton! 32 | 33 | internal var richEditor: RichEditor? 34 | 35 | private var contentType: ContentType = .webView { 36 | didSet { 37 | switch self.contentType { 38 | case .textView: 39 | self.textView.isHidden = false 40 | self.webView.isHidden = true 41 | 42 | self.toggleContentTypeButton.title = "Display HTML" 43 | case .webView: 44 | self.textView.isHidden = true 45 | self.webView.isHidden = false 46 | 47 | self.toggleContentTypeButton.title = "Display Raw HTML" 48 | } 49 | } 50 | } 51 | 52 | // MARK: - NSViewController 53 | 54 | override func viewDidLoad() { 55 | super.viewDidLoad() 56 | 57 | self.contentType = .webView 58 | 59 | self.webView.navigationDelegate = self 60 | self.textView.isEditable = false 61 | } 62 | 63 | } 64 | 65 | // MARK: - Actions 66 | 67 | extension PreviewViewController { 68 | 69 | @IBAction func loadHtmlButtonClicked(_ sender: Any) { 70 | guard let richEditor = self.richEditor else { 71 | return 72 | } 73 | 74 | var htmlOpt: String? 75 | 76 | do { 77 | htmlOpt = try richEditor.html() 78 | } 79 | catch let error { 80 | print("Error creating HTML from NSAttributedString: \(error)") 81 | } 82 | 83 | guard var html = htmlOpt else { 84 | print("HTML from NSAttributedString was nil") 85 | return 86 | } 87 | 88 | html = html.replacingOccurrences(of: "\n", with: "") 89 | 90 | self.textView.string = html 91 | 92 | guard let htmlData = html.data(using: .utf8) else { 93 | return 94 | } 95 | 96 | let directoryURL = RichEditor.attachmentsDirectory 97 | let htmlFileURL = directoryURL.appendingPathComponent("index.html") 98 | 99 | try! htmlData.write(to: htmlFileURL) 100 | 101 | self.webView.loadFileURL(htmlFileURL, allowingReadAccessTo: directoryURL) 102 | } 103 | 104 | @IBAction func toggleContentTypeButtonClicked(_ sender: Any) { 105 | self.contentType = self.contentType.otherType 106 | } 107 | 108 | } 109 | 110 | // MARK: - WKNavigation Delegate 111 | 112 | extension PreviewViewController: WKNavigationDelegate { 113 | 114 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 115 | print("WKWebView did finish loading") 116 | } 117 | 118 | func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 119 | print("WKWebView Error: \(error)") 120 | } 121 | 122 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 123 | decisionHandler(.allow) 124 | } 125 | 126 | func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { 127 | print("WKWebView ContentProcessDidTerminate") 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditorToolbar/RichEditorToolbar+UI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorToolbar+UI.swift 3 | // macColorPicker 4 | // 5 | // Created by William Lumley on 6/6/21. 6 | // 7 | 8 | import AppKit 9 | 10 | internal extension RichEditorToolbar { 11 | 12 | func setupFontUI() { 13 | self.fontFamiliesPopUpButton.menu = NSMenu.fontsMenu() 14 | self.fontFamiliesPopUpButton.target = self 15 | self.fontFamiliesPopUpButton.action = #selector(fontFamiliesButtonClicked(_:)) 16 | 17 | self.fontSizePopUpButton.menu = NSMenu.fontSizesMenu() 18 | self.fontSizePopUpButton.target = self 19 | self.fontSizePopUpButton.action = #selector(fontSizeButtonClicked(_:)) 20 | 21 | self.contentStackView.addArrangedSubview(self.fontFamiliesPopUpButton) 22 | self.contentStackView.addArrangedSubview(self.fontSizePopUpButton) 23 | 24 | NSLayoutConstraint.activate([ 25 | self.fontFamiliesPopUpButton.widthAnchor.constraint(equalToConstant: 125) 26 | ]) 27 | } 28 | 29 | func setupWeightButtons() { 30 | self.contentStackView.addArrangedSubview(self.boldButton) 31 | self.contentStackView.addArrangedSubview(self.italicButton) 32 | self.contentStackView.addArrangedSubview(self.underlineButton) 33 | 34 | self.boldButton.target = self 35 | self.boldButton.action = #selector(boldButtonClicked(_:)) 36 | 37 | self.italicButton.target = self 38 | self.italicButton.action = #selector(italicButtonClicked(_:)) 39 | 40 | self.underlineButton.target = self 41 | self.underlineButton.action = #selector(underlineButtonClicked(_:)) 42 | } 43 | 44 | func setupAlignButtons() { 45 | self.contentStackView.addArrangedSubview(self.alignLeftButton) 46 | self.contentStackView.addArrangedSubview(self.alignCentreButton) 47 | self.contentStackView.addArrangedSubview(self.alignRightButton) 48 | self.contentStackView.addArrangedSubview(self.alignJustifyButton) 49 | 50 | self.alignLeftButton.target = self 51 | self.alignLeftButton.action = #selector(alignLeftButtonClicked(_:)) 52 | 53 | self.alignCentreButton.target = self 54 | self.alignCentreButton.action = #selector(alignCentreButtonClicked(_:)) 55 | 56 | self.alignRightButton.target = self 57 | self.alignRightButton.action = #selector(alignRightButtonClicked(_:)) 58 | 59 | self.alignJustifyButton.target = self 60 | self.alignJustifyButton.action = #selector(alignJustifyButtonClicked(_:)) 61 | } 62 | 63 | func setupColorButtons() { 64 | self.contentStackView.addArrangedSubview(self.textColorButton) 65 | self.contentStackView.addArrangedSubview(self.highlightColorButton) 66 | 67 | self.textColorButton.delegate = self 68 | self.highlightColorButton.delegate = self 69 | 70 | NSLayoutConstraint.activate([ 71 | self.textColorButton.widthAnchor.constraint(equalToConstant: 24), 72 | self.textColorButton.heightAnchor.constraint(equalToConstant: 24), 73 | 74 | self.highlightColorButton.widthAnchor.constraint(equalToConstant: 24), 75 | self.highlightColorButton.heightAnchor.constraint(equalToConstant: 24), 76 | ]) 77 | } 78 | 79 | func setupCustomTextActionButtons() { 80 | self.contentStackView.addArrangedSubview(self.linkButton) 81 | self.contentStackView.addArrangedSubview(self.listButton) 82 | self.contentStackView.addArrangedSubview(self.strikethroughButton) 83 | self.contentStackView.addArrangedSubview(self.addImageButton) 84 | 85 | self.linkButton.target = self 86 | self.linkButton.action = #selector(linkButtonClicked(_:)) 87 | 88 | self.listButton.target = self 89 | self.listButton.action = #selector(listButtonClicked(_:)) 90 | 91 | self.strikethroughButton.target = self 92 | self.strikethroughButton.action = #selector(strikethroughButtonClicked(_:)) 93 | 94 | self.addImageButton.target = self 95 | self.addImageButton.action = #selector(addImageButtonClicked(_:)) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Tests/RichEditorTests/NSAttributedStringConvenienceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedStringConvenienceTests.swift 3 | // RichEditor_Tests 4 | // 5 | // Created by William Lumley on 22/1/2022. 6 | // Copyright © 2022 William Lumley. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class NSAttributedStringConvenienceTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | try super.setUpWithError() 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | 19 | } 20 | 21 | func testAttributes() { 22 | let font = NSFont.boldSystemFont(ofSize: 12) 23 | let colour = NSColor.green.cgColor 24 | 25 | let testSubject = NSAttributedString( 26 | string: "Lorem Ipsum", 27 | attributes: [ 28 | .font: font, 29 | .foregroundColor: colour 30 | ] 31 | ) 32 | 33 | XCTAssertEqual(testSubject.attributes.count, 2) 34 | 35 | XCTAssertEqual(testSubject.attributes[.font] as! NSFont, font) 36 | XCTAssertEqual(testSubject.attributes[.foregroundColor] as! CGColor, colour) 37 | } 38 | 39 | func testAllFonts() { 40 | let boldFont = NSFont.boldSystemFont(ofSize: 12) 41 | let font = NSFont.systemFont(ofSize: 15) 42 | 43 | let testSubject = NSMutableAttributedString( 44 | string: "Lorem Ipsum", 45 | attributes: [ 46 | .font: font, 47 | ] 48 | ) 49 | testSubject.addAttribute(.font, value: boldFont, range: NSRange(location: 7, length: 4)) 50 | 51 | XCTAssertEqual(testSubject.allFonts, [ 52 | font, 53 | boldFont, 54 | ]) 55 | } 56 | 57 | func testAllAlignments() { 58 | let leftAlignment = NSTextAlignment.left 59 | let rightAlignment = NSTextAlignment.right 60 | 61 | let paragraphStyle1 = NSMutableParagraphStyle() 62 | paragraphStyle1.alignment = leftAlignment 63 | 64 | let paragraphStyle2 = NSMutableParagraphStyle() 65 | paragraphStyle2.alignment = rightAlignment 66 | 67 | let testSubject = NSMutableAttributedString( 68 | string: "Lorem Ipsum", 69 | attributes: [ 70 | .paragraphStyle: paragraphStyle1 71 | ] 72 | ) 73 | testSubject.addAttribute(.paragraphStyle, value: paragraphStyle2, range: NSRange(location: 7, length: 4)) 74 | 75 | XCTAssertEqual(testSubject.allAlignments, [ 76 | leftAlignment, 77 | rightAlignment, 78 | ]) 79 | } 80 | 81 | func testAllTextColours() { 82 | let green = NSColor.green 83 | let red = NSColor.red 84 | 85 | let testSubject = NSMutableAttributedString( 86 | string: "Lorem Ipsum", 87 | attributes: [ 88 | .foregroundColor: green 89 | ] 90 | ) 91 | testSubject.addAttribute(.foregroundColor, value: red, range: NSRange(location: 7, length: 4)) 92 | 93 | XCTAssertEqual(testSubject.allTextColours, [ 94 | green, 95 | red, 96 | ]) 97 | } 98 | 99 | func testAllHighlightColours() { 100 | let green = NSColor.green 101 | let red = NSColor.red 102 | 103 | let testSubject = NSMutableAttributedString( 104 | string: "Lorem Ipsum", 105 | attributes: [ 106 | .backgroundColor: green 107 | ] 108 | ) 109 | testSubject.addAttribute(.backgroundColor, value: red, range: NSRange(location: 7, length: 4)) 110 | 111 | XCTAssertEqual(testSubject.allHighlightColours, [ 112 | green, 113 | red, 114 | ]) 115 | } 116 | 117 | func testAllAttachments() { 118 | let attachment = NSTextAttachment(data: nil, ofType: "jpg") 119 | 120 | let testSubject = NSMutableAttributedString(string: "Lorem Ipsum") 121 | testSubject.addAttribute(.attachment, value: attachment, range: NSRange(location: 7, length: 4)) 122 | 123 | XCTAssertEqual(testSubject.allAttachments, [ 124 | attachment, 125 | ]) 126 | } 127 | 128 | func testContainsFontTrait() { 129 | let font = NSFont.systemFont(ofSize: 12) 130 | let boldFont = NSFont.boldSystemFont(ofSize: 12) 131 | 132 | let testSubject = NSMutableAttributedString( 133 | string: "Lorem Ipsum", 134 | attributes: [ 135 | .font: font, 136 | ] 137 | ) 138 | testSubject.addAttribute(.font, value: boldFont, range: NSRange(location: 7, length: 4)) 139 | 140 | XCTAssertTrue(testSubject.contains(trait: .boldFontMask)) 141 | XCTAssertFalse(testSubject.contains(trait: .italicFontMask)) 142 | } 143 | 144 | func testDoesNotContainFontTrait() { 145 | let font = NSFont.systemFont(ofSize: 12) 146 | let boldFont = NSFont.boldSystemFont(ofSize: 12) 147 | 148 | let testSubject = NSMutableAttributedString( 149 | string: "Lorem Ipsum", 150 | attributes: [ 151 | .font: font, 152 | ] 153 | ) 154 | testSubject.addAttribute(.font, value: boldFont, range: NSRange(location: 7, length: 4)) 155 | 156 | XCTAssertTrue(testSubject.doesNotContain(trait: .italicFontMask)) 157 | XCTAssertFalse(testSubject.doesNotContain(trait: .boldFontMask)) 158 | } 159 | 160 | func testCheck() { 161 | let boldFont = NSFont.boldSystemFont(ofSize: 12) 162 | 163 | let testSubject = NSMutableAttributedString( 164 | string: "Lorem Ipsum", 165 | attributes: [ 166 | .underlineStyle: NSNumber(value: NSUnderlineStyle.double.rawValue), 167 | ] 168 | ) 169 | testSubject.addAttribute(.font, value: boldFont, range: NSRange(location: 7, length: 4)) 170 | 171 | let underline = testSubject.check(attribute: .underlineStyle) 172 | let textColour = testSubject.check(attribute: .foregroundColor) 173 | let font = testSubject.check(attribute: .font) 174 | 175 | XCTAssertTrue(underline.atParts) 176 | XCTAssertFalse(underline.notAtParts) 177 | 178 | XCTAssertFalse(textColour.atParts) 179 | XCTAssertTrue(textColour.notAtParts) 180 | 181 | XCTAssertTrue(font.atParts) 182 | XCTAssertTrue(font.notAtParts) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditor/RichEditor+Core.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditor+Core.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 7/12/20. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | enum TextApplicationType { 12 | case selected 13 | case range(range: NSRange) 14 | case future 15 | } 16 | 17 | internal extension RichEditor { 18 | 19 | /** 20 | Toggles a certain font trait (bold, italic, etc) for the RichTextView. 21 | If text is highlighted, then only the highlighted text will have its font toggled. If no text is highlighted 22 | then all 'future' text will have the new traits. 23 | 24 | - parameter trait: This is the trait that you want the font to adhere to 25 | */ 26 | func toggleTextView(with fontTrait: NSFontTraitMask) { 27 | //Get the current font, and make a 'new' version of it, with the traits provided to us 28 | let currentFont = self.currentFont 29 | var newFont = currentFont 30 | 31 | let textStyling = self.textStyling 32 | let textStylingTrait = textStyling.fontTraitFor(nsFontTraitMask: fontTrait) 33 | 34 | //print("\nOldFont: \(currentFont)") 35 | switch (textStylingTrait) { 36 | //If we're ONLY bold at the moment, let's make it unbold 37 | case .isTrait: 38 | newFont = NSFontManager.shared.convert(currentFont, toNotHaveTrait: fontTrait) 39 | 40 | //If we're ONLY unbold at the moment, let's make it bold 41 | case .isNotTrait: 42 | newFont = NSFontManager.shared.convert(currentFont, toHaveTrait: fontTrait) 43 | 44 | //If we're BOTH bold and unbold, we'll make it bold 45 | case .both: 46 | newFont = NSFontManager.shared.convert(currentFont, toHaveTrait: fontTrait) 47 | } 48 | //print("NewFont: \(newFont)\n") 49 | 50 | let updatedFontAttr = [NSAttributedString.Key.font: newFont] 51 | self.add(attributes: updatedFontAttr, textApplicationType: self.textView.hasSelectedText ? .selected : .future) 52 | 53 | self.richEditorDelegate?.fontStylingChanged(self.textStyling) 54 | self.toolbarRichEditorDelegate?.fontStylingChanged(self.textStyling) 55 | } 56 | 57 | /** 58 | Toggles a certain NSAttributeString.Key (underline, strikethrough, etc) for the RichTextView. 59 | If text is highlighted, then only the highlighted text will have its NSAttributedText toggled. 60 | If no text is highlighted then all 'future' text will have the new attributes. 61 | 62 | - parameter attribute: This is the trait that you want the NSAttributedString to adhere to 63 | - parameter negativeValue: This is the 'off' value for the attribute. If `attribute` was 64 | NSUnderlineStyle, then the negativeValue would be NSUnderlineStyle.styleNone.rawValue 65 | - parameter positiveValue: This is the 'on' value for the attribute. If `attribute` was 66 | NSUnderlineStyle, then the positive would be NSUnderlineStyle.styleSingle.rawValue 67 | - parameter range: This is a property that allows users to override the default range that the attribute will be applied to, and to choose their own custom range. 68 | */ 69 | func toggleTextView(with attribute: NSAttributedString.Key, negativeValue: Any, positiveValue: Any, range: NSRange? = nil) { 70 | let fontTrait = self.textStyling.trait(with: attribute) 71 | 72 | var newAttr = [NSAttributedString.Key: Any]() 73 | switch (fontTrait) { 74 | case .isTrait: 75 | newAttr = [attribute: negativeValue] 76 | 77 | case .isNotTrait: 78 | newAttr = [attribute: positiveValue] 79 | 80 | case .both: 81 | newAttr = [attribute: positiveValue] 82 | } 83 | 84 | // If the user has highlighted text, apply it to that range 85 | // If the user has not highlighted text, apply it to all future text 86 | // If the user has provided a range, use that instead 87 | var type: TextApplicationType = self.textView.hasSelectedText ? .selected : .future 88 | if let range = range { 89 | type = .range(range: range) 90 | } 91 | 92 | self.add(attributes: newAttr, textApplicationType: type) 93 | 94 | self.richEditorDelegate?.fontStylingChanged(self.textStyling) 95 | self.toolbarRichEditorDelegate?.fontStylingChanged(self.textStyling) 96 | } 97 | 98 | /** 99 | Adds the provided NSAttributedString.Key attributes to the TextView. The attributes will either 100 | be applied to the text that is selected, or to all 'future' text. This is dependant on the 101 | selected argument. 102 | - parameter attributes: The attributes that we wish to apply to our NSTextView 103 | - parameter textApplicationType: Determines how the effects of the NSAttributedString.Key will be applied to our TextViews string 104 | */ 105 | func add(attributes: [NSAttributedString.Key: Any], textApplicationType: TextApplicationType) { 106 | 107 | switch textApplicationType { 108 | case .selected: 109 | let selectedRange = self.textView.selectedRange() 110 | 111 | self.textStorage.addAttributes(attributes, range: selectedRange) 112 | 113 | //Create an attributed string out of ONLY the highlighted text 114 | guard let attr = self.textView.attributedSubstring(forProposedRange: selectedRange, actualRange: nil) else { 115 | return 116 | } 117 | 118 | //Ensure the UI is updated with the new TextStyling state's 119 | self.selectedTextFontStyling = TextStyling(attributedString: attr) 120 | 121 | case .range(let range): 122 | self.textStorage.addAttributes(attributes, range: range) 123 | 124 | //Create an attributed string out of ONLY the highlighted text 125 | guard let attr = self.textView.attributedSubstring(forProposedRange: range, actualRange: nil) else { 126 | return 127 | } 128 | 129 | //Ensure the UI is updated with the new TextStyling state's 130 | self.selectedTextFontStyling = TextStyling(attributedString: attr) 131 | 132 | case .future: 133 | //Get the existing TypingAttributes, and merge it into our new attributes dictionary 134 | var typingAttributes = self.textView.typingAttributes 135 | //print("Old TypingAttributes: \(typingAttributes)") 136 | 137 | typingAttributes.merge(newDict: attributes) 138 | //print("New TypingAttributes: \(typingAttributes)\n") 139 | 140 | self.textView.typingAttributes = typingAttributes 141 | 142 | } 143 | 144 | self.richEditorDelegate?.fontStylingChanged(self.textStyling) 145 | self.toolbarRichEditorDelegate?.fontStylingChanged(self.textStyling) 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![RichEditor: Customisable NSTextview WYSIWYG Editor](https://raw.githubusercontent.com/will-lumley/RichEditor/main/RichEditor.png) 2 | 3 | # RichEditor 4 | 5 |

6 | macOS - CI Status 7 |

8 |

9 | SPM Compatible 10 | Swift 5.5 11 | 12 | Bluesky 13 | 14 |

15 | 16 | RichEditor is a WYSIWYG editor written in pure Swift. RichEditor is a direct subclass of `NSTextView` so you'll feel perfectly comfortable using it. 17 | 18 | If you are developing a macOS application that uses an `NSTextView` and you want your users to be able to format their text, you are forced to use the `usesInspectorBar` property, which adds a formatting toolbar to your `NSTextView`. 19 | 20 | However if you want to modify the UI or modify functionality at all, you'll soon find a dead-end. This is where `RichEditor` comes in. 21 | 22 | Just drag `RichEditor` into your UI, and you can use RichEditors functionality to easily programatically control which parts of your text are formatted and how. From bolding and underlining to text alignment and text colour, you have control over your text view. You can create your own UI the way you want and connect your UI to the `RichEditor` functionality. 23 | 24 | However if you want to use a template UI, RichEditor has one last trick up its sleeve for you. Simply call the `configureToolbar()` from your instance of `RichEditor` and you will have our pre-made toolbar ready to go! 25 | 26 | RichEditor also handles the difficult logic of handling text formatting when a user has already highlighted a piece of text, or when you want to export the text with HTML formatting. 27 | 28 | RichEditor allows you to control: 29 | - [x] Bolding 30 | - [x] Italics 31 | - [x] Underlining 32 | - [x] Font Selection 33 | - [x] Font Size Selection 34 | - [x] Text Alignment (Left, Centre, Right, Justified) 35 | - [x] Text Colour 36 | - [x] Text Highlight Colour 37 | - [x] Link Insertion 38 | - [x] Bullet Points 39 | - [x] Text Strikethrough 40 | - [x] Attachment Insertion 41 | 42 | To do: 43 | - [ ] Implement better bullet point formatting 44 | 45 | You can see the provided `RichEditorToolbar` in action, as well as the export functionality in the screenshots below. 46 | These screenshots are taken from the Example project that you can use in the repository in the Example directory. 47 | 48 | Screen Shot 2022-01-25 at 4 00 14 pm 49 | Screen Shot 2022-01-25 at 4 00 23 pm 50 | 51 | 52 | ## Usage 53 | 54 | RichEditor is a direct subclass of `NSTextView` and as such, you can drag `NSTextView` into your storyboard and subclass it there, or you can directly instantiate `RichEditor` directly in your code using the initialisers from `NSTextView`. 55 | 56 | ### Exporting 57 | 58 | RichEditor allows you to export the content of the `RichEditor` as a HTML. You can do this as follows. 59 | ```swift 60 | let html = try richEditor.html() 61 | ``` 62 | 63 | `html()` returns an optional `String` type, and will `throw` in the event of an error. 64 | 65 | ### Format Types 66 | 67 | Below is a walkthrough of formatting that RichEditor allows you to use. 68 | 69 | ---- 70 | 71 | **Bold** 72 | 73 | `richEditor.toggleBold()` 74 | 75 | ---- 76 | 77 | **Italic** 78 | 79 | `richEditor.toggleItalic()` 80 | 81 | ---- 82 | 83 | **Underline** 84 | 85 | `richEditor.toggleUnderline(.single)` 86 | 87 | `toggleUnderline(_)` takes `NSUnderlineStyle` as an argument, so you can specify which underline style should be applied. 88 | 89 | ---- 90 | 91 | **Strikethrough** 92 | 93 | `richEditor.toggleStrikethrough(.single)` 94 | 95 | `toggleStrikethrough(_)` takes `NSUnderlineStyle` as an argument, so you can specify which underline style should be applied with your strikethrough. 96 | 97 | ---- 98 | 99 | **Text Colour** 100 | 101 | `richEditor.applyColour(textColour: .green)` 102 | 103 | `applyColour(textColour:)` takes `NSColor` as an argument, so you can specify which colour should be applied. 104 | 105 | ---- 106 | 107 | **Text Highlighy Colour** 108 | 109 | `richEditor.applyColour(highlightColour: .green)` 110 | 111 | `applyColour(highlightColour:)` takes `NSColor` as an argument, so you can specify which colour should be applied. 112 | 113 | ---- 114 | 115 | **Font** 116 | 117 | `richEditor.apply(font: .systemFont(ofSize: 12))` 118 | 119 | `applyColour(font:)` takes `NSFont` as an argument, so you can specify which font should be applied. 120 | 121 | ---- 122 | 123 | **Text Alignment** 124 | 125 | `richEditor.apply(alignment: .left)` 126 | 127 | `applyColour(alignment:)` takes `NSTextAlignment` as an argument, so you can specify which alignment should be applied. 128 | 129 | ---- 130 | 131 | **Links** 132 | 133 | `richEditor.insert(link: url, with: name)` 134 | 135 | `insert(link:, with:, at:)` takes a `URL` as an argument, so you can specify which alignment should be applied. 136 | 137 | A `String` is also taken for how you want this link to appear to the user. 138 | 139 | An optional `Int` argument can also be supplied which indicates what index of the `NSTextView`s string the link should be insert at. If nil, the link will be appended to the end of the string. 140 | 141 | ---- 142 | 143 | ## Example Project 144 | 145 | To run the example project, clone the repo, and open the example Xcode Project in RichEditorExample. 146 | 147 | ## Requirements 148 | 149 | RichEditor supports iOS 10.0 and above & macOS 10.10 and above. 150 | 151 | ## Installation 152 | 153 | ### Swift Package Manager 154 | RichEditor is available through [Swift Package Manager](https://github.com/apple/swift-package-manager). 155 | To install it, simply add the dependency to your Package.Swift file: 156 | 157 | ```swift 158 | ... 159 | dependencies: [ 160 | .package(url: "https://github.com/will-lumley/RichEditor.git", from: "1.2.0"), 161 | ], 162 | targets: [ 163 | .target( name: "YourTarget", dependencies: ["RichEditor"]), 164 | ] 165 | ... 166 | ``` 167 | 168 | ### Cocoapods and Carthage 169 | RichEditor was previously available through CocoaPods and Carthage, however making the library available to all three Cocoapods, 170 | Carthage, and SPM (and functional to all three) was becoming troublesome. This, combined with the fact that SPM has seen a serious 171 | up-tick in adoption & functionality, has led me to remove support for CocoaPods and Carthage. 172 | 173 | ## Author 174 | 175 | [William Lumley](https://lumley.io/), will@lumley.io 176 | 177 | ## License 178 | 179 | RichEditor is available under the MIT license. See the LICENSE file for more info. 180 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/NSTextView+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSTextView+Generics.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 20/3/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | extension NSTextView { 13 | 14 | struct LineInfo { 15 | let lineNumber: Int 16 | let lineRange: NSRange 17 | let lineString: String 18 | let caretLocation: Int 19 | } 20 | 21 | /// Determines if the user has selected (ie. highlighted) any text 22 | var hasSelectedText: Bool { 23 | self.selectedRange().length > 0 24 | } 25 | 26 | /// The location of our caret within the textview 27 | var caretLocation: Int { 28 | self.selectedRange().location 29 | } 30 | 31 | /** 32 | Calculates which line number (using a 0 based index) our caret is on, the range of this line (in comparison to the whole string), and the string that makes up that line of text. 33 | Will return nil if there is no caret present, and a portion of text is highlighted instead. 34 | 35 | A pain point of this function is that it cannot return the current line number when it's found, but rather 36 | has to wait for every single line to be iterated through first. This is because the enumerateSubstrings() function 37 | on the String is not an actual loop, and as such we cannot return or break within it. 38 | 39 | - returns: The line number that the caret is on, the range of our line, and the string that makes up that line of text 40 | */ 41 | var currentLine: LineInfo { 42 | //The line number that we're currently iterating on 43 | var lineNumber = 0 44 | 45 | //The line number & line of text that we believe the caret to be on 46 | var selectedLineNumber = 0 47 | var selectedLineRange = NSRange(location: 0, length: 0) 48 | var selectedLineOfText = "" 49 | var caretLocationInLine = 0 50 | 51 | var foundSelectedLine = false 52 | 53 | //Iterate over every line in our TextView 54 | self.string.enumerateSubstrings(in: self.string.startIndex..= startOfLine && self.caretLocation <= endOfLine { 64 | // MARK the line number 65 | selectedLineNumber = lineNumber 66 | selectedLineOfText = substring ?? "" 67 | selectedLineRange = range 68 | caretLocationInLine = self.caretLocation - startOfLine 69 | 70 | foundSelectedLine = true 71 | } 72 | 73 | lineNumber += 1 74 | } 75 | 76 | //If we're not at the starting point, and we didn't find a current line, then we're at the end of our TextView 77 | if self.caretLocation > 0 && !foundSelectedLine { 78 | selectedLineNumber = lineNumber 79 | selectedLineOfText = "" 80 | selectedLineRange = NSRange(location: self.caretLocation, length: 0) 81 | } 82 | 83 | return LineInfo(lineNumber: selectedLineNumber, lineRange: selectedLineRange, lineString: selectedLineOfText, caretLocation: caretLocationInLine) 84 | } 85 | 86 | /** 87 | Replaces the current NSString/NSAttributedString that is currently within 88 | the NSTextView and replaces it with the provided HTML string. 89 | This HTML string is converted into a UTF8 encoded piece of data at first, 90 | and then converted to an NSAttributedString using native cocoa functions 91 | - parameter html: The HTML we wish to populate our NSTextView with 92 | - returns: A boolean value indicative of if the conversion and setting of 93 | the HTML string was successful 94 | */ 95 | func set(html: String) -> Bool { 96 | guard let htmlData = html.data(using: .utf8) else { 97 | print("Error creating NSAttributedString, HTML data is nil.") 98 | return false 99 | } 100 | 101 | let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.rtfd] 102 | guard let attrFromHTML = NSAttributedString(html: htmlData, options: options, documentAttributes: nil) else { 103 | print("Error creating NSAttributedString, NSAttributedString from HTML is nil.") 104 | return false 105 | } 106 | 107 | //We've created the NSAttributedString from the HTML, let's apply it 108 | return self.set(attributedString: attrFromHTML) 109 | } 110 | 111 | /** 112 | Replaces the current NSString/NSAttributedString that is currently within 113 | the NSTextView and replaces it with the provided NSAttributedString 114 | - parameter attributedString: The NSAttributedString we wish to populate our NSTextView with 115 | - returns: A boolean value indicative of if the setting of the NSAttributedString was successful 116 | */ 117 | @discardableResult 118 | func set(attributedString: NSAttributedString) -> Bool { 119 | guard let textStorage = self.textStorage else { 120 | print("Error setting NSAttributedString, TextStorage is nil.") 121 | return false 122 | } 123 | 124 | let fullRange = self.string.fullRange 125 | textStorage.replaceCharacters(in: fullRange, with: attributedString) 126 | 127 | return true 128 | } 129 | 130 | func iterateThroughAllAttachments() { 131 | let attachments = self.attributedString().allAttachments 132 | for attachment in attachments { 133 | guard let fileWrapper = attachment.fileWrapper else { 134 | continue 135 | } 136 | 137 | if !fileWrapper.isRegularFile { 138 | continue 139 | } 140 | 141 | guard let fileData = fileWrapper.regularFileContents else { continue } 142 | let fileName = fileWrapper.filename 143 | let fileAttr = fileWrapper.fileAttributes 144 | let fileIcon = fileWrapper.icon 145 | 146 | print("FileAttributes: \(fileAttr)") 147 | print("FileData: \(fileData)") 148 | print("FileName: \(fileName ?? "NoName")") 149 | print("FileIcon: \(String(describing: fileIcon))") 150 | 151 | print("") 152 | } 153 | } 154 | 155 | func append(_ string: String) { 156 | let textViewText = NSMutableAttributedString(attributedString: self.attributedString()) 157 | textViewText.append(NSAttributedString(string: string, attributes: self.typingAttributes)) 158 | 159 | self.set(attributedString: textViewText) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditor/RichEditor+BulletPoints.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditor+BulletPoints.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 7/12/20. 6 | // 7 | 8 | import AppKit 9 | 10 | public extension RichEditor { 11 | 12 | func startBulletPoints() { 13 | let currentLine = self.textView.currentLine 14 | 15 | // Get the string that makes up our current string, and find out where it sits in our TextView 16 | let currentLineStr = currentLine.lineString 17 | let currentLineRange = currentLine.lineRange 18 | 19 | // If our current line already has a bullet point, remove it 20 | if currentLineStr.isBulletPoint { 21 | //Get the line in our TextView that our caret is on, and remove our bulletpoint 22 | var noBulletPointStr = currentLineStr.replacingOccurrences(of: "\(RichEditor.bulletPointMarker) ", with: "") 23 | noBulletPointStr = currentLineStr.replacingOccurrences(of: RichEditor.bulletPointMarker, with: "") 24 | 25 | self.textView.replaceCharacters(in: currentLineRange, with: noBulletPointStr) 26 | } 27 | // If our current line doesn't already have a bullet point appended to it, prepend one 28 | else { 29 | // Get the line in our TextView that our caret is on, and prepend a bulletpoint to it 30 | let bulletPointStr = "\(RichEditor.bulletPointMarker) \(currentLineStr)" 31 | self.textView.replaceCharacters(in: currentLineRange, with: bulletPointStr) 32 | } 33 | } 34 | 35 | } 36 | 37 | extension RichEditor: NSTextViewDelegate { 38 | 39 | public func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { 40 | 41 | // Handle: 42 | // 1. If a line with *just* a bullet point exists and backspace is pressed, delete 1 tab 43 | 44 | // Get all the lines of the text 45 | // Get the line of text that we're on 46 | 47 | guard let newString = replacementString else { return true } 48 | 49 | let currentLine = textView.currentLine 50 | 51 | // If the line we're currently on is NOT prefixed with a bullet point, bail out 52 | let currentLineStr = currentLine.lineString 53 | if currentLineStr.isBulletPoint == false { 54 | // We have decided we don't want to make any artificial changes, let the literal changes go through 55 | return true 56 | } 57 | 58 | let currentLineRange = currentLine.lineRange 59 | 60 | // If the user just hit enter/newline 61 | if newString == "\n" { 62 | // The line we're currently on is prefixed with a bullet point, append a bullet point to the next line 63 | 64 | // If our current line is just an empty bullet point line, remove the bullet point and turn it into a regular line 65 | if currentLineStr == RichEditor.bulletPointMarker { 66 | self.textView.replaceCharacters(in: currentLineRange, with: "") 67 | } 68 | 69 | // If our current line is a full bullet point line, append a brand spanking new bullet point line below our current line for our user 70 | else { 71 | 72 | // We want to make sure that our newline has the same amount of starting tabs as our current line 73 | var prependedTabs = "" 74 | if currentLineStr.isPrefixedWithTab { 75 | prependedTabs = "\t".repeated(currentLineStr.prefixedStringCount(needle: "\t")) 76 | } 77 | 78 | let bulletPointStr = "\(currentLineStr)\n\(prependedTabs)\(RichEditor.bulletPointMarker)" 79 | self.textView.replaceCharacters(in: currentLineRange, with: bulletPointStr) 80 | } 81 | 82 | // We've made the artificial changes to the string, don't let the literal change go through 83 | return false 84 | } 85 | 86 | // If the user just hit the tab button 87 | else if newString == "\t" { 88 | let bulletPointStr = "\t\(currentLineStr)" 89 | self.textView.replaceCharacters(in: currentLineRange, with: bulletPointStr) 90 | 91 | // We've made the artificial changes to the string, don't let the literal change go through 92 | return false 93 | } 94 | 95 | // If the user just hit the backspace button 96 | else if newString.isBackspace { 97 | // If our line has any tabs prepended to it, delete one of them 98 | if currentLineStr.isPrefixedWithTab { 99 | 100 | // Get the CaretLocation of our caret relative to this current line 101 | var caretLocation = currentLine.caretLocation 102 | 103 | // Calculate how many bullet point & tabs exist in the start of this line 104 | let prefixTabCount = currentLineStr.prefixedStringCount(needle: "\t") 105 | let prefixBulletPointCount = currentLineStr.prefixedStringCount(needle: "•", ignoring: ["\t"]) 106 | 107 | // Remove the bullet point & tab count from our location, as we don't consider them actual characters 108 | caretLocation = caretLocation - (prefixTabCount + prefixBulletPointCount) 109 | 110 | // Remove the extra space that sits after the bullet point 111 | caretLocation -= 1 112 | 113 | // If our caret is at the start (barring any tabs and bullet point markers) of our line 114 | if caretLocation == 0 { 115 | var bulletPointStr = String(currentLineStr) 116 | bulletPointStr.removeFirst(needle: "\t") 117 | 118 | self.textView.replaceCharacters(in: currentLineRange, with: bulletPointStr) 119 | 120 | // We've made the artificial changes to the string, don't let the literal change go through 121 | return false 122 | } 123 | } 124 | } 125 | 126 | // We have decided we don't want to make any artificial changes, let the literal changes go through// We have decided we don't want to make any artificial changes, let the literal changes go through 127 | return true 128 | } 129 | 130 | /* 131 | func textView(_ textView: NSTextView, urlForContentsOf textAttachment: NSTextAttachment, at charIndex: Int) -> URL? 132 | { 133 | print("TextAttachment: \(textAttachment)") 134 | print("CharIndex: \(charIndex)") 135 | 136 | let fileWrapper = textAttachment.fileWrapper! 137 | //let metaFileWrappers = fileWrapper.fileWrappers 138 | let data = fileWrapper.regularFileContents ?? Data() 139 | 140 | //print("FileWrapper.fileWrappers: \(String(describing: metaFileWrappers))") 141 | print("Data: \(data)") 142 | 143 | print("") 144 | return nil 145 | } 146 | */ 147 | 148 | public func textViewDidChangeSelection(_ notification: Notification) { 149 | let selectedRange = self.textView.selectedRange() 150 | let isSelected = selectedRange.length > 0 151 | 152 | if !isSelected { 153 | self.selectedTextFontStyling = nil 154 | return 155 | } 156 | 157 | //Create an attributed string out of ONLY the highlighted text 158 | guard let attr = self.textView.attributedSubstring(forProposedRange: selectedRange, actualRange: nil) else { 159 | return 160 | } 161 | 162 | self.selectedTextFontStyling = TextStyling(attributedString: attr) 163 | } 164 | 165 | } 166 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/TextStyling.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextStyling.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 20/3/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | public struct TextStyling { 13 | 14 | public private(set) var isBold : Bool 15 | public private(set) var isUnbold: Bool 16 | 17 | public private(set) var isItalic : Bool 18 | public private(set) var isUnitalic: Bool 19 | 20 | public private(set) var isUnderline : Bool 21 | public private(set) var isUnunderline: Bool 22 | 23 | public private(set) var isStrikethrough : Bool 24 | public private(set) var isUnstrikethrough: Bool 25 | 26 | public private(set) var fonts: [NSFont] 27 | public private(set) var alignments: [NSTextAlignment] 28 | 29 | public private(set) var textColours : [NSColor] 30 | public private(set) var highlightColours: [NSColor] 31 | 32 | public var boldTrait: TextStyling.Trait { 33 | //If we're ONLY bold 34 | if self.isBold && !self.isUnbold { 35 | return .isTrait 36 | } 37 | 38 | //If we're ONLY unbold 39 | else if !self.isBold && self.isUnbold { 40 | return .isNotTrait 41 | } 42 | 43 | //If we're BOTH bold and unbold 44 | else if self.isBold && self.isUnbold { 45 | return .both 46 | } 47 | 48 | fatalError("Failed to reach conclusion for BoldTrait, for TextStyling: \(self)") 49 | } 50 | 51 | public var italicsTrait: TextStyling.Trait { 52 | //If we're ONLY italic 53 | if self.isItalic && !self.isUnitalic { 54 | return .isTrait 55 | } 56 | 57 | //If we're ONLY unitalic 58 | else if !self.isItalic && self.isUnitalic { 59 | return .isNotTrait 60 | } 61 | 62 | //If we're BOTH italic and unitalic 63 | else if self.isItalic && self.isUnitalic { 64 | return .both 65 | } 66 | 67 | fatalError("Failed to reach conclusion for ItalicTrait, for TextStyling: \(self)") 68 | } 69 | 70 | public var underlineTrait: TextStyling.Trait { 71 | //If we're ONLY underline 72 | if self.isUnderline && !self.isUnunderline { 73 | return .isTrait 74 | } 75 | 76 | //If we're ONLY un-underline 77 | else if !self.isUnderline && self.isUnunderline { 78 | return .isNotTrait 79 | } 80 | 81 | //If we're BOTH underline and un-underline 82 | else if self.isUnderline && self.isUnunderline { 83 | return .both 84 | } 85 | 86 | fatalError("Failed to reach conclusion for UnderlineTrait, for TextStyling: \(self)") 87 | } 88 | 89 | public var strikethroughTrait: TextStyling.Trait { 90 | //If we're ONLY strikethrough 91 | if self.isStrikethrough && !self.isUnstrikethrough { 92 | return .isTrait 93 | } 94 | 95 | //If we're ONLY un-strikethrough 96 | else if !self.isStrikethrough && self.isUnstrikethrough { 97 | return .isNotTrait 98 | } 99 | 100 | //If we're BOTH strikethrough and un-strikethrough 101 | else if self.isStrikethrough && self.isUnstrikethrough { 102 | return .both 103 | } 104 | 105 | fatalError("Failed to reach conclusion for StrikethroughTrait, for TextStyling: \(self)") 106 | } 107 | 108 | // MARK: - TextStyling 109 | 110 | init(attributedString: NSAttributedString) { 111 | self.textColours = attributedString.allTextColours 112 | 113 | self.isBold = attributedString.contains(trait: .boldFontMask) 114 | self.isUnbold = attributedString.doesNotContain(trait: .boldFontMask) 115 | 116 | self.isItalic = attributedString.contains(trait: .italicFontMask) 117 | self.isUnitalic = attributedString.doesNotContain(trait: .italicFontMask) 118 | 119 | let underlineQualities = attributedString.check(attribute: NSAttributedString.Key.underlineStyle) 120 | self.isUnderline = underlineQualities.atParts 121 | self.isUnunderline = underlineQualities.notAtParts 122 | 123 | let strikethroughQualities = attributedString.check(attribute: NSAttributedString.Key.strikethroughStyle) 124 | self.isStrikethrough = strikethroughQualities.atParts 125 | self.isUnstrikethrough = strikethroughQualities.notAtParts 126 | 127 | self.textColours = attributedString.allTextColours 128 | self.highlightColours = attributedString.allHighlightColours 129 | 130 | self.fonts = attributedString.allFonts 131 | self.alignments = attributedString.allAlignments 132 | } 133 | 134 | init(typingAttributes: [NSAttributedString.Key: Any]) { 135 | let font = typingAttributes[NSAttributedString.Key.font] as! NSFont 136 | 137 | self.isBold = font.contains(trait: .boldFontMask) 138 | self.isUnbold = !self.isBold 139 | 140 | self.isItalic = font.contains(trait: .italicFontMask) 141 | self.isUnitalic = !self.isItalic 142 | 143 | self.isUnderline = typingAttributes.check(attribute: NSAttributedString.Key.underlineStyle) {(rawAttr) -> Bool in 144 | return rawAttr == 0 145 | } 146 | self.isUnunderline = !self.isUnderline 147 | 148 | self.isStrikethrough = typingAttributes.check(attribute: NSAttributedString.Key.strikethroughStyle) {(rawAttr) -> Bool in 149 | return rawAttr == 0 150 | } 151 | self.isUnstrikethrough = !self.isStrikethrough 152 | 153 | self.textColours = [] 154 | self.highlightColours = [] 155 | 156 | self.fonts = [] 157 | self.alignments = [] 158 | 159 | if let textColour = typingAttributes[NSAttributedString.Key.foregroundColor] as? NSColor { 160 | self.textColours = [textColour] 161 | } 162 | 163 | if let highlightColour = typingAttributes[NSAttributedString.Key.backgroundColor] as? NSColor { 164 | self.highlightColours = [highlightColour] 165 | } 166 | 167 | if let font = typingAttributes[NSAttributedString.Key.font] as? NSFont { 168 | self.fonts = [font] 169 | } 170 | 171 | if let paragraphStyle = typingAttributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle { 172 | self.alignments = [paragraphStyle.alignment] 173 | } 174 | 175 | //print("Typing Attributes: \(typingAttributes)") 176 | } 177 | 178 | // MARK: - Functions 179 | 180 | /** 181 | Given an NSFontTraitMask, matches the correlating Trait enum that correlates with the provided argument 182 | - parameter nsFontTraitMask: The NSFontTraitMask that we need to match to a Trait enum 183 | - returns: A Trait enum that will correlate with the NSFontTraitMask 184 | */ 185 | public func fontTraitFor(nsFontTraitMask: NSFontTraitMask) -> TextStyling.Trait { 186 | switch (nsFontTraitMask) { 187 | case .boldFontMask: 188 | return self.boldTrait 189 | case .italicFontMask: 190 | return self.italicsTrait 191 | default: 192 | fatalError("Failed to reach conclusion for determining correlating Trait and NSFontTraitMask. NSFontTraitMask: \(nsFontTraitMask)") 193 | } 194 | } 195 | 196 | public func trait(with key: NSAttributedString.Key) -> TextStyling.Trait { 197 | switch (key) { 198 | case .strikethroughStyle: 199 | return self.strikethroughTrait 200 | 201 | case .underlineStyle: 202 | return self.underlineTrait 203 | 204 | default: 205 | fatalError("NSAttributedString.Key has not been accounted for in Trait determination: \(key).") 206 | } 207 | } 208 | 209 | } 210 | 211 | public extension TextStyling { 212 | 213 | enum Trait { 214 | case isTrait 215 | case isNotTrait 216 | case both 217 | } 218 | 219 | } 220 | -------------------------------------------------------------------------------- /Sources/RichEditor/Extensions/NSAttributedString+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+Generics.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 20/3/18. 6 | // Copyright © 2018 William Lumley. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppKit 11 | 12 | public extension NSAttributedString { 13 | 14 | /** 15 | Determines the attributes for the whole complete NSAttributedString 16 | - returns: The attributes, in the form of a dictionary, for the whole NSAttributedString 17 | */ 18 | var attributes: [NSAttributedString.Key: Any] { 19 | self.attributes(at: 0, longestEffectiveRange: nil, in: self.string.fullRange) 20 | } 21 | 22 | /** 23 | Calculates all the various fonts that exist within this NSAttributedString 24 | - returns: All NSFonts that are used in this NSAttributedString 25 | */ 26 | var allFonts: [NSFont] { 27 | var fonts = [NSFont]() 28 | self.enumerateAttribute(NSAttributedString.Key.font, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, stop) in 29 | let font = value as! NSFont 30 | fonts.append(font) 31 | }) 32 | 33 | return fonts 34 | } 35 | 36 | /** 37 | Calculates all the various text alignments that exist within this NSAttributedString 38 | - returns: All NSTextAlignments that are used in this NSAttributedString 39 | */ 40 | var allAlignments: [NSTextAlignment] { 41 | var alignments = [NSTextAlignment]() 42 | self.enumerateAttribute(NSAttributedString.Key.paragraphStyle, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, stop) in 43 | let paragraphStyle = value as! NSParagraphStyle 44 | let alignment = paragraphStyle.alignment 45 | alignments.append(alignment) 46 | }) 47 | 48 | return alignments 49 | } 50 | 51 | /** 52 | Calculates all the text colours that are used in this attributed string 53 | - returns: An array of NSColors, representing all the text colours in this attributed string 54 | */ 55 | var allTextColours: [NSColor] { 56 | var colours = [NSColor]() 57 | self.enumerateAttribute(.foregroundColor, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, finished) in 58 | if value != nil { 59 | if let colour = value as? NSColor { 60 | colours.append(colour) 61 | } 62 | } 63 | 64 | //If the value is nil, it's the default NSColor value, which is black 65 | else { 66 | colours.append(NSColor.black) 67 | } 68 | }) 69 | 70 | return colours 71 | } 72 | 73 | var allHighlightColours: [NSColor] { 74 | var colours = [NSColor]() 75 | self.enumerateAttribute(.backgroundColor, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, finished) in 76 | if value != nil { 77 | if let colour = value as? NSColor { 78 | colours.append(colour) 79 | } 80 | } 81 | 82 | //If the value is nil, it's the default NSColor value, which is white 83 | else { 84 | colours.append(NSColor.white) 85 | } 86 | }) 87 | 88 | return colours 89 | } 90 | 91 | /** 92 | Calculates all the NSTextAttachments that are contained in this attributed string 93 | - returns: An array of NSTextAttachments, representing all the attachments in this attributed string 94 | */ 95 | var allAttachments: [NSTextAttachment] { 96 | var attachments = [NSTextAttachment]() 97 | self.enumerateAttribute(.attachment, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(value, range, finished) in 98 | if value != nil { 99 | if let attachment = value as? NSTextAttachment { 100 | attachments.append(attachment) 101 | } 102 | } 103 | }) 104 | 105 | return attachments 106 | } 107 | 108 | // MARK: - Attribute Checking 109 | 110 | /** 111 | Iterates over every font that exists within this NSAttributedString, and checks if any of the fonts contain the desired NSFontTraitMask 112 | - returns: A boolean value, indicative of if this contains our desired trait 113 | */ 114 | func contains(trait: NSFontTraitMask) -> Bool { 115 | let allFonts = self.all(of: NSAttributedString.Key.font) as! [NSFont] 116 | for font in allFonts { 117 | if font.contains(trait: trait) { 118 | return true 119 | } 120 | } 121 | 122 | return false 123 | } 124 | 125 | /** 126 | Iterates over every font that exists within this NSAttributedString, and checks if any of the fonts contain the desired NSFontTraitMask 127 | - returns: A boolean value, indicative of if our desired trait could not be found 128 | */ 129 | func doesNotContain(trait: NSFontTraitMask) -> Bool { 130 | contains(trait: trait) == false 131 | } 132 | 133 | /** 134 | Determines if this attributed string contains any parts that have the provided NSAttributedString.Key, and any parts that do NOT have the provided NSAttributedString.Key 135 | - parameter key: The NSAttributedString.Key that we're looking for 136 | 137 | - returns: A tuple containing two arguments, atParts & notAtParts. atParts will be true if any part of this 138 | attributed string is present. notAtParts will be true if any part of this attributed string is NOT present. 139 | The two arguments are not mutually exclusive since a string can have an attribute at some parts and 140 | not have the same attributes at other parts. 141 | */ 142 | func check(attribute: NSAttributedString.Key) -> (atParts: Bool, notAtParts: Bool) { 143 | var atParts : Bool? 144 | var notAtParts: Bool? 145 | 146 | self.enumerateAttribute(attribute, in: self.string.fullRange, options: .longestEffectiveRangeNotRequired, using: {(valueOpt, range, stop) in 147 | 148 | // If this can be an NSNumber 149 | if let value = valueOpt as? NSNumber { 150 | // If we have a `none` enum value (by checking with the provided closure) 151 | if value.intValue != 0 { 152 | atParts = true 153 | } 154 | 155 | // If we don't have a `none` enum value 156 | else { 157 | notAtParts = true 158 | } 159 | } 160 | 161 | else if let _ = valueOpt as? NSFont { 162 | atParts = true 163 | } 164 | 165 | else { 166 | notAtParts = true 167 | } 168 | }) 169 | 170 | // If noUnderlineAtParts wasn't set and neither was underlineAtParts, then clearly we have no underline 171 | if notAtParts == nil && atParts == nil { 172 | notAtParts = true 173 | } 174 | 175 | // If noUnderlineAtParts wasn't set but underlineAtParts WAS, then clearly we only have underline 176 | if notAtParts == nil && atParts != nil { 177 | notAtParts = false 178 | } 179 | 180 | // If underlineAtParts wasn't set, then clearly we don't have any underline 181 | if atParts == nil { 182 | atParts = false 183 | } 184 | 185 | guard let atParts = atParts, let notAtParts = notAtParts else { 186 | fatalError("`atParts` or `notAtParts` was nil.") 187 | } 188 | 189 | return (atParts, notAtParts) 190 | } 191 | 192 | } 193 | 194 | // MARK: - Basic Attribute Fetching 195 | 196 | private extension NSAttributedString { 197 | 198 | /** 199 | Collects all the types of the attribute that we're after 200 | - parameter attribute: The NSAttributedString.Key values we're searching for 201 | - returns: An array of all the values that correlated with the provided attribute key 202 | */ 203 | func all(of attribute: NSAttributedString.Key) -> [Any] { 204 | var allValues = [Any]() 205 | let fullRange = self.string.fullRange 206 | let options = NSAttributedString.EnumerationOptions.longestEffectiveRangeNotRequired 207 | 208 | self.enumerateAttribute(attribute, in: fullRange, options: options, using: {(valueOpt, range, stop) in 209 | if let value = valueOpt { 210 | allValues.append(value) 211 | } 212 | }) 213 | 214 | return allValues 215 | } 216 | 217 | } 218 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditor/RichEditor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditor.swift 3 | // RichEditor 4 | // 5 | // Created by Will Lumley on 30/1/20. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | public class RichEditor: NSView { 12 | 13 | // MARK: - Types 14 | 15 | enum CommandShortcut: String { 16 | case b = "b" 17 | case i = "i" 18 | case u = "u" 19 | case plus = "+" 20 | case minus = "-" 21 | } 22 | 23 | // MARK: - Properties 24 | 25 | //The NSTextView stack 26 | /*------------------------------------------------------------*/ 27 | public private(set) lazy var textStorage = NSTextStorage() 28 | public private(set) lazy var layoutManager = NSLayoutManager() 29 | public private(set) lazy var textContainer = NSTextContainer() 30 | public private(set) lazy var textView = RichTextView(frame: CGRect(), textContainer: self.textContainer, delegate: self) 31 | public private(set) lazy var scrollview = NSScrollView() 32 | /*------------------------------------------------------------*/ 33 | 34 | /// The TextStyling that contains information of the 'relevant' text 35 | internal var selectedTextFontStyling: TextStyling? { 36 | didSet { 37 | self.richEditorDelegate?.fontStylingChanged(self.textStyling) 38 | self.toolbarRichEditorDelegate?.fontStylingChanged(self.textStyling) 39 | } 40 | } 41 | 42 | /// The marker that will be used for bullet points 43 | internal static var bulletPointMarker = "•\u{00A0}" //NSTextList.MarkerFormat.circle 44 | 45 | /// Returns the TextStyling object that was derived from the selected text, or the future text if nothing is selected 46 | public var textStyling: TextStyling { 47 | self.selectedTextFontStyling ?? TextStyling(typingAttributes: self.textView.typingAttributes) 48 | } 49 | 50 | /// The delegate which will notify the listener of significant events 51 | public var richEditorDelegate: RichEditorDelegate? 52 | 53 | /// The toolbar object, allowing for users to easily apply styling to their text 54 | internal var toolbar: RichEditorToolbar? 55 | 56 | /// The delegate which will notify the listener of significant events 57 | internal var toolbarRichEditorDelegate: RichEditorDelegate? 58 | 59 | /// Returns the NSFont object that was derived from the selected text, or the future text if nothing is selected 60 | public var currentFont: NSFont { 61 | 62 | // If we have highlighted text, we'll analyse the font of the highlighted text 63 | if self.textView.hasSelectedText { 64 | let range = self.textView.selectedRange() 65 | 66 | // Create an attributed string out of ONLY the highlighted text 67 | guard let attr = self.textView.attributedSubstring(forProposedRange: range, actualRange: nil) else { 68 | fatalError("Failed to create AttributedString.") 69 | } 70 | 71 | let fonts = attr.allFonts 72 | if fonts.count < 1 { 73 | fatalError("AttributedString had no fonts: \(attr)") 74 | } 75 | 76 | return fonts[0] 77 | } 78 | 79 | // We have not highlighted text, so we'll just use the 'future' font 80 | else { 81 | let typingAttributes = self.textView.typingAttributes 82 | 83 | let font = typingAttributes[NSAttributedString.Key.font] as! NSFont 84 | return font 85 | } 86 | } 87 | 88 | // MARK: - NSView 89 | 90 | override init(frame frameRect: NSRect) { 91 | super.init(frame: frameRect) 92 | self.setup() 93 | } 94 | 95 | required init?(coder decoder: NSCoder) { 96 | super.init(coder: decoder) 97 | self.setup() 98 | } 99 | 100 | } 101 | 102 | // MARK: - Private Interface 103 | 104 | private extension RichEditor { 105 | 106 | /** 107 | Perform the initial setup operations to get a functional NSTextView running 108 | */ 109 | func setup() { 110 | /// This line of code here is disgusting. 111 | /// The only reason it's here is because of an NSTextView issue that only pops up 112 | /// when launching this library from a iOS -> Catalyst application. A very edge case scenario 113 | /// but one that we should account for. 114 | /// 115 | /// I'll be on the lookout for when Apple fixes this :) 116 | /// 117 | guard self.scrollview.contentSize != .zero else { 118 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { 119 | self.setup() 120 | } 121 | return 122 | } 123 | self.textView.delegate = self 124 | 125 | self.configureTextView(isHorizontalScrollingEnabled: false) 126 | 127 | self.configureTextViewLayout() 128 | 129 | self.textView.textStorage?.delegate = self 130 | self.textView.layoutManager?.defaultAttachmentScaling = NSImageScaling.scaleProportionallyDown 131 | 132 | self.selectedTextFontStyling = nil 133 | } 134 | 135 | func configureTextViewLayout() { 136 | self.scrollview.translatesAutoresizingMaskIntoConstraints = false 137 | self.addSubview(self.scrollview) 138 | 139 | // If there is a toolbar, attach our scroll view to the bottom our toolbar 140 | if let toolbar = self.toolbar { 141 | NSLayoutConstraint.activate([ 142 | self.scrollview.widthAnchor.constraint(equalTo: self.widthAnchor), 143 | self.scrollview.topAnchor.constraint(equalTo: toolbar.bottomAnchor, constant: 5), 144 | self.scrollview.bottomAnchor.constraint(equalTo: self.bottomAnchor), 145 | ]) 146 | } 147 | 148 | // If there is NOT a toolbar, attach our scroll view to the bottom of our view 149 | else { 150 | NSLayoutConstraint.activate([ 151 | self.scrollview.widthAnchor.constraint(equalTo: self.widthAnchor), 152 | self.scrollview.topAnchor.constraint(equalTo: self.topAnchor), 153 | self.scrollview.bottomAnchor.constraint(equalTo: self.bottomAnchor), 154 | ]) 155 | } 156 | } 157 | 158 | } 159 | 160 | // MARK: - Public Interface 161 | 162 | public extension RichEditor { 163 | 164 | func configureToolbar() { 165 | self.toolbar = RichEditorToolbar(richEditor: self) 166 | self.toolbarRichEditorDelegate = self.toolbar 167 | 168 | guard let toolbar = self.toolbar else { 169 | return 170 | } 171 | 172 | toolbar.translatesAutoresizingMaskIntoConstraints = false 173 | self.addSubview(toolbar) 174 | 175 | toolbar.wantsLayer = true 176 | toolbar.layer?.backgroundColor = NSColor.clear.cgColor 177 | 178 | NSLayoutConstraint.activate([ 179 | toolbar.topAnchor.constraint(equalTo: self.topAnchor), 180 | toolbar.widthAnchor.constraint(equalTo: self.widthAnchor), 181 | toolbar.heightAnchor.constraint(equalToConstant: 35) 182 | ]) 183 | 184 | self.scrollview.removeFromSuperview() 185 | 186 | self.scrollview.translatesAutoresizingMaskIntoConstraints = false 187 | self.addSubview(self.scrollview) 188 | 189 | NSLayoutConstraint.activate([ 190 | self.scrollview.widthAnchor.constraint(equalTo: self.widthAnchor), 191 | self.scrollview.topAnchor.constraint(equalTo: toolbar.bottomAnchor, constant: 5), 192 | self.scrollview.bottomAnchor.constraint(equalTo: self.bottomAnchor), 193 | ]) 194 | } 195 | 196 | /** 197 | Uses the NSTextView's attributed string to create a HTML string that 198 | represents the content held within the NSTextView. 199 | The NSAttribtedString -> HTML String conversion uses the cocoa 200 | native data(from...) function. 201 | - throws: An error, if the NSAttribtedString -> HTML String conversion fails 202 | - returns: The string object that is the HTML 203 | */ 204 | func html() throws -> String? { 205 | let attrStr = self.textView.attributedString() 206 | let documentAttributes = [ 207 | NSAttributedString.DocumentAttributeKey.documentType: NSAttributedString.DocumentType.html, 208 | NSAttributedString.DocumentAttributeKey.characterEncoding: String.Encoding.utf8.rawValue 209 | ] as [NSAttributedString.DocumentAttributeKey: Any] 210 | 211 | let htmlData = try attrStr.data(from: attrStr.string.fullRange, documentAttributes: documentAttributes) 212 | if var htmlString = String(data: htmlData, encoding: .utf8) { 213 | 214 | // Iterate over each attachment, and replace each "file://" component with the image 215 | let allAttachments = self.textView.attributedString().allAttachments 216 | for attachment in allAttachments { 217 | guard let imageID = attachment.fileWrapper?.filename else { 218 | continue 219 | } 220 | 221 | htmlString = htmlString.replacingOccurrences(of: "file:///\(imageID)", with: imageID) 222 | } 223 | 224 | return htmlString 225 | } 226 | 227 | return nil 228 | } 229 | 230 | } 231 | 232 | // MARK: - NSTextStorage Delegate 233 | 234 | extension RichEditor: NSTextStorageDelegate { 235 | 236 | public func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) { 237 | self.richEditorDelegate?.richEditorTextChanged(self) 238 | self.toolbarRichEditorDelegate?.richEditorTextChanged(self) 239 | } 240 | 241 | } 242 | -------------------------------------------------------------------------------- /Sources/RichEditor/Classes/RichEditorToolbar/RichEditorToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichEditorToolbar.swift 3 | // RichEditor 4 | // 5 | // Created by William Lumley on 7/12/20. 6 | // 7 | 8 | import AppKit 9 | import macColorPicker 10 | 11 | class RichEditorToolbar: NSView { 12 | 13 | // MARK: - Properties 14 | 15 | private let richEditor: RichEditor 16 | 17 | internal let contentStackView = NSStackView() 18 | 19 | internal let fontFamiliesPopUpButton = NSPopUpButton(frame: .zero) 20 | internal let fontSizePopUpButton = NSPopUpButton(frame: .zero) 21 | 22 | internal let boldButton = RichEditorToolbarButton(imageName: "white-weight-bold") 23 | internal let italicButton = RichEditorToolbarButton(imageName: "white-weight-italic") 24 | internal let underlineButton = RichEditorToolbarButton(imageName: "white-weight-underline") 25 | 26 | internal let alignLeftButton = RichEditorToolbarButton(imageName: "white-align-left") 27 | internal let alignRightButton = RichEditorToolbarButton(imageName: "white-align-right") 28 | internal let alignCentreButton = RichEditorToolbarButton(imageName: "white-align-centre") 29 | internal let alignJustifyButton = RichEditorToolbarButton(imageName: "white-align-justify") 30 | 31 | internal let textColorButton = ColorPicker(frame: .zero) 32 | internal let highlightColorButton = ColorPicker(frame: .zero) 33 | 34 | internal let linkButton = RichEditorToolbarButton(imageName: "white-text-link") 35 | internal let listButton = RichEditorToolbarButton(imageName: "white-text-list") 36 | internal let strikethroughButton = RichEditorToolbarButton(imageName: "white-text-strikethrough") 37 | internal let addImageButton = RichEditorToolbarButton(imageName: "white-text-image") 38 | 39 | // MARK: - NSView 40 | 41 | init(richEditor: RichEditor) { 42 | self.richEditor = richEditor 43 | 44 | super.init(frame: .zero) 45 | 46 | self.setupUI() 47 | } 48 | 49 | override init(frame frameRect: NSRect) { 50 | fatalError("RichEditorToolbar does not support init(frame:)") 51 | } 52 | 53 | required init?(coder decoder: NSCoder) { 54 | fatalError("RichEditorToolbar does not support init(coder:)") 55 | } 56 | 57 | private func setupUI() { 58 | self.contentStackView.alignment = .centerY 59 | self.contentStackView.spacing = 8 60 | self.contentStackView.distribution = .gravityAreas 61 | 62 | // self.contentStackView.wantsLayer = true 63 | // self.contentStackView.layer?.backgroundColor = NSColor.blue.cgColor 64 | 65 | self.contentStackView.translatesAutoresizingMaskIntoConstraints = false 66 | self.addSubview(self.contentStackView) 67 | 68 | NSLayoutConstraint.activate([ 69 | self.contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), 70 | self.contentStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), 71 | self.contentStackView.topAnchor.constraint(equalTo: self.topAnchor, constant: 6), 72 | self.contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2), 73 | ]) 74 | 75 | self.setupFontUI() 76 | self.contentStackView.addSeperatorView() 77 | self.setupWeightButtons() 78 | self.contentStackView.addSeperatorView() 79 | self.setupAlignButtons() 80 | self.contentStackView.addSeperatorView() 81 | self.setupColorButtons() 82 | self.contentStackView.addSeperatorView() 83 | self.setupCustomTextActionButtons() 84 | self.contentStackView.addSeperatorView() 85 | } 86 | 87 | } 88 | 89 | // MARK: - RichEditorDelegate 90 | 91 | extension RichEditorToolbar: RichEditorDelegate { 92 | 93 | func fontStylingChanged(_ textStyling: TextStyling) { 94 | self.configureUI(with: textStyling) 95 | } 96 | 97 | func richEditorTextChanged(_ richEditor: RichEditor) { 98 | 99 | } 100 | 101 | } 102 | 103 | // MARK: - Actions 104 | 105 | internal extension RichEditorToolbar { 106 | 107 | @objc 108 | func fontFamiliesButtonClicked(_ sender: NSPopUpButton) { 109 | self.applyFont() 110 | } 111 | 112 | @objc 113 | func fontSizeButtonClicked(_ sender: NSPopUpButton) { 114 | self.applyFont() 115 | } 116 | 117 | @objc 118 | func boldButtonClicked(_ sender: RichEditorToolbarButton) { 119 | self.richEditor.toggleBold() 120 | } 121 | 122 | @objc 123 | func italicButtonClicked(_ sender: RichEditorToolbarButton) { 124 | self.richEditor.toggleItalic() 125 | } 126 | 127 | @objc 128 | func underlineButtonClicked(_ sender: RichEditorToolbarButton) { 129 | self.richEditor.toggleUnderline(.single) 130 | } 131 | 132 | @objc 133 | func alignLeftButtonClicked(_ sender: RichEditorToolbarButton) { 134 | self.richEditor.apply(alignment: .left) 135 | } 136 | 137 | @objc 138 | func alignCentreButtonClicked(_ sender: RichEditorToolbarButton) { 139 | self.richEditor.apply(alignment: .center) 140 | } 141 | 142 | @objc 143 | func alignRightButtonClicked(_ sender: RichEditorToolbarButton) { 144 | self.richEditor.apply(alignment: .right) 145 | } 146 | 147 | @objc 148 | func alignJustifyButtonClicked(_ sender: RichEditorToolbarButton) { 149 | self.richEditor.apply(alignment: .justified) 150 | } 151 | 152 | @objc 153 | func linkButtonClicked(_ sender: RichEditorToolbarButton) { 154 | let nameTextField = NSTextField(frame: NSRect(x: 0, y: 28, width: 200, height: 20)) 155 | nameTextField.placeholderString = "Link Name" 156 | 157 | let urlTextField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 20)) 158 | urlTextField.placeholderString = "Link URL" 159 | 160 | let textFieldView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 48)) 161 | textFieldView.addSubview(nameTextField) 162 | textFieldView.addSubview(urlTextField) 163 | 164 | let alert = NSAlert() 165 | alert.messageText = "Please provide the link name and the link URL" 166 | alert.addButton(withTitle: "Add Link") 167 | alert.addButton(withTitle: "Cancel") 168 | alert.accessoryView = textFieldView 169 | 170 | alert.window.initialFirstResponder = nameTextField 171 | nameTextField.nextKeyView = urlTextField 172 | 173 | let selectedButton = alert.runModal() 174 | 175 | switch selectedButton.rawValue { 176 | case 1000: 177 | let name = nameTextField.stringValue 178 | let url = urlTextField.stringValue 179 | 180 | self.richEditor.insert(link: url, with: name) 181 | default: 182 | print("Unknown raw value selected: \(selectedButton)") 183 | } 184 | } 185 | 186 | @objc 187 | func listButtonClicked(_ sender: RichEditorToolbarButton) { 188 | self.richEditor.startBulletPoints() 189 | } 190 | 191 | @objc 192 | func strikethroughButtonClicked(_ sender: RichEditorToolbarButton) { 193 | self.richEditor.toggleStrikethrough(.single) 194 | } 195 | 196 | @objc 197 | func addImageButtonClicked(_ sender: RichEditorToolbarButton) { 198 | self.richEditor.promptUserForAttachments(windowForModal: self.window) 199 | } 200 | 201 | } 202 | 203 | // MARK: - ColorPickerDelegate 204 | 205 | extension RichEditorToolbar: ColorPickerDelegate { 206 | 207 | func didSelectColor(_ sender: ColorPicker, color: NSColor) { 208 | switch sender { 209 | case self.textColorButton: 210 | self.richEditor.apply(textColour: color) 211 | case self.highlightColorButton: 212 | self.richEditor.apply(highlightColour: color) 213 | default:() 214 | } 215 | } 216 | 217 | } 218 | 219 | // MARK: - Private Extensions 220 | 221 | private extension RichEditorToolbar { 222 | 223 | var toolbarButtons: [RichEditorToolbarButton] { 224 | [ 225 | self.boldButton, 226 | self.italicButton, 227 | self.underlineButton, 228 | 229 | self.alignLeftButton, 230 | self.alignCentreButton, 231 | self.alignRightButton, 232 | self.alignJustifyButton, 233 | 234 | self.linkButton, 235 | self.listButton, 236 | self.strikethroughButton, 237 | self.addImageButton, 238 | ] 239 | } 240 | 241 | var alignmentButtons: [RichEditorToolbarButton] { 242 | [ 243 | self.alignLeftButton, 244 | self.alignCentreButton, 245 | self.alignRightButton, 246 | self.alignJustifyButton, 247 | ] 248 | } 249 | 250 | } 251 | 252 | private extension RichEditorToolbar { 253 | 254 | /** 255 | Grabs the selected font title and the selected font size, creates an instance of NSFont from them 256 | and applies it to the RichEditor 257 | */ 258 | func applyFont() { 259 | guard let selectedFontNameMenuItem = self.fontFamiliesPopUpButton.selectedItem else { 260 | return 261 | } 262 | 263 | guard let selectedFontSizeMenuItem = self.fontSizePopUpButton.selectedItem else { 264 | return 265 | } 266 | 267 | let selectedFontTitle = selectedFontNameMenuItem.title 268 | let selectedFontSize = CGFloat((selectedFontSizeMenuItem.title as NSString).doubleValue) 269 | 270 | guard let font = NSFont(name: selectedFontTitle, size: selectedFontSize) else { 271 | return 272 | } 273 | 274 | self.richEditor.apply(font: font) 275 | } 276 | 277 | func configureUI(with textStyling: TextStyling) { 278 | self.boldButton.selected = textStyling.boldTrait != .isNotTrait 279 | self.italicButton.selected = textStyling.italicsTrait != .isNotTrait 280 | self.underlineButton.selected = textStyling.underlineTrait != .isNotTrait 281 | self.strikethroughButton.selected = textStyling.strikethroughTrait != .isNotTrait 282 | 283 | // Configure the TextColour UI 284 | let textColours = textStyling.textColours 285 | switch (textColours.count) { 286 | case 0: 287 | self.textColorButton.selectedColor = NSColor.white 288 | case 1: 289 | self.textColorButton.selectedColor = textColours[0] 290 | case 2: 291 | self.textColorButton.selectedColor = NSColor.gray 292 | default:() 293 | } 294 | 295 | // Configure the HighlightColour UI 296 | let highlightColours = textStyling.highlightColours 297 | switch (highlightColours.count) { 298 | case 0: 299 | self.highlightColorButton.selectedColor = NSColor.white 300 | case 1: 301 | self.highlightColorButton.selectedColor = highlightColours[0] 302 | case 2: 303 | self.highlightColorButton.selectedColor = NSColor.gray 304 | default:() 305 | } 306 | 307 | // Configure the Fonts UI 308 | let fonts = textStyling.fonts 309 | switch (fonts.count) { 310 | case 0: 311 | fatalError("Fonts count is somehow 0: \(fonts)") 312 | 313 | case 1: 314 | self.fontFamiliesPopUpButton.title = fonts[0].displayName ?? fonts[0].fontName 315 | self.fontSizePopUpButton.title = "\(fonts[0].pointSize.cleanValue)" 316 | 317 | case 2: 318 | self.fontFamiliesPopUpButton.title = fonts[0].displayName ?? fonts[0].fontName 319 | self.fontSizePopUpButton.title = "\(fonts[0].pointSize.cleanValue)" 320 | 321 | default:() 322 | } 323 | 324 | // Configure the Alignments UI 325 | let alignments = textStyling.alignments 326 | 327 | self.alignmentButtons.forEach { $0.selected = false } 328 | 329 | for alignment in alignments { 330 | switch alignment { 331 | case .left: 332 | self.alignLeftButton.selected = true 333 | case .center: 334 | self.alignCentreButton.selected = true 335 | case .right: 336 | self.alignRightButton.selected = true 337 | case .justified: 338 | self.alignJustifyButton.selected = true 339 | default:() 340 | } 341 | } 342 | } 343 | 344 | } 345 | 346 | // MARK: - NSStackView 347 | 348 | private extension NSStackView { 349 | 350 | func addSeperatorView() { 351 | let seperator = NSView() 352 | 353 | seperator.wantsLayer = true 354 | seperator.layer?.backgroundColor = NSColor.lightGray.cgColor 355 | 356 | self.addArrangedSubview(seperator) 357 | 358 | seperator.translatesAutoresizingMaskIntoConstraints = false 359 | NSLayoutConstraint.activate([ 360 | seperator.widthAnchor.constraint(equalToConstant: 1) 361 | ]) 362 | } 363 | 364 | } 365 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 60; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | BE96A1F42B5A216500917EE3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A1F32B5A216500917EE3 /* AppDelegate.swift */; }; 11 | BE96A1F62B5A216500917EE3 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A1F52B5A216500917EE3 /* ViewController.swift */; }; 12 | BE96A1F82B5A216600917EE3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BE96A1F72B5A216600917EE3 /* Assets.xcassets */; }; 13 | BE96A1FB2B5A216700917EE3 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BE96A1F92B5A216700917EE3 /* Main.storyboard */; }; 14 | BE96A2042B5A21F200917EE3 /* RichEditor in Frameworks */ = {isa = PBXBuildFile; productRef = BE96A2032B5A21F200917EE3 /* RichEditor */; }; 15 | BE96A2092B5A221700917EE3 /* PreviewWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A2052B5A221700917EE3 /* PreviewWebViewController.swift */; }; 16 | BE96A20A2B5A221700917EE3 /* PreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A2062B5A221700917EE3 /* PreviewViewController.swift */; }; 17 | BE96A20B2B5A221700917EE3 /* NSColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A2072B5A221700917EE3 /* NSColor+Hex.swift */; }; 18 | BE96A20C2B5A221700917EE3 /* PreviewTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE96A2082B5A221700917EE3 /* PreviewTextViewController.swift */; }; 19 | /* End PBXBuildFile section */ 20 | 21 | /* Begin PBXFileReference section */ 22 | BE96A1F02B5A216500917EE3 /* RichEditorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RichEditorExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 23 | BE96A1F32B5A216500917EE3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 24 | BE96A1F52B5A216500917EE3 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 25 | BE96A1F72B5A216600917EE3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 26 | BE96A1FA2B5A216700917EE3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 27 | BE96A1FC2B5A216700917EE3 /* RichEditorExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RichEditorExample.entitlements; sourceTree = ""; }; 28 | BE96A2052B5A221700917EE3 /* PreviewWebViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewWebViewController.swift; sourceTree = ""; }; 29 | BE96A2062B5A221700917EE3 /* PreviewViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewViewController.swift; sourceTree = ""; }; 30 | BE96A2072B5A221700917EE3 /* NSColor+Hex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSColor+Hex.swift"; sourceTree = ""; }; 31 | BE96A2082B5A221700917EE3 /* PreviewTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewTextViewController.swift; sourceTree = ""; }; 32 | /* End PBXFileReference section */ 33 | 34 | /* Begin PBXFrameworksBuildPhase section */ 35 | BE96A1ED2B5A216500917EE3 /* Frameworks */ = { 36 | isa = PBXFrameworksBuildPhase; 37 | buildActionMask = 2147483647; 38 | files = ( 39 | BE96A2042B5A21F200917EE3 /* RichEditor in Frameworks */, 40 | ); 41 | runOnlyForDeploymentPostprocessing = 0; 42 | }; 43 | /* End PBXFrameworksBuildPhase section */ 44 | 45 | /* Begin PBXGroup section */ 46 | BE96A1E72B5A216500917EE3 = { 47 | isa = PBXGroup; 48 | children = ( 49 | BE96A1F22B5A216500917EE3 /* RichEditorExample */, 50 | BE96A1F12B5A216500917EE3 /* Products */, 51 | ); 52 | sourceTree = ""; 53 | }; 54 | BE96A1F12B5A216500917EE3 /* Products */ = { 55 | isa = PBXGroup; 56 | children = ( 57 | BE96A1F02B5A216500917EE3 /* RichEditorExample.app */, 58 | ); 59 | name = Products; 60 | sourceTree = ""; 61 | }; 62 | BE96A1F22B5A216500917EE3 /* RichEditorExample */ = { 63 | isa = PBXGroup; 64 | children = ( 65 | BE96A1F32B5A216500917EE3 /* AppDelegate.swift */, 66 | BE96A2072B5A221700917EE3 /* NSColor+Hex.swift */, 67 | BE96A1F72B5A216600917EE3 /* Assets.xcassets */, 68 | BE96A20D2B5A222500917EE3 /* ViewControllers */, 69 | BE96A1F92B5A216700917EE3 /* Main.storyboard */, 70 | BE96A1FC2B5A216700917EE3 /* RichEditorExample.entitlements */, 71 | ); 72 | path = RichEditorExample; 73 | sourceTree = ""; 74 | }; 75 | BE96A20D2B5A222500917EE3 /* ViewControllers */ = { 76 | isa = PBXGroup; 77 | children = ( 78 | BE96A1F52B5A216500917EE3 /* ViewController.swift */, 79 | BE96A2082B5A221700917EE3 /* PreviewTextViewController.swift */, 80 | BE96A2062B5A221700917EE3 /* PreviewViewController.swift */, 81 | BE96A2052B5A221700917EE3 /* PreviewWebViewController.swift */, 82 | ); 83 | path = ViewControllers; 84 | sourceTree = ""; 85 | }; 86 | /* End PBXGroup section */ 87 | 88 | /* Begin PBXNativeTarget section */ 89 | BE96A1EF2B5A216500917EE3 /* RichEditorExample */ = { 90 | isa = PBXNativeTarget; 91 | buildConfigurationList = BE96A1FF2B5A216700917EE3 /* Build configuration list for PBXNativeTarget "RichEditorExample" */; 92 | buildPhases = ( 93 | BE96A1EC2B5A216500917EE3 /* Sources */, 94 | BE96A1ED2B5A216500917EE3 /* Frameworks */, 95 | BE96A1EE2B5A216500917EE3 /* Resources */, 96 | ); 97 | buildRules = ( 98 | ); 99 | dependencies = ( 100 | ); 101 | name = RichEditorExample; 102 | packageProductDependencies = ( 103 | BE96A2032B5A21F200917EE3 /* RichEditor */, 104 | ); 105 | productName = RichEditorExample; 106 | productReference = BE96A1F02B5A216500917EE3 /* RichEditorExample.app */; 107 | productType = "com.apple.product-type.application"; 108 | }; 109 | /* End PBXNativeTarget section */ 110 | 111 | /* Begin PBXProject section */ 112 | BE96A1E82B5A216500917EE3 /* Project object */ = { 113 | isa = PBXProject; 114 | attributes = { 115 | BuildIndependentTargetsInParallel = 1; 116 | LastSwiftUpdateCheck = 1520; 117 | LastUpgradeCheck = 1520; 118 | TargetAttributes = { 119 | BE96A1EF2B5A216500917EE3 = { 120 | CreatedOnToolsVersion = 15.2; 121 | }; 122 | }; 123 | }; 124 | buildConfigurationList = BE96A1EB2B5A216500917EE3 /* Build configuration list for PBXProject "RichEditorExample" */; 125 | compatibilityVersion = "Xcode 14.0"; 126 | developmentRegion = en; 127 | hasScannedForEncodings = 0; 128 | knownRegions = ( 129 | en, 130 | Base, 131 | ); 132 | mainGroup = BE96A1E72B5A216500917EE3; 133 | packageReferences = ( 134 | BE96A2022B5A21F200917EE3 /* XCLocalSwiftPackageReference ".." */, 135 | ); 136 | productRefGroup = BE96A1F12B5A216500917EE3 /* Products */; 137 | projectDirPath = ""; 138 | projectRoot = ""; 139 | targets = ( 140 | BE96A1EF2B5A216500917EE3 /* RichEditorExample */, 141 | ); 142 | }; 143 | /* End PBXProject section */ 144 | 145 | /* Begin PBXResourcesBuildPhase section */ 146 | BE96A1EE2B5A216500917EE3 /* Resources */ = { 147 | isa = PBXResourcesBuildPhase; 148 | buildActionMask = 2147483647; 149 | files = ( 150 | BE96A1F82B5A216600917EE3 /* Assets.xcassets in Resources */, 151 | BE96A1FB2B5A216700917EE3 /* Main.storyboard in Resources */, 152 | ); 153 | runOnlyForDeploymentPostprocessing = 0; 154 | }; 155 | /* End PBXResourcesBuildPhase section */ 156 | 157 | /* Begin PBXSourcesBuildPhase section */ 158 | BE96A1EC2B5A216500917EE3 /* Sources */ = { 159 | isa = PBXSourcesBuildPhase; 160 | buildActionMask = 2147483647; 161 | files = ( 162 | BE96A20C2B5A221700917EE3 /* PreviewTextViewController.swift in Sources */, 163 | BE96A1F62B5A216500917EE3 /* ViewController.swift in Sources */, 164 | BE96A20B2B5A221700917EE3 /* NSColor+Hex.swift in Sources */, 165 | BE96A1F42B5A216500917EE3 /* AppDelegate.swift in Sources */, 166 | BE96A2092B5A221700917EE3 /* PreviewWebViewController.swift in Sources */, 167 | BE96A20A2B5A221700917EE3 /* PreviewViewController.swift in Sources */, 168 | ); 169 | runOnlyForDeploymentPostprocessing = 0; 170 | }; 171 | /* End PBXSourcesBuildPhase section */ 172 | 173 | /* Begin PBXVariantGroup section */ 174 | BE96A1F92B5A216700917EE3 /* Main.storyboard */ = { 175 | isa = PBXVariantGroup; 176 | children = ( 177 | BE96A1FA2B5A216700917EE3 /* Base */, 178 | ); 179 | name = Main.storyboard; 180 | sourceTree = ""; 181 | }; 182 | /* End PBXVariantGroup section */ 183 | 184 | /* Begin XCBuildConfiguration section */ 185 | BE96A1FD2B5A216700917EE3 /* Debug */ = { 186 | isa = XCBuildConfiguration; 187 | buildSettings = { 188 | ALWAYS_SEARCH_USER_PATHS = NO; 189 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 190 | CLANG_ANALYZER_NONNULL = YES; 191 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 192 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 193 | CLANG_ENABLE_MODULES = YES; 194 | CLANG_ENABLE_OBJC_ARC = YES; 195 | CLANG_ENABLE_OBJC_WEAK = YES; 196 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 197 | CLANG_WARN_BOOL_CONVERSION = YES; 198 | CLANG_WARN_COMMA = YES; 199 | CLANG_WARN_CONSTANT_CONVERSION = YES; 200 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 201 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 202 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 203 | CLANG_WARN_EMPTY_BODY = YES; 204 | CLANG_WARN_ENUM_CONVERSION = YES; 205 | CLANG_WARN_INFINITE_RECURSION = YES; 206 | CLANG_WARN_INT_CONVERSION = YES; 207 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 208 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 209 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 210 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 211 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 212 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 213 | CLANG_WARN_STRICT_PROTOTYPES = YES; 214 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 215 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 216 | CLANG_WARN_UNREACHABLE_CODE = YES; 217 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 218 | COPY_PHASE_STRIP = NO; 219 | DEBUG_INFORMATION_FORMAT = dwarf; 220 | ENABLE_STRICT_OBJC_MSGSEND = YES; 221 | ENABLE_TESTABILITY = YES; 222 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 223 | GCC_C_LANGUAGE_STANDARD = gnu17; 224 | GCC_DYNAMIC_NO_PIC = NO; 225 | GCC_NO_COMMON_BLOCKS = YES; 226 | GCC_OPTIMIZATION_LEVEL = 0; 227 | GCC_PREPROCESSOR_DEFINITIONS = ( 228 | "DEBUG=1", 229 | "$(inherited)", 230 | ); 231 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 232 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 233 | GCC_WARN_UNDECLARED_SELECTOR = YES; 234 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 235 | GCC_WARN_UNUSED_FUNCTION = YES; 236 | GCC_WARN_UNUSED_VARIABLE = YES; 237 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 238 | MACOSX_DEPLOYMENT_TARGET = 14.2; 239 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 240 | MTL_FAST_MATH = YES; 241 | ONLY_ACTIVE_ARCH = YES; 242 | SDKROOT = macosx; 243 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 244 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 245 | }; 246 | name = Debug; 247 | }; 248 | BE96A1FE2B5A216700917EE3 /* Release */ = { 249 | isa = XCBuildConfiguration; 250 | buildSettings = { 251 | ALWAYS_SEARCH_USER_PATHS = NO; 252 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 253 | CLANG_ANALYZER_NONNULL = YES; 254 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 255 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 256 | CLANG_ENABLE_MODULES = YES; 257 | CLANG_ENABLE_OBJC_ARC = YES; 258 | CLANG_ENABLE_OBJC_WEAK = YES; 259 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 260 | CLANG_WARN_BOOL_CONVERSION = YES; 261 | CLANG_WARN_COMMA = YES; 262 | CLANG_WARN_CONSTANT_CONVERSION = YES; 263 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 264 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 265 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 266 | CLANG_WARN_EMPTY_BODY = YES; 267 | CLANG_WARN_ENUM_CONVERSION = YES; 268 | CLANG_WARN_INFINITE_RECURSION = YES; 269 | CLANG_WARN_INT_CONVERSION = YES; 270 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 271 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 272 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 273 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 274 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 275 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 276 | CLANG_WARN_STRICT_PROTOTYPES = YES; 277 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 278 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 279 | CLANG_WARN_UNREACHABLE_CODE = YES; 280 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 281 | COPY_PHASE_STRIP = NO; 282 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 283 | ENABLE_NS_ASSERTIONS = NO; 284 | ENABLE_STRICT_OBJC_MSGSEND = YES; 285 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 286 | GCC_C_LANGUAGE_STANDARD = gnu17; 287 | GCC_NO_COMMON_BLOCKS = YES; 288 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 289 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 290 | GCC_WARN_UNDECLARED_SELECTOR = YES; 291 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 292 | GCC_WARN_UNUSED_FUNCTION = YES; 293 | GCC_WARN_UNUSED_VARIABLE = YES; 294 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 295 | MACOSX_DEPLOYMENT_TARGET = 14.2; 296 | MTL_ENABLE_DEBUG_INFO = NO; 297 | MTL_FAST_MATH = YES; 298 | SDKROOT = macosx; 299 | SWIFT_COMPILATION_MODE = wholemodule; 300 | }; 301 | name = Release; 302 | }; 303 | BE96A2002B5A216700917EE3 /* Debug */ = { 304 | isa = XCBuildConfiguration; 305 | buildSettings = { 306 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 307 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 308 | CODE_SIGN_ENTITLEMENTS = RichEditorExample/RichEditorExample.entitlements; 309 | CODE_SIGN_STYLE = Automatic; 310 | COMBINE_HIDPI_IMAGES = YES; 311 | CURRENT_PROJECT_VERSION = 1; 312 | DEVELOPMENT_TEAM = 4ELTP9RFTJ; 313 | ENABLE_HARDENED_RUNTIME = YES; 314 | GENERATE_INFOPLIST_FILE = YES; 315 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 316 | INFOPLIST_KEY_NSMainStoryboardFile = Main; 317 | INFOPLIST_KEY_NSPrincipalClass = NSApplication; 318 | LD_RUNPATH_SEARCH_PATHS = ( 319 | "$(inherited)", 320 | "@executable_path/../Frameworks", 321 | ); 322 | MARKETING_VERSION = 1.0; 323 | PRODUCT_BUNDLE_IDENTIFIER = com.williamlumley.RichEditorExample; 324 | PRODUCT_NAME = "$(TARGET_NAME)"; 325 | SWIFT_EMIT_LOC_STRINGS = YES; 326 | SWIFT_VERSION = 5.0; 327 | }; 328 | name = Debug; 329 | }; 330 | BE96A2012B5A216700917EE3 /* Release */ = { 331 | isa = XCBuildConfiguration; 332 | buildSettings = { 333 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 334 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 335 | CODE_SIGN_ENTITLEMENTS = RichEditorExample/RichEditorExample.entitlements; 336 | CODE_SIGN_STYLE = Automatic; 337 | COMBINE_HIDPI_IMAGES = YES; 338 | CURRENT_PROJECT_VERSION = 1; 339 | DEVELOPMENT_TEAM = 4ELTP9RFTJ; 340 | ENABLE_HARDENED_RUNTIME = YES; 341 | GENERATE_INFOPLIST_FILE = YES; 342 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 343 | INFOPLIST_KEY_NSMainStoryboardFile = Main; 344 | INFOPLIST_KEY_NSPrincipalClass = NSApplication; 345 | LD_RUNPATH_SEARCH_PATHS = ( 346 | "$(inherited)", 347 | "@executable_path/../Frameworks", 348 | ); 349 | MARKETING_VERSION = 1.0; 350 | PRODUCT_BUNDLE_IDENTIFIER = com.williamlumley.RichEditorExample; 351 | PRODUCT_NAME = "$(TARGET_NAME)"; 352 | SWIFT_EMIT_LOC_STRINGS = YES; 353 | SWIFT_VERSION = 5.0; 354 | }; 355 | name = Release; 356 | }; 357 | /* End XCBuildConfiguration section */ 358 | 359 | /* Begin XCConfigurationList section */ 360 | BE96A1EB2B5A216500917EE3 /* Build configuration list for PBXProject "RichEditorExample" */ = { 361 | isa = XCConfigurationList; 362 | buildConfigurations = ( 363 | BE96A1FD2B5A216700917EE3 /* Debug */, 364 | BE96A1FE2B5A216700917EE3 /* Release */, 365 | ); 366 | defaultConfigurationIsVisible = 0; 367 | defaultConfigurationName = Release; 368 | }; 369 | BE96A1FF2B5A216700917EE3 /* Build configuration list for PBXNativeTarget "RichEditorExample" */ = { 370 | isa = XCConfigurationList; 371 | buildConfigurations = ( 372 | BE96A2002B5A216700917EE3 /* Debug */, 373 | BE96A2012B5A216700917EE3 /* Release */, 374 | ); 375 | defaultConfigurationIsVisible = 0; 376 | defaultConfigurationName = Release; 377 | }; 378 | /* End XCConfigurationList section */ 379 | 380 | /* Begin XCLocalSwiftPackageReference section */ 381 | BE96A2022B5A21F200917EE3 /* XCLocalSwiftPackageReference ".." */ = { 382 | isa = XCLocalSwiftPackageReference; 383 | relativePath = ..; 384 | }; 385 | /* End XCLocalSwiftPackageReference section */ 386 | 387 | /* Begin XCSwiftPackageProductDependency section */ 388 | BE96A2032B5A21F200917EE3 /* RichEditor */ = { 389 | isa = XCSwiftPackageProductDependency; 390 | productName = RichEditor; 391 | }; 392 | /* End XCSwiftPackageProductDependency section */ 393 | }; 394 | rootObject = BE96A1E82B5A216500917EE3 /* Project object */; 395 | } 396 | -------------------------------------------------------------------------------- /RichEditorExample/RichEditorExample/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 513 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | --------------------------------------------------------------------------------