Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Indent Extension For Tiptap 2 (just want to share) #1036

Closed
sereneinserenade opened this issue Apr 19, 2021 · 23 comments
Closed

Indent Extension For Tiptap 2 (just want to share) #1036

sereneinserenade opened this issue Apr 19, 2021 · 23 comments

Comments

@sereneinserenade
Copy link
Contributor

sereneinserenade commented Apr 19, 2021

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

#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()
    }
  },
})
@hanspagel hanspagel transferred this issue from ueberdosis/tiptap-next Apr 21, 2021
@hanspagel hanspagel added the v2 label Apr 21, 2021
@hanspagel
Copy link
Contributor

Thanks for sharing! 🙌 It’s not documented, but there is an isList(name: string, extensions: Extensions) helper in the core you could probably use.

I’ll add this to the list of Community extensions and close this here for now. But feel free to comment.

@hanspagel hanspagel mentioned this issue Apr 21, 2021
6 tasks
@sereneinserenade
Copy link
Contributor Author

Cool, thanks!

@augustusnaz
Copy link

Excellent work!!
Although it doesn't nest (bullet/ordered) lists

@sereneinserenade
Copy link
Contributor Author

hey there @augustusnaz, I think the problem you mentioned is caused by this code

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

so when you're inside a bullet list or ordered list, you can ignore the this.editor.commands.indent() or this.editor.commands.outdent() , here's a solution that came to me on first thought.

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() }
  }
},

@sereneinserenade
Copy link
Contributor Author

@augustusnaz this might work better

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

@thetarnav
Copy link

thetarnav commented 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),
    }
  },

@evanfuture
Copy link

Thank you @sereneinserenade for sharing this. I applied some of my own thoughts around best practices and control flow and bundled it in a custom extension, I hope that's ok with you: https://github.com/evanfuture/tiptaptop-extension-indent

Specifically, I thought it best to give control over the visual (spacing, margin/padding), to the user's scss, and just use a data-attribute number for the scale. I still have an open question around it (#1943), but overall, it's working really well in my project. So again, thanks!

@sereneinserenade
Copy link
Contributor Author

You're very welcome @evanfuture, and if you don't mind, could also mention @Leecason on your repo's description ? Because if he hadn't wrote his implementation of indent extension, this implementation wouldn't have been possible.

@danline
Copy link

danline commented Nov 27, 2021

Does anyone have vanilla js version of this?!

@sereneinserenade
Copy link
Contributor Author

sereneinserenade commented Nov 28, 2021

@Daniel-Sudhindaran here: https://github.com/django-tiptap/django_tiptap/blob/main/django_tiptap/templates/forms/tiptap_textarea.html#L453-L602

you also have to take care of imports yourself

@danline
Copy link

danline commented 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: #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() },
        }
      },
})

@yuyuyukie
Copy link

yuyuyukie commented Dec 25, 2021

Hi, I appreciate your great work!
I wrote a TypeScript version of this extension with a bit of changes. Please let me know if there are any kind of problems!

  • Migrated IndentProps to IndentOptions, therefore they can be configured now.
  • Added keyboardShortcuts
    • You need to e.preventDefault() in onKeyDown callback.
// example with React
const pressedKeys = useRef<Record<string, any>>({})
~~~
onKeyDown={(e) => {
  pressedKeys.current[e.key] = e.key
  if (
    (pressedKeys.current['['] || pressedKeys.current[']']) &&
    pressedKeys.current.Meta
  ) {
    e.preventDefault()
  }
}}
onKeyUp={() => {
  pressedKeys.current = {}
}}
original code
/* eslint-disable no-param-reassign */
// 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 {
  CommandProps,
  Extension,
  Extensions,
  isList,
  KeyboardShortcutCommand,
} from '@tiptap/core'
import { TextSelection, Transaction } from 'prosemirror-state'

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    indent: {
      indent: () => ReturnType
      outdent: () => ReturnType
    }
  }
}

type IndentOptions = {
  names: Array<string>
  indentRange: number
  minIndentLevel: number
  maxIndentLevel: number
  defaultIndentLevel: number
  HTMLAttributes: Record<string, any>
}
export const Indent = Extension.create<IndentOptions, never>({
  name: 'indent',

  addOptions() {
    return {
      names: ['heading', 'paragraph'],
      indentRange: 24,
      minIndentLevel: 0,
      maxIndentLevel: 24 * 10,
      defaultIndentLevel: 0,
      HTMLAttributes: {},
    }
  },

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

  addCommands(this) {
    return {
      indent:
        () =>
        ({ tr, state, dispatch, editor }: CommandProps) => {
          const { selection } = state
          tr = tr.setSelection(selection)
          tr = updateIndentLevel(
            tr,
            this.options,
            editor.extensionManager.extensions,
            'indent'
          )
          if (tr.docChanged && dispatch) {
            dispatch(tr)
            return true
          }
          editor.chain().focus().run()
          return false
        },
      outdent:
        () =>
        ({ tr, state, dispatch, editor }: CommandProps) => {
          const { selection } = state
          tr = tr.setSelection(selection)
          tr = updateIndentLevel(
            tr,
            this.options,
            editor.extensionManager.extensions,
            'outdent'
          )
          if (tr.docChanged && dispatch) {
            dispatch(tr)
            return true
          }
          editor.chain().focus().run()
          return false
        },
    }
  },

  addKeyboardShortcuts() {
    return {
      Tab: indent(),
      'Shift-Tab': outdent(false),
      Backspace: outdent(true),
      'Mod-]': indent(),
      'Mod-[': outdent(false),
    }
  },
})

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

function setNodeIndentMarkup(
  tr: Transaction,
  pos: number,
  delta: number,
  min: number,
  max: number
): Transaction {
  if (!tr.doc) return tr
  const node = tr.doc.nodeAt(pos)
  if (!node) return tr
  const indent = clamp((node.attrs.indent || 0) + delta, min, max)
  if (indent === node.attrs.indent) return tr
  const nodeAttrs = {
    ...node.attrs,
    indent,
  }
  return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks)
}

type IndentType = 'indent' | 'outdent'
const updateIndentLevel = (
  tr: Transaction,
  options: IndentOptions,
  extensions: Extensions,
  type: IndentType
): Transaction => {
  const { doc, selection } = tr
  if (!doc || !selection) return tr
  if (!(selection instanceof TextSelection)) {
    return tr
  }
  const { from, to } = selection
  doc.nodesBetween(from, to, (node, pos) => {
    if (options.names.includes(node.type.name)) {
      tr = setNodeIndentMarkup(
        tr,
        pos,
        options.indentRange * (type === 'indent' ? 1 : -1),
        options.minIndentLevel,
        options.maxIndentLevel
      )
      return false
    }
    return !isList(node.type.name, extensions)
  })
  return tr
}

const indent: () => KeyboardShortcutCommand =
  () =>
  ({ editor }) => {
    if (
      !isList(editor.state.doc.type.name, editor.extensionManager.extensions)
    ) {
      return editor.commands.indent()
    }
    return false
  }
const outdent: (outdentOnlyAtHead: boolean) => KeyboardShortcutCommand =
  (outdentOnlyAtHead) =>
  ({ editor }) => {
    if (
      !(
        isList(
          editor.state.doc.type.name,
          editor.extensionManager.extensions
        ) ||
        (outdentOnlyAtHead && editor.state.selection.$head.parentOffset !== 0)
      )
    ) {
      return editor.commands.outdent()
    }
    return false
  }

P.S.
I fixed some issues and refactored the code.

  • converting paragraph to heading now inherits its indention levels
  • converting paragraph to listItem now resets its indention levels
  • fixed conditional expressions in keyboard shortcut methods indent and outdent
  • methods indent and outdent are now renamed to getIndent and getOutdent.
  • you can use above methods outside of the extension in order to indent/outdent nodes including listItems
// example
<button type="button"
    onClick={() => getIndent()({ editor })}
>
  Indent
</button>
  • integrated listItem indention/outdention into getIndent and getOutdent methods
fixed code
/* eslint-disable no-param-reassign */
// 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 {
  CommandProps,
  Extension,
  Extensions,
  isList,
  KeyboardShortcutCommand,
} from '@tiptap/core'
import { TextSelection, Transaction } from 'prosemirror-state'

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    indent: {
      indent: () => ReturnType
      outdent: () => ReturnType
    }
  }
}

type IndentOptions = {
  names: Array<string>
  indentRange: number
  minIndentLevel: number
  maxIndentLevel: number
  defaultIndentLevel: number
  HTMLAttributes: Record<string, any>
}
export const Indent = Extension.create<IndentOptions, never>({
  name: 'indent',

  addOptions() {
    return {
      names: ['heading', 'paragraph'],
      indentRange: 24,
      minIndentLevel: 0,
      maxIndentLevel: 24 * 10,
      defaultIndentLevel: 0,
      HTMLAttributes: {},
    }
  },

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

  addCommands(this) {
    return {
      indent: () => ({ tr, state, dispatch, editor }: CommandProps) => {
        const { selection } = state
        tr = tr.setSelection(selection)
        tr = updateIndentLevel(
          tr,
          this.options,
          editor.extensionManager.extensions,
          'indent'
        )
        if (tr.docChanged && dispatch) {
          dispatch(tr)
          return true
        }
        return false
      },
      outdent: () => ({ tr, state, dispatch, editor }: CommandProps) => {
        const { selection } = state
        tr = tr.setSelection(selection)
        tr = updateIndentLevel(
          tr,
          this.options,
          editor.extensionManager.extensions,
          'outdent'
        )
        if (tr.docChanged && dispatch) {
          dispatch(tr)
          return true
        }
        return false
      },
    }
  },

  addKeyboardShortcuts() {
    return {
      Tab: getIndent(),
      'Shift-Tab': getOutdent(false),
      Backspace: getOutdent(true),
      'Mod-]': getIndent(),
      'Mod-[': getOutdent(false),
    }
  },
  onUpdate() {
    const { editor } = this
    // インデントされたparagraphがlistItemに変更されたらindentをリセット
    if (editor.isActive('listItem')) {
      const node = editor.state.selection.$head.node()
      if (node.attrs.indent) {
        editor.commands.updateAttributes(node.type.name, { indent: 0 })
      }
    }
  },
})

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

function setNodeIndentMarkup(
  tr: Transaction,
  pos: number,
  delta: number,
  min: number,
  max: number
): Transaction {
  if (!tr.doc) return tr
  const node = tr.doc.nodeAt(pos)
  if (!node) return tr
  const indent = clamp((node.attrs.indent || 0) + delta, min, max)
  if (indent === node.attrs.indent) return tr
  const nodeAttrs = {
    ...node.attrs,
    indent,
  }
  return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks)
}

type IndentType = 'indent' | 'outdent'
const updateIndentLevel = (
  tr: Transaction,
  options: IndentOptions,
  extensions: Extensions,
  type: IndentType
): Transaction => {
  const { doc, selection } = tr
  if (!doc || !selection) return tr
  if (!(selection instanceof TextSelection)) {
    return tr
  }
  const { from, to } = selection
  doc.nodesBetween(from, to, (node, pos) => {
    if (options.names.includes(node.type.name)) {
      tr = setNodeIndentMarkup(
        tr,
        pos,
        options.indentRange * (type === 'indent' ? 1 : -1),
        options.minIndentLevel,
        options.maxIndentLevel
      )
      return false
    }
    return !isList(node.type.name, extensions)
  })
  return tr
}

export const getIndent: () => KeyboardShortcutCommand = () => ({ editor }) => {
  if (editor.can().sinkListItem('listItem')) {
    return editor.chain().focus().sinkListItem('listItem').run()
  }
  return editor.chain().focus().indent().run()
}
export const getOutdent: (
  outdentOnlyAtHead: boolean
) => KeyboardShortcutCommand = (outdentOnlyAtHead) => ({ editor }) => {
  if (outdentOnlyAtHead && editor.state.selection.$head.parentOffset > 0) {
    return false
  }
  if (
    /**
     * editor.state.selection.$head.parentOffset > 0があるのは
     * ```
     * - Hello
     * |<<ここにカーソル
     * ```
     * この状態でBackSpaceを繰り返すとlistItemのtoggleが繰り返されるのを防ぐため
     */
    (!outdentOnlyAtHead || editor.state.selection.$head.parentOffset > 0) &&
    editor.can().liftListItem('listItem')
  ) {
    return editor.chain().focus().liftListItem('listItem').run()
  }
  return editor.chain().focus().outdent().run()
}

@dilizarov
Copy link
Contributor

dilizarov commented Apr 30, 2022

This is great!

One thing I've noticed that doesn't make sense to me (I admit I'm a ProseMirror newbie) is the following:

When I indent a paragraph and then press enter (creating another paragraph) the indentation is preserved in the new paragraph. This is great.

When I indent a paragraph and then create a heading on the next line, the indentation is not preserved.

I'm not sure why attributes.indent resets itself back to 0 - especially since this extension should work for headings.

Any thoughts?

@dilizarov
Copy link
Contributor

I noticed a situation where if I indent a few times on a line and then made a list on that line, I got something like this:

Screen Shot 2022-05-09 at 4 34 44 PM

For me, this is incredibly undesirable behavior.

My solution is below:

const editor = useEditor({
    onUpdate({ editor: e }) {
      e.state.doc.descendants((node, pos, parent) => {
        if (
          ["orderedList", "bulletList", "listItem"].includes(node.type.name)
        ) {
          return true;
        }

        if (
          node.type.name === "paragraph" &&
          parent.type.name === "listItem" &&
          node.attrs.indent > 0
        ) {
          e.view.dispatch(e.state.tr.setNodeMarkup(pos, null, { indent: 0 }));
        }

        return false;
      });
    },
   ...

Tailor it to however you see fit for your use-case.

Perhaps there is a better way to do this, but I'm not well-versed in ProseMirror/TipTap... I don't like that for every update we're iterating over the document looking for offending paragraphs, but iteration aside, nothing else seems heavy and it'd require a huge document to start seeing performance degrade IMO.

@wenerme
Copy link

wenerme commented May 26, 2022

Add another margin-left based indent extension, online demo here https://wode.vercel.app/tiptap

@yuyuyukie
Copy link

yuyuyukie commented Jun 13, 2022

@dilizarov

When I indent a paragraph and then create a heading on the next line, the indentation is not preserved.
I'm not sure why attributes.indent resets itself back to 0 - especially since this extension should work for headings.

Indention only works when the indent/outdent command runs. In addition, toggling heading replaces original paragraph, since indent level resets to 0.
You need to extend Heading extension's addCommands and addInputRules .

Heading.extend({
  addCommands() {
    const { options, name, editor } = this
    return {
      setHeading: (attributes) => ({ commands }) => {
        if (!options.levels.includes(attributes.level)) {
          return false
        }
        if (editor.state.selection.$head.node().attrs.indent) {
          return commands.setNode(name, {
            ...attributes,
            indent: editor.state.selection.$head.node().attrs.indent,
          })
        }
        return commands.setNode(name, attributes)
      },
      toggleHeading: (attributes) => ({ commands }) => {
        if (!options.levels.includes(attributes.level)) {
          return false
        }

        if (editor.state.selection.$head.node().attrs.indent) {
          return commands.toggleNode(name, 'paragraph', {
            ...attributes,
            indent: editor.state.selection.$head.node().attrs.indent,
          })
        }
        return commands.toggleNode(name, 'paragraph', attributes)
      },
    }
  },
  addInputRules() {
    const { options, type, editor } = this
    return options.levels.map((level: Level) => {
      return textblockTypeInputRule({
        find: new RegExp(`^(#{1,${level}})\\s$`),
        type,
        getAttributes: () => ({
          level,
          indent: editor.state.selection.$head.node().attrs.indent,
        }),
      })
    })
  },
})

@yuyuyukie
Copy link

yuyuyukie commented Jun 13, 2022

I noticed a situation where if I indent a few times on a line and then made a list on that line, I got something like this:

How about adding the code below to indentExtension itself?
You don't need to iterate over all nodes, but you can use isActive helper.

  onUpdate() {
    const { editor } = this
    if (editor.isActive('listItem')) {
      const node = editor.state.selection.$head.node()
      if (node.attrs.indent) {
        editor.commands.updateAttributes(node.type.name, { indent: 0 })
      }
    }
  },

@ogios
Copy link

ogios commented Aug 4, 2023

but somehow it can not apply to CodeBlock or am i missing something

@rchasman
Copy link

Most up to date answer I've found so far on this issue:
#457 (comment)

@nperez0111
Copy link
Contributor

I really like the solutions that have come up here. I think Tiptap should officially support one of these implementations.

From looking around, I too think that this one might be the best bet. If anyone would like to contribute, feel free to make a PR. Don't worry about making a package or anything, just a demo would be enough & I could move it to a package (we are looking to reorganize our packages)

@anasvemmully
Copy link

Add another margin-left based indent extension, online demo here https://wode.vercel.app/tiptap

does the online demo works, because I don't see....its all black

@Suraja18
Copy link

Mention TypeScript code as well

@harrisonhoward
Copy link

harrisonhoward commented Nov 27, 2024

Hello, I went with a different solution to the extension because I was having issues with nodes that support `content: "paragraph", "paragraph+" or "block*". My issue was it wouldn't indent or outdent the content nodes at all.
My version of the extension uses a similar methodology taken in TipTap's text-align extension where it brute forces the attribute, worked more consistently.

A few things to note I have remove the default indentation, it will always be 0. I've also had exposed any min or max, the min is 0 and the max is whatever the hell the user wants. Feel free to add those into your own solution.

Code
import { Extension } from "@tiptap/core";

type IndentOptions = {
  /**
   * @default ["paragraph", "heading"]
   */
  types: string[];
  /**
   * Amount of margin to increase and decrease the indent
   *
   * @default 40
   */
  margin: number;
};

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    indent: {
      indent: () => ReturnType;
      outdent: () => ReturnType;
    };
  }
}

export default Extension.create<IndentOptions>({
  name: "indent",

  defaultOptions: {
    types: ["paragraph", "heading"],
    margin: 40
  },

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

  addCommands() {
    return {
      indent:
        () =>
        ({ editor, chain, commands }) => {
          // Check for a list
          if (
            editor.isActive("listItem") ||
            editor.isActive("bulletList") ||
            editor.isActive("orderedList")
          ) {
            return chain().sinkListItem("listItem").run();
          }

          return this.options.types
            .map((type) => {
              const attrs = editor.getAttributes(type).indent;
              const indent = (attrs || 0) + 1;
              return commands.updateAttributes(type, { indent });
            })
            .every(Boolean);
        },
      outdent:
        () =>
        ({ editor, chain, commands }) => {
          // Check for a list
          if (
            editor.isActive("listItem") ||
            editor.isActive("bulletList") ||
            editor.isActive("orderedList")
          ) {
            return chain().liftListItem("listItem").run();
          }

          const result = this.options.types
            .filter((type) => {
              const attrs = editor.getAttributes(type).indent;
              return attrs > 0;
            })
            .map((type) => {
              const attrs = editor.getAttributes(type).indent;
              const indent = (attrs || 0) - 1;
              return commands.updateAttributes(type, { indent });
            });
          return result.every(Boolean) && result.length > 0;
        }
    };
  },

  addKeyboardShortcuts() {
    return {
      Tab: ({ editor }) => {
        return editor.commands.indent();
      },
      "Shift-Tab": ({ editor }) => {
        return editor.commands.outdent();
      },
      Backspace: ({ editor }) => {
        const { selection } = editor.state;

        // Make sure we are at the start of the node
        if (selection.$anchor.parentOffset > 0 || selection.from !== selection.to) {
          return false;
        }

        return editor.commands.outdent();
      }
    };
  }
});

EDIT: I reduced the tab width on the code block cause it looks weird as heck on here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests