Skip to content

Commit 56b5636

Browse files
authored
Merge pull request #53 from beNative/codex/add-support-for-common-image-formats
Add support for image previews
2 parents f3cab99 + 9baab37 commit 56b5636

16 files changed

Lines changed: 509 additions & 36 deletions

App.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ const MainApp: React.FC = () => {
127127
const commandPaletteTargetRef = useRef<HTMLDivElement>(null);
128128
const commandPaletteInputRef = useRef<HTMLInputElement>(null);
129129
const dragCounter = useRef(0);
130+
const ensureNodeVisibleRef = useRef<(node: Pick<DocumentOrFolder, 'id' | 'type' | 'parentId'>) => void>();
130131

131132
const llmStatus = useLLMStatus(settings.llmProviderUrl);
132133
const { logs, addLog } = useLogger();
@@ -323,8 +324,23 @@ const MainApp: React.FC = () => {
323324
};
324325
});
325326

326-
await addDocumentsFromFiles(fileEntries, parentId);
327-
}, [addDocumentsFromFiles]);
327+
const importedNodes = await addDocumentsFromFiles(fileEntries, parentId);
328+
329+
if (importedNodes.length > 0) {
330+
const imageNodes = importedNodes.filter(node => node.docType === 'image');
331+
const targetNode = imageNodes[imageNodes.length - 1] ?? importedNodes[importedNodes.length - 1];
332+
if (targetNode) {
333+
const nodeForReveal = { id: targetNode.nodeId, type: 'document' as const, parentId: targetNode.parentId };
334+
setActiveNodeId(targetNode.nodeId);
335+
setSelectedIds(new Set([targetNode.nodeId]));
336+
setLastClickedId(targetNode.nodeId);
337+
setActiveTemplateId(null);
338+
setDocumentView('editor');
339+
setView('editor');
340+
ensureNodeVisibleRef.current?.(nodeForReveal);
341+
}
342+
}
343+
}, [addDocumentsFromFiles, setActiveNodeId, setSelectedIds, setLastClickedId, setActiveTemplateId, setDocumentView, setView]);
328344

329345
useEffect(() => {
330346
const handleDragEnter = (e: DragEvent) => {
@@ -447,6 +463,10 @@ const MainApp: React.FC = () => {
447463
setPendingRevealId(node.id);
448464
}, [items, setPendingRevealId]);
449465

466+
useEffect(() => {
467+
ensureNodeVisibleRef.current = ensureNodeVisible;
468+
}, [ensureNodeVisible]);
469+
450470
const handleNewDocument = useCallback(async (parentId?: string | null) => {
451471
addLog('INFO', 'User action: Create New Document.');
452472
const effectiveParentId = parentId !== undefined ? parentId : getParentIdForNewItem();

components/PreviewPane.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const PreviewPane = React.forwardRef<HTMLDivElement, PreviewPaneProps>(({ conten
2828

2929
setError(null);
3030
const renderer = previewService.getRendererForLanguage(language);
31-
const result = await renderer.render(content, addLog);
31+
const result = await renderer.render(content, addLog, language);
3232

3333
clearTimeout(loadingTimer);
3434
if (!isCancelled) {

components/PromptEditor.tsx

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,43 @@ interface DocumentEditorProps {
2626
formatTrigger: number;
2727
}
2828

29+
const PREVIEWABLE_LANGUAGES = new Set<string>([
30+
'markdown',
31+
'html',
32+
'pdf',
33+
'application/pdf',
34+
'image',
35+
'png',
36+
'jpg',
37+
'jpeg',
38+
'gif',
39+
'bmp',
40+
'webp',
41+
'svg',
42+
'svg+xml',
43+
'image/png',
44+
'image/jpg',
45+
'image/jpeg',
46+
'image/gif',
47+
'image/webp',
48+
'image/bmp',
49+
'image/svg',
50+
'image/svg+xml',
51+
]);
52+
2953
const resolveDefaultViewMode = (mode: ViewMode | null | undefined, languageHint: string | null | undefined): ViewMode => {
3054
if (mode) return mode;
3155
const normalizedHint = languageHint?.toLowerCase();
32-
return normalizedHint === 'pdf' || normalizedHint === 'application/pdf' ? 'preview' : 'edit';
56+
if (!normalizedHint) {
57+
return 'edit';
58+
}
59+
if (normalizedHint === 'pdf' || normalizedHint === 'application/pdf') {
60+
return 'preview';
61+
}
62+
if (normalizedHint === 'image' || normalizedHint.startsWith('image/')) {
63+
return 'preview';
64+
}
65+
return 'edit';
3366
};
3467

3568
const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, onCommitVersion, onDelete, settings, onShowHistory, onLanguageChange, onViewModeChange, formatTrigger }) => {
@@ -357,7 +390,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
357390
}
358391
setRefinedContent(null);
359392
};
360-
393+
361394
const handleCopy = async () => {
362395
if (!content.trim()) return;
363396
await navigator.clipboard.writeText(content);
@@ -367,10 +400,11 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
367400
};
368401

369402
const language = documentNode.language_hint || 'plaintext';
370-
const supportsAiTools = ['markdown', 'plaintext'].includes(language);
371-
const supportsPreview = ['markdown', 'html', 'pdf'].includes(language);
372-
const supportsFormatting = ['javascript', 'typescript', 'json', 'html', 'css', 'xml', 'yaml'].includes(language);
373-
const isPythonDocument = typeof window !== 'undefined' && !!window.electronAPI && (language === 'python');
403+
const normalizedLanguage = language.toLowerCase();
404+
const supportsAiTools = ['markdown', 'plaintext'].includes(normalizedLanguage);
405+
const supportsPreview = PREVIEWABLE_LANGUAGES.has(normalizedLanguage);
406+
const supportsFormatting = ['javascript', 'typescript', 'json', 'html', 'css', 'xml', 'yaml'].includes(normalizedLanguage);
407+
const isPythonDocument = typeof window !== 'undefined' && !!window.electronAPI && (normalizedLanguage === 'python');
374408
const pythonDefaults = useMemo(() => ({
375409
...settings.pythonDefaults,
376410
workingDirectory: settings.pythonWorkingDirectory ?? settings.pythonDefaults.workingDirectory ?? null,

electron/database.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { INITIAL_SCHEMA } from './schema';
66
import { v4 as uuidv4 } from 'uuid';
77
import * as crypto from 'crypto';
88
// Fix: Import types to use for casting
9-
import type { Node, Document, DocVersion, DatabaseStats } from '../types';
9+
import type { Node, Document, DocVersion, DatabaseStats, DocType, ViewMode, ImportedNodeSummary } from '../types';
1010

1111
let db: Database.Database;
1212

@@ -43,6 +43,23 @@ const mapExtensionToLanguageId_local = (extension: string | null): string => {
4343
case 'application/pdf':
4444
case 'pdf':
4545
return 'pdf';
46+
case 'png':
47+
case 'jpg':
48+
case 'jpeg':
49+
case 'gif':
50+
case 'bmp':
51+
case 'webp':
52+
case 'svg':
53+
case 'svgz':
54+
case 'image/png':
55+
case 'image/jpg':
56+
case 'image/jpeg':
57+
case 'image/gif':
58+
case 'image/bmp':
59+
case 'image/webp':
60+
case 'image/svg':
61+
case 'image/svg+xml':
62+
return 'image';
4663
default: return 'plaintext';
4764
}
4865
};
@@ -409,7 +426,8 @@ export const databaseService = {
409426
}
410427
},
411428

412-
importFiles(filesData: {path: string, name: string, content: string}[], targetParentId: string | null): { success: boolean, error?: string } {
429+
importFiles(filesData: {path: string; name: string; content: string}[], targetParentId: string | null): { success: boolean; error?: string; createdNodes: ImportedNodeSummary[] } {
430+
const createdNodes: ImportedNodeSummary[] = [];
413431
const transaction = db.transaction(() => {
414432
console.log(`Starting import transaction for ${filesData.length} files.`);
415433
const knownFolderPaths = new Map<string, string>(); // 'parentId/folderName' -> 'node_id'
@@ -461,15 +479,23 @@ export const databaseService = {
461479
const extension = file.name.split('.').pop() || null;
462480
let languageHint = mapExtensionToLanguageId_local(extension);
463481

464-
db.prepare(`INSERT INTO nodes (node_id, parent_id, node_type, title, sort_order, created_at, updated_at) VALUES (?, ?, 'document', ?, ?, ?, ?)`).run(newNodeId, currentParentId, file.name, sortOrder, now, now);
465-
466482
const trimmedContent = file.content.trim();
467-
const isPdf = languageHint === 'pdf' || languageHint === 'application/pdf' || trimmedContent.startsWith('data:application/pdf');
483+
const sample = trimmedContent.slice(0, 64).toLowerCase();
484+
const isPdf = languageHint === 'pdf' || sample.startsWith('data:application/pdf');
485+
const isSvgContent = sample.startsWith('<svg');
486+
const isImageDataUrl = sample.startsWith('data:image/');
487+
const isImage = languageHint === 'image' || isImageDataUrl || isSvgContent;
488+
468489
if (isPdf) {
469490
languageHint = 'pdf';
491+
} else if (isImage) {
492+
languageHint = 'image';
470493
}
471-
const docType = isPdf ? 'pdf' : 'source_code';
472-
const defaultViewMode = isPdf ? 'preview' : null;
494+
495+
const docType: DocType = isPdf ? 'pdf' : isImage ? 'image' : 'source_code';
496+
const defaultViewMode: ViewMode | null = docType === 'pdf' || docType === 'image' ? 'preview' : null;
497+
498+
db.prepare(`INSERT INTO nodes (node_id, parent_id, node_type, title, sort_order, created_at, updated_at) VALUES (?, ?, 'document', ?, ?, ?, ?)`).run(newNodeId, currentParentId, file.name, sortOrder, now, now);
473499

474500
const docResult = db.prepare(`INSERT INTO documents (node_id, doc_type, language_hint, default_view_mode) VALUES (?, ?, ?, ?)`)
475501
.run(newNodeId, docType, languageHint, defaultViewMode);
@@ -480,15 +506,23 @@ export const databaseService = {
480506
const newVersionId = Number(versionResult.lastInsertRowid);
481507
db.prepare('UPDATE documents SET current_version_id = ? WHERE document_id = ?').run(newVersionId, documentId);
482508
console.log(`Created document "${file.name}" with node id ${newNodeId}`);
509+
510+
createdNodes.push({
511+
nodeId: newNodeId,
512+
parentId: currentParentId ?? null,
513+
docType,
514+
languageHint,
515+
defaultViewMode,
516+
});
483517
}
484518
});
485519

486520
try {
487521
transaction();
488-
return { success: true };
522+
return { success: true, createdNodes };
489523
} catch (error) {
490524
console.error('File import transaction failed:', error);
491-
return { success: false, error: error instanceof Error ? error.message : String(error) };
525+
return { success: false, error: error instanceof Error ? error.message : String(error), createdNodes: [] };
492526
}
493527
},
494528

hooks/useNodes.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useCallback } from 'react';
2-
import type { Node, ViewMode } from '../types';
2+
import type { Node, ViewMode, ImportedNodeSummary } from '../types';
33
import { repository } from '../services/repository';
44
import { useLogger } from './useLogger';
55

@@ -68,9 +68,15 @@ export const useNodes = () => {
6868
addLog('DEBUG', `Content for node ${nodeId} saved.`);
6969
}, [addLog]);
7070

71-
const importFiles = useCallback(async (filesData: {path: string, name: string, content: string}[], targetParentId: string | null) => {
72-
await repository.importFiles(filesData, targetParentId);
73-
}, []);
71+
const importFiles = useCallback(
72+
async (
73+
filesData: { path: string; name: string; content: string }[],
74+
targetParentId: string | null
75+
): Promise<ImportedNodeSummary[]> => {
76+
return repository.importFiles(filesData, targetParentId);
77+
},
78+
[]
79+
);
7480

7581
return { nodes, isLoading, refreshNodes, addNode, updateNode, deleteNode, deleteNodes, moveNodes, updateDocumentContent, duplicateNodes, importFiles, addLog };
7682
};

hooks/usePrompts.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useMemo } from 'react';
2-
import type { Node, DocumentOrFolder, DocType } from '../types';
2+
import type { Node, DocumentOrFolder, DocType, ImportedNodeSummary } from '../types';
33
import { useNodes } from './useNodes';
44
import { mapExtensionToLanguageId } from '../services/languageService';
55

@@ -59,7 +59,8 @@ export const useDocuments = () => {
5959

6060
const addDocument = useCallback(async ({ parentId, title = 'New Document', content = '', doc_type = 'prompt', language_hint = 'markdown' }: { parentId: string | null, title?: string, content?: string, doc_type?: DocType, language_hint?: string | null }) => {
6161
const resolvedLanguage = mapExtensionToLanguageId(language_hint);
62-
const defaultViewMode = doc_type === 'pdf' || resolvedLanguage === 'pdf' ? 'preview' : undefined;
62+
const shouldPreviewByDefault = doc_type === 'pdf' || doc_type === 'image' || resolvedLanguage === 'pdf' || resolvedLanguage === 'image';
63+
const defaultViewMode = shouldPreviewByDefault ? 'preview' : undefined;
6364
const newNode = await addNode({
6465
parent_id: parentId,
6566
node_type: 'document',
@@ -113,7 +114,10 @@ export const useDocuments = () => {
113114
await moveNodes(draggedIds, targetId, position);
114115
}, [moveNodes]);
115116

116-
const addDocumentsFromFiles = useCallback(async (files: { path: string; name: string; file: File }[], targetNodeId: string | null) => {
117+
const addDocumentsFromFiles = useCallback(async (
118+
files: { path: string; name: string; file: File }[],
119+
targetNodeId: string | null
120+
): Promise<ImportedNodeSummary[]> => {
117121
addLog('INFO', `Importing ${files.length} files...`);
118122

119123
const fileReadPromises = files.map(entry => {
@@ -124,7 +128,14 @@ export const useDocuments = () => {
124128

125129
const fileName = entry.name.toLowerCase();
126130
const mimeType = entry.file.type;
127-
const shouldReadAsDataUrl = (mimeType && mimeType.includes('pdf')) || fileName.endsWith('.pdf');
131+
const extension = fileName.split('.').pop() || '';
132+
const isPdf = (mimeType && mimeType.includes('pdf')) || extension === 'pdf';
133+
const isSvg = extension === 'svg' || extension === 'svgz' || mimeType === 'image/svg+xml';
134+
const isImage =
135+
(!isSvg && !!mimeType && mimeType.startsWith('image/')) ||
136+
['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].some(ext => extension === ext);
137+
138+
const shouldReadAsDataUrl = isPdf || isImage;
128139

129140
if (shouldReadAsDataUrl) {
130141
reader.readAsDataURL(entry.file);
@@ -136,12 +147,14 @@ export const useDocuments = () => {
136147

137148
try {
138149
const filesData = await Promise.all(fileReadPromises);
139-
await importFiles(filesData, targetNodeId);
150+
const createdNodes = await importFiles(filesData, targetNodeId);
140151
addLog('INFO', 'File import process completed successfully in the backend.');
141152
await refreshNodes();
153+
return createdNodes;
142154
} catch (error) {
143155
const message = error instanceof Error ? error.message : String(error);
144156
addLog('ERROR', `File import failed: ${message}`);
157+
return [];
145158
}
146159
}, [addLog, importFiles, refreshNodes]);
147160

services/languageService.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const SUPPORTED_LANGUAGES = [
2222
{ id: 'pascal', label: 'Pascal' },
2323
{ id: 'ini', label: 'INI' },
2424
{ id: 'pdf', label: 'PDF' },
25+
{ id: 'image', label: 'Image (PNG/JPEG/GIF/WebP/SVG/BMP)' },
2526
];
2627

2728
export const mapExtensionToLanguageId = (extension: string | null): string => {
@@ -80,6 +81,24 @@ export const mapExtensionToLanguageId = (extension: string | null): string => {
8081
return 'pdf';
8182
case 'pdf':
8283
return 'pdf';
84+
case 'png':
85+
case 'jpg':
86+
case 'jpeg':
87+
case 'gif':
88+
case 'bmp':
89+
case 'webp':
90+
case 'svg':
91+
case 'svgz':
92+
return 'image';
93+
case 'image/png':
94+
case 'image/jpg':
95+
case 'image/jpeg':
96+
case 'image/gif':
97+
case 'image/bmp':
98+
case 'image/webp':
99+
case 'image/svg':
100+
case 'image/svg+xml':
101+
return 'image';
83102
default:
84103
// Try to find a direct match in supported languages by id
85104
const match = SUPPORTED_LANGUAGES.find(l => l.id === extension.toLowerCase());

services/preview/IRenderer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,9 @@ export interface IRenderer {
1010
/**
1111
* Takes a string of content and transforms it into a renderable React element or HTML string.
1212
*/
13-
render(content: string, addLog?: (level: LogLevel, message: string) => void): Promise<{ output: React.ReactElement | string; error?: string }>;
13+
render(
14+
content: string,
15+
addLog?: (level: LogLevel, message: string) => void,
16+
languageId?: string | null,
17+
): Promise<{ output: React.ReactElement | string; error?: string }>;
1418
}

services/preview/htmlRenderer.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ export class HtmlRenderer implements IRenderer {
77
return languageId === 'html';
88
}
99

10-
async render(content: string, addLog?: (level: LogLevel, message: string) => void): Promise<{ output: React.ReactElement; error?: string }> {
10+
async render(
11+
content: string,
12+
addLog?: (level: LogLevel, message: string) => void,
13+
languageId?: string | null,
14+
): Promise<{ output: React.ReactElement; error?: string }> {
1115
// Using `color-scheme` allows the iframe content to respect the system's light/dark mode preference.
1216
const fullHtml = `<html><head><style>body { color-scheme: light dark; font-family: sans-serif; padding: 1rem; }</style></head><body>${content}</body></html>`;
1317

0 commit comments

Comments
 (0)