diff --git a/src/App.tsx b/src/App.tsx index 1f3e68cf..bc19805a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1421,6 +1421,7 @@ function App() { inspectorWidth={layout.inspectorWidth} defaultAiAgent={aiAgentPreferences.defaultAiAgent} defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady} + onUnsupportedAiPaste={setToastMessage} onInspectorResize={layout.handleInspectorResize} inspectorEntry={activeTab?.entry ?? null} inspectorContent={activeTab?.content ?? null} diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx index 5b06a9c5..50b65020 100644 --- a/src/components/AiPanel.test.tsx +++ b/src/components/AiPanel.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent, act } from '@testing-library/react' import { AiPanel } from './AiPanel' +import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput' import type { VaultEntry } from '../types' import { queueAiPrompt } from '../utils/aiPromptBridge' @@ -245,4 +246,30 @@ describe('AiPanel', () => { ]) expect(screen.getByTestId('agent-send')).toBeDisabled() }) + + it('surfaces an unsupported image paste notice without locking the composer', () => { + const onUnsupportedAiPaste = vi.fn() + const entry = makeEntry({ title: 'My Note' }) + + render( + , + ) + + fireEvent.paste(screen.getByTestId('agent-input'), { + clipboardData: { + getData: vi.fn(() => ''), + files: [new File(['image'], 'paste.png', { type: 'image/png' })], + items: [{ kind: 'file', type: 'image/png' }], + }, + }) + + expect(onUnsupportedAiPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE) + expect(screen.getByTestId('agent-input').textContent).not.toContain('paste.png') + }) }) diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index 0e30cf3a..21bd98b5 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -17,6 +17,7 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent' interface AiPanelProps { onClose: () => void onOpenNote?: (path: string) => void + onUnsupportedAiPaste?: (message: string) => void defaultAiAgent?: AiAgentId defaultAiAgentReady?: boolean onFileCreated?: (relativePath: string) => void @@ -36,6 +37,7 @@ interface AiPanelViewProps { controller: AiPanelController onClose: () => void onOpenNote?: (path: string) => void + onUnsupportedAiPaste?: (message: string) => void defaultAiAgent?: AiAgentId defaultAiAgentReady?: boolean activeEntry?: VaultEntry | null @@ -46,6 +48,7 @@ export function AiPanelView({ controller, onClose, onOpenNote, + onUnsupportedAiPaste, defaultAiAgent: providedDefaultAiAgent, defaultAiAgentReady: providedDefaultAiAgentReady, activeEntry, @@ -125,6 +128,7 @@ export function AiPanelView({ legacyCopy={useLegacyAiExperience} onChange={setInput} onSend={handleSend} + onUnsupportedAiPaste={onUnsupportedAiPaste} /> ) @@ -133,6 +137,7 @@ export function AiPanelView({ export function AiPanel({ onClose, onOpenNote, + onUnsupportedAiPaste, defaultAiAgent: providedDefaultAiAgent, defaultAiAgentReady: providedDefaultAiAgentReady, onFileCreated, @@ -167,6 +172,7 @@ export function AiPanel({ controller={controller} onClose={onClose} onOpenNote={onOpenNote} + onUnsupportedAiPaste={onUnsupportedAiPaste} defaultAiAgent={providedDefaultAiAgent} defaultAiAgentReady={providedDefaultAiAgentReady} activeEntry={activeEntry} diff --git a/src/components/AiPanelChrome.tsx b/src/components/AiPanelChrome.tsx index 3af6890e..4f77fc17 100644 --- a/src/components/AiPanelChrome.tsx +++ b/src/components/AiPanelChrome.tsx @@ -42,6 +42,7 @@ interface AiPanelComposerProps { legacyCopy: boolean onChange: (value: string) => void onSend: (text: string, references: NoteReference[]) => void + onUnsupportedAiPaste?: (message: string) => void } function getComposerPlaceholder( @@ -215,6 +216,7 @@ export function AiPanelComposer({ legacyCopy, onChange, onSend, + onUnsupportedAiPaste, }: AiPanelComposerProps) { const composerDisabled = isActive || !agentReady const canSend = !composerDisabled && input.trim().length > 0 @@ -240,6 +242,7 @@ export function AiPanelComposer({ value={input} onChange={onChange} onSend={onSend} + onUnsupportedPaste={onUnsupportedAiPaste} disabled={composerDisabled} placeholder={placeholder} inputRef={inputRef} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 856a6b8a..a632dfd8 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -35,6 +35,7 @@ interface EditorProps { activeTabPath: string | null entries: VaultEntry[] onNavigateWikilink: (target: string) => void + onUnsupportedAiPaste?: (message: string) => void onLoadDiff?: (path: string) => Promise onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise pendingCommitDiffRequest?: CommitDiffRequest | null @@ -316,6 +317,7 @@ function EditorLayout({ onFileCreated, onFileModified, onVaultChanged, + onUnsupportedAiPaste, }: { tabs: Tab[] activeTab: Tab | null @@ -369,6 +371,7 @@ function EditorLayout({ onFileCreated?: (relativePath: string) => void onFileModified?: (relativePath: string) => void onVaultChanged?: () => void + onUnsupportedAiPaste?: (message: string) => void }) { return (
@@ -417,6 +420,7 @@ function EditorLayout({ inspectorWidth={inspectorWidth} defaultAiAgent={defaultAiAgent} defaultAiAgentReady={defaultAiAgentReady} + onUnsupportedAiPaste={onUnsupportedAiPaste} inspectorEntry={inspectorEntry} inspectorContent={inspectorContent} entries={entries} @@ -451,6 +455,7 @@ export const Editor = memo(function Editor(props: EditorProps) { getNoteStatus, inspectorCollapsed, onToggleInspector, inspectorWidth, defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true, + onUnsupportedAiPaste, onInspectorResize, inspectorEntry, inspectorContent, gitHistory, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, @@ -525,6 +530,7 @@ export const Editor = memo(function Editor(props: EditorProps) { inspectorWidth={inspectorWidth} defaultAiAgent={defaultAiAgent} defaultAiAgentReady={defaultAiAgentReady} + onUnsupportedAiPaste={onUnsupportedAiPaste} inspectorEntry={inspectorEntry} inspectorContent={inspectorContent} gitHistory={gitHistory} diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index 4b229c10..3acbb6ae 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -13,6 +13,7 @@ interface EditorRightPanelProps { inspectorWidth: number defaultAiAgent?: AiAgentId defaultAiAgentReady?: boolean + onUnsupportedAiPaste?: (message: string) => void inspectorEntry: VaultEntry | null inspectorContent: string | null entries: VaultEntry[] @@ -40,6 +41,7 @@ interface EditorRightPanelProps { export function EditorRightPanel({ showAIChat, inspectorCollapsed, inspectorWidth, defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true, + onUnsupportedAiPaste, inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, noteList, noteListFilter, onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff, @@ -81,6 +83,7 @@ export function EditorRightPanel({ controller={aiPanelController} onClose={() => onToggleAIChat?.()} onOpenNote={onOpenNote} + onUnsupportedAiPaste={onUnsupportedAiPaste} defaultAiAgent={defaultAiAgent} defaultAiAgentReady={defaultAiAgentReady} activeEntry={inspectorEntry} diff --git a/src/components/InlineWikilinkInput.tsx b/src/components/InlineWikilinkInput.tsx index b358d507..5b946d23 100644 --- a/src/components/InlineWikilinkInput.tsx +++ b/src/components/InlineWikilinkInput.tsx @@ -37,6 +37,7 @@ interface InlineWikilinkInputProps { value: string onChange: (value: string) => void onSubmit?: (text: string, references: NoteReference[]) => void + onUnsupportedPaste?: (message: string) => void submitOnEmpty?: boolean disabled?: boolean placeholder?: string @@ -56,6 +57,21 @@ function collapseSelectionRange(nextSelectionIndex: number) { end: nextSelectionIndex, } } + +export const UNSUPPORTED_INLINE_PASTE_MESSAGE = 'Only text paste is supported in the AI composer right now.' + +function hasUnsupportedClipboardPayload(clipboardData: DataTransfer) { + if (clipboardData.files.length > 0) return true + + return Array.from(clipboardData.items).some((item) => + item.kind === 'file' || item.type.startsWith('image/'), + ) +} + +function containsUnsupportedInlineContent(editor: HTMLDivElement) { + return editor.querySelector('img, picture, video, audio, canvas, figure, iframe, object') !== null +} + function submitInlineValue({ onSubmit, submitOnEmpty, @@ -110,6 +126,7 @@ export function InlineWikilinkInput({ value, onChange, onSubmit, + onUnsupportedPaste, submitOnEmpty = false, disabled = false, placeholder, @@ -122,7 +139,7 @@ export function InlineWikilinkInput({ paletteEmptyState, paletteFooter, }: InlineWikilinkInputProps) { - const [, forceRender] = useState(0) + const [renderVersion, forceRender] = useState(0) const segments = useMemo( () => buildInlineWikilinkSegments(value, entries), [entries, value], @@ -171,15 +188,40 @@ export function InlineWikilinkInput({ onChange(nextState.value) setSelectionRange(nextState.selection) } + const notifyUnsupportedPaste = () => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE) + const recoverUnsupportedMutation = () => { + pendingPasteRef.current = null + notifyUnsupportedPaste() + forceRender((current) => current + 1) + setSelectionRange({ ...selectionRange }) + } const deleteContent = (direction: 'backward' | 'forward') => { const nextState = deleteInlineSelection(value, selectionRange, segments, direction) if (!nextState) return onChange(nextState.value) setSelectionRange(nextState.selection) } + const handleBeforeInput = (event: React.FormEvent) => { + if (disabled) return + + const nativeEvent = event.nativeEvent as InputEvent + if (!nativeEvent.inputType.startsWith('insert')) return + + const dataTransfer = nativeEvent.dataTransfer + if (!dataTransfer || !hasUnsupportedClipboardPayload(dataTransfer)) return + + event.preventDefault() + notifyUnsupportedPaste() + } const handlePaste = (event: React.ClipboardEvent) => { if (disabled) return + if (hasUnsupportedClipboardPayload(event.clipboardData)) { + event.preventDefault() + notifyUnsupportedPaste() + return + } + const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain')) if (!pastedText) return @@ -192,6 +234,11 @@ export function InlineWikilinkInput({ } const handleInput = () => { const editor = editorRef.current + if (editor && containsUnsupportedInlineContent(editor)) { + recoverUnsupportedMutation() + return + } + const pendingPaste = pendingPasteRef.current if (editor && pendingPaste) { const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor)) @@ -223,12 +270,14 @@ export function InlineWikilinkInput({ }) const editor = ( dataTestId: string editorClassName?: string + onBeforeInput: (event: React.FormEvent) => void onInput: () => void onKeyDown: (event: React.KeyboardEvent) => void onPaste: (event: React.ClipboardEvent) => void @@ -204,6 +206,7 @@ export function InlineWikilinkEditorField({ disabled && 'cursor-not-allowed opacity-60', editorClassName, )} + onBeforeInput={onBeforeInput} onInput={onInput} onKeyDown={onKeyDown} onPaste={onPaste} diff --git a/src/components/WikilinkChatInput.test.tsx b/src/components/WikilinkChatInput.test.tsx index 842d7f08..5cd3df65 100644 --- a/src/components/WikilinkChatInput.test.tsx +++ b/src/components/WikilinkChatInput.test.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' import { describe, it, expect, vi } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { WikilinkChatInput } from './WikilinkChatInput' +import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput' import type { VaultEntry } from '../types' const makeEntry = (overrides: Partial = {}): VaultEntry => ({ @@ -37,10 +38,12 @@ const entries: VaultEntry[] = [ function Controlled({ onSend, + onUnsupportedPaste, disabled = false, placeholder, }: { onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void + onUnsupportedPaste?: (message: string) => void disabled?: boolean placeholder?: string }) { @@ -52,6 +55,7 @@ function Controlled({ value={value} onChange={setValue} onSend={onSend ?? vi.fn()} + onUnsupportedPaste={onUnsupportedPaste} disabled={disabled} placeholder={placeholder} /> @@ -181,4 +185,41 @@ describe('WikilinkChatInput', () => { expect(editor).toHaveAttribute('contenteditable', 'false') expect(editor).toHaveAttribute('aria-disabled', 'true') }) + + it('rejects pasted images without freezing the editor', () => { + const onUnsupportedPaste = vi.fn() + render() + + const editor = screen.getByTestId('agent-input') + const clipboardData = { + getData: vi.fn(() => ''), + files: [new File(['image'], 'paste.png', { type: 'image/png' })], + items: [{ kind: 'file', type: 'image/png' }], + } + + fireEvent.paste(editor, { clipboardData }) + + expect(onUnsupportedPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE) + + updateEditorText('still works') + expect(editor.textContent).toContain('still works') + }) + + it('recovers if unsupported media lands in the editor DOM', async () => { + const onUnsupportedPaste = vi.fn() + render() + + const editor = screen.getByTestId('agent-input') + editor.innerHTML = 'paste' + + fireEvent.input(editor) + + expect(onUnsupportedPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE) + await waitFor(() => { + expect(screen.getByTestId('agent-input').querySelector('img')).toBeNull() + }) + + updateEditorText('still works') + expect(screen.getByTestId('agent-input').textContent).toContain('still works') + }) }) diff --git a/src/components/WikilinkChatInput.tsx b/src/components/WikilinkChatInput.tsx index 01f4a12e..e57ccfa7 100644 --- a/src/components/WikilinkChatInput.tsx +++ b/src/components/WikilinkChatInput.tsx @@ -7,6 +7,7 @@ interface WikilinkChatInputProps { value: string onChange: (value: string) => void onSend: (text: string, references: NoteReference[]) => void + onUnsupportedPaste?: (message: string) => void disabled?: boolean placeholder?: string inputRef?: React.RefObject @@ -17,6 +18,7 @@ export function WikilinkChatInput({ value, onChange, onSend, + onUnsupportedPaste, disabled, placeholder, inputRef, @@ -27,6 +29,7 @@ export function WikilinkChatInput({ value={value} onChange={onChange} onSubmit={onSend} + onUnsupportedPaste={onUnsupportedPaste} disabled={disabled} placeholder={placeholder} inputRef={inputRef}