1 // ==========================================================================
  2 // Project:   SproutCore - JavaScript Application Framework
  3 // Copyright: ©2006-2011 Strobe Inc. and contributors.
  4 //            Portions ©2008-2011 Apple Inc. All rights reserved.
  5 // License:   Licensed under MIT license (see license.js)
  6 // ==========================================================================
  7 
  8 sc_require("system/utils/string_measurement");
  9 
 10 /**
 11   @class
 12   Use this mixin to make your view automatically resize based upon its value,
 13   title, or other string property. Only works for views that support automatic
 14   resizing.
 15 
 16   Supporting Automatic Resizing
 17   -------------------------------------
 18   To support automatic resizing, your view must provide these properties:
 19 
 20   - *`supportsAutoResize`.* Must be set to YES.
 21 
 22   - *`autoResizeLayer`* A DOM element to use as a template for resizing the
 23     view. Font sizes and other styles will be copied to the measuring element
 24     SproutCore uses to measure the text.
 25 
 26   - *`autoResizeText`.* The text to measure. A button view might make a proxy
 27     to its `displayTitle`, for instance.
 28 
 29   Your view may also supply:
 30 
 31   - *`autoResizePadding`.* An amount to add to the measured size. This may be either
 32     a single number to be added to both width and height, or a hash containing
 33     separate `width` and `height` properties.
 34 
 35 
 36   NOTE: these properties are not defined in the mixin itself because the supporting view,
 37   rather than the user of SC.AutoResize, will be providing the properties, and mixing
 38   SC.AutoResize into the view should not override these properties.
 39 */
 40 SC.AutoResize = {
 41   /*@scope SC.AutoResize.prototype */
 42 
 43   /**
 44     If YES, automatically resizes the view (default). If NO, only measures,
 45     setting 'measuredSize' to the measured value (you can bind to measuredSize
 46     and update size manually).
 47 
 48     @type Boolean
 49     @default YES
 50   */
 51   shouldAutoResize: YES,
 52 
 53   /**
 54     If NO, prevents SC.AutoResize from doing anything at all.
 55 
 56     @type Boolean
 57     @default YES
 58   */
 59   shouldMeasureSize: YES,
 60 
 61   /**
 62     Caches sizes for measured strings. This cache does not have a max size, so
 63     should only be used when a view has a limited number of possible values.
 64     Multiple views that have the same batchResizeId will share the same cache.
 65 
 66     @type Boolean
 67     @default NO
 68   */
 69   shouldCacheSizes: NO,
 70 
 71   /**
 72     Determines if the view's width should be resized
 73     on calculation.
 74 
 75     @type Boolean
 76     @default YES
 77   */
 78   shouldResizeWidth: YES,
 79 
 80   /**
 81     Determines if the view's height should be resized
 82     on calculation. Default is NO to retain backwards
 83     compatibility.
 84 
 85     @type Boolean
 86     @default NO
 87   */
 88   shouldResizeHeight: NO,
 89 
 90   /**
 91     The measured size of the view's content (the value of the autoResizeField).
 92     This property is observable, and, if used in conjunction with setting
 93     shouldAutoResize to NO, allows you to customize the 'sizing' part, using
 94     SC.AutoResize purely for its measuring code.
 95 
 96     @type Rect
 97   */
 98   measuredSize: { width: 0, height: 0 },
 99 
100   /**
101     If provided, will limit the maximum width to this value.
102   */
103   maxWidth: null,
104 
105   /**
106     If provided, will limit the maximum height to this value.
107   */
108   maxHeight: null,
109 
110   /**
111     If YES, the view's text will be resized to fit the view. This is applied _after_ any
112     resizing, so will only take affect if shouldAutoResize is off, or a maximum width/height
113     is set.
114 
115     You also must set a minimum and maximum font size. Any auto resizing will happen at the
116     maximum size, and then the text will be resized as necessary.
117   */
118   shouldAutoFitText: NO,
119 
120   /**
121     If NO, the calculated font size may be any size between minFontSize and
122     maxFontSize. If YES, it will only be either minFontSize or maxFontSize.
123 
124     @type Boolean
125     @default NO
126   */
127   autoFitDiscreteFontSizes: NO,
128 
129   /**
130     The minimum font size to use when automatically fitting text. If shouldAutoFitText is set,
131     this _must_ be supplied.
132 
133     Font size is in pixels.
134   */
135   minFontSize: 12,
136 
137   /**
138     The maximum font size to use when automatically fitting text. If shouldAutoFitText is set,
139     this _must_ be supplied.
140 
141     Font size is in pixels.
142   */
143   maxFontSize: 20,
144 
145   /**
146     If shouldAutoFitText is YES, this is the calculated font size.
147   */
148   calculatedFontSize: 20,
149 
150   fontPropertyDidChange: function() {
151     if(this.get('shouldAutoFitText')) this.invokeLast(this.fitTextToFrame);
152   }.observes('shouldAutoFitText', 'minFontSize', 'maxFontSize', 'measuredSize'),
153 
154   /**
155     Observes the measured size and actually performs the resize if necessary.
156   */
157   measuredSizeDidChange: function() {
158     var measuredSize = this.get('measuredSize'),
159       calculatedWidth = measuredSize.width,
160       calculatedHeight = measuredSize.height,
161       paddingHeight, paddingWidth,
162       autoResizePadding = this.get('autoResizePadding') || 0,
163       maxWidth = this.get('maxWidth'),
164       maxHeight = this.get('maxHeight');
165 
166     if (SC.typeOf(autoResizePadding) === SC.T_NUMBER) {
167       paddingHeight = paddingWidth = autoResizePadding;
168     } else {
169       paddingHeight = autoResizePadding.height;
170       paddingWidth = autoResizePadding.width;
171     }
172 
173     calculatedHeight += paddingHeight;
174     calculatedWidth += paddingWidth;
175 
176     if (this.get('shouldAutoResize')) {
177       // if we are allowed to autoresize, adjust the layout
178       if (this.get('shouldResizeWidth')) {
179         if (maxWidth && calculatedWidth > maxWidth) {
180           calculatedWidth = maxWidth;
181         }
182         this.set('calculatedWidth', calculatedWidth);
183 
184         this.adjust('width', calculatedWidth);
185       }
186 
187       if (this.get('shouldResizeHeight')) {
188         if (maxHeight && calculatedHeight > maxHeight) {
189           calculatedHeight = maxHeight;
190         }
191         this.set('calculatedHeight', calculatedHeight);
192         this.adjust('height', calculatedHeight);
193       }
194     }
195 
196   }.observes('shouldAutoResize', 'measuredSize', 'autoResizePadding', 'maxWidth', 'maxHeight', 'shouldResizeWidth', 'shouldResizeHeight'),
197 
198   /**
199     @private
200     Begins observing the auto resize field.
201   */
202   // @if (debug)
203   initMixin: function() {
204     if (!this.get('supportsAutoResize')) {
205       throw new Error("View `%@` does not support automatic resize. See documentation for SC.AutoResize".fmt(this));
206     }
207   },
208   // @endif
209 
210   /**
211     If this property is provided, all views that share the same value for this property will be resized as a batch for increased performance.
212 
213     @type String
214   */
215   batchResizeId: null,
216 
217   /**
218     Schedules a measurement to happen later.
219   */
220   scheduleMeasurement: function() {
221     var batchResizeId = this.get('batchResizeId');
222 
223     // only measure if we are visible, active, and the text or style actually changed
224     if (!this.get('shouldMeasureSize') || !this.get('isVisibleInWindow') || (this.get('autoResizeText') === this._lastMeasuredText && batchResizeId === this._lastMeasuredId)) return;
225 
226     // batchResizeId is allowed to be undefined; views without an id will just
227     // get measured one at a time
228     SC.AutoResizeManager.scheduleMeasurementForView(this, batchResizeId);
229   }.observes('isVisibleInWindow', 'shouldMeasureSize', 'autoResizeText', 'batchResizeId'),
230 
231   /** @private */
232   _lastMeasuredText: null,
233 
234   /** @private */
235   _cachedMetrics: function(key, value) {
236     if(!this.get('shouldCacheSizes')) return;
237 
238     // if we don't have a tag, then it is unique per view
239     // you shouldn't usually turn on caching without a tag, but it is supported
240     var cacheSlot = SC.cacheSlotFor(this.get('batchResizeId') || this),
241     autoResizeText = this.get('autoResizeText');
242 
243     if(value) cacheSlot[autoResizeText] = value;
244     else value = cacheSlot[autoResizeText];
245 
246     return value;
247   }.property('shouldCacheSizes', 'autoResizeText', 'batchResizeId').cacheable(),
248 
249   /**
250     Measures the size of the view.
251 
252     @param batch For internal use during batch resizing.
253   */
254   measureSize: function(batch) {
255     var metrics, layer = this.get('autoResizeLayer'),
256         autoResizeText = this.get('autoResizeText'),
257         ignoreEscape = !this.get('escapeHTML'),
258         batchResizeId = this.get('batchResizeId'),
259         cachedMetrics = this.get('_cachedMetrics');
260         // maxFontSize = this.get('maxFontSize');
261 
262     if (!layer) return;
263 
264     // There are three special cases.
265     //   - size is cached: the cached size is used with no measurement
266     //     necessary
267     //   - empty: we should do nothing. The metrics are 0.
268     //   - batch mode: just call measureString.
269     //
270     // If we are in neither of those special cases, we should go ahead and
271     // resize normally.
272     //
273     if(cachedMetrics) {
274       metrics = cachedMetrics;
275     }
276 
277     else if (SC.none(autoResizeText) || autoResizeText === "") {
278       metrics = { width: 0, height: 0 };
279     }
280 
281     else if (batch) {
282       metrics = SC.measureString(autoResizeText, ignoreEscape);
283     }
284 
285     else {
286       this.prepareLayerForStringMeasurement(layer);
287 
288       metrics = SC.metricsForString(autoResizeText, layer, this.get('classNames'), ignoreEscape);
289     }
290 
291     // In any case, we set measuredSize.
292     this.set('measuredSize', metrics);
293 
294     // and update the cache if we are using it
295     if(this.get('shouldCacheSizes')) this.setIfChanged('_cachedMetrics', metrics);
296 
297     // set the measured value so we can avoid extra measurements in the future
298     this._lastMeasuredText = autoResizeText;
299     this._lastMeasuredId = batchResizeId;
300 
301     return metrics;
302   },
303 
304 
305   //
306   // FITTING TEXT
307   //
308 
309   /**
310     If we are fitting text, the layer must be measured with its font size set to our
311     maximum font size.
312   */
313   prepareLayerForStringMeasurement: function(layer) {
314     var maxFontSize = this.get('maxFontSize');
315 
316     if (this.get('shouldAutoFitText') && this.get('calculatedFontSize') !== maxFontSize) {
317       layer.style.fontSize = maxFontSize + "px";
318     }
319 
320     // When resizing only the height, we should restrict the width to that of the given
321     // layer. This way, the height will grow appropriately to fit the target as
322     // text *wraps* within the current width.
323     if (!this.get('shouldResizeWidth')) {
324       layer.style.maxWidth = $(layer).outerWidth() + 'px';
325     }
326   },
327 
328   /**
329     Whenever the view resizes, the text fitting must be reevaluated.
330   */
331   viewDidResize: function(orig) {
332     orig();
333 
334     this.fontPropertyDidChange();
335   }.enhance(),
336 
337   /**
338     Fits the text into the frame's size, minus autoResizePadding.
339   */
340   fitTextToFrame: function() {
341     // we can only fit text when we have a layer.
342     var layer = this.get('autoResizeLayer');
343     if (!layer) return;
344 
345     var maxFontSize = this.get('maxFontSize'),
346         minFontSize = this.get('minFontSize');
347 
348     // if the font size has been adjusted, reset it to the max
349     this.prepareLayerForStringMeasurement(layer);
350 
351     var frame = this.get('frame'),
352 
353         padding = this.get('autoResizePadding') || 0,
354 
355         // these need to be shrunk by 1 pixel or text that is exactly as wide as
356         // the frame will be truncated
357         width = frame.width - 1, height = frame.height - 1,
358         measured = this.get('measuredSize'),
359         mWidth = measured.width, mHeight = measured.height,
360         actual;
361 
362     // figure out and apply padding to the width/height
363     if(SC.typeOf(padding) === SC.T_NUMBER) {
364       width -= padding;
365       height -= padding;
366     } else {
367       width -= padding.width;
368       height -= padding.height;
369     }
370 
371     // measured size is at maximum. If there is no resizing to be done, short-circuit.
372     if (mWidth <= width && mHeight <= height) return;
373 
374     // if only discrete values are allowed, we can short circuit here and just
375     // use the minimum
376     if(this.get('autoFitDiscreteFontSizes')) {
377       actual = minFontSize;
378     }
379 
380     // otherwise we have to find the actual best font size
381     else {
382       // now, we are going to make an estimate font size. We will figure out the proportion
383       // of both actual width and actual height to the measured width and height, and then we'll
384       // pick the smaller. We'll multiply that by the maximum font size to figure out
385       // a rough guestimate of the proper font size.
386       var xProportion = width / mWidth, yProportion = height / mHeight,
387 
388           guestimate = Math.floor(maxFontSize * Math.min(xProportion, yProportion)),
389 
390           classNames = this.get('classNames'),
391           ignoreEscape = !this.get('escapeHTML'),
392           value = this.get('autoResizeText'),
393 
394           metrics;
395 
396 
397       guestimate = actual = Math.min(maxFontSize, Math.max(minFontSize, guestimate));
398 
399       // Now, we must test the guestimate. Based on that, we'll either loop down
400       // or loop up, depending on the measured size.
401       layer.style.fontSize = guestimate + "px";
402       metrics = SC.metricsForString(value, layer, classNames, ignoreEscape);
403 
404       if (metrics.width > width || metrics.height > height) {
405 
406         // if we're larger, we must go down until we are smaller, at which point we are done.
407         for (guestimate = guestimate - 1; guestimate >= minFontSize; guestimate--) {
408           layer.style.fontSize = guestimate + "px";
409           metrics = SC.metricsForString(value, layer, classNames, ignoreEscape);
410 
411           // always have an actual in this case; even if we can't get it small enough, we want
412           // to keep this as close as possible.
413           actual = guestimate;
414 
415           // if the new size is small enough, stop shrinking and set it for real
416           if (metrics.width <= width && metrics.height <= height) {
417             break;
418           }
419         }
420 
421       } else if (metrics.width < width || metrics.height < height) {
422         // if we're smaller, we must go up until we hit maxFontSize or get larger. If we get
423         // larger, we want to use the previous guestimate (which we know was valid)
424         //
425         // So, we'll start actual at guestimate, and only increase it while we're smaller.
426         for (guestimate = guestimate + 1; guestimate <= maxFontSize; guestimate++) {
427           layer.style.fontSize = guestimate + "px";
428           metrics = SC.metricsForString(value, layer, classNames, ignoreEscape);
429 
430           // we update actual only if it is still valid. Then below, whether valid
431           // or not, if we are at or past the width/height we leave
432           if (metrics.width <= width && metrics.height <= height) {
433             actual = guestimate;
434           }
435 
436           // we put this in a separate if statement JUST IN CASE it is ===.
437           // Unlikely, but possible, and why ruin a good thing?
438           if (metrics.width >= width || metrics.height >= height){
439             break;
440           }
441         }
442       }
443     }
444 
445     layer.style.fontSize = actual + "px";
446     this.set('calculatedFontSize', actual);
447   },
448 
449   /**
450     Extends renderSettingsToContext to add font size if shouldAutoFitText is YES.
451   */
452   applyAttributesToContext: function(orig, context) {
453     orig(context);
454 
455     if (this.get('shouldAutoFitText')) {
456       context.setStyle('font-size', this.get('calculatedFontSize') + "px");
457     }
458   }.enhance(),
459 
460   /**
461     @private
462     When the layer is first created, measurement will need to take place.
463   */
464   didCreateLayer: function(orig) {
465     orig();
466 
467     this.scheduleMeasurement();
468   }.enhance(),
469 
470   /** @private
471     If the view has a transitionIn property, we have to delay the transition
472     setup and execution until after we measure.  In order to prevent a brief
473     flash of the view, we ensure it is hidden while it is being measured and
474     adjusted.
475 
476     TODO: consider making the measurement state a formal SC.View state
477   */
478   _transitionIn: function (original, inPlace) {
479     // In order to allow views to measure and adjust themselves on append, we
480     // can't transition until after the measurement is done.
481     var preTransitionOpacity = this.get('layout').opacity || 1;
482 
483     this.adjust('opacity', 0);
484     this.invokeNext(function () {
485       this.adjust('opacity', preTransitionOpacity);
486       original(inPlace);
487     });
488   }.enhance()
489 
490 };
491 
492 /**
493  * @private
494  * @class
495  * Manages batch auto resizing.
496  *
497  * This used to be part of SC.AutoResize, but we shouldn't mix these
498  * methods/properties into each view.
499  */
500 SC.AutoResizeManager = {
501   /**
502     Views queued for batch resizing, but with no batch resize id.
503 
504     @property {SC.CoreSet}
505   */
506   measurementQueue: SC.CoreSet.create(),
507 
508   /**
509     Schedules a re-measurement for the specified view in the batch with the
510     given id.
511 
512     If a batch does not exist by that id, it will be created. If there is no id,
513     the view will be measured individually.
514 
515     @param view The view to measure.
516     @param id The id of the batch to measure the view in.
517   */
518   scheduleMeasurementForView: function(view) {
519     this.measurementQueue.add(view);
520 
521     SC.RunLoop.currentRunLoop.invokeOnce(this.doBatchResize);
522   },
523 
524   /**
525     Cancels a scheduled measurement for a view in the named batch id.
526 
527     @param view The view that was scheduled for measurement.
528     @param id The batch id the view was scheduled in.
529   */
530   cancelMeasurementForView: function(view, id) {
531     this.measurementQueue.remove(view);
532   },
533 
534   /**
535     Processes all autoResize batches. This will automatically be invoked at the
536     end of any run loop in which measurements were scheduled.
537   */
538   doBatchResize: function() {
539     // make sure we are called from the correct scope.
540     // this will make our property references below clearer.
541     if (this !== SC.AutoResizeManager) {
542       return SC.AutoResizeManager.doBatchResize();
543     }
544 
545     var tag, view, layer, measurementQueue = this.measurementQueue, prepared, autoResizeText,
546     i, len;
547 
548     while((len = measurementQueue.get('length')) > 0) {
549       prepared = NO;
550       // save the first tag we see
551       tag = measurementQueue[len - 1].get('batchResizeId');
552 
553       // now we iterate over all the views with the same tag
554       for(i = len - 1; i >= 0; --i) {
555         view = measurementQueue[i];
556 
557         // if the view has a different tag, skip it
558         if(view.get('batchResizeId') !== tag) continue;
559 
560         // make sure the view is still qualified to be measured
561         if(view.get('isVisibleInWindow') && view.get('shouldMeasureSize') && (layer = view.get('autoResizeLayer'))) {
562           autoResizeText = view.get('autoResizeText');
563 
564           // if the text is empty or a size is cached don't bother preparing
565           if(!SC.none(autoResizeText) && autoResizeText !== "" && !view.get('_cachedMetrics') && !prepared) {
566             // this is a bit of a hack: before we can prepare string measurement, there are cases where we
567             // need to reset the font size first (specifically, if we are also fitting text)
568             //
569             // It is expected that all views in a batch will have the same font settings.
570             view.prepareLayerForStringMeasurement(layer);
571 
572             // now we can tell SC to prepare the layer with the settings from the view's layer
573             SC.prepareStringMeasurement(layer, view.get('classNames'));
574             prepared = YES;
575           }
576 
577           view.measureSize(YES);
578         }
579 
580         // it's been handled
581         measurementQueue.remove(view);
582 
583         // if the view didn't have a tag, we can't batch so just move on
584         if(!tag) break;
585       }
586 
587       // only call teardown if prepare was called
588       if(prepared) {
589         SC.teardownStringMeasurement();
590       }
591     }
592   }
593 
594 };
595