0

Reference: screen recording link

I’m working on a feature similar to Figma or Lucidchart where an ellipse (or circle) is placed inside a reference box (a square) with four corner handles for resizing.

Current functionality:

  • Dragging a corner handle resizes the ellipse while keeping the opposite corner fixed.

  • Dragging a side handle adjusts the width or height proportionally.

This works fine for ellipses.

Now, I want to add:

  • Rotation of the ellipse using a rotation handle.

I tried multiple approaches, including using SVG <ellipse> and experimenting with <path> for more flexibility, but I couldn’t get rotation to work properly.

Question:

  • Is using an SVG <path> the right approach for enabling rotation and node-based reshaping?

  • Are there any recommended patterns or libraries for implementing this functionality?

Any guidance or code examples would be greatly appreciated.

Method used for resize on corner, side edge

_handleCircleReferenceBoxResize(state: any, cur: {x: number, y: number}, hit: any) {
  const shape = state.shape;
  const center = shape.startPoint;
  const proportional = !!state.shiftKey;

  if (!state.hasStartedResize) {
    const dxMove = Math.hypot(cur.x - state.startSvgPoint.x, cur.y - state.startSvgPoint.y);
    if (dxMove > 3) { 
      this.toggleOriginalShapeArea(shape, true); 
      state.hasStartedResize = true; 
    }
  }

  // Get original ellipse data and bounds
  const origEllipse = state.originalEllipse || (shape.__ellipse ? { ...shape.__ellipse } : { rx: 40, ry: 40 });
  const origRx = origEllipse.rx || 40;
  const origRy = origEllipse.ry || 40;
  
  // Use the stored original bounds from createReferenceBox
  const origBounds = state.originalBounds;

  let newRx = origRx;
  let newRy = origRy;
  let newCenterX = center.x;
  let newCenterY = center.y;

  if (hit.type === 'edge') {
    // Edge resize - opposite edge should remain fixed
    switch (hit.edge) {
      case 'left':
        // Left edge resize - right edge stays fixed
        newRx = Math.max(2, (origBounds.right - cur.x) / 2);
        newCenterX = (origBounds.right + cur.x) / 2;
        if (proportional) {
          const scale = newRx / origRx;
          newRy = Math.max(2, origRy * scale);
          newCenterY = center.y;
        } else {
          newRy = origRy;
          newCenterY = center.y;
        }
        break;
        
      case 'right':
        // Right edge resize - left edge stays fixed
        newRx = Math.max(2, (cur.x - origBounds.left) / 2);
        newCenterX = (origBounds.left + cur.x) / 2;
        if (proportional) {
          const scale = newRx / origRx;
          newRy = Math.max(2, origRy * scale);
          newCenterY = center.y;
        } else {
          newRy = origRy;
          newCenterY = center.y;
        }
        break;
        
      case 'top':
        // Top edge resize - bottom edge stays fixed
        newRy = Math.max(2, (origBounds.bottom - cur.y) / 2);
        newCenterY = (origBounds.bottom + cur.y) / 2;
        if (proportional) {
          const scale = newRy / origRy;
          newRx = Math.max(2, origRx * scale);
          newCenterX = center.x;
        } else {
          newRx = origRx;
          newCenterX = center.x;
        }
        break;
        
      case 'bottom':
        // Bottom edge resize - top edge stays fixed
        newRy = Math.max(2, (cur.y - origBounds.top) / 2);
        newCenterY = (origBounds.top + cur.y) / 2;
        if (proportional) {
          const scale = newRy / origRy;
          newRx = Math.max(2, origRx * scale);
          newCenterX = center.x;
        } else {
          newRx = origRx;
          newCenterX = center.x;
        }
        break;
    }
  } else if (hit.type === 'corner') {
    // Corner resize - opposite corner should remain fixed
    const oppositeCorner = this._getOppositeCorner(hit.cornerIndex, origBounds);
    
    switch (hit.cornerIndex) {
      case 0: // Top-left corner - bottom-right fixed
        newRx = Math.max(2, (oppositeCorner.x - cur.x) / 2);
        newRy = Math.max(2, (oppositeCorner.y - cur.y) / 2);
        newCenterX = (oppositeCorner.x + cur.x) / 2;
        newCenterY = (oppositeCorner.y + cur.y) / 2;
        break;
        
      case 1: // Top-right corner - bottom-left fixed
        newRx = Math.max(2, (cur.x - oppositeCorner.x) / 2);
        newRy = Math.max(2, (oppositeCorner.y - cur.y) / 2);
        newCenterX = (oppositeCorner.x + cur.x) / 2;
        newCenterY = (oppositeCorner.y + cur.y) / 2;
        break;
        
      case 2: // Bottom-right corner - top-left fixed
        newRx = Math.max(2, (cur.x - oppositeCorner.x) / 2);
        newRy = Math.max(2, (cur.y - oppositeCorner.y) / 2);
        newCenterX = (oppositeCorner.x + cur.x) / 2;
        newCenterY = (oppositeCorner.y + cur.y) / 2;
        break;
        
      case 3: // Bottom-left corner - top-right fixed
        newRx = Math.max(2, (oppositeCorner.x - cur.x) / 2);
        newRy = Math.max(2, (cur.y - oppositeCorner.y) / 2);
        newCenterX = (oppositeCorner.x + cur.x) / 2;
        newCenterY = (oppositeCorner.y + cur.y) / 2;
        break;
    }
    
    if (proportional) {
      // Maintain aspect ratio - use the smaller scale for both axes
      const scaleX = newRx / origRx;
      const scaleY = newRy / origRy;
      const scale = Math.min(scaleX, scaleY);
      
      // Recalculate based on proportional scale while keeping opposite corner fixed
      newRx = Math.max(2, origRx * scale);
      newRy = Math.max(2, origRy * scale);
      
      // Recalculate center based on new radii and opposite corner
      switch (hit.cornerIndex) {
        case 0: // Top-left
          newCenterX = oppositeCorner.x - newRx;
          newCenterY = oppositeCorner.y - newRy;
          break;
        case 1: // Top-right
          newCenterX = oppositeCorner.x + newRx;
          newCenterY = oppositeCorner.y - newRy;
          break;
        case 2: // Bottom-right
          newCenterX = oppositeCorner.x + newRx;
          newCenterY = oppositeCorner.y + newRy;
          break;
        case 3: // Bottom-left
          newCenterX = oppositeCorner.x - newRx;
          newCenterY = oppositeCorner.y + newRy;
          break;
      }
    }
  }

  // Update preview state and shape center
  (shape as any).__tempEllipse = { rx: newRx, ry: newRy };
  shape.startPoint = { x: newCenterX, y: newCenterY, id: shape.startPoint?.id };

  // Update drop points to maintain consistency
  if (shape.dropPoints && shape.dropPoints.length > 0) {
    const lastPoint = shape.dropPoints[shape.dropPoints.length - 1];
    lastPoint.x = newCenterX + newRx;
    lastPoint.y = newCenterY;
  }

  // Update visuals and reference box
  this.updateCircleVisuals(shape);
  this.updateReferenceBox(shape);
}



updateCircleVisuals(shape: any) {
  try {
    if (!shape || !shape.group) return;
    const g = shape.group;
    const start = shape.startPoint;

    // canonical radii (persisted or from last dropPoint)
    const persisted = (shape as any).__ellipse;
    const last = shape.dropPoints?.length ? shape.dropPoints[shape.dropPoints.length - 1] : null;
    const canonicalRx = persisted?.rx ?? (last ? Math.abs(last.x - start.x) : 40);
    const canonicalRy = persisted?.ry ?? (last ? Math.abs(last.y - start.y) : canonicalRx);

    // preview temp ellipse (set during pointermove)
    const temp = (shape as any).__tempEllipse;
    let rx = temp && typeof temp.rx === 'number' ? Math.max(1, temp.rx) : Math.max(1, canonicalRx);
    let ry = temp && typeof temp.ry === 'number' ? Math.max(1, temp.ry) : Math.max(1, canonicalRy);

    // avoid collapsed axes
    if (!ry || ry < 1) ry = Math.max(2, rx);
    if (!rx || rx < 1) rx = Math.max(2, ry);

    const rootStyle = this.getRootStyles();

    // --- Update the "authoritative" shapeArea (the element other code may query) ---
    // If you intentionally hide shapeArea while editing, keep but update attributes so getBBox etc is correct.
    let shapeArea = g.select('.shapeArea');
    if (shapeArea.empty()) {
      // if missing, create a proper shapeArea (keep pointer-events consistent with your UX)
      shapeArea = g.append('ellipse').attr('class', 'shapeArea');
    }
    shapeArea
      .datum(shape.id)
      .attr('cx', start.x)
      .attr('cy', start.y)
      .attr('rx', rx)
      .attr('ry', ry)
      .style('stroke', rootStyle.primary)
      .style('fill', `rgba(${rootStyle.primaryrgb},0.1)`)
      .style('fill-opacity', 0.88);

    // If you previously toggled display: none for shapeArea when showing a preview, keep that behaviour
    // but still update its attributes. Example: keep hidden via style when toggled
    if ((shape as any).__hideShapeAreaDuringPreview) {
      shapeArea.style('display', 'none').style('pointer-events', 'none');
    } else {
      shapeArea.style('display', null).style('pointer-events', null);
    }

    // --- visible ellipse (preview visual) ---
    let visible = g.select('ellipse.shape-visible');
    if (visible.empty()) {
      visible = g.append('ellipse').attr('class', 'shape-visible').style('pointer-events', 'none');
    }
    visible
      .attr('cx', start.x)
      .attr('cy', start.y)
      .attr('rx', rx)
      .attr('ry', ry)
      .style('stroke', rootStyle.primary)
      .style('fill', `rgba(${rootStyle.primaryrgb},0.1)`)
      .style('fill-opacity', 0.88);

    // --- hit ellipse (interaction) ---
    let hit = g.select('ellipse.shape-hit-ellipse');
    const HIT_EXTRA = 12;
    const hitRx = Math.max(1, rx + HIT_EXTRA);
    const hitRy = Math.max(1, ry + HIT_EXTRA);

    if (hit.empty()) {
      hit = g.append('ellipse')
        .attr('class', 'shape-hit-ellipse')
        .style('fill', 'transparent')
        .style('pointer-events', 'all');
      // attach handlers only if not attached elsewhere
      // hit.on('pointermove', (ev) => this.onCircleHitPointerMove(ev as PointerEvent, shape, hit.node()));
      // hit.on('pointerdown', (ev) => this.onCircleHitPointerDown(ev as PointerEvent, shape, hit.node()));
    }
    hit
      .attr('data-shape-id', shape.id)
      .attr('cx', start.x)
      .attr('cy', start.y)
      .attr('rx', hitRx)
      .attr('ry', hitRy)
      .attr('stroke-width', Math.max(8, HIT_EXTRA * 2));

    // If your bbox overlay is handling pointer events, you may want to disable hit ellipse temporarily:
    if ((shape as any).__hitWasDisabledForBBox) {
      hit.attr('pointer-events', 'none');
      (hit.node() as HTMLElement).style.cursor = '';
    } else {
      hit.attr('pointer-events', 'all');
    }

    // ensure hit is topmost within this shape group
    const hitNode = g.select('ellipse.shape-hit-ellipse').node();
    if (hitNode && hitNode.parentNode) hitNode.parentNode.appendChild(hitNode);

    // --- update zone-name path + foreignObject if you use them (so label follows) ---
    const zonePath = g.select(`#${shape.id}zone-name`);
    if (!zonePath.empty()) {
      // set path to span left/right extremes of the ellipse center ± rx along x axis
      const leftX = start.x - rx;
      const rightX = start.x + rx;
      zonePath.attr('d', `M ${leftX} ${start.y} L ${rightX} ${start.y}`);
    }
    const fo = g.select('foreignObject');
    if (!fo.empty()) {
      // center foreignObject (approx)
      const widthAttr = parseFloat((fo.attr('width') || '0') as string) || 75;
      const heightAttr = parseFloat((fo.attr('height') || '0') as string) || 20;
      fo.attr('x', String(Math.round(start.x - widthAttr / 2))).attr('y', String(Math.round(start.y - heightAttr / 2)));
    }

  } catch (err) {
    console.debug('updateCircleVisuals err', err);
  }
}
New contributor
yashwanth is a new contributor to this site. Take care in asking for clarification, commenting, and answering. Check out our Code of Conduct.
2
  • If you need an option for adding commands to manipulate the shape geometry you definitely need <path>. You can also translate transforms to explicit path commands - see "Baking transforms into SVG Path Element commands". However, you should prefer transforms for scaling and rotation and flatten transformed coordinates only when editing the shape itself - otherwise you get too many accumulating rounding errors. You also need a structured pathdata object that can be manipulated Commented 18 hours ago
  • 1
    Better split your question into separate tasks: e.g 1. rotation and scaling 2. path shape editing. Your current question is too broad and is likely to be closed Commented 18 hours ago

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.