Skip to content

Commit 5eb1a74

Browse files
authored
refactor(block-editor): Make indent/outdent functionality works with paragraph / lists (dotCMS#32235)
This pull request introduces an `IndentExtension` to the block editor, enhancing text formatting capabilities by adding support for indenting and outdenting paragraphs, headings, and blockquotes. It also includes minor code cleanups and reorganization of imports. Below are the key changes: ### New Feature: Indentation Support * Added a new `IndentExtension` to handle indentation for paragraphs, headings, and blockquotes, including commands (`indent`, `outdent`) and keyboard shortcuts (e.g., `Tab`, `Shift-Tab`, `Backspace`). It ensures lists are handled separately using native commands. (`core-web/libs/block-editor/src/lib/extensions/indent/indent.extension.ts`) * Updated the `DotBlockEditorComponent` to include the `IndentExtension` in its list of editor extensions. (`core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts`) * Modified the `DotBubbleMenuPlugin` to incorporate the `IndentExtension` for non-list nodes while maintaining native commands for list items. (`core-web/libs/block-editor/src/lib/extensions/bubble-menu/plugins/dot-bubble-menu.plugin.ts`) ### Code Cleanup and Reorganization * Consolidated and alphabetized import statements in multiple files for better readability. (`core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts`, `core-web/libs/block-editor/src/lib/extensions/bubble-menu/plugins/dot-bubble-menu.plugin.ts`) [[1]](diffhunk://#diff-266eab162f8661e695c3e40956692fa3696fbd4e8cd3e0352ea9e01f90b13609L36-R36) [[2]](diffhunk://#diff-c8d4bb53e36d994727c35eba8571ce71e300c27447a472e866cc6c01be20b101L31-R31) * Updated the `index.ts` file to export the new `IndentExtension` and removed redundant exports for AI-related extensions. (`core-web/libs/block-editor/src/lib/extensions/index.ts`) [[1]](diffhunk://#diff-7c6595ce89739ae8d986dc0ee805e6cb6d00b0abcde4baec0479fa09e65e3823R2-R6) [[2]](diffhunk://#diff-7c6595ce89739ae8d986dc0ee805e6cb6d00b0abcde4baec0479fa09e65e3823L30-R35) ### Proposed Changes - Updated import order in for consistency. - Added to the block editor component. - Enhanced to handle indentation and outdentation for list items and other block types using the new . - Cleaned up unused imports in ### Checklist - [ ] Tests - [ ] Translations - [ ] Security Implications Contemplated (add notes if applicable) ### Additional Info ** any additional useful context or info ** ### Screenshots https://github.com/user-attachments/assets/2b53a1ba-9882-4797-8535-018f0e3741ad
1 parent d6dde0f commit 5eb1a74

File tree

4 files changed

+255
-21
lines changed

4 files changed

+255
-21
lines changed

core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { Underline } from '@tiptap/extension-underline';
3333
import { Youtube } from '@tiptap/extension-youtube';
3434
import StarterKit, { StarterKitOptions } from '@tiptap/starter-kit';
3535

36-
import { DotPropertiesService, DotAiService, DotMessageService } from '@dotcms/data-access';
36+
import { DotAiService, DotMessageService, DotPropertiesService } from '@dotcms/data-access';
3737
import {
3838
DotCMSContentlet,
3939
DotCMSContentTypeField,
@@ -60,7 +60,8 @@ import {
6060
DotTableHeaderExtension,
6161
DragHandler,
6262
FREEZE_SCROLL_KEY,
63-
FreezeScroll
63+
FreezeScroll,
64+
IndentExtension
6465
} from '../../extensions';
6566
import { DotPlaceholder } from '../../extensions/dot-placeholder/dot-placeholder-plugin';
6667
import { AIContentNode, ContentletBlock, ImageNode, LoaderNode, VideoNode } from '../../nodes';
@@ -457,7 +458,8 @@ export class DotBlockEditorComponent implements OnInit, OnDestroy, ControlValueA
457458
TableRow,
458459
FreezeScroll,
459460
CharacterCount,
460-
AssetUploader(this.#injector, this.viewContainerRef)
461+
AssetUploader(this.#injector, this.viewContainerRef),
462+
IndentExtension
461463
];
462464

463465
if (isAIPluginInstalled) {

core-web/libs/block-editor/src/lib/extensions/bubble-menu/plugins/dot-bubble-menu.plugin.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,7 @@ import {
2828
DotBubbleMenuPluginProps,
2929
DotBubbleMenuViewProps
3030
} from '../models';
31-
import {
32-
getBubbleMenuItem,
33-
getNodeCoords,
34-
isListNode,
35-
popperModifiers,
36-
setBubbleMenuCoords
37-
} from '../utils';
31+
import { getBubbleMenuItem, getNodeCoords, popperModifiers, setBubbleMenuCoords } from '../utils';
3832

3933
export const DotBubbleMenuPlugin = (options: DotBubbleMenuPluginProps) => {
4034
const component = options.component.instance;
@@ -379,17 +373,32 @@ export class DotBubbleMenuPluginView extends BubbleMenuView {
379373
break;
380374

381375
case 'indent':
382-
if (isListNode(this.editor)) {
383-
this.editor.commands.sinkListItem('listItem');
376+
// Handle lists with native commands directly
377+
if (this.editor.isActive('listItem')) {
378+
// Try sinkListItem first, if it fails, manually indent with wrapIn
379+
if (this.editor.can().sinkListItem('listItem')) {
380+
this.editor.commands.sinkListItem('listItem');
381+
} else {
382+
// Alternative: wrap in a new list of the same type
383+
const currentList = this.editor.isActive('bulletList')
384+
? 'bulletList'
385+
: 'orderedList';
386+
this.editor.chain().wrapIn(currentList).focus().run();
387+
}
388+
} else {
389+
// Use IndentExtension for paragraphs, headings, blockquotes
390+
this.editor.commands.indent();
384391
}
385-
386392
break;
387393

388394
case 'outdent':
389-
if (isListNode(this.editor)) {
395+
// Handle lists with native commands directly
396+
if (this.editor.isActive('listItem')) {
390397
this.editor.commands.liftListItem('listItem');
398+
} else {
399+
// Use IndentExtension for paragraphs, headings, blockquotes
400+
this.editor.commands.outdent();
391401
}
392-
393402
break;
394403

395404
case 'link':
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { Extension } from '@tiptap/core';
2+
import { AllSelection, TextSelection, Transaction } from 'prosemirror-state';
3+
4+
declare module '@tiptap/core' {
5+
interface Commands<ReturnType> {
6+
indent: {
7+
/**
8+
* Indent content
9+
*/
10+
indent: () => ReturnType;
11+
/**
12+
* Outdent content
13+
*/
14+
outdent: () => ReturnType;
15+
};
16+
}
17+
}
18+
19+
export interface IndentOptions {
20+
/**
21+
* The types of nodes that should be indented
22+
*/
23+
types: string[];
24+
25+
/**
26+
* The minimum indentation level
27+
*/
28+
minIndentLevel: number;
29+
30+
/**
31+
* The maximum indentation level
32+
*/
33+
maxIndentLevel: number;
34+
35+
/**
36+
* The indent size
37+
*/
38+
indentSize: number;
39+
}
40+
41+
function clamp(val: number, min: number, max: number): number {
42+
if (val < min) {
43+
return min;
44+
}
45+
if (val > max) {
46+
return max;
47+
}
48+
return val;
49+
}
50+
51+
const INDENT_MIN = 0;
52+
const INDENT_MAX = 400; // Maximum limit of 400px
53+
const INDENT_MORE = 40; // Increment of 40px
54+
const INDENT_LESS = -40;
55+
56+
function setNodeIndentMarkup(tr: Transaction, pos: number, delta: number): Transaction {
57+
if (!tr.doc) return tr;
58+
59+
const node = tr.doc.nodeAt(pos);
60+
if (!node) return tr;
61+
62+
const minIndent = INDENT_MIN;
63+
const maxIndent = INDENT_MAX;
64+
const indent = clamp((node.attrs.indent || 0) + delta, minIndent, maxIndent);
65+
66+
if (indent === node.attrs.indent) return tr;
67+
68+
const nodeAttrs = {
69+
...node.attrs,
70+
indent
71+
};
72+
73+
return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks);
74+
}
75+
76+
function updateIndentLevel(tr: Transaction, delta: number): Transaction {
77+
const { doc, selection } = tr;
78+
79+
if (!doc || !selection) return tr;
80+
81+
if (!(selection instanceof TextSelection || selection instanceof AllSelection)) {
82+
return tr;
83+
}
84+
85+
const { from, to } = selection;
86+
87+
doc.nodesBetween(from, to, (node, pos) => {
88+
const nodeType = node.type;
89+
90+
// Only handle paragraphs, headings and blockquotes - NO lists
91+
if (
92+
nodeType.name === 'paragraph' ||
93+
nodeType.name === 'heading' ||
94+
nodeType.name === 'blockquote'
95+
) {
96+
tr = setNodeIndentMarkup(tr, pos, delta);
97+
return false;
98+
}
99+
100+
return true;
101+
});
102+
103+
return tr;
104+
}
105+
106+
export const IndentExtension = Extension.create<IndentOptions>({
107+
name: 'indent',
108+
109+
addOptions() {
110+
return {
111+
types: ['heading', 'paragraph', 'blockquote'],
112+
minIndentLevel: 0,
113+
maxIndentLevel: 10,
114+
indentSize: 40
115+
};
116+
},
117+
118+
addGlobalAttributes() {
119+
return [
120+
{
121+
types: this.options.types,
122+
attributes: {
123+
indent: {
124+
default: 0,
125+
renderHTML: (attributes) => {
126+
if (!attributes.indent || attributes.indent <= 0) {
127+
return {};
128+
}
129+
return {
130+
style: `margin-left: ${attributes.indent}px;`
131+
};
132+
},
133+
parseHTML: (element) => {
134+
const marginLeft = element.style.marginLeft;
135+
if (!marginLeft) return 0;
136+
137+
const value = parseInt(marginLeft, 10);
138+
return value
139+
? Math.round(value / this.options.indentSize) *
140+
this.options.indentSize
141+
: 0;
142+
}
143+
}
144+
}
145+
}
146+
];
147+
},
148+
149+
addCommands() {
150+
return {
151+
indent:
152+
() =>
153+
({ state, dispatch }) => {
154+
// Only for paragraphs, headings, blockquotes - NO lists
155+
const { selection } = state;
156+
const newTr = state.tr.setSelection(selection);
157+
const updatedTr = updateIndentLevel(newTr, INDENT_MORE);
158+
159+
if (updatedTr.docChanged && dispatch) {
160+
dispatch(updatedTr);
161+
return true;
162+
}
163+
164+
return false;
165+
},
166+
167+
outdent:
168+
() =>
169+
({ state, dispatch }) => {
170+
// Only for paragraphs, headings, blockquotes - NO lists
171+
const { selection } = state;
172+
const newTr = state.tr.setSelection(selection);
173+
const updatedTr = updateIndentLevel(newTr, INDENT_LESS);
174+
175+
if (updatedTr.docChanged && dispatch) {
176+
dispatch(updatedTr);
177+
return true;
178+
}
179+
180+
return false;
181+
}
182+
};
183+
},
184+
185+
addKeyboardShortcuts() {
186+
return {
187+
Tab: () => {
188+
// Don't handle Tab if we're in a list
189+
if (this.editor.isActive('bulletList') || this.editor.isActive('orderedList')) {
190+
return false;
191+
}
192+
return this.editor.commands.indent();
193+
},
194+
'Shift-Tab': () => {
195+
// Don't handle Shift-Tab if we're in a list
196+
if (this.editor.isActive('bulletList') || this.editor.isActive('orderedList')) {
197+
return false;
198+
}
199+
return this.editor.commands.outdent();
200+
},
201+
Backspace: () => {
202+
const { state } = this.editor;
203+
const { from, to } = state.selection;
204+
205+
// Only outdent if we're at the beginning of the node without selection
206+
const hasTextBeforeCursor =
207+
from > 1 && state.doc.textBetween(from - 1, from).trim().length > 0;
208+
const hasSelection = from !== to;
209+
210+
if (hasTextBeforeCursor || hasSelection) {
211+
return false;
212+
}
213+
214+
// Don't handle if we're in a list
215+
if (this.editor.isActive('bulletList') || this.editor.isActive('orderedList')) {
216+
return false;
217+
}
218+
219+
return this.editor.commands.outdent();
220+
}
221+
};
222+
}
223+
});

core-web/libs/block-editor/src/lib/extensions/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
export * from './action-button/actions-menu.extension';
2+
export * from './ai-content-prompt/ai-content-prompt.component';
3+
export * from './ai-content-prompt/ai-content-prompt.extension';
4+
export * from './ai-content-prompt/plugins/ai-content-prompt.plugin';
5+
export * from './ai-image-prompt/ai-image-prompt.extension';
6+
export * from './ai-image-prompt/ai-image-prompt.plugin';
27
export * from './asset-form/asset-form.component';
38
export * from './asset-form/asset-form.extension';
49
export * from './asset-form/plugins/bubble-asset-form.plugin';
@@ -27,9 +32,4 @@ export * from './floating-button/floating-button.component';
2732
export * from './floating-button/floating-button.extension';
2833
export * from './floating-button/plugin/floating-button.plugin';
2934
export * from './freeze-scroll/freeze-scroll.extension';
30-
export * from './ai-content-prompt/ai-content-prompt.component';
31-
export * from './ai-content-prompt/ai-content-prompt.extension';
32-
export * from './ai-content-prompt/plugins/ai-content-prompt.plugin';
33-
export * from './ai-content-prompt/ai-content-prompt.component';
34-
export * from './ai-image-prompt/ai-image-prompt.extension';
35-
export * from './ai-image-prompt/ai-image-prompt.plugin';
35+
export * from './indent/indent.extension';

0 commit comments

Comments
 (0)