mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Compare commits
6 Commits
dbf1650c1c
...
manually-m
Author | SHA1 | Date | |
---|---|---|---|
![]() |
354e137af5 | ||
![]() |
5b0ffa201b | ||
![]() |
9cade913be | ||
![]() |
8743f294d8 | ||
![]() |
5f6e2df243 | ||
![]() |
46a60f8747 |
543
frontend/src/component/photo/edit/face-editor.vue
Normal file
543
frontend/src/component/photo/edit/face-editor.vue
Normal 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>
|
@@ -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>
|
||||
|
104
frontend/src/css/face-markers.css
Normal file
104
frontend/src/css/face-markers.css
Normal 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;
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user