@sovann72-dev/lynqify-ui 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/components/NoteEditor/BatchImageGalleryNodeView.d.ts +19 -0
  2. package/dist/components/RichTextEditor/Extension/Indent/backspace.indent.handlers.d.ts +18 -0
  3. package/dist/components/RichTextEditor/Extension/Indent/indent.extension.d.ts +26 -0
  4. package/dist/components/RichTextEditor/Extension/Indent/indent.handlers.d.ts +16 -0
  5. package/{src/components/RichTextEditor/Extension/Indent/indent.types.ts → dist/components/RichTextEditor/Extension/Indent/indent.types.d.ts} +10 -35
  6. package/dist/components/RichTextEditor/Extension/Indent/indent.utils.d.ts +8 -0
  7. package/dist/components/RichTextEditor/Extension/Indent/outdent.handlers.d.ts +6 -0
  8. package/dist/components/RichTextEditor/Extension/Indent/shifttab.indent.handlers.d.ts +18 -0
  9. package/dist/components/RichTextEditor/Extension/Indent/tab.indent.handlers.d.ts +21 -0
  10. package/dist/components/RichTextEditor/Extension/List/custom-list-item.extension.d.ts +17 -0
  11. package/dist/components/RichTextEditor/Extension/List/dynamic-bullet-styling.extension.d.ts +2 -0
  12. package/dist/components/RichTextEditor/Extension/batch-segment-images.extension.d.ts +19 -0
  13. package/dist/components/RichTextEditor/Extension/batch-segment-images.types.d.ts +33 -0
  14. package/dist/components/RichTextEditor/Extension/custom-image.extension.d.ts +4 -0
  15. package/dist/components/RichTextEditor/Extension/custom-link.extension.d.ts +3 -0
  16. package/dist/components/RichTextEditor/Extension/custom-mention.extension.d.ts +3 -0
  17. package/dist/components/RichTextEditor/Extension/custom-paragraph.extension.d.ts +6 -0
  18. package/dist/components/RichTextEditor/Extension/extensions.d.ts +4 -0
  19. package/dist/components/RichTextEditor/Extension/file-filtering.extension.d.ts +1 -0
  20. package/dist/components/RichTextEditor/Extension/list-indent-integration.extension.d.ts +2 -0
  21. package/dist/components/RichTextEditor/Extension/mentionstorage.extension.d.ts +9 -0
  22. package/dist/components/RichTextEditor/Extension/tiptap-extension-fontsize.d.ts +21 -0
  23. package/dist/components/RichTextEditor/Extension/tiptap-extension-lineheight.d.ts +21 -0
  24. package/{src/index.ts → dist/index.d.ts} +5 -17
  25. package/dist/lynqify-ui.js +3264 -0
  26. package/dist/lynqify-ui.umd.cjs +30 -0
  27. package/package.json +60 -31
  28. package/src/components/RichTextEditor/Extension/Indent/backspace.indent.handlers.ts +0 -77
  29. package/src/components/RichTextEditor/Extension/Indent/indent.extension.ts +0 -285
  30. package/src/components/RichTextEditor/Extension/Indent/indent.handlers.ts +0 -121
  31. package/src/components/RichTextEditor/Extension/Indent/indent.utils.ts +0 -8
  32. package/src/components/RichTextEditor/Extension/Indent/outdent.handlers.ts +0 -71
  33. package/src/components/RichTextEditor/Extension/Indent/shifttab.indent.handlers.ts +0 -133
  34. package/src/components/RichTextEditor/Extension/Indent/tab.indent.handlers.ts +0 -103
  35. package/src/components/RichTextEditor/Extension/List/custom-list-item.extension.ts +0 -107
  36. package/src/components/RichTextEditor/Extension/List/dynamic-bullet-styling.extension.ts +0 -40
  37. package/src/components/RichTextEditor/Extension/batch-segment-images.extension.ts +0 -486
  38. package/src/components/RichTextEditor/Extension/batch-segment-images.types.ts +0 -35
  39. package/src/components/RichTextEditor/Extension/custom-image.extension.ts +0 -18
  40. package/src/components/RichTextEditor/Extension/custom-link.extension.ts +0 -58
  41. package/src/components/RichTextEditor/Extension/custom-mention.extension.ts +0 -29
  42. package/src/components/RichTextEditor/Extension/custom-paragraph.extension.ts +0 -46
  43. package/src/components/RichTextEditor/Extension/extensions.ts +0 -118
  44. package/src/components/RichTextEditor/Extension/file-filtering.extension.ts +0 -0
  45. package/src/components/RichTextEditor/Extension/list-indent-integration.extension.ts +0 -125
  46. package/src/components/RichTextEditor/Extension/mentionstorage.extension.ts +0 -10
  47. package/src/components/RichTextEditor/Extension/tiptap-extension-fontsize.ts +0 -73
  48. package/src/components/RichTextEditor/Extension/tiptap-extension-lineheight.ts +0 -73
@@ -1,285 +0,0 @@
1
- import { Plugin, Transaction } from '@tiptap/pm/state';
2
- import { CommandProps, Extension } from '@tiptap/react';
3
-
4
- import { handleOutdentMultiNodeSelection } from 'src/components/RichTextEditor/Extension/Indent/outdent.handlers';
5
- import {
6
- handleBackspaceOutdentAtLineStart,
7
- handleBackspaceDeleteEmptyIndentedLine,
8
- handleBackspaceDeleteEmptyParagraphAfterList,
9
- } from './backspace.indent.handlers';
10
- import {
11
- handleIndentMultiNodeSelection,
12
- handleIndentList,
13
- handleIndentTextNode,
14
- } from './indent.handlers';
15
- import {
16
- ShortcutContext,
17
- isListParent,
18
- isTextNode,
19
- runChain,
20
- runIndentChain,
21
- } from './indent.types';
22
- import {
23
- handleShiftTabMultiNodeSelection,
24
- handleShiftTabFromEmptyParagraphAfterList,
25
- handleShiftTabInTopLevelListItem,
26
- } from './shifttab.indent.handlers';
27
- import {
28
- handleTabMultiNodeSelection,
29
- handleTabSingleNodeTextSelection,
30
- handleTabInsideListItem,
31
- handleTabMidTextCursor,
32
- } from './tab.indent.handlers';
33
-
34
- type IndentOptions = {
35
- types: string[];
36
- };
37
-
38
- declare module '@tiptap/core' {
39
- interface Commands<ReturnType> {
40
- indent: {
41
- indent: () => ReturnType;
42
- outdent: () => ReturnType;
43
- outdentShiftTab: () => ReturnType;
44
- increaseIndent: () => ReturnType;
45
- decreaseIndent: () => ReturnType;
46
- _handleEnterKeyDown: () => ReturnType;
47
- _handleSpaceKeyDown: () => ReturnType;
48
- };
49
- }
50
- }
51
-
52
- /**
53
- * Builds a ShortcutContext from the current editor state.
54
- * Resolved once per keypress so every handler in the chain sees the same snapshot.
55
- */
56
- function buildContext(editor: any): ShortcutContext {
57
- const { state, view } = editor;
58
- const { $from, $to, empty: selectionIsEmpty } = state.selection;
59
- return { editor, state, view, $from, $to, selectionIsEmpty, currentNode: $from.node() };
60
- }
61
-
62
- /**
63
- * Adds Google-Doc-style indentation to paragraphs, headings, and lists.
64
- *
65
- * Indentation is stored as a `data-indent` attribute and rendered as
66
- * `padding-left` on the node. Structural list nesting (sinkListItem / liftListItem)
67
- * is handled separately by the List extension and is not affected by this attribute.
68
- */
69
- export const IndentExtension = Extension.create<IndentOptions>({
70
- name: 'indent',
71
-
72
- addKeyboardShortcuts() {
73
- return {
74
- Enter: () => this.editor.commands._handleEnterKeyDown(),
75
-
76
- Tab: () => {
77
- const context = buildContext(this.editor);
78
- const handled = runChain(
79
- [
80
- handleTabMultiNodeSelection,
81
- handleTabSingleNodeTextSelection,
82
- handleTabInsideListItem,
83
- handleTabMidTextCursor,
84
- ],
85
- context,
86
- );
87
- return handled || this.editor.commands.indent();
88
- },
89
-
90
- Backspace: () => {
91
- const context = buildContext(this.editor);
92
- return runChain(
93
- [
94
- handleBackspaceOutdentAtLineStart,
95
- handleBackspaceDeleteEmptyIndentedLine,
96
- handleBackspaceDeleteEmptyParagraphAfterList,
97
- ],
98
- context,
99
- );
100
- },
101
-
102
- 'Shift-Tab': () => {
103
- const context = buildContext(this.editor);
104
- const handled = runChain(
105
- [
106
- handleShiftTabMultiNodeSelection,
107
- handleShiftTabFromEmptyParagraphAfterList,
108
- handleShiftTabInTopLevelListItem,
109
- ],
110
- context,
111
- );
112
- return handled || this.editor.chain().outdentShiftTab().run();
113
- },
114
-
115
- Space: () => this.editor.commands._handleSpaceKeyDown(),
116
- };
117
- },
118
-
119
- addProseMirrorPlugins() {
120
- return [
121
- new Plugin({
122
- // WHY: When a paragraph/heading inside a listItem gets an indent attribute (e.g. from a
123
- // paste or undo), the indent belongs on the parent list node, not the text node inside it.
124
- // This plugin corrects that automatically on every document change.
125
- appendTransaction: (transactions, _oldState, newState) => {
126
- const hasDocChanged = transactions.some((t) => t.docChanged);
127
- if (!hasDocChanged) return null;
128
-
129
- let tr: Transaction | null = null;
130
-
131
- newState.doc.descendants((node, pos) => {
132
- const isIndentedTextInsideDocument =
133
- isTextNode(node.type.name) && (node?.attrs?.indent ?? 0) > 0;
134
- if (!isIndentedTextInsideDocument) return;
135
-
136
- const resolvedPos = newState.doc.resolve(pos);
137
- const isDirectChildOfListItem = resolvedPos.parent.type.name === 'listItem';
138
- if (!isDirectChildOfListItem) return;
139
-
140
- // Walk up to find the enclosing bulletList or orderedList position.
141
- // WHY: resolvedPos.start(d) gives the first content position inside the node,
142
- // so we subtract 1 to get the node's own document position.
143
- let enclosingListPos: number | null = null;
144
- for (let d = resolvedPos.depth - 1; d > 0; d--) {
145
- if (isListParent(resolvedPos.node(d).type.name)) {
146
- enclosingListPos = resolvedPos.start(d) - 1;
147
- break;
148
- }
149
- }
150
-
151
- if (enclosingListPos === null || enclosingListPos < 0) return;
152
-
153
- if (!tr) tr = newState.tr;
154
-
155
- const indentToPromote = node.attrs.indent;
156
- tr.setNodeAttribute(pos, 'indent', 0); // Clear from text node
157
- tr.setNodeAttribute(enclosingListPos, 'indent', indentToPromote); // Promote to list
158
- });
159
-
160
- return tr;
161
- },
162
- }),
163
- ];
164
- },
165
-
166
- addGlobalAttributes() {
167
- return [
168
- {
169
- types: this.options.types ?? ['orderedList', 'bulletList', 'paragraph', 'heading'],
170
- attributes: {
171
- indent: {
172
- default: 1,
173
- parseHTML: (element) => parseInt(element.getAttribute('data-indent') || '1', 10),
174
- renderHTML: (attributes) => {
175
- if (!attributes?.indent) return {};
176
- return { style: `padding-left: ${attributes.indent * 20}px` };
177
- },
178
- },
179
- },
180
- },
181
- ];
182
- },
183
-
184
- addCommands() {
185
- return {
186
- _handleEnterKeyDown: () => (commands: CommandProps) => {
187
- const { tr, state, dispatch } = commands;
188
- const { $from } = state.selection;
189
- const currentNode = $from.node();
190
-
191
- const isIndentedParagraph =
192
- currentNode?.type?.name === 'paragraph' && currentNode.attrs.indent;
193
- if (!isIndentedParagraph) return false;
194
-
195
- const currentIndent = currentNode.attrs.indent;
196
- tr.split($from.pos);
197
- tr.setNodeAttribute(tr.selection.$from.before(), 'indent', currentIndent);
198
- tr.scrollIntoView();
199
- tr.setMeta('addToHistory', false);
200
- dispatch?.(tr);
201
- return true;
202
- },
203
-
204
- _handleSpaceKeyDown: () => () => false,
205
-
206
- indent: () => (commands: CommandProps) => {
207
- return runIndentChain(
208
- [handleIndentMultiNodeSelection, handleIndentList, handleIndentTextNode],
209
- commands,
210
- );
211
- },
212
-
213
- // WHY: `outdent` is used by Backspace. It only reduces indent when the cursor
214
- // is at the very start of the node, preventing accidental outdenting mid-sentence.
215
- outdent: () => (commands: CommandProps) => {
216
- const { state, view, tr, dispatch } = commands;
217
- const { $from, $to, empty: selectionIsEmpty } = state.selection;
218
- const context: ShortcutContext = {
219
- editor: commands.editor,
220
- state,
221
- view,
222
- $from,
223
- $to,
224
- selectionIsEmpty,
225
- currentNode: $from.node(),
226
- };
227
-
228
- if (handleOutdentMultiNodeSelection(context)) return true;
229
-
230
- const currentNode = $from.node();
231
- const isIndentedTextNode =
232
- isTextNode(currentNode.type.name) && currentNode.attrs.indent > 0;
233
- if (!isIndentedTextNode) return false;
234
-
235
- const isCursorAtLineStart = $from.pos === $from.start();
236
- if (!isCursorAtLineStart) return false;
237
-
238
- const newIndent = Math.max(0, currentNode.attrs.indent - 1);
239
- tr.setNodeAttribute($from.before($from.depth), 'indent', newIndent);
240
- tr.setMeta('addToHistory', false);
241
- dispatch?.(tr);
242
- return true;
243
- },
244
-
245
- // WHY: `outdentShiftTab` is used by Shift+Tab. Unlike `outdent`, it reduces indent
246
- // regardless of where the cursor is — Shift+Tab is an explicit intent to un-indent.
247
- outdentShiftTab: () => (commands: CommandProps) => {
248
- const { state, tr, dispatch } = commands;
249
- const { $from } = state.selection;
250
- const currentNode = $from.node();
251
-
252
- const isIndentedTextNode =
253
- isTextNode(currentNode.type.name) && currentNode.attrs.indent > 0;
254
- if (!isIndentedTextNode) return false;
255
-
256
- const newIndent = Math.max(0, currentNode.attrs.indent - 1);
257
- tr.setNodeAttribute($from.before($from.depth), 'indent', newIndent);
258
- tr.setMeta('addToHistory', false);
259
- dispatch?.(tr);
260
- return true;
261
- },
262
-
263
- increaseIndent:
264
- () =>
265
- ({ commands }: CommandProps) => {
266
- return commands.indent();
267
- },
268
- decreaseIndent: () => (commands: CommandProps) => {
269
- const { state, view } = commands;
270
- const { $from, $to, empty: selectionIsEmpty } = state.selection;
271
- const context: ShortcutContext = {
272
- editor: commands.editor,
273
- state,
274
- view,
275
- $from,
276
- $to,
277
- selectionIsEmpty,
278
- currentNode: $from.node(),
279
- };
280
- if (handleOutdentMultiNodeSelection(context)) return true;
281
- return commands.chain().outdentShiftTab().run();
282
- },
283
- };
284
- },
285
- });
@@ -1,121 +0,0 @@
1
- import { TextSelection } from '@tiptap/pm/state';
2
- import type { CommandProps } from '@tiptap/react';
3
-
4
- import { isListParent, isTextNode } from './indent.types';
5
- import type { IndentHandler } from './indent.types';
6
-
7
- /**
8
- * Multi-node selection handler: bulk-bump indent on all selected nodes.
9
- * Triggers when >1 indentable node (text or list) exists in the selection range.
10
- */
11
- export const handleIndentMultiNodeSelection: IndentHandler = (commands: CommandProps) => {
12
- const { tr, state, dispatch } = commands;
13
- const { selection } = state;
14
- const { $from, $to } = selection;
15
-
16
- if (selection.empty) return false;
17
-
18
- // Search for indentable node
19
- let indentableNodeCount = 0;
20
-
21
- // Traverse for nodes in between the selection
22
- state.doc.nodesBetween($from.pos, $to.pos, (node: any) => {
23
- if (isListParent(node.type.name) || isTextNode(node.type.name)) indentableNodeCount++;
24
- });
25
-
26
- // Early return if can't find the indentable node
27
- if (indentableNodeCount <= 1) return false;
28
-
29
- // Construct set for processing the to-be indented positions
30
- const processedPositions = new Set<number>();
31
- let hasApplied = false;
32
-
33
- // Traverse through again
34
- state.doc.nodesBetween($from.pos, $to.pos, (node: any, pos: number) => {
35
- // Target only list/text elements
36
- const isTarget = isListParent(node.type.name) || isTextNode(node.type.name);
37
- if (!isTarget || processedPositions.has(pos)) return true;
38
-
39
- processedPositions.add(pos);
40
- tr.setNodeAttribute(pos, 'indent', (node.attrs?.indent ?? 0) + 1);
41
- hasApplied = true;
42
- return false;
43
- });
44
-
45
- if (hasApplied) {
46
- tr.setMeta('addToHistory', true);
47
- dispatch?.(tr);
48
- return true;
49
- }
50
-
51
- return false;
52
- };
53
-
54
- /**
55
- * List handler: single-item lists get visual indent bump, multi-item lists get structural sink.
56
- * Walks up the ancestor tree from cursor to find the nearest enclosing list.
57
- */
58
- export const handleIndentList: IndentHandler = (commands: CommandProps) => {
59
- const { tr, state, dispatch } = commands;
60
- const { selection } = state;
61
- const { $from } = selection;
62
-
63
- // Walk up the ancestor tree from the cursor to find the nearest enclosing list.
64
- let enclosingList: any = null;
65
- let enclosingListItem: any = null;
66
-
67
- for (let depth = $from.depth; depth > 0; depth--) {
68
- const ancestor = $from.node(depth);
69
- if (isListParent(ancestor.type.name)) {
70
- enclosingList = { ...ancestor, pos: $from.before(depth) };
71
- enclosingListItem = { ...$from.node(depth + 1), pos: $from.before(depth + 1) };
72
- break;
73
- }
74
- }
75
-
76
- if (!enclosingList || !enclosingListItem) return false;
77
-
78
- // WHY: A list with only one item cannot be structurally sunk further (no sibling to nest under).
79
- // We increase the visual indent attribute instead, which mirrors Google Docs behavior.
80
- const listHasOnlyOneItem = enclosingList.content?.childCount === 1;
81
- if (listHasOnlyOneItem && selection instanceof TextSelection) {
82
- const existingIndent = enclosingList.attrs.indent ?? 0;
83
- tr.setNodeAttribute(enclosingList.pos, 'indent', existingIndent + 1);
84
- tr.setMeta('addToHistory', false);
85
- dispatch?.(tr);
86
- return true;
87
- }
88
-
89
- // WHY: For multi-item lists, we structurally sink the list item under its predecessor
90
- // (standard list nesting). Lists don't require the cursor to be at line start.
91
- commands
92
- .chain()
93
- .focus()
94
- .sinkListItem('listItem')
95
- .updateAttributes('bulletList', { indent: 1 })
96
- .updateAttributes('orderedList', { indent: 1 })
97
- .run();
98
- return true;
99
- };
100
-
101
- /**
102
- * Text node handler: bump indent on plain paragraphs and headings (not inside a list).
103
- * Only triggers on a blinking cursor (TextSelection).
104
- */
105
- export const handleIndentTextNode: IndentHandler = (commands: CommandProps) => {
106
- const { tr, state, dispatch } = commands;
107
- const { selection } = state;
108
- const { $from } = selection;
109
-
110
- const currentNode = $from.node();
111
- const isIndentableTextNode =
112
- isTextNode(currentNode.type.name) && selection instanceof TextSelection;
113
-
114
- if (!isIndentableTextNode) return false;
115
-
116
- const existingIndent = currentNode.attrs.indent ?? 0;
117
- tr.setNodeAttribute($from.before($from.depth), 'indent', existingIndent + 1);
118
- tr.setMeta('addToHistory', false);
119
- dispatch?.(tr);
120
- return true;
121
- };
@@ -1,8 +0,0 @@
1
- /**
2
- * Predicates that classify ProseMirror node types used throughout the indent logic.
3
- * Kept as plain functions (not constants/enums) so TypeScript infers the correct
4
- * string-narrowing type when used in conditional branches.
5
- */
6
- export const isListParent = (name: string) => name === 'bulletList' || name === 'orderedList';
7
- export const isList = (name: string) => name === 'listItem';
8
- export const isTextNode = (name: string) => name === 'paragraph' || name === 'heading';
@@ -1,71 +0,0 @@
1
- import { NodeRange } from '@tiptap/pm/model';
2
- import { liftTarget } from '@tiptap/pm/transform';
3
-
4
- import { ShortcutHandler, isListParent, isList, isTextNode } from './indent.types';
5
-
6
- /**
7
- * WHY: When a user highlights across multiple lines or list items,
8
- * we want to bulk-decrease their indentation level or lift them structurally out of nesting.
9
- */
10
- export const handleOutdentMultiNodeSelection: ShortcutHandler = ({
11
- state,
12
- view,
13
- $from,
14
- $to,
15
- selectionIsEmpty,
16
- }) => {
17
- if (selectionIsEmpty) return false;
18
-
19
- let selectedIndentableNodesCount = 0;
20
- state.doc.nodesBetween($from.pos, $to.pos, (node: any) => {
21
- if (isList(node.type.name) || isTextNode(node.type.name)) {
22
- selectedIndentableNodesCount++;
23
- }
24
- });
25
-
26
- const isMultiNodeSelection = selectedIndentableNodesCount > 1;
27
- if (!isMultiNodeSelection) return false;
28
-
29
- const { tr, doc } = state;
30
- let hasAppliedAnyIndent = false;
31
- const processedListPositions = new Set<number>();
32
-
33
- doc.nodesBetween($from.pos, $to.pos, (node: any, pos: number) => {
34
- if (isListParent(node.type.name) && (node.attrs.indent ?? 0) > 0) {
35
- if (!processedListPositions.has(pos)) {
36
- processedListPositions.add(pos);
37
-
38
- const currentIndent = node.attrs.indent ?? 0;
39
- tr.setNodeAttribute(pos, 'indent', Math.max(0, currentIndent - 1));
40
- hasAppliedAnyIndent = true;
41
-
42
- const $start = state.doc.resolve(pos);
43
- const $end = state.doc.resolve(pos + node.nodeSize);
44
- const range = new NodeRange($start, $end, $start.depth);
45
- const target = liftTarget(range);
46
-
47
- if (target != null) {
48
- tr.lift(range, target);
49
- hasAppliedAnyIndent = true;
50
- }
51
- }
52
- return false; // Prevent recursing into children of processed nodes
53
- }
54
-
55
- if (isTextNode(node.type.name) && (node.attrs.indent ?? 0) > 0) {
56
- const currentIndent = node.attrs.indent ?? 0;
57
- tr.setNodeAttribute(pos, 'indent', Math.max(0, currentIndent - 1));
58
- hasAppliedAnyIndent = true;
59
- }
60
-
61
- return true;
62
- });
63
-
64
- if (hasAppliedAnyIndent) {
65
- tr.setMeta('addToHistory', true);
66
- view.dispatch(tr);
67
- return true;
68
- }
69
-
70
- return false;
71
- };
@@ -1,133 +0,0 @@
1
- import { NodeRange } from '@tiptap/pm/model';
2
- import { TextSelection } from '@tiptap/pm/state';
3
- import { liftTarget } from '@tiptap/pm/transform';
4
-
5
- import { ShortcutHandler, isListParent, isList, isTextNode } from './indent.types';
6
-
7
- /**
8
- * WHY: When a user highlights across multiple lines or list items,
9
- * we want to bulk-decrease their indentation level or lift them structurally out of nesting.
10
- */
11
- export const handleShiftTabMultiNodeSelection: ShortcutHandler = ({
12
- state,
13
- view,
14
- $from,
15
- $to,
16
- selectionIsEmpty,
17
- }) => {
18
- if (selectionIsEmpty) return false;
19
-
20
- let selectedIndentableNodesCount = 0;
21
- state.doc.nodesBetween($from.pos, $to.pos, (node: any) => {
22
- if (isList(node.type.name) || isTextNode(node.type.name)) {
23
- selectedIndentableNodesCount++;
24
- }
25
- });
26
-
27
- const isMultiNodeSelection = selectedIndentableNodesCount > 1;
28
- if (!isMultiNodeSelection) return false;
29
-
30
- const { tr, doc } = state;
31
- let hasAppliedAnyIndent = false;
32
- const processedListPositions = new Set<number>();
33
-
34
- doc.nodesBetween($from.pos, $to.pos, (node: any, pos: number) => {
35
- if (isListParent(node.type.name) && (node.attrs.indent ?? 0) > 0) {
36
- if (!processedListPositions.has(pos)) {
37
- processedListPositions.add(pos);
38
-
39
- const currentIndent = node.attrs.indent ?? 0;
40
- tr.setNodeAttribute(pos, 'indent', Math.max(0, currentIndent - 1));
41
- hasAppliedAnyIndent = true;
42
-
43
- const $start = state.doc.resolve(pos);
44
- const $end = state.doc.resolve(pos + node.nodeSize);
45
- const range = new NodeRange($start, $end, $start.depth);
46
- const target = liftTarget(range);
47
-
48
- if (target != null) {
49
- tr.lift(range, target);
50
- hasAppliedAnyIndent = true;
51
- }
52
- }
53
- return false; // Prevent recursing into children of processed nodes
54
- }
55
-
56
- if (isTextNode(node.type.name) && (node.attrs.indent ?? 0) > 0) {
57
- const currentIndent = node.attrs.indent ?? 0;
58
- tr.setNodeAttribute(pos, 'indent', Math.max(0, currentIndent - 1));
59
- hasAppliedAnyIndent = true;
60
- }
61
-
62
- return true;
63
- });
64
-
65
- if (hasAppliedAnyIndent) {
66
- tr.setMeta('addToHistory', true);
67
- view.dispatch(tr);
68
- return true;
69
- }
70
-
71
- return false;
72
- };
73
-
74
- /**
75
- * WHY: A list item at the outermost structural level (no enclosing parent list) with no visual
76
- * indent has nothing to reduce. Without this guard, the event falls through to TipTap's List
77
- * extension which calls liftListItem and ejects the item out of the list entirely.
78
- */
79
- export const handleShiftTabInTopLevelListItem: ShortcutHandler = ({ $from, selectionIsEmpty }) => {
80
- if (!selectionIsEmpty) return false;
81
-
82
- let foundList = false;
83
- let hasParentList = false;
84
- let enclosingListIndent = 0;
85
-
86
- for (let d = $from.depth; d > 0; d--) {
87
- const node = $from.node(d);
88
- if (isListParent(node.type.name)) {
89
- if (!foundList) {
90
- foundList = true;
91
- enclosingListIndent = node.attrs?.indent ?? 0;
92
- } else {
93
- hasParentList = true;
94
- break;
95
- }
96
- }
97
- }
98
-
99
- const isInTopLevelList = foundList && !hasParentList;
100
- if (!isInTopLevelList) return false;
101
-
102
- return enclosingListIndent === 0;
103
- };
104
-
105
- /**
106
- * WHY: An empty trailing paragraph after a list has no indent to reduce, so Shift-Tab
107
- * falls through all handlers and the browser steals focus. We intercept and move the
108
- * cursor into the last list item instead, which matches the user's intent of "go back in."
109
- */
110
- export const handleShiftTabFromEmptyParagraphAfterList: ShortcutHandler = ({
111
- state,
112
- view,
113
- $from,
114
- currentNode,
115
- selectionIsEmpty,
116
- }) => {
117
- if (!selectionIsEmpty) return false;
118
-
119
- const isEmptyParagraph = currentNode.type.name === 'paragraph' && currentNode.content.size === 0;
120
- if (!isEmptyParagraph) return false;
121
-
122
- const nodePos = $from.before($from.depth);
123
- const prevNode = state.doc.resolve(nodePos).nodeBefore;
124
- const isPrecededByList = prevNode != null && isListParent(prevNode.type.name);
125
- if (!isPrecededByList) return false;
126
-
127
- // WHY: nodePos - 1 is the last position inside the list's closing boundary.
128
- // TextSelection.near with bias -1 resolves it to the end of the last list item's text content.
129
- const $endOfList = state.doc.resolve(nodePos - 1);
130
- const tr = state.tr.setSelection(TextSelection.near($endOfList, -1));
131
- view.dispatch(tr);
132
- return true;
133
- };