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; +}