Сделать постоянную скорость перемещения содержимого div при его масштабирование с помощью transform: translate() scale();

Прочитал статью https://habr.com/ru/articles/722964/

Сделать рабочую область получилось, добавил возможность двигать <div class="node">, но при изменении зума, скорость (изменение положения div) перемещения уменьшается. Я хочу чтобы скорость оставалась постоянной.

Как это сделать? Помогите, пожалуйста???

Что сделал я, компонент App и MovingComponent, он вместо <div class="node">:

import { useEffect, useRef, useState } from 'react';
import { TextInput, Button } from './components';
import "./App.css"

export interface IBlock {
    x: number;
    y: number;
}

interface IMovingComponentProps {
    block: IBlock;
    onMove: (newX: number, newY: number) => void;
    children?: React.ReactNode;
}



const MovingComponent = (props: IMovingComponentProps) => {
    const [isDragging, setIsDragging] = useState(false);
    let offsetX = 0;
    let offsetY = 0;



    const handleMouseDown = (e: React.MouseEvent) => {
        setIsDragging(true);
        e.preventDefault();
        e.stopPropagation();
        
        // как я понял, нужно учитывать scale родительского div
        // но я не понимаю где и как это нужно делать, здесь и в методе handleMouseMove?
        offsetX = e.clientX - props.block.x;
        offsetY = e.clientY - props.block.y;

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

    const handleMouseMove = (e: MouseEvent) => {
        e.stopPropagation();
        props.onMove(e.clientX - offsetX, e.clientY - offsetY)
    };

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

    return (
        <div
            className={`interactive-block  ${isDragging ? 'selected' : ''} `}
            style={{
                top: props.block.y,
                left: props.block.x,
                border: 'solid',
                zIndex: 1
            }}
            onMouseDown={handleMouseDown}
        >
            {props.children}
        </div>
    );
};


function App() {

    const [block, setBlock] = useState<IBlock>({ x: 100, y: 100 });
    const handleMove = (newX: number, newY: number) => {
        setBlock({ x: newX, y: newY });
    }

    const layerRef = useRef<HTMLDivElement>(null);
    const [viewport, setViewport] = useState({
        offset: {
            x: 0.0,
            y: 0.0
        },
        zoom: 1
    });

    useEffect(() => {
        if (!layerRef.current) {
            return;
        }

        layerRef.current.onwheel = (e: WheelEvent) => {
            e.preventDefault();
            e.stopPropagation();

            if (e.ctrlKey) {
                const speedFactor =
                    (e.deltaMode === 1 ? 0.05 : e.deltaMode ? 1 : 0.002) * 10;

                setViewport((prev) => {
                    const pinchDelta = -e.deltaY * speedFactor;

                    return {
                        ...prev,
                        zoom: Math.min(
                            1.3,
                            Math.max(0.1, prev.zoom * Math.pow(2, pinchDelta))
                        )
                    };
                });
            }
        };
    }, [setViewport]);

    return (
        <div className="app"
            ref={layerRef}>
            <div className="container">
                <div
                    className="nodes-container"
                    style={{
                        transform: `
                      translate(
                        ${viewport.offset.x * viewport.zoom}px, 
                        ${viewport.offset.y * viewport.zoom}px
                      ) 
                      scale(${viewport.zoom})
                    `
                    }}
                >
                    <MovingComponent block={block} onMove={handleMove}>Что-то</MovingComponent>
                </div>
            </div>
        </div>
    );
}

export default App;

файл App.css

.interactive-block {
    position: absolute;
    border: solid black 2px;
    padding: 10px;
    background-color: #fff;
}


html,
body {
    width: 100%;
    height: 100%;
}

.app {
    overflow: hidden;
    width: 100%;
    height: 100%;
    position: relative;
}

.container,
.nodes-container {
    position: absolute;
    width: 100%;
    height: 100%;
    top: 0px;
    left: 0px;
}

.container {
    overflow: hidden;
}

Инструкция как запустить код:

  1. создайте стандартное приложение react в vs.code
  2. скопируйте код с компонентами App и MovingComponent в файл App.tsx
  3. создай файл App.css в той же папке , где лежит App.tsx и скопируйте код css туда
  4. в файле index.html дополните <div id="root" style="width: 100%; height: 100%;"></div>
  5. запустите приложение react

Ответы (1 шт):

Автор решения: Кирилл Серебренников

Вот исправленный код компонентов, файл CSS менять не надо. Всего-то нужно было понять, где и как учитывать новый масштаб.

Вроде бы работает:

import { useEffect, useRef, useState } from 'react';
import "./App.css"

export interface IBlock {
    x: number;
    y: number;
}

interface IMovingComponentProps {
    block: IBlock;
    i: number;
    port: {
        offset: {
            x: number,
            y: number
        },
        zoom: number
    }
    onMove: (newX: number, newY: number, i: number) => void;
    children?: React.ReactNode;
}

const MovingComponent = (props: IMovingComponentProps) => {
    const [isDragging, setIsDragging] = useState(false);
    let offsetX = 0;
    let offsetY = 0;

    const handleMouseDown = (e: React.MouseEvent) => {
        setIsDragging(true);
        e.preventDefault();
        e.stopPropagation();
        // нужно учитывать масштаб здесь
        offsetX = e.clientX / props.port.zoom - props.block.x;
        offsetY = e.clientY / props.port.zoom - props.block.y;

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

    const handleMouseMove = (e: MouseEvent) => {
        e.stopPropagation();
        // и нужно учитывать масштаб здесь
        props.onMove((e.clientX / props.port.zoom - offsetX), (e.clientY / props.port.zoom - offsetY), props.i);
    };

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

    return (
        <div
            className={`interactive-block  ${isDragging ? 'selected' : ''} `}
            style={{
                top: props.block.y,
                left: props.block.x,
                border: 'solid',
                zIndex: 1,
            }}
            onMouseDown={handleMouseDown}
        >
            {props.children}
        </div>
    );
};

function App() {

    const [block, setBlock] = useState<IBlock[]>([{ x: 100, y: 100 }, { x: 300, y: 100 }]);

    const handleMove = (newX: number, newY: number, index: number) => {
        let newBlocks = block.filter((b, i) => i != index)
        let newBlock = block[index];
        newBlock.x = newX;
        newBlock.y = newY;
        newBlocks.push(newBlock);
        setBlock(newBlocks);
        //setBlock({ x: newX, y: newY });
    }

    const layerRef = useRef<HTMLDivElement>(null);
    const [viewport, setViewport] = useState({
        offset: {
            x: 0.0,
            y: 0.0
        },
        zoom: 1
    });

    useEffect(() => {
        if (!layerRef.current) {
            return;
        }

        layerRef.current.onwheel = (e: WheelEvent) => {
            e.preventDefault();
            e.stopPropagation();

            if (e.ctrlKey) {
                const speedFactor = 0.002;

                setViewport((prev) => {
                    const pinchDelta = -e.deltaY * speedFactor;

                    return {
                        ...prev,
                        zoom: Math.min(
                            1.3,
                            Math.max(0.1, prev.zoom * Math.pow(2, pinchDelta))
                        )
                    };
                });
            }
        };
    }, [setViewport]);

    return (
        <div className="app"
            ref={layerRef}>
            <div className="container">
                <div
                    className="nodes-container"
                    style={{
                        transform: `
                      translate(
                        ${viewport.offset.x * viewport.zoom}px, 
                        ${viewport.offset.y * viewport.zoom}px
                      ) 
                      scale(${viewport.zoom})
                    `
                    }}
                >
                    {block.map((b, i) =>
                        <MovingComponent
                            key={i}
                            block={b}
                            onMove={handleMove}
                            port={viewport}
                            i={i}
                        >
                            Что-то {i}
                        </MovingComponent>
                    )}
                </div>
            </div>
        </div>
    );
}

export default App;
→ Ссылка