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) { constructor(state: BrushLine, parent: CanvasLayer) {
super(state.id, parent); super(state.id, parent);
this._log.trace({ state }, 'Creating brush line'); this.log.trace({ state }, 'Creating brush line');
const { strokeWidth, clip, color, points } = state; const { strokeWidth, clip, color, points } = state;
@ -49,7 +49,7 @@ export class CanvasBrushLine extends CanvasObject {
update(state: BrushLine, force?: boolean): boolean { update(state: BrushLine, force?: boolean): boolean {
if (force || this.state !== state) { 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; const { points, color, clip, strokeWidth } = state;
this.konva.line.setAttrs({ this.konva.line.setAttrs({
// A line with only one point will not be rendered, so we duplicate the points to make it visible // 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() { destroy() {
this._log.trace('Destroying brush line'); this.log.trace('Destroying brush line');
this.konva.group.destroy(); this.konva.group.destroy();
} }
setVisibility(isVisible: boolean): void { 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); this.konva.group.visible(isVisible);
} }
@ -79,7 +79,7 @@ export class CanvasBrushLine extends CanvasObject {
return { return {
id: this.id, id: this.id,
type: CanvasBrushLine.TYPE, type: CanvasBrushLine.TYPE,
parent: this._parent.id, parent: this.parent.id,
state: deepClone(this.state), 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 { CanvasImage } from 'features/controlLayers/konva/CanvasImage';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; 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 { type ControlAdapterEntity, isDrawingTool } from 'features/controlLayers/store/types';
import Konva from 'konva'; import Konva from 'konva';
export class CanvasControlAdapter { export class CanvasControlAdapter extends CanvasEntity {
static NAME_PREFIX = 'control-adapter'; static NAME_PREFIX = 'control-adapter';
static LAYER_NAME = `${CanvasControlAdapter.NAME_PREFIX}_layer`; static LAYER_NAME = `${CanvasControlAdapter.NAME_PREFIX}_layer`;
static TRANSFORMER_NAME = `${CanvasControlAdapter.NAME_PREFIX}_transformer`; static TRANSFORMER_NAME = `${CanvasControlAdapter.NAME_PREFIX}_transformer`;
static GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_group`; static GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_group`;
static OBJECT_GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_object-group`; static OBJECT_GROUP_NAME = `${CanvasControlAdapter.NAME_PREFIX}_object-group`;
private state: ControlAdapterEntity; type = 'control_adapter';
_state: ControlAdapterEntity;
id: string;
manager: CanvasManager;
konva: { konva: {
layer: Konva.Layer; layer: Konva.Layer;
group: Konva.Group; group: Konva.Group;
objectGroup: Konva.Group; objectGroup: Konva.Group;
transformer: Konva.Transformer;
}; };
image: CanvasImage | null; image: CanvasImage | null;
transformer: CanvasTransformer;
constructor(state: ControlAdapterEntity, manager: CanvasManager) { constructor(state: ControlAdapterEntity, manager: CanvasManager) {
const { id } = state; super(state.id, manager);
this.id = id;
this.manager = manager;
this.konva = { this.konva = {
layer: new Konva.Layer({ layer: new Konva.Layer({
name: CanvasControlAdapter.LAYER_NAME, name: CanvasControlAdapter.LAYER_NAME,
@ -39,42 +37,18 @@ export class CanvasControlAdapter {
listening: false, listening: false,
}), }),
objectGroup: new Konva.Group({ name: CanvasControlAdapter.GROUP_NAME, 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.transformer = new CanvasTransformer(this);
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.konva.group.add(this.konva.objectGroup); this.konva.group.add(this.konva.objectGroup);
this.konva.layer.add(this.konva.group); this.konva.layer.add(this.konva.group);
this.konva.layer.add(this.konva.transformer); this.konva.layer.add(this.konva.transformer);
this.image = null; this.image = null;
this.state = state; this._state = state;
} }
async render(state: ControlAdapterEntity) { async render(state: ControlAdapterEntity) {
this.state = state; this._state = state;
// Update the layer's position and listening state // Update the layer's position and listening state
this.konva.group.setAttrs({ this.konva.group.setAttrs({
@ -94,7 +68,7 @@ export class CanvasControlAdapter {
didDraw = true; didDraw = true;
} }
} else if (!this.image) { } else if (!this.image) {
this.image = new CanvasImage(imageObject); this.image = new CanvasImage(imageObject, this);
this.updateGroup(true); this.updateGroup(true);
this.konva.objectGroup.add(this.image.konva.group); this.konva.objectGroup.add(this.image.konva.group);
await this.image.updateImageSource(imageObject.image.name); await this.image.updateImageSource(imageObject.image.name);
@ -108,13 +82,13 @@ export class CanvasControlAdapter {
} }
updateGroup(didDraw: boolean) { 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 isSelected = this.manager.stateApi.getIsSelected(this.id);
const selectedTool = this.manager.stateApi.getToolState().selected; 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. // If the layer is totally empty, reset the cache and bail out.
this.konva.layer.listening(false); this.konva.layer.listening(false);
this.konva.transformer.nodes([]); this.konva.transformer.nodes([]);
@ -175,4 +149,12 @@ export class CanvasControlAdapter {
destroy(): void { destroy(): void {
this.konva.layer.destroy(); 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 { export abstract class CanvasEntity {
id: string; id: string;
_manager: CanvasManager; manager: CanvasManager;
_log: Logger; log: Logger;
constructor(id: string, manager: CanvasManager) { constructor(id: string, manager: CanvasManager) {
this.id = id; this.id = id;
this._manager = manager; this.manager = manager;
this._log = this._manager.buildLogger(this._getLoggingContext); this.log = this.manager.buildLogger(this.getLoggingContext);
} }
/** /**
* Get a serializable representation of the entity. * Get a serializable representation of the entity.
*/ */
abstract repr(): JSONObject; abstract repr(): JSONObject;
_getLoggingContext = (extra?: Record<string, unknown>) => { getLoggingContext = (extra?: Record<string, unknown>) => {
return { return {
...this._manager._getLoggingContext(), ...this.manager._getLoggingContext(),
layerId: this.id, layerId: this.id,
...extra, ...extra,
}; };

View File

@ -20,7 +20,7 @@ export class CanvasEraserLine extends CanvasObject {
constructor(state: EraserLine, parent: CanvasLayer) { constructor(state: EraserLine, parent: CanvasLayer) {
super(state.id, parent); super(state.id, parent);
this._log.trace({ state }, 'Creating eraser line'); this.log.trace({ state }, 'Creating eraser line');
const { strokeWidth, clip, points } = state; const { strokeWidth, clip, points } = state;
@ -50,7 +50,7 @@ export class CanvasEraserLine extends CanvasObject {
update(state: EraserLine, force?: boolean): boolean { update(state: EraserLine, force?: boolean): boolean {
if (force || this.state !== state) { if (force || this.state !== state) {
this._log.trace({ state }, 'Updating eraser line'); this.log.trace({ state }, 'Updating eraser line');
const { points, clip, strokeWidth } = state; const { points, clip, strokeWidth } = state;
this.konva.line.setAttrs({ this.konva.line.setAttrs({
// A line with only one point will not be rendered, so we duplicate the points to make it visible // 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() { destroy() {
this._log.trace('Destroying eraser line'); this.log.trace('Destroying eraser line');
this.konva.group.destroy(); this.konva.group.destroy();
} }
setVisibility(isVisible: boolean): void { 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); this.konva.group.visible(isVisible);
} }
@ -79,7 +79,7 @@ export class CanvasEraserLine extends CanvasObject {
return { return {
id: this.id, id: this.id,
type: CanvasEraserLine.TYPE, type: CanvasEraserLine.TYPE,
parent: this._parent.id, parent: this.parent.id,
state: deepClone(this.state), state: deepClone(this.state),
}; };
} }

View File

@ -1,4 +1,5 @@
import { deepClone } from 'common/util/deepClone'; import { deepClone } from 'common/util/deepClone';
import type { CanvasControlAdapter } from 'features/controlLayers/konva/CanvasControlAdapter';
import type { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer'; import type { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer';
import { CanvasObject } from 'features/controlLayers/konva/CanvasObject'; import { CanvasObject } from 'features/controlLayers/konva/CanvasObject';
import type { CanvasStagingArea } from 'features/controlLayers/konva/CanvasStagingArea'; import type { CanvasStagingArea } from 'features/controlLayers/konva/CanvasStagingArea';
@ -28,9 +29,9 @@ export class CanvasImage extends CanvasObject {
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
constructor(state: ImageObject, parent: CanvasLayer | CanvasStagingArea) { constructor(state: ImageObject, parent: CanvasLayer | CanvasStagingArea | CanvasControlAdapter) {
super(state.id, parent); super(state.id, parent);
this._log.trace({ state }, 'Creating image'); this.log.trace({ state }, 'Creating image');
const { width, height, x, y } = state; const { width, height, x, y } = state;
@ -73,7 +74,7 @@ export class CanvasImage extends CanvasObject {
async updateImageSource(imageName: string) { async updateImageSource(imageName: string) {
try { try {
this._log.trace({ imageName }, 'Updating image source'); this.log.trace({ imageName }, 'Updating image source');
this.isLoading = true; this.isLoading = true;
this.konva.group.visible(true); this.konva.group.visible(true);
@ -85,7 +86,7 @@ export class CanvasImage extends CanvasObject {
const imageDTO = await getImageDTO(imageName); const imageDTO = await getImageDTO(imageName);
if (imageDTO === null) { if (imageDTO === null) {
this._log.error({ imageName }, 'Image not found'); this.log.error({ imageName }, 'Image not found');
return; return;
} }
const imageEl = await loadImage(imageDTO.image_url); const imageEl = await loadImage(imageDTO.image_url);
@ -118,7 +119,7 @@ export class CanvasImage extends CanvasObject {
this.isError = false; this.isError = false;
this.konva.placeholder.group.visible(false); this.konva.placeholder.group.visible(false);
} catch { } catch {
this._log({ imageName }, 'Failed to load image'); this.log({ imageName }, 'Failed to load image');
this.konva.image?.visible(false); this.konva.image?.visible(false);
this.imageName = null; this.imageName = null;
this.isLoading = false; this.isLoading = false;
@ -130,7 +131,7 @@ export class CanvasImage extends CanvasObject {
async update(state: ImageObject, force?: boolean): Promise<boolean> { async update(state: ImageObject, force?: boolean): Promise<boolean> {
if (this.state !== state || force) { 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; const { width, height, x, y, image, filters } = state;
if (this.state.image.name !== image.name || force) { if (this.state.image.name !== image.name || force) {
@ -154,12 +155,12 @@ export class CanvasImage extends CanvasObject {
} }
destroy() { destroy() {
this._log.trace('Destroying image'); this.log.trace('Destroying image');
this.konva.group.destroy(); this.konva.group.destroy();
} }
setVisibility(isVisible: boolean): void { setVisibility(isVisible: boolean): void {
this._log.trace({ isVisible }, 'Setting image visibility'); this.log.trace({ isVisible }, 'Setting image visibility');
this.konva.group.visible(isVisible); this.konva.group.visible(isVisible);
} }
@ -167,7 +168,7 @@ export class CanvasImage extends CanvasObject {
return { return {
id: this.id, id: this.id,
type: CanvasImage.TYPE, type: CanvasImage.TYPE,
parent: this._parent.id, parent: this.parent.id,
imageName: this.imageName, imageName: this.imageName,
isLoading: this.isLoading, isLoading: this.isLoading,
isError: this.isError, 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 { CanvasImage } from 'features/controlLayers/konva/CanvasImage';
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasRect } from 'features/controlLayers/konva/CanvasRect'; 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 { getPrefixedId, konvaNodeToBlob, mapId, previewBlob } from 'features/controlLayers/konva/util';
import { layerRasterized } from 'features/controlLayers/store/canvasV2Slice'; import { layerRasterized } from 'features/controlLayers/store/canvasV2Slice';
import { import {
@ -24,31 +25,28 @@ import { uploadImage } from 'services/api/endpoints/images';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
export class CanvasLayer extends CanvasEntity { export class CanvasLayer extends CanvasEntity {
static NAME_PREFIX = 'layer'; static TYPE = 'layer';
static LAYER_NAME = `${CanvasLayer.NAME_PREFIX}_layer`; static LAYER_NAME = `${CanvasLayer.TYPE}_layer`;
static TRANSFORMER_NAME = `${CanvasLayer.NAME_PREFIX}_transformer`; static TRANSFORMER_NAME = `${CanvasLayer.TYPE}_transformer`;
static INTERACTION_RECT_NAME = `${CanvasLayer.NAME_PREFIX}_interaction-rect`; static INTERACTION_RECT_NAME = `${CanvasLayer.TYPE}_interaction-rect`;
static GROUP_NAME = `${CanvasLayer.NAME_PREFIX}_group`; static GROUP_NAME = `${CanvasLayer.TYPE}_group`;
static OBJECT_GROUP_NAME = `${CanvasLayer.NAME_PREFIX}_object-group`; static OBJECT_GROUP_NAME = `${CanvasLayer.TYPE}_object-group`;
static BBOX_NAME = `${CanvasLayer.NAME_PREFIX}_bbox`; static BBOX_NAME = `${CanvasLayer.TYPE}_bbox`;
_drawingBuffer: BrushLine | EraserLine | RectShape | null; drawingBuffer: BrushLine | EraserLine | RectShape | null;
_state: LayerEntity; state: LayerEntity;
type = 'layer';
konva: { konva: {
layer: Konva.Layer; layer: Konva.Layer;
bbox: Konva.Rect; bbox: Konva.Rect;
objectGroup: Konva.Group; objectGroup: Konva.Group;
transformer: Konva.Transformer;
interactionRect: Konva.Rect; interactionRect: Konva.Rect;
}; };
objects: Map<string, CanvasBrushLine | CanvasEraserLine | CanvasRect | CanvasImage>; objects: Map<string, CanvasBrushLine | CanvasEraserLine | CanvasRect | CanvasImage>;
transformer: CanvasTransformer;
_bboxNeedsUpdate: boolean; bboxNeedsUpdate: boolean;
_isFirstRender: boolean; isFirstRender: boolean;
isTransforming: boolean; isTransforming: boolean;
isPendingBboxCalculation: boolean; isPendingBboxCalculation: boolean;
@ -57,7 +55,7 @@ export class CanvasLayer extends CanvasEntity {
constructor(state: LayerEntity, manager: CanvasManager) { constructor(state: LayerEntity, manager: CanvasManager) {
super(state.id, manager); super(state.id, manager);
this._log.debug({ state }, 'Creating layer'); this.log.debug({ state }, 'Creating layer');
this.konva = { this.konva = {
layer: new Konva.Layer({ id: this.id, name: CanvasLayer.LAYER_NAME, listening: false }), layer: new Konva.Layer({ id: this.id, name: CanvasLayer.LAYER_NAME, listening: false }),
@ -70,17 +68,6 @@ export class CanvasLayer extends CanvasEntity {
strokeHitEnabled: false, strokeHitEnabled: false,
}), }),
objectGroup: new Konva.Group({ name: CanvasLayer.OBJECT_GROUP_NAME, listening: 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({ interactionRect: new Konva.Rect({
name: CanvasLayer.INTERACTION_RECT_NAME, name: CanvasLayer.INTERACTION_RECT_NAME,
listening: false, 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.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.interactionRect);
this.konva.layer.add(this.konva.bbox); 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', () => { this.konva.interactionRect.on('dragmove', () => {
// Snap the interaction rect to the nearest pixel // Snap the interaction rect to the nearest pixel
this.konva.interactionRect.x(Math.round(this.konva.interactionRect.x())); 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 // The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding
// and border // and border
this.konva.bbox.setAttrs({ this.konva.bbox.setAttrs({
x: this.konva.interactionRect.x() - this._manager.getScaledBboxPadding(), x: this.konva.interactionRect.x() - this.manager.getScaledBboxPadding(),
y: this.konva.interactionRect.y() - 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 // 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, y: this.konva.interactionRect.y() - this.bbox.y,
}; };
this._log.trace({ position }, 'Position changed'); this.log.trace({ position }, 'Position changed');
this._manager.stateApi.onPosChanged({ id: this.id, position }, 'layer'); this.manager.stateApi.onPosChanged({ id: this.id, position }, 'layer');
}); });
this.objects = new Map(); this.objects = new Map();
this._drawingBuffer = null; this.drawingBuffer = null;
this._state = state; this.state = state;
this.rect = this.getDefaultRect(); this.rect = this.getDefaultRect();
this.bbox = this.getDefaultRect(); this.bbox = this.getDefaultRect();
this._bboxNeedsUpdate = true; this.bboxNeedsUpdate = true;
this.isTransforming = false; this.isTransforming = false;
this._isFirstRender = true; this.isFirstRender = true;
this.isPendingBboxCalculation = false; 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 { destroy = (): void => {
this._log.debug('Destroying layer'); this.log.debug('Destroying layer');
this.konva.layer.destroy(); this.konva.layer.destroy();
} };
getDrawingBuffer() { getDrawingBuffer = () => {
return this._drawingBuffer; return this.drawingBuffer;
} };
async setDrawingBuffer(obj: BrushLine | EraserLine | RectShape | null) {
setDrawingBuffer = async (obj: BrushLine | EraserLine | RectShape | null) => {
if (obj) { if (obj) {
this._drawingBuffer = obj; this.drawingBuffer = obj;
await this._renderObject(this._drawingBuffer, true); await this._renderObject(this.drawingBuffer, true);
} else { } else {
this._drawingBuffer = null; this.drawingBuffer = null;
} }
} };
async finalizeDrawingBuffer() { finalizeDrawingBuffer = async () => {
if (!this._drawingBuffer) { if (!this.drawingBuffer) {
return; return;
} }
const drawingBuffer = this._drawingBuffer; const drawingBuffer = this.drawingBuffer;
await this.setDrawingBuffer(null); 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 // 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') { if (drawingBuffer.type === 'brush_line') {
drawingBuffer.id = getPrefixedId('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') { } else if (drawingBuffer.type === 'eraser_line') {
drawingBuffer.id = getPrefixedId('brush_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') { } else if (drawingBuffer.type === 'rect_shape') {
drawingBuffer.id = getPrefixedId('brush_line'); 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 }) { update = async (arg?: { state: LayerEntity; toolState: CanvasV2State['tool']; isSelected: boolean }) => {
const state = get(arg, 'state', this._state); const state = get(arg, 'state', this.state);
const toolState = get(arg, 'toolState', this._manager.stateApi.getToolState()); const toolState = get(arg, 'toolState', this.manager.stateApi.getToolState());
const isSelected = get(arg, 'isSelected', this._manager.stateApi.getIsSelected(this.id)); const isSelected = get(arg, 'isSelected', this.manager.stateApi.getIsSelected(this.id));
if (!this._isFirstRender && state === this._state) { if (!this.isFirstRender && state === this.state) {
this._log.trace('State unchanged, skipping update'); this.log.trace('State unchanged, skipping update');
return; return;
} }
this._log.debug('Updating'); this.log.debug('Updating');
const { position, objects, opacity, isEnabled } = state; const { position, objects, opacity, isEnabled } = state;
if (this._isFirstRender || objects !== this._state.objects) { if (this.isFirstRender || objects !== this.state.objects) {
await this.updateObjects({ objects }); await this.updateObjects({ objects });
} }
if (this._isFirstRender || position !== this._state.position) { if (this.isFirstRender || position !== this.state.position) {
await this.updatePosition({ position }); await this.updatePosition({ position });
} }
if (this._isFirstRender || opacity !== this._state.opacity) { if (this.isFirstRender || opacity !== this.state.opacity) {
await this.updateOpacity({ 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.updateVisibility({ isEnabled });
} }
await this.updateInteraction({ toolState, isSelected }); await this.updateInteraction({ toolState, isSelected });
if (this._isFirstRender) { if (this.isFirstRender) {
await this.updateBbox(); await this.updateBbox();
} }
this._state = state; this.state = state;
this._isFirstRender = false; this.isFirstRender = false;
} };
updateVisibility(arg?: { isEnabled: boolean }) { updateVisibility = (arg?: { isEnabled: boolean }) => {
this._log.trace('Updating visibility'); this.log.trace('Updating visibility');
const isEnabled = get(arg, 'isEnabled', this._state.isEnabled); const isEnabled = get(arg, 'isEnabled', this.state.isEnabled);
const hasObjects = this.objects.size > 0 || this._drawingBuffer !== null; const hasObjects = this.objects.size > 0 || this.drawingBuffer !== null;
this.konva.layer.visible(isEnabled && hasObjects); this.konva.layer.visible(isEnabled && hasObjects);
} };
updatePosition(arg?: { position: Coordinate }) { updatePosition = (arg?: { position: Coordinate }) => {
this._log.trace('Updating position'); this.log.trace('Updating position');
const position = get(arg, 'position', this._state.position); const position = get(arg, 'position', this.state.position);
const bboxPadding = this._manager.getScaledBboxPadding(); const bboxPadding = this.manager.getScaledBboxPadding();
this.konva.objectGroup.setAttrs({ this.konva.objectGroup.setAttrs({
x: position.x + this.bbox.x, 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(), x: position.x + this.bbox.x * this.konva.interactionRect.scaleX(),
y: position.y + this.bbox.y * this.konva.interactionRect.scaleY(), y: position.y + this.bbox.y * this.konva.interactionRect.scaleY(),
}); });
} };
async updateObjects(arg?: { objects: LayerEntity['objects'] }) { updateObjects = async (arg?: { objects: LayerEntity['objects'] }) => {
this._log.trace('Updating 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); const objectIds = objects.map(mapId);
@ -378,7 +243,7 @@ export class CanvasLayer extends CanvasEntity {
// Destroy any objects that are no longer in state // Destroy any objects that are no longer in state
for (const object of this.objects.values()) { 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); this.objects.delete(object.id);
object.destroy(); object.destroy();
didUpdate = true; didUpdate = true;
@ -391,8 +256,8 @@ export class CanvasLayer extends CanvasEntity {
} }
} }
if (this._drawingBuffer) { if (this.drawingBuffer) {
if (await this._renderObject(this._drawingBuffer)) { if (await this._renderObject(this.drawingBuffer)) {
didUpdate = true; didUpdate = true;
} }
} }
@ -401,20 +266,20 @@ export class CanvasLayer extends CanvasEntity {
this.calculateBbox(); this.calculateBbox();
} }
this._isFirstRender = false; this.isFirstRender = false;
} };
updateOpacity(arg?: { opacity: number }) { updateOpacity = (arg?: { opacity: number }) => {
this._log.trace('Updating opacity'); this.log.trace('Updating opacity');
const opacity = get(arg, 'opacity', this._state.opacity); const opacity = get(arg, 'opacity', this.state.opacity);
this.konva.objectGroup.opacity(opacity); this.konva.objectGroup.opacity(opacity);
} };
updateInteraction(arg?: { toolState: CanvasV2State['tool']; isSelected: boolean }) { updateInteraction = (arg?: { toolState: CanvasV2State['tool']; isSelected: boolean }) => {
this._log.trace('Updating interaction'); this.log.trace('Updating interaction');
const toolState = get(arg, 'toolState', this._manager.stateApi.getToolState()); const toolState = get(arg, 'toolState', this.manager.stateApi.getToolState());
const isSelected = get(arg, 'isSelected', this._manager.stateApi.getIsSelected(this.id)); const isSelected = get(arg, 'isSelected', this.manager.stateApi.getIsSelected(this.id));
if (this.objects.size === 0) { if (this.objects.size === 0) {
// The layer is totally empty, we can just disable the layer // 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); this.konva.layer.listening(true);
// The transformer is not needed // The transformer is not needed
this.konva.transformer.listening(false); this.transformer.deactivate();
this.konva.transformer.nodes([]);
// The bbox rect should be visible and interaction rect listening for dragging // The bbox rect should be visible and interaction rect listening for dragging
this.konva.bbox.visible(true); this.konva.bbox.visible(true);
@ -440,10 +304,11 @@ export class CanvasLayer extends CanvasEntity {
const listening = toolState.selected !== 'view'; const listening = toolState.selected !== 'view';
this.konva.layer.listening(listening); this.konva.layer.listening(listening);
this.konva.interactionRect.listening(listening); this.konva.interactionRect.listening(listening);
this.konva.transformer.listening(listening); if (listening) {
this.transformer.activate();
// The transformer transforms the interaction rect, not the object group } else {
this.konva.transformer.nodes([this.konva.interactionRect]); this.transformer.deactivate();
}
// Hide the bbox rect, the transformer will has its own bbox // Hide the bbox rect, the transformer will has its own bbox
this.konva.bbox.visible(false); this.konva.bbox.visible(false);
@ -452,15 +317,14 @@ export class CanvasLayer extends CanvasEntity {
this.konva.layer.listening(false); this.konva.layer.listening(false);
// The transformer, bbox and interaction rect should be inactive // The transformer, bbox and interaction rect should be inactive
this.konva.transformer.listening(false); this.transformer.deactivate();
this.konva.transformer.nodes([]);
this.konva.bbox.visible(false); this.konva.bbox.visible(false);
this.konva.interactionRect.listening(false); this.konva.interactionRect.listening(false);
} }
} };
updateBbox() { updateBbox = () => {
this._log.trace('Updating bbox'); this.log.trace('Updating bbox');
if (this.isPendingBboxCalculation) { if (this.isPendingBboxCalculation) {
return; return;
@ -470,9 +334,9 @@ export class CanvasLayer extends CanvasEntity {
// eraser lines, fully clipped brush lines or if it has been fully erased. // eraser lines, fully clipped brush lines or if it has been fully erased.
if (this.bbox.width === 0 || this.bbox.height === 0) { 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 // 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 // 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.bbox.visible(false);
this.konva.interactionRect.visible(false); this.konva.interactionRect.visible(false);
@ -482,35 +346,35 @@ export class CanvasLayer extends CanvasEntity {
this.konva.bbox.visible(true); this.konva.bbox.visible(true);
this.konva.interactionRect.visible(true); this.konva.interactionRect.visible(true);
const onePixel = this._manager.getScaledPixel(); const onePixel = this.manager.getScaledPixel();
const bboxPadding = this._manager.getScaledBboxPadding(); const bboxPadding = this.manager.getScaledBboxPadding();
this.konva.bbox.setAttrs({ this.konva.bbox.setAttrs({
x: this._state.position.x + this.bbox.x - bboxPadding, x: this.state.position.x + this.bbox.x - bboxPadding,
y: this._state.position.y + this.bbox.y - bboxPadding, y: this.state.position.y + this.bbox.y - bboxPadding,
width: this.bbox.width + bboxPadding * 2, width: this.bbox.width + bboxPadding * 2,
height: this.bbox.height + bboxPadding * 2, height: this.bbox.height + bboxPadding * 2,
strokeWidth: onePixel, strokeWidth: onePixel,
}); });
this.konva.interactionRect.setAttrs({ this.konva.interactionRect.setAttrs({
x: this._state.position.x + this.bbox.x, x: this.state.position.x + this.bbox.x,
y: this._state.position.y + this.bbox.y, y: this.state.position.y + this.bbox.y,
width: this.bbox.width, width: this.bbox.width,
height: this.bbox.height, height: this.bbox.height,
}); });
this.konva.objectGroup.setAttrs({ this.konva.objectGroup.setAttrs({
x: this._state.position.x + this.bbox.x, x: this.state.position.x + this.bbox.x,
y: this._state.position.y + this.bbox.y, y: this.state.position.y + this.bbox.y,
offsetX: this.bbox.x, offsetX: this.bbox.x,
offsetY: this.bbox.y, offsetY: this.bbox.y,
}); });
} };
syncStageScale() { syncStageScale = () => {
this._log.trace('Syncing scale to stage'); this.log.trace('Syncing scale to stage');
const onePixel = this._manager.getScaledPixel(); const onePixel = this.manager.getScaledPixel();
const bboxPadding = this._manager.getScaledBboxPadding(); const bboxPadding = this.manager.getScaledBboxPadding();
this.konva.bbox.setAttrs({ this.konva.bbox.setAttrs({
x: this.konva.interactionRect.x() - bboxPadding, 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, height: this.konva.interactionRect.height() * this.konva.interactionRect.scaleY() + bboxPadding * 2,
strokeWidth: onePixel, 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') { if (obj.type === 'brush_line') {
let brushLine = this.objects.get(obj.id); let brushLine = this.objects.get(obj.id);
assert(brushLine instanceof CanvasBrushLine || brushLine === undefined); assert(brushLine instanceof CanvasBrushLine || brushLine === undefined);
@ -581,29 +444,26 @@ export class CanvasLayer extends CanvasEntity {
} }
return false; return false;
} };
startTransform() { startTransform = () => {
this._log.debug('Starting transform'); this.log.debug('Starting transform');
this.isTransforming = true; this.isTransforming = true;
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or // 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 // interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening
// when the view tool is selected // 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.layer.listening(listening);
this.konva.interactionRect.listening(listening); this.konva.interactionRect.listening(listening);
this.konva.transformer.listening(listening); this.transformer.activate();
// The transformer transforms the interaction rect, not the object group
this.konva.transformer.nodes([this.konva.interactionRect]);
// Hide the bbox rect, the transformer will has its own bbox // Hide the bbox rect, the transformer will has its own bbox
this.konva.bbox.visible(false); this.konva.bbox.visible(false);
} };
resetScale() { resetScale = () => {
const attrs = { const attrs = {
scaleX: 1, scaleX: 1,
scaleY: 1, scaleY: 1,
@ -612,16 +472,16 @@ export class CanvasLayer extends CanvasEntity {
this.konva.objectGroup.setAttrs(attrs); this.konva.objectGroup.setAttrs(attrs);
this.konva.bbox.setAttrs(attrs); this.konva.bbox.setAttrs(attrs);
this.konva.interactionRect.setAttrs(attrs); this.konva.interactionRect.setAttrs(attrs);
} };
async rasterizeLayer() { rasterizeLayer = async () => {
this._log.debug('Rasterizing layer'); this.log.debug('Rasterizing layer');
const objectGroupClone = this.konva.objectGroup.clone(); const objectGroupClone = this.konva.objectGroup.clone();
const interactionRectClone = this.konva.interactionRect.clone(); const interactionRectClone = this.konva.interactionRect.clone();
const rect = interactionRectClone.getClientRect(); const rect = interactionRectClone.getClientRect();
const blob = await konvaNodeToBlob(objectGroupClone, rect); const blob = await konvaNodeToBlob(objectGroupClone, rect);
if (this._manager._isDebugging) { if (this.manager._isDebugging) {
previewBlob(blob, 'Rasterized layer'); previewBlob(blob, 'Rasterized layer');
} }
const imageDTO = await uploadImage(blob, `${this.id}_rasterized.png`, 'other', true); const imageDTO = await uploadImage(blob, `${this.id}_rasterized.png`, 'other', true);
@ -635,29 +495,29 @@ export class CanvasLayer extends CanvasEntity {
} }
this.resetScale(); this.resetScale();
dispatch(layerRasterized({ id: this.id, imageObject, position: { x: rect.x, y: rect.y } })); dispatch(layerRasterized({ id: this.id, imageObject, position: { x: rect.x, y: rect.y } }));
} };
stopTransform() { stopTransform = () => {
this._log.debug('Stopping transform'); this.log.debug('Stopping transform');
this.isTransforming = false; this.isTransforming = false;
this.resetScale(); this.resetScale();
this.updatePosition(); this.updatePosition();
this.updateBbox(); this.updateBbox();
this.updateInteraction(); this.updateInteraction();
} };
getDefaultRect(): Rect { getDefaultRect = (): Rect => {
return { x: 0, y: 0, width: 0, height: 0 }; return { x: 0, y: 0, width: 0, height: 0 };
} };
calculateBbox = debounce(() => { calculateBbox = debounce(() => {
this._log.debug('Calculating bbox'); this.log.debug('Calculating bbox');
this.isPendingBboxCalculation = true; this.isPendingBboxCalculation = true;
if (this.objects.size === 0) { if (this.objects.size === 0) {
this._log.trace('No objects, resetting bbox'); this.log.trace('No objects, resetting bbox');
this.rect = this.getDefaultRect(); this.rect = this.getDefaultRect();
this.bbox = this.getDefaultRect(); this.bbox = this.getDefaultRect();
this.isPendingBboxCalculation = false; this.isPendingBboxCalculation = false;
@ -694,7 +554,7 @@ export class CanvasLayer extends CanvasEntity {
this.rect = deepClone(rect); this.rect = deepClone(rect);
this.bbox = deepClone(rect); this.bbox = deepClone(rect);
this.isPendingBboxCalculation = false; 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(); this.updateBbox();
return; return;
} }
@ -708,7 +568,7 @@ export class CanvasLayer extends CanvasEntity {
return; return;
} }
const imageData = ctx.getImageData(0, 0, rect.width, rect.height); 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 }, { buffer: imageData.data.buffer, width: imageData.width, height: imageData.height },
(extents) => { (extents) => {
if (extents) { if (extents) {
@ -725,27 +585,27 @@ export class CanvasLayer extends CanvasEntity {
this.rect = this.getDefaultRect(); this.rect = this.getDefaultRect();
} }
this.isPendingBboxCalculation = false; 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(); this.updateBbox();
clone.destroy(); clone.destroy();
} }
); );
}, CanvasManager.BBOX_DEBOUNCE_MS); }, CanvasManager.BBOX_DEBOUNCE_MS);
repr() { repr = () => {
return { return {
id: this.id, id: this.id,
type: this.type, type: CanvasLayer.TYPE,
state: deepClone(this._state), state: deepClone(this.state),
rect: deepClone(this.rect), rect: deepClone(this.rect),
bbox: deepClone(this.bbox), bbox: deepClone(this.bbox),
bboxNeedsUpdate: this._bboxNeedsUpdate, bboxNeedsUpdate: this.bboxNeedsUpdate,
isFirstRender: this._isFirstRender, isFirstRender: this.isFirstRender,
isTransforming: this.isTransforming, isTransforming: this.isTransforming,
isPendingBboxCalculation: this.isPendingBboxCalculation, isPendingBboxCalculation: this.isPendingBboxCalculation,
objects: Array.from(this.objects.values()).map((obj) => obj.repr()), objects: Array.from(this.objects.values()).map((obj) => obj.repr()),
}; };
} };
logDebugInfo(msg = 'Debug info') { logDebugInfo(msg = 'Debug info') {
const info = { const info = {
@ -771,6 +631,6 @@ export class CanvasLayer extends CanvasEntity {
offsetY: this.konva.objectGroup.offsetY(), 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 { JSONObject } from 'common/types';
import type { CanvasControlAdapter } from 'features/controlLayers/konva/CanvasControlAdapter';
import type { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer'; import type { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { CanvasStagingArea } from 'features/controlLayers/konva/CanvasStagingArea'; import type { CanvasStagingArea } from 'features/controlLayers/konva/CanvasStagingArea';
@ -7,27 +8,17 @@ import type { Logger } from 'roarr';
export abstract class CanvasObject { export abstract class CanvasObject {
id: string; id: string;
_parent: CanvasLayer | CanvasStagingArea; parent: CanvasLayer | CanvasStagingArea | CanvasControlAdapter;
_manager: CanvasManager; manager: CanvasManager;
_log: Logger; log: Logger;
constructor(id: string, parent: CanvasLayer | CanvasStagingArea) { constructor(id: string, parent: CanvasLayer | CanvasStagingArea | CanvasControlAdapter) {
this.id = id; this.id = id;
this._parent = parent; this.parent = parent;
this._manager = parent._manager; this.manager = parent.manager;
this._log = this._manager.buildLogger(this._getLoggingContext); 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. * Get a serializable representation of the object.
*/ */
@ -38,9 +29,9 @@ export abstract class CanvasObject {
* @param extra Extra data to merge into the context * @param extra Extra data to merge into the context
* @returns The logging context for this object * @returns The logging context for this object
*/ */
_getLoggingContext = (extra?: Record<string, unknown>) => { getLoggingContext = (extra?: Record<string, unknown>) => {
return { return {
...this._parent._getLoggingContext(), ...this.parent.getLoggingContext(),
objectId: this.id, objectId: this.id,
...extra, ...extra,
}; };

View File

@ -19,7 +19,7 @@ export class CanvasRect extends CanvasObject {
constructor(state: RectShape, parent: CanvasLayer) { constructor(state: RectShape, parent: CanvasLayer) {
super(state.id, parent); super(state.id, parent);
this._log.trace({ state }, 'Creating rect'); this.log.trace({ state }, 'Creating rect');
const { x, y, width, height, color } = state; const { x, y, width, height, color } = state;
@ -41,7 +41,7 @@ export class CanvasRect extends CanvasObject {
update(state: RectShape, force?: boolean): boolean { update(state: RectShape, force?: boolean): boolean {
if (this.state !== state || force) { if (this.state !== state || force) {
this._log.trace({ state }, 'Updating rect'); this.log.trace({ state }, 'Updating rect');
const { x, y, width, height, color } = state; const { x, y, width, height, color } = state;
this.konva.rect.setAttrs({ this.konva.rect.setAttrs({
x, x,
@ -58,12 +58,12 @@ export class CanvasRect extends CanvasObject {
} }
destroy() { destroy() {
this._log.trace('Destroying rect'); this.log.trace('Destroying rect');
this.konva.group.destroy(); this.konva.group.destroy();
} }
setVisibility(isVisible: boolean): void { setVisibility(isVisible: boolean): void {
this._log.trace({ isVisible }, 'Setting rect visibility'); this.log.trace({ isVisible }, 'Setting rect visibility');
this.konva.group.visible(isVisible); this.konva.group.visible(isVisible);
} }
@ -71,7 +71,7 @@ export class CanvasRect extends CanvasObject {
return { return {
id: this.id, id: this.id,
type: CanvasRect.TYPE, type: CanvasRect.TYPE,
parent: this._parent.id, parent: this.parent.id,
state: deepClone(this.state), state: deepClone(this.state),
}; };
} }

View File

@ -22,9 +22,9 @@ export class CanvasStagingArea extends CanvasEntity {
} }
async render() { async render() {
const session = this._manager.stateApi.getSession(); const session = this.manager.stateApi.getSession();
const bboxRect = this._manager.stateApi.getBbox().rect; const bboxRect = this.manager.stateApi.getBbox().rect;
const shouldShowStagedImage = this._manager.stateApi.getShouldShowStagedImage(); const shouldShowStagedImage = this.manager.stateApi.getShouldShowStagedImage();
this.selectedImage = session.stagedImages[session.selectedStagedImageIndex] ?? null; 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.x(bboxRect.x + offsetX);
this.image.konva.group.y(bboxRect.y + offsetY); this.image.konva.group.y(bboxRect.y + offsetY);
await this.image.updateImageSource(imageDTO.image_name); await this.image.updateImageSource(imageDTO.image_name);
this._manager.stateApi.resetLastProgressEvent(); this.manager.stateApi.resetLastProgressEvent();
} }
this.image.konva.group.visible(shouldShowStagedImage); this.image.konva.group.visible(shouldShowStagedImage);
} else { } 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,
};
};
}