@rogieking/figui3 2.37.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
@@ -6858,6 +6858,460 @@ class Fig3DRotate extends HTMLElement {
6858
6858
  }
6859
6859
  customElements.define("fig-3d-rotate", Fig3DRotate);
6860
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
+
6861
7315
  /**
6862
7316
  * A custom joystick input element.
6863
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.37.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",