From ddd77b36112550e1edaff16b5553cb34be9ff22a Mon Sep 17 00:00:00 2001 From: Dukeabaddon Date: Sun, 28 Jun 2026 17:43:06 +0800 Subject: [PATCH 1/3] Add Figma-style deep select for comment mode Ctrl+Click drills one layer deeper through stacked elements; plain click opens a comment. Includes stale-build auto-reload, trackpad click fallback, element picker utilities, e2e Playwright tests, and a demo test page. Co-authored-by: Cursor --- .gitignore | 10 + chrome-extension/background.js | 28 ++- chrome-extension/background.test.js | 57 ++++- chrome-extension/content_script.js | 242 ++++++++++++++++--- chrome-extension/manifest.json | 4 +- chrome-extension/moat.css | 25 ++ chrome-extension/utils/buildInfo.js | 24 ++ chrome-extension/utils/buildInfo.test.js | 7 + chrome-extension/utils/elementPicker.js | 145 +++++++++++ chrome-extension/utils/elementPicker.test.js | 74 ++++++ demo/deep-select-test.html | 139 +++++++++++ jest.config.js | 3 + package-lock.json | 68 +++++- package.json | 6 +- playwright.config.js | 13 + tests/e2e/deep-select.spec.js | 167 +++++++++++++ 16 files changed, 970 insertions(+), 42 deletions(-) create mode 100644 chrome-extension/utils/buildInfo.js create mode 100644 chrome-extension/utils/buildInfo.test.js create mode 100644 chrome-extension/utils/elementPicker.js create mode 100644 chrome-extension/utils/elementPicker.test.js create mode 100644 demo/deep-select-test.html create mode 100644 jest.config.js create mode 100644 playwright.config.js create mode 100644 tests/e2e/deep-select.spec.js diff --git a/.gitignore b/.gitignore index 0aca9b1..188f8b0 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,10 @@ temp/ # Coverage directory used by tools like istanbul coverage/ +# Playwright +test-results/ +playwright-report/ + # Optional npm cache directory .npm @@ -84,3 +88,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..3a4653b 100644 --- a/chrome-extension/content_script.js +++ b/chrome-extension/content_script.js @@ -1,14 +1,54 @@ // 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; + let lastTopPickElement = null; let projectRoot = null; let markdownFileHandle = null; // Handle for moat-tasks.md @@ -3383,6 +3423,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 +3435,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 +3761,126 @@ 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 isDeepSelectModifier(event) { + if (!event) { + return false; + } + return !!( + event.ctrlKey || + event.metaKey || + (event.getModifierState && (event.getModifierState('Control') || event.getModifierState('Meta'))) + ); + } + + 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 +3923,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 +3933,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 +4146,57 @@ 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(); + 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); + + // Pointerdown: Ctrl+Click drills; otherwise swallow press for page UI libraries + document.addEventListener('pointerdown', (e) => { + if (!commentMode || commentBox) return; + if (isEventInMoatUI(e)) return; + + if (isDeepSelectModifier(e)) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + handleDeepSelectDrill(); + drilledOnPointerdown = true; + return; + } + + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); }, 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) { + document.addEventListener('mouseup', (e) => { if (!commentMode) return; if (isEventInMoatUI(e)) 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; @@ -4448,7 +4626,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')); @@ -4528,4 +4709,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..cad13cb --- /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-v1'; + +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..9ee9827 --- /dev/null +++ b/chrome-extension/utils/elementPicker.js @@ -0,0 +1,145 @@ +// 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 + }; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + SKIP_TAGS, + isMoatUIElement, + isPickableElement, + getElementsStackAtPoint, + filterPickableStack, + normalizeDepthIndex, + cycleDepthIndex, + pickElementAtPoint + }; +} else { + window.MoatElementPicker = { + isMoatUIElement, + isPickableElement, + getElementsStackAtPoint, + filterPickableStack, + normalizeDepthIndex, + cycleDepthIndex, + pickElementAtPoint + }; +} diff --git a/chrome-extension/utils/elementPicker.test.js b/chrome-extension/utils/elementPicker.test.js new file mode 100644 index 0000000..814e3fa --- /dev/null +++ b/chrome-extension/utils/elementPicker.test.js @@ -0,0 +1,74 @@ +const { + isMoatUIElement, + isPickableElement, + filterPickableStack, + normalizeDepthIndex, + cycleDepthIndex, + pickElementAtPoint +} = 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); + }); +}); diff --git a/demo/deep-select-test.html b/demo/deep-select-test.html new file mode 100644 index 0000000..ef7b90f --- /dev/null +++ b/demo/deep-select-test.html @@ -0,0 +1,139 @@ + + + + + + Drawbridge deep select test + + + +

Deep select test page

+
+

Press C in Drawbridge, then hover the stacked area below.

+

Ctrl+Click drills one layer deeper. Click (no Ctrl) opens the comment on the highlighted element.

+
+ +
+
Back card (blue)
+
Middle card (amber)
+
Front chip (red)
+ Front image +
+ +
+
Top layer: image
+
Layer 2: red chip
+
Layer 3: amber card
+
Layer 4: blue card
+
+ + diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..4eeeffe --- /dev/null +++ b/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + testPathIgnorePatterns: ['/node_modules/', '/tests/e2e/'], +}; diff --git a/package-lock.json b/package-lock.json index 005bb76..3317958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "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": { + "@playwright/test": "^1.61.1", "@types/node": "^20.0.0", "jest": "^30.0.2" } @@ -1025,6 +1026,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.36", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.36.tgz", @@ -3583,6 +3600,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pretty-format": { "version": "30.0.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", diff --git a/package.json b/package.json index db3718f..38d4305 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": { @@ -9,7 +9,8 @@ "demo": "cd demo && python3 -m http.server 8080", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "test:e2e": "playwright test" }, "keywords": [ "chrome-extension", @@ -22,6 +23,7 @@ "author": "", "license": "MIT", "devDependencies": { + "@playwright/test": "^1.61.1", "@types/node": "^20.0.0", "jest": "^30.0.2" } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..c04b132 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,13 @@ +const { defineConfig } = require('@playwright/test'); + +// Extensions require a headed (or new-headless) Chromium with a persistent +// context, so each spec launches its own context. Keep workers at 1 to avoid +// extension/profile contention. +module.exports = defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + workers: 1, + reporter: [['list']], + timeout: 60000, + expect: { timeout: 10000 }, +}); diff --git a/tests/e2e/deep-select.spec.js b/tests/e2e/deep-select.spec.js new file mode 100644 index 0000000..c7a09fc --- /dev/null +++ b/tests/e2e/deep-select.spec.js @@ -0,0 +1,167 @@ +const { test, expect, chromium } = require('@playwright/test'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const http = require('http'); + +const EXT_PATH = path.resolve(__dirname, '../../chrome-extension'); +const DEMO_DIR = path.resolve(__dirname, '../../demo'); + +const MIME = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', +}; + +function startStaticServer(rootDir) { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + const urlPath = decodeURIComponent(req.url.split('?')[0]); + const filePath = path.join(rootDir, urlPath === '/' ? 'index.html' : urlPath); + fs.readFile(filePath, (err, data) => { + if (err) { + res.statusCode = 404; + res.end('not found'); + return; + } + res.setHeader('Content-Type', MIME[path.extname(filePath)] || 'application/octet-stream'); + res.end(data); + }); + }); + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +let server; +let baseURL; +let context; +let userDataDir; + +test.beforeAll(async () => { + server = await startStaticServer(DEMO_DIR); + baseURL = `http://127.0.0.1:${server.address().port}`; + + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-e2e-')); + context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + `--disable-extensions-except=${EXT_PATH}`, + `--load-extension=${EXT_PATH}`, + '--no-first-run', + ], + }); + + // Content-script globals live in an isolated world we cannot read from the + // page's main world, but localStorage is shared per-origin. Seed it so the + // sidebar auto-shows and the "C" comment shortcut is allowed. + await context.addInitScript(() => { + try { + localStorage.setItem('moat.visible', 'true'); + } catch (e) {} + }); +}); + +test.afterAll(async () => { + if (context) await context.close(); + if (server) await new Promise((r) => server.close(r)); + if (userDataDir) fs.rmSync(userDataDir, { recursive: true, force: true }); +}); + +async function openCommentModePage() { + const page = await context.newPage(); + await page.bringToFront(); + await page.goto(`${baseURL}/deep-select-test.html`); + + // Sidebar (light DOM) proves the extension content scripts ran. + await page.locator('#moat-moat.float-moat-visible').waitFor({ timeout: 20000 }); + + // Enter comment mode via the real keyboard shortcut. + await page.keyboard.press('c'); + await page.waitForFunction( + () => document.body.classList.contains('float-comment-mode'), + null, + { timeout: 10000 } + ); + return page; +} + +async function pointOn(page, selector) { + const box = await page.locator(selector).boundingBox(); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +test('Ctrl+Click drills deeper and does NOT open a comment box', async () => { + const page = await openCommentModePage(); + const p = await pointOn(page, '.front-image'); + + await page.mouse.move(p.x, p.y); + + await page.keyboard.down('Control'); + await page.mouse.click(p.x, p.y); + await page.keyboard.up('Control'); + + const badge = page.locator('.float-deep-select-badge'); + await expect(badge).toBeVisible(); + await expect(badge).toHaveText(/Layer 2\/\d+/); + + expect(await page.locator('.float-comment-box').count(), 'no comment box on Ctrl+Click').toBe(0); + + await page.close(); +}); + +test('plain Click opens a comment box', async () => { + const page = await openCommentModePage(); + const p = await pointOn(page, '.front-image'); + + await page.mouse.move(p.x, p.y); + await page.mouse.click(p.x, p.y); + + await expect(page.locator('.float-comment-box')).toBeVisible(); + + await page.close(); +}); + +test('Ctrl+Click twice drills two layers', async () => { + const page = await openCommentModePage(); + const p = await pointOn(page, '.front-image'); + + await page.mouse.move(p.x, p.y); + + await page.keyboard.down('Control'); + await page.mouse.click(p.x, p.y); + await page.mouse.move(p.x, p.y); + await page.mouse.click(p.x, p.y); + await page.keyboard.up('Control'); + + await expect(page.locator('.float-deep-select-badge')).toHaveText(/Layer 3\/\d+/); + expect(await page.locator('.float-comment-box').count()).toBe(0); + + await page.close(); +}); + +test('Ctrl+Click via click-only event drills (trackpad path)', async () => { + const page = await openCommentModePage(); + const p = await pointOn(page, '.front-image'); + + await page.mouse.move(p.x, p.y); + + await page.evaluate(({ x, y }) => { + const target = document.elementFromPoint(x, y); + target.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + ctrlKey: true, + }) + ); + }, p); + + await expect(page.locator('.float-deep-select-badge')).toHaveText(/Layer 2\/\d+/); + expect(await page.locator('.float-comment-box').count()).toBe(0); + + await page.close(); +}); From 830e3e6fff41b9eeb06618b94bdd14e8f8afb8af Mon Sep 17 00:00:00 2001 From: Dukeabaddon Date: Sun, 28 Jun 2026 17:45:44 +0800 Subject: [PATCH 2/3] Remove local-only e2e tooling from PR scope Drop Playwright config, e2e specs, and demo test page from tracked files. Keep extension feature code and Jest unit tests for upstream review. Co-authored-by: Cursor --- .gitignore | 6 +- demo/deep-select-test.html | 139 ---------------------------- jest.config.js | 3 - package-lock.json | 64 ------------- package.json | 4 +- playwright.config.js | 13 --- tests/e2e/deep-select.spec.js | 167 ---------------------------------- 7 files changed, 6 insertions(+), 390 deletions(-) delete mode 100644 demo/deep-select-test.html delete mode 100644 jest.config.js delete mode 100644 playwright.config.js delete mode 100644 tests/e2e/deep-select.spec.js diff --git a/.gitignore b/.gitignore index 188f8b0..34b9434 100644 --- a/.gitignore +++ b/.gitignore @@ -56,9 +56,13 @@ temp/ # Coverage directory used by tools like istanbul coverage/ -# Playwright +# 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 diff --git a/demo/deep-select-test.html b/demo/deep-select-test.html deleted file mode 100644 index ef7b90f..0000000 --- a/demo/deep-select-test.html +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - Drawbridge deep select test - - - -

Deep select test page

-
-

Press C in Drawbridge, then hover the stacked area below.

-

Ctrl+Click drills one layer deeper. Click (no Ctrl) opens the comment on the highlighted element.

-
- -
-
Back card (blue)
-
Middle card (amber)
-
Front chip (red)
- Front image -
- -
-
Top layer: image
-
Layer 2: red chip
-
Layer 3: amber card
-
Layer 4: blue card
-
- - diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 4eeeffe..0000000 --- a/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - testPathIgnorePatterns: ['/node_modules/', '/tests/e2e/'], -}; diff --git a/package-lock.json b/package-lock.json index 3317958..0bc5ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.1.1", "license": "MIT", "devDependencies": { - "@playwright/test": "^1.61.1", "@types/node": "^20.0.0", "jest": "^30.0.2" } @@ -1026,22 +1025,6 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@sinclair/typebox": { "version": "0.34.36", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.36.tgz", @@ -3600,53 +3583,6 @@ "node": ">=8" } }, - "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/pretty-format": { "version": "30.0.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", diff --git a/package.json b/package.json index 38d4305..3ec587a 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "demo": "cd demo && python3 -m http.server 8080", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "test:e2e": "playwright test" + "test:coverage": "jest --coverage" }, "keywords": [ "chrome-extension", @@ -23,7 +22,6 @@ "author": "", "license": "MIT", "devDependencies": { - "@playwright/test": "^1.61.1", "@types/node": "^20.0.0", "jest": "^30.0.2" } diff --git a/playwright.config.js b/playwright.config.js deleted file mode 100644 index c04b132..0000000 --- a/playwright.config.js +++ /dev/null @@ -1,13 +0,0 @@ -const { defineConfig } = require('@playwright/test'); - -// Extensions require a headed (or new-headless) Chromium with a persistent -// context, so each spec launches its own context. Keep workers at 1 to avoid -// extension/profile contention. -module.exports = defineConfig({ - testDir: './tests/e2e', - fullyParallel: false, - workers: 1, - reporter: [['list']], - timeout: 60000, - expect: { timeout: 10000 }, -}); diff --git a/tests/e2e/deep-select.spec.js b/tests/e2e/deep-select.spec.js deleted file mode 100644 index c7a09fc..0000000 --- a/tests/e2e/deep-select.spec.js +++ /dev/null @@ -1,167 +0,0 @@ -const { test, expect, chromium } = require('@playwright/test'); -const path = require('path'); -const fs = require('fs'); -const os = require('os'); -const http = require('http'); - -const EXT_PATH = path.resolve(__dirname, '../../chrome-extension'); -const DEMO_DIR = path.resolve(__dirname, '../../demo'); - -const MIME = { - '.html': 'text/html', - '.js': 'text/javascript', - '.css': 'text/css', - '.svg': 'image/svg+xml', - '.png': 'image/png', -}; - -function startStaticServer(rootDir) { - return new Promise((resolve) => { - const server = http.createServer((req, res) => { - const urlPath = decodeURIComponent(req.url.split('?')[0]); - const filePath = path.join(rootDir, urlPath === '/' ? 'index.html' : urlPath); - fs.readFile(filePath, (err, data) => { - if (err) { - res.statusCode = 404; - res.end('not found'); - return; - } - res.setHeader('Content-Type', MIME[path.extname(filePath)] || 'application/octet-stream'); - res.end(data); - }); - }); - server.listen(0, '127.0.0.1', () => resolve(server)); - }); -} - -let server; -let baseURL; -let context; -let userDataDir; - -test.beforeAll(async () => { - server = await startStaticServer(DEMO_DIR); - baseURL = `http://127.0.0.1:${server.address().port}`; - - userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-e2e-')); - context = await chromium.launchPersistentContext(userDataDir, { - headless: false, - args: [ - `--disable-extensions-except=${EXT_PATH}`, - `--load-extension=${EXT_PATH}`, - '--no-first-run', - ], - }); - - // Content-script globals live in an isolated world we cannot read from the - // page's main world, but localStorage is shared per-origin. Seed it so the - // sidebar auto-shows and the "C" comment shortcut is allowed. - await context.addInitScript(() => { - try { - localStorage.setItem('moat.visible', 'true'); - } catch (e) {} - }); -}); - -test.afterAll(async () => { - if (context) await context.close(); - if (server) await new Promise((r) => server.close(r)); - if (userDataDir) fs.rmSync(userDataDir, { recursive: true, force: true }); -}); - -async function openCommentModePage() { - const page = await context.newPage(); - await page.bringToFront(); - await page.goto(`${baseURL}/deep-select-test.html`); - - // Sidebar (light DOM) proves the extension content scripts ran. - await page.locator('#moat-moat.float-moat-visible').waitFor({ timeout: 20000 }); - - // Enter comment mode via the real keyboard shortcut. - await page.keyboard.press('c'); - await page.waitForFunction( - () => document.body.classList.contains('float-comment-mode'), - null, - { timeout: 10000 } - ); - return page; -} - -async function pointOn(page, selector) { - const box = await page.locator(selector).boundingBox(); - return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; -} - -test('Ctrl+Click drills deeper and does NOT open a comment box', async () => { - const page = await openCommentModePage(); - const p = await pointOn(page, '.front-image'); - - await page.mouse.move(p.x, p.y); - - await page.keyboard.down('Control'); - await page.mouse.click(p.x, p.y); - await page.keyboard.up('Control'); - - const badge = page.locator('.float-deep-select-badge'); - await expect(badge).toBeVisible(); - await expect(badge).toHaveText(/Layer 2\/\d+/); - - expect(await page.locator('.float-comment-box').count(), 'no comment box on Ctrl+Click').toBe(0); - - await page.close(); -}); - -test('plain Click opens a comment box', async () => { - const page = await openCommentModePage(); - const p = await pointOn(page, '.front-image'); - - await page.mouse.move(p.x, p.y); - await page.mouse.click(p.x, p.y); - - await expect(page.locator('.float-comment-box')).toBeVisible(); - - await page.close(); -}); - -test('Ctrl+Click twice drills two layers', async () => { - const page = await openCommentModePage(); - const p = await pointOn(page, '.front-image'); - - await page.mouse.move(p.x, p.y); - - await page.keyboard.down('Control'); - await page.mouse.click(p.x, p.y); - await page.mouse.move(p.x, p.y); - await page.mouse.click(p.x, p.y); - await page.keyboard.up('Control'); - - await expect(page.locator('.float-deep-select-badge')).toHaveText(/Layer 3\/\d+/); - expect(await page.locator('.float-comment-box').count()).toBe(0); - - await page.close(); -}); - -test('Ctrl+Click via click-only event drills (trackpad path)', async () => { - const page = await openCommentModePage(); - const p = await pointOn(page, '.front-image'); - - await page.mouse.move(p.x, p.y); - - await page.evaluate(({ x, y }) => { - const target = document.elementFromPoint(x, y); - target.dispatchEvent( - new MouseEvent('click', { - bubbles: true, - cancelable: true, - clientX: x, - clientY: y, - ctrlKey: true, - }) - ); - }, p); - - await expect(page.locator('.float-deep-select-badge')).toHaveText(/Layer 2\/\d+/); - expect(await page.locator('.float-comment-box').count()).toBe(0); - - await page.close(); -}); From 25e1abce4947f6bf7559138a7baa736b14d8b9cb Mon Sep 17 00:00:00 2001 From: Aaron Mecate Date: Sun, 19 Jul 2026 10:48:03 +0800 Subject: [PATCH 3/3] fix(deep-select): Ctrl layer pick on Mac/Linux/Windows macOS remaps Ctrl+Click and drops ctrlKey; track Control held so deep select drills stacked layers on all platforms. --- chrome-extension/content_script.js | 107 +++++++++++++++++-- chrome-extension/utils/buildInfo.js | 2 +- chrome-extension/utils/elementPicker.js | 48 ++++++++- chrome-extension/utils/elementPicker.test.js | 24 ++++- 4 files changed, 169 insertions(+), 12 deletions(-) diff --git a/chrome-extension/content_script.js b/chrome-extension/content_script.js index 3a4653b..74bc0e2 100644 --- a/chrome-extension/content_script.js +++ b/chrome-extension/content_script.js @@ -48,6 +48,8 @@ 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 @@ -3765,17 +3767,66 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are return window.MoatElementPicker; } + function setControlKeyHeld(held) { + controlKeyHeld = !!held; + } + function isDeepSelectModifier(event) { - if (!event) { - return false; + const pickerApi = getElementPicker(); + if (pickerApi && typeof pickerApi.isDeepSelectControlActive === 'function') { + return pickerApi.isDeepSelectControlActive(event, controlKeyHeld); } + // Fallback if picker script failed to inject return !!( - event.ctrlKey || - event.metaKey || - (event.getModifierState && (event.getModifierState('Control') || event.getModifierState('Meta'))) + 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(); @@ -4154,6 +4205,7 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are if (isDeepSelectModifier(e)) { e.preventDefault(); e.stopPropagation(); + e.stopImmediatePropagation(); if (drilledOnPointerdown) { drilledOnPointerdown = false; return; @@ -4171,7 +4223,31 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are createCommentBox(target, e.clientX, e.clientY); }, true); - // Pointerdown: Ctrl+Click drills; otherwise swallow press for page UI libraries + // 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; @@ -4180,11 +4256,17 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are 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(); @@ -4193,6 +4275,15 @@ JSON stores relative paths like \`./screenshots/file.png\`, but actual files are 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(); @@ -4278,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(); @@ -4686,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 diff --git a/chrome-extension/utils/buildInfo.js b/chrome-extension/utils/buildInfo.js index cad13cb..2440e3e 100644 --- a/chrome-extension/utils/buildInfo.js +++ b/chrome-extension/utils/buildInfo.js @@ -1,5 +1,5 @@ // Shared build id โ€” keep DRAWBRIDGE_FEATURE_BUILD in sync across content + background -const DRAWBRIDGE_FEATURE_BUILD = 'deep-select-v1'; +const DRAWBRIDGE_FEATURE_BUILD = 'deep-select-v2'; function getContentScriptBuild(manifestVersion) { return `${manifestVersion}-${DRAWBRIDGE_FEATURE_BUILD}`; diff --git a/chrome-extension/utils/elementPicker.js b/chrome-extension/utils/elementPicker.js index 9ee9827..93fff8f 100644 --- a/chrome-extension/utils/elementPicker.js +++ b/chrome-extension/utils/elementPicker.js @@ -121,6 +121,46 @@ function pickElementAtPoint(x, y, depthIndex, doc = document) { }; } +/** + * 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, @@ -130,7 +170,9 @@ if (typeof module !== 'undefined' && module.exports) { filterPickableStack, normalizeDepthIndex, cycleDepthIndex, - pickElementAtPoint + pickElementAtPoint, + isControlKeyEvent, + isDeepSelectControlActive }; } else { window.MoatElementPicker = { @@ -140,6 +182,8 @@ if (typeof module !== 'undefined' && module.exports) { filterPickableStack, normalizeDepthIndex, cycleDepthIndex, - pickElementAtPoint + pickElementAtPoint, + isControlKeyEvent, + isDeepSelectControlActive }; } diff --git a/chrome-extension/utils/elementPicker.test.js b/chrome-extension/utils/elementPicker.test.js index 814e3fa..354004f 100644 --- a/chrome-extension/utils/elementPicker.test.js +++ b/chrome-extension/utils/elementPicker.test.js @@ -4,7 +4,9 @@ const { filterPickableStack, normalizeDepthIndex, cycleDepthIndex, - pickElementAtPoint + pickElementAtPoint, + isControlKeyEvent, + isDeepSelectControlActive } = require('./elementPicker.js'); function mockElement(tag, options = {}) { @@ -71,4 +73,24 @@ describe('elementPicker', () => { 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); + }); });