fix preview
This commit is contained in:
@@ -1,32 +1,92 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize, Clone)]
|
||||||
struct ImageItem {
|
struct ImageItem {
|
||||||
path: String,
|
path: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
thumbnail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_cache_dir() -> std::path::PathBuf {
|
||||||
|
let mut path = env::temp_dir();
|
||||||
|
path.push("watermark-wizard-thumbs");
|
||||||
|
if !path.exists() {
|
||||||
|
let _ = fs::create_dir_all(&path);
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_thumbnail(original_path: &Path) -> Option<String> {
|
||||||
|
let cache_dir = get_cache_dir();
|
||||||
|
|
||||||
|
// Generate simple hash for filename
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
original_path.hash(&mut hasher);
|
||||||
|
let hash = hasher.finish();
|
||||||
|
let file_name = format!("{}.jpg", hash);
|
||||||
|
let thumb_path = cache_dir.join(file_name);
|
||||||
|
|
||||||
|
// Return if exists
|
||||||
|
if thumb_path.exists() {
|
||||||
|
return Some(thumb_path.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate
|
||||||
|
// Use image reader to be faster? open is fine.
|
||||||
|
if let Ok(img) = image::open(original_path) {
|
||||||
|
// Resize to height 200, preserve ratio
|
||||||
|
let thumb = img.thumbnail(u32::MAX, 200);
|
||||||
|
// Save as jpeg with quality 80
|
||||||
|
let mut file = fs::File::create(&thumb_path).ok()?;
|
||||||
|
// thumb.write_to(&mut file, image::ImageOutputFormat::Jpeg(80)).ok()?;
|
||||||
|
// write_to might be slow due to encoding, but parallel execution helps.
|
||||||
|
// save directly
|
||||||
|
thumb.save_with_format(&thumb_path, image::ImageFormat::Jpeg).ok()?;
|
||||||
|
|
||||||
|
return Some(thumb_path.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn scan_dir(path: String) -> Result<Vec<ImageItem>, String> {
|
async fn scan_dir(path: String) -> Result<Vec<ImageItem>, String> {
|
||||||
let mut images = Vec::new();
|
let entries = fs::read_dir(&path).map_err(|e| e.to_string())?;
|
||||||
let dir = fs::read_dir(&path).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
for entry in dir {
|
// Collect valid paths first to avoid holding fs locks or iterators during parallel proc
|
||||||
|
let mut valid_paths = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
if let Ok(entry) = entry {
|
if let Ok(entry) = entry {
|
||||||
let path = entry.path();
|
let p = entry.path();
|
||||||
if path.is_file() {
|
if p.is_file() {
|
||||||
if let Some(ext) = path.extension() {
|
if let Some(ext) = p.extension() {
|
||||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||||
if ["png", "jpg", "jpeg", "webp"].contains(&ext_str.as_str()) {
|
if ["png", "jpg", "jpeg", "webp"].contains(&ext_str.as_str()) {
|
||||||
images.push(ImageItem {
|
valid_paths.push(p);
|
||||||
path: path.to_string_lossy().to_string(),
|
|
||||||
name: path.file_name().unwrap_or_default().to_string_lossy().to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process in parallel
|
||||||
|
let mut images: Vec<ImageItem> = valid_paths.par_iter().filter_map(|path| {
|
||||||
|
let name = path.file_name()?.to_string_lossy().to_string();
|
||||||
|
let path_str = path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Generate thumbnail
|
||||||
|
let thumb = generate_thumbnail(path).unwrap_or_else(|| path_str.clone());
|
||||||
|
|
||||||
|
Some(ImageItem {
|
||||||
|
path: path_str,
|
||||||
|
name,
|
||||||
|
thumbnail: thumb,
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
// Sort by name
|
// Sort by name
|
||||||
images.sort_by(|a, b| a.name.cmp(&b.name));
|
images.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
Ok(images)
|
Ok(images)
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useGalleryStore } from "../stores/gallery";
|
import { useGalleryStore } from "../stores/gallery";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||||
|
|
||||||
const store = useGalleryStore();
|
const store = useGalleryStore();
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const dragStart = ref({ x: 0, y: 0 });
|
const dragStart = ref({ x: 0, y: 0 });
|
||||||
|
const imgRef = ref<HTMLImageElement | null>(null);
|
||||||
|
const renderHeight = ref(0);
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const updateHeight = () => {
|
||||||
|
if (imgRef.value) {
|
||||||
|
renderHeight.value = imgRef.value.clientHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
updateHeight();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (resizeObserver) resizeObserver.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for image ref availability
|
||||||
|
watch(imgRef, (el) => {
|
||||||
|
if (el && resizeObserver) {
|
||||||
|
resizeObserver.disconnect(); // clear old
|
||||||
|
resizeObserver.observe(el);
|
||||||
|
// Force update immediately
|
||||||
|
// Wait for load? ResizeObserver usually handles layout shifts
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Use either manual position (if override is true) or ZCA suggestion
|
// Use either manual position (if override is true) or ZCA suggestion
|
||||||
const position = computed(() => {
|
const position = computed(() => {
|
||||||
@@ -56,12 +86,6 @@ const onMouseUp = () => {
|
|||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate font size relative to current display image height
|
|
||||||
// We need to know the rendered height of the image to approximate the visual effect
|
|
||||||
// Backend uses "percentage of real image height".
|
|
||||||
// In CSS, if we use % of container height, it should match if container matches image aspect.
|
|
||||||
// Since we use inline-flex and the img determines size, 100% height of parent refers to the image height.
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -77,10 +101,16 @@ const onMouseLeave = () => {
|
|||||||
style="max-width: 100%; max-height: 100%;"
|
style="max-width: 100%; max-height: 100%;"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
ref="imgRef"
|
||||||
:src="convertFileSrc(store.selectedImage.path)"
|
:src="convertFileSrc(store.selectedImage.path)"
|
||||||
class="max-w-full max-h-full w-auto h-auto block shadow-lg select-none pointer-events-none"
|
class="max-w-full max-h-full w-auto h-auto block shadow-lg select-none pointer-events-none"
|
||||||
style="max-height: calc(100vh - 10rem);"
|
style="max-height: calc(100vh - 10rem);"
|
||||||
alt="Hero Image"
|
alt="Hero Image"
|
||||||
|
loading="eager"
|
||||||
|
decoding="sync"
|
||||||
|
fetchpriority="high"
|
||||||
|
@load="updateHeight"
|
||||||
|
@error="(e) => console.error('Hero Image Load Error:', e)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Text Watermark Overlay -->
|
<!-- Text Watermark Overlay -->
|
||||||
@@ -93,34 +123,15 @@ const onMouseLeave = () => {
|
|||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
opacity: store.watermarkSettings.opacity,
|
opacity: store.watermarkSettings.opacity,
|
||||||
color: store.watermarkSettings.color,
|
color: store.watermarkSettings.color,
|
||||||
fontSize: (store.watermarkSettings.scale * 100 * 1.5) + 'cqh',
|
fontSize: (renderHeight * store.watermarkSettings.scale) + 'px',
|
||||||
/* Using container query units or just % of height?
|
height: '0px',
|
||||||
Since container is the div wrapping img, its height IS the img height.
|
|
||||||
So height: 100% = img height.
|
|
||||||
fontSize: X% of height.
|
|
||||||
*/
|
|
||||||
height: '0px', /* collapse container height so it doesn't affect layout, rely on overflow visible for text */
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center'
|
justifyContent: 'center'
|
||||||
}"
|
}"
|
||||||
@mousedown="onMouseDown"
|
@mousedown="onMouseDown"
|
||||||
>
|
>
|
||||||
<span :style="{ fontSize: (store.watermarkSettings.scale * 1000) + '%' }">
|
<span>
|
||||||
<!-- This is tricky in CSS. Let's use viewport units approximation or just fix it
|
|
||||||
The simplest way to scale text with container height in Vue without ResizeObserver
|
|
||||||
is to use a computed style that relies on container size if we knew it.
|
|
||||||
Actually, since we are inside the container, we can use % of height for font-size? No, font-size % refers to parent font size.
|
|
||||||
|
|
||||||
Hack: Viewport units are stable-ish.
|
|
||||||
Better: Render text in SVG overlaid?
|
|
||||||
|
|
||||||
Simple approach for MVP Preview:
|
|
||||||
Use a fixed visual size or simple scale multiplier assuming 1080p screen.
|
|
||||||
Real backend logic is exact.
|
|
||||||
|
|
||||||
Alternative: Use CSS 'container-type: size' on the parent!
|
|
||||||
-->
|
|
||||||
{{ store.watermarkSettings.text }}
|
{{ store.watermarkSettings.text }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -136,12 +147,10 @@ const onMouseLeave = () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.image-container {
|
.image-container {
|
||||||
container-type: size;
|
/* Removed container-type: size to prevent layout collapse */
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
/* Font size relative to container height (cqh) */
|
|
||||||
font-size: v-bind('(store.watermarkSettings.scale * 100) + "cqh"');
|
|
||||||
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ const onSelect = (index: number) => {
|
|||||||
@click="onSelect(index)"
|
@click="onSelect(index)"
|
||||||
>
|
>
|
||||||
<div class="h-full w-full bg-gray-800 flex items-center justify-center overflow-hidden rounded border border-gray-600">
|
<div class="h-full w-full bg-gray-800 flex items-center justify-center overflow-hidden rounded border border-gray-600">
|
||||||
<!-- Use actual thumbnail path later -->
|
<!-- Use generated thumbnail -->
|
||||||
<img :src="convertFileSrc(item.path)" class="w-full h-full object-cover" loading="lazy" />
|
<img
|
||||||
|
:src="convertFileSrc(item.thumbnail)"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority="low"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
|
|
||||||
export interface ImageItem {
|
export interface ImageItem {
|
||||||
path: string;
|
path: string;
|
||||||
thumbnail?: string;
|
thumbnail: string; // Now mandatory from backend
|
||||||
name: string;
|
name: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user