Social Annotations in the Atmosphere
15
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: floating annotation scroll position not being updated

+743 -3
+243
packages/core/src/content/__tests__/extension.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 + import { ExtensionContentScript } from '../extension'; 3 + 4 + // Mock browser API 5 + const mockBrowser = { 6 + runtime: { 7 + sendMessage: vi.fn().mockResolvedValue(undefined), 8 + onMessage: { 9 + addListener: vi.fn(), 10 + }, 11 + }, 12 + }; 13 + 14 + // @ts-expect-error - browser is defined globally in extension 15 + globalThis.browser = mockBrowser; 16 + 17 + describe('ExtensionContentScript', () => { 18 + let contentScript: ExtensionContentScript; 19 + let mockStorage: { 20 + get: ReturnType<typeof vi.fn>; 21 + set: ReturnType<typeof vi.fn>; 22 + onChange: ReturnType<typeof vi.fn>; 23 + }; 24 + let mockApplyHighlights: ReturnType<typeof vi.fn>; 25 + let mockClearHighlights: ReturnType<typeof vi.fn>; 26 + let mockGenerateSelectors: ReturnType<typeof vi.fn>; 27 + 28 + beforeEach(() => { 29 + vi.useFakeTimers(); 30 + vi.spyOn(console, 'log').mockImplementation(() => {}); 31 + vi.spyOn(console, 'error').mockImplementation(() => {}); 32 + 33 + mockStorage = { 34 + get: vi.fn().mockResolvedValue([]), 35 + set: vi.fn().mockResolvedValue(undefined), 36 + onChange: vi.fn(), 37 + }; 38 + 39 + mockApplyHighlights = vi.fn(); 40 + mockClearHighlights = vi.fn(); 41 + mockGenerateSelectors = vi.fn().mockReturnValue([{ type: 'TextQuoteSelector' }]); 42 + 43 + mockBrowser.runtime.sendMessage.mockClear(); 44 + mockBrowser.runtime.onMessage.addListener.mockClear(); 45 + }); 46 + 47 + afterEach(() => { 48 + vi.useRealTimers(); 49 + vi.restoreAllMocks(); 50 + // Clean up any buttons left in the DOM 51 + document.querySelectorAll('.seams-mobile-annotate-btn').forEach((el) => el.remove()); 52 + }); 53 + 54 + describe('floating button with scroll tracking', () => { 55 + it('passes Range to showButton for scroll tracking', async () => { 56 + contentScript = new ExtensionContentScript({ 57 + storage: mockStorage as any, 58 + applyHighlights: mockApplyHighlights, 59 + clearHighlights: mockClearHighlights, 60 + generateSelectors: mockGenerateSelectors, 61 + showFloatingButton: true, 62 + }); 63 + 64 + await contentScript.start(); 65 + 66 + // Create a mock selection with a range 67 + const mockRange = document.createRange(); 68 + const textNode = document.createTextNode('Hello World'); 69 + document.body.appendChild(textNode); 70 + mockRange.selectNodeContents(textNode); 71 + 72 + const mockSelection = { 73 + toString: () => 'Hello World', 74 + getRangeAt: vi.fn().mockReturnValue(mockRange), 75 + rangeCount: 1, 76 + } as unknown as Selection; 77 + vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); 78 + 79 + // Trigger selection change 80 + document.dispatchEvent(new Event('selectionchange')); 81 + vi.advanceTimersByTime(200); 82 + 83 + // The button should be created 84 + const button = document.querySelector('.seams-mobile-annotate-btn'); 85 + expect(button).not.toBeNull(); 86 + 87 + // Verify scroll listener was attached (indicates Range was passed) 88 + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); 89 + 90 + // Trigger another selection to verify scroll tracking is set up 91 + document.dispatchEvent(new Event('selectionchange')); 92 + vi.advanceTimersByTime(200); 93 + 94 + // Check that scroll listener exists by triggering scroll and checking button updates 95 + // The button position should update on scroll 96 + const scrolledRect = new DOMRect(100, 300, 200, 20); 97 + mockRange.getBoundingClientRect = vi.fn().mockReturnValue(scrolledRect); 98 + 99 + window.dispatchEvent(new Event('scroll')); 100 + 101 + const buttonAfterScroll = document.querySelector('.seams-mobile-annotate-btn') as HTMLElement; 102 + // Button should have updated position (300 + 20 + 8 = 328) 103 + expect(buttonAfterScroll.style.top).toBe('328px'); 104 + 105 + // Cleanup 106 + textNode.remove(); 107 + addEventListenerSpy.mockRestore(); 108 + }); 109 + 110 + it('hides button when selection scrolls off screen', async () => { 111 + contentScript = new ExtensionContentScript({ 112 + storage: mockStorage as any, 113 + applyHighlights: mockApplyHighlights, 114 + clearHighlights: mockClearHighlights, 115 + generateSelectors: mockGenerateSelectors, 116 + showFloatingButton: true, 117 + }); 118 + 119 + await contentScript.start(); 120 + 121 + // Set viewport height 122 + vi.stubGlobal('innerHeight', 800); 123 + 124 + // Create a mock selection 125 + const mockRange = document.createRange(); 126 + const textNode = document.createTextNode('Test text'); 127 + document.body.appendChild(textNode); 128 + mockRange.selectNodeContents(textNode); 129 + mockRange.getBoundingClientRect = vi.fn().mockReturnValue(new DOMRect(100, 50, 200, 20)); 130 + 131 + const mockSelection = { 132 + toString: () => 'Test text', 133 + getRangeAt: vi.fn().mockReturnValue(mockRange), 134 + rangeCount: 1, 135 + } as unknown as Selection; 136 + vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); 137 + 138 + // Trigger selection change 139 + document.dispatchEvent(new Event('selectionchange')); 140 + vi.advanceTimersByTime(200); 141 + 142 + const button = document.querySelector('.seams-mobile-annotate-btn') as HTMLElement; 143 + expect(button).not.toBeNull(); 144 + expect(button.style.display).not.toBe('none'); 145 + 146 + // Simulate scrolling selection off screen (above viewport) 147 + (mockRange.getBoundingClientRect as ReturnType<typeof vi.fn>).mockReturnValue( 148 + new DOMRect(100, -100, 200, 20) 149 + ); 150 + window.dispatchEvent(new Event('scroll')); 151 + 152 + expect(button.style.display).toBe('none'); 153 + 154 + // Cleanup 155 + textNode.remove(); 156 + vi.unstubAllGlobals(); 157 + }); 158 + 159 + it('does not create button when showFloatingButton is false', async () => { 160 + contentScript = new ExtensionContentScript({ 161 + storage: mockStorage as any, 162 + applyHighlights: mockApplyHighlights, 163 + clearHighlights: mockClearHighlights, 164 + generateSelectors: mockGenerateSelectors, 165 + showFloatingButton: false, 166 + }); 167 + 168 + await contentScript.start(); 169 + 170 + // Create a mock selection 171 + const mockSelection = { 172 + toString: () => 'Hello World', 173 + getRangeAt: vi.fn(), 174 + rangeCount: 1, 175 + } as unknown as Selection; 176 + vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); 177 + 178 + mockGenerateSelectors.mockReturnValue([{ type: 'TextQuoteSelector' }]); 179 + 180 + // Trigger selection change 181 + document.dispatchEvent(new Event('selectionchange')); 182 + vi.advanceTimersByTime(200); 183 + 184 + // No button should be created 185 + const button = document.querySelector('.seams-mobile-annotate-btn'); 186 + expect(button).toBeNull(); 187 + }); 188 + 189 + it('removes button and cleans up scroll listener when selection is cleared', async () => { 190 + contentScript = new ExtensionContentScript({ 191 + storage: mockStorage as any, 192 + applyHighlights: mockApplyHighlights, 193 + clearHighlights: mockClearHighlights, 194 + generateSelectors: mockGenerateSelectors, 195 + showFloatingButton: true, 196 + }); 197 + 198 + await contentScript.start(); 199 + 200 + // Create initial selection 201 + const mockRange = document.createRange(); 202 + const textNode = document.createTextNode('Hello World'); 203 + document.body.appendChild(textNode); 204 + mockRange.selectNodeContents(textNode); 205 + 206 + let mockSelection: Selection | null = { 207 + toString: () => 'Hello World', 208 + getRangeAt: vi.fn().mockReturnValue(mockRange), 209 + rangeCount: 1, 210 + } as unknown as Selection; 211 + const getSelectionSpy = vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); 212 + 213 + // Trigger selection 214 + document.dispatchEvent(new Event('selectionchange')); 215 + vi.advanceTimersByTime(200); 216 + 217 + expect(document.querySelector('.seams-mobile-annotate-btn')).not.toBeNull(); 218 + 219 + // Clear selection 220 + mockSelection = { 221 + toString: () => '', 222 + getRangeAt: vi.fn(), 223 + rangeCount: 0, 224 + } as unknown as Selection; 225 + getSelectionSpy.mockReturnValue(mockSelection); 226 + 227 + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); 228 + 229 + document.dispatchEvent(new Event('selectionchange')); 230 + vi.advanceTimersByTime(200); 231 + 232 + // Button should be removed 233 + expect(document.querySelector('.seams-mobile-annotate-btn')).toBeNull(); 234 + 235 + // Scroll listener should have been cleaned up 236 + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); 237 + 238 + // Cleanup 239 + textNode.remove(); 240 + removeEventListenerSpy.mockRestore(); 241 + }); 242 + }); 243 + });
+290
packages/core/src/content/__tests__/proxy.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 + import { ProxyContentScript } from '../proxy'; 3 + 4 + describe('ProxyContentScript', () => { 5 + let contentScript: ProxyContentScript; 6 + let mockStorage: { 7 + get: ReturnType<typeof vi.fn>; 8 + set: ReturnType<typeof vi.fn>; 9 + onChange: ReturnType<typeof vi.fn>; 10 + }; 11 + let mockApplyHighlights: ReturnType<typeof vi.fn>; 12 + let mockClearHighlights: ReturnType<typeof vi.fn>; 13 + let mockGenerateSelectors: ReturnType<typeof vi.fn>; 14 + let mockOnAnnotate: ReturnType<typeof vi.fn>; 15 + let mockOnSelectionChange: ReturnType<typeof vi.fn>; 16 + 17 + beforeEach(() => { 18 + vi.useFakeTimers(); 19 + vi.spyOn(console, 'log').mockImplementation(() => {}); 20 + vi.spyOn(console, 'error').mockImplementation(() => {}); 21 + 22 + mockStorage = { 23 + get: vi.fn().mockResolvedValue([]), 24 + set: vi.fn().mockResolvedValue(undefined), 25 + onChange: vi.fn(), 26 + }; 27 + 28 + mockApplyHighlights = vi.fn(); 29 + mockClearHighlights = vi.fn(); 30 + mockGenerateSelectors = vi.fn().mockReturnValue([{ type: 'TextQuoteSelector' }]); 31 + mockOnAnnotate = vi.fn(); 32 + mockOnSelectionChange = vi.fn(); 33 + }); 34 + 35 + afterEach(() => { 36 + vi.useRealTimers(); 37 + vi.restoreAllMocks(); 38 + // Clean up any buttons left in the DOM 39 + document.querySelectorAll('.seams-mobile-annotate-btn').forEach((el) => el.remove()); 40 + }); 41 + 42 + describe('floating button with scroll tracking', () => { 43 + it('passes Range to showButton for scroll tracking', async () => { 44 + contentScript = new ProxyContentScript({ 45 + storage: mockStorage as any, 46 + getCurrentUrl: () => 'https://example.com/page', 47 + applyHighlights: mockApplyHighlights, 48 + clearHighlights: mockClearHighlights, 49 + generateSelectors: mockGenerateSelectors, 50 + onAnnotate: mockOnAnnotate, 51 + onSelectionChange: mockOnSelectionChange, 52 + }); 53 + 54 + await contentScript.start(); 55 + 56 + // Create a mock selection with a range 57 + const mockRange = document.createRange(); 58 + const textNode = document.createTextNode('Hello World'); 59 + document.body.appendChild(textNode); 60 + mockRange.selectNodeContents(textNode); 61 + 62 + const mockSelection = { 63 + toString: () => 'Hello World', 64 + getRangeAt: vi.fn().mockReturnValue(mockRange), 65 + rangeCount: 1, 66 + } as unknown as Selection; 67 + vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); 68 + 69 + // Trigger selection change 70 + document.dispatchEvent(new Event('selectionchange')); 71 + vi.advanceTimersByTime(200); 72 + 73 + // The button should be created 74 + const button = document.querySelector('.seams-mobile-annotate-btn'); 75 + expect(button).not.toBeNull(); 76 + 77 + // Verify scroll tracking works by checking button position updates on scroll 78 + const scrolledRect = new DOMRect(100, 300, 200, 20); 79 + mockRange.getBoundingClientRect = vi.fn().mockReturnValue(scrolledRect); 80 + 81 + window.dispatchEvent(new Event('scroll')); 82 + 83 + const buttonAfterScroll = document.querySelector('.seams-mobile-annotate-btn') as HTMLElement; 84 + // Button should have updated position (300 + 20 + 8 = 328) 85 + expect(buttonAfterScroll.style.top).toBe('328px'); 86 + 87 + // Cleanup 88 + textNode.remove(); 89 + }); 90 + 91 + it('hides button when selection scrolls off screen', async () => { 92 + contentScript = new ProxyContentScript({ 93 + storage: mockStorage as any, 94 + getCurrentUrl: () => 'https://example.com/page', 95 + applyHighlights: mockApplyHighlights, 96 + clearHighlights: mockClearHighlights, 97 + generateSelectors: mockGenerateSelectors, 98 + onAnnotate: mockOnAnnotate, 99 + }); 100 + 101 + await contentScript.start(); 102 + 103 + // Set viewport height 104 + vi.stubGlobal('innerHeight', 800); 105 + 106 + // Create a mock selection 107 + const mockRange = document.createRange(); 108 + const textNode = document.createTextNode('Test text'); 109 + document.body.appendChild(textNode); 110 + mockRange.selectNodeContents(textNode); 111 + mockRange.getBoundingClientRect = vi.fn().mockReturnValue(new DOMRect(100, 50, 200, 20)); 112 + 113 + const mockSelection = { 114 + toString: () => 'Test text', 115 + getRangeAt: vi.fn().mockReturnValue(mockRange), 116 + rangeCount: 1, 117 + } as unknown as Selection; 118 + vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); 119 + 120 + // Trigger selection change 121 + document.dispatchEvent(new Event('selectionchange')); 122 + vi.advanceTimersByTime(200); 123 + 124 + const button = document.querySelector('.seams-mobile-annotate-btn') as HTMLElement; 125 + expect(button).not.toBeNull(); 126 + expect(button.style.display).not.toBe('none'); 127 + 128 + // Simulate scrolling selection off screen (below viewport) 129 + (mockRange.getBoundingClientRect as ReturnType<typeof vi.fn>).mockReturnValue( 130 + new DOMRect(100, 900, 200, 20) 131 + ); 132 + window.dispatchEvent(new Event('scroll')); 133 + 134 + expect(button.style.display).toBe('none'); 135 + 136 + // Cleanup 137 + textNode.remove(); 138 + vi.unstubAllGlobals(); 139 + }); 140 + 141 + it('shows button again when selection scrolls back into view', async () => { 142 + contentScript = new ProxyContentScript({ 143 + storage: mockStorage as any, 144 + getCurrentUrl: () => 'https://example.com/page', 145 + applyHighlights: mockApplyHighlights, 146 + clearHighlights: mockClearHighlights, 147 + generateSelectors: mockGenerateSelectors, 148 + onAnnotate: mockOnAnnotate, 149 + }); 150 + 151 + await contentScript.start(); 152 + 153 + vi.stubGlobal('innerHeight', 800); 154 + 155 + // Create a mock selection 156 + const mockRange = document.createRange(); 157 + const textNode = document.createTextNode('Test text'); 158 + document.body.appendChild(textNode); 159 + mockRange.selectNodeContents(textNode); 160 + mockRange.getBoundingClientRect = vi.fn().mockReturnValue(new DOMRect(100, 50, 200, 20)); 161 + 162 + const mockSelection = { 163 + toString: () => 'Test text', 164 + getRangeAt: vi.fn().mockReturnValue(mockRange), 165 + rangeCount: 1, 166 + } as unknown as Selection; 167 + vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); 168 + 169 + // Trigger selection change 170 + document.dispatchEvent(new Event('selectionchange')); 171 + vi.advanceTimersByTime(200); 172 + 173 + const button = document.querySelector('.seams-mobile-annotate-btn') as HTMLElement; 174 + 175 + // Scroll off screen 176 + (mockRange.getBoundingClientRect as ReturnType<typeof vi.fn>).mockReturnValue( 177 + new DOMRect(100, -100, 200, 20) 178 + ); 179 + window.dispatchEvent(new Event('scroll')); 180 + expect(button.style.display).toBe('none'); 181 + 182 + // Scroll back into view 183 + (mockRange.getBoundingClientRect as ReturnType<typeof vi.fn>).mockReturnValue( 184 + new DOMRect(100, 200, 200, 20) 185 + ); 186 + window.dispatchEvent(new Event('scroll')); 187 + 188 + expect(button.style.display).toBe('block'); 189 + expect(button.style.top).toBe('228px'); // 200 + 20 + 8 = 228 190 + 191 + // Cleanup 192 + textNode.remove(); 193 + vi.unstubAllGlobals(); 194 + }); 195 + 196 + it('removes button and cleans up scroll listener when selection is cleared', async () => { 197 + contentScript = new ProxyContentScript({ 198 + storage: mockStorage as any, 199 + getCurrentUrl: () => 'https://example.com/page', 200 + applyHighlights: mockApplyHighlights, 201 + clearHighlights: mockClearHighlights, 202 + generateSelectors: mockGenerateSelectors, 203 + onAnnotate: mockOnAnnotate, 204 + }); 205 + 206 + await contentScript.start(); 207 + 208 + // Create initial selection 209 + const mockRange = document.createRange(); 210 + const textNode = document.createTextNode('Hello World'); 211 + document.body.appendChild(textNode); 212 + mockRange.selectNodeContents(textNode); 213 + 214 + let mockSelection: Selection | null = { 215 + toString: () => 'Hello World', 216 + getRangeAt: vi.fn().mockReturnValue(mockRange), 217 + rangeCount: 1, 218 + } as unknown as Selection; 219 + const getSelectionSpy = vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); 220 + 221 + // Trigger selection 222 + document.dispatchEvent(new Event('selectionchange')); 223 + vi.advanceTimersByTime(200); 224 + 225 + expect(document.querySelector('.seams-mobile-annotate-btn')).not.toBeNull(); 226 + 227 + // Clear selection 228 + mockSelection = { 229 + toString: () => '', 230 + getRangeAt: vi.fn(), 231 + rangeCount: 0, 232 + } as unknown as Selection; 233 + getSelectionSpy.mockReturnValue(mockSelection); 234 + 235 + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); 236 + 237 + document.dispatchEvent(new Event('selectionchange')); 238 + vi.advanceTimersByTime(200); 239 + 240 + // Button should be removed 241 + expect(document.querySelector('.seams-mobile-annotate-btn')).toBeNull(); 242 + 243 + // Scroll listener should have been cleaned up 244 + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); 245 + 246 + // Cleanup 247 + textNode.remove(); 248 + removeEventListenerSpy.mockRestore(); 249 + }); 250 + 251 + it('calls onSelectionChange callback when selection changes', async () => { 252 + contentScript = new ProxyContentScript({ 253 + storage: mockStorage as any, 254 + getCurrentUrl: () => 'https://example.com/page', 255 + applyHighlights: mockApplyHighlights, 256 + clearHighlights: mockClearHighlights, 257 + generateSelectors: mockGenerateSelectors, 258 + onAnnotate: mockOnAnnotate, 259 + onSelectionChange: mockOnSelectionChange, 260 + }); 261 + 262 + await contentScript.start(); 263 + 264 + const mockRange = document.createRange(); 265 + const textNode = document.createTextNode('Hello World'); 266 + document.body.appendChild(textNode); 267 + mockRange.selectNodeContents(textNode); 268 + 269 + const mockSelection = { 270 + toString: () => 'Hello World', 271 + getRangeAt: vi.fn().mockReturnValue(mockRange), 272 + rangeCount: 1, 273 + } as unknown as Selection; 274 + vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection); 275 + 276 + document.dispatchEvent(new Event('selectionchange')); 277 + vi.advanceTimersByTime(200); 278 + 279 + expect(mockOnSelectionChange).toHaveBeenCalledWith({ 280 + text: 'Hello World', 281 + selectors: [{ type: 'TextQuoteSelector' }], 282 + }); 283 + 284 + // Cleanup 285 + textNode.remove(); 286 + }); 287 + 288 + 289 + }); 290 + });
+159
packages/core/src/content/__tests__/ui.test.ts
··· 1 1 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 2 import { AnnotationUIManager } from '../ui'; 3 3 4 + // Helper to create a mock Range with controllable getBoundingClientRect 5 + function createMockRange(rect: DOMRect): Range { 6 + const range = document.createRange(); 7 + // Override getBoundingClientRect to return our controlled rect 8 + range.getBoundingClientRect = vi.fn(() => rect); 9 + return range; 10 + } 11 + 4 12 describe('AnnotationUIManager', () => { 5 13 let manager: AnnotationUIManager; 6 14 let onAnnotate: ReturnType<typeof vi.fn>; ··· 11 19 onAnnotate, 12 20 isMobile: true, 13 21 }); 22 + // Set viewport height for visibility tests 23 + vi.stubGlobal('innerHeight', 800); 14 24 }); 15 25 16 26 afterEach(() => { 17 27 // Clean up any buttons left in the DOM 18 28 document.querySelectorAll('.seams-mobile-annotate-btn').forEach((el) => el.remove()); 29 + vi.unstubAllGlobals(); 19 30 }); 20 31 21 32 describe('showButton', () => { ··· 95 106 manager.removeButton(); 96 107 97 108 expect(document.querySelector('.seams-mobile-annotate-btn')).toBeNull(); 109 + }); 110 + 111 + it('removes scroll event listener when button is removed', () => { 112 + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); 113 + const range = createMockRange(new DOMRect(100, 50, 200, 20)); 114 + 115 + manager.showButton(new DOMRect(100, 50, 200, 20), 'Text', [], range); 116 + manager.removeButton(); 117 + 118 + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); 119 + removeEventListenerSpy.mockRestore(); 120 + }); 121 + }); 122 + 123 + describe('scroll tracking', () => { 124 + it('attaches scroll event listener when button is shown with range', () => { 125 + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); 126 + const range = createMockRange(new DOMRect(100, 50, 200, 20)); 127 + 128 + manager.showButton(new DOMRect(100, 50, 200, 20), 'Text', [], range); 129 + 130 + expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); 131 + addEventListenerSpy.mockRestore(); 132 + }); 133 + 134 + it('updates button position when scroll event fires', () => { 135 + // Initial position 136 + const initialRect = new DOMRect(100, 50, 200, 20); 137 + const range = createMockRange(initialRect); 138 + 139 + manager.showButton(initialRect, 'Text', [], range); 140 + 141 + const button = document.querySelector('.seams-mobile-annotate-btn') as HTMLElement; 142 + expect(button.style.top).toBe('78px'); // 50 + 20 + 8 = 78 143 + 144 + // Simulate scroll - selection moves up in viewport 145 + const scrolledRect = new DOMRect(100, 150, 200, 20); 146 + (range.getBoundingClientRect as ReturnType<typeof vi.fn>).mockReturnValue(scrolledRect); 147 + 148 + // Fire scroll event 149 + window.dispatchEvent(new Event('scroll')); 150 + 151 + // Button should update to new position 152 + expect(button.style.top).toBe('178px'); // 150 + 20 + 8 = 178 153 + }); 154 + 155 + it('hides button when selection scrolls above viewport', () => { 156 + const initialRect = new DOMRect(100, 50, 200, 20); 157 + const range = createMockRange(initialRect); 158 + 159 + manager.showButton(initialRect, 'Text', [], range); 160 + 161 + const button = document.querySelector('.seams-mobile-annotate-btn') as HTMLElement; 162 + expect(button.style.display).not.toBe('none'); 163 + 164 + // Simulate scroll - selection moves above viewport (bottom < 0) 165 + const scrolledRect = new DOMRect(100, -100, 200, 20); 166 + (range.getBoundingClientRect as ReturnType<typeof vi.fn>).mockReturnValue(scrolledRect); 167 + 168 + window.dispatchEvent(new Event('scroll')); 169 + 170 + expect(button.style.display).toBe('none'); 171 + }); 172 + 173 + it('hides button when selection scrolls below viewport', () => { 174 + const initialRect = new DOMRect(100, 50, 200, 20); 175 + const range = createMockRange(initialRect); 176 + 177 + manager.showButton(initialRect, 'Text', [], range); 178 + 179 + const button = document.querySelector('.seams-mobile-annotate-btn') as HTMLElement; 180 + expect(button.style.display).not.toBe('none'); 181 + 182 + // Simulate scroll - selection moves below viewport (bottom > innerHeight) 183 + const scrolledRect = new DOMRect(100, 850, 200, 20); 184 + (range.getBoundingClientRect as ReturnType<typeof vi.fn>).mockReturnValue(scrolledRect); 185 + 186 + window.dispatchEvent(new Event('scroll')); 187 + 188 + expect(button.style.display).toBe('none'); 189 + }); 190 + 191 + it('shows button again when selection scrolls back into viewport', () => { 192 + const initialRect = new DOMRect(100, 50, 200, 20); 193 + const range = createMockRange(initialRect); 194 + 195 + manager.showButton(initialRect, 'Text', [], range); 196 + 197 + const button = document.querySelector('.seams-mobile-annotate-btn') as HTMLElement; 198 + 199 + // Scroll out of view 200 + const offScreenRect = new DOMRect(100, -100, 200, 20); 201 + (range.getBoundingClientRect as ReturnType<typeof vi.fn>).mockReturnValue(offScreenRect); 202 + window.dispatchEvent(new Event('scroll')); 203 + expect(button.style.display).toBe('none'); 204 + 205 + // Scroll back into view 206 + const backInViewRect = new DOMRect(100, 200, 200, 20); 207 + (range.getBoundingClientRect as ReturnType<typeof vi.fn>).mockReturnValue(backInViewRect); 208 + window.dispatchEvent(new Event('scroll')); 209 + 210 + expect(button.style.display).toBe('block'); 211 + expect(button.style.top).toBe('228px'); // 200 + 20 + 8 = 228 212 + }); 213 + 214 + it('updates position on resize events', () => { 215 + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); 216 + const initialRect = new DOMRect(100, 50, 200, 20); 217 + const range = createMockRange(initialRect); 218 + 219 + manager.showButton(initialRect, 'Text', [], range); 220 + 221 + expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); 222 + addEventListenerSpy.mockRestore(); 223 + }); 224 + 225 + it('removes resize event listener when button is removed', () => { 226 + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); 227 + const range = createMockRange(new DOMRect(100, 50, 200, 20)); 228 + 229 + manager.showButton(new DOMRect(100, 50, 200, 20), 'Text', [], range); 230 + manager.removeButton(); 231 + 232 + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); 233 + removeEventListenerSpy.mockRestore(); 234 + }); 235 + 236 + it('works without range (backwards compatibility)', () => { 237 + // Should not throw when called without range 238 + expect(() => { 239 + manager.showButton(new DOMRect(100, 50, 200, 20), 'Text', []); 240 + }).not.toThrow(); 241 + 242 + const button = document.querySelector('.seams-mobile-annotate-btn'); 243 + expect(button).not.toBeNull(); 244 + }); 245 + 246 + it('cleans up old scroll listener when showing new button', () => { 247 + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); 248 + const range1 = createMockRange(new DOMRect(100, 50, 200, 20)); 249 + const range2 = createMockRange(new DOMRect(200, 100, 200, 20)); 250 + 251 + manager.showButton(new DOMRect(100, 50, 200, 20), 'First', [], range1); 252 + manager.showButton(new DOMRect(200, 100, 200, 20), 'Second', [], range2); 253 + 254 + // Old listener should have been removed 255 + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); 256 + removeEventListenerSpy.mockRestore(); 98 257 }); 99 258 }); 100 259 });
+2 -1
packages/core/src/content/extension.ts
··· 45 45 if (domSelection && domSelection.rangeCount > 0) { 46 46 const range = domSelection.getRangeAt(0); 47 47 const rect = range.getBoundingClientRect(); 48 - ui.showButton(rect, selection.text, selection.selectors); 48 + // Pass range for scroll tracking - button will follow selection 49 + ui.showButton(rect, selection.text, selection.selectors, range); 49 50 } 50 51 } else { 51 52 ui.removeButton();
+2 -1
packages/core/src/content/proxy.ts
··· 41 41 if (domSelection && domSelection.rangeCount > 0) { 42 42 const range = domSelection.getRangeAt(0); 43 43 const rect = range.getBoundingClientRect(); 44 - this.uiManager.showButton(rect, selection.text, selection.selectors); 44 + // Pass range for scroll tracking - button will follow selection 45 + this.uiManager.showButton(rect, selection.text, selection.selectors, range); 45 46 } 46 47 } else { 47 48 this.uiManager.removeButton();
+47 -1
packages/core/src/content/ui.ts
··· 7 7 8 8 export class AnnotationUIManager { 9 9 private activeBtn: HTMLElement | null = null; 10 + private activeRange: Range | null = null; 11 + private scrollHandler: (() => void) | null = null; 12 + private resizeHandler: (() => void) | null = null; 10 13 11 14 constructor(private options: AnnotationUIOptions) {} 12 15 13 - showButton(rect: DOMRect, text: string, selectors: any[]) { 16 + showButton(rect: DOMRect, text: string, selectors: any[], range?: Range) { 14 17 this.removeButton(); 15 18 16 19 this.activeBtn = createMobileAnnotateButton( ··· 21 24 this.removeButton(); 22 25 } 23 26 ); 27 + 28 + // If a range is provided, set up scroll tracking 29 + if (range) { 30 + this.activeRange = range; 31 + this.setupScrollTracking(); 32 + } 33 + } 34 + 35 + private setupScrollTracking() { 36 + this.scrollHandler = () => this.updateButtonPosition(); 37 + this.resizeHandler = () => this.updateButtonPosition(); 38 + 39 + // Use capture phase to catch scroll events from any scrollable container 40 + window.addEventListener('scroll', this.scrollHandler, true); 41 + window.addEventListener('resize', this.resizeHandler); 42 + } 43 + 44 + private updateButtonPosition() { 45 + if (!this.activeRange || !this.activeBtn) return; 46 + 47 + const rect = this.activeRange.getBoundingClientRect(); 48 + 49 + // Check if selection bottom is within viewport 50 + const isVisible = rect.bottom > 0 && rect.bottom < window.innerHeight; 51 + 52 + if (isVisible) { 53 + this.activeBtn.style.top = `${rect.bottom + 8}px`; 54 + this.activeBtn.style.left = `${rect.left + rect.width / 2}px`; 55 + this.activeBtn.style.display = 'block'; 56 + } else { 57 + this.activeBtn.style.display = 'none'; 58 + } 24 59 } 25 60 26 61 removeButton() { 62 + // Clean up scroll/resize listeners 63 + if (this.scrollHandler) { 64 + window.removeEventListener('scroll', this.scrollHandler, true); 65 + this.scrollHandler = null; 66 + } 67 + if (this.resizeHandler) { 68 + window.removeEventListener('resize', this.resizeHandler); 69 + this.resizeHandler = null; 70 + } 71 + this.activeRange = null; 72 + 27 73 if (this.activeBtn) { 28 74 this.activeBtn.remove(); 29 75 this.activeBtn = null;