mirror of
https://github.com/immich-app/immich.git
synced 2025-12-14 09:13:17 +03:00
Compare commits
3 Commits
revert-sve
...
push-qolzz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08ad95e89 | ||
|
|
06f0f8dc48 | ||
|
|
f35c4c3a1a |
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -815,6 +815,9 @@ importers:
|
|||||||
tabbable:
|
tabbable:
|
||||||
specifier: ^6.2.0
|
specifier: ^6.2.0
|
||||||
version: 6.3.0
|
version: 6.3.0
|
||||||
|
three:
|
||||||
|
specifier: ^0.179.1
|
||||||
|
version: 0.179.1
|
||||||
thumbhash:
|
thumbhash:
|
||||||
specifier: ^0.1.1
|
specifier: ^0.1.1
|
||||||
version: 0.1.1
|
version: 0.1.1
|
||||||
@@ -876,6 +879,9 @@ importers:
|
|||||||
'@types/qrcode':
|
'@types/qrcode':
|
||||||
specifier: ^1.5.5
|
specifier: ^1.5.5
|
||||||
version: 1.5.6
|
version: 1.5.6
|
||||||
|
'@types/three':
|
||||||
|
specifier: ^0.181.0
|
||||||
|
version: 0.181.0
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
|
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
|
||||||
@@ -2078,6 +2084,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.4
|
postcss: ^8.4
|
||||||
|
|
||||||
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
'@discoveryjs/json-ext@0.5.7':
|
'@discoveryjs/json-ext@0.5.7':
|
||||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -4640,6 +4649,9 @@ packages:
|
|||||||
'@turf/invariant@7.2.0':
|
'@turf/invariant@7.2.0':
|
||||||
resolution: {integrity: sha512-kV4u8e7Gkpq+kPbAKNC21CmyrXzlbBgFjO1PhrHPgEdNqXqDawoZ3i6ivE3ULJj2rSesCjduUaC/wyvH/sNr2Q==}
|
resolution: {integrity: sha512-kV4u8e7Gkpq+kPbAKNC21CmyrXzlbBgFjO1PhrHPgEdNqXqDawoZ3i6ivE3ULJj2rSesCjduUaC/wyvH/sNr2Q==}
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@23.1.3':
|
||||||
|
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||||
|
|
||||||
'@types/accepts@1.3.7':
|
'@types/accepts@1.3.7':
|
||||||
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
|
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
|
||||||
|
|
||||||
@@ -4960,6 +4972,9 @@ packages:
|
|||||||
'@types/ssh2@1.15.5':
|
'@types/ssh2@1.15.5':
|
||||||
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
|
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
|
||||||
|
|
||||||
|
'@types/stats.js@0.17.4':
|
||||||
|
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||||
|
|
||||||
'@types/superagent@8.1.9':
|
'@types/superagent@8.1.9':
|
||||||
resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==}
|
resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==}
|
||||||
|
|
||||||
@@ -4969,6 +4984,9 @@ packages:
|
|||||||
'@types/supertest@6.0.3':
|
'@types/supertest@6.0.3':
|
||||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
||||||
|
|
||||||
|
'@types/three@0.181.0':
|
||||||
|
resolution: {integrity: sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==}
|
||||||
|
|
||||||
'@types/through@0.0.33':
|
'@types/through@0.0.33':
|
||||||
resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==}
|
resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==}
|
||||||
|
|
||||||
@@ -4984,6 +5002,9 @@ packages:
|
|||||||
'@types/validator@13.15.10':
|
'@types/validator@13.15.10':
|
||||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||||
|
|
||||||
|
'@types/webxr@0.5.24':
|
||||||
|
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||||
|
|
||||||
'@types/whatwg-mimetype@3.0.2':
|
'@types/whatwg-mimetype@3.0.2':
|
||||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||||
|
|
||||||
@@ -5145,6 +5166,9 @@ packages:
|
|||||||
'@webassemblyjs/wast-printer@1.14.1':
|
'@webassemblyjs/wast-printer@1.14.1':
|
||||||
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
||||||
|
|
||||||
|
'@webgpu/types@0.1.67':
|
||||||
|
resolution: {integrity: sha512-uk53+2ECGUkWoDFez/hymwpRfdgdIn6y1ref70fEecGMe5607f4sozNFgBk0oxlr7j2CRGWBEc3IBYMmFdGGTQ==}
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0':
|
'@xtuc/ieee754@1.2.0':
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||||
|
|
||||||
@@ -8407,6 +8431,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
meshoptimizer@0.22.0:
|
||||||
|
resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
|
||||||
|
|
||||||
methods@1.1.2:
|
methods@1.1.2:
|
||||||
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -10934,9 +10961,6 @@ packages:
|
|||||||
three@0.179.1:
|
three@0.179.1:
|
||||||
resolution: {integrity: sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==}
|
resolution: {integrity: sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==}
|
||||||
|
|
||||||
three@0.180.0:
|
|
||||||
resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==}
|
|
||||||
|
|
||||||
throttleit@2.1.0:
|
throttleit@2.1.0:
|
||||||
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
|
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -13458,6 +13482,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
|
|
||||||
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
'@discoveryjs/json-ext@0.5.7': {}
|
'@discoveryjs/json-ext@0.5.7': {}
|
||||||
|
|
||||||
'@docsearch/css@4.2.0': {}
|
'@docsearch/css@4.2.0': {}
|
||||||
@@ -15629,7 +15655,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@photo-sphere-viewer/core': 5.14.0
|
'@photo-sphere-viewer/core': 5.14.0
|
||||||
'@photo-sphere-viewer/video-plugin': 5.14.0(@photo-sphere-viewer/core@5.14.0)
|
'@photo-sphere-viewer/video-plugin': 5.14.0(@photo-sphere-viewer/core@5.14.0)
|
||||||
three: 0.180.0
|
three: 0.179.1
|
||||||
|
|
||||||
'@photo-sphere-viewer/markers-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0)':
|
'@photo-sphere-viewer/markers-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -15647,7 +15673,7 @@ snapshots:
|
|||||||
'@photo-sphere-viewer/video-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0)':
|
'@photo-sphere-viewer/video-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@photo-sphere-viewer/core': 5.14.0
|
'@photo-sphere-viewer/core': 5.14.0
|
||||||
three: 0.180.0
|
three: 0.179.1
|
||||||
|
|
||||||
'@photostructure/tz-lookup@11.3.0': {}
|
'@photostructure/tz-lookup@11.3.0': {}
|
||||||
|
|
||||||
@@ -16559,6 +16585,8 @@ snapshots:
|
|||||||
'@types/geojson': 7946.0.16
|
'@types/geojson': 7946.0.16
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
'@types/accepts@1.3.7':
|
'@types/accepts@1.3.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.10.1
|
'@types/node': 24.10.1
|
||||||
@@ -16955,6 +16983,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 18.19.130
|
'@types/node': 18.19.130
|
||||||
|
|
||||||
|
'@types/stats.js@0.17.4': {}
|
||||||
|
|
||||||
'@types/superagent@8.1.9':
|
'@types/superagent@8.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/cookiejar': 2.1.5
|
'@types/cookiejar': 2.1.5
|
||||||
@@ -16971,6 +17001,16 @@ snapshots:
|
|||||||
'@types/methods': 1.1.4
|
'@types/methods': 1.1.4
|
||||||
'@types/superagent': 8.1.9
|
'@types/superagent': 8.1.9
|
||||||
|
|
||||||
|
'@types/three@0.181.0':
|
||||||
|
dependencies:
|
||||||
|
'@dimforge/rapier3d-compat': 0.12.0
|
||||||
|
'@tweenjs/tween.js': 23.1.3
|
||||||
|
'@types/stats.js': 0.17.4
|
||||||
|
'@types/webxr': 0.5.24
|
||||||
|
'@webgpu/types': 0.1.67
|
||||||
|
fflate: 0.8.2
|
||||||
|
meshoptimizer: 0.22.0
|
||||||
|
|
||||||
'@types/through@0.0.33':
|
'@types/through@0.0.33':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.10.1
|
'@types/node': 24.10.1
|
||||||
@@ -16983,6 +17023,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/validator@13.15.10': {}
|
'@types/validator@13.15.10': {}
|
||||||
|
|
||||||
|
'@types/webxr@0.5.24': {}
|
||||||
|
|
||||||
'@types/whatwg-mimetype@3.0.2': {}
|
'@types/whatwg-mimetype@3.0.2': {}
|
||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
@@ -17228,6 +17270,8 @@ snapshots:
|
|||||||
'@webassemblyjs/ast': 1.14.1
|
'@webassemblyjs/ast': 1.14.1
|
||||||
'@xtuc/long': 4.2.2
|
'@xtuc/long': 4.2.2
|
||||||
|
|
||||||
|
'@webgpu/types@0.1.67': {}
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0': {}
|
'@xtuc/ieee754@1.2.0': {}
|
||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
@@ -21121,6 +21165,8 @@ snapshots:
|
|||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
|
meshoptimizer@0.22.0: {}
|
||||||
|
|
||||||
methods@1.1.2: {}
|
methods@1.1.2: {}
|
||||||
|
|
||||||
micromark-core-commonmark@2.0.3:
|
micromark-core-commonmark@2.0.3:
|
||||||
@@ -24194,8 +24240,6 @@ snapshots:
|
|||||||
|
|
||||||
three@0.179.1: {}
|
three@0.179.1: {}
|
||||||
|
|
||||||
three@0.180.0: {}
|
|
||||||
|
|
||||||
throttleit@2.1.0: {}
|
throttleit@2.1.0: {}
|
||||||
|
|
||||||
through@2.3.8: {}
|
through@2.3.8: {}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"svelte-maplibre": "^1.2.5",
|
"svelte-maplibre": "^1.2.5",
|
||||||
"svelte-persisted-store": "^0.12.0",
|
"svelte-persisted-store": "^0.12.0",
|
||||||
"tabbable": "^6.2.0",
|
"tabbable": "^6.2.0",
|
||||||
|
"three": "^0.179.1",
|
||||||
"thumbhash": "^0.1.1",
|
"thumbhash": "^0.1.1",
|
||||||
"uplot": "^1.6.32"
|
"uplot": "^1.6.32"
|
||||||
},
|
},
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/three": "^0.181.0",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^17.0.0",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
|
|||||||
426
web/src/lib/components/asset-viewer/image-viewer.svelte
Normal file
426
web/src/lib/components/asset-viewer/image-viewer.svelte
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { MapControls } from 'three/addons/controls/MapControls.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Props
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
// Exported variables for external access
|
||||||
|
imageWidth?: number;
|
||||||
|
imageHeight?: number;
|
||||||
|
zoomPercent?: number;
|
||||||
|
cameraX?: number;
|
||||||
|
cameraY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
imageUrl = '',
|
||||||
|
|
||||||
|
imageWidth = $bindable(0),
|
||||||
|
imageHeight = $bindable(0),
|
||||||
|
zoomPercent = $bindable(100),
|
||||||
|
cameraX = $bindable(0),
|
||||||
|
cameraY = $bindable(0),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Internal state
|
||||||
|
let container: HTMLDivElement = $state();
|
||||||
|
let canvas: HTMLCanvasElement = $state();
|
||||||
|
let scene: THREE.Scene = $state();
|
||||||
|
let camera: THREE.OrthographicCamera;
|
||||||
|
let renderer: THREE.WebGLRenderer = $state();
|
||||||
|
let controls: MapControls;
|
||||||
|
let imageMesh: THREE.Mesh | null = null;
|
||||||
|
let initialZoom: number = 1;
|
||||||
|
let currentZoom: number = 1;
|
||||||
|
|
||||||
|
// Reactive statements
|
||||||
|
let canvasSize = $derived(
|
||||||
|
container ? `${renderer?.domElement.width || 0} × ${renderer?.domElement.height || 0}` : 'Not initialized',
|
||||||
|
);
|
||||||
|
let imageDims = $derived(imageWidth && imageHeight ? `${imageWidth} × ${imageHeight}` : 'Not loaded');
|
||||||
|
|
||||||
|
function initThreeJS() {
|
||||||
|
if (!container || !canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create scene
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x00_00_00);
|
||||||
|
|
||||||
|
// Get initial canvas display size
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
|
||||||
|
// Create orthographic camera
|
||||||
|
const aspect = width / height;
|
||||||
|
const frustumSize = 1000;
|
||||||
|
camera = new THREE.OrthographicCamera(
|
||||||
|
(frustumSize * aspect) / -2,
|
||||||
|
(frustumSize * aspect) / 2,
|
||||||
|
frustumSize / 2,
|
||||||
|
frustumSize / -2,
|
||||||
|
0.1,
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
camera.position.z = 10;
|
||||||
|
|
||||||
|
// Create renderer
|
||||||
|
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
|
// Pass false to prevent setting inline styles - CSS handles display size
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
// Cap pixel ratio at 2 for performance (most displays are 1x or 2x)
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
// Use sRGB color space for correct color rendering (matches browser's <img> behavior)
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
// Initialize MapControls - handles all pan/zoom/touch interactions
|
||||||
|
controls = new MapControls(camera, canvas);
|
||||||
|
controls.enableRotate = false; // Disable rotation for 2D viewing
|
||||||
|
controls.zoomToCursor = true; // Zoom toward mouse cursor
|
||||||
|
controls.screenSpacePanning = true; // Pan in screen space
|
||||||
|
controls.minZoom = 0.1; // Will be updated after image loads
|
||||||
|
controls.maxZoom = 20;
|
||||||
|
controls.zoomSpeed = 3; // Faster zoom response (default is 1)
|
||||||
|
controls.panSpeed = 3;
|
||||||
|
controls.enableDamping = true; // Disable damping for instant response
|
||||||
|
controls.dampingFactor = 0.1;
|
||||||
|
|
||||||
|
// Configure mouse buttons - LEFT button only for panning
|
||||||
|
controls.mouseButtons = {
|
||||||
|
LEFT: THREE.MOUSE.PAN,
|
||||||
|
MIDDLE: THREE.MOUSE.DOLLY,
|
||||||
|
RIGHT: undefined, // Disable right-click to allow context menu
|
||||||
|
};
|
||||||
|
|
||||||
|
controls.addEventListener('change', () => {
|
||||||
|
constrainPan(); // Constrain panning to keep image in viewport
|
||||||
|
updateExportedVars();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAndHandleResize() {
|
||||||
|
if (!canvas || !renderer || !camera) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual display size of the canvas
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
|
||||||
|
// Check if the canvas drawing buffer size needs to be updated
|
||||||
|
const needsResize = canvas.width !== width || canvas.height !== height;
|
||||||
|
|
||||||
|
if (needsResize) {
|
||||||
|
// Update the drawing buffer size to match display size
|
||||||
|
// Pass false to prevent three.js from setting inline styles
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
|
||||||
|
// Update camera aspect
|
||||||
|
const aspect = width / height;
|
||||||
|
const frustumSize = 1000;
|
||||||
|
camera.left = (frustumSize * aspect) / -2;
|
||||||
|
camera.right = (frustumSize * aspect) / 2;
|
||||||
|
camera.top = frustumSize / 2;
|
||||||
|
camera.bottom = frustumSize / -2;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
if (imageMesh && controls) {
|
||||||
|
// Preserve the user's zoom level during resize
|
||||||
|
const userZoomLevel = camera.zoom;
|
||||||
|
|
||||||
|
// Recalculate the mesh scale to fit the new frustum
|
||||||
|
const canvasAspect = width / height;
|
||||||
|
const imageAspect = imageWidth / imageHeight;
|
||||||
|
const newScale =
|
||||||
|
imageAspect > canvasAspect
|
||||||
|
? (camera.right - camera.left) / imageWidth
|
||||||
|
: (camera.top - camera.bottom) / imageHeight;
|
||||||
|
|
||||||
|
// Update mesh scale to fit new viewport
|
||||||
|
imageMesh.scale.set(newScale, newScale, 1);
|
||||||
|
|
||||||
|
// Maintain user's zoom level
|
||||||
|
camera.zoom = userZoomLevel;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsResize;
|
||||||
|
}
|
||||||
|
|
||||||
|
function constrainPan() {
|
||||||
|
if (!controls || !camera || !imageMesh) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bounding box of the image mesh in world space
|
||||||
|
const bbox = new THREE.Box3().setFromObject(imageMesh);
|
||||||
|
|
||||||
|
// Calculate the visible area based on camera frustum (adjusted for zoom)
|
||||||
|
const viewWidth = (camera.right - camera.left) / camera.zoom;
|
||||||
|
const viewHeight = (camera.top - camera.bottom) / camera.zoom;
|
||||||
|
|
||||||
|
// Calculate image size in world space
|
||||||
|
const imageWidth = bbox.max.x - bbox.min.x;
|
||||||
|
const imageHeight = bbox.max.y - bbox.min.y;
|
||||||
|
|
||||||
|
// Calculate pan limits
|
||||||
|
// If image is smaller than view, center it (no panning)
|
||||||
|
// If image is larger than view, allow panning to see all parts
|
||||||
|
let minX, maxX, minY, maxY;
|
||||||
|
|
||||||
|
if (imageWidth <= viewWidth) {
|
||||||
|
// Image fits horizontally - center it
|
||||||
|
minX = maxX = 0;
|
||||||
|
} else {
|
||||||
|
// Image is larger - allow panning within bounds
|
||||||
|
const halfViewWidth = viewWidth / 2;
|
||||||
|
minX = bbox.min.x + halfViewWidth;
|
||||||
|
maxX = bbox.max.x - halfViewWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageHeight <= viewHeight) {
|
||||||
|
// Image fits vertically - center it
|
||||||
|
minY = maxY = 0;
|
||||||
|
} else {
|
||||||
|
// Image is larger - allow panning within bounds
|
||||||
|
const halfViewHeight = viewHeight / 2;
|
||||||
|
minY = bbox.min.y + halfViewHeight;
|
||||||
|
maxY = bbox.max.y - halfViewHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp the camera position within bounds
|
||||||
|
camera.position.x = Math.max(minX, Math.min(maxX, camera.position.x));
|
||||||
|
camera.position.y = Math.max(minY, Math.min(maxY, camera.position.y));
|
||||||
|
|
||||||
|
// Clamp the controls target to match
|
||||||
|
controls.target.x = camera.position.x;
|
||||||
|
controls.target.y = camera.position.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateInitialView() {
|
||||||
|
if (!imageMesh || !container || !camera || !controls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasAspect = container.clientWidth / container.clientHeight;
|
||||||
|
const imageAspect = imageWidth / imageHeight;
|
||||||
|
|
||||||
|
// Scale the mesh to fit within the frustum
|
||||||
|
// The frustum is 1000 units, so scale image to fit within that
|
||||||
|
let scale;
|
||||||
|
scale =
|
||||||
|
imageAspect > canvasAspect
|
||||||
|
? (camera.right - camera.left) / imageWidth
|
||||||
|
: (camera.top - camera.bottom) / imageHeight;
|
||||||
|
|
||||||
|
// Apply scale to mesh so it fits in the viewport at zoom=1
|
||||||
|
imageMesh.scale.set(scale, scale, 1);
|
||||||
|
|
||||||
|
initialZoom = 1;
|
||||||
|
currentZoom = 1;
|
||||||
|
|
||||||
|
// Reset camera zoom to 1 (mesh is already scaled to fit)
|
||||||
|
camera.zoom = 1;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
// Center camera and controls target
|
||||||
|
camera.position.set(0, 0, 10);
|
||||||
|
controls.target.set(0, 0, 0);
|
||||||
|
controls.update();
|
||||||
|
|
||||||
|
// Set minimum zoom to prevent zooming out beyond initial fit
|
||||||
|
controls.minZoom = 1;
|
||||||
|
|
||||||
|
updateExportedVars();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(url: string) {
|
||||||
|
if (!url || !scene) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textureLoader = new THREE.TextureLoader();
|
||||||
|
textureLoader.setCrossOrigin('anonymous');
|
||||||
|
|
||||||
|
textureLoader.load(
|
||||||
|
url,
|
||||||
|
function (texture) {
|
||||||
|
imageWidth = texture.image.width;
|
||||||
|
imageHeight = texture.image.height;
|
||||||
|
|
||||||
|
// Configure texture for high quality zooming
|
||||||
|
texture.minFilter = THREE.LinearFilter; // Smooth when zoomed out
|
||||||
|
texture.magFilter = THREE.LinearFilter; // Smooth when zoomed in
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace; // Use sRGB color space to match browser rendering
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
|
||||||
|
// Remove old mesh if exists
|
||||||
|
if (imageMesh) {
|
||||||
|
scene.remove(imageMesh);
|
||||||
|
imageMesh.geometry.dispose();
|
||||||
|
(imageMesh.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create plane geometry with image dimensions
|
||||||
|
const geometry = new THREE.PlaneGeometry(imageWidth, imageHeight);
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
|
||||||
|
imageMesh = new THREE.Mesh(geometry, material);
|
||||||
|
scene.add(imageMesh);
|
||||||
|
|
||||||
|
calculateInitialView();
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
function (error) {
|
||||||
|
console.error('Error loading image:', error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoop() {
|
||||||
|
if (!renderer || !scene || !camera || !controls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check and handle resize on every frame to prevent flickering
|
||||||
|
checkAndHandleResize();
|
||||||
|
|
||||||
|
// Update controls (handles damping/inertia)
|
||||||
|
controls.update();
|
||||||
|
|
||||||
|
// Render the scene
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRenderLoop() {
|
||||||
|
if (!renderer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Use Three.js's setAnimationLoop for better integration and XR support
|
||||||
|
renderer.setAnimationLoop(renderLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRenderLoop() {
|
||||||
|
if (!renderer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Pass null to stop the animation loop
|
||||||
|
renderer.setAnimationLoop(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExportedVars() {
|
||||||
|
if (!camera) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// MapControls uses camera.zoom property
|
||||||
|
zoomPercent = Math.round((camera.zoom / initialZoom) * 100);
|
||||||
|
cameraX = Math.round(camera.position.x);
|
||||||
|
cameraY = Math.round(camera.position.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetView() {
|
||||||
|
if (!camera || !controls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reset camera zoom and position
|
||||||
|
camera.zoom = initialZoom;
|
||||||
|
camera.position.set(0, 0, 10);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controls.target.set(0, 0, 0);
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom keyboard shortcuts (MapControls handles arrow keys, +/-, etc.)
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!camera || !controls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'r':
|
||||||
|
case 'R': {
|
||||||
|
e.preventDefault();
|
||||||
|
resetView();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// MapControls automatically handles: arrow keys for pan, +/- for zoom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMount(() => {
|
||||||
|
initThreeJS();
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
loadImage(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the continuous render loop (handles resize checks automatically)
|
||||||
|
startRenderLoop();
|
||||||
|
|
||||||
|
// Keyboard events on document
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
// Cleanup
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
|
// Stop the render loop
|
||||||
|
stopRenderLoop();
|
||||||
|
|
||||||
|
// Dispose controls
|
||||||
|
if (controls) {
|
||||||
|
controls.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageMesh) {
|
||||||
|
imageMesh.geometry.dispose();
|
||||||
|
(imageMesh.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderer) {
|
||||||
|
renderer.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for imageUrl changes
|
||||||
|
$effect(() => {
|
||||||
|
if (imageUrl && scene) {
|
||||||
|
loadImage(imageUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-viewer-container h-full w-full" bind:this={container}>
|
||||||
|
<!-- MapControls handles all mouse/touch interactions automatically -->
|
||||||
|
<canvas bind:this={canvas}></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-viewer-container {
|
||||||
|
position: relative;
|
||||||
|
background: #222;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
|
import ImageViewer from '$lib/components/asset-viewer/image-viewer.svelte';
|
||||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
import { assetViewerFadeDuration } from '$lib/constants';
|
import { assetViewerFadeDuration } from '$lib/constants';
|
||||||
@@ -158,7 +159,8 @@
|
|||||||
// when true, will force loading of the original image
|
// when true, will force loading of the original image
|
||||||
let forceUseOriginal: boolean = $derived(
|
let forceUseOriginal: boolean = $derived(
|
||||||
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
|
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
|
||||||
$photoZoomState.currentZoom > 1,
|
$photoZoomState.currentZoom > 1 ||
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetImageSize = $derived.by(() => {
|
const targetImageSize = $derived.by(() => {
|
||||||
@@ -250,42 +252,44 @@
|
|||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
{:else if !imageError}
|
{:else if !imageError}
|
||||||
<div
|
<ImageViewer imageUrl={assetFileUrl}></ImageViewer>
|
||||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
<div style="display:none;">
|
||||||
{...useSwipe(onSwipe)}
|
<div
|
||||||
class="h-full w-full"
|
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
{...useSwipe(onSwipe)}
|
||||||
>
|
class="h-full w-full"
|
||||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||||
|
>
|
||||||
|
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||||
|
<img
|
||||||
|
src={assetFileUrl}
|
||||||
|
alt=""
|
||||||
|
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<img
|
<img
|
||||||
|
bind:this={$photoViewerImgElement}
|
||||||
src={assetFileUrl}
|
src={assetFileUrl}
|
||||||
alt=""
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||||
|
? 'object-contain'
|
||||||
|
: slideshowLookCssMapping[$slideshowLook]}"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
{/if}
|
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||||
<img
|
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
|
||||||
bind:this={$photoViewerImgElement}
|
<div
|
||||||
src={assetFileUrl}
|
class="absolute border-solid border-white border-3 rounded-lg"
|
||||||
alt={$getAltText(toTimelineAsset(asset))}
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
></div>
|
||||||
? 'object-contain'
|
{/each}
|
||||||
: slideshowLookCssMapping[$slideshowLook]}"
|
|
||||||
draggable="false"
|
|
||||||
/>
|
|
||||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
|
||||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
|
|
||||||
<div
|
|
||||||
class="absolute border-solid border-white border-3 rounded-lg"
|
|
||||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
|
||||||
></div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||||
<OcrBoundingBox {ocrBox} />
|
<OcrBoundingBox {ocrBox} />
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isFaceEditMode.value}
|
{#if isFaceEditMode.value}
|
||||||
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user