From 8824331f3b44e35db9e58442c32bcc1551341ae6 Mon Sep 17 00:00:00 2001 From: Michal Kinas <113341662+MichalKinas@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:42:19 +0100 Subject: [PATCH] [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 --- .../components/alfresco-viewer.component.md | 14 +- .../components/viewer-render.component.md | 14 +- docs/core/components/viewer.component.md | 14 +- .../img-viewer/img-viewer.component.spec.ts | 150 ++++++++++++++++++ .../img-viewer/img-viewer.component.ts | 67 ++++++-- .../viewer-render/viewer-render.component.ts | 5 +- .../components/viewer.component.spec.ts | 89 +++++++++++ .../lib/viewer/components/viewer.component.ts | 20 ++- 8 files changed, 338 insertions(+), 35 deletions(-) diff --git a/docs/content-services/components/alfresco-viewer.component.md b/docs/content-services/components/alfresco-viewer.component.md index a6ec5a49a4..5bd22b9d32 100644 --- a/docs/content-services/components/alfresco-viewer.component.md +++ b/docs/content-services/components/alfresco-viewer.component.md @@ -107,12 +107,14 @@ See the [Custom layout](#custom-layout) section for full details of all availabl ## Keyboard shortcuts -| Name | Description | -|--------|---------------------------------------| -| Esc | Close the viewer (overlay mode only). | -| Left | Invoke 'Navigate before' action. | -| Right | Invoke 'Navigate next' action. | -| Ctrl+F | Activate full-screen mode. | +| Name | Description | +|-------------------|---------------------------------------------------------------| +| Esc | Close the viewer (overlay mode only). | +| Left | Invoke 'Navigate before' action. Disabled in image edit mode. | +| Right | Invoke 'Navigate next' action. Disabled in image edit mode. | +| Shift + Arrow Key | Increase image crop area. Active only in image edit mode. | +| Alt + Arrow Key | Decrease image crop area. Active only in image edit mode. | +| Ctrl+F | Activate full-screen mode. | ## Details diff --git a/docs/core/components/viewer-render.component.md b/docs/core/components/viewer-render.component.md index 27665fb562..1534bd4a94 100644 --- a/docs/core/components/viewer-render.component.md +++ b/docs/core/components/viewer-render.component.md @@ -76,12 +76,14 @@ Using with file [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob): ## Keyboard shortcuts -| Name | Description | -| ---- | ----------- | -| Esc | Close the viewer (overlay mode only). | -| Left | Invoke 'Navigate before' action. | -| Right | Invoke 'Navigate next' action. | -| Ctrl+F | Activate full-screen mode. | +| Name | Description | +| ----------------- | ------------------------------------------------------------- | +| Esc | Close the viewer (overlay mode only). | +| Left | Invoke 'Navigate before' action. Disabled in image edit mode. | +| Right | Invoke 'Navigate next' action. Disabled in image edit mode. | +| Shift + Arrow Key | Increase image crop area. Active only in image edit mode. | +| Alt + Arrow Key | Decrease image crop area. Active only in image edit mode. | +| Ctrl+F | Activate full-screen mode. | ## Details diff --git a/docs/core/components/viewer.component.md b/docs/core/components/viewer.component.md index 0bb12eef06..575e39d2f5 100644 --- a/docs/core/components/viewer.component.md +++ b/docs/core/components/viewer.component.md @@ -105,12 +105,14 @@ See the [Custom layout](#custom-layout) section for full details of all availabl ## Keyboard shortcuts -| Name | Description | -| ---- | ----------- | -| Esc | Close the viewer (overlay mode only). | -| Left | Invoke 'Navigate before' action. | -| Right | Invoke 'Navigate next' action. | -| Ctrl+F | Activate full-screen mode. | +| Name | Description | +| ----------------- | ------------------------------------------------------------- | +| Esc | Close the viewer (overlay mode only). | +| Left | Invoke 'Navigate before' action. Disabled in image edit mode. | +| Right | Invoke 'Navigate next' action. Disabled in image edit mode. | +| Shift + Arrow Key | Increase image crop area. Active only in image edit mode. | +| Alt + Arrow Key | Decrease image crop area. Active only in image edit mode. | +| Ctrl+F | Activate full-screen mode. | ## Details diff --git a/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.spec.ts b/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.spec.ts index a3e46bd95f..84e10b242e 100644 --- a/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.spec.ts +++ b/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.spec.ts @@ -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(); + }); + }); }); diff --git a/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.ts b/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.ts index 5dd00384ba..2e33eb48a7 100644 --- a/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.ts +++ b/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.ts @@ -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); + } } diff --git a/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.ts b/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.ts index 218a09c132..e1d7fa977f 100644 --- a/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.ts +++ b/lib/core/src/lib/viewer/components/viewer-render/viewer-render.component.ts @@ -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(); + @ViewChild(ImgViewerComponent) + imgViewer: ImgViewerComponent; + extensionTemplates: { template: TemplateRef; isVisible: boolean }[] = []; extensionsSupportedByTemplates: string[] = []; extension: string; diff --git a/lib/core/src/lib/viewer/components/viewer.component.spec.ts b/lib/core/src/lib/viewer/components/viewer.component.spec.ts index bf1d7f076e..84f1f003fa 100644 --- a/lib/core/src/lib/viewer/components/viewer.component.spec.ts +++ b/lib/core/src/lib/viewer/components/viewer.component.spec.ts @@ -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); + }); + }); }); diff --git a/lib/core/src/lib/viewer/components/viewer.component.ts b/lib/core/src/lib/viewer/components/viewer.component.ts index 6723fcbc3f..6abf255186 100644 --- a/lib/core/src/lib/viewer/components/viewer.component.ts +++ b/lib/core/src/lib/viewer/components/viewer.component.ts @@ -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 implements OnDestroy, OnInit, OnChanges { @Output() submitFile = new EventEmitter(); + @ViewChild(ViewerRenderComponent) + viewerRenderer: ViewerRenderComponent; + private closeViewer = true; private keyDown$ = fromEvent(document, 'keydown'); private isDialogVisible = false; @@ -437,14 +441,16 @@ export class ViewerComponent 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) {