diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 461e09d..b1bf488 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -19,6 +19,8 @@ import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl" import { handleInitialPageLoad } from "@/utils/handleInitialPageLoad" import { MycrozineTemplateTool } from "@/tools/MycrozineTemplateTool" import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil" +import { MycroZineGeneratorTool } from "@/tools/MycroZineGeneratorTool" +import { MycroZineGeneratorShape } from "@/shapes/MycroZineGeneratorShapeUtil" import { registerPropagators, ChangePropagator, @@ -144,6 +146,7 @@ const customShapeUtils = [ EmbedShape, SlideShape, MycrozineTemplateShape, + MycroZineGeneratorShape, MarkdownShape, PromptShape, ObsNoteShape, @@ -168,6 +171,7 @@ const customTools = [ EmbedTool, SlideShapeTool, MycrozineTemplateTool, + MycroZineGeneratorTool, MarkdownTool, PromptShapeTool, GestureTool, diff --git a/src/shapes/MycroZineGeneratorShapeUtil.tsx b/src/shapes/MycroZineGeneratorShapeUtil.tsx new file mode 100644 index 0000000..121dff5 --- /dev/null +++ b/src/shapes/MycroZineGeneratorShapeUtil.tsx @@ -0,0 +1,1152 @@ +import { + BaseBoxShapeUtil, + Geometry2d, + HTMLContainer, + Rectangle2d, + TLBaseShape, + TLShapeId, + createShapeId, +} from "tldraw" +import React, { useState, useRef, useEffect } from "react" +import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper" +import { usePinnedToView } from "@/hooks/usePinnedToView" +import { useMaximize } from "@/hooks/useMaximize" + +// ============================================================================ +// Types +// ============================================================================ + +type ZinePhase = 'ideation' | 'drafts' | 'feedback' | 'finalizing' | 'complete' + +type ZineStyle = 'punk-zine' | 'minimal' | 'collage' | 'retro' | 'academic' +type ZineTone = 'rebellious' | 'playful' | 'informative' | 'poetic' + +interface ChatMessage { + id: string + role: 'user' | 'assistant' + content: string + timestamp: number +} + +interface ZinePageOutline { + pageNumber: number // 1-8 + type: 'cover' | 'content' | 'cta' + title: string + subtitle?: string + keyPoints: string[] + hashtags: string[] + imagePrompt: string +} + +interface GeneratedZinePage { + pageNumber: number + imageUrl: string + shapeId?: string // Canvas shape ID if spawned + outline: ZinePageOutline + generationPrompt: string + timestamp: number + version: number // 1=draft, 2+=refined +} + +interface PageFeedback { + pageNumber: number + feedbackText: string + requestedChanges: string[] + approved: boolean +} + +interface ZineTemplate { + id: string + name: string + topic: string + style: ZineStyle + tone: ZineTone + pages: GeneratedZinePage[] + createdAt: number +} + +type IMycroZineGenerator = TLBaseShape< + "MycroZineGenerator", + { + w: number + h: number + + // Zine Identity + zineId: string + title: string + topic: string + style: ZineStyle + tone: ZineTone + + // Phase State + phase: ZinePhase + + // Ideation Phase + ideationMessages: ChatMessage[] + contentOutline: ZinePageOutline[] | null + + // Drafts Phase + draftPages: GeneratedZinePage[] + spawnedShapeIds: string[] + currentGeneratingPage: number // 0 = not generating, 1-8 = generating that page + + // Feedback Phase + pageFeedback: PageFeedback[] + + // Final Phase + finalPages: GeneratedZinePage[] + printLayoutUrl: string | null + + // UI State + isLoading: boolean + error: string | null + tags: string[] + pinnedToView: boolean + } +> + +// ============================================================================ +// Constants +// ============================================================================ + +const STYLES: Record = { + 'punk-zine': 'Xerox texture, high contrast B&W, DIY collage, hand-drawn typography', + 'minimal': 'Clean lines, white space, modern sans-serif, subtle gradients', + 'collage': 'Layered imagery, mixed media textures, vintage photographs', + 'retro': '1970s aesthetic, earth tones, groovy typography, halftone patterns', + 'academic': 'Diagram-heavy, annotated illustrations, infographic elements', +} + +const TONES: Record = { + 'rebellious': 'Defiant, anti-establishment, punk energy', + 'playful': 'Fun, whimsical, approachable', + 'informative': 'Educational, clear, accessible', + 'poetic': 'Lyrical, metaphorical, evocative', +} + +const PAGE_TEMPLATES = [ + { type: 'cover', description: 'Bold title, subtitle, visual hook' }, + { type: 'content', description: 'Key concepts with visual explanations' }, + { type: 'content', description: 'Deep dive on main topic' }, + { type: 'content', description: 'Supporting information' }, + { type: 'content', description: 'Practical applications' }, + { type: 'content', description: 'Community or movement aspect' }, + { type: 'content', description: 'Philosophy or manifesto' }, + { type: 'cta', description: 'Call-to-action with QR codes' }, +] + +// ============================================================================ +// Shape Util +// ============================================================================ + +export class MycroZineGeneratorShape extends BaseBoxShapeUtil { + static override type = "MycroZineGenerator" as const + + // Mycro-zine theme color: Punk green + static readonly PRIMARY_COLOR = "#00ff00" + static readonly SECONDARY_COLOR = "#1a1a1a" + + MIN_WIDTH = 400 as const + MIN_HEIGHT = 500 as const + DEFAULT_WIDTH = 450 as const + DEFAULT_HEIGHT = 600 as const + + getDefaultProps(): IMycroZineGenerator["props"] { + return { + w: this.DEFAULT_WIDTH, + h: this.DEFAULT_HEIGHT, + + zineId: `zine_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + title: '', + topic: '', + style: 'punk-zine', + tone: 'rebellious', + + phase: 'ideation', + + ideationMessages: [], + contentOutline: null, + + draftPages: [], + spawnedShapeIds: [], + currentGeneratingPage: 0, + + pageFeedback: [], + + finalPages: [], + printLayoutUrl: null, + + isLoading: false, + error: null, + tags: ['zine', 'mycrozine', 'print'], + pinnedToView: false, + } + } + + getGeometry(shape: IMycroZineGenerator): Geometry2d { + return new Rectangle2d({ + width: Math.max(shape.props.w, 1), + height: Math.max(shape.props.h, 1), + isFilled: true, + }) + } + + component(shape: IMycroZineGenerator) { + const editor = this.editor + const isSelected = editor.getSelectedShapeIds().includes(shape.id) + + usePinnedToView(editor, shape.id, shape.props.pinnedToView) + + const { isMaximized, toggleMaximize } = useMaximize({ + editor: editor, + shapeId: shape.id, + currentW: shape.props.w, + currentH: shape.props.h, + shapeType: 'MycroZineGenerator', + }) + + const [isMinimized, setIsMinimized] = useState(false) + const [inputValue, setInputValue] = useState('') + const messagesEndRef = useRef(null) + + // Scroll to bottom of messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [shape.props.ideationMessages]) + + const handlePinToggle = () => { + editor.updateShape({ + id: shape.id, + type: "MycroZineGenerator", + props: { pinnedToView: !shape.props.pinnedToView }, + }) + } + + const handleClose = () => { + editor.deleteShape(shape.id) + } + + const handleMinimize = () => { + setIsMinimized(!isMinimized) + } + + const handleTagsChange = (newTags: string[]) => { + editor.updateShape({ + id: shape.id, + type: "MycroZineGenerator", + props: { tags: newTags }, + }) + } + + // Update shape props helper + const updateProps = (updates: Partial) => { + editor.updateShape({ + id: shape.id, + type: "MycroZineGenerator", + props: updates, + }) + } + + // Add a chat message + const addMessage = (role: 'user' | 'assistant', content: string) => { + const newMessage: ChatMessage = { + id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + role, + content, + timestamp: Date.now(), + } + updateProps({ + ideationMessages: [...shape.props.ideationMessages, newMessage], + }) + return newMessage + } + + // Generate content outline from ideation + const generateOutline = async () => { + if (!shape.props.topic) { + updateProps({ error: 'Please enter a topic first' }) + return + } + + updateProps({ isLoading: true, error: null }) + + try { + // Build outline based on topic and conversation + const outline: ZinePageOutline[] = PAGE_TEMPLATES.map((template, index) => ({ + pageNumber: index + 1, + type: template.type as 'cover' | 'content' | 'cta', + title: index === 0 ? shape.props.topic.toUpperCase() : `Page ${index + 1}`, + subtitle: template.description, + keyPoints: [], + hashtags: [], + imagePrompt: `${STYLES[shape.props.style]}. ${TONES[shape.props.tone]}. ${template.description} for ${shape.props.topic}.`, + })) + + // Add assistant message with outline summary + addMessage('assistant', `Great! I've created an outline for your "${shape.props.topic}" zine:\n\n${outline.map(p => `Page ${p.pageNumber}: ${p.title}`).join('\n')}\n\nClick "Generate Drafts" when you're ready to create the pages!`) + + updateProps({ + contentOutline: outline, + title: shape.props.topic.toUpperCase(), + isLoading: false, + }) + } catch (err) { + updateProps({ + isLoading: false, + error: `Failed to generate outline: ${err instanceof Error ? err.message : String(err)}`, + }) + } + } + + // Handle user message in ideation phase + const handleSendMessage = async () => { + const message = inputValue.trim() + if (!message) return + + setInputValue('') + addMessage('user', message) + + // If this is the first message and no topic set, use it as topic + if (!shape.props.topic && shape.props.ideationMessages.length === 0) { + updateProps({ topic: message }) + + // Auto-respond + setTimeout(() => { + addMessage('assistant', `Let's create an 8-page zine about "${message}"!\n\nI'll use the ${shape.props.style} style with a ${shape.props.tone} tone.\n\nFeel free to:\n• Add more details about what to cover\n• Specify any key points or themes\n• Change the style/tone using the dropdowns\n\nOr click "Generate Outline" to proceed!`) + }, 500) + } else { + // Continue conversation + setTimeout(() => { + addMessage('assistant', `Got it! I'll incorporate that into the zine. ${shape.props.contentOutline ? 'Ready to generate drafts!' : 'Click "Generate Outline" when ready.'}`) + }, 500) + } + } + + // Generate draft pages using Gemini + const generateDrafts = async () => { + if (!shape.props.contentOutline) { + await generateOutline() + if (!shape.props.contentOutline) return + } + + updateProps({ + phase: 'drafts', + isLoading: true, + error: null, + currentGeneratingPage: 1, + }) + + const outline = shape.props.contentOutline! + const generatedPages: GeneratedZinePage[] = [] + + for (let i = 0; i < outline.length; i++) { + const pageOutline = outline[i] + updateProps({ currentGeneratingPage: i + 1 }) + + try { + // Build the image generation prompt + const prompt = buildImagePrompt(pageOutline, shape.props.topic, shape.props.style, shape.props.tone) + + // Call Gemini MCP for image generation + // Note: In actual implementation, this would call the MCP server + // For now, we'll use a placeholder approach + const imageUrl = await generatePageImage(prompt, i + 1) + + generatedPages.push({ + pageNumber: i + 1, + imageUrl, + outline: pageOutline, + generationPrompt: prompt, + timestamp: Date.now(), + version: 1, + }) + + // Spawn image on canvas + const spawnedId = await spawnPageOnCanvas(editor, imageUrl, i, shape) + if (spawnedId) { + generatedPages[generatedPages.length - 1].shapeId = spawnedId + } + + updateProps({ draftPages: [...generatedPages] }) + } catch (err) { + console.error(`Failed to generate page ${i + 1}:`, err) + updateProps({ + error: `Failed to generate page ${i + 1}: ${err instanceof Error ? err.message : String(err)}`, + }) + } + } + + // Initialize feedback for all pages + const initialFeedback: PageFeedback[] = outline.map((_, i) => ({ + pageNumber: i + 1, + feedbackText: '', + requestedChanges: [], + approved: false, + })) + + updateProps({ + draftPages: generatedPages, + pageFeedback: initialFeedback, + spawnedShapeIds: generatedPages.map(p => p.shapeId).filter(Boolean) as string[], + isLoading: false, + currentGeneratingPage: 0, + phase: generatedPages.length === 8 ? 'feedback' : 'drafts', + }) + } + + // Approve a page + const approvePage = (pageNumber: number) => { + const newFeedback = shape.props.pageFeedback.map(f => + f.pageNumber === pageNumber ? { ...f, approved: true } : f + ) + updateProps({ pageFeedback: newFeedback }) + } + + // Add feedback to a page + const addPageFeedback = (pageNumber: number, feedback: string) => { + const newFeedback = shape.props.pageFeedback.map(f => + f.pageNumber === pageNumber + ? { ...f, feedbackText: feedback, requestedChanges: [feedback] } + : f + ) + updateProps({ pageFeedback: newFeedback }) + } + + // Check if all pages are approved or have feedback + const allPagesReviewed = shape.props.pageFeedback.every( + f => f.approved || f.feedbackText.length > 0 + ) + + // Move to finalization + const startFinalization = async () => { + const pagesNeedingUpdate = shape.props.pageFeedback.filter(f => !f.approved && f.feedbackText) + + if (pagesNeedingUpdate.length === 0) { + // All approved, skip to complete + updateProps({ + phase: 'complete', + finalPages: shape.props.draftPages, + }) + return + } + + updateProps({ + phase: 'finalizing', + isLoading: true, + error: null, + }) + + const finalPages = [...shape.props.draftPages] + + for (const feedback of pagesNeedingUpdate) { + updateProps({ currentGeneratingPage: feedback.pageNumber }) + + try { + const originalPage = shape.props.draftPages.find(p => p.pageNumber === feedback.pageNumber) + if (!originalPage) continue + + // Regenerate with feedback incorporated + const prompt = `${originalPage.generationPrompt}\n\nIMPORTANT REVISIONS: ${feedback.feedbackText}` + const imageUrl = await generatePageImage(prompt, feedback.pageNumber) + + finalPages[feedback.pageNumber - 1] = { + ...originalPage, + imageUrl, + generationPrompt: prompt, + timestamp: Date.now(), + version: originalPage.version + 1, + } + } catch (err) { + console.error(`Failed to regenerate page ${feedback.pageNumber}:`, err) + } + } + + updateProps({ + finalPages, + isLoading: false, + currentGeneratingPage: 0, + phase: 'complete', + }) + } + + // Generate print layout + const generatePrintLayout = async () => { + updateProps({ isLoading: true, error: null }) + + try { + // In actual implementation, this would call the mycro-zine layout generator + // For now, show a success message + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) + const printUrl = `/output/${shape.props.zineId}_print_${timestamp}.png` + + updateProps({ + printLayoutUrl: printUrl, + isLoading: false, + }) + + addMessage('assistant', `Print layout generated! Your zine is ready to download and print.`) + } catch (err) { + updateProps({ + isLoading: false, + error: `Failed to generate print layout: ${err instanceof Error ? err.message : String(err)}`, + }) + } + } + + // Save as template + const saveAsTemplate = () => { + const template: ZineTemplate = { + id: shape.props.zineId, + name: shape.props.title || shape.props.topic, + topic: shape.props.topic, + style: shape.props.style, + tone: shape.props.tone, + pages: shape.props.finalPages.length > 0 ? shape.props.finalPages : shape.props.draftPages, + createdAt: Date.now(), + } + + // Save to localStorage + const templates = JSON.parse(localStorage.getItem('mycrozine_templates') || '[]') + templates.push(template) + localStorage.setItem('mycrozine_templates', JSON.stringify(templates)) + + addMessage('assistant', `Template "${template.name}" saved! You can reprint it anytime from the template library.`) + } + + // Render phase-specific content + const renderPhaseContent = () => { + switch (shape.props.phase) { + case 'ideation': + return renderIdeationPhase() + case 'drafts': + return renderDraftsPhase() + case 'feedback': + return renderFeedbackPhase() + case 'finalizing': + return renderFinalizingPhase() + case 'complete': + return renderCompletePhase() + default: + return null + } + } + + // ========== IDEATION PHASE ========== + const renderIdeationPhase = () => ( +
+ {/* Style/Tone selectors */} +
+ + +
+ + {/* Chat messages */} +
+ {shape.props.ideationMessages.length === 0 && ( +
+ What topic would you like to make a zine about? +
+ )} + {shape.props.ideationMessages.map((msg) => ( +
+ {msg.content} +
+ ))} +
+
+ + {/* Input area */} +
+ setInputValue(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + }} + onPointerDown={(e) => e.stopPropagation()} + placeholder={shape.props.topic ? "Add details or feedback..." : "Enter your zine topic..."} + style={inputStyle} + /> + +
+ + {/* Action buttons */} +
+ {!shape.props.contentOutline && shape.props.topic && ( + + )} + {shape.props.contentOutline && ( + + )} +
+
+ ) + + // ========== DRAFTS PHASE ========== + const renderDraftsPhase = () => ( +
+
šŸ„
+
Generating Drafts...
+ + {/* Progress bar */} +
+
+
+ +
+ Page {shape.props.currentGeneratingPage}/8 +
+ + {/* Page status list */} +
+ {Array.from({ length: 8 }, (_, i) => i + 1).map(pageNum => { + const isComplete = shape.props.draftPages.some(p => p.pageNumber === pageNum) + const isGenerating = shape.props.currentGeneratingPage === pageNum + return ( +
+ {isComplete ? 'āœ“' : pageNum} +
+ ) + })} +
+
+ ) + + // ========== FEEDBACK PHASE ========== + const renderFeedbackPhase = () => { + const [selectedPage, setSelectedPage] = useState(null) + const [feedbackInput, setFeedbackInput] = useState('') + + return ( +
+
+ Review your drafts. Approve pages or add feedback for revision. +
+ + {/* Page grid */} +
+ {shape.props.draftPages.map((page) => { + const feedback = shape.props.pageFeedback.find(f => f.pageNumber === page.pageNumber) + const isApproved = feedback?.approved + const hasFeedback = feedback?.feedbackText + + return ( +
setSelectedPage(page.pageNumber)} + onPointerDown={(e) => e.stopPropagation()} + style={{ + position: 'relative', + borderRadius: '4px', + overflow: 'hidden', + cursor: 'pointer', + border: selectedPage === page.pageNumber + ? `2px solid ${MycroZineGeneratorShape.PRIMARY_COLOR}` + : isApproved + ? '2px solid #4ade80' + : hasFeedback + ? '2px solid #fbbf24' + : '2px solid #333', + }} + > + {`Page +
+ {page.pageNumber} {isApproved ? 'āœ“' : hasFeedback ? 'āœŽ' : ''} +
+
+ ) + })} +
+ + {/* Selected page actions */} + {selectedPage && ( +
+
Page {selectedPage}
+
+ + +
+ setFeedbackInput(e.target.value)} + onPointerDown={(e) => e.stopPropagation()} + placeholder="Describe changes needed..." + style={inputStyle} + /> + {feedbackInput && ( + + )} +
+ )} + + {/* Continue button */} + +
+ ) + } + + // ========== FINALIZING PHASE ========== + const renderFinalizingPhase = () => ( +
+
šŸ”„
+
Applying Feedback...
+ +
+ Regenerating {shape.props.pageFeedback.filter(f => !f.approved && f.feedbackText).length} pages +
+ + {shape.props.currentGeneratingPage > 0 && ( +
+ Currently updating page {shape.props.currentGeneratingPage}... +
+ )} +
+ ) + + // ========== COMPLETE PHASE ========== + const renderCompletePhase = () => { + const pages = shape.props.finalPages.length > 0 ? shape.props.finalPages : shape.props.draftPages + + return ( +
+
+
šŸ„
+
+ Zine Complete! +
+
+ "{shape.props.title || shape.props.topic}" +
+
+ + {/* Print preview - 2x4 grid */} +
+ {pages.map((page) => ( + {`Page + ))} +
+ + {/* Action buttons */} +
+ + + +
+
+ ) + } + + // Phase indicator + const phaseLabels: Record = { + ideation: '1. Ideation', + drafts: '2. Drafts', + feedback: '3. Feedback', + finalizing: '4. Finalizing', + complete: '5. Complete', + } + + return ( + + + šŸ„ MycroZine + + {phaseLabels[shape.props.phase]} + + + } + > +
+ {/* Error display */} + {shape.props.error && ( +
+ {shape.props.error} + +
+ )} + + {renderPhaseContent()} +
+
+
+ ) + } + + override indicator(shape: IMycroZineGenerator) { + return ( + + ) + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function buildImagePrompt( + pageOutline: ZinePageOutline, + topic: string, + style: ZineStyle, + tone: ZineTone +): string { + const styleDesc = STYLES[style] + const toneDesc = TONES[tone] + + return `Punk zine page ${pageOutline.pageNumber}/8 for "${topic}". + +${pageOutline.imagePrompt} + +Title text: "${pageOutline.title}" +${pageOutline.subtitle ? `Subtitle: "${pageOutline.subtitle}"` : ''} +${pageOutline.keyPoints.length > 0 ? `Key points: ${pageOutline.keyPoints.join(', ')}` : ''} +${pageOutline.hashtags.length > 0 ? `Hashtags: ${pageOutline.hashtags.join(' ')}` : ''} + +Style: ${styleDesc} +Tone: ${toneDesc} + +High contrast black and white with neon green accent highlights. Xerox texture, DIY cut-and-paste collage aesthetic, rough edges, rebellious feel.` +} + +async function generatePageImage(prompt: string, pageNumber: number): Promise { + // In actual implementation, this would call the Gemini MCP server + // For now, return a placeholder + console.log(`Generating page ${pageNumber} with prompt:`, prompt.substring(0, 100) + '...') + + // Simulate generation delay + await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1000)) + + // Return placeholder image + return `https://via.placeholder.com/825x1275/1a1a1a/00ff00?text=Page+${pageNumber}` +} + +async function spawnPageOnCanvas( + editor: any, + imageUrl: string, + index: number, + parentShape: IMycroZineGenerator +): Promise { + try { + // Calculate position in a 4x2 grid to the right of the generator + const col = index % 4 + const row = Math.floor(index / 4) + const spacing = 20 + const pageWidth = 200 + const pageHeight = 300 + + const x = parentShape.props.w + 50 + col * (pageWidth + spacing) + const y = row * (pageHeight + spacing) + + // Get parent shape position + const parentBounds = editor.getShapePageBounds(parentShape.id) + if (!parentBounds) return undefined + + const shapeId = createShapeId() + + // Create an image shape on the canvas + editor.createShape({ + id: shapeId, + type: 'image', + x: parentBounds.x + x, + y: parentBounds.y + y, + props: { + w: pageWidth, + h: pageHeight, + url: imageUrl, + }, + }) + + return shapeId + } catch (err) { + console.error('Failed to spawn page on canvas:', err) + return undefined + } +} + +// ============================================================================ +// Styles +// ============================================================================ + +const inputStyle: React.CSSProperties = { + flex: 1, + padding: '10px 12px', + backgroundColor: '#1a1a1a', + border: '1px solid #333', + borderRadius: '6px', + color: '#fff', + fontSize: '13px', + outline: 'none', +} + +const buttonStyle: React.CSSProperties = { + padding: '10px 16px', + backgroundColor: '#00ff00', + border: 'none', + borderRadius: '6px', + color: '#000', + fontSize: '13px', + fontWeight: 'bold', + cursor: 'pointer', + transition: 'opacity 0.15s', +} + +const selectStyle: React.CSSProperties = { + flex: 1, + padding: '8px 12px', + backgroundColor: '#1a1a1a', + border: '1px solid #333', + borderRadius: '6px', + color: '#fff', + fontSize: '12px', + cursor: 'pointer', +} diff --git a/src/tools/MycroZineGeneratorTool.ts b/src/tools/MycroZineGeneratorTool.ts new file mode 100644 index 0000000..686ded4 --- /dev/null +++ b/src/tools/MycroZineGeneratorTool.ts @@ -0,0 +1,7 @@ +import { BaseBoxShapeTool } from "tldraw" + +export class MycroZineGeneratorTool extends BaseBoxShapeTool { + static override id = "MycroZineGenerator" + static override initial = "idle" + override shapeType = "MycroZineGenerator" +}