使用 HTML5 Canvas 實現使用者自定義裁剪圖片

雪旭發表於2024-06-12

在Web開發中,經常需要處理使用者上傳的圖片,其中一個常見的需求是允許使用者選擇並裁剪圖片。本文將介紹如何使用HTML、CSS和JavaScript實現一個簡單的圖片裁剪工具。

步驟概覽

  1. 建立HTML結構,包含檔案上傳控制元件、裁剪前的圖片顯示區域,選擇裁剪區域、Canvas和顯示裁剪後圖片的標籤。
  2. uploadFile.onchange 中建立一個 Image 物件,並在 onload 事件中獲取圖片的實際尺寸(image.widthimage.height),用於計算並設定圖片在裁剪容器中的顯示尺寸,以保持圖片的寬高比例。
  3. 在圖片上實現裁剪功能,其實就是一個拖拽,滑鼠按下可以拖動裁剪區域,鬆開就禁止拖拽。使用者選擇完裁剪區域後,我們先計算出裁剪框在原圖片中的位置和尺寸,根據這些資訊繪製裁剪後的圖片到Canvas上。
  4. 根據裁剪框在原圖片中的位置和尺寸與Canvas上圖片尺寸根據比列關聯起來。
  5. 將裁剪後的圖片轉換為Blob物件,並顯示在頁面上。
  6. 將Blob物件上傳到伺服器。

一.HTML和CSS結構

首先,我們需要一個HTML結構來容納上傳的圖片和裁剪區域。以下是一個基本的HTML結構:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>Title</title>

    <style>
        * {
            margin: 0;
        }

        body {
            color: #fff;
            background: rgba(0, 0, 0, 0.8);
        }

        .wrap {
            width: 500px;
            margin: 50px;
        }

        canvas {
            display: none;
            border: 1px solid red;
        }

        .upload {
            width: 150px;
            height: 30px;
            border: 1px solid;
            margin: 20px auto;
            position: relative;
        }

        .upload-file {
            position: absolute;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
            opacity: 0;
            z-index: 1;
            cursor: pointer;
        }

        .upload-btn {
            position: absolute;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
            text-align: center;
            line-height: 1.5;
            font-size: 20px;
            color: #fff;
            border-radius: 5px;
        }


        .clip-area {
            text-align: center;
        }

        .clip-wrap {
            display: none;
            width: 500px;
            height: 500px;
            background: #000;
            position: relative;
        }

        .clip-wrap .clip-img {
            position: absolute;
            left: 0;
            right: 0;
            bottom: 0;
            top: 0;
            margin: auto;
        }

        .clip-box {
            display: none;
            position: absolute;
            width: 300px;
            height: 300px;
            background: rgba(0, 0, 0, 0.5);
            box-sizing: border-box;
            border: 1px solid #fff;
        }


        .clip-after {
            text-align: center;
        }

        .clip-after h1 {
            margin: 20px 0;
        }

        .clip-after-img {
            width: 300px;
        }

        .clip-after-btn {
            width: 100px;
            height: 30px;
            line-height: 30px;
            text-align: center;
            color: #fff;
            background: transparent;
            border: 1px solid #fff;
            cursor: pointer;
            margin-top: 20px;
        }
    </style>
</head>

<body>
    <div class="wrap">
        <div class="upload">
            <input type="file" class="upload-file">
            <div class="upload-btn">圖片上傳</div>
        </div>

        <!-- 顯示上傳的圖片並選擇裁剪區域 -->
        <div class="clip-area">
            <h1>裁剪區域</h1>
            <div class="clip-wrap">
                <img src="" class="clip-img" style="width: 300px;">
                <div class="clip-box"></div>
            </div>
        </div>


        <!-- 裁剪後的圖片 -->
        <div class="clip-after">
            <h1> 裁剪後的圖片</h1>
            <div class="clip-after-content">
                <img src="" class="clip-after-img">
            </div>
            <canvas id="canvas"></canvas>
            <button class="clip-after-btn">確認</button>
        </div>

    </div>

</body>

</html>

二.JS邏輯

1、實現選擇圖片並把圖片等比例顯示出來

實現一個簡單的選擇圖片,再建立了一個圖片例項 image,然後使用 FileReader 對使用者上傳的圖片進行讀取,並將讀取結果賦值給 imagesrc 屬性。接著,透過獲取 image 的寬度和高度,我們可以得知原始圖片的尺寸。基於這些尺寸資訊,在裁剪前先把圖片等比例顯示出來

 const canvas = document.getElementById('canvas');
        const ctx = canvas.getContext('2d');
        canvas.width = 300;
        canvas.height = 300;
        const uploadFile = document.querySelector('.upload-file');
        const clipAfterImg = document.querySelector('.clip-after-img');
        let imageBlob;

        //上傳圖片
        uploadFile.onchange = (e) => {
            clipWrap.style.display = 'block';
            const fileData = e.target.files[0];
            const reader = new FileReader();
            const image = new Image();
            reader.readAsDataURL(fileData);
            // 非同步讀取檔案內容,結果用data:url的字串形式表示
            reader.onload = function (e) {
                const imgUrl = this.result;
                image.src = this.result;
                image.onload = function () {
                    // 計算圖片繪製區域,確保圖片保持比例
                    const aspectRatio = image.width / image.height;
                    computeSize(aspectRatio, imgUrl);
                };
            }
        }

        //計算上傳圖片要顯示的尺寸
        function computeSize(aspectRatio, imgUrl) {
            let drawWidth, drawHeight;
            if (aspectRatio > 1) {
                // 圖片更寬
                drawWidth = clipWrap.offsetWidth;
                drawHeight = drawWidth / aspectRatio;

            } else {
                // 圖片更高
                drawHeight = clipWrap.offsetHeight;
                drawWidth = clipWrap.offsetHeight * aspectRatio;
            }
            clipImg.src = imgUrl
            clipImg.style.width = `${drawWidth}px`;
            clipImg.style.height = `${drawHeight}px`

        }

每次選擇完圖片後顯示選擇區域並預設居中。

 //裁剪選擇塊的位置居中
        function centerClipBox() {
            clipBox.style.left = `${(clipWrap.clientWidth - clipBox.clientWidth) / 2}px`;
            clipBox.style.top = `${(clipWrap.clientHeight - clipBox.clientHeight) / 2}px`;
            clipBox.style.display = 'block';
        }

2、實現拖拽功能,用拖拽選擇區域來選擇內容

只有在滑鼠按下 mousedown 事件才允許拖拽,滑鼠鬆開 mouseup 禁止拖拽,再滑鼠移動事件 mousemove 中計算拖拽的位置。並新增範圍限制。

// 拖拽選擇裁剪區域
const clipWrap = document.querySelector('.clip-wrap');
const clipImg = document.querySelector('.clip-img');
const clipBox = document.querySelector('.clip-box');
let isTtrue = false;
let initX;
let initY;

//初始化裁剪選擇塊的位置
centerClipBox()
//裁剪選擇塊的位置居中
function centerClipBox() {
    clipBox.style.left = `${(clipWrap.clientWidth - clipBox.clientWidth) / 2}px`;
    clipBox.style.top = `${(clipWrap.clientHeight - clipBox.clientHeight) / 2}px`;
    clipBox.style.display = 'block';
}
clipBox.addEventListener('mousedown', (e) => {
    isTtrue = true;
    initX = e.clientX - clipWrap.offsetLeft - clipImg.offsetLeft - clipBox.offsetLeft;
    initY = e.clientY - clipWrap.offsetLeft - clipImg.offsetTop - clipBox.offsetTop;
})

//滑鼠移動選擇裁剪區域,並新增節流最佳化
document.addEventListener('mousemove', (e) => {
    if (isTtrue) {
        moveX = e.clientX - initX;
        let moveY = e.clientY - initY;
        let maxX = clipImg.offsetWidth - clipBox.offsetWidth + clipImg.offsetLeft;
        let maxY = clipImg.offsetHeight - clipBox.offsetHeight + clipImg.offsetTop;
        let minX = clipImg.offsetLeft;
        let minY = clipImg.offsetTop;
        moveX = Math.max(minX, Math.min(moveX, maxX));
        moveY = Math.max(minY, Math.min(moveY, maxY));
        clipBox.style.left = moveX + 'px';
        clipBox.style.top = moveY + 'px';
    }
})
document.addEventListener('mouseup', () => {
    isTtrue = false;
});

3、計算選擇區域在圖片上的位置和尺寸

drawImage函式繪製裁剪後的圖片,並將其顯示在頁面上。首先獲取裁剪框和圖片的位置和尺寸資訊,然後根據這些資訊繪製裁剪後的圖片到Canvas上。最後透過 canvas.toBlob方法將Canvas內容轉換為Blob物件,建立圖片URL並設定給裁剪後的圖片元素。

function drawImage() {
    // 獲取裁剪框和圖片的位置和尺寸資訊
    const clipRect = clipBox.getBoundingClientRect();
    const imgRect = clipImg.getBoundingClientRect();

    const scaleX = clipImg.naturalWidth / imgRect.width;
    const scaleY = clipImg.naturalHeight / imgRect.height;

    const cropX = (clipRect.left - imgRect.left) * scaleX;
    const cropY = (clipRect.top - imgRect.top) * scaleY;
    const cropWidth = clipBox.clientWidth * scaleX;
    const cropHeight = clipBox.clientHeight * scaleY;

    // 調整畫布尺寸
    canvas.width = cropWidth;
    canvas.height = cropHeight;

    // 繪製裁剪後的圖片
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.drawImage(
        clipImg,
        cropX,
        cropY,
        cropWidth,
        cropHeight,
        0,
        0,
        canvas.width,
        canvas.height
    );

    // 將畫布內容轉換為Blob物件
    canvas.toBlob(function (blob) {
        // 建立圖片URL並設定給裁剪後的圖片元素
        const url = URL.createObjectURL(blob);
        clipAfterImg.src = url;
        imageBlob = blob;
    }, 'image/png');
}

透過 getBoundingClientRect () 獲取圖片和選擇塊的位置,再透過圖片的原始尺寸和顯示的尺寸算出寬高比,移動距離和 canvas 繪製的圖片都乘以寬高比,算出從哪裡開始裁剪和畫布的尺寸大小。

5、再圖片 onchange 和拖拽觸發 mousemove 呼叫drawImage來繪製圖片,並新增節流事件防止頻繁觸發。

//計算上傳圖片要顯示的尺寸
function computeSize(aspectRatio, imgUrl) {
    let drawWidth, drawHeight;
    if (aspectRatio > 1) {
        // 圖片更寬
        drawWidth = clipWrap.offsetWidth;
        drawHeight = drawWidth / aspectRatio;

    } else {
        // 圖片更高
        drawHeight = clipWrap.offsetHeight;
        drawWidth = clipWrap.offsetHeight * aspectRatio;
    }
    clipImg.src = imgUrl
    clipImg.style.width = `${drawWidth}px`;
    clipImg.style.height = `${drawHeight}px`

    clipImg.onload = () => {
        // 在計算完大小後居中顯示clipBox
        centerClipBox();
        drawImage()
    }
}

document.addEventListener('mousemove', throttle((e) => {
    if (isTtrue) {
        moveX = e.clientX - initX;
        let moveY = e.clientY - initY;
        let maxX = clipImg.offsetWidth - clipBox.offsetWidth + clipImg.offsetLeft;
        let maxY = clipImg.offsetHeight - clipBox.offsetHeight + clipImg.offsetTop;
        let minX = clipImg.offsetLeft;
        let minY = clipImg.offsetTop;
        moveX = Math.max(minX, Math.min(moveX, maxX));
        moveY = Math.max(minY, Math.min(moveY, maxY));
        clipBox.style.left = moveX + 'px';
        clipBox.style.top = moveY + 'px';

        //裁剪區域移動重新繪製圖片
        drawImage();
    }
}, 50));

//    節流
function throttle(fn, delay = 300) {
    let timer;
    return function (...args) {

        if (timer) return;
        timer = setTimeout(() => {
            fn(...args);
            timer = null;
        }, delay);
    }
}

效果

全部程式碼

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>Title</title>

    <style>
        * {
            margin: 0;
        }

        body {
            color: #fff;
            background: rgba(0, 0, 0, 0.8);
        }

        .wrap {
            width: 500px;
            margin: 50px;
        }

        canvas {
            display: none;
            border: 1px solid red;
        }

        .upload {
            width: 150px;
            height: 30px;
            border: 1px solid;
            margin: 20px auto;
            position: relative;
        }

        .upload-file {
            position: absolute;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
            opacity: 0;
            z-index: 1;
            cursor: pointer;
        }

        .upload-btn {
            position: absolute;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
            text-align: center;
            line-height: 1.5;
            font-size: 20px;
            color: #fff;
            border-radius: 5px;
        }


        .clip-area {
            text-align: center;
        }

        .clip-wrap {
            display: none;
            width: 500px;
            height: 500px;
            background: #000;
            position: relative;
        }

        .clip-wrap .clip-img {
            position: absolute;
            left: 0;
            right: 0;
            bottom: 0;
            top: 0;
            margin: auto;
        }

        .clip-box {
            display: none;
            position: absolute;
            width: 300px;
            height: 300px;
            background: rgba(0, 0, 0, 0.5);
            box-sizing: border-box;
            border: 1px solid #fff;
        }


        .clip-after {
            text-align: center;
        }

        .clip-after h1 {
            margin: 20px 0;
        }

        .clip-after-img {
            width: 300px;
        }

        .clip-after-btn {
            width: 100px;
            height: 30px;
            line-height: 30px;
            text-align: center;
            color: #fff;
            background: transparent;
            border: 1px solid #fff;
            cursor: pointer;
            margin-top: 20px;
        }
    </style>
</head>

<body>
    <div class="wrap">
        <div class="upload">
            <input type="file" class="upload-file">
            <div class="upload-btn">圖片上傳</div>
        </div>

        <!-- 顯示上傳的圖片並選擇裁剪區域 -->
        <div class="clip-area">
            <h1>裁剪區域</h1>
            <div class="clip-wrap">
                <img src="" class="clip-img" style="width: 300px;">
                <div class="clip-box"></div>
            </div>
        </div>


        <!-- 裁剪後的圖片 -->
        <div class="clip-after">
            <h1> 裁剪後的圖片</h1>
            <div class="clip-after-content">
                <img src="" class="clip-after-img">
            </div>
            <canvas id="canvas"></canvas>
            <button class="clip-after-btn">確認</button>
        </div>

    </div>
    <script>
        const canvas = document.getElementById('canvas');
        const ctx = canvas.getContext('2d');
        canvas.width = 300;
        canvas.height = 300;
        const uploadFile = document.querySelector('.upload-file');
        const clipAfterImg = document.querySelector('.clip-after-img');
        let imageBlob;

        //上傳圖片
        uploadFile.onchange = (e) => {
            clipWrap.style.display = 'block';
            const fileData = e.target.files[0];
            const reader = new FileReader();
            const image = new Image();
            reader.readAsDataURL(fileData);
            // 非同步讀取檔案內容,結果用data:url的字串形式表示
            reader.onload = function (e) {
                const imgUrl = this.result;
                image.src = this.result;
                image.onload = function () {
                    // 計算圖片繪製區域,確保圖片保持比例
                    const aspectRatio = image.width / image.height;
                    computeSize(aspectRatio, imgUrl);
                };
            }
        }

        //計算上傳圖片要顯示的尺寸
        function computeSize(aspectRatio, imgUrl) {
            let drawWidth, drawHeight;
            if (aspectRatio > 1) {
                // 圖片更寬
                drawWidth = clipWrap.offsetWidth;
                drawHeight = drawWidth / aspectRatio;

            } else {
                // 圖片更高
                drawHeight = clipWrap.offsetHeight;
                drawWidth = clipWrap.offsetHeight * aspectRatio;
            }
            clipImg.src = imgUrl
            clipImg.style.width = `${drawWidth}px`;
            clipImg.style.height = `${drawHeight}px`

            clipImg.onload = () => {
                // 在計算完大小後居中顯示clipBox
                centerClipBox();
                drawImage()
            }

        }


        // 拖拽選擇裁剪區域
        const clipWrap = document.querySelector('.clip-wrap');
        const clipImg = document.querySelector('.clip-img');
        const clipBox = document.querySelector('.clip-box');
        let isTtrue = false;
        let initX;
        let initY;

        //初始化裁剪選擇塊的位置
        centerClipBox()
        //裁剪選擇塊的位置居中
        function centerClipBox() {
            clipBox.style.left = `${(clipWrap.clientWidth - clipBox.clientWidth) / 2}px`;
            clipBox.style.top = `${(clipWrap.clientHeight - clipBox.clientHeight) / 2}px`;
            clipBox.style.display = 'block';
        }
        clipBox.addEventListener('mousedown', (e) => {
            isTtrue = true;
            initX = e.clientX - clipWrap.offsetLeft - clipImg.offsetLeft - clipBox.offsetLeft;
            initY = e.clientY - clipWrap.offsetLeft - clipImg.offsetTop - clipBox.offsetTop;
        })

        //滑鼠移動選擇裁剪區域,並新增節流最佳化
        document.addEventListener('mousemove', throttle((e) => {
            if (isTtrue) {
                moveX = e.clientX - initX;
                let moveY = e.clientY - initY;
                let maxX = clipImg.offsetWidth - clipBox.offsetWidth + clipImg.offsetLeft;
                let maxY = clipImg.offsetHeight - clipBox.offsetHeight + clipImg.offsetTop;
                let minX = clipImg.offsetLeft;
                let minY = clipImg.offsetTop;
                moveX = Math.max(minX, Math.min(moveX, maxX));
                moveY = Math.max(minY, Math.min(moveY, maxY));
                clipBox.style.left = moveX + 'px';
                clipBox.style.top = moveY + 'px';

                //裁剪區域移動重新繪製圖片
                drawImage()
            }
        }, 50))
        document.addEventListener('mouseup', () => {
            isTtrue = false;
        });

        //在canvas上繪製選擇的區域並轉為圖片
        function drawImage() {
            const clipRect = clipBox.getBoundingClientRect();
            const imgRect = clipImg.getBoundingClientRect();

            const scaleX = clipImg.naturalWidth / imgRect.width;
            const scaleY = clipImg.naturalHeight / imgRect.height;

            const cropX = (clipRect.left - imgRect.left) * scaleX;
            const cropY = (clipRect.top - imgRect.top) * scaleY;
            const cropWidth = clipBox.clientWidth * scaleX;
            const cropHeight = clipBox.clientHeight * scaleY;

            // 調整畫布尺寸
            canvas.width = cropWidth;
            canvas.height = cropHeight;

            // 清空畫布
            ctx.clearRect(0, 0, canvas.width, canvas.height);

            // 將畫布剪裁為圓形區域
            ctx.beginPath();
            ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2);
            ctx.closePath();
            ctx.clip();

            // 繪製圖片
            ctx.drawImage(
                clipImg,
                cropX,
                cropY,
                cropWidth,
                cropHeight,
                0,
                0,
                canvas.width,
                canvas.height
            );

            // 將畫布內容轉換為圖片
            canvas.toBlob(function (blob) {
                const url = URL.createObjectURL(blob);
                clipAfterImg.src = url;
                imageBlob = blob;
            }, 'image/png');
        }

        //圖片上傳
        uploadBtn.addEventListener('click', () => {
            if (imageBlob) {
                const formData = new FormData();
                formData.append('image', imageBlob, 'image.png');

                fetch('/upload', {
                    method: 'POST',
                    body: formData,
                })
                    .then(response => response.json())
                    .then(data => {
                        console.log('Success:', data);
                    })
                    .catch((error) => {
                        console.error('Error:', error);
                    });
            } else {
                console.error('No image available to upload.');
            }
        });

        //    節流
        function throttle(fn, delay = 300) {
            let timer;
            return function (...args) {

                if (timer) return;
                timer = setTimeout(() => {
                    fn(...args);
                    timer = null;
                }, delay);
            }
        }
    </script>
</body>

</html>

相關文章