Add keyboard shortcuts (#637)

* Add documentation
* Fix manual styling
* Add dialog for setting Movie images
* Mention manual in README
This commit is contained in:
WithoutPants 2020-07-02 08:45:14 +10:00 committed by GitHub
parent 3157d748bc
commit bfeb7d1824
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 698 additions and 30 deletions

View file

@ -8,6 +8,8 @@
See a demo [here](https://vimeo.com/275537038) (password is stashapp).
An in-app manual is available, and the manual pages can be viewed [here](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en).
# Docker install
Follow [this README.md in the docker directory.](docker/production/README.md)

View file

@ -31,6 +31,7 @@
"@fortawesome/free-regular-svg-icons": "^5.13.0",
"@fortawesome/free-solid-svg-icons": "^5.13.0",
"@fortawesome/react-fontawesome": "^0.1.9",
"@types/mousetrap": "^1.6.3",
"apollo-cache": "^1.3.4",
"apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8",
@ -50,6 +51,7 @@
"jimp": "^0.12.1",
"localforage": "1.7.3",
"lodash": "^4.17.15",
"mousetrap": "^1.6.5",
"query-string": "6.12.1",
"react": "16.13.1",
"react-apollo": "^3.1.5",

View file

@ -3,12 +3,14 @@ import ReactMarkdown from "react-markdown";
const markup = `
### New Features
* Add various keyboard shortcuts (see manual).
* Support deleting multiple scenes.
* Add in-app help manual.
* Add support for custom served folders.
* Add support for parent/child studios.
### 🎨 Improvements
* Add dialog when pasting movie images.
* Allow click and click-drag selection after selecting scene.
* Added multi-scene edit dialog.
* Moved images to separate tables, increasing performance.

View file

@ -10,6 +10,7 @@ import Galleries from "src/docs/en/Galleries.md";
import Scraping from "src/docs/en/Scraping.md";
import Contributing from "src/docs/en/Contributing.md";
import SceneFilenameParser from "src/docs/en/SceneFilenameParser.md";
import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md";
import Help from "src/docs/en/Help.md";
import { Page } from "./Page";
@ -32,7 +33,7 @@ export const Manual: React.FC<IManualProps> = ({ show, onClose }) => {
},
{
key: "Interface.md",
title: "Interface",
title: "Interface Options",
content: Interface,
},
{
@ -68,6 +69,11 @@ export const Manual: React.FC<IManualProps> = ({ show, onClose }) => {
title: "Metadata Scraping",
content: Scraping,
},
{
key: "KeyboardShortcuts.md",
title: "Keyboard Shortcuts",
content: KeyboardShortcuts,
},
{
key: "Contributing.md",
title: "Contributing",
@ -116,7 +122,7 @@ export const Manual: React.FC<IManualProps> = ({ show, onClose }) => {
id="manual-tabs"
>
<Row>
<Col lg={3} className="mb-3 mb-lg-0">
<Col lg={3} className="mb-3 mb-lg-0 manual-toc">
<Nav variant="pills" className="flex-column">
{content.map((c) => {
return (

View file

@ -17,22 +17,24 @@
color: $text-color;
overflow-y: hidden;
}
}
.manual .manual-content {
max-height: calc(100vh - 10rem);
overflow-y: auto;
.indent-1 {
padding-left: 2rem;
}
}
.manual .manual-content,
.manual .manual-toc {
max-height: calc(100vh - 10rem);
overflow-y: auto;
}
@media (max-width: 992px) {
.manual .modal-body {
overflow-y: auto;
.manual-content {
.manual-content,
.manual-toc {
max-height: inherit;
overflow-y: hidden;
}

View file

@ -30,6 +30,15 @@ export const AddFilter: React.FC<IAddFilterProps> = (
const valueStage = useRef<CriterionValue>(criterion.value);
// configure keyboard shortcuts
useEffect(() => {
Mousetrap.bind("f", () => setIsOpen(true));
return () => {
Mousetrap.unbind("f");
};
});
// Configure if we are editing an existing criterion
useEffect(() => {
if (!props.editingCriterion) {

View file

@ -1,5 +1,5 @@
import _, { debounce } from "lodash";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { SortDirectionEnum } from "src/core/generated-graphql";
import {
Badge,
@ -19,6 +19,7 @@ import { Icon } from "src/components/Shared";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { useFocus } from "src/utils";
import { AddFilter } from "./AddFilter";
interface IListFilterOperation {
@ -27,6 +28,7 @@ interface IListFilterOperation {
}
interface IListFilterProps {
subComponent?: boolean;
onFilterUpdate: (newFilter: ListFilterModel) => void;
zoomIndex?: number;
onChangeZoom?: (zoomIndex: number) => void;
@ -40,10 +42,14 @@ interface IListFilterProps {
}
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"];
const minZoom = 0;
const maxZoom = 3;
export const ListFilter: React.FC<IListFilterProps> = (
props: IListFilterProps
) => {
const [queryRef, setQueryFocus] = useFocus();
const searchCallback = debounce((value: string) => {
const newFilter = _.cloneDeep(props.filter);
newFilter.searchTerm = value;
@ -55,6 +61,81 @@ export const ListFilter: React.FC<IListFilterProps> = (
Criterion | undefined
>(undefined);
useEffect(() => {
Mousetrap.bind("/", (e) => {
setQueryFocus();
e.preventDefault();
});
Mousetrap.bind("r", () => onReshuffleRandomSort());
Mousetrap.bind("v g", () => {
if (props.filter.displayModeOptions.includes(DisplayMode.Grid)) {
onChangeDisplayMode(DisplayMode.Grid);
}
});
Mousetrap.bind("v l", () => {
if (props.filter.displayModeOptions.includes(DisplayMode.List)) {
onChangeDisplayMode(DisplayMode.List);
}
});
Mousetrap.bind("v w", () => {
if (props.filter.displayModeOptions.includes(DisplayMode.Wall)) {
onChangeDisplayMode(DisplayMode.Wall);
}
});
Mousetrap.bind("+", () => {
if (
props.onChangeZoom &&
props.zoomIndex !== undefined &&
props.zoomIndex < maxZoom
) {
props.onChangeZoom(props.zoomIndex + 1);
}
});
Mousetrap.bind("-", () => {
if (
props.onChangeZoom &&
props.zoomIndex !== undefined &&
props.zoomIndex > minZoom
) {
props.onChangeZoom(props.zoomIndex - 1);
}
});
Mousetrap.bind("s a", () => onSelectAll());
Mousetrap.bind("s n", () => onSelectNone());
if (!props.subComponent && props.itemsSelected) {
Mousetrap.bind("e", () => {
if (props.onEdit) {
props.onEdit();
}
});
Mousetrap.bind("d d", () => {
if (props.onDelete) {
props.onDelete();
}
});
}
return () => {
Mousetrap.unbind("/");
Mousetrap.unbind("r");
Mousetrap.unbind("v g");
Mousetrap.unbind("v l");
Mousetrap.unbind("v w");
Mousetrap.unbind("+");
Mousetrap.unbind("-");
Mousetrap.unbind("s a");
Mousetrap.unbind("s n");
if (!props.subComponent && props.itemsSelected) {
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
}
};
});
function onChangePageSize(event: React.ChangeEvent<HTMLSelectElement>) {
const val = event.currentTarget.value;
@ -322,9 +403,9 @@ export const ListFilter: React.FC<IListFilterProps> = (
<Form.Control
className="zoom-slider d-none d-sm-inline-flex ml-3"
type="range"
min={0}
max={3}
defaultValue={1}
min={minZoom}
max={maxZoom}
value={props.zoomIndex}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChangeZoom(Number.parseInt(e.currentTarget.value, 10))
}
@ -369,6 +450,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
<ButtonGroup className="mr-3 my-1">
<InputGroup className="mr-2">
<FormControl
ref={queryRef}
placeholder="Search..."
defaultValue={props.filter.searchTerm}
onInput={onChangeQuery}

View file

@ -8,7 +8,7 @@ import {
import { Nav, Navbar, Button } from "react-bootstrap";
import { IconName } from "@fortawesome/fontawesome-svg-core";
import { LinkContainer } from "react-router-bootstrap";
import { Link, NavLink, useLocation } from "react-router-dom";
import { Link, NavLink, useLocation, useHistory } from "react-router-dom";
import { SessionUtils } from "src/utils";
import { Icon } from "src/components/Shared";
@ -90,6 +90,7 @@ const menuItems: IMenuItem[] = [
];
export const MainNavbar: React.FC = () => {
const history = useHistory();
const location = useLocation();
const [expanded, setExpanded] = useState(false);
const [showManual, setShowManual] = useState(false);
@ -120,7 +121,14 @@ export const MainNavbar: React.FC = () => {
};
}, [expanded]);
const path =
function goto(page: string) {
history.push(page);
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
const newPath =
location.pathname === "/performers"
? "/performers/new"
: location.pathname === "/studios"
@ -129,16 +137,49 @@ export const MainNavbar: React.FC = () => {
? "/movies/new"
: null;
const newButton =
path === null ? (
newPath === null ? (
""
) : (
<Link to={path}>
<Link to={newPath}>
<Button variant="primary">
<FormattedMessage id="new" defaultMessage="New" />
</Button>
</Link>
);
// set up hotkeys
useEffect(() => {
Mousetrap.bind("?", () => setShowManual(!showManual));
Mousetrap.bind("g s", () => goto("/scenes"));
Mousetrap.bind("g v", () => goto("/movies"));
Mousetrap.bind("g k", () => goto("/scenes/markers"));
Mousetrap.bind("g l", () => goto("/galleries"));
Mousetrap.bind("g p", () => goto("/performers"));
Mousetrap.bind("g u", () => goto("/studios"));
Mousetrap.bind("g t", () => goto("/tags"));
Mousetrap.bind("g z", () => goto("/settings"));
if (newPath) {
Mousetrap.bind("n", () => history.push(newPath));
}
return () => {
Mousetrap.unbind("?");
Mousetrap.unbind("g s");
Mousetrap.unbind("g v");
Mousetrap.unbind("g k");
Mousetrap.unbind("g l");
Mousetrap.unbind("g p");
Mousetrap.unbind("g u");
Mousetrap.unbind("g t");
Mousetrap.unbind("g z");
if (newPath) {
Mousetrap.unbind("n");
}
};
});
function maybeRenderLogout() {
if (SessionUtils.isLoggedIn()) {
return (

View file

@ -16,7 +16,7 @@ import {
StudioSelect,
} from "src/components/Shared";
import { useToast } from "src/hooks";
import { Table, Form } from "react-bootstrap";
import { Table, Form, Modal as BSModal, Button } from "react-bootstrap";
import {
TableUtils,
ImageUtils,
@ -34,6 +34,7 @@ export const Movie: React.FC = () => {
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [isImageAlertOpen, setIsImageAlertOpen] = useState<boolean>(false);
// Editing movie state
const [frontImage, setFrontImage] = useState<string | undefined>(undefined);
@ -57,6 +58,10 @@ export const Movie: React.FC = () => {
undefined
);
const [imageClipboard, setImageClipboard] = useState<string | undefined>(
undefined
);
// Network state
const { data, error, loading } = useFindMovie(id);
const [updateMovie] = useMovieUpdate(getMovieInput() as GQL.MovieUpdateInput);
@ -67,6 +72,42 @@ export const Movie: React.FC = () => {
const intl = useIntl();
// set up hotkeys
useEffect(() => {
if (isEditing) {
Mousetrap.bind("r 0", () => setRating(NaN));
Mousetrap.bind("r 1", () => setRating(1));
Mousetrap.bind("r 2", () => setRating(2));
Mousetrap.bind("r 3", () => setRating(3));
Mousetrap.bind("r 4", () => setRating(4));
Mousetrap.bind("r 5", () => setRating(5));
// Mousetrap.bind("u", (e) => {
// setStudioFocus()
// e.preventDefault();
// });
Mousetrap.bind("s s", () => onSave());
}
Mousetrap.bind("e", () => setIsEditing(true));
Mousetrap.bind("d d", () => onDelete());
return () => {
if (isEditing) {
Mousetrap.unbind("r 0");
Mousetrap.unbind("r 1");
Mousetrap.unbind("r 2");
Mousetrap.unbind("r 3");
Mousetrap.unbind("r 4");
Mousetrap.unbind("r 5");
// Mousetrap.unbind("u");
Mousetrap.unbind("s s");
}
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
});
function updateMovieEditState(state: Partial<GQL.MovieDataFragment>) {
setName(state.name ?? undefined);
setAliases(state.aliases ?? undefined);
@ -98,8 +139,21 @@ export const Movie: React.FC = () => {
}, [data, updateMovieData]);
function onImageLoad(imageData: string) {
setImagePreview(imageData);
setFrontImage(imageData);
setImageClipboard(imageData);
setIsImageAlertOpen(true);
}
function setImageFromClipboard(isFrontImage: boolean) {
if (isFrontImage) {
setImagePreview(imageClipboard);
setFrontImage(imageClipboard);
} else {
setBackImagePreview(imageClipboard);
setBackImage(imageClipboard);
}
setImageClipboard(undefined);
setIsImageAlertOpen(false);
}
function onBackImageLoad(imageData: string) {
@ -107,11 +161,7 @@ export const Movie: React.FC = () => {
setBackImage(imageData);
}
const encodingFrontImage = ImageUtils.usePasteImage(onImageLoad, isEditing);
const encodingBackImage = ImageUtils.usePasteImage(
onBackImageLoad,
isEditing
);
const encodingImage = ImageUtils.usePasteImage(onImageLoad, isEditing);
if (!isNew && !isEditing) {
if (!data || !data.findMovie || loading) return <LoadingIndicator />;
@ -198,13 +248,50 @@ export const Movie: React.FC = () => {
);
}
function renderImageAlert() {
return (
<BSModal
show={isImageAlertOpen}
onHide={() => setIsImageAlertOpen(false)}
>
<BSModal.Body>
<p>Select image to set</p>
</BSModal.Body>
<BSModal.Footer>
<div>
<Button
className="mr-2"
variant="secondary"
onClick={() => setIsImageAlertOpen(false)}
>
Cancel
</Button>
<Button
className="mr-2"
onClick={() => setImageFromClipboard(false)}
>
Back Image
</Button>
<Button
className="mr-2"
onClick={() => setImageFromClipboard(true)}
>
Front Image
</Button>
</div>
</BSModal.Footer>
</BSModal>
);
}
// TODO: CSS class
return (
<div className="row">
<div className="movie-details col">
{isNew && <h2>Add Movie</h2>}
<div className="logo w-100">
{encodingFrontImage || encodingBackImage ? (
{encodingImage ? (
<LoadingIndicator message="Encoding image..." />
) : (
<>
@ -312,6 +399,7 @@ export const Movie: React.FC = () => {
</div>
)}
{renderDeleteAlert()}
{renderImageAlert()}
</div>
);
};

View file

@ -35,6 +35,8 @@ export const Performer: React.FC = () => {
// Network state
const [isLoading, setIsLoading] = useState(false);
const [activeTabKey, setActiveTabKey] = useState("details");
const { data, error } = useFindPerformer(id);
const [updatePerformer] = usePerformerUpdate();
const [createPerformer] = usePerformerCreate();
@ -49,6 +51,23 @@ export const Performer: React.FC = () => {
const onImageEncoding = (isEncoding = false) => setImageEncoding(isEncoding);
// set up hotkeys
useEffect(() => {
Mousetrap.bind("a", () => setActiveTabKey("details"));
Mousetrap.bind("e", () => setActiveTabKey("edit"));
Mousetrap.bind("c", () => setActiveTabKey("scenes"));
Mousetrap.bind("o", () => setActiveTabKey("operations"));
Mousetrap.bind("f", () => setFavorite(!performer.favorite));
return () => {
Mousetrap.unbind("a");
Mousetrap.unbind("e");
Mousetrap.unbind("c");
Mousetrap.unbind("f");
Mousetrap.unbind("o");
};
});
if ((!isNew && (!data || !data.findPerformer)) || isLoading)
return <LoadingIndicator />;
@ -100,9 +119,18 @@ export const Performer: React.FC = () => {
}
const renderTabs = () => (
<Tabs defaultActiveKey="details" id="performer-details" unmountOnExit>
<Tabs
activeKey={activeTabKey}
onSelect={(k: string) => setActiveTabKey(k)}
id="performer-details"
unmountOnExit
>
<Tab eventKey="details" title="Details">
<PerformerDetailsPanel performer={performer} isEditing={false} />
<PerformerDetailsPanel
performer={performer}
isEditing={false}
isVisible={activeTabKey === "details"}
/>
</Tab>
<Tab eventKey="scenes" title="Scenes">
<PerformerScenesPanel performer={performer} />
@ -111,6 +139,7 @@ export const Performer: React.FC = () => {
<PerformerDetailsPanel
performer={performer}
isEditing
isVisible={activeTabKey === "edit"}
isNew={isNew}
onDelete={onDelete}
onSave={onSave}
@ -227,6 +256,7 @@ export const Performer: React.FC = () => {
<PerformerDetailsPanel
performer={performer}
isEditing
isVisible
isNew={isNew}
onDelete={onDelete}
onSave={onSave}

View file

@ -32,6 +32,7 @@ interface IPerformerDetails {
performer: Partial<GQL.PerformerDataFragment>;
isNew?: boolean;
isEditing?: boolean;
isVisible: boolean;
onSave?: (
performer:
| Partial<GQL.PerformerCreateInput>
@ -46,6 +47,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
performer,
isNew,
isEditing,
isVisible,
onSave,
onDelete,
onImageChange,
@ -162,6 +164,29 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
setImage(imageData);
}
// set up hotkeys
useEffect(() => {
if (isEditing && isVisible) {
Mousetrap.bind("s s", () => {
onSave?.(getPerformerInput());
});
if (!isNew) {
Mousetrap.bind("d d", () => {
setIsDeleteAlertOpen(true);
});
}
return () => {
Mousetrap.unbind("s s");
if (!isNew) {
Mousetrap.unbind("d d");
}
};
}
});
useEffect(() => {
setImage(undefined);
updatePerformerEditState(performer);

View file

@ -18,9 +18,23 @@ export const PerformerList: React.FC = () => {
},
];
const addKeybinds = (
result: FindPerformersQueryResult,
filter: ListFilterModel
) => {
Mousetrap.bind("p r", () => {
getRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
};
const listData = usePerformersList({
otherOperations,
renderContent,
addKeybinds,
});
async function getRandom(

View file

@ -15,6 +15,7 @@ import { LoadingIndicator, Icon } from "src/components/Shared";
import { useToast } from "src/hooks";
import { ScenePlayer } from "src/components/ScenePlayer";
import { TextUtils, JWUtils } from "src/utils";
import * as Mousetrap from "mousetrap";
import { SceneMarkersPanel } from "./SceneMarkersPanel";
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
import { SceneEditPanel } from "./SceneEditPanel";
@ -38,6 +39,8 @@ export const Scene: React.FC = () => {
const [decrementO] = useSceneDecrementO(scene?.id ?? "0");
const [resetO] = useSceneResetO(scene?.id ?? "0");
const [activeTabKey, setActiveTabKey] = useState("scene-details-panel");
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const queryParams = queryString.parse(location.search);
@ -177,7 +180,10 @@ export const Scene: React.FC = () => {
}
return (
<Tab.Container defaultActiveKey="scene-details-panel">
<Tab.Container
activeKey={activeTabKey}
onSelect={(k) => setActiveTabKey(k)}
>
<div>
<Nav variant="tabs" className="mr-auto">
<Nav.Item>
@ -224,7 +230,11 @@ export const Scene: React.FC = () => {
<SceneDetailPanel scene={scene} />
</Tab.Pane>
<Tab.Pane eventKey="scene-markers-panel" title="Markers">
<SceneMarkersPanel scene={scene} onClickMarker={onClickMarker} />
<SceneMarkersPanel
scene={scene}
onClickMarker={onClickMarker}
isVisible={activeTabKey === "scene-markers-panel"}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-movie-panel" title="Movies">
<SceneMoviePanel scene={scene} />
@ -245,6 +255,7 @@ export const Scene: React.FC = () => {
</Tab.Pane>
<Tab.Pane eventKey="scene-edit-panel" title="Edit">
<SceneEditPanel
isVisible={activeTabKey === "scene-edit-panel"}
scene={scene}
onUpdate={(newScene) => setScene(newScene)}
onDelete={() => setIsDeleteAlertOpen(true)}
@ -255,6 +266,23 @@ export const Scene: React.FC = () => {
);
}
// set up hotkeys
useEffect(() => {
Mousetrap.bind("a", () => setActiveTabKey("scene-details-panel"));
Mousetrap.bind("e", () => setActiveTabKey("scene-edit-panel"));
Mousetrap.bind("k", () => setActiveTabKey("scene-markers-panel"));
Mousetrap.bind("f", () => setActiveTabKey("scene-file-info-panel"));
Mousetrap.bind("o", () => onIncrementClick());
return () => {
Mousetrap.unbind("a");
Mousetrap.unbind("e");
Mousetrap.unbind("k");
Mousetrap.unbind("f");
Mousetrap.unbind("o");
};
});
if (loading || !scene || !data?.findScene) {
return <LoadingIndicator />;
}

View file

@ -34,6 +34,7 @@ import { RatingStars } from "./RatingStars";
interface IProps {
scene: GQL.SceneDataFragment;
isVisible: boolean;
onUpdate: (scene: GQL.SceneDataFragment) => void;
onDelete: () => void;
}
@ -65,6 +66,48 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
const [updateScene] = useSceneUpdate(getSceneInput());
useEffect(() => {
if (props.isVisible) {
Mousetrap.bind("s s", () => {
onSave();
});
Mousetrap.bind("d d", () => {
props.onDelete();
});
// numeric keypresses get caught by jwplayer, so blur the element
// if the rating sequence is started
Mousetrap.bind("r", () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
Mousetrap.bind("0", () => setRating(NaN));
Mousetrap.bind("1", () => setRating(1));
Mousetrap.bind("2", () => setRating(2));
Mousetrap.bind("3", () => setRating(3));
Mousetrap.bind("4", () => setRating(4));
Mousetrap.bind("5", () => setRating(5));
setTimeout(() => {
Mousetrap.unbind("0");
Mousetrap.unbind("1");
Mousetrap.unbind("2");
Mousetrap.unbind("3");
Mousetrap.unbind("4");
Mousetrap.unbind("5");
}, 1000);
});
return () => {
Mousetrap.unbind("s s");
Mousetrap.unbind("d d");
Mousetrap.unbind("r");
};
}
});
useEffect(() => {
const newQueryableScrapers = (
Scrapers?.data?.listSceneScrapers ?? []

View file

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { Button } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { WallPanel } from "src/components/Wall/WallPanel";
@ -7,6 +7,7 @@ import { SceneMarkerForm } from "./SceneMarkerForm";
interface ISceneMarkersPanelProps {
scene: GQL.SceneDataFragment;
isVisible: boolean;
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
}
@ -18,6 +19,17 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
GQL.SceneMarkerDataFragment
>();
// set up hotkeys
useEffect(() => {
if (props.isVisible) {
Mousetrap.bind("n", () => onOpenEditor());
return () => {
Mousetrap.unbind("n");
};
}
});
function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) {
setIsEditorOpen(true);
setEditingMarker(marker ?? undefined);

View file

@ -32,6 +32,19 @@ export const SceneList: React.FC<ISceneList> = ({
},
];
const addKeybinds = (
result: FindScenesQueryResult,
filter: ListFilterModel
) => {
Mousetrap.bind("p r", () => {
playRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
};
const listData = useScenesList({
zoomable: true,
otherOperations,
@ -40,6 +53,7 @@ export const SceneList: React.FC<ISceneList> = ({
renderDeleteDialog: renderDeleteScenesDialog,
subComponent,
filterHook,
addKeybinds,
});
async function playRandom(

View file

@ -18,9 +18,23 @@ export const SceneMarkerList: React.FC = () => {
},
];
const addKeybinds = (
result: FindSceneMarkersQueryResult,
filter: ListFilterModel
) => {
Mousetrap.bind("p r", () => {
playRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
};
const listData = useSceneMarkersList({
otherOperations,
renderContent,
addKeybinds,
});
async function playRandom(

View file

@ -68,6 +68,25 @@ export const Studio: React.FC = () => {
setStudio(studioData);
}
// set up hotkeys
useEffect(() => {
if (isEditing) {
Mousetrap.bind("s s", () => onSave());
}
Mousetrap.bind("e", () => setIsEditing(true));
Mousetrap.bind("d d", () => onDelete());
return () => {
if (isEditing) {
Mousetrap.unbind("s s");
}
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
});
useEffect(() => {
if (data && data.findStudio) {
setImage(undefined);

View file

@ -0,0 +1,155 @@
# Keyboard Shortcuts
## Global shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `?` | Display manual |
### Global Navigation
| Keyboard sequence | Target page |
|-------------------|--------|
| `g s` | Scenes |
| `g v` | Movies |
| `g k` | Markers |
| `g l` | Galleries |
| `g p` | Performers |
| `g u` | Studios |
| `g t` | Tags |
| `g z` | Settings |
## Query page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `/` | Focus search field |
| `f` | Show Add Filter dialog |
| `r` | Reshuffle if sorted by random |
| `v g` | Set view to grid |
| `v l` | Set view to list |
| `v w` | Set view to wall |
| `+` | Increase zoom slider |
| `-` | Decrease zoom slider |
| `←` | Previous page of results |
| `→` | Next page of results |
| `Shift + ←` | Go to current results page -10 |
| `Shift + →` | Go to current results page +10 |
| `Ctrl + Home` | Go to first page of results |
| `Ctrl + End` | Go to last page of results |
| `s a` | Select all on page |
| `s n` | Unselect all |
| `e` | Edit selected |
| `d d` | Delete selected |
## Scenes page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `p r` | Play random scene |
## Scene page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `a` | Details tab |
| `k` | Markers tab |
| `f` | File info tab |
| `e` | Edit tab |
| `o` | Increment O-Counter |
### Scene Markers tab shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | Display Create Markers dialog |
### Edit Scene tab shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `r {1-5}` | Set rating |
| `r 0` | Unset rating |
| `s s` | Save Scene |
| `d d` | Delete Scene |
| `Ctrl + v` | Paste Scene cover |
[//]: # "Commented until implementation is dealt with"
[//]: # "(| `l` | Focus Gallery selector |)"
[//]: # "(| `u` | Focus Studio selector |)"
[//]: # "(| `p` | Focus Performers selector |)"
[//]: # "(| `v` | Focus Movies selector |)"
[//]: # "(| `t` | Focus Tags selector |)"
## Movies Page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | New Movie |
## Movie Page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `e` | Edit Movie |
| `s s` | Save Movie |
| `d d` | Delete Movie |
| `r {1-5}` | Set rating (in edit mode) |
| `r 0` | Unset rating (in edit mode) |
| `Ctrl + v` | Paste Movie image |
[//]: # "Commented until implementation is dealt with"
[//]: # "(| `u` | Focus Studio selector (in edit mode) |)"
## Markers Page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `p r` | Play random marker |
## Performers Page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | New Performer |
| `p r` | Open random Performer |
## Performer Page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `a` | Details tab |
| `c` | Scenes tab |
| `e` | Edit tab |
| `o` | Operations tab |
| `f` | Toggle favourite |
### Edit Performer tab shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `s s` | Save Performer |
| `d d` | Delete Performer |
| `Ctrl + v` | Paste Performer image |
## Studios Page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | New Studio |
## Studio Page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `e` | Edit Studio |
| `s s` | Save Studio |
| `d d` | Delete Studio |
| `Ctrl + v` | Paste Studio image |
## Tags Page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | New Tag |

View file

@ -69,6 +69,11 @@ interface IListHookOptions<T, E> {
selected: E[],
onClose: (confirmed: boolean) => void
) => JSX.Element | undefined;
addKeybinds?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => () => void;
}
interface IDataItem {
@ -112,6 +117,53 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
const totalCount = options.getCount(result);
const items = options.getData(result);
useEffect(() => {
Mousetrap.bind("right", () => {
const maxPage = totalCount / filter.itemsPerPage;
if (filter.currentPage < maxPage) {
onChangePage(filter.currentPage + 1);
}
});
Mousetrap.bind("left", () => {
if (filter.currentPage > 1) {
onChangePage(filter.currentPage - 1);
}
});
Mousetrap.bind("shift+right", () => {
const maxPage = totalCount / filter.itemsPerPage + 1;
onChangePage(Math.min(maxPage, filter.currentPage + 10));
});
Mousetrap.bind("shift+left", () => {
onChangePage(Math.max(1, filter.currentPage - 10));
});
Mousetrap.bind("ctrl+end", () => {
const maxPage = totalCount / filter.itemsPerPage + 1;
onChangePage(maxPage);
});
Mousetrap.bind("ctrl+home", () => {
onChangePage(1);
});
let unbindExtras: () => void;
if (options.addKeybinds) {
unbindExtras = options.addKeybinds(result, filter, selectedIds);
}
return () => {
Mousetrap.unbind("right");
Mousetrap.unbind("left");
Mousetrap.unbind("shift+right");
Mousetrap.unbind("shift+left");
Mousetrap.unbind("ctrl+end");
Mousetrap.unbind("ctrl+home");
if (unbindExtras) {
unbindExtras();
}
};
});
const updateInterfaceConfig = useCallback(
(updatedFilter: ListFilterModel) => {
setInterfaceState((config) => {
@ -354,6 +406,7 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
const template = (
<div>
<ListFilter
subComponent={options.subComponent}
onFilterUpdate={updateQueryParams}
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}

View file

@ -0,0 +1,16 @@
import { useRef } from "react";
const useFocus = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const htmlElRef = useRef<any>();
const setFocus = () => {
const currentEl = htmlElRef.current;
if (currentEl) {
currentEl.focus();
}
};
return [htmlElRef, setFocus] as const;
};
export default useFocus;

View file

@ -9,3 +9,4 @@ export { default as JWUtils } from "./jwplayer";
export { default as SessionUtils } from "./session";
export { default as flattenMessages } from "./flattenMessages";
export { default as getISOCountry } from "./country";
export { default as useFocus } from "./focus";

View file

@ -2860,6 +2860,11 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
"@types/mousetrap@^1.6.3":
version "1.6.3"
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.3.tgz#3159a01a2b21c9155a3d8f85588885d725dc987d"
integrity sha512-13gmo3M2qVvjQrWNseqM3+cR6S2Ss3grbR2NZltgMq94wOwqJYQdgn8qzwDshzgXqMlSUtyPZjysImmktu22ew==
"@types/node@*":
version "13.1.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.1.2.tgz#fe94285bf5e0782e1a9e5a8c482b1c34465fa385"
@ -10198,6 +10203,11 @@ moment@~2.25.0:
resolved "https://registry.yarnpkg.com/moment/-/moment-2.25.1.tgz#1cb546dca1eccdd607c9324747842200b683465d"
integrity sha512-nRKMf9wDS4Fkyd0C9LXh2FFXinD+iwbJ5p/lh3CHitW9kZbRbJ8hCruiadiIXZVbeAqKZzqcTvHnK3mRhFjb6w==
mousetrap@^1.6.5:
version "1.6.5"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9"
integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"