Inkpen
A modern, TipTap-based rich text editor for Ruby on Rails applications. Inkpen provides a clean, extensible editor with a PORO-based architecture that seamlessly integrates with Rails forms and Hotwire/Stimulus.
Features
- TipTap/ProseMirror Foundation: Built on the powerful TipTap editor framework
- Rails Integration: Works seamlessly with Rails forms, Turbo, and Stimulus
- PORO Architecture: Clean Ruby objects for configuration and extensions
- Importmap Compatible: No Node.js build step required
- Extensible: Modular extension system for adding features
- Toolbar Options: Floating, fixed, or hidden toolbar configurations
Installation
Add to your Gemfile:
gem "inkpen", github: "nauman/inkpen"
Then run:
bundle install
Stylesheets (important — must be included by the host)
The gem ships a separate stylesheet at inkpen/editor.css. It is not auto-loaded by your host app's layout. You must include it yourself, typically in app/views/layouts/application.html.erb:
<%= stylesheet_link_tag "inkpen/editor", "data-turbo-track": "reload" %>
If you have other layouts (fullscreen tool layouts, custom marketing layouts, etc.) and Inkpen mounts on any view they render, each layout must include the stylesheet. Forgetting this is the most common "the editor renders but looks broken" failure mode — TipTap mounts fine in JS, but no Inkpen styles apply.
If you also use any of the visual extensions below, include their stylesheets too (Sprockets *= require inkpen/<name> or Propshaft <%= stylesheet_link_tag "inkpen/<name>" %>):
inkpen/advanced_table,inkpen/inkpen_table— table stylesinkpen/callout,inkpen/columns,inkpen/database,inkpen/document_section— block extensionsinkpen/drag_drop,inkpen/block_gutter,inkpen/sticky_toolbar— chromeinkpen/embed,inkpen/enhanced_image,inkpen/file_attachment,inkpen/footnotes,inkpen/toc— content blocksinkpen/mention,inkpen/slash_menu,inkpen/preformatted,inkpen/search_replace,inkpen/section,inkpen/toggle,inkpen/markdown_mode,inkpen/export,inkpen/animations— UX
Only inkpen/editor is added to the asset precompile list automatically; everything else opts in.
Configuration
Configure Inkpen globally in an initializer:
# config/initializers/inkpen.rb
Inkpen.configure do |config|
config. = :floating # :floating, :fixed, :none
config.placeholder = "Start writing..."
config.autosave = true
config.autosave_interval = 5000 # milliseconds
config.min_height = "200px"
config.max_height = "600px"
# Enable/disable extensions
config.extensions = [:bold, :italic, :link, :heading, :bullet_list]
end
Basic Usage
Creating an Editor Instance
# In your controller or view
editor = Inkpen::Editor.new(
name: "post[body]",
value: @post.body,
toolbar: :floating,
extensions: [:bold, :italic, :link, :heading, :mentions],
placeholder: "Write your post..."
)
In Views (ERB)
<%= tag.div editor.data_attributes do %>
<%= hidden_field_tag editor.input_name, editor.value %>
<div class="inkpen-editor" style="<%= editor.style_attributes %>"></div>
<% end %>
Toolbar Configuration
= Inkpen::Toolbar.new(
style: :floating,
buttons: [:bold, :italic, :link, :heading],
position: :top
)
# Predefined button presets
Inkpen::Toolbar::PRESET_MINIMAL # [:bold, :italic, :link]
Inkpen::Toolbar::PRESET_STANDARD # Formatting + common blocks
Inkpen::Toolbar::PRESET_FULL # All available buttons
Sticky Toolbar
The sticky toolbar provides a fixed-position toolbar for inserting blocks, media, and widgets. It supports horizontal (bottom) and vertical (left/right) positions.
# Enable sticky toolbar with default settings
editor = Inkpen::Editor.new(
name: "post[body]",
value: @post.body,
sticky_toolbar: Inkpen::StickyToolbar.new(
position: :bottom, # :bottom, :left, :right
buttons: [:table, :code_block, :image, :youtube, :widget],
widget_types: %w[form gallery poll]
)
)
Available buttons:
| Button | Description |
|---|---|
table |
Insert a table |
code_block |
Insert code block |
blockquote |
Insert quote block |
horizontal_rule |
Insert divider line |
task_list |
Insert task list |
image |
Insert image (triggers inkpen:request-image event) |
youtube |
Insert YouTube video |
embed |
Insert embed (triggers inkpen:request-embed event) |
widget |
Open widget picker modal |
divider |
Visual separator |
Presets:
Inkpen::StickyToolbar::PRESET_BLOCKS # table, code_block, blockquote, etc.
Inkpen::StickyToolbar::PRESET_MEDIA # image, youtube, embed
Inkpen::StickyToolbar::PRESET_FULL # All buttons
Handling widget events:
// In your application.js or page-specific controller
document.addEventListener("inkpen:insert-widget", (event) => {
const { type, controller } = event.detail
// type is "form", "gallery", or "poll"
// Show your widget picker UI
})
document.addEventListener("inkpen:request-image", (event) => {
const { controller } = event.detail
// Show image upload modal
// Then call: controller.insertImage(url, altText)
})
Extensions
Inkpen uses a modular extension system. Each extension is a PORO that configures TipTap extensions.
Core Extensions
Available by default:
bold,italic,strike,underlinelink,headingbullet_list,ordered_listblockquote,code_blockhorizontal_rule,hard_break
Advanced Extensions
Forced Document Structure
Enforces a document structure with a required title heading:
extension = Inkpen::Extensions::ForcedDocument.new(
heading_level: 1,
placeholder: "Enter your title...",
allow_deletion: false
)
extension.to_config
# => { headingLevel: 1, titlePlaceholder: "Enter your title...", ... }
Mentions
Enable @mentions functionality:
extension = Inkpen::Extensions::Mention.new(
search_url: "/api/users/search",
trigger: "@",
min_chars: 1,
suggestion_class: "mention-popup",
allow_custom: false
)
# Or with static items:
extension = Inkpen::Extensions::Mention.new(
items: [
{ id: 1, label: "John Doe" },
{ id: 2, label: "Jane Smith" }
]
)
Code Block with Syntax Highlighting
Add syntax highlighting to code blocks:
extension = Inkpen::Extensions::CodeBlockSyntax.new(
languages: [:ruby, :javascript, :python, :sql],
default_language: :ruby,
line_numbers: true,
language_selector: true,
copy_button: true,
theme: "github" # or "monokai", "dracula"
)
Available languages: javascript, typescript, ruby, python, css, xml, html, json, bash, sql, markdown, go, rust, java, kotlin, swift, php, c, cpp, csharp, elixir, and more.
Tables
Add table support with resizing and toolbar:
extension = Inkpen::Extensions::Table.new(
resizable: true,
header_row: true,
header_column: false,
cell_min_width: 25,
toolbar: true,
allow_merge: true,
default_rows: 3,
default_cols: 3
)
Task Lists
Add interactive checkboxes/task lists:
extension = Inkpen::Extensions::TaskList.new(
nested: true,
list_class: "task-list",
item_class: "task-item",
checked_class: "task-checked",
keyboard_shortcut: "Mod-Shift-9"
)
Creating Custom Extensions
Extend Inkpen::Extensions::Base:
module Inkpen
module Extensions
class MyCustomExtension < Base
def name
:my_custom
end
def to_config
{
optionOne: .fetch(:option_one, "default"),
optionTwo: .fetch(:option_two, true)
}
end
private
def
super.merge(
option_one: "default",
option_two: true
)
end
end
end
end
JavaScript Integration
Inkpen uses Stimulus controllers and importmaps. The gem automatically registers pins for TipTap and ProseMirror dependencies.
Required Importmap Pins
The gem includes pins for:
- TipTap core and PM adapters
- ProseMirror packages
- TipTap extensions (document, paragraph, text, formatting, etc.)
- Lowlight for syntax highlighting
- Highlight.js language definitions
Stimulus Controller
The editor is controlled by inkpen--editor Stimulus controller. Connect it to your editor container:
<div data-controller="inkpen--editor"
data-inkpen--editor-extensions-value='["bold","italic","link"]'
data-inkpen--editor-toolbar-value="floating"
data-inkpen--editor-placeholder-value="Start writing...">
<!-- Editor content here -->
</div>
The extensions-value array gates which TipTap extensions the editor instantiates. The full bundle ships every extension, but unconfigured ones don't run — they just sit in the bundle. (A future spec — 02-lazy-load-and-extension-gating in the planning surface — will gate the bundle download too, so a lite editor downloads only what it asks for. As of 0.8.x the gate is runtime-only, not bundle-time.)
Supported extension names: bold, italic, link, heading, bullet_list, ordered_list, task_list, code_block, code, strike, underline, subscript, superscript, highlight, typography, placeholder, blockquote, horizontal_rule, hard_break, history, dropcursor, gapcursor, image, youtube, character_count, bubble_menu, floating_menu, mention, table, inkpen_table, task_item, callout, columns, database, document_section, embed, enhanced_image, file_attachment, slash_commands, block_commands, block_gutter, drag_handle, toggle_block, preformatted, section, section_title, table_of_contents, export_commands, content_embed.
Public events
The editor controller dispatches CustomEvents that bubble up the DOM, so any ancestor element (including the host's application.js) can listen with element.addEventListener("inkpen:<name>", handler). All events include detail.controller pointing at the dispatching EditorController instance.
| Event | When it fires | Payload (detail) |
|---|---|---|
inkpen:ready |
Editor is mounted and ready to use | { editor } |
inkpen:change |
Content changed | { content, title, subtitle, body, wordCount, characterCount } |
inkpen:focus |
Editor gained focus | {} |
inkpen:blur |
Editor lost focus | {} |
inkpen:selection-change |
Selection or marks changed | { selection, marks } |
inkpen:autosave |
Autosave fired | { content, timestamp } |
inkpen:mode-change |
WYSIWYG/split/markdown view toggled | { mode, previousMode } |
inkpen:error |
Recoverable error | { kind, error?, message? } |
inkpen:slash-command |
User picked an item from the / slash menu |
{ commandId, range, editor } |
inkpen:export-success |
Export action succeeded | { message } |
inkpen:export-error |
Export action failed | { message } |
inkpen:widget-inserted |
Sticky-toolbar widget inserted | { type, data } |
inkpen:insert-widget, inkpen:request-file, inkpen:request-image, inkpen:request-embed |
Sticky-toolbar requests host to handle an action | varies; see sticky_toolbar_controller.js |
inkpen:error is the most useful one to wire to a host-side error tracker — log it globally so any future failure mode is visible. Today's kind values include module-load (a TipTap module failed to import) and markdown-import (reserved for the future markdown-import path).
inkpen:slash-command is how host code adds custom slash-menu actions. The commandId is whatever you registered in the slash-commands extension config; intercept the event, prevent default if your code handles it, otherwise let the editor's default action run.
Architecture
inkpen/
├── lib/
│ ├── inkpen.rb # Main entry point
│ ├── inkpen/
│ │ ├── configuration.rb # Global config PORO
│ │ ├── editor.rb # Editor instance PORO
│ │ ├── toolbar.rb # Floating toolbar config PORO
│ │ ├── sticky_toolbar.rb # Sticky toolbar config PORO
│ │ ├── engine.rb # Rails engine
│ │ ├── version.rb
│ │ └── extensions/
│ │ ├── base.rb # Extension base class
│ │ ├── forced_document.rb # Title heading structure
│ │ ├── mention.rb # @mentions
│ │ ├── code_block_syntax.rb # Syntax highlighting
│ │ ├── table.rb # Table support
│ │ └── task_list.rb # Task/checkbox lists
├── app/
│ └── assets/
│ ├── javascripts/
│ │ └── inkpen/
│ │ ├── controllers/
│ │ │ ├── editor_controller.js # Main TipTap editor
│ │ │ ├── toolbar_controller.js # Floating toolbar
│ │ │ └── sticky_toolbar_controller.js # Sticky toolbar
│ │ └── index.js # Entry point
│ └── stylesheets/
│ └── inkpen/
│ ├── editor.css # Editor styles
│ └── sticky_toolbar.css # Sticky toolbar styles
├── config/
│ └── importmap.rb # TipTap/PM dependencies
└── README.md
API Reference
Inkpen::Editor
| Method | Description |
|---|---|
name |
Form field name |
value |
Current editor content |
toolbar |
Toolbar style (:floating, :fixed, :none) |
extensions |
Array of enabled extension symbols |
data_attributes |
Hash of Stimulus data attributes |
style_attributes |
CSS inline styles string |
extension_enabled?(name) |
Check if extension is enabled |
input_id |
Safe HTML ID from name |
Inkpen::Toolbar
| Method | Description |
|---|---|
style |
Toolbar style |
buttons |
Array of button symbols |
position |
Toolbar position (:top, :bottom) |
floating? |
Is floating toolbar? |
fixed? |
Is fixed toolbar? |
hidden? |
Is toolbar hidden? |
Inkpen::StickyToolbar
| Method | Description |
|---|---|
position |
Position (:bottom, :left, :right) |
buttons |
Array of button symbols |
widget_types |
Array of widget type strings |
enabled? |
Is sticky toolbar enabled? |
vertical? |
Is vertical layout (left/right)? |
horizontal? |
Is horizontal layout (bottom)? |
data_attributes |
Hash of Stimulus data attributes |
Inkpen::Extensions::Base
| Method | Description |
|---|---|
name |
Extension identifier (Symbol) |
enabled? |
Is extension enabled? |
options |
Configuration options hash |
to_config |
JS configuration hash |
to_h |
Full extension hash |
to_json |
JSON representation |
Development
After checking out the repo:
bin/setup # Install dependencies
bundle exec rake test # Run tests
bin/console # Interactive console
Used by
Powering the series editor at inventlist.com/stream?type=series — where indie builders write build-in-public stories.
Contributing
Bug reports and pull requests are welcome on GitHub.
License
MIT License