feat: Basic general context menu
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Deploy Web Apps / deploy (push) Successful in 14m39s
Queue Release Build / build-linux (push) Successful in 40m59s
Queue Release Build / build-windows (push) Successful in 28m59s
Queue Release Build / finalize (push) Successful in 1m58s
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Deploy Web Apps / deploy (push) Successful in 14m39s
Queue Release Build / build-linux (push) Successful in 40m59s
Queue Release Build / build-windows (push) Successful in 28m59s
Queue Release Build / finalize (push) Successful in 1m58s
This commit is contained in:
@@ -4,6 +4,8 @@ import {
|
|||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
dialog,
|
dialog,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
|
nativeImage,
|
||||||
|
net,
|
||||||
Notification,
|
Notification,
|
||||||
shell
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
@@ -503,4 +505,34 @@ export function setupSystemHandlers(): void {
|
|||||||
await fsp.mkdir(dirPath, { recursive: true });
|
await fsp.mkdir(dirPath, { recursive: true });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('copy-image-to-clipboard', (_event, srcURL: string) => {
|
||||||
|
if (typeof srcURL !== 'string' || !srcURL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<boolean>((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();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export interface ElectronAPI {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -194,6 +210,9 @@ export interface ElectronAPI {
|
|||||||
deleteFile: (filePath: string) => Promise<boolean>;
|
deleteFile: (filePath: string) => Promise<boolean>;
|
||||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||||
|
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||||
|
|
||||||
command: <T = unknown>(command: Command) => Promise<T>;
|
command: <T = unknown>(command: Command) => Promise<T>;
|
||||||
query: <T = unknown>(query: Query) => Promise<T>;
|
query: <T = unknown>(query: Query) => Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -299,6 +318,19 @@ const electronAPI: ElectronAPI = {
|
|||||||
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
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),
|
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -264,6 +264,24 @@ export async function createWindow(): Promise<void> {
|
|||||||
|
|
||||||
emitWindowState();
|
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 }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
|
|||||||
@@ -150,6 +150,7 @@
|
|||||||
}
|
}
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
<app-screen-share-source-picker />
|
<app-screen-share-source-picker />
|
||||||
|
<app-native-context-menu />
|
||||||
<app-debug-console [showLauncher]="false" />
|
<app-debug-console [showLauncher]="false" />
|
||||||
<app-theme-picker-overlay />
|
<app-theme-picker-overlay />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/
|
|||||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.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 { 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 { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
@@ -61,6 +62,7 @@ import {
|
|||||||
SettingsModalComponent,
|
SettingsModalComponent,
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareSourcePickerComponent,
|
ScreenShareSourcePickerComponent,
|
||||||
|
NativeContextMenuComponent,
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent
|
ThemePickerOverlayComponent
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ export interface ElectronQuery {
|
|||||||
payload: unknown;
|
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 {
|
export interface ElectronApi {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -176,6 +192,8 @@ export interface ElectronApi {
|
|||||||
fileExists: (filePath: string) => Promise<boolean>;
|
fileExists: (filePath: string) => Promise<boolean>;
|
||||||
deleteFile: (filePath: string) => Promise<boolean>;
|
deleteFile: (filePath: string) => Promise<boolean>;
|
||||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||||
|
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||||
|
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||||
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
||||||
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,21 +13,19 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Saved servers icons -->
|
<!-- Saved servers icons -->
|
||||||
<div class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto">
|
<div class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto pt-0.5">
|
||||||
@for (room of visibleSavedRooms(); track room.id) {
|
@for (room of visibleSavedRooms(); track room.id) {
|
||||||
<div class="relative flex w-full justify-center">
|
<div class="group/server relative flex w-full justify-center">
|
||||||
@if (isSelectedRoom(room)) {
|
<span
|
||||||
<span
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
class="pointer-events-none absolute left-0 top-1/2 w-[3px] -translate-y-1/2 rounded-r-full bg-primary transition-[height,opacity] duration-100"
|
||||||
class="pointer-events-none absolute left-0 top-1/2 h-10 w-1 -translate-y-1/2 rounded-l-full bg-primary"
|
[ngClass]="isSelectedRoom(room) ? 'h-5 opacity-100' : 'h-0 opacity-0 group-hover/server:h-2.5 group-hover/server:opacity-100'"
|
||||||
></span>
|
></span>
|
||||||
}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative z-10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md border border-transparent bg-card transition-colors hover:border-border hover:bg-card"
|
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
|
||||||
[class.border-primary/30]="isSelectedRoom(room)"
|
[ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
|
||||||
[class.bg-primary/10]="isSelectedRoom(room)"
|
|
||||||
[title]="room.name"
|
[title]="room.name"
|
||||||
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
|
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
|
||||||
(click)="joinSavedRoom(room)"
|
(click)="joinSavedRoom(room)"
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
@if (params()) {
|
||||||
|
<app-context-menu
|
||||||
|
[x]="params()!.posX"
|
||||||
|
[y]="params()!.posY"
|
||||||
|
[width]="'w-44'"
|
||||||
|
(closed)="close()"
|
||||||
|
>
|
||||||
|
@if (params()!.isEditable) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="context-menu-item"
|
||||||
|
[disabled]="!params()!.editFlags.canCut"
|
||||||
|
[class.opacity-40]="!params()!.editFlags.canCut"
|
||||||
|
(click)="execCommand('cut')"
|
||||||
|
>
|
||||||
|
Cut
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="context-menu-item"
|
||||||
|
[disabled]="!params()!.editFlags.canCopy"
|
||||||
|
[class.opacity-40]="!params()!.editFlags.canCopy"
|
||||||
|
(click)="execCommand('copy')"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="context-menu-item"
|
||||||
|
[disabled]="!params()!.editFlags.canPaste"
|
||||||
|
[class.opacity-40]="!params()!.editFlags.canPaste"
|
||||||
|
(click)="execCommand('paste')"
|
||||||
|
>
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="context-menu-item"
|
||||||
|
[disabled]="!params()!.editFlags.canSelectAll"
|
||||||
|
[class.opacity-40]="!params()!.editFlags.canSelectAll"
|
||||||
|
(click)="execCommand('selectAll')"
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</button>
|
||||||
|
} @else if (params()!.selectionText) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="context-menu-item"
|
||||||
|
(click)="execCommand('copy')"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (params()!.linkURL) {
|
||||||
|
@if (params()!.isEditable || params()!.selectionText) {
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="context-menu-item"
|
||||||
|
(click)="copyLink()"
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (params()!.mediaType === 'image' && params()!.srcURL) {
|
||||||
|
@if (params()!.isEditable || params()!.selectionText || params()!.linkURL) {
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="context-menu-item"
|
||||||
|
(click)="copyImage()"
|
||||||
|
>
|
||||||
|
Copy Image
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</app-context-menu>
|
||||||
|
}
|
||||||
@@ -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<ContextMenuParams | null>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user