Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 134 additions & 2 deletions frontend/src/components/editor-uploads-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 <img> 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(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, '')
.replace(/\bon\w+\s*=/gi, 'data-removed=')
.replace(/javascript:/gi, '')
.replace(/(<use\b[^>]*\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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

const root = doc.documentElement
if (root.tagName.toLowerCase() !== 'svg') return null
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Strictly comparing tagName to 'svg' rejects valid namespace-prefixed SVG roots (e.g. svg:svg), causing imported SVGs to be silently skipped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/components/editor-uploads-panel.tsx, line 36:

<comment>Strictly comparing `tagName` to `'svg'` rejects valid namespace-prefixed SVG roots (e.g. `svg:svg`), causing imported SVGs to be silently skipped.</comment>

<file context>
@@ -33,6 +33,7 @@ function parseSvgNaturalSize(markup: string): { width: number; height: number }
   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')
</file context>
Suggested change
if (root.tagName.toLowerCase() !== 'svg') return null
if (root.localName.toLowerCase() !== 'svg' || root.namespaceURI !== 'http://www.w3.org/2000/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<HTMLInputElement>(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<string>((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 (
<div
data-avnac-chrome
Expand All @@ -35,7 +145,29 @@ export default function EditorUploadsPanel({ open, onClose }: Props) {
<HugeiconsIcon icon={Cancel01Icon} size={18} strokeWidth={1.75} />
</button>
</div>
<div className="px-3 py-8 text-center text-sm text-neutral-500">Coming soon</div>

<div className="p-3">
<input
ref={fileInputRef}
type="file"
accept=".svg,image/svg+xml"
className="hidden"
multiple
onChange={e => {
void handleSvgFiles(e.target.files)
e.target.value = ''
}}
/>
<button
type="button"
className="flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-black/[0.12] px-4 py-6 text-center text-sm text-neutral-500 hover:border-black/[0.2] hover:bg-black/[0.02]"
onClick={() => fileInputRef.current?.click()}
>
<HugeiconsIcon icon={CloudUploadIcon} size={28} strokeWidth={1.5} />
<span>Upload SVG file</span>
<span className="text-xs text-neutral-400">Click to browse</span>
</button>
</div>
</div>
)
}
20 changes: 20 additions & 0 deletions frontend/src/components/scene-editor/object-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,26 @@ export function SceneObjectView({
)
}

if (obj.type === 'svg') {
return (
<div
style={style}
data-avnac-scene-object
onPointerDown={e => onObjectPointerDown(e, obj)}
{...hoverProps}
title={obj.locked ? 'Locked SVG' : undefined}
>
<img
src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(obj.markup)}`}
alt=""
draggable={false}
className="pointer-events-none block h-full w-full select-none"
style={{ objectFit: 'fill' }}
/>
</div>
)
}

if (obj.type === 'vector-board') {
return (
<div
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/avnac-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
type SceneGroup,
type SceneIcon,
type SceneImage,
type SceneSvg,
type SceneLine,
type SceneObject,
type ScenePolygon,
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/avnac-scene-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ export async function loadSceneImageElement(rawUrl: string): Promise<HTMLImageEl
}
}

function svgMarkupToDataUrl(markup: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`
}

function applyShadow(ctx: CanvasRenderingContext2D, obj: SceneObject) {
if (!obj.shadow) {
ctx.shadowColor = 'transparent'
Expand Down Expand Up @@ -727,6 +731,11 @@ async function drawSceneObject(
ctx.drawImage(img, iconBox.x, iconBox.y, iconBox.width, iconBox.height)
break
}
case 'svg': {
const img = await loadSceneImageElement(svgMarkupToDataUrl(obj.markup))
ctx.drawImage(img, 0, 0, obj.width, obj.height)
break
}
case 'vector-board': {
const doc = vectorBoardDocs[obj.boardId]
if (doc) {
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/lib/avnac-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type SceneObjectType =
| 'arrow'
| 'text'
| 'image'
| 'svg'
| 'icon'
| 'vector-board'
| 'group'
Expand Down Expand Up @@ -117,6 +118,13 @@ export type SceneImage = SceneObjectBase & {
cornerRadius: number
}

export type SceneSvg = SceneObjectBase & {
type: 'svg'
markup: string
naturalWidth: number
naturalHeight: number
}

export type SceneIcon = SceneObjectBase & {
type: 'icon'
iconName: string
Expand Down Expand Up @@ -144,6 +152,7 @@ export type SceneObject =
| SceneArrow
| SceneText
| SceneImage
| SceneSvg
| SceneIcon
| SceneVectorBoard
| SceneGroup
Expand Down Expand Up @@ -469,6 +478,21 @@ function parseSceneObject(raw: unknown): SceneObject | null {
cornerRadius: typeof obj.cornerRadius === 'number' ? Math.max(0, obj.cornerRadius) : 0,
}
}
if (type === 'svg') {
return {
...baseObjectFromUnknown(obj, 'svg'),
type: 'svg' as const,
markup: typeof obj.markup === 'string' ? obj.markup : '',
naturalWidth:
typeof obj.naturalWidth === 'number' && Number.isFinite(obj.naturalWidth)
? Math.max(1, obj.naturalWidth)
: 100,
naturalHeight:
typeof obj.naturalHeight === 'number' && Number.isFinite(obj.naturalHeight)
? Math.max(1, obj.naturalHeight)
: 100,
}
}
if (type === 'icon') {
const svg = normalizeIconSvg(obj.svg)
if (!svg) return null
Expand Down Expand Up @@ -954,6 +978,8 @@ export function cloneSceneObject<T extends SceneObject>(obj: T): T {
...base,
crop: { ...obj.crop },
} as T
case 'svg':
return { ...base } as T
case 'icon':
return {
...base,
Expand Down Expand Up @@ -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':
Expand Down