codenuk_frontend_mine/src/components/wireframe-canvas.tsx
2025-09-12 16:47:01 +05:30

1472 lines
53 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}