mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00:00
[PM-10128] [BEEEP] Add Right-Click Menu Options to Vault (#10954)
* Added right click functionality on cipher row * Updated menu directive to position menu option on mouse event location * Updated menu directive to reopen menu option on new mouse event location and close previously opened menu-option * removed preventdefault call * Added new events for favorite and edit cipher * Added new menu options favorite, edit cipher Added new copy options for the other cipher types Simplified the copy by using the copy cipher field directive * Listen to new events * Refactored parameter to be MouseEvent * Added locales * Remove the backdrop from `MenuTriggerForDirective` * Handle the Angular overlay's outside pointer events * Cleaned up cipher row component as copy functions and disable menu functions would not be needed anymore * Fixed bug with right clicking on a row * Add right click to collections * Disable backdrop on right click * Fixed bug where dvivided didn't show for secure notes * Added comments to enable to disable context menu * Removed conditionals * Removed preferences setting to enable to disable setting * Removed setting from right click listener * improve context menu positioning to prevent viewport clipping * Keep icon consisten when favorite or not * fixed prettier issues * removed duplicate translation keys * Fix favorite status not persisting by toggling in handleFavoriteEvent * Addressed claude comments * Added comment to variable --------- Co-authored-by: Addison Beck <github@addisonbeck.com>
This commit is contained in:
@@ -93,7 +93,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</bit-menu>
|
</bit-menu>
|
||||||
@if (!decryptionFailure && !hideMenu) {
|
@if (!decryptionFailure) {
|
||||||
<button
|
<button
|
||||||
[bitMenuTriggerFor]="cipherOptions"
|
[bitMenuTriggerFor]="cipherOptions"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
@@ -104,16 +104,16 @@
|
|||||||
label="{{ 'options' | i18n }}"
|
label="{{ 'options' | i18n }}"
|
||||||
></button>
|
></button>
|
||||||
<bit-menu #cipherOptions>
|
<bit-menu #cipherOptions>
|
||||||
<ng-container *ngIf="isActiveLoginCipher">
|
<ng-container *ngIf="isLoginCipher">
|
||||||
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
|
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
|
||||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
{{ "copyUsername" | i18n }}
|
{{ "copyUsername" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="hasPasswordToCopy">
|
<button bitMenuItem type="button" appCopyField="password" [cipher]="cipher">
|
||||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
{{ "copyPassword" | i18n }}
|
{{ "copyPassword" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button bitMenuItem type="button" (click)="copy('totp')" *ngIf="showTotpCopyButton">
|
<button bitMenuItem type="button" appCopyField="totp" [cipher]="cipher">
|
||||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
{{ "copyVerificationCode" | i18n }}
|
{{ "copyVerificationCode" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
@@ -130,6 +130,53 @@
|
|||||||
</a>
|
</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="isCardCipher">
|
||||||
|
<button type="button" bitMenuItem appCopyField="cardNumber" [cipher]="cipher">
|
||||||
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
|
{{ "copyNumber" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button type="button" bitMenuItem appCopyField="securityCode" [cipher]="cipher">
|
||||||
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
|
{{ "copySecurityCode" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="isIdentityCipher">
|
||||||
|
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
|
||||||
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
|
{{ "copyUsername" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button type="button" bitMenuItem appCopyField="email" [cipher]="cipher">
|
||||||
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
|
{{ "copyEmail" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button type="button" bitMenuItem appCopyField="phone" [cipher]="cipher">
|
||||||
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
|
{{ "copyPhone" | i18n }}
|
||||||
|
</button>
|
||||||
|
<button type="button" bitMenuItem appCopyField="address" [cipher]="cipher">
|
||||||
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
|
{{ "copyAddress" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="isSecureNoteCipher">
|
||||||
|
<button type="button" bitMenuItem appCopyField="secureNote" [cipher]="cipher">
|
||||||
|
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||||
|
{{ "copyNote" | i18n }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<bit-menu-divider *ngIf="showMenuDivider"></bit-menu-divider>
|
||||||
|
|
||||||
|
<button bitMenuItem type="button" (click)="toggleFavorite()">
|
||||||
|
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
|
||||||
|
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
|
||||||
|
</button>
|
||||||
|
<button bitMenuItem type="button" (click)="editCipher()" *ngIf="canEditCipher">
|
||||||
|
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
|
||||||
|
{{ "edit" | i18n }}
|
||||||
|
</button>
|
||||||
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
|
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
|
||||||
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
|
||||||
{{ "attachments" | i18n }}
|
{{ "attachments" | i18n }}
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
|
Input,
|
||||||
|
OnInit,
|
||||||
|
Output,
|
||||||
|
ViewChild,
|
||||||
|
} from "@angular/core";
|
||||||
|
|
||||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import {
|
import {
|
||||||
CipherViewLike,
|
CipherViewLike,
|
||||||
CipherViewLikeUtils,
|
CipherViewLikeUtils,
|
||||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||||
|
import { MenuTriggerForDirective } from "@bitwarden/components";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
convertToPermission,
|
convertToPermission,
|
||||||
@@ -26,6 +36,8 @@ import { RowHeightClass } from "./vault-items.component";
|
|||||||
export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit {
|
export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit {
|
||||||
protected RowHeightClass = RowHeightClass;
|
protected RowHeightClass = RowHeightClass;
|
||||||
|
|
||||||
|
@ViewChild(MenuTriggerForDirective, { static: false }) menuTrigger: MenuTriggerForDirective;
|
||||||
|
|
||||||
@Input() disabled: boolean;
|
@Input() disabled: boolean;
|
||||||
@Input() cipher: C;
|
@Input() cipher: C;
|
||||||
@Input() showOwner: boolean;
|
@Input() showOwner: boolean;
|
||||||
@@ -73,7 +85,10 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
|||||||
];
|
];
|
||||||
protected organization?: Organization;
|
protected organization?: Organization;
|
||||||
|
|
||||||
constructor(private i18nService: I18nService) {}
|
constructor(
|
||||||
|
private i18nService: I18nService,
|
||||||
|
private vaultSettingsService: VaultSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle hook for component initialization.
|
* Lifecycle hook for component initialization.
|
||||||
@@ -180,7 +195,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
|||||||
return this.useEvents && this.cipher.organizationId;
|
return this.useEvents && this.cipher.organizationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get isActiveLoginCipher() {
|
protected get isLoginCipher() {
|
||||||
return (
|
return (
|
||||||
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login &&
|
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login &&
|
||||||
!CipherViewLikeUtils.isDeleted(this.cipher) &&
|
!CipherViewLikeUtils.isDeleted(this.cipher) &&
|
||||||
@@ -230,43 +245,63 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
|||||||
return this.i18nService.t("noAccess");
|
return this.i18nService.t("noAccess");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get showCopyUsername(): boolean {
|
protected get hasVisibleLoginOptions() {
|
||||||
const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username");
|
return (
|
||||||
return this.isActiveLoginCipher && usernameCopy;
|
this.isLoginCipher &&
|
||||||
}
|
(CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") ||
|
||||||
|
(this.cipher.viewPassword &&
|
||||||
protected get showCopyPassword(): boolean {
|
CipherViewLikeUtils.hasCopyableValue(this.cipher, "password")) ||
|
||||||
const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password");
|
this.showTotpCopyButton ||
|
||||||
return this.isActiveLoginCipher && this.cipher.viewPassword && passwordCopy;
|
this.canLaunch)
|
||||||
}
|
|
||||||
|
|
||||||
protected get showCopyTotp(): boolean {
|
|
||||||
return this.isActiveLoginCipher && this.showTotpCopyButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get showLaunchUri(): boolean {
|
|
||||||
return this.isActiveLoginCipher && this.canLaunch;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get isDeletedCanRestore(): boolean {
|
|
||||||
return CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get hideMenu() {
|
|
||||||
return !(
|
|
||||||
this.isDeletedCanRestore ||
|
|
||||||
this.showCopyUsername ||
|
|
||||||
this.showCopyPassword ||
|
|
||||||
this.showCopyTotp ||
|
|
||||||
this.showLaunchUri ||
|
|
||||||
this.showAttachments ||
|
|
||||||
this.showClone ||
|
|
||||||
this.canEditCipher
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected copy(field: "username" | "password" | "totp") {
|
protected get isCardCipher(): boolean {
|
||||||
this.onEvent.emit({ type: "copyField", item: this.cipher, field });
|
return CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Card && !this.isDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get hasVisibleCardOptions(): boolean {
|
||||||
|
return (
|
||||||
|
this.isCardCipher &&
|
||||||
|
(CipherViewLikeUtils.hasCopyableValue(this.cipher, "cardNumber") ||
|
||||||
|
CipherViewLikeUtils.hasCopyableValue(this.cipher, "securityCode"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get isIdentityCipher() {
|
||||||
|
return CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Identity && !this.isDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get hasVisibleIdentityOptions(): boolean {
|
||||||
|
return (
|
||||||
|
this.isIdentityCipher &&
|
||||||
|
(CipherViewLikeUtils.hasCopyableValue(this.cipher, "address") ||
|
||||||
|
CipherViewLikeUtils.hasCopyableValue(this.cipher, "email") ||
|
||||||
|
CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") ||
|
||||||
|
CipherViewLikeUtils.hasCopyableValue(this.cipher, "phone"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get isSecureNoteCipher() {
|
||||||
|
return (
|
||||||
|
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.SecureNote &&
|
||||||
|
!(this.isDeleted && this.canRestoreCipher)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get hasVisibleSecureNoteOptions(): boolean {
|
||||||
|
return (
|
||||||
|
this.isSecureNoteCipher && CipherViewLikeUtils.hasCopyableValue(this.cipher, "secureNote")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get showMenuDivider() {
|
||||||
|
return (
|
||||||
|
this.hasVisibleLoginOptions ||
|
||||||
|
this.hasVisibleCardOptions ||
|
||||||
|
this.hasVisibleIdentityOptions ||
|
||||||
|
this.hasVisibleSecureNoteOptions
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected clone() {
|
protected clone() {
|
||||||
@@ -308,4 +343,26 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
|||||||
|
|
||||||
return this.organization.canEditAllCiphers || (this.cipher.edit && this.cipher.viewPassword);
|
return this.organization.canEditAllCiphers || (this.cipher.edit && this.cipher.viewPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected toggleFavorite() {
|
||||||
|
this.onEvent.emit({
|
||||||
|
type: "toggleFavorite",
|
||||||
|
item: this.cipher,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected editCipher() {
|
||||||
|
this.onEvent.emit({ type: "editCipher", item: this.cipher });
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener("contextmenu", ["$event"])
|
||||||
|
protected onRightClick(event: MouseEvent) {
|
||||||
|
if (event.shiftKey && event.ctrlKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.disabled && this.menuTrigger) {
|
||||||
|
this.menuTrigger.toggleMenuOnRightClick(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
import { Component, EventEmitter, HostListener, Input, Output, ViewChild } from "@angular/core";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CollectionAdminView,
|
CollectionAdminView,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||||
|
import { MenuTriggerForDirective } from "@bitwarden/components";
|
||||||
|
|
||||||
import { GroupView } from "../../../admin-console/organizations/core";
|
import { GroupView } from "../../../admin-console/organizations/core";
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ export class VaultCollectionRowComponent<C extends CipherViewLike> {
|
|||||||
protected CollectionPermission = CollectionPermission;
|
protected CollectionPermission = CollectionPermission;
|
||||||
protected DefaultCollectionType = CollectionTypes.DefaultUserCollection;
|
protected DefaultCollectionType = CollectionTypes.DefaultUserCollection;
|
||||||
|
|
||||||
|
@ViewChild(MenuTriggerForDirective, { static: false }) menuTrigger: MenuTriggerForDirective;
|
||||||
|
|
||||||
@Input() disabled: boolean;
|
@Input() disabled: boolean;
|
||||||
@Input() collection: CollectionView;
|
@Input() collection: CollectionView;
|
||||||
@Input() showOwner: boolean;
|
@Input() showOwner: boolean;
|
||||||
@@ -122,4 +125,15 @@ export class VaultCollectionRowComponent<C extends CipherViewLike> {
|
|||||||
protected deleteCollection() {
|
protected deleteCollection() {
|
||||||
this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] });
|
this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostListener("contextmenu", ["$event"])
|
||||||
|
protected onRightClick(event: MouseEvent) {
|
||||||
|
if (event.shiftKey && event.ctrlKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.disabled && this.menuTrigger) {
|
||||||
|
this.menuTrigger.toggleMenuOnRightClick(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,6 @@ export type VaultItemEvent<C extends CipherViewLike> =
|
|||||||
| { type: "moveToFolder"; items: C[] }
|
| { type: "moveToFolder"; items: C[] }
|
||||||
| { type: "assignToCollections"; items: C[] }
|
| { type: "assignToCollections"; items: C[] }
|
||||||
| { type: "archive"; items: C[] }
|
| { type: "archive"; items: C[] }
|
||||||
| { type: "unarchive"; items: C[] };
|
| { type: "unarchive"; items: C[] }
|
||||||
|
| { type: "toggleFavorite"; item: C }
|
||||||
|
| { type: "editCipher"; item: C };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NgModule } from "@angular/core";
|
|||||||
import { RouterModule } from "@angular/router";
|
import { RouterModule } from "@angular/router";
|
||||||
|
|
||||||
import { ScrollLayoutDirective, TableModule } from "@bitwarden/components";
|
import { ScrollLayoutDirective, TableModule } from "@bitwarden/components";
|
||||||
|
import { CopyCipherFieldDirective } from "@bitwarden/vault";
|
||||||
|
|
||||||
import { CollectionNameBadgeComponent } from "../../../admin-console/organizations/collections";
|
import { CollectionNameBadgeComponent } from "../../../admin-console/organizations/collections";
|
||||||
import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module";
|
import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module";
|
||||||
@@ -26,6 +27,7 @@ import { VaultItemsComponent } from "./vault-items.component";
|
|||||||
CollectionNameBadgeComponent,
|
CollectionNameBadgeComponent,
|
||||||
GroupBadgeModule,
|
GroupBadgeModule,
|
||||||
PipesModule,
|
PipesModule,
|
||||||
|
CopyCipherFieldDirective,
|
||||||
ScrollLayoutDirective,
|
ScrollLayoutDirective,
|
||||||
],
|
],
|
||||||
declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent],
|
declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent],
|
||||||
|
|||||||
@@ -679,6 +679,12 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
|||||||
await this.bulkUnarchive(event.items);
|
await this.bulkUnarchive(event.items);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "toggleFavorite":
|
||||||
|
await this.handleFavoriteEvent(event.item);
|
||||||
|
break;
|
||||||
|
case "editCipher":
|
||||||
|
await this.editCipher(event.item);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.processingEvent = false;
|
this.processingEvent = false;
|
||||||
@@ -1452,6 +1458,27 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the favorite status of the cipher and updates it on the server.
|
||||||
|
*/
|
||||||
|
async handleFavoriteEvent(cipher: C) {
|
||||||
|
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||||
|
const cipherFullView = await this.cipherService.getFullCipherView(cipher);
|
||||||
|
cipherFullView.favorite = !cipherFullView.favorite;
|
||||||
|
const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId);
|
||||||
|
await this.cipherService.updateWithServer(encryptedCipher);
|
||||||
|
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: null,
|
||||||
|
message: this.i18nService.t(
|
||||||
|
cipherFullView.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
protected deleteCipherWithServer(id: string, userId: UserId, permanent: boolean) {
|
protected deleteCipherWithServer(id: string, userId: UserId, permanent: boolean) {
|
||||||
return permanent
|
return permanent
|
||||||
? this.cipherService.deleteWithServer(id, userId)
|
? this.cipherService.deleteWithServer(id, userId)
|
||||||
|
|||||||
@@ -11028,6 +11028,15 @@
|
|||||||
"domainClaimed": {
|
"domainClaimed": {
|
||||||
"message": "Domain claimed"
|
"message": "Domain claimed"
|
||||||
},
|
},
|
||||||
|
"itemAddedToFavorites": {
|
||||||
|
"message": "Item added to favorites"
|
||||||
|
},
|
||||||
|
"itemRemovedFromFavorites": {
|
||||||
|
"message": "Item removed from favorites"
|
||||||
|
},
|
||||||
|
"copyNote": {
|
||||||
|
"message": "Copy note"
|
||||||
|
},
|
||||||
"organizationNameMaxLength": {
|
"organizationNameMaxLength": {
|
||||||
"message": "Organization name cannot exceed 50 characters."
|
"message": "Organization name cannot exceed 50 characters."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { hasModifierKey } from "@angular/cdk/keycodes";
|
import { hasModifierKey } from "@angular/cdk/keycodes";
|
||||||
import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
|
import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
|
||||||
import { TemplatePortal } from "@angular/cdk/portal";
|
import { TemplatePortal } from "@angular/cdk/portal";
|
||||||
import {
|
import {
|
||||||
Directive,
|
Directive,
|
||||||
@@ -10,11 +10,46 @@ import {
|
|||||||
ViewContainerRef,
|
ViewContainerRef,
|
||||||
input,
|
input,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { Observable, Subscription } from "rxjs";
|
import { merge, Subscription } from "rxjs";
|
||||||
import { filter, mergeWith } from "rxjs/operators";
|
import { filter, skip, takeUntil } from "rxjs/operators";
|
||||||
|
|
||||||
import { MenuComponent } from "./menu.component";
|
import { MenuComponent } from "./menu.component";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position strategies for context menus.
|
||||||
|
* Tries positions in order: below-right, above-right, below-left, above-left
|
||||||
|
*/
|
||||||
|
const CONTEXT_MENU_POSITIONS: ConnectedPosition[] = [
|
||||||
|
// below-right
|
||||||
|
{
|
||||||
|
originX: "start",
|
||||||
|
originY: "top",
|
||||||
|
overlayX: "start",
|
||||||
|
overlayY: "top",
|
||||||
|
},
|
||||||
|
// above-right
|
||||||
|
{
|
||||||
|
originX: "start",
|
||||||
|
originY: "bottom",
|
||||||
|
overlayX: "start",
|
||||||
|
overlayY: "bottom",
|
||||||
|
},
|
||||||
|
// below-left
|
||||||
|
{
|
||||||
|
originX: "end",
|
||||||
|
originY: "top",
|
||||||
|
overlayX: "end",
|
||||||
|
overlayY: "top",
|
||||||
|
},
|
||||||
|
// above-left
|
||||||
|
{
|
||||||
|
originX: "end",
|
||||||
|
originY: "bottom",
|
||||||
|
overlayX: "end",
|
||||||
|
overlayY: "bottom",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[bitMenuTriggerFor]",
|
selector: "[bitMenuTriggerFor]",
|
||||||
exportAs: "menuTrigger",
|
exportAs: "menuTrigger",
|
||||||
@@ -52,6 +87,7 @@ export class MenuTriggerForDirective implements OnDestroy {
|
|||||||
};
|
};
|
||||||
private closedEventsSub: Subscription | null = null;
|
private closedEventsSub: Subscription | null = null;
|
||||||
private keyDownEventsSub: Subscription | null = null;
|
private keyDownEventsSub: Subscription | null = null;
|
||||||
|
private menuCloseListenerSub: Subscription | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private elementRef: ElementRef<HTMLElement>,
|
private elementRef: ElementRef<HTMLElement>,
|
||||||
@@ -63,37 +99,49 @@ export class MenuTriggerForDirective implements OnDestroy {
|
|||||||
this.isOpen ? this.destroyMenu() : this.openMenu();
|
this.isOpen ? this.destroyMenu() : this.openMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the menu on right click event.
|
||||||
|
* If the menu is already open, it updates the menu position.
|
||||||
|
* @param event The MouseEvent from the right-click interaction
|
||||||
|
*/
|
||||||
|
toggleMenuOnRightClick(event: MouseEvent) {
|
||||||
|
event.preventDefault(); // Prevent default context menu
|
||||||
|
this.isOpen ? this.updateMenuPosition(event) : this.openMenu(event);
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.disposeAll();
|
this.disposeAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
private openMenu() {
|
private openMenu(event?: MouseEvent) {
|
||||||
const menu = this.menu();
|
const menu = this.menu();
|
||||||
if (menu == null) {
|
if (menu == null) {
|
||||||
throw new Error("Cannot find bit-menu element");
|
throw new Error("Cannot find bit-menu element");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isOpen = true;
|
this.isOpen = true;
|
||||||
this.overlayRef = this.overlay.create(this.defaultMenuConfig);
|
|
||||||
|
const positionStrategy = event
|
||||||
|
? this.overlay
|
||||||
|
.position()
|
||||||
|
.flexibleConnectedTo({ x: event.clientX, y: event.clientY })
|
||||||
|
.withPositions(CONTEXT_MENU_POSITIONS)
|
||||||
|
.withLockedPosition(false)
|
||||||
|
.withFlexibleDimensions(false)
|
||||||
|
.withPush(true)
|
||||||
|
: this.defaultMenuConfig.positionStrategy;
|
||||||
|
|
||||||
|
const config = { ...this.defaultMenuConfig, positionStrategy, hasBackdrop: !event };
|
||||||
|
|
||||||
|
this.overlayRef = this.overlay.create(config);
|
||||||
|
|
||||||
const templatePortal = new TemplatePortal(menu.templateRef(), this.viewContainerRef);
|
const templatePortal = new TemplatePortal(menu.templateRef(), this.viewContainerRef);
|
||||||
this.overlayRef.attach(templatePortal);
|
this.overlayRef.attach(templatePortal);
|
||||||
|
|
||||||
this.closedEventsSub =
|
// Context menus are opened with a MouseEvent
|
||||||
this.getClosedEvents()?.subscribe((event: KeyboardEvent | undefined) => {
|
const isContextMenu = !!event;
|
||||||
// Closing the menu is handled in this.destroyMenu, so we want to prevent the escape key
|
this.setupClosingActions(isContextMenu);
|
||||||
// from doing its normal default action, which would otherwise cause a parent component
|
this.setupMenuCloseListener();
|
||||||
// (like a dialog) or extension window to close
|
|
||||||
if (event?.key === "Escape" && !hasModifierKey(event)) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event?.key && ["Tab", "Escape"].includes(event.key)) {
|
|
||||||
// Required to ensure tab order resumes correctly
|
|
||||||
this.elementRef.nativeElement.focus();
|
|
||||||
}
|
|
||||||
this.destroyMenu();
|
|
||||||
}) ?? null;
|
|
||||||
|
|
||||||
if (menu.keyManager) {
|
if (menu.keyManager) {
|
||||||
menu.keyManager.setFirstItemActive();
|
menu.keyManager.setFirstItemActive();
|
||||||
@@ -103,6 +151,32 @@ export class MenuTriggerForDirective implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the position of the menu overlay based on the mouse event coordinates.
|
||||||
|
* This is typically called when the menu is already open and the user right-clicks again,
|
||||||
|
* allowing the menu to reposition itself to the new cursor location.
|
||||||
|
* @param event The MouseEvent containing the new clientX and clientY coordinates
|
||||||
|
*/
|
||||||
|
private updateMenuPosition(event: MouseEvent) {
|
||||||
|
if (this.overlayRef == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionStrategy = this.overlay
|
||||||
|
.position()
|
||||||
|
.flexibleConnectedTo({ x: event.clientX, y: event.clientY })
|
||||||
|
.withPositions([
|
||||||
|
{
|
||||||
|
originX: "start",
|
||||||
|
originY: "top",
|
||||||
|
overlayX: "start",
|
||||||
|
overlayY: "top",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.overlayRef.updatePositionStrategy(positionStrategy);
|
||||||
|
}
|
||||||
|
|
||||||
private destroyMenu() {
|
private destroyMenu() {
|
||||||
if (this.overlayRef == null || !this.isOpen) {
|
if (this.overlayRef == null || !this.isOpen) {
|
||||||
return;
|
return;
|
||||||
@@ -113,26 +187,63 @@ export class MenuTriggerForDirective implements OnDestroy {
|
|||||||
this.menu().closed.emit();
|
this.menu().closed.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClosedEvents(): Observable<any> | null {
|
private setupClosingActions(isContextMenu: boolean) {
|
||||||
if (!this.overlayRef) {
|
if (!this.overlayRef) {
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
const detachments = this.overlayRef.detachments();
|
|
||||||
const escKey = this.overlayRef.keydownEvents().pipe(
|
const escKey = this.overlayRef.keydownEvents().pipe(
|
||||||
filter((event: KeyboardEvent) => {
|
filter((event: KeyboardEvent) => {
|
||||||
const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"];
|
const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"];
|
||||||
return keys.includes(event.key);
|
return keys.includes(event.key);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const backdrop = this.overlayRef.backdropClick();
|
|
||||||
const menuClosed = this.menu().closed;
|
const menuClosed = this.menu().closed;
|
||||||
|
const detachments = this.overlayRef.detachments();
|
||||||
|
|
||||||
return detachments.pipe(mergeWith(escKey, backdrop, menuClosed));
|
const closeEvents = isContextMenu
|
||||||
|
? merge(detachments, escKey, menuClosed)
|
||||||
|
: merge(detachments, escKey, this.overlayRef.backdropClick(), menuClosed);
|
||||||
|
|
||||||
|
this.closedEventsSub = closeEvents
|
||||||
|
.pipe(takeUntil(this.overlayRef.detachments()))
|
||||||
|
.subscribe((event) => {
|
||||||
|
// Closing the menu is handled in this.destroyMenu, so we want to prevent the escape key
|
||||||
|
// from doing its normal default action, which would otherwise cause a parent component
|
||||||
|
// (like a dialog) or extension window to close
|
||||||
|
if (event instanceof KeyboardEvent && event.key === "Escape" && !hasModifierKey(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event instanceof KeyboardEvent && (event.key === "Tab" || event.key === "Escape")) {
|
||||||
|
this.elementRef.nativeElement.focus();
|
||||||
|
}
|
||||||
|
this.destroyMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a listener for clicks outside the menu overlay.
|
||||||
|
* We skip(1) because the initial right-click event that opens the menu is also
|
||||||
|
* considered an outside click event, which would immediately close the menu
|
||||||
|
*/
|
||||||
|
private setupMenuCloseListener() {
|
||||||
|
if (!this.overlayRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.menuCloseListenerSub = this.overlayRef
|
||||||
|
.outsidePointerEvents()
|
||||||
|
.pipe(skip(1), takeUntil(this.overlayRef.detachments()))
|
||||||
|
.subscribe((_) => {
|
||||||
|
this.destroyMenu();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private disposeAll() {
|
private disposeAll() {
|
||||||
this.closedEventsSub?.unsubscribe();
|
this.closedEventsSub?.unsubscribe();
|
||||||
this.overlayRef?.dispose();
|
|
||||||
this.keyDownEventsSub?.unsubscribe();
|
this.keyDownEventsSub?.unsubscribe();
|
||||||
|
this.menuCloseListenerSub?.unsubscribe();
|
||||||
|
this.overlayRef?.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user