1import { Controller } from "@hotwired/stimulus" 2 3const THUMBNAIL_BATCH_SIZE_DESKTOP = 20 4const THUMBNAIL_BATCH_SIZE_MOBILE = 12 5const SLIDE_BUFFER_DESKTOP = 2 6const SLIDE_BUFFER_MOBILE = 1 7const MAX_THUMBNAIL_DPR_DESKTOP = 2 8const MAX_THUMBNAIL_DPR_MOBILE = 1.5 9const MAX_MODAL_DPR_MOBILE = 1.5 10 11export default class extends Controller { 12 static values = { url: String, title: String, docId: String } 13 static targets = ["thumbnails"] 14 15 connect() { 16 this.pdfDoc = null 17 this.currentPage = 1 18 this.totalPages = 0 19 this.thumbnailsRendered = 0 20 this.modal = null 21 this.swiper = null 22 this.renderedSlides = new Set() 23 this.isMobile = this.detectMobile() 24 this.isIOS = this.detectIOS() 25 this._statePushed = false 26 this._suppressHashOpen = false 27 this._boundKeydown = (e) => this.handleKeydown(e) 28 this._boundHashChange = () => this.maybeOpenFromHash() 29 this._boundPopstate = (e) => this.handlePopstate(e) 30 this._boundResize = () => this.handleResize() 31 this.injectTextLayerCSS() 32 this.observeForThumbnails() 33 this.maybeOpenFromHash() 34 window.addEventListener("hashchange", this._boundHashChange) 35 window.addEventListener("popstate", this._boundPopstate) 36 window.addEventListener("resize", this._boundResize) 37 } 38 39 // A product page can carry half a dozen datasheets, and decoding every PDF on 40 // connect saturates the main thread â which is what left the loading skeletons 41 // sitting above half-rendered thumbnails, reading as duplicated documents. 42 // Load each one only as it comes near the viewport. 43 observeForThumbnails() { 44 if (!this.hasThumbnailsTarget) return 45 if (typeof IntersectionObserver === "undefined") return this.loadThumbnails() 46 47 this._thumbObserver = new IntersectionObserver((entries) => { 48 if (!entries.some((e) => e.isIntersecting)) return 49 this.disconnectThumbObserver() 50 this.loadThumbnails() 51 }, {rootMargin: "600px 0px"}) 52 this._thumbObserver.observe(this.element) 53 } 54 55 disconnectThumbObserver() { 56 if (!this._thumbObserver) return 57 this._thumbObserver.disconnect() 58 this._thumbObserver = null 59 } 60 61 removeSkeleton() { 62 const skeleton = this.element.querySelector("[data-pdf-skeleton]") 63 if (skeleton) skeleton.remove() 64 } 65 66 disconnect() { 67 this.disconnectThumbObserver() 68 window.removeEventListener("hashchange", this._boundHashChange) 69 window.removeEventListener("popstate", this._boundPopstate) 70 window.removeEventListener("resize", this._boundResize) 71 this.closeViewer({ updateHash: false }) 72 if (this.pdfDoc) { 73 this.pdfDoc.destroy() 74 this.pdfDoc = null 75 } 76 } 77 78 detectMobile() { 79 return window.matchMedia("(max-width: 767px)").matches || ("ontouchstart" in window && window.innerWidth < 900) 80 } 81 82 detectIOS() { 83 return /iPad|iPhone|iPod/.test(navigator.userAgent) || 84 (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) 85 } 86 87 handleResize() { 88 this.isMobile = this.detectMobile() 89 } 90 91 injectTextLayerCSS() { 92 if (document.getElementById("pdf-text-layer-css")) return 93 const style = document.createElement("style") 94 style.id = "pdf-text-layer-css" 95 style.textContent = ` 96 .pdf-text-layer { 97 position: absolute; 98 inset: 0; 99 overflow: hidden; 100 opacity: 1; 101 line-height: 1; 102 -webkit-text-size-adjust: none; 103 text-size-adjust: none; 104 forced-color-adjust: none; 105 transform-origin: 0 0; 106 z-index: 2; 107 } 108 .pdf-text-layer :is(span, br) { 109 color: transparent; 110 position: absolute; 111 white-space: pre; 112 cursor: text; 113 transform-origin: 0% 0%; 114 } 115 .pdf-text-layer span::selection { 116 background: rgba(59, 130, 246, 0.3); 117 } 118 .pdf-text-layer span::-moz-selection { 119 background: rgba(59, 130, 246, 0.3); 120 } 121 ` 122 document.head.appendChild(style) 123 } 124 125 async ensurePdfLoaded() { 126 if (this.pdfDoc) return true 127 const pdfjsLib = await import("pdfjs-dist") 128 pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.worker.min.mjs" 129 try { 130 const opts = { url: this.urlValue } 131 // On mobile / iOS, avoid pulling the whole PDF into memory; rely on 132 // HTTP range requests to fetch only the bytes for visible pages. 133 if (this.isMobile || this.isIOS) { 134 opts.disableAutoFetch = true 135 opts.disableStream = false 136 } 137 this.pdfDoc = await pdfjsLib.getDocument(opts).promise 138 this.totalPages = this.pdfDoc.numPages 139 return true 140 } catch (err) { 141 console.error("PDF load error:", err) 142 return false 143 } 144 } 145 146 // ââ Thumbnails ââ 147 148 async loadThumbnails() { 149 if (!this.hasUrlValue || !this.hasThumbnailsTarget) return 150 151 if (!(await this.ensurePdfLoaded())) { 152 this.thumbnailsTarget.innerHTML = '<p class="text-sm text-gray-400 col-span-full">Unable to load PDF preview</p>' 153 this.removeSkeleton() 154 return 155 } 156 157 this.thumbnailsTarget.innerHTML = "" 158 159 // Drop the placeholder before the first page renders, not after the whole 160 // batch. renderThumbnailBatch appends up to 20 pages one at a time, so 161 // clearing it afterwards left four grey boxes stacked above the real pages 162 // for as long as the batch took. 163 this.removeSkeleton() 164 165 await this.renderThumbnailBatch() 166 167 this.refreshLoadMoreButton() 168 } 169 170 thumbnailBatchSize() { 171 return this.isMobile ? THUMBNAIL_BATCH_SIZE_MOBILE : THUMBNAIL_BATCH_SIZE_DESKTOP 172 } 173 174 async renderThumbnailBatch() { 175 const batchSize = this.thumbnailBatchSize() 176 const start = this.thumbnailsRendered + 1 177 const end = Math.min(this.thumbnailsRendered + batchSize, this.totalPages) 178 if (start > end) return 179 180 const maxDpr = this.isMobile ? MAX_THUMBNAIL_DPR_MOBILE : MAX_THUMBNAIL_DPR_DESKTOP 181 182 for (let i = start; i <= end; i++) { 183 const page = await this.pdfDoc.getPage(i) 184 const unscaled = page.getViewport({ scale: 1 }) 185 // Cap DPR on thumbnails â they're small, extra resolution wastes memory 186 // (and on iOS DPR=3 makes 20 thumbnails ~40MB+ of canvas backing store). 187 const dpr = Math.min(window.devicePixelRatio || 1, maxDpr) 188 const cssWidth = 200 189 const scale = (cssWidth * dpr) / unscaled.width 190 const viewport = page.getViewport({ scale }) 191 192 const canvas = document.createElement("canvas") 193 canvas.width = viewport.width 194 canvas.height = viewport.height 195 canvas.style.width = (viewport.width / dpr) + "px" 196 canvas.style.height = (viewport.height / dpr) + "px" 197 canvas.className = "rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md hover:border-brand-400 dark:hover:border-brand-400 transition-all duration-200 hover:scale-[1.03]" 198 canvas.dataset.page = i 199 canvas.title = "Page " + i + " of " + this.totalPages 200 201 const ctx = canvas.getContext("2d") 202 await page.render({ canvasContext: ctx, viewport }).promise 203 204 // Free the parsed operator stream now that we have the pixels â this 205 // is the single biggest fix for iOS Safari OOM crashes on long PDFs 206 // (without it, every rendered page keeps its parsed state in memory).
207 try { page.cleanup() } catch (_e) {} 208 209 // On mobile/iOS, the in-page PDF.js modal reliably crashes Safari for 210 // any non-trivial PDF. Wrap the thumbnail in a real <a> link to the 211 // PDF with the #page= anchor â iOS Safari (and Android Chrome) render 212 // PDFs natively at the OS level, which is dramatically more memory- 213 // efficient than canvas rendering and gives users the familiar 214 // swipe-through-PDF experience. 215 const useNativeViewer = this.isMobile || this.isIOS 216 const trigger = document.createElement(useNativeViewer ? "a" : "div") 217 trigger.className = "relative group block cursor-pointer" 218 if (useNativeViewer) { 219 trigger.href = this.urlValue + "#page=" + i 220 trigger.target = "_blank" 221 trigger.rel = "noopener noreferrer" 222 trigger.setAttribute("aria-label", "Open page " + i + " in PDF viewer") 223 } else { 224 trigger.addEventListener("click", () => this.openAtPage(i)) 225 } 226 trigger.appendChild(canvas) 227 228 const badge = document.createElement("span") 229 badge.className = "absolute bottom-1.5 right-1.5 text-[10px] font-medium bg-black/60 text-white px-1.5 py-0.5 rounded pointer-events-none" 230 badge.textContent = i 231 trigger.appendChild(badge) 232 233 this.thumbnailsTarget.appendChild(trigger) 234 } 235 236 this.thumbnailsRendered = end 237 238 // Doc-wide cleanup: releases any cached page resources that aren't 239 // currently in use. Cheap; worth doing after each batch on mobile. 240 if (this.isMobile && this.pdfDoc) { 241 try { await this.pdfDoc.cleanup() } catch (_e) {} 242 } 243 } 244 245 refreshLoadMoreButton() { 246 if (this.loadMoreContainer && this.loadMoreContainer.parentNode) { 247 this.loadMoreContainer.remove() 248 } 249 this.loadMoreContainer = null 250 251 const remaining = this.totalPages - this.thumbnailsRendered 252 if (remaining <= 0) return 253 254 const container = document.createElement("div") 255 container.className = "mt-5 flex justify-center" 256 257 const nextBatch = Math.min(this.thumbnailBatchSize(), remaining) 258 const button = document.createElement("button") 259 button.type = "button" 260 button.className = "inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-sm hover:shadow-md hover:border-brand-400 dark:hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400 transition disabled:opacity-50 disabled:cursor-not-allowed" 261 button.innerHTML = 262 '<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /></svg>
262' + 263 '<span>Load ' + nextBatch + ' more page' + (nextBatch === 1 ? '' : 's') + '</span>' + 264 '<span class="text-xs font-normal text-gray-400 dark:text-gray-500">(' + this.thumbnailsRendered + ' of ' + this.totalPages + ' shown)</span>' 265 266 button.addEventListener("click", async () => { 267 button.disabled = true 268 const label = button.querySelector("span") 269 const originalText = label.textContent 270 label.textContent = "Loadingâ¦" 271 await this.renderThumbnailBatch() 272 this.refreshLoadMoreButton() 273 if (button.parentNode) { 274 label.textContent = originalText 275 button.disabled = false 276 } 277 }) 278 279 container.appendChild(button) 280 this.thumbnailsTarget.parentNode.insertBefore(container, this.thumbnailsTarget.nextSibling) 281 this.loadMoreContainer = container 282 } 283 284 // ââ Modal viewer with Swiper + text layer ââ 285 286 async openAtPage(pageNum, opts = {}) { 287 // Mobile/iOS: hand off to the native PDF viewer rather than the PDF.js 288 // modal. The PDF.js + Swiper combo accumulates enough memory on a long 289 // PDF that iOS Safari OOM-crashes the tab on the first few page swipes; 290 // native rendering doesn't have that problem. Thumbnails on mobile use 291 // <a> links so they reach the native viewer without going through this 292 // path â this branch only runs for programmatic deep-link calls. 293 if (this.isMobile || this.isIOS) { 294 const target = this.urlValue + "#page=" + (pageNum || 1) 295 if (opts.fromUserGesture === true) { 296 try { window.open(target, "_blank") } catch (_e) { 297 window.location.assign(target) 298 } 299 } else { 300 // No user gesture (deep-link auto-open) â popup-blocked, and 301 // yanking the user out of the product page would be jarring. 302 // Just scroll the PDF section into view. 303 try { this.element.scrollIntoView({ behavior: "smooth", block: "start" }) } catch (_e) {} 304 } 305 this.clearHashIfMine() 306 return 307 } 308 309 this.currentPage = pageNum || 1 310 this.renderedSlides = new Set() 311 312 if (!(await this.ensurePdfLoaded())) { 313 return 314 } 315 316 // Clamp to valid range 317 if (this.currentPage > this.totalPages) this.currentPage = this.totalPages 318 if (this.currentPage < 1) this.currentPage = 1 319 320 // Get first page dimensions to size the modal to the paper aspect ratio 321 const firstPage = await this.pdfDoc.getPage(1) 322 const vp = firstPage.getViewport({ scale: 1 }) 323 this.pageAspect = vp.width / vp.height 324 325 this.buildModal() 326 document.body.appendChild(this.modal) 327 document.body.style.overflow = "hidden" 328 document.addEventListener("keydown", this._boundKeydown) 329 330 // History trap: push a marker entry so iOS Safari edge-swipe-back (and 331 // browser back) closes the viewer instead of navigating away from the 332 // product page. We only do this on mobile â on desktop, browser back 333 // behaving normally is the expected interaction. 334 if (this.isMobile) { 335 try { 336 history.pushState( 337 { pdfViewerOpen: String(this.docIdValue || "") }, 338 "", 339 window.location.href 340 ) 341 this._statePushed = true 342 } catch (_e) { 343 this._statePushed = false 344 } 345 } 346 347 this.hideLoading() 348 this.updateControls() 349 350 const swiperModule = await import("swiper") 351 const Swiper = swiperModule.default 352 const isMobile = this.isMobile 353 354 // Swiper v11 bundled build includes FreeMode and Mousewheel as modules 355 const modules = [] 356 if (swiperModule.FreeMode) modules.push(swiperModule.FreeMode) 357 if (swiperModule.Mousewheel) modules.push(swiperModule.Mousewheel) 358 359 this.swiper = new Swiper(this.modal.querySelector(".swiper"), { 360 modules: modules, 361 initialSlide: this.currentPage - 1, 362 direction: isMobile ? "horizontal" : "vertical", 363 spaceBetween: isMobile ? 0 : 16, 364 freeMode: isMobile ? false : { enabled: true, sticky: false, momentumRatio: 0.5 }, 365 mousewheel: isMobile ? false : { enabled: true, releaseOnEdges: true, sensitivity: 1 }, 366 simulateTouch: isMobile, 367 // On mobile, preventDefault on touchstart so iOS doesn't claim the 368 // gesture for native edge-swipe / pinch / scroll. Combined with 369 // touch-action:none on the swiper body, this keeps every horizontal 370 // swipe inside Swiper's handler. 371 touchStartPreventDefault: isMobile, 372 touchMoveStopPropagation: isMobile, 373 allowTouchMove: true, 374 threshold: isMobile ? 5 : 0, 375 on: { 376 slideChange: (sw) => { 377 this.currentPage = sw.activeIndex + 1 378 this.updateControls() 379 this.renderVisibleSlides() 380 this.writeHash(this.currentPage) 381 }, 382 progress: () => { 383 if (!this.swiper) return 384 const idx = this.swiper.activeIndex 385 if (idx + 1 !== this.currentPage) { 386 this.currentPage = idx + 1 387 this.updateControls() 388 this.renderVisibleSlides() 389 this.writeHash(this.currentPage) 390 } 391 } 392 } 393 }) 394 395 await this.renderVisibleSlides() 396 if (opts.updateHash !== false) this.writeHash(this.currentPage) 397 } 398 399 async renderVisibleSlides() { 400 const idx = this.currentPage - 1 401 const buffer = this.isMobile ? SLIDE_BUFFER_MOBILE : SLIDE_BUFFER_DESKTOP 402 const window = new Set() 403 for (let i = idx - buffer; i <= idx + buffer; i++) { 404 if (i >= 0 && i < this.totalPages) window.add(i) 405 } 406 407 // Render anything in the window that isn't already painted 408 const toRender = [...window].filter(i => !this.renderedSlides.has(i))
409 await Promise.all(toRender.map(i => this.renderSlide(i))) 410 411 // Evict any rendered slide outside the window â frees canvas backing 412 // store + text-layer DOM, the main source of iOS Safari memory pressure. 413 for (const i of [...this.renderedSlides]) { 414 if (!window.has(i)) this.evictSlide(i) 415 } 416 417 // Doc-wide cleanup after each navigation step on mobile â releases any 418 // PDF.js caches the cleanup-per-page calls couldn't reach. 419 if (this.isMobile && this.pdfDoc) { 420 try { await this.pdfDoc.cleanup() } catch (_e) {} 421 } 422 } 423 424 evictSlide(slideIndex) { 425 if (!this.modal) return 426 const slideEl = this.modal.querySelectorAll(".swiper-slide")[slideIndex] 427 if (!slideEl) { 428 this.renderedSlides.delete(slideIndex) 429 return 430 } 431 const canvas = slideEl.querySelector("canvas") 432 const textLayer = slideEl.querySelector(".pdf-text-layer") 433 if (canvas) { 434 // Setting both width and height to 0 releases the canvas backing store 435 canvas.width = 0 436 canvas.height = 0 437 canvas.removeAttribute("style") 438 } 439 if (textLayer) { 440 textLayer.innerHTML = "" 441 textLayer.removeAttribute("style") 442 } 443 this.renderedSlides.delete(slideIndex) 444 } 445 446 async renderSlide(slideIndex) { 447 if (this.renderedSlides.has(slideIndex)) return 448 this.renderedSlides.add(slideIndex) 449 450 const pageNum = slideIndex + 1 451 const slideEl = this.modal.querySelectorAll(".swiper-slide")[slideIndex] 452 if (!slideEl) return 453 454 try { 455 const page = await this.pdfDoc.getPage(pageNum) 456 const canvas = slideEl.querySelector("canvas") 457 const textLayerDiv = slideEl.querySelector(".pdf-text-layer") 458 const ctx = canvas.getContext("2d") 459 460 const container = this.modal.querySelector("[data-pdf-body]") 461 const containerPadding = this.isMobile ? 0 : 32 462 const containerWidth = Math.max(container.clientWidth - containerPadding, 200) 463 // Cap DPR aggressively on mobile/iOS â 1.5 still looks sharp on a 464 // 3x iPhone display when the canvas is shown at near-fullscreen, and 465 // it saves ~44% per-canvas memory vs DPR 2 (which itself was already 466 // capped from raw DPR 3). Together with page.cleanup() below, this 467 // keeps the viewer well under iOS Safari's per-tab memory ceiling. 468 const rawDpr = window.devicePixelRatio || 1 469 const dpr = (this.isMobile || this.isIOS) ? Math.min(rawDpr, MAX_MODAL_DPR_MOBILE) : rawDpr 470 const unscaledViewport = page.getViewport({ scale: 1 }) 471 const cssScale = Math.min(2, containerWidth / unscaledViewport.width) 472 473 // Render canvas at retina resolution 474 const viewport = page.getViewport({ scale: cssScale * dpr }) 475 canvas.width = viewport.width 476 canvas.height = viewport.height 477 canvas.style.width = (viewport.width / dpr) + "px" 478 canvas.style.height = (viewport.height / dpr) + "px" 479 await page.render({ canvasContext: ctx, viewport }).promise 480 481 // Skip the text layer on mobile â it can add thousands of absolute- 482 // positioned spans per page (real memory cost on long PDFs), and text 483 // selection on touch is awkward anyway. Also free the parsed page 484 // operator stream now that we've painted â without this, every page 485 // the user swipes to keeps its parsed state in memory. 486 if (this.isMobile) { 487 textLayerDiv.style.display = "none" 488 try { page.cleanup() } catch (_e) {} 489 return 490 } 491 492 // Render text layer at CSS resolution for selection 493 const cssViewport = page.getViewport({ scale: cssScale }) 494 textLayerDiv.style.display = "" 495 textLayerDiv.style.width = cssViewport.width + "px" 496 textLayerDiv.style.height = cssViewport.height + "px" 497 textLayerDiv.innerHTML = "" 498 499 const textContent = await page.getTextContent() 500 const { TextLayer } = await import("pdfjs-dist") 501 const textLayer = new TextLayer({ 502 textContentSource: textContent, 503 container: textLayerDiv, 504 viewport: cssViewport, 505 }) 506 await textLayer.render() 507 try { page.cleanup() } catch (_e) {} 508 } catch (err) { 509 console.error("PDF render error (page " + (pageNum) + "):", err) 510 } 511 } 512 513 async open(event) { 514 event.preventDefault() 515 event.stopPropagation() 516 await this.openAtPage(1) 517 } 518 519 prevPage() { 520 if (this.swiper) this.swiper.slidePrev() 521 } 522 523 nextPage() { 524 if (this.swiper) this.swiper.slideNext() 525 } 526 527 updateControls() { 528 if (!this.modal) return 529 const pageInfo = this.modal.querySelector("[data-pdf-page-info]") 530 if (pageInfo) pageInfo.textContent = "Page " + this.currentPage + " of " + this.totalPages 531 const prevBtn = this.modal.querySelector("[data-pdf-prev]") 532 const nextBtn = this.modal.querySelector("[data-pdf-next]") 533 if (prevBtn) prevBtn.disabled = this.currentPage <= 1 534 if (nextBtn) nextBtn.disabled = this.currentPage >= this.totalPages 535 } 536 537 closeViewer(opts = {}) { 538 const wasOpen = !!this.modal 539 if (this.swiper) { 540 this.swiper.destroy(true, true) 541 this.swiper = null 542 } 543 if (this.modal && this.modal.parentNode) { 544 this.modal.remove() 545 this.modal = null 546 } 547 this.renderedSlides = new Set() 548 document.body.style.overflow = "" 549 document.removeEventListener("keydown", this._boundKeydown) 550 551 if (!wasOpen) { 552 this._statePushed = false 553 return 554 } 555 556 const popMarker = this._statePushed && !opts.fromPopstate 557 this._statePushed = false 558 559 // Always scrub our own hash from the current entry first. This handles 560 // the simple cases (no marker pushed; popstate-induced close) and the 561 // initial step of the deep-link case below. 562 if (opts.updateHash !== false) this.clearHashIfMine() 563 564 if (popMarker) { 565 // Programmatic close (X button, Escape, hash navigation) â pop the 566 // marker we pushed in openAtPage so the user's back button doesn't 567 // land on a stale "viewer open" history entry. Suppress the resulting 568 // hashchange so we don't immediately re-open from a still-present 569 // hash, and re-scrub the hash *after* the back lands: if the user 570 // arrived via a deep link, the entry we're popping to still has our 571 // pdf=...&page=... in its URL. 572 this._suppressHashOpen = true
573 try { history.back() } catch (_e) {} 574 setTimeout(() => { 575 this.clearHashIfMine() 576 this._suppressHashOpen = false 577 }, 120) 578 } 579 } 580 581 handlePopstate(_event) { 582 if (!this.modal) return 583 // The user navigated back (browser back, iOS edge swipe, gesture nav). 584 // Close the viewer without touching the URL again â the browser already 585 // popped to the previous entry. Suppress hashchange-driven re-open in 586 // case the previous entry's URL still contains our hash. 587 this._statePushed = false 588 this._suppressHashOpen = true 589 this.closeViewer({ updateHash: false, fromPopstate: true }) 590 setTimeout(() => { this._suppressHashOpen = false }, 250) 591 } 592 593 // ââ Deep linking via URL hash ââ 594 // Hash format: #pdf=<doc-id>&page=<n> 595 // 596 // Multiple PDF viewers can be on the same product page, so each instance 597 // checks whether the hash's pdf id matches its own docIdValue before 598 // opening or writing. 599 600 parseHash() { 601 const raw = (window.location.hash || "").replace(/^#/, "") 602 if (!raw) return null 603 const params = new URLSearchParams(raw) 604 const id = params.get("pdf") 605 if (!id) return null 606 const page = parseInt(params.get("page") || "1", 10) 607 return { id: id, page: isNaN(page) ? 1 : Math.max(1, page) } 608 } 609 610 hashIsMine(parsed) { 611 return !!parsed && this.hasDocIdValue && String(parsed.id) === String(this.docIdValue) 612 } 613 614 async maybeOpenFromHash() { 615 if (this._suppressHashOpen) return 616 const parsed = this.parseHash() 617 if (!this.hashIsMine(parsed)) return 618 619 // On mobile/iOS we never open the PDF.js modal â it crashes Safari for 620 // long PDFs. Don't auto-redirect to the native viewer either (Safari 621 // blocks unprompted navigation, and yanking the user out of a product 622 // page they just landed on is jarring). Scroll the PDF section into 623 // view so they can tap the page they want. 624 if (this.isMobile || this.isIOS) { 625 try { this.element.scrollIntoView({ behavior: "smooth", block: "start" }) } catch (_e) {} 626 this.clearHashIfMine() 627 return 628 } 629 630 if (!(await this.ensurePdfLoaded())) return 631 const targetPage = Math.min(parsed.page, this.totalPages) 632 if (this.modal && this.swiper) { 633 this.swiper.slideTo(targetPage - 1, 0) 634 } else { 635 await this.openAtPage(targetPage, { updateHash: false }) 636 } 637 } 638 639 writeHash(page) { 640 if (!this.hasDocIdValue) return 641 const params = new URLSearchParams() 642 params.set("pdf", this.docIdValue) 643 params.set("page", String(page)) 644 const newHash = "#" + params.toString() 645 if (window.location.hash !== newHash) { 646 const url = window.location.pathname + window.location.search + newHash 647 // Preserve the current state object so the back-button marker pushed 648 // in openAtPage survives slide-change replaceStates. 649 history.replaceState(history.state, "", url) 650 } 651 } 652 653 clearHashIfMine() { 654 const parsed = this.parseHash() 655 if (this.hashIsMine(parsed)) { 656 const url = window.location.pathname + window.location.search 657 history.replaceState(null, "", url) 658 } 659 } 660 661 handleKeydown(event) { 662 if (event.key === "Escape" || event.key === " ") { 663 event.preventDefault() 664 this.closeViewer() 665 } 666 if (event.key === "ArrowLeft" || event.key === "ArrowUp") this.prevPage() 667 if (event.key === "ArrowRight" || event.key === "ArrowDown") this.nextPage() 668 } 669 670 hideLoading() { 671 const loader = this.modal ? this.modal.querySelector("[data-pdf-loader]") : null 672 if (loader) loader.classList.add("hidden") 673 } 674 675 showError() { 676 const body = this.modal ? this.modal.querySelector("[data-pdf-body]") : null 677 if (body) { 678 body.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400">' + 679 '<svg class="h-12 w-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">' + 680 '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />' + 681 '</svg>' + 682 '<p class="font-medium">Unable to load PDF</p>' + 683 '<p class="text-sm mt-1">Try downloading the file instead</p>' + 684 '</div>' 685 } 686 } 687 688 buildModal() { 689 const title = this.hasTitleValue ? this.titleValue : "PDF Viewer" 690 const isMobile = this.isMobile 691 692 const m = document.createElement("div") 693 m.className = "fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 " + (isMobile ? "" : "backdrop-blur-sm") 694 m.style.animation = "fadeIn 0.15s ease-out" 695 m.addEventListener("click", (e) => { if (e.target === m) this.closeViewer() }) 696 697 // Build slide HTML for each page (canvas/text-layer painted on demand 698 // by renderSlide; only ~3-5 slides are populated at any time). 699 let slidesHtml = "" 700 for (let i = 0; i < this.totalPages; i++) {
701 slidesHtml += '<div class="swiper-slide !flex items-center justify-center">' + 702 '<div class="relative inline-block">' + 703 '<canvas class="' + (isMobile ? 'shadow-none' : 'shadow-lg rounded') + '"></canvas>' + 704 '<div class="pdf-text-layer"></div>' + 705 '</div></div>' 706 } 707 708 const inner = document.createElement("div") 709 710 if (isMobile) { 711 // Fullscreen on mobile â fills the viewport edge-to-edge, no rounded 712 // corners. Uses 100dvh so iOS Safari URL bar transitions are clean. 713 inner.className = "relative bg-white dark:bg-gray-900 shadow-2xl flex flex-col overflow-hidden" 714 inner.style.width = "100vw" 715 inner.style.height = "100dvh" 716 inner.style.maxWidth = "100vw" 717 inner.style.maxHeight = "100dvh" 718 } else { 719 // Desktop: size to paper aspect ratio within a safe viewport area 720 const chromeH = 100 721 const safeH = window.visualViewport ? window.visualViewport.height : window.innerHeight 722 const maxH = safeH * 0.92 723 const maxW = Math.min(window.innerWidth * 0.95, 896) 724 const contentH = maxH - chromeH 725 const idealW = contentH * (this.pageAspect || 0.707) + 32 726 const modalW = Math.round(Math.min(idealW, maxW)) 727 const modalH = Math.round(maxH) 728 729 inner.className = "relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl flex flex-col overflow-hidden" 730 inner.style.width = "min(" + modalW + "px, 95vw)" 731 inner.style.height = "min(" + modalH + "px, 92dvh)" 732 inner.style.maxHeight = "calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 32px)" 733 } 734 inner.addEventListener("click", (e) => e.stopPropagation()) 735 736 // Safe-area insets â render-as-no-op on platforms without notches/home-indicators 737 const headerStyle = isMobile 738 ? 'style="padding-top: calc(env(safe-area-inset-top) + 0.75rem); padding-left: calc(env(safe-area-inset-left) + 1rem); padding-right: calc(env(safe-area-inset-right) + 1rem);"' 739 : '' 740 const footerStyle = isMobile 741 ? 'style="padding-bottom: calc(env(safe-area-inset-bottom) + 0.625rem); padding-left: calc(env(safe-area-inset-left) + 1rem); padding-right: calc(env(safe-area-inset-right) + 1rem);"' 742 : '' 743 744 inner.innerHTML = 745 // Header 746 '<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 flex-shrink-0" ' + headerStyle + '>' + 747 '<div class="flex items-center gap-3 min-w-0">' + 748 '<svg class="h-5 w-5 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625ZM7.5 15a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 7.5 15Zm.75 2.25a.75.75 0 0 0 0 1.5H12a.75.75 0 0 0 0-1.5H8.25Z M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 0 3.434-1.279 19.105 19.105 0 0 0-6.963-4.405Z" /></svg>
748' + 749 '<span class="text-sm font-semibold text-gray-900 dark:text-white line-clamp-1">' + this.escapeHtml(title) + '</span>' + 750 '</div>' + 751 '<div class="flex items-center gap-1 flex-shrink-0">' + 752 '<a href="' + this.urlValue + '" download class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg hover:shadow-sm transition whitespace-nowrap">' + 753 '<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>' + 754 (isMobile ? '' : 'Download') + 755 '</a>' + 756 '<button data-pdf-close aria-label="Close" class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition">' + 757 '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' + 758 '</button>' + 759 '</div>' + 760 '</div>' + 761 // Loading 762 '<div data-pdf-loader class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-900/80 z-10">' + 763 '<div class="flex flex-col items-center gap-3">
763' + 764 '<svg class="animate-spin h-8 w-8 text-brand-500" fill="none" viewBox="0 0 24 24">' + 765 '<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>' + 766 '<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>' + 767 '</svg>' + 768 '<span class="text-sm text-gray-500 dark:text-gray-400">Loading PDF...</span>' + 769 '</div>' + 770 '</div>' + 771 // Swiper 772 // touch-action: none â tells iOS Safari to hand all touch gestures 773 // (including edge-swipe-back) to JS; overscroll-behavior: contain 774 // prevents the swipe from bleeding into pull-to-refresh / page scroll. 775 '<div data-pdf-body class="flex-1 overflow-hidden bg-gray-100 dark:bg-gray-950"' + 776 (isMobile ? ' style="touch-action: none; overscroll-behavior: contain; -webkit-user-select: none; user-select: none;"' : '') + 777 '>' + 778 '<div class="swiper h-full"><div class="swiper-wrapper">' + slidesHtml + '</div></div>' + 779 '</div>' + 780 // Footer nav 781 '<div class="flex items-center justify-center gap-3 px-4 py-2.5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 flex-shrink-0" ' + footerStyle + '>' + 782 '<button data-pdf-prev disabled aria-label="Previous page" class="p-1.5 rounded-lg text-gray-500 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 transition disabled:opacity-30 disabled:cursor-not-allowed">' + 783 '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="' + (isMobile ? 'M15.75 19.5 8.25 12l7.5-7.5' : 'M4.5 15.75l7.5-7.5 7.5 7.5') + '" /></svg>' + 784 '</button>' + 785 '<span data-pdf-page-info class="text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[8rem] text-center">Page 1 of 1</span>' + 786 '<button data-pdf-next disabled aria-label="Next page" class="p-1.5 rounded-lg text-gray-500 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 transition disabled:opacity-30 disabled:cursor-not-allowed">' + 787 '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="' + (isMobile ? 'm8.25 4.5 7.5 7.5-7.5 7.5' : 'M19.5 8.25l-7.5 7.5-7.5-7.5') + '" /></svg>' + 788 '</button>' + 789 '</div>' 790 791 m.appendChild(inner) 792 793 inner.querySelector("[data-pdf-close]").addEventListener("click", () => this.closeViewer()) 794 inner.querySelector("[data-pdf-prev]").addEventListener("click", () => this.prevPage()) 795 inner.querySelector("[data-pdf-next]").addEventListener("click", () => this.nextPage()) 796 797 this.modal = m 798 } 799 800 escapeHtml(str) { 801 const div = document.createElement("div") 802 div.textContent = str 803 return div.innerHTML 804 } 805}
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.