Repository: zauberzeug/nicegui
Pull request #4732 (2025-05-10 19:02:38)

Show the documentation hierarchy in the sidebar
This PR shows the documentation hierarchy in the sidebar. 

- Completes https://github.com/orgs/zauberzeug/projects/6/views/1?pane=issue&itemId=82941340
- Mentioned in https://github.com/zauberzeug/nicegui/discussions/2976

<img width="1280" alt="{A8984628-61CD-40C3-BEEF-7B7834085616}" src="https://github.com/user-attachments/assets/f9389386-5517-4b3a-a23a-301d830ff45e" />

<img width="1280" alt="{40313142-2528-40CD-92E5-75E6773CBDE0}" src="https://github.com/user-attachments/assets/45a44731-728b-4ad4-8363-7fa062dc2eeb" />

---

_If, in the unlucky case that you have found some deficiencies in the tree generation code, here is an old copy of the code with a lot of debugging prints:_ 

<details>
<summary> Ugly code ahead! </summary>


```py
@ui.page('/debug_documentation')
def _debug_documentation_page() -> None:
    def display_dict_pretty(d: dict) -> None:
        for k, v in d.items():
            ui.label(f'{k}: {v}').classes('text-xs font-mono')
    ui.label('Debug documentation')

    all_registry_keys = [k for k in documentation.registry.keys()]

    single_key_empty = {k: v for k, v in documentation.registry.items() if k == ''}
    no_back_link = {k: v for k, v in documentation.registry.items() if k != '' and v.back_link is None}
    rest = {k: v for k, v in documentation.registry.items() if k != '' and v.back_link is not None}

    """ui.label('Single key empty:').classes('font-bold')
    display_dict_pretty(single_key_empty)

    for k, v in documentation.content.overview.tiles:
        ui.label(k.__name__.rpartition(".")[2]).classes('text-xs font-mono')

    ui.label('No back link:').classes('font-bold')
    display_dict_pretty(no_back_link)

    ui.label('Rest:').classes('font-bold')
    display_dict_pretty(rest)"""

    all_registry_keys.remove('')

    # First build the adjacency list
    adjacency_list: List[tuple[str, str, str]] = []
    for k, v in documentation.content.overview.tiles:
        adjacency_list.append(('', k.__name__.rpartition(
            ".")[2], documentation.registry[k.__name__.rpartition(".")[2]].title))
        all_registry_keys.remove(k.__name__.rpartition(".")[2])

    """for k, v in rest.items():
        adjacency_list.append((v.back_link, k))"""

    i = 0
    while i < len(adjacency_list):
        if i > 1000000:
            break  # no way
        _, v, _ = adjacency_list[i]
        if '#' in v:
            i += 1
            continue
        registry_entry = documentation.registry.get(v)
        if registry_entry and registry_entry.parts:
            for part in registry_entry.parts:
                if part.link:
                    adjacency_list.append((v, part.link, part.title))
                    all_registry_keys.remove(part.link)
                elif part.link_target:
                    adjacency_list.append((v, f'{v}#{part.link_target}', part.title))
        i += 1

    """ui.label('Adjacency list:').classes('font-bold')
    for k, v, t in adjacency_list:
        ui.label(f'{k} -> {v} ({t})').classes('text-xs font-mono')

    ui.label('Length of adjacency list:').classes('font-bold')
    ui.label(str(len(adjacency_list))).classes('text-xs font-mono')

    ui.label('Number of remaining entries:').classes('font-bold')
    ui.label(str(len(all_registry_keys))).classes('text-xs font-mono')

    ui.label('Remaining entries:').classes('font-bold')
    for k in all_registry_keys:
        ui.label(k).classes('text-xs font-mono')"""

    def add_to_tree(tree, parent_id, child_id, title):
        for node in tree:
            if node['id'] == parent_id:
                node['children'].append({'id': child_id, 'children': [], 'title': title})
                return True
            # Recursively search in the children
            if add_to_tree(node['children'], parent_id, child_id, title):
                return True
        return False

    adjacency_list = [(k, v, t.replace("*", "")) for k, v, t in adjacency_list]

    # Build the tree from adjacency list
    tree_format_list = []
    for k, v, t in adjacency_list:
        if k == '':
            tree_format_list.append({'id': v, 'children': [], 'title': t})
        else:
            # Try to add the child to the correct parent in the tree
            if not add_to_tree(tree_format_list, k, v, t):
                # If the parent is not found, create a new top-level node
                tree_format_list.append({'id': k, 'children': [{'id': v, 'children': [], 'title': t}]})

    ui.tree(tree_format_list, label_key='title',).props('accordion=true').classes('w-full').add_slot('default-header', '''
        <span :props="props">
            <a :href="'/documentation/' + props.node.id" target="_blank" onclick="event.stopPropagation()">{{ props.node.title }}</a>
        </span>
    ''')
```


</details>


Patch:
diff --git a/CITATION.cff b/CITATION.cff
index 1fe99063..46790e3f 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -8,8 +8,8 @@ authors:
     given-names: Rodja
     orcid: https://orcid.org/0009-0009-4735-6227
 title: "NiceGUI: Web-based user interfaces with Python. The nice way."
-version: 2.16.1
-date-released: "2025-05-05"
+version: 2.17.0
+date-released: "2025-05-12"
 url: https://github.com/zauberzeug/nicegui
 doi: 10.5281/zenodo.7785516
 identifiers:
diff --git a/main.py b/main.py
index 82cb576b..e7dd2f6b 100755
--- a/main.py
+++ b/main.py
@@ -26,6 +26,7 @@ app.add_static_file(local_file=svg.PATH / 'logo.png', url_path='/logo.png')
 app.add_static_file(local_file=svg.PATH / 'logo_square.png', url_path='/logo_square.png')
 
 documentation.build_search_index()
+documentation.build_tree()
 
 
 @app.post('/dark_mode')
diff --git a/nicegui/elements/interactive_image.js b/nicegui/elements/interactive_image.js
index 47985552..71a2d717 100644
--- a/nicegui/elements/interactive_image.js
+++ b/nicegui/elements/interactive_image.js
@@ -76,7 +76,7 @@ export default {
         this.loading = true;
       }
       if (!this.src && this.size) {
-        this.viewBox = `0 0 ${this.size[0]} ${this.size[1]}`;
+        this.updateViewbox(this.size[0], this.size[1]);
       }
     },
     updateCrossHair(e) {
@@ -88,7 +88,7 @@ export default {
     onImageLoaded(e) {
       this.loaded_image_width = e.target.naturalWidth;
       this.loaded_image_height = e.target.naturalHeight;
-      this.viewBox = `0 0 ${this.loaded_image_width} ${this.loaded_image_height}`;
+      this.updateViewbox(this.loaded_image_width, this.loaded_image_height);
       this.$emit("loaded", { width: this.loaded_image_width, height: this.loaded_image_height, source: e.target.src });
     },
     onMouseEvent(type, e) {
@@ -116,6 +116,9 @@ export default {
         image_y: (e.offsetY * imageHeight) / this.$refs.svg.clientHeight,
       });
     },
+    updateViewbox(width, height) {
+      this.viewBox = `0 0 ${width} ${height}`;
+    },
   },
   computed: {
     onCrossEvents() {
diff --git a/nicegui/elements/interactive_image.py b/nicegui/elements/interactive_image.py
index b6f8aa2e..064b03a6 100644
--- a/nicegui/elements/interactive_image.py
+++ b/nicegui/elements/interactive_image.py
@@ -101,3 +101,34 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
             return
         self._props['t'] = time.time()
         self.update()
+
+    def add_layer(self, *, content: str = '') -> InteractiveImageLayer:
+        """Add a new layer with its own content.
+
+        *Added in version 2.17.0*
+        """
+        with self:
+            layer = InteractiveImageLayer(source=self.source, content=content, size=self._props['size']) \
+                .classes('nicegui-interactive-image-layer')
+            self.on('loaded', lambda e: layer.run_method('updateViewbox', e.args['width'], e.args['height']))
+            return layer
+
+
+class InteractiveImageLayer(SourceElement, ContentElement, component='interactive_image.js'):
+    CONTENT_PROP = 'content'
+    PIL_CONVERT_FORMAT = 'PNG'
+
+    def __init__(self, *, source: str, content: str, size: Optional[Tuple[float, float]]) -> None:
+        """Interactive Image Layer
+
+        This element is created when adding a layer to an ``InteractiveImage``.
+
+        *Added in version 2.17.0*
+        """
+        super().__init__(source=source, content=content)
+        self._props['size'] = size
+
+    def _set_props(self, source: Union[str, Path, 'PIL_Image']) -> None:  # noqa: UP037
+        if optional_features.has('pillow') and isinstance(source, PIL_Image):
+            source = pil_to_base64(source, self.PIL_CONVERT_FORMAT)
+        super()._set_props(source)
diff --git a/nicegui/elements/markdown.js b/nicegui/elements/markdown.js
index ad9f54ed..f6fdc912 100644
--- a/nicegui/elements/markdown.js
+++ b/nicegui/elements/markdown.js
@@ -7,12 +7,14 @@ export default {
     await loadResource(window.path_prefix + this.codehilite_css_url);
     if (this.use_mermaid) {
       this.mermaid = (await import("mermaid")).default;
+      this.mermaid.initialize({ startOnLoad: false });
       this.renderMermaid();
     }
   },
   data() {
     return {
       mermaid: null,
+      diagrams: {},
     };
   },
   updated() {
@@ -20,9 +22,33 @@ export default {
   },
   methods: {
     renderMermaid() {
+      // render new diagrams
+      const usedKeys = new Set();
       this.$el.querySelectorAll(".mermaid-pre").forEach(async (pre, i) => {
-        await this.mermaid.run({ nodes: [pre.children[0]] });
+        const key = pre.children[0].innerText + "\n" + i;
+        usedKeys.add(key);
+        if (!this.diagrams[key]) {
+          try {
+            this.diagrams[key] = await this.mermaid.render(this.$el.id + "_mermaid_" + i, pre.children[0].innerText);
+          } catch (error) {
+            this.diagrams[key] = await this.mermaid.render(this.$el.id + "_mermaid_" + i, "error");
+            console.error(error);
+          }
+        }
+        const svgElement = document.createElement("div");
+        svgElement.classList.add("mermaid-svg");
+        svgElement.innerHTML = this.diagrams[key].svg;
+        this.diagrams[key].bindFunctions?.(svgElement);
+        pre.querySelectorAll(".mermaid-svg").forEach((svg) => svg.remove());
+        pre.appendChild(svgElement);
       });
+
+      // prune cached diagrams that are not used anymore
+      for (const key in this.diagrams) {
+        if (!usedKeys.has(key)) {
+          delete this.diagrams[key];
+        }
+      }
     },
   },
   props: {
diff --git a/nicegui/static/nicegui.css b/nicegui/static/nicegui.css
index 7b83ae21..c9b572ae 100644
--- a/nicegui/static/nicegui.css
+++ b/nicegui/static/nicegui.css
@@ -193,6 +193,9 @@
 .nicegui-markdown .codehilite pre {
   margin: 0.5rem 0;
 }
+.nicegui-markdown .mermaid-pre > .mermaid {
+  display: none;
+}
 
 /* other NiceGUI elements */
 .nicegui-grid {
@@ -263,8 +266,14 @@ h6.q-timeline__title {
 .nicegui-scene-view[data-initializing] {
   visibility: hidden;
 }
-
-/* CodeMirror */
+.nicegui-interactive-image-layer {
+  position: absolute !important;
+  inset: 0;
+  pointer-events: none;
+}
+.nicegui-interactive-image-layer img {
+  display: none;
+}
 .nicegui-codemirror .cm-editor.cm-focused {
   outline: none;
 }
diff --git a/npm.py b/npm.py
index 3344eb90..66f65b86 100755
--- a/npm.py
+++ b/npm.py
@@ -15,12 +15,15 @@ import json
 import re
 import shutil
 import tarfile
+import tempfile
 from argparse import ArgumentParser
 from pathlib import Path
 from typing import Dict, List
 
 import requests
 
+temp_dir = tempfile.TemporaryDirectory()
+
 parser = ArgumentParser()
 parser.add_argument('path', default='.', help='path to the root of the repository')
 parser.add_argument('--name', nargs='*', help='filter library updates by name')
@@ -44,9 +47,7 @@ def url_to_filename(url: str) -> str:
 
 
 def download_buffered(url: str) -> Path:
-    path = Path('/tmp/nicegui_dependencies')
-    path.mkdir(exist_ok=True)
-    filepath = path / url_to_filename(url)
+    filepath = Path(temp_dir.name) / url_to_filename(url)
     if not filepath.exists():
         response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=3)
         filepath.write_bytes(response.content)
@@ -121,6 +122,8 @@ for key, dependency in dependencies.items():
                 filename = filename.replace(rename, dependency['rename'][rename])
 
             newfile = prepare(Path(destination, filename))
+            if newfile.exists():
+                newfile.unlink()
             Path(tmp, key, extracted.name).rename(newfile)
 
             if 'GLTFLoader' in filename:
@@ -144,8 +147,13 @@ for key, dependency in dependencies.items():
                 content = re.sub(r'"\./chunks/mermaid.esm.min/(.*?)\.mjs"', r'"\1"', content)
                 newfile.write_text(content, encoding='utf-8')
 
-    # Delete destination folder if empty.
-    if not any(destination.iterdir()):
-        destination.rmdir()
+    try:
+        # Delete destination folder if empty.
+        if not any(destination.iterdir()):
+            destination.rmdir()
+    except Exception:
+        pass
+
+temp_dir.cleanup()
 
 cleanup(tmp)
diff --git a/pyproject.toml b/pyproject.toml
index 377b9e3c..ece469fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.16.1-dev"
+version = "2.17.0-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
diff --git a/website/documentation/__init__.py b/website/documentation/__init__.py
index f25557ab..6623dced 100644
--- a/website/documentation/__init__.py
+++ b/website/documentation/__init__.py
@@ -3,6 +3,7 @@ from .custom_restructured_text import CustomRestructuredText
 from .intro import create_intro
 from .rendering import render_page
 from .search import build_search_index
+from .tree import build_tree
 from .windows import bash_window, browser_window, python_window
 
 __all__ = [
@@ -10,6 +11,7 @@ __all__ = [
     'bash_window',
     'browser_window',
     'build_search_index',
+    'build_tree',
     'create_intro',
     'overview',  # ensure documentation tree is built
     'python_window',
diff --git a/website/documentation/content/interactive_image_documentation.py b/website/documentation/content/interactive_image_documentation.py
index cc1354db..3dea3ba2 100644
--- a/website/documentation/content/interactive_image_documentation.py
+++ b/website/documentation/content/interactive_image_documentation.py
@@ -16,6 +16,31 @@ def main_demo() -> None:
     ii = ui.interactive_image(src, on_mouse=mouse_handler, events=['mousedown', 'mouseup'], cross=True)
 
 
+@doc.demo('Adding layers', '''
+    In some cases you might want to add different groups of SVG elements to an image.
+    Maybe there is one element that needs frequent updates, while the other elements are rarely changed.
+    Putting all elements in the same SVG can lead to performance issues,
+    because the whole SVG needs to be sent to the client whenever one of the elements changes.
+
+    The solution is to add multiple layers to the image.
+    Each layer is a separate SVG element, which means that each layer can be updated independently.
+
+    The following demo shows this concept in action, even though both layers are changed at the same time.
+
+    *Added in version 2.17.0*
+''')
+def adding_layers():
+    from nicegui import events
+
+    def mouse_handler(e: events.MouseEventArguments):
+        image.content += f'<circle cx="{e.image_x}" cy="{e.image_y}" r="30" fill="none" stroke="red" stroke-width="4" />'
+        highlight.content = f'<circle cx="{e.image_x}" cy="{e.image_y}" r="28" fill="yellow" opacity="0.5" />'
+
+    src = 'https://picsum.photos/id/674/640/360'
+    image = ui.interactive_image(src, on_mouse=mouse_handler, cross=True)
+    highlight = image.add_layer()
+
+
 @doc.demo('Nesting elements', '''
     You can nest elements inside an interactive image.
     Use Tailwind classes like "absolute top-0 left-0" to position the label absolutely with respect to the image.
diff --git a/website/documentation/content/leaflet_documentation.py b/website/documentation/content/leaflet_documentation.py
index 9b358990..3d701bb0 100644
--- a/website/documentation/content/leaflet_documentation.py
+++ b/website/documentation/content/leaflet_documentation.py
@@ -80,6 +80,8 @@ def move_markers() -> None:
 @doc.demo('Image Overlays', '''
     Leaflet supports [image overlays](https://leafletjs.com/reference.html#imageoverlay).
     You can add an image overlay with the `image_overlay` method.
+
+    *Added in version 2.17.0*
 ''')
 def overlay_image():
     m = ui.leaflet(center=(40.74, -74.18), zoom=11)
@@ -93,6 +95,8 @@ def overlay_image():
 @doc.demo('Video Overlays', '''
     Leaflet supports [video overlays](https://leafletjs.com/reference.html#videooverlay).
     You can add a video overlay with the `video_overlay` method.
+
+    *Added in version 2.17.0*
 ''')
 def overlay_video():
     m = ui.leaflet(center=(23.0, -115.0), zoom=3)
diff --git a/website/documentation/content/markdown_documentation.py b/website/documentation/content/markdown_documentation.py
index db25c532..f6798e58 100644
--- a/website/documentation/content/markdown_documentation.py
+++ b/website/documentation/content/markdown_documentation.py
@@ -55,6 +55,23 @@ def markdown_tables():
     ''', extras=['tables'])
 
 
+@doc.demo('Mermaid diagrams', '''
+    You can use Mermaid diagrams with the "mermaid" extra.
+    See the [markdown2 documentation](https://github.com/trentm/python-markdown2/wiki/Extras#implemented-extras)
+    for a list of available extras.
+''')
+def mermaid():
+    ui.markdown('''
+        ```mermaid
+        graph TD;
+            A-->B;
+            A-->C;
+            B-->D;
+            C-->D;
+        ```
+    ''', extras=['mermaid'])
+
+
 @doc.demo('LaTeX formulas', '''
     By activating the "latex" extra, you can use LaTeX formulas.
     This requires markdown2 version >=2.5 as well as latex2mathml to be installed.
diff --git a/website/documentation/content/section_binding_properties.py b/website/documentation/content/section_binding_properties.py
index 3887fc5b..e2c0dd01 100644
--- a/website/documentation/content/section_binding_properties.py
+++ b/website/documentation/content/section_binding_properties.py
@@ -28,6 +28,30 @@ def bindings_demo():
         ui.number().bind_value(demo, 'number')
 
 
+@doc.demo('Transformation functions', '''
+    You can use ``forward`` and ``backward`` transformation functions to convert the value
+    when propagating it from one object to another.
+    These functions are called whenever the source attribute changes,
+    or - in case of active links (see below) - whenever the source attribute is checked for changes.
+
+    Note:
+    NiceGUI 2.16.0 improved efficiency of binding propagation by strictly adhering to a Depth-First-Search approach,
+    updating every affected node once and executing the transformation function once.
+    If you are migrating from NiceGUI 2.15.0 or older, there may be extra runs on transformation functions,
+    especially ones in the opposite direction to the current propagation direction,
+    which are no-longer ran in NiceGUI 2.16.0.
+    As a result, you would need to change your code appropriately.
+
+    We would also like to mention that, for the most stable behaviour across releases,
+    it is best-practice that transform functions have no side-effects and do basic transform operations only.
+    This way, it will not matter how NiceGUI chooses to call them in what order and by how many times.
+''')
+def transformation_functions():
+    i = ui.input(value='Lorem ipsum')
+    ui.label().bind_text_from(i, 'value',
+                              backward=lambda text: f'{len(text)} characters')
+
+
 @doc.demo('Bind to dictionary', '''
     Here we are binding the text of labels to a dictionary.
 ''')
diff --git a/website/documentation/rendering.py b/website/documentation/rendering.py
index dea29bd4..1be7cabf 100644
--- a/website/documentation/rendering.py
+++ b/website/documentation/rendering.py
@@ -1,3 +1,5 @@
+from typing import List
+
 from nicegui import ui
 
 from ..header import add_head_html, add_header
@@ -6,6 +8,7 @@ from .content import DocumentationPage
 from .custom_restructured_text import CustomRestructuredText as custom_restructured_text
 from .demo import demo
 from .reference import generate_class_doc
+from .tree import nodes
 
 
 def render_page(documentation: DocumentationPage, *, with_menu: bool = True) -> None:
@@ -16,11 +19,16 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
         with ui.left_drawer() \
                 .classes('column no-wrap gap-1 bg-[#eee] dark:bg-[#1b1b1b] mt-[-20px] px-8 py-20') \
                 .style('height: calc(100% + 20px) !important') as menu:
-            if documentation.back_link:
-                ui.markdown(f'[← back]({documentation.back_link or "."})').classes('bold-links')
-            else:
-                ui.markdown('[← Overview](/documentation)').classes('bold-links')
-            ui.markdown(f'**{documentation.heading.replace("*", "")}**').classes('mt-4')
+            tree = ui.tree(nodes, label_key='title').classes('w-full').props('accordion no-connectors')
+            tree.add_slot('default-header', '''
+                <a :href="'/documentation/' + props.node.id" onclick="event.stopPropagation()">{{ props.node.title }}</a>
+            ''')
+            tree.expand(_ancestor_nodes(documentation.name))
+            ui.run_javascript(f'''
+                Array.from(getHtmlElement({tree.id}).getElementsByTagName("a"))
+                    .find(el => el.innerText.trim() === "{(documentation.parts[0].title or '').replace('*', '')}")
+                    .scrollIntoView({{block: "center"}});
+            ''')
     else:
         menu = None
 
@@ -64,3 +72,8 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                     documentation.extra_column()
         else:
             render_content()
+
+
+def _ancestor_nodes(node_id: str) -> List[str]:
+    parent = next((node for node in nodes if any(child['id'] == node_id for child in node.get('children', []))), None)
+    return [node_id] + (_ancestor_nodes(parent['id']) if parent else [])
diff --git a/website/documentation/tree.py b/website/documentation/tree.py
new file mode 100644
index 00000000..902d960e
--- /dev/null
+++ b/website/documentation/tree.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+from .content import DocumentationPage, registry
+from .content.overview import tiles
+
+nodes: List[Dict[str, Any]] = []
+
+
+def build_tree() -> None:
+    """Build tree by recursively collecting documentation pages and parts."""
+    nodes.clear()
+    for module, _ in tiles:
+        page = registry[module.__name__.rsplit('.', 1)[-1]]
+        nodes.append({
+            'id': page.name,
+            'title': _plain(page.title),
+            'children': _children(page),
+        })
+
+
+def _children(page: DocumentationPage) -> List[Dict[str, Any]]:
+    return [
+        {
+            'id': part.link if part.link else f'{page.name}#{part.link_target}',
+            'title': _plain(part.title),
+            'children': _children(registry[part.link]) if part.link else [],
+        }
+        for part in page.parts
+        if part.link or part.link_target
+    ]
+
+
+def _plain(string: Optional[str]) -> str:
+    assert string is not None
+    return string.replace('*', '')
diff --git a/website/search.py b/website/search.py
index 7948f828..a742047e 100644
--- a/website/search.py
+++ b/website/search.py
@@ -1,6 +1,7 @@
 from nicegui import __version__, background_tasks, events, ui
 
 from .documentation import CustomRestructuredText as custom_restructured_text
+from .documentation.search import search_index
 
 
 class Search:
@@ -55,19 +56,26 @@ class Search:
     def handle_input(self, e: events.ValueChangeEventArguments) -> None:
         async def handle_input() -> None:
             with self.results:
-                results = await ui.run_javascript(f'return window.fuse.search("{e.value}").slice(0, 100)', timeout=6)
+                indices = await ui.run_javascript(f'''
+                    const isMobile = window.innerWidth < 610;
+                    const limit = isMobile ? 25 : 100;
+                    return window.fuse.search("{e.value}", {{ limit }}).map(result => result.refIndex);
+                ''', timeout=6)
                 self.results.clear()
                 with ui.list().props('bordered separator'):
-                    for result in results:
-                        if not result['item']['content']:
+                    for index in indices:
+                        if not 0 <= index < len(search_index):
+                            continue
+                        result_item = search_index[index]
+                        if not result_item['content']:
                             continue
                         with ui.item().props('clickable'):
                             with ui.item_section():
-                                with ui.link(target=result['item']['url']):
-                                    ui.item_label(result['item']['title'])
+                                with ui.link(target=result_item['url']):
+                                    ui.item_label(result_item['title'])
                                     with ui.item_label().props('caption'):
-                                        intro = result['item']['content'].split(':param')[0]
-                                        if result['item']['format'] == 'md':
+                                        intro = result_item['content'].split(':param')[0]
+                                        if result_item['format'] == 'md':
                                             element = ui.markdown(intro)
                                         else:
                                             element = custom_restructured_text(intro)
diff --git a/website/sponsors.json b/website/sponsors.json
index c73fcb89..dd552db6 100644
--- a/website/sponsors.json
+++ b/website/sponsors.json
@@ -1,5 +1,5 @@
 {
   "top": [],
-  "total": 18,
-  "contributors": 156
+  "total": 20,
+  "contributors": 158
 }
diff --git a/website/style.py b/website/style.py
index 6248871c..cc300fea 100644
--- a/website/style.py
+++ b/website/style.py
@@ -73,16 +73,6 @@ def subheading(text: str, *, link: Optional[str] = None, major: bool = False, an
             ui.label(text).classes(classes)
         with ui.link(target=f'#{name}').classes('absolute').style('transform: translateX(-150%)'):
             ui.icon('link', size='sm').classes('opacity-10 hover:opacity-80')
-    drawers = [element for element in ui.context.client.elements.values() if isinstance(element, ui.left_drawer)]
-    if drawers:
-        menu = drawers[0]
-        with menu:
-            async def click():
-                if await ui.run_javascript('!!document.querySelector("div.q-drawer__backdrop")', timeout=5.0):
-                    menu.hide()
-                    ui.navigate.to(f'#{name}')
-            ui.link(text, target=f'#{name}').props('data-close-overlay').on('click', click, []) \
-                .classes('font-bold mt-4' if major else '')
 
 
 def create_anchor_name(text: str) -> str:


Test patch:
diff --git a/tests/test_interactive_image.py b/tests/test_interactive_image.py
index ef927227..5d44af6f 100644
--- a/tests/test_interactive_image.py
+++ b/tests/test_interactive_image.py
@@ -95,3 +95,14 @@ def test_loaded_event(screen: Screen):
     screen.click('Change Source')
     screen.should_contain('loaded')
     assert (screen.find_by_tag('img').get_attribute('src') or '').endswith(URL_PATH2)
+
+
+def test_add_layer(screen: Screen):
+    ii = ui.interactive_image(URL_PATH1, content='<rect x="0" y="0" width="100" height="100" fill="red" />')
+    ii.add_layer(content='<circle cx="100" cy="100" r="15" />')
+
+    screen.open('/')
+    screen.find_by_tag('svg')
+    with screen.implicitly_wait(0.5):
+        assert len(screen.find_all_by_tag('rect')) == 1
+        assert len(screen.find_all_by_tag('circle')) == 1
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
index fba43011..9176f0ca 100644
--- a/tests/test_markdown.py
+++ b/tests/test_markdown.py
@@ -31,9 +31,8 @@ def test_markdown_with_mermaid(screen: Screen):
     screen.open('/')
     screen.wait(0.5)  # wait for Mermaid to render
     screen.should_contain('Mermaid')
-    assert screen.find_by_tag('svg').get_attribute('id').startswith('mermaid-')
-    node_a = screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_A")]]')
-    assert node_a.get_attribute('class') == 'nodeLabel'
+    assert screen.find_by_tag('svg').get_attribute('id') == f'{m.html_id}_mermaid_0'
+    assert screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_A")]]').is_displayed()
 
     m.set_content('''
         New:
@@ -44,8 +43,7 @@ def test_markdown_with_mermaid(screen: Screen):
         ```
     ''')
     screen.should_contain('New')
-    node_c = screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_C")]]')
-    assert node_c.get_attribute('class') == 'nodeLabel'
+    assert screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_C")]]').is_displayed()
     screen.should_not_contain('Node_A')
 
 
@@ -59,8 +57,8 @@ def test_markdown_with_mermaid_on_demand(screen: Screen):
 
     screen.open('/')
     screen.click('Create Mermaid')
-    screen.should_contain('Node_A')
-    screen.should_contain('Node_B')
+    assert screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_A")]]').is_displayed()
+    assert screen.selenium.find_element(By.XPATH, '//span[p[contains(text(), "Node_B")]]').is_displayed()
 
 
 def test_strip_indentation(screen: Screen):