mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Frontend: Merge sidebar text fields, add Undo + commit-on-Enter #4966
Unifies Subject / Artist / Copyright / License / Keywords / Notes into a single icon-prepended row layout (Option A from the proposal), adds per-field maxLength sourced from new PhotoMaxLength constants mirroring the backend VARCHAR caps, fixes a pre-existing textarea auto-grow regression caused by a static min-height override in lightbox.css, wires Enter-to-commit on the single-line fields via a commitOnEnter registry flag, and adds an Undo affordance + new hideEditUndo / hideEditSave toggles for A/B testing the inline toolbar chrome.
This commit is contained in:
parent
367af097f0
commit
ee775026e4
6 changed files with 404 additions and 93 deletions
|
|
@ -1,5 +1,8 @@
|
|||
<template>
|
||||
<div class="p-sidebar-info bg-background metadata" :class="{ 'hide-edit-pencils': hideEditPencils }">
|
||||
<div
|
||||
class="p-sidebar-info bg-background metadata"
|
||||
:class="{ 'hide-edit-pencils': hideEditPencils, 'hide-edit-undo': hideEditUndo, 'hide-edit-save': hideEditSave }"
|
||||
>
|
||||
<v-toolbar density="comfortable" color="background">
|
||||
<v-btn :icon="$isRtl ? 'mdi-chevron-left' : 'mdi-chevron-right'" :title="$gettext('Close')" @click.stop="close()"></v-btn>
|
||||
<v-toolbar-title>{{ $gettext(`Information`) }}</v-toolbar-title>
|
||||
|
|
@ -16,7 +19,7 @@
|
|||
v-if="editingField === 'title'"
|
||||
:ref="setInlineEditorRef"
|
||||
v-model="photo.Title"
|
||||
:rules="rules.text(false, 0, $config.get('clip'), $pgettext('Photo', 'Title'))"
|
||||
:rules="rules.text(false, 0, fieldRegistry.title.maxLength, fieldRegistry.title.label)"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
autocomplete="off"
|
||||
|
|
@ -28,7 +31,14 @@
|
|||
<div v-else-if="model.Title" class="text-subtitle-2 meta-title">{{ model.Title }}</div>
|
||||
<div v-else class="meta-add-prompt" @click.stop="startEditing('title')">{{ $pgettext("Photo", "Add a Title") }}</div>
|
||||
<template v-if="isEditable" #append>
|
||||
<p-sidebar-inline-toolbar :editing="editingField === 'title'" @confirm="confirmField" @start="startEditing('title')" />
|
||||
<p-sidebar-inline-toolbar
|
||||
:editing="editingField === 'title'"
|
||||
:can-undo="editingField === 'title'"
|
||||
:undo-disabled="!inlineEditDirty"
|
||||
@confirm="confirmField"
|
||||
@undo="undoInlineEdit"
|
||||
@start="startEditing('title')"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
|
|
@ -42,9 +52,10 @@
|
|||
v-if="editingField === 'caption'"
|
||||
:ref="setInlineEditorRef"
|
||||
v-model="photo.Caption"
|
||||
:rows="1"
|
||||
:max-rows="6"
|
||||
density="compact"
|
||||
auto-grow
|
||||
:max-rows="6"
|
||||
hide-details="auto"
|
||||
autocomplete="off"
|
||||
class="meta-inline-edit meta-inline-caption"
|
||||
|
|
@ -55,7 +66,14 @@
|
|||
<div v-else-if="model.Caption" class="text-body-2 meta-caption meta-scrollable" v-html="captionHtml"></div>
|
||||
<div v-else class="meta-add-prompt" @click.stop="startEditing('caption')">{{ $gettext("Add a Caption") }}</div>
|
||||
<template v-if="isEditable" #append>
|
||||
<p-sidebar-inline-toolbar :editing="editingField === 'caption'" @confirm="confirmField" @start="startEditing('caption')" />
|
||||
<p-sidebar-inline-toolbar
|
||||
:editing="editingField === 'caption'"
|
||||
:can-undo="editingField === 'caption'"
|
||||
:undo-disabled="!inlineEditDirty"
|
||||
@confirm="confirmField"
|
||||
@undo="undoInlineEdit"
|
||||
@start="startEditing('caption')"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
|
|
@ -382,49 +400,12 @@
|
|||
|
||||
<template v-if="showDetailsSection">
|
||||
<v-divider class="my-3"></v-divider>
|
||||
<v-list-item
|
||||
v-for="f in detailsFields"
|
||||
v-show="shouldShowFieldRow(f)"
|
||||
:key="f.key"
|
||||
v-tooltip="f.label"
|
||||
:prepend-icon="f.icon"
|
||||
:class="['metadata__item', `meta-${f.key}`, { clickable: editingField !== f.key && (isEditable || f.read(photo)) }]"
|
||||
@click.stop="onTextRowClick(f.key, f.read(photo))"
|
||||
>
|
||||
<v-textarea
|
||||
v-if="editingField === f.key"
|
||||
:ref="setInlineEditorRef"
|
||||
:model-value="f.read(photo)"
|
||||
:placeholder="f.label"
|
||||
:rules="rules.text(false, 0, $config.get('clip'), f.label)"
|
||||
density="compact"
|
||||
auto-grow
|
||||
hide-details="auto"
|
||||
autocomplete="off"
|
||||
class="meta-inline-edit"
|
||||
:class="`meta-inline-${f.key}`"
|
||||
@update:model-value="(v) => f.write(photo, v)"
|
||||
@keydown.escape.stop.prevent="cancelEditing"
|
||||
@blur="onInlineFieldBlur"
|
||||
></v-textarea>
|
||||
<div v-else-if="f.read(photo)" class="text-body-2 meta-scrollable">{{ f.read(photo) }}</div>
|
||||
<div v-else class="meta-add-prompt" @click.stop="startEditing(f.key)">{{ f.label }}</div>
|
||||
<template v-if="isEditable" #append>
|
||||
<p-sidebar-inline-toolbar :editing="editingField === f.key" @confirm="confirmField" @start="startEditing(f.key)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<template v-for="f in textFields" :key="f.key">
|
||||
<template v-if="!restrictedRole && shouldShowFieldRow(f)">
|
||||
<v-divider class="my-3"></v-divider>
|
||||
<v-list-item class="metadata__item" :class="`meta-${f.key}`">
|
||||
<div class="text-subtitle-2">{{ f.label }}</div>
|
||||
<template v-if="isEditable" #append>
|
||||
<p-sidebar-inline-toolbar :editing="editingField === f.key" @confirm="confirmField" @start="startEditing(f.key)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
<template v-for="f in detailsFields" :key="f.key">
|
||||
<v-divider v-if="f.key === 'notes' && showRightsDivider" class="my-3 meta-rights-divider"></v-divider>
|
||||
<v-list-item
|
||||
v-show="shouldShowFieldRow(f)"
|
||||
v-tooltip="f.label"
|
||||
:prepend-icon="f.icon"
|
||||
:class="['metadata__item', `meta-${f.key}`, { clickable: editingField !== f.key && (isEditable || f.read(photo)) }]"
|
||||
@click.stop="onTextRowClick(f.key, f.read(photo))"
|
||||
>
|
||||
|
|
@ -432,7 +413,8 @@
|
|||
v-if="editingField === f.key"
|
||||
:ref="setInlineEditorRef"
|
||||
:model-value="f.read(photo)"
|
||||
:placeholder="f.label"
|
||||
:rules="rules.text(false, 0, f.maxLength, f.label)"
|
||||
:rows="1"
|
||||
density="compact"
|
||||
auto-grow
|
||||
hide-details="auto"
|
||||
|
|
@ -440,15 +422,24 @@
|
|||
class="meta-inline-edit"
|
||||
:class="`meta-inline-${f.key}`"
|
||||
@update:model-value="(v) => f.write(photo, v)"
|
||||
@keydown.enter="(ev) => onInlineEnter(ev, f)"
|
||||
@keydown.escape.stop.prevent="cancelEditing"
|
||||
@blur="onInlineFieldBlur"
|
||||
></v-textarea>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- f.htmlValue references a sanitized computed (e.g. notesHtml) — encode-then-sanitize via $util.sanitizeHtml($util.encodeHTML(raw)). -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- f.htmlValue points at a sanitized computed (e.g. notesHtml) which runs $util.sanitizeHtml($util.encodeHTML(raw)). -->
|
||||
<div v-else-if="f.display === 'html' && fieldHtml(f)" class="text-body-2 meta-scrollable" :class="`meta-${f.key}`" v-html="fieldHtml(f)"></div>
|
||||
<div v-else-if="f.display !== 'html' && f.read(photo)" class="text-body-2 meta-scrollable" :class="`meta-${f.key}`">
|
||||
{{ f.read(photo) }}
|
||||
</div>
|
||||
<div v-else class="meta-add-prompt" @click.stop="startEditing(f.key)">{{ f.label }}</div>
|
||||
<div v-else-if="f.display !== 'html' && f.read(photo)" class="text-body-2 meta-scrollable" :class="`meta-${f.key}`">{{ f.read(photo) }}</div>
|
||||
<div v-else class="meta-add-prompt" @click.stop="startEditing(f.key)">{{ f.placeholder ? f.placeholder : f.label }}</div>
|
||||
<template v-if="isEditable" #append>
|
||||
<p-sidebar-inline-toolbar
|
||||
:editing="editingField === f.key"
|
||||
:can-undo="editingField === f.key"
|
||||
:undo-disabled="!inlineEditDirty"
|
||||
@confirm="confirmField"
|
||||
@undo="undoInlineEdit"
|
||||
@start="startEditing(f.key)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -490,6 +481,7 @@ import * as media from "common/media";
|
|||
import typeaheadCache from "common/typeahead-cache";
|
||||
import { rules } from "common/form";
|
||||
import { Album } from "model/album";
|
||||
import { MaxLength as PhotoMaxLength } from "model/photo";
|
||||
import PMap from "component/map.vue";
|
||||
import PMetaDatetimeDialog from "component/meta/datetime/dialog.vue";
|
||||
import PMetaCameraDialog from "component/meta/camera/dialog.vue";
|
||||
|
|
@ -550,9 +542,20 @@ export default {
|
|||
// 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 stay visible because they
|
||||
// commit pending state rather than enter edit mode.
|
||||
// and Undo (`meta-inline-undo`) buttons have their own toggles
|
||||
// below.
|
||||
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
|
||||
|
|
@ -744,9 +747,14 @@ export default {
|
|||
},
|
||||
// Single source of truth for inline-text fields. Each entry knows how to
|
||||
// read/write its raw value, what label to render (tooltip, placeholder,
|
||||
// add-prompt), and whether the display branch should treat the value as
|
||||
// sanitized HTML (Caption, Notes) or plain text (everything else).
|
||||
// detailsFields/textFields below select subsets for the two visual layouts.
|
||||
// add-prompt), the per-field maxLength (sourced from PhotoMaxLength which
|
||||
// mirrors the backend VARCHAR + clip caps so UI validation matches what
|
||||
// the server will persist), and whether the display branch should treat
|
||||
// the value as sanitized HTML (Caption, Notes) or plain text. The
|
||||
// detailsFields computed below selects the subset rendered in the merged
|
||||
// single-row icon-prepended layout (everything except Title and Caption,
|
||||
// which keep their bespoke header rows above the file/taken/camera
|
||||
// section).
|
||||
fieldRegistry() {
|
||||
return {
|
||||
title: {
|
||||
|
|
@ -757,6 +765,7 @@ export default {
|
|||
if (p) p.Title = v;
|
||||
},
|
||||
display: "text",
|
||||
maxLength: PhotoMaxLength.Title,
|
||||
},
|
||||
caption: {
|
||||
key: "caption",
|
||||
|
|
@ -767,26 +776,19 @@ export default {
|
|||
},
|
||||
display: "html",
|
||||
htmlValue: "captionHtml",
|
||||
maxLength: PhotoMaxLength.Caption,
|
||||
},
|
||||
subject: {
|
||||
key: "subject",
|
||||
label: this.$gettext("Subject"),
|
||||
icon: "mdi-text-box-outline",
|
||||
icon: "mdi-flower-tulip-outline",
|
||||
read: (p) => p?.Details?.Subject,
|
||||
write: (p, v) => {
|
||||
if (p?.Details) p.Details.Subject = v;
|
||||
},
|
||||
display: "text",
|
||||
},
|
||||
artist: {
|
||||
key: "artist",
|
||||
label: this.$gettext("Artist"),
|
||||
icon: "mdi-palette",
|
||||
read: (p) => p?.Details?.Artist,
|
||||
write: (p, v) => {
|
||||
if (p?.Details) p.Details.Artist = v;
|
||||
},
|
||||
display: "text",
|
||||
maxLength: PhotoMaxLength.Subject,
|
||||
commitOnEnter: true,
|
||||
},
|
||||
copyright: {
|
||||
key: "copyright",
|
||||
|
|
@ -797,49 +799,92 @@ export default {
|
|||
if (p?.Details) p.Details.Copyright = v;
|
||||
},
|
||||
display: "text",
|
||||
maxLength: PhotoMaxLength.Copyright,
|
||||
commitOnEnter: true,
|
||||
},
|
||||
artist: {
|
||||
key: "artist",
|
||||
label: this.$gettext("Artist"),
|
||||
icon: "mdi-human-handsdown",
|
||||
read: (p) => p?.Details?.Artist,
|
||||
write: (p, v) => {
|
||||
if (p?.Details) p.Details.Artist = v;
|
||||
},
|
||||
display: "text",
|
||||
maxLength: PhotoMaxLength.Artist,
|
||||
commitOnEnter: true,
|
||||
},
|
||||
license: {
|
||||
key: "license",
|
||||
label: this.$gettext("License"),
|
||||
icon: "mdi-license",
|
||||
icon: "mdi-scale-balance",
|
||||
read: (p) => p?.Details?.License,
|
||||
write: (p, v) => {
|
||||
if (p?.Details) p.Details.License = v;
|
||||
},
|
||||
display: "text",
|
||||
maxLength: PhotoMaxLength.License,
|
||||
commitOnEnter: true,
|
||||
},
|
||||
keywords: {
|
||||
key: "keywords",
|
||||
label: this.$gettext("Keywords"),
|
||||
icon: "mdi-tag-multiple-outline",
|
||||
read: (p) => p?.Details?.Keywords,
|
||||
write: (p, v) => {
|
||||
if (p?.Details) p.Details.Keywords = v;
|
||||
},
|
||||
display: "text",
|
||||
maxLength: PhotoMaxLength.Keywords,
|
||||
commitOnEnter: true,
|
||||
},
|
||||
notes: {
|
||||
key: "notes",
|
||||
label: this.$gettext("Notes"),
|
||||
placeholder: this.$gettext("Add Notes"),
|
||||
icon: null,
|
||||
read: (p) => p?.Details?.Notes,
|
||||
write: (p, v) => {
|
||||
if (p?.Details) p.Details.Notes = v;
|
||||
},
|
||||
display: "html",
|
||||
htmlValue: "notesHtml",
|
||||
maxLength: PhotoMaxLength.Notes,
|
||||
},
|
||||
};
|
||||
},
|
||||
detailsFields() {
|
||||
return ["subject", "artist", "copyright", "license"].map((k) => this.fieldRegistry[k]);
|
||||
},
|
||||
textFields() {
|
||||
return ["keywords", "notes"].map((k) => this.fieldRegistry[k]);
|
||||
return ["subject", "copyright", "artist", "license", "keywords", "notes"].map((k) => this.fieldRegistry[k]);
|
||||
},
|
||||
showDetailsSection() {
|
||||
if (this.restrictedRole) return false;
|
||||
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.
|
||||
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.
|
||||
showRightsDivider() {
|
||||
if (this.isEditable) return true;
|
||||
const hasAbove = ["subject", "artist", "copyright", "license", "keywords"].some((k) => this.shouldShowFieldRow(this.fieldRegistry[k]));
|
||||
const hasBelow = this.shouldShowFieldRow(this.fieldRegistry.notes);
|
||||
return hasAbove && hasBelow;
|
||||
},
|
||||
placeName() {
|
||||
if (!this.photo) return "";
|
||||
return this.photo.locationInfo() || "";
|
||||
|
|
@ -1070,6 +1115,28 @@ export default {
|
|||
if (f.display === "html") return Boolean(this.fieldHtml(f));
|
||||
return Boolean(f.read(this.photo));
|
||||
},
|
||||
// Enter handler for the merged details textareas. Commits when the
|
||||
// field opts in via `commitOnEnter` (single-line fields: Subject,
|
||||
// Artist, Copyright, License, Keywords) and Shift is not held;
|
||||
// Caption + Notes leave commitOnEnter unset so plain Enter falls
|
||||
// through to the textarea's default newline insertion. Shift+Enter
|
||||
// also falls through, giving power users a way to add a line break
|
||||
// even on the single-line fields.
|
||||
onInlineEnter(ev, f) {
|
||||
if (!f?.commitOnEnter || ev.shiftKey) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.confirmField();
|
||||
},
|
||||
// undoInlineEdit reverts the active inline editor to its
|
||||
// editOriginal value without exiting edit mode — distinct from
|
||||
// cancelEditing (Escape) which reverts AND exits. Wired to the
|
||||
// Undo button on every inline-text toolbar so mouse users get
|
||||
// parity with the keyboard cancel.
|
||||
undoInlineEdit() {
|
||||
if (!this.editingField || this.editOriginal === null) return;
|
||||
this.setFieldValue(this.editingField, this.editOriginal);
|
||||
},
|
||||
startEditing(field) {
|
||||
if (this.editingField) {
|
||||
this.cancelEditing();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<div class="p-sidebar-inline-toolbar d-flex">
|
||||
<v-btn
|
||||
v-if="editing && canUndo"
|
||||
:disabled="undoDisabled"
|
||||
icon="mdi-undo-variant"
|
||||
density="compact"
|
||||
variant="plain"
|
||||
|
|
@ -48,6 +49,15 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Renders the Undo button in a disabled (non-clickable) state — used
|
||||
// by inline-text editors that always show Undo while editing but
|
||||
// want it inactive until the value differs from the editOriginal.
|
||||
// Chip toolbars leave this at false (their parent v-if already
|
||||
// gates mounting on whether Undo would do anything).
|
||||
undoDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["confirm", "start", "undo"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -411,7 +411,16 @@
|
|||
}
|
||||
|
||||
.p-lightbox__container > .p-lightbox__sidebar .v-input--density-compact {
|
||||
--v-input-control-height: 30px;
|
||||
/* Drives both the static control height for v-text-field rows
|
||||
(Title) and the floor for auto-grown v-textareas. Vuetify routes
|
||||
this var through `.v-textarea .v-field__field { --v-input-control-height:
|
||||
var(--v-textarea-control-height) }` so when auto-grow writes the
|
||||
calculated control height inline, the v-field__input min-height
|
||||
(which uses `max(var(--v-input-control-height, …), …)`) follows.
|
||||
Pinning a static `min-height` on .v-field__input would shadow that
|
||||
inheritance and freeze the textarea at one row — see the auto-grow
|
||||
regression notes in the May 15 sidebar refinement. */
|
||||
--v-input-control-height: 36px;
|
||||
}
|
||||
|
||||
.p-lightbox__container > .p-lightbox__sidebar .v-input--density-compact .v-field {
|
||||
|
|
@ -426,7 +435,6 @@
|
|||
}
|
||||
|
||||
.p-lightbox__container > .p-lightbox__sidebar .v-input--density-compact .v-field .v-field__input {
|
||||
min-height: 36px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
|
@ -439,7 +447,16 @@
|
|||
scrollbar. Drop both if a fresh visual pass shows the OS defaults are
|
||||
acceptable. */
|
||||
.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit textarea.v-field__input {
|
||||
min-height: unset;
|
||||
/* min-height intentionally NOT unset: Vuetify's stock rule
|
||||
`.v-field__input { min-height: max(var(--v-input-control-height, …), …) }`
|
||||
is what carries the auto-grow signal — when `auto-grow` runs, it
|
||||
writes `--v-textarea-control-height` inline on `.v-field`, and
|
||||
the `.v-textarea .v-field__field { --v-input-control-height:
|
||||
var(--v-textarea-control-height) }` rule rewires it for the
|
||||
textarea's children. Killing min-height here would freeze the
|
||||
textarea at the .v-field floor (~36px) while the calculated
|
||||
height grows in the inline style — see the May 15 sidebar
|
||||
regression notes. */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(var(--v-theme-on-surface), 0.2) transparent;
|
||||
}
|
||||
|
|
@ -484,6 +501,23 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* Sidebar toggles for the inline Undo and Save buttons. Both default
|
||||
to ON (buttons hidden) since the keyboard paths are first-class:
|
||||
Enter commits on the commitOnEnter fields, blur commits on Caption
|
||||
+ Notes, Escape cancels, and Ctrl+Z reverts inside the focused
|
||||
textarea. Per-user preference or A/B variants flip `hideEditUndo` /
|
||||
`hideEditSave` on the sidebar root to surface the mouse-driven
|
||||
affordances. The chip-section toolbars share these class names, so
|
||||
the same toggles also hide their batched-removal Save / Undo
|
||||
buttons. */
|
||||
.p-lightbox__container > .p-lightbox__sidebar > .p-sidebar-info.hide-edit-undo .meta-inline-undo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.p-lightbox__container > .p-lightbox__sidebar > .p-sidebar-info.hide-edit-save .meta-inline-confirm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Add prompt for empty editable fields */
|
||||
.p-lightbox__container > .p-lightbox__sidebar .meta-add-prompt {
|
||||
color: rgba(var(--v-theme-on-surface), 0.35);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,24 @@ export const TimeZoneLocal = "Local";
|
|||
|
||||
export let BatchSize = 156;
|
||||
|
||||
// MaxLength mirrors the backend VARCHAR caps so UI validation matches
|
||||
// what the server will actually persist. Keep in sync with the GORM
|
||||
// struct tags in internal/entity/photo.go (PhotoTitle, PhotoCaption)
|
||||
// and internal/entity/details.go (Subject, Artist, Copyright, License,
|
||||
// Keywords, Notes). The Set* helpers in details.go further clip via
|
||||
// txt.ClipShortText (1024) / txt.ClipText (2048); these caps mirror
|
||||
// that ceiling, not the looser raw VARCHAR length.
|
||||
export const MaxLength = Object.freeze({
|
||||
Title: 200,
|
||||
Caption: 4096,
|
||||
Subject: 1024,
|
||||
Artist: 1024,
|
||||
Copyright: 1024,
|
||||
License: 1024,
|
||||
Keywords: 2048,
|
||||
Notes: 2048,
|
||||
});
|
||||
|
||||
// Photo models core metadata for images and videos shown in the UI.
|
||||
export class Photo extends RestModel {
|
||||
constructor(values) {
|
||||
|
|
|
|||
|
|
@ -1352,6 +1352,61 @@ describe("PSidebarInfo component", () => {
|
|||
expect(w.vm.editingField).toBe("title");
|
||||
});
|
||||
|
||||
// onInlineEnter — Enter commits on single-line fields (commitOnEnter:
|
||||
// true) and falls through (no preventDefault) for free-form fields
|
||||
// like Notes / Caption so the textarea can insert a newline. Shift+
|
||||
// Enter always falls through, even on commitOnEnter fields.
|
||||
describe("onInlineEnter (commit-on-Enter for single-line fields)", () => {
|
||||
let w;
|
||||
beforeEach(() => {
|
||||
w = mountSidebar({
|
||||
props: {
|
||||
modelValue: mockModel,
|
||||
photo: { ...mockPhoto, Details: { Subject: "", Notes: "", Keywords: "" }, update: vi.fn(), wasChanged: () => false },
|
||||
canEdit: true,
|
||||
context: contexts.Photos,
|
||||
},
|
||||
global: { stubs: { PMap: true } },
|
||||
});
|
||||
});
|
||||
|
||||
function makeEvent(shiftKey) {
|
||||
return { shiftKey, preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
}
|
||||
|
||||
it("commits and suppresses the newline for commitOnEnter fields", () => {
|
||||
const subject = w.vm.fieldRegistry.subject;
|
||||
expect(subject.commitOnEnter).toBe(true);
|
||||
const ev = makeEvent(false);
|
||||
const confirmSpy = vi.spyOn(w.vm, "confirmField").mockImplementation(() => {});
|
||||
w.vm.onInlineEnter(ev, subject);
|
||||
expect(ev.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(ev.stopPropagation).toHaveBeenCalledTimes(1);
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls through (no commit) for fields without commitOnEnter (Notes)", () => {
|
||||
const notes = w.vm.fieldRegistry.notes;
|
||||
expect(notes.commitOnEnter).toBeUndefined();
|
||||
const ev = makeEvent(false);
|
||||
const confirmSpy = vi.spyOn(w.vm, "confirmField").mockImplementation(() => {});
|
||||
w.vm.onInlineEnter(ev, notes);
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through on Shift+Enter even for commitOnEnter fields", () => {
|
||||
const keywords = w.vm.fieldRegistry.keywords;
|
||||
expect(keywords.commitOnEnter).toBe(true);
|
||||
const ev = makeEvent(true);
|
||||
const confirmSpy = vi.spyOn(w.vm, "confirmField").mockImplementation(() => {});
|
||||
w.vm.onInlineEnter(ev, keywords);
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should still cancel on Escape via cancelEditing", async () => {
|
||||
const photo = {
|
||||
...mockPhoto,
|
||||
|
|
@ -1963,6 +2018,19 @@ describe("PSidebarInfo component", () => {
|
|||
expect(wrapper.vm.notesHtml).toBe("Some notes about this photo");
|
||||
});
|
||||
|
||||
// The merged single-row detailsFields template must route Notes
|
||||
// through the sanitized v-html branch (display: "html" + htmlValue:
|
||||
// "notesHtml") rather than plain-text interpolation. Catches any
|
||||
// future regression where the html-aware ladder is dropped from the
|
||||
// merged loop and Notes silently falls back to the text branch.
|
||||
it("renders Notes through the sanitized v-html branch in the merged details row", () => {
|
||||
const notesRow = wrapper.find(".meta-notes");
|
||||
expect(notesRow.exists()).toBe(true);
|
||||
const innerNotes = notesRow.find(".meta-scrollable.meta-notes");
|
||||
expect(innerNotes.exists()).toBe(true);
|
||||
expect(innerNotes.html()).toContain("Some notes about this photo");
|
||||
});
|
||||
|
||||
it("should return empty caption HTML when no caption", () => {
|
||||
const w = mountSidebar({
|
||||
props: { modelValue: { ...mockModel, Caption: "" }, photo: mockPhoto, context: contexts.Photos },
|
||||
|
|
@ -2038,6 +2106,94 @@ describe("PSidebarInfo component", () => {
|
|||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
// inlineEditDirty + undoInlineEdit — Undo affordance for inline-text
|
||||
// editors. The Undo button is always shown while editing, but
|
||||
// disabled until inlineEditDirty flips true; clicking it reverts to
|
||||
// the editOriginal captured at startEditing time without exiting
|
||||
// edit mode (Escape is the revert-AND-exit gesture).
|
||||
describe("inline edit Undo", () => {
|
||||
let w;
|
||||
let photo;
|
||||
beforeEach(() => {
|
||||
photo = {
|
||||
...mockPhoto,
|
||||
Title: "Original Title",
|
||||
Details: { Subject: "Original Subject", Notes: "", Keywords: "" },
|
||||
update: vi.fn(),
|
||||
wasChanged: () => true,
|
||||
};
|
||||
w = mountSidebar({
|
||||
props: { modelValue: mockModel, photo, canEdit: true, context: contexts.Photos },
|
||||
global: { stubs: { PMap: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("inlineEditDirty is false when no field is being edited", () => {
|
||||
expect(w.vm.editingField).toBeNull();
|
||||
expect(w.vm.inlineEditDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("inlineEditDirty stays false right after startEditing (value unchanged)", async () => {
|
||||
w.vm.startEditing("subject");
|
||||
await w.vm.$nextTick();
|
||||
expect(w.vm.editingField).toBe("subject");
|
||||
expect(w.vm.inlineEditDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("inlineEditDirty flips true once the field value diverges from the original", async () => {
|
||||
w.vm.startEditing("subject");
|
||||
await w.vm.$nextTick();
|
||||
// Route the mutation through the reactive write helper that the
|
||||
// textarea's @update:model-value handler uses, so Vue's computed
|
||||
// dependency tracking picks it up.
|
||||
w.vm.setFieldValue("subject", "Edited");
|
||||
await w.vm.$nextTick();
|
||||
expect(w.vm.getFieldValue("subject")).toBe("Edited");
|
||||
expect(w.vm.inlineEditDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("undoInlineEdit reverts to editOriginal without exiting edit mode", async () => {
|
||||
w.vm.startEditing("subject");
|
||||
await w.vm.$nextTick();
|
||||
w.vm.setFieldValue("subject", "Edited");
|
||||
await w.vm.$nextTick();
|
||||
expect(w.vm.editingField).toBe("subject");
|
||||
|
||||
w.vm.undoInlineEdit();
|
||||
await w.vm.$nextTick();
|
||||
expect(w.vm.getFieldValue("subject")).toBe("Original Subject");
|
||||
expect(w.vm.editingField).toBe("subject");
|
||||
expect(w.vm.inlineEditDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("undoInlineEdit is a no-op when no field is being edited", () => {
|
||||
expect(w.vm.editingField).toBeNull();
|
||||
w.vm.undoInlineEdit();
|
||||
expect(photo.Details.Subject).toBe("Original Subject");
|
||||
});
|
||||
|
||||
// Defaults-agnostic binding check: the test sets each flag to a
|
||||
// known value before asserting, so flipping the data() default
|
||||
// (e.g., for an A/B variant that hides Save by default) doesn't
|
||||
// break the test. What we care about is that the root :class
|
||||
// binding actually mirrors the flags in both directions.
|
||||
it("hide-edit-undo / hide-edit-save root classes mirror the data flags in both directions", async () => {
|
||||
const root = w.find(".p-sidebar-info");
|
||||
|
||||
w.vm.hideEditUndo = false;
|
||||
w.vm.hideEditSave = false;
|
||||
await w.vm.$nextTick();
|
||||
expect(root.classes()).not.toContain("hide-edit-undo");
|
||||
expect(root.classes()).not.toContain("hide-edit-save");
|
||||
|
||||
w.vm.hideEditUndo = true;
|
||||
w.vm.hideEditSave = true;
|
||||
await w.vm.$nextTick();
|
||||
expect(root.classes()).toContain("hide-edit-undo");
|
||||
expect(root.classes()).toContain("hide-edit-save");
|
||||
});
|
||||
});
|
||||
|
||||
// isEditable
|
||||
it("should not be editable without canEdit prop", () => {
|
||||
expect(wrapper.vm.isEditable).toBeFalsy();
|
||||
|
|
@ -3026,8 +3182,6 @@ describe("PSidebarInfo component", () => {
|
|||
expect(html).not.toContain(">People<");
|
||||
expect(html).not.toContain(">Labels<");
|
||||
expect(html).not.toContain(">Albums<");
|
||||
expect(html).not.toContain(">Keywords<");
|
||||
expect(html).not.toContain(">Notes<");
|
||||
|
||||
expect(html).not.toContain("Jane Doe");
|
||||
expect(html).not.toContain("Nature");
|
||||
|
|
@ -3133,8 +3287,6 @@ describe("PSidebarInfo component", () => {
|
|||
peopleHeader: ">People<",
|
||||
labelsHeader: ">Labels<",
|
||||
albumsHeader: ">Albums<",
|
||||
keywordsHeader: ">Keywords<",
|
||||
notesHeader: ">Notes<",
|
||||
namedMarker: "Jane Doe",
|
||||
labelName: "Nature",
|
||||
albumTitle: "Vacation 2024",
|
||||
|
|
@ -3288,8 +3440,6 @@ describe("PSidebarInfo component", () => {
|
|||
TEXT.peopleHeader,
|
||||
TEXT.labelsHeader,
|
||||
TEXT.albumsHeader,
|
||||
TEXT.keywordsHeader,
|
||||
TEXT.notesHeader,
|
||||
TEXT.namedMarker,
|
||||
TEXT.labelName,
|
||||
TEXT.albumTitle,
|
||||
|
|
@ -3308,6 +3458,16 @@ describe("PSidebarInfo component", () => {
|
|||
const fileRow = w.find(".meta-file");
|
||||
expect(fileRow.exists()).toBe(true);
|
||||
expect(fileRow.text()).toContain(TEXT.filename);
|
||||
// Keywords/Notes are now part of the single-row icon-prepended
|
||||
// detailsFields layout (no more text-subtitle-2 header rows).
|
||||
// Confirm the rows render with their prepend icons and value
|
||||
// content under the canonical .meta-{key} selector.
|
||||
const keywordsRow = w.find(".meta-keywords");
|
||||
expect(keywordsRow.exists()).toBe(true);
|
||||
expect(keywordsRow.text()).toContain(TEXT.keywords);
|
||||
const notesRow = w.find(".meta-notes");
|
||||
expect(notesRow.exists()).toBe(true);
|
||||
expect(notesRow.html()).toContain(TEXT.notes);
|
||||
});
|
||||
|
||||
it("renders inline pencils and only the Edit Faces toggle (no eye toggle for editable users)", () => {
|
||||
|
|
@ -3350,8 +3510,6 @@ describe("PSidebarInfo component", () => {
|
|||
TEXT.peopleHeader,
|
||||
TEXT.labelsHeader,
|
||||
TEXT.albumsHeader,
|
||||
TEXT.keywordsHeader,
|
||||
TEXT.notesHeader,
|
||||
TEXT.namedMarker,
|
||||
TEXT.labelName,
|
||||
TEXT.albumTitle,
|
||||
|
|
@ -3383,7 +3541,7 @@ describe("PSidebarInfo component", () => {
|
|||
// be falsy even for an otherwise-editable session.
|
||||
expect(w.vm.isEditable).toBeFalsy();
|
||||
const html = w.html();
|
||||
for (const needle of [TEXT.camera, TEXT.lens, TEXT.placeName, TEXT.peopleHeader, TEXT.labelsHeader, TEXT.keywordsHeader, TEXT.notesHeader]) {
|
||||
for (const needle of [TEXT.camera, TEXT.lens, TEXT.placeName, TEXT.peopleHeader, TEXT.labelsHeader]) {
|
||||
expect(html).not.toContain(needle);
|
||||
}
|
||||
expect(w.find(".meta-inline-pencil").exists()).toBe(false);
|
||||
|
|
@ -3423,15 +3581,21 @@ describe("PSidebarInfo component", () => {
|
|||
// Add-prompt spans are the "click to start editing" placeholders.
|
||||
const prompts = w.findAll(".meta-add-prompt");
|
||||
expect(prompts.length).toBeGreaterThanOrEqual(5);
|
||||
// At least title, caption, keywords, notes, subject are all expected.
|
||||
// Title/Caption use a "Add a <Field>" affordance label; the others
|
||||
// surface the bare field name as the prompt.
|
||||
// Structural assertion for the merged details rows: each empty
|
||||
// field renders exactly one add-prompt under its canonical
|
||||
// .meta-{key} row. Avoids coupling to the exact label copy
|
||||
// (which UX iterates on per field).
|
||||
for (const key of ["subject", "copyright", "artist", "license", "keywords", "notes"]) {
|
||||
const row = w.find(`.meta-${key}`);
|
||||
expect(row.exists(), `missing .meta-${key} row`).toBe(true);
|
||||
expect(row.find(".meta-add-prompt").exists(), `missing add-prompt in .meta-${key}`).toBe(true);
|
||||
}
|
||||
// Title and Caption rows don't carry a per-key class on the row
|
||||
// itself (legacy hand-coded structure); fall back to label match
|
||||
// for those, since "Add a Title" / "Add a Caption" are stable.
|
||||
const texts = prompts.map((p) => p.text());
|
||||
expect(texts).toContain("Add a Title");
|
||||
expect(texts).toContain("Add a Caption");
|
||||
expect(texts).toContain("Keywords");
|
||||
expect(texts).toContain("Notes");
|
||||
expect(texts).toContain("Subject");
|
||||
});
|
||||
|
||||
// Explicit share-link (anonymous) case: the sidebar must behave
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import "../fixtures";
|
||||
import * as media from "common/media";
|
||||
import { Photo, BatchSize } from "model/photo";
|
||||
import { Photo, BatchSize, MaxLength } from "model/photo";
|
||||
import $event from "common/event";
|
||||
|
||||
// Drains the pubsub-js async queue so subscribers configured as `async: true`
|
||||
|
|
@ -9,6 +9,24 @@ import $event from "common/event";
|
|||
const flushEvents = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("model/photo", () => {
|
||||
// Pins the per-field length caps to the backend VARCHAR + clip ceilings.
|
||||
// If a backend bump (internal/entity/photo.go or details.go) changes any
|
||||
// of these, this test fails on purpose so the frontend cap moves with it.
|
||||
it("MaxLength mirrors the backend VARCHAR + clip caps", () => {
|
||||
expect(MaxLength).toEqual({
|
||||
Title: 200,
|
||||
Caption: 4096,
|
||||
Subject: 1024,
|
||||
Artist: 1024,
|
||||
Copyright: 1024,
|
||||
License: 1024,
|
||||
Keywords: 2048,
|
||||
Notes: 2048,
|
||||
});
|
||||
// Frozen so consumers can't accidentally mutate per-field caps.
|
||||
expect(Object.isFrozen(MaxLength)).toBe(true);
|
||||
});
|
||||
|
||||
it("should get photo entity name", () => {
|
||||
const values = { UID: 5, Title: "Crazy Cat" };
|
||||
const photo = new Photo(values);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue