mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
Add image upload support
This commit is contained in:
parent
d9ddeff1c9
commit
a2e929ed05
7 changed files with 350 additions and 6 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
168
apps/x/apps/renderer/src/extensions/image-upload.tsx
Normal file
168
apps/x/apps/renderer/src/extensions/image-upload.tsx
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
12
apps/x/pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue