A part that looks and works like a sticky note

Hey walter,

below is a template of a GOJS PArt which should more or less look and work like a sticky note.
however, as u can see in the screenshot ,

  1. the textblock is overflowing outside of the Rectangle Shape as i type in it. My expectation is the Rectangle shud dynamically changes it width and height to ensure the textblock is is always inside it.
  2. the Textblock is always centered. i want it to occupy fill the entire rectangle always, with some padding, and i want the ability to center, left and right align the text.
  3. i have added adornments, to change the background color of the node, and font color of the node, but they dont seem to work.

i am having trouble debugging the issue. could you please help.

export const stickyNoteTemplate = $(
  go.Node,
  "Spot",
  {
    layerName: STICKY_NOTES_LAYER,
    resizable: true,
    resizeObjectName: "PANEL",
    selectionObjectName: "PANEL",
    selectionAdorned: true,
    locationSpot: go.Spot.Center,
    // Add shadow effect
    shadowVisible: true,
    shadowOffset: new go.Point(3, 3),
    shadowBlur: 5,

    // Add double-click behavior to edit text
    doubleClick: function (e, obj) {
      const textblock = obj.findObject("TEXTBLOCK");
      if (textblock) textblock.isEditing = true;
    },
  },
  // Main panel that will be resized
  $(
    go.Panel,
    "Auto",
    {
      name: "PANEL",
      minSize: new go.Size(100, 50),
    },
    // The shape that represents the sticky note
    $(
      go.Shape,
      "RoundedRectangle",
      {
        name: "SHAPE",
        fill: "lightyellow",
        stroke: "darkgray",
        strokeWidth: 1,
        // Make sure the shape fills the panel
        stretch: go.GraphObject.Fill,
      },
      new go.Binding("fill", "backgroundColor")
    ),

    // The text block that contains the editable text
    $(
      go.TextBlock,
      {
        name: "TEXTBLOCK",
        margin: 10,
        textAlign: "left",
        verticalAlignment: go.Spot.Top,
        editable: true,
        font: "12pt sans-serif",
        stroke: "black", // Default font color
        wrap: go.TextBlock.WrapFit,
        isMultiline: true,
        overflow: go.TextBlock.WrapFit,
        width: 150, // Default width
        // Listen for text changes to resize the node
        textEdited: function (textBlock, oldText, newText) {
          textBlock.diagram.startTransaction("resize after text edit");
          textBlock.diagram.commitTransaction("resize after text edit");
        },
      },
      new go.Binding("text").makeTwoWay(),
      new go.Binding("stroke", "fontColor"),
      new go.Binding("textAlign", "textAlign")
    )
  ),

  // Add a context menu
  {
    contextMenu: $(
      go.Adornment,
      "Vertical",

      // Background color section
      $(go.TextBlock, "Background Color", { margin: 5 }),
      $(
        go.Panel,
        "Horizontal",
        { defaultStretch: go.GraphObject.Horizontal },
        ...backgroundColors.map((color) =>
          $(
            go.Panel,
            "Auto",
            {
              margin: 3,
              // Use mouseDrop instead of click for better reliability
              mouseDrop: function (e, obj) {
                console.log("Background color clicked:", color);
                const node = obj.part.adornedPart;
                if (node && node.diagram) {
                  const diagram = node.diagram;
                  diagram.startTransaction("change background color");
                  diagram.model.setDataProperty(
                    node.data,
                    "backgroundColor",
                    color
                  );
                  diagram.commitTransaction("change background color");
                }
              },
              cursor: "pointer",
            },
            $(go.Shape, "Rectangle", {
              fill: color,
              stroke: "black",
              width: 20,
              height: 20,
            })
          )
        )
      ),

      // Font color section
      $(go.TextBlock, "Font Color", { margin: 5 }),
      $(
        go.Panel,
        "Horizontal",
        { defaultStretch: go.GraphObject.Horizontal },
        ...fontColors.map((color) =>
          $(
            go.Panel,
            "Auto",
            {
              margin: 3,
              // Use mouseDrop instead of click for better reliability
              mouseDrop: function (e, obj) {
                console.log("Font color clicked:", color);
                const node = obj.part.adornedPart;
                if (node && node.diagram) {
                  const diagram = node.diagram;
                  diagram.startTransaction("change font color");
                  diagram.model.setDataProperty(node.data, "fontColor", color);
                  diagram.commitTransaction("change font color");
                }
              },
              cursor: "pointer",
            },
            $(go.Shape, "Rectangle", {
              fill: color,
              stroke: "black",
              width: 20,
              height: 20,
            })
          )
        )
      ),

      // Text alignment section
      $(go.TextBlock, "Text Alignment", { margin: 5 }),
      $(
        go.Panel,
        "Horizontal",
        { defaultStretch: go.GraphObject.Horizontal },
        $(
          go.Panel,
          "Auto",
          {
            margin: 3,
            // Use mouseDrop instead of click for better reliability
            mouseDrop: function (e, obj) {
              console.log("Text align left clicked");
              const node = obj.part.adornedPart;
              if (node && node.diagram) {
                const diagram = node.diagram;
                diagram.startTransaction("change text align");
                diagram.model.setDataProperty(node.data, "textAlign", "left");
                diagram.commitTransaction("change text align");
              }
            },
            cursor: "pointer",
          },
          $(go.TextBlock, "Left", { margin: 2 })
        ),
        $(
          go.Panel,
          "Auto",
          {
            margin: 3,
            // Use mouseDrop instead of click for better reliability
            mouseDrop: function (e, obj) {
              console.log("Text align center clicked");
              const node = obj.part.adornedPart;
              if (node && node.diagram) {
                const diagram = node.diagram;
                diagram.startTransaction("change text align");
                diagram.model.setDataProperty(node.data, "textAlign", "center");
                diagram.commitTransaction("change text align");
              }
            },
            cursor: "pointer",
          },
          $(go.TextBlock, "Center", { margin: 2 })
        ),
        $(
          go.Panel,
          "Auto",
          {
            margin: 3,
            // Use mouseDrop instead of click for better reliability
            mouseDrop: function (e, obj) {
              console.log("Text align right clicked");
              const node = obj.part.adornedPart;
              if (node && node.diagram) {
                const diagram = node.diagram;
                diagram.startTransaction("change text align");
                diagram.model.setDataProperty(node.data, "textAlign", "right");
                diagram.commitTransaction("change text align");
              }
            },
            cursor: "pointer",
          },
          $(go.TextBlock, "Right", { margin: 2 })
        )
      ),

      // Center in viewport
      $(
        go.Panel,
        "Auto",
        {
          margin: 5,
          // Use mouseDrop instead of click for better reliability
          mouseDrop: function (e, obj) {
            console.log("Center in viewport clicked");
            const node = obj.part.adornedPart;
            if (node && node.diagram) {
              const diagram = node.diagram;
              diagram.startTransaction("center shape");
              const viewportBounds = diagram.viewportBounds;
              const centerX = viewportBounds.width / 2;
              const centerY = viewportBounds.height / 2;
              node.location = new go.Point(centerX, centerY);
              diagram.commitTransaction("center shape");
            }
          },
          cursor: "pointer",
        },
        $(go.TextBlock, "Center in Viewport", { margin: 2 })
      )
    ),
  },

  new go.Binding("location", "location", go.Point.parse).makeTwoWay(
    go.Point.stringify
  )
);

Do these need to be Nodes so that they have have relationships to other Nodes? Like balloon comments: Balloon Links for Creating Speech Bubbles or Comments for Nodes | GoJS Diagramming Library

Or are they supposed to be stand-alone, with no relationships at all?

This node(or part) cannot be connected to other nodes. They are standalone parts.
I have placed them in a layer before background, so that Nodes can be on top of these Parts.

Here’s a simple example. I have not had time to add selection Adornments to let the user modify color or font or whatever.

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <script id="code">
const myDiagram =
  new go.Diagram("myDiagramDiv", {
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  new go.Node("Auto")
    .bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
    .add(
      new go.Shape({ fill: "white" })
        .bind("fill", "color"),
      new go.TextBlock({ margin: 8, editable: true })
        .bindTwoWay("text")
    );

myDiagram.nodeTemplateMap.add("Note",
  new go.Part("Auto", {
      resizable: true
    })
    .bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
    .bindTwoWay("desiredSize", "size", go.Size.parse, go.Size.stringify)
    .add(
      new go.Shape({ fill: "lightyellow", stroke: "gray" }),
      new go.TextBlock({
          stretch: go.Stretch.Fill, margin: new go.Margin(8, 8, 6, 8),
          overflow: go.TextOverflow.Ellipsis,
          font: "italic 10pt sans-serif",
          editable: true
        })
        .bindTwoWay("text")
    ));

myDiagram.linkTemplate =
  new go.Link({
      relinkableFrom: true, relinkableTo: true,
      reshapable: true, resegmentable: true
    })
    .add(
      new go.Shape(),
      new go.Shape({ toArrow: "OpenTriangle" })
    );

myDiagram.model = new go.GraphLinksModel(
[
  { key: 1, text: "Alpha", color: "lightblue" },
  { key: 2, text: "Beta", color: "orange" },
  { key: 3, text: "Gamma", color: "lightgreen" },
  { key: 4, text: "Delta", color: "pink" },
  { key: -1, category: "Note", text: "This is a sticky note.\nIt can take multiple lines of text.\nIt can be edited and resized." },
],
[
  { from: 1, to: 2 },
  { from: 1, to: 3 },
  { from: 2, to: 2 },
  { from: 3, to: 4 },
  { from: 4, to: 1 }
]);
  </script>
</body>
</html>


I don’t want this to happen (see screenshot).

If the text overflows, the Shape’s height and width should adjust accordingly.

The surrounding Shape does resize automatically when the user finishes editing.

If you want the Part to be resized while the user is editing, that is possible, but usually it is undesirable when editing text in a Node, since that affects the size and maybe position of the Node, which reroutes all connected Links, and which often causes a new layout to be performed.

I would recommend implementing a custom text editor where the HTML shown with the input text area is surrounded by the colored frame that you want. The implementation of the built-in text editor is given as an extension, in extensions/TextEditor.js or extensionsJSM/TextEditor.ts. If you are willing to wait until I have free time, I could do something like this for you.

sure i can wait. I want the Part to be resized as the user is editing the text. this part resides in a layer behind the node layer so it shouldn’t be a problem. its basically a sticky note on my canvas.

OK, what I’ve done is copy the existing TextEditor extension and modify the code to update the TextBlock.text as the user types.

Here’s that code, as RealtimeTextEditor.ts:

/*
 *  Copyright 1998-2025 by Northwoods Software Corporation. All Rights Reserved.
 */

/*
 * This is an extension and not part of the main GoJS library.
 * Note that the API for this class may change with any version, even point releases.
 * If you intend to use an extension in production, you should copy the code to your own source directory.
 * Extensions can be found in the GoJS kit under the extensions or extensionsJSM folders.
 * See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
 */

import * as go from 'gojs';

// This is the definitions of the predefined text editor used by TextEditingTool
// when you set or bind TextBlock.editable to true.
// The source code for this is at extensionsJSM/RealtimeTextEditor.ts.
// You do not need to load this file in order to use in-place text editing.

// HTML + JavaScript text editor menu, made with HTMLInfo
// This is a re-implementation of the default text editor
// This file exposes one instance of HTMLInfo, window.RealtimeTextEditor
// Typical usage is:
// ```js
//   new go.Diagram(...,
//      {
//        'textEditingTool.defaultTextEditor': window.RealtimeTextEditor,
//        . . .
//      })
// ```
// or:
// ```js
//    myDiagram.toolManager.textEditingTool.defaultTextEditor = window.RealtimeTextEditor;
// ```
// ```js
//   new go.Node(. . .)
//     .add(
//       . . .
//       new go.TextBlock({ textEditor: window.RealtimeTextEditor, . . . })
//       . . .
//     )
// ```
// If you do use this code, copy it into your project and modify it there.
// See also RealtimeTextEditor.html
((window: any) => {
  const RealtimeTextEditor: go.HTMLInfo & {
      originalString: string,
      tool: go.TextEditingTool
    } = new go.HTMLInfo() as any;

  const textarea = document.createElement('textarea');

  textarea.addEventListener('input', e => {
      const tool = RealtimeTextEditor.tool;
      if (tool.textBlock === null) return;
      tool.diagram.startTransaction();
      tool.textBlock.text = textarea.value;
      tool.diagram.commitTransaction('input text');
      const tempText = tool.measureTemporaryTextBlock(textarea.value);
      const scale = (textarea as any).textScale;
      textarea.style.width = 2 + Math.max(tool.textBlock.measuredBounds.width, tempText.measuredBounds.width) * scale + 'px';
      textarea.rows = Math.max(tool.textBlock.lineCount, tempText.lineCount);
    }, false);

  textarea.addEventListener('keydown', e => {
      if (e.isComposing) return;
      const tool = RealtimeTextEditor.tool;
      const tb = tool.textBlock;
      if (tb === null) return;
      const key = e.key;
      if (key === 'Enter') { // Enter
        if (tb.isMultiline === false) e.preventDefault();
        tool.acceptText(go.TextEditingAccept.Enter);
        return;
      } else if (key === 'Tab') { // Tab
        tool.acceptText(go.TextEditingAccept.Tab);
        e.preventDefault();
        return;
      } else if (key === 'Escape') { // Esc
        tool.doCancel();
        tool.diagram.startTransaction();
        tb.text = RealtimeTextEditor.originalString;
        tool.diagram.commitTransaction('cancel text edit');
        if (tool.diagram !== null) tool.diagram.doFocus();
      }
    }, false);

  // handle focus:
  textarea.addEventListener('focus', e => {
      const tool = RealtimeTextEditor.tool;
      if (!tool || tool.currentTextEditor === null || tool.state === go.TextEditingState.None) return;
      if (tool.state === go.TextEditingState.Active) {
        tool.state = go.TextEditingState.Editing;
      }
      if (typeof textarea.select === 'function' && tool.selectsTextOnActivate) {
        textarea.select();
        textarea.setSelectionRange(0, 9999);
      }
    }, false);

  // Disallow blur.
  // If the textEditingTool blurs and the text is not valid,
  // we do not want focus taken off the element just because a user clicked elsewhere.
  textarea.addEventListener('blur', e => {
      const tool = RealtimeTextEditor.tool;
      if (!tool || tool.currentTextEditor === null || tool.state === go.TextEditingState.None) return;
      if (typeof textarea.focus === 'function') textarea.focus();
      if (typeof textarea.select === 'function' && tool.selectsTextOnActivate) {
        textarea.select();
        textarea.setSelectionRange(0, 9999);
      }
    }, false);

  RealtimeTextEditor.valueFunction = () => textarea.value;

  RealtimeTextEditor.mainElement = textarea; // to reference it more easily

  RealtimeTextEditor.tool = null as any; // Initialize

  // used to be in doActivate
  RealtimeTextEditor.show = (textBlock: go.GraphObject | null, diagram: go.Diagram, tool: go.Tool) => {
    if (!(textBlock instanceof go.TextBlock)) return;
    if (!diagram || !diagram.div) return;
    if (!(tool instanceof go.TextEditingTool)) return;
    if (RealtimeTextEditor.tool) return; // Only one at a time.

    RealtimeTextEditor.tool = tool; // remember the TextEditingTool for use by listeners
    RealtimeTextEditor.originalString = textBlock.text;

    // This is called during validation, if validation failed:
    if (tool.state === go.TextEditingState.Invalid) {
      textarea.style.border = '3px solid red';
      textarea.focus();
      return;
    }

    // This part is called during initalization:

    const loc = textBlock.getDocumentPoint(go.Spot.Center);
    const pos = diagram.position;
    const sc = diagram.scale;
    let textscale = textBlock.getDocumentScale() * sc;
    if (textscale < tool.minimumEditorScale) textscale = tool.minimumEditorScale;
    // Add slightly more width/height to stop scrollbars and line wrapping on some browsers
    // +6 is firefox minimum, otherwise lines will be wrapped improperly
    const textwidth = textBlock.naturalBounds.width * textscale + 6;
    const textheight = textBlock.naturalBounds.height * textscale + 2;
    const left = (loc.x - pos.x) * sc;
    const yCenter = (loc.y - pos.y) * sc; // this is actually the center, used to set style.top
    const valign = textBlock.verticalAlignment;
    const oneLineHeight = textBlock.lineHeight + textBlock.spacingAbove + textBlock.spacingBelow;
    const allLinesHeight = oneLineHeight * textBlock.lineCount * textscale;
    const center = (0.5 * textheight) - (0.5 * allLinesHeight);
    // add offset to yCenter to get the appropriate position:
    const yOffset = ((valign.y * textheight) - (valign.y * allLinesHeight) + valign.offsetY) - center - (allLinesHeight / 2);

    textarea.value = textBlock.text;
    // the only way you can mix font and fontSize is if the font inherits and the fontSize overrides
    // in the future maybe have textarea contained in its own div
    diagram.div.style['font'] = textBlock.font;

    const paddingsize = 1;
    textarea.style['position'] = 'absolute';
    textarea.style['zIndex'] = '100';
    textarea.style['font'] = 'inherit';
    textarea.style['fontSize'] = (textscale * 100) + '%';
    textarea.style['lineHeight'] = 'normal';
    textarea.style['width'] = textwidth + 'px';
    textarea.style['left'] = ((left - (textwidth / 2) | 0) - paddingsize) + 'px';
    textarea.style['top'] = (((yCenter + yOffset) | 0) - paddingsize) + 'px';
    textarea.style['textAlign'] = textBlock.textAlign;
    textarea.style['margin'] = '0';
    textarea.style['padding'] = paddingsize + 'px';
    textarea.style['border'] = '0';
    textarea.style['outline'] = 'none';
    textarea.style['whiteSpace'] = 'pre-wrap';
    textarea.style['overflow'] = 'hidden'; // for proper IE wrap
    textarea.style['resize'] = 'none';
    textarea.rows = textBlock.lineCount;
    (textarea as any).textScale = textscale; // attach a value to the textarea, for convenience
    textarea.className = 'goTXarea';

    // Show:
    diagram.div.appendChild(textarea);

    // After adding, focus:
    textarea.focus();
    if (tool.selectsTextOnActivate) {
      textarea.select();
      textarea.setSelectionRange(0, 9999);
    }
  };

  RealtimeTextEditor.hide = (diagram: go.Diagram, tool: go.Tool) => {
    RealtimeTextEditor.tool = null as any; // forget reference to TextEditingTool
    if (diagram.div) diagram.div.removeChild(textarea);
  };

  window.RealtimeTextEditor = RealtimeTextEditor;
})(window);

And here’s an example use. Really all that you have to do is set either the
TextEditingTool.defaultTextEditor or an individual TextBlock.textEditor.

<!DOCTYPE html>
<html>
<head>
  <title>RealtimeTextEditor sample</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script type="importmap">{"imports":{"gojs":"https://cdn.jsdelivr.net/npm/gojs/release/go-debug-module.js"}}</script>
  <script id="code" type="module">
import * as go from "gojs";
import "../extras/RealtimeTextEditor.js";

const myDiagram =
  new go.Diagram("myDiagramDiv", {
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  new go.Node("Auto")
    .bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
    .add(
      new go.Shape({ fill: "white" })
        .bind("fill", "color"),
      new go.TextBlock({ margin: 8, editable: true })
        .bindTwoWay("text")
    );

myDiagram.nodeTemplateMap.add("Note",
  new go.Part("Auto", {
      resizable: true
    })
    .bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
    .bindTwoWay("desiredSize", "size", go.Size.parse, go.Size.stringify)
    .add(
      new go.Shape({ fill: "lightyellow", stroke: "gray" }),
      new go.TextBlock({
          stretch: go.Stretch.Fill, margin: new go.Margin(8, 8, 6, 8),
          overflow: go.TextOverflow.Ellipsis,
          font: "italic 10pt sans-serif",
          editable: true,
          textEditor: RealtimeTextEditor
        })
        .bindTwoWay("text")
    ));

myDiagram.linkTemplate =
  new go.Link({
      relinkableFrom: true, relinkableTo: true,
      reshapable: true, resegmentable: true
    })
    .add(
      new go.Shape(),
      new go.Shape({ toArrow: "OpenTriangle" })
    );

myDiagram.model = new go.GraphLinksModel(
[
  { key: 1, text: "Alpha", color: "lightblue" },
  { key: 2, text: "Beta", color: "orange" },
  { key: 3, text: "Gamma", color: "lightgreen" },
  { key: -1, category: "Note", text: "This is a sticky note.\nIt can take multiple lines of text.\nIt can be edited and resized." },
],
[
  { from: 1, to: 2 },
  { from: 1, to: 3 },
]);
  </script>
</body>
</html>

Yes, thank you, this seems to be what i want. I am still facing some challenges related to this same Part… will first try to resolve them myself, will reach out if not.

hey Walter, so after playing around with the above, i noticed one issue, the TextBlock resizes correctly as expected as long as i don’t resize the part manually. After i resize it, the TextBlock overflows again (please see screenshot. this happened after i manually resized the Part. It shouldn’t have overflowed, instead it should’ve been contained in the Shape and become scrollable).

OK, I’ve made some minor modifications to RealtimeTextEditor. The primary one is that it decides whether or not the textarea is a fixed size by looking at the TextBlock’s Part’s data.size property to see if its a real Size or not. This is under the assumption that there’s a TwoWay Binding on the desiredSize of whatever the user is able to resize. (It needn’t be the TextBlock itself, or else it would have been a lot easier to tell.)

/*
 *  Copyright 1998-2025 by Northwoods Software Corporation. All Rights Reserved.
 */

/*
 * This is an extension and not part of the main GoJS library.
 * Note that the API for this class may change with any version, even point releases.
 * If you intend to use an extension in production, you should copy the code to your own source directory.
 * Extensions can be found in the GoJS kit under the extensions or extensionsJSM folders.
 * See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
 */

import * as go from 'gojs';

// This is the definitions of the predefined text editor used by TextEditingTool
// when you set or bind TextBlock.editable to true.
// The source code for this is at extensionsJSM/RealtimeTextEditor.ts.
// You do not need to load this file in order to use in-place text editing.

// HTML + JavaScript text editor menu, made with HTMLInfo
// This is a re-implementation of the default text editor
// This file exposes one instance of HTMLInfo, window.RealtimeTextEditor
// Typical usage is:
// ```js
//   new go.Diagram(...,
//      {
//        'textEditingTool.defaultTextEditor': window.RealtimeTextEditor,
//        . . .
//      })
// ```
// or:
// ```js
//    myDiagram.toolManager.textEditingTool.defaultTextEditor = window.RealtimeTextEditor;
// ```
// ```js
//   new go.Node(. . .)
//     .add(
//       . . .
//       new go.TextBlock({ textEditor: window.RealtimeTextEditor, . . . })
//       . . .
//     )
// ```
// If you do use this code, copy it into your project and modify it there.
// See also RealtimeTextEditor.html
((window: any) => {
  const RealtimeTextEditor: go.HTMLInfo & {
      originalString: string,
      tool: go.TextEditingTool,
      fixedSize: boolean,
    } = new go.HTMLInfo() as any;

  const textarea = document.createElement('textarea');

  textarea.addEventListener('input', e => {
      const tool = RealtimeTextEditor.tool;
      if (tool.textBlock === null) return;
      tool.diagram.startTransaction();
      tool.textBlock.text = textarea.value;
      tool.diagram.commitTransaction('input text');
      if (!RealtimeTextEditor.fixedSize) {
        const tempText = tool.measureTemporaryTextBlock(textarea.value);
        const scale = (textarea as any).textScale;
        textarea.style.width = 2 + Math.max(tool.textBlock.measuredBounds.width, tempText.measuredBounds.width) * scale + 'px';
        textarea.rows = Math.max(tool.textBlock.lineCount, tempText.lineCount);
      }
    }, false);

  textarea.addEventListener('keydown', e => {
      if (e.isComposing) return;
      const tool = RealtimeTextEditor.tool;
      const tb = tool.textBlock;
      if (tb === null) return;
      const key = e.key;
      if (key === 'Enter') { // Enter
        if (tb.isMultiline === false) e.preventDefault();
        tool.acceptText(go.TextEditingAccept.Enter);
        return;
      } else if (key === 'Tab') { // Tab
        tool.acceptText(go.TextEditingAccept.Tab);
        e.preventDefault();
        return;
      } else if (key === 'Escape') { // Esc
        tool.doCancel();
        tool.diagram.startTransaction();
        tb.text = RealtimeTextEditor.originalString;
        tool.diagram.commitTransaction('cancel text edit');
        if (tool.diagram !== null) tool.diagram.doFocus();
      }
    }, false);

  // handle focus:
  textarea.addEventListener('focus', e => {
      const tool = RealtimeTextEditor.tool;
      if (!tool || tool.currentTextEditor === null || tool.state === go.TextEditingState.None) return;
      if (tool.state === go.TextEditingState.Active) {
        tool.state = go.TextEditingState.Editing;
      }
      if (typeof textarea.select === 'function' && tool.selectsTextOnActivate) {
        textarea.select();
        textarea.setSelectionRange(0, 9999);
      }
    }, false);

  // Disallow blur.
  // If the textEditingTool blurs and the text is not valid,
  // we do not want focus taken off the element just because a user clicked elsewhere.
  textarea.addEventListener('blur', e => {
      const tool = RealtimeTextEditor.tool;
      if (!tool || tool.currentTextEditor === null || tool.state === go.TextEditingState.None) return;
      if (typeof textarea.focus === 'function') textarea.focus();
      if (typeof textarea.select === 'function' && tool.selectsTextOnActivate) {
        textarea.select();
        textarea.setSelectionRange(0, 9999);
      }
    }, false);

  RealtimeTextEditor.valueFunction = () => textarea.value;

  RealtimeTextEditor.mainElement = textarea; // to reference it more easily

  RealtimeTextEditor.tool = null as any; // Initialize

  // used to be in doActivate
  RealtimeTextEditor.show = (textBlock: go.GraphObject | null, diagram: go.Diagram, tool: go.Tool) => {
    if (!(textBlock instanceof go.TextBlock)) return;
    if (!diagram || !diagram.div) return;
    if (!(tool instanceof go.TextEditingTool)) return;
    if (RealtimeTextEditor.tool) return; // Only one at a time.

    RealtimeTextEditor.tool = tool; // remember the TextEditingTool for use by listeners
    RealtimeTextEditor.originalString = textBlock.text;
    const part = textBlock.part;
    RealtimeTextEditor.fixedSize = part && part.data.size && part.data.size !== "NaN NaN";

    // This is called during validation, if validation failed:
    if (tool.state === go.TextEditingState.Invalid) {
      textarea.style.border = '3px solid red';
      textarea.focus();
      return;
    }

    // This part is called during initalization:

    const loc = textBlock.getDocumentPoint(go.Spot.Center);
    const pos = diagram.position;
    const sc = diagram.scale;
    let textscale = textBlock.getDocumentScale() * sc;
    if (textscale < tool.minimumEditorScale) textscale = tool.minimumEditorScale;
    // Add slightly more width/height to stop scrollbars and line wrapping on some browsers
    // +6 is firefox minimum, otherwise lines will be wrapped improperly
    const textwidth = textBlock.naturalBounds.width * textscale + 6;
    const textheight = textBlock.naturalBounds.height * textscale + 2;
    const left = (loc.x - pos.x) * sc;
    const yCenter = (loc.y - pos.y) * sc; // this is actually the center, used to set style.top
    const valign = textBlock.verticalAlignment;
    const oneLineHeight = textBlock.lineHeight + textBlock.spacingAbove + textBlock.spacingBelow;
    const allLinesHeight = oneLineHeight * textBlock.lineCount * textscale;
    const center = (0.5 * textheight) - (0.5 * allLinesHeight);
    // add offset to yCenter to get the appropriate position:
    const yOffset = ((valign.y * textheight) - (valign.y * allLinesHeight) + valign.offsetY) - center - (allLinesHeight / 2);

    textarea.value = textBlock.text;
    // the only way you can mix font and fontSize is if the font inherits and the fontSize overrides
    // in the future maybe have textarea contained in its own div
    diagram.div.style['font'] = textBlock.font;

    const paddingsize = 0;
    textarea.style['position'] = 'absolute';
    textarea.style['zIndex'] = '100';
    textarea.style['font'] = 'inherit';
    textarea.style['fontSize'] = (textscale * 100) + '%';
    textarea.style['lineHeight'] = 'normal';
    textarea.style['width'] = textwidth + 'px';
    if (RealtimeTextEditor.fixedSize) {
      textarea.style['height'] = textheight + 'px';
    }
    textarea.style['left'] = ((left - (textwidth / 2) | 0) - paddingsize + 2) + 'px';
    textarea.style['top'] = (((yCenter + yOffset) | 0) - paddingsize) + 'px';
    textarea.style['textAlign'] = textBlock.textAlign;
    textarea.style['margin'] = '0';
    textarea.style['padding'] = paddingsize + 'px';
    textarea.style['border'] = '0';
    textarea.style['outline'] = 'none';
    textarea.style['whiteSpace'] = 'pre-wrap';
    textarea.style['overflow'] = 'hidden'; // for proper IE wrap
    textarea.style['resize'] = 'none';
    textarea.rows = textBlock.lineCount;
    (textarea as any).textScale = textscale; // attach a value to the textarea, for convenience
    textarea.className = 'goTXarea';

    // Show:
    diagram.div.appendChild(textarea);

    // After adding, focus:
    textarea.focus();
    if (tool.selectsTextOnActivate) {
      textarea.select();
      textarea.setSelectionRange(0, 9999);
    }
  };

  RealtimeTextEditor.hide = (diagram: go.Diagram, tool: go.Tool) => {
    RealtimeTextEditor.tool = null as any; // forget reference to TextEditingTool
    if (diagram.div) diagram.div.removeChild(textarea);
  };

  window.RealtimeTextEditor = RealtimeTextEditor;
})(window);

I have changed the “Note” template in the sample so that the TextBlock stretches to fill inside the border Shape.

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script type="importmap">{"imports":{"gojs":"https://cdn.jsdelivr.net/npm/gojs/release/go-debug-module.js"}}</script>
  <!-- <script type="importmap">{"imports":{"gojs":"../latest/release/go-debug-module.js"}}</script> -->
  <script id="code" type="module">
import * as go from "gojs";
import "../extras/RealtimeTextEditor.js";

const myDiagram =
  new go.Diagram("myDiagramDiv", {
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  new go.Node("Auto")
    .bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
    .add(
      new go.Shape({ fill: "white" })
        .bind("fill", "color"),
      new go.TextBlock({ stretch: go.Stretch.Fill, margin: 8, editable: true })
        .bindTwoWay("text")
    );

myDiagram.nodeTemplateMap.add("Note",
  new go.Part("Auto", {
      resizable: true
    })
    .bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
    .bindTwoWay("desiredSize", "size", go.Size.parse, go.Size.stringify)
    .add(
      new go.Shape({ fill: "lightyellow", stroke: "gray" }),
      new go.TextBlock({
          stretch: go.Stretch.Fill, margin: 8,
          overflow: go.TextOverflow.Ellipsis,
          font: "italic 10pt sans-serif",
          editable: true,
          textEditor: RealtimeTextEditor
        })
        .bindTwoWay("text")
    ));

myDiagram.linkTemplate =
  new go.Link({
      relinkableFrom: true, relinkableTo: true,
      reshapable: true, resegmentable: true
    })
    .add(
      new go.Shape(),
      new go.Shape({ toArrow: "OpenTriangle" })
    );

myDiagram.model = new go.GraphLinksModel(
[
  { key: 1, text: "Alpha", color: "lightblue" },
  { key: 2, text: "Beta", color: "orange" },
  { key: 3, text: "Gamma", color: "lightgreen" },
  { key: -1, category: "Note", text: "This is a sticky note.\nIt can take multiple lines of text.\nIt can be edited and resized." },
],
[
  { from: 1, to: 2 },
  { from: 1, to: 3 },
]);
  </script>
</body>
</html>
import * as go from "gojs";
import { STICKY_NOTES_LAYER } from "../constants";
import RealtimeTextEditor from "./realTimeTextEditor";

const $ = go.GraphObject.make;

// Define colors for background and font
const backgroundColors = [
  "lightyellow",
  "lightgreen",
  "lightblue",
  "lightpink",
  "white",
];
const fontColors = ["black", "darkblue", "darkred", "darkgreen", "purple"];

// Minimum and maximum sizes for sticky notes
const MIN_SIZE = new go.Size(100, 50);

// Track whether the mouse is over the adornment
let isOverAdornment = false;
let isOverNode = false;

// Create a hover adornment with color options
const createHoverAdornment = (node) => {
  const adornment = $(
    go.Adornment,
    "Spot",
    {
      adornedObject: node,
      // Add mouse enter/leave handlers to the adornment itself
      mouseEnter: (e, obj) => {
        isOverAdornment = true;
      },
      mouseLeave: (e, obj) => {
        isOverAdornment = false;
        // Give a small delay before checking if we should remove the adornment
        setTimeout(() => {
          if (!isOverAdornment && !isOverNode) {
            const node = obj.adornedPart;
            if (node) node.removeAdornment("ColorPalette");
          }
        }, 1000);
      },
    },
    $(go.Placeholder),
    $(
      go.Panel,
      "Auto",
      {
        alignment: new go.Spot(0.5, 0, 0, -10),
        alignmentFocus: go.Spot.Bottom,
      },
      // Background shape that can have stroke
      $(go.Shape, "Rectangle", {
        fill: "rgba(255,255,255,0.95)",
        stroke: "rgba(0,0,0,0.2)",
        strokeWidth: 1,
      }),
      // Content panel
      $(
        go.Panel,
        "Horizontal",
        {
          margin: 4, // Add padding inside the shape
        },
        // Background color options
        $(
          go.Panel,
          "Horizontal",
          { margin: new go.Margin(0, 10, 0, 0) },
          $(go.TextBlock, "Background:", {
            font: "10px sans-serif",
            stroke: "black",
          }),
          ...backgroundColors.map((color) =>
            $(
              go.Panel,
              "Auto",
              {
                margin: new go.Margin(0, 0, 0, 4),
                cursor: "pointer",
                click: (e, obj) => {
                  const diagram = e.diagram;
                  if (!diagram) return;

                  const adorn = obj.part;
                  if (!adorn || !adorn.adornedPart) return;

                  const node = adorn.adornedPart;
                  // Start a transaction to modify the model
                  diagram.startTransaction("change background color");
                  // Update the model data
                  diagram.model.setDataProperty(
                    node.data,
                    "backgroundColor",
                    color
                  );
                  diagram.commitTransaction("change background color");
                },
              },
              $(go.Shape, "Circle", {
                width: 16,
                height: 16,
                fill: color,
                stroke: "darkgray",
                strokeWidth: 1,
              })
            )
          )
        ),
        // Font color options
        $(
          go.Panel,
          "Horizontal",
          { margin: new go.Margin(0, 0, 0, 10) },
          $(go.TextBlock, "Text:", {
            font: "10px sans-serif",
            stroke: "black",
          }),
          ...fontColors.map((color) =>
            $(
              go.Panel,
              "Auto",
              {
                margin: new go.Margin(0, 0, 0, 4),
                cursor: "pointer",
                click: (e, obj) => {
                  const diagram = e.diagram;
                  if (!diagram) return;

                  const adorn = obj.part;
                  if (!adorn || !adorn.adornedPart) return;

                  const node = adorn.adornedPart;
                  // const color = obj._color;
                  console.log(node, color, "color");
                  // Start a transaction to modify the model
                  diagram.startTransaction("change font color");
                  // Update the model data
                  diagram.model.setDataProperty(node.data, "fontColor", color);
                  diagram.commitTransaction("change font color");
                },
              },
              $(go.Shape, "Circle", {
                width: 16,
                height: 16,
                fill: color,
                stroke: "darkgray",
                strokeWidth: 1,
              })
            )
          )
        )
      )
    )
  );

  return adornment;
};

// Create the sticky note template
export const stickyNoteTemplate = $(
  go.Node,
  "Auto",
  {
    layerName: STICKY_NOTES_LAYER,
    resizable: true,
    minSize: MIN_SIZE,
    // Use mouseEnter and mouseLeave for hover behavior
    mouseEnter: (e, node) => {
      isOverNode = true;
      // Create and add the hover adornment when mouse enters
      const diagram = node.diagram;
      console.log(node);
      if (diagram === null) return;
      // Don't show adornment if text editing is active
      if (
        diagram.currentTool instanceof go.TextEditingTool &&
        diagram.currentTool.textBlock !== null
      )
        return;

      // Create the adornment if it doesn't exist
      if (!node.findAdornment("ColorPalette")) {
        const adornment = createHoverAdornment(node);
        node.addAdornment("ColorPalette", adornment);
      }
    },
    mouseLeave: (e, node) => {
      isOverNode = false;
      // Give a small delay before checking if we should remove the adornment
      setTimeout(() => {
        if (!isOverAdornment && !isOverNode) {
          node.removeAdornment("ColorPalette");
        }
      }, 1000);
    },
    isShadowed: true,
    shadowBlur: 12,
    shadowOffset: new go.Point(5, 5),
    shadowColor: "rgba(122,124, 141, 0.2)",
  },
  new go.Binding("position"),
  new go.Binding("location", "location", go.Point.parse).makeTwoWay(
    go.Point.stringify
  ),
  new go.Binding("desiredSize", "size", go.Size.parse).makeTwoWay(
    go.Size.stringify
  ),
  $(
    go.Shape,
    "RoundedRectangle",
    {
      fill: "lightyellow",
      stroke: null,
      parameter1: 10,
    },
    new go.Binding("fill", "backgroundColor")
  ),
  $(
    go.TextBlock,
    {
      stretch: go.Stretch.Fill,
      margin: 5,
      overflow: go.TextOverflow.Ellipsis,
      font: "italic 16px Inter",
      editable: true,
      textEditor: RealtimeTextEditor,
      textAlign: "left",
    },
    new go.Binding("text").makeTwoWay(),
    new go.Binding("stroke", "fontColor")
  )
);

hey Walter, this is my modified template file. as u can see i trying to show hover adornments which will enable me to change the background and font color. however, the click in the adornments is not getting detected/triggered and I need your help to understand the issue in the code.

Yes, intentionally the default behavior is that clicks don’t happen in Adornments. If they did, there would be a lot more clicks happening on Adornments, which would usually be confusing/frustrating to users.

But if you want to implement click event handlers in Adornments, set GraphObject.isActionable to true on them (the GraphObjects with the click event handler).

Note that "Button"s have that isActionable property set to true on them already, so "Button"s will work in Adornments.

thanks for the prompt reply. got it working based on your advise above :)