1// app/javascript/controllers/preview_controller.js 2import { Controller } from "@hotwired/stimulus" 3 4// Connects to data-controller="numiko--link-preview-card" 5 6/* 7 The anchor element requires the following attributes: 8 class="preview-cardable" 9 data-controller="numiko--link-preview-card" 10 data-action="mouseenter->numiko--link-preview-card#showPreview mouseleave->numiko--link-preview-card#hidePreview mousemove->numiko--link-preview-card#trackCursor" 11*/ 12export default class extends Controller { 13 static values = { previewDelay: { type: Number, default: 300 } } 14 15 initialize() { 16 this.hoverTimeout = null 17 this.cache = new Map() 18 this.previewCard = null 19 this.cursorX = 0 20 this.cursorY = 0 21 this.trackCursorBound = this.trackCursor.bind(this) 22 } 23 24 showPreview() { 25 if (window.innerWidth < 768) { return } // Don't show preview on mobile 26 this.hoverTimeout = setTimeout(() => { 27 const href = this.element.href 28 29 if (this.cache.has(href)) { 30 this.#renderPreview(this.cache.get(href)) 31 this.#setInitialCursorPosition() 32 return 33 } 34 35 fetch(href, { 36 headers: { 'Turbo-Frame': 'preview-frame' } 37 }) 38 .then(r => r.text()) 39 .then(html => { 40 this.cache.set(href, html) 41 this.#renderPreview(html) 42 this.#setInitialCursorPosition() 43 }) 44 .catch(err => console.error('Preview failed:', err)) 45 }, this.previewDelayValue) 46 } 47 48 trackCursor(event) { 49 this.cursorX = event.clientX 50 this.cursorY = event.clientY 51 52 // Update position if card is visible 53 if (this.previewCard) { this.#updateCardPosition() } 54 } 55 56 #updateCardPosition() { 57 this.previewCard.style.left = (this.cursorX + 20) + 'px' 58 this.previewCard.style.top = this.cursorY + 'px' 59 this.previewCard.style.transform = 'translateY(-70%)' 60 this.#adjustForViewport() 61 } 62 63 #adjustForViewport() { 64 const cardRect = this.previewCard.getBoundingClientRect() 65 if (cardRect.right > window.innerWidth) { 66 this.previewCard.style.left = (window.innerWidth - cardRect.width - 20) + 'px' 67 } 68 if (cardRect.top < 0) { 69 this.previewCard.style.top = '20px' 70 this.previewCard.style.transform = 'translateY(0)' 71 } else if (cardRect.bottom > window.innerHeight) { 72 this.previewCard.style.top = (window.innerHeight - cardRect.height - 20) + 'px' 73 this.previewCard.style.transform = 'translateY(0)' 74 } 75 } 76 77 hidePreview() { 78 clearTimeout(this.hoverTimeout) 79 document.removeEventListener('mousemove', this.trackCursorBound) 80 this.previewCard?.remove() 81 this.previewCard = null 82 } 83 84 #renderPreview(html) { 85 const card = document.createElement('div') 86 card.innerHTML = html 87 card.style.position = 'fixed' 88 card.style.zIndex = 9999 89 card.style.pointerEvents = 'none' // Don't interfere with hover 90 91 // Ensure the fetched content contains the expected card element 92 if (!card.firstElementChild.classList.contains('link-preview-card')) { return; } 93 94 document.body.appendChild(card) 95 this.previewCard = card 96 97 this.#updateCardPosition() 98 document.addEventListener('mousemove', this.trackCursorBound) 99 } 100 101 disconnect() { 102 this.hidePreview() 103 this.#removeAllPreviewCards() 104 } 105 106 #setInitialCursorPosition() { 107 const rect = this.element.getBoundingClientRect(); 108 this.cursorX = rect.left; 109 this.cursorY = rect.top; 110 this.#updateCardPosition(); 111 } 112 113 #removeAllPreviewCards() { 114 document.querySelectorAll('.link-preview-card').forEach(card => card.remove()) 115 } 116}
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.