-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
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
Comments
Thanks for sharing! 🙌 It’s not documented, but there is an I’ll add this to the list of Community extensions and close this here for now. But feel free to comment. |
Cool, thanks! |
Excellent work!! |
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 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() }
}
}, |
@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')
}
}
}, |
Amazing extension!
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),
}
}, |
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! |
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. |
Does anyone have vanilla js version of this?! |
@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 |
@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
|
Hi, I appreciate your great work!
// 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.
// example
<button type="button"
onClick={() => getIndent()({ editor })}
>
Indent
</button>
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()
} |
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 Any thoughts? |
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: 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. |
Add another margin-left based indent extension, online demo here https://wode.vercel.app/tiptap |
Indention only works when the indent/outdent command runs. In addition, toggling heading replaces original paragraph, since indent level resets to 0. 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,
}),
})
})
},
}) |
How about adding the code below to indentExtension itself? 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 })
}
}
}, |
but somehow it can not apply to |
Most up to date answer I've found so far on this issue: |
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) |
does the online demo works, because I don't see....its all black |
Mention TypeScript code as well |
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. 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. Codeimport { 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 |
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 theTextAlign
extension of Tiptap 2 with inspiration from https://github.com/Leecason/element-tiptapDescribe the solution you’d like
(this is my try at indent extension for tiptap 2 )
The text was updated successfully, but these errors were encountered: