TypeCellOS / BlockNote

A React Rich Text Editor that's block-based (Notion style) and extensible. Built on top of Prosemirror and Tiptap.
https://www.blocknotejs.org/
Mozilla Public License 2.0
6.52k stars 445 forks source link

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

Open alex3236 opened 7 months ago

alex3236 commented 7 months ago

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
});
}
nicholasdavidbrown commented 7 months ago

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.

matthewlipski commented 4 months ago

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.

sb8244 commented 2 months ago

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.

matthewlipski commented 2 months ago

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.

sb8244 commented 2 months ago

@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:

// 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 commented 2 months ago

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