diff --git a/src/app-components/Card/Card.module.css b/src/app-components/Card/Card.module.css index f157517e97..4a3f126233 100644 --- a/src/app-components/Card/Card.module.css +++ b/src/app-components/Card/Card.module.css @@ -1,6 +1,5 @@ .mediaCard { padding: 0; - margin-bottom: -7px; } .mediaCard img { object-fit: cover; diff --git a/src/app-components/Card/Card.tsx b/src/app-components/Card/Card.tsx index 858499d512..d7c69a6002 100644 --- a/src/app-components/Card/Card.tsx +++ b/src/app-components/Card/Card.tsx @@ -13,6 +13,8 @@ type AppCardProps = { color?: Parameters[0]['color']; children?: React.ReactNode; variant?: 'tinted' | 'default'; + className?: string; + ref?: React.Ref; }; export function AppCard({ @@ -24,11 +26,15 @@ export function AppCard({ mediaPosition = 'top', children, variant = 'tinted', + className, + ref, }: AppCardProps) { return ( {media && mediaPosition === 'top' && {media}} diff --git a/src/app-components/Dropzone/Dropzone.tsx b/src/app-components/Dropzone/Dropzone.tsx index 9e6ec9bd0b..5c607ff806 100644 --- a/src/app-components/Dropzone/Dropzone.tsx +++ b/src/app-components/Dropzone/Dropzone.tsx @@ -6,22 +6,21 @@ import type { FileRejection } from 'react-dropzone'; import cn from 'classnames'; import classes from 'src/app-components/Dropzone/Dropzone.module.css'; -import { mapExtensionToAcceptMime } from 'src/app-components/Dropzone/mapExtensionToAcceptMime'; type MaxFileSize = { sizeInMB: number; text: string; }; -export type IDropzoneComponentProps = { +export type IDropzoneProps = { id: string; maxFileSize?: MaxFileSize; readOnly: boolean; - onClick: (event: React.MouseEvent) => void; + onClick?: (event: React.MouseEvent) => void; onDrop: (acceptedFiles: File[], rejectedFiles: FileRejection[]) => void; + onDragActiveChange?: (isDragActive: boolean) => void; hasValidationMessages: boolean; - hasCustomFileEndings?: boolean; - validFileEndings?: string | string[]; + acceptedFiles?: { [key: string]: string[] }; labelId?: string; describedBy?: string; className?: string; @@ -35,15 +34,15 @@ export function Dropzone({ readOnly, onClick, onDrop, + onDragActiveChange, hasValidationMessages, - hasCustomFileEndings, - validFileEndings, + acceptedFiles, labelId, children, className, describedBy, ...rest -}: IDropzoneComponentProps): React.JSX.Element { +}: IDropzoneProps): React.JSX.Element { const maxSizeLabelId = `file-upload-max-size-${id}`; const describedby = [describedBy, maxFileSize?.sizeInMB ? maxSizeLabelId : undefined].filter(Boolean).join(' ') || undefined; @@ -52,9 +51,16 @@ export function Dropzone({ onDrop, maxSize: maxFileSize && maxFileSize.sizeInMB * bytesInOneMB, disabled: readOnly, - accept: - hasCustomFileEndings && validFileEndings !== undefined ? mapExtensionToAcceptMime(validFileEndings) : undefined, + accept: acceptedFiles, }); + + // set drag active state in parent component if callback is provided + React.useEffect(() => { + if (onDragActiveChange) { + onDragActiveChange(isDragActive); + } + }, [isDragActive, onDragActiveChange]); + return (
{maxFileSize && ( diff --git a/src/features/expressions/shared-tests/functions/displayValue/type-ImageUpload.json b/src/features/expressions/shared-tests/functions/displayValue/type-ImageUpload.json new file mode 100644 index 0000000000..14a20ac2fe --- /dev/null +++ b/src/features/expressions/shared-tests/functions/displayValue/type-ImageUpload.json @@ -0,0 +1,42 @@ +{ + "name": "Display value of FileUpload component", + "expression": [ + "displayValue", + "image" + ], + "context": { + "component": "image", + "currentLayout": "Page" + }, + "expects": "my-image.jpg", + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "image", + "type": "ImageUpload" + } + ] + } + } + }, + "instanceDataElements": [ + { + "id": "bb2b2222-2b22-2b22-222b-222222222222", + "instanceGuid": "aa1a1111-1a11-1a11-111a-111111111111", + "dataType": "image", + "filename": "my-image.jpg", + "contentType": "image/jpeg", + "blobStoragePath": "", + "size": 100, + "locked": false, + "refs": [], + "created": "2021-01-01T00:00:00.000Z", + "createdBy": "testUser", + "lastChanged": "2021-01-01T00:00:00.000Z", + "lastChangedBy": "testUser" + } + ] +} diff --git a/src/language/texts/en.ts b/src/language/texts/en.ts index e277af3969..92663fc4ed 100644 --- a/src/language/texts/en.ts +++ b/src/language/texts/en.ts @@ -208,6 +208,17 @@ export function en() { 'iframe_component.unsupported_browser_title': 'Your browser is unsupported', 'iframe_component.unsupported_browser': 'Your browser does not support iframes that use srcdoc. This may result in not being able to see all the content intended to be displayed here. We recommend trying a different browser.', + 'image_upload_component.animated_warning': 'If the image is animated, only the first frame will be shown.', + 'image_upload_component.button_change': 'Change image', + 'image_upload_component.button_delete': 'Delete image', + 'image_upload_component.button_save': 'Save image', + 'image_upload_component.crop_area': 'Crop area', + 'image_upload_component.slider_zoom': 'Zoom', + 'image_upload_component.summary_empty': "You haven't uploaded an image", + 'image_upload_component.reset': 'Reset position and zoom', + 'image_upload_component.error_invalid_file_type': 'Invalid file format. Please upload an image file.', + 'image_upload_component.error_file_size_exceeded': 'File size exceeds 10MB limit.', + 'image_upload_component.valid_file_types': 'Image files only', 'input_components.remaining_characters': 'You have %d characters left', 'input_components.exceeded_max_limit': 'You have exceeded the maximum limit with %d characters', 'instance_selection.changed_by': 'Changed by', diff --git a/src/language/texts/nb.ts b/src/language/texts/nb.ts index 2080da38e6..18f85f6752 100644 --- a/src/language/texts/nb.ts +++ b/src/language/texts/nb.ts @@ -70,7 +70,7 @@ export function nb() { 'form_filler.file_upload_valid_file_format_all': 'alle', 'form_filler.file_uploader_add_attachment': 'Legg til flere vedlegg', 'form_filler.file_uploader_drag': 'Dra og slipp eller', - 'form_filler.file_uploader_find': 'let etter fil', + 'form_filler.file_uploader_find': 'finn fil', 'form_filler.file_uploader_list_delete': 'Slett vedlegg', 'form_filler.file_uploader_delete_warning': 'Er du sikker på at du vil slette dette vedlegget?', 'form_filler.file_uploader_delete_button_confirm': 'Ja, slett vedlegg', @@ -209,6 +209,17 @@ export function nb() { 'iframe_component.unsupported_browser_title': 'Nettleseren din støttes ikke', 'iframe_component.unsupported_browser': 'Nettleseren du bruker støtter ikke iframes som benytter seg av srcdoc. Dette kan føre til at du ikke ser all innholdet som er ment å vises her. Vi anbefaler deg å prøve en annen nettleser.', + 'image_upload_component.animated_warning': 'Hvis bildet er animert, vises bare det første bildet.', + 'image_upload_component.button_change': 'Bytt bilde', + 'image_upload_component.button_delete': 'Slett bildet', + 'image_upload_component.button_save': 'Lagre bilde', + 'image_upload_component.crop_area': 'Beskjæringsområde', + 'image_upload_component.slider_zoom': 'Tilpass bildet', + 'image_upload_component.summary_empty': 'Du har ikke lastet opp noe bilde', + 'image_upload_component.reset': 'Tilbakestill zoom og plassering', + 'image_upload_component.error_invalid_file_type': 'Ugyldig filformat. Last opp en bildefil.', + 'image_upload_component.error_file_size_exceeded': 'Filen er for stor. Største tillatte filstørrelse er 10MB.', + 'image_upload_component.valid_file_types': 'Bildefiler er tillatt', 'input_components.remaining_characters': 'Du har %d tegn igjen', 'input_components.exceeded_max_limit': 'Du har overskredet maks antall tegn med %d', 'instance_selection.changed_by': 'Endret av', diff --git a/src/language/texts/nn.ts b/src/language/texts/nn.ts index 479a7ef8e5..5e6c9adcc8 100644 --- a/src/language/texts/nn.ts +++ b/src/language/texts/nn.ts @@ -70,7 +70,7 @@ export function nn() { 'form_filler.file_upload_valid_file_format_all': 'alle', 'form_filler.file_uploader_add_attachment': 'Legg til fleire vedlegg', 'form_filler.file_uploader_drag': 'Dra og slepp eller', - 'form_filler.file_uploader_find': 'leit etter fil', + 'form_filler.file_uploader_find': 'finn fil', 'form_filler.file_uploader_list_delete': 'Slett vedlegg', 'form_filler.file_uploader_delete_warning': 'Er du sikker på at du vil sletta dette vedlegget?', 'form_filler.file_uploader_delete_button_confirm': 'Ja, slett vedlegg', @@ -209,6 +209,17 @@ export function nn() { 'iframe_component.unsupported_browser_title': 'Nettlesaren din støttas ikkje', 'iframe_component.unsupported_browser': 'Nettlesaren di støttar ikkje iframes som brukar srcdoc. Dette kan føre til at du ikkje ser all innhaldet som er meint å visast her. Vi anbefalar deg å prøve ein annan nettlesar.', + 'image_upload_component.animated_warning': 'Hvis bildet er animert, vises bare det første bildet.', + 'image_upload_component.button_change': 'Bytt bilde', + 'image_upload_component.button_delete': 'Slett bildet', + 'image_upload_component.button_save': 'Lagre bilde', + 'image_upload_component.slider_zoom': 'Tilpass bildet', + 'image_upload_component.crop_area': 'Beskjæringsområde', + 'image_upload_component.summary_empty': 'Du har ikkje lasta opp noko bilde', + 'image_upload_component.reset': 'Tilbakestill zoom og plassering', + 'image_upload_component.error_invalid_file_type': 'Ugyldig filformat. Last opp ein bildefil.', + 'image_upload_component.error_file_size_exceeded': 'Fila er for stor. Største tillatte filstorleik er 10MB.', + 'image_upload_component.valid_file_types': 'Bildefiler er tillatne', 'input_components.remaining_characters': 'Du har %d teikn igjen', 'input_components.exceeded_max_limit': 'Du har overskride maks teikn med %d', 'instance_selection.changed_by': 'Endra av', diff --git a/src/layout/Cards/Cards.module.css b/src/layout/Cards/Cards.module.css index fc857e068f..5766ccce9e 100644 --- a/src/layout/Cards/Cards.module.css +++ b/src/layout/Cards/Cards.module.css @@ -11,3 +11,7 @@ video { .cardMedia:last-of-type { margin-top: auto; } + +.mediaCard { + margin-bottom: -7px; +} diff --git a/src/layout/Cards/Cards.tsx b/src/layout/Cards/Cards.tsx index daa2d8ccad..29a3a86712 100644 --- a/src/layout/Cards/Cards.tsx +++ b/src/layout/Cards/Cards.tsx @@ -5,6 +5,7 @@ import { AppCard } from 'src/app-components/Card/Card'; import { Flex } from 'src/app-components/Flex/Flex'; import { Lang } from 'src/features/language/Lang'; import { CardProvider } from 'src/layout/Cards/CardContext'; +import classes from 'src/layout/Cards/Cards.module.css'; import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper'; import { GenericComponent } from 'src/layout/GenericComponent'; import { useHasCapability } from 'src/utils/layout/canRenderIn'; @@ -117,6 +118,7 @@ function CardItem({ baseComponentId, parentBaseId, isMedia, minMediaHeight }: Ca
{ }); expect(screen.getByRole('presentation', { name: /attachment-title/i }).textContent).toMatch( - 'Dra og slipp eller let etter filTillatte filformater er: alle', + 'Dra og slipp eller finn filTillatte filformater er: alle', ); }); diff --git a/src/layout/FileUpload/FileUploadComponent.tsx b/src/layout/FileUpload/FileUploadComponent.tsx index d6d32e9630..1b29e3f011 100644 --- a/src/layout/FileUpload/FileUploadComponent.tsx +++ b/src/layout/FileUpload/FileUploadComponent.tsx @@ -7,6 +7,7 @@ import { CloudUpIcon } from '@navikt/aksel-icons'; import cn from 'classnames'; import { Dropzone } from 'src/app-components/Dropzone/Dropzone'; +import { mapExtensionToAcceptMime } from 'src/app-components/Dropzone/mapExtensionToAcceptMime'; import { getDescriptionId, getLabelId, Label } from 'src/components/label/Label'; import { useAddRejectedAttachments, useAttachmentsFor, useAttachmentsUploader } from 'src/features/attachments/hooks'; import { Lang } from 'src/features/language/Lang'; @@ -59,7 +60,8 @@ export function FileUploadComponent({ const validations = useUnifiedValidationsForNode(baseComponentId).filter( (v) => !('attachmentId' in v) || !v.attachmentId, ); - + const filesToAccept = + hasCustomFileEndings && validFileEndings !== undefined ? mapExtensionToAcceptMime(validFileEndings) : undefined; const { options, isFetching } = useGetOptions(baseComponentId, 'single'); const indexedId = useIndexedId(baseComponentId); @@ -139,8 +141,7 @@ export function FileUploadComponent({ onClick={(e) => e.preventDefault()} onDrop={handleDrop} hasValidationMessages={hasValidationErrors(validations)} - hasCustomFileEndings={hasCustomFileEndings} - validFileEndings={validFileEndings} + acceptedFiles={filesToAccept} labelId={textResourceBindings?.title ? getLabelId(id) : undefined} describedBy={ariaDescribedBy} > diff --git a/src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts b/src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts index f6abcbd29a..d1d1383860 100644 --- a/src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts +++ b/src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts @@ -7,7 +7,7 @@ import { } from 'src/utils/layout/generator/validation/hooks'; import type { IDataModelBindings } from 'src/layout/layout'; -export function useFileUploaderDataBindingsValidation( +export function useFileUploaderDataBindingsValidation( baseComponentId: string, bindings: IDataModelBindings, ): string[] { diff --git a/src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css b/src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css new file mode 100644 index 0000000000..4063157bf8 --- /dev/null +++ b/src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css @@ -0,0 +1,12 @@ +.canvas { + display: block; + position: relative; + left: 50%; + transform: translateX(-50%); + cursor: grab; + touch-action: none; +} + +.canvas:active { + cursor: grabbing; +} diff --git a/src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx b/src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx new file mode 100644 index 0000000000..755f2bf265 --- /dev/null +++ b/src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx @@ -0,0 +1,84 @@ +import React, { useCallback } from 'react'; + +import { useLanguage } from 'src/features/language/useLanguage'; +import { useImageFile } from 'src/layout/ImageUpload/hooks/useImageFile'; +import { useCanvasDraw } from 'src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw'; +import { useDragInteraction } from 'src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction'; +import { useKeyboardNavigation } from 'src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation'; +import { useZoomInteraction } from 'src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction'; +import classes from 'src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css'; +import { ImagePreview } from 'src/layout/ImageUpload/ImageCanvas/ImagePreview'; +import { constrainToArea, type CropInternal, type Position } from 'src/layout/ImageUpload/imageUploadUtils'; +interface ImageCanvasProps { + imageRef: React.RefObject; + zoom: number; + minAllowedZoom: number; + position: Position; + cropArea: CropInternal; + baseComponentId: string; + setPosition: (newPosition: Position) => void; + onZoomChange: (newZoom: number) => void; + canvasRef: React.RefObject; +} + +const CANVAS_HEIGHT = 320; +const CANVAS_WIDTH = 1000; + +export function ImageCanvas({ + imageRef, + zoom, + minAllowedZoom, + position, + cropArea, + baseComponentId, + setPosition, + onZoomChange, + canvasRef, +}: ImageCanvasProps) { + const { storedImage, imageUrl } = useImageFile(baseComponentId); + const { langAsString } = useLanguage(); + + const handlePositionChange = useCallback( + (newPosition: Position) => { + if (!imageRef.current) { + return; + } + setPosition( + constrainToArea({ + image: imageRef.current, + zoom, + position: newPosition, + cropArea, + }), + ); + }, + [zoom, cropArea, setPosition, imageRef], + ); + + useCanvasDraw({ canvasRef, imageRef, zoom, position, cropArea }); + useZoomInteraction({ canvasRef, zoom, minAllowedZoom, onZoomChange }); + const { handlePointerDown } = useDragInteraction({ canvasRef, position, onPositionChange: handlePositionChange }); + const { handleKeyDown } = useKeyboardNavigation({ position, onPositionChange: handlePositionChange }); + + if (storedImage) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/layout/ImageUpload/ImageCanvas/ImagePreview.module.css b/src/layout/ImageUpload/ImageCanvas/ImagePreview.module.css new file mode 100644 index 0000000000..10075fa2c0 --- /dev/null +++ b/src/layout/ImageUpload/ImageCanvas/ImagePreview.module.css @@ -0,0 +1,9 @@ +.previewWrapper { + background-color: var(--ds-color-neutral-background-tinted); + height: 320px; + display: flex; + justify-content: center; + align-items: center; + padding: var(--ds-size-4); + box-sizing: border-box; +} diff --git a/src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx b/src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx new file mode 100644 index 0000000000..0686868d44 --- /dev/null +++ b/src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { Spinner } from '@digdir/designsystemet-react'; + +import { useLanguage } from 'src/features/language/useLanguage'; +import classes from 'src/layout/ImageUpload/ImageCanvas/ImagePreview.module.css'; +import type { UploadedAttachment } from 'src/features/attachments'; + +type ImagePreviewProps = { + storedImage: UploadedAttachment; + imageUrl?: string; +}; + +export function ImagePreview({ storedImage, imageUrl }: ImagePreviewProps) { + const { langAsString } = useLanguage(); + + if (!storedImage.uploaded) { + return ( +
+
+ ); + } + + return ( +
+ {storedImage.data?.filename} +
+ ); +} diff --git a/src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx b/src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx new file mode 100644 index 0000000000..237e5ba2b3 --- /dev/null +++ b/src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import type React from 'react'; + +import { + cropAreaPlacement, + type CropInternal, + drawCropArea, + imagePlacement, + type Position, +} from 'src/layout/ImageUpload/imageUploadUtils'; + +type UseCanvasDrawProps = { + canvasRef: React.RefObject; + imageRef: React.RefObject; + zoom: number; + position: Position; + cropArea: CropInternal; +}; + +export const useCanvasDraw = ({ canvasRef, imageRef, zoom, position, cropArea }: UseCanvasDrawProps) => { + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + const img = imageRef.current; + + if (!canvas || !img?.complete || !ctx) { + return; + } + + const draw = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position }); + + ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight); + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.save(); + + const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea }); + drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea }); + ctx.clip(); + ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight); + ctx.restore(); + + drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea }); + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + }; + + if (img.complete) { + draw(); + } else { + img.addEventListener('load', draw, { once: true }); + } + + return () => { + img.removeEventListener('load', draw); + }; + }, [canvasRef, imageRef, zoom, position, cropArea]); +}; diff --git a/src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx b/src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx new file mode 100644 index 0000000000..bc3cfb7247 --- /dev/null +++ b/src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx @@ -0,0 +1,40 @@ +import type React from 'react'; + +import type { Position } from 'src/layout/ImageUpload/imageUploadUtils'; + +type UseDragInteractionProps = { + canvasRef: React.RefObject; + position: Position; + onPositionChange: (newPosition: Position) => void; +}; + +export const useDragInteraction = ({ canvasRef, position, onPositionChange }: UseDragInteractionProps) => { + const handlePointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + canvas.setPointerCapture(e.pointerId); + const startDrag = { x: e.clientX - position.x, y: e.clientY - position.y }; + + const handlePointerMove = (moveEvent: PointerEvent) => { + onPositionChange({ + x: moveEvent.clientX - startDrag.x, + y: moveEvent.clientY - startDrag.y, + }); + }; + + const handlePointerUp = () => { + canvas.releasePointerCapture(e.pointerId); + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + }; + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + }; + + return { handlePointerDown }; +}; diff --git a/src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx b/src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx new file mode 100644 index 0000000000..ea25bc6082 --- /dev/null +++ b/src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; +import type React from 'react'; + +import type { Position } from 'src/layout/ImageUpload/imageUploadUtils'; + +type UseKeyboardNavigationProps = { + position: Position; + onPositionChange: (newPosition: Position) => void; +}; + +export const useKeyboardNavigation = ({ position, onPositionChange }: UseKeyboardNavigationProps) => { + const MOVE_AMOUNT = 10; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const keyMap: Record void> = { + ArrowUp: () => onPositionChange({ ...position, y: position.y - MOVE_AMOUNT }), + ArrowDown: () => onPositionChange({ ...position, y: position.y + MOVE_AMOUNT }), + ArrowLeft: () => onPositionChange({ ...position, x: position.x - MOVE_AMOUNT }), + ArrowRight: () => onPositionChange({ ...position, x: position.x + MOVE_AMOUNT }), + }; + + if (keyMap[e.key]) { + e.preventDefault(); + keyMap[e.key](); + } + }, + [position, onPositionChange], + ); + + return { handleKeyDown }; +}; diff --git a/src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx b/src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx new file mode 100644 index 0000000000..f7600ec50b --- /dev/null +++ b/src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx @@ -0,0 +1,35 @@ +import { useCallback, useEffect } from 'react'; +import type React from 'react'; + +import { MAX_ZOOM } from 'src/layout/ImageUpload/imageUploadUtils'; + +interface UseZoomInteractionProps { + canvasRef: React.RefObject; + zoom: number; + minAllowedZoom: number; + onZoomChange: (newZoom: number) => void; +} + +export const useZoomInteraction = ({ canvasRef, zoom, minAllowedZoom, onZoomChange }: UseZoomInteractionProps) => { + const handleWheel = useCallback( + (e: WheelEvent) => { + e.preventDefault(); + + const zoomSensitivity = 0.001; + const zoomFactor = 1 - e.deltaY * zoomSensitivity; + const newZoom = Math.max(minAllowedZoom, Math.min(zoom * zoomFactor, MAX_ZOOM)); + onZoomChange(newZoom); + }, + [zoom, onZoomChange, minAllowedZoom], + ); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + canvas.addEventListener('wheel', handleWheel, { passive: false }); + return () => canvas.removeEventListener('wheel', handleWheel); + }, [handleWheel, canvasRef]); +}; diff --git a/src/layout/ImageUpload/ImageControllers.module.css b/src/layout/ImageUpload/ImageControllers.module.css new file mode 100644 index 0000000000..4b5ae345a3 --- /dev/null +++ b/src/layout/ImageUpload/ImageControllers.module.css @@ -0,0 +1,69 @@ +.controlsContainer { + display: flex; + flex-direction: column; + gap: var(--ds-size-3); +} + +.zoomControls { + display: flex; + align-items: center; +} + +.zoomSlider { + width: 100%; + height: var(--ds-size-2); + background-color: var(--ds-color-neutral-border-strong); + border-radius: var(--ds-border-radius-full); + -webkit-appearance: none; + appearance: none; + cursor: pointer; + touch-action: none; +} + +/* Chrome / Safari / Edge */ +.zoomSlider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: var(--ds-size-7); + height: var(--ds-size-7); + border-radius: var(--ds-border-radius-full); + border: 1px solid var(--ds-color-border-strong); + background: var(--ds-color-base-contrast-default); + cursor: pointer; +} + +/* Firefox */ +.zoomSlider::-moz-range-thumb { + width: var(--ds-size-7); + height: var(--ds-size-7); + border-radius: var(--ds-border-radius-full); + border: 1px solid var(--ds-color-border-strong); + background: var(--ds-color-base-contrast-default); + cursor: pointer; +} + +/* Firefox track (to override default blue bar) */ +.zoomSlider::-moz-range-track { + background-color: var(--ds-color-neutral-border-strong); + height: var(--ds-size-2); + border-radius: var(--ds-border-radius-full); +} + +.actionButtons { + display: flex; + gap: var(--ds-size-2); + flex-direction: row; + align-items: center; +} + +.resetButton { + font-size: var(--ds-size-6); + color: var(--ds-color-text-default); +} + +@media (max-width: 450px) { + .actionButtons { + flex-direction: column; + align-items: stretch; + } +} diff --git a/src/layout/ImageUpload/ImageControllers.tsx b/src/layout/ImageUpload/ImageControllers.tsx new file mode 100644 index 0000000000..d960094339 --- /dev/null +++ b/src/layout/ImageUpload/ImageControllers.tsx @@ -0,0 +1,159 @@ +import React, { useId, useRef } from 'react'; + +import { Button, Input, Label } from '@digdir/designsystemet-react'; +import { ArrowUndoIcon, TrashIcon, UploadIcon } from '@navikt/aksel-icons'; + +import { Lang } from 'src/features/language/Lang'; +import { useLanguage } from 'src/features/language/useLanguage'; +import classes from 'src/layout/ImageUpload/ImageControllers.module.css'; +import { isAnimationFile, logToNormalZoom, normalToLogZoom } from 'src/layout/ImageUpload/imageUploadUtils'; +import type { UploadedAttachment } from 'src/features/attachments'; + +type ImageControllersProps = { + imageType: string; + readOnly: boolean; + zoom: number; + zoomLimits: { minZoom: number; maxZoom: number }; + storedImage?: UploadedAttachment; + onSave: () => void; + onDelete: () => void; + onCancel: () => void; + updateZoom: (zoom: number) => void; + onFileUploaded: (file: File) => void; + onReset: () => void; +}; + +export function ImageControllers({ + imageType, + readOnly, + zoom, + zoomLimits: { minZoom, maxZoom }, + storedImage, + onSave, + onDelete, + onCancel, + updateZoom, + onFileUploaded, + onReset, +}: ImageControllersProps) { + const { langAsString } = useLanguage(); + const uid = useId(); + const zoomId = `${uid}-zoom`; + const inputId = `${uid}-image-upload`; + const fileInputRef = useRef(null); + + const handleSliderZoom = (e: React.ChangeEvent) => { + const logarithmicZoomValue = normalToLogZoom({ + value: Number.parseFloat(e.target.value), + minZoom, + maxZoom, + }); + + updateZoom(logarithmicZoomValue); + }; + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onFileUploaded(file); + } + }; + + if (storedImage) { + return ( + + ); + } + + const handleFileSelectKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInputRef?.current?.click(); + } + }; + + return ( +
+ {isAnimationFile(imageType) && ( + + + + )} +
+ +
+ + +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/layout/ImageUpload/ImageCropper.tsx b/src/layout/ImageUpload/ImageCropper.tsx new file mode 100644 index 0000000000..a798318fd2 --- /dev/null +++ b/src/layout/ImageUpload/ImageCropper.tsx @@ -0,0 +1,147 @@ +import React, { useCallback, useRef, useState } from 'react'; + +import { ValidationMessage } from '@digdir/designsystemet-react'; + +import { AppCard } from 'src/app-components/Card/Card'; +import { Lang } from 'src/features/language/Lang'; +import { useImageCropperSave } from 'src/layout/ImageUpload/hooks/useImageCropperSave'; +import { useImageFile } from 'src/layout/ImageUpload/hooks/useImageFile'; +import { useImageUploader } from 'src/layout/ImageUpload/hooks/useImageUploader'; +import { ImageCanvas } from 'src/layout/ImageUpload/ImageCanvas/ImageCanvas'; +import { ImageControllers } from 'src/layout/ImageUpload/ImageControllers'; +import { ImageDropzone } from 'src/layout/ImageUpload/ImageDropzone'; +import { + calculateMinZoom, + calculatePositionForZoom, + IMAGE_TYPE, + MAX_ZOOM, +} from 'src/layout/ImageUpload/imageUploadUtils'; +import type { CropInternal, Position } from 'src/layout/ImageUpload/imageUploadUtils'; + +interface ImageCropperProps { + baseComponentId: string; + cropArea: CropInternal; + readOnly: boolean; +} + +export function ImageCropper({ baseComponentId, cropArea, readOnly }: ImageCropperProps) { + const { deleteImage, storedImage } = useImageFile(baseComponentId); + const canvasRef = useRef(null); + const imageRef = useRef(null); + const imageTypeRef = useRef(null); + const [zoom, setZoom] = useState(0); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [validationErrors, setValidationErrors] = useState(null); + const { handleSave } = useImageCropperSave({ + canvasRef, + imageRef, + cropArea, + zoom, + position, + baseComponentId, + setValidationErrors, + }); + + type UpdateImageState = { minZoom?: number; img?: HTMLImageElement | null }; + const updateImageState = ({ minZoom = minAllowedZoom, img = imageRef.current }: UpdateImageState) => { + setZoom(minZoom); + setPosition({ x: 0, y: 0 }); + imageRef.current = img; + }; + + const { handleFileUpload } = useImageUploader({ cropArea, updateImageState, setValidationErrors, imageTypeRef }); + const minAllowedZoom = imageRef.current ? calculateMinZoom({ img: imageRef.current, cropArea }) : 0.1; + + const handleZoomChange = useCallback( + (newZoomValue: number) => { + const canvas = canvasRef.current; + const img = imageRef.current; + + if (!canvas || !img) { + return; + } + + const newZoom = Math.max(minAllowedZoom, Math.min(newZoomValue, MAX_ZOOM)); + const newPosition = calculatePositionForZoom({ canvas, img, oldZoom: zoom, newZoom, position, cropArea }); + setZoom(newZoom); + setPosition(newPosition); + }, + [minAllowedZoom, position, zoom, cropArea], + ); + + const handleDeleteImage = () => { + deleteImage(); + updateImageState({ img: null }); + }; + + const handleCancel = () => { + setValidationErrors(null); + updateImageState({ img: null }); + }; + + if (!imageRef.current && !storedImage) { + return ( + <> + handleFileUpload(files[0])} + readOnly={readOnly} + hasErrors={!!validationErrors && validationErrors?.length > 0} + /> + + + ); + } + + return ( + + } + > + {(imageRef.current || storedImage) && ( + updateImageState({})} + /> + )} + + + ); +} + +const ValidationMessages = ({ validationErrors }: { validationErrors: string[] | null }) => { + if (!validationErrors) { + return null; + } + + return validationErrors.map((error, index) => ( + + + + )); +}; diff --git a/src/layout/ImageUpload/ImageDropzone.module.css b/src/layout/ImageUpload/ImageDropzone.module.css new file mode 100644 index 0000000000..fc32c9fa1e --- /dev/null +++ b/src/layout/ImageUpload/ImageDropzone.module.css @@ -0,0 +1,74 @@ +.placeholder { + background-image: + linear-gradient( + 45deg, + rgba(204, 204, 204, 0.5) 25%, + transparent 25%, + transparent 75%, + rgba(204, 204, 204, 0.5) 75%, + rgba(204, 204, 204, 0.5) 100% + ), + linear-gradient( + 45deg, + rgba(204, 204, 204, 0.5) 25%, + rgba(255, 255, 255, 0.5) 25%, + rgba(255, 255, 255, 0.5) 75%, + rgba(204, 204, 204, 0.5) 75%, + rgba(204, 204, 204, 0.5) 100% + ); + background-size: 20px 20px; + background-position: + 0 0, + 10px 10px; + height: 320px; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + padding: var(--ds-size-4); + border-radius: var(--ds-border-radius-lg); + border: var(--ds-border-width-default) solid var(--ds-color-border-subtle); + box-sizing: border-box; +} + +.underLine { + border-bottom: 2px solid var(--colors-primary-blue) !important; +} + +.dropZone { + padding: var(--ds-size-4); + background-color: var(--ds-color-neutral-surface-default); + border-radius: var(--ds-border-radius-lg); + border: 2px dashed var(--ds-color-border-default); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; +} + +.placeholder:hover, +.dragActive { + background-image: + linear-gradient( + 45deg, + rgba(204, 204, 204, 0.75) 25%, + transparent 25%, + transparent 75%, + rgba(204, 204, 204, 0.75) 75%, + rgba(204, 204, 204, 0.75) 100% + ), + linear-gradient( + 45deg, + rgba(204, 204, 204, 0.75) 25%, + rgba(255, 255, 255, 0.75) 25%, + rgba(255, 255, 255, 0.75) 75%, + rgba(204, 204, 204, 0.75) 75%, + rgba(204, 204, 204, 0.75) 100% + ); +} + +.placeholder:hover .dropZone, +.dragActive .dropZone { + border-style: solid; +} diff --git a/src/layout/ImageUpload/ImageDropzone.tsx b/src/layout/ImageUpload/ImageDropzone.tsx new file mode 100644 index 0000000000..329d1f4ac5 --- /dev/null +++ b/src/layout/ImageUpload/ImageDropzone.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import cn from 'classnames'; + +import { Dropzone } from 'src/app-components/Dropzone/Dropzone'; +import { getDescriptionId } from 'src/components/label/Label'; +import { Lang } from 'src/features/language/Lang'; +import { useIsMobileOrTablet } from 'src/hooks/useDeviceWidths'; +import classes from 'src/layout/ImageUpload/ImageDropzone.module.css'; +import type { IDropzoneProps } from 'src/app-components/Dropzone/Dropzone'; + +type ImageDropzoneProps = { + baseComponentId: string; + hasErrors: boolean; + readOnly: boolean; +} & Pick; + +export function ImageDropzone({ baseComponentId, hasErrors, readOnly, onDrop }: ImageDropzoneProps) { + const [dragActive, setDragActive] = React.useState(false); + const isMobile = useIsMobileOrTablet(); + const descriptionId = getDescriptionId(baseComponentId); + const dragLabelId = `file-upload-drag-${baseComponentId}`; + const formatLabelId = `file-upload-format-${baseComponentId}`; + const ariaDescribedBy = [descriptionId, dragLabelId, formatLabelId].filter(Boolean).join(' '); + + return ( + +
+ + {isMobile ? ( + + ) : ( + <> + + + {' '} + + + + )} + + + + +
+
+ ); +} diff --git a/src/layout/ImageUpload/ImageUploadComponent.test.tsx b/src/layout/ImageUpload/ImageUploadComponent.test.tsx new file mode 100644 index 0000000000..09990855f3 --- /dev/null +++ b/src/layout/ImageUpload/ImageUploadComponent.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { getAttachmentsMock } from 'src/__mocks__/getAttachmentsMock'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; +import { UploadedAttachment } from 'src/features/attachments'; +import * as imageHooks from 'src/layout/ImageUpload/hooks/useImageFile'; +import { ImageUploadComponent } from 'src/layout/ImageUpload/ImageUploadComponent'; +import { renderGenericComponentTest, RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; + +const attachmentsMock: UploadedAttachment[] = getAttachmentsMock({ count: 1, fileSize: 500 }); + +describe('ImageUploadComponent', () => { + beforeEach(() => jest.restoreAllMocks()); + + it('should render label', async () => { + await renderImageUploadComponent(); + expect(screen.getByText('mock.label')).toBeInTheDocument(); + }); + + it('should render delete button when there is an stored image', async () => { + jest.spyOn(imageHooks, 'useImageFile').mockReturnValue({ + storedImage: attachmentsMock[0], + saveImage: jest.fn(), + deleteImage: jest.fn(), + }); + + await renderImageUploadComponent(); + + expect(screen.getByRole('button', { name: 'Slett bildet' })).toBeInTheDocument(); + }); + + it('should render disabled delete button when there is an stored image and is readOnly', async () => { + jest.spyOn(imageHooks, 'useImageFile').mockReturnValue({ + storedImage: attachmentsMock[0], + saveImage: jest.fn(), + deleteImage: jest.fn(), + }); + await renderImageUploadComponent({ component: { readOnly: true } }); + const deleteButton = screen.getByRole('button', { name: 'Slett bildet' }); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).toBeDisabled(); + }); + + it('should call to delete image when delete button is clicked', async () => { + const user = userEvent.setup(); + const deleteImage = jest.fn(); + + jest.spyOn(imageHooks, 'useImageFile').mockReturnValue({ + storedImage: attachmentsMock[0], + saveImage: jest.fn(), + deleteImage, + }); + + await renderImageUploadComponent(); + const deleteButton = screen.getByRole('button', { name: 'Slett bildet' }); + expect(deleteButton).toBeInTheDocument(); + await user.click(deleteButton); + + expect(deleteImage).toHaveBeenCalled(); + }); + + it('should render validation message when the file are bigger than 10mb', async () => { + await renderImageUploadComponent(); + + const bigBuffer = new Uint8Array(11 * 1024 * 1024); // 11 MB + const image = new File([bigBuffer], 'chucknorris.png', { type: 'image/png' }); + const dropzone = screen.getByRole('presentation').querySelector('input'); + await userEvent.upload(dropzone!, image); + expect(screen.getByText('Filen er for stor. Største tillatte filstørrelse er 10MB.')).toBeInTheDocument(); + }); +}); + +const renderImageUploadComponent = async ({ + component, + ...rest +}: Partial> = {}) => + await renderGenericComponentTest({ + type: 'ImageUpload', + renderer: (props) => , + component: { + id: 'mock-id', + required: false, + textResourceBindings: { + title: 'mock.label', + }, + dataModelBindings: { + simpleBinding: { dataType: defaultDataTypeMock, field: 'some.field' }, + }, + ...component, + }, + ...rest, + }); diff --git a/src/layout/ImageUpload/ImageUploadComponent.tsx b/src/layout/ImageUpload/ImageUploadComponent.tsx new file mode 100644 index 0000000000..3c24920442 --- /dev/null +++ b/src/layout/ImageUpload/ImageUploadComponent.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { Label } from 'src/app-components/Label/Label'; +import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper'; +import { ImageCropper } from 'src/layout/ImageUpload/ImageCropper'; +import { getCropArea, IMAGE_TYPE, isAllowedContentTypesValid } from 'src/layout/ImageUpload/imageUploadUtils'; +import { useLabel } from 'src/utils/layout/useLabel'; +import { useItemWhenType } from 'src/utils/layout/useNodeItem'; +import type { PropsFromGenericComponent } from 'src/layout'; + +export function ImageUploadComponent({ baseComponentId, overrideDisplay }: PropsFromGenericComponent<'ImageUpload'>) { + const { dataTypes } = useApplicationMetadata(); + const { id, crop, grid, required, readOnly } = useItemWhenType(baseComponentId, 'ImageUpload'); + const { labelText, getRequiredComponent, getOptionalComponent, getHelpTextComponent, getDescriptionComponent } = + useLabel({ baseComponentId, overrideDisplay }); + + if (!isAllowedContentTypesValid({ baseComponentId, dataTypes })) { + throw new Error( + `allowedContentTypes is configured for '${baseComponentId}', but is missing '${IMAGE_TYPE}' which is required for ImageUpload components.`, + ); + } + + return ( + + ); +} diff --git a/src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.module.css b/src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.module.css new file mode 100644 index 0000000000..1309febc51 --- /dev/null +++ b/src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.module.css @@ -0,0 +1,5 @@ +.image { + max-width: 150px; + width: 100%; + object-fit: contain; +} diff --git a/src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.test.tsx b/src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.test.tsx new file mode 100644 index 0000000000..c410992398 --- /dev/null +++ b/src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { screen } from '@testing-library/react'; + +import { getAttachmentsMock } from 'src/__mocks__/getAttachmentsMock'; +import { UploadedAttachment } from 'src/features/attachments'; +import * as useImageFile from 'src/layout/ImageUpload/hooks/useImageFile'; +import { ImageUploadSummary2 } from 'src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2'; +import { renderGenericComponentTest, RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; + +const targetBaseComponentId = 'mock-id'; + +describe('ImageUploadSummary2', () => { + beforeEach(() => jest.restoreAllMocks()); + + it('renders label', async () => { + await renderImageUploadSummary2(); + expect(screen.getByText('mock.label', { selector: 'span' })).toBeInTheDocument(); + }); + + it('renders empty value text when no attachment', async () => { + await renderImageUploadSummary2(); + expect(screen.getByText('Du har ikke lastet opp noe bilde')).toBeInTheDocument(); + }); + + it('renders image when attachment exists', async () => { + const mockAttachment = getAttachmentsMock({ count: 1, fileSize: 500 })[0] as UploadedAttachment; + jest.spyOn(useImageFile, 'useImageFile').mockReturnValue({ + storedImage: mockAttachment, + imageUrl: 'https://mock.url/image.png', + saveImage: jest.fn(), + deleteImage: jest.fn(), + }); + + await renderImageUploadSummary2(); + + const img = screen.getByRole('img') as HTMLImageElement; + expect(img).toBeInTheDocument(); + expect(img.src).toBe('https://mock.url/image.png'); + expect(img.alt).toBe(mockAttachment.data.filename); + }); +}); + +const renderImageUploadSummary2 = async ( + props: Partial> & { required?: boolean } = {}, +) => { + const { required = false, component, ...rest } = props; + + return renderGenericComponentTest({ + type: 'ImageUpload', + renderer: (p) => ( + + ), + component: { + id: targetBaseComponentId, + required, + textResourceBindings: { title: 'mock.label' }, + ...component, + }, + ...rest, + }); +}; diff --git a/src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.tsx b/src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.tsx new file mode 100644 index 0000000000..f0cb9ab3a2 --- /dev/null +++ b/src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { Lang } from 'src/features/language/Lang'; +import { useUploaderSummaryData } from 'src/layout/FileUpload/Summary/summary'; +import { useImageFile } from 'src/layout/ImageUpload/hooks/useImageFile'; +import classes from 'src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2.module.css'; +import { SingleValueSummary } from 'src/layout/Summary2/CommonSummaryComponents/SingleValueSummary'; +import { SummaryContains, SummaryFlex } from 'src/layout/Summary2/SummaryComponent2/ComponentSummary'; +import { useSummaryProp } from 'src/layout/Summary2/summaryStoreContext'; +import { useItemWhenType } from 'src/utils/layout/useNodeItem'; +import type { Summary2Props } from 'src/layout/Summary2/SummaryComponent2/types'; + +export function ImageUploadSummary2({ targetBaseComponentId }: Summary2Props) { + const attachment = useUploaderSummaryData(targetBaseComponentId); + const { required, textResourceBindings } = useItemWhenType(targetBaseComponentId, 'ImageUpload'); + const isCompact = useSummaryProp('isCompact'); + const { storedImage } = useImageFile(targetBaseComponentId); + const isEmpty = attachment.length === 0; + const title = textResourceBindings?.title; + const emptyValueText = required ? SummaryContains.EmptyValueRequired : SummaryContains.EmptyValueNotRequired; + const contentLogic = isEmpty ? emptyValueText : SummaryContains.SomeUserContent; + const imageElement = storedImage ? : undefined; + + return ( + + } + targetBaseComponentId={targetBaseComponentId} + displayData={imageElement && } + hideEditButton={false} + isCompact={isCompact} + emptyFieldText='image_upload_component.summary_empty' + /> + + ); +} + +interface ImageToDisplayProps { + targetBaseComponentId: string; +} + +const ImageToDisplay = ({ targetBaseComponentId }: ImageToDisplayProps) => { + const { imageUrl, storedImage } = useImageFile(targetBaseComponentId); + + return ( + {storedImage!.data?.filename} + ); +}; diff --git a/src/layout/ImageUpload/config.ts b/src/layout/ImageUpload/config.ts new file mode 100644 index 0000000000..4ce1fd1d51 --- /dev/null +++ b/src/layout/ImageUpload/config.ts @@ -0,0 +1,52 @@ +import { CG } from 'src/codegen/CG'; +import { AttachmentsPlugin } from 'src/features/attachments/AttachmentsPlugin'; +import { CompCategory } from 'src/layout/common'; + +export const Config = new CG.component({ + category: CompCategory.Form, + capabilities: { + renderInTable: true, + renderInButtonGroup: false, + renderInAccordion: true, + renderInAccordionGroup: false, + renderInTabs: true, + renderInCards: true, + renderInCardsMedia: false, + }, + functionality: { + customExpressions: true, + }, +}) + .addPlugin(new AttachmentsPlugin()) + .extendTextResources(CG.common('TRBLabel')) + .addProperty( + new CG.prop( + 'crop', + new CG.union( + new CG.obj( + new CG.prop('shape', new CG.const('circle').setTitle('Shape').setDescription('Circular cropping area')), + new CG.prop( + 'diameter', + new CG.num().optional({ default: 250 }).setTitle('Diameter').setDescription('Diameter of the circle'), + ), + ).exportAs('CropConfigCircle'), + new CG.obj( + new CG.prop('shape', new CG.const('rectangle').setTitle('Shape').setDescription('Rectangular cropping area')), + new CG.prop( + 'width', + new CG.num().optional({ default: 250 }).setTitle('Width').setDescription('Width of the rectangle'), + ), + new CG.prop( + 'height', + new CG.num().optional({ default: 250 }).setTitle('Height').setDescription('Height of the rectangle'), + ), + ).exportAs('CropConfigRect'), + ) + .setUnionType('discriminated') + .optional({ default: { shape: 'circle', diameter: 250 } }) + .exportAs('CropConfig'), + ), + ) + .addDataModelBinding(CG.common('IDataModelBindingsSimple').optional()) + .extends(CG.common('LabeledComponentProps')) + .addSummaryOverrides(); diff --git a/src/layout/ImageUpload/hooks/useImageCropperSave.tsx b/src/layout/ImageUpload/hooks/useImageCropperSave.tsx new file mode 100644 index 0000000000..a57816fe8f --- /dev/null +++ b/src/layout/ImageUpload/hooks/useImageCropperSave.tsx @@ -0,0 +1,69 @@ +import type React from 'react'; + +import { useImageFile } from 'src/layout/ImageUpload/hooks/useImageFile'; +import { + cropAreaPlacement, + drawCropArea, + getNewFileName, + IMAGE_TYPE, + imagePlacement, +} from 'src/layout/ImageUpload/imageUploadUtils'; +import type { CropInternal, Position } from 'src/layout/ImageUpload/imageUploadUtils'; + +type UseImageCropperSaveProps = { + canvasRef: React.RefObject; + imageRef: React.RefObject; + cropArea: CropInternal; + zoom: number; + position: Position; + baseComponentId: string; + setValidationErrors: (errors: string[] | null) => void; +}; + +export function useImageCropperSave({ + canvasRef, + imageRef, + cropArea, + zoom, + position, + baseComponentId, + setValidationErrors, +}: UseImageCropperSaveProps) { + const { saveImage } = useImageFile(baseComponentId); + + const handleSave = () => { + const canvas = canvasRef.current; + const img = imageRef.current; + const cropCanvas = document.createElement('canvas'); + const cropCtx = cropCanvas.getContext('2d'); + + if (!canvas || !img || !cropCtx) { + return; + } + + cropCtx.imageSmoothingEnabled = true; + cropCtx.imageSmoothingQuality = 'high'; + + cropCanvas.width = cropArea.width; + cropCanvas.height = cropArea.height; + + const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position }); + const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea }); + + drawCropArea({ ctx: cropCtx, cropArea }); + cropCtx.clip(); + cropCtx.drawImage(img, imgX - cropAreaX, imgY - cropAreaY, scaledWidth, scaledHeight); + + cropCanvas.toBlob((blob) => { + if (!blob) { + return; + } + + const newFileName = getNewFileName({ fileName: img.id }); + const imageFile = new File([blob], newFileName, { type: IMAGE_TYPE }); + saveImage(imageFile); + setValidationErrors(null); + }, IMAGE_TYPE); + }; + return { handleSave }; +} diff --git a/src/layout/ImageUpload/hooks/useImageFile.tsx b/src/layout/ImageUpload/hooks/useImageFile.tsx new file mode 100644 index 0000000000..1f88c90c4e --- /dev/null +++ b/src/layout/ImageUpload/hooks/useImageFile.tsx @@ -0,0 +1,52 @@ +import { type UploadedAttachment } from 'src/features/attachments'; +import { useAttachmentsFor, useAttachmentsRemover, useAttachmentsUploader } from 'src/features/attachments/hooks'; +import { useLaxInstanceId } from 'src/features/instance/InstanceContext'; +import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; +import { useIndexedId } from 'src/utils/layout/DataModelLocation'; +import { useItemWhenType } from 'src/utils/layout/useNodeItem'; +import { getDataElementUrl } from 'src/utils/urls/appUrlHelper'; +import { makeUrlRelativeIfSameDomain } from 'src/utils/urls/urlHelper'; + +type UseImageFileResult = { + storedImage?: UploadedAttachment; + imageUrl?: string; + saveImage: (file: File) => void; + deleteImage: () => void; +}; + +export const useImageFile = (baseComponentId: string): UseImageFileResult => { + const { dataModelBindings } = useItemWhenType(baseComponentId, 'ImageUpload'); + const indexedId = useIndexedId(baseComponentId); + const uploadImage = useAttachmentsUploader(); + const removeImage = useAttachmentsRemover(); + const storedImage = useAttachmentsFor(baseComponentId)[0] as UploadedAttachment | undefined; + + const language = useCurrentLanguage(); + const instanceId = useLaxInstanceId(); + const imageUrl = + storedImage && + instanceId && + makeUrlRelativeIfSameDomain(getDataElementUrl(instanceId, storedImage.data.id, language)); + + const saveImage = (file: File) => { + uploadImage({ + files: [file], + nodeId: indexedId, + dataModelBindings, + }); + }; + + const deleteImage = () => { + if (storedImage?.deleting || !storedImage) { + return; + } + + removeImage({ + attachment: storedImage, + nodeId: indexedId, + dataModelBindings, + }); + }; + + return { storedImage, imageUrl, saveImage, deleteImage }; +}; diff --git a/src/layout/ImageUpload/hooks/useImageUploader.tsx b/src/layout/ImageUpload/hooks/useImageUploader.tsx new file mode 100644 index 0000000000..0070bdd943 --- /dev/null +++ b/src/layout/ImageUpload/hooks/useImageUploader.tsx @@ -0,0 +1,43 @@ +import type React from 'react'; + +import { calculateMinZoom, validateFile } from 'src/layout/ImageUpload/imageUploadUtils'; +import type { CropInternal } from 'src/layout/ImageUpload/imageUploadUtils'; + +type UseImageUploaderProps = { + cropArea: CropInternal; + updateImageState: (args: { minZoom: number; img: HTMLImageElement }) => void; + setValidationErrors: (errors: string[]) => void; + imageTypeRef: React.RefObject; +}; + +export function useImageUploader({ + cropArea, + updateImageState, + setValidationErrors, + imageTypeRef, +}: UseImageUploaderProps) { + const handleFileUpload = (file: File) => { + const validationErrors = validateFile(file); + setValidationErrors(validationErrors); + if (validationErrors.length > 0) { + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const result = event.target?.result; + + if (typeof result === 'string') { + const img = new Image(); + img.id = file.name; + imageTypeRef.current = file.type; + img.onload = () => { + updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img }); + }; + img.src = result; + } + }; + reader.readAsDataURL(file); + }; + return { handleFileUpload }; +} diff --git a/src/layout/ImageUpload/imageUploadUtils.test.tsx b/src/layout/ImageUpload/imageUploadUtils.test.tsx new file mode 100644 index 0000000000..26f2dce66f --- /dev/null +++ b/src/layout/ImageUpload/imageUploadUtils.test.tsx @@ -0,0 +1,284 @@ +import { + calculateMinZoom, + calculatePositionForZoom, + constrainToArea, + cropAreaPlacement, + CropForm, + drawCropArea, + getCropArea, + getNewFileName, + imagePlacement, + isAllowedContentTypesValid, + isAnimationFile, + logToNormalZoom, + normalToLogZoom, + validateFile, +} from 'src/layout/ImageUpload/imageUploadUtils'; +import { IDataType } from 'src/types/shared'; + +const mockImage = (width: number, height: number): HTMLImageElement => { + const img = new Image(); + Object.defineProperty(img, 'width', { get: () => width }); + Object.defineProperty(img, 'height', { get: () => height }); + return img; +}; +const mockCanvas = (): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + Object.defineProperty(canvas, 'width', { get: () => 400 }); + Object.defineProperty(canvas, 'height', { get: () => 400 }); + return canvas; +}; + +describe('getCropArea', () => { + it('returns default crop area when no params are provided', () => { + expect(getCropArea()).toEqual({ width: 250, height: 250, shape: CropForm.Circle }); + }); + + it('returns provided width and height', () => { + const crop1 = { shape: CropForm.Rectangle, width: 300, height: 200 }; + expect(getCropArea(crop1)).toEqual({ + width: 300, + height: 200, + shape: CropForm.Rectangle, + }); + + const crop2 = { shape: CropForm.Circle, diameter: 200 }; + expect(getCropArea(crop2)).toEqual({ + width: 200, + height: 200, + shape: CropForm.Circle, + }); + }); + + it('returns default size for missing dimensions', () => { + const crop1 = { shape: CropForm.Rectangle, width: 300 }; + expect(getCropArea(crop1)).toEqual({ + width: 300, + height: 250, + shape: CropForm.Rectangle, + }); + const crop2 = { shape: CropForm.Rectangle, height: 200 }; + expect(getCropArea(crop2)).toEqual({ + width: 250, + height: 200, + shape: CropForm.Rectangle, + }); + }); +}); + +describe('constrainToArea', () => { + const image = mockImage(500, 500); + const cropArea = { width: 200, height: 200, shape: CropForm.Rectangle }; + const wantedPosition = { x: 200, y: 200 }; + + it('should clamp position to max offset for image larger than crop area', () => { + const zoom = 1; + const constrainedPosition = constrainToArea({ image, zoom, position: wantedPosition, cropArea }); + expect(constrainedPosition).toEqual({ x: 150, y: 150 }); + }); + + it('should allow wanted position when image is scaled because of zoomed in', () => { + const zoom = 2; + const constrainedPosition = constrainToArea({ image, zoom, position: wantedPosition, cropArea }); + expect(constrainedPosition).toEqual({ x: 200, y: 200 }); + }); +}); + +describe('imagePlacement', () => { + const image = mockImage(500, 500); + const canvas = mockCanvas(); + const position = { x: 50, y: -30 }; + + it('calculates centered position and scaled size with zoom = 1', () => { + const zoom = 1; + const result = imagePlacement({ canvas, img: image, zoom, position }); + + expect(result).toEqual({ + scaledWidth: 500, + scaledHeight: 500, + imgX: 0, + imgY: -80, + }); + }); + + it('calculates centered position and scaled size with zoom = 2', () => { + const zoom = 2; + const result = imagePlacement({ canvas, img: image, zoom, position }); + expect(result).toEqual({ + scaledWidth: 1000, + scaledHeight: 1000, + imgX: -250, + imgY: -330, + }); + }); +}); + +describe('cropAreaPlacement', () => { + it('calculates centered crop area position within canvas', () => { + const canvas = mockCanvas(); + const cropArea = { width: 200, height: 200, shape: CropForm.Rectangle }; + const cropAreaCenter = cropAreaPlacement({ canvas, cropArea }); + expect(cropAreaCenter).toEqual({ cropAreaX: 100, cropAreaY: 100 }); + }); + + it('calculates centered crop area position with non-square area', () => { + const canvas = mockCanvas(); + const cropArea = { width: 300, height: 200, shape: CropForm.Rectangle }; + const cropAreaCenter = cropAreaPlacement({ canvas, cropArea }); + expect(cropAreaCenter).toEqual({ cropAreaX: 50, cropAreaY: 100 }); + }); +}); + +describe('drawCropArea', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockCtx = { + beginPath: jest.fn(), + arc: jest.fn(), + rect: jest.fn(), + } as unknown as CanvasRenderingContext2D; + + const cropWidth = 100; + const cropHeight = 100; + + it('should draw a circle crop area at the given position', () => { + const cropArea = { width: cropWidth, height: cropHeight, shape: CropForm.Circle }; + + drawCropArea({ ctx: mockCtx, cropArea }); + expect(mockCtx.beginPath).toHaveBeenCalled(); + expect(mockCtx.rect).not.toHaveBeenCalled(); + }); + + it('should draw a rectangle crop area at the given position', () => { + const cropArea = { width: cropWidth, height: cropHeight, shape: CropForm.Rectangle }; + drawCropArea({ ctx: mockCtx, cropArea }); + expect(mockCtx.beginPath).toHaveBeenCalled(); + expect(mockCtx.arc).not.toHaveBeenCalled(); + }); +}); + +describe('normalToLogZoom', () => { + it('calculates zoom correctly within min and max bounds', () => { + const minZoom = 0.5; + const maxZoom = 4; + expect(normalToLogZoom({ value: 0, minZoom, maxZoom })).toBeCloseTo(minZoom); + expect(normalToLogZoom({ value: 100, minZoom, maxZoom })).toBeCloseTo(maxZoom); + expect(normalToLogZoom({ value: 50, minZoom, maxZoom })).toBeCloseTo(Math.sqrt(minZoom * maxZoom)); + }); +}); + +describe('logToNormalZoom', () => { + it('calculates normal zoom correctly within min and max bounds', () => { + const minZoom = 0.5; + const maxZoom = 4; + expect(logToNormalZoom({ value: minZoom, minZoom, maxZoom })).toBeCloseTo(0); + expect(logToNormalZoom({ value: maxZoom, minZoom, maxZoom })).toBeCloseTo(100); + expect(logToNormalZoom({ value: Math.sqrt(minZoom * maxZoom), minZoom, maxZoom })).toBeCloseTo(50); + }); + + it('returns NaN if log scale is zero', () => { + const minZoom = 2; + const maxZoom = 2; + expect(logToNormalZoom({ value: 2, minZoom, maxZoom })).toBe(0); + }); +}); + +describe('calculateMinZoom', () => { + it('calculates minimum zoom to cover crop area', () => { + const cropArea = { width: 200, height: 100, shape: CropForm.Rectangle }; + const image = mockImage(400, 400); + expect(calculateMinZoom({ img: image, cropArea })).toBe(0.5); + const wideImage = mockImage(200, 100); + expect(calculateMinZoom({ img: wideImage, cropArea })).toBe(1); + }); +}); + +describe('validateFile', () => { + const typeError = 'image_upload_component.error_invalid_file_type'; + const sizeError = 'image_upload_component.error_file_size_exceeded'; + const maxSize = 10 * 1024 * 1024; // 10 MB + + it('returns error for undefined file', () => { + expect(validateFile(undefined)).toEqual([typeError]); + }); + + it('returns error for non-image file type', () => { + const file = new File(['dummy content'], 'test.txt', { type: 'text/plain' }); + expect(validateFile(file)).toEqual([typeError]); + }); + + it('returns error for file exceeding max size', () => { + const bigBuffer = new Uint8Array(maxSize + 1); + const file = new File([bigBuffer], 'bigimage.png', { type: 'image/png' }); + expect(validateFile(file)).toEqual([sizeError]); + }); + + it('returns both errors for invalid type and size', () => { + const bigBuffer = new Uint8Array(maxSize + 1); + const file = new File([bigBuffer], 'bigfile.txt', { type: 'text/plain' }); + expect(validateFile(file)).toEqual([sizeError, typeError]); + }); + + it('returns no errors for valid image file', () => { + const file = new File([new Uint8Array(maxSize)], 'image.png', { type: 'image/png' }); + expect(validateFile(file)).toEqual([]); + }); +}); + +describe('isAnimationFile', () => { + it('returns true for .gif files', () => { + expect(isAnimationFile('image/gif')).toBe(true); + expect(isAnimationFile('IMAGE/GIF')).toBe(true); + }); + it('returns true for .apng files', () => { + expect(isAnimationFile('image/apng')).toBe(true); + expect(isAnimationFile('IMAGE/APNG')).toBe(true); + }); + it('returns true for .webp files', () => { + expect(isAnimationFile('image/webp')).toBe(true); + expect(isAnimationFile('IMAGE/WEBP')).toBe(true); + }); + it('returns false for non-animated image types', () => { + expect(isAnimationFile('image/png')).toBe(false); + }); +}); + +describe('isAllowedContentTypesValid', () => { + const dataTypes: IDataType[] = [ + { id: 'comp1', allowedContentTypes: null, maxCount: 1, minCount: 1 }, + { id: 'comp2', allowedContentTypes: ['image/png'], maxCount: 1, minCount: 1 }, + { id: 'comp3', allowedContentTypes: ['image/jpeg'], maxCount: 1, minCount: 1 }, + ]; + + it('returns true when allowedContentTypes is empty', () => { + expect(isAllowedContentTypesValid({ baseComponentId: 'comp1', dataTypes })).toBe(true); + }); + it('returns true when allowedContentTypes includes image/png', () => { + expect(isAllowedContentTypesValid({ baseComponentId: 'comp2', dataTypes })).toBe(true); + }); + it('returns false when allowedContentTypes does not include image/png', () => { + expect(isAllowedContentTypesValid({ baseComponentId: 'comp3', dataTypes })).toBe(false); + }); +}); + +describe('getNewFileName', () => { + it('replaces file extension based on image type to be saved', () => { + expect(getNewFileName({ fileName: 'picture.jpg' })).toBe('picture.png'); + }); +}); + +describe('calculatePositionForZoom', () => { + it('calculates new position to keep image centered on zoom', () => { + const canvas = mockCanvas(); + const img = mockImage(500, 500); + const cropArea = { width: 200, height: 200, shape: CropForm.Rectangle }; + const position = { x: 50, y: 30 }; + const minZoom = calculateMinZoom({ img, cropArea }); + const oldZoom = minZoom; + const newZoom = oldZoom * 2; + const newPosition = calculatePositionForZoom({ canvas, img, position, oldZoom, newZoom, cropArea }); + expect(newPosition).toEqual({ x: 100, y: 60 }); + }); +}); diff --git a/src/layout/ImageUpload/imageUploadUtils.ts b/src/layout/ImageUpload/imageUploadUtils.ts new file mode 100644 index 0000000000..956b545c17 --- /dev/null +++ b/src/layout/ImageUpload/imageUploadUtils.ts @@ -0,0 +1,201 @@ +import type { CropConfig } from 'src/layout/ImageUpload/config.generated'; +import type { IDataType } from 'src/types/shared'; + +export const MAX_ZOOM = 5; +// Always save canvas as PNG to preserve transparency; JPEG is not suitable for circular crops +export const IMAGE_TYPE = 'image/png'; + +export type Position = { x: number; y: number }; +export enum CropForm { + Rectangle = 'rectangle', + Circle = 'circle', +} + +export type CropInternal = { width: number; height: number; shape: CropForm.Rectangle | CropForm.Circle }; +export const getCropArea = (crop?: CropConfig): CropInternal => { + const defaultSize = 250; + + if (!crop) { + return { width: defaultSize, height: defaultSize, shape: CropForm.Circle }; + } + + const isCircle = !crop?.shape || crop.shape === CropForm.Circle; + + const width = (isCircle ? crop.diameter : crop.width) ?? defaultSize; + const height = (isCircle ? crop.diameter : crop.height) ?? defaultSize; + const shape = isCircle ? CropForm.Circle : CropForm.Rectangle; + + return { width, height, shape }; +}; + +interface ConstrainToAreaParams { + image: HTMLImageElement; + zoom: number; + position: Position; + cropArea: CropInternal; +} + +export function constrainToArea({ image, zoom, position, cropArea }: ConstrainToAreaParams): Position { + const scaledWidth = image.width * zoom; + const scaledHeight = image.height * zoom; + + const clampX = scaledWidth > cropArea.width ? (scaledWidth - cropArea.width) / 2 : 0; + const clampY = scaledHeight > cropArea.height ? (scaledHeight - cropArea.height) / 2 : 0; + + const newX = Math.max(-clampX, Math.min(position.x, clampX)); + const newY = Math.max(-clampY, Math.min(position.y, clampY)); + + return { x: newX, y: newY }; +} + +interface ImagePlacementParams { + canvas: HTMLCanvasElement; + img: HTMLImageElement; + zoom: number; + position: Position; +} + +export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => { + const scaledWidth = img.width * zoom; + const scaledHeight = img.height * zoom; + const imgX = (canvas.width - scaledWidth) / 2 + position.x; + const imgY = (canvas.height - scaledHeight) / 2 + position.y; + + return { imgX, imgY, scaledWidth, scaledHeight }; +}; + +type CropAreaPlacementParams = { canvas: HTMLCanvasElement; cropArea: CropInternal }; +type CropAreaPlacement = { cropAreaX: number; cropAreaY: number }; + +export const cropAreaPlacement = ({ canvas, cropArea }: CropAreaPlacementParams): CropAreaPlacement => { + const cropAreaX = (canvas.width - cropArea.width) / 2; + const cropAreaY = (canvas.height - cropArea.height) / 2; + return { cropAreaX, cropAreaY }; +}; + +interface DrawCropAreaParams { + ctx: CanvasRenderingContext2D; + cropArea: CropInternal; + x?: number; + y?: number; +} + +export function drawCropArea({ ctx, x = 0, y = 0, cropArea }: DrawCropAreaParams) { + const { width, height, shape } = cropArea; + ctx.beginPath(); + if (shape === CropForm.Circle) { + ctx.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2); + } else { + ctx.rect(x, y, width, height); + } +} + +interface ZoomParams { + minZoom: number; + maxZoom: number; +} + +interface CalculateZoomParams extends ZoomParams { + value: number; +} + +function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } { + const logMin = Math.log(minZoom); + const logMax = Math.log(maxZoom); + return { logScale: (logMax - logMin) / 100, logMin }; +} + +export function normalToLogZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number { + const { logScale, logMin } = getLogValues({ minZoom, maxZoom }); + return Math.exp(logMin + logScale * value); +} + +export function logToNormalZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number { + const { logScale, logMin } = getLogValues({ minZoom, maxZoom }); + if (logScale === 0) { + return 0; + } + return (Math.log(value) - logMin) / logScale; +} + +type CalculateMinZoomParams = { cropArea: CropInternal; img: HTMLImageElement }; +export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) => + Math.max(cropArea.width / img.width, cropArea.height / img.height); + +export const validateFile = (file?: File): string[] => { + const MAX_FILE_SIZE_MB = 10; + const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; + const errors: string[] = []; + + const typeError = 'image_upload_component.error_invalid_file_type'; + const sizeError = 'image_upload_component.error_file_size_exceeded'; + + if (!file) { + errors.push(typeError); + return errors; + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + errors.push(sizeError); + } + + const isTypeInvalid = !file.type.startsWith('image/'); + if (isTypeInvalid) { + errors.push(typeError); + } + + return errors; +}; + +export const isAnimationFile = (fileType: string): boolean => { + const animationMimeTypes = ['image/gif', 'image/apng', 'image/webp']; + return animationMimeTypes.includes(fileType.toLowerCase()); +}; + +type AllowedImageTypeParams = { + baseComponentId: string; + dataTypes: IDataType[]; +}; + +export const isAllowedContentTypesValid = ({ baseComponentId, dataTypes }: AllowedImageTypeParams) => { + const dataTypeItem = dataTypes.find((dt) => dt.id === baseComponentId); + const allowedTypes = (dataTypeItem?.allowedContentTypes ?? []).map((type) => type.toLowerCase()); + + return allowedTypes.length === 0 || allowedTypes.includes(IMAGE_TYPE); +}; + +export const getNewFileName = ({ fileName }: { fileName: string }) => { + const nameWithoutExtension = fileName.replace(/\.[^/.]+$/, ''); + return `${nameWithoutExtension}.png`; +}; + +type CalculateNewPositionProps = { + canvas: HTMLCanvasElement; + img: HTMLImageElement; + position: Position; + oldZoom: number; + newZoom: number; + cropArea: CropInternal; +}; + +export const calculatePositionForZoom = ({ + canvas, + img, + position, + oldZoom, + newZoom, + cropArea, +}: CalculateNewPositionProps) => { + const viewportCenterX = canvas.width / 2; + const viewportCenterY = canvas.height / 2; + + const { imgX, imgY } = imagePlacement({ canvas, img, zoom: oldZoom, position }); + const imageCenterX = (viewportCenterX - imgX) / oldZoom; + const imageCenterY = (viewportCenterY - imgY) / oldZoom; + + const newPosition = { + x: viewportCenterX - imageCenterX * newZoom - (canvas.width - img.width * newZoom) / 2, + y: viewportCenterY - imageCenterY * newZoom - (canvas.height - img.height * newZoom) / 2, + }; + return constrainToArea({ image: img, zoom: newZoom, position: newPosition, cropArea }); +}; diff --git a/src/layout/ImageUpload/index.tsx b/src/layout/ImageUpload/index.tsx new file mode 100644 index 0000000000..d0f5898e49 --- /dev/null +++ b/src/layout/ImageUpload/index.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef } from 'react'; +import type { JSX } from 'react'; + +import { useAttachmentsFor } from 'src/features/attachments/hooks'; +import { useFileUploaderDataBindingsValidation } from 'src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation'; +import { ImageUploadDef } from 'src/layout/ImageUpload/config.def.generated'; +import { ImageUploadComponent } from 'src/layout/ImageUpload/ImageUploadComponent'; +import { ImageUploadSummary2 } from 'src/layout/ImageUpload/ImageUploadSummary2/ImageUploadSummary2'; +import type { LayoutLookups } from 'src/features/form/layout/makeLayoutLookups'; +import type { PropsFromGenericComponent } from 'src/layout'; +import type { IDataModelBindings } from 'src/layout/layout'; +import type { ExprResolver, SummaryRendererProps } from 'src/layout/LayoutComponent'; +import type { Summary2Props } from 'src/layout/Summary2/SummaryComponent2/types'; + +export class ImageUpload extends ImageUploadDef { + useDisplayData(baseComponentId: string): string { + const attachments = useAttachmentsFor(baseComponentId); + return attachments.map((a) => a.data.filename).join(', '); + } + + render = forwardRef>( + function LayoutComponentImageUploadRender(props, _): JSX.Element | null { + return ; + }, + ); + + isDataModelBindingsRequired(baseComponentId: string, layoutLookups: LayoutLookups): boolean { + // Data model bindings are only required when the component is defined inside a repeating group + const parentId = layoutLookups.componentToParent[baseComponentId]; + const parentLayout = parentId && parentId.type === 'node' ? layoutLookups.allComponents[parentId.id] : undefined; + return parentLayout?.type === 'RepeatingGroup'; + } + + useDataModelBindingValidation(baseComponentId: string, bindings: IDataModelBindings<'ImageUpload'>): string[] { + return useFileUploaderDataBindingsValidation(baseComponentId, bindings); + } + + renderSummary(_props: SummaryRendererProps): JSX.Element | null { + throw new Error('ImageUpload is not supported in Summary; use Summary2 instead.'); + } + + renderSummary2(props: Summary2Props): JSX.Element | null { + return ; + } + + evalExpressions(props: ExprResolver<'ImageUpload'>) { + return { + ...this.evalDefaultExpressions(props), + }; + } +} diff --git a/src/layout/Option/OptionSummary.tsx b/src/layout/Option/OptionSummary.tsx index 4f30c1b648..fe42c5a551 100644 --- a/src/layout/Option/OptionSummary.tsx +++ b/src/layout/Option/OptionSummary.tsx @@ -30,7 +30,7 @@ export const OptionSummary = ({ targetBaseComponentId }: Summary2Props) => { displayData={displayData} errors={errors} targetBaseComponentId={targetBaseComponentId} - hideEditButton + hideEditButton={true} isCompact={compact} emptyFieldText={emptyFieldText} /> diff --git a/src/layout/Summary2/CommonSummaryComponents/SingleValueSummary.tsx b/src/layout/Summary2/CommonSummaryComponents/SingleValueSummary.tsx index 4d466c66d0..da0c262cb0 100644 --- a/src/layout/Summary2/CommonSummaryComponents/SingleValueSummary.tsx +++ b/src/layout/Summary2/CommonSummaryComponents/SingleValueSummary.tsx @@ -12,7 +12,7 @@ type SingleValueSummaryProps = { title: React.ReactNode; errors?: BaseValidation[]; targetBaseComponentId: string; - displayData?: string; + displayData?: string | React.ReactNode; hideEditButton?: boolean; multiline?: boolean; isCompact: boolean | undefined; diff --git a/test/e2e/fixtures/test-cat.jpg b/test/e2e/fixtures/test-cat.jpg new file mode 100644 index 0000000000..25af3e6063 Binary files /dev/null and b/test/e2e/fixtures/test-cat.jpg differ diff --git a/test/e2e/integration/component-library/image-upload.ts b/test/e2e/integration/component-library/image-upload.ts new file mode 100644 index 0000000000..4014965668 --- /dev/null +++ b/test/e2e/integration/component-library/image-upload.ts @@ -0,0 +1,78 @@ +import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; +import { makeTestFile, uploadImageAndVerify } from 'test/e2e/support/apps/component-library/uploadImageAndVerify'; + +const appFrontend = new AppFrontend(); + +const fileName1 = 'uploadThis1.png'; + +describe('ImageUpload component', () => { + beforeEach(() => { + cy.startAppInstance(appFrontend.apps.componentLibrary); + }); + + it('is able to upload image correctly', () => { + cy.gotoNavPage('Bildeopplasting'); + uploadImageAndVerify(fileName1); + }); + + it('is able to cancel the cropping process', () => { + cy.gotoNavPage('Bildeopplasting'); + uploadImageAndVerify(fileName1); + + cy.findByRole('button', { name: /Avbryt/i }).click(); + cy.get('canvas').should('not.exist'); + cy.get('[data-componentId="ImageUploadPage-ImageUpload"]').should('be.visible'); + }); + + it('is able to upload, crop and save', () => { + cy.gotoNavPage('Bildeopplasting'); + uploadImageAndVerify(fileName1); + + cy.findByRole('button', { name: /Lagre/i }).click(); + cy.get('canvas').should('not.exist'); + cy.findByRole('img', { name: /uploadThis1.png/ }).should('be.visible'); + }); + + it('is able to delete a saved image', () => { + cy.gotoNavPage('Bildeopplasting'); + + uploadImageAndVerify(fileName1); + + cy.findByRole('button', { name: /Lagre/i }).click(); + cy.get('canvas').should('not.exist'); + cy.get('img').should('be.visible'); + cy.findByRole('button', { name: /Slett bildet/i }).click(); + cy.findByRole('img', { name: /uploadThis1.jpg/ }).should('not.exist'); + cy.get('canvas').should('not.exist'); + cy.get('[data-componentId="ImageUploadPage-ImageUpload"]').should('be.visible'); + }); + + it('is able to replace an uploaded image before saving', () => { + cy.gotoNavPage('Bildeopplasting'); + uploadImageAndVerify(fileName1); + const fileName2 = 'uploadThis2.png'; + const newImageUrl = 'test/e2e/fixtures/test-cat.jpg'; + cy.get('canvas').then(($canvas) => { + const ctx = $canvas[0].getContext('2d'); + if (!ctx) { + throw new Error('Could not get canvas context'); + } + const originalPixels = ctx.getImageData(0, 0, $canvas[0].width, $canvas[0].height).data; + + cy.contains('label', 'Bytt bilde').selectFile(makeTestFile(fileName2, newImageUrl), { force: true }); + cy.get('canvas').should(($newCanvas) => { + const ctx2 = $newCanvas[0].getContext('2d'); + if (!ctx2) { + throw new Error('Could not get canvas context'); + } + const newPixels = ctx2.getImageData(0, 0, $newCanvas[0].width, $newCanvas[0].height).data; + + const pixelsChanged = newPixels.some((v, i) => v !== originalPixels[i]); + expect(pixelsChanged).to.be.true; + }); + }); + + cy.findByRole('button', { name: /Lagre/i }).click(); + cy.findByRole('img', { name: /uploadThis2.png/ }).should('be.visible'); + }); +}); diff --git a/test/e2e/support/apps/component-library/uploadImageAndVerify.ts b/test/e2e/support/apps/component-library/uploadImageAndVerify.ts new file mode 100644 index 0000000000..e1351a7a2c --- /dev/null +++ b/test/e2e/support/apps/component-library/uploadImageAndVerify.ts @@ -0,0 +1,23 @@ +export const makeTestFile = (fileName: string, newImageUrl?: string): Cypress.FileReference => ({ + fileName, + mimeType: 'image/png', + lastModified: Date.now(), + contents: newImageUrl ?? 'test/e2e/fixtures/map-tile.png', +}); + +export const uploadImageAndVerify = (fileName: string) => { + cy.get('[data-componentId="ImageUploadPage-ImageUpload"]').should('be.visible'); + + cy.get('[data-componentId="ImageUploadPage-ImageUpload"]') + .find('input[type="file"]') + .selectFile(makeTestFile(fileName), { force: true }); + + cy.get('canvas').should('be.visible'); + cy.get('canvas').then(($canvas) => { + const canvas = $canvas[0] as HTMLCanvasElement; + const ctx = canvas.getContext('2d'); + const data = ctx?.getImageData(0, 0, canvas.width, canvas.height).data; + const hasImage = data && Array.from(data).some((value, index) => index % 4 !== 3 && value !== 0); + expect(hasImage).to.be.true; + }); +};