Skip to content

Commit c6b094c

Browse files
authored
feat: Implement the ability to copy and paste images (#577)
1 parent 106b124 commit c6b094c

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

apps/twig/src/main/trpc/routers/os.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,40 @@ export const osRouter = router({
149149
* Get the worktree base location (e.g., ~/.twig)
150150
*/
151151
getWorktreeLocation: publicProcedure.query(() => getWorktreeLocation()),
152+
153+
/**
154+
* Save clipboard image data to a temp file
155+
* Returns the file path for use as a file attachment
156+
*/
157+
saveClipboardImage: publicProcedure
158+
.input(
159+
z.object({
160+
base64Data: z.string(),
161+
mimeType: z.string(),
162+
originalName: z.string().optional(),
163+
}),
164+
)
165+
.mutation(async ({ input }) => {
166+
const extension = input.mimeType.split("/")[1] || "png";
167+
const isGenericName =
168+
!input.originalName ||
169+
input.originalName === "image.png" ||
170+
input.originalName === "image.jpeg" ||
171+
input.originalName === "image.jpg";
172+
const displayName = isGenericName
173+
? `clipboard.${extension}`
174+
: input.originalName!;
175+
// Add timestamp to actual filename to avoid collisions
176+
const baseName = displayName.replace(/\.[^.]+$/, "");
177+
const filename = `${baseName}-${Date.now()}.${extension}`;
178+
const tempDir = path.join(os.tmpdir(), "twig-clipboard");
179+
180+
await fsPromises.mkdir(tempDir, { recursive: true });
181+
const filePath = path.join(tempDir, filename);
182+
183+
const buffer = Buffer.from(input.base64Data, "base64");
184+
await fsPromises.writeFile(filePath, buffer);
185+
186+
return { path: filePath, name: displayName };
187+
}),
152188
});

apps/twig/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { trpcVanilla } from "@renderer/trpc/client";
12
import { toast } from "@renderer/utils/toast";
23
import { useSettingsStore } from "@stores/settingsStore";
34
import { useEditor } from "@tiptap/react";
@@ -207,6 +208,63 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
207208

208209
return false;
209210
},
211+
handlePaste: (view, event) => {
212+
const items = event.clipboardData?.items;
213+
if (!items) return false;
214+
215+
const imageItems: DataTransferItem[] = [];
216+
for (let i = 0; i < items.length; i++) {
217+
const item = items[i];
218+
if (item.type.startsWith("image/")) {
219+
imageItems.push(item);
220+
}
221+
}
222+
223+
if (imageItems.length === 0) return false;
224+
225+
event.preventDefault();
226+
227+
(async () => {
228+
for (const item of imageItems) {
229+
const file = item.getAsFile();
230+
if (!file) continue;
231+
232+
try {
233+
const arrayBuffer = await file.arrayBuffer();
234+
const base64 = btoa(
235+
new Uint8Array(arrayBuffer).reduce(
236+
(data, byte) => data + String.fromCharCode(byte),
237+
"",
238+
),
239+
);
240+
241+
const result = await trpcVanilla.os.saveClipboardImage.mutate({
242+
base64Data: base64,
243+
mimeType: file.type,
244+
originalName: file.name,
245+
});
246+
247+
const chipNode = view.state.schema.nodes.mentionChip?.create({
248+
type: "file",
249+
id: result.path,
250+
label: result.name,
251+
});
252+
253+
if (chipNode) {
254+
const { tr } = view.state;
255+
const pos = view.state.selection.from;
256+
tr.insert(pos, chipNode);
257+
tr.insertText(" ", pos + chipNode.nodeSize);
258+
view.dispatch(tr);
259+
}
260+
} catch (_error) {
261+
toast.error("Failed to paste image");
262+
}
263+
}
264+
})();
265+
266+
return true;
267+
},
210268
},
211269
onCreate: ({ editor: e }) => {
212270
setIsReady(true);

0 commit comments

Comments
 (0)