feat(ui): wip transform mode

This commit is contained in:
psychedelicious 2024-07-26 18:28:02 +10:00
parent 7f9a31ca4a
commit 65353ac1e1
13 changed files with 202 additions and 270 deletions

View File

@ -9,21 +9,22 @@ import { PiBoundingBoxBold } from 'react-icons/pi';
export const BboxToolButton = memo(() => { export const BboxToolButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging); const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging || s.canvasV2.tool.isTransforming);
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'bbox'); const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'bbox');
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(toolChanged('bbox')); dispatch(toolChanged('bbox'));
}, [dispatch]); }, [dispatch]);
useHotkeys('q', onClick, [onClick]); useHotkeys('q', onClick, { enabled: !isDisabled }, [onClick, isDisabled]);
return ( return (
<IconButton <IconButton
aria-label={`${t('controlLayers.bbox')} (Q)`} aria-label={`${t('controlLayers.bbox')} (Q)`}
tooltip={`${t('controlLayers.bbox')} (Q)`} tooltip={`${t('controlLayers.bbox')} (Q)`}
icon={<PiBoundingBoxBold />} icon={<PiBoundingBoxBold />}
variant={isSelected ? 'solid' : 'outline'} colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick} onClick={onClick}
isDisabled={isDisabled} isDisabled={isDisabled}
/> />

View File

@ -15,13 +15,13 @@ export const BrushToolButton = memo(() => {
const entityType = s.canvasV2.selectedEntityIdentifier?.type; const entityType = s.canvasV2.selectedEntityIdentifier?.type;
const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false; const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false;
const isStaging = s.canvasV2.session.isStaging; const isStaging = s.canvasV2.session.isStaging;
return !isDrawingToolAllowed || isStaging; return !isDrawingToolAllowed || isStaging || s.canvasV2.tool.isTransforming;
}); });
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(toolChanged('brush')); dispatch(toolChanged('brush'));
}, [dispatch]); }, [dispatch]);
useHotkeys('b', onClick, { enabled: !isDisabled }, [isDisabled, onClick]); useHotkeys('b', onClick, { enabled: !isDisabled }, [isDisabled, onClick]);
return ( return (
@ -29,7 +29,8 @@ export const BrushToolButton = memo(() => {
aria-label={`${t('unifiedCanvas.brush')} (B)`} aria-label={`${t('unifiedCanvas.brush')} (B)`}
tooltip={`${t('unifiedCanvas.brush')} (B)`} tooltip={`${t('unifiedCanvas.brush')} (B)`}
icon={<PiPaintBrushBold />} icon={<PiPaintBrushBold />}
variant={isSelected ? 'solid' : 'outline'} colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick} onClick={onClick}
isDisabled={isDisabled} isDisabled={isDisabled}
/> />

View File

@ -15,7 +15,7 @@ export const EraserToolButton = memo(() => {
const entityType = s.canvasV2.selectedEntityIdentifier?.type; const entityType = s.canvasV2.selectedEntityIdentifier?.type;
const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false; const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false;
const isStaging = s.canvasV2.session.isStaging; const isStaging = s.canvasV2.session.isStaging;
return !isDrawingToolAllowed || isStaging; return !isDrawingToolAllowed || isStaging || s.canvasV2.tool.isTransforming;
}); });
const onClick = useCallback(() => { const onClick = useCallback(() => {
@ -29,7 +29,8 @@ export const EraserToolButton = memo(() => {
aria-label={`${t('unifiedCanvas.eraser')} (E)`} aria-label={`${t('unifiedCanvas.eraser')} (E)`}
tooltip={`${t('unifiedCanvas.eraser')} (E)`} tooltip={`${t('unifiedCanvas.eraser')} (E)`}
icon={<PiEraserBold />} icon={<PiEraserBold />}
variant={isSelected ? 'solid' : 'outline'} colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick} onClick={onClick}
isDisabled={isDisabled} isDisabled={isDisabled}
/> />

View File

@ -11,7 +11,7 @@ export const MoveToolButton = memo(() => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'move'); const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'move');
const isDisabled = useAppSelector( const isDisabled = useAppSelector(
(s) => s.canvasV2.selectedEntityIdentifier === null || s.canvasV2.session.isStaging (s) => s.canvasV2.selectedEntityIdentifier === null || s.canvasV2.session.isStaging || s.canvasV2.tool.isTransforming
); );
const onClick = useCallback(() => { const onClick = useCallback(() => {
@ -25,7 +25,8 @@ export const MoveToolButton = memo(() => {
aria-label={`${t('unifiedCanvas.move')} (V)`} aria-label={`${t('unifiedCanvas.move')} (V)`}
tooltip={`${t('unifiedCanvas.move')} (V)`} tooltip={`${t('unifiedCanvas.move')} (V)`}
icon={<PiCursorBold />} icon={<PiCursorBold />}
variant={isSelected ? 'solid' : 'outline'} colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick} onClick={onClick}
isDisabled={isDisabled} isDisabled={isDisabled}
/> />

View File

@ -15,7 +15,7 @@ export const RectToolButton = memo(() => {
const entityType = s.canvasV2.selectedEntityIdentifier?.type; const entityType = s.canvasV2.selectedEntityIdentifier?.type;
const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false; const isDrawingToolAllowed = entityType ? isDrawableEntityType(entityType) : false;
const isStaging = s.canvasV2.session.isStaging; const isStaging = s.canvasV2.session.isStaging;
return !isDrawingToolAllowed || isStaging; return !isDrawingToolAllowed || isStaging || s.canvasV2.tool.isTransforming;
}); });
const onClick = useCallback(() => { const onClick = useCallback(() => {
@ -29,7 +29,8 @@ export const RectToolButton = memo(() => {
aria-label={`${t('controlLayers.rectangle')} (U)`} aria-label={`${t('controlLayers.rectangle')} (U)`}
tooltip={`${t('controlLayers.rectangle')} (U)`} tooltip={`${t('controlLayers.rectangle')} (U)`}
icon={<PiRectangleBold />} icon={<PiRectangleBold />}
variant={isSelected ? 'solid' : 'outline'} colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick} onClick={onClick}
isDisabled={isDisabled} isDisabled={isDisabled}
/> />

View File

@ -14,23 +14,26 @@ export const ToolChooser: React.FC = () => {
useCanvasResetLayerHotkey(); useCanvasResetLayerHotkey();
useCanvasDeleteLayerHotkey(); useCanvasDeleteLayerHotkey();
const isCanvasSessionActive = useAppSelector((s) => s.canvasV2.session.isActive); const isCanvasSessionActive = useAppSelector((s) => s.canvasV2.session.isActive);
const isTransforming = useAppSelector((s) => s.canvasV2.tool.isTransforming);
if (isCanvasSessionActive) { if (isCanvasSessionActive) {
return ( return (
<ButtonGroup isAttached> <>
<BrushToolButton /> <ButtonGroup isAttached isDisabled={isTransforming}>
<EraserToolButton /> <BrushToolButton />
<RectToolButton /> <EraserToolButton />
<MoveToolButton /> <RectToolButton />
<MoveToolButton />
<ViewToolButton />
<BboxToolButton />
</ButtonGroup>
<TransformToolButton /> <TransformToolButton />
<ViewToolButton /> </>
<BboxToolButton />
</ButtonGroup>
); );
} }
return ( return (
<ButtonGroup isAttached> <ButtonGroup isAttached isDisabled={isTransforming}>
<BrushToolButton /> <BrushToolButton />
<EraserToolButton /> <EraserToolButton />
<RectToolButton /> <RectToolButton />

View File

@ -1,6 +1,6 @@
import { IconButton } from '@invoke-ai/ui-library'; import { Button, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { toolChanged } from 'features/controlLayers/store/canvasV2Slice'; import { toolIsTransformingChanged } from 'features/controlLayers/store/canvasV2Slice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,24 +9,41 @@ import { PiResizeBold } from 'react-icons/pi';
export const TransformToolButton = memo(() => { export const TransformToolButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'transform'); const isTransforming = useAppSelector((s) => s.canvasV2.tool.isTransforming);
const isDisabled = useAppSelector( const isDisabled = useAppSelector(
(s) => s.canvasV2.selectedEntityIdentifier === null || s.canvasV2.session.isStaging (s) => s.canvasV2.selectedEntityIdentifier === null || s.canvasV2.session.isStaging
); );
const onClick = useCallback(() => { const onTransform = useCallback(() => {
dispatch(toolChanged('transform')); dispatch(toolIsTransformingChanged(true));
}, [dispatch]); }, [dispatch]);
useHotkeys(['ctrl+t', 'meta+t'], onClick, { enabled: !isDisabled }, [isDisabled, onClick]); const onApplyTransformation = useCallback(() => {
false && dispatch(toolIsTransformingChanged(true));
}, [dispatch]);
const onCancelTransformation = useCallback(() => {
dispatch(toolIsTransformingChanged(false));
}, [dispatch]);
useHotkeys(['ctrl+t', 'meta+t'], onTransform, { enabled: !isDisabled }, [isDisabled, onTransform]);
if (isTransforming) {
return (
<>
<Button onClick={onApplyTransformation}>Apply</Button>
<Button onClick={onCancelTransformation}>Cancel</Button>
</>
);
}
return ( return (
<IconButton <IconButton
aria-label={`${t('unifiedCanvas.transform')} (Ctrl+T)`} aria-label={`${t('unifiedCanvas.transform')} (Ctrl+T)`}
tooltip={`${t('unifiedCanvas.transform')} (Ctrl+T)`} tooltip={`${t('unifiedCanvas.transform')} (Ctrl+T)`}
icon={<PiResizeBold />} icon={<PiResizeBold />}
variant={isSelected ? 'solid' : 'outline'} variant="solid"
onClick={onClick} onClick={onTransform}
isDisabled={isDisabled} isDisabled={isDisabled}
/> />
); );

View File

@ -10,19 +10,20 @@ export const ViewToolButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'view'); const isSelected = useAppSelector((s) => s.canvasV2.tool.selected === 'view');
const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging); const isDisabled = useAppSelector((s) => s.canvasV2.session.isStaging || s.canvasV2.tool.isTransforming);
const onClick = useCallback(() => { const onClick = useCallback(() => {
dispatch(toolChanged('view')); dispatch(toolChanged('view'));
}, [dispatch]); }, [dispatch]);
useHotkeys('h', onClick, [onClick]); useHotkeys('h', onClick, { enabled: !isDisabled }, [onClick]);
return ( return (
<IconButton <IconButton
aria-label={`${t('unifiedCanvas.view')} (H)`} aria-label={`${t('unifiedCanvas.view')} (H)`}
tooltip={`${t('unifiedCanvas.view')} (H)`} tooltip={`${t('unifiedCanvas.view')} (H)`}
icon={<PiHandBold />} icon={<PiHandBold />}
variant={isSelected ? 'solid' : 'outline'} colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="outline"
onClick={onClick} onClick={onClick}
isDisabled={isDisabled} isDisabled={isDisabled}
/> />

View File

@ -5,14 +5,12 @@ import { CanvasImage } from 'features/controlLayers/konva/CanvasImage';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasRect } from 'features/controlLayers/konva/CanvasRect'; import { CanvasRect } from 'features/controlLayers/konva/CanvasRect';
import { mapId } from 'features/controlLayers/konva/util'; import { mapId } from 'features/controlLayers/konva/util';
import type { BrushLine, EraserLine, LayerEntity, Rect, RectShape } from 'features/controlLayers/store/types'; import type { BrushLine, EraserLine, LayerEntity, RectShape } from 'features/controlLayers/store/types';
import { isDrawingTool } from 'features/controlLayers/store/types'; import { isDrawingTool } from 'features/controlLayers/store/types';
import Konva from 'konva'; import Konva from 'konva';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
const MIN_LAYER_SIZE_PX = 10;
export class CanvasLayer { export class CanvasLayer {
static NAME_PREFIX = 'layer'; static NAME_PREFIX = 'layer';
static LAYER_NAME = `${CanvasLayer.NAME_PREFIX}_layer`; static LAYER_NAME = `${CanvasLayer.NAME_PREFIX}_layer`;
@ -34,14 +32,15 @@ export class CanvasLayer {
layer: Konva.Layer; layer: Konva.Layer;
bbox: Konva.Rect; bbox: Konva.Rect;
objectGroup: Konva.Group; objectGroup: Konva.Group;
objectGroupBbox: Konva.Rect;
positionXLine: Konva.Line;
positionYLine: Konva.Line;
transformer: Konva.Transformer; transformer: Konva.Transformer;
interactionRect: Konva.Rect; interactionRect: Konva.Rect;
}; };
objects: Map<string, CanvasBrushLine | CanvasEraserLine | CanvasRect | CanvasImage>; objects: Map<string, CanvasBrushLine | CanvasEraserLine | CanvasRect | CanvasImage>;
bbox: Rect;
offsetX: number;
offsetY: number;
width: number;
height: number;
getBbox = debounce(this._getBbox, 300); getBbox = debounce(this._getBbox, 300);
@ -59,18 +58,16 @@ export class CanvasLayer {
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 }),
objectGroupBbox: new Konva.Rect({ fill: 'green', opacity: 0.5, listening: false }),
positionXLine: new Konva.Line({ stroke: 'white', strokeWidth: 1 }),
positionYLine: new Konva.Line({ stroke: 'white', strokeWidth: 1 }),
transformer: new Konva.Transformer({ transformer: new Konva.Transformer({
name: CanvasLayer.TRANSFORMER_NAME, name: CanvasLayer.TRANSFORMER_NAME,
draggable: false, draggable: true,
// enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'], // enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
rotateEnabled: false, rotateEnabled: true,
flipEnabled: false, flipEnabled: true,
listening: false, listening: false,
padding: CanvasLayer.BBOX_PADDING_PX, padding: CanvasLayer.BBOX_PADDING_PX,
stroke: 'hsl(200deg 76% 59%)', // invokeBlue.400 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,
@ -84,9 +81,6 @@ export class CanvasLayer {
this.konva.layer.add(this.konva.transformer); this.konva.layer.add(this.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.layer.add(this.konva.objectGroupBbox);
this.konva.layer.add(this.konva.positionXLine);
this.konva.layer.add(this.konva.positionYLine);
this.konva.transformer.on('transformstart', () => { this.konva.transformer.on('transformstart', () => {
console.log('>>> transformstart'); console.log('>>> transformstart');
@ -98,35 +92,35 @@ export class CanvasLayer {
width: this.konva.interactionRect.width(), width: this.konva.interactionRect.width(),
height: this.konva.interactionRect.height(), height: this.konva.interactionRect.height(),
}); });
console.log('this.bbox', deepClone(this.bbox)); this.logBbox('transformstart bbox');
console.log('this.state.position', this.state.position); console.log('this.state.position', this.state.position);
}); });
this.konva.transformer.on('transform', () => { this.konva.transformer.on('transform', () => {
// Always snap the interaction rect to the nearest pixel when transforming // Always snap the interaction rect to the nearest pixel when transforming
const x = Math.round(this.konva.interactionRect.x()); // const x = Math.round(this.konva.interactionRect.x());
const y = Math.round(this.konva.interactionRect.y()); // const y = Math.round(this.konva.interactionRect.y());
// Snap its position // // Snap its position
this.konva.interactionRect.x(x); // this.konva.interactionRect.x(x);
this.konva.interactionRect.y(y); // this.konva.interactionRect.y(y);
// Calculate the new scale of the interaction rect such that its width and height snap to the nearest pixel // // Calculate the new scale of the interaction rect such that its width and height snap to the nearest pixel
const targetWidth = Math.max( // const targetWidth = Math.max(
Math.round(this.konva.interactionRect.width() * Math.abs(this.konva.interactionRect.scaleX())), // Math.round(this.konva.interactionRect.width() * Math.abs(this.konva.interactionRect.scaleX())),
MIN_LAYER_SIZE_PX // MIN_LAYER_SIZE_PX
); // );
const scaleX = targetWidth / this.konva.interactionRect.width(); // const scaleX = targetWidth / this.konva.interactionRect.width();
const targetHeight = Math.max( // const targetHeight = Math.max(
Math.round(this.konva.interactionRect.height() * Math.abs(this.konva.interactionRect.scaleY())), // Math.round(this.konva.interactionRect.height() * Math.abs(this.konva.interactionRect.scaleY())),
MIN_LAYER_SIZE_PX // MIN_LAYER_SIZE_PX
); // );
const scaleY = targetHeight / this.konva.interactionRect.height(); // const scaleY = targetHeight / this.konva.interactionRect.height();
// Snap the width and height (via scale) of the interaction rect // // Snap the width and height (via scale) of the interaction rect
this.konva.interactionRect.scaleX(scaleX); // this.konva.interactionRect.scaleX(scaleX);
this.konva.interactionRect.scaleY(scaleY); // this.konva.interactionRect.scaleY(scaleY);
this.konva.interactionRect.rotation(0); // this.konva.interactionRect.rotation(0);
console.log('>>> transform'); console.log('>>> transform');
console.log('activeAnchor', this.konva.transformer.getActiveAnchor()); console.log('activeAnchor', this.konva.transformer.getActiveAnchor());
@ -140,119 +134,20 @@ export class CanvasLayer {
rotation: this.konva.interactionRect.rotation(), rotation: this.konva.interactionRect.rotation(),
}); });
// Handle anchor-specific transformations of the layer's objects this.konva.objectGroup.setAttrs({
const anchor = this.konva.transformer.getActiveAnchor(); x: this.konva.interactionRect.x(),
// 'top-left' y: this.konva.interactionRect.y(),
// 'top-center' scaleX: this.konva.interactionRect.scaleX(),
// 'top-right' scaleY: this.konva.interactionRect.scaleY(),
// 'middle-right' rotation: this.konva.interactionRect.rotation(),
// 'middle-left'
// 'bottom-left'
// 'bottom-center'
// 'bottom-right'
if (anchor === 'middle-right') {
// Dragging the anchor to the right
this.konva.objectGroup.setAttrs({
scaleX,
x: x - (x - this.state.position.x) * scaleX,
});
} else if (anchor === 'middle-left') {
// Dragging the anchor to the right
this.konva.objectGroup.setAttrs({
scaleX,
x: x - (x - this.state.position.x) * scaleX,
});
} else if (anchor === 'bottom-center') {
// Resize the interaction rect downwards
this.konva.objectGroup.setAttrs({
scaleY,
y: y - (y - this.state.position.y) * scaleY,
});
} else if (anchor === 'bottom-right') {
// Resize the interaction rect to the right and downwards via scale
this.konva.objectGroup.setAttrs({
scaleX,
scaleY,
x: x - (x - this.state.position.x) * scaleX,
y: y - (y - this.state.position.y) * scaleY,
});
} else if (anchor === 'top-center') {
// Resize the interaction rect to the upwards via scale & y position
this.konva.objectGroup.setAttrs({
y,
scaleY,
});
}
this.konva.objectGroupBbox.setAttrs({
x: this.konva.objectGroup.x(),
y: this.konva.objectGroup.y(),
rotation: this.konva.objectGroup.rotation(),
scaleX: this.konva.objectGroup.scaleX(),
scaleY: this.konva.objectGroup.scaleY(),
}); });
}); });
// this.konva.transformer.on('transform', () => {
// // We need to snap the transform to the nearest pixel - both the position and the scale
// // Snap the interaction rect to the nearest pixel
// this.konva.interactionRect.x(Math.round(this.konva.interactionRect.x()));
// this.konva.interactionRect.y(Math.round(this.konva.interactionRect.y()));
// // Calculate the new scale of the interaction rect such that its width and height snap to the nearest pixel
// const roundedScaledWidth = Math.round(this.konva.interactionRect.width() * this.konva.interactionRect.scaleX());
// const correctedScaleX = roundedScaledWidth / this.konva.interactionRect.width();
// const roundedScaledHeight = Math.round(this.konva.interactionRect.height() * this.konva.interactionRect.scaleY());
// const correctedScaleY = roundedScaledHeight / this.konva.interactionRect.height();
// // Update the interaction rect's scale to the corrected scale
// this.konva.interactionRect.scaleX(correctedScaleX);
// this.konva.interactionRect.scaleY(correctedScaleY);
// console.log('>>> transform');
// console.log('activeAnchor', this.konva.transformer.getActiveAnchor());
// console.log('interactionRect', {
// x: this.konva.interactionRect.x(),
// y: this.konva.interactionRect.y(),
// scaleX: this.konva.interactionRect.scaleX(),
// scaleY: this.konva.interactionRect.scaleY(),
// width: this.konva.interactionRect.width(),
// height: this.konva.interactionRect.height(),
// rotation: this.konva.interactionRect.rotation(),
// });
// // Update the object group to reflect the new scale and position of the interaction rect
// this.konva.objectGroup.setAttrs({
// // The scale is the same as the interaction rect
// scaleX: this.konva.interactionRect.scaleX(),
// scaleY: this.konva.interactionRect.scaleY(),
// rotation: this.konva.interactionRect.rotation(),
// // We need to do some compensation for the new position. The bounds of the object group may be different from the
// // interaction rect/bbox, because the object group may have eraser strokes that are not included in the bbox.
// x:
// this.konva.interactionRect.x() -
// Math.abs(this.konva.interactionRect.x() - this.state.position.x) * this.konva.interactionRect.scaleX(),
// y:
// this.konva.interactionRect.y() -
// Math.abs(this.konva.interactionRect.y() - this.state.position.y) * this.konva.interactionRect.scaleY(),
// // x: this.konva.interactionRect.x() + (this.konva.interactionRect.x() - this.state.position.x) * this.konva.interactionRect.scaleX(),
// // y: this.konva.interactionRect.y() + (this.konva.interactionRect.y() - this.state.position.y) * this.konva.interactionRect.scaleY(),
// });
// this.konva.objectGroupBbox.setAttrs({
// x: this.konva.objectGroup.x(),
// y: this.konva.objectGroup.y(),
// scaleX: this.konva.objectGroup.scaleX(),
// scaleY: this.konva.objectGroup.scaleY(),
// });
// });
this.konva.transformer.on('transformend', () => { this.konva.transformer.on('transformend', () => {
this.bbox = { this.offsetX = this.konva.interactionRect.x() - this.state.position.x;
x: this.konva.interactionRect.x(), this.offsetY = this.konva.interactionRect.y() - this.state.position.y;
y: this.konva.interactionRect.y(), this.width = Math.round(this.konva.interactionRect.width() * this.konva.interactionRect.scaleX());
width: Math.round(this.konva.interactionRect.width() * this.konva.interactionRect.scaleX()), this.height = Math.round(this.konva.interactionRect.height() * this.konva.interactionRect.scaleY());
height: Math.round(this.konva.interactionRect.height() * this.konva.interactionRect.scaleY()),
};
// this.manager.stateApi.onPosChanged( // this.manager.stateApi.onPosChanged(
// { // {
// id: this.id, // id: this.id,
@ -260,6 +155,7 @@ export class CanvasLayer {
// }, // },
// 'layer' // 'layer'
// ); // );
this.logBbox('transformend bbox');
}); });
this.konva.interactionRect.on('dragmove', () => { this.konva.interactionRect.on('dragmove', () => {
@ -277,19 +173,15 @@ export class CanvasLayer {
// 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
// stored as this.bbox) // stored as this.bbox)
this.konva.objectGroup.setAttrs({ this.konva.objectGroup.setAttrs({
x: this.state.position.x + this.konva.interactionRect.x() - this.bbox.x, x: this.konva.interactionRect.x(),
y: this.state.position.y + this.konva.interactionRect.y() - this.bbox.y, y: this.konva.interactionRect.y(),
}); });
const rect = this.konva.objectGroup.getClientRect({ skipTransform: true });
this.konva.objectGroupBbox.setAttrs({ ...rect, x: this.konva.objectGroup.x(), y: this.konva.objectGroup.y() });
}); });
this.konva.interactionRect.on('dragend', () => { this.konva.interactionRect.on('dragend', () => {
// Update the bbox this.logBbox('dragend bbox');
this.bbox.x = this.konva.interactionRect.x();
this.bbox.y = this.konva.interactionRect.y();
// Update internal state // Update internal state
// this.state.position = { x: this.konva.objectGroup.x(), y: this.konva.objectGroup.y() };
this.manager.stateApi.onPosChanged( this.manager.stateApi.onPosChanged(
{ {
id: this.id, id: this.id,
@ -302,7 +194,10 @@ export class CanvasLayer {
this.objects = new Map(); this.objects = new Map();
this.drawingBuffer = null; this.drawingBuffer = null;
this.state = state; this.state = state;
this.bbox = CanvasLayer.DEFAULT_BBOX_RECT; this.offsetX = 0;
this.offsetY = 0;
this.width = 0;
this.height = 0;
console.log(this); console.log(this);
} }
@ -319,6 +214,32 @@ export class CanvasLayer {
return this.drawingBuffer; return this.drawingBuffer;
} }
updatePosition() {
const scale = this.manager.stage.scaleX();
const onePixel = 1 / scale;
const bboxPadding = CanvasLayer.BBOX_PADDING_PX / scale;
this.konva.objectGroup.setAttrs({
x: this.state.position.x,
y: this.state.position.y,
offsetX: this.offsetX,
offsetY: this.offsetY,
});
this.konva.bbox.setAttrs({
x: this.state.position.x - bboxPadding,
y: this.state.position.y - bboxPadding,
width: this.width + bboxPadding * 2,
height: this.height + bboxPadding * 2,
strokeWidth: onePixel,
});
this.konva.interactionRect.setAttrs({
x: this.state.position.x,
y: this.state.position.y,
width: this.width,
height: this.height,
});
}
async setDrawingBuffer(obj: BrushLine | EraserLine | RectShape | null) { async setDrawingBuffer(obj: BrushLine | EraserLine | RectShape | null) {
if (obj) { if (obj) {
this.drawingBuffer = obj; this.drawingBuffer = obj;
@ -344,27 +265,7 @@ export class CanvasLayer {
} }
async render(state: LayerEntity) { async render(state: LayerEntity) {
this.state = state; this.state = deepClone(state);
// Update the layer's position and listening state
this.konva.objectGroup.setAttrs({
x: state.position.x,
y: state.position.y,
scaleX: 1,
scaleY: 1,
});
this.konva.positionXLine.points([
state.position.x,
-this.manager.stage.y(),
state.position.x,
this.manager.stage.y() + this.manager.stage.height() / this.manager.stage.scaleY(),
]);
this.konva.positionYLine.points([
-this.manager.stage.x(),
state.position.y,
this.manager.stage.x() + this.manager.stage.width() / this.manager.stage.scaleX(),
state.position.y,
]);
let didDraw = false; let didDraw = false;
@ -465,9 +366,12 @@ export class CanvasLayer {
if (didDraw) { if (didDraw) {
if (this.objects.size > 0) { if (this.objects.size > 0) {
// this.getBbox(); this.getBbox();
} else { } else {
this.bbox = CanvasLayer.DEFAULT_BBOX_RECT; this.offsetX = 0;
this.offsetY = 0;
this.width = 0;
this.height = 0;
this.renderBbox(); this.renderBbox();
} }
} }
@ -475,15 +379,14 @@ export class CanvasLayer {
this.konva.layer.visible(true); this.konva.layer.visible(true);
this.konva.objectGroup.opacity(this.state.opacity); this.konva.objectGroup.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 toolState = this.manager.stateApi.getToolState();
const isTransforming = selectedTool === 'transform' && isSelected; const isMoving = toolState.selected === 'move' && isSelected;
const isMoving = selectedTool === 'move' && isSelected;
this.konva.layer.listening(isTransforming || isMoving); this.konva.layer.listening(toolState.isTransforming || isMoving);
this.konva.transformer.listening(isTransforming); this.konva.transformer.listening(toolState.isTransforming);
this.konva.bbox.visible(isMoving); this.konva.bbox.visible(isMoving);
this.konva.interactionRect.listening(isMoving); this.konva.interactionRect.listening(toolState.isTransforming || isMoving);
if (this.objects.size === 0) { if (this.objects.size === 0) {
// If the layer is totally empty, reset the cache and bail out. // If the layer is totally empty, reset the cache and bail out.
@ -491,7 +394,7 @@ export class CanvasLayer {
if (this.konva.objectGroup.isCached()) { if (this.konva.objectGroup.isCached()) {
this.konva.objectGroup.clearCache(); this.konva.objectGroup.clearCache();
} }
} else if (isSelected && selectedTool === 'transform') { } else if (isSelected && toolState.isTransforming) {
// When the layer is selected and being moved, we should always cache it. // When the layer is selected and being moved, we should always cache it.
// We should update the cache if we drew to the layer. // We should update the cache if we drew to the layer.
if (!this.konva.objectGroup.isCached() || didDraw) { if (!this.konva.objectGroup.isCached() || didDraw) {
@ -501,7 +404,7 @@ export class CanvasLayer {
this.konva.transformer.nodes([this.konva.interactionRect]); this.konva.transformer.nodes([this.konva.interactionRect]);
this.konva.transformer.forceUpdate(); this.konva.transformer.forceUpdate();
this.konva.transformer.visible(true); this.konva.transformer.visible(true);
} else if (selectedTool === 'move') { } else if (toolState.selected === 'move') {
// When the layer is selected and being moved, we should always cache it. // When the layer is selected and being moved, we should always cache it.
// We should update the cache if we drew to the layer. // We should update the cache if we drew to the layer.
if (!this.konva.objectGroup.isCached() || didDraw) { if (!this.konva.objectGroup.isCached() || didDraw) {
@ -515,7 +418,7 @@ export class CanvasLayer {
// If the layer is selected but not using the move tool, we don't want the layer to be listening. // If the layer is selected but not using the move tool, we don't want the layer to be listening.
// The transformer also does not need to be active. // The transformer also does not need to be active.
this.konva.transformer.nodes([]); this.konva.transformer.nodes([]);
if (isDrawingTool(selectedTool)) { if (isDrawingTool(toolState.selected)) {
// We are using a drawing tool (brush, eraser, rect). These tools change the layer's rendered appearance, so we // We are using a drawing tool (brush, eraser, rect). These tools change the layer's rendered appearance, so we
// should never be cached. // should never be cached.
if (this.konva.objectGroup.isCached()) { if (this.konva.objectGroup.isCached()) {
@ -540,43 +443,23 @@ export class CanvasLayer {
} }
renderBbox() { renderBbox() {
const toolState = this.manager.stateApi.getToolState();
if (toolState.isTransforming) {
return;
}
const isSelected = this.manager.stateApi.getIsSelected(this.id); const isSelected = this.manager.stateApi.getIsSelected(this.id);
const selectedTool = this.manager.stateApi.getToolState().selected; const hasBbox = this.width !== 0 && this.height !== 0;
const scale = this.manager.stage.scaleX(); this.konva.bbox.visible(hasBbox && isSelected && toolState.selected === 'move');
const hasBbox = this.bbox.width !== 0 && this.bbox.height !== 0;
this.konva.bbox.visible(hasBbox && isSelected && selectedTool === 'move');
this.konva.interactionRect.visible(hasBbox); this.konva.interactionRect.visible(hasBbox);
const rect = this.konva.objectGroup.getClientRect({ skipTransform: true }); this.updatePosition();
this.konva.objectGroupBbox.setAttrs({
...rect,
x: this.konva.objectGroup.x(),
y: this.konva.objectGroup.y(),
scaleX: 1,
scaleY: 1,
});
this.konva.bbox.setAttrs({
x: this.bbox.x - CanvasLayer.BBOX_PADDING_PX / scale,
y: this.bbox.y - CanvasLayer.BBOX_PADDING_PX / scale,
width: this.bbox.width + (CanvasLayer.BBOX_PADDING_PX / scale) * 2,
height: this.bbox.height + (CanvasLayer.BBOX_PADDING_PX / scale) * 2,
scaleX: 1,
scaleY: 1,
strokeWidth: 1 / this.manager.stage.scaleX(),
});
this.konva.interactionRect.setAttrs({
x: this.bbox.x,
y: this.bbox.y,
width: this.bbox.width,
height: this.bbox.height,
scaleX: 1,
scaleY: 1,
});
} }
private _getBbox() { private _getBbox() {
if (this.objects.size === 0) { if (this.objects.size === 0) {
this.bbox = CanvasLayer.DEFAULT_BBOX_RECT; this.offsetX = 0;
this.offsetY = 0;
this.width = 0;
this.height = 0;
this.renderBbox(); this.renderBbox();
return; return;
} }
@ -595,16 +478,21 @@ export class CanvasLayer {
} }
if (!needsPixelBbox) { if (!needsPixelBbox) {
if (rect.width === 0 || rect.height === 0) { this.offsetX = rect.x;
this.bbox = CanvasLayer.DEFAULT_BBOX_RECT; this.offsetY = rect.y;
} else { this.width = rect.width;
this.bbox = { this.height = rect.height;
x: this.konva.objectGroup.x() + rect.x, // if (rect.width === 0 || rect.height === 0) {
y: this.konva.objectGroup.y() + rect.y, // this.bbox = CanvasLayer.DEFAULT_BBOX_RECT;
width: rect.width, // } else {
height: rect.height, // this.bbox = {
}; // x: rect.x,
} // y: rect.y,
// width: rect.width,
// height: rect.height,
// };
// }
this.logBbox('new bbox from client rect');
this.renderBbox(); this.renderBbox();
return; return;
} }
@ -621,21 +509,34 @@ export class CanvasLayer {
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) => {
console.log('extents', extents);
if (extents) { if (extents) {
const { minX, minY, maxX, maxY } = extents; const { minX, minY, maxX, maxY } = extents;
this.bbox = { this.offsetX = minX + rect.x;
x: this.konva.objectGroup.x() + rect.x + minX, this.offsetY = minY + rect.y;
y: this.konva.objectGroup.y() + rect.y + minY, this.width = maxX - minX;
width: maxX - minX, this.height = maxY - minY;
height: maxY - minY,
};
} else { } else {
this.bbox = CanvasLayer.DEFAULT_BBOX_RECT; this.offsetX = 0;
this.offsetY = 0;
this.width = 0;
this.height = 0;
} }
console.log('new bbox', deepClone(this.bbox)); this.logBbox('new bbox from worker');
this.renderBbox(); this.renderBbox();
clone.destroy(); clone.destroy();
} }
); );
} }
logBbox(msg: string = 'bbox') {
console.log(msg, {
x: this.state.position.x,
y: this.state.position.y,
offsetX: this.offsetX,
offsetY: this.offsetY,
width: this.width,
height: this.height,
});
}
} }

View File

@ -160,7 +160,7 @@ export class CanvasTool {
} else if (!isDrawableEntity) { } else if (!isDrawableEntity) {
// Non-drawable layers don't have tools // Non-drawable layers don't have tools
stage.container().style.cursor = 'not-allowed'; stage.container().style.cursor = 'not-allowed';
} else if (tool === 'move') { } else if (tool === 'move' || toolState.isTransforming) {
// Move tool gets a pointer // Move tool gets a pointer
stage.container().style.cursor = 'default'; stage.container().style.cursor = 'default';
} else if (tool === 'rect') { } else if (tool === 'rect') {

View File

@ -194,6 +194,7 @@ export const {
allEntitiesDeleted, allEntitiesDeleted,
clipToBboxChanged, clipToBboxChanged,
canvasReset, canvasReset,
toolIsTransformingChanged,
// bbox // bbox
bboxChanged, bboxChanged,
bboxScaledSizeChanged, bboxScaledSizeChanged,

View File

@ -20,4 +20,7 @@ export const toolReducers = {
toolBufferChanged: (state, action: PayloadAction<Tool | null>) => { toolBufferChanged: (state, action: PayloadAction<Tool | null>) => {
state.tool.selectedBuffer = action.payload; state.tool.selectedBuffer = action.payload;
}, },
toolIsTransformingChanged: (state, action: PayloadAction<boolean>) => {
state.tool.isTransforming = action.payload;
},
} satisfies SliceCaseReducers<CanvasV2State>; } satisfies SliceCaseReducers<CanvasV2State>;

View File

@ -464,7 +464,7 @@ export const CA_PROCESSOR_DATA: CAProcessorsData = {
}, },
}; };
const zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'transform']); const zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox']);
export type Tool = z.infer<typeof zTool>; export type Tool = z.infer<typeof zTool>;
export function isDrawingTool(tool: Tool): tool is 'brush' | 'eraser' | 'rect' { export function isDrawingTool(tool: Tool): tool is 'brush' | 'eraser' | 'rect' {
return tool === 'brush' || tool === 'eraser' || tool === 'rect'; return tool === 'brush' || tool === 'eraser' || tool === 'rect';
@ -850,6 +850,7 @@ export type CanvasV2State = {
brush: { width: number }; brush: { width: number };
eraser: { width: number }; eraser: { width: number };
fill: RgbaColor; fill: RgbaColor;
isTransforming: boolean;
}; };
settings: { settings: {
imageSmoothing: boolean; imageSmoothing: boolean;