@sovann72-dev/lynqify-ui 1.0.0
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.
- package/README.md +73 -0
- package/package.json +240 -0
- package/src/components/RichTextEditor/Extension/Indent/backspace.indent.handlers.ts +77 -0
- package/src/components/RichTextEditor/Extension/Indent/indent.extension.ts +285 -0
- package/src/components/RichTextEditor/Extension/Indent/indent.handlers.ts +121 -0
- package/src/components/RichTextEditor/Extension/Indent/indent.types.ts +63 -0
- package/src/components/RichTextEditor/Extension/Indent/indent.utils.ts +8 -0
- package/src/components/RichTextEditor/Extension/Indent/outdent.handlers.ts +71 -0
- package/src/components/RichTextEditor/Extension/Indent/shifttab.indent.handlers.ts +133 -0
- package/src/components/RichTextEditor/Extension/Indent/tab.indent.handlers.ts +103 -0
- package/src/components/RichTextEditor/Extension/List/custom-list-item.extension.ts +107 -0
- package/src/components/RichTextEditor/Extension/List/dynamic-bullet-styling.extension.ts +40 -0
- package/src/components/RichTextEditor/Extension/batch-segment-images.extension.ts +486 -0
- package/src/components/RichTextEditor/Extension/batch-segment-images.types.ts +35 -0
- package/src/components/RichTextEditor/Extension/custom-image.extension.ts +18 -0
- package/src/components/RichTextEditor/Extension/custom-link.extension.ts +58 -0
- package/src/components/RichTextEditor/Extension/custom-mention.extension.ts +29 -0
- package/src/components/RichTextEditor/Extension/custom-paragraph.extension.ts +46 -0
- package/src/components/RichTextEditor/Extension/extensions.ts +118 -0
- package/src/components/RichTextEditor/Extension/file-filtering.extension.ts +0 -0
- package/src/components/RichTextEditor/Extension/list-indent-integration.extension.ts +125 -0
- package/src/components/RichTextEditor/Extension/mentionstorage.extension.ts +10 -0
- package/src/components/RichTextEditor/Extension/tiptap-extension-fontsize.ts +73 -0
- package/src/components/RichTextEditor/Extension/tiptap-extension-lineheight.ts +73 -0
- package/src/index.ts +42 -0
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Predicate utilities for node type classification
|
|
2
|
+
export { isListParent, isList, isTextNode } from './indent.utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The data snapshot passed into every keyboard-shortcut handler.
|
|
6
|
+
* Resolved once per keypress so each handler receives a stable, consistent view
|
|
7
|
+
* of the editor state — no handler can accidentally observe mid-chain mutations.
|
|
8
|
+
*/
|
|
9
|
+
export interface ShortcutContext {
|
|
10
|
+
editor: any;
|
|
11
|
+
state: any;
|
|
12
|
+
view: any;
|
|
13
|
+
$from: any;
|
|
14
|
+
$to: any;
|
|
15
|
+
selectionIsEmpty: boolean;
|
|
16
|
+
currentNode: any;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A single link in a Chain of Responsibility for keyboard shortcuts.
|
|
21
|
+
* Returns `true` if this handler consumed the event (chain stops),
|
|
22
|
+
* or `false` to pass control to the next handler in the chain.
|
|
23
|
+
*/
|
|
24
|
+
export type ShortcutHandler = (context: ShortcutContext) => boolean;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Runs each handler in order. Stops and returns `true` as soon as one
|
|
28
|
+
* handler claims the event. Returns `false` if no handler matched.
|
|
29
|
+
*/
|
|
30
|
+
export function runChain(handlers: ShortcutHandler[], context: ShortcutContext): boolean {
|
|
31
|
+
for (const handler of handlers) {
|
|
32
|
+
if (handler(context)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* CommandProps is provided by TipTap's addCommands.
|
|
41
|
+
* Contains transaction, state, dispatch, and helper chains/commands.
|
|
42
|
+
*/
|
|
43
|
+
import type { CommandProps } from '@tiptap/react';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A single link in a Chain of Responsibility for indent commands.
|
|
47
|
+
* Returns `true` if this handler consumed the command (chain stops),
|
|
48
|
+
* or `false` to pass control to the next handler in the chain.
|
|
49
|
+
*/
|
|
50
|
+
export type IndentHandler = (commands: CommandProps) => boolean;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Runs each indent handler in order. Stops and returns `true` as soon as one
|
|
54
|
+
* handler claims the command. Returns `false` if no handler matched.
|
|
55
|
+
*/
|
|
56
|
+
export function runIndentChain(handlers: IndentHandler[], commands: CommandProps): boolean {
|
|
57
|
+
let index = 0;
|
|
58
|
+
for (const handler of handlers) {
|
|
59
|
+
index = index++;
|
|
60
|
+
if (handler(commands)) return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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';
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ShortcutHandler, isListParent, isList, isTextNode } from './indent.types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WHY: When a user highlights across multiple lines or list items,
|
|
5
|
+
* we want to bulk-increase their indentation level as a group.
|
|
6
|
+
*/
|
|
7
|
+
export const handleTabMultiNodeSelection: ShortcutHandler = ({
|
|
8
|
+
state,
|
|
9
|
+
view,
|
|
10
|
+
$from,
|
|
11
|
+
$to,
|
|
12
|
+
selectionIsEmpty,
|
|
13
|
+
}) => {
|
|
14
|
+
if (selectionIsEmpty) return false;
|
|
15
|
+
|
|
16
|
+
let selectedIndentableNodesCount = 0;
|
|
17
|
+
state.doc.nodesBetween($from.pos, $to.pos, (node: any) => {
|
|
18
|
+
if (isList(node.type.name) || isTextNode(node.type.name)) {
|
|
19
|
+
selectedIndentableNodesCount++;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const isMultiNodeSelection = selectedIndentableNodesCount > 1;
|
|
24
|
+
if (!isMultiNodeSelection) return false;
|
|
25
|
+
|
|
26
|
+
const { tr, doc } = state;
|
|
27
|
+
let hasAppliedAnyIndent = false;
|
|
28
|
+
const processedListPositions = new Set<number>();
|
|
29
|
+
|
|
30
|
+
doc.nodesBetween($from.pos, $to.pos, (node: any, pos: number) => {
|
|
31
|
+
const isTargetNode = isListParent(node.type.name) || isTextNode(node.type.name);
|
|
32
|
+
|
|
33
|
+
if (isTargetNode && !processedListPositions.has(pos)) {
|
|
34
|
+
processedListPositions.add(pos);
|
|
35
|
+
const currentIndent = node.attrs.indent ?? 0;
|
|
36
|
+
tr.setNodeAttribute(pos, 'indent', currentIndent + 1);
|
|
37
|
+
hasAppliedAnyIndent = true;
|
|
38
|
+
return false; // Prevent recursing into children of processed nodes
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (hasAppliedAnyIndent) {
|
|
44
|
+
tr.setMeta('addToHistory', true);
|
|
45
|
+
view.dispatch(tr);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* WHY: If a user has highlighted a specific word or phrase inside a single text line,
|
|
54
|
+
* pressing Tab shouldn't move the entire line layout. It should just overwrite the text with a \t.
|
|
55
|
+
*/
|
|
56
|
+
export const handleTabSingleNodeTextSelection: ShortcutHandler = ({
|
|
57
|
+
state,
|
|
58
|
+
view,
|
|
59
|
+
$from,
|
|
60
|
+
selectionIsEmpty,
|
|
61
|
+
}) => {
|
|
62
|
+
if (selectionIsEmpty) return false;
|
|
63
|
+
|
|
64
|
+
// WHY: A selection inside a list item should indent the list, not insert a literal tab.
|
|
65
|
+
const isInsideList = $from.path.some((entry: any) => entry?.type?.name === 'listItem');
|
|
66
|
+
if (isInsideList) return false;
|
|
67
|
+
|
|
68
|
+
view.dispatch(state.tr.insertText('\t'));
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* WHY: If the user is currently typing inside a List Item but they haven't highlighted anything,
|
|
74
|
+
* let the List extension handle indenting structurally.
|
|
75
|
+
*/
|
|
76
|
+
export const handleTabInsideListItem: ShortcutHandler = ({ editor, currentNode, $from }) => {
|
|
77
|
+
const isLineEmpty = currentNode.content.size === 0;
|
|
78
|
+
const parentNode = $from.node($from.depth - 1);
|
|
79
|
+
const isInsideListItem = parentNode && isList(parentNode.type.name);
|
|
80
|
+
|
|
81
|
+
if (isInsideListItem && !isLineEmpty) {
|
|
82
|
+
editor.chain().focus().indent().focus().run();
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* WHY: If the cursor is right in the middle of a sentence, inject a literal Tab character.
|
|
91
|
+
* We only block-indent the whole line if the cursor is at the very beginning.
|
|
92
|
+
*/
|
|
93
|
+
export const handleTabMidTextCursor: ShortcutHandler = ({ state, view, $from, currentNode }) => {
|
|
94
|
+
const isCursorAtStart = $from.pos === $from.start();
|
|
95
|
+
const isLineEmpty = currentNode.content.size === 0;
|
|
96
|
+
|
|
97
|
+
if (!isCursorAtStart && !isLineEmpty) {
|
|
98
|
+
view.dispatch(state.tr.insertText('\t'));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return false;
|
|
103
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom List Extensions: Indent Attribute Support
|
|
3
|
+
*
|
|
4
|
+
* Extends TipTap list nodes (BulletList, OrderedList, ListItem) with indent attributes.
|
|
5
|
+
*
|
|
6
|
+
* Per FR-020: ListItem receives transferred indent from paragraph
|
|
7
|
+
* Per FR-021: Paragraph children inside lists must NOT have indent
|
|
8
|
+
* Per FR-022: List-level indent separate from item indent
|
|
9
|
+
* Per FR-030: Backward compatibility via attribute defaults
|
|
10
|
+
*
|
|
11
|
+
* @see specs/001-richtext-indent-lists/data-model.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import BulletList from '@tiptap/extension-bullet-list';
|
|
15
|
+
import ListItem from '@tiptap/extension-list-item';
|
|
16
|
+
import OrderedList from '@tiptap/extension-ordered-list';
|
|
17
|
+
|
|
18
|
+
// CONVENTION - T009: Extend BulletList with indent attribute (FR-022)
|
|
19
|
+
export const CustomBulletList = BulletList.extend({
|
|
20
|
+
addAttributes() {
|
|
21
|
+
return {
|
|
22
|
+
...this.parent?.(),
|
|
23
|
+
indent: {
|
|
24
|
+
default: 0, // Backward compatibility (FR-030)
|
|
25
|
+
parseHTML: (element) => parseInt(element.getAttribute('data-indent') || '0', 10),
|
|
26
|
+
renderHTML: (attributes) => {
|
|
27
|
+
if (!attributes.indent) return {};
|
|
28
|
+
return { 'data-indent': attributes.indent };
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
addNodeView() {
|
|
35
|
+
return ({ node }) => {
|
|
36
|
+
const indentLevel = node.attrs.indent || 0;
|
|
37
|
+
const dom = document.createElement('ul');
|
|
38
|
+
dom.setAttribute('data-indent', String(indentLevel));
|
|
39
|
+
|
|
40
|
+
// Apply padding based on indent level (20px per level)
|
|
41
|
+
if (indentLevel > 0) {
|
|
42
|
+
dom.style.paddingLeft = `${indentLevel * 20}px`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const contentDOM = dom;
|
|
46
|
+
return { dom, contentDOM };
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// CONVENTION - T010: Extend OrderedList with indent attribute (FR-022)
|
|
52
|
+
export const CustomOrderedList = OrderedList.extend({
|
|
53
|
+
addAttributes() {
|
|
54
|
+
return {
|
|
55
|
+
...this.parent?.(),
|
|
56
|
+
indent: {
|
|
57
|
+
default: 0, // Backward compatibility (FR-030)
|
|
58
|
+
parseHTML: (element) => parseInt(element.getAttribute('data-indent') || '0', 10),
|
|
59
|
+
renderHTML: (attributes) => {
|
|
60
|
+
if (!attributes.indent) return {};
|
|
61
|
+
return { 'data-indent': attributes.indent };
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
addNodeView() {
|
|
68
|
+
return ({ node }) => {
|
|
69
|
+
const indentLevel = node.attrs.indent || 0;
|
|
70
|
+
const dom = document.createElement('ol');
|
|
71
|
+
dom.setAttribute('data-indent', String(indentLevel));
|
|
72
|
+
|
|
73
|
+
// Apply padding based on indent level (20px per level)
|
|
74
|
+
if (indentLevel > 0) {
|
|
75
|
+
dom.style.paddingLeft = `${indentLevel * 20}px`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const contentDOM = dom;
|
|
79
|
+
return { dom, contentDOM };
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// CONVENTION - T011: Extend ListItem with indent attribute (FR-020, FR-021)
|
|
85
|
+
export const CustomListItem = ListItem.extend({
|
|
86
|
+
addAttributes() {
|
|
87
|
+
return {
|
|
88
|
+
...this.parent?.(),
|
|
89
|
+
class: {
|
|
90
|
+
default: null,
|
|
91
|
+
parseHTML: (element: any) => element.getAttribute('class'),
|
|
92
|
+
renderHTML: (attributes: any) => {
|
|
93
|
+
if (!attributes.class) return {};
|
|
94
|
+
return { class: attributes.class, ...attributes };
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
indent: {
|
|
98
|
+
default: 0, // Backward compatibility (FR-030)
|
|
99
|
+
parseHTML: (element) => parseInt(element.getAttribute('data-indent') || '0', 10),
|
|
100
|
+
renderHTML: (attributes) => {
|
|
101
|
+
if (!attributes.indent) return {};
|
|
102
|
+
return { 'data-indent': attributes.indent };
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core';
|
|
2
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
3
|
+
|
|
4
|
+
// Dynamic bullet styling extension (assigns class based on nesting level)
|
|
5
|
+
export const DynamicBulletStyling = Extension.create({
|
|
6
|
+
name: 'dynamicBulletStyling',
|
|
7
|
+
addProseMirrorPlugins() {
|
|
8
|
+
return [
|
|
9
|
+
new Plugin({
|
|
10
|
+
key: new PluginKey('dynamicBulletStyling'),
|
|
11
|
+
appendTransaction: (transactions, oldState, newState) => {
|
|
12
|
+
const tr = newState.tr;
|
|
13
|
+
let modified = false;
|
|
14
|
+
newState.doc.descendants((node, pos) => {
|
|
15
|
+
if (node.type.name === 'listItem') {
|
|
16
|
+
let nestingLevel = 0;
|
|
17
|
+
|
|
18
|
+
const $pos = newState.doc.resolve(pos);
|
|
19
|
+
|
|
20
|
+
for (let depth = $pos.depth; depth > 0; depth--) {
|
|
21
|
+
const nodeAtDepth = $pos.node(depth);
|
|
22
|
+
if (nodeAtDepth.type.name === 'bulletList') {
|
|
23
|
+
nestingLevel = 1;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (nestingLevel > 0) {
|
|
29
|
+
tr.setNodeAttribute($pos.pos, '[data-style]', 'line-height: inherit');
|
|
30
|
+
// tr.setDocAttribute('[data-style]', 'line-height: inherit');
|
|
31
|
+
modified = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
return modified ? tr : null;
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
];
|
|
39
|
+
},
|
|
40
|
+
});
|