bubkoo / html-to-image

βœ‚οΈ Generates an image from a DOM node using HTML5 canvas and SVG.
MIT License
5.4k stars 505 forks source link

Is there a way to wait for a few seconds before capturing the canvas to render an image? #369

Open Blankeos opened 1 year ago

Blankeos commented 1 year ago

General Summary of the Problem

The canvas captures the html node before it's completely rendered on the canvas. I'm just assuming that this is the case because when:

  1. I first "download" the image, it renders a with the right position styles, but no elements loaded in yet.
  2. Second time, it renders the some elements, but the biggest image is not painted in yet.
  3. Third time, I guess everything has properly loaded before, so it now actually loads the image that I want.
Here's a visual of those three cases I mentioned: 1st download 2nd download 3rd download

In other words, I'm assuming that the problem is because the <img /> elements are not completely loaded into the canvas yet but the html-to-image library captures it and renders it right away.

Expected Behavior

The expected behavior should be rendering the 3rd image right away.

Current Behavior

(already explained this in the general summary) This bug also doesn't happen on the computer/laptop but happens on my phone for some reason. I'm just assuming that the computer has more computing resources than a phone.

Possible Solution

I think the easiest fix for this when working with <img /> that take time to load, is to just simply delay the "capture" of the image on the canvas.

So is there a way to delay the "capture" of the image while it gets rendered on the canvas?

Steps To Reproduce

Can't really give steps to reproduce but try using an element with a large image as a node and then use downloadjs with that and try downloading on a phone.

vivcat[bot] commented 1 year ago

πŸ‘‹ @Blankeos

Thanks for opening your first issue here! If you're reporting a 🐞 bug, please make sure you include steps to reproduce it. To help make it easier for us to investigate your issue, please follow the contributing guidelines.

We get a lot of issues on this repo, so please be patient and we will get back to you as soon as we can.

qq15725 commented 1 year ago
  1. You need to waitUntilLoad before the dom renders the image

  2. But it could also be a Safari problem https://github.com/bubkoo/html-to-image/issues/361#issuecomment-1413526381

waitUntilLoad code:

https://github.com/qq15725/modern-screenshot/blob/v4.2.12/src/utils.ts#L185-L198

export const isElementNode = (node: Node): node is Element => node.nodeType === 1 // Node.ELEMENT_NODE
export const isSVGElementNode = (node: Element): node is SVGElement => typeof (node as SVGElement).className === 'object'
export const isSVGImageElementNode = (node: Element): node is SVGImageElement => isSVGElementNode(node) && node.tagName === 'IMAGE'
export const isHTMLElementNode = (node: Node): node is HTMLElement => isElementNode(node) && typeof (node as HTMLElement).style !== 'undefined' && !isSVGElementNode(node)
export const isImageElement = (node: Element): node is HTMLImageElement => node.tagName === 'IMG'
export const isVideoElement = (node: Element): node is HTMLVideoElement => node.tagName === 'VIDEO'
export const consoleWarn = (...args: any[]) => console.warn(...args)

export function getDocument<T extends Node>(target?: T | null): Document {
  return (
    (
      target && isElementNode(target as any)
        ? target?.ownerDocument
        : target
    ) ?? window.document
  ) as any
}

export function createImage(url: string, ownerDocument?: Document | null, useCORS = false): HTMLImageElement {
  const img = getDocument(ownerDocument).createElement('img')
  if (useCORS) {
    img.crossOrigin = 'anonymous'
  }
  img.decoding = 'sync'
  img.loading = 'eager'
  img.src = url
  return img
}

type Media = HTMLVideoElement | HTMLImageElement | SVGImageElement

interface LoadMediaOptions {
  ownerDocument?: Document
  timeout?: number
}

export function loadMedia<T extends Media>(media: T, options?: LoadMediaOptions): Promise<T>
export function loadMedia(media: string, options?: LoadMediaOptions): Promise<HTMLImageElement>
export function loadMedia(media: any, options?: LoadMediaOptions): Promise<any> {
  return new Promise(resolve => {
    const { timeout, ownerDocument } = options ?? {}
    const node: Media = typeof media === 'string'
      ? createImage(media, getDocument(ownerDocument))
      : media
    let timer: any = null
    let removeEventListeners: null | (() => void) = null

    function onResolve() {
      resolve(node)
      timer && clearTimeout(timer)
      removeEventListeners?.()
    }

    if (timeout) {
      timer = setTimeout(onResolve, timeout)
    }

    if (isVideoElement(node)) {
      const poster = node.poster
      if (poster) {
        return loadMedia(poster, options).then(resolve)
      }
      const currentSrc = (node.currentSrc || node.src)
      if (node.readyState >= 2 || !currentSrc) {
        return onResolve()
      }
      const onLoadeddata = onResolve
      const onError = (error: any) => {
        consoleWarn(
          'Video load failed',
          currentSrc,
          error,
        )
        onResolve()
      }
      removeEventListeners = () => {
        node.removeEventListener('loadeddata', onLoadeddata)
        node.removeEventListener('error', onError)
      }
      node.addEventListener('loadeddata', onLoadeddata, { once: true })
      node.addEventListener('error', onError, { once: true })
    } else {
      const currentSrc = isSVGImageElementNode(node)
        ? node.href.baseVal
        : (node.currentSrc || node.src)

      if (!currentSrc) {
        return onResolve()
      }

      const onLoad = async () => {
        if (isImageElement(node) && 'decode' in node) {
          try {
            await node.decode()
          } catch (error) {
            consoleWarn(
              'Failed to decode image, trying to render anyway',
              node.dataset.originalSrc || currentSrc,
              error,
            )
          }
        }
        onResolve()
      }

      const onError = (error: any) => {
        consoleWarn(
          'Image load failed',
          node.dataset.originalSrc || currentSrc,
          error,
        )
        onResolve()
      }

      if (isImageElement(node) && node.complete) {
        return onLoad()
      }

      removeEventListeners = () => {
        node.removeEventListener('load', onLoad)
        node.removeEventListener('error', onError)
      }

      node.addEventListener('load', onLoad, { once: true })
      node.addEventListener('error', onError, { once: true })
    }
  })
}

export async function waitUntilLoad(node: Node, timeout: number) {
  if (isHTMLElementNode(node)) {
    if (isImageElement(node) || isVideoElement(node)) {
      await loadMedia(node, { timeout })
    } else {
      await Promise.all(
        ['img', 'video'].flatMap(selectors => {
          return Array.from(node.querySelectorAll(selectors))
            .map(el => loadMedia(el as any, { timeout }))
        }),
      )
    }
  }
}
Icegreeen commented 1 year ago

Some libs use promises, html-to-image for example. It runs in the background.

Safari, perhaps in the name of performance, ignores these promises and the HTML is converted to PNG without the images included in the html block.

We found the solution here:

https://github.com/bubkoo/html-to-image/issues/361#issuecomment-1402537176

Also avoid using .then and .catch in functions, opt for async functions with await. It may take a few seconds, so just add a toast to let the user know something is up.