Figma-like Zoom Implementation

implement figma like zoom system for this import { useEffect, useRef } from 'react'; const App = () => { const canvasRef = useRef(null); let scale = 1; let translateX = 0; let translateY = 0; useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const wheelHandler = e => { const canvas = e.currentTarget; const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey; if (!canvas || !hasModifier) return; e.preventDefault(); const { top, left } = canvas.getBoundingClientRect(); const mouseX = e.clientX - left; const mouseY = e.clientY - top; scale = scale + scale * 0.1; console.log(scale); canvas.style.transform = `scale(${scale})`; canvas.style.transformOrigin = `${mouseX}px ${mouseY}px`; }; canvas.addEventListener('wheel', wheelHandler, { passive: false }); return () => canvas.removeEventListener('wheel', wheelHandler, { passive: false }); }, []); return ( <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100vw', height: '100dvh', background: 'skyblue', transformOrigin: 'center', // Set the transform origin to the top-left for proper calculation }} > <div ref={canvasRef} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100vw', height: '100dvh', background: 'violet', transformOrigin: 'center', // Set the transform origin to the top-left for proper calculation }} > <span style={{ background: 'red', color: 'black' }}>this is the text</span> </div> </div> ); }; export default App;

To implement a Figma-like zoom system for your canvas, you need to handle the following:

  1. Zooming with Mouse Wheel: This includes calculating the zoom factor and using the cursor's position as the zoom origin.
  2. Panning with Drag: Enable the ability to pan around the canvas while zoomed in.
  3. Proper Transformations: Use scale and translate together to achieve a smooth experience.

Here's an updated implementation:

Implementation

import { useEffect, useRef, useState } from 'react';

const App = () => {
  const canvasRef = useRef(null);
  const containerRef = useRef(null);

  const [transform, setTransform] = useState({
    scale: 1,
    translateX: 0,
    translateY: 0,
  });

  const handleWheel = (e) => {
    e.preventDefault();
    const canvas = canvasRef.current;
    if (!canvas || (!e.ctrlKey && !e.metaKey)) return;

    const { scale, translateX, translateY } = transform;

    // Determine the cursor position relative to the canvas
    const { left, top } = canvas.getBoundingClientRect();
    const mouseX = e.clientX - left;
    const mouseY = e.clientY - top;

    // Zoom in or out
    const zoomFactor = 0.1;
    const newScale = e.deltaY < 0 ? scale * (1 + zoomFactor) : scale * (1 - zoomFactor);

    // Clamp the scale to prevent excessive zoom
    const clampedScale = Math.max(0.2, Math.min(newScale, 5));

    // Adjust translations to zoom around the mouse position
    const deltaScale = clampedScale / scale;
    const newTranslateX = translateX - (mouseX * (deltaScale - 1));
    const newTranslateY = translateY - (mouseY * (deltaScale - 1));

    setTransform({
      scale: clampedScale,
      translateX: newTranslateX,
      translateY: newTranslateY,
    });
  };

  const handleMouseDown = (e) => {
    if (e.button !== 0) return; // Left mouse button only
    const startX = e.clientX;
    const startY = e.clientY;

    const { translateX, translateY } = transform;

    const handleMouseMove = (e) => {
      const deltaX = e.clientX - startX;
      const deltaY = e.clientY - startY;

      setTransform({
        ...transform,
        translateX: translateX + deltaX,
        translateY: translateY + deltaY,
      });
    };

    const handleMouseUp = () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };

    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
  };

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    canvas.addEventListener('wheel', handleWheel, { passive: false });
    canvas.addEventListener('mousedown', handleMouseDown);

    return () => {
      canvas.removeEventListener('wheel', handleWheel);
      canvas.removeEventListener('mousedown', handleMouseDown);
    };
  }, [transform]);

  const { scale, translateX, translateY } = transform;

  return (
    <div
      ref={containerRef}
      style={{
        width: '100vw',
        height: '100vh',
        overflow: 'hidden',
        background: 'skyblue',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      <div
        ref={canvasRef}
        style={{
          width: '100vw',
          height: '100vh',
          background: 'violet',
          transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
          transformOrigin: '0 0',
          cursor: 'grab',
        }}
      >
        <span style={{ background: 'red', color: 'black' }}>this is the text</span>
      </div>
    </div>
  );
};

export default App;

Key Features

  1. Zoom Around Cursor:

    • The transformOrigin is always 0 0, and translate values are adjusted based on the cursor position.
  2. Panning:

    • Implemented with mousedown, mousemove, and mouseup to drag the canvas.
  3. Transform Management:

    • The transform state tracks the scale and translateX/translateY for proper rendering.
    • Clamped scale prevents excessive zoom in or out.
  4. Keyboard Modifiers:

    • Zooming is enabled only when the ctrlKey or metaKey is pressed during a wheel event.

Improvements & Extensions

Let me know if you need further adjustments!

explain the formula to me

Sure! Let's break down the formula used to calculate the new translation values when zooming in or out, specifically this part:

const deltaScale = clampedScale / scale;
const newTranslateX = translateX - (mouseX * (deltaScale - 1));
const newTranslateY = translateY - (mouseY * (deltaScale - 1));

Context

When zooming, the canvas is scaled using scale(). However, scaling always happens relative to the transformOrigin, which we set to 0 0 (top-left of the canvas). To ensure the zoom "centers" around the cursor (the point where the user is pointing with the mouse), we need to adjust the canvas's position (translateX and translateY) accordingly.

The goal is to "shift" the canvas so that the cursor stays fixed at the same spot in the viewport after the zoom.


Breaking Down the Formula

  1. deltaScale = clampedScale / scale

    • scale: The current zoom level (before the wheel event).

    • clampedScale: The new zoom level (after applying the zoom factor and clamping).

    • deltaScale: The ratio between the new scale and the current scale.

    • If deltaScale > 1, we are zooming in (scale increases).

    • If deltaScale < 1, we are zooming out (scale decreases).

    This tells us by how much the scale is changing.


  1. Adjusting Translations

    To ensure that the canvas zooms around the cursor position, we calculate how much to adjust the translations (translateX and translateY):

    • Horizontal adjustment (newTranslateX):
     newTranslateX = translateX - (mouseX * (deltaScale - 1));
    • translateX: The current horizontal translation.

    • mouseX: The cursor's X-coordinate relative to the canvas's top-left corner.

    • deltaScale - 1: The amount of change in scale. For example:

      • If deltaScale = 1.1 (zoom in), deltaScale - 1 = 0.1 (10% increase).
      • If deltaScale = 0.9 (zoom out), deltaScale - 1 = -0.1 (10% decrease).

      When zooming:

    • The mouseX is multiplied by this scale change (deltaScale - 1) to calculate how much of the canvas's width needs to shift to keep the cursor's position consistent.

    • Subtracting this value from translateX adjusts the canvas's position so that the cursor remains fixed.

      • Vertical adjustment (newTranslateY):
     newTranslateY = translateY - (mouseY * (deltaScale - 1));
    • This works exactly the same way as the horizontal adjustment, but for the vertical axis (translateY and mouseY).

Why This Works

When you zoom in or out, without adjusting the translations, the canvas would scale uniformly outward from the top-left corner (the transformOrigin). This would cause the point under the cursor to move on the screen.

To counteract this movement, the formula:


Example

Let's walk through an example:

Initial State:

Zoom In:

Now calculate the adjustments:

Result:


Zoom Out:

Now calculate the adjustments:

Result:


Let me know if you'd like further clarification!

Is it possible to implement this for the container whilst its fixed to the viewport. So the scaling happens for the content inside?

Yes, it is absolutely possible to implement the zoom functionality for the content inside a fixed container, rather than the container itself. The scaling and translation can be applied directly to the content inside the container, while keeping the container fixed to the viewport.

Here’s how you can achieve this:


Key Changes for the Implementation

  1. Fixed Container:

    • The container (div) will remain fixed to the viewport.
    • It acts as a "viewport" for the zooming and panning content.
  2. Transform Applied to Content:

    • Instead of scaling and translating the container itself, the transformations (scale and translate) are applied to the inner content.
  3. Bounding Box Management:

    • The fixed container acts like a "window" through which you view the zoomable and pannable content.

Updated Code

Here’s the modified implementation:

import { useEffect, useRef, useState } from 'react';

const App = () => {
  const containerRef = useRef(null);
  const contentRef = useRef(null);

  const [transform, setTransform] = useState({
    scale: 1,
    translateX: 0,
    translateY: 0,
  });

  const handleWheel = (e) => {
    e.preventDefault();

    const content = contentRef.current;
    if (!content || (!e.ctrlKey && !e.metaKey)) return;

    const { scale, translateX, translateY } = transform;

    // Get the cursor position relative to the content
    const { left, top } = content.getBoundingClientRect();
    const mouseX = e.clientX - left;
    const mouseY = e.clientY - top;

    // Zoom in or out
    const zoomFactor = 0.1;
    const newScale = e.deltaY < 0 ? scale * (1 + zoomFactor) : scale * (1 - zoomFactor);

    // Clamp scale between 0.5 and 5
    const clampedScale = Math.max(0.5, Math.min(newScale, 5));

    // Calculate new translation values to zoom around the cursor
    const deltaScale = clampedScale / scale;
    const newTranslateX = translateX - (mouseX * (deltaScale - 1));
    const newTranslateY = translateY - (mouseY * (deltaScale - 1));

    setTransform({
      scale: clampedScale,
      translateX: newTranslateX,
      translateY: newTranslateY,
    });
  };

  const handleMouseDown = (e) => {
    if (e.button !== 0) return; // Left mouse button only
    const startX = e.clientX;
    const startY = e.clientY;

    const { translateX, translateY } = transform;

    const handleMouseMove = (e) => {
      const deltaX = e.clientX - startX;
      const deltaY = e.clientY - startY;

      setTransform((prevTransform) => ({
        ...prevTransform,
        translateX: translateX + deltaX,
        translateY: translateY + deltaY,
      }));
    };

    const handleMouseUp = () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };

    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
  };

  useEffect(() => {
    const content = contentRef.current;
    if (!content) return;

    content.addEventListener('wheel', handleWheel, { passive: false });
    content.addEventListener('mousedown', handleMouseDown);

    return () => {
      content.removeEventListener('wheel', handleWheel);
      content.removeEventListener('mousedown', handleMouseDown);
    };
  }, [transform]);

  const { scale, translateX, translateY } = transform;

  return (
    <div
      ref={containerRef}
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        width: '100vw',
        height: '100vh',
        overflow: 'hidden',
        background: 'skyblue',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      <div
        ref={contentRef}
        style={{
          width: '2000px', // Content width (can be larger than the container)
          height: '2000px', // Content height (can be larger than the container)
          background: 'violet',
          transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
          transformOrigin: '0 0',
          cursor: 'grab',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        <span style={{ background: 'red', color: 'black' }}>this is the text</span>
      </div>
    </div>
  );
};

export default App;

Key Changes Explained

  1. Container Setup:

    • The outer container (containerRef) is fixed to the viewport using position: fixed and overflow: hidden.
    • It serves as the viewport for the inner content, which can scale and move.
  2. Scaling Content:

    • The transform (scale and translate) is applied to the contentRef (inner content), leaving the container unaffected.
    • transformOrigin is set to 0 0 for proper translation calculations.
  3. Cursor Position for Zoom:

    • The cursor's position relative to the content (not the container) is used to calculate how to adjust translateX and translateY.
  4. Large Content:

    • The contentRef dimensions (2000px by 2000px) are larger than the viewport, allowing meaningful panning and zooming.

How It Works


Result


Enhancements

  1. Bounds Check:

    • Ensure that the content doesn't pan completely out of the viewport. Clamp translateX and translateY values to prevent this.
  2. Touch Support:

    • Add pinch-to-zoom and pan gestures for mobile devices.
  3. Reset Button:

    • Add a button to reset scale and translate values to their defaults.

Would you like me to implement any of these enhancements?

Can you explain to me how does css scale affect element's position relative to viewport and how did you use it to extract the zooming plus transform logic to create this effect

How CSS scale() Affects an Element's Position Relative to the Viewport

The scale() property in CSS transforms an element by resizing it (making it larger or smaller) relative to its transform origin. Here's a detailed explanation:


Key Concepts of CSS scale()

  1. Transform Origin:

    • The transform-origin property determines the pivot point around which the element scales.
    • By default, this is 50% 50% (center of the element), but it can be set to any point (e.g., 0 0 for top-left, or a specific pixel position).
  2. Scaling:

    • When an element is scaled with scale(sx, sy):
      • sx: Horizontal scaling factor.
      • sy: Vertical scaling factor (optional; defaults to sx).
    • It modifies the size of the element without affecting its layout in the DOM.
    • The element scales visually but retains its original position and size in the document flow.
  3. Viewport Impact:

    • The element's position on the viewport changes visually if the transform origin isn't the top-left (0 0).
    • For example, if an element scales with its center (50% 50%) as the origin, its position relative to the viewport will appear to "move" as it scales outward from the center.
  4. Cumulative Effects:

    • The scaling can interact with other transforms (like translate, rotate, etc.).
    • This means when scaling is applied, the element’s visual position is affected relative to its scaled size.

Scaling for Zooming

In our case, the goal is to scale the content inside the fixed container while maintaining the cursor's relative position as the scaling center. Here's how this was achieved:


Logic Behind Zooming and Transform in the Code

To implement zooming and panning logic using scale:

1. Apply Scaling to the Content

We use the scale property to zoom the inner content (contentRef) while ensuring:

The transform:

transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,

applies both scaling and translations. Scaling resizes the content, and translations shift it so the cursor remains stable.


2. Cursor as the Pivot Point

When zooming in, the content visually expands outward. To make it appear as though the zoom is centered on the cursor:

  const mouseX = e.clientX - left; // Relative to the content's top-left
  const mouseY = e.clientY - top;

3. Translate to Cancel Out the Movement

When scaling changes:

const deltaScale = clampedScale / scale;
const newTranslateX = translateX - (mouseX * (deltaScale - 1));
const newTranslateY = translateY - (mouseY * (deltaScale - 1));

Here's what's happening:

  1. deltaScale = clampedScale / scale:

    • This calculates the relative change in scale (e.g., if scale increases from 1 to 1.2, deltaScale = 1.2).
  2. Calculate the Shift Due to Scale:

    • The distance from the transform-origin to the cursor (e.g., mouseX) is affected by the scale change.
    • (deltaScale - 1) gives the amount of scale change.
      • For zoom in (deltaScale > 1), this is a positive number.
      • For zoom out (deltaScale < 1), this is negative.
  3. Compensate for the Shift:

    • Multiply the cursor distance (mouseX, mouseY) by the scale change to calculate how much the element would "move."
    • Subtract this shift from the current translation values (translateX, translateY) to cancel out the movement.

This ensures that the visual scaling appears centered on the cursor.


4. Panning (Dragging the Content)

To implement panning:

  const deltaX = e.clientX - startX;
  const deltaY = e.clientY - startY;

  setTransform((prevTransform) => ({
    ...prevTransform,
    translateX: prevTransform.translateX + deltaX,
    translateY: prevTransform.translateY + deltaY,
  }));

This shifts the content relative to the drag distance (deltaX, deltaY).


Summary of Key Steps

  1. Scale Logic:

    • Use scale to zoom the content.
    • Adjust the translation values to make the zoom appear centered on the cursor.
  2. Translation Logic:

    • Combine translateX and translateY with scale to allow independent panning and zooming.
  3. Interaction Between Scale and Translate:

    • Scaling expands or contracts the content.
    • Translations correct for the shift caused by scaling, ensuring the viewport remains aligned with the cursor.

This combination of scale and translate provides a flexible and intuitive zoom-and-pan effect. Let me know if you need further clarification or enhancements!

No I meant what happens when we scale an element that we need to apply translate to cancel it explain for transformOrigin 50% 50% and 0 0

Great question! Let’s break it down to understand how scaling affects the position of an element in relation to the viewport, especially for different values of transform-origin (like 50% 50% and 0 0), and why we need to apply translations to cancel the shift caused by scaling.


How Scaling Affects Element Position

When we apply a scale() transformation to an element, the size of the element changes. However, the position of the element in the viewport appears to change if the transform-origin is not the top-left corner (0 0). Here’s why:

  1. Scaling Around Transform-Origin:

    • The scale() operation enlarges or shrinks the element around the transform-origin, which acts as the pivot point.
    • If the transform-origin is not at the top-left (0 0), the scaling will "pull" or "push" the element away from its original position.
  2. Viewport Perception:

    • The element’s visual position relative to the viewport changes because the scaling operation modifies the distances between the transform-origin and the element's edges.
    • The transform-origin stays in place, but the edges of the element expand or contract relative to it, creating the perception of movement.

Effect for Different transform-origin Values

Case 1: transform-origin: 0 0 (Top-Left Corner)

Case 2: transform-origin: 50% 50% (Center of the Element)


Why We Need Translation for transform-origin: 50% 50%

When scaling an element with transform-origin: 50% 50%, the following happens:

  1. The element scales outward or inward symmetrically around its center.
  2. The top-left corner of the element moves relative to the viewport because the center of the element remains fixed, but the size changes:
    • If scaling up (e.g., scale(2)), the top-left corner "moves" half the amount of the element's growth outward.
    • If scaling down (e.g., scale(0.5)), the top-left corner "moves" half the amount of the element's shrinkage inward.

This movement is proportional to the size change and needs to be corrected by translation.


Translation Adjustment Formula

To cancel the shift caused by scaling for any transform-origin, we calculate the translation required.

General Case:

  1. Delta Scale:

    • The change in scale is the factor by which the element grows or shrinks. This is represented as:
     deltaScale = newScale - 1

    (e.g., for scale(2), deltaScale = 1 because the element doubles in size.)

  2. Translation for transform-origin: 50% 50%:

    • The movement of the element's top-left corner is equal to half the change in size in both the horizontal and vertical directions:
     translateX = elementWidth * deltaScale * 0.5
     translateY = elementHeight * deltaScale * 0.5
  3. Translation for transform-origin: 0 0:

    • No translation is needed because the top-left corner remains fixed.

Example Walkthrough

Example 1: transform-origin: 0 0 with scale(2)


Example 2: transform-origin: 50% 50% with scale(2)

  transform: translate(-50px, -50px) scale(2);

Example 3: transform-origin: 25% 25% with scale(2)

  transform: translate(-25px, -25px) scale(2);

How This Works in Your Code

In your implementation:

  1. You calculate how much the element "shifts" due to scaling by measuring the distance from the cursor to the transform-origin (in this case, 0 0 or the cursor itself).
  2. The formula:
   translateX = translateX - (mouseX * (deltaScale - 1));
   translateY = translateY - (mouseY * (deltaScale - 1));

adjusts the translation to cancel out the perceived movement caused by scaling.

  1. This ensures that the element visually appears to scale around the cursor without jumping or shifting in the viewport.

Would you like to dive deeper into any part of this? Let me know!