From 5dd1b0753176e88be17143fd28ae98a62bb32128 Mon Sep 17 00:00:00 2001
From: Divyesh Canopas <83937721+cp-divyesh-v@users.noreply.github.com>
Date: Wed, 1 Jan 2025 14:33:30 +0530
Subject: [PATCH 1/7] Updated read me with what's coming next (#76)
* updated read me and index
* updated list format
* updated upcoming feature list
* updated sample screen shots path
---
README.md | 14 ++++++-
docs/index.md | 113 +++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 116 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index 710b04f..d38ac09 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,19 @@ The editor offers the following options:
- [x] Font family
- [x] Background color
- [x] Export with .txt, .rtf, .pdf, .json
-- [x] Link
+- [ ] Link
+- [ ] Image Attachment
+- [ ] Undo/Redo
+
+## What’s Coming Next for RichEditorSwiftUI?🚀
+
+We’re thrilled about the future of **RichEditorSwiftUI!** 🎉 Check out the exciting features currently in development:
+
+- **Link Support:** Easily add hyperlinks to your rich text content.
+- **Image Drop:** Drag and drop images directly into your editor for seamless integration.
+- **Undo & Redo:** Effortlessly step forward or backward in your edits for greater control.
+
+Thank you for your support and feedback—it fuels our journey. Stay tuned for these enhancements and more! 🙌
## Screenshots
diff --git a/docs/index.md b/docs/index.md
index c04d869..6d7355d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,8 +1,6 @@
# RichEditorSwiftUI
-iOS WYSIWYG Rich editor for SwiftUI.
-
-
+![RichEditorSwiftUI (1)](https://github.com/canopas/rich-editor-swiftui/assets/73588408/8c3013ae-8a27-4ebc-a511-51e726825c4b)
## Features
@@ -12,6 +10,65 @@ The editor offers the following options:
- [x] *Italic*
- [x] Underline
- [x] Different Heading
+- [x] Text Alignment
+- [x] Font size
+- [x] Font color
+- [x] Font family
+- [x] Background color
+- [x] Export with .txt, .rtf, .pdf, .json
+
+## Screenshots
+
+
+
+ Editor light |
+ Editor dark |
+
+
+ |
+ |
+
+
+
+
+
+ Toolbar dark |
+ Toolbar light |
+
+
+ |
+ |
+
+
+
+
+
+ mac Editor light |
+
+
+ |
+
+
+
+
+
+ mac Editor dark |
+
+
+ |
+
+
+
+## mac Editor video
+
+
+
+
+
+## iPhone Editor video
+
+
+
## Installation
@@ -23,7 +80,7 @@ Once you have your Swift package set up, adding RichEditorSwiftUI as a dependenc
```swift
dependencies: [
- .package(url: "https://github.com/canopas/rich-editor-swiftui.git", .upToNextMajor(from: "1.0.0"))
+ .package(url: "https://github.com/canopas/rich-editor-swiftui.git", .upToNextMajor(from: "1.1.0"))
]
```
@@ -32,7 +89,7 @@ dependencies: [
[CocoaPods][] is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate RichEditorSwiftUI into your Xcode project using CocoaPods, specify it in your Podfile:
target 'YourAppName' do
- pod 'RichEditorSwiftUI', '~> 1.0.0'
+ pod 'RichEditorSwiftUI', '~> 1.1.0'
end
[CocoaPods]: https://cocoapods.org
@@ -42,21 +99,57 @@ dependencies: [
Add the dependency
```
- import XYZRichEditor
+ import RichEditorSwiftUI
```
-## How to use ?
+## How to use?
```
struct EditorView: View {
@ObservedObject var state: RichEditorState = .init(input: "Hello World")
-
+
var body: some View {
- RichEditor(state: _state)
- .padding(10)
+ VStack {
+ #if os(macOS)
+ RichTextFormat.Toolbar(context: state)
+ #endif
+
+ RichTextEditor(
+ context: _state,
+ viewConfiguration: { _ in
+
+ }
+ )
+ .cornerRadius(10)
+
+ #if os(iOS)
+ RichTextKeyboardToolbar(
+ context: state,
+ leadingButtons: { $0 },
+ trailingButtons: { $0 },
+ formatSheet: { $0 }
+ )
+ #endif
+ }
+ .inspector(isPresented: $isInspectorPresented) {
+ RichTextFormat.Sidebar(context: state)
+ #if os(macOS)
+ .inspectorColumnWidth(min: 200, ideal: 200, max: 320)
+ #endif
+ }
}
}
```
+
+## Tech stack
+
+RichEditorSwiftUI utilizes the latest Apple technologies and adheres to industry best practices. Below is the current tech stack used in the development process:
+
+- MVVM Architecture
+- SwiftUI
+- Swift
+- Xcode
+
# Demo
[Sample](https://github.com/canopas/rich-editor-swiftui/tree/main/RichEditorDemo) app demonstrates how simple the usage of the library actually is.
From b782f3ccec6e71ec26c229e0e22081d68cb65065 Mon Sep 17 00:00:00 2001
From: Divyesh Canopas
Date: Mon, 23 Dec 2024 14:15:37 +0530
Subject: [PATCH 2/7] add image drop support
---
.../RichTextViewComponent+Images.swift | 75 +++++++
.../RichTextViewComponent+Pasting.swift | 105 +++++++++-
.../Components/RichTextViewComponent.swift | 31 +--
.../Images/ImageRepresentable.swift | 35 ++++
.../Images/RichTextImageAttachment.swift | 193 ++++++++++++++++++
.../RichTextImageAttachmentManager.swift | 85 ++++++++
.../Images/RichTextImageAttachmentSize.swift | 39 ++++
.../Images/RichTextImageConfiguration.swift | 64 ++++++
.../RichTextImageInsertConfiguration.swift | 28 +++
.../Extensions/NSTextAttachment+Image.swift | 32 +++
.../UI/TextViewUI/RichTextView_AppKit.swift | 58 +++---
.../UI/TextViewUI/RichTextView_UIKit.swift | 116 +++++------
12 files changed, 756 insertions(+), 105 deletions(-)
create mode 100644 Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Images.swift
create mode 100644 Sources/RichEditorSwiftUI/Images/ImageRepresentable.swift
create mode 100644 Sources/RichEditorSwiftUI/Images/RichTextImageAttachment.swift
create mode 100644 Sources/RichEditorSwiftUI/Images/RichTextImageAttachmentManager.swift
create mode 100644 Sources/RichEditorSwiftUI/Images/RichTextImageAttachmentSize.swift
create mode 100644 Sources/RichEditorSwiftUI/Images/RichTextImageConfiguration.swift
create mode 100644 Sources/RichEditorSwiftUI/Images/RichTextImageInsertConfiguration.swift
create mode 100644 Sources/RichEditorSwiftUI/UI/Extensions/NSTextAttachment+Image.swift
diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Images.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Images.swift
new file mode 100644
index 0000000..9e83467
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Images.swift
@@ -0,0 +1,75 @@
+//
+// RichTextViewComponent+Images.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 23/12/24.
+//
+
+import Foundation
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+import AppKit
+#endif
+
+public extension RichTextViewComponent {
+
+ /// Get the max image attachment size.
+ var imageAttachmentMaxSize: CGSize {
+ let maxSize = imageConfiguration.maxImageSize
+ let insetX = 2 * textContentInset.width
+ let insetY = 2 * textContentInset.height
+ let paddedFrame = frame.insetBy(dx: insetX, dy: insetY)
+ let width = maxSize.width.width(in: paddedFrame)
+ let height = maxSize.height.height(in: paddedFrame)
+ return CGSize(width: width, height: height)
+ }
+
+ /// Get the attachment bounds for a certain image.
+ func attachmentBounds(
+ for image: ImageRepresentable
+ ) -> CGRect {
+ attributedString.attachmentBounds(
+ for: image,
+ maxSize: imageAttachmentMaxSize
+ )
+ }
+
+ /// Get the attachment size for a certain image.
+ func attachmentSize(
+ for image: ImageRepresentable
+ ) -> CGSize {
+ attributedString.attachmentSize(
+ for: image,
+ maxSize: imageAttachmentMaxSize
+ )
+ }
+
+ /// Get the current image drop configuration.
+ var imageDropConfiguration: RichTextImageInsertConfiguration {
+ imageConfiguration.dropConfiguration
+ }
+
+ /// Get the current image paste configuration.
+ var imagePasteConfiguration: RichTextImageInsertConfiguration {
+ imageConfiguration.pasteConfiguration
+ }
+
+ /// Validate that image drop will be performed.
+ func validateImageInsertion(
+ for config: RichTextImageInsertConfiguration
+ ) -> Bool {
+ switch config {
+ case .disabled:
+ return false
+ case .disabledWithWarning(let title, let message):
+ alert(title: title, message: message)
+ return false
+ case .enabled:
+ return true
+ }
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift
index d109009..dd4b00e 100644
--- a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift
+++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift
@@ -8,14 +8,81 @@
import Foundation
#if canImport(UIKit)
- import UIKit
+import UIKit
#endif
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
- import AppKit
+import AppKit
#endif
-extension RichTextViewComponent {
+public extension RichTextViewComponent {
+
+ /**
+ Paste an image into the rich text, at a certain index.
+
+ Pasting images only works on iOS, tvOS and macOS. Other
+ platform will trigger an assertion failure.
+
+ > Todo: This automatically inserts images as compressed
+ jpeg. We should make it more configurable.
+
+ - Parameters:
+ - image: The image to paste.
+ - index: The index to paste at.
+ - moveCursorToPastedContent: Whether or not the input
+ cursor should be moved to the end of the pasted content,
+ by default `false`.
+ */
+ func pasteImage(
+ _ image: ImageRepresentable,
+ at index: Int,
+ moveCursorToPastedContent: Bool = true
+ ) {
+ pasteImages(
+ [image],
+ at: index,
+ moveCursorToPastedContent: moveCursorToPastedContent
+ )
+ }
+
+ /**
+ Paste images into the text view, at a certain index.
+
+ This will automatically insert an image as a compressed
+ jpeg. We should make it more configurable.
+
+ > Todo: This automatically inserts images as compressed
+ jpeg. We should make it more configurable.
+
+ - Parameters:
+ - images: The images to paste.
+ - index: The index to paste at.
+ - moveCursorToPastedContent: Whether or not the input
+ cursor should be moved to the end of the pasted content,
+ by default `false`.
+ */
+ func pasteImages(
+ _ images: [ImageRepresentable],
+ at index: Int,
+ moveCursorToPastedContent move: Bool = false
+ ) {
+#if os(watchOS)
+ assertionFailure("Image pasting is not supported on this platform")
+#else
+ guard validateImageInsertion(for: imagePasteConfiguration) else { return }
+ let items = images.count * 2 // The number of inserted "items" is the images and a newline for each
+ let insertRange = NSRange(location: index, length: 0)
+ let safeInsertRange = safeRange(for: insertRange)
+ let isSelectedRange = (index == selectedRange.location)
+ if isSelectedRange { deleteCharacters(in: selectedRange) }
+ if move { moveInputCursor(to: index) }
+ images.reversed().forEach { performPasteImage($0, at: index) }
+ if move { moveInputCursor(to: safeInsertRange.location + items) }
+ if move || isSelectedRange {
+ self.moveInputCursor(to: self.selectedRange.location)
+ }
+#endif
+ }
/**
Paste text into the text view, at a certain index.
@@ -27,7 +94,7 @@ extension RichTextViewComponent {
cursor should be moved to the end of the pasted content,
by default `false`.
*/
- public func pasteText(
+ func pasteText(
_ text: String,
at index: Int,
moveCursorToPastedContent: Bool = false
@@ -52,3 +119,33 @@ extension RichTextViewComponent {
}
}
}
+
+#if iOS || macOS || os(tvOS) || os(visionOS)
+private extension RichTextViewComponent {
+
+ func getAttachmentString(
+ for image: ImageRepresentable
+ ) -> NSMutableAttributedString? {
+ guard let data = image.jpegData(compressionQuality: 0.7) else { return nil }
+ guard let compressed = ImageRepresentable(data: data) else { return nil }
+ let attachment = RichTextImageAttachment(jpegData: data)
+ attachment.bounds = attachmentBounds(for: compressed)
+ return NSMutableAttributedString(attachment: attachment)
+ }
+
+ func performPasteImage(
+ _ image: ImageRepresentable,
+ at index: Int
+ ) {
+ let newLine = NSAttributedString(string: "\n", attributes: richTextAttributes)
+ let content = NSMutableAttributedString(attributedString: richText)
+ guard let insertString = getAttachmentString(for: image) else { return }
+
+ insertString.insert(newLine, at: insertString.length)
+ insertString.addAttributes(richTextAttributes, range: insertString.richTextRange)
+ content.insert(insertString, at: index)
+
+ setRichText(content)
+ }
+}
+#endif
diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift
index 46aebec..17df4fb 100644
--- a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift
+++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift
@@ -38,6 +38,9 @@ public protocol RichTextViewComponent: AnyObject,
/// The style to use when highlighting text in the view.
var highlightingStyle: RichTextHighlightingStyle { get set }
+ /// The image configuration used by the rich text view.
+ var imageConfiguration: RichTextImageConfiguration { get set }
+
/// Whether or not the text view is the first responder.
var isFirstResponder: Bool { get }
@@ -124,20 +127,20 @@ extension RichTextViewComponent {
}
/// Get the image configuration for a certain format.
- // func standardImageConfiguration(
- // for format: RichTextDataFormat
- // ) -> RichTextImageConfiguration {
- // let insertConfig = standardImageInsertConfiguration(for: format)
- // return RichTextImageConfiguration(
- // pasteConfiguration: insertConfig,
- // dropConfiguration: insertConfig,
- // maxImageSize: (width: .frame, height: .frame))
- // }
+ func standardImageConfiguration(
+ for format: RichTextDataFormat
+ ) -> RichTextImageConfiguration {
+ let insertConfig = standardImageInsertConfiguration(for: format)
+ return RichTextImageConfiguration(
+ pasteConfiguration: insertConfig,
+ dropConfiguration: insertConfig,
+ maxImageSize: (width: .frame, height: .frame))
+ }
/// Get the image insert config for a certain format.
- // func standardImageInsertConfiguration(
- // for format: RichTextDataFormat
- // ) -> RichTextImageInsertConfiguration {
- // format.supportsImages ? .enabled : .disabled
- // }
+ func standardImageInsertConfiguration(
+ for format: RichTextDataFormat
+ ) -> RichTextImageInsertConfiguration {
+ format.supportsImages ? .enabled : .disabled
+ }
}
diff --git a/Sources/RichEditorSwiftUI/Images/ImageRepresentable.swift b/Sources/RichEditorSwiftUI/Images/ImageRepresentable.swift
new file mode 100644
index 0000000..6be22b2
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Images/ImageRepresentable.swift
@@ -0,0 +1,35 @@
+//
+// ImageRepresentable.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 23/12/24.
+//
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+import AppKit
+
+/// This typealias bridges platform-specific image types.
+public typealias ImageRepresentable = NSImage
+
+public extension ImageRepresentable {
+
+ /// Try to get a CoreGraphic image from the AppKit image.
+ var cgImage: CGImage? {
+ cgImage(forProposedRect: nil, context: nil, hints: nil)
+ }
+
+ /// Try to get JPEG compressed data for the AppKit image.
+ func jpegData(compressionQuality: CGFloat) -> Data? {
+ guard let image = cgImage else { return nil }
+ let bitmap = NSBitmapImageRep(cgImage: image)
+ return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
+ }
+}
+#endif
+
+#if canImport(UIKit)
+import UIKit
+
+/// This typealias bridges platform-specific image types.
+public typealias ImageRepresentable = UIImage
+#endif
diff --git a/Sources/RichEditorSwiftUI/Images/RichTextImageAttachment.swift b/Sources/RichEditorSwiftUI/Images/RichTextImageAttachment.swift
new file mode 100644
index 0000000..0790e67
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Images/RichTextImageAttachment.swift
@@ -0,0 +1,193 @@
+//
+// RichTextImageAttachment.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 23/12/24.
+//
+
+#if iOS || os(tvOS) || os(visionOS)
+import UIKit
+#endif
+
+#if macOS
+import AppKit
+#endif
+
+#if iOS || macOS || os(tvOS) || os(visionOS)
+import UniformTypeIdentifiers
+
+/**
+ This custom attachment type inherits `NSTextAttachment` and
+ aims to solve multi-platform image attachment problems.
+
+ When using `NSTextAttachment` directly, any images added by
+ iOS can't be loaded on macOS, and vice versa. To solve this,
+ this custom attachment class uses the `contents` data, then
+ overrides `image` on iOS and `attachmentCell` on macOS.
+
+ This is probably the wrong way to solve this problem, but I
+ haven't been able to find another way. If we set `image` on
+ a plain `NSTextAttachment`, it can add and load attachments
+ on the same platform, but fails on other platforms.
+
+ Another problem with `NSTextAttachment`, is that it results
+ in large files, since it by default doesn't specify uniform
+ type identifier or compression, which makes it handle image
+ attachments as huge png data. This attachment allows you to
+ easily use jpg with a custom compression rate instead.
+
+ # WARNING
+
+ If we use ``RichTextDataFormat/archivedData`` to persist an
+ image attachment, we'll use `NSKeyedArchiver` to archive it
+ and `NSKeyedUnarchiver` to unarchive it. This requires that
+ the types within the archive data still exist when the data
+ is unarchived. If any type is missing, you must register an
+ unarchiver class replacement like this:
+
+ ```
+ let unarchiver = NSKeyedUnarchiver()
+ unarchiver.setClass(MyNewAttachment.self, forClassName: "...")`
+ ```
+
+ You'll see the name of the missing class in the unarchiving
+ error, so just use that name as the class name.
+ */
+@preconcurrency @MainActor
+open class RichTextImageAttachment: NSTextAttachment {
+
+ /**
+ Create a custom image attachment with an JPEG image and
+ no image compression.
+
+ - Parameters:
+ - image: The image to add to the attachment.
+ */
+ public convenience init(
+ jpegImage image: ImageRepresentable
+ ) {
+ self.init(jpegImage: image, compressionQuality: 1.0)
+ }
+
+ /**
+ Create a custom image attachment with an JPEG image and
+ a custom compression rate.
+
+ - Parameters:
+ - image: The image to add to the attachment.
+ - compressionQuality: The percentage rate to apply.
+ */
+ public convenience init(
+ jpegImage image: ImageRepresentable,
+ compressionQuality: CGFloat = 0.7
+ ) {
+ let data = image.jpegData(compressionQuality: compressionQuality)
+ self.init(jpegData: data)
+ contents = data
+ }
+
+ /**
+ Create a custom image attachment with PNG data.
+
+ Note that using PNG data may result in large file sizes.
+ */
+ public convenience init(
+ jpegData data: Data?
+ ) {
+ self.init(data: data, ofType: UTType.jpeg.identifier)
+ }
+
+ /**
+ Create a custom image attachment with PNG data.
+
+ Note that using PNG data may result in large file sizes.
+ */
+ public convenience init(
+ pngData data: Data?
+ ) {
+ self.init(data: data, ofType: UTType.png.identifier)
+ }
+
+ /**
+ Create a custom image attachment using plain image data
+ and a custom uniform type.
+
+ - Parameters:
+ - data: The data to add to the attachment.
+ - type: The uniform type to use, e.g. `UTType.jpeg`.
+ */
+ public convenience init(
+ data contentData: Data?,
+ ofType type: UTType
+ ) {
+ self.init(data: contentData, ofType: type.identifier)
+ }
+
+ /**
+ Create a custom image attachment using plain image data
+ and a custom uniform type.
+
+ - Parameters:
+ - data: The data to add to the attachment.
+ - uti: The uniform type identifier, e.g. `UTType.jpeg.identifier`
+ */
+ public override init(
+ data contentData: Data?,
+ ofType uti: String?
+ ) {
+ super.init(data: contentData, ofType: uti)
+ }
+
+ public required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ }
+
+ /**
+ Whether or not the attachment supports secure coding.
+
+ This attachment type supports secure coding by default.
+ */
+ public override class var supportsSecureCoding: Bool { true }
+
+ #if iOS || os(tvOS) || os(visionOS)
+ /**
+ Get or set the attachment image.
+
+ This will use the underlying functionality to setup the
+ attachment in a way that makes it multi-platform.
+ */
+ public override var image: UIImage? {
+ get {
+ guard let data = contents else { return nil }
+ return ImageRepresentable(data: data)
+ }
+ set {
+ super.image = newValue
+ }
+ }
+ #endif
+
+ #if macOS
+ /**
+ Get or set the attachment image.
+
+ This will use the underlying functionality to setup the
+ attachment in a way that makes it multi-platform.
+ */
+ public override var attachmentCell: NSTextAttachmentCellProtocol? {
+ get {
+ guard
+ let data = contents,
+ let image = ImageRepresentable(data: data)
+ else { return nil }
+ return MainActor.assumeIsolated {
+ NSTextAttachmentCell(imageCell: image)
+ }
+ }
+ set {
+ super.attachmentCell = newValue
+ }
+ }
+ #endif
+}
+#endif
diff --git a/Sources/RichEditorSwiftUI/Images/RichTextImageAttachmentManager.swift b/Sources/RichEditorSwiftUI/Images/RichTextImageAttachmentManager.swift
new file mode 100644
index 0000000..ae138f8
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Images/RichTextImageAttachmentManager.swift
@@ -0,0 +1,85 @@
+//
+// RichTextImageAttachmentManager.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 23/12/24.
+//
+
+import CoreGraphics
+import Foundation
+
+#if iOS || os(tvOS) || os(visionOS)
+import UIKit
+#endif
+
+#if macOS
+import AppKit
+#endif
+
+/**
+ This protocol extends ``RichTextReader`` with functionality
+ for handling image attachments.
+
+ The protocol is implemented by `NSAttributedString` and can
+ be implemented by any `RichTextReader` as well.
+ */
+public protocol RichTextImageAttachmentManager: RichTextReader {}
+
+extension NSAttributedString: RichTextImageAttachmentManager {}
+
+public extension RichTextImageAttachmentManager {
+
+ /**
+ Get the attachment bounds of an image, given a max size.
+ */
+ func attachmentBounds(
+ for image: ImageRepresentable,
+ maxSize: CGSize
+ ) -> CGRect {
+ let size = attachmentSize(for: image, maxSize: maxSize)
+ return CGRect(origin: .zero, size: size)
+ }
+
+ /**
+ Get the attachment size of an image, given a max size.
+ */
+ func attachmentSize(
+ for image: ImageRepresentable,
+ maxSize: CGSize
+ ) -> CGSize {
+ let size = image.size
+ let validWidth = size.width < maxSize.width
+ let validHeight = size.height < maxSize.height
+ let validSize = validWidth && validHeight
+ if validSize { return image.size }
+ let aspectWidth = maxSize.width / size.width
+ let aspectHeight = maxSize.height / size.height
+ let aspectRatio = min(aspectWidth, aspectHeight)
+ let newSize = CGSize(
+ width: size.width * aspectRatio,
+ height: size.height * aspectRatio)
+ return newSize
+ }
+}
+
+#if iOS || macOS || os(tvOS) || os(visionOS)
+public extension RichTextImageAttachmentManager {
+
+ /**
+ Auto-size all images attachments within a rich text, by
+ applying a max image size.
+ */
+ func autosizeImageAttachments(maxSize: CGSize) {
+ let range = NSRange(location: 0, length: richText.length)
+ let safeRange = safeRange(for: range)
+ richText.enumerateAttribute(.attachment, in: safeRange, options: []) { object, _, _ in
+ guard let attachment = object as? NSTextAttachment else { return }
+ guard let image = attachment.attachedImage else { return }
+ let oldBounds = attachment.bounds
+ let newBounds = attachmentBounds(for: image, maxSize: maxSize)
+ if oldBounds == newBounds { return }
+ attachment.bounds = newBounds
+ }
+ }
+}
+#endif
diff --git a/Sources/RichEditorSwiftUI/Images/RichTextImageAttachmentSize.swift b/Sources/RichEditorSwiftUI/Images/RichTextImageAttachmentSize.swift
new file mode 100644
index 0000000..d77e356
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Images/RichTextImageAttachmentSize.swift
@@ -0,0 +1,39 @@
+//
+// RichTextImageAttachmentSize.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 23/12/24.
+//
+
+import CoreGraphics
+
+/**
+ This enum defines various ways to size images in rich text.
+ */
+public enum RichTextImageAttachmentSize {
+
+ /// This size aims to make image fit the frame.
+ case frame
+
+ /// This size aims to make image fit the size in points.
+ case points(CGFloat)
+}
+
+public extension RichTextImageAttachmentSize {
+
+ /// The image's resulting height in a certain frame.
+ func height(in frame: CGRect) -> CGFloat {
+ switch self {
+ case .frame: frame.height
+ case .points(let points): points
+ }
+ }
+
+ /// The image's resulting width in a certain frame.
+ func width(in frame: CGRect) -> CGFloat {
+ switch self {
+ case .frame: frame.width
+ case .points(let points): points
+ }
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Images/RichTextImageConfiguration.swift b/Sources/RichEditorSwiftUI/Images/RichTextImageConfiguration.swift
new file mode 100644
index 0000000..636eba5
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Images/RichTextImageConfiguration.swift
@@ -0,0 +1,64 @@
+//
+// RichTextImageConfiguration.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 23/12/24.
+//
+
+import Foundation
+
+/**
+ This struct can be used to configure how images are handled
+ in e.g. a ``RichTextView``.
+
+ The paste and drop configurations should be `.disabled` for
+ rich text formats that don't support inserting images, like
+ `.txt` and `.rtf`.
+ */
+public struct RichTextImageConfiguration {
+
+ /**
+ Create a rich text image configuration.
+
+ - Parameters:
+ - pasteConfiguration: The configuration to use when pasting images.
+ - dropConfiguration: The configuration to use when dropping images.
+ - maxImageSize: The max size to limit images in the text view to.
+ */
+ public init(
+ pasteConfiguration: RichTextImageInsertConfiguration,
+ dropConfiguration: RichTextImageInsertConfiguration,
+ maxImageSize: (
+ width: RichTextImageAttachmentSize,
+ height: RichTextImageAttachmentSize
+ )
+ ) {
+ self.pasteConfiguration = pasteConfiguration
+ self.dropConfiguration = dropConfiguration
+ self.maxImageSize = maxImageSize
+ }
+
+ /// The image configuration to use when dropping images.
+ public var dropConfiguration: RichTextImageInsertConfiguration
+
+ /// The max size to limit images in the text view to.
+ public var maxImageSize: (
+ width: RichTextImageAttachmentSize,
+ height: RichTextImageAttachmentSize
+ )
+
+ /// The image configuration to use when pasting images.
+ public var pasteConfiguration: RichTextImageInsertConfiguration
+}
+
+public extension RichTextImageConfiguration {
+
+ /// Get a disabled image configuration.
+ static var disabled: RichTextImageConfiguration {
+ RichTextImageConfiguration(
+ pasteConfiguration: .disabled,
+ dropConfiguration: .disabled,
+ maxImageSize: (width: .frame, height: .frame)
+ )
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Images/RichTextImageInsertConfiguration.swift b/Sources/RichEditorSwiftUI/Images/RichTextImageInsertConfiguration.swift
new file mode 100644
index 0000000..7d760fd
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Images/RichTextImageInsertConfiguration.swift
@@ -0,0 +1,28 @@
+//
+// RichTextImageInsertConfiguration.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 23/12/24.
+//
+
+import Foundation
+
+/**
+ This enum can be used to configure the image drop and paste
+ behavior of a ``RichTextView``.
+
+ The configuration is needed, since ``RichTextDataFormat/rtf``
+ and ``RichTextDataFormat/plainText`` doesn't support images
+ and a text view doesn't know about the data format.
+ */
+public enum RichTextImageInsertConfiguration: Equatable {
+
+ /// Image inserting is disabled
+ case disabled
+
+ /// Image inserting is enabled but aborts with a warning
+ case disabledWithWarning(title: String, message: String)
+
+ /// Image inserting is enabled
+ case enabled
+}
diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/NSTextAttachment+Image.swift b/Sources/RichEditorSwiftUI/UI/Extensions/NSTextAttachment+Image.swift
new file mode 100644
index 0000000..ea477db
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/UI/Extensions/NSTextAttachment+Image.swift
@@ -0,0 +1,32 @@
+//
+// NSTextAttachment+Image.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 23/12/24.
+//
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+import AppKit
+#endif
+
+#if iOS || macOS || os(tvOS) || os(visionOS)
+public extension NSTextAttachment {
+
+ /**
+ Get an `image` value, if any, or use `contents` data to
+ create a platform-specific image.
+
+ This additional handling is needed since the `image` is
+ not always available on certain platforms.
+ */
+ var attachedImage: ImageRepresentable? {
+ if let image { return image }
+ guard let contents else { return nil }
+ return ImageRepresentable(data: contents)
+ }
+}
+#endif
diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift
index b12db04..bd64599 100644
--- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift
+++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift
@@ -42,10 +42,10 @@
/// Paste the current pasteboard content into the view.
open override func paste(_ sender: Any?) {
- // let pasteboard = NSPasteboard.general
- // if let image = pasteboard.image {
- // return pasteImage(image, at: selectedRange.location)
- // }
+ let pasteboard = NSPasteboard.general
+ if let image = pasteboard.image {
+ return pasteImage(image, at: selectedRange.location)
+ }
super.paste(sender)
}
@@ -53,31 +53,31 @@
Try to perform a certain drag operation, which will get
and paste images from the drag info into the text.
*/
- // open override func performDragOperation(_ draggingInfo: NSDraggingInfo) -> Bool {
- // let pasteboard = draggingInfo.draggingPasteboard
- // if let images = pasteboard.images, images.count > 0 {
- // pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true)
- // return true
- // }
- // // Handle fileURLs that contain known image types
- // let images = pasteboard.pasteboardItems?.compactMap {
- // if let str = $0.string(forType: NSPasteboard.PasteboardType.fileURL),
- // let url = URL(string: str), let image = ImageRepresentable(contentsOf: url) {
- // let fileExtension = url.pathExtension.lowercased()
- // let imageExtensions = ["jpg", "jpeg", "png", "gif", "tiff", "bmp", "heic"]
- // if imageExtensions.contains(fileExtension) {
- // return image
- // }
- // }
- // return nil
- // } ?? [ImageRepresentable]()
- // if images.count > 0 {
- // pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true)
- // return true
- // }
- //
- // return super.performDragOperation(draggingInfo)
- // }
+ open override func performDragOperation(_ draggingInfo: NSDraggingInfo) -> Bool {
+ let pasteboard = draggingInfo.draggingPasteboard
+ if let images = pasteboard.images, images.count > 0 {
+ pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true)
+ return true
+ }
+ // Handle fileURLs that contain known image types
+ let images = pasteboard.pasteboardItems?.compactMap {
+ if let str = $0.string(forType: NSPasteboard.PasteboardType.fileURL),
+ let url = URL(string: str), let image = ImageRepresentable(contentsOf: url) {
+ let fileExtension = url.pathExtension.lowercased()
+ let imageExtensions = ["jpg", "jpeg", "png", "gif", "tiff", "bmp", "heic"]
+ if imageExtensions.contains(fileExtension) {
+ return image
+ }
+ }
+ return nil
+ } ?? [ImageRepresentable]()
+ if images.count > 0 {
+ pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true)
+ return true
+ }
+
+ return super.performDragOperation(draggingInfo)
+ }
open override func scrollWheel(with event: NSEvent) {
diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
index dd79497..ee89020 100644
--- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
+++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
@@ -77,13 +77,13 @@
setting the property manually or by setting up the view
with a ``RichTextDataFormat`` that supports images.
*/
- // public var imageConfiguration: RichTextImageConfiguration = .disabled {
- // didSet {
- //#if os(iOS) || os(visionOS)
- // refreshDropInteraction()
- //#endif
- // }
- // }
+ public var imageConfiguration: RichTextImageConfiguration = .disabled {
+ didSet {
+ #if os(iOS) || os(visionOS)
+ refreshDropInteraction()
+ #endif
+ }
+ }
#if os(iOS) || os(visionOS)
@@ -124,33 +124,33 @@
}
}
- //#if os(iOS) || os(visionOS)
- // /**
- // Check whether or not a certain action can be performed.
- // */
- // open override func canPerformAction(
- // _ action: Selector,
- // withSender sender: Any?
- // ) -> Bool {
- // let pasteboard = UIPasteboard.general
- // let hasImage = pasteboard.image != nil
- // let isPaste = action == #selector(paste(_:))
- // let canPerformImagePaste = imagePasteConfiguration != .disabled
- // if isPaste && hasImage && canPerformImagePaste { return true }
- // return super.canPerformAction(action, withSender: sender)
- // }
- //
- // /**
- // Paste the current content of the general pasteboard.
- // */
- // open override func paste(_ sender: Any?) {
- // let pasteboard = UIPasteboard.general
- // if let image = pasteboard.image {
- // return pasteImage(image, at: selectedRange.location)
- // }
- // super.paste(sender)
- // }
- //#endif
+ #if os(iOS) || os(visionOS)
+ /**
+ Check whether or not a certain action can be performed.
+ */
+ open override func canPerformAction(
+ _ action: Selector,
+ withSender sender: Any?
+ ) -> Bool {
+ let pasteboard = UIPasteboard.general
+ let hasImage = pasteboard.image != nil
+ let isPaste = action == #selector(paste(_:))
+ let canPerformImagePaste = imagePasteConfiguration != .disabled
+ if isPaste && hasImage && canPerformImagePaste { return true }
+ return super.canPerformAction(action, withSender: sender)
+ }
+
+ /**
+ Paste the current content of the general pasteboard.
+ */
+ open override func paste(_ sender: Any?) {
+ let pasteboard = UIPasteboard.general
+ if let image = pasteboard.image {
+ return pasteImage(image, at: selectedRange.location)
+ }
+ super.paste(sender)
+ }
+ #endif
// MARK: - Setup
@@ -166,7 +166,7 @@
with text: NSAttributedString,
format: RichTextDataFormat? = nil
) {
- // text.autosizeImageAttachments(maxSize: imageAttachmentMaxSize)
+ text.autosizeImageAttachments(maxSize: imageAttachmentMaxSize)
setupSharedBehavior(with: text, format)
if let format {
richTextDataFormat = format
@@ -278,14 +278,14 @@
// MARK: - UIDropInteractionDelegate
/// Whether or not the view can handle a drop session.
- // open func dropInteraction(
- // _ interaction: UIDropInteraction,
- // canHandle session: UIDropSession
- // ) -> Bool {
- // if session.hasImage && imageDropConfiguration == .disabled { return false }
- // let identifiers = supportedDropInteractionTypes.map { $0.identifier }
- // return session.hasItemsConforming(toTypeIdentifiers: identifiers)
- // }
+ open func dropInteraction(
+ _ interaction: UIDropInteraction,
+ canHandle session: UIDropSession
+ ) -> Bool {
+ if session.hasImage && imageDropConfiguration == .disabled { return false }
+ let identifiers = supportedDropInteractionTypes.map { $0.identifier }
+ return session.hasItemsConforming(toTypeIdentifiers: identifiers)
+ }
/// Handle an updated drop session.
open func dropInteraction(
@@ -331,13 +331,13 @@
We reverse the item collection, since each item will be
pasted at the original drop point.
*/
- // open func performImageDrop(with session: UIDropSession, at range: NSRange) {
- // guard validateImageInsertion(for: imageDropConfiguration) else { return }
- // session.loadObjects(ofClass: UIImage.self) { items in
- // let images = items.compactMap { $0 as? UIImage }.reversed()
- // images.forEach { self.pasteImage($0, at: range.location) }
- // }
- // }
+ open func performImageDrop(with session: UIDropSession, at range: NSRange) {
+ guard validateImageInsertion(for: imageDropConfiguration) else { return }
+ session.loadObjects(ofClass: UIImage.self) { items in
+ let images = items.compactMap { $0 as? UIImage }.reversed()
+ images.forEach { self.pasteImage($0, at: range.location) }
+ }
+ }
/**
Perform a text drop session.
@@ -356,14 +356,14 @@
}
/// Refresh the drop interaction based on the config.
- // open func refreshDropInteraction() {
- // switch imageDropConfiguration {
- // case .disabled:
- // removeInteraction(imageDropInteraction)
- // case .disabledWithWarning, .enabled:
- // addInteraction(imageDropInteraction)
- // }
- // }
+ open func refreshDropInteraction() {
+ switch imageDropConfiguration {
+ case .disabled:
+ removeInteraction(imageDropInteraction)
+ case .disabledWithWarning, .enabled:
+ addInteraction(imageDropInteraction)
+ }
+ }
#endif
}
From 57b33f7c413453567069e65e30c19e83cbb4456a Mon Sep 17 00:00:00 2001
From: Divyesh Canopas
Date: Mon, 23 Dec 2024 16:27:16 +0530
Subject: [PATCH 3/7] Update mac code to support image drop
---
.../Pasteboard/PasteboardImageReader.swift | 58 +++++++++++++++++++
.../UI/TextViewUI/RichTextView+Setup.swift | 3 +
.../UI/TextViewUI/RichTextView_AppKit.swift | 7 ++-
.../UI/TextViewUI/RichTextView_UIKit.swift | 4 ++
4 files changed, 71 insertions(+), 1 deletion(-)
create mode 100644 Sources/RichEditorSwiftUI/Pasteboard/PasteboardImageReader.swift
diff --git a/Sources/RichEditorSwiftUI/Pasteboard/PasteboardImageReader.swift b/Sources/RichEditorSwiftUI/Pasteboard/PasteboardImageReader.swift
new file mode 100644
index 0000000..28017bc
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Pasteboard/PasteboardImageReader.swift
@@ -0,0 +1,58 @@
+//
+// PasteboardImageReader.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 23/12/24.
+//
+
+import Foundation
+
+/**
+ This protocol can be implemented by types that can fetch an
+ image or multiple images from the pasteboard.
+
+ The protocol is implemented by the UIKit `UIPasteboard`, as
+ well as the AppKit `NSPasteboard`.
+ */
+public protocol PasteboardImageReader {
+
+ /// Get the first image in the pasteboard, if any.
+ var image: ImageRepresentable? { get }
+
+ /// Get all images in the pasteboard.
+ var images: [ImageRepresentable]? { get }
+}
+
+public extension PasteboardImageReader {
+
+ /// Check whether or not the pasteboard han any images.
+ var hasImages: Bool {
+ guard let images = images else { return false }
+ return !images.isEmpty
+ }
+}
+
+#if iOS || os(visionOS)
+import UIKit
+
+extension UIPasteboard: PasteboardImageReader {}
+#endif
+
+#if macOS
+import AppKit
+
+extension NSPasteboard: PasteboardImageReader {}
+
+public extension NSPasteboard {
+
+ /// Get the first image in the pasteboard, if any.
+ var image: ImageRepresentable? {
+ images?.first
+ }
+
+ /// Get all images in the pasteboard.
+ var images: [ImageRepresentable]? {
+ readObjects(forClasses: [NSImage.self]) as? [NSImage]
+ }
+}
+#endif
diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Setup.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Setup.swift
index 2fe40a7..09b623f 100644
--- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Setup.swift
+++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView+Setup.swift
@@ -15,6 +15,9 @@
_ format: RichTextDataFormat?
) {
attributedString = .empty
+ if let format, !imageConfigurationWasSet {
+ imageConfiguration = standardImageConfiguration(for: format)
+ }
attributedString = text
setContentCompressionResistancePriority(
diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift
index bd64599..10c6c78 100644
--- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift
+++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift
@@ -36,7 +36,12 @@
public var highlightingStyle: RichTextHighlightingStyle = .standard
/// The image configuration to use by the rich text view.
- // public var imageConfiguration: RichTextImageConfiguration = .disabled
+ public var imageConfiguration: RichTextImageConfiguration = .disabled {
+ didSet { imageConfigurationWasSet = true }
+ }
+
+ /// The image configuration to use by the rich text view.
+ var imageConfigurationWasSet = false
// MARK: - Overrides
diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
index ee89020..29b8c0c 100644
--- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
+++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
@@ -81,10 +81,14 @@
didSet {
#if os(iOS) || os(visionOS)
refreshDropInteraction()
+ imageConfigurationWasSet = true
#endif
}
}
+ /// The image configuration to use by the rich text view.
+ var imageConfigurationWasSet = false
+
#if os(iOS) || os(visionOS)
/// The image drop interaction to use.
From aaa6b10244c861b8714f4f00b51c2082c451359d Mon Sep 17 00:00:00 2001
From: Divyesh Canopas
Date: Thu, 26 Dec 2024 11:20:36 +0530
Subject: [PATCH 4/7] Fix image drop not working in iOS
---
.../UI/TextViewUI/RichTextView_UIKit.swift | 701 +++++++++---------
1 file changed, 353 insertions(+), 348 deletions(-)
diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
index 29b8c0c..bba26c6 100644
--- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
+++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
@@ -6,70 +6,78 @@
//
#if os(iOS) || os(tvOS) || os(visionOS)
- import UIKit
-
- #if os(iOS) || os(visionOS)
- import UniformTypeIdentifiers
-
- extension RichTextView: UIDropInteractionDelegate {}
- #endif
+ import UIKit
+
+ #if os(iOS) || os(visionOS)
+ import UniformTypeIdentifiers
+
+ extension RichTextView: UIDropInteractionDelegate {}
+ #endif
+
+ /// This is a platform-agnostic rich text view that can be used
+ /// in both UIKit and AppKit.
+ ///
+ /// The view inherits `NSTextView` in AppKit and `UITextView`
+ /// in UIKit. It aims to make these views behave more alike and
+ /// make them implement ``RichTextViewComponent``, which is the
+ /// protocol that is used within this library.
+ open class RichTextView: UITextView, RichTextViewComponent {
+
+ // MARK: - Initializers
+
+ public convenience init(
+ data: Data,
+ format: RichTextDataFormat = .archivedData
+ ) throws {
+ self.init()
+ try self.setup(with: data, format: format)
+ }
- /// This is a platform-agnostic rich text view that can be used
- /// in both UIKit and AppKit.
- ///
- /// The view inherits `NSTextView` in AppKit and `UITextView`
- /// in UIKit. It aims to make these views behave more alike and
- /// make them implement ``RichTextViewComponent``, which is the
- /// protocol that is used within this library.
- open class RichTextView: UITextView, RichTextViewComponent {
-
- // MARK: - Initializers
-
- public convenience init(
- data: Data,
- format: RichTextDataFormat = .archivedData
- ) throws {
- self.init()
- try self.setup(with: data, format: format)
- }
+ public convenience init(
+ string: NSAttributedString,
+ format: RichTextDataFormat = .archivedData
+ ) {
+ self.init()
+ self.setup(with: string, format: format)
+ }
- public convenience init(
- string: NSAttributedString,
- format: RichTextDataFormat = .archivedData
- ) {
- self.init()
- self.setup(with: string, format: format)
- }
+ public convenience init(
+ spans: [RichTextSpanInternal]? = nil
+ ) {
+ self.init()
+ }
- public convenience init(
- spans: [RichTextSpanInternal]? = nil
- ) {
- self.init()
- }
+ /// Get the text range at a certain point.
+ open func range(at index: CGPoint) -> NSRange? {
+ let range = characterRange(at: index) ?? UITextRange()
+ let location = offset(from: beginningOfDocument, to: range.start)
+ let length = offset(from: range.start, to: range.end)
+ return NSRange(location: location, length: length)
+ }
- // MARK: - Properties
-
- /// The configuration to use by the rich text view.
- public var configuration: Configuration = .standard {
- didSet {
- isScrollEnabled = configuration.isScrollingEnabled
- allowsEditingTextAttributes =
- configuration.allowsEditingTextAttributes
- autocapitalizationType = configuration.autocapitalizationType
- spellCheckingType = configuration.spellCheckingType
- }
- }
+ // MARK: - Properties
+
+ /// The configuration to use by the rich text view.
+ public var configuration: Configuration = .standard {
+ didSet {
+ isScrollEnabled = configuration.isScrollingEnabled
+ allowsEditingTextAttributes =
+ configuration.allowsEditingTextAttributes
+ autocapitalizationType = configuration.autocapitalizationType
+ spellCheckingType = configuration.spellCheckingType
+ }
+ }
- public var theme: Theme = .standard {
- didSet {
- setup(theme)
- }
- }
+ public var theme: Theme = .standard {
+ didSet {
+ setup(theme)
+ }
+ }
- /// The style to use when highlighting text in the view.
- public var highlightingStyle: RichTextHighlightingStyle = .standard
+ /// The style to use when highlighting text in the view.
+ public var highlightingStyle: RichTextHighlightingStyle = .standard
- /**
+ /**
The image configuration to use by the rich text view.
The view uses the ``RichTextImageConfiguration/disabled``
@@ -77,41 +85,41 @@
setting the property manually or by setting up the view
with a ``RichTextDataFormat`` that supports images.
*/
- public var imageConfiguration: RichTextImageConfiguration = .disabled {
- didSet {
- #if os(iOS) || os(visionOS)
- refreshDropInteraction()
- imageConfigurationWasSet = true
- #endif
- }
- }
+ public var imageConfiguration: RichTextImageConfiguration = .disabled {
+ didSet {
+ #if os(iOS) || os(visionOS)
+ refreshDropInteraction()
+ imageConfigurationWasSet = true
+ #endif
+ }
+ }
- /// The image configuration to use by the rich text view.
- var imageConfigurationWasSet = false
+ /// The image configuration to use by the rich text view.
+ var imageConfigurationWasSet = false
- #if os(iOS) || os(visionOS)
+ #if os(iOS) || os(visionOS)
- /// The image drop interaction to use.
- lazy var imageDropInteraction: UIDropInteraction = {
- UIDropInteraction(delegate: self)
- }()
+ /// The image drop interaction to use.
+ lazy var imageDropInteraction: UIDropInteraction = {
+ UIDropInteraction(delegate: self)
+ }()
- /// The interaction types supported by drag & drop.
- var supportedDropInteractionTypes: [UTType] {
- [.image, .text, .plainText, .utf8PlainText, .utf16PlainText]
- }
+ /// The interaction types supported by drag & drop.
+ var supportedDropInteractionTypes: [UTType] {
+ [.image, .text, .plainText, .utf8PlainText, .utf16PlainText]
+ }
- #endif
+ #endif
- /// Keeps track of the first time a valid frame is set.
- private var isInitialFrameSetupNeeded = true
+ /// Keeps track of the first time a valid frame is set.
+ private var isInitialFrameSetupNeeded = true
- /// Keeps track of the data format used by the view.
- private var richTextDataFormat: RichTextDataFormat = .archivedData
+ /// Keeps track of the data format used by the view.
+ private var richTextDataFormat: RichTextDataFormat = .archivedData
- // MARK: - Overrides
+ // MARK: - Overrides
- /**
+ /**
Layout subviews and auto-resize images in the rich text.
I tried to only autosize image attachments here, but it
@@ -119,46 +127,46 @@
font size adjustment, but that also didn't work. So now
we initialize this once, when the frame is first set.
*/
- open override var frame: CGRect {
- didSet {
- if frame.size == .zero { return }
- if !isInitialFrameSetupNeeded { return }
- isInitialFrameSetupNeeded = false
- setup(with: attributedString, format: richTextDataFormat)
- }
- }
+ open override var frame: CGRect {
+ didSet {
+ if frame.size == .zero { return }
+ if !isInitialFrameSetupNeeded { return }
+ isInitialFrameSetupNeeded = false
+ setup(with: attributedString, format: richTextDataFormat)
+ }
+ }
- #if os(iOS) || os(visionOS)
- /**
+ #if os(iOS) || os(visionOS)
+ /**
Check whether or not a certain action can be performed.
*/
- open override func canPerformAction(
- _ action: Selector,
- withSender sender: Any?
- ) -> Bool {
- let pasteboard = UIPasteboard.general
- let hasImage = pasteboard.image != nil
- let isPaste = action == #selector(paste(_:))
- let canPerformImagePaste = imagePasteConfiguration != .disabled
- if isPaste && hasImage && canPerformImagePaste { return true }
- return super.canPerformAction(action, withSender: sender)
- }
-
- /**
+ open override func canPerformAction(
+ _ action: Selector,
+ withSender sender: Any?
+ ) -> Bool {
+ let pasteboard = UIPasteboard.general
+ let hasImage = pasteboard.image != nil
+ let isPaste = action == #selector(paste(_:))
+ let canPerformImagePaste = imagePasteConfiguration != .disabled
+ if isPaste && hasImage && canPerformImagePaste { return true }
+ return super.canPerformAction(action, withSender: sender)
+ }
+
+ /**
Paste the current content of the general pasteboard.
*/
- open override func paste(_ sender: Any?) {
- let pasteboard = UIPasteboard.general
- if let image = pasteboard.image {
- return pasteImage(image, at: selectedRange.location)
- }
- super.paste(sender)
- }
- #endif
+ open override func paste(_ sender: Any?) {
+ let pasteboard = UIPasteboard.general
+ if let image = pasteboard.image {
+ return pasteImage(image, at: selectedRange.location)
+ }
+ super.paste(sender)
+ }
+ #endif
- // MARK: - Setup
+ // MARK: - Setup
- /**
+ /**
Setup the rich text view with a rich text and a certain
``RichTextDataFormat``.
@@ -166,285 +174,282 @@
- text: The text to edit with the text view.
- format: The rich text format to edit.
*/
- open func setup(
- with text: NSAttributedString,
- format: RichTextDataFormat? = nil
- ) {
- text.autosizeImageAttachments(maxSize: imageAttachmentMaxSize)
- setupSharedBehavior(with: text, format)
- if let format {
- richTextDataFormat = format
- }
- setup(theme)
- }
+ open func setup(
+ with text: NSAttributedString,
+ format: RichTextDataFormat? = nil
+ ) {
+ text.autosizeImageAttachments(maxSize: imageAttachmentMaxSize)
+ setupSharedBehavior(with: text, format)
+ if let format {
+ richTextDataFormat = format
+ }
+ setup(theme)
+ }
- open func setup(with richText: RichText) {
- var tempSpans: [RichTextSpanInternal] = []
- var text = ""
- richText.spans.forEach({
- let span = RichTextSpanInternal(
- from: text.utf16Length,
- to: (text.utf16Length + $0.insert.utf16Length - 1),
- attributes: $0.attributes)
- tempSpans.append(span)
- text += $0.insert
- })
-
- let str = NSMutableAttributedString(string: text)
-
- tempSpans.forEach { span in
- str.addAttributes(
- span.attributes?.toAttributes(font: .standardRichTextFont)
- ?? [:], range: span.spanRange)
- if span.attributes?.color == nil {
- str.addAttributes(
- [.foregroundColor: theme.fontColor],
- range: span.spanRange)
- }
- }
-
- setup(with: str, format: .archivedData)
+ open func setup(with richText: RichText) {
+ var tempSpans: [RichTextSpanInternal] = []
+ var text = ""
+ richText.spans.forEach({
+ let span = RichTextSpanInternal(
+ from: text.utf16Length,
+ to: (text.utf16Length + $0.insert.utf16Length - 1),
+ attributes: $0.attributes)
+ tempSpans.append(span)
+ text += $0.insert
+ })
+
+ let str = NSMutableAttributedString(string: text)
+
+ tempSpans.forEach { span in
+ str.addAttributes(
+ span.attributes?.toAttributes(font: .standardRichTextFont)
+ ?? [:], range: span.spanRange)
+ if span.attributes?.color == nil {
+ str.addAttributes(
+ [.foregroundColor: theme.fontColor],
+ range: span.spanRange)
}
+ }
- // MARK: - Open Functionality
-
- /// Alert a certain title and message.
- open func alert(title: String, message: String, buttonTitle: String) {
- let alert = UIAlertController(
- title: title, message: message, preferredStyle: .alert)
- let action = UIAlertAction(
- title: buttonTitle, style: .default, handler: nil)
- alert.addAction(action)
- let controller = window?.rootViewController?.presentedViewController
- controller?.present(alert, animated: true, completion: nil)
- }
+ setup(with: str, format: .archivedData)
+ }
- /// Copy the current selection.
- open func copySelection() {
- #if os(iOS) || os(visionOS)
- let pasteboard = UIPasteboard.general
- let range = safeRange(for: selectedRange)
- let text = richText(at: range)
- pasteboard.string = text.string
- #else
- print("Pasteboard is not available on this platform")
- #endif
- }
+ // MARK: - Open Functionality
+
+ /// Alert a certain title and message.
+ open func alert(title: String, message: String, buttonTitle: String) {
+ let alert = UIAlertController(
+ title: title, message: message, preferredStyle: .alert)
+ let action = UIAlertAction(
+ title: buttonTitle, style: .default, handler: nil)
+ alert.addAction(action)
+ let controller = window?.rootViewController?.presentedViewController
+ controller?.present(alert, animated: true, completion: nil)
+ }
- /// Get the frame of a certain range.
- open func frame(of range: NSRange) -> CGRect {
- let beginning = beginningOfDocument
- guard
- let start = position(from: beginning, offset: range.location),
- let end = position(from: start, offset: range.length),
- let textRange = textRange(from: start, to: end)
- else { return .zero }
- let rect = firstRect(for: textRange)
- return convert(rect, from: textInputView)
- }
+ /// Copy the current selection.
+ open func copySelection() {
+ #if os(iOS) || os(visionOS)
+ let pasteboard = UIPasteboard.general
+ let range = safeRange(for: selectedRange)
+ let text = richText(at: range)
+ pasteboard.string = text.string
+ #else
+ print("Pasteboard is not available on this platform")
+ #endif
+ }
- /// Get the text range at a certain point.
- open func range(at index: CGPoint) -> NSRange? {
- let range = characterRange(at: index) ?? UITextRange()
- let location = offset(from: beginningOfDocument, to: range.start)
- let length = offset(from: range.start, to: range.end)
- return NSRange(location: location, length: length)
- }
+ /// Get the frame of a certain range.
+ open func frame(of range: NSRange) -> CGRect {
+ let beginning = beginningOfDocument
+ guard
+ let start = position(from: beginning, offset: range.location),
+ let end = position(from: start, offset: range.length),
+ let textRange = textRange(from: start, to: end)
+ else { return .zero }
+ let rect = firstRect(for: textRange)
+ return convert(rect, from: textInputView)
+ }
- /// Try to redo the latest undone change.
- open func redoLatestChange() {
- undoManager?.redo()
- }
+ /// Delete the text at a certain range.
+ open func deleteText(in range: NSRange) {
+ deleteCharacters(in: range)
+ }
- /// Scroll to a certain range.
- open func scroll(to range: NSRange) {
- let caret = frame(of: range)
- scrollRectToVisible(caret, animated: true)
- }
+ /// Try to redo the latest undone change.
+ open func redoLatestChange() {
+ undoManager?.redo()
+ }
- /// Set the rich text in the text view.
- open func setRichText(_ text: NSAttributedString) {
- attributedString = text
- }
+ /// Scroll to a certain range.
+ open func scroll(to range: NSRange) {
+ let caret = frame(of: range)
+ scrollRectToVisible(caret, animated: true)
+ }
- /// Set the selected range in the text view.
- open func setSelectedRange(_ range: NSRange) {
- selectedRange = range
- }
+ /// Set the rich text in the text view.
+ open func setRichText(_ text: NSAttributedString) {
+ attributedString = text
+ }
- /// Undo the latest change.
- open func undoLatestChange() {
- undoManager?.undo()
- }
+ /// Set the selected range in the text view.
+ open func setSelectedRange(_ range: NSRange) {
+ selectedRange = range
+ }
- #if os(iOS) || os(visionOS)
+ /// Undo the latest change.
+ open func undoLatestChange() {
+ undoManager?.undo()
+ }
+
+ #if os(iOS) || os(visionOS)
- // MARK: - UIDropInteractionDelegate
-
- /// Whether or not the view can handle a drop session.
- open func dropInteraction(
- _ interaction: UIDropInteraction,
- canHandle session: UIDropSession
- ) -> Bool {
- if session.hasImage && imageDropConfiguration == .disabled { return false }
- let identifiers = supportedDropInteractionTypes.map { $0.identifier }
- return session.hasItemsConforming(toTypeIdentifiers: identifiers)
- }
-
- /// Handle an updated drop session.
- open func dropInteraction(
- _ interaction: UIDropInteraction,
- sessionDidUpdate session: UIDropSession
- ) -> UIDropProposal {
- let operation = dropInteractionOperation(for: session)
- return UIDropProposal(operation: operation)
- }
-
- /// The drop interaction operation for a certain session.
- open func dropInteractionOperation(
- for session: UIDropSession
- ) -> UIDropOperation {
- guard session.hasDroppableContent else { return .forbidden }
- let location = session.location(in: self)
- return frame.contains(location) ? .copy : .cancel
- }
-
- /**
+ // MARK: - UIDropInteractionDelegate
+
+ /// Whether or not the view can handle a drop session.
+ open func dropInteraction(
+ _ interaction: UIDropInteraction,
+ canHandle session: UIDropSession
+ ) -> Bool {
+ if session.hasImage && imageDropConfiguration == .disabled { return false }
+ let identifiers = supportedDropInteractionTypes.map { $0.identifier }
+ return session.hasItemsConforming(toTypeIdentifiers: identifiers)
+ }
+
+ /// Handle an updated drop session.
+ open func dropInteraction(
+ _ interaction: UIDropInteraction,
+ sessionDidUpdate session: UIDropSession
+ ) -> UIDropProposal {
+ let operation = dropInteractionOperation(for: session)
+ return UIDropProposal(operation: operation)
+ }
+
+ /// The drop interaction operation for a certain session.
+ open func dropInteractionOperation(
+ for session: UIDropSession
+ ) -> UIDropOperation {
+ guard session.hasDroppableContent else { return .forbidden }
+ let location = session.location(in: self)
+ return frame.contains(location) ? .copy : .cancel
+ }
+
+ /**
Handle a performed drop session.
In this function, we reverse the item collection, since
each item will be pasted at the drop point, which would
result in a reverse result.
*/
- open func dropInteraction(
- _ interaction: UIDropInteraction,
- performDrop session: UIDropSession
- ) {
- guard session.hasDroppableContent else { return }
- let location = session.location(in: self)
- guard let range = self.range(at: location) else { return }
- // performImageDrop(with: session, at: range)
- performTextDrop(with: session, at: range)
- }
-
- // MARK: - Drop Interaction Support
-
- /**
+ open func dropInteraction(
+ _ interaction: UIDropInteraction,
+ performDrop session: UIDropSession
+ ) {
+ guard session.hasDroppableContent else { return }
+ let location = session.location(in: self)
+ guard let range = self.range(at: location) else { return }
+ performImageDrop(with: session, at: range)
+ performTextDrop(with: session, at: range)
+ }
+
+ // MARK: - Drop Interaction Support
+
+ /**
Performs an image drop session.
We reverse the item collection, since each item will be
pasted at the original drop point.
*/
- open func performImageDrop(with session: UIDropSession, at range: NSRange) {
- guard validateImageInsertion(for: imageDropConfiguration) else { return }
- session.loadObjects(ofClass: UIImage.self) { items in
- let images = items.compactMap { $0 as? UIImage }.reversed()
- images.forEach { self.pasteImage($0, at: range.location) }
- }
- }
-
- /**
+ open func performImageDrop(with session: UIDropSession, at range: NSRange) {
+ guard validateImageInsertion(for: imageDropConfiguration) else { return }
+ session.loadObjects(ofClass: UIImage.self) { items in
+ let images = items.compactMap { $0 as? UIImage }.reversed()
+ images.forEach { self.pasteImage($0, at: range.location) }
+ }
+ }
+
+ /**
Perform a text drop session.
We reverse the item collection, since each item will be
pasted at the original drop point.
*/
- open func performTextDrop(
- with session: UIDropSession, at range: NSRange
- ) {
- if session.hasImage { return }
- _ = session.loadObjects(ofClass: String.self) { items in
- let strings = items.reversed()
- strings.forEach { self.pasteText($0, at: range.location) }
- }
- }
-
- /// Refresh the drop interaction based on the config.
- open func refreshDropInteraction() {
- switch imageDropConfiguration {
- case .disabled:
- removeInteraction(imageDropInteraction)
- case .disabledWithWarning, .enabled:
- addInteraction(imageDropInteraction)
- }
- }
- #endif
- }
+ open func performTextDrop(
+ with session: UIDropSession, at range: NSRange
+ ) {
+ if session.hasImage { return }
+ _ = session.loadObjects(ofClass: String.self) { items in
+ let strings = items.reversed()
+ strings.forEach { self.pasteText($0, at: range.location) }
+ }
+ }
+
+ /// Refresh the drop interaction based on the config.
+ open func refreshDropInteraction() {
+ switch imageDropConfiguration {
+ case .disabled:
+ removeInteraction(imageDropInteraction)
+ case .disabledWithWarning, .enabled:
+ addInteraction(imageDropInteraction)
+ }
+ }
+ #endif
+ }
- #if os(iOS) || os(visionOS)
- extension UIDropSession {
+ #if os(iOS) || os(visionOS)
+ extension UIDropSession {
- fileprivate var hasDroppableContent: Bool {
- hasImage || hasText
- }
+ fileprivate var hasDroppableContent: Bool {
+ hasImage || hasText
+ }
- fileprivate var hasImage: Bool {
- canLoadObjects(ofClass: UIImage.self)
- }
+ fileprivate var hasImage: Bool {
+ canLoadObjects(ofClass: UIImage.self)
+ }
- fileprivate var hasText: Bool {
- canLoadObjects(ofClass: String.self)
- }
- }
- #endif
+ fileprivate var hasText: Bool {
+ canLoadObjects(ofClass: String.self)
+ }
+ }
+ #endif
- // MARK: - Public Extensions
+ // MARK: - Public Extensions
- extension RichTextView {
+ extension RichTextView {
- /// The text view's layout manager, if any.
- public var layoutManagerWrapper: NSLayoutManager? {
- layoutManager
- }
+ /// The text view's layout manager, if any.
+ public var layoutManagerWrapper: NSLayoutManager? {
+ layoutManager
+ }
- /// The spacing between the text view edges and its text.
- public var textContentInset: CGSize {
- get {
- CGSize(
- width: textContainerInset.left,
- height: textContainerInset.top
- )
- }
- set {
- textContainerInset = UIEdgeInsets(
- top: newValue.height,
- left: newValue.width,
- bottom: newValue.height,
- right: newValue.width
- )
- }
- }
+ /// The spacing between the text view edges and its text.
+ public var textContentInset: CGSize {
+ get {
+ CGSize(
+ width: textContainerInset.left,
+ height: textContainerInset.top
+ )
+ }
+ set {
+ textContainerInset = UIEdgeInsets(
+ top: newValue.height,
+ left: newValue.width,
+ bottom: newValue.height,
+ right: newValue.width
+ )
+ }
+ }
- /// The text view's text storage, if any.
- public var textStorageWrapper: NSTextStorage? {
- textStorage
- }
+ /// The text view's text storage, if any.
+ public var textStorageWrapper: NSTextStorage? {
+ textStorage
}
+ }
- // MARK: - RichTextProvider
+ // MARK: - RichTextProvider
- extension RichTextView {
+ extension RichTextView {
- /// Get the rich text managed by the text view.
- public var attributedString: NSAttributedString {
- get { super.attributedText ?? NSAttributedString(string: "") }
- set { attributedText = newValue }
- }
+ /// Get the rich text managed by the text view.
+ public var attributedString: NSAttributedString {
+ get { super.attributedText ?? NSAttributedString(string: "") }
+ set { attributedText = newValue }
}
+ }
- // MARK: - RichTextWriter
+ // MARK: - RichTextWriter
- extension RichTextView {
+ extension RichTextView {
- /// Get the mutable rich text managed by the view.
- public var mutableAttributedString: NSMutableAttributedString? {
- textStorage
- }
+ /// Get the mutable rich text managed by the view.
+ public var mutableAttributedString: NSMutableAttributedString? {
+ textStorage
}
+ }
- extension RichTextView {
- var textString: String {
- return self.text ?? ""
- }
+ extension RichTextView {
+ var textString: String {
+ return self.text ?? ""
}
+ }
#endif
From 6ee55a53c4061fed9a35b363148653f84458df5c Mon Sep 17 00:00:00 2001
From: Divyesh Canopas
Date: Tue, 31 Dec 2024 10:08:01 +0530
Subject: [PATCH 5/7] Suport image drop with json format
---
.../RichEditorDemo/ContentView.swift | 355 +++--
.../RichEditorDemo/ImageFileManager.swift | 114 ++
.../Actions/RichTextAction.swift | 268 ++--
.../Actions/RichTextInsertion.swift | 94 ++
.../RichTextCoordinator+Actions.swift | 376 ++---
.../BaseFoundation/RichTextCoordinator.swift | 478 +++---
.../RichTextViewComponent+Pasting.swift | 173 ++-
.../RichTextViewComponent+Ranges.swift | 100 +-
.../Components/RichTextViewComponent.swift | 187 +--
.../Data/Models/RichAttributes.swift | 763 ++++-----
.../ExportData/UTType+RichText.swift | 26 +-
.../Images/ImageAttachment.swift | 36 +
.../Images/ImageAttachmentAction.swift | 22 +
.../Images/ImageDownloadManager.swift | 70 +
.../Keyboard/RichTextKeyboardToolbar.swift | 410 +++--
.../Localization/RTEL10n.swift | 270 ++--
.../RichTextOtherMenu+ToggleStack.swift | 60 +-
.../UI/Context/RichEditorState.swift | 419 ++---
.../UI/Context/RichTextContext+Actions.swift | 48 +-
.../UI/Editor/RichEditor.swift | 272 ++--
.../UI/Editor/RichEditorState+Spans.swift | 1379 +++++++++--------
.../UI/Editor/RichTextSpanStyle.swift | 804 +++++-----
.../UI/TextViewUI/RichTextView_AppKit.swift | 415 ++---
.../UI/TextViewUI/RichTextView_UIKit.swift | 48 +-
.../UI/TextViewUI/TextViewEvents.swift | 11 +-
25 files changed, 3878 insertions(+), 3320 deletions(-)
create mode 100644 RichEditorDemo/RichEditorDemo/ImageFileManager.swift
create mode 100644 Sources/RichEditorSwiftUI/Actions/RichTextInsertion.swift
create mode 100644 Sources/RichEditorSwiftUI/Images/ImageAttachment.swift
create mode 100644 Sources/RichEditorSwiftUI/Images/ImageAttachmentAction.swift
create mode 100644 Sources/RichEditorSwiftUI/Images/ImageDownloadManager.swift
diff --git a/RichEditorDemo/RichEditorDemo/ContentView.swift b/RichEditorDemo/RichEditorDemo/ContentView.swift
index 336e449..66d8408 100644
--- a/RichEditorDemo/RichEditorDemo/ContentView.swift
+++ b/RichEditorDemo/RichEditorDemo/ContentView.swift
@@ -9,183 +9,208 @@ import RichEditorSwiftUI
import SwiftUI
struct ContentView: View {
- @Environment(\.colorScheme) var colorScheme
-
- @ObservedObject var state: RichEditorState
- @State private var isInspectorPresented = false
- @State private var fileName: String = ""
- @State private var exportFormat: RichTextDataFormat? = nil
- @State private var otherExportFormat: RichTextExportOption? = nil
- @State private var exportService: StandardRichTextExportService = .init()
-
- init(state: RichEditorState? = nil) {
- if let state {
- self.state = state
- } else {
- if let richText = readJSONFromFile(
- fileName: "Sample_json",
- type: RichText.self)
- {
- self.state = .init(richText: richText)
- } else {
- self.state = .init(input: "Hello World!")
- }
- }
- }
+ @Environment(\.colorScheme) var colorScheme
+
+ @ObservedObject var state: RichEditorState
+ @State private var isInspectorPresented = false
+ @State private var fileName: String = ""
+ @State private var exportFormat: RichTextDataFormat? = nil
+ @State private var otherExportFormat: RichTextExportOption? = nil
+ @State private var exportService: StandardRichTextExportService = .init()
+
+ init(state: RichEditorState? = nil) {
+ if let state {
+ self.state = state
+ } else {
+ if let richText = readJSONFromFile(
+ fileName: "Sample_json",
+ type: RichText.self)
+ {
+ self.state = .init(richText: richText, handleImageDropAction: handleImages(_:_:))
+ } else {
+ self.state = .init(
+ input: "Hello World!",
+ handleImageDropAction: handleImages(_:_:))
+ }
- var body: some View {
- NavigationStack {
- VStack {
- #if os(macOS)
- RichTextFormat.Toolbar(context: state)
- #endif
-
- RichTextEditor(
- context: _state,
- viewConfiguration: { _ in
-
- }
- )
- .background(
- colorScheme == .dark ? .gray.opacity(0.3) : Color.white
- )
- .cornerRadius(10)
-
- #if os(iOS)
- RichTextKeyboardToolbar(
- context: state,
- leadingButtons: { $0 },
- trailingButtons: { $0 },
- formatSheet: { $0 }
- )
- #endif
- }
- #if os(iOS) || os(macOS)
- .inspector(isPresented: $isInspectorPresented) {
- RichTextFormat.Sidebar(context: state)
- #if os(macOS)
- .inspectorColumnWidth(
- min: 200, ideal: 200, max: 315)
- #endif
- }
- #endif
- .padding(10)
- #if os(iOS) || os(macOS)
- .toolbar {
- ToolbarItemGroup(placement: .automatic) {
- toolBarGroup
- }
- }
- #endif
- .background(colorScheme == .dark ? .black : .gray.opacity(0.07))
- .navigationTitle("Rich Editor")
- .alert("Enter file name", isPresented: getBindingAlert()) {
- TextField("Enter file name", text: $fileName)
- Button("OK", action: submit)
- } message: {
- Text("Please enter file name")
- }
- .focusedValue(\.richEditorState, state)
- .toolbarRole(.automatic)
- #if os(iOS) || os(macOS) || os(visionOS)
- .richTextFormatSheetConfig(.init(colorPickers: colorPickers))
- .richTextFormatSidebarConfig(
- .init(
- colorPickers: colorPickers,
- fontPicker: isMac
- )
- )
- .richTextFormatToolbarConfig(.init(colorPickers: []))
- #endif
- }
}
+ }
- #if os(iOS) || os(macOS)
- var toolBarGroup: some View {
- return Group {
- RichTextExportMenu.init(
- formatAction: { format in
- exportFormat = format
- },
- otherOptionAction: { format in
- otherExportFormat = format
- }
- )
- #if !os(macOS)
- .frame(width: 25, alignment: .center)
- #endif
- Button(
- action: {
- print("Exported JSON == \(state.outputAsString())")
- },
- label: {
- Image(systemName: "printer.inverse")
- }
- )
- #if !os(macOS)
- .frame(width: 25, alignment: .center)
- #endif
- Toggle(isOn: $isInspectorPresented) {
- Image.richTextFormatBrush
- .resizable()
- .aspectRatio(1, contentMode: .fit)
- }
- #if !os(macOS)
- .frame(width: 25, alignment: .center)
- #endif
- }
- }
- #endif
-
- func getBindingAlert() -> Binding {
- .init(
- get: { exportFormat != nil || otherExportFormat != nil },
- set: { newValue in
- exportFormat = nil
- otherExportFormat = nil
- })
- }
-
- func submit() {
- guard !fileName.isEmpty else { return }
- var path: URL?
+ var body: some View {
+ NavigationStack {
+ VStack {
+ #if os(macOS)
+ RichTextFormat.Toolbar(context: state)
+ #endif
- if let exportFormat {
- path = try? exportService.generateExportFile(
- withName: fileName, content: state.attributedString,
- format: exportFormat)
+ RichTextEditor(
+ context: _state,
+ viewConfiguration: { _ in
+
+ }
+ )
+ .background(
+ colorScheme == .dark ? .gray.opacity(0.3) : Color.white
+ )
+ .cornerRadius(10)
+
+ #if os(iOS)
+ RichTextKeyboardToolbar(
+ context: state,
+ leadingButtons: { $0 },
+ trailingButtons: { $0 },
+ formatSheet: { $0 }
+ )
+ #endif
+ }
+ #if os(iOS) || os(macOS)
+ .inspector(isPresented: $isInspectorPresented) {
+ RichTextFormat.Sidebar(context: state)
+ #if os(macOS)
+ .inspectorColumnWidth(
+ min: 200, ideal: 200, max: 315)
+ #endif
}
- if let otherExportFormat {
- switch otherExportFormat {
- case .pdf:
- path = try? exportService.generatePdfExportFile(
- withName: fileName, content: state.attributedString)
- case .json:
- path = try? exportService.generateJsonExportFile(
- withName: fileName, content: state.richText)
- }
+ #endif
+ .padding(10)
+ #if os(iOS) || os(macOS)
+ .toolbar {
+ ToolbarItemGroup(placement: .automatic) {
+ toolBarGroup
+ }
}
- if let path {
- print("Exported at path == \(path)")
+ #endif
+ .background(colorScheme == .dark ? .black : .gray.opacity(0.07))
+ .navigationTitle("Rich Editor")
+ .alert("Enter file name", isPresented: getBindingAlert()) {
+ TextField("Enter file name", text: $fileName)
+ Button("OK", action: submit)
+ } message: {
+ Text("Please enter file name")
+ }
+ .focusedValue(\.richEditorState, state)
+ .toolbarRole(.automatic)
+ #if os(iOS) || os(macOS) || os(visionOS)
+ .richTextFormatSheetConfig(.init(colorPickers: colorPickers))
+ .richTextFormatSidebarConfig(
+ .init(
+ colorPickers: colorPickers,
+ fontPicker: isMac
+ )
+ )
+ .richTextFormatToolbarConfig(.init(colorPickers: []))
+ #endif
+ }
+ }
+
+ #if os(iOS) || os(macOS)
+ var toolBarGroup: some View {
+ return Group {
+ RichTextExportMenu.init(
+ formatAction: { format in
+ exportFormat = format
+ },
+ otherOptionAction: { format in
+ otherExportFormat = format
+ }
+ )
+ #if !os(macOS)
+ .frame(width: 25, alignment: .center)
+ #endif
+ Button(
+ action: {
+ print("Exported JSON == \(state.outputAsString())")
+ },
+ label: {
+ Image(systemName: "printer.inverse")
+ }
+ )
+ #if !os(macOS)
+ .frame(width: 25, alignment: .center)
+ #endif
+ Toggle(isOn: $isInspectorPresented) {
+ Image.richTextFormatBrush
+ .resizable()
+ .aspectRatio(1, contentMode: .fit)
}
+ #if !os(macOS)
+ .frame(width: 25, alignment: .center)
+ #endif
+ }
}
+ #endif
+
+ func getBindingAlert() -> Binding {
+ .init(
+ get: { exportFormat != nil || otherExportFormat != nil },
+ set: { newValue in
+ exportFormat = nil
+ otherExportFormat = nil
+ })
+ }
+
+ func submit() {
+ guard !fileName.isEmpty else { return }
+ var path: URL?
+
+ if let exportFormat {
+ path = try? exportService.generateExportFile(
+ withName: fileName, content: state.attributedString,
+ format: exportFormat)
+ }
+ if let otherExportFormat {
+ switch otherExportFormat {
+ case .pdf:
+ path = try? exportService.generatePdfExportFile(
+ withName: fileName, content: state.attributedString)
+ case .json:
+ path = try? exportService.generateJsonExportFile(
+ withName: fileName, content: state.richText)
+ }
+ }
+ if let path {
+ print("Exported at path == \(path)")
+ }
+ }
}
extension ContentView {
- var isMac: Bool {
- #if os(macOS)
- true
- #else
- false
- #endif
- }
+ var isMac: Bool {
+ #if os(macOS)
+ true
+ #else
+ false
+ #endif
+ }
- var colorPickers: [RichTextColor] {
- [.foreground, .background]
- }
+ var colorPickers: [RichTextColor] {
+ [.foreground, .background]
+ }
- var formatToolbarEdge: VerticalEdge {
- isMac ? .top : .bottom
- }
+ var formatToolbarEdge: VerticalEdge {
+ isMac ? .top : .bottom
+ }
+}
+
+private func handleImages(
+ _ action: ImageAttachmentAction,
+ _ onCompletion: ((ImageAttachmentCompleteAction) -> Void)?
+) {
+ switch action {
+ case .save(let images):
+ images.forEach({
+ $0.updateUrl(with: "https://example.com/image/\($0.id).jpg")
+ })
+ onCompletion?(.saved(images))
+ case .delete(_):
+ onCompletion?(.deleted)
+ return
+ case .getImage(_):
+ onCompletion?(.getImage(nil))
+ return
+ case .getImages(_):
+ onCompletion?(.getImages([]))
+ return
+ }
}
diff --git a/RichEditorDemo/RichEditorDemo/ImageFileManager.swift b/RichEditorDemo/RichEditorDemo/ImageFileManager.swift
new file mode 100644
index 0000000..181dd24
--- /dev/null
+++ b/RichEditorDemo/RichEditorDemo/ImageFileManager.swift
@@ -0,0 +1,114 @@
+//
+// ImageFileManager.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 30/12/24.
+//
+
+import CryptoKit
+import Foundation
+
+class ImageFileManager {
+
+ static let shared = ImageFileManager()
+
+ private let fileManager = FileManager.default
+ private let imagesDirectory: URL
+
+ private init() {
+ // Define a directory for storing images.
+ let directory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
+ imagesDirectory = directory.appendingPathComponent("Images")
+
+ // Create the directory if it doesn't exist.
+ if !fileManager.fileExists(atPath: imagesDirectory.path) {
+ try? fileManager.createDirectory(
+ at: imagesDirectory, withIntermediateDirectories: true, attributes: nil)
+ }
+ }
+
+ /// Saves an image to the filesystem if it is not already stored.
+ /// - Parameters:
+ /// - image: The image to save.
+ /// - fileName: The name of the file (without extension).
+ /// - compressionQuality: Compression quality for JPEG (0.0 - 1.0).
+ /// - Returns: The file name of the stored image.
+ func saveImage(_ image: ImageRepresentable, fileName: String, compressionQuality: CGFloat = 1.0)
+ throws -> String
+ {
+ let imageHash = try hashImage(image)
+ let uniqueFileName = "\(fileName)_\(imageHash).jpg"
+ let fileURL = imagesDirectory.appendingPathComponent(uniqueFileName)
+
+ if fileManager.fileExists(atPath: fileURL.path) {
+ return uniqueFileName // Image already exists; return its file name.
+ }
+
+ #if canImport(AppKit)
+ guard let data = image.jpegData(compressionQuality: compressionQuality) else {
+ throw NSError(
+ domain: "ImageFileManager", code: 500,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to data."])
+ }
+ #elseif canImport(UIKit)
+ guard let data = image.jpegData(compressionQuality: compressionQuality) else {
+ throw NSError(
+ domain: "ImageFileManager", code: 500,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to data."])
+ }
+ #endif
+
+ try data.write(to: fileURL)
+ return uniqueFileName
+ }
+
+ /// Retrieves an image from the filesystem.
+ /// - Parameter fileName: The name of the file (without extension).
+ /// - Returns: The retrieved image or nil if not found.
+ func loadImage(fileName: String) -> ImageRepresentable? {
+ let fileURL = imagesDirectory.appendingPathComponent(fileName)
+
+ #if canImport(AppKit)
+ return ImageRepresentable(contentsOfFile: fileURL.path)
+ #elseif canImport(UIKit)
+ return ImageRepresentable(contentsOfFile: fileURL.path)
+ #endif
+ }
+
+ /// Deletes an image from the filesystem.
+ /// - Parameter fileName: The name of the file (without extension).
+ func deleteImage(fileName: String) throws {
+ let fileURL = imagesDirectory.appendingPathComponent(fileName)
+ try fileManager.removeItem(at: fileURL)
+ }
+
+ /// Checks if an image exists in the filesystem.
+ /// - Parameter fileName: The name of the file (without extension).
+ /// - Returns: `true` if the image exists, otherwise `false`.
+ func imageExists(fileName: String) -> Bool {
+ let fileURL = imagesDirectory.appendingPathComponent(fileName)
+ return fileManager.fileExists(atPath: fileURL.path)
+ }
+
+ /// Computes a unique hash for an image.
+ /// - Parameter image: The image to hash.
+ /// - Returns: A string representing the hash of the image.
+ private func hashImage(_ image: ImageRepresentable) throws -> String {
+ #if canImport(AppKit)
+ guard let data = image.tiffRepresentation else {
+ throw NSError(
+ domain: "ImageFileManager", code: 500,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to get TIFF data for image."])
+ }
+ #elseif canImport(UIKit)
+ guard let data = image.pngData() else {
+ throw NSError(
+ domain: "ImageFileManager", code: 500,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to get PNG data for image."])
+ }
+ #endif
+
+ let hash = SHA256.hash(data: data)
+ return hash.map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift b/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift
index 532080c..b166383 100644
--- a/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift
+++ b/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift
@@ -15,179 +15,179 @@ import SwiftUI
/// types and views, like ``RichTextAction/Button``.
public enum RichTextAction: Identifiable, Equatable {
- /// Copy the currently selected text, if any.
- case copy
+ /// Copy the currently selected text, if any.
+ case copy
- /// Dismiss any presented software keyboard.
- case dismissKeyboard
+ /// Dismiss any presented software keyboard.
+ case dismissKeyboard
- /// Paste a single image.
- // case pasteImage(RichTextInsertion)
- //
- // /// Paste multiple images.
- // case pasteImages(RichTextInsertion<[ImageRepresentable]>)
- //
- // /// Paste plain text.
- // case pasteText(RichTextInsertion)
+ /// Paste a single image.
+ case pasteImage(RichTextInsertion)
- /// A print command.
- case print
+ /// Paste multiple images.
+ case pasteImages(RichTextInsertion<[ImageRepresentable]>)
- /// Redo the latest undone change.
- case redoLatestChange
+ /// Paste plain text.
+ case pasteText(RichTextInsertion)
- /// Select a range.
- case selectRange(NSRange)
+ /// A print command.
+ case print
- /// Set the text alignment.
- case setAlignment(_ alignment: RichTextAlignment)
+ /// Redo the latest undone change.
+ case redoLatestChange
- /// Set the entire attributed string.
- case setAttributedString(NSAttributedString)
+ /// Select a range.
+ case selectRange(NSRange)
- // Change background color
- case setColor(RichTextColor, ColorRepresentable)
+ /// Set the text alignment.
+ case setAlignment(_ alignment: RichTextAlignment)
- // Highlighted renge
- case setHighlightedRange(NSRange?)
+ /// Set the entire attributed string.
+ case setAttributedString(NSAttributedString)
- // Change highlighting style
- case setHighlightingStyle(RichTextHighlightingStyle)
+ // Change background color
+ case setColor(RichTextColor, ColorRepresentable)
- /// Set a certain ``RichTextStyle``.
- case setStyle(RichTextStyle, Bool)
+ // Highlighted renge
+ case setHighlightedRange(NSRange?)
- /// Step the font size.
- case stepFontSize(points: Int)
+ // Change highlighting style
+ case setHighlightingStyle(RichTextHighlightingStyle)
- /// Step the indent level.
- case stepIndent(points: CGFloat)
+ /// Set a certain ``RichTextStyle``.
+ case setStyle(RichTextStyle, Bool)
- /// Step the line spacing.
- case stepLineSpacing(points: CGFloat)
+ /// Step the font size.
+ case stepFontSize(points: Int)
- /// Step the superscript level.
- case stepSuperscript(steps: Int)
+ /// Step the indent level.
+ case stepIndent(points: CGFloat)
- /// Toggle a certain style.
- case toggleStyle(_ style: RichTextStyle)
+ /// Step the line spacing.
+ case stepLineSpacing(points: CGFloat)
- /// Undo the latest change.
- case undoLatestChange
+ /// Step the superscript level.
+ case stepSuperscript(steps: Int)
- /// Set HeaderStyle.
- case setHeaderStyle(_ style: RichTextSpanStyle)
+ /// Toggle a certain style.
+ case toggleStyle(_ style: RichTextStyle)
- /// Set link
- case setLink(String? = nil)
-}
+ /// Undo the latest change.
+ case undoLatestChange
-extension RichTextAction {
+ /// Set HeaderStyle.
+ case setHeaderStyle(_ style: RichTextSpanStyle)
- public typealias Publisher = PassthroughSubject
-
- /// The action's unique identifier.
- public var id: String { title }
-
- /// The action's standard icon.
- public var icon: Image {
- switch self {
- case .copy: .richTextCopy
- case .dismissKeyboard: .richTextDismissKeyboard
- // case .pasteImage: .richTextDocuments
- // case .pasteImages: .richTextDocuments
- // case .pasteText: .richTextDocuments
- case .print: .richTextPrint
- case .redoLatestChange: .richTextRedo
- case .selectRange: .richTextSelection
- case .setAlignment(let val): val.icon
- case .setAttributedString: .richTextDocument
- case .setColor(let color, _): color.icon
- case .setHighlightedRange: .richTextAlignmentCenter
- case .setHighlightingStyle: .richTextAlignmentCenter
- case .setStyle(let style, _): style.icon
- case .stepFontSize(let val): .richTextStepFontSize(val)
- case .stepIndent(let val): .richTextStepIndent(val)
- case .stepLineSpacing(let val): .richTextStepLineSpacing(val)
- case .stepSuperscript(let val): .richTextStepSuperscript(val)
- case .toggleStyle(let val): val.icon
- case .undoLatestChange: .richTextUndo
- case .setHeaderStyle: .richTextIgnoreIt
- case .setLink: .richTextLink
- }
- }
-
- /// The localized label to use for the action.
- public var label: some View {
- icon.label(title)
- }
+ /// Set link
+ case setLink(String? = nil)
+}
- /// The localized title to use in the main menu.
- public var menuTitle: String {
- menuTitleKey.text
- }
+extension RichTextAction {
- /// The localized title key to use in the main menu.
- public var menuTitleKey: RTEL10n {
- switch self {
- case .stepIndent(let points): .menuIndent(points)
- default: titleKey
- }
+ public typealias Publisher = PassthroughSubject
+
+ /// The action's unique identifier.
+ public var id: String { title }
+
+ /// The action's standard icon.
+ public var icon: Image {
+ switch self {
+ case .copy: .richTextCopy
+ case .dismissKeyboard: .richTextDismissKeyboard
+ case .pasteImage: .richTextDocuments
+ case .pasteImages: .richTextDocuments
+ case .pasteText: .richTextDocuments
+ case .print: .richTextPrint
+ case .redoLatestChange: .richTextRedo
+ case .selectRange: .richTextSelection
+ case .setAlignment(let val): val.icon
+ case .setAttributedString: .richTextDocument
+ case .setColor(let color, _): color.icon
+ case .setHighlightedRange: .richTextAlignmentCenter
+ case .setHighlightingStyle: .richTextAlignmentCenter
+ case .setStyle(let style, _): style.icon
+ case .stepFontSize(let val): .richTextStepFontSize(val)
+ case .stepIndent(let val): .richTextStepIndent(val)
+ case .stepLineSpacing(let val): .richTextStepLineSpacing(val)
+ case .stepSuperscript(let val): .richTextStepSuperscript(val)
+ case .toggleStyle(let val): val.icon
+ case .undoLatestChange: .richTextUndo
+ case .setHeaderStyle: .richTextIgnoreIt
+ case .setLink: .richTextLink
}
-
- /// The localized action title.
- public var title: String {
- titleKey.text
+ }
+
+ /// The localized label to use for the action.
+ public var label: some View {
+ icon.label(title)
+ }
+
+ /// The localized title to use in the main menu.
+ public var menuTitle: String {
+ menuTitleKey.text
+ }
+
+ /// The localized title key to use in the main menu.
+ public var menuTitleKey: RTEL10n {
+ switch self {
+ case .stepIndent(let points): .menuIndent(points)
+ default: titleKey
}
-
- /// The localized action title key.
- public var titleKey: RTEL10n {
- switch self {
- case .copy: .actionCopy
- case .dismissKeyboard: .actionDismissKeyboard
- // case .pasteImage: .pasteImage
- // case .pasteImages: .pasteImages
- // case .pasteText: .pasteText
- case .print: .actionPrint
- case .redoLatestChange: .actionRedoLatestChange
- case .selectRange: .selectRange
- case .setAlignment(let alignment): alignment.titleKey
- case .setAttributedString: .setAttributedString
- case .setColor(let color, _): color.titleKey
- case .setHighlightedRange: .highlightedRange
- case .setHighlightingStyle: .highlightingStyle
- case .setStyle(let style, _): style.titleKey
- case .stepFontSize(let points): .actionStepFontSize(points)
- case .stepIndent(let points): .actionStepIndent(points)
- case .stepLineSpacing(let points): .actionStepLineSpacing(points)
- case .stepSuperscript(let steps): .actionStepSuperscript(steps)
- case .toggleStyle(let style): style.titleKey
- case .undoLatestChange: .actionUndoLatestChange
- case .setLink: .link
- case .setHeaderStyle: .ignoreIt
- }
+ }
+
+ /// The localized action title.
+ public var title: String {
+ titleKey.text
+ }
+
+ /// The localized action title key.
+ public var titleKey: RTEL10n {
+ switch self {
+ case .copy: .actionCopy
+ case .dismissKeyboard: .actionDismissKeyboard
+ case .pasteImage: .pasteImage
+ case .pasteImages: .pasteImages
+ case .pasteText: .pasteText
+ case .print: .actionPrint
+ case .redoLatestChange: .actionRedoLatestChange
+ case .selectRange: .selectRange
+ case .setAlignment(let alignment): alignment.titleKey
+ case .setAttributedString: .setAttributedString
+ case .setColor(let color, _): color.titleKey
+ case .setHighlightedRange: .highlightedRange
+ case .setHighlightingStyle: .highlightingStyle
+ case .setStyle(let style, _): style.titleKey
+ case .stepFontSize(let points): .actionStepFontSize(points)
+ case .stepIndent(let points): .actionStepIndent(points)
+ case .stepLineSpacing(let points): .actionStepLineSpacing(points)
+ case .stepSuperscript(let steps): .actionStepSuperscript(steps)
+ case .toggleStyle(let style): style.titleKey
+ case .undoLatestChange: .actionUndoLatestChange
+ case .setLink: .link
+ case .setHeaderStyle: .ignoreIt
}
+ }
}
// MARK: - Aliases
extension RichTextAction {
- /// A name alias for `.redoLatestChange`.
- public static var redo: RichTextAction { .redoLatestChange }
+ /// A name alias for `.redoLatestChange`.
+ public static var redo: RichTextAction { .redoLatestChange }
- /// A name alias for `.undoLatestChange`.
- public static var undo: RichTextAction { .undoLatestChange }
+ /// A name alias for `.undoLatestChange`.
+ public static var undo: RichTextAction { .undoLatestChange }
}
extension CGFloat {
- /// The default rich text indent step size.
- public static var defaultRichTextIntentStepSize: CGFloat = 30.0
+ /// The default rich text indent step size.
+ public static var defaultRichTextIntentStepSize: CGFloat = 30.0
}
extension UInt {
- /// The default rich text indent step size.
- public static var defaultRichTextIntentStepSize: UInt = 30
+ /// The default rich text indent step size.
+ public static var defaultRichTextIntentStepSize: UInt = 30
}
diff --git a/Sources/RichEditorSwiftUI/Actions/RichTextInsertion.swift b/Sources/RichEditorSwiftUI/Actions/RichTextInsertion.swift
new file mode 100644
index 0000000..02ec355
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Actions/RichTextInsertion.swift
@@ -0,0 +1,94 @@
+//
+// RichTextInsertion.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 27/12/24.
+//
+
+import Foundation
+
+/// This protocol can be implemented by anything that can be
+/// inserted into a rich text.
+public protocol RichTextInsertable: Hashable, Equatable {}
+
+extension String: RichTextInsertable {}
+extension ImageRepresentable: RichTextInsertable {}
+extension [ImageRepresentable]: RichTextInsertable {}
+extension NSAttributedString: RichTextInsertable {}
+
+/// This struct represents something that should be inserted
+/// into a rich text attributed string.
+public struct RichTextInsertion: Hashable, Equatable {
+
+ /// Create a rich text insertion.
+ ///
+ /// - Parameters:
+ /// - content: The content to insert.
+ /// - index: The index at where to insert.
+ /// - moveCursor: Whether or not to move the cursor to the insertion point.
+ public init(
+ content: T,
+ index: Int,
+ moveCursor: Bool
+ ) {
+ self.content = content
+ self.index = index
+ self.moveCursor = moveCursor
+ }
+
+ /// The content to insert.
+ public let content: T
+
+ /// The index at where to insert.
+ public let index: Int
+
+ /// Whether or not to move the cursor to the insertion point.
+ public let moveCursor: Bool
+}
+
+extension RichTextInsertion {
+
+ /// The corresponding rich text action.
+ public var action: RichTextAction? {
+ if let insertion = self as? RichTextInsertion {
+ return .pasteImage(insertion)
+ }
+ if let insertion = self as? RichTextInsertion<[ImageRepresentable]> {
+ return .pasteImages(insertion)
+ }
+ if let insertion = self as? RichTextInsertion {
+ return .pasteText(insertion)
+ }
+ return nil
+ }
+}
+
+extension RichTextInsertion {
+
+ /// This is a shorthand for creating an image insertion.
+ public static func image(
+ _ image: ImageRepresentable,
+ at index: Int,
+ moveCursor: Bool
+ ) -> RichTextInsertion {
+ .init(content: image, index: index, moveCursor: moveCursor)
+ }
+
+ /// This is a shorthand for creating an image insertion.
+ public static func images(
+ _ images: [ImageRepresentable],
+ at index: Int,
+ moveCursor: Bool
+ ) -> RichTextInsertion<[ImageRepresentable]> {
+ .init(content: images, index: index, moveCursor: moveCursor)
+ }
+
+ /// This is a shorthand for creating a text insertion.
+ public static func text(
+ _ text: String,
+ at index: Int,
+ moveCursor: Bool
+ ) -> RichTextInsertion {
+ .init(content: text, index: index, moveCursor: moveCursor)
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift
index 89b7711..f2aae8b 100644
--- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift
+++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift
@@ -8,205 +8,205 @@
import Foundation
#if os(iOS) || os(macOS) || os(tvOS) || os(visionOS)
- import SwiftUI
-
- extension RichTextCoordinator {
-
- func handle(_ action: RichTextAction?) {
- guard let action else { return }
- switch action {
- case .copy: textView.copySelection()
- case .dismissKeyboard:
- textView.resignFirstResponder()
- // case .pasteImage(let image):
- // pasteImage(image)
- // case .pasteImages(let images):
- // pasteImages(images)
- // case .pasteText(let text):
- // pasteText(text)
- case .print:
- break
- case .redoLatestChange:
- textView.redoLatestChange()
- syncContextWithTextView()
- case .selectRange(let range):
- setSelectedRange(to: range)
- case .setAlignment(let alignment):
- textView.setRichTextAlignment(alignment)
- case .setAttributedString(let string):
- setAttributedString(to: string)
- case .setColor(let color, let newValue):
- setColor(color, to: newValue)
- case .setHighlightedRange(let range):
- setHighlightedRange(to: range)
- case .setHighlightingStyle(let style):
- textView.highlightingStyle = style
- case .setStyle(let style, let newValue):
- setStyle(style, to: newValue)
- case .stepFontSize(let points):
- textView.stepRichTextFontSize(points: points)
- syncContextWithTextView()
- case .stepIndent(_):
- // textView.stepRichTextIndent(points: points)
- return
- case .stepLineSpacing(_):
- // textView.stepRichTextLineSpacing(points: points)
- return
- case .stepSuperscript(_):
- // textView.stepRichTextSuperscriptLevel(points: points)
- return
- case .toggleStyle(_):
- // textView.toggleRichTextStyle(style)
- return
- case .undoLatestChange:
- textView.undoLatestChange()
- syncContextWithTextView()
- case .setHeaderStyle(let style):
- let size = style.fontSizeMultiplier * .standardRichTextFontSize
- let range = textView.textString.getHeaderRangeFor(
- textView.selectedRange
- )
- var font = textView.richTextFont(at: range)
- font = font?.withSize(size)
- textView
- .setRichTextFont(font ?? .standardRichTextFont, at: range)
- case .setLink(let link):
- if let link, link != self.context.link {
- setLink(link)
- } else {
- removeLink()
- }
- }
+ import SwiftUI
+
+ extension RichTextCoordinator {
+
+ func handle(_ action: RichTextAction?) {
+ guard let action else { return }
+ switch action {
+ case .copy: textView.copySelection()
+ case .dismissKeyboard:
+ textView.resignFirstResponder()
+ case .pasteImage(let image):
+ pasteImage(image)
+ case .pasteImages(let images):
+ pasteImages(images)
+ case .pasteText(let text):
+ pasteText(text)
+ case .print:
+ break
+ case .redoLatestChange:
+ textView.redoLatestChange()
+ syncContextWithTextView()
+ case .selectRange(let range):
+ setSelectedRange(to: range)
+ case .setAlignment(let alignment):
+ textView.setRichTextAlignment(alignment)
+ case .setAttributedString(let string):
+ setAttributedString(to: string)
+ case .setColor(let color, let newValue):
+ setColor(color, to: newValue)
+ case .setHighlightedRange(let range):
+ setHighlightedRange(to: range)
+ case .setHighlightingStyle(let style):
+ textView.highlightingStyle = style
+ case .setStyle(let style, let newValue):
+ setStyle(style, to: newValue)
+ case .stepFontSize(let points):
+ textView.stepRichTextFontSize(points: points)
+ syncContextWithTextView()
+ case .stepIndent(_):
+ // textView.stepRichTextIndent(points: points)
+ return
+ case .stepLineSpacing(_):
+ // textView.stepRichTextLineSpacing(points: points)
+ return
+ case .stepSuperscript(_):
+ // textView.stepRichTextSuperscriptLevel(points: points)
+ return
+ case .toggleStyle(_):
+ // textView.toggleRichTextStyle(style)
+ return
+ case .undoLatestChange:
+ textView.undoLatestChange()
+ syncContextWithTextView()
+ case .setHeaderStyle(let style):
+ let size = style.fontSizeMultiplier * .standardRichTextFontSize
+ let range = textView.textString.getHeaderRangeFor(
+ textView.selectedRange
+ )
+ var font = textView.richTextFont(at: range)
+ font = font?.withSize(size)
+ textView
+ .setRichTextFont(font ?? .standardRichTextFont, at: range)
+ case .setLink(let link):
+ if let link, link != self.context.link {
+ setLink(link)
+ } else {
+ removeLink()
}
+ }
+ }
+ }
+
+ extension RichTextCoordinator {
+
+ func paste(_ data: RichTextInsertion) {
+ if let data = data as? RichTextInsertion {
+ pasteImage(data)
+ } else if let data = data as? RichTextInsertion<[ImageRepresentable]> {
+ pasteImages(data)
+ } else if let data = data as? RichTextInsertion {
+ pasteText(data)
+ } else {
+ print("Unsupported media type")
+ }
}
- extension RichTextCoordinator {
-
- // func paste(_ data: RichTextInsertion) {
- // if let data = data as? RichTextInsertion {
- // pasteImage(data)
- // } else if let data = data as? RichTextInsertion<[ImageRepresentable]> {
- // pasteImages(data)
- // } else if let data = data as? RichTextInsertion {
- // pasteText(data)
- // } else {
- // print("Unsupported media type")
- // }
- // }
- //
- // func pasteImage(_ data: RichTextInsertion) {
- // textView.pasteImage(
- // data.content,
- // at: data.index,
- // moveCursorToPastedContent: data.moveCursor
- // )
- // }
- //
- // func pasteImages(_ data: RichTextInsertion<[ImageRepresentable]>) {
- // textView.pasteImages(
- // data.content,
- // at: data.index,
- // moveCursorToPastedContent: data.moveCursor
- // )
- // }
-
- // func pasteText(_ data: RichTextInsertion) {
- // textView.pasteText(
- // data.content,
- // at: data.index,
- // moveCursorToPastedContent: data.moveCursor
- // )
- // }
-
- func setAttributedString(to newValue: NSAttributedString?) {
- guard let newValue else { return }
- textView.setRichText(newValue)
- }
+ func pasteImage(_ data: RichTextInsertion) {
+ let insertedString = textView.pasteImage(
+ data.content,
+ at: data.index,
+ moveCursorToPastedContent: data.moveCursor
+ )
+ }
- // TODO: This code should be handled by the component
- func setColor(_ color: RichTextColor, to val: ColorRepresentable) {
- var applyRange: NSRange?
- if textView.hasSelectedRange {
- applyRange = textView.selectedRange
- }
- guard let attribute = color.attribute else { return }
- if let applyRange {
- textView.setRichTextColor(color, to: val, at: applyRange)
- } else {
- textView.setRichTextAttribute(attribute, to: val)
- }
- }
+ func pasteImages(_ data: RichTextInsertion<[ImageRepresentable]>) {
+ let insertedString = textView.pasteImages(
+ data.content,
+ at: data.index,
+ moveCursorToPastedContent: data.moveCursor
+ )
+ }
- func setHighlightedRange(to range: NSRange?) {
- resetHighlightedRangeAppearance()
- guard let range = range else { return }
- setHighlightedRangeAppearance(for: range)
- }
+ func pasteText(_ data: RichTextInsertion) {
+ textView.pasteText(
+ data.content,
+ at: data.index,
+ moveCursorToPastedContent: data.moveCursor
+ )
+ }
- func setHighlightedRangeAppearance(for range: NSRange) {
- let back = textView.richTextColor(.background, at: range) ?? .clear
- let fore =
- textView.richTextColor(.foreground, at: range) ?? .textColor
- highlightedRangeOriginalBackgroundColor = back
- highlightedRangeOriginalForegroundColor = fore
- let style = textView.highlightingStyle
- let background = ColorRepresentable(style.backgroundColor)
- let foreground = ColorRepresentable(style.foregroundColor)
- textView.setRichTextColor(.background, to: background, at: range)
- textView.setRichTextColor(.foreground, to: foreground, at: range)
- }
+ func setAttributedString(to newValue: NSAttributedString?) {
+ guard let newValue else { return }
+ textView.setRichText(newValue)
+ }
- func setIsEditable(to newValue: Bool) {
- #if os(iOS) || os(macOS) || os(visionOS)
- if newValue == textView.isEditable { return }
- textView.isEditable = newValue
- #endif
- }
+ // TODO: This code should be handled by the component
+ func setColor(_ color: RichTextColor, to val: ColorRepresentable) {
+ var applyRange: NSRange?
+ if textView.hasSelectedRange {
+ applyRange = textView.selectedRange
+ }
+ guard let attribute = color.attribute else { return }
+ if let applyRange {
+ textView.setRichTextColor(color, to: val, at: applyRange)
+ } else {
+ textView.setRichTextAttribute(attribute, to: val)
+ }
+ }
- func setIsEditing(to newValue: Bool) {
- if newValue == textView.isFirstResponder { return }
- if newValue {
- #if os(iOS) || os(visionOS)
- textView.becomeFirstResponder()
- #else
- print("macOS currently doesn't resign first responder.")
- #endif
- } else {
- #if os(iOS) || os(visionOS)
- textView.resignFirstResponder()
- #else
- print("macOS currently doesn't resign first responder.")
- #endif
- }
- }
+ func setHighlightedRange(to range: NSRange?) {
+ resetHighlightedRangeAppearance()
+ guard let range = range else { return }
+ setHighlightedRangeAppearance(for: range)
+ }
- func setSelectedRange(to range: NSRange) {
- if range == textView.selectedRange { return }
- textView.selectedRange = range
- }
+ func setHighlightedRangeAppearance(for range: NSRange) {
+ let back = textView.richTextColor(.background, at: range) ?? .clear
+ let fore =
+ textView.richTextColor(.foreground, at: range) ?? .textColor
+ highlightedRangeOriginalBackgroundColor = back
+ highlightedRangeOriginalForegroundColor = fore
+ let style = textView.highlightingStyle
+ let background = ColorRepresentable(style.backgroundColor)
+ let foreground = ColorRepresentable(style.foregroundColor)
+ textView.setRichTextColor(.background, to: background, at: range)
+ textView.setRichTextColor(.foreground, to: foreground, at: range)
+ }
- func setStyle(_ style: RichTextStyle, to newValue: Bool) {
- let hasStyle = textView.richTextStyles.hasStyle(style)
- guard newValue != hasStyle else { return }
- textView.setRichTextStyle(style, to: newValue)
- }
+ func setIsEditable(to newValue: Bool) {
+ #if os(iOS) || os(macOS) || os(visionOS)
+ if newValue == textView.isEditable { return }
+ textView.isEditable = newValue
+ #endif
+ }
- func setLink(_ link: String) {
- let style: RichTextSpanStyle = .link(link)
- var applyRange: NSRange?
- if textView.hasSelectedRange {
- applyRange = textView.selectedRange
- }
- textView.setRichTextLink(.link(link))
- if let applyRange {
- textView.setRichTextLink(style, at: applyRange)
- } else {
- textView.setRichTextLink(style)
- }
- }
+ func setIsEditing(to newValue: Bool) {
+ if newValue == textView.isFirstResponder { return }
+ if newValue {
+ #if os(iOS) || os(visionOS)
+ textView.becomeFirstResponder()
+ #else
+ print("macOS currently doesn't resign first responder.")
+ #endif
+ } else {
+ #if os(iOS) || os(visionOS)
+ textView.resignFirstResponder()
+ #else
+ print("macOS currently doesn't resign first responder.")
+ #endif
+ }
+ }
- func removeLink() {
- textView.removeRichTextLink(.link())
- }
+ func setSelectedRange(to range: NSRange) {
+ if range == textView.selectedRange { return }
+ textView.selectedRange = range
+ }
+
+ func setStyle(_ style: RichTextStyle, to newValue: Bool) {
+ let hasStyle = textView.richTextStyles.hasStyle(style)
+ guard newValue != hasStyle else { return }
+ textView.setRichTextStyle(style, to: newValue)
+ }
+
+ func setLink(_ link: String) {
+ let style: RichTextSpanStyle = .link(link)
+ var applyRange: NSRange?
+ if textView.hasSelectedRange {
+ applyRange = textView.selectedRange
+ }
+ textView.setRichTextLink(.link(link))
+ if let applyRange {
+ textView.setRichTextLink(style, at: applyRange)
+ } else {
+ textView.setRichTextLink(style)
+ }
+ }
+
+ func removeLink() {
+ textView.removeRichTextLink(.link())
}
+ }
#endif
diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift
index c35f957..c69e086 100644
--- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift
+++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift
@@ -5,56 +5,56 @@
// Created by Divyesh Vekariya on 21/10/24.
//
#if os(iOS) || os(macOS) || os(tvOS) || os(visionOS)
- import Combine
- import SwiftUI
+ import Combine
+ import SwiftUI
- /// This coordinator is used to keep a ``RichTextView`` in sync
- /// with a ``RichEditorState``.
- ///
- /// This is used by ``RichTextEditor`` to coordinate changes in
- /// its context and the underlying text view.
- ///
- /// The coordinator sets itself as the text view's delegate. It
- /// updates the context when things change in the text view and
- /// syncs to context changes to the text view.
- open class RichTextCoordinator: NSObject {
+ /// This coordinator is used to keep a ``RichTextView`` in sync
+ /// with a ``RichEditorState``.
+ ///
+ /// This is used by ``RichTextEditor`` to coordinate changes in
+ /// its context and the underlying text view.
+ ///
+ /// The coordinator sets itself as the text view's delegate. It
+ /// updates the context when things change in the text view and
+ /// syncs to context changes to the text view.
+ open class RichTextCoordinator: NSObject {
- // MARK: - Properties
+ // MARK: - Properties
- /// The rich text context to coordinate with.
- public let context: RichEditorState
+ /// The rich text context to coordinate with.
+ public let context: RichEditorState
- /// The rich text to edit.
- public var text: Binding
+ /// The rich text to edit.
+ public var text: Binding
- /// The text view for which the coordinator is used.
- public private(set) var textView: RichTextView
+ /// The text view for which the coordinator is used.
+ public private(set) var textView: RichTextView
- /// This set is used to store context observations.
- public var cancellables = Set()
+ /// This set is used to store context observations.
+ public var cancellables = Set()
- /// This flag is used to avoid delaying context sync.
- var shouldDelaySyncContextWithTextView = false
+ /// This flag is used to avoid delaying context sync.
+ var shouldDelaySyncContextWithTextView = false
- // MARK: - Internal Properties
+ // MARK: - Internal Properties
- /**
+ /**
The background color that was used before the currently
highlighted range was set.
*/
- var highlightedRangeOriginalBackgroundColor: ColorRepresentable?
+ var highlightedRangeOriginalBackgroundColor: ColorRepresentable?
- /**
+ /**
The foreground color that was used before the currently
highlighted range was set.
*/
- var highlightedRangeOriginalForegroundColor: ColorRepresentable?
+ var highlightedRangeOriginalForegroundColor: ColorRepresentable?
- private var cancellable: Set = []
+ private var cancellable: Set = []
- // MARK: - Initialization
+ // MARK: - Initialization
- /**
+ /**
Create a rich text coordinator.
- Parameters:
@@ -62,211 +62,211 @@
- textView: The rich text view to keep in sync.
- richEditorState: The context to keep in sync.
*/
- public init(
- text: Binding,
- textView: RichTextView,
- richTextContext: RichEditorState
- ) {
- textView.attributedString = text.wrappedValue
- self.text = text
- self.textView = textView
- self.context = richTextContext
- super.init()
- self.textView.delegate = self
- subscribeToUserActions()
- }
- #if canImport(UIKit)
-
- // MARK: - UITextViewDelegate
-
- open func textViewDidBeginEditing(_ textView: UITextView) {
- context.onTextViewEvent(
- .didBeginEditing(
- selectedRange: textView.selectedRange,
- text: textView.attributedText
- )
- )
- context.isEditingText = true
- }
-
- open func textViewDidChange(_ textView: UITextView) {
- context.onTextViewEvent(
- .didChange(
- selectedRange: textView.selectedRange,
- text: textView.attributedText
- )
- )
- syncWithTextView()
- }
-
- open func textViewDidChangeSelection(_ textView: UITextView) {
- context.onTextViewEvent(
- .didChangeSelection(
- selectedRange: textView.selectedRange,
- text: textView.attributedText
- )
- )
- syncWithTextView()
- }
-
- open func textViewDidEndEditing(_ textView: UITextView) {
- context.onTextViewEvent(
- .didEndEditing(
- selectedRange: textView.selectedRange,
- text: textView.attributedText
- )
- )
- context.isEditingText = false
- syncWithTextView()
- }
- #endif
-
- #if canImport(AppKit) && !targetEnvironment(macCatalyst)
-
- // MARK: - NSTextViewDelegate
-
- open func textDidBeginEditing(_ notification: Notification) {
- context.onTextViewEvent(
- .didBeginEditing(
- selectedRange: textView.selectedRange,
- text: textView.attributedString()
- )
- )
- context.isEditingText = true
- }
-
- open func textDidChange(_ notification: Notification) {
- context.onTextViewEvent(
- .didChange(
- selectedRange: textView.selectedRange,
- text: textView.attributedString()
- )
- )
- syncWithTextView()
- }
-
- open func textViewDidChangeSelection(_ notification: Notification) {
- replaceCurrentAttributesIfNeeded()
- context.onTextViewEvent(
- .didChangeSelection(
- selectedRange: textView.selectedRange,
- text: textView.attributedString()
- )
- )
- syncWithTextView()
- }
-
- open func textDidEndEditing(_ notification: Notification) {
- context.onTextViewEvent(
- .didEndEditing(
- selectedRange: textView.selectedRange,
- text: textView.attributedString()
- )
- )
- context.isEditingText = false
- }
- #endif
+ public init(
+ text: Binding,
+ textView: RichTextView,
+ richTextContext: RichEditorState
+ ) {
+ textView.attributedString = text.wrappedValue
+ self.text = text
+ self.textView = textView
+ self.context = richTextContext
+ super.init()
+ self.textView.delegate = self
+ subscribeToUserActions()
}
+ #if canImport(UIKit)
+
+ // MARK: - UITextViewDelegate
+
+ open func textViewDidBeginEditing(_ textView: UITextView) {
+ context.onTextViewEvent(
+ .didBeginEditing(
+ selectedRange: textView.selectedRange,
+ text: textView.attributedText
+ )
+ )
+ context.isEditingText = true
+ }
+
+ open func textViewDidChange(_ textView: UITextView) {
+ context.onTextViewEvent(
+ .didChange(
+ selectedRange: textView.selectedRange,
+ text: textView.attributedText
+ )
+ )
+ syncWithTextView()
+ }
+
+ open func textViewDidChangeSelection(_ textView: UITextView) {
+ context.onTextViewEvent(
+ .didChangeSelection(
+ selectedRange: textView.selectedRange,
+ text: textView.attributedText
+ )
+ )
+ syncWithTextView()
+ }
+
+ open func textViewDidEndEditing(_ textView: UITextView) {
+ context.onTextViewEvent(
+ .didEndEditing(
+ selectedRange: textView.selectedRange,
+ text: textView.attributedText
+ )
+ )
+ context.isEditingText = false
+ syncWithTextView()
+ }
+ #endif
+
+ #if canImport(AppKit) && !targetEnvironment(macCatalyst)
+
+ // MARK: - NSTextViewDelegate
+
+ open func textDidBeginEditing(_ notification: Notification) {
+ context.onTextViewEvent(
+ .didBeginEditing(
+ selectedRange: textView.selectedRange,
+ text: textView.attributedString()
+ )
+ )
+ context.isEditingText = true
+ }
+
+ open func textDidChange(_ notification: Notification) {
+ context.onTextViewEvent(
+ .didChange(
+ selectedRange: textView.selectedRange,
+ text: textView.attributedString()
+ )
+ )
+ syncWithTextView()
+ }
+
+ open func textViewDidChangeSelection(_ notification: Notification) {
+ replaceCurrentAttributesIfNeeded()
+ context.onTextViewEvent(
+ .didChangeSelection(
+ selectedRange: textView.selectedRange,
+ text: textView.attributedString()
+ )
+ )
+ syncWithTextView()
+ }
+
+ open func textDidEndEditing(_ notification: Notification) {
+ context.onTextViewEvent(
+ .didEndEditing(
+ selectedRange: textView.selectedRange,
+ text: textView.attributedString()
+ )
+ )
+ context.isEditingText = false
+ }
+ #endif
+ }
- #if os(iOS) || os(tvOS) || os(visionOS)
- import UIKit
+ #if os(iOS) || os(tvOS) || os(visionOS)
+ import UIKit
- extension RichTextCoordinator: UITextViewDelegate {}
+ extension RichTextCoordinator: UITextViewDelegate {}
- #elseif macOS
- import AppKit
+ #elseif macOS
+ import AppKit
- extension RichTextCoordinator: NSTextViewDelegate {}
- #endif
+ extension RichTextCoordinator: NSTextViewDelegate {}
+ #endif
- // MARK: - Public Extensions
+ // MARK: - Public Extensions
- extension RichTextCoordinator {
+ extension RichTextCoordinator {
- /// Reset appearance for the currently highlighted range.
- public func resetHighlightedRangeAppearance() {
- guard
- let range = context.highlightedRange,
- let background = highlightedRangeOriginalBackgroundColor,
- let foreground = highlightedRangeOriginalForegroundColor
- else { return }
- textView.setRichTextColor(.background, to: background, at: range)
- textView.setRichTextColor(.foreground, to: foreground, at: range)
- }
+ /// Reset appearance for the currently highlighted range.
+ public func resetHighlightedRangeAppearance() {
+ guard
+ let range = context.highlightedRange,
+ let background = highlightedRangeOriginalBackgroundColor,
+ let foreground = highlightedRangeOriginalForegroundColor
+ else { return }
+ textView.setRichTextColor(.background, to: background, at: range)
+ textView.setRichTextColor(.foreground, to: foreground, at: range)
}
+ }
- // MARK: - Internal Extensions
+ // MARK: - Internal Extensions
- extension RichTextCoordinator {
+ extension RichTextCoordinator {
- /// Sync state from the text view's current state.
- func syncWithTextView() {
- syncContextWithTextView()
- syncTextWithTextView()
- }
+ /// Sync state from the text view's current state.
+ func syncWithTextView() {
+ syncContextWithTextView()
+ syncTextWithTextView()
+ }
- /// Sync the rich text context with the text view.
- func syncContextWithTextView() {
- if shouldDelaySyncContextWithTextView {
- DispatchQueue.main.async {
- self.syncContextWithTextViewAfterDelay()
- }
- } else {
- syncContextWithTextViewAfterDelay()
- }
+ /// Sync the rich text context with the text view.
+ func syncContextWithTextView() {
+ if shouldDelaySyncContextWithTextView {
+ DispatchQueue.main.async {
+ self.syncContextWithTextViewAfterDelay()
}
+ } else {
+ syncContextWithTextViewAfterDelay()
+ }
+ }
- func sync(_ prop: inout T, with value: T) {
- if prop == value { return }
- prop = value
- }
+ func sync(_ prop: inout T, with value: T) {
+ if prop == value { return }
+ prop = value
+ }
- /// Sync the rich text context with the text view.
- func syncContextWithTextViewAfterDelay() {
- let font = textView.richTextFont ?? .standardRichTextFont
- sync(&context.attributedString, with: textView.attributedString)
- sync(&context.selectedRange, with: textView.selectedRange)
- sync(&context.canCopy, with: textView.hasSelectedRange)
- sync(
- &context.canRedoLatestChange,
- with: textView.undoManager?.canRedo ?? false)
- sync(
- &context.canUndoLatestChange,
- with: textView.undoManager?.canUndo ?? false)
- sync(&context.fontName, with: font.fontName)
- sync(&context.fontSize, with: font.pointSize)
- sync(&context.isEditingText, with: textView.isFirstResponder)
- sync(
- &context.paragraphStyle,
- with: textView.richTextParagraphStyle ?? .default)
- sync(
- &context.textAlignment,
- with: textView.richTextAlignment ?? .left)
- sync(&context.link, with: textView.richTextLink)
-
- RichTextColor.allCases.forEach {
- if let color = textView.richTextColor($0) {
- context.setColor($0, to: color)
- }
- }
-
- let styles = textView.richTextStyles
- RichTextStyle.allCases.forEach {
- let style = styles.hasStyle($0)
- context.setStyleInternal($0, to: style)
- }
-
- updateTextViewAttributesIfNeeded()
+ /// Sync the rich text context with the text view.
+ func syncContextWithTextViewAfterDelay() {
+ let font = textView.richTextFont ?? .standardRichTextFont
+ sync(&context.attributedString, with: textView.attributedString)
+ sync(&context.selectedRange, with: textView.selectedRange)
+ sync(&context.canCopy, with: textView.hasSelectedRange)
+ sync(
+ &context.canRedoLatestChange,
+ with: textView.undoManager?.canRedo ?? false)
+ sync(
+ &context.canUndoLatestChange,
+ with: textView.undoManager?.canUndo ?? false)
+ sync(&context.fontName, with: font.fontName)
+ sync(&context.fontSize, with: font.pointSize)
+ sync(&context.isEditingText, with: textView.isFirstResponder)
+ sync(
+ &context.paragraphStyle,
+ with: textView.richTextParagraphStyle ?? .default)
+ sync(
+ &context.textAlignment,
+ with: textView.richTextAlignment ?? .left)
+ sync(&context.link, with: textView.richTextLink)
+
+ RichTextColor.allCases.forEach {
+ if let color = textView.richTextColor($0) {
+ context.setColor($0, to: color)
}
+ }
- /// Sync the text binding with the text view.
- func syncTextWithTextView() {
- DispatchQueue.main.async {
- self.text.wrappedValue = self.textView.attributedString
- }
- }
+ let styles = textView.richTextStyles
+ RichTextStyle.allCases.forEach {
+ let style = styles.hasStyle($0)
+ context.setStyleInternal($0, to: style)
+ }
- /**
+ updateTextViewAttributesIfNeeded()
+ }
+
+ /// Sync the text binding with the text view.
+ func syncTextWithTextView() {
+ DispatchQueue.main.async {
+ self.text.wrappedValue = self.textView.attributedString
+ }
+ }
+
+ /**
On macOS, we have to update the font and colors when we
move the text input cursor and there's no selected text.
@@ -280,25 +280,39 @@
the presented information will be correct, but when you
type, the last selected font, colors etc. will be used.
*/
- func updateTextViewAttributesIfNeeded() {
- #if macOS
- if textView.hasSelectedRange { return }
- let attributes = textView.richTextAttributes
- textView.setRichTextAttributes(attributes)
- #endif
- }
+ func updateTextViewAttributesIfNeeded() {
+ #if macOS
+ if textView.hasSelectedRange { return }
+ let attributes = textView.richTextAttributes
+ textView.setRichTextAttributes(attributes)
+ #endif
+ }
- /**
+ /**
On macOS, we have to update the typingAttributes when we
move the text input cursor and there's no selected text.
So that the current attributes will set again for updated location.
*/
- func replaceCurrentAttributesIfNeeded() {
- #if macOS
- if textView.hasSelectedRange { return }
- let attributes = textView.richTextAttributes
- textView.setNewRichTextAttributes(attributes)
- #endif
- }
+ func replaceCurrentAttributesIfNeeded() {
+ #if macOS
+ if textView.hasSelectedRange { return }
+ let attributes = textView.richTextAttributes
+ textView.setNewRichTextAttributes(attributes)
+ #endif
}
+ }
+
+//extension RichTextCoordinator: NSFilePromiseProviderDelegate {
+// public func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, fileNameForType fileType: String) -> String {
+//
+// }
+//
+// public func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, writePromiseTo url: URL, completionHandler: @escaping ((any Error)?) -> Void) {
+// <#code#>
+// }
+//
+// public func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, writePromiseTo url: URL) async throws {
+// <#code#>
+// }
+//}
#endif
diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift
index dd4b00e..173dc47 100644
--- a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift
+++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift
@@ -8,16 +8,16 @@
import Foundation
#if canImport(UIKit)
-import UIKit
+ import UIKit
#endif
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
-import AppKit
+ import AppKit
#endif
-public extension RichTextViewComponent {
+extension RichTextViewComponent {
- /**
+ /**
Paste an image into the rich text, at a certain index.
Pasting images only works on iOS, tvOS and macOS. Other
@@ -33,19 +33,19 @@ public extension RichTextViewComponent {
cursor should be moved to the end of the pasted content,
by default `false`.
*/
- func pasteImage(
- _ image: ImageRepresentable,
- at index: Int,
- moveCursorToPastedContent: Bool = true
- ) {
- pasteImages(
- [image],
- at: index,
- moveCursorToPastedContent: moveCursorToPastedContent
- )
- }
-
- /**
+ public func pasteImage(
+ _ image: ImageRepresentable,
+ at index: Int,
+ moveCursorToPastedContent: Bool = true
+ ) -> NSMutableAttributedString? {
+ return pasteImages(
+ [image],
+ at: index,
+ moveCursorToPastedContent: moveCursorToPastedContent
+ )
+ }
+
+ /**
Paste images into the text view, at a certain index.
This will automatically insert an image as a compressed
@@ -61,30 +61,34 @@ public extension RichTextViewComponent {
cursor should be moved to the end of the pasted content,
by default `false`.
*/
- func pasteImages(
- _ images: [ImageRepresentable],
- at index: Int,
- moveCursorToPastedContent move: Bool = false
- ) {
-#if os(watchOS)
- assertionFailure("Image pasting is not supported on this platform")
-#else
- guard validateImageInsertion(for: imagePasteConfiguration) else { return }
- let items = images.count * 2 // The number of inserted "items" is the images and a newline for each
- let insertRange = NSRange(location: index, length: 0)
- let safeInsertRange = safeRange(for: insertRange)
- let isSelectedRange = (index == selectedRange.location)
- if isSelectedRange { deleteCharacters(in: selectedRange) }
- if move { moveInputCursor(to: index) }
- images.reversed().forEach { performPasteImage($0, at: index) }
- if move { moveInputCursor(to: safeInsertRange.location + items) }
- if move || isSelectedRange {
- self.moveInputCursor(to: self.selectedRange.location)
- }
-#endif
- }
-
- /**
+ public func pasteImages(
+ _ images: [ImageRepresentable],
+ at index: Int,
+ moveCursorToPastedContent move: Bool = false
+ ) -> NSMutableAttributedString? {
+ #if os(watchOS)
+ assertionFailure("Image pasting is not supported on this platform")
+ #else
+ guard validateImageInsertion(for: imagePasteConfiguration) else { return nil }
+ let items = images.count * 2 // The number of inserted "items" is the images and a newline for each
+ let insertRange = NSRange(location: index, length: 0)
+ let safeInsertRange = safeRange(for: insertRange)
+ let isSelectedRange = (index == selectedRange.location)
+ if isSelectedRange { deleteCharacters(in: selectedRange) }
+ if move { moveInputCursor(to: index) }
+ var insertedString: NSMutableAttributedString = .init()
+ images.reversed().forEach {
+ insertedString.append(performPasteImage($0, at: index) ?? .init())
+ }
+ if move { moveInputCursor(to: safeInsertRange.location + items) }
+ if move || isSelectedRange {
+ self.moveInputCursor(to: self.selectedRange.location)
+ }
+ return insertedString
+ #endif
+ }
+
+ /**
Paste text into the text view, at a certain index.
- Parameters:
@@ -94,58 +98,59 @@ public extension RichTextViewComponent {
cursor should be moved to the end of the pasted content,
by default `false`.
*/
- func pasteText(
- _ text: String,
- at index: Int,
- moveCursorToPastedContent: Bool = false
- ) {
- let selected = selectedRange
- let isSelectedRange = (index == selected.location)
- let content = NSMutableAttributedString(attributedString: richText)
- let insertString = NSMutableAttributedString(string: text)
- let insertRange = NSRange(location: index, length: 0)
- let safeInsertRange = safeRange(for: insertRange)
- let safeMoveIndex = safeInsertRange.location + insertString.length
- let attributes = content.richTextAttributes(at: safeInsertRange)
- let attributeRange = NSRange(location: 0, length: insertString.length)
- let safeAttributeRange = safeRange(for: attributeRange)
- insertString.setRichTextAttributes(attributes, at: safeAttributeRange)
- content.insert(insertString, at: index)
- setRichText(content)
- if moveCursorToPastedContent {
- moveInputCursor(to: safeMoveIndex)
- } else if isSelectedRange {
- moveInputCursor(to: selected.location + text.count)
- }
+ public func pasteText(
+ _ text: String,
+ at index: Int,
+ moveCursorToPastedContent: Bool = false
+ ) {
+ let selected = selectedRange
+ let isSelectedRange = (index == selected.location)
+ let content = NSMutableAttributedString(attributedString: richText)
+ let insertString = NSMutableAttributedString(string: text)
+ let insertRange = NSRange(location: index, length: 0)
+ let safeInsertRange = safeRange(for: insertRange)
+ let safeMoveIndex = safeInsertRange.location + insertString.length
+ let attributes = content.richTextAttributes(at: safeInsertRange)
+ let attributeRange = NSRange(location: 0, length: insertString.length)
+ let safeAttributeRange = safeRange(for: attributeRange)
+ insertString.setRichTextAttributes(attributes, at: safeAttributeRange)
+ content.insert(insertString, at: index)
+ setRichText(content)
+ if moveCursorToPastedContent {
+ moveInputCursor(to: safeMoveIndex)
+ } else if isSelectedRange {
+ moveInputCursor(to: selected.location + text.count)
}
+ }
}
#if iOS || macOS || os(tvOS) || os(visionOS)
-private extension RichTextViewComponent {
+ extension RichTextViewComponent {
- func getAttachmentString(
- for image: ImageRepresentable
+ fileprivate func getAttachmentString(
+ for image: ImageRepresentable
) -> NSMutableAttributedString? {
- guard let data = image.jpegData(compressionQuality: 0.7) else { return nil }
- guard let compressed = ImageRepresentable(data: data) else { return nil }
- let attachment = RichTextImageAttachment(jpegData: data)
- attachment.bounds = attachmentBounds(for: compressed)
- return NSMutableAttributedString(attachment: attachment)
+ guard let data = image.jpegData(compressionQuality: 0.7) else { return nil }
+ guard let compressed = ImageRepresentable(data: data) else { return nil }
+ let attachment = RichTextImageAttachment(jpegData: data)
+ attachment.bounds = attachmentBounds(for: compressed)
+ return NSMutableAttributedString(attachment: attachment)
}
- func performPasteImage(
- _ image: ImageRepresentable,
- at index: Int
- ) {
- let newLine = NSAttributedString(string: "\n", attributes: richTextAttributes)
- let content = NSMutableAttributedString(attributedString: richText)
- guard let insertString = getAttachmentString(for: image) else { return }
+ fileprivate func performPasteImage(
+ _ image: ImageRepresentable,
+ at index: Int
+ ) -> NSMutableAttributedString? {
+ let newLine = NSAttributedString(string: "\n", attributes: richTextAttributes)
+ let content = NSMutableAttributedString(attributedString: richText)
+ guard let insertString = getAttachmentString(for: image) else { return nil }
- insertString.insert(newLine, at: insertString.length)
- insertString.addAttributes(richTextAttributes, range: insertString.richTextRange)
- content.insert(insertString, at: index)
+ insertString.insert(newLine, at: insertString.length)
+ insertString.addAttributes(richTextAttributes, range: insertString.richTextRange)
+ content.insert(insertString, at: index)
- setRichText(content)
+ setRichText(content)
+ return insertString
}
-}
+ }
#endif
diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Ranges.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Ranges.swift
index f2732af..336bc32 100644
--- a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Ranges.swift
+++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Ranges.swift
@@ -1,60 +1,60 @@
//
// RichTextViewComponent+Ranges.swift
-// RichTextKit
+// RichEditorSwiftUI
//
-// Created by Dominik Bucher
+// Created by Divyesh Vekariya on 25/11/24.
//
import Foundation
extension RichTextViewComponent {
- var notFoundRange: NSRange {
- .init(location: NSNotFound, length: 0)
- }
-
- /// Get the line range at a certain text location.
- func lineRange(at location: Int) -> NSRange {
- #if os(watchOS)
- return notFoundRange
- #else
- guard
- let manager = layoutManagerWrapper,
- let storage = textStorageWrapper
- else { return NSRange(location: NSNotFound, length: 0) }
- let string = storage.string as NSString
- let locationRange = NSRange(location: location, length: 0)
- let lineRange = string.lineRange(for: locationRange)
- return manager.characterRange(
- forGlyphRange: lineRange, actualGlyphRange: nil)
- #endif
- }
-
- /// Get the line range for a certain text range.
- func lineRange(for range: NSRange) -> NSRange {
- #if os(watchOS)
- return notFoundRange
- #else
- // Use the location-based logic if range is empty
- if range.length == 0 {
- return lineRange(at: range.location)
- }
-
- guard let manager = layoutManagerWrapper else {
- return NSRange(location: NSNotFound, length: 0)
- }
-
- var lineRange = NSRange(location: NSNotFound, length: 0)
- manager.enumerateLineFragments(
- forGlyphRange: range
- ) { (_, _, _, glyphRange, stop) in
- lineRange = glyphRange
- stop.pointee = true
- }
-
- // Convert glyph range to character range
- return manager.characterRange(
- forGlyphRange: lineRange, actualGlyphRange: nil)
- #endif
- }
+ var notFoundRange: NSRange {
+ .init(location: NSNotFound, length: 0)
+ }
+
+ /// Get the line range at a certain text location.
+ func lineRange(at location: Int) -> NSRange {
+ #if os(watchOS)
+ return notFoundRange
+ #else
+ guard
+ let manager = layoutManagerWrapper,
+ let storage = textStorageWrapper
+ else { return NSRange(location: NSNotFound, length: 0) }
+ let string = storage.string as NSString
+ let locationRange = NSRange(location: location, length: 0)
+ let lineRange = string.lineRange(for: locationRange)
+ return manager.characterRange(
+ forGlyphRange: lineRange, actualGlyphRange: nil)
+ #endif
+ }
+
+ /// Get the line range for a certain text range.
+ func lineRange(for range: NSRange) -> NSRange {
+ #if os(watchOS)
+ return notFoundRange
+ #else
+ // Use the location-based logic if range is empty
+ if range.length == 0 {
+ return lineRange(at: range.location)
+ }
+
+ guard let manager = layoutManagerWrapper else {
+ return NSRange(location: NSNotFound, length: 0)
+ }
+
+ var lineRange = NSRange(location: NSNotFound, length: 0)
+ manager.enumerateLineFragments(
+ forGlyphRange: range
+ ) { (_, _, _, glyphRange, stop) in
+ lineRange = glyphRange
+ stop.pointee = true
+ }
+
+ // Convert glyph range to character range
+ return manager.characterRange(
+ forGlyphRange: lineRange, actualGlyphRange: nil)
+ #endif
+ }
}
diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift
index 17df4fb..ba37652 100644
--- a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift
+++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift
@@ -9,9 +9,9 @@ import CoreGraphics
import Foundation
#if canImport(UIKit)
- import UIKit
+ import UIKit
#elseif canImport(AppKit) && !targetEnvironment(macCatalyst)
- import AppKit
+ import AppKit
#endif
/// This protocol provides a common interface for the UIKit and
@@ -27,120 +27,123 @@ import Foundation
/// The protocol implements and extends many other protocols to
/// provide more features for components with more capabilities.
public protocol RichTextViewComponent: AnyObject,
- RichTextPresenter,
- RichTextAttributeReader,
- RichTextAttributeWriter
+ RichTextPresenter,
+ RichTextAttributeReader,
+ RichTextAttributeWriter,
+ RichTextDataReader,
+ RichTextPdfDataReader,
+ RichTextImageAttachmentManager
{
- /// The text view's frame.
- var frame: CGRect { get }
+ /// The text view's frame.
+ var frame: CGRect { get }
- /// The style to use when highlighting text in the view.
- var highlightingStyle: RichTextHighlightingStyle { get set }
+ /// The style to use when highlighting text in the view.
+ var highlightingStyle: RichTextHighlightingStyle { get set }
- /// The image configuration used by the rich text view.
- var imageConfiguration: RichTextImageConfiguration { get set }
+ /// The image configuration used by the rich text view.
+ var imageConfiguration: RichTextImageConfiguration { get set }
- /// Whether or not the text view is the first responder.
- var isFirstResponder: Bool { get }
+ /// Whether or not the text view is the first responder.
+ var isFirstResponder: Bool { get }
- #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS)
- /// The text view's layout manager, if any.
- var layoutManagerWrapper: NSLayoutManager? { get }
+ #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS)
+ /// The text view's layout manager, if any.
+ var layoutManagerWrapper: NSLayoutManager? { get }
- /// The text view's text storage, if any.
- var textStorageWrapper: NSTextStorage? { get }
- #endif
+ /// The text view's text storage, if any.
+ var textStorageWrapper: NSTextStorage? { get }
+ #endif
- /// The text view's mutable attributed string, if any.
- var mutableAttributedString: NSMutableAttributedString? { get }
+ /// The text view's mutable attributed string, if any.
+ var mutableAttributedString: NSMutableAttributedString? { get }
- /// The spacing between the text view's edge and its text.
- var textContentInset: CGSize { get set }
+ /// The spacing between the text view's edge and its text.
+ var textContentInset: CGSize { get set }
- /// The text view current typing attributes.
- var typingAttributes: RichTextAttributes { get set }
+ /// The text view current typing attributes.
+ var typingAttributes: RichTextAttributes { get set }
- // MARK: - Setup
- /// Setup the view with a text and data format.
- func setup(
- with text: NSAttributedString,
- format: RichTextDataFormat?
- )
+ // MARK: - Setup
+ /// Setup the view with a text and data format.
+ func setup(
+ with text: NSAttributedString,
+ format: RichTextDataFormat?
+ )
- func setup(
- with text: RichText
- )
+ func setup(
+ with text: RichText
+ )
- // MARK: - Functions
+ // MARK: - Functions
- /// Show an alert with a title, message and button text.
- func alert(title: String, message: String, buttonTitle: String)
+ /// Show an alert with a title, message and button text.
+ func alert(title: String, message: String, buttonTitle: String)
- /// Copy the current selection.
- func copySelection()
+ /// Copy the current selection.
+ func copySelection()
- /// Try to redo the latest undone change.
- func redoLatestChange()
+ /// Try to redo the latest undone change.
+ func redoLatestChange()
- /// Scroll to a certain range.
- func scroll(to range: NSRange)
+ /// Scroll to a certain range.
+ func scroll(to range: NSRange)
- /// Set the rich text in the text view.
- func setRichText(_ text: NSAttributedString)
+ /// Set the rich text in the text view.
+ func setRichText(_ text: NSAttributedString)
- /// Set the selected range in the text view.
- func setSelectedRange(_ range: NSRange)
+ /// Set the selected range in the text view.
+ func setSelectedRange(_ range: NSRange)
- /// Undo the latest change.
- func undoLatestChange()
+ /// Undo the latest change.
+ func undoLatestChange()
}
// MARK: - Public Extension
extension RichTextViewComponent {
- /// Show an alert with a title, message and OK button.
- public func alert(title: String, message: String) {
- alert(title: title, message: message, buttonTitle: "OK")
- }
-
- /// Delete all characters in a certain range.
- public func deleteCharacters(in range: NSRange) {
- mutableAttributedString?.deleteCharacters(in: range)
- }
-
- /// Move the text cursor to a certain input index.
- public func moveInputCursor(to index: Int) {
- let newRange = NSRange(location: index, length: 0)
- let safeRange = safeRange(for: newRange)
- setSelectedRange(safeRange)
- }
-
- /// Setup the view with data and a data format.
- public func setup(
- with data: Data,
- format: RichTextDataFormat
- ) throws {
- let string = try NSAttributedString(data: data, format: format)
- setup(with: string, format: format)
- }
-
- /// Get the image configuration for a certain format.
- func standardImageConfiguration(
- for format: RichTextDataFormat
- ) -> RichTextImageConfiguration {
- let insertConfig = standardImageInsertConfiguration(for: format)
- return RichTextImageConfiguration(
- pasteConfiguration: insertConfig,
- dropConfiguration: insertConfig,
- maxImageSize: (width: .frame, height: .frame))
- }
-
- /// Get the image insert config for a certain format.
- func standardImageInsertConfiguration(
- for format: RichTextDataFormat
- ) -> RichTextImageInsertConfiguration {
- format.supportsImages ? .enabled : .disabled
- }
+ /// Show an alert with a title, message and OK button.
+ public func alert(title: String, message: String) {
+ alert(title: title, message: message, buttonTitle: "OK")
+ }
+
+ /// Delete all characters in a certain range.
+ public func deleteCharacters(in range: NSRange) {
+ mutableAttributedString?.deleteCharacters(in: range)
+ }
+
+ /// Move the text cursor to a certain input index.
+ public func moveInputCursor(to index: Int) {
+ let newRange = NSRange(location: index, length: 0)
+ let safeRange = safeRange(for: newRange)
+ setSelectedRange(safeRange)
+ }
+
+ /// Setup the view with data and a data format.
+ public func setup(
+ with data: Data,
+ format: RichTextDataFormat
+ ) throws {
+ let string = try NSAttributedString(data: data, format: format)
+ setup(with: string, format: format)
+ }
+
+ /// Get the image configuration for a certain format.
+ func standardImageConfiguration(
+ for format: RichTextDataFormat
+ ) -> RichTextImageConfiguration {
+ let insertConfig = standardImageInsertConfiguration(for: format)
+ return RichTextImageConfiguration(
+ pasteConfiguration: insertConfig,
+ dropConfiguration: insertConfig,
+ maxImageSize: (width: .frame, height: .frame))
+ }
+
+ /// Get the image insert config for a certain format.
+ func standardImageInsertConfiguration(
+ for format: RichTextDataFormat
+ ) -> RichTextImageInsertConfiguration {
+ format.supportsImages ? .enabled : .disabled
+ }
}
diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift
index 484947f..24e2d6a 100644
--- a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift
+++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift
@@ -9,407 +9,430 @@ import SwiftUI
// MARK: - RichAttributes
public struct RichAttributes: Codable {
- // public let id: String
- public let bold: Bool?
- public let italic: Bool?
- public let underline: Bool?
- public let strike: Bool?
- public let header: HeaderType?
- public let list: ListType?
- public let indent: Int?
- public let size: Int?
- public let font: String?
- public let color: String?
- public let background: String?
- public let align: RichTextAlignment?
- public let link: String?
+ // public let id: String
+ public let bold: Bool?
+ public let italic: Bool?
+ public let underline: Bool?
+ public let strike: Bool?
+ public let header: HeaderType?
+ public let list: ListType?
+ public let indent: Int?
+ public let size: Int?
+ public let font: String?
+ public let color: String?
+ public let background: String?
+ public let align: RichTextAlignment?
+ public let link: String?
+ public var image: String? = nil
- public init(
- // id: String = UUID().uuidString,
- bold: Bool? = nil,
- italic: Bool? = nil,
- underline: Bool? = nil,
- strike: Bool? = nil,
- header: HeaderType? = nil,
- list: ListType? = nil,
- indent: Int? = nil,
- size: Int? = nil,
- font: String? = nil,
- color: String? = nil,
- background: String? = nil,
- align: RichTextAlignment? = nil,
- link: String? = nil
- ) {
- // self.id = id
- self.bold = bold
- self.italic = italic
- self.underline = underline
- self.strike = strike
- self.header = header
- self.list = list
- self.indent = indent
- self.size = size
- self.font = font
- self.color = color
- self.background = background
- self.align = align
- self.link = link
- }
+ public init(
+ // id: String = UUID().uuidString,
+ bold: Bool? = nil,
+ italic: Bool? = nil,
+ underline: Bool? = nil,
+ strike: Bool? = nil,
+ header: HeaderType? = nil,
+ list: ListType? = nil,
+ indent: Int? = nil,
+ size: Int? = nil,
+ font: String? = nil,
+ color: String? = nil,
+ background: String? = nil,
+ align: RichTextAlignment? = nil,
+ link: String? = nil,
+ image: String? = nil
+ ) {
+ // self.id = id
+ self.bold = bold
+ self.italic = italic
+ self.underline = underline
+ self.strike = strike
+ self.header = header
+ self.list = list
+ self.indent = indent
+ self.size = size
+ self.font = font
+ self.color = color
+ self.background = background
+ self.align = align
+ self.link = link
+ self.image = image
+ }
- enum CodingKeys: String, CodingKey {
- case bold = "bold"
- case italic = "italic"
- case underline = "underline"
- case strike = "strike"
- case header = "header"
- case list = "list"
- case indent = "indent"
- case size = "size"
- case font = "font"
- case color = "color"
- case background = "background"
- case align = "align"
- case link = "link"
- }
+ enum CodingKeys: String, CodingKey {
+ case bold = "bold"
+ case italic = "italic"
+ case underline = "underline"
+ case strike = "strike"
+ case header = "header"
+ case list = "list"
+ case indent = "indent"
+ case size = "size"
+ case font = "font"
+ case color = "color"
+ case background = "background"
+ case align = "align"
+ case link = "link"
+ case image = "image"
+ }
- public init(from decoder: Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- // self.id = UUID().uuidString
- self.bold = try values.decodeIfPresent(Bool.self, forKey: .bold)
- self.italic = try values.decodeIfPresent(Bool.self, forKey: .italic)
- self.underline = try values.decodeIfPresent(
- Bool.self, forKey: .underline)
- self.strike = try values.decodeIfPresent(Bool.self, forKey: .strike)
- self.header = try values.decodeIfPresent(
- HeaderType.self, forKey: .header)
- self.list = try values.decodeIfPresent(ListType.self, forKey: .list)
- self.indent = try values.decodeIfPresent(Int.self, forKey: .indent)
- self.size = try values.decodeIfPresent(Int.self, forKey: .size)
- self.font = try values.decodeIfPresent(String.self, forKey: .font)
- self.color = try values.decodeIfPresent(String.self, forKey: .color)
- self.background = try values.decodeIfPresent(
- String.self, forKey: .background)
- self.align = try values.decodeIfPresent(
- RichTextAlignment.self, forKey: .align)
- self.link = try values.decodeIfPresent(String.self, forKey: .link)
- }
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ // self.id = UUID().uuidString
+ self.bold = try values.decodeIfPresent(Bool.self, forKey: .bold)
+ self.italic = try values.decodeIfPresent(Bool.self, forKey: .italic)
+ self.underline = try values.decodeIfPresent(
+ Bool.self, forKey: .underline)
+ self.strike = try values.decodeIfPresent(Bool.self, forKey: .strike)
+ self.header = try values.decodeIfPresent(
+ HeaderType.self, forKey: .header)
+ self.list = try values.decodeIfPresent(ListType.self, forKey: .list)
+ self.indent = try values.decodeIfPresent(Int.self, forKey: .indent)
+ self.size = try values.decodeIfPresent(Int.self, forKey: .size)
+ self.font = try values.decodeIfPresent(String.self, forKey: .font)
+ self.color = try values.decodeIfPresent(String.self, forKey: .color)
+ self.background = try values.decodeIfPresent(
+ String.self, forKey: .background)
+ self.align = try values.decodeIfPresent(
+ RichTextAlignment.self, forKey: .align)
+ self.link = try values.decodeIfPresent(String.self, forKey: .link)
+ self.image = try values.decodeIfPresent(String.self, forKey: .image)
+ }
}
extension RichAttributes: Hashable {
- public func hash(into hasher: inout Hasher) {
- // hasher.combine(id)
- hasher.combine(bold)
- hasher.combine(italic)
- hasher.combine(underline)
- hasher.combine(strike)
- hasher.combine(header)
- hasher.combine(list)
- hasher.combine(indent)
- hasher.combine(size)
- hasher.combine(font)
- hasher.combine(color)
- hasher.combine(background)
- hasher.combine(align)
- hasher.combine(link)
- }
+ public func hash(into hasher: inout Hasher) {
+ // hasher.combine(id)
+ hasher.combine(bold)
+ hasher.combine(italic)
+ hasher.combine(underline)
+ hasher.combine(strike)
+ hasher.combine(header)
+ hasher.combine(list)
+ hasher.combine(indent)
+ hasher.combine(size)
+ hasher.combine(font)
+ hasher.combine(color)
+ hasher.combine(background)
+ hasher.combine(align)
+ hasher.combine(link)
+ hasher.combine(image)
+ }
}
extension RichAttributes: Equatable {
- public static func == (
- lhs: RichAttributes,
- rhs: RichAttributes
- ) -> Bool {
- return (
- // lhs.id == rhs.id
- lhs.bold == rhs.bold
- && lhs.italic == rhs.italic
- && lhs.underline == rhs.underline
- && lhs.strike == rhs.strike
- && lhs.header == rhs.header
- && lhs.list == rhs.list
- && lhs.indent == rhs.indent
- && lhs.size == rhs.size
- && lhs.font == rhs.font
- && lhs.color == rhs.color
- && lhs.background == rhs.background
- && lhs.align == rhs.align
- && lhs.link == rhs.link)
- }
+ public static func == (
+ lhs: RichAttributes,
+ rhs: RichAttributes
+ ) -> Bool {
+ return (
+ // lhs.id == rhs.id
+ lhs.bold == rhs.bold
+ && lhs.italic == rhs.italic
+ && lhs.underline == rhs.underline
+ && lhs.strike == rhs.strike
+ && lhs.header == rhs.header
+ && lhs.list == rhs.list
+ && lhs.indent == rhs.indent
+ && lhs.size == rhs.size
+ && lhs.font == rhs.font
+ && lhs.color == rhs.color
+ && lhs.background == rhs.background
+ && lhs.align == rhs.align
+ && lhs.link == rhs.link)
+ && lhs.image == rhs.image
+ }
}
extension RichAttributes {
- public func copy(
- bold: Bool? = nil,
- header: HeaderType? = nil,
- italic: Bool? = nil,
- underline: Bool? = nil,
- strike: Bool? = nil,
- list: ListType? = nil,
- indent: Int? = nil,
- size: Int? = nil,
- font: String? = nil,
- color: String? = nil,
- background: String? = nil,
- align: RichTextAlignment? = nil,
- link: String? = nil
- ) -> RichAttributes {
- return RichAttributes(
- bold: (bold != nil ? bold! : self.bold),
- italic: (italic != nil ? italic! : self.italic),
- underline: (underline != nil ? underline! : self.underline),
- strike: (strike != nil ? strike! : self.strike),
- header: (header != nil ? header! : self.header),
- list: (list != nil ? list! : self.list),
- indent: (indent != nil ? indent! : self.indent),
- size: (size != nil ? size! : self.size),
- font: (font != nil ? font! : self.font),
- color: (color != nil ? color! : self.color),
- background: (background != nil ? background! : self.background),
- align: (align != nil ? align! : self.align),
- link: (link != nil ? link! : self.link)
- )
- }
+ public func copy(
+ bold: Bool? = nil,
+ header: HeaderType? = nil,
+ italic: Bool? = nil,
+ underline: Bool? = nil,
+ strike: Bool? = nil,
+ list: ListType? = nil,
+ indent: Int? = nil,
+ size: Int? = nil,
+ font: String? = nil,
+ color: String? = nil,
+ background: String? = nil,
+ align: RichTextAlignment? = nil,
+ link: String? = nil,
+ image: String? = nil
+ ) -> RichAttributes {
+ return RichAttributes(
+ bold: (bold != nil ? bold! : self.bold),
+ italic: (italic != nil ? italic! : self.italic),
+ underline: (underline != nil ? underline! : self.underline),
+ strike: (strike != nil ? strike! : self.strike),
+ header: (header != nil ? header! : self.header),
+ list: (list != nil ? list! : self.list),
+ indent: (indent != nil ? indent! : self.indent),
+ size: (size != nil ? size! : self.size),
+ font: (font != nil ? font! : self.font),
+ color: (color != nil ? color! : self.color),
+ background: (background != nil ? background! : self.background),
+ align: (align != nil ? align! : self.align),
+ link: (link != nil ? link! : self.link),
+ image: (image != nil ? image! : self.image)
+ )
+ }
- public func copy(with style: RichTextSpanStyle, byAdding: Bool = true)
- -> RichAttributes
- {
- return copy(with: [style], byAdding: byAdding)
- }
+ public func copy(with style: RichTextSpanStyle, byAdding: Bool = true)
+ -> RichAttributes
+ {
+ return copy(with: [style], byAdding: byAdding)
+ }
- public func copy(with styles: [RichTextSpanStyle], byAdding: Bool = true)
- -> RichAttributes
- {
- let att = getRichAttributesFor(styles: styles)
- return RichAttributes(
- bold: (att.bold != nil ? (byAdding ? att.bold! : nil) : self.bold),
- italic: (att.italic != nil
- ? (byAdding ? att.italic! : nil) : self.italic),
- underline: (att.underline != nil
- ? (byAdding ? att.underline! : nil) : self.underline),
- strike: (att.strike != nil
- ? (byAdding ? att.strike! : nil) : self.strike),
- header: (att.header != nil
- ? (byAdding ? att.header! : nil) : self.header),
- list: (att.list != nil ? (byAdding ? att.list! : nil) : self.list),
- indent: (att.indent != nil
- ? (byAdding ? att.indent! : nil) : self.indent),
- size: (att.size != nil ? (byAdding ? att.size! : nil) : self.size),
- font: (att.font != nil ? (byAdding ? att.font! : nil) : self.font),
- color: (att.color != nil
- ? (byAdding ? att.color! : nil) : self.color),
- background: (att.background != nil
- ? (byAdding ? att.background! : nil) : self.background),
- align: (att.align != nil
- ? (byAdding ? att.align! : nil) : self.align),
- ///nil link indicates removal as well so removing link if `byAdding == false && att.link == nil`
- link: (att.link != nil ? (byAdding ? att.link! : nil) : (att.link == nil && !byAdding) ? nil : self.link)
- )
- }
+ public func copy(with styles: [RichTextSpanStyle], byAdding: Bool = true)
+ -> RichAttributes
+ {
+ let att = getRichAttributesFor(styles: styles)
+ return RichAttributes(
+ bold: (att.bold != nil ? (byAdding ? att.bold! : nil) : self.bold),
+ italic: (att.italic != nil
+ ? (byAdding ? att.italic! : nil) : self.italic),
+ underline: (att.underline != nil
+ ? (byAdding ? att.underline! : nil) : self.underline),
+ strike: (att.strike != nil
+ ? (byAdding ? att.strike! : nil) : self.strike),
+ header: (att.header != nil
+ ? (byAdding ? att.header! : nil) : self.header),
+ list: (att.list != nil ? (byAdding ? att.list! : nil) : self.list),
+ indent: (att.indent != nil
+ ? (byAdding ? att.indent! : nil) : self.indent),
+ size: (att.size != nil ? (byAdding ? att.size! : nil) : self.size),
+ font: (att.font != nil ? (byAdding ? att.font! : nil) : self.font),
+ color: (att.color != nil
+ ? (byAdding ? att.color! : nil) : self.color),
+ background: (att.background != nil
+ ? (byAdding ? att.background! : nil) : self.background),
+ align: (att.align != nil
+ ? (byAdding ? att.align! : nil) : self.align),
+ ///nil link indicates removal as well so removing link if `byAdding == false && att.link == nil`
+ link: (att.link != nil
+ ? (byAdding ? att.link! : nil) : (att.link == nil && !byAdding) ? nil : self.link),
+ image: (att.image != nil ? (byAdding ? att.image! : nil) : self.image)
+ )
+ }
}
extension RichAttributes {
- public func styles() -> [RichTextSpanStyle] {
- var styles: [RichTextSpanStyle] = []
- if let bold = bold, bold {
- styles.append(.bold)
- }
- if let italic = italic, italic {
- styles.append(.italic)
- }
- if let underline = underline, underline {
- styles.append(.underline)
- }
- if let strike = strike, strike {
- styles.append(.strikethrough)
- }
- if let header = header {
- styles.append(header.getTextSpanStyle())
- }
- if let list = list {
- styles.append(list.getTextSpanStyle())
- }
- if let size = size {
- styles.append(.size(size))
- }
- if let font = font {
- styles.append(.font(font))
- }
- if let color = color {
- styles.append(.color(.init(hex: color)))
- }
- if let background = background {
- styles.append(.background(.init(hex: background)))
- }
- if let align = align {
- styles.append(align.getTextSpanStyle())
- }
- if let link = link {
- styles.append(.link(link))
- }
- return styles
+ public func styles() -> [RichTextSpanStyle] {
+ var styles: [RichTextSpanStyle] = []
+ if let bold = bold, bold {
+ styles.append(.bold)
+ }
+ if let italic = italic, italic {
+ styles.append(.italic)
+ }
+ if let underline = underline, underline {
+ styles.append(.underline)
+ }
+ if let strike = strike, strike {
+ styles.append(.strikethrough)
+ }
+ if let header = header {
+ styles.append(header.getTextSpanStyle())
+ }
+ if let list = list {
+ styles.append(list.getTextSpanStyle())
+ }
+ if let size = size {
+ styles.append(.size(size))
+ }
+ if let font = font {
+ styles.append(.font(font))
}
+ if let color = color {
+ styles.append(.color(.init(hex: color)))
+ }
+ if let background = background {
+ styles.append(.background(.init(hex: background)))
+ }
+ if let align = align {
+ styles.append(align.getTextSpanStyle())
+ }
+ if let link = link {
+ styles.append(.link(link))
+ }
+ if let image = image {
+ styles.append(.image(image))
+ }
+ return styles
+ }
- public func stylesSet() -> Set {
- var styles: Set = []
- if let bold = bold, bold {
- styles.insert(.bold)
- }
- if let italic = italic, italic {
- styles.insert(.italic)
- }
- if let underline = underline, underline {
- styles.insert(.underline)
- }
- if let strike = strike, strike {
- styles.insert(.strikethrough)
- }
- if let header = header {
- styles.insert(header.getTextSpanStyle())
- }
- if let list = list {
- styles.insert(list.getTextSpanStyle())
- }
- if let size = size {
- styles.insert(.size(size))
- }
- if let font = font {
- styles.insert(.font(font))
- }
- if let color = color {
- styles.insert(.color(Color(hex: color)))
- }
- if let background = background {
- styles.insert(.background(Color(hex: background)))
- }
- if let align = align {
- styles.insert(align.getTextSpanStyle())
- }
- if let link = link {
- styles.insert(.link(link))
- }
- return styles
+ public func stylesSet() -> Set {
+ var styles: Set = []
+ if let bold = bold, bold {
+ styles.insert(.bold)
+ }
+ if let italic = italic, italic {
+ styles.insert(.italic)
+ }
+ if let underline = underline, underline {
+ styles.insert(.underline)
+ }
+ if let strike = strike, strike {
+ styles.insert(.strikethrough)
+ }
+ if let header = header {
+ styles.insert(header.getTextSpanStyle())
+ }
+ if let list = list {
+ styles.insert(list.getTextSpanStyle())
}
+ if let size = size {
+ styles.insert(.size(size))
+ }
+ if let font = font {
+ styles.insert(.font(font))
+ }
+ if let color = color {
+ styles.insert(.color(Color(hex: color)))
+ }
+ if let background = background {
+ styles.insert(.background(Color(hex: background)))
+ }
+ if let align = align {
+ styles.insert(align.getTextSpanStyle())
+ }
+ if let link = link {
+ styles.insert(.link(link))
+ }
+ if let image {
+ styles.insert(.image(image))
+ }
+ return styles
+ }
}
extension RichAttributes {
- public func hasStyle(style: RichTextSpanStyle) -> Bool {
- switch style {
- case .default:
- return true
- case .bold:
- return bold ?? false
- case .italic:
- return italic ?? false
- case .underline:
- return underline ?? false
- case .strikethrough:
- return strike ?? false
- case .h1:
- return header == .h1
- case .h2:
- return header == .h2
- case .h3:
- return header == .h3
- case .h4:
- return header == .h4
- case .h5:
- return header == .h5
- case .h6:
- return header == .h6
- case .bullet:
- return list == .bullet(indent)
- case .size(let size):
- return size == size
- case .font(let name):
- return font == name
- case .color(let colorItem):
- return color == colorItem?.hexString
- case .background(let color):
- return background == color?.hexString
- case .align(let alignment):
- return align == alignment
- case .link(let linkItem):
- return link == linkItem
- }
+ public func hasStyle(style: RichTextSpanStyle) -> Bool {
+ switch style {
+ case .default:
+ return true
+ case .bold:
+ return bold ?? false
+ case .italic:
+ return italic ?? false
+ case .underline:
+ return underline ?? false
+ case .strikethrough:
+ return strike ?? false
+ case .h1:
+ return header == .h1
+ case .h2:
+ return header == .h2
+ case .h3:
+ return header == .h3
+ case .h4:
+ return header == .h4
+ case .h5:
+ return header == .h5
+ case .h6:
+ return header == .h6
+ case .bullet:
+ return list == .bullet(indent)
+ case .size(let size):
+ return size == size
+ case .font(let name):
+ return font == name
+ case .color(let colorItem):
+ return color == colorItem?.hexString
+ case .background(let color):
+ return background == color?.hexString
+ case .align(let alignment):
+ return align == alignment
+ case .link(let linkItem):
+ return link == linkItem
+ case .image(let imageItem):
+ return image == imageItem
}
+ }
}
internal func getRichAttributesFor(style: RichTextSpanStyle) -> RichAttributes {
- return getRichAttributesFor(styles: [style])
+ return getRichAttributesFor(styles: [style])
}
internal func getRichAttributesFor(styles: [RichTextSpanStyle])
- -> RichAttributes
+ -> RichAttributes
{
- guard !styles.isEmpty else { return RichAttributes() }
- var bold: Bool? = nil
- var italic: Bool? = nil
- var underline: Bool? = nil
- var strike: Bool? = nil
- var header: HeaderType? = nil
- var list: ListType? = nil
- var indent: Int? = nil
- var size: Int? = nil
- var font: String? = nil
- var color: String? = nil
- var background: String? = nil
- var align: RichTextAlignment? = nil
- var link: String? = nil
+ guard !styles.isEmpty else { return RichAttributes() }
+ var bold: Bool? = nil
+ var italic: Bool? = nil
+ var underline: Bool? = nil
+ var strike: Bool? = nil
+ var header: HeaderType? = nil
+ var list: ListType? = nil
+ var indent: Int? = nil
+ var size: Int? = nil
+ var font: String? = nil
+ var color: String? = nil
+ var background: String? = nil
+ var align: RichTextAlignment? = nil
+ var link: String? = nil
+ var image: String? = nil
- for style in styles {
- switch style {
- case .bold:
- bold = true
- case .italic:
- italic = true
- case .underline:
- underline = true
- case .strikethrough:
- strike = true
- case .h1:
- header = .h1
- case .h2:
- header = .h2
- case .h3:
- header = .h3
- case .h4:
- header = .h4
- case .h5:
- header = .h5
- case .h6:
- header = .h6
- case .bullet(let indentIndex):
- list = .bullet(indentIndex)
- indent = indentIndex
- case .default:
- header = .default
- case .size(let fontSize):
- size = fontSize
- case .font(let name):
- font = name
- case .color(let textColor):
- color = textColor?.hexString
- case .background(let backgroundColor):
- background = backgroundColor?.hexString
- case .align(let alignment):
- align = alignment
- case .link(let linkItem):
- link = linkItem
- }
+ for style in styles {
+ switch style {
+ case .bold:
+ bold = true
+ case .italic:
+ italic = true
+ case .underline:
+ underline = true
+ case .strikethrough:
+ strike = true
+ case .h1:
+ header = .h1
+ case .h2:
+ header = .h2
+ case .h3:
+ header = .h3
+ case .h4:
+ header = .h4
+ case .h5:
+ header = .h5
+ case .h6:
+ header = .h6
+ case .bullet(let indentIndex):
+ list = .bullet(indentIndex)
+ indent = indentIndex
+ case .default:
+ header = .default
+ case .size(let fontSize):
+ size = fontSize
+ case .font(let name):
+ font = name
+ case .color(let textColor):
+ color = textColor?.hexString
+ case .background(let backgroundColor):
+ background = backgroundColor?.hexString
+ case .align(let alignment):
+ align = alignment
+ case .link(let linkItem):
+ link = linkItem
+ case .image(let imageItem):
+ image = imageItem
}
- return RichAttributes(
- bold: bold,
- italic: italic,
- underline: underline,
- strike: strike,
- header: header,
- list: list,
- indent: indent,
- size: size,
- font: font,
- color: color,
- background: background,
- align: align,
- link: link
- )
+ }
+ return RichAttributes(
+ bold: bold,
+ italic: italic,
+ underline: underline,
+ strike: strike,
+ header: header,
+ list: list,
+ indent: indent,
+ size: size,
+ font: font,
+ color: color,
+ background: background,
+ align: align,
+ link: link,
+ image: image
+ )
}
diff --git a/Sources/RichEditorSwiftUI/ExportData/UTType+RichText.swift b/Sources/RichEditorSwiftUI/ExportData/UTType+RichText.swift
index 6c80780..28fc5c7 100644
--- a/Sources/RichEditorSwiftUI/ExportData/UTType+RichText.swift
+++ b/Sources/RichEditorSwiftUI/ExportData/UTType+RichText.swift
@@ -9,22 +9,22 @@ import UniformTypeIdentifiers
extension UTType {
- /// Uniform rich text types that RichTextKit supports.
- public static let richTextTypes: [UTType] = [
- .archivedData,
- .rtf,
- .text,
- .plainText,
- .data,
- ]
+ /// Uniform rich text types that RichTextEditor supports.
+ public static let richTextTypes: [UTType] = [
+ .archivedData,
+ .rtf,
+ .text,
+ .plainText,
+ .data,
+ ]
- /// The uniform type for ``RichTextDataFormat/archivedData``.
- public static let archivedData = UTType(
- exportedAs: "com.richtextkit.archiveddata")
+ /// The uniform type for ``RichTextDataFormat/archivedData``.
+ public static let archivedData = UTType(
+ exportedAs: "com.richtexteditor.archiveddata")
}
extension Collection where Element == UTType {
- /// The uniforum types that rich text documents support.
- public static var richTextTypes: [UTType] { UTType.richTextTypes }
+ /// The uniforum types that rich text documents support.
+ public static var richTextTypes: [UTType] { UTType.richTextTypes }
}
diff --git a/Sources/RichEditorSwiftUI/Images/ImageAttachment.swift b/Sources/RichEditorSwiftUI/Images/ImageAttachment.swift
new file mode 100644
index 0000000..9c6d75e
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Images/ImageAttachment.swift
@@ -0,0 +1,36 @@
+//
+// ImageAttachment.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 30/12/24.
+//
+
+import Foundation
+
+public class ImageAttachment {
+ public let id: String
+ public let image: ImageRepresentable
+ internal var range: NSRange? = nil
+ internal var url: String? = nil
+
+ internal init(id: String? = nil, image: ImageRepresentable, range: NSRange, url: String? = nil) {
+ self.id = id ?? UUID().uuidString
+ self.image = image
+ self.range = range
+ self.url = url
+ }
+
+ public init(id: String? = nil, image: ImageRepresentable, url: String) {
+ self.id = id ?? UUID().uuidString
+ self.image = image
+ self.url = url
+ }
+
+ public func updateUrl(with url: String) {
+ self.url = url
+ }
+
+ internal func updateRange(with range: NSRange) {
+ self.range = range
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Images/ImageAttachmentAction.swift b/Sources/RichEditorSwiftUI/Images/ImageAttachmentAction.swift
new file mode 100644
index 0000000..221a020
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Images/ImageAttachmentAction.swift
@@ -0,0 +1,22 @@
+//
+// ImageAttachmentAction.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 30/12/24.
+//
+
+import Foundation
+
+public enum ImageAttachmentAction {
+ case save([ImageAttachment])
+ case delete([ImageAttachment])
+ case getImage(ImageAttachment)
+ case getImages([ImageAttachment])
+}
+
+public enum ImageAttachmentCompleteAction {
+ case saved([ImageAttachment])
+ case deleted
+ case getImage(ImageAttachment?)
+ case getImages([ImageAttachment])
+}
diff --git a/Sources/RichEditorSwiftUI/Images/ImageDownloadManager.swift b/Sources/RichEditorSwiftUI/Images/ImageDownloadManager.swift
new file mode 100644
index 0000000..264111c
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Images/ImageDownloadManager.swift
@@ -0,0 +1,70 @@
+//
+// ImageDownloadManager.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 30/12/24.
+//
+
+import Foundation
+
+public class ImageDownloadManager {
+
+ static let shared = ImageDownloadManager()
+
+ private init() {}
+
+ /// Fetches an image from a URL, which can be a local file path or a remote URL.
+ /// - Parameter urlString: The URL string of the image.
+ /// - Returns: An `ImageRepresentable` instance or throws an error.
+ func fetchImage(from urlString: String) async throws -> ImageRepresentable {
+ if FileManager.default.fileExists(atPath: urlString) {
+ return try await fetchImageFromLocalPath(urlString)
+ } else {
+ return try await fetchImageFromRemoteURL(urlString)
+ }
+ }
+
+ /// Fetches an image from a local file path.
+ private func fetchImageFromLocalPath(_ path: String) async throws -> ImageRepresentable {
+ return try await withCheckedThrowingContinuation { continuation in
+ #if canImport(AppKit)
+ if let image = ImageRepresentable(contentsOfFile: path) {
+ continuation.resume(returning: image)
+ } else {
+ continuation.resume(
+ throwing: NSError(
+ domain: "ImageDownloadManager", code: 404,
+ userInfo: [NSLocalizedDescriptionKey: "Image not found at path: \(path)"]))
+ }
+ #elseif canImport(UIKit)
+ if let image = ImageRepresentable(contentsOfFile: path) {
+ continuation.resume(returning: image)
+ } else {
+ continuation.resume(
+ throwing: NSError(
+ domain: "ImageDownloadManager", code: 404,
+ userInfo: [NSLocalizedDescriptionKey: "Image not found at path: \(path)"]))
+ }
+ #endif
+ }
+ }
+
+ /// Fetches an image from a remote URL.
+ private func fetchImageFromRemoteURL(_ urlString: String) async throws -> ImageRepresentable {
+ guard let url = URL(string: urlString) else {
+ throw NSError(
+ domain: "ImageDownloadManager", code: 400,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
+ }
+
+ let (data, _) = try await URLSession.shared.data(from: url)
+
+ guard let image = ImageRepresentable(data: data) else {
+ throw NSError(
+ domain: "ImageDownloadManager", code: 500,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to decode image"])
+ }
+
+ return image
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift b/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift
index 41a016f..da0c4f3 100644
--- a/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift
+++ b/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift
@@ -1,71 +1,70 @@
//
// RichTextKeyboardToolbar.swift
-// RichTextKit
+// RichEditorSwiftUI
//
-// Created by Daniel Saidi on 2022-12-14.
-// Copyright © 2022-2024 Daniel Saidi. All rights reserved.
+// Created by Divyesh Vekariya on 25/11/24.
//
#if os(iOS) || os(macOS) || os(visionOS)
- import SwiftUI
-
- /// This toolbar can be added above an iOS keyboard, to provide
- /// rich text formatting in a compact form.
- ///
- /// This toolbar is needed since the ``RichTextEditor`` can not
- /// use a `toolbar` modifier with `.keyboard` placement:
- ///
- /// ```swift
- /// RichTextEditor(text: $text, context: context)
- /// .toolbar {
- /// ToolbarItemGroup(placement: .keyboard) {
- /// ....
- /// }
- /// }
- /// ```
- ///
- /// Instead, add this toolbar below a ``RichTextEditor`` to let
- /// it automatically show when the text editor is edited in iOS.
- ///
- /// You can inject additional leading and trailing buttons, and
- /// customize the format sheet that is presented when users tap
- /// format button:
- ///
- /// ```swift
- /// VStack {
- /// RichTextEditor(...)
- /// RichTextKeyboardToolbar(
- /// context: context,
- /// leadingButtons: {},
- /// trailingButtons: {},
- /// formatSheet: { $0 }
- /// )
- /// }
- /// ```
- ///
- /// These view builders provide you with standard views. Return
- /// `$0` to use these standard views, or return any custom view
- /// that you want to use instead.
- ///
- /// You can configure and style the view by applying its config
- /// and style view modifiers to your view hierarchy:
- ///
- /// ```swift
- /// VStack {
- /// RichTextEditor(...)
- /// RichTextKeyboardToolbar(...)
- /// }
- /// .richTextKeyboardToolbarStyle(...)
- /// .richTextKeyboardToolbarConfig(...)
- /// ```
- ///
- /// For more information, see ``RichTextKeyboardToolbarConfig``
- /// and ``RichTextKeyboardToolbarStyle``.
- public struct RichTextKeyboardToolbar<
- LeadingButtons: View, TrailingButtons: View, FormatSheet: View
- >: View {
-
- /**
+ import SwiftUI
+
+ /// This toolbar can be added above an iOS keyboard, to provide
+ /// rich text formatting in a compact form.
+ ///
+ /// This toolbar is needed since the ``RichTextEditor`` can not
+ /// use a `toolbar` modifier with `.keyboard` placement:
+ ///
+ /// ```swift
+ /// RichTextEditor(text: $text, context: context)
+ /// .toolbar {
+ /// ToolbarItemGroup(placement: .keyboard) {
+ /// ....
+ /// }
+ /// }
+ /// ```
+ ///
+ /// Instead, add this toolbar below a ``RichTextEditor`` to let
+ /// it automatically show when the text editor is edited in iOS.
+ ///
+ /// You can inject additional leading and trailing buttons, and
+ /// customize the format sheet that is presented when users tap
+ /// format button:
+ ///
+ /// ```swift
+ /// VStack {
+ /// RichTextEditor(...)
+ /// RichTextKeyboardToolbar(
+ /// context: context,
+ /// leadingButtons: {},
+ /// trailingButtons: {},
+ /// formatSheet: { $0 }
+ /// )
+ /// }
+ /// ```
+ ///
+ /// These view builders provide you with standard views. Return
+ /// `$0` to use these standard views, or return any custom view
+ /// that you want to use instead.
+ ///
+ /// You can configure and style the view by applying its config
+ /// and style view modifiers to your view hierarchy:
+ ///
+ /// ```swift
+ /// VStack {
+ /// RichTextEditor(...)
+ /// RichTextKeyboardToolbar(...)
+ /// }
+ /// .richTextKeyboardToolbarStyle(...)
+ /// .richTextKeyboardToolbarConfig(...)
+ /// ```
+ ///
+ /// For more information, see ``RichTextKeyboardToolbarConfig``
+ /// and ``RichTextKeyboardToolbarStyle``.
+ public struct RichTextKeyboardToolbar<
+ LeadingButtons: View, TrailingButtons: View, FormatSheet: View
+ >: View {
+
+ /**
Create a rich text keyboard toolbar.
- Parameters:
@@ -74,175 +73,174 @@
- trailingButtons: The trailing buttons to place before the trailing actions.
- formatSheet: The rich text format sheet to use, by default ``RichTextFormat/Sheet``.
*/
- public init(
- context: RichEditorState,
- @ViewBuilder leadingButtons: @escaping (StandardLeadingButtons) ->
- LeadingButtons,
- @ViewBuilder trailingButtons: @escaping (StandardTrailingButtons) ->
- TrailingButtons,
- @ViewBuilder formatSheet: @escaping (StandardFormatSheet) ->
- FormatSheet
- ) {
- self._context = ObservedObject(wrappedValue: context)
- self.leadingButtons = leadingButtons
- self.trailingButtons = trailingButtons
- self.formatSheet = formatSheet
- }
+ public init(
+ context: RichEditorState,
+ @ViewBuilder leadingButtons: @escaping (StandardLeadingButtons) ->
+ LeadingButtons,
+ @ViewBuilder trailingButtons: @escaping (StandardTrailingButtons) ->
+ TrailingButtons,
+ @ViewBuilder formatSheet: @escaping (StandardFormatSheet) ->
+ FormatSheet
+ ) {
+ self._context = ObservedObject(wrappedValue: context)
+ self.leadingButtons = leadingButtons
+ self.trailingButtons = trailingButtons
+ self.formatSheet = formatSheet
+ }
+
+ public typealias StandardLeadingButtons = EmptyView
+ public typealias StandardTrailingButtons = EmptyView
+ public typealias StandardFormatSheet = RichTextFormat.Sheet
+
+ private let leadingButtons: (StandardLeadingButtons) -> LeadingButtons
+ private let trailingButtons: (StandardTrailingButtons) -> TrailingButtons
+ private let formatSheet: (StandardFormatSheet) -> FormatSheet
+
+ @ObservedObject
+ private var context: RichEditorState
- public typealias StandardLeadingButtons = EmptyView
- public typealias StandardTrailingButtons = EmptyView
- public typealias StandardFormatSheet = RichTextFormat.Sheet
-
- private let leadingButtons: (StandardLeadingButtons) -> LeadingButtons
- private let trailingButtons:
- (StandardTrailingButtons) -> TrailingButtons
- private let formatSheet: (StandardFormatSheet) -> FormatSheet
-
- @ObservedObject
- private var context: RichEditorState
-
- @State
- private var isFormatSheetPresented = false
-
- @Environment(\.horizontalSizeClass)
- private var horizontalSizeClass
-
- @Environment(\.richTextKeyboardToolbarConfig)
- private var config
-
- @Environment(\.richTextKeyboardToolbarStyle)
- private var style
-
- public var body: some View {
- VStack(spacing: 0) {
- HStack(spacing: style.itemSpacing) {
- leadingViews
- Spacer()
- .frame(minWidth: 0, maxWidth: .infinity)
- trailingViews
- }
- .padding(10)
- }
- .environment(\.sizeCategory, .medium)
- .frame(height: style.toolbarHeight)
- .overlay(Divider(), alignment: .bottom)
- .accentColor(.primary)
- .background(
- Color.primary.colorInvert()
- .overlay(Color.white.opacity(0.2))
- .shadow(
- color: style.shadowColor, radius: style.shadowRadius,
- x: 0, y: 0)
- )
- .opacity(shouldDisplayToolbar ? 1 : 0)
- .offset(y: shouldDisplayToolbar ? 0 : style.toolbarHeight)
- .frame(height: shouldDisplayToolbar ? nil : 0)
- .sheet(isPresented: $isFormatSheetPresented) {
- formatSheet(
- .init(context: context)
- )
- .prefersMediumSize()
- }
+ @State
+ private var isFormatSheetPresented = false
+
+ @Environment(\.horizontalSizeClass)
+ private var horizontalSizeClass
+
+ @Environment(\.richTextKeyboardToolbarConfig)
+ private var config
+
+ @Environment(\.richTextKeyboardToolbarStyle)
+ private var style
+
+ public var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: style.itemSpacing) {
+ leadingViews
+ Spacer()
+ .frame(minWidth: 0, maxWidth: .infinity)
+ trailingViews
}
+ .padding(10)
+ }
+ .environment(\.sizeCategory, .medium)
+ .frame(height: style.toolbarHeight)
+ .overlay(Divider(), alignment: .bottom)
+ .accentColor(.primary)
+ .background(
+ Color.primary.colorInvert()
+ .overlay(Color.white.opacity(0.2))
+ .shadow(
+ color: style.shadowColor, radius: style.shadowRadius,
+ x: 0, y: 0)
+ )
+ .opacity(shouldDisplayToolbar ? 1 : 0)
+ .offset(y: shouldDisplayToolbar ? 0 : style.toolbarHeight)
+ .frame(height: shouldDisplayToolbar ? nil : 0)
+ .sheet(isPresented: $isFormatSheetPresented) {
+ formatSheet(
+ .init(context: context)
+ )
+ .prefersMediumSize()
+ }
}
-
- extension View {
-
- @ViewBuilder
- fileprivate func prefersMediumSize() -> some View {
- #if macOS
- self
- #else
- if #available(iOS 16, *) {
- self.presentationDetents([.medium])
- } else {
- self
- }
- #endif
+ }
+
+ extension View {
+
+ @ViewBuilder
+ fileprivate func prefersMediumSize() -> some View {
+ #if macOS
+ self
+ #else
+ if #available(iOS 16, *) {
+ self.presentationDetents([.medium])
+ } else {
+ self
}
+ #endif
}
+ }
- extension RichTextKeyboardToolbar {
+ extension RichTextKeyboardToolbar {
- fileprivate var isCompact: Bool {
- horizontalSizeClass == .compact
- }
+ fileprivate var isCompact: Bool {
+ horizontalSizeClass == .compact
}
+ }
- extension RichTextKeyboardToolbar {
+ extension RichTextKeyboardToolbar {
- fileprivate var divider: some View {
- Divider()
- .frame(height: 25)
- }
+ fileprivate var divider: some View {
+ Divider()
+ .frame(height: 25)
+ }
- @ViewBuilder
- fileprivate var leadingViews: some View {
- RichTextAction.ButtonStack(
- context: context,
- actions: config.leadingActions,
- spacing: style.itemSpacing
- )
+ @ViewBuilder
+ fileprivate var leadingViews: some View {
+ RichTextAction.ButtonStack(
+ context: context,
+ actions: config.leadingActions,
+ spacing: style.itemSpacing
+ )
- leadingButtons(StandardLeadingButtons())
+ leadingButtons(StandardLeadingButtons())
- divider
+ divider
- Button(action: presentFormatSheet) {
- Image.richTextFormat
- .contentShape(Rectangle())
- }
+ Button(action: presentFormatSheet) {
+ Image.richTextFormat
+ .contentShape(Rectangle())
+ }
- RichTextStyle.ToggleStack(context: context)
- .keyboardShortcutsOnly(if: isCompact)
+ RichTextStyle.ToggleStack(context: context)
+ .keyboardShortcutsOnly(if: isCompact)
- RichTextFont.SizePickerStack(context: context)
- .keyboardShortcutsOnly()
- }
+ RichTextFont.SizePickerStack(context: context)
+ .keyboardShortcutsOnly()
+ }
- @ViewBuilder
- fileprivate var trailingViews: some View {
- RichTextAlignment.Picker(selection: $context.textAlignment)
- .pickerStyle(.segmented)
- .frame(maxWidth: 200)
- .keyboardShortcutsOnly(if: isCompact)
+ @ViewBuilder
+ fileprivate var trailingViews: some View {
+ RichTextAlignment.Picker(selection: $context.textAlignment)
+ .pickerStyle(.segmented)
+ .frame(maxWidth: 200)
+ .keyboardShortcutsOnly(if: isCompact)
- trailingButtons(StandardTrailingButtons())
+ trailingButtons(StandardTrailingButtons())
- RichTextAction.ButtonStack(
- context: context,
- actions: config.trailingActions,
- spacing: style.itemSpacing
- )
- }
+ RichTextAction.ButtonStack(
+ context: context,
+ actions: config.trailingActions,
+ spacing: style.itemSpacing
+ )
}
-
- extension View {
-
- @ViewBuilder
- fileprivate func keyboardShortcutsOnly(
- if condition: Bool = true
- ) -> some View {
- if condition {
- self.hidden()
- .frame(width: 0)
- } else {
- self
- }
- }
+ }
+
+ extension View {
+
+ @ViewBuilder
+ fileprivate func keyboardShortcutsOnly(
+ if condition: Bool = true
+ ) -> some View {
+ if condition {
+ self.hidden()
+ .frame(width: 0)
+ } else {
+ self
+ }
}
+ }
- extension RichTextKeyboardToolbar {
+ extension RichTextKeyboardToolbar {
- fileprivate var shouldDisplayToolbar: Bool {
- context.isEditingText || config.alwaysDisplayToolbar
- }
+ fileprivate var shouldDisplayToolbar: Bool {
+ context.isEditingText || config.alwaysDisplayToolbar
}
+ }
- extension RichTextKeyboardToolbar {
+ extension RichTextKeyboardToolbar {
- fileprivate func presentFormatSheet() {
- isFormatSheetPresented = true
- }
+ fileprivate func presentFormatSheet() {
+ isFormatSheetPresented = true
}
+ }
#endif
diff --git a/Sources/RichEditorSwiftUI/Localization/RTEL10n.swift b/Sources/RichEditorSwiftUI/Localization/RTEL10n.swift
index 2f84cb6..923fef8 100644
--- a/Sources/RichEditorSwiftUI/Localization/RTEL10n.swift
+++ b/Sources/RichEditorSwiftUI/Localization/RTEL10n.swift
@@ -7,152 +7,152 @@
import SwiftUI
-/// This enum defines RichTextKit-specific, localized texts.
+/// This enum defines RichTextEditor-specific, localized texts.
public enum RTEL10n: String, CaseIterable, Identifiable {
- case
- done,
- more,
-
- font,
- fontSize,
- fontSizeIncrease,
- fontSizeIncreaseDescription,
- fontSizeDecrease,
- fontSizeDecreaseDescription,
-
- setHeaderStyle,
-
- color,
- foregroundColor,
- backgroundColor,
- underlineColor,
- strikethroughColor,
- strokeColor,
-
- actionCopy,
- actionDismissKeyboard,
- actionPrint,
- actionRedoLatestChange,
- actionUndoLatestChange,
-
- fileFormatRtk,
- fileFormatPdf,
- fileFormatRtf,
- fileFormatTxt,
- fileFormatJson,
-
- indent,
- indentIncrease,
- indentIncreaseDescription,
- indentDecrease,
- indentDecreaseDescription,
-
- lineSpacing,
- lineSpacingIncrease,
- lineSpacingIncreaseDescription,
- lineSpacingDecrease,
- lineSpacingDecreaseDescription,
-
- menuExport,
- menuExportAs,
- menuFormat,
- menuPrint,
- menuSave,
- menuSaveAs,
- menuShare,
- menuShareAs,
- menuText,
-
- highlightedRange,
- highlightingStyle,
-
- pasteImage,
- pasteImages,
- pasteText,
- selectRange,
-
- setAttributedString,
-
- styleBold,
- styleItalic,
- styleStrikethrough,
- styleUnderlined,
-
- superscript,
- superscriptIncrease,
- superscriptIncreaseDescription,
- superscriptDecrease,
- superscriptDecreaseDescription,
-
- textAlignment,
- textAlignmentLeft,
- textAlignmentRight,
- textAlignmentCentered,
- textAlignmentJustified,
-
- headerDefault,
- header1,
- header2,
- header3,
- header4,
- header5,
- header6,
-
- link,
-
- ignoreIt
+ case
+ done,
+ more,
+
+ font,
+ fontSize,
+ fontSizeIncrease,
+ fontSizeIncreaseDescription,
+ fontSizeDecrease,
+ fontSizeDecreaseDescription,
+
+ setHeaderStyle,
+
+ color,
+ foregroundColor,
+ backgroundColor,
+ underlineColor,
+ strikethroughColor,
+ strokeColor,
+
+ actionCopy,
+ actionDismissKeyboard,
+ actionPrint,
+ actionRedoLatestChange,
+ actionUndoLatestChange,
+
+ fileFormatRtk,
+ fileFormatPdf,
+ fileFormatRtf,
+ fileFormatTxt,
+ fileFormatJson,
+
+ indent,
+ indentIncrease,
+ indentIncreaseDescription,
+ indentDecrease,
+ indentDecreaseDescription,
+
+ lineSpacing,
+ lineSpacingIncrease,
+ lineSpacingIncreaseDescription,
+ lineSpacingDecrease,
+ lineSpacingDecreaseDescription,
+
+ menuExport,
+ menuExportAs,
+ menuFormat,
+ menuPrint,
+ menuSave,
+ menuSaveAs,
+ menuShare,
+ menuShareAs,
+ menuText,
+
+ highlightedRange,
+ highlightingStyle,
+
+ pasteImage,
+ pasteImages,
+ pasteText,
+ selectRange,
+
+ setAttributedString,
+
+ styleBold,
+ styleItalic,
+ styleStrikethrough,
+ styleUnderlined,
+
+ superscript,
+ superscriptIncrease,
+ superscriptIncreaseDescription,
+ superscriptDecrease,
+ superscriptDecreaseDescription,
+
+ textAlignment,
+ textAlignmentLeft,
+ textAlignmentRight,
+ textAlignmentCentered,
+ textAlignmentJustified,
+
+ headerDefault,
+ header1,
+ header2,
+ header3,
+ header4,
+ header5,
+ header6,
+
+ link,
+
+ ignoreIt
}
extension RTEL10n {
- public static func actionStepFontSize(
- _ points: Int
- ) -> RTEL10n {
- points < 0 ? .fontSizeDecreaseDescription : .fontSizeIncreaseDescription
- }
-
- public static func actionStepIndent(
- _ points: Double
- ) -> RTEL10n {
- points < 0 ? .indentDecreaseDescription : .indentIncreaseDescription
- }
-
- public static func actionStepLineSpacing(
- _ points: CGFloat
- ) -> RTEL10n {
- points < 0
- ? .lineSpacingDecreaseDescription : .lineSpacingIncreaseDescription
- }
-
- public static func actionStepSuperscript(
- _ steps: Int
- ) -> RTEL10n {
- steps < 0
- ? .superscriptDecreaseDescription : .superscriptIncreaseDescription
- }
-
- public static func menuIndent(_ points: Double) -> RTEL10n {
- points < 0 ? .indentDecrease : .indentIncrease
- }
+ public static func actionStepFontSize(
+ _ points: Int
+ ) -> RTEL10n {
+ points < 0 ? .fontSizeDecreaseDescription : .fontSizeIncreaseDescription
+ }
+
+ public static func actionStepIndent(
+ _ points: Double
+ ) -> RTEL10n {
+ points < 0 ? .indentDecreaseDescription : .indentIncreaseDescription
+ }
+
+ public static func actionStepLineSpacing(
+ _ points: CGFloat
+ ) -> RTEL10n {
+ points < 0
+ ? .lineSpacingDecreaseDescription : .lineSpacingIncreaseDescription
+ }
+
+ public static func actionStepSuperscript(
+ _ steps: Int
+ ) -> RTEL10n {
+ steps < 0
+ ? .superscriptDecreaseDescription : .superscriptIncreaseDescription
+ }
+
+ public static func menuIndent(_ points: Double) -> RTEL10n {
+ points < 0 ? .indentDecrease : .indentIncrease
+ }
}
extension RTEL10n {
- /// The item's unique identifier.
- public var id: String { rawValue }
+ /// The item's unique identifier.
+ public var id: String { rawValue }
- /// The item's localization key.
- public var key: String { rawValue }
+ /// The item's localization key.
+ public var key: String { rawValue }
- /// The item's localized text.
- public var text: String {
- rawValue
- }
+ /// The item's localized text.
+ public var text: String {
+ rawValue
+ }
- /// Get the localized text for a certain `Locale`.
- // func text(for locale: Locale) -> String {
- // guard let bundle = Bundle.module.bundle(for: locale) else { return "" }
- // return NSLocalizedString(key, bundle: bundle, comment: "")
- // }
+ /// Get the localized text for a certain `Locale`.
+ // func text(for locale: Locale) -> String {
+ // guard let bundle = Bundle.module.bundle(for: locale) else { return "" }
+ // return NSLocalizedString(key, bundle: bundle, comment: "")
+ // }
}
diff --git a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleStack.swift b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleStack.swift
index e4da70d..abe068d 100644
--- a/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleStack.swift
+++ b/Sources/RichEditorSwiftUI/RichTextOtherMenu/RichTextOtherMenu+ToggleStack.swift
@@ -1,5 +1,5 @@
//
-// File.swift
+// RichTextOtherMenu+ToggleStack.swift
// RichEditorSwiftUI
//
// Created by Divyesh Vekariya on 19/12/24.
@@ -9,16 +9,16 @@ import SwiftUI
extension RichTextOtherMenu {
- /**
+ /**
This view can list ``RichTextOtherMenu/Toggle``s for a list
of ``RichTextOtherMenu`` values, in a horizontal stack.
Since this view uses multiple styles, it binds directly
to a ``RichTextContext`` instead of individual values.
*/
- public struct ToggleStack: View {
+ public struct ToggleStack: View {
- /**
+ /**
Create a rich text style toggle button group.
- Parameters:
@@ -26,33 +26,33 @@ extension RichTextOtherMenu {
- styles: The styles to list, by default ``RichTextOtherMenu/all``.
- spacing: The spacing to apply to stack items, by default `5`.
*/
- public init(
- context: RichEditorState,
- styles: [RichTextOtherMenu] = .all,
- spacing: Double = 5
- ) {
- self._context = ObservedObject(wrappedValue: context)
- self.styles = styles
- self.spacing = spacing
- }
+ public init(
+ context: RichEditorState,
+ styles: [RichTextOtherMenu] = .all,
+ spacing: Double = 5
+ ) {
+ self._context = ObservedObject(wrappedValue: context)
+ self.styles = styles
+ self.spacing = spacing
+ }
+
+ private let styles: [RichTextOtherMenu]
+ private let spacing: Double
+
+ @ObservedObject
+ private var context: RichEditorState
- private let styles: [RichTextOtherMenu]
- private let spacing: Double
-
- @ObservedObject
- private var context: RichEditorState
-
- public var body: some View {
- HStack(spacing: spacing) {
- ForEach(styles) {
- RichTextOtherMenu.Toggle(
- style: $0,
- context: context,
- fillVertically: true
- )
- }
- }
- .fixedSize(horizontal: false, vertical: true)
+ public var body: some View {
+ HStack(spacing: spacing) {
+ ForEach(styles) {
+ RichTextOtherMenu.Toggle(
+ style: $0,
+ context: context,
+ fillVertically: true
+ )
}
+ }
+ .fixedSize(horizontal: false, vertical: true)
}
+ }
}
diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift
index 85c0b46..7b6c850 100644
--- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift
+++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift
@@ -18,12 +18,12 @@ import SwiftUI
/// context with focus in a multi-windowed app.
public class RichEditorState: ObservableObject {
- /// Create a new rich text context instance.
- public init() {}
+ /// Create a new rich text context instance.
+ public init() {}
- // MARK: - Not yet observable properties
+ // MARK: - Not yet observable properties
- /**
+ /**
The currently attributed string, if any.
Note that the property is read-only and not `@Published`
@@ -36,248 +36,263 @@ public class RichEditorState: ObservableObject {
Until then, use `setAttributedString(to:)` to change it.
*/
- public internal(set) var attributedString = NSAttributedString()
+ public internal(set) var attributedString = NSAttributedString()
- /// The currently selected range, if any.
- public internal(set) var selectedRange = NSRange()
+ /// The currently selected range, if any.
+ public internal(set) var selectedRange = NSRange()
- // MARK: - Bindable & Settable Properies
+ // MARK: - Bindable & Settable Properies
- /// Whether or not the rich text editor is editable.
- @Published
- public var isEditable = true
+ /// Whether or not the rich text editor is editable.
+ @Published
+ public var isEditable = true
- /// Whether or not the text is currently being edited.
- @Published
- public var isEditingText = false
+ /// Whether or not the text is currently being edited.
+ @Published
+ public var isEditingText = false
- @Published
- public var headerType: HeaderType = .default
+ @Published
+ public var headerType: HeaderType = .default
- /// The current text alignment, if any.
- @Published
- public var textAlignment: RichTextAlignment = .left
+ /// The current text alignment, if any.
+ @Published
+ public var textAlignment: RichTextAlignment = .left
- /// The current font name.
- @Published
- public var fontName = RichTextFont.PickerFont.all.first?.fontName ?? ""
+ /// The current font name.
+ @Published
+ public var fontName = RichTextFont.PickerFont.all.first?.fontName ?? ""
- /// The current font size.
- @Published
- public var fontSize = CGFloat.standardRichTextFontSize
+ /// The current font size.
+ @Published
+ public var fontSize = CGFloat.standardRichTextFontSize
- /// The current line spacing.
- @Published
- public var lineSpacing: CGFloat = 10.0
+ /// The current line spacing.
+ @Published
+ public var lineSpacing: CGFloat = 10.0
- // MARK: - Observable Properties
+ // MARK: - Observable Properties
- /// Whether or not the current rich text can be copied.
- @Published
- public internal(set) var canCopy = false
+ /// Whether or not the current rich text can be copied.
+ @Published
+ public internal(set) var canCopy = false
- /// Whether or not the latest undo can be redone.
- @Published
- public internal(set) var canRedoLatestChange = false
+ /// Whether or not the latest undo can be redone.
+ @Published
+ public internal(set) var canRedoLatestChange = false
- /// Whether or not the latest change can be undone.
- @Published
- public internal(set) var canUndoLatestChange = false
+ /// Whether or not the latest change can be undone.
+ @Published
+ public internal(set) var canUndoLatestChange = false
- /// The current color values.
- @Published
- public internal(set) var colors = [RichTextColor: ColorRepresentable]()
+ /// The current color values.
+ @Published
+ public internal(set) var colors = [RichTextColor: ColorRepresentable]()
- /// The style to apply when highlighting a range.
- @Published
- public internal(set) var highlightingStyle = RichTextHighlightingStyle
- .standard
+ /// The style to apply when highlighting a range.
+ @Published
+ public internal(set) var highlightingStyle = RichTextHighlightingStyle
+ .standard
- /// The current paragraph style.
- @Published
- public internal(set) var paragraphStyle = NSParagraphStyle.default
+ /// The current paragraph style.
+ @Published
+ public internal(set) var paragraphStyle = NSParagraphStyle.default
- /// The current rich text styles.
- @Published
- public internal(set) var styles = [RichTextStyle: Bool]()
+ /// The current rich text styles.
+ @Published
+ public internal(set) var styles = [RichTextStyle: Bool]()
- @Published
- public internal(set) var link: String? = nil
+ @Published
+ public internal(set) var link: String? = nil
- // MARK: - Properties
+ // MARK: - Properties
- /// This publisher can emit actions to the coordinator.
- public let actionPublisher = RichTextAction.Publisher()
+ /// This publisher can emit actions to the coordinator.
+ public let actionPublisher = RichTextAction.Publisher()
- /// The currently highlighted range, if any.
- public var highlightedRange: NSRange?
+ /// The currently highlighted range, if any.
+ public var highlightedRange: NSRange?
- //MARK: - Variables To Handle JSON
- internal var adapter: EditorAdapter = DefaultAdapter()
+ //MARK: - Variables To Handle JSON
+ internal var adapter: EditorAdapter = DefaultAdapter()
- @Published internal var activeStyles: Set = []
- @Published internal var activeAttributes: [NSAttributedString.Key: Any]? =
- [:]
+ @Published internal var activeStyles: Set = []
+ @Published internal var activeAttributes: [NSAttributedString.Key: Any]? =
+ [:]
- internal var internalSpans: [RichTextSpanInternal] = []
+ internal var internalSpans: [RichTextSpanInternal] = []
- internal var rawText: String = ""
+ internal var rawText: String = ""
- internal var updateAttributesQueue:
- [(span: RichTextSpanInternal, shouldApply: Bool)] = []
- internal let alertController: AlertController = AlertController()
+ internal var updateAttributesQueue: [(span: RichTextSpanInternal, shouldApply: Bool)] = []
+ internal let alertController: AlertController = AlertController()
- /**
+ internal var handleImageDropAction:
+ ((ImageAttachmentAction, _ onCompletion: ((ImageAttachmentCompleteAction) -> Void)?) -> Void)? =
+ nil
+
+ /**
This will provide encoded text which is of type RichText
*/
- public var richText: RichText {
- return getRichText()
- }
-
- internal var spans: RichTextSpans {
- return internalSpans.map({
- .init(
- insert: getStringWith(from: $0.from, to: $0.to),
- attributes: $0.attributes)
- })
- }
-
- var internalRichText: RichText = .init()
- //MARK: - Initializers
- /**
+ public var richText: RichText {
+ return getRichText()
+ }
+
+ internal var spans: RichTextSpans {
+ return internalSpans.map({
+ .init(
+ insert: getStringWith(from: $0.from, to: $0.to),
+ attributes: $0.attributes)
+ })
+ }
+
+ var internalRichText: RichText = .init()
+ //MARK: - Initializers
+ /**
Init with richText which is of type RichText
*/
- public init(richText: RichText) {
- internalRichText = richText
- let input = richText.spans.map({ $0.insert }).joined()
- var tempSpans: [RichTextSpanInternal] = []
- var text = ""
- richText.spans.forEach({
- let span = RichTextSpanInternal(
- from: text.utf16Length,
- to: (text.utf16Length + $0.insert.utf16Length - 1),
- attributes: $0.attributes)
- tempSpans.append(span)
- text += $0.insert
- })
-
- let str = NSMutableAttributedString(string: text)
-
- tempSpans.forEach { span in
- str.addAttributes(
- span.attributes?.toAttributes(font: .standardRichTextFont)
- ?? [:], range: span.spanRange)
- if span.attributes?.color == nil {
- var color: ColorRepresentable = .clear
- #if os(watchOS)
- color = .black
- #else
- color = RichTextView.Theme.standard.fontColor
- #endif
- str.addAttributes(
- [.foregroundColor: color], range: span.spanRange)
- }
- }
-
- self.attributedString = str
-
- self.internalSpans = tempSpans
-
- selectedRange = NSRange(location: 0, length: 0)
- activeStyles = []
-
- rawText = input
+ public init(
+ richText: RichText,
+ handleImageDropAction: (
+ (ImageAttachmentAction, _ onCompletion: ((ImageAttachmentCompleteAction) -> Void)?) -> Void
+ )? = nil
+ ) {
+ self.handleImageDropAction = handleImageDropAction
+ internalRichText = richText
+ let input = richText.spans.map({ $0.insert }).joined()
+ var tempSpans: [RichTextSpanInternal] = []
+ var text = ""
+ richText.spans.forEach({
+ let span = RichTextSpanInternal(
+ from: text.utf16Length,
+ to: (text.utf16Length + $0.insert.utf16Length - 1),
+ attributes: $0.attributes)
+ tempSpans.append(span)
+ text += $0.insert
+ })
+
+ let str = NSMutableAttributedString(string: text)
+
+ tempSpans.forEach { span in
+ str.addAttributes(
+ span.attributes?.toAttributes(font: .standardRichTextFont)
+ ?? [:], range: span.spanRange)
+ if span.attributes?.color == nil {
+ var color: ColorRepresentable = .clear
+ #if os(watchOS)
+ color = .black
+ #else
+ color = RichTextView.Theme.standard.fontColor
+ #endif
+ str.addAttributes(
+ [.foregroundColor: color], range: span.spanRange)
+ }
}
- /**
- Init with input which is of type String
- */
- public init(input: String) {
- let adapter = DefaultAdapter()
+ self.attributedString = str
- self.adapter = adapter
+ self.internalSpans = tempSpans
- let str = NSMutableAttributedString(string: input)
+ selectedRange = NSRange(location: 0, length: 0)
+ activeStyles = []
- str.addAttributes(
- [.font: FontRepresentable.standardRichTextFont],
- range: str.richTextRange)
- self.attributedString = str
+ rawText = input
+ }
- self.internalSpans = [
- .init(
- from: 0, to: input.utf16Length > 0 ? input.utf16Length - 1 : 0,
- attributes: RichAttributes())
- ]
-
- selectedRange = NSRange(location: 0, length: 0)
- activeStyles = []
-
- rawText = input
- }
+ /**
+ Init with input which is of type String
+ */
+ public init(
+ input: String,
+ handleImageDropAction: (
+ (ImageAttachmentAction, _ onCompletion: ((ImageAttachmentCompleteAction) -> Void)?) -> Void
+ )? = nil
+ ) {
+ self.handleImageDropAction = handleImageDropAction
+ let adapter = DefaultAdapter()
+
+ self.adapter = adapter
+
+ let str = NSMutableAttributedString(string: input)
+
+ str.addAttributes(
+ [.font: FontRepresentable.standardRichTextFont],
+ range: str.richTextRange)
+ self.attributedString = str
+
+ self.internalSpans = [
+ .init(
+ from: 0, to: input.utf16Length > 0 ? input.utf16Length - 1 : 0,
+ attributes: RichAttributes())
+ ]
+
+ selectedRange = NSRange(location: 0, length: 0)
+ activeStyles = []
+
+ rawText = input
+ }
}
extension RichEditorState {
- /// Whether or not the context has a selected range.
- public var hasHighlightedRange: Bool {
- highlightedRange != nil
- }
+ /// Whether or not the context has a selected range.
+ public var hasHighlightedRange: Bool {
+ highlightedRange != nil
+ }
- /// Whether or not the context has a selected range.
- public var hasSelectedRange: Bool {
- selectedRange.length > 0
- }
+ /// Whether or not the context has a selected range.
+ public var hasSelectedRange: Bool {
+ selectedRange.length > 0
+ }
}
extension RichEditorState {
- /// Set ``highlightedRange`` to a new, optional range.
- public func highlightRange(_ range: NSRange?) {
- actionPublisher.send(.setHighlightedRange(range))
- highlightedRange = range
- }
-
- /// Reset the attributed string.
- public func resetAttributedString() {
- setAttributedString(to: "")
- }
-
- /// Reset the ``highlightedRange``.
- public func resetHighlightedRange() {
- guard hasHighlightedRange else { return }
- highlightedRange = nil
- }
-
- /// Reset the ``selectedRange``.
- public func resetSelectedRange() {
- selectedRange = NSRange()
- }
-
- /// Set a new range and start editing.
- public func selectRange(_ range: NSRange) {
- isEditingText = true
- actionPublisher.send(.selectRange(range))
- }
-
- /// Set the attributed string to a new plain text.
- public func setAttributedString(to text: String) {
- setAttributedString(to: NSAttributedString(string: text))
- }
-
- /// Set the attributed string to a new rich text.
- public func setAttributedString(to string: NSAttributedString) {
- let mutable = NSMutableAttributedString(attributedString: string)
- actionPublisher.send(.setAttributedString(mutable))
- }
-
- /// Set ``isEditingText`` to `false`.
- public func stopEditingText() {
- isEditingText = false
- }
-
- /// Toggle whether or not the text is being edited.
- public func toggleIsEditing() {
- isEditingText.toggle()
- }
+ /// Set ``highlightedRange`` to a new, optional range.
+ public func highlightRange(_ range: NSRange?) {
+ actionPublisher.send(.setHighlightedRange(range))
+ highlightedRange = range
+ }
+
+ /// Reset the attributed string.
+ public func resetAttributedString() {
+ setAttributedString(to: "")
+ }
+
+ /// Reset the ``highlightedRange``.
+ public func resetHighlightedRange() {
+ guard hasHighlightedRange else { return }
+ highlightedRange = nil
+ }
+
+ /// Reset the ``selectedRange``.
+ public func resetSelectedRange() {
+ selectedRange = NSRange()
+ }
+
+ /// Set a new range and start editing.
+ public func selectRange(_ range: NSRange) {
+ isEditingText = true
+ actionPublisher.send(.selectRange(range))
+ }
+
+ /// Set the attributed string to a new plain text.
+ public func setAttributedString(to text: String) {
+ setAttributedString(to: NSAttributedString(string: text))
+ }
+
+ /// Set the attributed string to a new rich text.
+ public func setAttributedString(to string: NSAttributedString) {
+ let mutable = NSMutableAttributedString(attributedString: string)
+ actionPublisher.send(.setAttributedString(mutable))
+ }
+
+ /// Set ``isEditingText`` to `false`.
+ public func stopEditingText() {
+ isEditingText = false
+ }
+
+ /// Toggle whether or not the text is being edited.
+ public func toggleIsEditing() {
+ isEditingText.toggle()
+ }
}
diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Actions.swift b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Actions.swift
index 14764c8..6f74487 100644
--- a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Actions.swift
+++ b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Actions.swift
@@ -9,32 +9,32 @@ import SwiftUI
extension RichEditorState {
- /// Handle a certain rich text action.
- public func handle(_ action: RichTextAction) {
- switch action {
- // case .stepFontSize(let size):
- // fontSize += CGFloat(size)
- // updateStyle(style: .size(Int(fontSize)))
- default: actionPublisher.send(action)
- }
+ /// Handle a certain rich text action.
+ public func handle(_ action: RichTextAction) {
+ switch action {
+ case .stepFontSize(let size):
+ fontSize += CGFloat(size)
+ updateStyle(style: .size(Int(fontSize)))
+ default: actionPublisher.send(action)
}
+ }
- /// Check if the context can handle a certain action.
- public func canHandle(_ action: RichTextAction) -> Bool {
- switch action {
- case .copy: canCopy
- // case .pasteImage: true
- // case .pasteImages: true
- // case .pasteText: true
- case .print: false
- case .redoLatestChange: canRedoLatestChange
- case .undoLatestChange: canUndoLatestChange
- default: true
- }
+ /// Check if the context can handle a certain action.
+ public func canHandle(_ action: RichTextAction) -> Bool {
+ switch action {
+ case .copy: canCopy
+ case .pasteImage: true
+ case .pasteImages: true
+ case .pasteText: true
+ case .print: false
+ case .redoLatestChange: canRedoLatestChange
+ case .undoLatestChange: canUndoLatestChange
+ default: true
}
+ }
- /// Trigger a certain rich text action.
- public func trigger(_ action: RichTextAction) {
- handle(action)
- }
+ /// Trigger a certain rich text action.
+ public func trigger(_ action: RichTextAction) {
+ handle(action)
+ }
}
diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift
index 22f4c76..d23d5c2 100644
--- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift
+++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift
@@ -6,163 +6,167 @@
//
#if os(iOS) || os(macOS) || os(tvOS) || os(visionOS)
- import SwiftUI
- import Combine
-
- /// This view can be used to view and edit rich text in SwiftUI.
- ///
- /// The view uses a platform-specific ``RichTextView`` together
- /// with a ``RichEditorState`` and a ``RichTextCoordinator`` to
- /// keep the view and context in sync.
- ///
- /// You can use the provided context to trigger and observe any
- /// changes to the text editor. Note that changing the value of
- /// the `text` binding will not yet update the editor. Until it
- /// is fixed, use `setAttributedString(to:)`.
- ///
- /// Since the view wraps a native `UIKit` or `AppKit` text view,
- /// you can't apply `.toolbar` modifiers to it, like you can do
- /// with other SwiftUI views. This means that this doesn't work:
- ///
- /// ```swift
- /// RichTextEditor(text: $text, context: context)
- /// .toolbar {
- /// ToolbarItemGroup(placement: .keyboard) {
- /// ....
- /// }
- /// }
- /// ```
- ///
- /// This will not show anything. To work around this limitation,
- /// use a ``RichTextKeyboardToolbar`` instead.
- ///
- /// You can configure and style the view by applying its config
- /// and style view modifiers to your view hierarchy:
- ///
- /// ```swift
- /// VStack {
- /// RichTextEditor(...)
- /// ...
- /// }
- /// .richTextEditorStyle(...)
- /// .richTextEditorConfig(...)
- /// ```
+ import SwiftUI
+ import Combine
+
+ /// This view can be used to view and edit rich text in SwiftUI.
+ ///
+ /// The view uses a platform-specific ``RichTextView`` together
+ /// with a ``RichEditorState`` and a ``RichTextCoordinator`` to
+ /// keep the view and context in sync.
+ ///
+ /// You can use the provided context to trigger and observe any
+ /// changes to the text editor. Note that changing the value of
+ /// the `text` binding will not yet update the editor. Until it
+ /// is fixed, use `setAttributedString(to:)`.
+ ///
+ /// Since the view wraps a native `UIKit` or `AppKit` text view,
+ /// you can't apply `.toolbar` modifiers to it, like you can do
+ /// with other SwiftUI views. This means that this doesn't work:
+ ///
+ /// ```swift
+ /// RichTextEditor(text: $text, context: context)
+ /// .toolbar {
+ /// ToolbarItemGroup(placement: .keyboard) {
+ /// ....
+ /// }
+ /// }
+ /// ```
+ ///
+ /// This will not show anything. To work around this limitation,
+ /// use a ``RichTextKeyboardToolbar`` instead.
+ ///
+ /// You can configure and style the view by applying its config
+ /// and style view modifiers to your view hierarchy:
+ ///
+ /// ```swift
+ /// VStack {
+ /// RichTextEditor(...)
+ /// ...
+ /// }
+ /// .richTextEditorStyle(...)
+ /// .richTextEditorConfig(...)
+ /// ```
+ ///
+ /// For more information, see ``RichTextKeyboardToolbarConfig``
+ /// and ``RichTextKeyboardToolbarStyle``.
+ public struct RichTextEditor: ViewRepresentable {
+
+ @State var cancellable: Set = []
+
+ /// Create a rich text editor with a rich text value and
+ /// a certain rich text data format.
///
- /// For more information, see ``RichTextKeyboardToolbarConfig``
- /// and ``RichTextKeyboardToolbarStyle``.
- public struct RichTextEditor: ViewRepresentable {
-
- @State var cancellable: Set = []
-
- /// Create a rich text editor with a rich text value and
- /// a certain rich text data format.
- ///
- /// - Parameters:
- /// - context: The rich editor state to use.
- /// - viewConfiguration: A platform-specific view configuration, if any.
- public init(
- context: ObservedObject,
- format: RichTextDataFormat = .archivedData,
- viewConfiguration: @escaping ViewConfiguration = { _ in }
- ) {
- self._context = context
- self.format = format
- self.viewConfiguration = viewConfiguration
- }
+ /// - Parameters:
+ /// - context: The rich editor state to use.
+ /// - viewConfiguration: A platform-specific view configuration, if any.
+ public init(
+ context: ObservedObject,
+ format: RichTextDataFormat = .archivedData,
+ viewConfiguration: @escaping ViewConfiguration = { _ in }
+ ) {
+ self._context = context
+ self.format = format
+ self.viewConfiguration = viewConfiguration
+ }
- public typealias ViewConfiguration = (RichTextViewComponent) -> Void
+ public typealias ViewConfiguration = (RichTextViewComponent) -> Void
- @ObservedObject
- private var context: RichEditorState
+ @ObservedObject
+ private var context: RichEditorState
- private var viewConfiguration: ViewConfiguration
+ private var viewConfiguration: ViewConfiguration
- private var format: RichTextDataFormat
+ private var format: RichTextDataFormat
- @Environment(\.richTextEditorConfig)
- private var config
+ @Environment(\.richTextEditorConfig)
+ private var config
- @Environment(\.richTextEditorStyle)
- private var style
+ @Environment(\.richTextEditorStyle)
+ private var style
- #if os(iOS) || os(tvOS) || os(visionOS)
- public let textView = RichTextView()
- #endif
+ #if os(iOS) || os(tvOS) || os(visionOS)
+ public let textView = RichTextView()
+ #endif
- #if macOS
- public let scrollView = RichTextView.scrollableTextView()
+ #if macOS
+ public let scrollView = RichTextView.scrollableTextView()
- public var textView: RichTextView {
- scrollView.documentView as? RichTextView ?? RichTextView()
- }
- #endif
+ public var textView: RichTextView {
+ scrollView.documentView as? RichTextView ?? RichTextView()
+ }
+ #endif
+
+ public func makeCoordinator() -> RichTextCoordinator {
+ RichTextCoordinator(
+ text: $context.attributedString,
+ textView: textView,
+ richTextContext: context
+ )
+ }
- public func makeCoordinator() -> RichTextCoordinator {
- RichTextCoordinator(
- text: $context.attributedString,
- textView: textView,
- richTextContext: context
- )
+ #if os(iOS) || os(tvOS) || os(visionOS)
+ public func makeUIView(context: Context) -> some UIView {
+ textView.setup(
+ with: self.context.attributedString, format: format)
+ textView.configuration = config
+ textView.theme = style
+ viewConfiguration(textView)
+ textView.onTextViewEvent = self.context.onTextViewEvent(_:)
+ return textView
+ }
+
+ public func updateUIView(_ view: UIViewType, context: Context) {
+ // if !(self.context.activeAttributes?
+ // .contains(where: { $0.key == .font }) ?? false) {
+ // self.textView.typingAttributes = [.font: style.font]
+ // }
+ }
+ #else
+
+ public func makeNSView(context: Context) -> some NSView {
+ textView.setup(
+ with: self.context.attributedString, format: format)
+ textView.configuration = config
+ textView.theme = style
+ viewConfiguration(textView)
+ textView.onTextViewEvent = {
+ self.context.onTextViewEvent($0)
}
+ return scrollView
+ }
- #if os(iOS) || os(tvOS) || os(visionOS)
- public func makeUIView(context: Context) -> some UIView {
- textView.setup(
- with: self.context.attributedString, format: format)
- textView.configuration = config
- textView.theme = style
- viewConfiguration(textView)
- return textView
- }
-
- public func updateUIView(_ view: UIViewType, context: Context) {
- // if !(self.context.activeAttributes?
- // .contains(where: { $0.key == .font }) ?? false) {
- // self.textView.typingAttributes = [.font: style.font]
- // }
- }
- #else
-
- public func makeNSView(context: Context) -> some NSView {
- textView.setup(
- with: self.context.attributedString, format: format)
- textView.configuration = config
- textView.theme = style
- viewConfiguration(textView)
- return scrollView
- }
-
- public func updateNSView(_ view: NSViewType, context: Context) {}
- #endif
- }
+ public func updateNSView(_ view: NSViewType, context: Context) {}
+ #endif
+ }
- // MARK: RichTextPresenter
+ // MARK: RichTextPresenter
- extension RichTextEditor {
+ extension RichTextEditor {
- /// Get the currently selected range.
- public var selectedRange: NSRange {
- textView.selectedRange
- }
+ /// Get the currently selected range.
+ public var selectedRange: NSRange {
+ textView.selectedRange
}
+ }
- // MARK: RichTextReader
+ // MARK: RichTextReader
- extension RichTextEditor {
+ extension RichTextEditor {
- /// Get the string that is managed by the editor.
- public var attributedString: NSAttributedString {
- context.attributedString
- }
+ /// Get the string that is managed by the editor.
+ public var attributedString: NSAttributedString {
+ context.attributedString
}
+ }
- // MARK: RichTextWriter
+ // MARK: RichTextWriter
- extension RichTextEditor {
+ extension RichTextEditor {
- /// Get the mutable string that is managed by the editor.
- public var mutableAttributedString: NSMutableAttributedString? {
- textView.mutableAttributedString
- }
+ /// Get the mutable string that is managed by the editor.
+ public var mutableAttributedString: NSMutableAttributedString? {
+ textView.mutableAttributedString
}
+ }
#endif
diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift
index 0218566..ab6b2ec 100644
--- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift
+++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift
@@ -10,358 +10,419 @@ import SwiftUI
//MARK: - Public Methods
extension RichEditorState {
- func getStringWith(from: Int, to: Int) -> String {
- guard (to - from) >= 0 else { return "" }
- return attributedString.string.substring(
- from: .init(location: from, length: (to - from)))
- }
+ func getStringWith(from: Int, to: Int) -> String {
+ guard (to - from) >= 0 else { return "" }
+ return attributedString.string.substring(
+ from: .init(location: from, length: (to - from)))
+ }
- /**
+ /**
This will provide RichText which is encoded from input and editor text
*/
- internal func getRichText() -> RichText {
- return attributedString.string.isEmpty
- ? RichText() : RichText(spans: spans)
- }
+ internal func getRichText() -> RichText {
+ return attributedString.string.isEmpty
+ ? RichText() : RichText(spans: spans)
+ }
- /**
+ /**
This will provide String value from editor
*/
- public func outputAsString() -> String {
- return (try? adapter.encodeToString(type: richText)) ?? ""
- }
+ public func outputAsString() -> String {
+ return (try? adapter.encodeToString(type: richText)) ?? ""
+ }
- public func output() throws -> Data {
- return try adapter.encode(type: richText)
- }
+ public func output() throws -> Data {
+ return try adapter.encode(type: richText)
+ }
- /**
+ /**
This will export editor text as JSON string
*/
- public func export() -> String? {
- return (try? adapter.encodeToString(type: richText))
- }
+ public func export() -> String? {
+ return (try? adapter.encodeToString(type: richText))
+ }
- /**
+ /**
This will toggle the style
- Parameters:
- style: is of type RichTextSpanStyle
*/
- public func toggleStyle(style: RichTextSpanStyle) {
- if activeStyles.contains(style) {
- setInternalStyles(style: style, add: false)
- removeStyle(style)
- } else {
- setInternalStyles(style: style)
- addStyle(style)
- }
+ public func toggleStyle(style: RichTextSpanStyle) {
+ if activeStyles.contains(style) {
+ setInternalStyles(style: style, add: false)
+ removeStyle(style)
+ } else {
+ setInternalStyles(style: style)
+ addStyle(style)
}
+ }
- /**
+ /**
This will update the style
- Parameters:
- style: is of type RichTextSpanStyle
*/
- public func updateStyle(style: RichTextSpanStyle) {
- setInternalStyles(style: style)
- setStyle(style)
- }
+ public func updateStyle(style: RichTextSpanStyle) {
+ setInternalStyles(style: style)
+ setStyle(style)
+ }
}
//MARK: - TextView Helper Methods
extension RichEditorState {
- /**
+ /**
Handle UITextView's delegate methods calles
- Parameters:
- event: is of type TextViewEvents
This will switch on event and call respective method
*/
- internal func onTextViewEvent(_ event: TextViewEvents) {
- switch event {
- case .didChangeSelection(let range, let text):
- selectedRange = range
- guard
- rawText.count == text.string.count && selectedRange.isCollapsed
- else {
- return
+ internal func onTextViewEvent(_ event: TextViewEvents) {
+ switch event {
+ case .didChangeSelection(let range, let text):
+ selectedRange = range
+ guard
+ rawText.count == text.string.count && selectedRange.isCollapsed
+ else {
+ return
+ }
+ onSelectionDidChanged()
+ case .didBeginEditing(let range, _):
+ selectedRange = range
+ case .didChange:
+ onTextFieldValueChange(
+ newText: attributedString, selection: selectedRange)
+ case .didEndEditing:
+ selectedRange = .init(location: 0, length: 0)
+
+ case .didDroppedItems(let insertedString, let atRange, let isReplaced, let images):
+ handleImageDropFor(at: atRange, with: images, in: insertedString, isReplaced: isReplaced)
+ }
+ }
+
+ func handleImageDropFor(
+ at range: NSRange, with images: [ImageRepresentable], in text: NSAttributedString,
+ isReplaced: Bool
+ ) {
+
+ if isReplaced, range.length < text.length {
+ handleRemovingCharacters(attributedString)
+ } else {
+ handleAddingCharacters(attributedString)
+ }
+ ///Update the rawText after adding/removing characters
+ rawText = attributedString.string
+
+ let originRange = NSRange(
+ location: min(attributedString.length, max(0, selectedRange.location - text.length)),
+ length: range.length)
+ var imageAttachments: [ImageAttachment] = []
+ var previousRange: NSRange?
+
+ text.string.split(separator: "\n").enumerated().forEach { (index, item) in
+ var range: NSRange = .init()
+ if let previousRange {
+ range = .init(location: previousRange.location + item.count, length: 1)
+ } else {
+ range = NSRange(location: originRange.location, length: 1)
+ }
+
+ previousRange = range
+
+ if images.count > index {
+ imageAttachments.append(.init(image: images[index], range: range))
+ }
+ }
+
+ handleImageDropAction?(
+ .save(imageAttachments),
+ { [weak self] actions in
+ switch actions {
+ case .saved(let attachments):
+ attachments.forEach { item in
+ if let url = item.url, let range = item.range {
+ self?.createImageDropSpanFor(image: url, range: range)
}
- onSelectionDidChanged()
- case .didBeginEditing(let range, _):
- selectedRange = range
- case .didChange:
- onTextFieldValueChange(
- newText: attributedString, selection: selectedRange)
- case .didEndEditing:
- selectedRange = .init(location: 0, length: 0)
+ }
+ default:
+ break
}
- }
+ })
+
+ }
+
+ func createImageDropSpanFor(image: String, range: NSRange) {
- /**
+ // let span = internalSpans.firstIndex(where: { $0.spanRange.contains(range.location) })
+ // if span != nil {
+ processSpansFor(new: .image(image), in: range)
+ // }
+ }
+
+ /**
This will decide whether Character is added or removed and perform accordingly
- Parameters:
- newText: is updated NSMutableAttributedString
- selection: is the range of the selected text
*/
- private func onTextFieldValueChange(
- newText: NSAttributedString, selection: NSRange
- ) {
- self.selectedRange = selection
-
- if newText.string.count > rawText.count {
- handleAddingCharacters(newText)
- } else if newText.string.count < rawText.count {
- handleRemovingCharacters(newText)
- }
+ private func onTextFieldValueChange(
+ newText: NSAttributedString, selection: NSRange
+ ) {
+ self.selectedRange = selection
- rawText = newText.string
- updateCurrentSpanStyle()
+ if newText.string.count > rawText.count {
+ handleAddingCharacters(newText)
+ } else if newText.string.count < rawText.count {
+ handleRemovingCharacters(newText)
}
- /**
+ rawText = newText.string
+ updateCurrentSpanStyle()
+ }
+
+ /**
Update the selection
- Parameters:
- range: is the range of the selected text
- newText: is updated NSMutableAttributedString
*/
- internal func onSelectionDidChanged() {
- updateCurrentSpanStyle()
- }
+ internal func onSelectionDidChanged() {
+ updateCurrentSpanStyle()
+ }
- /**
+ /**
Set the activeStyles
- Parameters:
- style: is of type RichTextSpanStyle
This will set the activeStyle according to style passed
*/
- private func setStyle(_ style: RichTextSpanStyle) {
- activeStyles.removeAll()
- activeAttributes = [:]
- activeStyles.insert(style)
+ private func setStyle(_ style: RichTextSpanStyle) {
+ activeStyles.removeAll()
+ activeAttributes = [:]
+ activeStyles.insert(style)
- if style.isHeaderStyle || style.isDefault || style.isList
- || style.isAlignmentStyle
- {
- handleAddOrRemoveStyleToLine(
- in: selectedRange, style: style, byAdding: !style.isDefault)
- } else if !selectedRange.isCollapsed {
- let addStyle = checkIfStyleIsActiveWithSameAttributes(style)
- processSpansFor(new: style, in: selectedRange, addStyle: addStyle)
- }
-
- updateCurrentSpanStyle()
- }
-
- func checkIfStyleIsActiveWithSameAttributes(_ style: RichTextSpanStyle)
- -> Bool
+ if style.isHeaderStyle || style.isDefault || style.isList
+ || style.isAlignmentStyle
{
- var addStyle: Bool = true
- switch style {
- case .size(let size):
- if let size {
- addStyle = CGFloat(size) != CGFloat.standardRichTextFontSize
- }
- case .font(let fontName):
- if let fontName {
- addStyle = fontName == self.fontName
- }
- case .color(let color):
- if let color, color.toHex() != Color.primary.toHex() {
- if let internalColor = self.color(for: .foreground) {
- addStyle = Color(internalColor) != color
- } else {
- addStyle = true
- }
- } else {
- addStyle = false
- }
- case .background(let bgColor):
- if let color = bgColor, color.toHex() != Color.clear.toHex() {
- if let internalColor = self.color(for: .background) {
- addStyle = Color(internalColor) != color
- } else {
- addStyle = true
- }
- } else {
- addStyle = false
- }
- case .align(let alignment):
- if let alignment {
- addStyle = alignment != self.textAlignment || alignment != .left
- }
- case .link(let link):
- addStyle = link != nil
- default:
- return addStyle
+ handleAddOrRemoveStyleToLine(
+ in: selectedRange, style: style, byAdding: !style.isDefault)
+ } else if !selectedRange.isCollapsed {
+ let addStyle = checkIfStyleIsActiveWithSameAttributes(style)
+ processSpansFor(new: style, in: selectedRange, addStyle: addStyle)
+ }
+
+ updateCurrentSpanStyle()
+ }
+
+ func checkIfStyleIsActiveWithSameAttributes(_ style: RichTextSpanStyle)
+ -> Bool
+ {
+ var addStyle: Bool = true
+ switch style {
+ case .size(let size):
+ if let size {
+ addStyle = CGFloat(size) != CGFloat.standardRichTextFontSize
+ }
+ case .font(let fontName):
+ if let fontName {
+ addStyle = fontName == self.fontName
+ }
+ case .color(let color):
+ if let color, color.toHex() != Color.primary.toHex() {
+ if let internalColor = self.color(for: .foreground) {
+ addStyle = Color(internalColor) != color
+ } else {
+ addStyle = true
}
-
- return addStyle
- }
-
- /**
+ } else {
+ addStyle = false
+ }
+ case .background(let bgColor):
+ if let color = bgColor, color.toHex() != Color.clear.toHex() {
+ if let internalColor = self.color(for: .background) {
+ addStyle = Color(internalColor) != color
+ } else {
+ addStyle = true
+ }
+ } else {
+ addStyle = false
+ }
+ case .align(let alignment):
+ if let alignment {
+ addStyle = alignment != self.textAlignment || alignment != .left
+ }
+ case .link(let link):
+ addStyle = link != nil
+ default:
+ return addStyle
+ }
+
+ return addStyle
+ }
+
+ /**
Update the activeStyles and activeAttributes
*/
- internal func updateCurrentSpanStyle() {
- guard !attributedString.string.isEmpty else { return }
- var newStyles: Set = []
+ internal func updateCurrentSpanStyle() {
+ guard !attributedString.string.isEmpty else { return }
+ var newStyles: Set = []
- if selectedRange.isCollapsed {
- newStyles = getRichSpanStyleByTextIndex(selectedRange.location - 1)
- } else {
- newStyles = Set(getRichSpanStyleListByTextRange(selectedRange))
- }
+ if selectedRange.isCollapsed {
+ newStyles = getRichSpanStyleByTextIndex(selectedRange.location - 1)
+ } else {
+ newStyles = Set(getRichSpanStyleListByTextRange(selectedRange))
+ }
- guard activeStyles != newStyles && selectedRange.location != 0 else {
- return
- }
- activeStyles = newStyles
- var attributes: [NSAttributedString.Key: Any] = [:]
- activeStyles.forEach({
- attributes[$0.attributedStringKey] = $0.defaultAttributeValue(
- font: FontRepresentable.standardRichTextFont)
- })
+ guard activeStyles != newStyles && selectedRange.location != 0 else {
+ return
+ }
+ activeStyles = newStyles
+ var attributes: [NSAttributedString.Key: Any] = [:]
+ activeStyles.forEach({
+ attributes[$0.attributedStringKey] = $0.defaultAttributeValue(
+ font: FontRepresentable.standardRichTextFont)
+ })
- headerType =
- activeStyles.first(where: { $0.isHeaderStyle })?.headerType
- ?? .default
+ headerType =
+ activeStyles.first(where: { $0.isHeaderStyle })?.headerType
+ ?? .default
- activeAttributes = attributes
- }
+ activeAttributes = attributes
+ }
}
//MARK: - Add styles
extension RichEditorState {
- /**
+ /**
This will add style to the selected text
- Parameters:
- style: which is of type RichTextSpanStyle
It will add style to the selected text if needed and set activeAttributes and activeStyle accordingly.
*/
- private func addStyle(_ style: RichTextSpanStyle) {
- guard !activeStyles.contains(style) else { return }
- activeStyles.insert(style)
+ private func addStyle(_ style: RichTextSpanStyle) {
+ guard !activeStyles.contains(style) else { return }
+ activeStyles.insert(style)
- if style.isHeaderStyle || style.isDefault || style.isList
- || style.isAlignmentStyle
- {
- handleAddOrRemoveStyleToLine(in: selectedRange, style: style)
- } else if !selectedRange.isCollapsed {
- processSpansFor(new: style, in: selectedRange)
- }
+ if style.isHeaderStyle || style.isDefault || style.isList
+ || style.isAlignmentStyle
+ {
+ handleAddOrRemoveStyleToLine(in: selectedRange, style: style)
+ } else if !selectedRange.isCollapsed {
+ processSpansFor(new: style, in: selectedRange)
}
+ }
- //MARK: - Remove Style
- /**
+ //MARK: - Remove Style
+ /**
This will remove style from active style if it contains it
- Parameters:
- style: which is of type RichTextSpanStyle
This will remove typing attributes as well for style.
*/
- private func removeStyle(_ style: RichTextSpanStyle) {
- guard activeStyles.contains(style) || style.isDefault else { return }
- activeStyles.remove(style)
- updateTypingAttributes()
-
- if style.isHeaderStyle || style.isDefault || style.isList {
- handleAddOrRemoveStyleToLine(
- in: selectedRange, style: style, byAdding: false)
- } else if !selectedRange.isCollapsed {
- processSpansFor(new: style, in: selectedRange, addStyle: false)
- }
+ private func removeStyle(_ style: RichTextSpanStyle) {
+ guard activeStyles.contains(style) || style.isDefault else { return }
+ activeStyles.remove(style)
+ updateTypingAttributes()
+
+ if style.isHeaderStyle || style.isDefault || style.isList {
+ handleAddOrRemoveStyleToLine(
+ in: selectedRange, style: style, byAdding: false)
+ } else if !selectedRange.isCollapsed {
+ processSpansFor(new: style, in: selectedRange, addStyle: false)
}
+ }
- /**
+ /**
This will update the typing attribute according to active style
*/
- private func updateTypingAttributes() {
- var attributes: [NSAttributedString.Key: Any] = [:]
+ private func updateTypingAttributes() {
+ var attributes: [NSAttributedString.Key: Any] = [:]
- activeStyles.forEach({
- attributes[$0.attributedStringKey] = $0.defaultAttributeValue(
- font: FontRepresentable.standardRichTextFont)
- })
+ activeStyles.forEach({
+ attributes[$0.attributedStringKey] = $0.defaultAttributeValue(
+ font: FontRepresentable.standardRichTextFont)
+ })
- activeAttributes = attributes
- }
+ activeAttributes = attributes
+ }
}
//MARK: - Add character
extension RichEditorState {
- /**
+ /**
This will handle the newly added character in editor
- Parameters:
- newValue: is of type NSMutableAttributedString
This will generate break the span according to requirement to avoid duplication of the span.
*/
- private func handleAddingCharacters(_ newValue: NSAttributedString) {
- let typedChars = newValue.string.utf16Length - rawText.utf16Length
- let startTypeIndex = selectedRange.location - typedChars
- let startTypeChar = newValue.string.utf16.map({ $0 })[startTypeIndex]
-
- if startTypeChar == "\n".utf16.first
- && startTypeChar == "\n".utf16.last,
- activeStyles.contains(where: { $0.isHeaderStyle })
- {
- activeStyles.removeAll()
- }
-
- var selectedStyles = activeStyles
+ private func handleAddingCharacters(_ newValue: NSAttributedString) {
+ let typedChars = newValue.string.utf16Length - rawText.utf16Length
+ let startTypeIndex = selectedRange.location - typedChars
+ let startTypeChar = newValue.string.utf16.map({ $0 })[startTypeIndex]
+
+ if startTypeChar == "\n".utf16.first
+ && startTypeChar == "\n".utf16.last,
+ activeStyles.contains(where: { $0.isHeaderStyle })
+ {
+ activeStyles.removeAll()
+ }
- moveSpansForward(startTypeIndex: startTypeIndex, by: typedChars)
+ var selectedStyles = activeStyles
- let startParts = internalSpans.filter {
- $0.closedRange.contains(startTypeIndex - 1)
- }
- let endParts = internalSpans.filter {
- $0.closedRange.contains(startTypeIndex)
- }
- let commonParts = Set(startParts).intersection(Set(endParts))
+ moveSpansForward(startTypeIndex: startTypeIndex, by: typedChars)
+ let startParts = internalSpans.filter {
+ $0.closedRange.contains(startTypeIndex - 1)
+ }
+ let endParts = internalSpans.filter {
+ $0.closedRange.contains(startTypeIndex)
+ }
+ let commonParts = Set(startParts).intersection(Set(endParts))
- var addedInFirstPart: Bool = false
+ var addedInFirstPart: Bool = false
- startParts.filter { !commonParts.contains($0) }.forEach { part in
- if selectedStyles == part.attributes?.stylesSet() {
- if let index = internalSpans.firstIndex(of: part) {
- internalSpans[index] = part.copy(to: part.to + typedChars)
- selectedStyles.removeAll()
- addedInFirstPart = true
- }
- }
+ startParts.filter { !commonParts.contains($0) }.forEach { part in
+ if selectedStyles == part.attributes?.stylesSet() {
+ if let index = internalSpans.firstIndex(of: part) {
+ internalSpans[index] = part.copy(to: part.to + typedChars)
+ selectedStyles.removeAll()
+ addedInFirstPart = true
}
+ }
+ }
- if !addedInFirstPart {
- endParts.filter { !commonParts.contains($0) }.forEach { part in
- processSpan(
- part, typedChars: typedChars,
- startTypeIndex: startTypeIndex,
- selectedStyles: &selectedStyles, forward: true)
- }
- }
+ if !addedInFirstPart {
+ endParts.filter { !commonParts.contains($0) }.forEach { part in
+ processSpan(
+ part, typedChars: typedChars,
+ startTypeIndex: startTypeIndex,
+ selectedStyles: &selectedStyles, forward: true)
+ }
+ }
- commonParts.forEach { part in
- processSpan(
- part, typedChars: typedChars, startTypeIndex: startTypeIndex,
- selectedStyles: &selectedStyles)
- }
+ commonParts.forEach { part in
+ processSpan(
+ part, typedChars: typedChars, startTypeIndex: startTypeIndex,
+ selectedStyles: &selectedStyles)
+ }
- internalSpans = mergeSameStyledSpans(internalSpans)
+ internalSpans = mergeSameStyledSpans(internalSpans)
- guard
- !internalSpans.contains(where: {
- $0.closedRange.contains(startTypeIndex)
- })
- else { return }
- let toIndex =
- typedChars > 1 ? (startTypeIndex + typedChars - 1) : startTypeIndex
- let span = RichTextSpanInternal(
- from: startTypeIndex, to: toIndex,
- attributes: getRichAttributesFor(styles: Array(selectedStyles)))
- internalSpans.append(span)
- }
+ guard
+ !internalSpans.contains(where: {
+ $0.closedRange.contains(startTypeIndex)
+ })
+ else { return }
+ let toIndex =
+ typedChars > 1 ? (startTypeIndex + typedChars - 1) : startTypeIndex
+ let span = RichTextSpanInternal(
+ from: startTypeIndex, to: toIndex,
+ attributes: getRichAttributesFor(styles: Array(selectedStyles)))
+ internalSpans.append(span)
+ }
- /**
+ /**
This will handle the newly added character in editor
- Parameters:
- startTypeIndex: is of type Int
@@ -369,62 +430,62 @@ extension RichEditorState {
This will update the span according to requirement, like break, remove, merge or extend.
*/
- private func processSpan(
- _ richTextSpan: RichTextSpanInternal, typedChars: Int,
- startTypeIndex: Int, selectedStyles: inout Set,
- forward: Bool = false
- ) {
- let newFromIndex = richTextSpan.from + typedChars
- let newToIndex = richTextSpan.to + typedChars
-
- if let index = internalSpans.firstIndex(of: richTextSpan) {
- if selectedStyles == richTextSpan.attributes?.stylesSet() {
- internalSpans[index] = richTextSpan.copy(to: newToIndex)
- selectedStyles.removeAll()
- } else {
- if forward {
- internalSpans[index] = richTextSpan.copy(
- from: newFromIndex, to: newToIndex)
- } else {
- divideSpanAndAddTextWithCurrentStyle(
- span: richTextSpan, typedChars: typedChars,
- startTypeIndex: startTypeIndex, with: &selectedStyles)
- selectedStyles.removeAll()
- }
- }
- }
- }
-
- func divideSpanAndAddTextWithCurrentStyle(
- span: RichTextSpanInternal, typedChars: Int, startTypeIndex: Int,
- with styles: inout Set
- ) {
- guard let index = internalSpans.firstIndex(of: span) else { return }
- let extendedSpan = span.copy(to: span.to + typedChars)
-
- let startIndex = startTypeIndex
- let endIndex = startTypeIndex + typedChars - 1
-
- var spansToAdd: [RichTextSpanInternal] = []
- spansToAdd.append(
- RichTextSpanInternal(
- from: startIndex, to: endIndex,
- attributes: getRichAttributesFor(styles: Array(styles))))
-
- if startTypeIndex == extendedSpan.from {
- spansToAdd.append(extendedSpan.copy(from: endIndex))
- } else if endIndex == extendedSpan.to {
- spansToAdd.append(extendedSpan.copy(to: startIndex - 1))
+ private func processSpan(
+ _ richTextSpan: RichTextSpanInternal, typedChars: Int,
+ startTypeIndex: Int, selectedStyles: inout Set,
+ forward: Bool = false
+ ) {
+ let newFromIndex = richTextSpan.from + typedChars
+ let newToIndex = richTextSpan.to + typedChars
+
+ if let index = internalSpans.firstIndex(of: richTextSpan) {
+ if selectedStyles == richTextSpan.attributes?.stylesSet() {
+ internalSpans[index] = richTextSpan.copy(to: newToIndex)
+ selectedStyles.removeAll()
+ } else {
+ if forward {
+ internalSpans[index] = richTextSpan.copy(
+ from: newFromIndex, to: newToIndex)
} else {
- spansToAdd.append(extendedSpan.copy(to: startIndex - 1))
- spansToAdd.append(extendedSpan.copy(from: endIndex + 1))
+ divideSpanAndAddTextWithCurrentStyle(
+ span: richTextSpan, typedChars: typedChars,
+ startTypeIndex: startTypeIndex, with: &selectedStyles)
+ selectedStyles.removeAll()
}
- internalSpans.removeAll(where: { $0 == span })
- internalSpans.insert(
- contentsOf: spansToAdd.sorted(by: { $0.from < $1.from }), at: index)
- }
-
- /**
+ }
+ }
+ }
+
+ func divideSpanAndAddTextWithCurrentStyle(
+ span: RichTextSpanInternal, typedChars: Int, startTypeIndex: Int,
+ with styles: inout Set
+ ) {
+ guard let index = internalSpans.firstIndex(of: span) else { return }
+ let extendedSpan = span.copy(to: span.to + typedChars)
+
+ let startIndex = startTypeIndex
+ let endIndex = startTypeIndex + typedChars - 1
+
+ var spansToAdd: [RichTextSpanInternal] = []
+ spansToAdd.append(
+ RichTextSpanInternal(
+ from: startIndex, to: endIndex,
+ attributes: getRichAttributesFor(styles: Array(styles))))
+
+ if startTypeIndex == extendedSpan.from {
+ spansToAdd.append(extendedSpan.copy(from: endIndex))
+ } else if endIndex == extendedSpan.to {
+ spansToAdd.append(extendedSpan.copy(to: startIndex - 1))
+ } else {
+ spansToAdd.append(extendedSpan.copy(to: startIndex - 1))
+ spansToAdd.append(extendedSpan.copy(from: endIndex + 1))
+ }
+ internalSpans.removeAll(where: { $0 == span })
+ internalSpans.insert(
+ contentsOf: spansToAdd.sorted(by: { $0.from < $1.from }), at: index)
+ }
+
+ /**
This will handle the newly added character in editor
- Parameters:
- startTypeIndex: is of type Int
@@ -432,83 +493,82 @@ extension RichEditorState {
This will move the span according to it's position if it is after the typed character then it will move forward by number or typed character which is step.
*/
- private func moveSpansForward(startTypeIndex: Int, by step: Int) {
- let filteredSpans = internalSpans.filter { $0.from >= startTypeIndex }
+ private func moveSpansForward(startTypeIndex: Int, by step: Int) {
+ let filteredSpans = internalSpans.filter { $0.from >= startTypeIndex }
- filteredSpans.forEach { part in
- if let index = internalSpans.firstIndex(of: part) {
- internalSpans[index] = part.copy(
- from: part.from + step, to: part.to + step)
- }
- }
+ filteredSpans.forEach { part in
+ if let index = internalSpans.firstIndex(of: part) {
+ internalSpans[index] = part.copy(
+ from: part.from + step, to: part.to + step)
+ }
}
+ }
}
//MARK: - Remove Character
extension RichEditorState {
- /**
+ /**
This will handle the removing character in editor and from relative span
- Parameters:
- newText: is of type NsMutableAttributedString
This will generate, break and remove the span according to requirement to avoid duplication and untracked span.
*/
- private func handleRemovingCharacters(_ newText: NSAttributedString) {
- guard !newText.string.isEmpty else {
- internalSpans.removeAll()
- activeStyles.removeAll()
- return
- }
-
- let removedCharsCount = rawText.utf16Length - newText.string.utf16Length
- let startRemoveIndex = selectedRange.location
- let endRemoveIndex = selectedRange.location + removedCharsCount - 1
- let removeRange = startRemoveIndex...endRemoveIndex
- let start = rawText.utf16.index(
- rawText.startIndex, offsetBy: startRemoveIndex)
- let end = rawText.utf16.index(
- rawText.startIndex, offsetBy: endRemoveIndex)
-
- if startRemoveIndex != endRemoveIndex,
- let newLineIndex = String(rawText[start...end]).map({ $0 })
- .lastIndex(of: "\n"), newLineIndex >= 0
+ private func handleRemovingCharacters(_ newText: NSAttributedString) {
+ guard !newText.string.isEmpty else {
+ internalSpans.removeAll()
+ activeStyles.removeAll()
+ return
+ }
+
+ let removedCharsCount = rawText.utf16Length - newText.string.utf16Length
+ let startRemoveIndex = selectedRange.location
+ let endRemoveIndex = selectedRange.location + removedCharsCount - 1
+ let removeRange = startRemoveIndex...endRemoveIndex
+ let start = rawText.utf16.index(
+ rawText.startIndex, offsetBy: startRemoveIndex)
+ let end = rawText.utf16.index(
+ rawText.startIndex, offsetBy: endRemoveIndex)
+
+ if startRemoveIndex != endRemoveIndex,
+ let newLineIndex = String(rawText[start...end]).map({ $0 })
+ .lastIndex(of: "\n"), newLineIndex >= 0
+ {
+ handleRemoveHeaderStyle(
+ newText: newText.string, at: removeRange.nsRange,
+ newLineIndex: newLineIndex)
+ }
+
+ let partsCopy = internalSpans
+
+ let lowerBound = removeRange.lowerBound //- (selectedRange.length < removedCharsCount ? 1 : 0)
+ for part in partsCopy {
+ if let index = internalSpans.firstIndex(of: part) {
+ if removeRange.upperBound < part.from {
+ internalSpans[index] = part.copy(
+ from: part.from - (removedCharsCount),
+ to: part.to - (removedCharsCount))
+ } else if lowerBound <= part.from
+ && removeRange.upperBound >= part.to
{
- handleRemoveHeaderStyle(
- newText: newText.string, at: removeRange.nsRange,
- newLineIndex: newLineIndex)
- }
-
- let partsCopy = internalSpans
-
- let lowerBound = removeRange.lowerBound //- (selectedRange.length < removedCharsCount ? 1 : 0)
-
- for part in partsCopy {
- if let index = internalSpans.firstIndex(of: part) {
- if removeRange.upperBound < part.from {
- internalSpans[index] = part.copy(
- from: part.from - (removedCharsCount),
- to: part.to - (removedCharsCount))
- } else if lowerBound <= part.from
- && removeRange.upperBound >= part.to
- {
- internalSpans.removeAll(where: { $0 == part })
- } else if lowerBound <= part.from {
- internalSpans[index] = part.copy(
- from: max(0, lowerBound),
- to: min(
- newText.string.utf16Length,
- part.to - removedCharsCount))
- } else if removeRange.upperBound <= part.to {
- internalSpans[index] = part.copy(
- to: part.to - removedCharsCount)
- } else if lowerBound < part.to {
- internalSpans[index] = part.copy(to: lowerBound)
- }
- }
+ internalSpans.removeAll(where: { $0 == part })
+ } else if lowerBound <= part.from {
+ internalSpans[index] = part.copy(
+ from: max(0, lowerBound),
+ to: min(
+ newText.string.utf16Length,
+ part.to - removedCharsCount))
+ } else if removeRange.upperBound <= part.to {
+ internalSpans[index] = part.copy(
+ to: part.to - removedCharsCount)
+ } else if lowerBound < part.to {
+ internalSpans[index] = part.copy(to: lowerBound)
}
+ }
}
+ }
- /**
+ /**
This will handle the newly added character in editor
- Parameters:
- startTypeIndex: is of type Int
@@ -516,384 +576,395 @@ extension RichEditorState {
This will move the span according to it's position if it is after the typed character then it will move forward by number or typed character which is step.
*/
- private func moveSpansBackward(endTypeIndex: Int, by step: Int) {
- let filteredSpans = internalSpans.filter { $0.to > endTypeIndex }
+ private func moveSpansBackward(endTypeIndex: Int, by step: Int) {
+ let filteredSpans = internalSpans.filter { $0.to > endTypeIndex }
- filteredSpans.forEach { part in
- if let index = internalSpans.firstIndex(of: part) {
- internalSpans[index] = part.copy(
- from: part.from - step, to: part.to - step)
- }
- }
+ filteredSpans.forEach { part in
+ if let index = internalSpans.firstIndex(of: part) {
+ internalSpans[index] = part.copy(
+ from: part.from - step, to: part.to - step)
+ }
}
+ }
}
//MARK: - Header style's related methods
extension RichEditorState {
- //MARK: - Remove Header style
- /**
+ //MARK: - Remove Header style
+ /**
This will handle the adding header style in editor and to relative span
- Parameters:
- style: is of type RichTextSpanStyle
*/
- private func handleAddOrRemoveStyleToLine(
- in range: NSRange, style: RichTextSpanStyle, byAdding: Bool = true
- ) {
- guard !rawText.isEmpty else { return }
-
- let range =
- style.isList
- ? getListRangeFor(range, in: rawText)
- : rawText.getHeaderRangeFor(range)
- processSpansFor(new: style, in: range, addStyle: byAdding)
- }
-
- /**
+ private func handleAddOrRemoveStyleToLine(
+ in range: NSRange, style: RichTextSpanStyle, byAdding: Bool = true
+ ) {
+ guard !rawText.isEmpty else { return }
+
+ let range =
+ style.isList
+ ? getListRangeFor(range, in: rawText)
+ : rawText.getHeaderRangeFor(range)
+ processSpansFor(new: style, in: range, addStyle: byAdding)
+ }
+
+ /**
This will remove header style form selected range of text
- Parameters:
- newText: it's NSMutableAttributedString
- range: is the NSRange
- newLineIndex: is string index of new line where is it located
*/
- private func handleRemoveHeaderStyle(
- newText: String? = nil, at range: NSRange, newLineIndex: Int
- ) {
- let text = newText ?? rawText
- let startIndex = max(0, text.map({ $0 }).index(before: newLineIndex))
+ private func handleRemoveHeaderStyle(
+ newText: String? = nil, at range: NSRange, newLineIndex: Int
+ ) {
+ let text = newText ?? rawText
+ let startIndex = max(0, text.map({ $0 }).index(before: newLineIndex))
- let endIndex = text.map({ $0 }).index(after: newLineIndex)
+ let endIndex = text.map({ $0 }).index(after: newLineIndex)
- let selectedParts = internalSpans.filter({
- ($0.from < endIndex && $0.to >= startIndex
- && $0.attributes?.header != nil)
- })
+ let selectedParts = internalSpans.filter({
+ ($0.from < endIndex && $0.to >= startIndex
+ && $0.attributes?.header != nil)
+ })
- internalSpans.removeAll(where: { selectedParts.contains($0) })
- }
+ internalSpans.removeAll(where: { selectedParts.contains($0) })
+ }
- //MARK: - Add Header style
- /**
+ //MARK: - Add Header style
+ /**
This will create span for selected text with provided style
- Parameters:
- styles: is of type [RichTextSpanStyle]
- range: is of type NSRange
*/
- private func processSpansFor(
- new style: RichTextSpanStyle, in range: NSRange, addStyle: Bool = true
- ) {
- guard !range.isCollapsed else {
- return
+ private func processSpansFor(
+ new style: RichTextSpanStyle, in range: NSRange, addStyle: Bool = true
+ ) {
+ guard !range.isCollapsed else {
+ return
+ }
+
+ var processedSpans: [RichTextSpanInternal] = []
+
+ let completeOverlap = getCompleteOverlappingSpans(for: range)
+ var partialOverlap = getPartialOverlappingSpans(for: range)
+ var sameSpans = getSameSpans(for: range)
+
+ partialOverlap.removeAll(where: { completeOverlap.contains($0) })
+ sameSpans.removeAll(where: { completeOverlap.contains($0) })
+
+ let partialOverlapSpan = processPartialOverlappingSpans(
+ partialOverlap, range: range, style: style, addStyle: addStyle)
+ let completeOverlapSpan = processCompleteOverlappingSpans(
+ completeOverlap, range: range, style: style, addStyle: addStyle)
+ let sameSpan = processSameSpans(
+ sameSpans, range: range, style: style, addStyle: addStyle)
+
+ processedSpans.append(contentsOf: partialOverlapSpan)
+ processedSpans.append(contentsOf: completeOverlapSpan)
+ processedSpans.append(contentsOf: sameSpan)
+
+ processedSpans = mergeSameStyledSpans(processedSpans)
+
+ internalSpans.removeAll(where: {
+ $0.closedRange.overlaps(range.closedRange)
+ })
+ internalSpans.append(contentsOf: processedSpans)
+ internalSpans = mergeSameStyledSpans(internalSpans)
+ internalSpans.sort(by: { $0.from < $1.from })
+ }
+
+ private func processCompleteOverlappingSpans(
+ _ spans: [RichTextSpanInternal], range: NSRange,
+ style: RichTextSpanStyle, addStyle: Bool = true
+ ) -> [RichTextSpanInternal] {
+ var processedSpans: [RichTextSpanInternal] = []
+
+ for span in spans {
+ if span.closedRange.isInRange(range.closedRange) {
+ processedSpans.append(
+ span.copy(
+ attributes: span.attributes?.copy(
+ with: style, byAdding: addStyle)))
+ } else {
+ if span.from < range.lowerBound {
+ let leftPart = span.copy(to: range.lowerBound - 1)
+ processedSpans.append(leftPart)
}
- var processedSpans: [RichTextSpanInternal] = []
-
- let completeOverlap = getCompleteOverlappingSpans(for: range)
- var partialOverlap = getPartialOverlappingSpans(for: range)
- var sameSpans = getSameSpans(for: range)
-
- partialOverlap.removeAll(where: { completeOverlap.contains($0) })
- sameSpans.removeAll(where: { completeOverlap.contains($0) })
-
- let partialOverlapSpan = processPartialOverlappingSpans(
- partialOverlap, range: range, style: style, addStyle: addStyle)
- let completeOverlapSpan = processCompleteOverlappingSpans(
- completeOverlap, range: range, style: style, addStyle: addStyle)
- let sameSpan = processSameSpans(
- sameSpans, range: range, style: style, addStyle: addStyle)
-
- processedSpans.append(contentsOf: partialOverlapSpan)
- processedSpans.append(contentsOf: completeOverlapSpan)
- processedSpans.append(contentsOf: sameSpan)
-
- processedSpans = mergeSameStyledSpans(processedSpans)
-
- internalSpans.removeAll(where: {
- $0.closedRange.overlaps(range.closedRange)
- })
- internalSpans.append(contentsOf: processedSpans)
- internalSpans = mergeSameStyledSpans(internalSpans)
- internalSpans.sort(by: { $0.from < $1.from })
- }
-
- private func processCompleteOverlappingSpans(
- _ spans: [RichTextSpanInternal], range: NSRange,
- style: RichTextSpanStyle, addStyle: Bool = true
- ) -> [RichTextSpanInternal] {
- var processedSpans: [RichTextSpanInternal] = []
-
- for span in spans {
- if span.closedRange.isInRange(range.closedRange) {
- processedSpans.append(
- span.copy(
- attributes: span.attributes?.copy(
- with: style, byAdding: addStyle)))
- } else {
- if span.from < range.lowerBound {
- let leftPart = span.copy(to: range.lowerBound - 1)
- processedSpans.append(leftPart)
- }
-
- if span.from <= (range.lowerBound)
- && span.to >= (range.upperBound - 1)
- {
- let centerPart = span.copy(
- from: range.lowerBound, to: range.upperBound - 1,
- attributes: span.attributes?.copy(
- with: style, byAdding: addStyle))
- processedSpans.append(centerPart)
- }
-
- if span.to > (range.upperBound - 1) {
- let rightPart = span.copy(from: range.upperBound)
- processedSpans.append(rightPart)
- }
- }
+ if span.from <= (range.lowerBound)
+ && span.to >= (range.upperBound - 1)
+ {
+ let centerPart = span.copy(
+ from: range.lowerBound, to: range.upperBound - 1,
+ attributes: span.attributes?.copy(
+ with: style, byAdding: addStyle))
+ processedSpans.append(centerPart)
}
- processedSpans = mergeSameStyledSpans(processedSpans)
-
- return processedSpans
- }
-
- private func processPartialOverlappingSpans(
- _ spans: [RichTextSpanInternal], range: NSRange,
- style: RichTextSpanStyle, addStyle: Bool = true
- ) -> [RichTextSpanInternal] {
- var processedSpans: [RichTextSpanInternal] = []
-
- for span in spans {
- if span.from < range.location {
- let leftPart = span.copy(to: range.lowerBound - 1)
- let rightPart = span.copy(
- from: range.lowerBound,
- attributes: span.attributes?.copy(
- with: style, byAdding: addStyle))
- processedSpans.append(leftPart)
- processedSpans.append(rightPart)
- } else {
- let leftPart = span.copy(
- to: min(span.to, range.upperBound),
- attributes: span.attributes?.copy(
- with: style, byAdding: addStyle))
- let rightPart = span.copy(from: range.location)
- processedSpans.append(leftPart)
- processedSpans.append(rightPart)
- }
+ if span.to > (range.upperBound - 1) {
+ let rightPart = span.copy(from: range.upperBound)
+ processedSpans.append(rightPart)
}
-
- processedSpans = mergeSameStyledSpans(processedSpans)
- return processedSpans
+ }
+ }
+
+ processedSpans = mergeSameStyledSpans(processedSpans)
+
+ return processedSpans
+ }
+
+ private func processPartialOverlappingSpans(
+ _ spans: [RichTextSpanInternal], range: NSRange,
+ style: RichTextSpanStyle, addStyle: Bool = true
+ ) -> [RichTextSpanInternal] {
+ var processedSpans: [RichTextSpanInternal] = []
+
+ for span in spans {
+ if span.from < range.location {
+ let leftPart = span.copy(to: range.lowerBound - 1)
+ let rightPart = span.copy(
+ from: range.lowerBound,
+ attributes: span.attributes?.copy(
+ with: style, byAdding: addStyle))
+ processedSpans.append(leftPart)
+ processedSpans.append(rightPart)
+ } else {
+ let leftPart = span.copy(
+ to: min(span.to, range.upperBound),
+ attributes: span.attributes?.copy(
+ with: style, byAdding: addStyle))
+ let rightPart = span.copy(from: range.location)
+ processedSpans.append(leftPart)
+ processedSpans.append(rightPart)
+ }
+ }
+
+ processedSpans = mergeSameStyledSpans(processedSpans)
+ return processedSpans
+ }
+
+ private func processSameSpans(
+ _ spans: [RichTextSpanInternal], range: NSRange,
+ style: RichTextSpanStyle, addStyle: Bool = true
+ ) -> [RichTextSpanInternal] {
+ var processedSpans: [RichTextSpanInternal] = []
+
+ processedSpans = spans.map({
+ $0.copy(
+ attributes: $0.attributes?.copy(with: style, byAdding: addStyle)
+ )
+ })
+
+ processedSpans = mergeSameStyledSpans(processedSpans)
+ return processedSpans
+ }
+
+ // merge adjacent spans with same style
+ private func mergeSameStyledSpans(_ spans: [RichTextSpanInternal])
+ -> [RichTextSpanInternal]
+ {
+ guard !spans.isEmpty else { return [] }
+ var mergedSpans: [RichTextSpanInternal] = []
+ var previousSpan: RichTextSpanInternal?
+
+ for span in spans.sorted(by: { $0.from < $1.from }) {
+ if let current = previousSpan {
+ if span.attributes?.stylesSet()
+ == current.attributes?.stylesSet()
+ {
+ // Merge overlapping spans
+ previousSpan = current.copy(to: max(current.to, span.to))
+ } else {
+ // Add merged span and start a new span
+ mergedSpans.append(current)
+ previousSpan = span
+ }
+ } else {
+ previousSpan = span
+ }
}
- private func processSameSpans(
- _ spans: [RichTextSpanInternal], range: NSRange,
- style: RichTextSpanStyle, addStyle: Bool = true
- ) -> [RichTextSpanInternal] {
- var processedSpans: [RichTextSpanInternal] = []
-
- processedSpans = spans.map({
- $0.copy(
- attributes: $0.attributes?.copy(with: style, byAdding: addStyle)
- )
- })
-
- processedSpans = mergeSameStyledSpans(processedSpans)
- return processedSpans
+ // Append the last current span
+ if let lastSpan = previousSpan {
+ mergedSpans.append(lastSpan)
}
- // merge adjacent spans with same style
- private func mergeSameStyledSpans(_ spans: [RichTextSpanInternal])
- -> [RichTextSpanInternal]
- {
- guard !spans.isEmpty else { return [] }
- var mergedSpans: [RichTextSpanInternal] = []
- var previousSpan: RichTextSpanInternal?
-
- for span in spans.sorted(by: { $0.from < $1.from }) {
- if let current = previousSpan {
- if span.attributes?.stylesSet()
- == current.attributes?.stylesSet()
- {
- // Merge overlapping spans
- previousSpan = current.copy(to: max(current.to, span.to))
- } else {
- // Add merged span and start a new span
- mergedSpans.append(current)
- previousSpan = span
- }
- } else {
- previousSpan = span
- }
- }
-
- // Append the last current span
- if let lastSpan = previousSpan {
- mergedSpans.append(lastSpan)
- }
-
- return mergedSpans.sorted(by: { $0.from < $1.from })
- }
+ return mergedSpans.sorted(by: { $0.from < $1.from })
+ }
}
//MARK: - Add Bullet list
extension RichEditorState {
- private func getListRangeFor(_ range: NSRange, in text: String) -> NSRange {
- guard !text.isEmpty else { return range }
- let lineRange = currentLine.lineRange
+ private func getListRangeFor(_ range: NSRange, in text: String) -> NSRange {
+ guard !text.isEmpty else { return range }
+ let lineRange = currentLine.lineRange
- guard !range.isCollapsed else { return lineRange }
+ guard !range.isCollapsed else { return lineRange }
- let fromIndex = range.lowerBound
- let toIndex = range.isCollapsed ? fromIndex : range.upperBound
+ let fromIndex = range.lowerBound
+ let toIndex = range.isCollapsed ? fromIndex : range.upperBound
- let newLineStartIndex =
- text.utf16.prefix(fromIndex).map({ $0 }).lastIndex(
- of: "\n".utf16.last) ?? 0
- let newLineEndIndex = text.utf16.suffix(
- from: text.utf16.index(text.utf16.startIndex, offsetBy: toIndex - 1)
- ).map({ $0 }).firstIndex(of: "\n".utf16.last)
+ let newLineStartIndex =
+ text.utf16.prefix(fromIndex).map({ $0 }).lastIndex(
+ of: "\n".utf16.last) ?? 0
+ let newLineEndIndex = text.utf16.suffix(
+ from: text.utf16.index(text.utf16.startIndex, offsetBy: toIndex - 1)
+ ).map({ $0 }).firstIndex(of: "\n".utf16.last)
- ///Added +1 to start new line after \n otherwise it will create bullets for previous line as well
- let startIndex = max(0, (newLineStartIndex + 1))
- var endIndex = (toIndex - 1) + (newLineEndIndex ?? 0)
+ ///Added +1 to start new line after \n otherwise it will create bullets for previous line as well
+ let startIndex = max(0, (newLineStartIndex + 1))
+ var endIndex = (toIndex - 1) + (newLineEndIndex ?? 0)
- if newLineEndIndex == nil {
- endIndex = (text.utf16Length)
- }
-
- let range = startIndex...endIndex
- return range.nsRange
+ if newLineEndIndex == nil {
+ endIndex = (text.utf16Length)
}
+
+ let range = startIndex...endIndex
+ return range.nsRange
+ }
}
//MARK: - RichTextSpanInternal Helper
extension RichEditorState {
- /**
+ /**
This will provide overlapping span for range
- Parameters:
- selectedRange: is of type NSRange
*/
- private func getOverlappingSpans(for selectedRange: NSRange)
- -> [RichTextSpanInternal]
- {
- return internalSpans.filter {
- $0.closedRange.overlaps(selectedRange.closedRange)
- }
+ private func getOverlappingSpans(for selectedRange: NSRange)
+ -> [RichTextSpanInternal]
+ {
+ return internalSpans.filter {
+ $0.closedRange.overlaps(selectedRange.closedRange)
}
+ }
- /**
+ /**
This will provide partial overlapping span for range
- Parameters:
- selectedRange: selectedRange is of type NSRange
*/
- func getPartialOverlappingSpans(for selectedRange: NSRange)
- -> [RichTextSpanInternal]
- {
- return getOverlappingSpans(for: selectedRange).filter({
- $0.closedRange.isPartialOverlap(selectedRange.closedRange)
- })
- }
-
- /**
+ func getPartialOverlappingSpans(for selectedRange: NSRange)
+ -> [RichTextSpanInternal]
+ {
+ return getOverlappingSpans(for: selectedRange).filter({
+ $0.closedRange.isPartialOverlap(selectedRange.closedRange)
+ })
+ }
+
+ /**
This will provide complete overlapping span for range
- Parameters:
- selectedRange: selectedRange is of type NSRange
*/
- func getCompleteOverlappingSpans(for selectedRange: NSRange)
- -> [RichTextSpanInternal]
- {
- return getOverlappingSpans(for: selectedRange).filter({
- $0.closedRange.isInRange(selectedRange.closedRange)
- || selectedRange.closedRange.isInRange($0.closedRange)
- })
- }
-
- /**
+ func getCompleteOverlappingSpans(for selectedRange: NSRange)
+ -> [RichTextSpanInternal]
+ {
+ return getOverlappingSpans(for: selectedRange).filter({
+ $0.closedRange.isInRange(selectedRange.closedRange)
+ || selectedRange.closedRange.isInRange($0.closedRange)
+ })
+ }
+
+ /**
This will provide same span for range
- Parameters:
- selectedRange: selectedRange is of type NSRange
*/
- func getSameSpans(for selectedRange: NSRange) -> [RichTextSpanInternal] {
- return getOverlappingSpans(for: selectedRange).filter({
- $0.closedRange.isSameAs(selectedRange.closedRange)
- })
- }
+ func getSameSpans(for selectedRange: NSRange) -> [RichTextSpanInternal] {
+ return getOverlappingSpans(for: selectedRange).filter({
+ $0.closedRange.isSameAs(selectedRange.closedRange)
+ })
+ }
}
//MARK: - Helper Methods
extension RichEditorState {
- /**
+ /**
This will reset the editor. It will remove all the text form the editor.
*/
- public func reset() {
- internalSpans.removeAll()
- rawText = ""
- attributedString = NSMutableAttributedString(string: "")
- }
+ public func reset() {
+ internalSpans.removeAll()
+ rawText = ""
+ attributedString = NSMutableAttributedString(string: "")
+ }
- /**
+ /**
This will provide Set of RichTextSpanStyle applied on given index
- Parameters:
- index: index or location of text
*/
- private func getRichSpanStyleByTextIndex(_ index: Int) -> Set<
- RichTextSpanStyle
- > {
- let styles = Set(
- internalSpans.filter { index >= $0.from && index <= $0.to }.map {
- $0.attributes?.styles() ?? []
- }.flatMap({ $0 }))
- return styles
- }
-
- /**
+ private func getRichSpanStyleByTextIndex(_ index: Int) -> Set<
+ RichTextSpanStyle
+ > {
+ let styles = Set(
+ internalSpans.filter { index >= $0.from && index <= $0.to }.map {
+ $0.attributes?.styles() ?? []
+ }.flatMap({ $0 }))
+ return styles
+ }
+
+ /**
This will provide Array of RichTextSpanStyle applied on given range
- Parameters:
- range: range of text which is of type NSRange
*/
- private func getRichSpanStyleListByTextRange(_ range: NSRange)
- -> [RichTextSpanStyle]
- {
- return internalSpans.filter({
- range.closedRange.overlaps($0.closedRange)
- }).map { $0.attributes?.styles() ?? [] }.flatMap({ $0 })
- }
+ private func getRichSpanStyleListByTextRange(_ range: NSRange)
+ -> [RichTextSpanStyle]
+ {
+ return internalSpans.filter({
+ range.closedRange.overlaps($0.closedRange)
+ }).map { $0.attributes?.styles() ?? [] }.flatMap({ $0 })
+ }
}
extension RichEditorState {
- func setInternalStyles(style: RichTextSpanStyle, add: Bool = true) {
- switch style {
- case .bold, .italic, .underline, .strikethrough:
- if let style = style.richTextStyle {
- setStyle(style, to: add)
- }
- case .h1, .h2, .h3, .h4, .h5, .h6, .default:
- actionPublisher.send(.setHeaderStyle(style))
- case .bullet(_):
- return
- case .size(let size):
- if let size, fontSize != CGFloat(size) {
- self.fontSize = CGFloat(size)
- }
- case .font(let fontName):
- if let fontName {
- self.fontName = fontName
- }
- case .color(let color):
- if let color {
- setColor(.foreground, to: .init(color))
- }
- case .background(let color):
- if let color {
- setColor(.background, to: .init(color))
- }
- case .align(let alignment):
- if let alignment, alignment != self.textAlignment {
- actionPublisher.send(.setAlignment(alignment))
- }
- case .link(let link):
- actionPublisher.send(.setLink(link))
+ func setInternalStyles(style: RichTextSpanStyle, add: Bool = true) {
+ switch style {
+ case .bold, .italic, .underline, .strikethrough:
+ if let style = style.richTextStyle {
+ setStyle(style, to: add)
+ }
+ case .h1, .h2, .h3, .h4, .h5, .h6, .default:
+ actionPublisher.send(.setHeaderStyle(style))
+ case .bullet(_):
+ return
+ case .size(let size):
+ if let size, fontSize != CGFloat(size) {
+ self.fontSize = CGFloat(size)
+ }
+ case .font(let fontName):
+ if let fontName {
+ self.fontName = fontName
+ }
+ case .color(let color):
+ if let color {
+ setColor(.foreground, to: .init(color))
+ }
+ case .background(let color):
+ if let color {
+ setColor(.background, to: .init(color))
+ }
+ case .align(let alignment):
+ if let alignment, alignment != self.textAlignment {
+ actionPublisher.send(.setAlignment(alignment))
+ }
+ case .link(let link):
+ actionPublisher.send(.setLink(link))
+ case .image(let imageUrl):
+ if let imageUrl {
+ Task {
+ let image = try? await ImageDownloadManager.shared.fetchImage(from: imageUrl)
+ if let image {
+ actionPublisher.send(
+ .pasteImage(
+ RichTextInsertion(content: image, index: selectedRange.location, moveCursor: true)))
+ }
}
+ }
}
+ }
}
diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift
index fb44093..425239b 100644
--- a/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift
+++ b/Sources/RichEditorSwiftUI/UI/Editor/RichTextSpanStyle.swift
@@ -9,440 +9,448 @@ import SwiftUI
public enum RichTextSpanStyle: Equatable, CaseIterable, Hashable {
- public static var allCases: [RichTextSpanStyle] = [
- .default,
- .bold,
- .italic,
- .underline,
- .strikethrough,
- .h1,
- .h2,
- .h3,
- .h4,
- .h5,
- .h6,
- .bullet(),
- .size(),
- .font(),
- .color(),
- .background(),
- .align(),
- ]
-
- public func hash(into hasher: inout Hasher) {
- hasher.combine(key)
-
- if case .bullet(let indent) = self {
- hasher.combine(indent)
- }
- if case .align(let alignment) = self {
- hasher.combine(alignment)
- }
+ public static var allCases: [RichTextSpanStyle] = [
+ .default,
+ .bold,
+ .italic,
+ .underline,
+ .strikethrough,
+ .h1,
+ .h2,
+ .h3,
+ .h4,
+ .h5,
+ .h6,
+ .bullet(),
+ .size(),
+ .font(),
+ .color(),
+ .background(),
+ .align(),
+ ]
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(key)
+
+ if case .bullet(let indent) = self {
+ hasher.combine(indent)
}
-
- case `default`
- case bold
- case italic
- case underline
- case strikethrough
- case h1
- case h2
- case h3
- case h4
- case h5
- case h6
- case bullet(_ indent: Int? = nil)
- // case ordered(_ indent: Int? = nil)
- case size(Int? = nil)
- case font(String? = nil)
- case color(Color? = nil)
- case background(Color? = nil)
- case align(RichTextAlignment? = nil)
- case link(String? = nil)
-
- var key: String {
- switch self {
- case .default:
- return "default"
- case .bold:
- return "bold"
- case .italic:
- return "italic"
- case .underline:
- return "underline"
- case .strikethrough:
- return "strikethrough"
- case .h1:
- return "h1"
- case .h2:
- return "h2"
- case .h3:
- return "h3"
- case .h4:
- return "h4"
- case .h5:
- return "h5"
- case .h6:
- return "h6"
- case .bullet:
- return "bullet"
- // case .ordered:
- // return "ordered"
- case .size:
- return "size"
- case .font:
- return "font"
- case .color:
- return "color"
- case .background:
- return "background"
- case .align(let alignment):
- return "align" + "\(alignment?.rawValue ?? "")"
- case .link(let link):
- return "link" + (link ?? "")
- }
+ if case .align(let alignment) = self {
+ hasher.combine(alignment)
}
-
- func defaultAttributeValue(font: FontRepresentable? = nil) -> Any {
- let font = font ?? .systemFont(ofSize: .standardRichTextFontSize)
- switch self {
- case .underline:
- return NSUnderlineStyle.single.rawValue
- case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6:
- return getFontWithUpdating(font: font)
- case .bullet(let indent):
- return getListStyleAttributeValue(
- listType ?? .bullet(), indent: indent)
- case .strikethrough:
- return NSUnderlineStyle.single.rawValue
- case .size(let size):
- if let fontSize = size {
- return getFontWithUpdating(font: font)
- .withSize(CGFloat(fontSize))
- } else {
- return getFontWithUpdating(font: font)
- }
- case .font(let name):
- if let name {
- return FontRepresentable(
- name: name,
- size: .standardRichTextFontSize
- ) ?? font
- } else {
- #if os(watchOS)
- return CGFloat.standardRichTextFontSize
- #else
- return RichTextView.Theme.standard.font
- #endif
- }
- case .color:
- #if os(watchOS)
- return Color.primary
- #else
- return RichTextView.Theme.standard.fontColor
- #endif
- case .background:
- return ColorRepresentable.white
- case .align:
- return RichTextAlignment.left.nativeAlignment
- case .link(let link):
- return link ?? ""
- }
+ }
+
+ case `default`
+ case bold
+ case italic
+ case underline
+ case strikethrough
+ case h1
+ case h2
+ case h3
+ case h4
+ case h5
+ case h6
+ case bullet(_ indent: Int? = nil)
+ // case ordered(_ indent: Int? = nil)
+ case size(Int? = nil)
+ case font(String? = nil)
+ case color(Color? = nil)
+ case background(Color? = nil)
+ case align(RichTextAlignment? = nil)
+ case link(String? = nil)
+ case image(String? = nil)
+
+ var key: String {
+ switch self {
+ case .default:
+ return "default"
+ case .bold:
+ return "bold"
+ case .italic:
+ return "italic"
+ case .underline:
+ return "underline"
+ case .strikethrough:
+ return "strikethrough"
+ case .h1:
+ return "h1"
+ case .h2:
+ return "h2"
+ case .h3:
+ return "h3"
+ case .h4:
+ return "h4"
+ case .h5:
+ return "h5"
+ case .h6:
+ return "h6"
+ case .bullet:
+ return "bullet"
+ // case .ordered:
+ // return "ordered"
+ case .size:
+ return "size"
+ case .font:
+ return "font"
+ case .color:
+ return "color"
+ case .background:
+ return "background"
+ case .align(let alignment):
+ return "align" + "\(alignment?.rawValue ?? "")"
+ case .link(let link):
+ return "link" + (link ?? "")
+ case .image(let image):
+ return "image" + (image ?? "")
}
-
- var attributedStringKey: NSAttributedString.Key {
- switch self {
- case .underline:
- return .underlineStyle
- case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6, .size,
- .font:
- return .font
- case .bullet, .align:
- return .paragraphStyle
- case .strikethrough:
- return .strikethroughStyle
- case .color:
- return .foregroundColor
- case .background:
- return .backgroundColor
- case .link:
- return .link
- }
+ }
+
+ func defaultAttributeValue(font: FontRepresentable? = nil) -> Any {
+ let font = font ?? .systemFont(ofSize: .standardRichTextFontSize)
+ switch self {
+ case .underline:
+ return NSUnderlineStyle.single.rawValue
+ case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6:
+ return getFontWithUpdating(font: font)
+ case .bullet(let indent):
+ return getListStyleAttributeValue(
+ listType ?? .bullet(), indent: indent)
+ case .strikethrough:
+ return NSUnderlineStyle.single.rawValue
+ case .size(let size):
+ if let fontSize = size {
+ return getFontWithUpdating(font: font)
+ .withSize(CGFloat(fontSize))
+ } else {
+ return getFontWithUpdating(font: font)
+ }
+ case .font(let name):
+ if let name {
+ return FontRepresentable(
+ name: name,
+ size: .standardRichTextFontSize
+ ) ?? font
+ } else {
+ #if os(watchOS)
+ return CGFloat.standardRichTextFontSize
+ #else
+ return RichTextView.Theme.standard.font
+ #endif
+ }
+ case .color:
+ #if os(watchOS)
+ return Color.primary
+ #else
+ return RichTextView.Theme.standard.fontColor
+ #endif
+ case .background:
+ return ColorRepresentable.white
+ case .align:
+ return RichTextAlignment.left.nativeAlignment
+ case .link(let link):
+ return link ?? ""
+ case .image(let image):
+ return image ?? ""
}
-
- public static func == (lhs: RichTextSpanStyle, rhs: RichTextSpanStyle)
- -> Bool
- {
- return lhs.key == rhs.key
+ }
+
+ var attributedStringKey: NSAttributedString.Key {
+ switch self {
+ case .underline:
+ return .underlineStyle
+ case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6, .size,
+ .font:
+ return .font
+ case .bullet, .align:
+ return .paragraphStyle
+ case .strikethrough:
+ return .strikethroughStyle
+ case .color:
+ return .foregroundColor
+ case .background:
+ return .backgroundColor
+ case .link:
+ return .link
+ case .image:
+ return .attachment
}
-
- /// The standard icon to use for the trait.
- var icon: Image {
- switch self {
- case .bold: .richTextStyleBold
- case .italic: .richTextStyleItalic
- case .strikethrough: .richTextStyleStrikethrough
- case .underline: .richTextStyleUnderline
- default: .richTextPrint
- }
+ }
+
+ public static func == (lhs: RichTextSpanStyle, rhs: RichTextSpanStyle)
+ -> Bool
+ {
+ return lhs.key == rhs.key
+ }
+
+ /// The standard icon to use for the trait.
+ var icon: Image {
+ switch self {
+ case .bold: .richTextStyleBold
+ case .italic: .richTextStyleItalic
+ case .strikethrough: .richTextStyleStrikethrough
+ case .underline: .richTextStyleUnderline
+ default: .richTextPrint
}
-
- /// The localized style title key.
- var titleKey: RTEL10n {
- switch self {
- case .bold: .styleBold
- case .italic: .styleItalic
- case .underline: .styleUnderlined
- case .strikethrough: .styleStrikethrough
- default: .done
- }
+ }
+
+ /// The localized style title key.
+ var titleKey: RTEL10n {
+ switch self {
+ case .bold: .styleBold
+ case .italic: .styleItalic
+ case .underline: .styleUnderlined
+ case .strikethrough: .styleStrikethrough
+ default: .done
}
-
- var richTextStyle: RichTextStyle? {
- switch self {
- case .bold: .bold
- case .italic: .italic
- case .underline: .underline
- case .strikethrough: .strikethrough
- default: nil
- }
+ }
+
+ var richTextStyle: RichTextStyle? {
+ switch self {
+ case .bold: .bold
+ case .italic: .italic
+ case .underline: .underline
+ case .strikethrough: .strikethrough
+ default: nil
}
-
- var listType: ListType? {
- switch self {
- case .bullet(let indent):
- return .bullet(indent)
- default:
- return nil
- }
+ }
+
+ var listType: ListType? {
+ switch self {
+ case .bullet(let indent):
+ return .bullet(indent)
+ default:
+ return nil
}
-
- var headerType: HeaderType {
- switch self {
- case .h1:
- return .h1
- case .h2:
- return .h2
- case .h3:
- return .h3
- case .h4:
- return .h4
- case .h5:
- return .h5
- case .h6:
- return .h6
- default:
- return .default
- }
+ }
+
+ var headerType: HeaderType {
+ switch self {
+ case .h1:
+ return .h1
+ case .h2:
+ return .h2
+ case .h3:
+ return .h3
+ case .h4:
+ return .h4
+ case .h5:
+ return .h5
+ case .h6:
+ return .h6
+ default:
+ return .default
}
-
- var isHeaderStyle: Bool {
- switch self {
- case .h1, .h2, .h3, .h4, .h5, .h6:
- return true
- default:
- return false
- }
+ }
+
+ var isHeaderStyle: Bool {
+ switch self {
+ case .h1, .h2, .h3, .h4, .h5, .h6:
+ return true
+ default:
+ return false
}
-
- var isAlignmentStyle: Bool {
- switch self {
- case .align:
- return true
- default:
- return false
- }
+ }
+
+ var isAlignmentStyle: Bool {
+ switch self {
+ case .align:
+ return true
+ default:
+ return false
}
-
- var isList: Bool {
- switch self {
- case .bullet:
- return true
- default:
- return false
- }
+ }
+
+ var isList: Bool {
+ switch self {
+ case .bullet:
+ return true
+ default:
+ return false
}
-
- var isDefault: Bool {
- switch self {
- case .default:
- return true
- case .align(let alignment):
- return alignment == .left
- default:
- return false
- }
+ }
+
+ var isDefault: Bool {
+ switch self {
+ case .default:
+ return true
+ case .align(let alignment):
+ return alignment == .left
+ default:
+ return false
}
-
- func getFontWithUpdating(font: FontRepresentable) -> FontRepresentable {
- switch self {
- case .default:
- return font
- case .bold, .italic:
- return font.addFontStyle(self)
- case .underline, .bullet, .strikethrough, .color, .background, .align,
- .link:
- return font
- case .h1:
- return font.updateFontSize(multiple: 1.5)
- case .h2:
- return font.updateFontSize(multiple: 1.4)
- case .h3:
- return font.updateFontSize(multiple: 1.3)
- case .h4:
- return font.updateFontSize(multiple: 1.2)
- case .h5:
- return font.updateFontSize(multiple: 1.1)
- case .h6:
- return font.updateFontSize(multiple: 1)
- case .size(let size):
- if let size {
- return font.updateFontSize(size: CGFloat(size))
- } else {
- return font
- }
- case .font(let name):
- if let name {
- return FontRepresentable(name: name, size: font.pointSize)
- ?? font
- } else {
- return font
- }
- }
- }
-
- var fontSizeMultiplier: CGFloat {
- switch self {
- case .h1:
- return 1.5
- case .h2:
- return 1.4
- case .h3:
- return 1.3
- case .h4:
- return 1.2
- case .h5:
- return 1.1
- default:
- return 1
- }
+ }
+
+ func getFontWithUpdating(font: FontRepresentable) -> FontRepresentable {
+ switch self {
+ case .default:
+ return font
+ case .bold, .italic:
+ return font.addFontStyle(self)
+ case .underline, .bullet, .strikethrough, .color, .background, .align, .image,
+ .link:
+ return font
+ case .h1:
+ return font.updateFontSize(multiple: 1.5)
+ case .h2:
+ return font.updateFontSize(multiple: 1.4)
+ case .h3:
+ return font.updateFontSize(multiple: 1.3)
+ case .h4:
+ return font.updateFontSize(multiple: 1.2)
+ case .h5:
+ return font.updateFontSize(multiple: 1.1)
+ case .h6:
+ return font.updateFontSize(multiple: 1)
+ case .size(let size):
+ if let size {
+ return font.updateFontSize(size: CGFloat(size))
+ } else {
+ return font
+ }
+ case .font(let name):
+ if let name {
+ return FontRepresentable(name: name, size: font.pointSize)
+ ?? font
+ } else {
+ return font
+ }
}
-
- func getFontAfterRemovingStyle(font: FontRepresentable) -> FontRepresentable
- {
- switch self {
- case .bold, .italic, .bullet:
- return font.removeFontStyle(self)
- case .underline, .strikethrough, .color, .background, .align, .link:
- return font
- case .default, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font:
- return font.updateFontSize(size: .standardRichTextFontSize)
- }
+ }
+
+ var fontSizeMultiplier: CGFloat {
+ switch self {
+ case .h1:
+ return 1.5
+ case .h2:
+ return 1.4
+ case .h3:
+ return 1.3
+ case .h4:
+ return 1.2
+ case .h5:
+ return 1.1
+ default:
+ return 1
}
-
- func getListStyleAttributeValue(_ listType: ListType, indent: Int? = nil)
- -> NSMutableParagraphStyle
- {
- let paragraphStyle = NSMutableParagraphStyle()
- paragraphStyle.alignment = .left
- let listItem = TextList(
- markerFormat: listType.getMarkerFormat(), options: 0)
- paragraphStyle.textLists = Array(
- repeating: listItem, count: (indent ?? 0) + 1)
- return paragraphStyle
+ }
+
+ func getFontAfterRemovingStyle(font: FontRepresentable) -> FontRepresentable {
+ switch self {
+ case .bold, .italic, .bullet:
+ return font.removeFontStyle(self)
+ case .underline, .strikethrough, .color, .background, .align, .link, .image:
+ return font
+ case .default, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font:
+ return font.updateFontSize(size: .standardRichTextFontSize)
}
+ }
+
+ func getListStyleAttributeValue(_ listType: ListType, indent: Int? = nil)
+ -> NSMutableParagraphStyle
+ {
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.alignment = .left
+ let listItem = TextList(
+ markerFormat: listType.getMarkerFormat(), options: 0)
+ paragraphStyle.textLists = Array(
+ repeating: listItem, count: (indent ?? 0) + 1)
+ return paragraphStyle
+ }
}
#if canImport(UIKit)
- extension RichTextSpanStyle {
-
- /// The symbolic font traits for the style, if any.
- public var symbolicTraits: UIFontDescriptor.SymbolicTraits? {
- switch self {
- case .bold: .traitBold
- case .italic: .traitItalic
- default: nil
- }
- }
+ extension RichTextSpanStyle {
+
+ /// The symbolic font traits for the style, if any.
+ public var symbolicTraits: UIFontDescriptor.SymbolicTraits? {
+ switch self {
+ case .bold: .traitBold
+ case .italic: .traitItalic
+ default: nil
+ }
}
+ }
#endif
#if macOS
- extension RichTextSpanStyle {
-
- /// The symbolic font traits for the trait, if any.
- public var symbolicTraits: NSFontDescriptor.SymbolicTraits? {
- switch self {
- case .bold: .bold
- case .italic: .italic
- default: nil
- }
- }
+ extension RichTextSpanStyle {
+
+ /// The symbolic font traits for the trait, if any.
+ public var symbolicTraits: NSFontDescriptor.SymbolicTraits? {
+ switch self {
+ case .bold: .bold
+ case .italic: .italic
+ default: nil
+ }
}
+ }
#endif
extension RichTextSpanStyle {
- func getRichAttribute() -> RichAttributes? {
- switch self {
- case .default:
- return nil
- case .bold:
- return RichAttributes(bold: true)
- case .italic:
- return RichAttributes(italic: true)
- case .underline:
- return RichAttributes(underline: true)
- case .strikethrough:
- return RichAttributes(strike: true)
- case .bullet:
- return RichAttributes(list: .bullet())
- case .h1:
- return RichAttributes(header: .h1)
- case .h2:
- return RichAttributes(header: .h2)
- case .h3:
- return RichAttributes(header: .h3)
- case .h4:
- return RichAttributes(header: .h4)
- case .h5:
- return RichAttributes(header: .h5)
- case .h6:
- return RichAttributes(header: .h6)
- case .size(let size):
- return RichAttributes(size: size)
- case .font(let font):
- return RichAttributes(font: font)
- case .color(let color):
- return RichAttributes(color: color?.hexString)
- case .background(let background):
- return RichAttributes(background: background?.hexString)
- case .align(let alignment):
- return RichAttributes(align: alignment)
- case .link(let link):
- return RichAttributes(link: link)
- }
+ func getRichAttribute() -> RichAttributes? {
+ switch self {
+ case .default:
+ return nil
+ case .bold:
+ return RichAttributes(bold: true)
+ case .italic:
+ return RichAttributes(italic: true)
+ case .underline:
+ return RichAttributes(underline: true)
+ case .strikethrough:
+ return RichAttributes(strike: true)
+ case .bullet:
+ return RichAttributes(list: .bullet())
+ case .h1:
+ return RichAttributes(header: .h1)
+ case .h2:
+ return RichAttributes(header: .h2)
+ case .h3:
+ return RichAttributes(header: .h3)
+ case .h4:
+ return RichAttributes(header: .h4)
+ case .h5:
+ return RichAttributes(header: .h5)
+ case .h6:
+ return RichAttributes(header: .h6)
+ case .size(let size):
+ return RichAttributes(size: size)
+ case .font(let font):
+ return RichAttributes(font: font)
+ case .color(let color):
+ return RichAttributes(color: color?.hexString)
+ case .background(let background):
+ return RichAttributes(background: background?.hexString)
+ case .align(let alignment):
+ return RichAttributes(align: alignment)
+ case .link(let link):
+ return RichAttributes(link: link)
+ case .image(let image):
+ return RichAttributes(image: image)
}
+ }
}
extension Collection where Element == RichTextSpanStyle {
- /**
+ /**
Check if the collection contains a certain style.
- Parameters:
- style: The style to look for.
*/
- public func hasStyle(_ style: RichTextSpanStyle) -> Bool {
- contains(style)
- }
-
- /// Check if a certain style change should be applied.
- public func shouldAddOrRemove(
- _ style: RichTextSpanStyle,
- _ newValue: Bool
- ) -> Bool {
- let shouldAdd = newValue && !hasStyle(style)
- let shouldRemove = !newValue && hasStyle(style)
- return shouldAdd || shouldRemove
- }
+ public func hasStyle(_ style: RichTextSpanStyle) -> Bool {
+ contains(style)
+ }
+
+ /// Check if a certain style change should be applied.
+ public func shouldAddOrRemove(
+ _ style: RichTextSpanStyle,
+ _ newValue: Bool
+ ) -> Bool {
+ let shouldAdd = newValue && !hasStyle(style)
+ let shouldRemove = !newValue && hasStyle(style)
+ return shouldAdd || shouldRemove
+ }
}
diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift
index 10c6c78..08e1300 100644
--- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift
+++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift
@@ -1,107 +1,134 @@
//
-// File.swift
+// RichTextView_AppKit.swift
// RichEditorSwiftUI
//
// Created by Divyesh Vekariya on 19/10/24.
//
#if macOS
- import AppKit
-
- /// This is a platform-agnostic rich text view that can be used
- /// in both UIKit and AppKit.
- ///
- /// The view inherits `NSTextView` in AppKit and `UITextView`
- /// in UIKit. It aims to make these views behave more alike and
- /// make them implement ``RichTextViewComponent``, which is the
- /// protocol that is used within this library.
- ///
- /// The view will apply a ``RichTextImageConfiguration/disabled``
- /// image config by default. You can change this by setting the
- /// property manually or by using a ``RichTextDataFormat`` that
- /// supports images.
- open class RichTextView: NSTextView, RichTextViewComponent {
-
- // MARK: - Properties
-
- /// The configuration to use by the rich text view.
- public var configuration: Configuration = .standard
-
- /// The theme for coloring and setting style to text view.
- public var theme: Theme = .standard {
- didSet { setup(theme) }
- }
+ import AppKit
+
+ /// This is a platform-agnostic rich text view that can be used
+ /// in both UIKit and AppKit.
+ ///
+ /// The view inherits `NSTextView` in AppKit and `UITextView`
+ /// in UIKit. It aims to make these views behave more alike and
+ /// make them implement ``RichTextViewComponent``, which is the
+ /// protocol that is used within this library.
+ ///
+ /// The view will apply a ``RichTextImageConfiguration/disabled``
+ /// image config by default. You can change this by setting the
+ /// property manually or by using a ``RichTextDataFormat`` that
+ /// supports images.
+ open class RichTextView: NSTextView, RichTextViewComponent {
+
+ // MARK: - Properties
+
+ /// The configuration to use by the rich text view.
+ public var configuration: Configuration = .standard
+
+ /// The theme for coloring and setting style to text view.
+ public var theme: Theme = .standard {
+ didSet { setup(theme) }
+ }
- /// The style to use when highlighting text in the view.
- public var highlightingStyle: RichTextHighlightingStyle = .standard
+ /// The style to use when highlighting text in the view.
+ public var highlightingStyle: RichTextHighlightingStyle = .standard
- /// The image configuration to use by the rich text view.
- public var imageConfiguration: RichTextImageConfiguration = .disabled {
- didSet { imageConfigurationWasSet = true }
- }
+ /// The image configuration to use by the rich text view.
+ public var imageConfiguration: RichTextImageConfiguration = .disabled {
+ didSet { imageConfigurationWasSet = true }
+ }
+
+ //Pass event to the parent
+ var onTextViewEvent: ((_ event: TextViewEvents) -> Void)? = nil
- /// The image configuration to use by the rich text view.
- var imageConfigurationWasSet = false
+ /// The image configuration to use by the rich text view.
+ var imageConfigurationWasSet = false
- // MARK: - Overrides
+ // MARK: - Overrides
- /// Paste the current pasteboard content into the view.
- open override func paste(_ sender: Any?) {
- let pasteboard = NSPasteboard.general
- if let image = pasteboard.image {
- return pasteImage(image, at: selectedRange.location)
- }
- super.paste(sender)
+ /// Paste the current pasteboard content into the view.
+ open override func paste(_ sender: Any?) {
+ let pasteboard = NSPasteboard.general
+ if let image = pasteboard.image {
+ let insertedString = pasteImage(image, at: selectedRange.location)
+ if let insertedString = insertedString {
+ onTextViewEvent?(
+ .didDroppedItems(
+ insertedString: insertedString, atRange: selectedRange,
+ isReplaced: !selectedRange.isCollapsed, with: [image]))
}
+ return
+ }
+ super.paste(sender)
+ }
- /**
+ /**
Try to perform a certain drag operation, which will get
and paste images from the drag info into the text.
*/
- open override func performDragOperation(_ draggingInfo: NSDraggingInfo) -> Bool {
- let pasteboard = draggingInfo.draggingPasteboard
- if let images = pasteboard.images, images.count > 0 {
- pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true)
- return true
- }
- // Handle fileURLs that contain known image types
- let images = pasteboard.pasteboardItems?.compactMap {
- if let str = $0.string(forType: NSPasteboard.PasteboardType.fileURL),
- let url = URL(string: str), let image = ImageRepresentable(contentsOf: url) {
- let fileExtension = url.pathExtension.lowercased()
- let imageExtensions = ["jpg", "jpeg", "png", "gif", "tiff", "bmp", "heic"]
- if imageExtensions.contains(fileExtension) {
- return image
- }
- }
- return nil
- } ?? [ImageRepresentable]()
- if images.count > 0 {
- pasteImages(images, at: selectedRange().location, moveCursorToPastedContent: true)
- return true
- }
-
- return super.performDragOperation(draggingInfo)
+ open override func performDragOperation(_ draggingInfo: NSDraggingInfo) -> Bool {
+ let pasteboard = draggingInfo.draggingPasteboard
+ if let images = pasteboard.images, images.count > 0 {
+ let insertedString = pasteImages(
+ images, at: selectedRange().location, moveCursorToPastedContent: true)
+ if let insertedString = insertedString {
+ onTextViewEvent?(
+ .didDroppedItems(
+ insertedString: insertedString, atRange: selectedRange(),
+ isReplaced: !selectedRange().isCollapsed, with: images))
+ }
+ return true
+ }
+ // Handle fileURLs that contain known image types
+ let images =
+ pasteboard.pasteboardItems?.compactMap {
+ if let str = $0.string(forType: NSPasteboard.PasteboardType.fileURL),
+ let url = URL(string: str), let image = ImageRepresentable(contentsOf: url)
+ {
+ let fileExtension = url.pathExtension.lowercased()
+ let imageExtensions = ["jpg", "jpeg", "png", "gif", "tiff", "bmp", "heic"]
+ if imageExtensions.contains(fileExtension) {
+ return image
}
+ }
+ return nil
+ } ?? [ImageRepresentable]()
+ if images.count > 0 {
+ let insertedString = pasteImages(
+ images, at: selectedRange().location, moveCursorToPastedContent: true)
+
+ if let insertedString = insertedString {
+ onTextViewEvent?(
+ .didDroppedItems(
+ insertedString: insertedString, atRange: selectedRange(),
+ isReplaced: !selectedRange().isCollapsed, with: images))
+ }
+ return true
+ }
- open override func scrollWheel(with event: NSEvent) {
+ return super.performDragOperation(draggingInfo)
+ }
- if configuration.isScrollingEnabled {
- return super.scrollWheel(with: event)
- }
+ open override func scrollWheel(with event: NSEvent) {
- // 1st nextResponder is NSClipView
- // 2nd nextResponder is NSScrollView
- // 3rd nextResponder is NSResponder SwiftUIPlatformViewHost
- self.nextResponder?
- .nextResponder?
- .nextResponder?
- .scrollWheel(with: event)
- }
+ if configuration.isScrollingEnabled {
+ return super.scrollWheel(with: event)
+ }
- // MARK: - Setup
+ // 1st nextResponder is NSClipView
+ // 2nd nextResponder is NSScrollView
+ // 3rd nextResponder is NSResponder SwiftUIPlatformViewHost
+ self.nextResponder?
+ .nextResponder?
+ .nextResponder?
+ .scrollWheel(with: event)
+ }
+
+ // MARK: - Setup
- /**
+ /**
Setup the rich text view with a rich text and a certain
``RichTextDataFormat``.
@@ -109,46 +136,46 @@
- text: The text to edit with the text view.
- format: The rich text format to edit.
*/
- open func setup(
- with text: NSAttributedString,
- format: RichTextDataFormat?
- ) {
- setupSharedBehavior(with: text, format)
- allowsImageEditing = true
- allowsUndo = true
- layoutManager?.defaultAttachmentScaling =
- NSImageScaling.scaleProportionallyDown
- isContinuousSpellCheckingEnabled =
- configuration.isContinuousSpellCheckingEnabled
- setup(theme)
- }
-
- public func setup(with richText: RichText) {
- var tempSpans: [RichTextSpanInternal] = []
- var text = ""
- richText.spans.forEach({
- let span = RichTextSpanInternal(
- from: text.utf16Length,
- to: (text.utf16Length + $0.insert.utf16Length - 1),
- attributes: $0.attributes)
- tempSpans.append(span)
- text += $0.insert
- })
-
- let str = NSMutableAttributedString(string: text)
-
- tempSpans.forEach { span in
- str.addAttributes(
- span.attributes?.toAttributes(font: .standardRichTextFont)
- ?? [:], range: span.spanRange)
- }
+ open func setup(
+ with text: NSAttributedString,
+ format: RichTextDataFormat?
+ ) {
+ setupSharedBehavior(with: text, format)
+ allowsImageEditing = true
+ allowsUndo = true
+ layoutManager?.defaultAttachmentScaling =
+ NSImageScaling.scaleProportionallyDown
+ isContinuousSpellCheckingEnabled =
+ configuration.isContinuousSpellCheckingEnabled
+ setup(theme)
+ }
- setup(with: str, format: .archivedData)
- }
+ public func setup(with richText: RichText) {
+ var tempSpans: [RichTextSpanInternal] = []
+ var text = ""
+ richText.spans.forEach({
+ let span = RichTextSpanInternal(
+ from: text.utf16Length,
+ to: (text.utf16Length + $0.insert.utf16Length - 1),
+ attributes: $0.attributes)
+ tempSpans.append(span)
+ text += $0.insert
+ })
+
+ let str = NSMutableAttributedString(string: text)
+
+ tempSpans.forEach { span in
+ str.addAttributes(
+ span.attributes?.toAttributes(font: .standardRichTextFont)
+ ?? [:], range: span.spanRange)
+ }
+
+ setup(with: str, format: .archivedData)
+ }
- // MARK: - Open Functionality
+ // MARK: - Open Functionality
- /**
+ /**
Alert a certain title and message.
- Parameters:
@@ -156,108 +183,106 @@
- message: The alert message.
- buttonTitle: The alert button title.
*/
- open func alert(title: String, message: String, buttonTitle: String) {
- let alert = NSAlert()
- alert.messageText = title
- alert.informativeText = message
- alert.alertStyle = NSAlert.Style.warning
- alert.addButton(withTitle: buttonTitle)
- alert.runModal()
- }
+ open func alert(title: String, message: String, buttonTitle: String) {
+ let alert = NSAlert()
+ alert.messageText = title
+ alert.informativeText = message
+ alert.alertStyle = NSAlert.Style.warning
+ alert.addButton(withTitle: buttonTitle)
+ alert.runModal()
+ }
- /// Copy the current selection.
- open func copySelection() {
- let pasteboard = NSPasteboard.general
- let range = safeRange(for: selectedRange)
- let text = richText(at: range)
- pasteboard.clearContents()
- pasteboard.setString(text.string, forType: .string)
- }
+ /// Copy the current selection.
+ open func copySelection() {
+ let pasteboard = NSPasteboard.general
+ let range = safeRange(for: selectedRange)
+ let text = richText(at: range)
+ pasteboard.clearContents()
+ pasteboard.setString(text.string, forType: .string)
+ }
- /// Try to redo the latest undone change.
- open func redoLatestChange() {
- undoManager?.redo()
- }
+ /// Try to redo the latest undone change.
+ open func redoLatestChange() {
+ undoManager?.redo()
+ }
- /// Scroll to a certain range.
- open func scroll(to range: NSRange) {
- scrollRangeToVisible(range)
- }
+ /// Scroll to a certain range.
+ open func scroll(to range: NSRange) {
+ scrollRangeToVisible(range)
+ }
- /// Set the rich text in the text view.
- open func setRichText(_ text: NSAttributedString) {
- attributedString = text
- }
+ /// Set the rich text in the text view.
+ open func setRichText(_ text: NSAttributedString) {
+ attributedString = text
+ }
- /// Undo the latest change.
- open func undoLatestChange() {
- undoManager?.undo()
- }
+ /// Undo the latest change.
+ open func undoLatestChange() {
+ undoManager?.undo()
}
+ }
- // MARK: - Public Extensions
+ // MARK: - Public Extensions
- extension RichTextView {
+ extension RichTextView {
- /// The text view's layout manager, if any.
- public var layoutManagerWrapper: NSLayoutManager? {
- layoutManager
- }
+ /// The text view's layout manager, if any.
+ public var layoutManagerWrapper: NSLayoutManager? {
+ layoutManager
+ }
- /// The spacing between the text view edges and its text.
- public var textContentInset: CGSize {
- get { textContainerInset }
- set { textContainerInset = newValue }
- }
+ /// The spacing between the text view edges and its text.
+ public var textContentInset: CGSize {
+ get { textContainerInset }
+ set { textContainerInset = newValue }
+ }
- /// The text view's text storage, if any.
- public var textStorageWrapper: NSTextStorage? {
- textStorage
- }
+ /// The text view's text storage, if any.
+ public var textStorageWrapper: NSTextStorage? {
+ textStorage
}
+ }
- // MARK: - RichTextProvider
+ // MARK: - RichTextProvider
- extension RichTextView {
+ extension RichTextView {
- /// Get the rich text that is managed by the view.
- public var attributedString: NSAttributedString {
- get { attributedString() }
- set { textStorage?.setAttributedString(newValue) }
- }
+ /// Get the rich text that is managed by the view.
+ public var attributedString: NSAttributedString {
+ get { attributedString() }
+ set { textStorage?.setAttributedString(newValue) }
+ }
- /// Whether or not the text view is the first responder.
- public var isFirstResponder: Bool {
- window?.firstResponder == self
- }
+ /// Whether or not the text view is the first responder.
+ public var isFirstResponder: Bool {
+ window?.firstResponder == self
}
+ }
- // MARK: - RichTextWriter
+ // MARK: - RichTextWriter
- extension RichTextView {
+ extension RichTextView {
- // Get the rich text that is managed by the view.
- public var mutableAttributedString: NSMutableAttributedString? {
- textStorage
- }
+ // Get the rich text that is managed by the view.
+ public var mutableAttributedString: NSMutableAttributedString? {
+ textStorage
}
+ }
- // MARK: - Additional Pasteboard Types
+ // MARK: - Additional Pasteboard Types
- extension RichTextView {
- public override var readablePasteboardTypes:
- [NSPasteboard.PasteboardType]
- {
- var pasteboardTypes = super.readablePasteboardTypes
- pasteboardTypes.append(.png)
- return pasteboardTypes
- }
+ extension RichTextView {
+ public override var readablePasteboardTypes: [NSPasteboard.PasteboardType] {
+ var pasteboardTypes = super.readablePasteboardTypes
+ pasteboardTypes.append(.png)
+ return pasteboardTypes
}
+ }
- extension RichTextView {
- var textString: String {
- return self.string
- }
+ extension RichTextView {
+ var textString: String {
+ return self.string
}
+ }
#endif
diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
index bba26c6..eea57dc 100644
--- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
+++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift
@@ -1,5 +1,5 @@
//
-// File.swift
+// RichTextView_UIKit.swift
// RichEditorSwiftUI
//
// Created by Divyesh Vekariya on 19/10/24.
@@ -23,6 +23,9 @@
/// protocol that is used within this library.
open class RichTextView: UITextView, RichTextViewComponent {
+ //Pass event to the parent
+ var onTextViewEvent: ((_ event: TextViewEvents) -> Void)? = nil
+
// MARK: - Initializers
public convenience init(
@@ -158,7 +161,14 @@
open override func paste(_ sender: Any?) {
let pasteboard = UIPasteboard.general
if let image = pasteboard.image {
- return pasteImage(image, at: selectedRange.location)
+ let insertedString = pasteImage(image, at: selectedRange.location)
+ if let insertedString = insertedString {
+ onTextViewEvent?(
+ .didDroppedItems(
+ insertedString: insertedString, atRange: selectedRange,
+ isReplaced: !selectedRange.isCollapsed, with: [image]))
+ }
+ return
}
super.paste(sender)
}
@@ -291,9 +301,14 @@
_ interaction: UIDropInteraction,
canHandle session: UIDropSession
) -> Bool {
- if session.hasImage && imageDropConfiguration == .disabled { return false }
- let identifiers = supportedDropInteractionTypes.map { $0.identifier }
- return session.hasItemsConforming(toTypeIdentifiers: identifiers)
+ if session.hasImage && imageDropConfiguration == .disabled {
+ return false
+ }
+ let identifiers = supportedDropInteractionTypes.map {
+ $0.identifier
+ }
+ return session.hasItemsConforming(
+ toTypeIdentifiers: identifiers)
}
/// Handle an updated drop session.
@@ -340,12 +355,25 @@
We reverse the item collection, since each item will be
pasted at the original drop point.
*/
- open func performImageDrop(with session: UIDropSession, at range: NSRange) {
- guard validateImageInsertion(for: imageDropConfiguration) else { return }
- session.loadObjects(ofClass: UIImage.self) { items in
- let images = items.compactMap { $0 as? UIImage }.reversed()
- images.forEach { self.pasteImage($0, at: range.location) }
+ open func performImageDrop(
+ with session: UIDropSession, at range: NSRange
+ ) {
+ guard validateImageInsertion(for: imageDropConfiguration) else {
+ return
}
+ var insertedString =
+ session.loadObjects(ofClass: UIImage.self) { [weak self] items in
+ guard let self else { return }
+ let images = items.compactMap({ $0 as? UIImage }).reversed().map({ $0 })
+ let insertedString = self.pasteImages(
+ images, at: range.location, moveCursorToPastedContent: true)
+ if let insertedString = insertedString {
+ self.onTextViewEvent?(
+ .didDroppedItems(
+ insertedString: insertedString, atRange: self.selectedRange,
+ isReplaced: !self.selectedRange.isCollapsed, with: images))
+ }
+ }
}
/**
diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/TextViewEvents.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/TextViewEvents.swift
index b72aa16..977d25a 100644
--- a/Sources/RichEditorSwiftUI/UI/TextViewUI/TextViewEvents.swift
+++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/TextViewEvents.swift
@@ -9,8 +9,11 @@ import Foundation
//MARK: - TextView Events
public enum TextViewEvents {
- case didChangeSelection(selectedRange: NSRange, text: NSAttributedString)
- case didBeginEditing(selectedRange: NSRange, text: NSAttributedString)
- case didChange(selectedRange: NSRange, text: NSAttributedString)
- case didEndEditing(selectedRange: NSRange, text: NSAttributedString)
+ case didChangeSelection(selectedRange: NSRange, text: NSAttributedString)
+ case didBeginEditing(selectedRange: NSRange, text: NSAttributedString)
+ case didChange(selectedRange: NSRange, text: NSAttributedString)
+ case didEndEditing(selectedRange: NSRange, text: NSAttributedString)
+ case didDroppedItems(
+ insertedString: NSAttributedString, atRange: NSRange, isReplaced: Bool,
+ with: [ImageRepresentable])
}
From fa6e4d5ccc6273aca647c204da1d8a6d9ead2242 Mon Sep 17 00:00:00 2001
From: Divyesh Canopas
Date: Tue, 31 Dec 2024 11:20:56 +0530
Subject: [PATCH 6/7] Update encoder decoder to match quill structure
---
.../Data/Models/RichAttributes.swift | 14 ++-
.../Data/Models/RichText.swift | 93 +++++++++++++++----
2 files changed, 87 insertions(+), 20 deletions(-)
diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift
index 24e2d6a..70ad0d5 100644
--- a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift
+++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift
@@ -73,7 +73,12 @@ public struct RichAttributes: Codable {
case background = "background"
case align = "align"
case link = "link"
- case image = "image"
+ /**
+ Commented as it should not be encode as we follow Quill Architecture so in that image is encoded
+ in ``RichTextSpan.insert`` of span model
+
+ */
+ // case image = "image"
}
public init(from decoder: Decoder) throws {
@@ -96,7 +101,12 @@ public struct RichAttributes: Codable {
self.align = try values.decodeIfPresent(
RichTextAlignment.self, forKey: .align)
self.link = try values.decodeIfPresent(String.self, forKey: .link)
- self.image = try values.decodeIfPresent(String.self, forKey: .image)
+ /**
+ Commented as it should not be decode as we follow Quill Structure so in that image is decoded
+ in ``RichTextSpan.insert`` of span's json and we add it by copying it
+
+ */
+ // self.image = try values.decodeIfPresent(String.self, forKey: .image)
}
}
diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichText.swift b/Sources/RichEditorSwiftUI/Data/Models/RichText.swift
index 9d2f669..1683087 100644
--- a/Sources/RichEditorSwiftUI/Data/Models/RichText.swift
+++ b/Sources/RichEditorSwiftUI/Data/Models/RichText.swift
@@ -10,28 +10,85 @@ import Foundation
typealias RichTextSpans = [RichTextSpan]
public struct RichText: Codable {
- public let spans: [RichTextSpan]
+ public let spans: [RichTextSpan]
- public init(spans: [RichTextSpan] = []) {
- self.spans = spans
- }
+ public init(spans: [RichTextSpan] = []) {
+ self.spans = spans
+ }
- func encodeToData() throws -> Data {
- return try JSONEncoder().encode(self)
- }
+ func encodeToData() throws -> Data {
+ return try JSONEncoder().encode(self)
+ }
}
public struct RichTextSpan: Codable {
- // public var id: String = UUID().uuidString
- public let insert: String
- public let attributes: RichAttributes?
-
- public init(
- insert: String,
- attributes: RichAttributes? = nil
- ) {
- // self.id = id
- self.insert = insert
- self.attributes = attributes
+ // public var id: String = UUID().uuidString
+ public let insert: String
+ public let attributes: RichAttributes?
+
+ public init(
+ insert: String,
+ attributes: RichAttributes? = nil
+ ) {
+ // self.id = id
+ self.insert = insert
+ self.attributes = attributes
+ }
+}
+
+extension RichTextSpan {
+
+ // Custom Encoding
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ // If there is an image in the attributes, encode insert as an image object
+ if let image = attributes?.image {
+ let richTextImage = RichTextImage(image: image)
+ try container.encode(richTextImage, forKey: .insert)
+ } else {
+ // Otherwise, just encode insert as a simple string
+ try container.encode(insert, forKey: .insert)
}
+
+ try container.encodeIfPresent(attributes, forKey: .attributes)
+ }
+
+ // Custom Decoding
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ var imageAttribute: String? = nil
+ // Decode insert as a string and extract image form json's insert if available
+ if let imageDict = try? container.decode([String: String].self, forKey: .insert),
+ let image = imageDict["image"]
+ {
+ self.insert = " "
+ // If insert contains an image, set insert to the image URL and set the image attribute
+ imageAttribute = image
+ } else {
+ // If insert is just a string, set insert as usual
+ self.insert = try container.decode(String.self, forKey: .insert)
+ }
+ let attributesInit = try container.decodeIfPresent(RichAttributes.self, forKey: .attributes)
+ self.attributes = attributesInit?.copy(with: .image(imageAttribute), byAdding: true)
+
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case insert
+ case attributes
+ }
+}
+
+internal struct RichTextImage: Codable {
+ let image: String
+
+ enum CodingKeys: String, CodingKey {
+ case image = "image"
+ }
+
+ init(image: String) {
+ self.image = image
+ }
}
From 38106e2ebc0571df6b8fcd2a2a2cf61c939b3fda Mon Sep 17 00:00:00 2001
From: Divyesh Canopas
Date: Wed, 1 Jan 2025 11:47:22 +0530
Subject: [PATCH 7/7] Fix image loading is not loding image on init in editor
---
.../RichEditorDemo.entitlements | 10 +--
.../Actions/RichTextAction.swift | 7 ++-
.../RichTextCoordinator+Actions.swift | 4 ++
.../BaseFoundation/RichTextCoordinator.swift | 1 +
.../RichTextViewComponent+Pasting.swift | 20 +++++-
.../Models/RichAttributes+ImageDownload.swift | 18 ++++++
.../Data/Models/RichAttributes.swift | 6 +-
.../Images/ImageAttachment.swift | 6 +-
.../Images/ImageDownloadManager.swift | 3 +-
.../UI/Context/RichEditorState.swift | 63 +++++++++++++++++++
10 files changed, 127 insertions(+), 11 deletions(-)
create mode 100644 Sources/RichEditorSwiftUI/Data/Models/RichAttributes+ImageDownload.swift
diff --git a/RichEditorDemo/RichEditorDemo/RichEditorDemo.entitlements b/RichEditorDemo/RichEditorDemo/RichEditorDemo.entitlements
index f2ef3ae..625af03 100644
--- a/RichEditorDemo/RichEditorDemo/RichEditorDemo.entitlements
+++ b/RichEditorDemo/RichEditorDemo/RichEditorDemo.entitlements
@@ -2,9 +2,11 @@
- com.apple.security.app-sandbox
-
- com.apple.security.files.user-selected.read-only
-
+ com.apple.security.app-sandbox
+
+ com.apple.security.files.user-selected.read-only
+
+ com.apple.security.network.client
+
diff --git a/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift b/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift
index b166383..1347ce8 100644
--- a/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift
+++ b/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift
@@ -80,6 +80,10 @@ public enum RichTextAction: Identifiable, Equatable {
/// Set link
case setLink(String? = nil)
+
+ /// Update Image Attachment as image takes time to download
+ case updateImageAttachments([ImageAttachment])
+
}
extension RichTextAction {
@@ -114,6 +118,7 @@ extension RichTextAction {
case .undoLatestChange: .richTextUndo
case .setHeaderStyle: .richTextIgnoreIt
case .setLink: .richTextLink
+ case .updateImageAttachments: .richTextIgnoreIt
}
}
@@ -164,7 +169,7 @@ extension RichTextAction {
case .toggleStyle(let style): style.titleKey
case .undoLatestChange: .actionUndoLatestChange
case .setLink: .link
- case .setHeaderStyle: .ignoreIt
+ case .setHeaderStyle, .updateImageAttachments: .ignoreIt
}
}
}
diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift
index f2aae8b..ac07060 100644
--- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift
+++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift
@@ -76,6 +76,10 @@ import Foundation
} else {
removeLink()
}
+ case .updateImageAttachments(let attachments):
+ attachments.forEach({
+ textView.setImageAttachment(imageAttachment: $0)
+ })
}
}
}
diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift
index c69e086..39498b2 100644
--- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift
+++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift
@@ -74,6 +74,7 @@
super.init()
self.textView.delegate = self
subscribeToUserActions()
+ context.onTextViewDidEndWithSetUp()
}
#if canImport(UIKit)
diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift
index 173dc47..d357e29 100644
--- a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift
+++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift
@@ -76,7 +76,7 @@ extension RichTextViewComponent {
let isSelectedRange = (index == selectedRange.location)
if isSelectedRange { deleteCharacters(in: selectedRange) }
if move { moveInputCursor(to: index) }
- var insertedString: NSMutableAttributedString = .init()
+ let insertedString: NSMutableAttributedString = .init()
images.reversed().forEach {
insertedString.append(performPasteImage($0, at: index) ?? .init())
}
@@ -88,6 +88,12 @@ extension RichTextViewComponent {
#endif
}
+ public func setImageAttachment(imageAttachment: ImageAttachment) {
+ guard let range = imageAttachment.range else { return }
+ let image = imageAttachment.image
+ performSetImageAttachment(image, at: range)
+ }
+
/**
Paste text into the text view, at a certain index.
@@ -154,3 +160,15 @@ extension RichTextViewComponent {
}
}
#endif
+
+#if iOS || macOS || os(tvOS) || os(visionOS)
+ extension RichTextViewComponent {
+ fileprivate func performSetImageAttachment(
+ _ image: ImageRepresentable,
+ at range: NSRange
+ ) {
+ guard let attachmentString = getAttachmentString(for: image) else { return }
+ mutableAttributedString?.replaceCharacters(in: range, with: attachmentString)
+ }
+ }
+#endif
diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes+ImageDownload.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes+ImageDownload.swift
new file mode 100644
index 0000000..39ee4a5
--- /dev/null
+++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes+ImageDownload.swift
@@ -0,0 +1,18 @@
+//
+// RichAttributes+ImageDownload.swift
+// RichEditorSwiftUI
+//
+// Created by Divyesh Vekariya on 31/12/24.
+//
+
+import Foundation
+
+extension RichAttributes {
+ func getImage() async -> ImageRepresentable? {
+ if let imageUrl = image {
+ let image = try? await ImageDownloadManager.shared.fetchImage(from: imageUrl)
+ return image
+ }
+ return nil
+ }
+}
diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift
index 70ad0d5..07c8d73 100644
--- a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift
+++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift
@@ -222,8 +222,10 @@ extension RichAttributes {
? (byAdding ? att.align! : nil) : self.align),
///nil link indicates removal as well so removing link if `byAdding == false && att.link == nil`
link: (att.link != nil
- ? (byAdding ? att.link! : nil) : (att.link == nil && !byAdding) ? nil : self.link),
- image: (att.image != nil ? (byAdding ? att.image! : nil) : self.image)
+ ? (byAdding ? att.link! : nil)
+ : (att.link == nil && !byAdding) ? nil : self.link),
+ image: (att.image != nil
+ ? (byAdding ? att.image! : nil) : self.image)
)
}
}
diff --git a/Sources/RichEditorSwiftUI/Images/ImageAttachment.swift b/Sources/RichEditorSwiftUI/Images/ImageAttachment.swift
index 9c6d75e..4abe503 100644
--- a/Sources/RichEditorSwiftUI/Images/ImageAttachment.swift
+++ b/Sources/RichEditorSwiftUI/Images/ImageAttachment.swift
@@ -7,7 +7,11 @@
import Foundation
-public class ImageAttachment {
+public class ImageAttachment: Equatable {
+ public static func == (lhs: ImageAttachment, rhs: ImageAttachment) -> Bool {
+ return lhs.id == rhs.id && lhs.image == rhs.image
+ }
+
public let id: String
public let image: ImageRepresentable
internal var range: NSRange? = nil
diff --git a/Sources/RichEditorSwiftUI/Images/ImageDownloadManager.swift b/Sources/RichEditorSwiftUI/Images/ImageDownloadManager.swift
index 264111c..21d5791 100644
--- a/Sources/RichEditorSwiftUI/Images/ImageDownloadManager.swift
+++ b/Sources/RichEditorSwiftUI/Images/ImageDownloadManager.swift
@@ -57,8 +57,7 @@ public class ImageDownloadManager {
userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
}
- let (data, _) = try await URLSession.shared.data(from: url)
-
+ let (data, response) = try await URLSession.shared.data(from: url)
guard let image = ImageRepresentable(data: data) else {
throw NSError(
domain: "ImageDownloadManager", code: 500,
diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift
index 7b6c850..16dcc97 100644
--- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift
+++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift
@@ -232,6 +232,69 @@ public class RichEditorState: ObservableObject {
}
}
+//MARK: - Handle Image download
+extension RichEditorState {
+ func onTextViewDidEndWithSetUp() {
+ setupWithImage()
+ }
+
+ func setupWithImage() {
+ let imageSpans = internalSpans.filter({ $0.attributes?.image != nil })
+ guard !imageSpans.isEmpty else { return }
+ imageSpans.forEach { item in
+ Task { @MainActor [weak self] in
+ guard let attributes = item.attributes else { return }
+ let image = await attributes.getImage()
+ if let image, let imageUrl = attributes.image {
+ let attachment = ImageAttachment(image: image, url: imageUrl)
+ attachment.updateRange(with: item.spanRange)
+ self?.actionPublisher.send(.updateImageAttachments([attachment]))
+ }
+ }
+ }
+ }
+
+ func setupWithImageAttachment(imageAttachment: [ImageAttachment]) {
+ let richText = internalRichText
+ var tempSpans: [RichTextSpanInternal] = []
+ var text = ""
+ richText.spans.forEach({
+ let span = RichTextSpanInternal(
+ from: text.utf16Length,
+ to: (text.utf16Length + $0.insert.utf16Length - 1),
+ attributes: $0.attributes)
+
+ tempSpans.append(span)
+ text += $0.insert
+ })
+
+ let str = NSMutableAttributedString(string: text)
+
+ tempSpans.forEach { span in
+ str.addAttributes(
+ span.attributes?.toAttributes(font: .standardRichTextFont)
+ ?? [:], range: span.spanRange)
+ if span.attributes?.color == nil {
+ var color: ColorRepresentable = .clear
+ #if os(watchOS)
+ color = .black
+ #else
+ color = RichTextView.Theme.standard.fontColor
+ #endif
+ str.addAttributes(
+ [.foregroundColor: color], range: span.spanRange)
+ }
+ if let imageUrl = span.attributes?.image,
+ let image = imageAttachment.first(where: { $0.url == imageUrl })
+ {
+ str.addAttribute(.attachment, value: image.image, range: span.spanRange)
+ }
+ }
+
+ self.attributedString = str
+ }
+}
+
extension RichEditorState {
/// Whether or not the context has a selected range.