super-productivity/electron/clipboard-image-handler.ts
Pue-Tsuâ 9a9e31f36a
Adding Ctrl-V pasting function from clipboard. (#5998)
* docs(clipboard): Added the plan to guide AI implementation.

* feat(clipboard): Changed requirement to reflect the fact that some functions can't be done in browsers.

* feat(clipboard): Added Ctrl-v pasting functionality from clipboard.

- Storing image in IndexedDB in browser, and storing image in userdata path in Electron.
- Added image resizing in markdown.

* docs(clipboard): Removed requirement file since it's implemented.

* feat(clipboard): Added clipboard images management in settings.

* feat(tests): Enhance focus mode and inline markdown tests with mock store and actions

* feat(clipboard): Add electron-only annotation to findImageFile function

* feat(clipboard): Prevent memory leaks by revoking cached blob URLs on image deletion

* feat(clipboard): Improve paste handling by tracking current placeholder and cleaning up old progress

* Merge branch 'master' into feat/clipboard-files

* feat(marked-options): Refactor renderer methods and add hooks for image sizing syntax

* feat(clipboard): Added feature that pasted image will be added to task attachment.

* revert: Revert the change of lock file from last merge.

* revert: Removed unwanted changes.

* refactor(clipboard): Cleaned up code that seems not necessary.

* feat(clipboard): Handle storage quota exceeded error and update messages

* feat(clipboard): Implement validated IPC handlers for clipboard image operations

* feat(clipboard): Add error handling for clipboard image URL resolution

* feat(clipboard): Refactor file operations to use async fs promises

* feat(clipboard): Integrate ClipboardPasteHandlerService for improved paste handling

* feat(clipboard): Rename resolveUrl to resolveIndexedDbUrl for clarity and update references

* feat(clipboard): Add defaultPath option to showOpenDialog for improved user experience

* feat(clipboard): Implement getDefaultClipboardImagesPath utility for consistent image path retrieval

* feat(clipboard): Refactor MIME type handling to use centralized mapping for improved maintainability

* feat(clipboard): Add computed property to toggle markdown parsing based on formatting settings

* revert: Removed unwanted changes.

* revert: Removed unwanted chagnes.

* revert: Removed unwanted chagnes.

* fix(clipboard-images-cfg): remove debug log from selectImagePath method

* refactor(paste-handler): update currentPlaceholder to use getter/setter methods

* fix(docs): correct formatting and improve clarity in multiple wiki pages

* fix: update dialog handling in initLocalFileSyncAdapter for type safety

* revert: Revert space change.

* fix(inline-markdown): ensure model is set to an empty string instead of undefined

* fix(tests): update clipboard images section locator to use collapsible title
2026-02-04 15:46:51 +01:00

284 lines
7.6 KiB
TypeScript

import { ipcMain, clipboard } from 'electron';
import { promises as fsPromises } from 'fs';
import * as fs from 'fs';
import * as path from 'path';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { createValidatedHandler } from './ipc-handler-wrapper';
import { EXTENSION_MIME_TYPES } from './shared-with-frontend/mime-type-mapping.const';
interface ClipboardImageMeta {
id: string;
mimeType: string;
createdAt: number;
size: number;
}
const SUPPORTED_IMAGE_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.svg',
'.bmp',
];
/**
* Ensures the clipboard-images directory exists.
*/
const ensureDir = (dirPath: string): void => {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
};
/**
* Gets the MIME type from file extension.
*/
const getMimeFromExt = (ext: string): string => {
const extLower = ext.toLowerCase();
return (
EXTENSION_MIME_TYPES[extLower as keyof typeof EXTENSION_MIME_TYPES] || 'image/png'
);
};
/**
* Finds the image file by ID (checking various extensions).
* @electron-only This function is only available in the Electron main process.
*/
const findImageFile = (basePath: string, imageId: string): string | null => {
for (const ext of SUPPORTED_IMAGE_EXTENSIONS) {
const filePath = path.join(basePath, `${imageId}${ext}`);
if (fs.existsSync(filePath)) {
return filePath;
}
}
return null;
};
export const initClipboardImageHandlers = (): void => {
// Save clipboard image
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_SAVE,
createValidatedHandler(
async ({
basePath,
fileName,
base64Data,
}: {
basePath: string;
fileName: string;
base64Data: string;
mimeType: string;
}) => {
ensureDir(basePath);
const filePath = path.join(basePath, fileName);
const buffer = Buffer.from(base64Data, 'base64');
await fsPromises.writeFile(filePath, new Uint8Array(buffer));
return filePath;
},
{ validatePath: true },
),
);
// Load clipboard image
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_LOAD,
createValidatedHandler(
async ({ basePath, imageId }: { basePath: string; imageId: string }) => {
const filePath = findImageFile(basePath, imageId);
if (!filePath) {
return null;
}
const buffer = await fsPromises.readFile(filePath);
const ext = path.extname(filePath);
const mimeType = getMimeFromExt(ext);
return {
base64: buffer.toString('base64'),
mimeType,
};
},
{ validatePath: true, errorValue: null },
),
);
// Delete clipboard image
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_DELETE,
createValidatedHandler(
async ({ basePath, imageId }: { basePath: string; imageId: string }) => {
const filePath = findImageFile(basePath, imageId);
if (!filePath) {
return false;
}
await fsPromises.unlink(filePath);
return true;
},
{ validatePath: true, errorValue: false },
),
);
// List clipboard images
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_LIST,
createValidatedHandler(
async ({ basePath }: { basePath: string }) => {
if (!fs.existsSync(basePath)) {
return [];
}
const files = await fsPromises.readdir(basePath);
const imageExtensions = new Set(SUPPORTED_IMAGE_EXTENSIONS);
const images: ClipboardImageMeta[] = [];
for (const file of files) {
const ext = path.extname(file).toLowerCase();
if (!imageExtensions.has(ext)) {
continue;
}
const filePath = path.join(basePath, file);
const stats = await fsPromises.stat(filePath);
const id = path.basename(file, ext);
images.push({
id,
mimeType: getMimeFromExt(ext),
createdAt: stats.birthtimeMs,
size: stats.size,
});
}
return images;
},
{ validatePath: true, errorValue: [] },
),
);
// Get clipboard image file path
ipcMain.handle(
IPC.CLIPBOARD_IMAGE_GET_PATH,
createValidatedHandler(
async ({ basePath, imageId }: { basePath: string; imageId: string }) => {
return findImageFile(basePath, imageId);
},
{ validatePath: true, errorValue: null },
),
);
// Copy image file from clipboard to clipboard-images directory
ipcMain.handle(
IPC.CLIPBOARD_COPY_IMAGE_FILE,
createValidatedHandler(
async ({ basePath, filePath }: { basePath: string; filePath: string }) => {
ensureDir(basePath);
// Generate unique ID
const id = Date.now().toString(36) + Math.random().toString(36).substring(2);
const ext = path.extname(filePath).toLowerCase();
const destFileName = `${id}${ext}`;
const destPath = path.join(basePath, destFileName);
// Copy the file
await fsPromises.copyFile(filePath, destPath);
// Get file stats
const stats = await fsPromises.stat(destPath);
const mimeType = getMimeFromExt(ext);
return {
id,
mimeType,
size: stats.size,
createdAt: Date.now(),
};
},
{ validatePath: true, errorValue: null },
),
);
// Read image directly from clipboard
ipcMain.handle(
IPC.CLIPBOARD_READ_IMAGE,
createValidatedHandler(
async ({ basePath }: { basePath: string }) => {
const image = clipboard.readImage();
if (image.isEmpty()) {
return null;
}
ensureDir(basePath);
// Generate unique ID
const id = Date.now().toString(36) + Math.random().toString(36).substring(2);
const fileName = `${id}.png`;
const filePath = path.join(basePath, fileName);
// Save as PNG
const pngBuffer = image.toPNG();
await fsPromises.writeFile(filePath, new Uint8Array(pngBuffer));
const stats = await fsPromises.stat(filePath);
return {
id,
mimeType: 'image/png',
size: stats.size,
createdAt: Date.now(),
};
},
{ validatePath: true, errorValue: null },
),
);
ipcMain.handle(
IPC.CLIPBOARD_GET_FILE_PATHS,
createValidatedHandler(
async () => {
const filePaths: string[] = [];
// Note: Electron's clipboard API on Windows doesn't reliably read file paths
// when files are copied. clipboard.readImage() is used as fallback.
// Try reading plain text (sometimes contains file paths)
const plainText = clipboard.readText();
if (plainText && plainText.startsWith('file://')) {
const lines = plainText.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('file://')) {
let filePath = trimmed.substring(7);
if (process.platform === 'win32' && filePath.startsWith('/')) {
filePath = filePath.substring(1);
}
filePath = decodeURIComponent(filePath);
if (process.platform === 'win32') {
filePath = filePath.replace(/\//g, '\\');
}
filePaths.push(filePath);
}
}
}
// Filter to only existing image files
const imageFiles = filePaths.filter((filePath) => {
if (!fs.existsSync(filePath)) return false;
const ext = path.extname(filePath).toLowerCase();
return SUPPORTED_IMAGE_EXTENSIONS.includes(ext);
});
return imageFiles;
},
{ errorValue: [] },
),
);
};