[ACS-10884] Keyboard interactions for crop tool in image viewer (#11522)

* [ACS-10884] Keyboard interactions for crop tool in image viewer

* [ACS-10884] Sonarqube fix

* [ACS-10884] MD formatting fix
This commit is contained in:
Michal Kinas
2026-01-13 15:42:19 +01:00
committed by GitHub
parent 716ce228ce
commit 8824331f3b
8 changed files with 338 additions and 35 deletions
@@ -33,6 +33,26 @@ describe('Test Img viewer component ', () => {
return new Blob([data], { type: 'image/png' });
};
const dispatchKeyboardEvent = (key: string, shiftKey = false, altKey = false): KeyboardEvent => {
const event = new KeyboardEvent('keyup', {
key,
shiftKey,
altKey
});
spyOn(event, 'preventDefault');
document.dispatchEvent(event);
return event;
};
const getExpectedCropBoxData = (cropper: Cropper, left: number, width: number, top: number, height: number): Cropper.CropBoxData => {
const cropBoxData = cropper.getCropBoxData();
cropBoxData.left += left;
cropBoxData.top += top;
cropBoxData.width += width;
cropBoxData.height += height;
return cropBoxData;
};
describe('Zoom customization', () => {
beforeEach(() => {
urlService = TestBed.inject(UrlService);
@@ -375,4 +395,134 @@ describe('Test Img viewer component ', () => {
expect(testingUtils.getByCSS('#viewer-crop-button')).toBeNull('Crop button should not be visible when disallowed');
});
});
describe('keyboard interactions', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ImgViewerComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should rotate the image when r key is pressed', () => {
spyOn(component, 'rotateImage');
dispatchKeyboardEvent('r');
fixture.detectChanges();
expect(component.rotateImage).toHaveBeenCalled();
});
it('should zoom the image out when o key is pressed', () => {
spyOn(component, 'zoomOut');
dispatchKeyboardEvent('o');
fixture.detectChanges();
expect(component.zoomOut).toHaveBeenCalled();
});
it('should zoom the image in when i key is pressed', () => {
spyOn(component, 'zoomIn');
dispatchKeyboardEvent('i');
fixture.detectChanges();
expect(component.zoomIn).toHaveBeenCalled();
});
it('should move the cropper when arrow keys are pressed', () => {
spyOn(component.cropper, 'move');
dispatchKeyboardEvent('ArrowLeft');
fixture.detectChanges();
expect(component.cropper.move).toHaveBeenCalledWith(-3, 0);
dispatchKeyboardEvent('ArrowRight');
fixture.detectChanges();
expect(component.cropper.move).toHaveBeenCalledWith(3, 0);
dispatchKeyboardEvent('ArrowUp');
fixture.detectChanges();
expect(component.cropper.move).toHaveBeenCalledWith(0, -3);
dispatchKeyboardEvent('ArrowDown');
fixture.detectChanges();
expect(component.cropper.move).toHaveBeenCalledWith(0, 3);
});
it('should increase crop box area when arrow keys with shift are pressed', () => {
component.cropImage();
spyOn(component.cropper, 'setCropBoxData');
let expectedCropBoxData = getExpectedCropBoxData(component.cropper, -3, 3, 0, 0);
dispatchKeyboardEvent('ArrowLeft', true);
fixture.detectChanges();
expect(component.cropper.setCropBoxData).toHaveBeenCalledWith(expectedCropBoxData);
expectedCropBoxData = getExpectedCropBoxData(component.cropper, 0, 3, 0, 0);
dispatchKeyboardEvent('ArrowRight', true);
fixture.detectChanges();
expect(component.cropper.setCropBoxData).toHaveBeenCalledWith(expectedCropBoxData);
expectedCropBoxData = getExpectedCropBoxData(component.cropper, 0, 0, -3, 3);
dispatchKeyboardEvent('ArrowUp', true);
fixture.detectChanges();
expect(component.cropper.setCropBoxData).toHaveBeenCalledWith(expectedCropBoxData);
expectedCropBoxData = getExpectedCropBoxData(component.cropper, 0, 0, 0, 3);
dispatchKeyboardEvent('ArrowDown', true);
fixture.detectChanges();
expect(component.cropper.setCropBoxData).toHaveBeenCalledWith(expectedCropBoxData);
});
it('should decrease crop box area when arrow keys with alt are pressed', () => {
component.cropImage();
spyOn(component.cropper, 'setCropBoxData');
let expectedCropBoxData = getExpectedCropBoxData(component.cropper, 3, -3, 0, 0);
dispatchKeyboardEvent('ArrowLeft', false, true);
fixture.detectChanges();
expect(component.cropper.setCropBoxData).toHaveBeenCalledWith(expectedCropBoxData);
expectedCropBoxData = getExpectedCropBoxData(component.cropper, 0, -3, 0, 0);
dispatchKeyboardEvent('ArrowRight', false, true);
fixture.detectChanges();
expect(component.cropper.setCropBoxData).toHaveBeenCalledWith(expectedCropBoxData);
expectedCropBoxData = getExpectedCropBoxData(component.cropper, 0, 0, 3, -3);
dispatchKeyboardEvent('ArrowUp', false, true);
fixture.detectChanges();
expect(component.cropper.setCropBoxData).toHaveBeenCalledWith(expectedCropBoxData);
expectedCropBoxData = getExpectedCropBoxData(component.cropper, 0, 0, 0, -3);
dispatchKeyboardEvent('ArrowDown', false, true);
fixture.detectChanges();
expect(component.cropper.setCropBoxData).toHaveBeenCalledWith(expectedCropBoxData);
});
it('should prevent default for all arrow keys events', () => {
const leftEvent = dispatchKeyboardEvent('ArrowLeft');
fixture.detectChanges();
expect(leftEvent.preventDefault).toHaveBeenCalled();
const rightEvent = dispatchKeyboardEvent('ArrowRight');
fixture.detectChanges();
expect(rightEvent.preventDefault).toHaveBeenCalled();
const upEvent = dispatchKeyboardEvent('ArrowUp');
fixture.detectChanges();
expect(upEvent.preventDefault).toHaveBeenCalled();
const downEvent = dispatchKeyboardEvent('ArrowDown');
fixture.detectChanges();
expect(downEvent.preventDefault).toHaveBeenCalled();
});
});
});
@@ -85,27 +85,23 @@ export class ImgViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
@ViewChild('image', { static: false })
imageElement: ElementRef;
@HostListener('document:keydown', ['$event'])
@HostListener('document:keyup', ['$event'])
onKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowLeft': {
event.preventDefault();
this.cropper.move(-3, 0);
this.handleArrowLeftKey(event);
break;
}
case 'ArrowUp': {
event.preventDefault();
this.cropper.move(0, -3);
this.handleArrowUpKey(event);
break;
}
case 'ArrowRight': {
event.preventDefault();
this.cropper.move(3, 0);
this.handleArrowRightKey(event);
break;
}
case 'ArrowDown': {
event.preventDefault();
this.cropper.move(0, 3);
this.handleArrowDownKey(event);
break;
}
case 'i': {
@@ -255,4 +251,57 @@ export class ImgViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
onImageError() {
this.error.emit();
}
private handleArrowLeftKey(event: KeyboardEvent) {
event.preventDefault();
if (event.shiftKey) {
this.changeCropBoxArea(-3, 3, 0, 0);
} else if (event.altKey) {
this.changeCropBoxArea(3, -3, 0, 0);
} else {
this.cropper.move(-3, 0);
}
}
private handleArrowUpKey(event: KeyboardEvent) {
event.preventDefault();
if (event.shiftKey) {
this.changeCropBoxArea(0, 0, -3, 3);
} else if (event.altKey) {
this.changeCropBoxArea(0, 0, 3, -3);
} else {
this.cropper.move(0, -3);
}
}
private handleArrowRightKey(event: KeyboardEvent) {
event.preventDefault();
if (event.shiftKey) {
this.changeCropBoxArea(0, 3, 0, 0);
} else if (event.altKey) {
this.changeCropBoxArea(0, -3, 0, 0);
} else {
this.cropper.move(3, 0);
}
}
private handleArrowDownKey(event: KeyboardEvent) {
event.preventDefault();
if (event.shiftKey) {
this.changeCropBoxArea(0, 0, 0, 3);
} else if (event.altKey) {
this.changeCropBoxArea(0, 0, 0, -3);
} else {
this.cropper.move(0, 3);
}
}
private changeCropBoxArea(left: number, width: number, top: number, height: number) {
const cropBoxData = this.cropper.getCropBoxData();
cropBoxData.left += left;
cropBoxData.width += width;
cropBoxData.top += top;
cropBoxData.height += height;
this.cropper.setCropBoxData(cropBoxData);
}
}
@@ -17,7 +17,7 @@
import { AppExtensionService, ExtensionsModule, ViewerExtensionRef, PreviewExtensionComponent } from '@alfresco/adf-extensions';
import { NgForOf, NgTemplateOutlet } from '@angular/common';
import { Component, EventEmitter, Injector, Input, OnChanges, OnInit, Output, TemplateRef, ViewEncapsulation } from '@angular/core';
import { Component, EventEmitter, Injector, Input, OnChanges, OnInit, Output, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
@@ -129,6 +129,9 @@ export class ViewerRenderComponent implements OnChanges, OnInit {
@Output()
isSaving = new EventEmitter<boolean>();
@ViewChild(ImgViewerComponent)
imgViewer: ImgViewerComponent;
extensionTemplates: { template: TemplateRef<any>; isVisible: boolean }[] = [];
extensionsSupportedByTemplates: string[] = [];
extension: string;
@@ -53,6 +53,20 @@ describe('ViewerComponent', () => {
const getFileName = (): string => testingUtils.getByCSS('#adf-viewer-display-name').nativeElement.textContent;
const getTitle = (): string => testingUtils.getByCSS('.adf-viewer__title-value')?.nativeElement?.textContent;
const getDividers = (): DebugElement[] => testingUtils.getAllByCSS('.adf-toolbar-divider');
const mockImgViewerEditMode = (isEditing: boolean) => {
const imgViewerMock = jasmine.createSpyObj('ImgViewerComponent', [], {
isEditing
});
component.viewerRenderer.imgViewer = imgViewerMock;
};
const dispatchKeyboardEvent = (key: string): KeyboardEvent => {
const event = new KeyboardEvent('keyup', {
key
});
spyOn(event, 'preventDefault');
document.dispatchEvent(event);
return event;
};
beforeEach(() => {
TestBed.configureTestingModule({
@@ -750,4 +764,79 @@ describe('ViewerComponent', () => {
expect(component.downloadFile.emit).toHaveBeenCalled();
}));
});
describe('keyboard events', () => {
it('should do nothing when default was already prevented', () => {
spyOn(component, 'onNavigateBeforeClick').and.callThrough();
spyOn(component, 'onNavigateNextClick').and.callThrough();
spyOn(component, 'enterFullScreen').and.callThrough();
const event = new KeyboardEvent('keyup', {
key: 'a'
});
event.preventDefault();
document.dispatchEvent(event);
fixture.detectChanges();
expect(component.onNavigateBeforeClick).not.toHaveBeenCalled();
expect(component.onNavigateNextClick).not.toHaveBeenCalled();
expect(component.enterFullScreen).not.toHaveBeenCalled();
});
it('should enter full screen mode when ctrl + F is pressed', () => {
spyOn(component, 'enterFullScreen').and.callThrough();
const event = new KeyboardEvent('keyup', {
code: 'KeyF',
ctrlKey: true
});
spyOn(event, 'preventDefault');
document.dispatchEvent(event);
fixture.detectChanges();
expect(event.preventDefault).toHaveBeenCalled();
expect(component.enterFullScreen).toHaveBeenCalled();
});
it('should navigate before when left arrow is pressed', () => {
component.canNavigateBefore = true;
spyOn(component, 'onNavigateBeforeClick').and.callThrough();
fixture.detectChanges();
mockImgViewerEditMode(true);
const event = dispatchKeyboardEvent('ArrowLeft');
fixture.detectChanges();
expect(event.preventDefault).not.toHaveBeenCalled();
expect(component.onNavigateBeforeClick).not.toHaveBeenCalled();
mockImgViewerEditMode(false);
const event2 = dispatchKeyboardEvent('ArrowLeft');
fixture.detectChanges();
expect(event2.preventDefault).toHaveBeenCalled();
expect(component.onNavigateBeforeClick).toHaveBeenCalledWith(event2);
});
it('should navigate next when right arrow is pressed', () => {
component.canNavigateNext = true;
spyOn(component, 'onNavigateNextClick').and.callThrough();
fixture.detectChanges();
mockImgViewerEditMode(true);
const event = dispatchKeyboardEvent('ArrowRight');
fixture.detectChanges();
expect(event.preventDefault).not.toHaveBeenCalled();
expect(component.onNavigateNextClick).not.toHaveBeenCalled();
mockImgViewerEditMode(false);
const event2 = dispatchKeyboardEvent('ArrowRight');
fixture.detectChanges();
expect(event2.preventDefault).toHaveBeenCalled();
expect(component.onNavigateNextClick).toHaveBeenCalledWith(event2);
});
});
});
@@ -33,6 +33,7 @@ import {
Output,
SimpleChanges,
TemplateRef,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
@@ -299,6 +300,9 @@ export class ViewerComponent<T> implements OnDestroy, OnInit, OnChanges {
@Output()
submitFile = new EventEmitter<Blob>();
@ViewChild(ViewerRenderComponent)
viewerRenderer: ViewerRenderComponent;
private closeViewer = true;
private keyDown$ = fromEvent<KeyboardEvent>(document, 'keydown');
private isDialogVisible = false;
@@ -437,14 +441,16 @@ export class ViewerComponent<T> implements OnDestroy, OnInit, OnChanges {
return;
}
if (event.key === 'ArrowLeft' && this.canNavigateBefore) {
event.preventDefault();
this.onNavigateBeforeClick(event);
}
if (!this.viewerRenderer?.imgViewer?.isEditing) {
if (event.key === 'ArrowLeft' && this.canNavigateBefore) {
event.preventDefault();
this.onNavigateBeforeClick(event);
}
if (event.key === 'ArrowRight' && this.canNavigateNext) {
event.preventDefault();
this.onNavigateNextClick(event);
if (event.key === 'ArrowRight' && this.canNavigateNext) {
event.preventDefault();
this.onNavigateNextClick(event);
}
}
if (event.code === 'KeyF' && event.ctrlKey) {