Development
How it works
How the bundle replaces the desk's Text Editor control, how values are converted to and from Quill's markup, and why the stylesheet is scoped.
On this page
The app has no server-side behaviour. hooks.py adds one script and one stylesheet to every desk page, and everything else happens in the browser.
Where things live
ui_text_editor/
hooks.py app_include_js and app_include_css: the desk bundle
demo.py the demo Note for frappe-demo and the screenshots
tests/ Python smoke tests of the hooks, the bundle names and the demo
public/dist/ the build output (generated, not committed)
src/
index.ts the desk entry point: installs the control
control.ts the Text Editor field control; the only code that knows about Frappe
FrappeEditor.vue the editor: frappe-ui's Editor, its extensions and menus
toolbar.ts the toolbar, the selection menu and the table menu
html.ts conversion between the stored value and the editor's HTML
upload.ts image uploads to Frappe's upload_file
scope.ts the scope class and the portal host for pop-ups
popups.ts createApp for frappe-ui's own pop-up apps
style.css Tailwind layers and the few rules of the app's own
build/
scope.ts the Vite plugin that scopes the stylesheet
check-scope.mjs the check that nothing escaped the scope
vite.config.ts the bundle build, and the pop-up redirect
marketplace/ the registry listing and the screenshot specThe control
src/index.ts runs once when the bundle loads and calls installTextEditorControl() (src/control.ts). That defines a new ControlTextEditor class on top of Frappe's ControlCode, as Frappe's own Quill control is, and assigns it to frappe.ui.form.ControlTextEditor. Frappe's make_control looks the class up by field type each time it builds a control, so every Text Editor field built afterwards, in a form or a dialog, gets the new one. Classes that already extended the old control, such as ControlComment, keep Quill.
The control keeps the contract the rest of the desk relies on:
Mounting.
make_inputcreates adiv.frappe-editor-rootin the field's input area and mounts a small Vue app withFrappeEditor.vue. Its reactive state holds the content, the placeholder (df.placeholder, translated), whether it is editable (!disabled && !df.read_only, refreshed on everyrefresh()), whether to show the toolbar (not in a grid row) anddf.max_height.Writing. The editor emits
changeonly for transactions that changed the document, so clicking into a field does not dirty the form. The control converts the HTML withtoFrappeand writes it to the model 300 ms after the last change (the delay Frappe's Quill control uses), or at once on blur.Reading.
set_formatted_inputignores the control's own write coming back from the model, and converts any other value withfromFrappe.Parsing.
parseremoves scripts and styles withfrappe.dom.remove_script_and_style, as Quill's control does.
The value conversion
src/html.ts has two functions.
fromFrappe parses the stored value with DOMParser (an inert document: nothing runs or loads), unwraps a single .ql-editor root, removes Quill's ql-ui and ql-cursor elements, turns ql-align-* classes into text-align styles, and turns Quill's ol lists with data-list="bullet" into ul, and with checked or unchecked into the editor's task list (ul data-type="taskList" with li data-type="taskItem" data-checked). Lists with data-list="ordered" stay ol.
toFrappe returns an empty string for an empty editor, and otherwise wraps the HTML in <div class="ql-editor read-mode">. The desk, print formats and emails style rich text through that class. The desk's mandatory check before saving (frappe.model.has_value) does not strip tags, so a wrapped empty paragraph would count as a value there; the empty string makes it, and "is set" filters, see an empty field.
The editor
FrappeEditor.vue uses frappe-ui's RichTextKit with these parts turned off: the kit's heading (replaced by a plain Heading with levels 1 to 6, because the kit's version stamps generated ids on every heading), video, attachments, iframes, the image group and viewer, slash commands, emoji, mentions, tags and typography. Mentions, emoji, slash commands and tags open suggestion pop-ups that would sit outside the scoped stylesheet; video, attachments and iframes were never part of the desk's Text Editor; typography would rewrite quotes and dashes in business text.
src/toolbar.ts lays the toolbar out like Quill's and defines the items frappe-ui's presets lack: underline, clear formatting, task list, code block, indent and outdent. The selection menu has Bold, Italic, Underline, Strike and Link; the table menu is frappe-ui's tableToolbar.
src/upload.ts posts images to /api/method/upload_file with the CSRF token, is_private=0 and folder=Home, and the document's doctype, name and field when the form is saved. It reports progress to the editor and turns Frappe's _server_messages into the error the editor shows.
The scoped stylesheet
The bundle loads on every desk page, but frappe-ui expects to own the page: it brings Tailwind's reset, its design tokens on :root, and generic class names such as .flex and .border that the desk's Bootstrap also defines. The build rewrites every rule so it only matches inside .frappe-editor-root (build/scope.ts): :root, html and body become the scope itself, a leading [data-theme] stays outside it so the dark theme still follows the desk, and everything else is prefixed. yarn check:scope fails the build if a rule gets through. A new frappe-ui release that adds a rule shape the scoper does not know is the usual cause.
The Tailwind content list in tailwind.config.js names only the frappe-ui sources the editor renders, so the stylesheet does not carry the whole library. A class missing from that list shows up as an unstyled control.
Two things keep the editor's floating UI inside the scope:
frappe-ui's overlays (menus, tooltips, dialogs) teleport into one portal host,
div.frappe-editor-root.frappe-editor-portal, through frappe-ui'sportalTargetKey(src/scope.ts).The link, colour and table-size pop-ups mount their own Vue apps directly on
<body>.vite.config.tsroutes thevueimports made from frappe-ui's editor tosrc/popups.ts, whosecreateApptags each of those containers with the scope class, sets itsz-indexto 1200 (above Bootstrap's modals at 1050) and points its overlays at the same host.
If a pop-up shows up unstyled, check that it goes through one of those two paths.
One consequence of the scope: frappe-ui's colour palette (--prose-color-* and --prose-highlight-*) is defined on .ProseMirror, inside the editor, while the colours the editor stores refer to those variables. Outside the editor the variables are undefined, so stored colours do not show in read-only views, print formats or emails. See Limitations.
The build
vite.config.ts builds one IIFE, ui_text_editor.bundle.<hash>.js, and its stylesheet into ui_text_editor/public/dist/. scripts/vite-register.mjs, managed by frappe-nix, maps those files to the ui_text_editor.bundle.js and ui_text_editor.bundle.css keys that hooks.py names, in the bench's sites/assets/assets.json. yarn dev builds under the fixed name development instead of a hash, so the registered file stays the same across rebuilds.
The app's typescript is the native TypeScript 7 build, which has no JavaScript API. Vue's single-file-component compiler and vue-tsc need one, so typescript5 is an alias of TypeScript 5: vite.config.ts registers it with the compiler, and scripts/vue-tsc.cjs runs vue-tsc against it.