diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushLine.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushLine.ts index 5d4d415917..acafcf3b27 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushLine.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBrushLine.ts @@ -19,7 +19,7 @@ export class CanvasBrushLine extends CanvasObject { constructor(state: BrushLine, parent: CanvasLayer) { super(state.id, parent); - this._log.trace({ state }, 'Creating brush line'); + this.log.trace({ state }, 'Creating brush line'); const { strokeWidth, clip, color, points } = state; @@ -49,7 +49,7 @@ export class CanvasBrushLine extends CanvasObject { update(state: BrushLine, force?: boolean): boolean { if (force || this.state !== state) { - this._log.trace({ state }, 'Updating brush line'); + this.log.trace({ state }, 'Updating brush line'); const { points, color, clip, strokeWidth } = state; this.konva.line.setAttrs({ // A line with only one point will not be rendered, so we duplicate the points to make it visible @@ -66,12 +66,12 @@ export class CanvasBrushLine extends CanvasObject { } destroy() { - this._log.trace('Destroying brush line'); + this.log.trace('Destroying brush line'); this.konva.group.destroy(); } setVisibility(isVisible: boolean): void { - this._log.trace({ isVisible }, 'Setting brush line visibility'); + this.log.trace({ isVisible }, 'Setting brush line visibility'); this.konva.group.visible(isVisible); } @@ -79,7 +79,7 @@ export class CanvasBrushLine extends CanvasObject { return { id: this.id, type: CanvasBrushLine.TYPE, - parent: this._parent.id, + parent: this.parent.id, state: deepClone(this.state), }; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasControlAdapter.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasControlAdapter.ts index a16d0a158c..0065d876d3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasControlAdapter.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasControlAdapter.ts @@ -1,33 +1,31 @@ +import { CanvasEntity } from 'features/controlLayers/konva/CanvasEntity'; import { CanvasImage } from 'features/controlLayers/konva/CanvasImage'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer'; import { type ControlAdapterEntity, isDrawingTool } from 'features/controlLayers/store/types'; import Konva from 'konva'; -export class CanvasControlAdapter { +export class CanvasControlAdapter extends CanvasEntity { static NAME_PREFIX = 'control-adapter'; static LAYER_NAME = `${CanvasControlAdapter.NAME_PREFIX}_layer`; static TRANSFORMER_NAME = `${CanvasControlAdapter.NAME_PREFIX}_transformer`; static GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_group`; static OBJECT_GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_object-group`; - private state: ControlAdapterEntity; - - id: string; - manager: CanvasManager; + type = 'control_adapter'; + _state: ControlAdapterEntity; konva: { layer: Konva.Layer; group: Konva.Group; objectGroup: Konva.Group; - transformer: Konva.Transformer; }; image: CanvasImage | null; + transformer: CanvasTransformer; constructor(state: ControlAdapterEntity, manager: CanvasManager) { - const { id } = state; - this.id = id; - this.manager = manager; + super(state.id, manager); this.konva = { layer: new Konva.Layer({ name: CanvasControlAdapter.LAYER_NAME, @@ -39,42 +37,18 @@ export class CanvasControlAdapter { listening: false, }), objectGroup: new Konva.Group({ name: CanvasControlAdapter.GROUP_NAME, listening: false }), - transformer: new Konva.Transformer({ - name: CanvasControlAdapter.TRANSFORMER_NAME, - shouldOverdrawWholeArea: true, - draggable: true, - dragDistance: 0, - enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'], - rotateEnabled: false, - flipEnabled: false, - }), }; - this.konva.transformer.on('transformend', () => { - this.manager.stateApi.onScaleChanged( - { - id: this.id, - scale: this.konva.group.scaleX(), - position: { x: this.konva.group.x(), y: this.konva.group.y() }, - }, - 'control_adapter' - ); - }); - this.konva.transformer.on('dragend', () => { - this.manager.stateApi.onPosChanged( - { id: this.id, position: { x: this.konva.group.x(), y: this.konva.group.y() } }, - 'control_adapter' - ); - }); + this.transformer = new CanvasTransformer(this); this.konva.group.add(this.konva.objectGroup); this.konva.layer.add(this.konva.group); this.konva.layer.add(this.konva.transformer); this.image = null; - this.state = state; + this._state = state; } async render(state: ControlAdapterEntity) { - this.state = state; + this._state = state; // Update the layer's position and listening state this.konva.group.setAttrs({ @@ -94,7 +68,7 @@ export class CanvasControlAdapter { didDraw = true; } } else if (!this.image) { - this.image = new CanvasImage(imageObject); + this.image = new CanvasImage(imageObject, this); this.updateGroup(true); this.konva.objectGroup.add(this.image.konva.group); await this.image.updateImageSource(imageObject.image.name); @@ -108,13 +82,13 @@ export class CanvasControlAdapter { } updateGroup(didDraw: boolean) { - this.konva.layer.visible(this.state.isEnabled); + this.konva.layer.visible(this._state.isEnabled); - this.konva.group.opacity(this.state.opacity); + this.konva.group.opacity(this._state.opacity); const isSelected = this.manager.stateApi.getIsSelected(this.id); const selectedTool = this.manager.stateApi.getToolState().selected; - if (!this.image?.image) { + if (!this.image?.konva.image) { // If the layer is totally empty, reset the cache and bail out. this.konva.layer.listening(false); this.konva.transformer.nodes([]); @@ -175,4 +149,12 @@ export class CanvasControlAdapter { destroy(): void { this.konva.layer.destroy(); } + + repr() { + return { + id: this.id, + type: this.type, + state: this._state, + }; + } } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity.ts index 390d17d5cc..b9775cd2c1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity.ts @@ -4,22 +4,22 @@ import type { Logger } from 'roarr'; export abstract class CanvasEntity { id: string; - _manager: CanvasManager; - _log: Logger; + manager: CanvasManager; + log: Logger; constructor(id: string, manager: CanvasManager) { this.id = id; - this._manager = manager; - this._log = this._manager.buildLogger(this._getLoggingContext); + this.manager = manager; + this.log = this.manager.buildLogger(this.getLoggingContext); } /** * Get a serializable representation of the entity. */ abstract repr(): JSONObject; - _getLoggingContext = (extra?: Record) => { + getLoggingContext = (extra?: Record) => { return { - ...this._manager._getLoggingContext(), + ...this.manager._getLoggingContext(), layerId: this.id, ...extra, }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEraserLine.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEraserLine.ts index dfe1ee5708..64e7595f0e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEraserLine.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEraserLine.ts @@ -20,7 +20,7 @@ export class CanvasEraserLine extends CanvasObject { constructor(state: EraserLine, parent: CanvasLayer) { super(state.id, parent); - this._log.trace({ state }, 'Creating eraser line'); + this.log.trace({ state }, 'Creating eraser line'); const { strokeWidth, clip, points } = state; @@ -50,7 +50,7 @@ export class CanvasEraserLine extends CanvasObject { update(state: EraserLine, force?: boolean): boolean { if (force || this.state !== state) { - this._log.trace({ state }, 'Updating eraser line'); + this.log.trace({ state }, 'Updating eraser line'); const { points, clip, strokeWidth } = state; this.konva.line.setAttrs({ // A line with only one point will not be rendered, so we duplicate the points to make it visible @@ -66,12 +66,12 @@ export class CanvasEraserLine extends CanvasObject { } destroy() { - this._log.trace('Destroying eraser line'); + this.log.trace('Destroying eraser line'); this.konva.group.destroy(); } setVisibility(isVisible: boolean): void { - this._log.trace({ isVisible }, 'Setting brush line visibility'); + this.log.trace({ isVisible }, 'Setting brush line visibility'); this.konva.group.visible(isVisible); } @@ -79,7 +79,7 @@ export class CanvasEraserLine extends CanvasObject { return { id: this.id, type: CanvasEraserLine.TYPE, - parent: this._parent.id, + parent: this.parent.id, state: deepClone(this.state), }; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasImage.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasImage.ts index 96c14ba27d..021ced6581 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasImage.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasImage.ts @@ -1,4 +1,5 @@ import { deepClone } from 'common/util/deepClone'; +import type { CanvasControlAdapter } from 'features/controlLayers/konva/CanvasControlAdapter'; import type { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer'; import { CanvasObject } from 'features/controlLayers/konva/CanvasObject'; import type { CanvasStagingArea } from 'features/controlLayers/konva/CanvasStagingArea'; @@ -28,9 +29,9 @@ export class CanvasImage extends CanvasObject { isLoading: boolean; isError: boolean; - constructor(state: ImageObject, parent: CanvasLayer | CanvasStagingArea) { + constructor(state: ImageObject, parent: CanvasLayer | CanvasStagingArea | CanvasControlAdapter) { super(state.id, parent); - this._log.trace({ state }, 'Creating image'); + this.log.trace({ state }, 'Creating image'); const { width, height, x, y } = state; @@ -73,7 +74,7 @@ export class CanvasImage extends CanvasObject { async updateImageSource(imageName: string) { try { - this._log.trace({ imageName }, 'Updating image source'); + this.log.trace({ imageName }, 'Updating image source'); this.isLoading = true; this.konva.group.visible(true); @@ -85,7 +86,7 @@ export class CanvasImage extends CanvasObject { const imageDTO = await getImageDTO(imageName); if (imageDTO === null) { - this._log.error({ imageName }, 'Image not found'); + this.log.error({ imageName }, 'Image not found'); return; } const imageEl = await loadImage(imageDTO.image_url); @@ -118,7 +119,7 @@ export class CanvasImage extends CanvasObject { this.isError = false; this.konva.placeholder.group.visible(false); } catch { - this._log({ imageName }, 'Failed to load image'); + this.log({ imageName }, 'Failed to load image'); this.konva.image?.visible(false); this.imageName = null; this.isLoading = false; @@ -130,7 +131,7 @@ export class CanvasImage extends CanvasObject { async update(state: ImageObject, force?: boolean): Promise { if (this.state !== state || force) { - this._log.trace({ state }, 'Updating image'); + this.log.trace({ state }, 'Updating image'); const { width, height, x, y, image, filters } = state; if (this.state.image.name !== image.name || force) { @@ -154,12 +155,12 @@ export class CanvasImage extends CanvasObject { } destroy() { - this._log.trace('Destroying image'); + this.log.trace('Destroying image'); this.konva.group.destroy(); } setVisibility(isVisible: boolean): void { - this._log.trace({ isVisible }, 'Setting image visibility'); + this.log.trace({ isVisible }, 'Setting image visibility'); this.konva.group.visible(isVisible); } @@ -167,7 +168,7 @@ export class CanvasImage extends CanvasObject { return { id: this.id, type: CanvasImage.TYPE, - parent: this._parent.id, + parent: this.parent.id, imageName: this.imageName, isLoading: this.isLoading, isError: this.isError, diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasInteractionRect.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasInteractionRect.ts new file mode 100644 index 0000000000..742c8073b1 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasInteractionRect.ts @@ -0,0 +1,71 @@ +import { nanoid } from '@reduxjs/toolkit'; +import { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer'; +import { CanvasObject } from 'features/controlLayers/konva/CanvasObject'; +import Konva from 'konva'; + +export class CanvasInteractionRect extends CanvasObject { + static TYPE = 'interaction_rect'; + + konva: { + rect: Konva.Rect; + }; + + constructor(parent: CanvasLayer) { + super(`${CanvasInteractionRect.TYPE}:${nanoid()}`, parent); + + this.konva = { + rect: new Konva.Rect({ + name: CanvasInteractionRect.TYPE, + listening: false, + draggable: true, + // fill: 'rgba(255,0,0,0.5)', + }), + }; + + this.konva.rect.on('dragmove', () => { + // Snap the interaction rect to the nearest pixel + this.konva.rect.x(Math.round(this.konva.rect.x())); + this.konva.rect.y(Math.round(this.konva.rect.y())); + + // The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding + // and border + this.parent.konva.bbox.setAttrs({ + x: this.konva.rect.x() - this.manager.getScaledBboxPadding(), + y: this.konva.rect.y() - this.manager.getScaledBboxPadding(), + }); + + // 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({ + x: this.konva.rect.x(), + y: this.konva.rect.y(), + }); + }); + this.konva.rect.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. + return; + } + + const position = { + x: this.konva.rect.x() - this.parent.bbox.x, + y: this.konva.rect.y() - this.parent.bbox.y, + }; + + this.log.trace({ position }, 'Position changed'); + this.manager.stateApi.onPosChanged({ id: this.id, position }, 'layer'); + }); + } + + repr = () => { + return { + id: this.id, + type: CanvasInteractionRect.TYPE, + x: this.konva.rect.x(), + y: this.konva.rect.y(), + width: this.konva.rect.width(), + height: this.konva.rect.height(), + }; + }; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts index fb48c18434..c651df5d5c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasLayer.ts @@ -6,6 +6,7 @@ import { CanvasEraserLine } from 'features/controlLayers/konva/CanvasEraserLine' import { CanvasImage } from 'features/controlLayers/konva/CanvasImage'; import { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasRect } from 'features/controlLayers/konva/CanvasRect'; +import { CanvasTransformer } from 'features/controlLayers/konva/CanvasTransformer'; import { getPrefixedId, konvaNodeToBlob, mapId, previewBlob } from 'features/controlLayers/konva/util'; import { layerRasterized } from 'features/controlLayers/store/canvasV2Slice'; import { @@ -24,31 +25,28 @@ import { uploadImage } from 'services/api/endpoints/images'; import { assert } from 'tsafe'; export class CanvasLayer extends CanvasEntity { - static NAME_PREFIX = 'layer'; - static LAYER_NAME = `${CanvasLayer.NAME_PREFIX}_layer`; - static TRANSFORMER_NAME = `${CanvasLayer.NAME_PREFIX}_transformer`; - static INTERACTION_RECT_NAME = `${CanvasLayer.NAME_PREFIX}_interaction-rect`; - static GROUP_NAME = `${CanvasLayer.NAME_PREFIX}_group`; - static OBJECT_GROUP_NAME = `${CanvasLayer.NAME_PREFIX}_object-group`; - static BBOX_NAME = `${CanvasLayer.NAME_PREFIX}_bbox`; + static TYPE = 'layer'; + static LAYER_NAME = `${CanvasLayer.TYPE}_layer`; + static TRANSFORMER_NAME = `${CanvasLayer.TYPE}_transformer`; + static INTERACTION_RECT_NAME = `${CanvasLayer.TYPE}_interaction-rect`; + static GROUP_NAME = `${CanvasLayer.TYPE}_group`; + static OBJECT_GROUP_NAME = `${CanvasLayer.TYPE}_object-group`; + static BBOX_NAME = `${CanvasLayer.TYPE}_bbox`; - _drawingBuffer: BrushLine | EraserLine | RectShape | null; - _state: LayerEntity; - - type = 'layer'; + drawingBuffer: BrushLine | EraserLine | RectShape | null; + state: LayerEntity; konva: { layer: Konva.Layer; bbox: Konva.Rect; objectGroup: Konva.Group; - transformer: Konva.Transformer; interactionRect: Konva.Rect; }; objects: Map; + transformer: CanvasTransformer; - _bboxNeedsUpdate: boolean; - _isFirstRender: boolean; - + bboxNeedsUpdate: boolean; + isFirstRender: boolean; isTransforming: boolean; isPendingBboxCalculation: boolean; @@ -57,7 +55,7 @@ export class CanvasLayer extends CanvasEntity { constructor(state: LayerEntity, manager: CanvasManager) { super(state.id, manager); - this._log.debug({ state }, 'Creating layer'); + this.log.debug({ state }, 'Creating layer'); this.konva = { layer: new Konva.Layer({ id: this.id, name: CanvasLayer.LAYER_NAME, listening: false }), @@ -70,17 +68,6 @@ export class CanvasLayer extends CanvasEntity { strokeHitEnabled: false, }), objectGroup: new Konva.Group({ name: CanvasLayer.OBJECT_GROUP_NAME, listening: false }), - transformer: new Konva.Transformer({ - name: CanvasLayer.TRANSFORMER_NAME, - draggable: false, - // enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'], - rotateEnabled: true, - flipEnabled: true, - listening: false, - padding: this._manager.getTransformerPadding(), - stroke: 'hsl(200deg 76% 59%)', // invokeBlue.400 - keepRatio: false, - }), interactionRect: new Konva.Rect({ name: CanvasLayer.INTERACTION_RECT_NAME, listening: false, @@ -89,131 +76,13 @@ export class CanvasLayer extends CanvasEntity { }), }; + this.transformer = new CanvasTransformer(this); + this.konva.layer.add(this.konva.objectGroup); - this.konva.layer.add(this.konva.transformer); + this.konva.layer.add(this.transformer.konva.transformer); this.konva.layer.add(this.konva.interactionRect); this.konva.layer.add(this.konva.bbox); - this.konva.transformer.anchorDragBoundFunc((oldPos: Coordinate, newPos: Coordinate) => { - if (this.konva.transformer.getActiveAnchor() === 'rotater') { - return newPos; - } - const stageScale = this._manager.getStageScale(); - const stagePos = this._manager.getStagePosition(); - const targetX = Math.round(newPos.x / stageScale); - const targetY = Math.round(newPos.y / stageScale); - // Because the stage position may be a float, we need to calculate the offset of the stage position to the nearest - // pixel, then add that back to the target position. This ensures the anchors snap to the nearest pixel. - const scaledOffsetX = stagePos.x % stageScale; - const scaledOffsetY = stagePos.y % stageScale; - const scaledTargetX = targetX * stageScale + scaledOffsetX; - const scaledTargetY = targetY * stageScale + scaledOffsetY; - this._log.trace( - { - oldPos, - newPos, - stageScale, - stagePos, - targetX, - targetY, - scaledOffsetX, - scaledOffsetY, - scaledTargetX, - scaledTargetY, - }, - 'Anchor drag bound' - ); - return { x: scaledTargetX, y: scaledTargetY }; - }); - - this.konva.transformer.boundBoxFunc((oldBoundBox, newBoundBox) => { - if (this._manager.stateApi.getShiftKey()) { - if (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) { - return oldBoundBox; - } - } - return newBoundBox; - }); - - this.konva.transformer.on('transformstart', () => { - this._log.trace( - { - x: this.konva.interactionRect.x(), - y: this.konva.interactionRect.y(), - scaleX: this.konva.interactionRect.scaleX(), - scaleY: this.konva.interactionRect.scaleY(), - rotation: this.konva.interactionRect.rotation(), - }, - 'Transform started' - ); - }); - - this.konva.transformer.on('transform', () => { - this.konva.objectGroup.setAttrs({ - x: this.konva.interactionRect.x(), - y: this.konva.interactionRect.y(), - scaleX: this.konva.interactionRect.scaleX(), - scaleY: this.konva.interactionRect.scaleY(), - rotation: this.konva.interactionRect.rotation(), - }); - }); - - this.konva.transformer.on('transformend', () => { - // Always snap the interaction rect to the nearest pixel when transforming - const x = this.konva.interactionRect.x(); - const y = this.konva.interactionRect.y(); - const width = this.konva.interactionRect.width(); - const height = this.konva.interactionRect.height(); - const scaleX = this.konva.interactionRect.scaleX(); - const scaleY = this.konva.interactionRect.scaleY(); - const rotation = this.konva.interactionRect.rotation(); - - // Round to the nearest pixel - const snappedX = Math.round(x); - const snappedY = Math.round(y); - - // Calculate a rounded width and height - must be at least 1! - const targetWidth = Math.max(Math.round(width * scaleX), 1); - const targetHeight = Math.max(Math.round(height * scaleY), 1); - - // Calculate the scale we need to use to get the target width and height - const snappedScaleX = targetWidth / width; - const snappedScaleY = targetHeight / height; - - // Update interaction rect and object group - this.konva.interactionRect.setAttrs({ - x: snappedX, - y: snappedY, - scaleX: snappedScaleX, - scaleY: snappedScaleY, - }); - this.konva.objectGroup.setAttrs({ - x: snappedX, - y: snappedY, - scaleX: snappedScaleX, - scaleY: snappedScaleY, - }); - - this._log.trace( - { - x, - y, - width, - height, - scaleX, - scaleY, - rotation, - snappedX, - snappedY, - targetWidth, - targetHeight, - snappedScaleX, - snappedScaleY, - }, - 'Transform ended' - ); - }); - this.konva.interactionRect.on('dragmove', () => { // Snap the interaction rect to the nearest pixel this.konva.interactionRect.x(Math.round(this.konva.interactionRect.x())); @@ -222,8 +91,8 @@ export class CanvasLayer extends CanvasEntity { // The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding // and border this.konva.bbox.setAttrs({ - x: this.konva.interactionRect.x() - this._manager.getScaledBboxPadding(), - y: this.konva.interactionRect.y() - this._manager.getScaledBboxPadding(), + x: this.konva.interactionRect.x() - this.manager.getScaledBboxPadding(), + y: this.konva.interactionRect.y() - this.manager.getScaledBboxPadding(), }); // The object group is translated by the difference between the interaction rect's new and old positions (which is @@ -245,48 +114,44 @@ export class CanvasLayer extends CanvasEntity { y: this.konva.interactionRect.y() - this.bbox.y, }; - this._log.trace({ position }, 'Position changed'); - this._manager.stateApi.onPosChanged({ id: this.id, position }, 'layer'); + this.log.trace({ position }, 'Position changed'); + this.manager.stateApi.onPosChanged({ id: this.id, position }, 'layer'); }); this.objects = new Map(); - this._drawingBuffer = null; - this._state = state; + this.drawingBuffer = null; + this.state = state; this.rect = this.getDefaultRect(); this.bbox = this.getDefaultRect(); - this._bboxNeedsUpdate = true; + this.bboxNeedsUpdate = true; this.isTransforming = false; - this._isFirstRender = true; + this.isFirstRender = true; this.isPendingBboxCalculation = false; - - this._manager.stateApi.onShiftChanged((isPressed) => { - // Use shift enable/disable rotation snaps - this.konva.transformer.rotationSnaps(isPressed ? [0, 45, 90, 135, 180, 225, 270, 315] : []); - }); } - destroy(): void { - this._log.debug('Destroying layer'); + destroy = (): void => { + this.log.debug('Destroying layer'); this.konva.layer.destroy(); - } + }; - getDrawingBuffer() { - return this._drawingBuffer; - } - async setDrawingBuffer(obj: BrushLine | EraserLine | RectShape | null) { + getDrawingBuffer = () => { + return this.drawingBuffer; + }; + + setDrawingBuffer = async (obj: BrushLine | EraserLine | RectShape | null) => { if (obj) { - this._drawingBuffer = obj; - await this._renderObject(this._drawingBuffer, true); + this.drawingBuffer = obj; + await this._renderObject(this.drawingBuffer, true); } else { - this._drawingBuffer = null; + this.drawingBuffer = null; } - } + }; - async finalizeDrawingBuffer() { - if (!this._drawingBuffer) { + finalizeDrawingBuffer = async () => { + if (!this.drawingBuffer) { return; } - const drawingBuffer = this._drawingBuffer; + const drawingBuffer = this.drawingBuffer; await this.setDrawingBuffer(null); // We need to give the objects a fresh ID else they will be considered the same object when they are re-rendered as @@ -294,62 +159,62 @@ export class CanvasLayer extends CanvasEntity { if (drawingBuffer.type === 'brush_line') { drawingBuffer.id = getPrefixedId('brush_line'); - this._manager.stateApi.onBrushLineAdded({ id: this.id, brushLine: drawingBuffer }, 'layer'); + this.manager.stateApi.onBrushLineAdded({ id: this.id, brushLine: drawingBuffer }, 'layer'); } else if (drawingBuffer.type === 'eraser_line') { drawingBuffer.id = getPrefixedId('brush_line'); - this._manager.stateApi.onEraserLineAdded({ id: this.id, eraserLine: drawingBuffer }, 'layer'); + this.manager.stateApi.onEraserLineAdded({ id: this.id, eraserLine: drawingBuffer }, 'layer'); } else if (drawingBuffer.type === 'rect_shape') { drawingBuffer.id = getPrefixedId('brush_line'); - this._manager.stateApi.onRectShapeAdded({ id: this.id, rectShape: drawingBuffer }, 'layer'); + this.manager.stateApi.onRectShapeAdded({ id: this.id, rectShape: drawingBuffer }, 'layer'); } - } + }; - async update(arg?: { state: LayerEntity; 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)); + update = async (arg?: { state: LayerEntity; 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'); + if (!this.isFirstRender && state === this.state) { + this.log.trace('State unchanged, skipping update'); return; } - this._log.debug('Updating'); + this.log.debug('Updating'); const { position, objects, opacity, isEnabled } = state; - if (this._isFirstRender || objects !== this._state.objects) { + if (this.isFirstRender || objects !== this.state.objects) { await this.updateObjects({ objects }); } - if (this._isFirstRender || position !== this._state.position) { + if (this.isFirstRender || position !== this.state.position) { await this.updatePosition({ position }); } - if (this._isFirstRender || opacity !== this._state.opacity) { + if (this.isFirstRender || opacity !== this.state.opacity) { await this.updateOpacity({ opacity }); } - if (this._isFirstRender || isEnabled !== this._state.isEnabled) { + if (this.isFirstRender || isEnabled !== this.state.isEnabled) { await this.updateVisibility({ isEnabled }); } await this.updateInteraction({ toolState, isSelected }); - if (this._isFirstRender) { + if (this.isFirstRender) { await this.updateBbox(); } - this._state = state; - this._isFirstRender = false; - } + this.state = state; + this.isFirstRender = false; + }; - updateVisibility(arg?: { isEnabled: boolean }) { - this._log.trace('Updating visibility'); - const isEnabled = get(arg, 'isEnabled', this._state.isEnabled); - const hasObjects = this.objects.size > 0 || this._drawingBuffer !== null; + updateVisibility = (arg?: { isEnabled: boolean }) => { + this.log.trace('Updating visibility'); + const isEnabled = get(arg, 'isEnabled', this.state.isEnabled); + const hasObjects = this.objects.size > 0 || this.drawingBuffer !== null; this.konva.layer.visible(isEnabled && hasObjects); - } + }; - updatePosition(arg?: { position: Coordinate }) { - this._log.trace('Updating position'); - const position = get(arg, 'position', this._state.position); - const bboxPadding = this._manager.getScaledBboxPadding(); + 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, @@ -365,12 +230,12 @@ export class CanvasLayer extends CanvasEntity { x: position.x + this.bbox.x * this.konva.interactionRect.scaleX(), y: position.y + this.bbox.y * this.konva.interactionRect.scaleY(), }); - } + }; - async updateObjects(arg?: { objects: LayerEntity['objects'] }) { - this._log.trace('Updating objects'); + updateObjects = async (arg?: { objects: LayerEntity['objects'] }) => { + this.log.trace('Updating objects'); - const objects = get(arg, 'objects', this._state.objects); + const objects = get(arg, 'objects', this.state.objects); const objectIds = objects.map(mapId); @@ -378,7 +243,7 @@ export class CanvasLayer extends CanvasEntity { // Destroy any objects that are no longer in state for (const object of this.objects.values()) { - if (!objectIds.includes(object.id) && object.id !== this._drawingBuffer?.id) { + if (!objectIds.includes(object.id) && object.id !== this.drawingBuffer?.id) { this.objects.delete(object.id); object.destroy(); didUpdate = true; @@ -391,8 +256,8 @@ export class CanvasLayer extends CanvasEntity { } } - if (this._drawingBuffer) { - if (await this._renderObject(this._drawingBuffer)) { + if (this.drawingBuffer) { + if (await this._renderObject(this.drawingBuffer)) { didUpdate = true; } } @@ -401,20 +266,20 @@ export class CanvasLayer extends CanvasEntity { this.calculateBbox(); } - this._isFirstRender = false; - } + this.isFirstRender = false; + }; - updateOpacity(arg?: { opacity: number }) { - this._log.trace('Updating opacity'); - const opacity = get(arg, 'opacity', this._state.opacity); + updateOpacity = (arg?: { opacity: number }) => { + this.log.trace('Updating opacity'); + const opacity = get(arg, 'opacity', this.state.opacity); this.konva.objectGroup.opacity(opacity); - } + }; - updateInteraction(arg?: { toolState: CanvasV2State['tool']; isSelected: boolean }) { - this._log.trace('Updating interaction'); + 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)); + const toolState = get(arg, 'toolState', this.manager.stateApi.getToolState()); + const isSelected = get(arg, 'isSelected', this.manager.stateApi.getIsSelected(this.id)); if (this.objects.size === 0) { // The layer is totally empty, we can just disable the layer @@ -427,8 +292,7 @@ export class CanvasLayer extends CanvasEntity { this.konva.layer.listening(true); // The transformer is not needed - this.konva.transformer.listening(false); - this.konva.transformer.nodes([]); + this.transformer.deactivate(); // The bbox rect should be visible and interaction rect listening for dragging this.konva.bbox.visible(true); @@ -440,10 +304,11 @@ export class CanvasLayer extends CanvasEntity { const listening = toolState.selected !== 'view'; this.konva.layer.listening(listening); this.konva.interactionRect.listening(listening); - this.konva.transformer.listening(listening); - - // The transformer transforms the interaction rect, not the object group - this.konva.transformer.nodes([this.konva.interactionRect]); + if (listening) { + this.transformer.activate(); + } else { + this.transformer.deactivate(); + } // Hide the bbox rect, the transformer will has its own bbox this.konva.bbox.visible(false); @@ -452,15 +317,14 @@ export class CanvasLayer extends CanvasEntity { this.konva.layer.listening(false); // The transformer, bbox and interaction rect should be inactive - this.konva.transformer.listening(false); - this.konva.transformer.nodes([]); + this.transformer.deactivate(); this.konva.bbox.visible(false); this.konva.interactionRect.listening(false); } - } + }; - updateBbox() { - this._log.trace('Updating bbox'); + updateBbox = () => { + this.log.trace('Updating bbox'); if (this.isPendingBboxCalculation) { return; @@ -470,9 +334,9 @@ export class CanvasLayer extends CanvasEntity { // eraser lines, fully clipped brush lines or if it has been fully erased. if (this.bbox.width === 0 || this.bbox.height === 0) { // We shouldn't reset on the first render - the bbox will be calculated on the next render - if (!this._isFirstRender && this.objects.size > 0) { + if (!this.isFirstRender && this.objects.size > 0) { // The layer is fully transparent but has objects - reset it - this._manager.stateApi.onEntityReset({ id: this.id }, 'layer'); + this.manager.stateApi.onEntityReset({ id: this.id }, 'layer'); } this.konva.bbox.visible(false); this.konva.interactionRect.visible(false); @@ -482,35 +346,35 @@ export class CanvasLayer extends CanvasEntity { this.konva.bbox.visible(true); this.konva.interactionRect.visible(true); - const onePixel = this._manager.getScaledPixel(); - const bboxPadding = this._manager.getScaledBboxPadding(); + const onePixel = this.manager.getScaledPixel(); + const bboxPadding = this.manager.getScaledBboxPadding(); this.konva.bbox.setAttrs({ - x: this._state.position.x + this.bbox.x - bboxPadding, - y: this._state.position.y + this.bbox.y - bboxPadding, + 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.konva.interactionRect.setAttrs({ - x: this._state.position.x + this.bbox.x, - y: this._state.position.y + this.bbox.y, + 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.konva.objectGroup.setAttrs({ - x: this._state.position.x + this.bbox.x, - y: this._state.position.y + this.bbox.y, + x: this.state.position.x + this.bbox.x, + y: this.state.position.y + this.bbox.y, offsetX: this.bbox.x, offsetY: this.bbox.y, }); - } + }; - syncStageScale() { - this._log.trace('Syncing scale to stage'); + syncStageScale = () => { + this.log.trace('Syncing scale to stage'); - const onePixel = this._manager.getScaledPixel(); - const bboxPadding = this._manager.getScaledBboxPadding(); + const onePixel = this.manager.getScaledPixel(); + const bboxPadding = this.manager.getScaledBboxPadding(); this.konva.bbox.setAttrs({ x: this.konva.interactionRect.x() - bboxPadding, @@ -519,10 +383,9 @@ export class CanvasLayer extends CanvasEntity { height: this.konva.interactionRect.height() * this.konva.interactionRect.scaleY() + bboxPadding * 2, strokeWidth: onePixel, }); - this.konva.transformer.forceUpdate(); - } + }; - async _renderObject(obj: LayerEntity['objects'][number], force = false): Promise { + _renderObject = async (obj: LayerEntity['objects'][number], force = false): Promise => { if (obj.type === 'brush_line') { let brushLine = this.objects.get(obj.id); assert(brushLine instanceof CanvasBrushLine || brushLine === undefined); @@ -581,29 +444,26 @@ export class CanvasLayer extends CanvasEntity { } return false; - } + }; - startTransform() { - this._log.debug('Starting transform'); + 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 listening = this._manager.stateApi.getToolState().selected !== 'view'; + const listening = this.manager.stateApi.getToolState().selected !== 'view'; this.konva.layer.listening(listening); this.konva.interactionRect.listening(listening); - this.konva.transformer.listening(listening); - - // The transformer transforms the interaction rect, not the object group - this.konva.transformer.nodes([this.konva.interactionRect]); + this.transformer.activate(); // Hide the bbox rect, the transformer will has its own bbox this.konva.bbox.visible(false); - } + }; - resetScale() { + resetScale = () => { const attrs = { scaleX: 1, scaleY: 1, @@ -612,16 +472,16 @@ export class CanvasLayer extends CanvasEntity { this.konva.objectGroup.setAttrs(attrs); this.konva.bbox.setAttrs(attrs); this.konva.interactionRect.setAttrs(attrs); - } + }; - async rasterizeLayer() { - this._log.debug('Rasterizing layer'); + rasterizeLayer = async () => { + this.log.debug('Rasterizing layer'); const objectGroupClone = this.konva.objectGroup.clone(); const interactionRectClone = this.konva.interactionRect.clone(); const rect = interactionRectClone.getClientRect(); const blob = await konvaNodeToBlob(objectGroupClone, rect); - if (this._manager._isDebugging) { + if (this.manager._isDebugging) { previewBlob(blob, 'Rasterized layer'); } const imageDTO = await uploadImage(blob, `${this.id}_rasterized.png`, 'other', true); @@ -635,29 +495,29 @@ export class CanvasLayer extends CanvasEntity { } this.resetScale(); dispatch(layerRasterized({ id: this.id, imageObject, position: { x: rect.x, y: rect.y } })); - } + }; - stopTransform() { - this._log.debug('Stopping transform'); + stopTransform = () => { + this.log.debug('Stopping transform'); this.isTransforming = false; this.resetScale(); this.updatePosition(); this.updateBbox(); this.updateInteraction(); - } + }; - getDefaultRect(): Rect { + getDefaultRect = (): Rect => { return { x: 0, y: 0, width: 0, height: 0 }; - } + }; calculateBbox = debounce(() => { - this._log.debug('Calculating bbox'); + this.log.debug('Calculating bbox'); this.isPendingBboxCalculation = true; if (this.objects.size === 0) { - this._log.trace('No objects, resetting bbox'); + this.log.trace('No objects, resetting bbox'); this.rect = this.getDefaultRect(); this.bbox = this.getDefaultRect(); this.isPendingBboxCalculation = false; @@ -694,7 +554,7 @@ export class CanvasLayer extends CanvasEntity { this.rect = deepClone(rect); this.bbox = deepClone(rect); this.isPendingBboxCalculation = false; - this._log.trace({ bbox: this.bbox, rect: this.rect }, 'Got bbox from client rect'); + this.log.trace({ bbox: this.bbox, rect: this.rect }, 'Got bbox from client rect'); this.updateBbox(); return; } @@ -708,7 +568,7 @@ export class CanvasLayer extends CanvasEntity { return; } const imageData = ctx.getImageData(0, 0, rect.width, rect.height); - this._manager.requestBbox( + this.manager.requestBbox( { buffer: imageData.data.buffer, width: imageData.width, height: imageData.height }, (extents) => { if (extents) { @@ -725,27 +585,27 @@ export class CanvasLayer extends CanvasEntity { this.rect = this.getDefaultRect(); } this.isPendingBboxCalculation = false; - this._log.trace({ bbox: this.bbox, rect: this.rect, extents }, `Got bbox from worker`); + this.log.trace({ bbox: this.bbox, rect: this.rect, extents }, `Got bbox from worker`); this.updateBbox(); clone.destroy(); } ); }, CanvasManager.BBOX_DEBOUNCE_MS); - repr() { + repr = () => { return { id: this.id, - type: this.type, - state: deepClone(this._state), + type: CanvasLayer.TYPE, + state: deepClone(this.state), rect: deepClone(this.rect), bbox: deepClone(this.bbox), - bboxNeedsUpdate: this._bboxNeedsUpdate, - isFirstRender: this._isFirstRender, + bboxNeedsUpdate: this.bboxNeedsUpdate, + isFirstRender: this.isFirstRender, isTransforming: this.isTransforming, isPendingBboxCalculation: this.isPendingBboxCalculation, objects: Array.from(this.objects.values()).map((obj) => obj.repr()), }; - } + }; logDebugInfo(msg = 'Debug info') { const info = { @@ -771,6 +631,6 @@ export class CanvasLayer extends CanvasEntity { offsetY: this.konva.objectGroup.offsetY(), }, }; - this._log.trace(info, msg); + this.log.trace(info, msg); } } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject.ts index 3a07b77e83..52b84a4cd3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject.ts @@ -1,4 +1,5 @@ import type { JSONObject } from 'common/types'; +import type { CanvasControlAdapter } from 'features/controlLayers/konva/CanvasControlAdapter'; import type { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import type { CanvasStagingArea } from 'features/controlLayers/konva/CanvasStagingArea'; @@ -7,27 +8,17 @@ import type { Logger } from 'roarr'; export abstract class CanvasObject { id: string; - _parent: CanvasLayer | CanvasStagingArea; - _manager: CanvasManager; - _log: Logger; + parent: CanvasLayer | CanvasStagingArea | CanvasControlAdapter; + manager: CanvasManager; + log: Logger; - constructor(id: string, parent: CanvasLayer | CanvasStagingArea) { + constructor(id: string, parent: CanvasLayer | CanvasStagingArea | CanvasControlAdapter) { this.id = id; - this._parent = parent; - this._manager = parent._manager; - this._log = this._manager.buildLogger(this._getLoggingContext); + this.parent = parent; + this.manager = parent.manager; + this.log = this.manager.buildLogger(this.getLoggingContext); } - /** - * Destroy the object's konva nodes. - */ - abstract destroy(): void; - - /** - * Set the visibility of the object's konva nodes. - */ - abstract setVisibility(isVisible: boolean): void; - /** * Get a serializable representation of the object. */ @@ -38,9 +29,9 @@ export abstract class CanvasObject { * @param extra Extra data to merge into the context * @returns The logging context for this object */ - _getLoggingContext = (extra?: Record) => { + getLoggingContext = (extra?: Record) => { return { - ...this._parent._getLoggingContext(), + ...this.parent.getLoggingContext(), objectId: this.id, ...extra, }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRect.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRect.ts index 96b4ac1c06..fe0b875379 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRect.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasRect.ts @@ -19,7 +19,7 @@ export class CanvasRect extends CanvasObject { constructor(state: RectShape, parent: CanvasLayer) { super(state.id, parent); - this._log.trace({ state }, 'Creating rect'); + this.log.trace({ state }, 'Creating rect'); const { x, y, width, height, color } = state; @@ -41,7 +41,7 @@ export class CanvasRect extends CanvasObject { update(state: RectShape, force?: boolean): boolean { if (this.state !== state || force) { - this._log.trace({ state }, 'Updating rect'); + this.log.trace({ state }, 'Updating rect'); const { x, y, width, height, color } = state; this.konva.rect.setAttrs({ x, @@ -58,12 +58,12 @@ export class CanvasRect extends CanvasObject { } destroy() { - this._log.trace('Destroying rect'); + this.log.trace('Destroying rect'); this.konva.group.destroy(); } setVisibility(isVisible: boolean): void { - this._log.trace({ isVisible }, 'Setting rect visibility'); + this.log.trace({ isVisible }, 'Setting rect visibility'); this.konva.group.visible(isVisible); } @@ -71,7 +71,7 @@ export class CanvasRect extends CanvasObject { return { id: this.id, type: CanvasRect.TYPE, - parent: this._parent.id, + parent: this.parent.id, state: deepClone(this.state), }; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingArea.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingArea.ts index c7f83c5173..88dfa3e3cd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingArea.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingArea.ts @@ -22,9 +22,9 @@ export class CanvasStagingArea extends CanvasEntity { } async render() { - const session = this._manager.stateApi.getSession(); - const bboxRect = this._manager.stateApi.getBbox().rect; - const shouldShowStagedImage = this._manager.stateApi.getShouldShowStagedImage(); + const session = this.manager.stateApi.getSession(); + const bboxRect = this.manager.stateApi.getBbox().rect; + const shouldShowStagedImage = this.manager.stateApi.getShouldShowStagedImage(); this.selectedImage = session.stagedImages[session.selectedStagedImageIndex] ?? null; @@ -59,7 +59,7 @@ export class CanvasStagingArea extends CanvasEntity { 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.resetLastProgressEvent(); } this.image.konva.group.visible(shouldShowStagedImage); } else { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts new file mode 100644 index 0000000000..b42cb093a8 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTransformer.ts @@ -0,0 +1,228 @@ +import type { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer'; +import { CanvasObject } from 'features/controlLayers/konva/CanvasObject'; +import { nanoid } from 'features/controlLayers/konva/util'; +import type { Coordinate } from 'features/controlLayers/store/types'; +import Konva from 'konva'; + +export class CanvasTransformer extends CanvasObject { + static TYPE = 'transformer'; + + isActive: boolean; + konva: { + transformer: Konva.Transformer; + }; + + constructor(parent: CanvasLayer) { + super(`${CanvasTransformer.TYPE}:${nanoid()}`, parent); + + this.isActive = false; + this.konva = { + transformer: new Konva.Transformer({ + name: CanvasTransformer.TYPE, + // The transformer will use the interaction rect as a proxy for the entity it is transforming. + nodes: [parent.konva.interactionRect], + // Visibility and listening are managed via activate() and deactivate() + visible: false, + listening: false, + // Rotation is allowed + rotateEnabled: true, + // When dragging a transform anchor across either the x or y axis, the nodes will be flipped across the axis + flipEnabled: true, + // Transforming will retain aspect ratio only when shift is held + keepRatio: false, + // The padding is the distance between the transformer bbox and the nodes + padding: this.manager.getTransformerPadding(), + // This is `invokeBlue.400` + stroke: 'hsl(200deg 76% 59%)', + // 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. + anchorDragBoundFunc: (oldPos: Coordinate, newPos: Coordinate) => { + // The anchorDragBoundFunc callback puts constraints on the movement of the transformer anchors, which in + // turn constrain the transformation. It is called on every anchor move. We'll use this to snap the anchors + // to the nearest pixel. + + // If we are rotating, no need to do anything - just let the rotation happen. + if (this.konva.transformer.getActiveAnchor() === 'rotater') { + return newPos; + } + + // We need to snap the anchor to the nearest pixel, but the positions provided to this callback are absolute, + // scaled coordinates. They need to be converted to stage coordinates, snapped, then converted back to absolute + // before returning them. + const stageScale = this.manager.getStageScale(); + const stagePos = this.manager.getStagePosition(); + + // Unscale and round the target position to the nearest pixel. + const targetX = Math.round(newPos.x / stageScale); + const targetY = Math.round(newPos.y / stageScale); + + // The stage may be offset a fraction of a pixel. To ensure the anchor snaps to the nearest pixel, we need to + // calculate that offset and add it back to the target position. + + // Calculate the offset. It's the remainder of the stage position divided by the scale * desired grid size. In + // this case, the grid size is 1px. For example, if we wanted to snap to the nearest 8px, the calculation would + // be `stagePos.x % (stageScale * 8)`. + const scaledOffsetX = stagePos.x % stageScale; + const scaledOffsetY = stagePos.y % stageScale; + + // Unscale the target position and add the offset to get the absolute position for this anchor. + const scaledTargetX = targetX * stageScale + scaledOffsetX; + const scaledTargetY = targetY * stageScale + scaledOffsetY; + + this.log.trace( + { + oldPos, + newPos, + stageScale, + stagePos, + targetX, + targetY, + scaledOffsetX, + scaledOffsetY, + scaledTargetX, + scaledTargetY, + }, + 'Anchor drag bound' + ); + + return { x: scaledTargetX, y: scaledTargetY }; + }, + boundBoxFunc: (oldBoundBox, newBoundBox) => { + // 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 (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) { + return oldBoundBox; + } + } + + return newBoundBox; + }, + }), + }; + + this.konva.transformer.on('transformstart', () => { + // Just logging in this callback. Called on mouse down of a transform anchor. + this.log.trace( + { + x: parent.konva.interactionRect.x(), + y: parent.konva.interactionRect.y(), + scaleX: parent.konva.interactionRect.scaleX(), + scaleY: parent.konva.interactionRect.scaleY(), + rotation: parent.konva.interactionRect.rotation(), + }, + 'Transform started' + ); + }); + + this.konva.transformer.on('transform', () => { + // 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({ + x: parent.konva.interactionRect.x(), + y: parent.konva.interactionRect.y(), + scaleX: parent.konva.interactionRect.scaleX(), + scaleY: parent.konva.interactionRect.scaleY(), + rotation: parent.konva.interactionRect.rotation(), + }); + }); + + this.konva.transformer.on('transformend', () => { + // Called on mouse up on an anchor. We'll do some final snapping to ensure the transformer is pixel-perfect. + + // Snap the position to the nearest pixel. + const x = parent.konva.interactionRect.x(); + const y = parent.konva.interactionRect.y(); + const snappedX = Math.round(x); + const snappedY = Math.round(y); + + // The transformer doesn't modify the width and height. It only modifies scale. We'll need to apply the scale to + // the width and height, round them to the nearest pixel, and finally calculate a new scale that will result in + // the snapped width and height. + const width = parent.konva.interactionRect.width(); + const height = parent.konva.interactionRect.height(); + const scaleX = parent.konva.interactionRect.scaleX(); + const scaleY = parent.konva.interactionRect.scaleY(); + + // Determine the target width and height, rounded to the nearest pixel. Must be >= 1. Because the scales can be + // negative, we need to take the absolute value of the width and height. + const targetWidth = Math.max(Math.abs(Math.round(width * scaleX)), 1); + const targetHeight = Math.max(Math.abs(Math.round(height * scaleY)), 1); + + // Calculate the scale we need to use to get the target width and height. Restore the sign of the scales. + const snappedScaleX = (targetWidth / width) * Math.sign(scaleX); + const snappedScaleY = (targetHeight / height) * Math.sign(scaleY); + + // Update interaction rect and object group attributes. + parent.konva.interactionRect.setAttrs({ + x: snappedX, + y: snappedY, + scaleX: snappedScaleX, + scaleY: snappedScaleY, + }); + parent.konva.objectGroup.setAttrs({ + x: snappedX, + y: snappedY, + scaleX: snappedScaleX, + scaleY: snappedScaleY, + }); + + // Rotation is only retrieved for logging purposes. + const rotation = parent.konva.interactionRect.rotation(); + + this.log.trace( + { + x, + y, + width, + height, + scaleX, + scaleY, + rotation, + snappedX, + snappedY, + targetWidth, + targetHeight, + snappedScaleX, + snappedScaleY, + }, + 'Transform ended' + ); + }); + + this.manager.stateApi.onShiftChanged((isPressed) => { + // 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] : []); + }); + } + + /** + * Activate the transformer. This will make it visible and listening for events. + */ + activate = () => { + this.isActive = true; + this.konva.transformer.visible(true); + this.konva.transformer.listening(true); + }; + + /** + * Deactivate the transformer. This will make it invisible and not listening for events. + */ + deactivate = () => { + this.isActive = false; + this.konva.transformer.visible(false); + this.konva.transformer.listening(false); + }; + + repr = () => { + return { + id: this.id, + type: CanvasTransformer.TYPE, + isActive: this.isActive, + }; + }; +}