* ⬆️ Update devenv dependencies Update Node.js, OpenCode, clj-kondo, Babashka, Pixi, GitHub CLI, uv, and Serena to their current stable releases. AI-assisted-by: gpt-5.6-sol * ⬆️ Update devenv to Java 27 Use Zulu JDK 27 in the development image for compatibility testing. Update the official checksums for both supported architectures. AI-assisted-by: gpt-5.6-sol * 🐳 Replace MinIO with RustFS in devenv Run RustFS as the development S3 service and wait for its health check. Install a pinned AWS CLI with checksums and use it to create the bucket idempotently from each backend entry point. Keep the old MinIO volume untouched and use a new RustFS volume. AI-assisted-by: gpt-5.6-sol * 🐳 Replace MailCatcher with persistent Mailpit Run Mailpit as the devenv SMTP sink while preserving mailer:1025 and the localhost:1080 UI. Store its SQLite inbox in a named volume and wait for the readiness endpoint before starting runtime containers. Bind the web UI to loopback so development emails stay local. AI-assisted-by: gpt-5.6-sol * ⬆️ Update Node.js to 24.21.0 Align the host NVM version with the Node.js version used by devenv. AI-assisted-by: gpt-5.6-sol * ⬆️ Update devenv to PostgreSQL 18.6 Run PostgreSQL 18 with its versioned volume layout and a TCP readiness check that ignores the temporary initialization server. Install the matching client, create penpot_nexus, and preserve the old PostgreSQL 16 volume for rollback or logical migration. AI-assisted-by: gpt-5.6-sol * 🐳 Expose RustFS ports in devenv Publish the RustFS S3 API and management console on localhost port 9000 and 9001. Keep both bindings on loopback so object storage is not exposed to the local network. AI-assisted-by: gpt-5.6-sol * 🐳 Install standalone pnpm in devenv Install pnpm 12.5.0 from architecture-specific release archives and verify their published checksums. Remove the Corepack setup while allowing pnpm to honor the project packageManager pins. AI-assisted-by: gpt-5.6-sol * 🔥 Remove corepack, use system pnpm everywhere Corepack is gone from Node 25+, so every `corepack enable` call fails. pnpm now ships as a system binary (devenv, CI runners and Docker images install it directly) and auto-downloads the version pinned in `packageManager` on mismatch. Scripts, workflows and Dockerfiles call `pnpm` straight away; the three deploy workflows use a single `pnpm/setup@v2` step; and the new `scripts/sync-pnpm-version` stamps all 35 `packageManager` fields from the system pnpm, replacing the `corepack use` sweep. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix exporter watch missing render-wasm build step The exporter watch compiled CLJS requiring the generated src/app/wasm/shared.js, which only render-wasm/build export produces. Without it shadow-cljs failed with a cryptic missing ./shared.js dependency. Run build:wasm before watching, as the frontend watch:app and exporter scripts/build already do. AI-assisted-by: muse-spark-1.3-contributor * 🔧 Add opencode V2 support and adapt plugins Register the penpot tools for both opencode V1 (server()) and V2 (setup() with JSON Schema inputs) from a single dependency-free plugin file, sharing the psql and paren-repair runners between both paths. Install the opencode2 binary side-by-side with V1 in the devenv image and document the dual registration in the paren-repair and psql memories. AI-assisted-by: muse-spark-1.3-contributor * ⬆️ Update pnpm and opencode
Penpot Text Editor
How to run it
Development
To start the development environment run:
pnpm run dev
Testing
For running unit tests and running coverage:
pnpm run test
pnpm run coverage
If you want, you can run the vitest UI by running:
pnpm run test:ui
How to build it
The editor can be built and updated inside Penpot using the following command:
PENPOT_SOURCE_PATH=/path/to/penpot/repository pnpm build:update
This command is going to search for the file located in frontend/src/app/main/ui/workspace/shapes/text/new_editor/TextEditor.js and update it.
How it works?
The text editor divides the content in three elements: root, paragraph and textSpan. In terms of content, a textSpan is a styled element displayed on a line within a block. A textSpan can only have one child (a Text node). A paragraph is a block element that can contain multiple textSpans (textSpan elements).
<div data-itype="root">
<div data-itype="paragraph">
<span data-itype="inline">Hello, </span>
<span data-itype="inline" style="font-weight: bold">World!</span>
</div>
</div>
This way we only need to deal with a structure like this, where circular nodes are HTMLElements and square nodes are Text. Also with an structure like this we have a predictable and ordered tree where we can find our position easily to do any operation (remove, insert, replace, etc).
flowchart TB
root((root)) --> paragraph((paragraph))
paragraph --> text_span_1((textSpan))
paragraph --> text_span_2((textSpan))
text_span_1 --> text_1[Hello, ]
text_span_2 --> text_2[World!]
This is compatible with the way Penpot stores text content.
flowchart TB
root((root)) --> paragraph-set((paragraph-set))
paragraph-set --> paragraph((paragraph))
paragraph --> text((text))
How the TextEditor works?
flowchart TB
TextEditor -->|handles `selectionchange` events| SelectionController
TextEditor -->|handles how the editor dispatches changes| ChangeController
The TextEditor contains a series of references to DOM elements, one of them is a contenteditable element that keeps the sub-elements explained before (root, paragraphs and textspans).
SelectionController listens to the document event called selectionchange. This event is triggered everytime the focus/selection of the browser changes.
ChangeController is called by the TextEditor instance everytime a change is performed on the content of the contenteditable element.
Best practices
Use isType functions
Instead of handling elements by their properties like this:
if (element.tagName === "SPAN") {
...
}
Use functions like isParagraph, isTextSpan or isLineBreak:
if (isTextSpan(element)) {
...
}
Events
-
change: This event is dispatched every time a change is made in the editor. All changes are debounced to prevent dispatching too many change events. This event is also dispatched when there are pending change events and the user blurs the textarea element. -
stylechange: This event is dispatched every time thecurrentStylechanges. This normally happens when the user changes the caret position or the selection and thecurrentStyleis re-computed.
How the code is organized?
editor: contains everything related to the TextEditor. WhereTextEditor.jsis the main file where all the basic code of the editor is handled. This has been designed so that in the future, when the Web Components API is more stable and has features such as handling selection events within shadow roots we will be able to update this class with little effort.editor/clipboard: Event handlers for clipboard events.editor/commands: Event handlers for input events (commands) that modifies the content of the TextEditor.editor/content: Code related to handling elements like text nodes, paragraphs, line breaks, etc. This are a series of utility functions that can perform some verifications and mutations on DOM nodes.editor/controllers: There are two controllers;ChangeControllerthat handles when a change in the content should be notified andSelectionControllerthat handles operations on selections and text, this is where all the mutations on DOM nodes are performed.
Implementation
Everything is implemented in JavaScript using beforeinput and InputEvent for the user events. blur and focus are used to handle imposter selections.
Why imposter selections?
Normally when you click on another UI element, the current selection is replaced by the selection of the new UI element.
References
- InputEvent: the main event used for handling user input.
- Selection: for handling selections.
- Range: for handling range selections. 1
- Node: for operator functions like
compareDocumentPositionornodeType. - Text: for operator functions like
splitText. - Element: for operator functions like
after,before,append,remove,prepend, etc.
1: Firefox is the only browser right now (2024-07-08) that has support for multiple selection ranges so we have to deal with this special case removing old ranges.
For future reference
In a near future maybe we could take a lot at the EditContext API.
FAQ
Sometimes I receive 'TypeError: Cannot read from private field'
Sometimes, when you update the TextEditor source code, this exception could raise because shadow-cljs updated the code but keeps a reference to the old instance of the text editor, so the new code tries to read a private field from an old instance.