Skip to content

Commit 2ef0938

Browse files
mcintyre94claude
andcommitted
Add paste support for images and files in chat
- Paste from Clipboard option in the attachment menu reads UIPasteboard and routes images/files through the existing upload flow - .onPaste modifier on the chat TextField intercepts inline paste (long-press → Paste, or Cmd+V) for images and file URLs, same upload flow - Shared pasteImageFormats constant (UTType, ext) used by both paths - addAttachedFile(remotePath:) helper on ChatViewModel removes repeated lastPathComponent + attachedFiles.append pattern Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent de18615 commit 2ef0938

File tree

4 files changed

+110
-1
lines changed

4 files changed

+110
-1
lines changed

Wisp/ViewModels/ChatViewModel.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ final class ChatViewModel {
9595

9696
private static let maxUploadBytes: Int = 10 * 1024 * 1024 // 10 MB
9797

98+
func addAttachedFile(remotePath: String) {
99+
let name = (remotePath as NSString).lastPathComponent
100+
attachedFiles.append(AttachedFile(name: name, path: remotePath))
101+
}
102+
98103
func uploadFileFromDevice(apiClient: SpritesAPIClient, fileURL: URL) async -> String? {
99104
let accessing = fileURL.startAccessingSecurityScopedResource()
100105
defer { if accessing { fileURL.stopAccessingSecurityScopedResource() } }

Wisp/Views/SpriteDetail/Chat/ChatAttachmentButton.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ struct ChatAttachmentButton: View {
66
let onBrowseSpriteFiles: () -> Void
77
let onPickPhoto: () -> Void
88
let onPickFile: () -> Void
9+
var onPasteFromClipboard: (() -> Void)? = nil
910

1011
var body: some View {
1112
if isUploading {
@@ -30,6 +31,14 @@ struct ChatAttachmentButton: View {
3031
} label: {
3132
Label("Choose File", systemImage: "doc")
3233
}
34+
35+
if let onPasteFromClipboard {
36+
Button {
37+
onPasteFromClipboard()
38+
} label: {
39+
Label("Paste from Clipboard", systemImage: "clipboard")
40+
}
41+
}
3342
} label: {
3443
Image(systemName: "plus.circle.fill")
3544
.font(.title2)

Wisp/Views/SpriteDetail/Chat/ChatInputBar.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SwiftUI
2+
import UniformTypeIdentifiers
23

34
struct ChatInputBar: View {
45
@Binding var text: String
@@ -9,6 +10,8 @@ struct ChatInputBar: View {
910
var onBrowseSpriteFiles: (() -> Void)? = nil
1011
var onPickPhoto: (() -> Void)? = nil
1112
var onPickFile: (() -> Void)? = nil
13+
var onPasteFromClipboard: (() -> Void)? = nil
14+
var onPasteItems: (([NSItemProvider]) -> Void)? = nil
1215
var isUploading: Bool = false
1316
var attachedFiles: [AttachedFile] = []
1417
var onRemoveAttachment: ((AttachedFile) -> Void)? = nil
@@ -53,7 +56,8 @@ struct ChatInputBar: View {
5356
isDisabled: hasQueuedMessage,
5457
onBrowseSpriteFiles: onBrowseSpriteFiles,
5558
onPickPhoto: onPickPhoto,
56-
onPickFile: onPickFile
59+
onPickFile: onPickFile,
60+
onPasteFromClipboard: onPasteFromClipboard
5761
)
5862
}
5963

@@ -66,6 +70,9 @@ struct ChatInputBar: View {
6670
.frame(minHeight: 36)
6771
.glassEffect(in: .rect(cornerRadius: 20))
6872
.disabled(hasQueuedMessage)
73+
.onPaste(of: [.image, .fileURL]) { providers in
74+
onPasteItems?(providers)
75+
}
6976

7077
if isStreaming {
7178
Button {

Wisp/Views/SpriteDetail/Chat/ChatView.swift

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import PhotosUI
22
import SwiftUI
3+
import UIKit
4+
import UniformTypeIdentifiers
35

46
struct ChatView: View {
57
@Environment(SpritesAPIClient.self) private var apiClient
@@ -142,6 +144,8 @@ struct ChatView: View {
142144
onBrowseSpriteFiles: { showFileBrowser = true },
143145
onPickPhoto: { showPhotoPicker = true },
144146
onPickFile: { showFilePicker = true },
147+
onPasteFromClipboard: handlePasteFromClipboard,
148+
onPasteItems: handlePastedItems,
145149
isUploading: viewModel.isUploadingAttachment,
146150
attachedFiles: viewModel.attachedFiles,
147151
onRemoveAttachment: { file in
@@ -205,6 +209,90 @@ struct ChatView: View {
205209
}
206210
}
207211

212+
private static let pasteImageFormats: [(UTType, String)] = [
213+
(.png, "png"),
214+
(.jpeg, "jpg"),
215+
(.gif, "gif"),
216+
(.webP, "webp"),
217+
(UTType("public.heic") ?? .image, "heic"),
218+
]
219+
220+
private func handlePasteFromClipboard() {
221+
let pasteboard = UIPasteboard.general
222+
223+
for (type, ext) in Self.pasteImageFormats {
224+
if let data = pasteboard.data(forPasteboardType: type.identifier) {
225+
Task {
226+
if let remotePath = await viewModel.uploadPhotoData(apiClient: apiClient, data: data, fileExtension: ext) {
227+
viewModel.addAttachedFile(remotePath: remotePath)
228+
}
229+
}
230+
return
231+
}
232+
}
233+
234+
// Fallback: any image via UIImage
235+
if pasteboard.hasImages, let image = pasteboard.image, let data = image.pngData() {
236+
Task {
237+
if let remotePath = await viewModel.uploadPhotoData(apiClient: apiClient, data: data, fileExtension: "png") {
238+
viewModel.addAttachedFile(remotePath: remotePath)
239+
}
240+
}
241+
return
242+
}
243+
244+
// Try a file URL (e.g. copied from Files app)
245+
if let url = pasteboard.url, url.isFileURL {
246+
Task {
247+
if let remotePath = await viewModel.uploadFileFromDevice(apiClient: apiClient, fileURL: url) {
248+
viewModel.addAttachedFile(remotePath: remotePath)
249+
}
250+
}
251+
return
252+
}
253+
254+
viewModel.uploadAttachmentError = "No image or file found in clipboard"
255+
}
256+
257+
private func handlePastedItems(_ providers: [NSItemProvider]) {
258+
guard let provider = providers.first else { return }
259+
260+
for (type, ext) in Self.pasteImageFormats where provider.hasItemConformingToTypeIdentifier(type.identifier) {
261+
provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in
262+
guard let data else { return }
263+
Task { @MainActor in
264+
if let remotePath = await viewModel.uploadPhotoData(apiClient: apiClient, data: data, fileExtension: ext) {
265+
viewModel.addAttachedFile(remotePath: remotePath)
266+
}
267+
}
268+
}
269+
return
270+
}
271+
272+
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
273+
provider.loadDataRepresentation(forTypeIdentifier: UTType.png.identifier) { data, _ in
274+
guard let data else { return }
275+
Task { @MainActor in
276+
if let remotePath = await viewModel.uploadPhotoData(apiClient: apiClient, data: data, fileExtension: "png") {
277+
viewModel.addAttachedFile(remotePath: remotePath)
278+
}
279+
}
280+
}
281+
return
282+
}
283+
284+
if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
285+
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, _ in
286+
guard let url = item as? URL, url.isFileURL else { return }
287+
Task { @MainActor in
288+
if let remotePath = await viewModel.uploadFileFromDevice(apiClient: apiClient, fileURL: url) {
289+
viewModel.addAttachedFile(remotePath: remotePath)
290+
}
291+
}
292+
}
293+
}
294+
}
295+
208296
@ViewBuilder
209297
private func messageView(_ message: ChatMessage) -> some View {
210298
let isLastAssistant = message.role == .assistant

0 commit comments

Comments
 (0)