diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 46fa92ae10..fe8df2d409 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1659,6 +1659,7 @@
"brushSize": "Brush Size",
"width": "Width",
"zoom": "Zoom",
+ "resetView": "Reset View",
"controlLayers": "Control Layers",
"globalMaskOpacity": "Global Mask Opacity",
"autoNegative": "Auto Negative",
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx
new file mode 100644
index 0000000000..5df90004fe
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasResetViewButton.tsx
@@ -0,0 +1,49 @@
+import { $shift, IconButton } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { $canvasManager } from 'features/controlLayers/konva/CanvasManager';
+import { memo, useCallback } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
+import { useTranslation } from 'react-i18next';
+import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
+
+export const CanvasResetViewButton = memo(() => {
+ const { t } = useTranslation();
+ const canvasManager = useStore($canvasManager);
+
+ const resetZoom = useCallback(() => {
+ if (!canvasManager) {
+ return;
+ }
+ canvasManager.setStageScale(1);
+ }, [canvasManager]);
+
+ const resetView = useCallback(() => {
+ if (!canvasManager) {
+ return;
+ }
+ canvasManager.resetView();
+ }, [canvasManager]);
+
+ const onReset = useCallback(() => {
+ if ($shift.get()) {
+ resetZoom();
+ } else {
+ resetView();
+ }
+ }, [resetView, resetZoom]);
+
+ useHotkeys('r', resetView);
+ useHotkeys('shift+r', resetZoom);
+
+ return (
+ }
+ variant="link"
+ />
+ );
+});
+
+CanvasResetViewButton.displayName = 'CanvasResetViewButton';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasScale.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasScale.tsx
index cfb7a9ff29..43456a658f 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasScale.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasScale.tsx
@@ -1,4 +1,5 @@
import {
+ $shift,
CompositeSlider,
FormControl,
FormLabel,
@@ -6,6 +7,7 @@ import {
NumberInput,
NumberInputField,
Popover,
+ PopoverAnchor,
PopoverArrow,
PopoverBody,
PopoverContent,
@@ -14,14 +16,60 @@ import {
import { useStore } from '@nanostores/react';
import { $canvasManager } from 'features/controlLayers/konva/CanvasManager';
import { MAX_CANVAS_SCALE, MIN_CANVAS_SCALE } from 'features/controlLayers/konva/constants';
+import { snapToNearest } from 'features/controlLayers/konva/util';
import { $stageAttrs } from 'features/controlLayers/store/canvasV2Slice';
import { clamp, round } from 'lodash-es';
import type { KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
+import { PiCaretDownBold } from 'react-icons/pi';
-const formatPct = (v: number | string) => (isNaN(Number(v)) ? '' : `${round(Number(v), 2).toLocaleString()}%`);
+function formatPct(v: number | string) {
+ if (isNaN(Number(v))) {
+ return '';
+ }
+
+ return `${round(Number(v), 2).toLocaleString()}%`;
+}
+
+function mapSliderValueToScale(value: number) {
+ if (value <= 40) {
+ // 0 to 40 -> 10% to 100%
+ return 10 + (90 * value) / 40;
+ } else if (value <= 70) {
+ // 40 to 70 -> 100% to 500%
+ return 100 + (400 * (value - 40)) / 30;
+ } else {
+ // 70 to 100 -> 500% to 2000%
+ return 500 + (1500 * (value - 70)) / 30;
+ }
+}
+
+function mapScaleToSliderValue(scale: number) {
+ if (scale <= 100) {
+ return ((scale - 10) * 40) / 90;
+ } else if (scale <= 500) {
+ return 40 + ((scale - 100) * 30) / 400;
+ } else {
+ return 70 + ((scale - 500) * 30) / 1500;
+ }
+}
+
+function formatSliderValue(value: number) {
+ return String(mapSliderValueToScale(value));
+}
+
+const marks = [
+ mapScaleToSliderValue(10),
+ mapScaleToSliderValue(50),
+ mapScaleToSliderValue(100),
+ mapScaleToSliderValue(500),
+ mapScaleToSliderValue(2000),
+];
+
+const sliderDefaultValue = mapScaleToSliderValue(100);
+
+const snapCandidates = marks.slice(1, marks.length - 1);
export const CanvasScale = memo(() => {
const { t } = useTranslation();
@@ -29,29 +77,29 @@ export const CanvasScale = memo(() => {
const stageAttrs = useStore($stageAttrs);
const [localScale, setLocalScale] = useState(stageAttrs.scale * 100);
- const onChange = useCallback(
+ const onChangeSlider = useCallback(
(scale: number) => {
if (!canvasManager) {
return;
}
- canvasManager.setStageScale(scale / 100);
+ let snappedScale = scale;
+ // Do not snap if shift key is held
+ if (!$shift.get()) {
+ snappedScale = snapToNearest(scale, snapCandidates, 2);
+ }
+ const mappedScale = mapSliderValueToScale(snappedScale);
+ canvasManager.setStageScale(mappedScale / 100);
},
[canvasManager]
);
- const onReset = useCallback(() => {
- if (!canvasManager) {
- return;
- }
-
- canvasManager.setStageScale(1);
- }, [canvasManager]);
-
const onBlur = useCallback(() => {
if (!canvasManager) {
return;
}
if (isNaN(Number(localScale))) {
+ canvasManager.setStageScale(1);
+ setLocalScale(100);
return;
}
canvasManager.setStageScale(clamp(localScale / 100, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE));
@@ -75,39 +123,54 @@ export const CanvasScale = memo(() => {
}, [stageAttrs.scale]);
return (
-
- {t('controlLayers.zoom')}
-
-
+
+
+ {t('controlLayers.zoom')}
+
-
+
+
+ }
+ size="sm"
+ variant="link"
+ position="absolute"
+ insetInlineEnd={0}
+ h="full"
+ />
+
-
-
-
-
-
-
-
-
- } variant="link" />
-
+
+
+
+
+
+
+
+
+
);
});
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx
index e8d31eecf9..db3b42aaf9 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayersToolbar.tsx
@@ -4,6 +4,7 @@ import { Flex, Switch } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { BrushWidth } from 'features/controlLayers/components/BrushWidth';
+import { CanvasResetViewButton } from 'features/controlLayers/components/CanvasResetViewButton';
import { CanvasScale } from 'features/controlLayers/components/CanvasScale';
import ControlLayersSettingsPopover from 'features/controlLayers/components/ControlLayersSettingsPopover';
import { EraserWidth } from 'features/controlLayers/components/EraserWidth';
@@ -54,6 +55,7 @@ export const ControlLayersToolbar = memo(() => {
{tool === 'eraser' && }
+
debug
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackground.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackground.ts
index c75e8703b4..1143b5f130 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackground.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackground.ts
@@ -14,9 +14,19 @@ export class CanvasBackground {
layer: Konva.Layer;
};
+ /**
+ * A set of subscriptions that should be cleaned up when the transformer is destroyed.
+ */
+ subscriptions: Set<() => void> = new Set();
+
constructor(manager: CanvasManager) {
this.manager = manager;
this.konva = { layer: new Konva.Layer({ name: CanvasBackground.LAYER_NAME, listening: false }) };
+ this.subscriptions.add(
+ this.manager.stateApi.$stageAttrs.listen(() => {
+ this.render();
+ })
+ );
}
render() {
@@ -94,6 +104,13 @@ export class CanvasBackground {
}
}
+ destroy = () => {
+ for (const cleanup of this.subscriptions) {
+ cleanup();
+ }
+ this.konva.layer.destroy();
+ };
+
/**
* Gets the grid spacing. The value depends on the stage scale - at higher scales, the grid spacing is smaller.
* @param scale The stage scale
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
index cc779cdee6..6a3a5b6348 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
@@ -156,6 +156,19 @@ export class CanvasManager {
this.container = container;
this._store = store;
this.stateApi = new CanvasStateApi(this._store, this);
+
+ this.transformingEntity = new PubSub(null);
+ this.toolState = new PubSub(this.stateApi.getToolState());
+ this.currentFill = new PubSub(this.getCurrentFill());
+ this.selectedEntityIdentifier = new PubSub(
+ this.stateApi.getState().selectedEntityIdentifier,
+ (a, b) => a?.id === b?.id
+ );
+ this.selectedEntity = new PubSub(
+ this.getSelectedEntity(),
+ (a, b) => a?.state === b?.state && a?.adapter === b?.adapter
+ );
+
this._prevState = this.stateApi.getState();
this.log = logger('canvas').child((message) => {
@@ -213,18 +226,6 @@ export class CanvasManager {
this.log.error('Worker message error');
};
- this.transformingEntity = new PubSub(null);
- this.toolState = new PubSub(this.stateApi.getToolState());
- this.currentFill = new PubSub(this.getCurrentFill());
- this.selectedEntityIdentifier = new PubSub(
- this.stateApi.getState().selectedEntityIdentifier,
- (a, b) => a?.id === b?.id
- );
- this.selectedEntity = new PubSub(
- this.getSelectedEntity(),
- (a, b) => a?.state === b?.state && a?.adapter === b?.adapter
- );
-
this.inpaintMask = new CanvasMaskAdapter(this.stateApi.getInpaintMaskState(), this);
this.stage.add(this.inpaintMask.konva.layer);
}
@@ -303,7 +304,34 @@ export class CanvasManager {
height: this.stage.height(),
scale: this.stage.scaleX(),
});
- this.background.render();
+ }
+
+ resetView() {
+ const { width, height } = this.getStageSize();
+ const { rect } = this.stateApi.getBbox();
+
+ const padding = 20; // Padding in absolute pixels
+
+ const availableWidth = width - padding * 2;
+ const availableHeight = height - padding * 2;
+
+ const scale = Math.min(availableWidth / rect.width, availableHeight / rect.height);
+ const x = -rect.x * scale + padding + (availableWidth - rect.width * scale) / 2;
+ const y = -rect.y * scale + padding + (availableHeight - rect.height * scale) / 2;
+
+ this.stage.setAttrs({
+ x,
+ y,
+ scaleX: scale,
+ scaleY: scale,
+ });
+
+ this.stateApi.$stageAttrs.set({
+ ...this.stateApi.$stageAttrs.get(),
+ x,
+ y,
+ scale,
+ });
}
getEntity(identifier: CanvasEntityIdentifier): EntityStateAndAdapter | null {
@@ -618,6 +646,8 @@ export class CanvasManager {
for (const controlAdapter of this.controlAdapters.values()) {
controlAdapter.destroy();
}
+ this.background.destroy();
+ this.preview.destroy();
unsubscribeRenderer();
unsubscribeListeners();
unsubscribeShouldShowStagedImage();
@@ -678,8 +708,6 @@ export class CanvasManager {
height: this.stage.height(),
scale: this.stage.scaleX(),
});
- this.background.render();
- this.preview.tool.render();
}
/**
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasPreview.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasPreview.ts
index 882a01e7af..ef0f6a579b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasPreview.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasPreview.ts
@@ -32,4 +32,9 @@ export class CanvasPreview {
this.progressPreview = progressPreview;
this.layer.add(this.progressPreview.konva.group);
}
+
+ destroy() {
+ this.tool.destroy();
+ this.layer.destroy();
+ }
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts
index 7a20d3579e..11aeabac4d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts
@@ -42,6 +42,11 @@ export class CanvasTool {
};
};
+ /**
+ * A set of subscriptions that should be cleaned up when the transformer is destroyed.
+ */
+ subscriptions: Set<() => void> = new Set();
+
constructor(manager: CanvasManager) {
this.manager = manager;
this.konva = {
@@ -102,8 +107,27 @@ export class CanvasTool {
this.konva.eraser.group.add(this.konva.eraser.innerBorderCircle);
this.konva.eraser.group.add(this.konva.eraser.outerBorderCircle);
this.konva.group.add(this.konva.eraser.group);
+
+ this.subscriptions.add(
+ this.manager.stateApi.$stageAttrs.listen(() => {
+ this.render();
+ })
+ );
+
+ this.subscriptions.add(
+ this.manager.toolState.subscribe(() => {
+ this.render();
+ })
+ );
}
+ destroy = () => {
+ for (const cleanup of this.subscriptions) {
+ cleanup();
+ }
+ this.konva.group.destroy();
+ };
+
scaleTool = () => {
const toolState = this.manager.stateApi.getToolState();
const scale = this.manager.stage.scaleX();
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
index 054b433646..55ebcbaab4 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
@@ -492,8 +492,6 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
height: stage.height(),
scale: stage.scaleX(),
});
- manager.background.render();
- manager.preview.tool.render();
});
//#region dragend
@@ -531,13 +529,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
$spaceKey.set(true);
$lastCursorPos.set(null);
$lastMouseDownPos.set(null);
- } else if (e.key === 'r') {
- $lastCursorPos.set(null);
- $lastMouseDownPos.set(null);
- manager.background.render();
- // TODO(psyche): restore some kind of fit
}
- manager.preview.tool.render();
};
window.addEventListener('keydown', onKeyDown);
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
index f5c25c580e..764d9759a9 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
@@ -374,3 +374,17 @@ export function getObjectId(type: CanvasObjectState['type'], isBuffer?: boolean)
export const getEmptyRect = (): Rect => {
return { x: 0, y: 0, width: 0, height: 0 };
};
+export function snapToNearest(value: number, candidateValues: number[], threshold: number): number {
+ let closest = value;
+ let minDiff = Number.MAX_VALUE;
+
+ for (const candidate of candidateValues) {
+ const diff = Math.abs(value - candidate);
+ if (diff < minDiff && diff <= threshold) {
+ minDiff = diff;
+ closest = candidate;
+ }
+ }
+
+ return closest;
+}