mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 09:13:13 +03:00
Compare commits
4 Commits
feat/editi
...
a08ad95e89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08ad95e89 | ||
|
|
ba6687dde9 | ||
|
|
06f0f8dc48 | ||
|
|
f35c4c3a1a |
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -815,6 +815,9 @@ importers:
|
||||
tabbable:
|
||||
specifier: ^6.2.0
|
||||
version: 6.3.0
|
||||
three:
|
||||
specifier: ^0.179.1
|
||||
version: 0.179.1
|
||||
thumbhash:
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1
|
||||
@@ -876,6 +879,9 @@ importers:
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.6
|
||||
'@types/three':
|
||||
specifier: ^0.181.0
|
||||
version: 0.181.0
|
||||
'@vitest/coverage-v8':
|
||||
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))
|
||||
@@ -2078,6 +2084,9 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.4
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0':
|
||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7':
|
||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -4640,6 +4649,9 @@ packages:
|
||||
'@turf/invariant@7.2.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
|
||||
|
||||
@@ -4960,6 +4972,9 @@ packages:
|
||||
'@types/ssh2@1.15.5':
|
||||
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
|
||||
|
||||
'@types/stats.js@0.17.4':
|
||||
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==}
|
||||
|
||||
@@ -4969,6 +4984,9 @@ packages:
|
||||
'@types/supertest@6.0.3':
|
||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
||||
|
||||
'@types/three@0.181.0':
|
||||
resolution: {integrity: sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==}
|
||||
|
||||
'@types/through@0.0.33':
|
||||
resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==}
|
||||
|
||||
@@ -4984,6 +5002,9 @@ packages:
|
||||
'@types/validator@13.15.10':
|
||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||
|
||||
'@types/webxr@0.5.24':
|
||||
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
@@ -5145,6 +5166,9 @@ packages:
|
||||
'@webassemblyjs/wast-printer@1.14.1':
|
||||
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':
|
||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||
|
||||
@@ -8407,6 +8431,9 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
meshoptimizer@0.22.0:
|
||||
resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
|
||||
|
||||
methods@1.1.2:
|
||||
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -10934,9 +10961,6 @@ packages:
|
||||
three@0.179.1:
|
||||
resolution: {integrity: sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==}
|
||||
|
||||
three@0.180.0:
|
||||
resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==}
|
||||
|
||||
throttleit@2.1.0:
|
||||
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -13458,6 +13482,8 @@ snapshots:
|
||||
dependencies:
|
||||
postcss: 8.5.6
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7': {}
|
||||
|
||||
'@docsearch/css@4.2.0': {}
|
||||
@@ -15629,7 +15655,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@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)':
|
||||
dependencies:
|
||||
@@ -15647,7 +15673,7 @@ snapshots:
|
||||
'@photo-sphere-viewer/video-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0)':
|
||||
dependencies:
|
||||
'@photo-sphere-viewer/core': 5.14.0
|
||||
three: 0.180.0
|
||||
three: 0.179.1
|
||||
|
||||
'@photostructure/tz-lookup@11.3.0': {}
|
||||
|
||||
@@ -16559,6 +16585,8 @@ snapshots:
|
||||
'@types/geojson': 7946.0.16
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tweenjs/tween.js@23.1.3': {}
|
||||
|
||||
'@types/accepts@1.3.7':
|
||||
dependencies:
|
||||
'@types/node': 24.10.1
|
||||
@@ -16955,6 +16983,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
|
||||
'@types/stats.js@0.17.4': {}
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
dependencies:
|
||||
'@types/cookiejar': 2.1.5
|
||||
@@ -16971,6 +17001,16 @@ snapshots:
|
||||
'@types/methods': 1.1.4
|
||||
'@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':
|
||||
dependencies:
|
||||
'@types/node': 24.10.1
|
||||
@@ -16983,6 +17023,8 @@ snapshots:
|
||||
|
||||
'@types/validator@13.15.10': {}
|
||||
|
||||
'@types/webxr@0.5.24': {}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
@@ -17228,6 +17270,8 @@ snapshots:
|
||||
'@webassemblyjs/ast': 1.14.1
|
||||
'@xtuc/long': 4.2.2
|
||||
|
||||
'@webgpu/types@0.1.67': {}
|
||||
|
||||
'@xtuc/ieee754@1.2.0': {}
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
@@ -21121,6 +21165,8 @@ snapshots:
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
meshoptimizer@0.22.0: {}
|
||||
|
||||
methods@1.1.2: {}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
@@ -24194,8 +24240,6 @@ snapshots:
|
||||
|
||||
three@0.179.1: {}
|
||||
|
||||
three@0.180.0: {}
|
||||
|
||||
throttleit@2.1.0: {}
|
||||
|
||||
through@2.3.8: {}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"svelte-maplibre": "^1.2.5",
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"tabbable": "^6.2.0",
|
||||
"three": "^0.179.1",
|
||||
"thumbhash": "^0.1.1",
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
@@ -83,6 +84,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/three": "^0.181.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"dotenv": "^17.0.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 { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
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 BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
@@ -158,7 +159,8 @@
|
||||
// when true, will force loading of the original image
|
||||
let forceUseOriginal: boolean = $derived(
|
||||
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
|
||||
$photoZoomState.currentZoom > 1,
|
||||
$photoZoomState.currentZoom > 1 ||
|
||||
true,
|
||||
);
|
||||
|
||||
const targetImageSize = $derived.by(() => {
|
||||
@@ -250,42 +252,44 @@
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<ImageViewer imageUrl={assetFileUrl}></ImageViewer>
|
||||
<div style="display:none;">
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
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
|
||||
bind:this={$photoViewerImgElement}
|
||||
src={assetFileUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={$photoViewerImgElement}
|
||||
src={assetFileUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: 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}
|
||||
<!-- 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)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { Button, IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SearchHistoryBox from './search-history-box.svelte';
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
let isSearchSuggestions = $state(false);
|
||||
let selectedId: string | undefined = $state();
|
||||
let close: (() => Promise<void>) | undefined;
|
||||
let showSearchTypeDropdown = $state(false);
|
||||
let currentSearchType = $state('smart');
|
||||
|
||||
const listboxId = generateId();
|
||||
const searchTypeId = generateId();
|
||||
@@ -70,6 +72,7 @@
|
||||
|
||||
const onFocusIn = () => {
|
||||
searchStore.isSearchEnabled = true;
|
||||
getSearchType();
|
||||
};
|
||||
|
||||
const onFocusOut = () => {
|
||||
@@ -98,6 +101,9 @@
|
||||
const searchResult = await result.onClose;
|
||||
close = undefined;
|
||||
|
||||
// Refresh search type after modal closes
|
||||
getSearchType();
|
||||
|
||||
if (!searchResult) {
|
||||
return;
|
||||
}
|
||||
@@ -139,6 +145,7 @@
|
||||
|
||||
const onEscape = () => {
|
||||
closeDropdown();
|
||||
closeSearchTypeDropdown();
|
||||
};
|
||||
|
||||
const onArrow = async (direction: 1 | -1) => {
|
||||
@@ -168,6 +175,20 @@
|
||||
searchHistoryBox?.clearSelection();
|
||||
};
|
||||
|
||||
const toggleSearchTypeDropdown = () => {
|
||||
showSearchTypeDropdown = !showSearchTypeDropdown;
|
||||
};
|
||||
|
||||
const closeSearchTypeDropdown = () => {
|
||||
showSearchTypeDropdown = false;
|
||||
};
|
||||
|
||||
const selectSearchType = (type: string) => {
|
||||
localStorage.setItem('searchQueryType', type);
|
||||
currentSearchType = type;
|
||||
showSearchTypeDropdown = false;
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
@@ -180,17 +201,18 @@
|
||||
case 'metadata':
|
||||
case 'description':
|
||||
case 'ocr': {
|
||||
currentSearchType = searchType;
|
||||
return searchType;
|
||||
}
|
||||
default: {
|
||||
currentSearchType = 'smart';
|
||||
return 'smart';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchTypeText(): string {
|
||||
const searchType = getSearchType();
|
||||
switch (searchType) {
|
||||
switch (currentSearchType) {
|
||||
case 'smart': {
|
||||
return $t('context');
|
||||
}
|
||||
@@ -203,8 +225,22 @@
|
||||
case 'ocr': {
|
||||
return $t('ocr');
|
||||
}
|
||||
default: {
|
||||
return $t('context');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
getSearchType();
|
||||
});
|
||||
|
||||
const searchTypes = [
|
||||
{ value: 'smart', label: () => $t('context') },
|
||||
{ value: 'metadata', label: () => $t('filename') },
|
||||
{ value: 'description', label: () => $t('description') },
|
||||
{ value: 'ocr', label: () => $t('ocr') },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -293,11 +329,34 @@
|
||||
class:max-md:hidden={value}
|
||||
class:end-28={value.length > 0}
|
||||
>
|
||||
<p
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs"
|
||||
>
|
||||
{getSearchTypeText()}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<Button
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onclick={toggleSearchTypeDropdown}
|
||||
aria-expanded={showSearchTypeDropdown}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{getSearchTypeText()}
|
||||
</Button>
|
||||
|
||||
{#if showSearchTypeDropdown}
|
||||
<div
|
||||
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
|
||||
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
|
||||
>
|
||||
{#each searchTypes as searchType (searchType.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
|
||||
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
|
||||
onclick={() => selectSearchType(searchType.value)}
|
||||
>
|
||||
{searchType.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user