UNPKG

chrome-devtools-frontend

Version:
234 lines (196 loc) 7.38 kB
// 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; } }