//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 }