From 647c9026940260fb80abc4af21cd19207022d131 Mon Sep 17 00:00:00 2001 From: Jerry Ren Date: Sat, 13 Jun 2026 17:50:03 +0800 Subject: [PATCH 1/2] feat: support comment mode --- src/comment-mode.js | 192 +++++++++++++++++++++++++++++++++++++ src/constants.js | 11 +++ src/iframe-adapter.js | 213 ++++++++++++++++++++++++------------------ 3 files changed, 325 insertions(+), 91 deletions(-) create mode 100644 src/comment-mode.js create mode 100644 src/constants.js diff --git a/src/comment-mode.js b/src/comment-mode.js new file mode 100644 index 0000000..a17ed5f --- /dev/null +++ b/src/comment-mode.js @@ -0,0 +1,192 @@ +import { POST_MESSAGE_TYPE } from './constants'; + +const countSameTagSiblingsBefore = (element) => { + let count = 0; + let sibling = element.previousElementSibling; + while (sibling) { + if (sibling.tagName === element.tagName) count += 1; + sibling = sibling.previousElementSibling; + } + return count; +}; + +const generateSelector = (element) => { + if (!element || element === document.body) return null; + + const parts = []; + let current = element; + + while (current && current !== document.body) { + const tag = current.tagName.toLowerCase(); + const index = countSameTagSiblingsBefore(current) + 1; + parts.unshift(`${tag}:nth-of-type(${index})`); + current = current.parentElement; + } + + return 'body > ' + parts.join(' > '); +}; + +const getHtmlHint = (element, maxLen = 180) => { + const html = element.outerHTML || ''; + return html.length > maxLen ? html.slice(0, maxLen) : html; +}; + +const getCurrentText = (element, maxLen = 160) => { + const text = (element.textContent || '').replace(/\s+/g, ' ').trim(); + return text.length > maxLen ? text.slice(0, maxLen) : text; +}; + +const computeStyle = (element) => { + const style = window.getComputedStyle(element); + return { + backgroundColor: style.backgroundColor, + color: style.color, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + fontFamily: style.fontFamily, + lineHeight: style.lineHeight, + borderRadius: style.borderRadius, + paddingBottom: style.paddingBottom, + paddingLeft: style.paddingLeft, + paddingRight: style.paddingRight, + paddingTop: style.paddingTop, + marginBottom: style.marginBottom, + marginLeft: style.marginLeft, + marginRight: style.marginRight, + marginTop: style.marginTop, + textAlign: style.textAlign, + display: style.display, + width: style.width, + height: style.height, + }; +}; + +const generateLabel = (element) => { + const tag = element.tagName.toLowerCase(); + const classes = element.classList.length > 0 + ? '.' + Array.from(element.classList).join('.') + : ''; + return `${tag}${classes}`; +}; + +export class CommentModeAdapter { + constructor() { + this.isActive = false; + this._handleEvent = this._handleEvent.bind(this); + this._handleScroll = this._handleScroll.bind(this); + this.mouseEvents = ['click', 'dblclick', 'mousedown', 'mouseup', 'mousemove', 'mouseover', 'mouseout', 'mouseenter', 'mouseleave', 'contextmenu']; + this.hoverTarget = null; + this.selectedTarget = null; + this._scrollRAF = null; + this._hoverRAF = null; + } + + enable() { + if (this.isActive) return; + this.isActive = true; + this.mouseEvents.forEach(eventType => { + window.addEventListener(eventType, this._handleEvent, true); + }); + window.addEventListener('scroll', this._handleScroll, true); + this.addCommentModeStyle(); + } + + disable() { + if (!this.isActive) return; + this.isActive = false; + this.mouseEvents.forEach(eventType => { + window.removeEventListener(eventType, this._handleEvent, true); + }); + window.removeEventListener('scroll', this._handleScroll, true); + this.hoverTarget = null; + this.selectedTarget = null; + if (this._scrollRAF) cancelAnimationFrame(this._scrollRAF); + if (this._hoverRAF) cancelAnimationFrame(this._hoverRAF); + this._scrollRAF = null; + this._hoverRAF = null; + this.removeCommentStyle(); + } + + _handleScroll() { + if (!this.isActive) return; + + if (this._scrollRAF) return; + this._scrollRAF = requestAnimationFrame(() => { + this._scrollRAF = null; + + if (this.selectedTarget && document.body.contains(this.selectedTarget)) { + const data = this.buildElementData(this.selectedTarget); + window.parent.postMessage({ type: POST_MESSAGE_TYPE.HTML_PAGE_COMMENT_MODE_ELEMENT_POSITION_UPDATE, data, targetType: 'selected' }, '*'); + } + + if (this.hoverTarget && document.body.contains(this.hoverTarget)) { + const data = this.buildElementData(this.hoverTarget); + window.parent.postMessage({ type: POST_MESSAGE_TYPE.HTML_PAGE_COMMENT_MODE_ELEMENT_POSITION_UPDATE, data, targetType: 'hover' }, '*'); + } + }); + } + + addCommentModeStyle() { + let style = document.getElementById('ai-comment-cursor-style'); + if (!style) { + style = document.createElement('style'); + style.id = 'ai-comment-cursor-style'; + style.innerHTML = '* { cursor: crosshair !important; }'; + document.head.appendChild(style); + } + } + + removeCommentStyle() { + const style = document.getElementById('ai-comment-cursor-style'); + if (style) style.remove(); + } + + _handleEvent(event) { + if (!this.isActive) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + const target = event.target; + const isBodyOrHtml = target === document.body || target === document.documentElement; + + if (event.type === 'mouseover') { + this.hoverTarget = isBodyOrHtml ? null : target; + + if (this._hoverRAF) return; + this._hoverRAF = requestAnimationFrame(() => { + this._hoverRAF = null; + const currentTarget = this.hoverTarget; + const data = currentTarget ? this.buildElementData(currentTarget) : null; + window.parent.postMessage({ type: POST_MESSAGE_TYPE.HTML_PAGE_COMMENT_MODE_ELEMENT_HOVER, data }, '*'); + }); + } else if (event.type === 'click') { + this.selectedTarget = isBodyOrHtml ? null : target; + const data = isBodyOrHtml ? null : this.buildElementData(target); + window.parent.postMessage({ type: POST_MESSAGE_TYPE.HTML_PAGE_COMMENT_MODE_ELEMENT_SELECTED, data }, '*'); + } + } + + buildElementData(target) { + const rect = target.getBoundingClientRect(); + const selector = generateSelector(target) || null; + return { + selector, + currentText: getCurrentText(target), + htmlHint: getHtmlHint(target), + computedStyle: computeStyle(target), + label: generateLabel(target), + elementPosition: { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + } + }; + } + + destroy() { + this.disable(); + } +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..93a9ea3 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,11 @@ +export const POST_MESSAGE_TYPE = { + HTML_PAGE_REQUEST: 'HTML_PAGE_REQUEST', + HTML_PAGE_RESPONSE: 'HTML_PAGE_RESPONSE', + HTML_PAGE_EVENT: 'HTML_PAGE_EVENT', + HTML_PAGE_ENABLE_COMMENT_MODE: 'HTML_PAGE_ENABLE_COMMENT_MODE', + HTML_PAGE_DISABLE_COMMENT_MODE: 'HTML_PAGE_DISABLE_COMMENT_MODE', + HTML_PAGE_COMMENT_MODE_ELEMENT_HOVER: 'HTML_PAGE_COMMENT_MODE_ELEMENT_HOVER', + HTML_PAGE_COMMENT_MODE_ELEMENT_SELECTED: 'HTML_PAGE_COMMENT_MODE_ELEMENT_SELECTED', + HTML_PAGE_COMMENT_MODE_ELEMENT_POSITION_UPDATE: 'HTML_PAGE_COMMENT_MODE_ELEMENT_POSITION_UPDATE', + WINDOW_EVENT: 'WINDOW_EVENT', +}; diff --git a/src/iframe-adapter.js b/src/iframe-adapter.js index d62d2c3..04f7775 100644 --- a/src/iframe-adapter.js +++ b/src/iframe-adapter.js @@ -1,9 +1,5 @@ -const POST_MESSAGE_TYPE = { - HTML_PAGE_REQUEST: 'HTML_PAGE_REQUEST', - HTML_PAGE_RESPONSE: 'HTML_PAGE_RESPONSE', - HTML_PAGE_EVENT: 'HTML_PAGE_EVENT', - WINDOW_EVENT: 'WINDOW_EVENT', -}; +import { CommentModeAdapter } from './comment-mode'; +import { POST_MESSAGE_TYPE } from './constants'; export const POST_MESSAGE_REQUEST_TYPE = { GET_SERVER: 'get_server', @@ -80,6 +76,8 @@ export class IframeAdapter { this.pendingRequests = {}; this.eventHandlers = {}; this.timeout = this.options.timeout || 10000; + this.isCommentMode = false; + this.commentModeAdapter = new CommentModeAdapter(); this.setupMessageListener(); } @@ -111,44 +109,59 @@ export class IframeAdapter { } setEventsListener() { - let rafId = null; - let pendingEvent = null; - [ + this._windowEventHandler = this._windowEventHandler.bind(this); + this.interactiveEventTypes = [ ...SUPPORT_WINDOW_MOUSE_EVENT_TYPES, ...SUPPORT_WINDOW_KEYBOARD_EVENT_TYPES, ...SUPPORT_WINDOW_DRAG_EVENT_TYPES, - ].forEach(eventType => { - window.addEventListener(eventType, (event) => { - if (event.source === WINDOW_EVENT_SOURCE_TYPE.APP) return; - const target = event.target; - if (target && INTERACTIVE_TAGS.includes(target.tagName)) return; - if (SUPPORT_WINDOW_KEYBOARD_EVENT_TYPES.includes(eventType)) { - const active = document.activeElement; - if (active && INTERACTIVE_TAGS.includes(active.tagName)) return; - } - if (HIGH_FREQUENCY_WINDOW_EVENT_TYPES.includes(eventType)) { - // High-frequency events that need throttling (use RAF to limit to 60fps) - // Use requestAnimationFrame for throttling high-frequency events - // Store the latest event with necessary data - pendingEvent = createWindowEventData({ eventType, event }); + ]; + this.rafId = null; + this.pendingEvent = null; - // Only schedule a new frame if one isn't already scheduled - if (rafId === null) { - rafId = requestAnimationFrame(() => { - if (pendingEvent) { - this.postWindowEvent(pendingEvent); - pendingEvent = null; - rafId = null; - } - }); - } - return; - } + this.bindInteractiveEvents(); + } + + bindInteractiveEvents() { + this.interactiveEventTypes.forEach(eventType => { + window.addEventListener(eventType, this._windowEventHandler, true); + }); + } - // Low-frequency events - this.postWindowEvent(createWindowEventData({ eventType, event })); - }, true); + unbindInteractiveEvents() { + this.interactiveEventTypes.forEach(eventType => { + window.removeEventListener(eventType, this._windowEventHandler, true); }); + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + this.pendingEvent = null; + } + + _windowEventHandler(event) { + if (event.source === WINDOW_EVENT_SOURCE_TYPE.APP) return; + const target = event.target; + if (target && INTERACTIVE_TAGS.includes(target.tagName)) return; + + const eventType = event.type; + if (SUPPORT_WINDOW_KEYBOARD_EVENT_TYPES.includes(eventType)) { + const active = document.activeElement; + if (active && INTERACTIVE_TAGS.includes(active.tagName)) return; + } + if (HIGH_FREQUENCY_WINDOW_EVENT_TYPES.includes(eventType)) { + this.pendingEvent = createWindowEventData({ eventType, event }); + if (this.rafId === null) { + this.rafId = requestAnimationFrame(() => { + if (this.pendingEvent) { + this.postWindowEvent(this.pendingEvent); + this.pendingEvent = null; + this.rafId = null; + } + }); + } + return; + } + this.postWindowEvent(createWindowEventData({ eventType, event })); } async request(method, params) { @@ -186,6 +199,9 @@ export class IframeAdapter { handleMessage(event) { const { type, requestId, data, error, eventType, payload } = event.data; + if (type && type.includes('COMMENT')) { + console.log('--- SDK handleMessage ---', type); + } if (type === POST_MESSAGE_TYPE.HTML_PAGE_RESPONSE) { const pending = this.pendingRequests[requestId]; if (pending) { @@ -199,62 +215,74 @@ export class IframeAdapter { } } else if (type === POST_MESSAGE_TYPE.HTML_PAGE_EVENT) { this.emitEvent(eventType, payload); + } else if (type === POST_MESSAGE_TYPE.HTML_PAGE_ENABLE_COMMENT_MODE) { + this.isCommentMode = true; + this.unbindInteractiveEvents(); + if (this.commentModeAdapter) this.commentModeAdapter.enable(); + } else if (type === POST_MESSAGE_TYPE.HTML_PAGE_DISABLE_COMMENT_MODE) { + this.isCommentMode = false; + if (this.commentModeAdapter) this.commentModeAdapter.disable(); + this.bindInteractiveEvents(); } else if (type === POST_MESSAGE_TYPE.WINDOW_EVENT) { - const eventData = data.event_data; - if (!eventData) return; - let syntheticEvent; - let targetElement; - if (SUPPORT_WINDOW_KEYBOARD_EVENT_TYPES.includes(eventData.type)) { - syntheticEvent = new KeyboardEvent(eventData.type, { - bubbles: true, - cancelable: true, - key: eventData.key, - code: eventData.code, - keyCode: eventData.keyCode, - ctrlKey: eventData.ctrlKey, - shiftKey: eventData.shiftKey, - altKey: eventData.altKey, - metaKey: eventData.metaKey, - repeat: eventData.repeat, - view: window, - }); - targetElement = document.activeElement || document.body; - } else if (SUPPORT_WINDOW_MOUSE_EVENT_TYPES.includes(eventData.type)) { - syntheticEvent = new MouseEvent(eventData.type, { - bubbles: true, - cancelable: true, - view: window, - clientX: eventData.x, - clientY: eventData.y, - screenX: eventData.x, - screenY: eventData.y, - button: eventData.button, - buttons: eventData.buttons, - }); - const elementAtPoint = document.elementFromPoint(eventData.x, eventData.y); - targetElement = elementAtPoint || document.body; - } else if (SUPPORT_WINDOW_DRAG_EVENT_TYPES.includes(eventData.type)) { - syntheticEvent = new DragEvent(eventData.type, { - bubbles: true, - cancelable: true, - view: window, - clientX: eventData.x, - clientY: eventData.y, - screenX: eventData.x, - screenY: eventData.y, - button: eventData.button, - buttons: eventData.buttons, - }); - const elementAtPoint = document.elementFromPoint(eventData.x, eventData.y); - targetElement = elementAtPoint || document.body; - } - - if (!targetElement || !syntheticEvent) return; + this.handleWindowEvent(data); + } + } - // Dispatch once on the target element, it will bubble up naturally - syntheticEvent.source = eventData.source; - targetElement.dispatchEvent(syntheticEvent); + handleWindowEvent(data) { + const eventData = data.event_data; + if (!eventData || this.isCommentMode) return; + let syntheticEvent; + let targetElement; + if (SUPPORT_WINDOW_KEYBOARD_EVENT_TYPES.includes(eventData.type)) { + syntheticEvent = new KeyboardEvent(eventData.type, { + bubbles: true, + cancelable: true, + key: eventData.key, + code: eventData.code, + keyCode: eventData.keyCode, + ctrlKey: eventData.ctrlKey, + shiftKey: eventData.shiftKey, + altKey: eventData.altKey, + metaKey: eventData.metaKey, + repeat: eventData.repeat, + view: window, + }); + targetElement = document.activeElement || document.body; + } else if (SUPPORT_WINDOW_MOUSE_EVENT_TYPES.includes(eventData.type)) { + syntheticEvent = new MouseEvent(eventData.type, { + bubbles: true, + cancelable: true, + view: window, + clientX: eventData.x, + clientY: eventData.y, + screenX: eventData.x, + screenY: eventData.y, + button: eventData.button, + buttons: eventData.buttons, + }); + const elementAtPoint = document.elementFromPoint(eventData.x, eventData.y); + targetElement = elementAtPoint || document.body; + } else if (SUPPORT_WINDOW_DRAG_EVENT_TYPES.includes(eventData.type)) { + syntheticEvent = new DragEvent(eventData.type, { + bubbles: true, + cancelable: true, + view: window, + clientX: eventData.x, + clientY: eventData.y, + screenX: eventData.x, + screenY: eventData.y, + button: eventData.button, + buttons: eventData.buttons, + }); + const elementAtPoint = document.elementFromPoint(eventData.x, eventData.y); + targetElement = elementAtPoint || document.body; } + + if (!targetElement || !syntheticEvent) return; + + // Dispatch once on the target element, it will bubble up naturally + syntheticEvent.source = eventData.source; + targetElement.dispatchEvent(syntheticEvent); } on(eventType, handler) { @@ -294,5 +322,8 @@ export class IframeAdapter { }); this.pendingRequests = {}; this.eventHandlers = {}; + if (this.commentModeAdapter) { + this.commentModeAdapter.destroy(); + } } } From a54bb739ca416300c4b6a765d2799676ab9fb765 Mon Sep 17 00:00:00 2001 From: Jerry Ren Date: Tue, 4 Aug 2026 18:27:47 +0800 Subject: [PATCH 2/2] Fix comment hover cleanup and RAF state handling --- src/comment-mode.js | 23 ++- src/iframe-adapter.js | 39 +++-- tests/comment-mode.test.js | 293 +++++++++++++++++++++++++++++++++++ tests/iframe-adapter.test.js | 113 ++++++++++++++ 4 files changed, 451 insertions(+), 17 deletions(-) create mode 100644 tests/comment-mode.test.js diff --git a/src/comment-mode.js b/src/comment-mode.js index a17ed5f..83b4750 100644 --- a/src/comment-mode.js +++ b/src/comment-mode.js @@ -100,8 +100,8 @@ export class CommentModeAdapter { window.removeEventListener('scroll', this._handleScroll, true); this.hoverTarget = null; this.selectedTarget = null; - if (this._scrollRAF) cancelAnimationFrame(this._scrollRAF); - if (this._hoverRAF) cancelAnimationFrame(this._hoverRAF); + if (this._scrollRAF !== null) cancelAnimationFrame(this._scrollRAF); + if (this._hoverRAF !== null) cancelAnimationFrame(this._hoverRAF); this._scrollRAF = null; this._hoverRAF = null; this.removeCommentStyle(); @@ -110,7 +110,7 @@ export class CommentModeAdapter { _handleScroll() { if (!this.isActive) return; - if (this._scrollRAF) return; + if (this._scrollRAF !== null) return; this._scrollRAF = requestAnimationFrame(() => { this._scrollRAF = null; @@ -151,10 +151,23 @@ export class CommentModeAdapter { const target = event.target; const isBodyOrHtml = target === document.body || target === document.documentElement; - if (event.type === 'mouseover') { + if ((event.type === 'mouseout' || event.type === 'mouseleave') && event.relatedTarget === null) { + const hadHoverTarget = this.hoverTarget !== null; + const hadPendingHover = this._hoverRAF !== null; + this.hoverTarget = null; + + if (hadPendingHover) { + cancelAnimationFrame(this._hoverRAF); + this._hoverRAF = null; + } + + if (hadHoverTarget || hadPendingHover) { + window.parent.postMessage({ type: POST_MESSAGE_TYPE.HTML_PAGE_COMMENT_MODE_ELEMENT_HOVER, data: null }, '*'); + } + } else if (event.type === 'mouseover') { this.hoverTarget = isBodyOrHtml ? null : target; - if (this._hoverRAF) return; + if (this._hoverRAF !== null) return; this._hoverRAF = requestAnimationFrame(() => { this._hoverRAF = null; const currentTarget = this.hoverTarget; diff --git a/src/iframe-adapter.js b/src/iframe-adapter.js index 04f7775..2726835 100644 --- a/src/iframe-adapter.js +++ b/src/iframe-adapter.js @@ -78,6 +78,7 @@ export class IframeAdapter { this.timeout = this.options.timeout || 10000; this.isCommentMode = false; this.commentModeAdapter = new CommentModeAdapter(); + this._handleMessage = this.handleMessage.bind(this); this.setupMessageListener(); } @@ -91,7 +92,7 @@ export class IframeAdapter { setupMessageListener() { if (this.selfWindow) return; - window.addEventListener('message', this.handleMessage.bind(this)); + window.addEventListener('message', this._handleMessage); this.setEventsListener(); } @@ -198,10 +199,10 @@ export class IframeAdapter { } handleMessage(event) { - const { type, requestId, data, error, eventType, payload } = event.data; - if (type && type.includes('COMMENT')) { - console.log('--- SDK handleMessage ---', type); - } + const message = event && event.data; + if (!message || typeof message !== 'object' || Array.isArray(message)) return; + + const { type, requestId, data, error, eventType, payload } = message; if (type === POST_MESSAGE_TYPE.HTML_PAGE_RESPONSE) { const pending = this.pendingRequests[requestId]; if (pending) { @@ -210,7 +211,11 @@ export class IframeAdapter { if (error) { pending.reject(new Error(error)); } else { - pending.resolve(data ? JSON.parse(data) : null); + try { + pending.resolve(data ? JSON.parse(data) : null); + } catch { + pending.reject(new Error('Invalid response payload')); + } } } } else if (type === POST_MESSAGE_TYPE.HTML_PAGE_EVENT) { @@ -229,8 +234,11 @@ export class IframeAdapter { } handleWindowEvent(data) { - const eventData = data.event_data; - if (!eventData || this.isCommentMode) return; + const eventData = data && typeof data === 'object' && !Array.isArray(data) + ? data.event_data + : null; + const isValidEventData = eventData && typeof eventData === 'object' && !Array.isArray(eventData); + if (!isValidEventData || this.isCommentMode) return; let syntheticEvent; let targetElement; if (SUPPORT_WINDOW_KEYBOARD_EVENT_TYPES.includes(eventData.type)) { @@ -316,14 +324,21 @@ export class IframeAdapter { } destroy() { - this.pendingRequests.forEach(pending => { + if (!this.selfWindow) { + window.removeEventListener('message', this._handleMessage); + this.unbindInteractiveEvents(); + } + + this.isCommentMode = false; + if (this.commentModeAdapter) { + this.commentModeAdapter.destroy(); + } + + Object.values(this.pendingRequests).forEach(pending => { clearTimeout(pending.timeoutId); pending.reject(new Error('Adapter destroyed')); }); this.pendingRequests = {}; this.eventHandlers = {}; - if (this.commentModeAdapter) { - this.commentModeAdapter.destroy(); - } } } diff --git a/tests/comment-mode.test.js b/tests/comment-mode.test.js new file mode 100644 index 0000000..41ed77e --- /dev/null +++ b/tests/comment-mode.test.js @@ -0,0 +1,293 @@ +import { CommentModeAdapter } from '../src/comment-mode'; +import { POST_MESSAGE_TYPE } from '../src/constants'; + +const createElement = ({ + tagName = 'DIV', + classNames = [], + textContent = '', + outerHTML, + parentElement = null, + previousElementSibling = null, + rect = { left: 0, top: 0, width: 0, height: 0 }, +} = {}) => ({ + tagName, + classList: classNames, + textContent, + outerHTML: outerHTML || `<${tagName.toLowerCase()}>${textContent}`, + parentElement, + previousElementSibling, + getBoundingClientRect: jest.fn(() => rect), +}); + +describe('CommentModeAdapter', () => { + const originalWindow = global.window; + const originalDocument = global.document; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + let addEventListener; + let removeEventListener; + let postMessage; + let requestAnimationFrame; + let cancelAnimationFrame; + let frameCallbacks; + let nextFrameId; + let body; + let documentElement; + let styleElement; + + const flushAnimationFrames = () => { + const pendingFrames = Array.from(frameCallbacks.entries()); + frameCallbacks.clear(); + pendingFrames.forEach(([, callback]) => callback()); + }; + + beforeEach(() => { + addEventListener = jest.fn(); + removeEventListener = jest.fn(); + postMessage = jest.fn(); + frameCallbacks = new Map(); + nextFrameId = 1; + requestAnimationFrame = jest.fn((callback) => { + const frameId = nextFrameId; + nextFrameId += 1; + frameCallbacks.set(frameId, callback); + return frameId; + }); + cancelAnimationFrame = jest.fn((frameId) => { + frameCallbacks.delete(frameId); + }); + + body = createElement({ tagName: 'BODY' }); + body.contains = jest.fn(() => true); + documentElement = createElement({ tagName: 'HTML' }); + styleElement = null; + + global.window = { + addEventListener, + removeEventListener, + parent: { postMessage }, + getComputedStyle: jest.fn(() => ({ + backgroundColor: 'rgb(1, 2, 3)', + color: 'rgb(4, 5, 6)', + fontSize: '16px', + fontWeight: '400', + fontFamily: 'Arial', + lineHeight: '24px', + borderRadius: '4px', + paddingBottom: '1px', + paddingLeft: '2px', + paddingRight: '3px', + paddingTop: '4px', + marginBottom: '5px', + marginLeft: '6px', + marginRight: '7px', + marginTop: '8px', + textAlign: 'left', + display: 'block', + width: '100px', + height: '50px', + })), + }; + global.document = { + body, + documentElement, + head: { + appendChild: jest.fn((element) => { + styleElement = element; + }), + }, + createElement: jest.fn(() => ({ id: '', innerHTML: '', remove: jest.fn(() => { styleElement = null; }) })), + getElementById: jest.fn((id) => (id === 'ai-comment-cursor-style' ? styleElement : null)), + }; + global.requestAnimationFrame = requestAnimationFrame; + global.cancelAnimationFrame = cancelAnimationFrame; + }); + + afterEach(() => { + global.window = originalWindow; + global.document = originalDocument; + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + jest.restoreAllMocks(); + }); + + it('registers capture listeners once and removes them when disabled', () => { + const adapter = new CommentModeAdapter(); + + adapter.enable(); + adapter.enable(); + + expect(addEventListener).toHaveBeenCalledTimes(adapter.mouseEvents.length + 1); + adapter.mouseEvents.forEach((eventType) => { + expect(addEventListener).toHaveBeenCalledWith(eventType, adapter._handleEvent, true); + }); + expect(addEventListener).toHaveBeenCalledWith('scroll', adapter._handleScroll, true); + expect(document.head.appendChild).toHaveBeenCalledTimes(1); + + adapter._handleScroll(); + adapter._handleEvent({ + type: 'mouseover', + target: createElement({ parentElement: body }), + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + stopImmediatePropagation: jest.fn(), + }); + + adapter.disable(); + adapter.disable(); + + expect(removeEventListener).toHaveBeenCalledTimes(adapter.mouseEvents.length + 1); + adapter.mouseEvents.forEach((eventType) => { + expect(removeEventListener).toHaveBeenCalledWith(eventType, adapter._handleEvent, true); + }); + expect(removeEventListener).toHaveBeenCalledWith('scroll', adapter._handleScroll, true); + expect(cancelAnimationFrame).toHaveBeenCalledTimes(2); + expect(adapter.hoverTarget).toBeNull(); + expect(adapter.selectedTarget).toBeNull(); + expect(styleElement).toBeNull(); + }); + + it('posts selected and latest hovered element payloads', () => { + const adapter = new CommentModeAdapter(); + const target = createElement({ + tagName: 'BUTTON', + classNames: ['primary', 'large'], + textContent: ' Save changes ', + outerHTML: '', + parentElement: body, + rect: { left: 12, top: 34, width: 56, height: 78 }, + }); + const clickEvent = { + type: 'click', + target, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + stopImmediatePropagation: jest.fn(), + }; + const hoverEvent = { + ...clickEvent, + type: 'mouseover', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + stopImmediatePropagation: jest.fn(), + }; + + adapter.enable(); + adapter._handleEvent(clickEvent); + adapter._handleEvent(hoverEvent); + flushAnimationFrames(); + + const expectedData = { + selector: 'body > button:nth-of-type(1)', + currentText: 'Save changes', + htmlHint: '', + computedStyle: { + backgroundColor: 'rgb(1, 2, 3)', + color: 'rgb(4, 5, 6)', + fontSize: '16px', + fontWeight: '400', + fontFamily: 'Arial', + lineHeight: '24px', + borderRadius: '4px', + paddingBottom: '1px', + paddingLeft: '2px', + paddingRight: '3px', + paddingTop: '4px', + marginBottom: '5px', + marginLeft: '6px', + marginRight: '7px', + marginTop: '8px', + textAlign: 'left', + display: 'block', + width: '100px', + height: '50px', + }, + label: 'button.primary.large', + elementPosition: { left: 12, top: 34, width: 56, height: 78 }, + }; + + expect(clickEvent.preventDefault).toHaveBeenCalledTimes(1); + expect(clickEvent.stopPropagation).toHaveBeenCalledTimes(1); + expect(clickEvent.stopImmediatePropagation).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenNthCalledWith(1, { + type: POST_MESSAGE_TYPE.HTML_PAGE_COMMENT_MODE_ELEMENT_SELECTED, + data: expectedData, + }, '*'); + expect(postMessage).toHaveBeenNthCalledWith(2, { + type: POST_MESSAGE_TYPE.HTML_PAGE_COMMENT_MODE_ELEMENT_HOVER, + data: expectedData, + }, '*'); + }); + + it('clears hover and notifies the parent once when the pointer leaves the iframe', () => { + const adapter = new CommentModeAdapter(); + const target = createElement({ tagName: 'BUTTON', parentElement: body }); + + adapter.enable(); + adapter._handleEvent({ + type: 'mouseover', + target, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + stopImmediatePropagation: jest.fn(), + }); + + expect(adapter.hoverTarget).toBe(target); + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + + adapter._handleEvent({ + type: 'mouseout', + target, + relatedTarget: null, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + stopImmediatePropagation: jest.fn(), + }); + adapter._handleEvent({ + type: 'mouseleave', + target, + relatedTarget: null, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + stopImmediatePropagation: jest.fn(), + }); + flushAnimationFrames(); + + expect(cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(adapter.hoverTarget).toBeNull(); + expect(adapter._hoverRAF).toBeNull(); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ + type: POST_MESSAGE_TYPE.HTML_PAGE_COMMENT_MODE_ELEMENT_HOVER, + data: null, + }, '*'); + }); + + it('throttles scroll updates and posts the latest selected position', () => { + const adapter = new CommentModeAdapter(); + const rect = { left: 10, top: 20, width: 30, height: 40 }; + const target = createElement({ tagName: 'SECTION', parentElement: body, rect }); + + adapter.enable(); + adapter.selectedTarget = target; + adapter._handleScroll(); + + rect.left = 100; + rect.top = 200; + adapter._handleScroll(); + adapter._handleScroll(); + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + flushAnimationFrames(); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ + type: POST_MESSAGE_TYPE.HTML_PAGE_COMMENT_MODE_ELEMENT_POSITION_UPDATE, + data: expect.objectContaining({ + elementPosition: { left: 100, top: 200, width: 30, height: 40 }, + }), + targetType: 'selected', + }, '*'); + }); +}); diff --git a/tests/iframe-adapter.test.js b/tests/iframe-adapter.test.js index 8e0da4c..e898f0b 100644 --- a/tests/iframe-adapter.test.js +++ b/tests/iframe-adapter.test.js @@ -1,4 +1,5 @@ import { IframeAdapter } from '../src/iframe-adapter'; +import { POST_MESSAGE_TYPE } from '../src/constants'; describe('IframeAdapter', () => { const originalWindow = global.window; @@ -27,6 +28,118 @@ describe('IframeAdapter', () => { expect(global.window.addEventListener).not.toHaveBeenCalled(); }); + it('cleans up enabled comment mode and all window listeners when destroyed', () => { + const listeners = new Map(); + const addEventListener = jest.fn((eventType, handler) => { + if (!listeners.has(eventType)) { + listeners.set(eventType, new Set()); + } + listeners.get(eventType).add(handler); + }); + const removeEventListener = jest.fn((eventType, handler) => { + const handlers = listeners.get(eventType); + if (handlers) handlers.delete(handler); + }); + const dispatchMessage = (data) => { + const handlers = listeners.get('message') || new Set(); + handlers.forEach((handler) => handler({ data })); + }; + const postMessage = jest.fn(); + const styles = new Map(); + const rejected = jest.fn(); + + global.document = { + activeElement: null, + body: {}, + documentElement: {}, + head: { + appendChild: jest.fn((style) => styles.set(style.id, style)), + }, + createElement: jest.fn(() => ({ + remove: jest.fn(function remove() { + styles.delete(this.id); + }), + })), + getElementById: jest.fn((id) => styles.get(id) || null), + }; + global.window = { + self: {}, + parent: { postMessage }, + addEventListener, + removeEventListener, + }; + + const adapter = new IframeAdapter(); + const styleId = 'ai-comment-cursor-style'; + dispatchMessage({ type: POST_MESSAGE_TYPE.HTML_PAGE_ENABLE_COMMENT_MODE }); + adapter.pendingRequests = { + request: { timeoutId: 1, reject: rejected }, + }; + + expect(adapter.commentModeAdapter.isActive).toBe(true); + expect(styles.get(styleId)).toBeDefined(); + + adapter.destroy(); + + expect(rejected).toHaveBeenCalledWith(new Error('Adapter destroyed')); + expect(adapter.pendingRequests).toEqual({}); + expect(adapter.commentModeAdapter.isActive).toBe(false); + expect(styles.get(styleId)).toBeUndefined(); + expect(removeEventListener).toHaveBeenCalledWith('message', adapter._handleMessage); + adapter.interactiveEventTypes.forEach((eventType) => { + expect(removeEventListener).toHaveBeenCalledWith(eventType, adapter._windowEventHandler, true); + }); + expect(removeEventListener).toHaveBeenCalledWith('click', adapter.commentModeAdapter._handleEvent, true); + expect(removeEventListener).toHaveBeenCalledWith('scroll', adapter.commentModeAdapter._handleScroll, true); + + dispatchMessage({ type: POST_MESSAGE_TYPE.HTML_PAGE_ENABLE_COMMENT_MODE }); + expect(adapter.commentModeAdapter.isActive).toBe(false); + }); + + it('ignores malformed messages and rejects invalid response payloads', async () => { + jest.useFakeTimers(); + + const addEventListener = jest.fn(); + const removeEventListener = jest.fn(); + const postMessage = jest.fn(); + const target = { dispatchEvent: jest.fn() }; + + global.document = { + activeElement: null, + body: target, + elementFromPoint: jest.fn().mockReturnValue(target), + }; + global.window = { + self: {}, + parent: { postMessage }, + addEventListener, + removeEventListener, + }; + + const adapter = new IframeAdapter({ timeout: 1000 }); + + expect(() => adapter.handleMessage({ data: null })).not.toThrow(); + expect(() => adapter.handleMessage({ data: 'invalid' })).not.toThrow(); + expect(() => adapter.handleMessage({ data: [] })).not.toThrow(); + expect(() => adapter.handleMessage({ + data: { type: POST_MESSAGE_TYPE.WINDOW_EVENT }, + })).not.toThrow(); + + const requestPromise = adapter.request('get_server'); + const requestId = postMessage.mock.calls[0][0].requestId; + + adapter.handleMessage({ + data: { + type: POST_MESSAGE_TYPE.HTML_PAGE_RESPONSE, + requestId, + data: '{invalid-json', + }, + }); + + await expect(requestPromise).rejects.toThrow('Invalid response payload'); + expect(adapter.pendingRequests).toEqual({}); + }); + it('posts requests and resolves responses in iframe mode', async () => { jest.useFakeTimers();