diff --git a/.prettierrc b/.prettierrc index f058170..d1a7462 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,7 +3,7 @@ "tabWidth": 2, "singleQuote": true, "trailingComma": "all", - "semi": false, + "semi": true, "importOrder": [ "", "^(react/(.*)$)|^(react$)", diff --git a/README.md b/README.md index 0dca3a7..3376ce3 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ yarn add react-mindee-js You only need an image and a list of shapes to get started. ```jsx -import React from 'react' -import dummyImage from 'path-to-your/file.jpg' -import { AnnotationViewer } from 'react-mindee-js' +import React from 'react'; +import dummyImage from 'path-to-your/file.jpg'; +import { AnnotationViewer } from 'react-mindee-js'; const dummyShapes = [ { @@ -67,15 +67,15 @@ const dummyShapes = [ [0.394, 0.087], ], }, -] +]; const data = { image: dummyImage, shapes: dummyShapes, -} +}; function App() { - return + return ; } ``` diff --git a/cypress.config.ts b/cypress.config.ts index 5a2755d..98ceb98 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,7 +1,6 @@ -import { addMatchImageSnapshotPlugin } from '@simonsmith/cypress-image-snapshot/plugin' -import { defineConfig } from 'cypress' -import { defineConfig as defineViteConfig } from 'vite' -import tsconfigPaths from 'vite-tsconfig-paths' +import { addMatchImageSnapshotPlugin } from '@simonsmith/cypress-image-snapshot/plugin'; +import { defineConfig } from 'cypress'; +import { defineConfig as defineViteConfig } from 'vite'; export default defineConfig({ viewportWidth: 1500, @@ -17,9 +16,9 @@ export default defineConfig({ // if (config.testingType === 'component') // on('dev-server:start', async (options) => startDevServer({ options })) - addMatchImageSnapshotPlugin(on) + addMatchImageSnapshotPlugin(on); - return config + return config; }, specPattern: 'src/**/*.spec.{js,ts,jsx,tsx}', devServer: { @@ -27,8 +26,8 @@ export default defineConfig({ bundler: 'vite', viteConfig: defineViteConfig({ assetsInclude: ['**/*.tiff', '**/*.heic'], - plugins: [tsconfigPaths()], + resolve: { tsconfigPaths: true }, }), }, }, -}) +}); diff --git a/cypress/assets/shapes.ts b/cypress/assets/shapes.ts index 17e24cf..771873f 100644 --- a/cypress/assets/shapes.ts +++ b/cypress/assets/shapes.ts @@ -1,4 +1,4 @@ -import type { AnnotationShape } from '@/index' +import type { AnnotationShape } from '@/index'; export const dummyShapes: AnnotationShape[] = [ { @@ -46,4 +46,4 @@ export const dummyShapes: AnnotationShape[] = [ [0.625, 0.706], ], }, -] +]; diff --git a/cypress/support/component.ts b/cypress/support/component.ts index 437ee8f..4a3515d 100644 --- a/cypress/support/component.ts +++ b/cypress/support/component.ts @@ -1,9 +1,9 @@ -import { addMatchImageSnapshotCommand } from '@simonsmith/cypress-image-snapshot/command' -import { mount } from 'cypress/react' +import { addMatchImageSnapshotCommand } from '@simonsmith/cypress-image-snapshot/command'; +import { mount } from 'cypress/react'; addMatchImageSnapshotCommand({ failureThreshold: 0.2, failureThresholdType: 'percent', capture: 'runner', -}) -Cypress.Commands.add('mount', mount) +}); +Cypress.Commands.add('mount', mount); diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index 43a27b3..ed8caa9 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -1,9 +1,9 @@ -import type { mount } from 'cypress/react' +import type { mount } from 'cypress/react'; declare global { namespace Cypress { interface Chainable { - mount: typeof mount + mount: typeof mount; } } } diff --git a/eslint.config.ts b/eslint.config.ts index 8d2fc8f..6c97a3b 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,9 +1,9 @@ -import js from '@eslint/js' -import prettier from 'eslint-config-prettier' -import reactHooks from 'eslint-plugin-react-hooks' -import { defineConfig } from 'eslint/config' -import globals from 'globals' -import tseslint from 'typescript-eslint' +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import reactHooks from 'eslint-plugin-react-hooks'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; export default defineConfig( { ignores: ['dist', 'docs', 'node_modules', 'cypress/snapshots'] }, @@ -12,6 +12,9 @@ export default defineConfig( tseslint.configs.stylisticTypeChecked, reactHooks.configs.flat['recommended-latest'], { + linterOptions: { + reportUnusedDisableDirectives: 'error', + }, languageOptions: { globals: { ...globals.browser, ...globals.es2022 }, parserOptions: { @@ -20,19 +23,86 @@ export default defineConfig( }, }, rules: { + // --- typing strictness (beyond strictTypeChecked) --- '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-non-null-assertion': 'error', '@typescript-eslint/ban-ts-comment': [ 'error', { 'ts-expect-error': 'allow-with-description' }, ], - '@typescript-eslint/consistent-type-imports': 'error', - '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/explicit-function-return-type': [ + 'error', + { + allowExpressions: true, + allowTypedFunctionExpressions: true, + allowHigherOrderFunctions: true, + allowIIFEs: true, + }, + ], + '@typescript-eslint/explicit-module-boundary-types': 'error', + '@typescript-eslint/strict-boolean-expressions': 'error', '@typescript-eslint/switch-exhaustiveness-check': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/promise-function-async': 'error', + '@typescript-eslint/return-await': ['error', 'always'], + '@typescript-eslint/require-array-sort-compare': 'error', + '@typescript-eslint/prefer-readonly': 'error', + '@typescript-eslint/method-signature-style': 'error', + + // --- module hygiene --- + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/no-import-type-side-effects': 'error', + '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['../*'], + message: + 'Use the @/ path alias instead of relative parent paths.', + }, + ], + }, + ], + + // --- naming & scoping --- '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', ignoreRestSiblings: true }, ], + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'error', + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'default', + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'variable', + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + }, + { selector: 'function', format: ['camelCase', 'PascalCase'] }, + { selector: 'typeLike', format: ['PascalCase'] }, + { selector: 'enumMember', format: ['PascalCase', 'UPPER_CASE'] }, + // Konva/DOM/third-party config keys are dictated by their APIs. + { selector: ['objectLiteralProperty', 'typeProperty'], format: null }, + { selector: 'import', format: null }, + ], + + // --- base rules (mirrors mindee-api-nodejs where not covered by Prettier) --- + eqeqeq: 'error', + 'no-eval': 'error', + 'no-else-return': 'error', + 'no-nested-ternary': 'error', + 'no-implicit-coercion': 'error', + 'no-param-reassign': 'error', + 'default-case-last': 'error', + 'prefer-template': 'error', + 'object-shorthand': 'error', 'no-console': ['error', { allow: ['warn', 'error'] }], }, }, @@ -45,11 +115,21 @@ export default defineConfig( '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', }, }, + { + // Declaration merging (e.g. augmenting Cypress.Chainable) requires interfaces. + files: ['**/*.d.ts'], + rules: { '@typescript-eslint/consistent-type-definitions': 'off' }, + }, { files: ['*.config.ts'], languageOptions: { globals: globals.node }, }, prettier, -) + { + rules: { curly: ['error', 'all'] }, + }, +); diff --git a/package.json b/package.json index 5b221f9..3b58c11 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.71.0", "vite": "^8.3.1", - "vite-plugin-dts": "^5.1.1", - "vite-tsconfig-paths": "^6.1.1" + "vite-plugin-dts": "^5.1.1" }, "peerDependencies": { "react": "^18 || ^19", @@ -82,7 +81,7 @@ "docs:deploy": "pnpm --dir ./docs deploy", "prettier:check": "prettier --check .", "prettier:write": "prettier --write .", - "lint": "tsc --noEmit && eslint . && prettier --check .", + "lint": "tsc --noEmit && eslint --max-warnings 0 . && prettier --check .", "lint:fix": "eslint --fix . && prettier --write ." }, "keywords": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38ac078..e5b8602 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,9 +256,6 @@ importers: vite-plugin-dts: specifier: ^5.1.1 version: 5.1.1(@microsoft/api-extractor@7.59.3(@types/node@24.19.0))(rolldown@1.2.11)(rollup@3.30.0)(supports-color@8.1.1)(typescript@6.0.3)(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)) - vite-tsconfig-paths: - specifier: ^6.1.1 - version: 6.1.1(supports-color@8.1.1)(typescript@6.0.3)(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)) packages: @@ -1288,9 +1285,6 @@ packages: resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} engines: {node: '>=18'} - globrex@0.1.2: - resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - glur@1.1.2: resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} @@ -1972,17 +1966,6 @@ packages: peerDependencies: typescript: '>=4.8.4' - tsconfck@3.1.6: - resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} - engines: {node: ^18 || >=20} - deprecated: unmaintained - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -2095,11 +2078,6 @@ packages: vite: optional: true - vite-tsconfig-paths@6.1.1: - resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} - peerDependencies: - vite: '*' - vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3277,8 +3255,6 @@ snapshots: globals@17.12.0: {} - globrex@0.1.2: {} - glur@1.1.2: {} gopd@1.2.0: {} @@ -3877,10 +3853,6 @@ snapshots: dependencies: typescript: 6.0.3 - tsconfck@3.1.6(typescript@6.0.3): - optionalDependencies: - typescript: 6.0.3 - tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -3981,16 +3953,6 @@ snapshots: - typescript - webpack - vite-tsconfig-paths@6.1.1(supports-color@8.1.1)(typescript@6.0.3)(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)): - dependencies: - debug: 4.4.3(supports-color@8.1.1) - globrex: 0.1.2 - tsconfck: 3.1.6(typescript@6.0.3) - vite: 8.3.1(@types/node@24.19.0)(jiti@2.7.0) - transitivePeerDependencies: - - supports-color - - typescript - vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 diff --git a/src/common/constants.ts b/src/common/constants.ts index 1b9170a..f938924 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,45 +1,45 @@ -import type { Line } from 'konva/lib/shapes/Line' +import type { Line } from 'konva/lib/shapes/Line'; -import type { AnnotationData } from '@/common/types' +import type { AnnotationData } from '@/common/types'; export const DEFAULT_STYLE = { height: 300, width: 300, display: 'flex', backgroundColor: '#001429', -} +}; -export enum KONVA_REFS { - shapesLayer = 'shapes-layer', - shape = 'shape', +export enum KonvaRefs { + ShapesLayer = 'shapes-layer', + Shape = 'shape', } -const STROKE_COLOR = '#FF0000' -const SELECTION_FILL_COLOR = 'rgba(0,0,255,0.5)' -export const DEFAULT_LENS_ZOOM_LEVEL = 3 / 2 -export const DEFAULT_POINTER_POSITION = { x: 0.5, y: 0.5 } -export const PDF_RESOLUTION = 1.5 * 10 ** 6 // target resolution 1.5 Mpx -export const MAX_PDF_SCALE = 5.5 // max DPI = 500 +const STROKE_COLOR = '#FF0000'; +const SELECTION_FILL_COLOR = 'rgba(0,0,255,0.5)'; +export const DEFAULT_LENS_ZOOM_LEVEL = 3 / 2; +export const DEFAULT_POINTER_POSITION = { x: 0.5, y: 0.5 }; +export const PDF_RESOLUTION = 1.5 * 10 ** 6; // target resolution 1.5 Mpx +export const MAX_PDF_SCALE = 5.5; // max DPI = 500 export const DEFAULT_DATA: AnnotationData = { image: null, shapes: [], orientation: 0, -} +}; export const DEFAULT_ANNOTATION_VIEWER_OPTIONS = { enableSelection: false, selectionRectConfig: { fill: SELECTION_FILL_COLOR, }, - onMouseLeave: (polygon: Line) => { - const layer = polygon.getLayer() - polygon.setAttr('fill', 'transparent') - layer?.batchDraw() + onMouseLeave: (polygon: Line): void => { + const layer = polygon.getLayer(); + polygon.setAttr('fill', 'transparent'); + layer?.batchDraw(); }, - onMouseEnter: (polygon: Line) => { - const stroke = polygon.stroke() - polygon.setAttr('fill', `${stroke}40`) - polygon.draw() + onMouseEnter: (polygon: Line): void => { + const stroke = polygon.stroke(); + polygon.setAttr('fill', `${stroke}40`); + polygon.draw(); }, shapeConfig: { stroke: STROKE_COLOR, @@ -51,7 +51,7 @@ export const DEFAULT_ANNOTATION_VIEWER_OPTIONS = { max: 10, defaultZoom: 1, }, -} +}; export const DEFAULT_ANNOTATION_LENS_OPTIONS = { shapeConfig: { @@ -59,4 +59,4 @@ export const DEFAULT_ANNOTATION_LENS_OPTIONS = { strokeWidth: 1, listening: false, }, -} +}; diff --git a/src/common/types.ts b/src/common/types.ts index ccb2296..0989944 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,91 +1,91 @@ -import type { CSSProperties } from 'react' -import type Konva from 'konva' -import type { Line, LineConfig } from 'konva/lib/shapes/Line' -import type { RectConfig } from 'konva/lib/shapes/Rect' -import type { Stage } from 'konva/lib/Stage' +import type { CSSProperties } from 'react'; +import type Konva from 'konva'; +import type { Line, LineConfig } from 'konva/lib/shapes/Line'; +import type { RectConfig } from 'konva/lib/shapes/Rect'; +import type { Stage } from 'konva/lib/Stage'; export type AnnotationShape = T & { - id: string - coordinates: number[][] - config?: LineConfig -} + id: string; + coordinates: number[][]; + config?: LineConfig; +}; -export type Orientation = 0 | 90 | 180 | 270 +export type Orientation = 0 | 90 | 180 | 270; -export interface BaseOptions { - shapeConfig?: LineConfig -} +export type BaseOptions = { + shapeConfig?: LineConfig; +}; -export interface AnnotationLayers { - shapes: Konva.Layer - image: Konva.Layer -} +export type AnnotationLayers = { + shapes: Konva.Layer; + image: Konva.Layer; +}; -export type AnnotationLensOptions = BaseOptions +export type AnnotationLensOptions = BaseOptions; export type AnnotationViewerOptions = BaseOptions & { - selectionRectConfig?: RectConfig - enableSelection?: boolean - onMouseEnter?: (polygon: Line) => void - onMouseLeave?: (polygon: Line) => void - onClick?: (polygon: Line) => void + selectionRectConfig?: RectConfig; + enableSelection?: boolean; + onMouseEnter?: (polygon: Line) => void; + onMouseLeave?: (polygon: Line) => void; + onClick?: (polygon: Line) => void; zoom?: { - modifier: number - max: number - defaultZoom: number - } -} + modifier: number; + max: number; + defaultZoom: number; + }; +}; -export interface ImageBoundingBox { - x: number - y: number - width: number - height: number - scale: number -} +export type ImageBoundingBox = { + x: number; + y: number; + width: number; + height: number; + scale: number; +}; -export interface PointerPosition { - x: number - y: number -} +export type PointerPosition = { + x: number; + y: number; +}; -export interface AnnotationData { - image?: string | null - shapes?: AnnotationShape[] - orientation?: Orientation -} +export type AnnotationData = { + image?: string | null; + shapes?: AnnotationShape[]; + orientation?: Orientation; +}; -export interface ImageData { - element: HTMLImageElement - shape: Konva.Image -} +export type ImageData = { + element: HTMLImageElement; + shape: Konva.Image; +}; -export interface ZoomOptions { - scale?: number - position?: PointerPosition -} -export interface AnnotationViewerProps { - id?: string - getPointerPosition?: (data: PointerPosition) => void - data?: AnnotationData - getStage?: (stage: Stage) => void - onShapeMultiSelect?: (shapes: AnnotationShape[]) => void - onShapeClick?: (shape: AnnotationShape) => void - onShapeMouseEnter?: (shape: AnnotationShape) => void - onShapeMouseLeave?: (shape: AnnotationShape) => void - options?: AnnotationViewerOptions - style?: CSSProperties - zoomScale?: number - customStagePosition?: PointerPosition - customZoomLevel?: number -} +export type ZoomOptions = { + scale?: number; + position?: PointerPosition; +}; +export type AnnotationViewerProps = { + id?: string; + getPointerPosition?: (data: PointerPosition) => void; + data?: AnnotationData; + getStage?: (stage: Stage) => void; + onShapeMultiSelect?: (shapes: AnnotationShape[]) => void; + onShapeClick?: (shape: AnnotationShape) => void; + onShapeMouseEnter?: (shape: AnnotationShape) => void; + onShapeMouseLeave?: (shape: AnnotationShape) => void; + options?: AnnotationViewerOptions; + style?: CSSProperties; + zoomScale?: number; + customStagePosition?: PointerPosition; + customZoomLevel?: number; +}; -export interface AnnotationLensProps { - id?: string - zoomLevel?: number - data?: AnnotationData - pointerPosition?: PointerPosition - getStage?: (stage: Stage) => void - style?: CSSProperties - options?: AnnotationLensOptions -} +export type AnnotationLensProps = { + id?: string; + zoomLevel?: number; + data?: AnnotationData; + pointerPosition?: PointerPosition; + getStage?: (stage: Stage) => void; + style?: CSSProperties; + options?: AnnotationLensOptions; +}; diff --git a/src/components/AnnotationLens.spec.tsx b/src/components/AnnotationLens.spec.tsx index 37d6eaa..3c12d30 100644 --- a/src/components/AnnotationLens.spec.tsx +++ b/src/components/AnnotationLens.spec.tsx @@ -1,34 +1,34 @@ -import React, { useState } from 'react' -import anotherDummyImage from 'cypress/assets/another-demo.jpg' -import dummyImage from 'cypress/assets/demo.jpg' -import { dummyShapes } from 'cypress/assets/shapes' +import React, { useState } from 'react'; +import anotherDummyImage from 'cypress/assets/another-demo.jpg'; +import dummyImage from 'cypress/assets/demo.jpg'; +import { dummyShapes } from 'cypress/assets/shapes'; -import type { AnnotationData } from '@/common/types' +import type { AnnotationData, PointerPosition } from '@/common/types'; -import AnnotationLens from './AnnotationLens' +import AnnotationLens from './AnnotationLens'; -const containerHeight = 600 -const containerWidth = 600 +const CONTAINER_HEIGHT = 600; +const CONTAINER_WIDTH = 600; -interface TesterProps { - containerWidth: number - containerHeight: number - id?: string -} +type TesterProps = { + containerWidth: number; + containerHeight: number; + id?: string; +}; export const AnnotationLensPointerPositionTester = ({ id = 'annotationLens', containerHeight, containerWidth, }: TesterProps) => { - const [pointerPosition, setPointerPosition] = useState({ x: 0, y: 0 }) + const [pointerPosition, setPointerPosition] = useState({ x: 0, y: 0 }); const onMove = () => { - setPointerPosition((pointerPosition) => ({ - x: pointerPosition.x + 0.2, - y: pointerPosition.y + 0.2, - })) - } + setPointerPosition((pp: PointerPosition) => ({ + x: pp.x + 0.2, + y: pp.y + 0.2, + })); + }; return (
- ) -} + ); +}; export const AnnotationLensStateTester = ({ containerHeight, @@ -52,20 +52,20 @@ export const AnnotationLensStateTester = ({ const [data, setData] = useState({ image: dummyImage, shapes: dummyShapes, - }) + }); const passSameData = () => { - setData({ image: dummyImage, shapes: dummyShapes }) - } + setData({ image: dummyImage, shapes: dummyShapes }); + }; const passDifferentImage = () => { - setData({ image: anotherDummyImage, shapes: dummyShapes }) - } + setData({ image: anotherDummyImage, shapes: dummyShapes }); + }; const passDifferentShapes = () => { - setData({ image: dummyImage, shapes: dummyShapes.slice(3) }) - } + setData({ image: dummyImage, shapes: dummyShapes.slice(3) }); + }; const passDifferentOrientation = () => { - setData({ image: dummyImage, shapes: dummyShapes, orientation: 90 }) - } + setData({ image: dummyImage, shapes: dummyShapes, orientation: 90 }); + }; return (
- ) -} + ); +}; describe('AnnotationLens', () => { - const componentId = 'annotationLens' + const componentId = 'annotationLens'; it('mount correctly', () => { cy.mount( , ).then(() => { - cy.wait(500) - cy.pause() + cy.wait(500); + cy.pause(); cy.get('canvas') .should('have.length', 2) .each(($canvas) => { - const canvasWidth = $canvas.width() - const canvasHeight = $canvas.height() - cy.wrap(canvasWidth).should('equal', containerWidth) - cy.wrap(canvasHeight).should('equal', containerHeight) - }) - cy.get(`#${componentId}`).matchImageSnapshot(componentId + '.default') + const canvasWidth = $canvas.width(); + const canvasHeight = $canvas.height(); + cy.wrap(canvasWidth).should('equal', CONTAINER_WIDTH); + cy.wrap(canvasHeight).should('equal', CONTAINER_HEIGHT); + }); + cy.get(`#${componentId}`).matchImageSnapshot(`${componentId}.default`); cy.get('[data-cy="move-pointer"]') .click() .then(() => { cy.get(`#${componentId}`).matchImageSnapshot( - componentId + '.pointer-move.1', - ) - }) + `${componentId}.pointer-move.1`, + ); + }); cy.get('[data-cy="move-pointer"]') .click() .then(() => { cy.get(`#${componentId}`).matchImageSnapshot( - componentId + '.pointer-move.2', - ) - }) + `${componentId}.pointer-move.2`, + ); + }); cy.get('[data-cy="move-pointer"]') .click() .then(() => { cy.get(`#${componentId}`).matchImageSnapshot( - componentId + '.pointer-move.3', - ) - }) - }) - }) + `${componentId}.pointer-move.3`, + ); + }); + }); + }); it('support state changes', () => { cy.mount( , ).then(() => { - cy.get('[data-cy="same-data"]').click() - cy.wait(400) - cy.get(`#${componentId}`).matchImageSnapshot('annotationLens.same-data') - cy.get('[data-cy="different-image"]').click() - cy.wait(400) + cy.get('[data-cy="same-data"]').click(); + cy.wait(400); + cy.get(`#${componentId}`).matchImageSnapshot('annotationLens.same-data'); + cy.get('[data-cy="different-image"]').click(); + cy.wait(400); cy.get(`#${componentId}`).matchImageSnapshot( 'annotationLens.different-image', - ) - cy.get('[data-cy="different-shapes"]').click() - cy.wait(400) + ); + cy.get('[data-cy="different-shapes"]').click(); + cy.wait(400); cy.get(`#${componentId}`).matchImageSnapshot( 'annotationLens.different-shapes', - ) - cy.get('[data-cy="different-orientation"]').click() - cy.wait(400) + ); + cy.get('[data-cy="different-orientation"]').click(); + cy.wait(400); cy.get(`#${componentId}`).matchImageSnapshot( 'annotationLens.different-orientation', - ) - }) - }) -}) + ); + }); + }); +}); diff --git a/src/components/AnnotationLens.tsx b/src/components/AnnotationLens.tsx index f6c855b..3329799 100644 --- a/src/components/AnnotationLens.tsx +++ b/src/components/AnnotationLens.tsx @@ -1,5 +1,11 @@ -import React, { useEffect, useId, useRef, useState } from 'react' -import Konva from 'konva' +import React, { + useEffect, + useId, + useRef, + useState, + type ReactElement, +} from 'react'; +import Konva from 'konva'; import { DEFAULT_ANNOTATION_LENS_OPTIONS, @@ -7,33 +13,35 @@ import { DEFAULT_LENS_ZOOM_LEVEL, DEFAULT_POINTER_POSITION, DEFAULT_STYLE, - KONVA_REFS, -} from '@/common/constants' + KonvaRefs, +} from '@/common/constants'; import type { AnnotationLensOptions, AnnotationLensProps, AnnotationShape, ImageBoundingBox, ImageData, -} from '@/common/types' +} from '@/common/types'; -import { mapShapesToPolygons } from '@/utils/canvas' -import { handleResizeImage } from '@/utils/image' -import { clearLayers } from '@/utils/layer' -import { rotateImage } from '@/utils/orientation' -import { handleLensZoom } from '@/utils/zoom' +import { mapShapesToPolygons } from '@/utils/canvas'; +import { handleResizeImage } from '@/utils/image'; +import { clearLayers } from '@/utils/layer'; +import { rotateImage } from '@/utils/orientation'; +import { handleLensZoom } from '@/utils/zoom'; const drawLensShapes = ( shapesLayer: Konva.Layer, shapes: AnnotationShape[] | undefined, imageBoundingBox: ImageBoundingBox | null, options: AnnotationLensOptions, -) => { - shapesLayer.destroyChildren() - if (!shapes) return - mapShapesToPolygons(shapesLayer, shapes, false, imageBoundingBox, options) - shapesLayer.batchDraw() -} +): void => { + shapesLayer.destroyChildren(); + if (!shapes) { + return; + } + mapShapesToPolygons(shapesLayer, shapes, false, imageBoundingBox, options); + shapesLayer.batchDraw(); +}; export default function AnnotationLens({ id, @@ -43,88 +51,96 @@ export default function AnnotationLens({ style = {}, options: customOptions = {}, data = DEFAULT_DATA, -}: AnnotationLensProps) { - const generatedId = useId() - const containerId = id ?? generatedId +}: AnnotationLensProps): ReactElement { + const generatedId = useId(); + const containerId = id ?? generatedId; const options: AnnotationLensOptions = { ...DEFAULT_ANNOTATION_LENS_OPTIONS, ...customOptions, - } - const optionsKey = JSON.stringify(options) - const optionsRef = useRef(options) + }; + const optionsKey = JSON.stringify(options); + const optionsRef = useRef(options); useEffect(() => { - optionsRef.current = options - }) + optionsRef.current = options; + }); const imageDataObject = useRef({ element: new Image(), shape: new Konva.Image({ image: new Image() }), - }) - const containerRef = useRef(null) + }); + const containerRef = useRef(null); const layersObject = useRef({ - shapes: new Konva.Layer({ id: KONVA_REFS.shapesLayer, listening: false }), + shapes: new Konva.Layer({ id: KonvaRefs.ShapesLayer, listening: false }), image: new Konva.Layer({ listening: false }), - }) - const stageObject = useRef(null) - const imageBoundingBoxObject = useRef(null) - const [imageVersion, setImageVersion] = useState(0) + }); + const stageObject = useRef(null); + const imageBoundingBoxObject = useRef(null); + const [imageVersion, setImageVersion] = useState(0); - const getStageRef = useRef(getStage) + const getStageRef = useRef(getStage); useEffect(() => { - getStageRef.current = getStage - }) + getStageRef.current = getStage; + }); useEffect(() => { - const container = containerRef.current - if (!container) return - const stage = new Konva.Stage({ container, listening: false }) - stageObject.current = stage - stage.add(layersObject.current.image, layersObject.current.shapes) - layersObject.current.image.add(imageDataObject.current.shape) - getStageRef.current?.(stage) - return () => { - stage.destroy() - stageObject.current = null + const container = containerRef.current; + if (!container) { + return; } - }, []) + const stage = new Konva.Stage({ container, listening: false }); + stageObject.current = stage; + stage.add(layersObject.current.image, layersObject.current.shapes); + layersObject.current.image.add(imageDataObject.current.shape); + getStageRef.current?.(stage); + return () => { + stage.destroy(); + stageObject.current = null; + }; + }, []); - const { image, orientation, shapes } = data + const { image, orientation, shapes } = data; useEffect(() => { - if (!image) { - clearLayers(layersObject.current) - imageDataObject.current.element = new Image() - imageDataObject.current.shape.image(imageDataObject.current.element) - imageBoundingBoxObject.current = null - return + if (image === undefined || image === null || image === '') { + clearLayers(layersObject.current); + imageDataObject.current.element = new Image(); + imageDataObject.current.shape.image(imageDataObject.current.element); + imageBoundingBoxObject.current = null; + return; } - const controller = new AbortController() - const { signal } = controller + const controller = new AbortController(); + const { signal } = controller; void (async () => { try { - const src = orientation ? await rotateImage(image, orientation) : image - if (signal.aborted) return - const element = new Image() + const src = await rotateImage(image, orientation); + if (signal.aborted) { + return; + } + const element = new Image(); element.onload = () => { - if (signal.aborted) return - imageDataObject.current.element = element - imageDataObject.current.shape.image(element) + if (signal.aborted) { + return; + } + imageDataObject.current.element = element; + imageDataObject.current.shape.image(element); imageBoundingBoxObject.current = handleResizeImage( stageObject.current, containerRef.current, imageDataObject.current, - ) ?? null - setImageVersion((v) => v + 1) - } - element.src = src + ) ?? null; + setImageVersion((v) => v + 1); + }; + element.src = src; } catch (error) { - if (!signal.aborted) console.error(error) + if (!signal.aborted) { + console.error(error); + } } - })() + })(); return () => { - controller.abort() - } - }, [image, orientation]) + controller.abort(); + }; + }, [image, orientation]); useEffect(() => { handleLensZoom( @@ -132,8 +148,8 @@ export default function AnnotationLens({ imageBoundingBoxObject.current, pointerPosition, zoomLevel, - ) - }, [pointerPosition, zoomLevel, imageVersion]) + ); + }, [pointerPosition, zoomLevel, imageVersion]); useEffect(() => { drawLensShapes( @@ -141,8 +157,8 @@ export default function AnnotationLens({ shapes, imageBoundingBoxObject.current, optionsRef.current, - ) - }, [shapes, optionsKey, imageVersion]) + ); + }, [shapes, optionsKey, imageVersion]); return (
- ) + ); } diff --git a/src/components/AnnotationViewer.spec.tsx b/src/components/AnnotationViewer.spec.tsx index d23a5e3..f242095 100644 --- a/src/components/AnnotationViewer.spec.tsx +++ b/src/components/AnnotationViewer.spec.tsx @@ -1,50 +1,52 @@ -import React, { useState } from 'react' -import anotherDummyImage from 'cypress/assets/another-demo.jpg' -import dummyImageHEIC from 'cypress/assets/demo.heic' -import dummyImage from 'cypress/assets/demo.jpg' -import dummyImageTIFF from 'cypress/assets/demo.tiff' -import { dummyShapes } from 'cypress/assets/shapes' +import React, { useState } from 'react'; +import anotherDummyImage from 'cypress/assets/another-demo.jpg'; +import dummyImageHEIC from 'cypress/assets/demo.heic'; +import dummyImage from 'cypress/assets/demo.jpg'; +import dummyImageTIFF from 'cypress/assets/demo.tiff'; +import { dummyShapes } from 'cypress/assets/shapes'; +import Konva from 'konva'; -import type { AnnotationData } from '@/common/types' +import { KonvaRefs } from '@/common/constants'; +import type { AnnotationData } from '@/common/types'; -import AnnotationViewer from './AnnotationViewer' +import AnnotationViewer from './AnnotationViewer'; const dummyImageURL = - 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/ReceiptSwiss.jpg/1280px-ReceiptSwiss.jpg' + 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/ReceiptSwiss.jpg/1280px-ReceiptSwiss.jpg'; -const containerHeight = 800 -const containerWidth = 700 -const containerId = 'annotationViewer' +const CONTAINER_HEIGHT = 800; +const CONTAINER_WIDTH = 700; +const containerId = 'annotationViewer'; export const AnnotationViewerStateTester = ({ containerHeight, containerWidth, id = containerId, }: { - containerWidth: number - containerHeight: number - id?: string + containerWidth: number; + containerHeight: number; + id?: string; }) => { const [data, setData] = useState({ image: dummyImage, shapes: dummyShapes, - }) + }); const passSameData = () => { - setData({ image: dummyImage, shapes: dummyShapes }) - } + setData({ image: dummyImage, shapes: dummyShapes }); + }; const passDifferentImage = () => { - setData({ image: anotherDummyImage, shapes: dummyShapes }) - } + setData({ image: anotherDummyImage, shapes: dummyShapes }); + }; const passDifferentShapes = () => { - setData({ image: dummyImage, shapes: dummyShapes.slice(3) }) - } + setData({ image: dummyImage, shapes: dummyShapes.slice(3) }); + }; const passDifferentOrientation = () => { - setData({ image: dummyImage, shapes: dummyShapes, orientation: 90 }) - } + setData({ image: dummyImage, shapes: dummyShapes, orientation: 90 }); + }; const passEmptyImage = () => { - setData({ shapes: dummyShapes }) - } + setData({ shapes: dummyShapes }); + }; return (
- ) -} + ); +}; describe('AnnotationViewer', () => { it('shows an JPG image in the canvas', () => { @@ -88,92 +90,92 @@ describe('AnnotationViewer', () => { , - ) - cy.get(`#${containerId}`).should('be.visible') - cy.wait(1000) + ); + cy.get(`#${containerId}`).should('be.visible'); + cy.wait(1000); cy.get('canvas') .should('have.length', 2) .each(($canvas) => { - const canvasWidth = $canvas.width() - const canvasHeight = $canvas.height() - cy.wrap(canvasWidth).should('equal', containerWidth) - cy.wrap(canvasHeight).should('equal', containerHeight) - }) - cy.get(`#${containerId}`).matchImageSnapshot('jpg.file') - }) + const canvasWidth = $canvas.width(); + const canvasHeight = $canvas.height(); + cy.wrap(canvasWidth).should('equal', CONTAINER_WIDTH); + cy.wrap(canvasHeight).should('equal', CONTAINER_HEIGHT); + }); + cy.get(`#${containerId}`).matchImageSnapshot('jpg.file'); + }); it('shows a TIFF image in the canvas', () => { cy.mount( , - ) - cy.get(`#${containerId}`).should('be.visible') - cy.wait(1000) + ); + cy.get(`#${containerId}`).should('be.visible'); + cy.wait(1000); cy.get('canvas') .should('have.length', 2) .each(($canvas) => { - const canvasWidth = $canvas.width() - const canvasHeight = $canvas.height() - cy.wrap(canvasWidth).should('equal', containerWidth) - cy.wrap(canvasHeight).should('equal', containerHeight) - }) - cy.get(`#${containerId}`).matchImageSnapshot('tiff.file') - }) + const canvasWidth = $canvas.width(); + const canvasHeight = $canvas.height(); + cy.wrap(canvasWidth).should('equal', CONTAINER_WIDTH); + cy.wrap(canvasHeight).should('equal', CONTAINER_HEIGHT); + }); + cy.get(`#${containerId}`).matchImageSnapshot('tiff.file'); + }); it('shows a HEIC image in the canvas', () => { cy.mount( , - ) - cy.get(`#${containerId}`).should('be.visible') - cy.wait(1000) + ); + cy.get(`#${containerId}`).should('be.visible'); + cy.wait(1000); cy.get('canvas') .should('have.length', 2) .each(($canvas) => { - const canvasWidth = $canvas.width() - const canvasHeight = $canvas.height() - cy.wrap(canvasWidth).should('equal', containerWidth) - cy.wrap(canvasHeight).should('equal', containerHeight) - }) - cy.get(`#${containerId}`).matchImageSnapshot('heic.file') - }) + const canvasWidth = $canvas.width(); + const canvasHeight = $canvas.height(); + cy.wrap(canvasWidth).should('equal', CONTAINER_WIDTH); + cy.wrap(canvasHeight).should('equal', CONTAINER_HEIGHT); + }); + cy.get(`#${containerId}`).matchImageSnapshot('heic.file'); + }); it('shows a remote image in the canvas', () => { cy.mount( , - ) - cy.get(`#${containerId}`).should('be.visible') - cy.wait(1000) + ); + cy.get(`#${containerId}`).should('be.visible'); + cy.wait(1000); cy.get('canvas') .should('have.length', 2) .each(($canvas) => { - const canvasWidth = $canvas.width() - const canvasHeight = $canvas.height() - cy.wrap(canvasWidth).should('equal', containerWidth) - cy.wrap(canvasHeight).should('equal', containerHeight) - }) - cy.get(`#${containerId}`).matchImageSnapshot('remote.file') - }) + const canvasWidth = $canvas.width(); + const canvasHeight = $canvas.height(); + cy.wrap(canvasWidth).should('equal', CONTAINER_WIDTH); + cy.wrap(canvasHeight).should('equal', CONTAINER_HEIGHT); + }); + cy.get(`#${containerId}`).matchImageSnapshot('remote.file'); + }); it('zoom correctly', () => { cy.mount( , - ) - cy.wait(1000) + ); + cy.wait(1000); cy.get(`#${containerId}`) .trigger('wheel', { deltaY: -60, @@ -183,47 +185,47 @@ describe('AnnotationViewer', () => { }) .trigger('wheel', { deltaY: -60, - }) - cy.wait(200) - cy.get(`#${containerId}`).matchImageSnapshot('zoomed') - }) + }); + cy.wait(200); + cy.get(`#${containerId}`).matchImageSnapshot('zoomed'); + }); it('handle events correctly', () => { - const onShapeClick = cy.spy().as('onShapeClick') - const onShapeMouseEnter = cy.spy().as('onShapeMouseEnter') - const onShapeMouseLeave = cy.spy().as('onShapeMouseLeave') + const onShapeClick = cy.spy().as('onShapeClick'); + const onShapeMouseEnter = cy.spy().as('onShapeMouseEnter'); + const onShapeMouseLeave = cy.spy().as('onShapeMouseLeave'); cy.mount( , - ) - cy.wait(1000) + ); + cy.wait(1000); - cy.get(`#${containerId} .konvajs-content`).trigger('mousemove', 350, 50) + cy.get(`#${containerId} .konvajs-content`).trigger('mousemove', 350, 50); cy.get('@onShapeMouseEnter').should( 'have.been.calledOnceWith', dummyShapes[1], - ) + ); - cy.get(`#${containerId}`).click(350, 50) - cy.get('@onShapeClick').should('have.been.calledOnceWith', dummyShapes[1]) + cy.get(`#${containerId}`).click(350, 50); + cy.get('@onShapeClick').should('have.been.calledOnceWith', dummyShapes[1]); - cy.get(`#${containerId} .konvajs-content`).trigger('mousemove', 10, 10) + cy.get(`#${containerId} .konvajs-content`).trigger('mousemove', 10, 10); cy.get('@onShapeMouseLeave').should( 'have.been.calledOnceWith', dummyShapes[1], - ) - cy.get('@onShapeMouseEnter').should('have.been.calledOnce') - cy.get(`#${containerId}`).matchImageSnapshot('shapeClicked') - }) + ); + cy.get('@onShapeMouseEnter').should('have.been.calledOnce'); + cy.get(`#${containerId}`).matchImageSnapshot('shapeClicked'); + }); it('support multi selection', () => { - const onShapeMultiSelectSpy = cy.spy().as('onShapeMultiSelectSpy') + const onShapeMultiSelectSpy = cy.spy().as('onShapeMultiSelectSpy'); cy.mount( { }} id={containerId} data={{ image: dummyImage, shapes: dummyShapes }} - style={{ height: containerHeight, width: containerWidth }} + style={{ height: CONTAINER_HEIGHT, width: CONTAINER_WIDTH }} onShapeMultiSelect={onShapeMultiSelectSpy} />, ).then(() => { @@ -244,22 +246,63 @@ describe('AnnotationViewer', () => { .trigger('keydown', { altKey: true, ctrlKey: true }) .trigger('mousedown', { which: 1, clientX: 10, clientY: 10 }) .trigger('mousemove', { which: 1, clientX: 600, clientY: 300 }) - .matchImageSnapshot('multi-select') + .matchImageSnapshot('multi-select'); cy.get(`#${containerId}`) .trigger('mouseup') .trigger('keyup', { altKey: true, ctrlKey: true }) .then(() => { - cy.wait(200) + cy.wait(200); cy.get('@onShapeMultiSelectSpy').should( 'have.been.calledOnceWithExactly', dummyShapes.slice(0, 2), - ) - }) - }) - }) + ); + }); + }); + }); + + it('does not select shapes when enableSelection is not set', () => { + const onShapeMultiSelectSpy = cy.spy().as('onShapeMultiSelectSpy'); + let stage: Konva.Stage | null = null; + + cy.mount( + { + stage = s; + }} + />, + ); + cy.wait(1000); + 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.wait(200); + cy.get('@onShapeMultiSelectSpy') + .should('not.have.been.called') + .then(() => { + const shapesLayer = stage?.findOne(`#${KonvaRefs.ShapesLayer}`); + expect(shapesLayer).to.be.instanceOf(Konva.Layer); + expect((shapesLayer as Konva.Layer).find('Rect')).to.have.length(0); + expect((shapesLayer as Konva.Layer).find('Line')).to.have.length( + dummyShapes.length, + ); + }); + }); it('support custom options', () => { - dummyShapes[0].config = { fill: 'green', opacity: 0.2 } + const [firstShape] = dummyShapes; + if (!firstShape) { + throw new Error('fixture has no shapes'); + } + firstShape.config = { fill: 'green', opacity: 0.2 }; cy.mount( { }} id={containerId} data={{ image: dummyImage, shapes: dummyShapes }} - style={{ height: containerHeight, width: containerWidth }} + style={{ height: CONTAINER_HEIGHT, width: CONTAINER_WIDTH }} />, ).then(() => { - cy.wait(200) - cy.get(`#${containerId}`).matchImageSnapshot('custom-options') - }) - }) + cy.wait(200); + cy.get(`#${containerId}`).matchImageSnapshot('custom-options'); + }); + }); // it.only('support custom zoom level', () => { // dummyShapes[0].config = { fill: 'green', opacity: 0.2 } @@ -294,22 +337,22 @@ describe('AnnotationViewer', () => { cy.mount( , ).then(() => { - cy.get('[data-cy="same-data"]').click() - cy.wait(400) - cy.get(`#${containerId}`).matchImageSnapshot('same-data') - cy.get('[data-cy="different-image"]').click() - cy.wait(400) - cy.get(`#${containerId}`).matchImageSnapshot('different-image') - cy.get('[data-cy="different-shapes"]').click() - cy.wait(400) - cy.get(`#${containerId}`).matchImageSnapshot('different-shapes') - cy.get('[data-cy="different-orientation"]').click() - cy.wait(400) - cy.get(`#${containerId}`).matchImageSnapshot('different-orientation') - }) - }) -}) + cy.get('[data-cy="same-data"]').click(); + cy.wait(400); + cy.get(`#${containerId}`).matchImageSnapshot('same-data'); + cy.get('[data-cy="different-image"]').click(); + cy.wait(400); + cy.get(`#${containerId}`).matchImageSnapshot('different-image'); + cy.get('[data-cy="different-shapes"]').click(); + cy.wait(400); + cy.get(`#${containerId}`).matchImageSnapshot('different-shapes'); + cy.get('[data-cy="different-orientation"]').click(); + cy.wait(400); + cy.get(`#${containerId}`).matchImageSnapshot('different-orientation'); + }); + }); +}); diff --git a/src/components/AnnotationViewer.tsx b/src/components/AnnotationViewer.tsx index 984c40d..7892f7f 100644 --- a/src/components/AnnotationViewer.tsx +++ b/src/components/AnnotationViewer.tsx @@ -1,36 +1,42 @@ -import React, { useEffect, useId, useRef, useState } from 'react' -import Konva from 'konva' +import React, { + useEffect, + useId, + useRef, + useState, + type ReactElement, +} from 'react'; +import Konva from 'konva'; import { DEFAULT_ANNOTATION_VIEWER_OPTIONS, DEFAULT_DATA, DEFAULT_STYLE, - KONVA_REFS, -} from '@/common/constants' + KonvaRefs, +} from '@/common/constants'; import type { AnnotationShape, AnnotationViewerOptions, AnnotationViewerProps, ImageBoundingBox, ImageData, -} from '@/common/types' +} from '@/common/types'; -import { getMousePosition, mapShapesToPolygons } from '@/utils/canvas' +import { getMousePosition, mapShapesToPolygons } from '@/utils/canvas'; import { handleResizeImage, prepareImage, setStageBasedImagePosition, -} from '@/utils/image' -import { clearLayers } from '@/utils/layer' -import { rotateImage } from '@/utils/orientation' +} from '@/utils/image'; +import { clearLayers } from '@/utils/layer'; +import { rotateImage } from '@/utils/orientation'; import { createSelectionRect, onSelectionEnd, onSelectionMove, onSelectionStart, -} from '@/utils/selection' -import useMultiSelection from '@/utils/useMultiSelection' -import { getZoomScale, handleStageZoom, handleZoomScale } from '@/utils/zoom' +} from '@/utils/selection'; +import useMultiSelection from '@/utils/useMultiSelection'; +import { getZoomScale, handleStageZoom, handleZoomScale } from '@/utils/zoom'; type ShapeCallbacks = Pick< AnnotationViewerProps, @@ -40,7 +46,7 @@ type ShapeCallbacks = Pick< | 'onShapeMouseEnter' | 'onShapeMouseLeave' | 'onShapeMultiSelect' -> +>; const drawViewerShapes = ( shapesLayer: Konva.Layer, @@ -49,13 +55,15 @@ const drawViewerShapes = ( options: AnnotationViewerOptions, selectionRect: Konva.Rect, callbacks: ShapeCallbacks, -) => { - shapesLayer.destroyChildren() - if (options.enableSelection) { - selectionRect.setAttrs({ ...options.selectionRectConfig }) - shapesLayer.add(selectionRect) +): void => { + shapesLayer.destroyChildren(); + if (options.enableSelection === true) { + selectionRect.setAttrs({ ...options.selectionRectConfig }); + shapesLayer.add(selectionRect); + } + if (!shapes) { + return; } - if (!shapes) return mapShapesToPolygons( shapesLayer, shapes, @@ -65,9 +73,9 @@ const drawViewerShapes = ( callbacks.onShapeClick, callbacks.onShapeMouseEnter, callbacks.onShapeMouseLeave, - ) - shapesLayer.batchDraw() -} + ); + shapesLayer.batchDraw(); +}; export default function AnnotationViewer({ id, @@ -82,33 +90,33 @@ export default function AnnotationViewer({ onShapeClick, options: customOptions = {}, data = DEFAULT_DATA, -}: AnnotationViewerProps) { - const generatedId = useId() - const containerId = id ?? generatedId +}: AnnotationViewerProps): ReactElement { + const generatedId = useId(); + const containerId = id ?? generatedId; const options: AnnotationViewerOptions = { ...DEFAULT_ANNOTATION_VIEWER_OPTIONS, ...customOptions, - } - const optionsKey = JSON.stringify(options) - const isSelectionActiveRef = useRef(false) - const containerRef = useRef(null) - const selectionRectObject = useRef(createSelectionRect(options)) + }; + const optionsKey = JSON.stringify(options); + const isSelectionActiveRef = useRef(false); + const containerRef = useRef(null); + const selectionRectObject = useRef(createSelectionRect(options)); const imageDataObject = useRef({ element: new Image(), shape: new Konva.Image({ image: new Image() }), - }) + }); const layersObject = useRef({ - shapes: new Konva.Layer({ id: KONVA_REFS.shapesLayer }), + shapes: new Konva.Layer({ id: KonvaRefs.ShapesLayer }), image: new Konva.Layer({ listening: false }), - }) - const stageObject = useRef(null) - const imageBoundingBoxObject = useRef(null) - const [imageVersion, setImageVersion] = useState(0) + }); + const stageObject = useRef(null); + const imageBoundingBoxObject = useRef(null); + const [imageVersion, setImageVersion] = useState(0); - const optionsRef = useRef(options) - const callbacksRef = useRef({}) + const optionsRef = useRef(options); + const callbacksRef = useRef({}); useEffect(() => { - optionsRef.current = options + optionsRef.current = options; callbacksRef.current = { getPointerPosition, getStage, @@ -116,32 +124,36 @@ export default function AnnotationViewer({ onShapeMouseEnter, onShapeMouseLeave, onShapeMultiSelect, - } - }) + }; + }); - useMultiSelection({ stageRef: stageObject, isSelectionActiveRef }) + useMultiSelection({ stageRef: stageObject, isSelectionActiveRef }); - const fitImageToContainer = () => { + const fitImageToContainer = (): void => { const imageBoundingBox = handleResizeImage( stageObject.current, containerRef.current, imageDataObject.current, - ) - if (!imageBoundingBox) return - imageBoundingBoxObject.current = imageBoundingBox - layersObject.current.image.batchDraw() - setImageVersion((v) => v + 1) - } + ); + if (!imageBoundingBox) { + return; + } + imageBoundingBoxObject.current = imageBoundingBox; + layersObject.current.image.batchDraw(); + setImageVersion((v) => v + 1); + }; useEffect(() => { - const container = containerRef.current - if (!container) return - const stage = new Konva.Stage({ container }) - const layers = layersObject.current - stageObject.current = stage - stage.add(layers.image, layers.shapes) - layers.image.add(imageDataObject.current.shape) - callbacksRef.current.getStage?.(stage) + const container = containerRef.current; + if (!container) { + return; + } + const stage = new Konva.Stage({ container }); + const layers = layersObject.current; + stageObject.current = stage; + stage.add(layers.image, layers.shapes); + layers.image.add(imageDataObject.current.shape); + callbacksRef.current.getStage?.(stage); stage.on('wheel', (event) => { handleStageZoom( @@ -149,96 +161,113 @@ export default function AnnotationViewer({ imageBoundingBoxObject.current, event, optionsRef.current, - ) - }) + ); + }); stage.on('mousemove', () => { - const { getPointerPosition } = callbacksRef.current - if (!getPointerPosition) return + const { getPointerPosition: getPointerPosCallback } = + callbacksRef.current; + if (!getPointerPosCallback) { + return; + } const mousePointTo = getMousePosition( stage, imageBoundingBoxObject.current, - ) - if (mousePointTo) getPointerPosition(mousePointTo) - }) + ); + if (mousePointTo) { + getPointerPosCallback(mousePointTo); + } + }); stage.on('mousedown touchstart', (event) => { - if (!optionsRef.current.enableSelection) return + if (optionsRef.current.enableSelection !== true) { + return; + } onSelectionStart( event, layers.shapes, selectionRectObject.current, isSelectionActiveRef.current, - ) - }) + ); + }); stage.on('mousemove touchmove', () => { - if (!optionsRef.current.enableSelection) return - onSelectionMove(layers.shapes, selectionRectObject.current) - }) + if (optionsRef.current.enableSelection !== true) { + return; + } + onSelectionMove(layers.shapes, selectionRectObject.current); + }); stage.on('mouseup touchend', () => { - if (!optionsRef.current.enableSelection) return + if (optionsRef.current.enableSelection !== true) { + return; + } onSelectionEnd( layers.shapes, selectionRectObject.current, callbacksRef.current.onShapeMultiSelect, - ) - }) + ); + }); stage.on('dragstart', () => { - if (!optionsRef.current.enableSelection) return - stage.container().style.cursor = 'grabbing' - }) + if (optionsRef.current.enableSelection !== true) { + return; + } + stage.container().style.cursor = 'grabbing'; + }); stage.on('dragend', () => { - if (!optionsRef.current.enableSelection) return - stage.container().style.cursor = 'pointer' - }) + if (optionsRef.current.enableSelection !== true) { + return; + } + stage.container().style.cursor = 'pointer'; + }); return () => { - stage.destroy() - stageObject.current = null - } - }, []) + stage.destroy(); + stageObject.current = null; + }; + }, []); useEffect(() => { - window.addEventListener('resize', fitImageToContainer) + window.addEventListener('resize', fitImageToContainer); return () => { - window.removeEventListener('resize', fitImageToContainer) - } - }, []) + window.removeEventListener('resize', fitImageToContainer); + }; + }, []); - const { image, orientation, shapes } = data + const { image, orientation, shapes } = data; useEffect(() => { - if (!image) { - clearLayers(layersObject.current) - imageDataObject.current.element = new Image() - imageDataObject.current.shape.image(imageDataObject.current.element) - imageBoundingBoxObject.current = null - return + if (image === undefined || image === null || image === '') { + clearLayers(layersObject.current); + imageDataObject.current.element = new Image(); + imageDataObject.current.shape.image(imageDataObject.current.element); + imageBoundingBoxObject.current = null; + return; } - const controller = new AbortController() - const { signal } = controller + const controller = new AbortController(); + const { signal } = controller; void (async () => { try { - const prepared = await prepareImage(image) - signal.throwIfAborted() - const src = orientation - ? await rotateImage(prepared, orientation) - : prepared - signal.throwIfAborted() - const element = new Image() + const prepared = await prepareImage(image); + signal.throwIfAborted(); + const src = await rotateImage(prepared, orientation); + signal.throwIfAborted(); + const element = new Image(); element.onload = () => { - if (signal.aborted) return - imageDataObject.current.element = element - imageDataObject.current.shape.image(element) - fitImageToContainer() - } - element.src = src + if (signal.aborted) { + return; + } + imageDataObject.current.element = element; + imageDataObject.current.shape.image(element); + fitImageToContainer(); + }; + element.src = src; } catch (error) { - if (!signal.aborted) console.error(error) + if (!signal.aborted) { + console.error(error); + } } - })() - return () => { - controller.abort() - } - }, [image, orientation]) + })(); + return (): void => { + controller.abort(); + }; + }, [image, orientation]); useEffect(() => { drawViewerShapes( @@ -249,10 +278,10 @@ export default function AnnotationViewer({ ...optionsRef.current, onClick: (polygon) => optionsRef.current.onClick?.(polygon), onMouseEnter: (polygon) => { - optionsRef.current.onMouseEnter?.(polygon) + optionsRef.current.onMouseEnter?.(polygon); }, onMouseLeave: (polygon) => { - optionsRef.current.onMouseLeave?.(polygon) + optionsRef.current.onMouseLeave?.(polygon); }, }, selectionRectObject.current, @@ -263,23 +292,27 @@ export default function AnnotationViewer({ onShapeMouseLeave: (shape) => callbacksRef.current.onShapeMouseLeave?.(shape), }, - ) - }, [shapes, optionsKey, imageVersion]) + ); + }, [shapes, optionsKey, imageVersion]); useEffect(() => { - if (!customZoomLevel) return + if (customZoomLevel === undefined) { + return; + } handleZoomScale( stageObject.current, customZoomLevel, imageBoundingBoxObject.current, - ) - }, [customZoomLevel, imageVersion]) + ); + }, [customZoomLevel, imageVersion]); useEffect(() => { - const stage = stageObject.current - const imageBoundingBox = imageBoundingBoxObject.current - if (!customStagePosition || !stage || !imageBoundingBox) return - const zoomScale = getZoomScale(stage) + const stage = stageObject.current; + const imageBoundingBox = imageBoundingBoxObject.current; + if (!customStagePosition || !stage || !imageBoundingBox) { + return; + } + const zoomScale = getZoomScale(stage); setStageBasedImagePosition({ imageBoundingBox, stage, @@ -287,8 +320,8 @@ export default function AnnotationViewer({ x: stage.x() + customStagePosition.x * zoomScale, y: stage.y() + customStagePosition.y * zoomScale, }, - }) - }, [customStagePosition]) + }); + }, [customStagePosition]); return (
- ) + ); } diff --git a/src/components/dynamicZoom.spec.tsx b/src/components/dynamicZoom.spec.tsx index 0d6da59..dc67b28 100644 --- a/src/components/dynamicZoom.spec.tsx +++ b/src/components/dynamicZoom.spec.tsx @@ -1,39 +1,44 @@ -import React, { useRef, useState } from 'react' -import dummyImage from 'cypress/assets/demo.jpg' -import { dummyShapes } from 'cypress/assets/shapes' -import type { Stage } from 'konva/lib/Stage' +import React, { useRef, useState } from 'react'; +import dummyImage from 'cypress/assets/demo.jpg'; +import { dummyShapes } from 'cypress/assets/shapes'; +import type { Stage } from 'konva/lib/Stage'; -import type { AnnotationViewerProps, PointerPosition } from '@/common/types' +import type { AnnotationViewerProps, PointerPosition } from '@/common/types'; -import { getZoomScale } from '@/utils/zoom' +import { getZoomScale } from '@/utils/zoom'; -import AnnotationViewer from './AnnotationViewer' +import AnnotationViewer from './AnnotationViewer'; -const containerHeight = 700 -const containerWidth = 700 +const containerHeight = 700; +const containerWidth = 700; -interface AnnotationViewerWithDynamicZoomProps { - id: AnnotationViewerProps['id'] - data: AnnotationViewerProps['data'] -} +type AnnotationViewerWithDynamicZoomProps = { + id: AnnotationViewerProps['id']; + data: AnnotationViewerProps['data']; +}; const AnnotationViewerWithDynamicZoom = ({ id, data, }: AnnotationViewerWithDynamicZoomProps) => { - const [customStagePosition, setCustomStagePosition] = useState({ x: 0, y: 0 }) - const stageObject = useRef(null) - const [customZoomLevel, setCustomZoomLevel] = useState(1) + const [customStagePosition, setCustomStagePosition] = useState({ + x: 0, + y: 0, + }); + const stageObject = useRef(null); + const [customZoomLevel, setCustomZoomLevel] = useState(1); const changeScale = (modifier: number) => { - const stage = stageObject.current - if (!stage) return - setCustomZoomLevel(modifier * getZoomScale(stage)) - } + const stage = stageObject.current; + if (!stage) { + return; + } + setCustomZoomLevel(modifier * getZoomScale(stage)); + }; const changePosition = (newPosition: PointerPosition) => { - setCustomStagePosition(newPosition) - } + setCustomStagePosition(newPosition); + }; return (
{ - changeScale(1.2) + changeScale(1.2); }} > Zoom in @@ -51,7 +56,7 @@ const AnnotationViewerWithDynamicZoom = ({
- ) -} + ); +}; describe('AnnotationViewer', () => { it('mount correctly', () => { @@ -111,6 +116,6 @@ describe('AnnotationViewer', () => { id="annotationViewer" data={{ image: dummyImage, shapes: dummyShapes }} />, - ) - }) -}) + ); + }); +}); diff --git a/src/declarations.d.ts b/src/declarations.d.ts index d973cc3..7c3df58 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -1,8 +1,8 @@ -declare module '*.png' -declare module '*.jpeg' -declare module '*.pdf' -declare module '*.jpg' -declare module '*.jpeg' -declare module '*.svg' -declare module '*.tiff' -declare module '*.heic' +declare module '*.png'; +declare module '*.jpeg'; +declare module '*.pdf'; +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.svg'; +declare module '*.tiff'; +declare module '*.heic'; diff --git a/src/index.ts b/src/index.ts index 3f5ef59..3c4c156 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import type { Stage } from 'konva/lib/Stage' +import type { Stage } from 'konva/lib/Stage'; import type { AnnotationData, @@ -7,20 +7,20 @@ import type { AnnotationViewerOptions, Orientation, PointerPosition, -} from './common/types' -import AnnotationLens from './components/AnnotationLens' -import AnnotationViewer from './components/AnnotationViewer' +} from './common/types'; +import AnnotationLens from './components/AnnotationLens'; +import AnnotationViewer from './components/AnnotationViewer'; import { drawLayer, drawShape, drawShapes, setShapeConfig, toBase64, -} from './utils/functions' -import getImagesFromPDF from './utils/getImagesFromPDF' -import getPDFPageCount from './utils/getPDFPageCount' -import { dataURItoBlob } from './utils/image' -import { getZoomScale } from './utils/zoom' +} from './utils/functions'; +import getImagesFromPDF from './utils/getImagesFromPDF'; +import getPDFPageCount from './utils/getPDFPageCount'; +import { dataURItoBlob } from './utils/image'; +import { getZoomScale } from './utils/zoom'; export type { Stage, @@ -30,7 +30,7 @@ export type { PointerPosition, Orientation, AnnotationData, -} +}; export { toBase64, drawShapes, @@ -43,4 +43,4 @@ export { drawShape, drawLayer, setShapeConfig, -} +}; diff --git a/src/utils/canvas.spec.ts b/src/utils/canvas.spec.ts index 6c244a2..ee1231d 100644 --- a/src/utils/canvas.spec.ts +++ b/src/utils/canvas.spec.ts @@ -1,28 +1,28 @@ -import type { Stage } from 'konva/lib/Stage' +import type { Stage } from 'konva/lib/Stage'; -import type { ImageBoundingBox } from '@/common/types' +import type { ImageBoundingBox } from '@/common/types'; -import { getMousePosition, scalePointToImage } from '@/utils/canvas' +import { getMousePosition, scalePointToImage } from '@/utils/canvas'; describe('canvas', () => { describe('scalePointToImage', () => { it('should scale the point to the image', () => { - const point = { x: 0.5, y: 0.5 } + const point = { x: 0.5, y: 0.5 }; const imageBoundingBox = { x: 50, y: 0, width: 600, height: 800, scale: 0.781, - } + }; - const expected = { x: 384.12, y: 512.16 } + const expected = { x: 384.12, y: 512.16 }; - const actual = scalePointToImage(point, imageBoundingBox) + const actual = scalePointToImage(point, imageBoundingBox); - expect(actual).to.deep.equal(expected) - }) - }) + expect(actual).to.deep.equal(expected); + }); + }); describe('getMousePosition', () => { it('should return the correct mouse position', () => { @@ -31,7 +31,7 @@ describe('canvas', () => { scaleX: () => 1, x: () => 0, y: () => 0, - } + }; const imageBoundingBox = { x: 50, @@ -39,30 +39,30 @@ describe('canvas', () => { width: 600, height: 800, scale: 1.5, - } + }; - const expected = { x: 0.25, y: 0.38 } + const expected = { x: 0.25, y: 0.38 }; const actual = getMousePosition( stage as unknown as Stage, imageBoundingBox, - ) + ); - expect(actual).to.deep.equal(expected) - }) + expect(actual).to.deep.equal(expected); + }); it('should do nothing if stage or imageBoundingBox are null', () => { - const expected = undefined + const expected = undefined; { - const actual = getMousePosition({} as Stage, null) - expect(actual).to.equal(expected) + const actual = getMousePosition({} as Stage, null); + expect(actual).to.equal(expected); } { - const actual = getMousePosition(null, {} as ImageBoundingBox) - expect(actual).to.equal(expected) + const actual = getMousePosition(null, {} as ImageBoundingBox); + expect(actual).to.equal(expected); } - }) - }) -}) + }); + }); +}); diff --git a/src/utils/canvas.ts b/src/utils/canvas.ts index 0989eb3..a1922e7 100644 --- a/src/utils/canvas.ts +++ b/src/utils/canvas.ts @@ -1,20 +1,22 @@ -import Konva from 'konva' -import type { Layer } from 'konva/lib/Layer' -import type { Line } from 'konva/lib/shapes/Line' +import Konva from 'konva'; +import type { Layer } from 'konva/lib/Layer'; +import type { Line } from 'konva/lib/shapes/Line'; -import { KONVA_REFS } from '@/common/constants' +import { KonvaRefs } from '@/common/constants'; import type { AnnotationLensOptions, AnnotationShape, AnnotationViewerOptions, ImageBoundingBox, PointerPosition, -} from '@/common/types' +} from '@/common/types'; -import { roundTo } from './roundTo' +import { roundTo } from './roundTo'; -const shapeByNode = new WeakMap() -export const getShapeFromNode = (node: Konva.Node) => shapeByNode.get(node) +const shapeByNode = new WeakMap(); +export const getShapeFromNode = ( + node: Konva.Node, +): AnnotationShape | undefined => shapeByNode.get(node); export const mapShapesToPolygons = ( shapesLayer: Layer, @@ -25,22 +27,22 @@ export const mapShapesToPolygons = ( onClick?: (shape: AnnotationShape) => void, onShapeMouseEnter?: (shape: AnnotationShape) => void, onShapeMouseLeave?: (shape: AnnotationShape) => void, -) => { +): void => { if (!imageBoundingBox) { - return + return; } shapes.forEach((shape: AnnotationShape) => { const polygon = new Konva.Line({ id: shape.id, - name: KONVA_REFS.shape, - points: mapCoordinatesToPoints(shape.coordinates, imageBoundingBox), + name: KonvaRefs.Shape, + points: mapCoordinatesToPoints(shape, imageBoundingBox), closed: true, ...(options.shapeConfig ?? {}), ...shape.config, - }) - shapeByNode.set(polygon, shape) - shapesLayer.add(polygon) + }); + shapeByNode.set(polygon, shape); + shapesLayer.add(polygon); if (useEvents) { bindEventToPolygon( polygon, @@ -48,10 +50,10 @@ export const mapShapesToPolygons = ( onClick, onShapeMouseEnter, onShapeMouseLeave, - ) + ); } - }) -} + }); +}; const bindEventToPolygon = ( polygon: Line, @@ -59,67 +61,75 @@ const bindEventToPolygon = ( onClick?: (shape: AnnotationShape) => void, onShapeMouseEnter?: (shape: AnnotationShape) => void, onShapeMouseLeave?: (shape: AnnotationShape) => void, -) => { - const stage = polygon.getStage() - const shape = shapeByNode.get(polygon) - if (!shape || !stage) return +): void => { + const stage = polygon.getStage(); + const shape = shapeByNode.get(polygon); + if (!shape || !stage) { + return; + } polygon.on('mouseup', (event) => { - event.cancelBubble = true - onClick?.(shape) - options.onClick?.(polygon) - }) + event.cancelBubble = true; + onClick?.(shape); + options.onClick?.(polygon); + }); polygon.on('mouseleave', function (event) { - event.cancelBubble = true - stage.container().style.cursor = 'inherit' - options.onMouseLeave?.(polygon) - onShapeMouseLeave?.(shape) - }) + event.cancelBubble = true; + stage.container().style.cursor = 'inherit'; + options.onMouseLeave?.(polygon); + onShapeMouseLeave?.(shape); + }); polygon.on('mouseenter', function (event) { - event.cancelBubble = true - options.onMouseEnter?.(polygon) - stage.container().style.cursor = 'pointer' - onShapeMouseEnter?.(shape) - }) -} + event.cancelBubble = true; + options.onMouseEnter?.(polygon); + stage.container().style.cursor = 'pointer'; + onShapeMouseEnter?.(shape); + }); +}; export const scalePointToImage = ( point: PointerPosition, imageBoundingBox: ImageBoundingBox, -) => { - const { width, height, scale } = imageBoundingBox +): PointerPosition => { + const { width, height, scale } = imageBoundingBox; return { x: roundTo((Math.min(point.x, 1) * width) / scale, 2), y: roundTo((Math.min(point.y, 1) * height) / scale, 2), - } -} + }; +}; + +const isPoint = (coordinate: number[]): coordinate is [number, number] => + coordinate.length >= 2; const mapCoordinatesToPoints = ( - coordinates: number[][], + shape: AnnotationShape, imageBoundingBox: ImageBoundingBox, -): number[] => - coordinates.reduce((accumulator, element) => { - const { x, y } = scalePointToImage( - { x: element[0], y: element[1] }, - imageBoundingBox, - ) - accumulator = accumulator.concat([x, y]) - return accumulator - }, []) +): number[] => { + const points = shape.coordinates.filter(isPoint); + if (points.length !== shape.coordinates.length) { + console.warn( + `AnnotationShape "${shape.id}": ignored ${String(shape.coordinates.length - points.length)} coordinate(s) with fewer than 2 values`, + ); + } + return points.flatMap(([x, y]) => { + const scaled = scalePointToImage({ x, y }, imageBoundingBox); + return [scaled.x, scaled.y]; + }); +}; export const getMousePosition = ( stage: Konva.Stage | null, imageBoundingBox: ImageBoundingBox | null, -) => { +): PointerPosition | undefined => { if (!stage || !imageBoundingBox) { - return + return; } const { x: pointerX, y: pointerY } = stage.getPointerPosition() ?? { x: 0, y: 0, - } - const oldScale = stage.scaleX() - const stageX = stage.x() - const stageY = stage.y() + }; + const oldScale = stage.scaleX(); + const stageX = stage.x(); + const stageY = stage.y(); return { x: roundTo( ((pointerX - stageX) * imageBoundingBox.scale) / @@ -131,5 +141,5 @@ export const getMousePosition = ( (oldScale * imageBoundingBox.height), 2, ), - } -} + }; +}; diff --git a/src/utils/functions.ts b/src/utils/functions.ts index bbcb79a..a27247f 100644 --- a/src/utils/functions.ts +++ b/src/utils/functions.ts @@ -1,58 +1,60 @@ -import Konva from 'konva' -import type { LineConfig } from 'konva/lib/shapes/Line' -import type { Stage } from 'konva/lib/Stage' +import Konva from 'konva'; +import type { LineConfig } from 'konva/lib/shapes/Line'; +import type { Stage } from 'konva/lib/Stage'; -import { KONVA_REFS } from '@/common/constants' +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) => - stage.findOne(selector) as Konva.Node | undefined +const findOne = ( + stage: Konva.Stage, + selector: string, +): Konva.Node | undefined => stage.findOne(selector); -export const drawLayer = (stage: Konva.Stage) => { - const shapesLayer = findOne(stage, `#${KONVA_REFS.shapesLayer}`) +export const drawLayer = (stage: Konva.Stage): void => { + const shapesLayer = findOne(stage, `#${KonvaRefs.ShapesLayer}`); if (shapesLayer instanceof Konva.Layer) { - shapesLayer.batchDraw() + shapesLayer.batchDraw(); } else { - console.error('drawLayer : the layer is not found') + console.error('drawLayer : the layer is not found'); } -} +}; export const drawShape = ( stage: Konva.Stage, id: string | number, config: LineConfig, -) => { - const shape = findOne(stage, `#${id.toString()}`) +): void => { + const shape = findOne(stage, `#${id.toString()}`); if (shape instanceof Konva.Shape) { - shape.setAttrs(config) - shape.draw() + shape.setAttrs(config); + shape.draw(); } else { - console.error('drawShape : The provided shape id is not valid') + console.error('drawShape : The provided shape id is not valid'); } -} +}; -export const drawShapes = (stage: Stage, config: LineConfig) => { - const shapes = stage.find(`.${KONVA_REFS.shape}`) - if (shapes.length) { +export const drawShapes = (stage: Stage, config: LineConfig): void => { + const shapes = stage.find(`.${KonvaRefs.Shape}`); + if (shapes.length > 0) { shapes.forEach((shape) => { - shape.setAttrs(config) - }) - drawLayer(stage) + shape.setAttrs(config); + }); + drawLayer(stage); } -} +}; export const setShapeConfig = ( stage: Konva.Stage, id: string | number, config: LineConfig, -) => { - const shape = findOne(stage, `#${id.toString()}`) +): void => { + const shape = findOne(stage, `#${id.toString()}`); if (shape) { - shape.setAttrs(config) + shape.setAttrs(config); } else { - console.error('setShapeConfig : The provided shape id is not valid') + console.error('setShapeConfig : The provided shape id is not valid'); } -} +}; export const toBase64 = ( base64: string, @@ -61,4 +63,4 @@ export const toBase64 = ( | 'image/jpeg' | 'image/png' | 'image/svg+xml' = 'image/jpeg', -) => `data:${type};base64,${base64}` +): string => `data:${type};base64,${base64}`; diff --git a/src/utils/getImagesFromPDF.spec.tsx b/src/utils/getImagesFromPDF.spec.tsx index 8cf12d4..d582d97 100644 --- a/src/utils/getImagesFromPDF.spec.tsx +++ b/src/utils/getImagesFromPDF.spec.tsx @@ -1,24 +1,24 @@ -import brokenPDF from 'cypress/assets/broken-pdf.pdf' -import multiPage from 'cypress/assets/multi-page.pdf' +import brokenPDF from 'cypress/assets/broken-pdf.pdf'; +import multiPage from 'cypress/assets/multi-page.pdf'; -import getImagesFromPDF from './getImagesFromPDF' +import getImagesFromPDF from './getImagesFromPDF'; describe('getImageFromPDF', () => { it('should return PDF pages', () => { getImagesFromPDF(multiPage).then((images) => { - expect(images.length).to.equal(5) - }) - }) + expect(images.length).to.equal(5); + }); + }); it('should catch error when the PDF is broken', () => { getImagesFromPDF(brokenPDF).catch((error: unknown) => { - expect((error as Error).name).to.equal('InvalidPDFException') - }) - }) + expect((error as Error).name).to.equal('InvalidPDFException'); + }); + }); it('should catch error when the PDF has too many Pages', () => { getImagesFromPDF(multiPage, 3).catch((error: unknown) => { - expect((error as Error).name).to.equal('TooManyPagesError') - }) - }) -}) + expect((error as Error).name).to.equal('TooManyPagesError'); + }); + }); +}); diff --git a/src/utils/getImagesFromPDF.ts b/src/utils/getImagesFromPDF.ts index b9b75bb..1c71933 100644 --- a/src/utils/getImagesFromPDF.ts +++ b/src/utils/getImagesFromPDF.ts @@ -1,33 +1,38 @@ -import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist' -import { getDocument, GlobalWorkerOptions, version } from 'pdfjs-dist' +import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'; +import { getDocument, GlobalWorkerOptions, version } from 'pdfjs-dist'; -import { MAX_PDF_SCALE, PDF_RESOLUTION } from '@/common/constants' +import { MAX_PDF_SCALE, PDF_RESOLUTION } from '@/common/constants'; -GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.js` +GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.js`; const getImageFromPage = async ( _document: PDFDocumentProxy, pageNumber: number, resolution = PDF_RESOLUTION, -) => { - const page: PDFPageProxy = await _document.getPage(pageNumber) - const canvas = document.createElement('canvas') - const context = canvas.getContext('2d') +): 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') + throw new Error('Could not acquire a 2D canvas context'); } - const [, , width, height] = page.view - const newScale = (resolution / (height * width)) ** (1 / 2) - const safeScale = Math.min(newScale, MAX_PDF_SCALE) - const viewport = page.getViewport({ scale: safeScale }) - canvas.height = viewport.height - canvas.width = viewport.width + const [, , width, height] = page.view; + if (width === undefined || height === undefined) { + throw new Error('Invalid PDF page view'); + } + const newScale = (resolution / (height * width)) ** (1 / 2); + const safeScale = Math.min(newScale, MAX_PDF_SCALE); + const viewport = page.getViewport({ scale: safeScale }); + canvas.height = viewport.height; + canvas.width = viewport.width; const renderContext = { canvasContext: context, - viewport: viewport, - } - return page.render(renderContext).promise.then(() => canvas.toDataURL()) -} + viewport, + }; + return await page + .render(renderContext) + .promise.then(() => canvas.toDataURL()); +}; export default async function getImagesFromPDF( file: string, @@ -35,19 +40,20 @@ export default async function getImagesFromPDF( onSuccess?: () => void, resolution?: number, ): Promise { - const pdf = await getDocument(file).promise + const pdf = await getDocument(file).promise; if (pdf.numPages > maxPages) { - const error = new Error('Too many pages') - error.name = 'TooManyPagesError' - throw error + const error = new Error('Too many pages'); + error.name = 'TooManyPagesError'; + throw error; } - onSuccess?.() + onSuccess?.(); const results = await Promise.allSettled( - Array.from({ length: pdf.numPages }, (_, index) => - getImageFromPage(pdf, index + 1, resolution), + Array.from( + { length: pdf.numPages }, + async (_, index) => await getImageFromPage(pdf, index + 1, resolution), ), - ) + ); return results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : [], - ) + ); } diff --git a/src/utils/getPDFPageCount.spec.tsx b/src/utils/getPDFPageCount.spec.tsx index ce7961a..fe70d16 100644 --- a/src/utils/getPDFPageCount.spec.tsx +++ b/src/utils/getPDFPageCount.spec.tsx @@ -1,18 +1,18 @@ -import brokenPDF from 'cypress/assets/broken-pdf.pdf' -import multiPage from 'cypress/assets/multi-page.pdf' +import brokenPDF from 'cypress/assets/broken-pdf.pdf'; +import multiPage from 'cypress/assets/multi-page.pdf'; -import getPDFPageCount from './getPDFPageCount' +import getPDFPageCount from './getPDFPageCount'; describe('getPDFPageCount', () => { it('should return the number of PDF pages', () => { getPDFPageCount(multiPage).then((pageCount) => { - expect(pageCount).to.equal(5) - }) - }) + expect(pageCount).to.equal(5); + }); + }); it('should catch error when the PDF is broken', () => { getPDFPageCount(brokenPDF).catch((error: unknown) => { - expect((error as Error).name).to.equal('InvalidPDFException') - }) - }) -}) + expect((error as Error).name).to.equal('InvalidPDFException'); + }); + }); +}); diff --git a/src/utils/getPDFPageCount.ts b/src/utils/getPDFPageCount.ts index 63212e7..a8e3eb5 100644 --- a/src/utils/getPDFPageCount.ts +++ b/src/utils/getPDFPageCount.ts @@ -1,8 +1,8 @@ -import { getDocument, GlobalWorkerOptions, version } from 'pdfjs-dist' +import { getDocument, GlobalWorkerOptions, version } from 'pdfjs-dist'; -GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.js` +GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.js`; -export default async function getPDFPageCount(file: string) { - const pdf = await getDocument(file).promise - return pdf.numPages +export default async function getPDFPageCount(file: string): Promise { + const pdf = await getDocument(file).promise; + return pdf.numPages; } diff --git a/src/utils/image.spec.ts b/src/utils/image.spec.ts index 8b261cb..85ca3d3 100644 --- a/src/utils/image.spec.ts +++ b/src/utils/image.spec.ts @@ -1,17 +1,17 @@ -import type { Stage } from 'konva/lib/Stage' +import type { Stage } from 'konva/lib/Stage'; -import type { ImageBoundingBox } from '@/common/types' +import type { ImageBoundingBox } from '@/common/types'; import { computeImageBoundingBox, setStageBasedImagePosition, -} from '@/utils/image' +} from '@/utils/image'; describe('image', () => { describe('computeImageBoundingBox', () => { it('should return the correct bounding box', () => { - const htmlDivElement = { clientWidth: 700, clientHeight: 800 } - const htmlImageElement = { width: 768, height: 1024 } + const htmlDivElement = { clientWidth: 700, clientHeight: 800 }; + const htmlImageElement = { width: 768, height: 1024 }; const expected = { x: 50, @@ -19,21 +19,21 @@ describe('image', () => { width: 600, height: 800, scale: 0.781, - } + }; const actual = computeImageBoundingBox( htmlDivElement as HTMLDivElement, htmlImageElement as HTMLImageElement, - ) + ); - expect(actual).to.deep.equal(expected) - }) - }) + expect(actual).to.deep.equal(expected); + }); + }); describe('setStageBasedImagePosition', () => { it('should set the correct bounding box', () => { - const imageBoundingBox = { x: 50, y: 0, width: 600, height: 800 } - const pointerPosition = { x: 25, y: 25 } + const imageBoundingBox = { x: 50, y: 0, width: 600, height: 800 }; + const pointerPosition = { x: 25, y: 25 }; const stage = { getAttr: () => 1, @@ -43,17 +43,17 @@ describe('image', () => { height: () => 800, position: cy.spy().as('stagePositionSpy'), batchDraw: cy.spy(), - } + }; - const expected = { x: 25, y: 0 } + const expected = { x: 25, y: 0 }; setStageBasedImagePosition({ imageBoundingBox: imageBoundingBox as ImageBoundingBox, stage: stage as unknown as Stage, newPosition: pointerPosition, - }) + }); - cy.get('@stagePositionSpy').should('be.calledWithExactly', expected) - }) - }) -}) + cy.get('@stagePositionSpy').should('be.calledWithExactly', expected); + }); + }); +}); diff --git a/src/utils/image.ts b/src/utils/image.ts index 18ec5a9..34b2e5f 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -1,112 +1,128 @@ -import heic2any from 'heic2any' -import type Konva from 'konva' -import type { Stage } from 'konva/lib/Stage' -import UTIF from 'utif' +import heic2any from 'heic2any'; +import type Konva from 'konva'; +import type { Stage } from 'konva/lib/Stage'; +import UTIF from 'utif'; import type { ImageBoundingBox, ImageData, PointerPosition, -} from '@/common/types' +} from '@/common/types'; -import { getZoomScale, setZoomScale } from '@/utils/zoom' +import { getZoomScale, setZoomScale } from '@/utils/zoom'; -export const dataURItoBlob = (dataURI: string) => { - let byteString - const splitDataURL = dataURI.split(',') - if (splitDataURL[0].includes('base64')) { - // atob decodes base64 data - byteString = atob(splitDataURL[1]) +export const dataURItoBlob = (dataURI: string): Blob => { + let byteString; + const [meta, data] = dataURI.split(','); + if (meta === undefined || data === undefined) { + throw new Error('Invalid Data URI format'); + } + if (meta.includes('base64')) { + byteString = atob(data); } else { - byteString = decodeURI(dataURI.split(',')[1]) + byteString = decodeURI(data); } - const mimeString = splitDataURL[0].split(':')[1].split(';')[0] + const mimeString = meta.split(':')[1]?.split(';')[0]; // write the bytes of the string to a typed array - const ia = new Uint8Array(byteString.length) + const ia = new Uint8Array(byteString.length); for (let i = 0; i < byteString.length; i++) { - ia[i] = byteString.charCodeAt(i) + ia[i] = byteString.charCodeAt(i); } - return new Blob([ia], { type: mimeString }) -} + return new Blob([ia], { type: mimeString }); +}; -export const prepareImage = async (image: string) => { - const blob = await urlToBlob(image) +export const prepareImage = async (image: string): Promise => { + const blob = await urlToBlob(image); if (blob.type === 'image/heic') { - return await heicToJpg(blob) + return await heicToJpg(blob); } if (blob.type === 'image/tiff') { - return await tiffToJpg(blob) + return await tiffToJpg(blob); } - return image -} + return image; +}; -export const urlToBlob = async (url: string) => +export const urlToBlob = async (url: string): Promise => await fetch(url, { method: 'GET', cache: 'no-cache', - }).then((r) => r.blob()) - -export const tiffToJpg = async (blob: Blob) => { - const arrayBuffer = await blob.arrayBuffer() - const ifds = UTIF.decode(arrayBuffer) - const firstPageOfTif = ifds[0] - UTIF.decodeImage(arrayBuffer, firstPageOfTif) - const rgba = UTIF.toRGBA8(firstPageOfTif) + }).then(async (r) => await r.blob()); + +export const tiffToJpg = async (blob: Blob): Promise => { + const arrayBuffer = await blob.arrayBuffer(); + const ifds = UTIF.decode(arrayBuffer); + const firstPageOfTif = ifds[0]; + if (firstPageOfTif === undefined) { + throw new Error('Invalid TIFF format'); + } + UTIF.decodeImage(arrayBuffer, firstPageOfTif); + const rgba = UTIF.toRGBA8(firstPageOfTif); - const imageWidth = firstPageOfTif.width - const imageHeight = firstPageOfTif.height + const imageWidth = firstPageOfTif.width; + const imageHeight = firstPageOfTif.height; - const cnv = document.createElement('canvas') - cnv.width = imageWidth - cnv.height = imageHeight + const cnv = document.createElement('canvas'); + cnv.width = imageWidth; + cnv.height = imageHeight; - const ctx = cnv.getContext('2d') + const ctx = cnv.getContext('2d'); if (!ctx) { - throw new Error('Could not get 2D context') + throw new Error('Could not get 2D context'); } - const imageData = ctx.createImageData(imageWidth, imageHeight) - for (let i = 0; i < rgba.length; i++) { - imageData.data[i] = rgba[i] + const imageData = ctx.createImageData(imageWidth, imageHeight); + imageData.data.set(rgba); + ctx.putImageData(imageData, 0, 0); + return cnv.toDataURL('image/jpeg'); +}; + +export const heicToJpg = async (blob: Blob): Promise => { + 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'); } - ctx.putImageData(imageData, 0, 0) - return new Promise((resolve, reject) => { - cnv.toBlob((blob) => { - if (blob) { - resolve(URL.createObjectURL(blob)) + return await blobToDataURL(firstImage); +}; + +const blobToDataURL = async (blob: Blob): Promise => + await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); } else { - reject(new Error('Could not encode TIFF canvas to a Blob')) + reject(new Error('Could not read Blob as a data URL')); } - }) - }) -} - -export const heicToJpg = async (blob: Blob) => { - const result = await heic2any({ blob }) - return URL.createObjectURL(result as Blob) -} + }; + reader.onerror = () => { + reject(new Error('Could not read Blob as a data URL')); + }; + reader.readAsDataURL(blob); + }); export const computeImageBoundingBox = ( { clientWidth, clientHeight }: HTMLDivElement, imageObj: HTMLImageElement, -) => { - const imageAspectRatio = imageObj.width / imageObj.height - const canvasAspectRatio = clientWidth / clientHeight - let renderableHeight, renderableWidth, xStart, yStart +): ImageBoundingBox => { + const imageAspectRatio = imageObj.width / imageObj.height; + const canvasAspectRatio = clientWidth / clientHeight; + let renderableHeight, renderableWidth, xStart, yStart; - xStart = 0 - yStart = 0 - renderableHeight = clientHeight - renderableWidth = clientWidth + xStart = 0; + yStart = 0; + renderableHeight = clientHeight; + renderableWidth = clientWidth; if (imageAspectRatio < canvasAspectRatio) { - renderableWidth = imageObj.width * (renderableHeight / imageObj.height) - xStart = Math.round((clientWidth - renderableWidth) / 2) + renderableWidth = imageObj.width * (renderableHeight / imageObj.height); + xStart = Math.round((clientWidth - renderableWidth) / 2); } else if (imageAspectRatio > canvasAspectRatio) { - renderableHeight = imageObj.height * (renderableWidth / imageObj.width) - yStart = Math.round((clientHeight - renderableHeight) / 2) + renderableHeight = imageObj.height * (renderableWidth / imageObj.width); + yStart = Math.round((clientHeight - renderableHeight) / 2); } return { @@ -117,67 +133,67 @@ export const computeImageBoundingBox = ( y: yStart, width: Math.round(renderableWidth), height: Math.round(renderableHeight), - } -} + }; +}; -const resizeStage = (stage: Konva.Stage, container: HTMLDivElement) => { - stage.width(container.clientWidth) - stage.height(container.clientHeight) -} +const resizeStage = (stage: Konva.Stage, container: HTMLDivElement): void => { + stage.width(container.clientWidth); + stage.height(container.clientHeight); +}; export const handleResizeImage = ( stage: Konva.Stage | null, container: HTMLDivElement | null, { element, shape }: ImageData, -) => { +): ImageBoundingBox | undefined => { if (!container || !stage) { - return + return; } - resizeStage(stage, container) - const imageBoundingBox = computeImageBoundingBox(container, element) - const { x, y, width, height, scale } = imageBoundingBox + resizeStage(stage, container); + const imageBoundingBox = computeImageBoundingBox(container, element); + const { x, y, width, height, scale } = imageBoundingBox; stage.scale({ x: scale, y: scale, - }) - setZoomScale(stage, 1) - stage.position({ x, y }) - shape.width(width / scale) - shape.height(height / scale) - return imageBoundingBox -} + }); + setZoomScale(stage, 1); + stage.position({ x, y }); + shape.width(width / scale); + shape.height(height / scale); + return imageBoundingBox; +}; export const setStageBasedImagePosition = ({ imageBoundingBox, stage, newPosition, }: { - imageBoundingBox: ImageBoundingBox - stage: Stage - newPosition: PointerPosition -}) => { - const { x, y, width, height } = imageBoundingBox - const zoomScale = getZoomScale(stage) - let stageX = stage.x() - let stageY = stage.y() - const { x: newStageX, y: newStageY } = newPosition + imageBoundingBox: ImageBoundingBox; + stage: Stage; + newPosition: PointerPosition; +}): void => { + const { x, y, width, height } = imageBoundingBox; + const zoomScale = getZoomScale(stage); + let stageX = stage.x(); + let stageY = stage.y(); + const { x: newStageX, y: newStageY } = newPosition; if ( (newStageX < x * zoomScale || newStageX < stageX) && (newStageX > stage.width() - (width + x) * zoomScale || newStageX > stageX) ) { - stageX = newStageX + stageX = newStageX; } if ( (newStageY < y * zoomScale || newStageY < stageY) && (newStageY > stage.height() - (height + y) * zoomScale || newStageY > stageY) ) { - stageY = newStageY + stageY = newStageY; } stage.position({ x: stageX, y: stageY, - }) - stage.batchDraw() -} + }); + stage.batchDraw(); +}; diff --git a/src/utils/layer.ts b/src/utils/layer.ts index 62d3ccd..efc8f99 100644 --- a/src/utils/layer.ts +++ b/src/utils/layer.ts @@ -1,7 +1,7 @@ -import type { AnnotationLayers } from '@/common/types' +import type { AnnotationLayers } from '@/common/types'; -export const clearLayers = (layers: AnnotationLayers) => { - layers.shapes.destroyChildren() - layers.image.batchDraw() - layers.shapes.batchDraw() -} +export const clearLayers = (layers: AnnotationLayers): void => { + layers.shapes.destroyChildren(); + layers.image.batchDraw(); + layers.shapes.batchDraw(); +}; diff --git a/src/utils/orientation.ts b/src/utils/orientation.ts index 22ad87c..45db711 100644 --- a/src/utils/orientation.ts +++ b/src/utils/orientation.ts @@ -1,93 +1,103 @@ -import type { Orientation } from '@/common/types' +import type { Orientation } from '@/common/types'; const EXIF_ORIENTATION: Record = { 0: 1, 90: 6, 180: 3, 270: 8, -} +}; -export const rotateImage = (image: string, degrees: Orientation = 0) => - applyRotation(image, EXIF_ORIENTATION[degrees]) +export const rotateImage = async ( + image: string, + degrees: Orientation = 0, +): Promise => { + if (degrees === 0) { + return image; + } + return await applyRotation(image, EXIF_ORIENTATION[degrees]); +}; -const applyRotation = (file: string, orientation: number) => - new Promise((resolve, reject) => { - const image = new Image() - image.crossOrigin = '' +const applyRotation = async ( + file: string, + orientation: number, +): Promise => + await new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = ''; image.onerror = () => { - reject(new Error('Failed to load image for rotation')) - } + reject(new Error('Failed to load image for rotation')); + }; image.onload = () => { - const canvas = document.createElement('canvas') + const canvas = document.createElement('canvas'); const context = canvas.getContext('2d', { alpha: false, - }) + }); if (!context) { - reject(new Error('Could not acquire a 2D canvas context')) - return + reject(new Error('Could not acquire a 2D canvas context')); + return; } - const { width, height } = image + const { width, height } = image; const [outputWidth, outputHeight] = - orientation > 4 && orientation < 9 ? [height, width] : [width, height] + orientation > 4 && orientation < 9 ? [height, width] : [width, height]; // Using a square canvas on max(width, height) to rotate rectangular images - const wh = Math.max(width, height) - canvas.width = wh - canvas.height = wh + const wh = Math.max(width, height); + canvas.width = wh; + canvas.height = wh; // output image can be aligned to the right or bottom - let rightAligned = false - let bottomAligned = false + let rightAligned = false; + let bottomAligned = false; switch (orientation) { case 2: - context.transform(-1, 0, 0, 1, wh, 0) - rightAligned = true - break + context.transform(-1, 0, 0, 1, wh, 0); + rightAligned = true; + break; case 3: - context.transform(-1, 0, 0, -1, wh, wh) - rightAligned = true - bottomAligned = true - break + context.transform(-1, 0, 0, -1, wh, wh); + rightAligned = true; + bottomAligned = true; + break; case 4: - context.transform(1, 0, 0, -1, 0, wh) - bottomAligned = true - break + context.transform(1, 0, 0, -1, 0, wh); + bottomAligned = true; + break; case 5: - context.transform(0, 1, 1, 0, 0, 0) - break + context.transform(0, 1, 1, 0, 0, 0); + break; case 6: - context.transform(0, 1, -1, 0, wh, 0) - rightAligned = true - break + context.transform(0, 1, -1, 0, wh, 0); + rightAligned = true; + break; case 7: - context.transform(0, -1, -1, 0, wh, wh) - rightAligned = true - bottomAligned = true - break + context.transform(0, -1, -1, 0, wh, wh); + rightAligned = true; + bottomAligned = true; + break; case 8: - context.transform(0, -1, 1, 0, 0, wh) - bottomAligned = true - break + context.transform(0, -1, 1, 0, 0, wh); + bottomAligned = true; + break; default: - break + break; } // draw image now that transform matrix is set - context.drawImage(image, 0, 0, width, height) + context.drawImage(image, 0, 0, width, height); // transfer image to new canvas with correct dimensions - const outputCanvas = document.createElement('canvas') - outputCanvas.width = outputWidth - outputCanvas.height = outputHeight - const outputContext = outputCanvas.getContext('2d', { alpha: false }) + const outputCanvas = document.createElement('canvas'); + outputCanvas.width = outputWidth; + outputCanvas.height = outputHeight; + const outputContext = outputCanvas.getContext('2d', { alpha: false }); if (!outputContext) { - reject(new Error('Could not acquire a 2D canvas context')) - return + reject(new Error('Could not acquire a 2D canvas context')); + return; } - const sx = rightAligned ? canvas.width - outputCanvas.width : 0 - const sy = bottomAligned ? canvas.height - outputCanvas.height : 0 + const sx = rightAligned ? canvas.width - outputCanvas.width : 0; + const sy = bottomAligned ? canvas.height - outputCanvas.height : 0; outputContext.drawImage( canvas, sx, @@ -98,11 +108,11 @@ const applyRotation = (file: string, orientation: number) => 0, outputCanvas.width, outputCanvas.height, - ) + ); // export image data - resolve(outputCanvas.toDataURL('image/jpeg')) - } + resolve(outputCanvas.toDataURL('image/jpeg')); + }; - image.src = file - }) + image.src = file; + }); diff --git a/src/utils/roundTo.spec.ts b/src/utils/roundTo.spec.ts index 424c0f9..b663de6 100644 --- a/src/utils/roundTo.spec.ts +++ b/src/utils/roundTo.spec.ts @@ -1,33 +1,33 @@ -import { roundTo } from '@/utils/roundTo' +import { roundTo } from '@/utils/roundTo'; describe('roundTo', () => { it('should round to the correct precision', () => { - const value = 1.23456789 - const precision = 2 + const value = 1.23456789; + const precision = 2; - const expected = 1.23 - const actual = roundTo(value, precision) + const expected = 1.23; + const actual = roundTo(value, precision); - expect(actual).to.equal(expected) - }) + expect(actual).to.equal(expected); + }); it('should round to the correct precision with negative number', () => { - const value = -1.23456789 - const precision = 2 + const value = -1.23456789; + const precision = 2; - const expected = -1.23 - const actual = roundTo(value, precision) + const expected = -1.23; + const actual = roundTo(value, precision); - expect(actual).to.equal(expected) - }) + expect(actual).to.equal(expected); + }); it('should round to the correct precision with integer', () => { - const value = 1.5 - const precision = 0 + const value = 1.5; + const precision = 0; - const expected = 2 - const actual = roundTo(value, precision) + const expected = 2; + const actual = roundTo(value, precision); - expect(actual).to.equal(expected) - }) -}) + expect(actual).to.equal(expected); + }); +}); diff --git a/src/utils/roundTo.ts b/src/utils/roundTo.ts index 328f4ea..ea02d99 100644 --- a/src/utils/roundTo.ts +++ b/src/utils/roundTo.ts @@ -1,5 +1,5 @@ -export const roundTo = (value: number, precision = 0) => { - const multiplier = Math.pow(10, precision) - const newValue = parseFloat((Math.abs(value) * multiplier).toFixed(11)) - return (Math.round(newValue) / multiplier) * Math.sign(value) -} +export const roundTo = (value: number, precision = 0): number => { + const multiplier = Math.pow(10, precision); + const newValue = parseFloat((Math.abs(value) * multiplier).toFixed(11)); + return (Math.round(newValue) / multiplier) * Math.sign(value); +}; diff --git a/src/utils/selection.spec.ts b/src/utils/selection.spec.ts index ff9e382..1c310cc 100644 --- a/src/utils/selection.spec.ts +++ b/src/utils/selection.spec.ts @@ -1,6 +1,6 @@ -import type { Stage } from 'konva/lib/Stage' +import type { Stage } from 'konva/lib/Stage'; -import { calculateSelectionPoint } from '@/utils/selection' +import { calculateSelectionPoint } from '@/utils/selection'; describe('selection', () => { describe('calculateSelectionPoint', () => { @@ -9,25 +9,25 @@ describe('selection', () => { position: () => ({ x: 50, y: 0 }), getPointerPosition: () => ({ x: 2, y: 2 }), scaleX: () => 0.781, - } + }; - const expected = { x: -61.46, y: 2.56 } - const actual = calculateSelectionPoint(stage as Stage) + const expected = { x: -61.46, y: 2.56 }; + const actual = calculateSelectionPoint(stage as Stage); - expect(actual).to.deep.equal(expected) - }) + expect(actual).to.deep.equal(expected); + }); it('should return undefined if pointerPosition is null', () => { const stage = { position: () => ({ x: 50, y: 0 }), getPointerPosition: () => null, scaleX: () => 0.781, - } + }; - const expected = undefined - const actual = calculateSelectionPoint(stage as Stage) + const expected = undefined; + const actual = calculateSelectionPoint(stage as Stage); - expect(actual).to.deep.equal(expected) - }) - }) -}) + expect(actual).to.deep.equal(expected); + }); + }); +}); diff --git a/src/utils/selection.ts b/src/utils/selection.ts index ad34a3e..3e4b234 100644 --- a/src/utils/selection.ts +++ b/src/utils/selection.ts @@ -1,112 +1,133 @@ -import Konva from 'konva' -import type { Layer } from 'konva/lib/Layer' -import type { KonvaEventObject } from 'konva/lib/Node' -import type { Rect } from 'konva/lib/shapes/Rect' -import type { Stage } from 'konva/lib/Stage' +import Konva from 'konva'; +import type { Layer } from 'konva/lib/Layer'; +import type { KonvaEventObject } from 'konva/lib/Node'; +import type { Rect } from 'konva/lib/shapes/Rect'; +import type { Stage } from 'konva/lib/Stage'; -import { KONVA_REFS } from '@/common/constants' +import { KonvaRefs } from '@/common/constants'; import type { AnnotationShape, AnnotationViewerOptions, PointerPosition, -} from '@/common/types' +} from '@/common/types'; -import { getShapeFromNode } from '@/utils/canvas' +import { getShapeFromNode } from '@/utils/canvas'; -import { roundTo } from './roundTo' +import { roundTo } from './roundTo'; -const selectionAnchor = new WeakMap() +const selectionAnchor = new WeakMap(); -export const createSelectionRect = (options: AnnotationViewerOptions) => +export const createSelectionRect = (options: AnnotationViewerOptions): Rect => new Konva.Rect({ visible: false, ...options.selectionRectConfig, - }) + }); -export const calculateSelectionPoint = (stage: Stage) => { - const stagePosition = stage.position() - const pointerPosition = stage.getPointerPosition() +export const calculateSelectionPoint = ( + stage: Stage, +): PointerPosition | undefined => { + const stagePosition = stage.position(); + const pointerPosition = stage.getPointerPosition(); - if (!pointerPosition) return + if (!pointerPosition) { + return; + } - const x = roundTo((pointerPosition.x - stagePosition.x) / stage.scaleX(), 2) - const y = roundTo((pointerPosition.y - stagePosition.y) / stage.scaleX(), 2) + const x = roundTo((pointerPosition.x - stagePosition.x) / stage.scaleX(), 2); + const y = roundTo((pointerPosition.y - stagePosition.y) / stage.scaleX(), 2); - return { x, y } -} + return { x, y }; +}; export const onSelectionStart = ( event?: KonvaEventObject, layer?: Layer, rect?: Rect, selectionEnabled?: boolean, -) => { - if (!selectionEnabled || !layer || !rect || !event) return +): void => { + if (selectionEnabled !== true || !layer || !rect || !event) { + return; + } - const stage = layer.getStage() - const firstPoint = calculateSelectionPoint(stage) + const stage = layer.getStage(); + const firstPoint = calculateSelectionPoint(stage); - if (!firstPoint) return + if (!firstPoint) { + return; + } - selectionAnchor.set(rect, firstPoint) - rect.setAttrs({ x1: firstPoint.x, y1: firstPoint.y }) - rect.visible(true) - rect.width(0) - rect.height(0) - layer.draw() -} + selectionAnchor.set(rect, firstPoint); + rect.setAttrs({ x1: firstPoint.x, y1: firstPoint.y }); + rect.visible(true); + rect.width(0); + rect.height(0); + layer.draw(); +}; -export const onSelectionMove = (layer?: Layer, rect?: Rect) => { - const stage = layer?.getStage() +export const onSelectionMove = (layer?: Layer, rect?: Rect): void => { + const stage = layer?.getStage(); - if (!stage || !rect || !layer) return + if (!stage || !rect || !layer) { + return; + } // no nothing if we didn't start selection - if (!rect.visible()) return - const anchor = selectionAnchor.get(rect) + if (!rect.visible()) { + return; + } + const anchor = selectionAnchor.get(rect); - if (!anchor) return + if (!anchor) { + return; + } - const secondPoint = calculateSelectionPoint(stage) + const secondPoint = calculateSelectionPoint(stage); - if (!secondPoint) return + if (!secondPoint) { + return; + } rect.setAttrs({ x: Math.min(anchor.x, secondPoint.x), y: Math.min(anchor.y, secondPoint.y), width: Math.abs(secondPoint.x - anchor.x), height: Math.abs(secondPoint.y - anchor.y), - }) + }); - layer.batchDraw() -} + layer.batchDraw(); +}; export const onSelectionEnd = ( layer?: Layer, rect?: Rect, onShapeMultiSelect?: (shapes: AnnotationShape[]) => void, -) => { - const stage = layer?.getStage() +): void => { + const stage = layer?.getStage(); - if (!stage || !rect || !layer) return + if (!stage || !rect || !layer) { + return; + } - // no nothing if we didn't start selection - if (!rect.visible()) return + if (!rect.visible()) { + return; + } // update visibility in timeout, so we can check it in click event setTimeout(() => { - rect.visible(false) - layer.batchDraw() - }) + rect.visible(false); + layer.batchDraw(); + }); - const shapes = stage.find(`.${KONVA_REFS.shape}`) - const box = rect.getClientRect() + const shapes = stage.find(`.${KonvaRefs.Shape}`); + const box = rect.getClientRect(); const selected = shapes .filter((shape) => Konva.Util.haveIntersection(box, shape.getClientRect())) .map((node) => getShapeFromNode(node)) - .filter((shape) => shape !== undefined) + .filter((shape) => shape !== undefined); - if (selected.length) onShapeMultiSelect?.(selected) + if (selected.length > 0) { + onShapeMultiSelect?.(selected); + } - layer.batchDraw() -} + layer.batchDraw(); +}; diff --git a/src/utils/useEventListener.ts b/src/utils/useEventListener.ts index a572db3..d30b519 100644 --- a/src/utils/useEventListener.ts +++ b/src/utils/useEventListener.ts @@ -1,23 +1,23 @@ -import { useEffect, useRef, type RefObject } from 'react' +import { useEffect, useRef, type RefObject } from 'react'; export default function useEventListener( eventName: K, handler: (event: WindowEventMap[K]) => void, element?: RefObject, -) { - const savedHandler = useRef(handler) +): void { + const savedHandler = useRef(handler); useEffect(() => { - savedHandler.current = handler - }) + savedHandler.current = handler; + }); useEffect(() => { - const target: HTMLElement | Window = element?.current ?? window - const listener = (event: Event) => { - savedHandler.current(event as WindowEventMap[K]) - } - target.addEventListener(eventName, listener) + const target: HTMLElement | Window = element?.current ?? window; + const listener = (event: Event): void => { + savedHandler.current(event as WindowEventMap[K]); + }; + target.addEventListener(eventName, listener); return () => { - target.removeEventListener(eventName, listener) - } - }, [eventName, element]) + target.removeEventListener(eventName, listener); + }; + }, [eventName, element]); } diff --git a/src/utils/useMultiSelection.ts b/src/utils/useMultiSelection.ts index d4db99f..779dc35 100644 --- a/src/utils/useMultiSelection.ts +++ b/src/utils/useMultiSelection.ts @@ -1,35 +1,35 @@ -import type { RefObject } from 'react' -import type Konva from 'konva' +import type { RefObject } from 'react'; +import type Konva from 'konva'; -import useEventListener from './useEventListener' +import useEventListener from './useEventListener'; -interface Props { - stageRef: RefObject - isSelectionActiveRef: RefObject -} +type Props = { + stageRef: RefObject; + isSelectionActiveRef: RefObject; +}; -const isSelectionModifier = (event: KeyboardEvent) => +const isSelectionModifier = (event: KeyboardEvent): boolean => event.ctrlKey || event.altKey || event.key === 'Control' || - event.key === 'Alt' + event.key === 'Alt'; export default function useMultiSelection({ stageRef, isSelectionActiveRef, -}: Props) { +}: Props): void { useEventListener('keydown', (event) => { - event.stopPropagation() + event.stopPropagation(); if (isSelectionModifier(event)) { - stageRef.current?.draggable(false) - isSelectionActiveRef.current = true + stageRef.current?.draggable(false); + isSelectionActiveRef.current = true; } - }) + }); useEventListener('keyup', (event) => { - event.stopPropagation() + event.stopPropagation(); if (isSelectionModifier(event)) { - stageRef.current?.draggable(true) - isSelectionActiveRef.current = false + stageRef.current?.draggable(true); + isSelectionActiveRef.current = false; } - }) + }); } diff --git a/src/utils/zoom.spec.ts b/src/utils/zoom.spec.ts index dab5a05..327c59a 100644 --- a/src/utils/zoom.spec.ts +++ b/src/utils/zoom.spec.ts @@ -1,39 +1,39 @@ -import type Konva from 'konva' +import type Konva from 'konva'; -import type { AnnotationViewerOptions } from '@/common/types' +import type { AnnotationViewerOptions } from '@/common/types'; import { calculateLensZoom, calculateStageZoom, calculateZoomScale, -} from '@/utils/zoom' +} from '@/utils/zoom'; describe('zoom', () => { describe('calculateLensZoom', () => { it('should return the correct position', () => { - const pointerPosition = { x: 0.5, y: 0.5 } + const pointerPosition = { x: 0.5, y: 0.5 }; const imageBoundingBox = { x: 0, y: 75, width: 600, height: 450, scale: 0.586, - } - const stage = { width: () => 600, height: () => 600 } - const zoomLevel = 1.5 + }; + const stage = { width: () => 600, height: () => 600 }; + const zoomLevel = 1.5; - const expected = { x: -467.92, y: -275.94 } + const expected = { x: -467.92, y: -275.94 }; const actual = calculateLensZoom( pointerPosition, imageBoundingBox, stage as Konva.Stage, zoomLevel, - ) + ); - expect(actual).to.deep.equal(expected) - }) - }) + expect(actual).to.deep.equal(expected); + }); + }); describe('calculateStageZoom', () => { it('should return the correct scale and position', () => { @@ -41,24 +41,24 @@ describe('zoom', () => { scaleX: () => 1.12464, position: () => ({ x: -82, y: -176 }), getPointerPosition: () => ({ x: 350, y: 400 }), - } - const deltaY = -60 - const options = { zoom: { modifier: 1.2 } } + }; + const deltaY = -60; + const options = { zoom: { modifier: 1.2 } }; const expected = { newScale: 1.35, newPos: { x: -168.4, y: -291.2 }, - } + }; const actual = calculateStageZoom( stage as Konva.Stage, deltaY, options as AnnotationViewerOptions, - ) + ); - expect(actual).to.deep.equal(expected) - }) - }) + expect(actual).to.deep.equal(expected); + }); + }); describe('calculateZoomScale', () => { it('should return the correct scale and position', () => { @@ -66,28 +66,28 @@ describe('zoom', () => { scaleX: () => 1.12464, position: () => ({ x: -82, y: -176 }), getPointerPosition: () => ({ x: 350, y: 400 }), - } + }; const imageBoundingBox = { x: 0, y: 75, width: 600, height: 450, scale: 0.586, - } - const deltaY = -60 + }; + const deltaY = -60; const expected = { newScale: -35.16, newPos: { x: 12242.59, y: 15181.35 }, - } + }; const actual = calculateZoomScale( stage as Konva.Stage, deltaY, imageBoundingBox, - ) + ); - expect(actual).to.deep.equal(expected) - }) - }) -}) + expect(actual).to.deep.equal(expected); + }); + }); +}); diff --git a/src/utils/zoom.ts b/src/utils/zoom.ts index 563862d..3bc0eb8 100644 --- a/src/utils/zoom.ts +++ b/src/utils/zoom.ts @@ -1,143 +1,153 @@ -import type Konva from 'konva' -import type { KonvaEventObject } from 'konva/lib/Node' +import type Konva from 'konva'; +import type { KonvaEventObject } from 'konva/lib/Node'; -import { DEFAULT_LENS_ZOOM_LEVEL } from '@/common/constants' +import { DEFAULT_LENS_ZOOM_LEVEL } from '@/common/constants'; import type { AnnotationViewerOptions, ImageBoundingBox, PointerPosition, -} from '@/common/types' +} from '@/common/types'; -import { roundTo } from '@/utils/roundTo' +import { roundTo } from '@/utils/roundTo'; -const ZOOM_SCALE_ATTR = 'zoomScale' +const ZOOM_SCALE_ATTR = 'zoomScale'; /** Zoom factor relative to the fitted image (1 = fit to container). */ export const getZoomScale = (stage: Konva.Stage): number => { - const value: unknown = stage.getAttr(ZOOM_SCALE_ATTR) - return typeof value === 'number' ? value : 1 -} + const value: unknown = stage.getAttr(ZOOM_SCALE_ATTR); + return typeof value === 'number' ? value : 1; +}; -export const setZoomScale = (stage: Konva.Stage, zoomScale: number) => { - stage.setAttr(ZOOM_SCALE_ATTR, zoomScale) -} +export const setZoomScale = (stage: Konva.Stage, zoomScale: number): void => { + stage.setAttr(ZOOM_SCALE_ATTR, zoomScale); +}; export const calculateZoomScale = ( stage: Konva.Stage, zoomScale: number, imageBoundingBox: ImageBoundingBox, -) => { - const oldScale = stage.scaleX() +): { newScale: number; newPos: PointerPosition } => { + const oldScale = stage.scaleX(); - const stagePosition = stage.position() + const stagePosition = stage.position(); const pointerPosition = { x: 0.5 * imageBoundingBox.width + imageBoundingBox.x, y: 0.5 * imageBoundingBox.height + imageBoundingBox.y, - } + }; const mousePointTo = { x: (pointerPosition.x - stagePosition.x) / oldScale, y: (pointerPosition.y - stagePosition.y) / oldScale, - } + }; - const newScale = zoomScale * imageBoundingBox.scale + const newScale = zoomScale * imageBoundingBox.scale; const newPos = { x: roundTo(pointerPosition.x - mousePointTo.x * newScale, 2), y: roundTo(pointerPosition.y - mousePointTo.y * newScale, 2), - } + }; - return { newScale: roundTo(newScale, 2), newPos } -} + return { newScale: roundTo(newScale, 2), newPos }; +}; export const handleZoomScale = ( stage: Konva.Stage | null, zoomScale: number, imageBoundingBox: ImageBoundingBox | null, -) => { - if (!stage || !imageBoundingBox) return +): void => { + if (!stage || !imageBoundingBox) { + return; + } const { newScale, newPos } = calculateZoomScale( stage, zoomScale, imageBoundingBox, - ) + ); if (newScale < imageBoundingBox.scale) { - stage.draggable(false) - stage.scale({ x: imageBoundingBox.scale, y: imageBoundingBox.scale }) - stage.position({ x: imageBoundingBox.x, y: imageBoundingBox.y }) - setZoomScale(stage, 1) + stage.draggable(false); + stage.scale({ x: imageBoundingBox.scale, y: imageBoundingBox.scale }); + stage.position({ x: imageBoundingBox.x, y: imageBoundingBox.y }); + setZoomScale(stage, 1); } else { - stage.draggable(true) - stage.scale({ x: newScale, y: newScale }) - setZoomScale(stage, newScale / imageBoundingBox.scale) - stage.position(newPos) + stage.draggable(true); + stage.scale({ x: newScale, y: newScale }); + setZoomScale(stage, newScale / imageBoundingBox.scale); + stage.position(newPos); } - stage.batchDraw() -} + stage.batchDraw(); +}; export const calculateStageZoom = ( stage: Konva.Stage, deltaY: number, options: AnnotationViewerOptions, -) => { - const oldScale = stage.scaleX() +): { newScale: number; newPos: PointerPosition } | undefined => { + const oldScale = stage.scaleX(); - const stagePosition = stage.position() - const pointerPosition = stage.getPointerPosition() ?? { x: 0, y: 0 } + const stagePosition = stage.position(); + const pointerPosition = stage.getPointerPosition() ?? { x: 0, y: 0 }; const mousePointTo = { x: (pointerPosition.x - stagePosition.x) / oldScale, y: (pointerPosition.y - stagePosition.y) / oldScale, - } + }; - if (!options.zoom) return - const { modifier } = options.zoom + if (!options.zoom) { + return; + } + const { modifier } = options.zoom; - const newScale = deltaY < 0 ? oldScale * modifier : oldScale / modifier + const newScale = deltaY < 0 ? oldScale * modifier : oldScale / modifier; const newPos = { x: roundTo(pointerPosition.x - mousePointTo.x * newScale, 2), y: roundTo(pointerPosition.y - mousePointTo.y * newScale, 2), - } + }; - return { newScale: roundTo(newScale, 2), newPos } -} + return { newScale: roundTo(newScale, 2), newPos }; +}; export const handleStageZoom = ( stage: Konva.Stage | null, imageBoundingBox: ImageBoundingBox | null, event: KonvaEventObject, options: AnnotationViewerOptions, -) => { - if (!stage || !imageBoundingBox || !options.zoom) return +): void => { + if (!stage || !imageBoundingBox || !options.zoom) { + return; + } - event.evt.preventDefault() + event.evt.preventDefault(); - const { max } = options.zoom + const { max } = options.zoom; - const stageZoom = calculateStageZoom(stage, event.evt.deltaY, options) - if (!stageZoom) return - const { newScale, newPos } = stageZoom + const stageZoom = calculateStageZoom(stage, event.evt.deltaY, options); + if (!stageZoom) { + return; + } + const { newScale, newPos } = stageZoom; - if (newScale > max) return + if (newScale > max) { + return; + } if (newScale < imageBoundingBox.scale) { - stage.draggable(false) - stage.scale({ x: imageBoundingBox.scale, y: imageBoundingBox.scale }) - stage.position({ x: imageBoundingBox.x, y: imageBoundingBox.y }) - setZoomScale(stage, 1) + stage.draggable(false); + stage.scale({ x: imageBoundingBox.scale, y: imageBoundingBox.scale }); + stage.position({ x: imageBoundingBox.x, y: imageBoundingBox.y }); + setZoomScale(stage, 1); } else { - stage.draggable(true) - stage.scale({ x: newScale, y: newScale }) - setZoomScale(stage, newScale / imageBoundingBox.scale) - stage.position(newPos) + stage.draggable(true); + stage.scale({ x: newScale, y: newScale }); + setZoomScale(stage, newScale / imageBoundingBox.scale); + stage.position(newPos); } - stage.batchDraw() -} + stage.batchDraw(); +}; export const calculateLensZoom = ( pointerPosition: PointerPosition, @@ -146,32 +156,34 @@ export const calculateLensZoom = ( zoomLevel: number, ): PointerPosition => { const pointerX = - (pointerPosition.x * imageBoundingBox.width) / imageBoundingBox.scale + (pointerPosition.x * imageBoundingBox.width) / imageBoundingBox.scale; const pointerY = - (pointerPosition.y * imageBoundingBox.height) / imageBoundingBox.scale + (pointerPosition.y * imageBoundingBox.height) / imageBoundingBox.scale; return { x: roundTo(-pointerX * zoomLevel + stage.width() / 2, 2), y: roundTo(-pointerY * zoomLevel + stage.height() / 2, 2), - } -} + }; +}; export const handleLensZoom = ( stage: Konva.Stage | null, imageBoundingBox: ImageBoundingBox | null, pointerPosition: PointerPosition, zoomLevel = DEFAULT_LENS_ZOOM_LEVEL, -) => { - if (!stage || !imageBoundingBox) return +): void => { + if (!stage || !imageBoundingBox) { + return; + } const newPos = calculateLensZoom( pointerPosition, imageBoundingBox, stage, zoomLevel, - ) + ); - stage.scale({ x: zoomLevel, y: zoomLevel }) - stage.position(newPos) - stage.batchDraw() -} + stage.scale({ x: zoomLevel, y: zoomLevel }); + stage.position(newPos); + stage.batchDraw(); +}; diff --git a/tsconfig.json b/tsconfig.json index cb7e200..676bf3d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,12 @@ "jsx": "react-jsx", "types": ["node", "cypress"], "strict": true, - "isolatedModules": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "verbatimModuleSyntax": true, "esModuleInterop": true, "declaration": true, "outDir": "dist", diff --git a/vite.config.ts b/vite.config.ts index 7e3c08c..45bbcfb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,14 +1,12 @@ -import { fileURLToPath } from 'node:url' -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' -import dts from 'vite-plugin-dts' +import { fileURLToPath } from 'node:url'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; export default defineConfig({ assetsInclude: ['**/*.tiff', '**/*.heic'], resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - }, + tsconfigPaths: true, }, plugins: [ react(), @@ -29,4 +27,4 @@ export default defineConfig({ external: ['react', 'react-dom', 'react/jsx-runtime'], }, }, -}) +});