skip to Main Content

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.

2

Answers


  1. Edit: Can we clarify what the issue is please. Is it that the selection rectangle is offset, as in the video below?

    enter image description here

    If so then this is because the selection rectangle is a child of the layer. The hierarchy is stage > layer > shape. Because your code sets the (x,y) of the layer to be (x: clipX, y: clipY) but then in the selection logic you get the mouse position relative to the stage in

    stage.getPointerPosition()
    

    You could switch that to

    layer.getRelativePointerPosition()
    

    To solve the issue.

    Hi and welcome to SO and the Konva community.

    The transformer is a child of Layer, not Stage.

    In your creation of the Layer, try

        const layer = new Konva.Layer({
          x: 0,
          y: 0,
          clipX: clipX,
          clipY: clipY,
          clipWidth: canvasWidth,
          clipHeight: canvasHeight,
        });
    

    and now do all the related work from that perspective, which will provide co-ordinates based on the Layer origin which itself is aligned with the stage origin, as it seems you require.

    I switched your code into plain JS and made a working snippet.

        const width = window.innerWidth;
        const height = window.innerHeight;
    
        const stage = new Konva.Stage({
          container: "#container",
          width: width,
          id: 'stage',
          height: height,
        });
    
        const clipX = (width - 500) / 2;
        const clipY = (height - 300) / 2;
        const canvasHeight = 300;
        const canvasWidth = 500;
    
        stage.container().style.backgroundColor = '#ededed';
    
        const layer = new Konva.Layer({
        
          x: clipX,
          y: clipY,
          clipX: 0,
          clipY: 0,
    
    //      clipX: clipX,
    //      clipY: clipY,
    
    
          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, y1, x2, y2, 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);
          }
        });
    #container {
    width: 800px;
    height: 600px;
    }
    <script src="https://unpkg.com/konva@9/konva.js"></script>
    <!DOCTYPE html>
    <html>
    <head>
      <title>Demo</title>  
    </head>
    <body>
    
    <div id="container"></div>
    
    </body>
    
    </html>
    Login or Signup to reply.
  2. 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} />;
    };
    

    konva workspace

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search