Compare commits

...

4 Commits

Author SHA1 Message Date
Min Idzelis
a08ad95e89 Merge branch 'main' into push-qolzzzzxrvvn 2025-12-04 06:17:20 -05:00
Yaros
ba6687dde9 feat(web): search type selection dropdown (#24091)
* feat(web): search type selection dropdown

* chore: implement suggestions

* lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-04 04:10:12 +00:00
Min Idzelis
06f0f8dc48 Merge branch 'main' into push-qolzzzzxrvvn 2025-12-03 22:16:40 -05:00
midzelis
f35c4c3a1a draft - webgl viewer 2025-12-04 02:13:55 +00:00
5 changed files with 582 additions and 47 deletions

58
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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",

View 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>

View File

@@ -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}

View File

@@ -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}