phase 1 & 2 add text mark

This commit is contained in:
Julian Freeman
2026-01-18 23:22:52 -04:00
parent a588caf743
commit 0c5824d85c
10 changed files with 659 additions and 44 deletions

View File

@@ -1,11 +1,15 @@
<script setup lang="ts">
import HeroView from "./components/HeroView.vue";
import ThumbnailStrip from "./components/ThumbnailStrip.vue";
import SettingsPanel from "./components/SettingsPanel.vue";
import { useGalleryStore } from "./stores/gallery";
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { FolderOpen, Download } from 'lucide-vue-next';
import { ref } from "vue";
const store = useGalleryStore();
const isExporting = ref(false);
async function openFolder() {
try {
@@ -25,26 +29,80 @@ async function openFolder() {
console.error("Failed to open folder:", e);
}
}
async function exportBatch() {
if (store.images.length === 0) return;
if (!store.watermarkSettings.text) {
alert("Please enter watermark text.");
return;
}
try {
const outputDir = await open({
directory: true,
multiple: false,
title: "Select Output Directory"
});
if (outputDir && typeof outputDir === 'string') {
isExporting.value = true;
await invoke('export_batch', {
images: store.images,
watermark: store.watermarkSettings,
outputDir: outputDir
});
alert("Batch export completed!");
}
} catch (e) {
console.error("Export failed:", e);
alert("Export failed: " + e);
} finally {
isExporting.value = false;
}
}
</script>
<template>
<div class="h-screen w-screen bg-gray-900 text-white overflow-hidden flex flex-col">
<header class="h-12 bg-gray-800 flex items-center justify-between px-4 border-b border-gray-700 shrink-0">
<h1 class="text-sm font-bold tracking-wider">WATERMARK WIZARD</h1>
<button
@click="openFolder"
class="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded text-sm transition-colors"
>
Open Folder
</button>
<header class="h-14 bg-gray-800 flex items-center justify-between px-6 border-b border-gray-700 shrink-0 shadow-md z-10">
<div class="flex items-center gap-4">
<h1 class="text-lg font-bold tracking-wider bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">WATERMARK WIZARD</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="openFolder"
class="flex items-center gap-2 bg-gray-700 hover:bg-gray-600 text-gray-200 px-4 py-2 rounded-md text-sm font-medium transition-all hover:shadow-lg"
>
<FolderOpen class="w-4 h-4" />
Open Folder
</button>
<button
@click="exportBatch"
:disabled="isExporting"
class="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:cursor-not-allowed text-white px-4 py-2 rounded-md text-sm font-medium transition-all hover:shadow-lg shadow-blue-900/20"
>
<Download class="w-4 h-4" v-if="!isExporting" />
<div v-else class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
{{ isExporting ? 'Exporting...' : 'Export Batch' }}
</button>
</div>
</header>
<main class="flex-1 relative bg-black overflow-hidden">
<HeroView />
</main>
<footer class="h-32 bg-gray-800 border-t border-gray-700 shrink-0">
<ThumbnailStrip />
</footer>
<div class="flex-1 flex overflow-hidden">
<main class="flex-1 relative bg-black flex flex-col min-w-0">
<div class="flex-1 relative overflow-hidden">
<HeroView />
</div>
<footer class="h-32 bg-gray-800 border-t border-gray-700 shrink-0 z-10">
<ThumbnailStrip />
</footer>
</main>
<aside class="w-80 shrink-0 h-full border-l border-gray-700">
<SettingsPanel />
</aside>
</div>
</div>
</template>

View File

@@ -1,40 +1,147 @@
<script setup lang="ts">
import { useGalleryStore } from "../stores/gallery";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ref, computed } from "vue";
const store = useGalleryStore();
const isDragging = ref(false);
const dragStart = ref({ x: 0, y: 0 });
// Use either manual position (if override is true) or ZCA suggestion
const position = computed(() => {
if (store.watermarkSettings.manual_override) {
return store.watermarkSettings.manual_position;
}
return store.selectedImage?.zcaSuggestion ? { x: store.selectedImage.zcaSuggestion.x, y: store.selectedImage.zcaSuggestion.y } : { x: 0.5, y: 0.5 };
});
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
isDragging.value = true;
dragStart.value = { x: e.clientX, y: e.clientY };
};
const onMouseMove = (e: MouseEvent) => {
if (!isDragging.value || !store.selectedImage) return;
// Calculate delta in percentage relative to the image container
const container = (e.currentTarget as HTMLElement).closest('.image-container');
if (!container) return;
const rect = container.getBoundingClientRect();
const deltaX = (e.clientX - dragStart.value.x) / rect.width;
const deltaY = (e.clientY - dragStart.value.y) / rect.height;
// Update manual position
let newX = position.value.x + deltaX;
let newY = position.value.y + deltaY;
// Clamp
newX = Math.max(0, Math.min(1, newX));
newY = Math.max(0, Math.min(1, newY));
// Set store to manual mode immediately
store.updateWatermarkSettings({
manual_override: true,
manual_position: { x: newX, y: newY }
});
dragStart.value = { x: e.clientX, y: e.clientY };
};
const onMouseUp = () => {
isDragging.value = false;
};
const onMouseLeave = () => {
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>
<template>
<div class="w-full h-full flex items-center justify-center bg-black relative p-4 overflow-hidden">
<div v-if="store.selectedImage" class="relative inline-flex justify-center items-center" style="max-width: 100%; max-height: 100%;">
<div
class="w-full h-full flex items-center justify-center bg-black relative p-4 overflow-hidden"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseLeave"
>
<div
v-if="store.selectedImage"
class="relative inline-flex justify-center items-center image-container"
style="max-width: 100%; max-height: 100%;"
>
<img
:src="convertFileSrc(store.selectedImage.path)"
class="max-w-full max-h-full w-auto h-auto block shadow-lg"
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);"
alt="Hero Image"
/>
<!-- Watermark Overlay Placeholder -->
<!-- Text Watermark Overlay -->
<div
v-if="store.selectedImage.zcaSuggestion"
class="absolute border-2 border-dashed border-green-400 text-green-400 px-4 py-2 bg-black/50 pointer-events-none transition-all duration-500"
v-if="store.watermarkSettings.text"
class="absolute cursor-move select-none whitespace-nowrap font-sans font-medium"
:style="{
left: (store.selectedImage.zcaSuggestion.x * 100) + '%',
top: (store.selectedImage.zcaSuggestion.y * 100) + '%',
transform: 'translate(-50%, -50%)'
left: (position.x * 100) + '%',
top: (position.y * 100) + '%',
transform: 'translate(-50%, -50%)',
opacity: store.watermarkSettings.opacity,
color: store.watermarkSettings.color,
fontSize: (store.watermarkSettings.scale * 100 * 1.5) + 'cqh',
/* Using container query units or just % of height?
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',
alignItems: 'center',
justifyContent: 'center'
}"
@mousedown="onMouseDown"
>
Smart Watermark ({{ store.selectedImage.zcaSuggestion.zone }})
</div>
<div
v-else
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 border-2 border-dashed border-white text-white px-4 py-2 bg-black/50 pointer-events-none"
>
Calculating...
<span :style="{ fontSize: (store.watermarkSettings.scale * 1000) + '%' }">
<!-- 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 }}
</span>
<!-- Selection Ring when dragging -->
<div v-if="isDragging" class="absolute -inset-2 border border-blue-500 rounded-sm"></div>
</div>
</div>
<div v-else class="text-gray-500">
No image selected
<div v-else class="text-gray-500 flex flex-col items-center">
<p>No image selected</p>
</div>
</div>
</template>
<style scoped>
.image-container {
container-type: size;
}
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);
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { useGalleryStore } from "../stores/gallery";
import { Settings, CheckSquare, Type, Palette } from 'lucide-vue-next';
const store = useGalleryStore();
</script>
<template>
<div class="h-full bg-gray-800 text-white p-4 flex flex-col gap-6 overflow-y-auto border-l border-gray-700 w-80">
<h2 class="text-lg font-bold flex items-center gap-2">
<Settings class="w-5 h-5" />
Watermark Settings
</h2>
<!-- Text Input -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Content</label>
<div class="relative">
<Type class="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
<input
type="text"
v-model="store.watermarkSettings.text"
class="w-full bg-gray-700 text-white pl-10 pr-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Enter text..."
/>
</div>
</div>
<!-- Color Picker -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Color</label>
<div class="flex items-center gap-2 bg-gray-700 p-2 rounded border border-gray-600">
<Palette class="w-4 h-4 text-gray-400" />
<input
type="color"
v-model="store.watermarkSettings.color"
class="w-8 h-8 rounded cursor-pointer bg-transparent border-none p-0"
/>
<span class="text-xs text-gray-300 font-mono">{{ store.watermarkSettings.color }}</span>
</div>
</div>
<!-- Controls -->
<div class="space-y-4">
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">Size (Scale)</label>
<span class="text-xs text-gray-300">{{ (store.watermarkSettings.scale * 100).toFixed(1) }}%</span>
</div>
<input
type="range"
min="0.01"
max="0.20"
step="0.001"
:value="store.watermarkSettings.scale"
@input="e => store.updateWatermarkSettings({ scale: parseFloat((e.target as HTMLInputElement).value) })"
class="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<p class="text-[10px] text-gray-500 mt-1">Relative to image height</p>
</div>
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">Opacity</label>
<span class="text-xs text-gray-300">{{ (store.watermarkSettings.opacity * 100).toFixed(0) }}%</span>
</div>
<input
type="range"
min="0.1"
max="1.0"
step="0.01"
:value="store.watermarkSettings.opacity"
@input="e => store.updateWatermarkSettings({ opacity: parseFloat((e.target as HTMLInputElement).value) })"
class="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
<!-- Placement Mode -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Placement</label>
<div class="flex items-center gap-2 p-2 rounded bg-gray-700/50 border border-gray-600">
<div class="p-1 rounded bg-green-500/20 text-green-400">
<CheckSquare class="w-4 h-4" v-if="!store.watermarkSettings.manual_override" />
<div class="w-4 h-4" v-else></div>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-gray-200">Smart Auto-Placement</p>
<p class="text-xs text-gray-500">Uses ZCA algorithm for each image</p>
</div>
</div>
<p class="text-xs text-gray-500 mt-1 italic">
* Dragging the watermark in the preview will enable Manual Override for all images.
</p>
</div>
</div>
</template>

View File

@@ -11,9 +11,28 @@ export interface ImageItem {
zcaSuggestion?: { x: number; y: number; zone: string };
}
export interface WatermarkSettings {
type: 'text'; // Fixed to text
text: string;
color: string; // Hex
opacity: number; // 0-1
scale: number; // 0.01 - 0.5 (relative to image height)
manual_override: boolean;
manual_position: { x: number, y: number };
}
export const useGalleryStore = defineStore("gallery", () => {
const images = ref<ImageItem[]>([]);
const selectedIndex = ref<number>(-1);
const watermarkSettings = ref<WatermarkSettings>({
type: 'text',
text: 'Watermark',
color: '#FFFFFF',
opacity: 0.8,
scale: 0.05, // 5% of height default
manual_override: false,
manual_position: { x: 0.5, y: 0.9 }
});
const selectedImage = computed(() => {
if (selectedIndex.value >= 0 && selectedIndex.value < images.value.length) {
@@ -26,6 +45,10 @@ export const useGalleryStore = defineStore("gallery", () => {
images.value = newImages;
selectedIndex.value = -1;
}
function updateWatermarkSettings(settings: Partial<WatermarkSettings>) {
watermarkSettings.value = { ...watermarkSettings.value, ...settings };
}
async function selectImage(index: number) {
if (index < 0 || index >= images.value.length) return;
@@ -35,8 +58,6 @@ export const useGalleryStore = defineStore("gallery", () => {
if (!img.zcaSuggestion) {
try {
const suggestion = await invoke<{x: number, y: number, zone: string}>("get_zca_suggestion", { path: img.path });
// Update the item in the array
// Note: Directly modifying the object inside ref array is reactive in Vue 3
img.zcaSuggestion = suggestion;
} catch (e) {
console.error("ZCA failed", e);
@@ -48,7 +69,9 @@ export const useGalleryStore = defineStore("gallery", () => {
images,
selectedIndex,
selectedImage,
watermarkSettings,
setImages,
selectImage,
updateWatermarkSettings
};
});