用CSS绘画API绘制图形

653 阅读8分钟

CSS Paint是一个API,它允许开发者在CSS期望有图像的地方以编程方式生成和绘制图形。

它是CSS Houdini的一部分,是七个新的低级API的总称,这些API暴露了CSS引擎的不同部分,并允许开发者通过挂钩浏览器渲染引擎的样式和布局过程来扩展CSS。

它使开发者能够编写浏览器可以解析为CSS的代码,从而创造新的CSS功能,而不必等待它们在浏览器中的原生实现。

今天我们将探讨两个特别的API,它们是CSS Houdini伞的一部分。

  1. CSS Paint,在写这篇文章时,它已经在Chrome、Opera和Edge中完全实现,并通过一个polyfill在Firefox和Safari中可用。
  2. CSS属性和值API,它将允许我们明确地定义我们的CSS变量、它们的初始值、它们支持什么类型的值以及这些变量是否可以被继承。

CSS画图为我们提供了使用CSS API渲染图形的能力。 [PaintWorklet](https://developer.mozilla.org/en-US/docs/Web/API/PaintWorklet)的一个精简版本。 [CanvasRenderingContext2D](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D).主要的区别在于。

  • 不支持文本渲染
  • 没有直接的像素访问/操作

考虑到这两个遗漏,凡是你能用canvas2d ,你就能用CSS Paint API在普通的DOM元素上绘制。对于那些使用canvas2d 做过任何图形的人来说,你应该很自在。

此外,作为开发者,我们有能力将CSS变量作为输入传递给我们的PaintWorklet ,并使用自定义的预定义属性来控制其表现。

这允许高度的定制化,即使是那些不一定熟悉Javascript的设计人员也能做到。

你可以在这里这里看到更多的例子。说完这些,让我们开始编码吧!

最简单的例子:两条对角线

让我们创建一个CSS paintlet,一旦加载,它将在我们应用它的DOM元素的表面上画两条对角线。Painttlet绘制的表面大小将适应DOM元素的宽度和高度,我们将能够通过传递一个CSS变量来控制对角线的厚度。

创建我们的PaintWorklet

为了加载一个PaintWorklet ,我们将需要把它创建为一个单独的Javascript文件(diagonal-lines.js )。

const PAINTLET_NAME = 'diagonal-lines'

class CSSPaintlet {

  // 👉 Define the names of the input CSS variables we will support
  static get inputProperties() {
    return [      `--${PAINTLET_NAME}-line-width`,    ]
  }

  // 👉 Define names for input CSS arguments supported in paint()
  // ⚠ This part of the API is still experimental and hidden
  //    behind a flag.
  static get inputArguments () {
    return []
  }

  // 👉 paint() will be executed every time:
  //  - any input property changes
  //  - the DOM element we apply our paintlet to changes its dimensions
  paint(ctx, paintSize, props) {
    // 👉 Obtain the numeric value of our line width that is passed
    //    as a CSS variable
    const lineWidth = Number(props.get(`--${PAINTLET_NAME}-line-width`))

    ctx.lineWidth = lineWidth

    // 🎨 Draw diagonal line #1
    ctx.beginPath()
    ctx.moveTo(0, 0)
    ctx.lineTo(paintSize.width, paintSize.height)
    ctx.stroke()

    // 🎨 Draw diagonal line #2
    ctx.beginPath()
    ctx.moveTo(0, paintSize.height)
    ctx.lineTo(paintSize.width, 0)
    ctx.stroke()
  }
}

// 👉 Register our CSS Paintlet with the correct name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

我们将我们的CSS paintlet定义为一个独立的类。这个类只需要一个方法来工作 -paint() ,它将在我们指定的CSS paintlet的表面上绘制图形。它将在我们的painttlet所依赖的任何CSS变量发生变化或我们的DOM元素的尺寸发生变化时被执行。

另一个静态方法inputProperties() 是可选的。它告诉CSS paintlet它到底支持哪些输入CSS变量。在我们的例子中,这将是--diagonal-lines-line-width 。我们把它声明为一个输入属性,并在我们的paint() 方法中使用它。重要的是,我们把它放在Number ,以确保跨浏览器支持。

还有一个可选的静态方法支持:inputArguments 。它像这样向我们的paint() 方法暴露参数。

#myImage {
  background-image: paint(myWorklet, 30px, red, 10deg);
}

然而,CSS paintlet API的这一部分仍然隐藏在一个标志后面,被认为是实验性的。为了便于使用和兼容,我们不会在本文中涉及它,但我鼓励你自己去阅读它。相反,我们将使用CSS变量,使用inputProperties() 方法来控制我们的Painttlet的所有输入。

注册我们的CSS PaintWorklet

之后,我们必须引用我们的CSS paintlet并将其注册到我们的主页面。重要的是,我们要有条件地加载awesome [css-paint-polyfill](https://github.com/GoogleChromeLabs/css-paint-polyfill)包,这将确保我们的painttlets能在Firefox和Safari中工作。

应该注意的是,沿着我们的CSS paintlet,我们可以使用新的CSS属性和值API(也是Houdini伞的一部分),通过CSS.registerProperty() ,明确地定义我们的CSS变量输入。我们可以像这样控制我们的CSS变量。

  • 它们的类型和语法
  • 这个CSS变量是否继承自任何父元素
  • 如果用户没有指定,它的初始值是什么?

这个API在Firefox和Safari中也不支持,但我们仍然可以在Chromium浏览器中使用它。这样我们的演示就可以面向未来,不支持它的浏览器将直接忽略它。

;(async function() {
  // ⚠ Handle Firefox and Safari by importing a polyfill for CSS Pain    
  if (CSS['paintWorklet'] === undefined) {
    await import('https://unpkg.com/css-paint-polyfill')
  }

  // 👉 Explicitly define our custom CSS variable
  //    This is not supported in Safari and Firefox, so they will
  //    ignore it, but we can optionally use it in browsers that 
  //    support it. 
  //    This way we will future-proof our applications so once Safari
  //    and Firefox support it, they will benefit from these
  //    definitions too.
  //
  //    Make sure that the browser treats it as a number
  //    It does not inherit it's value
  //    It's initial value defaults to 1
  if ('registerProperty' in CSS) {
    CSS.registerProperty({
      name: '--diagonal-lines-line-width',
      syntax: '<number>',
      inherits: false,
      initialValue: 1
    })
  }

  // 👉 Include our separate paintlet file
  CSS.paintWorklet.addModule('path/to/our/external/worklet/diagonal-files.js')
})()

将我们的painttlet作为CSS背景来引用

一旦我们把我们的painttlet作为一个JS文件,使用它就非常简单了。我们选择我们要在CSS中样式的目标DOM元素,通过paint() CSS命令应用我们的painttlet。

#myElement {
   // 👉 Reference our CSS paintlet
   background-image: paint('--diagonal-lines');

   // 👉 Pass in custom CSS variable to be used in our CSS paintlet
   --diagonal-lines-line-width: 10;

   // 👉 Remember - the browser treats this as a regular image
   // referenced in CSS. We can control it's repeat, size, position
   // and any other background related property available
   background-repeat: no-repeat;
   background-size: cover;
   background-position: 50% 50%;

   // Some more styles to make sure we can see our element on the page
   border: 1px solid red;
   width: 200px;
   height: 200px;
   margin: 0 auto;
}

随着这段代码的完成,我们将得到以下结果。

请看Georgi Nikoloff (@gbnikolov) 在CodePen上的PenCSS Worklet介绍实例

记住,我们可以将这个CSS小工具作为背景应用于任何尺寸的DOM元素。让我们把我们的DOM元素放大到全屏,降低它的background-size x和y值,并设置它的background-repeat ,以重复。这就是我们更新的例子。

请看Georgi Nikoloff (@gbnikolov)在CodePen上写的PenCSS Worklet介绍实例

我们使用的是之前例子中的那个CSS paintlet,但现在我们将它扩展到了整个演示页面。

所以,现在我们已经涵盖了我们的基本例子,并且看到了如何组织我们的代码,让我们来写一些更漂亮的演示吧

粒子连接

请看Georgi Nikoloff(@gbnikolov)在CodePen上的PenCSS Worklet Particles

这个painttlet的灵感来自于@nucliweb精彩演示

同样,对于那些过去使用过canvas2d API来绘制图形的人来说,这将是非常简单的。

我们通过`-dots-connections-count`这个CSS变量来控制我们要渲染多少个点。一旦我们在我们的painttlet中获得了它的数值,我们就创建一个具有适当大小的数组,并用随机的x,yradius 属性的对象填充它。

然后我们循环数组中的每一个项目,在它的坐标上画一个球体,找到离它最近的邻居(最小距离通过`-dots-connections-connection-min-dist`CSS变量控制),用线把它们连接起来。

我们还将分别通过`-dots-connections-fill-color`和--dots-connections-stroke-color CSS变量来控制球体的填充颜色和线条的笔划颜色。

下面是完整的工作代码。

const PAINTLET_NAME = 'dots-connections'

class CSSPaintlet {
  // 👉 Define names for input CSS variables we will support
  static get inputProperties() {
    return [
      `--${PAINTLET_NAME}-line-width`,
      `--${PAINTLET_NAME}-stroke-color`,
      `--${PAINTLET_NAME}-fill-color`,
      `--${PAINTLET_NAME}-connection-min-dist`,
      `--${PAINTLET_NAME}-count`,
    ]
  }

  // 👉 Our paint method to be executed when CSS vars change
  paint(ctx, paintSize, props, args) {
    const lineWidth = Number(props.get(`--${PAINTLET_NAME}-line-width`))
    const minDist = Number(props.get(`--${PAINTLET_NAME}-connection-min-dist`))
    const strokeColor = props.get(`--${PAINTLET_NAME}-stroke-color`)
    const fillColor = props.get(`--${PAINTLET_NAME}-fill-color`)
    const numParticles = Number(props.get(`--${PAINTLET_NAME}-count`))
    
    // 👉 Generate particles at random positions
    //    across our DOM element surface
    const particles = new Array(numParticles).fill(null).map(_ => ({
      x: Math.random() * paintSize.width,
      y: Math.random() * paintSize.height,
      radius: 2 + Math.random() * 2,
    }))
    
    // 👉 Assign lineWidth coming from CSS variables and make sure
    //    lineCap and lineWidth are round
    ctx.lineWidth = lineWidth
    ctx.lineJoin = 'round'
    ctx.lineCap = 'round'
    
    // 👉 Loop over the particles with nested loops - O(n^2)
    for (let i = 0; i < numParticles; i++) {
      const particle = particles[i]
      // 👉 Loop second time 
      for (let n = 0; n < numParticles; n++) {
        if (i === n) {
          continue
        }
        const nextParticle = particles[n]
        // 👉 Calculate distance between the current particle
        //    and the particle from the previous loop iteration
        const dx = nextParticle.x - particle.x
        const dy = nextParticle.y - particle.y
        const dist = Math.sqrt(dx * dx + dy * dy)
        // 👉 If the dist is smaller then the minDist specified via
        //    CSS variable, then we will connect them with a line
        if (dist < minDist) {
          ctx.strokeStyle = strokeColor
          ctx.beginPath()
          ctx.moveTo(nextParticle.x, nextParticle.y)
          ctx.lineTo(particle.x, particle.y)
          // 👉 Draw the connecting line
          ctx.stroke()
        }
      }
      // Finally draw the particle at the right position
      ctx.fillStyle = fillColor
      ctx.beginPath()
      ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2)
      ctx.closePath()
      ctx.fill()
    }
    
  }
}

// 👉 Register our CSS paintlet with a unique name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

线条循环

下面是我们的下一个例子。它期望以下CSS变量作为输入到我们的painttlet。

--loop-line-width
--loop-stroke-color
--loop-sides
--loop-scale
--loop-rotation

我们围绕一个完整的圆(PI * 2)进行循环,并根据--loop-sides 计的CSS变量沿圆周定位。对于每一个位置,我们再次围绕我们的全圆进行循环,并通过ctx.lineTo() 命令将其连接到所有其他位置。

请看Georgi Nikoloff (@gbnikolov)在CodePen上写的PenCSS Worklet Line Loop

const PAINTLET_NAME = 'loop'

class CSSPaintlet {
  // 👉 Define names for input CSS variables we will support
  static get inputProperties() {
    return [
      `--${PAINTLET_NAME}-line-width`,
      `--${PAINTLET_NAME}-stroke-color`,
      `--${PAINTLET_NAME}-sides`,
      `--${PAINTLET_NAME}-scale`,
      `--${PAINTLET_NAME}-rotation`,
    ]
  }
  // 👉 Our paint method to be executed when CSS vars change
  paint(ctx, paintSize, props, args) {
    const lineWidth = Number(props.get(`--${PAINTLET_NAME}-line-width`))
    const strokeColor = props.get(`--${PAINTLET_NAME}-stroke-color`)
    const numSides = Number(props.get(`--${PAINTLET_NAME}-sides`))
    const scale = Number(props.get(`--${PAINTLET_NAME}-scale`))
    const rotation = Number(props.get(`--${PAINTLET_NAME}-rotation`))
    
    const angle = Math.PI * 2 / numSides
    const radius = paintSize.width / 2
    ctx.save()
    ctx.lineWidth = lineWidth
    ctx.lineJoin = 'round'
    ctx.lineCap = 'round'
    ctx.strokeStyle = strokeColor
    ctx.translate(paintSize.width / 2, paintSize.height / 2)
    ctx.rotate(rotation * (Math.PI / 180))
    ctx.scale(scale / 100, scale / 100)
    ctx.moveTo(0, radius)

    // 👉 Loop over the numsides twice in nested loop - O(n^2)
    //    Connect each corner with all other corners
    for (let i = 0; i < numSides; i++) {
      const x = Math.sin(i * angle) * radius
      const y = Math.cos(i * angle) * radius
      for (let n = i; n < numSides; n++) {
        const x2 = Math.sin(n * angle) * radius
        const y2 = Math.cos(n * angle) * radius
        ctx.lineTo(x, y)
        ctx.lineTo(x2, y2);
      }
    }
    ctx.closePath()
    ctx.stroke()
    ctx.restore()
  }   
}

// 👉 Register our CSS paintlet with a unique name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

噪声按钮

请看Georgi Nikoloff (@gbnikolov)在CodePen上发表的PenCSS Worklet Noise Button

这里是我们的下一个例子。它的灵感来自于Jhey Tompkins另一个很棒的CSS Paintlet。它期望以下的CSS变量作为我们painttlet的输入。

--grid-size
--grid-color
--grid-noise-scale

Paintlet本身使用perlin噪声(代码由joeiddon提供)来控制每个单元的不透明度。

const PAINTLET_NAME = 'grid'

class CSSPaintlet {
  // 👉 Define names for input CSS variables we will support
  static get inputProperties() {
    return [
      `--${PAINTLET_NAME}-size`,
      `--${PAINTLET_NAME}-color`,
      `--${PAINTLET_NAME}-noise-scale`
    ]
  }

  // 👉 Our paint method to be executed when CSS vars change
  paint(ctx, paintSize, props, args) {
    const gridSize = Number(props.get(`--${PAINTLET_NAME}-size`))
    const color = props.get(`--${PAINTLET_NAME}-color`)
    const noiseScale = Number(props.get(`--${PAINTLET_NAME}-noise-scale`))

    ctx.fillStyle = color
    for (let x = 0; x < paintSize.width; x += gridSize) {
      for (let y = 0; y < paintSize.height; y += gridSize) {
        // 👉 Use perlin noise to determine the cell opacity
        ctx.globalAlpha = mapRange(perlin.get(x * noiseScale, y * noiseScale), -1, 1, 0.5, 1)
        ctx.fillRect(x, y, gridSize, gridSize)
      }
    }
  }
}

// 👉 Register our CSS paintlet with a unique name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

弯曲的分隔线

作为最后一个例子,让我们做一些也许更有用的事情。我们将以编程的方式画出分隔线,以分隔我们页面的文本内容。

请看Georgi Nikoloff (@gbnikolov)在CodePen上写的PenCSS Worklet Curvy Dividers

和往常一样,这里是CSS paintlet的代码。

const PAINTLET_NAME = 'curvy-dividor'

class CSSPaintlet {
  // 👉 Define names for input CSS variables we will support
  static get inputProperties() {
    return [
      `--${PAINTLET_NAME}-points-count`,
      `--${PAINTLET_NAME}-line-width`,
      `--${PAINTLET_NAME}-stroke-color`
    ]
  }
  // 👉 Our paint method to be executed when CSS vars change
  paint(ctx, paintSize, props, args) {
    const pointsCount = Number(props.get(`--${PAINTLET_NAME}-points-count`))
    const lineWidth = Number(props.get(`--${PAINTLET_NAME}-line-width`))
    const strokeColor = props.get(`--${PAINTLET_NAME}-stroke-color`)
    
    const stepX = paintSize.width / pointsCount
    
    ctx.lineWidth = lineWidth
    ctx.lineJoin = 'round'
    ctx.lineCap = 'round'
    
    ctx.strokeStyle = strokeColor
    
    const offsetUpBound = -paintSize.height / 2
    const offsetDownBound = paintSize.height / 2
    
    // 👉 Draw quadratic bezier curves across the horizontal axies
    //    of our dividers:
    ctx.moveTo(-stepX / 2, paintSize.height / 2)
    for (let i = 0; i < pointsCount; i++) {
      const x = (i + 1) * stepX - stepX / 2
      const y = paintSize.height / 2 + (i % 2 === 0 ? offsetDownBound : offsetUpBound)
      const nextx = (i + 2) * stepX - stepX / 2
      const nexty = paintSize.height / 2 + (i % 2 === 0 ? offsetUpBound : offsetDownBound)
      const ctrlx = (x + nextx) / 2
      const ctrly = (y + nexty) / 2
      ctx.quadraticCurveTo(x, y, ctrlx, ctrly)
    }
    ctx.stroke()
  }
}

// 👉 Register our CSS paintlet with a unique name
//    so we can reference it from our CSS
registerPaint(PAINTLET_NAME, CSSPaintlet)

结论

在这篇文章中,我们介绍了CSS Paint API的所有关键组件和方法。它很容易设置,如果我们想画出CSS不支持的更高级的图形,它是非常有用的。

我们可以很容易地用这些CSS绘画工具创建一个库,并在我们的项目中不断重复使用它们,而只需进行最少的设置。

作为一种良好的做法,我鼓励你找到很酷的canvas2d 演示,并将它们移植到新的CSS Paint API上。

The post Drawing Graphicswith the CSS Paint APIappeared first onCodrops.