827 lines
28 KiB
TypeScript
827 lines
28 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
import Link from "next/link"
|
|
|
|
// Inline Propagator class (simplified from @folkjs/propagators)
|
|
type PropagatorFunction = (source: EventTarget, target: EventTarget, event: Event) => any
|
|
|
|
interface PropagatorOptions {
|
|
source?: EventTarget | null
|
|
target?: EventTarget | null
|
|
event?: string | null
|
|
handler?: PropagatorFunction | null
|
|
}
|
|
|
|
class Propagator {
|
|
private source: EventTarget | null = null
|
|
private target: EventTarget | null = null
|
|
private eventName: string | null = null
|
|
private handler: PropagatorFunction | null = null
|
|
|
|
constructor(options: PropagatorOptions = {}) {
|
|
const { source = null, target = null, event = null, handler = null } = options
|
|
|
|
this.source = source
|
|
this.target = target
|
|
this.eventName = event
|
|
this.handler = handler
|
|
|
|
// Add listener if we have all necessary parts
|
|
if (this.source && this.eventName) {
|
|
this.source.addEventListener(this.eventName, this.handleEvent)
|
|
}
|
|
}
|
|
|
|
private handleEvent = (event: Event) => {
|
|
if (!this.source || !this.target || !this.handler) return
|
|
|
|
try {
|
|
this.handler(this.source, this.target, event)
|
|
} catch (error) {
|
|
console.error("Error in propagator handler:", error)
|
|
}
|
|
}
|
|
|
|
propagate(event?: Event): void {
|
|
if (!event && this.eventName) {
|
|
event = new Event(this.eventName)
|
|
}
|
|
if (!event) return
|
|
this.handleEvent(event)
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this.source && this.eventName) {
|
|
this.source.removeEventListener(this.eventName, this.handleEvent)
|
|
}
|
|
this.source = null
|
|
this.target = null
|
|
this.handler = null
|
|
}
|
|
}
|
|
|
|
type Tool = "select" | "draw" | "erase" | "rectangle" | "text" | "arrow"
|
|
|
|
// Helper function to calculate distance from point to line segment
|
|
function pointToLineDistance(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
|
|
const A = px - x1
|
|
const B = py - y1
|
|
const C = x2 - x1
|
|
const D = y2 - y1
|
|
|
|
const dot = A * C + B * D
|
|
const lenSq = C * C + D * D
|
|
let param = -1
|
|
|
|
if (lenSq !== 0) {
|
|
param = dot / lenSq
|
|
}
|
|
|
|
let xx, yy
|
|
|
|
if (param < 0) {
|
|
xx = x1
|
|
yy = y1
|
|
} else if (param > 1) {
|
|
xx = x2
|
|
yy = y2
|
|
} else {
|
|
xx = x1 + param * C
|
|
yy = y1 + param * D
|
|
}
|
|
|
|
const dx = px - xx
|
|
const dy = py - yy
|
|
|
|
return Math.sqrt(dx * dx + dy * dy)
|
|
}
|
|
|
|
// Constants
|
|
const HIT_TOLERANCE = 10 // pixels - for line/arrow hit detection
|
|
|
|
interface Shape {
|
|
id: string
|
|
type: "rectangle" | "ellipse" | "line" | "text" | "arrow"
|
|
x: number
|
|
y: number
|
|
width?: number
|
|
height?: number
|
|
x2?: number
|
|
y2?: number
|
|
text?: string
|
|
color: string
|
|
// For arrows that connect shapes
|
|
sourceShapeId?: string
|
|
targetShapeId?: string
|
|
// Propagator expression for live arrows
|
|
expression?: string
|
|
// Data value for shapes (used in propagation)
|
|
value?: number
|
|
}
|
|
|
|
export default function ItalismPage() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const [tool, setTool] = useState<Tool>("select")
|
|
const [shapes, setShapes] = useState<Shape[]>([
|
|
{
|
|
id: "1",
|
|
type: "rectangle",
|
|
x: 100,
|
|
y: 100,
|
|
width: 750,
|
|
height: 50,
|
|
color: "#6366f1",
|
|
text: "Digital Liberation",
|
|
},
|
|
{ id: "2", type: "rectangle", x: 360, y: 180, width: 480, height: 50, color: "#6366f1", text: "Post-Appitalism" },
|
|
{
|
|
id: "3",
|
|
type: "rectangle",
|
|
x: 50,
|
|
y: 340,
|
|
width: 1110,
|
|
height: 50,
|
|
color: "#10b981",
|
|
text: "Collaborative Economy",
|
|
},
|
|
{ id: "4", type: "ellipse", x: 270, y: 430, width: 1020, height: 40, color: "#10b981", text: "Decentralized" },
|
|
{ id: "5", type: "ellipse", x: 310, y: 530, width: 1110, height: 40, color: "#10b981", text: "Future" },
|
|
{ id: "6", type: "rectangle", x: 80, y: 605, width: 1110, height: 50, color: "#6366f1", text: "Community" },
|
|
{ id: "7", type: "rectangle", x: 290, y: 710, width: 630, height: 50, color: "#8b5cf6", text: "Innovation" },
|
|
])
|
|
const [isDrawing, setIsDrawing] = useState(false)
|
|
const [currentShape, setCurrentShape] = useState<Partial<Shape> | null>(null)
|
|
const [selectedShape, setSelectedShape] = useState<string | null>(null)
|
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
|
const [arrowStartShape, setArrowStartShape] = useState<string | null>(null)
|
|
const [propagators, setPropagators] = useState<Map<string, Propagator>>(new Map())
|
|
const [editingArrow, setEditingArrow] = useState<string | null>(null)
|
|
const [eventTargets, setEventTargets] = useState<Map<string, { source: EventTarget; target: EventTarget }>>(new Map())
|
|
|
|
// Helper function to get the center of a shape
|
|
const getShapeCenter = (shape: Shape): { x: number; y: number } => {
|
|
if (shape.width && shape.height) {
|
|
return {
|
|
x: shape.x + shape.width / 2,
|
|
y: shape.y + shape.height / 2,
|
|
}
|
|
}
|
|
return { x: shape.x, y: shape.y }
|
|
}
|
|
|
|
// Helper function to find shape at coordinates (excludes arrows/lines - used for arrow tool)
|
|
const findShapeAt = (x: number, y: number): Shape | null => {
|
|
return (
|
|
shapes.find((shape) => {
|
|
if (shape.type === "arrow" || shape.type === "line") return false
|
|
if (shape.width && shape.height) {
|
|
return x >= shape.x && x <= shape.x + shape.width && y >= shape.y && y <= shape.y + shape.height
|
|
} else if (shape.type === "text" && shape.text) {
|
|
const textWidth = shape.text.length * 12
|
|
const textHeight = 20
|
|
return x >= shape.x && x <= shape.x + textWidth && y >= shape.y - textHeight && y <= shape.y
|
|
}
|
|
return false
|
|
}) || null
|
|
)
|
|
}
|
|
|
|
// Helper function to check if a point is inside/near a shape (includes all shape types)
|
|
const isPointInShape = (x: number, y: number, shape: Shape): boolean => {
|
|
if (shape.width && shape.height) {
|
|
return x >= shape.x && x <= shape.x + shape.width && y >= shape.y && y <= shape.y + shape.height
|
|
} else if (shape.type === "text" && shape.text) {
|
|
const textWidth = shape.text.length * 12
|
|
const textHeight = 20
|
|
return x >= shape.x && x <= shape.x + textWidth && y >= shape.y - textHeight && y <= shape.y
|
|
} else if ((shape.type === "line" || shape.type === "arrow") && shape.x2 && shape.y2) {
|
|
const distance = pointToLineDistance(x, y, shape.x, shape.y, shape.x2, shape.y2)
|
|
return distance < HIT_TOLERANCE
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Create a simple propagator for an arrow
|
|
const createPropagatorForArrow = (arrow: Shape) => {
|
|
if (!arrow.sourceShapeId || !arrow.targetShapeId) return
|
|
|
|
const sourceShape = shapes.find((s) => s.id === arrow.sourceShapeId)
|
|
const targetShape = shapes.find((s) => s.id === arrow.targetShapeId)
|
|
|
|
if (!sourceShape || !targetShape) return
|
|
|
|
// Create EventTargets for the connection
|
|
const mockSource = new EventTarget()
|
|
const mockTarget = new EventTarget()
|
|
|
|
// Store shape IDs on EventTargets so handler can reference them
|
|
;(mockSource as any)._shapeId = arrow.sourceShapeId
|
|
;(mockTarget as any)._shapeId = arrow.targetShapeId
|
|
|
|
const expression = arrow.expression || "value: from.value"
|
|
|
|
try {
|
|
const propagator = new Propagator({
|
|
source: mockSource,
|
|
target: mockTarget,
|
|
event: "update",
|
|
handler: (from: any, to: any) => {
|
|
// Use setShapes with function to get CURRENT state (avoid stale closure)
|
|
setShapes((currentShapes) => {
|
|
const currentSourceShape = currentShapes.find((s) => s.id === (from as any)._shapeId)
|
|
if (!currentSourceShape || currentSourceShape.value === undefined) {
|
|
console.log("⚠️ No source value to propagate")
|
|
return currentShapes // return unchanged
|
|
}
|
|
|
|
const sourceValue = currentSourceShape.value
|
|
const targetId = (to as any)._shapeId
|
|
|
|
console.log(`✅ Propagating from ${(from as any)._shapeId} to ${targetId}: ${sourceValue}`)
|
|
|
|
// Update target shape with value
|
|
return currentShapes.map((s) =>
|
|
s.id === targetId ? { ...s, value: sourceValue } : s
|
|
)
|
|
})
|
|
},
|
|
})
|
|
|
|
// Store both propagator and EventTargets in their respective Maps
|
|
setPropagators((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(arrow.id, propagator)
|
|
return next
|
|
})
|
|
|
|
setEventTargets((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(arrow.id, { source: mockSource, target: mockTarget })
|
|
return next
|
|
})
|
|
} catch (error) {
|
|
console.error("Failed to create propagator:", error)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const ctx = canvas.getContext("2d")
|
|
if (!ctx) return
|
|
|
|
// Set canvas size
|
|
canvas.width = canvas.offsetWidth
|
|
canvas.height = canvas.offsetHeight
|
|
|
|
// Clear canvas
|
|
ctx.fillStyle = "#0f172a"
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
|
|
// Helper function to draw arrowhead
|
|
const drawArrowhead = (x1: number, y1: number, x2: number, y2: number, color: string) => {
|
|
const headLength = 15
|
|
const angle = Math.atan2(y2 - y1, x2 - x1)
|
|
|
|
ctx.fillStyle = color
|
|
ctx.beginPath()
|
|
ctx.moveTo(x2, y2)
|
|
ctx.lineTo(
|
|
x2 - headLength * Math.cos(angle - Math.PI / 6),
|
|
y2 - headLength * Math.sin(angle - Math.PI / 6)
|
|
)
|
|
ctx.lineTo(
|
|
x2 - headLength * Math.cos(angle + Math.PI / 6),
|
|
y2 - headLength * Math.sin(angle + Math.PI / 6)
|
|
)
|
|
ctx.closePath()
|
|
ctx.fill()
|
|
}
|
|
|
|
// Draw shapes
|
|
shapes.forEach((shape) => {
|
|
ctx.strokeStyle = shape.color
|
|
ctx.lineWidth = 2
|
|
ctx.fillStyle = shape.color
|
|
ctx.font = "16px sans-serif"
|
|
|
|
if (shape.type === "rectangle" && shape.width && shape.height) {
|
|
ctx.strokeRect(shape.x, shape.y, shape.width, shape.height)
|
|
if (shape.text) {
|
|
ctx.fillText(shape.text, shape.x + 10, shape.y + shape.height / 2 + 5)
|
|
}
|
|
} else if (shape.type === "ellipse" && shape.width && shape.height) {
|
|
ctx.beginPath()
|
|
ctx.ellipse(
|
|
shape.x + shape.width / 2,
|
|
shape.y + shape.height / 2,
|
|
shape.width / 2,
|
|
shape.height / 2,
|
|
0,
|
|
0,
|
|
2 * Math.PI,
|
|
)
|
|
ctx.stroke()
|
|
if (shape.text) {
|
|
ctx.fillText(shape.text, shape.x + shape.width / 2 - 50, shape.y + shape.height / 2 + 5)
|
|
}
|
|
} else if (shape.type === "line" && shape.x2 && shape.y2) {
|
|
ctx.beginPath()
|
|
ctx.moveTo(shape.x, shape.y)
|
|
ctx.lineTo(shape.x2, shape.y2)
|
|
ctx.stroke()
|
|
} else if (shape.type === "arrow" && shape.x2 && shape.y2) {
|
|
// Draw arrow line with highlight if selected
|
|
const isSelected = selectedShape === shape.id
|
|
ctx.strokeStyle = isSelected ? "#22d3ee" : shape.color
|
|
ctx.lineWidth = isSelected ? 4 : 2
|
|
ctx.beginPath()
|
|
ctx.moveTo(shape.x, shape.y)
|
|
ctx.lineTo(shape.x2, shape.y2)
|
|
ctx.stroke()
|
|
// Draw arrowhead
|
|
drawArrowhead(shape.x, shape.y, shape.x2, shape.y2, isSelected ? "#22d3ee" : shape.color)
|
|
// Reset line width
|
|
ctx.lineWidth = 2
|
|
} else if (shape.type === "text" && shape.text) {
|
|
ctx.font = "20px sans-serif"
|
|
ctx.fillStyle = shape.color
|
|
ctx.fillText(shape.text, shape.x, shape.y)
|
|
}
|
|
|
|
// Highlight selected shape
|
|
if (selectedShape === shape.id && shape.width && shape.height) {
|
|
ctx.strokeStyle = "#22d3ee"
|
|
ctx.lineWidth = 3
|
|
ctx.strokeRect(shape.x - 5, shape.y - 5, shape.width + 10, shape.height + 10)
|
|
}
|
|
|
|
// Show connection point (center dot) for shapes that can be connected
|
|
if (shape.type !== "arrow" && shape.type !== "line" && (shape.width || shape.height)) {
|
|
const center = getShapeCenter(shape)
|
|
ctx.fillStyle = "#22d3ee"
|
|
ctx.beginPath()
|
|
ctx.arc(center.x, center.y, 3, 0, 2 * Math.PI)
|
|
ctx.fill()
|
|
}
|
|
|
|
// Show value label if shape has a value
|
|
if (shape.value !== undefined && shape.type !== "arrow") {
|
|
ctx.fillStyle = "#fbbf24"
|
|
ctx.font = "bold 12px sans-serif"
|
|
ctx.fillText(`${shape.value}`, shape.x + 5, shape.y - 5)
|
|
}
|
|
})
|
|
|
|
// Draw current shape being drawn
|
|
if (currentShape && isDrawing) {
|
|
ctx.strokeStyle = currentShape.color || "#6366f1"
|
|
ctx.lineWidth = 2
|
|
ctx.fillStyle = currentShape.color || "#6366f1"
|
|
ctx.setLineDash([5, 5]) // Dashed line for preview
|
|
|
|
if (currentShape.type === "rectangle" && currentShape.width && currentShape.height) {
|
|
ctx.strokeRect(currentShape.x || 0, currentShape.y || 0, currentShape.width, currentShape.height)
|
|
} else if (currentShape.type === "line" && currentShape.x2 && currentShape.y2) {
|
|
ctx.beginPath()
|
|
ctx.moveTo(currentShape.x || 0, currentShape.y || 0)
|
|
ctx.lineTo(currentShape.x2, currentShape.y2)
|
|
ctx.stroke()
|
|
} else if (currentShape.type === "arrow" && currentShape.x2 && currentShape.y2) {
|
|
ctx.beginPath()
|
|
ctx.moveTo(currentShape.x || 0, currentShape.y || 0)
|
|
ctx.lineTo(currentShape.x2, currentShape.y2)
|
|
ctx.stroke()
|
|
drawArrowhead(currentShape.x || 0, currentShape.y || 0, currentShape.x2, currentShape.y2, currentShape.color || "#6366f1")
|
|
}
|
|
|
|
ctx.setLineDash([]) // Reset to solid line
|
|
}
|
|
}, [shapes, selectedShape, currentShape, isDrawing])
|
|
|
|
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const x = e.clientX - rect.left
|
|
const y = e.clientY - rect.top
|
|
|
|
if (tool === "select") {
|
|
// Find clicked shape (including arrows)
|
|
const clicked = shapes.find((shape) => isPointInShape(x, y, shape))
|
|
setSelectedShape(clicked?.id || null)
|
|
|
|
// If a shape was clicked, prepare for dragging (but not for arrows - they're connections)
|
|
if (clicked && clicked.type !== "arrow" && clicked.type !== "line") {
|
|
setIsDragging(true)
|
|
setDragOffset({
|
|
x: x - clicked.x,
|
|
y: y - clicked.y,
|
|
})
|
|
}
|
|
} else if (tool === "erase") {
|
|
// Find and delete clicked shape
|
|
const clicked = shapes.find((shape) => isPointInShape(x, y, shape))
|
|
|
|
if (clicked) {
|
|
// Cleanup propagator if deleting an arrow
|
|
if (clicked.type === "arrow") {
|
|
const propagator = propagators.get(clicked.id)
|
|
if (propagator) {
|
|
propagator.dispose()
|
|
setPropagators((prev) => {
|
|
const next = new Map(prev)
|
|
next.delete(clicked.id)
|
|
return next
|
|
})
|
|
}
|
|
setEventTargets((prev) => {
|
|
const next = new Map(prev)
|
|
next.delete(clicked.id)
|
|
return next
|
|
})
|
|
}
|
|
setShapes(shapes.filter((shape) => shape.id !== clicked.id))
|
|
setSelectedShape(null)
|
|
}
|
|
} else if (tool === "text") {
|
|
// Prompt for text input
|
|
const text = prompt("Enter text:")
|
|
if (text) {
|
|
const newShape: Shape = {
|
|
id: Date.now().toString(),
|
|
type: "text",
|
|
x,
|
|
y,
|
|
text,
|
|
color: "#6366f1",
|
|
}
|
|
setShapes([...shapes, newShape])
|
|
}
|
|
} else if (tool === "arrow") {
|
|
// Special handling for arrow tool - snap to shapes
|
|
const shapeAtClick = findShapeAt(x, y)
|
|
|
|
if (shapeAtClick) {
|
|
// Clicked on a shape - start arrow from its center
|
|
setArrowStartShape(shapeAtClick.id)
|
|
const center = getShapeCenter(shapeAtClick)
|
|
setIsDrawing(true)
|
|
setCurrentShape({
|
|
id: Date.now().toString(),
|
|
type: "arrow",
|
|
x: center.x,
|
|
y: center.y,
|
|
color: "#6366f1",
|
|
sourceShapeId: shapeAtClick.id,
|
|
})
|
|
}
|
|
} else if (tool === "draw" || tool === "rectangle") {
|
|
setIsDrawing(true)
|
|
setCurrentShape({
|
|
id: Date.now().toString(),
|
|
type: tool === "rectangle" ? "rectangle" : "line",
|
|
x,
|
|
y,
|
|
color: "#6366f1",
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
const x = e.clientX - rect.left
|
|
const y = e.clientY - rect.top
|
|
|
|
// Handle dragging selected shape
|
|
if (isDragging && selectedShape && tool === "select") {
|
|
setShapes(
|
|
shapes.map((shape) => {
|
|
if (shape.id === selectedShape) {
|
|
const newX = x - dragOffset.x
|
|
const newY = y - dragOffset.y
|
|
|
|
// For lines and arrows, also update the end points
|
|
if ((shape.type === "line" || shape.type === "arrow") && shape.x2 !== undefined && shape.y2 !== undefined) {
|
|
const dx = newX - shape.x
|
|
const dy = newY - shape.y
|
|
return {
|
|
...shape,
|
|
x: newX,
|
|
y: newY,
|
|
x2: shape.x2 + dx,
|
|
y2: shape.y2 + dy,
|
|
}
|
|
}
|
|
|
|
return {
|
|
...shape,
|
|
x: newX,
|
|
y: newY,
|
|
}
|
|
}
|
|
return shape
|
|
})
|
|
)
|
|
return
|
|
}
|
|
|
|
// Handle drawing new shapes
|
|
if (isDrawing && currentShape) {
|
|
if (currentShape.type === "rectangle") {
|
|
setCurrentShape({
|
|
...currentShape,
|
|
width: x - (currentShape.x || 0),
|
|
height: y - (currentShape.y || 0),
|
|
})
|
|
} else if (currentShape.type === "arrow") {
|
|
// For arrows, snap to target shape center if hovering over one
|
|
const shapeAtMouse = findShapeAt(x, y)
|
|
if (shapeAtMouse && shapeAtMouse.id !== arrowStartShape) {
|
|
const center = getShapeCenter(shapeAtMouse)
|
|
setCurrentShape({
|
|
...currentShape,
|
|
x2: center.x,
|
|
y2: center.y,
|
|
targetShapeId: shapeAtMouse.id,
|
|
})
|
|
} else {
|
|
setCurrentShape({
|
|
...currentShape,
|
|
x2: x,
|
|
y2: y,
|
|
targetShapeId: undefined,
|
|
})
|
|
}
|
|
} else if (currentShape.type === "line") {
|
|
setCurrentShape({
|
|
...currentShape,
|
|
x2: x,
|
|
y2: y,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleMouseUp = () => {
|
|
if (isDrawing && currentShape) {
|
|
let newShape = currentShape as Shape
|
|
|
|
// Normalize rectangles with negative dimensions (drawn upward/leftward)
|
|
if (newShape.type === "rectangle" && newShape.width !== undefined && newShape.height !== undefined) {
|
|
if (newShape.width < 0) {
|
|
newShape.x = newShape.x + newShape.width
|
|
newShape.width = Math.abs(newShape.width)
|
|
}
|
|
if (newShape.height < 0) {
|
|
newShape.y = newShape.y + newShape.height
|
|
newShape.height = Math.abs(newShape.height)
|
|
}
|
|
}
|
|
|
|
// If it's an arrow with both source and target, create a propagator
|
|
if (newShape.type === "arrow" && newShape.sourceShapeId && newShape.targetShapeId) {
|
|
newShape.expression = "value: from.value" // Default expression
|
|
setShapes([...shapes, newShape])
|
|
// Create propagator for this arrow
|
|
setTimeout(() => createPropagatorForArrow(newShape), 0)
|
|
} else {
|
|
setShapes([...shapes, newShape])
|
|
}
|
|
|
|
setCurrentShape(null)
|
|
}
|
|
setIsDrawing(false)
|
|
setIsDragging(false)
|
|
setArrowStartShape(null)
|
|
}
|
|
|
|
const toggleFullscreen = () => {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen()
|
|
setIsFullscreen(true)
|
|
} else {
|
|
document.exitFullscreen()
|
|
setIsFullscreen(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-900 text-white">
|
|
{/* Header */}
|
|
<header className="flex items-center justify-between p-4 border-b border-slate-700">
|
|
<h1 className="text-xl font-semibold text-cyan-400">Interactive Canvas</h1>
|
|
<Link href="/" className="text-cyan-400 hover:text-cyan-300 transition-colors">
|
|
← Back to Home
|
|
</Link>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex h-[calc(100vh-73px)]">
|
|
{/* Canvas */}
|
|
<div className="flex-1 relative">
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full h-full cursor-crosshair"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
/>
|
|
{currentShape && (
|
|
<div className="absolute top-4 left-4 bg-slate-800 px-4 py-2 rounded-lg text-sm">
|
|
Drawing {currentShape.type}...
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="w-80 bg-slate-800 p-6 space-y-6 overflow-y-auto">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-cyan-400 mb-4">FolkJS Canvas</h2>
|
|
<ul className="space-y-2 text-sm text-slate-300">
|
|
<li>
|
|
• Use toolbar to <span className="text-white font-medium">draw</span> and create shapes
|
|
</li>
|
|
<li>
|
|
• Click and drag to <span className="text-white font-medium">move</span> elements
|
|
</li>
|
|
<li>
|
|
• Double-click text to <span className="text-white font-medium">edit</span>
|
|
</li>
|
|
<li>
|
|
• Use <span className="text-white font-medium">select</span> tool to interact
|
|
</li>
|
|
<li>
|
|
• Press <kbd className="px-2 py-1 bg-slate-700 rounded text-xs">Space</kbd> for{" "}
|
|
<span className="text-white font-medium">fullscreen</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-semibold text-slate-400">Tools</h3>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{[
|
|
{ id: "select", label: "Select" },
|
|
{ id: "draw", label: "Draw" },
|
|
{ id: "erase", label: "Erase" },
|
|
{ id: "rectangle", label: "Rectangle" },
|
|
{ id: "text", label: "Text" },
|
|
{ id: "arrow", label: "Arrow" },
|
|
].map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setTool(t.id as Tool)}
|
|
className={`px-3 py-2 rounded text-sm transition-colors ${
|
|
tool === t.id ? "bg-cyan-500 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Arrow Expression Editor */}
|
|
{selectedShape && shapes.find((s) => s.id === selectedShape)?.type === "arrow" && (
|
|
<div className="space-y-2 p-4 bg-slate-700 rounded">
|
|
<h3 className="text-sm font-semibold text-cyan-400">Live Arrow Properties</h3>
|
|
{(() => {
|
|
const arrow = shapes.find((s) => s.id === selectedShape)
|
|
if (!arrow) return null
|
|
|
|
const sourceShape = arrow.sourceShapeId
|
|
? shapes.find((s) => s.id === arrow.sourceShapeId)
|
|
: null
|
|
const targetShape = arrow.targetShapeId
|
|
? shapes.find((s) => s.id === arrow.targetShapeId)
|
|
: null
|
|
|
|
return (
|
|
<div className="space-y-2 text-xs">
|
|
<div className="text-slate-300">
|
|
<span className="text-white font-medium">From:</span>{" "}
|
|
{sourceShape?.text || sourceShape?.id || "None"}
|
|
</div>
|
|
<div className="text-slate-300">
|
|
<span className="text-white font-medium">To:</span>{" "}
|
|
{targetShape?.text || targetShape?.id || "None"}
|
|
</div>
|
|
{arrow.sourceShapeId && arrow.targetShapeId && (
|
|
<>
|
|
<div className="text-slate-300">
|
|
<label className="text-white font-medium block mb-1">Expression:</label>
|
|
<input
|
|
type="text"
|
|
value={arrow.expression || "value: from.value"}
|
|
onChange={(e) => {
|
|
setShapes(
|
|
shapes.map((s) =>
|
|
s.id === arrow.id ? { ...s, expression: e.target.value } : s,
|
|
),
|
|
)
|
|
}}
|
|
className="w-full px-2 py-1 bg-slate-800 text-white rounded text-xs"
|
|
placeholder="value: from.value * 2"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
// Get EventTargets from the Map
|
|
const targets = eventTargets.get(arrow.id)
|
|
const sourceShape = shapes.find((s) => s.id === arrow.sourceShapeId)
|
|
|
|
if (!targets) {
|
|
console.warn("⚠️ No EventTarget found. Arrow may need to be re-created.")
|
|
alert("This arrow needs to be re-drawn. Please delete and create it again.")
|
|
return
|
|
}
|
|
|
|
if (!sourceShape || sourceShape.value === undefined) {
|
|
console.warn("⚠️ Source shape has no value set")
|
|
alert(`Please set a value on "${sourceShape?.text || "the source shape"}" first.`)
|
|
return
|
|
}
|
|
|
|
// Trigger the propagation
|
|
targets.source.dispatchEvent(new Event("update"))
|
|
}}
|
|
className="w-full px-2 py-1 bg-cyan-600 hover:bg-cyan-700 rounded text-xs transition-colors"
|
|
>
|
|
Test Propagation
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Shape Value Editor */}
|
|
{selectedShape && shapes.find((s) => s.id === selectedShape)?.type !== "arrow" && (
|
|
<div className="space-y-2 p-4 bg-slate-700 rounded">
|
|
<h3 className="text-sm font-semibold text-cyan-400">Shape Properties</h3>
|
|
{(() => {
|
|
const shape = shapes.find((s) => s.id === selectedShape)
|
|
if (!shape) return null
|
|
|
|
return (
|
|
<div className="space-y-2 text-xs">
|
|
<div className="text-slate-300">
|
|
<label className="text-white font-medium block mb-1">Value:</label>
|
|
<input
|
|
type="number"
|
|
value={shape.value || 0}
|
|
onChange={(e) => {
|
|
setShapes(
|
|
shapes.map((s) =>
|
|
s.id === shape.id ? { ...s, value: parseFloat(e.target.value) || 0 } : s,
|
|
),
|
|
)
|
|
}}
|
|
className="w-full px-2 py-1 bg-slate-800 text-white rounded text-xs"
|
|
/>
|
|
</div>
|
|
<div className="text-slate-300 text-xs">
|
|
Arrows connected from this shape will propagate this value.
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="space-y-2">
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="w-full px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm transition-colors"
|
|
>
|
|
{isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
|
</button>
|
|
<button
|
|
onClick={() => setShapes([])}
|
|
className="w-full px-4 py-2 bg-red-600 hover:bg-red-700 rounded text-sm transition-colors"
|
|
>
|
|
Clear Canvas
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|