@@ -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 = '
'
+
+ 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}