html - استفاده از HTML5/Canvas/JavaScript برای گرفتن اسکرینشات در مرورگر


ابزار «گزارش یک مشکل» یا «ابزار بازخورد» گوگل به شما اجازه میدهد یک بخش از پنجره مرورگر خود را انتخاب کنید تا یک تصویر صفحه ایجاد شود که همراه با بازخورد شما درباره یک مشکل ارسال میشود.

اسکرینشات توسط جیسون اسمال گرفته شده و در یک سوال تکراری منتشر شده است.

آنها چگونه این کار را انجام میدهند؟ API بازخورد جاوااسکریپت گوگل از اینجا بارگذاری میشود و نمای کلی آنها از ماژول بازخورد، قابلیت گرفتن اسکرینشات را نشان خواهد داد.

پاسخ شماره ۱

جاوااسکریپت میتواند DOM را بخواند و یک بازنمایی نسبتاً دقیق از آن را با استفاده از canvas ارائه دهد. من روی اسکریپتی کار میکنم که HTML را به یک تصویر canvas تبدیل میکند. امروز تصمیم گرفتم پیادهسازی آن را برای ارسال بازخوردها همانطور که شما توصیف کردید، انجام دهم.

این اسکریپت به شما این امکان را میدهد که فرمهای بازخورد ایجاد کنید که شامل یک تصویر صفحه است، که در مرورگر کاربر ایجاد شده است، همراه با فرم. تصویر صفحه بر اساس DOM ساخته شده است و به همین دلیل ممکن است 100٪ دقیق با نمایش واقعی نباشد، زیرا تصویری واقعی نمیگیرد، بلکه تصویر صفحه را بر اساس اطلاعات موجود در صفحه میسازد.

این هیچ رندرینگ از طرف سرور نیاز ندارد، زیرا کل تصویر در مرورگر کاربر ایجاد میشود. خود اسکریپت HTML2Canvas هنوز در حالت بسیار آزمایشی است، زیرا تقریباً هیچکدام از ویژگیهای CSS3 که من میخواستم را تجزیه نمیکند و همچنین هیچ پشتیبانی برای بارگذاری تصاویر CORS ندارد، حتی اگر یک پراکسی موجود باشد.

هنوز سازگاری مرورگر نسبتاً محدودی دارد (نه به این دلیل که پشتیبانی بیشتری امکانپذیر نباشد، فقط زمان برای ایجاد پشتیبانی بیشتر میان مرورگرها وجود نداشته است).

برای اطلاعات بیشتر، به مثالهای اینجا نگاه کنید:

http://hertzen.com/experiments/jsfeedback/

ویرایش اسکریپت html2canvas اکنون به صورت جداگانه اینجا در دسترس است و چند نمونه هم اینجا موجود است.

ویرایش 2 تأیید دیگری که گوگل از روشی بسیار مشابه استفاده میکند (در واقع، بر اساس مستندات، تنها تفاوت عمده روش ناهمزمان آنها در پیمایش/رسم است) را میتوان در این ارائه توسط الیوت اسپرههن از تیم بازخورد گوگل یافت: http://www.elliottsprehn.com/preso/fluentconf/

پاسخ شماره ۲

برنامه وب شما اکنون میتواند یک اسکرینشات «اصلی» از کل دسکتاپ مشتری با استفاده از getUserMedia() بگیرد:

به این مثال نگاه کنید:

https://www.webrtc-experiment.com/Pluginfree-Screen-Sharing/

مشتری باید از مرورگر کروم استفاده کند (فعلاً) و نیاز دارد تا پشتیبانی از ضبط صفحه را در chrome://flags فعال کند.

پاسخ شماره ۳

گرفتن اسکرینشات بهصورت Canvas یا Jpeg Blob/ArrayBuffer با استفاده از API getDisplayMedia:

رفع مشکل ۱: تنها برای Electron.js از getUserMedia با chromeMediaSource استفاده کنید
رفع مشکل ۲: به جای بازگرداندن شیء null خطا پرتاب کنید
رفع مشکل ۳: دموی خود را برای جلوگیری از خطا اصلاح کنید: getDisplayMedia باید از یک هندلر اقدام کاربر فراخوانی شود

// docs: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia
// see: https://www.webrtc-experiment.com/Pluginfree-Screen-Sharing/#20893521368186473
// see: https://github.com/muaz-khan/WebRTC-Experiment/blob/master/Pluginfree-Screen-Sharing/conference.js

function getDisplayMedia(options) {
    if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) {
        return navigator.mediaDevices.getDisplayMedia(options)
    }
    if (navigator.getDisplayMedia) {
        return navigator.getDisplayMedia(options)
    }
    if (navigator.webkitGetDisplayMedia) {
        return navigator.webkitGetDisplayMedia(options)
    }
    if (navigator.mozGetDisplayMedia) {
        return navigator.mozGetDisplayMedia(options)
    }
    throw new Error('getDisplayMedia is not defined')
}

function getUserMedia(options) {
    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
        return navigator.mediaDevices.getUserMedia(options)
    }
    if (navigator.getUserMedia) {
        return navigator.getUserMedia(options)
    }
    if (navigator.webkitGetUserMedia) {
        return navigator.webkitGetUserMedia(options)
    }
    if (navigator.mozGetUserMedia) {
        return navigator.mozGetUserMedia(options)
    }
    throw new Error('getUserMedia is not defined')
}

async function takeScreenshotStream() {
    // see: https://developer.mozilla.org/en-US/docs/Web/API/Window/screen
    const width = screen.width * (window.devicePixelRatio || 1)
    const height = screen.height * (window.devicePixelRatio || 1)

    const errors = []
    let stream
    try {
        stream = await getDisplayMedia({
            audio: false,
            // see: https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints/video
            video: {
                width,
                height,
                frameRate: 1,
            },
        })
    } catch (ex) {
        errors.push(ex)
    }

    // for electron js
    if (navigator.userAgent.indexOf('Electron') >= 0) {
        try {
            stream = await getUserMedia({
                audio: false,
                video: {
                    mandatory: {
                        chromeMediaSource: 'desktop',
                        // chromeMediaSourceId: source.id,
                        minWidth         : width,
                        maxWidth         : width,
                        minHeight        : height,
                        maxHeight        : height,
                    },
                },
            })
        } catch (ex) {
            errors.push(ex)
        }
    }

    if (errors.length) {
        console.debug(...errors)
        if (!stream) {
            throw errors[errors.length - 1]
        }
    }

    return stream
}

async function takeScreenshotCanvas() {
    const stream = await takeScreenshotStream()

    // from: https://stackoverflow.com/a/57665309/5221762
    const video = document.createElement('video')
    const result = await new Promise((resolve, reject) => {
        video.onloadedmetadata = () => {
            video.play()
            video.pause()

            // from: https://github.com/kasprownik/electron-screencapture/blob/master/index.js
            const canvas = document.createElement('canvas')
            canvas.width = video.videoWidth
            canvas.height = video.videoHeight
            const context = canvas.getContext('2d')
            // see: https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement
            context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight)
            resolve(canvas)
        }
        video.srcObject = stream
    })

    stream.getTracks().forEach(function (track) {
        track.stop()
    })
    
    if (result == null) {
        throw new Error('Cannot take canvas screenshot')
    }

    return result
}

// from: https://stackoverflow.com/a/46182044/5221762
function getJpegBlob(canvas) {
    return new Promise((resolve, reject) => {
        // docs: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
        canvas.toBlob(blob => resolve(blob), 'image/jpeg', 0.95)
    })
}

async function getJpegBytes(canvas) {
    const blob = await getJpegBlob(canvas)
    return new Promise((resolve, reject) => {
        const fileReader = new FileReader()

        fileReader.addEventListener('loadend', function () {
            if (this.error) {
                reject(this.error)
                return
            }
            resolve(this.result)
        })

        fileReader.readAsArrayBuffer(blob)
    })
}

async function takeScreenshotJpegBlob() {
    const canvas = await takeScreenshotCanvas()
    return getJpegBlob(canvas)
}

async function takeScreenshotJpegBytes() {
    const canvas = await takeScreenshotCanvas()
    return getJpegBytes(canvas)
}

function blobToCanvas(blob, maxWidth, maxHeight) {
    return new Promise((resolve, reject) => {
        const img = new Image()
        img.onload = function () {
            const canvas = document.createElement('canvas')
            const scale = Math.min(
                1,
                maxWidth ? maxWidth / img.width : 1,
                maxHeight ? maxHeight / img.height : 1,
            )
            canvas.width = img.width * scale
            canvas.height = img.height * scale
            const ctx = canvas.getContext('2d')
            ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, canvas.width, canvas.height)
            resolve(canvas)
        }
        img.onerror = () => {
            reject(new Error('Error load blob to Image'))
        }
        img.src = URL.createObjectURL(blob)
    })
}

نسخه آزمایشی:

document.body.onclick = async () => {
    // take the screenshot
    var screenshotJpegBlob = await takeScreenshotJpegBlob()

    // show preview with max size 300 x 300 px
    var previewCanvas = await blobToCanvas(screenshotJpegBlob, 300, 300)
    previewCanvas.style.position = 'fixed'
    document.body.appendChild(previewCanvas)

    // send it to the server
    var formdata = new FormData()
    formdata.append("screenshot", screenshotJpegBlob)
    await fetch('https://your-web-site.com/', {
        method: 'POST',
        body: formdata,
        'Content-Type' : "multipart/form-data",
    })
}

// and click on the page

پاسخ شماره ۴

اثبات مفهوم

همانطور که نیکلاس اشاره کرد، شما میتوانید از کتابخانه html2canvas برای گرفتن اسکرینشات با استفاده از JS در مرورگر استفاده کنید. من پاسخ او را در این نقطه با ارائه یک مثال از گرفتن اسکرینشات با استفاده از این کتابخانه ("اثبات مفهوم") گسترش میدهم:

function report() {
  let region = document.querySelector("body"); // whole screen
  html2canvas(region, {
    onrendered: function(canvas) {
      let pngUrl = canvas.toDataURL(); // png in dataURL format
      let img = document.querySelector(".screen");
      img.src = pngUrl; 

      // here you can allow user to set bug-region
      // and send it with 'pngUrl' to server
    },
  });
}
.container {
  margin-top: 10px;
  border: solid 1px black;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/0.4.1/html2canvas.min.js"></script>
<div>Screenshot tester</div>
<button onclick="report()">Take screenshot</button>

<div class="container">
  <img width="75%" class="screen">
</div>

در تابع report() در onrendered پس از دریافت تصویر به صورت داده URI، میتوانید آن را به کاربر نشان دهید و به او اجازه دهید که با موس "ناحیه باگ" را رسم کند و سپس یک اسکرینشات و مختصات ناحیه را به سرور ارسال کند.

در این مثال نسخه async/await ساخته شد: با تابع خوب makeScreenshot().

بهروزرسانی

مثال سادهای که به شما اجازه میدهد اسکرینشات بگیرید، منطقهای را انتخاب کنید، باگ را توضیح دهید و درخواست POST ارسال کنید (در اینجا jsfiddle) (تابع اصلی report() است).

async function report() {
    let screenshot = await makeScreenshot(); // png dataUrl
    let img = q(".screen");
    img.src = screenshot; 
    
    let c = q(".bug-container");
    c.classList.remove('hide')
        
    let box = await getBox();    
    c.classList.add('hide');

    send(screenshot,box); // sed post request  with bug image, region and description
    alert('To see POST requset with image go to: chrome console > network tab');
}

// ----- Helper functions

let q = s => document.querySelector(s); // query selector helper
window.report = report; // bind report be visible in fiddle html

async function  makeScreenshot(selector="body") 
{
  return new Promise((resolve, reject) => {  
    let node = document.querySelector(selector);
    
    html2canvas(node, { onrendered: (canvas) => {
        let pngUrl = canvas.toDataURL();      
        resolve(pngUrl);
    }});  
  });
}

async function getBox(box) {
  return new Promise((resolve, reject) => {
     let b = q(".bug");
     let r = q(".region");
     let scr = q(".screen");
     let send = q(".send");
     let start=0;
     let sx,sy,ex,ey=-1;
     r.style.width=0;
     r.style.height=0;
     
     let drawBox= () => {
         r.style.left   = (ex > 0 ? sx : sx+ex ) +'px'; 
         r.style.top    = (ey > 0 ? sy : sy+ey) +'px';
         r.style.width  = Math.abs(ex) +'px';
         r.style.height = Math.abs(ey) +'px'; 
     }
     
     
     
     //console.log({b,r, scr});
     b.addEventListener("click", e=>{
       if(start==0) {
         sx=e.pageX;
         sy=e.pageY;
         ex=0;
         ey=0;
         drawBox();
       }
       start=(start+1)%3;       
     });
     
     b.addEventListener("mousemove", e=>{
       //console.log(e)
       if(start==1) {
           ex=e.pageX-sx;
           ey=e.pageY-sy
           drawBox(); 
       }
     });
     
     send.addEventListener("click", e=>{
       start=0;
       let a=100/75 //zoom out img 75%       
       resolve({
          x:Math.floor(((ex > 0 ? sx : sx+ex )-scr.offsetLeft)*a),
          y:Math.floor(((ey > 0 ? sy : sy+ey )-b.offsetTop)*a),
          width:Math.floor(Math.abs(ex)*a),
          height:Math.floor(Math.abs(ex)*a),
          desc: q('.bug-desc').value
          });
          
     });
  });
}

function send(image,box) {

    let formData = new FormData();
    let req = new XMLHttpRequest();
    
    formData.append("box", JSON.stringify(box)); 
    formData.append("screenshot", image);     
    
    req.open("POST", '/upload/screenshot');
    req.send(formData);
}
.bug-container { background: rgb(255,0,0,0.1); margin-top:20px; text-align: center; }
.send { border-radius:5px; padding:10px; background: green; cursor: pointer; }
.region { position: absolute; background: rgba(255,0,0,0.4); }
.example { height: 100px; background: yellow; }
.bug { margin-top: 10px; cursor: crosshair; }
.hide { display: none; }
.screen { pointer-events: none }
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/0.4.1/html2canvas.min.js"></script>
<body>
<div>Screenshot tester</div>
<button onclick="report()">Report bug</button>

<div class="example">Lorem ipsum</div>

<div class="bug-container hide">
  <div>Select bug region: click once - move mouse - click again</div>
  <div class="bug">    
    <img width="75%" class="screen" >
    <div class="region"></div> 
  </div>
  <div>
    <textarea class="bug-desc">Describe bug here...</textarea>
  </div>
  <div class="send">SEND BUG</div>
</div>

</body>

پاسخ شماره ۵

در اینجا یک نمونه کامل از اسکرینشات وجود دارد که در کروم در سال ۲۰۲۱ کار میکند. نتیجه نهایی یک بلوک داده است که آماده ارسال است. روند کار به این صورت است: درخواست رسانه > گرفتن فریم > کشیدن روی بوم > انتقال به بلوک داده. اگر میخواهید آن را به شکل بهینهتری از نظر حافظه انجام دهید، OffscreenCanvas یا شاید ImageBitmapRenderingContext را بررسی کنید.

https://jsfiddle.net/v24hyd3q/1/

// Request media
navigator.mediaDevices.getDisplayMedia().then(stream => 
{
  // Grab frame from stream
  let track = stream.getVideoTracks()[0];
  let capture = new ImageCapture(track);
  capture.grabFrame().then(bitmap => 
  {
    // Stop sharing
    track.stop();
      
    // Draw the bitmap to canvas
    canvas.width = bitmap.width;
    canvas.height = bitmap.height;
    canvas.getContext('2d').drawImage(bitmap, 0, 0);
      
    // Grab blob from canvas
    canvas.toBlob(blob => {
        // Do things with blob here
        console.log('output blob:', blob);
    });
  });
})
.catch(e => console.log(e));

پاسخ شماره ۶

در اینجا یک مثال با استفاده از: getDisplayMedia

document.body.innerHTML = '<video style="width: 100%; height: 100%; border: 1px black solid;"/>';

navigator.mediaDevices.getDisplayMedia()
.then( mediaStream => {
  const video = document.querySelector('video');
  video.srcObject = mediaStream;
  video.onloadedmetadata = e => {
    video.play();
    video.pause();
  };
})
.catch( err => console.log(`${err.name}: ${err.message}`));

همچنین ارزش بررسی کردن اسناد API ضبط صفحه را دارد.