@rogieking/figui3 2.25.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/components.css CHANGED
@@ -1152,7 +1152,7 @@ fig-chit {
1152
1152
  background: var(--chit-background);
1153
1153
  background-size: var(--chit-bg-size);
1154
1154
  background-position: var(--chit-bg-position);
1155
- background-repeat: repeat;
1155
+ background-repeat: no-repeat;
1156
1156
  border-radius: 0.125rem;
1157
1157
  box-shadow: inset 0 0 0 1px var(--figma-color-bordertranslucent);
1158
1158
  }
@@ -1199,16 +1199,21 @@ fig-chit {
1199
1199
  /* Fig Image */
1200
1200
  fig-image {
1201
1201
  --image-size: 2rem;
1202
+ --fit: cover;
1203
+ --image-width: var(--image-size);
1204
+ --image-height: var(--image-size);
1205
+ --aspect-ratio: 1 / 1;
1202
1206
  display: inline-grid;
1203
1207
  place-items: center;
1204
- width: var(--image-size);
1205
- height: var(--image-size);
1206
- aspect-ratio: 1 / 1;
1208
+ width: var(--image-width);
1209
+ height: var(--image-height);
1210
+ aspect-ratio: var(--aspect-ratio);
1207
1211
  > * {
1208
1212
  grid-area: 1/1;
1209
1213
  }
1210
1214
  fig-chit {
1211
- --size: var(--image-size) !important;
1215
+ --size: 100% !important;
1216
+ --chit-bg-size: var(--fit);
1212
1217
  &[disabled] {
1213
1218
  opacity: 1;
1214
1219
  }
@@ -1224,10 +1229,16 @@ fig-image {
1224
1229
  &[size="large"] {
1225
1230
  --image-size: 8rem;
1226
1231
  }
1227
- &[aspect-ratio="auto"] {
1228
- aspect-ratio: var(--aspect-ratio);
1232
+ &[size="auto"] {
1233
+ --image-size: 6rem;
1234
+ &[full]:not([full="false"]) {
1235
+ --image-width: 100%;
1236
+ }
1237
+ }
1238
+ &[aspect-ratio] {
1229
1239
  height: auto;
1230
1240
  fig-chit {
1241
+ --size: 100% !important;
1231
1242
  height: auto !important;
1232
1243
  aspect-ratio: var(--aspect-ratio) !important;
1233
1244
  }
@@ -1253,6 +1264,75 @@ fig-image {
1253
1264
  }
1254
1265
  }
1255
1266
 
1267
+ /* Easing Curve */
1268
+ fig-easing-curve {
1269
+ display: flex;
1270
+ flex-direction: column;
1271
+ gap: var(--spacer-2);
1272
+ --stroke-width: 1.25;
1273
+
1274
+ width: 100%;
1275
+ .fig-easing-curve-svg {
1276
+ width: 100%;
1277
+ aspect-ratio: 1 / 1;
1278
+ overflow: visible;
1279
+ }
1280
+ .fig-easing-curve-svg-container {
1281
+ border-radius: var(--radius-medium);
1282
+ background: var(--figma-color-bg-secondary);
1283
+ }
1284
+ .fig-easing-curve-diagonal,
1285
+ .fig-easing-curve-bounds,
1286
+ .fig-easing-curve-arm,
1287
+ .fig-easing-curve-path,
1288
+ .fig-easing-curve-target {
1289
+ pointer-events: none;
1290
+ }
1291
+ .fig-easing-curve-diagonal {
1292
+ stroke: var(--figma-color-bordertranslucent);
1293
+ stroke-width: var(--stroke-width);
1294
+ }
1295
+ .fig-easing-curve-bounds {
1296
+ fill: transparent;
1297
+ }
1298
+ .fig-easing-curve-arm {
1299
+ stroke: var(--figma-color-border-strong);
1300
+ stroke-width: var(--stroke-width);
1301
+ }
1302
+ .fig-easing-curve-path {
1303
+ fill: none;
1304
+ stroke: var(--figma-color-text);
1305
+ stroke-width: var(--stroke-width);
1306
+ }
1307
+ .fig-easing-curve-target {
1308
+ stroke: var(--figma-color-bordertranslucent);
1309
+ stroke-width: var(--stroke-width);
1310
+ }
1311
+ .fig-easing-curve-handle {
1312
+ fill: var(--figma-color-border-strong);
1313
+ cursor: grab;
1314
+ pointer-events: all;
1315
+ stroke: var(--figma-color-bg-secondary);
1316
+ stroke-width: var(--stroke-width);
1317
+ &:active {
1318
+ cursor: grabbing;
1319
+ fill: var(--figma-color-bg-brand);
1320
+ }
1321
+ }
1322
+ .fig-easing-curve-duration-bar {
1323
+ fill: var(--figma-color-border-strong);
1324
+ stroke: var(--figma-color-bg-secondary);
1325
+ stroke-width: var(--stroke-width);
1326
+ cursor: ew-resize;
1327
+ pointer-events: all;
1328
+ }
1329
+ .fig-easing-curve-dropdown {
1330
+ option svg {
1331
+ vertical-align: middle;
1332
+ }
1333
+ }
1334
+ }
1335
+
1256
1336
  /* Combo input */
1257
1337
  .input-combo {
1258
1338
  display: inline-flex;
@@ -2497,7 +2577,7 @@ fig-input-fill {
2497
2577
 
2498
2578
  fig-input-number {
2499
2579
  flex: 0;
2500
- flex-basis: 3.25rem;
2580
+ flex-basis: 3rem;
2501
2581
  }
2502
2582
 
2503
2583
  &[disabled]:not([disabled="false"]) {
package/fig.js CHANGED
@@ -365,6 +365,9 @@ customElements.define("fig-dropdown", FigDropdown);
365
365
  * @attr {string} offset - Comma-separated offset values: left,top,right,bottom
366
366
  */
367
367
  class FigTooltip extends HTMLElement {
368
+ static #lastShownAt = 0;
369
+ static #warmupWindow = 500;
370
+
368
371
  #boundHideOnChromeOpen;
369
372
  #boundHidePopupOutsideClick;
370
373
  #touchTimeout;
@@ -532,7 +535,9 @@ class FigTooltip extends HTMLElement {
532
535
  showDelayedPopup() {
533
536
  this.render();
534
537
  clearTimeout(this.timeout);
535
- this.timeout = setTimeout(this.showPopup.bind(this), this.delay);
538
+ const warm = Date.now() - FigTooltip.#lastShownAt < FigTooltip.#warmupWindow;
539
+ const effectiveDelay = warm ? 0 : this.delay;
540
+ this.timeout = setTimeout(this.showPopup.bind(this), effectiveDelay);
536
541
  }
537
542
 
538
543
  showPopup() {
@@ -544,6 +549,7 @@ class FigTooltip extends HTMLElement {
544
549
  this.popup.style.zIndex = figGetHighestZIndex() + 1;
545
550
 
546
551
  this.isOpen = true;
552
+ FigTooltip.#lastShownAt = Date.now();
547
553
  this.#startObserving();
548
554
  }
549
555
 
@@ -594,6 +600,7 @@ class FigTooltip extends HTMLElement {
594
600
  }
595
601
 
596
602
  this.isOpen = false;
603
+ FigTooltip.#lastShownAt = Date.now();
597
604
  }
598
605
 
599
606
  #startObserving() {
@@ -3830,7 +3837,7 @@ class FigInputFill extends HTMLElement {
3830
3837
  }
3831
3838
 
3832
3839
  static get observedAttributes() {
3833
- return ["value", "disabled", "mode", "experimental"];
3840
+ return ["value", "disabled", "mode", "experimental", "alpha"];
3834
3841
  }
3835
3842
 
3836
3843
  connectedCallback() {
@@ -3890,6 +3897,8 @@ class FigInputFill extends HTMLElement {
3890
3897
  if (mode) attrs["mode"] = mode;
3891
3898
  const experimental = this.getAttribute("experimental");
3892
3899
  if (experimental) attrs["experimental"] = experimental;
3900
+ const alpha = this.getAttribute("alpha");
3901
+ if (alpha) attrs["alpha"] = alpha;
3893
3902
  // picker-* overrides (except anchor, handled programmatically)
3894
3903
  for (const { name, value } of this.attributes) {
3895
3904
  if (name.startsWith("picker-") && name !== "picker-anchor") {
@@ -3905,6 +3914,22 @@ class FigInputFill extends HTMLElement {
3905
3914
  #render() {
3906
3915
  const disabled = this.hasAttribute("disabled");
3907
3916
  const fillPickerValue = JSON.stringify(this.value);
3917
+ const showAlpha = this.getAttribute("alpha") !== "false";
3918
+
3919
+ const opacityHtml = (value) =>
3920
+ showAlpha
3921
+ ? `<fig-tooltip text="Opacity">
3922
+ <fig-input-number
3923
+ class="fig-input-fill-opacity"
3924
+ placeholder="##"
3925
+ min="0"
3926
+ max="100"
3927
+ value="${value}"
3928
+ units="%"
3929
+ ${disabled ? "disabled" : ""}>
3930
+ </fig-input-number>
3931
+ </fig-tooltip>`
3932
+ : "";
3908
3933
 
3909
3934
  let controlsHtml = "";
3910
3935
 
@@ -3918,17 +3943,7 @@ class FigInputFill extends HTMLElement {
3918
3943
  value="${this.#solid.color.slice(1).toUpperCase()}"
3919
3944
  ${disabled ? "disabled" : ""}>
3920
3945
  </fig-input-text>
3921
- <fig-tooltip text="Opacity">
3922
- <fig-input-number
3923
- class="fig-input-fill-opacity"
3924
- placeholder="##"
3925
- min="0"
3926
- max="100"
3927
- value="${Math.round(this.#solid.alpha * 100)}"
3928
- units="%"
3929
- ${disabled ? "disabled" : ""}>
3930
- </fig-input-number>
3931
- </fig-tooltip>`;
3946
+ ${opacityHtml(Math.round(this.#solid.alpha * 100))}`;
3932
3947
  break;
3933
3948
 
3934
3949
  case "gradient":
@@ -3937,65 +3952,25 @@ class FigInputFill extends HTMLElement {
3937
3952
  this.#gradient.type.slice(1);
3938
3953
  controlsHtml = `
3939
3954
  <label class="fig-input-fill-label">${gradientLabel}</label>
3940
- <fig-tooltip text="Opacity">
3941
- <fig-input-number
3942
- class="fig-input-fill-opacity"
3943
- placeholder="##"
3944
- min="0"
3945
- max="100"
3946
- value="${this.#gradient.stops[0]?.opacity ?? 100}"
3947
- units="%"
3948
- ${disabled ? "disabled" : ""}>
3949
- </fig-input-number>
3950
- </fig-tooltip>`;
3955
+ ${opacityHtml(this.#gradient.stops[0]?.opacity ?? 100)}`;
3951
3956
  break;
3952
3957
 
3953
3958
  case "image":
3954
3959
  controlsHtml = `
3955
3960
  <label class="fig-input-fill-label">Image</label>
3956
- <fig-tooltip text="Opacity">
3957
- <fig-input-number
3958
- class="fig-input-fill-opacity"
3959
- placeholder="##"
3960
- min="0"
3961
- max="100"
3962
- value="${Math.round((this.#image.opacity ?? 1) * 100)}"
3963
- units="%"
3964
- ${disabled ? "disabled" : ""}>
3965
- </fig-input-number>
3966
- </fig-tooltip>`;
3961
+ ${opacityHtml(Math.round((this.#image.opacity ?? 1) * 100))}`;
3967
3962
  break;
3968
3963
 
3969
3964
  case "video":
3970
3965
  controlsHtml = `
3971
3966
  <label class="fig-input-fill-label">Video</label>
3972
- <fig-tooltip text="Opacity">
3973
- <fig-input-number
3974
- class="fig-input-fill-opacity"
3975
- placeholder="##"
3976
- min="0"
3977
- max="100"
3978
- value="${Math.round((this.#video.opacity ?? 1) * 100)}"
3979
- units="%"
3980
- ${disabled ? "disabled" : ""}>
3981
- </fig-input-number>
3982
- </fig-tooltip>`;
3967
+ ${opacityHtml(Math.round((this.#video.opacity ?? 1) * 100))}`;
3983
3968
  break;
3984
3969
 
3985
3970
  case "webcam":
3986
3971
  controlsHtml = `
3987
3972
  <label class="fig-input-fill-label">Webcam</label>
3988
- <fig-tooltip text="Opacity">
3989
- <fig-input-number
3990
- class="fig-input-fill-opacity"
3991
- placeholder="##"
3992
- min="0"
3993
- max="100"
3994
- value="${Math.round((this.#webcam.opacity ?? 1) * 100)}"
3995
- units="%"
3996
- ${disabled ? "disabled" : ""}>
3997
- </fig-input-number>
3998
- </fig-tooltip>`;
3973
+ ${opacityHtml(Math.round((this.#webcam.opacity ?? 1) * 100))}`;
3999
3974
  break;
4000
3975
  }
4001
3976
 
@@ -5154,6 +5129,14 @@ class FigImage extends HTMLElement {
5154
5129
  this.size = this.getAttribute("size") || "small";
5155
5130
  this.innerHTML = this.#getInnerHTML();
5156
5131
  this.#updateRefs();
5132
+ const ar = this.getAttribute("aspect-ratio");
5133
+ if (ar && ar !== "auto") {
5134
+ this.style.setProperty("--aspect-ratio", ar);
5135
+ }
5136
+ const fit = this.getAttribute("fit");
5137
+ if (fit) {
5138
+ this.style.setProperty("--fit", fit);
5139
+ }
5157
5140
  }
5158
5141
  disconnectedCallback() {
5159
5142
  this.fileInput?.removeEventListener("change", this.#boundHandleFileInput);
@@ -5190,10 +5173,13 @@ class FigImage extends HTMLElement {
5190
5173
  this.image.crossOrigin = "Anonymous";
5191
5174
  this.image.onload = async () => {
5192
5175
  this.aspectRatio = this.image.width / this.image.height;
5193
- this.style.setProperty(
5194
- "--aspect-ratio",
5195
- `${this.image.width}/${this.image.height}`
5196
- );
5176
+ const ar = this.getAttribute("aspect-ratio");
5177
+ if (!ar || ar === "auto") {
5178
+ this.style.setProperty(
5179
+ "--aspect-ratio",
5180
+ `${this.image.width}/${this.image.height}`
5181
+ );
5182
+ }
5197
5183
  this.dispatchEvent(
5198
5184
  new CustomEvent("loaded", {
5199
5185
  bubbles: true,
@@ -5265,7 +5251,7 @@ class FigImage extends HTMLElement {
5265
5251
  this.setAttribute("src", this.blob);
5266
5252
  }
5267
5253
  static get observedAttributes() {
5268
- return ["src", "upload"];
5254
+ return ["src", "upload", "aspect-ratio", "fit"];
5269
5255
  }
5270
5256
  get src() {
5271
5257
  return this.#src;
@@ -5297,10 +5283,665 @@ class FigImage extends HTMLElement {
5297
5283
  if (name === "size") {
5298
5284
  this.size = newValue;
5299
5285
  }
5286
+ if (name === "aspect-ratio") {
5287
+ if (newValue && newValue !== "auto") {
5288
+ this.style.setProperty("--aspect-ratio", newValue);
5289
+ } else if (!newValue) {
5290
+ this.style.removeProperty("--aspect-ratio");
5291
+ }
5292
+ }
5293
+ if (name === "fit") {
5294
+ if (newValue) {
5295
+ this.style.setProperty("--fit", newValue);
5296
+ } else {
5297
+ this.style.removeProperty("--fit");
5298
+ }
5299
+ }
5300
5300
  }
5301
5301
  }
5302
5302
  customElements.define("fig-image", FigImage);
5303
5303
 
5304
+ /**
5305
+ * A bezier / spring easing curve editor with draggable control points.
5306
+ * @attr {string} value - Bezier: "0.42, 0, 0.58, 1" or Spring: "spring(200, 15, 1)"
5307
+ * @attr {number} precision - Decimal places for output values (default 2)
5308
+ * @attr {boolean} dropdown - Show a preset dropdown selector
5309
+ */
5310
+ class FigEasingCurve extends HTMLElement {
5311
+ #cp1 = { x: 0.42, y: 0 };
5312
+ #cp2 = { x: 0.58, y: 1 };
5313
+ #spring = { stiffness: 200, damping: 15, mass: 1 };
5314
+ #mode = "bezier";
5315
+ #precision = 2;
5316
+ #isDragging = null;
5317
+ #svg = null;
5318
+ #curve = null;
5319
+ #line1 = null;
5320
+ #line2 = null;
5321
+ #handle1 = null;
5322
+ #handle2 = null;
5323
+ #dropdown = null;
5324
+ #presetName = null;
5325
+ #targetLine = null;
5326
+ #springDuration = 0.8;
5327
+ #drawWidth = 200;
5328
+ #drawHeight = 200;
5329
+ #bounds = null;
5330
+ #diagonal = null;
5331
+ #resizeObserver = null;
5332
+
5333
+ static PRESETS = [
5334
+ { group: null, name: "Linear", type: "bezier", value: [0, 0, 1, 1] },
5335
+ { group: "Bezier", name: "Ease in", type: "bezier", value: [0.42, 0, 1, 1] },
5336
+ { group: "Bezier", name: "Ease out", type: "bezier", value: [0, 0, 0.58, 1] },
5337
+ { group: "Bezier", name: "Ease in and out", type: "bezier", value: [0.42, 0, 0.58, 1] },
5338
+ { group: "Bezier", name: "Ease in back", type: "bezier", value: [0.6, -0.28, 0.735, 0.045] },
5339
+ { group: "Bezier", name: "Ease out back", type: "bezier", value: [0.175, 0.885, 0.32, 1.275] },
5340
+ { group: "Bezier", name: "Ease in and out back", type: "bezier", value: [0.68, -0.55, 0.265, 1.55] },
5341
+ { group: "Bezier", name: "Custom bezier", type: "bezier", value: null },
5342
+ { group: "Spring", name: "Gentle", type: "spring", spring: { stiffness: 120, damping: 14, mass: 1 } },
5343
+ { group: "Spring", name: "Quick", type: "spring", spring: { stiffness: 380, damping: 20, mass: 1 } },
5344
+ { group: "Spring", name: "Bouncy", type: "spring", spring: { stiffness: 250, damping: 8, mass: 1 } },
5345
+ { group: "Spring", name: "Slow", type: "spring", spring: { stiffness: 60, damping: 11, mass: 1 } },
5346
+ { group: "Spring", name: "Custom spring", type: "spring", spring: null },
5347
+ ];
5348
+
5349
+ static get observedAttributes() {
5350
+ return ["value", "precision"];
5351
+ }
5352
+
5353
+ connectedCallback() {
5354
+ this.#precision = parseInt(this.getAttribute("precision") || "2");
5355
+ const val = this.getAttribute("value");
5356
+ if (val) this.#parseValue(val);
5357
+ this.#presetName = this.#matchPreset();
5358
+ this.#render();
5359
+ this.#setupResizeObserver();
5360
+ }
5361
+
5362
+ disconnectedCallback() {
5363
+ this.#isDragging = null;
5364
+ if (this.#resizeObserver) {
5365
+ this.#resizeObserver.disconnect();
5366
+ this.#resizeObserver = null;
5367
+ }
5368
+ }
5369
+
5370
+ attributeChangedCallback(name, oldValue, newValue) {
5371
+ if (!this.#svg) return;
5372
+ if (name === "value" && newValue) {
5373
+ const prevMode = this.#mode;
5374
+ this.#parseValue(newValue);
5375
+ this.#presetName = this.#matchPreset();
5376
+ if (prevMode !== this.#mode) {
5377
+ this.#render();
5378
+ } else {
5379
+ this.#updatePaths();
5380
+ this.#syncDropdown();
5381
+ }
5382
+ }
5383
+ if (name === "precision") {
5384
+ this.#precision = parseInt(newValue || "2");
5385
+ }
5386
+ }
5387
+
5388
+ get value() {
5389
+ if (this.#mode === "spring") {
5390
+ const { stiffness, damping, mass } = this.#spring;
5391
+ return `spring(${stiffness}, ${damping}, ${mass})`;
5392
+ }
5393
+ const p = this.#precision;
5394
+ return `${this.#cp1.x.toFixed(p)}, ${this.#cp1.y.toFixed(p)}, ${this.#cp2.x.toFixed(p)}, ${this.#cp2.y.toFixed(p)}`;
5395
+ }
5396
+
5397
+ get cssValue() {
5398
+ if (this.#mode === "spring") {
5399
+ const points = this.#simulateSpring();
5400
+ const samples = 20;
5401
+ const step = Math.max(1, Math.floor(points.length / samples));
5402
+ const vals = [];
5403
+ for (let i = 0; i < points.length; i += step) {
5404
+ vals.push(points[i].value.toFixed(3));
5405
+ }
5406
+ if (points.length > 0) vals.push(points[points.length - 1].value.toFixed(3));
5407
+ return `linear(${vals.join(", ")})`;
5408
+ }
5409
+ const p = this.#precision;
5410
+ return `cubic-bezier(${this.#cp1.x.toFixed(p)}, ${this.#cp1.y.toFixed(p)}, ${this.#cp2.x.toFixed(p)}, ${this.#cp2.y.toFixed(p)})`;
5411
+ }
5412
+
5413
+ get preset() {
5414
+ return this.#presetName;
5415
+ }
5416
+
5417
+ set value(v) {
5418
+ this.setAttribute("value", v);
5419
+ }
5420
+
5421
+ #parseValue(str) {
5422
+ const springMatch = str.match(/^spring\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/);
5423
+ if (springMatch) {
5424
+ this.#mode = "spring";
5425
+ this.#spring.stiffness = parseFloat(springMatch[1]);
5426
+ this.#spring.damping = parseFloat(springMatch[2]);
5427
+ this.#spring.mass = parseFloat(springMatch[3]);
5428
+ return;
5429
+ }
5430
+ const parts = str.split(",").map((s) => parseFloat(s.trim()));
5431
+ if (parts.length >= 4 && parts.every((n) => !isNaN(n))) {
5432
+ this.#mode = "bezier";
5433
+ this.#cp1.x = parts[0];
5434
+ this.#cp1.y = parts[1];
5435
+ this.#cp2.x = parts[2];
5436
+ this.#cp2.y = parts[3];
5437
+ }
5438
+ }
5439
+
5440
+ #matchPreset() {
5441
+ const ep = 0.001;
5442
+ if (this.#mode === "bezier") {
5443
+ for (const p of FigEasingCurve.PRESETS) {
5444
+ if (p.type !== "bezier" || !p.value) continue;
5445
+ if (
5446
+ Math.abs(this.#cp1.x - p.value[0]) < ep &&
5447
+ Math.abs(this.#cp1.y - p.value[1]) < ep &&
5448
+ Math.abs(this.#cp2.x - p.value[2]) < ep &&
5449
+ Math.abs(this.#cp2.y - p.value[3]) < ep
5450
+ ) return p.name;
5451
+ }
5452
+ return "Custom bezier";
5453
+ }
5454
+ for (const p of FigEasingCurve.PRESETS) {
5455
+ if (p.type !== "spring" || !p.spring) continue;
5456
+ if (
5457
+ Math.abs(this.#spring.stiffness - p.spring.stiffness) < ep &&
5458
+ Math.abs(this.#spring.damping - p.spring.damping) < ep &&
5459
+ Math.abs(this.#spring.mass - p.spring.mass) < ep
5460
+ ) return p.name;
5461
+ }
5462
+ return "Custom spring";
5463
+ }
5464
+
5465
+ // --- Spring simulation ---
5466
+
5467
+ #simulateSpring() {
5468
+ const { stiffness, damping, mass } = this.#spring;
5469
+ const dt = 0.004;
5470
+ const maxTime = 5;
5471
+ const points = [];
5472
+ let pos = 0, vel = 0;
5473
+ for (let t = 0; t <= maxTime; t += dt) {
5474
+ const force = -stiffness * (pos - 1) - damping * vel;
5475
+ vel += (force / mass) * dt;
5476
+ pos += vel * dt;
5477
+ points.push({ t, value: pos });
5478
+ if (t > 0.1 && Math.abs(pos - 1) < 0.0005 && Math.abs(vel) < 0.0005) break;
5479
+ }
5480
+ return points;
5481
+ }
5482
+
5483
+ static #springIcon(spring, size = 24) {
5484
+ const { stiffness, damping, mass } = spring;
5485
+ const dt = 0.004;
5486
+ const maxTime = 5;
5487
+ const pts = [];
5488
+ let pos = 0, vel = 0;
5489
+ for (let t = 0; t <= maxTime; t += dt) {
5490
+ const force = -stiffness * (pos - 1) - damping * vel;
5491
+ vel += (force / mass) * dt;
5492
+ pos += vel * dt;
5493
+ pts.push({ t, value: pos });
5494
+ if (t > 0.1 && Math.abs(pos - 1) < 0.001 && Math.abs(vel) < 0.001) break;
5495
+ }
5496
+ const totalTime = pts[pts.length - 1].t || 1;
5497
+ let maxVal = 1;
5498
+ for (const p of pts) if (p.value > maxVal) maxVal = p.value;
5499
+ let minVal = 0;
5500
+ for (const p of pts) if (p.value < minVal) minVal = p.value;
5501
+ const range = Math.max(maxVal - minVal, 1);
5502
+ const pad = 6;
5503
+ const s = size - pad * 2;
5504
+ const step = Math.max(1, Math.floor(pts.length / 30));
5505
+ let d = "";
5506
+ for (let i = 0; i < pts.length; i += step) {
5507
+ const x = pad + (pts[i].t / totalTime) * s;
5508
+ const y = pad + (1 - (pts[i].value - minVal) / range) * s;
5509
+ d += (i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1);
5510
+ }
5511
+ return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none"><path d="${d}" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>`;
5512
+ }
5513
+
5514
+ static curveIcon(cp1x, cp1y, cp2x, cp2y, size = 24) {
5515
+ const pad = 6;
5516
+ const s = size - pad * 2;
5517
+ const x = (n) => pad + n * s;
5518
+ const y = (n) => pad + (1 - n) * s;
5519
+ const d = `M${x(0)},${y(0)} C${x(cp1x)},${y(cp1y)} ${x(cp2x)},${y(cp2y)} ${x(1)},${y(1)}`;
5520
+ return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none"><path d="${d}" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`;
5521
+ }
5522
+
5523
+ // --- Rendering ---
5524
+
5525
+ #render() {
5526
+ this.innerHTML = this.#getInnerHTML();
5527
+ this.#cacheRefs();
5528
+ this.#syncViewportSize();
5529
+ this.#updatePaths();
5530
+ this.#setupEvents();
5531
+ }
5532
+
5533
+ #getDropdownHTML() {
5534
+ if (this.getAttribute("dropdown") !== "true") return "";
5535
+ let optionsHTML = "";
5536
+ let currentGroup = undefined;
5537
+ for (const p of FigEasingCurve.PRESETS) {
5538
+ if (p.group !== currentGroup) {
5539
+ if (currentGroup !== undefined) optionsHTML += `</optgroup>`;
5540
+ if (p.group) optionsHTML += `<optgroup label="${p.group}">`;
5541
+ currentGroup = p.group;
5542
+ }
5543
+ let icon;
5544
+ if (p.type === "spring") {
5545
+ const sp = p.spring || this.#spring;
5546
+ icon = FigEasingCurve.#springIcon(sp);
5547
+ } else {
5548
+ const v = p.value || [this.#cp1.x, this.#cp1.y, this.#cp2.x, this.#cp2.y];
5549
+ icon = FigEasingCurve.curveIcon(...v);
5550
+ }
5551
+ const selected = p.name === this.#presetName ? " selected" : "";
5552
+ optionsHTML += `<option value="${p.name}"${selected}>${icon} ${p.name}</option>`;
5553
+ }
5554
+ if (currentGroup) optionsHTML += `</optgroup>`;
5555
+ return `<fig-dropdown class="fig-easing-curve-dropdown" full experimental="modern">${optionsHTML}</fig-dropdown>`;
5556
+ }
5557
+
5558
+ #getInnerHTML() {
5559
+ const size = 200;
5560
+ const dropdown = this.#getDropdownHTML();
5561
+
5562
+ if (this.#mode === "spring") {
5563
+ const targetY = 40;
5564
+ const startY = 180;
5565
+ return `${dropdown}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
5566
+ <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
5567
+ <line class="fig-easing-curve-target" x1="0" y1="${targetY}" x2="${size}" y2="${targetY}"/>
5568
+ <line class="fig-easing-curve-diagonal" x1="0" y1="${startY}" x2="0" y2="${startY}"/>
5569
+ <path class="fig-easing-curve-path"/>
5570
+ <circle class="fig-easing-curve-handle" data-handle="bounce" r="5.25"/>
5571
+ <rect class="fig-easing-curve-duration-bar" data-handle="duration" width="6" height="16" rx="3" ry="3"/>
5572
+ </svg></div>`;
5573
+ }
5574
+
5575
+ return `${dropdown}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
5576
+ <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
5577
+ <line class="fig-easing-curve-diagonal" x1="0" y1="${size}" x2="${size}" y2="0"/>
5578
+ <line class="fig-easing-curve-arm" data-arm="1"/>
5579
+ <line class="fig-easing-curve-arm" data-arm="2"/>
5580
+ <path class="fig-easing-curve-path"/>
5581
+ <circle class="fig-easing-curve-handle" data-handle="1" r="5.25"/>
5582
+ <circle class="fig-easing-curve-handle" data-handle="2" r="5.25"/>
5583
+ </svg></div>`;
5584
+ }
5585
+
5586
+ #cacheRefs() {
5587
+ this.#svg = this.querySelector(".fig-easing-curve-svg");
5588
+ this.#curve = this.querySelector(".fig-easing-curve-path");
5589
+ this.#line1 = this.querySelector('[data-arm="1"]');
5590
+ this.#line2 = this.querySelector('[data-arm="2"]');
5591
+ this.#handle1 = this.querySelector('[data-handle="1"]') || this.querySelector('[data-handle="bounce"]');
5592
+ this.#handle2 = this.querySelector('[data-handle="2"]') || this.querySelector('[data-handle="duration"]');
5593
+ this.#dropdown = this.querySelector(".fig-easing-curve-dropdown");
5594
+ this.#targetLine = this.querySelector(".fig-easing-curve-target");
5595
+ this.#bounds = this.querySelector(".fig-easing-curve-bounds");
5596
+ this.#diagonal = this.querySelector(".fig-easing-curve-diagonal");
5597
+ }
5598
+
5599
+ #setupResizeObserver() {
5600
+ if (this.#resizeObserver || !window.ResizeObserver) return;
5601
+ this.#resizeObserver = new ResizeObserver(() => {
5602
+ if (this.#syncViewportSize()) {
5603
+ this.#updatePaths();
5604
+ }
5605
+ });
5606
+ this.#resizeObserver.observe(this);
5607
+ }
5608
+
5609
+ #syncViewportSize() {
5610
+ if (!this.#svg) return false;
5611
+ const rect = this.#svg.getBoundingClientRect();
5612
+ const width = Math.max(1, Math.round(rect.width || 200));
5613
+ const height = Math.max(1, Math.round(rect.height || 200));
5614
+ const changed = width !== this.#drawWidth || height !== this.#drawHeight;
5615
+ this.#drawWidth = width;
5616
+ this.#drawHeight = height;
5617
+ this.#svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
5618
+ return changed;
5619
+ }
5620
+
5621
+ // --- Coordinate helpers ---
5622
+
5623
+ #toSVG(nx, ny) {
5624
+ return { x: nx * this.#drawWidth, y: (1 - ny) * this.#drawHeight };
5625
+ }
5626
+
5627
+ #fromSVG(sx, sy) {
5628
+ return { x: sx / this.#drawWidth, y: 1 - sy / this.#drawHeight };
5629
+ }
5630
+
5631
+ #springScale = { minVal: 0, maxVal: 1.2, totalTime: 1 };
5632
+
5633
+ #springToSVG(nt, nv) {
5634
+ const pad = 20;
5635
+ const draw = this.#drawHeight - pad * 2;
5636
+ const { minVal, maxVal } = this.#springScale;
5637
+ const range = maxVal - minVal || 1;
5638
+ return {
5639
+ x: nt * this.#drawWidth,
5640
+ y: pad + (1 - (nv - minVal) / range) * draw,
5641
+ };
5642
+ }
5643
+
5644
+ // --- Path updates ---
5645
+
5646
+ #updatePaths() {
5647
+ this.#syncViewportSize();
5648
+ if (this.#mode === "spring") {
5649
+ this.#updateSpringPaths();
5650
+ } else {
5651
+ this.#updateBezierPaths();
5652
+ }
5653
+ }
5654
+
5655
+ #updateBezierPaths() {
5656
+ if (this.#bounds) {
5657
+ this.#bounds.setAttribute("x", "0");
5658
+ this.#bounds.setAttribute("y", "0");
5659
+ this.#bounds.setAttribute("width", this.#drawWidth);
5660
+ this.#bounds.setAttribute("height", this.#drawHeight);
5661
+ }
5662
+ if (this.#diagonal) {
5663
+ this.#diagonal.setAttribute("x1", "0");
5664
+ this.#diagonal.setAttribute("y1", this.#drawHeight);
5665
+ this.#diagonal.setAttribute("x2", this.#drawWidth);
5666
+ this.#diagonal.setAttribute("y2", "0");
5667
+ }
5668
+
5669
+ const p0 = this.#toSVG(0, 0);
5670
+ const p1 = this.#toSVG(this.#cp1.x, this.#cp1.y);
5671
+ const p2 = this.#toSVG(this.#cp2.x, this.#cp2.y);
5672
+ const p3 = this.#toSVG(1, 1);
5673
+
5674
+ this.#curve.setAttribute("d", `M${p0.x},${p0.y} C${p1.x},${p1.y} ${p2.x},${p2.y} ${p3.x},${p3.y}`);
5675
+ this.#line1.setAttribute("x1", p0.x);
5676
+ this.#line1.setAttribute("y1", p0.y);
5677
+ this.#line1.setAttribute("x2", p1.x);
5678
+ this.#line1.setAttribute("y2", p1.y);
5679
+ this.#line2.setAttribute("x1", p3.x);
5680
+ this.#line2.setAttribute("y1", p3.y);
5681
+ this.#line2.setAttribute("x2", p2.x);
5682
+ this.#line2.setAttribute("y2", p2.y);
5683
+ this.#handle1.setAttribute("cx", p1.x);
5684
+ this.#handle1.setAttribute("cy", p1.y);
5685
+ this.#handle2.setAttribute("cx", p2.x);
5686
+ this.#handle2.setAttribute("cy", p2.y);
5687
+ }
5688
+
5689
+ #updateSpringPaths() {
5690
+ if (this.#bounds) {
5691
+ this.#bounds.setAttribute("x", "0");
5692
+ this.#bounds.setAttribute("y", "0");
5693
+ this.#bounds.setAttribute("width", this.#drawWidth);
5694
+ this.#bounds.setAttribute("height", this.#drawHeight);
5695
+ }
5696
+
5697
+ const points = this.#simulateSpring();
5698
+ if (!points.length) return;
5699
+ const totalTime = points[points.length - 1].t || 1;
5700
+
5701
+ let minVal = 0, maxVal = 1;
5702
+ for (const p of points) {
5703
+ if (p.value < minVal) minVal = p.value;
5704
+ if (p.value > maxVal) maxVal = p.value;
5705
+ }
5706
+ const maxDistFromCenter = Math.max(Math.abs(minVal - 1), Math.abs(maxVal - 1), 0.01);
5707
+ const valPad = 0;
5708
+ this.#springScale = {
5709
+ minVal: 1 - maxDistFromCenter - valPad,
5710
+ maxVal: 1 + maxDistFromCenter + valPad,
5711
+ totalTime,
5712
+ };
5713
+
5714
+ const durationNorm = Math.max(0.05, Math.min(0.95, this.#springDuration));
5715
+ let d = "";
5716
+ for (let i = 0; i < points.length; i++) {
5717
+ const nt = (points[i].t / totalTime) * durationNorm;
5718
+ const pt = this.#springToSVG(nt, points[i].value);
5719
+ d += (i === 0 ? "M" : "L") + pt.x.toFixed(1) + "," + pt.y.toFixed(1);
5720
+ }
5721
+ const flatStart = this.#springToSVG(durationNorm, 1);
5722
+ const flatEnd = this.#springToSVG(1, 1);
5723
+ d += `L${flatStart.x.toFixed(1)},${flatStart.y.toFixed(1)} L${flatEnd.x.toFixed(1)},${flatEnd.y.toFixed(1)}`;
5724
+ this.#curve.setAttribute("d", d);
5725
+
5726
+ // Update target line position at value=1
5727
+ if (this.#targetLine) {
5728
+ const tl = this.#springToSVG(0, 1);
5729
+ const tr = this.#springToSVG(1, 1);
5730
+ this.#targetLine.setAttribute("x1", tl.x);
5731
+ this.#targetLine.setAttribute("y1", tl.y);
5732
+ this.#targetLine.setAttribute("x2", tr.x);
5733
+ this.#targetLine.setAttribute("y2", tr.y);
5734
+ }
5735
+
5736
+ // Bounce handle: at first overshoot peak
5737
+ const peak = this.#findPeakOvershoot(points);
5738
+ const peakNorm = (peak.t / totalTime) * durationNorm;
5739
+ const peakPt = this.#springToSVG(peakNorm, peak.value);
5740
+ this.#handle1.setAttribute("cx", peakPt.x);
5741
+ this.#handle1.setAttribute("cy", peakPt.y);
5742
+
5743
+ // Duration handle: on the target line
5744
+ const targetPt = this.#springToSVG(durationNorm, 1);
5745
+ this.#handle2.setAttribute("x", targetPt.x - 3);
5746
+ this.#handle2.setAttribute("y", targetPt.y - 8);
5747
+
5748
+ }
5749
+
5750
+ #findPeakOvershoot(points) {
5751
+ let peak = { t: 0, value: 1 };
5752
+ let passedTarget = false;
5753
+ for (const p of points) {
5754
+ if (p.value >= 0.99) passedTarget = true;
5755
+ if (passedTarget && p.value > peak.value) {
5756
+ peak = { t: p.t, value: p.value };
5757
+ }
5758
+ }
5759
+ return peak;
5760
+ }
5761
+
5762
+ // --- Dropdown ---
5763
+
5764
+ #syncDropdown() {
5765
+ if (!this.#dropdown) return;
5766
+ this.#dropdown.value = this.#presetName;
5767
+ this.#refreshCustomPresetIcons();
5768
+ }
5769
+
5770
+ #setOptionIconByValue(root, optionValue, icon) {
5771
+ if (!root) return;
5772
+ for (const option of root.querySelectorAll("option")) {
5773
+ if (option.value === optionValue) {
5774
+ option.innerHTML = `${icon} ${optionValue}`;
5775
+ }
5776
+ }
5777
+ }
5778
+
5779
+ #refreshCustomPresetIcons() {
5780
+ if (!this.#dropdown) return;
5781
+ const bezierIcon = FigEasingCurve.curveIcon(
5782
+ this.#cp1.x,
5783
+ this.#cp1.y,
5784
+ this.#cp2.x,
5785
+ this.#cp2.y
5786
+ );
5787
+ const springIcon = FigEasingCurve.#springIcon(this.#spring);
5788
+
5789
+ // Update both slotted options and the cloned native select options.
5790
+ this.#setOptionIconByValue(this.#dropdown, "Custom bezier", bezierIcon);
5791
+ this.#setOptionIconByValue(this.#dropdown, "Custom spring", springIcon);
5792
+ this.#setOptionIconByValue(this.#dropdown.select, "Custom bezier", bezierIcon);
5793
+ this.#setOptionIconByValue(this.#dropdown.select, "Custom spring", springIcon);
5794
+ }
5795
+
5796
+ // --- Events ---
5797
+
5798
+ #emit(type) {
5799
+ this.dispatchEvent(new CustomEvent(type, {
5800
+ bubbles: true,
5801
+ detail: {
5802
+ mode: this.#mode,
5803
+ value: this.value,
5804
+ cssValue: this.cssValue,
5805
+ preset: this.#presetName,
5806
+ },
5807
+ }));
5808
+ }
5809
+
5810
+ #setupEvents() {
5811
+ if (this.#mode === "bezier") {
5812
+ this.#handle1.addEventListener("pointerdown", (e) => this.#startBezierDrag(e, 1));
5813
+ this.#handle2.addEventListener("pointerdown", (e) => this.#startBezierDrag(e, 2));
5814
+ } else {
5815
+ this.#handle1.addEventListener("pointerdown", (e) => this.#startSpringDrag(e, "bounce"));
5816
+ this.#handle2.addEventListener("pointerdown", (e) => this.#startSpringDrag(e, "duration"));
5817
+ }
5818
+
5819
+ if (this.#dropdown) {
5820
+ this.#dropdown.addEventListener("change", (e) => {
5821
+ const name = e.detail;
5822
+ const preset = FigEasingCurve.PRESETS.find((p) => p.name === name);
5823
+ if (!preset) return;
5824
+
5825
+ if (preset.type === "bezier") {
5826
+ if (preset.value) {
5827
+ this.#cp1.x = preset.value[0];
5828
+ this.#cp1.y = preset.value[1];
5829
+ this.#cp2.x = preset.value[2];
5830
+ this.#cp2.y = preset.value[3];
5831
+ }
5832
+ this.#presetName = name;
5833
+ if (this.#mode !== "bezier") {
5834
+ this.#mode = "bezier";
5835
+ this.#render();
5836
+ } else {
5837
+ this.#updatePaths();
5838
+ }
5839
+ } else if (preset.type === "spring") {
5840
+ if (preset.spring) {
5841
+ this.#spring = { ...preset.spring };
5842
+ }
5843
+ this.#presetName = name;
5844
+ if (this.#mode !== "spring") {
5845
+ this.#mode = "spring";
5846
+ this.#render();
5847
+ } else {
5848
+ this.#updatePaths();
5849
+ }
5850
+ }
5851
+ this.#emit("input");
5852
+ this.#emit("change");
5853
+ });
5854
+ }
5855
+ }
5856
+
5857
+ #clientToSVG(e) {
5858
+ const ctm = this.#svg.getScreenCTM();
5859
+ if (!ctm) return { x: 0, y: 0 };
5860
+ const inv = ctm.inverse();
5861
+ return {
5862
+ x: inv.a * e.clientX + inv.c * e.clientY + inv.e,
5863
+ y: inv.b * e.clientX + inv.d * e.clientY + inv.f,
5864
+ };
5865
+ }
5866
+
5867
+ #startBezierDrag(e, handle) {
5868
+ e.preventDefault();
5869
+ this.#isDragging = handle;
5870
+
5871
+ const onMove = (e) => {
5872
+ if (!this.#isDragging) return;
5873
+ const svgPt = this.#clientToSVG(e);
5874
+ const norm = this.#fromSVG(svgPt.x, svgPt.y);
5875
+
5876
+ norm.x = Math.round(norm.x * 100) / 100;
5877
+ norm.y = Math.round(norm.y * 100) / 100;
5878
+ norm.x = Math.max(0, Math.min(1, norm.x));
5879
+
5880
+ if (this.#isDragging === 1) {
5881
+ this.#cp1.x = norm.x;
5882
+ this.#cp1.y = norm.y;
5883
+ } else {
5884
+ this.#cp2.x = norm.x;
5885
+ this.#cp2.y = norm.y;
5886
+ }
5887
+ this.#updatePaths();
5888
+ this.#presetName = this.#matchPreset();
5889
+ this.#syncDropdown();
5890
+ this.#emit("input");
5891
+ };
5892
+
5893
+ const onUp = () => {
5894
+ this.#isDragging = null;
5895
+ document.removeEventListener("pointermove", onMove);
5896
+ document.removeEventListener("pointerup", onUp);
5897
+ this.#emit("change");
5898
+ };
5899
+
5900
+ document.addEventListener("pointermove", onMove);
5901
+ document.addEventListener("pointerup", onUp);
5902
+ }
5903
+
5904
+ #startSpringDrag(e, handleType) {
5905
+ e.preventDefault();
5906
+ this.#isDragging = handleType;
5907
+
5908
+ const startDamping = this.#spring.damping;
5909
+ const startStiffness = this.#spring.stiffness;
5910
+ const startDuration = this.#springDuration;
5911
+ const startY = e.clientY;
5912
+ const startX = e.clientX;
5913
+
5914
+ const onMove = (e) => {
5915
+ if (!this.#isDragging) return;
5916
+
5917
+ if (handleType === "bounce") {
5918
+ const dy = e.clientY - startY;
5919
+ this.#spring.damping = Math.max(1, Math.round(startDamping + dy * 0.15));
5920
+ } else {
5921
+ const dx = e.clientX - startX;
5922
+ this.#springDuration = Math.max(0.05, Math.min(0.95, startDuration + dx / 200));
5923
+ this.#spring.stiffness = Math.max(10, Math.round(startStiffness - dx * 1.5));
5924
+ }
5925
+
5926
+ this.#updatePaths();
5927
+ this.#presetName = this.#matchPreset();
5928
+ this.#syncDropdown();
5929
+ this.#emit("input");
5930
+ };
5931
+
5932
+ const onUp = () => {
5933
+ this.#isDragging = null;
5934
+ document.removeEventListener("pointermove", onMove);
5935
+ document.removeEventListener("pointerup", onUp);
5936
+ this.#emit("change");
5937
+ };
5938
+
5939
+ document.addEventListener("pointermove", onMove);
5940
+ document.addEventListener("pointerup", onUp);
5941
+ }
5942
+ }
5943
+ customElements.define("fig-easing-curve", FigEasingCurve);
5944
+
5304
5945
  /**
5305
5946
  * A custom joystick input element.
5306
5947
  * @attr {string} value - The current position of the joystick (e.g., "0.5,0.5").
package/index.html CHANGED
@@ -4339,6 +4339,32 @@
4339
4339
  <fig-button>No delay</fig-button>
4340
4340
  </fig-tooltip>
4341
4341
 
4342
+ <h3>Warmup (Rapid Hover)</h3>
4343
+ <p class="description">After the first tooltip appears, moving quickly between these buttons shows
4344
+ subsequent tooltips instantly — no repeated delay.</p>
4345
+ <hstack>
4346
+ <fig-tooltip text="Add" delay="500">
4347
+ <fig-button icon variant="ghost">
4348
+ <span class="fig-mask-icon" style="--icon: var(--icon-add)"></span>
4349
+ </fig-button>
4350
+ </fig-tooltip>
4351
+ <fig-tooltip text="Remove" delay="500">
4352
+ <fig-button icon variant="ghost">
4353
+ <span class="fig-mask-icon" style="--icon: var(--icon-minus)"></span>
4354
+ </fig-button>
4355
+ </fig-tooltip>
4356
+ <fig-tooltip text="Rotate" delay="500">
4357
+ <fig-button icon variant="ghost">
4358
+ <span class="fig-mask-icon" style="--icon: var(--icon-rotate)"></span>
4359
+ </fig-button>
4360
+ </fig-tooltip>
4361
+ <fig-tooltip text="Swap" delay="500">
4362
+ <fig-button icon variant="ghost">
4363
+ <span class="fig-mask-icon" style="--icon: var(--icon-swap)"></span>
4364
+ </fig-button>
4365
+ </fig-tooltip>
4366
+ </hstack>
4367
+
4342
4368
  <h3>Long Text</h3>
4343
4369
  <fig-tooltip text="This is a much longer tooltip that contains more detailed information about the element">
4344
4370
  <fig-button variant="secondary">Long tooltip</fig-button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "2.25.0",
3
+ "version": "2.26.0",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",