mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-04 00:24:57 -05:00
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:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user