@rogieking/figui3 6.6.2 → 6.6.4

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
@@ -2884,10 +2884,167 @@ class FigPopup extends HTMLDialogElement {
2884
2884
  return "top";
2885
2885
  }
2886
2886
 
2887
- updatePopoverBeak(anchorRect, popupRect, left, top, placementSide) {
2887
+ lengthToPx(value, fallback = 0) {
2888
+ const styles = getComputedStyle(this);
2889
+ const raw = String(value || "").trim();
2890
+ const n = parseFloat(raw);
2891
+ if (!Number.isFinite(n)) return fallback;
2892
+ if (raw.endsWith("rem")) {
2893
+ return n * parseFloat(getComputedStyle(document.documentElement).fontSize);
2894
+ }
2895
+ if (raw.endsWith("em")) {
2896
+ return n * parseFloat(styles.fontSize);
2897
+ }
2898
+ return n;
2899
+ }
2900
+
2901
+ radiusForSide(side) {
2902
+ const styles = getComputedStyle(this);
2903
+ const toPx = (value) => this.lengthToPx(value, 0);
2904
+ if (side === "top") {
2905
+ return Math.max(
2906
+ toPx(styles.borderTopLeftRadius),
2907
+ toPx(styles.borderTopRightRadius),
2908
+ );
2909
+ }
2910
+ if (side === "bottom") {
2911
+ return Math.max(
2912
+ toPx(styles.borderBottomLeftRadius),
2913
+ toPx(styles.borderBottomRightRadius),
2914
+ );
2915
+ }
2916
+ if (side === "left") {
2917
+ return Math.max(
2918
+ toPx(styles.borderTopLeftRadius),
2919
+ toPx(styles.borderBottomLeftRadius),
2920
+ );
2921
+ }
2922
+ if (side === "right") {
2923
+ return Math.max(
2924
+ toPx(styles.borderTopRightRadius),
2925
+ toPx(styles.borderBottomRightRadius),
2926
+ );
2927
+ }
2928
+ return 0;
2929
+ }
2930
+
2931
+ getBeakEdgeInset(beakSide) {
2932
+ const beakWidth = this.lengthToPx(
2933
+ getComputedStyle(this).getPropertyValue("--fig-popup-beak-width"),
2934
+ 16,
2935
+ );
2936
+ return Math.max(10, this.radiusForSide(beakSide) + beakWidth / 2);
2937
+ }
2938
+
2939
+ tracksAnchorBeak() {
2888
2940
  const variant = this.getAttribute("variant");
2889
- const beakVariants = variant === "popover" || variant === "tooltip";
2890
- if (!beakVariants || !anchorRect) {
2941
+ return variant === "popover" || variant === "tooltip";
2942
+ }
2943
+
2944
+ getViewportBounds(m) {
2945
+ const vv = window.visualViewport;
2946
+ const width = vv?.width ?? window.innerWidth;
2947
+ const height = vv?.height ?? window.innerHeight;
2948
+ const offsetLeft = vv?.offsetLeft ?? 0;
2949
+ const offsetTop = vv?.offsetTop ?? 0;
2950
+
2951
+ return {
2952
+ minLeft: offsetLeft + m.left,
2953
+ minTop: offsetTop + m.top,
2954
+ maxRight: offsetLeft + width - m.right,
2955
+ maxBottom: offsetTop + height - m.bottom,
2956
+ };
2957
+ }
2958
+
2959
+ clampToViewport(coords, popupRect, m) {
2960
+ const bounds = this.getViewportBounds(m);
2961
+ const maxLeft = bounds.maxRight - popupRect.width;
2962
+ const maxTop = bounds.maxBottom - popupRect.height;
2963
+
2964
+ return {
2965
+ left: Math.min(maxLeft, Math.max(bounds.minLeft, coords.left)),
2966
+ top: Math.min(maxTop, Math.max(bounds.minTop, coords.top)),
2967
+ };
2968
+ }
2969
+
2970
+ resolveCoordsAtViewport(anchorRect, popupRect, coords, placementSide, m) {
2971
+ let { left, top } = this.clampToViewport(coords, popupRect, m);
2972
+ if (!anchorRect || !this.tracksAnchorBeak()) {
2973
+ return { left, top };
2974
+ }
2975
+
2976
+ const beakSide = this.oppositeSide(placementSide);
2977
+ const bounds = this.getViewportBounds(m);
2978
+ const maxLeft = bounds.maxRight - popupRect.width;
2979
+ const minLeft = bounds.minLeft;
2980
+ const maxTop = bounds.maxBottom - popupRect.height;
2981
+ const minTop = bounds.minTop;
2982
+
2983
+ if (beakSide === "top" || beakSide === "bottom") {
2984
+ const inset = this.getBeakEdgeInset(beakSide);
2985
+ const anchorCenterX = anchorRect.left + anchorRect.width / 2;
2986
+ const beakOffset = anchorCenterX - left;
2987
+ if (beakOffset < inset) {
2988
+ left = anchorCenterX - inset;
2989
+ } else if (beakOffset > popupRect.width - inset) {
2990
+ left = anchorCenterX - (popupRect.width - inset);
2991
+ }
2992
+ left = Math.min(maxLeft, Math.max(minLeft, left));
2993
+ } else if (beakSide === "left" || beakSide === "right") {
2994
+ const inset = this.getBeakEdgeInset(beakSide);
2995
+ const anchorCenterY = anchorRect.top + anchorRect.height / 2;
2996
+ const beakOffset = anchorCenterY - top;
2997
+ if (beakOffset < inset) {
2998
+ top = anchorCenterY - inset;
2999
+ } else if (beakOffset > popupRect.height - inset) {
3000
+ top = anchorCenterY - (popupRect.height - inset);
3001
+ }
3002
+ top = Math.min(maxTop, Math.max(minTop, top));
3003
+ }
3004
+
3005
+ return { left, top };
3006
+ }
3007
+
3008
+ canPointAtAnchor(anchorRect, popupRect, left, top, placementSide) {
3009
+ if (!anchorRect || !this.tracksAnchorBeak()) return true;
3010
+
3011
+ const beakSide = this.oppositeSide(placementSide);
3012
+ const inset = this.getBeakEdgeInset(beakSide);
3013
+
3014
+ if (beakSide === "top" || beakSide === "bottom") {
3015
+ const beakOffset = anchorRect.left + anchorRect.width / 2 - left;
3016
+ return (
3017
+ beakOffset >= inset - 0.5 &&
3018
+ beakOffset <= popupRect.width - inset + 0.5
3019
+ );
3020
+ }
3021
+
3022
+ const beakOffset = anchorRect.top + anchorRect.height / 2 - top;
3023
+ return (
3024
+ beakOffset >= inset - 0.5 &&
3025
+ beakOffset <= popupRect.height - inset + 0.5
3026
+ );
3027
+ }
3028
+
3029
+ updatePointerVisibility(anchorRect, popupRect, left, top, placementSide) {
3030
+ if (!this.tracksAnchorBeak()) return;
3031
+ if (this.getAttribute("pointer") === "false") return;
3032
+
3033
+ if (
3034
+ anchorRect &&
3035
+ !this.canPointAtAnchor(anchorRect, popupRect, left, top, placementSide)
3036
+ ) {
3037
+ this.setAttribute("pointer", "false");
3038
+ return;
3039
+ }
3040
+
3041
+ if (this.hasAttribute("pointer")) {
3042
+ this.removeAttribute("pointer");
3043
+ }
3044
+ }
3045
+
3046
+ updatePopoverBeak(anchorRect, popupRect, left, top, placementSide) {
3047
+ if (!this.tracksAnchorBeak() || !anchorRect) {
2891
3048
  this.style.removeProperty("--fig-popup-beak-offset");
2892
3049
  this.removeAttribute("data-beak-side");
2893
3050
  return;
@@ -2903,54 +3060,9 @@ class FigPopup extends HTMLDialogElement {
2903
3060
  measuredRect.width > 0 && measuredRect.height > 0
2904
3061
  ? measuredRect
2905
3062
  : popupRect;
2906
- // Always use the rendered popup rect so beak alignment matches real final placement.
2907
3063
  const resolvedLeft = rect.left;
2908
3064
  const resolvedTop = rect.top;
2909
- const styles = getComputedStyle(this);
2910
- const toPx = (value, fallback = 0) => {
2911
- const raw = String(value || "").trim();
2912
- const n = parseFloat(raw);
2913
- if (!Number.isFinite(n)) return fallback;
2914
- if (raw.endsWith("rem")) {
2915
- return n * parseFloat(getComputedStyle(document.documentElement).fontSize);
2916
- }
2917
- if (raw.endsWith("em")) {
2918
- return n * parseFloat(styles.fontSize);
2919
- }
2920
- return n;
2921
- };
2922
- const radiusForSide = (side) => {
2923
- if (side === "top") {
2924
- return Math.max(
2925
- toPx(styles.borderTopLeftRadius),
2926
- toPx(styles.borderTopRightRadius),
2927
- );
2928
- }
2929
- if (side === "bottom") {
2930
- return Math.max(
2931
- toPx(styles.borderBottomLeftRadius),
2932
- toPx(styles.borderBottomRightRadius),
2933
- );
2934
- }
2935
- if (side === "left") {
2936
- return Math.max(
2937
- toPx(styles.borderTopLeftRadius),
2938
- toPx(styles.borderBottomLeftRadius),
2939
- );
2940
- }
2941
- if (side === "right") {
2942
- return Math.max(
2943
- toPx(styles.borderTopRightRadius),
2944
- toPx(styles.borderBottomRightRadius),
2945
- );
2946
- }
2947
- return 0;
2948
- };
2949
- const beakWidth = toPx(
2950
- styles.getPropertyValue("--fig-popup-beak-width"),
2951
- 16,
2952
- );
2953
- const edgeInset = Math.max(10, radiusForSide(beakSide) + beakWidth / 2);
3065
+ const edgeInset = this.getBeakEdgeInset(beakSide);
2954
3066
 
2955
3067
  let beakOffset;
2956
3068
  if (beakSide === "top" || beakSide === "bottom") {
@@ -2969,15 +3081,14 @@ class FigPopup extends HTMLDialogElement {
2969
3081
  }
2970
3082
 
2971
3083
  overflowScore(coords, popupRect, m) {
2972
- const vw = window.innerWidth;
2973
- const vh = window.innerHeight;
3084
+ const bounds = this.getViewportBounds(m);
2974
3085
  const right = coords.left + popupRect.width;
2975
3086
  const bottom = coords.top + popupRect.height;
2976
3087
 
2977
- const overflowLeft = Math.max(0, m.left - coords.left);
2978
- const overflowTop = Math.max(0, m.top - coords.top);
2979
- const overflowRight = Math.max(0, right - (vw - m.right));
2980
- const overflowBottom = Math.max(0, bottom - (vh - m.bottom));
3088
+ const overflowLeft = Math.max(0, bounds.minLeft - coords.left);
3089
+ const overflowTop = Math.max(0, bounds.minTop - coords.top);
3090
+ const overflowRight = Math.max(0, right - bounds.maxRight);
3091
+ const overflowBottom = Math.max(0, bottom - bounds.maxBottom);
2981
3092
 
2982
3093
  return overflowLeft + overflowTop + overflowRight + overflowBottom;
2983
3094
  }
@@ -2986,22 +3097,73 @@ class FigPopup extends HTMLDialogElement {
2986
3097
  return this.overflowScore(coords, popupRect, m) === 0;
2987
3098
  }
2988
3099
 
2989
- clamp(coords, popupRect, m) {
2990
- const minLeft = m.left;
2991
- const minTop = m.top;
2992
- const maxLeft = Math.max(
2993
- m.left,
2994
- window.innerWidth - popupRect.width - m.right,
2995
- );
2996
- const maxTop = Math.max(
2997
- m.top,
2998
- window.innerHeight - popupRect.height - m.bottom,
3100
+ clamp(coords, popupRect, m, anchorRect = null, placementSide = "top") {
3101
+ if (anchorRect) {
3102
+ return this.resolveCoordsAtViewport(
3103
+ anchorRect,
3104
+ popupRect,
3105
+ coords,
3106
+ placementSide,
3107
+ m,
3108
+ );
3109
+ }
3110
+ return this.clampToViewport(coords, popupRect, m);
3111
+ }
3112
+
3113
+ placementScore(anchorRect, popupRect, coords, placementSide, m) {
3114
+ const resolved = this.resolveCoordsAtViewport(
3115
+ anchorRect,
3116
+ popupRect,
3117
+ coords,
3118
+ placementSide,
3119
+ m,
2999
3120
  );
3121
+ let score = this.overflowScore(resolved, popupRect, m);
3122
+ if (
3123
+ anchorRect &&
3124
+ !this.canPointAtAnchor(
3125
+ anchorRect,
3126
+ popupRect,
3127
+ resolved.left,
3128
+ resolved.top,
3129
+ placementSide,
3130
+ )
3131
+ ) {
3132
+ score += 10000;
3133
+ }
3134
+ return { score, resolved };
3135
+ }
3000
3136
 
3001
- return {
3002
- left: Math.min(maxLeft, Math.max(minLeft, coords.left)),
3003
- top: Math.min(maxTop, Math.max(minTop, coords.top)),
3004
- };
3137
+ applyPopupPosition(
3138
+ anchorRect,
3139
+ popupRect,
3140
+ coords,
3141
+ placementSide,
3142
+ m,
3143
+ ) {
3144
+ const resolved = this.resolveCoordsAtViewport(
3145
+ anchorRect,
3146
+ popupRect,
3147
+ coords,
3148
+ placementSide,
3149
+ m,
3150
+ );
3151
+ this.style.left = `${resolved.left}px`;
3152
+ this.style.top = `${resolved.top}px`;
3153
+ this.updatePointerVisibility(
3154
+ anchorRect,
3155
+ popupRect,
3156
+ resolved.left,
3157
+ resolved.top,
3158
+ placementSide,
3159
+ );
3160
+ this.updatePopoverBeak(
3161
+ anchorRect,
3162
+ popupRect,
3163
+ resolved.left,
3164
+ resolved.top,
3165
+ placementSide,
3166
+ );
3005
3167
  }
3006
3168
 
3007
3169
  positionPopup() {
@@ -3015,14 +3177,16 @@ class FigPopup extends HTMLDialogElement {
3015
3177
 
3016
3178
  if (!anchor) {
3017
3179
  this.updatePopoverBeak(null, popupRect, 0, 0, "top");
3180
+ const bounds = this.getViewportBounds(m);
3018
3181
  const centered = {
3019
3182
  left:
3020
- m.left + (window.innerWidth - m.right - m.left - popupRect.width) / 2,
3183
+ bounds.minLeft +
3184
+ (bounds.maxRight - bounds.minLeft - popupRect.width) / 2,
3021
3185
  top:
3022
- m.top +
3023
- (window.innerHeight - m.bottom - m.top - popupRect.height) / 2,
3186
+ bounds.minTop +
3187
+ (bounds.maxBottom - bounds.minTop - popupRect.height) / 2,
3024
3188
  };
3025
- const clamped = this.clamp(centered, popupRect, m);
3189
+ const clamped = this.clampToViewport(centered, popupRect, m);
3026
3190
  this.style.left = `${clamped.left}px`;
3027
3191
  this.style.top = `${clamped.top}px`;
3028
3192
  return;
@@ -3041,64 +3205,56 @@ class FigPopup extends HTMLDialogElement {
3041
3205
  for (const { v, h, s } of candidates) {
3042
3206
  const coords = this.computeCoords(anchorRect, popupRect, v, h, offset, s);
3043
3207
  const placementSide = this.getPlacementSide(v, h, s);
3208
+ const { score, resolved } = this.placementScore(
3209
+ anchorRect,
3210
+ popupRect,
3211
+ coords,
3212
+ placementSide,
3213
+ m,
3214
+ );
3044
3215
 
3045
3216
  if (s) {
3046
- const clamped = this.clamp(coords, popupRect, m);
3217
+ const bounds = this.getViewportBounds(m);
3047
3218
  const primaryFits =
3048
3219
  s === "left" || s === "right"
3049
- ? coords.left >= m.left &&
3050
- coords.left + popupRect.width <= window.innerWidth - m.right
3051
- : coords.top >= m.top &&
3052
- coords.top + popupRect.height <= window.innerHeight - m.bottom;
3053
- if (primaryFits) {
3054
- this.style.left = `${clamped.left}px`;
3055
- this.style.top = `${clamped.top}px`;
3056
- this.updatePopoverBeak(
3220
+ ? coords.left >= bounds.minLeft &&
3221
+ coords.left + popupRect.width <= bounds.maxRight
3222
+ : coords.top >= bounds.minTop &&
3223
+ coords.top + popupRect.height <= bounds.maxBottom;
3224
+ if (primaryFits && score < 10000) {
3225
+ this.applyPopupPosition(
3057
3226
  anchorRect,
3058
3227
  popupRect,
3059
- clamped.left,
3060
- clamped.top,
3228
+ coords,
3061
3229
  placementSide,
3230
+ m,
3062
3231
  );
3063
3232
  return;
3064
3233
  }
3065
- const score = this.overflowScore(coords, popupRect, m);
3066
- if (score < bestScore) {
3067
- bestScore = score;
3068
- best = clamped;
3069
- bestSide = placementSide;
3070
- }
3071
- } else {
3072
- if (this.fits(coords, popupRect, m)) {
3073
- this.style.left = `${coords.left}px`;
3074
- this.style.top = `${coords.top}px`;
3075
- this.updatePopoverBeak(
3076
- anchorRect,
3077
- popupRect,
3078
- coords.left,
3079
- coords.top,
3080
- placementSide,
3081
- );
3082
- return;
3083
- }
3084
- const score = this.overflowScore(coords, popupRect, m);
3085
- if (score < bestScore) {
3086
- bestScore = score;
3087
- best = coords;
3088
- bestSide = placementSide;
3089
- }
3234
+ } else if (score === 0) {
3235
+ this.applyPopupPosition(
3236
+ anchorRect,
3237
+ popupRect,
3238
+ coords,
3239
+ placementSide,
3240
+ m,
3241
+ );
3242
+ return;
3243
+ }
3244
+
3245
+ if (score < bestScore) {
3246
+ bestScore = score;
3247
+ best = resolved;
3248
+ bestSide = placementSide;
3090
3249
  }
3091
3250
  }
3092
3251
 
3093
- const clamped = this.clamp(best || { left: 0, top: 0 }, popupRect, m);
3094
- this.style.left = `${clamped.left}px`;
3095
- this.style.top = `${clamped.top}px`;
3096
- this.updatePopoverBeak(
3252
+ this.applyPopupPosition(
3097
3253
  anchorRect,
3098
3254
  popupRect,
3099
- clamped.left,
3100
- clamped.top,
3255
+ best || { left: 0, top: 0 },
3101
3256
  bestSide,
3257
+ m,
3102
3258
  );
3103
3259
  }
3104
3260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "6.6.2",
3
+ "version": "6.6.4",
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",