Skip to content

Commit 059488b

Browse files
committed
add image drop support
1 parent f980ccc commit 059488b

12 files changed

+756
-105
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//
2+
// RichTextViewComponent+Images.swift
3+
// RichEditorSwiftUI
4+
//
5+
// Created by Divyesh Vekariya on 23/12/24.
6+
//
7+
8+
import Foundation
9+
10+
#if canImport(UIKit)
11+
import UIKit
12+
#endif
13+
14+
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
15+
import AppKit
16+
#endif
17+
18+
public extension RichTextViewComponent {
19+
20+
/// Get the max image attachment size.
21+
var imageAttachmentMaxSize: CGSize {
22+
let maxSize = imageConfiguration.maxImageSize
23+
let insetX = 2 * textContentInset.width
24+
let insetY = 2 * textContentInset.height
25+
let paddedFrame = frame.insetBy(dx: insetX, dy: insetY)
26+
let width = maxSize.width.width(in: paddedFrame)
27+
let height = maxSize.height.height(in: paddedFrame)
28+
return CGSize(width: width, height: height)
29+
}
30+
31+
/// Get the attachment bounds for a certain image.
32+
func attachmentBounds(
33+
for image: ImageRepresentable
34+
) -> CGRect {
35+
attributedString.attachmentBounds(
36+
for: image,
37+
maxSize: imageAttachmentMaxSize
38+
)
39+
}
40+
41+
/// Get the attachment size for a certain image.
42+
func attachmentSize(
43+
for image: ImageRepresentable
44+
) -> CGSize {
45+
attributedString.attachmentSize(
46+
for: image,
47+
maxSize: imageAttachmentMaxSize
48+
)
49+
}
50+
51+
/// Get the current image drop configuration.
52+
var imageDropConfiguration: RichTextImageInsertConfiguration {
53+
imageConfiguration.dropConfiguration
54+
}
55+
56+
/// Get the current image paste configuration.
57+
var imagePasteConfiguration: RichTextImageInsertConfiguration {
58+
imageConfiguration.pasteConfiguration
59+
}
60+
61+
/// Validate that image drop will be performed.
62+
func validateImageInsertion(
63+
for config: RichTextImageInsertConfiguration
64+
) -> Bool {
65+
switch config {
66+
case .disabled:
67+
return false
68+
case .disabledWithWarning(let title, let message):
69+
alert(title: title, message: message)
70+
return false
71+
case .enabled:
72+
return true
73+
}
74+
}
75+
}

Sources/RichEditorSwiftUI/Components/RichTextViewComponent+Pasting.swift

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,81 @@
88
import Foundation
99

1010
#if canImport(UIKit)
11-
import UIKit
11+
import UIKit
1212
#endif
1313

1414
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
15-
import AppKit
15+
import AppKit
1616
#endif
1717

18-
extension RichTextViewComponent {
18+
public extension RichTextViewComponent {
19+
20+
/**
21+
Paste an image into the rich text, at a certain index.
22+
23+
Pasting images only works on iOS, tvOS and macOS. Other
24+
platform will trigger an assertion failure.
25+
26+
> Todo: This automatically inserts images as compressed
27+
jpeg. We should make it more configurable.
28+
29+
- Parameters:
30+
- image: The image to paste.
31+
- index: The index to paste at.
32+
- moveCursorToPastedContent: Whether or not the input
33+
cursor should be moved to the end of the pasted content,
34+
by default `false`.
35+
*/
36+
func pasteImage(
37+
_ image: ImageRepresentable,
38+
at index: Int,
39+
moveCursorToPastedContent: Bool = true
40+
) {
41+
pasteImages(
42+
[image],
43+
at: index,
44+
moveCursorToPastedContent: moveCursorToPastedContent
45+
)
46+
}
47+
48+
/**
49+
Paste images into the text view, at a certain index.
50+
51+
This will automatically insert an image as a compressed
52+
jpeg. We should make it more configurable.
53+
54+
> Todo: This automatically inserts images as compressed
55+
jpeg. We should make it more configurable.
56+
57+
- Parameters:
58+
- images: The images to paste.
59+
- index: The index to paste at.
60+
- moveCursorToPastedContent: Whether or not the input
61+
cursor should be moved to the end of the pasted content,
62+
by default `false`.
63+
*/
64+
func pasteImages(
65+
_ images: [ImageRepresentable],
66+
at index: Int,
67+
moveCursorToPastedContent move: Bool = false
68+
) {
69+
#if os(watchOS)
70+
assertionFailure("Image pasting is not supported on this platform")
71+
#else
72+
guard validateImageInsertion(for: imagePasteConfiguration) else { return }
73+
let items = images.count * 2 // The number of inserted "items" is the images and a newline for each
74+
let insertRange = NSRange(location: index, length: 0)
75+
let safeInsertRange = safeRange(for: insertRange)
76+
let isSelectedRange = (index == selectedRange.location)
77+
if isSelectedRange { deleteCharacters(in: selectedRange) }
78+
if move { moveInputCursor(to: index) }
79+
images.reversed().forEach { performPasteImage($0, at: index) }
80+
if move { moveInputCursor(to: safeInsertRange.location + items) }
81+
if move || isSelectedRange {
82+
self.moveInputCursor(to: self.selectedRange.location)
83+
}
84+
#endif
85+
}
1986

2087
/**
2188
Paste text into the text view, at a certain index.
@@ -27,7 +94,7 @@ extension RichTextViewComponent {
2794
cursor should be moved to the end of the pasted content,
2895
by default `false`.
2996
*/
30-
public func pasteText(
97+
func pasteText(
3198
_ text: String,
3299
at index: Int,
33100
moveCursorToPastedContent: Bool = false
@@ -52,3 +119,33 @@ extension RichTextViewComponent {
52119
}
53120
}
54121
}
122+
123+
#if iOS || macOS || os(tvOS) || os(visionOS)
124+
private extension RichTextViewComponent {
125+
126+
func getAttachmentString(
127+
for image: ImageRepresentable
128+
) -> NSMutableAttributedString? {
129+
guard let data = image.jpegData(compressionQuality: 0.7) else { return nil }
130+
guard let compressed = ImageRepresentable(data: data) else { return nil }
131+
let attachment = RichTextImageAttachment(jpegData: data)
132+
attachment.bounds = attachmentBounds(for: compressed)
133+
return NSMutableAttributedString(attachment: attachment)
134+
}
135+
136+
func performPasteImage(
137+
_ image: ImageRepresentable,
138+
at index: Int
139+
) {
140+
let newLine = NSAttributedString(string: "\n", attributes: richTextAttributes)
141+
let content = NSMutableAttributedString(attributedString: richText)
142+
guard let insertString = getAttachmentString(for: image) else { return }
143+
144+
insertString.insert(newLine, at: insertString.length)
145+
insertString.addAttributes(richTextAttributes, range: insertString.richTextRange)
146+
content.insert(insertString, at: index)
147+
148+
setRichText(content)
149+
}
150+
}
151+
#endif

Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ public protocol RichTextViewComponent: AnyObject,
3838
/// The style to use when highlighting text in the view.
3939
var highlightingStyle: RichTextHighlightingStyle { get set }
4040

41+
/// The image configuration used by the rich text view.
42+
var imageConfiguration: RichTextImageConfiguration { get set }
43+
4144
/// Whether or not the text view is the first responder.
4245
var isFirstResponder: Bool { get }
4346

@@ -124,20 +127,20 @@ extension RichTextViewComponent {
124127
}
125128

126129
/// Get the image configuration for a certain format.
127-
// func standardImageConfiguration(
128-
// for format: RichTextDataFormat
129-
// ) -> RichTextImageConfiguration {
130-
// let insertConfig = standardImageInsertConfiguration(for: format)
131-
// return RichTextImageConfiguration(
132-
// pasteConfiguration: insertConfig,
133-
// dropConfiguration: insertConfig,
134-
// maxImageSize: (width: .frame, height: .frame))
135-
// }
130+
func standardImageConfiguration(
131+
for format: RichTextDataFormat
132+
) -> RichTextImageConfiguration {
133+
let insertConfig = standardImageInsertConfiguration(for: format)
134+
return RichTextImageConfiguration(
135+
pasteConfiguration: insertConfig,
136+
dropConfiguration: insertConfig,
137+
maxImageSize: (width: .frame, height: .frame))
138+
}
136139

137140
/// Get the image insert config for a certain format.
138-
// func standardImageInsertConfiguration(
139-
// for format: RichTextDataFormat
140-
// ) -> RichTextImageInsertConfiguration {
141-
// format.supportsImages ? .enabled : .disabled
142-
// }
141+
func standardImageInsertConfiguration(
142+
for format: RichTextDataFormat
143+
) -> RichTextImageInsertConfiguration {
144+
format.supportsImages ? .enabled : .disabled
145+
}
143146
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// ImageRepresentable.swift
3+
// RichEditorSwiftUI
4+
//
5+
// Created by Divyesh Vekariya on 23/12/24.
6+
//
7+
8+
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
9+
import AppKit
10+
11+
/// This typealias bridges platform-specific image types.
12+
public typealias ImageRepresentable = NSImage
13+
14+
public extension ImageRepresentable {
15+
16+
/// Try to get a CoreGraphic image from the AppKit image.
17+
var cgImage: CGImage? {
18+
cgImage(forProposedRect: nil, context: nil, hints: nil)
19+
}
20+
21+
/// Try to get JPEG compressed data for the AppKit image.
22+
func jpegData(compressionQuality: CGFloat) -> Data? {
23+
guard let image = cgImage else { return nil }
24+
let bitmap = NSBitmapImageRep(cgImage: image)
25+
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
26+
}
27+
}
28+
#endif
29+
30+
#if canImport(UIKit)
31+
import UIKit
32+
33+
/// This typealias bridges platform-specific image types.
34+
public typealias ImageRepresentable = UIImage
35+
#endif

0 commit comments

Comments
 (0)