Compare commits

...

6 Commits

Author SHA1 Message Date
Ömer Duran
354e137af5 Frontend: Delete useless comments 2025-07-01 11:02:30 +02:00
Ömer Duran
5b0ffa201b Merge remote-tracking branch 'origin/develop' into manually-mark-faces 2025-06-30 07:33:53 +02:00
Ömer Duran
9cade913be Frontend: Adjust face editor wrapper size based on image dimensions 2025-06-10 08:28:33 +02:00
Ömer Duran
8743f294d8 Frontend: Improve face marker interaction 2025-06-10 08:25:08 +02:00
Ömer Duran
5f6e2df243 Frontend: Enhance face editor UI #1548 2025-06-07 03:09:18 +02:00
Ömer Duran
46a60f8747 Frontend: Face marker editing functionality in photo editor #1548 2025-06-07 02:51:23 +02:00
4 changed files with 712 additions and 20 deletions

View File

@@ -0,0 +1,543 @@
<template>
<div class="photo-face-editor">
<div v-if="primaryFile" class="photo-preview-container mb-4">
<div
ref="photoPreviewWrapper"
class="photo-preview-wrapper"
:style="{ cursor: wrapperCursor }"
@mousedown="handleWrapperMouseDown"
>
<img ref="photoPreview" :src="photoUrl" class="photo-preview" @load="onPhotoLoaded" />
<div
v-for="marker in markers"
:key="marker.UID"
:ref="(el) => (markerRefs[marker.UID] = el)"
class="face-marker"
:class="{
'face-marker-selected': selectedMarker?.UID === marker.UID,
'face-marker-editing': isEditingMarker && selectedMarker?.UID === marker.UID,
}"
:style="getMarkerStyle(marker)"
@mousedown.stop="handleMarkerMouseDown($event, marker)"
@mouseenter="hoveredMarkerUID = marker.UID"
@mouseleave="hoveredMarkerUID = null"
>
<div v-if="selectedMarker?.UID === marker.UID || hoveredMarkerUID === marker.UID" class="face-marker-name">
{{ marker.Name || $gettext("Unnamed") }}
<div class="face-marker-actions">
<v-btn
v-if="!marker.SubjUID && !marker.Invalid"
size="x-small"
variant="text"
icon
color="error"
density="compact"
@click.stop="onReject(marker)"
>
<v-icon size="x-small">mdi-close</v-icon>
</v-btn>
</div>
</div>
<template v-if="selectedMarker?.UID === marker.UID && isEditingMarker && !interaction.active">
<div
class="marker-handle handle-nw"
@mousedown.stop="handleResizeHandleMouseDown($event, marker, 'nw')"
></div>
<div
class="marker-handle handle-ne"
@mousedown.stop="handleResizeHandleMouseDown($event, marker, 'ne')"
></div>
<div
class="marker-handle handle-sw"
@mousedown.stop="handleResizeHandleMouseDown($event, marker, 'sw')"
></div>
<div
class="marker-handle handle-se"
@mousedown.stop="handleResizeHandleMouseDown($event, marker, 'se')"
></div>
</template>
</div>
<div
v-if="interaction.drawingPreview"
class="face-marker face-marker-new-preview"
:style="getPreviewMarkerStyle()"
></div>
</div>
<div class="photo-actions mt-2 d-flex justify-space-between">
<div>
<v-btn
:color="isDrawingMode ? 'primary' : 'default'"
variant="outlined"
prepend-icon="mdi-plus"
class="mr-2"
@click="toggleDrawingMode"
>
{{ isDrawingMode ? $gettext("Cancel") : $gettext("Add Face Marker") }}
</v-btn>
<v-btn v-if="isEditingMarker" color="primary" variant="outlined" @click="saveMarkerChanges">
{{ $gettext("Save Changes") }}
</v-btn>
</div>
<div>
<v-btn color="success" variant="outlined" prepend-icon="mdi-check" @click="$emit('close')">
{{ $gettext("Done") }}
</v-btn>
</div>
</div>
</div>
</div>
</template>
<script>
import Marker from "model/marker";
import $api from "common/api";
import "../../../css/face-markers.css";
export default {
name: "PPhotoFaceEditor",
props: {
uid: {
type: String,
default: "",
},
primaryFile: {
type: Object,
default: null,
},
initialMarkers: {
type: Array,
default: () => [],
},
},
emits: ["close", "markers-updated"],
data() {
return {
markers: [],
markerRefs: {},
busy: false,
disabled: !this.$config.feature("edit"),
readonly: this.$config.get("readonly"),
mode: null,
selectedMarker: null,
hoveredMarkerUID: null,
interaction: {
active: false,
type: null,
startX: 0,
startY: 0,
wrapperRect: null,
initialMarkerRect: null,
resizeHandle: null,
drawingPreview: null,
},
photoAspectRatio: 1,
photoLoaded: false,
};
},
computed: {
photoUrl() {
if (!this.primaryFile) return "";
return `${this.$config.contentUri}/t/${this.primaryFile.Hash}/${this.$config.previewToken}/fit_1280`;
},
isDrawingMode() {
return this.mode === "drawing";
},
isEditingMarker() {
return this.selectedMarker && this.mode === "editing";
},
canResize() {
return this.isEditingMarker && !this.interaction.active;
},
wrapperCursor() {
if (this.isDrawingMode) return "crosshair";
if (this.interaction.active && this.interaction.type === "moving") return "grabbing";
if (this.interaction.active && this.interaction.type === "resizing") {
const handle = this.interaction.resizeHandle;
if (handle === "nw" || handle === "se") return "nwse-resize";
if (handle === "ne" || handle === "sw") return "nesw-resize";
}
return "default";
},
},
watch: {
initialMarkers: {
handler(newMarkers) {
this.markers = newMarkers.map((markerData) => new Marker(markerData));
},
immediate: true,
},
},
mounted() {
window.addEventListener("resize", this.updateWrapperRect);
if (this.$refs.photoPreviewWrapper) {
this.updateWrapperRect();
}
},
beforeUnmount() {
window.removeEventListener("resize", this.updateWrapperRect);
this.removeDocumentListeners();
},
methods: {
updateWrapperRect() {
if (this.$refs.photoPreviewWrapper && this.$refs.photoPreview) {
if (this.photoLoaded) {
const img = this.$refs.photoPreview;
const imgRect = img.getBoundingClientRect();
const wrapper = this.$refs.photoPreviewWrapper;
wrapper.style.width = imgRect.width + "px";
wrapper.style.height = imgRect.height + "px";
}
const rect = this.$refs.photoPreviewWrapper.getBoundingClientRect();
if (this.interaction.active) {
this.interaction.wrapperRect = rect;
}
}
},
onPhotoLoaded(event) {
const img = event.target;
this.photoAspectRatio = img.naturalWidth / img.naturalHeight;
this.photoLoaded = true;
this.$nextTick(() => {
if (this.$refs.photoPreviewWrapper) {
const wrapper = this.$refs.photoPreviewWrapper;
const imgRect = img.getBoundingClientRect();
wrapper.style.width = imgRect.width + "px";
wrapper.style.height = imgRect.height + "px";
}
this.updateWrapperRect();
});
},
getMarkerStyle(marker) {
return {
left: marker.X * 100 + "%",
top: marker.Y * 100 + "%",
width: marker.W * 100 + "%",
height: marker.H * 100 + "%",
};
},
getPreviewMarkerStyle() {
if (!this.interaction.drawingPreview) return {};
const preview = this.interaction.drawingPreview;
return {
left: preview.x + "%",
top: preview.y + "%",
width: preview.w + "%",
height: preview.h + "%",
};
},
selectAndEditMarker(marker) {
this.selectedMarker = marker;
this.mode = "editing";
// Initialize interaction state for the selected marker
if (!this.interaction.initialMarkerRect) {
this.interaction.initialMarkerRect = {
X: marker.X,
Y: marker.Y,
W: marker.W,
H: marker.H,
};
}
},
addDocumentListeners() {
document.addEventListener("mousemove", this.handleDocumentMouseMove);
document.addEventListener("mouseup", this.handleDocumentMouseUp);
},
removeDocumentListeners() {
document.removeEventListener("mousemove", this.handleDocumentMouseMove);
document.removeEventListener("mouseup", this.handleDocumentMouseUp);
},
handleWrapperMouseDown(event) {
if (this.isDrawingMode && event.button === 0) {
this.startInteraction("drawing");
this.setDrawingStart(event);
}
},
handleMarkerMouseDown(event, marker) {
if (event.button === 0) {
this.selectAndEditMarker(marker);
this.startInteraction("moving", marker);
this.setInteractionStart(event);
}
},
handleResizeHandleMouseDown(event, marker, handle) {
if (event.button === 0) {
this.selectAndEditMarker(marker);
this.startInteraction("resizing", marker, handle);
this.setInteractionStart(event);
}
},
handleDocumentMouseMove(event) {
if (!this.interaction.active) return;
window.requestAnimationFrame(() => {
const { type } = this.interaction;
if (type === "drawing") this.handleDrawing(event);
else if (type === "moving") this.handleMoving(event);
else if (type === "resizing") this.handleResizing(event);
});
},
handleDocumentMouseUp() {
if (!this.interaction.active) return;
const { type, drawingPreview } = this.interaction;
if (type === "drawing" && drawingPreview) {
if (drawingPreview.w > 0.01 && drawingPreview.h > 0.01) {
this.createMarker(
drawingPreview.x / 100,
drawingPreview.y / 100,
drawingPreview.w / 100,
drawingPreview.h / 100
);
}
}
this.interaction.active = false;
this.removeDocumentListeners();
},
setDrawingStart(event) {
const rect = this.interaction.wrapperRect;
if (!rect) return;
this.interaction.startX = (event.clientX - rect.left) / rect.width;
this.interaction.startY = (event.clientY - rect.top) / rect.height;
},
setInteractionStart(event) {
this.interaction.startX = event.clientX;
this.interaction.startY = event.clientY;
},
handleDrawing(event) {
const rect = this.interaction.wrapperRect;
if (!rect) return;
const currentX = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const currentY = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
const startX = this.interaction.startX;
const startY = this.interaction.startY;
this.interaction.drawingPreview = {
x: Math.min(startX, currentX) * 100,
y: Math.min(startY, currentY) * 100,
w: Math.abs(currentX - startX) * 100,
h: Math.abs(currentY - startY) * 100,
};
},
handleMoving(event) {
if (!this.selectedMarker || !this.interaction.wrapperRect) return;
const rect = this.interaction.wrapperRect;
const deltaX = (event.clientX - this.interaction.startX) / rect.width;
const deltaY = (event.clientY - this.interaction.startY) / rect.height;
const initial = this.interaction.initialMarkerRect;
let newX = initial.X + deltaX;
let newY = initial.Y + deltaY;
newX = Math.max(0, Math.min(1 - initial.W, newX));
newY = Math.max(0, Math.min(1 - initial.H, newY));
this.selectedMarker.X = newX;
this.selectedMarker.Y = newY;
this.updateMarkerElement(this.selectedMarker);
},
handleResizing(event) {
if (!this.selectedMarker || !this.interaction.wrapperRect) return;
const rect = this.interaction.wrapperRect;
const mouseX = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
const mouseY = Math.max(0, Math.min(rect.height, event.clientY - rect.top));
const initial = this.interaction.initialMarkerRect;
const handle = this.interaction.resizeHandle;
let newX = initial.X,
newY = initial.Y,
newW = initial.W,
newH = initial.H;
if (handle.includes("w")) {
newX = mouseX / rect.width;
newW = initial.X + initial.W - newX;
}
if (handle.includes("e")) {
newW = (mouseX - initial.X * rect.width) / rect.width;
}
if (handle.includes("n")) {
newY = mouseY / rect.height;
newH = initial.Y + initial.H - newY;
}
if (handle.includes("s")) {
newH = (mouseY - initial.Y * rect.height) / rect.height;
}
newW = Math.max(0.01, newW);
newH = Math.max(0.01, newH);
newX = Math.max(0, Math.min(1 - newW, newX));
newY = Math.max(0, Math.min(1 - newH, newY));
this.selectedMarker.X = newX;
this.selectedMarker.Y = newY;
this.selectedMarker.W = newW;
this.selectedMarker.H = newH;
this.updateMarkerElement(this.selectedMarker);
},
updateMarkerElement(marker) {
const markerRef = this.markerRefs[marker.UID];
if (markerRef) {
markerRef.style.left = `${marker.X * 100}%`;
markerRef.style.top = `${marker.Y * 100}%`;
markerRef.style.width = `${marker.W * 100}%`;
markerRef.style.height = `${marker.H * 100}%`;
}
},
createMarker(x, y, w, h) {
if (!this.primaryFile) return;
this.busy = true;
this.$notify.blockUI("busy");
const markerData = {
FileUID: this.primaryFile.UID,
Type: "face",
Src: "manual",
X: x,
Y: y,
W: w,
H: h,
MarkerName: "",
MarkerReview: false,
MarkerInvalid: false,
};
$api
.post("markers", markerData)
.then((response) => {
const newMarker = new Marker(response.data);
this.markers.push(newMarker);
this.$nextTick(() => {
// Wait for DOM to fully render the new marker
this.$nextTick(() => {
// Clear any active interaction before selecting new marker
this.interaction.active = false;
this.interaction.type = null;
this.interaction.drawingPreview = null;
this.selectAndEditMarker(newMarker);
});
});
this.$notify.success(this.$gettext("Face marker added"));
this.$emit("markers-updated", this.markers);
})
.catch((error) => {
console.error("Error creating marker:", error);
this.$notify.error(this.$gettext("Failed to add face marker"));
this.mode = null;
})
.finally(() => {
this.busy = false;
this.$notify.unblockUI();
});
},
updateMarkerPosition(marker) {
if (!marker || this.busy) return;
const original = this.interaction.initialMarkerRect;
if (
original &&
marker.X === original.X &&
marker.Y === original.Y &&
marker.W === original.W &&
marker.H === original.H
) {
this.$notify.info(this.$gettext("No changes to save."));
this.resetState();
return;
}
this.busy = true;
this.$notify.blockUI("busy");
const markerDataToUpdate = {
FileUID: marker.FileUID,
Type: marker.Type,
Src: marker.Src,
SubjSrc: marker.SubjSrc,
Name: marker.Name,
MarkerReview: marker.Review,
Invalid: marker.Invalid,
X: marker.X,
Y: marker.Y,
W: marker.W,
H: marker.H,
UpdatePosition: true,
};
$api
.put(`markers/${marker.UID}`, markerDataToUpdate)
.then((response) => {
const serverMarkerData = response.data;
const index = this.markers.findIndex((m) => m.UID === marker.UID);
if (index !== -1) {
const localMarker = this.markers[index];
Object.assign(localMarker, serverMarkerData);
if (localMarker.Thumb) {
const timestamp = new Date().getTime();
const baseUrl = localMarker.thumbnailUrl("tile_320");
localMarker.thumbWithTimestamp = `${baseUrl}?ts=${timestamp}`;
}
this.updateMarkerElement(localMarker);
}
this.$notify.success(this.$gettext("Face marker updated"));
this.$emit("markers-updated", this.markers);
})
.catch((error) => {
console.error("Error updating marker:", error);
this.$notify.error(this.$gettext("Failed to update face marker"));
const index = this.markers.findIndex((m) => m.UID === marker.UID);
if (index !== -1 && this.interaction.initialMarkerRect) {
Object.assign(this.markers[index], this.interaction.initialMarkerRect);
this.updateMarkerElement(this.markers[index]);
}
})
.finally(() => {
this.busy = false;
this.$notify.unblockUI();
this.resetState();
});
},
toggleDrawingMode() {
if (this.isDrawingMode) {
this.resetState();
} else {
this.mode = "drawing";
this.selectedMarker = null;
}
},
onReject(model) {
if (this.busy || !model) return;
this.busy = true;
this.$notify.blockUI("busy");
model.reject().finally(() => {
this.$notify.unblockUI();
this.busy = false;
this.$emit("markers-updated", this.markers);
});
},
resetState() {
this.mode = null;
this.selectedMarker = null;
this.interaction = {
active: false,
type: null,
startX: 0,
startY: 0,
wrapperRect: null,
initialMarkerRect: null,
resizeHandle: null,
drawingPreview: null,
};
this.removeDocumentListeners();
},
startInteraction(type, marker = null, resizeHandle = null) {
this.interaction = {
active: true,
type,
startX: 0,
startY: 0,
wrapperRect: this.$refs.photoPreviewWrapper?.getBoundingClientRect() || null,
initialMarkerRect: marker ? { X: marker.X, Y: marker.Y, W: marker.W, H: marker.H } : null,
resizeHandle,
drawingPreview: type === "drawing" ? { x: 0, y: 0, w: 0, h: 0 } : null,
};
this.addDocumentListeners();
},
saveMarkerChanges() {
if (this.selectedMarker) {
this.updateMarkerPosition(this.selectedMarker);
}
this.resetState();
},
},
};
</script>

View File

@@ -1,6 +1,17 @@
<template>
<div class="p-tab p-tab-photo-people">
<div class="pa-2 p-faces">
<transition name="slide-y-transition" appear>
<PPhotoFaceEditor
v-if="showManualEditing"
:uid="uid"
:primary-file="primaryFile"
:initial-markers="markers"
@close="closeManualEditing"
@markers-updated="onMarkersUpdated"
/>
</transition>
<v-alert
v-if="markers.length === 0"
color="surface-variant"
@@ -19,7 +30,7 @@
<div v-else class="v-row search-results face-results cards-view d-flex">
<div v-for="m in markers" :key="m.UID" class="v-col-12 v-col-sm-6 v-col-md-4 v-col-lg-3 d-flex">
<v-card :data-id="m.UID" :class="m.classes()" class="result not-selectable flex-grow-1" tabindex="1">
<v-img :src="m.thumbnailUrl('tile_320')" aspect-ratio="1" class="card">
<v-img :src="getMarkerThumbnailUrl(m)" aspect-ratio="1" class="card">
<v-btn
v-if="!m.SubjUID && !m.Invalid"
:ripple="false"
@@ -84,13 +95,19 @@
class="input-name pa-0 ma-0"
@blur="onSetName(m)"
@update:model-value="(person) => onSetPerson(m, person)"
@keyup.enter.native="onSetName(m)"
@keyup.enter="onSetName(m)"
>
</v-combobox>
</v-card-actions>
</v-card>
</div>
</div>
<div v-if="!showManualEditing" class="d-flex justify-start mt-4">
<v-btn color="primary" variant="outlined" @click="showManualEditing = true">
{{ $gettext("Edit Face Markers") }}
</v-btn>
</div>
</div>
<p-confirm-dialog
:visible="confirm.visible"
@@ -106,10 +123,11 @@
<script>
import Marker from "model/marker";
import PConfirmDialog from "component/confirm/dialog.vue";
import PPhotoFaceEditor from "./face-editor.vue";
export default {
name: "PTabPhotoPeople",
components: { PConfirmDialog },
components: { PConfirmDialog, PPhotoFaceEditor },
props: {
uid: {
type: String,
@@ -120,11 +138,12 @@ export default {
const view = this.$view.getData();
return {
view,
markers: view.model.getMarkers(true),
markers: [],
busy: false,
disabled: !this.$config.feature("edit"),
config: this.$config.values,
readonly: this.$config.get("readonly"),
showManualEditing: false,
confirm: {
visible: false,
model: new Marker(),
@@ -146,16 +165,35 @@ export default {
},
};
},
computed: {
primaryFile() {
if (!this.view.model || !this.view.model.Files || this.view.model.Files.length === 0) {
return null;
}
return this.view.model.Files.find((f) => f.Primary);
},
},
watch: {
uid: function () {
this.refresh();
},
},
mounted() {
this.markers = this.view.model.getMarkers(true).map((markerData) => new Marker(markerData));
},
methods: {
refresh() {
if (this.view.model) {
this.markers = this.view.model.getMarkers(true);
getMarkerThumbnailUrl(marker) {
if (marker.thumbWithTimestamp) {
return marker.thumbWithTimestamp;
}
return marker.thumbnailUrl("tile_320");
},
refresh() {
this.markers = this.view.model.getMarkers(true).map((markerData) => new Marker(markerData));
},
onMarkersUpdated(updatedMarkers) {
this.markers = updatedMarkers;
},
onReject(model) {
if (this.busy || !model) return;
@@ -196,10 +234,7 @@ export default {
return true;
},
onSetName(model) {
if (this.busy || !model) {
return;
}
if (this.busy || !model) return;
const name = model?.Name;
if (!name) {
@@ -226,23 +261,16 @@ export default {
this.confirm.visible = true;
},
onConfirmSetName() {
if (!this.confirm?.model?.Name) {
return;
}
if (!this.confirm?.model?.Name) return;
this.setName(this.confirm.model);
},
onCancelSetName() {
this.confirm.visible = false;
},
setName(model) {
if (this.busy || !model) {
return;
}
if (this.busy || !model) return;
this.busy = true;
this.$notify.blockUI("busy");
return model.setName().finally(() => {
this.$notify.unblockUI();
this.busy = false;
@@ -250,6 +278,9 @@ export default {
this.confirm.visible = false;
});
},
closeManualEditing() {
this.showManualEditing = false;
},
},
};
</script>

View File

@@ -0,0 +1,104 @@
.photo-preview-container {
max-width: 100%;
margin: 0 auto;
}
.photo-preview-wrapper {
position: relative;
overflow: hidden;
background-color: #f0f0f0;
border: 1px solid #ccc;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
display: inline-block;
max-width: 100%;
}
.photo-preview {
display: block;
max-width: 100%;
height: auto;
pointer-events: none;
}
.face-marker {
position: absolute;
box-sizing: border-box;
border: 2px solid rgba(255, 255, 255, 0.7);
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
transition: all 0.1s ease-in-out;
cursor: move;
z-index: 10;
}
.face-marker:hover {
border-color: rgba(255, 255, 255, 1);
}
.face-marker-selected {
border-color: #1976d2;
border-width: 3px;
box-shadow: 0 0 10px rgba(25, 118, 210, 0.7);
}
.face-marker-editing {
border-style: dashed;
border-color: #1976d2;
}
.face-marker-name {
position: absolute;
bottom: 100%;
left: 0;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 5px;
border-radius: 3px;
font-size: 0.8em;
white-space: nowrap;
display: flex;
align-items: center;
}
.face-marker-actions {
margin-left: 5px;
}
.marker-handle {
position: absolute;
width: 10px;
height: 10px;
background-color: #1976d2;
border: 1px solid white;
border-radius: 50%;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
z-index: 15;
}
.handle-nw {
top: -5px;
left: -5px;
cursor: nwse-resize;
}
.handle-ne {
top: -5px;
right: -5px;
cursor: nesw-resize;
}
.handle-sw {
bottom: -5px;
left: -5px;
cursor: nesw-resize;
}
.handle-se {
bottom: -5px;
right: -5px;
cursor: nwse-resize;
}

View File

@@ -221,6 +221,20 @@ func UpdateMarker(router *gin.RouterGroup) {
return
}
// Update marker position and thumb if needed.
if frm.W > 0 && frm.H > 0 && (marker.X != frm.X || marker.Y != frm.Y || marker.W != frm.W || marker.H != frm.H) {
marker.X = frm.X
marker.Y = frm.Y
marker.W = frm.W
marker.H = frm.H
marker.Thumb = crop.NewArea("face", marker.X, marker.Y, marker.W, marker.H).Thumb(file.FileHash)
if err := marker.Save(); err != nil {
log.Errorf("faces: %s (update marker position)", err)
AbortSaveFailed(c)
return
}
}
// Update marker from form values.
if changed, saveErr := marker.SaveForm(frm); saveErr != nil {
log.Errorf("faces: %s (update marker)", saveErr)