diff --git a/frontend/src/components/editor-uploads-panel.tsx b/frontend/src/components/editor-uploads-panel.tsx index ee8313b..773b4dd 100644 --- a/frontend/src/components/editor-uploads-panel.tsx +++ b/frontend/src/components/editor-uploads-panel.tsx @@ -1,18 +1,128 @@ -import { Cancel01Icon } from '@hugeicons/core-free-icons' +import { Cancel01Icon, CloudUploadIcon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' +import { useRef } from 'react' import { editorSidebarPanelLeftClass, editorSidebarPanelTopClass, } from '../lib/editor-sidebar-panel-layout' +import type { SceneSvg } from '../lib/avnac-scene' +import { useEditorStore } from './scene-editor/editor-store' type Props = { open: boolean onClose: () => void } +// SVGs are rendered exclusively via with a data URL, which sandboxes scripts +// and event handlers at the browser level. This sanitizer is defense-in-depth only — +// it removes common XSS vectors so that stored markup stays clean if the rendering +// approach ever changes. +function sanitizeSvgMarkup(markup: string): string { + return markup + .replace(//gi, '') + .replace(//gi, '') + .replace(/\bon\w+\s*=/gi, 'data-removed=') + .replace(/javascript:/gi, '') + .replace(/(]*\s(?:href|xlink:href)\s*=\s*["'])(https?:\/\/[^"']*)(["'])/gi, '$1#$3') +} + +// Returns null if the markup is not valid SVG. +function parseSvgNaturalSize(markup: string): { width: number; height: number } | null { + const parser = new DOMParser() + const doc = parser.parseFromString(markup, 'image/svg+xml') + if (doc.querySelector('parsererror')) return null + + const root = doc.documentElement + if (root.tagName.toLowerCase() !== 'svg') return null + const wAttr = root.getAttribute('width') + const hAttr = root.getAttribute('height') + const viewBox = root.getAttribute('viewBox') + + const numW = wAttr ? parseFloat(wAttr) : Number.NaN + const numH = hAttr ? parseFloat(hAttr) : Number.NaN + if (Number.isFinite(numW) && numW > 0 && Number.isFinite(numH) && numH > 0) { + return { width: numW, height: numH } + } + + if (viewBox) { + const parts = viewBox.trim().split(/[\s,]+/) + const vbW = parseFloat(parts[2] ?? '') + const vbH = parseFloat(parts[3] ?? '') + if (Number.isFinite(vbW) && vbW > 0 && Number.isFinite(vbH) && vbH > 0) { + return { width: vbW, height: vbH } + } + } + + return { width: 300, height: 300 } +} + export default function EditorUploadsPanel({ open, onClose }: Props) { + const fileInputRef = useRef(null) + const setDoc = useEditorStore(s => s.setDoc) + const doc = useEditorStore(s => s.doc) + const setSelectedIds = useEditorStore(s => s.setSelectedIds) + if (!open) return null + const artboardW = doc.artboard.width + const artboardH = doc.artboard.height + + const handleSvgFiles = async (files: FileList | null) => { + if (!files) return + const insertedIds: string[] = [] + + for (const file of Array.from(files)) { + if (!file.name.toLowerCase().endsWith('.svg') && file.type !== 'image/svg+xml') continue + + let rawMarkup: string + try { + rawMarkup = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result)) + reader.onerror = () => reject(reader.error) + reader.readAsText(file) + }) + } catch { + continue + } + + const markup = sanitizeSvgMarkup(rawMarkup) + const size = parseSvgNaturalSize(markup) + if (!size) continue + + const { width: naturalWidth, height: naturalHeight } = size + const maxEdge = 800 + const scale = Math.min(1, maxEdge / Math.max(naturalWidth, naturalHeight)) + const displayWidth = Math.max(1, Math.round(naturalWidth * scale)) + const displayHeight = Math.max(1, Math.round(naturalHeight * scale)) + + const obj: SceneSvg = { + id: crypto.randomUUID(), + type: 'svg', + x: Math.round(artboardW / 2 - displayWidth / 2), + y: Math.round(artboardH / 2 - displayHeight / 2), + width: displayWidth, + height: displayHeight, + rotation: 0, + opacity: 1, + visible: true, + locked: false, + blurPct: 0, + shadow: null, + markup, + naturalWidth, + naturalHeight, + } + + insertedIds.push(obj.id) + setDoc(prev => ({ ...prev, objects: [...prev.objects, obj] })) + } + + if (insertedIds.length > 0) { + setSelectedIds(insertedIds) + } + } + return (
-
Coming soon
+ +
+ { + void handleSvgFiles(e.target.files) + e.target.value = '' + }} + /> + +
) } diff --git a/frontend/src/components/scene-editor/object-view.tsx b/frontend/src/components/scene-editor/object-view.tsx index d5de34f..a5b640f 100644 --- a/frontend/src/components/scene-editor/object-view.tsx +++ b/frontend/src/components/scene-editor/object-view.tsx @@ -216,6 +216,26 @@ export function SceneObjectView({ ) } + if (obj.type === 'svg') { + return ( +
onObjectPointerDown(e, obj)} + {...hoverProps} + title={obj.locked ? 'Locked SVG' : undefined} + > + +
+ ) + } + if (obj.type === 'vector-board') { return (
(obj: T): T { ...base, crop: { ...obj.crop }, } as T + case 'svg': + return { ...base } as T case 'icon': return { ...base, @@ -1001,6 +1027,8 @@ export function objectDisplayName(obj: SceneObject): string { return obj.text.trim() || 'Text' case 'image': return 'Image' + case 'svg': + return 'SVG' case 'icon': return obj.iconName.replace(/Icon$/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2') || 'Icon' case 'vector-board':