Description: When attempting to create a workspace inside a clipped layer, we are encountering an issue where the transformer aligns itself relative to the clipped layer’s origin rather than the canvas (stage) coordinates. This causes transformed shapes to be offset within the clipped layer, making positioning inaccurate relative to the full canvas. We suspect the issue lies in how we’re applying coordinate transformations within our code, especially around the clipped layer boundaries.
Steps to Reproduce: Set up a React component with Konva, using the provided code. Add a clipped layer to the canvas (Konva stage) and populate it with a canvas rectangle, shapes, and a transformer. Attempt to move or resize shapes using the transformer.
Observed Behavior: The transformer tool calculates its position relative to the origin of the clipped layer, rather than the canvas (stage), causing alignment issues for shapes within the clipped area.
const Workspace: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const width = window.innerWidth;
const height = window.innerHeight;
const stage = new Konva.Stage({
container: containerRef.current!,
width: width,
id: 'stage',
height: height,
});
const clipX = (width - 500) / 2;
const clipY = (height - 500) / 2;
const canvasHeight = 500;
const canvasWidth = 500;
stage.container().style.backgroundColor = '#ededed';
const layer = new Konva.Layer({
x: clipX,
y: clipY,
clipX: 0,
clipY: 0,
clipWidth: canvasWidth,
clipHeight: canvasHeight,
});
stage.add(layer);
const canvas = new Konva.Rect({
id: 'canvas',
fill: 'white',
height: canvasHeight,
width: canvasWidth,
listening: false,
});
layer.add(canvas);
const transformer = new Konva.Transformer({
id: 'transformer',
});
layer.add(transformer);
const rect1 = new Konva.Rect({
x: 60,
y: 60,
width: 50,
height: 50,
fill: 'royalblue',
name: 'rect',
id: 'rect1',
draggable: true,
});
layer.add(rect1);
const text = new Konva.Text({
id: 'text',
y: 300,
x: 300,
fontSize: 20,
text: 'Test',
fill: '#a43',
});
layer.add(text);
// Selection logic
const selectionRectangle = new Konva.Rect({
fill: 'rgba(41, 171, 241, 0.5)',
visible: false,
listening: false,
});
layer.add(selectionRectangle);
let x1: number, y1: number, x2: number, y2: number, selecting = false;
stage.on('mousedown touchstart', (e) => {
if (!['canvas', 'stage'].includes(e.target.attrs.id)) return;
e.evt.preventDefault();
x1 = stage.getPointerPosition()!.x;
y1 = stage.getPointerPosition()!.y;
x2 = stage.getPointerPosition()!.x;
y2 = stage.getPointerPosition()!.y;
selectionRectangle.width(0);
selectionRectangle.height(0);
selecting = true;
});
stage.on('mousemove touchmove', (e) => {
if (!selecting) return;
e.evt.preventDefault();
x2 = stage.getPointerPosition()!.x;
y2 = stage.getPointerPosition()!.y;
selectionRectangle.setAttrs({
visible: true,
x: Math.min(x1, x2),
y: Math.min(y1, y2),
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
});
});
stage.on('mouseup touchend', (e) => {
selecting = false;
if (!selectionRectangle.visible()) return;
e.evt.preventDefault();
selectionRectangle.visible(false);
const shapes = stage.find('Text');
const box = selectionRectangle.getClientRect();
const selected = shapes.filter((shape) =>
Konva.Util.haveIntersection(box, shape.getClientRect()),
);
transformer.nodes(selected);
});
stage.on('click tap', (e) => {
if (selectionRectangle.visible()) return;
if (e.target === stage) {
transformer.nodes([]);
return;
}
if (!e.target.hasName('rect')) return;
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
if (!metaPressed && !isSelected) {
transformer.nodes([e.target]);
} else if (metaPressed && isSelected) {
const nodes = transformer.nodes().slice();
nodes.splice(nodes.indexOf(e.target), 1);
transformer.nodes(nodes);
} else if (metaPressed && !isSelected) {
const nodes = transformer.nodes().concat([e.target]);
transformer.nodes(nodes);
}
});
return () => {
stage.destroy();
};
}, []);
return <div ref={containerRef} />;
};
Expected Behavior: The transformer should respect the canvas (stage) coordinates and accurately display transformations within the clipped area.
This is because your selection rectangle is a child of the clipped layer. Create a new unclipped layer that fits the entire stage dimension and move the selection rectangle to it.
export const App: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const stage = new Konva.Stage({
container: containerRef.current!,
width: viewportWidth,
height: viewportHeight,
id: 'stage',
});
const canvasSize = 500;
const canvasOffsetX = (viewportWidth - canvasSize) / 2;
const canvasOffsetY = (viewportHeight - canvasSize) / 2;
stage.container().style.backgroundColor = '#ededed';
const clippedLayer = new Konva.Layer({
x: canvasOffsetX,
y: canvasOffsetY,
clipX: 0,
clipY: 0,
clipWidth: canvasSize,
clipHeight: canvasSize,
listening: false,
});
const fullLayer = new Konva.Layer();
stage.add(clippedLayer, fullLayer);
const canvasBackground = new Konva.Rect({
id: 'canvasBackground',
fill: 'white',
height: canvasSize,
width: canvasSize,
listening: false,
});
clippedLayer.add(canvasBackground);
const transformer = new Konva.Transformer({ id: 'transformer' });
clippedLayer.add(transformer);
fullLayer.add(transformer);
const draggableRect = new Konva.Rect({
x: 60,
y: 60,
width: 50,
height: 50,
fill: 'royalblue',
name: 'rect',
id: 'draggableRect',
draggable: true,
});
clippedLayer.add(draggableRect);
const labelText = new Konva.Text({
id: 'labelText',
x: 300,
y: 300,
fontSize: 20,
text: 'Test',
fill: '#a43',
draggable: true,
});
clippedLayer.add(labelText);
const selectionBox = new Konva.Rect({
fill: 'rgba(41, 171, 241, 0.5)',
visible: false,
listening: false,
});
fullLayer.add(selectionBox);
let startX: number,
startY: number,
endX: number,
endY: number,
isSelecting = false;
stage.on('mousedown touchstart', (e) => {
const targetId = e.target.attrs.id;
if (!['canvasBackground', 'stage'].includes(targetId)) return;
e.evt.preventDefault();
startX = stage.getPointerPosition()!.x;
startY = stage.getPointerPosition()!.y;
endX = startX;
endY = startY;
selectionBox.width(0);
selectionBox.height(0);
isSelecting = true;
});
stage.on('mousemove touchmove', (e) => {
if (!isSelecting) return;
e.evt.preventDefault();
endX = stage.getPointerPosition()!.x;
endY = stage.getPointerPosition()!.y;
selectionBox.setAttrs({
visible: true,
x: Math.min(startX, endX),
y: Math.min(startY, endY),
width: Math.abs(endX - startX),
height: Math.abs(endY - startY),
});
});
stage.on('mouseup touchend', (e) => {
isSelecting = false;
if (!selectionBox.visible()) return;
e.evt.preventDefault();
selectionBox.visible(false);
const textShapes = stage.find('Text');
const selectionArea = selectionBox.getClientRect();
const selectedNodes = textShapes.filter((shape) =>
Konva.Util.haveIntersection(selectionArea, shape.getClientRect())
);
transformer.nodes(selectedNodes);
});
stage.on('click tap', (e) => {
if (selectionBox.visible()) {
return;
}
if (e.target === stage) {
transformer.nodes([]);
return;
}
if (!e.target.hasName('text')) {
return;
}
const isMetaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
const isNodeSelected = transformer.nodes().includes(e.target);
if (!isMetaPressed && !isNodeSelected) {
transformer.nodes([e.target]);
} else if (isMetaPressed && isNodeSelected) {
const nodes = transformer.nodes().filter((node) => node !== e.target);
transformer.nodes(nodes);
} else if (isMetaPressed && !isNodeSelected) {
transformer.nodes([...transformer.nodes(), e.target]);
}
});
return () => {
stage.destroy();
};
}, []);
return <div ref={containerRef} />;
};