✂️图片裁剪进阶篇 - 固定比例与裁剪模式

写在开头

嘿嘿,大家好!👋

今是2025年06月15日,台风 "蝴蝶" 过境, 大雨倾盆,连下了两天,愉快的周末又宣告破产了。

蜗居两天,刷了一些资讯,看了一下火山引擎的 原动力大会,剩下时间就是刷刷视频,追追剧。

然后,继上次小编分享了两篇关于图片裁剪的文章:

想不到还有续集呢。😋😋😋

今天我们将来深入探讨图片裁剪的两个进阶功能 ------ 固定比例裁剪模式 ,具体效果如下,请诸君按需食用哈。

本次咱们就不从头开始一点点实现了,建议可以看看先前的两篇文章,或者直接看文末提供的完整源码。

固定比例

固定比例裁剪功能允许用户选择固定的宽高比例进行裁剪,比如1:1的正方形、3:4的竖版照片、4:3的横版照片等(参照了某红书📕的比例)。这个功能在社交媒体时代特别重要,因为不同平台对图片尺寸有不同的要求。

裁剪范围的核心在于比例约束算法,当用户拖拽裁剪框时,我们需要实时计算并保持指定的宽高比例,咱们单独用一个函数来维护裁剪比例:

javascript 复制代码
// 获取比例值的函数
function getAspectRatioValue(ratio) {
    switch (ratio) {
        case "1:1": return 1; 
        case "3:4": return 3 / 4; 
        case "4:3": return 4 / 3;
        default: return null;
    }
}

接下来是是整个功能最复杂的部分😵,咱们需要根据用户的拖拽方向和变化量,动态地调整裁剪框的尺寸,同时保持比例不变:

javascript 复制代码
document.body.addEventListener("mousemove", (e) => {
    // 若未处于拖拽状态,直接返回
    if (!dragging) return;

    // 获取鼠标当前位置并计算与起始位置的差值
    const { clientX, clientY } = e;
    let diffX = clientX - startPoint[0];
    let diffY = clientY - startPoint[1];

    // 处理移动裁剪框的逻辑(而非调整大小)
    if (moving) {
        // 限制移动范围,确保裁剪框不会超出容器边界
        diffX = Math.min(
            Math.max(diffX, -startDimension[LEFT]),  // 左边界限制
            startDimension[RIGHT]                    // 右边界限制
        );
        diffY = Math.min(
            Math.max(diffY, -startDimension[TOP]),   // 上边界限制
            startDimension[BOTTOM]                   // 下边界限制
        );
    }

    // 裁剪框最小尺寸限制
    const minWidth = 30;
    const minHeight = 30;
    // 获取当前选择的裁剪比例和容器尺寸
    const currentAspectRatio = cutRange.value;
    const cropperWidth = cropper.clientWidth;
    const cropperHeight = cropper.clientHeight;

    // 固定比例模式处理(非自定义比例且非移动操作)
    if (currentAspectRatio !== "custom" && !moving) {
        const ratio = getAspectRatioValue(currentAspectRatio);
        if (ratio) {
            // 计算当前裁剪框尺寸
            const currentWidth = cropperWidth - startDimension[LEFT] - startDimension[RIGHT];
            const currentHeight = cropperHeight - startDimension[TOP] - startDimension[BOTTOM];

            // 根据拖拽方向计算宽高变化量
            let newWidth, newHeight;
            let widthChange = 0;
            let heightChange = 0;

            // 处理左右方向的拖拽
            if (direction[LEFT] === 1) {
                widthChange = -diffX;  // 左边缘拖拽,宽度变化与鼠标移动相反
            } else if (direction[RIGHT] === -1) {
                widthChange = diffX;   // 右边缘拖拽,宽度变化与鼠标移动相同
            }

            // 处理上下方向的拖拽
            if (direction[TOP] === 1) {
                heightChange = -diffY; // 上边缘拖拽,高度变化与鼠标移动相反
            } else if (direction[BOTTOM] === -1) {
                heightChange = diffY;  // 下边缘拖拽,高度变化与鼠标移动相同
            }

            // 判断是否为角落拖拽(同时改变宽高)
            const isCornerDrag =
                (direction[LEFT] === 1 || direction[RIGHT] === -1) &&
                (direction[TOP] === 1 || direction[BOTTOM] === -1);

            // 根据拖拽类型计算新的宽高(保持固定比例)
            if (isCornerDrag) {
                // 角落拖拽时,根据鼠标移动幅度更大的方向决定优先调整的维度
                if (Math.abs(widthChange) > Math.abs(heightChange)) {
                    newWidth = Math.max(minWidth, currentWidth + widthChange);
                    newHeight = newWidth / ratio;  // 根据比例计算高度
                } else {
                    newHeight = Math.max(minHeight, currentHeight + heightChange);
                    newWidth = newHeight * ratio;  // 根据比例计算宽度
                }
            } else {
                // 单边拖拽时,根据拖拽方向决定优先调整的维度
                if (direction[LEFT] === 1 || direction[RIGHT] === -1) {
                    newWidth = Math.max(minWidth, currentWidth + widthChange);
                    newHeight = newWidth / ratio;  // 根据宽度计算高度
                } else {
                    newHeight = Math.max(minHeight, currentHeight + heightChange);
                    newWidth = newHeight * ratio;  // 根据高度计算宽度
                }
            }

            // 边界检查 - 确保不超出容器最大尺寸
            const maxWidth = cropperWidth - CROPPER_MARGIN;
            const maxHeight = cropperHeight - CROPPER_MARGIN;

            // 如果新尺寸超出容器,按比例缩小至最大允许尺寸
            if (newWidth >= maxWidth || newHeight >= maxHeight) {
                if (newWidth / maxWidth > newHeight / maxHeight) {
                    newWidth = maxWidth;
                    newHeight = newWidth / ratio;
                } else {
                    newHeight = maxHeight;
                    newWidth = newHeight * ratio;
                }

                // 如果调整量过小,忽略此次更新以避免抖动
                if (
                    Math.abs(newWidth - currentWidth) < 1 &&
                    Math.abs(newHeight - currentHeight) < 1
                ) {
                    return;
                }
            }

            // 计算新的裁剪框位置(上下左右边距)
            let newLeft = startDimension[LEFT];
            let newTop = startDimension[TOP];
            let newRight = startDimension[RIGHT];
            let newBottom = startDimension[BOTTOM];

            // 处理左右边界的调整
            if (direction[LEFT] === 1) {
                newRight = startDimension[RIGHT];
                newLeft = cropperWidth - newWidth - newRight;  // 固定右边,调整左边
            } else if (direction[RIGHT] === -1) {
                newLeft = startDimension[LEFT];
                newRight = cropperWidth - newWidth - newLeft;  // 固定左边,调整右边
            } else if (!isCornerDrag) {
                // 居中调整(当仅调整高度时)
                const currentHorizontalCenter = startDimension[LEFT] + 
                    (cropperWidth - startDimension[LEFT] - startDimension[RIGHT]) / 2;
                const newHorizontalOffset = newWidth / 2;
                newLeft = Math.max(
                    0,
                    Math.min(cropperWidth - newWidth, currentHorizontalCenter - newHorizontalOffset)
                );
                newRight = cropperWidth - newWidth - newLeft;
            }

            // 处理上下边界的调整
            if (direction[TOP] === 1) {
                newBottom = startDimension[BOTTOM];
                newTop = cropperHeight - newHeight - newBottom;  // 固定下边,调整上边
            } else if (direction[BOTTOM] === -1) {
                newTop = startDimension[TOP];
                newBottom = cropperHeight - newHeight - newTop;  // 固定上边,调整下边
            } else if (!isCornerDrag) {
                // 居中调整(当仅调整宽度时)
                const currentVerticalCenter = startDimension[TOP] + 
                    (cropperHeight - startDimension[TOP] - startDimension[BOTTOM]) / 2;
                const newVerticalOffset = newHeight / 2;
                newTop = Math.max(
                    0,
                    Math.min(cropperHeight - newHeight, currentVerticalCenter - newVerticalOffset)
                );
                newBottom = cropperHeight - newHeight - newTop;
            }

            // 最终边界检查 - 确保边距非负
            if (newLeft < 0) {
                newLeft = 0;
                newRight = cropperWidth - newWidth;
            }
            if (newTop < 0) {
                newTop = 0;
                newBottom = cropperHeight - newHeight;
            }
            if (newRight < 0) {
                newRight = 0;
                newLeft = cropperWidth - newWidth;
            }
            if (newBottom < 0) {
                newBottom = 0;
                newTop = cropperHeight - newHeight;
            }

            // 更新裁剪框位置和大小
            setDimension([newTop, newRight, newBottom, newLeft]);
        }
    } else {
        // 自由调整模式(自定义比例或移动操作)
        const currentDimensionNew = [0, 0, 0, 0];
        
        // 计算新的边距值,确保不小于0且裁剪框尺寸不小于最小限制
        currentDimensionNew[TOP] = Math.min(
            Math.max(startDimension[TOP] + direction[TOP] * diffY, 0),
            cropperHeight - currentDimensionNew[BOTTOM] - minHeight
        );
        currentDimensionNew[RIGHT] = Math.min(
            Math.max(startDimension[RIGHT] + direction[RIGHT] * diffX, 0),
            cropperWidth - currentDimensionNew[LEFT] - minWidth
        );
        currentDimensionNew[BOTTOM] = Math.min(
            Math.max(startDimension[BOTTOM] + direction[BOTTOM] * diffY, 0),
            cropperHeight - currentDimensionNew[TOP] - minHeight
        );
        currentDimensionNew[LEFT] = Math.min(
            Math.max(startDimension[LEFT] + direction[LEFT] * diffX, 0),
            cropperWidth - currentDimensionNew[RIGHT] - minWidth
        );
        
        // 更新裁剪框位置和大小
        setDimension(currentDimensionNew);
    }
});

这段代码是图片裁剪功能的核心逻辑,主要实现了两种裁剪模式:

  1. 固定比例裁剪:当用户选择了特定比例(如 1:1、3:4、4:3)时,裁剪框会始终保持这个比例进行调整。代码通过计算宽高变化量并互相约束来实现这一点。
  2. 自由调整裁剪:当用户选择自定义比例时,裁剪框可以自由调整大小,只需确保不小于最小尺寸限制。

这代码中包含了复杂的边界检查逻辑,确保裁剪框不会超出容器范围,同时呢,也处理了多种拖拽场景(单边拖拽、角落拖拽、移动裁剪框),通过一些细致的条件判断和计算,实现了比较流畅的裁剪交互体验,这整个过程非常考验你的基础 JS 能力唷。👈

效果:

裁剪模式

裁剪模式是这次图片裁剪的另一个功能,它的作用是决定裁剪区域外的内容如何处理。

小编我实现了三种模式:

  1. 去除背景 - 只保留裁剪区域。
  2. 保留黑边 - 用指定颜色填充裁剪区域外的部分。
  3. 图片模糊 - 裁剪区域外显示模糊的原图。

裁剪核心咱们还是背靠 Canvas 提供的能力。✅

模式一:去除背景

这是最简单的模式👌,直接裁剪出指定区域:

js 复制代码
if (mode === "remove") {
    // 去除模式:保持裁剪区域的原始尺寸
    canvas.width = actualWidth;
    canvas.height = actualHeight;
    ctx.clearRect(0, 0, actualWidth, actualHeight);
    
    // 直接绘制裁剪区域
    ctx.drawImage(
        bgImage,
        actualX, actualY, actualWidth, actualHeight,  // 源图区域
        0, 0, actualWidth, actualHeight               // 目标区域
    );
}

模式二:保留黑边

这个模式会保持原始画布尺寸,用指定颜色遮罩裁剪区域外的部分:

js 复制代码
else if (mode === "blackBorder") {
    // 保留黑边模式
    canvas.width = CANVAS_WIDTH;
    canvas.height = CANVAS_HEIGHT;
    ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    
    // 先绘制完整图片
    ctx.drawImage(bgImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    
    // 用指定颜色填充裁剪区域外的部分
    ctx.fillStyle = blackBorderColor.value;
    
    // 分别遮罩四个区域
    // 遮罩上方
    ctx.fillRect(0, 0, CANVAS_WIDTH, currentDimension[TOP]);
    // 遮罩下方
    ctx.fillRect(0, CANVAS_HEIGHT - currentDimension[BOTTOM], 
                 CANVAS_WIDTH, currentDimension[BOTTOM]);
    // 遮罩左侧
    ctx.fillRect(0, currentDimension[TOP], currentDimension[LEFT], 
                 CANVAS_HEIGHT - currentDimension[TOP] - currentDimension[BOTTOM]);
    // 遮罩右侧
    ctx.fillRect(CANVAS_WIDTH - currentDimension[RIGHT], currentDimension[TOP], 
                 currentDimension[RIGHT], 
                 CANVAS_HEIGHT - currentDimension[TOP] - currentDimension[BOTTOM]);
}

模式三:图片模糊

这是最有趣也是最复杂的模式,我们需要在裁剪区域外显示模糊的原图,而裁剪区域内保持清晰。

js 复制代码
else if (mode === "vague") {
    // 图片模糊模式
    const blurValue = getVagueValue();  // 获取模糊度
    canvas.width = CANVAS_WIDTH;
    canvas.height = CANVAS_HEIGHT;
    ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

    // 第一步:绘制模糊的完整图片
    ctx.filter = `blur(${blurValue}px)`;
    ctx.drawImage(bgImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

    // 第二步:重置滤镜并绘制清晰的裁剪区域
    ctx.filter = "none";
    ctx.save();
    
    // 创建裁剪路径
    ctx.beginPath();
    ctx.rect(
        currentDimension[LEFT],
        currentDimension[TOP],
        CANVAS_WIDTH - currentDimension[LEFT] - currentDimension[RIGHT],
        CANVAS_HEIGHT - currentDimension[TOP] - currentDimension[BOTTOM]
    );
    ctx.clip();  // 设置裁剪区域
    
    // 在裁剪区域内绘制清晰图片
    ctx.drawImage(bgImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
    ctx.restore();
}

这种模式使用 Canvasfilter 属性实现模糊效果,先绘制模糊的完整图片,然后通过裁剪区域只显示清晰的部分,形成突出显示的效果。

效果:

完整源码

传送门


至此,本篇文章就写完啦,撒花撒花。

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。

老样子,点赞+评论=你会了,收藏=你精通了。

相关推荐
前端工作日常1 小时前
我理解的`npm pack` 和 `npm install <local-path>`
前端
李剑一1 小时前
说个多年老前端都不知道的标签正确玩法——q标签
前端
嘉小华1 小时前
大白话讲解 Android屏幕适配相关概念(dp、px 和 dpi)
前端
姑苏洛言1 小时前
在开发跑腿小程序集成地图时,遇到的坑,MapContext.includePoints(Object object)接口无效在组件中使用无效?
前端
七八书1 小时前
Vue3 组件通信全解析:从基础到进阶的实用指南
vue.js
奇舞精选1 小时前
Prompt 工程实用技巧:掌握高效 AI 交互核心
前端·openai
用户3802258598241 小时前
vue3源码解析:模块总览
vue.js
Danny_FD2 小时前
React中可有可无的优化-对象类型的使用
前端·javascript
用户757582318552 小时前
混合应用开发:企业降本增效之道——面向2025年移动应用开发趋势的实践路径
前端
P1erce2 小时前
记一次微信小程序分包经历
前端