Spaces:
Running
Running
| import { useEffect, useState, useCallback, useMemo } from 'react'; | |
| import { useParams, useNavigate } from 'react-router-dom'; | |
| import { useProjectStore } from '../../store/projectStore'; | |
| import { useEditorStore } from '../../store/editorStore'; | |
| import { useCollaborationStore } from '../../store/collaborationStore'; | |
| import { useProjectSocket, getSocket } from '../../hooks/useWebSocket'; | |
| import { | |
| Blocks, Eye, Code2, FileType, Palette, Play, | |
| Save, ArrowLeft, Grid3X3, Maximize2, Minimize2, | |
| PanelLeft, PanelRight, Users | |
| } from 'lucide-react'; | |
| import FileTree from './FileTree'; | |
| import Toolbar from './Toolbar'; | |
| import BlockEditor from '../BlockEditor/BlockEditor'; | |
| import VisualEditor from '../VisualEditor/VisualEditor'; | |
| import CssRulePanel from '../VisualEditor/CssRulePanel'; | |
| import CodeView from '../CodeView/CodeView'; | |
| import ScriptPreview from './ScriptPreview'; | |
| import ShareModal from '../Collaboration/ShareModal'; | |
| import CollaboratorAvatars from '../Collaboration/CollaboratorAvatars'; | |
| import { ProjectFile } from '../../types/blocks'; | |
| import { generatePreviewHtml } from '../../compilers/web'; | |
| type FileCategory = 'markup' | 'script' | 'other'; | |
| function getFileCategory(fileType: string): FileCategory { | |
| if (['html', 'css'].includes(fileType)) return 'markup'; | |
| if (['js', 'ts', 'typescript', 'jsx', 'tsx', 'csharp', 'xaml'].includes(fileType)) return 'script'; | |
| return 'other'; | |
| } | |
| function findFileById(files: ProjectFile[], id: string): ProjectFile | null { | |
| for (const f of files) { | |
| if (f.id === id) return f; | |
| if (f.children) { | |
| const found = findFileById(f.children, id); | |
| if (found) return found; | |
| } | |
| } | |
| return null; | |
| } | |
| // Migrate file contents that contain legacy Blockly XML instead of generated code | |
| function migrateBlocklyXmlContent(files: ProjectFile[]): ProjectFile[] { | |
| return files.map((f) => { | |
| if (f.content && /^<xml\s|xmlns="http:\/\/www\.w3\.org\/1999\/xhtml"/.test(f.content)) { | |
| return { ...f, content: '' }; | |
| } | |
| if (f.children) { | |
| return { ...f, children: migrateBlocklyXmlContent(f.children) }; | |
| } | |
| return f; | |
| }); | |
| } | |
| export default function ProjectEditor() { | |
| const { id } = useParams<{ id: string }>(); | |
| const navigate = useNavigate(); | |
| const { currentProject, loadProject, updateProject, loading, error } = useProjectStore(); | |
| const { | |
| editorMode, setEditorMode, viewMode, setViewMode, | |
| activeFileId, setActiveFile, fileTree, setFileTree, | |
| visualElements, setVisualElements, setElementRegistry, | |
| elementRegistry, blocksXml, blockCode, cssRules, | |
| snapToGrid, toggleSnapToGrid, | |
| showMiniMap, toggleMiniMap, setBlocksXml, setCssRules | |
| } = useEditorStore(); | |
| const [showSidebar, setShowSidebar] = useState(true); | |
| const [saving, setSaving] = useState(false); | |
| const [showShareModal, setShowShareModal] = useState(false); | |
| const [showPreview, setShowPreview] = useState(false); | |
| const { emitFileChanged, emitActiveFileChanged, emitSave, emitCursorMove } = useProjectSocket(id); | |
| const connected = useCollaborationStore((s) => s.connected); | |
| useEffect(() => { | |
| if (id) loadProject(id); | |
| if (id) (window as any).__projectId = id; | |
| return () => { (window as any).__projectId = undefined; }; | |
| }, [id, loadProject]); | |
| useEffect(() => { | |
| if (currentProject?.data) { | |
| const migratedFiles = migrateBlocklyXmlContent((currentProject.data.files || [])); | |
| setFileTree(migratedFiles); | |
| // Load element registry from persisted project data | |
| const visualData = currentProject.data.visual || {}; | |
| setElementRegistry(visualData); | |
| // Set visual elements for the active file | |
| setVisualElements( | |
| visualData[activeFileId || ''] || [] | |
| ); | |
| // Restore per-file blocksXml metadata | |
| if (currentProject.data.blocksXml) { | |
| const xmlMap = currentProject.data.blocksXml; | |
| // Handle legacy string format: old blocksXml was a global string, not per-file | |
| if (typeof xmlMap === 'string') { | |
| // Discard old global blocksXml; blocks start fresh per-file | |
| } else { | |
| Object.entries(xmlMap).forEach(([fid, xml]) => { | |
| // Don't overwrite if store already has a non-empty value for this file. | |
| // The in-memory state from initialBlocksXml (sent on join_project) is | |
| // more recent than the SQLite snapshot, since live block_state_sync | |
| // happens before auto-save persists to the database. | |
| const current = useEditorStore.getState().blocksXml[fid]; | |
| if (!current || current === '') { | |
| setBlocksXml(fid, xml); | |
| } | |
| }); | |
| } | |
| } | |
| // Restore per-file block code cache | |
| const { setBlockCode } = useEditorStore.getState(); | |
| if (currentProject.data.blockCode) { | |
| Object.entries(currentProject.data.blockCode).forEach(([fid, code]) => { | |
| setBlockCode(fid, code); | |
| }); | |
| } | |
| // Restore per-file CSS rules | |
| if (currentProject.data.cssRules) { | |
| Object.entries(currentProject.data.cssRules).forEach(([fid, rules]) => { | |
| setCssRules(fid, rules as any[]); | |
| }); | |
| } | |
| if (!activeFileId && currentProject.data.files?.length > 0) { | |
| const firstFile = currentProject.data.files[0]; | |
| if (firstFile) { | |
| setActiveFile(firstFile.id); | |
| const cat = getFileCategory(firstFile.type); | |
| if (firstFile.type === 'css') setEditorMode('css'); | |
| else if (cat === 'markup') setEditorMode('visual'); | |
| else if (cat === 'script') setEditorMode('blocks'); | |
| } | |
| } | |
| } | |
| }, [currentProject, setFileTree, setVisualElements, setActiveFile, activeFileId, setEditorMode, setBlocksXml, setElementRegistry, setCssRules]); | |
| // When activeFileId changes, load visual elements from the registry | |
| useEffect(() => { | |
| if (activeFileId) { | |
| const registry = useEditorStore.getState().elementRegistry; | |
| setVisualElements(registry[activeFileId] || []); | |
| } | |
| }, [activeFileId, setVisualElements]); | |
| const activeFile = useMemo( | |
| () => (activeFileId ? findFileById(fileTree, activeFileId) : null), | |
| [activeFileId, fileTree] | |
| ); | |
| const fileCategory = activeFile ? getFileCategory(activeFile.type) : 'other'; | |
| // Check if the active file is script linked under an HTML file (for preview) | |
| const isScriptUnderHtml = useMemo(() => { | |
| if (!activeFile || fileCategory !== 'script') return false; | |
| // Check if any HTML file has this script as a linked file | |
| const htmlFiles = fileTree.filter(f => f.type === 'html'); | |
| return htmlFiles.some(html => (html.linkedFiles || []).includes(activeFile.id)); | |
| }, [activeFile, fileCategory, fileTree]); | |
| const availableModes = useMemo(() => { | |
| if (fileCategory === 'markup') { | |
| if (activeFile?.type === 'css') { | |
| return [ | |
| { id: 'css' as const, label: 'Styles', icon: Palette }, | |
| { id: 'code' as const, label: 'Code', icon: Code2 }, | |
| { id: 'files' as const, label: 'Files', icon: FileType }, | |
| ]; | |
| } | |
| return [ | |
| { id: 'visual' as const, label: 'Visual', icon: Eye }, | |
| { id: 'code' as const, label: 'Code', icon: Code2 }, | |
| { id: 'files' as const, label: 'Files', icon: FileType }, | |
| ]; | |
| } | |
| if (fileCategory === 'script') { | |
| const modes: { id: string; label: string; icon: any }[] = [ | |
| { id: 'blocks', label: 'Blocks', icon: Blocks }, | |
| { id: 'code', label: 'Code', icon: Code2 }, | |
| ]; | |
| modes.push({ id: 'files', label: 'Files', icon: FileType }); | |
| return modes; | |
| } | |
| return [ | |
| { id: 'code' as const, label: 'Code', icon: Code2 }, | |
| { id: 'files' as const, label: 'Files', icon: FileType }, | |
| ]; | |
| }, [fileCategory, isScriptUnderHtml, activeFile]); | |
| useEffect(() => { | |
| if (!activeFileId) return; | |
| const validIds = availableModes.map(m => m.id); | |
| if (!validIds.includes(editorMode)) { | |
| setEditorMode(validIds[0] as any); | |
| } | |
| setViewMode('design'); | |
| }, [activeFileId, availableModes]); | |
| // Compute preview content for ScriptPreview | |
| const previewContent = useMemo(() => { | |
| if (!activeFile || fileCategory !== 'script' || !isScriptUnderHtml) return null; | |
| // Find the parent HTML file that links this script | |
| const htmlFile = fileTree.find(f => f.type === 'html' && (f.linkedFiles || []).includes(activeFile.id)); | |
| if (!htmlFile) return null; | |
| // Build htmlContent: prefer file content, otherwise generate from visual elements | |
| let htmlContent: string; | |
| if (htmlFile.content) { | |
| htmlContent = htmlFile.content; | |
| } else { | |
| const elements = elementRegistry[htmlFile.id] || []; | |
| if (elements.length > 0) { | |
| // Inline linked CSS content so it works inside the sandboxed iframe | |
| const linkedIds = htmlFile.linkedFiles || []; | |
| const linkedCssContent = linkedIds | |
| .map(id => findFileById(fileTree, id)) | |
| .filter((f): f is ProjectFile => f !== null && f.type === 'css') | |
| .map(f => ({ content: f.content || '' })); | |
| htmlContent = generatePreviewHtml({ | |
| visualElements: elements, | |
| projectName: currentProject?.name || 'My Web App', | |
| linkedCssContent, | |
| }); | |
| } else { | |
| htmlContent = ''; | |
| } | |
| } | |
| return { | |
| htmlContent, | |
| scriptContent: activeFile.content || '', | |
| fileName: activeFile.name, | |
| }; | |
| }, [activeFile, fileCategory, isScriptUnderHtml, fileTree, elementRegistry, currentProject]); | |
| // Emit active file changes to collaborators | |
| useEffect(() => { | |
| if (id && activeFileId) { | |
| emitActiveFileChanged(activeFileId); | |
| } | |
| }, [id, activeFileId, emitActiveFileChanged]); | |
| // Set up global block selection WS emit | |
| useEffect(() => { | |
| (window as any).__wsBlockSelection = (blockId: string | null) => { | |
| const projectId = (window as any).__projectId; | |
| const socket = (window as any).__wsSocket || getSocket(); | |
| if (socket?.connected && projectId) { | |
| socket.emit('block_selection_changed', { projectId, blockId }); | |
| } | |
| }; | |
| return () => { (window as any).__wsBlockSelection = undefined; }; | |
| }, []); | |
| // Keep global socket reference | |
| useEffect(() => { | |
| const socket = getSocket(); | |
| (window as any).__wsSocket = socket; | |
| }); | |
| const handleSave = useCallback(async () => { | |
| if (!currentProject || !id) return; | |
| setSaving(true); | |
| try { | |
| const { blocksXml: currentBlocksXml, blockCode: currentBlockCode, elementRegistry: currentRegistry, cssRules: currentCssRules } = useEditorStore.getState(); | |
| // Sync current visual elements into registry before saving | |
| const fullRegistry = { ...currentRegistry }; | |
| if (activeFileId) { | |
| fullRegistry[activeFileId] = visualElements; | |
| } | |
| const data = { | |
| ...currentProject.data, | |
| files: fileTree, | |
| visual: fullRegistry, | |
| blocksXml: currentBlocksXml, | |
| blockCode: currentBlockCode, | |
| cssRules: currentCssRules, | |
| }; | |
| // Try WebSocket save first, fall back to REST | |
| if (connected) { | |
| await emitSave(data); | |
| } else { | |
| await updateProject(id, { data }); | |
| } | |
| } finally { | |
| setSaving(false); | |
| } | |
| }, [currentProject, id, fileTree, visualElements, activeFileId, updateProject, connected, emitSave]); | |
| // Auto-save via WebSocket when data changes (no more 30s polling) | |
| useEffect(() => { | |
| if (!currentProject || !id || !connected) return; | |
| const timer = setTimeout(() => handleSave(), 2000); | |
| return () => clearTimeout(timer); | |
| }, [currentProject, id, fileTree, visualElements, elementRegistry, activeFileId, blocksXml, blockCode, cssRules, connected, handleSave]); | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 's') { | |
| e.preventDefault(); | |
| handleSave(); | |
| } | |
| }; | |
| window.addEventListener('keydown', handleKeyDown); | |
| return () => window.removeEventListener('keydown', handleKeyDown); | |
| }, [handleSave]); | |
| if (loading && !currentProject) { | |
| return ( | |
| <div className="min-h-screen bg-surface-950 flex items-center justify-center"> | |
| <div className="flex flex-col items-center gap-4"> | |
| <div className="animate-spin rounded-full h-10 w-10 border-2 border-primary-500 border-t-transparent" /> | |
| <p className="text-surface-400 text-sm">Loading project...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (error && !currentProject) { | |
| return ( | |
| <div className="min-h-screen bg-surface-950 flex items-center justify-center"> | |
| <div className="text-center max-w-md mx-auto p-8"> | |
| <div className="text-red-400 text-lg font-semibold mb-2">Failed to load project</div> | |
| <p className="text-surface-400 text-sm mb-6">{error}</p> | |
| <button onClick={() => navigate('/dashboard')} className="btn-primary"> | |
| Back to Dashboard | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (!currentProject) { | |
| return ( | |
| <div className="min-h-screen bg-surface-950 flex items-center justify-center"> | |
| <p className="text-surface-400 text-sm">No project data</p> | |
| </div> | |
| ); | |
| } | |
| const framework = currentProject.framework; | |
| const displayMode = !activeFileId ? 'files' : editorMode; | |
| return ( | |
| <div className="h-[calc(100vh-3.5rem)] bg-surface-950 flex flex-col"> | |
| <Toolbar | |
| projectName={currentProject.name} | |
| framework={currentProject.framework} | |
| onBack={() => navigate('/dashboard')} | |
| onSave={handleSave} | |
| saving={saving} | |
| leftContent={ | |
| <div className="flex items-center gap-1 bg-surface-800 rounded-lg p-0.5"> | |
| {availableModes.map((mode) => { | |
| const Icon = mode.icon; | |
| return ( | |
| <button | |
| key={mode.id} | |
| onClick={() => setEditorMode(mode.id as any)} | |
| className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${ | |
| displayMode === mode.id | |
| ? 'bg-primary-600 text-white' | |
| : 'text-surface-400 hover:text-white' | |
| }`} | |
| > | |
| <Icon className="w-3.5 h-3.5" /> | |
| {mode.label} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| } | |
| rightContent={ | |
| <div className="flex items-center gap-2"> | |
| <CollaboratorAvatars /> | |
| {displayMode === 'visual' && ( | |
| <> | |
| <button | |
| onClick={toggleSnapToGrid} | |
| className={`btn-ghost p-1.5 ${snapToGrid ? 'text-primary-400' : 'text-surface-400'}`} | |
| title="Snap to Grid" | |
| > | |
| <Grid3X3 className="w-4 h-4" /> | |
| </button> | |
| <button | |
| onClick={toggleMiniMap} | |
| className={`btn-ghost p-1.5 ${showMiniMap ? 'text-primary-400' : 'text-surface-400'}`} | |
| title="Toggle Minimap" | |
| > | |
| {showMiniMap ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />} | |
| </button> | |
| </> | |
| )} | |
| {isScriptUnderHtml && ( | |
| <button | |
| onClick={() => setShowPreview(!showPreview)} | |
| className={`flex items-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${ | |
| showPreview ? 'bg-green-600 text-white' : 'text-surface-400 hover:text-white hover:bg-surface-800' | |
| }`} | |
| title="Run script preview" | |
| > | |
| <Play className="w-3.5 h-3.5" /> | |
| {showPreview ? 'Close Preview' : 'Run'} | |
| </button> | |
| )} | |
| <button | |
| onClick={() => setShowShareModal(true)} | |
| className={`btn-ghost p-1.5 ${connected ? 'text-green-400' : 'text-surface-400'}`} | |
| title={connected ? 'Collaborators online' : 'Share project'} | |
| > | |
| <Users className="w-4 h-4" /> | |
| </button> | |
| <button | |
| onClick={() => setShowSidebar(!showSidebar)} | |
| className="btn-ghost p-1.5" | |
| title="Toggle Sidebar" | |
| > | |
| {showSidebar ? <PanelLeft className="w-4 h-4" /> : <PanelRight className="w-4 h-4" />} | |
| </button> | |
| <button | |
| onClick={handleSave} | |
| disabled={saving} | |
| className="btn-primary text-xs py-1.5" | |
| > | |
| <Save className="w-3.5 h-3.5" /> | |
| {saving ? 'Saving...' : 'Save'} | |
| </button> | |
| </div> | |
| } | |
| /> | |
| <div className="flex-1 flex overflow-hidden"> | |
| {showSidebar && ( | |
| <div className="w-56 panel flex-shrink-0 overflow-y-auto"> | |
| <FileTree | |
| files={fileTree} | |
| activeFileId={activeFileId} | |
| onFileSelect={setActiveFile} | |
| framework={framework} | |
| /> | |
| </div> | |
| )} | |
| <div className="flex-1 flex overflow-hidden"> | |
| {showPreview && previewContent && ( | |
| <div className="w-96 flex-shrink-0 border-r border-surface-700"> | |
| <ScriptPreview | |
| htmlContent={previewContent.htmlContent} | |
| scriptContent={previewContent.scriptContent} | |
| fileName={previewContent.fileName} | |
| onClose={() => setShowPreview(false)} | |
| /> | |
| </div> | |
| )} | |
| <div className="flex-1 overflow-hidden"> | |
| {displayMode === 'blocks' && <BlockEditor />} | |
| {displayMode === 'visual' && <VisualEditor />} | |
| {displayMode === 'css' && <CssRulePanel />} | |
| {displayMode === 'code' && <CodeView />} | |
| {displayMode === 'files' && ( | |
| <div className="p-6"> | |
| <FileTree | |
| files={fileTree} | |
| activeFileId={activeFileId} | |
| onFileSelect={setActiveFile} | |
| framework={framework} | |
| expanded | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {id && ( | |
| <ShareModal | |
| projectId={id} | |
| isOpen={showShareModal} | |
| onClose={() => setShowShareModal(false)} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| } | |