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

This commit is contained in:
2026-04-04 05:00:45 +02:00
parent 4a41de79d6
commit 0865c2fe33
9 changed files with 269 additions and 11 deletions

View File

@@ -150,6 +150,7 @@
}
<app-settings-modal />
<app-screen-share-source-picker />
<app-native-context-menu />
<app-debug-console [showLauncher]="false" />
<app-theme-picker-overlay />
</div>

View File

@@ -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
],

View File

@@ -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<boolean>;
deleteFile: (filePath: 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>;
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
}

View File

@@ -13,21 +13,19 @@
</button>
<!-- 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) {
<div class="relative flex w-full justify-center">
@if (isSelectedRoom(room)) {
<span
aria-hidden="true"
class="pointer-events-none absolute left-0 top-1/2 h-10 w-1 -translate-y-1/2 rounded-l-full bg-primary"
></span>
}
<div class="group/server relative flex w-full justify-center">
<span
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"
[ngClass]="isSelectedRoom(room) ? 'h-5 opacity-100' : 'h-0 opacity-0 group-hover/server:h-2.5 group-hover/server:opacity-100'"
></span>
<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.border-primary/30]="isSelectedRoom(room)"
[class.bg-primary/10]="isSelectedRoom(room)"
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"
[ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
[title]="room.name"
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
(click)="joinSavedRoom(room)"

View File

@@ -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>
}

View File

@@ -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();
}
}