Add image upload support

This commit is contained in:
tusharmagar 2026-01-21 13:23:33 +05:30
parent d9ddeff1c9
commit a2e929ed05
7 changed files with 350 additions and 6 deletions

View file

@ -25,6 +25,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-image": "^3.16.0",
"@tiptap/extension-link": "^3.15.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/extension-task-item": "^3.15.3",

View file

@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { Button } from './components/ui/button';
import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon } from 'lucide-react';
import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor';
import { ChatInputBar } from './components/chat-button';
@ -399,6 +399,9 @@ function App() {
// Track permission responses (toolCallId -> response)
const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map())
// Workspace root for full paths
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
// Load directory tree
const loadDirectory = useCallback(async () => {
try {
@ -1033,6 +1036,45 @@ function App() {
setIsGraphOpen(false)
}, [])
// Handle image upload for the markdown editor
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
try {
// Read file as data URL (includes mime type)
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
// Also save to .assets folder for persistence
const timestamp = Date.now()
const extension = file.name.split('.').pop() || 'png'
const filename = `image-${timestamp}.${extension}`
const assetsPath = 'knowledge/.assets'
const imagePath = `${assetsPath}/${filename}`
try {
// Extract base64 data (remove data URL prefix)
const base64Data = dataUrl.split(',')[1]
await window.ipc.invoke('workspace:writeFile', {
path: imagePath,
data: base64Data,
opts: { encoding: 'base64', mkdirp: true }
})
} catch (err) {
console.error('Failed to save image to disk:', err)
// Continue anyway - image will still display via data URL
}
// Return data URL for immediate display in editor
return dataUrl
} catch (error) {
console.error('Failed to upload image:', error)
return null
}
}, [])
// Keyboard shortcut: Ctrl+L to open main chat view
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -1106,8 +1148,7 @@ function App() {
return visible
}, [knowledgeFiles, expandedPaths])
// Get workspace root for full paths
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
// Load workspace root on mount
useEffect(() => {
window.ipc.invoke('workspace:getRoot', null).then(result => {
setWorkspaceRoot(result.root)
@ -1461,6 +1502,22 @@ function App() {
) : null}
</div>
)}
{!isGraphOpen && (
<Button
variant="ghost"
size="sm"
onClick={() => {
handleNewChat()
if (selectedPath) {
setIsChatSidebarOpen(true)
}
}}
className="text-foreground gap-1.5"
>
<SquarePen className="size-4" />
New Chat
</Button>
)}
{!selectedPath && isGraphOpen && (
<Button
variant="ghost"
@ -1508,6 +1565,7 @@ function App() {
onChange={setEditorContent}
placeholder="Start writing..."
wikiLinks={wikiLinkConfig}
onImageUpload={handleImageUpload}
/>
</div>
) : (

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef } from 'react'
import type { Editor } from '@tiptap/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -26,16 +26,19 @@ import {
Redo2Icon,
ExternalLinkIcon,
Trash2Icon,
ImageIcon,
} from 'lucide-react'
interface EditorToolbarProps {
editor: Editor | null
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
onImageUpload?: (file: File) => Promise<void> | void
}
export function EditorToolbar({ editor, onSelectionHighlight }: EditorToolbarProps) {
export function EditorToolbar({ editor, onSelectionHighlight, onImageUpload }: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const openLinkPopover = useCallback(() => {
if (!editor) return
@ -79,6 +82,23 @@ export function EditorToolbar({ editor, onSelectionHighlight }: EditorToolbarPro
closeLinkPopover()
}, [editor, closeLinkPopover])
const handleImageUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !onImageUpload) return
// Reset file input immediately
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
// Call the upload handler (which handles placeholder insertion)
try {
await onImageUpload(file)
} catch (error) {
console.error('Failed to upload image:', error)
}
}, [onImageUpload])
if (!editor) return null
const isLinkActive = editor.isActive('link')
@ -320,6 +340,27 @@ export function EditorToolbar({ editor, onSelectionHighlight }: EditorToolbarPro
</div>
</PopoverContent>
</Popover>
{/* Image upload */}
{onImageUpload && (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => fileInputRef.current?.click()}
title="Insert Image"
>
<ImageIcon className="size-4" />
</Button>
</>
)}
</div>
)
}

View file

@ -3,9 +3,11 @@ import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
import { EditorToolbar } from './editor-toolbar'
@ -27,6 +29,7 @@ interface MarkdownEditorProps {
onChange: (markdown: string) => void
placeholder?: string
wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null>
}
type WikiLinkMatch = {
@ -74,6 +77,7 @@ export function MarkdownEditor({
onChange,
placeholder = 'Start writing...',
wikiLinks,
onImageUpload,
}: MarkdownEditorProps) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
@ -105,6 +109,14 @@ export function MarkdownEditor({
target: '_blank',
},
}),
Image.configure({
inline: false,
allowBase64: true,
HTMLAttributes: {
class: 'editor-image',
},
}),
ImageUploadPlaceholderExtension,
WikiLink.configure({
onCreate: wikiLinks?.onCreate
? (path) => {
@ -298,9 +310,15 @@ export function MarkdownEditor({
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
// Create image upload handler that shows placeholder
const handleImageUploadWithPlaceholder = useMemo(() => {
if (!editor || !onImageUpload) return undefined
return createImageUploadHandler(editor, onImageUpload)
}, [editor, onImageUpload])
return (
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
<EditorToolbar editor={editor} onSelectionHighlight={setSelectionHighlight} />
<EditorToolbar editor={editor} onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} />
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} />
{wikiLinks ? (

View file

@ -0,0 +1,168 @@
import { mergeAttributes } from '@tiptap/react'
import { Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import { Loader2, ImageIcon } from 'lucide-react'
// Component for the upload placeholder
function ImageUploadPlaceholder({ node }: { node: { attrs: { progress?: number } } }) {
const progress = node.attrs.progress || 0
return (
<NodeViewWrapper className="image-upload-placeholder">
<div className="flex flex-col items-center justify-center gap-2 p-8 border-2 border-dashed border-border rounded-lg bg-muted/30">
{progress < 100 ? (
<>
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Uploading image...
</span>
{progress > 0 && (
<div className="w-32 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
)}
</>
) : (
<>
<ImageIcon className="size-8 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Processing...
</span>
</>
)}
</div>
</NodeViewWrapper>
)
}
// Extension for the upload placeholder node
export const ImageUploadPlaceholderExtension = Node.create({
name: 'imageUploadPlaceholder',
group: 'block',
atom: true,
draggable: false,
selectable: true,
addAttributes() {
return {
id: {
default: null,
},
progress: {
default: 0,
},
}
},
parseHTML() {
return [
{
tag: 'div[data-type="image-upload-placeholder"]',
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-upload-placeholder' })]
},
addNodeView() {
return ReactNodeViewRenderer(ImageUploadPlaceholder)
},
})
// Helper to insert placeholder and handle upload
export function createImageUploadHandler(
editor: Editor | null,
uploadFn: (file: File) => Promise<string | null>
) {
return async (file: File) => {
if (!editor) return
// Generate unique ID for this upload
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`
// Insert placeholder at current position
editor
.chain()
.focus()
.insertContent({
type: 'imageUploadPlaceholder',
attrs: { id: uploadId, progress: 0 },
})
.run()
try {
// Perform the upload
const imageUrl = await uploadFn(file)
if (imageUrl) {
// Find and replace the placeholder with the actual image
const { state } = editor
let placeholderPos: number | null = null
state.doc.descendants((node, pos) => {
if (
node.type.name === 'imageUploadPlaceholder' &&
node.attrs.id === uploadId
) {
placeholderPos = pos
return false
}
return true
})
if (placeholderPos !== null) {
editor
.chain()
.focus()
.deleteRange({ from: placeholderPos, to: placeholderPos + 1 })
.insertContentAt(placeholderPos, {
type: 'image',
attrs: { src: imageUrl },
})
.run()
}
} else {
// Upload failed - remove placeholder
removePlaceholder(editor, uploadId)
}
} catch (error) {
console.error('Image upload failed:', error)
removePlaceholder(editor, uploadId)
}
}
}
function removePlaceholder(
editor: Editor | null,
uploadId: string
) {
if (!editor) return
const { state } = editor
let placeholderPos: number | null = null
state.doc.descendants((node, pos) => {
if (
node.type.name === 'imageUploadPlaceholder' &&
node.attrs.id === uploadId
) {
placeholderPos = pos
return false
}
return true
})
if (placeholderPos !== null) {
editor
.chain()
.focus()
.deleteRange({ from: placeholderPos, to: placeholderPos + 1 })
.run()
}
}

View file

@ -281,3 +281,49 @@
background-color: color-mix(in srgb, var(--primary) 25%, transparent);
border-radius: 2px;
}
/* Images */
.tiptap-editor .ProseMirror img,
.tiptap-editor .ProseMirror .editor-image {
max-width: 100%;
height: auto;
border-radius: 0.5em;
margin: 0.75em 0;
display: block;
}
.tiptap-editor .ProseMirror img.ProseMirror-selectednode {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* Image upload placeholder */
.tiptap-editor .ProseMirror .image-upload-placeholder {
margin: 0.75em 0;
}
.tiptap-editor .ProseMirror .image-upload-placeholder > div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
border: 2px dashed var(--border);
border-radius: 0.5rem;
background-color: color-mix(in srgb, var(--muted) 30%, transparent);
}
.tiptap-editor .ProseMirror .image-upload-placeholder .progress-bar {
width: 8rem;
height: 0.375rem;
background-color: var(--muted);
border-radius: 9999px;
overflow: hidden;
}
.tiptap-editor .ProseMirror .image-upload-placeholder .progress-bar > div {
height: 100%;
background-color: var(--primary);
transition: width 0.3s ease;
}

12
apps/x/pnpm-lock.yaml generated
View file

@ -148,6 +148,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@tiptap/extension-image':
specifier: ^3.16.0
version: 3.16.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))
'@tiptap/extension-link':
specifier: ^3.15.3
version: 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
@ -2473,6 +2476,11 @@ packages:
'@tiptap/core': ^3.15.3
'@tiptap/pm': ^3.15.3
'@tiptap/extension-image@3.16.0':
resolution: {integrity: sha512-mTjt4kdyVtY/2dJcfxAgBae/dkH+r6GwARl7NlPtnI3EzpELFR65FNuOQyTxFXP3yfV9uMtPpq6Wevk8aLTsxQ==}
peerDependencies:
'@tiptap/core': ^3.16.0
'@tiptap/extension-italic@3.15.3':
resolution: {integrity: sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q==}
peerDependencies:
@ -9221,6 +9229,10 @@ snapshots:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/pm': 3.15.3
'@tiptap/extension-image@3.16.0(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
'@tiptap/extension-italic@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))':
dependencies:
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)