ContextMenuButton Data Binding to Node property

Is it possible to bind a visual property of a contextmenubutton to a property of the shape you have context clicked on?

For example, in the Block Editor sample: Simple Block Editor (gojs.net)

you may wish to bind the visible property of the ContextMenuButton to the current shape of the node so that that specific Shape does not appear in the menulist:

eg ContextMenuButton.visible = (ContextMenuButton.Shape !== Node.Shape)

Yes, it’s common to include a Binding of the GraphObject.visible property on a “ContextMenuButton”:

    $("ContextMenuButton",
      new go.Binding("visible", "text", t => t.indexOf("e") >= 0),
      . . .

where the binding source property is “text” on the node data object.

Thanks Walter. This worked for me.

One quick follow up.
I also tried to bind to a property of something else on the shape which I I know can be done for Nodes.

I tried this expression:

new go.Binding("visible", "visible", function (v) { return v; }).ofObject('buttonDocumentation') 

However couldn’t seem to make this work. Is this supported for ContextMenuButtons?

It could, if you have a GraphObject named “buttonDocumentation” in your context menu.

Usually, though, you bind to data properties, just like the Node itself.

If you really need it, you can get to the Node’s “buttonDocumentation” object via a conversion function going through the Adornment to the Adornment.adornedObject or Adornment.adornedPart.

new go.Binding("...", "adornedObject", obj => obj.part.findObject("...").someProp).ofObject()

I haven’t tried this, so pardon any typos or thinkos on my part.

Your suggestion helped.
This is the actual expression that worked for me:

new go.Binding("visible", "key", function (v){ return !myDiagram.findNodeForKey(v).part.findObject('buttonDocumentation').visible; } )

I guess I needed to find the node first using findNodeForKey and then pick up the visible property of the node.
Seem reasonable?

Sorry, it does not seem reasonable to me. How is that “buttonDocumentation” object’s GraphObject.visible property being controlled? Surely it’s not constant or else it wouldn’t make sense to have a binding at all. So there must be something causing its visibility to change.

If that change is implemented by a binding, then why can’t you depend on the same source rather than on that button?

Thanks Walter.

The complete example below should illuminate my problem, and show why the data binding on the context menu button versus the actual node is behaving differently. The idea is that the button and the relevant context menu item display at the right time. Should be obvious if you open the example I have provided.

You will see I have two nodes, one node (“Business Partner”) is initialized (in the data model) with some initial documentation content. The context menu works fine for this node. The other node (“Cost Centre”) is not initialized with a documentation value. And the binding to display hide the context menu button does not behave properly for context menu button on this node. However the same data binding expression works fine, to show / hide the actual node button.

The version of the contextmenu binding using part.findObject (shown commented out) works fine. The problem appears to relate to the fact that for one of the nodes the documentation property is not initialized, but it seems to me that data binding on the node versus the contextmenu works differently.

One final question, when I remove height/width property for graphObject “textBlockDocumentation” (line 117), the node location jumps when I invoke the custom editor. Can you explain why because this object is hidden?

<!DOCTYPE html>
<html lang="en">
<head>
    <title>ContextMenu Test</title>    
    <script src="https://unpkg.com/gojs/release/go-debug.js"></script>

<script>

function init() {

  var $ = go.GraphObject.make;  

  var myDiagram = $(go.Diagram, "myDiagramDiv");

  // custom editor #####################
  var customEditor = new go.HTMLInfo();  // Create an HTMLInfo and dynamically create HTML to show/hide
  var documentContainerDiv = document.getElementById("documentContainer");

  customEditor.show = function (textBlock, diagram, tool) {
    if (!(textBlock instanceof go.TextBlock)) return;

    const buttonDoc = textBlock.part.findObject('buttonDocumentation'); // doesnt work for button panels

    var loc = buttonDoc.getDocumentPoint(go.Spot.BottomLeft);
    var pos = diagram.transformDocToView(loc);

    documentContainerDiv.style.display = 'block';
    documentContainerDiv.style.left = (pos.x) + "px";
    documentContainerDiv.style.top = (pos.y + 20) + "px";

    documentContainerDiv.style.display = 'block';             // Unhide div
    var textContentDiv = document.getElementById("documentEditor")
    textContentDiv.value = textBlock.text;
  }

  customEditor.hide = function (diagram, tool) {
    documentContainerDiv.style.display = 'none';
  }

  customEditor.valueFunction = function () {
    var textContentDiv = document.getElementById("documentEditor")
    return textContentDiv.value;
  }

  // end of custom editor
  // ####################################




  function editDoc(e, obj) {

    var nodeObj;
    if (obj.name === "Add Comment") 
    {
      nodeObj = myDiagram.findNodeForKey(obj.part.data.key); // if called from right mouse click,  then add need to get node associated with the context
      // Initialize comment
      const docoTemplate =
        '<h1>' + obj.part.data.key + '</h1>\n' +
        '<h3>Definition:</h3>\n<p style=\"padding-left: 40px;\">enter definition here...</p>\n';
      myDiagram.startTransaction("set init doc");
      myDiagram.model.setDataProperty(obj.part.data, "documentation", docoTemplate);
      myDiagram.commitTransaction("set init doc");
    }
    else
      nodeObj = obj;


    var textBlock = nodeObj.part.findObject('textBlockDocumentation');
    if (myDiagram.commandHandler.canEditTextBlock(textBlock))
      myDiagram.commandHandler.editTextBlock(textBlock);

  }

  function removeComment(e, obj) {
    myDiagram.commit(function (d) {
      d.model.setDataProperty(obj.part.data, "documentation", "");
    }, "remove comment");
  }


  var contextMenuButtonArray = [];

  // This menu item calls editDoc which opens up the editor to allow the user to start creating a comment for th enode
  contextMenuButtonArray.push($("ContextMenuButton", { name: "Add Comment" }
    , new go.Binding("visible", "documentation", function (v) { return v.length === 0; }) // THIS BINDING DOESN'T WORK PROPERLY
    //, new go.Binding("visible", "key", function (v){ return !myDiagram.findNodeForKey(v).part.findObject('buttonDocumentation').visible; } )
    , $(go.TextBlock, "Add Comment", { margin: 4 }), { click: editDoc })
  );

  // This menu item removes the comment
  contextMenuButtonArray.push($("ContextMenuButton", { name: "Remove Comment" }
    , new go.Binding("visible", "documentation", function (v) { return (v.length !== 0); }) // THIS BINDING DOESN'T WORK PROPERLY
    //, new go.Binding("visible", "key", function (v){ return myDiagram.findNodeForKey(v).part.findObject('buttonDocumentation').visible; } )
    , $(go.TextBlock, "Remove Comment", { margin: 4 }), { click: removeComment }));

  var entityTemplate =
    $(go.Node, "Auto", { resizable: true, minSize: new go.Size(100, 100) },
      $(go.Shape, { figure: "Rectangle", strokeWidth: 2, stretch: go.GraphObject.Fill, fill: "lightgray" }),
      $(go.TextBlock,
        {
          name: "textBlockTitle"
          , cursor: "default"
          , textAlign: "center"
          , margin: 10
          , minSize: new go.Size(100, 100)
          , stretch: go.GraphObject.Fill
          , font: "18px sans-serif"
          , editable: true
        }
        , new go.Binding("text", "title").makeTwoWay() 
      ),

      $(go.TextBlock   // Text block for the node documentation, this is always hidden because I have a custom editor
        , {
            name: "textBlockDocumentation"
          , height: 500, width: 200 // this line should be irrelevant but if I remove, the location changes when the editor is invoked
          , textEditor: customEditor
          , visible: false
        }
        , new go.Binding("text", "documentation").makeTwoWay()
      ),
      $("Button"
        , {
          name: "buttonDocumentation"
          , alignment: new go.Spot(0, 1, 6, -6)
          , alignmentFocus: go.Spot.BottomLeft
          , "ButtonBorder.fill": "transparent"
          , "ButtonBorder.strokeWidth": 0
          , "ButtonBorder.margin": 0
          , click: editDoc
          , visible: false
        }
        , new go.Binding("visible", "documentation", function (v) { return v.length > 0; })

        , $(go.Shape,  // the button picture
          {
            width: 16, height: 22, strokeWidth: 2
          , geometryString: "M0,0 h14 L22,8 v20 h-22 z M5,14 h11.5 M5,18 h11.5 M5,22 h11.5 M14,0 v8h8"
          }
        )
      )

      , { contextMenu: $("ContextMenu", contextMenuButtonArray) }
    );

  myDiagram.nodeTemplate = entityTemplate;

  var nodeDataArray = [
    { "key": "Business Partner", "title": "Business Partner", "documentation": "<h1>Business Partner</h1>\n<h3>Definition:</h3>\n<p style=\"padding-left: 40px;\">enter definition here...</p>" },
    { "key": "Cost Centre", "title": "Cost Centre" }
  ];

  myDiagram.model = new go.GraphLinksModel(nodeDataArray, []);
}

window.addEventListener('DOMContentLoaded', init);

</script>

</head>

<body>

  
  <div id="myDiagramDiv" style="border: solid 1px blue; width:1000px; height:1000px"></div>
  <div id="documentContainer" style="position: absolute; display: none; z-index: 1000;">
    <textarea id="documentEditor" style="height: 300px; width:500px"></textarea>
  </div>

</body>

</html>

I see what you mean. The problem is that because the context menu is being reused each time it is shown, there is no (reset) initial value for any of the properties such as visible. So when the context menu is data-bound again, to some data that does not have the property that you care about, such as “documentation”, then the binding is not evaluated and the target property remains at whatever value it had before.

I think this would be clearer and more efficient:

  contextMenuButtonArray.push($("ContextMenuButton", { name: "Add Comment", visible: false }
    , new go.Binding("visible", "adornedPart", part => !part.findObject('buttonDocumentation').visible).ofObject()
    , $(go.TextBlock, "Add Comment", { margin: 4 }), { click: editDoc })
  );

  // This menu item removes the comment
  contextMenuButtonArray.push($("ContextMenuButton", { name: "Remove Comment", visible: false }
    , new go.Binding("visible", "adornedPart", part => part.findObject('buttonDocumentation').visible).ofObject()
    , $(go.TextBlock, "Remove Comment", { margin: 4 }), { click: removeComment }));

I’m unsure about your final question. I’ll look into it when I have time later today.

Regarding your other question, if you look at the Node.actualBounds both before and after invoking the “Add Comment” context menu button, you will see that the node has not changed position or size.

What has happened is that the TextEditingTool checks whether the text editing area is completely within the viewport or not. It really wouldn’t do to show the editor way off-screen. If it’s not within the viewport, it scrolls so that the editing area is within the viewport, as much as it can.

In this case because you have made that TextBlock not visible, it doesn’t have a real size because GoJS doesn’t bother to measure it. Because its size is NaN x NaN I bet the scroll-to-be-in-the-viewport algorithm isn’t doing exactly the right thing when the size is not real. I must say that it’s rather unusual to be editing a TextBlock that is not visible. I’m surprised that the TextEditingTool didn’t just refuse to edit it because it’s invisible. But because that wasn’t a consideration, dealing with NaN values wasn’t anticipated.

Thanks Walter, the reason why I was editing a TextBlock that was not visible, was that I am using a third party editor (not in the sample code I posted here - which I simplified). I therefore presumed I needed to have this hidden text block to copy the text from the rich text editor back to the hidden TextBlock (in customEditor.valueFunction) so it could be bound from the node to the data model. Does this makes sense, or is that wrong thing to do?

I suppose that’s OK. You could try setting the TextBlock’s width and height to 0.

Thanks. I agree your code using adornedPart is better and cleaner. If I change it an HTML menu, I presume I can’t use adorned part?

That’s correct – it’s either an Adornment or an HTMLInfo. Or you can implement everything yourself.

Thanks Walter.