diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts index e3589c0709..7748b9c785 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts @@ -69,12 +69,10 @@ export class CanvasLayer { objectGroup: new Konva.Group({ name: CanvasLayer.OBJECT_GROUP_NAME, listening: false }), }; - this.transformer = new CanvasTransformer(this); + this.transformer = new CanvasTransformer(this, this.konva.objectGroup); this.konva.layer.add(this.konva.objectGroup); - this.konva.layer.add(this.transformer.konva.transformer); - this.konva.layer.add(this.transformer.konva.proxyRect); - this.konva.layer.add(this.transformer.konva.bboxOutline); + this.konva.layer.add(...this.transformer.getNodes()); this.objects = new Map(); this.drawingBuffer = null; @@ -89,6 +87,11 @@ export class CanvasLayer { destroy = (): void => { this.log.debug('Destroying layer'); + // We need to call the destroy method on all children so they can do their own cleanup. + this.transformer.destroy(); + for (const obj of this.objects.values()) { + obj.destroy(); + } this.konva.layer.destroy(); }; @@ -172,7 +175,6 @@ export class CanvasLayer { updatePosition = (arg?: { position: Coordinate }) => { this.log.trace('Updating position'); const position = get(arg, 'position', this.state.position); - const bboxPadding = this.manager.getScaledBboxPadding(); this.konva.objectGroup.setAttrs({ x: position.x + this.bbox.x, @@ -180,14 +182,8 @@ export class CanvasLayer { offsetX: this.bbox.x, offsetY: this.bbox.y, }); - this.transformer.konva.bboxOutline.setAttrs({ - x: position.x + this.bbox.x - bboxPadding, - y: position.y + this.bbox.y - bboxPadding, - }); - this.transformer.konva.proxyRect.setAttrs({ - x: position.x + this.bbox.x * this.transformer.konva.proxyRect.scaleX(), - y: position.y + this.bbox.y * this.transformer.konva.proxyRect.scaleY(), - }); + + this.transformer.update(position, this.bbox); }; updateObjects = async (arg?: { objects: LayerEntity['objects'] }) => { @@ -242,18 +238,17 @@ export class CanvasLayer { if (this.objects.size === 0) { // 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 or - // interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening - // when the view tool is selected + // 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'); @@ -264,8 +259,6 @@ export class CanvasLayer { } 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); - - // The transformer, bbox and interaction rect should be inactive this.transformer.setMode('off'); } }; @@ -290,23 +283,7 @@ export class CanvasLayer { } this.transformer.setMode('drag'); - - const onePixel = this.manager.getScaledPixel(); - const bboxPadding = this.manager.getScaledBboxPadding(); - - this.transformer.konva.bboxOutline.setAttrs({ - x: this.state.position.x + this.bbox.x - bboxPadding, - y: this.state.position.y + this.bbox.y - bboxPadding, - width: this.bbox.width + bboxPadding * 2, - height: this.bbox.height + bboxPadding * 2, - strokeWidth: onePixel, - }); - this.transformer.konva.proxyRect.setAttrs({ - x: this.state.position.x + this.bbox.x, - y: this.state.position.y + this.bbox.y, - width: this.bbox.width, - height: this.bbox.height, - }); + this.transformer.update(this.state.position, this.bbox); this.konva.objectGroup.setAttrs({ x: this.state.position.x + this.bbox.x, y: this.state.position.y + this.bbox.y, @@ -315,21 +292,6 @@ export class CanvasLayer { }); }; - syncStageScale = () => { - this.log.trace('Syncing scale to stage'); - - const onePixel = this.manager.getScaledPixel(); - const bboxPadding = this.manager.getScaledBboxPadding(); - - this.transformer.konva.bboxOutline.setAttrs({ - x: this.transformer.konva.proxyRect.x() - bboxPadding, - y: this.transformer.konva.proxyRect.y() - bboxPadding, - width: this.transformer.konva.proxyRect.width() * this.transformer.konva.proxyRect.scaleX() + bboxPadding * 2, - height: this.transformer.konva.proxyRect.height() * this.transformer.konva.proxyRect.scaleY() + bboxPadding * 2, - strokeWidth: onePixel, - }); - }; - _renderObject = async (obj: LayerEntity['objects'][number], force = false): Promise => { if (obj.type === 'brush_line') { let brushLine = this.objects.get(obj.id); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts index 8ad090c833..68ceaf0037 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts @@ -1,10 +1,19 @@ 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 } from 'features/controlLayers/store/types'; +import type { Coordinate, GetLoggingContext, Rect } from 'features/controlLayers/store/types'; import Konva from 'konva'; import type { Logger } from 'roarr'; +/** + * The CanvasTransformer class is responsible for managing the transformation of a canvas entity: + * - Moving + * - Resizing + * - Rotating + * + * It renders an outline when dragging and resizing the entity, with transform anchors for resizing and rotation. + */ export class CanvasTransformer { static TYPE = 'entity_transformer'; static TRANSFORMER_NAME = `${CanvasTransformer.TYPE}:transformer`; @@ -17,24 +26,46 @@ export class CanvasTransformer { 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 + */ mode: 'transform' | 'drag' | 'off'; + + /** + * Whether dragging is enabled. Dragging is enabled in both 'transform' and 'drag' modes. + */ isDragEnabled: boolean; + + /** + * Whether transforming is enabled. Transforming is enabled only in 'transform' mode. + */ isTransformEnabled: boolean; + /** + * The konva group that the transformer will manipulate. + */ + transformTarget: Konva.Group; + konva: { transformer: Konva.Transformer; proxyRect: Konva.Rect; bboxOutline: Konva.Rect; }; - constructor(parent: CanvasLayer) { + constructor(parent: CanvasLayer, transformTarget: Konva.Group) { + this.id = getPrefixedId(CanvasTransformer.TYPE); this.parent = parent; this.manager = parent.manager; - this.id = getPrefixedId(CanvasTransformer.TYPE); + this.transformTarget = transformTarget; this.getLoggingContext = this.manager.buildObjectGetLoggingContext(this); this.log = this.manager.buildLogger(this.getLoggingContext); + this.subscriptions = []; this.mode = 'off'; this.isDragEnabled = false; @@ -156,7 +187,7 @@ export class CanvasTransformer { // This is called when a transform anchor is dragged. By this time, the transform constraints in the above // callbacks have been enforced, and the transformer has updated its nodes' attributes. We need to pass the // updated attributes to the object group, propagating the transformation on down. - parent.konva.objectGroup.setAttrs({ + this.transformTarget.setAttrs({ x: this.konva.proxyRect.x(), y: this.konva.proxyRect.y(), scaleX: this.konva.proxyRect.scaleX(), @@ -198,7 +229,7 @@ export class CanvasTransformer { scaleX: snappedScaleX, scaleY: snappedScaleY, }); - parent.konva.objectGroup.setAttrs({ + this.transformTarget.setAttrs({ x: snappedX, y: snappedY, scaleX: snappedScaleX, @@ -242,7 +273,7 @@ export class CanvasTransformer { // The object group is translated by the difference between the interaction rect's new and old positions (which is // stored as this.bbox) - this.parent.konva.objectGroup.setAttrs({ + this.transformTarget.setAttrs({ x: this.konva.proxyRect.x(), y: this.konva.proxyRect.y(), }); @@ -263,13 +294,73 @@ export class CanvasTransformer { this.manager.stateApi.onPosChanged({ id: this.parent.id, position }, 'layer'); }); - this.manager.stateApi.onShiftChanged((isPressed) => { + 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.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.konva.transformer.rotationSnaps(isPressed ? [0, 45, 90, 135, 180, 225, 270, 315] : []); - }); + this.manager.stateApi.onShiftChanged((isPressed) => { + this.konva.transformer.rotationSnaps(isPressed ? [0, 45, 90, 135, 180, 225, 270, 315] : []); + }) + ); } + /** + * Updates the transformer's visual components to match the parent entity's position and bounding box. + * @param position The position of the parent entity + * @param bbox The bounding box of the parent entity + */ + update = (position: Coordinate, bbox: Rect) => { + const onePixel = this.manager.getScaledPixel(); + const bboxPadding = this.manager.getScaledBboxPadding(); + + this.konva.bboxOutline.setAttrs({ + x: position.x + bbox.x - bboxPadding, + y: position.y + bbox.y - bboxPadding, + width: bbox.width + bboxPadding * 2, + height: bbox.height + bboxPadding * 2, + strokeWidth: onePixel, + }); + this.konva.proxyRect.setAttrs({ + x: position.x + bbox.x, + y: position.y + bbox.y, + width: bbox.width, + height: bbox.height, + }); + }; + + /** + * Updates the transformer's scale. This is called when the stage is scaled. + */ + scale = () => { + const onePixel = this.manager.getScaledPixel(); + const bboxPadding = this.manager.getScaledBboxPadding(); + + this.konva.bboxOutline.setAttrs({ + x: this.konva.proxyRect.x() - bboxPadding, + y: this.konva.proxyRect.y() - bboxPadding, + width: this.konva.proxyRect.width() * this.konva.proxyRect.scaleX() + bboxPadding * 2, + height: this.konva.proxyRect.height() * this.konva.proxyRect.scaleY() + bboxPadding * 2, + strokeWidth: onePixel, + }); + this.konva.transformer.forceUpdate(); + }; + + /** + * 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 + */ setMode = (mode: 'transform' | 'drag' | 'off') => { this.mode = mode; if (mode === 'drag') { @@ -321,11 +412,26 @@ export class CanvasTransformer { this.konva.bboxOutline.visible(false); }; + getNodes = () => [this.konva.transformer, this.konva.proxyRect, this.konva.bboxOutline]; + repr = () => { return { id: this.id, type: CanvasTransformer.TYPE, - isActive: this.isTransformEnabled, + mode: this.mode, + isTransformEnabled: this.isTransformEnabled, + isDragEnabled: this.isDragEnabled, }; }; + + destroy = () => { + this.log.trace('Destroying transformer'); + for (const { name, unsubscribe } of this.subscriptions) { + this.log.trace({ name }, 'Cleaning up listener'); + unsubscribe(); + } + this.konva.bboxOutline.destroy(); + this.konva.transformer.destroy(); + this.konva.proxyRect.destroy(); + }; }