1import { Controller } from "@hotwired/stimulus"; 2import { computePosition, flip, shift, offset } from "@floating-ui/dom"; 3 4export default class extends Controller { 5 static targets = ["menu"]; 6 static values = { 7 autoClose: { type: Boolean, default: true }, 8 portal: { type: Boolean, default: true }, 9 }; 10 11 connect() { 12 // Cache the menu before it is portaled outside the controller element. 13 this.menuElement = this.menuTarget; 14 this.originalParent = this.menuElement.parentNode; 15 this.originalNextSibling = this.menuElement.nextSibling; 16 this.isPortaled = false; 17 18 // Keep stable handler references for proper cleanup in disconnect(). 19 this.boundShowMenu = this.showMenu.bind(this); 20 this.boundHandleTouchStart = this.handleTouchStart.bind(this); 21 this.boundHandleTouchEnd = this.handleTouchEnd.bind(this); 22 this.boundHandleTouchMove = this.handleTouchMove.bind(this); 23 this.boundHandleTouchCancel = this.handleTouchCancel.bind(this); 24 this.boundCloseMenu = this.closeMenu.bind(this); 25 this.boundHandleKeydown = this.handleKeydown.bind(this); 26 this.boundHandleMenuItemClick = this.handleMenuItemClick.bind(this); 27 this.boundHandleMenuAction = this.handleMenuAction.bind(this); 28 29 // JS-only iOS fix: disable native touch callout on context menu triggers. 30 this.touchCalloutElements = Array.from( 31 new Set([this.element, ...this.element.querySelectorAll(".cursor-context-menu")]), 32 ).filter((element) => element instanceof HTMLElement); 33 this.previousTouchCalloutValues = new Map(); 34 this.touchCalloutElements.forEach((element) => { 35 this.previousTouchCalloutValues.set(element, element.style.webkitTouchCallout); 36 element.style.webkitTouchCallout = "none"; 37 }); 38 39 // Add context menu event listener 40 this.element.addEventListener("contextmenu", this.boundShowMenu); 41 // Add touch press & hold listeners for mobile with passive option 42 this.element.addEventListener("touchstart", this.boundHandleTouchStart, { passive: true }); 43 this.element.addEventListener("touchend", this.boundHandleTouchEnd, { passive: true }); 44 this.element.addEventListener("touchmove", this.boundHandleTouchMove, { passive: true }); 45 this.element.addEventListener("touchcancel", this.boundHandleTouchCancel, { passive: true }); 46 // Add click outside listener to close menu 47 document.addEventListener("click", this.boundCloseMenu); 48 // Add escape key listener 49 document.addEventListener("keydown", this.boundHandleKeydown); 50 // Add menu-item-clicked event listener 51 this.menuElement.addEventListener("menu-item-clicked", this.boundHandleMenuItemClick); 52 // Portaled menus leave the controller scope, so explicit context-menu actions need delegation. 53 this.menuElement.addEventListener("click", this.boundHandleMenuAction); 54 // Track scroll lock state 55 this.isScrollLocked = false; 56 // Touch press & hold state 57 this.touchTimer = null; 58 this.touchStartPos = null; 59 this.suppressContextMenuUntil = 0; 60 this.longPressDelay = 500; // 500ms for long press 61 this.maxTouchMovement = 10; // Max pixels allowed for movement during long press 62 } 63 64 disconnect() { 65 clearTimeout(this.closeTimeout); 66 67 // Make sure to unlock scroll when controller disconnects 68 if (this.isScrollLocked) { 69 this.unlockScroll(); 70 } 71 // Clear any pending touch timer 72 if (this.touchTimer) { 73 clearTimeout(this.touchTimer); 74 } 75 this.element.removeEventListener("contextmenu", this.boundShowMenu); 76 this.element.removeEventListener("touchstart", this.boundHandleTouchStart); 77 this.element.removeEventListener("touchend", this.boundHandleTouchEnd); 78 this.element.removeEventListener("touchmove", this.boundHandleTouchMove); 79 this.element.removeEventListener("touchcancel", this.boundHandleTouchCancel); 80 document.removeEventListener("click", this.boundCloseMenu); 81 document.removeEventListener("keydown", this.boundHandleKeydown); 82 this.menuElement.removeEventListener("menu-item-clicked", this.boundHandleMenuItemClick); 83 this.menuElement.removeEventListener("click", this.boundHandleMenuAction); 84 85 // Restore previous inline touch-callout styles. 86 if (this.touchCalloutElements && this.previousTouchCalloutValues) {
87 this.touchCalloutElements.forEach((element) => { 88 element.style.webkitTouchCallout = this.previousTouchCalloutValues.get(element) || ""; 89 }); 90 } 91 92 this.#restoreMenuMount(); 93 } 94 95 handleTouchStart(event) { 96 // Only handle single finger touches 97 if (event.touches.length !== 1) { 98 this.cancelTouchTimer(); 99 return; 100 } 101 102 // Store the initial touch position 103 const touch = event.touches[0]; 104 const touchX = touch.clientX; 105 const touchY = touch.clientY; 106 this.touchStartPos = { 107 x: touchX, 108 y: touchY, 109 }; 110 111 // Start the long press timer 112 this.touchTimer = setTimeout(() => { 113 // iOS Safari may emit a native contextmenu event right after long-press. 114 // Suppress it briefly to avoid duplicate open/repaint sequences. 115 this.suppressContextMenuUntil = Date.now() + 750; 116 // Show our custom context menu 117 this.showMenuAtPosition(touchX, touchY, { source: "touch" }); 118 this.touchTimer = null; 119 }, this.longPressDelay); 120 } 121 122 handleTouchMove(event) { 123 // If we don't have a timer running, ignore 124 if (!this.touchTimer || !this.touchStartPos) { 125 return; 126 } 127 128 // Check if the finger has moved too far 129 const touch = event.touches[0]; 130 const deltaX = Math.abs(touch.clientX - this.touchStartPos.x); 131 const deltaY = Math.abs(touch.clientY - this.touchStartPos.y); 132 const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); 133 134 // Cancel long press if moved too far 135 if (distance > this.maxTouchMovement) { 136 this.cancelTouchTimer(); 137 } 138 } 139 140 handleTouchEnd(event) { 141 this.cancelTouchTimer(); 142 } 143 144 handleTouchCancel(event) { 145 this.cancelTouchTimer(); 146 } 147 148 cancelTouchTimer() { 149 if (this.touchTimer) { 150 clearTimeout(this.touchTimer); 151 this.touchTimer = null; 152 } 153 this.touchStartPos = null; 154 } 155 156 showMenu(event) { 157 if (Date.now() < this.suppressContextMenuUntil) { 158 event.preventDefault(); 159 return; 160 } 161 162 event.preventDefault(); 163 this.showMenuAtPosition(event.clientX, event.clientY, { source: "contextmenu" }); 164 } 165 166 async showMenuAtPosition(clientX, clientY, { source = "programmatic" } = {}) { 167 clearTimeout(this.closeTimeout); 168 this.closeTimeout = null; 169 170 const anchorX = Number.isFinite(clientX) ? clientX : 0; 171 const anchorY = Number.isFinite(clientY) ? clientY : 0; 172 173 // Close any other open context menus first 174 this.application.controllers 175 .filter((controller) => controller.identifier === "context-menu" && controller !== this && controller.isOpen) 176 .forEach((controller) => controller.close()); 177 178 // Close all open dropdown popovers 179 this.application.controllers 180 .filter((controller) => controller.identifier === "dropdown-popover" && controller.isOpen) 181 .forEach((controller) => controller.close()); 182 183 this.lockScroll(source); 184 this.#mountMenuForOpen(); 185 186 if (!this.menuElement.open) { 187 this.menuElement.show(); 188 } 189 190 // Create a virtual element at the cursor/touch position for Floating UI 191 const virtualElement = { 192 getBoundingClientRect() { 193 return { 194 width: 0, 195 height: 0, 196 x: anchorX, 197 y: anchorY, 198 top: anchorY, 199 left: anchorX, 200 right: anchorX, 201 bottom: anchorY, 202 }; 203 }, 204 }; 205 206 // Keep the menu hidden at a safe position while measuring to avoid Safari scroll jumps. 207 const previousVisibility = this.menuElement.style.visibility; 208 this.menuElement.style.visibility = "hidden"; 209 this.menuElement.style.left = "0px"; 210 this.menuElement.style.top = "0px"; 211 212 try { 213 // Use Floating UI to compute the optimal position 214 const { x, y } = await computePosition(virtualElement, this.menuElement, { 215 strategy: "fixed", 216 placement: "bottom-start", 217 middleware: [ 218 offset(4), // Small offset from cursor 219 flip({ 220 fallbackPlacements: ["top-start", "bottom-end", "top-end"], 221 }), 222 shift({ padding: 8 }), // Keep 8px padding from window edges 223 ], 224 }); 225 226 // Apply the computed position 227 this.menuElement.style.left = `${x}px`; 228 this.menuElement.style.top = `${y}px`; 229 } catch (_error) { 230 // Fallback to the click/touch location if positioning fails. 231 this.menuElement.style.left = `${anchorX}px`; 232 this.menuElement.style.top = `${anchorY}px`; 233 } 234 235 // Add animation classes after a frame 236 requestAnimationFrame(() => { 237 this.menuElement.style.visibility = previousVisibility; 238 this.menuElement.classList.add("[&[open]]:scale-100", "[&[open]]:opacity-100"); 239 // Focus the menu element itself instead of any items 240 this.#focusWithoutScroll(this.menuElement); 241 }); 242 } 243 244 get isOpen() { 245 return Boolean(this.menuElement?.open); 246 } 247 248 #mountMenuForOpen() { 249 if (!this.portalValue) return; 250 251 const appendTarget = this.element.closest("dialog[open]") || document.body; 252 if (this.menuElement.parentElement !== appendTarget) { 253 appendTarget.appendChild(this.menuElement); 254 } 255 this.isPortaled = this.menuElement.parentElement !== this.originalParent; 256 } 257 258 #restoreMenuMount() { 259 if (!this.isPortaled || !this.originalParent) return; 260 261 if (this.originalNextSibling && this.originalNextSibling.parentNode === this.originalParent) { 262 this.originalParent.insertBefore(this.menuElement, this.originalNextSibling); 263 } else { 264 this.originalParent.appendChild(this.menuElement); 265 } 266 267 this.isPortaled = false;
268 } 269 270 closeMenu(event) { 271 if (!this.isOpen) return; 272 273 if (!this.menuElement.contains(event.target)) { 274 this.#closeContextMenu(); 275 } 276 } 277 278 handleKeydown(event) { 279 if (event.key === "Escape" && this.menuElement.open) { 280 this.#closeContextMenu(); 281 } 282 } 283 284 handleMenuItemClick(event) { 285 if (this.autoCloseValue) { 286 this.#closeContextMenu(); 287 } 288 } 289 290 handleMenuAction(event) { 291 if (this.element.contains(this.menuElement)) return; 292 if (!(event.target instanceof Element)) return; 293 294 const actionElement = event.target.closest("[data-action]"); 295 if (!actionElement || !this.menuElement.contains(actionElement)) return; 296 297 const closestContextMenu = actionElement.closest('[data-controller~="context-menu"]'); 298 if (closestContextMenu && closestContextMenu !== this.element) return; 299 300 const actionNames = this.#localContextMenuActionNamesFor(event); 301 if (actionNames.length === 0) return; 302 303 actionNames.forEach((actionName) => { 304 event.preventDefault(); 305 this[actionName](); 306 }); 307 } 308 309 // Public method to force close the menu (can be called via data-action) 310 close() { 311 this.#closeContextMenu(); 312 } 313 314 #localContextMenuActionNamesFor(event) { 315 const handledActions = new Set(["close"]); 316 if (!(event.target instanceof Element)) return []; 317 318 const actionElement = event.target.closest("[data-action]"); 319 if (!actionElement || !this.menuElement.contains(actionElement)) return []; 320 321 return (actionElement.getAttribute("data-action") || "") 322 .split(/\s+/) 323 .map((descriptor) => { 324 const match = descriptor.match(/^(?:(?:click)(?:\.[^@-]+)?->)?context-menu#([A-Za-z0-9_]+)$/); 325 return match?.[1]; 326 }) 327 .filter((actionName) => handledActions.has(actionName)); 328 } 329 330 #focusWithoutScroll(element) { 331 if (!element || typeof element.focus !== "function") return; 332 333 try { 334 element.focus({ preventScroll: true }); 335 } catch (_error) { 336 element.focus(); 337 } 338 } 339 340 // Private method to handle all context menu closing logic 341 #closeContextMenu() { 342 if (!this.isOpen && !this.closeTimeout) return; 343 344 clearTimeout(this.closeTimeout); 345 this.closeTimeout = null; 346 347 this.unlockScroll(); 348 349 // Reset focus states in the menu 350 const menuController = this.application.getControllerForElementAndIdentifier(this.menuElement, "menu"); 351 if (menuController) { 352 menuController.reset(); 353 } 354 355 // Close all nested dropdowns immediately (no animation) and remove state attributes 356 const nestedDropdowns = this.menuElement.querySelectorAll('[data-controller~="dropdown-popover"]'); 357 nestedDropdowns.forEach((dropdown) => { 358 const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover"); 359 if (controller && controller.isOpen) { 360 controller.buttonTarget.removeAttribute("data-state"); 361 // Use closeImmediate for faster closing of nested menus 362 if (typeof controller.closeImmediate === "function") { 363 controller.closeImmediate(); 364 } else { 365 controller.close(); 366 } 367 } 368 }); 369 370 // Then close the main context menu with animation 371 this.menuElement.classList.remove("[&[open]]:scale-100", "[&[open]]:opacity-100"); 372 this.closeTimeout = setTimeout(() => { 373 if (this.menuElement.open) { 374 this.menuElement.close(); 375 } 376 this.#restoreMenuMount(); 377 this.closeTimeout = null; 378 }, 100); 379 } 380 381 // Add these new methods for scroll locking 382 lockScroll(source = "programmatic") { 383 if (this.isScrollLocked) return; 384 385 this.scrollLockTarget = this.#getScrollLockTarget(); 386 387 // For parent overlays, block user-driven scroll events without mutating 388 // overflow styles so scrollbar layout stays stable. 389 if (this.scrollLockTarget !== document.body) { 390 this.previousTargetOverscrollBehavior = this.scrollLockTarget.style.overscrollBehavior; 391 this.scrollLockTarget.style.overscrollBehavior = "contain"; 392 this.lockedTargetScrollTop = this.scrollLockTarget.scrollTop; 393 this.lockedTargetScrollLeft = this.scrollLockTarget.scrollLeft; 394 395 this.boundPreventTargetScrollInput = this.#preventTargetScrollInput.bind(this); 396 this.boundRestoreTargetScrollPosition = this.#restoreTargetScrollPosition.bind(this); 397 398 this.scrollLockTarget.addEventListener("wheel", this.boundPreventTargetScrollInput, { 399 passive: false, 400 capture: true, 401 }); 402 this.scrollLockTarget.addEventListener("touchmove", this.boundPreventTargetScrollInput, { 403 passive: false, 404 capture: true, 405 }); 406 this.scrollLockTarget.addEventListener("scroll", this.boundRestoreTargetScrollPosition, { passive: true }); 407 408 this.isOverlayScrollLocked = true; 409 this.isScrollLocked = true; 410 return; 411 } 412 413 // On iOS Safari, body locking during long-press can trigger brief black repaints. 414 // Skip body-level touch locking for touch-origin opens. 415 if (source === "touch" && this.scrollLockTarget === document.body) { 416 return; 417 } 418 419 // Store current scroll position 420 this.scrollPosition = window.pageYOffset || document.documentElement.scrollTop; 421 422 // Store current body styles 423 this.previousBodyPosition = document.body.style.position; 424 this.previousBodyTop = document.body.style.top; 425 this.previousBodyWidth = document.body.style.width; 426 this.previousBodyPointerEvents = document.body.style.pointerEvents; 427 428 // Lock scroll by fixing the body position 429 document.body.style.position = "fixed"; 430 document.body.style.top = `-${this.scrollPosition}px`; 431 document.body.style.width = "100%"; 432 433 // Disable pointer events on the body (but not the menu) 434 document.body.style.pointerEvents = "none"; 435 this.menuElement.style.pointerEvents = "auto"; 436 437 this.isScrollLocked = true; 438 } 439 440 unlockScroll() { 441 if (!this.isScrollLocked) return; 442 443 if (this.scrollLockTarget && this.scrollLockTarget !== document.body) { 444 this.scrollLockTarget.style.overscrollBehavior = this.previousTargetOverscrollBehavior || ""; 445 446 if (this.boundPreventTargetScrollInput) { 447 this.scrollLockTarget.removeEventListener("wheel", this.boundPreventTargetScrollInput, { capture: true }); 448 this.scrollLockTarget.removeEventListener("touchmove", this.boundPreventTargetScrollInput, { capture: true }); 449 } 450 451 if (this.boundRestoreTargetScrollPosition) { 452 this.scrollLockTarget.removeEventListener("scroll", this.boundRestoreTargetScrollPosition); 453 } 454 455 this.boundPreventTargetScrollInput = null; 456 this.boundRestoreTargetScrollPosition = null; 457 this.isOverlayScrollLocked = false;
458 this.isScrollLocked = false; 459 this.scrollLockTarget = null; 460 return; 461 } 462 463 // Restore previous body styles 464 document.body.style.position = this.previousBodyPosition; 465 document.body.style.top = this.previousBodyTop; 466 document.body.style.width = this.previousBodyWidth; 467 document.body.style.pointerEvents = this.previousBodyPointerEvents; 468 469 // Restore scroll position 470 window.scrollTo(0, this.scrollPosition); 471 472 this.isScrollLocked = false; 473 this.scrollLockTarget = null; 474 } 475 476 #preventTargetScrollInput(event) { 477 if (!this.isOverlayScrollLocked || !this.scrollLockTarget) return; 478 479 // Allow interaction inside the context menu itself. 480 if (this.menuElement.contains(event.target)) return; 481 482 event.preventDefault(); 483 } 484 485 #restoreTargetScrollPosition() { 486 if (!this.isOverlayScrollLocked || !this.scrollLockTarget) return; 487 488 if (this.scrollLockTarget.scrollTop !== this.lockedTargetScrollTop) { 489 this.scrollLockTarget.scrollTop = this.lockedTargetScrollTop; 490 } 491 if (this.scrollLockTarget.scrollLeft !== this.lockedTargetScrollLeft) { 492 this.scrollLockTarget.scrollLeft = this.lockedTargetScrollLeft; 493 } 494 } 495 496 #getScrollLockTarget() { 497 const parentOverlay = this.element.closest( 498 'dialog[open][data-modal-target="dialog"], dialog[open][data-slideover-target="dialog"], dialog[open][data-drawer-target="dialog"], dialog[open], .modal-div.modal-open', 499 ); 500 501 if (parentOverlay instanceof HTMLElement) { 502 return parentOverlay; 503 } 504 505 return document.body; 506 } 507};
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.