Phase 3 implementation. Coordinate system implemented. Initial text box tool implemented.
This commit is contained in:
parent
c159ad4f37
commit
4cb038ec78
34 changed files with 6676 additions and 130 deletions
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
legacy-peer-deps=true
|
||||
|
|
@ -3,7 +3,7 @@ FROM node:22-alpine AS build
|
|||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ server {
|
|||
listen 80;
|
||||
server_name _;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
types {
|
||||
application/javascript mjs;
|
||||
application/wasm wasm;
|
||||
}
|
||||
|
||||
# Match MAX_UPLOAD_MB — raise in both this and the outer reverse proxy
|
||||
client_max_body_size 200m;
|
||||
|
||||
|
|
@ -42,7 +48,7 @@ server {
|
|||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
location ~* \.(js|mjs|wasm|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
|
|
|
|||
1
frontend/openapi.json
Normal file
1
frontend/openapi.json
Normal file
File diff suppressed because one or more lines are too long
462
frontend/package-lock.json
generated
462
frontend/package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
|||
"@fontsource/outfit": "^5.2.8",
|
||||
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"fabric": "^7.4.0",
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"uuid": "^14.0.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -34,6 +36,7 @@
|
|||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
|
|
@ -129,7 +132,6 @@
|
|||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
|
|
@ -450,7 +452,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
|
|
@ -499,7 +500,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
|
|
@ -1061,11 +1061,95 @@
|
|||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js-replace": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/config": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
|
||||
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core": {
|
||||
"version": "1.34.15",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz",
|
||||
"integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/ajv": "8.11.2",
|
||||
"@redocly/config": "0.22.0",
|
||||
"colorette": "1.4.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"js-levenshtein": "1.1.6",
|
||||
"js-yaml": "4.1.1",
|
||||
"minimatch": "5.1.9",
|
||||
"pluralize": "8.0.0",
|
||||
"yaml-ast-parser": "0.0.43"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0",
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
|
|
@ -1073,6 +1157,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1089,6 +1174,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1105,6 +1191,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1121,6 +1208,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1137,6 +1225,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1153,6 +1242,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1169,6 +1259,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1185,6 +1276,7 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1201,6 +1293,7 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1217,6 +1310,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1233,6 +1327,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1249,6 +1344,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1265,6 +1361,7 @@
|
|||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1280,6 +1377,7 @@
|
|||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1291,6 +1389,7 @@
|
|||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1301,6 +1400,7 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1314,6 +1414,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1330,6 +1431,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1343,6 +1445,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
|
|
@ -1609,27 +1712,6 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
|
|
@ -1695,13 +1777,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
|
@ -1752,9 +1827,8 @@
|
|||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
|
|
@ -1763,9 +1837,8 @@
|
|||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -1776,11 +1849,16 @@
|
|||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||
|
|
@ -1826,7 +1904,6 @@
|
|||
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
|
|
@ -2170,7 +2247,6 @@
|
|||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -2192,8 +2268,8 @@
|
|||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
|
|
@ -2215,28 +2291,22 @@
|
|||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
|
|
@ -2357,7 +2427,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
|
|
@ -2418,6 +2487,21 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz",
|
||||
"integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.0.0",
|
||||
"prebuild-install": "^7.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.12.0 || >= 20.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
|
|
@ -2428,6 +2512,13 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
|
|
@ -2435,6 +2526,13 @@
|
|||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
|
|
@ -2591,6 +2689,49 @@
|
|||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle/node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle/node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
|
|
@ -2602,7 +2743,7 @@
|
|||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
|
|
@ -2706,13 +2847,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.371",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
|
||||
|
|
@ -2792,7 +2926,6 @@
|
|||
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
|
|
@ -3210,6 +3343,7 @@
|
|||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
|
|
@ -3285,6 +3419,7 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -3392,8 +3527,8 @@
|
|||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
|
|
@ -3466,6 +3601,19 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
|
|
@ -3526,6 +3674,16 @@
|
|||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-levenshtein": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -3533,6 +3691,19 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
|
|
@ -3930,16 +4101,6 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -4023,6 +4184,7 @@
|
|||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -4125,6 +4287,27 @@
|
|||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/openapi-core": "^1.34.6",
|
||||
"ansi-colors": "^4.1.3",
|
||||
"change-case": "^5.4.4",
|
||||
"parse-json": "^8.3.0",
|
||||
"supports-color": "^10.2.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-typescript": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.x"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -4175,6 +4358,24 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"index-to-position": "^1.1.0",
|
||||
"type-fest": "^4.39.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
|
|
@ -4231,14 +4432,15 @@
|
|||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -4246,10 +4448,21 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -4312,21 +4525,6 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
|
|
@ -4369,7 +4567,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -4379,7 +4576,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -4387,13 +4583,6 @@
|
|||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||
|
|
@ -4475,6 +4664,7 @@
|
|||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
|
|
@ -4707,6 +4897,19 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
|
@ -4784,6 +4987,7 @@
|
|||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -4898,13 +5102,25 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -4951,7 +5167,7 @@
|
|||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
|
|
@ -4995,6 +5211,13 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js-replace": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
||||
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
|
@ -5002,12 +5225,25 @@
|
|||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
|
@ -5328,6 +5564,23 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml-ast-parser": {
|
||||
"version": "0.0.43",
|
||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
@ -5347,7 +5600,6 @@
|
|||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"@fontsource/outfit": "^5.2.8",
|
||||
"@fontsource/plus-jakarta-sans": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"fabric": "^7.4.0",
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"uuid": "^14.0.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -39,6 +41,7 @@
|
|||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
|
|
|
|||
181
frontend/src/features/editor/canvas/AnnotationLayer.tsx
Normal file
181
frontend/src/features/editor/canvas/AnnotationLayer.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import * as fabric from 'fabric';
|
||||
import { useEditorStore } from '../store';
|
||||
import { getTool } from '../../../lib/annotations/registry';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
import { screenToPdf } from '../../../lib/coords';
|
||||
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
viewportParams: ViewportParams;
|
||||
}
|
||||
|
||||
export function AnnotationLayer({ pageNumber, width, height, viewportParams }: AnnotationLayerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
||||
|
||||
const activeToolName = useEditorStore(state => state.activeTool);
|
||||
|
||||
const annotations = useEditorStore(state => state.annotations);
|
||||
|
||||
// Initial setup and event binding
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
width,
|
||||
height,
|
||||
selection: activeToolName === 'select',
|
||||
});
|
||||
|
||||
// Sync fabric modifications back to Zustand
|
||||
canvas.on('object:modified', (e) => {
|
||||
const obj = e.target as any;
|
||||
if (obj && obj.id) {
|
||||
// Find existing annotation
|
||||
const ann = useEditorStore.getState().annotations.find(a => a.id === obj.id);
|
||||
if (ann) {
|
||||
const pt = screenToPdf({ x: obj.left, y: obj.top }, viewportParams);
|
||||
|
||||
if (ann.type === 'text') {
|
||||
const scaleX = obj.scaleX || 1;
|
||||
const scaleY = obj.scaleY || 1;
|
||||
|
||||
// Box width/height in PDF space
|
||||
const newWidth = (obj.width * scaleX) / (viewportParams.scale * (viewportParams.dpr || 1));
|
||||
const newHeight = (obj.height * scaleY) / (viewportParams.scale * (viewportParams.dpr || 1));
|
||||
|
||||
const textProps = ann.props as any;
|
||||
const newFontSize = (textProps.fontSize || 14) * scaleY;
|
||||
|
||||
useEditorStore.getState().updateAnnotation(obj.id, {
|
||||
rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight },
|
||||
props: { ...ann.props, fontSize: newFontSize }
|
||||
});
|
||||
} else {
|
||||
useEditorStore.getState().updateAnnotation(obj.id, {
|
||||
rect: { ...ann.rect, x: pt.x, y: pt.y }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updateSelection = (_e?: any) => {
|
||||
const activeObj = canvas.getActiveObject() as any;
|
||||
if (activeObj && activeObj.id) {
|
||||
useEditorStore.getState().setSelection(activeObj.id);
|
||||
} else {
|
||||
useEditorStore.getState().setSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
canvas.on('selection:created', updateSelection);
|
||||
canvas.on('selection:updated', updateSelection);
|
||||
canvas.on('selection:cleared', updateSelection);
|
||||
|
||||
// Keyboard shortcut for delete
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
const activeObj = canvas.getActiveObject() as any;
|
||||
// Don't delete if we are actively editing text
|
||||
if (activeObj && activeObj.isEditing === true) {
|
||||
return;
|
||||
}
|
||||
if (activeObj && activeObj.id) {
|
||||
useEditorStore.getState().deleteAnnotation(activeObj.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
fabricRef.current = canvas;
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
canvas.dispose();
|
||||
fabricRef.current = null;
|
||||
};
|
||||
}, []); // Run once on mount
|
||||
|
||||
// Handle dimensions and re-rendering annotations
|
||||
useEffect(() => {
|
||||
if (!fabricRef.current) return;
|
||||
const canvas = fabricRef.current;
|
||||
canvas.setDimensions({ width, height });
|
||||
|
||||
// Simple naive sync: clear and re-render everything
|
||||
// We keep track of the currently active object ID to restore it
|
||||
const activeObj = canvas.getActiveObject() as any;
|
||||
const activeId = activeObj?.id;
|
||||
|
||||
canvas.clear();
|
||||
|
||||
annotations.forEach(ann => {
|
||||
// Only render annotations for THIS page
|
||||
if (ann.page === pageNumber) {
|
||||
const tool = getTool(ann.type);
|
||||
if (tool && tool.renderToFabric) {
|
||||
tool.renderToFabric(ann, canvas, viewportParams);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (activeId) {
|
||||
const newActive = canvas.getObjects().find((o: any) => o.id === activeId);
|
||||
if (newActive) canvas.setActiveObject(newActive);
|
||||
}
|
||||
}, [width, height, annotations, pageNumber]); // We ignore viewportParams identity for now to avoid loops
|
||||
|
||||
// Handle tool activation/deactivation and event binding
|
||||
useEffect(() => {
|
||||
const canvas = fabricRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// First deactivate any previous tool logic
|
||||
// This is a bit tricky if we don't remember the previous tool,
|
||||
// but we can just clear event listeners.
|
||||
canvas.off('mouse:down');
|
||||
canvas.off('mouse:move');
|
||||
canvas.off('mouse:up');
|
||||
|
||||
canvas.selection = activeToolName === 'select';
|
||||
|
||||
const tool = getTool(activeToolName);
|
||||
if (tool) {
|
||||
if (tool.onActivate) tool.onActivate(canvas);
|
||||
|
||||
if (tool.onPointerDown) {
|
||||
canvas.on('mouse:down', (e) => tool.onPointerDown!(e, canvas, viewportParams, pageNumber));
|
||||
}
|
||||
if (tool.onPointerMove) {
|
||||
canvas.on('mouse:move', (e) => tool.onPointerMove!(e, canvas, viewportParams, pageNumber));
|
||||
}
|
||||
if (tool.onPointerUp) {
|
||||
canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, viewportParams, pageNumber));
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (tool && tool.onDeactivate) {
|
||||
tool.onDeactivate(canvas);
|
||||
}
|
||||
};
|
||||
}, [activeToolName]);
|
||||
|
||||
const selection = useEditorStore(state => state.selection);
|
||||
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 left-0" style={{ width, height }}>
|
||||
<canvas ref={canvasRef} />
|
||||
{isSelectedOnThisPage && (
|
||||
<TextFormatToolbar annotationId={selection!} viewportParams={viewportParams} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/features/editor/pages/PageRenderer.tsx
Normal file
88
frontend/src/features/editor/pages/PageRenderer.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
|
||||
import { AnnotationLayer } from '../canvas/AnnotationLayer';
|
||||
|
||||
interface PageRendererProps {
|
||||
pdfDoc: PDFDocumentProxy;
|
||||
pageNumber: number;
|
||||
scale: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [page, setPage] = useState<PDFPageProxy | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
pdfDoc.getPage(pageNumber).then(p => {
|
||||
if (active) setPage(p);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [pdfDoc, pageNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!page || !canvasRef.current) return;
|
||||
|
||||
const viewport = page.getViewport({ scale: scale * dpr });
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
canvas.style.width = `${viewport.width / dpr}px`;
|
||||
canvas.style.height = `${viewport.height / dpr}px`;
|
||||
|
||||
const renderContext = {
|
||||
canvas: canvas,
|
||||
viewport: viewport,
|
||||
};
|
||||
|
||||
let renderTask = page.render(renderContext);
|
||||
|
||||
return () => {
|
||||
renderTask.cancel();
|
||||
};
|
||||
}, [page, scale, dpr]);
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-sm flex items-center justify-center text-neutral-400 border border-neutral-200"
|
||||
style={{ width: 612 * scale, height: 792 * scale }}
|
||||
>
|
||||
Loading page {pageNumber}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null;
|
||||
|
||||
return (
|
||||
<div className="relative shadow-xl bg-white border border-neutral-200 group">
|
||||
<canvas ref={canvasRef} className="block" />
|
||||
|
||||
{baseViewport && (
|
||||
<AnnotationLayer
|
||||
pageNumber={pageNumber}
|
||||
width={baseViewport.width * scale}
|
||||
height={baseViewport.height * scale}
|
||||
viewportParams={{
|
||||
scale,
|
||||
rotation: page.rotate,
|
||||
canonicalWidth: baseViewport.width,
|
||||
canonicalHeight: baseViewport.height,
|
||||
dpr
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute -left-10 top-2 text-xs font-bold text-neutral-400 bg-white/80 rounded-md px-1.5 py-0.5 shadow-sm border border-neutral-200">
|
||||
{pageNumber}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/features/editor/pages/PageStack.tsx
Normal file
32
frontend/src/features/editor/pages/PageStack.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import { PageRenderer } from './PageRenderer';
|
||||
import { useEditorStore } from '../store';
|
||||
|
||||
interface PageStackProps {
|
||||
pdfDoc: PDFDocumentProxy;
|
||||
}
|
||||
|
||||
export function PageStack({ pdfDoc }: PageStackProps) {
|
||||
const numPages = pdfDoc.numPages;
|
||||
const pages = Array.from({ length: numPages }, (_, i) => i + 1);
|
||||
|
||||
const zoom = useEditorStore(state => state.zoom);
|
||||
|
||||
// For now, render all pages vertically. Virtualization comes in Phase 7.
|
||||
const scale = zoom * 2.0;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 py-8 w-full max-w-full">
|
||||
{pages.map(pageNum => (
|
||||
<PageRenderer
|
||||
key={pageNum}
|
||||
pdfDoc={pdfDoc}
|
||||
pageNumber={pageNum}
|
||||
scale={scale}
|
||||
dpr={dpr}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/features/editor/pages/PdfDocument.tsx
Normal file
53
frontend/src/features/editor/pages/PdfDocument.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { PageStack } from './PageStack';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
export function PdfDocument({ documentId }: { documentId: string }) {
|
||||
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const url = `/api/v1/documents/${documentId}/file`;
|
||||
|
||||
const loadingTask = pdfjsLib.getDocument({ url });
|
||||
|
||||
loadingTask.promise.then((doc) => {
|
||||
if (active) setPdfDoc(doc);
|
||||
}).catch(err => {
|
||||
console.error('Failed to load PDF', err);
|
||||
if (active) setError(err.message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
loadingTask.destroy();
|
||||
};
|
||||
}, [documentId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<div className="text-red-500 bg-red-50 px-4 py-3 rounded-lg border border-red-200">
|
||||
Error loading PDF: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pdfDoc) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full gap-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500"></div>
|
||||
<div className="text-neutral-500 font-medium animate-pulse">Loading document...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PageStack pdfDoc={pdfDoc} />;
|
||||
}
|
||||
50
frontend/src/features/editor/store.ts
Normal file
50
frontend/src/features/editor/store.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { create } from 'zustand';
|
||||
import type { Annotation } from '../../lib/annotations/types';
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
interface EditorState {
|
||||
documentId: string | null;
|
||||
annotations: Annotation[];
|
||||
activeTool: string;
|
||||
selection: string | null;
|
||||
zoom: number;
|
||||
saveStatus: SaveStatus;
|
||||
|
||||
// Actions
|
||||
setDocumentId: (id: string | null) => void;
|
||||
setAnnotations: (annotations: Annotation[]) => void;
|
||||
addAnnotation: (annotation: Annotation) => void;
|
||||
updateAnnotation: (id: string, updates: Partial<Annotation>) => void;
|
||||
deleteAnnotation: (id: string) => void;
|
||||
setActiveTool: (tool: string) => void;
|
||||
setSelection: (id: string | null) => void;
|
||||
setZoom: (zoom: number) => void;
|
||||
setSaveStatus: (status: SaveStatus) => void;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set) => ({
|
||||
documentId: null,
|
||||
annotations: [],
|
||||
activeTool: 'select',
|
||||
selection: null,
|
||||
zoom: 1,
|
||||
saveStatus: 'idle',
|
||||
|
||||
setDocumentId: (id) => set({ documentId: id }),
|
||||
setAnnotations: (annotations) => set({ annotations }),
|
||||
addAnnotation: (annotation) => set((state) => ({
|
||||
annotations: [...state.annotations, annotation]
|
||||
})),
|
||||
updateAnnotation: (id, updates) => set((state) => ({
|
||||
annotations: state.annotations.map(a => a.id === id ? { ...a, ...updates } as Annotation : a)
|
||||
})),
|
||||
deleteAnnotation: (id) => set((state) => ({
|
||||
annotations: state.annotations.filter(a => a.id !== id),
|
||||
selection: state.selection === id ? null : state.selection
|
||||
})),
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
setSelection: (id) => set({ selection: id }),
|
||||
setZoom: (zoom) => set({ zoom }),
|
||||
setSaveStatus: (status) => set({ saveStatus: status }),
|
||||
}));
|
||||
63
frontend/src/features/editor/toolbar/EditorToolbar.tsx
Normal file
63
frontend/src/features/editor/toolbar/EditorToolbar.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useEditorStore } from '../store';
|
||||
|
||||
export function EditorToolbar() {
|
||||
const { activeTool, setActiveTool, saveStatus, zoom, setZoom } = useEditorStore();
|
||||
|
||||
const handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3));
|
||||
const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5));
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-neutral-200 px-2 py-2 flex items-center gap-2 z-50">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTool === 'select'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
onClick={() => setActiveTool('select')}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTool === 'text'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
onClick={() => setActiveTool('text')}
|
||||
>
|
||||
Add Text
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-200 mx-2" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="text-sm font-medium text-neutral-600 w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-200 mx-2" />
|
||||
|
||||
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
|
||||
{saveStatus === 'saving' && 'Saving...'}
|
||||
{saveStatus === 'saved' && 'Saved'}
|
||||
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>}
|
||||
{saveStatus === 'idle' && ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
Normal file
148
frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { useEditorStore } from '../store';
|
||||
import type { TextAnnotation } from '../../../lib/annotations/types';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
import { pdfRectToScreen } from '../../../lib/coords';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface TextFormatToolbarProps {
|
||||
annotationId: string;
|
||||
viewportParams: ViewportParams;
|
||||
}
|
||||
|
||||
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans'];
|
||||
const SIZES = [8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
|
||||
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
|
||||
const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000'];
|
||||
|
||||
export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatToolbarProps) {
|
||||
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation } = useEditorStore();
|
||||
|
||||
const annotation = annotations.find(a => a.id === annotationId);
|
||||
if (!annotation || annotation.type !== 'text') return null;
|
||||
|
||||
const textAnn = annotation as TextAnnotation;
|
||||
const props = textAnn.props;
|
||||
|
||||
// Calculate screen position
|
||||
const pt = pdfRectToScreen(textAnn.rect, viewportParams);
|
||||
// Place it just above the text box
|
||||
const top = pt.y - 48; // 48px above
|
||||
const left = pt.x;
|
||||
|
||||
const updateProps = (newProps: Partial<TextAnnotation['props']>) => {
|
||||
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
const newId = uuidv4();
|
||||
addAnnotation({
|
||||
...textAnn,
|
||||
id: newId,
|
||||
rect: {
|
||||
...textAnn.rect,
|
||||
x: textAnn.rect.x + 20,
|
||||
y: textAnn.rect.y + 20
|
||||
}
|
||||
});
|
||||
// Setting selection to new annotation requires it to be mounted, which is fine,
|
||||
// but we can just leave selection on the old one for now.
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-50 bg-white rounded-md shadow-lg border border-neutral-200 flex items-center p-1 gap-1"
|
||||
style={{ top: `${Math.max(0, top)}px`, left: `${left}px` }}
|
||||
>
|
||||
{/* Font Family */}
|
||||
<select
|
||||
value={props.fontFamily}
|
||||
onChange={(e) => updateProps({ fontFamily: e.target.value })}
|
||||
className="text-xs bg-neutral-100 hover:bg-neutral-200 rounded px-2 py-1 outline-none cursor-pointer"
|
||||
>
|
||||
{FONTS.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
|
||||
<div className="w-px h-4 bg-neutral-200 mx-1" />
|
||||
|
||||
{/* Font Size */}
|
||||
<select
|
||||
value={props.fontSize}
|
||||
onChange={(e) => updateProps({ fontSize: Number(e.target.value) })}
|
||||
className="text-xs bg-neutral-100 hover:bg-neutral-200 rounded px-2 py-1 outline-none cursor-pointer"
|
||||
>
|
||||
{SIZES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
|
||||
<div className="w-px h-4 bg-neutral-200 mx-1" />
|
||||
|
||||
{/* Bold / Italic */}
|
||||
<button
|
||||
onClick={() => updateProps({ bold: !props.bold })}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded text-sm font-bold ${props.bold ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
||||
title="Bold"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateProps({ italic: !props.italic })}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded text-sm italic font-serif ${props.italic ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
||||
title="Italic"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-neutral-200 mx-1" />
|
||||
|
||||
{/* Text Color */}
|
||||
<div className="flex gap-0.5 items-center">
|
||||
{COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => updateProps({ color: c })}
|
||||
className={`w-4 h-4 rounded-full border ${props.color === c ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
|
||||
style={{ backgroundColor: c }}
|
||||
title="Text Color"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-neutral-200 mx-1" />
|
||||
|
||||
{/* Highlight Color */}
|
||||
<div className="flex gap-0.5 items-center">
|
||||
{HIGHLIGHTS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => updateProps({ highlightColor: c === 'transparent' ? null : c })}
|
||||
className={`w-4 h-4 rounded-sm border ${props.highlightColor === c || (c === 'transparent' && !props.highlightColor) ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
|
||||
style={{
|
||||
backgroundColor: c === 'transparent' ? '#ffffff' : c,
|
||||
backgroundImage: c === 'transparent' ? 'linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc)' : 'none',
|
||||
backgroundSize: '4px 4px',
|
||||
backgroundPosition: '0 0, 2px 2px'
|
||||
}}
|
||||
title="Highlight Color"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-neutral-200 mx-1" />
|
||||
|
||||
{/* Duplicate */}
|
||||
<button
|
||||
onClick={handleDuplicate}
|
||||
className="text-xs px-2 py-1 rounded text-neutral-600 hover:bg-neutral-100 font-medium"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => deleteAnnotation(annotationId)}
|
||||
className="text-xs px-2 py-1 rounded text-red-600 hover:bg-red-50 font-medium ml-1"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/features/editor/tools/TextTool.ts
Normal file
141
frontend/src/features/editor/tools/TextTool.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import * as fabric from 'fabric';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useEditorStore } from '../store';
|
||||
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||
import type { Annotation, TextAnnotation } from '../../../lib/annotations/types';
|
||||
import { screenToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
|
||||
export const TextTool: ToolHandler = {
|
||||
name: 'text',
|
||||
|
||||
onActivate: (canvas: fabric.Canvas) => {
|
||||
canvas.defaultCursor = 'text';
|
||||
canvas.selection = false;
|
||||
},
|
||||
|
||||
onDeactivate: (canvas: fabric.Canvas) => {
|
||||
canvas.defaultCursor = 'default';
|
||||
},
|
||||
|
||||
onPointerDown: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||
// If we clicked on an existing object, don't create a new one
|
||||
if (e.target) return;
|
||||
|
||||
// Create the Fabric object first so the user can immediately type
|
||||
const pointer = e.scenePoint || canvas.getScenePoint(e.e);
|
||||
const textbox = new fabric.Textbox('', {
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
width: 150 * vp.scale,
|
||||
fontFamily: 'Liberation Sans',
|
||||
fontSize: 14 * vp.scale * (vp.dpr || 1), // scaled loosely for screen display
|
||||
fontWeight: 'normal',
|
||||
fontStyle: 'normal',
|
||||
fill: '#000000',
|
||||
backgroundColor: undefined,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
transparentCorners: false,
|
||||
cornerColor: '#3b82f6',
|
||||
cornerStrokeColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
padding: 5,
|
||||
});
|
||||
|
||||
// Hide all corners and vertical resizers, leaving only middle-left and middle-right for width
|
||||
textbox.setControlsVisibility({
|
||||
tl: false,
|
||||
tr: false,
|
||||
bl: false,
|
||||
br: false,
|
||||
mt: false,
|
||||
mb: false,
|
||||
mtr: false // hide rotation handle for now
|
||||
});
|
||||
|
||||
canvas.add(textbox);
|
||||
canvas.setActiveObject(textbox);
|
||||
textbox.enterEditing();
|
||||
|
||||
textbox.on('editing:exited', () => {
|
||||
if (!textbox.text || textbox.text.trim() === '') {
|
||||
canvas.remove(textbox);
|
||||
return;
|
||||
}
|
||||
|
||||
const pt = screenToPdf({ x: textbox.left!, y: textbox.top! }, vp);
|
||||
|
||||
const newAnn: TextAnnotation = {
|
||||
id: uuidv4(),
|
||||
page: pageNumber,
|
||||
type: 'text',
|
||||
rect: {
|
||||
x: pt.x,
|
||||
y: pt.y,
|
||||
width: textbox.width! / (vp.scale * (vp.dpr || 1)),
|
||||
height: textbox.height! / (vp.scale * (vp.dpr || 1))
|
||||
},
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
props: {
|
||||
text: textbox.text,
|
||||
fontFamily: 'Liberation Sans',
|
||||
fontSize: 14,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
bold: false,
|
||||
italic: false,
|
||||
lineHeight: 1.2,
|
||||
highlightColor: null
|
||||
}
|
||||
};
|
||||
|
||||
// We attach the ID to the fabric object so we can link it
|
||||
(textbox as any).id = newAnn.id;
|
||||
|
||||
useEditorStore.getState().addAnnotation(newAnn);
|
||||
});
|
||||
},
|
||||
|
||||
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||
const textAnn = annotation as TextAnnotation;
|
||||
const pt = pdfRectToScreen(textAnn.rect, vp);
|
||||
|
||||
const textbox = new fabric.Textbox(textAnn.props.text, {
|
||||
left: pt.x,
|
||||
top: pt.y,
|
||||
width: textAnn.rect.width * vp.scale * (vp.dpr || 1),
|
||||
fontFamily: textAnn.props.fontFamily,
|
||||
fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1),
|
||||
fontWeight: textAnn.props.bold ? 'bold' : 'normal',
|
||||
fontStyle: textAnn.props.italic ? 'italic' : 'normal',
|
||||
fill: textAnn.props.color,
|
||||
backgroundColor: textAnn.props.highlightColor || undefined,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
transparentCorners: false,
|
||||
cornerColor: '#3b82f6',
|
||||
cornerStrokeColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
padding: 5,
|
||||
});
|
||||
|
||||
textbox.setControlsVisibility({
|
||||
tl: false,
|
||||
tr: false,
|
||||
bl: false,
|
||||
br: false,
|
||||
mt: false,
|
||||
mb: false,
|
||||
mtr: false
|
||||
});
|
||||
|
||||
(textbox as any).id = annotation.id;
|
||||
canvas.add(textbox);
|
||||
}
|
||||
};
|
||||
28
frontend/src/features/editor/tools/index.ts
Normal file
28
frontend/src/features/editor/tools/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { registerTool } from '../../../lib/annotations/registry';
|
||||
import { TextTool } from './TextTool';
|
||||
|
||||
// Register all tools here
|
||||
registerTool(TextTool);
|
||||
|
||||
// SelectTool implementation
|
||||
registerTool({
|
||||
name: 'select',
|
||||
onActivate: (canvas) => {
|
||||
canvas.defaultCursor = 'default';
|
||||
canvas.selection = true;
|
||||
|
||||
// Allow interacting with objects
|
||||
canvas.forEachObject(obj => {
|
||||
obj.set('selectable', true);
|
||||
obj.set('evented', true);
|
||||
});
|
||||
},
|
||||
onDeactivate: (canvas) => {
|
||||
// Lock all objects
|
||||
canvas.forEachObject(obj => {
|
||||
obj.set('selectable', false);
|
||||
obj.set('evented', false);
|
||||
});
|
||||
canvas.discardActiveObject();
|
||||
}
|
||||
});
|
||||
75
frontend/src/features/editor/useAutosave.ts
Normal file
75
frontend/src/features/editor/useAutosave.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useEditorStore } from './store';
|
||||
import type { Annotation } from '../../lib/annotations/types';
|
||||
|
||||
export function useAutosave() {
|
||||
const { documentId, annotations, setSaveStatus, setAnnotations } = useEditorStore();
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!documentId) return;
|
||||
|
||||
let active = true;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/documents/${documentId}/annotations`);
|
||||
if (!res.ok) throw new Error('Failed to load annotations');
|
||||
const data = await res.json();
|
||||
|
||||
if (active) {
|
||||
setAnnotations(data.data as Annotation[]);
|
||||
setSaveStatus('saved');
|
||||
isFirstLoad.current = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading annotations:', err);
|
||||
if (active) setSaveStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => { active = false; };
|
||||
}, [documentId, setAnnotations, setSaveStatus]);
|
||||
|
||||
// Debounced save
|
||||
useEffect(() => {
|
||||
// Don't save on the initial load!
|
||||
if (isFirstLoad.current) return;
|
||||
if (!documentId) return;
|
||||
|
||||
setSaveStatus('saving');
|
||||
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/documents/${documentId}/annotations`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: annotations
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to save');
|
||||
setSaveStatus('saved');
|
||||
} catch (err) {
|
||||
console.error('Error saving annotations:', err);
|
||||
setSaveStatus('error');
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [annotations, documentId, setSaveStatus]);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { formatDistanceToNow } from 'date-fns'
|
|||
import { ContextMenu } from './ContextMenu'
|
||||
|
||||
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
|
||||
const { documents, selectedIds, toggleSelection, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary()
|
||||
const { documents, selectedIds, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { create } from 'zustand'
|
||||
import { libraryApi } from './api'
|
||||
import type { DocumentMeta } from './types'
|
||||
import type { DocumentMeta, DocumentUpdateRequest } from './types'
|
||||
|
||||
interface LibraryState {
|
||||
documents: DocumentMeta[]
|
||||
|
|
@ -25,6 +25,7 @@ interface LibraryState {
|
|||
bulkRestore: () => Promise<void>
|
||||
emptyTrash: () => Promise<void>
|
||||
setSearchQuery: (query: string) => void
|
||||
updateDocument: (id: string, data: DocumentUpdateRequest) => Promise<void>
|
||||
}
|
||||
|
||||
export const useLibrary = create<LibraryState>((set, get) => ({
|
||||
|
|
@ -176,5 +177,19 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
updateDocument: async (id: string, data: DocumentUpdateRequest) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const updatedDoc = await libraryApi.updateDocument(id, data)
|
||||
set((state) => ({
|
||||
documents: state.documents.map(d => d.id === id ? updatedDoc : d),
|
||||
isLoading: false
|
||||
}))
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
|
|||
43
frontend/src/lib/annotations/registry.ts
Normal file
43
frontend/src/lib/annotations/registry.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { Canvas } from 'fabric';
|
||||
import type { Annotation } from './types';
|
||||
import type { ViewportParams } from '../coords';
|
||||
|
||||
/**
|
||||
* Interface that all annotation tools must implement.
|
||||
*/
|
||||
export interface ToolHandler {
|
||||
/** Uniquely identifies the tool, usually matching the annotation type. */
|
||||
name: string;
|
||||
|
||||
/** Called when the tool is selected. Use this to change cursors, etc. */
|
||||
onActivate?: (canvas: Canvas) => void;
|
||||
|
||||
/** Called when the tool is deselected. */
|
||||
onDeactivate?: (canvas: Canvas) => void;
|
||||
|
||||
/** Called when the user presses down on the canvas. */
|
||||
onPointerDown?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
|
||||
|
||||
/** Called when the user drags the pointer. */
|
||||
onPointerMove?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
|
||||
|
||||
/** Called when the user releases the pointer. */
|
||||
onPointerUp?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
|
||||
|
||||
/** Renders a canonical Annotation onto the given Fabric Canvas. */
|
||||
renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void;
|
||||
}
|
||||
|
||||
const registry = new Map<string, ToolHandler>();
|
||||
|
||||
export function registerTool(handler: ToolHandler) {
|
||||
registry.set(handler.name, handler);
|
||||
}
|
||||
|
||||
export function getTool(name: string): ToolHandler | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
export function getAllTools(): ToolHandler[] {
|
||||
return Array.from(registry.values());
|
||||
}
|
||||
28
frontend/src/lib/annotations/types.ts
Normal file
28
frontend/src/lib/annotations/types.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { components } from '../../types/api';
|
||||
|
||||
export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null };
|
||||
|
||||
export type TextAnnotation = Omit<components['schemas']['TextAnnotation'], 'props'> & {
|
||||
props: TextProps;
|
||||
};
|
||||
export type DrawAnnotation = components['schemas']['DrawAnnotation'];
|
||||
export type SignatureAnnotation = components['schemas']['SignatureAnnotation'];
|
||||
export type ImageAnnotation = components['schemas']['ImageAnnotation'];
|
||||
export type HighlightAnnotation = components['schemas']['HighlightAnnotation'];
|
||||
export type ShapeAnnotation = components['schemas']['ShapeAnnotation'];
|
||||
|
||||
export type Annotation =
|
||||
| TextAnnotation
|
||||
| DrawAnnotation
|
||||
| SignatureAnnotation
|
||||
| ImageAnnotation
|
||||
| HighlightAnnotation
|
||||
| ShapeAnnotation;
|
||||
|
||||
export type Rect = components['schemas']['Rect'];
|
||||
export type DrawProps = components['schemas']['DrawProps'];
|
||||
export type SignatureDrawProps = components['schemas']['SignatureDrawProps'];
|
||||
export type SignatureTypeProps = components['schemas']['SignatureTypeProps'];
|
||||
export type ImageProps = components['schemas']['ImageProps'];
|
||||
export type HighlightProps = components['schemas']['HighlightProps'];
|
||||
export type ShapeProps = components['schemas']['ShapeProps'];
|
||||
95
frontend/src/lib/coords.test.ts
Normal file
95
frontend/src/lib/coords.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
pdfToScreen,
|
||||
screenToPdf,
|
||||
pdfRectToScreen,
|
||||
screenRectToPdf,
|
||||
} from './coords';
|
||||
import type {
|
||||
ViewportParams,
|
||||
PdfPoint,
|
||||
PdfRect
|
||||
} from './coords';
|
||||
|
||||
describe('Coordinate Transforms', () => {
|
||||
const W = 612; // 8.5 inches at 72dpi
|
||||
const H = 792; // 11 inches at 72dpi
|
||||
|
||||
const rotations = [0, 90, 180, 270];
|
||||
const zooms = [0.5, 1.0, 2.0];
|
||||
const dprs = [1, 2];
|
||||
|
||||
const testPoints: PdfPoint[] = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: W, y: 0 },
|
||||
{ x: 0, y: H },
|
||||
{ x: W, y: H },
|
||||
{ x: W / 2, y: H / 2 },
|
||||
{ x: 100, y: 150 },
|
||||
];
|
||||
|
||||
const testRects: PdfRect[] = [
|
||||
{ x: 0, y: 0, width: 0, height: 0 }, // zero-width
|
||||
{ x: 10, y: 20, width: 100, height: 200 },
|
||||
{ x: W - 50, y: H - 50, width: 50, height: 50 }, // boundary
|
||||
];
|
||||
|
||||
it('should round-trip points accurately', () => {
|
||||
for (const rotation of rotations) {
|
||||
for (const scale of zooms) {
|
||||
for (const dpr of dprs) {
|
||||
const vp: ViewportParams = { scale, rotation, canonicalWidth: W, canonicalHeight: H, dpr };
|
||||
|
||||
for (const p of testPoints) {
|
||||
const screen = pdfToScreen(p, vp);
|
||||
const backToPdf = screenToPdf(screen, vp);
|
||||
|
||||
expect(backToPdf.x).toBeCloseTo(p.x, 2);
|
||||
expect(backToPdf.y).toBeCloseTo(p.y, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should round-trip rects accurately', () => {
|
||||
for (const rotation of rotations) {
|
||||
for (const scale of zooms) {
|
||||
for (const dpr of dprs) {
|
||||
const vp: ViewportParams = { scale, rotation, canonicalWidth: W, canonicalHeight: H, dpr };
|
||||
|
||||
for (const r of testRects) {
|
||||
const screenR = pdfRectToScreen(r, vp);
|
||||
const backToPdf = screenRectToPdf(screenR, vp);
|
||||
|
||||
expect(backToPdf.x).toBeCloseTo(r.x, 2);
|
||||
expect(backToPdf.y).toBeCloseTo(r.y, 2);
|
||||
expect(backToPdf.width).toBeCloseTo(r.width, 2);
|
||||
expect(backToPdf.height).toBeCloseTo(r.height, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle CropBox offset equivalently (since canonical space is relative to CropBox top-left)', () => {
|
||||
const vp: ViewportParams = { scale: 1, rotation: 0, canonicalWidth: 612, canonicalHeight: 792 };
|
||||
const p = { x: 306, y: 396 };
|
||||
const s = pdfToScreen(p, vp);
|
||||
expect(s.x).toBeCloseTo(306, 2);
|
||||
expect(s.y).toBeCloseTo(396, 2);
|
||||
});
|
||||
|
||||
it('should correctly rotate points (90 deg)', () => {
|
||||
const vp: ViewportParams = { scale: 1, rotation: 90, canonicalWidth: W, canonicalHeight: H };
|
||||
const tl = { x: 0, y: 0 };
|
||||
const tlS = pdfToScreen(tl, vp);
|
||||
expect(tlS.x).toBeCloseTo(H, 2);
|
||||
expect(tlS.y).toBeCloseTo(0, 2);
|
||||
|
||||
const bl = { x: 0, y: H };
|
||||
const blS = pdfToScreen(bl, vp);
|
||||
expect(blS.x).toBeCloseTo(0, 2);
|
||||
expect(blS.y).toBeCloseTo(0, 2);
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/coords.ts
Normal file
74
frontend/src/lib/coords.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
export interface PdfPoint { x: number; y: number }
|
||||
export interface ScreenPoint { x: number; y: number }
|
||||
|
||||
export interface PdfRect { x: number; y: number; width: number; height: number }
|
||||
export interface ScreenRect { x: number; y: number; width: number; height: number }
|
||||
|
||||
export interface ViewportParams {
|
||||
scale: number; // CSS Zoom level (1.0 = 100%)
|
||||
rotation: number; // 0, 90, 180, 270 (clockwise)
|
||||
canonicalWidth: number; // Unrotated CropBox width in PDF points
|
||||
canonicalHeight: number;// Unrotated CropBox height in PDF points
|
||||
dpr?: number; // Device Pixel Ratio (defaults to 1)
|
||||
}
|
||||
|
||||
function getEffectiveScale(vp: ViewportParams): number {
|
||||
return vp.scale * (vp.dpr || 1);
|
||||
}
|
||||
|
||||
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
|
||||
const s = getEffectiveScale(vp);
|
||||
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
|
||||
const { x, y } = p;
|
||||
|
||||
const rot = ((rotation % 360) + 360) % 360;
|
||||
|
||||
switch (rot) {
|
||||
case 0: return { x: x * s, y: y * s };
|
||||
case 90: return { x: (H - y) * s, y: x * s };
|
||||
case 180: return { x: (W - x) * s, y: (H - y) * s };
|
||||
case 270: return { x: y * s, y: (W - x) * s };
|
||||
default: throw new Error(`Invalid rotation: ${rotation}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint {
|
||||
const s = getEffectiveScale(vp);
|
||||
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
|
||||
const sx = p.x / s;
|
||||
const sy = p.y / s;
|
||||
|
||||
const rot = ((rotation % 360) + 360) % 360;
|
||||
|
||||
switch (rot) {
|
||||
case 0: return { x: sx, y: sy };
|
||||
case 90: return { x: sy, y: H - sx };
|
||||
case 180: return { x: W - sx, y: H - sy };
|
||||
case 270: return { x: W - sy, y: sx };
|
||||
default: throw new Error(`Invalid rotation: ${rotation}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function pdfRectToScreen(r: PdfRect, vp: ViewportParams): ScreenRect {
|
||||
const p1 = pdfToScreen({ x: r.x, y: r.y }, vp);
|
||||
const p2 = pdfToScreen({ x: r.x + r.width, y: r.y + r.height }, vp);
|
||||
|
||||
return {
|
||||
x: Math.min(p1.x, p2.x),
|
||||
y: Math.min(p1.y, p2.y),
|
||||
width: Math.abs(p2.x - p1.x),
|
||||
height: Math.abs(p2.y - p1.y)
|
||||
};
|
||||
}
|
||||
|
||||
export function screenRectToPdf(r: ScreenRect, vp: ViewportParams): PdfRect {
|
||||
const p1 = screenToPdf({ x: r.x, y: r.y }, vp);
|
||||
const p2 = screenToPdf({ x: r.x + r.width, y: r.y + r.height }, vp);
|
||||
|
||||
return {
|
||||
x: Math.min(p1.x, p2.x),
|
||||
y: Math.min(p1.y, p2.y),
|
||||
width: Math.abs(p2.x - p1.x),
|
||||
height: Math.abs(p2.y - p1.y)
|
||||
};
|
||||
}
|
||||
|
|
@ -1,24 +1,38 @@
|
|||
import { useParams } from 'react-router-dom'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { PdfDocument } from '../features/editor/pages/PdfDocument'
|
||||
import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar'
|
||||
import { useEditorStore } from '../features/editor/store'
|
||||
import { useAutosave } from '../features/editor/useAutosave'
|
||||
import '../features/editor/tools'
|
||||
|
||||
/**
|
||||
* Editor page — PDF canvas workspace.
|
||||
* Full implementation begins in Phase 2 (rendering) and Phase 3 (annotations).
|
||||
*/
|
||||
export function EditorPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { setDocumentId } = useEditorStore()
|
||||
useAutosave()
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setDocumentId(id)
|
||||
}
|
||||
}, [id, setDocumentId])
|
||||
|
||||
if (!id) return <div>Invalid document ID</div>
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-neutral-100">
|
||||
<div className="flex h-screen flex-col bg-neutral-100 overflow-hidden">
|
||||
{/* Editor toolbar */}
|
||||
<header className="flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-2">
|
||||
<header className="flex-none flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-2 z-10 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100
|
||||
hover:text-neutral-700"
|
||||
<Link
|
||||
to="/"
|
||||
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700"
|
||||
>
|
||||
← Back
|
||||
</a>
|
||||
</Link>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
Document {id}
|
||||
</span>
|
||||
|
|
@ -30,20 +44,18 @@ export function EditorPage() {
|
|||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white
|
||||
transition-colors hover:bg-accent-600"
|
||||
className="rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Canvas area */}
|
||||
<main className="flex flex-1 items-center justify-center overflow-auto">
|
||||
<div className="text-sm text-neutral-400">
|
||||
PDF viewer will render here (Phase 2)
|
||||
</div>
|
||||
</main>
|
||||
{/* Main workspace area */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<EditorToolbar />
|
||||
<PdfDocument documentId={id} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
1501
frontend/src/types/api.d.ts
vendored
Normal file
1501
frontend/src/types/api.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load diff
1499
frontend/src/types/api.ts
Normal file
1499
frontend/src/types/api.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,11 +23,9 @@
|
|||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue