1472 lines
53 KiB
TypeScript
1472 lines
53 KiB
TypeScript
|
||
|
||
"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<Editor | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [currentWireframeId, setCurrentWireframeId] = useState<string | null>(null)
|
||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
|
||
const [lastSaved, setLastSaved] = useState<Date | null>(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<string, string> = {
|
||
'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<string, string> = {
|
||
'#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(/<svg[^>]*>[\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 <use> 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 <use> 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('<svg') ||
|
||
responseText.includes('<svg') ||
|
||
responseText.includes('<?xml')
|
||
|
||
if (containsSVG) {
|
||
await parseSVGAndRender(editor, responseText)
|
||
|
||
// Dispatch wireframe generation event for Components mode integration
|
||
window.dispatchEvent(new CustomEvent('wireframe:generated', {
|
||
detail: {
|
||
svgData: responseText,
|
||
deviceType: targetDevice,
|
||
prompt: prompt
|
||
}
|
||
}))
|
||
|
||
// Show success message
|
||
console.log('Wireframe generated successfully and integrated into Components mode')
|
||
|
||
// NEW: also convert SVG to component instances for the Components tab
|
||
try {
|
||
const parsed = wireframeConverter.parseSVGToWireframe(responseText, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||
// Replace existing components with generated ones
|
||
useEditorStore.getState().setComponents(componentInstances)
|
||
} catch (convErr) {
|
||
console.warn('SVG to components conversion failed (non-blocking):', convErr)
|
||
}
|
||
|
||
// Save the generated wireframe
|
||
await saveWireframe(editor, false)
|
||
// Notify parent component that wireframe was generated
|
||
onWireframeGenerated?.({
|
||
success: true,
|
||
wireframeId: currentWireframeId,
|
||
prompt,
|
||
device: targetDevice,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
} else {
|
||
// Try to extract SVG from JSON response
|
||
try {
|
||
const jsonResponse = JSON.parse(responseText)
|
||
if (jsonResponse.svg) {
|
||
await parseSVGAndRender(editor, jsonResponse.svg)
|
||
|
||
// Dispatch wireframe generation event for Components mode integration
|
||
window.dispatchEvent(new CustomEvent('wireframe:generated', {
|
||
detail: {
|
||
svgData: jsonResponse.svg,
|
||
deviceType: targetDevice,
|
||
prompt: prompt
|
||
}
|
||
}))
|
||
|
||
// Show success message
|
||
console.log('Wireframe generated successfully and integrated into Components mode')
|
||
|
||
try {
|
||
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||
useEditorStore.getState().setComponents(componentInstances)
|
||
} catch {}
|
||
await saveWireframe(editor, false)
|
||
onWireframeGenerated?.({
|
||
success: true,
|
||
wireframeId: currentWireframeId,
|
||
prompt,
|
||
device: targetDevice,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
} else if (jsonResponse.data && jsonResponse.data.svg) {
|
||
await parseSVGAndRender(editor, jsonResponse.data.svg)
|
||
|
||
// Dispatch wireframe generation event for Components mode integration
|
||
window.dispatchEvent(new CustomEvent('wireframe:generated', {
|
||
detail: {
|
||
svgData: jsonResponse.data.svg,
|
||
deviceType: targetDevice,
|
||
prompt: prompt
|
||
}
|
||
}))
|
||
|
||
try {
|
||
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.data.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||
useEditorStore.getState().setComponents(componentInstances)
|
||
} catch {}
|
||
await saveWireframe(editor, false)
|
||
onWireframeGenerated?.({
|
||
success: true,
|
||
wireframeId: currentWireframeId,
|
||
prompt,
|
||
device: targetDevice,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
} else {
|
||
throw new Error('No SVG content found in response')
|
||
}
|
||
} catch (jsonError) {
|
||
throw new Error('Backend returned non-SVG response and failed to parse as JSON. Expected SVG format.')
|
||
}
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error('Error generating wireframe:', err)
|
||
setError(err instanceof Error ? err.message : 'Failed to generate wireframe')
|
||
onWireframeGenerated?.({
|
||
success: false,
|
||
error: err instanceof Error ? err.message : 'Unknown error',
|
||
prompt,
|
||
device: device || selectedDevice || 'desktop',
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
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 (
|
||
<div className={cn("relative h-full w-full flex flex-col", className)}>
|
||
{/* Centered loading overlay during generation */}
|
||
{busy && (
|
||
<div
|
||
className="absolute inset-0 z-50 flex items-center justify-center bg-white/60 dark:bg-black/60"
|
||
aria-busy="true"
|
||
aria-live="polite"
|
||
>
|
||
<div className="flex flex-col items-center gap-3">
|
||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
|
||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Generating wireframe…</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{error && (
|
||
<div className="absolute top-4 left-4 right-4 z-50 bg-red-50 border border-red-200 rounded-lg p-3 shadow-lg">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-red-600 text-sm font-medium">⚠️ {error}</span>
|
||
<button
|
||
onClick={() => setError(null)}
|
||
className="ml-auto text-red-400 hover:text-red-600 transition-colors duration-200 text-lg leading-none"
|
||
aria-label="Close error"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{busy && (
|
||
<div className="absolute top-4 left-4 right-4 z-40 bg-blue-50 border border-blue-200 rounded-lg p-3 shadow-lg">
|
||
<div className="flex items-center gap-2">
|
||
<div className="animate-spin w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||
<span className="text-blue-600 text-sm font-medium">Generating wireframe with AI...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Save Status and Controls */}
|
||
<div className="absolute top-10 left-4 z-50 flex items-center gap-2">
|
||
{lastSaved && (
|
||
<div className="bg-green-50 border border-green-200 rounded-lg px-3 py-2 text-xs text-green-600">
|
||
Last saved: {lastSaved.toLocaleTimeString()}
|
||
</div>
|
||
)}
|
||
|
||
{/* Authentication Status */}
|
||
{isAuthenticated ? (
|
||
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2 flex items-center gap-2">
|
||
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||
Logged in as: {user?.username || 'User'}
|
||
</div>
|
||
|
||
<label className={`flex items-center gap-2 text-xs ${isAuthenticated ? 'text-gray-600' : 'text-gray-400'}`}>
|
||
<input
|
||
type="checkbox"
|
||
checked={autoSaveEnabled && isAuthenticated}
|
||
disabled={!isAuthenticated}
|
||
onChange={(e) => setAutoSaveEnabled(e.target.checked)}
|
||
className="w-3 h-3"
|
||
/>
|
||
Auto-save
|
||
</label>
|
||
|
||
<button
|
||
onClick={() => editorRef.current && saveWireframe(editorRef.current, false)}
|
||
disabled={!isAuthenticated}
|
||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||
isAuthenticated
|
||
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||
}`}
|
||
title={isAuthenticated ? "Save wireframe (Ctrl+S)" : "Please sign in to save wireframes"}
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg px-3 py-2 text-xs text-yellow-600 flex items-center gap-2">
|
||
<span>⚠️ Please log in to save wireframes</span>
|
||
<a
|
||
href="/signin"
|
||
className="bg-yellow-500 hover:bg-yellow-600 text-white px-2 py-1 rounded transition-colors text-xs"
|
||
>
|
||
Sign In
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1 min-h-0">
|
||
<Tldraw onMount={handleMount} />
|
||
</div>
|
||
</div>
|
||
)
|
||
} |