[ACA-1614] DocumentList - context menu actions (#544)

* context menu

* make same structure check

* align naming

* lazy loading support

* update module import implementation

* close context menu on Escape

* focus and navigate context menu items

* update with material cdk 'keycodes' name

* changed module folder name
This commit is contained in:
Cilibiu Bogdan
2018-08-03 14:14:19 +03:00
committed by GitHub
parent 50b0023967
commit 27977be9a2
20 changed files with 752 additions and 7 deletions

View File

@@ -39,7 +39,8 @@
"unindent",
"exif",
"cardview",
"webm"
"webm",
"keycodes"
],
"dictionaries": [
"html",

View File

@@ -76,6 +76,7 @@ import { DirectivesModule } from './directives/directives.module';
import { ToggleInfoDrawerComponent } from './components/toolbar/toggle-info-drawer/toggle-info-drawer.component';
import { DocumentDisplayModeComponent } from './components/toolbar/document-display-mode/document-display-mode.component';
import { ToggleFavoriteComponent } from './components/toolbar/toggle-favorite/toggle-favorite.component';
import { ContextMenuModule } from './context-menu/context-menu.module';
export function setupExtensionServiceFactory(service: ExtensionService): Function {
return () => service.load();
@@ -98,6 +99,7 @@ export function setupExtensionServiceFactory(service: ExtensionService): Functio
ExtensionsModule,
DirectivesModule,
ContextMenuModule.forRoot(),
AppInfoDrawerModule
],
declarations: [

View File

@@ -13,7 +13,10 @@
</div>
<div class="inner-layout__content">
<div class="inner-layout__panel">
<adf-document-list acaDocumentList #documentList
<adf-document-list #documentList
acaDocumentList
acaContextActions
[acaContextEnable]="selection.count"
[display]="documentDisplayMode$ | async"
currentFolderId="-favorites-"
selectionMode="multiple"

View File

@@ -24,7 +24,10 @@
[parentId]="node?.id"
[disabled]="!canUpload">
<adf-document-list acaDocumentList #documentList
<adf-document-list #documentList
acaDocumentList
acaContextActions
[acaContextEnable]="selection.count"
[display]="documentDisplayMode$ | async"
[sorting]="[ 'modifiedAt', 'desc' ]"
selectionMode="multiple"

View File

@@ -16,7 +16,10 @@
<div class="inner-layout__content">
<div class="inner-layout__panel">
<adf-document-list acaDocumentList #documentList
<adf-document-list #documentList
acaDocumentList
acaContextActions
[acaContextEnable]="selection.count"
[display]="documentDisplayMode$ | async"
currentFolderId="-mysites-"
selectionMode="single"

View File

@@ -14,7 +14,10 @@
<div class="inner-layout__content">
<div class="inner-layout__panel">
<adf-document-list acaDocumentList #documentList
<adf-document-list #documentList
acaDocumentList
acaContextActions
[acaContextEnable]="selection.count"
[display]="documentDisplayMode$ | async"
currentFolderId="-recent-"
selectionMode="multiple"

View File

@@ -14,7 +14,10 @@
<div class="inner-layout__content">
<div class="inner-layout__panel">
<adf-document-list acaDocumentList #documentList
<adf-document-list #documentList
acaDocumentList
acaContextActions
[acaContextEnable]="selection.count"
[display]="documentDisplayMode$ | async"
currentFolderId="-sharedlinks-"
selectionMode="multiple"

View File

@@ -14,7 +14,10 @@
<div class="inner-layout__content">
<div class="inner-layout__panel">
<adf-document-list acaDocumentList #documentList
<adf-document-list #documentList
acaDocumentList
acaContextActions
[acaContextEnable]="selection.count"
[display]="documentDisplayMode$ | async"
currentFolderId="-trashcan-"
selectionMode="multiple"

View File

@@ -0,0 +1,52 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import {
state,
style,
animate,
transition,
query,
group,
sequence
} from '@angular/animations';
export const contextMenuAnimation = [
state('void', style({
opacity: 0,
transform: 'scale(0.01, 0.01)'
})),
transition('void => *', sequence([
query('.mat-menu-content', style({ opacity: 0 })),
animate('100ms linear', style({ opacity: 1, transform: 'scale(1, 0.5)' })),
group([
query('.mat-menu-content', animate('400ms cubic-bezier(0.55, 0, 0.55, 0.2)',
style({ opacity: 1 })
)),
animate('300ms cubic-bezier(0.25, 0.8, 0.25, 1)', style({ transform: 'scale(1, 1)' })),
])
])),
transition('* => void', animate('150ms 50ms linear', style({ opacity: 0 })))
];

View File

@@ -0,0 +1,51 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Directive, ElementRef, OnDestroy } from '@angular/core';
import { FocusableOption, FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';
@Directive({
selector: '[acaContextMenuItem]',
})
export class ContextMenuItemDirective implements OnDestroy, FocusableOption {
constructor(
private elementRef: ElementRef,
private focusMonitor: FocusMonitor) {
focusMonitor.monitor(this.getHostElement(), false);
}
ngOnDestroy() {
this.focusMonitor.stopMonitoring(this.getHostElement());
}
focus(origin: FocusOrigin = 'keyboard'): void {
this.focusMonitor.focusVia(this.getHostElement(), origin);
}
private getHostElement(): HTMLElement {
return this.elementRef.nativeElement;
}
}

View File

@@ -0,0 +1,35 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { OverlayRef } from '@angular/cdk/overlay';
export class ContextMenuOverlayRef {
constructor(private overlayRef: OverlayRef) { }
close(): void {
this.overlayRef.dispose();
}
}

View File

@@ -0,0 +1,12 @@
<div mat-menu class="mat-menu-panel" @panelAnimation>
<div class="mat-menu-content">
<ng-container *ngFor="let entry of actions">
<button mat-menu-item
acaContextMenuItem
(click)="runAction(entry.actions.click)">
<mat-icon>{{ entry.icon }}</mat-icon>
<span>{{ entry.title | translate }}</span>
</button>
</ng-container>
</div>
</div>

View File

@@ -0,0 +1,117 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import {
Component, ViewEncapsulation, OnInit, OnDestroy, HostListener,
ViewChildren, QueryList, AfterViewInit
} from '@angular/core';
import { trigger } from '@angular/animations';
import { FocusKeyManager } from '@angular/cdk/a11y';
import { DOWN_ARROW, UP_ARROW } from '@angular/cdk/keycodes';
import { ExtensionService } from '../extensions/extension.service';
import { AppStore, SelectionState } from '../store/states';
import { appSelection } from '../store/selectors/app.selectors';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs/Rx';
import { takeUntil } from 'rxjs/operators';
import { ContextMenuOverlayRef } from './context-menu-overlay';
import { ContentActionRef } from '../extensions/action.extensions';
import { contextMenuAnimation } from './animations';
import { ContextMenuItemDirective } from './context-menu-item.directive';
@Component({
selector: 'aca-context-menu',
templateUrl: './context-menu.component.html',
host: { 'role': 'menu' },
encapsulation: ViewEncapsulation.None,
animations: [
trigger('panelAnimation', contextMenuAnimation)
]
})
export class ContextMenuComponent implements OnInit, OnDestroy, AfterViewInit {
private onDestroy$: Subject<boolean> = new Subject<boolean>();
private selection: SelectionState;
private _keyManager: FocusKeyManager<ContextMenuItemDirective>;
actions: Array<ContentActionRef> = [];
@ViewChildren(ContextMenuItemDirective)
private contextMenuItems: QueryList<ContextMenuItemDirective>;
@HostListener('document:keydown.Escape', ['$event'])
handleKeydownEscape(event: KeyboardEvent) {
if (event) {
this.contextMenuOverlayRef.close();
}
}
@HostListener('document:keydown', ['$event'])
handleKeydownEvent(event: KeyboardEvent) {
if (event) {
const keyCode = event.keyCode;
if (keyCode === UP_ARROW || keyCode === DOWN_ARROW) {
this._keyManager.onKeydown(event);
}
}
}
constructor(
private contextMenuOverlayRef: ContextMenuOverlayRef,
private extensions: ExtensionService,
private store: Store<AppStore>,
) { }
runAction(actionId: string) {
const context = {
selection: this.selection
};
this.extensions.runActionById(actionId, context);
this.contextMenuOverlayRef.close();
}
ngOnDestroy() {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
ngOnInit() {
this.store
.select(appSelection)
.pipe(takeUntil(this.onDestroy$))
.subscribe(selection => {
if (selection.count) {
this.selection = selection;
this.actions = this.extensions.getAllowedContentContextActions();
}
});
}
ngAfterViewInit() {
this._keyManager = new FocusKeyManager<ContextMenuItemDirective>(this.contextMenuItems);
this._keyManager.setFirstItemActive();
}
}

View File

@@ -0,0 +1,66 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Directive, HostListener, Input } from '@angular/core';
import { ContextMenuOverlayRef } from './context-menu-overlay';
import { ContextMenuService } from './context-menu.service';
@Directive({
selector: '[acaContextActions]'
})
export class ContextActionsDirective {
private overlayRef: ContextMenuOverlayRef = null;
// tslint:disable-next-line:no-input-rename
@Input('acaContextEnable') enabled: boolean;
@HostListener('window:resize', ['$event'])
onResize(event) {
if (event && this.overlayRef) {
this.overlayRef.close();
}
}
@HostListener('contextmenu', ['$event'])
onContextmenu(event: MouseEvent) {
if (event) {
event.preventDefault();
if (this.enabled) {
this.render(event);
}
}
}
constructor(private contextMenuService: ContextMenuService) { }
private render(event: MouseEvent) {
this.overlayRef = this.contextMenuService.open({
source: event,
hasBackdrop: true,
panelClass: 'cdk-overlay-pane',
});
}
}

View File

@@ -0,0 +1,73 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { NgModule, ModuleWithProviders } from '@angular/core';
import { MatMenuModule, MatListModule, MatIconModule, MatButtonModule } from '@angular/material';
import { BrowserModule } from '@angular/platform-browser';
import { ContextActionsDirective } from './context-menu.directive';
import { ContextMenuService } from './context-menu.service';
import { ContextMenuComponent } from './context-menu.component';
import { ContextMenuItemDirective } from './context-menu-item.directive';
import { CoreModule } from '@alfresco/adf-core';
@NgModule({
imports: [
MatMenuModule,
MatListModule,
MatIconModule,
MatButtonModule,
BrowserModule,
CoreModule.forChild()
],
declarations: [
ContextActionsDirective,
ContextMenuComponent,
ContextMenuItemDirective
],
exports: [
ContextActionsDirective,
ContextMenuComponent
],
entryComponents: [
ContextMenuComponent
]
})
export class ContextMenuModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: ContextMenuModule,
providers: [
ContextMenuService
]
};
}
static forChild(): ModuleWithProviders {
return {
ngModule: ContextMenuModule
};
}
}

View File

@@ -0,0 +1,100 @@
import { Injectable, Injector, ComponentRef, ElementRef } from '@angular/core';
import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal, PortalInjector } from '@angular/cdk/portal';
import { ContextMenuOverlayRef } from './context-menu-overlay';
import { ContextMenuComponent } from './context-menu.component';
import { ContextmenuOverlayConfig } from './interfaces';
@Injectable()
export class ContextMenuService {
constructor(
private injector: Injector,
private overlay: Overlay) { }
open(config: ContextmenuOverlayConfig) {
const overlay = this.createOverlay(config);
const overlayRef = new ContextMenuOverlayRef(overlay);
this.attachDialogContainer(overlay, config, overlayRef);
overlay.backdropClick().subscribe(() => overlayRef.close());
// prevent native contextmenu on overlay element if config.hasBackdrop is true
(<any>overlay)._backdropElement
.addEventListener('contextmenu', () => {
event.preventDefault();
(<any>overlay)._backdropClick.next(null);
}, true);
return overlayRef;
}
private createOverlay(config: ContextmenuOverlayConfig) {
const overlayConfig = this.getOverlayConfig(config);
return this.overlay.create(overlayConfig);
}
private attachDialogContainer(overlay: OverlayRef, config: ContextmenuOverlayConfig, contextmenuOverlayRef: ContextMenuOverlayRef) {
const injector = this.createInjector(config, contextmenuOverlayRef);
const containerPortal = new ComponentPortal(ContextMenuComponent, null, injector);
const containerRef: ComponentRef<ContextMenuComponent> = overlay.attach(containerPortal);
return containerRef.instance;
}
private createInjector(config: ContextmenuOverlayConfig, contextmenuOverlayRef: ContextMenuOverlayRef): PortalInjector {
const injectionTokens = new WeakMap();
injectionTokens.set(ContextMenuOverlayRef, contextmenuOverlayRef);
return new PortalInjector(this.injector, injectionTokens);
}
private getOverlayConfig(config: ContextmenuOverlayConfig): OverlayConfig {
const fakeElement: any = {
getBoundingClientRect: (): ClientRect => ({
bottom: config.source.clientY,
height: 0,
left: config.source.clientX,
right: config.source.clientX,
top: config.source.clientY,
width: 0
})
};
const positionStrategy = this.overlay.position()
.connectedTo(
new ElementRef(fakeElement),
{ originX: 'start', originY: 'bottom' },
{ overlayX: 'start', overlayY: 'top' })
.withFallbackPosition(
{ originX: 'start', originY: 'top' },
{ overlayX: 'start', overlayY: 'bottom' })
.withFallbackPosition(
{ originX: 'end', originY: 'top' },
{ overlayX: 'start', overlayY: 'top' })
.withFallbackPosition(
{ originX: 'start', originY: 'top' },
{ overlayX: 'end', overlayY: 'top' })
.withFallbackPosition(
{ originX: 'end', originY: 'center' },
{ overlayX: 'start', overlayY: 'center' })
.withFallbackPosition(
{ originX: 'start', originY: 'center' },
{ overlayX: 'end', overlayY: 'center' }
);
const overlayConfig = new OverlayConfig({
hasBackdrop: config.hasBackdrop,
backdropClass: config.backdropClass,
panelClass: config.panelClass,
scrollStrategy: this.overlay.scrollStrategies.close(),
positionStrategy
});
return overlayConfig;
}
}

View File

@@ -0,0 +1,32 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
export interface ContextmenuOverlayConfig {
panelClass?: string;
hasBackdrop?: boolean;
backdropClass?: string;
source?: MouseEvent;
data?: any;
}

View File

@@ -46,6 +46,7 @@ export interface ExtensionConfig {
navbar?: Array<NavBarGroupRef>;
content?: {
actions?: Array<ContentActionRef>;
contextActions?: Array<ContentActionRef>
};
};
}

View File

@@ -54,6 +54,7 @@ export class ExtensionService implements RuleContext {
contentActions: Array<ContentActionRef> = [];
viewerActions: Array<ContentActionRef> = [];
contentContextmenuActions: Array<ContentActionRef> = [];
openWithActions: Array<ContentActionRef> = [];
createActions: Array<ContentActionRef> = [];
navbar: Array<NavBarGroupRef> = [];
@@ -124,6 +125,7 @@ export class ExtensionService implements RuleContext {
this.routes = this.loadRoutes(config);
this.contentActions = this.loadContentActions(config);
this.viewerActions = this.loadViewerActions(config);
this.contentContextmenuActions = this.loadContentContextmenuActions(config);
this.openWithActions = this.loadViewerOpenWith(config);
this.createActions = this.loadCreateActions(config);
this.navbar = this.loadNavBar(config);
@@ -173,6 +175,14 @@ export class ExtensionService implements RuleContext {
return [];
}
protected loadContentContextmenuActions(config: ExtensionConfig): Array<ContentActionRef> {
if (config && config.features && config.features.content) {
return (config.features.content.contextActions || [])
.sort(this.sortByOrder);
}
return [];
}
protected loadNavBar(config: ExtensionConfig): any {
if (config && config.features) {
return (config.features.navbar || [])
@@ -335,6 +345,12 @@ export class ExtensionService implements RuleContext {
.filter(action => this.filterByRules(action));
}
getAllowedContentContextActions(): Array<ContentActionRef> {
return this.contentContextmenuActions
.filter(this.filterEnabled)
.filter(action => this.filterByRules(action));
}
reduceSeparators(
acc: ContentActionRef[],
el: ContentActionRef,

View File

@@ -452,6 +452,175 @@
}
]
}
],
"contextActions": [
{
"id": "app.contextmenu.download",
"type": "button",
"order": 10,
"title": "APP.ACTIONS.DOWNLOAD",
"icon": "get_app",
"actions": {
"click": "DOWNLOAD_NODES"
},
"rules": {
"visible": "app.toolbar.canDownload"
}
},
{
"id": "app.contextmenu.preview",
"type": "button",
"order": 15,
"title": "APP.ACTIONS.VIEW",
"icon": "open_in_browser",
"actions": {
"click": "VIEW_FILE"
},
"rules": {
"visible": "app.toolbar.canViewFile"
}
},
{
"id": "app.contextmenu.editFolder",
"type": "button",
"order": 20,
"title": "APP.ACTIONS.EDIT",
"icon": "create",
"actions": {
"click": "EDIT_FOLDER"
},
"rules": {
"visible": "app.toolbar.canEditFolder"
}
},
{
"id": "app.contextmenu.share",
"type": "button",
"title": "APP.ACTIONS.SHARE",
"order": 25,
"icon": "share",
"actions": {
"click": "SHARE_NODE"
},
"rules": {
"visible": "app.selection.file.canShare"
}
},
{
"id": "app.contextmenu.favorite.add",
"type": "button",
"title": "APP.ACTIONS.FAVORITE",
"order": 30,
"icon": "star_border",
"actions": {
"click": "ADD_FAVORITE"
},
"rules": {
"visible": "app.toolbar.favorite.canAdd"
}
},
{
"id": "app.contextmenu.favorite.remove",
"type": "button",
"title": "APP.ACTIONS.FAVORITE",
"order": 30,
"icon": "star",
"actions": {
"click": "REMOVE_FAVORITE"
},
"rules": {
"visible": "app.toolbar.favorite.canRemove"
}
},
{
"id": "app.contextmenu.copy",
"type": "button",
"title": "APP.ACTIONS.COPY",
"order": 35,
"icon": "content_copy",
"actions": {
"click": "COPY_NODES"
},
"rules": {
"visible": "app.toolbar.canCopyNode"
}
},
{
"id": "app.contextmenu.move",
"type": "button",
"title": "APP.ACTIONS.MOVE",
"order": 40,
"icon": "library_books",
"actions": {
"click": "MOVE_NODES"
},
"rules": {
"visible": "app.selection.canDelete"
}
},
{
"id": "app.contextmenu.delete",
"type": "button",
"title": "APP.ACTIONS.DELETE",
"order": 45,
"icon": "delete",
"actions": {
"click": "DELETE_NODES"
},
"rules": {
"visible": "app.selection.canDelete"
}
},
{
"id": "app.contextmenu.versions",
"type": "button",
"title": "APP.ACTIONS.VERSIONS",
"order": 50,
"icon": "history",
"actions": {
"click": "MANAGE_VERSIONS"
},
"rules": {
"visible": "app.toolbar.versions"
}
},
{
"id": "app.contextmenu.permissions",
"type": "button",
"title": "APP.ACTIONS.PERMISSIONS",
"icon": "settings_input_component",
"order": 55,
"actions": {
"click": "MANAGE_PERMISSIONS"
},
"rules": {
"visible": "app.toolbar.permissions"
}
},
{
"id": "app.contextmenu.purgeDeletedNodes",
"type": "button",
"title": "APP.ACTIONS.DELETE_PERMANENT",
"icon": "delete_forever",
"actions": {
"click": "PURGE_DELETED_NODES"
},
"rules": {
"visible": "app.trashcan.hasSelection"
}
},
{
"id": "app.contextmenu.restoreDeletedNodes",
"type": "button",
"title": "APP.ACTIONS.RESTORE",
"icon": "restore",
"actions": {
"click": "RESTORE_DELETED_NODES"
},
"rules": {
"visible": "app.trashcan.hasSelection"
}
}
]
},
"viewer": {