1import { Controller } from "@hotwired/stimulus"; 2import { computePosition, offset, flip, shift, arrow, autoUpdate } from "@floating-ui/dom"; 3 4export default class extends Controller { 5 static values = { 6 successMessage: { type: String, default: "Copied!" }, // Success message to show when text is copied 7 errorMessage: { type: String, default: "Failed to copy!" }, // Error message to show when text is not copied 8 showTooltip: { type: Boolean, default: true }, // Whether to show the tooltip 9 tooltipPlacement: { type: String, default: "top" }, // Placement(s) of the tooltip, e.g., "top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "left", "left-start", "left-end", "right", "right-start", "right-end" 10 tooltipOffset: { type: Number, default: 8 }, // Offset of the tooltip 11 tooltipDuration: { type: Number, default: 2000 }, // Duration of the tooltip 12 }; 13 14 static targets = ["copyContent", "copiedContent"]; 15 16 connect() { 17 this.boundCopyTextToClipboard = this._copyTextToClipboard.bind(this); 18 this.element.addEventListener("click", this.boundCopyTextToClipboard); 19 20 this.cleanupAutoUpdate = null; 21 this.tooltipElement = null; 22 this.arrowElement = null; 23 this.hideTooltipTimeout = null; 24 this.finalizeHideTooltipTimeout = null; 25 this.copiedStateTimeout = null; 26 this.intersectionObserver = null; 27 } 28 29 disconnect() { 30 // if (this.clipboard) { 31 // this.clipboard.destroy(); 32 // } 33 if (this.boundCopyTextToClipboard) { 34 this.element.removeEventListener("click", this.boundCopyTextToClipboard); 35 } 36 clearTimeout(this.copiedStateTimeout); 37 clearTimeout(this.hideTooltipTimeout); 38 clearTimeout(this.finalizeHideTooltipTimeout); 39 this._removeTooltipDOM();
39 // This also handles cleanupAutoUpdate 40 } 41 42 handleSuccess(e) { 43 if (this.hasCopyContentTarget && this.hasCopiedContentTarget) { 44 this.showCopiedState(); 45 } 46 if (this.showTooltipValue) { 47 this._showFloatingTooltip(this.successMessageValue); 48 } 49 // if (e && typeof e.clearSelection === "function") { Removed e.clearSelection() 50 // e.clearSelection(); 51 // } 52 } 53 54 handleError(e) { 55 if (this.showTooltipValue) { 56 this._showFloatingTooltip(this.errorMessageValue); 57 } 58 } 59 60 showCopiedState() { 61 if (this.hasCopyContentTarget && this.hasCopiedContentTarget) { 62 this.copyContentTarget.classList.add("hidden"); 63 this.copiedContentTarget.classList.remove("hidden"); 64 clearTimeout(this.copiedStateTimeout); 65 this.copiedStateTimeout = setTimeout(() => { 66 if (this.hasCopyContentTarget && this.hasCopiedContentTarget) { 67 // Check targets still exist 68 this.copyContentTarget.classList.remove("hidden"); 69 this.copiedContentTarget.classList.add("hidden"); 70 } 71 }, this.tooltipDurationValue); 72 } 73 } 74 75 async _copyTextToClipboard() { 76 const textToCopy = this.element.dataset.clipboardText || this.element.getAttribute("data-clipboard-text"); 77 if (textToCopy === null || typeof textToCopy === "undefined") { 78 console.warn("No text to copy. Missing data-clipboard-text attribute on element:", this.element); 79 this.handleError({ message: "No text to copy specified." }); // Pass a mock error object or just the message 80 return; 81 } 82 83 try { 84 await navigator.clipboard.writeText(textToCopy); 85 this.handleSuccess(); // Removed 'e' argument as it's not provided by navigator.clipboard 86 } catch (err) { 87 console.error("Failed to copy text: ", err); 88 this.handleError(err); // Pass the actual error object 89 } 90 } 91 92 _createOrUpdateTooltipDOMSafer(message) { 93 if (!this.tooltipElement) { 94 this.tooltipElement = document.createElement("div"); 95 this.tooltipElement.className = 96 "tooltip-content pointer-events-none shadow-sm border rounded-lg border-white/10 absolute bg-[#333333] text-white text-sm py-1 px-2 z-[1000] opacity-0 transition-opacity duration-150"; 97 // Example: this.tooltipElement.style.maxWidth = `${this.maxWidthValue}px`; // If you add a maxWidthValue 98 99 const messageSpan = document.createElement("span"); 100 messageSpan.setAttribute("data-tooltip-message-span", ""); 101 this.tooltipElement.appendChild(messageSpan); 102 103 // Create arrow container with padding to prevent clipping at viewport edges 104 this.arrowContainer = document.createElement("div"); 105 this.arrowContainer.className = "absolute z-[1000]"; 106 107 this.arrowElement = document.createElement("div"); 108 this.arrowElement.className = "tooltip-arrow-element bg-[#333333] w-2 h-2 border-white/10"; 109 this.arrowElement.style.transform = "rotate(45deg)"; 110 111 this.arrowContainer.appendChild(this.arrowElement); 112 this.tooltipElement.appendChild(this.arrowContainer); 113 114 this.tooltipElement.style.visibility = "hidden"; 115 } 116 117 const messageSpan = this.tooltipElement.querySelector("[data-tooltip-message-span]"); 118 if (messageSpan) { 119 messageSpan.textContent = message; 120 } 121 } 122 123 _ensureTooltipAppendTarget() { 124 if (!this.tooltipElement) return; 125 const appendTarget = this.element.closest("dialog[open]") || document.body; 126 if (this.tooltipElement.parentElement !== appendTarget) { 127 appendTarget.appendChild(this.tooltipElement); 128 } 129 } 130 131 _cleanupObservers() { 132 if (this.cleanupAutoUpdate) { 133 this.cleanupAutoUpdate(); 134 this.cleanupAutoUpdate = null; 135 } 136 if (this.intersectionObserver) { 137 this.intersectionObserver.disconnect(); 138 this.intersectionObserver = null; 139 } 140 } 141 142 _removeTooltipDOM() { 143 this._cleanupObservers(); 144 if (this.tooltipElement && this.tooltipElement.parentElement) { 145 this.tooltipElement.remove(); 146 } 147 this.tooltipElement = null; 148 this.arrowContainer = null; 149 this.arrowElement = null; 150 } 151 152 async _showFloatingTooltip(message) { 153 if (!this.showTooltipValue) return; 154 155 clearTimeout(this.hideTooltipTimeout); 156 clearTimeout(this.finalizeHideTooltipTimeout); 157 this._createOrUpdateTooltipDOMSafer(message); 158 159 if (!this.tooltipElement) return; // Should not happen if _createOrUpdateTooltipDOMSafer worked 160 161 this._ensureTooltipAppendTarget(); 162 163 this.tooltipElement.style.visibility = "visible"; 164 165 // Ensure autoUpdate is cleaned up if it was somehow active without a tooltipElement (defensive) 166 this._cleanupObservers(); 167 168 const referenceElement = this.element; 169 this.cleanupAutoUpdate = autoUpdate( 170 referenceElement, 171 this.tooltipElement, 172 async () => { 173 if (!this.tooltipElement || !this.arrowContainer) { 174 this._cleanupObservers(); 175 return; 176 } 177 178 this._ensureTooltipAppendTarget(); 179
180 // Parse placement value to support multiple placements 181 const placements = this.tooltipPlacementValue.split(/[\s,]+/).filter(Boolean); 182 const primaryPlacement = placements[0] || "top"; 183 const fallbackPlacements = placements.slice(1); 184 185 const middleware = [ 186 offset(this.tooltipOffsetValue), 187 flip({ 188 fallbackPlacements: fallbackPlacements.length > 0 ? fallbackPlacements : undefined, 189 }), 190 shift({ padding: 5 }), 191 ]; 192 middleware.push(arrow({ element: this.arrowContainer, padding: 2 })); 193 194 const { x, y, placement, middlewareData } = await computePosition(referenceElement, this.tooltipElement, { 195 placement: primaryPlacement, 196 middleware: middleware, 197 }); 198 199 Object.assign(this.tooltipElement.style, { 200 left: `${x}px`, 201 top: `${y}px`, 202 }); 203 204 if (this.arrowContainer && this.arrowElement && middlewareData.arrow) { 205 const { x: arrowX, y: arrowY } = middlewareData.arrow; 206 const basePlacement = placement.split("-")[0]; 207 const staticSide = { top: "bottom", right: "left", bottom: "top", left: "right" }[basePlacement]; 208 209 // Apply appropriate padding based on placement direction 210 this.arrowContainer.classList.remove("px-1", "py-1"); 211 if (basePlacement === "top" || basePlacement === "bottom") { 212 this.arrowContainer.classList.add("px-1"); // Horizontal padding for top/bottom 213 } else { 214 this.arrowContainer.classList.add("py-1"); // Vertical padding for left/right 215 } 216 217 // Position the arrow container 218 Object.assign(this.arrowContainer.style, { 219 left: arrowX != null ? `${arrowX}px` : "", 220 top: arrowY != null ? `${arrowY}px` : "", 221 right: "", 222 bottom: "", 223 [staticSide]: "-0.28rem", // Matches tooltip_controller.js arrow positioning 224 }); 225 226 // Style the arrow element within the container 227 this.arrowElement.classList.remove("border-t", "border-r", "border-b", "border-l"); 228 if (staticSide === "bottom") this.arrowElement.classList.add("border-b", "border-r"); 229 else if (staticSide === "top") this.arrowElement.classList.add("border-t", "border-l"); 230 else if (staticSide === "left") this.arrowElement.classList.add("border-b", "border-l"); 231 else if (staticSide === "right") this.arrowElement.classList.add("border-t", "border-r"); 232 } 233 }, 234 { animationFrame: true }, 235 ); 236 237 // Setup intersection observer to hide tooltip when trigger element goes out of view 238 if (this.intersectionObserver) { 239 this.intersectionObserver.disconnect(); 240 } 241 this.intersectionObserver = new IntersectionObserver( 242 (entries) => { 243 entries.forEach((entry) => { 244 if (!entry.isIntersecting) { 245 this._hideFloatingTooltip(); 246 } 247 }); 248 }, 249 { threshold: 0 }, // Hide as soon as any part goes out of view 250 ); 251 this.intersectionObserver.observe(this.element); 252 253 requestAnimationFrame(() => { 254 if (this.tooltipElement) { 255 this.tooltipElement.classList.remove("opacity-0"); 256 this.tooltipElement.classList.add("opacity-100"); 257 } 258 }); 259 260 this.hideTooltipTimeout = setTimeout(() => { 261 this._hideFloatingTooltip(); 262 }, this.tooltipDurationValue); 263 } 264 265 _hideFloatingTooltip() { 266 if (!this.tooltipElement || !this.tooltipElement.classList.contains("opacity-100")) { 267 return; 268 } 269 270 this.tooltipElement.classList.remove("opacity-100"); 271 this.tooltipElement.classList.add("opacity-0"); 272 this._cleanupObservers(); 273 274 clearTimeout(this.finalizeHideTooltipTimeout); 275 this.finalizeHideTooltipTimeout = setTimeout(() => { 276 if (this.tooltipElement) { 277 this.tooltipElement.style.visibility = "hidden"; 278 } 279 }, 150); // Transition duration (duration-150) 280 } 281};
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.