mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-05-28 17:11:20 +02:00
feat(ui): add OCR detect overlay and capture flow
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
175
ui/package-lock.json
generated
175
ui/package-lock.json
generated
@@ -43,6 +43,7 @@
|
|||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"styled-components": "^6.1.19",
|
"styled-components": "^6.1.19",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"validator": "^13.15.0",
|
"validator": "^13.15.0",
|
||||||
"w-touch": "^2.0.0",
|
"w-touch": "^2.0.0",
|
||||||
@@ -2821,6 +2822,66 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.0.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.4.0",
|
||||||
|
"@emnapi/runtime": "^1.4.0",
|
||||||
|
"@tybys/wasm-util": "^0.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.7",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz",
|
||||||
@@ -3847,6 +3908,12 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bmp-js": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -5777,6 +5844,12 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -6199,6 +6272,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-weakmap": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -6963,6 +7042,26 @@
|
|||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@@ -7112,6 +7211,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opencollective-postinstall": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"opencollective-postinstall": "index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -8430,6 +8538,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
@@ -9203,6 +9317,30 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tesseract.js": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bmp-js": "^0.1.0",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
|
"is-url": "^1.2.4",
|
||||||
|
"node-fetch": "^2.6.9",
|
||||||
|
"opencollective-postinstall": "^2.0.3",
|
||||||
|
"regenerator-runtime": "^0.13.3",
|
||||||
|
"tesseract.js-core": "^7.0.0",
|
||||||
|
"wasm-feature-detect": "^1.8.0",
|
||||||
|
"zlibjs": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js-core": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/text-encoding-utf-8": {
|
"node_modules/text-encoding-utf-8": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
|
||||||
@@ -9284,6 +9422,12 @@
|
|||||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||||
@@ -9739,12 +9883,34 @@
|
|||||||
"integrity": "sha512-PYnngF+KHzZRBFI6qsm5PHwVNO5Lx3dYDrvNv//Ei9fvnYlaOWr9sKAriD/crlshWee9ZPsyvDMA4UnoR1LUHw==",
|
"integrity": "sha512-PYnngF+KHzZRBFI6qsm5PHwVNO5Lx3dYDrvNv//Ei9fvnYlaOWr9sKAriD/crlshWee9ZPsyvDMA4UnoR1LUHw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/wasm-feature-detect": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/whatwg-fetch": {
|
"node_modules/whatwg-fetch": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz",
|
||||||
"integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==",
|
"integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -9890,6 +10056,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zlibjs": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "4.5.7",
|
"version": "4.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"styled-components": "^6.1.19",
|
"styled-components": "^6.1.19",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"validator": "^13.15.0",
|
"validator": "^13.15.0",
|
||||||
"w-touch": "^2.0.0",
|
"w-touch": "^2.0.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface ConfirmDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
description: React.ReactNode;
|
description: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string | null;
|
cancelText?: string | null;
|
||||||
@@ -65,6 +66,7 @@ export function ConfirmDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
children,
|
||||||
variant = "info",
|
variant = "info",
|
||||||
confirmText = "Confirm",
|
confirmText = "Confirm",
|
||||||
cancelText = "Cancel",
|
cancelText = "Cancel",
|
||||||
@@ -107,9 +109,10 @@ export function ConfirmDialog({
|
|||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
<div className={cx("mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3", isConfirming && "pointer-events-none")}>
|
||||||
<Button
|
<Button
|
||||||
size="LG"
|
size="LG"
|
||||||
theme={buttonTheme}
|
theme={buttonTheme}
|
||||||
@@ -157,10 +160,11 @@ export function ConfirmDialog({
|
|||||||
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-[#ffffff]">
|
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-[#ffffff]">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-x-2">
|
<div className={cx("flex justify-end gap-x-2", isConfirming && "pointer-events-none")}>
|
||||||
{cancelText && (
|
{cancelText && (
|
||||||
<Button size="SM" theme="light" text={cancelText} onClick={onClose} />
|
<Button size="SM" theme="light" text={cancelText} onClick={onClose} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
458
ui/src/components/OcrOverlay.tsx
Normal file
458
ui/src/components/OcrOverlay.tsx
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { useSettingsStore, useUiStore, useVideoStore } from "@/hooks/stores";
|
||||||
|
import Card from "@components/Card";
|
||||||
|
import { ConfirmDialog } from "@components/ConfirmDialog";
|
||||||
|
import TextArea from "@components/TextArea";
|
||||||
|
import notifications from "@/notifications";
|
||||||
|
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OcrStatus = "idle" | "selecting" | "processing" | "result";
|
||||||
|
|
||||||
|
type TesseractWorker = {
|
||||||
|
recognize: (image: HTMLCanvasElement, options?: unknown, output?: unknown) => Promise<{
|
||||||
|
data: {
|
||||||
|
text?: string;
|
||||||
|
blocks?: Array<{
|
||||||
|
paragraphs: Array<{
|
||||||
|
lines: Array<{
|
||||||
|
text: string;
|
||||||
|
bbox: { x0: number };
|
||||||
|
words: Array<{ text: string; bbox: { x0: number; x1: number } }>;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
terminate: () => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadTesseract() {
|
||||||
|
const { createWorker } = await import("tesseract.js");
|
||||||
|
return createWorker;
|
||||||
|
}
|
||||||
|
|
||||||
|
let workerPromise: Promise<TesseractWorker> | null = null;
|
||||||
|
let cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function initWorker() {
|
||||||
|
const createWorker = await loadTesseract();
|
||||||
|
return createWorker("eng", 1) as Promise<TesseractWorker>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminateWorker() {
|
||||||
|
if (!workerPromise) return;
|
||||||
|
try {
|
||||||
|
const worker = await workerPromise;
|
||||||
|
await worker.terminate();
|
||||||
|
} catch {
|
||||||
|
// Ignore termination errors.
|
||||||
|
}
|
||||||
|
workerPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorker() {
|
||||||
|
if (cleanupTimer) {
|
||||||
|
clearTimeout(cleanupTimer);
|
||||||
|
cleanupTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workerPromise) {
|
||||||
|
workerPromise = initWorker().catch(err => {
|
||||||
|
workerPromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-terminate worker after inactivity to reduce memory footprint.
|
||||||
|
cleanupTimer = setTimeout(() => {
|
||||||
|
void terminateWorker();
|
||||||
|
cleanupTimer = null;
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
return workerPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performOcr(canvas: HTMLCanvasElement): Promise<string> {
|
||||||
|
const worker = await getWorker();
|
||||||
|
const { data } = await worker.recognize(canvas, {}, { text: true, blocks: true });
|
||||||
|
const lines = data.blocks?.flatMap(b => b.paragraphs.flatMap(p => p.lines)) ?? [];
|
||||||
|
|
||||||
|
if (lines.length === 0) return (data.text || "").trim();
|
||||||
|
|
||||||
|
// Estimate character width from OCR words so left indentation is preserved.
|
||||||
|
let totalCharWidth = 0;
|
||||||
|
let samples = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
for (const word of line.words) {
|
||||||
|
const len = word.text.trim().length;
|
||||||
|
if (len > 0) {
|
||||||
|
totalCharWidth += (word.bbox.x1 - word.bbox.x0) / len;
|
||||||
|
samples++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (samples === 0) return (data.text || "").trim();
|
||||||
|
|
||||||
|
const charWidth = totalCharWidth / samples;
|
||||||
|
const minX = Math.min(...lines.map(l => l.bbox.x0));
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map(line => {
|
||||||
|
const indent = Math.round((line.bbox.x0 - minX) / charWidth);
|
||||||
|
return " ".repeat(indent) + line.text.trim();
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureRegion(videoEl: HTMLVideoElement, rect: Rect): HTMLCanvasElement {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = rect.width;
|
||||||
|
canvas.height = rect.height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Failed to acquire 2D canvas context");
|
||||||
|
ctx.drawImage(videoEl, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoDisplayRect(videoElement: HTMLVideoElement, videoWidth: number, videoHeight: number) {
|
||||||
|
const videoRect = videoElement.getBoundingClientRect();
|
||||||
|
const elementAspectRatio = videoRect.width / videoRect.height;
|
||||||
|
const streamAspectRatio = videoWidth / videoHeight;
|
||||||
|
|
||||||
|
let effectiveWidth = videoRect.width;
|
||||||
|
let effectiveHeight = videoRect.height;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
if (elementAspectRatio > streamAspectRatio) {
|
||||||
|
effectiveWidth = videoRect.height * streamAspectRatio;
|
||||||
|
offsetX = (videoRect.width - effectiveWidth) / 2;
|
||||||
|
} else if (elementAspectRatio < streamAspectRatio) {
|
||||||
|
effectiveHeight = videoRect.width / streamAspectRatio;
|
||||||
|
offsetY = (videoRect.height - effectiveHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OcrOverlayProps {
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement>;
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OcrOverlay({ videoRef, containerRef }: OcrOverlayProps) {
|
||||||
|
const { width: videoWidth, height: videoHeight } = useVideoStore();
|
||||||
|
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||||
|
const setOcrMode = useUiStore(state => state.setOcrMode);
|
||||||
|
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
|
const ocrShortcutEnabled = useSettingsStore(state => state.ocrShortcutEnabled);
|
||||||
|
const ocrShortcut = useSettingsStore(state => state.ocrShortcut);
|
||||||
|
const { $at } = useReactAt();
|
||||||
|
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const resultRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [status, setStatus] = useState<OcrStatus>("idle");
|
||||||
|
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [selectionRect, setSelectionRect] = useState<Rect | null>(null);
|
||||||
|
const [ocrResult, setOcrResult] = useState("");
|
||||||
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!ocrShortcutEnabled) return;
|
||||||
|
if (!eventMatchesShortcut(e, ocrShortcut)) return;
|
||||||
|
const activeElement = document.activeElement as HTMLElement | null;
|
||||||
|
const isEditable =
|
||||||
|
!!activeElement
|
||||||
|
&& (activeElement.tagName === "INPUT"
|
||||||
|
|| activeElement.tagName === "TEXTAREA"
|
||||||
|
|| activeElement.isContentEditable);
|
||||||
|
if (isEditable) return;
|
||||||
|
if (videoWidth === 0 || videoHeight === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setOcrMode(!isOcrMode);
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
|
}, [isOcrMode, ocrShortcut, ocrShortcutEnabled, setOcrMode, videoWidth, videoHeight]);
|
||||||
|
|
||||||
|
const closeOverlay = useCallback(() => {
|
||||||
|
if (status === "processing" || status === "result") {
|
||||||
|
setIsClosing(true);
|
||||||
|
setSelectionRect(null);
|
||||||
|
setSelectionStart(null);
|
||||||
|
setTimeout(() => setOcrMode(false), 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOcrMode(false);
|
||||||
|
}, [setOcrMode, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOcrMode) {
|
||||||
|
setStatus("idle");
|
||||||
|
setSelectionRect(null);
|
||||||
|
setSelectionStart(null);
|
||||||
|
setOcrResult("");
|
||||||
|
setIsClosing(false);
|
||||||
|
}
|
||||||
|
}, [isOcrMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOcrMode) return;
|
||||||
|
if (status === "processing" || status === "result") {
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
return () => setDisableVideoFocusTrap(false);
|
||||||
|
}
|
||||||
|
}, [isOcrMode, setDisableVideoFocusTrap, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOcrMode) return;
|
||||||
|
if (status === "processing" || status === "result") return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setOcrMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
|
}, [isOcrMode, setOcrMode, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOcrMode) return;
|
||||||
|
if (status !== "result") return;
|
||||||
|
const handleCopy = () => {
|
||||||
|
notifications.success($at("Copied"), { duration: 4000 });
|
||||||
|
closeOverlay();
|
||||||
|
};
|
||||||
|
document.addEventListener("copy", handleCopy);
|
||||||
|
return () => document.removeEventListener("copy", handleCopy);
|
||||||
|
}, [closeOverlay, isOcrMode, status, $at]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "result" && resultRef.current) {
|
||||||
|
resultRef.current.focus();
|
||||||
|
resultRef.current.select();
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const toVideoCoords = useCallback((clientX: number, clientY: number) => {
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
if (!videoElement) return { x: 0, y: 0 };
|
||||||
|
const { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY } = getVideoDisplayRect(
|
||||||
|
videoElement,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
);
|
||||||
|
const relX = clientX - videoRect.left - offsetX;
|
||||||
|
const relY = clientY - videoRect.top - offsetY;
|
||||||
|
const scaleX = videoWidth / effectiveWidth;
|
||||||
|
const scaleY = videoHeight / effectiveHeight;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(videoWidth, Math.round(relX * scaleX))),
|
||||||
|
y: Math.max(0, Math.min(videoHeight, Math.round(relY * scaleY))),
|
||||||
|
};
|
||||||
|
}, [videoHeight, videoRef, videoWidth]);
|
||||||
|
|
||||||
|
const finishSelection = useCallback(async () => {
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
if (status !== "selecting") return;
|
||||||
|
if (!videoElement || !selectionRect) {
|
||||||
|
setStatus("idle");
|
||||||
|
setSelectionStart(null);
|
||||||
|
setSelectionRect(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectionRect.width < 10 || selectionRect.height < 10) {
|
||||||
|
setStatus("idle");
|
||||||
|
setSelectionStart(null);
|
||||||
|
setSelectionRect(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("processing");
|
||||||
|
try {
|
||||||
|
const canvas = captureRegion(videoElement, selectionRect);
|
||||||
|
const text = await performOcr(canvas);
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (text) {
|
||||||
|
setOcrResult(text);
|
||||||
|
setStatus("result");
|
||||||
|
} else {
|
||||||
|
notifications.error($at("No text detected"));
|
||||||
|
closeOverlay();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
console.error("OCR failed:", error);
|
||||||
|
notifications.error($at("OCR failed"));
|
||||||
|
closeOverlay();
|
||||||
|
}
|
||||||
|
}, [closeOverlay, selectionRect, status, videoRef, $at]);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (status === "processing" || status === "result") return;
|
||||||
|
if (!videoRef.current || videoWidth === 0 || videoHeight === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
const coords = toVideoCoords(e.clientX, e.clientY);
|
||||||
|
setSelectionStart(coords);
|
||||||
|
setSelectionRect(null);
|
||||||
|
setStatus("selecting");
|
||||||
|
}, [status, toVideoCoords, videoHeight, videoRef, videoWidth]);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (status !== "selecting" || !selectionStart) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const coords = toVideoCoords(e.clientX, e.clientY);
|
||||||
|
const x = Math.min(selectionStart.x, coords.x);
|
||||||
|
const y = Math.min(selectionStart.y, coords.y);
|
||||||
|
const width = Math.abs(coords.x - selectionStart.x);
|
||||||
|
const height = Math.abs(coords.y - selectionStart.y);
|
||||||
|
setSelectionRect({ x, y, width, height });
|
||||||
|
}, [selectionStart, status, toVideoCoords]);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (status !== "selecting") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
void finishSelection();
|
||||||
|
}, [finishSelection, status]);
|
||||||
|
|
||||||
|
const selectionStyle = useMemo(() => {
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
const containerElement = containerRef.current;
|
||||||
|
if (!selectionRect || !videoElement || !containerElement || videoWidth === 0 || videoHeight === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { videoRect, effectiveWidth, effectiveHeight, offsetX, offsetY } = getVideoDisplayRect(
|
||||||
|
videoElement,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
);
|
||||||
|
const containerRect = containerElement.getBoundingClientRect();
|
||||||
|
const baseX = videoRect.left - containerRect.left + offsetX;
|
||||||
|
const baseY = videoRect.top - containerRect.top + offsetY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${baseX + (selectionRect.x / videoWidth) * effectiveWidth}px`,
|
||||||
|
top: `${baseY + (selectionRect.y / videoHeight) * effectiveHeight}px`,
|
||||||
|
width: `${(selectionRect.width / videoWidth) * effectiveWidth}px`,
|
||||||
|
height: `${(selectionRect.height / videoHeight) * effectiveHeight}px`,
|
||||||
|
};
|
||||||
|
}, [containerRef, selectionRect, videoHeight, videoRef, videoWidth]);
|
||||||
|
|
||||||
|
if (!isOcrMode) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute inset-0 z-20 touch-none"
|
||||||
|
style={{ cursor: status === "result" ? "default" : "crosshair" }}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/20" />
|
||||||
|
|
||||||
|
{status === "idle" && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
|
||||||
|
<div className="rounded-md bg-black/70 px-3 py-1.5 text-xs font-medium text-white">
|
||||||
|
{$at("Drag to select text area")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectionRect && selectionStyle && status !== "result" && (
|
||||||
|
<div
|
||||||
|
className="absolute border-2 border-dashed border-blue-400 bg-blue-400/10"
|
||||||
|
style={selectionStyle}
|
||||||
|
>
|
||||||
|
{selectionRect.width >= 10 && selectionRect.height >= 10 && (
|
||||||
|
<Card className="absolute right-0 -bottom-6 w-auto px-1.5 py-0.5 text-[10px] font-medium tabular-nums dark:text-white">
|
||||||
|
{selectionRect.width} × {selectionRect.height}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={(status === "processing" || status === "result") && !isClosing}
|
||||||
|
onClose={closeOverlay}
|
||||||
|
title={status === "result" ? $at("Copy text") : $at("Recognizing text...")}
|
||||||
|
description={status === "result" ? $at("Review the recognized text before copying.") : $at("Please wait while OCR is running.")}
|
||||||
|
confirmText={$at("Copy text")}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (status !== "result") return;
|
||||||
|
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(ocrResult).then(() => {
|
||||||
|
notifications.success($at("Copied"), { duration: 4000 });
|
||||||
|
closeOverlay();
|
||||||
|
}).catch(() => {
|
||||||
|
if (!resultRef.current) return;
|
||||||
|
resultRef.current.focus();
|
||||||
|
resultRef.current.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resultRef.current) return;
|
||||||
|
resultRef.current.focus();
|
||||||
|
resultRef.current.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
}}
|
||||||
|
isConfirming={status === "processing"}
|
||||||
|
>
|
||||||
|
{status === "processing" ? (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="h-4 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-700" />
|
||||||
|
<div className="h-4 w-3/4 animate-pulse rounded bg-slate-200 dark:bg-slate-700" />
|
||||||
|
<div className="h-4 w-5/6 animate-pulse rounded bg-slate-200 dark:bg-slate-700" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2">
|
||||||
|
<TextArea
|
||||||
|
ref={resultRef}
|
||||||
|
value={ocrResult}
|
||||||
|
readOnly
|
||||||
|
rows={Math.min(10, ocrResult.split("\n").length + 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ interface UIState {
|
|||||||
|
|
||||||
disableVideoFocusTrap: boolean;
|
disableVideoFocusTrap: boolean;
|
||||||
setDisableVideoFocusTrap: (enabled: boolean) => void;
|
setDisableVideoFocusTrap: (enabled: boolean) => void;
|
||||||
|
isOcrMode: boolean;
|
||||||
|
setOcrMode: (enabled: boolean) => void;
|
||||||
|
|
||||||
isWakeOnLanModalVisible: boolean;
|
isWakeOnLanModalVisible: boolean;
|
||||||
setWakeOnLanModalVisibility: (enabled: boolean) => void;
|
setWakeOnLanModalVisibility: (enabled: boolean) => void;
|
||||||
@@ -91,6 +93,8 @@ export const useUiStore = create<UIState>(set => ({
|
|||||||
|
|
||||||
disableVideoFocusTrap: false,
|
disableVideoFocusTrap: false,
|
||||||
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
|
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }),
|
||||||
|
isOcrMode: false,
|
||||||
|
setOcrMode: enabled => set({ isOcrMode: enabled }),
|
||||||
isAnimationComplete: false,
|
isAnimationComplete: false,
|
||||||
setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }),
|
setIsAnimationComplete: enabled => set({ isAnimationComplete: enabled }),
|
||||||
|
|
||||||
@@ -392,6 +396,14 @@ interface SettingsState {
|
|||||||
|
|
||||||
overrideCtrlV: boolean;
|
overrideCtrlV: boolean;
|
||||||
setOverrideCtrlV: (enabled: boolean) => void;
|
setOverrideCtrlV: (enabled: boolean) => void;
|
||||||
|
pasteShortcutEnabled: boolean;
|
||||||
|
setPasteShortcutEnabled: (enabled: boolean) => void;
|
||||||
|
pasteShortcut: string;
|
||||||
|
setPasteShortcut: (shortcut: string) => void;
|
||||||
|
ocrShortcutEnabled: boolean;
|
||||||
|
setOcrShortcutEnabled: (enabled: boolean) => void;
|
||||||
|
ocrShortcut: string;
|
||||||
|
setOcrShortcut: (shortcut: string) => void;
|
||||||
|
|
||||||
// Video enhancement settings
|
// Video enhancement settings
|
||||||
videoSaturation: number;
|
videoSaturation: number;
|
||||||
@@ -461,6 +473,14 @@ export const useSettingsStore = create(
|
|||||||
|
|
||||||
overrideCtrlV: false,
|
overrideCtrlV: false,
|
||||||
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
||||||
|
pasteShortcutEnabled: true,
|
||||||
|
setPasteShortcutEnabled: enabled => set({ pasteShortcutEnabled: enabled }),
|
||||||
|
pasteShortcut: "Ctrl+V",
|
||||||
|
setPasteShortcut: shortcut => set({ pasteShortcut: shortcut }),
|
||||||
|
ocrShortcutEnabled: true,
|
||||||
|
setOcrShortcutEnabled: enabled => set({ ocrShortcutEnabled: enabled }),
|
||||||
|
ocrShortcut: "Ctrl+C",
|
||||||
|
setOcrShortcut: shortcut => set({ ocrShortcut: shortcut }),
|
||||||
|
|
||||||
// Video enhancement settings with default values (1.0 = normal)
|
// Video enhancement settings with default values (1.0 = normal)
|
||||||
videoSaturation: 1.0,
|
videoSaturation: 1.0,
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
|
||||||
import { useClose } from "@headlessui/react";
|
import { useClose } from "@headlessui/react";
|
||||||
import { Checkbox, Button } from "antd";
|
import { Checkbox, Button, Input } from "antd";
|
||||||
import { useReactAt } from "i18n-auto-extractor/react";
|
import { useReactAt } from "i18n-auto-extractor/react";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
|
import { SettingsItem } from "@components/Settings/SettingsView";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useUiStore, useSettingsStore, useVideoStore } from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { layouts, chars } from "@/keyboardLayouts";
|
import { layouts, chars } from "@/keyboardLayouts";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
import { eventMatchesShortcut, shortcutFromKeyboardEvent } from "@/utils/shortcuts";
|
||||||
|
|
||||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||||
return { keys, modifier };
|
return { keys, modifier };
|
||||||
@@ -28,15 +30,24 @@ export default function Clipboard() {
|
|||||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
const setSidebarView = useUiStore(state => state.setSidebarView);
|
const setSidebarView = useUiStore(state => state.setSidebarView);
|
||||||
const toggleTopBarView = useUiStore(state => state.toggleTopBarView);
|
const toggleTopBarView = useUiStore(state => state.toggleTopBarView);
|
||||||
|
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||||
|
const setOcrMode = useUiStore(state => state.setOcrMode);
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
|
const videoWidth = useVideoStore(state => state.width);
|
||||||
|
const videoHeight = useVideoStore(state => state.height);
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||||
|
|
||||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||||
const close = useClose();
|
const close = useClose();
|
||||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
||||||
const setOverrideCtrlV = useSettingsStore(state => state.setOverrideCtrlV);
|
const setPasteShortcutEnabled = useSettingsStore(state => state.setPasteShortcutEnabled);
|
||||||
const [pasteBuffer, setPasteBuffer] = useState<string>("");
|
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
||||||
|
const setPasteShortcut = useSettingsStore(state => state.setPasteShortcut);
|
||||||
|
const ocrShortcutEnabled = useSettingsStore(state => state.ocrShortcutEnabled);
|
||||||
|
const setOcrShortcutEnabled = useSettingsStore(state => state.setOcrShortcutEnabled);
|
||||||
|
const ocrShortcut = useSettingsStore(state => state.ocrShortcut);
|
||||||
|
const setOcrShortcut = useSettingsStore(state => state.setOcrShortcut);
|
||||||
const [readyToRender, setReadyToRender] = useState(false);
|
const [readyToRender, setReadyToRender] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,7 +138,6 @@ export default function Clipboard() {
|
|||||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
||||||
|
|
||||||
const handleTextSend = useCallback(async (text: string) => {
|
const handleTextSend = useCallback(async (text: string) => {
|
||||||
setPasteBuffer(text);
|
|
||||||
const segInvalid = [
|
const segInvalid = [
|
||||||
...new Set(
|
...new Set(
|
||||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||||
@@ -192,12 +202,35 @@ export default function Clipboard() {
|
|||||||
}, [handleTextSend]);
|
}, [handleTextSend]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// When overrideCtrlV is true, we want to focus the container div to capture paste events
|
if (readyToRender && TextAreaRef.current) {
|
||||||
// When it is false, we want to focus the textarea if it exists
|
|
||||||
if (!overrideCtrlV && TextAreaRef.current) {
|
|
||||||
TextAreaRef.current.focus();
|
TextAreaRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [readyToRender, overrideCtrlV]);
|
}, [readyToRender]);
|
||||||
|
|
||||||
|
const handleShortcutInput = useCallback(
|
||||||
|
(setter: (shortcut: string) => void) => (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const shortcut = shortcutFromKeyboardEvent(e.nativeEvent);
|
||||||
|
if (!shortcut) return;
|
||||||
|
setter(shortcut);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenOcr = useCallback(() => {
|
||||||
|
if (videoWidth === 0 || videoHeight === 0) {
|
||||||
|
notifications.error($at("No video signal"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOcrMode(!isOcrMode);
|
||||||
|
close();
|
||||||
|
if (isMobile) {
|
||||||
|
toggleTopBarView("ClipboardMobile");
|
||||||
|
} else {
|
||||||
|
setSidebarView(null);
|
||||||
|
}
|
||||||
|
}, [videoWidth, videoHeight, $at, setOcrMode, isOcrMode, close, toggleTopBarView, setSidebarView]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 py-3" >
|
<div className="space-y-4 py-3" >
|
||||||
@@ -205,32 +238,42 @@ export default function Clipboard() {
|
|||||||
<div className="h-full space-y-4">
|
<div className="h-full space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={overrideCtrlV}
|
className="min-w-0"
|
||||||
onChange={e => setOverrideCtrlV(e.target.checked)}
|
checked={pasteShortcutEnabled}
|
||||||
|
onChange={e => setPasteShortcutEnabled(e.target.checked)}
|
||||||
>
|
>
|
||||||
{$at("Use Ctrl+V to paste clipboard to remote")}
|
<span className="whitespace-normal break-words">
|
||||||
|
{$at("Enable paste shortcut")}
|
||||||
|
</span>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={pasteShortcut}
|
||||||
|
onKeyDown={handleShortcutInput(setPasteShortcut)}
|
||||||
|
onChange={() => void 0}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full px-1 outline-none"
|
<div className="w-full px-1 outline-none"
|
||||||
tabIndex={overrideCtrlV ? 0 : -1}
|
tabIndex={pasteShortcutEnabled ? 0 : -1}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el && overrideCtrlV && readyToRender) {
|
if (el && pasteShortcutEnabled && readyToRender) {
|
||||||
el.focus();
|
el.focus();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyUp={e => e.stopPropagation()}
|
onKeyUp={e => e.stopPropagation()}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
|
if (pasteShortcutEnabled && eventMatchesShortcut(e.nativeEvent, pasteShortcut)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
readClipboardToBufferAndSend();
|
readClipboardToBufferAndSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPaste={e => {
|
onPaste={e => {
|
||||||
if (overrideCtrlV) {
|
if (pasteShortcutEnabled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const txt = e.clipboardData?.getData("text") || "";
|
const txt = e.clipboardData?.getData("text") || "";
|
||||||
if (txt) {
|
if (txt) {
|
||||||
@@ -240,7 +283,7 @@ export default function Clipboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{!overrideCtrlV && readyToRender && <TextAreaWithLabel
|
{readyToRender && <TextAreaWithLabel
|
||||||
ref={TextAreaRef}
|
ref={TextAreaRef}
|
||||||
label={$at("Copy text from your client to the remote host")}
|
label={$at("Copy text from your client to the remote host")}
|
||||||
rows={4}
|
rows={4}
|
||||||
@@ -295,32 +338,50 @@ export default function Clipboard() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex animate-fadeIn opacity-0 items-center justify-start gap-x-2"
|
className="flex animate-fadeIn opacity-0 flex-col gap-y-2"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.2s",
|
animationDelay: "0.2s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
||||||
type="primary"
|
type="primary"
|
||||||
className={isMobile ? "w-[49%]" : ""}
|
className="w-full"
|
||||||
onClick={onConfirmPaste}
|
onClick={onConfirmPaste}
|
||||||
>
|
>
|
||||||
{$at("Confirm paste")}</Button>
|
{$at("Confirm paste")}</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_140px] items-center gap-2 sm:grid-cols-[minmax(0,1fr)_180px]">
|
||||||
|
<Checkbox
|
||||||
|
className="min-w-0"
|
||||||
|
checked={ocrShortcutEnabled}
|
||||||
|
onChange={e => setOcrShortcutEnabled(e.target.checked)}
|
||||||
|
>
|
||||||
|
<span className="whitespace-normal break-words">
|
||||||
|
{$at("Enable OCR shortcut")}
|
||||||
|
</span>
|
||||||
|
</Checkbox>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={ocrShortcut}
|
||||||
|
onKeyDown={handleShortcutInput(setOcrShortcut)}
|
||||||
|
onChange={() => void 0}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsItem
|
||||||
|
title={$at("OCR")}
|
||||||
|
description={$at("Open OCR selection mode on the video area")}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
className={isMobile ? "w-[49%]" : ""}
|
type="primary"
|
||||||
onClick={() => {
|
className={`${isMobile ? "w-full" : ""}`}
|
||||||
onCancelPasteMode();
|
onClick={handleOpenOcr}
|
||||||
close();
|
>
|
||||||
if(isMobile){
|
{$at("Open OCR")}
|
||||||
toggleTopBarView("ClipboardMobile");
|
</Button>
|
||||||
}else{
|
</SettingsItem>
|
||||||
setSidebarView(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>{$at("Cancel")}</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import KeyboardPanel from "@/layout/components_bottom/keyboard/KeyboardPanel";
|
|||||||
import Clipboard from "@/layout/components_side/Clipboard/Clipboard";
|
import Clipboard from "@/layout/components_side/Clipboard/Clipboard";
|
||||||
import SettingsModal from "@/layout/components_setting";
|
import SettingsModal from "@/layout/components_setting";
|
||||||
import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
||||||
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore } from "@/hooks/stores";
|
import { useMacrosSideTitleState , useHidStore, useMouseStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||||
import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile";
|
import MobileTerminal from "@/layout/components_bottom/terminal/index.mobile";
|
||||||
import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color";
|
import { dark_bg_desktop, dark_bg_style_fun } from "@/layout/theme_color";
|
||||||
import PowerControl from "@/layout/components_side/Power";
|
import PowerControl from "@/layout/components_side/Power";
|
||||||
@@ -39,6 +39,7 @@ import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
|||||||
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
import UsbEpModeSelect from "@/layout/components_bottom/usbepmode/UsbEpModeSelect";
|
||||||
import VirtualMediaSource from "@/layout/components_side/VirtualMediaSource";
|
import VirtualMediaSource from "@/layout/components_side/VirtualMediaSource";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import OcrOverlay from "@components/OcrOverlay";
|
||||||
|
|
||||||
export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||||
const { $at } = useReactAt();
|
const { $at } = useReactAt();
|
||||||
@@ -49,6 +50,7 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
const zoomContainerRef = useRef<HTMLDivElement>(null);
|
const zoomContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||||
|
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||||
const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle);
|
const macrosSideTitle = useMacrosSideTitleState(state => state.sideTitle);
|
||||||
|
|
||||||
const videoEffects = useVideoEffects();
|
const videoEffects = useVideoEffects();
|
||||||
@@ -267,6 +269,7 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
`h-full w-full ${dark_bg_style_fun(isDark)} object-contain transition-all duration-1000`,
|
`h-full w-full ${dark_bg_style_fun(isDark)} object-contain transition-all duration-1000`,
|
||||||
{
|
{
|
||||||
"cursor-none": videoEffects.settings.isCursorHidden,
|
"cursor-none": videoEffects.settings.isCursorHidden,
|
||||||
|
"pointer-events-none": isOcrMode,
|
||||||
"opacity-0": overlays.shouldHideVideo,
|
"opacity-0": overlays.shouldHideVideo,
|
||||||
"opacity-60!": overlays.showPointerLockBar,
|
"opacity-60!": overlays.showPointerLockBar,
|
||||||
"animate-slideUpFade dark:border-slate-300/20":
|
"animate-slideUpFade dark:border-slate-300/20":
|
||||||
@@ -274,6 +277,10 @@ export default function MobileDesktop({ isFullscreen }: { isFullscreen?: number
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<OcrOverlay
|
||||||
|
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
|
||||||
|
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
|
||||||
|
/>
|
||||||
|
|
||||||
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { MacroMoreList } from "@/layout/components_side/Macros/MacroTopBar";
|
|||||||
import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
|
import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
|
||||||
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
|
||||||
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
|
||||||
|
import OcrOverlay from "@components/OcrOverlay";
|
||||||
|
|
||||||
export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
@@ -38,6 +39,7 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
|||||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
const setTerminalType = useUiStore(state => state.setTerminalType);
|
||||||
const terminalType = useUiStore(state => state.terminalType);
|
const terminalType = useUiStore(state => state.terminalType);
|
||||||
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
const setVirtualKeyboardEnabled = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||||
|
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||||
|
|
||||||
const forceHttp = useSettingsStore(state => state.forceHttp);
|
const forceHttp = useSettingsStore(state => state.forceHttp);
|
||||||
|
|
||||||
@@ -116,6 +118,7 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
|||||||
`max-h-full min-h-[384px] max-w-full min-w-[512px] object-contain transition-all duration-1000`,
|
`max-h-full min-h-[384px] max-w-full min-w-[512px] object-contain transition-all duration-1000`,
|
||||||
{
|
{
|
||||||
"cursor-none": videoEffects.settings.isCursorHidden,
|
"cursor-none": videoEffects.settings.isCursorHidden,
|
||||||
|
"pointer-events-none": isOcrMode,
|
||||||
"opacity-0": overlays.shouldHideVideo,
|
"opacity-0": overlays.shouldHideVideo,
|
||||||
"opacity-60!": overlays.showPointerLockBar,
|
"opacity-60!": overlays.showPointerLockBar,
|
||||||
"animate-slideUpFade shadow-xs ":
|
"animate-slideUpFade shadow-xs ":
|
||||||
@@ -123,6 +126,10 @@ export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<OcrOverlay
|
||||||
|
videoRef={videoElm as React.RefObject<HTMLVideoElement>}
|
||||||
|
containerRef={zoomContainerRef as React.RefObject<HTMLDivElement>}
|
||||||
|
/>
|
||||||
|
|
||||||
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
{(videoStream.peerConnectionState === "connected" || forceHttp) && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useHidStore, useSettingsStore } from "@/hooks/stores";
|
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
||||||
|
|
||||||
export const useKeyboardEvents = (
|
export const useKeyboardEvents = (
|
||||||
pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>,
|
pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>,
|
||||||
@@ -14,7 +15,9 @@ export const useKeyboardEvents = (
|
|||||||
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
||||||
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
||||||
const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable;
|
const isKeyboardLedManagedByHost = keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable;
|
||||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
||||||
|
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
||||||
|
const isOcrMode = useUiStore(state => state.isOcrMode);
|
||||||
|
|
||||||
const handleModifierKeys = useCallback((e: KeyboardEvent, activeModifiers: number[]) => {
|
const handleModifierKeys = useCallback((e: KeyboardEvent, activeModifiers: number[]) => {
|
||||||
const { shiftKey, ctrlKey, altKey, metaKey } = e;
|
const { shiftKey, ctrlKey, altKey, metaKey } = e;
|
||||||
@@ -28,8 +31,8 @@ export const useKeyboardEvents = (
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const keyDownHandler = useCallback(async (e: KeyboardEvent) => {
|
const keyDownHandler = useCallback(async (e: KeyboardEvent) => {
|
||||||
if (overrideCtrlV && (e.code === "KeyV" || e.key.toLowerCase() === "v") && (e.ctrlKey || e.metaKey)) {
|
if (isOcrMode) return;
|
||||||
console.log("Override Ctrl V");
|
if (pasteShortcutEnabled && eventMatchesShortcut(e, pasteShortcut)) {
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
if (pasteCaptureRef && pasteCaptureRef.current) {
|
if (pasteCaptureRef && pasteCaptureRef.current) {
|
||||||
pasteCaptureRef.current.value = "";
|
pasteCaptureRef.current.value = "";
|
||||||
@@ -74,9 +77,10 @@ export const useKeyboardEvents = (
|
|||||||
|
|
||||||
// Still update the full state for legacy compatibility and UI display
|
// Still update the full state for legacy compatibility and UI display
|
||||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
||||||
}, [handleModifierKeys, sendKeyboardEvent, sendKeypress, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, overrideCtrlV, pasteCaptureRef, isReinitializingGadget]);
|
}, [handleModifierKeys, sendKeyboardEvent, sendKeypress, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, isReinitializingGadget, isOcrMode]);
|
||||||
|
|
||||||
const keyUpHandler = useCallback((e: KeyboardEvent) => {
|
const keyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||||
|
if (isOcrMode) return;
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prev = useHidStore.getState();
|
const prev = useHidStore.getState();
|
||||||
@@ -101,7 +105,7 @@ export const useKeyboardEvents = (
|
|||||||
|
|
||||||
// Still update the full state for legacy compatibility and UI display
|
// Still update the full state for legacy compatibility and UI display
|
||||||
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
|
||||||
}, [handleModifierKeys, sendKeyboardEvent, sendKeypress, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive]);
|
}, [handleModifierKeys, sendKeyboardEvent, sendKeypress, isKeyboardLedManagedByHost, setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive, isOcrMode]);
|
||||||
|
|
||||||
const setupKeyboardEvents = useCallback(() => {
|
const setupKeyboardEvents = useCallback(() => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { useSettingsStore, useHidStore, useUiStore } from "@/hooks/stores";
|
|||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { chars } from "@/keyboardLayouts";
|
import { chars } from "@/keyboardLayouts";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
import { eventMatchesShortcut } from "@/utils/shortcuts";
|
||||||
|
|
||||||
export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>) => {
|
export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaElement>) => {
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
const pasteShortcutEnabled = useSettingsStore(state => state.pasteShortcutEnabled);
|
||||||
|
const pasteShortcut = useSettingsStore(state => state.pasteShortcut);
|
||||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||||
const setKeyboardLayout = useSettingsStore(state => state.setKeyboardLayout);
|
const setKeyboardLayout = useSettingsStore(state => state.setKeyboardLayout);
|
||||||
const debugMode = useSettingsStore(state => state.debugMode);
|
const debugMode = useSettingsStore(state => state.debugMode);
|
||||||
@@ -140,12 +142,11 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
|
|||||||
}, [log, send, setKeyboardLayout]);
|
}, [log, send, setKeyboardLayout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!overrideCtrlV) return;
|
if (!pasteShortcutEnabled) return;
|
||||||
|
|
||||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||||
if (!overrideCtrlV) return;
|
if (!pasteShortcutEnabled) return;
|
||||||
if (!(e.ctrlKey || e.metaKey)) return;
|
if (!eventMatchesShortcut(e, pasteShortcut)) return;
|
||||||
if (e.code !== "KeyV" && e.key.toLowerCase() !== "v") return;
|
|
||||||
if (isReinitializingGadget) return;
|
if (isReinitializingGadget) return;
|
||||||
|
|
||||||
const activeElement = document.activeElement as HTMLElement | null;
|
const activeElement = document.activeElement as HTMLElement | null;
|
||||||
@@ -196,20 +197,20 @@ export const usePasteHandler = (pasteCaptureRef?: React.RefObject<HTMLTextAreaEl
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", onKeyDownCapture, { capture: true });
|
document.removeEventListener("keydown", onKeyDownCapture, { capture: true });
|
||||||
};
|
};
|
||||||
}, [ensureFocusTrapPaused, isReinitializingGadget, log, overrideCtrlV, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
|
}, [ensureFocusTrapPaused, isReinitializingGadget, log, pasteShortcutEnabled, pasteShortcut, pasteCaptureRef, safeKeyboardLayout, sendTextToRemote]);
|
||||||
|
|
||||||
const handleGlobalPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement> | ClipboardEvent) => {
|
const handleGlobalPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement> | ClipboardEvent) => {
|
||||||
if (!overrideCtrlV) return;
|
if (!pasteShortcutEnabled) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const clipboardData = (e as React.ClipboardEvent).clipboardData || (e as ClipboardEvent).clipboardData;
|
const clipboardData = (e as React.ClipboardEvent).clipboardData || (e as ClipboardEvent).clipboardData;
|
||||||
const txt = clipboardData?.getData("text") || "";
|
const txt = clipboardData?.getData("text") || "";
|
||||||
|
|
||||||
await sendTextToRemote(txt);
|
await sendTextToRemote(txt);
|
||||||
}, [log, overrideCtrlV, safeKeyboardLayout, sendTextToRemote]);
|
}, [log, pasteShortcutEnabled, safeKeyboardLayout, sendTextToRemote]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleGlobalPaste,
|
handleGlobalPaste,
|
||||||
overrideCtrlV,
|
pasteShortcutEnabled,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user