From e5dcc8b523d7596e168f919fd53d9119f7c40ac7 Mon Sep 17 00:00:00 2001 From: Ricardo Amaral Date: Wed, 17 Jan 2024 19:51:43 +0000 Subject: [PATCH] feat(commands): Add `smartToggleBulletList` and `smartToggleOrderedList` (#612) --- package-lock.json | 13 +++ package.json | 1 + .../rich-text/rich-text-bullet-list.ts | 87 +++++++++++++++++++ src/extensions/rich-text/rich-text-kit.ts | 19 ++-- .../rich-text/rich-text-ordered-list.ts | 87 +++++++++++++++++++ .../typist-editor-toolbar.tsx | 4 +- 6 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 src/extensions/rich-text/rich-text-bullet-list.ts create mode 100644 src/extensions/rich-text/rich-text-ordered-list.ts diff --git a/package-lock.json b/package-lock.json index e9526f53..58e36d27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@tiptap/extension-task-item": "2.1.13", "@tiptap/extension-task-list": "2.1.13", "@tiptap/extension-text": "2.1.13", + "@tiptap/extension-text-style": "2.1.13", "@tiptap/extension-typography": "2.1.13", "@tiptap/pm": "2.1.13", "@tiptap/react": "2.1.13", @@ -7075,6 +7076,18 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.1.13.tgz", + "integrity": "sha512-K9/pNHxpZKQoc++crxrsppVUSeHv8YevfY2FkJ4YMaekGcX+q4BRrHR0tOfii4izAUPJF2L0/PexLQaWXtAY1w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, "node_modules/@tiptap/extension-typography": { "version": "2.1.13", "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-2.1.13.tgz", diff --git a/package.json b/package.json index 45fc43e8..0b82ecbd 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@tiptap/extension-task-item": "2.1.13", "@tiptap/extension-task-list": "2.1.13", "@tiptap/extension-text": "2.1.13", + "@tiptap/extension-text-style": "2.1.13", "@tiptap/extension-typography": "2.1.13", "@tiptap/pm": "2.1.13", "@tiptap/react": "2.1.13", diff --git a/src/extensions/rich-text/rich-text-bullet-list.ts b/src/extensions/rich-text/rich-text-bullet-list.ts new file mode 100644 index 00000000..67ec3e85 --- /dev/null +++ b/src/extensions/rich-text/rich-text-bullet-list.ts @@ -0,0 +1,87 @@ +import { BulletList } from '@tiptap/extension-bullet-list' +import { ListItem } from '@tiptap/extension-list-item' +import { TextStyle } from '@tiptap/extension-text-style' +import { Fragment, Slice } from '@tiptap/pm/model' + +import type { BulletListOptions } from '@tiptap/extension-bullet-list' + +/** + * Augment the official `@tiptap/core` module with extra commands, relevant for this extension, so + * that the compiler knows about them. + */ +declare module '@tiptap/core' { + interface Commands { + richTextBulletList: { + /** + * Smartly toggles the selection into a bullet list, converting any hard breaks into + * paragraphs before doing so. + * + * @see https://discuss.prosemirror.net/t/how-to-convert-a-selection-of-text-lines-into-paragraphs/6099 + */ + smartToggleBulletList: () => ReturnType + } + } +} + +/** + * Custom extension that extends the built-in `BulletList` extension to add a smart toggle command + * with support for hard breaks, which are automatically converted into paragraphs before toggling + * the selection into a bullet list. + */ +const RichTextBulletList = BulletList.extend({ + addCommands() { + const { editor, name, options } = this + + return { + smartToggleBulletList() { + return ({ commands, state, tr, chain }) => { + const { schema } = state + const { selection } = tr + const { $from, $to } = selection + + const hardBreakPositions: number[] = [] + + // Find and store the positions of all hard breaks in the selection + tr.doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (node.type.name === 'hardBreak') { + hardBreakPositions.push(pos) + } + }) + + // Replace each hard break with a slice that closes and re-opens a paragraph, + // effectively inserting a "paragraph break" in place of a "hard break" + // (this is performed in reverse order to compensate for content shifting that + // occurs with each replacement, ensuring accurate insertion points) + hardBreakPositions.reverse().forEach((pos) => { + tr.replace( + pos, + pos + 1, + Slice.maxOpen( + Fragment.fromArray([ + schema.nodes.paragraph.create(), + schema.nodes.paragraph.create(), + ]), + ), + ) + }) + + // Toggle the selection into a bullet list, optionally keeping attributes + // (this is a verbatim copy of the built-in`toggleBulletList` command) + + if (options.keepAttributes) { + return chain() + .toggleList(name, options.itemTypeName, options.keepMarks) + .updateAttributes(ListItem.name, editor.getAttributes(TextStyle.name)) + .run() + } + + return commands.toggleList(name, options.itemTypeName, options.keepMarks) + } + }, + } + }, +}) + +export { RichTextBulletList } + +export type { BulletListOptions as RichTextBulletListOptions } diff --git a/src/extensions/rich-text/rich-text-kit.ts b/src/extensions/rich-text/rich-text-kit.ts index 7ebd1e13..80f6df45 100644 --- a/src/extensions/rich-text/rich-text-kit.ts +++ b/src/extensions/rich-text/rich-text-kit.ts @@ -1,7 +1,6 @@ import { Extension } from '@tiptap/core' import { Blockquote } from '@tiptap/extension-blockquote' import { Bold } from '@tiptap/extension-bold' -import { BulletList } from '@tiptap/extension-bullet-list' import { CodeBlock } from '@tiptap/extension-code-block' import { Dropcursor } from '@tiptap/extension-dropcursor' import { Gapcursor } from '@tiptap/extension-gapcursor' @@ -12,7 +11,6 @@ import { HorizontalRule } from '@tiptap/extension-horizontal-rule' import { Italic } from '@tiptap/extension-italic' import { ListItem } from '@tiptap/extension-list-item' import { ListKeymap } from '@tiptap/extension-list-keymap' -import { OrderedList } from '@tiptap/extension-ordered-list' import { Paragraph } from '@tiptap/extension-paragraph' import { Text } from '@tiptap/extension-text' import { Typography } from '@tiptap/extension-typography' @@ -26,16 +24,17 @@ import { BoldAndItalics } from './bold-and-italics' import { CurvenoteCodemark } from './curvenote-codemark' import { PasteEmojis } from './paste-emojis' import { PasteMarkdown } from './paste-markdown' +import { RichTextBulletList } from './rich-text-bullet-list' import { RichTextCode } from './rich-text-code' import { RichTextDocument } from './rich-text-document' import { RichTextImage } from './rich-text-image' import { RichTextLink } from './rich-text-link' -import { RichTextStrikethrough, RichTextStrikethroughOptions } from './rich-text-strikethrough' +import { RichTextOrderedList } from './rich-text-ordered-list' +import { RichTextStrikethrough } from './rich-text-strikethrough' import type { Extensions } from '@tiptap/core' import type { BlockquoteOptions } from '@tiptap/extension-blockquote' import type { BoldOptions } from '@tiptap/extension-bold' -import type { BulletListOptions } from '@tiptap/extension-bullet-list' import type { CodeOptions } from '@tiptap/extension-code' import type { CodeBlockOptions } from '@tiptap/extension-code-block' import type { DropcursorOptions } from '@tiptap/extension-dropcursor' @@ -46,11 +45,13 @@ import type { HorizontalRuleOptions } from '@tiptap/extension-horizontal-rule' import type { ItalicOptions } from '@tiptap/extension-italic' import type { ListItemOptions } from '@tiptap/extension-list-item' import type { ListKeymapOptions } from '@tiptap/extension-list-keymap' -import type { OrderedListOptions } from '@tiptap/extension-ordered-list' import type { ParagraphOptions } from '@tiptap/extension-paragraph' +import type { RichTextBulletListOptions } from './rich-text-bullet-list' import type { RichTextDocumentOptions } from './rich-text-document' import type { RichTextImageOptions } from './rich-text-image' import type { RichTextLinkOptions } from './rich-text-link' +import type { RichTextOrderedListOptions } from './rich-text-ordered-list' +import type { RichTextStrikethroughOptions } from './rich-text-strikethrough' /** * The options available to customize the `RichTextKit` extension. @@ -69,7 +70,7 @@ type RichTextKitOptions = { /** * Set options for the `BulletList` extension, or `false` to disable. */ - bulletList: Partial | false + bulletList: Partial | false /** * Set options for the `Code` extension, or `false` to disable. @@ -144,7 +145,7 @@ type RichTextKitOptions = { /** * Set options for the `OrderedList` extension, or `false` to disable. */ - orderedList: Partial | false + orderedList: Partial | false /** * Set options for the `Paragraph` extension, or `false` to disable. @@ -210,7 +211,7 @@ const RichTextKit = Extension.create({ } if (this.options.bulletList !== false) { - extensions.push(BulletList.configure(this.options?.bulletList)) + extensions.push(RichTextBulletList.configure(this.options?.bulletList)) } if (this.options.code !== false) { @@ -310,7 +311,7 @@ const RichTextKit = Extension.create({ } if (this.options.orderedList !== false) { - extensions.push(OrderedList.configure(this.options?.orderedList)) + extensions.push(RichTextOrderedList.configure(this.options?.orderedList)) } if (this.options.paragraph !== false) { diff --git a/src/extensions/rich-text/rich-text-ordered-list.ts b/src/extensions/rich-text/rich-text-ordered-list.ts new file mode 100644 index 00000000..368f853a --- /dev/null +++ b/src/extensions/rich-text/rich-text-ordered-list.ts @@ -0,0 +1,87 @@ +import { ListItem } from '@tiptap/extension-list-item' +import { OrderedList } from '@tiptap/extension-ordered-list' +import { TextStyle } from '@tiptap/extension-text-style' +import { Fragment, Slice } from '@tiptap/pm/model' + +import type { OrderedListOptions } from '@tiptap/extension-ordered-list' + +/** + * Augment the official `@tiptap/core` module with extra commands, relevant for this extension, so + * that the compiler knows about them. + */ +declare module '@tiptap/core' { + interface Commands { + richTextOrderedList: { + /** + * Smartly toggles the selection into an oredered list, converting any hard breaks into + * paragraphs before doing so. + * + * @see https://discuss.prosemirror.net/t/how-to-convert-a-selection-of-text-lines-into-paragraphs/6099 + */ + smartToggleOrderedList: () => ReturnType + } + } +} + +/** + * Custom extension that extends the built-in `OrderedList` extension to add a smart toggle command + * with support for hard breaks, which are automatically converted into paragraphs before toggling + * the selection into an ordered list. + */ +const RichTextOrderedList = OrderedList.extend({ + addCommands() { + const { editor, name, options } = this + + return { + smartToggleOrderedList() { + return ({ commands, state, tr, chain }) => { + const { schema } = state + const { selection } = tr + const { $from, $to } = selection + + const hardBreakPositions: number[] = [] + + // Find and store the positions of all hard breaks in the selection + tr.doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (node.type.name === 'hardBreak') { + hardBreakPositions.push(pos) + } + }) + + // Replace each hard break with a slice that closes and re-opens a paragraph, + // effectively inserting a "paragraph break" in place of a "hard break" + // (this is performed in reverse order to compensate for content shifting that + // occurs with each replacement, ensuring accurate insertion points) + hardBreakPositions.reverse().forEach((pos) => { + tr.replace( + pos, + pos + 1, + Slice.maxOpen( + Fragment.fromArray([ + schema.nodes.paragraph.create(), + schema.nodes.paragraph.create(), + ]), + ), + ) + }) + + // Toggle the selection into a bullet list, optionally keeping attributes + // (this is a verbatim copy of the built-in`toggleBulletList` command) + + if (options.keepAttributes) { + return chain() + .toggleList(name, options.itemTypeName, options.keepMarks) + .updateAttributes(ListItem.name, editor.getAttributes(TextStyle.name)) + .run() + } + + return commands.toggleList(name, options.itemTypeName, options.keepMarks) + } + }, + } + }, +}) + +export { RichTextOrderedList } + +export type { OrderedListOptions as RichTextOrderedListOptions } diff --git a/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-toolbar.tsx b/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-toolbar.tsx index 297c9b88..8d200f2a 100644 --- a/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-toolbar.tsx +++ b/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-toolbar.tsx @@ -187,7 +187,7 @@ function TypistEditorToolbar({ editor }: TypistEditorToolbarProps) { disabled={false} icon={} variant="quaternary" - onClick={() => editor.chain().focus().toggleBulletList().run()} + onClick={() => editor.chain().focus().smartToggleBulletList().run()} />