Lightbox: Show batch edit selection via getLightboxContext() #271 #5324

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-11-17 02:25:18 +01:00
parent 0ab29f443d
commit 648dbf466f
5 changed files with 150 additions and 30 deletions

View file

@ -1,6 +1,6 @@
PhotoPrism — Frontend CODEMAP
**Last Updated:** November 12, 2025
**Last Updated:** November 17, 2025
Purpose
- Help agents and contributors navigate the Vue 3 + Vuetify 3 app quickly and make safe changes.
@ -43,6 +43,12 @@ Runtime & Plugins
- PWA: Workbox registers a service worker after config load (see `src/app.js`); scope and registration URL derive from `$config.baseUri` so non-root deployments work. Workbox precache rules live in `frontend/webpack.config.js` (see the `GenerateSW` plugin); locale chunks and non-woff2 font variants are excluded there so we dont force every user to download those assets on first visit.
- WebSocket: `src/common/websocket.js` publishes `websocket.*` events, used by `$session` for client info
Lightbox Integration
- Shared entry points live in `src/common/lightbox.js`; `$lightbox.open(options)` fires a `lightbox.open` event consumed by `component/lightbox.vue`.
- Prefer `$lightbox.openView(this, index)` when a component or dialog already has the photos in memory. Implement `getLightboxContext(index)` on the view and return `{ models, index, context, allowEdit?, allowSelect? }` so the lightbox can build slides without requerying.
- Set `allowEdit: false` when the caller shouldnt expose inline editing (the edit button and `KeyE` shortcut are disabled automatically). Set `allowSelect: false` to hide the selection toggle and block the `.` shortcut so batch-edit dialogs dont mutate the global clipboard.
- Legacy `$lightbox.openModels(models, index, collection)` still accepts raw thumb arrays, but it cannot express the context flags—only use it when you truly dont have a backing view.
HTTP Client
- Axios instance: `src/common/api.js`
- Base URL: `window.__CONFIG__.apiUri` (or `/api/v1` in tests)

View file

@ -6,8 +6,8 @@ export class Lightbox {
$event.publish("lightbox.open", options);
}
openModels(models, index, collection, isBatchDialog) {
$event.publish("lightbox.open", { models, index, collection, isBatchDialog });
openModels(models, index, collection) {
$event.publish("lightbox.open", { models, index, collection });
}
openView(view, index) {

View file

@ -193,7 +193,8 @@ export default {
model: new Thumb(), // Current slide.
models: [], // Slide models.
index: 0, // Current slide index in models.
isBatchDialog: false,
contextAllowsEdit: true,
contextAllowsSelect: true,
subscriptions: [], // Event subscriptions.
// Video properties for rendering the controls.
video: {
@ -265,8 +266,6 @@ export default {
return;
}
this.isBatchDialog = !!data.isBatchDialog;
if (data.view) {
this.showView(data.view, data.index);
} else {
@ -422,6 +421,9 @@ export default {
return Promise.reject();
}
this.contextAllowsEdit = ctx?.allowEdit !== false;
this.contextAllowsSelect = ctx?.allowSelect !== false;
// Check if at least one model was passed, as otherwise no content can be displayed.
if (!Array.isArray(models) || models.length === 0 || index >= models.length) {
this.log("model list passed to lightbox is empty:", models);
@ -450,7 +452,29 @@ export default {
return Promise.reject();
}
if (view.loading || !view.listen || view.lightbox.loading || !view.results[index]) {
if (view && typeof view.getLightboxContext === "function") {
const ctx = view.getLightboxContext(index);
if (!ctx || !Array.isArray(ctx.models) || ctx.models.length === 0) {
return Promise.reject();
}
const targetIndex = this.normalizeIndex(
typeof ctx.index === "number" ? ctx.index : typeof index === "number" ? index : 0,
ctx.models.length
);
return this.showThumbs(ctx.models, targetIndex, ctx);
}
if (
!view ||
view.loading ||
!view.listen ||
view.lightbox?.loading ||
!Array.isArray(view.results) ||
!view.results[index]
) {
return Promise.reject();
}
@ -529,6 +553,28 @@ export default {
view.lightbox.loading = false;
});
},
// Keeps the requested slide index within the available bounds before opening the lightbox.
normalizeIndex(idx, length) {
let target = Number.isFinite(idx) ? idx : 0;
if (target < 0) {
target = 0;
}
const maxIndex = Math.max(length - 1, 0);
if (target > maxIndex) {
target = maxIndex;
}
return target;
},
shouldShowEditButton() {
return this.canEdit && this.contextAllowsEdit;
},
shouldShowSelectionToggle() {
return this.contextAllowsSelect;
},
getNumItems() {
return this.models.length;
},
@ -1296,23 +1342,25 @@ export default {
}
// Add selection toggle control.
lightbox.pswp.ui.registerElement({
name: "select-toggle",
className: "pswp__button--select-toggle pswp__button--mdi", // Sets the icon style/size in lightbox.css.
title: this.$gettext("Select"),
ariaLabel: this.$gettext("Select"),
order: 10,
isButton: true,
html: {
isCustomSVG: true,
inner: `<use class="pswp__icn-shadow pswp__icn-select-on" xlink:href="#pswp__icn-select-on"></use><path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" id="pswp__icn-select-on" class="pswp__icn-select-on" /><use class="pswp__icn-shadow pswp__icn-select-off" xlink:href="#pswp__icn-select-off"></use><path d="M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" id="pswp__icn-select-off" class="pswp__icn-select-off" />`,
size: 24, // Depends on the original SVG viewBox, e.g. use 24 for viewBox="0 0 24 24".
},
onClick: (ev) => this.onControlClick(ev, this.toggleSelect),
});
if (this.shouldShowSelectionToggle()) {
lightbox.pswp.ui.registerElement({
name: "select-toggle",
className: "pswp__button--select-toggle pswp__button--mdi", // Sets the icon style/size in lightbox.css.
title: this.$gettext("Select"),
ariaLabel: this.$gettext("Select"),
order: 10,
isButton: true,
html: {
isCustomSVG: true,
inner: `<use class="pswp__icn-shadow pswp__icn-select-on" xlink:href="#pswp__icn-select-on"></use><path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" id="pswp__icn-select-on" class="pswp__icn-select-on" /><use class="pswp__icn-shadow pswp__icn-select-off" xlink:href="#pswp__icn-select-off"></use><path d="M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" id="pswp__icn-select-off" class="pswp__icn-select-off" />`,
size: 24, // Depends on the original SVG viewBox, e.g. use 24 for viewBox="0 0 24 24".
},
onClick: (ev) => this.onControlClick(ev, this.toggleSelect),
});
}
// Add edit button control if user has permission to use it.
if (this.canEdit && !this.isBatchDialog) {
if (this.shouldShowEditButton()) {
lightbox.pswp.ui.registerElement({
name: "edit-button",
className: "pswp__button--edit-button pswp__button--mdi hidden-shared-only", // Sets the icon style/size in lightbox.css.
@ -1536,7 +1584,8 @@ export default {
onReset() {
this.resetControls();
this.resetModels();
this.isBatchDialog = false;
this.contextAllowsEdit = true;
this.contextAllowsSelect = true;
},
// Resets the state of the lightbox controls.
resetControls() {
@ -1847,6 +1896,9 @@ export default {
},
// Toggles the selection of the current picture in the global photo clipboard.
toggleSelect() {
if (!this.contextAllowsSelect) {
return;
}
this.$clipboard.toggle(this.model);
},
// Returns the active HTMLMediaElement element in the lightbox, if any.
@ -1956,6 +2008,9 @@ export default {
this.close();
return true;
case "Period":
if (!this.contextAllowsSelect) {
return false;
}
this.onShowMenu();
this.toggleSelect();
return true;
@ -1974,7 +2029,7 @@ export default {
}
return true;
case "KeyE":
if (this.canEdit) {
if (this.canEdit && this.contextAllowsEdit) {
this.onEdit();
}
return true;

View file

@ -548,6 +548,8 @@ import PLocationInput from "component/location/input.vue";
import PInputChipSelector from "component/input/chip-selector.vue";
import $util from "common/util";
// TODO: Handle cases where users have more than 10000 albums and/or labels.
const MaxResults = 10000;
const iconClear = "mdi-close-circle";
const iconUndo = "mdi-undo";
@ -593,7 +595,6 @@ export default {
selectionsFullInfo: [],
selectedPhotosLength: 0,
expanded: [0],
isBatchDialog: true,
isAllSelected: true,
allSelectedLength: 0,
options,
@ -1239,7 +1240,30 @@ export default {
}
},
openPhoto(index) {
this.$lightbox.openModels(Thumb.fromPhotos([this.model.models[index]]), 0, null, this.isBatchDialog);
const targetIndex = typeof index === "number" ? index : 0;
this.$lightbox.openView(this, targetIndex);
},
getLightboxContext(index = 0) {
const photos = Array.isArray(this.model?.models) ? this.model.models : [];
if (!photos.length) {
return { models: [], index: 0, allowEdit: false };
}
const thumbs = Thumb.fromPhotos(photos);
let targetIndex = typeof index === "number" ? index : 0;
if (targetIndex < 0 || targetIndex >= thumbs.length) {
targetIndex = 0;
}
return {
models: thumbs,
index: targetIndex,
context: this.$gettext("Batch Edit"),
allowEdit: false,
allowSelect: false,
};
},
isSelected(model) {
return this.model.isSelected(model.UID);
@ -1468,8 +1492,8 @@ export default {
this.loading = true;
const [albumsResponse, labelsResponse] = await Promise.all([
Album.search({ count: 1000, type: "album", order: "name" }),
Label.search({ count: 1000, order: "name", all: true }),
Album.search({ count: MaxResults, type: "album", order: "name" }),
Label.search({ count: MaxResults, order: "name", all: true }),
]);
this.availableAlbumOptions = (albumsResponse.models || []).map((album) => ({

View file

@ -3,6 +3,7 @@ import { shallowMount } from "@vue/test-utils";
import { nextTick } from "vue";
import PPhotoBatchEdit from "component/photo/batch-edit.vue";
import { Batch } from "model/batch-edit";
import Thumb from "model/thumb";
import { Deleted, Mixed } from "options/options";
// Mock the models and dependencies
@ -151,7 +152,7 @@ describe("component/photo/edit/batch", () => {
error: vi.fn(),
},
$lightbox: {
openModels: vi.fn(),
openView: vi.fn(),
},
$event: {
subscribe: vi.fn(),
@ -374,7 +375,41 @@ describe("component/photo/edit/batch", () => {
it("should handle photo opening", () => {
wrapper.vm.openPhoto(0);
expect(wrapper.vm.$lightbox.openModels).toHaveBeenCalled();
expect(wrapper.vm.$lightbox.openView).toHaveBeenCalledWith(wrapper.vm, 0);
});
});
describe("Lightbox context", () => {
beforeEach(() => {
wrapper.vm.model = mockBatchInstance;
});
it("should build context with thumbs and disable edit", () => {
const thumbMock = [{ UID: "uid1" }, { UID: "uid2" }];
const spy = vi.spyOn(Thumb, "fromPhotos").mockReturnValue(thumbMock);
const ctx = wrapper.vm.getLightboxContext(1);
expect(spy).toHaveBeenCalledWith(mockBatchInstance.models);
expect(ctx.models).toBe(thumbMock);
expect(ctx.index).toBe(1);
expect(ctx.allowEdit).toBe(false);
expect(ctx.allowSelect).toBe(false);
expect(ctx.context).toBe("Batch Edit");
spy.mockRestore();
});
it("should clamp invalid index to first photo", () => {
const thumbMock = [{ UID: "uid1" }];
const spy = vi.spyOn(Thumb, "fromPhotos").mockReturnValue(thumbMock);
const ctx = wrapper.vm.getLightboxContext(5);
expect(ctx.index).toBe(0);
expect(ctx.allowSelect).toBe(false);
spy.mockRestore();
});
});