Implement real deskew algorithm and UI improvements

- Replace placeholder with OpenCV.js WASM perspective correction:
  pick highest-confidence rectangle, compute homography, fold
  weighted scale corrections from secondary datums, single warpPerspective
- All units now mm throughout (no cm conversion)
- Simplified datum creation: two buttons (+ Rectangle / + Line) with
  preset chips, auto-numbered labels (Line 1, Rectangle 2, etc.)
- Dimensions default to 0, user must input manually; Next button
  disabled until all datums have valid dimensions with tooltip hint
- Fix image preview (keep object URL alive), fix canvas disappearing
  on breakpoint switch (single instance + ResizeObserver re-fit)
- Mobile responsive: bottom sheet for datum panel, full-width canvas
- Spinner on result screen during processing
- Stricter ESLint config, updated Prettier to 4-space/no-semicolons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Prevost 2026-04-14 21:15:53 +02:00
parent 2d56c5dada
commit 4069491c2f
84 changed files with 3681 additions and 2275 deletions

View File

@ -1,9 +1,9 @@
{
"semi": true,
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"tabWidth": 4,
"trailingComma": "all",
"printWidth": 100,
"printWidth": 80,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"

2
.serena/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/cache
/project.local.yml

View File

@ -1,62 +1,85 @@
import pluginVue from "eslint-plugin-vue";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import vueParser from "vue-eslint-parser";
import prettierConfig from "eslint-config-prettier";
import type { Linter } from "eslint";
import tseslint from "typescript-eslint"
import pluginVue from "eslint-plugin-vue"
import vueParser from "vue-eslint-parser"
import prettierConfig from "eslint-config-prettier"
import type { Linter } from "eslint"
const config: Linter.Config[] = [
export default tseslint.config(
{ ignores: ["dist/**", "*.config.ts"] },
// TypeScript strict type-checked rules for .ts and .vue files
{
ignores: ["dist/**", "node_modules/**"],
},
{
files: ["**/*.ts"],
files: ["**/*.{ts,vue}"],
extends: [...tseslint.configs.strictTypeChecked],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"@typescript-eslint": tsPlugin as Record<string, unknown>,
},
rules: {
...tsPlugin.configs?.["recommended"]?.rules,
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/explicit-function-return-type": "off",
},
},
// Vue strongly recommended
...(pluginVue.configs["flat/strongly-recommended"] as Linter.Config[]),
// Vue parser + additional strict rules
{
files: ["**/*.vue"],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tsParser,
parser: tseslint.parser,
ecmaVersion: "latest",
sourceType: "module",
extraFileExtensions: [".vue"],
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
vue: pluginVue as unknown as Record<string, unknown>,
"@typescript-eslint": tsPlugin as Record<string, unknown>,
},
rules: {
...pluginVue.configs?.["flat/recommended"]?.reduce(
(acc: Record<string, unknown>, cfg: Linter.Config) => ({
...acc,
...cfg.rules,
}),
{},
),
"vue/multi-word-component-names": "off",
"vue/block-order": [
"error",
{ order: ["script", "template", "style"] },
],
"vue/component-api-style": ["error", ["script-setup"]],
"vue/define-macros-order": [
"error",
{
order: [
"defineProps",
"defineEmits",
"defineSlots",
],
},
],
"vue/no-empty-component-block": "error",
"vue/no-ref-object-reactivity-loss": "error",
"vue/no-unused-refs": "error",
"vue/no-useless-mustaches": "error",
"vue/no-useless-v-bind": "error",
"vue/prefer-separate-static-class": "error",
"vue/prefer-true-attribute-shorthand": "error",
},
},
prettierConfig as Linter.Config,
];
export default config;
// Relax rules for generated shadcn-vue UI components
{
files: ["src/components/ui/**/*.{ts,vue}"],
rules: {
"vue/multi-word-component-names": "off",
"vue/require-default-prop": "off",
"vue/define-macros-order": "off",
"vue/no-template-shadow": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
},
},
prettierConfig as Linter.Config,
)

7
knip.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { KnipConfig } from "knip"
const config: KnipConfig = {
ignore: ["src/components/ui/**"],
}
export default config

View File

@ -8,28 +8,30 @@
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .ts,.vue",
"lint:fix": "eslint . --ext .ts,.vue --fix",
"format": "prettier --write \"src/**/*.{ts,vue,css}\""
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,vue,css}\"",
"knip": "knip"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^25.6.0",
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
"@vitejs/plugin-vue": "^6.0.6",
"eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.8.0",
"knip": "^6.4.1",
"prettier": "^3.8.2",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.4",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.6"
},
"dependencies": {
"@tanstack/vue-table": "^8.21.3",
"@techstark/opencv-js": "4.12.0-release.1",
"@vueuse/core": "^14.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

587
pnpm-lock.yaml generated
View File

@ -8,9 +8,9 @@ importers:
.:
dependencies:
'@tanstack/vue-table':
specifier: ^8.21.3
version: 8.21.3(vue@3.5.32(typescript@6.0.2))
'@techstark/opencv-js':
specifier: 4.12.0-release.1
version: 4.12.0-release.1
'@vueuse/core':
specifier: ^14.2.1
version: 14.2.1(vue@3.5.32(typescript@6.0.2))
@ -56,19 +56,13 @@ importers:
devDependencies:
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0))
version: 4.2.2(vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)(yaml@2.8.3))
'@types/node':
specifier: ^25.6.0
version: 25.6.0
'@typescript-eslint/eslint-plugin':
specifier: ^8.58.2
version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)
'@typescript-eslint/parser':
specifier: ^8.58.2
version: 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)
'@vitejs/plugin-vue':
specifier: ^6.0.6
version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0))(vue@3.5.32(typescript@6.0.2))
version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)(yaml@2.8.3))(vue@3.5.32(typescript@6.0.2))
eslint:
specifier: ^10.2.0
version: 10.2.0(jiti@2.6.1)
@ -78,6 +72,9 @@ importers:
eslint-plugin-vue:
specifier: ^10.8.0
version: 10.8.0(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.2.0(jiti@2.6.1)))
knip:
specifier: ^6.4.1
version: 6.4.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
prettier:
specifier: ^3.8.2
version: 3.8.2
@ -90,9 +87,15 @@ importers:
typescript:
specifier: ~6.0.2
version: 6.0.2
typescript-eslint:
specifier: ^8.58.2
version: 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)
vite:
specifier: ^8.0.4
version: 8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)
version: 8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)(yaml@2.8.3)
vue-eslint-parser:
specifier: ^10.4.0
version: 10.4.0(eslint@10.2.0(jiti@2.6.1))
vue-tsc:
specifier: ^3.2.6
version: 3.2.6(typescript@6.0.2)
@ -382,9 +385,247 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@oxc-parser/binding-android-arm-eabi@0.121.0':
resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxc-parser/binding-android-arm64@0.121.0':
resolution: {integrity: sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxc-parser/binding-darwin-arm64@0.121.0':
resolution: {integrity: sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxc-parser/binding-darwin-x64@0.121.0':
resolution: {integrity: sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxc-parser/binding-freebsd-x64@0.121.0':
resolution: {integrity: sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxc-parser/binding-linux-arm-gnueabihf@0.121.0':
resolution: {integrity: sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm-musleabihf@0.121.0':
resolution: {integrity: sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm64-gnu@0.121.0':
resolution: {integrity: sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-musl@0.121.0':
resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-ppc64-gnu@0.121.0':
resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.121.0':
resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-musl@0.121.0':
resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-s390x-gnu@0.121.0':
resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.121.0':
resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-musl@0.121.0':
resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-openharmony-arm64@0.121.0':
resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxc-parser/binding-wasm32-wasi@0.121.0':
resolution: {integrity: sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@oxc-parser/binding-win32-arm64-msvc@0.121.0':
resolution: {integrity: sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxc-parser/binding-win32-ia32-msvc@0.121.0':
resolution: {integrity: sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxc-parser/binding-win32-x64-msvc@0.121.0':
resolution: {integrity: sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxc-project/types@0.121.0':
resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==}
'@oxc-project/types@0.124.0':
resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==}
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==}
cpu: [arm]
os: [android]
'@oxc-resolver/binding-android-arm64@11.19.1':
resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==}
cpu: [arm64]
os: [android]
'@oxc-resolver/binding-darwin-arm64@11.19.1':
resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==}
cpu: [arm64]
os: [darwin]
'@oxc-resolver/binding-darwin-x64@11.19.1':
resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==}
cpu: [x64]
os: [darwin]
'@oxc-resolver/binding-freebsd-x64@11.19.1':
resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==}
cpu: [x64]
os: [freebsd]
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==}
cpu: [arm]
os: [linux]
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==}
cpu: [arm]
os: [linux]
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==}
cpu: [arm64]
os: [openharmony]
'@oxc-resolver/binding-wasm32-wasi@11.19.1':
resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==}
cpu: [arm64]
os: [win32]
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==}
cpu: [ia32]
os: [win32]
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==}
cpu: [x64]
os: [win32]
'@rolldown/binding-android-arm64@1.0.0-rc.15':
resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -583,24 +824,17 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
'@tanstack/vue-table@8.21.3':
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
engines: {node: '>=12'}
peerDependencies:
vue: '>=3.2'
'@tanstack/vue-virtual@3.13.23':
resolution: {integrity: sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@techstark/opencv-js@4.12.0-release.1':
resolution: {integrity: sha512-LtTaph9v/HqLPXEg3m1xs2h7QJh10pUpuDT0nj8g77lelWnTwwQrehtd+fXElLOdrkqc4Fea6Z/sJBvEJLYPfw==}
'@ts-morph/common@0.28.1':
resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==}
@ -1249,6 +1483,9 @@ packages:
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fd-package-json@2.0.0:
resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -1285,6 +1522,11 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
formatly@0.3.0:
resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==}
engines: {node: '>=18.3.0'}
hasBin: true
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@ -1558,6 +1800,11 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
knip@6.4.1:
resolution: {integrity: sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
konva@10.2.5:
resolution: {integrity: sha512-WwBoe/EBhFcv+seL1Wnp3OAOwOFjCY4nCCgpLRrzUzw1IX4lKf/lYhj2Z3qo9P9q2fA3h+OdGDlimSNqZJaY5A==}
@ -1820,6 +2067,13 @@ packages:
resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==}
engines: {node: '>=20'}
oxc-parser@0.121.0:
resolution: {integrity: sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==}
engines: {node: ^20.19.0 || >=22.12.0}
oxc-resolver@11.19.1:
resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
p-event@6.0.1:
resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==}
engines: {node: '>=16.17'}
@ -2087,6 +2341,10 @@ packages:
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
engines: {node: '>=10'}
smol-toml@1.6.1:
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -2139,6 +2397,10 @@ packages:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'}
strip-json-comments@5.0.3:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
stylus@0.57.0:
resolution: {integrity: sha512-yOI6G8WYfr0q8v8rRvE91wbxFU+rJPo760Va4MF6K0I6BZjO4r+xSynkvyPBP9tV1CIEUeRsiidjIs2rzb1CnQ==}
hasBin: true
@ -2215,6 +2477,13 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
typescript-eslint@8.58.2:
resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@ -2228,6 +2497,10 @@ packages:
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
unbash@2.2.0:
resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==}
engines: {node: '>=14'}
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
@ -2351,6 +2624,10 @@ packages:
typescript:
optional: true
walk-up-path@4.0.0:
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
engines: {node: 20 || >=22}
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
@ -2382,6 +2659,11 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
hasBin: true
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -2402,6 +2684,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
snapshots:
'@babel/code-frame@7.29.0':
@ -2768,8 +3053,140 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@oxc-parser/binding-android-arm-eabi@0.121.0':
optional: true
'@oxc-parser/binding-android-arm64@0.121.0':
optional: true
'@oxc-parser/binding-darwin-arm64@0.121.0':
optional: true
'@oxc-parser/binding-darwin-x64@0.121.0':
optional: true
'@oxc-parser/binding-freebsd-x64@0.121.0':
optional: true
'@oxc-parser/binding-linux-arm-gnueabihf@0.121.0':
optional: true
'@oxc-parser/binding-linux-arm-musleabihf@0.121.0':
optional: true
'@oxc-parser/binding-linux-arm64-gnu@0.121.0':
optional: true
'@oxc-parser/binding-linux-arm64-musl@0.121.0':
optional: true
'@oxc-parser/binding-linux-ppc64-gnu@0.121.0':
optional: true
'@oxc-parser/binding-linux-riscv64-gnu@0.121.0':
optional: true
'@oxc-parser/binding-linux-riscv64-musl@0.121.0':
optional: true
'@oxc-parser/binding-linux-s390x-gnu@0.121.0':
optional: true
'@oxc-parser/binding-linux-x64-gnu@0.121.0':
optional: true
'@oxc-parser/binding-linux-x64-musl@0.121.0':
optional: true
'@oxc-parser/binding-openharmony-arm64@0.121.0':
optional: true
'@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
dependencies:
'@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.121.0':
optional: true
'@oxc-parser/binding-win32-ia32-msvc@0.121.0':
optional: true
'@oxc-parser/binding-win32-x64-msvc@0.121.0':
optional: true
'@oxc-project/types@0.121.0': {}
'@oxc-project/types@0.124.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
optional: true
'@oxc-resolver/binding-android-arm64@11.19.1':
optional: true
'@oxc-resolver/binding-darwin-arm64@11.19.1':
optional: true
'@oxc-resolver/binding-darwin-x64@11.19.1':
optional: true
'@oxc-resolver/binding-freebsd-x64@11.19.1':
optional: true
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
optional: true
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
optional: true
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
optional: true
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
optional: true
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
optional: true
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
optional: true
'@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
dependencies:
'@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
optional: true
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
optional: true
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
optional: true
'@rolldown/binding-android-arm64@1.0.0-rc.15':
optional: true
@ -2888,27 +3305,22 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
'@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0))':
'@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)(yaml@2.8.3))':
dependencies:
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2
vite: 8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)
'@tanstack/table-core@8.21.3': {}
vite: 8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)(yaml@2.8.3)
'@tanstack/virtual-core@3.13.23': {}
'@tanstack/vue-table@8.21.3(vue@3.5.32(typescript@6.0.2))':
dependencies:
'@tanstack/table-core': 8.21.3
vue: 3.5.32(typescript@6.0.2)
'@tanstack/vue-virtual@3.13.23(vue@3.5.32(typescript@6.0.2))':
dependencies:
'@tanstack/virtual-core': 3.13.23
vue: 3.5.32(typescript@6.0.2)
'@techstark/opencv-js@4.12.0-release.1': {}
'@ts-morph/common@0.28.1':
dependencies:
minimatch: 10.2.5
@ -3036,10 +3448,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0))(vue@3.5.32(typescript@6.0.2))':
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)(yaml@2.8.3))(vue@3.5.32(typescript@6.0.2))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
vite: 8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)
vite: 8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)(yaml@2.8.3)
vue: 3.5.32(typescript@6.0.2)
'@volar/language-core@2.4.28':
@ -3637,6 +4049,10 @@ snapshots:
dependencies:
reusify: 1.1.0
fd-package-json@2.0.0:
dependencies:
walk-up-path: 4.0.0
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@ -3677,6 +4093,10 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
formatly@0.3.0:
dependencies:
fd-package-json: 2.0.0
forwarded@0.2.0: {}
fresh@2.0.0: {}
@ -3892,6 +4312,27 @@ snapshots:
kleur@3.0.3: {}
knip@6.4.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
dependencies:
'@nodelib/fs.walk': 1.2.8
fast-glob: 3.3.3
formatly: 0.3.0
get-tsconfig: 4.13.7
jiti: 2.6.1
minimist: 1.2.8
oxc-parser: 0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
picocolors: 1.1.1
picomatch: 4.0.4
smol-toml: 1.6.1
strip-json-comments: 5.0.3
unbash: 2.2.0
yaml: 2.8.3
zod: 4.3.6
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
konva@10.2.5: {}
levn@0.4.1:
@ -4114,6 +4555,60 @@ snapshots:
stdin-discarder: 0.3.2
string-width: 8.2.0
oxc-parser@0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
dependencies:
'@oxc-project/types': 0.121.0
optionalDependencies:
'@oxc-parser/binding-android-arm-eabi': 0.121.0
'@oxc-parser/binding-android-arm64': 0.121.0
'@oxc-parser/binding-darwin-arm64': 0.121.0
'@oxc-parser/binding-darwin-x64': 0.121.0
'@oxc-parser/binding-freebsd-x64': 0.121.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.121.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.121.0
'@oxc-parser/binding-linux-arm64-gnu': 0.121.0
'@oxc-parser/binding-linux-arm64-musl': 0.121.0
'@oxc-parser/binding-linux-ppc64-gnu': 0.121.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.121.0
'@oxc-parser/binding-linux-riscv64-musl': 0.121.0
'@oxc-parser/binding-linux-s390x-gnu': 0.121.0
'@oxc-parser/binding-linux-x64-gnu': 0.121.0
'@oxc-parser/binding-linux-x64-musl': 0.121.0
'@oxc-parser/binding-openharmony-arm64': 0.121.0
'@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
'@oxc-parser/binding-win32-arm64-msvc': 0.121.0
'@oxc-parser/binding-win32-ia32-msvc': 0.121.0
'@oxc-parser/binding-win32-x64-msvc': 0.121.0
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
optionalDependencies:
'@oxc-resolver/binding-android-arm-eabi': 11.19.1
'@oxc-resolver/binding-android-arm64': 11.19.1
'@oxc-resolver/binding-darwin-arm64': 11.19.1
'@oxc-resolver/binding-darwin-x64': 11.19.1
'@oxc-resolver/binding-freebsd-x64': 11.19.1
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1
'@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1
'@oxc-resolver/binding-linux-arm64-gnu': 11.19.1
'@oxc-resolver/binding-linux-arm64-musl': 11.19.1
'@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1
'@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1
'@oxc-resolver/binding-linux-riscv64-musl': 11.19.1
'@oxc-resolver/binding-linux-s390x-gnu': 11.19.1
'@oxc-resolver/binding-linux-x64-gnu': 11.19.1
'@oxc-resolver/binding-linux-x64-musl': 11.19.1
'@oxc-resolver/binding-openharmony-arm64': 11.19.1
'@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
'@oxc-resolver/binding-win32-arm64-msvc': 11.19.1
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
p-event@6.0.1:
dependencies:
p-timeout: 6.1.4
@ -4448,6 +4943,8 @@ snapshots:
astral-regex: 2.0.0
is-fullwidth-code-point: 3.0.0
smol-toml@1.6.1: {}
source-map-js@1.2.1: {}
source-map-resolve@0.6.0:
@ -4493,6 +4990,8 @@ snapshots:
strip-final-newline@2.0.0: {}
strip-json-comments@5.0.3: {}
stylus@0.57.0:
dependencies:
css: 3.0.0
@ -4572,12 +5071,25 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
typescript-eslint@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2):
dependencies:
'@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)
'@typescript-eslint/parser': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)
'@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2)
'@typescript-eslint/utils': 8.58.2(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)
eslint: 10.2.0(jiti@2.6.1)
typescript: 6.0.2
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {}
typescript@6.0.2: {}
ufo@1.6.3: {}
unbash@2.2.0: {}
undici-types@7.19.2: {}
undici@7.25.0: {}
@ -4602,7 +5114,7 @@ snapshots:
vary@1.1.2: {}
vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0):
vite@8.0.8(@types/node@25.6.0)(jiti@2.6.1)(stylus@0.57.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@ -4614,6 +5126,7 @@ snapshots:
fsevents: 2.3.3
jiti: 2.6.1
stylus: 0.57.0
yaml: 2.8.3
vscode-uri@3.1.0: {}
@ -4680,6 +5193,8 @@ snapshots:
optionalDependencies:
typescript: 6.0.2
walk-up-path@4.0.0: {}
web-worker@1.5.0: {}
which@2.0.2:
@ -4702,6 +5217,8 @@ snapshots:
yallist@3.1.1: {}
yaml@2.8.3: {}
yocto-queue@0.1.0: {}
yocto-spinner@1.1.0:
@ -4715,3 +5232,5 @@ snapshots:
zod: 3.25.76
zod@3.25.76: {}
zod@4.3.6: {}

View File

@ -1,13 +1,13 @@
<script setup lang="ts">
import { useAppStore } from "@/stores/app";
import StepIndicator from "@/components/StepIndicator.vue";
import ImageUpload from "@/components/ImageUpload.vue";
import ExifViewer from "@/components/ExifViewer.vue";
import DatumEditor from "@/components/DatumEditor.vue";
import ResultViewer from "@/components/ResultViewer.vue";
import ThemeToggle from "@/components/ThemeToggle.vue";
import { useAppStore } from "@/stores/app"
import StepIndicator from "@/components/StepIndicator.vue"
import ImageUpload from "@/components/ImageUpload.vue"
import ExifViewer from "@/components/ExifViewer.vue"
import DatumEditor from "@/components/DatumEditor.vue"
import ResultViewer from "@/components/ResultViewer.vue"
import ThemeToggle from "@/components/ThemeToggle.vue"
const store = useAppStore();
const store = useAppStore()
</script>
<template>
@ -15,7 +15,9 @@ const store = useAppStore();
<header
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
<div
class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4"
>
<h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
<div class="flex items-center gap-4">
<StepIndicator />

View File

@ -1,5 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap");
@import "tailwindcss";
@ -10,7 +9,7 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: 'Geist Variable', sans-serif;
--font-sans: "Geist Variable", sans-serif;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);

View File

@ -1,35 +1,35 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useAppStore } from "@/stores/app";
import { getDatumColor } from "@/lib/datums";
import type { Datum, Point } from "@/types";
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { getDatumColor } from "@/lib/datums"
import type { Datum, Point } from "@/types"
const store = useAppStore();
const store = useAppStore()
const containerRef = ref<HTMLDivElement | null>(null);
const stageWidth = ref(800);
const stageHeight = ref(600);
const containerRef = ref<HTMLDivElement | null>(null)
const stageWidth = ref(800)
const stageHeight = ref(600)
const scale = ref(1);
const offsetX = ref(0);
const offsetY = ref(0);
const scale = ref(1)
const offsetX = ref(0)
const offsetY = ref(0)
// Touch state for pinch-to-zoom
let lastPinchDist = 0;
let isPanning = false;
let panStart = { x: 0, y: 0 };
let lastPinchDist = 0
let isPanning = false
let panStart = { x: 0, y: 0 }
const imageConfig = computed(() => {
const img = store.loadedImage;
if (!img) return null;
const img = store.loadedImage
if (!img) return null
return {
image: img,
x: 0,
y: 0,
width: img.naturalWidth,
height: img.naturalHeight,
};
});
}
})
const stageConfig = computed(() => ({
width: stageWidth.value,
@ -39,17 +39,17 @@ const stageConfig = computed(() => ({
x: offsetX.value,
y: offsetY.value,
draggable: false,
}));
}))
function datumIndex(datum: Datum): number {
return store.datums.findIndex((d) => d.id === datum.id);
return store.datums.findIndex((d) => d.id === datum.id)
}
function getPointConfigs(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx);
const isSelected = store.selectedDatumId === datum.id;
const points = datum.type === "rectangle" ? datum.corners : datum.endpoints;
const radius = isSelected ? 10 : 7;
const color = getDatumColor(dIdx)
const isSelected = store.selectedDatumId === datum.id
const points = datum.type === "rectangle" ? datum.corners : datum.endpoints
const radius = isSelected ? 10 : 7
return points.map((pt, pIdx) => ({
x: pt.x,
@ -62,12 +62,12 @@ function getPointConfigs(datum: Datum, dIdx: number) {
_datumId: datum.id,
_pointIndex: pIdx,
hitStrokeWidth: 20 / scale.value,
}));
}))
}
function getLineConfigs(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx);
const isSelected = store.selectedDatumId === datum.id;
const color = getDatumColor(dIdx)
const isSelected = store.selectedDatumId === datum.id
if (datum.type === "line") {
return [
@ -82,12 +82,12 @@ function getLineConfigs(datum: Datum, dIdx: number) {
strokeWidth: (isSelected ? 3 : 2) / scale.value,
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
},
];
]
}
// Rectangle: draw 4 edges
const c = datum.corners;
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y]);
const c = datum.corners
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y])
return [
{
points: pts,
@ -96,23 +96,25 @@ function getLineConfigs(datum: Datum, dIdx: number) {
closed: true,
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
},
];
]
}
function getLabelConfig(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx);
let pos: Point;
const color = getDatumColor(dIdx)
let pos: Point
if (datum.type === "rectangle") {
pos = {
x: (datum.corners[0].x + datum.corners[2].x) / 2,
y: (datum.corners[0].y + datum.corners[2].y) / 2,
};
}
} else {
pos = {
x: (datum.endpoints[0].x + datum.endpoints[1].x) / 2,
y: (datum.endpoints[0].y + datum.endpoints[1].y) / 2 - 20 / scale.value,
};
y:
(datum.endpoints[0].y + datum.endpoints[1].y) / 2 -
20 / scale.value,
}
}
return {
@ -124,159 +126,179 @@ function getLabelConfig(datum: Datum, dIdx: number) {
fontStyle: "bold",
align: "center" as const,
offsetX: (datum.label.length * 7) / 2 / scale.value,
};
}
}
function onPointDragMove(e: { target: { x: () => number; y: () => number; attrs: { _datumId: string; _pointIndex: number } } }) {
const { _datumId, _pointIndex } = e.target.attrs;
const datum = store.datums.find((d) => d.id === _datumId);
if (!datum) return;
function onPointDragMove(e: {
target: {
x: () => number
y: () => number
attrs: { _datumId: string; _pointIndex: number }
}
}) {
const { _datumId, _pointIndex } = e.target.attrs
const datum = store.datums.find((d) => d.id === _datumId)
if (!datum) return
const newPos: Point = { x: e.target.x(), y: e.target.y() };
const newPos: Point = { x: e.target.x(), y: e.target.y() }
if (datum.type === "rectangle") {
const newCorners = [...datum.corners] as [Point, Point, Point, Point];
newCorners[_pointIndex] = newPos;
store.updateDatum(_datumId, { corners: newCorners });
const newCorners = [...datum.corners] as [Point, Point, Point, Point]
newCorners[_pointIndex] = newPos
store.updateDatum(_datumId, { corners: newCorners })
} else {
const newEndpoints = [...datum.endpoints] as [Point, Point];
newEndpoints[_pointIndex] = newPos;
store.updateDatum(_datumId, { endpoints: newEndpoints });
const newEndpoints = [...datum.endpoints] as [Point, Point]
newEndpoints[_pointIndex] = newPos
store.updateDatum(_datumId, { endpoints: newEndpoints })
}
}
function onPointClick(datumId: string) {
store.selectedDatumId = datumId;
store.selectedDatumId = datumId
}
// Zoom with mouse wheel
function onWheel(e: WheelEvent) {
e.preventDefault();
const scaleBy = 1.08;
const oldScale = scale.value;
const newScale =
e.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy;
e.preventDefault()
const scaleBy = 1.08
const oldScale = scale.value
const newScale = e.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy
const clampedScale = Math.max(0.05, Math.min(10, newScale));
const clampedScale = Math.max(0.05, Math.min(10, newScale))
const rect = containerRef.value?.getBoundingClientRect();
if (!rect) return;
const rect = containerRef.value?.getBoundingClientRect()
if (!rect) return
const pointerX = e.clientX - rect.left;
const pointerY = e.clientY - rect.top;
const pointerX = e.clientX - rect.left
const pointerY = e.clientY - rect.top
const mousePointTo = {
x: (pointerX - offsetX.value) / oldScale,
y: (pointerY - offsetY.value) / oldScale,
};
}
scale.value = clampedScale;
offsetX.value = pointerX - mousePointTo.x * clampedScale;
offsetY.value = pointerY - mousePointTo.y * clampedScale;
scale.value = clampedScale
offsetX.value = pointerX - mousePointTo.x * clampedScale
offsetY.value = pointerY - mousePointTo.y * clampedScale
}
// Touch handlers for pinch-to-zoom and pan
function getTouchDistance(t1: Touch, t2: Touch): number {
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
}
function getTouchCenter(t1: Touch, t2: Touch): { x: number; y: number } {
return {
x: (t1.clientX + t2.clientX) / 2,
y: (t1.clientY + t2.clientY) / 2,
};
}
}
function onTouchStart(e: TouchEvent) {
if (e.touches.length === 2) {
e.preventDefault();
lastPinchDist = getTouchDistance(e.touches[0]!, e.touches[1]!);
e.preventDefault()
const t0 = e.touches[0]
const t1 = e.touches[1]
if (t0 && t1) {
lastPinchDist = getTouchDistance(t0, t1)
}
} else if (e.touches.length === 1) {
// Single-finger pan (only if not on a point)
const target = e.target as HTMLElement;
if (!target.closest(".konvajs-content")) return;
isPanning = true;
panStart = { x: e.touches[0]!.clientX - offsetX.value, y: e.touches[0]!.clientY - offsetY.value };
const target = e.target as HTMLElement
if (!target.closest(".konvajs-content")) return
const t0 = e.touches[0]
if (!t0) return
isPanning = true
panStart = {
x: t0.clientX - offsetX.value,
y: t0.clientY - offsetY.value,
}
}
}
function onTouchMove(e: TouchEvent) {
if (e.touches.length === 2) {
e.preventDefault();
const dist = getTouchDistance(e.touches[0]!, e.touches[1]!);
const center = getTouchCenter(e.touches[0]!, e.touches[1]!);
e.preventDefault()
const t0 = e.touches[0]
const t1 = e.touches[1]
if (!t0 || !t1) return
const dist = getTouchDistance(t0, t1)
const center = getTouchCenter(t0, t1)
const rect = containerRef.value?.getBoundingClientRect();
if (!rect) return;
const rect = containerRef.value?.getBoundingClientRect()
if (!rect) return
const scaleFactor = dist / lastPinchDist;
const oldScale = scale.value;
const newScale = Math.max(0.05, Math.min(10, oldScale * scaleFactor));
const scaleFactor = dist / lastPinchDist
const oldScale = scale.value
const newScale = Math.max(0.05, Math.min(10, oldScale * scaleFactor))
const cx = center.x - rect.left;
const cy = center.y - rect.top;
const cx = center.x - rect.left
const cy = center.y - rect.top
const mousePointTo = {
x: (cx - offsetX.value) / oldScale,
y: (cy - offsetY.value) / oldScale,
};
}
scale.value = newScale;
offsetX.value = cx - mousePointTo.x * newScale;
offsetY.value = cy - mousePointTo.y * newScale;
scale.value = newScale
offsetX.value = cx - mousePointTo.x * newScale
offsetY.value = cy - mousePointTo.y * newScale
lastPinchDist = dist;
lastPinchDist = dist
} else if (e.touches.length === 1 && isPanning) {
offsetX.value = e.touches[0]!.clientX - panStart.x;
offsetY.value = e.touches[0]!.clientY - panStart.y;
const t0 = e.touches[0]
if (!t0) return
offsetX.value = t0.clientX - panStart.x
offsetY.value = t0.clientY - panStart.y
}
}
function onTouchEnd() {
lastPinchDist = 0;
isPanning = false;
lastPinchDist = 0
isPanning = false
}
// Fit image to canvas on mount
function fitToCanvas() {
const img = store.loadedImage;
const container = containerRef.value;
if (!img || !container) return;
const img = store.loadedImage
const container = containerRef.value
if (!img || !container) return
const cw = container.clientWidth;
const ch = container.clientHeight;
stageWidth.value = cw;
stageHeight.value = ch;
const cw = container.clientWidth
const ch = container.clientHeight
stageWidth.value = cw
stageHeight.value = ch
const fitScale = Math.min(cw / img.naturalWidth, ch / img.naturalHeight) * 0.9;
scale.value = fitScale;
offsetX.value = (cw - img.naturalWidth * fitScale) / 2;
offsetY.value = (ch - img.naturalHeight * fitScale) / 2;
const fitScale =
Math.min(cw / img.naturalWidth, ch / img.naturalHeight) * 0.9
scale.value = fitScale
offsetX.value = (cw - img.naturalWidth * fitScale) / 2
offsetY.value = (ch - img.naturalHeight * fitScale) / 2
}
let resizeObserver: ResizeObserver | null = null;
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
fitToCanvas();
fitToCanvas()
if (containerRef.value) {
resizeObserver = new ResizeObserver(() => {
if (!containerRef.value) return;
const cw = containerRef.value.clientWidth;
const ch = containerRef.value.clientHeight;
if (!containerRef.value) return
const cw = containerRef.value.clientWidth
const ch = containerRef.value.clientHeight
// Skip if the container is hidden (0-sized)
if (cw === 0 || ch === 0) return;
if (cw === 0 || ch === 0) return
// Re-fit whenever the container size actually changes
fitToCanvas();
});
resizeObserver.observe(containerRef.value);
fitToCanvas()
})
resizeObserver.observe(containerRef.value)
}
});
})
onUnmounted(() => {
resizeObserver?.disconnect();
});
resizeObserver?.disconnect()
})
watch(() => store.loadedImage, fitToCanvas);
watch(() => store.loadedImage, fitToCanvas)
</script>
<template>
@ -297,17 +319,25 @@ watch(() => store.loadedImage, fitToCanvas);
<template v-for="datum in store.datums" :key="datum.id">
<!-- Lines/edges -->
<v-line
v-for="(lineCfg, li) in getLineConfigs(datum, datumIndex(datum))"
v-for="(lineCfg, li) in getLineConfigs(
datum,
datumIndex(datum),
)"
:key="`${datum.id}-line-${li}`"
:config="lineCfg"
/>
<!-- Center label -->
<v-text :config="getLabelConfig(datum, datumIndex(datum))" />
<v-text
:config="getLabelConfig(datum, datumIndex(datum))"
/>
<!-- Draggable points -->
<v-circle
v-for="ptCfg in getPointConfigs(datum, datumIndex(datum))"
v-for="ptCfg in getPointConfigs(
datum,
datumIndex(datum),
)"
:key="`${datum.id}-pt-${ptCfg._pointIndex}`"
:config="ptCfg"
@dragmove="onPointDragMove"

View File

@ -1,25 +1,45 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useAppStore } from "@/stores/app";
import { Button } from "@/components/ui/button";
import { ref, computed } from "vue"
import { useMediaQuery } from "@vueuse/core"
import { useAppStore } from "@/stores/app"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import DatumCanvas from "@/components/DatumCanvas.vue";
import DatumPanel from "@/components/DatumPanel.vue";
} from "@/components/ui/sheet"
import DatumCanvas from "@/components/DatumCanvas.vue"
import DatumPanel from "@/components/DatumPanel.vue"
const store = useAppStore();
const sheetOpen = ref(false);
const isMobile = useMediaQuery("(max-width: 767px)");
const store = useAppStore()
const sheetOpen = ref(false)
const isMobile = useMediaQuery("(max-width: 767px)")
const canvasHeight = computed(() =>
isMobile.value ? "h-[calc(100vh-14rem)]" : "h-[calc(100vh-12rem)]",
);
)
const incompleteDatums = computed(() =>
store.datums.filter((d) => {
if (d.type === "rectangle") return d.widthMm <= 0 || d.heightMm <= 0
return d.lengthMm <= 0
}),
)
const nextTooltip = computed(() => {
if (store.datums.length === 0) return "Add at least one datum"
if (incompleteDatums.value.length === 0) return ""
const names = incompleteDatums.value.map((d) => d.label)
return `Missing dimensions: ${names.join(", ")}`
})
</script>
<template>
@ -28,14 +48,32 @@ const canvasHeight = computed(() =>
<div class="min-w-0">
<h2 class="text-xl font-semibold">Place Datums</h2>
<p class="hidden text-sm text-muted-foreground sm:block">
Add reference shapes on the image and enter their real-world dimensions.
Add reference shapes on the image and enter their real-world
dimensions.
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button variant="outline" size="sm" @click="store.goToStep(2)">Back</Button>
<Button size="sm" :disabled="!store.canProceedToStep4" @click="store.goToStep(4)">
<Button variant="outline" size="sm" @click="store.goToStep(2)">
Back
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Button
size="sm"
:disabled="!store.canProceedToStep4"
@click="store.goToStep(4)"
>
Next
</Button>
</span>
</TooltipTrigger>
<TooltipContent v-if="nextTooltip" side="bottom">
{{ nextTooltip }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
@ -74,7 +112,10 @@ const canvasHeight = computed(() =>
</svg>
</Button>
</SheetTrigger>
<SheetContent side="bottom" class="h-[75vh] overflow-hidden rounded-t-xl">
<SheetContent
side="bottom"
class="h-[75vh] overflow-hidden rounded-t-xl"
>
<SheetHeader>
<SheetTitle>Datums</SheetTitle>
</SheetHeader>

View File

@ -1,68 +1,64 @@
<script setup lang="ts">
import { useAppStore } from "@/stores/app";
import { RECT_PRESETS, createRectDatum, createLineDatum, getDatumColor } from "@/lib/datums";
import type { ConfidenceScore, Datum, RectDatum } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { useAppStore } from "@/stores/app"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
RECT_PRESETS,
createRectDatum,
createLineDatum,
getDatumColor,
} from "@/lib/datums"
import type { ConfidenceScore, Datum, RectDatum } from "@/types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"
import { Separator } from "@/components/ui/separator"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const store = useAppStore();
const store = useAppStore()
function imageCenter() {
const img = store.loadedImage;
if (!img) return { x: 400, y: 300 };
return { x: img.naturalWidth / 2, y: img.naturalHeight / 2 };
const img = store.loadedImage
if (!img) return { x: 400, y: 300 }
return { x: img.naturalWidth / 2, y: img.naturalHeight / 2 }
}
function nextRectIndex(): number {
return store.datums.filter((d) => d.type === "rectangle").length + 1
}
function nextLineIndex(): number {
return store.datums.filter((d) => d.type === "line").length + 1
}
function addRect(presetLabel?: string) {
const preset = presetLabel
? RECT_PRESETS.find((p) => p.label === presetLabel)
: undefined;
store.addDatum(createRectDatum(imageCenter(), preset));
: undefined
store.addDatum(createRectDatum(imageCenter(), nextRectIndex(), preset))
}
function addLine() {
store.addDatum(createLineDatum(imageCenter()));
store.addDatum(createLineDatum(imageCenter(), nextLineIndex()))
}
function updateField(datum: Datum, field: string, value: string | number) {
store.updateDatum(datum.id, { [field]: value });
store.updateDatum(datum.id, { [field]: value })
}
function updateConfidence(datum: Datum, val: number[] | undefined) {
if (!val) return;
const v = val[0];
if (!val) return
const v = val[0]
if (v !== undefined && v >= 1 && v <= 5) {
store.updateDatum(datum.id, { confidence: v as ConfidenceScore });
store.updateDatum(datum.id, { confidence: v as ConfidenceScore })
}
}
function formatDimensions(datum: Datum): string {
if (datum.type === "rectangle") {
return `${datum.widthMm} \u00D7 ${datum.heightMm} mm`;
return `${String(datum.widthMm)} \u00D7 ${String(datum.heightMm)} mm`
}
return `${datum.lengthMm} mm`;
}
function onPresetSelect(value: unknown) {
const v = String(value);
addRect(v === "custom" ? undefined : v);
return `${String(datum.lengthMm)} mm`
}
</script>
@ -73,24 +69,37 @@ function onPresetSelect(value: unknown) {
<CardHeader class="pb-3">
<CardTitle class="text-sm">Add Datum</CardTitle>
</CardHeader>
<CardContent class="space-y-3 overflow-visible">
<div>
<Label class="mb-1.5 text-xs text-muted-foreground">Rectangle (preset)</Label>
<Select @update:model-value="onPresetSelect">
<SelectTrigger>
<SelectValue placeholder="Choose a preset..." />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="preset in RECT_PRESETS" :key="preset.label" :value="preset.label">
{{ preset.label }} ({{ preset.widthMm }}&times;{{ preset.heightMm }} mm)
</SelectItem>
<SelectItem value="custom">Custom rectangle</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" class="w-full" size="sm" @click="addLine">
+ Add Line
<CardContent class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
class="w-full"
@click="addRect()"
>
+ Rectangle
</Button>
<Button
variant="outline"
size="sm"
class="w-full"
@click="addLine"
>
+ Line
</Button>
</div>
<div class="flex flex-wrap gap-1.5">
<Button
v-for="preset in RECT_PRESETS"
:key="preset.label"
variant="secondary"
size="sm"
class="h-7 text-xs"
@click="addRect(preset.label)"
>
{{ preset.label }}
</Button>
</div>
</CardContent>
</Card>
@ -98,11 +107,16 @@ function onPresetSelect(value: unknown) {
<!-- Datum list -->
<div class="space-y-3">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
<p
class="text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Datums ({{ store.datums.length }})
</p>
<p v-if="store.datums.length === 0" class="text-sm text-muted-foreground">
<p
v-if="store.datums.length === 0"
class="text-sm text-muted-foreground"
>
No datums added yet. Use the controls above.
</p>
@ -126,9 +140,13 @@ function onPresetSelect(value: unknown) {
:style="{ backgroundColor: getDatumColor(idx) }"
/>
<Badge variant="outline" class="text-xs">
{{ datum.type === "rectangle" ? "Rect" : "Line" }}
{{
datum.type === "rectangle" ? "Rect" : "Line"
}}
</Badge>
<span class="text-xs text-muted-foreground">{{ formatDimensions(datum) }}</span>
<span class="text-xs text-muted-foreground">{{
formatDimensions(datum)
}}</span>
</div>
<Button
variant="ghost"
@ -148,7 +166,9 @@ function onPresetSelect(value: unknown) {
stroke-linejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</Button>
@ -160,32 +180,52 @@ function onPresetSelect(value: unknown) {
<Input
:model-value="datum.label"
class="mt-1 h-8 text-sm"
@update:model-value="(v: string | number) => updateField(datum, 'label', String(v))"
@update:model-value="
(v: string | number) =>
updateField(datum, 'label', String(v))
"
@click.stop
/>
</div>
<!-- Dimensions -->
<div v-if="datum.type === 'rectangle'" class="grid grid-cols-2 gap-2">
<div
v-if="datum.type === 'rectangle'"
class="grid grid-cols-2 gap-2"
>
<div>
<Label class="text-xs">Width (mm)</Label>
<Input
:model-value="String((datum as RectDatum).widthMm)"
:model-value="
String((datum as RectDatum).widthMm)
"
type="number"
min="1"
class="mt-1 h-8 text-sm"
@update:model-value="(v: string | number) => updateField(datum, 'widthMm', Number(v))"
@update:model-value="
(v: string | number) =>
updateField(datum, 'widthMm', Number(v))
"
@click.stop
/>
</div>
<div>
<Label class="text-xs">Height (mm)</Label>
<Input
:model-value="String((datum as RectDatum).heightMm)"
:model-value="
String((datum as RectDatum).heightMm)
"
type="number"
min="1"
class="mt-1 h-8 text-sm"
@update:model-value="(v: string | number) => updateField(datum, 'heightMm', Number(v))"
@update:model-value="
(v: string | number) =>
updateField(
datum,
'heightMm',
Number(v),
)
"
@click.stop
/>
</div>
@ -197,7 +237,10 @@ function onPresetSelect(value: unknown) {
type="number"
min="1"
class="mt-1 h-8 text-sm"
@update:model-value="(v: string | number) => updateField(datum, 'lengthMm', Number(v))"
@update:model-value="
(v: string | number) =>
updateField(datum, 'lengthMm', Number(v))
"
@click.stop
/>
</div>
@ -206,7 +249,9 @@ function onPresetSelect(value: unknown) {
<div>
<div class="flex items-center justify-between">
<Label class="text-xs">Confidence</Label>
<span class="text-xs font-medium text-muted-foreground">
<span
class="text-xs font-medium text-muted-foreground"
>
{{ datum.confidence }} / 5
</span>
</div>
@ -216,7 +261,10 @@ function onPresetSelect(value: unknown) {
:max="5"
:step="1"
class="mt-2"
@update:model-value="(v: number[] | undefined) => updateConfidence(datum, v)"
@update:model-value="
(v: number[] | undefined) =>
updateConfidence(datum, v)
"
@click.stop
/>
</div>

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
import { useAppStore } from "@/stores/app";
import { orientationLabel } from "@/lib/exif";
import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app"
import { orientationLabel } from "@/lib/exif"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
} from "@/components/ui/card"
import {
Table,
TableBody,
@ -16,28 +16,30 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
} from "@/components/ui/table"
const store = useAppStore();
const store = useAppStore()
interface ExifRow {
label: string;
value: string | undefined;
label: string
value: string | undefined
}
function getExifRows(): ExifRow[] {
const e = store.exifData;
const e = store.exifData
return [
{ label: "Camera Make", value: e.make },
{ label: "Camera Model", value: e.model },
{ label: "Lens", value: e.lensModel },
{
label: "Focal Length",
value: e.focalLength ? `${e.focalLength}mm` : undefined,
value: e.focalLength ? `${String(e.focalLength)}mm` : undefined,
},
{
label: "Focal Length (35mm eq.)",
value: e.focalLengthIn35mm ? `${e.focalLengthIn35mm}mm` : undefined,
value: e.focalLengthIn35mm
? `${String(e.focalLengthIn35mm)}mm`
: undefined,
},
{
label: "Orientation",
@ -47,12 +49,12 @@ function getExifRows(): ExifRow[] {
label: "Image Size",
value:
e.imageWidth && e.imageHeight
? `${e.imageWidth} \u00D7 ${e.imageHeight}`
? `${String(e.imageWidth)} \u00D7 ${String(e.imageHeight)}`
: undefined,
},
{
label: "Aperture",
value: e.fNumber ? `f/${e.fNumber}` : undefined,
value: e.fNumber ? `f/${String(e.fNumber)}` : undefined,
},
{
label: "ISO",
@ -62,8 +64,8 @@ function getExifRows(): ExifRow[] {
label: "Exposure",
value: e.exposureTime
? e.exposureTime < 1
? `1/${Math.round(1 / e.exposureTime)}s`
: `${e.exposureTime}s`
? `1/${String(Math.round(1 / e.exposureTime))}s`
: `${String(e.exposureTime)}s`
: undefined,
},
{ label: "Date Taken", value: e.dateTimeOriginal },
@ -74,7 +76,7 @@ function getExifRows(): ExifRow[] {
? `${e.gpsLatitude.toFixed(5)}, ${e.gpsLongitude.toFixed(5)}`
: undefined,
},
].filter((r) => r.value != null);
].filter((r) => r.value != null)
}
</script>
@ -88,7 +90,9 @@ function getExifRows(): ExifRow[] {
</p>
</div>
<div class="flex gap-2">
<Button variant="outline" @click="store.goToStep(1)">Back</Button>
<Button variant="outline" @click="store.goToStep(1)"
>Back</Button
>
<Button @click="store.goToStep(3)">Next: Add Datums</Button>
</div>
</div>
@ -98,10 +102,14 @@ function getExifRows(): ExifRow[] {
<Card>
<CardHeader>
<CardTitle class="text-base">Preview</CardTitle>
<CardDescription>{{ store.originalFile?.name }}</CardDescription>
<CardDescription>{{
store.originalFile?.name
}}</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-center overflow-hidden rounded-md bg-muted">
<div
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
>
<img
v-if="store.loadedImage"
:src="store.loadedImage.src"
@ -120,7 +128,9 @@ function getExifRows(): ExifRow[] {
<template v-if="getExifRows().length > 0">
Extracted from the image file
</template>
<template v-else> No EXIF data found in this image. </template>
<template v-else>
No EXIF data found in this image.
</template>
</CardDescription>
</CardHeader>
<CardContent>
@ -132,8 +142,13 @@ function getExifRows(): ExifRow[] {
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="row in getExifRows()" :key="row.label">
<TableCell class="font-medium">{{ row.label }}</TableCell>
<TableRow
v-for="row in getExifRows()"
:key="row.label"
>
<TableCell class="font-medium">{{
row.label
}}</TableCell>
<TableCell>{{ row.value }}</TableCell>
</TableRow>
</TableBody>
@ -163,17 +178,27 @@ function getExifRows(): ExifRow[] {
<path d="M12 8h.01" />
</svg>
<div class="text-sm text-muted-foreground">
<p class="font-medium text-foreground">Lens Correction Info</p>
<p class="font-medium text-foreground">
Lens Correction Info
</p>
<p class="mt-1">
<template v-if="store.exifData.focalLength">
This image was shot at <strong>{{ store.exifData.focalLength }}mm</strong>
This image was shot at
<strong
>{{ store.exifData.focalLength }}mm</strong
>
<template v-if="store.exifData.lensModel">
with a <strong>{{ store.exifData.lensModel }}</strong> </template
>. The deskew algorithm can use this to correct barrel/pincushion distortion.
with a
<strong>{{
store.exifData.lensModel
}}</strong> </template
>. The deskew algorithm can use this to correct
barrel/pincushion distortion.
</template>
<template v-else>
No focal length data found. The algorithm will rely solely on datum
measurements for perspective correction.
No focal length data found. The algorithm will
rely solely on datum measurements for
perspective correction.
</template>
</p>
</div>

View File

@ -1,58 +1,58 @@
<script setup lang="ts">
import { ref } from "vue";
import { useAppStore } from "@/stores/app";
import { loadImage } from "@/lib/image-loader";
import { extractExif } from "@/lib/exif";
import { ref } from "vue"
import { useAppStore } from "@/stores/app"
import { loadImage } from "@/lib/image-loader"
import { extractExif } from "@/lib/exif"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
} from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
const store = useAppStore();
const isDragging = ref(false);
const error = ref("");
const fileInput = ref<HTMLInputElement | null>(null);
const store = useAppStore()
const isDragging = ref(false)
const error = ref("")
const fileInput = ref<HTMLInputElement | null>(null)
const ACCEPTED = ".jpg,.jpeg,.heic,.heif";
const ACCEPTED = ".jpg,.jpeg,.heic,.heif"
async function handleFile(file: File) {
error.value = "";
store.isProcessing = true;
store.processingStatus = "Reading file...";
error.value = ""
store.isProcessing = true
store.processingStatus = "Reading file..."
try {
const { image, convertedFile } = await loadImage(file, (status) => {
store.processingStatus = status;
});
store.processingStatus = status
})
store.processingStatus = "Extracting EXIF data...";
const exif = await extractExif(file);
store.processingStatus = "Extracting EXIF data..."
const exif = await extractExif(file)
store.setImage(convertedFile, image);
store.setExif(exif);
store.goToStep(2);
store.setImage(convertedFile, image)
store.setExif(exif)
store.goToStep(2)
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load image";
error.value = e instanceof Error ? e.message : "Failed to load image"
} finally {
store.isProcessing = false;
store.processingStatus = "";
store.isProcessing = false
store.processingStatus = ""
}
}
function onDrop(e: DragEvent) {
isDragging.value = false;
const file = e.dataTransfer?.files[0];
if (file) handleFile(file);
isDragging.value = false
const file = e.dataTransfer?.files[0]
if (file) void handleFile(file)
}
function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) handleFile(file);
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) void handleFile(file)
}
</script>
@ -62,8 +62,8 @@ function onFileSelect(e: Event) {
<CardHeader class="text-center">
<CardTitle class="text-2xl">Upload an Image</CardTitle>
<CardDescription>
Drop a JPG or HEIC image, or click to browse. HEIC files will be converted
automatically.
Drop a JPG or HEIC image, or click to browse. HEIC files
will be converted automatically.
</CardDescription>
</CardHeader>
<CardContent>
@ -82,7 +82,9 @@ function onFileSelect(e: Event) {
<template v-if="store.isProcessing">
<div class="flex flex-col items-center gap-3 p-6">
<Progress :model-value="50" class="w-48" />
<p class="text-sm text-muted-foreground">{{ store.processingStatus }}</p>
<p class="text-sm text-muted-foreground">
{{ store.processingStatus }}
</p>
</div>
</template>
<template v-else>
@ -98,15 +100,21 @@ function onFileSelect(e: Event) {
stroke-linejoin="round"
class="mb-3 text-muted-foreground"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="17 8 12 3 7 8" />
<line x1="12" x2="12" y1="3" y2="15" />
</svg>
<p class="text-sm text-muted-foreground">
Drag &amp; drop or
<span class="font-medium text-primary underline">browse</span>
<span class="font-medium text-primary underline"
>browse</span
>
</p>
<p class="mt-1 text-xs text-muted-foreground/70">
JPG, JPEG, HEIC, HEIF
</p>
<p class="mt-1 text-xs text-muted-foreground/70">JPG, JPEG, HEIC, HEIF</p>
</template>
<input
@ -118,7 +126,10 @@ function onFileSelect(e: Event) {
/>
</div>
<p v-if="error" class="mt-3 text-center text-sm text-destructive">
<p
v-if="error"
class="mt-3 text-center text-sm text-destructive"
>
{{ error }}
</p>
</CardContent>

View File

@ -1,68 +1,85 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useAppStore } from "@/stores/app";
import { deskewImage } from "@/lib/deskew";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ref } from "vue"
import { useAppStore } from "@/stores/app"
import { deskewImage, waitForOpenCV } from "@/lib/deskew"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const store = useAppStore();
const resultUrl = ref<string | null>(null);
const error = ref("");
const hasRun = ref(false);
const store = useAppStore()
const resultUrl = ref<string | null>(null)
const error = ref("")
const hasRun = ref(false)
const cvReady = ref(false)
const cvLoading = ref(false)
async function ensureOpenCV() {
if (cvReady.value) return
cvLoading.value = true
store.processingStatus = "Loading OpenCV WASM..."
await waitForOpenCV()
cvReady.value = true
cvLoading.value = false
}
async function runDeskew() {
if (!store.loadedImage) return;
if (!store.loadedImage) return
error.value = "";
store.isProcessing = true;
store.processingStatus = "Running deskew algorithm...";
hasRun.value = true;
error.value = ""
store.isProcessing = true
hasRun.value = true
try {
// Draw the loaded image onto a canvas to pass to the algorithm
const canvas = document.createElement("canvas");
canvas.width = store.loadedImage.naturalWidth;
canvas.height = store.loadedImage.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Cannot get 2D context");
ctx.drawImage(store.loadedImage, 0, 0);
await ensureOpenCV()
store.processingStatus = "Running perspective correction..."
const result = await deskewImage({
imageData: canvas,
image: store.loadedImage,
datums: store.datums,
exif: store.exifData,
});
scalePxPerMm: store.scalePxPerMm,
})
store.setResult(result);
resultUrl.value = URL.createObjectURL(result.correctedImageBlob);
store.setResult(result)
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
resultUrl.value = URL.createObjectURL(result.correctedImageBlob)
} catch (e) {
error.value = e instanceof Error ? e.message : "Deskew failed";
error.value = e instanceof Error ? e.message : "Deskew failed"
} finally {
store.isProcessing = false;
store.processingStatus = "";
store.isProcessing = false
store.processingStatus = ""
}
}
function download() {
if (!resultUrl.value) return;
const a = document.createElement("a");
a.href = resultUrl.value;
a.download = `skwik-${store.originalFile?.name ?? "output"}.jpg`;
a.click();
if (!resultUrl.value) return
const a = document.createElement("a")
a.href = resultUrl.value
a.download = `skwik-${store.originalFile?.name ?? "output"}.png`
a.click()
}
onMounted(() => {
// Don't auto-run: let user set scale first
});
function hasRects(): boolean {
return store.datums.some((d) => d.type === "rectangle")
}
</script>
<template>
@ -71,32 +88,36 @@ onMounted(() => {
<div>
<h2 class="text-xl font-semibold">Process &amp; Download</h2>
<p class="text-sm text-muted-foreground">
Set the scale, run the deskew algorithm, and download the corrected image.
Set the output scale, run perspective correction, and
download.
</p>
</div>
<Button variant="outline" @click="store.goToStep(3)">Back</Button>
</div>
<!-- Scale setting (between step 3 and running the algo) -->
<!-- Scale setting -->
<Card>
<CardHeader>
<CardTitle class="text-base">Image Scale</CardTitle>
<CardTitle class="text-base">Output Scale</CardTitle>
<CardDescription>
How many pixels represent 1 cm in the original image. This helps the algorithm
interpret your datum measurements.
Pixels per millimeter in the corrected output image. Higher
= larger output.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center gap-3">
<Label>Scale</Label>
<Input
:model-value="String(store.scalePxPerCm)"
:model-value="String(store.scalePxPerMm)"
type="number"
min="1"
class="w-28"
@update:model-value="(v: string | number) => (store.scalePxPerCm = Number(v) || 50)"
@update:model-value="
(v: string | number) =>
(store.scalePxPerMm = Number(v) || 10)
"
/>
<span class="text-sm text-muted-foreground">px / cm</span>
<span class="text-sm text-muted-foreground">px / mm</span>
</div>
</CardContent>
</Card>
@ -106,52 +127,214 @@ onMounted(() => {
<CardHeader>
<CardTitle class="text-base">Datum Summary</CardTitle>
<CardDescription>
{{ store.datums.length }} datum(s) will be used for calibration.
{{ store.datums.length }} datum(s) will be used for
calibration.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-wrap gap-2">
<Badge v-for="datum in store.datums" :key="datum.id" variant="outline">
<Badge
v-for="datum in store.datums"
:key="datum.id"
variant="outline"
>
{{ datum.label }}
({{ datum.type === "rectangle" ? `${datum.widthMm}\u00D7${datum.heightMm}mm` : `${datum.lengthMm}mm` }})
&mdash; confidence {{ datum.confidence }}/5
({{
datum.type === "rectangle"
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
: `${datum.lengthMm}mm`
}}) &mdash; confidence {{ datum.confidence }}/5
</Badge>
</div>
<p v-if="!hasRects()" class="mt-3 text-sm text-destructive">
At least one rectangle datum is required for perspective
correction.
</p>
</CardContent>
</Card>
<!-- Run button -->
<div class="flex justify-center">
<Button size="lg" :disabled="store.isProcessing" @click="runDeskew">
<div class="flex flex-col items-center gap-3">
<Button
size="lg"
:disabled="store.isProcessing || !hasRects()"
@click="runDeskew"
>
<template v-if="store.isProcessing">
Processing...
<svg
class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{{ store.processingStatus || "Processing..." }}
</template>
<template v-else>
{{ hasRun ? "Re-run Deskew" : "Run Deskew Algorithm" }}
{{
hasRun
? "Re-run Correction"
: "Run Perspective Correction"
}}
</template>
</Button>
</div>
<p v-if="error" class="text-center text-sm text-destructive">{{ error }}</p>
<p v-if="error" class="text-center text-sm text-destructive">
{{ error }}
</p>
<!-- Result -->
<template v-if="store.deskewResult">
<!-- Diagnostics -->
<Card>
<CardHeader>
<CardTitle class="text-base">Diagnostics</CardTitle>
<CardDescription>
Primary reference:
<strong>{{
store.deskewResult.diagnostics.primaryDatum
}}</strong>
&ensp;&bull;&ensp; Output:
{{
store.deskewResult.diagnostics.outputWidthPx
}}&times;{{
store.deskewResult.diagnostics.outputHeightPx
}}px
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- Axis corrections -->
<div class="grid grid-cols-2 gap-4">
<div class="rounded-md border p-3">
<p
class="text-xs font-medium text-muted-foreground"
>
X-axis correction
</p>
<p class="text-lg font-semibold">
{{
(
store.deskewResult.diagnostics
.xCorrection.ratio * 100
).toFixed(2)
}}%
</p>
<p class="text-xs text-muted-foreground">
weight:
{{
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
1,
)
}}
</p>
</div>
<div class="rounded-md border p-3">
<p
class="text-xs font-medium text-muted-foreground"
>
Y-axis correction
</p>
<p class="text-lg font-semibold">
{{
(
store.deskewResult.diagnostics
.yCorrection.ratio * 100
).toFixed(2)
}}%
</p>
<p class="text-xs text-muted-foreground">
weight:
{{
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
1,
)
}}
</p>
</div>
</div>
<!-- Per-datum table -->
<Table
v-if="
store.deskewResult.diagnostics.perDatum.length > 0
"
>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Type</TableHead>
<TableHead class="text-right"
>Expected (mm)</TableHead
>
<TableHead class="text-right"
>Measured (mm)</TableHead
>
<TableHead class="text-right">Error</TableHead>
<TableHead>Axis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="report in store.deskewResult.diagnostics
.perDatum"
:key="report.label"
>
<TableCell class="font-medium">{{
report.label
}}</TableCell>
<TableCell>
<Badge variant="outline" class="text-xs">{{
report.type
}}</Badge>
</TableCell>
<TableCell class="text-right">{{
report.expectedMm.toFixed(1)
}}</TableCell>
<TableCell class="text-right">{{
report.measuredMm.toFixed(1)
}}</TableCell>
<TableCell
class="text-right"
:class="
report.errorPercent > 5
? 'text-destructive'
: ''
"
>
{{ report.errorPercent.toFixed(1) }}%
</TableCell>
<TableCell>{{
report.axisContribution
}}</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
<!-- Corrected image -->
<Card>
<CardHeader>
<CardTitle class="text-base">Corrected Image</CardTitle>
<CardDescription>
<span
v-for="(correction, i) in store.deskewResult.appliedCorrections"
:key="i"
>
{{ correction }}<template v-if="i < store.deskewResult.appliedCorrections.length - 1">
&ensp;&bull;&ensp;
</template>
</span>
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-center overflow-hidden rounded-md bg-muted">
<div
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
>
<img
v-if="resultUrl"
:src="resultUrl"
@ -162,7 +345,7 @@ onMounted(() => {
</CardContent>
</Card>
<div class="flex justify-center">
<div class="flex justify-center pb-8">
<Button size="lg" @click="download">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -180,7 +363,7 @@ onMounted(() => {
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
Download Image
Download PNG
</Button>
</div>
</template>

View File

@ -1,22 +1,24 @@
<script setup lang="ts">
import { useAppStore } from "@/stores/app";
import { Badge } from "@/components/ui/badge";
import { useAppStore } from "@/stores/app"
import { Badge } from "@/components/ui/badge"
const store = useAppStore();
const store = useAppStore()
const steps = [
{ num: 1 as const, label: "Upload" },
{ num: 2 as const, label: "EXIF" },
{ num: 3 as const, label: "Datums" },
{ num: 4 as const, label: "Result" },
];
]
</script>
<template>
<nav class="flex items-center gap-1" aria-label="Steps">
<template v-for="(step, i) in steps" :key="step.num">
<Badge
:variant="store.currentStep === step.num ? 'default' : 'outline'"
:variant="
store.currentStep === step.num ? 'default' : 'outline'
"
class="cursor-default select-none text-xs"
:class="{
'opacity-40': store.currentStep < step.num,
@ -24,7 +26,9 @@ const steps = [
>
{{ step.num }}. {{ step.label }}
</Badge>
<span v-if="i < steps.length - 1" class="text-muted-foreground">&middot;</span>
<span v-if="i < steps.length - 1" class="text-muted-foreground"
>&middot;</span
>
</template>
</nav>
</template>

View File

@ -1,29 +1,34 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Button } from "@/components/ui/button";
import { ref, onMounted } from "vue"
import { Button } from "@/components/ui/button"
const isDark = ref(false);
const isDark = ref(false)
onMounted(() => {
isDark.value =
document.documentElement.classList.contains("dark") ||
(!document.documentElement.classList.contains("light") &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
applyTheme();
});
window.matchMedia("(prefers-color-scheme: dark)").matches)
applyTheme()
})
function toggle() {
isDark.value = !isDark.value;
applyTheme();
isDark.value = !isDark.value
applyTheme()
}
function applyTheme() {
document.documentElement.classList.toggle("dark", isDark.value);
document.documentElement.classList.toggle("dark", isDark.value)
}
</script>
<template>
<Button variant="ghost" size="icon" @click="toggle" aria-label="Toggle theme">
<Button
variant="ghost"
size="icon"
@click="toggle"
aria-label="Toggle theme"
>
<svg
v-if="isDark"
xmlns="http://www.w3.org/2000/svg"

View File

@ -1,18 +1,20 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}>()
const props = defineProps<
PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}
>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>

View File

@ -1,23 +1,27 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from './Badge.vue'
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
default:
"bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)

View File

@ -1,19 +1,19 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
as: "button",
})
</script>

View File

@ -1,34 +1,41 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from './Button.vue'
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline',
default:
"bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive:
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'icon': 'size-8',
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3',
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)

View File

@ -1,20 +1,28 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
size?: 'default' | 'sm'
}>(), {
size: 'default',
})
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
size?: "default" | "sm"
}>(),
{
size: "default",
},
)
</script>
<template>
<div
data-slot="card"
:data-size="size"
:class="cn('ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', props.class)"
:class="
cn(
'ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col',
props.class,
)
"
>
<slot />
</div>

View File

@ -1,16 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
:class="
cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
props.class,
)
"
>
<slot />
</div>

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>

View File

@ -1,16 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', props.class)"
:class="
cn(
'bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center',
props.class,
)
"
>
<slot />
</div>

View File

@ -1,16 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
:class="
cn(
'gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
props.class,
)
"
>
<slot />
</div>

View File

@ -1,16 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-title"
:class="cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading', props.class)"
:class="
cn(
'text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading',
props.class,
)
"
>
<slot />
</div>

View File

@ -1,7 +1,7 @@
export { default as Card } from './Card.vue'
export { default as CardAction } from './CardAction.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"

View File

@ -1,19 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
@ -23,9 +23,11 @@ const modelValue = useVModel(props, 'modelValue', emits, {
<input
v-model="modelValue"
data-slot="input"
:class="cn(
:class="
cn(
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
)
"
/>
</template>

View File

@ -1 +1 @@
export { default as Input } from './Input.vue'
export { default as Input } from "./Input.vue"

View File

@ -1,13 +1,13 @@
<script setup lang="ts">
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Label } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>

View File

@ -1 +1 @@
export { default as Label } from './Label.vue'
export { default as Label } from "./Label.vue"

View File

@ -1,21 +1,18 @@
<script setup lang="ts">
import type { ProgressRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ProgressIndicator,
ProgressRoot,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import type { ProgressRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ProgressIndicator, ProgressRoot } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(),
defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(),
{
modelValue: 0,
},
)
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>

View File

@ -1 +1 @@
export { default as Progress } from './Progress.vue'
export { default as Progress } from "./Progress.vue"

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
@ -9,11 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<SelectRoot v-slot="slotProps" data-slot="select" v-bind="forwarded">
<slot v-bind="slotProps" />
</SelectRoot>
</template>

View File

@ -1,30 +1,30 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: 'item-aligned',
align: 'center',
position: "item-aligned",
align: "center",
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@ -35,10 +35,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="select-content"
:data-align-trigger="position === 'item-aligned'"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
:class="
cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
@ -46,9 +47,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<SelectScrollUpButton />
<SelectViewport
:data-position="position"
:class="cn(
:class="
cn(
'data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
)"
)
"
>
<slot />
</SelectViewport>

View File

@ -1,13 +1,15 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectGroup } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { SelectGroupProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectGroup } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
SelectGroupProps & { class?: HTMLAttributes["class"] }
>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>

View File

@ -1,20 +1,22 @@
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CheckIcon } from "lucide-vue-next"
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
SelectItemProps & { class?: HTMLAttributes["class"] }
>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
@ -30,7 +32,9 @@ const forwardedProps = useForwardProps(delegatedProps)
)
"
>
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<span
class="pointer-events-none absolute right-2 flex size-4 items-center justify-center"
>
<SelectItemIndicator>
<slot name="indicator-icon">
<CheckIcon class="pointer-events-none" />

View File

@ -1,15 +1,12 @@
<script setup lang="ts">
import type { SelectItemTextProps } from 'reka-ui'
import { SelectItemText } from 'reka-ui'
import type { SelectItemTextProps } from "reka-ui"
import { SelectItemText } from "reka-ui"
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<SelectItemText data-slot="select-item-text" v-bind="props">
<slot />
</SelectItemText>
</template>

View File

@ -1,10 +1,12 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { SelectLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { SelectLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { SelectLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
SelectLabelProps & { class?: HTMLAttributes["class"] }
>()
</script>
<template>

View File

@ -1,15 +1,17 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import type { SelectScrollDownButtonProps } from "reka-ui"
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDownIcon } from 'lucide-vue-next'
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDownIcon } from "lucide-vue-next"
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }
>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
@ -18,7 +20,12 @@ const forwardedProps = useForwardProps(delegatedProps)
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
:class="
cn(
'bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4',
props.class,
)
"
>
<slot>
<ChevronDownIcon />

View File

@ -1,15 +1,17 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import type { SelectScrollUpButtonProps } from "reka-ui"
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronUpIcon } from 'lucide-vue-next'
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronUpIcon } from "lucide-vue-next"
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }
>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
@ -18,7 +20,12 @@ const forwardedProps = useForwardProps(delegatedProps)
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
:class="
cn(
'bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4',
props.class,
)
"
>
<slot>
<ChevronUpIcon />

View File

@ -1,19 +1,23 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { SelectSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
SelectSeparatorProps & { class?: HTMLAttributes["class"] }
>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)"
:class="
cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)
"
/>
</template>

View File

@ -1,18 +1,23 @@
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDownIcon } from 'lucide-vue-next'
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDownIcon } from "lucide-vue-next"
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default' }>(),
{ size: 'default' },
defineProps<
SelectTriggerProps & {
class?: HTMLAttributes["class"]
size?: "sm" | "default"
}
>(),
{ size: "default" },
)
const delegatedProps = reactiveOmit(props, 'class', 'size')
const delegatedProps = reactiveOmit(props, "class", "size")
const forwardedProps = useForwardProps(delegatedProps)
</script>
@ -21,14 +26,18 @@ const forwardedProps = useForwardProps(delegatedProps)
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
:class="
cn(
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
<ChevronDownIcon
class="text-muted-foreground size-4 pointer-events-none"
/>
</SelectIcon>
</SelectTrigger>
</template>

View File

@ -1,15 +1,12 @@
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
import type { SelectValueProps } from "reka-ui"
import { SelectValue } from "reka-ui"
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue
data-slot="select-value"
v-bind="props"
>
<SelectValue data-slot="select-value" v-bind="props">
<slot />
</SelectValue>
</template>

View File

@ -1,11 +1,11 @@
export { default as Select } from './Select.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectValue } from './SelectValue.vue'
export { default as Select } from "./Select.vue"
export { default as SelectContent } from "./SelectContent.vue"
export { default as SelectGroup } from "./SelectGroup.vue"
export { default as SelectItem } from "./SelectItem.vue"
export { default as SelectItemText } from "./SelectItemText.vue"
export { default as SelectLabel } from "./SelectLabel.vue"
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
export { default as SelectSeparator } from "./SelectSeparator.vue"
export { default as SelectTrigger } from "./SelectTrigger.vue"
export { default as SelectValue } from "./SelectValue.vue"

View File

@ -1,18 +1,19 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { SeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Separator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<
SeparatorProps & { class?: HTMLAttributes['class'] }
>(), {
orientation: 'horizontal',
const props = withDefaults(
defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>(),
{
orientation: "horizontal",
decorative: true,
})
},
)
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>

View File

@ -1 +1 @@
export { default as Separator } from './Separator.vue'
export { default as Separator } from "./Separator.vue"

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
@ -9,11 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="sheet"
v-bind="forwarded"
>
<DialogRoot v-slot="slotProps" data-slot="sheet" v-bind="forwarded">
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@ -1,15 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="sheet-close"
v-bind="props"
>
<DialogClose data-slot="sheet-close" v-bind="props">
<slot />
</DialogClose>
</template>

View File

@ -1,22 +1,22 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { XIcon } from 'lucide-vue-next'
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { XIcon } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import SheetOverlay from './SheetOverlay.vue'
} from "reka-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import SheetOverlay from "./SheetOverlay.vue"
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes['class']
side?: 'top' | 'right' | 'bottom' | 'left'
class?: HTMLAttributes["class"]
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}
@ -25,12 +25,12 @@ defineOptions({
})
const props = withDefaults(defineProps<SheetContentProps>(), {
side: 'right',
side: "right",
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'side', 'showCloseButton')
const delegatedProps = reactiveOmit(props, "class", "side", "showCloseButton")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@ -41,7 +41,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DialogContent
data-slot="sheet-content"
:data-side="side"
:class="cn('bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10', props.class)"
:class="
cn(
'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
@ -51,7 +56,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="sheet-close"
as-child
>
<Button variant="ghost" class="absolute top-3 right-3" size="icon-sm">
<Button
variant="ghost"
class="absolute top-3 right-3"
size="icon-sm"
>
<XIcon />
<span class="sr-only">Close</span>
</Button>

View File

@ -1,13 +1,15 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
DialogDescriptionProps & { class?: HTMLAttributes["class"] }
>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>

View File

@ -1,19 +1,26 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
DialogOverlayProps & { class?: HTMLAttributes["class"] }
>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="sheet-overlay"
:class="cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', props.class)"
:class="
cn(
'bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
props.class,
)
"
v-bind="delegatedProps"
>
<slot />

View File

@ -1,19 +1,26 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
DialogTitleProps & { class?: HTMLAttributes["class"] }
>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogTitle
data-slot="sheet-title"
:class="cn('text-foreground text-base font-medium cn-font-heading', props.class)"
:class="
cn(
'text-foreground text-base font-medium cn-font-heading',
props.class,
)
"
v-bind="delegatedProps"
>
<slot />

View File

@ -1,15 +1,12 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="sheet-trigger"
v-bind="props"
>
<DialogTrigger data-slot="sheet-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@ -1,8 +1,8 @@
export { default as Sheet } from './Sheet.vue'
export { default as SheetClose } from './SheetClose.vue'
export { default as SheetContent } from './SheetContent.vue'
export { default as SheetDescription } from './SheetDescription.vue'
export { default as SheetFooter } from './SheetFooter.vue'
export { default as SheetHeader } from './SheetHeader.vue'
export { default as SheetTitle } from './SheetTitle.vue'
export { default as SheetTrigger } from './SheetTrigger.vue'
export { default as Sheet } from "./Sheet.vue"
export { default as SheetClose } from "./SheetClose.vue"
export { default as SheetContent } from "./SheetContent.vue"
export { default as SheetDescription } from "./SheetDescription.vue"
export { default as SheetFooter } from "./SheetFooter.vue"
export { default as SheetHeader } from "./SheetHeader.vue"
export { default as SheetTitle } from "./SheetTitle.vue"
export { default as SheetTrigger } from "./SheetTrigger.vue"

View File

@ -1,14 +1,22 @@
<script setup lang="ts">
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { SliderRootEmits, SliderRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SliderRange,
SliderRoot,
SliderThumb,
SliderTrack,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SliderRootProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<
SliderRootProps & { class?: HTMLAttributes["class"] }
>()
const emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@ -18,10 +26,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-slot="{ modelValue }"
data-slot="slider"
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
:class="cn(
:class="
cn(
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
props.class,
)"
)
"
v-bind="forwarded"
>
<SliderTrack
@ -32,8 +42,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
>
<SliderRange
data-slot="slider-range"
:data-horizontal="props.orientation !== 'vertical' ? '' : undefined"
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
:data-horizontal="
props.orientation !== 'vertical' ? '' : undefined
"
:data-vertical="
props.orientation === 'vertical' ? '' : undefined
"
class="bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderTrack>

View File

@ -1 +1 @@
export { default as Slider } from './Slider.vue'
export { default as Slider } from "./Slider.vue"

View File

@ -1,15 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-x-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<table
data-slot="table"
:class="cn('w-full caption-bottom text-sm', props.class)"
>
<slot />
</table>
</div>

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>

View File

@ -1,16 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
props.class,
)
"
>
<slot />
</td>

View File

@ -1,18 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { cn } from "@/lib/utils"
import TableCell from "./TableCell.vue"
import TableRow from "./TableRow.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
colspan?: number
}>(), {
}>(),
{
colspan: 1,
})
},
)
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>

View File

@ -1,16 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
:class="
cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
props.class,
)
"
>
<slot />
</tfoot>

View File

@ -1,16 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<th
data-slot="table-head"
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
:class="
cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0',
props.class,
)
"
>
<slot />
</th>

View File

@ -1,17 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>

View File

@ -1,16 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes['class']
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50', props.class)"
:class="
cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50',
props.class,
)
"
>
<slot />
</tr>

View File

@ -1,9 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'
export { default as Table } from "./Table.vue"
export { default as TableBody } from "./TableBody.vue"
export { default as TableCaption } from "./TableCaption.vue"
export { default as TableCell } from "./TableCell.vue"
export { default as TableEmpty } from "./TableEmpty.vue"
export { default as TableFooter } from "./TableFooter.vue"
export { default as TableHead } from "./TableHead.vue"
export { default as TableHeader } from "./TableHeader.vue"
export { default as TableRow } from "./TableRow.vue"

View File

@ -1,10 +0,0 @@
import type { Updater } from '@tanstack/vue-table'
import type { Ref } from 'vue'
import { isFunction } from '@tanstack/vue-table'
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
ref.value = isFunction(updaterOrValue)
? updaterOrValue(ref.value)
: updaterOrValue
}

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
import type { TooltipRootEmits, TooltipRootProps } from "reka-ui"
import { TooltipRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
@ -9,11 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot
v-slot="slotProps"
data-slot="tooltip"
v-bind="forwarded"
>
<TooltipRoot v-slot="slotProps" data-slot="tooltip" v-bind="forwarded">
<slot v-bind="slotProps" />
</TooltipRoot>
</template>

View File

@ -1,21 +1,29 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { TooltipContentEmits, TooltipContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
TooltipArrow,
TooltipContent,
TooltipPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 0,
})
},
)
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@ -24,11 +32,18 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)', props.class)"
:class="
cn(
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)',
props.class,
)
"
>
<slot />
<TooltipArrow class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
<TooltipArrow
class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]"
/>
</TooltipContent>
</TooltipPortal>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
import type { TooltipProviderProps } from "reka-ui"
import { TooltipProvider } from "reka-ui"
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0,

View File

@ -1,15 +1,12 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
import type { TooltipTriggerProps } from "reka-ui"
import { TooltipTrigger } from "reka-ui"
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger
data-slot="tooltip-trigger"
v-bind="props"
>
<TooltipTrigger data-slot="tooltip-trigger" v-bind="props">
<slot />
</TooltipTrigger>
</template>

View File

@ -1,4 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'
export { default as Tooltip } from "./Tooltip.vue"
export { default as TooltipContent } from "./TooltipContent.vue"
export { default as TooltipProvider } from "./TooltipProvider.vue"
export { default as TooltipTrigger } from "./TooltipTrigger.vue"

View File

@ -1,13 +1,13 @@
import { nanoid } from "nanoid";
import type { LineDatum, Point, RectDatum, RectPreset } from "@/types";
import { nanoid } from "nanoid"
import type { LineDatum, Point, RectDatum, RectPreset } from "@/types"
export const RECT_PRESETS: RectPreset[] = [
{ label: "A3", widthMm: 297, heightMm: 420 },
{ label: "A4", widthMm: 210, heightMm: 297 },
{ label: "A5", widthMm: 148, heightMm: 210 },
{ label: "A6", widthMm: 105, heightMm: 148 },
{ label: "10\u00D715 cm", widthMm: 100, heightMm: 150 },
];
{ label: "15\u00D710 cm", widthMm: 150, heightMm: 100 },
]
const DATUM_COLORS = [
"#3b82f6", // blue
@ -18,17 +18,20 @@ const DATUM_COLORS = [
"#ec4899", // pink
"#06b6d4", // cyan
"#f97316", // orange
];
]
export function getDatumColor(index: number): string {
return DATUM_COLORS[index % DATUM_COLORS.length]!;
const color = DATUM_COLORS[index % DATUM_COLORS.length]
if (!color) throw new Error("Unreachable: DATUM_COLORS is non-empty")
return color
}
export function createRectDatum(
center: Point,
index: number,
preset?: RectPreset,
): RectDatum {
const spread = 80;
const spread = 80
return {
id: nanoid(),
type: "rectangle",
@ -38,15 +41,15 @@ export function createRectDatum(
{ x: center.x + spread, y: center.y + spread },
{ x: center.x - spread, y: center.y + spread },
],
widthMm: preset?.widthMm ?? 210,
heightMm: preset?.heightMm ?? 297,
widthMm: preset?.widthMm ?? 0,
heightMm: preset?.heightMm ?? 0,
confidence: 3,
label: preset?.label ?? "Rectangle",
};
label: preset?.label ?? `Rectangle ${String(index)}`,
}
}
export function createLineDatum(center: Point): LineDatum {
const spread = 100;
export function createLineDatum(center: Point, index: number): LineDatum {
const spread = 100
return {
id: nanoid(),
type: "line",
@ -54,8 +57,8 @@ export function createLineDatum(center: Point): LineDatum {
{ x: center.x - spread, y: center.y },
{ x: center.x + spread, y: center.y },
],
lengthMm: 100,
lengthMm: 0,
confidence: 3,
label: "Line",
};
label: `Line ${String(index)}`,
}
}

View File

@ -1,45 +1,368 @@
import type { DeskewInput, DeskewResult } from "@/types";
/**
* deskew.ts Browser-based perspective correction using OpenCV.js (WASM)
*
* Adapted from the reference algorithm. Accepts N datums (rectangles and/or
* lines), each with known real-world dimensions and a confidence score (15).
* Minimum: one rectangle.
*
* Algorithm:
* 1. Pick the highest-confidence rectangle as primary reference.
* 2. getPerspectiveTransform from its 4 corners initial correction.
* 3. Project all other datums through that transform and measure them.
* 4. Compute per-axis weighted scale corrections from all secondary datums.
* 5. Fold corrections into the destination rectangle, recompute
* getPerspectiveTransform single clean perspective matrix.
* 6. warpPerspective the image.
*/
import cv from "@techstark/opencv-js"
import type {
AxisCorrection,
Datum,
DatumReport,
DeskewDiagnostics,
DeskewInput,
DeskewResult,
Point,
RectDatum,
} from "@/types"
// ─── OpenCV helpers ──────────────────────────────────────────────────────────
function pointsToMat(points: Point[]): InstanceType<typeof cv.Mat> {
const flat = points.flatMap((p) => [p.x, p.y])
return cv.matFromArray(points.length, 1, cv.CV_32FC2, flat)
}
function transformPoints(
points: Point[],
M: InstanceType<typeof cv.Mat>,
): Point[] {
const src = pointsToMat(points)
const dst = new cv.Mat()
cv.perspectiveTransform(src, dst, M)
const result: Point[] = []
const data = dst.data32F
for (let i = 0; i < points.length; i++) {
const x = data[i * 2]
const y = data[i * 2 + 1]
if (x === undefined || y === undefined) continue
result.push({ x, y })
}
src.delete()
dst.delete()
return result
}
function dist(a: Point, b: Point): number {
return Math.hypot(b.x - a.x, b.y - a.y)
}
function readMat3x3(M: InstanceType<typeof cv.Mat>): number[] {
const d: number[] = []
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
d.push(M.doubleAt(r, c))
}
}
return d
}
/** Row-major 3x3 matrix multiply */
function mul3x3(A: number[], B: number[]): number[] {
const R = Array<number>(9).fill(0)
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
let sum = 0
for (let k = 0; k < 3; k++) {
sum += (A[r * 3 + k] ?? 0) * (B[k * 3 + c] ?? 0)
}
R[r * 3 + c] = sum
}
}
return R
}
// ─── Validation ──────────────────────────────────────────────────────────────
function pickPrimary(datums: Datum[]): RectDatum {
const rects = datums.filter((d): d is RectDatum => d.type === "rectangle")
if (rects.length === 0) {
throw new Error(
"At least one rectangle datum is required for perspective correction.",
)
}
// Highest confidence; tie-break by pixel area (larger = more precise corners)
rects.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence
const area = (r: RectDatum) =>
dist(r.corners[0], r.corners[1]) * dist(r.corners[0], r.corners[3])
return area(b) - area(a)
})
return rects[0] as RectDatum
}
/**
* Placeholder deskew algorithm.
*
* TODO: Replace with actual perspective-correction implementation.
* The algorithm should:
* 1. Use datum measurements to compute a homography matrix
* 2. Apply lens distortion correction using EXIF focal length data
* 3. Warp the image to produce a corrected output
* Convert our app corner order (TL, TR, BR, BL) to the algorithm's
* expected order (TL, TR, BL, BR) for getPerspectiveTransform.
*/
export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
const canvas = document.createElement("canvas");
canvas.width = input.imageData.width;
canvas.height = input.imageData.height;
function cornersToAlgoOrder(
corners: [Point, Point, Point, Point],
): [Point, Point, Point, Point] {
// App: [TL, TR, BR, BL] → Algo: [TL, TR, BL, BR]
return [corners[0], corners[1], corners[3], corners[2]]
}
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Cannot get 2D context");
// ─── Canvas → Blob helper ───────────────────────────────────────────────────
ctx.drawImage(input.imageData, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
function canvasToBlob(
canvas: HTMLCanvasElement,
type = "image/png",
quality = 0.95,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("Canvas toBlob failed"))),
"image/jpeg",
0.95,
);
});
const corrections: string[] = [];
if (input.exif.focalLength) {
corrections.push(
`Lens: ${input.exif.lensModel ?? "unknown"} @ ${input.exif.focalLength}mm`,
);
(b) => {
if (b) {
resolve(b)
} else {
reject(new Error("toBlob failed"))
}
},
type,
quality,
)
})
}
corrections.push(`${input.datums.length} datum(s) used for calibration`);
corrections.push("Placeholder: no actual correction applied yet");
// ─── Core ────────────────────────────────────────────────────────────────────
return {
correctedImageBlob: blob,
appliedCorrections: corrections,
};
export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
const { image, datums, scalePxPerMm: scale } = input
if (datums.length === 0) throw new Error("No datums provided.")
const primary = pickPrimary(datums)
// Load source image into OpenCV
let srcCanvas: HTMLCanvasElement
if (image instanceof HTMLCanvasElement) {
srcCanvas = image
} else {
srcCanvas = document.createElement("canvas")
srcCanvas.width = image.naturalWidth
srcCanvas.height = image.naturalHeight
const ctx = srcCanvas.getContext("2d")
if (!ctx) throw new Error("Failed to get 2d context")
ctx.drawImage(image, 0, 0)
}
const src = cv.imread(srcCanvas)
const imgW = src.cols
const imgH = src.rows
// ================================================================
// STEP 1 — Initial perspective correction from primary rectangle
// ================================================================
const pw = primary.widthMm * scale
const ph = primary.heightMm * scale
const algoCorners = cornersToAlgoOrder(primary.corners)
const srcPts = pointsToMat(algoCorners)
const dstInit = pointsToMat([
{ x: 0, y: 0 },
{ x: pw, y: 0 },
{ x: 0, y: ph },
{ x: pw, y: ph },
])
const mInit = cv.getPerspectiveTransform(srcPts, dstInit)
// ================================================================
// STEP 2 — Measure all secondary datums, accumulate corrections
// ================================================================
let xWSum = 0,
xWTotal = 0
let yWSum = 0,
yWTotal = 0
const reports: DatumReport[] = []
for (const datum of datums) {
const w = datum.confidence
if (datum === primary) {
reports.push({
label: datum.label,
type: "rectangle",
measuredMm: datum.widthMm,
expectedMm: datum.widthMm,
errorPercent: 0,
axisContribution: "both",
})
continue
}
if (datum.type === "line") {
const [s, e] = transformPoints(datum.endpoints as Point[], mInit)
if (!s || !e) continue
const dx = Math.abs(e.x - s.x)
const dy = Math.abs(e.y - s.y)
const measured = dist(s, e)
const expected = datum.lengthMm * scale
const ratio = expected / measured
// Axis contribution proportional to alignment
const total = dx + dy
if (total > 1e-6) {
const xFrac = dx / total
const yFrac = dy / total
xWSum += ratio * w * xFrac
xWTotal += w * xFrac
yWSum += ratio * w * yFrac
yWTotal += w * yFrac
}
reports.push({
label: datum.label,
type: "line",
measuredMm: measured / scale,
expectedMm: datum.lengthMm,
errorPercent: Math.abs(1 - ratio) * 100,
axisContribution: dx > dy ? "x" : "y",
})
} else {
// Secondary rectangle: top edge → X, left edge → Y
const ac = cornersToAlgoOrder(datum.corners)
const [tl, tr, bl] = transformPoints([ac[0], ac[1], ac[2]], mInit)
if (!tl || !tr || !bl) continue
const mW = dist(tl, tr)
const mH = dist(tl, bl)
const xR = (datum.widthMm * scale) / mW
const yR = (datum.heightMm * scale) / mH
xWSum += xR * w
xWTotal += w
yWSum += yR * w
yWTotal += w
reports.push({
label: datum.label,
type: "rectangle",
measuredMm: mW / scale,
expectedMm: datum.widthMm,
errorPercent: (Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
axisContribution: "both",
})
}
}
// ================================================================
// STEP 3 — Weighted corrections (1.0 = no secondary data)
// ================================================================
const xCorr: AxisCorrection = {
ratio: xWTotal > 0 ? xWSum / xWTotal : 1.0,
totalWeight: xWTotal,
}
const yCorr: AxisCorrection = {
ratio: yWTotal > 0 ? yWSum / yWTotal : 1.0,
totalWeight: yWTotal,
}
// ================================================================
// STEP 4 — Fold into destination rectangle, recompute transform
// ================================================================
const pwFinal = pw * xCorr.ratio
const phFinal = ph * yCorr.ratio
const dstFinal = pointsToMat([
{ x: 0, y: 0 },
{ x: pwFinal, y: 0 },
{ x: 0, y: phFinal },
{ x: pwFinal, y: phFinal },
])
const mFinal = cv.getPerspectiveTransform(srcPts, dstFinal)
// ================================================================
// STEP 5 — Output bounds + translation shift
// ================================================================
const imgCorners: Point[] = [
{ x: 0, y: 0 },
{ x: imgW, y: 0 },
{ x: 0, y: imgH },
{ x: imgW, y: imgH },
]
const warped = transformPoints(imgCorners, mFinal)
let xMin = Infinity,
yMin = Infinity,
xMax = -Infinity,
yMax = -Infinity
for (const c of warped) {
xMin = Math.min(xMin, c.x)
yMin = Math.min(yMin, c.y)
xMax = Math.max(xMax, c.x)
yMax = Math.max(yMax, c.y)
}
const outW = Math.ceil(xMax - xMin)
const outH = Math.ceil(yMax - yMin)
const mData: number[] = readMat3x3(mFinal)
const tShift: number[] = [1, 0, -xMin, 0, 1, -yMin, 0, 0, 1]
const mOutData: number[] = mul3x3(tShift, mData)
const mOut = cv.matFromArray(3, 3, cv.CV_64FC1, mOutData)
// ================================================================
// STEP 6 — Warp
// ================================================================
const dstMat = new cv.Mat()
cv.warpPerspective(
src,
dstMat,
mOut,
new cv.Size(outW, outH),
cv.INTER_LANCZOS4 as number,
cv.BORDER_CONSTANT as number,
new cv.Scalar(0, 0, 0, 0),
)
const outCanvas = document.createElement("canvas")
outCanvas.width = outW
outCanvas.height = outH
cv.imshow(outCanvas, dstMat)
// Cleanup OpenCV mats
src.delete()
srcPts.delete()
dstInit.delete()
mInit.delete()
dstFinal.delete()
mFinal.delete()
mOut.delete()
dstMat.delete()
const blob = await canvasToBlob(outCanvas, "image/png", 0.95)
const diagnostics: DeskewDiagnostics = {
primaryDatum: primary.label,
xCorrection: xCorr,
yCorrection: yCorr,
perDatum: reports,
outputWidthPx: outW,
outputHeightPx: outH,
}
return { correctedImageBlob: blob, diagnostics }
}
// ─── OpenCV init ────────────────────────────────────────────────────────────
/** Wait for OpenCV WASM to initialize. Call once at app startup. */
export function waitForOpenCV(): Promise<void> {
return new Promise<void>((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (cv.Mat) {
resolve()
return
}
cv.onRuntimeInitialized = () => {
resolve()
}
})
}

View File

@ -1,62 +1,87 @@
import exifr from "exifr";
import type { ExifData } from "@/types";
import exifr from "exifr"
import type { ExifData } from "@/types"
interface ExifrResult {
Make?: string
Model?: string
LensModel?: string
FocalLength?: number
FocalLengthIn35mmFormat?: number
Orientation?: number
ImageWidth?: number
ExifImageWidth?: number
ImageHeight?: number
ExifImageHeight?: number
ExposureTime?: number
FNumber?: number
ISO?: number
DateTimeOriginal?: Date | string
latitude?: number
longitude?: number
}
export async function extractExif(file: File): Promise<ExifData> {
try {
const raw = await exifr.parse(file, {
const raw = (await exifr.parse(file, {
tiff: true,
exif: true,
gps: true,
ifd0: { pick: ["Make", "Model", "Orientation", "ImageWidth", "ImageHeight"] },
});
ifd0: {
pick: [
"Make",
"Model",
"Orientation",
"ImageWidth",
"ImageHeight",
],
},
})) as ExifrResult | undefined
if (!raw) return {};
if (!raw) return {}
return {
make: raw.Make as string | undefined,
model: raw.Model as string | undefined,
lensModel: raw.LensModel as string | undefined,
focalLength: raw.FocalLength as number | undefined,
focalLengthIn35mm: raw.FocalLengthIn35mmFormat as number | undefined,
orientation: raw.Orientation as number | undefined,
imageWidth: (raw.ImageWidth ?? raw.ExifImageWidth) as number | undefined,
imageHeight: (raw.ImageHeight ?? raw.ExifImageHeight) as
| number
| undefined,
exposureTime: raw.ExposureTime as number | undefined,
fNumber: raw.FNumber as number | undefined,
iso: raw.ISO as number | undefined,
make: raw.Make,
model: raw.Model,
lensModel: raw.LensModel,
focalLength: raw.FocalLength,
focalLengthIn35mm: raw.FocalLengthIn35mmFormat,
orientation: raw.Orientation,
imageWidth: raw.ImageWidth ?? raw.ExifImageWidth,
imageHeight: raw.ImageHeight ?? raw.ExifImageHeight,
exposureTime: raw.ExposureTime,
fNumber: raw.FNumber,
iso: raw.ISO,
dateTimeOriginal: raw.DateTimeOriginal
? String(raw.DateTimeOriginal)
: undefined,
gpsLatitude: raw.latitude as number | undefined,
gpsLongitude: raw.longitude as number | undefined,
};
gpsLatitude: raw.latitude,
gpsLongitude: raw.longitude,
}
} catch {
console.warn("EXIF extraction failed");
return {};
console.warn("EXIF extraction failed")
return {}
}
}
export function orientationLabel(orientation: number | undefined): string {
switch (orientation) {
case 1:
return "Normal";
return "Normal"
case 2:
return "Mirrored horizontal";
return "Mirrored horizontal"
case 3:
return "Rotated 180\u00B0";
return "Rotated 180\u00B0"
case 4:
return "Mirrored vertical";
return "Mirrored vertical"
case 5:
return "Mirrored horizontal + rotated 270\u00B0";
return "Mirrored horizontal + rotated 270\u00B0"
case 6:
return "Rotated 90\u00B0 CW";
return "Rotated 90\u00B0 CW"
case 7:
return "Mirrored horizontal + rotated 90\u00B0";
return "Mirrored horizontal + rotated 90\u00B0"
case 8:
return "Rotated 270\u00B0 CW";
return "Rotated 270\u00B0 CW"
default:
return "Unknown";
return "Unknown"
}
}

View File

@ -1,53 +1,55 @@
import { isHeic, heicTo } from "heic-to";
import { isHeic, heicTo } from "heic-to"
function isHeicFile(file: File): boolean {
const ext = file.name.toLowerCase();
if (ext.endsWith(".heic") || ext.endsWith(".heif")) return true;
return file.type === "image/heic" || file.type === "image/heif";
const ext = file.name.toLowerCase()
if (ext.endsWith(".heic") || ext.endsWith(".heif")) return true
return file.type === "image/heic" || file.type === "image/heif"
}
export async function loadImage(
file: File,
onProgress?: (status: string) => void,
): Promise<{ image: HTMLImageElement; convertedFile: File }> {
let processedFile = file;
let processedFile = file
if (isHeicFile(file)) {
onProgress?.("Checking HEIC format...");
onProgress?.("Checking HEIC format...")
if (await isHeic(file)) {
onProgress?.("Converting HEIC to JPEG...");
onProgress?.("Converting HEIC to JPEG...")
const jpegBlob = await heicTo({
blob: file,
type: "image/jpeg",
quality: 0.92,
});
})
processedFile = new File(
[jpegBlob],
file.name.replace(/\.hei[cf]$/i, ".jpg"),
{ type: "image/jpeg" },
);
)
}
}
onProgress?.("Loading image...");
const image = await createImageElement(processedFile);
return { image, convertedFile: processedFile };
onProgress?.("Loading image...")
const image = await createImageElement(processedFile)
return { image, convertedFile: processedFile }
}
function createImageElement(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
const img = new Image()
// Keep the object URL alive — the img.src must remain valid for later
// rendering in <img> tags and on the Konva canvas.
const url = URL.createObjectURL(file);
const url = URL.createObjectURL(file)
img.onload = () => resolve(img);
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
};
img.src = url;
});
img.onload = () => {
resolve(img)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error("Failed to load image"))
}
img.src = url
})
}

View File

@ -1,10 +1,10 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import VueKonva from "vue-konva";
import App from "./App.vue";
import "./assets/index.css";
import { createApp } from "vue"
import { createPinia } from "pinia"
import VueKonva from "vue-konva"
import App from "./App.vue"
import "./assets/index.css"
const app = createApp(App);
app.use(createPinia());
app.use(VueKonva);
app.mount("#app");
const app = createApp(App)
app.use(createPinia())
app.use(VueKonva)
app.mount("#app")

View File

@ -1,73 +1,81 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type { AppStep, Datum, DeskewResult, ExifData } from "@/types";
import { DEFAULT_SCALE_PX_PER_CM } from "@/types";
import { defineStore } from "pinia"
import { ref, computed } from "vue"
import type { AppStep, Datum, DeskewResult, ExifData } from "@/types"
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
export const useAppStore = defineStore("app", () => {
const currentStep = ref<AppStep>(1);
const originalFile = ref<File | null>(null);
const loadedImage = ref<HTMLImageElement | null>(null);
const exifData = ref<ExifData>({});
const datums = ref<Datum[]>([]);
const deskewResult = ref<DeskewResult | null>(null);
const isProcessing = ref(false);
const processingStatus = ref("");
const selectedDatumId = ref<string | null>(null);
const scalePxPerCm = ref(DEFAULT_SCALE_PX_PER_CM);
const currentStep = ref<AppStep>(1)
const originalFile = ref<File | null>(null)
const loadedImage = ref<HTMLImageElement | null>(null)
const exifData = ref<ExifData>({})
const datums = ref<Datum[]>([])
const deskewResult = ref<DeskewResult | null>(null)
const isProcessing = ref(false)
const processingStatus = ref("")
const selectedDatumId = ref<string | null>(null)
const scalePxPerMm = ref(DEFAULT_SCALE_PX_PER_MM)
const canProceedToStep2 = computed(() => loadedImage.value !== null);
const canProceedToStep3 = computed(() => canProceedToStep2.value);
const canProceedToStep4 = computed(
() => canProceedToStep3.value && datums.value.length > 0,
);
const canProceedToStep2 = computed(() => loadedImage.value !== null)
const canProceedToStep3 = computed(() => canProceedToStep2.value)
const canProceedToStep4 = computed(() => {
if (!canProceedToStep3.value || datums.value.length === 0) return false
return datums.value.every((d) => {
if (d.type === "rectangle") return d.widthMm > 0 && d.heightMm > 0
return d.lengthMm > 0
})
})
function setImage(file: File, image: HTMLImageElement) {
originalFile.value = file;
loadedImage.value = image;
originalFile.value = file
loadedImage.value = image
}
function setExif(data: ExifData) {
exifData.value = data;
exifData.value = data
}
function goToStep(step: AppStep) {
currentStep.value = step;
currentStep.value = step
}
function addDatum(datum: Datum) {
datums.value.push(datum);
selectedDatumId.value = datum.id;
datums.value.push(datum)
selectedDatumId.value = datum.id
}
function updateDatum(id: string, updates: Partial<Datum>) {
const index = datums.value.findIndex((d) => d.id === id);
if (index !== -1) {
datums.value[index] = { ...datums.value[index]!, ...updates } as Datum;
const index = datums.value.findIndex((d) => d.id === id)
const existing = datums.value[index]
if (index !== -1 && existing) {
datums.value[index] = {
...existing,
...updates,
} as Datum
}
}
function removeDatum(id: string) {
datums.value = datums.value.filter((d) => d.id !== id);
datums.value = datums.value.filter((d) => d.id !== id)
if (selectedDatumId.value === id) {
selectedDatumId.value = datums.value[0]?.id ?? null;
selectedDatumId.value = datums.value[0]?.id ?? null
}
}
function setResult(result: DeskewResult) {
deskewResult.value = result;
deskewResult.value = result
}
function reset() {
currentStep.value = 1;
originalFile.value = null;
loadedImage.value = null;
exifData.value = {};
datums.value = [];
deskewResult.value = null;
isProcessing.value = false;
processingStatus.value = "";
selectedDatumId.value = null;
scalePxPerCm.value = DEFAULT_SCALE_PX_PER_CM;
currentStep.value = 1
originalFile.value = null
loadedImage.value = null
exifData.value = {}
datums.value = []
deskewResult.value = null
isProcessing.value = false
processingStatus.value = ""
selectedDatumId.value = null
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
}
return {
@ -80,7 +88,7 @@ export const useAppStore = defineStore("app", () => {
isProcessing,
processingStatus,
selectedDatumId,
scalePxPerCm,
scalePxPerMm,
canProceedToStep2,
canProceedToStep3,
canProceedToStep4,
@ -92,5 +100,5 @@ export const useAppStore = defineStore("app", () => {
removeDatum,
setResult,
reset,
};
});
}
})

View File

@ -1,66 +1,91 @@
export interface Point {
x: number;
y: number;
x: number
y: number
}
export interface RectDatum {
id: string;
type: "rectangle";
corners: [Point, Point, Point, Point]; // TL, TR, BR, BL
widthMm: number;
heightMm: number;
confidence: 1 | 2 | 3 | 4 | 5;
label: string;
id: string
type: "rectangle"
corners: [Point, Point, Point, Point] // TL, TR, BR, BL
widthMm: number
heightMm: number
confidence: 1 | 2 | 3 | 4 | 5
label: string
}
export interface LineDatum {
id: string;
type: "line";
endpoints: [Point, Point];
lengthMm: number;
confidence: 1 | 2 | 3 | 4 | 5;
label: string;
id: string
type: "line"
endpoints: [Point, Point]
lengthMm: number
confidence: 1 | 2 | 3 | 4 | 5
label: string
}
export type Datum = RectDatum | LineDatum;
export type Datum = RectDatum | LineDatum
export type ConfidenceScore = 1 | 2 | 3 | 4 | 5;
export type ConfidenceScore = 1 | 2 | 3 | 4 | 5
export interface ExifData {
make?: string;
model?: string;
lensModel?: string;
focalLength?: number;
focalLengthIn35mm?: number;
orientation?: number;
imageWidth?: number;
imageHeight?: number;
exposureTime?: number;
fNumber?: number;
iso?: number;
dateTimeOriginal?: string;
gpsLatitude?: number;
gpsLongitude?: number;
make?: string
model?: string
lensModel?: string
focalLength?: number
focalLengthIn35mm?: number
orientation?: number
imageWidth?: number
imageHeight?: number
exposureTime?: number
fNumber?: number
iso?: number
dateTimeOriginal?: string
gpsLatitude?: number
gpsLongitude?: number
}
export interface DeskewInput {
imageData: HTMLCanvasElement;
datums: Datum[];
exif: ExifData;
image: HTMLImageElement | HTMLCanvasElement
datums: Datum[]
exif: ExifData
/** Output pixels per mm. */
scalePxPerMm: number
}
export interface AxisCorrection {
ratio: number
totalWeight: number
}
export interface DatumReport {
label: string
type: "rectangle" | "line"
measuredMm: number
expectedMm: number
errorPercent: number
axisContribution: "x" | "y" | "both"
}
export interface DeskewDiagnostics {
primaryDatum: string
xCorrection: AxisCorrection
yCorrection: AxisCorrection
perDatum: DatumReport[]
outputWidthPx: number
outputHeightPx: number
}
export interface DeskewResult {
correctedImageBlob: Blob;
appliedCorrections: string[];
correctedImageBlob: Blob
diagnostics: DeskewDiagnostics
}
export type AppStep = 1 | 2 | 3 | 4;
export type AppStep = 1 | 2 | 3 | 4
/** Pixels per centimeter in the image. Used for initial datum placement scaling. */
export const DEFAULT_SCALE_PX_PER_CM = 50;
/** Pixels per mm in the output image. Default 10 (= 100 px/cm). */
export const DEFAULT_SCALE_PX_PER_MM = 10
export interface RectPreset {
label: string;
widthMm: number;
heightMm: number;
label: string
widthMm: number
heightMm: number
}