diff --git a/.gitignore b/.gitignore index 1985dd7..c1016b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules package-lock.json -output/ \ No newline at end of file +output/ +dist/ \ No newline at end of file diff --git a/core/pix.ts b/core/pix.ts new file mode 100644 index 0000000..3f5f02e --- /dev/null +++ b/core/pix.ts @@ -0,0 +1,129 @@ +import * as jsonfile from "jsonfile"; +import sharp from "sharp"; + +/** + * Pix data structure for custom .pix format + */ +export interface PixData { + width: number; + height: number; + channel: number; + depth: number; + compression: { + method: string; + }; + hex_data: string; + label: Record; + joint: Record; +} + +/** + * Pix wrapper structure (may contain data property) + */ +export interface PixWrapper { + data?: PixData; + width?: number; + height?: number; + channel?: number; + depth?: number; + hex_data?: string; +} + +/** + * Pix class - Handles reading/writing custom .pix image format + */ +export class Pix { + /** + * Open a .pix file from disk + * @param path - File path to read from + * @returns Parsed pix data or null on error + */ + static open(path: string): PixData | null { + try { + const data = jsonfile.readFileSync(path); + return data; + } catch (err) { + console.error("Error reading pixData.json:", err); + return null; + } + } + + /** + * Save pix data to disk + * @param pixData - Pix data to save + * @param path - File path to save to + */ + static save(pixData: PixData, path: string): void { + try { + jsonfile.writeFileSync(path, pixData, { spaces: 2 }); + } catch (err) { + console.error("Error writing pixData.json:", err); + } + } + + /** + * Parse pix data from buffer + * @param buffer - Buffer or string containing pix JSON data + * @returns Parsed pix data or null on error + */ + static openFromBuffer(buffer: Buffer | string): PixData | null { + try { + const text = Buffer.isBuffer(buffer) ? buffer.toString("utf-8") : buffer; + return JSON.parse(text); + } catch (err) { + console.error("Error parsing pix buffer:", err); + return null; + } + } + + /** + * Convert pix data to Sharp buffer + * @param pixData - Pix data to convert + * @returns Sharp buffer with metadata or null on error + */ + static async toSharp( + pixData: PixWrapper + ): Promise<{ data: Buffer; info: sharp.OutputInfo } | null> { + const payload = pixData?.data ?? pixData; + const { width, height, channel, depth, hex_data } = payload; + if (!width || !height || !hex_data) return null; + + const channels = (channel ?? 4) as 1 | 2 | 3 | 4; + const raw = Buffer.from(hex_data, "hex"); + const bytesPerPixel = Math.ceil((depth ?? 8) / 8) * channels; + const expectedLength = width * height * bytesPerPixel; + if (raw.length < expectedLength) raw.fill(0, raw.length, expectedLength); + + return sharp(raw, { + raw: { width, height, channels }, + }) + .png() + .toBuffer({ resolveWithObject: true }); + } + + /** + * Convert image buffer to pix format + * @param buffer - Image buffer to convert + * @param info - Image metadata + * @returns Pix data structure + */ + static async toPix(buffer: Buffer, info: sharp.Metadata): Promise { + const { data, info: rawInfo } = await sharp(buffer) + .raw() + .toBuffer({ resolveWithObject: true }); + + return { + width: info.width!, + height: info.height!, + channel: info.channels!, + depth: (info as any).bitsPerSample || 8, + compression: { + method: "none", + }, + hex_data: data.toString("hex"), + label: {}, + joint: {}, + }; + } +} + diff --git a/features/image_mode.ts b/features/image_mode.ts new file mode 100644 index 0000000..55547ec --- /dev/null +++ b/features/image_mode.ts @@ -0,0 +1,147 @@ +/** + * ImageModes - Unified mode system for image layer interactions + * + * Replaces individual flags (drawFlag, dragFlag, magnifyFlag) with a single mode state. + * This makes the code more maintainable and prevents conflicting states. + */ + +import * as path from "path"; + +/** + * Available interaction modes for image layers + */ +export enum ImageMode { + /** Default mode - no special interaction */ + NORMAL = "normal", + + /** Drawing/painting mode - user is drawing on canvas */ + DRAWING = "drawing", + + /** Cropping mode - user is selecting a crop area (crosshair cursor) */ + CROPPING = "cropping", + + /** Magnifying glass mode - zoomed preview follows mouse (Alt + M) */ + MAGNIFY = "magnify", + + /** Color picker mode - user is picking a color from the image */ + COLORPICKER = "colorpicker", +} + +/** + * Mode manager utility class + * Provides helper methods for mode management + */ +export class ModeManager { + private currentMode: ImageMode = ImageMode.NORMAL; + private previousMode: ImageMode = ImageMode.NORMAL; + + /** + * Set the current mode + * @param mode - The mode to set + */ + setMode(mode: ImageMode): void { + if (!Object.values(ImageMode).includes(mode)) { + console.warn(`Invalid mode: ${mode}. Using NORMAL mode.`); + mode = ImageMode.NORMAL; + } + this.previousMode = this.currentMode; + this.currentMode = mode; + } + + /** + * Get the current mode + * @returns Current mode + */ + getMode(): ImageMode { + return this.currentMode; + } + + /** + * Check if in a specific mode + * @param mode - Mode to check + * @returns True if in the specified mode + */ + isMode(mode: ImageMode): boolean { + return this.currentMode === mode; + } + + /** + * Check if in normal mode + * @returns True if in normal mode + */ + isNormal(): boolean { + return this.currentMode === ImageMode.NORMAL; + } + + /** + * Check if in drawing mode + * @returns True if in drawing mode + */ + isDrawing(): boolean { + return this.currentMode === ImageMode.DRAWING; + } + + /** + * Check if in cropping mode + * @returns True if in cropping mode + */ + isCropping(): boolean { + return this.currentMode === ImageMode.CROPPING; + } + + /** + * Check if in magnify mode + * @returns True if in magnify mode + */ + isMagnifying(): boolean { + return this.currentMode === ImageMode.MAGNIFY; + } + + /** + * Check if in color picker mode + * @returns True if in color picker mode + */ + isColorPicker(): boolean { + return this.currentMode === ImageMode.COLORPICKER; + } + + /** + * Reset to normal mode + */ + reset(): void { + this.setMode(ImageMode.NORMAL); + } + + /** + * Restore previous mode + */ + restorePrevious(): void { + this.setMode(this.previousMode); + } + + /** + * Get cursor style for current mode + * @returns CSS cursor value + */ + getCursor(): string { + switch (this.currentMode) { + case ImageMode.CROPPING: + return "crosshair"; + case ImageMode.MAGNIFY: + return "zoom-in"; + case ImageMode.DRAWING: + const cursorPathDraw = path + .join(__dirname, "../../assets/pencil.png") + .replace(/\\/g, "/"); + return `url('file://${cursorPathDraw}') 0 32, crosshair`; + case ImageMode.COLORPICKER: + const cursorPathPicker = path + .join(__dirname, "../../assets/spoid.png") + .replace(/\\/g, "/"); + return `url('file://${cursorPathPicker}') 0 24, crosshair`; + default: + return "default"; + } + } +} + diff --git a/index.html b/index.html index a459a7a..9b1c02c 100644 --- a/index.html +++ b/index.html @@ -32,7 +32,7 @@
- - + + diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..07418c7 --- /dev/null +++ b/main.ts @@ -0,0 +1,375 @@ +import { + app, + ipcMain, + dialog, + BrowserWindow, + BrowserView, + Menu, + MenuItem, + clipboard, + nativeImage, + IpcMainEvent, + MenuItemConstructorOptions, +} from "electron"; +import * as pkg from "./package.json"; +import { ImageProcessor } from "./processing/image_processor"; +import { IPCBridge } from "./utils/ipc_bridge"; +import { ContextMenuOptions } from "./types/ipc"; + +//electron refresh (only develop) +if (process.env.NODE_ENV === "development") { + try { + const electronReload = require("electron-reload"); + electronReload(__dirname, { + electron: require(`${__dirname}/node_modules/electron`), + }); + } catch (e) { + console.log("electron-reload not found (this is okay in production)"); + } +} + +// Configuration for browser window creation +const WINDOW_CONFIG = { + width: 1280, + height: 1024, + center: true, + icon: `${__dirname}/assets/icon.ico`, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, +}; + +function createMainWindow(): BrowserWindow { + const mainWindow = new BrowserWindow(WINDOW_CONFIG); + mainWindow.loadFile("index.html"); + return mainWindow; +} + +function createPanelView(mainWindow: BrowserWindow): void { + const view = new BrowserView({ + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + view.setBounds({ x: 0, y: 33, width: 1280, height: 90 }); + view.setAutoResize({ width: true, height: false }); + view.webContents.loadFile(`./pages/_panel.html`); + mainWindow.addBrowserView(view); +} + +// Context menu helper +function createContextMenu(event: IpcMainEvent, { hasUndo, hasRedo }: ContextMenuOptions): Menu { + const contextMenuItems: Array< + | { label: string; accelerator: string; action: string; enabled?: boolean } + | { type: "separator" } + > = [ + { label: "Copy", accelerator: "Ctrl+C", action: "copy" }, + { label: "Paste", accelerator: "Ctrl+V", action: "paste" }, + { type: "separator" }, + { label: "Delete", accelerator: "Delete", action: "delete" }, + { type: "separator" }, + { label: "Undo", accelerator: "Ctrl+Z", action: "undo", enabled: hasUndo }, + { label: "Redo", accelerator: "Ctrl+Y", action: "redo", enabled: hasRedo }, + { + label: "Watermark", + accelerator: "Ctrl+W", + action: "watermark", + enabled: true, + }, + { type: "separator" }, + { label: "Hide", accelerator: "Alt + H", action: "hide" }, + ]; + + const menu = new Menu(); + contextMenuItems.forEach((item) => { + if ("type" in item && item.type === "separator") { + menu.append(new MenuItem({ type: "separator" })); + } else if ("action" in item) { + menu.append( + new MenuItem({ + label: item.label, + accelerator: item.accelerator, + enabled: item.enabled !== false, + click: () => { + event.sender.send("imgkit-context-menu-action", item.action); + }, + }) + ); + } + }); + + return menu; +} + +// Application menu helper +function buildApplicationMenu(event: IpcMainEvent, mainWindow: BrowserWindow): Menu { + const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; + + const IMAGE_EXTENSIONS = [ + "pix", + "png", + "svg", + "jpg", + "jpeg", + "webp", + "bmp", + "gif", + "ico", + "tiff", + "tif", + "avif", + "heif", + "heic", + ]; + + const template: MenuItemConstructorOptions[] = [ + { + label: "File", + submenu: [ + { + label: "Open...", + accelerator: "Ctrl+O", + click: async () => { + const result = await dialog.showOpenDialog({ + properties: ["openFile", "multiSelections"], + filters: [{ name: "Image file", extensions: IMAGE_EXTENSIONS }], + }); + if (!result.canceled) { + event.sender.send("openImgCMD", result.filePaths); + } + }, + }, + { + label: "Save", + accelerator: "Ctrl+S", + click: () => event.sender.send("saveImgCMD"), + }, + { + label: "Save As...", + accelerator: "Ctrl+Shift+S", + click: () => event.sender.send("setExtensionCMD"), + }, + ], + }, + ...(isDev + ? [ + { + label: "Debug", + submenu: [ + { + label: "Toggle Developer Tools", + accelerator: "F12", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.openDevTools(); + } + }, + }, + { + label: "Toggle BrowserView Developer Tools", + accelerator: "Ctrl+Shift+I", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + const views = mainWindow.getBrowserViews(); + if (views.length > 0) { + views[0].webContents.openDevTools(); + } + } + }, + }, + ], + }, + ] + : []), + { + label: "Help", + submenu: [ + { + label: "About", + click: () => { + dialog.showMessageBox({ + title: "About", + buttons: ["Ok"], + message: `Author : ${pkg.author.name}\nEmail : ${pkg.author.email}\nVersion : v${pkg.version}\nLicense : ${pkg.license}\n`, + }); + }, + }, + ], + }, + ]; + + return Menu.buildFromTemplate(template); +} + +// This method will be called when Electron has finished initialization +app.whenReady().then(() => { + Menu.setApplicationMenu(null); + + const mainWindow = createMainWindow(); + + // Initialize IPCBridge instance + const ipcBridge = new IPCBridge(ipcMain, mainWindow); + + // Panel loading handlers - load panel for various image operations + const PANEL_OPERATIONS = [ + "resizeImgREQ", + "cropImgREQ", + "filterImgREQ", + "rotateImgREQ", + "paintImgREQ", + "image_analysisImgREQ", + ]; + + PANEL_OPERATIONS.forEach((operation) => { + ipcMain.on(operation, () => { + const panelName = operation.replace("ImgREQ", ""); + mainWindow + .getBrowserViews()[0] + .webContents.loadFile(`./pages/${panelName}_panel.html`); + }); + }); + + // IPC Forwarders - Register simple forwarding patterns in bulk + ipcBridge.registerMultiple([ + // Resize commands + { from: "resizeValueSEND", to: "resizeImgCMD" }, + { from: "resizePixelValueSEND", to: "resizePixelImgCMD" }, + + // Filter commands + { from: "blurValueSEND", to: "blurImgCMD" }, + { from: "sharpenValueSEND", to: "sharpenImgCMD" }, + { from: "normalizeImgREQ", to: "normalizeImgCMD" }, + { from: "medianValueSEND", to: "medianImgCMD" }, + { from: "dilateValueSEND", to: "dilateImgCMD" }, + { from: "erodeValueSEND", to: "erodeImgCMD" }, + { from: "bitwiseValueSEND", to: "bitwiseImgCMD" }, + { from: "negativeImgREQ", to: "negativeImgCMD" }, + { from: "grayScaleImgREQ", to: "grayScaleImgCMD" }, + + // Rotate commands + { from: "rotateValueSEND", to: "rotateImgCMD" }, + { from: "rotateLeftImgREQ", to: "rotateLeftImgCMD" }, + { from: "rotateRightImgREQ", to: "rotateRightImgCMD" }, + { from: "flipImgREQ", to: "flipImgCMD" }, + { from: "flopImgREQ", to: "flopImgCMD" }, + + // Paint commands + { from: "tintValueSEND", to: "tintImgCMD" }, + { from: "colorpickerImgREQ", to: "colorpickerImgCMD" }, + { from: "watermarkUploadREQ", to: "watermarkUploadCMD" }, + { from: "watermarkImgREQ", to: "watermarkImgCMD" }, + { from: "drawImgREQ", to: "drawImgCMD" }, + { from: "padImgREQ", to: "padImgCMD" }, + + // Crop commands + { from: "rectCropImgREQ", to: "cropImgCMD" }, + ]); + + // Custom handler - colorpicker requires broadcast to BrowserViews + ipcMain.on("colorpickerValueSEND", async (event: IpcMainEvent, color: string) => { + const views = mainWindow.getBrowserViews(); + const color_name = await ImageProcessor.getColorName(color); + views.forEach((view) => { + view.webContents.send("colorpickerValueRECV", color, color_name); + }); + }); + + // Notification Handler - Forward to main window + ipcMain.on("showNotificationREQ", (event: IpcMainEvent, notificationId: string) => { + mainWindow.webContents.send("showNotificationCMD", notificationId); + }); + + // ImgKit Context Menu Handler + ipcMain.on("show-imgkit-context-menu", (event: IpcMainEvent, options: ContextMenuOptions) => { + const menu = createContextMenu(event, options); + menu.popup({ window: BrowserWindow.fromWebContents(event.sender)! }); + }); + + // Native Clipboard Handlers + ipcMain.on("copy-image-to-clipboard", (event: IpcMainEvent, imageBuffer: ArrayBuffer) => { + try { + const image = nativeImage.createFromBuffer(Buffer.from(imageBuffer)); + clipboard.writeImage(image); + } catch (error) { + console.error("Failed to copy image to clipboard:", error); + } + }); + + ipcMain.on("imgkit-layer-event", (event: IpcMainEvent, payload: any) => { + event.sender.send("imgkit-layer-event", payload); + }); + + ipcMain.handle("paste-image-from-clipboard", () => { + try { + const image = clipboard.readImage(); + if (!image.isEmpty()) { + return image.toPNG(); + } + return null; + } catch (error) { + console.error("Failed to paste image from clipboard:", error); + return null; + } + }); + + ipcMain.on("FullScreenREQ", (event: IpcMainEvent) => { + mainWindow.setSimpleFullScreen(true); + mainWindow.show(); + }); + + ipcMain.on("DefaultScreenREQ", (event: IpcMainEvent) => { + mainWindow.setSimpleFullScreen(false); + mainWindow.show(); + }); + + ipcMain.on("extensionValueSEND", async (event: IpcMainEvent, res: string) => { + const result = await dialog.showSaveDialog({ + title: "Save Image", + defaultPath: "~/image", + filters: [ + { + name: "Image file", + extensions: [res], + }, + ], + }); + if (!result.canceled && result.filePath) { + event.sender.send("saveAsImgCMD", result.filePath); + } + }); + + ipcMain.on("saveImgREQ", (event: IpcMainEvent) => { + event.sender.send("saveImgCMD"); + }); + + ipcMain.on("deleteImgREQ", (event: IpcMainEvent) => { + event.sender.send("deleteImgCMD"); + }); + + // Show application menu + ipcMain.on("showMenuREQ", (event: IpcMainEvent) => { + const menu = buildApplicationMenu(event, mainWindow); + Menu.setApplicationMenu(menu); + createPanelView(mainWindow); + }); + + app.on("activate", function () { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); + }); +}); + +// Quit when all windows are closed, except on macOS. There, it's common +// for applications and their menu bar to stay active until the user quits +// explicitly with Cmd + Q. +app.on("window-all-closed", function () { + if (process.platform !== "darwin") app.quit(); +}); + +// In this file you can include the rest of your app's specific main process +// code. You can also put them in separate files and require them here. + diff --git a/package-lock.json b/package-lock.json index c4b2c09..3b30654 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,10 +18,12 @@ "sharp-ico": "^0.1.5" }, "devDependencies": { + "@types/node": "^25.0.2", "electron": "^37.3.0", "electron-builder": "^26.0.12", "electron-rebuild": "^3.2.9", - "electron-reload": "^2.0.0-alpha.1" + "electron-reload": "^2.0.0-alpha.1", + "typescript": "^5.9.3" } }, "node_modules/@canvas/image-data": { @@ -1333,13 +1335,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", - "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "version": "25.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", + "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/plist": { @@ -2991,6 +2993,23 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5850,9 +5869,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 8fce27c..d6fa8db 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pegasus", "version": "1.0.12", "description": "", - "main": "main.js", + "main": "dist/main.js", "dependencies": { "color-namer": "^1.4.0", "fs": "^0.0.1-security", @@ -12,16 +12,21 @@ "sharp-ico": "^0.1.5" }, "devDependencies": { + "@types/node": "^25.0.2", "electron": "^37.3.0", "electron-builder": "^26.0.12", "electron-rebuild": "^3.2.9", - "electron-reload": "^2.0.0-alpha.1" + "electron-reload": "^2.0.0-alpha.1", + "typescript": "^5.9.3" }, "scripts": { "postinstall": "electron-builder install-app-deps", - "start": "electron .", - "build": "electron-builder", - "build:win-store": "electron-builder --win appx", + "build:ts": "tsc -p tsconfig.json", + "watch:ts": "tsc -w -p tsconfig.json", + "dev": "npm run build:ts && electron .", + "start": "npm run dev", + "build": "npm run build:ts && electron-builder", + "build:win-store": "npm run build:ts && electron-builder --win appx", "rebuild": "electron-rebuild -f -w sharp" }, "build": { @@ -54,7 +59,14 @@ "allowToChangeInstallationDirectory": true }, "files": [ - "**/*", + "dist/**/*", + "assets/**/*", + "pages/**/*", + "css/**/*", + "components/**/*", + "index.html", + "image_kit.js", + "package.json", "!output" ], "directories": { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c244337 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,50 @@ +{ + "compilerOptions": { + /* Language and Environment */ + "target": "ES2020", + "lib": ["ES2020"], + "module": "commonjs", + "moduleResolution": "node", + + /* Emit */ + "outDir": "./dist", + "rootDir": ".", + "sourceMap": true, + "removeComments": false, + + /* Interop Constraints */ + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + + /* Type Checking */ + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "strictFunctionTypes": false, + "strictBindCallApply": false, + "strictPropertyInitialization": false, + "noImplicitThis": false, + "alwaysStrict": false, + + /* Completeness */ + "skipLibCheck": true, + + /* Incremental Migration Support */ + "allowJs": true, + "checkJs": false, + "resolveJsonModule": true, + + /* Additional Options */ + "types": ["node"] + }, + "include": [ + "**/*.ts", + "**/*.js" + ], + "exclude": [ + "node_modules", + "output", + "dist" + ] +} diff --git a/types/ipc.ts b/types/ipc.ts new file mode 100644 index 0000000..4df7f5b --- /dev/null +++ b/types/ipc.ts @@ -0,0 +1,227 @@ +/** + * IPC Channel Type Definitions + * + * Centralized type definitions for IPC communication between main and renderer processes. + * This provides type safety for IPC channels and their payloads. + */ + +// ============================================================================ +// IPC CHANNEL NAMES +// ============================================================================ + +/** + * IPC channels for panel loading requests + */ +export const PANEL_CHANNELS = { + RESIZE: 'resizeImgREQ', + CROP: 'cropImgREQ', + FILTER: 'filterImgREQ', + ROTATE: 'rotateImgREQ', + PAINT: 'paintImgREQ', + IMAGE_ANALYSIS: 'image_analysisImgREQ', +} as const; + +/** + * IPC channels for resize operations + */ +export const RESIZE_CHANNELS = { + VALUE_SEND: 'resizeValueSEND', + VALUE_CMD: 'resizeImgCMD', + PIXEL_VALUE_SEND: 'resizePixelValueSEND', + PIXEL_VALUE_CMD: 'resizePixelImgCMD', +} as const; + +/** + * IPC channels for filter operations + */ +export const FILTER_CHANNELS = { + BLUR_SEND: 'blurValueSEND', + BLUR_CMD: 'blurImgCMD', + SHARPEN_SEND: 'sharpenValueSEND', + SHARPEN_CMD: 'sharpenImgCMD', + NORMALIZE_REQ: 'normalizeImgREQ', + NORMALIZE_CMD: 'normalizeImgCMD', + MEDIAN_SEND: 'medianValueSEND', + MEDIAN_CMD: 'medianImgCMD', + DILATE_SEND: 'dilateValueSEND', + DILATE_CMD: 'dilateImgCMD', + ERODE_SEND: 'erodeValueSEND', + ERODE_CMD: 'erodeImgCMD', + BITWISE_SEND: 'bitwiseValueSEND', + BITWISE_CMD: 'bitwiseImgCMD', + NEGATIVE_REQ: 'negativeImgREQ', + NEGATIVE_CMD: 'negativeImgCMD', + GRAYSCALE_REQ: 'grayScaleImgREQ', + GRAYSCALE_CMD: 'grayScaleImgCMD', +} as const; + +/** + * IPC channels for rotate operations + */ +export const ROTATE_CHANNELS = { + VALUE_SEND: 'rotateValueSEND', + VALUE_CMD: 'rotateImgCMD', + LEFT_REQ: 'rotateLeftImgREQ', + LEFT_CMD: 'rotateLeftImgCMD', + RIGHT_REQ: 'rotateRightImgREQ', + RIGHT_CMD: 'rotateRightImgCMD', + FLIP_REQ: 'flipImgREQ', + FLIP_CMD: 'flipImgCMD', + FLOP_REQ: 'flopImgREQ', + FLOP_CMD: 'flopImgCMD', +} as const; + +/** + * IPC channels for paint operations + */ +export const PAINT_CHANNELS = { + TINT_SEND: 'tintValueSEND', + TINT_CMD: 'tintImgCMD', + COLORPICKER_REQ: 'colorpickerImgREQ', + COLORPICKER_CMD: 'colorpickerImgCMD', + COLORPICKER_VALUE_SEND: 'colorpickerValueSEND', + COLORPICKER_VALUE_RECV: 'colorpickerValueRECV', + WATERMARK_UPLOAD_REQ: 'watermarkUploadREQ', + WATERMARK_UPLOAD_CMD: 'watermarkUploadCMD', + WATERMARK_REQ: 'watermarkImgREQ', + WATERMARK_CMD: 'watermarkImgCMD', + DRAW_REQ: 'drawImgREQ', + DRAW_CMD: 'drawImgCMD', + PAD_REQ: 'padImgREQ', + PAD_CMD: 'padImgCMD', +} as const; + +/** + * IPC channels for crop operations + */ +export const CROP_CHANNELS = { + RECT_CROP_REQ: 'rectCropImgREQ', + CROP_CMD: 'cropImgCMD', +} as const; + +/** + * IPC channels for file operations + */ +export const FILE_CHANNELS = { + OPEN_CMD: 'openImgCMD', + SAVE_CMD: 'saveImgCMD', + SAVE_AS_CMD: 'saveAsImgCMD', + SAVE_REQ: 'saveImgREQ', + DELETE_REQ: 'deleteImgREQ', + DELETE_CMD: 'deleteImgCMD', + SET_EXTENSION_CMD: 'setExtensionCMD', + EXTENSION_VALUE_SEND: 'extensionValueSEND', +} as const; + +/** + * IPC channels for UI operations + */ +export const UI_CHANNELS = { + SHOW_MENU_REQ: 'showMenuREQ', + SHOW_NOTIFICATION_REQ: 'showNotificationREQ', + SHOW_NOTIFICATION_CMD: 'showNotificationCMD', + FULLSCREEN_REQ: 'FullScreenREQ', + DEFAULT_SCREEN_REQ: 'DefaultScreenREQ', + SHOW_IMGKIT_CONTEXT_MENU: 'show-imgkit-context-menu', + IMGKIT_CONTEXT_MENU_ACTION: 'imgkit-context-menu-action', +} as const; + +/** + * IPC channels for clipboard operations + */ +export const CLIPBOARD_CHANNELS = { + COPY_IMAGE: 'copy-image-to-clipboard', + PASTE_IMAGE: 'paste-image-from-clipboard', +} as const; + +/** + * IPC channels for layer events + */ +export const LAYER_CHANNELS = { + LAYER_EVENT: 'imgkit-layer-event', +} as const; + +// ============================================================================ +// TYPE DEFINITIONS FOR PAYLOADS +// ============================================================================ + +/** + * Context menu options + */ +export interface ContextMenuOptions { + hasUndo: boolean; + hasRedo: boolean; +} + +/** + * Context menu action types + */ +export type ContextMenuAction = 'copy' | 'paste' | 'delete' | 'undo' | 'redo' | 'watermark' | 'hide'; + +/** + * Layer event payload + */ +export interface LayerEventPayload { + type: string; + layerId?: string; + data?: any; +} + +/** + * Resize options + */ +export interface ResizeOptions { + width?: number; + height?: number; + fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside'; +} + +/** + * Color value (hex string) + */ +export type ColorValue = string; + +/** + * Color name result + */ +export interface ColorNameResult { + name: string; + hex: string; +} + +/** + * File path result from dialog + */ +export interface FilePathResult { + filePaths: string[]; + canceled: boolean; +} + +/** + * Save path result from dialog + */ +export interface SavePathResult { + filePath?: string; + canceled: boolean; +} + +/** + * All IPC channel names grouped + */ +export const IPC_CHANNELS = { + ...PANEL_CHANNELS, + ...RESIZE_CHANNELS, + ...FILTER_CHANNELS, + ...ROTATE_CHANNELS, + ...PAINT_CHANNELS, + ...CROP_CHANNELS, + ...FILE_CHANNELS, + ...UI_CHANNELS, + ...CLIPBOARD_CHANNELS, + ...LAYER_CHANNELS, +} as const; + +/** + * Type helper to get all channel names + */ +export type IPCChannelName = typeof IPC_CHANNELS[keyof typeof IPC_CHANNELS]; diff --git a/utils/ipc_bridge.ts b/utils/ipc_bridge.ts new file mode 100644 index 0000000..d3bac1e --- /dev/null +++ b/utils/ipc_bridge.ts @@ -0,0 +1,120 @@ +import { IpcMain, BrowserWindow, IpcMainEvent } from "electron"; + +/** + * IPCBridge - Utility class to simplify IPC communication + * + * Eliminates repetitive IPC forwarder patterns and allows declarative channel registration. + * + * @example + * const bridge = new IPCBridge(ipcMain, mainWindow); + * bridge.registerForwarder('resizeValueSEND', 'resizeImgCMD'); + * bridge.registerForwarder('blurValueSEND', 'blurImgCMD'); + */ +export class IPCBridge { + private ipcMain: IpcMain; + private targetWindow: BrowserWindow; + private registeredChannels: Set = new Set(); + + /** + * @param ipcMain - Electron IPC Main instance + * @param targetWindow - Target window to forward messages to + */ + constructor(ipcMain: IpcMain, targetWindow: BrowserWindow) { + this.ipcMain = ipcMain; + this.targetWindow = targetWindow; + } + + /** + * Register a simple forwarder pattern + * Forwards messages received from fromChannel to toChannel and focuses the window + * + * @param fromChannel - IPC channel name to receive from + * @param toChannel - IPC channel name to send to + * @param shouldFocus - Whether to focus window after sending message (default: true) + */ + registerForwarder(fromChannel: string, toChannel: string, shouldFocus: boolean = true): void { + if (this.registeredChannels.has(fromChannel)) { + console.warn( + `[IPCBridge] Channel "${fromChannel}" is already registered. Skipping.` + ); + return; + } + + this.ipcMain.on(fromChannel, (event: IpcMainEvent, ...args: any[]) => { + this.targetWindow.webContents.send(toChannel, ...args); + if (shouldFocus) { + this.targetWindow.webContents.focus(); + } + }); + + this.registeredChannels.add(fromChannel); + } + + /** + * Register multiple forwarders in bulk + * + * @param mappings - Array of channel mappings + * + * @example + * bridge.registerMultiple([ + * { from: 'resizeValueSEND', to: 'resizeImgCMD' }, + * { from: 'blurValueSEND', to: 'blurImgCMD' }, + * { from: 'flipImgREQ', to: 'flipImgCMD' } + * ]); + */ + registerMultiple(mappings: Array<{ from: string; to: string; focus?: boolean }>): void { + mappings.forEach(({ from, to, focus = true }) => { + this.registerForwarder(from, to, focus); + }); + } + + /** + * Register a custom handler (for cases requiring complex logic) + * + * @param channel - IPC channel name to receive from + * @param handler - Custom handler function + */ + registerCustomHandler(channel: string, handler: (event: IpcMainEvent, ...args: any[]) => void): void { + if (this.registeredChannels.has(channel)) { + console.warn( + `[IPCBridge] Channel "${channel}" is already registered. Skipping.` + ); + return; + } + + this.ipcMain.on(channel, handler); + this.registeredChannels.add(channel); + } + + /** + * Return list of all registered channels + * + * @returns List of registered channels + */ + getRegisteredChannels(): string[] { + return Array.from(this.registeredChannels); + } + + /** + * Unregister a specific channel (mainly for testing) + * + * @param channel - Channel name to unregister + */ + unregister(channel: string): void { + if (this.registeredChannels.has(channel)) { + this.ipcMain.removeAllListeners(channel); + this.registeredChannels.delete(channel); + } + } + + /** + * Unregister all registered channels + */ + unregisterAll(): void { + this.registeredChannels.forEach((channel) => { + this.ipcMain.removeAllListeners(channel); + }); + this.registeredChannels.clear(); + } +} +