diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.html b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.html new file mode 100644 index 000000000000..f5dd4f631994 --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.html @@ -0,0 +1,18 @@ +
+ @if (isEnlargedCanvasLoading()) { +
+
+ +
+
+ } + + + @if (currentPage() !== 1) { + + } + @if (currentPage() !== totalPages()) { + + } +
{{ currentPage() }}
+
diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.scss b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.scss new file mode 100644 index 000000000000..333c12462b49 --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.scss @@ -0,0 +1,62 @@ +.enlarged-container { + position: absolute; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--pdf-preview-enlarged-container-overlay); + z-index: 5; + + .btn-close { + position: absolute; + top: 10px; + right: 10px; + cursor: pointer; + color: var(--bs-body-color); + } +} + +.nav-button { + position: absolute; + transform: translateY(-50%); + cursor: pointer; + border-radius: 50%; + width: 30px; + height: 30px; + display: flex; + justify-content: center; + align-items: center; + font-size: 20px; + z-index: 3; +} + +.nav-button.left { + left: calc(5% + 10px); + + @media (max-width: 1200px) { + left: 10px; + } +} + +.nav-button.right { + right: calc(5% + 10px); + + @media (max-width: 1200px) { + right: 10px; + } +} + +.page-number-display { + position: absolute; + bottom: 10px; + right: calc(5% + 10px); + font-size: 18px; + color: var(--bs-body-color); + z-index: 2; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + + @media (max-width: 1200px) { + right: 10px; + } +} diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts new file mode 100644 index 000000000000..789aba78aefb --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component.ts @@ -0,0 +1,225 @@ +import { Component, ElementRef, HostListener, effect, input, output, signal, viewChild } from '@angular/core'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; + +type NavigationDirection = 'next' | 'prev'; + +@Component({ + selector: 'jhi-pdf-preview-enlarged-canvas-component', + templateUrl: './pdf-preview-enlarged-canvas.component.html', + styleUrls: ['./pdf-preview-enlarged-canvas.component.scss'], + standalone: true, + imports: [ArtemisSharedModule], +}) +export class PdfPreviewEnlargedCanvasComponent { + enlargedContainer = viewChild.required>('enlargedContainer'); + enlargedCanvas = viewChild.required>('enlargedCanvas'); + + readonly DEFAULT_ENLARGED_SLIDE_HEIGHT = 800; + + // Inputs + pdfContainer = input.required(); + originalCanvas = input(); + totalPages = input(0); + + // Signals + currentPage = signal(1); + isEnlargedCanvasLoading = signal(false); + + //Outputs + isEnlargedViewOutput = output(); + + constructor() { + effect( + () => { + this.enlargedContainer().nativeElement.style.top = `${this.pdfContainer().scrollTop}px`; + this.displayEnlargedCanvas(this.originalCanvas()!); + }, + { allowSignalWrites: true }, + ); + } + + /** + * Handles navigation within the PDF viewer using keyboard arrow keys. + * @param event - The keyboard event captured for navigation. + */ + @HostListener('document:keydown', ['$event']) + handleKeyboardEvents(event: KeyboardEvent) { + if (event.key === 'ArrowRight' && this.currentPage() < this.totalPages()) { + this.navigatePages('next'); + } else if (event.key === 'ArrowLeft' && this.currentPage() > 1) { + this.navigatePages('prev'); + } + } + + /** + * Adjusts the canvas size based on the window resize event to ensure proper display. + */ + @HostListener('window:resize') + resizeCanvasBasedOnContainer() { + this.adjustCanvasSize(); + } + + /** + * Dynamically updates the canvas size within an enlarged view based on the viewport. + */ + adjustCanvasSize = () => { + const canvasElements = this.pdfContainer().querySelectorAll('.pdf-canvas-container canvas'); + if (this.currentPage() - 1 < canvasElements.length) { + const canvas = canvasElements[this.currentPage() - 1] as HTMLCanvasElement; + this.updateEnlargedCanvas(canvas); + } + }; + + displayEnlargedCanvas(originalCanvas: HTMLCanvasElement) { + this.isEnlargedCanvasLoading.set(true); + this.currentPage.set(Number(originalCanvas.id)); + this.toggleBodyScroll(true); + setTimeout(() => { + this.updateEnlargedCanvas(originalCanvas); + }, 500); + } + + /** + * Updates the enlarged canvas dimensions to optimize PDF page display within the current viewport. + * This method dynamically adjusts the size, position, and scale of the canvas to maintain the aspect ratio, + * ensuring the content is centered and displayed appropriately within the available space. + * It is called within an animation frame to synchronize updates with the browser's render cycle for smooth visuals. + * + * @param originalCanvas - The source canvas element used to extract image data for resizing and redrawing. + */ + updateEnlargedCanvas(originalCanvas: HTMLCanvasElement) { + requestAnimationFrame(() => { + const isVertical = originalCanvas.height > originalCanvas.width; + this.adjustPdfContainerSize(isVertical); + + const scaleFactor = this.calculateScaleFactor(originalCanvas); + this.resizeCanvas(originalCanvas, scaleFactor); + this.redrawCanvas(originalCanvas); + this.isEnlargedCanvasLoading.set(false); + }); + } + + /** + * Calculates the scaling factor to adjust the canvas size based on the dimensions of the container. + * This method ensures that the canvas is scaled to fit within the container without altering the aspect ratio. + * + * @param originalCanvas - The original canvas element representing the PDF page. + * @returns The scaling factor used to resize the original canvas to fit within the container dimensions. + */ + calculateScaleFactor(originalCanvas: HTMLCanvasElement): number { + const containerWidth = this.pdfContainer().clientWidth; + const containerHeight = this.pdfContainer().clientHeight; + + let scaleX, scaleY; + + if (originalCanvas.height > originalCanvas.width) { + // Vertical slide + const fixedHeight = this.DEFAULT_ENLARGED_SLIDE_HEIGHT; + scaleY = fixedHeight / originalCanvas.height; + scaleX = containerWidth / originalCanvas.width; + } else { + // Horizontal slide + scaleX = containerWidth / originalCanvas.width; + scaleY = containerHeight / originalCanvas.height; + } + + return Math.min(scaleX, scaleY); + } + + /** + * Resizes the canvas according to the computed scale factor. + * This method updates the dimensions of the enlarged canvas element to ensure that the entire PDF page + * is visible and properly scaled within the viewer. + * + * @param originalCanvas - The canvas element from which the image is scaled. + * @param scaleFactor - The factor by which the canvas is resized. + */ + resizeCanvas(originalCanvas: HTMLCanvasElement, scaleFactor: number): void { + const enlargedCanvas = this.enlargedCanvas().nativeElement; + enlargedCanvas.width = originalCanvas.width * scaleFactor; + enlargedCanvas.height = originalCanvas.height * scaleFactor; + } + + /** + * Redraws the original canvas content onto the enlarged canvas at the updated scale. + * This method ensures that the image is rendered clearly and correctly positioned on the enlarged canvas. + * + * @param originalCanvas - The original canvas containing the image to be redrawn. + */ + redrawCanvas(originalCanvas: HTMLCanvasElement): void { + const enlargedCanvas = this.enlargedCanvas().nativeElement; + const context = enlargedCanvas.getContext('2d'); + context!.clearRect(0, 0, enlargedCanvas.width, enlargedCanvas.height); + context!.drawImage(originalCanvas, 0, 0, enlargedCanvas.width, enlargedCanvas.height); + } + + /** + * Adjusts the size of the PDF container based on whether the enlarged view is active or not. + * If the enlarged view is active, the container's size is reduced to focus on the enlarged content. + * If the enlarged view is closed, the container returns to its original size. + * + * @param isVertical A boolean flag indicating whether to enlarge or reset the container size. + */ + adjustPdfContainerSize(isVertical: boolean): void { + const pdfContainer = this.pdfContainer(); + if (isVertical) { + pdfContainer.style.height = `${this.DEFAULT_ENLARGED_SLIDE_HEIGHT}px`; + } else { + pdfContainer.style.height = '60vh'; + } + } + + /** + * Toggles the ability to scroll through the PDF container. + * @param disable A boolean flag indicating whether scrolling should be disabled (`true`) or enabled (`false`). + */ + toggleBodyScroll(disable: boolean): void { + this.pdfContainer().style.overflow = disable ? 'hidden' : 'auto'; + } + + /** + * Closes the enlarged view of the PDF and re-enables scrolling in the PDF container. + */ + closeEnlargedView(event: MouseEvent) { + this.isEnlargedViewOutput.emit(false); + this.adjustPdfContainerSize(false); + this.toggleBodyScroll(false); + event.stopPropagation(); + } + + /** + * Closes the enlarged view if a click event occurs outside the actual canvas area but within the enlarged container. + * @param event The mouse event captured, used to determine the location of the click. + */ + closeIfOutside(event: MouseEvent): void { + const target = event.target as HTMLElement; + const enlargedCanvas = this.enlargedCanvas().nativeElement; + + if (target.classList.contains('enlarged-container') && target !== enlargedCanvas) { + this.closeEnlargedView(event); + } + } + + /** + * Handles navigation between PDF pages and stops event propagation to prevent unwanted side effects. + * @param direction The direction to navigate. + * @param event The MouseEvent to be stopped. + */ + handleNavigation(direction: NavigationDirection, event: MouseEvent): void { + event.stopPropagation(); + this.navigatePages(direction); + } + + /** + * Navigates to a specific page in the PDF based on the direction relative to the current page. + * @param direction The navigation direction (next or previous). + */ + navigatePages(direction: NavigationDirection) { + const nextPageIndex = direction === 'next' ? this.currentPage() + 1 : this.currentPage() - 1; + if (nextPageIndex > 0 && nextPageIndex <= this.totalPages()) { + this.currentPage.set(nextPageIndex); + const canvas = this.pdfContainer().querySelectorAll('.pdf-canvas-container canvas')[this.currentPage() - 1] as HTMLCanvasElement; + this.updateEnlargedCanvas(canvas); + } + } +} diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html new file mode 100644 index 000000000000..01ca1b9617a2 --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.html @@ -0,0 +1,11 @@ +
+ @if (isEnlargedView()) { + + } +
diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss new file mode 100644 index 000000000000..cbe9131ec39c --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.scss @@ -0,0 +1,26 @@ +.pdf-container { + position: relative; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 10px; + height: 60vh; + overflow-y: auto; + border: 1px solid var(--border-color); + padding: 10px; + margin: 10px 0; + width: 100%; + box-shadow: 0 2px 5px var(--pdf-preview-pdf-container-shadow); + z-index: 0; + + @media (max-width: 800px) { + grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); + } + + @media (max-width: 500px) { + grid-template-columns: 1fr; + } +} + +.enlarged-canvas { + display: contents; +} diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts new file mode 100644 index 000000000000..84a220ad8cba --- /dev/null +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component.ts @@ -0,0 +1,194 @@ +import { Component, ElementRef, effect, inject, input, output, signal, viewChild } from '@angular/core'; +import * as PDFJS from 'pdfjs-dist'; +import 'pdfjs-dist/build/pdf.worker'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { onError } from 'app/shared/util/global.utils'; +import { AlertService } from 'app/core/util/alert.service'; +import { PdfPreviewEnlargedCanvasComponent } from 'app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component'; + +@Component({ + selector: 'jhi-pdf-preview-thumbnail-grid-component', + templateUrl: './pdf-preview-thumbnail-grid.component.html', + styleUrls: ['./pdf-preview-thumbnail-grid.component.scss'], + standalone: true, + imports: [ArtemisSharedModule, PdfPreviewEnlargedCanvasComponent], +}) +export class PdfPreviewThumbnailGridComponent { + pdfContainer = viewChild.required>('pdfContainer'); + + readonly DEFAULT_SLIDE_WIDTH = 250; + + // Inputs + currentPdfUrl = input(); + appendFile = input(); + + // Signals + isEnlargedView = signal(false); + totalPages = signal(0); + selectedPages = signal>(new Set()); + originalCanvas = signal(undefined); + + // Outputs + isPdfLoading = output(); + totalPagesOutput = output(); + selectedPagesOutput = output>(); + + // Injected services + private readonly alertService = inject(AlertService); + + constructor() { + effect( + () => { + this.loadOrAppendPdf(this.currentPdfUrl()!, this.appendFile()); + }, + { allowSignalWrites: true }, + ); + } + + /** + * Loads or appends a PDF from a provided URL. + * @param fileUrl The URL of the file to load or append. + * @param append Whether the document should be appended to the existing one. + * @returns A promise that resolves when the PDF is loaded. + */ + async loadOrAppendPdf(fileUrl: string, append = false): Promise { + this.pdfContainer() + .nativeElement.querySelectorAll('.pdf-canvas-container') + .forEach((canvas) => canvas.remove()); + this.totalPages.set(0); + this.isPdfLoading.emit(true); + try { + const loadingTask = PDFJS.getDocument(fileUrl); + const pdf = await loadingTask.promise; + this.totalPages.set(pdf.numPages); + + for (let i = 1; i <= this.totalPages(); i++) { + const page = await pdf.getPage(i); + const viewport = page.getViewport({ scale: 2 }); + const canvas = this.createCanvas(viewport, i); + const context = canvas.getContext('2d'); + await page.render({ canvasContext: context!, viewport }).promise; + + const canvasContainer = this.createCanvasContainer(canvas, i); + this.pdfContainer().nativeElement.appendChild(canvasContainer); + } + + if (append) { + this.scrollToBottom(); + } + } catch (error) { + onError(this.alertService, error); + } finally { + this.totalPagesOutput.emit(this.totalPages()); + this.isPdfLoading.emit(false); + } + } + + /** + * Scrolls the PDF container to the bottom after appending new pages. + */ + scrollToBottom(): void { + const scrollOptions: ScrollToOptions = { + top: this.pdfContainer().nativeElement.scrollHeight, + left: 0, + behavior: 'smooth' as ScrollBehavior, + }; + this.pdfContainer().nativeElement.scrollTo(scrollOptions); + } + + /** + * Creates a canvas for each page of the PDF to allow for individual page rendering. + * @param viewport The viewport settings used for rendering the page. + * @param pageIndex The index of the page within the PDF document. + * @returns A new HTMLCanvasElement configured for the PDF page. + */ + createCanvas(viewport: PDFJS.PageViewport, pageIndex: number): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.id = `${pageIndex}`; + /* Canvas styling is predefined because Canvas tags do not support CSS classes + * as they are not HTML elements but rather a bitmap drawing surface. + * See: https://stackoverflow.com/a/29675448 + * */ + canvas.height = viewport.height; + canvas.width = viewport.width; + const fixedWidth = this.DEFAULT_SLIDE_WIDTH; + const scaleFactor = fixedWidth / viewport.width; + canvas.style.width = `${fixedWidth}px`; + canvas.style.height = `${viewport.height * scaleFactor}px`; + return canvas; + } + + /** + * Creates a container div for each canvas, facilitating layering and interaction. + * @param canvas The canvas element that displays a PDF page. + * @param pageIndex The index of the page within the PDF document. + * @returns A configured div element that includes the canvas and interactive overlays. + */ + createCanvasContainer(canvas: HTMLCanvasElement, pageIndex: number): HTMLDivElement { + const container = document.createElement('div'); + /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. + * See: https://stackoverflow.com/a/70911189 + */ + container.id = `pdf-page-${pageIndex}`; + container.classList.add('pdf-canvas-container'); + container.style.cssText = `position: relative; display: inline-block; width: ${canvas.style.width}; height: ${canvas.style.height}; margin: 20px; box-shadow: 0 2px 6px var(--pdf-preview-canvas-shadow);`; + + const overlay = this.createOverlay(pageIndex); + const checkbox = this.createCheckbox(pageIndex); + container.appendChild(canvas); + container.appendChild(overlay); + container.appendChild(checkbox); + + container.addEventListener('mouseenter', () => { + overlay.style.opacity = '1'; + }); + container.addEventListener('mouseleave', () => { + overlay.style.opacity = '0'; + }); + overlay.addEventListener('click', () => this.displayEnlargedCanvas(canvas)); + + return container; + } + + /** + * Generates an interactive overlay for each PDF page to allow for user interactions. + * @param pageIndex The index of the page. + * @returns A div element styled as an overlay. + */ + private createOverlay(pageIndex: number): HTMLDivElement { + const overlay = document.createElement('div'); + overlay.innerHTML = `${pageIndex}`; + /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. + * See: https://stackoverflow.com/a/70911189 + */ + overlay.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; font-size: 24px; color: white; z-index: 1; transition: opacity 0.3s ease; opacity: 0; cursor: pointer; background-color: var(--pdf-preview-container-overlay)`; + return overlay; + } + + private createCheckbox(pageIndex: number): HTMLDivElement { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = String(pageIndex); + checkbox.style.cssText = `position: absolute; top: -5px; right: -5px; z-index: 4;`; + checkbox.checked = this.selectedPages().has(pageIndex); + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + this.selectedPages().add(Number(checkbox.id)); + this.selectedPagesOutput.emit(this.selectedPages()); + } else { + this.selectedPages().delete(Number(checkbox.id)); + this.selectedPagesOutput.emit(this.selectedPages()); + } + }); + return checkbox; + } + + /** + * Displays the selected PDF page in an enlarged view for detailed examination. + * @param originalCanvas - The original canvas element of the PDF page to be enlarged. + * */ + displayEnlargedCanvas(originalCanvas: HTMLCanvasElement) { + this.originalCanvas.set(originalCanvas); + this.isEnlargedView.set(true); + } +} diff --git a/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html b/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html index 8883da0aa551..ace60a5014e2 100644 --- a/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html +++ b/src/main/webapp/app/lecture/pdf-preview/pdf-preview.component.html @@ -11,7 +11,7 @@

}

@if (isPdfLoading()) { -
+
} @@ -32,28 +32,24 @@

- -

-
- @if (isEnlargedView()) { -
- - - @if (currentPage() !== 1) { - - } - @if (currentPage() !== totalPages()) { - - } -
{{ currentPage() }}
-
- } -
+ @if (currentPdfUrl()) { + + } @else { +
+ }
>('pdfContainer'); - enlargedCanvas = viewChild.required>('enlargedCanvas'); fileInput = viewChild.required>('fileInput'); attachmentSub: Subscription; attachmentUnitSub: Subscription; - readonly DEFAULT_SLIDE_WIDTH = 250; - readonly DEFAULT_SLIDE_HEIGHT = 800; + // Signals course = signal(undefined); attachment = signal(undefined); attachmentUnit = signal(undefined); - isEnlargedView = signal(false); - isFileChanged = signal(false); - currentPage = signal(1); - totalPages = signal(0); - selectedPages = signal>(new Set()); isPdfLoading = signal(false); attachmentToBeEdited = signal(undefined); - currentPdfBlob = signal(null); + currentPdfBlob = signal(undefined); + currentPdfUrl = signal(undefined); + totalPages = signal(0); + appendFile = signal(false); + isFileChanged = signal(false); + selectedPages = signal>(new Set()); + allPagesSelected = computed(() => this.selectedPages().size === this.totalPages()); // Injected services private readonly route = inject(ActivatedRoute); @@ -74,386 +69,78 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { if ('attachment' in data) { this.attachment.set(data.attachment); this.attachmentSub = this.attachmentService.getAttachmentFile(this.course()!.id!, this.attachment()!.id!).subscribe({ - next: (blob: Blob) => this.handleBlob(blob), + next: (blob: Blob) => { + this.currentPdfBlob.set(blob); + this.currentPdfUrl.set(URL.createObjectURL(blob)); + }, error: (error: HttpErrorResponse) => onError(this.alertService, error), }); } else if ('attachmentUnit' in data) { this.attachmentUnit.set(data.attachmentUnit); this.attachmentUnitSub = this.attachmentUnitService.getAttachmentFile(this.course()!.id!, this.attachmentUnit()!.id!).subscribe({ - next: (blob: Blob) => this.handleBlob(blob), + next: (blob: Blob) => { + this.currentPdfBlob.set(blob); + this.currentPdfUrl.set(URL.createObjectURL(blob)); + }, error: (error: HttpErrorResponse) => onError(this.alertService, error), }); } }); } - handleBlob(blob: Blob): void { - this.currentPdfBlob.set(blob); - const objectUrl = URL.createObjectURL(blob); - this.loadOrAppendPdf(objectUrl).then(() => URL.revokeObjectURL(objectUrl)); - } - ngOnDestroy() { this.attachmentSub?.unsubscribe(); this.attachmentUnitSub?.unsubscribe(); } /** - * Checks if all pages are selected. - * @returns True if the number of selected pages equals the total number of pages, otherwise false. - */ - allPagesSelected() { - return this.selectedPages().size === this.totalPages(); - } - - /** - * Handles navigation within the PDF viewer using keyboard arrow keys. - * @param event - The keyboard event captured for navigation. - */ - @HostListener('document:keydown', ['$event']) - handleKeyboardEvents(event: KeyboardEvent) { - if (this.isEnlargedView()) { - if (event.key === 'ArrowRight' && this.currentPage() < this.totalPages()) { - this.navigatePages('next'); - } else if (event.key === 'ArrowLeft' && this.currentPage() > 1) { - this.navigatePages('prev'); - } - } - } - - /** - * Adjusts the canvas size based on the window resize event to ensure proper display. - */ - @HostListener('window:resize') - resizeCanvasBasedOnContainer() { - this.adjustCanvasSize(); - } - - /** - * Loads or appends a PDF from a provided URL. - * @param fileUrl The URL of the file to load or append. - * @param append Whether the document should be appended to the existing one. - * @returns A promise that resolves when the PDF is loaded. - */ - async loadOrAppendPdf(fileUrl: string, append = false): Promise { - this.pdfContainer() - .nativeElement.querySelectorAll('.pdf-canvas-container') - .forEach((canvas) => canvas.remove()); - this.totalPages.set(0); - this.isPdfLoading.set(true); - try { - const loadingTask = PDFJS.getDocument(fileUrl); - const pdf = await loadingTask.promise; - this.totalPages.set(pdf.numPages); - - for (let i = 1; i <= this.totalPages(); i++) { - const page = await pdf.getPage(i); - const viewport = page.getViewport({ scale: 2 }); - const canvas = this.createCanvas(viewport, i); - const context = canvas.getContext('2d'); - await page.render({ canvasContext: context!, viewport }).promise; - - const canvasContainer = this.createCanvasContainer(canvas, i); - this.pdfContainer().nativeElement.appendChild(canvasContainer); - } - - if (append) { - this.scrollToBottom(); - } - } catch (error) { - onError(this.alertService, error); - } finally { - this.isPdfLoading.set(false); - if (append) { - this.fileInput().nativeElement.value = ''; - } - } - } - - /** - * Scrolls the PDF container to the bottom after appending new pages. - */ - scrollToBottom(): void { - const scrollOptions: ScrollToOptions = { - top: this.pdfContainer().nativeElement.scrollHeight, - left: 0, - behavior: 'smooth' as ScrollBehavior, - }; - this.pdfContainer().nativeElement.scrollTo(scrollOptions); - } - - /** - * Creates a canvas for each page of the PDF to allow for individual page rendering. - * @param viewport The viewport settings used for rendering the page. - * @param pageIndex The index of the page within the PDF document. - * @returns A new HTMLCanvasElement configured for the PDF page. - */ - createCanvas(viewport: PDFJS.PageViewport, pageIndex: number): HTMLCanvasElement { - const canvas = document.createElement('canvas'); - canvas.id = `${pageIndex}`; - /* Canvas styling is predefined because Canvas tags do not support CSS classes - * as they are not HTML elements but rather a bitmap drawing surface. - * See: https://stackoverflow.com/a/29675448 - * */ - canvas.height = viewport.height; - canvas.width = viewport.width; - const fixedWidth = this.DEFAULT_SLIDE_WIDTH; - const scaleFactor = fixedWidth / viewport.width; - canvas.style.width = `${fixedWidth}px`; - canvas.style.height = `${viewport.height * scaleFactor}px`; - return canvas; - } - - /** - * Creates a container div for each canvas, facilitating layering and interaction. - * @param canvas The canvas element that displays a PDF page. - * @param pageIndex The index of the page within the PDF document. - * @returns A configured div element that includes the canvas and interactive overlays. - */ - createCanvasContainer(canvas: HTMLCanvasElement, pageIndex: number): HTMLDivElement { - const container = document.createElement('div'); - /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. - * See: https://stackoverflow.com/a/70911189 - */ - container.id = `pdf-page-${pageIndex}`; - container.classList.add('pdf-canvas-container'); - container.style.cssText = `position: relative; display: inline-block; width: ${canvas.style.width}; height: ${canvas.style.height}; margin: 20px; box-shadow: 0 2px 6px var(--pdf-preview-canvas-shadow);`; - - const overlay = this.createOverlay(pageIndex); - const checkbox = this.createCheckbox(pageIndex); - container.appendChild(canvas); - container.appendChild(overlay); - container.appendChild(checkbox); - - container.addEventListener('mouseenter', () => { - overlay.style.opacity = '1'; - }); - container.addEventListener('mouseleave', () => { - overlay.style.opacity = '0'; - }); - overlay.addEventListener('click', () => this.displayEnlargedCanvas(canvas)); - - return container; - } - - /** - * Generates an interactive overlay for each PDF page to allow for user interactions. - * @param pageIndex The index of the page. - * @returns A div element styled as an overlay. - */ - private createOverlay(pageIndex: number): HTMLDivElement { - const overlay = document.createElement('div'); - overlay.innerHTML = `${pageIndex}`; - /* Dynamically created elements are not detected by DOM, that is why we need to set the styles manually. - * See: https://stackoverflow.com/a/70911189 - */ - overlay.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; font-size: 24px; color: white; z-index: 1; transition: opacity 0.3s ease; opacity: 0; cursor: pointer; background-color: var(--pdf-preview-container-overlay)`; - return overlay; - } - - private createCheckbox(pageIndex: number): HTMLDivElement { - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.id = String(pageIndex); - checkbox.style.cssText = `position: absolute; top: -5px; right: -5px; z-index: 4;`; - checkbox.checked = this.selectedPages().has(pageIndex); - checkbox.addEventListener('change', () => { - if (checkbox.checked) { - this.selectedPages().add(Number(checkbox.id)); - } else { - this.selectedPages().delete(Number(checkbox.id)); - } - }); - return checkbox; - } - - /** - * Dynamically updates the canvas size within an enlarged view based on the viewport. - */ - adjustCanvasSize = () => { - if (this.isEnlargedView()) { - const canvasElements = this.pdfContainer().nativeElement.querySelectorAll('.pdf-canvas-container canvas'); - if (this.currentPage() - 1 < canvasElements.length) { - const canvas = canvasElements[this.currentPage() - 1] as HTMLCanvasElement; - this.updateEnlargedCanvas(canvas); - } - } - }; - - /** - * Adjusts the size of the PDF container based on whether the enlarged view is active or not. - * If the enlarged view is active, the container's size is reduced to focus on the enlarged content. - * If the enlarged view is closed, the container returns to its original size. - * - * @param isVertical A boolean flag indicating whether to enlarge or reset the container size. - */ - adjustPdfContainerSize(isVertical: boolean): void { - const pdfContainer = this.pdfContainer().nativeElement; - if (isVertical) { - pdfContainer.style.height = `${this.DEFAULT_SLIDE_HEIGHT}px`; - } else { - pdfContainer.style.height = '60vh'; - } - } - - /** - * Displays the selected PDF page in an enlarged view for detailed examination. - * @param originalCanvas - The original canvas element of the PDF page to be enlarged. - * */ - displayEnlargedCanvas(originalCanvas: HTMLCanvasElement) { - const isVertical = originalCanvas.height > originalCanvas.width; - this.adjustPdfContainerSize(isVertical); - this.isEnlargedView.set(true); - this.currentPage.set(Number(originalCanvas.id)); - this.toggleBodyScroll(true); - setTimeout(() => { - this.updateEnlargedCanvas(originalCanvas); - }, 50); - } - - /** - * Updates the enlarged canvas dimensions to optimize PDF page display within the current viewport. - * This method dynamically adjusts the size, position, and scale of the canvas to maintain the aspect ratio, - * ensuring the content is centered and displayed appropriately within the available space. - * It is called within an animation frame to synchronize updates with the browser's render cycle for smooth visuals. - * - * @param originalCanvas - The source canvas element used to extract image data for resizing and redrawing. + * Triggers the file input to select files. */ - updateEnlargedCanvas(originalCanvas: HTMLCanvasElement) { - requestAnimationFrame(() => { - if (!this.isEnlargedView) return; - - const isVertical = originalCanvas.height > originalCanvas.width; - this.adjustPdfContainerSize(isVertical); - - const scaleFactor = this.calculateScaleFactor(originalCanvas); - this.resizeCanvas(originalCanvas, scaleFactor); - this.redrawCanvas(originalCanvas); - this.positionCanvas(); - }); + triggerFileInput(): void { + this.fileInput().nativeElement.click(); } - /** - * Calculates the scaling factor to adjust the canvas size based on the dimensions of the container. - * This method ensures that the canvas is scaled to fit within the container without altering the aspect ratio. - * - * @param originalCanvas - The original canvas element representing the PDF page. - * @returns The scaling factor used to resize the original canvas to fit within the container dimensions. - */ - calculateScaleFactor(originalCanvas: HTMLCanvasElement): number { - const containerWidth = this.pdfContainer().nativeElement.clientWidth; - const containerHeight = this.pdfContainer().nativeElement.clientHeight; - - let scaleX, scaleY; + updateAttachmentWithFile(): void { + const pdfFile = new File([this.currentPdfBlob()!], 'updatedAttachment.pdf', { type: 'application/pdf' }); - if (originalCanvas.height > originalCanvas.width) { - // Vertical slide - const fixedHeight = this.DEFAULT_SLIDE_HEIGHT; - scaleY = fixedHeight / originalCanvas.height; - scaleX = containerWidth / originalCanvas.width; - } else { - // Horizontal slide - scaleX = containerWidth / originalCanvas.width; - scaleY = containerHeight / originalCanvas.height; + if (pdfFile.size > MAX_FILE_SIZE) { + this.alertService.error('artemisApp.attachment.pdfPreview.fileSizeError'); + return; } - return Math.min(scaleX, scaleY); - } - - /** - * Resizes the canvas according to the computed scale factor. - * This method updates the dimensions of the enlarged canvas element to ensure that the entire PDF page - * is visible and properly scaled within the viewer. - * - * @param originalCanvas - The canvas element from which the image is scaled. - * @param scaleFactor - The factor by which the canvas is resized. - */ - resizeCanvas(originalCanvas: HTMLCanvasElement, scaleFactor: number): void { - const enlargedCanvas = this.enlargedCanvas().nativeElement; - enlargedCanvas.width = originalCanvas.width * scaleFactor; - enlargedCanvas.height = originalCanvas.height * scaleFactor; - } - - /** - * Redraws the original canvas content onto the enlarged canvas at the updated scale. - * This method ensures that the image is rendered clearly and correctly positioned on the enlarged canvas. - * - * @param originalCanvas - The original canvas containing the image to be redrawn. - */ - redrawCanvas(originalCanvas: HTMLCanvasElement): void { - const enlargedCanvas = this.enlargedCanvas().nativeElement; - const context = enlargedCanvas.getContext('2d'); - context!.clearRect(0, 0, enlargedCanvas.width, enlargedCanvas.height); - context!.drawImage(originalCanvas, 0, 0, enlargedCanvas.width, enlargedCanvas.height); - } - - /** - * Adjusts the position of the enlarged canvas to center it within the viewport of the PDF container. - * This method ensures that the canvas is both vertically and horizontally centered, providing a consistent - * and visually appealing layout. - */ - positionCanvas(): void { - const enlargedCanvas = this.enlargedCanvas().nativeElement; - const containerWidth = this.pdfContainer().nativeElement.clientWidth; - const containerHeight = this.pdfContainer().nativeElement.clientHeight; - - enlargedCanvas.style.position = 'absolute'; - enlargedCanvas.style.left = `${(containerWidth - enlargedCanvas.width) / 2}px`; - enlargedCanvas.style.top = `${(containerHeight - enlargedCanvas.height) / 2}px`; - enlargedCanvas.parentElement!.style.top = `${this.pdfContainer().nativeElement.scrollTop}px`; - } - - /** - * Closes the enlarged view of the PDF and re-enables scrolling in the PDF container. - */ - closeEnlargedView(event: MouseEvent) { - this.isEnlargedView.set(false); - this.adjustPdfContainerSize(false); - this.toggleBodyScroll(false); - event.stopPropagation(); - } - - /** - * Toggles the ability to scroll through the PDF container. - * @param disable A boolean flag indicating whether scrolling should be disabled (`true`) or enabled (`false`). - */ - toggleBodyScroll(disable: boolean): void { - this.pdfContainer().nativeElement.style.overflow = disable ? 'hidden' : 'auto'; - } - - /** - * Closes the enlarged view if a click event occurs outside the actual canvas area but within the enlarged container. - * @param event The mouse event captured, used to determine the location of the click. - */ - closeIfOutside(event: MouseEvent): void { - const target = event.target as HTMLElement; - const enlargedCanvas = this.enlargedCanvas().nativeElement; + if (this.attachment()) { + this.attachmentToBeEdited.set(this.attachment()); + this.attachmentToBeEdited()!.version!++; + this.attachmentToBeEdited()!.uploadDate = dayjs(); - if (target.classList.contains('enlarged-container') && target !== enlargedCanvas) { - this.closeEnlargedView(event); - } - } + this.attachmentService.update(this.attachmentToBeEdited()!.id!, this.attachmentToBeEdited()!, pdfFile).subscribe({ + next: () => { + this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachment()!.lecture!.id, 'attachments']); + }, + error: (error) => { + this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); + }, + }); + } else if (this.attachmentUnit()) { + this.attachmentToBeEdited.set(this.attachmentUnit()!.attachment!); + this.attachmentToBeEdited()!.version!++; + this.attachmentToBeEdited()!.uploadDate = dayjs(); - /** - * Handles navigation between PDF pages and stops event propagation to prevent unwanted side effects. - * @param direction The direction to navigate. - * @param event The MouseEvent to be stopped. - */ - handleNavigation(direction: NavigationDirection, event: MouseEvent): void { - event.stopPropagation(); - this.navigatePages(direction); - } + const formData = new FormData(); + formData.append('file', pdfFile); + formData.append('attachment', objectToJsonBlob(this.attachmentToBeEdited()!)); + formData.append('attachmentUnit', objectToJsonBlob(this.attachmentUnit()!)); - /** - * Navigates to a specific page in the PDF based on the direction relative to the current page. - * @param direction The navigation direction (next or previous). - */ - navigatePages(direction: NavigationDirection) { - const nextPageIndex = direction === 'next' ? this.currentPage() + 1 : this.currentPage() - 1; - if (nextPageIndex > 0 && nextPageIndex <= this.totalPages()) { - this.currentPage.set(nextPageIndex); - const canvas = this.pdfContainer().nativeElement.querySelectorAll('.pdf-canvas-container canvas')[this.currentPage() - 1] as HTMLCanvasElement; - this.updateEnlargedCanvas(canvas); + this.attachmentUnitService.update(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!, formData).subscribe({ + next: () => { + this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachmentUnit()!.lecture!.id, 'unit-management']); + }, + error: (error) => { + this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); + }, + }); } } @@ -494,7 +181,7 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { const existingPdfBytes = await this.currentPdfBlob()!.arrayBuffer(); const pdfDoc = await PDFDocument.load(existingPdfBytes); - const pagesToDelete = Array.from(this.selectedPages()) + const pagesToDelete = Array.from(this.selectedPages()!) .map((page) => page - 1) .sort((a, b) => b - a); pagesToDelete.forEach((pageIndex) => { @@ -504,13 +191,12 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { this.isFileChanged.set(true); const pdfBytes = await pdfDoc.save(); this.currentPdfBlob.set(new Blob([pdfBytes], { type: 'application/pdf' })); - this.selectedPages().clear(); + this.selectedPages()!.clear(); const objectUrl = URL.createObjectURL(this.currentPdfBlob()!); - await this.loadOrAppendPdf(objectUrl, false).then(() => { - this.dialogErrorSource.next(''); - }); - URL.revokeObjectURL(objectUrl); + this.currentPdfUrl.set(objectUrl); + this.appendFile.set(false); + this.dialogErrorSource.next(''); } catch (error) { this.alertService.error('artemisApp.attachment.pdfPreview.pageDeleteError', { error: error.message }); } finally { @@ -518,13 +204,6 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { } } - /** - * Triggers the file input to select files. - */ - triggerFileInput(): void { - this.fileInput().nativeElement.click(); - } - /** * Adds a selected PDF file at the end of the current PDF document. * @param event - The event containing the file input. @@ -546,75 +225,16 @@ export class PdfPreviewComponent implements OnInit, OnDestroy { const mergedPdfBytes = await existingPdfDoc.save(); this.currentPdfBlob.set(new Blob([mergedPdfBytes], { type: 'application/pdf' })); - this.selectedPages().clear(); + this.selectedPages()!.clear(); const objectUrl = URL.createObjectURL(this.currentPdfBlob()!); - await this.loadOrAppendPdf(objectUrl, true).then(() => URL.revokeObjectURL(objectUrl)); + this.currentPdfUrl.set(objectUrl); + this.appendFile.set(true); } catch (error) { this.alertService.error('artemisApp.attachment.pdfPreview.mergeFailedError', { error: error.message }); } finally { this.isPdfLoading.set(false); - } - } - - /** - * Updates the IDs of remaining pages after some have been removed. - */ - updatePageIDs() { - const remainingPages = this.pdfContainer().nativeElement.querySelectorAll('.pdf-canvas-container'); - remainingPages.forEach((container, index) => { - const pageIndex = index + 1; - container.id = `pdf-page-${pageIndex}`; - const canvas = container.querySelector('canvas'); - const overlay = container.querySelector('div'); - const checkbox = container.querySelector('input[type="checkbox"]'); - canvas!.id = String(pageIndex); - overlay!.innerHTML = `${pageIndex}`; - checkbox!.id = String(pageIndex); - }); - } - - updateAttachmentWithFile(): void { - const pdfFile = new File([this.currentPdfBlob()!], 'updatedAttachment.pdf', { type: 'application/pdf' }); - - if (pdfFile.size > MAX_FILE_SIZE) { - this.alertService.error('artemisApp.attachment.pdfPreview.fileSizeError'); - return; - } - - if (this.attachment()) { - this.attachmentToBeEdited.set(this.attachment()); - this.attachmentToBeEdited()!.version!++; - this.attachmentToBeEdited()!.uploadDate = dayjs(); - - this.attachmentService.update(this.attachmentToBeEdited()!.id!, this.attachmentToBeEdited()!, pdfFile).subscribe({ - next: () => { - this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachment()!.lecture!.id, 'attachments']); - }, - error: (error) => { - this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); - }, - }); - } else if (this.attachmentUnit()) { - this.attachmentToBeEdited.set(this.attachmentUnit()!.attachment!); - this.attachmentToBeEdited()!.version!++; - this.attachmentToBeEdited()!.uploadDate = dayjs(); - - const formData = new FormData(); - formData.append('file', pdfFile); - formData.append('attachment', objectToJsonBlob(this.attachmentToBeEdited()!)); - formData.append('attachmentUnit', objectToJsonBlob(this.attachmentUnit()!)); - - this.attachmentUnitService.update(this.attachmentUnit()!.lecture!.id!, this.attachmentUnit()!.id!, formData).subscribe({ - next: () => { - this.alertService.success('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - this.router.navigate(['course-management', this.course()?.id, 'lectures', this.attachmentUnit()!.lecture!.id, 'unit-management']); - }, - error: (error) => { - this.alertService.error('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: error.message }); - }, - }); + this.fileInput()!.nativeElement.value = ''; } } } diff --git a/src/test/javascript/spec/component/lecture/pdf-preview.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview.component.spec.ts deleted file mode 100644 index e3b8a248fb9f..000000000000 --- a/src/test/javascript/spec/component/lecture/pdf-preview.component.spec.ts +++ /dev/null @@ -1,713 +0,0 @@ -import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; -import { MAX_FILE_SIZE } from 'app/shared/constants/input.constants'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { of, throwError } from 'rxjs'; -import { AttachmentService } from 'app/lecture/attachment.service'; -import { AttachmentUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service'; -import { PdfPreviewComponent } from 'app/lecture/pdf-preview/pdf-preview.component'; -import { signal } from '@angular/core'; -import { AlertService } from 'app/core/util/alert.service'; -import { HttpClientModule, HttpErrorResponse } from '@angular/common/http'; -import { TranslateService } from '@ngx-translate/core'; -import { PDFDocument } from 'pdf-lib'; - -jest.mock('pdf-lib', () => { - const originalModule = jest.requireActual('pdf-lib'); - - return { - ...originalModule, - PDFDocument: { - ...originalModule.PDFDocument, - load: jest.fn(), - create: jest.fn(), - prototype: { - removePage: jest.fn(), - save: jest.fn(), - }, - }, - }; -}); - -jest.mock('pdfjs-dist', () => { - return { - getDocument: jest.fn(() => ({ - promise: Promise.resolve({ - numPages: 1, - getPage: jest.fn(() => - Promise.resolve({ - getViewport: jest.fn(() => ({ width: 600, height: 800, scale: 1 })), - render: jest.fn(() => ({ - promise: Promise.resolve(), - })), - }), - ), - }), - })), - }; -}); - -jest.mock('pdfjs-dist/build/pdf.worker', () => { - return {}; -}); - -function createMockEvent(target: Element, eventType = 'click'): MouseEvent { - const event = new MouseEvent(eventType, { - view: window, - bubbles: true, - cancelable: true, - }); - Object.defineProperty(event, 'target', { value: target, writable: false }); - return event; -} - -describe('PdfPreviewComponent', () => { - let component: PdfPreviewComponent; - let fixture: ComponentFixture; - let attachmentServiceMock: any; - let attachmentUnitServiceMock: any; - let alertServiceMock: any; - let routeMock: any; - let mockCanvasElement: HTMLCanvasElement; - let mockEnlargedCanvas: HTMLCanvasElement; - let mockContext: any; - let mockOverlay: HTMLDivElement; - - beforeEach(async () => { - global.URL.createObjectURL = jest.fn().mockReturnValue('mocked_blob_url'); - attachmentServiceMock = { - getAttachmentFile: jest.fn().mockReturnValue(of(new Blob([''], { type: 'application/pdf' }))), - update: jest.fn().mockReturnValue(of({})), - }; - attachmentUnitServiceMock = { - getAttachmentFile: jest.fn().mockReturnValue(of(new Blob([''], { type: 'application/pdf' }))), - update: jest.fn().mockReturnValue(of({})), - }; - routeMock = { - data: of({ - course: { id: 1, name: 'Example Course' }, - attachment: { id: 1, name: 'Example PDF', lecture: { id: 1 } }, - attachmentUnit: { id: 1, name: 'Chapter 1', lecture: { id: 1 } }, - }), - }; - alertServiceMock = { - addAlert: jest.fn(), - error: jest.fn(), - success: jest.fn(), - }; - - await TestBed.configureTestingModule({ - imports: [PdfPreviewComponent, HttpClientModule], - providers: [ - { provide: ActivatedRoute, useValue: routeMock }, - { provide: AttachmentService, useValue: attachmentServiceMock }, - { provide: AttachmentUnitService, useValue: attachmentUnitServiceMock }, - { provide: AlertService, useValue: alertServiceMock }, - { provide: TranslateService, useClass: MockTranslateService }, - ], - }).compileComponents(); - - const pdfContainerElement = document.createElement('div'); - Object.defineProperty(pdfContainerElement, 'clientWidth', { value: 800 }); - Object.defineProperty(pdfContainerElement, 'clientHeight', { value: 600 }); - - fixture = TestBed.createComponent(PdfPreviewComponent); - component = fixture.componentInstance; - - mockCanvasElement = document.createElement('canvas'); - mockCanvasElement.width = 800; - mockCanvasElement.height = 600; - - jest.spyOn(component, 'updateEnlargedCanvas').mockImplementation(() => { - component.enlargedCanvas()!.nativeElement = mockCanvasElement; - }); - - mockEnlargedCanvas = document.createElement('canvas'); - mockEnlargedCanvas.classList.add('enlarged-canvas'); - component.enlargedCanvas = signal({ nativeElement: mockEnlargedCanvas }); - - mockContext = { - clearRect: jest.fn(), - drawImage: jest.fn(), - } as unknown as CanvasRenderingContext2D; - jest.spyOn(mockCanvasElement, 'getContext').mockReturnValue(mockContext); - - jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { - cb(0); - return 0; - }); - mockOverlay = document.createElement('div'); - mockOverlay.style.opacity = '0'; - mockCanvasElement.appendChild(mockOverlay); - component.currentPdfBlob.set(new Blob(['dummy content'], { type: 'application/pdf' })); - - global.URL.createObjectURL = jest.fn().mockReturnValue('blob-url'); - fixture.detectChanges(); - - component.pdfContainer = signal({ nativeElement: document.createElement('div') }); - component.enlargedCanvas = signal({ nativeElement: document.createElement('canvas') }); - fixture.detectChanges(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should load attachment file and verify service calls when attachment data is available', () => { - component.ngOnInit(); - expect(attachmentServiceMock.getAttachmentFile).toHaveBeenCalledWith(1, 1); - expect(attachmentUnitServiceMock.getAttachmentFile).not.toHaveBeenCalled(); - }); - - it('should load attachment unit file and verify service calls when attachment unit data is available', () => { - routeMock.data = of({ - course: { id: 1, name: 'Example Course' }, - attachmentUnit: { id: 1, name: 'Chapter 1' }, - }); - component.ngOnInit(); - expect(attachmentUnitServiceMock.getAttachmentFile).toHaveBeenCalledWith(1, 1); - expect(attachmentServiceMock.getAttachmentFile).toHaveBeenCalled(); - }); - - it('should handle errors and trigger alert when loading an attachment file fails', () => { - const errorResponse = new HttpErrorResponse({ - status: 404, - statusText: 'Not Found', - error: 'File not found', - }); - - const attachmentService = TestBed.inject(AttachmentService); - jest.spyOn(attachmentService, 'getAttachmentFile').mockReturnValue(throwError(() => errorResponse)); - const alertServiceSpy = jest.spyOn(alertServiceMock, 'error'); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(alertServiceSpy).toHaveBeenCalled(); - }); - - it('should handle errors and trigger alert when loading an attachment unit file fails', () => { - routeMock.data = of({ - course: { id: 1, name: 'Example Course' }, - attachmentUnit: { id: 1, name: 'Chapter 1' }, - }); - const errorResponse = new HttpErrorResponse({ - status: 404, - statusText: 'Not Found', - error: 'File not found', - }); - - const attachmentUnitService = TestBed.inject(AttachmentUnitService); - jest.spyOn(attachmentUnitService, 'getAttachmentFile').mockReturnValue(throwError(() => errorResponse)); - const alertServiceSpy = jest.spyOn(alertServiceMock, 'error'); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(alertServiceSpy).toHaveBeenCalled(); - }); - - it('should load PDF and verify rendering of pages', async () => { - const spyCreateCanvas = jest.spyOn(component, 'createCanvas'); - const spyCreateCanvasContainer = jest.spyOn(component, 'createCanvasContainer'); - const spyAppendChild = jest.spyOn(component.pdfContainer()!.nativeElement, 'appendChild'); - - await component.loadOrAppendPdf('fake-url'); - - expect(spyCreateCanvas).toHaveBeenCalled(); - expect(spyCreateCanvasContainer).toHaveBeenCalled(); - expect(spyAppendChild).toHaveBeenCalled(); - expect(component.totalPages()).toBe(1); - expect(component.isPdfLoading()).toBeFalsy(); - expect(component.fileInput()!.nativeElement.value).toBe(''); - }); - - it('should navigate through pages using keyboard in enlarged view', () => { - component.isEnlargedView.set(true); - component.totalPages.set(5); - component.currentPage.set(3); - - const eventRight = new KeyboardEvent('keydown', { key: 'ArrowRight' }); - const eventLeft = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); - - component.handleKeyboardEvents(eventRight); - expect(component.currentPage()).toBe(4); - - component.handleKeyboardEvents(eventLeft); - expect(component.currentPage()).toBe(3); - }); - - it('should toggle enlarged view state', () => { - const mockCanvas = document.createElement('canvas'); - component.displayEnlargedCanvas(mockCanvas); - expect(component.isEnlargedView()).toBeTruthy(); - - const clickEvent = new MouseEvent('click', { - button: 0, - }); - - component.closeEnlargedView(clickEvent); - expect(component.isEnlargedView()).toBeFalsy(); - }); - - it('should prevent scrolling when enlarged view is active', () => { - component.toggleBodyScroll(true); - expect(component.pdfContainer()!.nativeElement.style.overflow).toBe('hidden'); - - component.toggleBodyScroll(false); - expect(component.pdfContainer()!.nativeElement.style.overflow).toBe('auto'); - }); - - it('should not update canvas size if not in enlarged view', () => { - component.isEnlargedView.set(false); - component.currentPage.set(3); - - const spy = jest.spyOn(component, 'updateEnlargedCanvas'); - component.adjustCanvasSize(); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('should not update canvas size if the current page canvas does not exist', () => { - component.isEnlargedView.set(true); - component.currentPage.set(10); - - const spy = jest.spyOn(component, 'updateEnlargedCanvas'); - component.adjustCanvasSize(); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('should prevent navigation beyond last page', () => { - component.currentPage.set(5); - component.totalPages.set(5); - component.handleKeyboardEvents(new KeyboardEvent('keydown', { key: 'ArrowRight' })); - - expect(component.currentPage()).toBe(5); - }); - - it('should prevent navigation before first page', () => { - component.currentPage.set(1); - component.handleKeyboardEvents(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); - - expect(component.currentPage()).toBe(1); - }); - - it('should unsubscribe attachment subscription during component destruction', () => { - const spySub = jest.spyOn(component.attachmentSub, 'unsubscribe'); - component.ngOnDestroy(); - expect(spySub).toHaveBeenCalled(); - }); - - it('should unsubscribe attachmentUnit subscription during component destruction', () => { - routeMock.data = of({ - course: { id: 1, name: 'Example Course' }, - attachmentUnit: { id: 1, name: 'Chapter 1' }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.attachmentUnitSub).toBeDefined(); - const spySub = jest.spyOn(component.attachmentUnitSub, 'unsubscribe'); - component.ngOnDestroy(); - expect(spySub).toHaveBeenCalled(); - }); - - it('should stop event propagation and navigate pages', () => { - const navigateSpy = jest.spyOn(component, 'navigatePages'); - const eventMock = { stopPropagation: jest.fn() } as unknown as MouseEvent; - - component.handleNavigation('next', eventMock); - - expect(eventMock.stopPropagation).toHaveBeenCalled(); - expect(navigateSpy).toHaveBeenCalledWith('next'); - }); - - it('should call updateEnlargedCanvas when window is resized and conditions are met', () => { - component.isEnlargedView.set(true); - component.currentPage.set(1); - - const canvas = document.createElement('canvas'); - const pdfContainer = document.createElement('div'); - pdfContainer.classList.add('pdf-canvas-container'); - pdfContainer.appendChild(canvas); - component.pdfContainer = signal({ nativeElement: pdfContainer }); - - const updateEnlargedCanvasSpy = jest.spyOn(component, 'updateEnlargedCanvas'); - const adjustCanvasSizeSpy = jest.spyOn(component, 'adjustCanvasSize'); - - window.dispatchEvent(new Event('resize')); - expect(adjustCanvasSizeSpy).toHaveBeenCalled(); - expect(updateEnlargedCanvasSpy).toHaveBeenCalledWith(canvas); - }); - - it('should close the enlarged view if click is outside the canvas within the enlarged container', () => { - const target = document.createElement('div'); - target.classList.add('enlarged-container'); - const mockEvent = createMockEvent(target); - - component.isEnlargedView.set(true); - const closeSpy = jest.spyOn(component, 'closeEnlargedView'); - - component.closeIfOutside(mockEvent); - - expect(closeSpy).toHaveBeenCalled(); - expect(component.isEnlargedView()).toBeFalsy(); - }); - - it('should not close the enlarged view if the click is on the canvas itself', () => { - const mockEvent = createMockEvent(mockEnlargedCanvas); - Object.defineProperty(mockEvent, 'target', { value: mockEnlargedCanvas, writable: false }); - - component.isEnlargedView.set(true); - - const closeSpy = jest.spyOn(component, 'closeEnlargedView'); - - component.closeIfOutside(mockEvent as unknown as MouseEvent); - - expect(closeSpy).not.toHaveBeenCalled(); - }); - - it('should calculate the correct scale factor for horizontal slides', () => { - // Mock container dimensions - Object.defineProperty(component.pdfContainer()!.nativeElement, 'clientWidth', { value: 1000, configurable: true }); - Object.defineProperty(component.pdfContainer()!.nativeElement, 'clientHeight', { value: 800, configurable: true }); - - // Mock a horizontal canvas (width > height) - mockCanvasElement.width = 500; - mockCanvasElement.height = 400; - const scaleFactor = component.calculateScaleFactor(mockCanvasElement); - - // Expect scale factor to be based on width (scaleX) and height (scaleY), whichever is smaller - expect(scaleFactor).toBe(2); // Min of 1000/500 (scaleX = 2) and 800/400 (scaleY = 2) - }); - - it('should calculate the correct scale factor for vertical slides', () => { - Object.defineProperty(component.pdfContainer()!.nativeElement, 'clientWidth', { value: 1000, configurable: true }); - Object.defineProperty(component.pdfContainer()!.nativeElement, 'clientHeight', { value: 800, configurable: true }); - - // Mock a vertical canvas (height > width) - mockCanvasElement.width = 400; - mockCanvasElement.height = 500; - const scaleFactor = component.calculateScaleFactor(mockCanvasElement); - - // For vertical slides, scaleY is based on DEFAULT_SLIDE_HEIGHT, and scaleX is based on containerWidth - // Expect scaleY to be 800/500 = 1.6 and scaleX to be 1000/400 = 2.5 - expect(scaleFactor).toBe(1.6); // Min of 1.6 (scaleY) and 2.5 (scaleX) - }); - - it('should resize the canvas based on the given scale factor', () => { - mockCanvasElement.width = 500; - mockCanvasElement.height = 400; - component.resizeCanvas(mockCanvasElement, 2); - - expect(component.enlargedCanvas()!.nativeElement.width).toBe(1000); - expect(component.enlargedCanvas()!.nativeElement.height).toBe(800); - }); - - it('should clear and redraw the canvas with the new dimensions', () => { - mockCanvasElement.width = 500; - mockCanvasElement.height = 400; - - jest.spyOn(mockContext, 'clearRect'); - jest.spyOn(mockContext, 'drawImage'); - - component.resizeCanvas(mockCanvasElement, 2); - component.redrawCanvas(mockCanvasElement); - - expect(component.enlargedCanvas()!.nativeElement.width).toBe(1000); // 500 * 2 - expect(component.enlargedCanvas()!.nativeElement.height).toBe(800); // 400 * 2 - - expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 1000, 800); - expect(mockContext.drawImage).toHaveBeenCalledWith(mockCanvasElement, 0, 0, 1000, 800); - }); - - it('should correctly position the canvas', () => { - const parent = document.createElement('div'); - - const mockDivElement = document.createElement('div'); - Object.defineProperty(mockDivElement, 'clientWidth', { value: 1000 }); - Object.defineProperty(mockDivElement, 'clientHeight', { value: 800 }); - Object.defineProperty(mockDivElement, 'scrollTop', { value: 500, writable: true }); - - component.pdfContainer = signal({ nativeElement: mockDivElement }); - const canvasElem = component.enlargedCanvas()!.nativeElement; - parent.appendChild(canvasElem); - canvasElem.width = 500; - canvasElem.height = 400; - component.positionCanvas(); - expect(canvasElem.style.left).toBe('250px'); - expect(canvasElem.style.top).toBe('200px'); - expect(parent.style.top).toBe('500px'); - }); - - it('should create a container with correct styles and children', () => { - const mockCanvas = document.createElement('canvas'); - mockCanvas.style.width = '600px'; - mockCanvas.style.height = '400px'; - - const container = component.createCanvasContainer(mockCanvas, 1); - expect(container.tagName).toBe('DIV'); - expect(container.classList.contains('pdf-canvas-container')).toBeTruthy(); - expect(container.style.position).toBe('relative'); - expect(container.style.display).toBe('inline-block'); - expect(container.style.width).toBe('600px'); - expect(container.style.height).toBe('400px'); - expect(container.style.margin).toBe('20px'); - expect(container.children).toHaveLength(3); - - expect(container.firstChild).toBe(mockCanvas); - }); - - it('should handle mouseenter and mouseleave events correctly', () => { - const mockCanvas = document.createElement('canvas'); - const container = component.createCanvasContainer(mockCanvas, 1); - const overlay = container.children[1] as HTMLElement; - - // Trigger mouseenter - const mouseEnterEvent = new Event('mouseenter'); - container.dispatchEvent(mouseEnterEvent); - expect(overlay.style.opacity).toBe('1'); - - // Trigger mouseleave - const mouseLeaveEvent = new Event('mouseleave'); - container.dispatchEvent(mouseLeaveEvent); - expect(overlay.style.opacity).toBe('0'); - }); - - it('should handle click event on overlay to trigger displayEnlargedCanvas', () => { - jest.spyOn(component, 'displayEnlargedCanvas'); - const mockCanvas = document.createElement('canvas'); - const container = component.createCanvasContainer(mockCanvas, 1); - const overlay = container.children[1]; - - overlay.dispatchEvent(new Event('click')); - expect(component.displayEnlargedCanvas).toHaveBeenCalledWith(mockCanvas); - }); - - it('should trigger the file input click event', () => { - const mockFileInput = document.createElement('input'); - mockFileInput.type = 'file'; - component.fileInput = signal({ nativeElement: mockFileInput }); - - const clickSpy = jest.spyOn(component.fileInput()!.nativeElement, 'click'); - component.triggerFileInput(); - expect(clickSpy).toHaveBeenCalled(); - }); - - it('should merge PDF files correctly and update the component state', async () => { - const mockFile = new File(['new pdf'], 'test.pdf', { type: 'application/pdf' }); - mockFile.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); // Return an empty ArrayBuffer for simplicity - const mockEvent = { target: { files: [mockFile] } }; - - const existingPdfDoc = { - copyPages: jest.fn().mockResolvedValue(['page']), - addPage: jest.fn(), - save: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), - }; - - const newPdfDoc = { - getPageIndices: jest.fn().mockReturnValue([0]), - }; - - PDFDocument.load = jest - .fn() - .mockImplementationOnce(() => Promise.resolve(existingPdfDoc)) - .mockImplementationOnce(() => Promise.resolve(newPdfDoc)); - - component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); - component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); // Return an empty ArrayBuffer for simplicity - - component.selectedPages.set(new Set([1])); // Assume there is initially a selected page - - await component.mergePDF(mockEvent as any); - - expect(PDFDocument.load).toHaveBeenCalledTimes(2); - expect(existingPdfDoc.copyPages).toHaveBeenCalledWith(newPdfDoc, [0]); - expect(existingPdfDoc.addPage).toHaveBeenCalled(); - expect(existingPdfDoc.save).toHaveBeenCalled(); - expect(component.currentPdfBlob).toBeDefined(); - expect(component.selectedPages()!.size).toBe(0); - expect(component.isPdfLoading()).toBeFalsy(); - expect(URL.createObjectURL).toHaveBeenCalledWith(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' })); - }); - - it('should handle errors when merging PDFs fails', async () => { - const mockFile = new File(['new pdf'], 'test.pdf', { type: 'application/pdf' }); - - // Mock the arrayBuffer method for the file object - mockFile.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); // Return an empty ArrayBuffer for simplicity - - const mockEvent = { target: { files: [mockFile] } }; - const error = new Error('Error loading PDF'); - - component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); - component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); // Return an empty ArrayBuffer for simp - - // Mock PDFDocument.load to throw an error on the first call - PDFDocument.load = jest - .fn() - .mockImplementationOnce(() => Promise.reject(error)) // First call throws an error - .mockImplementationOnce(() => Promise.resolve({})); // Second call (not actually needed here) - - await component.mergePDF(mockEvent as any); - - expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.mergeFailedError', { error: error.message }); - expect(component.isPdfLoading()).toBeFalsy(); - }); - - it('should update the IDs of remaining pages after some have been removed', () => { - const mockContainer = document.createElement('div'); - - for (let i = 1; i <= 3; i++) { - const mockPageContainer = document.createElement('div'); - mockPageContainer.classList.add('pdf-canvas-container'); - mockPageContainer.id = `pdf-page-${i}`; - - const mockCanvas = document.createElement('canvas'); - mockCanvas.id = String(i); - mockPageContainer.appendChild(mockCanvas); - - const mockOverlay = document.createElement('div'); - mockOverlay.innerHTML = `${i}`; - mockPageContainer.appendChild(mockOverlay); - - const mockCheckbox = document.createElement('input'); - mockCheckbox.type = 'checkbox'; - mockCheckbox.id = String(i); - mockPageContainer.appendChild(mockCheckbox); - - mockContainer.appendChild(mockPageContainer); - } - - component.pdfContainer = signal({ nativeElement: mockContainer }); - component.updatePageIDs(); - - const remainingPages = component.pdfContainer()!.nativeElement.querySelectorAll('.pdf-canvas-container'); - remainingPages.forEach((pageContainer, index) => { - const pageIndex = index + 1; - const canvas = pageContainer.querySelector('canvas'); - const overlay = pageContainer.querySelector('div'); - const checkbox = pageContainer.querySelector('input[type="checkbox"]'); - - expect(pageContainer.id).toBe(`pdf-page-${pageIndex}`); - expect(canvas!.id).toBe(String(pageIndex)); - expect(overlay!.innerHTML).toBe(`${pageIndex}`); - expect(checkbox!.id).toBe(String(pageIndex)); - }); - while (mockContainer.firstChild) { - mockContainer.removeChild(mockContainer.firstChild); - } - }); - - it('should update attachment successfully and show success alert', () => { - component.attachment.set({ id: 1, version: 1 }); - component.updateAttachmentWithFile(); - - expect(attachmentServiceMock.update).toHaveBeenCalled(); - expect(alertServiceMock.success).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - }); - - it('should not update attachment if file size exceeds the limit and show an error alert', () => { - const oversizedData = new Uint8Array(MAX_FILE_SIZE + 1).fill(0); - component.currentPdfBlob.set(new Blob([oversizedData], { type: 'application/pdf' })); - - component.updateAttachmentWithFile(); - - expect(attachmentServiceMock.update).not.toHaveBeenCalled(); - expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.fileSizeError'); - }); - - it('should handle errors when updating an attachment fails', () => { - attachmentServiceMock.update.mockReturnValue(throwError(() => new Error('Update failed'))); - component.attachment.set({ id: 1, version: 1 }); - - component.updateAttachmentWithFile(); - - expect(attachmentServiceMock.update).toHaveBeenCalled(); - expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); - }); - - it('should update attachment unit successfully and show success alert', () => { - component.attachment.set(undefined); - component.attachmentUnit.set({ - id: 1, - lecture: { id: 1 }, - attachment: { id: 1, version: 1 }, - }); - attachmentUnitServiceMock.update.mockReturnValue(of({})); - - component.updateAttachmentWithFile(); - - expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); - expect(alertServiceMock.success).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); - }); - - it('should handle errors when updating an attachment unit fails', () => { - component.attachment.set(undefined); - component.attachmentUnit.set({ - id: 1, - lecture: { id: 1 }, - attachment: { id: 1, version: 1 }, - }); - const errorResponse = { message: 'Update failed' }; - attachmentUnitServiceMock.update.mockReturnValue(throwError(() => errorResponse)); - - component.updateAttachmentWithFile(); - - expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); - expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); - }); - - it('should delete selected slides and update the PDF', async () => { - const existingPdfDoc = { - removePage: jest.fn(), - save: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), - }; - - PDFDocument.load = jest.fn().mockResolvedValue(existingPdfDoc); - const mockArrayBuffer = new ArrayBuffer(8); - component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); - component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(mockArrayBuffer); - - const objectUrl = 'blob-url'; - global.URL.createObjectURL = jest.fn().mockReturnValue(objectUrl); - global.URL.revokeObjectURL = jest.fn(); - - component.selectedPages.set(new Set([1, 2])); // Pages 1 and 2 selected - - const loadOrAppendPdfSpy = jest.spyOn(component, 'loadOrAppendPdf'); - const alertServiceErrorSpy = jest.spyOn(alertServiceMock, 'error'); - - await component.deleteSelectedSlides(); - - expect(PDFDocument.load).toHaveBeenCalledWith(mockArrayBuffer); - expect(existingPdfDoc.removePage).toHaveBeenCalledWith(1); - expect(existingPdfDoc.removePage).toHaveBeenCalledWith(0); - expect(existingPdfDoc.removePage).toHaveBeenCalledTimes(2); - expect(existingPdfDoc.save).toHaveBeenCalled(); - expect(component.currentPdfBlob()).toEqual(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' })); - expect(loadOrAppendPdfSpy).toHaveBeenCalledWith(objectUrl, false); - expect(component.selectedPages()!.size).toBe(0); - expect(alertServiceErrorSpy).not.toHaveBeenCalled(); - expect(URL.revokeObjectURL).toHaveBeenCalledWith(objectUrl); - expect(component.isPdfLoading()).toBeFalsy(); - }); - - it('should handle errors when deleting slides', async () => { - // Mock the arrayBuffer method for the current PDF Blob - component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); - component.currentPdfBlob()!.arrayBuffer = jest.fn().mockRejectedValue(new Error('Failed to load PDF')); - - // Spy on the alert service - const alertServiceErrorSpy = jest.spyOn(alertServiceMock, 'error'); - - // Call the method - await component.deleteSelectedSlides(); - - // Ensure the alert service was called with the correct error message - expect(alertServiceErrorSpy).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.pageDeleteError', { error: 'Failed to load PDF' }); - - // Verify that the loading state is set to false after the operation - expect(component.isPdfLoading()).toBeFalsy(); - }); -}); diff --git a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-enlarged-canvas.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-enlarged-canvas.component.spec.ts new file mode 100644 index 000000000000..01812980adc0 --- /dev/null +++ b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-enlarged-canvas.component.spec.ts @@ -0,0 +1,202 @@ +import { PdfPreviewEnlargedCanvasComponent } from 'app/lecture/pdf-preview/pdf-preview-enlarged-canvas/pdf-preview-enlarged-canvas.component'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientModule } from '@angular/common/http'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { AlertService } from 'app/core/util/alert.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { signal } from '@angular/core'; + +function createMockEvent(target: Element, eventType = 'click'): MouseEvent { + const event = new MouseEvent(eventType, { + view: window, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'target', { value: target, writable: false }); + return event; +} + +describe('PdfPreviewEnlargedCanvasComponent', () => { + let component: PdfPreviewEnlargedCanvasComponent; + let fixture: ComponentFixture; + let mockCanvasElement: HTMLCanvasElement; + let mockEnlargedCanvas: HTMLCanvasElement; + let mockContainer: HTMLDivElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PdfPreviewEnlargedCanvasComponent, HttpClientModule], + providers: [ + { provide: ActivatedRoute, useValue: { data: of({}) } }, + { provide: AlertService, useValue: { error: jest.fn() } }, + { provide: TranslateService, useClass: MockTranslateService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PdfPreviewEnlargedCanvasComponent); + component = fixture.componentInstance; + + mockEnlargedCanvas = document.createElement('canvas'); + component.enlargedCanvas = signal({ nativeElement: mockEnlargedCanvas }); + + mockContainer = document.createElement('div'); + fixture.componentRef.setInput('pdfContainer', mockContainer); + + mockCanvasElement = document.createElement('canvas'); + + const mockOriginalCanvas = document.createElement('canvas'); + mockOriginalCanvas.id = 'canvas-3'; + fixture.componentRef.setInput('originalCanvas', mockOriginalCanvas); + + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Keyboard Navigation', () => { + it('should navigate through pages using keyboard in enlarged view', () => { + component.isEnlargedViewOutput.emit(true); + const mockCanvas = document.createElement('canvas'); + mockCanvas.id = 'canvas-3'; + fixture.componentRef.setInput('originalCanvas', mockCanvas); + fixture.componentRef.setInput('totalPages', 5); + component.currentPage.set(3); + + const eventRight = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + const eventLeft = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); + + component.handleKeyboardEvents(eventRight); + expect(component.currentPage()).toBe(4); + + component.handleKeyboardEvents(eventLeft); + expect(component.currentPage()).toBe(3); + }); + + it('should prevent navigation beyond last page', () => { + component.currentPage.set(5); + fixture.componentRef.setInput('totalPages', 5); + component.handleKeyboardEvents(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + + expect(component.currentPage()).toBe(5); + }); + + it('should prevent navigation before first page', () => { + component.currentPage.set(1); + component.handleKeyboardEvents(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + + expect(component.currentPage()).toBe(1); + }); + + it('should stop event propagation and navigate pages', () => { + const navigateSpy = jest.spyOn(component, 'navigatePages'); + const eventMock = { stopPropagation: jest.fn() } as unknown as MouseEvent; + + component.handleNavigation('next', eventMock); + + expect(eventMock.stopPropagation).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith('next'); + }); + }); + + describe('Canvas Rendering', () => { + it('should calculate the correct scale factor for horizontal slides', () => { + // Mock container dimensions + Object.defineProperty(mockContainer, 'clientWidth', { value: 1000, configurable: true }); + Object.defineProperty(mockContainer, 'clientHeight', { value: 800, configurable: true }); + + // Mock a horizontal canvas (width > height) + mockCanvasElement.width = 500; + mockCanvasElement.height = 400; + const scaleFactor = component.calculateScaleFactor(mockCanvasElement); + + expect(scaleFactor).toBe(2); // Min of 1000/500 (scaleX) and 800/400 (scaleY) + }); + + it('should calculate the correct scale factor for vertical slides', () => { + Object.defineProperty(mockContainer, 'clientWidth', { value: 1000, configurable: true }); + Object.defineProperty(mockContainer, 'clientHeight', { value: 800, configurable: true }); + + // Mock a vertical canvas (height > width) + mockCanvasElement.width = 400; + mockCanvasElement.height = 500; + const scaleFactor = component.calculateScaleFactor(mockCanvasElement); + + expect(scaleFactor).toBe(1.6); // Min of 1.6 (scaleY) and 2.5 (scaleX) + }); + + it('should resize the canvas based on the given scale factor', () => { + mockCanvasElement.width = 500; + mockCanvasElement.height = 400; + component.resizeCanvas(mockCanvasElement, 2); + + expect(mockEnlargedCanvas.width).toBe(1000); + expect(mockEnlargedCanvas.height).toBe(800); + }); + + it('should clear and redraw the canvas with the new dimensions', () => { + mockCanvasElement.width = 500; + mockCanvasElement.height = 400; + + const mockContext = mockEnlargedCanvas.getContext('2d')!; + jest.spyOn(mockContext, 'clearRect'); + jest.spyOn(mockContext, 'drawImage'); + + component.resizeCanvas(mockCanvasElement, 2); + component.redrawCanvas(mockCanvasElement); + + expect(mockEnlargedCanvas.width).toBe(1000); // 500 * 2 + expect(mockEnlargedCanvas.height).toBe(800); // 400 * 2 + expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 1000, 800); + expect(mockContext.drawImage).toHaveBeenCalledWith(mockCanvasElement, 0, 0, 1000, 800); + }); + }); + + describe('Layout', () => { + it('should prevent scrolling when enlarged view is active', () => { + component.toggleBodyScroll(true); + expect(mockContainer.style.overflow).toBe('hidden'); + + component.toggleBodyScroll(false); + expect(mockContainer.style.overflow).toBe('auto'); + }); + + it('should not update canvas size if not in enlarged view', () => { + component.isEnlargedViewOutput.emit(false); + component.currentPage.set(3); + + const spy = jest.spyOn(component, 'updateEnlargedCanvas'); + component.adjustCanvasSize(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('Enlarged View Management', () => { + it('should close the enlarged view if click is outside the canvas within the enlarged container', () => { + const target = document.createElement('div'); + target.classList.add('enlarged-container'); + const mockEvent = createMockEvent(target); + + const closeSpy = jest.fn(); + component.isEnlargedViewOutput.subscribe(closeSpy); + + component.closeIfOutside(mockEvent); + + expect(closeSpy).toHaveBeenCalledWith(false); + }); + + it('should not close the enlarged view if the click is on the canvas itself', () => { + const mockEvent = createMockEvent(mockEnlargedCanvas); + component.isEnlargedViewOutput.emit(true); + + const closeSpy = jest.spyOn(component, 'closeEnlargedView'); + + component.closeIfOutside(mockEvent as unknown as MouseEvent); + expect(closeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts new file mode 100644 index 000000000000..968e6830506f --- /dev/null +++ b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview-thumbnail-grid.component.spec.ts @@ -0,0 +1,102 @@ +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { AlertService } from 'app/core/util/alert.service'; +import { HttpClientModule } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { PdfPreviewThumbnailGridComponent } from 'app/lecture/pdf-preview/pdf-preview-thumbnail-grid/pdf-preview-thumbnail-grid.component'; + +jest.mock('pdfjs-dist', () => { + return { + getDocument: jest.fn(() => ({ + promise: Promise.resolve({ + numPages: 1, + getPage: jest.fn(() => + Promise.resolve({ + getViewport: jest.fn(() => ({ width: 600, height: 800, scale: 1 })), + render: jest.fn(() => ({ + promise: Promise.resolve(), + })), + }), + ), + }), + })), + }; +}); + +jest.mock('pdfjs-dist/build/pdf.worker', () => { + return {}; +}); + +describe('PdfPreviewThumbnailGridComponent', () => { + let component: PdfPreviewThumbnailGridComponent; + let fixture: ComponentFixture; + let alertServiceMock: any; + + beforeEach(async () => { + alertServiceMock = { + error: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [PdfPreviewThumbnailGridComponent, HttpClientModule], + providers: [ + { provide: ActivatedRoute, useValue: { data: of({}) } }, + { provide: AlertService, useValue: alertServiceMock }, + { provide: TranslateService, useClass: MockTranslateService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PdfPreviewThumbnailGridComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should load PDF and render pages', async () => { + const spyCreateCanvas = jest.spyOn(component, 'createCanvas'); + const spyCreateCanvasContainer = jest.spyOn(component, 'createCanvasContainer'); + + await component.loadOrAppendPdf('fake-url'); + + expect(spyCreateCanvas).toHaveBeenCalled(); + expect(spyCreateCanvasContainer).toHaveBeenCalled(); + expect(component.totalPages()).toBe(1); + }); + + it('should toggle enlarged view state', () => { + const mockCanvas = document.createElement('canvas'); + component.displayEnlargedCanvas(mockCanvas); + expect(component.isEnlargedView()).toBeTruthy(); + + component.isEnlargedView.set(false); + expect(component.isEnlargedView()).toBeFalsy(); + }); + + it('should handle mouseenter and mouseleave events correctly', () => { + const mockCanvas = document.createElement('canvas'); + const container = component.createCanvasContainer(mockCanvas, 1); + const overlay = container.querySelector('div'); + + container.dispatchEvent(new Event('mouseenter')); + expect(overlay!.style.opacity).toBe('1'); + + container.dispatchEvent(new Event('mouseleave')); + expect(overlay!.style.opacity).toBe('0'); + }); + + it('should handle click event on overlay to trigger displayEnlargedCanvas', () => { + const displayEnlargedCanvasSpy = jest.spyOn(component, 'displayEnlargedCanvas'); + const mockCanvas = document.createElement('canvas'); + const container = component.createCanvasContainer(mockCanvas, 1); + const overlay = container.querySelector('div'); + + overlay!.dispatchEvent(new Event('click')); + expect(displayEnlargedCanvasSpy).toHaveBeenCalledWith(mockCanvas); + }); +}); diff --git a/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts new file mode 100644 index 000000000000..2f0b2ed366f7 --- /dev/null +++ b/src/test/javascript/spec/component/lecture/pdf-preview/pdf-preview.component.spec.ts @@ -0,0 +1,421 @@ +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { MAX_FILE_SIZE } from 'app/shared/constants/input.constants'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { AttachmentService } from 'app/lecture/attachment.service'; +import { AttachmentUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service'; +import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; +import { PdfPreviewComponent } from 'app/lecture/pdf-preview/pdf-preview.component'; +import { signal } from '@angular/core'; +import { AlertService } from 'app/core/util/alert.service'; +import { HttpClientModule, HttpErrorResponse } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { PDFDocument } from 'pdf-lib'; + +jest.mock('pdf-lib', () => { + const originalModule = jest.requireActual('pdf-lib'); + + return { + ...originalModule, + PDFDocument: { + ...originalModule.PDFDocument, + load: jest.fn(), + create: jest.fn(), + prototype: { + removePage: jest.fn(), + save: jest.fn(), + }, + }, + }; +}); + +jest.mock('pdfjs-dist', () => { + return { + getDocument: jest.fn(() => ({ + promise: Promise.resolve({ + numPages: 1, + getPage: jest.fn(() => + Promise.resolve({ + getViewport: jest.fn(() => ({ width: 600, height: 800, scale: 1 })), + render: jest.fn(() => ({ + promise: Promise.resolve(), + })), + }), + ), + }), + })), + }; +}); + +jest.mock('pdfjs-dist/build/pdf.worker', () => { + return {}; +}); + +describe('PdfPreviewComponent', () => { + let component: PdfPreviewComponent; + let fixture: ComponentFixture; + let attachmentServiceMock: any; + let attachmentUnitServiceMock: any; + let lectureUnitServiceMock: any; + let alertServiceMock: any; + let routeMock: any; + let routerNavigateSpy: any; + + beforeEach(async () => { + attachmentServiceMock = { + getAttachmentFile: jest.fn().mockReturnValue(of(new Blob([''], { type: 'application/pdf' }))), + update: jest.fn().mockReturnValue(of({})), + delete: jest.fn().mockReturnValue(of({})), + }; + attachmentUnitServiceMock = { + getAttachmentFile: jest.fn().mockReturnValue(of(new Blob([''], { type: 'application/pdf' }))), + update: jest.fn().mockReturnValue(of({})), + delete: jest.fn().mockReturnValue(of({})), + }; + lectureUnitServiceMock = { + delete: jest.fn().mockReturnValue(of({})), + }; + routeMock = { + data: of({ + course: { id: 1, name: 'Example Course' }, + attachment: { id: 1, name: 'Example PDF', lecture: { id: 1 } }, + attachmentUnit: { id: 1, name: 'Chapter 1', lecture: { id: 1 } }, + }), + }; + alertServiceMock = { + addAlert: jest.fn(), + error: jest.fn(), + success: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [PdfPreviewComponent, HttpClientModule], + providers: [ + { provide: ActivatedRoute, useValue: routeMock }, + { provide: AttachmentService, useValue: attachmentServiceMock }, + { provide: AttachmentUnitService, useValue: attachmentUnitServiceMock }, + { provide: LectureUnitService, useValue: lectureUnitServiceMock }, + { provide: AlertService, useValue: alertServiceMock }, + { provide: TranslateService, useClass: MockTranslateService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PdfPreviewComponent); + component = fixture.componentInstance; + + jest.spyOn(component.dialogErrorSource, 'next'); + + global.URL.createObjectURL = jest.fn().mockReturnValue('blob-url'); + + routerNavigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Initialization and Data Loading', () => { + it('should load attachment file and verify service calls when attachment data is available', () => { + component.ngOnInit(); + expect(attachmentServiceMock.getAttachmentFile).toHaveBeenCalledWith(1, 1); + expect(attachmentUnitServiceMock.getAttachmentFile).not.toHaveBeenCalled(); + }); + + it('should load attachment unit file and verify service calls when attachment unit data is available', () => { + routeMock.data = of({ + course: { id: 1, name: 'Example Course' }, + attachmentUnit: { id: 1, name: 'Chapter 1' }, + }); + component.ngOnInit(); + expect(attachmentUnitServiceMock.getAttachmentFile).toHaveBeenCalledWith(1, 1); + expect(attachmentServiceMock.getAttachmentFile).toHaveBeenCalled(); + }); + + it('should handle errors and trigger alert when loading an attachment file fails', () => { + const errorResponse = new HttpErrorResponse({ + status: 404, + statusText: 'Not Found', + error: 'File not found', + }); + + const attachmentService = TestBed.inject(AttachmentService); + jest.spyOn(attachmentService, 'getAttachmentFile').mockReturnValue(throwError(() => errorResponse)); + const alertServiceSpy = jest.spyOn(alertServiceMock, 'error'); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(alertServiceSpy).toHaveBeenCalled(); + }); + + it('should handle errors and trigger alert when loading an attachment unit file fails', () => { + routeMock.data = of({ + course: { id: 1, name: 'Example Course' }, + attachmentUnit: { id: 1, name: 'Chapter 1' }, + }); + const errorResponse = new HttpErrorResponse({ + status: 404, + statusText: 'Not Found', + error: 'File not found', + }); + + const attachmentUnitService = TestBed.inject(AttachmentUnitService); + jest.spyOn(attachmentUnitService, 'getAttachmentFile').mockReturnValue(throwError(() => errorResponse)); + const alertServiceSpy = jest.spyOn(alertServiceMock, 'error'); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(alertServiceSpy).toHaveBeenCalled(); + }); + }); + + describe('Unsubscribing from Observables', () => { + it('should unsubscribe attachment subscription during component destruction', () => { + const spySub = jest.spyOn(component.attachmentSub, 'unsubscribe'); + component.ngOnDestroy(); + expect(spySub).toHaveBeenCalled(); + }); + + it('should unsubscribe attachmentUnit subscription during component destruction', () => { + routeMock.data = of({ + course: { id: 1, name: 'Example Course' }, + attachmentUnit: { id: 1, name: 'Chapter 1' }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.attachmentUnitSub).toBeDefined(); + const spySub = jest.spyOn(component.attachmentUnitSub, 'unsubscribe'); + component.ngOnDestroy(); + expect(spySub).toHaveBeenCalled(); + }); + }); + + describe('File Input Handling', () => { + it('should trigger the file input click event', () => { + const mockFileInput = document.createElement('input'); + mockFileInput.type = 'file'; + component.fileInput = signal({ nativeElement: mockFileInput }); + + const clickSpy = jest.spyOn(component.fileInput()!.nativeElement, 'click'); + component.triggerFileInput(); + expect(clickSpy).toHaveBeenCalled(); + }); + }); + + describe('Attachment Updating', () => { + it('should update attachment successfully and show success alert', () => { + component.attachment.set({ id: 1, version: 1 }); + component.updateAttachmentWithFile(); + + expect(attachmentServiceMock.update).toHaveBeenCalled(); + expect(alertServiceMock.success).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + }); + + it('should not update attachment if file size exceeds the limit and show an error alert', () => { + const oversizedData = new Uint8Array(MAX_FILE_SIZE + 1).fill(0); + component.currentPdfBlob.set(new Blob([oversizedData], { type: 'application/pdf' })); + + component.updateAttachmentWithFile(); + + expect(attachmentServiceMock.update).not.toHaveBeenCalled(); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.fileSizeError'); + }); + + it('should handle errors when updating an attachment fails', () => { + attachmentServiceMock.update.mockReturnValue(throwError(() => new Error('Update failed'))); + component.attachment.set({ id: 1, version: 1 }); + + component.updateAttachmentWithFile(); + + expect(attachmentServiceMock.update).toHaveBeenCalled(); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); + }); + + it('should update attachment unit successfully and show success alert', () => { + component.attachment.set(undefined); + component.attachmentUnit.set({ + id: 1, + lecture: { id: 1 }, + attachment: { id: 1, version: 1 }, + }); + attachmentUnitServiceMock.update.mockReturnValue(of({})); + + component.updateAttachmentWithFile(); + + expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); + expect(alertServiceMock.success).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateSuccess'); + }); + + it('should handle errors when updating an attachment unit fails', () => { + component.attachment.set(undefined); + component.attachmentUnit.set({ + id: 1, + lecture: { id: 1 }, + attachment: { id: 1, version: 1 }, + }); + const errorResponse = { message: 'Update failed' }; + attachmentUnitServiceMock.update.mockReturnValue(throwError(() => errorResponse)); + + component.updateAttachmentWithFile(); + + expect(attachmentUnitServiceMock.update).toHaveBeenCalledWith(1, 1, expect.any(FormData)); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Update failed' }); + }); + }); + + describe('PDF Merging', () => { + it('should merge PDF files correctly and update the component state', async () => { + const mockFile = new File(['new pdf'], 'test.pdf', { type: 'application/pdf' }); + mockFile.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + const mockEvent = { target: { files: [mockFile] } }; + + const existingPdfDoc = { + copyPages: jest.fn().mockResolvedValue(['page']), + addPage: jest.fn(), + save: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + }; + + const newPdfDoc = { + getPageIndices: jest.fn().mockReturnValue([0]), + }; + + PDFDocument.load = jest + .fn() + .mockImplementationOnce(() => Promise.resolve(existingPdfDoc)) + .mockImplementationOnce(() => Promise.resolve(newPdfDoc)); + + component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + + component.selectedPages.set(new Set([1])); + + await component.mergePDF(mockEvent as any); + + expect(PDFDocument.load).toHaveBeenCalledTimes(2); + expect(existingPdfDoc.copyPages).toHaveBeenCalledWith(newPdfDoc, [0]); + expect(existingPdfDoc.addPage).toHaveBeenCalled(); + expect(existingPdfDoc.save).toHaveBeenCalled(); + expect(component.currentPdfBlob).toBeDefined(); + expect(component.selectedPages()!.size).toBe(0); + expect(component.isPdfLoading()).toBeFalsy(); + expect(URL.createObjectURL).toHaveBeenCalledWith(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' })); + }); + + it('should handle errors when merging PDFs fails', async () => { + const mockFile = new File(['new pdf'], 'test.pdf', { type: 'application/pdf' }); + + mockFile.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + + const mockEvent = { target: { files: [mockFile] } }; + const error = new Error('Error loading PDF'); + + component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8)); + + PDFDocument.load = jest + .fn() + .mockImplementationOnce(() => Promise.reject(error)) + .mockImplementationOnce(() => Promise.resolve({})); + + await component.mergePDF(mockEvent as any); + + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.mergeFailedError', { error: error.message }); + expect(component.isPdfLoading()).toBeFalsy(); + }); + }); + + describe('Slide Deletion', () => { + it('should delete selected slides and update the PDF', async () => { + const existingPdfDoc = { + removePage: jest.fn(), + save: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + }; + + PDFDocument.load = jest.fn().mockResolvedValue(existingPdfDoc); + const mockArrayBuffer = new ArrayBuffer(8); + + component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockResolvedValue(mockArrayBuffer); + component.selectedPages.set(new Set([1, 2])); + + const alertServiceErrorSpy = jest.spyOn(alertServiceMock, 'error'); + + await component.deleteSelectedSlides(); + + expect(PDFDocument.load).toHaveBeenCalledWith(mockArrayBuffer); + expect(existingPdfDoc.removePage).toHaveBeenCalledWith(1); + expect(existingPdfDoc.removePage).toHaveBeenCalledWith(0); + expect(existingPdfDoc.removePage).toHaveBeenCalledTimes(2); + expect(existingPdfDoc.save).toHaveBeenCalled(); + expect(component.currentPdfBlob()).toEqual(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/pdf' })); + expect(component.selectedPages()!.size).toBe(0); + expect(alertServiceErrorSpy).not.toHaveBeenCalled(); + expect(component.isPdfLoading()).toBeFalsy(); + }); + + it('should handle errors when deleting slides', async () => { + component.currentPdfBlob.set(new Blob(['existing pdf'], { type: 'application/pdf' })); + component.currentPdfBlob()!.arrayBuffer = jest.fn().mockRejectedValue(new Error('Failed to load PDF')); + + const alertServiceErrorSpy = jest.spyOn(alertServiceMock, 'error'); + await component.deleteSelectedSlides(); + + expect(alertServiceErrorSpy).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.pageDeleteError', { error: 'Failed to load PDF' }); + expect(component.isPdfLoading()).toBeFalsy(); + }); + }); + + describe('Attachment Deletion', () => { + it('should delete the attachment and navigate to attachments on success', () => { + component.attachment.set({ id: 1, lecture: { id: 2 } }); + component.course.set({ id: 3 }); + + component.deleteAttachmentFile(); + + expect(attachmentServiceMock.delete).toHaveBeenCalledWith(1); + expect(routerNavigateSpy).toHaveBeenCalledWith(['course-management', 3, 'lectures', 2, 'attachments']); + expect(component.dialogErrorSource.next).toHaveBeenCalledWith(''); + }); + + it('should delete the attachment unit and navigate to unit management on success', () => { + component.attachment.set(undefined); + component.attachmentUnit.set({ id: 4, lecture: { id: 5 } }); + component.course.set({ id: 6 }); + + component.deleteAttachmentFile(); + + expect(lectureUnitServiceMock.delete).toHaveBeenCalledWith(4, 5); + expect(routerNavigateSpy).toHaveBeenCalledWith(['course-management', 6, 'lectures', 5, 'unit-management']); + expect(component.dialogErrorSource.next).toHaveBeenCalledWith(''); + }); + + it('should handle error and call alertService.error if deletion of attachment fails', () => { + const error = { message: 'Deletion failed' }; + attachmentServiceMock.delete.mockReturnValue(throwError(() => error)); + component.attachment.set({ id: 1, lecture: { id: 2 } }); + component.course.set({ id: 3 }); + + component.deleteAttachmentFile(); + + expect(attachmentServiceMock.delete).toHaveBeenCalledWith(1); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Deletion failed' }); + }); + + it('should handle error and call alertService.error if deletion of attachment unit fails', () => { + const error = { message: 'Deletion failed' }; + lectureUnitServiceMock.delete.mockReturnValue(throwError(() => error)); + component.attachment.set(undefined); + component.attachmentUnit.set({ id: 4, lecture: { id: 5 } }); + component.course.set({ id: 6 }); + + component.deleteAttachmentFile(); + + expect(lectureUnitServiceMock.delete).toHaveBeenCalledWith(4, 5); + expect(alertServiceMock.error).toHaveBeenCalledWith('artemisApp.attachment.pdfPreview.attachmentUpdateError', { error: 'Deletion failed' }); + }); + }); +});