@rogieking/figui3 2.36.0 → 2.38.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/fig.js CHANGED
@@ -2357,6 +2357,7 @@ customElements.define("fig-segmented-control", FigSegmentedControl);
2357
2357
  * @attr {number} max - The maximum value
2358
2358
  * @attr {number} step - The step increment
2359
2359
  * @attr {boolean} text - Whether to show a text input alongside the slider
2360
+ * @attr {string} placeholder - Placeholder for the number input when text is enabled
2360
2361
  * @attr {string} units - The units to display after the value
2361
2362
  * @attr {number} transform - A multiplier for the displayed value
2362
2363
  * @attr {boolean} disabled - Whether the slider is disabled
@@ -2364,6 +2365,7 @@ customElements.define("fig-segmented-control", FigSegmentedControl);
2364
2365
  */
2365
2366
  class FigSlider extends HTMLElement {
2366
2367
  #isInteracting = false;
2368
+ #showEmptyTextValue = false;
2367
2369
  // Private fields declarations
2368
2370
  #typeDefaults = {
2369
2371
  range: { min: 0, max: 100, step: 1 },
@@ -2405,7 +2407,7 @@ class FigSlider extends HTMLElement {
2405
2407
  }
2406
2408
 
2407
2409
  #regenerateInnerHTML() {
2408
- this.value = Number(this.getAttribute("value") || 0);
2410
+ const rawValue = this.getAttribute("value");
2409
2411
  this.type = this.getAttribute("type") || "range";
2410
2412
  this.variant = this.getAttribute("variant") || "default";
2411
2413
  this.text =
@@ -2416,13 +2418,25 @@ class FigSlider extends HTMLElement {
2416
2418
  this.precision = this.hasAttribute("precision")
2417
2419
  ? Number(this.getAttribute("precision"))
2418
2420
  : null;
2421
+ this.placeholder =
2422
+ this.getAttribute("placeholder") !== null
2423
+ ? this.getAttribute("placeholder")
2424
+ : "##";
2419
2425
 
2420
2426
  const defaults = this.#typeDefaults[this.type];
2421
2427
  this.min = Number(this.getAttribute("min") || defaults.min);
2422
2428
  this.max = Number(this.getAttribute("max") || defaults.max);
2423
2429
  this.step = Number(this.getAttribute("step") || defaults.step);
2424
2430
  this.color = this.getAttribute("color") || defaults?.color;
2425
- this.default = this.getAttribute("default") || this.min;
2431
+ this.default = this.hasAttribute("default")
2432
+ ? this.getAttribute("default")
2433
+ : this.type === "delta"
2434
+ ? 0
2435
+ : this.min;
2436
+ this.#showEmptyTextValue =
2437
+ rawValue === null ||
2438
+ (typeof rawValue === "string" && rawValue.trim() === "");
2439
+ this.value = this.#normalizeSliderValue(rawValue);
2426
2440
 
2427
2441
  if (this.color) {
2428
2442
  this.style.setProperty("--color", this.color);
@@ -2447,12 +2461,12 @@ class FigSlider extends HTMLElement {
2447
2461
  if (this.text) {
2448
2462
  html = `${slider}
2449
2463
  <fig-input-number
2450
- placeholder="##"
2464
+ placeholder="${this.placeholder}"
2451
2465
  min="${this.min}"
2452
2466
  max="${this.max}"
2453
2467
  transform="${this.transform}"
2454
2468
  step="${this.step}"
2455
- value="${this.value}"
2469
+ value="${this.#showEmptyTextValue ? "" : this.value}"
2456
2470
  ${this.units ? `units="${this.units}"` : ""}
2457
2471
  ${this.precision !== null ? `precision="${this.precision}"` : ""}>
2458
2472
  </fig-input-number>`;
@@ -2566,17 +2580,59 @@ class FigSlider extends HTMLElement {
2566
2580
 
2567
2581
  #handleTextInput() {
2568
2582
  if (this.figInputNumber) {
2569
- this.value = this.input.value = this.figInputNumber.value;
2570
- this.#syncProperties();
2583
+ const rawTextValue = this.figInputNumber.value;
2584
+ this.#showEmptyTextValue =
2585
+ rawTextValue === null ||
2586
+ rawTextValue === undefined ||
2587
+ (typeof rawTextValue === "string" && rawTextValue.trim() === "");
2588
+ const normalized = this.#normalizeSliderValue(rawTextValue);
2589
+ this.value = normalized;
2590
+ this.input.value = String(normalized);
2591
+ this.#syncValue();
2571
2592
  this.dispatchEvent(
2572
2593
  new CustomEvent("input", { detail: this.value, bubbles: true }),
2573
2594
  );
2574
2595
  }
2575
2596
  }
2576
2597
  #calculateNormal(value) {
2577
- let min = Number(this.min);
2578
- let max = Number(this.max);
2579
- return (Number(value) - min) / (max - min);
2598
+ const { min, max } = this.#getBounds();
2599
+ const range = max - min;
2600
+ if (range === 0) return 0;
2601
+ return (Number(value) - min) / range;
2602
+ }
2603
+ #toFiniteNumber(value) {
2604
+ if (value === null || value === undefined) return null;
2605
+ if (typeof value === "string" && value.trim() === "") return null;
2606
+ const parsed = Number(value);
2607
+ return Number.isFinite(parsed) ? parsed : null;
2608
+ }
2609
+ #getBounds() {
2610
+ let min = this.#toFiniteNumber(this.min);
2611
+ let max = this.#toFiniteNumber(this.max);
2612
+ if (min === null) min = 0;
2613
+ if (max === null) max = min;
2614
+ if (min > max) {
2615
+ [min, max] = [max, min];
2616
+ }
2617
+ return { min, max };
2618
+ }
2619
+ #clampToBounds(value) {
2620
+ const { min, max } = this.#getBounds();
2621
+ return Math.min(max, Math.max(min, value));
2622
+ }
2623
+ #getFallbackValue() {
2624
+ if (this.type === "delta") {
2625
+ const deltaDefault = this.#toFiniteNumber(this.default);
2626
+ if (deltaDefault !== null) return this.#clampToBounds(deltaDefault);
2627
+ return this.#clampToBounds(0);
2628
+ }
2629
+ const { min } = this.#getBounds();
2630
+ return min;
2631
+ }
2632
+ #normalizeSliderValue(rawValue) {
2633
+ const parsed = this.#toFiniteNumber(rawValue);
2634
+ if (parsed === null) return this.#getFallbackValue();
2635
+ return this.#clampToBounds(parsed);
2580
2636
  }
2581
2637
  #syncProperties() {
2582
2638
  let complete = this.#calculateNormal(this.value);
@@ -2592,11 +2648,15 @@ class FigSlider extends HTMLElement {
2592
2648
  // Update ARIA value
2593
2649
  this.input.setAttribute("aria-valuenow", val);
2594
2650
  if (this.figInputNumber) {
2595
- this.figInputNumber.setAttribute("value", val);
2651
+ this.figInputNumber.setAttribute(
2652
+ "value",
2653
+ this.#showEmptyTextValue ? "" : val,
2654
+ );
2596
2655
  }
2597
2656
  }
2598
2657
 
2599
2658
  #handleInput() {
2659
+ this.#showEmptyTextValue = false;
2600
2660
  this.#syncValue();
2601
2661
  this.dispatchEvent(
2602
2662
  new CustomEvent("input", { detail: this.value, bubbles: true }),
@@ -2605,6 +2665,7 @@ class FigSlider extends HTMLElement {
2605
2665
 
2606
2666
  #handleChange() {
2607
2667
  this.#isInteracting = false;
2668
+ this.#showEmptyTextValue = false;
2608
2669
  this.#syncValue();
2609
2670
  this.dispatchEvent(
2610
2671
  new CustomEvent("change", { detail: this.value, bubbles: true }),
@@ -2613,8 +2674,15 @@ class FigSlider extends HTMLElement {
2613
2674
 
2614
2675
  #handleTextChange() {
2615
2676
  if (this.figInputNumber) {
2616
- this.value = this.input.value = this.figInputNumber.value;
2617
- this.#syncProperties();
2677
+ const rawTextValue = this.figInputNumber.value;
2678
+ this.#showEmptyTextValue =
2679
+ rawTextValue === null ||
2680
+ rawTextValue === undefined ||
2681
+ (typeof rawTextValue === "string" && rawTextValue.trim() === "");
2682
+ const normalized = this.#normalizeSliderValue(rawTextValue);
2683
+ this.value = normalized;
2684
+ this.input.value = String(normalized);
2685
+ this.#syncValue();
2618
2686
  this.dispatchEvent(
2619
2687
  new CustomEvent("change", { detail: this.value, bubbles: true }),
2620
2688
  );
@@ -2634,6 +2702,7 @@ class FigSlider extends HTMLElement {
2634
2702
  "units",
2635
2703
  "transform",
2636
2704
  "text",
2705
+ "placeholder",
2637
2706
  "default",
2638
2707
  "precision",
2639
2708
  ];
@@ -2660,11 +2729,17 @@ class FigSlider extends HTMLElement {
2660
2729
  break;
2661
2730
  case "value":
2662
2731
  if (this.#isInteracting) break;
2663
- this.value = newValue;
2664
- this.input.value = newValue;
2665
- this.#syncProperties();
2732
+ this.#showEmptyTextValue =
2733
+ newValue === null ||
2734
+ (typeof newValue === "string" && newValue.trim() === "");
2735
+ this.value = this.#normalizeSliderValue(newValue);
2736
+ this.input.value = String(this.value);
2737
+ this.#syncValue();
2666
2738
  if (this.figInputNumber) {
2667
- this.figInputNumber.setAttribute("value", newValue);
2739
+ this.figInputNumber.setAttribute(
2740
+ "value",
2741
+ this.#showEmptyTextValue ? "" : this.value,
2742
+ );
2668
2743
  }
2669
2744
  break;
2670
2745
  case "transform":
@@ -2683,8 +2758,19 @@ class FigSlider extends HTMLElement {
2683
2758
  }
2684
2759
  }
2685
2760
  break;
2761
+ case "placeholder":
2762
+ this.placeholder = newValue !== null ? newValue : "##";
2763
+ if (this.figInputNumber) {
2764
+ this.figInputNumber.setAttribute("placeholder", this.placeholder);
2765
+ }
2766
+ break;
2686
2767
  case "default":
2687
- this.default = newValue;
2768
+ this.default =
2769
+ newValue !== null
2770
+ ? newValue
2771
+ : this.type === "delta"
2772
+ ? 0
2773
+ : this.min;
2688
2774
  this.#syncProperties();
2689
2775
  break;
2690
2776
  case "min":
@@ -6772,6 +6858,460 @@ class Fig3DRotate extends HTMLElement {
6772
6858
  }
6773
6859
  customElements.define("fig-3d-rotate", Fig3DRotate);
6774
6860
 
6861
+ /**
6862
+ * A transform-origin grid control with draggable handle.
6863
+ * @attr {string} value - CSS transform-origin pair, e.g. "50% 50%".
6864
+ * @attr {number} precision - Decimal places for percentage output (default 0).
6865
+ * @attr {boolean} drag - Enable handle dragging (default true).
6866
+ * @attr {boolean} fields - Show X/Y percentage fields when present/true (default false).
6867
+ */
6868
+ class FigOriginGrid extends HTMLElement {
6869
+ #x = 50;
6870
+ #y = 50;
6871
+ #precision = 0;
6872
+ #grid = null;
6873
+ #cells = [];
6874
+ #handle = null;
6875
+ #xInput = null;
6876
+ #yInput = null;
6877
+ #isDragging = false;
6878
+ #isSyncingValueAttr = false;
6879
+ #activePointerId = null;
6880
+ #boundHandlePointerMove = null;
6881
+ #boundHandlePointerEnd = null;
6882
+
6883
+ static SNAP_POINTS = [0, 16.6667, 33.3333, 50, 66.6667, 83.3333, 100];
6884
+
6885
+ static get observedAttributes() {
6886
+ return ["value", "precision", "aspect-ratio", "drag", "fields"];
6887
+ }
6888
+
6889
+ connectedCallback() {
6890
+ this.#precision = parseInt(this.getAttribute("precision") || "0");
6891
+ this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
6892
+ this.#applyIncomingValue(this.getAttribute("value"));
6893
+
6894
+ this.#render();
6895
+ this.#syncDragState();
6896
+ this.#syncValueAttribute();
6897
+ }
6898
+
6899
+ disconnectedCallback() {
6900
+ this.#isDragging = false;
6901
+ this.#detachHandleDragListeners();
6902
+ }
6903
+
6904
+ get value() {
6905
+ const p = this.#precision;
6906
+ return `${this.#x.toFixed(p)}% ${this.#y.toFixed(p)}%`;
6907
+ }
6908
+
6909
+ set value(v) {
6910
+ this.setAttribute("value", v);
6911
+ }
6912
+
6913
+ attributeChangedCallback(name, oldValue, newValue) {
6914
+ if (name === "aspect-ratio") {
6915
+ this.#syncAspectRatioVar(newValue);
6916
+ return;
6917
+ }
6918
+ if (name === "drag") {
6919
+ this.#syncDragState();
6920
+ return;
6921
+ }
6922
+ if (name === "fields") {
6923
+ this.#render();
6924
+ this.#syncDragState();
6925
+ this.#syncValueAttribute();
6926
+ return;
6927
+ }
6928
+ if (name === "precision") {
6929
+ this.#precision = parseInt(newValue || "0");
6930
+ this.#syncValueInputs();
6931
+ this.#syncValueAttribute();
6932
+ return;
6933
+ }
6934
+ if (name === "value") {
6935
+ if (this.#isSyncingValueAttr || this.#isDragging) return;
6936
+ this.#applyIncomingValue(newValue);
6937
+ this.#syncHandlePosition();
6938
+ this.#syncOverflowState();
6939
+ this.#syncValueInputs();
6940
+ }
6941
+ }
6942
+
6943
+ get #dragEnabled() {
6944
+ const attr = this.getAttribute("drag");
6945
+ return attr === null || attr.toLowerCase() !== "false";
6946
+ }
6947
+
6948
+ get #fieldsEnabled() {
6949
+ const attr = this.getAttribute("fields");
6950
+ if (attr === null) return false;
6951
+ return attr.toLowerCase() !== "false";
6952
+ }
6953
+
6954
+ #syncAspectRatioVar(value) {
6955
+ if (value && value.trim()) {
6956
+ this.style.setProperty("--aspect-ratio", value.trim());
6957
+ } else {
6958
+ this.style.removeProperty("--aspect-ratio");
6959
+ }
6960
+ }
6961
+
6962
+ #syncDragState() {
6963
+ if (!this.#grid) return;
6964
+ this.#grid.classList.toggle("drag-disabled", !this.#dragEnabled);
6965
+ }
6966
+
6967
+ #clampPercentage(value) {
6968
+ return Math.max(0, Math.min(100, value));
6969
+ }
6970
+
6971
+ #parseAxisValue(raw, axis) {
6972
+ const token = (raw || "").trim().toLowerCase();
6973
+ if (!token) return axis === "x" ? this.#x : this.#y;
6974
+
6975
+ const keywordMap =
6976
+ axis === "x"
6977
+ ? { left: 0, center: 50, right: 100 }
6978
+ : { top: 0, center: 50, bottom: 100 };
6979
+ if (token in keywordMap) return keywordMap[token];
6980
+
6981
+ const numeric = Number.parseFloat(token.replace("%", ""));
6982
+ if (Number.isFinite(numeric)) return numeric;
6983
+
6984
+ return axis === "x" ? this.#x : this.#y;
6985
+ }
6986
+
6987
+ #parseValue(value) {
6988
+ const parts = value
6989
+ .trim()
6990
+ .replace(/,/g, " ")
6991
+ .split(/\s+/)
6992
+ .filter(Boolean);
6993
+ if (parts.length < 1) return;
6994
+
6995
+ if (parts.length === 1) {
6996
+ const same = this.#parseAxisValue(parts[0], "x");
6997
+ this.#x = same;
6998
+ this.#y = same;
6999
+ return;
7000
+ }
7001
+
7002
+ this.#x = this.#parseAxisValue(parts[0], "x");
7003
+ this.#y = this.#parseAxisValue(parts[1], "y");
7004
+ }
7005
+
7006
+ #applyIncomingValue(value) {
7007
+ const normalized = typeof value === "string" ? value.trim() : "";
7008
+ if (!normalized) {
7009
+ this.#x = 50;
7010
+ this.#y = 50;
7011
+ return;
7012
+ }
7013
+ this.#parseValue(normalized);
7014
+ }
7015
+
7016
+ #render() {
7017
+ const cells = Array.from({ length: 9 }, (_, index) => {
7018
+ const col = index % 3;
7019
+ const row = Math.floor(index / 3);
7020
+ return `<span class="origin-grid-cell" data-col="${col}" data-row="${row}">
7021
+ <span class="origin-grid-dot"></span>
7022
+ </span>`;
7023
+ }).join("");
7024
+
7025
+ const xValue = this.#x.toFixed(this.#precision);
7026
+ const yValue = this.#y.toFixed(this.#precision);
7027
+ const fieldsMarkup = this.#fieldsEnabled
7028
+ ? `<div class="origin-values">
7029
+ <fig-input-number name="x" value="${xValue}" step="1" units="%"><span slot="prepend">X</span></fig-input-number>
7030
+ <fig-input-number name="y" value="${yValue}" step="1" units="%"><span slot="prepend">Y</span></fig-input-number>
7031
+ </div>`
7032
+ : "";
7033
+
7034
+ this.innerHTML = `<div class="fig-origin-grid-surface">
7035
+ <div class="origin-grid" aria-label="Transform origin grid">
7036
+ <div class="origin-grid-cells">${cells}</div>
7037
+ <span class="origin-grid-handle"></span>
7038
+ </div>
7039
+ </div>
7040
+ ${fieldsMarkup}`;
7041
+
7042
+ this.#grid = this.querySelector(".origin-grid");
7043
+ this.#cells = Array.from(this.querySelectorAll(".origin-grid-cell"));
7044
+ this.#handle = this.querySelector(".origin-grid-handle");
7045
+ this.#xInput = this.querySelector('fig-input-number[name="x"]');
7046
+ this.#yInput = this.querySelector('fig-input-number[name="y"]');
7047
+ this.#syncHandlePosition();
7048
+ this.#syncOverflowState();
7049
+ this.#syncValueInputs();
7050
+ this.#setupEvents();
7051
+ }
7052
+
7053
+ #syncValueInputs() {
7054
+ const xValue = this.#x.toFixed(this.#precision);
7055
+ const yValue = this.#y.toFixed(this.#precision);
7056
+ if (this.#xInput) {
7057
+ this.#xInput.setAttribute("value", xValue);
7058
+ }
7059
+ if (this.#yInput) {
7060
+ this.#yInput.setAttribute("value", yValue);
7061
+ }
7062
+ }
7063
+
7064
+ #syncValueAttribute() {
7065
+ const next = this.value;
7066
+ if (this.getAttribute("value") === next) return;
7067
+ this.#isSyncingValueAttr = true;
7068
+ this.setAttribute("value", next);
7069
+ this.#isSyncingValueAttr = false;
7070
+ }
7071
+
7072
+ #emit(type) {
7073
+ this.dispatchEvent(
7074
+ new CustomEvent(type, {
7075
+ bubbles: true,
7076
+ detail: {
7077
+ value: this.value,
7078
+ x: this.#x,
7079
+ y: this.#y,
7080
+ },
7081
+ }),
7082
+ );
7083
+ }
7084
+
7085
+ #syncHandlePosition() {
7086
+ if (!this.#handle) return;
7087
+ // Constrain draggable visual bounds to the 3x3 dot centers.
7088
+ const toVisual = (value) => 16.6667 + (this.#clampPercentage(value) / 100) * 66.6667;
7089
+ this.#handle.style.left = `${toVisual(this.#x)}%`;
7090
+ this.#handle.style.top = `${toVisual(this.#y)}%`;
7091
+ }
7092
+
7093
+ #syncOverflowState() {
7094
+ if (!this.#handle) return;
7095
+ const overflowX = this.#x < 0 || this.#x > 100;
7096
+ const overflowY = this.#y < 0 || this.#y > 100;
7097
+ const overflowLeft = this.#x < 0;
7098
+ const overflowRight = this.#x > 100;
7099
+ const overflowUp = this.#y < 0;
7100
+ const overflowDown = this.#y > 100;
7101
+
7102
+ this.#handle.classList.toggle("beyond-bounds-x", overflowX);
7103
+ this.#handle.classList.toggle("beyond-bounds-y", overflowY);
7104
+ this.#handle.classList.toggle("overflow-left", overflowLeft);
7105
+ this.#handle.classList.toggle("overflow-right", overflowRight);
7106
+ this.#handle.classList.toggle("overflow-up", overflowUp);
7107
+ this.#handle.classList.toggle("overflow-down", overflowDown);
7108
+ }
7109
+
7110
+ #gridCellFromClient(clientX, clientY) {
7111
+ if (!this.#grid) return null;
7112
+ const rect = this.#grid.getBoundingClientRect();
7113
+ const nx = (clientX - rect.left) / Math.max(rect.width, 1);
7114
+ const ny = (clientY - rect.top) / Math.max(rect.height, 1);
7115
+ if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return null;
7116
+ const col = Math.max(0, Math.min(2, Math.floor(nx * 3)));
7117
+ const row = Math.max(0, Math.min(2, Math.floor(ny * 3)));
7118
+ return this.#cells.find(
7119
+ (cell) =>
7120
+ Number(cell.getAttribute("data-col")) === col &&
7121
+ Number(cell.getAttribute("data-row")) === row,
7122
+ );
7123
+ }
7124
+
7125
+ #clearHoveredCells() {
7126
+ for (const cell of this.#cells) {
7127
+ cell.classList.remove("is-hovered");
7128
+ }
7129
+ }
7130
+
7131
+ #setHoveredCell(cell) {
7132
+ this.#clearHoveredCells();
7133
+ if (cell) cell.classList.add("is-hovered");
7134
+ }
7135
+
7136
+ #setFromPercent(x, y, eventType) {
7137
+ const nextX = Number(x);
7138
+ const nextY = Number(y);
7139
+ if (!Number.isFinite(nextX) || !Number.isFinite(nextY)) return;
7140
+ if (nextX === this.#x && nextY === this.#y && eventType === "input") return;
7141
+
7142
+ this.#x = nextX;
7143
+ this.#y = nextY;
7144
+ this.#syncHandlePosition();
7145
+ this.#syncOverflowState();
7146
+ this.#syncValueInputs();
7147
+ this.#syncValueAttribute();
7148
+ this.#emit(eventType);
7149
+ }
7150
+
7151
+ #clientToPercent(clientX, clientY) {
7152
+ if (!this.#grid) return { x: this.#x, y: this.#y };
7153
+ const rect = this.#grid.getBoundingClientRect();
7154
+ const insetX = rect.width / 6;
7155
+ const insetY = rect.height / 6;
7156
+ const usableWidth = Math.max(1, rect.width - insetX * 2);
7157
+ const usableHeight = Math.max(1, rect.height - insetY * 2);
7158
+ const nx = (clientX - (rect.left + insetX)) / usableWidth;
7159
+ const ny = (clientY - (rect.top + insetY)) / usableHeight;
7160
+ return {
7161
+ x: nx * 100,
7162
+ y: ny * 100,
7163
+ };
7164
+ }
7165
+
7166
+ #cellCenterFromClient(clientX, clientY) {
7167
+ if (!this.#grid) return { x: 50, y: 50 };
7168
+ const rect = this.#grid.getBoundingClientRect();
7169
+ const colRaw = ((clientX - rect.left) / Math.max(rect.width, 1)) * 3;
7170
+ const rowRaw = ((clientY - rect.top) / Math.max(rect.height, 1)) * 3;
7171
+ const col = Math.max(0, Math.min(2, Math.floor(colRaw)));
7172
+ const row = Math.max(0, Math.min(2, Math.floor(rowRaw)));
7173
+ return {
7174
+ x: col * 50,
7175
+ y: row * 50,
7176
+ };
7177
+ }
7178
+
7179
+ #snapPercentage(value) {
7180
+ const threshold = 2.5;
7181
+ let closest = value;
7182
+ let closestDistance = Infinity;
7183
+ for (const point of FigOriginGrid.SNAP_POINTS) {
7184
+ const distance = Math.abs(value - point);
7185
+ if (distance < closestDistance) {
7186
+ closestDistance = distance;
7187
+ closest = point;
7188
+ }
7189
+ }
7190
+ return closestDistance <= threshold ? closest : value;
7191
+ }
7192
+
7193
+ #maybeSnapPoint(point, shiftKey) {
7194
+ if (!shiftKey) return point;
7195
+ return {
7196
+ x: this.#snapPercentage(point.x),
7197
+ y: this.#snapPercentage(point.y),
7198
+ };
7199
+ }
7200
+
7201
+ #detachHandleDragListeners() {
7202
+ if (!this.#grid || !this.#boundHandlePointerMove || !this.#boundHandlePointerEnd) return;
7203
+ this.#grid.removeEventListener("pointermove", this.#boundHandlePointerMove);
7204
+ this.#grid.removeEventListener("pointerup", this.#boundHandlePointerEnd);
7205
+ this.#grid.removeEventListener("pointercancel", this.#boundHandlePointerEnd);
7206
+ this.#grid.removeEventListener(
7207
+ "lostpointercapture",
7208
+ this.#boundHandlePointerEnd,
7209
+ );
7210
+ this.#boundHandlePointerMove = null;
7211
+ this.#boundHandlePointerEnd = null;
7212
+ }
7213
+
7214
+ #startGridDrag(e) {
7215
+ if (!this.#grid || !this.#dragEnabled) return;
7216
+ e.preventDefault();
7217
+ this.#isDragging = true;
7218
+ this.#activePointerId = e.pointerId;
7219
+ const startClientX = e.clientX;
7220
+ const startClientY = e.clientY;
7221
+ const dragThresholdPx = 3;
7222
+ let didDrag = false;
7223
+ this.#grid.setPointerCapture(e.pointerId);
7224
+
7225
+ this.#boundHandlePointerMove = (moveEvent) => {
7226
+ if (!this.#isDragging || moveEvent.pointerId !== this.#activePointerId) return;
7227
+ const dx = moveEvent.clientX - startClientX;
7228
+ const dy = moveEvent.clientY - startClientY;
7229
+ const distance = Math.hypot(dx, dy);
7230
+ if (!didDrag && distance < dragThresholdPx) return;
7231
+ if (!didDrag) {
7232
+ didDrag = true;
7233
+ this.#grid.classList.add("is-dragging");
7234
+ this.#clearHoveredCells();
7235
+ }
7236
+ const nextPoint = this.#maybeSnapPoint(
7237
+ this.#clientToPercent(moveEvent.clientX, moveEvent.clientY),
7238
+ moveEvent.shiftKey,
7239
+ );
7240
+ this.#setFromPercent(nextPoint.x, nextPoint.y, "input");
7241
+ };
7242
+
7243
+ this.#boundHandlePointerEnd = (endEvent) => {
7244
+ if (!this.#isDragging || endEvent.pointerId !== this.#activePointerId) return;
7245
+ this.#isDragging = false;
7246
+ this.#activePointerId = null;
7247
+ this.#grid.classList.remove("is-dragging");
7248
+ this.#clearHoveredCells();
7249
+ this.#detachHandleDragListeners();
7250
+ if (!didDrag) {
7251
+ // Click behavior: snap to the center of the clicked cell.
7252
+ const center = this.#cellCenterFromClient(startClientX, startClientY);
7253
+ this.#setFromPercent(center.x, center.y, "input");
7254
+ }
7255
+ this.#emit("change");
7256
+ };
7257
+
7258
+ this.#grid.addEventListener("pointermove", this.#boundHandlePointerMove);
7259
+ this.#grid.addEventListener("pointerup", this.#boundHandlePointerEnd);
7260
+ this.#grid.addEventListener("pointercancel", this.#boundHandlePointerEnd);
7261
+ this.#grid.addEventListener("lostpointercapture", this.#boundHandlePointerEnd);
7262
+ }
7263
+
7264
+ #setupEvents() {
7265
+ if (!this.#grid || !this.#handle) return;
7266
+
7267
+ this.#grid.addEventListener("pointerdown", (e) => {
7268
+ const hovered = this.#gridCellFromClient(e.clientX, e.clientY);
7269
+ this.#setHoveredCell(hovered);
7270
+
7271
+ if (this.#dragEnabled) {
7272
+ this.#startGridDrag(e);
7273
+ return;
7274
+ }
7275
+
7276
+ const center = this.#cellCenterFromClient(e.clientX, e.clientY);
7277
+ this.#setFromPercent(center.x, center.y, "input");
7278
+ this.#emit("change");
7279
+ });
7280
+
7281
+ this.#grid.addEventListener("pointermove", (e) => {
7282
+ if (this.#isDragging) return;
7283
+ const hovered = this.#gridCellFromClient(e.clientX, e.clientY);
7284
+ this.#setHoveredCell(hovered);
7285
+ });
7286
+
7287
+ this.#grid.addEventListener("pointerleave", () => {
7288
+ this.#clearHoveredCells();
7289
+ });
7290
+
7291
+ const bindValueInput = (inputEl, axis) => {
7292
+ if (!inputEl) return;
7293
+ const handle = (e) => {
7294
+ const next = Number.parseFloat(e.target.value);
7295
+ if (!Number.isFinite(next)) return;
7296
+ if (axis === "x") {
7297
+ this.#setFromPercent(next, this.#y, "input");
7298
+ } else {
7299
+ this.#setFromPercent(this.#x, next, "input");
7300
+ }
7301
+ };
7302
+ inputEl.addEventListener("input", handle);
7303
+ inputEl.addEventListener("change", handle);
7304
+ inputEl.addEventListener("focusout", () => {
7305
+ this.#emit("change");
7306
+ });
7307
+ };
7308
+
7309
+ bindValueInput(this.#xInput, "x");
7310
+ bindValueInput(this.#yInput, "y");
7311
+ }
7312
+ }
7313
+ customElements.define("fig-origin-grid", FigOriginGrid);
7314
+
6775
7315
  /**
6776
7316
  * A custom joystick input element.
6777
7317
  * @attr {string} value - The current position of the joystick (e.g., "0.5,0.5").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "2.36.0",
3
+ "version": "2.38.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",