my-multiplayer-app / client /utils /svgImport.ts
Jaimodiji's picture
Upload folder using huggingface_hub
88e8d49 verified
import { Editor, createShapeId, TLShapeId } from 'tldraw'
interface ParsedShape {
type: 'geo' | 'draw' | 'line' | 'text'
x: number
y: number
props: Record<string, any>
rotation?: number
}
// Map SVG colors to tldraw color names
function mapColorToTldraw(color: string | null): string {
if (!color || color === 'none') return 'black'
const colorMap: Record<string, string> = {
'#000000': 'black',
'#000': 'black',
'black': 'black',
'#ffffff': 'white',
'#fff': 'white',
'white': 'white',
'#ff0000': 'red',
'red': 'red',
'#00ff00': 'green',
'green': 'green',
'#0000ff': 'blue',
'blue': 'blue',
'#ffff00': 'yellow',
'yellow': 'yellow',
'#ffa500': 'orange',
'orange': 'orange',
'#800080': 'violet',
'purple': 'violet',
'violet': 'violet',
'#808080': 'grey',
'gray': 'grey',
'grey': 'grey',
}
return colorMap[color.toLowerCase()] || 'black'
}
// Map SVG fill to tldraw fill type
function mapFillType(fill: string | null): 'none' | 'solid' | 'semi' {
if (!fill || fill === 'none' || fill === 'transparent') return 'none'
return 'solid'
}
// Parse transform attribute to extract translate values
function parseTransform(transform: string | null): { x: number; y: number; rotation: number } {
const result = { x: 0, y: 0, rotation: 0 }
if (!transform) return result
// Parse translate(x, y) or translate(x y)
const translateMatch = transform.match(/translate\s*\(\s*([^,\s]+)[\s,]*([^)]*)\)/)
if (translateMatch) {
result.x = parseFloat(translateMatch[1]) || 0
result.y = parseFloat(translateMatch[2]) || 0
}
// Parse rotate(angle) or rotate(angle, cx, cy)
const rotateMatch = transform.match(/rotate\s*\(\s*([^,)\s]+)/)
if (rotateMatch) {
result.rotation = (parseFloat(rotateMatch[1]) || 0) * (Math.PI / 180)
}
return result
}
// Parse an SVG path d attribute into segments for TLDrawShape
function parsePathToSegments(d: string): { type: 'line' | 'free'; points: { x: number; y: number }[] }[] {
const segments: { type: 'free'; points: { x: number; y: number }[] }[] = []
const points: { x: number; y: number }[] = []
// Simplified path parsing - handles M, L, C, Q, Z commands
const commands = d.match(/[MLHVCSQTAZmlhvcsqtaz][^MLHVCSQTAZmlhvcsqtaz]*/g) || []
let currentX = 0
let currentY = 0
let startX = 0
let startY = 0
for (const cmd of commands) {
const type = cmd[0]
const args = cmd.slice(1).trim().split(/[\s,]+/).map(parseFloat).filter(n => !isNaN(n))
switch (type) {
case 'M': // Move to absolute
currentX = args[0] || 0
currentY = args[1] || 0
startX = currentX
startY = currentY
points.push({ x: currentX, y: currentY })
break
case 'm': // Move to relative
currentX += args[0] || 0
currentY += args[1] || 0
startX = currentX
startY = currentY
points.push({ x: currentX, y: currentY })
break
case 'L': // Line to absolute
for (let i = 0; i < args.length; i += 2) {
currentX = args[i]
currentY = args[i + 1]
points.push({ x: currentX, y: currentY })
}
break
case 'l': // Line to relative
for (let i = 0; i < args.length; i += 2) {
currentX += args[i]
currentY += args[i + 1]
points.push({ x: currentX, y: currentY })
}
break
case 'H': // Horizontal line absolute
currentX = args[0]
points.push({ x: currentX, y: currentY })
break
case 'h': // Horizontal line relative
currentX += args[0]
points.push({ x: currentX, y: currentY })
break
case 'V': // Vertical line absolute
currentY = args[0]
points.push({ x: currentX, y: currentY })
break
case 'v': // Vertical line relative
currentY += args[0]
points.push({ x: currentX, y: currentY })
break
case 'C': // Cubic bezier absolute - sample points along curve
for (let i = 0; i < args.length; i += 6) {
// Add intermediate points for smoother curve
const x1 = args[i], y1 = args[i + 1]
const x2 = args[i + 2], y2 = args[i + 3]
const x3 = args[i + 4], y3 = args[i + 5]
for (let t = 0.25; t <= 1; t += 0.25) {
const px = Math.pow(1 - t, 3) * currentX + 3 * Math.pow(1 - t, 2) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t * x3
const py = Math.pow(1 - t, 3) * currentY + 3 * Math.pow(1 - t, 2) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t * y3
points.push({ x: px, y: py })
}
currentX = x3
currentY = y3
}
break
case 'c': // Cubic bezier relative
for (let i = 0; i < args.length; i += 6) {
const x1 = currentX + args[i], y1 = currentY + args[i + 1]
const x2 = currentX + args[i + 2], y2 = currentY + args[i + 3]
const x3 = currentX + args[i + 4], y3 = currentY + args[i + 5]
const sx = currentX, sy = currentY
for (let t = 0.25; t <= 1; t += 0.25) {
const px = Math.pow(1 - t, 3) * sx + 3 * Math.pow(1 - t, 2) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t * x3
const py = Math.pow(1 - t, 3) * sy + 3 * Math.pow(1 - t, 2) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t * y3
points.push({ x: px, y: py })
}
currentX = x3
currentY = y3
}
break
case 'Q': // Quadratic bezier absolute
for (let i = 0; i < args.length; i += 4) {
const x1 = args[i], y1 = args[i + 1]
const x2 = args[i + 2], y2 = args[i + 3]
for (let t = 0.25; t <= 1; t += 0.25) {
const px = Math.pow(1 - t, 2) * currentX + 2 * (1 - t) * t * x1 + t * t * x2
const py = Math.pow(1 - t, 2) * currentY + 2 * (1 - t) * t * y1 + t * t * y2
points.push({ x: px, y: py })
}
currentX = x2
currentY = y2
}
break
case 'q': // Quadratic bezier relative
for (let i = 0; i < args.length; i += 4) {
const x1 = currentX + args[i], y1 = currentY + args[i + 1]
const x2 = currentX + args[i + 2], y2 = currentY + args[i + 3]
const sx = currentX, sy = currentY
for (let t = 0.25; t <= 1; t += 0.25) {
const px = Math.pow(1 - t, 2) * sx + 2 * (1 - t) * t * x1 + t * t * x2
const py = Math.pow(1 - t, 2) * sy + 2 * (1 - t) * t * y1 + t * t * y2
points.push({ x: px, y: py })
}
currentX = x2
currentY = y2
}
break
case 'Z':
case 'z':
if (startX !== currentX || startY !== currentY) {
points.push({ x: startX, y: startY })
}
currentX = startX
currentY = startY
break
default:
// For unsupported commands, just move to end point if present
if (args.length >= 2) {
currentX = type === type.toUpperCase() ? args[args.length - 2] : currentX + args[args.length - 2]
currentY = type === type.toUpperCase() ? args[args.length - 1] : currentY + args[args.length - 1]
points.push({ x: currentX, y: currentY })
}
}
}
if (points.length > 0) {
segments.push({ type: 'free', points })
}
return segments
}
// Parse rect element
function parseRect(el: SVGRectElement, baseTransform: { x: number; y: number; rotation: number }): ParsedShape {
const x = parseFloat(el.getAttribute('x') || '0') + baseTransform.x
const y = parseFloat(el.getAttribute('y') || '0') + baseTransform.y
const width = parseFloat(el.getAttribute('width') || '100')
const height = parseFloat(el.getAttribute('height') || '100')
const fill = el.getAttribute('fill')
const stroke = el.getAttribute('stroke')
return {
type: 'geo',
x,
y,
rotation: baseTransform.rotation,
props: {
geo: 'rectangle',
w: width,
h: height,
color: mapColorToTldraw(stroke || fill),
fill: mapFillType(fill),
}
}
}
// Parse circle element
function parseCircle(el: SVGCircleElement, baseTransform: { x: number; y: number; rotation: number }): ParsedShape {
const cx = parseFloat(el.getAttribute('cx') || '0')
const cy = parseFloat(el.getAttribute('cy') || '0')
const r = parseFloat(el.getAttribute('r') || '50')
const fill = el.getAttribute('fill')
const stroke = el.getAttribute('stroke')
return {
type: 'geo',
x: cx - r + baseTransform.x,
y: cy - r + baseTransform.y,
rotation: baseTransform.rotation,
props: {
geo: 'ellipse',
w: r * 2,
h: r * 2,
color: mapColorToTldraw(stroke || fill),
fill: mapFillType(fill),
}
}
}
// Parse ellipse element
function parseEllipse(el: SVGEllipseElement, baseTransform: { x: number; y: number; rotation: number }): ParsedShape {
const cx = parseFloat(el.getAttribute('cx') || '0')
const cy = parseFloat(el.getAttribute('cy') || '0')
const rx = parseFloat(el.getAttribute('rx') || '50')
const ry = parseFloat(el.getAttribute('ry') || '50')
const fill = el.getAttribute('fill')
const stroke = el.getAttribute('stroke')
return {
type: 'geo',
x: cx - rx + baseTransform.x,
y: cy - ry + baseTransform.y,
rotation: baseTransform.rotation,
props: {
geo: 'ellipse',
w: rx * 2,
h: ry * 2,
color: mapColorToTldraw(stroke || fill),
fill: mapFillType(fill),
}
}
}
// Parse line element
function parseLine(el: SVGLineElement, baseTransform: { x: number; y: number; rotation: number }): ParsedShape {
const x1 = parseFloat(el.getAttribute('x1') || '0') + baseTransform.x
const y1 = parseFloat(el.getAttribute('y1') || '0') + baseTransform.y
const x2 = parseFloat(el.getAttribute('x2') || '100') + baseTransform.x
const y2 = parseFloat(el.getAttribute('y2') || '100') + baseTransform.y
const stroke = el.getAttribute('stroke')
return {
type: 'line',
x: Math.min(x1, x2),
y: Math.min(y1, y2),
rotation: baseTransform.rotation,
props: {
color: mapColorToTldraw(stroke),
points: {
a1: { id: 'a1', index: 'a1', x: x1 - Math.min(x1, x2), y: y1 - Math.min(y1, y2) },
a2: { id: 'a2', index: 'a2', x: x2 - Math.min(x1, x2), y: y2 - Math.min(y1, y2) },
}
}
}
}
// Parse text element
function parseText(el: SVGTextElement, baseTransform: { x: number; y: number; rotation: number }): ParsedShape {
const x = parseFloat(el.getAttribute('x') || '0') + baseTransform.x
const y = parseFloat(el.getAttribute('y') || '0') + baseTransform.y
const text = el.textContent || ''
const fill = el.getAttribute('fill')
const fontSize = parseFloat(el.getAttribute('font-size') || '16')
// Map font size to tldraw size
let size: 's' | 'm' | 'l' | 'xl' = 'm'
if (fontSize <= 12) size = 's'
else if (fontSize <= 18) size = 'm'
else if (fontSize <= 28) size = 'l'
else size = 'xl'
return {
type: 'text',
x,
y: y - fontSize, // Adjust for baseline
rotation: baseTransform.rotation,
props: {
text,
color: mapColorToTldraw(fill),
size,
autoSize: true,
}
}
}
// Parse path element
function parsePath(el: SVGPathElement, baseTransform: { x: number; y: number; rotation: number }): ParsedShape | null {
const d = el.getAttribute('d')
if (!d) return null
const segments = parsePathToSegments(d)
if (segments.length === 0 || segments[0].points.length === 0) return null
const stroke = el.getAttribute('stroke')
const strokeWidth = parseFloat(el.getAttribute('stroke-width') || '2')
// Calculate bounds
let minX = Infinity, minY = Infinity
for (const seg of segments) {
for (const pt of seg.points) {
minX = Math.min(minX, pt.x)
minY = Math.min(minY, pt.y)
}
}
// Normalize points relative to shape origin
const normalizedSegments = segments.map(seg => ({
...seg,
points: seg.points.map(pt => ({
x: pt.x - minX,
y: pt.y - minY,
z: 0.5 // Pressure
}))
}))
// Map stroke width to tldraw size
let size: 's' | 'm' | 'l' | 'xl' = 'm'
if (strokeWidth <= 1) size = 's'
else if (strokeWidth <= 3) size = 'm'
else if (strokeWidth <= 6) size = 'l'
else size = 'xl'
return {
type: 'draw',
x: minX + baseTransform.x,
y: minY + baseTransform.y,
rotation: baseTransform.rotation,
props: {
color: mapColorToTldraw(stroke),
size,
segments: normalizedSegments,
isComplete: true,
isClosed: false,
isPen: false,
}
}
}
// Process SVG element and children recursively
function processElement(
el: Element,
shapes: ParsedShape[],
parentTransform: { x: number; y: number; rotation: number }
): void {
const localTransform = parseTransform(el.getAttribute('transform'))
const combinedTransform = {
x: parentTransform.x + localTransform.x,
y: parentTransform.y + localTransform.y,
rotation: parentTransform.rotation + localTransform.rotation,
}
const tagName = el.tagName.toLowerCase()
switch (tagName) {
case 'rect':
shapes.push(parseRect(el as SVGRectElement, combinedTransform))
break
case 'circle':
shapes.push(parseCircle(el as SVGCircleElement, combinedTransform))
break
case 'ellipse':
shapes.push(parseEllipse(el as SVGEllipseElement, combinedTransform))
break
case 'line':
shapes.push(parseLine(el as SVGLineElement, combinedTransform))
break
case 'text':
shapes.push(parseText(el as SVGTextElement, combinedTransform))
break
case 'path': {
const pathShape = parsePath(el as SVGPathElement, combinedTransform)
if (pathShape) shapes.push(pathShape)
break
}
case 'g':
case 'svg':
// Process children
for (const child of Array.from(el.children)) {
processElement(child, shapes, combinedTransform)
}
break
case 'polygon':
case 'polyline':
case 'image':
case 'use':
case 'clippath':
case 'mask':
case 'defs':
case 'style':
case 'lineargradient':
case 'radialgradient':
case 'pattern':
console.warn(`[SVG Import] Unsupported element: <${tagName}>`)
break
default:
// Skip metadata elements silently
if (!['title', 'desc', 'metadata'].includes(tagName)) {
console.warn(`[SVG Import] Unknown element: <${tagName}>`)
}
}
}
/**
* Parse SVG string and import shapes into tldraw editor
*/
export function importSvgToEditor(editor: Editor, svgString: string): TLShapeId[] {
const parser = new DOMParser()
const doc = parser.parseFromString(svgString, 'image/svg+xml')
// Check for parse errors
const parseError = doc.querySelector('parsererror')
if (parseError) {
console.error('[SVG Import] Parse error:', parseError.textContent)
throw new Error('Invalid SVG file')
}
const svgEl = doc.querySelector('svg')
if (!svgEl) {
throw new Error('No SVG element found')
}
const shapes: ParsedShape[] = []
// Get viewBox for potential offset
const viewBox = svgEl.getAttribute('viewBox')
let viewBoxOffset = { x: 0, y: 0 }
if (viewBox) {
const [vbX, vbY] = viewBox.split(/[\s,]+/).map(parseFloat)
viewBoxOffset = { x: -vbX || 0, y: -vbY || 0 }
}
// Process all child elements
for (const child of Array.from(svgEl.children)) {
processElement(child, shapes, { x: viewBoxOffset.x, y: viewBoxOffset.y, rotation: 0 })
}
if (shapes.length === 0) {
console.warn('[SVG Import] No supported shapes found in SVG')
return []
}
// Get center of viewport for placement
const viewportCenter = editor.getViewportPageBounds().center
// Calculate bounds of all shapes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const shape of shapes) {
minX = Math.min(minX, shape.x)
minY = Math.min(minY, shape.y)
const w = shape.props.w || 100
const h = shape.props.h || 100
maxX = Math.max(maxX, shape.x + w)
maxY = Math.max(maxY, shape.y + h)
}
// Offset to center shapes in viewport
const offsetX = viewportCenter.x - (minX + maxX) / 2
const offsetY = viewportCenter.y - (minY + maxY) / 2
// Create shapes in tldraw
const createdIds: TLShapeId[] = []
for (const shape of shapes) {
const id = createShapeId()
createdIds.push(id)
editor.createShape({
id,
type: shape.type,
x: shape.x + offsetX,
y: shape.y + offsetY,
rotation: shape.rotation || 0,
props: shape.props,
})
}
// Select all created shapes
if (createdIds.length > 0) {
editor.select(...createdIds)
}
console.log(`[SVG Import] Created ${createdIds.length} shapes`)
return createdIds
}
/**
* Open file picker and import SVG
*/
export function triggerSvgImport(editor: Editor): void {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.svg,image/svg+xml'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const svgString = await file.text()
const ids = importSvgToEditor(editor, svgString)
if (ids.length === 0) {
alert('No supported shapes found in SVG file.\n\nSupported: rect, circle, ellipse, line, path, text')
}
} catch (err) {
console.error('[SVG Import] Error:', err)
alert('Failed to import SVG: ' + (err as Error).message)
}
}
input.click()
}