探索 CSS Paint API:多边形边框

Avatar of Temani Afif
Temani Afif

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

如今,使用 clip-path 创建复杂形状是一件很容易的事,但为这些形状添加边框却始终是一个难题。CSS 没有提供可靠的解决方案,我们总是需要针对每个特定情况编写特定的“hacky”代码。在本文中,我将向您展示如何使用 CSS Paint API 解决此问题。

探索 CSS Paint API 系列


在我们深入探讨第三次实验之前,这里简要概述一下我们要构建的内容。并且,请注意,我们在这里所做的一切仅在基于 Chromium 的浏览器中受支持,因此您需要在 Chrome、Edge 或 Opera 中查看演示。 查看 caniuse 获取最新的支持情况。

实时演示

您不会在那里找到复杂的 CSS 代码,而是一个通用的代码,我们只需要调整几个变量来控制形状。

主要思想

为了实现多边形边框,我将依靠 CSS clip-path 属性和使用 Paint API 创建的自定义蒙版相结合。

实时演示
  1. 我们从一个基本的矩形形状开始。
  2. 我们应用 clip-path 来获取我们的多边形形状。
  3. 我们应用自定义蒙版来获取我们的多边形边框

CSS 设置

这是我们将要使用的 clip-path 步骤的 CSS 代码

.box {
  --path: 50% 0,100% 100%,0 100%;

  width: 200px;
  height: 200px;
  background: red;
  display: inline-block;
  clip-path: polygon(var(--path));
}

到目前为止,没有什么复杂的,但请注意 CSS 变量 --path 的使用。整个技巧都依赖于这个单个变量。由于我将使用 clip-pathmask,两者都需要使用相同的参数,因此使用了 --path 变量。而且,是的,Paint API 将使用相同的变量来创建自定义蒙版。

整个过程的 CSS 代码变为

.box {
  --path: 50% 0,100% 100%,0 100%;
  --border: 5px;

  width: 200px;
  height: 200px;
  background: red;
  display: inline-block;
  clip-path: polygon(var(--path));
  -webkit-mask: paint(polygon-border)
}

除了 clip-path 之外,我们还应用了自定义蒙版,并添加了一个额外的变量 --border 来控制边框的粗细。如您所见,到目前为止,所有内容仍然是非常基础和通用的 CSS 代码。毕竟,这是使 CSS Paint API 易于使用的优势之一。

JavaScript 设置

我强烈建议阅读我之前文章的 第一部分 以了解 Paint API 的结构。

现在,让我们看看在进入 JavaScript 时 paint() 函数内部发生了什么

const points = properties.get('--path').toString().split(',');
const b = parseFloat(properties.get('--border').value);
const w = size.width;
const h = size.height;

const cc = function(x,y) {
  // ...
}

var p = points[0].trim().split(" ");
p = cc(p[0],p[1]);

ctx.beginPath();
ctx.moveTo(p[0],p[1]);
for (var i = 1; i < points.length; i++) {
  p = points[i].trim().split(" ");
  p = cc(p[0],p[1]);
  ctx.lineTo(p[0],p[1]);
}
ctx.closePath();

ctx.lineWidth = 2*b;
ctx.strokeStyle = '#000';
ctx.stroke();

能够获取和设置 CSS 自定义属性是它们如此强大的原因之一。我们可以使用 JavaScript 首先读取 --path 变量的值,然后将其转换为点数组(如上面最上面一行所示)。因此,这意味着 50% 0,100% 100%,0 100% 成为蒙版的点,即 points = ["50% 0","100% 100%","0 100%"]

然后我们循环遍历这些点,使用 moveTolineTo 绘制一个多边形。这个多边形与使用 clip-path 属性在 CSS 中绘制的多边形完全相同。

最后,在绘制形状之后,我为其添加了一个描边。我使用 lineWidth 定义描边的粗细,并使用 strokeStyle 设置纯色。换句话说,只有形状的描边可见,因为我没有用任何颜色填充形状(即它是透明的)。

现在我们所要做的就是更新路径和粗细以创建任何多边形边框。值得注意的是,我们不限于纯色,因为我们正在使用 CSS background 属性。我们可以考虑渐变或图像。

实时演示

如果我们需要添加内容,则必须考虑伪元素。否则,内容在过程中会被裁剪。支持内容并不难。我们将 mask 属性移动到伪元素。我们可以将 clip-path 声明保留在主要元素上。

到目前为止的问题?

我知道在查看了最后一个脚本后,您可能有一些迫切想要问的问题。请允许我抢先回答一些我猜您心里想到的问题。

那个 cc() 函数是什么?

我使用该函数将每个点的值转换为像素值。对于每个点,我获取 xy 坐标(使用 points[i].trim().split(" ")),然后转换这些坐标,以便在允许我们使用这些点进行绘制的画布元素内部使用它们。

const cc = function(x,y) {
  var fx=0,fy=0;
  if (x.indexOf('%') > -1) {
    fx = (parseFloat(x)/100)*w;
  } else if(x.indexOf('px') > -1) {
    fx = parseFloat(x);
  }
  if (y.indexOf('%') > -1) {
     fy = (parseFloat(y)/100)*h;
  } else if(y.indexOf('px') > -1) {
    fy = parseFloat(y);
  }
  return [fx,fy];
}

逻辑很简单:如果它是百分比值,我将使用宽度(或高度)来查找最终值。如果它是像素值,我只需获取不带单位的值。例如,如果我们有 [50% 20%],其中宽度等于 200px,高度等于 100px,那么我们将得到 [100 20]。如果它是 [20px 50px],那么我们将得到 [20 50]。以此类推。

如果蒙版已将元素裁剪到形状的描边,为什么要使用 CSS clip-path

只使用蒙版是我最初的想法,但我遇到了这种方法的两个主要问题。第一个与 stroke() 的工作原理有关。来自 MDN

描边与路径的中心对齐;换句话说,一半的描边绘制在内部,一半绘制在外部。

那个“一半在内,一半在外”让我非常头疼,并且在将所有内容组合在一起时,我总是会遇到奇怪的溢出。这就是 CSS clip-path 发挥作用的地方;它裁剪了外部部分,只保留了内部部分——不再溢出!

您会注意到 ctx.lineWidth = 2*b 的使用。我添加了两倍的边框粗细,因为我将裁剪其中的一半,以最终获得围绕整个形状所需的正确粗细。

第二个问题与形状的可悬停区域有关。众所周知,蒙版不会影响该区域,我们仍然可以悬停/与整个矩形交互。同样,使用 clip-path 可以解决此问题,并且我们还可以将交互限制在形状本身。

以下演示说明了这两个问题。第一个元素同时具有蒙版和裁剪路径,而第二个元素只有蒙版。我们可以清楚地看到溢出问题。尝试悬停第二个元素以查看即使光标在三角形外部,我们也可以更改颜色。

为什么要使用 @property 和边框值?

这是一个有趣且相当棘手的部分。默认情况下,自定义属性(例如 --border)被视为“CSSUnparsedValue”,这意味着它们被视为字符串。来自 CSS 规范

CSSUnparsedValue”对象表示引用自定义属性的属性值。它们由字符串片段和变量引用的列表组成。

使用 @property,我们可以注册自定义属性并为其指定类型,以便浏览器可以识别它并将其作为有效类型而不是字符串进行处理。在我们的例子中,我们将边框注册为 <length> 类型,以便稍后它成为 CSSUnitValue。这也允许我们对边框值使用任何长度单位(pxemchvh 等)。

这听起来可能有点复杂,但让我尝试用 DevTools 屏幕截图来说明一下区别。

我正在对一个变量使用 console.log(),其中我定义了 5em。第一个已注册,但第二个未注册。

在第一种情况下,浏览器识别类型并将值转换为像素值,这很有用,因为我们只需要在 paint() 函数内部使用像素值。在第二种情况下,我们将变量作为字符串获取,这不太有用,因为我们无法在 paint() 函数内部将 em 单位转换为 px 单位。

尝试所有单位。它始终会在 paint() 函数内部产生计算出的像素值。

--path 变量怎么样?

我想对 --path 变量使用相同的方法,但不幸的是,我认为我将 CSS 推到了它在这里所能做到的极限。使用 @property,我们可以注册复杂类型,甚至多值变量。但这对于我们需要的路径来说仍然不够。

我们可以使用 +# 符号来 定义以空格或逗号分隔的值列表,但我们的路径是以逗号分隔的空格分隔的百分比(或长度)值列表。我会使用类似 [<length-percentage>+]# 的东西,但它不存在。

对于路径,我不得不将其作为字符串值进行操作。这目前将我们限制在百分比和像素值上。出于这个原因,我定义了 cc() 函数将字符串值转换为像素值。

我们可以在 CSS 规范 中阅读

语法字符串的内部语法是 CSS 值定义语法 的一个子集。预计 规范 的未来级别将扩展允许的语法的复杂性,允许更接近 CSS 属性允许的全部范围的自定义属性。

即使语法扩展到能够注册路径,如果我们需要在路径中包含 calc(),我们仍然会遇到问题

--path: 0 0,calc(100% - 40px) 0,100% 40px,100% 100%,0 100%;

在上面,calc(100% - 40px) 是浏览器认为是 <length-percentage> 的值,但浏览器无法计算该值,除非它知道百分比的参考。换句话说,我们无法在 paint() 函数内部获取等效的像素值,因为只有在 var() 中使用该值时才能知道该参考。

为了克服这个问题,我们可以扩展 cc() 函数来进行转换。我们完成了百分比值和像素值的转换,所以让我们将它们组合成一个转换。我们将考虑两种情况:calc(P% - Xpx)calc(P% + Xpx)。我们的脚本变为

const cc = function(x,y) { 
  var fx=0,fy=0;
  if (x.indexOf('calc') > -1) {
    var tmp = x.replace('calc(','').replace(')','');
    if (tmp.indexOf('+') > -1) {
      tmp = tmp.split('+');
      fx = (parseFloat(tmp[0])/100)*w + parseFloat(tmp[1]);
    } else {
      tmp = tmp.split('-');
      fx = (parseFloat(tmp[0])/100)*w - parseFloat(tmp[1]);
    }
   } else if (x.indexOf('%') > -1) {
      fx = (parseFloat(x)/100)*w;
   } else if(x.indexOf('px') > -1) {
      fx = parseFloat(x);
   }
      
   if (y.indexOf('calc') > -1) {
    var tmp = y.replace('calc(','').replace(')','');
    if (tmp.indexOf('+') > -1) {
       tmp = tmp.split('+');
       fy = (parseFloat(tmp[0])/100)*h + parseFloat(tmp[1]);
     } else {
       tmp = tmp.split('-');
       fy = (parseFloat(tmp[0])/100)*h - parseFloat(tmp[1]);
     }
    } else if (y.indexOf('%') > -1) {
      fy = (parseFloat(y)/100)*h;
    } else if(y.indexOf('px') > -1) {
      fy = parseFloat(y);
    }
  return [fx,fy];
}

我们使用 indexOf() 来测试 calc 的存在,然后通过一些字符串操作,我们提取这两个值并找到最终的像素值。

并且,因此,我们还需要更新此行

p = points[i].trim().split(" ");

…到

p = points[i].trim().split(/(?!\(.*)\s(?![^(]*?\))/g);

由于我们需要考虑 calc(),因此使用空格字符来分割将不起作用。这是因为 calc() 也包含空格。所以我们需要一个正则表达式。不要问我关于它的事情——这是在尝试了 Stack Overflow 上的很多方法后才起作用的方法。

这是一个基本的演示,说明我们迄今为止为支持 calc() 所做的更新。

请注意,我们将 calc() 表达式存储在变量 --v 中,我们将其注册为 <length-percentage>。这也是技巧的一部分,因为如果我们这样做,浏览器会使用正确的格式。无论 calc() 表达式的复杂程度如何,浏览器始终将其转换为 calc(P% +/- Xpx) 格式。出于这个原因,我们只需要在 paint() 函数内部处理该格式。

下面是我们在每个示例中使用不同 calc() 表达式的不同示例

如果检查每个框的代码并查看 --v 的计算值,您将始终找到相同的格式,这非常有用,因为我们可以进行任何我们想要的计算。

需要注意的是,使用变量 --v 不是强制性的。我们可以直接在路径中包含 calc()。我们只需要确保插入正确的格式,因为浏览器不会为我们处理它(请记住,我们无法注册路径变量,因此它是浏览器的字符串)。当我们需要在路径中包含多个 calc() 并且为每个 calc() 创建一个变量会使代码过长时,这可能很有用。我们将在最后看到一些示例。

我们可以有虚线边框吗?

我们可以!这只需要一条指令。<canvas> 元素已经有一个内置函数来绘制虚线笔划 setLineDash()

Canvas 2D API 的 CanvasRenderingContext2D 接口的 setLineDash() 方法设置在描边线条时使用的线段图案。它使用一个值数组来指定线条和间隙的交替长度,这些长度描述了图案。

我们要做的就是引入另一个变量来定义我们的虚线图案。

实时演示

在 CSS 中,我们只需添加一个 CSS 变量 --dash,并在蒙版中添加以下内容

// ...
const d = properties.get('--dash').toString().split(',');
// ...
ctx.setLineDash(d);

我们还可以使用 lineDashOffset 控制偏移量。我们稍后将了解如何控制偏移量可以帮助我们实现一些很酷的动画。

为什么不使用 @property 来注册虚线变量?

从技术上讲,我们可以将虚线变量注册为 <length>#,因为它是以逗号分隔的长度值列表。它确实有效,但是我无法在 paint() 函数内部检索这些值。我不知道这是不是一个错误、缺乏支持,或者我只是缺少拼图的一部分。

这是一个演示来说明这个问题

我正在使用以下方法注册 --dash 变量

@property --dash{
  syntax: '<length>#';
  inherits: true;
  initial-value: 0;
}

…并在稍后将变量声明为

--dash: 10em,3em;

如果我们检查元素,我们可以看到浏览器正在正确处理变量,因为计算出的值是像素值

但我们只在 paint() 函数内部获取第一个值

在我找到此问题的修复方法之前,我只能将 --dash 变量用作字符串,就像 --path 一样。在这种情况下没什么大不了的,因为我认为我们不需要超过像素值。

用例!

在探索了此技术的幕后之后,现在让我们关注 CSS 部分并查看我们多边形边框的一些用例。

按钮集合

我们可以轻松生成具有很酷悬停效果的自定义形状按钮。

请注意 calc() 如何在最后一个按钮的路径中使用,就像我们之前描述的那样。它可以正常工作,因为我遵循了正确的格式。

面包屑

创建面包屑系统不再头疼!下面,您将找不到任何“hacky”或复杂的 CSS 代码,而是非常通用且易于理解的内容,我们只需调整一些变量即可。

卡片翻转动画

如果我们对厚度应用一些动画,就可以获得一些花哨的悬停效果。

我们可以使用同样的思路来创建一个揭示卡片的动画。

Callout & 语言泡

“我们到底怎么给那个小箭头添加边框??”我认为每个人在处理Callout或语言泡之类的设计时都遇到过这个问题。Paint API 使得这变得微不足道。

在该演示中,你会发现一些你可以扩展的例子。你只需要找到语言泡的路径,然后调整一些变量来控制边框厚度以及箭头的尺寸/位置。

动画虚线

在结束之前再讲最后一个。这次我们将重点关注虚线边框以创建更多动画。我们在按钮集合中已经做了一个,我们将虚线边框转换为实线边框。让我们再解决另外两个。

将鼠标悬停在下面,看看我们得到的效果。

那些使用过 SVG 一段时间的人可能熟悉我们通过动画 stroke-dasharray 实现的排序效果。Chris 甚至在一段时间前解决了这个概念。感谢 Paint API,我们可以在 CSS 中直接执行此操作。这个想法与我们在 SVG 中使用的几乎相同。我们定义了虚线变量。

--dash: var(--a),1000;

变量 --a0 开始,因此我们的模式是一条实线(长度等于 0)和一个间隙(长度为 1000);因此没有边框。我们将 --a 动画化为一个较大的值以绘制我们的边框。

我们还讨论了使用 lineDashOffset,我们可以将其用于另一种动画。将鼠标悬停在下面并查看结果。

最后,一个 CSS 解决方案来动画化虚线的位置,适用于任何类型的形状!

我所做的非常简单。我添加了一个额外的变量 --offset,我为其应用从 0N 的过渡。然后,在 paint() 函数内部,我执行以下操作。

const o = properties.get('--offset');
ctx.lineDashOffset=o;

就这么简单!不要忘记使用关键帧进行无限动画。

我们可以使动画持续运行,方法是从 0 偏移到 N,其中 N 是虚线变量中使用的值的总和(在本例中为 10+15=25)。我们使用负值来获得相反的方向。

我可能错过了很多用例,让您去发现!


探索 CSS Paint API 系列