1/** 2 * Report card text truncation. 3 * 4 * Rules: 5 * - Title 1 line -> description 4 lines 6 * - Title 2 lines -> description 3 lines 7 * - Title 3 lines -> description 2 lines 8 * - Title 4+ -> description 1 line 9 * 10 * Implementation details: 11 * - Title line count uses real rendered line boxes (Range.getClientRects). 12 * - Description is truncated destructively by words. 13 * - Candidate text always includes "..." during fitting to prevent a new 14 * line from being introduced by the ellipsis itself. 15 */ 16(function() { 17 const CARD_SELECTOR = '.report-card'; 18 const TITLE_SELECTOR = '.report-title'; 19 const TITLE_TEXT_SELECTOR = '.report-title span'; 20 const BRIEF_SELECTOR = '.report-brief'; 21 const ELLIPSIS = '...'; 22 23 let isApplying = false; 24 let observer = null; 25 26 function normalizeText(text) { 27 return (text || '').replace(/\s+/g, ' ').trim(); 28 } 29 30 function getLineHeight(element) { 31 const computed = window.getComputedStyle(element); 32 const parsed = parseFloat(computed.lineHeight); 33 34 if (!Number.isNaN(parsed)) { 35 return parsed; 36 } 37 38 const fontSize = parseFloat(computed.fontSize) || 16; 39 return fontSize * 1.2; 40 } 41 42 function getContentWidth(element) { 43 if (!element) { 44 return 0; 45 } 46 47 const rect = element.getBoundingClientRect(); 48 const styles = window.getComputedStyle(element); 49 const horizontalPadding = 50 (parseFloat(styles.paddingLeft) || 0) + 51 (parseFloat(styles.paddingRight) || 0); 52 const horizontalBorder = 53 (parseFloat(styles.borderLeftWidth) || 0) + 54 (parseFloat(styles.borderRightWidth) || 0); 55 56 return Math.max(0, rect.width - horizontalPadding - horizontalBorder); 57 } 58 59 function createTextProbe(styleSource) { 60 const styles = window.getComputedStyle(styleSource); 61 const probe = document.createElement('span'); 62 63 probe.style.position = 'absolute'; 64 probe.style.visibility = 'hidden'; 65 probe.style.pointerEvents = 'none'; 66 probe.style.left = '-99999px'; 67 probe.style.top = '0'; 68 probe.style.margin = '0'; 69 probe.style.padding = '0'; 70 probe.style.border = '0'; 71 probe.style.boxSizing = 'content-box'; 72 probe.style.whiteSpace = 'nowrap'; 73 probe.style.letterSpacing = styles.letterSpacing; 74 probe.style.wordSpacing = styles.wordSpacing; 75 probe.style.textTransform = styles.textTransform; 76 probe.style.textIndent = styles.textIndent; 77 probe.style.fontFamily = styles.fontFamily; 78 probe.style.fontSize = styles.fontSize; 79 probe.style.fontWeight = styles.fontWeight; 80 probe.style.fontStyle = styles.fontStyle; 81 probe.style.fontVariant = styles.fontVariant; 82 probe.style.lineHeight = styles.lineHeight; 83 84 return probe; 85 } 86 87 function createMultilineProbe(styleSource, widthPx) { 88 const styles = window.getComputedStyle(styleSource); 89 const probe = document.createElement('div'); 90 91 probe.style.position = 'absolute'; 92 probe.style.visibility = 'hidden'; 93 probe.style.pointerEvents = 'none'; 94 probe.style.left = '-99999px'; 95 probe.style.top = '0'; 96 probe.style.margin = '0'; 97 probe.style.padding = '0'; 98 probe.style.border = '0'; 99 probe.style.boxSizing = 'content-box'; 100 probe.style.whiteSpace = 'normal'; 101 probe.style.width = Math.floor(widthPx) + 'px'; 102 probe.style.overflowWrap = styles.overflowWrap; 103 probe.style.wordBreak = styles.wordBreak; 104 probe.style.letterSpacing = styles.letterSpacing; 105 probe.style.wordSpacing = styles.wordSpacing; 106 probe.style.textTransform = styles.textTransform; 107 probe.style.textIndent = styles.textIndent; 108 probe.style.fontFamily = styles.fontFamily; 109 probe.style.fontSize = styles.fontSize; 110 probe.style.fontWeight = styles.fontWeight; 111 probe.style.fontStyle = styles.fontStyle; 112 probe.style.fontVariant = styles.fontVariant; 113 probe.style.lineHeight = styles.lineHeight; 114 115 return probe; 116 } 117
118 function fitsInLineLimitOffscreen(element, text, maxLines) { 119 const contentWidth = getContentWidth(element); 120 121 if (contentWidth <= 0) { 122 return false; 123 } 124 125 const probe = createMultilineProbe(element, contentWidth); 126 probe.textContent = text; 127 document.body.appendChild(probe); 128 const fits = fitsWithinLineLimit(probe, maxLines); 129 document.body.removeChild(probe); 130 131 return fits; 132 } 133 134 function fitsInSingleLine(element, text) { 135 const contentWidth = getContentWidth(element); 136 137 if (contentWidth <= 0) { 138 return false; 139 } 140 141 const probe = createTextProbe(element); 142 probe.textContent = text; 143 document.body.appendChild(probe); 144 const probeWidth = probe.getBoundingClientRect().width; 145 document.body.removeChild(probe); 146 147 return probeWidth <= contentWidth + 0.5; 148 } 149 150 function countElementLines(element) { 151 if (!element) { 152 return 0; 153 } 154 155 const rect = element.getBoundingClientRect(); 156 157 if (rect.width <= 0 || rect.height <= 0) { 158 return 0; 159 } 160 161 try { 162 const range = document.createRange(); 163 range.selectNodeContents(element); 164 const rects = Array.from(range.getClientRects()); 165 166 if (rects.length > 0) { 167 const tops = []; 168 169 rects.forEach(function(lineRect) { 170 const roundedTop = Math.round(lineRect.top * 2) / 2; 171 const alreadyTracked = tops.some(function(top) { 172 return Math.abs(top - roundedTop) < 0.6; 173 }); 174 175 if (!alreadyTracked) { 176 tops.push(roundedTop); 177 } 178 }); 179 180 if (tops.length > 0) { 181 return tops.length; 182 } 183 } 184 } catch (error) { 185 // Fall through to height/line-height fallback. 186 } 187 188 const lineHeight = getLineHeight(element); 189 if (lineHeight <= 0) { 190 return 0; 191 } 192 193 return Math.max(1, Math.ceil((rect.height + 0.5) / lineHeight)); 194 } 195 196 function fitsWithinLineLimit(element, maxLines) { 197 if (!element) { 198 return false; 199 } 200 201 const rect = element.getBoundingClientRect(); 202 if (rect.width <= 0 || rect.height <= 0) { 203 return false; 204 } 205 206 const lineHeight = getLineHeight(element); 207 if (lineHeight > 0) { 208 // Small tolerance to absorb sub-pixel rounding differences so 209 // truncation is not overly conservative at line boundaries. 210 const allowedHeight = (maxLines * lineHeight) + 1.5; 211 if (rect.height <= allowedHeight) { 212 return true; 213 } 214 } 215 216 return countElementLines(element) <= maxLines; 217 } 218 219 function countWrappedTextLines(containerElement, styleElement, text) { 220 const content = normalizeText(text); 221 222 if (!containerElement || !styleElement || !content) { 223 return 0; 224 } 225 226 const containerRect = containerElement.getBoundingClientRect(); 227 const containerStyles = window.getComputedStyle(containerElement); 228 const styleSource = window.getComputedStyle(styleElement); 229 230 const horizontalPadding = 231 (parseFloat(containerStyles.paddingLeft) || 0) + 232 (parseFloat(containerStyles.paddingRight) || 0); 233 const horizontalBorder = 234 (parseFloat(containerStyles.borderLeftWidth) || 0) + 235 (parseFloat(containerStyles.borderRightWidth) || 0); 236 237 const contentWidth = Math.max(0, containerRect.width - horizontalPadding - horizontalBorder); 238 239 if (contentWidth <= 0) { 240 return 0; 241 } 242 243 const probe = document.createElement('div'); 244 probe.textContent = content; 245 probe.style.position = 'absolute'; 246 probe.style.visibility = 'hidden'; 247 probe.style.pointerEvents = 'none'; 248 probe.style.left = '-99999px'; 249 probe.style.top = '0'; 250 probe.style.margin = '0'; 251 probe.style.padding = '0'; 252 probe.style.border = '0'; 253 probe.style.boxSizing = 'content-box'; 254 probe.style.whiteSpace = 'normal'; 255 probe.style.width = Math.floor(contentWidth) + 'px'; 256 probe.style.overflowWrap = styleSource.overflowWrap;
257 probe.style.wordBreak = styleSource.wordBreak; 258 probe.style.letterSpacing = styleSource.letterSpacing; 259 probe.style.wordSpacing = styleSource.wordSpacing; 260 probe.style.textTransform = styleSource.textTransform; 261 probe.style.textIndent = styleSource.textIndent; 262 probe.style.fontFamily = styleSource.fontFamily; 263 probe.style.fontSize = styleSource.fontSize; 264 probe.style.fontWeight = styleSource.fontWeight; 265 probe.style.fontStyle = styleSource.fontStyle; 266 probe.style.fontVariant = styleSource.fontVariant; 267 probe.style.lineHeight = styleSource.lineHeight; 268 269 document.body.appendChild(probe); 270 const lines = countElementLines(probe); 271 document.body.removeChild(probe); 272 273 return lines; 274 } 275 276 function getTitleLines(card) { 277 const title = card.querySelector(TITLE_SELECTOR); 278 const titleText = card.querySelector(TITLE_TEXT_SELECTOR); 279 if (!title) { 280 return 0; 281 } 282 283 const rawTitleText = titleText ? titleText.textContent : title.textContent; 284 const measured = countWrappedTextLines(title, titleText || title, rawTitleText); 285 286 if (measured > 0) { 287 return measured; 288 } 289 290 const fallback = countElementLines(titleText || title); 291 // When titleText exists, the markup includes a structural <br> before it. 292 // Compensate that extra line in fallback path. 293 return titleText ? Math.max(1, fallback - 1) : fallback; 294 } 295 296 function getDescriptionLinesForTitleLines(titleLines) { 297 return Math.max(1, Math.min(4, 5 - titleLines)); 298 } 299 300 function getOriginalBriefText(brief) { 301 if (!brief.dataset.originalText) { 302 brief.dataset.originalText = normalizeText(brief.textContent); 303 } 304 305 return brief.dataset.originalText; 306 } 307 308 function splitWords(text) {
309 return normalizeText(text).split(' ').filter(Boolean); 310 } 311 312 function getMaxSingleLineCandidate(brief, text) { 313 const normalized = normalizeText(text); 314 315 if (!normalized) { 316 return ''; 317 } 318 319 let low = 1; 320 let high = normalized.length; 321 let best = ''; 322 323 while (low <= high) { 324 const mid = Math.floor((low + high) / 2); 325 const candidate = normalized.slice(0, mid).trimEnd() + ELLIPSIS; 326 327 if (fitsInSingleLine(brief, candidate)) { 328 best = candidate; 329 low = mid + 1; 330 } else { 331 high = mid - 1; 332 } 333 } 334 335 return best; 336 } 337 338 function getMaxMultiLineCandidateByChars(brief, text, maxLines) { 339 const normalized = normalizeText(text); 340 341 if (!normalized) { 342 return ''; 343 } 344 345 let low = 1; 346 let high = normalized.length; 347 let best = ''; 348 349 while (low <= high) { 350 const mid = Math.floor((low + high) / 2); 351 const candidate = normalized.slice(0, mid).trimEnd() + ELLIPSIS; 352 353 if (fitsInLineLimitOffscreen(brief, candidate, maxLines)) { 354 best = candidate; 355 low = mid + 1; 356 } else { 357 high = mid - 1; 358 } 359 } 360 361 return best; 362 } 363 364 function truncateBriefToLineCount(brief, maxLines) { 365 const original = getOriginalBriefText(brief); 366 367 if (!original) { 368 return false; 369 } 370 371 // Always start from full text so recalculation on resize/zoom is stable. 372 brief.textContent = original; 373 374 if (fitsWithinLineLimit(brief, maxLines)) { 375 return false; 376 } 377 378 const words = splitWords(original); 379 380 if (words.length === 0) { 381 return; 382 } 383 384 if (maxLines === 1) { 385 const bestCandidate = getMaxSingleLineCandidate(brief, original); 386 const fallbackOneLine = bestCandidate || (words[0] + ELLIPSIS); 387 brief.textContent = fallbackOneLine; 388 return brief.textContent !== original; 389 } 390 391 const bestCandidate = getMaxMultiLineCandidateByChars(brief, original, maxLines); 392 393 if (bestCandidate) { 394 brief.textContent = bestCandidate; 395 return brief.textContent !== original; 396 } 397 398 // If even one word + ellipsis does not fit, keep one-word ellipsis as 399 // the smallest meaningful destructive truncation. 400 brief.textContent = words[0] + ELLIPSIS; 401 return brief.textContent !== original; 402 } 403 404 function clampCard(card) { 405 const brief = card.querySelector(BRIEF_SELECTOR); 406 407 if (!brief) { 408 return false; 409 } 410 411 const titleLines = Math.max(1, getTitleLines(card)); 412 const briefLines = getDescriptionLinesForTitleLines(titleLines); 413 414 return truncateBriefToLineCount(brief, briefLines); 415 } 416 417 function applyAllCards(options = {}) { 418 const emitUpdate = options.emitUpdate !== false; 419 const onlyVisible = options.onlyVisible === true; 420 const cards = Array.from(document.querySelectorAll(CARD_SELECTOR)); 421 422 isApplying = true; 423 424 if (observer) { 425 observer.disconnect(); 426 } 427 428 let hasTextUpdates = false; 429 430 cards.forEach(function(card) { 431 const rect = card.getBoundingClientRect(); 432 const isVisible = rect.width > 0 && rect.height > 0; 433 if (!isVisible) { 434 return; 435 } 436 437 if (onlyVisible) { 438 const carouselItem = card.closest('.carousel-item'); 439 if (carouselItem && !carouselItem.classList.contains('active')) { 440 return; 441 } 442 } 443 444 if (isVisible) { 445 hasTextUpdates = clampCard(card) || hasTextUpdates; 446 } 447 }); 448 449 if (observer) { 450 observer.observe(document.body, { 451 childList: true, 452 subtree: true, 453 }); 454 } 455 456 if (emitUpdate && hasTextUpdates) { 457 document.dispatchEvent(new CustomEvent('djs:report-card-clamp-updated')); 458 } 459 460 isApplying = false; 461 } 462 463 function debounce(fn, delay) { 464 let timer = null; 465 466 return function() { 467 clearTimeout(timer); 468 timer = setTimeout(fn, delay); 469 }; 470 } 471 472 function runAfterLayoutSettles(options = {}) { 473 applyAllCards(options); 474 475 window.requestAnimationFrame(function() { 476 applyAllCards(options); 477 }); 478 479 window.setTimeout(function() { applyAllCards(options); }, 180); 480 } 481 482 const debouncedApply = debounce(runAfterLayoutSettles, 60); 483 484 document.addEventListener('DOMContentLoaded', runAfterLayoutSettles); 485 window.addEventListener('load', runAfterLayoutSettles); 486 window.addEventListener('resize', debouncedApply); 487 window.addEventListener('orientationchange', debouncedApply); 488 489 document.addEventListener('djs:cards-heights-equalized', function(event) { 490 const reason = event && event.detail ? event.detail.reason : ''; 491 492 // Prevent ping-pong loop: clamp ->
492 equalize -> clamp -> equalize... 493 if (reason === 'clamp-update') { 494 return; 495 } 496 497 debouncedApply(); 498 }); 499 500 if (document.fonts && document.fonts.ready) { 501 document.fonts.ready.then(runAfterLayoutSettles).catch(function() { 502 // Ignore font-ready errors. 503 }); 504 } 505 506 observer = new MutationObserver(function(mutations) { 507 if (isApplying) { 508 return; 509 } 510 511 function affectsReportCards(mutation) { 512 const target = mutation.target; 513 const targetElement = target instanceof Element ? target : target.parentElement; 514 515 if (!targetElement) { 516 return false; 517 } 518 519 if (targetElement.closest(CARD_SELECTOR)) { 520 return true; 521 } 522 523 const addedNodes = Array.from(mutation.addedNodes || []); 524 return addedNodes.some(function(node) { 525 if (!(node instanceof Element)) { 526 return false; 527 } 528 529 return Boolean( 530 node.matches(CARD_SELECTOR) || 531 node.querySelector(CARD_SELECTOR) 532 ); 533 }); 534 } 535 536 const hasExternalMutation = mutations.some(function(mutation) { 537 const node = mutation.target; 538 const element = node instanceof Element ? node : node.parentElement; 539 540 if (!element) { 541 return false; 542 } 543 544 if (element.closest(BRIEF_SELECTOR)) { 545 return false; 546 } 547 548 return affectsReportCards(mutation); 549 }); 550 551 if (hasExternalMutation) { 552 debouncedApply(); 553 } 554 }); 555 556 observer.observe(document.body, { 557 childList: true, 558 subtree: true, 559 }); 560})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.