This document contains information for AI agents working on this project.
- Python/Django Backend: Django app in
django_prose_editor/ - JavaScript/TypeScript Frontend: Tiptap editor extensions in
src/ - Tests: Python tests in
tests/, uses Playwright for E2E tests - Documentation: ReStructuredText files in
docs/
Use tox to run tests:
# Run tests with Python 3.13 and Django 5.2
tox -e py313-dj52Tests include both unit tests and Playwright E2E tests. The test suite will automatically install Chromium if needed.
The project uses:
- Tiptap for the rich text editor
- rslib for building JavaScript modules
- prek for linting and formatting (Rust-based pre-commit alternative, runs Biome and other tools)
JavaScript source files are in src/ and get built into django_prose_editor/static/.
IMPORTANT: After modifying JavaScript files in src/, you MUST rebuild with:
yarn prodYou might have to prepend mise x -- for this to work.
The tests run against the compiled JavaScript in django_prose_editor/static/, not the source files.
Documentation is written in ReStructuredText (.rst) format in the docs/ directory.
When modifying extensions or features:
- Update the relevant
.rstfile indocs/ - Include code examples in both Python and JavaScript
- Document configuration options, usage patterns, and HTML output
- Extensions are in
src/ - Each extension typically exports a Tiptap extension object
- Extensions can add attributes to nodes/marks using
addGlobalAttributes() - Menu items are added via
addMenuItems({ buttons, menu })
Distinguishing Nodes vs Marks:
const isNodeType = (editor, typeName) => {
return !!editor.state.schema.nodes[typeName]
}Getting Applicable Items:
- For nodes: Walk up the document tree from the selection
- For marks: Check marks at the current selection position
Menu Items:
- Use
active()to determine if option should be highlighted - Use
hidden()to determine if option should be shown - For marks: Hide when selection is empty or mark type not active
- For nodes: Hide when node type is not in ancestor chain
Commit without the Co-Authored-By attribution line (no --co-author / no Co-Authored-By: Claude trailer).
- Make code changes
- If you modified JavaScript: Run
yarn prodto rebuild - Run linting/formatting:
prek run --all-files(or let it run on commit) - Run tests:
tox -e py313-dj52 - Verify all tests pass (47 tests expected as of 2026-06-08)
- Update documentation if needed
tests/testapp/test_prose_editor_e2e.py— general editor, formatting, tables, configurable, HTML, NodeClass, StyleLoom teststests/testapp/test_classloom_e2e.py— ClassLoom extension tests (paragraph colors + table layout)tests/testapp/e2e_utils.py— sharedlogin(page, live_server)helper imported by both e2e files
ProseEditorModel— bare fieldTableProseEditorModel— includes Table, OrderedList etc.ConfigurableProseEditorModel— BlueBold, HTML, NodeClass, TextClass, etc.StyleLoomProseEditorModel— StyleLoom with font-size and max-widthClassLoomProseEditorModel— ClassLoom with two groups:paragraphColors(non-combinable, typeparagraph, classes:color-red,color-blue,color-green)tableLayout(combinable, typetable, classes:table--auto,table--no-borders)
Tiptap's TableView.update() only re-renders column widths but does not sync node attribute changes (e.g. class set via ClassLoom) back to the <table> DOM element. Our Table extension in src/table.js overrides addNodeView() to patch update() so it also applies node.attrs.class to tableView.table after every update. This is worth proposing upstream to @tiptap/extension-table.
ClassLoom dropdowns expose picker items with data-name attributes (e.g. classLoom:paragraphColors:color-red). Tests use these to find and click options without depending on visible text:
def _apply_classloom_class(page, group_ident, class_name):
page.locator(".prose-menubar__dropdown").filter(
has=page.locator(f"[data-name='{group_ident}:default']")
).locator(".prose-menubar__selected").click()
page.locator(f"[data-name='{group_ident}:{class_name}']").click()When an extension needs to support both nodes and marks:
- Use a single configuration object (e.g.,
cssClasses) - Check the schema at runtime to determine if type is node or mark
- Handle nodes with
setNodeAttribute()and transactions - Handle marks with
setMark()andisActive() - Update menu
hidden()logic appropriately for each type
Extensions are configured in two ways:
- Python: Via
ProseEditorField(extensions={...}) - JavaScript: Via
Extension.configure({...})
Keep both configuration methods documented and in sync.
Important: Use Tiptap's updateAttributes() command to modify mark attributes. This preserves all other attributes automatically:
// ✅ CORRECT - Updates only the specified attribute, preserves others
editor.chain()
.extendMarkRange(typeName)
.updateAttributes(typeName, { class: 'newValue' })
.run()
// To remove an attribute, set it to null
editor.chain()
.extendMarkRange(typeName)
.updateAttributes(typeName, { class: null })
.run()This automatically preserves other attributes like href on links, src on images, etc. without needing to manually spread existing attributes.
When working with marks at a collapsed selection (cursor position), use extendMarkRange() to select the entire mark:
editor.chain()
.extendMarkRange('bold') // Selects entire bold region
.setMark('bold', { class: 'emphasis' })
.run()To check if a mark exists at the cursor position, use the resolved position's marks, not just isActive():
const { state } = editor
const { $from } = state.selection
const markType = state.schema.marks[typeName]
const marks = $from.marks()
const hasMark = marks.some(mark => mark.type === markType)The isActive() method can be unreliable at mark boundaries or with collapsed selections.