feat(ui): rework canvas zoom UI in toolbar

- Add buttons to zoom in/out
- Update hotkeys for fit & 100% to match affinity (e.g. ctrl+0, ctrl+1)
- Add hotkeys for 200%, 400%, 800%
- Update tooltips
This commit is contained in:
psychedelicious
2024-09-19 12:28:31 +10:00
parent e84801e820
commit 676ea2e481
6 changed files with 126 additions and 68 deletions

View File

@@ -456,11 +456,23 @@
},
"fitLayersToCanvas": {
"title": "Fit Layers to Canvas",
"desc": "Scales and positions the view to fit all visible layers."
"desc": "Scale and position the view to fit all visible layers."
},
"setZoomTo100Percent": {
"title": "Reset Zoom",
"desc": "Resets the canvas zoom to 100%."
"title": "Zoom to 100%",
"desc": "Set the canvas zoom to 100%."
},
"setZoomTo200Percent": {
"title": "Zoom to 200%",
"desc": "Set the canvas zoom to 200%."
},
"setZoomTo400Percent": {
"title": "Zoom to 400%",
"desc": "Set the canvas zoom to 400%."
},
"setZoomTo800Percent": {
"title": "Zoom to 800%",
"desc": "Set the canvas zoom to 800%."
},
"quickSwitch": {
"title": "Layer Quick Switch",

View File

@@ -3,7 +3,7 @@ import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerP
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsOut } from 'react-icons/pi';
import { PiResizeBold } from 'react-icons/pi';
export const CanvasToolbarFitBboxToLayersButton = memo(() => {
const { t } = useTranslation();
@@ -19,7 +19,7 @@ export const CanvasToolbarFitBboxToLayersButton = memo(() => {
variant="ghost"
aria-label={t('controlLayers.fitBboxToLayers')}
tooltip={t('controlLayers.fitBboxToLayers')}
icon={<PiArrowsOut />}
icon={<PiResizeBold />}
isDisabled={isBusy}
/>
);

View File

@@ -1,63 +1,63 @@
import { $alt, IconButton } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
import { $canvasManager } from 'features/controlLayers/store/ephemeral';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { memo, useCallback } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
import { PiArrowsOutBold } from 'react-icons/pi';
export const CanvasToolbarResetViewButton = memo(() => {
const { t } = useTranslation();
const canvasManager = useStore($canvasManager);
const canvasManager = useCanvasManager();
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
const imageViewer = useImageViewer();
const resetZoom = useCallback(() => {
if (!canvasManager) {
return;
}
canvasManager.stage.setScale(1);
}, [canvasManager]);
const resetView = useCallback(() => {
if (!canvasManager) {
return;
}
canvasManager.stage.fitLayersToStage();
}, [canvasManager]);
const onReset = useCallback(() => {
if ($alt.get()) {
resetView();
} else {
resetZoom();
}
}, [resetView, resetZoom]);
useRegisteredHotkeys({
id: 'fitLayersToCanvas',
category: 'canvas',
callback: resetView,
options: { enabled: isCanvasActive && !imageViewer.isOpen },
callback: canvasManager.stage.fitLayersToStage,
options: { enabled: isCanvasActive && !imageViewer.isOpen, preventDefault: true },
dependencies: [isCanvasActive, imageViewer.isOpen],
});
useRegisteredHotkeys({
id: 'setZoomTo100Percent',
category: 'canvas',
callback: resetZoom,
options: { enabled: isCanvasActive && !imageViewer.isOpen },
callback: () => canvasManager.stage.setScale(1),
options: { enabled: isCanvasActive && !imageViewer.isOpen, preventDefault: true },
dependencies: [isCanvasActive, imageViewer.isOpen],
});
useRegisteredHotkeys({
id: 'setZoomTo200Percent',
category: 'canvas',
callback: () => canvasManager.stage.setScale(2),
options: { enabled: isCanvasActive && !imageViewer.isOpen, preventDefault: true },
dependencies: [isCanvasActive, imageViewer.isOpen],
});
useRegisteredHotkeys({
id: 'setZoomTo400Percent',
category: 'canvas',
callback: () => canvasManager.stage.setScale(4),
options: { enabled: isCanvasActive && !imageViewer.isOpen, preventDefault: true },
dependencies: [isCanvasActive, imageViewer.isOpen],
});
useRegisteredHotkeys({
id: 'setZoomTo800Percent',
category: 'canvas',
callback: () => canvasManager.stage.setScale(8),
options: { enabled: isCanvasActive && !imageViewer.isOpen, preventDefault: true },
dependencies: [isCanvasActive, imageViewer.isOpen],
});
return (
<IconButton
tooltip={t('controlLayers.resetView')}
aria-label={t('controlLayers.resetView')}
onClick={onReset}
icon={<PiArrowCounterClockwiseBold />}
tooltip={t('hotkeys.canvas.fitLayersToCanvas.title')}
aria-label={t('hotkeys.canvas.fitLayersToCanvas.title')}
onClick={canvasManager.stage.fitLayersToStage}
icon={<PiArrowsOutBold />}
variant="link"
alignSelf="stretch"
/>
);
});

View File

@@ -1,8 +1,7 @@
import {
$shift,
CompositeSlider,
FormControl,
FormLabel,
Flex,
IconButton,
NumberInput,
NumberInputField,
@@ -20,8 +19,7 @@ import { round } from 'lodash-es';
import { computed } from 'nanostores';
import type { KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
import { PiCaretDownBold, PiMagnifyingGlassMinusBold, PiMagnifyingGlassPlusBold } from 'react-icons/pi';
function formatPct(v: number | string) {
if (isNaN(Number(v))) {
@@ -71,7 +69,6 @@ const sliderDefaultValue = mapRawValueToSliderValue(100);
const snapCandidates = marks.slice(1, marks.length - 1);
export const CanvasToolbarScale = memo(() => {
const { t } = useTranslation();
const canvasManager = useCanvasManager();
const scale = useStore(computed(canvasManager.stage.$stageAttrs, (attrs) => attrs.scale));
const [localScale, setLocalScale] = useState(scale * 100);
@@ -116,9 +113,9 @@ export const CanvasToolbarScale = memo(() => {
}, [scale]);
return (
<Popover>
<FormControl w="min-content" gap={2}>
<FormLabel m={0}>{t('controlLayers.zoom')}</FormLabel>
<Flex alignItems="center">
<ZoomOutButton />
<Popover>
<PopoverAnchor>
<NumberInput
display="flex"
@@ -148,24 +145,70 @@ export const CanvasToolbarScale = memo(() => {
</PopoverTrigger>
</NumberInput>
</PopoverAnchor>
</FormControl>
<PopoverContent w={200} pt={0} pb={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localScale)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
/>
</PopoverBody>
</PopoverContent>
</Popover>
<PopoverContent w={200} pt={0} pb={2} px={4}>
<PopoverArrow />
<PopoverBody>
<CompositeSlider
min={0}
max={100}
value={mapRawValueToSliderValue(localScale)}
onChange={onChangeSlider}
defaultValue={sliderDefaultValue}
marks={marks}
formatValue={formatSliderValue}
alwaysShowMarks
/>
</PopoverBody>
</PopoverContent>
</Popover>
<ZoomInButton />
</Flex>
);
});
CanvasToolbarScale.displayName = 'CanvasToolbarScale';
const SCALE_SNAPS = [0.1, 0.15, 0.2, 0.25, 0.5, 0.75, 1, 1.5, 2, 2.5, 5, 7.5, 10, 15, 20];
const ZoomOutButton = () => {
const canvasManager = useCanvasManager();
const scale = useStore(computed(canvasManager.stage.$stageAttrs, (attrs) => attrs.scale));
const onClick = useCallback(() => {
const nextScale =
SCALE_SNAPS.slice()
.reverse()
.find((snap) => snap < scale) ?? canvasManager.stage.config.MIN_SCALE;
canvasManager.stage.setScale(Math.max(nextScale, canvasManager.stage.config.MIN_SCALE));
}, [canvasManager.stage, scale]);
return (
<IconButton
onClick={onClick}
icon={<PiMagnifyingGlassMinusBold />}
aria-label="Zoom out"
variant="link"
alignSelf="stretch"
isDisabled={scale <= canvasManager.stage.config.MIN_SCALE}
/>
);
};
const ZoomInButton = () => {
const canvasManager = useCanvasManager();
const scale = useStore(computed(canvasManager.stage.$stageAttrs, (attrs) => attrs.scale));
const onClick = useCallback(() => {
const nextScale = SCALE_SNAPS.find((snap) => snap > scale) ?? canvasManager.stage.config.MAX_SCALE;
canvasManager.stage.setScale(Math.min(nextScale, canvasManager.stage.config.MAX_SCALE));
}, [canvasManager.stage, scale]);
return (
<IconButton
onClick={onClick}
icon={<PiMagnifyingGlassPlusBold />}
aria-label="Zoom out"
variant="link"
alignSelf="stretch"
isDisabled={scale >= canvasManager.stage.config.MAX_SCALE}
/>
);
};

View File

@@ -441,7 +441,7 @@ export const getEmptyRect = (): Rect => {
return { x: 0, y: 0, width: 0, height: 0 };
};
export function snapToNearest(value: number, candidateValues: number[], threshold: number): number {
export function snapToNearest(value: number, candidateValues: number[], threshold: number = Infinity): number {
let closest = value;
let minDiff = Number.MAX_VALUE;

View File

@@ -100,8 +100,11 @@ export const useHotkeyData = (): HotkeysData => {
addHotkey('canvas', 'selectViewTool', ['h']);
addHotkey('canvas', 'selectColorPickerTool', ['i']);
addHotkey('canvas', 'setFillToWhite', ['d']);
addHotkey('canvas', 'fitLayersToCanvas', ['r']);
addHotkey('canvas', 'setZoomTo100Percent', ['alt+r']);
addHotkey('canvas', 'fitLayersToCanvas', ['mod+0']);
addHotkey('canvas', 'setZoomTo100Percent', ['mod+1']);
addHotkey('canvas', 'setZoomTo200Percent', ['mod+2']);
addHotkey('canvas', 'setZoomTo400Percent', ['mod+3']);
addHotkey('canvas', 'setZoomTo800Percent', ['mod+4']);
addHotkey('canvas', 'quickSwitch', ['q']);
addHotkey('canvas', 'deleteSelected', ['delete', 'backspace']);
addHotkey('canvas', 'resetSelected', ['shift+c']);