mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): split & document transformer logic, iterate on class structures
This commit is contained in:
parent
5c5a405c0f
commit
cf83af7a27
@ -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),
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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(),
|
||||
};
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user