Dynamically change schema


I thought I would be able to manage with just learning TipTap but turns out I can’t, so I spent this morning reading the ProseMirror docs, and I’m not sure how to approach things.

My goal is to have an editor that let’s me dynamically change from 2 types of content:

  • Page: if > 250 char
    • First node must be heading level 1
    • Second node must be heading level 2.
  • Note: if < 250 char
    • anything goes.
  • Prevent h1 to be used besides the first node
  • Prevent h2 to be used besides the second node

Before reading ProseMirror docs and learning what it can be done my approach was:

  • extension
  • use it’s storage to store current type
  • when doc size > 250 change type
    • check if node 1 and 2 are paragraph, heading 1/2, if not just create the node.
    • calculate the range of node 1 and 2, which took me a while to figure out how to do, I ended up doing this: const fetchedNodeFrom = fetchedNode.from - editor.$doc.node.resolve(fetchedNode.from).depth; which I don’t even know it’s the right way to do it.
    • replace node 1 and 2 with it’s own content using insertContentAt
            { from: fetchedNodeFrom, to: fetchedNode.to },
              updateSelection: false,
              parseOptions: {
                preserveWhitespace: "full",
  • continually monitor those node 1 and 2 for changes in which case enforce them again.
  • continually monitor if size < 250 to do the reverse operation.

It works surprisingly well as of now, but I guess this approach is not reasonable and will be a pain to maintain, so how should I actually do this?

Do you think this is the right approach?

If so, How do I enforce heading 1 and 2 to only appear as the first 2 nodes?

Thank you,


EDIT: I tried transactions a few days ago among many other things in order to transform the node 1 and 2 but I couldn’t initially, but after carefully reading the guide and trying again I think I managed to at least improve that part.

let transaction = editor.state.tr;

      const firstNodeFrom =
      firstNode.from - editor.$doc.node.resolve(firstNode.from).depth;
      const firstNodeTo = firstNode.to;

      const secondNodeFrom =
      secondNode.from - editor.$doc.node.resolve(secondNode.from).depth;
      const secondNodeTo = secondNode.to;
      const headingLevel1 = editor.schema.nodes.heading.create(
        { level: 1 },

      transaction = transaction.replaceWith(

      const headingLevel2 = editor.schema.nodes.heading.create(
        { level: 2 },

      transaction = transaction.replaceWith(

Something like this.

I’m no expert, but I don’t think it can be done. Could certainly be wrong.

1 Like

You need to create two separate schemas (they can share nodespecs and markspecs), since you need a different top node for each. Look into ProseMirror Reference manual and ProseMirror Reference manual. For the page, you can have the topNode with its content like this content: title subtitle block+'. And then for the note, its topNode content should have content: block+. If you want to prevent h1 and h2 from being re-used, exclude them from having block in their group.


Woah, thank you!

I still need to do some reading and testing since I haven’t dealt with schemas yet.

Then, I’d need to:

  • Monitor the content size through an Extension onUpdate, or should I monitor transactions elsewhere?
  • hot-swap schemas.


Where would I ideally save the content in this approach?

I ask because as of now I was running into a problem saving content debounced in the editor onUpdate and transforming content in the extension onUpdate, I guess a race condition or something. (I want to avoid having a saving button)

EDIT: I’ve seen that you recommended this for character count monitoring, so I guess the best place to trigger that schema event would be there, right?

You can maybe make a plugin with the view defined, so that you can reconfigure the editor state with a new schema whenever it should be changed.

ProseMirror Reference manual ProseMirror Reference manual ProseMirror Reference manual

1 Like


I tried going ahead with your input but I was missing context, so re-read the official guide and the PierBover one and I’m still struggling :melting_face:, and I don’t see how things fit together yet, so let me ask some clarifying questions.

  1. I’m using TipTap, is this approach you are suggesting incompatible with TipTap? (I think not)
  2. Is the character counting best performed on a transaction level through a plugin?
  3. Can I/Should I have the same plugin do the counting and changing the view?
  4. Is this view on the plugin sort of my “source of truth”? meaning that all the editor view is in reality this plugin view?
  5. Should I perform the saving onto the db in the same plugin through transactions?(debounced) Or should I do this somewhere else?

Thank you. :pray:

  1. TipTap can use ProseMirror plugins
  2. Yeah I would keep character counting in a plugin because its derived state from the editor state.
import { Plugin, PluginKey } from 'prosemirror-state';
export const WordCountKey = new PluginKey('WordCount');

export interface WordCountState {
	characters: number;
	words: number;
	sentences: number;

export function WordCount() {
	 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter
	const grapheme = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
	const word = new Intl.Segmenter(undefined, { granularity: 'word' });
	const sentence = new Intl.Segmenter(undefined, { granularity: 'sentence' });

	const segment = (text: string): WordCountState => ({
		characters: [...grapheme.segment(text)].length,
		words: [...word.segment(text)].filter(segment => segment.isWordLike).length,
		sentences: [...sentence.segment(text)].length,

	return new Plugin({
		key: WordCountKey,
		state: {
			init(config, state) {
				let textContent = state.doc.textBetween(0, state.doc.content.size, ' ', ' ');
				return segment(textContent);
			apply(tr, pluginState, prevState, state) {
				if (!tr.docChanged) return pluginState;
				let textContent = state.doc.textBetween(0, state.doc.content.size, ' ', ' ');
				return segment(textContent);

export default WordCount;
  1. You can, but you can also create a separate plugin and access the counting state with WordCountKey.getState(editorState).

  2. Not sure what you mean by this; you’re only reconfiguring the editorState, so if that is kept outside in React or etc, make sure to update that as well.

  3. Yeah, so calling view.updateState or view.setProps does not dispatch a transaction; so you should create another hook to persist the state back to a DB.

  1. noted
  2. I did this today:
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";

export const CharacterCount = Extension.create<{
  noteLimit: number;
  name: "characterCount",

  addOptions() {
    return {
      noteLimit: 250,

  addStorage() {
    return {
      characters: () => 0,
      type: "note",

  onBeforeCreate() {
    this.storage.characters = (node?: ProseMirrorNode) => {
      const currentNode = node || this.editor?.state?.doc;
      const text = currentNode.textBetween(
        " ",

      return text.length;

  addProseMirrorPlugins() {
    return [
      new Plugin({
        key: new PluginKey("characterCount"),
        filterTransaction: (transaction, state) => {
          const { editor } = this;
          const type = this.storage.type;
          const limit =
            type === "note" ? this.options.noteLimit : this.options.pageLimit;
if (
            !transaction.docChanged ||
            limit === null ||
            limit === undefined
          ) {
            return true;

          const oldSize = this.storage.characters(state.doc);
          const newSize = this.storage.characters(transaction.doc);

But I appreciate learning how to persist data on a plugin using state instead of storage so I don’t depend on tiptap, because a few times I tried to get storage from places I couldn’t access it.

  1. noted
  2. I’m just confused by so many terms, not sure what I was asking either :joy: but I think I meant that if by setting the plugin view somehow that’s what defined the view or state of the editor, so shouldn’t mess with it anywhere else but in the plugin, or if it was the case that you can have as many views as you want and that’s somehow fine.
  3. this is what I’ve also done
import { Extension, JSONContent } from "@tiptap/react";

function debounce(func: (...args: any[]) => void, wait: number) {
  let timeout: NodeJS.Timeout | null = null;
  return function (...args: any[]) {
    const later = () => {
      timeout = null;
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(later, wait);

const SaveExtension = Extension.create({
  name: "save",

  addOptions() {
    return {
      postID: null,
      ownerID: null,

  onCreate() {
    const { editor } = this;
    const postID = this.options.postID;
    const ownerID = this.options.ownerID;
    if (!postID || !ownerID) {
      console.warn("PostID and ownerID are required to save the document.");
    const debouncedSave = debounce((doc: JSONContent) => {
      const body = JSON.stringify({
        id: postID,
        ownerID: ownerID,
        content: doc,

      fetch("/api/docs/save", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        body: body,
        .then((response) => {
          if (response.ok) {
            console.log("Document saved successfully.");
          } else {
            console.warn("Failed to save document.");
        .catch((error) => {
          console.error("An error occurred while saving the document:", error);
    }, 1000);

    this.editor.on("transaction", ({ transaction }) => {
      if (transaction.docChanged) {

export default SaveExtension;

Do you think this approach is fine?

I tried to store things like postID and authorID inside the doc attrs, injecting it into the previously stored doc, and passing it as content when initialising the editor, but when I read the doc again they disappear. Note I don’t need them to change at all during the lifetime of the editor, just for access later. I’ve checked this thread but after re-reading a couple of times I have no idea if it’s even applicable to my case, so I proceed with the code above and set postID and authorID on configure, for this case I think I’m fine, but I think I will need to modify the doc attrs later on and no idea how.

Most importantly I’m now thinking on how to change schemas, or more precisely where should I perform that, should I do that directly in the charCounter plugin inside the filterTransactions? Doesn’t sound like a good idea, as I’m afraid that it may break it or at least lose some transactions, but no idea where is the right place.

Thank you once again @bhl