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 lightEditor dark
+ + + + + + + + + + +
Toolbar darkToolbar 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.