incognitolm commited on
Commit
2b73dbf
·
1 Parent(s): 72b3fd1
client/src/components/VisualEditor/LiveCanvas.tsx CHANGED
@@ -1,6 +1,13 @@
1
- import { useRef, useEffect, useCallback, useState } from 'react';
2
  import { generateEditorPage } from './editorSandbox';
3
 
 
 
 
 
 
 
 
4
  interface LiveCanvasProps {
5
  html: string;
6
  css: string;
@@ -9,12 +16,14 @@ interface LiveCanvasProps {
9
  selectedSelector: string | null;
10
  onElementClick: (selector: string) => void;
11
  onElementDrop: (parentSelector: string, slotIndex: number) => void;
 
12
  onCanvasClick: () => void;
 
13
  }
14
 
15
  export default function LiveCanvas({
16
  html, css, js, showOutlines, selectedSelector,
17
- onElementClick, onElementDrop, onCanvasClick,
18
  }: LiveCanvasProps) {
19
  const iframeRef = useRef<HTMLIFrameElement>(null);
20
  const overlayRef = useRef<HTMLDivElement>(null);
@@ -57,20 +66,40 @@ export default function LiveCanvas({
57
  }, '*');
58
  }, [showOutlines, ready]);
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  // Receive messages from iframe
61
  useEffect(() => {
62
  const handler = (event: MessageEvent) => {
63
  if (event.source !== iframeRef.current?.contentWindow) return;
64
  const data = event.data;
 
65
  switch (data.type) {
66
  case 'ELEMENT_CLICK':
67
- onElementClick(data.selector);
 
 
 
68
  break;
69
  case 'ELEMENT_DROP_IN_SLOT':
70
- onElementDrop(data.parentSelector, data.slotIndex);
71
  break;
72
  case 'CANVAS_CLICK':
73
- onCanvasClick();
74
  break;
75
  case 'READY':
76
  setReady(true);
@@ -79,7 +108,24 @@ export default function LiveCanvas({
79
  };
80
  window.addEventListener('message', handler);
81
  return () => window.removeEventListener('message', handler);
82
- }, [onElementClick, onElementDrop, onCanvasClick]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  return (
85
  <div className="relative w-full h-full">
@@ -92,8 +138,8 @@ export default function LiveCanvas({
92
  <div
93
  ref={overlayRef}
94
  className="absolute inset-0 z-10"
95
- onDragOver={(e) => e.preventDefault()}
96
- onDrop={(e) => e.preventDefault()}
97
  onClick={() => onCanvasClick()}
98
  />
99
  </div>
 
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;
 
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);
 
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);
 
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">
 
138
  <div
139
  ref={overlayRef}
140
  className="absolute inset-0 z-10"
141
+ onDragOver={handleOverlayDragOver}
142
+ onDrop={handleOverlayDrop}
143
  onClick={() => onCanvasClick()}
144
  />
145
  </div>
client/src/components/VisualEditor/Toolbox.tsx CHANGED
@@ -145,13 +145,18 @@ const ELEMENT_CATEGORIES: { name: string; icon: any; items: ElementPreset[] }[]
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 => {
 
145
  },
146
  ];
147
 
148
+ interface ToolboxProps {
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
+ onDragStart?.(element);
159
+ }, [onDragStart]);
160
 
161
  const toggleCategory = useCallback((name: string) => {
162
  setExpandedCategories(prev => {
client/src/components/VisualEditor/VisualEditor.tsx CHANGED
@@ -12,7 +12,6 @@ import {
12
  Plus, Code, Palette, Layout
13
  } from 'lucide-react';
14
  import { getColor } from '../Collaboration/CollaboratorAvatars';
15
- import { getSocket } from '../../hooks/useWebSocket';
16
 
17
  type DeviceMode = 'desktop' | 'tablet' | 'mobile';
18
 
@@ -104,6 +103,11 @@ export default function VisualEditor() {
104
  const [contextMenu, setContextMenu] = useState<{ x: number; y: number; elementId: string } | null>(null);
105
  const [showTagModal, setShowTagModal] = useState(false);
106
  const [iframeSelector, setIframeSelector] = useState<string | null>(null);
 
 
 
 
 
107
 
108
  // Collaborator hovers
109
  const collaborators = useCollaborationStore((s) => s.collaborators);
@@ -141,9 +145,8 @@ export default function VisualEditor() {
141
  }, [fileTree]);
142
 
143
  const liveJs = useMemo(() => {
144
- const state = useEditorStore.getState();
145
- return state.getBlockCode(activeFileId || '') || '';
146
- }, [activeFileId, visualElements]);
147
 
148
  // Map iframe selector to element ID
149
  const handleElementClick = useCallback((selector: string) => {
@@ -179,35 +182,45 @@ export default function VisualEditor() {
179
  setIframeSelector(null);
180
  }, [setSelectedElement]);
181
 
182
- // Handle drop from toolbox into slot
183
- const handleElementDrop = useCallback((parentSelector: string, slotIndex: number) => {
184
- // Find parent element by selector
185
- const match = parentSelector.match(/#([\w-]+)/);
186
- if (!match) return;
187
- const parentIdAttr = match[1];
188
- const searchByAttr = (els: VisualElement[]): VisualElement | null => {
189
- for (const e of els) {
190
- if (e.props?.id === parentIdAttr || e.attributes?.id === parentIdAttr) return e;
191
- if (e.children) {
192
- const found = searchByAttr(e.children);
193
- if (found) return found;
194
- }
195
  }
196
- return null;
197
- };
198
- const parent = searchByAttr(visualElements || []);
199
- if (!parent) return;
200
 
201
- const child = createVisualElement('div', {
202
- style: { padding: '12px', border: '1px dashed #6366f1', borderRadius: '4px' }
203
- });
204
- // Auto-ID generation
205
- const autoId = incrementCounter('div');
206
  child.props = { ...child.props, id: autoId };
207
  child.attributes = { ...child.attributes, id: autoId };
 
 
 
 
 
 
 
 
 
 
 
208
 
209
- addVisualElement(child, parent.id);
210
- }, [visualElements, addVisualElement, incrementCounter]);
 
 
 
 
 
 
211
 
212
  const handleElementClickLocal = useCallback((e: React.MouseEvent, elementId: string) => {
213
  e.stopPropagation();
@@ -237,6 +250,18 @@ export default function VisualEditor() {
237
  const selectedElement = selectedElementId ? findElementById(visualElements || [], selectedElementId) : null;
238
  const device = DEVICE_SIZES[deviceMode];
239
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  // Context menu actions
241
  const cmDuplicate = useCallback(() => {
242
  if (!contextMenu) return;
@@ -246,10 +271,15 @@ export default function VisualEditor() {
246
  const autoId = incrementCounter(el.tagName);
247
  dup.props = { ...dup.props, id: autoId };
248
  dup.attributes = { ...dup.attributes, id: autoId };
249
- addVisualElement(dup);
 
 
 
 
 
250
  }
251
  setContextMenu(null);
252
- }, [contextMenu, visualElements, addVisualElement, incrementCounter]);
253
 
254
  const cmDelete = useCallback(() => {
255
  if (contextMenu) { removeVisualElement(contextMenu.elementId); setContextMenu(null); }
@@ -337,7 +367,7 @@ export default function VisualEditor() {
337
  Elements
338
  </div>
339
  <div className="flex-1 overflow-y-auto px-2">
340
- <Toolbox />
341
  </div>
342
  </div>
343
 
@@ -392,7 +422,9 @@ export default function VisualEditor() {
392
  selectedSelector={iframeSelector}
393
  onElementClick={handleElementClick}
394
  onElementDrop={handleElementDrop}
 
395
  onCanvasClick={handleCanvasClick}
 
396
  />
397
  )}
398
  </div>
 
12
  Plus, Code, Palette, Layout
13
  } from 'lucide-react';
14
  import { getColor } from '../Collaboration/CollaboratorAvatars';
 
15
 
16
  type DeviceMode = 'desktop' | 'tablet' | 'mobile';
17
 
 
103
  const [contextMenu, setContextMenu] = useState<{ x: number; y: number; elementId: string } | null>(null);
104
  const [showTagModal, setShowTagModal] = useState(false);
105
  const [iframeSelector, setIframeSelector] = useState<string | null>(null);
106
+ const dragDataRef = useRef<any>(null);
107
+
108
+ // Subscribe to blockCode changes reactively
109
+ const blockCode = useEditorStore((s) => s.blockCode);
110
+ const currentBlockCode = blockCode[activeFileId || ''] || '';
111
 
112
  // Collaborator hovers
113
  const collaborators = useCollaborationStore((s) => s.collaborators);
 
145
  }, [fileTree]);
146
 
147
  const liveJs = useMemo(() => {
148
+ return currentBlockCode;
149
+ }, [currentBlockCode]);
 
150
 
151
  // Map iframe selector to element ID
152
  const handleElementClick = useCallback((selector: string) => {
 
182
  setIframeSelector(null);
183
  }, [setSelectedElement]);
184
 
185
+ // Search for element by HTML id attribute
186
+ const searchByAttr = useCallback((els: VisualElement[] | undefined, idAttr: string): VisualElement | null => {
187
+ if (!els) return null;
188
+ for (const e of els) {
189
+ if (e.props?.id === idAttr || e.attributes?.id === idAttr) return e;
190
+ if (e.children) {
191
+ const found = searchByAttr(e.children, idAttr);
192
+ if (found) return found;
 
 
 
 
 
193
  }
194
+ }
195
+ return null;
196
+ }, []);
 
197
 
198
+ // Create a new element from preset and add to parent
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();
 
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;
 
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, incrementCounter, findParentAndIndex]);
283
 
284
  const cmDelete = useCallback(() => {
285
  if (contextMenu) { removeVisualElement(contextMenu.elementId); setContextMenu(null); }
 
367
  Elements
368
  </div>
369
  <div className="flex-1 overflow-y-auto px-2">
370
+ <Toolbox onDragStart={(preset) => { dragDataRef.current = preset; }} />
371
  </div>
372
  </div>
373
 
 
422
  selectedSelector={iframeSelector}
423
  onElementClick={handleElementClick}
424
  onElementDrop={handleElementDrop}
425
+ onElementDropFromToolbox={handleElementDropFromToolbox}
426
  onCanvasClick={handleCanvasClick}
427
+ dragDataRef={dragDataRef}
428
  />
429
  )}
430
  </div>
client/src/store/editorStore.ts CHANGED
@@ -42,7 +42,7 @@ interface EditorStore {
42
  toggleSnapToGrid: () => void;
43
  toggleMiniMap: () => void;
44
  setVisualElements: (elements: VisualElement[]) => void;
45
- addVisualElement: (element: VisualElement, parentId?: string) => void;
46
  updateVisualElement: (id: string, updates: Partial<VisualElement>) => void;
47
  removeVisualElement: (id: string) => void;
48
  setFileTree: (files: ProjectFile[]) => void;
@@ -70,7 +70,7 @@ function getChildCount(elements: VisualElement[], id: string): number {
70
  if (found !== -1) return found;
71
  }
72
  }
73
- return 0;
74
  }
75
 
76
  function getLinkedFiles(fileId: string, fileTree: ProjectFile[]): string[] {
@@ -132,17 +132,29 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
132
 
133
  setVisualElements: (elements) => set({ visualElements: elements || [] }),
134
 
135
- addVisualElement: (element, parentId) =>
136
  set((state) => {
137
  const elements = state.visualElements || [];
138
  let newElements: VisualElement[];
139
  if (!parentId) {
140
- newElements = [...elements, element];
 
 
 
 
 
 
141
  } else {
142
  const addToParent = (els: VisualElement[]): VisualElement[] =>
143
  (els || []).map((el) => {
144
  if (el.id === parentId) {
145
- return { ...el, children: [...(el.children || []), element] };
 
 
 
 
 
 
146
  }
147
  if (el.children) {
148
  return { ...el, children: addToParent(el.children) };
@@ -151,7 +163,6 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
151
  });
152
  newElements = addToParent(elements);
153
  }
154
- // Recalculate slots for parent
155
  setTimeout(() => {
156
  if (parentId) get().recalculateSlots(parentId);
157
  get().recomputeRegistry();
@@ -334,7 +345,7 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
334
 
335
  setSlotCount: (elementId, count) => {
336
  const state = get();
337
- const childCount = getChildCount(state.visualElements, elementId);
338
  const clampedCount = Math.max(count, childCount, 1);
339
  set((s) => ({
340
  slotCounts: { ...s.slotCounts, [elementId]: clampedCount },
 
42
  toggleSnapToGrid: () => void;
43
  toggleMiniMap: () => void;
44
  setVisualElements: (elements: VisualElement[]) => void;
45
+ addVisualElement: (element: VisualElement, parentId?: string, insertIndex?: number) => void;
46
  updateVisualElement: (id: string, updates: Partial<VisualElement>) => void;
47
  removeVisualElement: (id: string) => void;
48
  setFileTree: (files: ProjectFile[]) => void;
 
70
  if (found !== -1) return found;
71
  }
72
  }
73
+ return -1;
74
  }
75
 
76
  function getLinkedFiles(fileId: string, fileTree: ProjectFile[]): string[] {
 
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
+ if (insertIndex !== undefined) {
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) };
 
163
  });
164
  newElements = addToParent(elements);
165
  }
 
166
  setTimeout(() => {
167
  if (parentId) get().recalculateSlots(parentId);
168
  get().recomputeRegistry();
 
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 },