元素拖动与旋转

用于dom拖动位置,以及按住指定dom旋转

export interface DraggableRotatableConfig {
    enableRotation?: boolean; // 是否开启旋转功能
    dragAxis?: 'x' | 'y' | 'both'; // 拖动方向(x、y 或 both)
    disabled?: boolean; // 是否禁用功能
    usePercentage?: boolean; // 是否使用百分比
    observerClass?: string, // 监听节点及其子元素变化
    onUpdate?: (state: { x: number; y: number; rotation: number }) => void; // 实时回调函数
}

export interface State {
    x?: number; // X 坐标(像素值或百分比)
    y?: number; // Y 坐标(像素值或百分比)
    rotation?: number; // 旋转角度
}

export class DraggableRotatableComponent {
    private observer: MutationObserver | null; // 容器元素
    private container: HTMLElement; // 容器元素
    private content: HTMLElement; // 内容元素
    private rotateHandle: HTMLElement; // 旋转触发元素
    private isDragging: boolean = false; // 是否正在拖动
    private isRotating: boolean = false; // 是否正在旋转
    private currentX: number = 0; // 当前 X 坐标(像素值)
    private currentY: number = 0; // 当前 Y 坐标(像素值)
    private initialX: number = 0; // 初始 X 坐标
    private initialY: number = 0; // 初始 Y 坐标
    private rotation: number = 0; // 当前旋转角度
    private startAngle: number = 0; // 开始旋转时的角度
    private centerX: number = 0; // 旋转中心 X 坐标
    private centerY: number = 0; // 旋转中心 Y 坐标
    private zoomScale: number = 1; // 父级的缩放比例
    private animationFrameId: number | null = null; // 动画帧 ID
    private config: DraggableRotatableConfig; // 配置项

    // 绑定事件监听器的引用,用于销毁时解绑
    private startDragBound: (e: MouseEvent) => void;
    private dragBound: (e: MouseEvent) => void;
    private stopDragBound: () => void;
    private startRotateBound: (e: MouseEvent) => void;
    private rotateBound: (e: MouseEvent) => void;
    private stopRotateBound: () => void;
    private domChangeBound: (mutationsList: MutationRecord[], observer: MutationObserver) => void;


    constructor(
        container: HTMLElement,
        config: DraggableRotatableConfig = {},
        initialState: State = {} // 初始状态
    ) {
        this.observer = null;
        this.container = container;
        this.container.style.transformOrigin = 'center center';
        this.content = container.querySelector('.draggable-content') as HTMLElement;
        this.rotateHandle = container.querySelector('.rotate-handle') as HTMLElement;
        this.config = {
            enableRotation: false, // 默认开启旋转功能
            dragAxis: 'both', // 默认允许双向拖动
            disabled: false, // 默认不禁用
            usePercentage: true, // 默认不使用百分比
            ...config, // 覆盖默认配置
        };

        // 检查必要的元素是否存在
        if (!this.content || !this.rotateHandle) {
            throw new Error('Required elements not found!');
        }

        // 获取父级的缩放比例
        this.zoomScale = this.getParentZoomScale(container);

        // 绑定事件监听器的引用
        this.startDragBound = this.startDrag.bind(this);
        this.dragBound = this.drag.bind(this);
        this.stopDragBound = this.stopDrag.bind(this);
        this.startRotateBound = this.startRotate.bind(this);
        this.rotateBound = this.rotate.bind(this);
        this.stopRotateBound = this.stopRotate.bind(this);
        this.domChangeBound = this.domChange.bind(this)
        // 设置初始状态
        this.setState(initialState);

        // 初始化事件监听
        this.init();
    }

    /**
     * 初始化事件监听
     */
    private init(): void {
        if (this.config.disabled) {
            return; // 如果禁用功能,则不绑定事件
        }
        if (this.config.observerClass) this.domChangeInit.bind(this)()
        // 绑定拖动事件
        this.container.addEventListener('mousedown', this.startDragBound);
        document.addEventListener('mousemove', this.dragBound);
        document.addEventListener('mouseup', this.stopDragBound);
        // 如果开启旋转功能,则绑定旋转事件
        if (this.config.enableRotation) {
            this.rotateHandle.addEventListener('mousedown', this.startRotateBound);
            document.addEventListener('mousemove', this.rotateBound);
            document.addEventListener('mouseup', this.stopRotateBound);
        } else {
            // 如果禁用旋转功能,则隐藏旋转触发元素
            this.rotateHandle.style.display = 'none';
        }
    }
    /**
     * 内容大小变化后重新修改位置和角度信息
     */
    private domChangeInit() {
        try {
            // 内容变化监听
            const MutationObserver = window.MutationObserver;
            const config = { childList: true, subtree: true, attributes: true };
            const targetdom = this.content.querySelector(this.config?.observerClass || '');
            if (!targetdom) return;
            this.observer = new MutationObserver(this.domChangeBound);
            this.observer.observe(targetdom, config);
            console.log('domChangeInit successfully');
        } catch (_) {
            console.log('domChangeInit error');
            console.error(_);

        }
    }

    private domChange() {
        // const deltaX = Math.round((newRect.width - this.prevRect.width) / this.zoomScale);
        // const deltaY = Math.round((newRect.height - this.prevRect.height) / this.zoomScale);
        // console.log('deltaX:', newRect, 'deltaY:', this.prevRect);
        // // 调整 currentX 和 currentY,确保内容位置不变
        // this.currentX -= deltaX / 2;
        // this.currentY -= deltaY / 2;
        console.log('domSizeZoom');
        // // 更新容器的位置和旋转角度
        // this.updateTransform();
    }
    /**
     * 开始拖动
     * @param e 鼠标事件
     */
    private startDrag(e: MouseEvent): void {
        if (e.target !== this.rotateHandle) {
            this.isDragging = true;

            // 考虑缩放比例,计算初始位置
            this.initialX = e.clientX / this.zoomScale - this.currentX;
            this.initialY = e.clientY / this.zoomScale - this.currentY;

            // 使用 requestAnimationFrame 开始动画
            this.animationFrameId = requestAnimationFrame(() => this.updateTransform());
        }
    }

    /**
     * 拖动中
     * @param e 鼠标事件
     */
    private drag(e: MouseEvent): void {
        if (this.isDragging) {
            e.preventDefault();

            // 根据配置限制拖动方向
            if (this.config.dragAxis === 'x') {
                this.currentX = e.clientX / this.zoomScale - this.initialX;
            } else if (this.config.dragAxis === 'y') {
                this.currentY = e.clientY / this.zoomScale - this.initialY;
            } else {
                this.currentX = e.clientX / this.zoomScale - this.initialX;
                this.currentY = e.clientY / this.zoomScale - this.initialY;
            }

            // 使用 requestAnimationFrame 优化性能
            if (!this.animationFrameId) {
                this.animationFrameId = requestAnimationFrame(() => this.updateTransform());
            }
        }
    }

    /**
     * 停止拖动
     */
    private stopDrag(): void {
        this.isDragging = false;

        // 停止动画
        if (this.animationFrameId) {
            cancelAnimationFrame(this.animationFrameId);
            this.animationFrameId = null;
        }
    }

    /**
     * 开始旋转
     * @param e 鼠标事件
     */
    private startRotate(e: MouseEvent): void {
        this.isRotating = true;

        // 计算旋转中心点
        const rect = this.content.getBoundingClientRect();
        this.centerX = rect.left + rect.width / 2;
        this.centerY = rect.top + rect.height / 2;

        // 计算初始角度
        this.startAngle = this.getAngle(e.clientX, e.clientY);
    }

    /**
     * 旋转中
     * @param e 鼠标事件
     */
    private rotate(e: MouseEvent): void {
        if (this.isRotating) {
            e.preventDefault();

            // 计算当前角度
            const currentAngle = this.getAngle(e.clientX, e.clientY);
            this.rotation += currentAngle - this.startAngle; // 累积旋转角度
            this.startAngle = currentAngle; // 更新起始角度

            // 更新旋转角度
            this.updateTransform();
        }
    }

    /**
     * 停止旋转
     */
    private stopRotate(): void {
        if (this.isRotating) {
            this.isRotating = false;
            // 松开时纠正角度到最近的 45 度倍数
            this.correctRotation();
        }
    }
    private getClientRect() {
        // 获取内容的宽高
        const rect = this.content.getBoundingClientRect();
        const width = rect.width / this.zoomScale;
        const height = rect.height / this.zoomScale;
        // 计算中心点偏移
        const offsetX = this.config.dragAxis === 'y' ? 0 : width / 2
        const offsetY = this.config.dragAxis === 'x' ? 0 : height / 2
        return { offsetX, offsetY }
    }
    /**
     *  获取画布元素的宽高
     * @returns 
     */
    private getClientParentRect() {
        const parentRect = this.container.parentElement!.getBoundingClientRect();
        const parentWidth = parentRect.width / this.zoomScale; // 考虑 zoom 缩放
        const parentHeight = parentRect.height / this.zoomScale; // 考虑 zoom 缩放 
        return { parentWidth, parentHeight }
    }
    private setTransform() {
        // 更新容器的位置和旋转角度
        const transform = `translate(${this.currentX}px, ${this.currentY}px) rotate(${this.rotation}deg)`;;
        this.container.style.transform = transform;
    }
    private setUpdateData() {
        const { parentWidth, parentHeight } = this.getClientParentRect()
        const { offsetX, offsetY } = this.getClientRect()
        // 触发回调函数,返回当前状态
        if (this.config.onUpdate) {
            const state = {
                x: this.config.usePercentage
                    ? ((this.currentX + offsetX) / parentWidth) * 100  // 返回百分比
                    : this.currentX + offsetX, // 返回像素值
                y: this.config.usePercentage
                    ? (((this.currentY + offsetY) / parentHeight) * 100)  // 返回百分比
                    : this.currentY + offsetY, // 返回像素值
                rotation: this.rotation,
            };
            this.config.onUpdate(state);
        }
    }
    /**
     * 更新容器的位置和旋转角度
     */
    private updateTransform(): void {
        this.setTransform();
        this.setUpdateData();
        // 重置动画帧 ID
        this.animationFrameId = null;
    }

    /**
     * 纠正旋转角度到最近的 45 度倍数
     */
    private correctRotation(): void {
        // 计算最近的 45 度倍数
        const targetRotation = Math.round(this.rotation / 45) * 45;

        // 使用动画平滑过渡到目标角度
        const animationDuration = 200; // 动画时长 200ms
        const startRotation = this.rotation;
        const startTime = performance.now();

        const animate = (currentTime: number) => {
            const elapsedTime = currentTime - startTime;
            const progress = Math.min(elapsedTime / animationDuration, 1);

            // 插值计算当前角度
            this.rotation = startRotation + (targetRotation - startRotation) * progress;
            this.updateTransform();

            // 继续动画直到完成
            if (progress < 1) {
                requestAnimationFrame(animate);
            }
        };

        requestAnimationFrame(animate);
    }

    /**
     * 计算鼠标位置与旋转中心点的角度
     * @param clientX 鼠标的 X 坐标
     * @param clientY 鼠标的 Y 坐标
     * @returns 角度(单位:度)
     */
    private getAngle(clientX: number, clientY: number): number {
        // 计算鼠标位置与旋转中心点的角度
        const deltaX = (clientX - this.centerX) / this.zoomScale;
        const deltaY = (clientY - this.centerY) / this.zoomScale;
        return (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
    }

    /**
     * 获取父级的缩放比例
     * @param element 当前元素
     * @returns 缩放比例
     */
    private getParentZoomScale(element: HTMLElement): number {
        let scale = 1;
        let currentElement: HTMLElement | null = element;

        // 遍历父级元素,查找是否有 zoom 属性
        while (currentElement) {
            const zoomValue = window.getComputedStyle(currentElement).zoom;
            if (zoomValue && !isNaN(parseFloat(zoomValue))) {
                scale *= parseFloat(zoomValue);
            }
            currentElement = currentElement.parentElement;
        }

        return scale;
    }

    /**
     * 更新配置
     * @param config 新的配置项
     */
    public updateConfig(config: DraggableRotatableConfig): void {
        this.config = { ...this.config, ...config }; // 合并配置

        // 如果禁用功能,则移除事件监听
        if (this.config.disabled) {
            this.destroy();
        } else {
            // 否则重新初始化事件监听
            this.init();
        }
    }

    /**
     * 设置状态(外部更新旋转角度和位置)
     * @param state 新的状态
     */
    public setState(state: State): void {
        // 获取画布宽高
        const { parentWidth, parentHeight } = this.getClientParentRect();
        // 获取内容的宽高
        const { offsetX, offsetY } = this.getClientRect()
        // 更新 X 坐标
        if (state.x !== undefined) {
            if (this.config.usePercentage) {
                this.currentX = (state.x) / 100 * parentWidth - offsetX; // 百分比转换为像素值 
            } else {
                this.currentX = state.x - offsetX; // 直接使用像素值
            }
        }

        // 更新 Y 坐标
        if (state.y !== undefined) {
            if (this.config.usePercentage) {
                this.currentY = (state.y) / 100 * parentHeight - offsetY; // 百分比转换为像素值 
            } else {
                this.currentY = state.y - offsetY; // 直接使用像素值
            }
        }

        // 更新旋转角度
        if (state.rotation !== undefined) {
            this.rotation = state.rotation;
        }

        // 更新位置和旋转角度
        this.updateTransform();
    }

    /**
     * 销毁实例,清理事件监听和引用
     */
    public destroy(): void {
        // 移除拖动事件
        this.container.removeEventListener('mousedown', this.startDragBound);
        document.removeEventListener('mousemove', this.dragBound);
        document.removeEventListener('mouseup', this.stopDragBound);

        // 移除旋转事件
        this.rotateHandle.removeEventListener('mousedown', this.startRotateBound);
        document.removeEventListener('mousemove', this.rotateBound);
        document.removeEventListener('mouseup', this.stopRotateBound);

        // 停止动画
        if (this.animationFrameId) {
            cancelAnimationFrame(this.animationFrameId);
            this.animationFrameId = null;
        }
        this.observer?.disconnect();
        // 清理引用
        this.container = null!;
        this.content = null!;
        this.rotateHandle = null!;
    }
}

使用方法

// 初始化组件
const draggableContainer: HTMLElement | null = document.querySelector('.draggable-container');
if (draggableContainer) {
  const component = new DraggableRotatableComponent(
    draggableContainer,
    {
      enableRotation: true, // 开启旋转功能
      dragAxis: 'both', // 允许双向拖动
      disabled: false, // 不禁用
      usePercentage: true, // 使用百分比
      onUpdate: (state) => {
        console.log('Current state:', state); // 实时打印当前状态
      },
    },
    {
      x: 50, // 初始 X 坐标为 50%
      y: 50, // 初始 Y 坐标为 50%
      rotation: 45, // 初始旋转角度为 45 度
    }
  );

  // 动态更新配置
  // component.updateConfig({ enableRotation: false }); // 禁用旋转功能
  // component.updateConfig({ dragAxis: 'x' }); // 仅允许沿 X 轴拖动
  // component.updateConfig({ disabled: true }); // 禁用所有功能

  // 销毁实例
  // component.destroy();
} else {
  console.error('Draggable container not found!');
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容