feat(ui): iterate on state flow and rendering 2

- Rely on redux + reselect more
- Remove all nanostores that simply "mirrored" redux state in favor of direct subscriptions to redux store
- Add abstractions for creating redux subs and running selectors
- Add `initialize` method to CanvasModuleBase, for post-instantiation tasks
- Reduce local caching of state in modules to a minimum
This commit is contained in:
psychedelicious
2024-09-05 22:19:45 +10:00
parent 8b747b022b
commit aa418f0aba
25 changed files with 296 additions and 294 deletions

View File

@@ -47,7 +47,7 @@ export const ControlLayerControlAdapterModel = memo(({ modelKey, onChange: onCha
} else {
canvasManager.filter.$config.set(IMAGE_FILTERS.canny_image_processor.buildDefaults(modelConfig.base));
}
canvasManager.filter.initialize(entityIdentifier);
canvasManager.filter.startFilter(entityIdentifier);
canvasManager.filter.previewFilter();
}
},

View File

@@ -1,15 +1,9 @@
import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
selectCanvasSettingsSlice,
settingsDynamicGridToggled,
} from 'features/controlLayers/store/canvasSettingsSlice';
import { selectDynamicGrid, settingsDynamicGridToggled } from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
const selectDynamicGrid = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.dynamicGrid);
export const CanvasSettingsDynamicGridSwitch = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();

View File

@@ -1,6 +1,5 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { $socket } from 'app/hooks/useSocketIO';
import { logger } from 'app/logging/logger';
import { useAppStore } from 'app/store/nanostores/store';
@@ -9,7 +8,7 @@ import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
import Konva from 'konva';
import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useDevicePixelRatio } from 'use-device-pixel-ratio';
@@ -47,9 +46,6 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null)
}, [dpr]);
};
const selectDynamicGrid = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.dynamicGrid);
const selectShowHUD = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.showHUD);
export const StageComponent = memo(() => {
const dynamicGrid = useAppSelector(selectDynamicGrid);
const showHUD = useAppSelector(selectShowHUD);

View File

@@ -13,7 +13,7 @@ export const CanvasEntityMenuItemsFilter = memo(() => {
const isBusy = useCanvasIsBusy();
const onClick = useCallback(() => {
canvasManager.filter.initialize(entityIdentifier);
canvasManager.filter.startFilter(entityIdentifier);
}, [canvasManager.filter, entityIdentifier]);
return (

View File

@@ -1,12 +1,8 @@
import { useStore } from '@nanostores/react';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useMemo } from 'react';
export const useIsTransforming = () => {
const canvasManager = useCanvasManager();
const transformingEntity = useStore(canvasManager.stateApi.$transformingAdapter);
const isTransforming = useMemo(() => {
return Boolean(transformingEntity);
}, [transformingEntity]);
const isTransforming = useStore(canvasManager.stateApi.$isTranforming);
return isTransforming;
};

View File

@@ -1,9 +1,8 @@
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { createReduxSubscription, getPrefixedId } from 'features/controlLayers/konva/util';
import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectDynamicGrid } from 'features/controlLayers/store/canvasSettingsSlice';
import Konva from 'konva';
import type { Logger } from 'roarr';
@@ -30,8 +29,6 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
readonly manager: CanvasManager;
readonly log: Logger;
dynamicGrid: boolean;
subscriptions = new Set<() => void>();
config: CanvasBackgroundModuleConfig = DEFAULT_CONFIG;
@@ -65,21 +62,24 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
*/
this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render));
const selectDynamicGrid = createSelector(selectCanvasSettingsSlice, (settings) => settings.dynamicGrid);
this.dynamicGrid = selectDynamicGrid(this.manager.stateApi.store.getState());
this.subscriptions.add(
createReduxSubscription(this.manager.stateApi.store, selectDynamicGrid, (dynamicGrid) => {
this.dynamicGrid = dynamicGrid;
this.render();
})
);
/**
* The background grid should be rendered when the dynamic grid setting changes.
*/
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectDynamicGrid, this.render));
}
initialize = () => {
this.log.debug('Initializing module');
this.render();
};
/**
* Renders the background grid.
*/
render = () => {
if (!this.dynamicGrid) {
const dynamicGrid = this.manager.stateApi.runSelector(selectDynamicGrid);
if (!dynamicGrid) {
this.konva.layer.visible(false);
return;
}

View File

@@ -1,10 +1,9 @@
import { createSelector } from '@reduxjs/toolkit';
import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMultiple';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { createReduxSubscription, getPrefixedId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { CanvasState, Coordinate, Rect } from 'features/controlLayers/store/types';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectBbox } from 'features/controlLayers/store/selectors';
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
@@ -52,9 +51,7 @@ export class CanvasBboxModule extends CanvasModuleBase {
* Buffer to store the last aspect ratio of the bbox. When the users holds shift while transforming the bbox, this is
* used to lock the aspect ratio.
*/
$aspectRatioBuffer = atom(0);
state: CanvasState['bbox'];
$aspectRatioBuffer = atom(1);
constructor(manager: CanvasManager) {
super();
@@ -110,25 +107,25 @@ export class CanvasBboxModule extends CanvasModuleBase {
this.subscriptions.add(this.manager.tool.$tool.listen(this.render));
// Also listen to redux state to update the bbox's position and dimensions.
const selectBbox = createSelector(selectCanvasSlice, (canvas) => canvas.bbox);
this.state = selectBbox(this.manager.stateApi.store.getState());
this.$aspectRatioBuffer.set(this.state.rect.width / this.state.rect.height);
this.render();
this.subscriptions.add(
createReduxSubscription(this.manager.stateApi.store, selectBbox, (bbox) => {
this.state = bbox;
this.render();
})
);
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectBbox, this.render));
}
initialize = () => {
this.log.debug('Initializing module');
// We need to retain a copy of the bbox state because
const { width, height } = this.manager.stateApi.runSelector(selectBbox).rect;
// Update the aspect ratio buffer with the initial aspect ratio
this.$aspectRatioBuffer.set(width / height);
this.render();
};
/**
* Renders the bbox. The bbox is only visible when the tool is set to 'bbox'.
*/
render = () => {
this.log.trace('Rendering');
const { x, y, width, height } = this.manager.stateApi.getBbox().rect;
const { x, y, width, height } = this.manager.stateApi.runSelector(selectBbox).rect;
const tool = this.manager.tool.$tool.get();
this.konva.group.visible(true);

View File

@@ -1,10 +1,11 @@
import { createSelector } from '@reduxjs/toolkit';
import type { SerializableObject } from 'common/types';
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntityObjectRenderer';
import type { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { selectEntity } from 'features/controlLayers/store/selectors';
import { getIsHiddenSelector, selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier, CanvasRenderableEntityState, Rect } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
@@ -23,16 +24,6 @@ export abstract class CanvasEntityAdapterBase<
readonly entityIdentifier: CanvasEntityIdentifier<T['type']>;
/**
* The Konva nodes that make up the entity adapter:
* - A Konva.Layer to hold the everything
*
* Note that the transformer and object renderer have their own Konva nodes, but they are not stored here.
*/
konva: {
layer: Konva.Layer;
};
/**
* The transformer for this entity adapter.
*/
@@ -43,6 +34,38 @@ export abstract class CanvasEntityAdapterBase<
*/
abstract renderer: CanvasEntityObjectRenderer;
/**
* Synchronizes the entity state with the canvas. This includes rendering the entity's objects, handling visibility,
* positioning, opacity, locked state, and any other properties.
*
* Implementations should be minimal and should only update the canvas if the state has changed.
*
* If `state` is undefined, the entity was just deleted and the adapter should destroy itself.
*
* If `prevState` is undefined, this is the first time the entity is being synced.
*/
abstract sync: (state: T | undefined, prevState: T | undefined) => void;
/**
* Gets the canvas element for the entity. If `rect` is provided, the canvas will be clipped to that rectangle.
*/
abstract getCanvas: (rect?: Rect) => HTMLCanvasElement;
/**
* Gets a hashable representation of the entity's state.
*/
abstract getHashableState: () => SerializableObject;
/**
* The Konva nodes that make up the entity adapter:
* - A Konva.Layer to hold the everything
*
* Note that the transformer and object renderer have their own Konva nodes, but they are not stored here.
*/
konva: {
layer: Konva.Layer;
};
/**
* The entity's state.
*/
@@ -75,40 +98,36 @@ export abstract class CanvasEntityAdapterBase<
this.manager.stage.addLayer(this.konva.layer);
// On creation, we need to get the latest snapshot of the entity's state from the store.
const initialState = this.getSnapshot();
assert(initialState !== undefined, 'Missing entity state on creation');
this.state = initialState;
// We must have the entity state on creation.
const state = this.manager.stateApi.runSelector(this.selectState);
assert(state !== undefined, 'Missing entity state on creation');
this.state = state;
// When the hidden flag is updated, we need to update the entity's visibility and transformer interaction state,
// which will show/hide the entity's selection outline
this.subscriptions.add(
this.manager.stateApi.createStoreSubscription(getIsHiddenSelector(this.entityIdentifier.type), () => {
this.syncOpacity();
this.transformer.syncInteractionState();
})
);
}
/**
* Gets the latest snapshot of the entity's state from the store. If the entity does not exist, returns undefined.
* A redux selector that selects the entity's state from the canvas slice.
*/
getSnapshot = (): T | undefined => {
return selectEntity(this.manager.stateApi.getCanvasState(), this.entityIdentifier) as T | undefined;
selectState = createSelector(
selectCanvasSlice,
(canvas) => selectEntity(canvas, this.entityIdentifier) as T | undefined
);
initialize = async () => {
this.log.debug('Initializing module');
await this.sync(this.manager.stateApi.runSelector(this.selectState), undefined);
this.transformer.initialize();
await this.renderer.initialize();
};
/**
* Syncs the entity state with the canvas. This includes rendering the entity's objects, handling visibility,
* positioning, opacity, locked state, and any other properties.
*
* Implementations should be minimal and should only update the canvas if the state has changed. However, if `force`
* is true, the entity should be updated regardless of whether the state has changed.
*
* If the entity cannot be rendered, it should be destroyed.
*/
abstract sync: (force?: boolean) => void;
/**
* Gets the canvas element for the entity. If `rect` is provided, the canvas will be clipped to that rectangle.
*/
abstract getCanvas: (rect?: Rect) => HTMLCanvasElement;
/**
* Gets a hashable representation of the entity's state.
*/
abstract getHashableState: () => SerializableObject;
/**
* Synchronizes the enabled state of the entity with the canvas.
*/
@@ -116,18 +135,18 @@ export abstract class CanvasEntityAdapterBase<
this.log.trace('Updating visibility');
this.konva.layer.visible(this.state.isEnabled);
this.renderer.syncCache(this.state.isEnabled);
this.transformer.syncInteractionState();
};
/**
* Synchronizes the entity's objects with the canvas.
*/
syncObjects = () => {
this.renderer.render().then((didRender) => {
if (didRender) {
// If the objects have changed, we need to recalculate the transformer's bounding box.
this.transformer.requestRectCalculation();
}
});
syncObjects = async () => {
const didRender = await this.renderer.render();
if (didRender) {
// If the objects have changed, we need to recalculate the transformer's bounding box.
this.transformer.requestRectCalculation();
}
};
/**

View File

@@ -15,16 +15,14 @@ export class CanvasEntityAdapterControlLayer extends CanvasEntityAdapterBase<Can
constructor(entityIdentifier: CanvasEntityIdentifier<'control_layer'>, manager: CanvasManager) {
super(entityIdentifier, manager, CanvasEntityAdapterControlLayer.TYPE);
this.transformer = new CanvasEntityTransformer(this);
this.renderer = new CanvasEntityObjectRenderer(this);
this.subscriptions.add(this.manager.stateApi.store.subscribe(this.sync));
this.sync(true);
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectState, this.sync));
}
sync = (force?: boolean) => {
const prevState = this.state;
const state = this.getSnapshot();
sync = async (state: CanvasControlLayerState | undefined, prevState: CanvasControlLayerState | undefined) => {
if (!state) {
this.destroy();
return;
@@ -32,26 +30,26 @@ export class CanvasEntityAdapterControlLayer extends CanvasEntityAdapterBase<Can
this.state = state;
if (!force && prevState === this.state) {
if (prevState && prevState === this.state) {
return;
}
if (force || this.state.isEnabled !== prevState.isEnabled) {
if (!prevState || this.state.isEnabled !== prevState.isEnabled) {
this.syncIsEnabled();
}
if (force || this.state.isLocked !== prevState.isLocked) {
if (!prevState || this.state.isLocked !== prevState.isLocked) {
this.syncIsLocked();
}
if (force || this.state.objects !== prevState.objects) {
this.syncObjects();
if (!prevState || this.state.objects !== prevState.objects) {
await this.syncObjects();
}
if (force || this.state.position !== prevState.position) {
if (!prevState || this.state.position !== prevState.position) {
this.syncPosition();
}
if (force || this.state.opacity !== prevState.opacity) {
if (!prevState || this.state.opacity !== prevState.opacity) {
this.syncOpacity();
}
if (force || this.state.withTransparencyEffect !== prevState.withTransparencyEffect) {
if (!prevState || this.state.withTransparencyEffect !== prevState.withTransparencyEffect) {
this.renderer.updateTransparencyEffect();
}
};

View File

@@ -19,14 +19,10 @@ export class CanvasEntityAdapterInpaintMask extends CanvasEntityAdapterBase<Canv
this.transformer = new CanvasEntityTransformer(this);
this.renderer = new CanvasEntityObjectRenderer(this);
this.subscriptions.add(this.manager.stateApi.store.subscribe(this.sync));
this.sync(true);
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectState, this.sync));
}
sync = (force?: boolean) => {
const prevState = this.state;
const state = this.getSnapshot();
sync = async (state: CanvasInpaintMaskState | undefined, prevState: CanvasInpaintMaskState | undefined) => {
if (!state) {
this.destroy();
return;
@@ -34,29 +30,29 @@ export class CanvasEntityAdapterInpaintMask extends CanvasEntityAdapterBase<Canv
this.state = state;
if (!force && prevState === this.state) {
if (prevState && prevState === this.state) {
return;
}
if (force || this.state.isEnabled !== prevState.isEnabled) {
if (!prevState || this.state.isEnabled !== prevState.isEnabled) {
this.syncIsEnabled();
}
if (force || this.state.isLocked !== prevState.isLocked) {
if (!prevState || this.state.isLocked !== prevState.isLocked) {
this.syncIsLocked();
}
if (force || this.state.objects !== prevState.objects) {
this.syncObjects();
if (!prevState || this.state.objects !== prevState.objects) {
await this.syncObjects();
}
if (force || this.state.position !== prevState.position) {
if (!prevState || this.state.position !== prevState.position) {
this.syncPosition();
}
if (force || this.state.opacity !== prevState.opacity) {
if (!prevState || this.state.opacity !== prevState.opacity) {
this.syncOpacity();
}
if (force || this.state.fill !== prevState.fill) {
if (!prevState || this.state.fill !== prevState.fill) {
this.syncCompositingRectFill();
}
if (force) {
if (!prevState) {
this.syncCompositingRectSize();
}
};

View File

@@ -15,16 +15,14 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase<Canv
constructor(entityIdentifier: CanvasEntityIdentifier<'raster_layer'>, manager: CanvasManager) {
super(entityIdentifier, manager, CanvasEntityAdapterRasterLayer.TYPE);
this.transformer = new CanvasEntityTransformer(this);
this.renderer = new CanvasEntityObjectRenderer(this);
this.subscriptions.add(this.manager.stateApi.store.subscribe(this.sync));
this.sync(true);
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectState, this.sync));
}
sync = (force?: boolean) => {
const prevState = this.state;
const state = this.getSnapshot();
sync = async (state: CanvasRasterLayerState | undefined, prevState: CanvasRasterLayerState | undefined) => {
if (!state) {
this.destroy();
return;
@@ -32,23 +30,23 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase<Canv
this.state = state;
if (!force && prevState === this.state) {
if (prevState && prevState === this.state) {
return;
}
if (force || this.state.isEnabled !== prevState.isEnabled) {
if (!prevState || this.state.isEnabled !== prevState.isEnabled) {
this.syncIsEnabled();
}
if (force || this.state.isLocked !== prevState.isLocked) {
if (!prevState || this.state.isLocked !== prevState.isLocked) {
this.syncIsLocked();
}
if (force || this.state.objects !== prevState.objects) {
this.syncObjects();
if (!prevState || this.state.objects !== prevState.objects) {
await this.syncObjects();
}
if (force || this.state.position !== prevState.position) {
if (!prevState || this.state.position !== prevState.position) {
this.syncPosition();
}
if (force || this.state.opacity !== prevState.opacity) {
if (!prevState || this.state.opacity !== prevState.opacity) {
this.syncOpacity();
}
};

View File

@@ -19,14 +19,10 @@ export class CanvasEntityAdapterRegionalGuidance extends CanvasEntityAdapterBase
this.transformer = new CanvasEntityTransformer(this);
this.renderer = new CanvasEntityObjectRenderer(this);
this.subscriptions.add(this.manager.stateApi.store.subscribe(this.sync));
this.sync(true);
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(this.selectState, this.sync));
}
sync = (force?: boolean) => {
const prevState = this.state;
const state = this.getSnapshot();
sync = async (state: CanvasRegionalGuidanceState | undefined, prevState: CanvasRegionalGuidanceState | undefined) => {
if (!state) {
this.destroy();
return;
@@ -34,29 +30,29 @@ export class CanvasEntityAdapterRegionalGuidance extends CanvasEntityAdapterBase
this.state = state;
if (!force && prevState === this.state) {
if (prevState && prevState === this.state) {
return;
}
if (force || this.state.isEnabled !== prevState.isEnabled) {
if (!prevState || this.state.isEnabled !== prevState.isEnabled) {
this.syncIsEnabled();
}
if (force || this.state.isLocked !== prevState.isLocked) {
if (!prevState || this.state.isLocked !== prevState.isLocked) {
this.syncIsLocked();
}
if (force || this.state.objects !== prevState.objects) {
this.syncObjects();
if (!prevState || this.state.objects !== prevState.objects) {
await this.syncObjects();
}
if (force || this.state.position !== prevState.position) {
if (!prevState || this.state.position !== prevState.position) {
this.syncPosition();
}
if (force || this.state.opacity !== prevState.opacity) {
if (!prevState || this.state.opacity !== prevState.opacity) {
this.syncOpacity();
}
if (force || this.state.fill !== prevState.fill) {
if (!prevState || this.state.fill !== prevState.fill) {
this.syncCompositingRectFill();
}
if (force) {
if (!prevState) {
this.syncCompositingRectSize();
}
};

View File

@@ -172,7 +172,9 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
// to pan _before_ releasing the mouse button, which would cause the buffer to be lost if we didn't commit it.
this.subscriptions.add(
this.manager.tool.$tool.listen(() => {
this.commitBuffer();
if (this.hasBuffer()) {
this.commitBuffer();
}
})
);
@@ -190,6 +192,11 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
);
}
initialize = async () => {
this.log.debug('Initializing module');
await this.render();
};
/**
* Renders the entity's objects.
* @returns A promise that resolves to a boolean, indicating if any of the objects were rendered.

View File

@@ -1,6 +1,7 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { type CanvasState, getEntityIdentifier } from 'features/controlLayers/store/types';
import type { Logger } from 'roarr';
@@ -12,8 +13,6 @@ export class CanvasEntityRendererModule extends CanvasModuleBase {
readonly parent: CanvasManager;
readonly manager: CanvasManager;
private _state: CanvasState | null = null;
subscriptions = new Set<() => void>();
constructor(manager: CanvasManager) {
@@ -26,99 +25,61 @@ export class CanvasEntityRendererModule extends CanvasModuleBase {
this.log.debug('Creating module');
this.subscriptions.add(this.manager.stateApi.store.subscribe(this.render));
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSlice, this.render));
}
render = () => {
const state = this.manager.stateApi.getCanvasState();
const prevState = this._state;
this._state = state;
this.manager.stateApi.$settingsState.set(this.manager.stateApi.getSettings());
this.manager.stateApi.$selectedEntityIdentifier.set(state.selectedEntityIdentifier);
this.manager.stateApi.$currentFill.set(this.manager.stateApi.getCurrentColor());
if (prevState === state) {
// No changes to state - no need to render
return;
}
this.renderRasterLayers(state, prevState);
this.renderControlLayers(prevState, state);
this.renderRegionalGuidance(prevState, state);
this.renderInpaintMasks(state, prevState);
this.arrangeEntities(state, prevState);
this.manager.tool.syncCursorStyle();
initialize = () => {
this.log.debug('Initializing module');
this.render(this.manager.stateApi.runSelector(selectCanvasSlice), null);
};
renderRasterLayers = (state: CanvasState, prevState: CanvasState | null) => {
const adapterMap = this.manager.adapters.rasterLayers;
if (!prevState || state.rasterLayers.isHidden !== prevState.rasterLayers.isHidden) {
for (const adapter of adapterMap.values()) {
adapter.syncOpacity();
}
}
render = async (state: CanvasState, prevState: CanvasState | null) => {
await this.createNewRasterLayers(state, prevState);
await this.createNewControlLayers(state, prevState);
await this.createNewRegionalGuidance(state, prevState);
await this.createNewInpaintMasks(state, prevState);
this.arrangeEntities(state, prevState);
};
createNewRasterLayers = async (state: CanvasState, prevState: CanvasState | null) => {
if (!prevState || state.rasterLayers.entities !== prevState.rasterLayers.entities) {
for (const entityState of state.rasterLayers.entities) {
if (!adapterMap.has(entityState.id)) {
this.manager.createAdapter(getEntityIdentifier(entityState));
if (!this.manager.adapters.rasterLayers.has(entityState.id)) {
const adapter = this.manager.createAdapter(getEntityIdentifier(entityState));
await adapter.initialize();
}
}
}
};
renderControlLayers = (prevState: CanvasState | null, state: CanvasState) => {
const adapterMap = this.manager.adapters.controlLayers;
if (!prevState || state.controlLayers.isHidden !== prevState.controlLayers.isHidden) {
for (const adapter of adapterMap.values()) {
adapter.syncOpacity();
}
}
createNewControlLayers = async (state: CanvasState, prevState: CanvasState | null) => {
if (!prevState || state.controlLayers.entities !== prevState.controlLayers.entities) {
for (const entityState of state.controlLayers.entities) {
if (!adapterMap.has(entityState.id)) {
this.manager.createAdapter(getEntityIdentifier(entityState));
if (!this.manager.adapters.controlLayers.has(entityState.id)) {
const adapter = this.manager.createAdapter(getEntityIdentifier(entityState));
await adapter.initialize();
}
}
}
};
renderRegionalGuidance = (prevState: CanvasState | null, state: CanvasState) => {
const adapterMap = this.manager.adapters.regionMasks;
if (!prevState || state.regions.isHidden !== prevState.regions.isHidden) {
for (const adapter of adapterMap.values()) {
adapter.syncOpacity();
}
}
createNewRegionalGuidance = async (state: CanvasState, prevState: CanvasState | null) => {
if (!prevState || state.regions.entities !== prevState.regions.entities) {
for (const entityState of state.regions.entities) {
if (!adapterMap.has(entityState.id)) {
this.manager.createAdapter(getEntityIdentifier(entityState));
if (!this.manager.adapters.regionMasks.has(entityState.id)) {
const adapter = this.manager.createAdapter(getEntityIdentifier(entityState));
await adapter.initialize();
}
}
}
};
renderInpaintMasks = (state: CanvasState, prevState: CanvasState | null) => {
const adapterMap = this.manager.adapters.inpaintMasks;
if (!prevState || state.inpaintMasks.isHidden !== prevState.inpaintMasks.isHidden) {
for (const adapter of adapterMap.values()) {
adapter.syncOpacity();
}
}
createNewInpaintMasks = async (state: CanvasState, prevState: CanvasState | null) => {
if (!prevState || state.inpaintMasks.entities !== prevState.inpaintMasks.entities) {
for (const entityState of state.inpaintMasks.entities) {
if (!adapterMap.has(entityState.id)) {
this.manager.createAdapter(getEntityIdentifier(entityState));
if (!this.manager.adapters.inpaintMasks.has(entityState.id)) {
const adapter = this.manager.createAdapter(getEntityIdentifier(entityState));
await adapter.initialize();
}
}
}
@@ -133,7 +94,7 @@ export class CanvasEntityRendererModule extends CanvasModuleBase {
state.inpaintMasks.entities !== prevState.inpaintMasks.entities ||
state.selectedEntityIdentifier?.id !== prevState.selectedEntityIdentifier?.id
) {
this.log.debug('Arranging entities');
this.log.trace('Arranging entities');
let zIndex = 0;

View File

@@ -2,6 +2,7 @@ import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEnt
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { canvasToImageData, getEmptyRect, getPrefixedId } from 'features/controlLayers/konva/util';
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import type { Coordinate, Rect, RectWithRotation } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { GroupConfig } from 'konva/lib/Group';
@@ -221,13 +222,20 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
this.subscriptions.add(this.manager.tool.$tool.listen(this.syncInteractionState));
// When the selected entity changes, we need to update the transformer's interaction state.
this.subscriptions.add(this.manager.stateApi.$selectedEntityIdentifier.listen(this.syncInteractionState));
this.subscriptions.add(
this.manager.stateApi.createStoreSubscription(selectSelectedEntityIdentifier, this.syncInteractionState)
);
this.parent.konva.layer.add(this.konva.outlineRect);
this.parent.konva.layer.add(this.konva.proxyRect);
this.parent.konva.layer.add(this.konva.transformer);
}
initialize = () => {
this.log.debug('Initializing module');
this.syncInteractionState();
};
anchorStyleFunc = (anchor: Konva.Rect): void => {
// Give the rotater special styling
if (anchor.hasName('rotater')) {

View File

@@ -46,7 +46,7 @@ export class CanvasFilterModule extends CanvasModuleBase {
this.log.debug('Creating filter module');
}
initialize = (entityIdentifier: CanvasEntityIdentifier) => {
startFilter = (entityIdentifier: CanvasEntityIdentifier) => {
this.log.trace('Initializing filter');
const adapter = this.manager.getAdapter(entityIdentifier);
if (!adapter) {

View File

@@ -172,19 +172,23 @@ export class CanvasManager extends CanvasModuleBase {
];
};
createAdapter = (entityIdentifier: CanvasEntityIdentifier): void => {
createAdapter = (entityIdentifier: CanvasEntityIdentifier): CanvasEntityAdapter => {
if (isRasterLayerEntityIdentifier(entityIdentifier)) {
const adapter = new CanvasEntityAdapterRasterLayer(entityIdentifier, this);
this.adapters.rasterLayers.set(adapter.id, adapter);
return adapter;
} else if (isControlLayerEntityIdentifier(entityIdentifier)) {
const adapter = new CanvasEntityAdapterControlLayer(entityIdentifier, this);
this.adapters.controlLayers.set(adapter.id, adapter);
return adapter;
} else if (isRegionalGuidanceEntityIdentifier(entityIdentifier)) {
const adapter = new CanvasEntityAdapterRegionalGuidance(entityIdentifier, this);
this.adapters.regionMasks.set(adapter.id, adapter);
return adapter;
} else if (isInpaintMaskEntityIdentifier(entityIdentifier)) {
const adapter = new CanvasEntityAdapterInpaintMask(entityIdentifier, this);
this.adapters.inpaintMasks.set(adapter.id, adapter);
return adapter;
} else {
assert(false, 'Unhandled entity type');
}
@@ -199,16 +203,29 @@ export class CanvasManager extends CanvasModuleBase {
this._isDebugging = false;
}
getAllModules = (): CanvasModuleBase[] => {
return [
this.bbox,
this.stagingArea,
this.tool,
this.progressImage,
this.stateApi,
this.background,
this.filter,
this.worker,
this.entityRenderer,
this.compositor,
this.stage,
];
};
initialize = () => {
this.log.debug('Initializing canvas manager module');
this.log.debug('Initializing');
// These atoms require the canvas manager to be set up before we can provide their initial values
this.stateApi.$transformingAdapter.set(null);
this.stateApi.$settingsState.set(this.stateApi.getSettings());
this.stateApi.$selectedEntityIdentifier.set(this.stateApi.getCanvasState().selectedEntityIdentifier);
this.stateApi.$currentFill.set(this.stateApi.getCurrentColor());
for (const canvasModule of this.getAllModules()) {
canvasModule.initialize?.();
}
this.stage.initialize();
$canvasManager.set(this);
};
@@ -219,19 +236,9 @@ export class CanvasManager extends CanvasModuleBase {
adapter.destroy();
}
this.bbox.destroy();
this.stagingArea.destroy();
this.tool.destroy();
this.progressImage.destroy();
this.konva.previewLayer.destroy();
this.stateApi.destroy();
this.background.destroy();
this.filter.destroy();
this.worker.destroy();
this.entityRenderer.destroy();
this.compositor.destroy();
this.stage.destroy();
for (const canvasModule of this.getAllModules()) {
canvasModule.destroy();
}
$canvasManager.set(null);
};

View File

@@ -55,6 +55,14 @@ export abstract class CanvasModuleBase {
*/
abstract readonly log: Logger;
/**
* An optional method that initializes the module. This method is called after all modules have been created.
*
* Use this method to perform any setup that requires all modules to be created. For example, setting some initial
* state or doing an initial render.
*/
initialize?: () => void = undefined;
/**
* Returns a logging context object that includes relevant information about the module.
* Canvas modules may override this method to include additional information in the logging context, but should

View File

@@ -86,7 +86,7 @@ export class CanvasStageModule extends CanvasModuleBase {
};
initialize = () => {
this.log.debug('Initializing stage');
this.log.debug('Initializing module');
this.konva.stage.container(this.container);
const resizeObserver = new ResizeObserver(this.fitStageToContainer);
resizeObserver.observe(this.container);

View File

@@ -1,8 +1,8 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObjectImage';
import { createReduxSubscription, getPrefixedId } from 'features/controlLayers/konva/util';
import { type CanvasSessionState, selectCanvasSessionSlice } from 'features/controlLayers/store/canvasSessionSlice';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectCanvasSessionSlice } from 'features/controlLayers/store/canvasSessionSlice';
import { imageDTOToImageWithDims, type StagingAreaImage } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { atom } from 'nanostores';
@@ -16,8 +16,6 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
readonly manager: CanvasManager;
readonly log: Logger;
state: CanvasSessionState;
subscriptions: Set<() => void> = new Set();
konva: { group: Konva.Group };
image: CanvasObjectImage | null;
@@ -40,22 +38,21 @@ export class CanvasStagingAreaModule extends CanvasModuleBase {
this.selectedImage = null;
this.subscriptions.add(this.$shouldShowStagedImage.listen(this.render));
this.state = selectCanvasSessionSlice(this.manager.stateApi.store.getState());
this.subscriptions.add(
createReduxSubscription(this.manager.stateApi.store, selectCanvasSessionSlice, (session) => {
this.state = session;
this.render();
})
);
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSessionSlice, this.render));
}
initialize = () => {
this.log.debug('Initializing module');
this.render();
};
render = async () => {
this.log.trace('Rendering staging area');
const stagingArea = this.manager.stateApi.runSelector(selectCanvasSessionSlice);
const { x, y, width, height } = this.manager.stateApi.getBbox().rect;
const shouldShowStagedImage = this.$shouldShowStagedImage.get();
this.selectedImage = this.state.stagedImages[this.state.selectedStagedImageIndex] ?? null;
this.selectedImage = stagingArea.stagedImages[stagingArea.selectedStagedImageIndex] ?? null;
this.konva.group.position({ x, y });
if (this.selectedImage) {

View File

@@ -1,9 +1,10 @@
import { $alt, $ctrl, $meta, $shift } from '@invoke-ai/ui-library';
import type { AppStore } from 'app/store/store';
import type { Selector } from '@reduxjs/toolkit';
import type { AppStore, RootState } from 'app/store/store';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { CanvasSettingsState } from 'features/controlLayers/store/canvasSettingsSlice';
import type { SubscriptionHandler } from 'features/controlLayers/konva/util';
import { createReduxSubscription, getPrefixedId } from 'features/controlLayers/konva/util';
import {
settingsBrushWidthChanged,
settingsColorChanged,
@@ -20,7 +21,6 @@ import {
} from 'features/controlLayers/store/canvasSlice';
import { selectAllRenderableEntities, selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type {
CanvasEntityIdentifier,
CanvasEntityType,
EntityBrushLineAddedPayload,
EntityEraserLineAddedPayload,
@@ -32,7 +32,6 @@ import type {
RgbaColor,
} from 'features/controlLayers/store/types';
import { RGBA_BLACK } from 'features/controlLayers/store/types';
import type { WritableAtom } from 'nanostores';
import { atom, computed } from 'nanostores';
import type { Logger } from 'roarr';
import { queueApi } from 'services/api/endpoints/queue';
@@ -68,6 +67,14 @@ export class CanvasStateApiModule extends CanvasModuleBase {
this.store = store;
}
runSelector = <T>(selector: Selector<RootState, T>) => {
return selector(this.store.getState());
};
createStoreSubscription = <T>(selector: Selector<RootState, T>, handler: SubscriptionHandler<T>) => {
return createReduxSubscription(this.store, selector, handler);
};
/**
* Gets the canvas slice.
*
@@ -310,21 +317,6 @@ export class CanvasStateApiModule extends CanvasModuleBase {
*/
$isTranforming = computed(this.$transformingAdapter, (transformingAdapter) => Boolean(transformingAdapter));
/**
* A nanostores atom, kept in sync with the redux store's settings state.
*/
$settingsState: WritableAtom<CanvasSettingsState> = atom();
/**
* The current fill color, derived from the tool state and the selected entity.
*/
$currentFill: WritableAtom<RgbaColor> = atom();
/**
* The currently selected entity's identifier, if an entity is selected.
*/
$selectedEntityIdentifier: WritableAtom<CanvasEntityIdentifier | null> = atom();
/**
* The last canvas progress event. This is set in a global event listener. The staging area may set it to null when it
* consumes the event.

View File

@@ -15,6 +15,8 @@ import {
isDistanceMoreThanMin,
offsetCoord,
} from 'features/controlLayers/konva/util';
import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type {
CanvasControlLayerState,
CanvasInpaintMaskState,
@@ -104,18 +106,8 @@ export class CanvasToolModule extends CanvasModuleBase {
this.konva.group.add(this.colorPickerToolPreview.konva.group);
this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render));
this.subscriptions.add(
this.manager.stateApi.$settingsState.listen((settings, prevSettings) => {
if (
settings !== prevSettings ||
settings.brushWidth !== prevSettings.brushWidth ||
settings.eraserWidth !== prevSettings.eraserWidth ||
settings.color !== prevSettings.color
) {
this.render();
}
})
);
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSettingsSlice, this.render));
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSlice, this.syncCursorStyle));
this.subscriptions.add(
this.$tool.listen(() => {
// On tool switch, reset mouse state
@@ -129,6 +121,12 @@ export class CanvasToolModule extends CanvasModuleBase {
this.subscriptions.add(cleanupListeners);
}
initialize = () => {
this.log.debug('Initializing module');
this.render();
this.syncCursorStyle();
};
setToolVisibility = (tool: Tool, isDrawable: boolean) => {
this.brushToolPreview.setVisibility(isDrawable && tool === 'brush');
this.eraserToolPreview.setVisibility(isDrawable && tool === 'eraser');

View File

@@ -482,7 +482,7 @@ export const getLastPointOfLastLine = (
return null;
};
type SubscriptionHandler<T> = (value: T, prevValue: T) => void;
export type SubscriptionHandler<T> = (value: T, prevValue: T) => void;
export const createReduxSubscription = <T, U>(
store: Store<T>,

View File

@@ -1,4 +1,4 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import type { RgbaColor } from 'features/controlLayers/store/types';
@@ -132,4 +132,12 @@ export const canvasSettingsPersistConfig: PersistConfig<CanvasSettingsState> = {
migrate,
persistDenylist: [],
};
export const selectCanvasSettingsSlice = (s: RootState) => s.canvasSettings;
export const selectDynamicGrid = createSelector(
selectCanvasSettingsSlice,
(canvasSettings) => canvasSettings.dynamicGrid
);
export const selectShowHUD = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.showHUD);

View File

@@ -5,6 +5,7 @@ import type {
CanvasControlLayerState,
CanvasEntityIdentifier,
CanvasEntityState,
CanvasEntityType,
CanvasInpaintMaskState,
CanvasRasterLayerState,
CanvasRegionalGuidanceState,
@@ -176,6 +177,8 @@ export function selectRegionalGuidanceIPAdapter(
return entity.ipAdapters.find((ipAdapter) => ipAdapter.id === ipAdapterId);
}
export const selectBbox = createSelector(selectCanvasSlice, (canvas) => canvas.bbox);
export const selectSelectedEntityIdentifier = createSelector(
selectCanvasSlice,
(canvas) => canvas.selectedEntityIdentifier
@@ -215,3 +218,26 @@ export const selectSelectedEntityFill = createSelector(
return entity.fill;
}
);
const selectRasterLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.rasterLayers.isHidden);
const selectControlLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.controlLayers.isHidden);
const selectInpaintMasksIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.inpaintMasks.isHidden);
const selectRegionalGuidanceIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.regions.isHidden);
/**
* Returns the hidden selector for the given entity type.
*/
export const getIsHiddenSelector = (type: CanvasEntityType) => {
switch (type) {
case 'raster_layer':
return selectRasterLayersIsHidden;
case 'control_layer':
return selectControlLayersIsHidden;
case 'inpaint_mask':
return selectInpaintMasksIsHidden;
case 'regional_guidance':
return selectRegionalGuidanceIsHidden;
default:
assert(false, 'Unhandled entity type');
}
};