Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

panel-mde

CI conda-forge pypi-version python-version

A rich text editor for Panel whose value is markdown, powered by EasyMDE.

pn.widgets.TextEditor is Quill-based and gives you HTML, with no way to write a table. MarkdownEditor reads and writes markdown source instead, so the editor, the rendered output and anything else that touches the text all agree on one format.

Features

  • Markdown in, markdown out - value is the markdown source, synced on every keystroke
  • A toolbar with tables - bold, italic, headings, lists, links, images, tables, undo/redo and more
  • Caret-safe programmatic writes - appending to value from Python keeps the caret, selection, scroll position and undo history intact
  • Live preview - optional, rendered by Panel's own markdown-it pane so it matches every other markdown surface in your app
  • Self-contained - the script, stylesheet and icons ship with the package, so nothing is fetched from a third-party CDN at runtime
  • Works in shadow roots and dialogs - inline SVG icons need no @font-face, and the editor re-measures itself when it is attached after render

Installation

Install via pip:

pip install panel-mde

Or via conda:

conda install -c conda-forge panel-mde

Quick Start

import panel as pn
from panel_mde import MarkdownEditor

pn.extension()

editor = MarkdownEditor(
    value="# Sprint notes\n\nMarkdown **in**, markdown **out**.",
    preview=True,
    status_bar=True,
    sizing_mode="stretch_width",
    height=430,
)

editor.servable()

Quick Start Example

Usage Examples

Two-way value sync

value updates on every keystroke, so a watcher (or a reactive expression) sees the text as it is typed:

import panel as pn
from panel_mde import MarkdownEditor

pn.extension()

editor = MarkdownEditor(value="# Title", height=300)
words = pn.pane.Markdown(editor.param.value.rx.pipe(lambda md: f"{len(md.split())} words"))

pn.Row(editor, words).servable()

Pass on_keyup=False to defer value until the editor loses focus or the user presses Ctrl/Cmd+Enter, the same contract as pn.widgets.TextInput. value_input always tracks the live text.

Appending while the user types

Writing value from Python applies the smallest edit that produces the new text, so an append from an upload flow does not disturb the person editing:

import panel as pn
from panel_mde import MarkdownEditor

pn.extension()

editor = MarkdownEditor(value="# Trip report\n\nWe left early.", height=300)
upload = pn.widgets.FileInput(accept=".png,.jpg")


def add_image(event):
    url = store(event.new)  # your own storage
    editor.value += f"\n\n![{upload.filename}]({url})\n"


upload.param.watch(add_image, "value")

pn.Row(editor, upload).servable()

The caret stays where it was, a selection survives, the view does not scroll and undo still walks back through what the user typed.

Choosing the toolbar

from panel_mde import MarkdownEditor

MarkdownEditor(
    toolbar=["bold", "italic", "heading", "|",
             "unordered-list", "ordered-list", "table", "|",
             "link", "image", "|", "undo", "redo"],
)

toolbar=True renders the set above, toolbar=False hides the toolbar, and "|" inserts a separator. panel_mde.TOOLBAR_ACTIONS lists every available action:

bold, italic, strikethrough, heading, heading-smaller, heading-bigger, heading-1, heading-2, heading-3, code, quote, unordered-list, ordered-list, check-list, clean-block, link, image, table, horizontal-rule, undo, redo, preview.

An unknown action raises ValueError rather than silently disappearing.

Live preview

from panel_mde import MarkdownEditor

MarkdownEditor(value="# Title", preview=True, preview_location="bottom", height=500)

The preview renders through pn.pane.Markdown, so it uses markdown-it with your app's own extensions. EasyMDE's bundled marked.js preview is never used, because it renders subtly differently from every other markdown surface in a Panel app. Add "preview" to the toolbar to let the user toggle it, or restyle it by assigning your own pane to preview_pane.

Inside a dialog

CodeMirror measures a detached element as zero-size, which is why some editors come up blank inside a dialog. MarkdownEditor re-measures on render, layout and resize, so it needs no help from the surrounding app:

import panel as pn
from panel_mde import MarkdownEditor

pn.extension()

editor = MarkdownEditor(value="# Notes", height=300)
card = pn.Card(editor, title="Notes", collapsed=True)

card.servable()

API Reference

MarkdownEditor

Parameter Type Default Description
value str "" The markdown source. Updates per keystroke unless on_keyup is disabled
value_input str "" The markdown source, always updated per keystroke
on_keyup bool True Whether value updates per keystroke or on blur
toolbar bool | list True The formatting toolbar: True, False or a list of actions
preview bool False Show the live preview
preview_location str "right" "right" or "bottom"
preview_pane Markdown None The pane rendering the preview, created on demand
autofocus bool False Focus the editor on initial render
disabled bool False Make the editor read-only
placeholder str "" Text shown while the editor is empty
line_numbers bool False Show line numbers in the gutter
line_wrapping bool True Wrap long lines instead of scrolling horizontally
indent_with_tabs bool False Indent with a tab character rather than spaces
tab_size int 2 Spaces per indent level
spellcheck bool True Use the browser's native spell checker
status_bar bool False Show the line, word and cursor counts
unordered_list_style str "-" Bullet marker: "-", "*" or "+"

Assets

Everything the editor needs is served by the Panel server itself. The EasyMDE bundle is compiled into the package, its stylesheet is vendored, and the toolbar icons are inline SVG. EasyMDE's two runtime downloads are both disabled: the Font Awesome icon font (replaced by the inline SVG) and the spell-check dictionaries (replaced by the browser's own checker). Inline SVG inherits currentColor, so the icons also render correctly inside the shadow root Panel gives every component, with no document-level @font-face registration.

Development

This project is managed by pixi.

Setup

git clone https://github.com/panel-extensions/panel-mde
cd panel-mde

pixi run pre-commit-install
pixi run postinstall
pixi run compile
pixi run test

Compiling the bundle

The ESM in src/panel_mde/models/ imports EasyMDE, which panel compile bundles into src/panel_mde/dist/:

pixi run compile        # one-off build
pixi run compile-dev    # rebuild on change

The vendored EasyMDE stylesheet is generated from the pinned npm version, with its @font-face rules and icon-font references stripped:

pixi run vendor-css

Testing

pixi run test                        # unit tests
pixi run -e test-ui test-ui          # Playwright UI tests

Examples

panel serve examples/apps/notes.py examples/apps/image_upload.py --dev

Pre-commit

Before committing the first time please install pre-commit:

pip install pre-commit
pre-commit install

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

See LICENSE file for details.

Release files for panel-mde 0.1.0a0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for panel-mde 0.1.0a0
File Size Uploaded
panel_mde-0.1.0a0.tar.gz 404.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for panel-mde 0.1.0a0
File Interpreter ABI Platform
panel_mde-0.1.0a0-py3-none-any.whl Python 3 none any Details

Total release size: 539.0 kB

Release files / panel_mde-0.1.0a0.tar.gz

Download URL panel_mde-0.1.0a0.tar.gz
Size 404.4 kB
Tags Source
SHA-256 checksum
How to use checksums
9e36519635b199425b1e9916292e1d0505b0b8de46fc548db9cc4cabec2596e2
BLAKE2b-256 checksum
How to use checksums
74be795f78997365d129e77ae044a0d379abd4219a5b5ca1b76115da4c574dd9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.

Transparency log

Release files / panel_mde-0.1.0a0-py3-none-any.whl

Download URL panel_mde-0.1.0a0-py3-none-any.whl
Size 134.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6ad8235e515414331a1b4742754e02e5d8b51e8cd8eb8dc4ebaac16a807d27df
BLAKE2b-256 checksum
How to use checksums
1d7ea6a2abe11be6e5d66a6c747d802898a3606d1fc6205402b721219f9093e4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 21, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page