From 07b797318a69a751ab46dd5f469b7af7b9b59778 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 06:08:42 +0000 Subject: [PATCH 1/2] fix: resolve login browser popup and paste issues - Fix OAuth flow blocking the async runtime by using spawn_blocking for the TCP listener that waits for the callback - Change default hotkey from Cmd+Shift+Space to Cmd+/ - Add session-level caching for accessibility permission prompts to avoid repeatedly showing the dialog on each paste attempt --- README.md | 2 +- package-lock.json | 9 +++++---- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/auth/google.rs | 11 ++++++++--- src-tauri/src/data/settings.rs | 4 ++-- src-tauri/src/lib.rs | 2 +- src-tauri/src/os/platform/macos.rs | 22 ++++++++++++++++------ 8 files changed, 35 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d453007..f55d782 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ PromptLight removes that friction with a keystroke-summoned launcher that finds ## Features -- **Global Hotkey** - Summon with `Cmd+Shift+Space` from anywhere +- **Global Hotkey** - Summon with `Cmd+/` from anywhere - **Instant Search** - Type to filter prompts in real-time - **Keyboard Navigation** - Arrow keys to navigate, Enter to paste - **Folders** - Organize prompts by project or category diff --git a/package-lock.json b/package-lock.json index 8093e59..66585f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "promptlight-scaffold", - "version": "1.1.2", + "name": "promptlight", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "promptlight-scaffold", - "version": "1.1.2", + "name": "promptlight", + "version": "1.2.0", + "license": "MIT", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 906577b..a6802a6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3270,7 +3270,7 @@ dependencies = [ [[package]] name = "promptlight" -version = "1.1.3" +version = "1.2.0" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bbc45d0..6d59669 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,7 +29,7 @@ async-trait = "0.1" once_cell = "1.19" # OAuth and HTTP reqwest = { version = "0.12", features = ["json"] } -tokio = { version = "1", features = ["sync", "net", "io-util", "macros"] } +tokio = { version = "1", features = ["sync", "net", "io-util", "macros", "rt"] } url = "2" base64 = "0.22" sha2 = "0.10" diff --git a/src-tauri/src/auth/google.rs b/src-tauri/src/auth/google.rs index 460d8f3..6a95b94 100644 --- a/src-tauri/src/auth/google.rs +++ b/src-tauri/src/auth/google.rs @@ -82,11 +82,16 @@ pub async fn start_google_sign_in() -> Result { // Build the authorization URL let auth_url = build_auth_url(&redirect_uri, &code_challenge)?; - // Open the browser + // Open the browser - do this BEFORE spawning the blocking task + // so the browser has time to start while we wait opener::open(&auth_url).map_err(|e| format!("Failed to open browser: {}", e))?; - // Wait for the OAuth callback - let code = wait_for_callback(listener)?; + // Wait for the OAuth callback in a blocking task to not block the async runtime + // This is critical: TcpListener::accept() is blocking and would freeze the app + let code = tokio::task::spawn_blocking(move || wait_for_callback(listener)) + .await + .map_err(|e| format!("Task join error: {}", e))? + .map_err(|e| format!("OAuth callback error: {}", e))?; // Exchange the authorization code for tokens let tokens = exchange_code_for_tokens(&code, &code_verifier, &redirect_uri).await?; diff --git a/src-tauri/src/data/settings.rs b/src-tauri/src/data/settings.rs index ca28ab3..7507b3d 100644 --- a/src-tauri/src/data/settings.rs +++ b/src-tauri/src/data/settings.rs @@ -20,9 +20,9 @@ pub struct GeneralSettings { pub welcome_screen_dismissed: bool, } -/// Default hotkey: Cmd/Ctrl+Shift+Space +/// Default hotkey: Cmd/Ctrl+/ fn default_hotkey() -> Option { - Some("CommandOrControl+Shift+Space".to_string()) + Some("CommandOrControl+/".to_string()) } /// Default editor always on top: true diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1fa25c7..4e17c81 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -109,7 +109,7 @@ pub fn run() { } } - // Register global shortcut from settings (defaults to Cmd+Shift+Space) + // Register global shortcut from settings (defaults to Cmd+/) let app_handle = app.handle(); if let Err(e) = init_hotkey_from_settings(app_handle) { eprintln!("Failed to register global hotkey from settings: {}", e); diff --git a/src-tauri/src/os/platform/macos.rs b/src-tauri/src/os/platform/macos.rs index 6eb36f1..f0aea3e 100644 --- a/src-tauri/src/os/platform/macos.rs +++ b/src-tauri/src/os/platform/macos.rs @@ -9,8 +9,13 @@ use cocoa::foundation::NSString; use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation, CGKeyCode}; use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; use objc::{class, msg_send, sel, sel_impl}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +/// Track if we've already prompted for accessibility permissions this session. +/// Prevents repeatedly showing the prompt on every paste attempt. +static PERMISSION_PROMPTED: AtomicBool = AtomicBool::new(false); + /// Check if the app has accessibility permissions (required for CGEvent paste simulation). /// Uses AXIsProcessTrusted() from the Accessibility framework. pub fn check_accessibility_permission() -> bool { @@ -180,13 +185,18 @@ impl InputSimulator for MacOSInputSimulator { println!("[platform:macos] Paste simulation may fail. Grant permission in:"); println!("[platform:macos] System Settings > Privacy & Security > Accessibility"); - // Request permission (shows system dialog) - let prompted = request_accessibility_permission(); - println!("[platform:macos] Permission request dialog shown: {}", prompted); - - if !prompted { - return Err("Accessibility permission required for paste. Please grant in System Settings > Privacy & Security > Accessibility".to_string()); + // Only prompt once per session to avoid spamming the user + if !PERMISSION_PROMPTED.swap(true, Ordering::SeqCst) { + // First time this session - show the system dialog + let prompted = request_accessibility_permission(); + println!("[platform:macos] Permission request dialog shown: {}", prompted); + } else { + println!("[platform:macos] Already prompted this session, skipping dialog"); } + + // Return error - paste will fail without permission + // The content is already in the clipboard, so user can manually paste + return Err("Accessibility permission required for paste. Content copied to clipboard - use Cmd+V to paste manually.".to_string()); } else { println!("[platform:macos] Accessibility permission: GRANTED"); } From b84b519ed37344345e522ef1fe074c96cd36df28 Mon Sep 17 00:00:00 2001 From: mmaher Date: Fri, 5 Dec 2025 10:08:39 -0800 Subject: [PATCH 2/2] fix: robust Select All (Cmd+A) handling for WKWebView virtualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Select All fix worked in Playwright tests but failed in the actual Tauri app because WKWebView intercepts Cmd+A at the native level before JavaScript receives the event. Additionally, CodeMirror's virtualization only renders visible lines, causing partial selections. This fix implements multiple strategies: - Detect partial selections via selectionchange event - Auto-fix by extending selection to full document via CodeMirror dispatch - Persist full selection state across scroll events (re-apply as user scrolls) - Use setTimeout and requestMeasure to ensure proper visual updates Also adds comprehensive E2E tests that load existing prompts (not create new) to properly test virtualization behavior, running in both Chromium and WebKit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- playwright.config.ts | 4 + src/__tests__/e2e/pages/EditorPage.ts | 4 +- src/__tests__/e2e/select-all.spec.ts | 235 ++++++++++++++++ .../MarkdownEditor/MarkdownEditor.tsx | 261 ++++++++++++++++-- src/stores/editorStore.ts | 5 + 5 files changed, 481 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/e2e/select-all.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index ea910cf..b36fbb0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, ], webServer: { command: 'npm run dev:vite', diff --git a/src/__tests__/e2e/pages/EditorPage.ts b/src/__tests__/e2e/pages/EditorPage.ts index 9d7cfbb..c0939ec 100644 --- a/src/__tests__/e2e/pages/EditorPage.ts +++ b/src/__tests__/e2e/pages/EditorPage.ts @@ -109,8 +109,8 @@ export class EditorPage { /** Trigger editor store to reload prompts from backend */ async reloadPrompts() { await this.page.evaluate(() => { - // Access the editor store via zustand's getState - const store = (window as unknown as { __ZUSTAND_DEVTOOLS_GLOBAL__?: { stores: Map { loadPrompts: () => Promise } }> } }).__ZUSTAND_DEVTOOLS_GLOBAL__?.stores?.get('editor'); + // Access the editor store via the exposed __editorStore + const store = (window as unknown as { __editorStore?: { getState: () => { loadPrompts: () => Promise } } }).__editorStore; if (store) { store.getState().loadPrompts(); } diff --git a/src/__tests__/e2e/select-all.spec.ts b/src/__tests__/e2e/select-all.spec.ts new file mode 100644 index 0000000..c00ad39 --- /dev/null +++ b/src/__tests__/e2e/select-all.spec.ts @@ -0,0 +1,235 @@ +/** + * Select All (Cmd+A) E2E Tests + * + * Tests that Cmd+A properly selects ALL content in the markdown editor, + * including content that is virtualized/scrolled out of view. + * + * This is a regression test for the CodeMirror virtualization issue where + * Cmd+A would only select visible content instead of the full document. + * + * IMPORTANT: These tests load EXISTING prompts (not create new ones) because + * the virtualization issue only occurs when content is loaded from storage, + * not when it's typed in (typing renders all content). + */ + +import { test, expect } from '@playwright/test'; +import { EditorPage } from './pages/EditorPage'; +import { createTestPrompt } from './fixtures/test-data'; +import os from 'os'; + +const modifier = os.platform() === 'darwin' ? 'Meta' : 'Control'; + +// Generate a very large body that requires multiple scrolls +function generateLargeContent(lines: number): string { + const content: string[] = []; + for (let i = 1; i <= lines; i++) { + content.push(`Line ${i}: This is paragraph ${i} of the test content. It contains enough text to make each line meaningful and help verify that all content is properly selected when using Cmd+A.`); + content.push(''); // Empty line between paragraphs + } + return content.join('\n'); +} + +test.describe('Select All in Editor', () => { + let editor: EditorPage; + + test.beforeEach(async ({ page }) => { + editor = new EditorPage(page); + }); + + test('should select ALL content including virtualized/scrolled content when pressing Cmd+A', async () => { + // This test verifies the fix for CodeMirror virtualization issue + // where Cmd+A only selected visible content instead of the full document + // + // CRITICAL: This test LOADS an existing prompt (not creates a new one) + // because the virtualization issue only occurs with loaded content. + // When content is typed, it's all rendered. When loaded, only visible + // lines are rendered due to CodeMirror virtualization. + + // Generate very large content (100 lines = definitely multiple scrolls worth) + const largeContent = generateLargeContent(100); + expect(largeContent.length).toBeGreaterThan(10000); // Sanity check + + // Create a prompt with large content that will be loaded from "storage" + const largePrompt = createTestPrompt({ + id: 'large-content-prompt', + name: 'Large Content Test', + description: 'A prompt with lots of content for select-all testing', + content: largeContent, + }); + + // Navigate and seed the data + await editor.goto(); + await editor.page.waitForTimeout(500); + + // Seed the prompt into mock backend + await editor.seedPrompts([largePrompt]); + await editor.page.waitForTimeout(300); + + // Reload prompts so the store picks up the seeded data + await editor.reloadPrompts(); + await editor.page.waitForTimeout(500); + + // Click on the prompt in the sidebar to LOAD it (not create new) + await editor.clickSidebarPromptByName('Large Content Test'); + await editor.page.waitForTimeout(500); + + // Click into the content editor + const cmContent = editor.contentEditor.locator('.cm-content'); + await cmContent.click(); + await editor.page.waitForTimeout(500); + + // Leave the field by clicking on the title + await editor.titleInput.click(); + await editor.page.waitForTimeout(500); + + // Go back to the content field + await cmContent.click(); + await editor.page.waitForTimeout(500); + + // Press Cmd+A to select all + await editor.page.keyboard.press(`${modifier}+a`); + await editor.page.waitForTimeout(500); + + // Type "hello" to replace ALL selected content + await editor.page.keyboard.type('hello'); + await editor.page.waitForTimeout(500); + + // Get the new content + const contentAfter = await cmContent.textContent(); + + // The content should be EXACTLY "hello" (no trailing text from previous content) + // If select-all only selected visible content, there would be leftover text + expect(contentAfter?.trim()).toBe('hello'); + }); + + test('should select all with shorter content (sanity check)', async () => { + // Sanity check with short content to ensure test mechanics work + await editor.goto(); + await editor.page.waitForTimeout(500); + + await editor.clickNewPrompt(); + await editor.page.waitForTimeout(300); + + const shortContent = 'This is a short test message.'; + + const cmContent = editor.contentEditor.locator('.cm-content'); + await cmContent.click(); + await editor.page.keyboard.type(shortContent); + await editor.page.waitForTimeout(200); + + // Leave and return + await editor.titleInput.click(); + await editor.page.waitForTimeout(100); + await cmContent.click(); + await editor.page.waitForTimeout(100); + + // Select all and replace + await editor.page.keyboard.press(`${modifier}+a`); + await editor.page.keyboard.type('replaced'); + await editor.page.waitForTimeout(100); + + const contentAfter = await cmContent.textContent(); + expect(contentAfter?.trim()).toBe('replaced'); + }); + + test('should select all content when scrolled to bottom', async () => { + // Test that select-all works even when scrolled away from the start + // Uses LOADED prompt (not created) to test virtualization issue + + const largeContent = generateLargeContent(50); + const bottomPrompt = createTestPrompt({ + id: 'bottom-scroll-prompt', + name: 'Bottom Scroll Test', + description: 'Testing select-all when scrolled to bottom', + content: largeContent, + }); + + await editor.goto(); + await editor.page.waitForTimeout(500); + + await editor.seedPrompts([bottomPrompt]); + await editor.page.waitForTimeout(300); + await editor.reloadPrompts(); + await editor.page.waitForTimeout(500); + + // Load the prompt by clicking it in sidebar + await editor.clickSidebarPromptByName('Bottom Scroll Test'); + await editor.page.waitForTimeout(500); + + const cmContent = editor.contentEditor.locator('.cm-content'); + await cmContent.click(); + await editor.page.waitForTimeout(500); + + // Scroll to the bottom by pressing Cmd+End or just End multiple times + await editor.page.keyboard.press(`${modifier}+End`); + await editor.page.waitForTimeout(500); + + // Leave and re-enter the field + await editor.titleInput.click(); + await editor.page.waitForTimeout(500); + await cmContent.click(); + await editor.page.waitForTimeout(500); + + // Now select all from the bottom position + await editor.page.keyboard.press(`${modifier}+a`); + await editor.page.waitForTimeout(500); + + // Replace with "bottom-test" + await editor.page.keyboard.type('bottom-test'); + await editor.page.waitForTimeout(500); + + const contentAfter = await cmContent.textContent(); + expect(contentAfter?.trim()).toBe('bottom-test'); + }); + + test('should select all content when scrolled to middle', async () => { + // Test that select-all works when scrolled to the middle of content + // Uses LOADED prompt (not created) to test virtualization issue + + const largeContent = generateLargeContent(80); + const middlePrompt = createTestPrompt({ + id: 'middle-scroll-prompt', + name: 'Middle Scroll Test', + description: 'Testing select-all when scrolled to middle', + content: largeContent, + }); + + await editor.goto(); + await editor.page.waitForTimeout(500); + + await editor.seedPrompts([middlePrompt]); + await editor.page.waitForTimeout(300); + await editor.reloadPrompts(); + await editor.page.waitForTimeout(500); + + // Load the prompt by clicking it in sidebar + await editor.clickSidebarPromptByName('Middle Scroll Test'); + await editor.page.waitForTimeout(500); + + const cmContent = editor.contentEditor.locator('.cm-content'); + await cmContent.click(); + await editor.page.waitForTimeout(500); + + // Go to the middle using Cmd+G or just scroll via Page Down + await editor.page.keyboard.press('PageDown'); + await editor.page.keyboard.press('PageDown'); + await editor.page.waitForTimeout(500); + + // Leave and re-enter the field + await editor.titleInput.click(); + await editor.page.waitForTimeout(500); + await cmContent.click(); + await editor.page.waitForTimeout(500); + + // Select all from middle position + await editor.page.keyboard.press(`${modifier}+a`); + await editor.page.waitForTimeout(500); + + // Replace + await editor.page.keyboard.type('middle-test'); + await editor.page.waitForTimeout(500); + + const contentAfter = await cmContent.textContent(); + expect(contentAfter?.trim()).toBe('middle-test'); + }); +}); diff --git a/src/components/editor/PromptEditor/MarkdownEditor/MarkdownEditor.tsx b/src/components/editor/PromptEditor/MarkdownEditor/MarkdownEditor.tsx index a46b360..9b35226 100644 --- a/src/components/editor/PromptEditor/MarkdownEditor/MarkdownEditor.tsx +++ b/src/components/editor/PromptEditor/MarkdownEditor/MarkdownEditor.tsx @@ -1,9 +1,22 @@ import { useEffect, useRef, useCallback } from 'react'; import { ink, inkPlugin } from 'ink-mde'; import type { Instance } from 'ink-mde'; -import { keymap } from '@codemirror/view'; +import { keymap, EditorView, ViewPlugin } from '@codemirror/view'; +import { Prec, EditorSelection } from '@codemirror/state'; import styles from './MarkdownEditor.module.css'; +// Debug logging for select-all issue (disabled in production) +const DEBUG_SELECT_ALL = false; +function logDebug(message: string, data?: unknown) { + if (DEBUG_SELECT_ALL) { + if (data !== undefined) { + console.warn(`[MarkdownEditor] ${message}`, data); + } else { + console.warn(`[MarkdownEditor] ${message}`); + } + } +} + interface MarkdownEditorProps { value: string; onChange: (value: string) => void; @@ -43,33 +56,38 @@ export function MarkdownEditor({ containerRef.current.innerHTML = ''; // Create keyboard shortcuts for formatting and selection - const formattingKeymap = keymap.of([ - { - key: 'Mod-a', - run: () => { - // Use ink-mde's native API to select all text - if (editorRef.current) { - const doc = editorRef.current.getDoc(); - editorRef.current.select({ selections: [{ start: 0, end: doc.length }] }); - } - return true; + // Use Prec.highest to ensure our selectAll takes precedence over CodeMirror's default + const formattingKeymap = Prec.highest( + keymap.of([ + { + key: 'Mod-a', + run: () => { + // Use ink-mde's native select API for maximum compatibility with WebKit + const editor = editorRef.current; + if (editor) { + const doc = editor.getDoc(); + editor.select({ selection: { start: 0, end: doc.length } }); + return true; + } + return false; + }, }, - }, - { - key: 'Mod-b', - run: () => { - editorRef.current?.format('bold', {}); - return true; + { + key: 'Mod-b', + run: () => { + editorRef.current?.format('bold', {}); + return true; + }, }, - }, - { - key: 'Mod-i', - run: () => { - editorRef.current?.format('italic', {}); - return true; + { + key: 'Mod-i', + run: () => { + editorRef.current?.format('italic', {}); + return true; + }, }, - }, - ]); + ]) + ); // Wrap the keymap as an ink-mde plugin const keymapPlugin = inkPlugin({ @@ -77,6 +95,47 @@ export function MarkdownEditor({ value: () => formattingKeymap, }); + // Track if we have a full-document selection active + // When true, we re-apply selection on scroll to fix virtualization visual glitch + let hasFullSelection = false; + let fullSelectionDocLength = 0; + + // Plugin to re-apply full selection on scroll (fixes virtualization visual glitch) + const selectionPersistPlugin = inkPlugin({ + type: 'default', + value: () => + ViewPlugin.define((view) => { + const onScroll = () => { + if (hasFullSelection && fullSelectionDocLength > 0) { + const state = view.state; + const mainSel = state.selection.main; + // Only re-apply if selection seems to have shrunk (virtualization issue) + if (mainSel.anchor === 0 && mainSel.head < fullSelectionDocLength) { + view.dispatch({ + selection: EditorSelection.single(0, fullSelectionDocLength), + }); + } + } + }; + view.scrollDOM.addEventListener('scroll', onScroll, { passive: true }); + return { + destroy() { + view.scrollDOM.removeEventListener('scroll', onScroll); + }, + }; + }), + }); + + // Helper to set full selection state (called from select-all handler) + const setFullSelectionState = (isFullSelection: boolean, docLength: number) => { + hasFullSelection = isFullSelection; + fullSelectionDocLength = docLength; + logDebug('Full selection state:', { hasFullSelection, fullSelectionDocLength }); + }; + + // Expose for use in selection handler + (window as unknown as { __setFullSelectionState: typeof setFullSelectionState }).__setFullSelectionState = setFullSelectionState; + editorRef.current = ink(containerRef.current, { doc: valueRef.current, hooks: { @@ -87,10 +146,15 @@ export function MarkdownEditor({ attribution: false, appearance: 'auto', }, - plugins: [keymapPlugin], + plugins: [keymapPlugin, selectionPersistPlugin], placeholder, }); + // Log when editor is initialized with content + logDebug('=== EDITOR INITIALIZED ==='); + logDebug('Initial content:', valueRef.current); + logDebug('Initial content length (chars):', valueRef.current.length); + // Handle focus behavior if (autoFocusRef.current) { editorRef.current.focus(); @@ -104,7 +168,149 @@ export function MarkdownEditor({ }, 0); } + // WKWebView in Tauri intercepts Cmd+A at the native level before JavaScript. + // We use multiple strategies to try to catch it: + // 1. Document keydown with capture (works in regular browsers) + // 2. beforeinput event (fires after native handling decides what to do) + // 3. selectionchange event (to detect and fix partial selections) + const container = containerRef.current; + + // Strategy 1: Standard keydown handler (may not fire in WKWebView for Cmd+A) + const handleKeyDown = (e: KeyboardEvent) => { + logDebug('=== KEYDOWN EVENT ===', { key: e.key, metaKey: e.metaKey, ctrlKey: e.ctrlKey }); + + if ((e.metaKey || e.ctrlKey) && e.key === 'a') { + logDebug('=== CMD+A PRESSED (keydown) ==='); + + const activeElement = document.activeElement; + const isInEditor = container?.contains(activeElement) || activeElement?.closest('.ink-mde'); + + if (isInEditor) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + selectAllContent(); + } + } + }; + + // Strategy 2: beforeinput event - fires when browser is about to modify content + // This might fire even when WKWebView handles Cmd+A natively + const handleBeforeInput = (e: InputEvent) => { + logDebug('=== BEFOREINPUT EVENT ===', { inputType: e.inputType }); + + // inputType for select-all is typically not fired, but let's log it + if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') { + return; // Let these through + } + }; + + // Strategy 3: Listen for selection changes and AUTO-FIX partial selections + // This fires AFTER WKWebView does its native select-all + // If WKWebView selected from 0 to some partial point, we extend it to the end + let lastSelectionTime = 0; + const handleSelectionChange = () => { + const editor = editorRef.current; + if (!editor) return; + + const activeElement = document.activeElement; + const isInEditor = container?.contains(activeElement) || activeElement?.closest('.ink-mde'); + if (!isInEditor) return; + + // Debounce to avoid rapid-fire on selection changes + const now = Date.now(); + if (now - lastSelectionTime < 100) return; + lastSelectionTime = now; + + // Check if we have a partial selection that looks like a failed select-all + const cmContent = container?.querySelector('.cm-content'); + if (cmContent) { + const view = EditorView.findFromDOM(cmContent as HTMLElement); + if (view) { + const state = view.state; + const mainSel = state.selection.main; + const docLength = state.doc.length; + + // Detect partial selection: starts at 0, has significant content selected, + // but doesn't reach the end. This is the signature of virtualization-broken select-all. + // We use 100 chars as a threshold to avoid false positives from normal selections. + const selectedLength = Math.abs(mainSel.head - mainSel.anchor); + const isPartialSelectAll = + mainSel.anchor === 0 && + mainSel.head > 100 && + mainSel.head < docLength && + selectedLength > 100; + + if (isPartialSelectAll) { + logDebug('=== PARTIAL SELECTION DETECTED - AUTO-FIXING ==='); + logDebug('Current selection: 0 to ' + mainSel.head + ' (doc length: ' + docLength + ')'); + + // Set state so scroll handler knows to maintain full selection + const setFullSelectionState = (window as unknown as { __setFullSelectionState?: (isFull: boolean, len: number) => void }).__setFullSelectionState; + if (setFullSelectionState) { + setFullSelectionState(true, docLength); + } + + // Use setTimeout to break out of the current event loop + setTimeout(() => { + // Re-get the view in case it changed + const freshView = EditorView.findFromDOM(cmContent as HTMLElement); + if (!freshView) { + logDebug('ERROR: Could not find view for fix'); + return; + } + + // Dispatch selection change with userEvent to ensure proper handling + freshView.dispatch({ + selection: EditorSelection.single(0, docLength), + userEvent: 'select', + }); + + freshView.requestMeasure(); + logDebug('Fixed selection via dispatch: 0 to ' + docLength); + }, 10); + } else if (mainSel.anchor !== 0 || mainSel.head !== docLength) { + // Selection is not full-document, clear the persist state + const setFullSelectionState = (window as unknown as { __setFullSelectionState?: (isFull: boolean, len: number) => void }).__setFullSelectionState; + if (setFullSelectionState) { + setFullSelectionState(false, 0); + } + } + } + } + }; + + // Helper function to select all content + const selectAllContent = () => { + const editor = editorRef.current; + if (!editor) { + logDebug('ERROR: No editor reference!'); + return; + } + + const doc = editor.getDoc(); + logDebug('Selecting all: 0 to', doc.length); + editor.select({ selection: { start: 0, end: doc.length } }); + + // Verify selection after a tick + setTimeout(() => { + const selections = editor.selections(); + if (selections.length > 0) { + const sel = selections[0]; + logDebug('Selected: start=' + sel.start + ', end=' + sel.end + ', total=' + doc.length); + } + }, 50); + }; + + // Attach handlers + document.addEventListener('keydown', handleKeyDown, true); + document.addEventListener('beforeinput', handleBeforeInput as EventListener, true); + document.addEventListener('selectionchange', handleSelectionChange); + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + document.removeEventListener('beforeinput', handleBeforeInput as EventListener, true); + document.removeEventListener('selectionchange', handleSelectionChange); editorRef.current?.destroy(); editorRef.current = null; }; @@ -117,6 +323,9 @@ export function MarkdownEditor({ return; } if (editorRef.current && editorRef.current.getDoc() !== value) { + logDebug('=== CONTENT LOADED/UPDATED ==='); + logDebug('New content:', value); + logDebug('New content length (chars):', value.length); editorRef.current.update(value); } }, [value]); diff --git a/src/stores/editorStore.ts b/src/stores/editorStore.ts index 83aff43..6926678 100644 --- a/src/stores/editorStore.ts +++ b/src/stores/editorStore.ts @@ -423,3 +423,8 @@ export const useEditorStore = create((set, get) => ({ set({ currentView: view }); }, })); + +// Expose store on window for E2E tests +if (typeof window !== 'undefined') { + (window as unknown as { __editorStore: typeof useEditorStore }).__editorStore = useEditorStore; +}