Skip to content

Commit 75a93c1

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
feat: implement Jupyter-style live Python code cells in Lab Notebook
1 parent 25d5957 commit 75a93c1

3 files changed

Lines changed: 260 additions & 1 deletion

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { Node, mergeAttributes } from '@tiptap/core';
2+
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
3+
import { Play, Terminal, Trash2, Loader2, AlertCircle } from 'lucide-react';
4+
import { useState, useEffect } from 'react';
5+
6+
/**
7+
* Tiptap Extension for Jupyter-style Code Cells
8+
*/
9+
export const CodeCell = Node.create({
10+
name: 'codeCell',
11+
group: 'block',
12+
atom: true,
13+
14+
addAttributes() {
15+
return {
16+
code: {
17+
default: '# Type your Python code here\nprint("Hello, Scientist!")',
18+
parseHTML: element => element.getAttribute('data-code') || '',
19+
renderHTML: attributes => ({ 'data-code': attributes.code }),
20+
},
21+
output: {
22+
default: '',
23+
parseHTML: element => element.getAttribute('data-output') || '',
24+
renderHTML: attributes => ({ 'data-output': attributes.output }),
25+
},
26+
lastRun: {
27+
default: null,
28+
}
29+
};
30+
},
31+
32+
parseHTML() {
33+
return [{ tag: 'div[data-type="code-cell"]' }];
34+
},
35+
36+
renderHTML({ HTMLAttributes }) {
37+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'code-cell' })];
38+
},
39+
40+
addNodeView() {
41+
return ReactNodeViewRenderer(CodeCellComponent);
42+
},
43+
44+
addStorage() {
45+
return {
46+
markdown: {
47+
serialize: (state: any, node: any) => {
48+
const payload = JSON.stringify({
49+
code: node.attrs.code,
50+
output: node.attrs.output
51+
});
52+
const base64 = btoa(unescape(encodeURIComponent(payload)));
53+
state.write(`[[code:${base64}]]`);
54+
state.closeBlock(node);
55+
},
56+
}
57+
}
58+
}
59+
});
60+
61+
// --- Pyodide Global Loader ---
62+
let pyodideInstance: any = null;
63+
let pyodidePromise: Promise<any> | null = null;
64+
65+
async function getPyodide() {
66+
if (pyodideInstance) return pyodideInstance;
67+
if (pyodidePromise) return pyodidePromise;
68+
69+
pyodidePromise = (async () => {
70+
// Check if script already exists
71+
if (!document.getElementById('pyodide-script')) {
72+
const script = document.createElement('script');
73+
script.id = 'pyodide-script';
74+
script.src = 'https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js';
75+
document.head.appendChild(script);
76+
77+
await new Promise((resolve) => {
78+
script.onload = resolve;
79+
});
80+
}
81+
82+
// @ts-ignore
83+
pyodideInstance = await loadPyodide({
84+
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.25.0/full/"
85+
});
86+
return pyodideInstance;
87+
})();
88+
89+
return pyodidePromise;
90+
}
91+
92+
// --- Component ---
93+
94+
const CodeCellComponent = ({ node, updateAttributes, deleteNode }: any) => {
95+
const { code, output } = node.attrs;
96+
const [localCode, setLocalCode] = useState(code);
97+
const [localOutput, setLocalOutput] = useState(output);
98+
const [isRunning, setIsRunning] = useState(false);
99+
const [isLoaded, setIsLoaded] = useState(!!pyodideInstance);
100+
const [error, setError] = useState<string | null>(null);
101+
102+
useEffect(() => {
103+
setLocalCode(code);
104+
}, [code]);
105+
106+
const runCode = async () => {
107+
if (isRunning) return;
108+
setIsRunning(true);
109+
setError(null);
110+
setLocalOutput("Running...");
111+
112+
try {
113+
const py = await getPyodide();
114+
setIsLoaded(true);
115+
116+
// Redirect stdout
117+
py.runPython(`
118+
import sys
119+
import io
120+
sys.stdout = io.StringIO()
121+
sys.stderr = io.StringIO()
122+
`);
123+
124+
try {
125+
await py.runPythonAsync(localCode);
126+
const stdout = py.runPython("sys.stdout.getvalue()");
127+
const stderr = py.runPython("sys.stderr.getvalue()");
128+
129+
const finalOutput = (stdout + stderr).trim() || "Execution finished (no output).";
130+
setLocalOutput(finalOutput);
131+
updateAttributes({ code: localCode, output: finalOutput, lastRun: new Date().toISOString() });
132+
} catch (e: any) {
133+
setLocalOutput(e.message);
134+
setError(e.message);
135+
updateAttributes({ code: localCode, output: e.message });
136+
}
137+
} catch (err: any) {
138+
setError("Failed to load Python environment.");
139+
setLocalOutput("Error loading Pyodide.");
140+
} finally {
141+
setIsRunning(false);
142+
}
143+
};
144+
145+
return (
146+
<NodeViewWrapper className="code-cell-wrapper my-8 group relative select-none">
147+
<div className="bg-[#0d1117] border border-[#30363d] rounded-2xl overflow-hidden shadow-2xl transition-all group-hover:border-blue-500/50">
148+
149+
{/* Header */}
150+
<div className="flex items-center justify-between px-4 py-2.5 bg-[#161b22] border-b border-[#30363d]">
151+
<div className="flex items-center gap-3">
152+
<div className="p-1.5 bg-blue-500/10 text-blue-400 rounded-lg shadow-inner">
153+
<Terminal className="w-3.5 h-3.5" />
154+
</div>
155+
<span className="text-[10px] font-bold text-neutral-400 uppercase tracking-widest flex items-center gap-2">
156+
Python Kernel
157+
{isLoaded && <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span>}
158+
</span>
159+
</div>
160+
161+
<div className="flex items-center gap-2">
162+
<button
163+
onClick={runCode}
164+
disabled={isRunning}
165+
className={`flex items-center gap-1.5 px-3 py-1 text-[10px] font-bold uppercase tracking-wider rounded-md transition-all ${
166+
isRunning
167+
? 'bg-blue-500/10 text-blue-400 cursor-wait'
168+
: 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 active:scale-95'
169+
}`}
170+
>
171+
{isRunning ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3 fill-current" />}
172+
{isRunning ? 'Running...' : 'Run Cell'}
173+
</button>
174+
<button onClick={deleteNode} className="p-1.5 hover:bg-red-500/10 text-neutral-500 hover:text-red-400 rounded-lg transition-all">
175+
<Trash2 className="w-3 h-3" />
176+
</button>
177+
</div>
178+
</div>
179+
180+
{/* Code Editor Area */}
181+
<div className="relative">
182+
<textarea
183+
value={localCode}
184+
onChange={(e) => setLocalCode(e.target.value)}
185+
onBlur={() => updateAttributes({ code: localCode })}
186+
className="w-full min-h-[100px] p-6 bg-transparent text-[#e6edf3] font-mono text-sm leading-relaxed focus:outline-none resize-y selection:bg-blue-500/30"
187+
spellCheck={false}
188+
/>
189+
</div>
190+
191+
{/* Output Area */}
192+
{localOutput && (
193+
<div className="border-t border-[#30363d] bg-black/30 p-4">
194+
<div className="flex items-center gap-2 mb-2">
195+
<div className={`text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded ${error ? 'bg-red-500/20 text-red-400' : 'bg-blue-500/20 text-blue-400'}`}>
196+
Output
197+
</div>
198+
</div>
199+
<pre className={`text-[13px] font-mono whitespace-pre-wrap leading-relaxed ${error ? 'text-red-400/90' : 'text-neutral-400'}`}>
200+
{localOutput}
201+
</pre>
202+
</div>
203+
)}
204+
205+
{/* Loading/Error State Footer */}
206+
{!isLoaded && !isRunning && (
207+
<div className="px-4 py-2 bg-amber-500/5 flex items-center gap-2 border-t border-amber-500/10">
208+
<AlertCircle className="w-3 h-3 text-amber-500/50" />
209+
<span className="text-[10px] text-amber-500/70">Kernel not initialized. Click "Run" to load Python.</span>
210+
</div>
211+
)}
212+
</div>
213+
</NodeViewWrapper>
214+
);
215+
};

src/components/dashboard/LabReportTemplate.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export const LabReportTemplate = React.forwardRef<HTMLDivElement, LabReportTempl
179179
const processed = React.Children.map(children, child => {
180180
if (typeof child === 'string') {
181181
// Combined split for all custom nodes
182-
const parts = child.split(/(\[\[structure:[a-f0-9-]{36}\]\]|\[\[sketcher:[A-Za-z0-9+/=]+\]\]|\[\[calculator:[A-Za-z0-9+/=]+\]\])/g);
182+
const parts = child.split(/(\[\[structure:[a-f0-9-]{36}\]\]|\[\[sketcher:[A-Za-z0-9+/=]+\]\]|\[\[calculator:[A-Za-z0-9+/=]+\]\]|\[\[code:[A-Za-z0-9+/=]+\]\])/g);
183183
return parts.map((part, i) => {
184184
// 1. Handle Structure Mentions
185185
const structMatch = part.match(/\[\[structure:([a-f0-9-]{36})\]\]/);
@@ -272,6 +272,37 @@ export const LabReportTemplate = React.forwardRef<HTMLDivElement, LabReportTempl
272272
return <span style={{ color: '#ef4444', fontSize: '10px' }}>Error rendering calculator</span>;
273273
}
274274
}
275+
276+
// 4. Handle Code Cell (Base64)
277+
const codeMatch = part.match(/\[\[code:([A-Za-z0-9+/=]+)\]\]/);
278+
if (codeMatch) {
279+
try {
280+
const base64 = codeMatch[1];
281+
const decoded = decodeURIComponent(escape(atob(base64)));
282+
const { code, output } = JSON.parse(decoded);
283+
284+
return (
285+
<div key={i} style={{ margin: '1.5rem 0', border: '1px solid #30363d', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#0d1117' }}>
286+
<div style={{ backgroundColor: '#161b22', padding: '0.4rem 0.75rem', borderBottom: '1px solid #30363d', display: 'flex', alignItems: 'center', gap: '8px' }}>
287+
<div style={{ width: '12px', height: '12px', color: '#3b82f6' }}>⌨️</div>
288+
<span style={{ fontSize: '9px', fontWeight: 'bold', color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Python Execution</span>
289+
</div>
290+
<div style={{ padding: '0.75rem', backgroundColor: '#0d1117' }}>
291+
<pre style={{ margin: 0, fontSize: '11px', color: '#e6edf3', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>{code}</pre>
292+
</div>
293+
{output && (
294+
<div style={{ padding: '0.75rem', backgroundColor: '#00000050', borderTop: '1px solid #30363d' }}>
295+
<p style={{ margin: '0 0 4px 0', fontSize: '8px', fontWeight: 'bold', color: '#58a6ff', textTransform: 'uppercase' }}>Output</p>
296+
<pre style={{ margin: 0, fontSize: '11px', color: '#8b949e', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>{output}</pre>
297+
</div>
298+
)}
299+
</div>
300+
);
301+
} catch (err) {
302+
console.error("PDF Code Cell Decode Error:", err);
303+
return <span style={{ color: '#ef4444', fontSize: '10px' }}>Error rendering code cell</span>;
304+
}
305+
}
275306

276307
return part;
277308
});

src/components/dashboard/RichTextEditor.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { SpreadsheetTable } from './SpreadsheetTable';
2222
import { InlineChart } from './InlineChart';
2323
import { ChemicalSketcher } from './ChemicalSketcher';
2424
import { LabCalculator } from './LabCalculator';
25+
import { CodeCell } from './CodeCell';
2526
import { createSuggestion } from './suggestion';
2627
import { useTheme } from '../../lib/ThemeContext';
2728
import {
@@ -195,6 +196,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
195196
InlineChart,
196197
ChemicalSketcher,
197198
LabCalculator,
199+
CodeCell,
198200
TableRow,
199201
TableHeader,
200202
TableCell,
@@ -301,6 +303,9 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
301303
case 'calculator':
302304
editor.chain().focus().insertContent({ type: 'labCalculator' }).run();
303305
break;
306+
case 'codeCell':
307+
editor.chain().focus().insertContent({ type: 'codeCell' }).run();
308+
break;
304309
case 'protocol':
305310
editor.chain().focus().insertContent('**Protocol:**\n\n- ').run();
306311
break;
@@ -638,6 +643,14 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
638643
<span className="font-semibold">Scientific Calculator</span>
639644
</button>
640645

646+
<button
647+
onClick={() => insertTemplate('codeCell')}
648+
className="w-full flex items-center gap-4 px-4 py-4 text-sm text-[var(--text-secondary)] hover:bg-[var(--input-bg)] rounded-xl transition-all text-left"
649+
>
650+
<Code className="w-5 h-5 text-emerald-400" />
651+
<span className="font-semibold">Code Cell (Python)</span>
652+
</button>
653+
641654
<button
642655
onClick={() => insertTemplate('setup')}
643656
className="w-full flex items-center gap-4 px-4 py-4 text-sm text-[var(--text-secondary)] hover:bg-[var(--input-bg)] rounded-xl transition-all text-left"

0 commit comments

Comments
 (0)