feat(library & studio): add EXIF info bar, portrait frame panning, and multi-photo selection with Panorama/HDR context menu
This commit is contained in:
+107
-19
@@ -271,6 +271,8 @@ function Catalogue() {
|
||||
// of a tree — which also carries folding the whole tree.
|
||||
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; rel?: string; node?: string; x: number; y: number } | null>(null);
|
||||
const [photoMenu, setPhotoMenu] = useState<{ photo: LibraryPhoto; x: number; y: number } | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||
// A reading of this roll that is still waiting its turn. The menu is where
|
||||
// that wait is called off, so it has to know one exists.
|
||||
const queued = menu?.folder ? scanQueue().some((j) => j.folder === menu.folder!.name) : false;
|
||||
@@ -969,6 +971,58 @@ function Catalogue() {
|
||||
return order === 'oldest' ? [...rows].reverse() : rows;
|
||||
}, [shown, minStar, year, from, to, order]);
|
||||
|
||||
const handleSelectPhoto = useCallback(
|
||||
(photo: LibraryPhoto, e: React.MouseEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey;
|
||||
const isShift = e.shiftKey;
|
||||
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isShift && lastSelectedId) {
|
||||
const currentIndex = listed.findIndex((p) => p.id === photo.id);
|
||||
const lastIndex = listed.findIndex((p) => p.id === lastSelectedId);
|
||||
if (currentIndex !== -1 && lastIndex !== -1) {
|
||||
const start = Math.min(currentIndex, lastIndex);
|
||||
const end = Math.max(currentIndex, lastIndex);
|
||||
for (let i = start; i <= end; i++) {
|
||||
next.add(listed[i].id);
|
||||
}
|
||||
} else {
|
||||
next.add(photo.id);
|
||||
}
|
||||
} else if (isCtrl) {
|
||||
if (next.has(photo.id)) {
|
||||
next.delete(photo.id);
|
||||
} else {
|
||||
next.add(photo.id);
|
||||
}
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(photo.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedId(photo.id);
|
||||
},
|
||||
[listed, lastSelectedId]
|
||||
);
|
||||
|
||||
const handlePhotoContextMenu = useCallback((photo: LibraryPhoto, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.has(photo.id)) {
|
||||
return prev;
|
||||
} else {
|
||||
return new Set([photo.id]);
|
||||
}
|
||||
});
|
||||
setPhotoMenu({
|
||||
photo,
|
||||
x: Math.min(e.clientX, window.innerWidth - 220),
|
||||
y: Math.min(e.clientY, window.innerHeight - 240),
|
||||
});
|
||||
}, []);
|
||||
|
||||
// The wall is the other list, and it draws its rows the way the strip draws its
|
||||
// tiles: the ones around the eye, with a spacer standing in for the rest. A card
|
||||
// is a card, an `<img>` and an object URL of its own, and twenty thousand of them
|
||||
@@ -1605,17 +1659,11 @@ function Catalogue() {
|
||||
{wall.above > 0 ? <span className="adm-wall-gap" style={{ height: wall.above }} aria-hidden="true" /> : null}
|
||||
{painted.map((photo) => (
|
||||
<article
|
||||
className="adm-card"
|
||||
className={`adm-card${selectedIds.has(photo.id) ? ' selected' : ''}`}
|
||||
key={photo.id}
|
||||
data-key={`lib-photo-${photo.id}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setPhotoMenu({
|
||||
photo,
|
||||
x: Math.min(e.clientX, window.innerWidth - 220),
|
||||
y: Math.min(e.clientY, window.innerHeight - 160),
|
||||
});
|
||||
}}
|
||||
onClick={(e) => handleSelectPhoto(photo, e)}
|
||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||
>
|
||||
{tile(photo, () => void open(photo))}
|
||||
<div className="adm-meta">
|
||||
@@ -1626,7 +1674,15 @@ function Catalogue() {
|
||||
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="btn primary" data-key={`lib-open-${photo.id}`} onClick={() => void open(photo)}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-key={`lib-open-${photo.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void open(photo);
|
||||
}}
|
||||
>
|
||||
{t('lib.open')}
|
||||
</button>
|
||||
</article>
|
||||
@@ -1775,18 +1831,14 @@ function Catalogue() {
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
className={`adm-thumb${frame?.id === photo.id ? ' on' : ''}`}
|
||||
className={`adm-thumb${frame?.id === photo.id ? ' on' : ''}${selectedIds.has(photo.id) ? ' selected' : ''}`}
|
||||
data-key={`lib-thumb-${photo.id}`}
|
||||
title={photo.name}
|
||||
onClick={() => setUp(photo.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setPhotoMenu({
|
||||
photo,
|
||||
x: Math.min(e.clientX, window.innerWidth - 220),
|
||||
y: Math.min(e.clientY, window.innerHeight - 160),
|
||||
});
|
||||
onClick={(e) => {
|
||||
setUp(photo.id);
|
||||
handleSelectPhoto(photo, e);
|
||||
}}
|
||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||
>
|
||||
{tile(photo)}
|
||||
<span className="adm-thumb-cap">{photo.name}</span>
|
||||
@@ -1966,6 +2018,42 @@ function Catalogue() {
|
||||
style={{ left: photoMenu.x, top: photoMenu.y }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{selectedIds.size >= 2 ? (
|
||||
<div style={{ padding: '4px 10px 6px', fontSize: '11px', fontWeight: 600, color: 'var(--accent)', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
{t('lib.count', { n: selectedIds.size })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key="lib-photo-merge-panorama"
|
||||
onClick={() => {
|
||||
const count = selectedIds.size >= 2 ? selectedIds.size : 1;
|
||||
setPhotoMenu(null);
|
||||
setNote(`${t('lib.mergePanorama')} (${count} photos)`);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">🏞️</span>
|
||||
{t('lib.mergePanorama')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key="lib-photo-merge-hdr"
|
||||
onClick={() => {
|
||||
const count = selectedIds.size >= 2 ? selectedIds.size : 1;
|
||||
setPhotoMenu(null);
|
||||
setNote(`${t('lib.mergeHdr')} (${count} photos)`);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">🌄</span>
|
||||
{t('lib.mergeHdr')}
|
||||
</button>
|
||||
|
||||
<div style={{ height: '1px', background: 'var(--border-soft)', margin: '4px 0' }} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
|
||||
Reference in New Issue
Block a user