From 851efe264ec220c1b2edb60efc6fe6cabeddcb7c Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:55:50 +0200 Subject: [PATCH 1/3] chg: :arrow_up: update dependencies --- package.json | 12 ++- pnpm-lock.yaml | 145 ++++++++++++++++------------------ src/common/constants.ts | 3 +- src/utils/functions.ts | 12 +-- src/utils/getImagesFromPDF.ts | 18 +---- src/utils/getPDFPageCount.ts | 6 +- src/utils/image.ts | 3 +- src/utils/pdf.ts | 28 +++++++ src/utils/pdfWorker.ts | 5 ++ tsconfig.json | 2 +- vite.config.ts | 3 + 11 files changed, 122 insertions(+), 115 deletions(-) create mode 100644 src/utils/pdf.ts create mode 100644 src/utils/pdfWorker.ts diff --git a/package.json b/package.json index 9592db5..51659df 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,14 @@ "CHANGELOG.md" ], "engines": { - "node": ">=22" + "node": ">=22.13" }, "packageManager": "pnpm@12.8.1", "dependencies": { "heic2any": "^0.0.4", - "konva": "^8.1.3", - "pdfjs-dist": "3.11.174", - "utif": "^3.1.0", - "uuid": "^8.3.2" + "konva": "^10.7.0", + "pdfjs-dist": "^6.3.289", + "utif": "^3.1.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -46,8 +45,7 @@ "@types/node": "^24.19.0", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", - "@types/utif": "^3.0.1", - "@types/uuid": "^8.3.1", + "@types/utif": "^3.0.6", "@vitejs/plugin-react": "^6.1.1", "cypress": "^16.1.0", "eslint": "^10.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7807099..640317c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,17 +175,14 @@ importers: specifier: ^0.0.4 version: 0.0.4 konva: - specifier: ^8.1.3 - version: 8.4.3 + specifier: ^10.7.0 + version: 10.7.0 pdfjs-dist: - specifier: ^4.2.67 - version: 4.10.38 + specifier: ^6.3.289 + version: 6.3.289 utif: specifier: ^3.1.0 version: 3.1.0 - uuid: - specifier: ^11.1.1 - version: 11.1.1 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -212,11 +209,8 @@ importers: specifier: ^19.2.3 version: 19.3.0(@types/react@19.3.0) '@types/utif': - specifier: ^3.0.1 + specifier: ^3.0.6 version: 3.0.6 - '@types/uuid': - specifier: ^8.3.1 - version: 8.3.4 '@vitejs/plugin-react': specifier: ^6.1.1 version: 6.1.1(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)) @@ -522,79 +516,79 @@ packages: '@microsoft/tsdoc@0.17.0': resolution: {integrity: sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==} - '@napi-rs/canvas-android-arm64@0.1.100': - resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + '@napi-rs/canvas-android-arm64@1.0.9': + resolution: {integrity: sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@0.1.100': - resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + '@napi-rs/canvas-darwin-arm64@1.0.9': + resolution: {integrity: sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@0.1.100': - resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + '@napi-rs/canvas-darwin-x64@1.0.9': + resolution: {integrity: sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': - resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.9': + resolution: {integrity: sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@0.1.100': - resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + '@napi-rs/canvas-linux-arm64-gnu@1.0.9': + resolution: {integrity: sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-arm64-musl@0.1.100': - resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + '@napi-rs/canvas-linux-arm64-musl@1.0.9': + resolution: {integrity: sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': - resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + '@napi-rs/canvas-linux-riscv64-gnu@1.0.9': + resolution: {integrity: sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-gnu@0.1.100': - resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + '@napi-rs/canvas-linux-x64-gnu@1.0.9': + resolution: {integrity: sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-musl@0.1.100': - resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + '@napi-rs/canvas-linux-x64-musl@1.0.9': + resolution: {integrity: sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/canvas-win32-arm64-msvc@0.1.100': - resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + '@napi-rs/canvas-win32-arm64-msvc@1.0.9': + resolution: {integrity: sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@0.1.100': - resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + '@napi-rs/canvas-win32-x64-msvc@1.0.9': + resolution: {integrity: sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@0.1.100': - resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + '@napi-rs/canvas@1.0.9': + resolution: {integrity: sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==} engines: {node: '>= 10'} '@oxc-project/types@0.151.0': @@ -784,9 +778,6 @@ packages: '@types/utif@3.0.6': resolution: {integrity: sha512-4XHDJkLHoUeLA2P/VOd6dpDJpSTpV84tN9EyHojZrW22jtUR0VrdLwVRUAw+oI3g72vSzOo+YQOzfwcW6WYHLw==} - '@types/uuid@8.3.4': - resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} - '@typescript-eslint/eslint-plugin@8.71.0': resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1535,8 +1526,16 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - konva@8.4.3: - resolution: {integrity: sha512-ARqdgAbdNIougRlOKvkQwHlGhXPRBV4KvhCP+qoPpGoVQwwiJe4Hkdu4HHdRPb9rGUp04jDTAxBzEwBsE272pg==} + konva@10.7.0: + resolution: {integrity: sha512-2CwuytBrOlTh7WHCSeXyUeysB2rpwHruUUDD5etYtrzijkCVH+00+CQz5fJUGI0hm7mzxxDLYLw4Wpo+8QGJ4g==} + peerDependencies: + canvas: ^3.0.0 + skia-canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + skia-canvas: + optional: true levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} @@ -1754,9 +1753,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pdfjs-dist@4.10.38: - resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} - engines: {node: '>=20'} + pdfjs-dist@6.3.289: + resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==} + engines: {node: '>=22.13.0 || >=24'} pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -2123,10 +2122,6 @@ packages: utif@3.1.0: resolution: {integrity: sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==} - uuid@11.1.1: - resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} - hasBin: true - verror@1.10.0: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} @@ -2544,52 +2539,52 @@ snapshots: '@microsoft/tsdoc@0.17.0': {} - '@napi-rs/canvas-android-arm64@0.1.100': + '@napi-rs/canvas-android-arm64@1.0.9': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.100': + '@napi-rs/canvas-darwin-arm64@1.0.9': optional: true - '@napi-rs/canvas-darwin-x64@0.1.100': + '@napi-rs/canvas-darwin-x64@1.0.9': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.9': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + '@napi-rs/canvas-linux-arm64-gnu@1.0.9': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.100': + '@napi-rs/canvas-linux-arm64-musl@1.0.9': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + '@napi-rs/canvas-linux-riscv64-gnu@1.0.9': optional: true - '@napi-rs/canvas-linux-x64-gnu@0.1.100': + '@napi-rs/canvas-linux-x64-gnu@1.0.9': optional: true - '@napi-rs/canvas-linux-x64-musl@0.1.100': + '@napi-rs/canvas-linux-x64-musl@1.0.9': optional: true - '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + '@napi-rs/canvas-win32-arm64-msvc@1.0.9': optional: true - '@napi-rs/canvas-win32-x64-msvc@0.1.100': + '@napi-rs/canvas-win32-x64-msvc@1.0.9': optional: true - '@napi-rs/canvas@0.1.100': + '@napi-rs/canvas@1.0.9': optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.100 - '@napi-rs/canvas-darwin-arm64': 0.1.100 - '@napi-rs/canvas-darwin-x64': 0.1.100 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 - '@napi-rs/canvas-linux-arm64-musl': 0.1.100 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 - '@napi-rs/canvas-linux-x64-gnu': 0.1.100 - '@napi-rs/canvas-linux-x64-musl': 0.1.100 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 - '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + '@napi-rs/canvas-android-arm64': 1.0.9 + '@napi-rs/canvas-darwin-arm64': 1.0.9 + '@napi-rs/canvas-darwin-x64': 1.0.9 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.9 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.9 + '@napi-rs/canvas-linux-arm64-musl': 1.0.9 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.9 + '@napi-rs/canvas-linux-x64-gnu': 1.0.9 + '@napi-rs/canvas-linux-x64-musl': 1.0.9 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.9 + '@napi-rs/canvas-win32-x64-msvc': 1.0.9 optional: true '@oxc-project/types@0.151.0': {} @@ -2732,8 +2727,6 @@ snapshots: dependencies: '@types/node': 24.19.0 - '@types/uuid@8.3.4': {} - '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3503,7 +3496,7 @@ snapshots: kolorist@1.8.0: {} - konva@8.4.3: {} + konva@10.7.0: {} levn@0.4.1: dependencies: @@ -3691,9 +3684,9 @@ snapshots: pathe@2.0.3: {} - pdfjs-dist@4.10.38: + pdfjs-dist@6.3.289: optionalDependencies: - '@napi-rs/canvas': 0.1.100 + '@napi-rs/canvas': 1.0.9 pend@1.2.0: {} @@ -4034,8 +4027,6 @@ snapshots: dependencies: pako: 1.0.11 - uuid@11.1.1: {} - verror@1.10.0: dependencies: assert-plus: 1.0.0 diff --git a/src/common/constants.ts b/src/common/constants.ts index f938924..a1c1ab8 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -38,7 +38,8 @@ export const DEFAULT_ANNOTATION_VIEWER_OPTIONS = { }, onMouseEnter: (polygon: Line): void => { const stroke = polygon.stroke(); - polygon.setAttr('fill', `${stroke}40`); + const fill = typeof stroke === 'string' ? `${stroke}40` : stroke; + polygon.setAttr('fill', fill); polygon.draw(); }, shapeConfig: { diff --git a/src/utils/functions.ts b/src/utils/functions.ts index a27247f..1b6ed94 100644 --- a/src/utils/functions.ts +++ b/src/utils/functions.ts @@ -4,14 +4,8 @@ import type { Stage } from 'konva/lib/Stage'; import { KonvaRefs } from '@/common/constants'; -/** Konva 8 types `findOne` as non-optional although it returns undefined on miss. */ -const findOne = ( - stage: Konva.Stage, - selector: string, -): Konva.Node | undefined => stage.findOne(selector); - export const drawLayer = (stage: Konva.Stage): void => { - const shapesLayer = findOne(stage, `#${KonvaRefs.ShapesLayer}`); + const shapesLayer = stage.findOne(`#${KonvaRefs.ShapesLayer}`); if (shapesLayer instanceof Konva.Layer) { shapesLayer.batchDraw(); } else { @@ -24,7 +18,7 @@ export const drawShape = ( id: string | number, config: LineConfig, ): void => { - const shape = findOne(stage, `#${id.toString()}`); + const shape = stage.findOne(`#${id.toString()}`); if (shape instanceof Konva.Shape) { shape.setAttrs(config); shape.draw(); @@ -48,7 +42,7 @@ export const setShapeConfig = ( id: string | number, config: LineConfig, ): void => { - const shape = findOne(stage, `#${id.toString()}`); + const shape = stage.findOne(`#${id.toString()}`); if (shape) { shape.setAttrs(config); } else { diff --git a/src/utils/getImagesFromPDF.ts b/src/utils/getImagesFromPDF.ts index 1c71933..4b00061 100644 --- a/src/utils/getImagesFromPDF.ts +++ b/src/utils/getImagesFromPDF.ts @@ -1,9 +1,8 @@ import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'; -import { getDocument, GlobalWorkerOptions, version } from 'pdfjs-dist'; import { MAX_PDF_SCALE, PDF_RESOLUTION } from '@/common/constants'; -GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.js`; +import { loadPdfDocument } from './pdf'; const getImageFromPage = async ( _document: PDFDocumentProxy, @@ -12,10 +11,6 @@ const getImageFromPage = async ( ): Promise => { const page: PDFPageProxy = await _document.getPage(pageNumber); const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (!context) { - throw new Error('Could not acquire a 2D canvas context'); - } const [, , width, height] = page.view; if (width === undefined || height === undefined) { throw new Error('Invalid PDF page view'); @@ -25,13 +20,8 @@ const getImageFromPage = async ( const viewport = page.getViewport({ scale: safeScale }); canvas.height = viewport.height; canvas.width = viewport.width; - const renderContext = { - canvasContext: context, - viewport, - }; - return await page - .render(renderContext) - .promise.then(() => canvas.toDataURL()); + await page.render({ canvas, viewport }).promise; + return canvas.toDataURL(); }; export default async function getImagesFromPDF( @@ -40,7 +30,7 @@ export default async function getImagesFromPDF( onSuccess?: () => void, resolution?: number, ): Promise { - const pdf = await getDocument(file).promise; + const pdf = await loadPdfDocument(file); if (pdf.numPages > maxPages) { const error = new Error('Too many pages'); error.name = 'TooManyPagesError'; diff --git a/src/utils/getPDFPageCount.ts b/src/utils/getPDFPageCount.ts index a8e3eb5..3dfbc69 100644 --- a/src/utils/getPDFPageCount.ts +++ b/src/utils/getPDFPageCount.ts @@ -1,8 +1,6 @@ -import { getDocument, GlobalWorkerOptions, version } from 'pdfjs-dist'; - -GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.js`; +import { loadPdfDocument } from './pdf'; export default async function getPDFPageCount(file: string): Promise { - const pdf = await getDocument(file).promise; + const pdf = await loadPdfDocument(file); return pdf.numPages; } diff --git a/src/utils/image.ts b/src/utils/image.ts index 34b2e5f..9eacac4 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -1,4 +1,3 @@ -import heic2any from 'heic2any'; import type Konva from 'konva'; import type { Stage } from 'konva/lib/Stage'; import UTIF from 'utif'; @@ -79,8 +78,8 @@ export const tiffToJpg = async (blob: Blob): Promise => { }; export const heicToJpg = async (blob: Blob): Promise => { + const { default: heic2any } = await import('heic2any'); const result = await heic2any({ blob, toType: 'image/jpeg' }); - // Multi-image HEIC files yield one Blob per image; only the first is shown. const firstImage = Array.isArray(result) ? result[0] : result; if (firstImage === undefined) { throw new Error('HEIC file contains no image'); diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts new file mode 100644 index 0000000..0bd1c68 --- /dev/null +++ b/src/utils/pdf.ts @@ -0,0 +1,28 @@ +import type * as Pdfjs from 'pdfjs-dist'; +import type { PDFDocumentProxy } from 'pdfjs-dist'; + +let pdfjsModule: Promise | undefined; + +/** + * Loads pdf.js and its worker on first use, so consumers need no CDN access or + * manual worker configuration. Both live in lazy chunks outside the main bundle. + */ +const loadPdfjs = async (): Promise => { + pdfjsModule ??= Promise.all([import('pdfjs-dist'), import('./pdfWorker')]) + .then(([pdfjs, { createPdfWorker }]) => { + pdfjs.GlobalWorkerOptions.workerPort = createPdfWorker(); + return pdfjs; + }) + .catch((error: unknown) => { + pdfjsModule = undefined; + throw error; + }); + return await pdfjsModule; +}; + +export const loadPdfDocument = async ( + file: string, +): Promise => { + const { getDocument } = await loadPdfjs(); + return await getDocument({ url: file }).promise; +}; diff --git a/src/utils/pdfWorker.ts b/src/utils/pdfWorker.ts new file mode 100644 index 0000000..fa2ed05 --- /dev/null +++ b/src/utils/pdfWorker.ts @@ -0,0 +1,5 @@ +// Inlined (blob) worker: a file-relative worker URL breaks once consumers' +// bundlers pre-bundle or relocate dist/index.js, an inline worker does not. +import PdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker&inline'; + +export const createPdfWorker = (): Worker => new PdfWorker(); diff --git a/tsconfig.json b/tsconfig.json index 676bf3d..318602a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", - "types": ["node", "cypress"], + "types": ["node", "cypress", "vite/client"], "strict": true, "noUncheckedIndexedAccess": true, "noImplicitReturns": true, diff --git a/vite.config.ts b/vite.config.ts index 45bbcfb..2bc2584 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,9 @@ export default defineConfig({ bundleTypes: true, }), ], + worker: { + format: 'es', + }, build: { lib: { entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)), From 835c20d597a6e895e61a23c540f92716cde723b0 Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:53:37 +0200 Subject: [PATCH 2/3] add integration tests --- .github/workflows/_test-integration.yml | 40 +++ .github/workflows/pull-request.yml | 4 + cypress.config.ts | 13 +- cypress/integration/api.spec.ts | 55 ++++ cypress/integration/helpers.ts | 79 ++++++ cypress/integration/images.spec.tsx | 124 +++++++++ cypress/integration/lens.spec.tsx | 80 ++++++ cypress/integration/pdf.spec.tsx | 133 ++++++++++ cypress/integration/viewer.spec.tsx | 331 ++++++++++++++++++++++++ package.json | 3 +- src/common/types.ts | 6 + src/utils/pdfWorker.ts | 2 - src/utils/zoom.ts | 2 +- 13 files changed, 867 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/_test-integration.yml create mode 100644 cypress/integration/api.spec.ts create mode 100644 cypress/integration/helpers.ts create mode 100644 cypress/integration/images.spec.tsx create mode 100644 cypress/integration/lens.spec.tsx create mode 100644 cypress/integration/pdf.spec.tsx create mode 100644 cypress/integration/viewer.spec.tsx diff --git a/.github/workflows/_test-integration.yml b/.github/workflows/_test-integration.yml new file mode 100644 index 0000000..1836bf6 --- /dev/null +++ b/.github/workflows/_test-integration.yml @@ -0,0 +1,40 @@ +name: Integration Tests + +on: + workflow_call: + +permissions: + contents: read + +jobs: + cypress: + name: Run Cypress integration tests against the built library + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v7 + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Cypress run + uses: cypress-io/github-action@c541a95ea92d9239347a2355f2e895cd4b3f8060 # v7.4.6 + with: + install-command: pnpm install --frozen-lockfile + build: pnpm build + command: pnpm cy:run:integration + + - name: Upload screenshots + if: failure() + uses: actions/upload-artifact@v7 + with: + name: cypress-integration-screenshots + path: cypress/screenshots + if-no-files-found: ignore diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 1bc0406..ccc7b67 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -17,3 +17,7 @@ jobs: uses: ./.github/workflows/_test-components.yml needs: static_analysis secrets: inherit + test_integration: + uses: ./.github/workflows/_test-integration.yml + needs: static_analysis + secrets: inherit diff --git a/cypress.config.ts b/cypress.config.ts index 98ceb98..e19975e 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -20,13 +20,24 @@ export default defineConfig({ return config; }, - specPattern: 'src/**/*.spec.{js,ts,jsx,tsx}', + specPattern: [ + 'src/**/*.spec.{js,ts,jsx,tsx}', + 'cypress/integration/**/*.spec.{ts,tsx}', + ], devServer: { framework: 'react', bundler: 'vite', viteConfig: defineViteConfig({ assetsInclude: ['**/*.tiff', '**/*.heic'], resolve: { tsconfigPaths: true }, + optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + ], + }, }), }, }, diff --git a/cypress/integration/api.spec.ts b/cypress/integration/api.spec.ts new file mode 100644 index 0000000..ac038b2 --- /dev/null +++ b/cypress/integration/api.spec.ts @@ -0,0 +1,55 @@ +import { loadDist } from './helpers'; + +/** + * Guards the public runtime surface of the package. A missing or renamed + * export is a breaking change for consumers and must be deliberate. + */ +describe('built library public API', () => { + const expectedFunctions = [ + 'AnnotationLens', + 'AnnotationViewer', + 'dataURItoBlob', + 'drawLayer', + 'drawShape', + 'drawShapes', + 'getImagesFromPDF', + 'getPDFPageCount', + 'getZoomScale', + 'setShapeConfig', + 'toBase64', + ]; + + it('exposes exactly the documented runtime exports, all of them functions', () => { + loadDist().then((lib) => { + expect(Object.keys(lib).sort()).to.deep.equal(expectedFunctions); + expectedFunctions.forEach((name) => { + expect(lib[name as keyof typeof lib]).to.be.a('function'); + }); + }); + }); + + it('builds data URLs with toBase64, defaulting to image/jpeg', () => { + loadDist().then(({ toBase64 }) => { + expect(toBase64('AAAA')).to.equal('data:image/jpeg;base64,AAAA'); + expect(toBase64('AAAA', 'application/pdf')).to.equal( + 'data:application/pdf;base64,AAAA', + ); + }); + }); + + it('round-trips a data URL through dataURItoBlob', () => { + loadDist().then(({ dataURItoBlob, toBase64 }) => { + const payload = btoa('hello'); + const blob = dataURItoBlob(toBase64(payload, 'image/png')); + expect(blob.type).to.equal('image/png'); + expect(blob.size).to.equal(5); + cy.wrap(blob.text()).should('equal', 'hello'); + }); + }); + + it('rejects malformed input in dataURItoBlob instead of producing an empty blob', () => { + loadDist().then(({ dataURItoBlob }) => { + expect(() => dataURItoBlob('not-a-data-url')).to.throw(); + }); + }); +}); diff --git a/cypress/integration/helpers.ts b/cypress/integration/helpers.ts new file mode 100644 index 0000000..f0c735b --- /dev/null +++ b/cypress/integration/helpers.ts @@ -0,0 +1,79 @@ +import type * as Library from '@/index'; + +export type DistLibrary = typeof Library; + +/** + * Path of the built entry point, relative to this file. It is kept in a + * variable so that neither tsc nor Vite resolve it ahead of time: the + * integration suite must stay type-checkable without a prior build, and the + * browser must receive the production chunks exactly as Rolldown emitted them. + */ +const distEntry = '../../dist/index.js'; + +export const CONTAINER_HEIGHT = 800; +export const CONTAINER_WIDTH = 700; +export const CONTAINER_STYLE = { + height: CONTAINER_HEIGHT, + width: CONTAINER_WIDTH, +}; + +/** + * Imports the built library (dist/) at runtime, as a consumer would, so that + * production-only behaviour such as lazy chunks and the inlined pdf.js worker + * is exercised rather than the TypeScript sources. + * + * Cypress keeps the same window for every test of a spec file, so module-level + * state inside dist/index.js (such as the memoised pdf.js worker) survives + * between tests. Pass `fresh: true` to cache-bust the entry point and get a + * brand new module instance when a test asserts on first-use behaviour. + */ +export const loadDist = ( + options: { fresh?: boolean } = {}, +): Cypress.Chainable => { + const specifier = + options.fresh === true + ? `${distEntry}?fresh=${String(Date.now())}` + : distEntry; + return cy.wrap( + (import(/* @vite-ignore */ specifier) as Promise).catch( + (error: unknown) => { + throw new Error( + `Could not load dist/index.js, run "pnpm build" first. ${String(error)}`, + ); + }, + ), + ); +}; + +/** + * Names of the network resources fetched so far by the test window whose URL + * contains the given fragment. Used to prove that a lazy chunk is only + * downloaded once the feature that needs it is used. + * + * Resource entries accumulate for the whole spec file, so the "not yet + * loaded" assertion is only meaningful if no earlier test in the same file + * used the feature. + */ +export const loadedResources = ( + win: Cypress.AUTWindow, + fragment: string, +): string[] => + win.performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .filter((name) => name.includes(fragment)); + +/** Reads the natural dimensions of a data URL or object URL image. */ +export const imageDimensions = async ( + source: string, +): Promise<{ width: number; height: number }> => + await new Promise((resolve, reject) => { + const image = new Image(); + image.onload = (): void => { + resolve({ width: image.naturalWidth, height: image.naturalHeight }); + }; + image.onerror = (): void => { + reject(new Error('Image could not be decoded')); + }; + image.src = source; + }); diff --git a/cypress/integration/images.spec.tsx b/cypress/integration/images.spec.tsx new file mode 100644 index 0000000..71584ca --- /dev/null +++ b/cypress/integration/images.spec.tsx @@ -0,0 +1,124 @@ +import dummyImageHEIC from 'cypress/assets/demo.heic'; +import dummyImage from 'cypress/assets/demo.jpg'; +import dummyImageTIFF from 'cypress/assets/demo.tiff'; +import multiPage from 'cypress/assets/multi-page.pdf'; + +import { CONTAINER_STYLE, loadDist, loadedResources } from './helpers'; + +const containerId = 'annotationViewer'; + +/** + * Asserts that the viewer ended up with a drawn image by checking that the + * canvas pixels are not fully transparent. + */ +const expectPaintedCanvas = (): void => { + cy.get(`#${containerId} canvas`) + .first() + .should(($canvas) => { + const canvas = $canvas[0] as HTMLCanvasElement; + const context = canvas.getContext('2d'); + if (context === null) { + throw new Error('Canvas has no 2d context'); + } + const { data } = context.getImageData(0, 0, canvas.width, canvas.height); + let opaquePixels = 0; + for (let index = 3; index < data.length; index += 4) { + if (data[index] !== 0) { + opaquePixels += 1; + } + } + expect(opaquePixels).to.be.greaterThan(0); + }); +}; + +/** + * Format conversion runs through utif (TIFF) and a lazily loaded heic2any + * chunk (HEIC). These paths rely on the production chunking, so they are + * verified against the built viewer. + */ +describe('built library image formats', () => { + it('displays a JPEG image', () => { + loadDist().then((lib) => { + cy.mount( + , + ); + expectPaintedCanvas(); + }); + }); + + it('displays a PNG data URL produced from a PDF page', () => { + loadDist().then((lib) => { + cy.wrap, string[]>( + lib.getImagesFromPDF(multiPage), + ).then((images) => { + cy.mount( + , + ); + expectPaintedCanvas(); + }); + }); + }); + + it('converts and displays a TIFF image', () => { + loadDist().then((lib) => { + cy.mount( + , + ); + expectPaintedCanvas(); + }); + }); + + it('converts and displays a HEIC image, downloading the heic2any chunk only then', () => { + loadDist().then((lib) => { + cy.mount( + , + ); + expectPaintedCanvas(); + cy.window().then((win) => { + expect(loadedResources(win, 'heic2any')).to.have.length(0); + }); + + cy.mount( + , + ); + expectPaintedCanvas(); + cy.window().should((win) => { + expect(loadedResources(win, 'heic2any')).to.have.length(1); + }); + }); + }); + + it('renders an empty stage without errors when no image is provided', () => { + loadDist().then((lib) => { + cy.mount( + , + ); + cy.get(`#${containerId}`).should('be.visible'); + cy.get(`#${containerId} canvas`).should('have.length.at.least', 1); + }); + }); +}); diff --git a/cypress/integration/lens.spec.tsx b/cypress/integration/lens.spec.tsx new file mode 100644 index 0000000..039eae4 --- /dev/null +++ b/cypress/integration/lens.spec.tsx @@ -0,0 +1,80 @@ +import dummyImage from 'cypress/assets/demo.jpg'; +import { dummyShapes } from 'cypress/assets/shapes'; +import type { Stage } from 'konva/lib/Stage'; + +import { CONTAINER_STYLE, loadDist, type DistLibrary } from './helpers'; + +const containerId = 'annotationLens'; + +const mountLens = ( + lib: DistLibrary, + props: { + zoomLevel?: number; + pointerPosition?: { x: number; y: number }; + } = {}, +): Cypress.Chainable<() => Stage> => { + let stage: Stage | undefined; + return cy + .mount( + { + stage = value; + }} + {...props} + />, + ) + .then(() => { + cy.get(`#${containerId} canvas`).should('have.length.at.least', 1); + }) + .then(() => (): Stage => { + if (stage === undefined) { + throw new Error('getStage was never called'); + } + return stage; + }); +}; + +describe('built library AnnotationLens', () => { + it('mounts with the image and every shape drawn', () => { + loadDist().then((lib) => { + mountLens(lib).then((stage) => { + cy.wrap(null).should(() => { + expect(stage().find('Line')).to.have.length(dummyShapes.length); + expect(stage().findOne('Image')).to.not.equal(undefined); + }); + }); + }); + }); + + it('scales the stage by the requested zoomLevel', () => { + loadDist().then((lib) => { + mountLens(lib, { zoomLevel: 2.5 }).then((stage) => { + cy.wrap(null).should(() => { + expect(stage().scaleX()).to.be.closeTo(2.5, 0.001); + expect(stage().scaleY()).to.be.closeTo(2.5, 0.001); + }); + }); + }); + }); + + it('moves the stage when the pointer position changes', () => { + loadDist().then((lib) => { + let initialPosition = { x: 0, y: 0 }; + mountLens(lib, { pointerPosition: { x: 0.2, y: 0.2 } }).then((stage) => { + cy.wrap(null).should(() => { + initialPosition = stage().position(); + }); + }); + mountLens(lib, { pointerPosition: { x: 0.8, y: 0.8 } }).then((stage) => { + cy.wrap(null).should(() => { + const position = stage().position(); + expect(position.x).to.not.equal(initialPosition.x); + expect(position.y).to.not.equal(initialPosition.y); + }); + }); + }); + }); +}); diff --git a/cypress/integration/pdf.spec.tsx b/cypress/integration/pdf.spec.tsx new file mode 100644 index 0000000..7cee748 --- /dev/null +++ b/cypress/integration/pdf.spec.tsx @@ -0,0 +1,133 @@ +import brokenPDF from 'cypress/assets/broken-pdf.pdf'; +import multiPage from 'cypress/assets/multi-page.pdf'; + +import { imageDimensions, loadDist } from './helpers'; + +const PAGE_COUNT = 5; + +/** + * pdf.js and its worker are shipped as lazy chunks and the worker is inlined + * as a blob. Those rewrites only happen in the production library build, so + * every expectation here runs against dist/ rather than the sources. + */ +describe('built library PDF support', () => { + it('does not spawn the pdf.js worker until a PDF is actually processed', () => { + cy.window().then((win) => { + cy.spy(win, 'Worker').as('worker'); + }); + loadDist({ fresh: true }).then(({ getPDFPageCount }) => { + cy.get('@worker').should('not.have.been.called'); + cy.wrap(getPDFPageCount(multiPage)).should('equal', PAGE_COUNT); + }); + cy.get('@worker').should('have.been.calledOnce'); + }); + + it('spawns a real Web Worker from a blob URL instead of falling back to the main thread', () => { + cy.window().then((win) => { + cy.spy(win, 'Worker').as('worker'); + }); + loadDist({ fresh: true }).then(({ getPDFPageCount }) => { + cy.wrap(getPDFPageCount(multiPage)).should('equal', PAGE_COUNT); + }); + cy.get('@worker').should((spy) => { + expect(spy.calledWithNew()).to.equal(true); + expect(String(spy.firstCall.args[0])).to.match(/^blob:/); + }); + }); + + it('reuses a single worker across documents and calls', () => { + cy.window().then((win) => { + cy.spy(win, 'Worker').as('worker'); + }); + loadDist({ fresh: true }).then(({ getPDFPageCount, getImagesFromPDF }) => { + cy.wrap(getPDFPageCount(multiPage)).should('equal', PAGE_COUNT); + cy.wrap(getImagesFromPDF(multiPage)).should('have.length', PAGE_COUNT); + cy.wrap(getPDFPageCount(multiPage)).should('equal', PAGE_COUNT); + }); + cy.get('@worker').should('have.been.calledOnce'); + }); + + it('renders every page to a PNG data URL', () => { + loadDist().then(({ getImagesFromPDF }) => { + cy.wrap, string[]>(getImagesFromPDF(multiPage)).should( + (images) => { + expect(images).to.have.length(PAGE_COUNT); + images.forEach((image) => { + expect(image).to.match(/^data:image\/png;base64,/); + }); + }, + ); + }); + }); + + it('produces decodable images whose size follows the requested resolution', () => { + loadDist().then(({ getImagesFromPDF }) => { + type Dimensions = Awaited>; + const render = async (resolution: number): Promise => { + const images = await getImagesFromPDF( + multiPage, + Infinity, + undefined, + resolution, + ); + return await imageDimensions(images[0] ?? ''); + }; + cy.wrap, [Dimensions, Dimensions]>( + Promise.all([render(500_000), render(2_000_000)]), + ).should(([small, large]) => { + expect(small.width * small.height).to.be.greaterThan(0); + expect(large.width * large.height).to.be.greaterThan( + small.width * small.height, + ); + }); + }); + }); + + it('invokes onSuccess once the document is accepted, before pages resolve', () => { + const onSuccess = cy.spy().as('onSuccess'); + loadDist().then(({ getImagesFromPDF }) => { + cy.wrap(getImagesFromPDF(multiPage, Infinity, onSuccess)).should( + 'have.length', + PAGE_COUNT, + ); + }); + cy.get('@onSuccess').should('have.been.calledOnce'); + }); + + it('rejects with TooManyPagesError without invoking onSuccess when the page cap is exceeded', () => { + const onSuccess = cy.spy().as('onSuccess'); + loadDist().then(({ getImagesFromPDF }) => { + cy.wrap( + getImagesFromPDF(multiPage, PAGE_COUNT - 1, onSuccess).then( + () => 'resolved', + (error: unknown) => (error as Error).name, + ), + ).should('equal', 'TooManyPagesError'); + }); + cy.get('@onSuccess').should('not.have.been.called'); + }); + + it('rejects a broken PDF with InvalidPDFException from both entry points', () => { + loadDist().then(({ getImagesFromPDF, getPDFPageCount }) => { + cy.wrap( + getImagesFromPDF(brokenPDF).then( + () => 'resolved', + (error: unknown) => (error as Error).name, + ), + ).should('equal', 'InvalidPDFException'); + cy.wrap( + getPDFPageCount(brokenPDF).then( + () => 'resolved', + (error: unknown) => (error as Error).name, + ), + ).should('equal', 'InvalidPDFException'); + }); + }); + + it('keeps working after a failed document', () => { + loadDist().then(({ getImagesFromPDF, getPDFPageCount }) => { + cy.wrap(getImagesFromPDF(brokenPDF).catch(() => undefined)); + cy.wrap(getPDFPageCount(multiPage)).should('equal', PAGE_COUNT); + }); + }); +}); diff --git a/cypress/integration/viewer.spec.tsx b/cypress/integration/viewer.spec.tsx new file mode 100644 index 0000000..ee3ca62 --- /dev/null +++ b/cypress/integration/viewer.spec.tsx @@ -0,0 +1,331 @@ +import type { AnnotationShape, AnnotationViewerOptions } from '@/index'; +import dummyImage from 'cypress/assets/demo.jpg'; +import { dummyShapes } from 'cypress/assets/shapes'; +import type { Line } from 'konva/lib/shapes/Line'; +import type { Stage } from 'konva/lib/Stage'; + +import { CONTAINER_STYLE, loadDist, type DistLibrary } from './helpers'; + +const containerId = 'annotationViewer'; +const SHAPE_HIT = { x: 350, y: 50 }; +const EMPTY_AREA = { x: 10, y: 10 }; + +type MountResult = { stage: () => Stage }; + +/** + * Mounts the built AnnotationViewer with the demo image and shapes and + * captures the Konva stage through the getStage prop so that tests can + * inspect the rendered scene graph directly. + */ +const mountViewer = ( + lib: DistLibrary, + props: { + shapes?: AnnotationShape[]; + options?: AnnotationViewerOptions; + orientation?: 0 | 90 | 180 | 270; + customZoomLevel?: number; + onShapeClick?: (shape: AnnotationShape) => void; + onShapeMouseEnter?: (shape: AnnotationShape) => void; + onShapeMouseLeave?: (shape: AnnotationShape) => void; + onShapeMultiSelect?: (shapes: AnnotationShape[]) => void; + getPointerPosition?: (position: { x: number; y: number }) => void; + } = {}, +): Cypress.Chainable => { + let stage: Stage | undefined; + const { shapes = dummyShapes, orientation, ...rest } = props; + return cy + .mount( + { + stage = value; + }} + {...rest} + />, + ) + .then(() => { + cy.get(`#${containerId} canvas`).should('have.length.at.least', 1); + }) + .then(() => ({ + stage: (): Stage => { + if (stage === undefined) { + throw new Error('getStage was never called'); + } + return stage; + }, + })); +}; + +const waitForShapes = (stage: () => Stage, count: number): void => { + cy.wrap(null).should(() => { + expect(stage().find('Line')).to.have.length(count); + }); +}; + +describe('built library AnnotationViewer', () => { + it('draws one Konva.Line per shape, addressable by the shape id', () => { + loadDist().then((lib) => { + mountViewer(lib).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + cy.wrap(null).should(() => { + dummyShapes.forEach((shape) => { + expect(stage().findOne(`#${shape.id}`)).to.not.equal(undefined); + }); + }); + }); + }); + }); + + it('redraws when the shapes prop changes', () => { + loadDist().then((lib) => { + mountViewer(lib, { shapes: dummyShapes.slice(0, 2) }).then( + ({ stage }) => { + waitForShapes(stage, 2); + }, + ); + mountViewer(lib, { shapes: dummyShapes }).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + }); + }); + }); + + it('fires enter, click and leave callbacks with the matching shape', () => { + const onShapeClick = cy.spy().as('onShapeClick'); + const onShapeMouseEnter = cy.spy().as('onShapeMouseEnter'); + const onShapeMouseLeave = cy.spy().as('onShapeMouseLeave'); + loadDist().then((lib) => { + mountViewer(lib, { + onShapeClick, + onShapeMouseEnter, + onShapeMouseLeave, + }).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + }); + }); + + cy.get(`#${containerId} .konvajs-content`).trigger( + 'mousemove', + SHAPE_HIT.x, + SHAPE_HIT.y, + ); + cy.get('@onShapeMouseEnter').should( + 'have.been.calledOnceWith', + dummyShapes[1], + ); + + cy.get(`#${containerId}`).click(SHAPE_HIT.x, SHAPE_HIT.y); + cy.get('@onShapeClick').should('have.been.calledOnceWith', dummyShapes[1]); + + cy.get(`#${containerId} .konvajs-content`).trigger( + 'mousemove', + EMPTY_AREA.x, + EMPTY_AREA.y, + ); + cy.get('@onShapeMouseLeave').should( + 'have.been.calledOnceWith', + dummyShapes[1], + ); + }); + + it('reports relative pointer positions within [0, 1] while hovering the image', () => { + const getPointerPosition = cy.spy().as('getPointerPosition'); + loadDist().then((lib) => { + mountViewer(lib, { getPointerPosition }).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + }); + }); + cy.get(`#${containerId} .konvajs-content`).trigger( + 'mousemove', + SHAPE_HIT.x, + SHAPE_HIT.y, + ); + cy.get('@getPointerPosition').should((spy) => { + expect(spy.called).to.equal(true); + const { x, y } = spy.lastCall.args[0] as { x: number; y: number }; + expect(x).to.be.within(0, 1); + expect(y).to.be.within(0, 1); + }); + }); + + it('does not create a selection rectangle unless enableSelection is set', () => { + loadDist().then((lib) => { + mountViewer(lib).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + cy.get(`#${containerId}`) + .children() + .trigger('keydown', { altKey: true, ctrlKey: true }) + .trigger('mousedown', { which: 1, clientX: 10, clientY: 10 }) + .trigger('mousemove', { which: 1, clientX: 600, clientY: 300 }); + cy.wrap(null).should(() => { + expect(stage().find('Rect')).to.have.length(0); + }); + cy.get(`#${containerId}`) + .trigger('mouseup') + .trigger('keyup', { altKey: true, ctrlKey: true }); + }); + }); + }); + + it('reports the shapes enclosed by a selection rectangle when enableSelection is set', () => { + const onShapeMultiSelect = cy.spy().as('onShapeMultiSelect'); + loadDist().then((lib) => { + mountViewer(lib, { + options: { enableSelection: true }, + onShapeMultiSelect, + }).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + }); + }); + cy.get(`#${containerId}`) + .children() + .trigger('keydown', { altKey: true, ctrlKey: true }) + .trigger('mousedown', { which: 1, clientX: 10, clientY: 10 }) + .trigger('mousemove', { which: 1, clientX: 600, clientY: 300 }); + cy.get(`#${containerId}`) + .trigger('mouseup') + .trigger('keyup', { altKey: true, ctrlKey: true }); + cy.get('@onShapeMultiSelect').should( + 'have.been.calledOnceWithExactly', + dummyShapes.slice(0, 2), + ); + }); + + it('zooms in on wheel up, exposes the level through getZoomScale and respects the max', () => { + loadDist().then((lib) => { + mountViewer(lib, { + options: { zoom: { modifier: 1.5, max: 2, defaultZoom: 1 } }, + }).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + cy.wrap(null).should(() => { + expect(lib.getZoomScale(stage())).to.equal(1); + }); + cy.get(`#${containerId}`).trigger('wheel', { deltaY: -60 }); + cy.wrap(null).should(() => { + expect(lib.getZoomScale(stage())).to.be.closeTo(1.5, 0.01); + }); + cy.get(`#${containerId}`) + .trigger('wheel', { deltaY: -60 }) + .trigger('wheel', { deltaY: -60 }); + cy.wrap(null).should(() => { + expect(lib.getZoomScale(stage())).to.be.at.most(2); + }); + cy.get(`#${containerId}`).trigger('wheel', { deltaY: 60 }); + cy.wrap(null).should(() => { + expect(lib.getZoomScale(stage())).to.be.lessThan(2); + }); + }); + }); + }); + + it('applies customZoomLevel on mount', () => { + loadDist().then((lib) => { + mountViewer(lib, { customZoomLevel: 1.75 }).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + cy.wrap(null).should(() => { + expect(lib.getZoomScale(stage())).to.be.closeTo(1.75, 0.01); + }); + }); + }); + }); + + it('swaps the displayed image aspect ratio for a 90 degree orientation', () => { + const imageRatio = (stage: Stage): number => { + const image = stage.findOne('Image'); + if (image === undefined) { + throw new Error('No image node on stage'); + } + return image.width() / image.height(); + }; + loadDist().then((lib) => { + let uprightRatio = 0; + mountViewer(lib, { orientation: 0 }).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + cy.wrap(null).should(() => { + uprightRatio = imageRatio(stage()); + expect(uprightRatio).to.be.greaterThan(0); + }); + }); + mountViewer(lib, { orientation: 90 }).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + cy.wrap(null).should(() => { + expect(imageRatio(stage())).to.be.closeTo(1 / uprightRatio, 0.05); + }); + }); + }); + }); + + it('lets consumers restyle shapes through setShapeConfig, drawShape and drawShapes', () => { + loadDist().then((lib) => { + mountViewer(lib).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + cy.wrap(null).then(() => { + const target = dummyShapes[0]; + if (target === undefined) { + throw new Error('Missing fixture shape'); + } + lib.setShapeConfig(stage(), target.id, { stroke: 'green' }); + expect(stage().findOne(`#${target.id}`)?.stroke()).to.equal( + 'green', + ); + + lib.drawShape(stage(), target.id, { stroke: 'blue' }); + expect(stage().findOne(`#${target.id}`)?.stroke()).to.equal( + 'blue', + ); + + lib.drawShapes(stage(), { strokeWidth: 7 }); + stage() + .find('Line') + .forEach((line) => { + expect(line.strokeWidth()).to.equal(7); + }); + }); + }); + }); + }); + + it('logs an error rather than throwing when styling an unknown shape id', () => { + loadDist().then((lib) => { + mountViewer(lib).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + cy.window().then((win) => { + const consoleError = cy.spy(win.console, 'error'); + lib.setShapeConfig(stage(), 'does-not-exist', { stroke: 'red' }); + lib.drawShape(stage(), 'does-not-exist', { stroke: 'red' }); + expect(consoleError.callCount).to.equal(2); + }); + }); + }); + }); + + it('warns and skips malformed coordinates instead of breaking the whole layer', () => { + const malformed: AnnotationShape = { + id: 'malformed', + coordinates: [[0.1, 0.1], [0.2], [0.2, 0.2, 0.3], [0.1, 0.2]], + }; + loadDist().then((lib) => { + cy.window().then((win) => { + cy.spy(win.console, 'warn').as('consoleWarn'); + }); + mountViewer(lib, { shapes: [...dummyShapes, malformed] }).then( + ({ stage }) => { + waitForShapes(stage, dummyShapes.length + 1); + cy.get('@consoleWarn').should('have.been.called'); + }, + ); + }); + }); + + it('removes its canvases from the DOM on unmount', () => { + loadDist().then((lib) => { + mountViewer(lib).then(({ stage }) => { + waitForShapes(stage, dummyShapes.length); + }); + cy.mount(
); + cy.get('[data-cy="placeholder"]').should('exist'); + cy.get('canvas').should('have.length', 0); + }); + }); +}); diff --git a/package.json b/package.json index 51659df..e62bedb 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,8 @@ "cy:prepare": "pnpm build", "build": "vite build", "cy:open": "cypress open --component --browser chrome", - "cy:run": "cypress run --component --browser chrome", + "cy:run": "cypress run --component --browser chrome --spec 'src/**/*.spec.{ts,tsx}'", + "cy:run:integration": "cypress run --component --browser chrome --spec 'cypress/integration/**/*.spec.{ts,tsx}'", "docs:start": "pnpm --dir ./docs start", "docs:install": "pnpm --dir ./docs install", "docs:prepare": "pnpm build && pnpm docs:install", diff --git a/src/common/types.ts b/src/common/types.ts index 0989944..db8bea7 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -29,8 +29,14 @@ export type AnnotationViewerOptions = BaseOptions & { onMouseEnter?: (polygon: Line) => void; onMouseLeave?: (polygon: Line) => void; onClick?: (polygon: Line) => void; + /** + * Wheel zoom settings. All factors are relative to the fitted image + * (1 = image fits the container), independently of its pixel size. + */ zoom?: { + /** Multiplier applied per wheel step. */ modifier: number; + /** Highest magnification reachable through the wheel. */ max: number; defaultZoom: number; }; diff --git a/src/utils/pdfWorker.ts b/src/utils/pdfWorker.ts index fa2ed05..df8a869 100644 --- a/src/utils/pdfWorker.ts +++ b/src/utils/pdfWorker.ts @@ -1,5 +1,3 @@ -// Inlined (blob) worker: a file-relative worker URL breaks once consumers' -// bundlers pre-bundle or relocate dist/index.js, an inline worker does not. import PdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker&inline'; export const createPdfWorker = (): Worker => new PdfWorker(); diff --git a/src/utils/zoom.ts b/src/utils/zoom.ts index 3bc0eb8..e48b9ed 100644 --- a/src/utils/zoom.ts +++ b/src/utils/zoom.ts @@ -130,7 +130,7 @@ export const handleStageZoom = ( } const { newScale, newPos } = stageZoom; - if (newScale > max) { + if (newScale / imageBoundingBox.scale > max) { return; } From 59bacd2b4c732ad8f1b4955231ce9d71cd4bcfae Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:18:02 +0200 Subject: [PATCH 3/3] fix tests --- cypress/integration/lens.spec.tsx | 57 +++++++++++++++++++++++++---- cypress/integration/viewer.spec.tsx | 55 ++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/cypress/integration/lens.spec.tsx b/cypress/integration/lens.spec.tsx index 039eae4..d92f7e4 100644 --- a/cypress/integration/lens.spec.tsx +++ b/cypress/integration/lens.spec.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import dummyImage from 'cypress/assets/demo.jpg'; import { dummyShapes } from 'cypress/assets/shapes'; import type { Stage } from 'konva/lib/Stage'; @@ -60,17 +61,59 @@ describe('built library AnnotationLens', () => { }); }); - it('moves the stage when the pointer position changes', () => { + it('moves the already mounted stage when the pointer position prop changes', () => { loadDist().then((lib) => { - let initialPosition = { x: 0, y: 0 }; - mountLens(lib, { pointerPosition: { x: 0.2, y: 0.2 } }).then((stage) => { - cy.wrap(null).should(() => { - initialPosition = stage().position(); + let stage: Stage | undefined; + const stageOrThrow = (): Stage => { + if (stage === undefined) { + throw new Error('getStage was never called'); + } + return stage; + }; + + /** + * Harness owning the pointer position as React state so that the test + * updates the prop of the same mounted lens instead of remounting it. + */ + const PointerHarness = (): React.JSX.Element => { + const [pointerPosition, setPointerPosition] = useState({ + x: 0.2, + y: 0.2, }); + return ( + <> + + { + stage = value; + }} + /> + + ); + }; + + cy.mount(); + cy.wrap(null).should(() => { + expect(stageOrThrow().findOne('Image')).to.not.equal(undefined); }); - mountLens(lib, { pointerPosition: { x: 0.8, y: 0.8 } }).then((stage) => { + cy.wrap(null).then(() => { + const initialStage = stageOrThrow(); + const initialPosition = initialStage.position(); + cy.get('[data-cy="move-pointer"]').click(); cy.wrap(null).should(() => { - const position = stage().position(); + expect(stageOrThrow()).to.equal(initialStage); + const position = stageOrThrow().position(); expect(position.x).to.not.equal(initialPosition.x); expect(position.y).to.not.equal(initialPosition.y); }); diff --git a/cypress/integration/viewer.spec.tsx b/cypress/integration/viewer.spec.tsx index ee3ca62..153b883 100644 --- a/cypress/integration/viewer.spec.tsx +++ b/cypress/integration/viewer.spec.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import type { AnnotationShape, AnnotationViewerOptions } from '@/index'; import dummyImage from 'cypress/assets/demo.jpg'; import { dummyShapes } from 'cypress/assets/shapes'; @@ -78,15 +79,53 @@ describe('built library AnnotationViewer', () => { }); }); - it('redraws when the shapes prop changes', () => { + it('redraws the existing stage when the shapes prop changes after mount', () => { loadDist().then((lib) => { - mountViewer(lib, { shapes: dummyShapes.slice(0, 2) }).then( - ({ stage }) => { - waitForShapes(stage, 2); - }, - ); - mountViewer(lib, { shapes: dummyShapes }).then(({ stage }) => { - waitForShapes(stage, dummyShapes.length); + let stage: Stage | undefined; + const stageOrThrow = (): Stage => { + if (stage === undefined) { + throw new Error('getStage was never called'); + } + return stage; + }; + + /** + * Harness owning the shapes as React state so that the test can push a + * new prop value into the same mounted viewer instead of remounting. + */ + const ShapesHarness = (): React.JSX.Element => { + const [shapes, setShapes] = useState(dummyShapes.slice(0, 2)); + return ( + <> + + { + stage = value; + }} + /> + + ); + }; + + cy.mount(); + waitForShapes(stageOrThrow, 2); + cy.wrap(null).then(() => { + const initialStage = stageOrThrow(); + cy.get('[data-cy="add-shapes"]').click(); + waitForShapes(stageOrThrow, dummyShapes.length); + cy.wrap(null).should(() => { + expect(stageOrThrow()).to.equal(initialStage); + }); }); }); });