diff --git a/.gitignore b/.gitignore index 0aca9b1..34b9434 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,14 @@ temp/ # Coverage directory used by tools like istanbul coverage/ +# Playwright / local e2e (dev-only, not for upstream PR) +test-results/ +playwright-report/ +playwright.config.js +jest.config.js +tests/e2e/ +demo/deep-select-test.html + # Optional npm cache directory .npm @@ -84,3 +92,9 @@ demo/images/ # Standalone UI style guide drawbridge-ui/ + +# Windsurf configuration +.windsurf/ + +# Codex CLI configuration +.codex/ diff --git a/chrome-extension/background.js b/chrome-extension/background.js index b21703f..9aabdd9 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -1,5 +1,7 @@ // chrome-extension/background.js +importScripts('utils/buildInfo.js'); + const RESTRICTED_URL_PREFIXES = [ 'chrome://', 'chrome-extension://', @@ -101,8 +103,19 @@ async function injectMoatScript(tabId) { }); } +function getExpectedContentScriptBuild() { + const buildInfo = globalThis.DrawbridgeBuildInfo; + return buildInfo.getContentScriptBuild(chrome.runtime.getManifest().version); +} + function needsManualInjection(pingResponse) { - return !pingResponse?.ready || pingResponse.moatLoaded === false; + if (!pingResponse?.ready || pingResponse.moatLoaded === false) { + return true; + } + if (pingResponse.contentScriptBuild !== getExpectedContentScriptBuild()) { + return true; + } + return false; } async function ensureContentScriptsReady(tabId) { @@ -113,7 +126,13 @@ async function ensureContentScriptsReady(tabId) { return; } - await injectMoatScript(tabId); + console.warn( + 'Drawbridge: Injecting content scripts (missing, outdated moat, or stale build:', + pingResponse?.contentScriptBuild, + 'expected:', + getExpectedContentScriptBuild() + ')' + ); + await injectManifestContentScripts(tabId); } catch (error) { console.warn('Drawbridge: Content script not ready, injecting into active tab:', error.message); await injectManifestContentScripts(tabId); @@ -121,7 +140,9 @@ async function ensureContentScriptsReady(tabId) { const pingResponse = await sendTabMessage(tabId, { action: 'ping' }); if (needsManualInjection(pingResponse)) { - throw new Error('Content script did not report ready after injection'); + throw new Error( + 'Content script did not report ready after injection. Refresh the page (Ctrl+Shift+R).' + ); } } @@ -167,6 +188,7 @@ if (typeof module !== 'undefined' && module.exports) { RESTRICTED_URL_PREFIXES, isRestrictedTab, needsManualInjection, + getExpectedContentScriptBuild, handleActionClick, ensureContentScriptsReady, injectManifestContentScripts, diff --git a/chrome-extension/background.test.js b/chrome-extension/background.test.js index 2066bde..1f2c514 100644 --- a/chrome-extension/background.test.js +++ b/chrome-extension/background.test.js @@ -1,8 +1,25 @@ const manifest = require('./manifest.json'); +const { getContentScriptBuild } = require('./utils/buildInfo.js'); + +function getReadyPing(overrides = {}) { + return { + success: true, + ready: true, + moatLoaded: true, + contentScriptBuild: getContentScriptBuild(manifest.version), + ...overrides + }; +} function loadBackground({ sendMessage, insertCSS, executeScript } = {}) { jest.resetModules(); + global.self = global; + global.importScripts = jest.fn(() => { + require('./utils/buildInfo.js'); + }); + require('./utils/buildInfo.js'); + global.chrome = { action: { onClicked: { @@ -17,7 +34,7 @@ function loadBackground({ sendMessage, insertCSS, executeScript } = {}) { } }, tabs: { - sendMessage: sendMessage || jest.fn((tabId, message, callback) => callback({ success: true, ready: true, moatLoaded: true })), + sendMessage: sendMessage || jest.fn((tabId, message, callback) => callback(getReadyPing())), captureVisibleTab: jest.fn() }, scripting: { @@ -53,7 +70,7 @@ describe('background action click handling', () => { test('toggles immediately when the content script is already ready', async () => { const sendMessage = jest.fn((tabId, message, callback) => { if (message.action === 'ping') { - callback({ success: true, ready: true, moatLoaded: true }); + callback(getReadyPing()); return; } @@ -79,7 +96,7 @@ describe('background action click handling', () => { return; } - callback({ success: true, ready: true, moatLoaded: true }); + callback({ ...getReadyPing(), moatLoaded: true }); return; } @@ -100,14 +117,13 @@ describe('background action click handling', () => { expect(sendMessage.mock.calls.map(call => call[1].action)).toEqual(['ping', 'ping', 'toggleMoat']); }); - test('injects only moat when the content script is ready but moat has not loaded yet', async () => { + test('injects full manifest when moat has not loaded yet', async () => { let pingCount = 0; const sendMessage = jest.fn((tabId, message, callback) => { if (message.action === 'ping') { pingCount += 1; callback({ - success: true, - ready: true, + ...getReadyPing(), moatLoaded: pingCount > 1 }); return; @@ -125,7 +141,34 @@ describe('background action click handling', () => { }, expect.any(Function)); expect(chrome.scripting.executeScript).toHaveBeenCalledWith({ target: { tabId: 7 }, - files: ['moat.js'] + files: manifest.content_scripts[0].js + }, expect.any(Function)); + expect(sendMessage.mock.calls.map(call => call[1].action)).toEqual(['ping', 'ping', 'toggleMoat']); + }); + + test('injects full manifest when content script build is stale', async () => { + let pingCount = 0; + const sendMessage = jest.fn((tabId, message, callback) => { + if (message.action === 'ping') { + pingCount += 1; + callback({ + success: true, + ready: true, + moatLoaded: true, + contentScriptBuild: pingCount > 1 ? getContentScriptBuild(manifest.version) : '1.0.0-outdated' + }); + return; + } + + callback({ success: true }); + }); + const { handleActionClick } = loadBackground({ sendMessage }); + + await handleActionClick({ id: 7, url: 'https://example.com/' }); + + expect(chrome.scripting.executeScript).toHaveBeenCalledWith({ + target: { tabId: 7 }, + files: manifest.content_scripts[0].js }, expect.any(Function)); expect(sendMessage.mock.calls.map(call => call[1].action)).toEqual(['ping', 'ping', 'toggleMoat']); }); diff --git a/chrome-extension/content_script.js b/chrome-extension/content_script.js index a2a73ed..74bc0e2 100644 --- a/chrome-extension/content_script.js +++ b/chrome-extension/content_script.js @@ -1,14 +1,56 @@ // Moat Chrome Extension - Content Script (function() { + const contentScriptBuild = DrawbridgeBuildInfo.getContentScriptBuild( + chrome.runtime.getManifest().version + ); + + if (window.__drawbridgeContentScriptBuild === contentScriptBuild) { + return; + } + if (window.__drawbridgeContentScriptLoaded) { + // An older build is already live in this page. Its event handlers cannot be + // swapped out in place, so reload once to load the fresh code instead of + // silently running stale handlers. The per-build sessionStorage flag makes + // this a single reload and guards against any reload loop. + const RELOAD_FLAG = '__drawbridgeReloadedForBuild'; + try { + if (sessionStorage.getItem(RELOAD_FLAG) !== contentScriptBuild) { + sessionStorage.setItem(RELOAD_FLAG, contentScriptBuild); + console.warn( + '[Drawbridge] New build (%s) detected; reloading page to apply it.', + contentScriptBuild + ); + location.reload(); + return; + } + } catch (e) { + // sessionStorage may be unavailable (sandboxed frames); fall through. + } + + window.__drawbridgeNeedsRefresh = true; + console.warn( + '[Drawbridge] Extension updated (%s). Refresh this page (Ctrl+Shift+R) to enable deep select.', + contentScriptBuild + ); return; } + window.__drawbridgeContentScriptLoaded = true; + window.__drawbridgeContentScriptBuild = contentScriptBuild; let commentMode = false; let hoveredElement = null; let commentBox = null; let highlightedElement = null; + let deepSelectIndex = 0; + let pickStack = []; + let lastPointer = { x: 0, y: 0 }; + let drilledOnPointerdown = false; + let deepSelectBadge = null; + // Track Control via keydown — macOS Ctrl+Click becomes right-click and drops ctrlKey. + let controlKeyHeld = false; + let lastTopPickElement = null; let projectRoot = null; let markdownFileHandle = null; // Handle for moat-tasks.md @@ -3383,6 +3425,10 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are // Enter comment mode function enterCommentMode() { + if (window.__drawbridgeNeedsRefresh) { + showNotification('Refresh page (Ctrl+Shift+R) for latest Drawbridge', 'error'); + return; + } commentMode = true; document.body.classList.add('float-comment-mode'); } @@ -3391,6 +3437,10 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are function exitCommentMode() { commentMode = false; document.body.classList.remove('float-comment-mode'); + deepSelectIndex = 0; + pickStack = []; + lastTopPickElement = null; + removeDeepSelectBadge(); // Remove DOM overlay from the element if (commentBox && commentBox.highlightOverlay) { @@ -3713,24 +3763,175 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are // Global hover overlay element let hoverOverlay = null; + function getElementPicker() { + return window.MoatElementPicker; + } + + function setControlKeyHeld(held) { + controlKeyHeld = !!held; + } + + function isDeepSelectModifier(event) { + const pickerApi = getElementPicker(); + if (pickerApi && typeof pickerApi.isDeepSelectControlActive === 'function') { + return pickerApi.isDeepSelectControlActive(event, controlKeyHeld); + } + // Fallback if picker script failed to inject + return !!( + controlKeyHeld || + (event && + (event.ctrlKey || + (event.getModifierState && event.getModifierState('Control')))) + ); + } + + // Capture Control held-state before OS remaps Ctrl+Click on macOS/Linux. + document.addEventListener( + 'keydown', + (e) => { + const pickerApi = getElementPicker(); + if (pickerApi && typeof pickerApi.isControlKeyEvent === 'function') { + if (pickerApi.isControlKeyEvent(e)) { + setControlKeyHeld(true); + } + return; + } + if (e.key === 'Control' || e.code === 'ControlLeft' || e.code === 'ControlRight') { + setControlKeyHeld(true); + } + }, + true + ); + + document.addEventListener( + 'keyup', + (e) => { + const pickerApi = getElementPicker(); + if (pickerApi && typeof pickerApi.isControlKeyEvent === 'function') { + if (pickerApi.isControlKeyEvent(e)) { + setControlKeyHeld(false); + } + return; + } + if (e.key === 'Control' || e.code === 'ControlLeft' || e.code === 'ControlRight') { + setControlKeyHeld(false); + } + }, + true + ); + + window.addEventListener('blur', () => setControlKeyHeld(false)); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') { + setControlKeyHeld(false); + } + }); + + function removeDeepSelectBadge() { + if (deepSelectBadge) { + deepSelectBadge.remove(); + deepSelectBadge = null; + } + document.body.classList.remove('float-deep-select-active'); + } + + function updateDeepSelectBadge(depth, total, x, y, show) { + removeDeepSelectBadge(); + if (!show || total <= 1) { + return; + } + + deepSelectBadge = document.createElement('div'); + deepSelectBadge.className = 'float-deep-select-badge'; + deepSelectBadge.textContent = `Layer ${depth}/${total}`; + deepSelectBadge.style.left = `${Math.min(x + 12, window.innerWidth - 120)}px`; + deepSelectBadge.style.top = `${Math.min(y + 12, window.innerHeight - 32)}px`; + document.body.appendChild(deepSelectBadge); + document.body.classList.add('float-deep-select-active'); + } + + function refreshPickAtPointer(clientX, clientY) { + const pickerApi = getElementPicker(); + if (!pickerApi) { + return null; + } + + lastPointer = { x: clientX, y: clientY }; + + const pick = pickerApi.pickElementAtPoint(clientX, clientY, deepSelectIndex); + pickStack = pick.stack; + + if (!pick.element) { + hoveredElement = null; + removeHighlight(); + removeDeepSelectBadge(); + return null; + } + + hoveredElement = pick.element; + const isDeep = deepSelectIndex > 0 && pick.total > 1; + highlightElement(pick.element, isDeep); + updateDeepSelectBadge(pick.depth, pick.total, clientX, clientY, isDeep); + return pick.element; + } + + function drillDeeperAtPointer() { + const pickerApi = getElementPicker(); + if (!pickerApi) { + console.warn('[Drawbridge] Deep select unavailable — MoatElementPicker missing. Refresh the page.'); + showNotification('Refresh page for deep select', 'error'); + return null; + } + + if (pickStack.length <= 1) { + showNotification('Only one layer under cursor', 'info'); + console.info('[Drawbridge] Deep select: single layer at pointer'); + return null; + } + + deepSelectIndex = pickerApi.cycleDepthIndex(deepSelectIndex, pickStack.length, 1); + const element = refreshPickAtPointer(lastPointer.x, lastPointer.y); + const pick = pickerApi.pickElementAtPoint(lastPointer.x, lastPointer.y, deepSelectIndex); + + console.info( + '[Drawbridge] Deep select drill → layer', + pick.depth + '/' + pick.total, + pick.element?.tagName?.toLowerCase(), + pick.element?.className || pick.element?.id || '' + ); + showNotification(`Layer ${pick.depth}/${pick.total} — click to comment`, 'info'); + + return element; + } + + // Trackpads often skip pointerdown with ctrlKey; click may be the only signal. + // pointerdown+click from a mouse should drill only once per gesture. + function handleDeepSelectDrill() { + return drillDeeperAtPointer(); + } + // Highlight element on hover with DOM overlay - function highlightElement(element) { + function highlightElement(element, isDeepSelect = false) { removeHighlight(); highlightedElement = element; // Create overlay div for both hover and click const rect = element.getBoundingClientRect(); hoverOverlay = document.createElement('div'); - hoverOverlay.className = 'float-hover-overlay'; + hoverOverlay.className = isDeepSelect + ? 'float-hover-overlay float-hover-overlay-deep' + : 'float-hover-overlay'; + const borderColor = isDeepSelect ? '#F59E0B' : '#3B82F6'; + const bgColor = isDeepSelect ? 'rgba(245, 158, 11, 0.12)' : 'rgba(59, 130, 246, 0.1)'; hoverOverlay.style.cssText = ` position: absolute; left: ${rect.x + window.scrollX}px; top: ${rect.y + window.scrollY}px; width: ${rect.width}px; height: ${rect.height}px; - border: 3px solid #3B82F6; + border: 3px solid ${borderColor}; border-radius: 4px; - background-color: rgba(59, 130, 246, 0.1); + background-color: ${bgColor}; pointer-events: none; z-index: 9998; box-sizing: border-box; @@ -3773,14 +3974,7 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are // Mouse move handler document.addEventListener('mousemove', (e) => { if (!commentMode || commentBox) return; // Don't highlight if comment box is open - - const element = document.elementFromPoint(e.clientX, e.clientY); - - // Skip if element is the shadow host (moat UI container) - if (element && element.id === 'moat-shadow-host') { - return; - } - + // Use composedPath() to properly detect if hovering over Shadow DOM elements const path = e.composedPath(); const hoveringOverMoatUI = path.some(el => @@ -3790,10 +3984,18 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are ) ); - if (element && element !== hoveredElement && !hoveringOverMoatUI) { - hoveredElement = element; - highlightElement(element); + if (hoveringOverMoatUI) return; + + const pickerApi = getElementPicker(); + if (pickerApi) { + const topPick = pickerApi.pickElementAtPoint(e.clientX, e.clientY, 0); + if (topPick.element !== lastTopPickElement) { + deepSelectIndex = 0; + lastTopPickElement = topPick.element; + } } + + refreshPickAtPointer(e.clientX, e.clientY); }); // Drawing mode mouse handlers @@ -3995,30 +4197,97 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are return false; } - // Click handler + // Click handler — plain click opens comment; Ctrl+Click drills (trackpad fallback) document.addEventListener('click', (e) => { if (!commentMode) return; if (isEventInMoatUI(e)) return; + if (isDeepSelectModifier(e)) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + if (drilledOnPointerdown) { + drilledOnPointerdown = false; + return; + } + handleDeepSelectDrill(); + return; + } + e.preventDefault(); e.stopPropagation(); - createCommentBox(e.target, e.clientX, e.clientY); + const target = highlightedElement; + if (!target) return; + + createCommentBox(target, e.clientX, e.clientY); }, true); - // Suppress mousedown/pointerdown/mouseup in comment mode so floating-UI - // libraries (Radix, Headless UI, Floating UI) don't dismiss user panels via - // their outside-press detection before our click handler fires. - function swallowPress(e) { + // macOS: Ctrl+Click is remapped to contextmenu / secondary click. + // Catch it here using controlKeyHeld (event.ctrlKey is often false). + document.addEventListener( + 'contextmenu', + (e) => { + if (!commentMode || commentBox) return; + if (isEventInMoatUI(e)) return; + if (!isDeepSelectModifier(e)) return; + + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + lastPointer = { x: e.clientX, y: e.clientY }; + + if (drilledOnPointerdown) { + drilledOnPointerdown = false; + return; + } + handleDeepSelectDrill(); + }, + true + ); + + // Pointerdown: Ctrl+Click drills (Win/Linux + Mac with held-state); else swallow press + document.addEventListener('pointerdown', (e) => { + if (!commentMode || commentBox) return; + if (isEventInMoatUI(e)) return; + + if (isDeepSelectModifier(e)) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + lastPointer = { x: e.clientX, y: e.clientY }; + handleDeepSelectDrill(); + drilledOnPointerdown = true; + return; + } + + // Ignore non-primary buttons when not deep-selecting (e.g. two-finger right-click) + if (typeof e.button === 'number' && e.button !== 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }, true); + + document.addEventListener('mouseup', (e) => { if (!commentMode) return; if (isEventInMoatUI(e)) return; + if (isDeepSelectModifier(e)) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + return; + } + if (typeof e.button === 'number' && e.button !== 0) { + return; + } e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); - } - document.addEventListener('mousedown', swallowPress, true); - document.addEventListener('pointerdown', swallowPress, true); - document.addEventListener('mouseup', swallowPress, true); + }, true); // Global variables for new notification system let hasPressedC = false; @@ -4100,7 +4369,7 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are if (!hasPressedC) { hasPressedC = true; // Show the click instruction notification - showNotification('Click anywhere to comment', 'info', 'click-instruction'); + showNotification('Click to comment · Ctrl+Click deep select', 'info', 'click-instruction'); } enterCommentMode(); @@ -4448,7 +4717,10 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are sendResponse({ success: true, ready: true, - moatLoaded: !!window.__drawbridgeMoatLoaded + moatLoaded: !!window.__drawbridgeMoatLoaded, + contentScriptBuild: window.__drawbridgeContentScriptBuild || null, + hasElementPicker: !!window.MoatElementPicker, + needsRefresh: !!window.__drawbridgeNeedsRefresh }); } else if (request.action === 'toggleMoat') { window.dispatchEvent(new CustomEvent('moat:toggle-moat')); @@ -4505,7 +4777,7 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are if (!hasPressedC) { hasPressedC = true; // Show the click instruction notification - showNotification('Click anywhere to comment', 'info', 'click-instruction'); + showNotification('Click to comment · Ctrl+Click deep select', 'info', 'click-instruction'); } // Enter comment mode @@ -4528,4 +4800,11 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are console.log('🔧 Moat: Already in drawing mode or comment mode'); } }); -})(); + + window.__drawbridgeDebug = { + build: contentScriptBuild, + hasElementPicker: !!window.MoatElementPicker, + deepSelect: true + }; + console.info('[Drawbridge] Loaded', window.__drawbridgeDebug); +})(); diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 2c58d7d..ddaf376 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Drawbridge - Visual editor for AI coding assistants", - "version": "1.1.0", + "version": "1.1.1", "description": "Send comments and screenshots directly to Cursor and Claude Code as prompts.", "permissions": [ "activeTab", @@ -24,6 +24,8 @@ "html2canvas.min.js", "utils/safeStorage.js", "utils/persistence.js", + "utils/buildInfo.js", + "utils/elementPicker.js", "utils/taskStore.js", "utils/markdownGenerator.js", "utils/migrateLegacyFiles.js", diff --git a/chrome-extension/moat.css b/chrome-extension/moat.css index b7f0380..70de7b6 100644 --- a/chrome-extension/moat.css +++ b/chrome-extension/moat.css @@ -84,6 +84,31 @@ body.float-comment-mode * { cursor: pointer !important; } +body.float-deep-select-active { + cursor: crosshair !important; +} + +.float-deep-select-badge { + position: fixed; + z-index: 10000; + pointer-events: none; + padding: 4px 8px; + border-radius: 6px; + font-family: 'Inter', system-ui, sans-serif; + font-size: 11px; + font-weight: 600; + line-height: 1.2; + color: #fff; + background: rgba(245, 158, 11, 0.95); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + white-space: nowrap; +} + +.float-hover-overlay.float-hover-overlay-deep { + border-color: #F59E0B; + background-color: rgba(245, 158, 11, 0.12); +} + /* Drawing Mode */ body.float-drawing-mode { cursor: crosshair !important; diff --git a/chrome-extension/utils/buildInfo.js b/chrome-extension/utils/buildInfo.js new file mode 100644 index 0000000..2440e3e --- /dev/null +++ b/chrome-extension/utils/buildInfo.js @@ -0,0 +1,24 @@ +// Shared build id — keep DRAWBRIDGE_FEATURE_BUILD in sync across content + background +const DRAWBRIDGE_FEATURE_BUILD = 'deep-select-v2'; + +function getContentScriptBuild(manifestVersion) { + return `${manifestVersion}-${DRAWBRIDGE_FEATURE_BUILD}`; +} + +if (typeof module !== 'undefined' && module.exports) { + const api = { + DRAWBRIDGE_FEATURE_BUILD, + getContentScriptBuild + }; + module.exports = api; + if (typeof globalThis !== 'undefined') { + globalThis.DrawbridgeBuildInfo = api; + } +} else { + const api = { + DRAWBRIDGE_FEATURE_BUILD, + getContentScriptBuild + }; + self.DrawbridgeBuildInfo = api; + globalThis.DrawbridgeBuildInfo = api; +} diff --git a/chrome-extension/utils/buildInfo.test.js b/chrome-extension/utils/buildInfo.test.js new file mode 100644 index 0000000..de57a75 --- /dev/null +++ b/chrome-extension/utils/buildInfo.test.js @@ -0,0 +1,7 @@ +const { getContentScriptBuild, DRAWBRIDGE_FEATURE_BUILD } = require('./buildInfo.js'); + +describe('buildInfo', () => { + test('build id includes manifest version and feature tag', () => { + expect(getContentScriptBuild('1.1.1')).toBe(`1.1.1-${DRAWBRIDGE_FEATURE_BUILD}`); + }); +}); diff --git a/chrome-extension/utils/elementPicker.js b/chrome-extension/utils/elementPicker.js new file mode 100644 index 0000000..93fff8f --- /dev/null +++ b/chrome-extension/utils/elementPicker.js @@ -0,0 +1,189 @@ +// Element picking helpers — stack-based deep select (Figma-style) + +const SKIP_TAGS = new Set(['HTML', 'BODY']); + +/** + * @param {Element|null|undefined} element + * @returns {boolean} + */ +function isMoatUIElement(element) { + if (!element || element.nodeType !== 1) { + return false; + } + if (element.id === 'moat-shadow-host') { + return true; + } + if (!element.classList) { + return false; + } + return ( + element.classList.contains('float-moat') || + element.classList.contains('float-comment-box') || + element.classList.contains('float-hover-overlay') || + element.classList.contains('float-deep-select-badge') || + element.classList.contains('float-drawing-canvas') + ); +} + +/** + * @param {Element|null|undefined} element + * @returns {boolean} + */ +function isPickableElement(element) { + if (!element || element.nodeType !== 1) { + return false; + } + if (SKIP_TAGS.has(element.tagName)) { + return false; + } + return !isMoatUIElement(element); +} + +/** + * @param {number} x + * @param {number} y + * @param {Document} doc + * @returns {Element[]} + */ +function getElementsStackAtPoint(x, y, doc = document) { + if (doc.elementsFromPoint) { + return doc.elementsFromPoint(x, y); + } + const el = doc.elementFromPoint(x, y); + return el ? [el] : []; +} + +/** + * @param {Element[]} elements + * @returns {Element[]} + */ +function filterPickableStack(elements) { + const seen = new Set(); + const result = []; + for (const el of elements) { + if (!isPickableElement(el)) { + continue; + } + if (seen.has(el)) { + continue; + } + seen.add(el); + result.push(el); + } + return result; +} + +/** + * @param {number} index + * @param {number} stackLength + * @returns {number} + */ +function normalizeDepthIndex(index, stackLength) { + if (stackLength <= 0) { + return 0; + } + const mod = index % stackLength; + return mod < 0 ? mod + stackLength : mod; +} + +/** + * @param {number} currentIndex + * @param {number} stackLength + * @param {number} [direction=1] + * @returns {number} + */ +function cycleDepthIndex(currentIndex, stackLength, direction = 1) { + if (stackLength <= 1) { + return 0; + } + return normalizeDepthIndex(currentIndex + direction, stackLength); +} + +/** + * @param {number} x + * @param {number} y + * @param {number} depthIndex + * @param {Document} [doc=document] + * @returns {{ element: Element|null, stack: Element[], index: number, depth: number, total: number }} + */ +function pickElementAtPoint(x, y, depthIndex, doc = document) { + const stack = filterPickableStack(getElementsStackAtPoint(x, y, doc)); + if (stack.length === 0) { + return { element: null, stack: [], index: 0, depth: 0, total: 0 }; + } + const index = normalizeDepthIndex(depthIndex, stack.length); + return { + element: stack[index], + stack, + index, + depth: index + 1, + total: stack.length + }; +} + +/** + * Physical Control key (not Cmd/Meta). Used on Win/Linux/macOS. + * @param {KeyboardEvent|null|undefined} event + * @returns {boolean} + */ +function isControlKeyEvent(event) { + if (!event) { + return false; + } + return ( + event.key === 'Control' || + event.code === 'ControlLeft' || + event.code === 'ControlRight' + ); +} + +/** + * Deep select uses Control on every OS. + * macOS remaps Ctrl+Click → right-click and often clears event.ctrlKey, + * so callers must also pass a keydown-tracked `controlHeld` flag. + * @param {MouseEvent|PointerEvent|KeyboardEvent|null|undefined} event + * @param {boolean} [controlHeld=false] + * @returns {boolean} + */ +function isDeepSelectControlActive(event, controlHeld = false) { + if (controlHeld) { + return true; + } + if (!event) { + return false; + } + if (event.ctrlKey) { + return true; + } + if (typeof event.getModifierState === 'function' && event.getModifierState('Control')) { + return true; + } + return false; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + SKIP_TAGS, + isMoatUIElement, + isPickableElement, + getElementsStackAtPoint, + filterPickableStack, + normalizeDepthIndex, + cycleDepthIndex, + pickElementAtPoint, + isControlKeyEvent, + isDeepSelectControlActive + }; +} else { + window.MoatElementPicker = { + isMoatUIElement, + isPickableElement, + getElementsStackAtPoint, + filterPickableStack, + normalizeDepthIndex, + cycleDepthIndex, + pickElementAtPoint, + isControlKeyEvent, + isDeepSelectControlActive + }; +} diff --git a/chrome-extension/utils/elementPicker.test.js b/chrome-extension/utils/elementPicker.test.js new file mode 100644 index 0000000..354004f --- /dev/null +++ b/chrome-extension/utils/elementPicker.test.js @@ -0,0 +1,96 @@ +const { + isMoatUIElement, + isPickableElement, + filterPickableStack, + normalizeDepthIndex, + cycleDepthIndex, + pickElementAtPoint, + isControlKeyEvent, + isDeepSelectControlActive +} = require('./elementPicker.js'); + +function mockElement(tag, options = {}) { + const classes = new Set(options.classes || []); + return { + tagName: tag.toUpperCase(), + nodeType: 1, + id: options.id || '', + classList: { + contains: (name) => classes.has(name) + } + }; +} + +describe('elementPicker', () => { + test('isMoatUIElement detects drawbridge UI nodes', () => { + expect(isMoatUIElement(mockElement('div', { id: 'moat-shadow-host' }))).toBe(true); + expect(isMoatUIElement(mockElement('div', { classes: ['float-comment-box'] }))).toBe(true); + expect(isMoatUIElement(mockElement('div', { classes: ['hero'] }))).toBe(false); + }); + + test('isPickableElement skips html, body, and moat UI', () => { + expect(isPickableElement(mockElement('html'))).toBe(false); + expect(isPickableElement(mockElement('body'))).toBe(false); + expect(isPickableElement(mockElement('div', { classes: ['float-moat'] }))).toBe(false); + expect(isPickableElement(mockElement('button'))).toBe(true); + }); + + test('filterPickableStack removes moat layers and dedupes', () => { + const button = mockElement('button'); + const overlay = mockElement('div', { classes: ['float-hover-overlay'] }); + const stack = filterPickableStack([overlay, button, button]); + expect(stack).toEqual([button]); + }); + + test('normalizeDepthIndex wraps around stack length', () => { + expect(normalizeDepthIndex(0, 3)).toBe(0); + expect(normalizeDepthIndex(3, 3)).toBe(0); + expect(normalizeDepthIndex(-1, 3)).toBe(2); + }); + + test('cycleDepthIndex moves deeper through the stack', () => { + expect(cycleDepthIndex(0, 4, 1)).toBe(1); + expect(cycleDepthIndex(3, 4, 1)).toBe(0); + expect(cycleDepthIndex(0, 1, 1)).toBe(0); + }); + + test('pickElementAtPoint selects by depth index', () => { + const top = mockElement('span'); + const middle = mockElement('div'); + const bottom = mockElement('section'); + const doc = { + elementsFromPoint: () => [ + mockElement('div', { classes: ['float-hover-overlay'] }), + top, + middle, + bottom, + mockElement('body') + ] + }; + + expect(pickElementAtPoint(10, 10, 0, doc).element).toBe(top); + expect(pickElementAtPoint(10, 10, 1, doc).element).toBe(middle); + expect(pickElementAtPoint(10, 10, 2, doc).element).toBe(bottom); + expect(pickElementAtPoint(10, 10, 0, doc).total).toBe(3); + }); + + test('isControlKeyEvent matches physical Control only', () => { + expect(isControlKeyEvent({ key: 'Control', code: 'ControlLeft' })).toBe(true); + expect(isControlKeyEvent({ key: 'Control', code: 'ControlRight' })).toBe(true); + expect(isControlKeyEvent({ key: 'Meta', code: 'MetaLeft' })).toBe(false); + expect(isControlKeyEvent({ key: 'c', code: 'KeyC', ctrlKey: true })).toBe(false); + }); + + test('isDeepSelectControlActive uses held flag when macOS clears ctrlKey', () => { + // Simulated macOS remapped Ctrl+Click: secondary button, ctrlKey false + const remappedClick = { ctrlKey: false, button: 2, getModifierState: () => false }; + expect(isDeepSelectControlActive(remappedClick, false)).toBe(false); + expect(isDeepSelectControlActive(remappedClick, true)).toBe(true); + + // Win/Linux normal Ctrl+Click + expect(isDeepSelectControlActive({ ctrlKey: true }, false)).toBe(true); + + // Cmd alone must not deep-select + expect(isDeepSelectControlActive({ ctrlKey: false, metaKey: true }, false)).toBe(false); + }); +}); diff --git a/package-lock.json b/package-lock.json index 005bb76..0bc5ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "moat", - "version": "1.0.3", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moat", - "version": "1.0.3", + "version": "1.1.1", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", diff --git a/package.json b/package.json index db3718f..3ec587a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moat", - "version": "1.1.0", + "version": "1.1.1", "description": "Moat - Visual UI feedback with markdown task logging", "main": "moat-watcher.js", "scripts": {