Social Annotations in the Atmosphere
15
fork

Configure Feed

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

fix: annotation form persists when selection is lost

The annotation box in the sidebar now stays visible when the user clicks
elsewhere on the page (losing text selection). This prevents the form from
disappearing unexpectedly when clicking buttons or composing notes.

Behavior:
- New valid selection: form updates with new selection
- Selection cleared (null): form keeps old selection (sticky)
- Clear button clicked: form explicitly closes
- Save successful/failed: existing behavior preserved

Added unit tests for selection persistence and E2E tests for annotation
form behavior.

+489 -2
+263
packages/core/src/sidebar/__tests__/selection.test.ts
··· 1 + import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 2 + import { Sidebar } from '../index'; 3 + import type { StorageAdapter } from '../../storage'; 4 + import type { OAuthLauncher, OAuthConfig } from '../../oauth'; 5 + 6 + // Mock the components registration 7 + vi.mock('../../components', () => ({ 8 + registerComponents: vi.fn(), 9 + })); 10 + 11 + // Mock actor-typeahead 12 + vi.mock('actor-typeahead', () => ({})); 13 + 14 + describe('Sidebar selection persistence', () => { 15 + let sidebar: Sidebar; 16 + let container: HTMLElement; 17 + let mockStorage: StorageAdapter; 18 + let mockLauncher: OAuthLauncher; 19 + let mockConfig: { oauth: OAuthConfig; pds: { backendUrl: string } }; 20 + 21 + beforeEach(() => { 22 + // Set up DOM container 23 + container = document.createElement('div'); 24 + document.body.appendChild(container); 25 + 26 + // Mock storage adapter 27 + mockStorage = { 28 + get: vi.fn().mockResolvedValue({}), 29 + set: vi.fn().mockResolvedValue(undefined), 30 + onChange: vi.fn(), 31 + } as unknown as StorageAdapter; 32 + 33 + // Mock OAuth launcher 34 + mockLauncher = { 35 + openAuthWindow: vi.fn(), 36 + }; 37 + 38 + // Mock config 39 + mockConfig = { 40 + oauth: { 41 + clientId: 'test-client', 42 + redirectUri: 'http://localhost/callback', 43 + }, 44 + pds: { 45 + backendUrl: 'http://localhost:8080', 46 + }, 47 + }; 48 + 49 + // Create sidebar instance 50 + sidebar = new Sidebar(container, mockStorage, mockLauncher, mockConfig); 51 + }); 52 + 53 + afterEach(() => { 54 + document.body.removeChild(container); 55 + vi.restoreAllMocks(); 56 + }); 57 + 58 + describe('setSelection', () => { 59 + it('shows annotation form when selection is provided', async () => { 60 + // Wait for initial render 61 + await vi.waitFor(() => { 62 + expect(container.querySelector('#annotation-form')).not.toBeNull(); 63 + }); 64 + 65 + const selection = { 66 + text: 'Selected text', 67 + selectors: [{ type: 'TextQuoteSelector', exact: 'Selected text' }], 68 + }; 69 + 70 + sidebar.setSelection(selection); 71 + 72 + const form = container.querySelector('#annotation-form') as HTMLElement; 73 + expect(form.style.display).toBe('block'); 74 + 75 + const selectedText = container.querySelector('#selected-text'); 76 + expect(selectedText?.innerHTML).toContain('Selected text'); 77 + }); 78 + 79 + it('does NOT clear annotation form when selection is null (sticky behavior)', async () => { 80 + // Wait for initial render 81 + await vi.waitFor(() => { 82 + expect(container.querySelector('#annotation-form')).not.toBeNull(); 83 + }); 84 + 85 + // First, set a valid selection 86 + const selection = { 87 + text: 'Selected text', 88 + selectors: [{ type: 'TextQuoteSelector', exact: 'Selected text' }], 89 + }; 90 + sidebar.setSelection(selection); 91 + 92 + // Verify form is shown 93 + const form = container.querySelector('#annotation-form') as HTMLElement; 94 + expect(form.style.display).toBe('block'); 95 + 96 + // Now set selection to null (simulating user clicking elsewhere) 97 + sidebar.setSelection(null); 98 + 99 + // Form should STILL be visible (sticky behavior) 100 + expect(form.style.display).toBe('block'); 101 + 102 + // Selected text should still be present 103 + const selectedText = container.querySelector('#selected-text'); 104 + expect(selectedText?.innerHTML).toContain('Selected text'); 105 + }); 106 + 107 + it('updates form when a new valid selection is provided', async () => { 108 + // Wait for initial render 109 + await vi.waitFor(() => { 110 + expect(container.querySelector('#annotation-form')).not.toBeNull(); 111 + }); 112 + 113 + // Set first selection 114 + sidebar.setSelection({ 115 + text: 'First selection', 116 + selectors: [{ type: 'TextQuoteSelector', exact: 'First selection' }], 117 + }); 118 + 119 + // Verify first selection 120 + let selectedText = container.querySelector('#selected-text'); 121 + expect(selectedText?.innerHTML).toContain('First selection'); 122 + 123 + // Set second selection 124 + sidebar.setSelection({ 125 + text: 'Second selection', 126 + selectors: [{ type: 'TextQuoteSelector', exact: 'Second selection' }], 127 + }); 128 + 129 + // Verify it updated to second selection 130 + selectedText = container.querySelector('#selected-text'); 131 + expect(selectedText?.innerHTML).toContain('Second selection'); 132 + expect(selectedText?.innerHTML).not.toContain('First selection'); 133 + }); 134 + 135 + it('ignores empty text selections', async () => { 136 + // Wait for initial render 137 + await vi.waitFor(() => { 138 + expect(container.querySelector('#annotation-form')).not.toBeNull(); 139 + }); 140 + 141 + // Set initial selection 142 + sidebar.setSelection({ 143 + text: 'Valid selection', 144 + selectors: [{ type: 'TextQuoteSelector', exact: 'Valid selection' }], 145 + }); 146 + 147 + const form = container.querySelector('#annotation-form') as HTMLElement; 148 + expect(form.style.display).toBe('block'); 149 + 150 + // Set selection with empty text 151 + sidebar.setSelection({ 152 + text: '', 153 + selectors: [], 154 + }); 155 + 156 + // Form should still show the original selection 157 + expect(form.style.display).toBe('block'); 158 + const selectedText = container.querySelector('#selected-text'); 159 + expect(selectedText?.innerHTML).toContain('Valid selection'); 160 + }); 161 + 162 + it('ignores whitespace-only selections', async () => { 163 + // Wait for initial render 164 + await vi.waitFor(() => { 165 + expect(container.querySelector('#annotation-form')).not.toBeNull(); 166 + }); 167 + 168 + // Set initial selection 169 + sidebar.setSelection({ 170 + text: 'Valid selection', 171 + selectors: [{ type: 'TextQuoteSelector', exact: 'Valid selection' }], 172 + }); 173 + 174 + // Set selection with whitespace only 175 + sidebar.setSelection({ 176 + text: ' ', 177 + selectors: [], 178 + }); 179 + 180 + // Form should still show the original selection 181 + const selectedText = container.querySelector('#selected-text'); 182 + expect(selectedText?.innerHTML).toContain('Valid selection'); 183 + }); 184 + }); 185 + 186 + describe('clear button', () => { 187 + it('explicitly clears the selection when clicked', async () => { 188 + // Wait for initial render 189 + await vi.waitFor(() => { 190 + expect(container.querySelector('#annotation-form')).not.toBeNull(); 191 + }); 192 + 193 + // Set a selection 194 + sidebar.setSelection({ 195 + text: 'Selected text', 196 + selectors: [{ type: 'TextQuoteSelector', exact: 'Selected text' }], 197 + }); 198 + 199 + const form = container.querySelector('#annotation-form') as HTMLElement; 200 + expect(form.style.display).toBe('block'); 201 + 202 + // Click the clear button 203 + const clearBtn = container.querySelector('#clear-selection-btn') as HTMLElement; 204 + clearBtn.click(); 205 + 206 + // Form should be hidden 207 + expect(form.style.display).toBe('none'); 208 + 209 + // Selected text should be cleared 210 + const selectedText = container.querySelector('#selected-text'); 211 + expect(selectedText?.innerHTML).toBe(''); 212 + }); 213 + }); 214 + 215 + describe('textarea preservation', () => { 216 + it('preserves textarea content when selection is set to null', async () => { 217 + // Wait for initial render 218 + await vi.waitFor(() => { 219 + expect(container.querySelector('#annotation-form')).not.toBeNull(); 220 + }); 221 + 222 + // Set a selection 223 + sidebar.setSelection({ 224 + text: 'Selected text', 225 + selectors: [{ type: 'TextQuoteSelector', exact: 'Selected text' }], 226 + }); 227 + 228 + // Type in the textarea 229 + const textarea = container.querySelector('#annotation-text') as HTMLTextAreaElement; 230 + textarea.value = 'My annotation note'; 231 + 232 + // Set selection to null (simulating losing selection) 233 + sidebar.setSelection(null); 234 + 235 + // Textarea content should be preserved 236 + expect(textarea.value).toBe('My annotation note'); 237 + }); 238 + 239 + it('clears textarea when clear button is clicked', async () => { 240 + // Wait for initial render 241 + await vi.waitFor(() => { 242 + expect(container.querySelector('#annotation-form')).not.toBeNull(); 243 + }); 244 + 245 + // Set a selection 246 + sidebar.setSelection({ 247 + text: 'Selected text', 248 + selectors: [{ type: 'TextQuoteSelector', exact: 'Selected text' }], 249 + }); 250 + 251 + // Type in the textarea 252 + const textarea = container.querySelector('#annotation-text') as HTMLTextAreaElement; 253 + textarea.value = 'My annotation note'; 254 + 255 + // Click clear button 256 + const clearBtn = container.querySelector('#clear-selection-btn') as HTMLElement; 257 + clearBtn.click(); 258 + 259 + // Textarea should be cleared 260 + expect(textarea.value).toBe(''); 261 + }); 262 + }); 263 + });
+9 -2
packages/core/src/sidebar/index.ts
··· 80 80 } 81 81 82 82 setSelection(selection: { text: string; selectors: any[] } | null) { 83 - this.currentSelection = selection; 84 - this.updateSelectionUI(); 83 + // Only update if we have a new valid selection (not clearing) 84 + // This implements "sticky" form behavior - the form persists when 85 + // selection is lost (e.g., clicking elsewhere on the page) 86 + if (selection && selection.text && selection.text.trim()) { 87 + this.currentSelection = selection; 88 + this.updateSelectionUI(); 89 + } 90 + // If selection is null or empty, keep the existing state (sticky form) 91 + // The form can still be cleared explicitly via the clear button 85 92 } 86 93 87 94 private async render() {
+217
tests/e2e/proxy/annotation-form.spec.ts
··· 1 + /** 2 + * Proxy client annotation form persistence tests 3 + * 4 + * Tests that the annotation form in the sidebar persists when: 5 + * - User clicks outside the form (losing text selection) 6 + * - User clicks buttons within the form 7 + * 8 + * Prerequisites: 9 + * - Run with: pnpm test:e2e:proxy 10 + */ 11 + 12 + import { test, expect } from '@playwright/test'; 13 + import { 14 + PROXY_BASE_URL, 15 + navigateToProxiedUrl, 16 + getSidebar, 17 + waitForShellReady, 18 + getProxiedContent, 19 + } from '../../helpers/proxy'; 20 + 21 + test.describe('Annotation Form Persistence', () => { 22 + test.skip( 23 + !process.env.RUN_PROXY_TESTS, 24 + 'Set RUN_PROXY_TESTS=1 to run proxy client tests' 25 + ); 26 + 27 + test.beforeEach(async ({ page }) => { 28 + // Navigate to a simple page for testing 29 + await navigateToProxiedUrl(page, 'https://example.com/'); 30 + await waitForShellReady(page); 31 + }); 32 + 33 + test('annotation form appears when text is selected', async ({ page }) => { 34 + const content = getProxiedContent(page); 35 + const sidebar = getSidebar(page); 36 + 37 + // Select some text in the proxied content 38 + await content.locator('h1').first().selectText(); 39 + 40 + // Wait a bit for the selection change to propagate (150ms debounce + messaging) 41 + await page.waitForTimeout(500); 42 + 43 + // Annotation form should be visible 44 + const annotationForm = sidebar.locator('#annotation-form'); 45 + await expect(annotationForm).toBeVisible(); 46 + 47 + // Selected text should be displayed 48 + const selectedText = sidebar.locator('#selected-text'); 49 + await expect(selectedText).toContainText('Example Domain'); 50 + }); 51 + 52 + test('annotation form persists when clicking outside (losing selection)', async ({ 53 + page, 54 + }) => { 55 + const content = getProxiedContent(page); 56 + const sidebar = getSidebar(page); 57 + 58 + // Select some text in the proxied content 59 + await content.locator('h1').first().selectText(); 60 + await page.waitForTimeout(500); 61 + 62 + // Verify form is visible 63 + const annotationForm = sidebar.locator('#annotation-form'); 64 + await expect(annotationForm).toBeVisible(); 65 + 66 + // Click somewhere else on the page to lose selection 67 + await content.locator('body').click({ position: { x: 10, y: 10 } }); 68 + 69 + // Wait for selection change to propagate 70 + await page.waitForTimeout(500); 71 + 72 + // Form should STILL be visible (sticky behavior) 73 + await expect(annotationForm).toBeVisible(); 74 + 75 + // Selected text should still show the original selection 76 + const selectedText = sidebar.locator('#selected-text'); 77 + await expect(selectedText).toContainText('Example Domain'); 78 + }); 79 + 80 + test('annotation form updates when making a new selection', async ({ 81 + page, 82 + }) => { 83 + const content = getProxiedContent(page); 84 + const sidebar = getSidebar(page); 85 + 86 + // Select the h1 text first 87 + await content.locator('h1').first().selectText(); 88 + await page.waitForTimeout(500); 89 + 90 + const annotationForm = sidebar.locator('#annotation-form'); 91 + const selectedText = sidebar.locator('#selected-text'); 92 + 93 + // Verify first selection 94 + await expect(annotationForm).toBeVisible(); 95 + await expect(selectedText).toContainText('Example Domain'); 96 + 97 + // Now select different text (the paragraph) 98 + await content.locator('p').first().selectText(); 99 + await page.waitForTimeout(500); 100 + 101 + // Form should still be visible 102 + await expect(annotationForm).toBeVisible(); 103 + 104 + // But now showing the new selection (paragraph text contains "More information") 105 + await expect(selectedText).toContainText('More information'); 106 + }); 107 + 108 + test('clear button closes the annotation form', async ({ page }) => { 109 + const content = getProxiedContent(page); 110 + const sidebar = getSidebar(page); 111 + 112 + // Select some text 113 + await content.locator('h1').first().selectText(); 114 + await page.waitForTimeout(500); 115 + 116 + // Verify form is visible 117 + const annotationForm = sidebar.locator('#annotation-form'); 118 + await expect(annotationForm).toBeVisible(); 119 + 120 + // Click the clear button 121 + const clearBtn = sidebar.locator('#clear-selection-btn'); 122 + await clearBtn.click(); 123 + 124 + // Form should be hidden 125 + await expect(annotationForm).not.toBeVisible(); 126 + 127 + // Selected text should be empty 128 + const selectedText = sidebar.locator('#selected-text'); 129 + await expect(selectedText).toBeEmpty(); 130 + }); 131 + 132 + test('textarea content is preserved when selection is lost', async ({ 133 + page, 134 + }) => { 135 + const content = getProxiedContent(page); 136 + const sidebar = getSidebar(page); 137 + 138 + // Select some text 139 + await content.locator('h1').first().selectText(); 140 + await page.waitForTimeout(500); 141 + 142 + // Type in the textarea 143 + const textarea = sidebar.locator('#annotation-text'); 144 + await textarea.fill('My annotation note'); 145 + 146 + // Click elsewhere to lose selection 147 + await content.locator('body').click({ position: { x: 10, y: 10 } }); 148 + await page.waitForTimeout(500); 149 + 150 + // Textarea content should be preserved 151 + await expect(textarea).toHaveValue('My annotation note'); 152 + }); 153 + 154 + test('textarea content is cleared when clear button is clicked', async ({ 155 + page, 156 + }) => { 157 + const content = getProxiedContent(page); 158 + const sidebar = getSidebar(page); 159 + 160 + // Select some text 161 + await content.locator('h1').first().selectText(); 162 + await page.waitForTimeout(500); 163 + 164 + // Type in the textarea 165 + const textarea = sidebar.locator('#annotation-text'); 166 + await textarea.fill('My annotation note'); 167 + 168 + // Click clear button 169 + const clearBtn = sidebar.locator('#clear-selection-btn'); 170 + await clearBtn.click(); 171 + 172 + // Textarea should be cleared 173 + await expect(textarea).toHaveValue(''); 174 + }); 175 + 176 + test('annotation form persists when clicking the save button', async ({ 177 + page, 178 + }) => { 179 + const content = getProxiedContent(page); 180 + const sidebar = getSidebar(page); 181 + 182 + // Select some text 183 + await content.locator('h1').first().selectText(); 184 + await page.waitForTimeout(500); 185 + 186 + // Verify form is visible 187 + const annotationForm = sidebar.locator('#annotation-form'); 188 + await expect(annotationForm).toBeVisible(); 189 + 190 + // Type a note 191 + const textarea = sidebar.locator('#annotation-text'); 192 + await textarea.fill('Test annotation'); 193 + 194 + // Click the save button (this will trigger login prompt since we're not logged in, 195 + // but the form should still persist) 196 + const saveBtn = sidebar.locator('#save-btn'); 197 + await saveBtn.click(); 198 + 199 + // Form should still be visible (either showing login or the form itself) 200 + // The key is that it didn't disappear due to losing selection from the button click 201 + 202 + // Wait a moment for any state changes 203 + await page.waitForTimeout(300); 204 + 205 + // Either the login form is shown OR the annotation form is still there 206 + // In both cases, clicking save shouldn't have made everything disappear 207 + const loginTrigger = sidebar.locator('#login-trigger-btn'); 208 + const loginForm = sidebar.locator('#handle-input'); 209 + 210 + const hasLoginUI = await loginTrigger.isVisible().catch(() => false) || 211 + await loginForm.isVisible().catch(() => false); 212 + const hasAnnotationForm = await annotationForm.isVisible().catch(() => false); 213 + 214 + // At least one of these should be true 215 + expect(hasLoginUI || hasAnnotationForm).toBe(true); 216 + }); 217 + });