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