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, "singleQuote": false,
"tabWidth": 2, "tabWidth": 4,
"trailingComma": "all", "trailingComma": "all",
"printWidth": 100, "printWidth": 80,
"bracketSpacing": true, "bracketSpacing": true,
"arrowParens": "always", "arrowParens": "always",
"endOfLine": "lf" "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 tseslint from "typescript-eslint"
import tsPlugin from "@typescript-eslint/eslint-plugin"; import pluginVue from "eslint-plugin-vue"
import tsParser from "@typescript-eslint/parser"; import vueParser from "vue-eslint-parser"
import vueParser from "vue-eslint-parser"; import prettierConfig from "eslint-config-prettier"
import prettierConfig from "eslint-config-prettier"; import type { Linter } from "eslint"
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,vue}"],
}, extends: [...tseslint.configs.strictTypeChecked],
{
files: ["**/*.ts"],
languageOptions: { languageOptions: {
parser: tsParser,
parserOptions: { parserOptions: {
ecmaVersion: "latest", projectService: true,
sourceType: "module", tsconfigRootDir: import.meta.dirname,
}, },
}, },
plugins: {
"@typescript-eslint": tsPlugin as Record<string, unknown>,
},
rules: { rules: {
...tsPlugin.configs?.["recommended"]?.rules,
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"error", "error",
{ argsIgnorePattern: "^_" }, { 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"], files: ["**/*.vue"],
languageOptions: { languageOptions: {
parser: vueParser, parser: vueParser,
parserOptions: { parserOptions: {
parser: tsParser, parser: tseslint.parser,
ecmaVersion: "latest", ecmaVersion: "latest",
sourceType: "module", sourceType: "module",
extraFileExtensions: [".vue"], 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: { rules: {
...pluginVue.configs?.["flat/recommended"]?.reduce( "vue/block-order": [
(acc: Record<string, unknown>, cfg: Linter.Config) => ({ "error",
...acc, { order: ["script", "template", "style"] },
...cfg.rules, ],
}), "vue/component-api-style": ["error", ["script-setup"]],
{}, "vue/define-macros-order": [
), "error",
"vue/multi-word-component-names": "off", {
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", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .ts,.vue", "lint": "eslint .",
"lint:fix": "eslint . --ext .ts,.vue --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,vue,css}\"" "format": "prettier --write \"src/**/*.{ts,vue,css}\"",
"knip": "knip"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"eslint": "^10.2.0", "eslint": "^10.2.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.8.0", "eslint-plugin-vue": "^10.8.0",
"knip": "^6.4.1",
"prettier": "^3.8.2", "prettier": "^3.8.2",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.4", "vite": "^8.0.4",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.6" "vue-tsc": "^3.2.6"
}, },
"dependencies": { "dependencies": {
"@tanstack/vue-table": "^8.21.3", "@techstark/opencv-js": "4.12.0-release.1",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

587
pnpm-lock.yaml generated
View File

@ -8,9 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@tanstack/vue-table': '@techstark/opencv-js':
specifier: ^8.21.3 specifier: 4.12.0-release.1
version: 8.21.3(vue@3.5.32(typescript@6.0.2)) version: 4.12.0-release.1
'@vueuse/core': '@vueuse/core':
specifier: ^14.2.1 specifier: ^14.2.1
version: 14.2.1(vue@3.5.32(typescript@6.0.2)) version: 14.2.1(vue@3.5.32(typescript@6.0.2))
@ -56,19 +56,13 @@ importers:
devDependencies: devDependencies:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 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': '@types/node':
specifier: ^25.6.0 specifier: ^25.6.0
version: 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': '@vitejs/plugin-vue':
specifier: ^6.0.6 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: eslint:
specifier: ^10.2.0 specifier: ^10.2.0
version: 10.2.0(jiti@2.6.1) version: 10.2.0(jiti@2.6.1)
@ -78,6 +72,9 @@ importers:
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^10.8.0 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))) 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: prettier:
specifier: ^3.8.2 specifier: ^3.8.2
version: 3.8.2 version: 3.8.2
@ -90,9 +87,15 @@ importers:
typescript: typescript:
specifier: ~6.0.2 specifier: ~6.0.2
version: 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: vite:
specifier: ^8.0.4 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: vue-tsc:
specifier: ^3.2.6 specifier: ^3.2.6
version: 3.2.6(typescript@6.0.2) version: 3.2.6(typescript@6.0.2)
@ -382,9 +385,247 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'} 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': '@oxc-project/types@0.124.0':
resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} 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': '@rolldown/binding-android-arm64@1.0.0-rc.15':
resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -583,24 +824,17 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8 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': '@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} 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': '@tanstack/vue-virtual@3.13.23':
resolution: {integrity: sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==} resolution: {integrity: sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==}
peerDependencies: peerDependencies:
vue: ^2.7.0 || ^3.0.0 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': '@ts-morph/common@0.28.1':
resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==}
@ -1249,6 +1483,9 @@ packages:
fastq@1.20.1: fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fd-package-json@2.0.0:
resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -1285,6 +1522,11 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'} 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: forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -1558,6 +1800,11 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'} 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: konva@10.2.5:
resolution: {integrity: sha512-WwBoe/EBhFcv+seL1Wnp3OAOwOFjCY4nCCgpLRrzUzw1IX4lKf/lYhj2Z3qo9P9q2fA3h+OdGDlimSNqZJaY5A==} resolution: {integrity: sha512-WwBoe/EBhFcv+seL1Wnp3OAOwOFjCY4nCCgpLRrzUzw1IX4lKf/lYhj2Z3qo9P9q2fA3h+OdGDlimSNqZJaY5A==}
@ -1820,6 +2067,13 @@ packages:
resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==}
engines: {node: '>=20'} 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: p-event@6.0.1:
resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==}
engines: {node: '>=16.17'} engines: {node: '>=16.17'}
@ -2087,6 +2341,10 @@ packages:
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
smol-toml@1.6.1:
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2139,6 +2397,10 @@ packages:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'} engines: {node: '>=6'}
strip-json-comments@5.0.3:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
stylus@0.57.0: stylus@0.57.0:
resolution: {integrity: sha512-yOI6G8WYfr0q8v8rRvE91wbxFU+rJPo760Va4MF6K0I6BZjO4r+xSynkvyPBP9tV1CIEUeRsiidjIs2rzb1CnQ==} resolution: {integrity: sha512-yOI6G8WYfr0q8v8rRvE91wbxFU+rJPo760Va4MF6K0I6BZjO4r+xSynkvyPBP9tV1CIEUeRsiidjIs2rzb1CnQ==}
hasBin: true hasBin: true
@ -2215,6 +2477,13 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'} 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: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@ -2228,6 +2497,10 @@ packages:
ufo@1.6.3: ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} 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: undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
@ -2351,6 +2624,10 @@ packages:
typescript: typescript:
optional: true 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: web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
@ -2382,6 +2659,11 @@ packages:
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} 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: yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2402,6 +2684,9 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
snapshots: snapshots:
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
@ -2768,8 +3053,140 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1 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-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': '@rolldown/binding-android-arm64@1.0.0-rc.15':
optional: true optional: true
@ -2888,27 +3305,22 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-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: dependencies:
'@tailwindcss/node': 4.2.2 '@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2 '@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2 tailwindcss: 4.2.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)
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.13.23': {} '@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))': '@tanstack/vue-virtual@3.13.23(vue@3.5.32(typescript@6.0.2))':
dependencies: dependencies:
'@tanstack/virtual-core': 3.13.23 '@tanstack/virtual-core': 3.13.23
vue: 3.5.32(typescript@6.0.2) vue: 3.5.32(typescript@6.0.2)
'@techstark/opencv-js@4.12.0-release.1': {}
'@ts-morph/common@0.28.1': '@ts-morph/common@0.28.1':
dependencies: dependencies:
minimatch: 10.2.5 minimatch: 10.2.5
@ -3036,10 +3448,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13 '@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) vue: 3.5.32(typescript@6.0.2)
'@volar/language-core@2.4.28': '@volar/language-core@2.4.28':
@ -3637,6 +4049,10 @@ snapshots:
dependencies: dependencies:
reusify: 1.1.0 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): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@ -3677,6 +4093,10 @@ snapshots:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
signal-exit: 4.1.0 signal-exit: 4.1.0
formatly@0.3.0:
dependencies:
fd-package-json: 2.0.0
forwarded@0.2.0: {} forwarded@0.2.0: {}
fresh@2.0.0: {} fresh@2.0.0: {}
@ -3892,6 +4312,27 @@ snapshots:
kleur@3.0.3: {} 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: {} konva@10.2.5: {}
levn@0.4.1: levn@0.4.1:
@ -4114,6 +4555,60 @@ snapshots:
stdin-discarder: 0.3.2 stdin-discarder: 0.3.2
string-width: 8.2.0 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: p-event@6.0.1:
dependencies: dependencies:
p-timeout: 6.1.4 p-timeout: 6.1.4
@ -4448,6 +4943,8 @@ snapshots:
astral-regex: 2.0.0 astral-regex: 2.0.0
is-fullwidth-code-point: 3.0.0 is-fullwidth-code-point: 3.0.0
smol-toml@1.6.1: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map-resolve@0.6.0: source-map-resolve@0.6.0:
@ -4493,6 +4990,8 @@ snapshots:
strip-final-newline@2.0.0: {} strip-final-newline@2.0.0: {}
strip-json-comments@5.0.3: {}
stylus@0.57.0: stylus@0.57.0:
dependencies: dependencies:
css: 3.0.0 css: 3.0.0
@ -4572,12 +5071,25 @@ snapshots:
media-typer: 1.1.0 media-typer: 1.1.0
mime-types: 3.0.2 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@5.9.3: {}
typescript@6.0.2: {} typescript@6.0.2: {}
ufo@1.6.3: {} ufo@1.6.3: {}
unbash@2.2.0: {}
undici-types@7.19.2: {} undici-types@7.19.2: {}
undici@7.25.0: {} undici@7.25.0: {}
@ -4602,7 +5114,7 @@ snapshots:
vary@1.1.2: {} 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: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
@ -4614,6 +5126,7 @@ snapshots:
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
stylus: 0.57.0 stylus: 0.57.0
yaml: 2.8.3
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
@ -4680,6 +5193,8 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 6.0.2 typescript: 6.0.2
walk-up-path@4.0.0: {}
web-worker@1.5.0: {} web-worker@1.5.0: {}
which@2.0.2: which@2.0.2:
@ -4702,6 +5217,8 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yaml@2.8.3: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
yocto-spinner@1.1.0: yocto-spinner@1.1.0:
@ -4715,3 +5232,5 @@ snapshots:
zod: 3.25.76 zod: 3.25.76
zod@3.25.76: {} zod@3.25.76: {}
zod@4.3.6: {}

View File

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app"
import StepIndicator from "@/components/StepIndicator.vue"; import StepIndicator from "@/components/StepIndicator.vue"
import ImageUpload from "@/components/ImageUpload.vue"; import ImageUpload from "@/components/ImageUpload.vue"
import ExifViewer from "@/components/ExifViewer.vue"; import ExifViewer from "@/components/ExifViewer.vue"
import DatumEditor from "@/components/DatumEditor.vue"; import DatumEditor from "@/components/DatumEditor.vue"
import ResultViewer from "@/components/ResultViewer.vue"; import ResultViewer from "@/components/ResultViewer.vue"
import ThemeToggle from "@/components/ThemeToggle.vue"; import ThemeToggle from "@/components/ThemeToggle.vue"
const store = useAppStore(); const store = useAppStore()
</script> </script>
<template> <template>
@ -15,7 +15,9 @@ const store = useAppStore();
<header <header
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60" 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> <h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<StepIndicator /> <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"; @import "tailwindcss";
@ -10,7 +9,7 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--font-sans: 'Geist Variable', sans-serif; --font-sans: "Geist Variable", sans-serif;
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);

View File

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

View File

@ -1,25 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue"
import { useMediaQuery } from "@vueuse/core"; import { useMediaQuery } from "@vueuse/core"
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet"
import DatumCanvas from "@/components/DatumCanvas.vue"; import DatumCanvas from "@/components/DatumCanvas.vue"
import DatumPanel from "@/components/DatumPanel.vue"; import DatumPanel from "@/components/DatumPanel.vue"
const store = useAppStore(); const store = useAppStore()
const sheetOpen = ref(false); const sheetOpen = ref(false)
const isMobile = useMediaQuery("(max-width: 767px)"); const isMobile = useMediaQuery("(max-width: 767px)")
const canvasHeight = computed(() => const canvasHeight = computed(() =>
isMobile.value ? "h-[calc(100vh-14rem)]" : "h-[calc(100vh-12rem)]", 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> </script>
<template> <template>
@ -28,14 +48,32 @@ const canvasHeight = computed(() =>
<div class="min-w-0"> <div class="min-w-0">
<h2 class="text-xl font-semibold">Place Datums</h2> <h2 class="text-xl font-semibold">Place Datums</h2>
<p class="hidden text-sm text-muted-foreground sm:block"> <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> </p>
</div> </div>
<div class="flex shrink-0 gap-2"> <div class="flex shrink-0 gap-2">
<Button variant="outline" size="sm" @click="store.goToStep(2)">Back</Button> <Button variant="outline" size="sm" @click="store.goToStep(2)">
<Button size="sm" :disabled="!store.canProceedToStep4" @click="store.goToStep(4)"> Back
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Button
size="sm"
:disabled="!store.canProceedToStep4"
@click="store.goToStep(4)"
>
Next Next
</Button> </Button>
</span>
</TooltipTrigger>
<TooltipContent v-if="nextTooltip" side="bottom">
{{ nextTooltip }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</div> </div>
@ -74,7 +112,10 @@ const canvasHeight = computed(() =>
</svg> </svg>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="bottom" class="h-[75vh] overflow-hidden rounded-t-xl"> <SheetContent
side="bottom"
class="h-[75vh] overflow-hidden rounded-t-xl"
>
<SheetHeader> <SheetHeader>
<SheetTitle>Datums</SheetTitle> <SheetTitle>Datums</SheetTitle>
</SheetHeader> </SheetHeader>

View File

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

View File

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

View File

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

View File

@ -1,68 +1,85 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref } from "vue"
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app"
import { deskewImage } from "@/lib/deskew"; import { deskewImage, waitForOpenCV } from "@/lib/deskew"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label"
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const store = useAppStore(); const store = useAppStore()
const resultUrl = ref<string | null>(null); const resultUrl = ref<string | null>(null)
const error = ref(""); const error = ref("")
const hasRun = ref(false); 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() { async function runDeskew() {
if (!store.loadedImage) return; if (!store.loadedImage) return
error.value = ""; error.value = ""
store.isProcessing = true; store.isProcessing = true
store.processingStatus = "Running deskew algorithm..."; hasRun.value = true
hasRun.value = true;
try { try {
// Draw the loaded image onto a canvas to pass to the algorithm await ensureOpenCV()
const canvas = document.createElement("canvas");
canvas.width = store.loadedImage.naturalWidth; store.processingStatus = "Running perspective correction..."
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);
const result = await deskewImage({ const result = await deskewImage({
imageData: canvas, image: store.loadedImage,
datums: store.datums, datums: store.datums,
exif: store.exifData, exif: store.exifData,
}); scalePxPerMm: store.scalePxPerMm,
})
store.setResult(result); store.setResult(result)
resultUrl.value = URL.createObjectURL(result.correctedImageBlob);
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
resultUrl.value = URL.createObjectURL(result.correctedImageBlob)
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Deskew failed"; error.value = e instanceof Error ? e.message : "Deskew failed"
} finally { } finally {
store.isProcessing = false; store.isProcessing = false
store.processingStatus = ""; store.processingStatus = ""
} }
} }
function download() { function download() {
if (!resultUrl.value) return; if (!resultUrl.value) return
const a = document.createElement("a"); const a = document.createElement("a")
a.href = resultUrl.value; a.href = resultUrl.value
a.download = `skwik-${store.originalFile?.name ?? "output"}.jpg`; a.download = `skwik-${store.originalFile?.name ?? "output"}.png`
a.click(); a.click()
} }
onMounted(() => { function hasRects(): boolean {
// Don't auto-run: let user set scale first return store.datums.some((d) => d.type === "rectangle")
}); }
</script> </script>
<template> <template>
@ -71,32 +88,36 @@ onMounted(() => {
<div> <div>
<h2 class="text-xl font-semibold">Process &amp; Download</h2> <h2 class="text-xl font-semibold">Process &amp; Download</h2>
<p class="text-sm text-muted-foreground"> <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> </p>
</div> </div>
<Button variant="outline" @click="store.goToStep(3)">Back</Button> <Button variant="outline" @click="store.goToStep(3)">Back</Button>
</div> </div>
<!-- Scale setting (between step 3 and running the algo) --> <!-- Scale setting -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base">Image Scale</CardTitle> <CardTitle class="text-base">Output Scale</CardTitle>
<CardDescription> <CardDescription>
How many pixels represent 1 cm in the original image. This helps the algorithm Pixels per millimeter in the corrected output image. Higher
interpret your datum measurements. = larger output.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Label>Scale</Label> <Label>Scale</Label>
<Input <Input
:model-value="String(store.scalePxPerCm)" :model-value="String(store.scalePxPerMm)"
type="number" type="number"
min="1" min="1"
class="w-28" 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> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -106,52 +127,214 @@ onMounted(() => {
<CardHeader> <CardHeader>
<CardTitle class="text-base">Datum Summary</CardTitle> <CardTitle class="text-base">Datum Summary</CardTitle>
<CardDescription> <CardDescription>
{{ store.datums.length }} datum(s) will be used for calibration. {{ store.datums.length }} datum(s) will be used for
calibration.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="flex flex-wrap gap-2"> <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.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> </Badge>
</div> </div>
<p v-if="!hasRects()" class="mt-3 text-sm text-destructive">
At least one rectangle datum is required for perspective
correction.
</p>
</CardContent> </CardContent>
</Card> </Card>
<!-- Run button --> <!-- Run button -->
<div class="flex justify-center"> <div class="flex flex-col items-center gap-3">
<Button size="lg" :disabled="store.isProcessing" @click="runDeskew"> <Button
size="lg"
:disabled="store.isProcessing || !hasRects()"
@click="runDeskew"
>
<template v-if="store.isProcessing"> <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>
<template v-else> <template v-else>
{{ hasRun ? "Re-run Deskew" : "Run Deskew Algorithm" }} {{
hasRun
? "Re-run Correction"
: "Run Perspective Correction"
}}
</template> </template>
</Button> </Button>
</div> </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 --> <!-- Result -->
<template v-if="store.deskewResult"> <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> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base">Corrected Image</CardTitle> <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> </CardHeader>
<CardContent> <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 <img
v-if="resultUrl" v-if="resultUrl"
:src="resultUrl" :src="resultUrl"
@ -162,7 +345,7 @@ onMounted(() => {
</CardContent> </CardContent>
</Card> </Card>
<div class="flex justify-center"> <div class="flex justify-center pb-8">
<Button size="lg" @click="download"> <Button size="lg" @click="download">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -180,7 +363,7 @@ onMounted(() => {
<polyline points="7 10 12 15 17 10" /> <polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" /> <line x1="12" x2="12" y1="15" y2="3" />
</svg> </svg>
Download Image Download PNG
</Button> </Button>
</div> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

@ -1,23 +1,27 @@
import type { VariantProps } from 'class-variance-authority' import type { VariantProps } from "class-variance-authority"
import { cva } 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( 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: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', default:
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', "bg-primary text-primary-foreground [a]:hover:bg-primary/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', secondary:
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', destructive:
link: 'text-primary underline-offset-4 hover:underline', "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: { defaultVariants: {
variant: 'default', variant: "default",
}, },
}, },
) )

View File

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

View File

@ -1,34 +1,41 @@
import type { VariantProps } from 'class-variance-authority' import type { VariantProps } from "class-variance-authority"
import { cva } 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( 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: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', default:
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', "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', outline:
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground', "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",
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', secondary:
link: 'text-primary underline-offset-4 hover:underline', "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: { size: {
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', default:
'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', "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
'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', 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",
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', 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",
'icon': 'size-8', lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3', icon: "size-8",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', "icon-xs":
'icon-lg': 'size-9', "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: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, },
) )

View File

@ -1,20 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{ const props = withDefaults(
class?: HTMLAttributes['class'] defineProps<{
size?: 'default' | 'sm' class?: HTMLAttributes["class"]
}>(), { size?: "default" | "sm"
size: 'default', }>(),
}) {
size: "default",
},
)
</script> </script>
<template> <template>
<div <div
data-slot="card" data-slot="card"
:data-size="size" :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 /> <slot />
</div> </div>

View File

@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<div <div
data-slot="card-action" 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 /> <slot />
</div> </div>

View File

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

View File

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

View File

@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<div <div
data-slot="card-footer" 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 /> <slot />
</div> </div>

View File

@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<div <div
data-slot="card-header" 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 /> <slot />
</div> </div>

View File

@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<div <div
data-slot="card-title" 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 /> <slot />
</div> </div>

View File

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

View File

@ -1,19 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { useVModel } from '@vueuse/core' import { useVModel } from "@vueuse/core"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
defaultValue?: string | number defaultValue?: string | number
modelValue?: string | number modelValue?: string | number
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
const emits = defineEmits<{ 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, passive: true,
defaultValue: props.defaultValue, defaultValue: props.defaultValue,
}) })
@ -23,9 +23,11 @@ const modelValue = useVModel(props, 'modelValue', emits, {
<input <input
v-model="modelValue" v-model="modelValue"
data-slot="input" 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', '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, props.class,
)" )
> "
/>
</template> </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"> <script setup lang="ts">
import type { LabelProps } from 'reka-ui' import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { Label } from 'reka-ui' import { Label } from "reka-ui"
import { cn } from '@/lib/utils' 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> </script>
<template> <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"> <script setup lang="ts">
import type { ProgressRootProps } from 'reka-ui' import type { ProgressRootProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { import { ProgressIndicator, ProgressRoot } from "reka-ui"
ProgressIndicator, import { cn } from "@/lib/utils"
ProgressRoot,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults( const props = withDefaults(
defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(), defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(),
{ {
modelValue: 0, modelValue: 0,
}, },
) )
const delegatedProps = reactiveOmit(props, 'class') const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <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"> <script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui' import type { SelectRootEmits, SelectRootProps } from "reka-ui"
import { SelectRoot, useForwardPropsEmits } from 'reka-ui' import { SelectRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<SelectRootProps>() const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>() const emits = defineEmits<SelectRootEmits>()
@ -9,11 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
</script> </script>
<template> <template>
<SelectRoot <SelectRoot v-slot="slotProps" data-slot="select" v-bind="forwarded">
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<slot v-bind="slotProps" /> <slot v-bind="slotProps" />
</SelectRoot> </SelectRoot>
</template> </template>

View File

@ -1,30 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui' import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { import {
SelectContent, SelectContent,
SelectPortal, SelectPortal,
SelectViewport, SelectViewport,
useForwardPropsEmits, useForwardPropsEmits,
} from 'reka-ui' } from "reka-ui"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from '.' import { SelectScrollDownButton, SelectScrollUpButton } from "."
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
const props = withDefaults( const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(), defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{ {
position: 'item-aligned', position: "item-aligned",
align: 'center', align: "center",
}, },
) )
const emits = defineEmits<SelectContentEmits>() const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, 'class') const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@ -35,10 +35,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="select-content" data-slot="select-content"
:data-align-trigger="position === 'item-aligned'" :data-align-trigger="position === 'item-aligned'"
v-bind="{ ...$attrs, ...forwarded }" 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', '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' 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', '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, props.class,
) )
" "
@ -46,9 +47,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectViewport <SelectViewport
:data-position="position" :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)]', '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 /> <slot />
</SelectViewport> </SelectViewport>

View File

@ -1,13 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui' import type { SelectGroupProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { SelectGroup } from 'reka-ui' import { SelectGroup } from "reka-ui"
import { cn } from '@/lib/utils' 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> </script>
<template> <template>

View File

@ -1,20 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItemProps } from 'reka-ui' import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { CheckIcon } from 'lucide-vue-next' import { CheckIcon } from "lucide-vue-next"
import { import {
SelectItem, SelectItem,
SelectItemIndicator, SelectItemIndicator,
SelectItemText, SelectItemText,
useForwardProps, useForwardProps,
} from 'reka-ui' } from "reka-ui"
import { cn } from '@/lib/utils' 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) const forwardedProps = useForwardProps(delegatedProps)
</script> </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> <SelectItemIndicator>
<slot name="indicator-icon"> <slot name="indicator-icon">
<CheckIcon class="pointer-events-none" /> <CheckIcon class="pointer-events-none" />

View File

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

View File

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

View File

@ -1,15 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui' import type { SelectScrollDownButtonProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { ChevronDownIcon } from 'lucide-vue-next' import { ChevronDownIcon } from "lucide-vue-next"
import { SelectScrollDownButton, useForwardProps } from 'reka-ui' import { SelectScrollDownButton, useForwardProps } from "reka-ui"
import { cn } from '@/lib/utils' 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) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
@ -18,7 +20,12 @@ const forwardedProps = useForwardProps(delegatedProps)
<SelectScrollDownButton <SelectScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
v-bind="forwardedProps" 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> <slot>
<ChevronDownIcon /> <ChevronDownIcon />

View File

@ -1,15 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui' import type { SelectScrollUpButtonProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { ChevronUpIcon } from 'lucide-vue-next' import { ChevronUpIcon } from "lucide-vue-next"
import { SelectScrollUpButton, useForwardProps } from 'reka-ui' import { SelectScrollUpButton, useForwardProps } from "reka-ui"
import { cn } from '@/lib/utils' 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) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
@ -18,7 +20,12 @@ const forwardedProps = useForwardProps(delegatedProps)
<SelectScrollUpButton <SelectScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
v-bind="forwardedProps" 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> <slot>
<ChevronUpIcon /> <ChevronUpIcon />

View File

@ -1,19 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui' import type { SelectSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { SelectSeparator } from 'reka-ui' import { SelectSeparator } from "reka-ui"
import { cn } from '@/lib/utils' 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> </script>
<template> <template>
<SelectSeparator <SelectSeparator
data-slot="select-separator" data-slot="select-separator"
v-bind="delegatedProps" 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> </template>

View File

@ -1,18 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui' import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { ChevronDownIcon } from 'lucide-vue-next' import { ChevronDownIcon } from "lucide-vue-next"
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui' import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = withDefaults( const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default' }>(), defineProps<
{ size: 'default' }, 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) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
@ -21,14 +26,18 @@ const forwardedProps = useForwardProps(delegatedProps)
data-slot="select-trigger" data-slot="select-trigger"
:data-size="size" :data-size="size"
v-bind="forwardedProps" 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', '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, props.class,
)" )
"
> >
<slot /> <slot />
<SelectIcon as-child> <SelectIcon as-child>
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" /> <ChevronDownIcon
class="text-muted-foreground size-4 pointer-events-none"
/>
</SelectIcon> </SelectIcon>
</SelectTrigger> </SelectTrigger>
</template> </template>

View File

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

View File

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

View File

@ -1,18 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SeparatorProps } from 'reka-ui' import type { SeparatorProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { Separator } from 'reka-ui' import { Separator } from "reka-ui"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = withDefaults(defineProps< const props = withDefaults(
SeparatorProps & { class?: HTMLAttributes['class'] } defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>(),
>(), { {
orientation: 'horizontal', orientation: "horizontal",
decorative: true, decorative: true,
}) },
)
const delegatedProps = reactiveOmit(props, 'class') const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <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"> <script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui' import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from 'reka-ui' import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>() const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>() const emits = defineEmits<DialogRootEmits>()
@ -9,11 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
</script> </script>
<template> <template>
<DialogRoot <DialogRoot v-slot="slotProps" data-slot="sheet" v-bind="forwarded">
v-slot="slotProps"
data-slot="sheet"
v-bind="forwarded"
>
<slot v-bind="slotProps" /> <slot v-bind="slotProps" />
</DialogRoot> </DialogRoot>
</template> </template>

View File

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

View File

@ -1,22 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui' import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { XIcon } from 'lucide-vue-next' import { XIcon } from "lucide-vue-next"
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from 'reka-ui' } from "reka-ui"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button"
import SheetOverlay from './SheetOverlay.vue' import SheetOverlay from "./SheetOverlay.vue"
interface SheetContentProps extends DialogContentProps { interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
side?: 'top' | 'right' | 'bottom' | 'left' side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean showCloseButton?: boolean
} }
@ -25,12 +25,12 @@ defineOptions({
}) })
const props = withDefaults(defineProps<SheetContentProps>(), { const props = withDefaults(defineProps<SheetContentProps>(), {
side: 'right', side: "right",
showCloseButton: true, showCloseButton: true,
}) })
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'side', 'showCloseButton') const delegatedProps = reactiveOmit(props, "class", "side", "showCloseButton")
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@ -41,7 +41,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DialogContent <DialogContent
data-slot="sheet-content" data-slot="sheet-content"
:data-side="side" :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 }" v-bind="{ ...$attrs, ...forwarded }"
> >
<slot /> <slot />
@ -51,7 +56,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="sheet-close" data-slot="sheet-close"
as-child 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 /> <XIcon />
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
</Button> </Button>

View File

@ -1,13 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui' import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { DialogDescription } from 'reka-ui' import { DialogDescription } from "reka-ui"
import { cn } from '@/lib/utils' 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> </script>
<template> <template>

View File

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

View File

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

View File

@ -1,19 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui' import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from 'reka-ui' import { DialogOverlay } from "reka-ui"
import { cn } from '@/lib/utils' 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> </script>
<template> <template>
<DialogOverlay <DialogOverlay
data-slot="sheet-overlay" 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" v-bind="delegatedProps"
> >
<slot /> <slot />

View File

@ -1,19 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui' import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { DialogTitle } from 'reka-ui' import { DialogTitle } from "reka-ui"
import { cn } from '@/lib/utils' 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> </script>
<template> <template>
<DialogTitle <DialogTitle
data-slot="sheet-title" 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" v-bind="delegatedProps"
> >
<slot /> <slot />

View File

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

View File

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

View File

@ -1,14 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SliderRootEmits, SliderRootProps } from 'reka-ui' import type { SliderRootEmits, SliderRootProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from 'reka-ui' import {
import { cn } from '@/lib/utils' 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 emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, 'class') const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@ -18,10 +26,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-slot="{ modelValue }" v-slot="{ modelValue }"
data-slot="slider" data-slot="slider"
:data-vertical="props.orientation === 'vertical' ? '' : undefined" :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', '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, props.class,
)" )
"
v-bind="forwarded" v-bind="forwarded"
> >
<SliderTrack <SliderTrack
@ -32,8 +42,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
> >
<SliderRange <SliderRange
data-slot="slider-range" data-slot="slider-range"
:data-horizontal="props.orientation !== 'vertical' ? '' : undefined" :data-horizontal="
:data-vertical="props.orientation === 'vertical' ? '' : undefined" props.orientation !== 'vertical' ? '' : undefined
"
:data-vertical="
props.orientation === 'vertical' ? '' : undefined
"
class="bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full" class="bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
/> />
</SliderTrack> </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"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<div data-slot="table-container" class="relative w-full overflow-x-auto"> <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 /> <slot />
</table> </table>
</div> </div>

View File

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

View File

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

View File

@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<td <td
data-slot="table-cell" 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 /> <slot />
</td> </td>

View File

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

View File

@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<tfoot <tfoot
data-slot="table-footer" 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 /> <slot />
</tfoot> </tfoot>

View File

@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<th <th
data-slot="table-head" 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 /> <slot />
</th> </th>

View File

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

View File

@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class'] class?: HTMLAttributes["class"]
}>() }>()
</script> </script>
<template> <template>
<tr <tr
data-slot="table-row" 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 /> <slot />
</tr> </tr>

View File

@ -1,9 +1,9 @@
export { default as Table } from './Table.vue' export { default as Table } from "./Table.vue"
export { default as TableBody } from './TableBody.vue' export { default as TableBody } from "./TableBody.vue"
export { default as TableCaption } from './TableCaption.vue' export { default as TableCaption } from "./TableCaption.vue"
export { default as TableCell } from './TableCell.vue' export { default as TableCell } from "./TableCell.vue"
export { default as TableEmpty } from './TableEmpty.vue' export { default as TableEmpty } from "./TableEmpty.vue"
export { default as TableFooter } from './TableFooter.vue' export { default as TableFooter } from "./TableFooter.vue"
export { default as TableHead } from './TableHead.vue' export { default as TableHead } from "./TableHead.vue"
export { default as TableHeader } from './TableHeader.vue' export { default as TableHeader } from "./TableHeader.vue"
export { default as TableRow } from './TableRow.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"> <script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui' import type { TooltipRootEmits, TooltipRootProps } from "reka-ui"
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui' import { TooltipRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<TooltipRootProps>() const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>() const emits = defineEmits<TooltipRootEmits>()
@ -9,11 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
</script> </script>
<template> <template>
<TooltipRoot <TooltipRoot v-slot="slotProps" data-slot="tooltip" v-bind="forwarded">
v-slot="slotProps"
data-slot="tooltip"
v-bind="forwarded"
>
<slot v-bind="slotProps" /> <slot v-bind="slotProps" />
</TooltipRoot> </TooltipRoot>
</template> </template>

View File

@ -1,21 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui' import type { TooltipContentEmits, TooltipContentProps } from "reka-ui"
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from "vue"
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from "@vueuse/core"
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui' import {
import { cn } from '@/lib/utils' TooltipArrow,
TooltipContent,
TooltipPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), { const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 0, sideOffset: 0,
}) },
)
const emits = defineEmits<TooltipContentEmits>() const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class') const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
@ -24,11 +32,18 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<TooltipContent <TooltipContent
data-slot="tooltip-content" data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }" 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 /> <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> </TooltipContent>
</TooltipPortal> </TooltipPortal>
</template> </template>

View File

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

View File

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

View File

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

View File

@ -1,13 +1,13 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid"
import type { LineDatum, Point, RectDatum, RectPreset } from "@/types"; import type { LineDatum, Point, RectDatum, RectPreset } from "@/types"
export const RECT_PRESETS: RectPreset[] = [ export const RECT_PRESETS: RectPreset[] = [
{ label: "A3", widthMm: 297, heightMm: 420 }, { label: "A3", widthMm: 297, heightMm: 420 },
{ label: "A4", widthMm: 210, heightMm: 297 }, { label: "A4", widthMm: 210, heightMm: 297 },
{ label: "A5", widthMm: 148, heightMm: 210 }, { label: "A5", widthMm: 148, heightMm: 210 },
{ label: "A6", widthMm: 105, heightMm: 148 }, { 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 = [ const DATUM_COLORS = [
"#3b82f6", // blue "#3b82f6", // blue
@ -18,17 +18,20 @@ const DATUM_COLORS = [
"#ec4899", // pink "#ec4899", // pink
"#06b6d4", // cyan "#06b6d4", // cyan
"#f97316", // orange "#f97316", // orange
]; ]
export function getDatumColor(index: number): string { 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( export function createRectDatum(
center: Point, center: Point,
index: number,
preset?: RectPreset, preset?: RectPreset,
): RectDatum { ): RectDatum {
const spread = 80; const spread = 80
return { return {
id: nanoid(), id: nanoid(),
type: "rectangle", 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 },
{ x: center.x - spread, y: center.y + spread }, { x: center.x - spread, y: center.y + spread },
], ],
widthMm: preset?.widthMm ?? 210, widthMm: preset?.widthMm ?? 0,
heightMm: preset?.heightMm ?? 297, heightMm: preset?.heightMm ?? 0,
confidence: 3, confidence: 3,
label: preset?.label ?? "Rectangle", label: preset?.label ?? `Rectangle ${String(index)}`,
}; }
} }
export function createLineDatum(center: Point): LineDatum { export function createLineDatum(center: Point, index: number): LineDatum {
const spread = 100; const spread = 100
return { return {
id: nanoid(), id: nanoid(),
type: "line", 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 },
{ x: center.x + spread, y: center.y }, { x: center.x + spread, y: center.y },
], ],
lengthMm: 100, lengthMm: 0,
confidence: 3, 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. * Convert our app corner order (TL, TR, BR, BL) to the algorithm's
* * expected order (TL, TR, BL, BR) for getPerspectiveTransform.
* 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
*/ */
export async function deskewImage(input: DeskewInput): Promise<DeskewResult> { function cornersToAlgoOrder(
const canvas = document.createElement("canvas"); corners: [Point, Point, Point, Point],
canvas.width = input.imageData.width; ): [Point, Point, Point, Point] {
canvas.height = input.imageData.height; // App: [TL, TR, BR, BL] → Algo: [TL, TR, BL, BR]
return [corners[0], corners[1], corners[3], corners[2]]
}
const ctx = canvas.getContext("2d"); // ─── Canvas → Blob helper ───────────────────────────────────────────────────
if (!ctx) throw new Error("Cannot get 2D context");
ctx.drawImage(input.imageData, 0, 0); function canvasToBlob(
canvas: HTMLCanvasElement,
const blob = await new Promise<Blob>((resolve, reject) => { type = "image/png",
quality = 0.95,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("Canvas toBlob failed"))), (b) => {
"image/jpeg", if (b) {
0.95, resolve(b)
); } else {
}); reject(new Error("toBlob failed"))
}
},
type,
quality,
)
})
}
const corrections: string[] = []; // ─── Core ────────────────────────────────────────────────────────────────────
if (input.exif.focalLength) { export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
corrections.push( const { image, datums, scalePxPerMm: scale } = input
`Lens: ${input.exif.lensModel ?? "unknown"} @ ${input.exif.focalLength}mm`, 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
} }
corrections.push(`${input.datums.length} datum(s) used for calibration`); if (datum.type === "line") {
corrections.push("Placeholder: no actual correction applied yet"); 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
return { // Axis contribution proportional to alignment
correctedImageBlob: blob, const total = dx + dy
appliedCorrections: corrections, 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 exifr from "exifr"
import type { ExifData } from "@/types"; 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> { export async function extractExif(file: File): Promise<ExifData> {
try { try {
const raw = await exifr.parse(file, { const raw = (await exifr.parse(file, {
tiff: true, tiff: true,
exif: true, exif: true,
gps: 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 { return {
make: raw.Make as string | undefined, make: raw.Make,
model: raw.Model as string | undefined, model: raw.Model,
lensModel: raw.LensModel as string | undefined, lensModel: raw.LensModel,
focalLength: raw.FocalLength as number | undefined, focalLength: raw.FocalLength,
focalLengthIn35mm: raw.FocalLengthIn35mmFormat as number | undefined, focalLengthIn35mm: raw.FocalLengthIn35mmFormat,
orientation: raw.Orientation as number | undefined, orientation: raw.Orientation,
imageWidth: (raw.ImageWidth ?? raw.ExifImageWidth) as number | undefined, imageWidth: raw.ImageWidth ?? raw.ExifImageWidth,
imageHeight: (raw.ImageHeight ?? raw.ExifImageHeight) as imageHeight: raw.ImageHeight ?? raw.ExifImageHeight,
| number exposureTime: raw.ExposureTime,
| undefined, fNumber: raw.FNumber,
exposureTime: raw.ExposureTime as number | undefined, iso: raw.ISO,
fNumber: raw.FNumber as number | undefined,
iso: raw.ISO as number | undefined,
dateTimeOriginal: raw.DateTimeOriginal dateTimeOriginal: raw.DateTimeOriginal
? String(raw.DateTimeOriginal) ? String(raw.DateTimeOriginal)
: undefined, : undefined,
gpsLatitude: raw.latitude as number | undefined, gpsLatitude: raw.latitude,
gpsLongitude: raw.longitude as number | undefined, gpsLongitude: raw.longitude,
}; }
} catch { } catch {
console.warn("EXIF extraction failed"); console.warn("EXIF extraction failed")
return {}; return {}
} }
} }
export function orientationLabel(orientation: number | undefined): string { export function orientationLabel(orientation: number | undefined): string {
switch (orientation) { switch (orientation) {
case 1: case 1:
return "Normal"; return "Normal"
case 2: case 2:
return "Mirrored horizontal"; return "Mirrored horizontal"
case 3: case 3:
return "Rotated 180\u00B0"; return "Rotated 180\u00B0"
case 4: case 4:
return "Mirrored vertical"; return "Mirrored vertical"
case 5: case 5:
return "Mirrored horizontal + rotated 270\u00B0"; return "Mirrored horizontal + rotated 270\u00B0"
case 6: case 6:
return "Rotated 90\u00B0 CW"; return "Rotated 90\u00B0 CW"
case 7: case 7:
return "Mirrored horizontal + rotated 90\u00B0"; return "Mirrored horizontal + rotated 90\u00B0"
case 8: case 8:
return "Rotated 270\u00B0 CW"; return "Rotated 270\u00B0 CW"
default: 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 { function isHeicFile(file: File): boolean {
const ext = file.name.toLowerCase(); const ext = file.name.toLowerCase()
if (ext.endsWith(".heic") || ext.endsWith(".heif")) return true; if (ext.endsWith(".heic") || ext.endsWith(".heif")) return true
return file.type === "image/heic" || file.type === "image/heif"; return file.type === "image/heic" || file.type === "image/heif"
} }
export async function loadImage( export async function loadImage(
file: File, file: File,
onProgress?: (status: string) => void, onProgress?: (status: string) => void,
): Promise<{ image: HTMLImageElement; convertedFile: File }> { ): Promise<{ image: HTMLImageElement; convertedFile: File }> {
let processedFile = file; let processedFile = file
if (isHeicFile(file)) { if (isHeicFile(file)) {
onProgress?.("Checking HEIC format..."); onProgress?.("Checking HEIC format...")
if (await isHeic(file)) { if (await isHeic(file)) {
onProgress?.("Converting HEIC to JPEG..."); onProgress?.("Converting HEIC to JPEG...")
const jpegBlob = await heicTo({ const jpegBlob = await heicTo({
blob: file, blob: file,
type: "image/jpeg", type: "image/jpeg",
quality: 0.92, quality: 0.92,
}); })
processedFile = new File( processedFile = new File(
[jpegBlob], [jpegBlob],
file.name.replace(/\.hei[cf]$/i, ".jpg"), file.name.replace(/\.hei[cf]$/i, ".jpg"),
{ type: "image/jpeg" }, { type: "image/jpeg" },
); )
} }
} }
onProgress?.("Loading image..."); onProgress?.("Loading image...")
const image = await createImageElement(processedFile); const image = await createImageElement(processedFile)
return { image, convertedFile: processedFile }; return { image, convertedFile: processedFile }
} }
function createImageElement(file: File): Promise<HTMLImageElement> { function createImageElement(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { 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 // Keep the object URL alive — the img.src must remain valid for later
// rendering in <img> tags and on the Konva canvas. // rendering in <img> tags and on the Konva canvas.
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file)
img.onload = () => resolve(img); img.onload = () => {
resolve(img)
}
img.onerror = () => { img.onerror = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url)
reject(new Error("Failed to load image")); reject(new Error("Failed to load image"))
}; }
img.src = url; img.src = url
}); })
} }

View File

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

View File

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

View File

@ -1,66 +1,91 @@
export interface Point { export interface Point {
x: number; x: number
y: number; y: number
} }
export interface RectDatum { export interface RectDatum {
id: string; id: string
type: "rectangle"; type: "rectangle"
corners: [Point, Point, Point, Point]; // TL, TR, BR, BL corners: [Point, Point, Point, Point] // TL, TR, BR, BL
widthMm: number; widthMm: number
heightMm: number; heightMm: number
confidence: 1 | 2 | 3 | 4 | 5; confidence: 1 | 2 | 3 | 4 | 5
label: string; label: string
} }
export interface LineDatum { export interface LineDatum {
id: string; id: string
type: "line"; type: "line"
endpoints: [Point, Point]; endpoints: [Point, Point]
lengthMm: number; lengthMm: number
confidence: 1 | 2 | 3 | 4 | 5; confidence: 1 | 2 | 3 | 4 | 5
label: string; 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 { export interface ExifData {
make?: string; make?: string
model?: string; model?: string
lensModel?: string; lensModel?: string
focalLength?: number; focalLength?: number
focalLengthIn35mm?: number; focalLengthIn35mm?: number
orientation?: number; orientation?: number
imageWidth?: number; imageWidth?: number
imageHeight?: number; imageHeight?: number
exposureTime?: number; exposureTime?: number
fNumber?: number; fNumber?: number
iso?: number; iso?: number
dateTimeOriginal?: string; dateTimeOriginal?: string
gpsLatitude?: number; gpsLatitude?: number
gpsLongitude?: number; gpsLongitude?: number
} }
export interface DeskewInput { export interface DeskewInput {
imageData: HTMLCanvasElement; image: HTMLImageElement | HTMLCanvasElement
datums: Datum[]; datums: Datum[]
exif: ExifData; 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 { export interface DeskewResult {
correctedImageBlob: Blob; correctedImageBlob: Blob
appliedCorrections: string[]; 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. */ /** Pixels per mm in the output image. Default 10 (= 100 px/cm). */
export const DEFAULT_SCALE_PX_PER_CM = 50; export const DEFAULT_SCALE_PX_PER_MM = 10
export interface RectPreset { export interface RectPreset {
label: string; label: string
widthMm: number; widthMm: number
heightMm: number; heightMm: number
} }