Repository: zauberzeug/nicegui
Pull request #3164 (2024-06-02 19:28:06)

Add optional config dictionary to mermaid component to be passed initialize() on mount
Added an optional parameter to ui.mermaid(), parameter is a dictionary that will be passed to the mermaid.initialize() method on mount, allowing the configuration of the mermaid engine prior to the first renderring.

This enables the adjustment of securityLevel, logLevel and other associated parameters on the engine.

Setting securityLevel to 'loose' will allow javascript call back functions to be called when nodes are clicked using mermaids 'click' syntax

A minimal example of usage would look like this
```py
ui.add_head_html('''
    <script>
    var _mermaidClickHandler = function (e) {
        emitEvent("mermaidNodeEvent",e);
        };
    </script>
''')

ui.on('mermaidNodeEvent',(lambda e: _label.set_text(e.args)))

m = ui.mermaid('''flowchart TD;
    AAAA((XXXX));
    click AAAA href \"javascript:_mermaidClickHandler(\'I was clicked!\')\"''',config={'securityLevel':'loose'}) 

_label = ui.label('Place holder')
```


Patch:
diff --git a/nicegui/elements/codemirror.js b/nicegui/elements/codemirror.js
index f6ed1483..4c8277bc 100644
--- a/nicegui/elements/codemirror.js
+++ b/nicegui/elements/codemirror.js
@@ -147,7 +147,8 @@ export default {
     },
   },
   async mounted() {
-    this.CM = await import(`${this.resource_path}/editor.js`);
+    await this.$nextTick(); // NOTE: wait for window.path_prefix to be set
+    this.CM = await import(window.path_prefix + `${this.resource_path}/editor.js`);
     const CM = this.CM;
 
     // This is used to prevent emitting the value we just received from the server.
diff --git a/nicegui/elements/mermaid.js b/nicegui/elements/mermaid.js
index 35adcc74..3773a45b 100644
--- a/nicegui/elements/mermaid.js
+++ b/nicegui/elements/mermaid.js
@@ -9,9 +9,18 @@ export default {
     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;
@@ -32,6 +41,7 @@ export default {
     },
   },
   props: {
+    config: Object,
     content: String,
   },
 };
diff --git a/nicegui/elements/mermaid.py b/nicegui/elements/mermaid.py
index 6123abbf..3af67ce0 100644
--- a/nicegui/elements/mermaid.py
+++ b/nicegui/elements/mermaid.py
@@ -1,3 +1,5 @@
+from typing import Dict, Optional
+
 from .mixins.content_element import ContentElement
 
 
@@ -7,15 +9,25 @@ class Mermaid(ContentElement,
               extra_libraries=['lib/mermaid/*.js']):
     CONTENT_PROP = 'content'
 
-    def __init__(self, content: str) -> None:
+    def __init__(self, content: str, config: Optional[Dict] = 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
+            ``{'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()``
         """
         super().__init__(content=content)
+        self._props['config'] = config
 
     def _handle_content_change(self, content: str) -> None:
         self._props[self.CONTENT_PROP] = content.strip()
diff --git a/nicegui/elements/teleport.js b/nicegui/elements/teleport.js
new file mode 100644
index 00000000..e3bc9e8b
--- /dev/null
+++ b/nicegui/elements/teleport.js
@@ -0,0 +1,20 @@
+export default {
+  template: `<Teleport v-if="isLoaded" :to="to" :key="key"><slot></slot></Teleport>`,
+  props: {
+    to: String,
+  },
+  mounted() {
+    this.isLoaded = true;
+  },
+  data() {
+    return {
+      key: 0,
+      isLoaded: false,
+    };
+  },
+  methods: {
+    update() {
+      this.key++;
+    },
+  },
+};
diff --git a/nicegui/elements/teleport.py b/nicegui/elements/teleport.py
new file mode 100644
index 00000000..2a91f018
--- /dev/null
+++ b/nicegui/elements/teleport.py
@@ -0,0 +1,26 @@
+from typing import Union
+
+from nicegui.element import Element
+
+
+class Teleport(Element, component='teleport.js'):
+
+    def __init__(self, to: Union[str, Element]) -> None:
+        """Teleport
+
+        An element that allows us to transmit the content from within a component to any location on the page.
+
+        :param to: NiceGUI element or CSS selector of the target element for the teleported content
+        """
+        super().__init__()
+        if isinstance(to, Element):
+            to = f'#c{to.id}'
+        self._props['to'] = to
+
+    def update(self) -> None:
+        """Force the internal content to be retransmitted to the specified location.
+
+        This method is usually called after the target container is rebuilt.
+        """
+        super().update()
+        self.run_method('update')
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index 69172950..e4275465 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -240,7 +240,7 @@ function download(src, filename, mediaType, prefix) {
   anchor.click();
   document.body.removeChild(anchor);
   if (typeof src !== "string") {
-    URL.revokeObjectURL(url);
+    URL.revokeObjectURL(anchor.href);
   }
 }
 
diff --git a/nicegui/ui.py b/nicegui/ui.py
index 2b858279..01e67d7a 100644
--- a/nicegui/ui.py
+++ b/nicegui/ui.py
@@ -88,6 +88,7 @@ __all__ = [
     'tab_panel',
     'tab_panels',
     'tabs',
+    'teleport',
     'textarea',
     'time',
     'timer',
@@ -212,6 +213,7 @@ from .elements.tabs import Tab as tab
 from .elements.tabs import TabPanel as tab_panel
 from .elements.tabs import TabPanels as tab_panels
 from .elements.tabs import Tabs as tabs
+from .elements.teleport import Teleport as teleport
 from .elements.textarea import Textarea as textarea
 from .elements.time import Time as time
 from .elements.timeline import Timeline as timeline
diff --git a/website/documentation/content/mermaid_documentation.py b/website/documentation/content/mermaid_documentation.py
index 1920c77f..6a1f84bc 100644
--- a/website/documentation/content/mermaid_documentation.py
+++ b/website/documentation/content/mermaid_documentation.py
@@ -10,6 +10,21 @@ def main_demo() -> None:
         A --> B;
         A --> C;
     ''')
+    # END OF DEMO
+    list(ui.context.client.elements.values())[-1]._props['config'] = {'securityLevel': 'loose'}  # HACK: for click_demo
+
+
+@doc.demo('Handle click events', '''
+    You can register to click events by adding a `click` directive to a node and emitting a custom event.
+    Make sure to set the `securityLevel` to `loose` in the `config` parameter to allow JavaScript execution.
+''')
+def click_demo() -> None:
+    ui.mermaid('''
+    graph LR;
+        A((Click me!));
+        click A call emitEvent("mermaid_click", "You clicked me!")
+    ''', config={'securityLevel': 'loose'})
+    ui.on('mermaid_click', lambda e: ui.notify(e.args))
 
 
 @doc.demo('Handle errors', '''
@@ -22,6 +37,8 @@ def error_demo() -> None:
         A --> B;
         A -> C;
     ''').on('error', lambda e: print(e.args['message']))
+    # END OF DEMO
+    list(ui.context.client.elements.values())[-1]._props['config'] = {'securityLevel': 'loose'}  # HACK: for click_demo
 
 
 doc.reference(ui.mermaid)
diff --git a/website/documentation/content/section_page_layout.py b/website/documentation/content/section_page_layout.py
index 7f991b55..1353a0f6 100644
--- a/website/documentation/content/section_page_layout.py
+++ b/website/documentation/content/section_page_layout.py
@@ -21,6 +21,7 @@ from . import (
     splitter_documentation,
     stepper_documentation,
     tabs_documentation,
+    teleport_documentation,
     timeline_documentation,
     tooltip_documentation,
 )
@@ -80,6 +81,7 @@ def clear_containers_demo():
     ui.button('Clear', on_click=container.clear)
 
 
+doc.intro(teleport_documentation)
 doc.intro(expansion_documentation)
 doc.intro(scroll_area_documentation)
 doc.intro(separator_documentation)
diff --git a/website/documentation/content/teleport_documentation.py b/website/documentation/content/teleport_documentation.py
new file mode 100644
index 00000000..47376009
--- /dev/null
+++ b/website/documentation/content/teleport_documentation.py
@@ -0,0 +1,17 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.teleport)
+def main_demo() -> None:
+    markdown = ui.markdown('Enter your **name**!')
+
+    def inject_input():
+        with ui.teleport(f'#c{markdown.id} strong'):
+            ui.input('name').classes('inline-flex').props('dense outlined')
+
+    ui.button('inject input', on_click=inject_input)
+
+
+doc.reference(ui.teleport)


Test patch:
diff --git a/nicegui/testing/screen.py b/nicegui/testing/screen.py
index 2fbe8a18..415fc9d6 100644
--- a/nicegui/testing/screen.py
+++ b/nicegui/testing/screen.py
@@ -219,6 +219,10 @@ class Screen:
         """Find all elements with the given HTML tag."""
         return self.selenium.find_elements(By.TAG_NAME, name)
 
+    def find_by_css(self, selector: str) -> WebElement:
+        """Find the element with the given CSS selector."""
+        return self.selenium.find_element(By.CSS_SELECTOR, selector)
+
     def render_js_logs(self) -> str:
         """Render the browser console logs as a string."""
         console = '\n'.join(log['message'] for log in self.selenium.get_log('browser'))
diff --git a/tests/test_mermaid.py b/tests/test_mermaid.py
index 850d9359..e21aaa59 100644
--- a/tests/test_mermaid.py
+++ b/tests/test_mermaid.py
@@ -1,3 +1,5 @@
+import pytest
+
 from nicegui import ui
 from nicegui.testing import Screen
 
@@ -75,3 +77,20 @@ def test_error(screen: Screen):
     screen.open('/')
     screen.should_contain('Syntax error in text')
     screen.should_contain('Parse error on line 3')
+
+
+@pytest.mark.parametrize('security_level', ['loose', 'strict'])
+def test_click_mermaid_node(security_level: str, screen: Screen):
+    ui.mermaid('''
+        flowchart TD;
+            A;
+            click A call document.write("Success")
+    ''', config={'securityLevel': security_level})
+
+    screen.open('/')
+    screen.click('A')
+    screen.wait(0.5)
+    if security_level == 'loose':
+        screen.should_contain('Success')
+    else:
+        screen.should_not_contain('Success')
diff --git a/tests/test_teleport.py b/tests/test_teleport.py
new file mode 100644
index 00000000..f4f03379
--- /dev/null
+++ b/tests/test_teleport.py
@@ -0,0 +1,58 @@
+from typing import Optional
+
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+def test_teleport(screen: Screen):
+    ui.card().classes('card')
+
+    def create_teleport():
+        with ui.teleport('.card'):
+            ui.label('Hello')
+
+    ui.button('create', on_click=create_teleport)
+
+    screen.open('/')
+    screen.click('create')
+    assert screen.find_by_css('.card > div').text == 'Hello'
+
+
+def test_teleport_with_element(screen: Screen):
+    card = ui.card().classes('card')
+
+    def create_teleport():
+        with ui.teleport(card):
+            ui.label('Hello')
+
+    ui.button('create', on_click=create_teleport)
+
+    screen.open('/')
+    screen.click('create')
+    assert screen.find_by_css('.card > div').text == 'Hello'
+
+
+def test_update(screen: Screen):
+    teleport: Optional[ui.teleport] = None
+
+    card = ui.card().classes('card')
+
+    def create_teleport():
+        nonlocal teleport
+        with ui.teleport('.card') as teleport:
+            ui.label('Hello')
+
+    ui.button('create', on_click=create_teleport)
+
+    def rebuild_card():
+        card.delete()
+        ui.card().classes('card')
+        teleport.update()  # type: ignore
+
+    ui.button('rebuild card', on_click=rebuild_card)
+
+    screen.open('/')
+    screen.click('create')
+    screen.should_contain('Hello')
+    screen.click('rebuild card')
+    assert screen.find_by_css('.card > div').text == 'Hello'