将 GIF 播放器 Web 组件转换为 React 组件?

Convert GIF Player Web Component into React Component?

我想将 GIF Player Web Component 转换为 React 组件。

我试图找到一个 React GIF Player 库,但没有一个可以正常工作。

目前,GIF Player Web Component 看起来很有前途,但它在 React 中不可用。看起来像:

import { LitElement, html, css } from "lit";
import { GifReader } from "omggif";

class GifPlayer extends LitElement {
  static get styles() {
    return css`
      :host {
        display: inline-block;
      }
      canvas {
        display: block;
        width: 100%;
        height: 100%;
      }
    `;
  }
  static get properties() {
    return {
      src: { type: String },
      alt: { type: String },
      autoplay: { type: Boolean },
      play: { type: Function },
      pause: { type: Function },
      restart: { type: Function },
      currentFrame: { type: Number },
      frames: { attribute: false, type: Array },
      playing: { attribute: false, type: Boolean },
      width: { attribute: false, type: Number },
      height: { attribute: false, type: Number },
    };
  }

  constructor() {
    super();
    this.currentFrame = 1;
    this.frames = [];
    this.step = this.step();
    this.play = this.play.bind(this);
    this.pause = this.pause.bind(this);
    this.renderFrame = this.renderFrame.bind(this);
    this.loadSource = this.loadSource.bind(this);
  }

  firstUpdated() {
    this.canvas = this.renderRoot.querySelector("canvas");
    this.context = this.canvas.getContext("2d");
    this.loadSource(this.src).then(() => {
      if (this.autoplay) this.play();
    });
  }

  updated(changedProperties) {
    if (changedProperties.has("width")) {
      this.canvas.width = this.width;
      this.renderFrame(false);
    }
    if (changedProperties.has("height")) {
      this.canvas.height = this.height;
      this.renderFrame(false);
    }
  }

  render() {
    return html`<canvas role="img" aria-label=${this.alt}></canvas>`;
  }

  play() {
    if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
    this.animationFrame = requestAnimationFrame(this.step);
    this.playing = true;
  }

  pause() {
    this.playing = false;
    if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
  }

  restart() {
    this.currentFrame = 1;
    if (this.playing) {
      this.play();
    } else {
      this.pause();
      this.renderFrame(false);
    }
  }

  step() {
    let previousTimestamp;
    return (timestamp) => {
      if (!previousTimestamp) previousTimestamp = timestamp;
      const delta = timestamp - previousTimestamp;
      const delay = this.frames[this.currentFrame]?.delay;
      if (this.playing && delay && delta > delay) {
        previousTimestamp = timestamp;
        this.renderFrame();
      }
      this.animationFrame = requestAnimationFrame(this.step);
    };
  }

  renderFrame(progress = true) {
    if (!this.frames.length) return;
    if (this.currentFrame === this.frames.length - 1) {
      this.currentFrame = 0;
    }

    this.context.putImageData(this.frames[this.currentFrame].data, 0, 0);
    if (progress) {
      this.currentFrame = this.currentFrame + 1;
    }
  }

  async loadSource(url) {
    const response = await fetch(url);
    const buffer = await response.arrayBuffer();
    const uInt8Array = new Uint8Array(buffer);
    const gifReader = new GifReader(uInt8Array);
    const gif = gifData(gifReader);
    const { width, height, frames } = gif;
    this.width = width;
    this.height = height;
    this.frames = frames;
    if (!this.alt) {
      this.alt = url;
    }
    this.renderFrame(false);
  }
}

function gifData(gif) {
  const frames = Array.from(frameDetails(gif));
  return { width: gif.width, height: gif.height, frames };
}

function* frameDetails(gifReader) {
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");
  const frameCount = gifReader.numFrames();
  let previousFrame;

  for (let i = 0; i < frameCount; i++) {
    const frameInfo = gifReader.frameInfo(i);
    const imageData = context.createImageData(
      gifReader.width,
      gifReader.height
    );
    if (i > 0 && frameInfo.disposal < 2) {
      imageData.data.set(new Uint8ClampedArray(previousFrame.data.data));
    }
    gifReader.decodeAndBlitFrameRGBA(i, imageData.data);
    previousFrame = {
      data: imageData,
      delay: gifReader.frameInfo(i).delay * 10,
    };
    yield previousFrame;
  }
}

customElements.define("gif-player", GifPlayer);

但是,我不知道如何将其转换为 React 组件。

我想用 TypeScript 转换它。我已经设法将其转换为:

// inspired by https://github.com/WillsonSmith/gif-player-component/blob/main/gif-player.js

import React from 'react'
import { GifReader } from 'omggif'

export class GifPlayer extends React.Component {
    static get styles() {
        return `
            :host {
                display: inline-block;
            }
            canvas {
                display: block;
                width: 100%;
                height: 100%;
            }
        `
    }

    static get properties() {
        return {
            src: { type: String },
            alt: { type: String },
            autoplay: { type: Boolean },
            play: { type: Function },
            pause: { type: Function },
            restart: { type: Function },
            currentFrame: { type: Number },
            frames: { attribute: false, type: Array },
            playing: { attribute: false, type: Boolean },
            width: { attribute: false, type: Number },
            height: { attribute: false, type: Number },
        }
    }

    constructor(props) {
        super(props)
        this.currentFrame = 1
        this.frames = []
        this.step = this.step()
        this.play = this.play.bind(this)
        this.pause = this.pause.bind(this)
        this.renderFrame = this.renderFrame.bind(this)
        this.loadSource = this.loadSource.bind(this)
    }

    firstUpdated = () => {
        this.canvas = this.renderRoot.querySelector('canvas')
        this.context = this.canvas.getContext('2d')
        this.loadSource(this.src).then(() => {
            if (this.autoplay) this.play()
        })
    }

    updated = (changedProperties) => {
        if (changedProperties.has('width')) {
            this.canvas.width = this.width
            this.renderFrame(false)
        }
        if (changedProperties.has('height')) {
            this.canvas.height = this.height
            this.renderFrame(false)
        }
    }

    render() {
        const { alt } = this.props
        return <canvas role="img" aria-label={alt}></canvas>
    }

    play = () => {
        if (this.animationFrame) cancelAnimationFrame(this.animationFrame)
        this.animationFrame = requestAnimationFrame(this.step)
        this.playing = true
    }

    pause = () => {
        this.playing = false
        if (this.animationFrame) cancelAnimationFrame(this.animationFrame)
    }

    restart = () => {
        this.currentFrame = 1
        if (this.playing) {
            this.play()
        } else {
            this.pause()
            this.renderFrame(false)
        }
    }

    step = () => {
        let previousTimestamp
        return (timestamp) => {
            if (!previousTimestamp) previousTimestamp = timestamp
            const delta = timestamp - previousTimestamp
            const delay = this.frames[this.currentFrame]?.delay
            if (this.playing && delay && delta > delay) {
                previousTimestamp = timestamp
                this.renderFrame()
            }
            this.animationFrame = requestAnimationFrame(this.step)
        }
    }

    renderFrame = (progress = true) => {
        if (!this.frames.length) return
        if (this.currentFrame === this.frames.length - 1) {
            this.currentFrame = 0
        }

        this.context.putImageData(this.frames[this.currentFrame].data, 0, 0)
        if (progress) {
            this.currentFrame = this.currentFrame + 1
        }
    }

    loadSource = async (url) => {
        const response = await fetch(url)
        const buffer = await response.arrayBuffer()
        const uInt8Array = new Uint8Array(buffer)
        const gifReader = new GifReader(uInt8Array)
        const gif = gifData(gifReader)
        const { width, height, frames } = gif
        this.width = width
        this.height = height
        this.frames = frames
        if (!this.alt) {
            this.alt = url
        }
        this.renderFrame(false)
    }
}

function gifData(gif) {
    const frames = Array.from(frameDetails(gif))
    return { width: gif.width, height: gif.height, frames }
}

function* frameDetails(gifReader) {
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')

    if (!context) return

    const frameCount = gifReader.numFrames()
    let previousFrame

    for (let i = 0; i < frameCount; i++) {
        const frameInfo = gifReader.frameInfo(i)
        const imageData = context.createImageData(gifReader.width, gifReader.height)
        if (i > 0 && frameInfo.disposal < 2) {
            imageData.data.set(new Uint8ClampedArray(previousFrame.data.data))
        }
        gifReader.decodeAndBlitFrameRGBA(i, imageData.data)
        previousFrame = {
            data: imageData,
            delay: gifReader.frameInfo(i).delay * 10,
        }
        yield previousFrame
    }
}

但是,我遇到了各种 TypeScript 错误。我也不知道如何设计 :host css 属性.

我该如何解决?

不想阅读这堵文字墙的人请快速link:repository link

这是一个非常有趣的项目。我不保证我支持所有用例,但这是一个利用 TypeScript 的现代实现。它还更喜欢更现代和可组合的 React Hook API 而不是使用 class 组件。

基本的想法是你有 useGifController 钩子,它通过 ref linked 到 canvas。然后,此控制器允许您自行处理加载和错误状态,然后根据需要控制在 canvas 中呈现的 GIF。因此,例如,我们可以编写一个 GifPlayer 组件,如下所示:

GifPlayer.tsx

import React, { useRef } from 'react'
import { useGifController } from '../hooks/useGifController'

export function GifPlayer(): JSX.Element | null {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const gifController = useGifController('/cradle.gif', canvasRef, true)

  if (gifController.loading) {
    return null
  }

  if (gifController.error) {
    return null
  }

  const { playing, play, pause, restart, renderNextFrame, renderPreviousFrame, width, height } = gifController

  return (
    <div>
      <canvas {...gifController.canvasProps} ref={canvasRef} />
      <div style={{ display: 'flex', gap: 16, justifyContent: 'space-around' }}>
        <button onClick={renderPreviousFrame}>Previous</button>
        {playing ? <button onClick={pause}>Pause</button> : <button onClick={play}>Play</button>}
        <button onClick={restart}>Restart</button>
        <button onClick={renderNextFrame}>Next</button>
      </div>
      <div>
        <p>Width: {width}</p>
        <p>Height: {height}</p>
      </div>
    </div>
  )
}

在这里,您可以看到 gifController 需要包含 GIF 的 URL,ref 需要 canvas 元素。然后,一旦您处理了 loadingerror 状态,您就可以访问 GifController 提供的所有控件。 playpauserenderNextFramerenderPreviousFrame 都完全符合您的期望。

那么,这个 useGifController 钩子里面有什么?好吧...有点冗长,但希望我已经充分记录了这一点,以便您稍微研究一下后就能理解。

useGifController.ts

import { GifReader } from 'omggif'
import {
  RefObject,
  DetailedHTMLProps,
  CanvasHTMLAttributes,
  useEffect,
  useState,
  MutableRefObject,
  useRef,
} from 'react'
import { extractFrames, Frame } from '../lib/extractFrames'

type HTMLCanvasElementProps = DetailedHTMLProps<CanvasHTMLAttributes<HTMLCanvasElement>, HTMLCanvasElement>

type GifControllerLoading = {
  canvasProps: HTMLCanvasElementProps
  loading: true
  error: false
}

type GifControllerError = {
  canvasProps: HTMLCanvasElementProps
  loading: false
  error: true
  errorMessage: string
}

type GifControllerResolved = {
  canvasProps: HTMLCanvasElementProps
  loading: false
  error: false
  frameIndex: MutableRefObject<number>
  playing: boolean
  play: () => void
  pause: () => void
  restart: () => void
  renderFrame: (frame: number) => void
  renderNextFrame: () => void
  renderPreviousFrame: () => void
  width: number
  height: number
}

type GifController = GifControllerLoading | GifControllerResolved | GifControllerError

export function useGifController(
  url: string,
  canvas: RefObject<HTMLCanvasElement | null>,
  autoplay = false,
): GifController {
  type LoadingState = {
    loading: true
    error: false
  }

  type ErrorState = {
    loading: false
    error: true
    errorMessage: string
  }

  type ResolvedState = {
    loading: false
    error: false
    gifReader: GifReader
    frames: Frame[]
  }

  type State = LoadingState | ResolvedState | ErrorState

  const ctx = canvas.current?.getContext('2d')

  // asynchronous state variables strongly typed as a union such that properties
  // are only defined when `loading === true`.
  const [state, setState] = useState<State>({ loading: true, error: false })
  const [shouldUpdate, setShouldUpdate] = useState(false)
  const [canvasAccessible, setCanvasAccessible] = useState(false)
  const frameIndex = useRef(-1)

  // state variable returned by hook
  const [playing, setPlaying] = useState(false)
  // ref that is used internally
  const _playing = useRef(false)

  // Load GIF on initial render and when url changes.
  useEffect(() => {
    async function loadGif() {
      const response = await fetch(url)
      const buffer = await response.arrayBuffer()
      const uInt8Array = new Uint8Array(buffer)

      // Type cast is necessary because GifReader expects Buffer, which extends
      // Uint8Array. Doesn't *seem* to cause any runtime errors, but I'm sure
      // there's some edge case I'm not covering here.
      const gifReader = new GifReader(uInt8Array as Buffer)
      const frames = extractFrames(gifReader)

      if (!frames) {
        setState({ loading: false, error: true, errorMessage: 'Could not extract frames from GIF.' })
      } else {
        setState({ loading: false, error: false, gifReader, frames })
      }

      // must trigger re-render to ensure access to canvas ref
      setShouldUpdate(true)
    }
    loadGif()
    // only run this effect on initial render and when URL changes.
    // eslint-disable-next-line
  }, [url])

  // update if shouldUpdate gets set to true
  useEffect(() => {
    if (shouldUpdate) {
      setShouldUpdate(false)
    } else if (canvas.current !== null) {
      setCanvasAccessible(true)
    }
  }, [canvas, shouldUpdate])

  // if canvasAccessible is set to true, render first frame and then autoplay if
  // specified in hook arguments
  useEffect(() => {
    if (canvasAccessible && frameIndex.current === -1) {
      renderNextFrame()
      autoplay && setPlaying(true)
    }
    // ignore renderNextFrame as it is referentially unstable
    // eslint-disable-next-line
  }, [canvasAccessible])

  useEffect(() => {
    if (playing) {
      _playing.current = true
      _iterateRenderLoop()
    } else {
      _playing.current = false
    }
    // ignore _iterateRenderLoop() as it is referentially unstable
    // eslint-disable-next-line
  }, [playing])

  if (state.loading === true || !canvas) return { canvasProps: { hidden: true }, loading: true, error: false }

  if (state.error === true)
    return { canvasProps: { hidden: true }, loading: false, error: true, errorMessage: state.errorMessage }

  const { width, height } = state.gifReader

  return {
    canvasProps: { width, height },
    loading: false,
    error: false,
    playing,
    play,
    pause,
    restart,
    frameIndex,
    renderFrame,
    renderNextFrame,
    renderPreviousFrame,
    width,
    height,
  }

  function play() {
    if (state.error || state.loading) return
    if (playing) return
    setPlaying(true)
  }

  function _iterateRenderLoop() {
    if (state.error || state.loading || !_playing.current) return

    const delay = state.frames[frameIndex.current].delay
    setTimeout(() => {
      renderNextFrame()
      _iterateRenderLoop()
    }, delay)
  }

  function pause() {
    setPlaying(false)
  }

  function restart() {
    frameIndex.current = 0
    setPlaying(true)
  }

  function renderFrame(frameIndex: number) {
    if (!ctx || state.loading === true || state.error === true) return
    if (frameIndex < 0 || frameIndex >= state.gifReader.numFrames()) return
    ctx.putImageData(state.frames[frameIndex].imageData, 0, 0)
  }

  function renderNextFrame() {
    if (!ctx || state.loading === true || state.error === true) return
    const nextFrame = frameIndex.current + 1 >= state.gifReader.numFrames() ? 0 : frameIndex.current + 1
    renderFrame(nextFrame)
    frameIndex.current = nextFrame
  }

  function renderPreviousFrame() {
    if (!ctx || state.loading === true || state.error === true) return
    const prevFrame = frameIndex.current - 1 < 0 ? state.gifReader.numFrames() - 1 : frameIndex.current - 1
    renderFrame(prevFrame)
    frameIndex.current = prevFrame
  }
}

这又取决于 extractFrames,它将帧提取为 ImageData 对象及其各自的延迟。

extractFrames.ts

import { GifReader } from 'omggif'

export type Frame = {
  /**
   * A full frame of a GIF represented as an ImageData object. This can be
   * rendered onto a canvas context simply by calling
   * `ctx.putImageData(frame.imageData, 0, 0)`.
   */
  imageData: ImageData
  /**
   * Delay in milliseconds.
   */
  delay: number
}

/**
 * Function that accepts a `GifReader` instance and returns an array of
 * `ImageData` objects that represent the frames of the gif.
 *
 * @param gifReader The `GifReader` instance.
 * @returns An array of `ImageData` objects representing each frame of the GIF.
 * Or `null` if something went wrong.
 */
export function extractFrames(gifReader: GifReader): Frame[] | null {
  const frames: Frame[] = []

  // the width and height of the complete gif
  const { width, height } = gifReader

  // This is the primary canvas that the tempCanvas below renders on top of. The
  // reason for this is that each frame stored internally inside the GIF is a
  // "diff" from the previous frame. To resolve frame 4, we must first resolve
  // frames 1, 2, 3, and then render frame 4 on top. This canvas accumulates the
  // previous frames.
  const canvas = document.createElement('canvas')
  canvas.width = width
  canvas.height = height
  const ctx = canvas.getContext('2d')
  if (!ctx) return null

  for (let frameIndex = 0; frameIndex < gifReader.numFrames(); frameIndex++) {
    // the width, height, x, and y of the "dirty" pixels that should be redrawn
    const { width: dirtyWidth, height: dirtyHeight, x: dirtyX, y: dirtyY, disposal, delay } = gifReader.frameInfo(0)

    // skip this frame if disposal >= 2; from GIF spec
    if (disposal >= 2) continue

    // create hidden temporary canvas that exists only to render the "diff"
    // between the previous frame and the current frame
    const tempCanvas = document.createElement('canvas')
    tempCanvas.width = width
    tempCanvas.height = height
    const tempCtx = tempCanvas.getContext('2d')
    if (!tempCtx) return null

    // extract GIF frame data to tempCanvas
    const newImageData = tempCtx.createImageData(width, height)
    gifReader.decodeAndBlitFrameRGBA(frameIndex, newImageData.data)
    tempCtx.putImageData(newImageData, 0, 0, dirtyX, dirtyY, dirtyWidth, dirtyHeight)

    // draw the tempCanvas on top. ctx.putImageData(tempCtx.getImageData(...))
    // is too primitive here, since the pixels would be *replaced* by incoming
    // RGBA values instead of layered.
    ctx.drawImage(tempCanvas, 0, 0)

    frames.push({
      delay: delay * 10,
      imageData: ctx.getImageData(0, 0, width, height),
    })
  }

  return frames
}

我确实计划有一天将其制作成 NPM 包。我认为它会非常有用。