Skip to content

Commit 9155a53

Browse files
Add Hotkeys
1 parent cd47395 commit 9155a53

File tree

5 files changed

+179
-0
lines changed

5 files changed

+179
-0
lines changed

invokeai/frontend/web/public/locales/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,14 @@
589589
"toggleNonRasterLayers": {
590590
"title": "Toggle Non-Raster Layers",
591591
"desc": "Show or hide all non-raster layer categories (Control Layers, Inpaint Masks, Regional Guidance)."
592+
},
593+
"invertMask": {
594+
"title": "Invert Mask",
595+
"desc": "Invert the selected inpaint mask. Only works when an inpaint mask is selected and has objects."
596+
},
597+
"adjustBbox": {
598+
"title": "Adjust Bbox to Masks",
599+
"desc": "Adjust the bounding box to fit all visible inpaint masks with padding."
592600
}
593601
},
594602
"workflows": {

invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanva
1616
import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey';
1717
import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey';
1818
import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys';
19+
import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey';
20+
import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey';
1921
import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity';
2022
import { memo } from 'react';
2123

@@ -28,6 +30,8 @@ export const CanvasToolbar = memo(() => {
2830
useCanvasTransformHotkey();
2931
useCanvasFilterHotkey();
3032
useCanvasToggleNonRasterLayersHotkey();
33+
useCanvasInvertMaskHotkey();
34+
useCanvasAdjustBboxHotkey();
3135

3236
return (
3337
<Flex w="full" gap={2} alignItems="center" px={2}>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
2+
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
3+
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
4+
import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice';
5+
import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice';
6+
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
7+
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
8+
import type { Rect } from 'features/controlLayers/store/types';
9+
import { useCallback, useMemo } from 'react';
10+
11+
export const useCanvasAdjustBboxHotkey = () => {
12+
useAssertSingleton('useCanvasAdjustBboxHotkey');
13+
const dispatch = useAppDispatch();
14+
const canvasSlice = useAppSelector(selectCanvasSlice);
15+
const maskBlur = useAppSelector(selectMaskBlur);
16+
const isBusy = useCanvasIsBusy();
17+
const inpaintMasks = canvasSlice.inpaintMasks.entities;
18+
19+
// Calculate the bounding box that contains all inpaint masks
20+
const calculateMaskBbox = useCallback((): Rect | null => {
21+
if (inpaintMasks.length === 0) {
22+
return null;
23+
}
24+
let minX = Infinity;
25+
let minY = Infinity;
26+
let maxX = -Infinity;
27+
let maxY = -Infinity;
28+
for (const mask of inpaintMasks) {
29+
if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) {
30+
continue;
31+
}
32+
for (const obj of mask.objects) {
33+
let objMinX = 0;
34+
let objMinY = 0;
35+
let objMaxX = 0;
36+
let objMaxY = 0;
37+
if (obj.type === 'rect') {
38+
objMinX = mask.position.x + obj.rect.x;
39+
objMinY = mask.position.y + obj.rect.y;
40+
objMaxX = objMinX + obj.rect.width;
41+
objMaxY = objMinY + obj.rect.height;
42+
} else if (
43+
obj.type === 'brush_line' ||
44+
obj.type === 'brush_line_with_pressure' ||
45+
obj.type === 'eraser_line' ||
46+
obj.type === 'eraser_line_with_pressure'
47+
) {
48+
for (let i = 0; i < obj.points.length; i += 2) {
49+
const x = mask.position.x + (obj.points[i] ?? 0);
50+
const y = mask.position.y + (obj.points[i + 1] ?? 0);
51+
if (i === 0) {
52+
objMinX = objMaxX = x;
53+
objMinY = objMaxY = y;
54+
} else {
55+
objMinX = Math.min(objMinX, x);
56+
objMinY = Math.min(objMinY, y);
57+
objMaxX = Math.max(objMaxX, x);
58+
objMaxY = Math.max(objMaxY, y);
59+
}
60+
}
61+
const strokeRadius = (obj.strokeWidth ?? 50) / 2;
62+
objMinX -= strokeRadius;
63+
objMinY -= strokeRadius;
64+
objMaxX += strokeRadius;
65+
objMaxY += strokeRadius;
66+
} else if (obj.type === 'image') {
67+
objMinX = mask.position.x;
68+
objMinY = mask.position.y;
69+
objMaxX = objMinX + obj.image.width;
70+
objMaxY = objMinY + obj.image.height;
71+
}
72+
minX = Math.min(minX, objMinX);
73+
minY = Math.min(minY, objMinY);
74+
maxX = Math.max(maxX, objMaxX);
75+
maxY = Math.max(maxY, objMaxY);
76+
}
77+
}
78+
if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) {
79+
return null;
80+
}
81+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
82+
}, [inpaintMasks]);
83+
84+
const handleAdjustBbox = useCallback(() => {
85+
const maskBbox = calculateMaskBbox();
86+
if (!maskBbox) {
87+
return;
88+
}
89+
90+
const padding = maskBlur + 8;
91+
const adjustedBbox: Rect = {
92+
x: maskBbox.x - padding,
93+
y: maskBbox.y - padding,
94+
width: maskBbox.width + padding * 2,
95+
height: maskBbox.height + padding * 2,
96+
};
97+
98+
dispatch(bboxChangedFromCanvas(adjustedBbox));
99+
}, [dispatch, calculateMaskBbox, maskBlur]);
100+
101+
const isAdjustBboxAllowed = useMemo(() => {
102+
const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects && mask.objects.length > 0);
103+
return hasValidMasks;
104+
}, [inpaintMasks]);
105+
106+
useRegisteredHotkeys({
107+
id: 'adjustBbox',
108+
category: 'canvas',
109+
callback: handleAdjustBbox,
110+
options: { enabled: isAdjustBboxAllowed && !isBusy, preventDefault: true },
111+
dependencies: [isAdjustBboxAllowed, isBusy, handleAdjustBbox],
112+
});
113+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
2+
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
3+
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
4+
import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice';
5+
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
6+
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
7+
import { useCallback, useMemo } from 'react';
8+
9+
export const useCanvasInvertMaskHotkey = () => {
10+
useAssertSingleton('useCanvasInvertMaskHotkey');
11+
const dispatch = useAppDispatch();
12+
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
13+
const canvasSlice = useAppSelector(selectCanvasSlice);
14+
const isBusy = useCanvasIsBusy();
15+
16+
const handleInvertMask = useCallback(() => {
17+
if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') {
18+
return;
19+
}
20+
21+
// Check if the selected entity has objects and there's a valid bounding box
22+
const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id);
23+
const hasObjects = entity?.objects && entity.objects.length > 0;
24+
const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0;
25+
26+
if (!hasObjects || !hasBbox) {
27+
return;
28+
}
29+
30+
dispatch(inpaintMaskInverted({ entityIdentifier: selectedEntityIdentifier as any }));
31+
}, [dispatch, selectedEntityIdentifier, canvasSlice]);
32+
33+
const isInvertMaskAllowed = useMemo(() => {
34+
if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') {
35+
return false;
36+
}
37+
38+
const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id);
39+
const hasObjects = entity?.objects && entity.objects.length > 0;
40+
const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0;
41+
42+
return hasObjects && hasBbox;
43+
}, [selectedEntityIdentifier, canvasSlice]);
44+
45+
useRegisteredHotkeys({
46+
id: 'invertMask',
47+
category: 'canvas',
48+
callback: handleInvertMask,
49+
options: { enabled: isInvertMaskAllowed && !isBusy, preventDefault: true },
50+
dependencies: [isInvertMaskAllowed, isBusy, handleInvertMask],
51+
});
52+
};

invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ export const useHotkeyData = (): HotkeysData => {
123123
addHotkey('canvas', 'applySegmentAnything', ['enter']);
124124
addHotkey('canvas', 'cancelSegmentAnything', ['esc']);
125125
addHotkey('canvas', 'toggleNonRasterLayers', ['shift+h']);
126+
addHotkey('canvas', 'invertMask', ['shift+v']);
127+
addHotkey('canvas', 'adjustBbox', ['shift+b']);
126128

127129
// Workflows
128130
addHotkey('workflows', 'addNode', ['shift+a', 'space']);

0 commit comments

Comments
 (0)