Spaces:
Sleeping
Sleeping
incognitolm commited on
Commit ·
2595e43
1
Parent(s): 2b73dbf
Roll Back
Browse files- client/package.json +0 -1
- client/src/api/client.ts +2 -2
- client/src/components/CodeView/CodeView.tsx +1 -48
- client/src/components/VisualEditor/LiveCanvas.tsx +0 -147
- client/src/components/VisualEditor/PropertyPanel.tsx +12 -54
- client/src/components/VisualEditor/Toolbox.tsx +2 -7
- client/src/components/VisualEditor/VisualEditor.tsx +295 -218
- client/src/components/VisualEditor/editorSandbox.ts +0 -174
- client/src/hooks/useWebSocket.ts +3 -165
- client/src/store/collaborationStore.ts +0 -28
- client/src/store/editorStore.ts +50 -209
- client/src/store/projectStore.ts +7 -52
- client/src/types/blocks.ts +0 -11
- client/src/utils/formatCode.ts +0 -42
- client/tsconfig.json +1 -2
- client/vite.config.ts +0 -1
- server/src/services/realtime.ts +48 -202
- server/src/services/wsStore.ts +0 -195
- shared/types.ts +0 -255
client/package.json
CHANGED
|
@@ -35,7 +35,6 @@
|
|
| 35 |
"@types/prismjs": "^1.26.3",
|
| 36 |
"@types/react-syntax-highlighter": "^15.5.11",
|
| 37 |
"@vitejs/plugin-react": "^4.2.1",
|
| 38 |
-
"prettier": "^3.2.0",
|
| 39 |
"autoprefixer": "^10.4.16",
|
| 40 |
"postcss": "^8.4.32",
|
| 41 |
"tailwindcss": "^3.4.0",
|
|
|
|
| 35 |
"@types/prismjs": "^1.26.3",
|
| 36 |
"@types/react-syntax-highlighter": "^15.5.11",
|
| 37 |
"@vitejs/plugin-react": "^4.2.1",
|
|
|
|
| 38 |
"autoprefixer": "^10.4.16",
|
| 39 |
"postcss": "^8.4.32",
|
| 40 |
"tailwindcss": "^3.4.0",
|
client/src/api/client.ts
CHANGED
|
@@ -47,7 +47,7 @@ class ApiClient {
|
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
| 50 |
-
// Auth
|
| 51 |
async register(email: string, username: string, password: string) {
|
| 52 |
return this.request<{ token: string; user: any }>('POST', '/auth/register', { email, username, password });
|
| 53 |
}
|
|
@@ -76,7 +76,7 @@ class ApiClient {
|
|
| 76 |
return this.request<{ user: any }>('GET', '/auth/me');
|
| 77 |
}
|
| 78 |
|
| 79 |
-
// Projects
|
| 80 |
async getProjects() {
|
| 81 |
return this.request<{ projects: any[] }>('GET', '/projects');
|
| 82 |
}
|
|
|
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
| 50 |
+
// Auth
|
| 51 |
async register(email: string, username: string, password: string) {
|
| 52 |
return this.request<{ token: string; user: any }>('POST', '/auth/register', { email, username, password });
|
| 53 |
}
|
|
|
|
| 76 |
return this.request<{ user: any }>('GET', '/auth/me');
|
| 77 |
}
|
| 78 |
|
| 79 |
+
// Projects
|
| 80 |
async getProjects() {
|
| 81 |
return this.request<{ projects: any[] }>('GET', '/projects');
|
| 82 |
}
|
client/src/components/CodeView/CodeView.tsx
CHANGED
|
@@ -10,10 +10,9 @@ import { compileElectron } from '../../compilers/electron';
|
|
| 10 |
import { compileMaui } from '../../compilers/maui';
|
| 11 |
import { compileNodeJS } from '../../compilers/nodejs';
|
| 12 |
import { SyntaxHighlight } from './SyntaxHighlight';
|
| 13 |
-
import { formatCode, getFileLanguage } from '../../utils/formatCode';
|
| 14 |
import {
|
| 15 |
Download, Copy, FileCode,
|
| 16 |
-
Eye, Code2, SplitSquareHorizontal
|
| 17 |
} from 'lucide-react';
|
| 18 |
import { saveAs } from 'file-saver';
|
| 19 |
import JSZip from 'jszip';
|
|
@@ -27,31 +26,6 @@ export default function CodeView() {
|
|
| 27 |
const { emitCursorMove, emitFileChanged } = useWebSocket(id);
|
| 28 |
const editorRef = useRef<HTMLDivElement>(null);
|
| 29 |
const [isEditing, setIsEditing] = useState(false);
|
| 30 |
-
const [formattedContent, setFormattedContent] = useState('');
|
| 31 |
-
|
| 32 |
-
const activeFile = useMemo(() => {
|
| 33 |
-
return fileTree.find(f => f.id === activeFileId) || null;
|
| 34 |
-
}, [fileTree, activeFileId]);
|
| 35 |
-
|
| 36 |
-
// Format on file load
|
| 37 |
-
useEffect(() => {
|
| 38 |
-
if (activeFile?.content && isEditing) {
|
| 39 |
-
const lang = getFileLanguage(activeFile.type);
|
| 40 |
-
formatCode(activeFile.content, lang).then(setFormattedContent);
|
| 41 |
-
} else {
|
| 42 |
-
setFormattedContent(activeFileContent);
|
| 43 |
-
}
|
| 44 |
-
}, [activeFile?.content, activeFile?.type, isEditing]);
|
| 45 |
-
|
| 46 |
-
// Format on save handler
|
| 47 |
-
const handleFormatSave = useCallback(async () => {
|
| 48 |
-
if (!activeFile || !isEditing) return;
|
| 49 |
-
const lang = getFileLanguage(activeFile.type);
|
| 50 |
-
const formatted = await formatCode(activeFileContent, lang);
|
| 51 |
-
useEditorStore.getState().saveGeneratedCodeToFile(activeFile.id, formatted);
|
| 52 |
-
useEditorStore.getState().setActiveFileContent(formatted);
|
| 53 |
-
setFormattedContent(formatted);
|
| 54 |
-
}, [activeFile, activeFileContent, isEditing]);
|
| 55 |
|
| 56 |
const compiledCode = useMemo(() => {
|
| 57 |
if (!currentProject) return '';
|
|
@@ -110,19 +84,6 @@ export default function CodeView() {
|
|
| 110 |
}, 100);
|
| 111 |
}, [activeFileId, emitCursorMove, emitFileChanged, setActiveFileContent]);
|
| 112 |
|
| 113 |
-
// Ctrl+S handler in edit mode
|
| 114 |
-
useEffect(() => {
|
| 115 |
-
if (!isEditing) return;
|
| 116 |
-
const handler = (e: KeyboardEvent) => {
|
| 117 |
-
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
| 118 |
-
e.preventDefault();
|
| 119 |
-
handleFormatSave();
|
| 120 |
-
}
|
| 121 |
-
};
|
| 122 |
-
window.addEventListener('keydown', handler);
|
| 123 |
-
return () => window.removeEventListener('keydown', handler);
|
| 124 |
-
}, [handleFormatSave, isEditing]);
|
| 125 |
-
|
| 126 |
const handleDownload = useCallback(async () => {
|
| 127 |
if (!currentProject) return;
|
| 128 |
const zip = new JSZip();
|
|
@@ -171,14 +132,6 @@ export default function CodeView() {
|
|
| 171 |
</button>
|
| 172 |
</div>
|
| 173 |
)}
|
| 174 |
-
{isEditing && (
|
| 175 |
-
<button onClick={handleFormatSave}
|
| 176 |
-
className="btn-ghost px-2 py-1 text-xs text-green-400"
|
| 177 |
-
title="Format & Save (Ctrl+S)">
|
| 178 |
-
<Save className="w-3.5 h-3.5" />
|
| 179 |
-
Format & Save
|
| 180 |
-
</button>
|
| 181 |
-
)}
|
| 182 |
<button onClick={() => setIsEditing(!isEditing)}
|
| 183 |
className={`btn-ghost px-2 py-1 text-xs ${isEditing ? 'text-primary-400 bg-primary-500/10' : ''}`}
|
| 184 |
title={isEditing ? 'View compiled code' : 'Edit source directly'}>
|
|
|
|
| 10 |
import { compileMaui } from '../../compilers/maui';
|
| 11 |
import { compileNodeJS } from '../../compilers/nodejs';
|
| 12 |
import { SyntaxHighlight } from './SyntaxHighlight';
|
|
|
|
| 13 |
import {
|
| 14 |
Download, Copy, FileCode,
|
| 15 |
+
Eye, Code2, SplitSquareHorizontal
|
| 16 |
} from 'lucide-react';
|
| 17 |
import { saveAs } from 'file-saver';
|
| 18 |
import JSZip from 'jszip';
|
|
|
|
| 26 |
const { emitCursorMove, emitFileChanged } = useWebSocket(id);
|
| 27 |
const editorRef = useRef<HTMLDivElement>(null);
|
| 28 |
const [isEditing, setIsEditing] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
const compiledCode = useMemo(() => {
|
| 31 |
if (!currentProject) return '';
|
|
|
|
| 84 |
}, 100);
|
| 85 |
}, [activeFileId, emitCursorMove, emitFileChanged, setActiveFileContent]);
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
const handleDownload = useCallback(async () => {
|
| 88 |
if (!currentProject) return;
|
| 89 |
const zip = new JSZip();
|
|
|
|
| 132 |
</button>
|
| 133 |
</div>
|
| 134 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
<button onClick={() => setIsEditing(!isEditing)}
|
| 136 |
className={`btn-ghost px-2 py-1 text-xs ${isEditing ? 'text-primary-400 bg-primary-500/10' : ''}`}
|
| 137 |
title={isEditing ? 'View compiled code' : 'Edit source directly'}>
|
client/src/components/VisualEditor/LiveCanvas.tsx
DELETED
|
@@ -1,147 +0,0 @@
|
|
| 1 |
-
import { useRef, useEffect, useCallback, useState, MutableRefObject } from 'react';
|
| 2 |
-
import { generateEditorPage } from './editorSandbox';
|
| 3 |
-
|
| 4 |
-
interface ElementPreset {
|
| 5 |
-
tagName: string;
|
| 6 |
-
label: string;
|
| 7 |
-
icon: any;
|
| 8 |
-
defaultProps: any;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
interface LiveCanvasProps {
|
| 12 |
-
html: string;
|
| 13 |
-
css: string;
|
| 14 |
-
js: string;
|
| 15 |
-
showOutlines: boolean;
|
| 16 |
-
selectedSelector: string | null;
|
| 17 |
-
onElementClick: (selector: string) => void;
|
| 18 |
-
onElementDrop: (parentSelector: string, slotIndex: number) => void;
|
| 19 |
-
onElementDropFromToolbox: (preset: ElementPreset, parentSelector: string, slotIndex: number) => void;
|
| 20 |
-
onCanvasClick: () => void;
|
| 21 |
-
dragDataRef: MutableRefObject<ElementPreset | null>;
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
export default function LiveCanvas({
|
| 25 |
-
html, css, js, showOutlines, selectedSelector,
|
| 26 |
-
onElementClick, onElementDrop, onElementDropFromToolbox, onCanvasClick, dragDataRef,
|
| 27 |
-
}: LiveCanvasProps) {
|
| 28 |
-
const iframeRef = useRef<HTMLIFrameElement>(null);
|
| 29 |
-
const overlayRef = useRef<HTMLDivElement>(null);
|
| 30 |
-
const [ready, setReady] = useState(false);
|
| 31 |
-
const [initContent, setInitContent] = useState(true);
|
| 32 |
-
|
| 33 |
-
// Initial page load
|
| 34 |
-
useEffect(() => {
|
| 35 |
-
if (initContent && iframeRef.current) {
|
| 36 |
-
const page = generateEditorPage(html, css, js);
|
| 37 |
-
iframeRef.current.srcdoc = page;
|
| 38 |
-
setInitContent(false);
|
| 39 |
-
}
|
| 40 |
-
}, [initContent, html, css, js]);
|
| 41 |
-
|
| 42 |
-
// Update content via postMessage after initial load
|
| 43 |
-
useEffect(() => {
|
| 44 |
-
if (!ready || !iframeRef.current?.contentWindow) return;
|
| 45 |
-
iframeRef.current.contentWindow.postMessage({
|
| 46 |
-
type: 'UPDATE_CONTENT',
|
| 47 |
-
html, css, js,
|
| 48 |
-
}, '*');
|
| 49 |
-
}, [html, css, js, ready]);
|
| 50 |
-
|
| 51 |
-
// Update selected element highlight
|
| 52 |
-
useEffect(() => {
|
| 53 |
-
if (!ready || !iframeRef.current?.contentWindow) return;
|
| 54 |
-
iframeRef.current.contentWindow.postMessage({
|
| 55 |
-
type: 'SELECT_ELEMENT',
|
| 56 |
-
selector: selectedSelector,
|
| 57 |
-
}, '*');
|
| 58 |
-
}, [selectedSelector, ready]);
|
| 59 |
-
|
| 60 |
-
// Update outline visibility
|
| 61 |
-
useEffect(() => {
|
| 62 |
-
if (!ready || !iframeRef.current?.contentWindow) return;
|
| 63 |
-
iframeRef.current.contentWindow.postMessage({
|
| 64 |
-
type: 'SHOW_OUTLINES',
|
| 65 |
-
show: showOutlines,
|
| 66 |
-
}, '*');
|
| 67 |
-
}, [showOutlines, ready]);
|
| 68 |
-
|
| 69 |
-
// Store callbacks in refs for stable message handler
|
| 70 |
-
const callbacksRef = useRef({ onElementClick, onElementDrop, onElementDropFromToolbox, onCanvasClick, dragDataRef });
|
| 71 |
-
callbacksRef.current = { onElementClick, onElementDrop, onElementDropFromToolbox, onCanvasClick, dragDataRef };
|
| 72 |
-
|
| 73 |
-
// Handle drop response from iframe (sent after GET_ELEMENT_AT query)
|
| 74 |
-
const handleIframeDrop = useCallback((selector: string) => {
|
| 75 |
-
const { dragDataRef: ddr, onElementDrop: oed, onElementDropFromToolbox: oedft } = callbacksRef.current;
|
| 76 |
-
const preset = ddr.current;
|
| 77 |
-
if (!preset) {
|
| 78 |
-
oed(selector, 0);
|
| 79 |
-
return;
|
| 80 |
-
}
|
| 81 |
-
oedft(preset, selector, 0);
|
| 82 |
-
ddr.current = null;
|
| 83 |
-
}, []);
|
| 84 |
-
|
| 85 |
-
// Receive messages from iframe
|
| 86 |
-
useEffect(() => {
|
| 87 |
-
const handler = (event: MessageEvent) => {
|
| 88 |
-
if (event.source !== iframeRef.current?.contentWindow) return;
|
| 89 |
-
const data = event.data;
|
| 90 |
-
const cbs = callbacksRef.current;
|
| 91 |
-
switch (data.type) {
|
| 92 |
-
case 'ELEMENT_CLICK':
|
| 93 |
-
cbs.onElementClick(data.selector);
|
| 94 |
-
break;
|
| 95 |
-
case 'ELEMENT_DROP':
|
| 96 |
-
handleIframeDrop(data.selector);
|
| 97 |
-
break;
|
| 98 |
-
case 'ELEMENT_DROP_IN_SLOT':
|
| 99 |
-
cbs.onElementDrop(data.parentSelector, data.slotIndex);
|
| 100 |
-
break;
|
| 101 |
-
case 'CANVAS_CLICK':
|
| 102 |
-
cbs.onCanvasClick();
|
| 103 |
-
break;
|
| 104 |
-
case 'READY':
|
| 105 |
-
setReady(true);
|
| 106 |
-
break;
|
| 107 |
-
}
|
| 108 |
-
};
|
| 109 |
-
window.addEventListener('message', handler);
|
| 110 |
-
return () => window.removeEventListener('message', handler);
|
| 111 |
-
}, [handleIframeDrop]);
|
| 112 |
-
|
| 113 |
-
// Handle overlay drop: query iframe for element at drop point
|
| 114 |
-
const handleOverlayDrop = useCallback((e: React.DragEvent) => {
|
| 115 |
-
e.preventDefault();
|
| 116 |
-
const rect = iframeRef.current?.getBoundingClientRect();
|
| 117 |
-
if (!rect || !iframeRef.current?.contentWindow) return;
|
| 118 |
-
const x = e.clientX - rect.left;
|
| 119 |
-
const y = e.clientY - rect.top;
|
| 120 |
-
iframeRef.current.contentWindow.postMessage({
|
| 121 |
-
type: 'GET_ELEMENT_AT',
|
| 122 |
-
x, y, action: 'drop',
|
| 123 |
-
}, '*');
|
| 124 |
-
}, []);
|
| 125 |
-
|
| 126 |
-
const handleOverlayDragOver = useCallback((e: React.DragEvent) => {
|
| 127 |
-
e.preventDefault();
|
| 128 |
-
}, []);
|
| 129 |
-
|
| 130 |
-
return (
|
| 131 |
-
<div className="relative w-full h-full">
|
| 132 |
-
<iframe
|
| 133 |
-
ref={iframeRef}
|
| 134 |
-
className="w-full h-full border-0"
|
| 135 |
-
sandbox="allow-scripts allow-same-origin allow-forms allow-modals"
|
| 136 |
-
title="Live Canvas Preview"
|
| 137 |
-
/>
|
| 138 |
-
<div
|
| 139 |
-
ref={overlayRef}
|
| 140 |
-
className="absolute inset-0 z-10"
|
| 141 |
-
onDragOver={handleOverlayDragOver}
|
| 142 |
-
onDrop={handleOverlayDrop}
|
| 143 |
-
onClick={() => onCanvasClick()}
|
| 144 |
-
/>
|
| 145 |
-
</div>
|
| 146 |
-
);
|
| 147 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client/src/components/VisualEditor/PropertyPanel.tsx
CHANGED
|
@@ -5,13 +5,14 @@ import {
|
|
| 5 |
Plus, Trash2, Palette, Type, Move, Square,
|
| 6 |
Bold, Italic, AlignLeft, AlignCenter, AlignRight,
|
| 7 |
MousePointer, EyeOff, Maximize2, Grid3X3, Play, RotateCcw,
|
| 8 |
-
Underline, Strikethrough
|
| 9 |
} from 'lucide-react';
|
| 10 |
|
| 11 |
interface PropertyPanelProps {
|
| 12 |
element: VisualElement | null;
|
| 13 |
}
|
| 14 |
|
|
|
|
| 15 |
const SPACING_PRESETS = ['0', '4px', '8px', '12px', '16px', '24px', '32px', '48px'];
|
| 16 |
const RADIUS_PRESETS = [
|
| 17 |
{ label: 'None', value: '0' }, { label: 'Small', value: '4px' },
|
|
@@ -148,10 +149,7 @@ const CSS_CATEGORIES: { name: string; icon: any; properties: CatProp[] }[] = [
|
|
| 148 |
];
|
| 149 |
|
| 150 |
export default function PropertyPanel({ element }: PropertyPanelProps) {
|
| 151 |
-
const {
|
| 152 |
-
updateVisualElement, removeVisualElement, setSelectedElement,
|
| 153 |
-
setSlotCount, slotCounts, setCustomId, incrementCounter,
|
| 154 |
-
} = useEditorStore();
|
| 155 |
const [expandedCat, setExpandedCat] = useState<string>('Colors');
|
| 156 |
const [addingToCat, setAddingToCat] = useState<string | null>(null);
|
| 157 |
|
|
@@ -186,24 +184,6 @@ export default function PropertyPanel({ element }: PropertyPanelProps) {
|
|
| 186 |
setAddingToCat(null);
|
| 187 |
};
|
| 188 |
|
| 189 |
-
// Slot count handler
|
| 190 |
-
const currentSlotCount = slotCounts[element.id] ?? Math.max((element.children || []).length + 1, 1);
|
| 191 |
-
const childCount = (element.children || []).length;
|
| 192 |
-
|
| 193 |
-
const handleSlotChange = (newCount: number) => {
|
| 194 |
-
setSlotCount(element.id, Math.max(newCount, childCount, 1));
|
| 195 |
-
};
|
| 196 |
-
|
| 197 |
-
// ID edit handler
|
| 198 |
-
const handleIdChange = (newId: string) => {
|
| 199 |
-
const oldId = element.props?.id || '';
|
| 200 |
-
updateVisualElement(element.id, {
|
| 201 |
-
props: { ...element.props, id: newId },
|
| 202 |
-
attributes: { ...element.attributes, id: newId },
|
| 203 |
-
});
|
| 204 |
-
setCustomId(element.id, newId.length > 0);
|
| 205 |
-
};
|
| 206 |
-
|
| 207 |
const renderStyleEditor = (prop: CatProp) => {
|
| 208 |
const value = (element.styles || {})[prop.key] ?? '';
|
| 209 |
return (
|
|
@@ -267,8 +247,8 @@ export default function PropertyPanel({ element }: PropertyPanelProps) {
|
|
| 267 |
<div className="grid grid-cols-2 gap-1.5">
|
| 268 |
<div>
|
| 269 |
<label className="text-[10px] text-surface-400 block mb-0.5">ID</label>
|
| 270 |
-
<input type="text" value={element.props?.id || ''} onChange={(e) =>
|
| 271 |
-
className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none" placeholder="
|
| 272 |
</div>
|
| 273 |
<div>
|
| 274 |
<label className="text-[10px] text-surface-400 block mb-0.5">Classes</label>
|
|
@@ -278,34 +258,6 @@ export default function PropertyPanel({ element }: PropertyPanelProps) {
|
|
| 278 |
</div>
|
| 279 |
</div>
|
| 280 |
|
| 281 |
-
{/* Slot Count Control */}
|
| 282 |
-
<div className="border-t border-surface-700 pt-2">
|
| 283 |
-
<div className="flex items-center justify-between">
|
| 284 |
-
<label className="text-[10px] text-surface-400 flex items-center gap-1">
|
| 285 |
-
<Layers className="w-3 h-3" /> Slots
|
| 286 |
-
</label>
|
| 287 |
-
<div className="flex items-center gap-1">
|
| 288 |
-
<button
|
| 289 |
-
onClick={() => handleSlotChange(currentSlotCount - 1)}
|
| 290 |
-
disabled={currentSlotCount <= Math.max(childCount, 1)}
|
| 291 |
-
className="p-0.5 rounded text-surface-400 hover:text-white hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
| 292 |
-
>
|
| 293 |
-
<Minus className="w-3 h-3" />
|
| 294 |
-
</button>
|
| 295 |
-
<span className="text-[11px] text-surface-200 w-6 text-center">{currentSlotCount}</span>
|
| 296 |
-
<button
|
| 297 |
-
onClick={() => handleSlotChange(currentSlotCount + 1)}
|
| 298 |
-
className="p-0.5 rounded text-surface-400 hover:text-white hover:bg-surface-700"
|
| 299 |
-
>
|
| 300 |
-
<Plus className="w-3 h-3" />
|
| 301 |
-
</button>
|
| 302 |
-
</div>
|
| 303 |
-
</div>
|
| 304 |
-
<p className="text-[9px] text-surface-500 mt-0.5">
|
| 305 |
-
Drop zone slots for children {childCount > 0 ? `(${childCount} children)` : '(empty)'}
|
| 306 |
-
</p>
|
| 307 |
-
</div>
|
| 308 |
-
|
| 309 |
{/* Text Content */}
|
| 310 |
{['p','h1','h2','h3','h4','h5','h6','span','a','button','label','li','strong','em','b','i','u','s','mark','figcaption','legend','summary','dt','dd','th','td','caption','option','blockquote','pre','code','abbr','cite','time','address'].includes(element.tagName) && (
|
| 311 |
<div className="border-t border-surface-700 pt-2">
|
|
@@ -373,7 +325,7 @@ export default function PropertyPanel({ element }: PropertyPanelProps) {
|
|
| 373 |
</div>
|
| 374 |
)}
|
| 375 |
|
| 376 |
-
{/* CSS CATEGORIES WITH PLUS BUTTONS */}
|
| 377 |
<div className="border-t border-surface-700 pt-2 space-y-1">
|
| 378 |
<h4 className="text-[10px] font-semibold text-surface-400 uppercase tracking-wider mb-1">CSS Styles</h4>
|
| 379 |
|
|
@@ -383,6 +335,7 @@ export default function PropertyPanel({ element }: PropertyPanelProps) {
|
|
| 383 |
const activeCount = cat.properties.filter(p => activeStyles.has(p.key)).length;
|
| 384 |
return (
|
| 385 |
<div key={cat.name} className="border border-surface-700/50 rounded-md">
|
|
|
|
| 386 |
<button onClick={() => setExpandedCat(isOpen ? '' : cat.name)}
|
| 387 |
className="w-full flex items-center gap-1.5 px-2 py-1.5 bg-surface-800/50 hover:bg-surface-800 text-xs text-surface-300 transition-colors rounded-t-md">
|
| 388 |
<CatIcon className="w-3.5 h-3.5 text-surface-400" />
|
|
@@ -392,8 +345,10 @@ export default function PropertyPanel({ element }: PropertyPanelProps) {
|
|
| 392 |
|
| 393 |
{isOpen && (
|
| 394 |
<div className="px-2 py-1.5 space-y-1">
|
|
|
|
| 395 |
{cat.properties.filter(p => activeStyles.has(p.key)).map(p => renderStyleEditor(p))}
|
| 396 |
|
|
|
|
| 397 |
{cat.name === 'Spacing & Size' && activeStyles.has('padding') && (
|
| 398 |
<div className="flex flex-wrap gap-1 pt-1">
|
| 399 |
{SPACING_PRESETS.map(v => (
|
|
@@ -405,6 +360,7 @@ export default function PropertyPanel({ element }: PropertyPanelProps) {
|
|
| 405 |
</div>
|
| 406 |
)}
|
| 407 |
|
|
|
|
| 408 |
{cat.name === 'Border & Rounding' && activeStyles.has('borderRadius') && (
|
| 409 |
<div className="flex flex-wrap gap-1 pt-1">
|
| 410 |
{RADIUS_PRESETS.map(r => (
|
|
@@ -416,6 +372,7 @@ export default function PropertyPanel({ element }: PropertyPanelProps) {
|
|
| 416 |
</div>
|
| 417 |
)}
|
| 418 |
|
|
|
|
| 419 |
<div className="relative pt-0.5">
|
| 420 |
<button onClick={() => setAddingToCat(addingToCat === cat.name ? null : cat.name)}
|
| 421 |
className="w-full flex items-center gap-1 px-2 py-1 rounded text-[10px] text-surface-500 hover:text-primary-400 hover:bg-surface-800 transition-colors">
|
|
@@ -442,6 +399,7 @@ export default function PropertyPanel({ element }: PropertyPanelProps) {
|
|
| 442 |
})}
|
| 443 |
</div>
|
| 444 |
|
|
|
|
| 445 |
{activeStyles.size > 0 && (
|
| 446 |
<div className="flex gap-2 pt-1 border-t border-surface-700">
|
| 447 |
<button onClick={() => updateVisualElement(element.id, { styles: {} })}
|
|
|
|
| 5 |
Plus, Trash2, Palette, Type, Move, Square,
|
| 6 |
Bold, Italic, AlignLeft, AlignCenter, AlignRight,
|
| 7 |
MousePointer, EyeOff, Maximize2, Grid3X3, Play, RotateCcw,
|
| 8 |
+
Underline, Strikethrough
|
| 9 |
} from 'lucide-react';
|
| 10 |
|
| 11 |
interface PropertyPanelProps {
|
| 12 |
element: VisualElement | null;
|
| 13 |
}
|
| 14 |
|
| 15 |
+
// Spacing quick buttons
|
| 16 |
const SPACING_PRESETS = ['0', '4px', '8px', '12px', '16px', '24px', '32px', '48px'];
|
| 17 |
const RADIUS_PRESETS = [
|
| 18 |
{ label: 'None', value: '0' }, { label: 'Small', value: '4px' },
|
|
|
|
| 149 |
];
|
| 150 |
|
| 151 |
export default function PropertyPanel({ element }: PropertyPanelProps) {
|
| 152 |
+
const { updateVisualElement, removeVisualElement, setSelectedElement } = useEditorStore();
|
|
|
|
|
|
|
|
|
|
| 153 |
const [expandedCat, setExpandedCat] = useState<string>('Colors');
|
| 154 |
const [addingToCat, setAddingToCat] = useState<string | null>(null);
|
| 155 |
|
|
|
|
| 184 |
setAddingToCat(null);
|
| 185 |
};
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
const renderStyleEditor = (prop: CatProp) => {
|
| 188 |
const value = (element.styles || {})[prop.key] ?? '';
|
| 189 |
return (
|
|
|
|
| 247 |
<div className="grid grid-cols-2 gap-1.5">
|
| 248 |
<div>
|
| 249 |
<label className="text-[10px] text-surface-400 block mb-0.5">ID</label>
|
| 250 |
+
<input type="text" value={element.props?.id || ''} onChange={(e) => updateProp('id', e.target.value)}
|
| 251 |
+
className="w-full bg-surface-800 border border-surface-700 rounded px-1.5 py-0.5 text-[11px] text-surface-200 outline-none" placeholder="my-id" />
|
| 252 |
</div>
|
| 253 |
<div>
|
| 254 |
<label className="text-[10px] text-surface-400 block mb-0.5">Classes</label>
|
|
|
|
| 258 |
</div>
|
| 259 |
</div>
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
{/* Text Content */}
|
| 262 |
{['p','h1','h2','h3','h4','h5','h6','span','a','button','label','li','strong','em','b','i','u','s','mark','figcaption','legend','summary','dt','dd','th','td','caption','option','blockquote','pre','code','abbr','cite','time','address'].includes(element.tagName) && (
|
| 263 |
<div className="border-t border-surface-700 pt-2">
|
|
|
|
| 325 |
</div>
|
| 326 |
)}
|
| 327 |
|
| 328 |
+
{/* ===== CSS CATEGORIES WITH PLUS BUTTONS ===== */}
|
| 329 |
<div className="border-t border-surface-700 pt-2 space-y-1">
|
| 330 |
<h4 className="text-[10px] font-semibold text-surface-400 uppercase tracking-wider mb-1">CSS Styles</h4>
|
| 331 |
|
|
|
|
| 335 |
const activeCount = cat.properties.filter(p => activeStyles.has(p.key)).length;
|
| 336 |
return (
|
| 337 |
<div key={cat.name} className="border border-surface-700/50 rounded-md">
|
| 338 |
+
{/* Category header */}
|
| 339 |
<button onClick={() => setExpandedCat(isOpen ? '' : cat.name)}
|
| 340 |
className="w-full flex items-center gap-1.5 px-2 py-1.5 bg-surface-800/50 hover:bg-surface-800 text-xs text-surface-300 transition-colors rounded-t-md">
|
| 341 |
<CatIcon className="w-3.5 h-3.5 text-surface-400" />
|
|
|
|
| 345 |
|
| 346 |
{isOpen && (
|
| 347 |
<div className="px-2 py-1.5 space-y-1">
|
| 348 |
+
{/* Active properties */}
|
| 349 |
{cat.properties.filter(p => activeStyles.has(p.key)).map(p => renderStyleEditor(p))}
|
| 350 |
|
| 351 |
+
{/* Quick presets for Spacing */}
|
| 352 |
{cat.name === 'Spacing & Size' && activeStyles.has('padding') && (
|
| 353 |
<div className="flex flex-wrap gap-1 pt-1">
|
| 354 |
{SPACING_PRESETS.map(v => (
|
|
|
|
| 360 |
</div>
|
| 361 |
)}
|
| 362 |
|
| 363 |
+
{/* Quick presets for border radius */}
|
| 364 |
{cat.name === 'Border & Rounding' && activeStyles.has('borderRadius') && (
|
| 365 |
<div className="flex flex-wrap gap-1 pt-1">
|
| 366 |
{RADIUS_PRESETS.map(r => (
|
|
|
|
| 372 |
</div>
|
| 373 |
)}
|
| 374 |
|
| 375 |
+
{/* Add property button */}
|
| 376 |
<div className="relative pt-0.5">
|
| 377 |
<button onClick={() => setAddingToCat(addingToCat === cat.name ? null : cat.name)}
|
| 378 |
className="w-full flex items-center gap-1 px-2 py-1 rounded text-[10px] text-surface-500 hover:text-primary-400 hover:bg-surface-800 transition-colors">
|
|
|
|
| 399 |
})}
|
| 400 |
</div>
|
| 401 |
|
| 402 |
+
{/* Clear all */}
|
| 403 |
{activeStyles.size > 0 && (
|
| 404 |
<div className="flex gap-2 pt-1 border-t border-surface-700">
|
| 405 |
<button onClick={() => updateVisualElement(element.id, { styles: {} })}
|
client/src/components/VisualEditor/Toolbox.tsx
CHANGED
|
@@ -145,18 +145,13 @@ const ELEMENT_CATEGORIES: { name: string; icon: any; items: ElementPreset[] }[]
|
|
| 145 |
},
|
| 146 |
];
|
| 147 |
|
| 148 |
-
|
| 149 |
-
onDragStart?: (preset: ElementPreset) => void;
|
| 150 |
-
}
|
| 151 |
-
|
| 152 |
-
export default function Toolbox({ onDragStart }: ToolboxProps) {
|
| 153 |
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(() => new Set(ELEMENT_CATEGORIES.slice(0, 3).map(c => c.name)));
|
| 154 |
|
| 155 |
const handleDragStart = useCallback((e: React.DragEvent, element: ElementPreset) => {
|
| 156 |
e.dataTransfer.setData('application/visual-element', JSON.stringify(element));
|
| 157 |
e.dataTransfer.effectAllowed = 'copy';
|
| 158 |
-
|
| 159 |
-
}, [onDragStart]);
|
| 160 |
|
| 161 |
const toggleCategory = useCallback((name: string) => {
|
| 162 |
setExpandedCategories(prev => {
|
|
|
|
| 145 |
},
|
| 146 |
];
|
| 147 |
|
| 148 |
+
export default function Toolbox() {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(() => new Set(ELEMENT_CATEGORIES.slice(0, 3).map(c => c.name)));
|
| 150 |
|
| 151 |
const handleDragStart = useCallback((e: React.DragEvent, element: ElementPreset) => {
|
| 152 |
e.dataTransfer.setData('application/visual-element', JSON.stringify(element));
|
| 153 |
e.dataTransfer.effectAllowed = 'copy';
|
| 154 |
+
}, []);
|
|
|
|
| 155 |
|
| 156 |
const toggleCategory = useCallback((name: string) => {
|
| 157 |
setExpandedCategories(prev => {
|
client/src/components/VisualEditor/VisualEditor.tsx
CHANGED
|
@@ -5,15 +5,16 @@ import { VisualElement } from '../../types/blocks';
|
|
| 5 |
import PropertyPanel from './PropertyPanel';
|
| 6 |
import ElementTree from './ElementTree';
|
| 7 |
import Toolbox from './Toolbox';
|
| 8 |
-
import LiveCanvas from './LiveCanvas';
|
| 9 |
import {
|
| 10 |
Monitor, Tablet, Smartphone, Grid3X3,
|
| 11 |
Eye, EyeOff, Trash2, Copy,
|
| 12 |
Plus, Code, Palette, Layout
|
| 13 |
} from 'lucide-react';
|
| 14 |
import { getColor } from '../Collaboration/CollaboratorAvatars';
|
|
|
|
| 15 |
|
| 16 |
type DeviceMode = 'desktop' | 'tablet' | 'mobile';
|
|
|
|
| 17 |
|
| 18 |
const DEVICE_SIZES: Record<DeviceMode, { width: number; height: number }> = {
|
| 19 |
desktop: { width: 1280, height: 800 },
|
|
@@ -23,11 +24,11 @@ const DEVICE_SIZES: Record<DeviceMode, { width: number; height: number }> = {
|
|
| 23 |
|
| 24 |
function uuid() { return crypto.randomUUID?.() || Math.random().toString(36).slice(2, 11); }
|
| 25 |
|
| 26 |
-
|
| 27 |
const id = uuid();
|
| 28 |
return {
|
| 29 |
id, tagName, type: 'element',
|
| 30 |
-
children: [],
|
| 31 |
props: Object.fromEntries(Object.entries(defaultProps || {}).filter(([k]) => k !== 'children' && k !== 'style')),
|
| 32 |
styles: defaultProps?.style || {},
|
| 33 |
classes: [], attributes: {},
|
|
@@ -38,6 +39,7 @@ function duplicateElement(el: VisualElement): VisualElement {
|
|
| 38 |
return { ...el, id: uuid(), children: (el.children || []).map(duplicateElement) };
|
| 39 |
}
|
| 40 |
|
|
|
|
| 41 |
function findElementById(elements: VisualElement[] | undefined, id: string): VisualElement | null {
|
| 42 |
if (!elements) return null;
|
| 43 |
for (const el of elements) {
|
|
@@ -47,48 +49,35 @@ function findElementById(elements: VisualElement[] | undefined, id: string): Vis
|
|
| 47 |
return null;
|
| 48 |
}
|
| 49 |
|
| 50 |
-
function
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
-
function
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
if (k !== 'id') attrs.push(`${k}="${escapeHtml(v)}"`);
|
| 61 |
});
|
| 62 |
}
|
| 63 |
-
|
| 64 |
-
if (
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
if (el.props?.value) attrs.push(`value="${escapeHtml(el.props.value)}"`);
|
| 70 |
-
if (el.props?.name) attrs.push(`name="${escapeHtml(el.props.name)}"`);
|
| 71 |
-
|
| 72 |
-
const styleStr = el.styles ? Object.entries(el.styles).map(([k, v]) => `${k}:${v}`).join(';') : '';
|
| 73 |
-
if (styleStr) attrs.push(`style="${escapeHtml(styleStr)}"`);
|
| 74 |
-
|
| 75 |
-
// Slot count attribute
|
| 76 |
-
if (el.slots && el.slots.length > 0) {
|
| 77 |
-
attrs.push(`data-rb-has-slots="true" data-rb-slot-count="${el.slots.length}"`);
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
const isVoidTag = ['br','hr','img','input','link','meta','area','base','col','embed','source','track','wbr'].includes(el.tagName);
|
| 81 |
-
if (isVoidTag) {
|
| 82 |
-
return `<${el.tagName} ${attrs.join(' ')} />`;
|
| 83 |
}
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
return `<${el.tagName} ${attrs.join(' ')}>${textContent}${childrenHtml}</${el.tagName}>`;
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
function escapeHtml(str: string): string {
|
| 91 |
-
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
| 92 |
}
|
| 93 |
|
| 94 |
export default function VisualEditor() {
|
|
@@ -96,20 +85,37 @@ export default function VisualEditor() {
|
|
| 96 |
visualElements, setVisualElements, selectedElementId, setSelectedElement,
|
| 97 |
viewMode, setViewMode, snapToGrid, toggleSnapToGrid,
|
| 98 |
addVisualElement, updateVisualElement, removeVisualElement,
|
| 99 |
-
activeFileId, fileTree, incrementCounter, setCustomId,
|
| 100 |
-
setSlotCount, recalculateSlots, recomputeRegistry,
|
| 101 |
} = useEditorStore();
|
| 102 |
const [deviceMode, setDeviceMode] = useState<DeviceMode>('desktop');
|
|
|
|
| 103 |
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; elementId: string } | null>(null);
|
|
|
|
| 104 |
const [showTagModal, setShowTagModal] = useState(false);
|
| 105 |
-
const [
|
| 106 |
-
const
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
//
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
// Collaborator hovers
|
| 113 |
const collaborators = useCollaborationStore((s) => s.collaborators);
|
| 114 |
const collaboratorHoverMap = useMemo(() => {
|
| 115 |
const map = new Map<string, { userId: string; username: string; color: string }[]>();
|
|
@@ -120,9 +126,10 @@ export default function VisualEditor() {
|
|
| 120 |
map.set(c.selectedElementId, arr);
|
| 121 |
}
|
| 122 |
});
|
|
|
|
|
|
|
| 123 |
return map;
|
| 124 |
}, [collaborators]);
|
| 125 |
-
|
| 126 |
const cmRef = useRef<HTMLDivElement>(null);
|
| 127 |
|
| 128 |
useEffect(() => {
|
|
@@ -133,113 +140,73 @@ export default function VisualEditor() {
|
|
| 133 |
return () => document.removeEventListener('mousedown', handler);
|
| 134 |
}, [contextMenu]);
|
| 135 |
|
| 136 |
-
//
|
| 137 |
-
const
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
const liveJs = useMemo(() => {
|
| 148 |
-
return currentBlockCode;
|
| 149 |
-
}, [currentBlockCode]);
|
| 150 |
-
|
| 151 |
-
// Map iframe selector to element ID
|
| 152 |
-
const handleElementClick = useCallback((selector: string) => {
|
| 153 |
-
setIframeSelector(selector);
|
| 154 |
-
// Try to find element by matching the ID part of the selector
|
| 155 |
-
const match = selector.match(/#([\w-]+)/);
|
| 156 |
-
if (match) {
|
| 157 |
-
const idAttr = match[1];
|
| 158 |
-
const el = findElementById(visualElements, selectedElementId || '');
|
| 159 |
-
// Search all elements for matching ID attribute
|
| 160 |
-
const searchById = (els: VisualElement[]): VisualElement | null => {
|
| 161 |
-
for (const e of els) {
|
| 162 |
-
if (e.props?.id === idAttr || e.attributes?.id === idAttr) return e;
|
| 163 |
-
if (e.children) {
|
| 164 |
-
const found = searchById(e.children);
|
| 165 |
-
if (found) return found;
|
| 166 |
-
}
|
| 167 |
-
}
|
| 168 |
-
return null;
|
| 169 |
-
};
|
| 170 |
-
const found = searchById(visualElements || []);
|
| 171 |
-
if (found) {
|
| 172 |
-
setSelectedElement(found.id);
|
| 173 |
-
return;
|
| 174 |
-
}
|
| 175 |
}
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
}
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
| 196 |
}, []);
|
| 197 |
|
| 198 |
-
|
| 199 |
-
const createElementFromPreset = useCallback((preset: any, parentId: string, slotIndex: number) => {
|
| 200 |
-
const child = createVisualElement(preset.tagName, preset.defaultProps || {});
|
| 201 |
-
const autoId = incrementCounter(preset.tagName);
|
| 202 |
-
child.props = { ...child.props, id: autoId };
|
| 203 |
-
child.attributes = { ...child.attributes, id: autoId };
|
| 204 |
-
addVisualElement(child, parentId, slotIndex);
|
| 205 |
-
}, [addVisualElement, incrementCounter]);
|
| 206 |
-
|
| 207 |
-
// Handle drop from within canvas (no preset, use generic div)
|
| 208 |
-
const handleElementDrop = useCallback((parentSelector: string, slotIndex: number) => {
|
| 209 |
-
const match = parentSelector.match(/#([\w-]+)/);
|
| 210 |
-
if (!match) return;
|
| 211 |
-
const parent = searchByAttr(visualElements, match[1]);
|
| 212 |
-
if (!parent) return;
|
| 213 |
-
createElementFromPreset({ tagName: 'div', defaultProps: { style: { padding: '12px', border: '1px dashed #6366f1', borderRadius: '4px' } } }, parent.id, slotIndex);
|
| 214 |
-
}, [visualElements, searchByAttr, createElementFromPreset]);
|
| 215 |
-
|
| 216 |
-
// Handle drop from toolbox (carries element preset data)
|
| 217 |
-
const handleElementDropFromToolbox = useCallback((preset: any, parentSelector: string, slotIndex: number) => {
|
| 218 |
-
const match = parentSelector.match(/#([\w-]+)/);
|
| 219 |
-
if (!match) return;
|
| 220 |
-
const parent = searchByAttr(visualElements, match[1]);
|
| 221 |
-
if (!parent) return;
|
| 222 |
-
createElementFromPreset(preset, parent.id, slotIndex);
|
| 223 |
-
}, [visualElements, searchByAttr, createElementFromPreset]);
|
| 224 |
-
|
| 225 |
-
const handleElementClickLocal = useCallback((e: React.MouseEvent, elementId: string) => {
|
| 226 |
e.stopPropagation();
|
| 227 |
-
setContextMenu(null);
|
| 228 |
setSelectedElement(elementId);
|
| 229 |
-
|
| 230 |
-
if (el?.props?.id) {
|
| 231 |
-
setIframeSelector(`#${el.props.id}`);
|
| 232 |
-
}
|
| 233 |
-
}, [setSelectedElement, visualElements]);
|
| 234 |
|
| 235 |
-
const
|
| 236 |
-
setContextMenu(null);
|
| 237 |
-
|
| 238 |
-
const el = findElementById(visualElements || [], elementId);
|
| 239 |
-
if (el?.props?.id) {
|
| 240 |
-
setIframeSelector(`#${el.props.id}`);
|
| 241 |
-
}
|
| 242 |
-
}, [setSelectedElement, visualElements]);
|
| 243 |
|
| 244 |
const handleContextMenu = useCallback((e: React.MouseEvent, elementId: string) => {
|
| 245 |
e.preventDefault(); e.stopPropagation();
|
|
@@ -250,36 +217,13 @@ export default function VisualEditor() {
|
|
| 250 |
const selectedElement = selectedElementId ? findElementById(visualElements || [], selectedElementId) : null;
|
| 251 |
const device = DEVICE_SIZES[deviceMode];
|
| 252 |
|
| 253 |
-
// Find parent and index of an element in the tree
|
| 254 |
-
const findParentAndIndex = useCallback((id: string, els: VisualElement[], parentId?: string, sibIdx?: number): { parentId: string | null; index: number } | null => {
|
| 255 |
-
for (let i = 0; i < (els || []).length; i++) {
|
| 256 |
-
if (els[i].id === id) return { parentId: parentId || null, index: i };
|
| 257 |
-
if (els[i].children) {
|
| 258 |
-
const found = findParentAndIndex(id, els[i].children, els[i].id, i);
|
| 259 |
-
if (found) return found;
|
| 260 |
-
}
|
| 261 |
-
}
|
| 262 |
-
return null;
|
| 263 |
-
}, []);
|
| 264 |
-
|
| 265 |
// Context menu actions
|
| 266 |
const cmDuplicate = useCallback(() => {
|
| 267 |
if (!contextMenu) return;
|
| 268 |
const el = findElementById(visualElements || [], contextMenu.elementId);
|
| 269 |
-
if (el)
|
| 270 |
-
const dup = duplicateElement(el);
|
| 271 |
-
const autoId = incrementCounter(el.tagName);
|
| 272 |
-
dup.props = { ...dup.props, id: autoId };
|
| 273 |
-
dup.attributes = { ...dup.attributes, id: autoId };
|
| 274 |
-
const location = findParentAndIndex(contextMenu.elementId, visualElements || []);
|
| 275 |
-
if (location) {
|
| 276 |
-
addVisualElement(dup, location.parentId || undefined, location.index + 1);
|
| 277 |
-
} else {
|
| 278 |
-
addVisualElement(dup);
|
| 279 |
-
}
|
| 280 |
-
}
|
| 281 |
setContextMenu(null);
|
| 282 |
-
}, [contextMenu, visualElements, addVisualElement
|
| 283 |
|
| 284 |
const cmDelete = useCallback(() => {
|
| 285 |
if (contextMenu) { removeVisualElement(contextMenu.elementId); setContextMenu(null); }
|
|
@@ -295,45 +239,26 @@ export default function VisualEditor() {
|
|
| 295 |
|
| 296 |
const cmAddChild = useCallback(() => {
|
| 297 |
if (!contextMenu) return;
|
| 298 |
-
const child = createVisualElement('div', {});
|
| 299 |
-
const autoId = incrementCounter('div');
|
| 300 |
-
child.props = { ...child.props, id: autoId };
|
| 301 |
-
child.attributes = { ...child.attributes, id: autoId };
|
| 302 |
addVisualElement(child, contextMenu.elementId);
|
| 303 |
-
recalculateSlots(contextMenu.elementId);
|
| 304 |
setContextMenu(null);
|
| 305 |
-
}, [contextMenu, addVisualElement
|
| 306 |
|
| 307 |
const handleAddChild = useCallback((parentId: string) => {
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
child.props = { ...child.props, id: autoId };
|
| 311 |
-
child.attributes = { ...child.attributes, id: autoId };
|
| 312 |
-
addVisualElement(child, parentId);
|
| 313 |
-
recalculateSlots(parentId);
|
| 314 |
-
}, [addVisualElement, incrementCounter, recalculateSlots]);
|
| 315 |
|
| 316 |
const cmColorPick = useCallback(() => {
|
| 317 |
if (!contextMenu) return;
|
| 318 |
-
|
| 319 |
-
if (el) {
|
| 320 |
-
updateVisualElement(contextMenu.elementId, {
|
| 321 |
-
styles: { ...el.styles, color: '#6366f1' }
|
| 322 |
-
});
|
| 323 |
-
}
|
| 324 |
setContextMenu(null);
|
| 325 |
-
}, [contextMenu
|
| 326 |
|
| 327 |
const cmBgColorPick = useCallback(() => {
|
| 328 |
if (!contextMenu) return;
|
| 329 |
-
|
| 330 |
-
if (el) {
|
| 331 |
-
updateVisualElement(contextMenu.elementId, {
|
| 332 |
-
styles: { ...el.styles, backgroundColor: '#6366f1', color: '#ffffff' }
|
| 333 |
-
});
|
| 334 |
-
}
|
| 335 |
setContextMenu(null);
|
| 336 |
-
}, [contextMenu
|
| 337 |
|
| 338 |
const ALL_TAGS = ['div','span','p','h1','h2','h3','h4','h5','h6','a','button','input','img','form','section','header','footer','nav','main','aside','ul','ol','li','table','label','textarea','select','article','blockquote','pre','code','figure','figcaption','details','summary','dialog','menu','strong','em','b','i','u','s','mark','small','sub','sup','cite','time','address','video','audio','canvas','iframe','fieldset','legend','progress','meter','dl','dt','dd'];
|
| 339 |
|
|
@@ -367,7 +292,7 @@ export default function VisualEditor() {
|
|
| 367 |
Elements
|
| 368 |
</div>
|
| 369 |
<div className="flex-1 overflow-y-auto px-2">
|
| 370 |
-
<Toolbox
|
| 371 |
</div>
|
| 372 |
</div>
|
| 373 |
|
|
@@ -391,6 +316,10 @@ export default function VisualEditor() {
|
|
| 391 |
className={`p-1.5 rounded-md ${snapToGrid ? 'text-primary-400' : 'text-surface-400 hover:text-white'}`} title="Snap to Grid">
|
| 392 |
<Grid3X3 className="w-3.5 h-3.5" />
|
| 393 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
</div>
|
| 395 |
<div className="flex items-center gap-2">
|
| 396 |
<span className="text-xs text-surface-500">{(visualElements || []).length} elements</span>
|
|
@@ -402,30 +331,36 @@ export default function VisualEditor() {
|
|
| 402 |
backgroundImage: snapToGrid ? 'radial-gradient(circle, rgba(148,163,184,0.1) 1px, transparent 1px)' : 'none',
|
| 403 |
backgroundSize: snapToGrid ? '20px 20px' : 'auto',
|
| 404 |
}}>
|
| 405 |
-
<div
|
| 406 |
-
className="bg-white rounded-lg shadow-2xl transition-all duration-300 overflow-hidden"
|
| 407 |
-
style={{ width: `${device.width}px`, minHeight: `${device.height}px`, maxWidth: '100%' }}
|
|
|
|
|
|
|
| 408 |
{(visualElements || []).length === 0 ? (
|
| 409 |
<div className="flex items-center justify-center h-full min-h-[400px]" style={{ color: '#94a3b8', fontSize: '14px' }}>
|
| 410 |
<div className="text-center">
|
| 411 |
<Layout className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
| 412 |
<p>Drag elements from the toolbox</p>
|
| 413 |
-
<p className="text-xs mt-1">
|
| 414 |
</div>
|
| 415 |
</div>
|
| 416 |
) : (
|
| 417 |
-
<
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
)}
|
| 430 |
</div>
|
| 431 |
</div>
|
|
@@ -445,7 +380,7 @@ export default function VisualEditor() {
|
|
| 445 |
<PropertyPanel element={selectedElement} />
|
| 446 |
) : (
|
| 447 |
<ElementTree elements={visualElements || []} selectedId={selectedElementId}
|
| 448 |
-
onSelect={
|
| 449 |
)}
|
| 450 |
</div>
|
| 451 |
|
|
@@ -465,9 +400,151 @@ export default function VisualEditor() {
|
|
| 465 |
</div>
|
| 466 |
)}
|
| 467 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
{showTagModal && contextMenu && (
|
| 469 |
<TagModal currentTag={findElementById(visualElements || [], contextMenu.elementId)?.tagName || 'div'} />
|
| 470 |
)}
|
| 471 |
</div>
|
| 472 |
);
|
| 473 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import PropertyPanel from './PropertyPanel';
|
| 6 |
import ElementTree from './ElementTree';
|
| 7 |
import Toolbox from './Toolbox';
|
|
|
|
| 8 |
import {
|
| 9 |
Monitor, Tablet, Smartphone, Grid3X3,
|
| 10 |
Eye, EyeOff, Trash2, Copy,
|
| 11 |
Plus, Code, Palette, Layout
|
| 12 |
} from 'lucide-react';
|
| 13 |
import { getColor } from '../Collaboration/CollaboratorAvatars';
|
| 14 |
+
import { getSocket } from '../../hooks/useWebSocket';
|
| 15 |
|
| 16 |
type DeviceMode = 'desktop' | 'tablet' | 'mobile';
|
| 17 |
+
type DropPosition = 'before' | 'after' | 'inside' | null;
|
| 18 |
|
| 19 |
const DEVICE_SIZES: Record<DeviceMode, { width: number; height: number }> = {
|
| 20 |
desktop: { width: 1280, height: 800 },
|
|
|
|
| 24 |
|
| 25 |
function uuid() { return crypto.randomUUID?.() || Math.random().toString(36).slice(2, 11); }
|
| 26 |
|
| 27 |
+
function createVisualElement(tagName: string, defaultProps: any): VisualElement {
|
| 28 |
const id = uuid();
|
| 29 |
return {
|
| 30 |
id, tagName, type: 'element',
|
| 31 |
+
children: defaultProps?.children || [],
|
| 32 |
props: Object.fromEntries(Object.entries(defaultProps || {}).filter(([k]) => k !== 'children' && k !== 'style')),
|
| 33 |
styles: defaultProps?.style || {},
|
| 34 |
classes: [], attributes: {},
|
|
|
|
| 39 |
return { ...el, id: uuid(), children: (el.children || []).map(duplicateElement) };
|
| 40 |
}
|
| 41 |
|
| 42 |
+
// Tree helpers
|
| 43 |
function findElementById(elements: VisualElement[] | undefined, id: string): VisualElement | null {
|
| 44 |
if (!elements) return null;
|
| 45 |
for (const el of elements) {
|
|
|
|
| 49 |
return null;
|
| 50 |
}
|
| 51 |
|
| 52 |
+
function removeFromTree(elements: VisualElement[], id: string): { result: VisualElement[]; removed: VisualElement | null } {
|
| 53 |
+
let removed: VisualElement | null = null;
|
| 54 |
+
const filter = (list: VisualElement[]): VisualElement[] =>
|
| 55 |
+
list.filter(el => {
|
| 56 |
+
if (el.id === id) { removed = el; return false; }
|
| 57 |
+
if (el.children) el.children = filter(el.children);
|
| 58 |
+
return true;
|
| 59 |
+
});
|
| 60 |
+
return { result: filter([...elements]), removed };
|
| 61 |
}
|
| 62 |
|
| 63 |
+
function insertAt(elements: VisualElement[], targetId: string, newEl: VisualElement, pos: DropPosition): VisualElement[] {
|
| 64 |
+
if (pos === 'inside') {
|
| 65 |
+
return elements.map(el => {
|
| 66 |
+
if (el.id === targetId) return { ...el, children: [...(el.children || []), newEl] };
|
| 67 |
+
if (el.children) return { ...el, children: insertAt(el.children, targetId, newEl, pos) };
|
| 68 |
+
return el;
|
|
|
|
| 69 |
});
|
| 70 |
}
|
| 71 |
+
const idx = elements.findIndex(el => el.id === targetId);
|
| 72 |
+
if (idx === -1) {
|
| 73 |
+
return elements.map(el => {
|
| 74 |
+
if (el.children) return { ...el, children: insertAt(el.children, targetId, newEl, pos) };
|
| 75 |
+
return el;
|
| 76 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
+
const arr = [...elements];
|
| 79 |
+
arr.splice(pos === 'before' ? idx : idx + 1, 0, newEl);
|
| 80 |
+
return arr;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
export default function VisualEditor() {
|
|
|
|
| 85 |
visualElements, setVisualElements, selectedElementId, setSelectedElement,
|
| 86 |
viewMode, setViewMode, snapToGrid, toggleSnapToGrid,
|
| 87 |
addVisualElement, updateVisualElement, removeVisualElement,
|
|
|
|
|
|
|
| 88 |
} = useEditorStore();
|
| 89 |
const [deviceMode, setDeviceMode] = useState<DeviceMode>('desktop');
|
| 90 |
+
const [showGuides, setShowGuides] = useState(true);
|
| 91 |
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; elementId: string } | null>(null);
|
| 92 |
+
const [colorPicker, setColorPicker] = useState<{ elementId: string; property: string; x: number; y: number } | null>(null);
|
| 93 |
const [showTagModal, setShowTagModal] = useState(false);
|
| 94 |
+
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
| 95 |
+
const [dragOverPos, setDragOverPos] = useState<DropPosition>(null);
|
| 96 |
+
const [draggedId, setDraggedId] = useState<string | null>(null);
|
| 97 |
+
const canvasRef = useRef<HTMLDivElement>(null);
|
| 98 |
|
| 99 |
+
// Set up global WS hover emit
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
(window as any).__wsElementHoverEmit = (elementId: string | null) => {
|
| 102 |
+
const socket = getSocket();
|
| 103 |
+
const projectId = (window as any).__projectId;
|
| 104 |
+
if (socket?.connected && projectId) {
|
| 105 |
+
socket.emit('visual_element_hover', { projectId, elementId });
|
| 106 |
+
}
|
| 107 |
+
};
|
| 108 |
+
(window as any).__wsElementHover = (elementId: string | null) => {
|
| 109 |
+
const fn = (window as any).__wsElementHoverEmit;
|
| 110 |
+
if (fn) fn(elementId);
|
| 111 |
+
};
|
| 112 |
+
return () => {
|
| 113 |
+
(window as any).__wsElementHover = undefined;
|
| 114 |
+
(window as any).__wsElementHoverEmit = undefined;
|
| 115 |
+
};
|
| 116 |
+
}, []);
|
| 117 |
|
| 118 |
+
// Collaborator element hovers for overlay
|
| 119 |
const collaborators = useCollaborationStore((s) => s.collaborators);
|
| 120 |
const collaboratorHoverMap = useMemo(() => {
|
| 121 |
const map = new Map<string, { userId: string; username: string; color: string }[]>();
|
|
|
|
| 126 |
map.set(c.selectedElementId, arr);
|
| 127 |
}
|
| 128 |
});
|
| 129 |
+
// Set global for child renderers
|
| 130 |
+
(window as any).__wsCollaboratorHoverMap = map;
|
| 131 |
return map;
|
| 132 |
}, [collaborators]);
|
|
|
|
| 133 |
const cmRef = useRef<HTMLDivElement>(null);
|
| 134 |
|
| 135 |
useEffect(() => {
|
|
|
|
| 140 |
return () => document.removeEventListener('mousedown', handler);
|
| 141 |
}, [contextMenu]);
|
| 142 |
|
| 143 |
+
// Combined drop handler: toolbox elements and reorder
|
| 144 |
+
const handleCanvasDrop = useCallback((e: React.DragEvent) => {
|
| 145 |
+
e.preventDefault();
|
| 146 |
+
const toolboxData = e.dataTransfer.getData('application/visual-element');
|
| 147 |
+
if (toolboxData) {
|
| 148 |
+
const preset = JSON.parse(toolboxData);
|
| 149 |
+
const element = createVisualElement(preset.tagName, preset.defaultProps);
|
| 150 |
+
const target = (e.target as HTMLElement).closest('[data-element-id]');
|
| 151 |
+
const parentId = target?.getAttribute('data-element-id') || undefined;
|
| 152 |
+
addVisualElement(element, parentId);
|
| 153 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
}
|
| 155 |
+
// Reorder
|
| 156 |
+
const dragId = draggedId;
|
| 157 |
+
const targetId = dragOverId;
|
| 158 |
+
setDraggedId(null);
|
| 159 |
+
setDragOverId(null);
|
| 160 |
+
setDragOverPos(null);
|
| 161 |
+
if (!dragId || !targetId || dragId === targetId) return;
|
| 162 |
+
const { result: afterRemove, removed } = removeFromTree(visualElements || [], dragId);
|
| 163 |
+
if (!removed) { setVisualElements(afterRemove); return; }
|
| 164 |
+
const newTree = insertAt(afterRemove, targetId, removed, dragOverPos);
|
| 165 |
+
setVisualElements(newTree);
|
| 166 |
+
}, [draggedId, dragOverId, dragOverPos, visualElements, setVisualElements, addVisualElement]);
|
| 167 |
+
|
| 168 |
+
const handleElementDragStart = useCallback((e: React.DragEvent, elementId: string) => {
|
| 169 |
+
e.stopPropagation();
|
| 170 |
+
setDraggedId(elementId);
|
| 171 |
+
e.dataTransfer.setData('text/plain', elementId);
|
| 172 |
+
e.dataTransfer.effectAllowed = 'move';
|
| 173 |
+
if (e.currentTarget instanceof HTMLElement) {
|
| 174 |
+
const rect = e.currentTarget.getBoundingClientRect();
|
| 175 |
+
e.dataTransfer.setDragImage(e.currentTarget, e.clientX - rect.left, e.clientY - rect.top);
|
| 176 |
+
}
|
| 177 |
+
}, []);
|
| 178 |
|
| 179 |
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
| 180 |
+
e.preventDefault();
|
| 181 |
+
const targetEl = (e.target as HTMLElement).closest('[data-element-id]');
|
| 182 |
+
const targetId = targetEl?.getAttribute('data-element-id') || null;
|
| 183 |
+
if (!targetId || targetId === draggedId) { setDragOverId(null); setDragOverPos(null); return; }
|
| 184 |
+
setDragOverId(targetId);
|
| 185 |
+
if (targetEl) {
|
| 186 |
+
const rect = targetEl.getBoundingClientRect();
|
| 187 |
+
const y = e.clientY - rect.top;
|
| 188 |
+
const h = rect.height;
|
| 189 |
+
const isContainer = !['INPUT','IMG','BR','HR'].includes(targetEl.tagName);
|
| 190 |
+
if (y < h * 0.25) setDragOverPos('before');
|
| 191 |
+
else if (y > h * 0.75) setDragOverPos('after');
|
| 192 |
+
else if (isContainer) setDragOverPos('inside');
|
| 193 |
+
else setDragOverPos('after');
|
| 194 |
}
|
| 195 |
+
}, [draggedId]);
|
| 196 |
+
|
| 197 |
+
const handleDragEnd = useCallback(() => {
|
| 198 |
+
setDraggedId(null); setDragOverId(null); setDragOverPos(null);
|
| 199 |
}, []);
|
| 200 |
|
| 201 |
+
const handleElementClick = useCallback((e: React.MouseEvent, elementId: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
e.stopPropagation();
|
| 203 |
+
setContextMenu(null); setColorPicker(null);
|
| 204 |
setSelectedElement(elementId);
|
| 205 |
+
}, [setSelectedElement]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
+
const handleCanvasClick = useCallback(() => {
|
| 208 |
+
setSelectedElement(null); setContextMenu(null); setColorPicker(null);
|
| 209 |
+
}, [setSelectedElement]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
const handleContextMenu = useCallback((e: React.MouseEvent, elementId: string) => {
|
| 212 |
e.preventDefault(); e.stopPropagation();
|
|
|
|
| 217 |
const selectedElement = selectedElementId ? findElementById(visualElements || [], selectedElementId) : null;
|
| 218 |
const device = DEVICE_SIZES[deviceMode];
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
// Context menu actions
|
| 221 |
const cmDuplicate = useCallback(() => {
|
| 222 |
if (!contextMenu) return;
|
| 223 |
const el = findElementById(visualElements || [], contextMenu.elementId);
|
| 224 |
+
if (el) addVisualElement(duplicateElement(el));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
setContextMenu(null);
|
| 226 |
+
}, [contextMenu, visualElements, addVisualElement]);
|
| 227 |
|
| 228 |
const cmDelete = useCallback(() => {
|
| 229 |
if (contextMenu) { removeVisualElement(contextMenu.elementId); setContextMenu(null); }
|
|
|
|
| 239 |
|
| 240 |
const cmAddChild = useCallback(() => {
|
| 241 |
if (!contextMenu) return;
|
| 242 |
+
const child = createVisualElement('div', { style: { padding: '12px', border: '1px dashed #94a3b8', borderRadius: '4px' } });
|
|
|
|
|
|
|
|
|
|
| 243 |
addVisualElement(child, contextMenu.elementId);
|
|
|
|
| 244 |
setContextMenu(null);
|
| 245 |
+
}, [contextMenu, addVisualElement]);
|
| 246 |
|
| 247 |
const handleAddChild = useCallback((parentId: string) => {
|
| 248 |
+
addVisualElement(createVisualElement('div', { style: { padding: '12px', border: '1px dashed #6366f1', borderRadius: '4px' } }), parentId);
|
| 249 |
+
}, [addVisualElement]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
const cmColorPick = useCallback(() => {
|
| 252 |
if (!contextMenu) return;
|
| 253 |
+
setColorPicker({ elementId: contextMenu.elementId, property: 'color', x: contextMenu.x, y: contextMenu.y });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
setContextMenu(null);
|
| 255 |
+
}, [contextMenu]);
|
| 256 |
|
| 257 |
const cmBgColorPick = useCallback(() => {
|
| 258 |
if (!contextMenu) return;
|
| 259 |
+
setColorPicker({ elementId: contextMenu.elementId, property: 'backgroundColor', x: contextMenu.x, y: contextMenu.y });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
setContextMenu(null);
|
| 261 |
+
}, [contextMenu]);
|
| 262 |
|
| 263 |
const ALL_TAGS = ['div','span','p','h1','h2','h3','h4','h5','h6','a','button','input','img','form','section','header','footer','nav','main','aside','ul','ol','li','table','label','textarea','select','article','blockquote','pre','code','figure','figcaption','details','summary','dialog','menu','strong','em','b','i','u','s','mark','small','sub','sup','cite','time','address','video','audio','canvas','iframe','fieldset','legend','progress','meter','dl','dt','dd'];
|
| 264 |
|
|
|
|
| 292 |
Elements
|
| 293 |
</div>
|
| 294 |
<div className="flex-1 overflow-y-auto px-2">
|
| 295 |
+
<Toolbox />
|
| 296 |
</div>
|
| 297 |
</div>
|
| 298 |
|
|
|
|
| 316 |
className={`p-1.5 rounded-md ${snapToGrid ? 'text-primary-400' : 'text-surface-400 hover:text-white'}`} title="Snap to Grid">
|
| 317 |
<Grid3X3 className="w-3.5 h-3.5" />
|
| 318 |
</button>
|
| 319 |
+
<button onClick={() => setShowGuides(!showGuides)}
|
| 320 |
+
className={`p-1.5 rounded-md ${showGuides ? 'text-primary-400' : 'text-surface-400 hover:text-white'}`} title="Guides">
|
| 321 |
+
{showGuides ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
| 322 |
+
</button>
|
| 323 |
</div>
|
| 324 |
<div className="flex items-center gap-2">
|
| 325 |
<span className="text-xs text-surface-500">{(visualElements || []).length} elements</span>
|
|
|
|
| 331 |
backgroundImage: snapToGrid ? 'radial-gradient(circle, rgba(148,163,184,0.1) 1px, transparent 1px)' : 'none',
|
| 332 |
backgroundSize: snapToGrid ? '20px 20px' : 'auto',
|
| 333 |
}}>
|
| 334 |
+
<div ref={canvasRef}
|
| 335 |
+
className="bg-white rounded-lg shadow-2xl transition-all duration-300 overflow-hidden relative"
|
| 336 |
+
style={{ width: `${device.width}px`, minHeight: `${device.height}px`, maxWidth: '100%' }}
|
| 337 |
+
onDrop={handleCanvasDrop} onDragOver={handleDragOver}
|
| 338 |
+
onClick={handleCanvasClick}>
|
| 339 |
{(visualElements || []).length === 0 ? (
|
| 340 |
<div className="flex items-center justify-center h-full min-h-[400px]" style={{ color: '#94a3b8', fontSize: '14px' }}>
|
| 341 |
<div className="text-center">
|
| 342 |
<Layout className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
| 343 |
<p>Drag elements from the toolbox</p>
|
| 344 |
+
<p className="text-xs mt-1">or drag existing elements to reorder them</p>
|
| 345 |
</div>
|
| 346 |
</div>
|
| 347 |
) : (
|
| 348 |
+
<div className="p-4">
|
| 349 |
+
{(visualElements || []).map((el) => (
|
| 350 |
+
<VisualElementRenderer key={el.id} element={el}
|
| 351 |
+
selectedId={selectedElementId} onSelect={handleElementClick}
|
| 352 |
+
onContextMenu={handleContextMenu} depth={0}
|
| 353 |
+
dragOverId={dragOverPos ? dragOverId : null}
|
| 354 |
+
dragOverPos={dragOverPos}
|
| 355 |
+
onAddChild={handleAddChild}
|
| 356 |
+
onDragStart={handleElementDragStart}
|
| 357 |
+
onDrop={handleCanvasDrop}
|
| 358 |
+
onDragOver={handleDragOver}
|
| 359 |
+
onDragEnd={handleDragEnd}
|
| 360 |
+
draggedId={draggedId}
|
| 361 |
+
collaboratorHovers={collaboratorHoverMap.get(el.id) || []} />
|
| 362 |
+
))}
|
| 363 |
+
</div>
|
| 364 |
)}
|
| 365 |
</div>
|
| 366 |
</div>
|
|
|
|
| 380 |
<PropertyPanel element={selectedElement} />
|
| 381 |
) : (
|
| 382 |
<ElementTree elements={visualElements || []} selectedId={selectedElementId}
|
| 383 |
+
onSelect={setSelectedElement} onContextMenu={handleContextMenu} />
|
| 384 |
)}
|
| 385 |
</div>
|
| 386 |
|
|
|
|
| 400 |
</div>
|
| 401 |
)}
|
| 402 |
|
| 403 |
+
{/* Color Picker */}
|
| 404 |
+
{colorPicker && (
|
| 405 |
+
<div className="fixed z-50" style={{ left: colorPicker.x, top: colorPicker.y }}>
|
| 406 |
+
<input type="color" value="#6366f1" autoFocus
|
| 407 |
+
onChange={(e) => {
|
| 408 |
+
updateVisualElement(colorPicker.elementId, { styles: { ...(findElementById(visualElements || [], colorPicker.elementId)?.styles || {}), [colorPicker.property]: e.target.value } });
|
| 409 |
+
setColorPicker(null);
|
| 410 |
+
}}
|
| 411 |
+
className="w-32 h-32 cursor-pointer border-0 rounded-lg shadow-xl"
|
| 412 |
+
style={{ background: 'none' }} onBlur={() => setColorPicker(null)} />
|
| 413 |
+
</div>
|
| 414 |
+
)}
|
| 415 |
+
|
| 416 |
{showTagModal && contextMenu && (
|
| 417 |
<TagModal currentTag={findElementById(visualElements || [], contextMenu.elementId)?.tagName || 'div'} />
|
| 418 |
)}
|
| 419 |
</div>
|
| 420 |
);
|
| 421 |
}
|
| 422 |
+
|
| 423 |
+
// ===== VISUAL ELEMENT RENDERER =====
|
| 424 |
+
function VisualElementRenderer({
|
| 425 |
+
element, selectedId, onSelect, onContextMenu, depth, dragOverId, dragOverPos,
|
| 426 |
+
onAddChild, onDragStart, onDrop, onDragOver, onDragEnd, draggedId,
|
| 427 |
+
collaboratorHovers,
|
| 428 |
+
}: {
|
| 429 |
+
element: VisualElement; selectedId: string | null;
|
| 430 |
+
onSelect: (e: React.MouseEvent, id: string) => void;
|
| 431 |
+
onContextMenu: (e: React.MouseEvent, id: string) => void;
|
| 432 |
+
depth: number; dragOverId: string | null; dragOverPos: DropPosition | null;
|
| 433 |
+
onAddChild: (id: string) => void;
|
| 434 |
+
onDragStart: (e: React.DragEvent, id: string) => void;
|
| 435 |
+
onDrop: (e: React.DragEvent) => void;
|
| 436 |
+
onDragOver: (e: React.DragEvent) => void;
|
| 437 |
+
onDragEnd: (e: React.DragEvent) => void;
|
| 438 |
+
draggedId: string | null;
|
| 439 |
+
collaboratorHovers: { userId: string; username: string; color: string }[];
|
| 440 |
+
}) {
|
| 441 |
+
const isSelected = selectedId === element.id;
|
| 442 |
+
const isDragTarget = dragOverId === element.id;
|
| 443 |
+
const isDragged = draggedId === element.id;
|
| 444 |
+
const Tag = element.tagName as keyof JSX.IntrinsicElements;
|
| 445 |
+
|
| 446 |
+
// Build border/indicator for drop position
|
| 447 |
+
let dropIndicator: React.CSSProperties = {};
|
| 448 |
+
if (isDragTarget && dragOverPos === 'before') {
|
| 449 |
+
dropIndicator = { borderTop: '3px solid #6366f1' };
|
| 450 |
+
} else if (isDragTarget && dragOverPos === 'after') {
|
| 451 |
+
dropIndicator = { borderBottom: '3px solid #6366f1' };
|
| 452 |
+
} else if (isDragTarget && dragOverPos === 'inside') {
|
| 453 |
+
dropIndicator = { outline: '3px dashed #6366f1', outlineOffset: '2px' };
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
const style: React.CSSProperties = {
|
| 457 |
+
...element.styles,
|
| 458 |
+
...dropIndicator,
|
| 459 |
+
outline: isSelected && !isDragTarget ? '2px solid #6366f1' : isDragTarget && dragOverPos === 'inside' ? '3px dashed #6366f1' : collaboratorHovers.length > 0 ? `2px solid ${collaboratorHovers[0].color}` : 'none',
|
| 460 |
+
outlineOffset: isSelected || collaboratorHovers.length > 0 ? '2px' : '0',
|
| 461 |
+
position: 'relative',
|
| 462 |
+
cursor: isDragged ? 'grabbing' : 'grab',
|
| 463 |
+
transition: 'outline 0.1s ease, border 0.1s ease',
|
| 464 |
+
opacity: isDragged ? 0.5 : 1,
|
| 465 |
+
};
|
| 466 |
+
|
| 467 |
+
const attribs: Record<string, string> = { ...element.attributes, 'data-element-id': element.id };
|
| 468 |
+
if (element.props?.src) attribs.src = element.props.src;
|
| 469 |
+
if (element.props?.href) attribs.href = element.props.href;
|
| 470 |
+
if (element.props?.alt) attribs.alt = element.props.alt;
|
| 471 |
+
if (element.props?.placeholder) attribs.placeholder = element.props.placeholder;
|
| 472 |
+
if (element.props?.type) attribs.type = element.props.type;
|
| 473 |
+
if (element.props?.value) attribs.value = element.props.value;
|
| 474 |
+
if (element.props?.name) attribs.name = element.props.name;
|
| 475 |
+
if (element.props?.id) attribs.id = element.props.id;
|
| 476 |
+
|
| 477 |
+
const isVoidTag = ['br','hr','img','input','link','meta','area','base','col','embed','source','track','wbr'].includes(element.tagName);
|
| 478 |
+
|
| 479 |
+
if (isVoidTag) {
|
| 480 |
+
return (
|
| 481 |
+
<div style={{ position: 'relative', display: 'inline-block' }}>
|
| 482 |
+
<Tag {...attribs} style={style} className={element.classes.join(' ')}
|
| 483 |
+
draggable
|
| 484 |
+
onDragStart={(e: React.DragEvent) => onDragStart(e, element.id)}
|
| 485 |
+
onDragOver={onDragOver}
|
| 486 |
+
onDrop={onDrop}
|
| 487 |
+
onDragEnd={onDragEnd}
|
| 488 |
+
onClick={(e: React.MouseEvent) => onSelect(e, element.id)}
|
| 489 |
+
onContextMenu={(e: React.MouseEvent) => onContextMenu(e, element.id)}
|
| 490 |
+
onMouseEnter={() => (window as any).__wsElementHover?.(element.id)}
|
| 491 |
+
onMouseLeave={() => (window as any).__wsElementHover?.(null)} />
|
| 492 |
+
{collaboratorHovers.length > 0 && (
|
| 493 |
+
<div className="absolute top-0 right-0 flex -space-x-1">
|
| 494 |
+
{collaboratorHovers.map((c) => (
|
| 495 |
+
<div key={c.userId} className="w-3.5 h-3.5 rounded-full flex items-center justify-center text-[6px] font-bold text-white"
|
| 496 |
+
style={{ backgroundColor: c.color }} title={c.username}>
|
| 497 |
+
{c.username.charAt(0).toUpperCase()}
|
| 498 |
+
</div>
|
| 499 |
+
))}
|
| 500 |
+
</div>
|
| 501 |
+
)}
|
| 502 |
+
</div>
|
| 503 |
+
);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
return (
|
| 507 |
+
<div style={{ position: 'relative' }}>
|
| 508 |
+
<Tag {...attribs} style={style} className={element.classes.join(' ')}
|
| 509 |
+
draggable
|
| 510 |
+
onDragStart={(e: React.DragEvent) => onDragStart(e, element.id)}
|
| 511 |
+
onDragOver={onDragOver}
|
| 512 |
+
|
| 513 |
+
onDrop={onDrop}
|
| 514 |
+
onDragEnd={onDragEnd}
|
| 515 |
+
onClick={(e: React.MouseEvent) => onSelect(e, element.id)}
|
| 516 |
+
onContextMenu={(e: React.MouseEvent) => onContextMenu(e, element.id)}
|
| 517 |
+
onMouseEnter={() => (window as any).__wsElementHover?.(element.id)}
|
| 518 |
+
onMouseLeave={() => (window as any).__wsElementHover?.(null)}>
|
| 519 |
+
{collaboratorHovers.length > 0 && (
|
| 520 |
+
<div className="absolute top-0 right-0 flex -space-x-1 z-10">
|
| 521 |
+
{collaboratorHovers.map((c) => (
|
| 522 |
+
<div key={c.userId} className="w-3.5 h-3.5 rounded-full flex items-center justify-center text-[6px] font-bold text-white"
|
| 523 |
+
style={{ backgroundColor: c.color }} title={c.username}>
|
| 524 |
+
{c.username.charAt(0).toUpperCase()}
|
| 525 |
+
</div>
|
| 526 |
+
))}
|
| 527 |
+
</div>
|
| 528 |
+
)}
|
| 529 |
+
{element.props?.textContent || ''}
|
| 530 |
+
{(element.children || []).map((child) => (
|
| 531 |
+
<VisualElementRenderer key={child.id} element={child}
|
| 532 |
+
selectedId={selectedId} onSelect={onSelect} onContextMenu={onContextMenu}
|
| 533 |
+
depth={depth + 1} dragOverId={dragOverId} dragOverPos={dragOverPos}
|
| 534 |
+
onAddChild={onAddChild} onDragStart={onDragStart}
|
| 535 |
+
onDrop={onDrop} onDragOver={onDragOver}
|
| 536 |
+
onDragEnd={onDragEnd} draggedId={draggedId}
|
| 537 |
+
collaboratorHovers={(window as any).__wsCollaboratorHoverMap?.get(child.id) || []} />
|
| 538 |
+
))}
|
| 539 |
+
</Tag>
|
| 540 |
+
{isVoidTag ? null : (
|
| 541 |
+
<button onClick={(e) => { e.stopPropagation(); onAddChild(element.id); }}
|
| 542 |
+
className="absolute -bottom-3 left-1/2 -translate-x-1/2 w-6 h-6 flex items-center justify-center rounded-full
|
| 543 |
+
bg-primary-600 text-white shadow-lg hover:bg-primary-500 transition-all opacity-0 hover:opacity-100 z-10"
|
| 544 |
+
title="Add child element">
|
| 545 |
+
<Plus className="w-3.5 h-3.5" />
|
| 546 |
+
</button>
|
| 547 |
+
)}
|
| 548 |
+
</div>
|
| 549 |
+
);
|
| 550 |
+
}
|
client/src/components/VisualEditor/editorSandbox.ts
DELETED
|
@@ -1,174 +0,0 @@
|
|
| 1 |
-
export const SANDBOX_SCRIPT = `
|
| 2 |
-
(function() {
|
| 3 |
-
var selectedSelector = null;
|
| 4 |
-
|
| 5 |
-
window.addEventListener('message', function(event) {
|
| 6 |
-
var data = event.data;
|
| 7 |
-
switch (data.type) {
|
| 8 |
-
case 'UPDATE_CONTENT':
|
| 9 |
-
updateContent(data.html, data.css, data.js);
|
| 10 |
-
break;
|
| 11 |
-
case 'GET_ELEMENT_AT':
|
| 12 |
-
var el = document.elementFromPoint(data.x - window.frameElement.getBoundingClientRect().left, data.y - window.frameElement.getBoundingClientRect().top);
|
| 13 |
-
if (el) {
|
| 14 |
-
var selector = getUniqueSelector(el);
|
| 15 |
-
if (data.action === 'drop') {
|
| 16 |
-
window.parent.postMessage({ type: 'ELEMENT_DROP', selector: selector, x: data.x, y: data.y }, '*');
|
| 17 |
-
} else {
|
| 18 |
-
window.parent.postMessage({ type: 'ELEMENT_DRAG_OVER', selector: selector, x: data.x, y: data.y }, '*');
|
| 19 |
-
}
|
| 20 |
-
}
|
| 21 |
-
break;
|
| 22 |
-
case 'SELECT_ELEMENT':
|
| 23 |
-
selectedSelector = data.selector;
|
| 24 |
-
document.querySelectorAll('[data-rb-selected]').forEach(function(el) { el.removeAttribute('data-rb-selected'); });
|
| 25 |
-
if (data.selector) {
|
| 26 |
-
var selEl = document.querySelector(data.selector);
|
| 27 |
-
if (selEl) selEl.setAttribute('data-rb-selected', 'true');
|
| 28 |
-
}
|
| 29 |
-
break;
|
| 30 |
-
case 'UPDATE_ELEMENT_ATTRIBUTE':
|
| 31 |
-
var target = document.querySelector(data.selector);
|
| 32 |
-
if (target) {
|
| 33 |
-
target.setAttribute(data.attr, data.value);
|
| 34 |
-
if (data.attr === 'id' && data.value) {
|
| 35 |
-
target.setAttribute('data-rb-editable', 'true');
|
| 36 |
-
}
|
| 37 |
-
}
|
| 38 |
-
break;
|
| 39 |
-
case 'SHOW_OUTLINES':
|
| 40 |
-
if (data.show) renderDropZones();
|
| 41 |
-
else document.querySelectorAll('.rb-drop-zone').forEach(function(el) { el.remove(); });
|
| 42 |
-
break;
|
| 43 |
-
}
|
| 44 |
-
});
|
| 45 |
-
|
| 46 |
-
function updateContent(html, css, js) {
|
| 47 |
-
var parser = new DOMParser();
|
| 48 |
-
var doc = parser.parseFromString(html, 'text/html');
|
| 49 |
-
document.body.innerHTML = doc.body.innerHTML;
|
| 50 |
-
document.head.innerHTML = doc.head.innerHTML;
|
| 51 |
-
|
| 52 |
-
var styleEl = document.getElementById('rb-styles');
|
| 53 |
-
if (!styleEl) {
|
| 54 |
-
styleEl = document.createElement('style');
|
| 55 |
-
styleEl.id = 'rb-styles';
|
| 56 |
-
document.head.appendChild(styleEl);
|
| 57 |
-
}
|
| 58 |
-
styleEl.textContent = css;
|
| 59 |
-
|
| 60 |
-
var scriptEl = document.getElementById('rb-script');
|
| 61 |
-
if (!scriptEl) {
|
| 62 |
-
scriptEl = document.createElement('script');
|
| 63 |
-
scriptEl.id = 'rb-script';
|
| 64 |
-
document.body.appendChild(scriptEl);
|
| 65 |
-
}
|
| 66 |
-
scriptEl.textContent = js;
|
| 67 |
-
|
| 68 |
-
attachEditorListeners();
|
| 69 |
-
renderDropZones();
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
function getUniqueSelector(el) {
|
| 73 |
-
if (el.id) return '#' + CSS.escape(el.id);
|
| 74 |
-
var path = [];
|
| 75 |
-
while (el && el.nodeType === Node.ELEMENT_NODE) {
|
| 76 |
-
var selector = el.tagName.toLowerCase();
|
| 77 |
-
if (el.id) { path.unshift('#' + CSS.escape(el.id)); break; }
|
| 78 |
-
var sib = el.parentNode.childNodes;
|
| 79 |
-
var idx = 1;
|
| 80 |
-
for (var i = 0; i < sib.length; i++) {
|
| 81 |
-
if (sib[i] === el) { selector += ':nth-child(' + idx + ')'; break; }
|
| 82 |
-
if (sib[i].nodeType === Node.ELEMENT_NODE && sib[i].tagName === el.tagName) idx++;
|
| 83 |
-
}
|
| 84 |
-
path.unshift(selector);
|
| 85 |
-
el = el.parentNode;
|
| 86 |
-
}
|
| 87 |
-
return path.join(' > ');
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
function attachEditorListeners() {
|
| 91 |
-
document.querySelectorAll('[data-rb-editable]').forEach(function(el) {
|
| 92 |
-
el.addEventListener('click', function(e) {
|
| 93 |
-
e.stopPropagation();
|
| 94 |
-
window.parent.postMessage({ type: 'ELEMENT_CLICK', selector: getUniqueSelector(this) }, '*');
|
| 95 |
-
});
|
| 96 |
-
el.addEventListener('mouseenter', function() {
|
| 97 |
-
window.parent.postMessage({ type: 'ELEMENT_HOVER', selector: getUniqueSelector(this) }, '*');
|
| 98 |
-
});
|
| 99 |
-
el.addEventListener('mouseleave', function() {
|
| 100 |
-
window.parent.postMessage({ type: 'ELEMENT_HOVER', selector: null }, '*');
|
| 101 |
-
});
|
| 102 |
-
});
|
| 103 |
-
document.addEventListener('click', function(e) {
|
| 104 |
-
if (e.target === document.body || e.target === document.documentElement) {
|
| 105 |
-
window.parent.postMessage({ type: 'CANVAS_CLICK' }, '*');
|
| 106 |
-
}
|
| 107 |
-
});
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
function renderDropZones() {
|
| 111 |
-
document.querySelectorAll('.rb-drop-zone').forEach(function(el) { el.remove(); });
|
| 112 |
-
document.querySelectorAll('[data-rb-has-slots]').forEach(function(parent) {
|
| 113 |
-
var slotCount = parseInt(parent.getAttribute('data-rb-slot-count') || '1');
|
| 114 |
-
var isForm = parent.tagName === 'FORM';
|
| 115 |
-
var existingChildren = parent.querySelectorAll(':scope > [data-rb-editable]');
|
| 116 |
-
var renderedSlots = 0;
|
| 117 |
-
for (var i = 0; i < slotCount; i++) {
|
| 118 |
-
var child = existingChildren[i];
|
| 119 |
-
if (!child || (isForm && renderedSlots > 0)) {
|
| 120 |
-
var zone = document.createElement('div');
|
| 121 |
-
zone.className = 'rb-drop-zone';
|
| 122 |
-
zone.dataset.rbSlotIndex = String(i);
|
| 123 |
-
zone.dataset.rbParentSelector = getUniqueSelector(parent);
|
| 124 |
-
zone.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('rb-drop-zone-active'); });
|
| 125 |
-
zone.addEventListener('dragleave', function() { this.classList.remove('rb-drop-zone-active'); });
|
| 126 |
-
zone.addEventListener('drop', function(e) {
|
| 127 |
-
e.preventDefault();
|
| 128 |
-
this.classList.remove('rb-drop-zone-active');
|
| 129 |
-
window.parent.postMessage({
|
| 130 |
-
type: 'ELEMENT_DROP_IN_SLOT',
|
| 131 |
-
parentSelector: this.dataset.rbParentSelector,
|
| 132 |
-
slotIndex: parseInt(this.dataset.rbSlotIndex || '0'),
|
| 133 |
-
}, '*');
|
| 134 |
-
});
|
| 135 |
-
parent.appendChild(zone);
|
| 136 |
-
}
|
| 137 |
-
if (child) renderedSlots++;
|
| 138 |
-
}
|
| 139 |
-
});
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
window.parent.postMessage({ type: 'READY' }, '*');
|
| 143 |
-
attachEditorListeners();
|
| 144 |
-
renderDropZones();
|
| 145 |
-
})();
|
| 146 |
-
`;
|
| 147 |
-
|
| 148 |
-
export function generateEditorPage(html: string, css: string, js: string): string {
|
| 149 |
-
return `<!DOCTYPE html>
|
| 150 |
-
<html lang="en">
|
| 151 |
-
<head>
|
| 152 |
-
<meta charset="UTF-8">
|
| 153 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 154 |
-
<style id="rb-styles">${css}</style>
|
| 155 |
-
<style>
|
| 156 |
-
[data-rb-editable] { outline: 1px solid transparent; transition: outline 0.1s; cursor: pointer; min-height: 4px; min-width: 4px; }
|
| 157 |
-
[data-rb-editable]:hover { outline: 1px solid #6366f1; }
|
| 158 |
-
[data-rb-selected] { outline: 2px solid #6366f1 !important; }
|
| 159 |
-
.rb-drop-zone { outline: 2px dashed #94a3b8; min-height: 20px; min-width: 100%; transition: all 0.15s; }
|
| 160 |
-
.rb-drop-zone-active { outline: 2px dashed #6366f1; background: rgba(99,102,241,0.05); }
|
| 161 |
-
</style>
|
| 162 |
-
</head>
|
| 163 |
-
<body>
|
| 164 |
-
${extractBodyContent(html)}
|
| 165 |
-
<script id="rb-script">${js}</script>
|
| 166 |
-
<script>${SANDBOX_SCRIPT}</script>
|
| 167 |
-
</body>
|
| 168 |
-
</html>`;
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
function extractBodyContent(fullHtml: string): string {
|
| 172 |
-
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
| 173 |
-
return match ? match[1] : fullHtml;
|
| 174 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client/src/hooks/useWebSocket.ts
CHANGED
|
@@ -16,9 +16,6 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 16 |
updateCursor,
|
| 17 |
updateBlockSelection,
|
| 18 |
updateElementHover,
|
| 19 |
-
updatePointerPosition,
|
| 20 |
-
updateDraggingBlock,
|
| 21 |
-
updateTextFieldCursor,
|
| 22 |
setConnected,
|
| 23 |
} = useCollaborationStore();
|
| 24 |
const socketRef = useRef<Socket | null>(null);
|
|
@@ -75,43 +72,12 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 75 |
updateElementHover(userId, elementId);
|
| 76 |
});
|
| 77 |
|
| 78 |
-
// NEW: pointer move tracking
|
| 79 |
-
socket.on('pointer_move', ({ userId, x, y }: { userId: string; x: number; y: number }) => {
|
| 80 |
-
updatePointerPosition(userId, x, y);
|
| 81 |
-
});
|
| 82 |
-
|
| 83 |
-
// NEW: block drag tracking
|
| 84 |
-
socket.on('block_drag_start', ({ userId, blockId, x, y }: { userId: string; blockId: string; x: number; y: number }) => {
|
| 85 |
-
updateDraggingBlock(userId, { blockId, x, y });
|
| 86 |
-
});
|
| 87 |
-
|
| 88 |
-
socket.on('block_drag_move', ({ userId, blockId, x, y }: { userId: string; blockId: string; x: number; y: number }) => {
|
| 89 |
-
updateDraggingBlock(userId, { blockId, x, y });
|
| 90 |
-
});
|
| 91 |
-
|
| 92 |
-
socket.on('block_drag_end', ({ userId }: { userId: string }) => {
|
| 93 |
-
updateDraggingBlock(userId, null);
|
| 94 |
-
});
|
| 95 |
-
|
| 96 |
-
// NEW: text field cursor tracking
|
| 97 |
-
socket.on('text_field_focus', ({ userId, fieldId }: { userId: string; fieldId: string }) => {
|
| 98 |
-
updateTextFieldCursor(userId, { fieldId, position: 0 });
|
| 99 |
-
});
|
| 100 |
-
|
| 101 |
-
socket.on('text_field_cursor', ({ userId, fieldId, position }: { userId: string; fieldId: string; position: number }) => {
|
| 102 |
-
updateTextFieldCursor(userId, { fieldId, position });
|
| 103 |
-
});
|
| 104 |
-
|
| 105 |
-
socket.on('text_field_blur', ({ userId }: { userId: string }) => {
|
| 106 |
-
updateTextFieldCursor(userId, null);
|
| 107 |
-
});
|
| 108 |
-
|
| 109 |
-
// File updates
|
| 110 |
socket.on('file_updated', ({ fileId, content }: { fileId: string; content: string }) => {
|
| 111 |
const { updateFile } = useCollaborationStore.getState();
|
| 112 |
updateFile(fileId, content);
|
| 113 |
});
|
| 114 |
|
|
|
|
| 115 |
socket.on('file_added', ({ file, parentId }: { file: any; parentId?: string }) => {
|
| 116 |
const { addFile } = useEditorStore.getState();
|
| 117 |
addFile(file, parentId);
|
|
@@ -127,7 +93,7 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 127 |
removeFile(fileId);
|
| 128 |
});
|
| 129 |
|
| 130 |
-
//
|
| 131 |
socket.on('visual_element_added', ({ element, parentId }: { element: any; parentId?: string }) => {
|
| 132 |
const { addVisualElement } = useEditorStore.getState();
|
| 133 |
addVisualElement(element, parentId);
|
|
@@ -148,7 +114,6 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 148 |
setVisualElements(elements);
|
| 149 |
});
|
| 150 |
|
| 151 |
-
// Project save notification
|
| 152 |
socket.on('project_saved', ({ updatedAt, savedBy }: { updatedAt: number; savedBy: string }) => {
|
| 153 |
const { setLastSaved, setSavedBy } = useCollaborationStore.getState();
|
| 154 |
setLastSaved(updatedAt);
|
|
@@ -161,9 +126,8 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 161 |
if (globalSocket === socket) globalSocket = null;
|
| 162 |
setConnected(false);
|
| 163 |
};
|
| 164 |
-
}, [token, projectId, setCollaborators, addCollaborator, removeCollaborator, updateActiveFile, updateCursor, updateBlockSelection, updateElementHover,
|
| 165 |
|
| 166 |
-
// ---- EMIT HELPERS ----
|
| 167 |
const emitFileChanged = useCallback((fileId: string, content: string) => {
|
| 168 |
if (socketRef.current?.connected && projectId) {
|
| 169 |
socketRef.current.emit('file_changed', { projectId, fileId, content });
|
|
@@ -194,52 +158,6 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 194 |
}
|
| 195 |
}, [projectId]);
|
| 196 |
|
| 197 |
-
// NEW: pointer move
|
| 198 |
-
const emitPointerMove = useCallback((x: number, y: number) => {
|
| 199 |
-
if (socketRef.current?.connected && projectId) {
|
| 200 |
-
socketRef.current.emit('pointer_move', { projectId, x, y });
|
| 201 |
-
}
|
| 202 |
-
}, [projectId]);
|
| 203 |
-
|
| 204 |
-
// NEW: block drag
|
| 205 |
-
const emitBlockDragStart = useCallback((blockId: string, x: number, y: number) => {
|
| 206 |
-
if (socketRef.current?.connected && projectId) {
|
| 207 |
-
socketRef.current.emit('block_drag_start', { projectId, blockId, x, y });
|
| 208 |
-
}
|
| 209 |
-
}, [projectId]);
|
| 210 |
-
|
| 211 |
-
const emitBlockDragMove = useCallback((blockId: string, x: number, y: number) => {
|
| 212 |
-
if (socketRef.current?.connected && projectId) {
|
| 213 |
-
socketRef.current.emit('block_drag_move', { projectId, blockId, x, y });
|
| 214 |
-
}
|
| 215 |
-
}, [projectId]);
|
| 216 |
-
|
| 217 |
-
const emitBlockDragEnd = useCallback(() => {
|
| 218 |
-
if (socketRef.current?.connected && projectId) {
|
| 219 |
-
socketRef.current.emit('block_drag_end', { projectId });
|
| 220 |
-
}
|
| 221 |
-
}, [projectId]);
|
| 222 |
-
|
| 223 |
-
// NEW: text field
|
| 224 |
-
const emitTextFieldFocus = useCallback((fieldId: string) => {
|
| 225 |
-
if (socketRef.current?.connected && projectId) {
|
| 226 |
-
socketRef.current.emit('text_field_focus', { projectId, fieldId });
|
| 227 |
-
}
|
| 228 |
-
}, [projectId]);
|
| 229 |
-
|
| 230 |
-
const emitTextFieldCursor = useCallback((fieldId: string, position: number) => {
|
| 231 |
-
if (socketRef.current?.connected && projectId) {
|
| 232 |
-
socketRef.current.emit('text_field_cursor', { projectId, fieldId, position });
|
| 233 |
-
}
|
| 234 |
-
}, [projectId]);
|
| 235 |
-
|
| 236 |
-
const emitTextFieldBlur = useCallback(() => {
|
| 237 |
-
if (socketRef.current?.connected && projectId) {
|
| 238 |
-
socketRef.current.emit('text_field_blur', { projectId });
|
| 239 |
-
}
|
| 240 |
-
}, [projectId]);
|
| 241 |
-
|
| 242 |
-
// File CRUD
|
| 243 |
const emitFileAdded = useCallback((file: any, parentId?: string) => {
|
| 244 |
if (socketRef.current?.connected && projectId) {
|
| 245 |
socketRef.current.emit('file_added', { projectId, file, parentId });
|
|
@@ -258,7 +176,6 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 258 |
}
|
| 259 |
}, [projectId]);
|
| 260 |
|
| 261 |
-
// Visual element CRUD
|
| 262 |
const emitVisualElementAdded = useCallback((element: any, parentId?: string) => {
|
| 263 |
if (socketRef.current?.connected && projectId) {
|
| 264 |
socketRef.current.emit('visual_element_added', { projectId, element, parentId });
|
|
@@ -283,7 +200,6 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 283 |
}
|
| 284 |
}, [projectId]);
|
| 285 |
|
| 286 |
-
// Save via WS
|
| 287 |
const emitSave = useCallback((data: any): Promise<any> => {
|
| 288 |
return new Promise((resolve, reject) => {
|
| 289 |
if (!socketRef.current?.connected || !projectId) {
|
|
@@ -297,72 +213,6 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 297 |
});
|
| 298 |
}, [projectId]);
|
| 299 |
|
| 300 |
-
// NEW: Project CRUD via WS
|
| 301 |
-
const emitProjectList = useCallback((): Promise<any> => {
|
| 302 |
-
return new Promise((resolve, reject) => {
|
| 303 |
-
if (!socketRef.current?.connected) {
|
| 304 |
-
reject(new Error('Not connected'));
|
| 305 |
-
return;
|
| 306 |
-
}
|
| 307 |
-
socketRef.current.emit('project_list', {}, (response: any) => {
|
| 308 |
-
if (response?.error) reject(new Error(response.error));
|
| 309 |
-
else resolve(response);
|
| 310 |
-
});
|
| 311 |
-
});
|
| 312 |
-
}, []);
|
| 313 |
-
|
| 314 |
-
const emitProjectCreate = useCallback((name: string, framework: string, description?: string): Promise<any> => {
|
| 315 |
-
return new Promise((resolve, reject) => {
|
| 316 |
-
if (!socketRef.current?.connected) {
|
| 317 |
-
reject(new Error('Not connected'));
|
| 318 |
-
return;
|
| 319 |
-
}
|
| 320 |
-
socketRef.current.emit('project_create', { name, framework, description }, (response: any) => {
|
| 321 |
-
if (response?.error) reject(new Error(response.error));
|
| 322 |
-
else resolve(response);
|
| 323 |
-
});
|
| 324 |
-
});
|
| 325 |
-
}, []);
|
| 326 |
-
|
| 327 |
-
const emitProjectGet = useCallback((pid: string): Promise<any> => {
|
| 328 |
-
return new Promise((resolve, reject) => {
|
| 329 |
-
if (!socketRef.current?.connected) {
|
| 330 |
-
reject(new Error('Not connected'));
|
| 331 |
-
return;
|
| 332 |
-
}
|
| 333 |
-
socketRef.current.emit('project_get', { projectId: pid }, (response: any) => {
|
| 334 |
-
if (response?.error) reject(new Error(response.error));
|
| 335 |
-
else resolve(response);
|
| 336 |
-
});
|
| 337 |
-
});
|
| 338 |
-
}, []);
|
| 339 |
-
|
| 340 |
-
const emitProjectUpdate = useCallback((pid: string, updates: any): Promise<any> => {
|
| 341 |
-
return new Promise((resolve, reject) => {
|
| 342 |
-
if (!socketRef.current?.connected) {
|
| 343 |
-
reject(new Error('Not connected'));
|
| 344 |
-
return;
|
| 345 |
-
}
|
| 346 |
-
socketRef.current.emit('project_update', { projectId: pid, ...updates }, (response: any) => {
|
| 347 |
-
if (response?.error) reject(new Error(response.error));
|
| 348 |
-
else resolve(response);
|
| 349 |
-
});
|
| 350 |
-
});
|
| 351 |
-
}, []);
|
| 352 |
-
|
| 353 |
-
const emitProjectDelete = useCallback((pid: string): Promise<any> => {
|
| 354 |
-
return new Promise((resolve, reject) => {
|
| 355 |
-
if (!socketRef.current?.connected) {
|
| 356 |
-
reject(new Error('Not connected'));
|
| 357 |
-
return;
|
| 358 |
-
}
|
| 359 |
-
socketRef.current.emit('project_delete', { projectId: pid }, (response: any) => {
|
| 360 |
-
if (response?.error) reject(new Error(response.error));
|
| 361 |
-
else resolve(response);
|
| 362 |
-
});
|
| 363 |
-
});
|
| 364 |
-
}, []);
|
| 365 |
-
|
| 366 |
return {
|
| 367 |
socket: socketRef.current,
|
| 368 |
emitFileChanged,
|
|
@@ -370,13 +220,6 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 370 |
emitCursorMove,
|
| 371 |
emitBlockSelection,
|
| 372 |
emitElementHover,
|
| 373 |
-
emitPointerMove,
|
| 374 |
-
emitBlockDragStart,
|
| 375 |
-
emitBlockDragMove,
|
| 376 |
-
emitBlockDragEnd,
|
| 377 |
-
emitTextFieldFocus,
|
| 378 |
-
emitTextFieldCursor,
|
| 379 |
-
emitTextFieldBlur,
|
| 380 |
emitFileAdded,
|
| 381 |
emitFileRenamed,
|
| 382 |
emitFileDeleted,
|
|
@@ -385,11 +228,6 @@ export function useWebSocket(projectId: string | undefined) {
|
|
| 385 |
emitVisualElementRemoved,
|
| 386 |
emitVisualElementsReordered,
|
| 387 |
emitSave,
|
| 388 |
-
emitProjectList,
|
| 389 |
-
emitProjectCreate,
|
| 390 |
-
emitProjectGet,
|
| 391 |
-
emitProjectUpdate,
|
| 392 |
-
emitProjectDelete,
|
| 393 |
};
|
| 394 |
}
|
| 395 |
|
|
|
|
| 16 |
updateCursor,
|
| 17 |
updateBlockSelection,
|
| 18 |
updateElementHover,
|
|
|
|
|
|
|
|
|
|
| 19 |
setConnected,
|
| 20 |
} = useCollaborationStore();
|
| 21 |
const socketRef = useRef<Socket | null>(null);
|
|
|
|
| 72 |
updateElementHover(userId, elementId);
|
| 73 |
});
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
socket.on('file_updated', ({ fileId, content }: { fileId: string; content: string }) => {
|
| 76 |
const { updateFile } = useCollaborationStore.getState();
|
| 77 |
updateFile(fileId, content);
|
| 78 |
});
|
| 79 |
|
| 80 |
+
// Remote file CRUD
|
| 81 |
socket.on('file_added', ({ file, parentId }: { file: any; parentId?: string }) => {
|
| 82 |
const { addFile } = useEditorStore.getState();
|
| 83 |
addFile(file, parentId);
|
|
|
|
| 93 |
removeFile(fileId);
|
| 94 |
});
|
| 95 |
|
| 96 |
+
// Remote visual element CRUD
|
| 97 |
socket.on('visual_element_added', ({ element, parentId }: { element: any; parentId?: string }) => {
|
| 98 |
const { addVisualElement } = useEditorStore.getState();
|
| 99 |
addVisualElement(element, parentId);
|
|
|
|
| 114 |
setVisualElements(elements);
|
| 115 |
});
|
| 116 |
|
|
|
|
| 117 |
socket.on('project_saved', ({ updatedAt, savedBy }: { updatedAt: number; savedBy: string }) => {
|
| 118 |
const { setLastSaved, setSavedBy } = useCollaborationStore.getState();
|
| 119 |
setLastSaved(updatedAt);
|
|
|
|
| 126 |
if (globalSocket === socket) globalSocket = null;
|
| 127 |
setConnected(false);
|
| 128 |
};
|
| 129 |
+
}, [token, projectId, setCollaborators, addCollaborator, removeCollaborator, updateActiveFile, updateCursor, updateBlockSelection, updateElementHover, setConnected]);
|
| 130 |
|
|
|
|
| 131 |
const emitFileChanged = useCallback((fileId: string, content: string) => {
|
| 132 |
if (socketRef.current?.connected && projectId) {
|
| 133 |
socketRef.current.emit('file_changed', { projectId, fileId, content });
|
|
|
|
| 158 |
}
|
| 159 |
}, [projectId]);
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
const emitFileAdded = useCallback((file: any, parentId?: string) => {
|
| 162 |
if (socketRef.current?.connected && projectId) {
|
| 163 |
socketRef.current.emit('file_added', { projectId, file, parentId });
|
|
|
|
| 176 |
}
|
| 177 |
}, [projectId]);
|
| 178 |
|
|
|
|
| 179 |
const emitVisualElementAdded = useCallback((element: any, parentId?: string) => {
|
| 180 |
if (socketRef.current?.connected && projectId) {
|
| 181 |
socketRef.current.emit('visual_element_added', { projectId, element, parentId });
|
|
|
|
| 200 |
}
|
| 201 |
}, [projectId]);
|
| 202 |
|
|
|
|
| 203 |
const emitSave = useCallback((data: any): Promise<any> => {
|
| 204 |
return new Promise((resolve, reject) => {
|
| 205 |
if (!socketRef.current?.connected || !projectId) {
|
|
|
|
| 213 |
});
|
| 214 |
}, [projectId]);
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
return {
|
| 217 |
socket: socketRef.current,
|
| 218 |
emitFileChanged,
|
|
|
|
| 220 |
emitCursorMove,
|
| 221 |
emitBlockSelection,
|
| 222 |
emitElementHover,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
emitFileAdded,
|
| 224 |
emitFileRenamed,
|
| 225 |
emitFileDeleted,
|
|
|
|
| 228 |
emitVisualElementRemoved,
|
| 229 |
emitVisualElementsReordered,
|
| 230 |
emitSave,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
};
|
| 232 |
}
|
| 233 |
|
client/src/store/collaborationStore.ts
CHANGED
|
@@ -10,10 +10,6 @@ export interface Collaborator {
|
|
| 10 |
cursor: { line: number; ch: number } | null;
|
| 11 |
selectedBlockId: string | null;
|
| 12 |
selectedElementId: string | null;
|
| 13 |
-
pointerPosition: { x: number; y: number } | null;
|
| 14 |
-
draggingBlock: { blockId: string; x: number; y: number } | null;
|
| 15 |
-
textFieldCursor: { fieldId: string; position: number } | null;
|
| 16 |
-
typing: boolean;
|
| 17 |
joinedAt: number;
|
| 18 |
}
|
| 19 |
|
|
@@ -37,9 +33,6 @@ interface CollaborationStore {
|
|
| 37 |
updateCursor: (userId: string, fileId: string, cursor: { line: number; ch: number } | null) => void;
|
| 38 |
updateBlockSelection: (userId: string, blockId: string | null) => void;
|
| 39 |
updateElementHover: (userId: string, elementId: string | null) => void;
|
| 40 |
-
updatePointerPosition: (userId: string, x: number, y: number) => void;
|
| 41 |
-
updateDraggingBlock: (userId: string, dragging: { blockId: string; x: number; y: number } | null) => void;
|
| 42 |
-
updateTextFieldCursor: (userId: string, cursor: { fieldId: string; position: number } | null) => void;
|
| 43 |
updateFile: (fileId: string, content: string) => void;
|
| 44 |
setLastSaved: (timestamp: number) => void;
|
| 45 |
setSavedBy: (username: string) => void;
|
|
@@ -102,27 +95,6 @@ export const useCollaborationStore = create<CollaborationStore>((set, get) => ({
|
|
| 102 |
),
|
| 103 |
})),
|
| 104 |
|
| 105 |
-
updatePointerPosition: (userId, x, y) =>
|
| 106 |
-
set((state) => ({
|
| 107 |
-
collaborators: state.collaborators.map((c) =>
|
| 108 |
-
c.userId === userId ? { ...c, pointerPosition: { x, y } } : c
|
| 109 |
-
),
|
| 110 |
-
})),
|
| 111 |
-
|
| 112 |
-
updateDraggingBlock: (userId, dragging) =>
|
| 113 |
-
set((state) => ({
|
| 114 |
-
collaborators: state.collaborators.map((c) =>
|
| 115 |
-
c.userId === userId ? { ...c, draggingBlock: dragging } : c
|
| 116 |
-
),
|
| 117 |
-
})),
|
| 118 |
-
|
| 119 |
-
updateTextFieldCursor: (userId, cursor) =>
|
| 120 |
-
set((state) => ({
|
| 121 |
-
collaborators: state.collaborators.map((c) =>
|
| 122 |
-
c.userId === userId ? { ...c, textFieldCursor: cursor } : c
|
| 123 |
-
),
|
| 124 |
-
})),
|
| 125 |
-
|
| 126 |
updateFile: (fileId, content) => {
|
| 127 |
const state = useEditorStore.getState();
|
| 128 |
if (state.activeFileId === fileId) {
|
|
|
|
| 10 |
cursor: { line: number; ch: number } | null;
|
| 11 |
selectedBlockId: string | null;
|
| 12 |
selectedElementId: string | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
joinedAt: number;
|
| 14 |
}
|
| 15 |
|
|
|
|
| 33 |
updateCursor: (userId: string, fileId: string, cursor: { line: number; ch: number } | null) => void;
|
| 34 |
updateBlockSelection: (userId: string, blockId: string | null) => void;
|
| 35 |
updateElementHover: (userId: string, elementId: string | null) => void;
|
|
|
|
|
|
|
|
|
|
| 36 |
updateFile: (fileId: string, content: string) => void;
|
| 37 |
setLastSaved: (timestamp: number) => void;
|
| 38 |
setSavedBy: (username: string) => void;
|
|
|
|
| 95 |
),
|
| 96 |
})),
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
updateFile: (fileId, content) => {
|
| 99 |
const state = useEditorStore.getState();
|
| 100 |
if (state.activeFileId === fileId) {
|
client/src/store/editorStore.ts
CHANGED
|
@@ -1,18 +1,9 @@
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
-
import { ProjectFile, VisualElement
|
| 3 |
|
| 4 |
type EditorMode = 'blocks' | 'visual' | 'code' | 'files' | 'preview';
|
| 5 |
type ViewMode = 'design' | 'preview' | 'split';
|
| 6 |
|
| 7 |
-
export interface ElementRef {
|
| 8 |
-
id: string;
|
| 9 |
-
tagName: string;
|
| 10 |
-
classes: string[];
|
| 11 |
-
attributes: Record<string, string>;
|
| 12 |
-
fileId: string;
|
| 13 |
-
linkedFiles: string[];
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
interface EditorStore {
|
| 17 |
activeFileId: string | null;
|
| 18 |
activeFileContent: string;
|
|
@@ -27,10 +18,6 @@ interface EditorStore {
|
|
| 27 |
fileTree: ProjectFile[];
|
| 28 |
blocksXml: Record<string, string>;
|
| 29 |
blockCode: Record<string, string>;
|
| 30 |
-
elementCounters: Record<string, number>;
|
| 31 |
-
elementRegistry: ElementRef[];
|
| 32 |
-
slotCounts: Record<string, number>;
|
| 33 |
-
customIds: Set<string>;
|
| 34 |
|
| 35 |
setActiveFile: (fileId: string | null) => void;
|
| 36 |
setActiveFileContent: (content: string) => void;
|
|
@@ -42,7 +29,7 @@ interface EditorStore {
|
|
| 42 |
toggleSnapToGrid: () => void;
|
| 43 |
toggleMiniMap: () => void;
|
| 44 |
setVisualElements: (elements: VisualElement[]) => void;
|
| 45 |
-
addVisualElement: (element: VisualElement, parentId?: string
|
| 46 |
updateVisualElement: (id: string, updates: Partial<VisualElement>) => void;
|
| 47 |
removeVisualElement: (id: string) => void;
|
| 48 |
setFileTree: (files: ProjectFile[]) => void;
|
|
@@ -54,39 +41,6 @@ interface EditorStore {
|
|
| 54 |
setBlockCode: (fileId: string, code: string) => void;
|
| 55 |
getBlockCode: (fileId: string) => string;
|
| 56 |
saveGeneratedCodeToFile: (fileId: string, code: string) => void;
|
| 57 |
-
incrementCounter: (tag: string) => string;
|
| 58 |
-
resetCounter: (tag: string) => void;
|
| 59 |
-
recomputeRegistry: () => void;
|
| 60 |
-
recalculateSlots: (elementId: string) => void;
|
| 61 |
-
setSlotCount: (elementId: string, count: number) => void;
|
| 62 |
-
setCustomId: (elementId: string, isCustom: boolean) => void;
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
function getChildCount(elements: VisualElement[], id: string): number {
|
| 66 |
-
for (const el of elements) {
|
| 67 |
-
if (el.id === id) return (el.children || []).length;
|
| 68 |
-
if (el.children) {
|
| 69 |
-
const found = getChildCount(el.children, id);
|
| 70 |
-
if (found !== -1) return found;
|
| 71 |
-
}
|
| 72 |
-
}
|
| 73 |
-
return -1;
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
function getLinkedFiles(fileId: string, fileTree: ProjectFile[]): string[] {
|
| 77 |
-
const file = findFileById(fileTree, fileId);
|
| 78 |
-
return file?.linkedFiles || [];
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
function findFileById(files: ProjectFile[], id: string): ProjectFile | null {
|
| 82 |
-
for (const f of files) {
|
| 83 |
-
if (f.id === id) return f;
|
| 84 |
-
if (f.children) {
|
| 85 |
-
const found = findFileById(f.children, id);
|
| 86 |
-
if (found) return found;
|
| 87 |
-
}
|
| 88 |
-
}
|
| 89 |
-
return null;
|
| 90 |
}
|
| 91 |
|
| 92 |
export const useEditorStore = create<EditorStore>((set, get) => ({
|
|
@@ -103,10 +57,6 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
|
|
| 103 |
fileTree: [],
|
| 104 |
blocksXml: {},
|
| 105 |
blockCode: {},
|
| 106 |
-
elementCounters: {},
|
| 107 |
-
elementRegistry: [],
|
| 108 |
-
slotCounts: {},
|
| 109 |
-
customIds: new Set<string>(),
|
| 110 |
|
| 111 |
setActiveFile: (fileId) => {
|
| 112 |
const state = get();
|
|
@@ -129,89 +79,57 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
|
|
| 129 |
setZoom: (zoom) => set({ zoom: Math.max(0.25, Math.min(2, zoom)) }),
|
| 130 |
toggleSnapToGrid: () => set((s) => ({ snapToGrid: !s.snapToGrid })),
|
| 131 |
toggleMiniMap: () => set((s) => ({ showMiniMap: !s.showMiniMap })),
|
| 132 |
-
|
| 133 |
setVisualElements: (elements) => set({ visualElements: elements || [] }),
|
| 134 |
-
|
| 135 |
-
addVisualElement: (element, parentId, insertIndex) =>
|
| 136 |
set((state) => {
|
| 137 |
const elements = state.visualElements || [];
|
| 138 |
-
let newElements: VisualElement[];
|
| 139 |
if (!parentId) {
|
| 140 |
-
|
| 141 |
-
const copy = [...elements];
|
| 142 |
-
copy.splice(insertIndex, 0, element);
|
| 143 |
-
newElements = copy;
|
| 144 |
-
} else {
|
| 145 |
-
newElements = [...elements, element];
|
| 146 |
-
}
|
| 147 |
-
} else {
|
| 148 |
-
const addToParent = (els: VisualElement[]): VisualElement[] =>
|
| 149 |
-
(els || []).map((el) => {
|
| 150 |
-
if (el.id === parentId) {
|
| 151 |
-
const children = [...(el.children || [])];
|
| 152 |
-
if (insertIndex !== undefined) {
|
| 153 |
-
children.splice(insertIndex, 0, element);
|
| 154 |
-
} else {
|
| 155 |
-
children.push(element);
|
| 156 |
-
}
|
| 157 |
-
return { ...el, children };
|
| 158 |
-
}
|
| 159 |
-
if (el.children) {
|
| 160 |
-
return { ...el, children: addToParent(el.children) };
|
| 161 |
-
}
|
| 162 |
-
return el;
|
| 163 |
-
});
|
| 164 |
-
newElements = addToParent(elements);
|
| 165 |
}
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
}),
|
| 172 |
-
|
| 173 |
updateVisualElement: (id, updates) =>
|
| 174 |
set((state) => {
|
| 175 |
const elements = state.visualElements || [];
|
| 176 |
-
const updateRecursive = (
|
| 177 |
-
(
|
| 178 |
if (el.id === id) return { ...el, ...updates };
|
| 179 |
if (el.children) return { ...el, children: updateRecursive(el.children) };
|
| 180 |
return el;
|
| 181 |
});
|
| 182 |
return { visualElements: updateRecursive(elements) };
|
| 183 |
}),
|
| 184 |
-
|
| 185 |
removeVisualElement: (id) =>
|
| 186 |
set((state) => {
|
| 187 |
const elements = state.visualElements || [];
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
if (el.
|
| 192 |
-
if (el.children) el.children = removeRecursive(el.children || [], el.id);
|
| 193 |
return true;
|
| 194 |
});
|
| 195 |
-
const newElements = removeRecursive(elements);
|
| 196 |
-
// Recalculate slots for parent
|
| 197 |
-
setTimeout(() => {
|
| 198 |
-
if (parentId) get().recalculateSlots(parentId);
|
| 199 |
-
get().recomputeRegistry();
|
| 200 |
-
}, 0);
|
| 201 |
return {
|
| 202 |
-
visualElements:
|
| 203 |
selectedElementId: state.selectedElementId === id ? null : state.selectedElementId,
|
| 204 |
};
|
| 205 |
}),
|
| 206 |
-
|
| 207 |
setFileTree: (files) => set({ fileTree: files || [] }),
|
| 208 |
-
|
| 209 |
addFile: (file, parentId) =>
|
| 210 |
set((state) => {
|
| 211 |
const files = state.fileTree || [];
|
| 212 |
if (!parentId) return { fileTree: [...files, file] };
|
| 213 |
-
const addToFolder = (
|
| 214 |
-
(
|
| 215 |
if (f.id === parentId && f.type === 'folder') {
|
| 216 |
return { ...f, children: [...(f.children || []), file] };
|
| 217 |
}
|
|
@@ -220,12 +138,11 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
|
|
| 220 |
});
|
| 221 |
return { fileTree: addToFolder(files) };
|
| 222 |
}),
|
| 223 |
-
|
| 224 |
removeFile: (id) =>
|
| 225 |
set((state) => {
|
| 226 |
const files = state.fileTree || [];
|
| 227 |
-
const removeRecursive = (
|
| 228 |
-
(
|
| 229 |
if (f.id === id) return false;
|
| 230 |
if (f.children) f.children = removeRecursive(f.children || []);
|
| 231 |
return true;
|
|
@@ -235,17 +152,20 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
|
|
| 235 |
activeFileId: state.activeFileId === id ? null : state.activeFileId,
|
| 236 |
};
|
| 237 |
}),
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
setBlocksXml: (fileId: string, xml: string) =>
|
| 242 |
-
set((state) => ({
|
| 243 |
-
|
|
|
|
| 244 |
setBlockCode: (fileId: string, code: string) =>
|
| 245 |
-
set((state) => ({
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
| 249 |
saveGeneratedCodeToFile: (fileId: string, code: string) =>
|
| 250 |
set((state) => {
|
| 251 |
const files = state.fileTree || [];
|
|
@@ -260,105 +180,26 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
|
|
| 260 |
activeFileContent: fileId === state.activeFileId ? code : state.activeFileContent,
|
| 261 |
};
|
| 262 |
}),
|
| 263 |
-
|
| 264 |
updateFile: (id, updates) =>
|
| 265 |
set((state) => {
|
| 266 |
const files = state.fileTree || [];
|
| 267 |
-
const updateRecursive = (
|
| 268 |
-
(
|
| 269 |
if (f.id === id) return { ...f, ...updates };
|
| 270 |
if (f.children) return { ...f, children: updateRecursive(f.children) };
|
| 271 |
return f;
|
| 272 |
});
|
| 273 |
return { fileTree: updateRecursive(files) };
|
| 274 |
}),
|
|
|
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
},
|
| 283 |
-
|
| 284 |
-
resetCounter: (tag) =>
|
| 285 |
-
set((state) => {
|
| 286 |
-
const { [tag]: _, ...rest } = state.elementCounters;
|
| 287 |
-
return { elementCounters: rest };
|
| 288 |
-
}),
|
| 289 |
-
|
| 290 |
-
recomputeRegistry: () => {
|
| 291 |
-
const state = get();
|
| 292 |
-
const registry: ElementRef[] = [];
|
| 293 |
-
|
| 294 |
-
const walkElements = (els: VisualElement[], fileId: string) => {
|
| 295 |
-
(els || []).forEach((el) => {
|
| 296 |
-
registry.push({
|
| 297 |
-
id: el.props?.id || el.attributes?.id || el.id,
|
| 298 |
-
tagName: el.tagName,
|
| 299 |
-
classes: el.classes || [],
|
| 300 |
-
attributes: el.attributes || {},
|
| 301 |
-
fileId,
|
| 302 |
-
linkedFiles: getLinkedFiles(fileId, state.fileTree),
|
| 303 |
-
});
|
| 304 |
-
if (el.children) walkElements(el.children, fileId);
|
| 305 |
-
});
|
| 306 |
-
};
|
| 307 |
-
|
| 308 |
-
if (state.activeFileId) {
|
| 309 |
-
walkElements(state.visualElements, state.activeFileId);
|
| 310 |
}
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
recalculateSlots: (elementId) => {
|
| 316 |
-
const state = get();
|
| 317 |
-
const updateRecursive = (elements: VisualElement[]): VisualElement[] =>
|
| 318 |
-
(elements || []).map((el) => {
|
| 319 |
-
if (el.id === elementId) {
|
| 320 |
-
const childCount = (el.children || []).length;
|
| 321 |
-
const userSlotCount = state.slotCounts[elementId];
|
| 322 |
-
const targetSlotCount = Math.max(
|
| 323 |
-
userSlotCount !== undefined ? Math.max(userSlotCount, childCount) : childCount + 1,
|
| 324 |
-
1
|
| 325 |
-
);
|
| 326 |
-
const slots: OutlineSlot[] = [];
|
| 327 |
-
for (let i = 0; i < targetSlotCount; i++) {
|
| 328 |
-
const occupied = (el.children || [])[i];
|
| 329 |
-
slots.push({
|
| 330 |
-
id: `slot_${el.id}_${i}`,
|
| 331 |
-
parentId: elementId,
|
| 332 |
-
index: i,
|
| 333 |
-
occupiedBy: occupied?.id || null,
|
| 334 |
-
});
|
| 335 |
-
}
|
| 336 |
-
return { ...el, slots };
|
| 337 |
-
}
|
| 338 |
-
if (el.children) return { ...el, children: updateRecursive(el.children) };
|
| 339 |
-
return el;
|
| 340 |
-
});
|
| 341 |
-
set((state) => ({
|
| 342 |
-
visualElements: updateRecursive(state.visualElements),
|
| 343 |
-
}));
|
| 344 |
-
},
|
| 345 |
-
|
| 346 |
-
setSlotCount: (elementId, count) => {
|
| 347 |
-
const state = get();
|
| 348 |
-
const childCount = Math.max(0, getChildCount(state.visualElements, elementId));
|
| 349 |
-
const clampedCount = Math.max(count, childCount, 1);
|
| 350 |
-
set((s) => ({
|
| 351 |
-
slotCounts: { ...s.slotCounts, [elementId]: clampedCount },
|
| 352 |
-
}));
|
| 353 |
-
get().recalculateSlots(elementId);
|
| 354 |
-
},
|
| 355 |
-
|
| 356 |
-
setCustomId: (elementId, isCustom) => {
|
| 357 |
-
set((state) => {
|
| 358 |
-
const newSet = new Set(state.customIds);
|
| 359 |
-
if (isCustom) newSet.add(elementId);
|
| 360 |
-
else newSet.delete(elementId);
|
| 361 |
-
return { customIds: newSet };
|
| 362 |
-
});
|
| 363 |
-
},
|
| 364 |
-
}));
|
|
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
+
import { ProjectFile, VisualElement } from '../types/blocks';
|
| 3 |
|
| 4 |
type EditorMode = 'blocks' | 'visual' | 'code' | 'files' | 'preview';
|
| 5 |
type ViewMode = 'design' | 'preview' | 'split';
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
interface EditorStore {
|
| 8 |
activeFileId: string | null;
|
| 9 |
activeFileContent: string;
|
|
|
|
| 18 |
fileTree: ProjectFile[];
|
| 19 |
blocksXml: Record<string, string>;
|
| 20 |
blockCode: Record<string, string>;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
setActiveFile: (fileId: string | null) => void;
|
| 23 |
setActiveFileContent: (content: string) => void;
|
|
|
|
| 29 |
toggleSnapToGrid: () => void;
|
| 30 |
toggleMiniMap: () => void;
|
| 31 |
setVisualElements: (elements: VisualElement[]) => void;
|
| 32 |
+
addVisualElement: (element: VisualElement, parentId?: string) => void;
|
| 33 |
updateVisualElement: (id: string, updates: Partial<VisualElement>) => void;
|
| 34 |
removeVisualElement: (id: string) => void;
|
| 35 |
setFileTree: (files: ProjectFile[]) => void;
|
|
|
|
| 41 |
setBlockCode: (fileId: string, code: string) => void;
|
| 42 |
getBlockCode: (fileId: string) => string;
|
| 43 |
saveGeneratedCodeToFile: (fileId: string, code: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
export const useEditorStore = create<EditorStore>((set, get) => ({
|
|
|
|
| 57 |
fileTree: [],
|
| 58 |
blocksXml: {},
|
| 59 |
blockCode: {},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
setActiveFile: (fileId) => {
|
| 62 |
const state = get();
|
|
|
|
| 79 |
setZoom: (zoom) => set({ zoom: Math.max(0.25, Math.min(2, zoom)) }),
|
| 80 |
toggleSnapToGrid: () => set((s) => ({ snapToGrid: !s.snapToGrid })),
|
| 81 |
toggleMiniMap: () => set((s) => ({ showMiniMap: !s.showMiniMap })),
|
|
|
|
| 82 |
setVisualElements: (elements) => set({ visualElements: elements || [] }),
|
| 83 |
+
addVisualElement: (element, parentId) =>
|
|
|
|
| 84 |
set((state) => {
|
| 85 |
const elements = state.visualElements || [];
|
|
|
|
| 86 |
if (!parentId) {
|
| 87 |
+
return { visualElements: [...elements, element] };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
+
const addToParent = (elements: VisualElement[]): VisualElement[] =>
|
| 90 |
+
(elements || []).map((el) => {
|
| 91 |
+
if (el.id === parentId) {
|
| 92 |
+
return { ...el, children: [...(el.children || []), element] };
|
| 93 |
+
}
|
| 94 |
+
if (el.children) {
|
| 95 |
+
return { ...el, children: addToParent(el.children) };
|
| 96 |
+
}
|
| 97 |
+
return el;
|
| 98 |
+
});
|
| 99 |
+
return { visualElements: addToParent(elements) };
|
| 100 |
}),
|
|
|
|
| 101 |
updateVisualElement: (id, updates) =>
|
| 102 |
set((state) => {
|
| 103 |
const elements = state.visualElements || [];
|
| 104 |
+
const updateRecursive = (elements: VisualElement[]): VisualElement[] =>
|
| 105 |
+
(elements || []).map((el) => {
|
| 106 |
if (el.id === id) return { ...el, ...updates };
|
| 107 |
if (el.children) return { ...el, children: updateRecursive(el.children) };
|
| 108 |
return el;
|
| 109 |
});
|
| 110 |
return { visualElements: updateRecursive(elements) };
|
| 111 |
}),
|
|
|
|
| 112 |
removeVisualElement: (id) =>
|
| 113 |
set((state) => {
|
| 114 |
const elements = state.visualElements || [];
|
| 115 |
+
const removeRecursive = (elements: VisualElement[]): VisualElement[] =>
|
| 116 |
+
(elements || []).filter((el) => {
|
| 117 |
+
if (el.id === id) return false;
|
| 118 |
+
if (el.children) el.children = removeRecursive(el.children || []);
|
|
|
|
| 119 |
return true;
|
| 120 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
return {
|
| 122 |
+
visualElements: removeRecursive(elements),
|
| 123 |
selectedElementId: state.selectedElementId === id ? null : state.selectedElementId,
|
| 124 |
};
|
| 125 |
}),
|
|
|
|
| 126 |
setFileTree: (files) => set({ fileTree: files || [] }),
|
|
|
|
| 127 |
addFile: (file, parentId) =>
|
| 128 |
set((state) => {
|
| 129 |
const files = state.fileTree || [];
|
| 130 |
if (!parentId) return { fileTree: [...files, file] };
|
| 131 |
+
const addToFolder = (files: ProjectFile[]): ProjectFile[] =>
|
| 132 |
+
(files || []).map((f) => {
|
| 133 |
if (f.id === parentId && f.type === 'folder') {
|
| 134 |
return { ...f, children: [...(f.children || []), file] };
|
| 135 |
}
|
|
|
|
| 138 |
});
|
| 139 |
return { fileTree: addToFolder(files) };
|
| 140 |
}),
|
|
|
|
| 141 |
removeFile: (id) =>
|
| 142 |
set((state) => {
|
| 143 |
const files = state.fileTree || [];
|
| 144 |
+
const removeRecursive = (files: ProjectFile[]): ProjectFile[] =>
|
| 145 |
+
(files || []).filter((f) => {
|
| 146 |
if (f.id === id) return false;
|
| 147 |
if (f.children) f.children = removeRecursive(f.children || []);
|
| 148 |
return true;
|
|
|
|
| 152 |
activeFileId: state.activeFileId === id ? null : state.activeFileId,
|
| 153 |
};
|
| 154 |
}),
|
| 155 |
+
getBlocksXml: (fileId: string) => {
|
| 156 |
+
return get().blocksXml[fileId] || '';
|
| 157 |
+
},
|
| 158 |
setBlocksXml: (fileId: string, xml: string) =>
|
| 159 |
+
set((state) => ({
|
| 160 |
+
blocksXml: { ...state.blocksXml, [fileId]: xml },
|
| 161 |
+
})),
|
| 162 |
setBlockCode: (fileId: string, code: string) =>
|
| 163 |
+
set((state) => ({
|
| 164 |
+
blockCode: { ...state.blockCode, [fileId]: code },
|
| 165 |
+
})),
|
| 166 |
+
getBlockCode: (fileId: string) => {
|
| 167 |
+
return get().blockCode[fileId] || '';
|
| 168 |
+
},
|
| 169 |
saveGeneratedCodeToFile: (fileId: string, code: string) =>
|
| 170 |
set((state) => {
|
| 171 |
const files = state.fileTree || [];
|
|
|
|
| 180 |
activeFileContent: fileId === state.activeFileId ? code : state.activeFileContent,
|
| 181 |
};
|
| 182 |
}),
|
|
|
|
| 183 |
updateFile: (id, updates) =>
|
| 184 |
set((state) => {
|
| 185 |
const files = state.fileTree || [];
|
| 186 |
+
const updateRecursive = (files: ProjectFile[]): ProjectFile[] =>
|
| 187 |
+
(files || []).map((f) => {
|
| 188 |
if (f.id === id) return { ...f, ...updates };
|
| 189 |
if (f.children) return { ...f, children: updateRecursive(f.children) };
|
| 190 |
return f;
|
| 191 |
});
|
| 192 |
return { fileTree: updateRecursive(files) };
|
| 193 |
}),
|
| 194 |
+
}));
|
| 195 |
|
| 196 |
+
function findFileById(files: ProjectFile[], id: string): ProjectFile | null {
|
| 197 |
+
for (const f of files) {
|
| 198 |
+
if (f.id === id) return f;
|
| 199 |
+
if (f.children) {
|
| 200 |
+
const found = findFileById(f.children, id);
|
| 201 |
+
if (found) return found;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
}
|
| 203 |
+
}
|
| 204 |
+
return null;
|
| 205 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client/src/store/projectStore.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { api } from '../api/client';
|
| 3 |
import { Project, FrameworkType } from '../types';
|
| 4 |
-
import { getSocket } from '../hooks/useWebSocket';
|
| 5 |
|
| 6 |
interface ProjectStore {
|
| 7 |
projects: Project[];
|
|
@@ -17,20 +16,6 @@ interface ProjectStore {
|
|
| 17 |
syncProject: (id: string) => Promise<void>;
|
| 18 |
}
|
| 19 |
|
| 20 |
-
function wsCall<T>(event: string, data: any): Promise<T> {
|
| 21 |
-
return new Promise((resolve, reject) => {
|
| 22 |
-
const socket = getSocket();
|
| 23 |
-
if (socket?.connected) {
|
| 24 |
-
socket.emit(event, data, (response: any) => {
|
| 25 |
-
if (response?.error) reject(new Error(response.error));
|
| 26 |
-
else resolve(response);
|
| 27 |
-
});
|
| 28 |
-
} else {
|
| 29 |
-
reject(new Error('WebSocket not connected'));
|
| 30 |
-
}
|
| 31 |
-
});
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
| 35 |
projects: [],
|
| 36 |
currentProject: null,
|
|
@@ -40,14 +25,8 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
| 40 |
fetchProjects: async () => {
|
| 41 |
set({ loading: true, error: null });
|
| 42 |
try {
|
| 43 |
-
const
|
| 44 |
-
|
| 45 |
-
const { projects } = await wsCall<any>('project_list', {});
|
| 46 |
-
set({ projects, loading: false });
|
| 47 |
-
} else {
|
| 48 |
-
const { projects } = await api.getProjects();
|
| 49 |
-
set({ projects, loading: false });
|
| 50 |
-
}
|
| 51 |
} catch (err: any) {
|
| 52 |
set({ error: err.message, loading: false });
|
| 53 |
}
|
|
@@ -56,14 +35,8 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
| 56 |
loadProject: async (id: string) => {
|
| 57 |
set({ loading: true, error: null });
|
| 58 |
try {
|
| 59 |
-
const
|
| 60 |
-
|
| 61 |
-
const { project } = await wsCall<any>('project_get', { projectId: id });
|
| 62 |
-
set({ currentProject: project, loading: false });
|
| 63 |
-
} else {
|
| 64 |
-
const { project } = await api.getProject(id);
|
| 65 |
-
set({ currentProject: project, loading: false });
|
| 66 |
-
}
|
| 67 |
} catch (err: any) {
|
| 68 |
set({ error: err.message, loading: false });
|
| 69 |
}
|
|
@@ -72,15 +45,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
| 72 |
createProject: async (name: string, framework: FrameworkType, description?: string) => {
|
| 73 |
set({ loading: true, error: null });
|
| 74 |
try {
|
| 75 |
-
const
|
| 76 |
-
let project: Project;
|
| 77 |
-
if (socket?.connected) {
|
| 78 |
-
const result = await wsCall<any>('project_create', { name, framework, description });
|
| 79 |
-
project = result.project;
|
| 80 |
-
} else {
|
| 81 |
-
const result = await api.createProject(name, framework, description);
|
| 82 |
-
project = result.project;
|
| 83 |
-
}
|
| 84 |
set((state) => ({ projects: [project, ...state.projects], loading: false }));
|
| 85 |
return project;
|
| 86 |
} catch (err: any) {
|
|
@@ -91,12 +56,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
| 91 |
|
| 92 |
updateProject: async (id: string, updates: any) => {
|
| 93 |
try {
|
| 94 |
-
|
| 95 |
-
if (socket?.connected) {
|
| 96 |
-
await wsCall<any>('project_update', { projectId: id, ...updates });
|
| 97 |
-
} else {
|
| 98 |
-
await api.updateProject(id, updates);
|
| 99 |
-
}
|
| 100 |
set((state) => ({
|
| 101 |
currentProject: state.currentProject?.id === id
|
| 102 |
? { ...state.currentProject, ...updates }
|
|
@@ -109,12 +69,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
| 109 |
|
| 110 |
deleteProject: async (id: string) => {
|
| 111 |
try {
|
| 112 |
-
|
| 113 |
-
if (socket?.connected) {
|
| 114 |
-
await wsCall<any>('project_delete', { projectId: id });
|
| 115 |
-
} else {
|
| 116 |
-
await api.deleteProject(id);
|
| 117 |
-
}
|
| 118 |
set((state) => ({
|
| 119 |
projects: state.projects.filter((p) => p.id !== id),
|
| 120 |
currentProject: state.currentProject?.id === id ? null : state.currentProject,
|
|
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { api } from '../api/client';
|
| 3 |
import { Project, FrameworkType } from '../types';
|
|
|
|
| 4 |
|
| 5 |
interface ProjectStore {
|
| 6 |
projects: Project[];
|
|
|
|
| 16 |
syncProject: (id: string) => Promise<void>;
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
| 20 |
projects: [],
|
| 21 |
currentProject: null,
|
|
|
|
| 25 |
fetchProjects: async () => {
|
| 26 |
set({ loading: true, error: null });
|
| 27 |
try {
|
| 28 |
+
const { projects } = await api.getProjects();
|
| 29 |
+
set({ projects, loading: false });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
} catch (err: any) {
|
| 31 |
set({ error: err.message, loading: false });
|
| 32 |
}
|
|
|
|
| 35 |
loadProject: async (id: string) => {
|
| 36 |
set({ loading: true, error: null });
|
| 37 |
try {
|
| 38 |
+
const { project } = await api.getProject(id);
|
| 39 |
+
set({ currentProject: project, loading: false });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
} catch (err: any) {
|
| 41 |
set({ error: err.message, loading: false });
|
| 42 |
}
|
|
|
|
| 45 |
createProject: async (name: string, framework: FrameworkType, description?: string) => {
|
| 46 |
set({ loading: true, error: null });
|
| 47 |
try {
|
| 48 |
+
const { project } = await api.createProject(name, framework, description);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
set((state) => ({ projects: [project, ...state.projects], loading: false }));
|
| 50 |
return project;
|
| 51 |
} catch (err: any) {
|
|
|
|
| 56 |
|
| 57 |
updateProject: async (id: string, updates: any) => {
|
| 58 |
try {
|
| 59 |
+
await api.updateProject(id, updates);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
set((state) => ({
|
| 61 |
currentProject: state.currentProject?.id === id
|
| 62 |
? { ...state.currentProject, ...updates }
|
|
|
|
| 69 |
|
| 70 |
deleteProject: async (id: string) => {
|
| 71 |
try {
|
| 72 |
+
await api.deleteProject(id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
set((state) => ({
|
| 74 |
projects: state.projects.filter((p) => p.id !== id),
|
| 75 |
currentProject: state.currentProject?.id === id ? null : state.currentProject,
|
client/src/types/blocks.ts
CHANGED
|
@@ -15,8 +15,6 @@ export interface BlockDefinition {
|
|
| 15 |
config: BlockConfigField[];
|
| 16 |
color: string;
|
| 17 |
icon: string;
|
| 18 |
-
dangerous?: boolean;
|
| 19 |
-
blockVariables?: { name: string; type: string }[];
|
| 20 |
compile: (config: Record<string, any>, inputs: Record<string, string>) => string;
|
| 21 |
}
|
| 22 |
|
|
@@ -37,7 +35,6 @@ export interface BlockConfigField {
|
|
| 37 |
placeholder?: string;
|
| 38 |
min?: number;
|
| 39 |
max?: number;
|
| 40 |
-
allowVariable?: boolean;
|
| 41 |
}
|
| 42 |
|
| 43 |
export interface BlockNode {
|
|
@@ -57,14 +54,6 @@ export interface VisualElement {
|
|
| 57 |
styles: Record<string, string>;
|
| 58 |
classes: string[];
|
| 59 |
attributes: Record<string, string>;
|
| 60 |
-
slots?: OutlineSlot[];
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
export interface OutlineSlot {
|
| 64 |
-
id: string;
|
| 65 |
-
parentId: string;
|
| 66 |
-
index: number;
|
| 67 |
-
occupiedBy: string | null;
|
| 68 |
}
|
| 69 |
|
| 70 |
export type { ProjectFile, BlockCategory };
|
|
|
|
| 15 |
config: BlockConfigField[];
|
| 16 |
color: string;
|
| 17 |
icon: string;
|
|
|
|
|
|
|
| 18 |
compile: (config: Record<string, any>, inputs: Record<string, string>) => string;
|
| 19 |
}
|
| 20 |
|
|
|
|
| 35 |
placeholder?: string;
|
| 36 |
min?: number;
|
| 37 |
max?: number;
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
export interface BlockNode {
|
|
|
|
| 54 |
styles: Record<string, string>;
|
| 55 |
classes: string[];
|
| 56 |
attributes: Record<string, string>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
export type { ProjectFile, BlockCategory };
|
client/src/utils/formatCode.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
import prettier from 'prettier';
|
| 2 |
-
|
| 3 |
-
export type CodeLanguage = 'html' | 'css' | 'javascript' | 'typescript' | 'json' | 'markdown';
|
| 4 |
-
|
| 5 |
-
const parserMap: Record<CodeLanguage, string> = {
|
| 6 |
-
html: 'html',
|
| 7 |
-
css: 'css',
|
| 8 |
-
javascript: 'babel',
|
| 9 |
-
typescript: 'typescript',
|
| 10 |
-
json: 'json',
|
| 11 |
-
markdown: 'markdown',
|
| 12 |
-
};
|
| 13 |
-
|
| 14 |
-
export async function formatCode(code: string, language: CodeLanguage): Promise<string> {
|
| 15 |
-
try {
|
| 16 |
-
const parser = parserMap[language] || 'html';
|
| 17 |
-
const formatted = await prettier.format(code, {
|
| 18 |
-
parser,
|
| 19 |
-
htmlWhitespaceSensitivity: 'ignore',
|
| 20 |
-
singleQuote: true,
|
| 21 |
-
tabWidth: 2,
|
| 22 |
-
trailingComma: 'es5',
|
| 23 |
-
printWidth: 100,
|
| 24 |
-
});
|
| 25 |
-
return formatted;
|
| 26 |
-
} catch {
|
| 27 |
-
return code;
|
| 28 |
-
}
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
export function getFileLanguage(fileType: string): CodeLanguage {
|
| 32 |
-
switch (fileType) {
|
| 33 |
-
case 'html': return 'html';
|
| 34 |
-
case 'css': return 'css';
|
| 35 |
-
case 'js': return 'javascript';
|
| 36 |
-
case 'ts':
|
| 37 |
-
case 'typescript': return 'typescript';
|
| 38 |
-
case 'json': return 'json';
|
| 39 |
-
case 'md': return 'markdown';
|
| 40 |
-
default: return 'html';
|
| 41 |
-
}
|
| 42 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client/tsconfig.json
CHANGED
|
@@ -16,8 +16,7 @@
|
|
| 16 |
"noUnusedParameters": false,
|
| 17 |
"noFallthroughCasesInSwitch": true,
|
| 18 |
"paths": {
|
| 19 |
-
"@/*": ["./src/*"]
|
| 20 |
-
"@shared/*": ["../shared/*"]
|
| 21 |
},
|
| 22 |
"baseUrl": "."
|
| 23 |
},
|
|
|
|
| 16 |
"noUnusedParameters": false,
|
| 17 |
"noFallthroughCasesInSwitch": true,
|
| 18 |
"paths": {
|
| 19 |
+
"@/*": ["./src/*"]
|
|
|
|
| 20 |
},
|
| 21 |
"baseUrl": "."
|
| 22 |
},
|
client/vite.config.ts
CHANGED
|
@@ -7,7 +7,6 @@ export default defineConfig({
|
|
| 7 |
resolve: {
|
| 8 |
alias: {
|
| 9 |
'@': path.resolve(__dirname, './src'),
|
| 10 |
-
'@shared': path.resolve(__dirname, '../shared'),
|
| 11 |
},
|
| 12 |
},
|
| 13 |
server: {
|
|
|
|
| 7 |
resolve: {
|
| 8 |
alias: {
|
| 9 |
'@': path.resolve(__dirname, './src'),
|
|
|
|
| 10 |
},
|
| 11 |
},
|
| 12 |
server: {
|
server/src/services/realtime.ts
CHANGED
|
@@ -4,10 +4,6 @@ import jwt from 'jsonwebtoken';
|
|
| 4 |
import { config } from '../config';
|
| 5 |
import { getDatabase } from '../database';
|
| 6 |
import { encryptCombined } from './encryption';
|
| 7 |
-
import {
|
| 8 |
-
listProjects, getProject, createProject, updateProjectData,
|
| 9 |
-
updateProjectMeta, deleteProject, checkPermission
|
| 10 |
-
} from './wsStore';
|
| 11 |
|
| 12 |
interface AuthPayload {
|
| 13 |
userId: string;
|
|
@@ -24,16 +20,12 @@ interface CollaborationSession {
|
|
| 24 |
cursor: { line: number; ch: number } | null;
|
| 25 |
selectedBlockId: string | null;
|
| 26 |
selectedElementId: string | null;
|
| 27 |
-
pointerPosition: { x: number; y: number } | null;
|
| 28 |
-
draggingBlock: { blockId: string; x: number; y: number } | null;
|
| 29 |
-
textFieldCursor: { fieldId: string; position: number } | null;
|
| 30 |
-
typing: boolean;
|
| 31 |
joinedAt: number;
|
| 32 |
permission: 'view' | 'edit' | 'admin';
|
| 33 |
}
|
| 34 |
|
| 35 |
-
const sessions = new Map<string, CollaborationSession>();
|
| 36 |
-
const projectRooms = new Map<string, Map<string, CollaborationSession[]>>();
|
| 37 |
|
| 38 |
let io: Server;
|
| 39 |
|
|
@@ -46,12 +38,14 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 46 |
},
|
| 47 |
pingInterval: 25000,
|
| 48 |
pingTimeout: 20000,
|
| 49 |
-
maxHttpBufferSize: 5e7, // 50MB for file uploads
|
| 50 |
});
|
| 51 |
|
|
|
|
| 52 |
io.use((socket, next) => {
|
| 53 |
const token = socket.handshake.auth?.token || socket.handshake.query?.token;
|
| 54 |
-
if (!token)
|
|
|
|
|
|
|
| 55 |
try {
|
| 56 |
const decoded = jwt.verify(token as string, config.jwtSecret) as { userId: string; email: string };
|
| 57 |
const db = getDatabase();
|
|
@@ -70,6 +64,8 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 70 |
// ---- PROJECT ROOM JOINING ----
|
| 71 |
socket.on('join_project', ({ projectId }: { projectId: string }) => {
|
| 72 |
if (!projectId) return;
|
|
|
|
|
|
|
| 73 |
const db = getDatabase();
|
| 74 |
const project = db.prepare('SELECT user_id FROM projects WHERE id = ?').get(projectId) as any;
|
| 75 |
if (!project) return;
|
|
@@ -81,12 +77,13 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 81 |
const collab = db.prepare(
|
| 82 |
'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
|
| 83 |
).get(projectId, user.userId) as any;
|
| 84 |
-
if (!collab) return;
|
| 85 |
permission = collab.permission;
|
| 86 |
}
|
| 87 |
|
| 88 |
socket.join(`project:${projectId}`);
|
| 89 |
|
|
|
|
| 90 |
const session: CollaborationSession = {
|
| 91 |
userId: user.userId,
|
| 92 |
username: user.username,
|
|
@@ -96,10 +93,6 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 96 |
cursor: null,
|
| 97 |
selectedBlockId: null,
|
| 98 |
selectedElementId: null,
|
| 99 |
-
pointerPosition: null,
|
| 100 |
-
draggingBlock: null,
|
| 101 |
-
textFieldCursor: null,
|
| 102 |
-
typing: false,
|
| 103 |
joinedAt: Date.now(),
|
| 104 |
permission,
|
| 105 |
};
|
|
@@ -114,6 +107,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 114 |
}
|
| 115 |
room.get(user.userId)!.push(session);
|
| 116 |
|
|
|
|
| 117 |
socket.to(`project:${projectId}`).emit('collaborator_joined', {
|
| 118 |
userId: user.userId,
|
| 119 |
username: user.username,
|
|
@@ -121,9 +115,10 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 121 |
joinedAt: session.joinedAt,
|
| 122 |
});
|
| 123 |
|
|
|
|
| 124 |
const collaboratorList: any[] = [];
|
| 125 |
-
for (const [uid,
|
| 126 |
-
for (const s of
|
| 127 |
collaboratorList.push({
|
| 128 |
userId: uid,
|
| 129 |
username: s.username,
|
|
@@ -132,10 +127,6 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 132 |
cursor: s.cursor,
|
| 133 |
selectedBlockId: s.selectedBlockId,
|
| 134 |
selectedElementId: s.selectedElementId,
|
| 135 |
-
pointerPosition: s.pointerPosition,
|
| 136 |
-
draggingBlock: s.draggingBlock,
|
| 137 |
-
textFieldCursor: s.textFieldCursor,
|
| 138 |
-
typing: s.typing,
|
| 139 |
joinedAt: s.joinedAt,
|
| 140 |
});
|
| 141 |
}
|
|
@@ -143,6 +134,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 143 |
socket.emit('collaborator_list', { collaborators: collaboratorList });
|
| 144 |
});
|
| 145 |
|
|
|
|
| 146 |
socket.on('leave_project', ({ projectId }: { projectId: string }) => {
|
| 147 |
leaveProject(socket, projectId);
|
| 148 |
});
|
|
@@ -159,9 +151,12 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 159 |
});
|
| 160 |
});
|
| 161 |
|
|
|
|
| 162 |
socket.on('active_file_changed', (data: { projectId: string; fileId: string | null }) => {
|
| 163 |
const session = sessions.get(socket.id);
|
| 164 |
-
if (session)
|
|
|
|
|
|
|
| 165 |
socket.to(`project:${data.projectId}`).emit('collaborator_active_file', {
|
| 166 |
userId: user.userId,
|
| 167 |
username: user.username,
|
|
@@ -169,6 +164,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 169 |
});
|
| 170 |
});
|
| 171 |
|
|
|
|
| 172 |
socket.on('cursor_move', (data: { projectId: string; fileId: string; cursor: { line: number; ch: number } }) => {
|
| 173 |
const session = sessions.get(socket.id);
|
| 174 |
if (session) {
|
|
@@ -183,9 +179,12 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 183 |
});
|
| 184 |
});
|
| 185 |
|
|
|
|
| 186 |
socket.on('block_selection_changed', (data: { projectId: string; blockId: string | null }) => {
|
| 187 |
const session = sessions.get(socket.id);
|
| 188 |
-
if (session)
|
|
|
|
|
|
|
| 189 |
socket.to(`project:${data.projectId}`).emit('collaborator_block_selected', {
|
| 190 |
userId: user.userId,
|
| 191 |
username: user.username,
|
|
@@ -193,9 +192,12 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 193 |
});
|
| 194 |
});
|
| 195 |
|
|
|
|
| 196 |
socket.on('visual_element_hover', (data: { projectId: string; elementId: string | null }) => {
|
| 197 |
const session = sessions.get(socket.id);
|
| 198 |
-
if (session)
|
|
|
|
|
|
|
| 199 |
socket.to(`project:${data.projectId}`).emit('collaborator_element_hover', {
|
| 200 |
userId: user.userId,
|
| 201 |
username: user.username,
|
|
@@ -203,48 +205,59 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 203 |
});
|
| 204 |
});
|
| 205 |
|
| 206 |
-
// ---- FILE
|
| 207 |
socket.on('file_added', (data: { projectId: string; file: any; parentId?: string }) => {
|
| 208 |
const session = sessions.get(socket.id);
|
| 209 |
if (!session || session.permission === 'view') return;
|
| 210 |
socket.to(`project:${data.projectId}`).emit('file_added', {
|
| 211 |
-
file: data.file,
|
|
|
|
|
|
|
| 212 |
});
|
| 213 |
});
|
| 214 |
|
|
|
|
| 215 |
socket.on('file_renamed', (data: { projectId: string; fileId: string; name: string }) => {
|
| 216 |
const session = sessions.get(socket.id);
|
| 217 |
if (!session || session.permission === 'view') return;
|
| 218 |
socket.to(`project:${data.projectId}`).emit('file_renamed', {
|
| 219 |
-
fileId: data.fileId,
|
|
|
|
|
|
|
| 220 |
});
|
| 221 |
});
|
| 222 |
|
|
|
|
| 223 |
socket.on('file_deleted', (data: { projectId: string; fileId: string }) => {
|
| 224 |
const session = sessions.get(socket.id);
|
| 225 |
if (!session || session.permission === 'view') return;
|
| 226 |
socket.to(`project:${data.projectId}`).emit('file_deleted', {
|
| 227 |
-
fileId: data.fileId,
|
|
|
|
| 228 |
});
|
| 229 |
});
|
| 230 |
|
| 231 |
-
// ---- VISUAL ELEMENT
|
| 232 |
socket.on('visual_element_added', (data: { projectId: string; element: any; parentId?: string }) => {
|
| 233 |
const session = sessions.get(socket.id);
|
| 234 |
if (!session || session.permission === 'view') return;
|
| 235 |
socket.to(`project:${data.projectId}`).emit('visual_element_added', {
|
| 236 |
-
element: data.element,
|
|
|
|
| 237 |
});
|
| 238 |
});
|
| 239 |
|
|
|
|
| 240 |
socket.on('visual_element_updated', (data: { projectId: string; elementId: string; updates: any }) => {
|
| 241 |
const session = sessions.get(socket.id);
|
| 242 |
if (!session || session.permission === 'view') return;
|
| 243 |
socket.to(`project:${data.projectId}`).emit('visual_element_updated', {
|
| 244 |
-
elementId: data.elementId,
|
|
|
|
| 245 |
});
|
| 246 |
});
|
| 247 |
|
|
|
|
| 248 |
socket.on('visual_element_removed', (data: { projectId: string; elementId: string }) => {
|
| 249 |
const session = sessions.get(socket.id);
|
| 250 |
if (!session || session.permission === 'view') return;
|
|
@@ -253,6 +266,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 253 |
});
|
| 254 |
});
|
| 255 |
|
|
|
|
| 256 |
socket.on('visual_elements_reordered', (data: { projectId: string; elements: any[] }) => {
|
| 257 |
const session = sessions.get(socket.id);
|
| 258 |
if (!session || session.permission === 'view') return;
|
|
@@ -261,124 +275,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 261 |
});
|
| 262 |
});
|
| 263 |
|
| 264 |
-
// ----
|
| 265 |
-
socket.on('pointer_move', (data: { projectId: string; x: number; y: number }) => {
|
| 266 |
-
const session = sessions.get(socket.id);
|
| 267 |
-
if (session) session.pointerPosition = { x: data.x, y: data.y };
|
| 268 |
-
socket.to(`project:${data.projectId}`).emit('pointer_move', {
|
| 269 |
-
userId: user.userId, x: data.x, y: data.y,
|
| 270 |
-
});
|
| 271 |
-
});
|
| 272 |
-
|
| 273 |
-
// ---- NEW: BLOCK DRAG BROADCAST ----
|
| 274 |
-
socket.on('block_drag_start', (data: { projectId: string; blockId: string; x: number; y: number }) => {
|
| 275 |
-
const session = sessions.get(socket.id);
|
| 276 |
-
if (session) session.draggingBlock = { blockId: data.blockId, x: data.x, y: data.y };
|
| 277 |
-
socket.to(`project:${data.projectId}`).emit('block_drag_start', {
|
| 278 |
-
userId: user.userId, blockId: data.blockId, x: data.x, y: data.y,
|
| 279 |
-
});
|
| 280 |
-
});
|
| 281 |
-
|
| 282 |
-
socket.on('block_drag_move', (data: { projectId: string; blockId: string; x: number; y: number }) => {
|
| 283 |
-
const session = sessions.get(socket.id);
|
| 284 |
-
if (session) session.draggingBlock = { blockId: data.blockId, x: data.x, y: data.y };
|
| 285 |
-
socket.to(`project:${data.projectId}`).emit('block_drag_move', {
|
| 286 |
-
userId: user.userId, blockId: data.blockId, x: data.x, y: data.y,
|
| 287 |
-
});
|
| 288 |
-
});
|
| 289 |
-
|
| 290 |
-
socket.on('block_drag_end', (data: { projectId: string }) => {
|
| 291 |
-
const session = sessions.get(socket.id);
|
| 292 |
-
if (session) session.draggingBlock = null;
|
| 293 |
-
socket.to(`project:${data.projectId}`).emit('block_drag_end', {
|
| 294 |
-
userId: user.userId,
|
| 295 |
-
});
|
| 296 |
-
});
|
| 297 |
-
|
| 298 |
-
// ---- NEW: TEXT FIELD CURSOR TRACKING ----
|
| 299 |
-
socket.on('text_field_focus', (data: { projectId: string; fieldId: string }) => {
|
| 300 |
-
socket.to(`project:${data.projectId}`).emit('text_field_focus', {
|
| 301 |
-
userId: user.userId, fieldId: data.fieldId,
|
| 302 |
-
});
|
| 303 |
-
});
|
| 304 |
-
|
| 305 |
-
socket.on('text_field_cursor', (data: { projectId: string; fieldId: string; position: number }) => {
|
| 306 |
-
const session = sessions.get(socket.id);
|
| 307 |
-
if (session) session.textFieldCursor = { fieldId: data.fieldId, position: data.position };
|
| 308 |
-
socket.to(`project:${data.projectId}`).emit('text_field_cursor', {
|
| 309 |
-
userId: user.userId, fieldId: data.fieldId, position: data.position,
|
| 310 |
-
});
|
| 311 |
-
});
|
| 312 |
-
|
| 313 |
-
socket.on('text_field_blur', (data: { projectId: string }) => {
|
| 314 |
-
const session = sessions.get(socket.id);
|
| 315 |
-
if (session) session.textFieldCursor = null;
|
| 316 |
-
socket.to(`project:${data.projectId}`).emit('text_field_blur', {
|
| 317 |
-
userId: user.userId,
|
| 318 |
-
});
|
| 319 |
-
});
|
| 320 |
-
|
| 321 |
-
// ---- NEW: PROJECT CRUD VIA WS ----
|
| 322 |
-
socket.on('project_list', async (_data: any, callback?: (response: any) => void) => {
|
| 323 |
-
try {
|
| 324 |
-
const projects = listProjects(user.userId);
|
| 325 |
-
if (callback) callback({ projects });
|
| 326 |
-
} catch (err: any) {
|
| 327 |
-
if (callback) callback({ error: err.message });
|
| 328 |
-
}
|
| 329 |
-
});
|
| 330 |
-
|
| 331 |
-
socket.on('project_create', (data: { name: string; framework: string; description?: string }, callback?: (response: any) => void) => {
|
| 332 |
-
try {
|
| 333 |
-
const project = createProject(user.userId, data.name, data.framework, data.description);
|
| 334 |
-
if (callback) callback({ project });
|
| 335 |
-
} catch (err: any) {
|
| 336 |
-
if (callback) callback({ error: err.message });
|
| 337 |
-
}
|
| 338 |
-
});
|
| 339 |
-
|
| 340 |
-
socket.on('project_get', (data: { projectId: string }, callback?: (response: any) => void) => {
|
| 341 |
-
try {
|
| 342 |
-
const project = getProject(data.projectId, user.userId);
|
| 343 |
-
if (!project) {
|
| 344 |
-
if (callback) callback({ error: 'Project not found' });
|
| 345 |
-
return;
|
| 346 |
-
}
|
| 347 |
-
if (callback) callback({ project });
|
| 348 |
-
} catch (err: any) {
|
| 349 |
-
if (callback) callback({ error: err.message });
|
| 350 |
-
}
|
| 351 |
-
});
|
| 352 |
-
|
| 353 |
-
socket.on('project_update', (data: { projectId: string; name?: string; description?: string; data?: any }, callback?: (response: any) => void) => {
|
| 354 |
-
const permission = checkPermission(data.projectId, user.userId);
|
| 355 |
-
if (!permission || permission === 'view') {
|
| 356 |
-
if (callback) callback({ error: 'Permission denied' });
|
| 357 |
-
return;
|
| 358 |
-
}
|
| 359 |
-
try {
|
| 360 |
-
if (data.data) {
|
| 361 |
-
updateProjectData(data.projectId, data.data);
|
| 362 |
-
}
|
| 363 |
-
if (data.name !== undefined || data.description !== undefined) {
|
| 364 |
-
updateProjectMeta(data.projectId, { name: data.name, description: data.description });
|
| 365 |
-
}
|
| 366 |
-
const updatedAt = Date.now();
|
| 367 |
-
io.to(`project:${data.projectId}`).emit('project_saved', {
|
| 368 |
-
updatedAt, savedBy: user.username,
|
| 369 |
-
});
|
| 370 |
-
if (callback) callback({ success: true, updated_at: updatedAt });
|
| 371 |
-
} catch (err: any) {
|
| 372 |
-
if (callback) callback({ error: err.message });
|
| 373 |
-
}
|
| 374 |
-
});
|
| 375 |
-
|
| 376 |
-
socket.on('project_delete', (data: { projectId: string }, callback?: (response: any) => void) => {
|
| 377 |
-
const ok = deleteProject(data.projectId, user.userId);
|
| 378 |
-
if (callback) callback(ok ? { success: true } : { error: 'Permission denied or not found' });
|
| 379 |
-
});
|
| 380 |
-
|
| 381 |
-
// ---- SAVE PROJECT (legacy, kept for backward compat) ----
|
| 382 |
socket.on('save_project', (data: { projectId: string; data: any }, callback?: (response: any) => void) => {
|
| 383 |
const session = sessions.get(socket.id);
|
| 384 |
if (!session || session.permission === 'view') {
|
|
@@ -388,57 +285,6 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|
| 388 |
saveProjectData(session, data.projectId, data.data, callback);
|
| 389 |
});
|
| 390 |
|
| 391 |
-
// ---- NEW: VERSION STUBS ----
|
| 392 |
-
socket.on('version_save', (data: { projectId: string; message?: string }, callback?: (response: any) => void) => {
|
| 393 |
-
const permission = checkPermission(data.projectId, user.userId);
|
| 394 |
-
if (!permission || permission === 'view') {
|
| 395 |
-
if (callback) callback({ error: 'Permission denied' });
|
| 396 |
-
return;
|
| 397 |
-
}
|
| 398 |
-
if (callback) callback({ success: true, versionId: null, message: 'Version history not yet implemented' });
|
| 399 |
-
});
|
| 400 |
-
|
| 401 |
-
socket.on('version_list', (data: { projectId: string }, callback?: (response: any) => void) => {
|
| 402 |
-
if (callback) callback({ versions: [] });
|
| 403 |
-
});
|
| 404 |
-
|
| 405 |
-
// ---- NEW: CSS EVENT STUBS ----
|
| 406 |
-
socket.on('css_rule_added', (data: { projectId: string; rule: any }) => {
|
| 407 |
-
const session = sessions.get(socket.id);
|
| 408 |
-
if (!session || session.permission === 'view') return;
|
| 409 |
-
socket.to(`project:${data.projectId}`).emit('css_rule_added', { rule: data.rule });
|
| 410 |
-
});
|
| 411 |
-
|
| 412 |
-
socket.on('css_rule_updated', (data: { projectId: string; ruleId: string; updates: any }) => {
|
| 413 |
-
const session = sessions.get(socket.id);
|
| 414 |
-
if (!session || session.permission === 'view') return;
|
| 415 |
-
socket.to(`project:${data.projectId}`).emit('css_rule_updated', { ruleId: data.ruleId, updates: data.updates });
|
| 416 |
-
});
|
| 417 |
-
|
| 418 |
-
socket.on('css_rule_removed', (data: { projectId: string; ruleId: string }) => {
|
| 419 |
-
const session = sessions.get(socket.id);
|
| 420 |
-
if (!session || session.permission === 'view') return;
|
| 421 |
-
socket.to(`project:${data.projectId}`).emit('css_rule_removed', { ruleId: data.ruleId });
|
| 422 |
-
});
|
| 423 |
-
|
| 424 |
-
socket.on('css_property_added', (data: { projectId: string; ruleId: string; property: any }) => {
|
| 425 |
-
const session = sessions.get(socket.id);
|
| 426 |
-
if (!session || session.permission === 'view') return;
|
| 427 |
-
socket.to(`project:${data.projectId}`).emit('css_property_added', { ruleId: data.ruleId, property: data.property });
|
| 428 |
-
});
|
| 429 |
-
|
| 430 |
-
socket.on('css_property_updated', (data: { projectId: string; ruleId: string; propertyId: string; updates: any }) => {
|
| 431 |
-
const session = sessions.get(socket.id);
|
| 432 |
-
if (!session || session.permission === 'view') return;
|
| 433 |
-
socket.to(`project:${data.projectId}`).emit('css_property_updated', { ruleId: data.ruleId, propertyId: data.propertyId, updates: data.updates });
|
| 434 |
-
});
|
| 435 |
-
|
| 436 |
-
socket.on('css_property_removed', (data: { projectId: string; ruleId: string; propertyId: string }) => {
|
| 437 |
-
const session = sessions.get(socket.id);
|
| 438 |
-
if (!session || session.permission === 'view') return;
|
| 439 |
-
socket.to(`project:${data.projectId}`).emit('css_property_removed', { ruleId: data.ruleId, propertyId: data.propertyId });
|
| 440 |
-
});
|
| 441 |
-
|
| 442 |
// ---- DISCONNECT ----
|
| 443 |
socket.on('disconnect', () => {
|
| 444 |
const session = sessions.get(socket.id);
|
|
|
|
| 4 |
import { config } from '../config';
|
| 5 |
import { getDatabase } from '../database';
|
| 6 |
import { encryptCombined } from './encryption';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
interface AuthPayload {
|
| 9 |
userId: string;
|
|
|
|
| 20 |
cursor: { line: number; ch: number } | null;
|
| 21 |
selectedBlockId: string | null;
|
| 22 |
selectedElementId: string | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
joinedAt: number;
|
| 24 |
permission: 'view' | 'edit' | 'admin';
|
| 25 |
}
|
| 26 |
|
| 27 |
+
const sessions = new Map<string, CollaborationSession>(); // socketId -> session
|
| 28 |
+
const projectRooms = new Map<string, Map<string, CollaborationSession[]>>(); // projectId -> { userId -> sessions[] }
|
| 29 |
|
| 30 |
let io: Server;
|
| 31 |
|
|
|
|
| 38 |
},
|
| 39 |
pingInterval: 25000,
|
| 40 |
pingTimeout: 20000,
|
|
|
|
| 41 |
});
|
| 42 |
|
| 43 |
+
// Auth middleware for socket connections
|
| 44 |
io.use((socket, next) => {
|
| 45 |
const token = socket.handshake.auth?.token || socket.handshake.query?.token;
|
| 46 |
+
if (!token) {
|
| 47 |
+
return next(new Error('Authentication required'));
|
| 48 |
+
}
|
| 49 |
try {
|
| 50 |
const decoded = jwt.verify(token as string, config.jwtSecret) as { userId: string; email: string };
|
| 51 |
const db = getDatabase();
|
|
|
|
| 64 |
// ---- PROJECT ROOM JOINING ----
|
| 65 |
socket.on('join_project', ({ projectId }: { projectId: string }) => {
|
| 66 |
if (!projectId) return;
|
| 67 |
+
|
| 68 |
+
// Check permission
|
| 69 |
const db = getDatabase();
|
| 70 |
const project = db.prepare('SELECT user_id FROM projects WHERE id = ?').get(projectId) as any;
|
| 71 |
if (!project) return;
|
|
|
|
| 77 |
const collab = db.prepare(
|
| 78 |
'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
|
| 79 |
).get(projectId, user.userId) as any;
|
| 80 |
+
if (!collab) return; // not a collaborator
|
| 81 |
permission = collab.permission;
|
| 82 |
}
|
| 83 |
|
| 84 |
socket.join(`project:${projectId}`);
|
| 85 |
|
| 86 |
+
// Track session
|
| 87 |
const session: CollaborationSession = {
|
| 88 |
userId: user.userId,
|
| 89 |
username: user.username,
|
|
|
|
| 93 |
cursor: null,
|
| 94 |
selectedBlockId: null,
|
| 95 |
selectedElementId: null,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
joinedAt: Date.now(),
|
| 97 |
permission,
|
| 98 |
};
|
|
|
|
| 107 |
}
|
| 108 |
room.get(user.userId)!.push(session);
|
| 109 |
|
| 110 |
+
// Notify others
|
| 111 |
socket.to(`project:${projectId}`).emit('collaborator_joined', {
|
| 112 |
userId: user.userId,
|
| 113 |
username: user.username,
|
|
|
|
| 115 |
joinedAt: session.joinedAt,
|
| 116 |
});
|
| 117 |
|
| 118 |
+
// Send current collaborators to the joining user
|
| 119 |
const collaboratorList: any[] = [];
|
| 120 |
+
for (const [uid, sessions] of room) {
|
| 121 |
+
for (const s of sessions) {
|
| 122 |
collaboratorList.push({
|
| 123 |
userId: uid,
|
| 124 |
username: s.username,
|
|
|
|
| 127 |
cursor: s.cursor,
|
| 128 |
selectedBlockId: s.selectedBlockId,
|
| 129 |
selectedElementId: s.selectedElementId,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
joinedAt: s.joinedAt,
|
| 131 |
});
|
| 132 |
}
|
|
|
|
| 134 |
socket.emit('collaborator_list', { collaborators: collaboratorList });
|
| 135 |
});
|
| 136 |
|
| 137 |
+
// ---- LEAVE PROJECT ----
|
| 138 |
socket.on('leave_project', ({ projectId }: { projectId: string }) => {
|
| 139 |
leaveProject(socket, projectId);
|
| 140 |
});
|
|
|
|
| 151 |
});
|
| 152 |
});
|
| 153 |
|
| 154 |
+
// ---- ACTIVE FILE CHANGE ----
|
| 155 |
socket.on('active_file_changed', (data: { projectId: string; fileId: string | null }) => {
|
| 156 |
const session = sessions.get(socket.id);
|
| 157 |
+
if (session) {
|
| 158 |
+
session.activeFileId = data.fileId;
|
| 159 |
+
}
|
| 160 |
socket.to(`project:${data.projectId}`).emit('collaborator_active_file', {
|
| 161 |
userId: user.userId,
|
| 162 |
username: user.username,
|
|
|
|
| 164 |
});
|
| 165 |
});
|
| 166 |
|
| 167 |
+
// ---- CURSOR MOVE ----
|
| 168 |
socket.on('cursor_move', (data: { projectId: string; fileId: string; cursor: { line: number; ch: number } }) => {
|
| 169 |
const session = sessions.get(socket.id);
|
| 170 |
if (session) {
|
|
|
|
| 179 |
});
|
| 180 |
});
|
| 181 |
|
| 182 |
+
// ---- BLOCK SELECTION ----
|
| 183 |
socket.on('block_selection_changed', (data: { projectId: string; blockId: string | null }) => {
|
| 184 |
const session = sessions.get(socket.id);
|
| 185 |
+
if (session) {
|
| 186 |
+
session.selectedBlockId = data.blockId;
|
| 187 |
+
}
|
| 188 |
socket.to(`project:${data.projectId}`).emit('collaborator_block_selected', {
|
| 189 |
userId: user.userId,
|
| 190 |
username: user.username,
|
|
|
|
| 192 |
});
|
| 193 |
});
|
| 194 |
|
| 195 |
+
// ---- VISUAL ELEMENT SELECTION / HOVER ----
|
| 196 |
socket.on('visual_element_hover', (data: { projectId: string; elementId: string | null }) => {
|
| 197 |
const session = sessions.get(socket.id);
|
| 198 |
+
if (session) {
|
| 199 |
+
session.selectedElementId = data.elementId;
|
| 200 |
+
}
|
| 201 |
socket.to(`project:${data.projectId}`).emit('collaborator_element_hover', {
|
| 202 |
userId: user.userId,
|
| 203 |
username: user.username,
|
|
|
|
| 205 |
});
|
| 206 |
});
|
| 207 |
|
| 208 |
+
// ---- FILE ADDED ----
|
| 209 |
socket.on('file_added', (data: { projectId: string; file: any; parentId?: string }) => {
|
| 210 |
const session = sessions.get(socket.id);
|
| 211 |
if (!session || session.permission === 'view') return;
|
| 212 |
socket.to(`project:${data.projectId}`).emit('file_added', {
|
| 213 |
+
file: data.file,
|
| 214 |
+
parentId: data.parentId,
|
| 215 |
+
addedBy: user.username,
|
| 216 |
});
|
| 217 |
});
|
| 218 |
|
| 219 |
+
// ---- FILE RENAMED ----
|
| 220 |
socket.on('file_renamed', (data: { projectId: string; fileId: string; name: string }) => {
|
| 221 |
const session = sessions.get(socket.id);
|
| 222 |
if (!session || session.permission === 'view') return;
|
| 223 |
socket.to(`project:${data.projectId}`).emit('file_renamed', {
|
| 224 |
+
fileId: data.fileId,
|
| 225 |
+
name: data.name,
|
| 226 |
+
renamedBy: user.username,
|
| 227 |
});
|
| 228 |
});
|
| 229 |
|
| 230 |
+
// ---- FILE DELETED ----
|
| 231 |
socket.on('file_deleted', (data: { projectId: string; fileId: string }) => {
|
| 232 |
const session = sessions.get(socket.id);
|
| 233 |
if (!session || session.permission === 'view') return;
|
| 234 |
socket.to(`project:${data.projectId}`).emit('file_deleted', {
|
| 235 |
+
fileId: data.fileId,
|
| 236 |
+
deletedBy: user.username,
|
| 237 |
});
|
| 238 |
});
|
| 239 |
|
| 240 |
+
// ---- VISUAL ELEMENT ADDED ----
|
| 241 |
socket.on('visual_element_added', (data: { projectId: string; element: any; parentId?: string }) => {
|
| 242 |
const session = sessions.get(socket.id);
|
| 243 |
if (!session || session.permission === 'view') return;
|
| 244 |
socket.to(`project:${data.projectId}`).emit('visual_element_added', {
|
| 245 |
+
element: data.element,
|
| 246 |
+
parentId: data.parentId,
|
| 247 |
});
|
| 248 |
});
|
| 249 |
|
| 250 |
+
// ---- VISUAL ELEMENT UPDATED ----
|
| 251 |
socket.on('visual_element_updated', (data: { projectId: string; elementId: string; updates: any }) => {
|
| 252 |
const session = sessions.get(socket.id);
|
| 253 |
if (!session || session.permission === 'view') return;
|
| 254 |
socket.to(`project:${data.projectId}`).emit('visual_element_updated', {
|
| 255 |
+
elementId: data.elementId,
|
| 256 |
+
updates: data.updates,
|
| 257 |
});
|
| 258 |
});
|
| 259 |
|
| 260 |
+
// ---- VISUAL ELEMENT REMOVED ----
|
| 261 |
socket.on('visual_element_removed', (data: { projectId: string; elementId: string }) => {
|
| 262 |
const session = sessions.get(socket.id);
|
| 263 |
if (!session || session.permission === 'view') return;
|
|
|
|
| 266 |
});
|
| 267 |
});
|
| 268 |
|
| 269 |
+
// ---- VISUAL ELEMENTS REORDERED ----
|
| 270 |
socket.on('visual_elements_reordered', (data: { projectId: string; elements: any[] }) => {
|
| 271 |
const session = sessions.get(socket.id);
|
| 272 |
if (!session || session.permission === 'view') return;
|
|
|
|
| 275 |
});
|
| 276 |
});
|
| 277 |
|
| 278 |
+
// ---- SAVE PROJECT (via WS instead of REST) ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
socket.on('save_project', (data: { projectId: string; data: any }, callback?: (response: any) => void) => {
|
| 280 |
const session = sessions.get(socket.id);
|
| 281 |
if (!session || session.permission === 'view') {
|
|
|
|
| 285 |
saveProjectData(session, data.projectId, data.data, callback);
|
| 286 |
});
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
// ---- DISCONNECT ----
|
| 289 |
socket.on('disconnect', () => {
|
| 290 |
const session = sessions.get(socket.id);
|
server/src/services/wsStore.ts
DELETED
|
@@ -1,195 +0,0 @@
|
|
| 1 |
-
import { v4 as uuidv4 } from 'uuid';
|
| 2 |
-
import { getDatabase } from '../database';
|
| 3 |
-
import { encryptCombined, decryptCombined } from './encryption';
|
| 4 |
-
|
| 5 |
-
export interface ProjectRecord {
|
| 6 |
-
id: string;
|
| 7 |
-
user_id: string;
|
| 8 |
-
name: string;
|
| 9 |
-
framework: string;
|
| 10 |
-
description: string;
|
| 11 |
-
encrypted_data: string;
|
| 12 |
-
encryption_iv: string;
|
| 13 |
-
created_at: number;
|
| 14 |
-
updated_at: number;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
export function getDefaultProjectData(framework: string): any {
|
| 18 |
-
const base = {
|
| 19 |
-
files: [] as any[],
|
| 20 |
-
blocks: {} as Record<string, any>,
|
| 21 |
-
visual: {} as Record<string, any>,
|
| 22 |
-
blocksXml: {} as Record<string, string>,
|
| 23 |
-
blockCode: {} as Record<string, string>,
|
| 24 |
-
};
|
| 25 |
-
|
| 26 |
-
switch (framework) {
|
| 27 |
-
case 'web':
|
| 28 |
-
return {
|
| 29 |
-
...base,
|
| 30 |
-
files: [
|
| 31 |
-
{ id: 'index.html', name: 'index.html', type: 'html', content: '' },
|
| 32 |
-
{ id: 'styles.css', name: 'styles.css', type: 'css', content: '' },
|
| 33 |
-
{ id: 'app.js', name: 'app.js', type: 'js', content: '' },
|
| 34 |
-
],
|
| 35 |
-
};
|
| 36 |
-
case 'electron':
|
| 37 |
-
return {
|
| 38 |
-
...base,
|
| 39 |
-
files: [
|
| 40 |
-
{ id: 'main.ts', name: 'main.ts', type: 'typescript', content: '' },
|
| 41 |
-
{ id: 'preload.ts', name: 'preload.ts', type: 'typescript', content: '' },
|
| 42 |
-
{ id: 'index.html', name: 'index.html', type: 'html', content: '' },
|
| 43 |
-
{ id: 'styles.css', name: 'styles.css', type: 'css', content: '' },
|
| 44 |
-
{ id: 'renderer.ts', name: 'renderer.ts', type: 'typescript', content: '' },
|
| 45 |
-
{ id: 'package.json', name: 'package.json', type: 'json', content: '' },
|
| 46 |
-
],
|
| 47 |
-
};
|
| 48 |
-
case 'maui':
|
| 49 |
-
return {
|
| 50 |
-
...base,
|
| 51 |
-
files: [
|
| 52 |
-
{ id: 'MainPage.xaml', name: 'MainPage.xaml', type: 'xaml', content: '' },
|
| 53 |
-
{ id: 'MainPage.xaml.cs', name: 'MainPage.xaml.cs', type: 'csharp', content: '' },
|
| 54 |
-
{ id: 'App.xaml', name: 'App.xaml', type: 'xaml', content: '' },
|
| 55 |
-
{ id: 'App.xaml.cs', name: 'App.xaml.cs', type: 'csharp', content: '' },
|
| 56 |
-
{ id: 'MauiProgram.cs', name: 'MauiProgram.cs', type: 'csharp', content: '' },
|
| 57 |
-
{ id: 'Models', name: 'Models', type: 'folder', children: [] },
|
| 58 |
-
{ id: 'ViewModels', name: 'ViewModels', type: 'folder', children: [] },
|
| 59 |
-
{ id: 'Services', name: 'Services', type: 'folder', children: [] },
|
| 60 |
-
],
|
| 61 |
-
};
|
| 62 |
-
case 'nodejs':
|
| 63 |
-
return {
|
| 64 |
-
...base,
|
| 65 |
-
files: [
|
| 66 |
-
{ id: 'server.js', name: 'server.js', type: 'js', content: '' },
|
| 67 |
-
{ id: 'package.json', name: 'package.json', type: 'json', content: '' },
|
| 68 |
-
{ id: '.env', name: '.env', type: 'env', content: '' },
|
| 69 |
-
{ id: 'routes', name: 'routes', type: 'folder', children: [] },
|
| 70 |
-
{ id: 'models', name: 'models', type: 'folder', children: [] },
|
| 71 |
-
{ id: 'middleware', name: 'middleware', type: 'folder', children: [] },
|
| 72 |
-
],
|
| 73 |
-
};
|
| 74 |
-
default:
|
| 75 |
-
return base;
|
| 76 |
-
}
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
export function listProjects(userId: string) {
|
| 80 |
-
const db = getDatabase();
|
| 81 |
-
const owned = db.prepare(
|
| 82 |
-
'SELECT id, name, framework, description, created_at, updated_at FROM projects WHERE user_id = ? ORDER BY updated_at DESC'
|
| 83 |
-
).all(userId) as any[];
|
| 84 |
-
|
| 85 |
-
const collab = db.prepare(`
|
| 86 |
-
SELECT p.id, p.name, p.framework, p.description, p.created_at, p.updated_at
|
| 87 |
-
FROM projects p
|
| 88 |
-
JOIN project_collaborators c ON p.id = c.project_id
|
| 89 |
-
WHERE c.user_id = ?
|
| 90 |
-
ORDER BY p.updated_at DESC
|
| 91 |
-
`).all(userId) as any[];
|
| 92 |
-
|
| 93 |
-
const seen = new Set<string>();
|
| 94 |
-
return [...owned, ...collab].filter((p) => {
|
| 95 |
-
if (seen.has(p.id)) return false;
|
| 96 |
-
seen.add(p.id);
|
| 97 |
-
return true;
|
| 98 |
-
});
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
export function getProject(projectId: string, userId: string) {
|
| 102 |
-
const db = getDatabase();
|
| 103 |
-
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId) as ProjectRecord | undefined;
|
| 104 |
-
if (!project) return null;
|
| 105 |
-
|
| 106 |
-
const isOwner = project.user_id === userId;
|
| 107 |
-
const isCollab = !isOwner && db.prepare(
|
| 108 |
-
'SELECT 1 FROM project_collaborators WHERE project_id = ? AND user_id = ?'
|
| 109 |
-
).get(projectId, userId);
|
| 110 |
-
|
| 111 |
-
if (!isOwner && !isCollab) return null;
|
| 112 |
-
|
| 113 |
-
try {
|
| 114 |
-
const decrypted = decryptCombined(project.encrypted_data);
|
| 115 |
-
const data = JSON.parse(decrypted);
|
| 116 |
-
return {
|
| 117 |
-
id: project.id,
|
| 118 |
-
name: project.name,
|
| 119 |
-
framework: project.framework,
|
| 120 |
-
description: project.description,
|
| 121 |
-
data,
|
| 122 |
-
created_at: project.created_at,
|
| 123 |
-
updated_at: project.updated_at,
|
| 124 |
-
};
|
| 125 |
-
} catch {
|
| 126 |
-
return null;
|
| 127 |
-
}
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
export function createProject(userId: string, name: string, framework: string, description?: string) {
|
| 131 |
-
const db = getDatabase();
|
| 132 |
-
const defaultData = getDefaultProjectData(framework);
|
| 133 |
-
const id = uuidv4();
|
| 134 |
-
const now = Date.now();
|
| 135 |
-
const combined = encryptCombined(JSON.stringify(defaultData));
|
| 136 |
-
|
| 137 |
-
db.prepare(`
|
| 138 |
-
INSERT INTO projects (id, user_id, name, framework, description, encrypted_data, encryption_iv, created_at, updated_at)
|
| 139 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 140 |
-
`).run(id, userId, name, framework, description || '', combined, '', now, now);
|
| 141 |
-
|
| 142 |
-
return {
|
| 143 |
-
id,
|
| 144 |
-
name,
|
| 145 |
-
framework,
|
| 146 |
-
description: description || '',
|
| 147 |
-
data: defaultData,
|
| 148 |
-
created_at: now,
|
| 149 |
-
updated_at: now,
|
| 150 |
-
};
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
export function updateProjectData(projectId: string, data: any) {
|
| 154 |
-
const db = getDatabase();
|
| 155 |
-
const now = Date.now();
|
| 156 |
-
const encryptedData = encryptCombined(JSON.stringify(data));
|
| 157 |
-
db.prepare(
|
| 158 |
-
'UPDATE projects SET encrypted_data = ?, encryption_iv = ?, updated_at = ? WHERE id = ?'
|
| 159 |
-
).run(encryptedData, '', now, projectId);
|
| 160 |
-
return now;
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
export function updateProjectMeta(projectId: string, updates: { name?: string; description?: string }) {
|
| 164 |
-
const db = getDatabase();
|
| 165 |
-
const now = Date.now();
|
| 166 |
-
const name = updates.name;
|
| 167 |
-
const description = updates.description;
|
| 168 |
-
db.prepare(`
|
| 169 |
-
UPDATE projects SET
|
| 170 |
-
name = COALESCE(?, name),
|
| 171 |
-
description = COALESCE(?, description),
|
| 172 |
-
updated_at = ?
|
| 173 |
-
WHERE id = ?
|
| 174 |
-
`).run(name || null, description !== undefined ? description : null, now, projectId);
|
| 175 |
-
return now;
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
export function deleteProject(projectId: string, userId: string) {
|
| 179 |
-
const db = getDatabase();
|
| 180 |
-
const project = db.prepare('SELECT user_id FROM projects WHERE id = ?').get(projectId) as any;
|
| 181 |
-
if (!project || project.user_id !== userId) return false;
|
| 182 |
-
db.prepare('DELETE FROM projects WHERE id = ?').run(projectId);
|
| 183 |
-
return true;
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
export function checkPermission(projectId: string, userId: string): 'owner' | 'admin' | 'edit' | 'view' | null {
|
| 187 |
-
const db = getDatabase();
|
| 188 |
-
const project = db.prepare('SELECT user_id FROM projects WHERE id = ?').get(projectId) as any;
|
| 189 |
-
if (!project) return null;
|
| 190 |
-
if (project.user_id === userId) return 'owner';
|
| 191 |
-
const collab = db.prepare(
|
| 192 |
-
'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
|
| 193 |
-
).get(projectId, userId) as any;
|
| 194 |
-
return collab ? collab.permission : null;
|
| 195 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
shared/types.ts
DELETED
|
@@ -1,255 +0,0 @@
|
|
| 1 |
-
// ============================================================
|
| 2 |
-
// ELEMENT SYSTEM
|
| 3 |
-
// ============================================================
|
| 4 |
-
|
| 5 |
-
export interface VisualElement {
|
| 6 |
-
id: string;
|
| 7 |
-
type: string;
|
| 8 |
-
tagName: string;
|
| 9 |
-
children: VisualElement[];
|
| 10 |
-
props: Record<string, any>;
|
| 11 |
-
styles: Record<string, string>;
|
| 12 |
-
classes: string[];
|
| 13 |
-
attributes: Record<string, string>;
|
| 14 |
-
slots?: OutlineSlot[];
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
export interface ElementRef {
|
| 18 |
-
id: string;
|
| 19 |
-
tagName: string;
|
| 20 |
-
classes: string[];
|
| 21 |
-
attributes: Record<string, string>;
|
| 22 |
-
fileId: string;
|
| 23 |
-
linkedFiles: string[];
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
export interface OutlineSlot {
|
| 27 |
-
id: string;
|
| 28 |
-
parentId: string;
|
| 29 |
-
index: number;
|
| 30 |
-
occupiedBy: string | null;
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
// ============================================================
|
| 34 |
-
// BLOCK SYSTEM
|
| 35 |
-
// ============================================================
|
| 36 |
-
|
| 37 |
-
export type BlockShape = 'hat' | 'stack' | 'boolean' | 'reporter' | 'c-block' | 'cap';
|
| 38 |
-
export type FrameworkType = 'web' | 'electron' | 'maui' | 'nodejs';
|
| 39 |
-
|
| 40 |
-
export interface BlockPort {
|
| 41 |
-
id: string;
|
| 42 |
-
label: string;
|
| 43 |
-
type: 'any' | 'string' | 'number' | 'boolean' | 'object' | 'array' | 'event' | 'element' | 'code';
|
| 44 |
-
position: 'left' | 'right' | 'top' | 'bottom';
|
| 45 |
-
shape?: 'round' | 'square' | 'diamond';
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
export interface BlockConfigField {
|
| 49 |
-
id: string;
|
| 50 |
-
label: string;
|
| 51 |
-
type: 'text' | 'number' | 'select' | 'boolean' | 'color' | 'code' | 'slider' | 'textarea' | 'variable' | 'element';
|
| 52 |
-
default?: any;
|
| 53 |
-
options?: { label: string; value: any }[];
|
| 54 |
-
placeholder?: string;
|
| 55 |
-
min?: number;
|
| 56 |
-
max?: number;
|
| 57 |
-
allowVariable?: boolean;
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
export interface BlockDefinition {
|
| 61 |
-
id: string;
|
| 62 |
-
type: string;
|
| 63 |
-
label: string;
|
| 64 |
-
category: string;
|
| 65 |
-
description: string;
|
| 66 |
-
framework: FrameworkType | 'all';
|
| 67 |
-
shape: BlockShape;
|
| 68 |
-
inputs: BlockPort[];
|
| 69 |
-
outputs: BlockPort[];
|
| 70 |
-
config: BlockConfigField[];
|
| 71 |
-
color: string;
|
| 72 |
-
icon: string;
|
| 73 |
-
dangerous?: boolean;
|
| 74 |
-
blockVariables?: { name: string; type: string }[];
|
| 75 |
-
compile: (config: Record<string, any>, inputs: Record<string, string>) => string;
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
export interface BlockNode {
|
| 79 |
-
id: string;
|
| 80 |
-
blockId: string;
|
| 81 |
-
position: { x: number; y: number };
|
| 82 |
-
config: Record<string, any>;
|
| 83 |
-
connections: Record<string, string>;
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
// ============================================================
|
| 87 |
-
// CSS SYSTEM
|
| 88 |
-
// ============================================================
|
| 89 |
-
|
| 90 |
-
export interface CssRule {
|
| 91 |
-
id: string;
|
| 92 |
-
selector: string;
|
| 93 |
-
type: 'id' | 'class' | 'element' | 'universal' | 'attribute' | 'pseudo-class' | 'pseudo-element';
|
| 94 |
-
properties: CssProperty[];
|
| 95 |
-
mediaQuery: string;
|
| 96 |
-
enabled: boolean;
|
| 97 |
-
order: number;
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
export interface CssProperty {
|
| 101 |
-
id: string;
|
| 102 |
-
name: string;
|
| 103 |
-
value: string;
|
| 104 |
-
important: boolean;
|
| 105 |
-
unit: 'px' | '%' | 'em' | 'rem' | 'vh' | 'vw' | 'auto' | 'none' | 'ms' | 's' | 'deg' | 'rad';
|
| 106 |
-
type: 'standard' | 'variable' | 'custom';
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
// ============================================================
|
| 110 |
-
// VERSION SYSTEM
|
| 111 |
-
// ============================================================
|
| 112 |
-
|
| 113 |
-
export interface VersionDiff {
|
| 114 |
-
fileId: string;
|
| 115 |
-
type: 'content_change' | 'file_add' | 'file_remove' | 'file_rename' |
|
| 116 |
-
'css_rule_add' | 'css_rule_remove' | 'css_rule_update' |
|
| 117 |
-
'css_property_add' | 'css_property_remove' | 'css_property_update' |
|
| 118 |
-
'visual_element_add' | 'visual_element_remove' | 'visual_element_update' |
|
| 119 |
-
'element_registry_change' | 'blocks_xml_change' | 'block_code_change' |
|
| 120 |
-
'project_meta_change' | 'binary_file_add';
|
| 121 |
-
oldValue?: any;
|
| 122 |
-
newValue?: any;
|
| 123 |
-
timestamp: number;
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
export interface VersionEntry {
|
| 127 |
-
id: string;
|
| 128 |
-
projectId: string;
|
| 129 |
-
parentId: string | null;
|
| 130 |
-
userId: string;
|
| 131 |
-
username: string;
|
| 132 |
-
timestamp: number;
|
| 133 |
-
message: string;
|
| 134 |
-
type: 'auto' | 'restore' | 'manual_branch';
|
| 135 |
-
diffs: VersionDiff[];
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
// ============================================================
|
| 139 |
-
// PROJECT SYSTEM
|
| 140 |
-
// ============================================================
|
| 141 |
-
|
| 142 |
-
export interface ProjectData {
|
| 143 |
-
files: ProjectFile[];
|
| 144 |
-
blocks: Record<string, BlockNode[]>;
|
| 145 |
-
visual: Record<string, VisualElement[]>;
|
| 146 |
-
blocksXml: Record<string, string>;
|
| 147 |
-
blockCode: Record<string, string>;
|
| 148 |
-
cssRules: CssRule[];
|
| 149 |
-
elementRegistry: ElementRef[];
|
| 150 |
-
elementCounters: Record<string, number>;
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
export interface ProjectFile {
|
| 154 |
-
id: string;
|
| 155 |
-
name: string;
|
| 156 |
-
type: string;
|
| 157 |
-
content?: string;
|
| 158 |
-
children?: ProjectFile[];
|
| 159 |
-
linkedFiles?: string[];
|
| 160 |
-
isBinary?: boolean;
|
| 161 |
-
binaryHash?: string;
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
// ============================================================
|
| 165 |
-
// COLLABORATION SYSTEM
|
| 166 |
-
// ============================================================
|
| 167 |
-
|
| 168 |
-
export interface Collaborator {
|
| 169 |
-
userId: string;
|
| 170 |
-
username: string;
|
| 171 |
-
permission: 'view' | 'edit' | 'admin';
|
| 172 |
-
activeFileId: string | null;
|
| 173 |
-
cursor: { line: number; ch: number } | null;
|
| 174 |
-
selectedBlockId: string | null;
|
| 175 |
-
selectedElementId: string | null;
|
| 176 |
-
pointerPosition: { x: number; y: number } | null;
|
| 177 |
-
draggingBlock: { blockId: string; x: number; y: number } | null;
|
| 178 |
-
textFieldCursor: { fieldId: string; position: number } | null;
|
| 179 |
-
typing: boolean;
|
| 180 |
-
joinedAt: number;
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
// ============================================================
|
| 184 |
-
// WS EVENT NAMES
|
| 185 |
-
// ============================================================
|
| 186 |
-
|
| 187 |
-
export const WS_EVENTS = {
|
| 188 |
-
JOIN_PROJECT: 'join_project',
|
| 189 |
-
LEAVE_PROJECT: 'leave_project',
|
| 190 |
-
|
| 191 |
-
FILE_CHANGED: 'file_changed',
|
| 192 |
-
FILE_ADDED: 'file_added',
|
| 193 |
-
FILE_RENAMED: 'file_renamed',
|
| 194 |
-
FILE_DELETED: 'file_deleted',
|
| 195 |
-
FILE_UPLOAD_START: 'file_upload_start',
|
| 196 |
-
FILE_UPLOAD_CHUNK: 'file_upload_chunk',
|
| 197 |
-
FILE_UPLOAD_END: 'file_upload_end',
|
| 198 |
-
FILE_UPLOAD_ERROR: 'file_upload_error',
|
| 199 |
-
FILE_UPLOAD_PROGRESS: 'file_upload_progress',
|
| 200 |
-
|
| 201 |
-
VISUAL_ELEMENT_ADDED: 'visual_element_added',
|
| 202 |
-
VISUAL_ELEMENT_UPDATED: 'visual_element_updated',
|
| 203 |
-
VISUAL_ELEMENT_REMOVED: 'visual_element_removed',
|
| 204 |
-
VISUAL_ELEMENTS_REORDERED: 'visual_elements_reordered',
|
| 205 |
-
ELEMENT_REGISTRY_UPDATED: 'element_registry_updated',
|
| 206 |
-
ELEMENT_ID_CHANGED: 'element_id_changed',
|
| 207 |
-
|
| 208 |
-
BLOCK_DRAG_START: 'block_drag_start',
|
| 209 |
-
BLOCK_DRAG_MOVE: 'block_drag_move',
|
| 210 |
-
BLOCK_DRAG_END: 'block_drag_end',
|
| 211 |
-
BLOCK_SELECTION_CHANGED: 'block_selection_changed',
|
| 212 |
-
|
| 213 |
-
CSS_RULE_ADDED: 'css_rule_added',
|
| 214 |
-
CSS_RULE_UPDATED: 'css_rule_updated',
|
| 215 |
-
CSS_RULE_REMOVED: 'css_rule_removed',
|
| 216 |
-
CSS_RULE_REORDERED: 'css_rule_reordered',
|
| 217 |
-
CSS_PROPERTY_ADDED: 'css_property_added',
|
| 218 |
-
CSS_PROPERTY_UPDATED: 'css_property_updated',
|
| 219 |
-
CSS_PROPERTY_REMOVED: 'css_property_removed',
|
| 220 |
-
|
| 221 |
-
POINTER_MOVE: 'pointer_move',
|
| 222 |
-
ACTIVE_FILE_CHANGED: 'active_file_changed',
|
| 223 |
-
CURSOR_MOVE: 'cursor_move',
|
| 224 |
-
TEXT_FIELD_FOCUS: 'text_field_focus',
|
| 225 |
-
TEXT_FIELD_CURSOR: 'text_field_cursor',
|
| 226 |
-
TEXT_FIELD_BLUR: 'text_field_blur',
|
| 227 |
-
|
| 228 |
-
PROJECT_SAVE: 'project_save',
|
| 229 |
-
PROJECT_CREATE: 'project_create',
|
| 230 |
-
PROJECT_LIST: 'project_list',
|
| 231 |
-
PROJECT_DELETE: 'project_delete',
|
| 232 |
-
PROJECT_SAVED: 'project_saved',
|
| 233 |
-
PROJECT_CREATED: 'project_created',
|
| 234 |
-
PROJECT_LIST_RESULT: 'project_list_result',
|
| 235 |
-
PROJECT_DELETED: 'project_deleted',
|
| 236 |
-
|
| 237 |
-
VERSION_SAVE: 'version_save',
|
| 238 |
-
VERSION_SAVED: 'version_saved',
|
| 239 |
-
VERSION_LIST: 'version_list',
|
| 240 |
-
VERSION_LIST_RESULT: 'version_list_result',
|
| 241 |
-
VERSION_SNAPSHOT: 'version_snapshot',
|
| 242 |
-
VERSION_SNAPSHOT_RESULT: 'version_snapshot_result',
|
| 243 |
-
VERSION_RESTORE: 'version_restore',
|
| 244 |
-
VERSION_RESTORED: 'version_restored',
|
| 245 |
-
|
| 246 |
-
COLLABORATOR_JOINED: 'collaborator_joined',
|
| 247 |
-
COLLABORATOR_LEFT: 'collaborator_left',
|
| 248 |
-
COLLABORATOR_LIST: 'collaborator_list',
|
| 249 |
-
COLLABORATOR_ACTIVE_FILE: 'collaborator_active_file',
|
| 250 |
-
COLLABORATOR_CURSOR: 'collaborator_cursor',
|
| 251 |
-
COLLABORATOR_BLOCK_SELECTED: 'collaborator_block_selected',
|
| 252 |
-
COLLABORATOR_ELEMENT_HOVER: 'collaborator_element_hover',
|
| 253 |
-
|
| 254 |
-
FILE_UPDATED: 'file_updated',
|
| 255 |
-
} as const;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|