mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Frontend: Compact verbose code comments in lightbox and sidebar #4966
This commit is contained in:
parent
7616a6cdab
commit
ae2645dbc8
2 changed files with 56 additions and 169 deletions
|
|
@ -226,16 +226,9 @@ export default {
|
|||
contextAllowsEdit: true,
|
||||
contextAllowsSelect: true,
|
||||
featPeople: this.$config.feature("people"),
|
||||
// Face-marker UI state lives in the shared singleton
|
||||
// `common/face-markers.js`. The lightbox is the policy owner — every
|
||||
// write goes through this reactive handle and the sidebar reads
|
||||
// from the same singleton (no `view.faceMarkerMode` bridge). Fields:
|
||||
// `mode` (null | FaceMarkerDisplay | FaceMarkerEdit), `busy`
|
||||
// (in-flight marker mutation lock), `pendingNameMarkerUid` (UID of
|
||||
// a freshly-created marker to auto-focus). Entering any non-null
|
||||
// mode pauses playback via the watcher on `faceMarkers.mode` and
|
||||
// `enterFaceMarkerMode`; exit does NOT resume — the user reopens
|
||||
// playback explicitly if wanted.
|
||||
// Shared face-marker state (`common/face-markers.js`). The lightbox
|
||||
// owns policy; the sidebar reads the same singleton. Entering any
|
||||
// non-null mode pauses playback; exit does NOT resume.
|
||||
faceMarkers: $faceMarkers,
|
||||
subscriptions: [], // Event subscriptions.
|
||||
// Video properties for rendering the controls.
|
||||
|
|
@ -1818,14 +1811,9 @@ export default {
|
|||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
// Pauses any actively playing media or slideshow when the user enters
|
||||
// either face-marker mode (display or draw). The CSS class
|
||||
// `face-marker-mode` on `.p-lightbox__content` swaps `<video>` /
|
||||
// `<live>` / `<animated>` content for the JPEG cover so the overlay's
|
||||
// boxes (positioned by image coordinates) align with the same frame
|
||||
// the detector ran against — a paused `<video>` shows an arbitrary
|
||||
// frame, not the still image. Playback is NOT resumed on exit; the
|
||||
// user reopens it explicitly if desired (see `exitFaceMarkerMode`).
|
||||
// Pauses playback on face-marker entry. The CSS `face-marker-mode`
|
||||
// class swaps video for the JPEG cover so marker boxes align with
|
||||
// the detector frame. Exit does NOT resume playback.
|
||||
enterFaceMarkerMode() {
|
||||
this.pauseLightbox();
|
||||
|
||||
|
|
@ -1837,14 +1825,8 @@ export default {
|
|||
this.$nextTick(() => overlay.scheduleUpdate());
|
||||
}
|
||||
},
|
||||
// Fully exits face-marker UI by clearing the singleton's mode flag.
|
||||
// The eye-toggle when active (toggleFaceMarkerMode exit), Escape (via
|
||||
// onEscapeKey), and hideInfo all route through here. The ✓ Done
|
||||
// button (toggleFaceMarkerEdit exit from draw) deliberately does NOT
|
||||
// route through here — it steps down to FaceMarkerDisplay so markers
|
||||
// stay visible after the user finishes drawing. Re-enabling display-
|
||||
// only review from null is one click away via the eye toggle in the
|
||||
// sidebar.
|
||||
// Fully exits face-marker UI. Eye-toggle / Escape / hideInfo all
|
||||
// route through here.
|
||||
exitFaceMarkerMode() {
|
||||
this.faceMarkers.exit();
|
||||
},
|
||||
|
|
@ -2437,14 +2419,8 @@ export default {
|
|||
return false;
|
||||
}
|
||||
|
||||
// While face-marker mode is active, shortcuts that open hidden
|
||||
// chrome (menus), stack a competing modal, fire silent
|
||||
// destructive actions, or contradict the entry-only-pause /
|
||||
// overlay-stays-mounted contracts are gated to a no-op. Escape
|
||||
// (priority chain), Tab (focus), KeyI (toggle sidebar — exits
|
||||
// face-marker mode in lockstep via hideInfo), KeyD (download),
|
||||
// KeyF (fullscreen), and KeyM (mute) stay enabled. See the
|
||||
// matching gate in onKeyDown for Arrow / Space.
|
||||
// While face-marker mode is active, only Escape / Tab / KeyI /
|
||||
// KeyD / KeyF / KeyM stay enabled (see `isShortcutDisabledInFaceMarkerMode`).
|
||||
if (this.faceMarkers?.active && this.isShortcutDisabledInFaceMarkerMode(ev.code)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -2586,17 +2562,9 @@ export default {
|
|||
return false;
|
||||
}
|
||||
},
|
||||
// Shared Escape handler. Delegated to from both the v-dialog template
|
||||
// (`@keydown.esc.exact.stop="onEscapeKey"`) and the global
|
||||
// `$view.onShortCut(ev)` forwarder (see `frontend/src/common/README.md`
|
||||
// for the documented keyboard pattern). Priority chain:
|
||||
//
|
||||
// 1. If the face-marker overlay has an in-flight draft / pending rect,
|
||||
// let it cancel that without exiting edit mode.
|
||||
// 2. Else, if any face-marker UI is active, hide the overlay —
|
||||
// closing the lightbox would skip a step the user can plausibly
|
||||
// still want to undo.
|
||||
// 3. Else, close the lightbox normally.
|
||||
// Escape priority: overlay's in-flight draft → exit face-marker
|
||||
// mode → close lightbox. Shared by the v-dialog binding and the
|
||||
// `$view.onShortCut` forwarder.
|
||||
onEscapeKey() {
|
||||
const overlay = this.$refs.faceMarkerOverlay;
|
||||
if (overlay && typeof overlay.handleEscape === "function" && overlay.handleEscape()) {
|
||||
|
|
@ -2616,16 +2584,9 @@ export default {
|
|||
overlay.handleEnter();
|
||||
}
|
||||
},
|
||||
// Bridges PhotoSwipe's dispatched 'keydown' event so printable keys
|
||||
// typed into the info sidebar (notably "z" for the Subject textarea)
|
||||
// are not swallowed by PhotoSwipe's toggleZoom shortcut. Calling
|
||||
// preventDefault on the dispatched event causes _onKeyDown in
|
||||
// photoswipe.esm.js to return before its switch statement runs.
|
||||
//
|
||||
// Tab is handled separately at the v-dialog level via onTabKey: stopping
|
||||
// propagation there is enough because PhotoSwipe's Tab path only calls
|
||||
// _focusRoot() (a focus trap), which Vuetify's v-dialog and the
|
||||
// PhotoPrism $view trap (common/view.js) already provide more generically.
|
||||
// Stops PhotoSwipe's "z" shortcut from swallowing printable keys
|
||||
// typed into sidebar inputs. `preventDefault` makes `_onKeyDown`
|
||||
// bail before its switch statement.
|
||||
onPswpKeyDown(ev) {
|
||||
if (!ev || !this.info) return;
|
||||
const active = document.activeElement;
|
||||
|
|
@ -2633,14 +2594,10 @@ export default {
|
|||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
// Tab keypress handler on the v-dialog root. Stops propagation so
|
||||
// PhotoSwipe's document-level _focusRoot() Tab handler doesn't fire,
|
||||
// letting browser-default Tab navigation continue normally and keeping
|
||||
// sidebar chips, inputs, and pencils reachable by keyboard. Suppression
|
||||
// is gated on focus being inside the lightbox tree so an event bubbling
|
||||
// up from somewhere unexpected (e.g. a teleported overlay) can still
|
||||
// reach PhotoSwipe. Vuetify's v-dialog focus scope and the PhotoPrism
|
||||
// $view focus trap re-anchor focus that escapes the modal.
|
||||
// Suppresses PhotoSwipe's document-level `_focusRoot` Tab handler
|
||||
// when focus is inside the lightbox tree, so browser-default Tab
|
||||
// navigation reaches sidebar inputs/chips. Vuetify + `$view` re-anchor
|
||||
// any focus that escapes the modal.
|
||||
onTabKey(ev) {
|
||||
if (!ev) return;
|
||||
const root = this.$refs.container || this.$refs.content;
|
||||
|
|
@ -2729,21 +2686,11 @@ export default {
|
|||
|
||||
return true;
|
||||
},
|
||||
// Toggles visibility of the PhotoSwipe Dynamic Caption overlay
|
||||
// (#5580). Persisted to localStorage as "lightbox.caption" so the
|
||||
// choice survives slide nav, lightbox close/reopen, and page
|
||||
// reload. The actual hide is driven by the .hide-caption class on
|
||||
// .p-lightbox__content via the CSS rule next to .sidebar-visible
|
||||
// in lightbox.css; the matching layout recompute (so the photo
|
||||
// reclaims the caption's reserved pan area) flows through
|
||||
// this.resize(true) — NOT a direct pswp.updateSize() call. The
|
||||
// class binding on .p-lightbox__content is reactive, so the
|
||||
// .hide-caption attribute (and the paddingFn early-return that
|
||||
// reads hideCaption) only flips after Vue flushes; running resize
|
||||
// synchronously would re-measure against the stale DOM. Same
|
||||
// pattern as showInfo() / hideInfo(). No-op when the lightbox is
|
||||
// not visible or the sidebar is open (the sidebar already
|
||||
// suppresses the overlay and renders the caption inline).
|
||||
// Toggles the Dynamic Caption overlay. Persisted to localStorage so
|
||||
// the choice survives slide nav and reload. The relayout runs via
|
||||
// `this.resize(true)` inside `$nextTick` so PhotoSwipe's `paddingFn`
|
||||
// reads the flushed `.hide-caption` class — see H8 in best-practices.
|
||||
// No-op when the lightbox is hidden or the sidebar is open.
|
||||
toggleCaption() {
|
||||
if (!this.visible || this.info) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -507,11 +507,9 @@ export default {
|
|||
PSidebarInlineToolbar,
|
||||
},
|
||||
props: {
|
||||
// UID of the photo currently shown in the parent lightbox. Drives the
|
||||
// sidebar lifecycle (re-fetching markers, resetting inline edits) when
|
||||
// the user navigates between slides. All other parent state is read
|
||||
// through `view` (see data() below) — this matches the pattern used
|
||||
// by component/photo/edit/labels.vue.
|
||||
// UID of the photo currently in the parent lightbox; drives the
|
||||
// slide-change lifecycle. All other parent state flows through
|
||||
// `view` (data() below).
|
||||
uid: {
|
||||
type: String,
|
||||
default: "",
|
||||
|
|
@ -520,22 +518,12 @@ export default {
|
|||
emits: ["close", "toggle-face-marker-mode", "toggle-face-marker-edit", "eject-marker", "reload-markers", "naming-started"],
|
||||
data() {
|
||||
return {
|
||||
// Live reactive handle to the parent lightbox's $data, captured once at
|
||||
// mount via `$view.getData()`. The lightbox calls `$view.enter(this)`
|
||||
// before the sidebar mounts (see lightbox.vue:showDialog), so this is
|
||||
// populated by the time data() runs. Mutations through this.view.X
|
||||
// write through to the parent and don't trigger vue/no-mutating-props.
|
||||
// Reactive handle to the parent lightbox's $data via `$view.getData()`.
|
||||
// Mutations through `this.view.X` write through to the parent.
|
||||
view: this.$view.getData(),
|
||||
// Reactive handle to the shared face-marker state singleton
|
||||
// (`common/face-markers.js`). Drives the eye / pencil toggles,
|
||||
// the inline naming flow, and the markersBusy gates. Sidebar
|
||||
// emits transition requests via `toggle-face-marker-mode` /
|
||||
// `toggle-face-marker-edit` / `eject-marker` / `reload-markers`;
|
||||
// the lightbox is the policy owner and performs the actual writes.
|
||||
// Marker removal lives on the face-marker overlay (click an
|
||||
// unnamed marker in edit mode → inline confirm pill) — not in
|
||||
// the sidebar — so the per-row `mdi-close` button that used to
|
||||
// sit inside the combobox was retired.
|
||||
// Shared face-marker state singleton. The lightbox owns policy
|
||||
// (transitions, API writes); the sidebar emits `toggle-face-marker-*`
|
||||
// / `eject-marker` / `reload-markers`.
|
||||
faceMarkers: $faceMarkers,
|
||||
actions: [],
|
||||
featPeople: this.$config.feature("people"),
|
||||
|
|
@ -544,44 +532,20 @@ export default {
|
|||
dateTimeDialog: false,
|
||||
cameraDialog: false,
|
||||
locationDialog: false,
|
||||
// When true, the inline pencil edit buttons are hidden via CSS
|
||||
// (`.hide-edit-pencils` rule in `css/lightbox.css`). Row-level
|
||||
// click handlers still route to `startEditing` / `open*Dialog`,
|
||||
// so the pencils are a redundant affordance that can be toggled
|
||||
// off without affecting reachability. Save (`meta-inline-confirm`)
|
||||
// and Undo (`meta-inline-undo`) buttons have their own toggles
|
||||
// below.
|
||||
// CSS toggles for the inline pencil / Undo / Save affordances —
|
||||
// hidden by default since row click + keyboard cover everything.
|
||||
// Flip per-user / A/B to surface the mouse-driven affordances.
|
||||
hideEditPencils: true,
|
||||
// hideEditUndo / hideEditSave hide the inline Undo and Save
|
||||
// buttons via the `.hide-edit-undo` / `.hide-edit-save` rules in
|
||||
// `css/lightbox.css`. Both default to true (hidden) since the
|
||||
// keyboard paths are first-class: Enter commits on the
|
||||
// commitOnEnter fields (Subject / Artist / Copyright / License /
|
||||
// Keywords), blur commits on Caption + Notes, Escape cancels,
|
||||
// and Ctrl+Z reverts inside the focused textarea. Flip either
|
||||
// to false (per-user preference or A/B variant) to surface the
|
||||
// mouse-driven affordances.
|
||||
hideEditUndo: true,
|
||||
hideEditSave: true,
|
||||
editingField: null,
|
||||
editOriginal: null,
|
||||
// Per-field combobox state. The combobox/autocomplete row stays
|
||||
// mounted whenever the section is editable (no pencil-to-edit
|
||||
// gesture for chips), so each section needs its own input/search
|
||||
// scratch refs and per-field force-remount key.
|
||||
//
|
||||
// - input/search: Vuetify v-model and v-model:search bindings.
|
||||
// `search` doubles as the "typed-but-not-yet-Enter" detector
|
||||
// for hasPendingEdit().
|
||||
// - key: incremented on Enter to force-remount the combobox so
|
||||
// stale dropdown state clears alongside the input.
|
||||
// - options: typeahead suggestions populated lazily from the
|
||||
// shared typeaheadCache (common/typeahead-cache.js); shape
|
||||
// matches the v-combobox/v-autocomplete item-title bindings.
|
||||
// - removals: pending Label.ID / Album.UID removals committed
|
||||
// by the toolbar ✓. Additions take an instant-save path
|
||||
// (addLabelImmediate / addAlbumImmediate → Photo model
|
||||
// methods), so they never enter chipState.
|
||||
// Per-field combobox state. input/search drive v-model bindings;
|
||||
// `search` also acts as the "typed-but-not-yet-Enter" pending
|
||||
// detector. `key` force-remounts the combobox on Enter to clear
|
||||
// stale dropdown state. `options` is the typeahead list (lazy,
|
||||
// shared cache). `removals` queues Label.ID / Album.UID for the
|
||||
// toolbar ✓; additions skip this and take the instant-save path.
|
||||
chipState: {
|
||||
labels: { input: null, search: "", key: 0, options: [], removals: [] },
|
||||
albums: { input: null, search: "", key: 0, options: [], removals: [] },
|
||||
|
|
@ -735,14 +699,9 @@ export default {
|
|||
if (!this.photo) return [];
|
||||
return this.photo.getMarkers(true);
|
||||
},
|
||||
// Sorted, locale-aware copy of $config.values.people for the marker
|
||||
// combobox dropdown. Mirrors the Labels/Albums sort baked into
|
||||
// loadChipOptions (L12): localeCompare with `undefined` locale +
|
||||
// base sensitivity + numeric collation. Reading from $config keeps the
|
||||
// list reactive to WS `people.{created,updated,deleted}` events that
|
||||
// are already handled inside common/config.js. A future shared
|
||||
// people-cache module (F1) will move this logic out of the component;
|
||||
// until then the per-consumer sort matches the Labels/Albums pattern.
|
||||
// Sorted, locale-aware copy of `$config.values.people` for the marker
|
||||
// combobox. Reading from `$config` keeps the list reactive to WS
|
||||
// `people.{created,updated,deleted}` events.
|
||||
knownPeople() {
|
||||
const values = this.$config && this.$config.values;
|
||||
if (!values || !Array.isArray(values.people)) return [];
|
||||
|
|
@ -921,24 +880,15 @@ export default {
|
|||
if (this.isEditable) return true;
|
||||
return this.detailsFields.some((f) => Boolean(f.read(this.photo)));
|
||||
},
|
||||
// inlineEditDirty is true when the active inline-text editor's
|
||||
// current value differs from the editOriginal captured at
|
||||
// startEditing time. Drives the disabled state of the Undo button
|
||||
// — when there's nothing to undo, the button stays visible (so the
|
||||
// affordance is discoverable) but inactive.
|
||||
// True when the active inline editor's value differs from the
|
||||
// editOriginal snapshot — gates the Undo button's disabled state.
|
||||
inlineEditDirty() {
|
||||
if (!this.editingField) return false;
|
||||
return this.getFieldValue(this.editingField) !== (this.editOriginal ?? "");
|
||||
},
|
||||
// showRightsDivider gates the single divider that visually splits
|
||||
// the structured metadata fields (Subject / Artist / Copyright /
|
||||
// License / Keywords) from the free-form Notes row inside the
|
||||
// merged details v-for. Template renders the divider just before
|
||||
// the Notes row (`f.key === 'notes'`). For editable users every
|
||||
// row is mounted, so the divider is always meaningful; for
|
||||
// read-only users we suppress it when either side would be empty,
|
||||
// otherwise the divider appears as an orphan line above or below
|
||||
// the lone visible row.
|
||||
// Renders the divider between the rights cluster and the Notes row.
|
||||
// For read-only users we drop it when either side is empty so it
|
||||
// doesn't appear as an orphan line.
|
||||
showRightsDivider() {
|
||||
if (this.isEditable) return true;
|
||||
const hasAbove = ["subject", "artist", "copyright", "license", "keywords"].some((k) => this.shouldShowFieldRow(this.fieldRegistry[k]));
|
||||
|
|
@ -999,18 +949,10 @@ export default {
|
|||
if (this.model?.Lat && this.model?.Lng) return true;
|
||||
return this.canViewPlaces && !!this.placeName;
|
||||
},
|
||||
// Returns the user-facing file path. For video, Live, and Animated
|
||||
// photos the primary file is the generated JPEG cover (used for
|
||||
// indexing and thumbnails), not the media file the user uploaded —
|
||||
// surface the underlying media file's Name so the sidebar shows the
|
||||
// .mp4 / .mov / .gif instead of "...mp4.jpg". The cards view uses the
|
||||
// same originalFile() routing via Photo.getOriginalName().
|
||||
// Returns `null` (not `""`) for users without library access and
|
||||
// the no-data state so the merged file row's `:subtitle` binding
|
||||
// skips rendering an empty `<v-list-item-subtitle>` element —
|
||||
// Vuetify gates the subtitle on `props.subtitle != null`, so an
|
||||
// empty string would still render an empty slot. Keeping the gate
|
||||
// in the computed lets the template stay free of ACL checks.
|
||||
// Prefers originalFile() so video / Live / Animated show .mp4 / .mov
|
||||
// / .gif instead of the generated JPEG cover. Returns null (not "")
|
||||
// so Vuetify's `:subtitle != null` gate hides the slot instead of
|
||||
// rendering an empty `<v-list-item-subtitle>`.
|
||||
fileName() {
|
||||
if (!this.canViewLibrary || !this.photo) return null;
|
||||
if (typeof this.photo.originalFile === "function") {
|
||||
|
|
@ -1064,10 +1006,8 @@ export default {
|
|||
return "mdi-image-outline";
|
||||
}
|
||||
},
|
||||
// Localized media type label for the file row's tooltip. Falls back
|
||||
// to the generic "File" label so the tooltip never reads as empty
|
||||
// when the photo's Type is unknown or the row is rendered for a
|
||||
// restricted-role model that doesn't expose Type.
|
||||
// Localized media-type label for the file row tooltip, with a
|
||||
// generic "File" fallback when Type is missing.
|
||||
fileTypeName() {
|
||||
const type = this.photo?.Type || this.model?.Type;
|
||||
return this.$util.typeName(type, this.$gettext("File"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue