generated from nhcarrigan/template
feat: add native clipboard support for screenshot paste
- Add Tauri clipboard-manager plugin for native clipboard access - Fall back to native clipboard when WebView clipboard API returns empty - Convert RGBA image data to PNG via canvas for saving - Allow sending messages with attachments only (no text required) - Log attached files to output with 📎 emoji - Update send button to enable when attachments exist Co-Authored-By: Naomi Carrigan <commits@nhcarrigan.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { readImage } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
@@ -165,10 +166,13 @@
|
||||
event.preventDefault();
|
||||
|
||||
const message = inputValue.trim();
|
||||
if (!message || isSubmitting) return;
|
||||
const hasAttachments = attachments.length > 0;
|
||||
|
||||
// Need either a message or attachments to submit
|
||||
if ((!message && !hasAttachments) || isSubmitting) return;
|
||||
|
||||
// Check for slash commands first (these work even when disconnected)
|
||||
if (isSlashCommand(message)) {
|
||||
if (message && isSlashCommand(message)) {
|
||||
// Add slash commands to history too
|
||||
addToHistory(message);
|
||||
historyIndex = -1;
|
||||
@@ -189,8 +193,10 @@
|
||||
// Regular messages require connection
|
||||
if (!isConnected) return;
|
||||
|
||||
// Add to history before clearing
|
||||
addToHistory(message);
|
||||
// Add to history before clearing (only if there's text)
|
||||
if (message) {
|
||||
addToHistory(message);
|
||||
}
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
userHasTyped = false;
|
||||
@@ -198,12 +204,33 @@
|
||||
isSubmitting = true;
|
||||
inputValue = "";
|
||||
|
||||
// Apply mode prefix if needed
|
||||
// Capture attachments before clearing
|
||||
const currentAttachments = [...attachments];
|
||||
|
||||
// Apply mode prefix if needed (only if there's a message)
|
||||
const currentMode = getCurrentMode();
|
||||
const formattedMessage = formatMessageWithMode(message, currentMode);
|
||||
const formattedMessage = message ? formatMessageWithMode(message, currentMode) : "";
|
||||
|
||||
// Build message with attachments
|
||||
let messageWithAttachments = formattedMessage;
|
||||
if (currentAttachments.length > 0) {
|
||||
const attachmentPaths = currentAttachments.map((a) => a.path).join("\n");
|
||||
const attachmentPrefix = formattedMessage
|
||||
? `${formattedMessage}\n\n`
|
||||
: "";
|
||||
messageWithAttachments = `${attachmentPrefix}[Attached files - please read these files to see their contents:]\n${attachmentPaths}`;
|
||||
|
||||
// Log attached files to the output
|
||||
for (const attachment of currentAttachments) {
|
||||
claudeStore.addLine("system", `📎 Attached: ${attachment.filename} (${attachment.path})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear attachments after capturing them
|
||||
claudeStore.clearAttachments();
|
||||
|
||||
// Check if we need to restore conversation history
|
||||
let messageToSend = formattedMessage;
|
||||
let messageToSend = messageWithAttachments;
|
||||
if (getShouldRestoreHistory()) {
|
||||
const savedHistory = getSavedHistory();
|
||||
|
||||
@@ -341,7 +368,7 @@ User: ${formattedMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
async function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
isDragging = false;
|
||||
@@ -349,6 +376,8 @@ User: ${formattedMessage}`;
|
||||
const files = event.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
|
||||
for (const file of files) {
|
||||
const filename = file.name;
|
||||
const extension = filename.split(".").pop()?.toLowerCase() || "";
|
||||
@@ -361,10 +390,29 @@ User: ${formattedMessage}`;
|
||||
previewUrl = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
// Check if the file has a native path (from Tauri's file drop)
|
||||
// If not, we need to save it to a temp file
|
||||
let savedPath = (file as File & { path?: string }).path;
|
||||
|
||||
if (!savedPath && conversationId) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save dropped file to temp:", error);
|
||||
savedPath = file.name;
|
||||
}
|
||||
}
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
filename,
|
||||
path: (file as File & { path?: string }).path || file.name,
|
||||
path: savedPath || file.name,
|
||||
size: file.size,
|
||||
type: fileType,
|
||||
mimeType: file.type,
|
||||
@@ -441,40 +489,124 @@ User: ${formattedMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
async function handlePaste(event: ClipboardEvent) {
|
||||
// First, try the web clipboard API for files
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
let handledFile = false;
|
||||
|
||||
for (const item of items) {
|
||||
// Check if the item is a file (image or other)
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
if (items && items.length > 0) {
|
||||
for (const item of items) {
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
|
||||
// Prevent default for file pastes so we handle it
|
||||
event.preventDefault();
|
||||
event.preventDefault();
|
||||
handledFile = true;
|
||||
|
||||
const filename = file.name || `pasted-${Date.now()}.${file.type.split("/")[1] || "png"}`;
|
||||
const extension = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const fileType = getFileTypeFromExtension(extension);
|
||||
const filename = file.name || `pasted-${Date.now()}.${file.type.split("/")[1] || "png"}`;
|
||||
const extension = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const fileType = getFileTypeFromExtension(extension);
|
||||
|
||||
// Create preview URL for images
|
||||
let previewUrl: string | undefined;
|
||||
if (fileType === "image" || file.type.startsWith("image/")) {
|
||||
previewUrl = URL.createObjectURL(file);
|
||||
let previewUrl: string | undefined;
|
||||
if (fileType === "image" || file.type.startsWith("image/")) {
|
||||
previewUrl = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
let savedPath = filename;
|
||||
|
||||
if (conversationId) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save pasted file to temp:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
filename,
|
||||
path: savedPath,
|
||||
size: file.size,
|
||||
type: file.type.startsWith("image/") ? "image" : fileType,
|
||||
mimeType: file.type,
|
||||
previewUrl,
|
||||
};
|
||||
|
||||
claudeStore.addAttachment(attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
filename,
|
||||
path: filename,
|
||||
size: file.size,
|
||||
type: file.type.startsWith("image/") ? "image" : fileType,
|
||||
mimeType: file.type,
|
||||
previewUrl,
|
||||
};
|
||||
// If web clipboard didn't have files, try Tauri's native clipboard for images
|
||||
if (!handledFile) {
|
||||
try {
|
||||
const image = await readImage();
|
||||
const rgba = await image.rgba();
|
||||
const size = await image.size();
|
||||
|
||||
claudeStore.addAttachment(attachment);
|
||||
if (rgba && rgba.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
const filename = `screenshot-${Date.now()}.png`;
|
||||
|
||||
// Convert RGBA to PNG using canvas
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size.width;
|
||||
canvas.height = size.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (ctx) {
|
||||
const imgData = ctx.createImageData(size.width, size.height);
|
||||
imgData.data.set(new Uint8ClampedArray(rgba));
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
// Create preview URL from canvas
|
||||
const previewUrl = canvas.toDataURL("image/png");
|
||||
|
||||
// Convert to blob for saving
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/png")
|
||||
);
|
||||
|
||||
let savedPath = filename;
|
||||
if (blob && conversationId) {
|
||||
try {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save clipboard image to temp:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
filename,
|
||||
path: savedPath,
|
||||
size: blob?.size || 0,
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
previewUrl,
|
||||
};
|
||||
|
||||
claudeStore.addAttachment(attachment);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// No image in clipboard or clipboard read failed - that's fine, just ignore
|
||||
console.log("No image in native clipboard:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,7 +751,7 @@ User: ${formattedMessage}`;
|
||||
{:else}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
||||
disabled={!isConnected || isSubmitting || (!inputValue.trim() && attachments.length === 0)}
|
||||
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user