"use client" import { Tldraw, type Editor, createShapeId, toRichText } from "@tldraw/tldraw" import { useEffect, useRef, useState } from "react" import { cn } from "@/lib/utils" import { getWireframeGenerationUrl, getWireframeUrl, getWireframeByIdUrl } from "@/lib/api-config" import { useAuth } from "@/contexts/auth-context" import { getAccessToken } from "@/components/apis/authApiClients" import { authApiClient, getRefreshToken, setTokens, clearTokens } from "@/components/apis/authApiClients" import { parseSVG, makeAbsolute } from 'svg-path-parser' import { wireframeConverter } from "@/lib/wireframe-converter" import { useEditorStore } from "@/lib/store" export default function WireframeCanvas({ className, onWireframeGenerated, onGenerationStart, selectedDevice }: { className?: string onWireframeGenerated?: (data: any) => void onGenerationStart?: () => void selectedDevice: 'desktop' | 'tablet' | 'mobile' // Make selectedDevice required }) { const editorRef = useRef(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [currentWireframeId, setCurrentWireframeId] = useState(null) const [autoSaveEnabled, setAutoSaveEnabled] = useState(true) const [lastSaved, setLastSaved] = useState(null) const { user } = useAuth() const isAuthenticated = !!user const handleMount = (editor: Editor) => { editorRef.current = editor } const handleClear = () => { const editor = editorRef.current if (!editor) return editor.selectAll() editor.deleteShapes(editor.getSelectedShapeIds()) setError(null) // Clear current wireframe ID when clearing canvas setCurrentWireframeId(null) setLastSaved(null) } // Auto-save wireframe data const saveWireframe = async (editor: Editor, isAutoSave: boolean = true) => { try { if (!editor) return // Check if user is authenticated if (!isAuthenticated) { console.warn('User not authenticated, skipping save') return } // Get current user const currentUser = user if (!currentUser || !currentUser.id) { console.warn('No valid user found, skipping save') return } // Get all shapes from the editor const shapes = editor.getCurrentPageShapes() console.log('Saving wireframe with', shapes.length, 'shapes') if (shapes.length === 0) { console.log('No shapes to save, skipping') return } // Debug: Log the device type being used console.log('DEBUG: saveWireframe called with selectedDevice:', selectedDevice) console.log('DEBUG: selectedDevice prop value:', selectedDevice) console.log('DEBUG: selectedDevice type:', typeof selectedDevice) console.log('DEBUG: selectedDevice === undefined:', selectedDevice === undefined) console.log('DEBUG: selectedDevice === null:', selectedDevice === null) console.log('DEBUG: selectedDevice === "desktop":', selectedDevice === 'desktop') console.log('DEBUG: selectedDevice === "mobile":', selectedDevice === 'mobile') console.log('DEBUG: selectedDevice === "tablet":', selectedDevice === 'tablet') // Validate and ensure selectedDevice is always a valid value const validDeviceTypes = ['desktop', 'tablet', 'mobile'] as const const deviceType = validDeviceTypes.includes(selectedDevice) ? selectedDevice : 'desktop' console.log('DEBUG: Validated device type:', deviceType) // Convert shapes to our format const elements = shapes.map((shape: any) => ({ id: shape.id, type: shape.type, data: shape, position: { x: shape.x, y: shape.y }, size: { width: shape.props?.w || 100, height: shape.props?.h || 100 }, style: { color: shape.style?.color, strokeWidth: shape.style?.strokeWidth, fill: shape.style?.fill }, parent_id: shape.parentId, z_index: shape.childIndex || 0 })) const wireframeData = { wireframe: { id: currentWireframeId, name: `Wireframe ${new Date().toLocaleString()}`, description: 'AI-generated wireframe', device_type: deviceType, // Use the selected device type instead of hardcoded 'desktop' dimensions: { width: 1440, height: 1024 }, metadata: { prompt: 'AI generated', timestamp: new Date().toISOString() } }, elements: elements, user_id: currentUser.id, // Get from authenticated user project_id: null } // Debug: Log the final wireframe data being sent console.log('DEBUG: Final wireframe data being sent:', JSON.stringify(wireframeData, null, 2)) console.log('DEBUG: Final device_type value:', wireframeData.wireframe.device_type) const url = currentWireframeId ? getWireframeByIdUrl(currentWireframeId) : getWireframeUrl() const method = currentWireframeId ? 'PUT' : 'POST' console.log('Saving wireframe to:', url, 'with method:', method) console.log('Wireframe data:', wireframeData) // Perform request with current token const doRequest = async (token?: string) => fetch(url, { method, headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : (getAccessToken() ? { 'Authorization': `Bearer ${getAccessToken()}` } : {})), }, body: JSON.stringify(wireframeData) }) let response = await doRequest() console.log('Save response status:', response.status, response.statusText) if (response.ok) { const result = await response.json() console.log('Save response:', result) if (!currentWireframeId && result.wireframe_id) { setCurrentWireframeId(result.wireframe_id) } setLastSaved(new Date()) if (!isAutoSave) { console.log('Wireframe saved successfully') } } else { const errorText = await response.text() console.error('Save failed with status:', response.status, 'Error:', errorText) // Handle specific error cases if (response.status === 401) { // Attempt silent token refresh then retry once try { const rt = getRefreshToken() if (!rt) throw new Error('Missing refresh token') const refreshResp = await authApiClient.post('/api/auth/refresh', { refreshToken: rt }) const { accessToken: newAccessToken, refreshToken: newRefreshToken } = refreshResp.data.data.tokens setTokens(newAccessToken, newRefreshToken) console.log('DEBUG: Token refreshed. Retrying save...') response = await doRequest(newAccessToken) if (response.ok) { const result = await response.json() console.log('Save response after refresh:', result) if (!currentWireframeId && result.wireframe_id) { setCurrentWireframeId(result.wireframe_id) } setLastSaved(new Date()) if (!isAutoSave) { console.log('Wireframe saved successfully (after refresh)') } return } // If still not ok, fall through to throw } catch (refreshErr) { console.error('Error saving wireframe: Token refresh failed', refreshErr) clearTokens() throw new Error('Authentication error: Token has expired. Please sign in again.') } throw new Error(`Authentication failed (${response.status}). Please try logging in again.`) } else if (response.status === 403) { throw new Error('Access denied. You may not have permission to save wireframes.') } else if (response.status >= 500) { throw new Error(`Server error (${response.status}). Please try again later.`) } else { throw new Error(`Failed to save wireframe: ${response.status} ${errorText}`) } } } catch (err) { console.error('Error saving wireframe:', err) if (!isAutoSave) { // Provide more specific error messages if (err instanceof Error) { if (err.message.includes('Authentication error') || err.message.includes('Authentication failed')) { setError(err.message) } else if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')) { setError('Network error. Please check your connection and try again.') } else { setError(err.message) } } else { setError('Failed to save wireframe. Please try again.') } } } } // Load wireframe data const loadWireframe = async (wireframeId: string) => { try { setBusy(true) // Check if user is authenticated if (!isAuthenticated) { setError('Authentication required to load wireframes') return } const response = await fetch(getWireframeByIdUrl(wireframeId), { headers: { 'Content-Type': 'application/json', ...(getAccessToken() ? { 'Authorization': `Bearer ${getAccessToken()}` } : {}), } }) if (response.ok) { const data = await response.json() const editor = editorRef.current if (!editor) return // Clear existing canvas editor.selectAll() editor.deleteShapes(editor.getSelectedShapeIds()) // Load elements if (data.elements && Array.isArray(data.elements)) { data.elements.forEach((element: any) => { try { // Create shape from element data const shapeData = element.data if (shapeData && shapeData.type) { editor.createShape(shapeData) } } catch (shapeError) { console.warn('Failed to load shape:', element.id, shapeError) } }) } setCurrentWireframeId(wireframeId) setLastSaved(new Date()) console.log('Wireframe loaded successfully') } else { throw new Error('Failed to load wireframe') } } catch (err) { console.error('Error loading wireframe:', err) setError('Failed to load wireframe') } finally { setBusy(false) } } // Enhanced color mapping with comprehensive hex support const mapColorToTldraw = (hexColor: string): string => { if (!hexColor) return 'black' // Normalize hex color const normalizedColor = hexColor.toLowerCase().trim() // Handle named colors const namedColors: Record = { 'red': 'red', 'green': 'green', 'blue': 'blue', 'yellow': 'yellow', 'orange': 'orange', 'purple': 'violet', 'violet': 'violet', 'pink': 'light-red', 'cyan': 'light-blue', 'lime': 'light-green', 'black': 'black', 'white': 'grey', 'gray': 'grey', 'grey': 'grey', 'none': 'black', 'transparent': 'black' } if (namedColors[normalizedColor]) { return namedColors[normalizedColor] } // Direct hex mappings const colorMap: Record = { '#0ea5e9': 'blue', '#3b82f6': 'blue', '#1d4ed8': 'blue', '#2563eb': 'blue', '#1e40af': 'blue', '#64748b': 'grey', '#6b7280': 'grey', '#9ca3af': 'grey', '#d1d5db': 'grey', '#f8fafc': 'grey', '#f1f5f9': 'grey', '#ffffff': 'grey', '#000000': 'black', '#1f2937': 'black', '#111827': 'black', '#ef4444': 'red', '#dc2626': 'red', '#b91c1c': 'red', '#991b1b': 'red', '#10b981': 'green', '#059669': 'green', '#047857': 'green', '#065f46': 'green', '#f59e0b': 'yellow', '#d97706': 'yellow', '#b45309': 'yellow', '#92400e': 'yellow', '#8b5cf6': 'violet', '#7c3aed': 'violet', '#6d28d9': 'violet', '#5b21b6': 'violet', '#ec4899': 'light-red', '#db2777': 'light-red', '#be185d': 'light-red', '#9d174d': 'light-red', '#06b6d4': 'light-blue', '#0891b2': 'light-blue', '#0e7490': 'light-blue', '#155e75': 'light-blue', '#84cc16': 'light-green', '#65a30d': 'light-green', '#4d7c0f': 'light-green', '#365314': 'light-green', '#f97316': 'orange', '#ea580c': 'orange', '#c2410c': 'orange', '#9a3412': 'orange', '#a855f7': 'light-violet', '#9333ea': 'light-violet', '#7e22ce': 'light-violet', '#6b21a8': 'light-violet' } // If exact match found, return it if (colorMap[normalizedColor]) { return colorMap[normalizedColor] } // Parse RGB values for approximate matching let r, g, b if (normalizedColor.startsWith('#')) { const hex = normalizedColor.slice(1) if (hex.length === 3) { r = parseInt(hex[0] + hex[0], 16) g = parseInt(hex[1] + hex[1], 16) b = parseInt(hex[2] + hex[2], 16) } else if (hex.length === 6) { r = parseInt(hex.slice(0, 2), 16) g = parseInt(hex.slice(2, 4), 16) b = parseInt(hex.slice(4, 6), 16) } } else if (normalizedColor.startsWith('rgb')) { // Handle rgb() format const rgbMatch = normalizedColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/) if (rgbMatch) { r = parseInt(rgbMatch[1]) g = parseInt(rgbMatch[2]) b = parseInt(rgbMatch[3]) } } if (r !== undefined && g !== undefined && b !== undefined) { // Approximate color matching based on RGB values if (r > 200 && g > 200 && b > 200) return 'grey' // Light colors if (r < 50 && g < 50 && b < 50) return 'black' // Dark colors // Primary color detection if (r > g + 30 && r > b + 30) return r > 150 ? 'light-red' : 'red' if (g > r + 30 && g > b + 30) return g > 150 ? 'light-green' : 'green' if (b > r + 30 && b > g + 30) return b > 150 ? 'light-blue' : 'blue' // Secondary colors if (r > 150 && g > 100 && b < 100) return 'orange' if (r > 100 && g < 100 && b > 150) return 'violet' if (r > 150 && g > 150 && b < 100) return 'yellow' } return 'black' // Default fallback } // Enhanced SVG parsing with better error handling and scaling const parseSVGAndRender = async (editor: Editor, svgString: string) => { try { console.log('Parsing SVG response:', svgString.substring(0, 200) + '...') // Clean up the SVG string let cleanSvgString = svgString.trim() // Extract SVG content if it's wrapped in other content const svgMatch = cleanSvgString.match(/]*>[\s\S]*?<\/svg>/i) if (svgMatch) { cleanSvgString = svgMatch[0] } // Create a temporary DOM element to parse SVG const parser = new DOMParser() const svgDoc = parser.parseFromString(cleanSvgString, 'image/svg+xml') // Check for parsing errors const parserError = svgDoc.querySelector('parsererror') if (parserError) { throw new Error(`SVG parsing failed: ${parserError.textContent}`) } const svgElement = svgDoc.querySelector('svg') if (!svgElement) { throw new Error('No SVG element found in response') } // Clear existing canvas editor.selectAll() editor.deleteShapes(editor.getSelectedShapeIds()) // Get SVG dimensions with comprehensive fallbacks let svgWidth = 800 let svgHeight = 600 let viewBoxX = 0 let viewBoxY = 0 // Try to get dimensions from viewBox first const viewBox = svgElement.getAttribute('viewBox') if (viewBox) { const viewBoxValues = viewBox.split(/\s+|,/).map(Number).filter(n => !isNaN(n)) if (viewBoxValues.length >= 4) { [viewBoxX, viewBoxY, svgWidth, svgHeight] = viewBoxValues } } else { // Fallback to width/height attributes const widthAttr = svgElement.getAttribute('width') const heightAttr = svgElement.getAttribute('height') if (widthAttr && heightAttr) { const parsedWidth = parseFloat(widthAttr.replace(/px|pt|em|rem|%/g, '')) const parsedHeight = parseFloat(heightAttr.replace(/px|pt|em|rem|%/g, '')) if (!isNaN(parsedWidth) && !isNaN(parsedHeight)) { svgWidth = parsedWidth svgHeight = parsedHeight } } } // Calculate proper offset to center content const canvasWidth = Math.max(1000, svgWidth + 200) const canvasHeight = Math.max(700, svgHeight + 200) const offsetX = (canvasWidth - svgWidth) / 2 + 50 const offsetY = (canvasHeight - svgHeight) / 2 + 50 // Create main frame const frameId = createShapeId() editor.createShape({ id: frameId, type: "frame", x: 25, y: 25, props: { w: canvasWidth, h: canvasHeight, name: "AI Generated Wireframe", }, }) // Parse and render SVG elements await renderSVGElements(editor, svgElement, offsetX - viewBoxX, offsetY - viewBoxY, svgWidth, svgHeight) // Capture the current selectedDevice value to avoid closure issues const currentDeviceType = selectedDevice console.log('DEBUG: parseSVGAndRender captured device type:', currentDeviceType) // Auto-zoom to fit content with proper timing setTimeout(() => { const shapes = editor.getCurrentPageShapes() if (shapes.length > 0) { const pageBounds = editor.getCurrentPageBounds() if (pageBounds) { editor.zoomToBounds(pageBounds, { animation: { duration: 600 }, targetZoom: Math.min(1.2, 0.85) // Add padding but don't zoom out too much }) } } // Save the wireframe after rendering is complete setTimeout(() => { console.log('DEBUG: parseSVGAndRender calling saveWireframe with device type:', currentDeviceType) saveWireframe(editor, true) }, 500) }, 200) } catch (error) { console.error('Error parsing SVG:', error) setError(`Failed to parse SVG: ${error instanceof Error ? error.message : 'Unknown error'}. Please try again.`) } } // Enhanced element rendering with better transform support const renderSVGElements = async (editor: Editor, svgElement: Element, offsetX: number, offsetY: number, svgWidth: number, svgHeight: number) => { const elements = Array.from(svgElement.children) for (const element of elements) { const tagName = element.tagName.toLowerCase() // Skip non-visual elements if (['defs', 'style', 'title', 'desc', 'metadata', 'clippath', 'mask'].includes(tagName)) { continue } try { // Check for visibility const visibility = element.getAttribute('visibility') const display = element.getAttribute('display') const opacity = element.getAttribute('opacity') if (visibility === 'hidden' || display === 'none' || opacity === '0') { continue } switch (tagName) { case 'rect': await renderSVGRect(editor, element as SVGRectElement, offsetX, offsetY) break case 'circle': await renderSVGCircle(editor, element as SVGCircleElement, offsetX, offsetY) break case 'ellipse': await renderSVGEllipse(editor, element as SVGEllipseElement, offsetX, offsetY) break case 'path': await renderSVGPath(editor, element as SVGPathElement, offsetX, offsetY) break case 'text': await renderSVGText(editor, element as SVGTextElement, offsetX, offsetY) break case 'line': await renderSVGLine(editor, element as SVGLineElement, offsetX, offsetY) break case 'polyline': case 'polygon': await renderSVGPoly(editor, element as SVGPolygonElement, offsetX, offsetY) break case 'g': // Handle group elements with transform support const transform = element.getAttribute('transform') let groupOffsetX = offsetX let groupOffsetY = offsetY let scaleX = 1 let scaleY = 1 if (transform) { // Parse translate transform const translateMatch = transform.match(/translate\(\s*([^)]+)\s*\)/) if (translateMatch) { const values = translateMatch[1].split(/[,\s]+/).map(Number).filter(n => !isNaN(n)) if (values.length >= 1) { groupOffsetX += values[0] || 0 groupOffsetY += values[1] || values[0] || 0 } } // Parse scale transform const scaleMatch = transform.match(/scale\(\s*([^)]+)\s*\)/) if (scaleMatch) { const values = scaleMatch[1].split(/[,\s]+/).map(Number).filter(n => !isNaN(n)) if (values.length >= 1) { scaleX = values[0] || 1 scaleY = values[1] || values[0] || 1 } } } await renderSVGElements(editor, element, groupOffsetX, groupOffsetY, svgWidth * scaleX, svgHeight * scaleY) break case 'use': // Handle elements (references to other elements) await renderSVGUse(editor, element as SVGUseElement, offsetX, offsetY, svgElement) break default: console.log(`Unsupported SVG element: ${tagName}`) } } catch (error) { console.error(`Error rendering SVG element ${tagName}:`, error) // Continue with other elements even if one fails } } } // Enhanced rectangle rendering const renderSVGRect = async (editor: Editor, element: SVGRectElement, offsetX: number, offsetY: number) => { const x = parseFloat(element.getAttribute('x') || '0') + offsetX const y = parseFloat(element.getAttribute('y') || '0') + offsetY const width = parseFloat(element.getAttribute('width') || '100') const height = parseFloat(element.getAttribute('height') || '100') const fill = element.getAttribute('fill') || 'none' const stroke = element.getAttribute('stroke') || '#000000' const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1') const rx = parseFloat(element.getAttribute('rx') || '0') const ry = parseFloat(element.getAttribute('ry') || '0') // Skip if dimensions are invalid if (width <= 0 || height <= 0) return // Determine fill type let fillType: 'none' | 'semi' | 'solid' = 'none' if (fill !== 'none' && fill !== 'transparent') { fillType = stroke === 'none' || stroke === 'transparent' ? 'solid' : 'semi' } editor.createShape({ id: createShapeId(), type: "geo", x, y, props: { w: Math.max(1, width), h: Math.max(1, height), geo: "rectangle", fill: fillType, color: mapColorToTldraw(stroke !== 'none' ? stroke : fill), size: strokeWidth > 3 ? 'xl' : strokeWidth > 2 ? 'l' : strokeWidth > 1 ? 'm' : 's', }, }) } // Enhanced circle rendering const renderSVGCircle = async (editor: Editor, element: SVGCircleElement, offsetX: number, offsetY: number) => { const cx = parseFloat(element.getAttribute('cx') || '0') + offsetX const cy = parseFloat(element.getAttribute('cy') || '0') + offsetY const r = parseFloat(element.getAttribute('r') || '50') const fill = element.getAttribute('fill') || 'none' const stroke = element.getAttribute('stroke') || '#000000' const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1') // Skip if radius is invalid if (r <= 0) return let fillType: 'none' | 'semi' | 'solid' = 'none' if (fill !== 'none' && fill !== 'transparent') { fillType = stroke === 'none' || stroke === 'transparent' ? 'solid' : 'semi' } editor.createShape({ id: createShapeId(), type: "geo", x: cx - r, y: cy - r, props: { w: r * 2, h: r * 2, geo: "ellipse", fill: fillType, color: mapColorToTldraw(stroke !== 'none' ? stroke : fill), size: strokeWidth > 3 ? 'xl' : strokeWidth > 2 ? 'l' : strokeWidth > 1 ? 'm' : 's', }, }) } // Enhanced ellipse rendering const renderSVGEllipse = async (editor: Editor, element: SVGEllipseElement, offsetX: number, offsetY: number) => { const cx = parseFloat(element.getAttribute('cx') || '0') + offsetX const cy = parseFloat(element.getAttribute('cy') || '0') + offsetY const rx = parseFloat(element.getAttribute('rx') || '50') const ry = parseFloat(element.getAttribute('ry') || '30') const fill = element.getAttribute('fill') || 'none' const stroke = element.getAttribute('stroke') || '#000000' const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1') // Skip if dimensions are invalid if (rx <= 0 || ry <= 0) return let fillType: 'none' | 'semi' | 'solid' = 'none' if (fill !== 'none' && fill !== 'transparent') { fillType = stroke === 'none' || stroke === 'transparent' ? 'solid' : 'semi' } editor.createShape({ id: createShapeId(), type: "geo", x: cx - rx, y: cy - ry, props: { w: rx * 2, h: ry * 2, geo: "ellipse", fill: fillType, color: mapColorToTldraw(stroke !== 'none' ? stroke : fill), size: strokeWidth > 3 ? 'xl' : strokeWidth > 2 ? 'l' : strokeWidth > 1 ? 'm' : 's', }, }) } // Significantly improved path rendering using svg-path-parser const renderSVGPath = async (editor: Editor, element: SVGPathElement, offsetX: number, offsetY: number) => { const d = element.getAttribute('d') || '' const fill = element.getAttribute('fill') || 'none' const stroke = element.getAttribute('stroke') || '#000000' const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1') if (!d.trim()) return try { // Parse the path data using svg-path-parser const commands = parseSVG(d) const absoluteCommands = makeAbsolute(commands) if (absoluteCommands.length === 0) return // Extract all coordinate points from the path const points: Array<{x: number, y: number}> = [] absoluteCommands.forEach((command: any) => { switch (command.code) { case 'M': // Move to case 'L': // Line to if ('x' in command && 'y' in command) { points.push({ x: command.x, y: command.y }) } break case 'C': // Cubic bezier curve if ('x1' in command && 'y1' in command) { points.push({ x: command.x1, y: command.y1 }) } if ('x2' in command && 'y2' in command) { points.push({ x: command.x2, y: command.y2 }) } if ('x' in command && 'y' in command) { points.push({ x: command.x, y: command.y }) } break case 'Q': // Quadratic bezier curve if ('x1' in command && 'y1' in command) { points.push({ x: command.x1, y: command.y1 }) } if ('x' in command && 'y' in command) { points.push({ x: command.x, y: command.y }) } break case 'A': // Arc if ('x' in command && 'y' in command) { points.push({ x: command.x, y: command.y }) } break case 'H': // Horizontal line if ('x' in command && points.length > 0) { points.push({ x: command.x, y: points[points.length - 1].y }) } break case 'V': // Vertical line if ('y' in command && points.length > 0) { points.push({ x: points[points.length - 1].x, y: command.y }) } break } }) if (points.length === 0) return // Calculate bounds const minX = Math.min(...points.map(p => p.x)) const minY = Math.min(...points.map(p => p.y)) const maxX = Math.max(...points.map(p => p.x)) const maxY = Math.max(...points.map(p => p.y)) const width = Math.max(2, maxX - minX) const height = Math.max(2, maxY - minY) // Determine shape type based on path characteristics const isClosedPath = d.toLowerCase().includes('z') const isLine = width < 3 || height < 3 || points.length <= 2 let fillType: 'none' | 'semi' | 'solid' = 'none' if (fill !== 'none' && fill !== 'transparent' && isClosedPath) { fillType = stroke === 'none' || stroke === 'transparent' ? 'solid' : 'semi' } if (isLine && !isClosedPath) { // Render as line/draw shape const drawPoints = points.map((point, index) => ({ x: point.x - minX, y: point.y - minY, z: 0.5 })) editor.createShape({ id: createShapeId(), type: "draw", x: minX + offsetX, y: minY + offsetY, props: { segments: [{ type: "straight", points: drawPoints.length > 0 ? drawPoints : [ { x: 0, y: 0, z: 0.5 }, { x: width, y: height, z: 0.5 } ] }], color: mapColorToTldraw(stroke), size: strokeWidth > 3 ? 'xl' : strokeWidth > 2 ? 'l' : strokeWidth > 1 ? 'm' : 's', }, }) } else { // Render as geometric shape const aspectRatio = width / height let geoType: 'rectangle' | 'ellipse' = 'rectangle' // Detect if it's more circular/elliptical if (Math.abs(aspectRatio - 1) < 0.2 && isClosedPath) { geoType = 'ellipse' } editor.createShape({ id: createShapeId(), type: "geo", x: minX + offsetX, y: minY + offsetY, props: { w: width, h: height, geo: geoType, fill: fillType, color: mapColorToTldraw(stroke !== 'none' ? stroke : fill), size: strokeWidth > 3 ? 'xl' : strokeWidth > 2 ? 'l' : strokeWidth > 1 ? 'm' : 's', }, }) } } catch (error) { console.error('Error parsing path with svg-path-parser:', error) // Fallback to simple rectangle editor.createShape({ id: createShapeId(), type: "geo", x: offsetX, y: offsetY, props: { w: 100, h: 50, geo: "rectangle", fill: 'none', color: mapColorToTldraw(stroke), }, }) } } // Render SVG line elements const renderSVGLine = async (editor: Editor, element: SVGLineElement, offsetX: number, offsetY: number) => { const x1 = parseFloat(element.getAttribute('x1') || '0') + offsetX const y1 = parseFloat(element.getAttribute('y1') || '0') + offsetY const x2 = parseFloat(element.getAttribute('x2') || '0') + offsetX const y2 = parseFloat(element.getAttribute('y2') || '0') + offsetY const stroke = element.getAttribute('stroke') || '#000000' const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1') const startX = Math.min(x1, x2) const startY = Math.min(y1, y2) const endX = Math.abs(x2 - x1) const endY = Math.abs(y2 - y1) editor.createShape({ id: createShapeId(), type: "draw", x: startX, y: startY, props: { segments: [{ type: "straight", points: [ { x: 0, y: 0, z: 0.5 }, { x: endX, y: endY, z: 0.5 } ] }], color: mapColorToTldraw(stroke), size: strokeWidth > 3 ? 'xl' : strokeWidth > 2 ? 'l' : strokeWidth > 1 ? 'm' : 's', }, }) } // Enhanced polyline/polygon rendering const renderSVGPoly = async (editor: Editor, element: SVGPolygonElement, offsetX: number, offsetY: number) => { const points = element.getAttribute('points') || '' const fill = element.getAttribute('fill') || 'none' const stroke = element.getAttribute('stroke') || '#000000' const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1') if (!points.trim()) return try { // Parse points more robustly const coords = points.trim() .split(/[\s,]+/) .map(Number) .filter(n => !isNaN(n)) if (coords.length < 4) return // Need at least 2 points (x,y pairs) // Extract coordinate pairs const pointPairs: Array<{x: number, y: number}> = [] for (let i = 0; i < coords.length; i += 2) { if (i + 1 < coords.length) { pointPairs.push({ x: coords[i], y: coords[i + 1] }) } } if (pointPairs.length < 2) return // Calculate bounds const minX = Math.min(...pointPairs.map(p => p.x)) const minY = Math.min(...pointPairs.map(p => p.y)) const maxX = Math.max(...pointPairs.map(p => p.x)) const maxY = Math.max(...pointPairs.map(p => p.y)) const width = Math.max(2, maxX - minX) const height = Math.max(2, maxY - minY) const isPolygon = element.tagName.toLowerCase() === 'polygon' let fillType: 'none' | 'semi' | 'solid' = 'none' if (fill !== 'none' && fill !== 'transparent' && isPolygon) { fillType = stroke === 'none' || stroke === 'transparent' ? 'solid' : 'semi' } // Convert to tldraw points const drawPoints = pointPairs.map(point => ({ x: point.x - minX, y: point.y - minY, z: 0.5 })) // Add closing point for polygons if (isPolygon && drawPoints.length > 0) { drawPoints.push(drawPoints[0]) } editor.createShape({ id: createShapeId(), type: "draw", x: minX + offsetX, y: minY + offsetY, props: { segments: [{ type: "straight", points: drawPoints }], color: mapColorToTldraw(stroke !== 'none' ? stroke : fill), size: strokeWidth > 3 ? 'xl' : strokeWidth > 2 ? 'l' : strokeWidth > 1 ? 'm' : 's', }, }) } catch (error) { console.error('Error parsing polygon/polyline:', error) // Fallback to rectangle editor.createShape({ id: createShapeId(), type: "geo", x: offsetX, y: offsetY, props: { w: 100, h: 50, geo: "rectangle", fill: 'none', color: mapColorToTldraw(stroke), }, }) } } // Enhanced text rendering with better positioning and styling const renderSVGText = async (editor: Editor, element: SVGTextElement, offsetX: number, offsetY: number) => { const x = parseFloat(element.getAttribute('x') || '0') + offsetX const y = parseFloat(element.getAttribute('y') || '0') + offsetY const textContent = element.textContent || '' const fill = element.getAttribute('fill') || '#000000' const fontSize = element.getAttribute('font-size') || '16' const fontWeight = element.getAttribute('font-weight') || 'normal' const fontFamily = element.getAttribute('font-family') || 'sans-serif' const textAnchor = element.getAttribute('text-anchor') || 'start' const dominantBaseline = element.getAttribute('dominant-baseline') || 'auto' if (!textContent.trim()) return // Parse font size more reliably const parsedFontSize = parseFloat(fontSize.replace(/px|pt|em|rem|%/g, '')) let tldrawSize: 's' | 'm' | 'l' | 'xl' = 'm' if (parsedFontSize <= 10) tldrawSize = 's' else if (parsedFontSize <= 14) tldrawSize = 'm' else if (parsedFontSize <= 20) tldrawSize = 'l' else tldrawSize = 'xl' // Adjust position based on text anchor and baseline let adjustedX = x let adjustedY = y // Estimate text dimensions for positioning const estimatedWidth = textContent.length * (parsedFontSize * 0.6) const estimatedHeight = parsedFontSize // Adjust for text-anchor switch (textAnchor) { case 'middle': adjustedX -= estimatedWidth / 2 break case 'end': adjustedX -= estimatedWidth break // 'start' is default, no adjustment needed } // Adjust for dominant-baseline (SVG uses baseline, tldraw uses top-left) switch (dominantBaseline) { case 'middle': adjustedY -= estimatedHeight / 2 break case 'hanging': adjustedY -= estimatedHeight break default: adjustedY -= estimatedHeight * 0.8 // Approximate baseline adjustment } editor.createShape({ id: createShapeId(), type: "text", x: adjustedX, y: adjustedY, props: { richText: toRichText(textContent.trim()), size: tldrawSize, color: mapColorToTldraw(fill), scale: 1, }, }) // Handle tspan elements within text const tspans = element.querySelectorAll('tspan') tspans.forEach((tspan, index) => { const tspanText = tspan.textContent || '' if (!tspanText.trim() || index === 0) return // Skip empty or first tspan (already handled) const dx = parseFloat(tspan.getAttribute('dx') || '0') const dy = parseFloat(tspan.getAttribute('dy') || '0') const tspanX = parseFloat(tspan.getAttribute('x') || x.toString()) const tspanY = parseFloat(tspan.getAttribute('y') || y.toString()) editor.createShape({ id: createShapeId(), type: "text", x: (tspanX || (adjustedX + dx)) + offsetX, y: (tspanY || (adjustedY + dy)) + offsetY - estimatedHeight * 0.8, props: { richText: toRichText(tspanText.trim()), size: tldrawSize, color: mapColorToTldraw(tspan.getAttribute('fill') || fill), scale: 1, }, }) }) } // New: Handle SVG elements const renderSVGUse = async (editor: Editor, element: SVGUseElement, offsetX: number, offsetY: number, rootSvg: Element) => { const href = element.getAttribute('href') || element.getAttribute('xlink:href') || '' const x = parseFloat(element.getAttribute('x') || '0') + offsetX const y = parseFloat(element.getAttribute('y') || '0') + offsetY if (!href.startsWith('#')) return // Find the referenced element const refId = href.slice(1) const referencedElement = rootSvg.querySelector(`#${refId}`) if (referencedElement) { // Render the referenced element at the use position await renderSVGElements(editor, referencedElement.parentElement || referencedElement, x, y, 100, 100) } } const generateFromPrompt = async (prompt: string, device?: string) => { const editor = editorRef.current if (!editor) return setBusy(true) setError(null) // Notify parent component that generation has started onGenerationStart?.() try { // Use device-specific endpoint for better performance and device-specific optimization const targetDevice = device || selectedDevice || 'desktop' console.log(`Generating wireframe for ${targetDevice} device using device-specific API`) const response = await fetch(getWireframeGenerationUrl(targetDevice as 'desktop' | 'tablet' | 'mobile'), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'image/svg+xml, application/xml, text/xml, text/plain, */*', }, body: JSON.stringify({ prompt }), }) console.log(`API Response status: ${response.status}, content-type: ${response.headers.get('content-type')}`) if (!response.ok) { let errorMessage = `API Error (${response.status})` try { const errorText = await response.text() errorMessage += `: ${errorText}` } catch { errorMessage += ': Unknown error' } throw new Error(errorMessage) } // Get response content const responseText = await response.text() console.log('Received response, length:', responseText.length, 'first 100 chars:', responseText.substring(0, 100)) // Check if response contains SVG content const containsSVG = responseText.trim().startsWith(' { const onGenerate = (e: Event) => { const ce = e as CustomEvent<{ prompt: string; device?: string }> if (typeof ce.detail?.prompt === "string") { console.log('DEBUG: Event listener onGenerate called with device:', ce.detail.device, 'selectedDevice:', selectedDevice) void generateFromPrompt(ce.detail.prompt, ce.detail.device) } } const onClear = () => { handleClear() } // Add keyboard shortcuts const onKeyDown = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey) { switch (e.key) { case 's': case 'S': e.preventDefault() if (editorRef.current) { void saveWireframe(editorRef.current, false) } break case 'k': case 'K': e.preventDefault() // Could trigger a prompt input modal break case 'Backspace': case 'Delete': e.preventDefault() handleClear() break } } } window.addEventListener("tldraw:generate", onGenerate as EventListener) window.addEventListener("tldraw:clear", onClear as EventListener) window.addEventListener("keydown", onKeyDown) return () => { window.removeEventListener("tldraw:generate", onGenerate as EventListener) window.removeEventListener("tldraw:clear", onClear as EventListener) window.removeEventListener("keydown", onKeyDown) } }, [selectedDevice]) // Include selectedDevice in dependencies // Auto-save effect useEffect(() => { if (!autoSaveEnabled || !editorRef.current || !isAuthenticated) { console.log('Auto-save disabled, no editor, or user not authenticated') return } const autoSaveInterval = setInterval(() => { if (editorRef.current) { console.log('DEBUG: Auto-save triggered with device type:', selectedDevice) void saveWireframe(editorRef.current, true) } }, 30000) // Auto-save every 30 seconds return () => clearInterval(autoSaveInterval) }, [autoSaveEnabled, selectedDevice, isAuthenticated]) // Include selectedDevice and isAuthenticated in dependencies // Load wireframe on mount if user is authenticated useEffect(() => { if (isAuthenticated && user?.id) { // Try to load the most recent wireframe for this user const loadRecentWireframe = async () => { try { const response = await fetch(getWireframeUrl() + `/user/${user.id}`, { headers: { 'Content-Type': 'application/json', ...(getAccessToken() ? { 'Authorization': `Bearer ${getAccessToken()}` } : {}), } }) if (response.ok) { const data = await response.json() if (data.wireframes && data.wireframes.length > 0) { // Load the most recent wireframe const mostRecent = data.wireframes[0] // Assuming they're sorted by created_at desc await loadWireframe(mostRecent.id) } } else { console.log('No recent wireframes found or error loading:', response.status) } } catch (error) { console.log('No recent wireframes to load or error loading:', error) } } loadRecentWireframe() } else { console.log('User not authenticated, skipping wireframe loading') } }, [isAuthenticated, user?.id]) // Debug: Log when selectedDevice prop changes useEffect(() => { console.log('DEBUG: WireframeCanvas selectedDevice prop changed to:', selectedDevice) }, [selectedDevice]) return (
{/* Centered loading overlay during generation */} {busy && (
Generating wireframe…
)} {error && (
⚠️ {error}
)} {busy && (
Generating wireframe with AI...
)} {/* Save Status and Controls */}
{lastSaved && (
Last saved: {lastSaved.toLocaleTimeString()}
)} {/* Authentication Status */} {isAuthenticated ? (
Logged in as: {user?.username || 'User'}
) : (
⚠️ Please log in to save wireframes Sign In
)}
) }