chrome-devtools-frontend
Version:
Chrome DevTools UI
234 lines (196 loc) • 7.38 kB
text/typescript
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as CodeMirror from '../../../../third_party/codemirror.next/codemirror.next.js';
const cssParser = CodeMirror.css.cssLanguage.parser;
export interface Point {
input: number;
output: number;
}
interface LinearStop {
number: number;
lengthA?: number;
lengthB?: number;
}
const numberFormatter = new Intl.NumberFormat('en', {
maximumFractionDigits: 2,
});
function findNextDefinedInputIndex(points: Point[], currentIndex: number): number {
for (let i = currentIndex; i < points.length; i++) {
if (!isNaN(points[i].input)) {
return i;
}
}
return -1;
}
function consumeLinearStop(cursor: CodeMirror.TreeCursor, referenceText: string): LinearStop|null {
const tokens = [];
while (cursor.type.name !== ',' && cursor.type.name !== ')') {
const token = referenceText.substring(cursor.from, cursor.to);
if (cursor.type.name !== 'NumberLiteral') {
// There is something that is not a number inside the argument.
return null;
}
tokens.push(token);
cursor.next(false);
}
// Invalid syntax `linear(0 50% 60% 40%, 1)`.
if (tokens.length > 3) {
return null;
}
const percentages = tokens.filter(token => token.includes('%'));
// There can't be more than 2 percentages.
if (percentages.length > 2) {
return null;
}
const numbers = tokens.filter(token => !token.includes('%'));
// There must only be 1 number.
if (numbers.length !== 1) {
return null;
}
return {
number: Number(numbers[0]),
lengthA: percentages[0] ? Number(percentages[0].substring(0, percentages[0].length - 1)) : undefined,
lengthB: percentages[1] ? Number(percentages[1].substring(0, percentages[1].length - 1)) : undefined,
};
}
function consumeLinearFunction(text: string): LinearStop[]|null {
const textToParse = `*{--a: ${text}}`;
const parsed = cssParser.parse(textToParse);
// Take the cursor from declaration
const cursor = parsed.cursorAt(textToParse.indexOf(':') + 1);
// Move until the `ArgList`
while (cursor.name !== 'ArgList' && cursor.next(true)) {
// If the callee is not the `linear` function, return null
if (cursor.name === 'Callee' && textToParse.substring(cursor.from, cursor.to) !== 'linear') {
return null;
}
}
if (cursor.name !== 'ArgList') {
return null;
}
// We're on the `ArgList`, enter into it
cursor.firstChild();
const stops = [];
while (cursor.type.name !== ')' && cursor.next(false)) {
const linearStop = consumeLinearStop(cursor, textToParse);
if (!linearStop) {
// Parsing a `linearStop` was invalid; abort.
return null;
}
stops.push(linearStop);
}
return stops;
}
const KeywordToValue: Record<string, string> = {
linear: 'linear(0 0%, 1 100%)',
};
export class CSSLinearEasingModel {
#points: Point[];
constructor(points: Point[]) {
this.#points = points;
}
// https://w3c.github.io/csswg-drafts/css-easing/#linear-easing-function-parsing
static parse(text: string): CSSLinearEasingModel|null {
// Parse `linear` keyword as `linear(0 0%, 1 100%)` function.
if (KeywordToValue[text]) {
return CSSLinearEasingModel.parse(KeywordToValue[text]);
}
const stops = consumeLinearFunction(text);
// 1. Let function be a new linear easing function.
// 2. Let largestInput be negative infinity.
// 3. If there are less than two items in stopList, then return failure.
if (!stops || stops.length < 2) {
return null;
}
// 4. For each stop in stopList:
let largestInput = -Infinity;
const points: Point[] = [];
for (let i = 0; i < stops.length; i++) {
const stop = stops[i];
// 4.1. Let point be a new linear easing point with its output set
// to stop’s <number> as a number.
const point: Point = {input: NaN, output: stop.number};
// 4.2. Append point to function’s points.
points.push(point);
// 4.3. If stop has a <linear-stop-length>, then:
if (stop.lengthA !== undefined) {
// 4.3.1. Set point’s input to whichever is greater:
// stop’s <linear-stop-length>'s first <percentage> as a number,
// or largestInput.
point.input = Math.max(stop.lengthA, largestInput);
// 4.3.2. Set largestInput to point’s input.
largestInput = point.input;
// 4.3.3. If stop’s <linear-stop-length> has a second <percentage>, then:
if (stop.lengthB !== undefined) {
// 4.3.3.1. Let extraPoint be a new linear easing point with its output
// set to stop’s <number> as a number.
const extraPoint: Point = {input: NaN, output: point.output};
// 4.3.3.2. Append extraPoint to function’s points.
points.push(extraPoint);
// 4.3.3.3. Set extraPoint’s input to whichever is greater:
// stop’s <linear-stop-length>'s second <percentage>
// as a number, or largestInput.
extraPoint.input = Math.max(stop.lengthB, largestInput);
// 4.3.3.4. Set largestInput to extraPoint’s input.
largestInput = extraPoint.input;
}
// 4.4. Otherwise, if stop is the first item in stopList, then:
} else if (i === 0) {
// 4.4.1. Set point’s input to 0.
point.input = 0;
// 4.4.2. Set largestInput to 0.
largestInput = 0;
// 4.5. Otherwise, if stop is the last item in stopList,
// then set point’s input to whichever is greater: 1 or largestInput.
} else if (i === stops.length - 1) {
point.input = Math.max(100, largestInput);
}
}
// 5. For runs of items in function’s points that have a null input, assign a
// number to the input by linearly interpolating between the closest previous
// and next points that have a non-null input.
let upperIndex = 0;
for (let i = 1; i < points.length; i++) {
if (isNaN(points[i].input)) {
if (i > upperIndex) {
// Since the last point's input is always defined
// we know that `upperIndex` cannot be `-1`.
upperIndex = findNextDefinedInputIndex(points, i);
}
points[i].input =
points[i - 1].input + (points[upperIndex].input - points[i - 1].input) / (upperIndex - (i - 1));
}
}
return new CSSLinearEasingModel(points);
}
addPoint(point: Point, index?: number): void {
if (index !== undefined) {
this.#points.splice(index, 0, point);
return;
}
this.#points.push(point);
}
removePoint(index: number): void {
this.#points.splice(index, 1);
}
setPoint(index: number, point: Point): void {
this.#points[index] = point;
}
points(): Point[] {
return this.#points;
}
asCSSText(): string {
const args =
this.#points.map(point => `${numberFormatter.format(point.output)} ${numberFormatter.format(point.input)}%`)
.join(', ');
const text = `linear(${args})`;
// If a keyword matches to this function, return the keyword value of it.
for (const [keyword, value] of Object.entries(KeywordToValue)) {
if (value === text) {
return keyword;
}
}
return text;
}
}