Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@
| `→` | Next image |
| `Escape` | Close lightbox |
| `d d` | Delete current image |
| Ratings ||
| `r {1-5}` | Set rating (stars) |
| `r 0` | Unset rating (stars) |
| `r {0-9} {0-9}` | Set rating (decimal - `00` for `10.0`) |
| ``r ` `` | Unset rating (decimal) |

## Groups page shortcuts

Expand Down
91 changes: 63 additions & 28 deletions ui/v2.5/src/hooks/Lightbox/Lightbox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Button,
Col,
Expand All @@ -15,6 +21,7 @@ import Mousetrap from "mousetrap";
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import useInterval from "../Interval";
import { useRatingKeybinds } from "../keybinds";
import usePageVisibility from "../PageVisibility";
import { useToast } from "../Toast";
import { FormattedMessage, useIntl } from "react-intl";
Expand Down Expand Up @@ -146,7 +153,6 @@ export const LightboxComponent: React.FC<IProps> = ({
// image the index has since moved to (e.g. a page-switch settle landing
// while the dialog is open).
const [deleteTarget, setDeleteTarget] = useState<ILightboxImage | null>(null);
const lastDKeyTime = useRef<number>(0);
const [navOffset, setNavOffset] = useState<React.CSSProperties | undefined>();

// An in-flight page switch's intended landing, set synchronously by
Expand Down Expand Up @@ -187,6 +193,17 @@ export const LightboxComponent: React.FC<IProps> = ({
[images, page, pageCallback, setSwitching]
);

// The lightbox pauses the global Mousetrap singleton while open, so it owns a
// separate (non-paused) instance for its own sequence shortcuts (ratings,
// "d d"). The mousetrap-pause plugin tracks `paused` per-instance, so this
// instance keeps firing while global shortcuts stay suppressed.
const mousetrap = useMemo(() => new Mousetrap(), []);
useEffect(() => {
return () => {
mousetrap.reset();
};
}, [mousetrap]);

const [zoom, setZoom] = useState(1);

function updateZoom(v: number) {
Expand Down Expand Up @@ -536,20 +553,8 @@ export const LightboxComponent: React.FC<IProps> = ({
if (e.key === "ArrowLeft") handleLeft();
else if (e.key === "ArrowRight") handleRight();
else if (e.key === "Escape") close();
else if (e.key === "d") {
// Not while a page switch is in flight: the index is parked at 0 then,
// so the shortcut would target an image the user isn't viewing.
const image = images[index ?? initialIndex];
if (!isSwitchingPageRef.current && image?.id !== undefined) {
const now = Date.now();
if (now - lastDKeyTime.current < 1000) {
setDeleteTarget(image);
}
lastDKeyTime.current = now;
}
}
},
[setInstant, handleLeft, handleRight, close, images, index, initialIndex]
[setInstant, handleLeft, handleRight, close]
);

const [clearCallback, resetCallback] = useInterval(
Expand Down Expand Up @@ -637,6 +642,49 @@ export const LightboxComponent: React.FC<IProps> = ({
};

const currentIndex = index === null ? initialIndex : index;
const currentImageId = images[currentIndex]?.id;

function setRating(v: number | null) {
if (currentImageId) {
updateImage({
variables: {
input: {
id: currentImageId,
rating100: v,
},
},
});
}
}

// Rating shortcuts ("r" then digit(s)) via the lightbox-scoped Mousetrap
// instance, reusing the same hook as the scene/image detail pages.
useRatingKeybinds(
isVisible,
config?.ui.ratingSystemOptions?.type,
(v) => setRating(Number.isNaN(v) ? null : v),
mousetrap
);

// "d d" delete shortcut, using Mousetrap's native sequence binding (matching
// the rest of the app) on the lightbox-scoped instance. Rebinding on
// currentImageId change resets Mousetrap's own sequence tracking, so a "d"
// press on one image can't combine with a second "d" press after
// navigating to another.
useEffect(() => {
if (!isVisible || currentImageId === undefined) return;

mousetrap.bind("d d", () => {
// Not while a page switch is in flight: the index is parked at 0 then,
// so the shortcut would target an image the user isn't viewing.
if (isSwitchingPageRef.current) return;
const image = images[currentIndex];
if (image?.id !== undefined) setDeleteTarget(image);
});
return () => {
mousetrap.unbind("d d");
};
}, [isVisible, currentImageId, images, currentIndex, mousetrap]);

useEffect(() => {
// Don't auto-close while images are still loading. Some entry points open
Expand Down Expand Up @@ -862,19 +910,6 @@ export const LightboxComponent: React.FC<IProps> = ({
const currentImage: ILightboxImage | undefined = images[currentIndex];
const title = currentImage ? imageTitle(currentImage) : undefined;

function setRating(v: number | null) {
if (currentImage?.id) {
updateImage({
variables: {
input: {
id: currentImage.id,
rating100: v,
},
},
});
}
}

async function onIncrementClick() {
if (currentImage?.id === undefined) return;
try {
Expand Down
142 changes: 84 additions & 58 deletions ui/v2.5/src/hooks/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,84 +2,110 @@ import Mousetrap from "mousetrap";
import { useEffect, useRef } from "react";
import { RatingSystemType } from "src/utils/rating";

const starRatingShortcuts: { [char: string]: number } = {
"0": NaN,
"1": 20,
"2": 40,
"3": 60,
"4": 80,
"5": 100,
};

type RatingSequenceMode = "idle" | "star" | "decimal";

export function useRatingKeybinds(
isVisible: boolean,
ratingSystem: RatingSystemType | undefined,
setRating: (v: number) => void
setRating: (v: number) => void,
mousetrap: Pick<Mousetrap.MousetrapInstance, "bind" | "unbind"> = Mousetrap
) {
// setRating/ratingSystem are recreated every render by every caller (they
// close over the currently displayed entity). Reading them through refs,
// updated unconditionally on each render, lets the bind effect below key
// only off isVisible/mousetrap while still always acting on the latest
// values -- rebinding "r" itself isn't needed to pick up a fresh setRating.
const setRatingRef = useRef(setRating);
setRatingRef.current = setRating;
const ratingSystemRef = useRef(ratingSystem);
ratingSystemRef.current = ratingSystem;

const mode = useRef<RatingSequenceMode>("idle");
const firstChar = useRef<string | undefined>(undefined);
const sequenceTimeout = useRef<ReturnType<typeof setTimeout>>();

const starRatingShortcuts: { [char: string]: number } = {
"0": NaN,
"1": 20,
"2": 40,
"3": 60,
"4": 80,
"5": 100,
};
useEffect(() => {
if (!isVisible) return;

function handleStarRatingKeybinds() {
for (const key in starRatingShortcuts) {
Mousetrap.bind(key, () => setRating(starRatingShortcuts[key]));
function endSequence() {
mode.current = "idle";
firstChar.current = undefined;
if (sequenceTimeout.current) {
clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = undefined;
}
}

setTimeout(() => {
for (const key in starRatingShortcuts) {
Mousetrap.unbind(key);
// "r", the digits and "`" are bound unconditionally for isVisible's
// lifetime, and gate their behaviour on `mode` instead of being bound
// and unbound per sequence. Callers pass a setRating closure they don't
// memoize, so this effect only depends on isVisible/mousetrap -- if it
// depended on setRating too, an unrelated re-render could tear down and
// rebind this effect mid-sequence, unbinding the digit keys before the
// 1s window elapses.
mousetrap.bind("r", () => {
// numeric keypresses get caught by jwplayer, so blur the element
// if the rating sequence is started
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}, 1000);
}

function handleDecimalKeybinds() {
Mousetrap.bind("`", () => {
setRating(NaN);
mode.current =
!ratingSystemRef.current ||
ratingSystemRef.current === RatingSystemType.Stars
? "star"
: "decimal";
firstChar.current = undefined;

if (sequenceTimeout.current) clearTimeout(sequenceTimeout.current);
sequenceTimeout.current = setTimeout(endSequence, 1000);
});

mousetrap.bind("`", () => {
if (mode.current !== "decimal") return;
setRatingRef.current(NaN);
endSequence();
});

for (let i = 0; i <= 9; ++i) {
Mousetrap.bind(i.toString(), () => {
if (firstChar.current !== undefined) {
let combined = parseInt(firstChar.current + i.toString(), 10);
if (combined === 0) {
combined = 100;
}
mousetrap.bind(i.toString(), () => {
if (mode.current === "star") {
const value = starRatingShortcuts[i.toString()];
if (value === undefined) return;
setRatingRef.current(value);
endSequence();
} else if (mode.current === "decimal") {
if (firstChar.current !== undefined) {
let combined = parseInt(firstChar.current + i.toString(), 10);
if (combined === 0) {
combined = 100;
}

setRating(combined);
firstChar.current = undefined;
} else {
firstChar.current = i.toString();
setRatingRef.current(combined);
endSequence();
} else {
firstChar.current = i.toString();
}
}
});
}

setTimeout(() => {
firstChar.current = undefined;

Mousetrap.unbind("`");
return () => {
mousetrap.unbind("r");
mousetrap.unbind("`");
for (let i = 0; i <= 9; ++i) {
Mousetrap.unbind(i.toString());
mousetrap.unbind(i.toString());
}
}, 1000);
}

useEffect(() => {
if (!isVisible) return;

Mousetrap.bind("r", () => {
// numeric keypresses get caught by jwplayer, so blur the element
// if the rating sequence is started
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}

if (!ratingSystem || ratingSystem === RatingSystemType.Stars) {
return handleStarRatingKeybinds();
} else {
return handleDecimalKeybinds();
}
});

return () => {
Mousetrap.unbind("r");
endSequence();
};
});
}, [isVisible, mousetrap]);
}
Loading