// ==UserScript== // @name 焦点解决 // @namespace https://greasyfork.org/zh-CN/users/1535852-severedline // @version 3.1.1 // @description 沉浸式无障碍审查工具。 // @author qwerty // @license Unlicense // @match *://*/* // @run-at document-idle // @require https://cdn.jsdelivr.net/npm/axe-core@4.11.3/axe.min.js // @require https://cdn.jsdelivr.net/npm/tabbable@6.4.0/dist/index.umd.min.js // @require https://cdn.jsdelivr.net/npm/hotkeys-js@4.0.3/dist/hotkeys-js.min.js // @grant GM_getResourceText // @grant GM_addElement // @grant unsafeWindow // @resource vsrScript https://unpkg.com/@guidepup/virtual-screen-reader/lib/esm/index.browser.js // @downloadURL https://update.greasyfork.icu/scripts/574415/%E7%84%A6%E7%82%B9%E8%A7%A3%E5%86%B3.user.js // @updateURL https://update.greasyfork.icu/scripts/574415/%E7%84%A6%E7%82%B9%E8%A7%A3%E5%86%B3.meta.js // ==/UserScript== /* global axe, tabbable, hotkeys */ (function () { 'use strict'; const OVERLAY_ID = 'a11y-spotlight-overlay'; const HOLE_ID = 'a11y-spotlight-hole'; const HUD_ID = 'a11y-hud-panel'; const TOGGLE_ID = 'a11y-toggle-btn'; if (document.getElementById(OVERLAY_ID)) return; const css = ` #${OVERLAY_ID} { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 2147483646; pointer-events: none; overflow: hidden; opacity: 0; visibility: hidden; transition: opacity 0.3s ease; } #${OVERLAY_ID}.active { visibility: visible; opacity: 1; } #${HOLE_ID} { position: absolute; top: 0; left: 0; box-sizing: border-box; transition: transform 0.2s cubic-bezier(0.25, 1.2, 0.5, 1), width 0.2s cubic-bezier(0.25, 1.2, 0.5, 1), height 0.2s cubic-bezier(0.25, 1.2, 0.5, 1), border-radius 0.2s ease, box-shadow 0.3s ease; transition: transform 0.1s ease-out; will-change: transform; } #${HOLE_ID}.status-analyzing { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.85), 0 0 15px 2px rgba(56, 189, 248, 0.5) inset, 0 0 0 2px rgba(56, 189, 248, 0.9); } #${HOLE_ID}.status-good { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.85), 0 0 15px 2px rgba(16, 185, 129, 0.5) inset, 0 0 0 2px rgba(16, 185, 129, 0.9); } #${HOLE_ID}.status-warn { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.85), 0 0 15px 2px rgba(239, 68, 68, 0.5) inset, 0 0 0 2px rgba(239, 68, 68, 0.9); } #${HOLE_ID}.status-nofocus { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.85), 0 0 15px 2px rgba(100, 116, 139, 0.5) inset, 0 0 0 2px rgba(100, 116, 139, 0.9); } #${HUD_ID} { position: fixed; top: 0; left: 0; z-index: 2147483647; margin: 0; background: rgba(15, 23, 42, 0.95); color: #e2e8f0; border-radius: 8px; padding: 10px 12px; font-family: 'Consolas', 'Menlo', monospace; font-size: 11px; border: 1px solid #334155; box-shadow: 0 15px 50px rgba(0,0,0,0.7); width: 480px; max-height: 90vh; overflow-y: auto; line-height: 1.3; backdrop-filter: blur(12px); pointer-events: auto; display: flex; flex-direction: column; gap: 4px; transition: transform 0.1s ease-out; will-change: transform; } #${HUD_ID}::-webkit-scrollbar { width: 4px; } #${HUD_ID}::-webkit-scrollbar-thumb { background: #475569; border-radius: 2px; } #${HUD_ID} .hud-header { font-weight: bold; font-size: 12px; border-bottom: 1px solid #475569; padding-bottom: 6px; color: #38bdf8; display:flex; justify-content: space-between; align-items:center;} #${HUD_ID} .hud-section { border-top: 1px dashed #334155; padding-top: 6px; margin-top: 2px; } #${HUD_ID} .hud-section-title { font-size: 10px; color: #94a3b8; text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px;} #${HUD_ID} .hud-row { display: flex; align-items: flex-start; word-break: break-all; margin-bottom: 2px; } #${HUD_ID} .hud-label { color: #94a3b8; width: 60px; flex-shrink: 0; font-size: 11px; padding-top: 1px;} #${HUD_ID} .hud-val { color: #f8fafc; flex: 1; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } #${HUD_ID} .tag-badge { color: #f472b6; font-weight: bold; font-size: 12px;} #${HUD_ID} .id-badge { color: #818cf8; font-size: 12px;} #${HUD_ID} .class-badge { color: #a78bfa; font-size: 12px;} .color-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; border: 1px solid #64748b; vertical-align: middle; } .badge { display: inline-flex; align-items: center; padding: 1px 4px; border-radius: 3px; font-size: 10px; font-weight: bold; line-height: 1.2;} .badge.focus-yes { background: #059669; color: #fff; } .badge.focus-no { background: #475569; color: #cbd5e1; } .badge.focus-prog { background: #0284c7; color: #fff; } .badge.disabled { background: #b91c1c; color: #fff; } .badge.role-explicit { background: #8b5cf6; color: #fff; } .badge.role-implicit { border: 1px solid #64748b; color: #94a3b8; } .badge.aria-attr { background: #1e293b; border: 1px solid #475569; color: #38bdf8; font-family: monospace; } .badge.aria-attr span { color: #a7f3d0; } #${HUD_ID} .axe-violation { color: #ef4444; font-size: 12px; background: rgba(239, 68, 68, 0.1); padding: 8px; border-radius: 6px; border-left: 3px solid #ef4444; margin-bottom: 6px;} #${HUD_ID} .axe-incomplete { color: #eab308; font-size: 12px; background: rgba(245, 158, 11, 0.1); padding: 8px; border-radius: 6px; border-left: 3px solid #eab308; margin-bottom: 6px;} #${HUD_ID} .axe-pass { color: #10b981; font-size: 12px; background: rgba(16, 185, 129, 0.1); padding: 6px; border-radius: 6px; border-left: 3px solid #10b981; } #${HUD_ID} .hud-analyzing { color: #38bdf8; font-size: 12px; font-style: italic; animation: a11ypulse 1.5s infinite;} .a11y-btn { background: #38bdf8; color: #0f172a; border: none; padding: 4px 10px; border-radius: 6px; font-weight: bold; cursor: pointer; transition: 0.2s; font-size: 11px; } .a11y-btn:hover { background: #7dd3fc; } #${TOGGLE_ID} { position: fixed; top: calc(100vh - 74px); left: 20px; z-index: 2147483647; width: 54px; height: 54px; border-radius: 27px; background: #1e293b; border: 2px solid #38bdf8; color: #38bdf8; font-weight: bold; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3); transition: background 0.3s, color 0.3s, border-color 0.3s, transform 0.3s; display: flex; align-items: center; justify-content: center; } #${TOGGLE_ID}:hover { background: #38bdf8; color: #0f172a; transform: scale(1.08); } #${TOGGLE_ID}.active { background: #ef4444; border-color: #ef4444; color: #fff; } .a11y-ignore-inspect { pointer-events: auto !important; cursor: default; } .a11y-ignore-inspect:hover { opacity: 0.15 !important; transition: opacity 0.3s ease; z-index: 2147483640 !important; } .vsr-simulation-text { background: linear-gradient(90deg, rgba(16,185,129,0.2) 0%, rgba(0,0,0,0) 100%); border-left: 3px solid #10b981; padding: 4px 8px; font-family: 'Microsoft YaHei', sans-serif; letter-spacing: 0.5px; } .a11y-focus-badge { position: absolute; top: 0; left: 0; z-index: 2147483646; background: #eab308; color: #000; font-weight: bold; font-size: 11px; width: 20px; height: 20px; border-radius: 10px; border: 2px solid #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 5px rgba(0,0,0,0.4); pointer-events: none !important; } #a11y-live-log { margin-top: 8px; max-height: 120px; overflow-y: auto; background: rgba(0,0,0,0.3); border-radius: 6px; padding: 6px; } .live-log-item { font-size: 11px; padding: 4px; border-bottom: 1px solid #334155; color: #a7f3d0; word-break: break-all; } .live-log-time { color: #94a3b8; margin-right: 6px; font-family: monospace; } @keyframes a11ypulse { 0% {opacity: 1;} 50% {opacity: 0.5;} 100% {opacity: 1;} } .a11y-landmark-box { position: absolute; pointer-events: none !important; z-index: 2147483641; border: 2px dashed; border-radius: 4px; background: rgba(0,0,0,0.03); transition: opacity 0.3s ease; } .a11y-landmark-label { position: absolute; top: 0; left: 0; padding: 2px 6px; font-size: 11px; font-weight: bold; color: #fff; border-bottom-right-radius: 4px; text-transform: uppercase; letter-spacing: 1px; backdrop-filter: blur(4px); font-family: 'Consolas', 'Menlo', monospace; } .a11y-heading-label { position: absolute; top: 0; right: 0; padding: 2px 6px; font-size: 11px; font-weight: bold; color: #000; background: #fbbf24; border-bottom-left-radius: 4px; text-transform: uppercase; letter-spacing: 1px; box-shadow: -2px 2px 5px rgba(0,0,0,0.2); font-family: 'Consolas', 'Menlo', monospace; } .a11y-trap-container-box { position: absolute; pointer-events: none !important; z-index: 2147483640; border: 3px dashed #a855f7; border-radius: 8px; background: rgba(168, 85, 247, 0.05); box-shadow: 0 0 15px rgba(168, 85, 247, 0.3); transition: all 0.3s ease; } .a11y-trap-label { position: absolute; top: -24px; left: -3px; background: #a855f7; color: #fff; padding: 2px 8px; font-size: 11px; font-weight: bold; border-radius: 4px 4px 0 0; font-family: 'Consolas', monospace; display: flex; align-items: center; gap: 4px; } .a11y-trap-node-badge { position: absolute; z-index: 2147483648; pointer-events: none; padding: 2px 6px; font-size: 10px; font-weight: bold; color: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.5); font-family: monospace; transform: translate(-50%, -100%); margin-top: -4px; } .trap-badge-first { background: #10b981; border: 1px solid #047857; } .trap-badge-last { background: #ef4444; border: 1px solid #b91c1c; } `; const styleEl = document.createElement('style'); styleEl.textContent = css; document.head.appendChild(styleEl); const isTopWindow = window === window.top; let overlay, hud, toggleBtn, hole, focusBadgeContainer, ariaBadgeContainer, svgLinesContainer, landmarkContainer; let customLines = []; let landmarkBoxes = []; let isActive = false; let currentTarget = null; let axeTimer = null; let lastAxeResults = null; let isFocusPathActive = false; let isAxeRunning = false; let pendingAxeElement = null; let isLocked = false; let isSpotlightEnabled = true; let liveLogs = []; let currentFocusElements = []; let focusBadges = []; let liveObserver = null; let trapVisualContainer; let currentTrapState = null; let virtual = null; let virtualFocusinHandlers = []; (async () => { try { const rawCode = GM_getResourceText('vsrScript'); if (!rawCode) throw new Error('未找到资源 vsrScript'); const modifiedCode = rawCode .replace(/export\s*\{[^}]+\};?/g, 'window.VirtualScreenReader = X$;') .replace( /async function re\(\)\{return await new Promise\(e=>setTimeout\(\(\)=>e\(\)\)\)\}/g, 'async function re(){return Promise.resolve()}' ); GM_addElement('script', { textContent: modifiedCode }); virtual = unsafeWindow.VirtualScreenReader; console.log('✅ Virtual Screen Reader 绕过 CSP 加载成功!'); } catch (e) { console.error('❌ 加载 virtual-screen-reader 失败:', e); } })(); if (isTopWindow) { overlay = document.createElement('div'); overlay.id = OVERLAY_ID; overlay.innerHTML = `
`; document.documentElement.appendChild(overlay); hud = document.createElement('div'); hud.id = HUD_ID; hud.style.display = 'none'; hud.addEventListener('click', (e) => { if (e.target.closest('#a11y-btn-focus-path')) { window.__toggleFocusPath(); } else if (e.target.closest('#a11y-btn-log-node')) { window.__logA11yNode(); } }); document.documentElement.appendChild(hud); toggleBtn = document.createElement('button'); toggleBtn.id = TOGGLE_ID; toggleBtn.innerText = 'A11y'; toggleBtn.title = '开启/关闭 无障碍审查'; toggleBtn.setAttribute('aria-pressed', 'false'); document.documentElement.appendChild(toggleBtn); hole = document.getElementById(HOLE_ID); focusBadgeContainer = document.createElement('div'); focusBadgeContainer.style.position = 'fixed'; focusBadgeContainer.style.top = '0'; focusBadgeContainer.style.left = '0'; focusBadgeContainer.style.width = '100%'; focusBadgeContainer.style.height = '0'; focusBadgeContainer.style.pointerEvents = 'none'; focusBadgeContainer.style.zIndex = '2147483646'; document.documentElement.appendChild(focusBadgeContainer); ariaBadgeContainer = document.createElement('div'); ariaBadgeContainer.id = 'a11y-aria-bubbles'; ariaBadgeContainer.style.position = 'fixed'; ariaBadgeContainer.style.top = '0'; ariaBadgeContainer.style.left = '0'; ariaBadgeContainer.style.width = '100vw'; ariaBadgeContainer.style.height = '100vh'; ariaBadgeContainer.style.pointerEvents = 'none'; ariaBadgeContainer.style.zIndex = '2147483646'; document.documentElement.appendChild(ariaBadgeContainer); svgLinesContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svgLinesContainer.id = 'a11y-custom-lines'; svgLinesContainer.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483645;overflow:visible;'; svgLinesContainer.innerHTML = ` `; document.documentElement.appendChild(svgLinesContainer); landmarkContainer = document.createElement('div'); landmarkContainer.id = 'a11y-landmarks-container'; landmarkContainer.style.position = 'fixed'; landmarkContainer.style.top = '0'; landmarkContainer.style.left = '0'; landmarkContainer.style.width = '100%'; landmarkContainer.style.height = '0'; landmarkContainer.style.pointerEvents = 'none'; landmarkContainer.style.zIndex = '2147483641'; document.documentElement.appendChild(landmarkContainer); trapVisualContainer = document.createElement('div'); trapVisualContainer.id = 'a11y-trap-visuals'; trapVisualContainer.style.position = 'fixed'; trapVisualContainer.style.top = '0'; trapVisualContainer.style.left = '0'; trapVisualContainer.style.width = '100%'; trapVisualContainer.style.height = '0'; trapVisualContainer.style.pointerEvents = 'none'; trapVisualContainer.style.zIndex = '2147483642'; document.documentElement.appendChild(trapVisualContainer); } function analyzeFocusTrap(el) { const container = el.closest( 'dialog, [role="dialog"], [role="alertdialog"], [aria-modal="true"]', ); if (!container) return null; const trapInfo = { container: container, firstNode: null, lastNode: null, totalTabbable: 0, }; try { const nodes = tabbable.tabbable(container, { getShadowRoot: true }); trapInfo.totalTabbable = nodes.length; if (nodes.length > 0) { trapInfo.firstNode = nodes[0]; trapInfo.lastNode = nodes[nodes.length - 1]; } } catch (e) { console.debug('Focus trap analysis error:', e); } return trapInfo; } function drawTrapVisuals(trapInfo) { if (!isTopWindow) return; trapVisualContainer.innerHTML = ''; if (!trapInfo) return; const { container, firstNode, lastNode } = trapInfo; const rect = getViewportRect(container); if (rect.width > 0 && rect.height > 0) { const box = document.createElement('div'); box.className = 'a11y-trap-container-box'; box.style.left = `${rect.left}px`; box.style.top = `${rect.top}px`; box.style.width = `${rect.width}px`; box.style.height = `${rect.height}px`; const label = document.createElement('div'); label.className = 'a11y-trap-label'; label.innerHTML = `🔒 Focus Trap Container (${trapInfo.totalTabbable} nodes)`; box.appendChild(label); trapVisualContainer.appendChild(box); } const drawBadge = (node, text, typeClass) => { if (!node) return; const nRect = getViewportRect(node); const badge = document.createElement('div'); badge.className = `a11y-trap-node-badge ${typeClass}`; badge.innerText = text; badge.style.left = `${nRect.left + nRect.width / 2}px`; badge.style.top = `${nRect.top}px`; trapVisualContainer.appendChild(badge); }; drawBadge(firstNode, '▶️ 第一落点', 'trap-badge-first'); if (lastNode && lastNode !== firstNode) { drawBadge(lastNode, '⏹️ 末尾节点', 'trap-badge-last'); } } function updateCustomLines() { customLines.forEach((item) => { try { const r1 = getViewportRect(item.sourceEl); const r2 = getViewportRect(item.targetEl); const inViewport1 = r1.bottom > 0 && r1.top < window.innerHeight && r1.right > 0 && r1.left < window.innerWidth; const inViewport2 = r2.bottom > 0 && r2.top < window.innerHeight && r2.right > 0 && r2.left < window.innerWidth; if (!inViewport1 && !inViewport2) { item.pathEl.style.display = 'none'; if (item.targetBox) item.targetBox.style.display = 'none'; if (item.targetBubble) item.targetBubble.style.display = 'none'; return; } item.pathEl.style.display = 'block'; if (item.targetBox) item.targetBox.style.display = 'block'; if (item.targetBubble) item.targetBubble.style.display = 'block'; const x1 = r1.left + r1.width / 2; const y1 = r1.top + r1.height / 2; const x2 = r2.left + r2.width / 2; const y2 = r2.top + r2.height / 2; const dx = x2 - x1; const cx1 = x1 + dx * 0.3; const cy1 = y1; const cx2 = x2 - dx * 0.3; const cy2 = y2; item.pathEl.setAttribute('d', `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`); if (item.gradEl) { item.gradEl.setAttribute('x1', x1); item.gradEl.setAttribute('y1', y1); item.gradEl.setAttribute('x2', x2); item.gradEl.setAttribute('y2', y2); } if (item.targetBox) { item.targetBox.style.transform = `translate(${r2.left - 6}px, ${r2.top - 6}px)`; item.targetBox.style.width = `${r2.width + 12}px`; item.targetBox.style.height = `${r2.height + 12}px`; } if (item.targetBubble) { item.targetBubble.style.transform = `translate(${r2.left}px, ${r2.top - item.targetBubble.offsetHeight - 8}px)`; } } catch (e) { console.debug(e); } }); } function updateHudPosition() { if (hud.style.display === 'none') return; const btnRect = toggleBtn.getBoundingClientRect(); const hudRect = hud.getBoundingClientRect(); let x = btnRect.left; let y = btnRect.top - hudRect.height - 16; if (y < 0) y = btnRect.bottom + 16; if (x + hudRect.width > window.innerWidth - 16) x = window.innerWidth - hudRect.width - 16; if (x < 16) x = 16; if (y + hudRect.height > window.innerHeight - 16) y = window.innerHeight - hudRect.height - 16; if (y < 16) y = 16; hud.style.transform = `translate(${x}px, ${y}px)`; } let hudObserver; if (isTopWindow) { hudObserver = new MutationObserver(() => { if (isActive) updateHudPosition(); }); hudObserver.observe(hud, { childList: true, subtree: true, characterData: true }); } function clearAriaVisuals() { customLines = customLines.filter((line) => { if (line.type === 'aria') { line.pathEl.remove(); if (line.targetBox) line.targetBox.remove(); if (line.targetBubble) line.targetBubble.remove(); return false; } return true; }); const ariaBubbleGroup = document.getElementById('a11y-aria-bubbles'); if (ariaBubbleGroup) ariaBubbleGroup.innerHTML = ''; } function deepQuerySelectorAll(selector, root = document) { const results = Array.from(root.querySelectorAll(selector)); const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { acceptNode: function (node) { return node.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; }, }); let currentNode = walker.nextNode(); while (currentNode) { results.push(...deepQuerySelectorAll(selector, currentNode.shadowRoot)); currentNode = walker.nextNode(); } return results; } function updateLandmarks(forceRebuild = false) { if (!isTopWindow) return; if (!isActive) { landmarkContainer.innerHTML = ''; landmarkBoxes = []; return; } if (landmarkBoxes.length === 0 || forceRebuild) { landmarkContainer.innerHTML = ''; landmarkBoxes = []; const landmarkSelectors = 'main, nav, header, footer, aside, form, search, section, [role="main"], [role="navigation"],[role="banner"], [role="contentinfo"],[role="complementary"], [role="form"], [role="search"],[role="region"], [role="group"]'; const headingSelectors = 'h1, h2, h3, h4, h5, h6,[role="heading"]'; const elements = deepQuerySelectorAll(`${landmarkSelectors}, ${headingSelectors}`); const colors = { main: '#ec4899', nav: '#3b82f6', header: '#f59e0b', footer: '#8b5cf6', aside: '#10b981', form: '#06b6d4', search: '#0ea5e9', section: '#64748b', group: '#64748b', }; elements.forEach((el) => { try { const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; } catch { return; } let tagName = el.tagName.toLowerCase(); let role = el.getAttribute('role') || tagName; let isHeading = /^h[1-6]$/.test(tagName) || role === 'heading'; let color = isHeading ? '#fbbf24' : colors[role] || '#94a3b8'; let labelText = role; if (isHeading && el.hasAttribute('aria-level')) { labelText += ` (L${el.getAttribute('aria-level')})`; } const ariaLabel = el.getAttribute('aria-label'); if (ariaLabel) labelText += ` ("${ariaLabel}")`; const box = document.createElement('div'); box.className = 'a11y-landmark-box'; box.style.border = isHeading ? `2px dotted ${color}` : `2px dashed ${color}`; const label = document.createElement('div'); label.className = isHeading ? 'a11y-heading-label' : 'a11y-landmark-label'; if (!isHeading) label.style.backgroundColor = color; label.innerText = labelText; box.appendChild(label); landmarkContainer.appendChild(box); landmarkBoxes.push({ el, box }); }); } landmarkBoxes.forEach(({ el, box }) => { const rect = getViewportRect(el); const inViewport = isElementInViewport(rect); if (inViewport && rect.width > 0 && rect.height > 0) { if (box.style.display !== 'block') box.style.display = 'block'; box.style.left = rect.left + 'px'; box.style.top = rect.top + 'px'; box.style.width = rect.width + 'px'; box.style.height = rect.height + 'px'; } else { if (box.style.display !== 'none') box.style.display = 'none'; } }); } function clearFocusVisuals() { customLines = customLines.filter((line) => { if (line.type === 'focus') { line.pathEl.remove(); if (line.defsEl) line.defsEl.remove(); return false; } return true; }); focusBadges = []; if (focusBadgeContainer) focusBadgeContainer.innerHTML = ''; } function drawAriaLines(sourceEl) { if (!isTopWindow) return; clearAriaVisuals(); if (!sourceEl) return; const relationships = [ { attrs: ['aria-labelledby'], color: '#38bdf8', marker: 'blue' }, { attrs: ['aria-describedby', 'aria-details', 'aria-errormessage'], color: '#a855f7', marker: 'purple', }, { attrs: ['aria-controls', 'aria-owns', 'aria-activedescendant'], color: '#f97316', marker: 'orange', }, ]; const ariaBubbleGroup = document.getElementById('a11y-aria-bubbles'); relationships.forEach((rel) => { rel.attrs.forEach((attr) => { if (sourceEl.hasAttribute(attr)) { const ids = sourceEl.getAttribute(attr).split(/\s+/).filter(Boolean); ids.forEach((id) => { const targetEl = document.getElementById(id); if (targetEl) { const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); pathEl.setAttribute('stroke', rel.color); pathEl.setAttribute('stroke-width', '2'); pathEl.setAttribute('fill', 'none'); pathEl.setAttribute('stroke-dasharray', '4 4'); pathEl.setAttribute('marker-end', `url(#a11y-arrow-${rel.marker})`); svgLinesContainer.appendChild(pathEl); const targetBox = document.createElement('div'); targetBox.style.position = 'absolute'; targetBox.style.top = '0'; targetBox.style.left = '0'; targetBox.style.border = `2px dashed ${rel.color}`; targetBox.style.backgroundColor = `${rel.color}20`; targetBox.style.borderRadius = '8px'; targetBox.style.pointerEvents = 'none'; ariaBubbleGroup.appendChild(targetBox); const targetBubble = document.createElement('div'); targetBubble.style.position = 'absolute'; targetBubble.style.top = '0'; targetBubble.style.left = '0'; targetBubble.style.backgroundColor = rel.color; targetBubble.style.color = '#fff'; targetBubble.style.padding = '4px 10px'; targetBubble.style.borderRadius = '12px'; targetBubble.style.fontSize = '12px'; targetBubble.style.fontWeight = 'bold'; targetBubble.style.boxShadow = '0 4px 10px rgba(0,0,0,0.5), 0 0 0 2px rgba(255,255,255,0.8)'; targetBubble.className = 'a11y-ignore-inspect'; targetBubble.style.maxWidth = '250px'; targetBubble.style.wordBreak = 'break-all'; targetBubble.innerText = attr; ariaBubbleGroup.appendChild(targetBubble); customLines.push({ type: 'aria', sourceEl, targetEl, pathEl, targetBox, targetBubble, }); } }); } }); }); updateCustomLines(); } window.__toggleFocusPath = function () { isFocusPathActive = !isFocusPathActive; clearFocusVisuals(); if (!isFocusPathActive) { drawSpotlight(); return; } let elements = []; try { elements = tabbable.focusable(document.body, { getShadowRoot: true }); } catch (e) { console.debug(e); } elements = elements.filter((el) => { if ( el.closest(`#${HUD_ID}`) || el.closest(`#${TOGGLE_ID}`) || el.closest('.a11y-ignore-inspect') ) return false; const rect = getViewportRect(el); const inViewport = isElementInViewport(rect, 500); const style = window.getComputedStyle(el); return ( inViewport && rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' ); }); if (elements.length > 120) { elements = elements.slice(0, 120); } currentFocusElements = elements; try { let strictTabbable = tabbable.tabbable(document.body, { getShadowRoot: true }); const tabIndexMap = new Map(strictTabbable.map((el, i) => [el, i])); elements.sort((a, b) => { let indexA = tabIndexMap.has(a) ? tabIndexMap.get(a) : 999999; let indexB = tabIndexMap.has(b) ? tabIndexMap.get(b) : 999999; return indexA - indexB; }); } catch (e) { console.debug(e); } if (isTopWindow) { let prevEl = null; let prevColor = null; const total = elements.length; elements.forEach((el, index) => { const progress = total > 1 ? index / (total - 1) : 0; const hue = progress * 280; const currentColor = `hsl(${hue}, 90%, 55%)`; const badge = document.createElement('div'); badge.className = 'a11y-focus-badge'; badge.innerHTML = index + 1; badge.style.backgroundColor = currentColor; badge.style.borderColor = '#fff'; badge.style.color = '#000'; focusBadgeContainer.appendChild(badge); const rect = getViewportRect(el); const tx = rect.left - 10; const ty = rect.top - 10; badge.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`; badge.style.opacity = '1'; focusBadges.push({ el, badge }); if (prevEl) { try { const gradId = `a11y-grad-${Date.now()}-${index}`; const markerId = `a11y-marker-${Date.now()}-${index}`; const linearGrad = document.createElementNS( 'http://www.w3.org/2000/svg', 'linearGradient', ); linearGrad.id = gradId; linearGrad.setAttribute('gradientUnits', 'userSpaceOnUse'); linearGrad.innerHTML = ` `; const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); marker.id = markerId; marker.setAttribute('viewBox', '0 0 10 10'); marker.setAttribute('refX', '8'); marker.setAttribute('refY', '5'); marker.setAttribute('markerWidth', '6'); marker.setAttribute('markerHeight', '6'); marker.setAttribute('orient', 'auto'); marker.innerHTML = ``; const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); defs.appendChild(linearGrad); defs.appendChild(marker); svgLinesContainer.appendChild(defs); const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); pathEl.setAttribute('stroke', `url(#${gradId})`); pathEl.setAttribute('stroke-width', '2.5'); pathEl.setAttribute('fill', 'none'); pathEl.setAttribute('marker-end', `url(#${markerId})`); svgLinesContainer.appendChild(pathEl); customLines.push({ type: 'focus', sourceEl: prevEl, targetEl: el, pathEl, defsEl: defs, gradEl: linearGrad, }); } catch (e) { console.debug(e); } } prevEl = el; prevColor = currentColor; }); updateFocusBadges(); updateCustomLines(); } drawSpotlight(); }; function updateFocusBadges() { focusBadges.forEach(({ el, badge }) => { const rect = getViewportRect(el); const inViewport = rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth; if (inViewport && rect.width > 0 && rect.height > 0) { badge.style.display = 'flex'; badge.style.transform = `translate(${Math.round(rect.left - 10)}px, ${Math.round(rect.top - 10)}px)`; } else { badge.style.display = 'none'; } }); } function setupLiveObserver(enable) { if (!enable && liveObserver) { liveObserver.disconnect(); return; } if (enable && !liveObserver) { let liveUpdateTimer = null; let pendingRegions = new Set(); liveObserver = new MutationObserver((mutations) => { mutations.forEach((m) => { let target = m.target; if (target.nodeType === Node.TEXT_NODE) target = target.parentNode; if (m.type === 'childList' || m.type === 'characterData') { const liveRegion = target.closest( '[aria-live], [role="alert"], [role="status"], [role="log"]', ); if (liveRegion) pendingRegions.add(liveRegion); } }); if (pendingRegions.size > 0) { if (liveUpdateTimer) clearTimeout(liveUpdateTimer); liveUpdateTimer = setTimeout(() => { const time = new Date().toLocaleTimeString(); pendingRegions.forEach((region) => { let textChange = (region.textContent || '').trim().replace(/\s+/g, ' '); if (!textChange) textChange = '(DOM节点变动, 无文本)'; if ( liveLogs.length > 0 && liveLogs[0].includes(textChange) && liveLogs[0].includes(time) ) { return; } liveLogs.unshift( `
[${time}] ${textChange}
`, ); }); if (liveLogs.length > 20) liveLogs.length = 20; if (isActive) { const logContainer = document.getElementById('a11y-live-log'); if (logContainer) logContainer.innerHTML = liveLogs.join(''); } pendingRegions.clear(); }, 150); } }); liveObserver.observe(document.body, { childList: true, characterData: true, subtree: true }); } } window.__logA11yNode = function () { if (!currentTarget) return; unsafeWindow.$a11y = currentTarget; unsafeWindow.$axe = lastAxeResults; console.log('%c========== A11y Inspector ==========', 'color:#38bdf8; font-weight:bold;'); console.log('%cDOM 节点 ($a11y):', 'color:#10b981; font-weight:bold;', currentTarget); if (lastAxeResults) { console.log('%cAxe 诊断数据 ($axe):', 'color:#f472b6; font-weight:bold;', lastAxeResults); } console.log('%c====================================', 'color:#38bdf8; font-weight:bold;'); }; const implicitRoles = { a: 'link', button: 'button', nav: 'navigation', main: 'main', header: 'banner', footer: 'contentinfo', aside: 'complementary', search: 'search', dialog: 'dialog', article: 'article', section: 'region', h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', ul: 'list', ol: 'list', li: 'listitem', table: 'table', tr: 'row', th: 'columnheader', td: 'cell', img: 'img', figure: 'figure', progress: 'progressbar', select: 'combobox', textarea: 'textbox', }; function inspectElement(el) { const info = { tag: el.tagName.toLowerCase(), id: el.id, className: el.className && typeof el.className === 'string' ? el.className.trim().split(' ').join('.') : '', role: { explicit: el.getAttribute('role'), computed: null, implicit: null }, focus: { isFocusable: false, tabIndex: el.tabIndex, statusStr: '', class: '' }, name: { text: null, description: null, source: null }, value: (() => { if (el.tagName === 'INPUT' && el.type === 'password') return null; if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') return el.value; if (el.tagName === 'SELECT') return el.options[el.selectedIndex]?.text || null; return null; })(), isPassword: el.tagName === 'INPUT' && el.type === 'password', nativeChecked: el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio') ? el.checked : null, shortcuts: el.getAttribute('aria-keyshortcuts') || el.getAttribute('accesskey') || null, roleDescription: el.getAttribute('aria-roledescription') || null, level: el.getAttribute('aria-level') || null, childCount: ['ul', 'ol', 'menu', 'tablist'].includes(el.tagName.toLowerCase()) || ['list', 'menu', 'tablist', 'grid'].includes(el.getAttribute('role')) ? el.children.length : null, aria: [], nativeAttrs: [], states: [], }; const standardAttrs = [ 'alt', 'title', 'placeholder', 'for', 'type', 'href', 'src', 'lang', 'required', 'readonly', ]; for (let attr of standardAttrs) { if (el.hasAttribute(attr)) { info.nativeAttrs.push({ name: attr, value: el.getAttribute(attr) }); } } if (info.tag === 'input') { const t = el.type || 'text'; info.role.implicit = ['button', 'submit', 'reset', 'image'].includes(t) ? 'button' : ['checkbox', 'radio', 'search'].includes(t) ? t : 'textbox'; } else { info.role.implicit = implicitRoles[info.tag] || null; } if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { info.role.implicit = 'textbox'; if (!info.states.includes('multiline')) info.states.push('HTML: multiline'); } info.role.computed = info.role.explicit || info.role.implicit; const isNativeFocusable = [ 'button', 'input', 'select', 'textarea', 'a', 'details', 'summary', ].includes(info.tag); const isDisabled = el.disabled || el.getAttribute('aria-disabled') === 'true' || el.closest?.('fieldset:disabled') !== null; const isHidden = el.getAttribute('aria-hidden') === 'true' || window.getComputedStyle(el).display === 'none'; const isInert = el.inert || el.closest?.('[inert]') !== null; if (isInert) { info.focus.statusStr = 'Inert (冻结/忽略/不可交互)'; info.focus.class = 'focus-no'; info.states.push('Inert'); } else if (isDisabled) { info.focus.statusStr = 'Disabled (不可聚焦/交互)'; info.focus.class = 'disabled'; info.states.push('Disabled'); } else if (isHidden) { info.focus.statusStr = 'Hidden (对屏幕阅读器隐藏)'; info.focus.class = 'focus-no'; info.states.push('Hidden'); } else if (el.tabIndex >= 0) { info.focus.isFocusable = true; info.focus.statusStr = `Keyboard Focusable (tabindex=${el.tabIndex})`; info.focus.class = 'focus-yes'; } else if (el.tabIndex === -1 && !isNativeFocusable && el.hasAttribute('tabindex')) { info.focus.isFocusable = true; info.focus.statusStr = `Programmatically Focusable (tabindex=-1)`; info.focus.class = 'focus-prog'; } else { info.focus.statusStr = `Not Focusable`; info.focus.class = 'focus-no'; } if (!info.name.text) { if (el.hasAttribute('aria-label')) { info.name = { text: el.getAttribute('aria-label'), source: 'aria-label' }; } else if (info.tag === 'img' && el.hasAttribute('alt')) { info.name = { text: el.getAttribute('alt'), source: 'alt' }; } else if (info.tag === 'svg' && el.querySelector('title')) { info.name = { text: el.querySelector('title').textContent, source: '' }; } else if (['input', 'textarea', 'select'].includes(info.tag) && el.id) { const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`); if (label) info.name = { text: label.textContent.trim(), source: '<label for>' }; } else if (el.hasAttribute('title')) { info.name = { text: el.getAttribute('title'), source: 'title' }; } else { let text = (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' '); if (text) { info.name.text = text.length > 40 ? text.substring(0, 40) + '...' : text; info.name.source = 'Inner Text'; } } } if (el.hasAttribute('aria-describedby')) { const ids = el.getAttribute('aria-describedby').split(/\s+/).filter(Boolean); info.name.description = ids .map((id) => document.getElementById(id)?.textContent.trim() || '') .join(' '); } else if (el.hasAttribute('aria-description')) { info.name.description = el.getAttribute('aria-description'); } else if (el.hasAttribute('title') && info.name.source !== 'title') { info.name.description = el.getAttribute('title'); } for (let attr of el.attributes) { if (attr.name.startsWith('aria-')) { info.aria.push({ name: attr.name, value: attr.value }); } } if (el.hasAttribute('required')) info.states.push('HTML: required'); if (el.hasAttribute('readonly')) info.states.push('HTML: readonly'); return info; } function updateHUDFromInfo(info, isFocusPathActiveHtml, focusOrderHtml, trapInfo) { if (!isTopWindow) return; let roleHtml = ''; if (info.role.explicit) roleHtml += `<span class="badge role-explicit">Explicit: ${info.role.explicit}</span>`; if (info.role.computed) roleHtml += `<span class="badge role-implicit">Computed: ${info.role.computed}</span>`; if (!roleHtml) roleHtml = '<span style="color:#64748b;">(无语义)</span>'; let ariaHtml = info.aria.length === 0 ? '<span style="color:#64748b;font-size:11px;">(无 ARIA 属性)</span>' : info.aria .map((a) => `<span class="badge aria-attr">${a.name}="<span>${a.value}</span>"</span>`) .join(''); let nativeHtml = info.nativeAttrs.length === 0 ? '<span style="color:#64748b;font-size:11px;">(无重点原生属性)</span>' : info.nativeAttrs .map( (a) => `<span class="badge" style="background:#334155;color:#e2e8f0;border:1px solid #475569;">${a.name}="<span style="color:#93c5fd">${a.value}</span>"</span>`, ) .join(''); let statesHtml = info.states.length === 0 ? '' : info.states.map((s) => `<span class="badge role-implicit">${s}</span>`).join(''); let trapHtml = ''; if (trapInfo) { trapHtml = ` <div class="hud-section" style="background: rgba(168, 85, 247, 0.1); border: 1px solid #a855f7; border-radius: 6px; padding: 4px 6px; margin-bottom: 6px; color: #c084fc; display: flex; justify-content: space-between; align-items: center;"> <span style="font-size:12px; font-weight:bold;">🔒 发现焦点陷阱容器</span> <span style="font-size:11px; color:#e9d5ff; background:#9333ea; padding:1px 6px; border-radius:10px;">${trapInfo.totalTabbable} 个节点</span> </div> `; } hud.innerHTML = ` <div class="hud-header"> <span>🎯 A11y Inspector ${isLocked ? '<span style="color:#ef4444;">🔒已锁定</span>' : ''}</span> <div style="display:flex; gap: 4px;"> <button id="a11y-btn-focus-path" class="a11y-btn" style="background:${isFocusPathActiveHtml ? '#eab308' : '#475569'}; color:#fff;" title="快捷键: F"> ${isFocusPathActiveHtml ? '🗺️ 关闭焦点 (F)' : '🗺️ 焦点路径 (F)'} </button> <button id="a11y-btn-log-node" class="a11y-btn" title="快捷键: P">🖨️ 打印 (P)</button> </div> </div> <div class="hud-section" style="background: rgba(16, 185, 129, 0.1); border: 1px solid #10b981; border-radius: 6px; padding: 6px; margin-bottom: 6px;"> <div style="font-size:11px; color:#34d399; font-weight:bold; margin-bottom:4px;"> 🎙️ VSR 仿真播报: </div> <div id="v-sr-speech" class="vsr-simulation-text" style="color: #fff; font-size: 13px; font-weight: bold; line-height: 1.4; word-break: break-all;"> " 正在计算无障碍树... " </div> </div> <div class="hud-section" style="border:none; padding-top:0;"> <div class="hud-row"> <span class="hud-label">Element:</span> <span class="hud-val"> <span class="tag-badge"><${info.tag}></span>${info.id ? `<span class="id-badge">#${info.id}</span>` : ''}${info.className ? `<span class="class-badge">.${info.className}</span>` : ''} ${statesHtml} </span> </div> <div class="hud-row"> <span class="hud-label">Focus:</span> <span class="hud-val"><span class="badge ${info.focus.class}">${info.focus.statusStr}</span>${focusOrderHtml || ''}</span> </div> <div class="hud-row"> <span class="hud-label">Role:</span> <span class="hud-val">${roleHtml}</span> </div> <div class="hud-row"> <span class="hud-label">AccName:</span> <span class="hud-val ${info.name.text ? 'good' : 'missing'}"> ${info.name.text ? `<span style="color:#a3e635;font-weight:bold;">"${info.name.text}"</span>` : '🚫 为空'} ${info.name.source ? `<span style="color:#64748b; font-size:10px;">(${info.name.source})</span>` : ''} ${info.name.description ? `<span style="color:#cbd5e1;font-size:10px; border-left:1px solid #475569; padding-left:4px; margin-left:4px;">Desc: "${info.name.description}"</span>` : ''} </span> </div> </div> <div class="hud-section" style="display:flex; gap:10px;"> <div style="flex:1"> <div class="hud-section-title">HTML Attrs</div> <div class="hud-val">${nativeHtml}</div> </div> <div style="flex:1; border-left: 1px dashed #334155; padding-left:10px;"> <div class="hud-section-title">ARIA Attrs</div> <div class="hud-val">${ariaHtml}</div> </div> </div> ${trapHtml} <div class="hud-section"> <div class="hud-section-title">🤖 Axe-core Validation</div> <div id="axe-results-container"> <div class="hud-analyzing">⚡ 正在执行规则校验...</div> </div> </div> <div class="hud-section"> <div class="hud-section-title">📡 Aria-Live</div> <div id="a11y-live-log" style="max-height: 60px;"> ${liveLogs.length ? liveLogs.join('') : '<div style="color:#64748b; font-size:10px;">等待实时播报...</div>'} </div> </div> <div style="margin-top:2px; font-size:10px; color:#94a3b8; text-align:right;"> 💡 <kbd style="background:#334155;padding:1px 3px;border-radius:3px;">Alt+Shift+A</kbd> 开关 | <kbd style="background:#334155;padding:1px 3px;border-radius:3px;">L</kbd> 锁元素 | <kbd style="background:#334155;padding:1px 3px;border-radius:3px;">W</kbd> 聚光灯 | 按住 <kbd style="background:#334155;padding:1px 3px;border-radius:3px;">Ctrl</kbd> 隐藏 </div> `; if (virtual && currentTarget && virtualFocusinHandlers.length > 0) { virtualFocusinHandlers.forEach((handler) => handler({ target: currentTarget })); setTimeout(async () => { try { const speech = await virtual.lastSpokenPhrase(); const speechEl = document.getElementById('v-sr-speech'); if (speechEl) { speechEl.innerText = '" ' + (speech || '空白') + ' "'; } } catch (e) { console.debug('Virtual SR 报错:', e); } }, 60); } } function drawSpotlight() { if ( !isActive || !currentTarget || (!isLocked && (currentTarget === document.body || currentTarget === document.documentElement)) ) { if (isTopWindow) { hole.style.width = '0px'; hole.style.height = '0px'; hud.innerHTML = `<div style="color:#94a3b8; text-align:center; padding: 20px;">🖱️ 鼠标悬浮或使用 Tab 键选择元素进行审查...</div>`; if (typeof trapVisualContainer !== 'undefined') trapVisualContainer.innerHTML = ''; } return; } let rect; if (currentTarget === document.documentElement || currentTarget === document.body) { rect = { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; } else { rect = getViewportRect(currentTarget); } const info = inspectElement(currentTarget); const borderRadius = window.getComputedStyle(currentTarget).borderRadius; let focusOrderHtml = ''; if (isFocusPathActive && currentFocusElements.length > 0) { const orderIndex = currentFocusElements.indexOf(currentTarget); if (orderIndex !== -1) { focusOrderHtml = `<span class="badge focus-prog" style="margin-left:4px;">#${orderIndex + 1} 顺位</span>`; } } currentTrapState = analyzeFocusTrap(currentTarget); if (currentTrapState) { drawTrapVisuals(currentTrapState); } else { trapVisualContainer.innerHTML = ''; } if (!isSpotlightEnabled) { hole.style.display = 'none'; } else { hole.style.display = 'block'; const padding = 4; hole.style.width = `${rect.width + padding * 2}px`; hole.style.height = `${rect.height + padding * 2}px`; hole.style.transform = `translate(${rect.left - padding}px, ${rect.top - padding}px)`; hole.style.borderRadius = borderRadius; hole.className = 'status-analyzing'; } updateHUDFromInfo(info, isFocusPathActive, focusOrderHtml, currentTrapState); } async function runAxeCheck(el) { if (!window.axe || !el || !document.body.contains(el)) return; if (isAxeRunning) { pendingAxeElement = el; return; } isAxeRunning = true; try { const axeContext = { include: [el], exclude: [ ['#' + HUD_ID], ['#' + TOGGLE_ID], ['#' + OVERLAY_ID], ['#a11y-aria-bubbles'], ['.a11y-focus-badge'], ], }; const results = await axe.run(axeContext, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'], }, }); if (el !== currentTarget || !document.body.contains(el)) return; lastAxeResults = { violations: [], incomplete: [] }; const isExactTarget = (nodeArray) => { return nodeArray.find((n) => { try { const selector = n.target[n.target.length - 1]; return document.querySelector(selector) === el; } catch { return false; } }); }; let violationsHtml = []; let incompleteHtml = []; results.violations.forEach((v) => { const matchedNode = isExactTarget(v.nodes); if (matchedNode) { lastAxeResults.violations.push(v); violationsHtml.push( `<div><b>[${v.id}]</b>: ${matchedNode.failureSummary.replace(/\n/g, '<br>')}</div>`, ); } }); results.incomplete.forEach((inc) => { const matchedNode = isExactTarget(inc.nodes); if (matchedNode) { lastAxeResults.incomplete.push(inc); incompleteHtml.push( `<div><b>[${inc.id}]</b>: ${matchedNode.failureSummary.replace(/\n/g, '<br>')}</div>`, ); } }); const axeData = { violationsHtml, incompleteHtml, isPass: violationsHtml.length === 0 && incompleteHtml.length === 0, }; updateAxeHUD(axeData); } catch (e) { console.debug(e); } finally { isAxeRunning = false; if (pendingAxeElement) { const nextEl = pendingAxeElement; pendingAxeElement = null; if (currentTarget === nextEl && document.body.contains(nextEl)) { setTimeout(() => runAxeCheck(nextEl), 50); } } } } function updateAxeHUD({ violationsHtml, incompleteHtml, isPass }) { if (!isTopWindow) return; const container = document.getElementById('axe-results-container'); if (container) { container.innerHTML = ''; if (violationsHtml.length > 0) { hole.className = 'status-warn'; container.innerHTML += `<div class="axe-violation">${violationsHtml.join('<hr style="border-color:rgba(239,68,68,0.2); margin:6px 0;">')}</div>`; } if (incompleteHtml.length > 0) { if (violationsHtml.length === 0) hole.className = 'status-nofocus'; container.innerHTML += `<div class="axe-incomplete"><b>🟡 需人工复核:</b><br>${incompleteHtml.join('<hr style="border-color:rgba(245,158,11,0.2); margin:6px 0;">')}</div>`; } if (isPass) { hole.className = 'status-good'; container.innerHTML += `<div class="axe-pass">✅ 该元素通过了所有 Axe-core 自动化规则校验。</div>`; } } } let drawRaf = null; function handleElementChange(el) { if (!isActive || !el || el === currentTarget || isLocked) return; if ( el.closest(`#${HUD_ID}`) || el.closest(`#${TOGGLE_ID}`) || el.closest('.a11y-ignore-inspect') ) return; currentTarget = el; lastAxeResults = null; if (drawRaf) cancelAnimationFrame(drawRaf); drawRaf = requestAnimationFrame(() => { drawSpotlight(); drawAriaLines(currentTarget); drawRaf = null; }); if (axeTimer) clearTimeout(axeTimer); axeTimer = setTimeout(() => { runAxeCheck(el); }, 350); } function getRealTarget(e) { return e.composedPath?.()[0] ?? e.target; } let hoverTimer = null; function handlePointerOver(e) { if (hoverTimer) clearTimeout(hoverTimer); hoverTimer = setTimeout(() => { handleElementChange(getRealTarget(e)); }, 30); } function handleFocusIn(e) { handleElementChange(getRealTarget(e)); } let resizeTimer = null; let scrollRaf = null; function handleScroll(e) { if (e && e.target && typeof e.target.closest === 'function' && e.target.closest(`#${HUD_ID}`)) return; if (!scrollRaf) { scrollRaf = requestAnimationFrame(() => { if (isActive && currentTarget) drawSpotlight(); updateCustomLines(); updateFocusBadges(); updateLandmarks(); updateHudPosition(); if (currentTrapState) drawTrapVisuals(currentTrapState); scrollRaf = null; }); } if (e && e.type === 'resize') { if (isFocusPathActive) { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { window.__toggleFocusPath(); window.__toggleFocusPath(); }, 300); } else { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { updateLandmarks(true); }, 300); } } } function handleClick(e) { if (!isActive) return; if ( e.target.closest(`#${HUD_ID}`) || e.target.closest(`#${TOGGLE_ID}`) || e.target.closest('.a11y-ignore-inspect') ) return; if (e.ctrlKey) { e.preventDefault(); e.stopPropagation(); handleElementChange(getRealTarget(e)); setTimeout(() => window.__logA11yNode(), 300); } else if (!isLocked && currentTarget === getRealTarget(e)) { setTimeout(() => drawSpotlight(), 50); } } function handleKeyDown(e) { if (e.key === 'Control' && isActive) hud.style.display = 'none'; } function handleKeyUp(e) { if (e.key === 'Control' && isActive) hud.style.display = 'flex'; if (isActive && !isLocked && currentTarget === getRealTarget(e)) { setTimeout(() => drawSpotlight(), 50); } } function handleInput(e) { if (isActive && !isLocked && currentTarget === getRealTarget(e)) { drawSpotlight(); } } function getViewportRect(el) { return el.getBoundingClientRect(); } function isElementInViewport(rect, margin = 0) { return ( rect.bottom > -margin && rect.top < window.innerHeight + margin && rect.right > -margin && rect.left < window.innerWidth + margin ); } let boundDocs = new Set(); function bindDocEvents(doc) { if (boundDocs.has(doc)) return; doc.addEventListener('mouseover', handlePointerOver, true); doc.addEventListener('focusin', handleFocusIn, true); doc.addEventListener('click', handleClick, true); doc.addEventListener('scroll', handleScroll, { capture: true, passive: true }); doc.addEventListener('keydown', handleKeyDown, true); doc.addEventListener('keyup', handleKeyUp, true); doc.addEventListener('input', handleInput, true); boundDocs.add(doc); } function unbindDocEvents(doc) { if (!boundDocs.has(doc)) return; doc.removeEventListener('mouseover', handlePointerOver, true); doc.removeEventListener('focusin', handleFocusIn, true); doc.removeEventListener('click', handleClick, true); doc.removeEventListener('scroll', handleScroll, { capture: true, passive: true }); doc.removeEventListener('keydown', handleKeyDown, true); doc.removeEventListener('keyup', handleKeyUp, true); doc.removeEventListener('input', handleInput, true); boundDocs.delete(doc); } function toggleEventBind(bind) { if (bind) { bindDocEvents(document); window.addEventListener('resize', handleScroll); } else { boundDocs.forEach((doc) => unbindDocEvents(doc)); boundDocs.clear(); window.removeEventListener('resize', handleScroll); if (axeTimer) clearTimeout(axeTimer); } } hotkeys('alt+shift+a', (e) => { e.preventDefault(); if (isTopWindow) toggleBtn.click(); }); hotkeys('f', 'a11y', (e) => { e.preventDefault(); if (isActive) window.__toggleFocusPath(); }); hotkeys('p', 'a11y', (e) => { e.preventDefault(); if (isActive) window.__logA11yNode(); }); hotkeys('l', 'a11y', (e) => { e.preventDefault(); if (isActive) { if (currentTarget) { isLocked = !isLocked; drawSpotlight(); } } }); hotkeys('w', 'a11y', (e) => { e.preventDefault(); if (isActive) { isSpotlightEnabled = !isSpotlightEnabled; drawSpotlight(); } }); hotkeys('esc', 'a11y', (e) => { e.preventDefault(); if (isActive && isTopWindow) toggleBtn.click(); }); let isDragging = false; if (isTopWindow) { toggleBtn.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; toggleBtn.setPointerCapture(e.pointerId); let rect = toggleBtn.getBoundingClientRect(); let offsetX = e.clientX - rect.left; let offsetY = e.clientY - rect.top; let startX = e.clientX; let startY = e.clientY; function onPointerMove(moveEvent) { if (Math.abs(moveEvent.clientX - startX) > 3 || Math.abs(moveEvent.clientY - startY) > 3) { isDragging = true; } let newX = moveEvent.clientX - offsetX; let newY = moveEvent.clientY - offsetY; newX = Math.max(0, Math.min(newX, window.innerWidth - rect.width)); newY = Math.max(0, Math.min(newY, window.innerHeight - rect.height)); toggleBtn.style.left = newX + 'px'; toggleBtn.style.top = newY + 'px'; toggleBtn.style.bottom = 'auto'; toggleBtn.style.right = 'auto'; toggleBtn.style.transform = 'none'; toggleBtn.style.transition = 'none'; updateHudPosition(); } function onPointerUp(upEvent) { toggleBtn.releasePointerCapture(upEvent.pointerId); toggleBtn.removeEventListener('pointermove', onPointerMove); toggleBtn.removeEventListener('pointerup', onPointerUp); toggleBtn.style.transition = 'all 0.3s ease'; setTimeout(() => { isDragging = false; }, 50); } toggleBtn.addEventListener('pointermove', onPointerMove); toggleBtn.addEventListener('pointerup', onPointerUp); }); toggleBtn.addEventListener('click', async (e) => { if (isDragging) { e.preventDefault(); e.stopPropagation(); return; } isActive = !isActive; if (isActive) { if (virtual) { virtualFocusinHandlers = []; const origAdd = document.body.addEventListener; document.body.addEventListener = function (type, listener) { if (type === 'focusin') virtualFocusinHandlers.push(listener); return origAdd.apply(this, arguments); }; await virtual.start({ container: document.body }); document.body.addEventListener = origAdd; } overlay.classList.add('active'); hud.style.display = 'flex'; toggleBtn.classList.add('active'); toggleBtn.innerText = '关闭'; toggleBtn.setAttribute('aria-pressed', 'true'); setupLiveObserver(true); updateHudPosition(); handleElementChange(document.activeElement || document.body); updateLandmarks(true); toggleEventBind(true); hotkeys.setScope('a11y'); } else { overlay.classList.remove('active'); hud.style.display = 'none'; toggleBtn.classList.remove('active'); toggleBtn.innerText = 'A11y'; toggleBtn.setAttribute('aria-pressed', 'false'); toggleEventBind(false); setupLiveObserver(false); if (virtual) { await virtual.stop(); virtualFocusinHandlers = []; } currentTarget = null; isLocked = false; clearAriaVisuals(); updateLandmarks(); if (typeof trapVisualContainer !== 'undefined' && trapVisualContainer) { trapVisualContainer.innerHTML = ''; } currentTrapState = null; if (isFocusPathActive) window.__toggleFocusPath(); hotkeys.setScope('all'); } }); } })();