Skip to content

Conversation

evnchn
Copy link
Collaborator

@evnchn evnchn commented Jun 12, 2025

Motivation

We're trying to add node click functionality to Mermaid diagram without resorting to global emitEvent.

Apparently, #4861 suffers, when you ought to include {handler} in your diagram, which you very much could, in:

ui.mermaid('''
    graph LR
        A[trigger] -->|event| B{handler}
''')

Source: #4861 (comment)

Implementation

Thought process:

  • So we would like to make customizable what to replace, because {handler} is a legit Mermaid syntax.
  • But, since we would allow changing the placeholder text, then we don't need the Python format string's escaping functionality with {{ and }} anymore
  • Then, my new proposal is even simplier, with a direct replace against NICEGUI_HANDLER norminally, and you can change the placeholder keyword if you ought to have NICEGUI_HANDLER in your diagram.

Implementation follows:

  • Let set function_placeholder
  • Replace set function_placeholder with random initialized function function_name which does this.$emit("nodeClicked", {node: node, param: param})

Progress

  • I chose a meaningful title that completes the sentence: "If applied, this PR will..."
  • The implementation is complete.
  • Pytests have been added (or are not necessary).
  • Documentation has been added (or is not necessary).

Pytest and documentation pending, since we're unsure of the idea.

Results

from nicegui import ui


@ui.page('/')
def main_page():
    ui.mermaid('''
        graph LR;
            A --> B;
            B --> C;
            click A NICEGUI_HANDLER
            click B call NICEGUI_HANDLER()
            click C call NICEGUI_HANDLER("C", "C some args")
        ''', on_node_click=lambda e: ui.notify(e))

    ui.mermaid('''
        graph LR;
            X(Cannot use format string) --> Y(NICEGUI_HANDLER solution);
            click X MYHANDLER
            click Y call MYHANDLER("Y", "Y some args")
        ''', on_node_click=lambda e: ui.notify(e), function_placeholder='MYHANDLER')


ui.run()

image

@thetableman
Copy link
Contributor

You sucked me back in @evnchn 😂

Although an original promoter of designating a handler name in the markdown (that's then replaced, altered, etc) I moved on to the idea that a handler doesn't need to be defined in markdown at all, I mentioned this in #4845 though it caused some confusion/miss-understanding about how it could work.

After the initial feedback on #4845 I reworked my logic and started using the below. I think there are still improvements to be made, further tests to be run, and discussions about the cons to be had (and I know they will be big ones for the developers), but... here it is: The no markdown solution to click events in Mermaid!

The Code

# mermaid.py

from ..events import GenericEventArguments, Handler
from .mixins.content_element import ContentElement


class Mermaid(ContentElement,
              component='mermaid.js',
              dependencies=[
                  'lib/mermaid/mermaid.esm.min.mjs',
                  'lib/mermaid/chunks/mermaid.esm.min/*.mjs',
              ]):
    CONTENT_PROP = 'content'

    def __init__(self, content: str, config: Optional[Dict] = None, on_node_click: Optional[Handler[GenericEventArguments]] = None) -> None:
        """Mermaid Diagrams

            Renders diagrams and charts written in the Markdown-inspired `Mermaid <https://mermaid.js.org/>`_ language.
            The mermaid syntax can also be used inside Markdown elements by providing the extension string 'mermaid' to the ``ui.markdown`` element.

            The optional configuration dictionary is passed directly to mermaid before the first diagram is rendered.
            This can be used to set such options as

                ``{'securityLevel': 'loose', ...}`` - allow running JavaScript when a node is clicked, applied automatically when `on_node_clicked` is specified
                ``{'logLevel': 'info', ...}`` - log debug info to the console

            Refer to the Mermaid documentation for the ``mermaid.initialize()`` method for a full list of options.

            :param content: the Mermaid content to be displayed
            :param config: configuration dictionary to be passed to ``mermaid.initialize()``
            :param on_node_click: callback that is invoked when a node is clicked, applies ``{'securityLevel': 'loose'}`` automatically
            """

        super().__init__(content=content)

        self._props['config'] = config or {}

        if on_node_click:
            self.on('nodeClick', on_node_click)
            self._props['config']['securityLevel'] = 'loose'
            self._props['clickInstance'] = True

    def _handle_content_change(self, content: str) -> None:
        self._props[self.CONTENT_PROP] = content.strip()
        self.run_method('update', content.strip())
// mermaid.js

import mermaid from "mermaid";

export default {
  template: `<div></div>`,
  data: () => ({
    last_content: "",
  }),
  mounted() {
    this.initialize();
    this.update(this.content);
  },
  methods: {
    initialize() {
      try {
        mermaid.initialize(this.config || {});
      } catch (error) {
        console.error(error);
        this.$emit("error", error);
      }
    },
    async update(content) {
      if (this.last_content === content) return;
      this.last_content = content;

      const element = this.$el
        try {
          const { svg, bindFunctions } = await mermaid.render(element.id + "_mermaid", content);
          element.innerHTML = svg;
          bindFunctions?.(element);
          if (this.clickInstance) {
            await this.$nextTick();
            this.attachClickHandlers();
          };
        } catch (error) {
          const { svg, bindFunctions } = await mermaid.render(element.id + "_mermaid", "error");
          element.innerHTML = svg;
          bindFunctions?.(element);
          const mermaidErrorFormat = { str: error.message, message: error.message, hash: error.name, error };
          console.error(mermaidErrorFormat);
          this.$emit("error", mermaidErrorFormat);
        }
    },
    attachClickHandlers() {
      const clickables = this.$el.querySelectorAll("g.node");
      clickables.forEach(node => {
        if (node.getAttribute('data-listener-added')) return;
        node.setAttribute('data-listener-added', 'true');
        node.style.cursor = "pointer";

        const nodeText = node.textContent.trim();
        const nodeId = node.id;

        node.addEventListener("click", () => {
          this.$emit("nodeClick", {
            node: this.getNodeName(nodeId),
            nodeId,
            nodeText,
          });
        });
      });
    },
    getNodeName(domId) {
      if (!domId) return undefined;
      const parts = domId.split("-");
      if (parts.length >= 3) return parts.slice(1, -1).join("-");
      if (parts.length === 2) return parts[1];
      return domId;
    },
  },
  props: {
    config: Object,
    content: String,
    clickInstance: Boolean,
  },
};
# test.py

from nicegui import ui

ui.mermaid('''
    graph LR;
        A --> B;
    ''', on_node_click=lambda e: ui.notify(e))

ui.mermaid('''
    graph LR;
        A --> B;
    ''', on_node_click=lambda e: ui.notify(e))

ui.run()

The Money Shot

image

The Pros, Cons, and Inbetweens

Pros

  • No requirement for declaring click events in Mermaid
  • No confusion about Mermaid's click syntax
  • No placeholder handlers in markdown
  • No unique event handlers
  • Returns consistent data, both within its self (each trigger has the same arguments) and as expected from a NiceGUI element
  • Returns node name, node html id, and node text
  • Doesn't require the queue to work
  • Adds protection against double-triggers by preventing multiple event handlers being attached
  • Works with multiple diagrams on the same page
  • Works where multiple diagrams share similar node names
  • Doesn't stop or prevent traditional Mermaid click events if defined in the markdown
  • Adds the ability for further functionality through node html id

Cons

  • Relies on finding g.node with a query selector, leading to potential breaking changes from Mermaid (unlikely)
  • Relies on extracting node name from its html id, leading to potential breaking changes from Mermaid (unlikely)
  • Currently only works with diagrams with g.node like 'graph', 'state', and 'class' (though these are also the most popular diagram types)

Inbetweens

  • Ability to add additional query selectors to support other diagram types that even Mermaid doesn't support click events for
  • Adds click events in a manner that doesn't match Mermaid documentation

The Extra Functionality

Mermaid click events still work

Because no changes are made to the markdown or the Mermaid click definitions, they still work with existing methods and as per the Mermaid documentation.

# test.py

from nicegui import ui

ui.mermaid('''
    graph LR;
        A --> B;
        click A alert
    ''', on_node_click=lambda e: ui.notify(e))

ui.run()

image

We can reference nodes now!

Because we have the node html id (and not just the node name) we can reference it directly and make changes.

# test.py

from nicegui import ui

node_styles = {}

def style_node(e):
    node_id = e.args['nodeId']
    node_el = ui.query(f'#{node_id} rect')
    
    if node_id not in node_styles:
        node_styles[node_id] = True
    else:
        node_styles[node_id] = not node_styles[node_id]
    
    if node_styles[node_id]:
        node_el.style(add='fill: yellow;')
    else:
        node_el.style(remove='fill: yellow;')

    e.args['state'] = node_styles[node_id]
    ui.notify(e)

ui.mermaid('''
    graph LR;
        A --> B;
    ''', on_node_click=lambda e: style_node(e))
    
ui.run()

image

@falkoschindler
Copy link
Contributor

Looks very promising, @thetableman!

potential breaking changes from Mermaid

If we write tests against such potential regressions, we can handle such cases when updating Mermaid.

Doesn't require the queue to work

But we still need to the queue for Mermaid's native click handlers (as in #4853), right?

@thetableman
Copy link
Contributor

Yes, the queue may still be needed if multiple diagrams render at the same time and affect the bindFunctions.

This js was straight out of my app dev environment with a stable release, so didn't have the changes implemented in the recent PR that reintroduced the queue.

@thetableman
Copy link
Contributor

Looks very promising

Should I submit a dedicated PR or do we feel we have enough viable options to resolve this already?

@evnchn
Copy link
Collaborator Author

evnchn commented Jun 15, 2025

I'm going to be busy for a while, so IMO it's best that you open a dedicated PR where the maintainers can review your solution from ground zero.

@evnchn evnchn added feature Type/scope: New feature or enhancement ⚪️ minor Priority: Low impact, nice-to-have labels Jun 17, 2025
@evnchn
Copy link
Collaborator Author

evnchn commented Aug 7, 2025

#4871 looks more promising. Mark as draft for now (though it's not really draft) so it doesn't show up when I search for open PRs.

Feel free to unmark as draft, if you prefer this over #4871.

@evnchn evnchn marked this pull request as draft August 7, 2025 05:10
@evnchn
Copy link
Collaborator Author

evnchn commented Oct 15, 2025

Merge conflict, cannot resolved with the web editor. See you next time, then!

@evnchn evnchn closed this Oct 15, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Type/scope: New feature or enhancement ⚪️ minor Priority: Low impact, nice-to-have

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants