diff --git a/_config_theme.yml b/_config_theme.yml index 6a79789a..921673ce 100644 --- a/_config_theme.yml +++ b/_config_theme.yml @@ -25,6 +25,7 @@ navbar-links: - Support: "support" Tools: - Gamepad Tester: "gamepad-tester" + - Mouse Tester: "mouse-tester" Downloads: - Packages: "https://app.lizardbyte.dev/packages" - Homebrew: "https://github.com/LizardByte/homebrew-homebrew" diff --git a/_includes/mouse-tester.svg b/_includes/mouse-tester.svg new file mode 100644 index 00000000..b5e12f02 --- /dev/null +++ b/_includes/mouse-tester.svg @@ -0,0 +1,60 @@ + diff --git a/_sass/styles.scss b/_sass/styles.scss index 6091c1a2..9d2dcc7f 100644 --- a/_sass/styles.scss +++ b/_sass/styles.scss @@ -1140,6 +1140,610 @@ table tr:nth-child(2n) { } } +/* Mouse tester */ +.mouse-tester .container { + max-width: 82rem; +} + +.mouse-tester-header { + max-width: 52rem; +} + +.mouse-kicker, +.mouse-panel-eyebrow { + color: var(--link-col); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.mouse-kicker { + align-items: center; + display: inline-flex; + gap: 0.45rem; +} + +.mouse-status { + align-items: center; + background-color: var(--navbar-col); + border: 1px solid var(--navbar-border-col); + border-left-width: 0.3rem; + border-radius: 0.75rem; + color: var(--text-col); + display: flex; + gap: 0.75rem; + margin-inline: auto; + max-width: 46rem; + padding: 0.8rem 1rem; + text-align: left; +} + +.mouse-status.alert-warning { + border-left-color: var(--bs-warning); +} + +.mouse-status.alert-success { + border-left-color: var(--bs-success); +} + +.mouse-status.alert-danger { + border-left-color: var(--bs-danger); +} + +.mouse-status-indicator { + background-color: var(--bs-warning); + border-radius: 50%; + box-shadow: 0 0 0 0.3rem rgba(var(--bs-warning-rgb), 0.15); + flex: 0 0 auto; + height: 0.65rem; + width: 0.65rem; +} + +.mouse-status.alert-success .mouse-status-indicator { + animation: mouse-status-pulse 2s ease-out infinite; + background-color: var(--bs-success); + box-shadow: 0 0 0 0.3rem rgba(var(--bs-success-rgb), 0.15); +} + +.mouse-status.alert-danger .mouse-status-indicator { + background-color: var(--bs-danger); + box-shadow: 0 0 0 0.3rem rgba(var(--bs-danger-rgb), 0.15); +} + +@keyframes mouse-status-pulse { + 0%, 100% { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.12); + } + 50% { + box-shadow: 0 0 0 0.5rem rgba(var(--bs-success-rgb), 0); + } +} + +.mouse-privacy-note { + font-size: 0.78rem; +} + +.mouse-inline-notice { + align-items: flex-start; + background-color: rgba(var(--bs-warning-rgb), 0.1); + border: 1px solid rgba(var(--bs-warning-rgb), 0.3); + border-radius: 0.75rem; + display: flex; + font-size: 0.82rem; + gap: 0.55rem; + padding: 0.8rem; +} + +.mouse-browser-warning { + margin-inline: auto; + max-width: 46rem; +} + +.mouse-browser-warning[hidden] { + display: none; +} + +.mouse-browser-warning a { + font-weight: 600; + overflow-wrap: anywhere; + text-decoration: underline; +} + +.mouse-workspace { + margin-top: 1.5rem !important; +} + +.mouse-panel { + background-color: var(--navbar-col); + border: 1px solid var(--navbar-border-col); + border-radius: 1rem; + box-shadow: 0 0.35rem 1.25rem rgba(0, 0, 0, 0.06); + color: var(--text-col); + overflow: hidden; +} + +[data-bs-theme="dark"] .mouse-panel { + box-shadow: 0 0.35rem 1.25rem rgba(0, 0, 0, 0.2); +} + +.mouse-panel-header { + align-items: center; + border-bottom: 1px solid var(--navbar-border-col); + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + justify-content: space-between; + padding: 1rem 1.25rem; +} + +.mouse-panel-body { + padding: 1.25rem; +} + +.mouse-live-badge { + align-items: center; + background-color: rgba(var(--bs-success-rgb), 0.12); + border: 1px solid rgba(var(--bs-success-rgb), 0.35); + border-radius: 999px; + color: var(--bs-success); + display: inline-flex; + font-size: 0.72rem; + font-weight: 700; + gap: 0.4rem; + padding: 0.25rem 0.6rem; + text-transform: uppercase; +} + +.mouse-live-badge > span { + background-color: currentColor; + border-radius: 50%; + height: 0.45rem; + width: 0.45rem; +} + +.mouse-visual-body { + background-image: radial-gradient(circle at center, rgba(var(--bs-primary-rgb), 0.08), transparent 62%); + height: calc(100% - 4.25rem); + min-height: 31rem; + padding: 0; +} + +.mouse-capture-surface { + align-items: center; + cursor: crosshair; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + min-height: inherit; + overflow: hidden; + padding: 1.25rem; + position: relative; + user-select: none; +} + +.mouse-capture-surface:focus-visible { + box-shadow: inset 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25); + outline: 0; +} + +.mouse-visual-svg { + display: block; + height: auto; + max-height: 23rem; + max-width: 20rem; + overflow: visible; + pointer-events: none; + width: min(70%, 20rem); +} + +.mouse-shell-highlight { + stop-color: var(--navbar-col); +} + +.mouse-shell-shadow { + stop-color: var(--footer-col); +} + +.mouse-svg-shell { + fill: url("#mouse-shell-gradient"); + stroke: var(--navbar-border-col); + stroke-width: 4; +} + +.mouse-svg-button-surface { + fill: var(--footer-col); + stroke: var(--navbar-border-col); + stroke-linejoin: round; + stroke-width: 3; + transition: fill 70ms linear, filter 70ms linear, stroke 70ms linear; +} + +.mouse-svg-control.is-active .mouse-svg-button-surface { + fill: var(--bs-primary); + filter: drop-shadow(0 0 0.8rem rgba(var(--bs-primary-rgb), 0.8)); + stroke: var(--bs-primary); +} + +.mouse-svg-seam, +.mouse-svg-accent, +.mouse-svg-base-highlight { + fill: none; + stroke-linecap: round; +} + +.mouse-svg-seam { + stroke: var(--navbar-border-col); + stroke-width: 3; +} + +.mouse-svg-accent { + opacity: 0.5; + stroke: var(--navbar-border-col); + stroke-width: 2; +} + +.mouse-svg-base-highlight { + opacity: 0.35; + stroke: var(--footer-text-col); + stroke-width: 2; +} + +.mouse-svg-button-detail { + fill: none; + pointer-events: none; + stroke: var(--footer-text-col); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 4; + transition: stroke 70ms linear; +} + +.mouse-svg-control.is-active .mouse-svg-button-detail { + stroke: #fff; +} + +.mouse-svg-scroll-indicator { + opacity: 0; + transition: opacity 70ms linear; +} + +.mouse-svg-scroll-indicator.is-active { + filter: drop-shadow(0 0 0.7rem rgba(var(--bs-primary-rgb), 0.9)); + opacity: 1; +} + +.mouse-svg-scroll-arrow { + fill: var(--bs-primary); + pointer-events: none; + stroke: var(--page-col); + stroke-linejoin: round; + stroke-width: 2; +} + +.mouse-svg-side-button-shadow { + fill: rgba(0, 0, 0, 0.18); +} + +.mouse-capture-instructions { + background-color: var(--footer-col); + border: 1px solid var(--navbar-border-col); + border-radius: 999px; + color: var(--footer-text-col); + font-size: 0.78rem; + margin-top: 1rem; + max-width: 34rem; + padding: 0.45rem 0.85rem; + text-align: center; +} + +.mouse-position-indicator { + border: 2px solid var(--bs-primary); + border-radius: 50%; + box-shadow: 0 0 0 0.3rem rgba(var(--bs-primary-rgb), 0.18); + height: 0.75rem; + pointer-events: none; + position: absolute; + transform: translate(-50%, -50%); + width: 0.75rem; +} + +.mouse-button-grid { + display: grid; + gap: 0.65rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.mouse-button-card { + background-color: var(--footer-col); + border: 1px solid var(--navbar-border-col); + border-radius: 0.75rem; + display: grid; + gap: 0.15rem; + min-width: 0; + padding: 0.7rem; + position: relative; + transition: background-color 70ms linear, border-color 70ms linear, box-shadow 70ms linear; +} + +.mouse-button-card.is-active { + background-color: var(--card-primary-bg); + border-color: var(--bs-primary); + box-shadow: inset 0 0 0 1px var(--bs-primary), 0 0 0.75rem rgba(var(--bs-primary-rgb), 0.25); +} + +.mouse-button-number { + align-items: center; + background-color: var(--navbar-col); + border: 1px solid var(--navbar-border-col); + border-radius: 50%; + display: flex; + font-size: 0.68rem; + font-weight: 700; + height: 1.4rem; + justify-content: center; + position: absolute; + right: 0.55rem; + top: 0.55rem; + width: 1.4rem; +} + +.mouse-button-name { + font-size: 0.82rem; + font-weight: 700; + overflow: hidden; + padding-right: 1.5rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mouse-button-state { + color: var(--footer-text-col); + font-size: 0.72rem; +} + +.mouse-button-card.is-active .mouse-button-state { + color: var(--bs-primary); + font-weight: 700; +} + +.mouse-button-count { + color: var(--footer-text-col); + font-size: 0.65rem; +} + +.mouse-button-count output, +.mouse-metric dd, +.mouse-detail dd { + font-variant-numeric: tabular-nums; +} + +.mouse-metric-heading { + color: var(--footer-text-col); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.06em; + margin-bottom: 0.55rem; + text-transform: uppercase; +} + +.mouse-metric-grid { + display: grid; + gap: 0.55rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.mouse-wheel-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.mouse-metric, +.mouse-detail { + background-color: var(--footer-col); + border: 1px solid var(--navbar-border-col); + border-radius: 0.65rem; + margin-bottom: 0; + min-width: 0; + padding: 0.65rem; +} + +.mouse-metric dt, +.mouse-detail dt { + color: var(--footer-text-col); + font-size: 0.64rem; + letter-spacing: 0.05em; + margin-bottom: 0.2rem; + text-transform: uppercase; +} + +.mouse-metric dd, +.mouse-detail dd { + cursor: text; + font-size: 0.8rem; + font-weight: 600; + margin-bottom: 0; + overflow-wrap: anywhere; + user-select: text; +} + +.mouse-event-details > summary { + align-items: center; + cursor: pointer; + display: flex; + gap: 1rem; + justify-content: space-between; + list-style: none; + padding: 1rem 1.25rem; +} + +.mouse-event-details > summary::-webkit-details-marker { + display: none; +} + +.mouse-event-details > summary:focus-visible { + box-shadow: inset 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25); + outline: 0; +} + +.mouse-event-details[open] > summary { + border-bottom: 1px solid var(--navbar-border-col); +} + +.mouse-details-action { + align-items: center; + display: inline-flex; + font-size: 0.78rem; + gap: 0.5rem; + white-space: nowrap; +} + +.mouse-details-action i { + transition: transform 150ms ease; +} + +.mouse-event-details[open] .mouse-details-action i { + transform: rotate(180deg); +} + +.mouse-details-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.mouse-detail-wide { + grid-column: span 2; +} + +@media (min-width: 1200px) { + .mouse-tester { + padding-bottom: 1.25rem !important; + padding-top: 1.25rem !important; + } + + .mouse-tester-header { + align-items: center; + display: flex; + gap: 1rem; + max-width: none; + text-align: left !important; + } + + .mouse-tester-header .mouse-kicker, + .mouse-tester-header > .lead, + .mouse-tester-header .mouse-privacy-note { + display: none; + } + + .mouse-tester-header > h2 { + flex: 0 0 auto; + font-size: 1.65rem; + margin-bottom: 0 !important; + } + + .mouse-status { + flex: 1 1 auto; + margin: 0 !important; + max-width: none; + padding: 0.65rem 0.85rem; + } + + .mouse-workspace { + margin-top: 0.75rem !important; + } + + .mouse-browser-warning { + margin-top: 0.75rem !important; + max-width: none; + padding: 0.55rem 0.75rem; + } + + .mouse-panel-header { + padding: 0.7rem 0.9rem; + } + + .mouse-panel-body { + padding: 0.85rem; + } + + .mouse-visual-body { + padding: 0; + } + + .mouse-panel-header h3 { + font-size: 0.95rem; + } + + .mouse-panel-eyebrow { + font-size: 0.6rem; + } +} + +@media (min-width: 1600px) { + .mouse-visual-body { + min-height: 27rem; + } + + .mouse-button-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .mouse-button-card { + padding: 0.6rem; + } + + .mouse-metric-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .mouse-wheel-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 767.98px) { + .mouse-button-grid, + .mouse-metric-grid, + .mouse-details-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 575.98px) { + .mouse-panel-header, + .mouse-panel-body, + .mouse-event-details > summary { + padding: 0.9rem; + } + + .mouse-panel-header > .small { + width: 100%; + } + + .mouse-visual-body { + min-height: 25rem; + padding: 0; + } + + .mouse-visual-svg { + width: min(82%, 17rem); + } + + .mouse-capture-instructions { + border-radius: 0.75rem; + } + + .mouse-metric-grid, + .mouse-details-grid { + grid-template-columns: minmax(0, 1fr); + } + + .mouse-detail-wide { + grid-column: auto; + } +} + /* Project card styling */ .project-card-link { color: var(--text-col); diff --git a/assets/css/mouse-tester.css b/assets/css/mouse-tester.css new file mode 100644 index 00000000..5a4b3d35 --- /dev/null +++ b/assets/css/mouse-tester.css @@ -0,0 +1,9 @@ +.intro-header { + margin-bottom: 0; +} + +@media (min-width: 1200px) { + .intro-header { + margin-top: 5.6875rem; + } +} diff --git a/assets/js/mouse-tester.js b/assets/js/mouse-tester.js new file mode 100644 index 00000000..1bbde855 --- /dev/null +++ b/assets/js/mouse-tester.js @@ -0,0 +1,331 @@ +function formatSigned(value) { + const roundedValue = Math.round(value); + return roundedValue > 0 ? `+${roundedValue}` : `${roundedValue}`; +} + +function wheelUnit(deltaMode) { + if (deltaMode === WheelEvent.DOM_DELTA_LINE) return 'lines'; + if (deltaMode === WheelEvent.DOM_DELTA_PAGE) return 'pages'; + return 'px'; +} + +function wheelModeName(deltaMode) { + if (deltaMode === WheelEvent.DOM_DELTA_LINE) return 'Lines'; + if (deltaMode === WheelEvent.DOM_DELTA_PAGE) return 'Pages'; + return 'Pixels'; +} + +function wheelDirection(deltaX, deltaY) { + if (Math.abs(deltaY) >= Math.abs(deltaX) && deltaY !== 0) { + return deltaY < 0 ? '↑ Up' : '↓ Down'; + } + if (deltaX !== 0) { + return deltaX < 0 ? '← Left' : '→ Right'; + } + return 'Idle'; +} + +function preventBrowserAction(event) { + event.preventDefault(); +} + +document.addEventListener('DOMContentLoaded', function() { + const buttonConfigs = [ + { number: 0, bit: 1, name: 'primary' }, + { number: 1, bit: 4, name: 'wheel' }, + { number: 2, bit: 2, name: 'secondary' }, + { number: 3, bit: 8, name: 'back' }, + { number: 4, bit: 16, name: 'forward' } + ]; + const browserNavigationButtons = new Set([3, 4]); + const buttonPressCounts = new Map(buttonConfigs.map(config => [config.number, 0])); + const pressedButtons = new Set(); + const suppressedNavigationButtons = new Set(); + const captureSurface = document.getElementById('mouse-capture-surface'); + const positionIndicator = document.getElementById('mouse-position-indicator'); + const status = document.getElementById('mouse-status'); + const statusMessage = document.getElementById('mouse-status-message'); + const activeButtonsOutput = document.getElementById('mouse-active-buttons'); + const lastEventOutput = document.getElementById('mouse-last-event'); + const wheelModeOutput = document.getElementById('mouse-wheel-mode'); + const browserSupportOutput = document.getElementById('mouse-browser-support'); + const firefoxMouseButtonsWarning = document.getElementById('firefox-mouse-buttons-warning'); + const movementXOutput = document.getElementById('mouse-movement-x'); + const movementYOutput = document.getElementById('mouse-movement-y'); + const positionOutput = document.getElementById('mouse-position'); + const distanceOutput = document.getElementById('mouse-distance'); + const moveEventsOutput = document.getElementById('mouse-move-events'); + const doubleClicksOutput = document.getElementById('mouse-double-clicks'); + const wheelXOutput = document.getElementById('mouse-wheel-x'); + const wheelYOutput = document.getElementById('mouse-wheel-y'); + const wheelDirectionOutput = document.getElementById('mouse-wheel-direction'); + const wheelEventsOutput = document.getElementById('mouse-wheel-events'); + let hasReceivedInput = false; + let scrollArrowTimer = null; + let motionFrameId = null; + let movementX = 0; + let movementY = 0; + let positionX = 0; + let positionY = 0; + let totalDistance = 0; + let moveEvents = 0; + let doubleClicks = 0; + let wheelX = 0; + let wheelY = 0; + let wheelEvents = 0; + let wheelDeltaMode = 0; + + firefoxMouseButtonsWarning.hidden = !navigator.userAgent.includes('Firefox/'); + + function updateStatus(message, type) { + status.classList.remove('alert-warning', 'alert-success', 'alert-danger'); + status.classList.add(`alert-${type}`); + statusMessage.textContent = message; + } + + function markInput(eventName) { + lastEventOutput.textContent = eventName; + if (!hasReceivedInput) { + hasReceivedInput = true; + updateStatus('Mouse input detected. Keep testing buttons, movement, and scrolling.', 'success'); + } + } + + function getButtonElements(buttonNumber) { + return { + card: document.querySelector(`.mouse-button-card[data-mouse-button="${buttonNumber}"]`), + count: document.getElementById(`mouse-button-${buttonNumber}-count`), + state: document.getElementById(`mouse-button-${buttonNumber}-state`), + visual: document.querySelector(`.mouse-svg-control[data-mouse-button="${buttonNumber}"]`) + }; + } + + function renderButton(buttonNumber) { + const elements = getButtonElements(buttonNumber); + const isActive = pressedButtons.has(buttonNumber); + elements.card.classList.toggle('is-active', isActive); + elements.visual.classList.toggle('is-active', isActive); + elements.state.textContent = isActive ? 'Pressed' : 'Released'; + elements.count.textContent = buttonPressCounts.get(buttonNumber); + } + + function renderAllButtons() { + buttonConfigs.forEach(config => renderButton(config.number)); + } + + function renderActiveButtons(buttonsBitmask) { + const activeNames = buttonConfigs + .filter(config => (buttonsBitmask & config.bit) !== 0) + .map(config => config.name); + activeButtonsOutput.textContent = activeNames.length === 0 + ? '0 (none)' + : `${buttonsBitmask} (${activeNames.join(' + ')})`; + } + + function syncPressedButtons(buttonsBitmask) { + pressedButtons.clear(); + buttonConfigs.forEach(config => { + if ((buttonsBitmask & config.bit) !== 0) { + pressedButtons.add(config.number); + } + }); + renderActiveButtons(buttonsBitmask); + renderAllButtons(); + } + + function renderMotion() { + movementXOutput.textContent = formatSigned(movementX); + movementYOutput.textContent = formatSigned(movementY); + positionOutput.textContent = `${Math.round(positionX)}, ${Math.round(positionY)}`; + distanceOutput.textContent = Math.round(totalDistance); + moveEventsOutput.textContent = moveEvents; + positionIndicator.style.left = `${positionX}px`; + positionIndicator.style.top = `${positionY}px`; + positionIndicator.hidden = false; + motionFrameId = null; + } + + function scheduleMotionRender() { + if (motionFrameId === null) { + motionFrameId = requestAnimationFrame(renderMotion); + } + } + + function showScrollArrows(deltaX, deltaY) { + if (scrollArrowTimer !== null) { + clearTimeout(scrollArrowTimer); + } + const activeDirections = new Set(); + if (deltaX < 0) activeDirections.add('left'); + if (deltaX > 0) activeDirections.add('right'); + if (deltaY < 0) activeDirections.add('up'); + if (deltaY > 0) activeDirections.add('down'); + document.querySelectorAll('.mouse-svg-scroll-indicator').forEach(indicator => { + indicator.classList.toggle('is-active', activeDirections.has(indicator.dataset.scrollDirection)); + }); + scrollArrowTimer = setTimeout(function() { + document.querySelectorAll('.mouse-svg-scroll-indicator').forEach(indicator => { + indicator.classList.remove('is-active'); + }); + scrollArrowTimer = null; + }, 450); + } + + function recordButtonDown(event) { + const wasPressed = pressedButtons.has(event.button); + captureSurface.focus({ preventScroll: true }); + markInput(`Button ${event.button} down`); + syncPressedButtons(event.buttons); + if (!wasPressed && buttonPressCounts.has(event.button)) { + buttonPressCounts.set(event.button, buttonPressCounts.get(event.button) + 1); + renderButton(event.button); + } + } + + function handleMouseDown(event) { + event.preventDefault(); + recordButtonDown(event); + } + + function handleSideButtonPointerDown(event) { + if (!browserNavigationButtons.has(event.button)) return; + event.preventDefault(); + suppressedNavigationButtons.add(event.button); + recordButtonDown(event); + } + + function handleSideButtonPointerUp(event) { + if (!browserNavigationButtons.has(event.button) || !suppressedNavigationButtons.has(event.button)) return; + event.preventDefault(); + syncPressedButtons(event.buttons); + markInput(`Button ${event.button} up`); + suppressedNavigationButtons.delete(event.button); + } + + function handleMouseMove(event) { + const surfaceBounds = captureSurface.getBoundingClientRect(); + movementX = event.movementX; + movementY = event.movementY; + positionX = Math.min(Math.max(event.clientX - surfaceBounds.left, 0), surfaceBounds.width); + positionY = Math.min(Math.max(event.clientY - surfaceBounds.top, 0), surfaceBounds.height); + totalDistance += Math.hypot(movementX, movementY); + moveEvents += 1; + markInput('Mouse move'); + syncPressedButtons(event.buttons); + scheduleMotionRender(); + } + + function handleWheel(event) { + event.preventDefault(); + wheelDeltaMode = event.deltaMode; + wheelX += event.deltaX; + wheelY += event.deltaY; + wheelEvents += 1; + const unit = wheelUnit(wheelDeltaMode); + wheelXOutput.textContent = `${formatSigned(wheelX)} ${unit}`; + wheelYOutput.textContent = `${formatSigned(wheelY)} ${unit}`; + wheelDirectionOutput.textContent = wheelDirection(event.deltaX, event.deltaY); + wheelEventsOutput.textContent = wheelEvents; + wheelModeOutput.textContent = wheelModeName(wheelDeltaMode); + markInput('Mouse wheel'); + showScrollArrows(event.deltaX, event.deltaY); + } + + function releaseButtons(event) { + syncPressedButtons(event?.buttons ?? 0); + if (event && captureSurface.contains(event.target)) { + event.preventDefault(); + markInput(`Button ${event.button} up`); + } + } + + function preventSideButtonBrowserAction(event) { + if (browserNavigationButtons.has(event.button)) { + event.preventDefault(); + } + } + + function releaseAllButtons() { + suppressedNavigationButtons.clear(); + syncPressedButtons(0); + } + + function resetTester() { + hasReceivedInput = false; + buttonConfigs.forEach(config => buttonPressCounts.set(config.number, 0)); + pressedButtons.clear(); + suppressedNavigationButtons.clear(); + if (scrollArrowTimer !== null) { + clearTimeout(scrollArrowTimer); + scrollArrowTimer = null; + } + document.querySelectorAll('.mouse-svg-scroll-indicator').forEach(indicator => { + indicator.classList.remove('is-active'); + }); + if (motionFrameId !== null) { + cancelAnimationFrame(motionFrameId); + motionFrameId = null; + } + movementX = 0; + movementY = 0; + positionX = 0; + positionY = 0; + totalDistance = 0; + moveEvents = 0; + doubleClicks = 0; + wheelX = 0; + wheelY = 0; + wheelEvents = 0; + wheelDeltaMode = 0; + movementXOutput.textContent = '0'; + movementYOutput.textContent = '0'; + positionOutput.textContent = '0, 0'; + distanceOutput.textContent = '0'; + moveEventsOutput.textContent = '0'; + doubleClicksOutput.textContent = '0'; + wheelXOutput.textContent = '0 px'; + wheelYOutput.textContent = '0 px'; + wheelDirectionOutput.textContent = 'Idle'; + wheelEventsOutput.textContent = '0'; + wheelModeOutput.textContent = 'Pixels'; + lastEventOutput.textContent = 'None'; + positionIndicator.hidden = true; + renderActiveButtons(0); + renderAllButtons(); + updateStatus('Move, click, or scroll inside the test area to begin.', 'warning'); + } + + if (globalThis.MouseEvent === undefined || globalThis.WheelEvent === undefined) { + browserSupportOutput.textContent = 'Not supported'; + updateStatus('Mouse events are not supported in this browser.', 'danger'); + captureSurface.removeAttribute('tabindex'); + return; + } + + browserSupportOutput.textContent = 'Supported'; + captureSurface.addEventListener('pointerdown', handleSideButtonPointerDown, { capture: true }); + captureSurface.addEventListener('mousedown', handleMouseDown); + captureSurface.addEventListener('mousemove', handleMouseMove); + captureSurface.addEventListener('mouseup', preventBrowserAction); + captureSurface.addEventListener('wheel', handleWheel, { passive: false }); + captureSurface.addEventListener('contextmenu', preventBrowserAction); + captureSurface.addEventListener('click', preventSideButtonBrowserAction, { capture: true }); + captureSurface.addEventListener('auxclick', preventSideButtonBrowserAction, { capture: true }); + captureSurface.addEventListener('dblclick', function(event) { + event.preventDefault(); + doubleClicks += 1; + doubleClicksOutput.textContent = doubleClicks; + markInput('Double click'); + }); + globalThis.addEventListener('pointerup', handleSideButtonPointerUp, { capture: true }); + globalThis.addEventListener('pointercancel', releaseAllButtons, { capture: true }); + globalThis.addEventListener('mouseup', releaseButtons); + globalThis.addEventListener('blur', releaseAllButtons); + document.addEventListener('visibilitychange', function() { + if (document.hidden) { + releaseAllButtons(); + } + }); + document.getElementById('mouse-reset').addEventListener('click', resetTester); + resetTester(); +}); diff --git a/mouse-tester.html b/mouse-tester.html new file mode 100644 index 00000000..fcb03c70 --- /dev/null +++ b/mouse-tester.html @@ -0,0 +1,174 @@ +--- +title: Mouse Tester +layout: page +full-width: true +css: + - /assets/css/hide-heading.css + - /assets/css/mouse-tester.css +js: + - /assets/js/mouse-tester.js +--- + + +
+
+
+
+ + Browser mouse diagnostics +
+

Mouse Tester

+

Check buttons, movement, and scrolling in real time.

+
+ + Move, click, or scroll inside the test area to begin. +
+

+ + Input data stays in your browser. +

+
+ + + +
+
+
+
+
+ Live input +

Mouse visual

+
+ Listening +
+
+
+ {% include mouse-tester.svg %} + +

+ Move, click, and scroll anywhere in this panel. + Right-click is disabled only while testing here. + Back and Forward buttons may navigate this tab in Firefox. +

+
+
+
+
+ +
+
+
+
+ Digital input +

Buttons

+
+ +
+
+
+
+ 1 + Primary + Released + 0 presses +
+
+ 3 + Wheel + Released + 0 presses +
+
+ 2 + Secondary + Released + 0 presses +
+
+ 4 + Back + Released + 0 presses +
+
+ 5 + Forward + Released + 0 presses +
+
+

+ Extra button reporting can vary by mouse, operating system, and browser. +

+
+
+ +
+
+
+ Browser events +

Movement and wheel

+
+ Values update inside the test area +
+
+

Movement

+
+
Horizontal delta
0 px
+
Vertical delta
0 px
+
Position
0, 0
+
Total distance
0 px
+
Move events
0
+
Double clicks
0
+
+ +

Wheel

+
+
Horizontal
0 px
+
Vertical
0 px
+
Direction
Idle
+
Wheel events
0
+
+
+
+
+ +
+
+ + + Technical information + Event details + + Show details + +
+
+
Input APIs
MouseEvent and WheelEvent
+
Capture scope
Test area only
+
Active buttons bitmask
0 (none)
+
Last event
None
+
Wheel delta mode
Pixels
+
Browser support
Checking
+
+
+
+
+
+
+