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/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-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"); } 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; +}