diff --git a/Apps/Web/aiplugin/content.js b/Apps/Web/aiplugin/content.js
index ab6d4519..004c445c 100644
--- a/Apps/Web/aiplugin/content.js
+++ b/Apps/Web/aiplugin/content.js
@@ -99,6 +99,35 @@ function injectInspectorUI() {
opacity: 1;
}
+ /* Hint tooltip: explains how to start/stop inspecting */
+ .hint {
+ position: absolute;
+ padding: 7px 11px;
+ background: #1f2937;
+ color: #f9fafb;
+ font-size: 12px;
+ line-height: 1.35;
+ font-weight: 500;
+ border-radius: 8px;
+ white-space: nowrap;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
+ pointer-events: none;
+ opacity: 0;
+ transform: translateY(4px);
+ transition: opacity 0.18s ease, transform 0.18s ease;
+ }
+ .hint.show {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ .hint b {
+ color: #c4b5fd;
+ font-weight: 700;
+ }
+ .hint.active b {
+ color: #fca5a5;
+ }
+
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(220, 38, 38, 0); }
@@ -122,17 +151,57 @@ function injectInspectorUI() {
// close btn
const closeBtn = document.createElement('div');
closeBtn.className = 'close-btn';
- closeBtn.innerHTML = '✕';
+ closeBtn.innerHTML = '\u2715';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
host.remove(); // remove the whole UI
});
+ // hint tooltip
+ const hint = document.createElement('div');
+ hint.className = 'hint';
+
container.appendChild(btn);
container.appendChild(closeBtn);
+ container.appendChild(hint);
shadow.appendChild(container);
+ // hint: keeps the click / right-click affordance discoverable
+ let hintTimer = null;
+
+ const placeHint = () => {
+ const rect = host.getBoundingClientRect();
+ const onRightHalf = rect.left + rect.width / 2 > window.innerWidth / 2;
+ hint.style.left = onRightHalf ? 'auto' : '0';
+ hint.style.right = onRightHalf ? '0' : 'auto';
+
+ const nearTop = rect.top < 70;
+ hint.style.top = nearTop ? 'calc(100% + 10px)' : 'auto';
+ hint.style.bottom = nearTop ? 'auto' : 'calc(100% + 10px)';
+ };
+
+ const showHint = (autoHideMs) => {
+ const active = btn.classList.contains('active');
+ hint.classList.toggle('active', active);
+ hint.innerHTML = active
+ ? 'Inspecting \u2014 right-click the icon to stop'
+ : 'Click the icon to start inspecting';
+ placeHint();
+ hint.classList.add('show');
+
+ clearTimeout(hintTimer);
+ hintTimer = autoHideMs ? setTimeout(() => hint.classList.remove('show'), autoHideMs) : null;
+ };
+
+ const hideHint = () => {
+ clearTimeout(hintTimer);
+ hint.classList.remove('show');
+ };
+
+ container.addEventListener('mouseenter', () => showHint());
+ container.addEventListener('mouseleave', hideHint);
+
// drag
let isDragging = false;
let hasMoved = false;
@@ -206,7 +275,17 @@ function injectInspectorUI() {
} else if (request.action === 'deactivate') {
btn.classList.remove('active');
btnImg.src = chrome.runtime.getURL('zeuz.png');
+ } else {
+ return;
}
+ // announce the new state, so the way in/out is never a guess
+ chrome.storage.local.set({ zeuzInspectorUsed: true });
+ showHint(4000);
+ });
+
+ // first-run nudge, until the user has toggled the inspector at least once
+ chrome.storage.local.get({ zeuzInspectorUsed: false }, (result) => {
+ if (!result.zeuzInspectorUsed) showHint(5000);
});
}
diff --git a/Apps/Web/aiplugin/inspect.js b/Apps/Web/aiplugin/inspect.js
index 981407f8..5e4315a2 100644
--- a/Apps/Web/aiplugin/inspect.js
+++ b/Apps/Web/aiplugin/inspect.js
@@ -49,6 +49,15 @@ setInterval(() => {
}, 5000);
+// Attribute panel limits: pages can carry huge attribute payloads (inline styles,
+// data-* blobs), showing all of it makes the panel cover the page.
+const MAX_ATTR_VALUE_CHARS = 60;
+const MAX_ATTR_TOTAL_CHARS = 200;
+
+// How long the pointer must stay inside an iframe before we spotlight it.
+const IFRAME_DWELL_MS = 1500;
+const FRAME_MESSAGE_TAG = 'zeuz-iframe-spotlight';
+
class Inspector {
constructor() {
this.win = window;
@@ -57,12 +66,23 @@ class Inspector {
this.draw = this.draw.bind(this);
this.getData = this.getData.bind(this);
this.setOptions = this.setOptions.bind(this);
+ this.onFrameMessage = this.onFrameMessage.bind(this);
+ this.startFrameDwell = this.startFrameDwell.bind(this);
+ this.endFrameDwell = this.endFrameDwell.bind(this);
+ this.onFrameMouseOut = this.onFrameMouseOut.bind(this);
+ this.syncSpotlight = this.syncSpotlight.bind(this);
+ this.trackPointer = this.trackPointer.bind(this);
this.cssNode = 'xpath-css';
this.overlayElement = 'xpath-overlay';
this.modalNode = 'zeuzMyModal';
this.elementNode = 'zeuzMyElement';
+ this.spotlightNode = 'zeuz-iframe-spotlight-host';
+ this.isTopFrame = window.top === window.self;
+ this.dwellTimer = null;
+ this.dwellActive = false;
+ this.spotlightHost = null;
}
@@ -294,7 +314,6 @@ class Inspector {
}
getOptions() {
- console.log(navigator.userAgentData.platform);
const storage = browserAppData.storage && (browserAppData.storage.local);
const promise = storage.get({
inspector: true,
@@ -355,9 +374,10 @@ class Inspector {
host.id = 'zeuz-attributes-host';
Object.assign(host.style, {
position: 'fixed',
- top: '10px',
- left: '10px',
- zIndex: '2147483647',
+ top: '12px',
+ left: '12px',
+ // just under the floating button, so the panel can never bury it
+ zIndex: '2147483640',
pointerEvents: 'none'
});
document.body.appendChild(host);
@@ -366,26 +386,72 @@ class Inspector {
const style = document.createElement('style');
style.textContent = `
.attributes-container {
- background: rgba(0, 0, 0, 0.8);
+ background: rgba(0, 0, 0, 0.82);
color: white;
padding: 6px 10px;
- border-radius: 4px;
+ border-radius: 6px;
font-family: monospace;
font-size: 11px;
- max-width: 300px;
+ line-height: 1.45;
+ max-width: 320px;
word-break: break-all;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
+ .tag {
+ color: #c4b5fd;
+ font-weight: bold;
+ }
+ .attrs:empty, .more:empty {
+ display: none;
+ }
+ .more {
+ color: rgba(255, 255, 255, 0.55);
+ font-style: italic;
+ }
`;
shadow.appendChild(style);
const container = document.createElement('div');
container.className = 'attributes-container';
+
+ const tag = document.createElement('div');
+ tag.className = 'tag';
+ const attrs = document.createElement('div');
+ attrs.className = 'attrs';
+ const more = document.createElement('div');
+ more.className = 'more';
+ container.append(tag, attrs, more);
shadow.appendChild(container);
this.attributesHost = host;
- this.attributesContainer = container;
+ this.attributesTag = tag;
+ this.attributesText = attrs;
+ this.attributesMore = more;
+ }
+
+ renderAttributes(node) {
+ const names = node.getAttributeNames();
+ const parts = [];
+ let total = 0;
+
+ for (const name of names) {
+ if (total >= MAX_ATTR_TOTAL_CHARS) break;
+ let value = node.getAttribute(name) || '';
+ if (value.length > MAX_ATTR_VALUE_CHARS) {
+ // escaped, not literal: keeps this file pure ASCII so a page
+ // charset other than UTF-8 cannot mangle the glyph
+ value = value.slice(0, MAX_ATTR_VALUE_CHARS) + '\u2026';
+ }
+ const part = `${name}="${value}"`;
+ parts.push(part);
+ total += part.length;
+ }
+
+ const hidden = names.length - parts.length;
+ this.attributesTag.textContent = `<${node.localName}>`;
+ this.attributesText.textContent = parts.join(' ');
+ this.attributesMore.textContent = hidden > 0 ? `+${hidden} more attribute${hidden > 1 ? 's' : ''}` : '';
}
createSuccessMessage() {
@@ -433,17 +499,33 @@ class Inspector {
this.successContainer = container;
}
- updateAttributePosition(mouseY) {
- if (this.attributesHost) {
- const isTopHalf = mouseY < window.innerHeight / 2;
- if (isTopHalf) {
- this.attributesHost.style.top = 'auto';
- this.attributesHost.style.bottom = '10px';
- } else {
- this.attributesHost.style.top = '10px';
- this.attributesHost.style.bottom = 'auto';
- }
- }
+ // mouseover alone is not enough: moving across one large element never
+ // re-fires it, so the panel would stay in a stale corner.
+ trackPointer(e) {
+ if (this.positionFrame) return;
+ const x = e.clientX;
+ const y = e.clientY;
+ this.positionFrame = requestAnimationFrame(() => {
+ this.positionFrame = null;
+ this.updateAttributePosition(x, y);
+ });
+ }
+
+ // Park the panel in whichever of the 4 corners is opposite the cursor,
+ // so it never sits under the element being inspected.
+ updateAttributePosition(mouseX, mouseY) {
+ if (!this.attributesHost) return;
+
+ const margin = '12px';
+ const toRight = mouseX < window.innerWidth / 2;
+ const toBottom = mouseY < window.innerHeight / 2;
+
+ Object.assign(this.attributesHost.style, {
+ left: toRight ? 'auto' : margin,
+ right: toRight ? margin : 'auto',
+ top: toBottom ? 'auto' : margin,
+ bottom: toBottom ? margin : 'auto'
+ });
}
copyText(XPath) {
@@ -485,15 +567,13 @@ class Inspector {
}
// position based on mouse location
- this.updateAttributePosition(e.clientY);
+ this.updateAttributePosition(e.clientX, e.clientY);
+ this.renderAttributes(node);
- let elementText = "";
- for (let name of e.target.getAttributeNames()) {
- let value = e.target.getAttribute(name);
- elementText += `${name}="${value}" `;
+ // pointer is back on the host page, so any iframe spotlight is stale
+ if (this.spotlightHost && node !== this.spotlightFrame) {
+ this.hideSpotlight();
}
-
- this.attributesContainer.textContent = elementText.trim();
}
activate() {
@@ -508,11 +588,21 @@ class Inspector {
// add listeners
document.addEventListener('click', this.getData, true);
this.options.inspector && (document.addEventListener('mouseover', this.draw));
+ this.options.inspector && (document.addEventListener('mousemove', this.trackPointer, { passive: true }));
+
+ // iframe spotlight: children report dwell, every frame relays it upwards
+ window.addEventListener('message', this.onFrameMessage);
+ if (!this.isTopFrame) {
+ document.addEventListener('mouseover', this.startFrameDwell);
+ document.addEventListener('mouseout', this.onFrameMouseOut);
+ }
}
deactivate() {
// remove overlay
this.removeOverlay();
+ this.endFrameDwell();
+ this.hideSpotlight();
let Remove = [
this.cssNode,
@@ -529,14 +619,197 @@ class Inspector {
// remove listeners
document.removeEventListener('click', this.getData, true);
this.options && this.options.inspector && (document.removeEventListener('mouseover', this.draw));
+ document.removeEventListener('mousemove', this.trackPointer);
+ cancelAnimationFrame(this.positionFrame);
+ this.positionFrame = null;
+ window.removeEventListener('message', this.onFrameMessage);
+ document.removeEventListener('mouseover', this.startFrameDwell);
+ document.removeEventListener('mouseout', this.onFrameMouseOut);
// reset
this.attributesHost = null;
- this.attributesContainer = null;
+ this.attributesTag = null;
+ this.attributesText = null;
+ this.attributesMore = null;
this.successHost = null;
this.successContainer = null;
}
+ /* ---------------- iframe spotlight ---------------- */
+
+ // Inside a frame: once the pointer has stayed here long enough, ask the top
+ // frame to dim everything but us.
+ startFrameDwell() {
+ if (this.dwellTimer || this.dwellActive) return;
+ this.dwellTimer = setTimeout(() => {
+ this.dwellTimer = null;
+ this.dwellActive = true;
+ window.parent.postMessage({
+ tag: FRAME_MESSAGE_TAG,
+ type: 'focus',
+ rect: null,
+ label: (document.title || location.hostname || '').slice(0, 60)
+ }, '*');
+ }, IFRAME_DWELL_MS);
+ }
+
+ // relatedTarget is null only when the pointer crosses out of this document
+ onFrameMouseOut(e) {
+ if (!e.relatedTarget) this.endFrameDwell();
+ }
+
+ endFrameDwell() {
+ clearTimeout(this.dwellTimer);
+ this.dwellTimer = null;
+ if (!this.dwellActive) return;
+ this.dwellActive = false;
+ window.parent.postMessage({ tag: FRAME_MESSAGE_TAG, type: 'blur' }, '*');
+ }
+
+ onFrameMessage(e) {
+ const data = e.data;
+ if (!data || data.tag !== FRAME_MESSAGE_TAG) return;
+
+ if (data.type === 'blur') {
+ if (this.attributesHost) this.attributesHost.style.visibility = 'visible';
+ this.isTopFrame ? this.hideSpotlight() : window.parent.postMessage(data, '*');
+ return;
+ }
+
+ const frame = this.findFrameElement(e.source);
+ if (!frame) return;
+
+ // The pointer is inside the frame now and the frame highlights its own
+ // element. Our highlight is still sitting on the