Some checks failed
CI / build (push) Failing after 36s
WASM reactive UI framework for Go: - reactive/ - Signal[T], Effect, Runtime - ui/ - Button, Text, Input, View, Canvas, SVG components - navigation/ - Router, guards, history management - auth/ - OIDC client for WASM applications - host/ - Static file server Extracted from arcadia as open-source component. Co-Authored-By: Claude <noreply@anthropic.com>
1125 lines
33 KiB
Go
1125 lines
33 KiB
Go
//go:build js && wasm
|
|
|
|
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"syscall/js"
|
|
|
|
"git.flowmade.one/flowmade-one/iris/internal/element"
|
|
"git.flowmade.one/flowmade-one/iris/reactive"
|
|
)
|
|
|
|
// Note: Additional geometry functions are in canvas_geometry.go
|
|
|
|
// Point represents a 2D coordinate
|
|
type Point struct {
|
|
X float64
|
|
Y float64
|
|
}
|
|
|
|
// InteractionMode represents the current user interaction mode on the canvas
|
|
type InteractionMode int
|
|
|
|
const (
|
|
ModeIdle InteractionMode = iota
|
|
ModeDragging
|
|
ModePanning
|
|
ModeDrawingConnection
|
|
ModeResizing
|
|
)
|
|
|
|
// ResizeHandle represents which resize handle is being dragged
|
|
type ResizeHandle int
|
|
|
|
const (
|
|
HandleNone ResizeHandle = iota
|
|
HandleSE // Southeast (bottom-right)
|
|
HandleE // East (right)
|
|
HandleS // South (bottom)
|
|
)
|
|
|
|
// SelectionType indicates what kind of item is selected
|
|
type SelectionType int
|
|
|
|
const (
|
|
SelectionNone SelectionType = iota
|
|
SelectionItem
|
|
SelectionConnection
|
|
)
|
|
|
|
// Selection represents the current selection state
|
|
type Selection struct {
|
|
Type SelectionType
|
|
ID string
|
|
}
|
|
|
|
// ConnectionStyle defines the visual style for a connection
|
|
type ConnectionStyle struct {
|
|
Color string // Line color (default: "#333333")
|
|
StrokeWidth string // Line width (default: "2")
|
|
DashArray string // "" for solid, "8,4" for dashed, "2,2" for dotted
|
|
}
|
|
|
|
// CanvasConnection represents a connection between two items
|
|
type CanvasConnection struct {
|
|
ID string
|
|
FromID string
|
|
ToID string
|
|
Label string // Optional label displayed on the connection
|
|
Style ConnectionStyle
|
|
Data any // App-specific data (e.g., connection type)
|
|
}
|
|
|
|
// DrawingConnectionState represents a connection being drawn (temporary line)
|
|
type DrawingConnectionState struct {
|
|
FromID string
|
|
FromPoint Point
|
|
ToPoint Point
|
|
Style ConnectionStyle
|
|
}
|
|
|
|
// CanvasItem represents an item that can be placed on a canvas
|
|
type CanvasItem struct {
|
|
ID string
|
|
Position Point
|
|
Size Point // Width, Height
|
|
ZIndex int // Lower values render first (background), higher values on top
|
|
Resizable bool // If true, show resize handles when selected
|
|
Content func(selected bool) View
|
|
Data any // App-specific data
|
|
}
|
|
|
|
// CanvasConfig holds configuration options for the canvas
|
|
type CanvasConfig struct {
|
|
Width string // CSS width (e.g., "100%", "800px")
|
|
Height string // CSS height
|
|
Background string // Canvas background color
|
|
GridSize int // Snap-to-grid size (0 = disabled)
|
|
|
|
// Item callbacks
|
|
OnItemSelect func(id string)
|
|
OnItemMove func(id string, pos Point, delta Point)
|
|
OnItemDrag func(id string, pos Point, delta Point) // Called during drag (before mouseup)
|
|
OnItemResize func(id string, size Point) // Called when an item is resized
|
|
OnCanvasClick func(pos Point) // Called when clicking empty canvas area
|
|
OnContainerClick func(containerID string, pos Point) // Called when clicking inside a container (not header)
|
|
|
|
// Container detection - if set, items with ZIndex 0 are treated as containers
|
|
IsContainer func(id string) bool
|
|
|
|
// Connection drawing callbacks
|
|
OnEdgeDragStart func(itemID string, edgePoint Point) // Called when drag starts from element edge
|
|
OnEdgeDragMove func(currentPoint Point) // Called during edge drag with current position
|
|
OnEdgeDragEnd func(targetItemID string) // Called when edge drag ends (targetItemID is "" if on empty space)
|
|
|
|
// Connection callbacks
|
|
OnConnectionSelect func(id string) // Called when a connection is selected
|
|
}
|
|
|
|
// CanvasState holds all state for the canvas
|
|
type CanvasState struct {
|
|
Items reactive.Signal[[]CanvasItem]
|
|
|
|
// Unified selection state
|
|
Selection reactive.Signal[Selection]
|
|
|
|
// Connection state
|
|
Connections reactive.Signal[[]CanvasConnection]
|
|
DrawingConnection reactive.Signal[*DrawingConnectionState]
|
|
|
|
// Drag position overrides for connection rendering (reactive for live updates)
|
|
DragPositions reactive.Signal[map[string]Point]
|
|
|
|
// Viewport state (not reactive - only used for rendering)
|
|
viewportOffset Point
|
|
zoom float64
|
|
|
|
// Internal interaction state
|
|
mode InteractionMode
|
|
dragItemID string
|
|
dragStart Point
|
|
itemStartPos Point
|
|
panStart Point
|
|
draggedElement js.Value // Reference to DOM element being dragged
|
|
|
|
// Connection drawing source (used when mode == ModeDrawingConnection)
|
|
drawingSourceID string
|
|
|
|
// Resize state (used when mode == ModeResizing)
|
|
resizeHandle ResizeHandle
|
|
resizeItemID string
|
|
itemStartSize Point
|
|
resizedElement js.Value // Reference to DOM element being resized
|
|
|
|
// DOM element references for direct manipulation during drag
|
|
itemElements map[string]js.Value
|
|
itemStartPositions map[string]Point // Store start positions for drag delta calculation
|
|
}
|
|
|
|
// NewCanvasState creates a new canvas state
|
|
func NewCanvasState() *CanvasState {
|
|
return &CanvasState{
|
|
Items: reactive.NewSignal([]CanvasItem{}),
|
|
Selection: reactive.NewSignal(Selection{Type: SelectionNone, ID: ""}),
|
|
Connections: reactive.NewSignal([]CanvasConnection{}),
|
|
DrawingConnection: reactive.NewSignal[*DrawingConnectionState](nil),
|
|
DragPositions: reactive.NewSignal(map[string]Point{}),
|
|
zoom: 1.0,
|
|
itemElements: make(map[string]js.Value),
|
|
itemStartPositions: make(map[string]Point),
|
|
}
|
|
}
|
|
|
|
// AddItem adds an item to the canvas
|
|
func (s *CanvasState) AddItem(item CanvasItem) {
|
|
items := s.Items.Get()
|
|
items = append(items, item)
|
|
s.Items.Set(items)
|
|
}
|
|
|
|
// RemoveItem removes an item by ID
|
|
func (s *CanvasState) RemoveItem(id string) {
|
|
items := s.Items.Get()
|
|
for i, item := range items {
|
|
if item.ID == id {
|
|
items = append(items[:i], items[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
s.Items.Set(items)
|
|
|
|
// Clear selection if removed item was selected
|
|
sel := s.Selection.Get()
|
|
if sel.Type == SelectionItem && sel.ID == id {
|
|
s.Selection.Set(Selection{Type: SelectionNone, ID: ""})
|
|
}
|
|
}
|
|
|
|
// UpdateItem updates an item by ID using an update function
|
|
func (s *CanvasState) UpdateItem(id string, update func(*CanvasItem)) {
|
|
items := s.Items.Get()
|
|
for i := range items {
|
|
if items[i].ID == id {
|
|
update(&items[i])
|
|
break
|
|
}
|
|
}
|
|
s.Items.Set(items)
|
|
}
|
|
|
|
// MoveItemDOMByDelta moves an item's DOM element directly during drag (without triggering re-render)
|
|
func (s *CanvasState) MoveItemDOMByDelta(id string, delta Point) {
|
|
elem, ok := s.itemElements[id]
|
|
if !ok || elem.IsUndefined() || elem.IsNull() {
|
|
return
|
|
}
|
|
|
|
startPos, ok := s.itemStartPositions[id]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
newX := startPos.X + delta.X
|
|
newY := startPos.Y + delta.Y
|
|
|
|
elem.Get("style").Set("left", fmt.Sprintf("%fpx", newX))
|
|
elem.Get("style").Set("top", fmt.Sprintf("%fpx", newY))
|
|
}
|
|
|
|
// GetItemStartPosition returns the start position of an item (from before drag started)
|
|
func (s *CanvasState) GetItemStartPosition(id string) Point {
|
|
if pos, ok := s.itemStartPositions[id]; ok {
|
|
return pos
|
|
}
|
|
return Point{}
|
|
}
|
|
|
|
// UpdateItemPosition updates an item's position and its DOM element during drag
|
|
func (s *CanvasState) UpdateItemPosition(id string, pos Point) {
|
|
// Update DOM element directly
|
|
if elem, ok := s.itemElements[id]; ok && !elem.IsUndefined() && !elem.IsNull() {
|
|
elem.Get("style").Set("left", fmt.Sprintf("%fpx", pos.X))
|
|
elem.Get("style").Set("top", fmt.Sprintf("%fpx", pos.Y))
|
|
}
|
|
|
|
// Update in Items signal (for connection rendering)
|
|
items := s.Items.Get()
|
|
for i := range items {
|
|
if items[i].ID == id {
|
|
items[i].Position = pos
|
|
break
|
|
}
|
|
}
|
|
s.Items.Set(items)
|
|
}
|
|
|
|
// SelectItem selects an item by ID
|
|
func (s *CanvasState) SelectItem(id string) {
|
|
if id == "" {
|
|
s.Selection.Set(Selection{Type: SelectionNone, ID: ""})
|
|
} else {
|
|
s.Selection.Set(Selection{Type: SelectionItem, ID: id})
|
|
}
|
|
}
|
|
|
|
// ClearSelection deselects all items and connections
|
|
func (s *CanvasState) ClearSelection() {
|
|
s.Selection.Set(Selection{Type: SelectionNone, ID: ""})
|
|
}
|
|
|
|
// GetSelection returns the current selection state
|
|
func (s *CanvasState) GetSelection() Selection {
|
|
return s.Selection.Get()
|
|
}
|
|
|
|
// GetSelectedItem returns the currently selected item, or nil
|
|
func (s *CanvasState) GetSelectedItem() *CanvasItem {
|
|
sel := s.Selection.Get()
|
|
if sel.Type != SelectionItem || sel.ID == "" {
|
|
return nil
|
|
}
|
|
return s.GetItem(sel.ID)
|
|
}
|
|
|
|
// GetItem returns an item by ID, or nil if not found
|
|
func (s *CanvasState) GetItem(id string) *CanvasItem {
|
|
items := s.Items.Get()
|
|
for i := range items {
|
|
if items[i].ID == id {
|
|
return &items[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddConnection adds a connection to the canvas
|
|
func (s *CanvasState) AddConnection(conn CanvasConnection) {
|
|
conns := s.Connections.Get()
|
|
conns = append(conns, conn)
|
|
s.Connections.Set(conns)
|
|
}
|
|
|
|
// RemoveConnection removes a connection by ID
|
|
func (s *CanvasState) RemoveConnection(id string) {
|
|
conns := s.Connections.Get()
|
|
for i, conn := range conns {
|
|
if conn.ID == id {
|
|
conns = append(conns[:i], conns[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
s.Connections.Set(conns)
|
|
|
|
// Clear selection if removed connection was selected
|
|
sel := s.Selection.Get()
|
|
if sel.Type == SelectionConnection && sel.ID == id {
|
|
s.Selection.Set(Selection{Type: SelectionNone, ID: ""})
|
|
}
|
|
}
|
|
|
|
// UpdateConnection updates a connection by ID using an update function
|
|
func (s *CanvasState) UpdateConnection(id string, update func(*CanvasConnection)) {
|
|
conns := s.Connections.Get()
|
|
for i := range conns {
|
|
if conns[i].ID == id {
|
|
update(&conns[i])
|
|
break
|
|
}
|
|
}
|
|
s.Connections.Set(conns)
|
|
}
|
|
|
|
// SelectConnection selects a connection by ID
|
|
func (s *CanvasState) SelectConnection(id string) {
|
|
if id == "" {
|
|
s.Selection.Set(Selection{Type: SelectionNone, ID: ""})
|
|
} else {
|
|
s.Selection.Set(Selection{Type: SelectionConnection, ID: id})
|
|
}
|
|
}
|
|
|
|
// GetConnection returns a connection by ID, or nil if not found
|
|
func (s *CanvasState) GetConnection(id string) *CanvasConnection {
|
|
conns := s.Connections.Get()
|
|
for i := range conns {
|
|
if conns[i].ID == id {
|
|
return &conns[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetSelectedConnection returns the currently selected connection, or nil
|
|
func (s *CanvasState) GetSelectedConnection() *CanvasConnection {
|
|
sel := s.Selection.Get()
|
|
if sel.Type != SelectionConnection || sel.ID == "" {
|
|
return nil
|
|
}
|
|
return s.GetConnection(sel.ID)
|
|
}
|
|
|
|
// ScreenToCanvas converts screen coordinates to canvas coordinates
|
|
func (s *CanvasState) ScreenToCanvas(screen Point) Point {
|
|
return Point{
|
|
X: (screen.X - s.viewportOffset.X) / s.zoom,
|
|
Y: (screen.Y - s.viewportOffset.Y) / s.zoom,
|
|
}
|
|
}
|
|
|
|
// CanvasToScreen converts canvas coordinates to screen coordinates
|
|
func (s *CanvasState) CanvasToScreen(canvas Point) Point {
|
|
return Point{
|
|
X: canvas.X*s.zoom + s.viewportOffset.X,
|
|
Y: canvas.Y*s.zoom + s.viewportOffset.Y,
|
|
}
|
|
}
|
|
|
|
// Canvas creates a canvas component
|
|
func Canvas(state *CanvasState, config CanvasConfig) View {
|
|
// Create viewport container
|
|
viewport := element.NewElement("div")
|
|
viewport.Position("relative")
|
|
viewport.Overflow("hidden")
|
|
viewport.Width(config.Width)
|
|
viewport.Height(config.Height)
|
|
viewport.BackgroundColor(config.Background)
|
|
viewport.UserSelect("none")
|
|
|
|
// Create content container (holds all items, transforms for pan/zoom)
|
|
content := element.NewElement("div")
|
|
content.Position("absolute")
|
|
content.Left("0")
|
|
content.Top("0")
|
|
content.Width("100%")
|
|
content.Height("100%")
|
|
|
|
// Create SVG layer for connections (rendered first, so items appear on top)
|
|
connectionsSVG := SVG().
|
|
Width("100%").
|
|
Height("100%").
|
|
Position("absolute").
|
|
Left("0").
|
|
Top("0").
|
|
PointerEvents("none") // Let clicks pass through to items
|
|
|
|
// Add arrowhead marker definitions
|
|
defs := SVGDefs()
|
|
defs = defs.ChildSVG(createArrowMarker("default", "#333333"))
|
|
defs = defs.ChildSVG(createArrowMarker("selected", "#E74C3C"))
|
|
defs = defs.ChildSVG(createArrowMarker("drawing", "#95A5A6"))
|
|
connectionsSVG = connectionsSVG.ChildSVG(defs)
|
|
|
|
// Track content element for re-rendering
|
|
var contentRef = content
|
|
var svgRef = connectionsSVG
|
|
|
|
// Helper to render all items
|
|
renderItems := func() {
|
|
contentRef.ClearChildren()
|
|
|
|
// Clear element references (will be repopulated)
|
|
state.itemElements = make(map[string]js.Value)
|
|
state.itemStartPositions = make(map[string]Point)
|
|
|
|
// Add SVG layer first (if valid)
|
|
if svgRef.e.Value.Truthy() {
|
|
contentRef.Child(svgRef.e)
|
|
}
|
|
|
|
items := state.Items.Get()
|
|
sel := state.Selection.Get()
|
|
selectedID := ""
|
|
if sel.Type == SelectionItem {
|
|
selectedID = sel.ID
|
|
}
|
|
|
|
// Sort items by ZIndex (lower values render first/behind)
|
|
sortedItems := make([]CanvasItem, len(items))
|
|
copy(sortedItems, items)
|
|
sort.Slice(sortedItems, func(i, j int) bool {
|
|
return sortedItems[i].ZIndex < sortedItems[j].ZIndex
|
|
})
|
|
|
|
for _, item := range sortedItems {
|
|
if item.ID == "" {
|
|
continue // Skip invalid items
|
|
}
|
|
itemDiv := renderCanvasItem(state, item, item.ID == selectedID, config)
|
|
contentRef.Child(itemDiv)
|
|
|
|
// Store element reference and position for drag operations
|
|
state.itemElements[item.ID] = itemDiv.Value
|
|
state.itemStartPositions[item.ID] = item.Position
|
|
}
|
|
}
|
|
|
|
// Helper to render connections
|
|
renderConnections := func() {
|
|
// Clear existing connections (but keep defs)
|
|
newSVG := SVG().
|
|
Width("100%").
|
|
Height("100%").
|
|
Position("absolute").
|
|
Left("0").
|
|
Top("0").
|
|
PointerEvents("none")
|
|
|
|
// Re-add defs
|
|
newDefs := SVGDefs()
|
|
newDefs = newDefs.ChildSVG(createArrowMarker("default", "#333333"))
|
|
newDefs = newDefs.ChildSVG(createArrowMarker("selected", "#E74C3C"))
|
|
newDefs = newDefs.ChildSVG(createArrowMarker("drawing", "#95A5A6"))
|
|
newSVG = newSVG.ChildSVG(newDefs)
|
|
|
|
connections := state.Connections.Get()
|
|
items := state.Items.Get()
|
|
sel := state.Selection.Get()
|
|
selectedConnID := ""
|
|
if sel.Type == SelectionConnection {
|
|
selectedConnID = sel.ID
|
|
}
|
|
drawing := state.DrawingConnection.Get()
|
|
|
|
// Build item lookup map
|
|
itemMap := make(map[string]CanvasItem)
|
|
for _, item := range items {
|
|
itemMap[item.ID] = item
|
|
}
|
|
|
|
// Render existing connections
|
|
for _, conn := range connections {
|
|
fromItem, fromOK := itemMap[conn.FromID]
|
|
toItem, toOK := itemMap[conn.ToID]
|
|
if !fromOK || !toOK {
|
|
continue
|
|
}
|
|
|
|
isSelected := conn.ID == selectedConnID
|
|
group := renderConnection(state, conn, fromItem, toItem, isSelected, config)
|
|
newSVG = newSVG.ChildSVG(group)
|
|
}
|
|
|
|
// Render drawing connection (temporary line during drag)
|
|
if drawing != nil {
|
|
line := SVGLine(drawing.FromPoint.X, drawing.FromPoint.Y, drawing.ToPoint.X, drawing.ToPoint.Y).
|
|
Stroke("#95A5A6").
|
|
StrokeWidth("2").
|
|
StrokeDasharray("4,4").
|
|
MarkerEnd("url(#arrow-drawing)")
|
|
newSVG = newSVG.ChildSVG(line)
|
|
}
|
|
|
|
svgRef = newSVG
|
|
}
|
|
|
|
// Main reactive effect to re-render everything (for state changes)
|
|
// Order matters: connections first (creates SVG), then items (adds SVG and item divs)
|
|
reactive.NewEffect(func() {
|
|
// Subscribe to all relevant signals
|
|
_ = state.Items.Get()
|
|
_ = state.Selection.Get()
|
|
_ = state.Connections.Get()
|
|
_ = state.DrawingConnection.Get()
|
|
|
|
// Render connections first (updates svgRef)
|
|
renderConnections()
|
|
// Then render items (clears content and adds svgRef + items)
|
|
renderItems()
|
|
})
|
|
|
|
|
|
// Mouse down on viewport (for panning or clicking empty area)
|
|
viewport.OnWithEvent("mousedown", func(e js.Value) {
|
|
target := e.Get("target")
|
|
|
|
// Check if clicked on viewport background (not an item)
|
|
// This includes the viewport itself, the content container, or the SVG element
|
|
isBackground := target.Equal(viewport.Value) || target.Equal(contentRef.Value)
|
|
|
|
// Also check if clicked on the SVG element (connections layer)
|
|
// The SVG is regenerated on each render, so we check by tag name
|
|
tagName := target.Get("tagName").String()
|
|
if tagName == "svg" || tagName == "SVG" {
|
|
isBackground = true
|
|
}
|
|
|
|
if isBackground {
|
|
e.Call("preventDefault")
|
|
|
|
clientX := e.Get("clientX").Float()
|
|
clientY := e.Get("clientY").Float()
|
|
|
|
// Get viewport bounds
|
|
rect := viewport.Call("getBoundingClientRect")
|
|
x := clientX - rect.Get("left").Float()
|
|
y := clientY - rect.Get("top").Float()
|
|
|
|
// Clear selection when clicking background
|
|
state.ClearSelection()
|
|
if config.OnItemSelect != nil {
|
|
config.OnItemSelect("")
|
|
}
|
|
|
|
// Callback for canvas click
|
|
if config.OnCanvasClick != nil {
|
|
canvasPos := state.ScreenToCanvas(Point{X: x, Y: y})
|
|
config.OnCanvasClick(canvasPos)
|
|
}
|
|
|
|
// Start panning
|
|
state.mode = ModePanning
|
|
state.panStart = Point{X: clientX, Y: clientY}
|
|
}
|
|
})
|
|
|
|
// Global mouse move handler
|
|
doc := element.Document()
|
|
doc.OnWithEvent("mousemove", func(e js.Value) {
|
|
if state.mode == ModeIdle {
|
|
return
|
|
}
|
|
|
|
clientX := e.Get("clientX").Float()
|
|
clientY := e.Get("clientY").Float()
|
|
|
|
if state.mode == ModeDragging && !state.draggedElement.IsUndefined() && !state.draggedElement.IsNull() {
|
|
// Calculate new position
|
|
deltaX := (clientX - state.dragStart.X) / state.zoom
|
|
deltaY := (clientY - state.dragStart.Y) / state.zoom
|
|
|
|
newPos := Point{
|
|
X: state.itemStartPos.X + deltaX,
|
|
Y: state.itemStartPos.Y + deltaY,
|
|
}
|
|
|
|
// Snap to grid
|
|
newPos = snapToGrid(newPos, config.GridSize)
|
|
|
|
// Calculate snapped delta
|
|
snappedDelta := Point{
|
|
X: newPos.X - state.itemStartPos.X,
|
|
Y: newPos.Y - state.itemStartPos.Y,
|
|
}
|
|
|
|
// Update DOM directly during drag (don't trigger reactive re-render)
|
|
state.draggedElement.Get("style").Set("left", fmt.Sprintf("%fpx", newPos.X))
|
|
state.draggedElement.Get("style").Set("top", fmt.Sprintf("%fpx", newPos.Y))
|
|
|
|
// Notify about drag in progress (for moving children etc.)
|
|
if config.OnItemDrag != nil {
|
|
config.OnItemDrag(state.dragItemID, newPos, snappedDelta)
|
|
}
|
|
}
|
|
|
|
if state.mode == ModePanning {
|
|
deltaX := clientX - state.panStart.X
|
|
deltaY := clientY - state.panStart.Y
|
|
|
|
state.viewportOffset.X += deltaX
|
|
state.viewportOffset.Y += deltaY
|
|
state.panStart = Point{X: clientX, Y: clientY}
|
|
|
|
// Update transform
|
|
transform := fmt.Sprintf("translate(%fpx, %fpx) scale(%f)",
|
|
state.viewportOffset.X, state.viewportOffset.Y, state.zoom)
|
|
content.Transform(transform)
|
|
}
|
|
|
|
if state.mode == ModeDrawingConnection && config.OnEdgeDragMove != nil {
|
|
// Get viewport bounds
|
|
rect := viewport.Call("getBoundingClientRect")
|
|
x := clientX - rect.Get("left").Float()
|
|
y := clientY - rect.Get("top").Float()
|
|
canvasPos := state.ScreenToCanvas(Point{X: x, Y: y})
|
|
config.OnEdgeDragMove(canvasPos)
|
|
}
|
|
|
|
if state.mode == ModeResizing && !state.resizedElement.IsUndefined() && !state.resizedElement.IsNull() {
|
|
// Calculate delta
|
|
deltaX := (clientX - state.dragStart.X) / state.zoom
|
|
deltaY := (clientY - state.dragStart.Y) / state.zoom
|
|
|
|
// Calculate new size based on which handle is being dragged
|
|
newWidth := state.itemStartSize.X
|
|
newHeight := state.itemStartSize.Y
|
|
|
|
switch state.resizeHandle {
|
|
case HandleSE:
|
|
newWidth = state.itemStartSize.X + deltaX
|
|
newHeight = state.itemStartSize.Y + deltaY
|
|
case HandleE:
|
|
newWidth = state.itemStartSize.X + deltaX
|
|
case HandleS:
|
|
newHeight = state.itemStartSize.Y + deltaY
|
|
}
|
|
|
|
// Apply minimum size constraints
|
|
minWidth := DefaultCanvasDefaults.MinResizeWidth
|
|
minHeight := DefaultCanvasDefaults.MinResizeHeight
|
|
if newWidth < minWidth {
|
|
newWidth = minWidth
|
|
}
|
|
if newHeight < minHeight {
|
|
newHeight = minHeight
|
|
}
|
|
|
|
// Update DOM directly during resize (update the wrapper element)
|
|
state.resizedElement.Get("style").Set("width", fmt.Sprintf("%fpx", newWidth))
|
|
state.resizedElement.Get("style").Set("height", fmt.Sprintf("%fpx", newHeight))
|
|
}
|
|
})
|
|
|
|
// Global mouse up handler
|
|
doc.OnWithEvent("mouseup", func(e js.Value) {
|
|
if state.mode == ModeDragging {
|
|
// Calculate final position from current drag state
|
|
clientX := e.Get("clientX").Float()
|
|
clientY := e.Get("clientY").Float()
|
|
|
|
deltaX := (clientX - state.dragStart.X) / state.zoom
|
|
deltaY := (clientY - state.dragStart.Y) / state.zoom
|
|
|
|
finalPos := Point{
|
|
X: state.itemStartPos.X + deltaX,
|
|
Y: state.itemStartPos.Y + deltaY,
|
|
}
|
|
finalPos = snapToGrid(finalPos, config.GridSize)
|
|
|
|
// Check if this was a click (minimal movement) or a drag
|
|
dragThreshold := DefaultCanvasDefaults.DragThreshold
|
|
wasDrag := abs(deltaX) > dragThreshold || abs(deltaY) > dragThreshold
|
|
|
|
// Clear drag state BEFORE any callbacks (which may trigger re-render)
|
|
draggedItemID := state.dragItemID
|
|
state.mode = ModeIdle
|
|
state.dragItemID = ""
|
|
state.draggedElement = js.Undefined()
|
|
|
|
if wasDrag {
|
|
// Update the state with final position (triggers re-render)
|
|
state.UpdateItem(draggedItemID, func(item *CanvasItem) {
|
|
item.Position = finalPos
|
|
})
|
|
|
|
if config.OnItemMove != nil {
|
|
delta := Point{X: deltaX, Y: deltaY}
|
|
config.OnItemMove(draggedItemID, finalPos, delta)
|
|
}
|
|
} else {
|
|
// Only select on click (not drag) to avoid cascading effects
|
|
if config.OnItemSelect != nil {
|
|
config.OnItemSelect(draggedItemID)
|
|
} else {
|
|
state.SelectItem(draggedItemID)
|
|
}
|
|
}
|
|
|
|
// Early return since we handled ModeDragging
|
|
return
|
|
}
|
|
|
|
if state.mode == ModeDrawingConnection && config.OnEdgeDragEnd != nil {
|
|
clientX := e.Get("clientX").Float()
|
|
clientY := e.Get("clientY").Float()
|
|
|
|
// Find item under cursor
|
|
targetID := findItemUnderPoint(state, viewport, clientX, clientY)
|
|
|
|
// Don't connect to self
|
|
if targetID == state.drawingSourceID {
|
|
LogCanvasWarning(WarnSelfConnection, "cannot connect item %s to itself", targetID)
|
|
targetID = ""
|
|
}
|
|
|
|
config.OnEdgeDragEnd(targetID)
|
|
}
|
|
|
|
if state.mode == ModeResizing {
|
|
// Calculate final size
|
|
clientX := e.Get("clientX").Float()
|
|
clientY := e.Get("clientY").Float()
|
|
|
|
deltaX := (clientX - state.dragStart.X) / state.zoom
|
|
deltaY := (clientY - state.dragStart.Y) / state.zoom
|
|
|
|
newWidth := state.itemStartSize.X
|
|
newHeight := state.itemStartSize.Y
|
|
|
|
switch state.resizeHandle {
|
|
case HandleSE:
|
|
newWidth = state.itemStartSize.X + deltaX
|
|
newHeight = state.itemStartSize.Y + deltaY
|
|
case HandleE:
|
|
newWidth = state.itemStartSize.X + deltaX
|
|
case HandleS:
|
|
newHeight = state.itemStartSize.Y + deltaY
|
|
}
|
|
|
|
// Apply minimum size constraints
|
|
minWidth := DefaultCanvasDefaults.MinResizeWidth
|
|
minHeight := DefaultCanvasDefaults.MinResizeHeight
|
|
if newWidth < minWidth {
|
|
newWidth = minWidth
|
|
}
|
|
if newHeight < minHeight {
|
|
newHeight = minHeight
|
|
}
|
|
|
|
finalSize := Point{X: newWidth, Y: newHeight}
|
|
|
|
// Clear resize state BEFORE callbacks
|
|
resizedItemID := state.resizeItemID
|
|
state.mode = ModeIdle
|
|
state.resizeItemID = ""
|
|
state.resizeHandle = HandleNone
|
|
state.resizedElement = js.Undefined()
|
|
|
|
// Update state with final size (triggers re-render)
|
|
state.UpdateItem(resizedItemID, func(item *CanvasItem) {
|
|
item.Size = finalSize
|
|
})
|
|
|
|
if config.OnItemResize != nil {
|
|
config.OnItemResize(resizedItemID, finalSize)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Reset all interaction state
|
|
state.mode = ModeIdle
|
|
state.dragItemID = ""
|
|
state.drawingSourceID = ""
|
|
state.draggedElement = js.Undefined()
|
|
state.resizeItemID = ""
|
|
state.resizeHandle = HandleNone
|
|
state.resizedElement = js.Undefined()
|
|
})
|
|
|
|
viewport.Child(content)
|
|
return View{viewport}
|
|
}
|
|
|
|
// renderCanvasItem creates a DOM element for a canvas item
|
|
func renderCanvasItem(state *CanvasState, item CanvasItem, selected bool, config CanvasConfig) element.Element {
|
|
wrapper := element.NewElement("div")
|
|
wrapper.Attr("data-item-id", item.ID) // Add ID for DOM queries
|
|
wrapper.Position("absolute")
|
|
wrapper.Left(fmt.Sprintf("%fpx", item.Position.X))
|
|
wrapper.Top(fmt.Sprintf("%fpx", item.Position.Y))
|
|
wrapper.Cursor("grab")
|
|
|
|
// Set explicit size on wrapper if item has size defined
|
|
// This is needed for resizable items where content uses 100% width/height
|
|
if item.Size.X > 0 && item.Size.Y > 0 {
|
|
wrapper.Width(fmt.Sprintf("%fpx", item.Size.X))
|
|
wrapper.Height(fmt.Sprintf("%fpx", item.Size.Y))
|
|
}
|
|
|
|
// Render content
|
|
if item.Content != nil {
|
|
contentView := item.Content(selected)
|
|
if contentView.e.Value.Truthy() {
|
|
wrapper.Child(contentView.e)
|
|
}
|
|
}
|
|
|
|
// Add resize handles if item is resizable and selected
|
|
if item.Resizable && selected {
|
|
cornerSize := fmt.Sprintf("%.0fpx", DefaultCanvasDefaults.CornerHandleSize)
|
|
|
|
// Southeast handle (bottom-right corner) - main resize handle
|
|
seHandle := element.NewElement("div")
|
|
seHandle.Position("absolute")
|
|
seHandle.Right("0")
|
|
seHandle.Bottom("0")
|
|
seHandle.Width(cornerSize)
|
|
seHandle.Height(cornerSize)
|
|
seHandle.BackgroundColor("#607D8B")
|
|
seHandle.Cursor("se-resize")
|
|
seHandle.Set("style", seHandle.Get("style").String()+"; border-radius: 2px; z-index: 10;")
|
|
|
|
// Add mousedown handler for resize
|
|
seHandle.OnWithEvent("mousedown", func(e js.Value) {
|
|
e.Call("stopPropagation")
|
|
e.Call("preventDefault")
|
|
|
|
clientX := e.Get("clientX").Float()
|
|
clientY := e.Get("clientY").Float()
|
|
|
|
// Get current item state
|
|
currentItem := state.GetItem(item.ID)
|
|
if currentItem == nil {
|
|
LogCanvasWarning(WarnItemNotFound, "item %s not found during SE resize", item.ID)
|
|
return
|
|
}
|
|
|
|
// Start resize mode
|
|
state.mode = ModeResizing
|
|
state.resizeHandle = HandleSE
|
|
state.resizeItemID = item.ID
|
|
state.dragStart = Point{X: clientX, Y: clientY}
|
|
state.itemStartSize = currentItem.Size
|
|
state.resizedElement = e.Get("currentTarget").Get("parentElement")
|
|
})
|
|
|
|
wrapper.Child(seHandle)
|
|
|
|
edgeWidth := fmt.Sprintf("%.0fpx", DefaultCanvasDefaults.EdgeHandleWidth)
|
|
edgeLength := fmt.Sprintf("%.0fpx", DefaultCanvasDefaults.EdgeHandleLength)
|
|
|
|
// East handle (right edge)
|
|
eHandle := element.NewElement("div")
|
|
eHandle.Position("absolute")
|
|
eHandle.Right("0")
|
|
eHandle.Top("50%")
|
|
eHandle.Width(edgeWidth)
|
|
eHandle.Height(edgeLength)
|
|
eHandle.BackgroundColor("#607D8B")
|
|
eHandle.Cursor("e-resize")
|
|
eHandle.Set("style", eHandle.Get("style").String()+"; border-radius: 2px; transform: translateY(-50%); z-index: 10;")
|
|
|
|
eHandle.OnWithEvent("mousedown", func(e js.Value) {
|
|
e.Call("stopPropagation")
|
|
e.Call("preventDefault")
|
|
|
|
clientX := e.Get("clientX").Float()
|
|
clientY := e.Get("clientY").Float()
|
|
|
|
currentItem := state.GetItem(item.ID)
|
|
if currentItem == nil {
|
|
LogCanvasWarning(WarnItemNotFound, "item %s not found during E resize", item.ID)
|
|
return
|
|
}
|
|
|
|
state.mode = ModeResizing
|
|
state.resizeHandle = HandleE
|
|
state.resizeItemID = item.ID
|
|
state.dragStart = Point{X: clientX, Y: clientY}
|
|
state.itemStartSize = currentItem.Size
|
|
state.resizedElement = e.Get("currentTarget").Get("parentElement")
|
|
})
|
|
|
|
wrapper.Child(eHandle)
|
|
|
|
// South handle (bottom edge)
|
|
sHandle := element.NewElement("div")
|
|
sHandle.Position("absolute")
|
|
sHandle.Bottom("0")
|
|
sHandle.Left("50%")
|
|
sHandle.Width(edgeLength) // Swapped: length is horizontal for bottom edge
|
|
sHandle.Height(edgeWidth) // Swapped: width is vertical for bottom edge
|
|
sHandle.BackgroundColor("#607D8B")
|
|
sHandle.Cursor("s-resize")
|
|
sHandle.Set("style", sHandle.Get("style").String()+"; border-radius: 2px; transform: translateX(-50%); z-index: 10;")
|
|
|
|
sHandle.OnWithEvent("mousedown", func(e js.Value) {
|
|
e.Call("stopPropagation")
|
|
e.Call("preventDefault")
|
|
|
|
clientX := e.Get("clientX").Float()
|
|
clientY := e.Get("clientY").Float()
|
|
|
|
currentItem := state.GetItem(item.ID)
|
|
if currentItem == nil {
|
|
LogCanvasWarning(WarnItemNotFound, "item %s not found during S resize", item.ID)
|
|
return
|
|
}
|
|
|
|
state.mode = ModeResizing
|
|
state.resizeHandle = HandleS
|
|
state.resizeItemID = item.ID
|
|
state.dragStart = Point{X: clientX, Y: clientY}
|
|
state.itemStartSize = currentItem.Size
|
|
state.resizedElement = e.Get("currentTarget").Get("parentElement")
|
|
})
|
|
|
|
wrapper.Child(sHandle)
|
|
}
|
|
|
|
// Capture item data for closure - avoid referencing item directly
|
|
itemID := item.ID
|
|
itemPosition := item.Position
|
|
itemSize := item.Size
|
|
|
|
// Check if this item is a container
|
|
isContainer := config.IsContainer != nil && config.IsContainer(itemID)
|
|
|
|
// Mouse down on item (for dragging, selection, or connection drawing)
|
|
wrapper.OnWithEvent("mousedown", func(e js.Value) {
|
|
e.Call("stopPropagation")
|
|
e.Call("preventDefault")
|
|
|
|
// Get click position relative to element BEFORE any state changes
|
|
// Use currentTarget instead of wrapper reference (which may be stale after re-render)
|
|
target := e.Get("currentTarget")
|
|
rect := target.Call("getBoundingClientRect")
|
|
clickX := e.Get("clientX").Float() - rect.Get("left").Float()
|
|
clickY := e.Get("clientY").Float() - rect.Get("top").Float()
|
|
elemWidth := rect.Get("width").Float()
|
|
elemHeight := rect.Get("height").Float()
|
|
clientX := e.Get("clientX").Float()
|
|
clientY := e.Get("clientY").Float()
|
|
|
|
// For containers: check if click is in the header area
|
|
// If clicking inside the container body (not header), pass through to OnContainerClick
|
|
containerHeaderHeight := DefaultCanvasDefaults.ContainerHeaderHeight
|
|
if isContainer && clickY > containerHeaderHeight {
|
|
// Click inside container body - treat as canvas click to create element inside
|
|
if config.OnContainerClick != nil {
|
|
// Get canvas coordinates for the click position
|
|
viewportRect := target.Get("parentElement").Call("getBoundingClientRect")
|
|
x := clientX - viewportRect.Get("left").Float()
|
|
y := clientY - viewportRect.Get("top").Float()
|
|
canvasPos := state.ScreenToCanvas(Point{X: x, Y: y})
|
|
config.OnContainerClick(itemID, canvasPos)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check if click is in edge zone (for connection drawing)
|
|
if isNearEdge(clickX, clickY, elemWidth, elemHeight) && config.OnEdgeDragStart != nil {
|
|
// Edge drag - start connection drawing
|
|
// Use captured item data for edge point calculation
|
|
tempItem := CanvasItem{ID: itemID, Position: itemPosition, Size: itemSize}
|
|
edgePoint := calculateEdgePoint(tempItem, clickX, clickY, elemWidth, elemHeight)
|
|
state.mode = ModeDrawingConnection
|
|
state.drawingSourceID = itemID
|
|
config.OnEdgeDragStart(itemID, edgePoint)
|
|
} else {
|
|
// Center drag - element dragging
|
|
// Get current position from state (not captured value which may be stale)
|
|
currentItem := state.GetItem(itemID)
|
|
if currentItem == nil {
|
|
LogCanvasWarning(WarnItemNotFound, "item %s not found during drag start", itemID)
|
|
return
|
|
}
|
|
currentPos := currentItem.Position
|
|
|
|
// Set up drag state - do NOT trigger selection during drag
|
|
// Selection will cause re-render which invalidates our DOM reference
|
|
state.mode = ModeDragging
|
|
state.dragItemID = itemID
|
|
state.dragStart = Point{X: clientX, Y: clientY}
|
|
state.itemStartPos = currentPos
|
|
state.draggedElement = target // Store reference to DOM element for direct manipulation
|
|
}
|
|
})
|
|
|
|
return wrapper
|
|
}
|
|
|
|
// createArrowMarker creates an SVG marker element for arrowheads
|
|
func createArrowMarker(id, color string) SVGElement {
|
|
path := SVGPath("M0,0 L0,6 L9,3 z").Fill(color)
|
|
|
|
marker := SVGMarker("arrow-" + id).
|
|
MarkerWidth("10").
|
|
MarkerHeight("10").
|
|
RefX("9").
|
|
RefY("3").
|
|
Orient("auto").
|
|
MarkerUnits("strokeWidth").
|
|
ChildSVG(path)
|
|
|
|
return marker
|
|
}
|
|
|
|
// renderConnection creates an SVG group for a connection with click handling
|
|
func renderConnection(state *CanvasState, conn CanvasConnection, fromItem, toItem CanvasItem, isSelected bool, config CanvasConfig) SVGElement {
|
|
// Calculate center points
|
|
fromCenter := Point{
|
|
X: fromItem.Position.X + fromItem.Size.X/2,
|
|
Y: fromItem.Position.Y + fromItem.Size.Y/2,
|
|
}
|
|
toCenter := Point{
|
|
X: toItem.Position.X + toItem.Size.X/2,
|
|
Y: toItem.Position.Y + toItem.Size.Y/2,
|
|
}
|
|
|
|
// Calculate edge points
|
|
fromEdge := calculateConnectionEdgePoint(fromCenter, toCenter, fromItem.Size)
|
|
toEdge := calculateConnectionEdgePoint(toCenter, fromCenter, toItem.Size)
|
|
|
|
group := SVGGroup()
|
|
|
|
// Invisible wider line for easier clicking (hit area)
|
|
hitWidth := fmt.Sprintf("%.0f", DefaultCanvasDefaults.ConnectionHitWidth)
|
|
hitArea := SVGLine(fromEdge.X, fromEdge.Y, toEdge.X, toEdge.Y).
|
|
Stroke("transparent").
|
|
StrokeWidth(hitWidth).
|
|
PointerEvents("stroke").
|
|
Cursor("pointer")
|
|
|
|
// Add click handler for hit area
|
|
connID := conn.ID
|
|
hitArea = hitArea.OnClick(func() {
|
|
state.SelectConnection(connID)
|
|
if config.OnConnectionSelect != nil {
|
|
config.OnConnectionSelect(connID)
|
|
}
|
|
})
|
|
|
|
// Visible line with selection styling
|
|
var visibleLine SVGElement
|
|
if isSelected {
|
|
visibleLine = SVGLine(fromEdge.X, fromEdge.Y, toEdge.X, toEdge.Y).
|
|
Stroke("#E74C3C").
|
|
StrokeWidth("3").
|
|
MarkerEnd("url(#arrow-selected)")
|
|
} else {
|
|
color := conn.Style.Color
|
|
if color == "" {
|
|
color = "#333333"
|
|
}
|
|
strokeWidth := conn.Style.StrokeWidth
|
|
if strokeWidth == "" {
|
|
strokeWidth = "2"
|
|
}
|
|
visibleLine = SVGLine(fromEdge.X, fromEdge.Y, toEdge.X, toEdge.Y).
|
|
Stroke(color).
|
|
StrokeWidth(strokeWidth).
|
|
MarkerEnd("url(#arrow-default)")
|
|
|
|
if conn.Style.DashArray != "" {
|
|
visibleLine = visibleLine.StrokeDasharray(conn.Style.DashArray)
|
|
}
|
|
}
|
|
visibleLine = visibleLine.PointerEvents("none") // Let hit area handle clicks
|
|
|
|
group = group.ChildSVG(hitArea).ChildSVG(visibleLine)
|
|
|
|
// Add label if present
|
|
if conn.Label != "" {
|
|
// Calculate midpoint of the line
|
|
midX := (fromEdge.X + toEdge.X) / 2
|
|
midY := (fromEdge.Y + toEdge.Y) / 2
|
|
|
|
// Add a small background rect for readability
|
|
labelWidth := DefaultCanvasDefaults.LabelBackgroundWidth
|
|
labelHeight := DefaultCanvasDefaults.LabelBackgroundHeight
|
|
labelBg := SVGRect(midX-labelWidth/2, midY-labelHeight/2, labelWidth, labelHeight).
|
|
Fill("#FFFFFF").
|
|
Stroke("#CCCCCC").
|
|
StrokeWidth("1").
|
|
Attr("rx", "3").
|
|
Attr("ry", "3").
|
|
PointerEvents("none")
|
|
|
|
// Add the label text
|
|
labelText := SVGText(conn.Label, midX, midY).
|
|
FontSize("12px").
|
|
FontFamily("sans-serif").
|
|
TextAnchor("middle").
|
|
DominantBaseline("middle").
|
|
Fill("#333333").
|
|
PointerEvents("none")
|
|
|
|
group = group.ChildSVG(labelBg).ChildSVG(labelText)
|
|
}
|
|
|
|
return group
|
|
}
|