PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/pdf_viewer_controller-e98a447a.js

js littlebirdelectronics.com.au collected 2026-09-25 14:11:45 UTC 34,548 bytes, 805 lines download raw bytes

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.