Erkundung der CSS Paint API: Polygon Rand

Avatar of Temani Afif
Temani Afif am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Heutzutage ist das Erstellen komplexer Formen mit clip-path eine einfache Aufgabe, aber das Hinzufügen eines Rands zu den Formen ist immer eine Qual. Es gibt keine robuste CSS-Lösung und wir müssen für jeden speziellen Fall immer spezifischen „hacky“ Code erstellen. In diesem Artikel zeige ich Ihnen, wie Sie dieses Problem mit der CSS Paint API lösen.

Erkundung der CSS Paint API-Reihe


Bevor wir uns in dieses dritte Experiment vertiefen, hier ist ein kurzer Überblick darüber, was wir bauen. Und beachten Sie bitte, dass alles, was wir hier tun, nur in Chromium-basierten Browsern unterstützt wird. Sie möchten sich die Demos also in Chrome, Edge oder Opera ansehen. Siehe caniuse für die aktuellste Unterstützung.

Live-Demo

Hier finden Sie keinen komplexen CSS-Code, sondern eher einen generischen Code, bei dem wir nur ein paar Variablen anpassen, um die Form zu steuern.

Die Hauptidee

Um den Polygon-Rand zu erzielen, werde ich mich auf eine Kombination der CSS-Eigenschaft clip-path und einer benutzerdefinierten Maske stützen, die mit der Paint API erstellt wurde.

Live-Demo
  1. Wir beginnen mit einer einfachen rechteckigen Form.
  2. Wir wenden clip-path an, um unsere Polygonform zu erhalten.
  3. Wir wenden die benutzerdefinierte Maske an, um unseren Polygon-Rand zu erhalten

Das CSS-Setup

Hier ist der CSS-Code für den clip-path-Schritt, zu dem wir kommen werden

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

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

Bis hierher nichts Komplexes, aber beachten Sie die Verwendung der CSS-Variable --path. Der gesamte Trick beruht auf dieser einen Variable. Da ich einen clip-path und eine mask verwende, müssen beide dieselben Parameter verwenden, daher die Variable --path. Und ja, die Paint API wird dieselbe Variable verwenden, um die benutzerdefinierte Maske zu erstellen.

Der CSS-Code für den gesamten Prozess lautet

.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)
}

Zusätzlich zu clip-path wenden wir die benutzerdefinierte Maske an und fügen eine zusätzliche Variable, --border, hinzu, um die Dicke des Rands zu steuern. Wie Sie sehen, ist alles bisher noch ziemlich grundlegendes und generisches CSS. Das ist schließlich eines der Dinge, die die CSS Paint API so großartig machen.

Das JavaScript-Setup

Ich empfehle dringend, den ersten Teil meines vorherigen Artikels zu lesen, um die Struktur der Paint API zu verstehen.

Nun wollen wir sehen, was in der paint()-Funktion passiert, wenn wir zu JavaScript wechseln

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();

Die Möglichkeit, CSS-Custom-Properties zu erhalten und zu setzen, ist einer der Gründe, warum sie so großartig sind. Wir können mit JavaScript zuerst den Wert der Variable --path auslesen und ihn dann in ein Array von Punkten umwandeln (siehe ganz oben). Das bedeutet also, dass 50% 0,100% 100%,0 100% zu den Punkten für die Maske werden, d.h. points = ["50% 0","100% 100%","0 100%"].

Dann durchlaufen wir die Punkte, um ein Polygon mit moveTo und lineTo zu zeichnen. Dieses Polygon ist exakt dasselbe wie das, das in CSS mit der clip-path-Eigenschaft gezeichnet wurde.

Schließlich, und nachdem die Form gezeichnet ist, füge ich einen Strich hinzu. Ich definiere die Dicke des Strichs mit lineWidth und setze eine Vollfarbe mit strokeStyle. Mit anderen Worten, nur der Strich der Form ist sichtbar, da ich die Form nicht mit einer Farbe fülle (d.h. sie ist transparent).

Nun müssen wir nur noch den Pfad und die Dicke anpassen, um beliebige Polygonränder zu erstellen. Es ist erwähnenswert, dass wir hier nicht auf Vollfarben beschränkt sind, da wir die CSS-Eigenschaft background verwenden. Wir können Verläufe oder Bilder in Betracht ziehen.

Live-Demo

Falls wir Inhalte hinzufügen müssen, müssen wir ein Pseudoelement berücksichtigen. Andernfalls werden die Inhalte im Prozess abgeschnitten. Es ist nicht unglaublich schwierig, Inhalte zu unterstützen. Wir verschieben die mask-Eigenschaft auf das Pseudoelement. Die clip-path-Deklaration können wir am Hauptelement belassen.

Fragen bisher?

Ich weiß, dass Sie wahrscheinlich einige brennende Fragen haben, die Sie nach Durchsicht des letzten Skripts stellen möchten. Lassen Sie mich präventiv ein paar Dinge beantworten, die Sie sich wahrscheinlich fragen.

Was ist diese cc()-Funktion?

Ich verwende diese Funktion, um den Wert jedes Punktes in Pixelwerte umzuwandeln. Für jeden Punkt hole ich mir sowohl die x- als auch die y-Koordinaten – mittels points[i].trim().split(" ") – und wandle dann diese Koordinaten um, um sie innerhalb des Canvas-Elements nutzbar zu machen, das es uns ermöglicht, mit diesen Punkten zu zeichnen.

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];
}

Die Logik ist einfach: Wenn es sich um einen Prozentwert handelt, verwende ich die Breite (oder die Höhe), um den endgültigen Wert zu ermitteln. Wenn es sich um einen Pixelwert handelt, nehme ich einfach den Wert ohne Einheit. Wenn wir zum Beispiel [50% 20%] haben, wobei die Breite gleich 200px und die Höhe gleich 100px ist, erhalten wir [100 20]. Wenn es [20px 50px] ist, erhalten wir [20 50]. Und so weiter.

Warum verwenden Sie CSS clip-path, wenn die Maske das Element bereits auf den Rand der Form zuschneidet?

Die Verwendung nur der Maske war die erste Idee, die mir in den Sinn kam, aber ich stieß auf zwei Hauptprobleme mit diesem Ansatz. Das erste betrifft die Funktionsweise von stroke(). Laut MDN

Striche werden am Mittelpunkt eines Pfades ausgerichtet; mit anderen Worten, die Hälfte des Strichs wird nach innen und die Hälfte nach außen gezeichnet.

Diese „halbe Innen- und halbe Außenseite“ bereitete mir viele Kopfschmerzen, und ich hatte immer einen seltsamen Überlauf, wenn ich alles zusammenfügte. Hier hilft CSS clip-path; es schneidet den äußeren Teil ab und behält nur die Innenseite – kein Überlauf mehr!

Sie werden die Verwendung von ctx.lineWidth = 2*b bemerken. Ich addiere die doppelte Randdicke, da ich die Hälfte davon abschneiden werde, um die richtige Dicke um die gesamte Form herum zu erhalten.

Das zweite Problem betrifft den hover-baren Bereich der Form. Es ist bekannt, dass Maskierung diesen Bereich nicht beeinflusst und wir immer noch den gesamten Rechteckbereich hoovern/interagieren können. Wiederum löst die Verwendung von clip-path das Problem, und wir beschränken die Interaktion nur auf die Form selbst.

Die folgende Demo veranschaulicht diese beiden Probleme. Das erste Element hat sowohl eine Maske als auch einen Clip-Pfad, während das zweite nur die Maske hat. Wir können das Überlaufproblem deutlich erkennen. Versuchen Sie, über das zweite Element zu hoovern, um zu sehen, dass wir die Farbe ändern können, auch wenn sich der Mauszeiger außerhalb des Dreiecks befindet.

Warum verwenden Sie @property mit dem Border-Wert?

Dies ist ein interessanter – und ziemlich kniffliger – Teil. Standardmäßig werden benutzerdefinierte Eigenschaften (wie --border) als „CSSUnparsedValue“ betrachtet, was bedeutet, dass sie als Strings behandelt werden. Aus der CSS-Spezifikation

CSSUnparsedValue“-Objekte stellen Eigenschaftswerte dar, die auf benutzerdefinierte Eigenschaften verweisen. Sie bestehen aus einer Liste von Stringfragmenten und Variablensubstitutionen.

Mit @property können wir die benutzerdefinierte Eigenschaft registrieren und ihr einen Typ zuweisen, damit sie vom Browser erkannt und als gültiger Typ anstelle eines Strings behandelt wird. In unserem Fall registrieren wir den Rand als Typ <length>, damit er später zu einem CSSUnitValue wird. Dies ermöglicht es uns auch, jede Längeneinheit (px, em, ch, vh usw.) für den Randwert zu verwenden.

Das mag etwas komplex klingen, aber lassen Sie mich den Unterschied mit einem Screenshot der Entwicklertools veranschaulichen.

Ich verwende console.log() für eine Variable, in der ich 5em definiert habe. Die erste wurde registriert, die zweite nicht.

Im ersten Fall erkennt der Browser den Typ und wandelt ihn in einen Pixelwert um, was nützlich ist, da wir nur Pixelwerte innerhalb der paint()-Funktion benötigen. Im zweiten Fall erhalten wir die Variable als String, was nicht sehr nützlich ist, da wir em-Einheiten nicht in px-Einheiten innerhalb der paint()-Funktion umwandeln können.

Probieren Sie alle Einheiten aus. Es führt immer zum berechneten Pixelwert innerhalb der paint()-Funktion.

Was ist mit der --path-Variable?

Ich wollte denselben Ansatz mit der --path-Variable verwenden, aber leider denke ich, dass ich CSS damit bis an die Grenzen dessen ausgereizt habe, was es hier leisten kann. Mit @property können wir komplexe Typen registrieren, sogar mehrwertige Variablen. Aber das reicht immer noch nicht für den Pfad, den wir brauchen.

Wir können die Symbole + und # verwenden, um eine durch Leerzeichen oder Kommas getrennte Liste von Werten zu definieren, aber unser Pfad ist eine durch Kommas getrennte Liste von durch Leerzeichen getrennten Prozent- (oder Längen-)Werten. Ich würde etwas wie [<length-percentage>+]# verwenden, aber das existiert nicht.

Für den Pfad bin ich gezwungen, ihn als Zeichenkette zu manipulieren. Das beschränkt uns vorerst nur auf Prozent- und Pixelwerte. Aus diesem Grund habe ich die Funktion cc() definiert, um die Zeichenkettenwerte in Pixelwerte umzuwandeln.

Wir können in der CSS-Spezifikation lesen

Die interne Grammatik der Syntax-Strings ist eine Teilmenge von der CSS Value Definition Syntax. Zukünftige Versionen von der Spezifikation werden voraussichtlich die Komplexität der erlaubten Grammatik erweitern und benutzerdefinierte Eigenschaften zulassen, die dem vollen Umfang dessen, was CSS-Eigenschaften zulassen, stärker ähneln.

Selbst wenn die Grammatik erweitert wird, um den Pfad registrieren zu können, werden wir immer noch auf Probleme stoßen, wenn wir calc() in unseren Pfad aufnehmen müssen

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

Im obigen Beispiel ist calc(100% - 40px) ein Wert, den der Browser als <length-percentage> betrachtet, aber der Browser kann diesen Wert erst berechnen, wenn er die Referenz für den Prozentsatz kennt. Mit anderen Worten, wir können den entsprechenden Pixelwert nicht innerhalb der paint()-Funktion erhalten, da die Referenz erst bekannt ist, wenn der Wert innerhalb von var() verwendet wird.

Um dies zu überwinden, können wir die Funktion cc() erweitern, um die Umwandlung durchzuführen. Wir haben die Umwandlung eines Prozentwerts und eines Pixelwerts durchgeführt, also lassen Sie uns diese in einer einzigen Umwandlung kombinieren. Wir werden 2 Fälle betrachten: calc(P% - Xpx) und calc(P% + Xpx). Unser Skript wird

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];
}

Wir verwenden indexOf(), um die Existenz von calc zu prüfen, extrahieren dann mit etwas String-Manipulation beide Werte und ermitteln den endgültigen Pixelwert.

Und als Ergebnis müssen wir auch diese Zeile aktualisieren

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

...zu

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

Da wir calc() berücksichtigen müssen, funktioniert die Verwendung des Leerzeichens zum Aufteilen nicht. Das liegt daran, dass calc() auch Leerzeichen enthält. Wir benötigen also einen Regex. Fragen Sie mich nicht danach – es ist derjenige, der nach vielen Versuchen von Stack Overflow funktioniert hat.

Hier ist eine einfache Demo, die die bisher vorgenommene Aktualisierung zur Unterstützung von calc() veranschaulicht

Beachten Sie, dass wir den Ausdruck calc() innerhalb der Variable --v gespeichert haben, die wir als <length-percentage> registriert haben. Das ist auch Teil des Tricks, denn wenn wir das tun, verwendet der Browser das richtige Format. Unabhängig von der Komplexität des calc()-Ausdrucks wandelt der Browser ihn immer in das Format calc(P% +/- Xpx) um. Aus diesem Grund müssen wir uns nur mit diesem Format innerhalb der paint()-Funktion befassen.

Unten sind verschiedene Beispiele, bei denen wir einen anderen calc()-Ausdruck für jeden verwenden

Wenn Sie den Code jeder Box inspizieren und den berechneten Wert von --v sehen, finden Sie immer dasselbe Format, was super nützlich ist, da wir beliebige Berechnungen durchführen können.

Es ist erwähnenswert, dass die Verwendung der Variablen --v nicht zwingend erforderlich ist. Wir können calc() direkt in den Pfad einfügen. Wir müssen nur sicherstellen, dass wir das richtige Format einfügen, da der Browser es nicht für uns handhabt (denken Sie daran, dass wir die Pfadvariable nicht registrieren können, also ist es für den Browser ein String). Dies kann nützlich sein, wenn wir viele calc()s im Pfad haben müssen und das Erstellen einer Variablen für jede einzelne den Code zu langwierig machen würde. Einige Beispiele sehen wir am Ende.

Können wir einen gestrichelten Rand haben?

Das können wir! Und es erfordert nur eine Anweisung. Das <canvas>-Element hat bereits eine integrierte Funktion zum Zeichnen von gestrichelten Linien: setLineDash()

Die Methode setLineDash() der Schnittstelle CanvasRenderingContext2D des Canvas 2D API legt das Linienstichmuster fest, das beim Zeichnen von Linien verwendet wird. Sie verwendet ein Array von Werten, die abwechselnde Längen von Linien und Lücken angeben, die das Muster beschreiben.

Wir müssen nur eine weitere Variable einführen, um unser Stichmuster zu definieren.

Live-Demo

Im CSS haben wir einfach eine CSS-Variable, --dash, hinzugefügt, und innerhalb der Maske ist Folgendes zu sehen

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

Wir können auch den Versatz mit lineDashOffset steuern. Wir werden später sehen, wie die Steuerung des Versatzes uns helfen kann, einige coole Animationen zu erreichen.

Warum nicht @property verwenden, um die Dash-Variable zu registrieren?

Technisch gesehen können wir die Dash-Variable als <length># registrieren, da es sich um eine durch Kommas getrennte Liste von Längenwerten handelt. Es funktioniert, *aber* ich konnte die Werte nicht innerhalb der paint()-Funktion abrufen. Ich weiß nicht, ob es ein Bug ist, ein Mangel an Unterstützung, oder ob mir einfach ein Puzzleteil fehlt.

Hier ist eine Demo, die das Problem veranschaulicht

Ich registriere die --dash-Variable mit diesem

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

…und deklariere später die Variable so

--dash: 10em,3em;

Wenn wir das Element inspizieren, können wir sehen, dass der Browser die Variable korrekt behandelt, da die berechneten Werte Pixelwerte sind

Aber wir erhalten nur den ersten Wert innerhalb der paint()-Funktion

Bis ich eine Lösung dafür gefunden habe, bleibe ich dabei, die --dash-Variable als String zu verwenden, wie --path. Kein großes Problem in diesem Fall, da ich nicht glaube, dass wir mehr als Pixelwerte benötigen werden.

Anwendungsfälle!

Nachdem wir die Hintergründe dieser Technik beleuchtet haben, konzentrieren wir uns nun auf den CSS-Teil und betrachten einige Anwendungsfälle für unseren Polygon-Rand.

Eine Sammlung von Buttons

Wir können leicht benutzerdefinierte Button-Formen mit coolen Hover-Effekten erstellen.

Beachten Sie, wie calc() im Pfad des letzten Buttons verwendet wird, so wie wir es zuvor beschrieben haben. Es funktioniert einwandfrei, da ich das richtige Format befolge.

Breadcrumbs

Keine Kopfschmerzen mehr bei der Erstellung eines Breadcrumb-Systems! Unten finden Sie keinen „hacky“ oder komplexen CSS-Code, sondern etwas, das ziemlich generisch und leicht verständlich ist, wo wir nur ein paar Variablen anpassen müssen.

Karten-Reveal-Animation

Wenn wir die Dicke animieren, können wir einige schicke Hover-Effekte erzielen

Wir können diese gleiche Idee nutzen, um eine Animation zu erstellen, die die Karte enthüllt

Callout & Sprechblase

„Wie zum Teufel fügen wir dieser kleinen Pfeil einen Rand hinzu???“ Ich denke, jeder ist auf dieses Problem gestoßen, wenn er mit einem Callout oder einer Sprechblase zu tun hatte. Die Paint API macht dies trivial.

In dieser Demo finden Sie einige Beispiele, die Sie erweitern können. Sie müssen nur den Pfad für Ihre Sprechblase finden und dann einige Variablen anpassen, um die Randdicke und die Größe/Position des Pfeils zu steuern.

Animierende Striche

Ein letzter Punkt, bevor wir enden. Dieses Mal konzentrieren wir uns auf den gestrichelten Rand, um weitere Animationen zu erstellen. Einen haben wir bereits in der Button-Sammlung gemacht, wo wir einen gestrichelten Rand in einen durchgezogenen verwandelt haben. Lassen Sie uns zwei weitere angehen.

Hovern Sie über das Folgende und sehen Sie den schönen Effekt, den wir erzielen

Diejenigen, die schon länger mit SVG arbeiten, sind wahrscheinlich mit dem Sortier-Effekt vertraut, den wir durch Animation von stroke-dasharray erzielen. Chris hat sich dem Konzept sogar vor einiger Zeit angenommen. Dank der Paint API können wir dies direkt in CSS tun. Die Idee ist fast dieselbe wie bei SVG. Wir definieren die Stichvariable

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

Die Variable --a beginnt bei 0, also ist unser Muster eine durchgezogene Linie (deren Länge 0 beträgt) mit einer Lücke (deren Länge 1000); daher kein Rand. Wir animieren --a zu einem großen Wert, um unseren Rand zu zeichnen.

Wir haben auch über die Verwendung von lineDashOffset gesprochen, die wir für eine andere Art von Animation nutzen können. Hovern Sie über das Folgende und sehen Sie das Ergebnis

Endlich eine CSS-Lösung, um die Position von Strichen zu animieren, die mit jeder Art von Form funktioniert!

Was ich getan habe, ist ziemlich einfach. Ich habe eine zusätzliche Variable, --offset, hinzugefügt, auf die ich eine Übergangszeit von 0 bis N anwende. Dann mache ich innerhalb der paint()-Funktion Folgendes

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

So einfach ist das! Vergessen wir nicht eine unendliche Animation mit Keyframes

Wir können die Animation kontinuierlich laufen lassen, indem wir 0 bis N versetzen, wobei N die Summe der Werte ist, die in der Stichvariable verwendet werden (in unserem Fall 10+15=25). Wir verwenden einen negativen Wert für die entgegengesetzte Richtung.

Ich habe wahrscheinlich viele Anwendungsfälle übersehen, die ich Sie entdecken lasse!


Erkundung der CSS Paint API-Reihe