@moldable-ai/editor 0.1.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.
- package/LICENSE +101 -0
- package/dist/components/floating-toolbar.d.ts +2 -0
- package/dist/components/floating-toolbar.d.ts.map +1 -0
- package/dist/components/floating-toolbar.js +109 -0
- package/dist/components/markdown-editor.d.ts +15 -0
- package/dist/components/markdown-editor.d.ts.map +1 -0
- package/dist/components/markdown-editor.js +80 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/lib/lexical/auto-link-config.d.ts +21 -0
- package/dist/lib/lexical/auto-link-config.d.ts.map +1 -0
- package/dist/lib/lexical/auto-link-config.js +23 -0
- package/dist/lib/lexical/clickable-link-plugin.d.ts +11 -0
- package/dist/lib/lexical/clickable-link-plugin.d.ts.map +1 -0
- package/dist/lib/lexical/clickable-link-plugin.js +97 -0
- package/dist/lib/lexical/editor-theme.d.ts +38 -0
- package/dist/lib/lexical/editor-theme.d.ts.map +1 -0
- package/dist/lib/lexical/editor-theme.js +37 -0
- package/dist/lib/lexical/floating-toolbar-plugin.d.ts +30 -0
- package/dist/lib/lexical/floating-toolbar-plugin.d.ts.map +1 -0
- package/dist/lib/lexical/floating-toolbar-plugin.js +237 -0
- package/dist/lib/lexical/headless-editor.d.ts +6 -0
- package/dist/lib/lexical/headless-editor.d.ts.map +1 -0
- package/dist/lib/lexical/headless-editor.js +30 -0
- package/dist/lib/lexical/markdown-transformers.d.ts +40 -0
- package/dist/lib/lexical/markdown-transformers.d.ts.map +1 -0
- package/dist/lib/lexical/markdown-transformers.js +52 -0
- package/dist/lib/lexical/split-node-at-selection.d.ts +9 -0
- package/dist/lib/lexical/split-node-at-selection.d.ts.map +1 -0
- package/dist/lib/lexical/split-node-at-selection.js +224 -0
- package/dist/lib/lexical/sync-plugin.d.ts +11 -0
- package/dist/lib/lexical/sync-plugin.d.ts.map +1 -0
- package/dist/lib/lexical/sync-plugin.js +45 -0
- package/package.json +81 -0
- package/src/styles/index.css +71 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { $splitNodesAtSelectionBoundaries } from './split-node-at-selection';
|
|
5
|
+
import { $createCodeNode } from '@lexical/code';
|
|
6
|
+
import { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListNode, } from '@lexical/list';
|
|
7
|
+
import { $createHeadingNode, $createQuoteNode, $isHeadingNode, } from '@lexical/rich-text';
|
|
8
|
+
import { $setBlocksType } from '@lexical/selection';
|
|
9
|
+
import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils';
|
|
10
|
+
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, FORMAT_TEXT_COMMAND, SELECTION_CHANGE_COMMAND, $createParagraphNode as createParagraphNode, } from 'lexical';
|
|
11
|
+
export function useFloatingToolbar() {
|
|
12
|
+
const [editor] = useLexicalComposerContext();
|
|
13
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
14
|
+
const [isBold, setIsBold] = useState(false);
|
|
15
|
+
const [isItalic, setIsItalic] = useState(false);
|
|
16
|
+
const [isUnderline, setIsUnderline] = useState(false);
|
|
17
|
+
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
|
18
|
+
const [isCode, setIsCode] = useState(false);
|
|
19
|
+
const [blockType, setBlockType] = useState('paragraph');
|
|
20
|
+
const [position, setPosition] = useState(null);
|
|
21
|
+
const updateToolbar = useCallback(() => {
|
|
22
|
+
const selection = $getSelection();
|
|
23
|
+
// Hide toolbar if editor is not editable (read-only mode)
|
|
24
|
+
if (!editor.isEditable()) {
|
|
25
|
+
setIsVisible(false);
|
|
26
|
+
setPosition(null);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if ($isRangeSelection(selection)) {
|
|
30
|
+
const anchorNode = selection.anchor.getNode();
|
|
31
|
+
const element = anchorNode.getKey() === 'root'
|
|
32
|
+
? anchorNode
|
|
33
|
+
: anchorNode.getTopLevelElementOrThrow();
|
|
34
|
+
const elementKey = element.getKey();
|
|
35
|
+
const elementDOM = editor.getElementByKey(elementKey);
|
|
36
|
+
// Check if selection is empty (no text selected)
|
|
37
|
+
const isCollapsed = selection.isCollapsed();
|
|
38
|
+
if (elementDOM !== null && !isCollapsed) {
|
|
39
|
+
setIsVisible(true);
|
|
40
|
+
// Update text format states
|
|
41
|
+
setIsBold(selection.hasFormat('bold'));
|
|
42
|
+
setIsItalic(selection.hasFormat('italic'));
|
|
43
|
+
setIsUnderline(selection.hasFormat('underline'));
|
|
44
|
+
setIsStrikethrough(selection.hasFormat('strikethrough'));
|
|
45
|
+
setIsCode(selection.hasFormat('code'));
|
|
46
|
+
// Update block type
|
|
47
|
+
if ($isHeadingNode(element)) {
|
|
48
|
+
const tag = element.getTag();
|
|
49
|
+
setBlockType(tag);
|
|
50
|
+
}
|
|
51
|
+
else if ($isListNode(element)) {
|
|
52
|
+
const parentList = $getNearestNodeOfType(anchorNode, ListNode);
|
|
53
|
+
const type = parentList
|
|
54
|
+
? parentList.getListType()
|
|
55
|
+
: element.getListType();
|
|
56
|
+
if (type === 'number') {
|
|
57
|
+
setBlockType('number');
|
|
58
|
+
}
|
|
59
|
+
else if (type === 'check') {
|
|
60
|
+
setBlockType('check');
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
setBlockType('bullet');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const type = element.getType();
|
|
68
|
+
if (type === 'quote') {
|
|
69
|
+
setBlockType('quote');
|
|
70
|
+
}
|
|
71
|
+
else if (type === 'code') {
|
|
72
|
+
setBlockType('code');
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
setBlockType('paragraph');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Calculate position from native selection
|
|
79
|
+
const nativeSelection = window.getSelection();
|
|
80
|
+
const rootElement = editor.getRootElement();
|
|
81
|
+
if (nativeSelection !== null &&
|
|
82
|
+
nativeSelection.rangeCount > 0 &&
|
|
83
|
+
rootElement !== null) {
|
|
84
|
+
const range = nativeSelection.getRangeAt(0);
|
|
85
|
+
const rect = range.getBoundingClientRect();
|
|
86
|
+
if (rect.width > 0 || rect.height > 0) {
|
|
87
|
+
// Use fixed positioning relative to viewport
|
|
88
|
+
// Position at start (left) and bottom of selection
|
|
89
|
+
setPosition({
|
|
90
|
+
top: rect.bottom,
|
|
91
|
+
left: rect.left,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
setIsVisible(false);
|
|
98
|
+
setPosition(null);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
setIsVisible(false);
|
|
103
|
+
setPosition(null);
|
|
104
|
+
}
|
|
105
|
+
}, [editor]);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
return mergeRegister(editor.registerUpdateListener(({ editorState }) => {
|
|
108
|
+
editorState.read(() => {
|
|
109
|
+
updateToolbar();
|
|
110
|
+
});
|
|
111
|
+
}), editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
|
|
112
|
+
updateToolbar();
|
|
113
|
+
return false;
|
|
114
|
+
}, COMMAND_PRIORITY_LOW));
|
|
115
|
+
}, [editor, updateToolbar]);
|
|
116
|
+
const formatBold = () => {
|
|
117
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
|
118
|
+
};
|
|
119
|
+
const formatItalic = () => {
|
|
120
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
|
121
|
+
};
|
|
122
|
+
const formatUnderline = () => {
|
|
123
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
|
124
|
+
};
|
|
125
|
+
const formatStrikethrough = () => {
|
|
126
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
|
127
|
+
};
|
|
128
|
+
const formatCode = () => {
|
|
129
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
|
|
130
|
+
};
|
|
131
|
+
const formatParagraph = () => {
|
|
132
|
+
editor.update(() => {
|
|
133
|
+
const selection = $getSelection();
|
|
134
|
+
if ($isRangeSelection(selection)) {
|
|
135
|
+
$setBlocksType(selection, () => createParagraphNode());
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
const formatHeading = (headingSize) => {
|
|
140
|
+
if (blockType !== headingSize) {
|
|
141
|
+
editor.update(() => {
|
|
142
|
+
const selection = $getSelection();
|
|
143
|
+
if ($isRangeSelection(selection)) {
|
|
144
|
+
// Split nodes at selection boundaries to ensure we only convert
|
|
145
|
+
// the selected portion, not entire blocks
|
|
146
|
+
$splitNodesAtSelectionBoundaries(selection);
|
|
147
|
+
// Get the updated selection after splitting
|
|
148
|
+
const updatedSelection = $getSelection();
|
|
149
|
+
if ($isRangeSelection(updatedSelection)) {
|
|
150
|
+
// Now apply the heading style - this will only affect blocks
|
|
151
|
+
// that are within or intersect with the selection
|
|
152
|
+
$setBlocksType(updatedSelection, () => $createHeadingNode(headingSize));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const formatBulletList = () => {
|
|
159
|
+
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
|
160
|
+
};
|
|
161
|
+
const formatNumberedList = () => {
|
|
162
|
+
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
|
163
|
+
};
|
|
164
|
+
const formatCheckList = () => {
|
|
165
|
+
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
|
|
166
|
+
};
|
|
167
|
+
const formatQuote = () => {
|
|
168
|
+
if (blockType !== 'quote') {
|
|
169
|
+
editor.update(() => {
|
|
170
|
+
const selection = $getSelection();
|
|
171
|
+
if ($isRangeSelection(selection)) {
|
|
172
|
+
$setBlocksType(selection, () => $createQuoteNode());
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const formatCodeBlock = () => {
|
|
178
|
+
if (blockType !== 'code') {
|
|
179
|
+
editor.update(() => {
|
|
180
|
+
const selection = $getSelection();
|
|
181
|
+
if ($isRangeSelection(selection)) {
|
|
182
|
+
$setBlocksType(selection, () => $createCodeNode());
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const clearFormatting = () => {
|
|
188
|
+
editor.update(() => {
|
|
189
|
+
const selection = $getSelection();
|
|
190
|
+
if ($isRangeSelection(selection)) {
|
|
191
|
+
// Convert to paragraph if it's a heading, quote, or code block
|
|
192
|
+
const shouldConvertToParagraph = blockType !== 'paragraph' &&
|
|
193
|
+
blockType !== 'bullet' &&
|
|
194
|
+
blockType !== 'number';
|
|
195
|
+
if (shouldConvertToParagraph) {
|
|
196
|
+
$setBlocksType(selection, () => createParagraphNode());
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
// Dispatch format commands outside the update block
|
|
201
|
+
// Toggle off any active formats
|
|
202
|
+
if (isBold)
|
|
203
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
|
204
|
+
if (isItalic)
|
|
205
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
|
206
|
+
if (isUnderline)
|
|
207
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
|
208
|
+
if (isStrikethrough)
|
|
209
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
|
210
|
+
if (isCode)
|
|
211
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
isVisible,
|
|
215
|
+
isBold,
|
|
216
|
+
isItalic,
|
|
217
|
+
isUnderline,
|
|
218
|
+
isStrikethrough,
|
|
219
|
+
isCode,
|
|
220
|
+
blockType,
|
|
221
|
+
editor,
|
|
222
|
+
position,
|
|
223
|
+
onBold: formatBold,
|
|
224
|
+
onItalic: formatItalic,
|
|
225
|
+
onUnderline: formatUnderline,
|
|
226
|
+
onStrikethrough: formatStrikethrough,
|
|
227
|
+
onCode: formatCode,
|
|
228
|
+
onParagraph: formatParagraph,
|
|
229
|
+
onHeading: formatHeading,
|
|
230
|
+
onBulletList: formatBulletList,
|
|
231
|
+
onNumberedList: formatNumberedList,
|
|
232
|
+
onCheckList: formatCheckList,
|
|
233
|
+
onQuote: formatQuote,
|
|
234
|
+
onCodeBlock: formatCodeBlock,
|
|
235
|
+
onClearFormatting: clearFormatting,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headless-editor.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/headless-editor.ts"],"names":[],"mappings":"AAQA;;;GAGG;AACH,wBAAgB,4BAA4B,oCAkB3C"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { editorTheme } from './editor-theme';
|
|
2
|
+
import { CodeHighlightNode, CodeNode } from '@lexical/code';
|
|
3
|
+
import { HorizontalRuleNode } from '@lexical/extension';
|
|
4
|
+
import { createHeadlessEditor } from '@lexical/headless';
|
|
5
|
+
import { AutoLinkNode, LinkNode } from '@lexical/link';
|
|
6
|
+
import { ListItemNode, ListNode } from '@lexical/list';
|
|
7
|
+
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
|
8
|
+
/**
|
|
9
|
+
* Creates a headless Lexical editor for server-side operations.
|
|
10
|
+
* Use this for translation, content extraction, etc.
|
|
11
|
+
*/
|
|
12
|
+
export function createMoldableHeadlessEditor() {
|
|
13
|
+
return createHeadlessEditor({
|
|
14
|
+
nodes: [
|
|
15
|
+
HeadingNode,
|
|
16
|
+
QuoteNode,
|
|
17
|
+
ListNode,
|
|
18
|
+
ListItemNode,
|
|
19
|
+
CodeNode,
|
|
20
|
+
CodeHighlightNode,
|
|
21
|
+
AutoLinkNode,
|
|
22
|
+
LinkNode,
|
|
23
|
+
HorizontalRuleNode,
|
|
24
|
+
],
|
|
25
|
+
theme: editorTheme,
|
|
26
|
+
onError: (error) => {
|
|
27
|
+
throw error;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ElementTransformer, Transformer } from '@lexical/markdown';
|
|
2
|
+
import type { ElementNode } from 'lexical';
|
|
3
|
+
/**
|
|
4
|
+
* Horizontal rule transformer - supports ---, ***, and ___
|
|
5
|
+
*/
|
|
6
|
+
export declare const HR: ElementTransformer;
|
|
7
|
+
/**
|
|
8
|
+
* All markdown transformers
|
|
9
|
+
*/
|
|
10
|
+
export declare const markdownTransformers: Array<Transformer>;
|
|
11
|
+
/**
|
|
12
|
+
* Options for converting from markdown string
|
|
13
|
+
*/
|
|
14
|
+
interface ConvertFromMarkdownOptions {
|
|
15
|
+
markdown: string;
|
|
16
|
+
transformers?: Array<Transformer>;
|
|
17
|
+
node?: ElementNode;
|
|
18
|
+
shouldPreserveNewLines?: boolean;
|
|
19
|
+
shouldMergeAdjacentLines?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Options for converting to markdown string
|
|
23
|
+
*/
|
|
24
|
+
interface ConvertToMarkdownOptions {
|
|
25
|
+
transformers?: Array<Transformer>;
|
|
26
|
+
node?: ElementNode;
|
|
27
|
+
shouldPreserveNewLines?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Renders markdown from a string. The selection is moved to the start after the operation.
|
|
31
|
+
* Uses named arguments for better readability and maintainability.
|
|
32
|
+
*/
|
|
33
|
+
export declare function $convertFromMarkdownString({ markdown, transformers, node, shouldPreserveNewLines, shouldMergeAdjacentLines, }: ConvertFromMarkdownOptions): void;
|
|
34
|
+
/**
|
|
35
|
+
* Renders string from markdown. The selection is moved to the start after the operation.
|
|
36
|
+
* Uses named arguments for better readability and maintainability.
|
|
37
|
+
*/
|
|
38
|
+
export declare function $convertToMarkdownString({ transformers, node, shouldPreserveNewLines, }?: ConvertToMarkdownOptions): string;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=markdown-transformers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown-transformers.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/markdown-transformers.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AACxE,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,SAAS,CAAA;AAEvD;;GAEG;AACH,eAAO,MAAM,EAAE,EAAE,kBAgBhB,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,oBAAoB,EAAE,KAAK,CAAC,WAAW,CAOnD,CAAA;AAED;;GAEG;AACH,UAAU,0BAA0B;IAClC,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,CAAA;IACjC,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,sBAAsB,CAAC,EAAE,OAAO,CAAA;IAChC,wBAAwB,CAAC,EAAE,OAAO,CAAA;CACnC;AAED;;GAEG;AACH,UAAU,wBAAwB;IAChC,YAAY,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,CAAA;IACjC,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,sBAAsB,CAAC,EAAE,OAAO,CAAA;CACjC;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,EACzC,QAAQ,EACR,YAAmC,EACnC,IAAI,EACJ,sBAA6B,EAC7B,wBAAgC,GACjC,EAAE,0BAA0B,GAAG,IAAI,CAQnC;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,EACvC,YAAmC,EACnC,IAAI,EACJ,sBAA6B,GAC9B,GAAE,wBAA6B,GAAG,MAAM,CAExC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown Transformers for Lexical Editor
|
|
3
|
+
* Provides import/export functionality with proper newline preservation
|
|
4
|
+
*/
|
|
5
|
+
import { $createHorizontalRuleNode, $isHorizontalRuleNode, HorizontalRuleNode, } from '@lexical/extension';
|
|
6
|
+
import { CHECK_LIST, ELEMENT_TRANSFORMERS, MULTILINE_ELEMENT_TRANSFORMERS, TEXT_FORMAT_TRANSFORMERS, TEXT_MATCH_TRANSFORMERS, $convertFromMarkdownString as lexicalConvertFromMarkdown, $convertToMarkdownString as lexicalConvertToMarkdown, } from '@lexical/markdown';
|
|
7
|
+
/**
|
|
8
|
+
* Horizontal rule transformer - supports ---, ***, and ___
|
|
9
|
+
*/
|
|
10
|
+
export const HR = {
|
|
11
|
+
dependencies: [HorizontalRuleNode],
|
|
12
|
+
export: (node) => {
|
|
13
|
+
return $isHorizontalRuleNode(node) ? '***' : null;
|
|
14
|
+
},
|
|
15
|
+
regExp: /^(---|\*\*\*|___)\s?$/,
|
|
16
|
+
replace: (parentNode, _1, _2, isImport) => {
|
|
17
|
+
const line = $createHorizontalRuleNode();
|
|
18
|
+
if (isImport || parentNode.getNextSibling() != null) {
|
|
19
|
+
parentNode.replace(line);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
parentNode.insertBefore(line);
|
|
23
|
+
}
|
|
24
|
+
line.selectNext();
|
|
25
|
+
},
|
|
26
|
+
type: 'element',
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* All markdown transformers
|
|
30
|
+
*/
|
|
31
|
+
export const markdownTransformers = [
|
|
32
|
+
HR,
|
|
33
|
+
CHECK_LIST,
|
|
34
|
+
...ELEMENT_TRANSFORMERS,
|
|
35
|
+
...MULTILINE_ELEMENT_TRANSFORMERS,
|
|
36
|
+
...TEXT_FORMAT_TRANSFORMERS,
|
|
37
|
+
...TEXT_MATCH_TRANSFORMERS,
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Renders markdown from a string. The selection is moved to the start after the operation.
|
|
41
|
+
* Uses named arguments for better readability and maintainability.
|
|
42
|
+
*/
|
|
43
|
+
export function $convertFromMarkdownString({ markdown, transformers = markdownTransformers, node, shouldPreserveNewLines = true, shouldMergeAdjacentLines = false, }) {
|
|
44
|
+
return lexicalConvertFromMarkdown(markdown, transformers, node, shouldPreserveNewLines, shouldMergeAdjacentLines);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Renders string from markdown. The selection is moved to the start after the operation.
|
|
48
|
+
* Uses named arguments for better readability and maintainability.
|
|
49
|
+
*/
|
|
50
|
+
export function $convertToMarkdownString({ transformers = markdownTransformers, node, shouldPreserveNewLines = true, } = {}) {
|
|
51
|
+
return lexicalConvertToMarkdown(transformers, node, shouldPreserveNewLines);
|
|
52
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { RangeSelection } from 'lexical';
|
|
2
|
+
/**
|
|
3
|
+
* Splits nodes at the selection boundaries to ensure block-level formatting
|
|
4
|
+
* (like headings) only affects the selected portion, not entire blocks.
|
|
5
|
+
*
|
|
6
|
+
* @param selection The range selection to split nodes for
|
|
7
|
+
*/
|
|
8
|
+
export declare function $splitNodesAtSelectionBoundaries(selection: RangeSelection): void;
|
|
9
|
+
//# sourceMappingURL=split-node-at-selection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"split-node-at-selection.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/split-node-at-selection.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,cAAc,EACf,MAAM,SAAS,CAAA;AAEhB;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAC9C,SAAS,EAAE,cAAc,GACxB,IAAI,CAmKN"}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { $createParagraphNode, $isParagraphNode, $isTextNode, } from 'lexical';
|
|
2
|
+
/**
|
|
3
|
+
* Splits nodes at the selection boundaries to ensure block-level formatting
|
|
4
|
+
* (like headings) only affects the selected portion, not entire blocks.
|
|
5
|
+
*
|
|
6
|
+
* @param selection The range selection to split nodes for
|
|
7
|
+
*/
|
|
8
|
+
export function $splitNodesAtSelectionBoundaries(selection) {
|
|
9
|
+
const anchorNode = selection.anchor.getNode();
|
|
10
|
+
const focusNode = selection.focus.getNode();
|
|
11
|
+
// Get the top-level element nodes
|
|
12
|
+
const anchorElement = anchorNode.getKey() === 'root'
|
|
13
|
+
? anchorNode
|
|
14
|
+
: anchorNode.getTopLevelElementOrThrow();
|
|
15
|
+
const focusElement = focusNode.getKey() === 'root'
|
|
16
|
+
? focusNode
|
|
17
|
+
: focusNode.getTopLevelElementOrThrow();
|
|
18
|
+
const anchorOffset = selection.anchor.offset;
|
|
19
|
+
const focusOffset = selection.focus.offset;
|
|
20
|
+
// Split paragraph at anchor (start of selection) if needed
|
|
21
|
+
// We need to split if there's content before the selection start
|
|
22
|
+
if ($isParagraphNode(anchorElement) && $isTextNode(anchorNode)) {
|
|
23
|
+
const shouldSplitAtAnchor = anchorOffset > 0 ||
|
|
24
|
+
(anchorOffset === 0 && anchorElement.getFirstChild() !== anchorNode);
|
|
25
|
+
if (shouldSplitAtAnchor) {
|
|
26
|
+
// If anchor and focus are in the same text node, we need to handle the focus offset
|
|
27
|
+
// after splitting because splitText will modify the original node
|
|
28
|
+
const anchorAndFocusInSameNode = anchorNode === focusNode && $isTextNode(anchorNode);
|
|
29
|
+
const originalFocusOffset = focusOffset;
|
|
30
|
+
const splitNode = $splitParagraphAtTextOffset(anchorElement, anchorNode, anchorOffset);
|
|
31
|
+
// After splitting at anchor, the focus node might have moved to a new paragraph
|
|
32
|
+
// Re-evaluate the focus element and node
|
|
33
|
+
let updatedFocusNode = selection.focus.getNode();
|
|
34
|
+
let updatedFocusOffset = selection.focus.offset;
|
|
35
|
+
// If anchor and focus were in the same node, we need to adjust the focus
|
|
36
|
+
// because splitText creates a new node for text after the split point
|
|
37
|
+
if (anchorAndFocusInSameNode &&
|
|
38
|
+
anchorOffset < originalFocusOffset &&
|
|
39
|
+
splitNode &&
|
|
40
|
+
$isTextNode(splitNode)) {
|
|
41
|
+
// The focus is now in the split node (the new node created by splitText)
|
|
42
|
+
// Calculate the adjusted offset
|
|
43
|
+
const adjustedOffset = originalFocusOffset - anchorOffset;
|
|
44
|
+
const splitNodeText = splitNode.getTextContent();
|
|
45
|
+
if (adjustedOffset >= 0 && adjustedOffset <= splitNodeText.length) {
|
|
46
|
+
updatedFocusNode = splitNode;
|
|
47
|
+
updatedFocusOffset = adjustedOffset;
|
|
48
|
+
// Update the selection to point to the correct node
|
|
49
|
+
selection.focus.set(updatedFocusNode.getKey(), updatedFocusOffset, 'text');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const updatedFocusElement = updatedFocusNode.getKey() === 'root'
|
|
53
|
+
? updatedFocusNode
|
|
54
|
+
: updatedFocusNode.getTopLevelElementOrThrow();
|
|
55
|
+
// Now split at focus using the updated references
|
|
56
|
+
if ($isParagraphNode(updatedFocusElement) &&
|
|
57
|
+
$isTextNode(updatedFocusNode)) {
|
|
58
|
+
const focusTextNodeSize = updatedFocusNode.getTextContentSize();
|
|
59
|
+
const paragraphChildren = updatedFocusElement.getChildren();
|
|
60
|
+
const focusNodeIndex = paragraphChildren.indexOf(updatedFocusNode);
|
|
61
|
+
const isFocusAtEndOfTextNode = updatedFocusOffset === focusTextNodeSize;
|
|
62
|
+
const hasNodesAfterFocus = focusNodeIndex < paragraphChildren.length - 1;
|
|
63
|
+
const shouldSplitAtFocus = updatedFocusOffset < focusTextNodeSize ||
|
|
64
|
+
(isFocusAtEndOfTextNode && hasNodesAfterFocus);
|
|
65
|
+
if (shouldSplitAtFocus) {
|
|
66
|
+
// Split at focus - this will move content after focus to a new paragraph
|
|
67
|
+
// The selected text (from 0 to updatedFocusOffset) remains in updatedFocusNode
|
|
68
|
+
$splitParagraphAtTextOffset(updatedFocusElement, updatedFocusNode, updatedFocusOffset);
|
|
69
|
+
// After splitting at focus, updatedFocusNode contains text from 0 to updatedFocusOffset
|
|
70
|
+
// This is the selected text - update the selection to point to it
|
|
71
|
+
if (anchorAndFocusInSameNode && $isTextNode(updatedFocusNode)) {
|
|
72
|
+
selection.anchor.set(updatedFocusNode.getKey(), 0, 'text');
|
|
73
|
+
selection.focus.set(updatedFocusNode.getKey(), updatedFocusOffset, 'text');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (anchorAndFocusInSameNode && $isTextNode(updatedFocusNode)) {
|
|
77
|
+
// No split at focus needed - selection already points to the right node
|
|
78
|
+
// Just ensure anchor is at the start
|
|
79
|
+
selection.anchor.set(updatedFocusNode.getKey(), 0, 'text');
|
|
80
|
+
selection.focus.set(updatedFocusNode.getKey(), updatedFocusOffset, 'text');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// If anchor and focus are in different elements, split both
|
|
87
|
+
if (anchorElement !== focusElement) {
|
|
88
|
+
if ($isParagraphNode(focusElement)) {
|
|
89
|
+
// Check if we need to split at focus
|
|
90
|
+
// We need to split if:
|
|
91
|
+
// 1. The focus offset is not at the end of the text node, OR
|
|
92
|
+
// 2. The focus node is not the last child of the paragraph (there's content after it)
|
|
93
|
+
const focusTextNodeSize = $isTextNode(focusNode)
|
|
94
|
+
? focusNode.getTextContentSize()
|
|
95
|
+
: 0;
|
|
96
|
+
const isFocusAtEndOfTextNode = focusOffset === focusTextNodeSize;
|
|
97
|
+
const isFocusLastChild = focusElement.getLastChild() === focusNode;
|
|
98
|
+
const shouldSplitAtFocus = focusOffset < focusTextNodeSize ||
|
|
99
|
+
(isFocusAtEndOfTextNode && !isFocusLastChild);
|
|
100
|
+
if (shouldSplitAtFocus &&
|
|
101
|
+
focusOffset <= focusElement.getTextContentSize()) {
|
|
102
|
+
$splitParagraphAtTextOffset(focusElement, focusNode, focusOffset);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Same element and we didn't split at anchor - we need to split at focus
|
|
108
|
+
if ($isParagraphNode(focusElement) && $isTextNode(focusNode)) {
|
|
109
|
+
// Check if we need to split at focus
|
|
110
|
+
// We split if there's content after the focus point
|
|
111
|
+
const focusTextNodeSize = focusNode.getTextContentSize();
|
|
112
|
+
const paragraphChildren = focusElement.getChildren();
|
|
113
|
+
const focusNodeIndex = paragraphChildren.indexOf(focusNode);
|
|
114
|
+
const isFocusAtEndOfTextNode = focusOffset === focusTextNodeSize;
|
|
115
|
+
const hasNodesAfterFocus = focusNodeIndex < paragraphChildren.length - 1;
|
|
116
|
+
const shouldSplitAtFocus = focusOffset < focusTextNodeSize ||
|
|
117
|
+
(isFocusAtEndOfTextNode && hasNodesAfterFocus);
|
|
118
|
+
if (shouldSplitAtFocus) {
|
|
119
|
+
$splitParagraphAtTextOffset(focusElement, focusNode, focusOffset);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Splits a paragraph node at a specific text offset.
|
|
126
|
+
* Creates a new paragraph node and moves the content after the offset to it.
|
|
127
|
+
*
|
|
128
|
+
* @param paragraph The paragraph element to split
|
|
129
|
+
* @param textNode The text node containing the offset
|
|
130
|
+
* @param offset The text offset at which to split
|
|
131
|
+
* @returns The first text node in the new paragraph (the split node), or null if no split occurred
|
|
132
|
+
*/
|
|
133
|
+
function $splitParagraphAtTextOffset(paragraph, textNode, offset) {
|
|
134
|
+
if (!$isParagraphNode(paragraph)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!$isTextNode(textNode)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const children = paragraph.getChildren();
|
|
141
|
+
const textNodeIndex = children.indexOf(textNode);
|
|
142
|
+
if (textNodeIndex === -1) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const textNodeSize = textNode.getTextContentSize();
|
|
146
|
+
// If offset is 0 and text node is not the first child, split before the text node
|
|
147
|
+
if (offset === 0 && textNodeIndex > 0) {
|
|
148
|
+
const newParagraph = $createParagraphNode();
|
|
149
|
+
// Move this text node and all subsequent nodes to the new paragraph
|
|
150
|
+
for (let i = textNodeIndex; i < children.length; i++) {
|
|
151
|
+
const node = children[i];
|
|
152
|
+
if (node) {
|
|
153
|
+
node.remove();
|
|
154
|
+
newParagraph.append(node);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
paragraph.insertAfter(newParagraph);
|
|
158
|
+
// Return the first text node in the new paragraph
|
|
159
|
+
const newParagraphChildren = newParagraph.getChildren();
|
|
160
|
+
return newParagraphChildren.find((node) => $isTextNode(node)) || null;
|
|
161
|
+
}
|
|
162
|
+
// If offset is at the end of the text node, we need to split after the text node
|
|
163
|
+
if (offset === textNodeSize) {
|
|
164
|
+
// Check if there are nodes after this text node
|
|
165
|
+
if (textNodeIndex < children.length - 1) {
|
|
166
|
+
const newParagraph = $createParagraphNode();
|
|
167
|
+
// Move all nodes after the text node to the new paragraph
|
|
168
|
+
for (let i = textNodeIndex + 1; i < children.length; i++) {
|
|
169
|
+
const node = children[i];
|
|
170
|
+
if (node) {
|
|
171
|
+
node.remove();
|
|
172
|
+
newParagraph.append(node);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
paragraph.insertAfter(newParagraph);
|
|
176
|
+
// Return the first text node in the new paragraph
|
|
177
|
+
const newParagraphChildren = newParagraph.getChildren();
|
|
178
|
+
return newParagraphChildren.find((node) => $isTextNode(node)) || null;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
// Split the text node if offset is within the text node
|
|
183
|
+
if (offset > 0 && offset < textNodeSize) {
|
|
184
|
+
const splitNodes = textNode.splitText(offset);
|
|
185
|
+
if (splitNodes.length === 0) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
// The split node is the first node in splitNodes (the part after the split)
|
|
189
|
+
const splitTextNode = splitNodes[0];
|
|
190
|
+
if (!splitTextNode) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
// Create a new paragraph and move all nodes after the split point to it
|
|
194
|
+
const newParagraph = $createParagraphNode();
|
|
195
|
+
// Get all nodes from the paragraph that should move to the new paragraph
|
|
196
|
+
const nodesToMove = [];
|
|
197
|
+
const updatedChildren = paragraph.getChildren();
|
|
198
|
+
// Find the first node that should be moved (the split text node)
|
|
199
|
+
for (let i = 0; i < updatedChildren.length; i++) {
|
|
200
|
+
const child = updatedChildren[i];
|
|
201
|
+
if (child === splitTextNode) {
|
|
202
|
+
// Found the split point - move this node and all subsequent nodes
|
|
203
|
+
for (let j = i; j < updatedChildren.length; j++) {
|
|
204
|
+
const nodeToMove = updatedChildren[j];
|
|
205
|
+
if (nodeToMove) {
|
|
206
|
+
nodesToMove.push(nodeToMove);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Move nodes to the new paragraph
|
|
213
|
+
if (nodesToMove.length > 0) {
|
|
214
|
+
nodesToMove.forEach((node) => {
|
|
215
|
+
node.remove();
|
|
216
|
+
newParagraph.append(node);
|
|
217
|
+
});
|
|
218
|
+
// Insert the new paragraph after the original paragraph
|
|
219
|
+
paragraph.insertAfter(newParagraph);
|
|
220
|
+
return splitTextNode;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface SyncPluginProps {
|
|
2
|
+
value: string;
|
|
3
|
+
initialValueRef: React.MutableRefObject<string>;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Plugin that syncs external value changes with the editor.
|
|
7
|
+
* Handles cases where the value prop changes from outside the editor.
|
|
8
|
+
*/
|
|
9
|
+
export declare function SyncPlugin({ value, initialValueRef }: SyncPluginProps): null;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=sync-plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-plugin.d.ts","sourceRoot":"","sources":["../../../src/lib/lexical/sync-plugin.tsx"],"names":[],"mappings":"AAWA,UAAU,eAAe;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,eAAe,EAAE,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAA;CAChD;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,EAAE,KAAK,EAAE,eAAe,EAAE,EAAE,eAAe,GAAG,IAAI,CAsC5E"}
|