@rogieking/figui3 2.24.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.
Files changed (4) hide show
  1. package/components.css +94 -8
  2. package/fig.js +894 -119
  3. package/index.html +369 -3
  4. package/package.json +1 -1
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() {
@@ -3384,16 +3391,29 @@ class FigInputColor extends HTMLElement {
3384
3391
  return this.getAttribute("picker") || "native";
3385
3392
  }
3386
3393
 
3394
+ #buildFillPickerAttrs() {
3395
+ const attrs = {};
3396
+ const experimental = this.getAttribute("experimental");
3397
+ if (experimental) attrs["experimental"] = experimental;
3398
+ // picker-* attributes forwarded to fill picker (except anchor, handled programmatically)
3399
+ for (const { name, value } of this.attributes) {
3400
+ if (name.startsWith("picker-") && name !== "picker-anchor") {
3401
+ attrs[name.slice(7)] = value;
3402
+ }
3403
+ }
3404
+ if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
3405
+ return Object.entries(attrs)
3406
+ .map(([k, v]) => `${k}="${v}"`)
3407
+ .join(" ");
3408
+ }
3409
+
3387
3410
  connectedCallback() {
3388
3411
  this.#setValues(this.getAttribute("value"));
3389
3412
 
3390
3413
  const useFigmaPicker = this.picker === "figma";
3391
3414
  const hidePicker = this.picker === "false";
3392
3415
  const showAlpha = this.getAttribute("alpha") === "true";
3393
- const experimental = this.getAttribute("experimental");
3394
- const expAttr = experimental ? `experimental="${experimental}"` : "";
3395
- const dialogPos = this.getAttribute("dialog-position") || "left";
3396
- const dialogPosAttr = `dialog-position="${dialogPos}"`;
3416
+ const fpAttrs = this.#buildFillPickerAttrs();
3397
3417
 
3398
3418
  let html = ``;
3399
3419
  if (this.getAttribute("text")) {
@@ -3418,7 +3438,7 @@ class FigInputColor extends HTMLElement {
3418
3438
  let swatchElement = "";
3419
3439
  if (!hidePicker) {
3420
3440
  swatchElement = useFigmaPicker
3421
- ? `<fig-fill-picker mode="solid" ${dialogPosAttr} ${expAttr} ${
3441
+ ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
3422
3442
  showAlpha ? "" : 'alpha="false"'
3423
3443
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
3424
3444
  this.alpha
@@ -3436,7 +3456,7 @@ class FigInputColor extends HTMLElement {
3436
3456
  html = ``;
3437
3457
  } else {
3438
3458
  html = useFigmaPicker
3439
- ? `<fig-fill-picker mode="solid" ${dialogPosAttr} ${expAttr} ${
3459
+ ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
3440
3460
  showAlpha ? "" : 'alpha="false"'
3441
3461
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
3442
3462
  this.alpha
@@ -3460,6 +3480,13 @@ class FigInputColor extends HTMLElement {
3460
3480
 
3461
3481
  // Setup fill picker (figma picker)
3462
3482
  if (this.#fillPicker) {
3483
+ const anchor = this.getAttribute("picker-anchor");
3484
+ if (anchor === "self") {
3485
+ this.#fillPicker.anchorElement = this;
3486
+ } else if (anchor) {
3487
+ const el = document.querySelector(anchor);
3488
+ if (el) this.#fillPicker.anchorElement = el;
3489
+ }
3463
3490
  if (this.hasAttribute("disabled")) {
3464
3491
  this.#fillPicker.setAttribute("disabled", "");
3465
3492
  }
@@ -3628,7 +3655,7 @@ class FigInputColor extends HTMLElement {
3628
3655
  }
3629
3656
 
3630
3657
  static get observedAttributes() {
3631
- return ["value", "style", "mode", "picker", "experimental", "dialog-position"];
3658
+ return ["value", "style", "mode", "picker", "experimental"];
3632
3659
  }
3633
3660
 
3634
3661
  get mode() {
@@ -3810,7 +3837,7 @@ class FigInputFill extends HTMLElement {
3810
3837
  }
3811
3838
 
3812
3839
  static get observedAttributes() {
3813
- return ["value", "disabled", "mode", "experimental"];
3840
+ return ["value", "disabled", "mode", "experimental", "alpha"];
3814
3841
  }
3815
3842
 
3816
3843
  connectedCallback() {
@@ -3863,9 +3890,46 @@ class FigInputFill extends HTMLElement {
3863
3890
  }
3864
3891
  }
3865
3892
 
3893
+ #buildFillPickerAttrs() {
3894
+ const attrs = {};
3895
+ // Backward-compat: direct attributes forwarded to fill picker
3896
+ const mode = this.getAttribute("mode");
3897
+ if (mode) attrs["mode"] = mode;
3898
+ const experimental = this.getAttribute("experimental");
3899
+ if (experimental) attrs["experimental"] = experimental;
3900
+ const alpha = this.getAttribute("alpha");
3901
+ if (alpha) attrs["alpha"] = alpha;
3902
+ // picker-* overrides (except anchor, handled programmatically)
3903
+ for (const { name, value } of this.attributes) {
3904
+ if (name.startsWith("picker-") && name !== "picker-anchor") {
3905
+ attrs[name.slice(7)] = value;
3906
+ }
3907
+ }
3908
+ if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
3909
+ return Object.entries(attrs)
3910
+ .map(([k, v]) => `${k}="${v}"`)
3911
+ .join(" ");
3912
+ }
3913
+
3866
3914
  #render() {
3867
3915
  const disabled = this.hasAttribute("disabled");
3868
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
+ : "";
3869
3933
 
3870
3934
  let controlsHtml = "";
3871
3935
 
@@ -3879,17 +3943,7 @@ class FigInputFill extends HTMLElement {
3879
3943
  value="${this.#solid.color.slice(1).toUpperCase()}"
3880
3944
  ${disabled ? "disabled" : ""}>
3881
3945
  </fig-input-text>
3882
- <fig-tooltip text="Opacity">
3883
- <fig-input-number
3884
- class="fig-input-fill-opacity"
3885
- placeholder="##"
3886
- min="0"
3887
- max="100"
3888
- value="${Math.round(this.#solid.alpha * 100)}"
3889
- units="%"
3890
- ${disabled ? "disabled" : ""}>
3891
- </fig-input-number>
3892
- </fig-tooltip>`;
3946
+ ${opacityHtml(Math.round(this.#solid.alpha * 100))}`;
3893
3947
  break;
3894
3948
 
3895
3949
  case "gradient":
@@ -3898,75 +3952,34 @@ class FigInputFill extends HTMLElement {
3898
3952
  this.#gradient.type.slice(1);
3899
3953
  controlsHtml = `
3900
3954
  <label class="fig-input-fill-label">${gradientLabel}</label>
3901
- <fig-tooltip text="Opacity">
3902
- <fig-input-number
3903
- class="fig-input-fill-opacity"
3904
- placeholder="##"
3905
- min="0"
3906
- max="100"
3907
- value="${this.#gradient.stops[0]?.opacity ?? 100}"
3908
- units="%"
3909
- ${disabled ? "disabled" : ""}>
3910
- </fig-input-number>
3911
- </fig-tooltip>`;
3955
+ ${opacityHtml(this.#gradient.stops[0]?.opacity ?? 100)}`;
3912
3956
  break;
3913
3957
 
3914
3958
  case "image":
3915
3959
  controlsHtml = `
3916
3960
  <label class="fig-input-fill-label">Image</label>
3917
- <fig-tooltip text="Opacity">
3918
- <fig-input-number
3919
- class="fig-input-fill-opacity"
3920
- placeholder="##"
3921
- min="0"
3922
- max="100"
3923
- value="${Math.round((this.#image.opacity ?? 1) * 100)}"
3924
- units="%"
3925
- ${disabled ? "disabled" : ""}>
3926
- </fig-input-number>
3927
- </fig-tooltip>`;
3961
+ ${opacityHtml(Math.round((this.#image.opacity ?? 1) * 100))}`;
3928
3962
  break;
3929
3963
 
3930
3964
  case "video":
3931
3965
  controlsHtml = `
3932
3966
  <label class="fig-input-fill-label">Video</label>
3933
- <fig-tooltip text="Opacity">
3934
- <fig-input-number
3935
- class="fig-input-fill-opacity"
3936
- placeholder="##"
3937
- min="0"
3938
- max="100"
3939
- value="${Math.round((this.#video.opacity ?? 1) * 100)}"
3940
- units="%"
3941
- ${disabled ? "disabled" : ""}>
3942
- </fig-input-number>
3943
- </fig-tooltip>`;
3967
+ ${opacityHtml(Math.round((this.#video.opacity ?? 1) * 100))}`;
3944
3968
  break;
3945
3969
 
3946
3970
  case "webcam":
3947
3971
  controlsHtml = `
3948
3972
  <label class="fig-input-fill-label">Webcam</label>
3949
- <fig-tooltip text="Opacity">
3950
- <fig-input-number
3951
- class="fig-input-fill-opacity"
3952
- placeholder="##"
3953
- min="0"
3954
- max="100"
3955
- value="${Math.round((this.#webcam.opacity ?? 1) * 100)}"
3956
- units="%"
3957
- ${disabled ? "disabled" : ""}>
3958
- </fig-input-number>
3959
- </fig-tooltip>`;
3973
+ ${opacityHtml(Math.round((this.#webcam.opacity ?? 1) * 100))}`;
3960
3974
  break;
3961
3975
  }
3962
3976
 
3963
- const modeAttr = this.getAttribute("mode");
3964
- const experimentalAttr = this.getAttribute("experimental");
3977
+ const fpAttrs = this.#buildFillPickerAttrs();
3965
3978
  this.innerHTML = `
3966
3979
  <div class="input-combo">
3967
- <fig-fill-picker dialog-position="left" value='${fillPickerValue}' ${
3980
+ <fig-fill-picker ${fpAttrs} value='${fillPickerValue}' ${
3968
3981
  disabled ? "disabled" : ""
3969
- } ${modeAttr ? `mode="${modeAttr}"` : ""} ${experimentalAttr ? `experimental="${experimentalAttr}"` : ""}></fig-fill-picker>
3982
+ }></fig-fill-picker>
3970
3983
  ${controlsHtml}
3971
3984
  </div>`;
3972
3985
 
@@ -3991,6 +4004,14 @@ class FigInputFill extends HTMLElement {
3991
4004
  }
3992
4005
 
3993
4006
  if (this.#fillPicker) {
4007
+ const anchor = this.getAttribute("picker-anchor");
4008
+ if (!anchor || anchor === "self") {
4009
+ this.#fillPicker.anchorElement = this;
4010
+ } else {
4011
+ const el = document.querySelector(anchor);
4012
+ if (el) this.#fillPicker.anchorElement = el;
4013
+ }
4014
+
3994
4015
  this.#fillPicker.addEventListener("input", (e) => {
3995
4016
  e.stopPropagation();
3996
4017
  const detail = e.detail;
@@ -5108,6 +5129,14 @@ class FigImage extends HTMLElement {
5108
5129
  this.size = this.getAttribute("size") || "small";
5109
5130
  this.innerHTML = this.#getInnerHTML();
5110
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
+ }
5111
5140
  }
5112
5141
  disconnectedCallback() {
5113
5142
  this.fileInput?.removeEventListener("change", this.#boundHandleFileInput);
@@ -5144,10 +5173,13 @@ class FigImage extends HTMLElement {
5144
5173
  this.image.crossOrigin = "Anonymous";
5145
5174
  this.image.onload = async () => {
5146
5175
  this.aspectRatio = this.image.width / this.image.height;
5147
- this.style.setProperty(
5148
- "--aspect-ratio",
5149
- `${this.image.width}/${this.image.height}`
5150
- );
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
+ }
5151
5183
  this.dispatchEvent(
5152
5184
  new CustomEvent("loaded", {
5153
5185
  bubbles: true,
@@ -5219,7 +5251,7 @@ class FigImage extends HTMLElement {
5219
5251
  this.setAttribute("src", this.blob);
5220
5252
  }
5221
5253
  static get observedAttributes() {
5222
- return ["src", "upload"];
5254
+ return ["src", "upload", "aspect-ratio", "fit"];
5223
5255
  }
5224
5256
  get src() {
5225
5257
  return this.#src;
@@ -5251,10 +5283,665 @@ class FigImage extends HTMLElement {
5251
5283
  if (name === "size") {
5252
5284
  this.size = newValue;
5253
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
+ }
5254
5300
  }
5255
5301
  }
5256
5302
  customElements.define("fig-image", FigImage);
5257
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
+
5258
5945
  /**
5259
5946
  * A custom joystick input element.
5260
5947
  * @attr {string} value - The current position of the joystick (e.g., "0.5,0.5").
@@ -6223,6 +6910,7 @@ class FigFillPicker extends HTMLElement {
6223
6910
  #chit = null;
6224
6911
  #dialog = null;
6225
6912
  #activeTab = "solid";
6913
+ anchorElement = null;
6226
6914
 
6227
6915
  // Fill state
6228
6916
  #fillType = "solid";
@@ -6241,6 +6929,10 @@ class FigFillPicker extends HTMLElement {
6241
6929
  #video = { url: null, scaleMode: "fill", scale: 50 };
6242
6930
  #webcam = { stream: null, snapshot: null };
6243
6931
 
6932
+ // Custom mode slots and data
6933
+ #customSlots = {};
6934
+ #customData = {};
6935
+
6244
6936
  // DOM references for solid tab
6245
6937
  #colorArea = null;
6246
6938
  #colorAreaHandle = null;
@@ -6275,7 +6967,9 @@ class FigFillPicker extends HTMLElement {
6275
6967
  }
6276
6968
 
6277
6969
  #setupTrigger() {
6278
- const child = this.firstElementChild;
6970
+ const child = Array.from(this.children).find(
6971
+ (el) => !el.getAttribute("slot")?.startsWith("mode-")
6972
+ );
6279
6973
 
6280
6974
  if (!child) {
6281
6975
  // Scenario 1: Empty - create fig-chit
@@ -6315,6 +7009,8 @@ class FigFillPicker extends HTMLElement {
6315
7009
  const valueAttr = this.getAttribute("value");
6316
7010
  if (!valueAttr) return;
6317
7011
 
7012
+ const builtinTypes = ["solid", "gradient", "image", "video", "webcam"];
7013
+
6318
7014
  try {
6319
7015
  const parsed = JSON.parse(valueAttr);
6320
7016
  if (parsed.type) this.#fillType = parsed.type;
@@ -6337,6 +7033,12 @@ class FigFillPicker extends HTMLElement {
6337
7033
  this.#gradient = { ...this.#gradient, ...parsed.gradient };
6338
7034
  if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
6339
7035
  if (parsed.video) this.#video = { ...this.#video, ...parsed.video };
7036
+
7037
+ // Store full parsed data for custom (non-built-in) types
7038
+ if (parsed.type && !builtinTypes.includes(parsed.type)) {
7039
+ const { type, ...rest } = parsed;
7040
+ this.#customData[parsed.type] = rest;
7041
+ }
6340
7042
  } catch (e) {
6341
7043
  // If not JSON, treat as hex color
6342
7044
  if (valueAttr.startsWith("#")) {
@@ -6387,7 +7089,8 @@ class FigFillPicker extends HTMLElement {
6387
7089
  }
6388
7090
  break;
6389
7091
  default:
6390
- bg = "#D9D9D9";
7092
+ const slot = this.#customSlots[this.#fillType];
7093
+ bg = slot?.element?.getAttribute("chit-background") || "#D9D9D9";
6391
7094
  }
6392
7095
 
6393
7096
  this.#chit.setAttribute("background", bg);
@@ -6434,6 +7137,18 @@ class FigFillPicker extends HTMLElement {
6434
7137
  }
6435
7138
 
6436
7139
  #createDialog() {
7140
+ // Collect slotted custom mode content before any DOM changes
7141
+ this.#customSlots = {};
7142
+ this.querySelectorAll('[slot^="mode-"]').forEach((el) => {
7143
+ const modeName = el.getAttribute("slot").slice(5);
7144
+ this.#customSlots[modeName] = {
7145
+ element: el,
7146
+ label:
7147
+ el.getAttribute("label") ||
7148
+ modeName.charAt(0).toUpperCase() + modeName.slice(1),
7149
+ };
7150
+ });
7151
+
6437
7152
  this.#dialog = document.createElement("dialog", { is: "fig-popup" });
6438
7153
  this.#dialog.setAttribute("is", "fig-popup");
6439
7154
  this.#dialog.setAttribute("drag", "true");
@@ -6441,14 +7156,12 @@ class FigFillPicker extends HTMLElement {
6441
7156
  this.#dialog.setAttribute("autoresize", "false");
6442
7157
  this.#dialog.classList.add("fig-fill-picker-dialog");
6443
7158
 
6444
- this.#dialog.anchor = this.#trigger;
7159
+ this.#dialog.anchor = this.anchorElement || this.#trigger;
6445
7160
  const dialogPosition = this.getAttribute("dialog-position") || "left";
6446
7161
  this.#dialog.setAttribute("position", dialogPosition);
6447
7162
 
6448
- // Check for allowed modes (supports comma-separated values like "solid,gradient")
6449
- const mode = this.getAttribute("mode");
6450
- const allModes = ["solid", "gradient", "image", "video", "webcam"];
6451
- const modeLabels = {
7163
+ const builtinModes = ["solid", "gradient", "image", "video", "webcam"];
7164
+ const builtinLabels = {
6452
7165
  solid: "Solid",
6453
7166
  gradient: "Gradient",
6454
7167
  image: "Image",
@@ -6456,24 +7169,33 @@ class FigFillPicker extends HTMLElement {
6456
7169
  webcam: "Webcam",
6457
7170
  };
6458
7171
 
6459
- // Parse allowed modes
6460
- let allowedModes = allModes;
7172
+ // Build allowed modes: built-ins filtered normally, custom names accepted if slot exists
7173
+ const mode = this.getAttribute("mode");
7174
+ let allowedModes;
6461
7175
  if (mode) {
6462
- const requestedModes = mode.split(",").map((m) => m.trim().toLowerCase());
6463
- allowedModes = requestedModes.filter((m) => allModes.includes(m));
6464
- if (allowedModes.length === 0) allowedModes = allModes;
7176
+ const requested = mode.split(",").map((m) => m.trim().toLowerCase());
7177
+ allowedModes = requested.filter(
7178
+ (m) => builtinModes.includes(m) || this.#customSlots[m]
7179
+ );
7180
+ if (allowedModes.length === 0) allowedModes = [...builtinModes];
7181
+ } else {
7182
+ allowedModes = [...builtinModes];
7183
+ }
7184
+
7185
+ // Build labels map: built-in labels + custom slot labels
7186
+ const modeLabels = { ...builtinLabels };
7187
+ for (const [name, { label }] of Object.entries(this.#customSlots)) {
7188
+ modeLabels[name] = label;
6465
7189
  }
6466
7190
 
6467
- // If current fillType not in allowed modes, switch to first allowed
6468
7191
  if (!allowedModes.includes(this.#fillType)) {
6469
7192
  this.#fillType = allowedModes[0];
6470
7193
  this.#activeTab = allowedModes[0];
6471
7194
  }
6472
7195
 
6473
- // Build header content - label if single mode, dropdown if multiple
6474
7196
  const experimental = this.getAttribute("experimental");
6475
7197
  const expAttr = experimental ? `experimental="${experimental}"` : "";
6476
-
7198
+
6477
7199
  let headerContent;
6478
7200
  if (allowedModes.length === 1) {
6479
7201
  headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`;
@@ -6486,6 +7208,11 @@ class FigFillPicker extends HTMLElement {
6486
7208
  </fig-dropdown>`;
6487
7209
  }
6488
7210
 
7211
+ // Generate tab containers for all allowed modes
7212
+ const tabDivs = allowedModes
7213
+ .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
7214
+ .join("\n ");
7215
+
6489
7216
  this.#dialog.innerHTML = `
6490
7217
  <fig-header>
6491
7218
  ${headerContent}
@@ -6494,16 +7221,33 @@ class FigFillPicker extends HTMLElement {
6494
7221
  </fig-button>
6495
7222
  </fig-header>
6496
7223
  <div class="fig-fill-picker-content">
6497
- <div class="fig-fill-picker-tab" data-tab="solid"></div>
6498
- <div class="fig-fill-picker-tab" data-tab="gradient"></div>
6499
- <div class="fig-fill-picker-tab" data-tab="image"></div>
6500
- <div class="fig-fill-picker-tab" data-tab="video"></div>
6501
- <div class="fig-fill-picker-tab" data-tab="webcam"></div>
7224
+ ${tabDivs}
6502
7225
  </div>
6503
7226
  `;
6504
7227
 
6505
7228
  document.body.appendChild(this.#dialog);
6506
7229
 
7230
+ // Populate custom tab containers and emit modeready
7231
+ for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
7232
+ const container = this.#dialog.querySelector(
7233
+ `[data-tab="${modeName}"]`
7234
+ );
7235
+ if (!container) continue;
7236
+
7237
+ // Move children (not the element itself) for vanilla HTML usage
7238
+ while (element.firstChild) {
7239
+ container.appendChild(element.firstChild);
7240
+ }
7241
+
7242
+ // Emit modeready so frameworks can render into the container
7243
+ this.dispatchEvent(
7244
+ new CustomEvent("modeready", {
7245
+ bubbles: true,
7246
+ detail: { mode: modeName, container },
7247
+ })
7248
+ );
7249
+ }
7250
+
6507
7251
  // Setup type dropdown switching (only if not locked)
6508
7252
  const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
6509
7253
  if (typeDropdown) {
@@ -6518,34 +7262,50 @@ class FigFillPicker extends HTMLElement {
6518
7262
  this.#dialog.open = false;
6519
7263
  });
6520
7264
 
6521
- // Emit change on close
6522
7265
  this.#dialog.addEventListener("close", () => {
6523
7266
  this.#emitChange();
6524
7267
  });
6525
7268
 
6526
- // Initialize tabs
6527
- this.#initSolidTab();
6528
- this.#initGradientTab();
6529
- this.#initImageTab();
6530
- this.#initVideoTab();
6531
- this.#initWebcamTab();
6532
- }
6533
-
6534
- #switchTab(tabName) {
6535
- // Check for allowed modes - prevent switching to disallowed mode
6536
- const mode = this.getAttribute("mode");
6537
- const allModes = ["solid", "gradient", "image", "video", "webcam"];
6538
-
6539
- let allowedModes = allModes;
6540
- if (mode) {
6541
- const requestedModes = mode.split(",").map((m) => m.trim().toLowerCase());
6542
- allowedModes = requestedModes.filter((m) => allModes.includes(m));
6543
- if (allowedModes.length === 0) allowedModes = allModes;
7269
+ // Initialize built-in tabs (skip any overridden by custom slots)
7270
+ const builtinInits = {
7271
+ solid: () => this.#initSolidTab(),
7272
+ gradient: () => this.#initGradientTab(),
7273
+ image: () => this.#initImageTab(),
7274
+ video: () => this.#initVideoTab(),
7275
+ webcam: () => this.#initWebcamTab(),
7276
+ };
7277
+ for (const [name, init] of Object.entries(builtinInits)) {
7278
+ if (!this.#customSlots[name] && allowedModes.includes(name)) init();
6544
7279
  }
6545
7280
 
6546
- if (!allowedModes.includes(tabName)) {
6547
- return; // Don't allow switching to disallowed mode
7281
+ // Listen for input/change from custom tab content
7282
+ for (const modeName of Object.keys(this.#customSlots)) {
7283
+ if (builtinModes.includes(modeName)) continue;
7284
+ const container = this.#dialog.querySelector(
7285
+ `[data-tab="${modeName}"]`
7286
+ );
7287
+ if (!container) continue;
7288
+ container.addEventListener("input", (e) => {
7289
+ if (e.target === this) return;
7290
+ e.stopPropagation();
7291
+ if (e.detail) this.#customData[modeName] = e.detail;
7292
+ this.#emitInput();
7293
+ });
7294
+ container.addEventListener("change", (e) => {
7295
+ if (e.target === this) return;
7296
+ e.stopPropagation();
7297
+ if (e.detail) this.#customData[modeName] = e.detail;
7298
+ this.#emitChange();
7299
+ });
6548
7300
  }
7301
+ }
7302
+
7303
+ #switchTab(tabName) {
7304
+ // Only allow switching to modes that have a tab container in the dialog
7305
+ const tab = this.#dialog?.querySelector(
7306
+ `.fig-fill-picker-tab[data-tab="${tabName}"]`
7307
+ );
7308
+ if (!tab) return;
6549
7309
 
6550
7310
  this.#activeTab = tabName;
6551
7311
  this.#fillType = tabName;
@@ -6566,6 +7326,12 @@ class FigFillPicker extends HTMLElement {
6566
7326
  }
6567
7327
  });
6568
7328
 
7329
+ // Zero out content padding for custom mode tabs
7330
+ const contentEl = this.#dialog.querySelector(".fig-fill-picker-content");
7331
+ if (contentEl) {
7332
+ contentEl.style.padding = this.#customSlots[tabName] ? "0" : "";
7333
+ }
7334
+
6569
7335
  // Update tab-specific UI after visibility change
6570
7336
  if (tabName === "gradient") {
6571
7337
  // Use RAF to ensure layout is complete before updating angle input
@@ -6987,7 +7753,7 @@ class FigFillPicker extends HTMLElement {
6987
7753
  <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
6988
7754
  stop.position
6989
7755
  }" units="%"></fig-input-number>
6990
- <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" dialog-position="right" value="${
7756
+ <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${
6991
7757
  stop.color
6992
7758
  }"></fig-input-color>
6993
7759
  <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
@@ -7015,9 +7781,18 @@ class FigFillPicker extends HTMLElement {
7015
7781
  this.#emitInput();
7016
7782
  });
7017
7783
 
7018
- row
7019
- .querySelector(".fig-fill-picker-stop-color")
7020
- .addEventListener("input", (e) => {
7784
+ const stopColor = row.querySelector(".fig-fill-picker-stop-color");
7785
+ const stopFillPicker = stopColor.querySelector("fig-fill-picker");
7786
+ if (stopFillPicker) {
7787
+ stopFillPicker.anchorElement = this.#dialog;
7788
+ } else {
7789
+ requestAnimationFrame(() => {
7790
+ const fp = stopColor.querySelector("fig-fill-picker");
7791
+ if (fp) fp.anchorElement = this.#dialog;
7792
+ });
7793
+ }
7794
+
7795
+ stopColor.addEventListener("input", (e) => {
7021
7796
  this.#gradient.stops[index].color =
7022
7797
  e.target.hexOpaque || e.target.value;
7023
7798
  const parsedAlpha = parseFloat(e.target.alpha);
@@ -7711,7 +8486,7 @@ class FigFillPicker extends HTMLElement {
7711
8486
  image: { url: this.#webcam.snapshot, scaleMode: "fill", scale: 50 },
7712
8487
  };
7713
8488
  default:
7714
- return base;
8489
+ return { ...base, ...this.#customData[this.#fillType] };
7715
8490
  }
7716
8491
  }
7717
8492