LinearEasingFunctionWidget.js (22972B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 /** 8 * This is a chart-like editor for linear() easing function, used in the Rules View. 9 */ 10 11 const EventEmitter = require("devtools/shared/event-emitter"); 12 const { InspectorCSSParserWrapper } = require("devtools/shared/css/lexer"); 13 const { throttle } = require("devtools/shared/throttle"); 14 const { 15 canPointerEventDrag, 16 } = require("resource://devtools/client/shared/events.js"); 17 const XHTML_NS = "http://www.w3.org/1999/xhtml"; 18 const SVG_NS = "http://www.w3.org/2000/svg"; 19 20 const numberFormatter = new Intl.NumberFormat("en", { 21 maximumFractionDigits: 3, 22 }); 23 const percentFormatter = new Intl.NumberFormat("en", { 24 maximumFractionDigits: 2, 25 style: "percent", 26 }); 27 28 /** 29 * Easing function widget. Draw the lines and control points in an svg. 30 * 31 * XXX: The spec allows input and output values in the [-Infinity,Infinity] range, 32 * but this will be hard to have proper visual representation to handle those cases, so we 33 * only handle points inside [0,0] [1,1] to represent most common use cases (even though 34 * the line will properly link points outside of this range) 35 * 36 * @fires "updated" events whenever the line is changed, with the updated property value. 37 */ 38 class LinearEasingFunctionWidget extends EventEmitter { 39 /** 40 * @param {DOMNode} parent The container where the widget should be created 41 */ 42 constructor(parent) { 43 super(); 44 45 this.parent = parent; 46 this.#initMarkup(); 47 48 this.#svgEl.addEventListener( 49 "pointerdown", 50 this.#onPointerDown.bind(this), 51 { 52 signal: this.#abortController.signal, 53 passive: true, 54 } 55 ); 56 this.#svgEl.addEventListener("dblclick", this.#onDoubleClick.bind(this), { 57 signal: this.#abortController.signal, 58 }); 59 60 // Add the timing function previewer 61 // if prefers-reduced-motion is not set 62 this.#reducedMotion = parent.ownerGlobal.matchMedia( 63 "(prefers-reduced-motion)" 64 ); 65 if (!this.#reducedMotion.matches) { 66 this.#timingPreview = new TimingFunctionPreviewWidget(this.#wrapperEl); 67 } 68 69 // add event listener to change prefers-reduced-motion 70 // of the timing function preview during runtime 71 this.#reducedMotion.addEventListener( 72 "change", 73 event => { 74 // if prefers-reduced-motion is enabled destroy timing function preview 75 // else create it if it does not exist 76 if (event.matches) { 77 if (this.#timingPreview) { 78 this.#timingPreview.destroy(); 79 } 80 this.#timingPreview = undefined; 81 } else if (!this.#timingPreview) { 82 this.#timingPreview = new TimingFunctionPreviewWidget( 83 this.#wrapperEl 84 ); 85 } 86 }, 87 { signal: this.#abortController.signal } 88 ); 89 } 90 91 static CONTROL_POINTS_CLASSNAME = "control-point"; 92 93 // Handles event listener that are enabled for the whole widget lifetime 94 #abortController = new AbortController(); 95 96 // Array<Object>: Object has `input` (plotted on x axis) and `output` (plotted on y axis) properties 97 #functionPoints; 98 99 // MediaQueryList 100 #reducedMotion; 101 102 // TimingFunctionPreviewWidget 103 #timingPreview; 104 105 // current dragged element. null if there's no dragging happening 106 #draggedEl = null; 107 108 // handles event listeners added when user starts dragging an element 109 #dragAbortController; 110 111 // element references 112 #wrapperEl; 113 #svgEl; 114 #linearLineEl; 115 #controlPointGroupEl; 116 117 /** 118 * Creates the markup of the widget 119 */ 120 #initMarkup() { 121 const doc = this.parent.ownerDocument; 122 123 const wrap = doc.createElementNS(XHTML_NS, "div"); 124 wrap.className = "display-wrap"; 125 this.#wrapperEl = wrap; 126 127 const svg = doc.createElementNS(SVG_NS, "svg"); 128 svg.classList.add("chart"); 129 130 // Add some "padding" to the viewBox so circles near the edges are not clipped. 131 const padding = 0.1; 132 const length = 1 + padding * 2; 133 // XXX: The spec allows input and output values in the [-Infinity,Infinity] range, 134 // but this will be hard to have proper visual representation for all cases, so we 135 // set the viewBox is basically starting at 0,0 and has a size of 1 (if we don't take the 136 // padding into account), to represent most common use cases. 137 svg.setAttribute( 138 "viewBox", 139 `${0 - padding} ${0 - padding} ${length} ${length}` 140 ); 141 142 // Create a background grid 143 const chartGrid = doc.createElementNS(SVG_NS, "g"); 144 chartGrid.setAttribute("stroke-width", "0.005"); 145 chartGrid.classList.add("chart-grid"); 146 for (let i = 0; i <= 10; i++) { 147 const value = i / 10; 148 const hLine = doc.createElementNS(SVG_NS, "line"); 149 hLine.setAttribute("x1", 0); 150 hLine.setAttribute("y1", value); 151 hLine.setAttribute("x2", 1); 152 hLine.setAttribute("y2", value); 153 const vLine = doc.createElementNS(SVG_NS, "line"); 154 vLine.setAttribute("x1", value); 155 vLine.setAttribute("y1", 0); 156 vLine.setAttribute("x2", value); 157 vLine.setAttribute("y2", 1); 158 chartGrid.append(hLine, vLine); 159 } 160 161 // Create the actual graph line 162 const linearLine = doc.createElementNS(SVG_NS, "polyline"); 163 linearLine.classList.add("chart-linear"); 164 linearLine.setAttribute("fill", "none"); 165 linearLine.setAttribute("stroke", "context-stroke black"); 166 linearLine.setAttribute("stroke-width", "0.01"); 167 168 // And a group for all the control points 169 const controlPointGroup = doc.createElementNS(SVG_NS, "g"); 170 controlPointGroup.classList.add("control-points-group"); 171 172 this.#linearLineEl = linearLine; 173 this.#svgEl = svg; 174 this.#controlPointGroupEl = controlPointGroup; 175 176 svg.append(chartGrid, linearLine, controlPointGroup); 177 wrap.append(svg); 178 this.parent.append(wrap); 179 } 180 181 /** 182 * Remove widget markup, called on destroy 183 */ 184 #removeMarkup() { 185 this.#wrapperEl.remove(); 186 } 187 188 /** 189 * Handle pointerdown event on the svg 190 * 191 * @param {PointerEvent} event 192 */ 193 #onPointerDown(event) { 194 if ( 195 !canPointerEventDrag(event) || 196 !event.target.classList.contains( 197 LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME 198 ) 199 ) { 200 return; 201 } 202 203 this.#draggedEl = event.target; 204 this.#draggedEl.setPointerCapture(event.pointerId); 205 206 this.#dragAbortController = new AbortController(); 207 // Note that "pointermove" is also fired when the button state is changed. 208 // Therefore, we should listen to "mousemove". 209 this.#draggedEl.addEventListener( 210 "mousemove", 211 this.#onMouseMove.bind(this), 212 { signal: this.#dragAbortController.signal } 213 ); 214 this.#draggedEl.addEventListener( 215 "pointerup", 216 this.#onPointerUp.bind(this), 217 { 218 signal: this.#dragAbortController.signal, 219 } 220 ); 221 } 222 223 /** 224 * Handle mousemove event on a control point. Only active when there's a control point 225 * being dragged. 226 * 227 * @param {MouseEvent} event 228 */ 229 #onMouseMove = throttle(event => { 230 if (!this.#draggedEl) { 231 return; 232 } 233 234 const { x, y } = this.#getPositionInSvgFromEvent(event); 235 236 // XXX: The spec allows input and output values in the [-Infinity,Infinity] range, 237 // but this will be hard to have proper visual representation for all cases, so we 238 // clamp x and y between 0 and 1 as it's more likely the range that will be used. 239 let cx = clamp(0, 1, x); 240 let cy = clamp(0, 1, y); 241 242 if (this.#draggedEl.previousSibling) { 243 // We don't allow moving the point before the previous point 244 cx = Math.max( 245 cx, 246 parseFloat(this.#draggedEl.previousSibling.getAttribute("cx")) 247 ); 248 } 249 if (this.#draggedEl.nextSibling) { 250 // We don't allow moving the point after the next point 251 cx = Math.min( 252 cx, 253 parseFloat(this.#draggedEl.nextSibling.getAttribute("cx")) 254 ); 255 } 256 257 // Enable "Snap to grid" when the user holds the shift key 258 if (event.shiftKey) { 259 cx = Math.round(cx * 10) / 10; 260 cy = Math.round(cy * 10) / 10; 261 } 262 263 this.#draggedEl.setAttribute("cx", cx); 264 this.#draggedEl.setAttribute("cy", cy); 265 266 this.#updateFunctionPointsFromControlPoints(); 267 this.#redrawLineFromFunctionPoints(); 268 this.emit("updated", this.getCssLinearValue()); 269 }, 20); 270 271 /** 272 * Handle pointerup event on a control point. Only active when there's a control point 273 * being dragged. 274 */ 275 #onPointerUp() { 276 this.#draggedEl = null; 277 this.#dragAbortController.abort(); 278 this.#dragAbortController = null; 279 } 280 281 /** 282 * Handle dblclick event on the svg. 283 * If the target is a control point, this will remove it, otherwise this will add 284 * a new control point at the clicked position. 285 * 286 * @param {MouseEvent} event 287 */ 288 #onDoubleClick(event) { 289 const existingPoints = Array.from( 290 this.#controlPointGroupEl.querySelectorAll( 291 `.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}` 292 ) 293 ); 294 295 if ( 296 event.target.classList.contains( 297 LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME 298 ) 299 ) { 300 // The function is only valid when it has at least 2 points, so don't allow to 301 // produce invalid value. 302 if (existingPoints.length <= 2) { 303 return; 304 } 305 306 event.target.remove(); 307 this.#updateFunctionPointsFromControlPoints(); 308 this.#redrawFromFunctionPoints(); 309 } else { 310 let { x, y } = this.#getPositionInSvgFromEvent(event); 311 312 // Enable "Snap to grid" when the user holds the shift key 313 if (event.shiftKey) { 314 x = clamp(0, 1, Math.round(x * 10) / 10); 315 y = clamp(0, 1, Math.round(y * 10) / 10); 316 } 317 318 // Add a control point at specified x and y in svg coords 319 // We need to loop through existing control points to insert it at the correct index. 320 const nextSibling = existingPoints.find( 321 el => parseFloat(el.getAttribute("cx")) >= x 322 ); 323 324 this.#controlPointGroupEl.insertBefore( 325 this.#createSvgControlPointEl(x, y), 326 nextSibling 327 ); 328 this.#updateFunctionPointsFromControlPoints(); 329 this.#redrawLineFromFunctionPoints(); 330 } 331 } 332 333 /** 334 * Update this.#functionPoints based on the control points in the svg 335 */ 336 #updateFunctionPointsFromControlPoints() { 337 // We ensure to order the control points based on their x position within the group, 338 // so here, we can iterate through them without any need to sort them. 339 this.#functionPoints = Array.from( 340 this.#controlPointGroupEl.querySelectorAll( 341 `.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}` 342 ) 343 ).map(el => { 344 const input = parseFloat(el.getAttribute("cx")); 345 // Since svg coords start from the top-left corner, we need to translate cy 346 // to have the actual value we want for the function. 347 const output = 1 - parseFloat(el.getAttribute("cy")); 348 349 return { 350 input, 351 output, 352 }; 353 }); 354 } 355 356 /** 357 * Redraw the control points and the linear() line in the svg, 358 * based on the value of this.functionPoints. 359 */ 360 #redrawFromFunctionPoints() { 361 // Remove previous control points 362 this.#controlPointGroupEl 363 .querySelectorAll( 364 `.${LinearEasingFunctionWidget.CONTROL_POINTS_CLASSNAME}` 365 ) 366 .forEach(el => el.remove()); 367 368 if (this.#functionPoints) { 369 // Add controls for each function points 370 this.#functionPoints.forEach(({ input, output }) => { 371 this.#controlPointGroupEl.append( 372 // Since svg coords start from the top-left corner, we need to translate output 373 // to properly place it on the graph. 374 this.#createSvgControlPointEl(input, 1 - output) 375 ); 376 }); 377 } 378 379 this.#redrawLineFromFunctionPoints(); 380 } 381 382 /** 383 * Redraw linear() line in the svg based on the value of this.functionPoints. 384 */ 385 #redrawLineFromFunctionPoints() { 386 // Set the line points 387 this.#linearLineEl.setAttribute( 388 "points", 389 (this.#functionPoints || []) 390 .map( 391 ({ input, output }) => 392 // Since svg coords start from the top-left corner, we need to translate output 393 // to properly place it on the graph. 394 `${input},${1 - output}` 395 ) 396 .join(" ") 397 ); 398 399 const cssLinearValue = this.getCssLinearValue(); 400 if (this.#timingPreview) { 401 this.#timingPreview.preview(cssLinearValue); 402 } 403 404 this.emit("updated", cssLinearValue); 405 } 406 407 /** 408 * Create a control points for the svg line. 409 * 410 * @param {number} cx 411 * @param {number} cy 412 * @returns {SVGCircleElement} 413 */ 414 #createSvgControlPointEl(cx, cy) { 415 const controlEl = this.parent.ownerDocument.createElementNS( 416 SVG_NS, 417 "circle" 418 ); 419 controlEl.classList.add("control-point"); 420 controlEl.setAttribute("cx", cx); 421 controlEl.setAttribute("cy", cy); 422 controlEl.setAttribute("r", 0.025); 423 controlEl.setAttribute("fill", "context-fill"); 424 controlEl.setAttribute("stroke-width", 0); 425 return controlEl; 426 } 427 428 /** 429 * Return the position in the SVG viewbox from mouse event. 430 * 431 * @param {MouseEvent} event 432 * @returns {object} An object with x and y properties 433 */ 434 #getPositionInSvgFromEvent(event) { 435 const position = this.#svgEl.createSVGPoint(); 436 position.x = event.clientX; 437 position.y = event.clientY; 438 439 const matrix = this.#svgEl.getScreenCTM(); 440 const inverseSvgMatrix = matrix.inverse(); 441 const transformedPosition = position.matrixTransform(inverseSvgMatrix); 442 443 return { x: transformedPosition.x, y: transformedPosition.y }; 444 } 445 446 /** 447 * Provide the value of the linear() function we want to visualize here. 448 * Called from the tooltip with the value of the function in the rule view. 449 * 450 * @param {string} linearFunctionValue: e.g. `linear(0, 0.5, 1)`. 451 */ 452 setCssLinearValue(linearFunctionValue) { 453 if (!linearFunctionValue) { 454 return; 455 } 456 457 // Parse the string to extract all the points 458 const points = parseTimingFunction(linearFunctionValue); 459 this.#functionPoints = points; 460 461 // And draw the line and points 462 this.#redrawFromFunctionPoints(); 463 } 464 465 /** 466 * Return the value of the linear() function based on the state of the graph. 467 * The resulting value is what we emit in the "updated" event. 468 * 469 * @return {string | null} e.g. `linear(0 0%, 0.5 50%, 1 100%)`. 470 */ 471 getCssLinearValue() { 472 if (!this.#functionPoints) { 473 return null; 474 } 475 476 return `linear(${this.#functionPoints 477 .map( 478 ({ input, output }) => 479 `${numberFormatter.format(output)} ${percentFormatter.format(input)}` 480 ) 481 .join(", ")})`; 482 } 483 484 destroy() { 485 this.#abortController.abort(); 486 this.#dragAbortController?.abort(); 487 this.#removeMarkup(); 488 this.#reducedMotion = null; 489 490 if (this.#timingPreview) { 491 this.#timingPreview.destroy(); 492 this.#timingPreview = null; 493 } 494 } 495 } 496 497 exports.LinearEasingFunctionWidget = LinearEasingFunctionWidget; 498 499 /** 500 * The TimingFunctionPreviewWidget animates a dot on a scale with a given 501 * timing-function 502 */ 503 class TimingFunctionPreviewWidget { 504 /** 505 * @param {DOMNode} parent The container where this widget should go 506 */ 507 constructor(parent) { 508 this.#initMarkup(parent); 509 } 510 511 #PREVIEW_DURATION = 1000; 512 #dotEl; 513 #previousValue; 514 515 #initMarkup(parent) { 516 const doc = parent.ownerDocument; 517 518 const container = doc.createElementNS(XHTML_NS, "div"); 519 container.className = "timing-function-preview"; 520 521 this.#dotEl = doc.createElementNS(XHTML_NS, "div"); 522 this.#dotEl.className = "dot"; 523 container.appendChild(this.#dotEl); 524 parent.appendChild(container); 525 } 526 527 destroy() { 528 this.#dotEl.getAnimations().forEach(anim => anim.cancel()); 529 this.#dotEl.parentElement.remove(); 530 } 531 532 /** 533 * Preview a new timing function. The current preview will only be stopped if 534 * the supplied function value is different from the previous one. If the 535 * supplied function is invalid, the preview will stop. 536 * 537 * @param {Array} value 538 */ 539 preview(timingFunction) { 540 if (this.#previousValue == timingFunction) { 541 return; 542 } 543 this.#restartAnimation(timingFunction); 544 this.#previousValue = timingFunction; 545 } 546 547 /** 548 * Re-start the preview animation from the beginning. 549 * 550 * @param {Array} points 551 */ 552 #restartAnimation = throttle(timingFunction => { 553 // Cancel the previous animation if there was any. 554 this.#dotEl.getAnimations().forEach(anim => anim.cancel()); 555 556 // And start the new one. 557 // The animation consists of a few keyframes that move the dot to the right of the 558 // container, and then move it back to the left. 559 // It also contains some pause where the dot is greyed-out, before it moves to 560 // the right, and once again, before it comes back to the left. 561 // The timing function passed to this function is applied to the keyframes that 562 // actually move the dot. This way it can be previewed in both direction, instead of 563 // being spread over the whole animation. 564 const grayscaleFilter = "grayscale(100%)"; 565 566 this.#dotEl.animate( 567 [ 568 { translate: "0%", filter: grayscaleFilter, offset: 0 }, 569 { translate: "0%", filter: grayscaleFilter, offset: 0.19 }, 570 { 571 translate: "0%", 572 filter: "none", 573 offset: 0.2, 574 easing: timingFunction, 575 }, 576 { translate: "100%", filter: "none", offset: 0.5 }, 577 { translate: "100%", filter: grayscaleFilter, offset: 0.51 }, 578 { translate: "100%", filter: grayscaleFilter, offset: 0.7 }, 579 { 580 translate: "100%", 581 filter: "none", 582 offset: 0.71, 583 easing: timingFunction, 584 }, 585 { translate: "0%", filter: "none", offset: 1 }, 586 ], 587 { 588 duration: this.#PREVIEW_DURATION * 2, 589 iterations: Infinity, 590 } 591 ); 592 }, 250); 593 } 594 595 /** 596 * Parse a linear() string to collect the different values. 597 * https://drafts.csswg.org/css-easing-2/#the-linear-easing-function 598 * 599 * @param {string} value 600 * @return {Array<object> | undefined} returns undefined if value isn't a valid linear() value. 601 * the items of the array are objects with {Number} `input` 602 * and {Number} `output` properties. 603 */ 604 function parseTimingFunction(value) { 605 value = value.trim(); 606 const tokenStream = new InspectorCSSParserWrapper(value); 607 const getNextToken = () => { 608 while (true) { 609 const token = tokenStream.nextToken(); 610 if ( 611 !token || 612 (token.tokenType !== "WhiteSpace" && token.tokenType !== "Comment") 613 ) { 614 return token; 615 } 616 } 617 }; 618 619 let token = getNextToken(); 620 if (!token || token.tokenType !== "Function" || token.value !== "linear") { 621 return undefined; 622 } 623 624 // Let's follow the spec parsing algorithm https://drafts.csswg.org/css-easing-2/#linear-easing-function-parsing 625 const points = []; 626 let largestInput = -Infinity; 627 628 while ((token = getNextToken())) { 629 if (token.tokenType === "CloseParenthesis") { 630 break; 631 } 632 633 if (token.tokenType === "Number") { 634 // [parsing step 4.1] 635 const point = { input: null, output: token.number }; 636 // [parsing step 4.2] 637 points.push(point); 638 639 // get nextToken to see if there's a linear stop length 640 token = getNextToken(); 641 // [parsing step 4.3] 642 if (token && token.tokenType === "Percentage") { 643 // [parsing step 4.3.1] 644 point.input = Math.max(token.number, largestInput); 645 // [parsing step 4.3.2] 646 largestInput = point.input; 647 648 // get nextToken to see if there's a second linear stop length 649 token = getNextToken(); 650 651 // [parsing step 4.3.3] 652 if (token && token.tokenType === "Percentage") { 653 // [parsing step 4.3.3.1] 654 const extraPoint = { input: null, output: point.output }; 655 // [parsing step 4.3.3.2] 656 points.push(extraPoint); 657 658 // [parsing step 4.3.3.3] 659 extraPoint.input = Math.max(token.number, largestInput); 660 // [parsing step 4.3.3.4] 661 largestInput = extraPoint.input; 662 } 663 } else if (points.length == 1) { 664 // [parsing step 4.4] 665 // [parsing step 4.4.1] 666 point.input = 0; 667 // [parsing step 4.4.2] 668 largestInput = 0; 669 } 670 } 671 } 672 673 if (points.length < 2) { 674 return undefined; 675 } 676 677 // [parsing step 4.5] 678 if (points.at(-1).input === null) { 679 points.at(-1).input = Math.max(largestInput, 1); 680 } 681 682 // [parsing step 5] 683 684 // We want to retrieve ranges ("runs" in the spec) of items with null inputs so we 685 // can compute their input using linear interpolation. 686 const nullInputPoints = []; 687 points.forEach((point, index, array) => { 688 if (point.input == null) { 689 // since the first point is guaranteed to have an non-null input, and given that 690 // we iterate through the points in regular order, we are guaranteed to find a previous 691 // non null point. 692 const previousNonNull = array.findLast( 693 (item, i) => i < index && item.input !== null 694 ).input; 695 // since the last point is guaranteed to have an non-null input, and given that 696 // we iterate through the points in regular order, we are guaranteed to find a next 697 // non null point. 698 const nextNonNull = array.find( 699 (item, i) => i > index && item.input !== null 700 ).input; 701 702 if (nullInputPoints.at(-1)?.indexes?.at(-1) == index - 1) { 703 nullInputPoints.at(-1).indexes.push(index); 704 } else { 705 nullInputPoints.push({ 706 indexes: [index], 707 previousNonNull, 708 nextNonNull, 709 }); 710 } 711 } 712 }); 713 714 // For each range of consecutive null-input indexes 715 nullInputPoints.forEach(({ indexes, previousNonNull, nextNonNull }) => { 716 // For each null-input points, compute their input by linearly interpolating between 717 // the closest previous and next points that have a non-null input. 718 indexes.forEach((index, i) => { 719 points[index].input = lerp( 720 previousNonNull, 721 nextNonNull, 722 (i + 1) / (indexes.length + 1) 723 ); 724 }); 725 }); 726 727 return points; 728 } 729 730 /** 731 * Linearly interpolate between 2 numbers. 732 * 733 * @param {number} x 734 * @param {number} y 735 * @param {number} a 736 * A value of 0 returns x, and 1 returns y 737 * @return {number} 738 */ 739 function lerp(x, y, a) { 740 return x * (1 - a) + y * a; 741 } 742 743 /** 744 * Clamp value in a range, meaning the result won't be smaller than min 745 * and no bigger than max. 746 * 747 * @param {number} min 748 * @param {number} max 749 * @param {number} value 750 * @returns {number} 751 */ 752 function clamp(min, max, value) { 753 return Math.max(min, Math.min(value, max)); 754 } 755 756 exports.parseTimingFunction = parseTimingFunction;