Files
iris/ui/canvas.go
Hugo Nijhuis 00d98879d3
Some checks failed
CI / build (push) Failing after 36s
Initial iris repository structure
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>
2026-01-08 19:23:49 +01:00

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
}