Skip to content

(Question) Change numbering style of nested ordered lists #587

Description

@alex3236

At #62, @brandondrew raised the issue of incorrect numbering.

#42 fixed the numbering, but it doesn't solve the other problem: sublists commonly have a different number style.

For example, a common numbered list shall look like this, which is more readable:

1.  sth
    a.  sth
        i.  sth
        ii. sth
            1. sth
        iii.sth
        iv. sth

while current presentation is:

1.  sth
    1.  sth
        1.  sth
        2.  sth
            1.  sth
        3.  sth
        4.  sth

common nested styles are:

1st 2nd 3rd 4th
1. a. i. 1.
1. 1) a) 1.
1. 1.1. 1.1.1. 1.1.1.1.

Further more, there are multiple variants of one number style, which is useful in different scenarios. For example, a. could also be a) or (a).

If possible, consider implementing this, or provide a way to customize this behavior easily. Maybe do something like this:

function listNumberProvider(numeric: number, layer: number) {
    if (layer % 3 === 2) {
        return `${toAlphabet(numeric)}.`; // a.
    } else if (layer % 3 === 0) {
        return `${toRomeNumber(numeric)}.`; // i.
    } else {
        return `${numeric}.` // 1.
    }
}

const editor: BlockNoteEditor = useBlockNote({
    listNumberProvider: listNumberProvider
});
}

Activity

  1. nicholasdavidbrown commented on Feb 29, 2024

    @nicholasdavidbrown

    I'm just a user of this library, but implementing this to the core list block would make lists much more complex than they need to be for most users (I can only speak for myself). Writing a custom block using the core Blocknote ListBlock as a basis would be what I would do in the short term.

    You can also just use the slash menu in any nested block too:

    image

    Just to offer some alternatives for you in the meantime.

  2. matthewlipski commented on Jun 11, 2024

    @matthewlipski
    Collaborator

    I think this is a good suggestion! While customizing the numbering style for each nesting level is probably overkill, I agree that nested numbered lists should have distinct numbering styles.

  3. sb8244 commented on Jul 14, 2024

    @sb8244

    Having this in the base NumberedListItemBlock seems very useful. Unless I'm missing something, I don't think it's super easy to implement this even in a custom block today. If there's a path towards implementing it, I'd love to know so I can take a stab at it.

  4. matthewlipski commented on Jul 16, 2024

    @matthewlipski
    Collaborator

    Yeah agreed it's not a super easy change, but definitely take a look at the NumberedListIndexingPlugin. I think in theory you would just need to check the depth from blockInfo and have a function which converts the regular index to/from the other formats, like roman numerals. Also had a quick look and the TipTap node doesn't care what the index attribute is since it just stores, renders, and parses it, so doing e.g. index: "iii" shouldn't cause any issues.

  5. sb8244 commented on Aug 3, 2024

    @sb8244

    @matthewlipski I know a lot more about BlockNote now, so was more comfortable taking this on.

    I'm not sure how to actually work in the dev version of the project. I was hoping to be able to write some tests or examples to test this feature, but I think they're based on published version of blocknote and not local.

    I'm more than happy to work through putting this into BlockNote proper, or if you want to use the code that is also good with me.

    A few notes on the code:

    • I wasn't sure how to best pass the function into the plugin. Because I am overriding the plugin itself, I decided to put it into the closure. I don't think this would work in the main project.
    • The plugin actually becomes simpler because we calculate the list index. This means that the requirement of firstInDoc + firstOfType is not really needed.
    • getListCharacter could be default-included in this project. I kept it dependency free
    • I could not use the existing index attribute to calculate "previous index", because it's no longer guaranteed to be a digit. I did not want to extend the existing types at all as part of this, and think the re-calculation of depth/index is probably good overall.
    • BUG: I realized that depth is not calculate properly here. It should be the depth of the list entry and NOT the depth relative to the document. I think that calculation would need to change to be accurate. Impact: a numeric list started at depth will not necessarily start at 1
    // NumberListIndexingPlugin.ts
    import { Plugin, PluginKey } from "prosemirror-state"
    import { getBlockInfoFromPos } from "../helpers/getBlockInfoFromPos"
    
    // ProseMirror Plugin which automatically assigns indices to ordered list items per nesting level.
    const PLUGIN_KEY = new PluginKey(`numbered-list-indexing`)
    
    interface Options {
      getListCharacter?: (positionDetails: { depth: number; index: number }) => string
    }
    
    const defaultGetListCharacter = (position: { index: number }) => position.index.toString()
    
    export const NumberedListIndexingPlugin = (opts: Options = {}) => {
      const getListCharacter = opts.getListCharacter || defaultGetListCharacter
    
      return new Plugin({
        key: PLUGIN_KEY,
        appendTransaction: (_transactions, _oldState, newState) => {
          const tr = newState.tr
          tr.setMeta("numberedListIndexing", true)
    
          let modified = false
    
          // Traverses each node the doc using DFS, so blocks which are on the same nesting level will be traversed in the
          // same order they appear. This means the index of each list item block can be calculated by incrementing the
          // index of the previous list item block.
          newState.doc.descendants((node, pos) => {
            if (node.type.name === "blockContainer" && node.firstChild!.type.name === "numberedListItem") {
              const blockInfo = getBlockInfoFromPos(tr.doc, pos + 1)!
              if (blockInfo === undefined) {
                return
              }
    
              // Divide by 2 because each block has a nesting around it
              const depth = blockInfo.depth / 2
    
              let firstListBlock = blockInfo
              let blockIndex = 1
    
              while (firstListBlock) {
                const prevBlockInfo = getBlockInfoFromPos(tr.doc, firstListBlock.startPos - 2)!
    
                if (
                  prevBlockInfo &&
                  prevBlockInfo.id !== firstListBlock.id &&
                  prevBlockInfo.depth === firstListBlock.depth &&
                  prevBlockInfo.contentType === firstListBlock.contentType
                ) {
                  blockIndex++
                  firstListBlock = prevBlockInfo
                } else {
                  break
                }
              }
    
              const newIndex = getListCharacter({ depth, index: blockIndex })
              const contentNode = blockInfo.contentNode
              const index = contentNode.attrs["index"]
    
              if (index !== newIndex) {
                modified = true
    
                tr.setNodeMarkup(pos + 1, undefined, {
                  index: newIndex
                })
              }
            }
          })
    
          return modified ? tr : null
        }
      })
    }
    // getListCharacter.ts
    
    export function getListCharacter(positionDetails: { depth: number; index: number }) {
      const style = (positionDetails.depth - 1) % 3
    
      if (style === 1) {
        return getColumnLetters(positionDetails.index)
      } else if (style === 2) {
        return romanize(positionDetails.index).toLowerCase()
      } else {
        return `${positionDetails.index}`
      }
    }
    
    // from https://dev.to/all_stacks_developer/how-to-convert-column-index-of-a-spreadsheet-into-letters-31k0
    // prettier-ignore
    const ALPHABET = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']
    
    function getColumnLetters(columnIndexStartFromOne: number): string {
      if (columnIndexStartFromOne < 27) {
        return ALPHABET[columnIndexStartFromOne - 1]
      } else {
        var res = columnIndexStartFromOne % 26
        var div = Math.floor(columnIndexStartFromOne / 26)
        if (res === 0) {
          div = div - 1
          res = 26
        }
        return getColumnLetters(div) + ALPHABET[res - 1]
      }
    }
    
    // from https://stackoverflow.com/questions/9083037/convert-a-number-into-a-roman-numeral-in-javascript
    function romanize(num: number): string {
      const digits = `${num}`.split("")
      // prettier-ignore
      const key = ["","C","CC","CCC","CD","D","DC","DCC","DCCC","CM",
                 "","X","XX","XXX","XL","L","LX","LXX","LXXX","XC",
                 "","I","II","III","IV","V","VI","VII","VIII","IX"]
      let roman = ""
      let i = 3
      while (i--) roman = (key[+digits.pop()! + i * 10] || "") + roman
      return Array(+digits.join("") + 1).join("M") + roman
    }
    // editor.tsx
    
    defaultBlockSpecs.numberedListItem.implementation.node.config.addProseMirrorPlugins = () => {
      return [
        NumberedListIndexingPlugin({
          getListCharacter
        })
      ]
    }
  6. sb8244 commented on Aug 5, 2024

    @sb8244

    This updated version handles broken lists properly, by finding the closest parent of the same type: https://gist.github.com/sb8244/c59acf0836eda8c79852e623afe936d1

  7. serpent213 commented on Nov 12, 2024

    @serpent213
    Contributor

    Just solved the same problem. I'm using CSS to generate the actual ordering symbols (lower-alpha in this case):

    @property --alpha-index {
        syntax: "<integer>";
        initial-value: 1;
        inherits: true;
    }
    
    .bn-block-content[data-content-type="alphabeticalListItem"] {
        display: flex;
        gap: 0.5em;
    }
    
    .bn-block-content[data-content-type="alphabeticalListItem"]::before {
        counter-reset: alphabeticalCounter var(--alpha-index);
        content: counter(alphabeticalCounter, lower-alpha) ".";
    }

    And extended my ListIndexingPlugin like so:

        tr.setNodeMarkup(blockInfo.blockContent.beforePos, undefined, {
            ...contentNode.attrs,
            index: newIndex,
            style: `--alpha-index: ${newIndex}`
        })

    For my AlphabeticalListItem (based on NumericalListItem) to work I had to patch core to export updateblock, though.

    export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js";

    Happy to learn about a workaround – or have that exported by default...? 🙂

  8. added
    needs-triageIssue has not yet been reviewed or classified by maintainers.
    on Feb 3, 2026
  9. locked and limited conversation to collaborators on Feb 19, 2026
  10. converted this issue into a discussion #2496 on Feb 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageIssue has not yet been reviewed or classified by maintainers.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions