From f69ca5b63f8ff71150412cb22c55c9d49de55911 Mon Sep 17 00:00:00 2001 From: Jono Brandel Date: Sat, 8 Aug 2026 19:04:46 -0700 Subject: [PATCH 01/17] Add playground registry and wiremarks demo Refactors the app to support multiple playgrounds via a typed registry and sidebar selection, replacing the single hardcoded Playground scene with dynamic components. Adds a new Wiremarks playground (DSL editor, pan/zoom/drag canvas interactions, SVG export) and moves the existing primitives grid into a dedicated Components Showcase playground. Also fixes DOM prop application in Provider by setting the `class` attribute directly for `className`. --- lib/Provider.tsx | 2 +- src/App.tsx | 45 +- src/Playground.tsx | 408 +----------------- .../ComponentsShowcasePlayground.tsx | 352 +++++++++++++++ src/playgrounds/registry.ts | 23 + src/playgrounds/types.ts | 13 + src/playgrounds/wiremarks/WiremarkCanvas.tsx | 238 ++++++++++ .../wiremarks/WiremarksPlayground.tsx | 175 ++++++++ src/playgrounds/wiremarks/connection.ts | 104 +++++ src/playgrounds/wiremarks/constants.ts | 16 + src/playgrounds/wiremarks/entity.ts | 149 +++++++ src/playgrounds/wiremarks/utils/color.ts | 20 + src/playgrounds/wiremarks/wiremark.ts | 153 +++++++ 13 files changed, 1290 insertions(+), 408 deletions(-) create mode 100644 src/playgrounds/components-showcase/ComponentsShowcasePlayground.tsx create mode 100644 src/playgrounds/registry.ts create mode 100644 src/playgrounds/types.ts create mode 100644 src/playgrounds/wiremarks/WiremarkCanvas.tsx create mode 100644 src/playgrounds/wiremarks/WiremarksPlayground.tsx create mode 100644 src/playgrounds/wiremarks/connection.ts create mode 100644 src/playgrounds/wiremarks/constants.ts create mode 100644 src/playgrounds/wiremarks/entity.ts create mode 100644 src/playgrounds/wiremarks/utils/color.ts create mode 100644 src/playgrounds/wiremarks/wiremark.ts diff --git a/lib/Provider.tsx b/lib/Provider.tsx index 4f9d4dd..baaa81b 100644 --- a/lib/Provider.tsx +++ b/lib/Provider.tsx @@ -505,7 +505,7 @@ export const Provider = React.forwardRef< // Merge style object Object.assign(element.style, value); } else if (key === 'className') { - element.className = value as string; + element.setAttribute('class', value as string); } else if (key.startsWith('on') && typeof value === 'function') { // Handle React event props (onClick, onMouseMove, etc.) const eventName = key.slice(2).toLowerCase(); diff --git a/src/App.tsx b/src/App.tsx index 87bb916..ae039e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,16 +16,21 @@ import { CommandLineIcon, CurrencyDollarIcon, SparklesIcon, + Squares2X2Icon, + ShareIcon, } from '@heroicons/react/20/solid'; import cn from 'clsx'; import Playground from './Playground'; import { useEffect, useState } from 'react'; import { version } from '../package.json'; +import { PLAYGROUNDS } from './playgrounds/registry'; export default function App() { const [domElement, setDomElement] = useState(null); const [width, setWidth] = useState(400); const [height, setHeight] = useState(300); + const [activePlaygroundId, setActivePlaygroundId] = useState('wiremarks'); + const sidebar = ( @@ -59,25 +64,43 @@ export default function App() { + + Playgrounds + {PLAYGROUNDS.map((playground) => { + const isWiremarks = playground.id === 'wiremarks'; + const Icon = isWiremarks ? ShareIcon : Squares2X2Icon; + const isCurrent = activePlaygroundId === playground.id; + + return ( + setActivePlaygroundId(playground.id)} + > + + {playground.name} + + ); + })} + + + + - Github + Github - Package + Package - Sponsor + Sponsor - + ChatGPT - - - - ); @@ -101,7 +124,11 @@ export default function App() { return ( - + ); } diff --git a/src/Playground.tsx b/src/Playground.tsx index 45f875f..e6393a5 100644 --- a/src/Playground.tsx +++ b/src/Playground.tsx @@ -1,406 +1,18 @@ -import Two from 'two.js'; -import { - Group, - Canvas, - Path, - RefPath, - useTwo, - Points, - RefPoints, - Circle, - RefCircle, - Rectangle, - RefRectangle, - RoundedRectangle, - RefRoundedRectangle, - Ellipse, - RefEllipse, - Line, - RefLine, - Polygon, - RefPolygon, - Star, - RefStar, - ArcSegment, - RefArcSegment, - Sprite, - RefSprite, - ImageSequence, - RefImageSequence, - LinearGradient, - RefLinearGradient, - RadialGradient, - RefRadialGradient, - Texture, - RefTexture, - Text, - SVG, - RefSVG, - RefGroup, -} from '../lib/main'; -import { useEffect, useRef, useState } from 'react'; -import { useFrame } from '../lib/Context'; +import { getPlaygroundById } from './playgrounds/registry'; -function Scene() { - const { width, height } = useTwo(); - - // Create refs for all components - const path = useRef(null); - const points = useRef(null); - const circle = useRef(null); - const rectangle = useRef(null); - const roundedRectangle = useRef(null); - const ellipse = useRef(null); - const line = useRef(null); - const polygon = useRef(null); - const star = useRef(null); - const arcSegment = useRef(null); - const sprite = useRef(null); - const imageSequence = useRef(null); - const svg = useRef(null); - - // Gradient refs - const [linearGradient, setLinearGradient] = useState< - string | RefLinearGradient - >('#FF6B6B'); - const radialGradient = useRef(null); - - // Texture ref - const [texture, setTexture] = useState('#E8E8E8'); - - // Interactive state - const [circleHovered, setCircleHovered] = useState(false); - const [rectangleClicked, setRectangleClicked] = useState(false); - const [starHovered, setStarHovered] = useState(false); - - useFrame((elapsed) => { - // Animate all the components - if (path.current) { - path.current.rotation = elapsed * 0.5; - } - if (points.current) { - points.current.rotation = -elapsed * 0.3; - } - if (circle.current) { - circle.current.scale = Math.sin(elapsed) * 0.25 + 1; - } - if (rectangle.current) { - rectangle.current.rotation = elapsed * 0.2; - } - if (roundedRectangle.current) { - roundedRectangle.current.rotation = -elapsed * 0.15; - } - if (ellipse.current) { - ellipse.current.scale = Math.cos(elapsed * 0.5) * 0.25 + 1; - ellipse.current.dashes.offset = -50 * elapsed; - } - if (line.current) { - line.current.rotation = elapsed * 0.4; - } - if (polygon.current) { - polygon.current.rotation = elapsed * 0.1; - } - if (star.current) { - star.current.rotation = -elapsed * 0.25; - star.current.innerRadius = 20 + Math.sin(elapsed * 2) * 10; - } - if (arcSegment.current) { - arcSegment.current.startAngle = elapsed % (Math.PI * 2); - } - if (sprite.current) { - sprite.current.rotation = elapsed * 0.3; - } - if (imageSequence.current) { - imageSequence.current.scale = Math.cos(elapsed * 1.2) * 0.15 + 1; - } - if (svg.current) { - svg.current.rotation = elapsed * 0.2; - svg.current.scale = Math.sin(elapsed * 0.8) * 0.2 + 1; - } - }, []); - - // Calculate grid positions - const gridSize = 4; - const cellWidth = width / gridSize; - const cellHeight = height / gridSize; - - return ( - - {/* Original components */} - - - - - - {/* Circle - Interactive with hover */} - - setCircleHovered(true)} - onPointerOut={() => setCircleHovered(false)} - /> - - - {/* Rectangle - Interactive with click */} - - setRectangleClicked(!rectangleClicked)} - /> - - - {/* SVG Example - Inline content */} - - - - - - - - `} - scale={0.6} - onLoad={(svg: RefGroup) => { - svg.center(); - }} - /> - - - {/* Rounded Rectangle */} - - - - - {/* Ellipse */} - - - - - {/* Line */} - - - - - {/* Text */} - - - - - {/* Polygon */} - - - - - {/* Star - Interactive with hover and pointer events */} - - setStarHovered(true)} - onPointerLeave={() => setStarHovered(false)} - /> - - - {/* ArcSegment */} - - - - - {/* Sprite */} - - - - - {/* Extra components in the 4th row */} - - - - - {/* Linear Gradient Example */} - - { - if (ref) setLinearGradient(ref); - }} - x1={0} - y1={0} - x2={1} - y2={1} - stops={[ - new Two.Stop(0, 'red'), - new Two.Stop(0.5, 'green'), - new Two.Stop(1, 'blue'), - ]} - /> - - - - {/* Radial Gradient Example */} - - - - - - {/* Texture Example */} - - { - if (ref) setTexture(ref); - }} - src="https://placehold.co/60x60/9B59B6/FFFFFF?text=TEX" - /> - - - - ); +interface PlaygroundProps { + width?: number; + height?: number; + activePlaygroundId?: string; } export default function Playground({ width, height, -}: { - width?: number; - height?: number; -}) { - // Demonstrate ref forwarding to access the actual canvas element - const canvasRef = useRef(null); - - // Example: You can now access the canvas element directly - useEffect(() => { - if (canvasRef.current) { - console.log('Canvas element:', canvasRef.current); - // You could get the 2D context: canvasRef.current.getContext('2d') - } - }, []); + activePlaygroundId = 'wiremarks', +}: PlaygroundProps) { + const playgroundDef = getPlaygroundById(activePlaygroundId); + const ActiveComponent = playgroundDef.component; - return ( - - - - ); + return ; } diff --git a/src/playgrounds/components-showcase/ComponentsShowcasePlayground.tsx b/src/playgrounds/components-showcase/ComponentsShowcasePlayground.tsx new file mode 100644 index 0000000..8c9c52b --- /dev/null +++ b/src/playgrounds/components-showcase/ComponentsShowcasePlayground.tsx @@ -0,0 +1,352 @@ +import Two from 'two.js'; +import { + Group, + Canvas, + Path, + RefPath, + useTwo, + Points, + RefPoints, + Circle, + RefCircle, + Rectangle, + RefRectangle, + RoundedRectangle, + RefRoundedRectangle, + Ellipse, + RefEllipse, + Line, + RefLine, + Polygon, + RefPolygon, + Star, + RefStar, + ArcSegment, + RefArcSegment, + Sprite, + RefSprite, + ImageSequence, + RefImageSequence, + LinearGradient, + RefLinearGradient, + RadialGradient, + RefRadialGradient, + Texture, + RefTexture, + Text, + SVG, + RefSVG, + RefGroup, + useFrame, +} from 'react-two.js'; +import { useRef, useState } from 'react'; +import { PlaygroundProps } from '../types'; + +function Scene() { + const { width, height } = useTwo(); + + const path = useRef(null); + const points = useRef(null); + const circle = useRef(null); + const rectangle = useRef(null); + const roundedRectangle = useRef(null); + const ellipse = useRef(null); + const line = useRef(null); + const polygon = useRef(null); + const star = useRef(null); + const arcSegment = useRef(null); + const sprite = useRef(null); + const imageSequence = useRef(null); + const svg = useRef(null); + + const [linearGradient, setLinearGradient] = useState< + string | RefLinearGradient + >('#FF6B6B'); + const radialGradient = useRef(null); + const [texture, setTexture] = useState('#E8E8E8'); + + const [circleHovered, setCircleHovered] = useState(false); + const [rectangleClicked, setRectangleClicked] = useState(false); + const [starHovered, setStarHovered] = useState(false); + + useFrame((elapsed) => { + if (path.current) path.current.rotation = elapsed * 0.5; + if (points.current) points.current.rotation = -elapsed * 0.3; + if (circle.current) circle.current.scale = Math.sin(elapsed) * 0.25 + 1; + if (rectangle.current) rectangle.current.rotation = elapsed * 0.2; + if (roundedRectangle.current) + roundedRectangle.current.rotation = -elapsed * 0.15; + if (ellipse.current) { + ellipse.current.scale = Math.cos(elapsed * 0.5) * 0.25 + 1; + if (ellipse.current.dashes) { + ellipse.current.dashes.offset = -50 * elapsed; + } + } + if (line.current) line.current.rotation = elapsed * 0.4; + if (polygon.current) polygon.current.rotation = elapsed * 0.1; + if (star.current) { + star.current.rotation = -elapsed * 0.25; + star.current.innerRadius = 20 + Math.sin(elapsed * 2) * 10; + } + if (arcSegment.current) + arcSegment.current.startAngle = elapsed % (Math.PI * 2); + if (sprite.current) sprite.current.rotation = elapsed * 0.3; + if (imageSequence.current) + imageSequence.current.scale = Math.cos(elapsed * 1.2) * 0.15 + 1; + if (svg.current) { + svg.current.rotation = elapsed * 0.2; + svg.current.scale = Math.sin(elapsed * 0.8) * 0.2 + 1; + } + }, []); + + const gridSize = 4; + const cellWidth = width / gridSize; + const cellHeight = height / gridSize; + + return ( + + + + + + + + setCircleHovered(true)} + onPointerOut={() => setCircleHovered(false)} + /> + + + + setRectangleClicked(!rectangleClicked)} + /> + + + + + + + + + + `} + scale={0.6} + onLoad={(svgGroup: RefGroup) => { + svgGroup.center(); + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + setStarHovered(true)} + onPointerLeave={() => setStarHovered(false)} + /> + + + + + + + + + + + + + + + + { + if (ref) setLinearGradient(ref); + }} + x1={0} + y1={0} + x2={1} + y2={1} + stops={[ + new Two.Stop(0, 'red'), + new Two.Stop(0.5, 'green'), + new Two.Stop(1, 'blue'), + ]} + /> + + + + + + + + + + { + if (ref) setTexture(ref); + }} + src="https://placehold.co/60x60/9B59B6/FFFFFF?text=TEX" + /> + + + + ); +} + +export function ComponentsShowcasePlayground({ width, height }: PlaygroundProps) { + const canvasRef = useRef(null); + + return ( +
+ + + +
+ ); +} diff --git a/src/playgrounds/registry.ts b/src/playgrounds/registry.ts new file mode 100644 index 0000000..57317f7 --- /dev/null +++ b/src/playgrounds/registry.ts @@ -0,0 +1,23 @@ +import { PlaygroundDefinition } from './types'; +import { WiremarksPlayground } from './wiremarks/WiremarksPlayground'; +import { ComponentsShowcasePlayground } from './components-showcase/ComponentsShowcasePlayground'; + +export const PLAYGROUNDS: PlaygroundDefinition[] = [ + { + id: 'wiremarks', + name: 'Wiremarks', + description: 'Declarative text-to-wireframe interactive diagramming tool', + component: WiremarksPlayground, + }, + { + id: 'components-showcase', + name: 'Components Showcase', + description: 'Comprehensive grid demonstration of Two.js React primitives', + component: ComponentsShowcasePlayground, + }, +]; + +export function getPlaygroundById(id: string): PlaygroundDefinition { + const playground = PLAYGROUNDS.find((p) => p.id === id); + return playground || PLAYGROUNDS[0]; +} diff --git a/src/playgrounds/types.ts b/src/playgrounds/types.ts new file mode 100644 index 0000000..ce5a825 --- /dev/null +++ b/src/playgrounds/types.ts @@ -0,0 +1,13 @@ +import { ComponentType } from 'react'; + +export interface PlaygroundProps { + width?: number; + height?: number; +} + +export interface PlaygroundDefinition { + id: string; + name: string; + description: string; + component: ComponentType; +} diff --git a/src/playgrounds/wiremarks/WiremarkCanvas.tsx b/src/playgrounds/wiremarks/WiremarkCanvas.tsx new file mode 100644 index 0000000..be0dff5 --- /dev/null +++ b/src/playgrounds/wiremarks/WiremarkCanvas.tsx @@ -0,0 +1,238 @@ +import { useEffect, useRef } from 'react'; +import { useTwo, useFrame } from 'react-two.js'; +import Two from 'two.js'; +import { Wiremark } from './wiremark'; +// @ts-expect-error - ZUI module path from two.js extras +import { ZUI } from 'two.js/extras/jsm/zui.js'; +import type { Entity } from './entity'; + +const eventParams = { passive: false }; + +interface WiremarkCanvasProps { + instructions: string; +} + +interface ZUIInstance { + scale: number; + addLimits: (min: number, max: number) => void; + translateSurface: (x: number, y: number) => void; + zoomBy: (delta: number, x: number, y: number) => void; + clientToSurface: (x: number, y: number) => { x: number; y: number; z: number }; +} + +export function WiremarkCanvas({ instructions }: WiremarkCanvasProps) { + const { two, parent } = useTwo(); + const wiremarkRef = useRef(null); + const zuiRef = useRef(null); + const grabbingRef = useRef(''); + + useEffect(() => { + if (!two || !parent) return; + + const wiremark = new Wiremark(); + parent.add(wiremark); + wiremarkRef.current = wiremark; + + const domElement = two.renderer.domElement; + // Pass domElement as viewport so ZUI correctly measures container position on page + const zui: ZUIInstance = new ZUI(wiremark, domElement); + zui.addLimits(0.06, 8); + zuiRef.current = zui; + + const setGrabbing = (className: string) => { + grabbingRef.current = className; + const container = domElement.parentElement; + if (container) { + container.className = ['wireframe', className].filter(Boolean).join(' '); + } + }; + + const getEntityUnderMouse = (clientX: number, clientY: number): Entity | null => { + const pt = zui.clientToSurface(clientX, clientY); + const { registry } = wiremark.entities; + for (const name in registry) { + const child = registry[name]; + const halfW = child.width / 2; + const halfH = child.height / 2; + if ( + pt.x >= child.position.x - halfW && + pt.x <= child.position.x + halfW && + pt.y >= child.position.y - halfH && + pt.y <= child.position.y + halfH + ) { + return child; + } + } + return null; + }; + + const mouse = new Two.Vector(); + let touches: Touch[] = []; + let moving: Entity | null = null; + let distance = 0; + + function mousedown(e: MouseEvent) { + setGrabbing('grabbing'); + mouse.x = e.clientX; + mouse.y = e.clientY; + moving = getEntityUnderMouse(e.clientX, e.clientY); + if (moving) { + setGrabbing('dragging'); + } + window.addEventListener('mousemove', mousemove, false); + window.addEventListener('mouseup', mouseup, false); + } + + function mousemove(e: MouseEvent) { + const dx = e.clientX - mouse.x; + const dy = e.clientY - mouse.y; + if (moving) { + const newX = moving.position.x + dx / zui.scale; + const newY = moving.position.y + dy / zui.scale; + moving.position.set(newX, newY); + } else { + zui.translateSurface(dx, dy); + } + mouse.set(e.clientX, e.clientY); + } + + function mouseup() { + setGrabbing(''); + moving = null; + window.removeEventListener('mousemove', mousemove, false); + window.removeEventListener('mouseup', mouseup, false); + } + + function mousewheel(e: WheelEvent) { + const wheelE = e as WheelEvent & { wheelDeltaY?: number }; + const dy = (wheelE.wheelDeltaY ? wheelE.wheelDeltaY : -wheelE.deltaY) / 1000; + zui.zoomBy(dy, e.clientX, e.clientY); + } + + function touchstart(e: TouchEvent) { + e.preventDefault(); + switch (e.touches.length) { + case 2: + pinchstart(e); + break; + case 1: + panstart(e); + break; + } + } + + function touchmove(e: TouchEvent) { + e.preventDefault(); + switch (e.touches.length) { + case 2: + pinchmove(e); + break; + case 1: + panmove(e); + break; + } + } + + function touchend(e: TouchEvent) { + e.preventDefault(); + setGrabbing(''); + moving = null; + touches = []; + const touch = e.touches[0]; + if (touch) { + mouse.x = touch.clientX; + mouse.y = touch.clientY; + } + } + + function panstart(e: TouchEvent) { + const touch = e.touches[0]; + mouse.x = touch.clientX; + mouse.y = touch.clientY; + moving = getEntityUnderMouse(touch.clientX, touch.clientY); + if (moving) { + setGrabbing('dragging'); + } else { + setGrabbing('grabbing'); + } + } + + function panmove(e: TouchEvent) { + const touch = e.touches[0]; + const dx = touch.clientX - mouse.x; + const dy = touch.clientY - mouse.y; + if (moving) { + const newX = moving.position.x + dx / zui.scale; + const newY = moving.position.y + dy / zui.scale; + moving.position.set(newX, newY); + } else { + zui.translateSurface(dx, dy); + } + mouse.set(touch.clientX, touch.clientY); + } + + function pinchstart(e: TouchEvent) { + for (let i = 0; i < e.touches.length; i++) { + touches[i] = e.touches[i]; + } + const a = touches[0]; + const b = touches[1]; + const dx = b.clientX - a.clientX; + const dy = b.clientY - a.clientY; + distance = Math.sqrt(dx * dx + dy * dy); + mouse.x = dx / 2 + a.clientX; + mouse.y = dy / 2 + a.clientY; + } + + function pinchmove(e: TouchEvent) { + for (let i = 0; i < e.touches.length; i++) { + touches[i] = e.touches[i]; + } + const a = touches[0]; + const b = touches[1]; + const dx = b.clientX - a.clientX; + const dy = b.clientY - a.clientY; + const d = Math.sqrt(dx * dx + dy * dy); + const delta = d - distance; + zui.zoomBy(delta / 250, mouse.x, mouse.y); + distance = d; + } + + if (window.navigator.maxTouchPoints <= 0) { + domElement.addEventListener('mousedown', mousedown, eventParams); + domElement.addEventListener('mousewheel', mousewheel as unknown as EventListener, eventParams); + domElement.addEventListener('wheel', mousewheel as unknown as EventListener, eventParams); + } else { + domElement.addEventListener('touchstart', touchstart, eventParams); + domElement.addEventListener('touchmove', touchmove, eventParams); + domElement.addEventListener('touchend', touchend, eventParams); + domElement.addEventListener('touchcancel', touchend, eventParams); + } + + return () => { + domElement.removeEventListener('mousedown', mousedown, eventParams); + domElement.removeEventListener('mousewheel', mousewheel as unknown as EventListener, eventParams); + domElement.removeEventListener('wheel', mousewheel as unknown as EventListener, eventParams); + domElement.removeEventListener('touchstart', touchstart, eventParams); + domElement.removeEventListener('touchmove', touchmove, eventParams); + domElement.removeEventListener('touchend', touchend, eventParams); + domElement.removeEventListener('touchcancel', touchend, eventParams); + wiremark.remove().dispose(); + }; + }, [two, parent]); + + useEffect(() => { + if (wiremarkRef.current && two) { + wiremarkRef.current.instructions = instructions; + two.update(); + } + }, [instructions, two]); + + useFrame((_, frameDelta) => { + if (wiremarkRef.current) { + wiremarkRef.current.update(frameDelta); + } + }); + + return null; +} diff --git a/src/playgrounds/wiremarks/WiremarksPlayground.tsx b/src/playgrounds/wiremarks/WiremarksPlayground.tsx new file mode 100644 index 0000000..80de932 --- /dev/null +++ b/src/playgrounds/wiremarks/WiremarksPlayground.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef, useState } from 'react'; +import { Canvas } from 'react-two.js'; +import Two from 'two.js'; +import { WiremarkCanvas } from './WiremarkCanvas'; +import { PlaygroundProps } from '../types'; +import { Button } from '@/components/catalyst/Button'; +import { + ArrowDownTrayIcon, + CodeBracketSquareIcon, + XMarkIcon, + ArrowPathIcon, +} from '@heroicons/react/20/solid'; + +const defaultPrompt = ` +# Welcome to Wiremarks! + +# Wiremarks is a simple interface to compose +# wireframes and organizational structures +# through text. Connect things with an arrow +# like so: +# Grandmother -> Mother + +# Each line of text is a connection. +# Mother -> Daughter + +# And you can label connections by using +# brackets like so: +# Grid -[Electricity]-> Home + +# Lastly, starting a line with a hashtag +# makes your text a comment and will not +# be compiled into any connections. +# Remove a hashtag above to see the +# Mother / Daughter connection. + +# When you close the instructions, you +# can drag each entity and move around +# to fine tune your composition. You +# can even save it out as an SVG! + +# Happy wire marking! +`.trim(); + +export function WiremarksPlayground({ width, height }: PlaygroundProps) { + const containerRef = useRef(null); + const textareaRef = useRef(null); + + const [text, setText] = useState(() => { + return window.localStorage.getItem('wiremarks-state') || defaultPrompt; + }); + const [isOpen, setIsOpen] = useState(true); + + useEffect(() => { + window.localStorage.setItem('wiremarks-state', text); + }, [text]); + + const handleOpen = () => { + setIsOpen(true); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + }); + }; + + const handleClose = () => { + setIsOpen(false); + }; + + const handleReset = () => { + setText(defaultPrompt); + }; + + const handleDownload = () => { + const svgElement = containerRef.current?.querySelector('svg'); + if (!svgElement) return; + + const serializer = new XMLSerializer(); + const source = serializer.serializeToString(svgElement); + const a = document.createElement('a'); + a.href = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`; + a.download = 'wiremarks.svg'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + a.remove(); + }; + + return ( +
+ {/* Two.js Canvas Stage */} +
+ + + +
+ + {/* Floating Action Controls */} +
+ {!isOpen && ( + + )} + +
+ + {/* DSL Editor Overlay Panel */} + {isOpen && ( +
+
+
+

+ + Wiremarks Script Editor +

+

+ Define nodes and connections using simple text syntax. +

+
+
+ + +
+
+ +
+