RealBlocks / client /src /components /ProjectEditor /ProjectEditor.tsx
SafeSight's picture
Update ProjectEditor.tsx
9d3a367
Raw
History Blame Contribute Delete
18.5 kB
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>
);
}