diff --git a/src/comment-mode.js b/src/comment-mode.js
new file mode 100644
index 0000000..83b4750
--- /dev/null
+++ b/src/comment-mode.js
@@ -0,0 +1,205 @@
+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 !== null) cancelAnimationFrame(this._scrollRAF);
+ if (this._hoverRAF !== null) cancelAnimationFrame(this._hoverRAF);
+ this._scrollRAF = null;
+ this._hoverRAF = null;
+ this.removeCommentStyle();
+ }
+
+ _handleScroll() {
+ if (!this.isActive) return;
+
+ if (this._scrollRAF !== null) 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 === '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 !== null) 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..2726835 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,9 @@ export class IframeAdapter {
this.pendingRequests = {};
this.eventHandlers = {};
this.timeout = this.options.timeout || 10000;
+ this.isCommentMode = false;
+ this.commentModeAdapter = new CommentModeAdapter();
+ this._handleMessage = this.handleMessage.bind(this);
this.setupMessageListener();
}
@@ -93,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();
}
@@ -111,46 +110,61 @@ 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();
+ }
- // Low-frequency events
- this.postWindowEvent(createWindowEventData({ eventType, event }));
- }, true);
+ bindInteractiveEvents() {
+ this.interactiveEventTypes.forEach(eventType => {
+ window.addEventListener(eventType, this._windowEventHandler, 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) {
if (this.selfWindow) {
return new Promise((resolve) => {
@@ -185,7 +199,10 @@ export class IframeAdapter {
}
handleMessage(event) {
- const { type, requestId, data, error, eventType, payload } = event.data;
+ 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) {
@@ -194,67 +211,86 @@ 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) {
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 && 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)) {
+ 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) {
@@ -288,7 +324,17 @@ 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'));
});
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}${tagName.toLowerCase()}>`,
+ 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();