Knowledge Keeper LogoKnowledge Keeper
BlogHow it worksPricingLogin/Signup
Building a Rich Text Editor with Protected Content using ProseMirror With Markdown
August 13, 2025
Yamini Chaudhary
Product Updates
Article

Building a Rich Text Editor with Protected Content using ProseMirror With Markdown

Some strings in the document needed to be untouchable — placeholders, keywords, or template fields. Users can edit around them, but if they try to change or delete them, the editor blocks it.

I’d already been knee-deep in ProseMirror for a while. At that point, I had a working editor running inside React with TypeScript. Things were fine — until I ran into a use case that completely shifted my perspective:

I needed certain strings in the document to be untouchable, while everything else stayed editable.

This wasn’t just a nice-to-have. In my project, some pieces of text were “protected” — like keywords in templates, or placeholders in structured documents. Users could type all around them, but the moment they tried to delete or modify those strings, the editor had to step in and say: “Nope, not allowed.”

First Attempts (and Frustrations)

My initial thought was: easy — just wrap those strings in <span contenteditable="false"> and call it a day.

Well, reality check: ProseMirror doesn’t work that way. The moment I tried mixing raw DOM hacks with its transaction system, everything fell apart. Selections went haywire, cursor jumps broke, and undo/redo became unreliable.

So I had to step back and ask: What’s the “ProseMirror way” of doing this?

The Breakthrough: Marks + Plugins

The answer came from two things ProseMirror is really good at:

  1. Schema Marks — I realized I could define a readonly mark. That mark isn’t just styling; it’s metadata baked into the document model.
  2. Plugins with Transaction Filters — ProseMirror lets you inspect every attempted change before it happens. That’s where I could enforce the “don’t touch this string” rule.

Once that clicked, everything fell into place, I decided to stop theorizing and actually wire this up in code.
Let’s walk through it.
Step 1: Setting up App.tsx

The entry point is simple: we load some sample markdown, define the readonly strings, and pass both into our ProseMirrorEditor component.

import React from 'react'
import './App.css'
import ProseMirrorEditor from './components/ProseMirrorEditor'
const sampleMarkdown = `# Sample Document
This is a sample document with some **readonly strings** that cannot be edited.
## Features
- The word "readonly" should be protected
- The phrase "sample document" should also be readonly
- Normal text can be edited freely
- Try to edit the readonly parts - they won't change!
## Testing
You can try to:
1. Select and delete readonly text
2. Type over readonly text
3. Cut/copy readonly text
The readonly strings are: "readonly", "sample document", "protected content"
This is some protected content that you cannot modify.
## Conclusion
This demonstrates how ProseMirror can handle readonly text segments while keeping the rest editable.
`

const readonlyStrings = [
'readonly',
'sample document',
'protected content',
'ProseMirror'
]
function App() {
return (
<div className="App">
<header className="App-header">
<h1>ProseMirror Readonly Strings Test</h1>
<p>
This editor contains readonly text segments highlighted in red.
Try editing them - they're protected!
</p>
</header>

<main>
<ProseMirrorEditor
initialMarkdown={sampleMarkdown}
readonlyStrings={readonlyStrings}
/>

<div className="info-panel">
<h3>Readonly Strings:</h3>
<ul>
{readonlyStrings.map((str, index) => (
<li key={index}>"{str}"</li>
))}
</ul>
<p><strong>Instructions:</strong> Try editing the document above. Notice that the highlighted readonly strings cannot be modified!</p>
</div>
</main>
</div>
)
}
export default App

Here’s the fun part: everything between “readonly” and “protected content” should be locked down once the editor renders.

Step 2: Building the Editor Component

The ProseMirrorEditor is where the ProseMirror machinery comes alive.

import React, { useEffect, useRef } from 'react'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { createReadonlyPlugin } from './prosemirror/plugin'
import { parseMarkdown } from './prosemirror/markdown'
import { baseKeymap } from 'prosemirror-commands'
import { keymap } from 'prosemirror-keymap'
import { history, undo, redo } from 'prosemirror-history'
interface ProseMirrorEditorProps {
initialMarkdown: string
readonlyStrings: string[]
}
const ProseMirrorEditor: React.FC<ProseMirrorEditorProps> = ({
initialMarkdown,
readonlyStrings
}
) =>
{
const editorRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
useEffect(() => {
if (!editorRef.current) return
// Parse markdown and create initial document
const doc = parseMarkdown(initialMarkdown, readonlyStrings)
const state = EditorState.create({
doc,
plugins: [
history(),
keymap({
'Mod-z': undo,
'Mod-y': redo,
'Mod-Shift-z': redo,
}),
keymap(baseKeymap),
createReadonlyPlugin(readonlyStrings),
]
})
const view = new EditorView(editorRef.current, {
state,
dispatchTransaction(transaction) {
const newState = view.state.apply(transaction)
view.updateState(newState)
}
})
viewRef.current = view
return () => view.destroy()
}, [initialMarkdown, readonlyStrings])
return <div ref={editorRef} />
}
export default ProseMirrorEditor

Here’s where the magic happens:

  • We parse markdown into a ProseMirror document.
  • We load up the usual suspects (history, keymap).
  • Then we drop in our custom createReadonlyPlugin.

That plugin is what makes the whole “you can’t touch this” feature work.

Step 3: Schema Extension

Next, we extend ProseMirror’s schema to add support for a readonly mark.

import { Schema } from 'prosemirror-model'
import { schema as basicSchema } from 'prosemirror-schema-basic'
import { addListNodes } from 'prosemirror-schema-list'
const nodes = addListNodes(basicSchema.spec.nodes, "paragraph")
export const schema = new Schema({
nodes,
marks: basicSchema.spec.marks.addBefore('link', 'readonly', {
parseDOM: [{ tag: 'span.readonly-text' }],
toDOM() {
return ['span', { class: 'readonly-text' }, 0]
}
})
})

This makes readonly a first-class citizen in the document model.

Step 4: The Readonly Plugin

Finally, the heart of the implementation:

import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'

const readonlyPluginKey = new PluginKey('readonly')

export function createReadonlyPlugin(readonlyStrings: string[]) {
return new Plugin({
key: readonlyPluginKey,

state: {
init(config, state) {
return findAndDecorateReadonlyText(state.doc, readonlyStrings)
},

apply(tr, decorationSet, oldState, newState) {
// If document changed, recalculate decorations
if (tr.docChanged) {
return findAndDecorateReadonlyText(newState.doc, readonlyStrings)
}
// Otherwise just map the existing decorations
return decorationSet.map(tr.mapping, tr.doc)
}
},

props: {
decorations(state) {
return this.getState(state)
},

handleClick(view, pos, event) {
const decorations = readonlyPluginKey.getState(view.state)
const clickedDecoration = decorations.find(pos, pos + 1)

if (clickedDecoration.length > 0) {
console.log('Cannot edit readonly text!')
return true // Prevent default click behavior
}
return false
}
},

filterTransaction(transaction, state) {
// Check if any step in the transaction affects readonly text
const decorations = readonlyPluginKey.getState(state)
let blocked = false

transaction.steps.forEach(step => {
const stepMap = step.getMap()
stepMap.forEach((oldStart, oldEnd) => {
// Check if the affected range overlaps with any readonly decorations
decorations.find(oldStart, oldEnd).forEach(decoration => {
if (decoration.spec && decoration.spec.readonly) {
blocked = true
}
})
})
})

return !blocked
}
})
}

function findAndDecorateReadonlyText(doc: any, readonlyStrings: string[]): DecorationSet {
const decorations: Decoration[] = []

doc.descendants((node: any, pos: number) => {
if (node.isText && node.text) {
const text = node.text

readonlyStrings.forEach(readonlyStr => {
if (!readonlyStr.trim()) return // Skip empty strings

let searchIndex = 0
while (searchIndex < text.length) {
const found = text.toLowerCase().indexOf(readonlyStr.toLowerCase(), searchIndex)
if (found === -1) break

const from = pos + found
const to = pos + found + readonlyStr.length

// Create decoration for this readonly text
const decoration = Decoration.inline(from, to, {
class: 'readonly-text'
}, { readonly: true })

decorations.push(decoration)
searchIndex = found + readonlyStr.length
}
})
}
})

return DecorationSet.create(doc, decorations)
}

This plugin does three things:

  1. Decorates readonly strings (so they show up styled).
  2. Blocks transactions that would affect those decorations.
  3. Logs interactions when you click on them, just to prove it’s working.

Wrapping It Up

So from the App.tsx setup to the schema extension and the readonly plugin, we’ve basically built an editor that:

  • Highlights protected terms.
  • Lets you type freely around them.
  • Completely ignores any attempt to delete or overwrite them.

It’s one of those features where the idea sounds simple, but it takes some careful wiring in ProseMirror to make it bulletproof.

Github: https://github.com/yamini-chaudhary/prose-mirror-readonly

LinkedInReddit
Privacy PolicyTerms of ServiceCookie Policy🔒 SSL Secured