feat(library): the single view walks the shelf from the picture itself, by arrow and by finger
SINGLE is where a roll is read one frame at a time, and the step from one frame to the next was a trip to the strip below the picture and back. The stage now carries the pair itself, at the edges the eye already reads as "there is more this way", dimmed at the ends of the roll rather than taken away so the pair does not jump about. The press stops at the arrow: the zoom, the pan and the open under it are the picture's. The phone's full-screen view has no room for a pair, so the same step is the reader's own finger. A drag that travelled sideways is a step the way the list is read; one that wandered up and down, and a tap in place, are left alone. Both walk the list on screen — filters and sort included — so what is drawn after the step is what stood beside it. Verified against a local preview with a seeded shelf: the arrows appear, the left one is dimmed on the newest frame and the right one on the oldest, and a left swipe on the phone moves to the next frame and back.
This commit is contained in:
@@ -2216,6 +2216,20 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
setView('preview');
|
||||
};
|
||||
|
||||
// The frame next to this one, on the shelf's own order: the arrows at the
|
||||
// edges of the stage and the swipe in the mobile preview both walk the list the
|
||||
// reader is looking at, so what is drawn after the step is what was beside it.
|
||||
const stepFrame = useCallback(
|
||||
(d: number) => {
|
||||
if (!frame) return;
|
||||
const i = listed.findIndex((p) => p.id === frame.id);
|
||||
if (i < 0) return;
|
||||
const next = listed[i + d];
|
||||
if (next) setUp(next.id);
|
||||
},
|
||||
[frame?.id, listed],
|
||||
);
|
||||
|
||||
// A frame's score: click a star to give it, click the star it already has to
|
||||
// take the score back. The catalogue holds it, so it is the same score the next
|
||||
// visit reads — and the list on screen is told, rather than read back whole for
|
||||
@@ -2469,6 +2483,9 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
const gradedTile = frame && edited.has(frame.id) ? urls[frame.id] ?? null : null;
|
||||
const stageSrc = gradedTile ?? (stageUrl && stageFor === frame?.id ? stageUrl : frame ? urls[frame.id] ?? null : null);
|
||||
const stageTurned = useTurned(stageSrc, frame?.rot ?? 0);
|
||||
// Where the open frame stands on the shelf, so the arrows at the stage's edges
|
||||
// know which of them has nothing to go to.
|
||||
const frameAt = listed.findIndex((p) => p.id === frame?.id);
|
||||
|
||||
|
||||
|
||||
@@ -3185,6 +3202,50 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
{/* The frame's neighbours, at the edges the eye already
|
||||
reads as "there is more this way". Both are drawn
|
||||
whenever the shelf holds another frame, and the one
|
||||
with nothing to go to is dimmed rather than taken
|
||||
away, so the pair does not jump about. They are the
|
||||
stage's own overlay: a press on one must not reach
|
||||
the zoom, the pan or the open under it. */}
|
||||
{listed.length > 1 && frameAt >= 0 ? (
|
||||
<>
|
||||
{(
|
||||
[
|
||||
{ d: -1, key: 'lib-prev', edge: { left: '10px' }, label: t('lib.prev'), icon: 'M10 2 4 8l6 6' },
|
||||
{ d: 1, key: 'lib-next', edge: { right: '10px' }, label: t('lib.next'), icon: 'M6 2l6 6-6 6' },
|
||||
] as const
|
||||
).map((a) => {
|
||||
const at = frameAt + a.d;
|
||||
const off = at < 0 || at >= listed.length;
|
||||
return (
|
||||
<button
|
||||
key={a.key}
|
||||
type="button"
|
||||
className="lib-stage-nav"
|
||||
data-key={a.key}
|
||||
disabled={off}
|
||||
aria-label={a.label}
|
||||
title={a.label}
|
||||
style={{ ...a.edge, opacity: off ? 0.25 : 1, cursor: off ? 'default' : 'pointer' }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerMove={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
stepFrame(a.d);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={a.icon} />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
{stageTurned ? (
|
||||
<img
|
||||
className="adm-stage-img"
|
||||
|
||||
@@ -278,6 +278,8 @@ export const en: Dict = {
|
||||
'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)',
|
||||
'lib.viewPreview': 'SINGLE',
|
||||
'lib.viewGrid': 'ALL THUMBS',
|
||||
'lib.prev': 'Previous frame',
|
||||
'lib.next': 'Next frame',
|
||||
'lib.subsShow': 'SHOW SUBFOLDERS',
|
||||
'lib.subsHide': 'THIS FOLDER ONLY',
|
||||
'lib.subsHint': 'Frames in subfolders: the whole branch under the open folder, or only what that folder holds itself.',
|
||||
|
||||
@@ -289,6 +289,8 @@ export const vi = {
|
||||
'lib.colWidth': 'Kéo để chỉnh độ rộng cột thư mục (mũi tên trái/phải khi đang chọn)',
|
||||
'lib.viewPreview': 'MỘT ẢNH',
|
||||
'lib.viewGrid': 'TOÀN BỘ',
|
||||
'lib.prev': 'Ảnh trước',
|
||||
'lib.next': 'Ảnh sau',
|
||||
'lib.subsShow': 'HIỆN CẢ THƯ MỤC CON',
|
||||
'lib.subsHide': 'CHỈ THƯ MỤC ĐANG CHỌN',
|
||||
'lib.subsHint':
|
||||
|
||||
@@ -1420,6 +1420,27 @@ form[data-key='immich-modal'] {
|
||||
.lib-root .adm-stage { min-height: 0; overflow: hidden; }
|
||||
.lib-root .adm-stage-viewport { flex: 1 1 auto; min-height: 0; width: 100%; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
.lib-root .adm-stage-img { flex: 1 1 auto; min-height: 0; width: 100%; aspect-ratio: auto; object-fit: contain; }
|
||||
/* The pair that walks the shelf from the stage itself: half out of the picture's
|
||||
way, over the sunken ground at either edge, and never a step along with it —
|
||||
the press is the arrow's and stops before the zoom and the pan under it. */
|
||||
.lib-stage-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--bg-sunken) 70%, transparent);
|
||||
color: var(--text);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.lib-stage-nav:hover:not(:disabled) { background: var(--bg-sunken); border-color: var(--accent); color: var(--accent); }
|
||||
.lib-root .adm-stage > .lib-noimg { flex: 1 1 auto; min-height: 0; aspect-ratio: auto; }
|
||||
.lib-root .adm-stage > .adm-meta,
|
||||
.lib-root .adm-stage > .chip-row { flex: 0 0 auto; }
|
||||
|
||||
@@ -584,6 +584,18 @@ export function MobileLibrary({
|
||||
return groups;
|
||||
}, [visiblePhotos, lang]);
|
||||
|
||||
// The frame a swipe lands on: the list the reader is looking at is the shelf,
|
||||
// so the step goes the way that list is read and stops at its ends.
|
||||
const stepPhoto = (d: number) => {
|
||||
if (!selectedPhoto) return;
|
||||
const i = searchResults.findIndex((p) => p.id === selectedPhoto.id);
|
||||
const next = i < 0 ? undefined : searchResults[i + d];
|
||||
if (next) setSelectedPhoto(next);
|
||||
};
|
||||
// Where the finger went down: only the distance it travelled matters, and only
|
||||
// once it has lifted.
|
||||
const swipeFrom = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Filtered photos in Search tab
|
||||
const searchResults = useMemo(() => {
|
||||
let list = photos;
|
||||
@@ -2243,7 +2255,28 @@ export function MobileLibrary({
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mob-lib-preview-body">
|
||||
{/* A finger across the picture is the shelf's own step: left goes the way
|
||||
the list is read, right goes back. A drag that wandered more up and
|
||||
down than sideways is a scroll of a page that cannot scroll, and is
|
||||
left alone — and so is a tap, which is a press and a lift in place. */}
|
||||
<div
|
||||
className="mob-lib-preview-body"
|
||||
data-key="mob-lib-preview-body"
|
||||
onTouchStart={(e) => {
|
||||
const t = e.touches[0];
|
||||
swipeFrom.current = { x: t.clientX, y: t.clientY };
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
const start = swipeFrom.current;
|
||||
swipeFrom.current = null;
|
||||
if (!start) return;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - start.x;
|
||||
const dy = t.clientY - start.y;
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy)) return;
|
||||
stepPhoto(dx < 0 ? 1 : -1);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className="mob-lib-preview-img"
|
||||
src={remoteUrl('preview', selectedPhoto)}
|
||||
|
||||
Reference in New Issue
Block a user