diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 4de75ca..1965f2b 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -4,6 +4,8 @@ import { desktopCapturer, dialog, ipcMain, + nativeImage, + net, Notification, shell } from 'electron'; @@ -503,4 +505,34 @@ export function setupSystemHandlers(): void { await fsp.mkdir(dirPath, { recursive: true }); return true; }); + + ipcMain.handle('copy-image-to-clipboard', (_event, srcURL: string) => { + if (typeof srcURL !== 'string' || !srcURL) { + return false; + } + + return new Promise((resolve) => { + const request = net.request(srcURL); + + request.on('response', (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const image = nativeImage.createFromBuffer(Buffer.concat(chunks)); + + if (!image.isEmpty()) { + clipboard.writeImage(image); + resolve(true); + } else { + resolve(false); + } + }); + response.on('error', () => resolve(false)); + }); + + request.on('error', () => resolve(false)); + request.end(); + }); + }); } diff --git a/electron/preload.ts b/electron/preload.ts index 29daf80..6eff138 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -124,6 +124,22 @@ function readLinuxDisplayServer(): string { } } +export interface ContextMenuParams { + posX: number; + posY: number; + isEditable: boolean; + selectionText: string; + linkURL: string; + mediaType: string; + srcURL: string; + editFlags: { + canCut: boolean; + canCopy: boolean; + canPaste: boolean; + canSelectAll: boolean; + }; +} + export interface ElectronAPI { linuxDisplayServer: string; minimizeWindow: () => void; @@ -194,6 +210,9 @@ export interface ElectronAPI { deleteFile: (filePath: string) => Promise; ensureDir: (dirPath: string) => Promise; + onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void; + copyImageToClipboard: (srcURL: string) => Promise; + command: (command: Command) => Promise; query: (query: Query) => Promise; } @@ -299,6 +318,19 @@ const electronAPI: ElectronAPI = { deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath), ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath), + onContextMenu: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, params: ContextMenuParams) => { + listener(params); + }; + + ipcRenderer.on('show-context-menu', wrappedListener); + + return () => { + ipcRenderer.removeListener('show-context-menu', wrappedListener); + }; + }, + copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL), + command: (command) => ipcRenderer.invoke('cqrs:command', command), query: (query) => ipcRenderer.invoke('cqrs:query', query) }; diff --git a/electron/window/create-window.ts b/electron/window/create-window.ts index c4acabe..a88f249 100644 --- a/electron/window/create-window.ts +++ b/electron/window/create-window.ts @@ -264,6 +264,24 @@ export async function createWindow(): Promise { emitWindowState(); + mainWindow.webContents.on('context-menu', (_event, params) => { + mainWindow?.webContents.send('show-context-menu', { + posX: params.x, + posY: params.y, + isEditable: params.isEditable, + selectionText: params.selectionText, + linkURL: params.linkURL, + mediaType: params.mediaType, + srcURL: params.srcURL, + editFlags: { + canCut: params.editFlags.canCut, + canCopy: params.editFlags.canCopy, + canPaste: params.editFlags.canPaste, + canSelectAll: params.editFlags.canSelectAll + } + }); + }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index 5cca12f..a63ac6f 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -150,6 +150,7 @@ } + diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index 7f18835..69c7da0 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -39,6 +39,7 @@ import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/ import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component'; import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component'; import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component'; +import { NativeContextMenuComponent } from './features/shell/native-context-menu.component'; import { UsersActions } from './store/users/users.actions'; import { RoomsActions } from './store/rooms/rooms.actions'; import { selectCurrentRoom } from './store/rooms/rooms.selectors'; @@ -61,6 +62,7 @@ import { SettingsModalComponent, DebugConsoleComponent, ScreenShareSourcePickerComponent, + NativeContextMenuComponent, ThemeNodeDirective, ThemePickerOverlayComponent ], diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index 65d34e2..5d94461 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -134,6 +134,22 @@ export interface ElectronQuery { payload: unknown; } +export interface ContextMenuParams { + posX: number; + posY: number; + isEditable: boolean; + selectionText: string; + linkURL: string; + mediaType: string; + srcURL: string; + editFlags: { + canCut: boolean; + canCopy: boolean; + canPaste: boolean; + canSelectAll: boolean; + }; +} + export interface ElectronApi { linuxDisplayServer: string; minimizeWindow: () => void; @@ -176,6 +192,8 @@ export interface ElectronApi { fileExists: (filePath: string) => Promise; deleteFile: (filePath: string) => Promise; ensureDir: (dirPath: string) => Promise; + onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void; + copyImageToClipboard: (srcURL: string) => Promise; command: (command: ElectronCommand) => Promise; query: (query: ElectronQuery) => Promise; } diff --git a/toju-app/src/app/features/servers/servers-rail.component.html b/toju-app/src/app/features/servers/servers-rail.component.html index b83c776..fa42b1b 100644 --- a/toju-app/src/app/features/servers/servers-rail.component.html +++ b/toju-app/src/app/features/servers/servers-rail.component.html @@ -13,21 +13,19 @@ -
+
@for (room of visibleSavedRooms(); track room.id) { -
- @if (isSelectedRoom(room)) { - - } +
+ + + +
+ + } @else if (params()!.selectionText) { + + } + + @if (params()!.linkURL) { + @if (params()!.isEditable || params()!.selectionText) { +
+ } + + } + + @if (params()!.mediaType === 'image' && params()!.srcURL) { + @if (params()!.isEditable || params()!.selectionText || params()!.linkURL) { +
+ } + + } + +} diff --git a/toju-app/src/app/features/shell/native-context-menu.component.ts b/toju-app/src/app/features/shell/native-context-menu.component.ts new file mode 100644 index 0000000..1c0fdf1 --- /dev/null +++ b/toju-app/src/app/features/shell/native-context-menu.component.ts @@ -0,0 +1,75 @@ +import { + Component, + OnInit, + OnDestroy, + inject, + signal +} from '@angular/core'; +import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service'; +import { ContextMenuComponent } from '../../shared'; +import type { ContextMenuParams } from '../../core/platform/electron/electron-api.models'; + +@Component({ + selector: 'app-native-context-menu', + standalone: true, + imports: [ContextMenuComponent], + templateUrl: './native-context-menu.component.html' +}) +export class NativeContextMenuComponent implements OnInit, OnDestroy { + params = signal(null); + + private readonly electronBridge = inject(ElectronBridgeService); + private cleanup: (() => void) | null = null; + + ngOnInit(): void { + const api = this.electronBridge.getApi(); + + if (!api?.onContextMenu) { + return; + } + + this.cleanup = api.onContextMenu((incoming) => { + const hasContent = incoming.isEditable + || !!incoming.selectionText + || !!incoming.linkURL + || (incoming.mediaType === 'image' && !!incoming.srcURL); + + this.params.set(hasContent ? incoming : null); + }); + } + + ngOnDestroy(): void { + this.cleanup?.(); + this.cleanup = null; + } + + close(): void { + this.params.set(null); + } + + execCommand(command: string): void { + document.execCommand(command); + this.close(); + } + + copyLink(): void { + const url = this.params()?.linkURL; + + if (url) { + navigator.clipboard.writeText(url).catch(() => {}); + } + + this.close(); + } + + copyImage(): void { + const srcURL = this.params()?.srcURL; + const api = this.electronBridge.getApi(); + + if (srcURL && api?.copyImageToClipboard) { + api.copyImageToClipboard(srcURL).catch(() => {}); + } + + this.close(); + } +}