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:
parent
2d56c5dada
commit
4069491c2f
16
.prettierrc
16
.prettierrc
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"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
2
.serena/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
143
eslint.config.ts
143
eslint.config.ts
@ -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"] },
|
||||||
ignores: ["dist/**", "node_modules/**"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["**/*.ts"],
|
|
||||||
languageOptions: {
|
|
||||||
parser: tsParser,
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
"@typescript-eslint": tsPlugin as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...tsPlugin.configs?.["recommended"]?.rules,
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
|
||||||
"error",
|
|
||||||
{ argsIgnorePattern: "^_" },
|
|
||||||
],
|
|
||||||
"@typescript-eslint/explicit-function-return-type": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["**/*.vue"],
|
|
||||||
languageOptions: {
|
|
||||||
parser: vueParser,
|
|
||||||
parserOptions: {
|
|
||||||
parser: tsParser,
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
extraFileExtensions: [".vue"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
vue: pluginVue as unknown as Record<string, unknown>,
|
|
||||||
"@typescript-eslint": tsPlugin as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...pluginVue.configs?.["flat/recommended"]?.reduce(
|
|
||||||
(acc: Record<string, unknown>, cfg: Linter.Config) => ({
|
|
||||||
...acc,
|
|
||||||
...cfg.rules,
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
"vue/multi-word-component-names": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prettierConfig as Linter.Config,
|
|
||||||
];
|
|
||||||
|
|
||||||
export default config;
|
// TypeScript strict type-checked rules for .ts and .vue files
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,vue}"],
|
||||||
|
extends: [...tseslint.configs.strictTypeChecked],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vue strongly recommended
|
||||||
|
...(pluginVue.configs["flat/strongly-recommended"] as Linter.Config[]),
|
||||||
|
|
||||||
|
// Vue parser + additional strict rules
|
||||||
|
{
|
||||||
|
files: ["**/*.vue"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: vueParser,
|
||||||
|
parserOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
extraFileExtensions: [".vue"],
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"vue/block-order": [
|
||||||
|
"error",
|
||||||
|
{ order: ["script", "template", "style"] },
|
||||||
|
],
|
||||||
|
"vue/component-api-style": ["error", ["script-setup"]],
|
||||||
|
"vue/define-macros-order": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
order: [
|
||||||
|
"defineProps",
|
||||||
|
"defineEmits",
|
||||||
|
"defineSlots",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"vue/no-empty-component-block": "error",
|
||||||
|
"vue/no-ref-object-reactivity-loss": "error",
|
||||||
|
"vue/no-unused-refs": "error",
|
||||||
|
"vue/no-useless-mustaches": "error",
|
||||||
|
"vue/no-useless-v-bind": "error",
|
||||||
|
"vue/prefer-separate-static-class": "error",
|
||||||
|
"vue/prefer-true-attribute-shorthand": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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
7
knip.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { KnipConfig } from "knip"
|
||||||
|
|
||||||
|
const config: KnipConfig = {
|
||||||
|
ignore: ["src/components/ui/**"],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
14
package.json
14
package.json
@ -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
587
pnpm-lock.yaml
generated
@ -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: {}
|
||||||
|
|||||||
56
src/App.vue
56
src/App.vue
@ -1,34 +1,36 @@
|
|||||||
<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>
|
||||||
<div class="min-h-screen bg-background text-foreground">
|
<div class="min-h-screen bg-background text-foreground">
|
||||||
<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
|
||||||
<h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
|
class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4"
|
||||||
<div class="flex items-center gap-4">
|
>
|
||||||
<StepIndicator />
|
<h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
|
||||||
<ThemeToggle />
|
<div class="flex items-center gap-4">
|
||||||
</div>
|
<StepIndicator />
|
||||||
</div>
|
<ThemeToggle />
|
||||||
</header>
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||||
<ImageUpload v-if="store.currentStep === 1" />
|
<ImageUpload v-if="store.currentStep === 1" />
|
||||||
<ExifViewer v-else-if="store.currentStep === 2" />
|
<ExifViewer v-else-if="store.currentStep === 2" />
|
||||||
<DatumEditor v-else-if="store.currentStep === 3" />
|
<DatumEditor v-else-if="store.currentStep === 3" />
|
||||||
<ResultViewer v-else-if="store.currentStep === 4" />
|
<ResultViewer v-else-if="store.currentStep === 4" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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);
|
||||||
@ -119,11 +118,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,321 +1,351 @@
|
|||||||
<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,
|
||||||
height: stageHeight.value,
|
height: stageHeight.value,
|
||||||
scaleX: scale.value,
|
scaleX: scale.value,
|
||||||
scaleY: scale.value,
|
scaleY: scale.value,
|
||||||
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,
|
||||||
y: pt.y,
|
y: pt.y,
|
||||||
radius: radius / scale.value,
|
radius: radius / scale.value,
|
||||||
fill: color,
|
fill: color,
|
||||||
stroke: isSelected ? "#fff" : color,
|
stroke: isSelected ? "#fff" : color,
|
||||||
strokeWidth: 2 / scale.value,
|
strokeWidth: 2 / scale.value,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
_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 [
|
||||||
|
{
|
||||||
|
points: [
|
||||||
|
datum.endpoints[0].x,
|
||||||
|
datum.endpoints[0].y,
|
||||||
|
datum.endpoints[1].x,
|
||||||
|
datum.endpoints[1].y,
|
||||||
|
],
|
||||||
|
stroke: color,
|
||||||
|
strokeWidth: (isSelected ? 3 : 2) / scale.value,
|
||||||
|
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rectangle: draw 4 edges
|
||||||
|
const c = datum.corners
|
||||||
|
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y])
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
points: [
|
points: pts,
|
||||||
datum.endpoints[0].x,
|
stroke: color,
|
||||||
datum.endpoints[0].y,
|
strokeWidth: (isSelected ? 3 : 2) / scale.value,
|
||||||
datum.endpoints[1].x,
|
closed: true,
|
||||||
datum.endpoints[1].y,
|
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
|
||||||
],
|
},
|
||||||
stroke: color,
|
]
|
||||||
strokeWidth: (isSelected ? 3 : 2) / scale.value,
|
|
||||||
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rectangle: draw 4 edges
|
|
||||||
const c = datum.corners;
|
|
||||||
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y]);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
points: pts,
|
|
||||||
stroke: color,
|
|
||||||
strokeWidth: (isSelected ? 3 : 2) / scale.value,
|
|
||||||
closed: true,
|
|
||||||
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 {
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
y: pos.y,
|
y: pos.y,
|
||||||
text: datum.label,
|
text: datum.label,
|
||||||
fontSize: 14 / scale.value,
|
fontSize: 14 / scale.value,
|
||||||
fill: color,
|
fill: color,
|
||||||
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]
|
||||||
} else if (e.touches.length === 1) {
|
const t1 = e.touches[1]
|
||||||
// Single-finger pan (only if not on a point)
|
if (t0 && t1) {
|
||||||
const target = e.target as HTMLElement;
|
lastPinchDist = getTouchDistance(t0, t1)
|
||||||
if (!target.closest(".konvajs-content")) return;
|
}
|
||||||
isPanning = true;
|
} else if (e.touches.length === 1) {
|
||||||
panStart = { x: e.touches[0]!.clientX - offsetX.value, y: e.touches[0]!.clientY - offsetY.value };
|
// Single-finger pan (only if not on a point)
|
||||||
}
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest(".konvajs-content")) return
|
||||||
|
const t0 = e.touches[0]
|
||||||
|
if (!t0) return
|
||||||
|
isPanning = true
|
||||||
|
panStart = {
|
||||||
|
x: t0.clientX - offsetX.value,
|
||||||
|
y: t0.clientY - offsetY.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchMove(e: TouchEvent) {
|
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>
|
||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="h-full w-full touch-none overflow-hidden rounded-lg border border-border bg-muted"
|
class="h-full w-full touch-none overflow-hidden rounded-lg border border-border bg-muted"
|
||||||
@wheel.prevent="onWheel"
|
@wheel.prevent="onWheel"
|
||||||
@touchstart="onTouchStart"
|
@touchstart="onTouchStart"
|
||||||
@touchmove="onTouchMove"
|
@touchmove="onTouchMove"
|
||||||
@touchend="onTouchEnd"
|
@touchend="onTouchEnd"
|
||||||
>
|
>
|
||||||
<v-stage :config="stageConfig">
|
<v-stage :config="stageConfig">
|
||||||
<v-layer>
|
<v-layer>
|
||||||
<!-- Background image -->
|
<!-- Background image -->
|
||||||
<v-image v-if="imageConfig" :config="imageConfig" />
|
<v-image v-if="imageConfig" :config="imageConfig" />
|
||||||
|
|
||||||
<!-- Datum shapes -->
|
<!-- Datum shapes -->
|
||||||
<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(
|
||||||
:key="`${datum.id}-line-${li}`"
|
datum,
|
||||||
:config="lineCfg"
|
datumIndex(datum),
|
||||||
/>
|
)"
|
||||||
|
:key="`${datum.id}-line-${li}`"
|
||||||
|
: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(
|
||||||
:key="`${datum.id}-pt-${ptCfg._pointIndex}`"
|
datum,
|
||||||
:config="ptCfg"
|
datumIndex(datum),
|
||||||
@dragmove="onPointDragMove"
|
)"
|
||||||
@click="onPointClick(datum.id)"
|
:key="`${datum.id}-pt-${ptCfg._pointIndex}`"
|
||||||
@tap="onPointClick(datum.id)"
|
:config="ptCfg"
|
||||||
/>
|
@dragmove="onPointDragMove"
|
||||||
</template>
|
@click="onPointClick(datum.id)"
|
||||||
</v-layer>
|
@tap="onPointClick(datum.id)"
|
||||||
</v-stage>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</v-layer>
|
||||||
|
</v-stage>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,87 +1,128 @@
|
|||||||
<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 {
|
import {
|
||||||
Sheet,
|
Tooltip,
|
||||||
SheetContent,
|
TooltipContent,
|
||||||
SheetHeader,
|
TooltipProvider,
|
||||||
SheetTitle,
|
TooltipTrigger,
|
||||||
SheetTrigger,
|
} from "@/components/ui/tooltip"
|
||||||
} from "@/components/ui/sheet";
|
import {
|
||||||
import DatumCanvas from "@/components/DatumCanvas.vue";
|
Sheet,
|
||||||
import DatumPanel from "@/components/DatumPanel.vue";
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import DatumCanvas from "@/components/DatumCanvas.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>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<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
|
||||||
</p>
|
dimensions.
|
||||||
</div>
|
</p>
|
||||||
<div class="flex shrink-0 gap-2">
|
</div>
|
||||||
<Button variant="outline" size="sm" @click="store.goToStep(2)">Back</Button>
|
<div class="flex shrink-0 gap-2">
|
||||||
<Button size="sm" :disabled="!store.canProceedToStep4" @click="store.goToStep(4)">
|
<Button variant="outline" size="sm" @click="store.goToStep(2)">
|
||||||
Next
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<TooltipProvider>
|
||||||
</div>
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
<!-- Single layout: canvas always present, sidebar conditionally placed -->
|
<span class="inline-flex">
|
||||||
<div
|
<Button
|
||||||
class="grid gap-4"
|
size="sm"
|
||||||
:class="isMobile ? 'grid-cols-1' : 'grid-cols-[1fr_360px]'"
|
:disabled="!store.canProceedToStep4"
|
||||||
:style="{ height: isMobile ? undefined : 'calc(100vh - 12rem)' }"
|
@click="store.goToStep(4)"
|
||||||
>
|
>
|
||||||
<div :class="canvasHeight">
|
Next
|
||||||
<DatumCanvas />
|
</Button>
|
||||||
</div>
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
<!-- Desktop: inline sidebar -->
|
<TooltipContent v-if="nextTooltip" side="bottom">
|
||||||
<DatumPanel v-if="!isMobile" />
|
{{ nextTooltip }}
|
||||||
</div>
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<!-- Mobile: bottom sheet for datums -->
|
</TooltipProvider>
|
||||||
<Sheet v-if="isMobile" v-model:open="sheetOpen">
|
</div>
|
||||||
<SheetTrigger as-child>
|
|
||||||
<Button variant="outline" class="w-full">
|
|
||||||
Datums ({{ store.datums.length }})
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="ml-2"
|
|
||||||
>
|
|
||||||
<path d="m18 15-6-6-6 6" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="bottom" class="h-[75vh] overflow-hidden rounded-t-xl">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>Datums</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<div class="h-[calc(75vh-4rem)] overflow-y-auto">
|
|
||||||
<DatumPanel />
|
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
<!-- Single layout: canvas always present, sidebar conditionally placed -->
|
||||||
</div>
|
<div
|
||||||
|
class="grid gap-4"
|
||||||
|
:class="isMobile ? 'grid-cols-1' : 'grid-cols-[1fr_360px]'"
|
||||||
|
:style="{ height: isMobile ? undefined : 'calc(100vh - 12rem)' }"
|
||||||
|
>
|
||||||
|
<div :class="canvasHeight">
|
||||||
|
<DatumCanvas />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop: inline sidebar -->
|
||||||
|
<DatumPanel v-if="!isMobile" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: bottom sheet for datums -->
|
||||||
|
<Sheet v-if="isMobile" v-model:open="sheetOpen">
|
||||||
|
<SheetTrigger as-child>
|
||||||
|
<Button variant="outline" class="w-full">
|
||||||
|
Datums ({{ store.datums.length }})
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="ml-2"
|
||||||
|
>
|
||||||
|
<path d="m18 15-6-6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
class="h-[75vh] overflow-hidden rounded-t-xl"
|
||||||
|
>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Datums</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div class="h-[calc(75vh-4rem)] overflow-y-auto">
|
||||||
|
<DatumPanel />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,227 +1,275 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-4 overflow-y-auto p-4">
|
<div class="flex h-full flex-col gap-4 overflow-y-auto p-4">
|
||||||
<!-- Add datum controls -->
|
<!-- Add datum controls -->
|
||||||
<Card class="shrink-0">
|
<Card class="shrink-0">
|
||||||
<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 }}×{{ preset.heightMm }} mm)
|
</Button>
|
||||||
</SelectItem>
|
<Button
|
||||||
<SelectItem value="custom">Custom rectangle</SelectItem>
|
variant="outline"
|
||||||
</SelectContent>
|
size="sm"
|
||||||
</Select>
|
class="w-full"
|
||||||
</div>
|
@click="addLine"
|
||||||
<Button variant="outline" class="w-full" size="sm" @click="addLine">
|
>
|
||||||
+ Add Line
|
+ Line
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<Button
|
||||||
|
v-for="preset in RECT_PRESETS"
|
||||||
|
:key="preset.label"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
@click="addRect(preset.label)"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<!-- 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
|
||||||
Datums ({{ store.datums.length }})
|
class="text-xs font-medium text-muted-foreground uppercase tracking-wider"
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="store.datums.length === 0" class="text-sm text-muted-foreground">
|
|
||||||
No datums added yet. Use the controls above.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
v-for="(datum, idx) in store.datums"
|
|
||||||
:key="datum.id"
|
|
||||||
class="cursor-pointer transition-colors"
|
|
||||||
:class="
|
|
||||||
store.selectedDatumId === datum.id
|
|
||||||
? 'ring-2 ring-primary'
|
|
||||||
: 'hover:bg-accent/50'
|
|
||||||
"
|
|
||||||
@click="store.selectedDatumId = datum.id"
|
|
||||||
>
|
|
||||||
<CardContent class="space-y-3 pt-4">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="h-3 w-3 rounded-full"
|
|
||||||
:style="{ backgroundColor: getDatumColor(idx) }"
|
|
||||||
/>
|
|
||||||
<Badge variant="outline" class="text-xs">
|
|
||||||
{{ datum.type === "rectangle" ? "Rect" : "Line" }}
|
|
||||||
</Badge>
|
|
||||||
<span class="text-xs text-muted-foreground">{{ formatDimensions(datum) }}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-6 w-6 text-destructive"
|
|
||||||
@click.stop="store.removeDatum(datum.id)"
|
|
||||||
>
|
>
|
||||||
<svg
|
Datums ({{ store.datums.length }})
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</p>
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3 6h18" />
|
|
||||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
|
||||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Label -->
|
<p
|
||||||
<div>
|
v-if="store.datums.length === 0"
|
||||||
<Label class="text-xs">Label</Label>
|
class="text-sm text-muted-foreground"
|
||||||
<Input
|
>
|
||||||
:model-value="datum.label"
|
No datums added yet. Use the controls above.
|
||||||
class="mt-1 h-8 text-sm"
|
</p>
|
||||||
@update:model-value="(v: string | number) => updateField(datum, 'label', String(v))"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dimensions -->
|
<Card
|
||||||
<div v-if="datum.type === 'rectangle'" class="grid grid-cols-2 gap-2">
|
v-for="(datum, idx) in store.datums"
|
||||||
<div>
|
:key="datum.id"
|
||||||
<Label class="text-xs">Width (mm)</Label>
|
class="cursor-pointer transition-colors"
|
||||||
<Input
|
:class="
|
||||||
:model-value="String((datum as RectDatum).widthMm)"
|
store.selectedDatumId === datum.id
|
||||||
type="number"
|
? 'ring-2 ring-primary'
|
||||||
min="1"
|
: 'hover:bg-accent/50'
|
||||||
class="mt-1 h-8 text-sm"
|
"
|
||||||
@update:model-value="(v: string | number) => updateField(datum, 'widthMm', Number(v))"
|
@click="store.selectedDatumId = datum.id"
|
||||||
@click.stop
|
>
|
||||||
/>
|
<CardContent class="space-y-3 pt-4">
|
||||||
</div>
|
<!-- Header -->
|
||||||
<div>
|
<div class="flex items-center justify-between">
|
||||||
<Label class="text-xs">Height (mm)</Label>
|
<div class="flex items-center gap-2">
|
||||||
<Input
|
<div
|
||||||
:model-value="String((datum as RectDatum).heightMm)"
|
class="h-3 w-3 rounded-full"
|
||||||
type="number"
|
:style="{ backgroundColor: getDatumColor(idx) }"
|
||||||
min="1"
|
/>
|
||||||
class="mt-1 h-8 text-sm"
|
<Badge variant="outline" class="text-xs">
|
||||||
@update:model-value="(v: string | number) => updateField(datum, 'heightMm', Number(v))"
|
{{
|
||||||
@click.stop
|
datum.type === "rectangle" ? "Rect" : "Line"
|
||||||
/>
|
}}
|
||||||
</div>
|
</Badge>
|
||||||
</div>
|
<span class="text-xs text-muted-foreground">{{
|
||||||
<div v-else>
|
formatDimensions(datum)
|
||||||
<Label class="text-xs">Length (mm)</Label>
|
}}</span>
|
||||||
<Input
|
</div>
|
||||||
:model-value="String(datum.lengthMm)"
|
<Button
|
||||||
type="number"
|
variant="ghost"
|
||||||
min="1"
|
size="icon"
|
||||||
class="mt-1 h-8 text-sm"
|
class="h-6 w-6 text-destructive"
|
||||||
@update:model-value="(v: string | number) => updateField(datum, 'lengthMm', Number(v))"
|
@click.stop="store.removeDatum(datum.id)"
|
||||||
@click.stop
|
>
|
||||||
/>
|
<svg
|
||||||
</div>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path
|
||||||
|
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
||||||
|
/>
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Confidence -->
|
<!-- Label -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<Label class="text-xs">Label</Label>
|
||||||
<Label class="text-xs">Confidence</Label>
|
<Input
|
||||||
<span class="text-xs font-medium text-muted-foreground">
|
:model-value="datum.label"
|
||||||
{{ datum.confidence }} / 5
|
class="mt-1 h-8 text-sm"
|
||||||
</span>
|
@update:model-value="
|
||||||
</div>
|
(v: string | number) =>
|
||||||
<Slider
|
updateField(datum, 'label', String(v))
|
||||||
:model-value="[datum.confidence]"
|
"
|
||||||
:min="1"
|
@click.stop
|
||||||
:max="5"
|
/>
|
||||||
:step="1"
|
</div>
|
||||||
class="mt-2"
|
|
||||||
@update:model-value="(v: number[] | undefined) => updateConfidence(datum, v)"
|
<!-- Dimensions -->
|
||||||
@click.stop
|
<div
|
||||||
/>
|
v-if="datum.type === 'rectangle'"
|
||||||
</div>
|
class="grid grid-cols-2 gap-2"
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<div>
|
||||||
|
<Label class="text-xs">Width (mm)</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="
|
||||||
|
String((datum as RectDatum).widthMm)
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="mt-1 h-8 text-sm"
|
||||||
|
@update:model-value="
|
||||||
|
(v: string | number) =>
|
||||||
|
updateField(datum, 'widthMm', Number(v))
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-xs">Height (mm)</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="
|
||||||
|
String((datum as RectDatum).heightMm)
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="mt-1 h-8 text-sm"
|
||||||
|
@update:model-value="
|
||||||
|
(v: string | number) =>
|
||||||
|
updateField(
|
||||||
|
datum,
|
||||||
|
'heightMm',
|
||||||
|
Number(v),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<Label class="text-xs">Length (mm)</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="String(datum.lengthMm)"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="mt-1 h-8 text-sm"
|
||||||
|
@update:model-value="
|
||||||
|
(v: string | number) =>
|
||||||
|
updateField(datum, 'lengthMm', Number(v))
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confidence -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label class="text-xs">Confidence</Label>
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ datum.confidence }} / 5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
:model-value="[datum.confidence]"
|
||||||
|
:min="1"
|
||||||
|
:max="5"
|
||||||
|
:step="1"
|
||||||
|
class="mt-2"
|
||||||
|
@update:model-value="
|
||||||
|
(v: number[] | undefined) =>
|
||||||
|
updateConfidence(datum, v)
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,184 +1,209 @@
|
|||||||
<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,
|
||||||
TableCell,
|
TableCell,
|
||||||
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",
|
},
|
||||||
value: e.orientation ? orientationLabel(e.orientation) : undefined,
|
{
|
||||||
},
|
label: "Orientation",
|
||||||
{
|
value: e.orientation ? orientationLabel(e.orientation) : undefined,
|
||||||
label: "Image Size",
|
},
|
||||||
value:
|
{
|
||||||
e.imageWidth && e.imageHeight
|
label: "Image Size",
|
||||||
? `${e.imageWidth} \u00D7 ${e.imageHeight}`
|
value:
|
||||||
: undefined,
|
e.imageWidth && e.imageHeight
|
||||||
},
|
? `${String(e.imageWidth)} \u00D7 ${String(e.imageHeight)}`
|
||||||
{
|
: undefined,
|
||||||
label: "Aperture",
|
},
|
||||||
value: e.fNumber ? `f/${e.fNumber}` : undefined,
|
{
|
||||||
},
|
label: "Aperture",
|
||||||
{
|
value: e.fNumber ? `f/${String(e.fNumber)}` : undefined,
|
||||||
label: "ISO",
|
},
|
||||||
value: e.iso ? String(e.iso) : undefined,
|
{
|
||||||
},
|
label: "ISO",
|
||||||
{
|
value: e.iso ? String(e.iso) : undefined,
|
||||||
label: "Exposure",
|
},
|
||||||
value: e.exposureTime
|
{
|
||||||
? e.exposureTime < 1
|
label: "Exposure",
|
||||||
? `1/${Math.round(1 / e.exposureTime)}s`
|
value: e.exposureTime
|
||||||
: `${e.exposureTime}s`
|
? e.exposureTime < 1
|
||||||
: undefined,
|
? `1/${String(Math.round(1 / e.exposureTime))}s`
|
||||||
},
|
: `${String(e.exposureTime)}s`
|
||||||
{ label: "Date Taken", value: e.dateTimeOriginal },
|
: undefined,
|
||||||
{
|
},
|
||||||
label: "GPS",
|
{ label: "Date Taken", value: e.dateTimeOriginal },
|
||||||
value:
|
{
|
||||||
e.gpsLatitude != null && e.gpsLongitude != null
|
label: "GPS",
|
||||||
? `${e.gpsLatitude.toFixed(5)}, ${e.gpsLongitude.toFixed(5)}`
|
value:
|
||||||
: undefined,
|
e.gpsLatitude != null && e.gpsLongitude != null
|
||||||
},
|
? `${e.gpsLatitude.toFixed(5)}, ${e.gpsLongitude.toFixed(5)}`
|
||||||
].filter((r) => r.value != null);
|
: undefined,
|
||||||
|
},
|
||||||
|
].filter((r) => r.value != null)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-3xl space-y-6">
|
<div class="mx-auto max-w-3xl space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold">Image & EXIF Data</h2>
|
<h2 class="text-xl font-semibold">Image & EXIF Data</h2>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Review camera and lens information extracted from the image.
|
Review camera and lens information extracted from the image.
|
||||||
</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)"
|
||||||
<Button @click="store.goToStep(3)">Next: Add Datums</Button>
|
>Back</Button
|
||||||
</div>
|
>
|
||||||
</div>
|
<Button @click="store.goToStep(3)">Next: Add Datums</Button>
|
||||||
|
</div>
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
|
||||||
<!-- Image preview -->
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-base">Preview</CardTitle>
|
|
||||||
<CardDescription>{{ store.originalFile?.name }}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="flex items-center justify-center overflow-hidden rounded-md bg-muted">
|
|
||||||
<img
|
|
||||||
v-if="store.loadedImage"
|
|
||||||
:src="store.loadedImage.src"
|
|
||||||
alt="Uploaded image preview"
|
|
||||||
class="max-h-[400px] w-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- EXIF table -->
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-base">EXIF Metadata</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
<template v-if="getExifRows().length > 0">
|
|
||||||
Extracted from the image file
|
|
||||||
</template>
|
|
||||||
<template v-else> No EXIF data found in this image. </template>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table v-if="getExifRows().length > 0">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Property</TableHead>
|
|
||||||
<TableHead>Value</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-for="row in getExifRows()" :key="row.label">
|
|
||||||
<TableCell class="font-medium">{{ row.label }}</TableCell>
|
|
||||||
<TableCell>{{ row.value }}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info card about lens correction -->
|
|
||||||
<Card>
|
|
||||||
<CardContent class="pt-6">
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="mt-0.5 shrink-0 text-primary"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M12 16v-4" />
|
|
||||||
<path d="M12 8h.01" />
|
|
||||||
</svg>
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<p class="font-medium text-foreground">Lens Correction Info</p>
|
|
||||||
<p class="mt-1">
|
|
||||||
<template v-if="store.exifData.focalLength">
|
|
||||||
This image was shot at <strong>{{ store.exifData.focalLength }}mm</strong>
|
|
||||||
<template v-if="store.exifData.lensModel">
|
|
||||||
with a <strong>{{ store.exifData.lensModel }}</strong> </template
|
|
||||||
>. The deskew algorithm can use this to correct barrel/pincushion distortion.
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
No focal length data found. The algorithm will rely solely on datum
|
|
||||||
measurements for perspective correction.
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
</div>
|
<!-- Image preview -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Preview</CardTitle>
|
||||||
|
<CardDescription>{{
|
||||||
|
store.originalFile?.name
|
||||||
|
}}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="store.loadedImage"
|
||||||
|
:src="store.loadedImage.src"
|
||||||
|
alt="Uploaded image preview"
|
||||||
|
class="max-h-[400px] w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- EXIF table -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">EXIF Metadata</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<template v-if="getExifRows().length > 0">
|
||||||
|
Extracted from the image file
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
No EXIF data found in this image.
|
||||||
|
</template>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table v-if="getExifRows().length > 0">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Property</TableHead>
|
||||||
|
<TableHead>Value</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="row in getExifRows()"
|
||||||
|
:key="row.label"
|
||||||
|
>
|
||||||
|
<TableCell class="font-medium">{{
|
||||||
|
row.label
|
||||||
|
}}</TableCell>
|
||||||
|
<TableCell>{{ row.value }}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info card about lens correction -->
|
||||||
|
<Card>
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mt-0.5 shrink-0 text-primary"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4" />
|
||||||
|
<path d="M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
<p class="font-medium text-foreground">
|
||||||
|
Lens Correction Info
|
||||||
|
</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
<template v-if="store.exifData.focalLength">
|
||||||
|
This image was shot at
|
||||||
|
<strong
|
||||||
|
>{{ store.exifData.focalLength }}mm</strong
|
||||||
|
>
|
||||||
|
<template v-if="store.exifData.lensModel">
|
||||||
|
with a
|
||||||
|
<strong>{{
|
||||||
|
store.exifData.lensModel
|
||||||
|
}}</strong> </template
|
||||||
|
>. The deskew algorithm can use this to correct
|
||||||
|
barrel/pincushion distortion.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
No focal length data found. The algorithm will
|
||||||
|
rely solely on datum measurements for
|
||||||
|
perspective correction.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,127 +1,138 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-[60vh] items-center justify-center">
|
<div class="flex min-h-[60vh] items-center justify-center">
|
||||||
<Card class="w-full max-w-lg">
|
<Card class="w-full max-w-lg">
|
||||||
<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>
|
||||||
<div
|
<div
|
||||||
class="relative flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors"
|
class="relative flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors"
|
||||||
:class="
|
:class="
|
||||||
isDragging
|
isDragging
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-muted-foreground/25 hover:border-primary/50'
|
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||||
"
|
"
|
||||||
@dragover.prevent="isDragging = true"
|
@dragover.prevent="isDragging = true"
|
||||||
@dragleave.prevent="isDragging = false"
|
@dragleave.prevent="isDragging = false"
|
||||||
@drop.prevent="onDrop"
|
@drop.prevent="onDrop"
|
||||||
@click="fileInput?.click()"
|
@click="fileInput?.click()"
|
||||||
>
|
>
|
||||||
<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">
|
||||||
</div>
|
{{ store.processingStatus }}
|
||||||
</template>
|
</p>
|
||||||
<template v-else>
|
</div>
|
||||||
<svg
|
</template>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<template v-else>
|
||||||
width="48"
|
<svg
|
||||||
height="48"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
width="48"
|
||||||
fill="none"
|
height="48"
|
||||||
stroke="currentColor"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="1.5"
|
fill="none"
|
||||||
stroke-linecap="round"
|
stroke="currentColor"
|
||||||
stroke-linejoin="round"
|
stroke-width="1.5"
|
||||||
class="mb-3 text-muted-foreground"
|
stroke-linecap="round"
|
||||||
>
|
stroke-linejoin="round"
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
class="mb-3 text-muted-foreground"
|
||||||
<polyline points="17 8 12 3 7 8" />
|
>
|
||||||
<line x1="12" x2="12" y1="3" y2="15" />
|
<path
|
||||||
</svg>
|
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
|
||||||
<p class="text-sm text-muted-foreground">
|
/>
|
||||||
Drag & drop or
|
<polyline points="17 8 12 3 7 8" />
|
||||||
<span class="font-medium text-primary underline">browse</span>
|
<line x1="12" x2="12" y1="3" y2="15" />
|
||||||
</p>
|
</svg>
|
||||||
<p class="mt-1 text-xs text-muted-foreground/70">JPG, JPEG, HEIC, HEIF</p>
|
<p class="text-sm text-muted-foreground">
|
||||||
</template>
|
Drag & drop or
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
:accept="ACCEPTED"
|
:accept="ACCEPTED"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@change="onFileSelect"
|
@change="onFileSelect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="mt-3 text-center text-sm text-destructive">
|
<p
|
||||||
{{ error }}
|
v-if="error"
|
||||||
</p>
|
class="mt-3 text-center text-sm text-destructive"
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
{{ error }}
|
||||||
</div>
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,188 +1,371 @@
|
|||||||
<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;
|
|
||||||
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({
|
store.processingStatus = "Running perspective correction..."
|
||||||
imageData: canvas,
|
|
||||||
datums: store.datums,
|
|
||||||
exif: store.exifData,
|
|
||||||
});
|
|
||||||
|
|
||||||
store.setResult(result);
|
const result = await deskewImage({
|
||||||
resultUrl.value = URL.createObjectURL(result.correctedImageBlob);
|
image: store.loadedImage,
|
||||||
} catch (e) {
|
datums: store.datums,
|
||||||
error.value = e instanceof Error ? e.message : "Deskew failed";
|
exif: store.exifData,
|
||||||
} finally {
|
scalePxPerMm: store.scalePxPerMm,
|
||||||
store.isProcessing = false;
|
})
|
||||||
store.processingStatus = "";
|
|
||||||
}
|
store.setResult(result)
|
||||||
|
|
||||||
|
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
|
||||||
|
resultUrl.value = URL.createObjectURL(result.correctedImageBlob)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Deskew failed"
|
||||||
|
} finally {
|
||||||
|
store.isProcessing = false
|
||||||
|
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>
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold">Process & Download</h2>
|
<h2 class="text-xl font-semibold">Process & 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
|
||||||
</p>
|
download.
|
||||||
</div>
|
</p>
|
||||||
<Button variant="outline" @click="store.goToStep(3)">Back</Button>
|
</div>
|
||||||
</div>
|
<Button variant="outline" @click="store.goToStep(3)">Back</Button>
|
||||||
|
|
||||||
<!-- Scale setting (between step 3 and running the algo) -->
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-base">Image Scale</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
How many pixels represent 1 cm in the original image. This helps the algorithm
|
|
||||||
interpret your datum measurements.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Label>Scale</Label>
|
|
||||||
<Input
|
|
||||||
:model-value="String(store.scalePxPerCm)"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
class="w-28"
|
|
||||||
@update:model-value="(v: string | number) => (store.scalePxPerCm = Number(v) || 50)"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-muted-foreground">px / cm</span>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Summary of datums -->
|
<!-- Scale setting -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-base">Datum Summary</CardTitle>
|
<CardTitle class="text-base">Output Scale</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{{ store.datums.length }} datum(s) will be used for calibration.
|
Pixels per millimeter in the corrected output image. Higher
|
||||||
</CardDescription>
|
= larger output.
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div class="flex flex-wrap gap-2">
|
<CardContent>
|
||||||
<Badge v-for="datum in store.datums" :key="datum.id" variant="outline">
|
<div class="flex items-center gap-3">
|
||||||
{{ datum.label }}
|
<Label>Scale</Label>
|
||||||
({{ datum.type === "rectangle" ? `${datum.widthMm}\u00D7${datum.heightMm}mm` : `${datum.lengthMm}mm` }})
|
<Input
|
||||||
— confidence {{ datum.confidence }}/5
|
:model-value="String(store.scalePxPerMm)"
|
||||||
</Badge>
|
type="number"
|
||||||
</div>
|
min="1"
|
||||||
</CardContent>
|
class="w-28"
|
||||||
</Card>
|
@update:model-value="
|
||||||
|
(v: string | number) =>
|
||||||
|
(store.scalePxPerMm = Number(v) || 10)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">px / mm</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- Run button -->
|
<!-- Summary of datums -->
|
||||||
<div class="flex justify-center">
|
<Card>
|
||||||
<Button size="lg" :disabled="store.isProcessing" @click="runDeskew">
|
<CardHeader>
|
||||||
<template v-if="store.isProcessing">
|
<CardTitle class="text-base">Datum Summary</CardTitle>
|
||||||
Processing...
|
<CardDescription>
|
||||||
</template>
|
{{ store.datums.length }} datum(s) will be used for
|
||||||
<template v-else>
|
calibration.
|
||||||
{{ hasRun ? "Re-run Deskew" : "Run Deskew Algorithm" }}
|
</CardDescription>
|
||||||
</template>
|
</CardHeader>
|
||||||
</Button>
|
<CardContent>
|
||||||
</div>
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="datum in store.datums"
|
||||||
|
:key="datum.id"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ datum.label }}
|
||||||
|
({{
|
||||||
|
datum.type === "rectangle"
|
||||||
|
? `${datum.widthMm}\u00D7${datum.heightMm}mm`
|
||||||
|
: `${datum.lengthMm}mm`
|
||||||
|
}}) — confidence {{ datum.confidence }}/5
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p v-if="!hasRects()" class="mt-3 text-sm text-destructive">
|
||||||
|
At least one rectangle datum is required for perspective
|
||||||
|
correction.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<p v-if="error" class="text-center text-sm text-destructive">{{ error }}</p>
|
<!-- Run button -->
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
<!-- Result -->
|
<Button
|
||||||
<template v-if="store.deskewResult">
|
size="lg"
|
||||||
<Card>
|
:disabled="store.isProcessing || !hasRects()"
|
||||||
<CardHeader>
|
@click="runDeskew"
|
||||||
<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">
|
<template v-if="store.isProcessing">
|
||||||
 • 
|
<svg
|
||||||
</template>
|
class="mr-2 h-4 w-4 animate-spin"
|
||||||
</span>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</CardDescription>
|
fill="none"
|
||||||
</CardHeader>
|
viewBox="0 0 24 24"
|
||||||
<CardContent>
|
>
|
||||||
<div class="flex items-center justify-center overflow-hidden rounded-md bg-muted">
|
<circle
|
||||||
<img
|
class="opacity-25"
|
||||||
v-if="resultUrl"
|
cx="12"
|
||||||
:src="resultUrl"
|
cy="12"
|
||||||
alt="Corrected image"
|
r="10"
|
||||||
class="max-h-[500px] w-full object-contain"
|
stroke="currentColor"
|
||||||
/>
|
stroke-width="4"
|
||||||
</div>
|
/>
|
||||||
</CardContent>
|
<path
|
||||||
</Card>
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ store.processingStatus || "Processing..." }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{
|
||||||
|
hasRun
|
||||||
|
? "Re-run Correction"
|
||||||
|
: "Run Perspective Correction"
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<p v-if="error" class="text-center text-sm text-destructive">
|
||||||
<Button size="lg" @click="download">
|
{{ error }}
|
||||||
<svg
|
</p>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
<!-- Result -->
|
||||||
height="18"
|
<template v-if="store.deskewResult">
|
||||||
viewBox="0 0 24 24"
|
<!-- Diagnostics -->
|
||||||
fill="none"
|
<Card>
|
||||||
stroke="currentColor"
|
<CardHeader>
|
||||||
stroke-width="2"
|
<CardTitle class="text-base">Diagnostics</CardTitle>
|
||||||
stroke-linecap="round"
|
<CardDescription>
|
||||||
stroke-linejoin="round"
|
Primary reference:
|
||||||
class="mr-2"
|
<strong>{{
|
||||||
>
|
store.deskewResult.diagnostics.primaryDatum
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
}}</strong>
|
||||||
<polyline points="7 10 12 15 17 10" />
|
 •  Output:
|
||||||
<line x1="12" x2="12" y1="15" y2="3" />
|
{{
|
||||||
</svg>
|
store.deskewResult.diagnostics.outputWidthPx
|
||||||
Download Image
|
}}×{{
|
||||||
</Button>
|
store.deskewResult.diagnostics.outputHeightPx
|
||||||
</div>
|
}}px
|
||||||
</template>
|
</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<!-- Axis corrections -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="rounded-md border p-3">
|
||||||
|
<p
|
||||||
|
class="text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
X-axis correction
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-semibold">
|
||||||
|
{{
|
||||||
|
(
|
||||||
|
store.deskewResult.diagnostics
|
||||||
|
.xCorrection.ratio * 100
|
||||||
|
).toFixed(2)
|
||||||
|
}}%
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
weight:
|
||||||
|
{{
|
||||||
|
store.deskewResult.diagnostics.xCorrection.totalWeight.toFixed(
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border p-3">
|
||||||
|
<p
|
||||||
|
class="text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Y-axis correction
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-semibold">
|
||||||
|
{{
|
||||||
|
(
|
||||||
|
store.deskewResult.diagnostics
|
||||||
|
.yCorrection.ratio * 100
|
||||||
|
).toFixed(2)
|
||||||
|
}}%
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
weight:
|
||||||
|
{{
|
||||||
|
store.deskewResult.diagnostics.yCorrection.totalWeight.toFixed(
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-datum table -->
|
||||||
|
<Table
|
||||||
|
v-if="
|
||||||
|
store.deskewResult.diagnostics.perDatum.length > 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Datum</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead class="text-right"
|
||||||
|
>Expected (mm)</TableHead
|
||||||
|
>
|
||||||
|
<TableHead class="text-right"
|
||||||
|
>Measured (mm)</TableHead
|
||||||
|
>
|
||||||
|
<TableHead class="text-right">Error</TableHead>
|
||||||
|
<TableHead>Axis</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="report in store.deskewResult.diagnostics
|
||||||
|
.perDatum"
|
||||||
|
:key="report.label"
|
||||||
|
>
|
||||||
|
<TableCell class="font-medium">{{
|
||||||
|
report.label
|
||||||
|
}}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" class="text-xs">{{
|
||||||
|
report.type
|
||||||
|
}}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right">{{
|
||||||
|
report.expectedMm.toFixed(1)
|
||||||
|
}}</TableCell>
|
||||||
|
<TableCell class="text-right">{{
|
||||||
|
report.measuredMm.toFixed(1)
|
||||||
|
}}</TableCell>
|
||||||
|
<TableCell
|
||||||
|
class="text-right"
|
||||||
|
:class="
|
||||||
|
report.errorPercent > 5
|
||||||
|
? 'text-destructive'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ report.errorPercent.toFixed(1) }}%
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{
|
||||||
|
report.axisContribution
|
||||||
|
}}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Corrected image -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Corrected Image</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center overflow-hidden rounded-md bg-muted"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="resultUrl"
|
||||||
|
:src="resultUrl"
|
||||||
|
alt="Corrected image"
|
||||||
|
class="max-h-[500px] w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="flex justify-center pb-8">
|
||||||
|
<Button size="lg" @click="download">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" x2="12" y1="15" y2="3" />
|
||||||
|
</svg>
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,30 +1,34 @@
|
|||||||
<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="
|
||||||
class="cursor-default select-none text-xs"
|
store.currentStep === step.num ? 'default' : 'outline'
|
||||||
:class="{
|
"
|
||||||
'opacity-40': store.currentStep < step.num,
|
class="cursor-default select-none text-xs"
|
||||||
}"
|
:class="{
|
||||||
>
|
'opacity-40': store.currentStep < step.num,
|
||||||
{{ step.num }}. {{ step.label }}
|
}"
|
||||||
</Badge>
|
>
|
||||||
<span v-if="i < steps.length - 1" class="text-muted-foreground">·</span>
|
{{ step.num }}. {{ step.label }}
|
||||||
</template>
|
</Badge>
|
||||||
</nav>
|
<span v-if="i < steps.length - 1" class="text-muted-foreground"
|
||||||
|
>·</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,64 +1,69 @@
|
|||||||
<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
|
||||||
<svg
|
variant="ghost"
|
||||||
v-if="isDark"
|
size="icon"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
@click="toggle"
|
||||||
width="18"
|
aria-label="Toggle theme"
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="4" />
|
<svg
|
||||||
<path d="M12 2v2" />
|
v-if="isDark"
|
||||||
<path d="M12 20v2" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path d="m4.93 4.93 1.41 1.41" />
|
width="18"
|
||||||
<path d="m17.66 17.66 1.41 1.41" />
|
height="18"
|
||||||
<path d="M2 12h2" />
|
viewBox="0 0 24 24"
|
||||||
<path d="M20 12h2" />
|
fill="none"
|
||||||
<path d="m6.34 17.66-1.41 1.41" />
|
stroke="currentColor"
|
||||||
<path d="m19.07 4.93-1.41 1.41" />
|
stroke-width="2"
|
||||||
</svg>
|
stroke-linecap="round"
|
||||||
<svg
|
stroke-linejoin="round"
|
||||||
v-else
|
>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<circle cx="12" cy="12" r="4" />
|
||||||
width="18"
|
<path d="M12 2v2" />
|
||||||
height="18"
|
<path d="M12 20v2" />
|
||||||
viewBox="0 0 24 24"
|
<path d="m4.93 4.93 1.41 1.41" />
|
||||||
fill="none"
|
<path d="m17.66 17.66 1.41 1.41" />
|
||||||
stroke="currentColor"
|
<path d="M2 12h2" />
|
||||||
stroke-width="2"
|
<path d="M20 12h2" />
|
||||||
stroke-linecap="round"
|
<path d="m6.34 17.66-1.41 1.41" />
|
||||||
stroke-linejoin="round"
|
<path d="m19.07 4.93-1.41 1.41" />
|
||||||
>
|
</svg>
|
||||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
<svg
|
||||||
</svg>
|
v-else
|
||||||
</Button>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,27 +1,29 @@
|
|||||||
<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>
|
||||||
<Primitive
|
<Primitive
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
:data-variant="variant"
|
:data-variant="variant"
|
||||||
:class="cn(badgeVariants({ variant }), props.class)"
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Primitive>
|
</Primitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,24 +1,28 @@
|
|||||||
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: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||||
|
|||||||
@ -1,31 +1,31 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
:data-variant="variant"
|
:data-variant="variant"
|
||||||
:data-size="size"
|
:data-size="size"
|
||||||
:as="as"
|
:as="as"
|
||||||
:as-child="asChild"
|
:as-child="asChild"
|
||||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Primitive>
|
</Primitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,35 +1,42 @@
|
|||||||
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",
|
||||||
size: {
|
destructive:
|
||||||
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
"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",
|
||||||
'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',
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
|
},
|
||||||
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
size: {
|
||||||
'icon': 'size-8',
|
default:
|
||||||
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_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",
|
||||||
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
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",
|
||||||
'icon-lg': 'size-9',
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5",
|
||||||
},
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
size: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
|
|||||||
@ -1,21 +1,29 @@
|
|||||||
<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(
|
||||||
<slot />
|
'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',
|
||||||
</div>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<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(
|
||||||
<slot />
|
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||||
</div>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
<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-content"
|
data-slot="card-content"
|
||||||
:class="cn('px-4 group-data-[size=sm]/card:px-3', props.class)"
|
:class="cn('px-4 group-data-[size=sm]/card:px-3', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
<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-description"
|
data-slot="card-description"
|
||||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<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(
|
||||||
<slot />
|
'bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center',
|
||||||
</div>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<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(
|
||||||
<slot />
|
'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]',
|
||||||
</div>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<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(
|
||||||
<slot />
|
'text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading',
|
||||||
</div>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -1,31 +1,33 @@
|
|||||||
<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,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
props.class,
|
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
)"
|
props.class,
|
||||||
>
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { default as Input } from './Input.vue'
|
export { default as Input } from "./Input.vue"
|
||||||
|
|||||||
@ -1,26 +1,26 @@
|
|||||||
<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>
|
||||||
<Label
|
<Label
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Label>
|
</Label>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { default as Label } from './Label.vue'
|
export { default as Label } from "./Label.vue"
|
||||||
|
|||||||
@ -1,38 +1,35 @@
|
|||||||
<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>
|
||||||
<ProgressRoot
|
<ProgressRoot
|
||||||
data-slot="progress"
|
data-slot="progress"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden',
|
'bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<ProgressIndicator
|
<ProgressIndicator
|
||||||
data-slot="progress-indicator"
|
data-slot="progress-indicator"
|
||||||
class="bg-primary size-full flex-1 transition-all"
|
class="bg-primary size-full flex-1 transition-all"
|
||||||
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||||
/>
|
/>
|
||||||
</ProgressRoot>
|
</ProgressRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { default as Progress } from './Progress.vue'
|
export { default as Progress } from "./Progress.vue"
|
||||||
|
|||||||
@ -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"
|
<slot v-bind="slotProps" />
|
||||||
data-slot="select"
|
</SelectRoot>
|
||||||
v-bind="forwarded"
|
|
||||||
>
|
|
||||||
<slot v-bind="slotProps" />
|
|
||||||
</SelectRoot>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,58 +1,61 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectPortal>
|
<SelectPortal>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
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="
|
||||||
'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',
|
cn(
|
||||||
position === 'popper'
|
'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',
|
||||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
position === 'popper' &&
|
||||||
props.class,
|
'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,
|
||||||
"
|
)
|
||||||
>
|
"
|
||||||
<SelectScrollUpButton />
|
>
|
||||||
<SelectViewport
|
<SelectScrollUpButton />
|
||||||
:data-position="position"
|
<SelectViewport
|
||||||
:class="cn(
|
:data-position="position"
|
||||||
'data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
|
:class="
|
||||||
)"
|
cn(
|
||||||
>
|
'data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
|
||||||
<slot />
|
)
|
||||||
</SelectViewport>
|
"
|
||||||
<SelectScrollDownButton />
|
>
|
||||||
</SelectContent>
|
<slot />
|
||||||
</SelectPortal>
|
</SelectViewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,21 +1,23 @@
|
|||||||
<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>
|
||||||
<SelectGroup
|
<SelectGroup
|
||||||
data-slot="select-group"
|
data-slot="select-group"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="cn('scroll-my-1 p-1', props.class)"
|
:class="cn('scroll-my-1 p-1', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,45 +1,49 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
<span
|
||||||
<SelectItemIndicator>
|
class="pointer-events-none absolute right-2 flex size-4 items-center justify-center"
|
||||||
<slot name="indicator-icon">
|
>
|
||||||
<CheckIcon class="pointer-events-none" />
|
<SelectItemIndicator>
|
||||||
</slot>
|
<slot name="indicator-icon">
|
||||||
</SelectItemIndicator>
|
<CheckIcon class="pointer-events-none" />
|
||||||
</span>
|
</slot>
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
<SelectItemText>
|
<SelectItemText>
|
||||||
<slot />
|
<slot />
|
||||||
</SelectItemText>
|
</SelectItemText>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
<slot />
|
||||||
v-bind="props"
|
</SelectItemText>
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</SelectItemText>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,19 @@
|
|||||||
<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>
|
||||||
<SelectLabel
|
<SelectLabel
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
:class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)"
|
:class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,27 +1,34 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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(
|
||||||
<slot>
|
'bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4',
|
||||||
<ChevronDownIcon />
|
props.class,
|
||||||
</slot>
|
)
|
||||||
</SelectScrollDownButton>
|
"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollDownButton>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,27 +1,34 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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(
|
||||||
<slot>
|
'bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4',
|
||||||
<ChevronUpIcon />
|
props.class,
|
||||||
</slot>
|
)
|
||||||
</SelectScrollUpButton>
|
"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollUpButton>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,34 +1,43 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
:data-size="size"
|
:data-size="size"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
props.class,
|
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
)"
|
props.class,
|
||||||
>
|
)
|
||||||
<slot />
|
"
|
||||||
<SelectIcon as-child>
|
>
|
||||||
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
|
<slot />
|
||||||
</SelectIcon>
|
<SelectIcon as-child>
|
||||||
</SelectTrigger>
|
<ChevronDownIcon
|
||||||
|
class="text-muted-foreground size-4 pointer-events-none"
|
||||||
|
/>
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
<slot />
|
||||||
v-bind="props"
|
</SelectValue>
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</SelectValue>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -1,29 +1,30 @@
|
|||||||
<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>
|
||||||
<Separator
|
<Separator
|
||||||
data-slot="separator"
|
data-slot="separator"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
|
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { default as Separator } from './Separator.vue'
|
export { default as Separator } from "./Separator.vue"
|
||||||
|
|||||||
@ -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"
|
<slot v-bind="slotProps" />
|
||||||
data-slot="sheet"
|
</DialogRoot>
|
||||||
v-bind="forwarded"
|
|
||||||
>
|
|
||||||
<slot v-bind="slotProps" />
|
|
||||||
</DialogRoot>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
<slot />
|
||||||
v-bind="props"
|
</DialogClose>
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</DialogClose>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,61 +1,70 @@
|
|||||||
<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
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<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="
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
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',
|
||||||
<slot />
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose
|
||||||
v-if="showCloseButton"
|
v-if="showCloseButton"
|
||||||
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
|
||||||
<XIcon />
|
variant="ghost"
|
||||||
<span class="sr-only">Close</span>
|
class="absolute top-3 right-3"
|
||||||
</Button>
|
size="icon-sm"
|
||||||
</DialogClose>
|
>
|
||||||
</DialogContent>
|
<XIcon />
|
||||||
</DialogPortal>
|
<span class="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,21 +1,23 @@
|
|||||||
<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>
|
||||||
<DialogDescription
|
<DialogDescription
|
||||||
data-slot="sheet-description"
|
data-slot="sheet-description"
|
||||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
<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>
|
||||||
<div
|
<div
|
||||||
data-slot="sheet-footer"
|
data-slot="sheet-footer"
|
||||||
:class="cn('gap-2 p-4 mt-auto flex flex-col', props.class)"
|
:class="cn('gap-2 p-4 mt-auto flex flex-col', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
<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>
|
||||||
<div
|
<div
|
||||||
data-slot="sheet-header"
|
data-slot="sheet-header"
|
||||||
:class="cn('gap-0.5 p-4 flex flex-col', props.class)"
|
:class="cn('gap-0.5 p-4 flex flex-col', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,21 +1,28 @@
|
|||||||
<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="
|
||||||
v-bind="delegatedProps"
|
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',
|
||||||
<slot />
|
props.class,
|
||||||
</DialogOverlay>
|
)
|
||||||
|
"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,21 +1,28 @@
|
|||||||
<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="
|
||||||
v-bind="delegatedProps"
|
cn(
|
||||||
>
|
'text-foreground text-base font-medium cn-font-heading',
|
||||||
<slot />
|
props.class,
|
||||||
</DialogTitle>
|
)
|
||||||
|
"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
<slot />
|
||||||
v-bind="props"
|
</DialogTrigger>
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</DialogTrigger>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -1,49 +1,63 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SliderRoot
|
<SliderRoot
|
||||||
v-slot="{ modelValue }"
|
v-slot="{ modelValue }"
|
||||||
data-slot="slider"
|
data-slot="slider"
|
||||||
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
|
|
||||||
:class="cn(
|
|
||||||
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
|
|
||||||
props.class,
|
|
||||||
)"
|
|
||||||
v-bind="forwarded"
|
|
||||||
>
|
|
||||||
<SliderTrack
|
|
||||||
data-slot="slider-track"
|
|
||||||
:data-horizontal="props.orientation !== 'vertical' ? '' : undefined"
|
|
||||||
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
|
|
||||||
class="bg-muted rounded-full data-horizontal:h-1 data-vertical:w-1 relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full"
|
|
||||||
>
|
|
||||||
<SliderRange
|
|
||||||
data-slot="slider-range"
|
|
||||||
:data-horizontal="props.orientation !== 'vertical' ? '' : undefined"
|
|
||||||
:data-vertical="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="
|
||||||
/>
|
cn(
|
||||||
</SliderTrack>
|
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<SliderTrack
|
||||||
|
data-slot="slider-track"
|
||||||
|
:data-horizontal="props.orientation !== 'vertical' ? '' : undefined"
|
||||||
|
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
|
||||||
|
class="bg-muted rounded-full data-horizontal:h-1 data-vertical:w-1 relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full"
|
||||||
|
>
|
||||||
|
<SliderRange
|
||||||
|
data-slot="slider-range"
|
||||||
|
:data-horizontal="
|
||||||
|
props.orientation !== 'vertical' ? '' : undefined
|
||||||
|
"
|
||||||
|
:data-vertical="
|
||||||
|
props.orientation === 'vertical' ? '' : undefined
|
||||||
|
"
|
||||||
|
class="bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
|
||||||
|
/>
|
||||||
|
</SliderTrack>
|
||||||
|
|
||||||
<SliderThumb
|
<SliderThumb
|
||||||
v-for="(_, key) in modelValue"
|
v-for="(_, key) in modelValue"
|
||||||
:key="key"
|
:key="key"
|
||||||
data-slot="slider-thumb"
|
data-slot="slider-thumb"
|
||||||
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
|
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
|
||||||
class="border-ring ring-ring/50 relative size-3 rounded-full border bg-white transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
|
class="border-ring ring-ring/50 relative size-3 rounded-full border bg-white transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</SliderRoot>
|
</SliderRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { default as Slider } from './Slider.vue'
|
export { default as Slider } from "./Slider.vue"
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
<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
|
||||||
<slot />
|
data-slot="table"
|
||||||
</table>
|
:class="cn('w-full caption-bottom text-sm', props.class)"
|
||||||
</div>
|
>
|
||||||
|
<slot />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
<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>
|
||||||
<tbody
|
<tbody
|
||||||
data-slot="table-body"
|
data-slot="table-body"
|
||||||
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</tbody>
|
</tbody>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
<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>
|
||||||
<caption
|
<caption
|
||||||
data-slot="table-caption"
|
data-slot="table-caption"
|
||||||
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</caption>
|
</caption>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<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(
|
||||||
<slot />
|
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||||
</td>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</td>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,34 +1,37 @@
|
|||||||
<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<{
|
||||||
colspan?: number
|
class?: HTMLAttributes["class"]
|
||||||
}>(), {
|
colspan?: number
|
||||||
colspan: 1,
|
}>(),
|
||||||
})
|
{
|
||||||
|
colspan: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, 'class')
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-center py-10">
|
<div class="flex items-center justify-center py-10">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<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(
|
||||||
<slot />
|
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
|
||||||
</tfoot>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tfoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<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(
|
||||||
<slot />
|
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||||
</th>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</th>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
<slot />
|
||||||
:class="cn('[&_tr]:border-b', props.class)"
|
</thead>
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</thead>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<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(
|
||||||
<slot />
|
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50',
|
||||||
</tr>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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"
|
<slot v-bind="slotProps" />
|
||||||
data-slot="tooltip"
|
</TooltipRoot>
|
||||||
v-bind="forwarded"
|
|
||||||
>
|
|
||||||
<slot v-bind="slotProps" />
|
|
||||||
</TooltipRoot>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,34 +1,49 @@
|
|||||||
<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(
|
||||||
sideOffset: 0,
|
defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
})
|
{
|
||||||
|
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>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<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(
|
||||||
<slot />
|
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
<TooltipArrow class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
|
<TooltipArrow
|
||||||
</TooltipContent>
|
class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]"
|
||||||
</TooltipPortal>
|
/>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
<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,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TooltipProvider v-bind="props">
|
<TooltipProvider v-bind="props">
|
||||||
<slot />
|
<slot />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
<slot />
|
||||||
v-bind="props"
|
</TooltipTrigger>
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</TooltipTrigger>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -1,61 +1,64 @@
|
|||||||
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
|
||||||
"#ef4444", // red
|
"#ef4444", // red
|
||||||
"#22c55e", // green
|
"#22c55e", // green
|
||||||
"#f59e0b", // amber
|
"#f59e0b", // amber
|
||||||
"#8b5cf6", // violet
|
"#8b5cf6", // violet
|
||||||
"#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,
|
||||||
preset?: RectPreset,
|
index: number,
|
||||||
|
preset?: RectPreset,
|
||||||
): RectDatum {
|
): RectDatum {
|
||||||
const spread = 80;
|
const spread = 80
|
||||||
return {
|
return {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
corners: [
|
corners: [
|
||||||
{ 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 },
|
||||||
{ 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",
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{ 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)}`,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (1–5).
|
||||||
|
* 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");
|
}
|
||||||
if (!ctx) throw new Error("Cannot get 2D context");
|
|
||||||
|
// ─── Canvas → Blob helper ───────────────────────────────────────────────────
|
||||||
ctx.drawImage(input.imageData, 0, 0);
|
|
||||||
|
function canvasToBlob(
|
||||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
canvas: HTMLCanvasElement,
|
||||||
canvas.toBlob(
|
type = "image/png",
|
||||||
(b) => (b ? resolve(b) : reject(new Error("Canvas toBlob failed"))),
|
quality = 0.95,
|
||||||
"image/jpeg",
|
): Promise<Blob> {
|
||||||
0.95,
|
return new Promise((resolve, reject) => {
|
||||||
);
|
canvas.toBlob(
|
||||||
});
|
(b) => {
|
||||||
|
if (b) {
|
||||||
const corrections: string[] = [];
|
resolve(b)
|
||||||
|
} else {
|
||||||
if (input.exif.focalLength) {
|
reject(new Error("toBlob failed"))
|
||||||
corrections.push(
|
}
|
||||||
`Lens: ${input.exif.lensModel ?? "unknown"} @ ${input.exif.focalLength}mm`,
|
},
|
||||||
);
|
type,
|
||||||
}
|
quality,
|
||||||
|
)
|
||||||
corrections.push(`${input.datums.length} datum(s) used for calibration`);
|
})
|
||||||
corrections.push("Placeholder: no actual correction applied yet");
|
}
|
||||||
|
|
||||||
return {
|
// ─── Core ────────────────────────────────────────────────────────────────────
|
||||||
correctedImageBlob: blob,
|
|
||||||
appliedCorrections: corrections,
|
export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
|
||||||
};
|
const { image, datums, scalePxPerMm: scale } = input
|
||||||
|
if (datums.length === 0) throw new Error("No datums provided.")
|
||||||
|
|
||||||
|
const primary = pickPrimary(datums)
|
||||||
|
|
||||||
|
// Load source image into OpenCV
|
||||||
|
let srcCanvas: HTMLCanvasElement
|
||||||
|
if (image instanceof HTMLCanvasElement) {
|
||||||
|
srcCanvas = image
|
||||||
|
} else {
|
||||||
|
srcCanvas = document.createElement("canvas")
|
||||||
|
srcCanvas.width = image.naturalWidth
|
||||||
|
srcCanvas.height = image.naturalHeight
|
||||||
|
const ctx = srcCanvas.getContext("2d")
|
||||||
|
if (!ctx) throw new Error("Failed to get 2d context")
|
||||||
|
ctx.drawImage(image, 0, 0)
|
||||||
|
}
|
||||||
|
const src = cv.imread(srcCanvas)
|
||||||
|
const imgW = src.cols
|
||||||
|
const imgH = src.rows
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// STEP 1 — Initial perspective correction from primary rectangle
|
||||||
|
// ================================================================
|
||||||
|
const pw = primary.widthMm * scale
|
||||||
|
const ph = primary.heightMm * scale
|
||||||
|
|
||||||
|
const algoCorners = cornersToAlgoOrder(primary.corners)
|
||||||
|
const srcPts = pointsToMat(algoCorners)
|
||||||
|
const dstInit = pointsToMat([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: pw, y: 0 },
|
||||||
|
{ x: 0, y: ph },
|
||||||
|
{ x: pw, y: ph },
|
||||||
|
])
|
||||||
|
const mInit = cv.getPerspectiveTransform(srcPts, dstInit)
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// STEP 2 — Measure all secondary datums, accumulate corrections
|
||||||
|
// ================================================================
|
||||||
|
let xWSum = 0,
|
||||||
|
xWTotal = 0
|
||||||
|
let yWSum = 0,
|
||||||
|
yWTotal = 0
|
||||||
|
const reports: DatumReport[] = []
|
||||||
|
|
||||||
|
for (const datum of datums) {
|
||||||
|
const w = datum.confidence
|
||||||
|
|
||||||
|
if (datum === primary) {
|
||||||
|
reports.push({
|
||||||
|
label: datum.label,
|
||||||
|
type: "rectangle",
|
||||||
|
measuredMm: datum.widthMm,
|
||||||
|
expectedMm: datum.widthMm,
|
||||||
|
errorPercent: 0,
|
||||||
|
axisContribution: "both",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datum.type === "line") {
|
||||||
|
const [s, e] = transformPoints(datum.endpoints as Point[], mInit)
|
||||||
|
if (!s || !e) continue
|
||||||
|
const dx = Math.abs(e.x - s.x)
|
||||||
|
const dy = Math.abs(e.y - s.y)
|
||||||
|
const measured = dist(s, e)
|
||||||
|
const expected = datum.lengthMm * scale
|
||||||
|
const ratio = expected / measured
|
||||||
|
|
||||||
|
// Axis contribution proportional to alignment
|
||||||
|
const total = dx + dy
|
||||||
|
if (total > 1e-6) {
|
||||||
|
const xFrac = dx / total
|
||||||
|
const yFrac = dy / total
|
||||||
|
xWSum += ratio * w * xFrac
|
||||||
|
xWTotal += w * xFrac
|
||||||
|
yWSum += ratio * w * yFrac
|
||||||
|
yWTotal += w * yFrac
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.push({
|
||||||
|
label: datum.label,
|
||||||
|
type: "line",
|
||||||
|
measuredMm: measured / scale,
|
||||||
|
expectedMm: datum.lengthMm,
|
||||||
|
errorPercent: Math.abs(1 - ratio) * 100,
|
||||||
|
axisContribution: dx > dy ? "x" : "y",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Secondary rectangle: top edge → X, left edge → Y
|
||||||
|
const ac = cornersToAlgoOrder(datum.corners)
|
||||||
|
const [tl, tr, bl] = transformPoints([ac[0], ac[1], ac[2]], mInit)
|
||||||
|
if (!tl || !tr || !bl) continue
|
||||||
|
const mW = dist(tl, tr)
|
||||||
|
const mH = dist(tl, bl)
|
||||||
|
const xR = (datum.widthMm * scale) / mW
|
||||||
|
const yR = (datum.heightMm * scale) / mH
|
||||||
|
|
||||||
|
xWSum += xR * w
|
||||||
|
xWTotal += w
|
||||||
|
yWSum += yR * w
|
||||||
|
yWTotal += w
|
||||||
|
|
||||||
|
reports.push({
|
||||||
|
label: datum.label,
|
||||||
|
type: "rectangle",
|
||||||
|
measuredMm: mW / scale,
|
||||||
|
expectedMm: datum.widthMm,
|
||||||
|
errorPercent: (Math.abs(1 - xR) + Math.abs(1 - yR)) * 50,
|
||||||
|
axisContribution: "both",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// STEP 3 — Weighted corrections (1.0 = no secondary data)
|
||||||
|
// ================================================================
|
||||||
|
const xCorr: AxisCorrection = {
|
||||||
|
ratio: xWTotal > 0 ? xWSum / xWTotal : 1.0,
|
||||||
|
totalWeight: xWTotal,
|
||||||
|
}
|
||||||
|
const yCorr: AxisCorrection = {
|
||||||
|
ratio: yWTotal > 0 ? yWSum / yWTotal : 1.0,
|
||||||
|
totalWeight: yWTotal,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// STEP 4 — Fold into destination rectangle, recompute transform
|
||||||
|
// ================================================================
|
||||||
|
const pwFinal = pw * xCorr.ratio
|
||||||
|
const phFinal = ph * yCorr.ratio
|
||||||
|
|
||||||
|
const dstFinal = pointsToMat([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: pwFinal, y: 0 },
|
||||||
|
{ x: 0, y: phFinal },
|
||||||
|
{ x: pwFinal, y: phFinal },
|
||||||
|
])
|
||||||
|
const mFinal = cv.getPerspectiveTransform(srcPts, dstFinal)
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// STEP 5 — Output bounds + translation shift
|
||||||
|
// ================================================================
|
||||||
|
const imgCorners: Point[] = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: imgW, y: 0 },
|
||||||
|
{ x: 0, y: imgH },
|
||||||
|
{ x: imgW, y: imgH },
|
||||||
|
]
|
||||||
|
const warped = transformPoints(imgCorners, mFinal)
|
||||||
|
let xMin = Infinity,
|
||||||
|
yMin = Infinity,
|
||||||
|
xMax = -Infinity,
|
||||||
|
yMax = -Infinity
|
||||||
|
for (const c of warped) {
|
||||||
|
xMin = Math.min(xMin, c.x)
|
||||||
|
yMin = Math.min(yMin, c.y)
|
||||||
|
xMax = Math.max(xMax, c.x)
|
||||||
|
yMax = Math.max(yMax, c.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outW = Math.ceil(xMax - xMin)
|
||||||
|
const outH = Math.ceil(yMax - yMin)
|
||||||
|
|
||||||
|
const mData: number[] = readMat3x3(mFinal)
|
||||||
|
const tShift: number[] = [1, 0, -xMin, 0, 1, -yMin, 0, 0, 1]
|
||||||
|
const mOutData: number[] = mul3x3(tShift, mData)
|
||||||
|
const mOut = cv.matFromArray(3, 3, cv.CV_64FC1, mOutData)
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// STEP 6 — Warp
|
||||||
|
// ================================================================
|
||||||
|
const dstMat = new cv.Mat()
|
||||||
|
cv.warpPerspective(
|
||||||
|
src,
|
||||||
|
dstMat,
|
||||||
|
mOut,
|
||||||
|
new cv.Size(outW, outH),
|
||||||
|
cv.INTER_LANCZOS4 as number,
|
||||||
|
cv.BORDER_CONSTANT as number,
|
||||||
|
new cv.Scalar(0, 0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const outCanvas = document.createElement("canvas")
|
||||||
|
outCanvas.width = outW
|
||||||
|
outCanvas.height = outH
|
||||||
|
cv.imshow(outCanvas, dstMat)
|
||||||
|
|
||||||
|
// Cleanup OpenCV mats
|
||||||
|
src.delete()
|
||||||
|
srcPts.delete()
|
||||||
|
dstInit.delete()
|
||||||
|
mInit.delete()
|
||||||
|
dstFinal.delete()
|
||||||
|
mFinal.delete()
|
||||||
|
mOut.delete()
|
||||||
|
dstMat.delete()
|
||||||
|
|
||||||
|
const blob = await canvasToBlob(outCanvas, "image/png", 0.95)
|
||||||
|
|
||||||
|
const diagnostics: DeskewDiagnostics = {
|
||||||
|
primaryDatum: primary.label,
|
||||||
|
xCorrection: xCorr,
|
||||||
|
yCorrection: yCorr,
|
||||||
|
perDatum: reports,
|
||||||
|
outputWidthPx: outW,
|
||||||
|
outputHeightPx: outH,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { correctedImageBlob: blob, diagnostics }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OpenCV init ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for OpenCV WASM to initialize. Call once at app startup. */
|
||||||
|
export function waitForOpenCV(): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (cv.Mat) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cv.onRuntimeInitialized = () => {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
133
src/lib/exif.ts
133
src/lib/exif.ts
@ -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,
|
dateTimeOriginal: raw.DateTimeOriginal
|
||||||
iso: raw.ISO as number | undefined,
|
? String(raw.DateTimeOriginal)
|
||||||
dateTimeOriginal: raw.DateTimeOriginal
|
: undefined,
|
||||||
? String(raw.DateTimeOriginal)
|
gpsLatitude: raw.latitude,
|
||||||
: undefined,
|
gpsLongitude: raw.longitude,
|
||||||
gpsLatitude: raw.latitude as number | undefined,
|
}
|
||||||
gpsLongitude: raw.longitude as number | undefined,
|
} catch {
|
||||||
};
|
console.warn("EXIF extraction failed")
|
||||||
} catch {
|
return {}
|
||||||
console.warn("EXIF extraction failed");
|
}
|
||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
img.onerror = () => {
|
resolve(img)
|
||||||
URL.revokeObjectURL(url);
|
}
|
||||||
reject(new Error("Failed to load image"));
|
img.onerror = () => {
|
||||||
};
|
URL.revokeObjectURL(url)
|
||||||
img.src = url;
|
reject(new Error("Failed to load image"))
|
||||||
});
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,5 +3,5 @@ import { clsx } from "clsx"
|
|||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/main.ts
18
src/main.ts
@ -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")
|
||||||
|
|||||||
@ -1,96 +1,104 @@
|
|||||||
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) {
|
|
||||||
exifData.value = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToStep(step: AppStep) {
|
|
||||||
currentStep.value = step;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDatum(datum: Datum) {
|
|
||||||
datums.value.push(datum);
|
|
||||||
selectedDatumId.value = datum.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDatum(id: string, updates: Partial<Datum>) {
|
|
||||||
const index = datums.value.findIndex((d) => d.id === id);
|
|
||||||
if (index !== -1) {
|
|
||||||
datums.value[index] = { ...datums.value[index]!, ...updates } as Datum;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function removeDatum(id: string) {
|
function setExif(data: ExifData) {
|
||||||
datums.value = datums.value.filter((d) => d.id !== id);
|
exifData.value = data
|
||||||
if (selectedDatumId.value === id) {
|
|
||||||
selectedDatumId.value = datums.value[0]?.id ?? null;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function setResult(result: DeskewResult) {
|
function goToStep(step: AppStep) {
|
||||||
deskewResult.value = result;
|
currentStep.value = step
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function addDatum(datum: Datum) {
|
||||||
currentStep.value = 1;
|
datums.value.push(datum)
|
||||||
originalFile.value = null;
|
selectedDatumId.value = datum.id
|
||||||
loadedImage.value = null;
|
}
|
||||||
exifData.value = {};
|
|
||||||
datums.value = [];
|
|
||||||
deskewResult.value = null;
|
|
||||||
isProcessing.value = false;
|
|
||||||
processingStatus.value = "";
|
|
||||||
selectedDatumId.value = null;
|
|
||||||
scalePxPerCm.value = DEFAULT_SCALE_PX_PER_CM;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
function updateDatum(id: string, updates: Partial<Datum>) {
|
||||||
currentStep,
|
const index = datums.value.findIndex((d) => d.id === id)
|
||||||
originalFile,
|
const existing = datums.value[index]
|
||||||
loadedImage,
|
if (index !== -1 && existing) {
|
||||||
exifData,
|
datums.value[index] = {
|
||||||
datums,
|
...existing,
|
||||||
deskewResult,
|
...updates,
|
||||||
isProcessing,
|
} as Datum
|
||||||
processingStatus,
|
}
|
||||||
selectedDatumId,
|
}
|
||||||
scalePxPerCm,
|
|
||||||
canProceedToStep2,
|
function removeDatum(id: string) {
|
||||||
canProceedToStep3,
|
datums.value = datums.value.filter((d) => d.id !== id)
|
||||||
canProceedToStep4,
|
if (selectedDatumId.value === id) {
|
||||||
setImage,
|
selectedDatumId.value = datums.value[0]?.id ?? null
|
||||||
setExif,
|
}
|
||||||
goToStep,
|
}
|
||||||
addDatum,
|
|
||||||
updateDatum,
|
function setResult(result: DeskewResult) {
|
||||||
removeDatum,
|
deskewResult.value = result
|
||||||
setResult,
|
}
|
||||||
reset,
|
|
||||||
};
|
function reset() {
|
||||||
});
|
currentStep.value = 1
|
||||||
|
originalFile.value = null
|
||||||
|
loadedImage.value = null
|
||||||
|
exifData.value = {}
|
||||||
|
datums.value = []
|
||||||
|
deskewResult.value = null
|
||||||
|
isProcessing.value = false
|
||||||
|
processingStatus.value = ""
|
||||||
|
selectedDatumId.value = null
|
||||||
|
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep,
|
||||||
|
originalFile,
|
||||||
|
loadedImage,
|
||||||
|
exifData,
|
||||||
|
datums,
|
||||||
|
deskewResult,
|
||||||
|
isProcessing,
|
||||||
|
processingStatus,
|
||||||
|
selectedDatumId,
|
||||||
|
scalePxPerMm,
|
||||||
|
canProceedToStep2,
|
||||||
|
canProceedToStep3,
|
||||||
|
canProceedToStep4,
|
||||||
|
setImage,
|
||||||
|
setExif,
|
||||||
|
goToStep,
|
||||||
|
addDatum,
|
||||||
|
updateDatum,
|
||||||
|
removeDatum,
|
||||||
|
setResult,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user