Indent Extension For Tiptap 2 (just want to share)

See original GitHub issue

Is your feature request related to a problem? Please describe.

ueberdosis/tiptap#819 has a thorough list of extensions and it’s stated there that there is an indent extension already implemented at https://github.com/Leecason/element-tiptap ( can be seen here in action https://leecason.github.io/element-tiptap/all_extensions ). There, it’s implemented with an HTML attribute, but I wanted to implement it using styles margin-left so I made an extension myself build on top of the TextAlign extension of Tiptap 2 with inspiration from https://github.com/Leecason/element-tiptap

Describe the solution you’d like

(this is my try at indent extension for tiptap 2 )
import { Command, Extension } from '@tiptap/core'
import { Node } from 'prosemirror-model'
import { TextSelection, AllSelection, Transaction } from 'prosemirror-state'

type IndentOptions = {
  types: string[],
  indentLevels: number[],
  defaultIndentLevel: number,
}

declare module '@tiptap/core' {
  interface Commands {
    indent: {
      /**
       * Set the indent attribute
       */
      indent: () => Command,
      /**
       * Unset the indent attribute
       */
      outdent: () => Command,
    }
  }
}

export function clamp(val: number, min: number, max: number): number {
  if (val < min) {
    return min
  }
  if (val > max) {
    return max
  }
  return val
}

export enum IndentProps {
  min = 0,
  max = 210,

  more = 30,
  less = -30
}

export function isBulletListNode(node: Node): boolean {
  return node.type.name === 'bullet_list'
}

export function isOrderedListNode(node: Node): boolean {
  return node.type.name === 'order_list'
}

export function isTodoListNode(node: Node): boolean {
  return node.type.name === 'todo_list'
}

export function isListNode(node: Node): boolean {
  return isBulletListNode(node) ||
    isOrderedListNode(node) ||
    isTodoListNode(node)
}

function setNodeIndentMarkup(tr: Transaction, pos: number, delta: number): Transaction {
  if (!tr.doc) return tr

  const node = tr.doc.nodeAt(pos)
  if (!node) return tr

  const minIndent = IndentProps.min
  const maxIndent = IndentProps.max

  const indent = clamp(
    (node.attrs.indent || 0) + delta,
    minIndent,
    maxIndent,
  )

  if (indent === node.attrs.indent) return tr

  const nodeAttrs = {
    ...node.attrs,
    indent,
  }

  return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks)
}

function updateIndentLevel(tr: Transaction, delta: number): Transaction {
  const { doc, selection } = tr

  if (!doc || !selection) return tr

  if (!(selection instanceof TextSelection || selection instanceof AllSelection)) {
    return tr
  }

  const { from, to } = selection

  doc.nodesBetween(from, to, (node, pos) => {
    const nodeType = node.type

    if (nodeType.name === 'paragraph' || nodeType.name === 'heading') {
      tr = setNodeIndentMarkup(tr, pos, delta)
      return false
    } if (isListNode(node)) {
      return false
    }
    return true
  })

  return tr
}

export const Indent = Extension.create<IndentOptions>({
  name: 'indent',

  defaultOptions: {
    types: ['heading', 'paragraph'],
    indentLevels: [0, 30, 60, 90, 120, 150, 180, 210],
    defaultIndentLevel: 0,
  },

  addGlobalAttributes() {
    return [
      {
        types: this.options.types,
        attributes: {
          indent: {
            default: this.options.defaultIndentLevel,
            renderHTML: attributes => ({
              style: `margin-left: ${attributes.indent}px!important;`
            }),
            parseHTML: element => ({
              indent: parseInt(element.style.marginLeft) || this.options.defaultIndentLevel,
            }),
          },
        },
      },
    ]
  },

  addCommands() {
    return {
      indent: () => ({ tr, state, dispatch }) => {
        const { selection } = state
        tr = tr.setSelection(selection)
        tr = updateIndentLevel(tr, IndentProps.more)

        if (tr.docChanged) {
          // eslint-disable-next-line no-unused-expressions
          dispatch && dispatch(tr)
          return true
        }

        return false
      },
      outdent: () => ({ tr, state, dispatch }) => {
        const { selection } = state
        tr = tr.setSelection(selection)
        tr = updateIndentLevel(tr, IndentProps.less)

        if (tr.docChanged) {
          // eslint-disable-next-line no-unused-expressions
          dispatch && dispatch(tr)
          return true
        }

        return false
      },
    }
  },

  addKeyboardShortcuts() {
    return {
      Tab: () => this.editor.commands.indent(),
      'Shift-Tab': () => this.editor.commands.outdent()
    }
  },
})

Issue Analytics

  • State:closed
  • Created 2 years ago
  • Reactions:18
  • Comments:17 (8 by maintainers)

github_iconTop GitHub Comments

6reactions
danlinecommented, Nov 28, 2021

@sereneinserenade Thank you so much!!

Here is the full code with imports if anyone else needs it. Since I guess I am using the latest version, I also had to return value instead of object within parseHTML according to: https://github.com/ueberdosis/tiptap/issues/1863

// Sources:
//https://github.com/ueberdosis/tiptap/issues/1036#issuecomment-981094752
//https://github.com/django-tiptap/django_tiptap/blob/main/django_tiptap/templates/forms/tiptap_textarea.html#L453-L602

import { Extension } from '@tiptap/core'
import { TextSelection, AllSelection } from "prosemirror-state"

export const clamp = (val, min, max) => {
    if (val < min) {
        return min
    }
    if (val > max) {
        return max
    }
    return val
}

const IndentProps = {
    min: 0,
    max: 210,

    more: 30,
    less: -30
}

export function isBulletListNode(node) {
    return node.type.name === 'bullet_list'
}

export function isOrderedListNode(node) {
    return node.type.name === 'order_list'
}

export function isTodoListNode(node) {
    return node.type.name === 'todo_list'
}

export function isListNode(node) {
    return isBulletListNode(node) ||
        isOrderedListNode(node) ||
        isTodoListNode(node)
}

function setNodeIndentMarkup(tr, pos, delta) {
    if (!tr.doc) return tr

    const node = tr.doc.nodeAt(pos)
    if (!node) return tr

    const minIndent = IndentProps.min
    const maxIndent = IndentProps.max

    const indent = clamp(
        (node.attrs.indent || 0) + delta,
        minIndent,
        maxIndent,
    )

    if (indent === node.attrs.indent) return tr

    const nodeAttrs = {
        ...node.attrs,
        indent,
    }

    return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks)
}

const updateIndentLevel = (tr, delta) => {
    const { doc, selection } = tr

    if (!doc || !selection) return tr

    if (!(selection instanceof TextSelection || selection instanceof AllSelection)) {
        return tr
    }

    const { from, to } = selection

    doc.nodesBetween(from, to, (node, pos) => {
        const nodeType = node.type

        if (nodeType.name === 'paragraph' || nodeType.name === 'heading') {
            tr = setNodeIndentMarkup(tr, pos, delta)
            return false
        } if (isListNode(node)) {
            return false
        }
        return true
    })

    return tr
}

export const Indent = Extension.create({
    name: 'indent',

    defaultOptions: {
        types: ['heading', 'paragraph'],
        indentLevels: [0, 30, 60, 90, 120, 150, 180, 210],
        defaultIndentLevel: 0,
    },

    addGlobalAttributes() {
        return [
            {
                types: this.options.types,
                attributes: {
                    indent: {
                        default: this.options.defaultIndentLevel,
                        renderHTML: attributes => ({
                            style: `margin-left: ${attributes.indent}px!important;`
                        }),
                        parseHTML: element => parseInt(element.style.marginLeft) || this.options.defaultIndentLevel,
                    },
                },
            },
        ]
    },

    addCommands() {
        return {
            indent: () => ({ tr, state, dispatch, editor }) => {
                const { selection } = state
                tr = tr.setSelection(selection)
                tr = updateIndentLevel(tr, IndentProps.more)

                if (tr.docChanged) {
                    // eslint-disable-next-line no-unused-expressions
                    dispatch && dispatch(tr)
                    return true
                }

                editor.chain().focus().run()

                return false
            },
            outdent: () => ({ tr, state, dispatch, editor }) => {
                const { selection } = state
                tr = tr.setSelection(selection)
                tr = updateIndentLevel(tr, IndentProps.less)

                if (tr.docChanged) {
                    // eslint-disable-next-line no-unused-expressions
                    dispatch && dispatch(tr)
                    return true
                }

                editor.chain().focus().run()

                return false
            },
        }
    },
    addKeyboardShortcuts() {
        return {
          Tab: () => { if (!(this.editor.isActive('bulletList') || this.editor.isActive('orderedList'))) return this.editor.commands.indent() },
          'Shift-Tab': () => { if (!(this.editor.isActive('bulletList') || this.editor.isActive('orderedList'))) return this.editor.commands.outdent() },
          Backspace: () => { if (!(this.editor.isActive('bulletList') || this.editor.isActive('orderedList'))) return this.editor.commands.outdent() },
        }
      },
})

5reactions
thetarnavcommented, Aug 29, 2021

Amazing extension! I’ve tried to add an option to it to unindent using backspace, just seems more intuitive to me than using a shift-tab shortcut. Generally, it works, although it seems kinda brute. But I don’t know tiptap enough to do any better.

edit: now it works better while selecting

outdent:
  (backspace = false) =>
  ({ tr, state, dispatch }) => {
    const { selection } = state
    if (backspace && (selection.$anchor.parentOffset > 0 || selection.from !== selection.to))
      return false
    
    // ...
  },
addKeyboardShortcuts() {
  return {
      Tab: () => this.editor.commands.indent(),
      'Shift-Tab': () => this.editor.commands.outdent(false),
      Backspace: () => this.editor.commands.outdent(true),
    }
  },
Read more comments on GitHub >

github_iconTop Results From Across the Web

Custom extensions – Tiptap Editor
Extend existing attributes. If you want to add an attribute to an extension and keep existing attributes, you can access them through this.parent()...
Read more >
Newest 'tiptap' Questions - Stack Overflow
I 'm currently experimenting with TipTap, an editor framework. My goal is to build a Custom Node extension for TipTap that wraps a...
Read more >
Tiptap custom extensions - Hutton
Build a custom class extension – Tiptap Editor Series: Building custom ... Upload to S3 mechanic) Indent Indent Extension For Tiptap 2 (just...
Read more >
Tiptap editor - Strapi Market
I don't want to keep toggling between editor mode and preview mode, just to see what ... 1 2 3 4 5 #...
Read more >
Switching Rich Text Editors, Part 1: Picking Tiptap
Then, in your code you just need import `slate` when you want the older ... Trix and I've switched from Trix to TipTap...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found