How to implement rotation and custom node-based reshaping for SVG ellipse inside a resizable...

Discussão em 'Angular' iniciado por yashwanth, Novembro 30, 2025.

  1. yashwanth

    yashwanth Guest

    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:


    1. Rotation of the ellipse using a rotation handle.


    2. Double-click to add an extra node that can be dragged to reshape the ellipse (similar to vector editing tools).

    I tried multiple approaches, including using SVG <ellipse> and experimenting with <path> for more flexibility, but I couldn’t get rotation and custom node-based reshaping 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);
    }
    }

    Continue reading...

Compartilhe esta Página