mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
macOS app/chat/browser/cron/permissions fixes. Co-authored-by: ImHermes1 <lukeforn@gmail.com>
728 lines
26 KiB
Swift
728 lines
26 KiB
Swift
import Foundation
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
#if !os(macOS)
|
|
import PhotosUI
|
|
import UniformTypeIdentifiers
|
|
#endif
|
|
|
|
@MainActor
|
|
struct OpenClawChatComposer: View {
|
|
@Bindable var viewModel: OpenClawChatViewModel
|
|
let style: OpenClawChatView.Style
|
|
let showsSessionSwitcher: Bool
|
|
|
|
#if !os(macOS)
|
|
@State private var pickerItems: [PhotosPickerItem] = []
|
|
@FocusState private var isFocused: Bool
|
|
#else
|
|
@State private var shouldFocusTextView = false
|
|
#endif
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if self.showsToolbar {
|
|
HStack(spacing: 6) {
|
|
if self.showsSessionSwitcher {
|
|
self.sessionPicker
|
|
}
|
|
self.thinkingPicker
|
|
Spacer()
|
|
self.refreshButton
|
|
self.attachmentPicker
|
|
}
|
|
}
|
|
|
|
if self.showsAttachments, !self.viewModel.attachments.isEmpty {
|
|
self.attachmentsStrip
|
|
}
|
|
|
|
self.editor
|
|
}
|
|
.padding(self.composerPadding)
|
|
.background {
|
|
let cornerRadius: CGFloat = 18
|
|
|
|
#if os(macOS)
|
|
if self.style == .standard {
|
|
let shape = UnevenRoundedRectangle(
|
|
cornerRadii: RectangleCornerRadii(
|
|
topLeading: 0,
|
|
bottomLeading: cornerRadius,
|
|
bottomTrailing: cornerRadius,
|
|
topTrailing: 0),
|
|
style: .continuous)
|
|
shape
|
|
.fill(OpenClawChatTheme.composerBackground)
|
|
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
|
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
|
} else {
|
|
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
shape
|
|
.fill(OpenClawChatTheme.composerBackground)
|
|
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
|
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
|
}
|
|
#else
|
|
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
shape
|
|
.fill(OpenClawChatTheme.composerBackground)
|
|
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
|
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
|
#endif
|
|
}
|
|
#if os(macOS)
|
|
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
|
self.handleDrop(providers)
|
|
}
|
|
.onAppear {
|
|
self.shouldFocusTextView = true
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private var thinkingPicker: some View {
|
|
Picker("Thinking", selection: self.$viewModel.thinkingLevel) {
|
|
Text("Off").tag("off")
|
|
Text("Low").tag("low")
|
|
Text("Medium").tag("medium")
|
|
Text("High").tag("high")
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
.controlSize(.small)
|
|
.frame(maxWidth: 140, alignment: .leading)
|
|
}
|
|
|
|
private var sessionPicker: some View {
|
|
Picker(
|
|
"Session",
|
|
selection: Binding(
|
|
get: { self.viewModel.sessionKey },
|
|
set: { next in self.viewModel.switchSession(to: next) }))
|
|
{
|
|
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
|
|
Text(session.displayName ?? session.key)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.tag(session.key)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
.controlSize(.small)
|
|
.frame(maxWidth: 160, alignment: .leading)
|
|
.help("Session")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var attachmentPicker: some View {
|
|
#if os(macOS)
|
|
Button {
|
|
self.pickFilesMac()
|
|
} label: {
|
|
Image(systemName: "paperclip")
|
|
}
|
|
.help("Add Image")
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
#else
|
|
PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) {
|
|
Image(systemName: "paperclip")
|
|
}
|
|
.help("Add Image")
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.onChange(of: self.pickerItems) { _, newItems in
|
|
Task { await self.loadPhotosPickerItems(newItems) }
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private var attachmentsStrip: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
ForEach(
|
|
self.viewModel.attachments,
|
|
id: \OpenClawPendingAttachment.id)
|
|
{ (att: OpenClawPendingAttachment) in
|
|
HStack(spacing: 6) {
|
|
if let img = att.preview {
|
|
OpenClawPlatformImageFactory.image(img)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 22, height: 22)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
|
} else {
|
|
Image(systemName: "photo")
|
|
}
|
|
|
|
Text(att.fileName)
|
|
.lineLimit(1)
|
|
|
|
Button {
|
|
self.viewModel.removeAttachment(att.id)
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 5)
|
|
.background(Color.accentColor.opacity(0.08))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var editor: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
self.editorOverlay
|
|
|
|
if !self.isComposerCompacted {
|
|
Rectangle()
|
|
.fill(OpenClawChatTheme.divider)
|
|
.frame(height: 1)
|
|
.padding(.horizontal, 2)
|
|
}
|
|
|
|
HStack(alignment: .center, spacing: 8) {
|
|
if self.showsConnectionPill {
|
|
self.connectionPill
|
|
}
|
|
Spacer(minLength: 0)
|
|
self.sendButton
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(OpenClawChatTheme.composerField)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(OpenClawChatTheme.composerBorder)))
|
|
.padding(self.editorPadding)
|
|
}
|
|
|
|
private var connectionPill: some View {
|
|
HStack(spacing: 6) {
|
|
Circle()
|
|
.fill(self.viewModel.healthOK ? .green : .orange)
|
|
.frame(width: 7, height: 7)
|
|
Text(self.activeSessionLabel)
|
|
.font(.caption2.weight(.semibold))
|
|
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(OpenClawChatTheme.subtleCard)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
private var activeSessionLabel: String {
|
|
let match = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey }
|
|
let trimmed = match?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? self.viewModel.sessionKey : trimmed
|
|
}
|
|
|
|
private var editorOverlay: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
Text("Message OpenClaw…")
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
#if os(macOS)
|
|
ChatComposerTextView(
|
|
text: self.$viewModel.input,
|
|
shouldFocus: self.$shouldFocusTextView,
|
|
onSend: {
|
|
self.viewModel.send()
|
|
},
|
|
onPasteImageAttachment: { data, fileName, mimeType in
|
|
self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType)
|
|
})
|
|
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 3)
|
|
#else
|
|
TextEditor(text: self.$viewModel.input)
|
|
.font(.system(size: 15))
|
|
.scrollContentBackground(.hidden)
|
|
.frame(
|
|
minHeight: self.textMinHeight,
|
|
idealHeight: self.textMinHeight,
|
|
maxHeight: self.textMaxHeight)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 4)
|
|
.focused(self.$isFocused)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private var sendButton: some View {
|
|
Group {
|
|
if self.viewModel.pendingRunCount > 0 {
|
|
Button {
|
|
self.viewModel.abort()
|
|
} label: {
|
|
if self.viewModel.isAborting {
|
|
ProgressView().controlSize(.mini)
|
|
} else {
|
|
Image(systemName: "stop.fill")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.white)
|
|
.padding(6)
|
|
.background(Circle().fill(Color.red))
|
|
.disabled(self.viewModel.isAborting)
|
|
} else {
|
|
Button {
|
|
self.viewModel.send()
|
|
} label: {
|
|
if self.viewModel.isSending {
|
|
ProgressView().controlSize(.mini)
|
|
} else {
|
|
Image(systemName: "arrow.up")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.white)
|
|
.padding(6)
|
|
.background(Circle().fill(Color.accentColor))
|
|
.disabled(!self.viewModel.canSend)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var refreshButton: some View {
|
|
Button {
|
|
self.viewModel.refresh()
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.help("Refresh")
|
|
}
|
|
|
|
private var showsToolbar: Bool {
|
|
self.style == .standard && !self.isComposerCompacted
|
|
}
|
|
|
|
private var showsAttachments: Bool {
|
|
self.style == .standard
|
|
}
|
|
|
|
private var showsConnectionPill: Bool {
|
|
self.style == .standard && !self.isComposerCompacted
|
|
}
|
|
|
|
private var composerPadding: CGFloat {
|
|
self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6)
|
|
}
|
|
|
|
private var editorPadding: CGFloat {
|
|
self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6)
|
|
}
|
|
|
|
private var textMinHeight: CGFloat {
|
|
self.style == .onboarding ? 24 : 28
|
|
}
|
|
|
|
private var textMaxHeight: CGFloat {
|
|
self.style == .onboarding ? 52 : 64
|
|
}
|
|
|
|
private var isComposerCompacted: Bool {
|
|
#if os(macOS)
|
|
false
|
|
#else
|
|
self.style == .standard && self.isFocused
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func pickFilesMac() {
|
|
let panel = NSOpenPanel()
|
|
panel.title = "Select image attachments"
|
|
panel.allowsMultipleSelection = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowedContentTypes = [.image]
|
|
panel.begin { resp in
|
|
guard resp == .OK else { return }
|
|
self.viewModel.addAttachments(urls: panel.urls)
|
|
}
|
|
}
|
|
|
|
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
|
|
let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) }
|
|
guard !fileProviders.isEmpty else { return false }
|
|
for item in fileProviders {
|
|
item.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
|
|
guard let data = item as? Data,
|
|
let url = URL(dataRepresentation: data, relativeTo: nil)
|
|
else { return }
|
|
Task { @MainActor in
|
|
self.viewModel.addAttachments(urls: [url])
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
#else
|
|
private func loadPhotosPickerItems(_ items: [PhotosPickerItem]) async {
|
|
for item in items {
|
|
do {
|
|
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
|
|
let type = item.supportedContentTypes.first ?? .image
|
|
let ext = type.preferredFilenameExtension ?? "jpg"
|
|
let mime = type.preferredMIMEType ?? "image/jpeg"
|
|
let name = "photo-\(UUID().uuidString.prefix(8)).\(ext)"
|
|
self.viewModel.addImageAttachment(data: data, fileName: name, mimeType: mime)
|
|
} catch {
|
|
self.viewModel.errorText = error.localizedDescription
|
|
}
|
|
}
|
|
self.pickerItems = []
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
import AppKit
|
|
import UniformTypeIdentifiers
|
|
|
|
private struct ChatComposerTextView: NSViewRepresentable {
|
|
@Binding var text: String
|
|
@Binding var shouldFocus: Bool
|
|
var onSend: () -> Void
|
|
var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
let textView = ChatComposerNSTextView()
|
|
textView.delegate = context.coordinator
|
|
textView.drawsBackground = false
|
|
textView.isRichText = false
|
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
textView.isAutomaticTextReplacementEnabled = false
|
|
textView.isAutomaticDashSubstitutionEnabled = false
|
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
|
textView.font = .systemFont(ofSize: 14, weight: .regular)
|
|
textView.textContainer?.lineBreakMode = .byWordWrapping
|
|
textView.textContainer?.lineFragmentPadding = 0
|
|
textView.textContainerInset = NSSize(width: 2, height: 4)
|
|
textView.focusRingType = .none
|
|
|
|
textView.minSize = .zero
|
|
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
|
textView.isHorizontallyResizable = false
|
|
textView.isVerticallyResizable = true
|
|
textView.autoresizingMask = [.width]
|
|
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
|
|
textView.textContainer?.widthTracksTextView = true
|
|
|
|
textView.string = self.text
|
|
textView.onSend = { [weak textView] in
|
|
textView?.window?.makeFirstResponder(nil)
|
|
self.onSend()
|
|
}
|
|
textView.onPasteImageAttachment = self.onPasteImageAttachment
|
|
|
|
let scroll = NSScrollView()
|
|
scroll.drawsBackground = false
|
|
scroll.borderType = .noBorder
|
|
scroll.hasVerticalScroller = true
|
|
scroll.autohidesScrollers = true
|
|
scroll.scrollerStyle = .overlay
|
|
scroll.hasHorizontalScroller = false
|
|
scroll.documentView = textView
|
|
return scroll
|
|
}
|
|
|
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
|
guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return }
|
|
textView.onPasteImageAttachment = self.onPasteImageAttachment
|
|
|
|
if self.shouldFocus, let window = scrollView.window {
|
|
window.makeFirstResponder(textView)
|
|
self.shouldFocus = false
|
|
}
|
|
|
|
let isEditing = scrollView.window?.firstResponder == textView
|
|
|
|
// Always allow clearing the text (e.g. after send), even while editing.
|
|
// Only skip other updates while editing to avoid cursor jumps.
|
|
let shouldClear = self.text.isEmpty && !textView.string.isEmpty
|
|
if isEditing, !shouldClear { return }
|
|
|
|
if textView.string != self.text {
|
|
context.coordinator.isProgrammaticUpdate = true
|
|
defer { context.coordinator.isProgrammaticUpdate = false }
|
|
textView.string = self.text
|
|
}
|
|
}
|
|
|
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
|
var parent: ChatComposerTextView
|
|
var isProgrammaticUpdate = false
|
|
|
|
init(_ parent: ChatComposerTextView) { self.parent = parent }
|
|
|
|
func textDidChange(_ notification: Notification) {
|
|
guard !self.isProgrammaticUpdate else { return }
|
|
guard let view = notification.object as? NSTextView else { return }
|
|
guard view.window?.firstResponder === view else { return }
|
|
self.parent.text = view.string
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class ChatComposerNSTextView: NSTextView {
|
|
var onSend: (() -> Void)?
|
|
var onPasteImageAttachment: ((_ data: Data, _ fileName: String, _ mimeType: String) -> Void)?
|
|
|
|
override var readablePasteboardTypes: [NSPasteboard.PasteboardType] {
|
|
var types = super.readablePasteboardTypes
|
|
for type in ChatComposerPasteSupport.readablePasteboardTypes where !types.contains(type) {
|
|
types.append(type)
|
|
}
|
|
return types
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
let isReturn = event.keyCode == 36
|
|
if isReturn {
|
|
if self.hasMarkedText() {
|
|
super.keyDown(with: event)
|
|
return
|
|
}
|
|
if event.modifierFlags.contains(.shift) {
|
|
super.insertNewline(nil)
|
|
return
|
|
}
|
|
self.onSend?()
|
|
return
|
|
}
|
|
super.keyDown(with: event)
|
|
}
|
|
|
|
override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool {
|
|
if !self.handleImagePaste(from: pboard, matching: type) {
|
|
return super.readSelection(from: pboard, type: type)
|
|
}
|
|
return true
|
|
}
|
|
|
|
override func paste(_ sender: Any?) {
|
|
if !self.handleImagePaste(from: NSPasteboard.general, matching: nil) {
|
|
super.paste(sender)
|
|
}
|
|
}
|
|
|
|
override func pasteAsPlainText(_ sender: Any?) {
|
|
self.paste(sender)
|
|
}
|
|
|
|
private func handleImagePaste(
|
|
from pasteboard: NSPasteboard,
|
|
matching preferredType: NSPasteboard.PasteboardType?) -> Bool
|
|
{
|
|
let attachments = ChatComposerPasteSupport.imageAttachments(from: pasteboard, matching: preferredType)
|
|
if !attachments.isEmpty {
|
|
self.deliver(attachments)
|
|
return true
|
|
}
|
|
|
|
let fileReferences = ChatComposerPasteSupport.imageFileReferences(from: pasteboard, matching: preferredType)
|
|
if !fileReferences.isEmpty {
|
|
self.loadAndDeliver(fileReferences)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func deliver(_ attachments: [ChatComposerPasteSupport.ImageAttachment]) {
|
|
for attachment in attachments {
|
|
self.onPasteImageAttachment?(
|
|
attachment.data,
|
|
attachment.fileName,
|
|
attachment.mimeType)
|
|
}
|
|
}
|
|
|
|
private func loadAndDeliver(_ fileReferences: [ChatComposerPasteSupport.FileImageReference]) {
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self, fileReferences] in
|
|
let attachments = ChatComposerPasteSupport.loadImageAttachments(from: fileReferences)
|
|
guard !attachments.isEmpty else { return }
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.deliver(attachments)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ChatComposerPasteSupport {
|
|
typealias ImageAttachment = (data: Data, fileName: String, mimeType: String)
|
|
typealias FileImageReference = (url: URL, fileName: String, mimeType: String)
|
|
|
|
static var readablePasteboardTypes: [NSPasteboard.PasteboardType] {
|
|
[.fileURL] + self.preferredImagePasteboardTypes.map(\.type)
|
|
}
|
|
|
|
static func imageAttachments(
|
|
from pasteboard: NSPasteboard,
|
|
matching preferredType: NSPasteboard.PasteboardType? = nil) -> [ImageAttachment]
|
|
{
|
|
let dataAttachments = self.imageAttachmentsFromRawData(in: pasteboard, matching: preferredType)
|
|
if !dataAttachments.isEmpty {
|
|
return dataAttachments
|
|
}
|
|
|
|
if let preferredType, !self.matchesImageType(preferredType) {
|
|
return []
|
|
}
|
|
|
|
guard let images = pasteboard.readObjects(forClasses: [NSImage.self]) as? [NSImage], !images.isEmpty else {
|
|
return []
|
|
}
|
|
return images.enumerated().compactMap { index, image in
|
|
self.imageAttachment(from: image, index: index)
|
|
}
|
|
}
|
|
|
|
static func imageFileReferences(
|
|
from pasteboard: NSPasteboard,
|
|
matching preferredType: NSPasteboard.PasteboardType? = nil) -> [FileImageReference]
|
|
{
|
|
guard self.matchesFileURL(preferredType) else { return [] }
|
|
return self.imageFileReferencesFromFileURLs(in: pasteboard)
|
|
}
|
|
|
|
static func loadImageAttachments(from fileReferences: [FileImageReference]) -> [ImageAttachment] {
|
|
fileReferences.compactMap { reference in
|
|
guard let data = try? Data(contentsOf: reference.url), !data.isEmpty else {
|
|
return nil
|
|
}
|
|
return (
|
|
data: data,
|
|
fileName: reference.fileName,
|
|
mimeType: reference.mimeType)
|
|
}
|
|
}
|
|
|
|
private static func imageFileReferencesFromFileURLs(in pasteboard: NSPasteboard) -> [FileImageReference] {
|
|
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty else {
|
|
return []
|
|
}
|
|
|
|
return urls.enumerated().compactMap { index, url -> FileImageReference? in
|
|
guard url.isFileURL,
|
|
let type = UTType(filenameExtension: url.pathExtension),
|
|
type.conforms(to: .image)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let mimeType = type.preferredMIMEType ?? "image/\(type.preferredFilenameExtension ?? "png")"
|
|
let fileName = url.lastPathComponent.isEmpty
|
|
? self.defaultFileName(index: index, ext: type.preferredFilenameExtension ?? "png")
|
|
: url.lastPathComponent
|
|
return (url: url, fileName: fileName, mimeType: mimeType)
|
|
}
|
|
}
|
|
|
|
private static func imageAttachmentsFromRawData(
|
|
in pasteboard: NSPasteboard,
|
|
matching preferredType: NSPasteboard.PasteboardType?) -> [ImageAttachment]
|
|
{
|
|
let items = pasteboard.pasteboardItems ?? []
|
|
guard !items.isEmpty else { return [] }
|
|
|
|
return items.enumerated().compactMap { index, item in
|
|
self.imageAttachment(from: item, index: index, matching: preferredType)
|
|
}
|
|
}
|
|
|
|
private static func imageAttachment(from image: NSImage, index: Int) -> ImageAttachment? {
|
|
guard let tiffData = image.tiffRepresentation,
|
|
let bitmap = NSBitmapImageRep(data: tiffData)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
if let pngData = bitmap.representation(using: .png, properties: [:]), !pngData.isEmpty {
|
|
return (
|
|
data: pngData,
|
|
fileName: self.defaultFileName(index: index, ext: "png"),
|
|
mimeType: "image/png")
|
|
}
|
|
|
|
guard !tiffData.isEmpty else {
|
|
return nil
|
|
}
|
|
return (
|
|
data: tiffData,
|
|
fileName: self.defaultFileName(index: index, ext: "tiff"),
|
|
mimeType: "image/tiff")
|
|
}
|
|
|
|
private static func imageAttachment(
|
|
from item: NSPasteboardItem,
|
|
index: Int,
|
|
matching preferredType: NSPasteboard.PasteboardType?) -> ImageAttachment?
|
|
{
|
|
for type in self.preferredImagePasteboardTypes where self.matches(preferredType, candidate: type.type) {
|
|
guard let data = item.data(forType: type.type), !data.isEmpty else { continue }
|
|
return (
|
|
data: data,
|
|
fileName: self.defaultFileName(index: index, ext: type.fileExtension),
|
|
mimeType: type.mimeType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static let preferredImagePasteboardTypes: [
|
|
(type: NSPasteboard.PasteboardType, fileExtension: String, mimeType: String)
|
|
] = [
|
|
(.png, "png", "image/png"),
|
|
(.tiff, "tiff", "image/tiff"),
|
|
(NSPasteboard.PasteboardType("public.jpeg"), "jpg", "image/jpeg"),
|
|
(NSPasteboard.PasteboardType("com.compuserve.gif"), "gif", "image/gif"),
|
|
(NSPasteboard.PasteboardType("public.heic"), "heic", "image/heic"),
|
|
(NSPasteboard.PasteboardType("public.heif"), "heif", "image/heif"),
|
|
]
|
|
|
|
private static func matches(_ preferredType: NSPasteboard.PasteboardType?, candidate: NSPasteboard.PasteboardType) -> Bool {
|
|
guard let preferredType else { return true }
|
|
return preferredType == candidate
|
|
}
|
|
|
|
private static func matchesFileURL(_ preferredType: NSPasteboard.PasteboardType?) -> Bool {
|
|
guard let preferredType else { return true }
|
|
return preferredType == .fileURL
|
|
}
|
|
|
|
private static func matchesImageType(_ preferredType: NSPasteboard.PasteboardType) -> Bool {
|
|
self.preferredImagePasteboardTypes.contains { $0.type == preferredType }
|
|
}
|
|
|
|
private static func defaultFileName(index: Int, ext: String) -> String {
|
|
"pasted-image-\(index + 1).\(ext)"
|
|
}
|
|
}
|
|
#endif
|