/** * The logo's menu: host status, network simulation, auth token, panel position, * and the session-hide switch. * * A corner-anchored dropdown on a wide screen; on a phone it takes over the room * drawer's box (see `applyMenuPosition` in core.ts), which is why the two never * appear together. The logo itself is built by `panel.ts`; this module owns what * happens when you press it — tap toggles the drawer, long press opens the menu. */ import { copyIcon, eyeSlashIcon, keyIcon, networkIcon, settingsIcon, sizedIcon } from "./icons.ts"; import { authInstances, getBorderColor, getDebugRoot, hidePanelsForSession, isCompactRevealed, latencyRamp, preferences, roomDebugInfo, savePreferences, setCompactMenuOpen, setCompactRevealed } from "./core.ts"; import { horizontalEdge, verticalEdge } from "./geometry.ts"; import { isCompact, reflow } from "./layout.ts"; import { applySegmentedState, bindSegmentedHover } from "./tokens.ts"; // Long enough not to fire on a hurried tap, short enough not to feel stuck. const LONG_PRESS_MS = 450; // Movement beyond this cancels the press — a finger is never perfectly still. const PRESS_SLOP = 10; export function createMenu(logoContainer: HTMLElement) { var menu = document.createElement('div'); menu.id = 'debug-menu'; menu.className = 'cds-surface cds-scroll'; menu.style.position = 'fixed'; // Geometry (corner, min-width, border-radius, max-height, overflow) is owned by // applyMenuPosition — it differs between the dropdown and the compact sheet. // border-box so its 1px border doesn't push it 2px wider than the drawer it // replaces, whose max-width it shares. menu.style.boxSizing = 'border-box'; menu.style.backgroundColor = 'rgba(18, 18, 20, 0.97)'; menu.style.color = '#fff'; menu.style.padding = '0 0 4px 0'; menu.style.border = '1px solid rgba(255, 255, 255, 0.08)'; menu.style.fontFamily = 'system-ui, -apple-system, sans-serif'; menu.style.fontSize = '12px'; menu.style.zIndex = '1001'; menu.style.boxShadow = '0 8px 28px rgba(0, 0, 0, 0.55)'; menu.style.display = 'none'; // A single faint hairline disappears on the near-black menu. Pairing a light rule // with a dark one below engraves a groove, which reads at a glance without // shouting. 0.16 also brings the menu in line with the panels' 0.15 rules. function makeDivider() { var d = document.createElement('div'); d.style.cssText = 'height:1px;flex-shrink:0;background:rgba(255,255,255,0.16);' + 'box-shadow:0 1px 0 rgba(0,0,0,0.4)'; return d; } // Shared section header: a small leading icon + an uppercase, muted label. // Used by every group so they all read the same way. function makeSectionHeader(iconSvg, text) { var h = document.createElement('div'); h.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:10px;font-weight:600;' + 'letter-spacing:0.6px;text-transform:uppercase;color:#8a8a8a;margin-bottom:8px'; h.innerHTML = sizedIcon(iconSvg, 12) + '' + text + ''; return h; } // Host title bar: a connection status dot + the server host. var hostContainer = document.createElement('div'); hostContainer.style.cssText = 'display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:default;' + 'background:rgba(255,255,255,0.04);border-bottom:1px solid rgba(255,255,255,0.16);' + 'border-top-left-radius:8px;border-top-right-radius:8px'; var hostDot = document.createElement('span'); hostDot.style.cssText = 'flex-shrink:0;width:7px;height:7px;border-radius:50%;background:#666;' + 'box-shadow:0 0 0 3px rgba(255,255,255,0.04)'; var hostValue = document.createElement('div'); hostValue.id = 'debug-menu-host'; hostValue.style.cssText = 'flex:1;min-width:0;font-size:11px;color:#eee;font-family:monospace;' + 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500'; // Function to update host display (text + green/grey status dot) function updateHostDisplay() { var hostText = 'N/A'; var connected = roomDebugInfo.size > 0; if (connected) { // Get host from first room var firstRoom = roomDebugInfo.values().next().value; if (firstRoom && firstRoom.host) { hostText = firstRoom.host; } } hostValue.textContent = hostText; hostDot.style.background = connected ? '#22c55e' : '#666'; } // Update host display initially updateHostDisplay(); hostContainer.appendChild(hostDot); hostContainer.appendChild(hostValue); menu.appendChild(hostContainer); // Simulate latency option var latencyContainer = document.createElement('div'); latencyContainer.style.padding = '8px 12px'; latencyContainer.style.cursor = 'default'; var latencyLabel = document.createElement('div'); latencyLabel.style.marginBottom = '8px'; latencyLabel.style.display = 'flex'; latencyLabel.style.alignItems = 'center'; latencyLabel.style.justifyContent = 'space-between'; var latencyValueSpan = document.createElement('span'); latencyValueSpan.id = 'latency-value'; latencyValueSpan.style.color = '#888'; latencyValueSpan.style.fontSize = '11px'; latencyValueSpan.textContent = preferences.latencySimulation.delay + 'ms'; var latencyTextSpan = document.createElement('span'); latencyTextSpan.textContent = 'Latency (RTT)'; latencyTextSpan.style.cssText = 'font-size:11px;color:#ccc'; latencyLabel.appendChild(latencyTextSpan); latencyLabel.appendChild(latencyValueSpan); var latencySlider = document.createElement('input'); latencySlider.type = 'range'; latencySlider.min = '0'; latencySlider.max = preferences.maxLatency.toString(); latencySlider.value = preferences.latencySimulation.delay.toString(); // No inline `height` — .cds-range owns it so the coarse-pointer rule can grow it. latencySlider.className = 'cds-range'; latencySlider.style.border = 'none'; latencySlider.style.width = '100%'; latencySlider.style.padding = '0'; latencySlider.style.margin = '0'; latencySlider.style.outline = 'none'; latencySlider.style.cursor = 'pointer'; latencySlider.style.webkitAppearance = 'none'; latencySlider.style.appearance = 'none'; latencySlider.style.background = 'transparent'; latencySlider.id = 'latency-slider'; function getSliderColor(value) { var c = latencyRamp(value, preferences.maxLatency); return 'rgb(' + c.r + ', ' + c.g + ', ' + c.b + ')'; } // Build the green→yellow→red fill for a track. Only the `background` is // dynamic — track/thumb geometry lives in the base sheet (styles.ts), so the // coarse-pointer override isn't clobbered when this sheet is re-appended. function trackGradient(value) { var color = getSliderColor(value); var valuePercent = (value / preferences.maxLatency) * 100; var yellowColor = getSliderColor(preferences.maxLatency / 2); return (value <= preferences.maxLatency / 2) ? `linear-gradient(to right, #00c800 0%, ${yellowColor} ${valuePercent}%, #333 ${valuePercent}%, #333 100%)` : `linear-gradient(to right, #00c800 0%, ${yellowColor} 50%, ${color} ${valuePercent}%, #333 ${valuePercent}%, #333 100%)`; } function paintTrack(sliderId, gradient) { var root = getDebugRoot(); var styleId = sliderId + '-style'; var existingStyle = root.getElementById(styleId); if (existingStyle) { existingStyle.remove(); } var style = document.createElement('style'); style.id = styleId; style.textContent = ` #${sliderId}::-webkit-slider-runnable-track { background: ${gradient}; } #${sliderId}::-moz-range-track { background: ${gradient}; } `; root.appendChild(style); } function updateSliderColor(value) { paintTrack('latency-slider', trackGradient(value)); } // Initialize slider color updateSliderColor(parseInt(latencySlider.value)); // Function to update container border color function refreshLogoRing() { var container = getDebugRoot().getElementById('debug-logo-container'); if (container) { // Update to normal state (hover handlers will update on hover) container.style.borderColor = getBorderColor(preferences.latencySimulation.delay, 0.7); } } // Update latency value display latencySlider.addEventListener('input', function() { var value = parseInt(latencySlider.value); latencyValueSpan.textContent = value + 'ms'; preferences.latencySimulation.delay = value; preferences.latencySimulation.enabled = value > 0 || preferences.latencySimulation.jitter > 0; updateSliderColor(value); refreshLogoRing(); savePreferences(); syncPresetButtons(); // a manual drag → "Custom" (no preset highlighted) }); latencyContainer.appendChild(latencyLabel); latencyContainer.appendChild(latencySlider); menu.appendChild(latencyContainer); // Simulate jitter option — mirrors the latency slider; applied as ±jitter around // the latency per message (order-preserving). Reuses the in-scope getSliderColor. var jitterContainer = document.createElement('div'); jitterContainer.style.padding = '8px 12px 12px'; jitterContainer.style.cursor = 'default'; var jitterLabel = document.createElement('div'); jitterLabel.style.cssText = 'margin-bottom:8px;display:flex;align-items:center;justify-content:space-between'; var jitterValueSpan = document.createElement('span'); jitterValueSpan.id = 'jitter-value'; jitterValueSpan.style.cssText = 'color:#888;font-size:11px'; jitterValueSpan.textContent = preferences.latencySimulation.jitter + 'ms'; var jitterTextSpan = document.createElement('span'); jitterTextSpan.textContent = 'Jitter'; jitterTextSpan.style.cssText = 'font-size:11px;color:#ccc'; jitterLabel.appendChild(jitterTextSpan); jitterLabel.appendChild(jitterValueSpan); var jitterSlider = document.createElement('input'); jitterSlider.type = 'range'; jitterSlider.min = '0'; jitterSlider.max = preferences.maxLatency.toString(); jitterSlider.value = preferences.latencySimulation.jitter.toString(); jitterSlider.id = 'jitter-slider'; jitterSlider.className = 'cds-range'; jitterSlider.style.cssText = 'border:none;width:100%;padding:0;margin:0;outline:none;cursor:pointer;-webkit-appearance:none;appearance:none;background:transparent'; function updateJitterColor(value) { paintTrack('jitter-slider', trackGradient(value)); } updateJitterColor(parseInt(jitterSlider.value)); jitterSlider.addEventListener('input', function() { var value = parseInt(jitterSlider.value); jitterValueSpan.textContent = value + 'ms'; preferences.latencySimulation.jitter = value; preferences.latencySimulation.enabled = preferences.latencySimulation.delay > 0 || value > 0; updateJitterColor(value); savePreferences(); syncPresetButtons(); // a manual drag → "Custom" (no preset highlighted) }); jitterContainer.appendChild(jitterLabel); jitterContainer.appendChild(jitterSlider); menu.appendChild(jitterContainer); // Network presets — one tap sets both sliders. The active preset is derived // by matching the saved delay/jitter (no extra persisted state), so it stays // in sync with manual drags and the __net() console API. var NET_PRESETS = [ { label: 'Off', title: 'No simulated latency', delay: 0, jitter: 0 }, { label: 'Low', title: 'Low latency · 60ms, no jitter', delay: 60, jitter: 0 }, { label: 'Med', title: 'Medium latency + jitter · 150ms ± 30ms', delay: 150, jitter: 30 }, { label: 'Large', title: 'Large latency + jitter · 300ms ± 60ms', delay: 300, jitter: 60 }, ]; // Reflect a chosen (delay, jitter) onto BOTH sliders + their gradients/labels, // persist, and re-highlight. Preset-click path only — slider handlers move one. function applyLatencySim(delay, jitter) { preferences.latencySimulation.delay = delay; preferences.latencySimulation.jitter = jitter; preferences.latencySimulation.enabled = delay > 0 || jitter > 0; latencySlider.value = delay.toString(); jitterSlider.value = jitter.toString(); latencyValueSpan.textContent = delay + 'ms'; jitterValueSpan.textContent = jitter + 'ms'; updateSliderColor(delay); updateJitterColor(jitter); refreshLogoRing(); savePreferences(); syncPresetButtons(); } function activePresetIndex() { var d = preferences.latencySimulation.delay; var j = preferences.latencySimulation.jitter; for (var i = 0; i < NET_PRESETS.length; i++) { if (NET_PRESETS[i].delay === d && NET_PRESETS[i].jitter === j) return i; } return -1; // custom } var presetButtons = []; function syncPresetButtons() { var active = activePresetIndex(); for (var i = 0; i < presetButtons.length; i++) { applySegmentedState(presetButtons[i], i === active); } } var presetContainer = document.createElement('div'); presetContainer.style.padding = '12px 12px 0'; presetContainer.style.cursor = 'default'; var presetLabel = makeSectionHeader(networkIcon, 'Network simulation'); var presetRow = document.createElement('div'); presetRow.style.cssText = 'display:flex;gap:4px'; NET_PRESETS.forEach(function(preset) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'cds-hit'; btn.textContent = preset.label; btn.title = preset.title; btn.style.cssText = 'flex:1;padding:4px 6px;border:1px solid;border-radius:4px;' + 'cursor:pointer;font-size:10px;font-family:inherit;transition:background 0.2s,border-color 0.2s'; applySegmentedState(btn, false); bindSegmentedHover(btn); btn.addEventListener('click', function(e) { e.stopPropagation(); applyLatencySim(preset.delay, preset.jitter); }); presetButtons.push(btn); presetRow.appendChild(btn); }); presetContainer.appendChild(presetLabel); presetContainer.appendChild(presetRow); menu.insertBefore(presetContainer, latencyContainer); // above the latency slider syncPresetButtons(); // highlight the saved preset on first open menu.appendChild(makeDivider()); // network → auth // Auth token section — preview + copy the current token, or clear a stale one // that fails onAuth when switching between projects on the same origin. var authSection = document.createElement('div'); authSection.style.padding = '12px'; authSection.style.cursor = 'default'; var authLabel = makeSectionHeader(keyIcon, 'Auth token'); // Empty state (no token present) var authEmpty = document.createElement('div'); authEmpty.textContent = 'None'; authEmpty.style.cssText = 'color:#666;font-size:11px;font-style:italic'; // Token preview + copy row var tokenRow = document.createElement('div'); tokenRow.style.cssText = 'display:flex;gap:4px;align-items:center;margin-bottom:6px'; var tokenPreview = document.createElement('div'); tokenPreview.style.cssText = 'flex:1;min-width:0;font-family:monospace;font-size:10px;color:#ddd;' + 'background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:4px;' + 'padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer'; var copyBtn = document.createElement('button'); copyBtn.type = 'button'; copyBtn.className = 'cds-hit'; copyBtn.title = 'Copy token'; copyBtn.style.cssText = 'flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;' + 'width:26px;height:26px;padding:0;border:1px solid rgba(255,255,255,0.2);border-radius:4px;' + 'background:rgba(255,255,255,0.05);color:#fff;cursor:pointer;transition:background 0.2s,border-color 0.2s'; var copyBtnIcon = sizedIcon(copyIcon, 13); copyBtn.innerHTML = copyBtnIcon; tokenRow.appendChild(tokenPreview); tokenRow.appendChild(copyBtn); // Clear button (red) var clearBtn = document.createElement('button'); clearBtn.type = 'button'; clearBtn.className = 'cds-hit'; clearBtn.textContent = 'Clear token'; clearBtn.style.cssText = 'width:100%;padding:5px 8px;border:1px solid rgba(239,68,68,0.5);border-radius:4px;' + 'background:rgba(239,68,68,0.2);color:#ef4444;font-size:11px;font-family:inherit;cursor:pointer;' + 'transition:background 0.2s,border-color 0.2s'; authSection.appendChild(authLabel); authSection.appendChild(authEmpty); authSection.appendChild(tokenRow); authSection.appendChild(clearBtn); // Current token = first live client's in-memory token, else the persisted one // (present in storage but not yet loaded into an Auth instance). var currentToken: string | null = null; function getAuthToken() { var t: string | null = null; authInstances.forEach(function(a) { if (!t && a.token) { t = a.token; } }); if (t) { return t; } try { return localStorage.getItem('colyseus-auth-token'); } catch (e) { return null; } } // Middle-truncate so both ends stay legible; full value lives in the title. function truncateToken(t) { return (t.length > 22) ? (t.slice(0, 12) + '…' + t.slice(-8)) : t; } // Reflect presence on (re)open: show preview/copy/clear, or the "None" line. function syncAuthOption() { var token = getAuthToken(); currentToken = token; var present = !!token; authEmpty.style.display = present ? 'none' : 'block'; tokenRow.style.display = present ? 'flex' : 'none'; clearBtn.style.display = present ? 'block' : 'none'; if (token) { tokenPreview.textContent = truncateToken(token); tokenPreview.title = token; // full token on hover } } syncAuthOption(); // Copy the full token to the clipboard, flashing a green check on the button. var copyResetTimer: any = null; function flashCopied() { copyBtn.innerHTML = '✓'; copyBtn.style.borderColor = 'rgba(34,197,94,0.6)'; if (copyResetTimer) { clearTimeout(copyResetTimer); } copyResetTimer = setTimeout(function() { copyBtn.innerHTML = copyBtnIcon; copyBtn.style.borderColor = 'rgba(255,255,255,0.2)'; }, 1000); } function copyToken() { if (!currentToken) { return; } try { var p = navigator.clipboard && navigator.clipboard.writeText(currentToken); if (p && p.then) { p.then(flashCopied, flashCopied); } else { flashCopied(); } } catch (e) { flashCopied(); } } copyBtn.addEventListener('mouseenter', function() { copyBtn.style.background = 'rgba(255,255,255,0.15)'; }); copyBtn.addEventListener('mouseleave', function() { copyBtn.style.background = 'rgba(255,255,255,0.05)'; }); copyBtn.addEventListener('click', function(e) { e.stopPropagation(); copyToken(); }); tokenPreview.addEventListener('click', function(e) { e.stopPropagation(); copyToken(); }); clearBtn.addEventListener('mouseenter', function() { clearBtn.style.background = 'rgba(239,68,68,0.35)'; clearBtn.style.borderColor = 'rgba(239,68,68,0.7)'; }); clearBtn.addEventListener('mouseleave', function() { clearBtn.style.background = 'rgba(239,68,68,0.2)'; clearBtn.style.borderColor = 'rgba(239,68,68,0.5)'; }); clearBtn.addEventListener('click', function(e) { e.stopPropagation(); // Live instances: signOut() nulls the in-memory token + removes storage (real key). authInstances.forEach(function(a) { if (a.token) { void a.signOut(); } }); try { localStorage.removeItem('colyseus-auth-token'); } catch (e) {} // default-key fallback syncAuthOption(); }); // Auth sits below the network group (after its divider), with its own divider // separating it from the position picker. menu.appendChild(authSection); menu.appendChild(makeDivider()); // auth → position/visibility // Panel position and visibility ride side by side: two short controls that would // each waste a full-width row of a 226px menu. var prefsSection = document.createElement('div'); prefsSection.style.cssText = 'display:flex;align-items:stretch;gap:8px;padding:12px;cursor:default'; // Left — a 2×2 grid standing in for the screen, the dot marking the corner. // Four corners on four buttons; a dropdown would say less in more space. var POSITIONS = [ { value: 'top-left', label: 'Top left' }, { value: 'top-right', label: 'Top right' }, { value: 'bottom-left', label: 'Bottom left' }, { value: 'bottom-right', label: 'Bottom right' }, ]; var positionColumn = document.createElement('div'); positionColumn.style.cssText = 'flex:1;min-width:0;display:flex;flex-direction:column'; positionColumn.appendChild(makeSectionHeader(settingsIcon, 'Position')); var positionGrid = document.createElement('div'); // The 32px floor lives on the row, not on the button: an inline min-height there // would outrank .cds-hit and quietly cost the cells their 44px coarse target. positionGrid.style.cssText = 'flex:1;display:grid;grid-template-columns:1fr 1fr;' + 'grid-auto-rows:minmax(32px, 1fr);gap:4px'; var positionButtons = []; function syncPositionButtons() { for (var i = 0; i < positionButtons.length; i++) { applySegmentedState(positionButtons[i], positionButtons[i].dataset.value === preferences.panelPosition.position); } } POSITIONS.forEach(function(pos) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'cds-hit'; btn.dataset.value = pos.value; btn.title = pos.label; btn.setAttribute('aria-label', pos.label); btn.style.cssText = 'position:relative;padding:0;border:1px solid;border-radius:4px;' + 'cursor:pointer;transition:background 0.2s,border-color 0.2s'; // currentColor, so the marker dims and brightens with the button's state. var dot = document.createElement('span'); dot.style.cssText = 'position:absolute;width:10px;height:6px;border-radius:2px;' + 'background:currentColor;' + verticalEdge(pos.value) + ':6px;' + horizontalEdge(pos.value) + ':6px'; btn.appendChild(dot); applySegmentedState(btn, false); bindSegmentedHover(btn); btn.addEventListener('click', function(e) { e.stopPropagation(); preferences.panelPosition.position = pos.value; savePreferences(); reflow(); // logo, menu, drawer and Predict all anchor off this syncPositionButtons(); }); positionButtons.push(btn); positionGrid.appendChild(btn); }); positionColumn.appendChild(positionGrid); syncPositionButtons(); // Right — get the overlay out of the way for the rest of the page's life. var visibilityColumn = document.createElement('div'); visibilityColumn.style.cssText = 'flex:1;min-width:0;display:flex;flex-direction:column'; visibilityColumn.appendChild(makeSectionHeader(eyeSlashIcon, 'Visibility')); var hideBtn = document.createElement('button'); hideBtn.type = 'button'; hideBtn.className = 'cds-hit'; hideBtn.title = 'Hide the dev tools until you reload the page'; hideBtn.style.cssText = 'flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;' + 'gap:6px;padding:6px 8px;border:1px solid rgba(255,255,255,0.2);border-radius:4px;' + 'background:rgba(255,255,255,0.05);color:#fff;font-size:10px;line-height:1.3;text-align:center;' + 'font-family:inherit;cursor:pointer;transition:background 0.2s,border-color 0.2s'; hideBtn.innerHTML = sizedIcon(eyeSlashIcon, 14) + 'Hide for this session'; hideBtn.addEventListener('mouseenter', function() { hideBtn.style.background = 'rgba(255, 255, 255, 0.15)'; hideBtn.style.borderColor = 'rgba(255, 255, 255, 0.3)'; }); hideBtn.addEventListener('mouseleave', function() { hideBtn.style.background = 'rgba(255, 255, 255, 0.05)'; hideBtn.style.borderColor = 'rgba(255, 255, 255, 0.2)'; }); hideBtn.addEventListener('click', function(e) { e.stopPropagation(); setMenuVisible(false); // clears the host-poll interval before everything goes hidePanelsForSession(); }); visibilityColumn.appendChild(hideBtn); prefsSection.appendChild(positionColumn); prefsSection.appendChild(visibilityColumn); menu.appendChild(prefsSection); // Footer — the only place that says how to remove the overlay for good. var disableHint = document.createElement('div'); disableHint.style.cssText = 'padding:0 12px 12px;font-size:10px;line-height:1.4;color:#666'; disableHint.innerHTML = 'Remove debug.js from your HTML to disable this UI.'; menu.appendChild(disableHint); getDebugRoot().appendChild(menu); // Toggle menu on logo click var menuVisible = false; var hostUpdateInterval = null; function setMenuVisible(visible) { menuVisible = visible; menu.style.display = visible ? 'block' : 'none'; // Compact: the menu lands in the drawer's box, so the drawer stands down. // The reveal state survives, and the drawer comes back when the menu closes. setCompactMenuOpen(visible); if (visible) { updateHostDisplay(); syncAuthOption(); // refresh enabled/greyed state on each open // Update host every second while menu is visible hostUpdateInterval = setInterval(updateHostDisplay, 1000); } else if (hostUpdateInterval) { clearInterval(hostUpdateInterval); hostUpdateInterval = null; } } // Two gestures on one target, compact only: // tap → show/hide the room panels + Predict overlay // long press → open the network-simulation / preferences sheet // Desktop keeps the single click-opens-the-menu behaviour. var pressTimer: any = null; var longPressFired = false; var pressOrigin = { x: 0, y: 0 }; function pressed(down) { // A tap should feel like a button, and the cue doubles as long-press feedback. logoContainer.style.transform = down ? 'scale(0.92)' : ''; } function cancelPress() { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } pressed(false); } logoContainer.addEventListener('pointerdown', function(e: PointerEvent) { if (!isCompact() || pressTimer) { return; } longPressFired = false; pressOrigin = { x: e.clientX, y: e.clientY }; pressed(true); pressTimer = setTimeout(function() { pressTimer = null; longPressFired = true; // swallows the `click` that follows the release pressed(false); setMenuVisible(true); }, LONG_PRESS_MS); }); // A press that turns into a drag was never a long press. logoContainer.addEventListener('pointermove', function(e: PointerEvent) { if (!pressTimer) { return; } if (Math.abs(e.clientX - pressOrigin.x) > PRESS_SLOP || Math.abs(e.clientY - pressOrigin.y) > PRESS_SLOP) { cancelPress(); } }); logoContainer.addEventListener('pointerup', cancelPress); logoContainer.addEventListener('pointerleave', cancelPress); logoContainer.addEventListener('pointercancel', function() { cancelPress(); longPressFired = false; }); // Long press on touch otherwise raises the selection callout / context menu. logoContainer.addEventListener('contextmenu', function(e) { e.preventDefault(); }); logoContainer.addEventListener('click', function(e) { e.stopPropagation(); if (!isCompact()) { setMenuVisible(!menuVisible); return; } if (longPressFired) { longPressFired = false; return; } // the press already opened the menu if (menuVisible) { setMenuVisible(false); return; } // tap dismisses the sheet first setCompactRevealed(!isCompactRevealed()); }); // Close menu when clicking outside. // Because menu/logo live inside a shadow root, event.target is retargeted // to the shadow host for document-level listeners, so we walk the composed // path to see whether the real click target was inside the menu or logo. document.addEventListener('click', function(e) { var path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target as EventTarget]; var clickedInsideMenu = path.indexOf(menu) !== -1; var clickedInsideLogo = path.indexOf(logoContainer) !== -1; if (menuVisible && !clickedInsideMenu && !clickedInsideLogo) { setMenuVisible(false); } }); }