Skip to content

Commit f3cab99

Browse files
authored
Merge pull request #52 from beNative/codex/add-support-for-displaying-pdf-documents
Add PDF renderer for stored documents
2 parents 9f218bb + eaf2c3e commit f3cab99

8 files changed

Lines changed: 165 additions & 16 deletions

File tree

components/PromptEditor.tsx

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

29+
const resolveDefaultViewMode = (mode: ViewMode | null | undefined, languageHint: string | null | undefined): ViewMode => {
30+
if (mode) return mode;
31+
const normalizedHint = languageHint?.toLowerCase();
32+
return normalizedHint === 'pdf' || normalizedHint === 'application/pdf' ? 'preview' : 'edit';
33+
};
34+
2935
const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, onCommitVersion, onDelete, settings, onShowHistory, onLanguageChange, onViewModeChange, formatTrigger }) => {
3036
const [title, setTitle] = useState(documentNode.title);
3137
const [content, setContent] = useState(documentNode.content || '');
@@ -39,7 +45,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
3945
const [isSaving, setIsSaving] = useState(false);
4046
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
4147
const [isCopied, setIsCopied] = useState(false);
42-
const [viewMode, setViewMode] = useState<ViewMode>(documentNode.default_view_mode || 'edit');
48+
const [viewMode, setViewMode] = useState<ViewMode>(resolveDefaultViewMode(documentNode.default_view_mode, documentNode.language_hint));
4349
const [splitSize, setSplitSize] = useState(50);
4450
const { addLog } = useLogger();
4551
const { skipNextAutoSave } = useDocumentAutoSave({
@@ -108,7 +114,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
108114
setTitle(documentNode.title);
109115
setContent(nextContent);
110116
setBaselineContent(nextContent);
111-
setViewMode(documentNode.default_view_mode || 'edit');
117+
setViewMode(resolveDefaultViewMode(documentNode.default_view_mode, documentNode.language_hint));
112118
setSplitSize(50);
113119
isContentInitialized.current = true;
114120
setIsDirty(false);
@@ -126,7 +132,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
126132
setContent(nextContent);
127133
}
128134
}
129-
}, [documentNode.id, documentNode.content, documentNode.default_view_mode, documentNode.title, isDirty]);
135+
}, [documentNode.id, documentNode.content, documentNode.default_view_mode, documentNode.language_hint, documentNode.title, isDirty]);
130136

131137
useEffect(() => {
132138
setTitle(documentNode.title);
@@ -138,6 +144,13 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
138144
}
139145
}, [viewMode, isDiffMode]);
140146

147+
useEffect(() => {
148+
const normalizedHint = documentNode.language_hint?.toLowerCase();
149+
if ((normalizedHint === 'pdf' || normalizedHint === 'application/pdf') && !documentNode.default_view_mode && viewMode === 'edit') {
150+
setViewMode('preview');
151+
}
152+
}, [documentNode.language_hint, documentNode.default_view_mode, viewMode]);
153+
141154
useEffect(() => {
142155
// Only mark as dirty after the initial content has been loaded.
143156
if (isContentInitialized.current) {
@@ -355,7 +368,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
355368

356369
const language = documentNode.language_hint || 'plaintext';
357370
const supportsAiTools = ['markdown', 'plaintext'].includes(language);
358-
const supportsPreview = ['markdown', 'html'].includes(language);
371+
const supportsPreview = ['markdown', 'html', 'pdf'].includes(language);
359372
const supportsFormatting = ['javascript', 'typescript', 'json', 'html', 'css', 'xml', 'yaml'].includes(language);
360373
const isPythonDocument = typeof window !== 'undefined' && !!window.electronAPI && (language === 'python');
361374
const pythonDefaults = useMemo(() => ({

electron/database.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ const mapExtensionToLanguageId_local = (extension: string | null): string => {
4040
case 'fmx':
4141
case 'ini':
4242
return 'ini';
43+
case 'application/pdf':
44+
case 'pdf':
45+
return 'pdf';
4346
default: return 'plaintext';
4447
}
4548
};
@@ -208,7 +211,7 @@ export const databaseService = {
208211
}
209212

210213
// Insert Documents and Versions
211-
const docStmt = db.prepare('INSERT INTO documents (node_id, doc_type, language_hint) VALUES (?, ?, ?)');
214+
const docStmt = db.prepare('INSERT INTO documents (node_id, doc_type, language_hint, default_view_mode) VALUES (?, ?, ?, ?)');
212215
const versionStmt = db.prepare('INSERT INTO doc_versions (document_id, created_at, content_id) VALUES (?, ?, ?)');
213216
const updateDocStmt = db.prepare('UPDATE documents SET current_version_id = ? WHERE document_id = ?');
214217

@@ -219,7 +222,7 @@ export const databaseService = {
219222
}
220223

221224
for (const doc of data.documents) {
222-
const docResult = docStmt.run(doc.node_id, doc.doc_type, doc.language_hint);
225+
const docResult = docStmt.run(doc.node_id, doc.doc_type, doc.language_hint, doc.default_view_mode ?? null);
223226
const docId = Number(docResult.lastInsertRowid);
224227

225228
const versions = docVersionsByNode.get(doc.node_id) || [];
@@ -296,9 +299,9 @@ export const databaseService = {
296299
const originalDoc = db.prepare('SELECT * FROM documents WHERE node_id = ?').get(nodeId) as Document;
297300
if (originalDoc) {
298301
const newDocResult = db.prepare(`
299-
INSERT INTO documents (node_id, doc_type, language_hint, current_version_id)
300-
VALUES (?, ?, ?, NULL)
301-
`).run(newNodeId, originalDoc.doc_type, originalDoc.language_hint);
302+
INSERT INTO documents (node_id, doc_type, language_hint, default_view_mode, current_version_id)
303+
VALUES (?, ?, ?, ?, NULL)
304+
`).run(newNodeId, originalDoc.doc_type, originalDoc.language_hint, originalDoc.default_view_mode);
302305
const newDocId = newDocResult.lastInsertRowid;
303306

304307
// Fix: Cast the result to DocVersion array.
@@ -456,11 +459,20 @@ export const databaseService = {
456459
const maxSortOrderResult = db.prepare(`SELECT MAX(sort_order) as max_order FROM nodes WHERE parent_id ${currentParentId ? '= ?' : 'IS NULL'}`).get(currentParentId) as { max_order: number | null };
457460
const sortOrder = (maxSortOrderResult?.max_order ?? -1) + 1;
458461
const extension = file.name.split('.').pop() || null;
459-
const languageHint = mapExtensionToLanguageId_local(extension);
462+
let languageHint = mapExtensionToLanguageId_local(extension);
460463

461464
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);
462465

463-
const docResult = db.prepare(`INSERT INTO documents (node_id, doc_type, language_hint) VALUES (?, ?, ?)`).run(newNodeId, 'source_code', languageHint);
466+
const trimmedContent = file.content.trim();
467+
const isPdf = languageHint === 'pdf' || languageHint === 'application/pdf' || trimmedContent.startsWith('data:application/pdf');
468+
if (isPdf) {
469+
languageHint = 'pdf';
470+
}
471+
const docType = isPdf ? 'pdf' : 'source_code';
472+
const defaultViewMode = isPdf ? 'preview' : null;
473+
474+
const docResult = db.prepare(`INSERT INTO documents (node_id, doc_type, language_hint, default_view_mode) VALUES (?, ?, ?, ?)`)
475+
.run(newNodeId, docType, languageHint, defaultViewMode);
464476
const documentId = Number(docResult.lastInsertRowid);
465477

466478
const contentId = getContentId(file.content);

hooks/usePrompts.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,13 @@ export const useDocuments = () => {
5858
const items: DocumentOrFolder[] = useMemo(() => allNodesFlat.map(nodeToDocumentOrFolder), [allNodesFlat]);
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 }) => {
61+
const resolvedLanguage = mapExtensionToLanguageId(language_hint);
62+
const defaultViewMode = doc_type === 'pdf' || resolvedLanguage === 'pdf' ? 'preview' : undefined;
6163
const newNode = await addNode({
6264
parent_id: parentId,
6365
node_type: 'document',
6466
title,
65-
document: { content, doc_type, language_hint: mapExtensionToLanguageId(language_hint) } as any,
67+
document: { content, doc_type, language_hint: resolvedLanguage, default_view_mode: defaultViewMode } as any,
6668
});
6769
return nodeToDocumentOrFolder(newNode);
6870
}, [addNode]);
@@ -119,7 +121,16 @@ export const useDocuments = () => {
119121
const reader = new FileReader();
120122
reader.onload = () => resolve({ path: entry.path, name: entry.name, content: reader.result as string });
121123
reader.onerror = (error) => reject(error);
122-
reader.readAsText(entry.file);
124+
125+
const fileName = entry.name.toLowerCase();
126+
const mimeType = entry.file.type;
127+
const shouldReadAsDataUrl = (mimeType && mimeType.includes('pdf')) || fileName.endsWith('.pdf');
128+
129+
if (shouldReadAsDataUrl) {
130+
reader.readAsDataURL(entry.file);
131+
} else {
132+
reader.readAsText(entry.file);
133+
}
123134
});
124135
});
125136

services/languageService.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const SUPPORTED_LANGUAGES = [
2121
{ id: 'yaml', label: 'YAML' },
2222
{ id: 'pascal', label: 'Pascal' },
2323
{ id: 'ini', label: 'INI' },
24+
{ id: 'pdf', label: 'PDF' },
2425
];
2526

2627
export const mapExtensionToLanguageId = (extension: string | null): string => {
@@ -75,6 +76,10 @@ export const mapExtensionToLanguageId = (extension: string | null): string => {
7576
case 'fmx':
7677
case 'ini':
7778
return 'ini';
79+
case 'application/pdf':
80+
return 'pdf';
81+
case 'pdf':
82+
return 'pdf';
7883
default:
7984
// Try to find a direct match in supported languages by id
8085
const match = SUPPORTED_LANGUAGES.find(l => l.id === extension.toLowerCase());

services/preview/pdfRenderer.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import React, { useEffect, useMemo } from 'react';
2+
import type { IRenderer } from './IRenderer';
3+
4+
interface PdfPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
5+
content: string;
6+
}
7+
8+
const PdfPreview = React.forwardRef<HTMLDivElement, PdfPreviewProps>(({ content, className, ...rest }, ref) => {
9+
const { url, error, isBlobUrl } = useMemo(() => {
10+
const trimmed = content.trim();
11+
if (!trimmed) {
12+
return { url: null as string | null, error: 'This document does not contain any PDF data.', isBlobUrl: false };
13+
}
14+
15+
if (trimmed.startsWith('data:application/pdf')) {
16+
return { url: trimmed, error: null, isBlobUrl: false };
17+
}
18+
19+
const cleanBase64 = trimmed.replace(/\s+/g, '');
20+
21+
const decodeBase64 = () => {
22+
try {
23+
const markerIndex = cleanBase64.toLowerCase().indexOf('base64,');
24+
const payload = markerIndex !== -1
25+
? cleanBase64.slice(markerIndex + 'base64,'.length)
26+
: cleanBase64;
27+
const byteCharacters = atob(payload);
28+
const byteLength = byteCharacters.length;
29+
const byteNumbers = new Uint8Array(byteLength);
30+
for (let i = 0; i < byteLength; i += 1) {
31+
byteNumbers[i] = byteCharacters.charCodeAt(i);
32+
}
33+
const blob = new Blob([byteNumbers], { type: 'application/pdf' });
34+
return URL.createObjectURL(blob);
35+
} catch {
36+
return null;
37+
}
38+
};
39+
40+
const blobUrlFromBase64 = decodeBase64();
41+
if (blobUrlFromBase64) {
42+
return { url: blobUrlFromBase64, error: null, isBlobUrl: true };
43+
}
44+
45+
if (trimmed.startsWith('%PDF')) {
46+
const blob = new Blob([trimmed], { type: 'application/pdf' });
47+
return { url: URL.createObjectURL(blob), error: null, isBlobUrl: true };
48+
}
49+
50+
return { url: null, error: 'Stored PDF data is not in a recognized format.', isBlobUrl: false };
51+
}, [content]);
52+
53+
useEffect(() => {
54+
return () => {
55+
if (isBlobUrl && url) {
56+
URL.revokeObjectURL(url);
57+
}
58+
};
59+
}, [isBlobUrl, url]);
60+
61+
if (error) {
62+
return (
63+
<div
64+
ref={ref}
65+
className={`w-full h-full flex items-center justify-center text-text-secondary text-sm ${className ?? ''}`}
66+
{...rest}
67+
>
68+
{error}
69+
</div>
70+
);
71+
}
72+
73+
if (!url) {
74+
return (
75+
<div
76+
ref={ref}
77+
className={`w-full h-full flex items-center justify-center text-text-secondary text-sm ${className ?? ''}`}
78+
{...rest}
79+
>
80+
Unable to display the stored PDF document.
81+
</div>
82+
);
83+
}
84+
85+
return (
86+
<div ref={ref} className={`w-full h-full overflow-auto bg-secondary ${className ?? ''}`} {...rest}>
87+
<iframe
88+
title="PDF Preview"
89+
src={url}
90+
className="w-full h-full border-0 bg-white"
91+
/>
92+
</div>
93+
);
94+
});
95+
96+
PdfPreview.displayName = 'PdfPreview';
97+
98+
export class PdfRenderer implements IRenderer {
99+
canRender(languageId: string): boolean {
100+
return languageId === 'pdf' || languageId === 'application/pdf';
101+
}
102+
103+
async render(content: string) {
104+
return { output: <PdfPreview content={content} /> };
105+
}
106+
}

services/previewService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { IRenderer } from './preview/IRenderer';
22
import { HtmlRenderer } from './preview/htmlRenderer';
33
import { MarkdownRenderer } from './preview/markdownRenderer';
44
import { PlaintextRenderer } from './preview/plaintextRenderer';
5+
import { PdfRenderer } from './preview/pdfRenderer';
56

67
class PreviewService {
78
private renderers: IRenderer[];
@@ -11,6 +12,7 @@ class PreviewService {
1112
this.renderers = [
1213
new MarkdownRenderer(),
1314
new HtmlRenderer(),
15+
new PdfRenderer(),
1416
new PlaintextRenderer(), // Fallback renderer should be last
1517
];
1618
}

services/repository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -561,8 +561,8 @@ export const repository = {
561561

562562
if (nodeData.node_type === 'document' && nodeData.document) {
563563
const docRes = await window.electronAPI!.dbRun(
564-
`INSERT INTO documents (node_id, doc_type, language_hint) VALUES (?, ?, ?)`,
565-
[newNodeId, nodeData.document.doc_type, nodeData.document.language_hint]
564+
`INSERT INTO documents (node_id, doc_type, language_hint, default_view_mode) VALUES (?, ?, ?, ?)`,
565+
[newNodeId, nodeData.document.doc_type, nodeData.document.language_hint, nodeData.document.default_view_mode ?? null]
566566
);
567567
const documentId = docRes.lastInsertRowid;
568568

types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ declare global {
6464
// =================================================================
6565

6666
export type NodeType = 'folder' | 'document';
67-
export type DocType = 'prompt' | 'source_code';
67+
export type DocType = 'prompt' | 'source_code' | 'pdf';
6868
export type ViewMode = 'edit' | 'preview' | 'split-vertical' | 'split-horizontal';
6969

7070
export type PythonExecutionStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'canceled';

0 commit comments

Comments
 (0)