feat(ui): split & document transformer logic, iterate on class structures

This commit is contained in:
psychedelicious 2024-08-01 19:18:34 +10:00
parent 5c5a405c0f
commit cf83af7a27
11 changed files with 507 additions and 374 deletions

View File

@ -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),
};
}

View File

@ -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,
};
}
}

View File

@ -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<string, unknown>) => {
getLoggingContext = (extra?: Record<string, unknown>) => {
return {
...this._manager._getLoggingContext(),
...this.manager._getLoggingContext(),
layerId: this.id,
...extra,
};

View File

@ -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),
};
}

View File

@ -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<boolean> {
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,

View File

@ -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(),
};
};
}

View File

@ -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<string, CanvasBrushLine | CanvasEraserLine | CanvasRect | CanvasImage>;
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<boolean> {
_renderObject = async (obj: LayerEntity['objects'][number], force = false): Promise<boolean> => {
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);
}
}

View File

@ -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<string, unknown>) => {
getLoggingContext = (extra?: Record<string, unknown>) => {
return {
...this._parent._getLoggingContext(),
...this.parent.getLoggingContext(),
objectId: this.id,
...extra,
};

View File

@ -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),
};
}

View File

@ -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 {

View File

@ -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,
};
};
}