mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
feat(chat): enhance chat streaming with 'thinking' state and improve SVG handling in toolbar
- Add support for 'thinking' state in chat message streaming to indicate processing. - Update toolbar to parse SVG files into editable nodes, allowing for better integration of SVG graphics. - Refactor image handling to accommodate both SVG and raster images, ensuring proper placement and scaling on the canvas. - Improve error handling and user feedback during file uploads.
This commit is contained in:
parent
1950f8f618
commit
a4e0934aa8
7 changed files with 486 additions and 46 deletions
|
|
@ -53,12 +53,14 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str
|
|||
})
|
||||
|
||||
for await (const ev of messageStream) {
|
||||
if (
|
||||
ev.type === 'content_block_delta' &&
|
||||
ev.delta.type === 'text_delta'
|
||||
) {
|
||||
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
if (ev.type === 'content_block_delta') {
|
||||
if (ev.delta.type === 'text_delta') {
|
||||
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
} else if (ev.delta.type === 'thinking_delta') {
|
||||
const data = JSON.stringify({ type: 'thinking', content: '' })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,12 +115,14 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
|
|||
for await (const message of q) {
|
||||
if (message.type === 'stream_event') {
|
||||
const ev = message.event
|
||||
if (
|
||||
ev.type === 'content_block_delta' &&
|
||||
ev.delta.type === 'text_delta'
|
||||
) {
|
||||
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
if (ev.type === 'content_block_delta') {
|
||||
if (ev.delta.type === 'text_delta') {
|
||||
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
} else if (ev.delta.type === 'thinking_delta') {
|
||||
const data = JSON.stringify({ type: 'thinking', content: '' })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
}
|
||||
}
|
||||
} else if (message.type === 'result') {
|
||||
if (message.subtype !== 'success') {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import ToolButton from './tool-button'
|
||||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { useDocumentStore, generateId } from '@/stores/document-store'
|
||||
import { parseSvgToNodes } from '@/utils/svg-parser'
|
||||
import { useHistoryStore } from '@/stores/history-store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
|
@ -66,43 +67,71 @@ export default function Toolbar() {
|
|||
// Reset input so the same file can be re-selected
|
||||
e.target.value = ''
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const isSvg = file.type === 'image/svg+xml'
|
||||
|
||||
if (isSvg) {
|
||||
// SVG → parse into editable path/shape nodes
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const svgText = reader.result as string
|
||||
const nodes = parseSvgToNodes(svgText)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const { viewport, fabricCanvas } = useCanvasStore.getState()
|
||||
// Place image at center of current viewport
|
||||
const canvasEl = fabricCanvas?.getElement()
|
||||
const canvasW = canvasEl?.clientWidth ?? 800
|
||||
const canvasH = canvasEl?.clientHeight ?? 600
|
||||
const centerX = (-viewport.panX + canvasW / 2) / viewport.zoom
|
||||
const centerY = (-viewport.panY + canvasH / 2) / viewport.zoom
|
||||
|
||||
// Scale down large images to fit reasonably on canvas
|
||||
let w = img.naturalWidth
|
||||
let h = img.naturalHeight
|
||||
const maxDim = 400
|
||||
if (w > maxDim || h > maxDim) {
|
||||
const scale = maxDim / Math.max(w, h)
|
||||
w = Math.round(w * scale)
|
||||
h = Math.round(h * scale)
|
||||
for (const node of nodes) {
|
||||
const w = ('width' in node ? (typeof node.width === 'number' ? node.width : 100) : 100)
|
||||
const h = ('height' in node ? (typeof node.height === 'number' ? node.height : 100) : 100)
|
||||
node.x = centerX - w / 2
|
||||
node.y = centerY - h / 2
|
||||
node.name = file.name.replace(/\.[^.]+$/, '')
|
||||
useDocumentStore.getState().addNode(null, node)
|
||||
}
|
||||
|
||||
useDocumentStore.getState().addNode(null, {
|
||||
id: generateId(),
|
||||
type: 'image',
|
||||
name: file.name.replace(/\.[^.]+$/, ''),
|
||||
src: dataUrl,
|
||||
x: centerX - w / 2,
|
||||
y: centerY - h / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
})
|
||||
}
|
||||
img.src = dataUrl
|
||||
reader.readAsText(file)
|
||||
} else {
|
||||
// Raster image → ImageNode with data URL
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const { viewport, fabricCanvas } = useCanvasStore.getState()
|
||||
const canvasEl = fabricCanvas?.getElement()
|
||||
const canvasW = canvasEl?.clientWidth ?? 800
|
||||
const canvasH = canvasEl?.clientHeight ?? 600
|
||||
const centerX = (-viewport.panX + canvasW / 2) / viewport.zoom
|
||||
const centerY = (-viewport.panY + canvasH / 2) / viewport.zoom
|
||||
|
||||
let w = img.naturalWidth
|
||||
let h = img.naturalHeight
|
||||
const maxDim = 400
|
||||
if (w > maxDim || h > maxDim) {
|
||||
const scale = maxDim / Math.max(w, h)
|
||||
w = Math.round(w * scale)
|
||||
h = Math.round(h * scale)
|
||||
}
|
||||
|
||||
useDocumentStore.getState().addNode(null, {
|
||||
id: generateId(),
|
||||
type: 'image',
|
||||
name: file.name.replace(/\.[^.]+$/, ''),
|
||||
src: dataUrl,
|
||||
x: centerX - w / 2,
|
||||
y: centerY - h / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
})
|
||||
}
|
||||
img.src = dataUrl
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ function useChatHandlers() {
|
|||
if (chunk.type === 'text') {
|
||||
accumulated += chunk.content
|
||||
updateLastMessage(accumulated)
|
||||
} else if (chunk.type === 'thinking') {
|
||||
// Model is in extended thinking phase — SSE heartbeat, no display update needed
|
||||
} else if (chunk.type === 'error') {
|
||||
accumulated += `\n\n**Error:** ${chunk.content}`
|
||||
updateLastMessage(accumulated)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
} from '@tanstack/react-router'
|
||||
|
|
@ -27,9 +28,19 @@ export const Route = createRootRoute({
|
|||
},
|
||||
],
|
||||
}),
|
||||
component: RootComponent,
|
||||
notFoundComponent: () => (
|
||||
<div className="min-h-screen flex items-center justify-center text-muted-foreground">
|
||||
<p>Page not found</p>
|
||||
</div>
|
||||
),
|
||||
shellComponent: RootDocument,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ PenNode types (the ONLY format you output for designs):
|
|||
- rectangle: Props: width, height, cornerRadius, fill, stroke, effects
|
||||
- ellipse: Props: width, height, fill, stroke, effects
|
||||
- text: Props: content (string), fontFamily, fontSize, fontWeight, fill, width, height, textAlign
|
||||
- path: SVG icon/shape. Props: d (SVG path string), width, height, fill, stroke, effects
|
||||
- image: Raster image. Props: src (URL string), width, height, cornerRadius, effects
|
||||
|
||||
All nodes share: id (string), type, name, x, y, rotation, opacity
|
||||
|
||||
|
|
@ -23,11 +25,16 @@ RULES:
|
|||
const DESIGN_EXAMPLES = `
|
||||
EXAMPLES:
|
||||
|
||||
Button:
|
||||
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 160, "height": 44, "cornerRadius": 8, "layout": "horizontal", "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-text", "type": "text", "name": "Label", "content": "Click Me", "fontSize": 16, "fontWeight": 600, "width": 80, "height": 22, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
|
||||
Button with icon:
|
||||
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 180, "height": 44, "cornerRadius": 8, "layout": "horizontal", "gap": 8, "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowIcon", "d": "M5 12h14M12 5l7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "width": 80, "height": 22, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
|
||||
|
||||
Card:
|
||||
{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 200, "cornerRadius": 12, "layout": "vertical", "padding": 20, "gap": 12, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "width": 280, "height": 28, "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "width": 280, "height": 20, "fill": [{ "type": "solid", "color": "#6B7280" }] }] }
|
||||
Card with image:
|
||||
{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 340, "cornerRadius": 12, "layout": "vertical", "gap": 0, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-img", "type": "image", "name": "Cover", "src": "https://picsum.photos/320/180", "width": 320, "height": 180 }, { "id": "card-body", "type": "frame", "name": "Body", "width": 320, "height": 140, "layout": "vertical", "padding": 20, "gap": 8, "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "width": 280, "height": 28, "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "width": 280, "height": 20, "fill": [{ "type": "solid", "color": "#6B7280" }] }] }] }
|
||||
|
||||
ICONS & IMAGES:
|
||||
- Icons: Use "path" nodes with Lucide-style SVG d attribute (24x24 viewBox). Use stroke for line icons, fill for solid icons. Size 16-24px.
|
||||
- Images: Use "image" nodes. src = "https://picsum.photos/{width}/{height}" for placeholders. Set explicit width/height.
|
||||
- You know many Lucide icon SVG paths — use them freely. Always give icon nodes descriptive names.
|
||||
`
|
||||
|
||||
export const CHAT_SYSTEM_PROMPT = `You are a design assistant for OpenPencil, a vector design tool that renders PenNode JSON on a canvas.
|
||||
|
|
@ -58,7 +65,10 @@ DESIGN GUIDELINES:
|
|||
- Text: titles 22-28px bold, body 14-16px, captions 12px
|
||||
- Buttons: height 44-48px, cornerRadius 8-12
|
||||
- Inputs: height 44px, light bg, subtle border
|
||||
- Consistent color palette`
|
||||
- Consistent color palette
|
||||
- Use path nodes for icons (SVG d path data, Lucide-style 24x24 viewBox). Size icons 16-24px in UI elements
|
||||
- Use image nodes for photos/illustrations with picsum.photos placeholder URLs
|
||||
- Buttons, nav items, and list items should include icons when appropriate for better UX`
|
||||
|
||||
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON generation engine. Your ONLY job is to convert design descriptions into PenNode JSON.
|
||||
|
||||
|
|
@ -88,7 +98,13 @@ SIZING:
|
|||
- Use unique descriptive IDs
|
||||
- All colors as fill arrays: [{ "type": "solid", "color": "#hex" }]
|
||||
|
||||
Design like a professional: visual hierarchy, contrast, whitespace, consistent palette.`
|
||||
ICONS & IMAGES:
|
||||
- Use "path" nodes for icons: provide SVG d attribute, set width/height (16-24px for UI icons), use stroke for line icons or fill for solid icons
|
||||
- Use "image" nodes for photos/illustrations: set src to "https://picsum.photos/{width}/{height}" as placeholder, set explicit width/height
|
||||
- Include icons in buttons, nav items, list items, cards for professional polish
|
||||
- Reference the icon patterns in the examples section for common icons
|
||||
|
||||
Design like a professional: visual hierarchy, contrast, whitespace, consistent palette, purposeful iconography.`
|
||||
|
||||
export const CODE_GENERATOR_PROMPT = `You are a code generation engine for OpenPencil. Convert PenNode design descriptions into clean, production-ready code.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,6 @@ export interface AICodeRequest {
|
|||
}
|
||||
|
||||
export interface AIStreamChunk {
|
||||
type: 'text' | 'done' | 'error'
|
||||
type: 'text' | 'thinking' | 'done' | 'error'
|
||||
content: string
|
||||
}
|
||||
|
|
|
|||
378
src/utils/svg-parser.ts
Normal file
378
src/utils/svg-parser.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import type { PenNode } from '@/types/pen'
|
||||
import type { PenFill, PenStroke } from '@/types/styles'
|
||||
import { generateId } from '@/stores/document-store'
|
||||
|
||||
/** Inherited style context passed from parent SVG/g elements */
|
||||
interface StyleCtx {
|
||||
fill: string | null
|
||||
stroke: string | null
|
||||
strokeWidth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SVG string into editable PenNode array.
|
||||
* Handles fill/stroke inheritance, path coordinate scaling, and viewBox transforms.
|
||||
*/
|
||||
export function parseSvgToNodes(
|
||||
svgText: string,
|
||||
maxDim = 400,
|
||||
): PenNode[] {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(svgText, 'image/svg+xml')
|
||||
const svg = doc.querySelector('svg')
|
||||
if (!svg) return []
|
||||
|
||||
// Resolve source dimensions from viewBox or width/height
|
||||
const vb = svg.getAttribute('viewBox')?.split(/[\s,]+/).map(Number)
|
||||
const svgW = parseFloat(svg.getAttribute('width') ?? '') || vb?.[2] || 100
|
||||
const svgH = parseFloat(svg.getAttribute('height') ?? '') || vb?.[3] || 100
|
||||
|
||||
// Compute scale to fit within maxDim
|
||||
const scale =
|
||||
svgW > maxDim || svgH > maxDim ? maxDim / Math.max(svgW, svgH) : 1
|
||||
|
||||
// Build inherited style context from <svg> attributes
|
||||
const rootCtx: StyleCtx = {
|
||||
fill: svg.getAttribute('fill'),
|
||||
stroke: svg.getAttribute('stroke'),
|
||||
strokeWidth: parseFloat(svg.getAttribute('stroke-width') ?? '') || 1,
|
||||
}
|
||||
|
||||
const nodes = parseChildren(svg, scale, rootCtx)
|
||||
|
||||
if (nodes.length === 0) return []
|
||||
if (nodes.length === 1) return nodes
|
||||
|
||||
// Wrap multiple elements in a frame
|
||||
return [
|
||||
{
|
||||
id: generateId(),
|
||||
type: 'frame',
|
||||
name: 'SVG',
|
||||
width: Math.round(svgW * scale),
|
||||
height: Math.round(svgH * scale),
|
||||
layout: 'none' as const,
|
||||
children: nodes,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKIP_TAGS = new Set([
|
||||
'defs', 'style', 'title', 'desc', 'metadata',
|
||||
'clippath', 'mask', 'filter', 'lineargradient', 'radialgradient',
|
||||
'symbol', 'marker', 'pattern',
|
||||
])
|
||||
|
||||
function parseChildren(
|
||||
parent: Element,
|
||||
scale: number,
|
||||
ctx: StyleCtx,
|
||||
): PenNode[] {
|
||||
const nodes: PenNode[] = []
|
||||
for (const el of parent.children) {
|
||||
const node = parseElement(el, scale, ctx)
|
||||
if (node) nodes.push(node)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
function parseElement(
|
||||
el: Element,
|
||||
scale: number,
|
||||
parentCtx: StyleCtx,
|
||||
): PenNode | null {
|
||||
const tag = el.tagName.toLowerCase()
|
||||
if (SKIP_TAGS.has(tag)) return null
|
||||
|
||||
// Merge this element's style context with parent
|
||||
const ctx = mergeStyleCtx(parentCtx, el)
|
||||
|
||||
// Handle <g> by recursing with inherited styles
|
||||
if (tag === 'g') {
|
||||
const children = parseChildren(el, scale, ctx)
|
||||
if (children.length === 0) return null
|
||||
if (children.length === 1) return children[0]
|
||||
return {
|
||||
id: generateId(),
|
||||
type: 'frame',
|
||||
name: el.getAttribute('id') ?? 'Group',
|
||||
layout: 'none' as const,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve fill & stroke from element + inherited context
|
||||
const fill = resolveFill(el, ctx)
|
||||
const stroke = resolveStroke(el, ctx, scale)
|
||||
const opacity = parseFloat(getAttr(el, 'opacity') ?? '1')
|
||||
|
||||
const base = {
|
||||
id: generateId(),
|
||||
opacity: opacity !== 1 ? opacity : undefined,
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case 'path': {
|
||||
const d = el.getAttribute('d')
|
||||
if (!d) return null
|
||||
const scaledD = scaleSvgPath(d, scale)
|
||||
const bbox = getPathBBox(scaledD)
|
||||
return {
|
||||
...base,
|
||||
type: 'path' as const,
|
||||
name: el.getAttribute('id') ?? 'Path',
|
||||
d: scaledD,
|
||||
x: bbox.x,
|
||||
y: bbox.y,
|
||||
width: Math.round(bbox.w),
|
||||
height: Math.round(bbox.h),
|
||||
fill,
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
case 'rect': {
|
||||
const x = num(el, 'x') * scale
|
||||
const y = num(el, 'y') * scale
|
||||
const w = num(el, 'width') * scale
|
||||
const h = num(el, 'height') * scale
|
||||
const rx = num(el, 'rx') * scale
|
||||
return {
|
||||
...base,
|
||||
type: 'rectangle' as const,
|
||||
name: el.getAttribute('id') ?? 'Rectangle',
|
||||
x, y,
|
||||
width: Math.round(w),
|
||||
height: Math.round(h),
|
||||
cornerRadius: rx || undefined,
|
||||
fill,
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
case 'circle': {
|
||||
const cx = num(el, 'cx') * scale
|
||||
const cy = num(el, 'cy') * scale
|
||||
const r = num(el, 'r') * scale
|
||||
return {
|
||||
...base,
|
||||
type: 'ellipse' as const,
|
||||
name: el.getAttribute('id') ?? 'Circle',
|
||||
x: cx - r, y: cy - r,
|
||||
width: Math.round(r * 2),
|
||||
height: Math.round(r * 2),
|
||||
fill,
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
case 'ellipse': {
|
||||
const cx = num(el, 'cx') * scale
|
||||
const cy = num(el, 'cy') * scale
|
||||
const rx = num(el, 'rx') * scale
|
||||
const ry = num(el, 'ry') * scale
|
||||
return {
|
||||
...base,
|
||||
type: 'ellipse' as const,
|
||||
name: el.getAttribute('id') ?? 'Ellipse',
|
||||
x: cx - rx, y: cy - ry,
|
||||
width: Math.round(rx * 2),
|
||||
height: Math.round(ry * 2),
|
||||
fill,
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
case 'line': {
|
||||
return {
|
||||
...base,
|
||||
type: 'line' as const,
|
||||
name: el.getAttribute('id') ?? 'Line',
|
||||
x: num(el, 'x1') * scale,
|
||||
y: num(el, 'y1') * scale,
|
||||
x2: num(el, 'x2') * scale,
|
||||
y2: num(el, 'y2') * scale,
|
||||
stroke: stroke ?? { thickness: 1, fill: [{ type: 'solid', color: '#000000' }] },
|
||||
}
|
||||
}
|
||||
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const pts = el.getAttribute('points')
|
||||
if (!pts) return null
|
||||
const scaledD = scaleSvgPath(pointsToD(pts, tag === 'polygon'), scale)
|
||||
const bbox = getPathBBox(scaledD)
|
||||
return {
|
||||
...base,
|
||||
type: 'path' as const,
|
||||
name: el.getAttribute('id') ?? (tag === 'polygon' ? 'Polygon' : 'Polyline'),
|
||||
d: scaledD,
|
||||
x: bbox.x,
|
||||
y: bbox.y,
|
||||
width: Math.round(bbox.w),
|
||||
height: Math.round(bbox.h),
|
||||
fill: tag === 'polygon' ? fill : noFill(),
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style resolution with inheritance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Read an attribute from the element, checking inline `style` first */
|
||||
function getAttr(el: Element, name: string): string | null {
|
||||
// Check inline style first (higher priority)
|
||||
const style = el.getAttribute('style')
|
||||
if (style) {
|
||||
const m = style.match(new RegExp(`${name}\\s*:\\s*([^;]+)`))
|
||||
if (m) return m[1].trim()
|
||||
}
|
||||
return el.getAttribute(name)
|
||||
}
|
||||
|
||||
/** Build style context by merging parent context with this element's attributes */
|
||||
function mergeStyleCtx(parent: StyleCtx, el: Element): StyleCtx {
|
||||
return {
|
||||
fill: getAttr(el, 'fill') ?? parent.fill,
|
||||
stroke: getAttr(el, 'stroke') ?? parent.stroke,
|
||||
strokeWidth: parseFloat(getAttr(el, 'stroke-width') ?? '') || parent.strokeWidth,
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a color string: resolve currentColor, inherit → black */
|
||||
function normalizeColor(raw: string): string {
|
||||
if (raw === 'currentColor' || raw === 'inherit') return '#000000'
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Explicit "no fill" — transparent so canvas DEFAULT_FILL won't apply */
|
||||
function noFill(): PenFill[] {
|
||||
return [{ type: 'solid', color: 'transparent' }]
|
||||
}
|
||||
|
||||
/** Resolve fill from element attribute + inherited context → PenFill[] */
|
||||
function resolveFill(el: Element, ctx: StyleCtx): PenFill[] | undefined {
|
||||
const raw = getAttr(el, 'fill') ?? ctx.fill
|
||||
|
||||
// Explicit "none" → transparent fill
|
||||
if (raw === 'none') return noFill()
|
||||
// url(#gradient) references — not supported, use black fallback
|
||||
if (raw && raw.startsWith('url(')) return [{ type: 'solid', color: '#000000' }]
|
||||
// Has a color
|
||||
if (raw) return [{ type: 'solid', color: normalizeColor(raw) }]
|
||||
// SVG spec default: black fill
|
||||
return [{ type: 'solid', color: '#000000' }]
|
||||
}
|
||||
|
||||
/** Resolve stroke from element attribute + inherited context → PenStroke */
|
||||
function resolveStroke(el: Element, ctx: StyleCtx, scale: number): PenStroke | undefined {
|
||||
const raw = getAttr(el, 'stroke') ?? ctx.stroke
|
||||
if (!raw || raw === 'none') return undefined
|
||||
if (raw.startsWith('url(')) return undefined
|
||||
|
||||
const width = (parseFloat(getAttr(el, 'stroke-width') ?? '') || ctx.strokeWidth) * scale
|
||||
return {
|
||||
thickness: width,
|
||||
fill: [{ type: 'solid', color: normalizeColor(raw) }],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG path data scaling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scale all coordinates in an SVG path `d` string by a factor.
|
||||
* Handles M/L/C/S/Q/T/H/V/A commands (both absolute and relative).
|
||||
* For arc (A) commands, only rx/ry/x/y are scaled — flags and rotation are preserved.
|
||||
*/
|
||||
function scaleSvgPath(d: string, scale: number): string {
|
||||
if (scale === 1) return d
|
||||
|
||||
// Tokenize into commands and numbers
|
||||
const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g)
|
||||
if (!tokens) return d
|
||||
|
||||
let result = ''
|
||||
let cmd = ''
|
||||
let paramIdx = 0
|
||||
|
||||
for (const tok of tokens) {
|
||||
if (/[a-zA-Z]/.test(tok)) {
|
||||
cmd = tok
|
||||
paramIdx = 0
|
||||
result += tok
|
||||
continue
|
||||
}
|
||||
|
||||
const n = parseFloat(tok)
|
||||
const upper = cmd.toUpperCase()
|
||||
|
||||
if (upper === 'A') {
|
||||
// Arc: rx ry x-rotation large-arc-flag sweep-flag x y (7 params per arc)
|
||||
const pos = paramIdx % 7
|
||||
// Scale rx(0), ry(1), x(5), y(6); keep rotation(2), flags(3,4) unchanged
|
||||
const shouldScale = pos === 0 || pos === 1 || pos === 5 || pos === 6
|
||||
result += ' ' + (shouldScale ? n * scale : n)
|
||||
} else {
|
||||
// All other commands: every param is a coordinate → scale it
|
||||
result += ' ' + n * scale
|
||||
}
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path bounding box (uses browser SVG engine for accuracy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Compute the bounding box of an SVG path d string via the browser's SVG DOM */
|
||||
function getPathBBox(d: string): { x: number; y: number; w: number; h: number } {
|
||||
try {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
path.setAttribute('d', d)
|
||||
svg.appendChild(path)
|
||||
svg.style.position = 'absolute'
|
||||
svg.style.width = '0'
|
||||
svg.style.height = '0'
|
||||
svg.style.overflow = 'hidden'
|
||||
document.body.appendChild(svg)
|
||||
const bbox = path.getBBox()
|
||||
document.body.removeChild(svg)
|
||||
return { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height }
|
||||
} catch {
|
||||
return { x: 0, y: 0, w: 100, h: 100 }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function num(el: Element, attr: string): number {
|
||||
return parseFloat(getAttr(el, attr) ?? '0') || 0
|
||||
}
|
||||
|
||||
function pointsToD(points: string, close: boolean): string {
|
||||
const nums = points.trim().split(/[\s,]+/).map(Number)
|
||||
if (nums.length < 2) return ''
|
||||
let d = `M${nums[0]} ${nums[1]}`
|
||||
for (let i = 2; i < nums.length - 1; i += 2) {
|
||||
d += `L${nums[i]} ${nums[i + 1]}`
|
||||
}
|
||||
if (close) d += 'Z'
|
||||
return d
|
||||
}
|
||||
Loading…
Reference in a new issue