@rogieking/figui3 3.17.0 → 3.18.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
@@ -13343,6 +13343,640 @@ class FigChooser extends HTMLElement {
13343
13343
  }
13344
13344
  customElements.define("fig-chooser", FigChooser);
13345
13345
 
13346
+ /* Canvas Point */
13347
+ class FigCanvasPoint extends HTMLElement {
13348
+ static observedAttributes = [
13349
+ "type",
13350
+ "value",
13351
+ "color",
13352
+ "name",
13353
+ "tooltips",
13354
+ "disabled",
13355
+ "drag-surface",
13356
+ "snapping",
13357
+ ];
13358
+
13359
+ #x = 50;
13360
+ #y = 50;
13361
+ #radius = 0;
13362
+ #radiusIsPercent = false;
13363
+ #angle = 0;
13364
+ #pointHandle = null;
13365
+ #angleHandle = null;
13366
+ #radiusSvg = null;
13367
+ #angleSvg = null;
13368
+ #pointTooltip = null;
13369
+ #radiusTooltip = null;
13370
+ #angleTooltip = null;
13371
+ #isDragging = false;
13372
+ #isRadiusDragging = false;
13373
+ #isAngleDragging = false;
13374
+
13375
+ get #type() {
13376
+ return this.getAttribute("type") || "point";
13377
+ }
13378
+
13379
+ get #hasRadius() {
13380
+ return this.#type === "point-radius" || this.#type === "point-radius-angle";
13381
+ }
13382
+
13383
+ get #hasAngle() {
13384
+ return this.#type === "point-radius-angle";
13385
+ }
13386
+
13387
+ get #tooltipsEnabled() {
13388
+ const v = this.getAttribute("tooltips");
13389
+ return v === null || v !== "false";
13390
+ }
13391
+
13392
+ get #snappingMode() {
13393
+ const raw = this.getAttribute("snapping");
13394
+ if (raw === null) return "false";
13395
+ const n = raw.trim().toLowerCase();
13396
+ if (n === "modifier") return "modifier";
13397
+ if (n === "" || n === "true") return "true";
13398
+ return "false";
13399
+ }
13400
+
13401
+ #shouldSnap(shiftKey) {
13402
+ const mode = this.#snappingMode;
13403
+ if (mode === "true") return true;
13404
+ if (mode === "modifier") return !!shiftKey;
13405
+ return false;
13406
+ }
13407
+
13408
+ get #pointTipText() {
13409
+ const name = this.getAttribute("name");
13410
+ if (name) return name;
13411
+ return `${Math.round(this.#x)}%, ${Math.round(this.#y)}%`;
13412
+ }
13413
+
13414
+ get #dragSurface() {
13415
+ return this.getAttribute("drag-surface") || "parent";
13416
+ }
13417
+
13418
+ get #container() {
13419
+ const surface = this.#dragSurface;
13420
+ if (surface === "parent") return this.parentElement;
13421
+ return this.closest(surface);
13422
+ }
13423
+
13424
+ get #handleDragSurface() {
13425
+ const surface = this.#dragSurface;
13426
+ if (surface === "parent") {
13427
+ const container = this.parentElement;
13428
+ if (container) {
13429
+ container.setAttribute("data-fig-canvas-point-surface", "");
13430
+ return "[data-fig-canvas-point-surface]";
13431
+ }
13432
+ }
13433
+ return surface;
13434
+ }
13435
+
13436
+ #resolveRadius(containerWidth) {
13437
+ if (this.#radiusIsPercent) return (this.#radius / 100) * containerWidth;
13438
+ return this.#radius;
13439
+ }
13440
+
13441
+ #formatRadius() {
13442
+ if (this.#radiusIsPercent) return `${Math.round(this.#radius)}%`;
13443
+ return `${Math.round(this.#radius)}px`;
13444
+ }
13445
+
13446
+ connectedCallback() {
13447
+ this.#parseValue();
13448
+ this.#render();
13449
+ }
13450
+
13451
+ disconnectedCallback() {
13452
+ this.#teardownRadiusDrag();
13453
+ }
13454
+
13455
+ attributeChangedCallback(name, oldVal, newVal) {
13456
+ if (oldVal === newVal) return;
13457
+ if (name === "value" && !this.#isDragging && !this.#isRadiusDragging && !this.#isAngleDragging) {
13458
+ this.#parseValue();
13459
+ if (this.#pointHandle) this.#syncPositions();
13460
+ else this.#render();
13461
+ }
13462
+ if (name === "type") {
13463
+ this.#parseValue();
13464
+ this.#render();
13465
+ }
13466
+ if (name === "color" && this.#pointHandle) {
13467
+ if (newVal) this.#pointHandle.setAttribute("color", newVal);
13468
+ else this.#pointHandle.removeAttribute("color");
13469
+ }
13470
+ if (name === "disabled") {
13471
+ this.#render();
13472
+ }
13473
+ if (name === "tooltips") {
13474
+ this.#render();
13475
+ }
13476
+ if (name === "snapping" && this.#pointHandle) {
13477
+ this.#pointHandle.setAttribute("drag-snapping", newVal || "false");
13478
+ }
13479
+ if (name === "name" && this.#pointTooltip) {
13480
+ this.#pointTooltip.setAttribute("text", this.#pointTipText);
13481
+ }
13482
+ }
13483
+
13484
+ #parseValue() {
13485
+ const raw = this.getAttribute("value");
13486
+ if (!raw) return;
13487
+ try {
13488
+ const v = JSON.parse(raw);
13489
+ if (typeof v.x === "number") this.#x = v.x;
13490
+ if (typeof v.y === "number") this.#y = v.y;
13491
+ if (v.radius !== undefined) {
13492
+ const rs = String(v.radius);
13493
+ if (rs.endsWith("%")) {
13494
+ this.#radiusIsPercent = true;
13495
+ this.#radius = parseFloat(rs);
13496
+ } else {
13497
+ this.#radiusIsPercent = false;
13498
+ this.#radius = parseFloat(rs);
13499
+ }
13500
+ if (!Number.isFinite(this.#radius)) this.#radius = 0;
13501
+ }
13502
+ if (typeof v.angle === "number") this.#angle = v.angle;
13503
+ } catch { /* ignore */ }
13504
+ }
13505
+
13506
+ get value() {
13507
+ const v = { x: this.#x, y: this.#y };
13508
+ if (this.#hasRadius) {
13509
+ v.radius = this.#radiusIsPercent ? `${this.#radius}%` : this.#radius;
13510
+ }
13511
+ if (this.#hasAngle) v.angle = this.#angle;
13512
+ return v;
13513
+ }
13514
+
13515
+ set value(val) {
13516
+ if (typeof val === "object") {
13517
+ this.setAttribute("value", JSON.stringify(val));
13518
+ } else if (typeof val === "string") {
13519
+ this.setAttribute("value", val);
13520
+ }
13521
+ }
13522
+
13523
+ #render() {
13524
+ this.innerHTML = "";
13525
+ this.#pointHandle = null;
13526
+ this.#angleHandle = null;
13527
+ this.#radiusSvg = null;
13528
+ this.#angleSvg = null;
13529
+ this.#pointTooltip = null;
13530
+ this.#radiusTooltip = null;
13531
+ this.#angleTooltip = null;
13532
+
13533
+ const disabled = this.hasAttribute("disabled");
13534
+ const type = this.#type;
13535
+ const tooltips = this.#tooltipsEnabled;
13536
+
13537
+ const handleSurface = this.#handleDragSurface;
13538
+
13539
+ const handle = document.createElement("fig-handle");
13540
+ handle.setAttribute("drag", "true");
13541
+ handle.setAttribute("drag-surface", handleSurface);
13542
+ handle.setAttribute("drag-axes", "x,y");
13543
+ handle.setAttribute("drag-snapping", this.#snappingMode);
13544
+ handle.setAttribute("value", `${this.#x}% ${this.#y}%`);
13545
+ if (disabled) handle.setAttribute("disabled", "");
13546
+ if (type === "color") {
13547
+ handle.setAttribute("type", "color");
13548
+ const color = this.getAttribute("color");
13549
+ if (color) handle.setAttribute("color", color);
13550
+ }
13551
+ this.#pointHandle = handle;
13552
+
13553
+ if (this.#hasRadius) {
13554
+ this.#createRadiusSvg();
13555
+ }
13556
+
13557
+ if (this.#hasAngle) {
13558
+ this.#createAngleSvg();
13559
+ }
13560
+
13561
+ if (tooltips) {
13562
+ const tip = document.createElement("fig-tooltip");
13563
+ tip.setAttribute("action", "manual");
13564
+ tip.setAttribute("text", this.#pointTipText);
13565
+ tip.appendChild(handle);
13566
+ this.appendChild(tip);
13567
+ this.#pointTooltip = tip;
13568
+ } else {
13569
+ this.appendChild(handle);
13570
+ }
13571
+
13572
+ if (this.#hasAngle) {
13573
+ this.#createAngleHandle(disabled, tooltips, handleSurface);
13574
+ }
13575
+
13576
+ this.#setupEventListeners();
13577
+ requestAnimationFrame(() => this.#syncPositions());
13578
+ }
13579
+
13580
+ #createRadiusSvg() {
13581
+ const ns = "http://www.w3.org/2000/svg";
13582
+ const svg = document.createElementNS(ns, "svg");
13583
+ svg.classList.add("fig-canvas-point-radius");
13584
+ svg.setAttribute("overflow", "visible");
13585
+ const hitCircle = document.createElementNS(ns, "circle");
13586
+ hitCircle.classList.add("fig-canvas-point-radius-hit");
13587
+ svg.appendChild(hitCircle);
13588
+ const circle = document.createElementNS(ns, "circle");
13589
+ svg.appendChild(circle);
13590
+ this.#radiusSvg = svg;
13591
+
13592
+ if (this.#tooltipsEnabled) {
13593
+ const tip = document.createElement("fig-tooltip");
13594
+ tip.setAttribute("action", "manual");
13595
+ tip.setAttribute("text", this.#formatRadius());
13596
+ tip.appendChild(svg);
13597
+ this.appendChild(tip);
13598
+ this.#radiusTooltip = tip;
13599
+ } else {
13600
+ this.appendChild(svg);
13601
+ }
13602
+
13603
+ this.#setupRadiusDrag(hitCircle);
13604
+ }
13605
+
13606
+ #createAngleSvg() {
13607
+ const ns = "http://www.w3.org/2000/svg";
13608
+ const svg = document.createElementNS(ns, "svg");
13609
+ svg.classList.add("fig-canvas-point-angle-svg");
13610
+ svg.setAttribute("overflow", "visible");
13611
+ svg.style.position = "absolute";
13612
+ svg.style.pointerEvents = "none";
13613
+ const line = document.createElementNS(ns, "line");
13614
+ line.classList.add("fig-canvas-point-angle-line");
13615
+ svg.appendChild(line);
13616
+ this.#angleSvg = svg;
13617
+ this.appendChild(svg);
13618
+ }
13619
+
13620
+ #createAngleHandle(disabled, tooltips, handleSurface) {
13621
+ const handle = document.createElement("fig-handle");
13622
+ handle.setAttribute("drag", "true");
13623
+ handle.setAttribute("drag-surface", handleSurface);
13624
+ handle.setAttribute("drag-axes", "x,y");
13625
+ handle.setAttribute("size", "small");
13626
+ handle.setAttribute("hit-area", "12 circle");
13627
+ handle.setAttribute("hit-area-mode", "delegate");
13628
+ if (disabled) handle.setAttribute("disabled", "");
13629
+ this.#angleHandle = handle;
13630
+
13631
+ if (tooltips) {
13632
+ const tip = document.createElement("fig-tooltip");
13633
+ tip.setAttribute("action", "manual");
13634
+ tip.setAttribute("text", `${Math.round(this.#angle)}°`);
13635
+ tip.appendChild(handle);
13636
+ this.appendChild(tip);
13637
+ this.#angleTooltip = tip;
13638
+ } else {
13639
+ this.appendChild(handle);
13640
+ }
13641
+ }
13642
+
13643
+ #resizeCursorSvg(deg) {
13644
+ const r = Math.round(deg);
13645
+ return `url("data:image/svg+xml,%3Csvg width='32' height='32' viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform='rotate(${r} 16 16)'%3E%3Cg filter='url(%23f)'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11.1212 16.9998L11.5607 17.4394C12.1465 18.0252 12.1464 18.975 11.5606 19.5607C10.9748 20.1465 10.0251 20.1465 9.4393 19.5606L6.4393 16.5604C5.85354 15.9746 5.85357 15.0249 6.43938 14.4391L9.43938 11.4393C10.0252 10.8535 10.9749 10.8536 11.5607 11.4394C12.1465 12.0252 12.1464 12.9749 11.5606 13.5607L11.1215 13.9998L20.8786 13.9999L20.4394 13.5607C19.8536 12.9749 19.8535 12.0252 20.4393 11.4394C21.0251 10.8536 21.9749 10.8536 22.5606 11.4394L25.5606 14.4393C25.842 14.7206 26 15.1021 26 15.4999C26 15.8978 25.842 16.2793 25.5607 16.5606L22.5607 19.5607C21.9749 20.1465 21.0251 20.1465 20.4393 19.5607C19.8536 18.9749 19.8535 18.0252 20.4393 17.4394L20.8788 16.9999L11.1212 16.9998Z' fill='white'/%3E%3C/g%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10.8536 12.1465C11.0488 12.3417 11.0488 12.6583 10.8535 12.8536L8.70715 14.9998L23.2929 14.9999L21.1465 12.8536C20.9512 12.6583 20.9512 12.3417 21.1464 12.1465C21.3417 11.9512 21.6583 11.9512 21.8535 12.1465L24.8535 15.1464C24.9473 15.2402 25 15.3673 25 15.4999C25 15.6326 24.9473 15.7597 24.8536 15.8535L21.8536 18.8536C21.6583 19.0488 21.3417 19.0488 21.1465 18.8536C20.9512 18.6583 20.9512 18.3417 21.1464 18.1465L23.2929 15.9999L8.70705 15.9998L10.8536 18.1465C11.0488 18.3417 11.0488 18.6583 10.8535 18.8536C10.6583 19.0488 10.3417 19.0488 10.1464 18.8535L7.14643 15.8533C6.95118 15.658 6.95119 15.3415 7.14646 15.1462L10.1465 12.1464C10.3417 11.9512 10.6583 11.9512 10.8536 12.1465Z' fill='black'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='f' x='3' y='9' width='26' height='15' filterUnits='userSpaceOnUse' color-interpolation-filters='sRGB'%3E%3CfeFlood flood-opacity='0' result='a'/%3E%3CfeColorMatrix in='SourceAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' result='b'/%3E%3CfeOffset dy='1'/%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0'/%3E%3CfeBlend in2='a' result='c'/%3E%3CfeBlend in='SourceGraphic' in2='c'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E") 16 16, nwse-resize`;
13646
+ }
13647
+
13648
+ #rotateCursorSvg(deg) {
13649
+ const r = Math.round(deg - 45);
13650
+ return `url("data:image/svg+xml,%3Csvg width='32' height='32' viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform='rotate(${r} 16 16)'%3E%3Cg filter='url(%23f)'%3E%3Cpath d='M12.5607 22.4393L12.0216 21.9002C17.1558 21.2216 21.2216 17.1558 21.9002 12.0216L22.4393 12.5607C23.0251 13.1464 23.9749 13.1464 24.5607 12.5607C25.1464 11.9749 25.1464 11.0251 24.5607 10.4393L21.5607 7.43934C20.9749 6.85355 20.0251 6.85355 19.4393 7.43934L16.4393 10.4393C15.8536 11.0251 15.8536 11.9749 16.4393 12.5607C17.0251 13.1464 17.9749 13.1464 18.5607 12.5607L18.8056 12.3157C18.1013 15.5527 15.5527 18.1013 12.3157 18.8056L12.5607 18.5607C13.1464 17.9749 13.1464 17.0251 12.5607 16.4393C11.9749 15.8536 11.0251 15.8536 10.4393 16.4393L7.43934 19.4393C6.85356 20.0251 6.85356 20.9749 7.43934 21.5607L10.4393 24.5607C11.0251 25.1464 11.9749 25.1464 12.5607 24.5607C13.1464 23.9749 13.1464 23.0251 12.5607 22.4393Z' fill='white'/%3E%3C/g%3E%3Cpath d='M23.8536 11.8536C23.6583 12.0488 23.3417 12.0488 23.1464 11.8536L21 9.70711V10.5C21 16.299 16.299 21 10.5 21H9.70711L11.8536 23.1464C12.0488 23.3417 12.0488 23.6583 11.8536 23.8536C11.6583 24.0488 11.3417 24.0488 11.1464 23.8536L8.14645 20.8536C7.95119 20.6583 7.95119 20.3417 8.14645 20.1464L11.1464 17.1464C11.3417 16.9512 11.6583 16.9512 11.8536 17.1464C12.0488 17.3417 12.0488 17.6583 11.8536 17.8536L9.70711 20H10.5C15.7467 20 20 15.7467 20 10.5V9.70711L17.8536 11.8536C17.6583 12.0488 17.3417 12.0488 17.1464 11.8536C16.9512 11.6583 16.9512 11.3417 17.1464 11.1464L20.1464 8.14645C20.3417 7.95119 20.6583 7.95119 20.8536 8.14645L23.8536 11.1464C24.0488 11.3417 24.0488 11.6583 23.8536 11.8536Z' fill='black'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='f' x='4' y='5' width='24' height='24' filterUnits='userSpaceOnUse' color-interpolation-filters='sRGB'%3E%3CfeFlood flood-opacity='0' result='a'/%3E%3CfeColorMatrix in='SourceAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' result='b'/%3E%3CfeOffset dy='1'/%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0'/%3E%3CfeBlend in2='a' result='c'/%3E%3CfeBlend in='SourceGraphic' in2='c'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E") 16 16, pointer`;
13651
+ }
13652
+
13653
+ #syncAngleCursor() {
13654
+ if (!this.#angleHandle || !this.#hasAngle) return;
13655
+ const hitArea = this.#angleHandle.querySelector(".fig-handle-hit-area");
13656
+ if (!hitArea) return;
13657
+ hitArea.style.cursor = this.#rotateCursorSvg(this.#angle);
13658
+ }
13659
+
13660
+ #syncPositions() {
13661
+ const container = this.#container;
13662
+ if (!container || !this.#pointHandle) return;
13663
+ const rect = container.getBoundingClientRect();
13664
+
13665
+ this.#pointHandle.setAttribute("value", `${this.#x}% ${this.#y}%`);
13666
+
13667
+ if (this.#radiusSvg) {
13668
+ const cx = (this.#x / 100) * rect.width;
13669
+ const cy = (this.#y / 100) * rect.height;
13670
+ const r = this.#resolveRadius(rect.width);
13671
+ const svg = this.#radiusSvg;
13672
+ const d = Math.max(r * 2, 1);
13673
+ svg.style.position = "absolute";
13674
+ svg.style.width = `${d}px`;
13675
+ svg.style.height = `${d}px`;
13676
+ svg.style.left = `${cx - r}px`;
13677
+ svg.style.top = `${cy - r}px`;
13678
+ svg.setAttribute("viewBox", `0 0 ${d} ${d}`);
13679
+ const circles = svg.querySelectorAll("circle");
13680
+ for (const c of circles) {
13681
+ c.setAttribute("cx", String(r));
13682
+ c.setAttribute("cy", String(r));
13683
+ c.setAttribute("r", String(Math.max(r - 1, 0)));
13684
+ }
13685
+ if (this.#radiusTooltip) {
13686
+ this.#radiusTooltip.setAttribute("text", this.#formatRadius());
13687
+ }
13688
+ }
13689
+
13690
+ if (this.#angleSvg && this.#hasAngle) {
13691
+ const cx = (this.#x / 100) * rect.width;
13692
+ const cy = (this.#y / 100) * rect.height;
13693
+ const r = this.#resolveRadius(rect.width);
13694
+ const angleRad = (this.#angle * Math.PI) / 180;
13695
+ const ax = cx + r * Math.cos(angleRad);
13696
+ const ay = cy + r * Math.sin(angleRad);
13697
+
13698
+ const svg = this.#angleSvg;
13699
+ svg.style.width = `${rect.width}px`;
13700
+ svg.style.height = `${rect.height}px`;
13701
+ svg.style.left = "0";
13702
+ svg.style.top = "0";
13703
+ svg.setAttribute("viewBox", `0 0 ${rect.width} ${rect.height}`);
13704
+ const line = svg.querySelector("line");
13705
+ if (line) {
13706
+ line.setAttribute("x1", String(cx));
13707
+ line.setAttribute("y1", String(cy));
13708
+ line.setAttribute("x2", String(ax));
13709
+ line.setAttribute("y2", String(ay));
13710
+ }
13711
+ }
13712
+
13713
+ if (this.#angleHandle && this.#hasAngle) {
13714
+ const cx = (this.#x / 100) * rect.width;
13715
+ const cy = (this.#y / 100) * rect.height;
13716
+ const r = this.#resolveRadius(rect.width);
13717
+ const angleRad = (this.#angle * Math.PI) / 180;
13718
+ const ax = cx + r * Math.cos(angleRad);
13719
+ const ay = cy + r * Math.sin(angleRad);
13720
+ const pxPct = rect.width > 0 ? (ax / rect.width) * 100 : 0;
13721
+ const pyPct = rect.height > 0 ? (ay / rect.height) * 100 : 0;
13722
+ this.#angleHandle.setAttribute("value", `${pxPct}% ${pyPct}%`);
13723
+
13724
+ if (this.#angleTooltip) {
13725
+ this.#angleTooltip.setAttribute("text", `${Math.round(this.#angle)}°`);
13726
+ }
13727
+ }
13728
+
13729
+ this.#syncAngleCursor();
13730
+ }
13731
+
13732
+ #emitInput() {
13733
+ this.dispatchEvent(new CustomEvent("input", { bubbles: true, detail: this.value }));
13734
+ }
13735
+
13736
+ #emitChange() {
13737
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true, detail: this.value }));
13738
+ }
13739
+
13740
+ #syncValueAttribute() {
13741
+ this.setAttribute("value", JSON.stringify(this.value));
13742
+ }
13743
+
13744
+ #setupEventListeners() {
13745
+ if (!this.#pointHandle) return;
13746
+
13747
+ this.#pointHandle.addEventListener("input", (e) => {
13748
+ e.stopPropagation();
13749
+ this.#isDragging = true;
13750
+ const px = e.detail?.px ?? this.#x / 100;
13751
+ const py = e.detail?.py ?? this.#y / 100;
13752
+ this.#x = Math.round(Math.max(0, Math.min(100, px * 100)));
13753
+ this.#y = Math.round(Math.max(0, Math.min(100, py * 100)));
13754
+ if (this.#pointTooltip) {
13755
+ this.#pointTooltip.setAttribute("text", this.#pointTipText);
13756
+ this.#pointTooltip.setAttribute("show", "true");
13757
+ this.#pointTooltip.showPopup?.();
13758
+ }
13759
+ this.#syncPositions();
13760
+ this.#emitInput();
13761
+ });
13762
+
13763
+ this.#pointHandle.addEventListener("change", (e) => {
13764
+ e.stopPropagation();
13765
+ const px = e.detail?.px ?? this.#x / 100;
13766
+ const py = e.detail?.py ?? this.#y / 100;
13767
+ this.#x = Math.round(Math.max(0, Math.min(100, px * 100)));
13768
+ this.#y = Math.round(Math.max(0, Math.min(100, py * 100)));
13769
+ if (this.#pointTooltip) this.#pointTooltip.removeAttribute("show");
13770
+ this.#syncPositions();
13771
+ this.#syncValueAttribute();
13772
+ this.#emitChange();
13773
+ requestAnimationFrame(() => { this.#isDragging = false; });
13774
+ });
13775
+
13776
+ if (this.#angleHandle) {
13777
+ this.#angleHandle.addEventListener("input", (e) => {
13778
+ e.stopPropagation();
13779
+ this.#isAngleDragging = true;
13780
+ this.classList.add("fig-canvas-point-ring-active");
13781
+ const container = this.#container;
13782
+ if (!container) return;
13783
+ const rect = container.getBoundingClientRect();
13784
+ const cx = (this.#x / 100) * rect.width;
13785
+ const cy = (this.#y / 100) * rect.height;
13786
+ const hx = e.detail?.x ?? 0;
13787
+ const hy = e.detail?.y ?? 0;
13788
+ const hw = this.#angleHandle.offsetWidth / 2;
13789
+ const hh = this.#angleHandle.offsetHeight / 2;
13790
+ const dx = (hx + hw) - cx;
13791
+ const dy = (hy + hh) - cy;
13792
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
13793
+ if (this.#shouldSnap(e.detail?.shiftKey)) {
13794
+ angle = Math.round(angle / 15) * 15;
13795
+ }
13796
+ this.#angle = angle;
13797
+
13798
+ let dist = Math.sqrt(dx * dx + dy * dy);
13799
+ if (this.#shouldSnap(e.detail?.shiftKey)) {
13800
+ const step = this.#radiusIsPercent ? 5 : 10;
13801
+ if (this.#radiusIsPercent) {
13802
+ let pct = (dist / rect.width) * 100;
13803
+ pct = Math.round(pct / step) * step;
13804
+ dist = (pct / 100) * rect.width;
13805
+ } else {
13806
+ dist = Math.round(dist / step) * step;
13807
+ }
13808
+ }
13809
+ if (this.#radiusIsPercent) {
13810
+ this.#radius = Math.max(0, (dist / rect.width) * 100);
13811
+ } else {
13812
+ this.#radius = Math.max(0, dist);
13813
+ }
13814
+
13815
+ this.#syncPositions();
13816
+ if (this.#angleTooltip) {
13817
+ this.#angleTooltip.setAttribute("text", `${Math.round(this.#angle)}°`);
13818
+ this.#angleTooltip.setAttribute("show", "true");
13819
+ this.#angleTooltip.showPopup?.();
13820
+ }
13821
+ if (this.#radiusTooltip) {
13822
+ this.#radiusTooltip.setAttribute("text", this.#formatRadius());
13823
+ }
13824
+ this.#emitInput();
13825
+ });
13826
+
13827
+ this.#angleHandle.addEventListener("change", (e) => {
13828
+ e.stopPropagation();
13829
+ this.classList.remove("fig-canvas-point-ring-active");
13830
+ if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
13831
+ this.#syncPositions();
13832
+ this.#syncValueAttribute();
13833
+ this.#emitChange();
13834
+ requestAnimationFrame(() => { this.#isAngleDragging = false; });
13835
+ });
13836
+
13837
+ this.#angleHandle.addEventListener("hitareadown", (e) => {
13838
+ e.stopPropagation();
13839
+ const origEvent = e.detail?.originalEvent;
13840
+ if (!origEvent) return;
13841
+ origEvent.preventDefault();
13842
+ this.#isAngleDragging = true;
13843
+ this.classList.add("fig-canvas-point-ring-active");
13844
+ const container = this.#container;
13845
+ if (!container) return;
13846
+
13847
+ if (this.#angleTooltip) {
13848
+ this.#angleTooltip.setAttribute("show", "true");
13849
+ this.#angleTooltip.showPopup?.();
13850
+ }
13851
+
13852
+ const prevBodyCursor = document.body.style.cursor;
13853
+ document.body.style.cursor = this.#rotateCursorSvg(this.#angle);
13854
+
13855
+ const onMove = (ev) => {
13856
+ const rect = container.getBoundingClientRect();
13857
+ const cx = (this.#x / 100) * rect.width;
13858
+ const cy = (this.#y / 100) * rect.height;
13859
+ const dx = ev.clientX - rect.left - cx;
13860
+ const dy = ev.clientY - rect.top - cy;
13861
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
13862
+ if (this.#shouldSnap(ev.shiftKey)) {
13863
+ angle = Math.round(angle / 15) * 15;
13864
+ }
13865
+ this.#angle = angle;
13866
+ this.#syncPositions();
13867
+ document.body.style.cursor = this.#rotateCursorSvg(this.#angle);
13868
+ if (this.#angleTooltip) {
13869
+ this.#angleTooltip.setAttribute("text", `${Math.round(this.#angle)}°`);
13870
+ }
13871
+ this.#emitInput();
13872
+ };
13873
+
13874
+ const onUp = () => {
13875
+ this.#isAngleDragging = false;
13876
+ this.classList.remove("fig-canvas-point-ring-active");
13877
+ document.body.style.cursor = prevBodyCursor;
13878
+ if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
13879
+ this.#syncValueAttribute();
13880
+ this.#emitChange();
13881
+ window.removeEventListener("pointermove", onMove);
13882
+ window.removeEventListener("pointerup", onUp);
13883
+ };
13884
+
13885
+ window.addEventListener("pointermove", onMove);
13886
+ window.addEventListener("pointerup", onUp);
13887
+ });
13888
+ }
13889
+ }
13890
+
13891
+ #setupRadiusDrag(circle) {
13892
+ if (!circle) return;
13893
+ circle.addEventListener("pointermove", (e) => {
13894
+ if (this.#isRadiusDragging) return;
13895
+ const container = this.#container;
13896
+ if (!container) return;
13897
+ const rect = container.getBoundingClientRect();
13898
+ const cx = (this.#x / 100) * rect.width;
13899
+ const cy = (this.#y / 100) * rect.height;
13900
+ const deg = (Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx) * 180) / Math.PI;
13901
+ circle.style.cursor = this.#resizeCursorSvg(deg);
13902
+ });
13903
+ const onDown = (e) => {
13904
+ if (this.hasAttribute("disabled")) return;
13905
+ e.preventDefault();
13906
+ e.stopPropagation();
13907
+ this.#isRadiusDragging = true;
13908
+ this.classList.add("fig-canvas-point-ring-active");
13909
+ const container = this.#container;
13910
+ if (!container) return;
13911
+
13912
+ if (this.#radiusTooltip) {
13913
+ this.#radiusTooltip.setAttribute("show", "true");
13914
+ this.#radiusTooltip.showPopup?.();
13915
+ }
13916
+
13917
+ const prevBodyCursor = document.body.style.cursor;
13918
+ circle.style.pointerEvents = "none";
13919
+ const rect0 = container.getBoundingClientRect();
13920
+ const cx0 = (this.#x / 100) * rect0.width;
13921
+ const cy0 = (this.#y / 100) * rect0.height;
13922
+ const initDeg = (Math.atan2(e.clientY - rect0.top - cy0, e.clientX - rect0.left - cx0) * 180) / Math.PI;
13923
+ document.body.style.cursor = this.#resizeCursorSvg(initDeg);
13924
+
13925
+ const onMove = (ev) => {
13926
+ const rect = container.getBoundingClientRect();
13927
+ const cx = (this.#x / 100) * rect.width;
13928
+ const cy = (this.#y / 100) * rect.height;
13929
+ const dx = ev.clientX - rect.left - cx;
13930
+ const dy = ev.clientY - rect.top - cy;
13931
+ document.body.style.cursor = this.#resizeCursorSvg((Math.atan2(dy, dx) * 180) / Math.PI);
13932
+ let dist = Math.sqrt(dx * dx + dy * dy);
13933
+ if (this.#shouldSnap(ev.shiftKey)) {
13934
+ const step = this.#radiusIsPercent ? 5 : 10;
13935
+ if (this.#radiusIsPercent) {
13936
+ let pct = (dist / rect.width) * 100;
13937
+ pct = Math.round(pct / step) * step;
13938
+ dist = (pct / 100) * rect.width;
13939
+ } else {
13940
+ dist = Math.round(dist / step) * step;
13941
+ }
13942
+ }
13943
+ if (this.#radiusIsPercent) {
13944
+ this.#radius = Math.max(0, (dist / rect.width) * 100);
13945
+ } else {
13946
+ this.#radius = Math.max(0, dist);
13947
+ }
13948
+ this.#syncPositions();
13949
+ this.#emitInput();
13950
+ };
13951
+
13952
+ const onUp = () => {
13953
+ this.#isRadiusDragging = false;
13954
+ this.classList.remove("fig-canvas-point-ring-active");
13955
+ circle.style.pointerEvents = "";
13956
+ document.body.style.cursor = prevBodyCursor;
13957
+ if (this.#radiusTooltip) this.#radiusTooltip.removeAttribute("show");
13958
+ this.#syncValueAttribute();
13959
+ this.#emitChange();
13960
+ window.removeEventListener("pointermove", onMove);
13961
+ window.removeEventListener("pointerup", onUp);
13962
+ };
13963
+
13964
+ window.addEventListener("pointermove", onMove);
13965
+ window.addEventListener("pointerup", onUp);
13966
+ };
13967
+ circle.addEventListener("pointerdown", onDown);
13968
+ this._radiusDragCleanup = () => circle.removeEventListener("pointerdown", onDown);
13969
+ }
13970
+
13971
+ #teardownRadiusDrag() {
13972
+ if (this._radiusDragCleanup) {
13973
+ this._radiusDragCleanup();
13974
+ this._radiusDragCleanup = null;
13975
+ }
13976
+ }
13977
+ }
13978
+ customElements.define("fig-canvas-point", FigCanvasPoint);
13979
+
13346
13980
  /* Handle */
13347
13981
  class FigHandle extends HTMLElement {
13348
13982
  static observedAttributes = [
@@ -13356,6 +13990,8 @@ class FigHandle extends HTMLElement {
13356
13990
  "value",
13357
13991
  "type",
13358
13992
  "control",
13993
+ "hit-area",
13994
+ "hit-area-mode",
13359
13995
  ];
13360
13996
 
13361
13997
  #isDragging = false;
@@ -13363,6 +13999,7 @@ class FigHandle extends HTMLElement {
13363
13999
  #boundPointerDown = null;
13364
14000
  #applyingValue = false;
13365
14001
  #colorTip = null;
14002
+ #hitAreaEl = null;
13366
14003
 
13367
14004
  get #controlMode() {
13368
14005
  return this.getAttribute("control") || null;
@@ -13500,8 +14137,70 @@ class FigHandle extends HTMLElement {
13500
14137
  this.#applyingValue = false;
13501
14138
  }
13502
14139
 
14140
+ get #hitAreaMode() {
14141
+ return this.getAttribute("hit-area-mode") || "handle";
14142
+ }
14143
+
14144
+ #parseHitArea() {
14145
+ const raw = this.getAttribute("hit-area");
14146
+ if (!raw) return null;
14147
+ const tokens = raw.trim().split(/\s+/);
14148
+ let vPad = 0, hPad = 0, circle = false;
14149
+ const nums = [];
14150
+ for (const t of tokens) {
14151
+ if (t === "circle") { circle = true; continue; }
14152
+ const n = parseFloat(t);
14153
+ if (Number.isFinite(n)) nums.push(n);
14154
+ }
14155
+ if (nums.length >= 2) { vPad = nums[0]; hPad = nums[1]; }
14156
+ else if (nums.length === 1) { vPad = nums[0]; hPad = nums[0]; }
14157
+ else return null;
14158
+ return { vPad, hPad, circle };
14159
+ }
14160
+
14161
+ #syncHitArea() {
14162
+ const parsed = this.#parseHitArea();
14163
+ if (!parsed) {
14164
+ if (this.#hitAreaEl) {
14165
+ this.#hitAreaEl.remove();
14166
+ this.#hitAreaEl = null;
14167
+ }
14168
+ this.style.removeProperty("--fig-handle-hit-area-size");
14169
+ return;
14170
+ }
14171
+ if (!this.#hitAreaEl) {
14172
+ const el = document.createElement("div");
14173
+ el.classList.add("fig-handle-hit-area");
14174
+ el.addEventListener("pointerdown", (e) => this.#onHitAreaPointerDown(e));
14175
+ this.prepend(el);
14176
+ this.#hitAreaEl = el;
14177
+ }
14178
+ this.style.setProperty("--fig-handle-hit-area-size", String(parsed.hPad * 2));
14179
+ if (parsed.circle) {
14180
+ this.#hitAreaEl.style.borderRadius = "50%";
14181
+ } else {
14182
+ this.#hitAreaEl.style.borderRadius = "inherit";
14183
+ }
14184
+ }
14185
+
14186
+ #onHitAreaPointerDown(e) {
14187
+ if (this.hasAttribute("disabled")) return;
14188
+ if (e.target !== this.#hitAreaEl) return;
14189
+ if (this.#hitAreaMode === "delegate") {
14190
+ e.preventDefault();
14191
+ e.stopPropagation();
14192
+ this.dispatchEvent(new CustomEvent("hitareadown", {
14193
+ bubbles: true,
14194
+ detail: { originalEvent: e },
14195
+ }));
14196
+ } else {
14197
+ this.#onPointerDown(e);
14198
+ }
14199
+ }
14200
+
13503
14201
  connectedCallback() {
13504
14202
  this.#syncDrag();
14203
+ this.#syncHitArea();
13505
14204
  this.addEventListener("click", this.#handleSelect);
13506
14205
  document.addEventListener("pointerdown", this.#handleDeselect);
13507
14206
  document.addEventListener("keydown", this.#handleKeyDown);
@@ -13513,6 +14212,7 @@ class FigHandle extends HTMLElement {
13513
14212
  disconnectedCallback() {
13514
14213
  this.#teardownDrag();
13515
14214
  this.#hideColorTip();
14215
+ if (this.#hitAreaEl) { this.#hitAreaEl.remove(); this.#hitAreaEl = null; }
13516
14216
  this.removeEventListener("click", this.#handleSelect);
13517
14217
  document.removeEventListener("pointerdown", this.#handleDeselect);
13518
14218
  document.removeEventListener("keydown", this.#handleKeyDown);
@@ -13566,6 +14266,7 @@ class FigHandle extends HTMLElement {
13566
14266
  }
13567
14267
  }
13568
14268
  if (name === "drag") this.#syncDrag();
14269
+ if (name === "hit-area") this.#syncHitArea();
13569
14270
  if (name === "value" && !this.#applyingValue && !this.#isDragging) {
13570
14271
  this.#applyValue(value);
13571
14272
  }
@@ -13608,13 +14309,19 @@ class FigHandle extends HTMLElement {
13608
14309
  const handleH = this.offsetHeight;
13609
14310
  let lastRect = null;
13610
14311
 
14312
+ const handleRect = this.getBoundingClientRect();
14313
+ const handleCenterX = handleRect.left + handleRect.width / 2;
14314
+ const handleCenterY = handleRect.top + handleRect.height / 2;
14315
+ const offsetX = e.clientX - handleCenterX;
14316
+ const offsetY = e.clientY - handleCenterY;
14317
+
13611
14318
  const clampAndApply = (clientX, clientY, shiftKey = false) => {
13612
14319
  const rect = container.getBoundingClientRect();
13613
14320
  lastRect = rect;
13614
14321
  const currentLeft = parseFloat(this.style.left) || 0;
13615
14322
  const currentTop = parseFloat(this.style.top) || 0;
13616
- const rawX = clientX - rect.left - handleW / 2;
13617
- const rawY = clientY - rect.top - handleH / 2;
14323
+ const rawX = (clientX - offsetX) - rect.left - handleW / 2;
14324
+ const rawY = (clientY - offsetY) - rect.top - handleH / 2;
13618
14325
 
13619
14326
  const clampedX = Math.max(
13620
14327
  -handleW / 2,