BlockNote icon indicating copy to clipboard operation
BlockNote copied to clipboard

(Question) Change numbering style of nested ordered lists

Open alex3236 opened this issue 1 year ago • 6 comments

At https://github.com/TypeCellOS/BlockNote/issues/62, @brandondrew raised the issue of incorrect numbering.

https://github.com/TypeCellOS/BlockNote/pull/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
});
}

alex3236 avatar Feb 18 '24 10:02 alex3236

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.

nicholasdavidbrown avatar Feb 29 '24 03:02 nicholasdavidbrown

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.

matthewlipski avatar Jun 11 '24 11:06 matthewlipski

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.

sb8244 avatar Jul 14 '24 21:07 sb8244

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.

matthewlipski avatar Jul 16 '24 13:07 matthewlipski

@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
    })
  ]
}

sb8244 avatar Aug 03 '24 06:08 sb8244

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

sb8244 avatar Aug 05 '24 06:08 sb8244