feat(ui): revised event pubsub, transformer logic split out

This commit is contained in:
psychedelicious 2024-08-05 17:42:02 +10:00
parent 54f2acf5b9
commit fa94979ab6
13 changed files with 506 additions and 456 deletions

View File

@ -90,7 +90,7 @@ export class CanvasBbox {
assert(stage, 'Stage must exist');
// We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid.
const gridSize = this.manager.stateApi.getCtrlKey() || this.manager.stateApi.getMetaKey() ? 8 : 64;
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
// Because we are working in absolute coordinates, we need to scale the grid size by the stage scale.
const scaledGridSize = gridSize * stage.scaleX();
// To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position.
@ -107,7 +107,7 @@ export class CanvasBbox {
}),
};
this.konva.rect.on('dragmove', () => {
const gridSize = this.manager.stateApi.getCtrlKey() || this.manager.stateApi.getMetaKey() ? 8 : 64;
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
const bbox = this.manager.stateApi.getBbox();
const bboxRect: Rect = {
...bbox.rect,
@ -129,10 +129,10 @@ export class CanvasBbox {
return;
}
const alt = this.manager.stateApi.getAltKey();
const ctrl = this.manager.stateApi.getCtrlKey();
const meta = this.manager.stateApi.getMetaKey();
const shift = this.manager.stateApi.getShiftKey();
const alt = this.manager.stateApi.$altKey.get();
const ctrl = this.manager.stateApi.$ctrlKey.get();
const meta = this.manager.stateApi.$metaKey.get();
const shift = this.manager.stateApi.$shiftKey.get();
// Grid size depends on the modifier keys
let gridSize = ctrl || meta ? 8 : 64;
@ -141,7 +141,7 @@ export class CanvasBbox {
// new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if
// we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes.
// Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid.
if (this.manager.stateApi.getAltKey()) {
if (this.manager.stateApi.$altKey.get()) {
gridSize = gridSize * 2;
}

View File

@ -11,8 +11,9 @@ export class CanvasControlAdapter extends CanvasEntity {
static TRANSFORMER_NAME = `${CanvasControlAdapter.NAME_PREFIX}_transformer`;
static GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_group`;
static OBJECT_GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_object-group`;
static TYPE = 'control_adapter' as const;
type = 'control_adapter';
type = CanvasControlAdapter.TYPE;
_state: CanvasControlAdapterState;
konva: {

View File

@ -5,7 +5,12 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasRectRenderer } from 'features/controlLayers/konva/CanvasRect';
import { getNodeBboxFast } from 'features/controlLayers/konva/entityBbox';
import { mapId } from 'features/controlLayers/konva/util';
import type { CanvasBrushLineState, CanvasEraserLineState, CanvasInpaintMaskState, CanvasRectState } from 'features/controlLayers/store/types';
import type {
CanvasBrushLineState,
CanvasEraserLineState,
CanvasInpaintMaskState,
CanvasRectState,
} from 'features/controlLayers/store/types';
import { isDrawingTool, RGBA_RED } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { assert } from 'tsafe';
@ -17,11 +22,12 @@ export class CanvasInpaintMask {
static GROUP_NAME = `${CanvasInpaintMask.NAME_PREFIX}_group`;
static OBJECT_GROUP_NAME = `${CanvasInpaintMask.NAME_PREFIX}_object-group`;
static COMPOSITING_RECT_NAME = `${CanvasInpaintMask.NAME_PREFIX}_compositing-rect`;
static TYPE = 'inpaint_mask' as const;
private drawingBuffer: CanvasBrushLineState | CanvasEraserLineState | CanvasRectState | null;
private state: CanvasInpaintMaskState;
id = 'inpaint_mask';
id = CanvasInpaintMask.TYPE;
type = CanvasInpaintMask.TYPE;
manager: CanvasManager;
konva: {

View File

@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasObjectRenderer } from 'features/controlLayers/konva/CanvasObjectRenderer';
import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer';
import { konvaNodeToBlob, previewBlob } from 'features/controlLayers/konva/util';
import { getEmptyRect, konvaNodeToBlob, previewBlob } from 'features/controlLayers/konva/util';
import { layerRasterized } from 'features/controlLayers/store/canvasV2Slice';
import type {
CanvasLayerState,
@ -19,11 +19,12 @@ import type { Logger } from 'roarr';
import { uploadImage } from 'services/api/endpoints/images';
export class CanvasLayer {
static TYPE = 'layer';
static TYPE = 'layer' as const;
static KONVA_LAYER_NAME = `${CanvasLayer.TYPE}_layer`;
static KONVA_OBJECT_GROUP_NAME = `${CanvasLayer.TYPE}_object-group`;
id: string;
type = CanvasLayer.TYPE;
manager: CanvasManager;
log: Logger;
getLoggingContext: GetLoggingContext;
@ -38,12 +39,11 @@ export class CanvasLayer {
renderer: CanvasObjectRenderer;
isFirstRender: boolean = true;
bboxNeedsUpdate: boolean;
isTransforming: boolean;
isPendingBboxCalculation: boolean;
bboxNeedsUpdate: boolean = true;
isPendingBboxCalculation: boolean = false;
rect: Rect;
bbox: Rect;
rect: Rect = getEmptyRect();
bbox: Rect = getEmptyRect();
constructor(state: CanvasLayerState, manager: CanvasManager) {
this.id = state.id;
@ -69,11 +69,6 @@ export class CanvasLayer {
this.konva.layer.add(...this.transformer.getNodes());
this.state = state;
this.rect = this.getDefaultRect();
this.bbox = this.getDefaultRect();
this.bboxNeedsUpdate = true;
this.isTransforming = false;
this.isPendingBboxCalculation = false;
}
destroy = (): void => {
@ -86,8 +81,6 @@ export class CanvasLayer {
update = async (arg?: { state: CanvasLayerState; toolState: CanvasV2State['tool']; isSelected: boolean }) => {
const state = get(arg, 'state', this.state);
const toolState = get(arg, 'toolState', this.manager.stateApi.getToolState());
const isSelected = get(arg, 'isSelected', this.manager.stateApi.getIsSelected(this.id));
if (!this.isFirstRender && state === this.state) {
this.log.trace('State unchanged, skipping update');
@ -109,7 +102,7 @@ export class CanvasLayer {
if (this.isFirstRender || isEnabled !== this.state.isEnabled) {
await this.updateVisibility({ isEnabled });
}
await this.updateInteraction({ toolState, isSelected });
// this.transformer.syncInteractionState();
if (this.isFirstRender) {
await this.updateBbox();
@ -159,40 +152,6 @@ export class CanvasLayer {
this.konva.objectGroup.opacity(opacity);
};
updateInteraction = (arg?: { toolState: CanvasV2State['tool']; isSelected: boolean }) => {
this.log.trace('Updating interaction');
const toolState = get(arg, 'toolState', this.manager.stateApi.getToolState());
const isSelected = get(arg, 'isSelected', this.manager.stateApi.getIsSelected(this.id));
if (!this.renderer.hasObjects()) {
// The layer is totally empty, we can just disable the layer
this.konva.layer.listening(false);
this.transformer.setMode('off');
return;
}
if (isSelected && !this.isTransforming && toolState.selected === 'move') {
// We are moving this layer, it must be listening
this.konva.layer.listening(true);
this.transformer.setMode('drag');
} else if (isSelected && this.isTransforming) {
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer is
// active, it will interrupt the stage drag events. So we should disable listening when the view tool is selected.
if (toolState.selected !== 'view') {
this.konva.layer.listening(true);
this.transformer.setMode('transform');
} else {
this.konva.layer.listening(false);
this.transformer.setMode('off');
}
} else {
// The layer is not selected, or we are using a tool that doesn't need the layer to be listening - disable interaction stuff
this.konva.layer.listening(false);
this.transformer.setMode('off');
}
};
updateBbox = () => {
this.log.trace('Updating bbox');
@ -208,11 +167,11 @@ export class CanvasLayer {
// The layer is fully transparent but has objects - reset it
this.manager.stateApi.onEntityReset({ id: this.id }, 'layer');
}
this.transformer.setMode('off');
this.transformer.syncInteractionState();
return;
}
this.transformer.setMode('drag');
this.transformer.syncInteractionState();
this.transformer.update(this.state.position, this.bbox);
this.konva.objectGroup.setAttrs({
x: this.state.position.x + this.bbox.x,
@ -222,18 +181,6 @@ export class CanvasLayer {
});
};
startTransform = () => {
this.log.debug('Starting transform');
this.isTransforming = true;
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or
// interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening
// when the view tool is selected
const shouldListen = this.manager.stateApi.getToolState().selected !== 'view';
this.konva.layer.listening(shouldListen);
this.transformer.setMode('transform');
};
resetScale = () => {
const attrs = {
scaleX: 1,
@ -245,7 +192,7 @@ export class CanvasLayer {
this.transformer.konva.proxyRect.setAttrs(attrs);
};
rasterizeLayer = async () => {
rasterize = async () => {
this.log.debug('Rasterizing layer');
const objectGroupClone = this.konva.objectGroup.clone();
@ -263,20 +210,6 @@ export class CanvasLayer {
dispatch(layerRasterized({ id: this.id, imageObject, position: { x: Math.round(rect.x), y: Math.round(rect.y) } }));
};
stopTransform = () => {
this.log.debug('Stopping transform');
this.isTransforming = false;
this.resetScale();
this.updatePosition();
this.updateBbox();
this.updateInteraction();
};
getDefaultRect = (): Rect => {
return { x: 0, y: 0, width: 0, height: 0 };
};
calculateBbox = debounce(() => {
this.log.debug('Calculating bbox');
@ -284,8 +217,8 @@ export class CanvasLayer {
if (!this.renderer.hasObjects()) {
this.log.trace('No objects, resetting bbox');
this.rect = this.getDefaultRect();
this.bbox = this.getDefaultRect();
this.rect = getEmptyRect();
this.bbox = getEmptyRect();
this.isPendingBboxCalculation = false;
this.updateBbox();
return;
@ -324,8 +257,8 @@ export class CanvasLayer {
height: maxY - minY,
};
} else {
this.bbox = this.getDefaultRect();
this.rect = this.getDefaultRect();
this.bbox = getEmptyRect();
this.rect = getEmptyRect();
}
this.isPendingBboxCalculation = false;
this.log.trace({ bbox: this.bbox, rect: this.rect, extents }, `Got bbox from worker`);
@ -343,7 +276,6 @@ export class CanvasLayer {
rect: deepClone(this.rect),
bbox: deepClone(this.bbox),
bboxNeedsUpdate: this.bboxNeedsUpdate,
isTransforming: this.isTransforming,
isPendingBboxCalculation: this.isPendingBboxCalculation,
transformer: this.transformer.repr(),
renderer: this.renderer.repr(),

View File

@ -2,6 +2,7 @@ import type { Store } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import type { RootState } from 'app/store/store';
import type { JSONObject } from 'common/types';
import { PubSub } from 'common/util/PubSub/PubSub';
import type { CanvasBrushLineRenderer } from 'features/controlLayers/konva/CanvasBrushLine';
import type { CanvasEraserLineRenderer } from 'features/controlLayers/konva/CanvasEraserLine';
import type { CanvasImageRenderer } from 'features/controlLayers/konva/CanvasImage';
@ -22,7 +23,19 @@ import {
} from 'features/controlLayers/konva/util';
import type { Extents, ExtentsResult, GetBboxTask, WorkerLogMessage } from 'features/controlLayers/konva/worker';
import { $lastProgressEvent, $shouldShowStagedImage } from 'features/controlLayers/store/canvasV2Slice';
import type { CanvasV2State, Coordinate, GenerationMode, GetLoggingContext } from 'features/controlLayers/store/types';
import type {
CanvasControlAdapterState,
CanvasEntity,
CanvasEntityIdentifier,
CanvasInpaintMaskState,
CanvasLayerState,
CanvasRegionalGuidanceState,
CanvasV2State,
Coordinate,
GenerationMode,
GetLoggingContext,
RgbaColor,
} from 'features/controlLayers/store/types';
import type Konva from 'konva';
import { atom } from 'nanostores';
import type { Logger } from 'roarr';
@ -70,6 +83,24 @@ type Util = {
) => Promise<ImageDTO>;
};
type EntityStateAndAdapter =
| {
state: CanvasLayerState;
adapter: CanvasLayer;
}
| {
state: CanvasInpaintMaskState;
adapter: CanvasInpaintMask;
}
| {
state: CanvasControlAdapterState;
adapter: CanvasControlAdapter;
}
| {
state: CanvasRegionalGuidanceState;
adapter: CanvasRegion;
};
export const $canvasManager = atom<CanvasManager | null>(null);
export class CanvasManager {
@ -101,6 +132,11 @@ export class CanvasManager {
_worker: Worker;
_tasks: Map<string, { task: GetBboxTask; onComplete: (extents: Extents | null) => void }>;
toolState: PubSub<CanvasV2State['tool']>;
currentFill: PubSub<RgbaColor>;
selectedEntity: PubSub<EntityStateAndAdapter | null>;
selectedEntityIdentifier: PubSub<CanvasEntityIdentifier | null>;
constructor(
stage: Konva.Stage,
container: HTMLDivElement,
@ -111,7 +147,7 @@ export class CanvasManager {
this.stage = stage;
this.container = container;
this._store = store;
this.stateApi = new CanvasStateApi(this._store);
this.stateApi = new CanvasStateApi(this._store, this);
this._prevState = this.stateApi.getState();
this._isFirstRender = true;
@ -178,6 +214,17 @@ export class CanvasManager {
};
this.onTransform = null;
this._isDebugging = false;
this.toolState = new PubSub<CanvasV2State['tool']>(this.stateApi.getToolState());
this.currentFill = new PubSub<RgbaColor>(this.getCurrentFill());
this.selectedEntityIdentifier = new PubSub<CanvasEntityIdentifier | null>(
this.stateApi.getState().selectedEntityIdentifier,
(a, b) => a?.id === b?.id
);
this.selectedEntity = new PubSub<EntityStateAndAdapter | null>(
this.getSelectedEntity(),
(a, b) => a?.state === b?.state && a?.adapter === b?.adapter
);
}
enableDebugging() {
@ -226,7 +273,7 @@ export class CanvasManager {
}
async renderProgressPreview() {
await this.preview.progressPreview.render(this.stateApi.getLastProgressEvent());
await this.preview.progressPreview.render(this.stateApi.$lastProgressEvent.get());
}
async renderInpaintMask() {
@ -279,7 +326,7 @@ export class CanvasManager {
fitStageToContainer() {
this.stage.width(this.container.offsetWidth);
this.stage.height(this.container.offsetHeight);
this.stateApi.setStageAttrs({
this.stateApi.$stageAttrs.set({
position: { x: this.stage.x(), y: this.stage.y() },
dimensions: { width: this.stage.width(), height: this.stage.height() },
scale: this.stage.scaleX(),
@ -287,8 +334,57 @@ export class CanvasManager {
this.background.render();
}
getEntity(identifier: CanvasEntityIdentifier): EntityStateAndAdapter | null {
const state = this.stateApi.getState();
let entityState: CanvasEntity | null = null;
let entityAdapter: CanvasLayer | CanvasRegion | CanvasControlAdapter | CanvasInpaintMask | null = null;
if (identifier.type === 'layer') {
entityState = state.layers.entities.find((i) => i.id === identifier.id) ?? null;
entityAdapter = this.layers.get(identifier.id) ?? null;
} else if (identifier.type === 'control_adapter') {
entityState = state.controlAdapters.entities.find((i) => i.id === identifier.id) ?? null;
entityAdapter = this.controlAdapters.get(identifier.id) ?? null;
} else if (identifier.type === 'regional_guidance') {
entityState = state.regions.entities.find((i) => i.id === identifier.id) ?? null;
entityAdapter = this.regions.get(identifier.id) ?? null;
} else if (identifier.type === 'inpaint_mask') {
entityState = state.inpaintMask;
entityAdapter = this.inpaintMask;
}
if (entityState && entityAdapter && entityState.type === entityAdapter.type) {
return { state: entityState, adapter: entityAdapter } as EntityStateAndAdapter;
}
return null;
}
getSelectedEntity = () => {
const state = this.stateApi.getState();
if (state.selectedEntityIdentifier) {
return this.getEntity(state.selectedEntityIdentifier);
}
return null;
};
getCurrentFill = () => {
const state = this.stateApi.getState();
let currentFill: RgbaColor = state.tool.fill;
const selectedEntity = this.getSelectedEntity();
if (selectedEntity) {
if (selectedEntity.state.type === 'regional_guidance') {
currentFill = { ...selectedEntity.state.fill, a: state.settings.maskOpacity };
} else if (selectedEntity.state.type === 'inpaint_mask') {
currentFill = { ...state.inpaintMask.fill, a: state.settings.maskOpacity };
}
}
return currentFill;
};
getTransformingLayer() {
return Array.from(this.layers.values()).find((layer) => layer.isTransforming);
return Array.from(this.layers.values()).find((layer) => layer.transformer.isTransforming);
}
getIsTransforming() {
@ -299,17 +395,17 @@ export class CanvasManager {
if (this.getIsTransforming()) {
return;
}
const layer = this.getSelectedEntityAdapter();
assert(layer instanceof CanvasLayer, 'No selected layer');
layer.startTransform();
const layer = this.getSelectedEntity();
// TODO(psyche): Support other entity types
assert(layer?.adapter instanceof CanvasLayer, 'No selected layer');
layer.adapter.transformer.startTransform();
this.onTransform?.(true);
}
async applyTransform() {
const layer = this.getTransformingLayer();
if (layer) {
await layer.rasterizeLayer();
layer.stopTransform();
await layer.transformer.applyTransform();
}
this.onTransform?.(false);
}
@ -317,7 +413,7 @@ export class CanvasManager {
cancelTransform() {
const layer = this.getTransformingLayer();
if (layer) {
layer.stopTransform();
layer.transformer.stopTransform();
}
this.onTransform?.(false);
}
@ -355,16 +451,10 @@ export class CanvasManager {
}
}
if (
this._isFirstRender ||
state.tool.selected !== this._prevState.tool.selected ||
state.selectedEntityIdentifier?.id !== this._prevState.selectedEntityIdentifier?.id
) {
this.log.debug('Updating interaction');
for (const layer of this.layers.values()) {
layer.updateInteraction({ toolState: state.tool, isSelected: state.selectedEntityIdentifier?.id === layer.id });
}
}
this.toolState.publish(state.tool);
this.selectedEntityIdentifier.publish(state.selectedEntityIdentifier);
this.selectedEntity.publish(this.getSelectedEntity());
this.currentFill.publish(this.getCurrentFill());
if (
this._isFirstRender ||
@ -521,24 +611,6 @@ export class CanvasManager {
return CanvasManager.BBOX_PADDING_PX;
}
getSelectedEntityAdapter = (): CanvasLayer | CanvasRegion | CanvasControlAdapter | CanvasInpaintMask | null => {
const state = this.stateApi.getState();
const identifier = state.selectedEntityIdentifier;
if (!identifier) {
return null;
} else if (identifier.type === 'layer') {
return this.layers.get(identifier.id) ?? null;
} else if (identifier.type === 'control_adapter') {
return this.controlAdapters.get(identifier.id) ?? null;
} else if (identifier.type === 'regional_guidance') {
return this.regions.get(identifier.id) ?? null;
} else if (identifier.type === 'inpaint_mask') {
return this.inpaintMask;
} else {
return null;
}
};
getGenerationMode(): GenerationMode {
const session = this.stateApi.getSession();
if (session.isActive) {

View File

@ -25,6 +25,9 @@ type AnyObjectRenderer = CanvasBrushLineRenderer | CanvasEraserLineRenderer | Ca
*/
type AnyObjectState = CanvasBrushLineState | CanvasEraserLineState | CanvasImageState | CanvasRectState;
/**
* Handles rendering of objects for a canvas entity.
*/
export class CanvasObjectRenderer {
static TYPE = 'object_renderer';

View File

@ -5,7 +5,12 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasRectRenderer } from 'features/controlLayers/konva/CanvasRect';
import { getNodeBboxFast } from 'features/controlLayers/konva/entityBbox';
import { mapId } from 'features/controlLayers/konva/util';
import type { CanvasBrushLineState, CanvasEraserLineState, CanvasRectState, CanvasRegionalGuidanceState } from 'features/controlLayers/store/types';
import type {
CanvasBrushLineState,
CanvasEraserLineState,
CanvasRectState,
CanvasRegionalGuidanceState,
} from 'features/controlLayers/store/types';
import { isDrawingTool, RGBA_RED } from 'features/controlLayers/store/types';
import Konva from 'konva';
import { assert } from 'tsafe';
@ -17,11 +22,13 @@ export class CanvasRegion {
static GROUP_NAME = `${CanvasRegion.NAME_PREFIX}_group`;
static OBJECT_GROUP_NAME = `${CanvasRegion.NAME_PREFIX}_object-group`;
static COMPOSITING_RECT_NAME = `${CanvasRegion.NAME_PREFIX}_compositing-rect`;
static TYPE = 'regional_guidance' as const;
private drawingBuffer: CanvasBrushLineState | CanvasEraserLineState | CanvasRectState | null;
private state: CanvasRegionalGuidanceState;
id: string;
type = CanvasRegion.TYPE;
manager: CanvasManager;
konva: {

View File

@ -34,7 +34,7 @@ export class CanvasStagingArea {
render = async () => {
const session = this.manager.stateApi.getSession();
const bboxRect = this.manager.stateApi.getBbox().rect;
const shouldShowStagedImage = this.manager.stateApi.getShouldShowStagedImage();
const shouldShowStagedImage = this.manager.stateApi.$shouldShowStagedImage.get();
this.selectedImage = session.stagedImages[session.selectedStagedImageIndex] ?? null;
@ -69,7 +69,7 @@ export class CanvasStagingArea {
this.image.konva.group.x(bboxRect.x + offsetX);
this.image.konva.group.y(bboxRect.y + offsetY);
await this.image.updateImageSource(imageDTO.image_name);
this.manager.stateApi.resetLastProgressEvent();
this.manager.stateApi.$lastProgressEvent.set(null);
}
this.image.konva.group.visible(shouldShowStagedImage);
} else {

View File

@ -2,7 +2,7 @@ import { $alt, $ctrl, $meta, $shift } from '@invoke-ai/ui-library';
import type { Store } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import type { RootState } from 'app/store/store';
import { buildSubscribe } from 'features/controlLayers/konva/util';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import {
$isDrawing,
$isMouseDown,
@ -49,173 +49,143 @@ import type {
CanvasBrushLineState,
CanvasEntity,
CanvasEraserLineState,
PositionChangedArg,
CanvasRectState,
PositionChangedArg,
ScaleChangedArg,
Tool,
} from 'features/controlLayers/store/types';
import type { IRect } from 'konva/lib/types';
import type { RgbaColor } from 'react-colorful';
import type { ImageDTO } from 'services/api/types';
const log = logger('canvas');
export class CanvasStateApi {
private store: Store<RootState>;
constructor(store: Store<RootState>) {
this.store = store;
export class CanvasStateApi {
_store: Store<RootState>;
manager: CanvasManager;
constructor(store: Store<RootState>, manager: CanvasManager) {
this._store = store;
this.manager = manager;
}
// Reminder - use arrow functions to avoid binding issues
getState = () => {
return this.store.getState().canvasV2;
return this._store.getState().canvasV2;
};
onEntityReset = (arg: { id: string }, entityType: CanvasEntity['type']) => {
log.debug('onEntityReset');
if (entityType === 'layer') {
this.store.dispatch(layerReset(arg));
this._store.dispatch(layerReset(arg));
}
};
onPosChanged = (arg: PositionChangedArg, entityType: CanvasEntity['type']) => {
log.debug('onPosChanged');
if (entityType === 'layer') {
this.store.dispatch(layerTranslated(arg));
this._store.dispatch(layerTranslated(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgTranslated(arg));
this._store.dispatch(rgTranslated(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imTranslated(arg));
this._store.dispatch(imTranslated(arg));
} else if (entityType === 'control_adapter') {
this.store.dispatch(caTranslated(arg));
this._store.dispatch(caTranslated(arg));
}
};
onScaleChanged = (arg: ScaleChangedArg, entityType: CanvasEntity['type']) => {
log.debug('onScaleChanged');
if (entityType === 'inpaint_mask') {
this.store.dispatch(imScaled(arg));
this._store.dispatch(imScaled(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgScaled(arg));
this._store.dispatch(rgScaled(arg));
} else if (entityType === 'control_adapter') {
this.store.dispatch(caScaled(arg));
this._store.dispatch(caScaled(arg));
}
};
onBboxChanged = (arg: BboxChangedArg, entityType: CanvasEntity['type']) => {
log.debug('Entity bbox changed');
if (entityType === 'layer') {
this.store.dispatch(layerBboxChanged(arg));
this._store.dispatch(layerBboxChanged(arg));
} else if (entityType === 'control_adapter') {
this.store.dispatch(caBboxChanged(arg));
this._store.dispatch(caBboxChanged(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgBboxChanged(arg));
this._store.dispatch(rgBboxChanged(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imBboxChanged(arg));
this._store.dispatch(imBboxChanged(arg));
}
};
onBrushLineAdded = (arg: { id: string; brushLine: CanvasBrushLineState }, entityType: CanvasEntity['type']) => {
log.debug('Brush line added');
if (entityType === 'layer') {
this.store.dispatch(layerBrushLineAdded(arg));
this._store.dispatch(layerBrushLineAdded(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgBrushLineAdded(arg));
this._store.dispatch(rgBrushLineAdded(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imBrushLineAdded(arg));
this._store.dispatch(imBrushLineAdded(arg));
}
};
onEraserLineAdded = (arg: { id: string; eraserLine: CanvasEraserLineState }, entityType: CanvasEntity['type']) => {
log.debug('Eraser line added');
if (entityType === 'layer') {
this.store.dispatch(layerEraserLineAdded(arg));
this._store.dispatch(layerEraserLineAdded(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgEraserLineAdded(arg));
this._store.dispatch(rgEraserLineAdded(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imEraserLineAdded(arg));
this._store.dispatch(imEraserLineAdded(arg));
}
};
onRectShapeAdded = (arg: { id: string; rectShape: CanvasRectState }, entityType: CanvasEntity['type']) => {
log.debug('Rect shape added');
if (entityType === 'layer') {
this.store.dispatch(layerRectShapeAdded(arg));
this._store.dispatch(layerRectShapeAdded(arg));
} else if (entityType === 'regional_guidance') {
this.store.dispatch(rgRectShapeAdded(arg));
this._store.dispatch(rgRectShapeAdded(arg));
} else if (entityType === 'inpaint_mask') {
this.store.dispatch(imRectShapeAdded(arg));
this._store.dispatch(imRectShapeAdded(arg));
}
};
onEntitySelected = (arg: { id: string; type: CanvasEntity['type'] }) => {
log.debug('Entity selected');
this.store.dispatch(entitySelected(arg));
this._store.dispatch(entitySelected(arg));
};
onBboxTransformed = (bbox: IRect) => {
log.debug('Generation bbox transformed');
this.store.dispatch(bboxChanged(bbox));
this._store.dispatch(bboxChanged(bbox));
};
onBrushWidthChanged = (width: number) => {
log.debug('Brush width changed');
this.store.dispatch(brushWidthChanged(width));
this._store.dispatch(brushWidthChanged(width));
};
onEraserWidthChanged = (width: number) => {
log.debug('Eraser width changed');
this.store.dispatch(eraserWidthChanged(width));
this._store.dispatch(eraserWidthChanged(width));
};
onRegionMaskImageCached = (id: string, imageDTO: ImageDTO) => {
log.debug('Region mask image cached');
this.store.dispatch(rgImageCacheChanged({ id, imageDTO }));
this._store.dispatch(rgImageCacheChanged({ id, imageDTO }));
};
onInpaintMaskImageCached = (imageDTO: ImageDTO) => {
log.debug('Inpaint mask image cached');
this.store.dispatch(imImageCacheChanged({ imageDTO }));
this._store.dispatch(imImageCacheChanged({ imageDTO }));
};
onLayerImageCached = (imageDTO: ImageDTO) => {
log.debug('Layer image cached');
this.store.dispatch(layerImageCacheChanged({ imageDTO }));
this._store.dispatch(layerImageCacheChanged({ imageDTO }));
};
setTool = (tool: Tool) => {
log.debug('Tool selection changed');
this.store.dispatch(toolChanged(tool));
this._store.dispatch(toolChanged(tool));
};
setToolBuffer = (toolBuffer: Tool | null) => {
log.debug('Tool buffer changed');
this.store.dispatch(toolBufferChanged(toolBuffer));
this._store.dispatch(toolBufferChanged(toolBuffer));
};
getSelectedEntity = (): CanvasEntity | null => {
const state = this.getState();
const identifier = state.selectedEntityIdentifier;
if (!identifier) {
return null;
} else if (identifier.type === 'layer') {
return state.layers.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'control_adapter') {
return state.controlAdapters.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'ip_adapter') {
return state.ipAdapters.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'regional_guidance') {
return state.regions.entities.find((i) => i.id === identifier.id) ?? null;
} else if (identifier.type === 'inpaint_mask') {
return state.inpaintMask;
} else {
return null;
}
};
getCurrentFill = () => {
const state = this.getState();
const selectedEntity = this.getSelectedEntity();
let currentFill: RgbaColor = state.tool.fill;
if (selectedEntity) {
if (selectedEntity.type === 'regional_guidance') {
currentFill = { ...selectedEntity.fill, a: state.settings.maskOpacity };
} else if (selectedEntity.type === 'inpaint_mask') {
currentFill = { ...state.inpaintMask.fill, a: state.settings.maskOpacity };
}
} else {
currentFill = state.tool.fill;
}
return currentFill;
};
getBbox = () => {
return this.getState().bbox;
};
getToolState = () => {
return this.getState().tool;
};
@ -244,61 +214,24 @@ export class CanvasStateApi {
return this.getState().session;
};
getIsSelected = (id: string) => {
return this.getSelectedEntity()?.id === id;
return this.getState().selectedEntityIdentifier?.id === id;
};
getLogLevel = () => {
return this.store.getState().system.consoleLogLevel;
};
// Read-only state, derived from nanostores
resetLastProgressEvent = () => {
$lastProgressEvent.set(null);
return this._store.getState().system.consoleLogLevel;
};
// Read-write state, ephemeral interaction state
getIsDrawing = $isDrawing.get;
setIsDrawing = $isDrawing.set;
onIsDrawingChanged = $isDrawing.subscribe;
getIsMouseDown = $isMouseDown.get;
setIsMouseDown = $isMouseDown.set;
onIsMouseDownChanged = $isMouseDown.subscribe;
getLastAddedPoint = $lastAddedPoint.get;
setLastAddedPoint = $lastAddedPoint.set;
onLastAddedPointChanged = $lastAddedPoint.subscribe;
getLastMouseDownPos = $lastMouseDownPos.get;
setLastMouseDownPos = $lastMouseDownPos.set;
onLastMouseDownPosChanged = $lastMouseDownPos.subscribe;
getLastCursorPos = $lastCursorPos.get;
setLastCursorPos = $lastCursorPos.set;
onLastCursorPosChanged = $lastCursorPos.subscribe;
getSpaceKey = $spaceKey.get;
setSpaceKey = $spaceKey.set;
onSpaceKeyChanged = $spaceKey.subscribe;
getLastProgressEvent = $lastProgressEvent.get;
setLastProgressEvent = $lastProgressEvent.set;
onLastProgressEventChanged = $lastProgressEvent.subscribe;
getAltKey = $alt.get;
onAltChanged = $alt.subscribe;
getCtrlKey = $ctrl.get;
onCtrlChanged = $ctrl.subscribe;
getMetaKey = $meta.get;
onMetaChanged = $meta.subscribe;
getShiftKey = $shift.get;
onShiftChanged = buildSubscribe($shift.subscribe, 'onShiftChanged');
getShouldShowStagedImage = $shouldShowStagedImage.get;
onGetShouldShowStagedImageChanged = $shouldShowStagedImage.subscribe;
setStageAttrs = $stageAttrs.set;
onStageAttrsChanged = buildSubscribe($stageAttrs.subscribe, 'onStageAttrsChanged');
$isDrawing = $isDrawing;
$isMouseDown = $isMouseDown;
$lastAddedPoint = $lastAddedPoint;
$lastMouseDownPos = $lastMouseDownPos;
$lastCursorPos = $lastCursorPos;
$lastProgressEvent = $lastProgressEvent;
$spaceKey = $spaceKey;
$altKey = $alt;
$ctrlKey = $ctrl;
$metaKey = $meta;
$shiftKey = $shift;
$shouldShowStagedImage = $shouldShowStagedImage;
$stageAttrs = $stageAttrs;
}

View File

@ -139,17 +139,17 @@ export class CanvasTool {
const stage = this.manager.stage;
const renderedEntityCount: number = 1; // TODO(psyche): this.manager should be renderable entity count
const toolState = this.manager.stateApi.getToolState();
const currentFill = this.manager.stateApi.getCurrentFill();
const selectedEntity = this.manager.stateApi.getSelectedEntity();
const cursorPos = this.manager.stateApi.getLastCursorPos();
const isDrawing = this.manager.stateApi.getIsDrawing();
const isMouseDown = this.manager.stateApi.getIsMouseDown();
const currentFill = this.manager.getCurrentFill();
const selectedEntity = this.manager.getSelectedEntity();
const cursorPos = this.manager.stateApi.$lastCursorPos.get();
const isDrawing = this.manager.stateApi.$isDrawing.get();
const isMouseDown = this.manager.stateApi.$isMouseDown.get();
const tool = toolState.selected;
const isDrawableEntity =
selectedEntity?.type === 'regional_guidance' ||
selectedEntity?.type === 'layer' ||
selectedEntity?.type === 'inpaint_mask';
selectedEntity?.state.type === 'regional_guidance' ||
selectedEntity?.state.type === 'layer' ||
selectedEntity?.state.type === 'inpaint_mask';
// Update the stage's pointer style
if (tool === 'view') {

View File

@ -1,6 +1,5 @@
import type { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { Subscription } from 'features/controlLayers/konva/util';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { Coordinate, GetLoggingContext, Rect } from 'features/controlLayers/store/types';
import Konva from 'konva';
@ -19,32 +18,50 @@ export class CanvasTransformer {
static TRANSFORMER_NAME = `${CanvasTransformer.TYPE}:transformer`;
static PROXY_RECT_NAME = `${CanvasTransformer.TYPE}:proxy_rect`;
static BBOX_OUTLINE_NAME = `${CanvasTransformer.TYPE}:bbox_outline`;
static STROKE_COLOR = 'hsl(200deg 76% 59%)'; // `invokeBlue.400
static STROKE_COLOR = 'hsl(200 76% 50% / 1)'; // invokeBlue.500
static ANCHOR_FILL_COLOR = CanvasTransformer.STROKE_COLOR;
static ANCHOR_STROKE_COLOR = 'hsl(200 76% 77% / 1)'; // invokeBlue.200
static RESIZE_ANCHOR_SIZE = 8;
static ROTATE_ANCHOR_FILL_COLOR = 'hsl(200 76% 95% / 1)'; // invokeBlue.50
static ROTATE_ANCHOR_STROKE_COLOR = 'hsl(200 76% 40% / 1)'; // invokeBlue.700
static ROTATE_ANCHOR_SIZE = 12;
static ANCHOR_CORNER_RADIUS_RATIO = 0.5;
static ANCHOR_STROKE_WIDTH = 2;
static ANCHOR_HIT_PADDING = 10;
id: string;
parent: CanvasLayer;
manager: CanvasManager;
log: Logger;
getLoggingContext: GetLoggingContext;
subscriptions: Subscription[];
/**
* The current mode of the transformer:
* - 'transform': The entity can be moved, resized, and rotated
* - 'drag': The entity can only be moved
* - 'off': The transformer is disabled
* A list of subscriptions that should be cleaned up when the transformer is destroyed.
*/
mode: 'transform' | 'drag' | 'off';
subscriptions: (() => void)[] = [];
/**
* Whether dragging is enabled. Dragging is enabled in both 'transform' and 'drag' modes.
* Whether the transformer is currently transforming the entity.
*/
isDragEnabled: boolean;
isTransforming: boolean = false;
/**
* Whether transforming is enabled. Transforming is enabled only in 'transform' mode.
* The current interaction mode of the transformer:
* - 'all': The entity can be moved, resized, and rotated.
* - 'drag': The entity can be moved.
* - 'off': The transformer is not interactable.
*/
isTransformEnabled: boolean;
interactionMode: 'all' | 'drag' | 'off' = 'off';
/**
* Whether dragging is enabled. Dragging is enabled in both 'all' and 'drag' interaction modes.
*/
isDragEnabled: boolean = false;
/**
* Whether transforming is enabled. Transforming is enabled only in 'all' interaction mode.
*/
isTransformEnabled: boolean = false;
konva: {
transformer: Konva.Transformer;
@ -59,11 +76,6 @@ export class CanvasTransformer {
this.getLoggingContext = this.manager.buildGetLoggingContext(this);
this.log = this.manager.buildLogger(this.getLoggingContext);
this.subscriptions = [];
this.mode = 'off';
this.isDragEnabled = false;
this.isTransformEnabled = false;
this.konva = {
bboxOutline: new Konva.Rect({
@ -89,6 +101,35 @@ export class CanvasTransformer {
padding: this.manager.getTransformerPadding(),
// This is `invokeBlue.400`
stroke: CanvasTransformer.STROKE_COLOR,
anchorFill: CanvasTransformer.ANCHOR_FILL_COLOR,
anchorStroke: CanvasTransformer.ANCHOR_STROKE_COLOR,
anchorStrokeWidth: CanvasTransformer.ANCHOR_STROKE_WIDTH,
anchorSize: CanvasTransformer.RESIZE_ANCHOR_SIZE,
anchorCornerRadius: CanvasTransformer.RESIZE_ANCHOR_SIZE * CanvasTransformer.ANCHOR_CORNER_RADIUS_RATIO,
anchorStyleFunc: (anchor) => {
if (anchor.hasName('rotater')) {
anchor.setAttrs({
height: CanvasTransformer.ROTATE_ANCHOR_SIZE,
width: CanvasTransformer.ROTATE_ANCHOR_SIZE,
cornerRadius: CanvasTransformer.ROTATE_ANCHOR_SIZE * CanvasTransformer.ANCHOR_CORNER_RADIUS_RATIO,
fill: CanvasTransformer.ROTATE_ANCHOR_FILL_COLOR,
stroke: CanvasTransformer.ANCHOR_FILL_COLOR,
offsetX: CanvasTransformer.ROTATE_ANCHOR_SIZE / 2,
offsetY: CanvasTransformer.ROTATE_ANCHOR_SIZE / 2,
});
}
anchor.hitFunc((context) => {
context.beginPath();
context.rect(
-CanvasTransformer.ANCHOR_HIT_PADDING,
-CanvasTransformer.ANCHOR_HIT_PADDING,
anchor.width() + CanvasTransformer.ANCHOR_HIT_PADDING * 2,
anchor.height() + CanvasTransformer.ANCHOR_HIT_PADDING * 2
);
context.closePath();
context.fillStrokeShape(anchor);
});
},
// TODO(psyche): The konva Vector2D type is is apparently not compatible with the JSONObject type that the log
// function expects. The in-house Coordinate type is functionally the same - `{x: number; y: number}` - and
// TypeScript is happy with it.
@ -152,7 +193,7 @@ export class CanvasTransformer {
// This transform constraint operates on the bounding box of the transformer. This box has x, y, width, and
// height in stage coordinates, and rotation in radians. This can be used to snap the transformer rotation to
// the nearest 45 degrees when shift is held.
if (this.manager.stateApi.getShiftKey()) {
if (this.manager.stateApi.$shiftKey.get()) {
if (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) {
return oldBoundBox;
}
@ -278,9 +319,9 @@ export class CanvasTransformer {
});
});
this.konva.proxyRect.on('dragend', () => {
if (this.parent.isTransforming) {
// When the user cancels the transformation, we need to reset the layer, so we should not update the layer's
// positition while we are transforming - bail out early.
if (this.isTransforming) {
// If we are transforming the entity, we should not push the new position to the state. This will trigger a
// re-render of the entity and bork the transformation.
return;
}
@ -296,9 +337,9 @@ export class CanvasTransformer {
this.subscriptions.push(
// When the stage scale changes, we may need to re-scale some of the transformer's components. For example,
// the bbox outline should always be 1 screen pixel wide, so we need to update its stroke width.
this.manager.stateApi.onStageAttrsChanged((newAttrs, oldAttrs) => {
if (newAttrs.scale !== oldAttrs?.scale) {
this.scale();
this.manager.stateApi.$stageAttrs.listen((newVal, oldVal) => {
if (newVal.scale !== oldVal.scale) {
this.syncScale();
}
})
);
@ -306,8 +347,24 @@ export class CanvasTransformer {
this.subscriptions.push(
// While the user holds shift, we want to snap rotation to 45 degree increments. Listen for the shift key state
// and update the snap angles accordingly.
this.manager.stateApi.onShiftChanged((isPressed) => {
this.konva.transformer.rotationSnaps(isPressed ? [0, 45, 90, 135, 180, 225, 270, 315] : []);
this.manager.stateApi.$shiftKey.listen((newVal) => {
this.konva.transformer.rotationSnaps(newVal ? [0, 45, 90, 135, 180, 225, 270, 315] : []);
})
);
this.subscriptions.push(
// When the selected tool changes, we need to update the transformer's interaction state.
this.manager.toolState.subscribe((newVal, oldVal) => {
if (newVal.selected !== oldVal.selected) {
this.syncInteractionState();
}
})
);
this.subscriptions.push(
// When the selected entity changes, we need to update the transformer's interaction state.
this.manager.selectedEntityIdentifier.subscribe(() => {
this.syncInteractionState();
})
);
}
@ -336,10 +393,48 @@ export class CanvasTransformer {
});
};
/**
* Syncs the transformer's interaction state with the application and entity's states. This is called when the entity
* is selected or deselected, or when the user changes the selected tool.
*/
syncInteractionState = () => {
this.log.trace('Syncing interaction state');
const toolState = this.manager.stateApi.getToolState();
const isSelected = this.manager.stateApi.getIsSelected(this.parent.id);
if (!this.parent.renderer.hasObjects()) {
// The layer is totally empty, we can just disable the layer
this.parent.konva.layer.listening(false);
this.setInteractionMode('off');
return;
}
if (isSelected && !this.isTransforming && toolState.selected === 'move') {
// We are moving this layer, it must be listening
this.parent.konva.layer.listening(true);
this.setInteractionMode('drag');
} else if (isSelected && this.isTransforming) {
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer is
// active, it will interrupt the stage drag events. So we should disable listening when the view tool is selected.
if (toolState.selected !== 'view') {
this.parent.konva.layer.listening(true);
this.setInteractionMode('all');
} else {
this.parent.konva.layer.listening(false);
this.setInteractionMode('off');
}
} else {
// The layer is not selected, or we are using a tool that doesn't need the layer to be listening - disable interaction stuff
this.parent.konva.layer.listening(false);
this.setInteractionMode('off');
}
};
/**
* Updates the transformer's scale. This is called when the stage is scaled.
*/
scale = () => {
syncScale = () => {
const onePixel = this.manager.getScaledPixel();
const bboxPadding = this.manager.getScaledBboxPadding();
@ -353,24 +448,53 @@ export class CanvasTransformer {
this.konva.transformer.forceUpdate();
};
startTransform = () => {
this.log.debug('Starting transform');
this.isTransforming = true;
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or
// interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening
// when the view tool is selected
const shouldListen = this.manager.stateApi.getToolState().selected !== 'view';
this.parent.konva.layer.listening(shouldListen);
this.setInteractionMode('all');
};
applyTransform = async () => {
this.log.debug('Applying transform');
await this.parent.rasterize();
this.stopTransform();
};
stopTransform = () => {
this.log.debug('Stopping transform');
this.isTransforming = false;
this.setInteractionMode('off');
this.parent.resetScale();
this.parent.updatePosition();
this.parent.updateBbox();
this.syncInteractionState();
};
/**
* Sets the transformer to a specific mode.
* @param mode The mode to set the transformer to. The transformer can be in one of three modes:
* - 'transform': The entity can be moved, resized, and rotated
* - 'drag': The entity can only be moved
* - 'off': The transformer is disabled
* Sets the transformer to a specific interaction mode.
* @param interactionMode The mode to set the transformer to. The transformer can be in one of three modes:
* - 'all': The entity can be moved, resized, and rotated.
* - 'drag': The entity can be moved.
* - 'off': The transformer is not interactable.
*/
setMode = (mode: 'transform' | 'drag' | 'off') => {
this.mode = mode;
if (mode === 'drag') {
setInteractionMode = (interactionMode: 'all' | 'drag' | 'off') => {
this.interactionMode = interactionMode;
if (interactionMode === 'drag') {
this._enableDrag();
this._disableTransform();
this._showBboxOutline();
} else if (mode === 'transform') {
} else if (interactionMode === 'all') {
this._enableDrag();
this._enableTransform();
this._hideBboxOutline();
} else if (mode === 'off') {
} else if (interactionMode === 'off') {
this._disableDrag();
this._disableTransform();
this._hideBboxOutline();
@ -411,13 +535,13 @@ export class CanvasTransformer {
this.konva.bboxOutline.visible(false);
};
getNodes = () => [this.konva.transformer, this.konva.proxyRect, this.konva.bboxOutline];
getNodes = () => [this.konva.bboxOutline, this.konva.proxyRect, this.konva.transformer];
repr = () => {
return {
id: this.id,
type: CanvasTransformer.TYPE,
mode: this.mode,
mode: this.interactionMode,
isTransformEnabled: this.isTransformEnabled,
isDragEnabled: this.isDragEnabled,
};
@ -425,9 +549,9 @@ export class CanvasTransformer {
destroy = () => {
this.log.trace('Destroying transformer');
for (const { name, unsubscribe } of this.subscriptions) {
this.log.trace({ name }, 'Cleaning up listener');
unsubscribe();
for (const cleanup of this.subscriptions) {
this.log.trace('Cleaning up listener');
cleanup();
}
this.konva.bboxOutline.destroy();
this.konva.transformer.destroy();

View File

@ -26,7 +26,10 @@ import { BRUSH_SPACING_TARGET_SCALE, CANVAS_SCALE_BY, MAX_CANVAS_SCALE, MIN_CANV
* @param stage The konva stage
* @param setLastCursorPos The callback to store the cursor pos
*/
const updateLastCursorPos = (stage: Konva.Stage, setLastCursorPos: CanvasManager['stateApi']['setLastCursorPos']) => {
const updateLastCursorPos = (
stage: Konva.Stage,
setLastCursorPos: CanvasManager['stateApi']['$lastCursorPos']['set']
) => {
const pos = getScaledCursorPosition(stage);
if (!pos) {
return null;
@ -112,22 +115,17 @@ const getLastPointOfLastLineOfEntity = (
};
export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
const { stage, stateApi, getSelectedEntityAdapter } = manager;
const { stage, stateApi, getCurrentFill, getSelectedEntity } = manager;
const {
getToolState,
getCurrentFill,
setTool,
setToolBuffer,
setIsMouseDown,
setLastMouseDownPos,
getLastCursorPos,
setLastCursorPos,
// getLastAddedPoint,
setLastAddedPoint,
setStageAttrs,
getSelectedEntity,
getSpaceKey,
setSpaceKey,
$isMouseDown,
$lastMouseDownPos,
$lastCursorPos,
$lastAddedPoint,
$stageAttrs,
$spaceKey,
getBbox,
getSettings,
onBrushWidthChanged,
@ -166,34 +164,31 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
//#region mousedown
stage.on('mousedown', async (e) => {
setIsMouseDown(true);
$isMouseDown.set(true);
const toolState = getToolState();
const pos = updateLastCursorPos(stage, setLastCursorPos);
const pos = updateLastCursorPos(stage, $lastCursorPos.set);
const selectedEntity = getSelectedEntity();
const selectedEntityAdapter = getSelectedEntityAdapter();
if (
pos &&
selectedEntity &&
isDrawableEntity(selectedEntity) &&
selectedEntityAdapter &&
isDrawableEntityAdapter(selectedEntityAdapter) &&
!getSpaceKey() &&
isDrawableEntity(selectedEntity.state) &&
!$spaceKey.get() &&
getIsPrimaryMouseDown(e)
) {
setLastMouseDownPos(pos);
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
$lastMouseDownPos.set(pos);
const normalizedPoint = offsetCoord(pos, selectedEntity.state.position);
if (toolState.selected === 'brush') {
const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected);
const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity.state, toolState.selected);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
if (selectedEntityAdapter.renderer.buffer) {
await selectedEntityAdapter.renderer.commitBuffer();
if (selectedEntity.adapter.renderer.buffer) {
await selectedEntity.adapter.renderer.commitBuffer();
}
await selectedEntityAdapter.renderer.setBuffer({
await selectedEntity.adapter.renderer.setBuffer({
id: getObjectId('brush_line', true),
type: 'brush_line',
points: [
@ -205,33 +200,33 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
],
strokeWidth: toolState.brush.width,
color: getCurrentFill(),
clip: getClip(selectedEntity),
clip: getClip(selectedEntity.state),
});
} else {
if (selectedEntityAdapter.renderer.buffer) {
await selectedEntityAdapter.renderer.commitBuffer();
if (selectedEntity.adapter.renderer.buffer) {
await selectedEntity.adapter.renderer.commitBuffer();
}
await selectedEntityAdapter.renderer.setBuffer({
await selectedEntity.adapter.renderer.setBuffer({
id: getObjectId('brush_line', true),
type: 'brush_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.brush.width,
color: getCurrentFill(),
clip: getClip(selectedEntity),
clip: getClip(selectedEntity.state),
});
}
setLastAddedPoint(alignedPoint);
$lastAddedPoint.set(alignedPoint);
}
if (toolState.selected === 'eraser') {
const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected);
const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity.state, toolState.selected);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
if (selectedEntityAdapter.renderer.buffer) {
await selectedEntityAdapter.renderer.commitBuffer();
if (selectedEntity.adapter.renderer.buffer) {
await selectedEntity.adapter.renderer.commitBuffer();
}
await selectedEntityAdapter.renderer.setBuffer({
await selectedEntity.adapter.renderer.setBuffer({
id: getObjectId('eraser_line', true),
type: 'eraser_line',
points: [
@ -242,28 +237,28 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
alignedPoint.y,
],
strokeWidth: toolState.eraser.width,
clip: getClip(selectedEntity),
clip: getClip(selectedEntity.state),
});
} else {
if (selectedEntityAdapter.renderer.buffer) {
await selectedEntityAdapter.renderer.commitBuffer();
if (selectedEntity.adapter.renderer.buffer) {
await selectedEntity.adapter.renderer.commitBuffer();
}
await selectedEntityAdapter.renderer.setBuffer({
await selectedEntity.adapter.renderer.setBuffer({
id: getObjectId('eraser_line', true),
type: 'eraser_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.eraser.width,
clip: getClip(selectedEntity),
clip: getClip(selectedEntity.state),
});
}
setLastAddedPoint(alignedPoint);
$lastAddedPoint.set(alignedPoint);
}
if (toolState.selected === 'rect') {
if (selectedEntityAdapter.renderer.buffer) {
await selectedEntityAdapter.renderer.commitBuffer();
if (selectedEntity.adapter.renderer.buffer) {
await selectedEntity.adapter.renderer.commitBuffer();
}
await selectedEntityAdapter.renderer.setBuffer({
await selectedEntity.adapter.renderer.setBuffer({
id: getObjectId('rect', true),
type: 'rect',
x: Math.round(normalizedPoint.x),
@ -279,49 +274,41 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
//#region mouseup
stage.on('mouseup', async () => {
setIsMouseDown(false);
const pos = getLastCursorPos();
$isMouseDown.set(false);
const pos = $lastCursorPos.get();
const selectedEntity = getSelectedEntity();
const selectedEntityAdapter = getSelectedEntityAdapter();
if (
pos &&
selectedEntity &&
isDrawableEntity(selectedEntity) &&
selectedEntityAdapter &&
isDrawableEntityAdapter(selectedEntityAdapter) &&
!getSpaceKey()
) {
if (pos && selectedEntity && isDrawableEntity(selectedEntity.state) && !$spaceKey.get()) {
const toolState = getToolState();
if (toolState.selected === 'brush') {
const drawingBuffer = selectedEntityAdapter.renderer.buffer;
const drawingBuffer = selectedEntity.adapter.renderer.buffer;
if (drawingBuffer?.type === 'brush_line') {
await selectedEntityAdapter.renderer.commitBuffer();
await selectedEntity.adapter.renderer.commitBuffer();
} else {
await selectedEntityAdapter.renderer.clearBuffer();
await selectedEntity.adapter.renderer.clearBuffer();
}
}
if (toolState.selected === 'eraser') {
const drawingBuffer = selectedEntityAdapter.renderer.buffer;
const drawingBuffer = selectedEntity.adapter.renderer.buffer;
if (drawingBuffer?.type === 'eraser_line') {
await selectedEntityAdapter.renderer.commitBuffer();
await selectedEntity.adapter.renderer.commitBuffer();
} else {
await selectedEntityAdapter.renderer.clearBuffer();
await selectedEntity.adapter.renderer.clearBuffer();
}
}
if (toolState.selected === 'rect') {
const drawingBuffer = selectedEntityAdapter.renderer.buffer;
const drawingBuffer = selectedEntity.adapter.renderer.buffer;
if (drawingBuffer?.type === 'rect') {
await selectedEntityAdapter.renderer.commitBuffer();
await selectedEntity.adapter.renderer.commitBuffer();
} else {
await selectedEntityAdapter.renderer.clearBuffer();
await selectedEntity.adapter.renderer.clearBuffer();
}
}
setLastMouseDownPos(null);
$lastMouseDownPos.set(null);
}
manager.preview.tool.render();
@ -330,94 +317,93 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
//#region mousemove
stage.on('mousemove', async (e) => {
const toolState = getToolState();
const pos = updateLastCursorPos(stage, setLastCursorPos);
const pos = updateLastCursorPos(stage, $lastCursorPos.set);
const selectedEntity = getSelectedEntity();
const selectedEntityAdapter = getSelectedEntityAdapter();
if (
pos &&
selectedEntity &&
isDrawableEntity(selectedEntity) &&
selectedEntityAdapter &&
isDrawableEntityAdapter(selectedEntityAdapter) &&
!getSpaceKey() &&
isDrawableEntity(selectedEntity.state) &&
selectedEntity.adapter &&
isDrawableEntityAdapter(selectedEntity.adapter) &&
!$spaceKey.get() &&
getIsPrimaryMouseDown(e)
) {
if (toolState.selected === 'brush') {
const drawingBuffer = selectedEntityAdapter.renderer.buffer;
const drawingBuffer = selectedEntity.adapter.renderer.buffer;
if (drawingBuffer) {
if (drawingBuffer?.type === 'brush_line') {
const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points));
if (nextPoint) {
const normalizedPoint = offsetCoord(nextPoint, selectedEntity.position);
const normalizedPoint = offsetCoord(nextPoint, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntityAdapter.renderer.setBuffer(drawingBuffer);
setLastAddedPoint(alignedPoint);
await selectedEntity.adapter.renderer.setBuffer(drawingBuffer);
$lastAddedPoint.set(alignedPoint);
}
} else {
await selectedEntityAdapter.renderer.clearBuffer();
await selectedEntity.adapter.renderer.clearBuffer();
}
} else {
if (selectedEntityAdapter.renderer.buffer) {
await selectedEntityAdapter.renderer.commitBuffer();
if (selectedEntity.adapter.renderer.buffer) {
await selectedEntity.adapter.renderer.commitBuffer();
}
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
const normalizedPoint = offsetCoord(pos, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
await selectedEntityAdapter.renderer.setBuffer({
await selectedEntity.adapter.renderer.setBuffer({
id: getObjectId('brush_line', true),
type: 'brush_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.brush.width,
color: getCurrentFill(),
clip: getClip(selectedEntity),
clip: getClip(selectedEntity.state),
});
setLastAddedPoint(alignedPoint);
$lastAddedPoint.set(alignedPoint);
}
}
if (toolState.selected === 'eraser') {
const drawingBuffer = selectedEntityAdapter.renderer.buffer;
const drawingBuffer = selectedEntity.adapter.renderer.buffer;
if (drawingBuffer) {
if (drawingBuffer.type === 'eraser_line') {
const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points));
if (nextPoint) {
const normalizedPoint = offsetCoord(nextPoint, selectedEntity.position);
const normalizedPoint = offsetCoord(nextPoint, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntityAdapter.renderer.setBuffer(drawingBuffer);
setLastAddedPoint(alignedPoint);
await selectedEntity.adapter.renderer.setBuffer(drawingBuffer);
$lastAddedPoint.set(alignedPoint);
}
} else {
await selectedEntityAdapter.renderer.clearBuffer();
await selectedEntity.adapter.renderer.clearBuffer();
}
} else {
if (selectedEntityAdapter.renderer.buffer) {
await selectedEntityAdapter.renderer.commitBuffer();
if (selectedEntity.adapter.renderer.buffer) {
await selectedEntity.adapter.renderer.commitBuffer();
}
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
const normalizedPoint = offsetCoord(pos, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
await selectedEntityAdapter.renderer.setBuffer({
await selectedEntity.adapter.renderer.setBuffer({
id: getObjectId('eraser_line', true),
type: 'eraser_line',
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.eraser.width,
clip: getClip(selectedEntity),
clip: getClip(selectedEntity.state),
});
setLastAddedPoint(alignedPoint);
$lastAddedPoint.set(alignedPoint);
}
}
if (toolState.selected === 'rect') {
const drawingBuffer = selectedEntityAdapter.renderer.buffer;
const drawingBuffer = selectedEntity.adapter.renderer.buffer;
if (drawingBuffer) {
if (drawingBuffer.type === 'rect') {
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
const normalizedPoint = offsetCoord(pos, selectedEntity.state.position);
drawingBuffer.width = Math.round(normalizedPoint.x - drawingBuffer.x);
drawingBuffer.height = Math.round(normalizedPoint.y - drawingBuffer.y);
await selectedEntityAdapter.renderer.setBuffer(drawingBuffer);
await selectedEntity.adapter.renderer.setBuffer(drawingBuffer);
} else {
await selectedEntityAdapter.renderer.clearBuffer();
await selectedEntity.adapter.renderer.clearBuffer();
}
}
}
@ -427,39 +413,36 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
//#region mouseleave
stage.on('mouseleave', async (e) => {
const pos = updateLastCursorPos(stage, setLastCursorPos);
setLastCursorPos(null);
setLastMouseDownPos(null);
const pos = updateLastCursorPos(stage, $lastCursorPos.set);
$lastCursorPos.set(null);
$lastMouseDownPos.set(null);
const selectedEntity = getSelectedEntity();
const selectedEntityAdapter = getSelectedEntityAdapter();
const toolState = getToolState();
if (
pos &&
selectedEntity &&
isDrawableEntity(selectedEntity) &&
selectedEntityAdapter &&
isDrawableEntityAdapter(selectedEntityAdapter) &&
!getSpaceKey() &&
isDrawableEntity(selectedEntity.state) &&
!$spaceKey.get() &&
getIsPrimaryMouseDown(e)
) {
const drawingBuffer = selectedEntityAdapter.renderer.buffer;
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
const drawingBuffer = selectedEntity.adapter.renderer.buffer;
const normalizedPoint = offsetCoord(pos, selectedEntity.state.position);
if (toolState.selected === 'brush' && drawingBuffer?.type === 'brush_line') {
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntityAdapter.renderer.setBuffer(drawingBuffer);
await selectedEntityAdapter.renderer.commitBuffer();
await selectedEntity.adapter.renderer.setBuffer(drawingBuffer);
await selectedEntity.adapter.renderer.commitBuffer();
} else if (toolState.selected === 'eraser' && drawingBuffer?.type === 'eraser_line') {
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntityAdapter.renderer.setBuffer(drawingBuffer);
await selectedEntityAdapter.renderer.commitBuffer();
await selectedEntity.adapter.renderer.setBuffer(drawingBuffer);
await selectedEntity.adapter.renderer.commitBuffer();
} else if (toolState.selected === 'rect' && drawingBuffer?.type === 'rect') {
drawingBuffer.width = Math.round(normalizedPoint.x - drawingBuffer.x);
drawingBuffer.height = Math.round(normalizedPoint.y - drawingBuffer.y);
await selectedEntityAdapter.renderer.setBuffer(drawingBuffer);
await selectedEntityAdapter.renderer.commitBuffer();
await selectedEntity.adapter.renderer.setBuffer(drawingBuffer);
await selectedEntity.adapter.renderer.commitBuffer();
}
}
@ -503,7 +486,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
stage.scaleX(newScale);
stage.scaleY(newScale);
stage.position(newPos);
setStageAttrs({
$stageAttrs.set({
position: newPos,
dimensions: { width: stage.width(), height: stage.height() },
scale: newScale,
@ -516,7 +499,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
//#region dragmove
stage.on('dragmove', () => {
setStageAttrs({
$stageAttrs.set({
position: { x: Math.floor(stage.x()), y: Math.floor(stage.y()) },
dimensions: { width: stage.width(), height: stage.height() },
scale: stage.scaleX(),
@ -528,7 +511,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
//#region dragend
stage.on('dragend', () => {
// Stage position should always be an integer, else we get fractional pixels which are blurry
setStageAttrs({
$stageAttrs.set({
position: { x: Math.floor(stage.x()), y: Math.floor(stage.y()) },
dimensions: { width: stage.width(), height: stage.height() },
scale: stage.scaleX(),
@ -546,17 +529,17 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
}
if (e.key === 'Escape') {
// Cancel shape drawing on escape
setLastMouseDownPos(null);
$lastMouseDownPos.set(null);
} else if (e.key === ' ') {
// Select the view tool on space key down
setToolBuffer(getToolState().selected);
setTool('view');
setSpaceKey(true);
setLastCursorPos(null);
setLastMouseDownPos(null);
$spaceKey.set(true);
$lastCursorPos.set(null);
$lastMouseDownPos.set(null);
} else if (e.key === 'r') {
setLastCursorPos(null);
setLastMouseDownPos(null);
$lastCursorPos.set(null);
$lastMouseDownPos.set(null);
manager.background.render();
// TODO(psyche): restore some kind of fit
}
@ -576,7 +559,7 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
const toolBuffer = getToolState().selectedBuffer;
setTool(toolBuffer ?? 'move');
setToolBuffer(null);
setSpaceKey(false);
$spaceKey.set(false);
}
manager.preview.tool.render();
};

View File

@ -1,12 +1,17 @@
import { getImageDataTransparency } from 'common/util/arrayBuffer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { CanvasObjectState, Coordinate, GenerationMode, Rect, RgbaColor } from 'features/controlLayers/store/types';
import type {
CanvasObjectState,
Coordinate,
GenerationMode,
Rect,
RgbaColor,
} from 'features/controlLayers/store/types';
import { isValidLayer } from 'features/nodes/util/graph/generation/addLayers';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { Vector2d } from 'konva/lib/types';
import { customAlphabet } from 'nanoid';
import type { WritableAtom } from 'nanostores';
import type { ImageDTO } from 'services/api/types';
import { assert } from 'tsafe';
@ -64,7 +69,7 @@ export const alignCoordForTool = (coord: Coordinate, toolWidth: number): Coordin
* Offsets a point by the given offset. The offset is subtracted from the point.
* @param coord The coordinate to offset
* @param offset The offset to apply
* @returns
* @returns
*/
export const offsetCoord = (coord: Coordinate, offset: Coordinate): Coordinate => {
return {
@ -623,22 +628,6 @@ export function getObjectId(type: CanvasObjectState['type'], isBuffer?: boolean)
}
}
export type Subscription = {
name: string;
unsubscribe: () => void;
};
/**
* Builds a subscribe function for a nanostores atom.
* @param subscribe The subscribe function of the atom
* @param name The name of the atom
* @returns A subscribe function that returns an object with the name and unsubscribe function
*/
export const buildSubscribe = <T>(subscribe: WritableAtom<T>['subscribe'], name: string) => {
return (cb: Parameters<WritableAtom<T>['subscribe']>[0]): Subscription => {
return {
name,
unsubscribe: subscribe(cb),
};
};
export const getEmptyRect = (): Rect => {
return { x: 0, y: 0, width: 0, height: 0 };
};