@@ -102,143 +102,203 @@ export function parseBullet(text: string): Bullet | undefined {
102102 return { prefix, indent, number } ;
103103}
104104
105- export function wrapIfNecessary ( { node, maxLength } : { node : TextNode ; maxLength : number } ) {
106- const paragraph = node . getParent ( ) ;
105+ /**
106+ * Checks if a paragraph is the start of a new logical paragraph.
107+ * A new logical paragraph begins when:
108+ * - The line is empty
109+ * - The line starts with a bullet point
110+ * - The line has different indentation
111+ * - The line is wrapping-exempt (code blocks, headings, etc.)
112+ */
113+ function isLogicalParagraphBoundary ( para : ParagraphNode , previousIndent : string ) : boolean {
114+ const text = para . getTextContent ( ) ;
107115
108- if ( ! $isParagraphNode ( paragraph ) ) {
109- console . warn ( '[wrapIfNecessary] Node parent is not a paragraph:' , paragraph ?. getType ( ) ) ;
110- return ;
111- }
116+ if ( ! text . trim ( ) ) return true ; // Empty line
117+ if ( parseBullet ( text ) ) return true ; // Bullet point
118+ if ( isWrappingExempt ( text ) ) return true ; // Code blocks, headings, etc.
119+ if ( parseIndent ( text ) !== previousIndent ) return true ; // Different indentation
112120
113- // Get the full text content from the paragraph, not just the mutated text node
114- // This is important when typing in the middle of text, as Lexical may split text nodes
115- const line = paragraph . getTextContent ( ) ;
121+ return false ;
122+ }
116123
117- if ( line . length <= maxLength ) {
118- return ;
119- }
120- if ( line . indexOf ( ' ' ) === - 1 ) {
121- return ; // No spaces to wrap at
124+ /**
125+ * Collects all paragraphs that belong to the same logical paragraph.
126+ */
127+ function collectLogicalParagraph ( paragraph : ParagraphNode , indent : string ) : ParagraphNode [ ] {
128+ const paragraphs : ParagraphNode [ ] = [ paragraph ] ;
129+ let nextSibling = paragraph . getNextSibling ( ) ;
130+
131+ while ( nextSibling && $isParagraphNode ( nextSibling ) ) {
132+ if ( isLogicalParagraphBoundary ( nextSibling , indent ) ) break ;
133+ paragraphs . push ( nextSibling ) ;
134+ nextSibling = nextSibling . getNextSibling ( ) ;
122135 }
123- if ( isWrappingExempt ( line ) ) {
124- return ; // Line contains text that should not be wrapped.
136+
137+ return paragraphs ;
138+ }
139+
140+ /**
141+ * Combines text from all paragraphs in a logical paragraph.
142+ */
143+ function combineLogicalParagraphText (
144+ paragraphs : ParagraphNode [ ] ,
145+ indent : string ,
146+ firstLineText : string
147+ ) : string {
148+ let combined = firstLineText ;
149+
150+ for ( let i = 1 ; i < paragraphs . length ; i ++ ) {
151+ const text = paragraphs [ i ] . getTextContent ( ) ;
152+ const textWithoutIndent = text . startsWith ( indent ) ? text . substring ( indent . length ) : text ;
153+ combined += ' ' + textWithoutIndent ;
125154 }
126155
127- const bullet = parseBullet ( line ) ;
128- const indent = bullet ? bullet . indent : parseIndent ( line ) ;
156+ return combined ;
157+ }
129158
130- const selection = getSelection ( ) ;
131- const selectionOffset = isRangeSelection ( selection ) ? selection . focus . offset : 0 ;
159+ /**
160+ * Wraps combined text into multiple lines respecting maxLength.
161+ */
162+ function wrapCombinedText (
163+ combinedText : string ,
164+ maxLength : number ,
165+ indent : string ,
166+ bullet : Bullet | undefined
167+ ) : string [ ] {
168+ const wrappedLines : string [ ] = [ ] ;
169+ let remainder = combinedText ;
170+ let isFirstLine = true ;
171+
172+ while ( remainder . length > 0 ) {
173+ const lineToWrap = isFirstLine ? remainder : indent + remainder ;
174+ const { newLine, newRemainder } = wrapLine ( {
175+ line : lineToWrap ,
176+ maxLength,
177+ indent : isFirstLine ? '' : indent ,
178+ bullet : isFirstLine ? bullet : undefined
179+ } ) ;
180+
181+ wrappedLines . push ( newLine ) ;
182+ remainder = newRemainder ;
183+ isFirstLine = false ;
184+ }
185+
186+ return wrappedLines ;
187+ }
132188
133- // Wrap only the current line - don't collect other paragraphs
134- const { newLine, newRemainder } = wrapLine ( {
135- line,
136- maxLength,
137- indent,
138- bullet
139- } ) ;
189+ /**
190+ * Updates the DOM by replacing old paragraphs with wrapped lines.
191+ */
192+ function updateParagraphsWithWrappedLines (
193+ paragraph : ParagraphNode ,
194+ paragraphsToRemove : ParagraphNode [ ] ,
195+ wrappedLines : string [ ]
196+ ) : void {
197+ // Remove old continuation paragraphs
198+ for ( let i = 1 ; i < paragraphsToRemove . length ; i ++ ) {
199+ paragraphsToRemove [ i ] . remove ( ) ;
200+ }
140201
141- // Replace all text nodes in the paragraph with a single text node containing the wrapped text
142- // This is important because Lexical may have split the text into multiple nodes during typing
202+ // Update the first paragraph with the first wrapped line
143203 const children = paragraph . getChildren ( ) ;
144204 const firstTextNode = children . find ( ( child ) => isTextNode ( child ) ) as TextNode | undefined ;
145205
146206 if ( firstTextNode ) {
147- // Update the first text node with the new content
148- firstTextNode . setTextContent ( newLine ) ;
207+ firstTextNode . setTextContent ( wrappedLines [ 0 ] ) ;
149208 // Remove all other children
150- for ( const child of children ) {
151- if ( child !== firstTextNode ) {
152- child . remove ( ) ;
153- }
154- }
209+ children . forEach ( ( child ) => {
210+ if ( child !== firstTextNode ) child . remove ( ) ;
211+ } ) ;
155212 } else {
156213 // Fallback: no text nodes found, create one
157- const newTextNode = new TextNode ( newLine ) ;
158- paragraph . append ( newTextNode ) ;
214+ paragraph . append ( new TextNode ( wrappedLines [ 0 ] ) ) ;
159215 }
160216
161- // Get reference to the text node we'll use for cursor positioning
162- const currentTextNode = firstTextNode || ( paragraph . getFirstChild ( ) as TextNode ) ;
163-
164- // If there's a remainder, create new paragraphs for it
165- if ( newRemainder ) {
166- let remainder = newRemainder ;
167- let lastParagraph = paragraph ;
168-
169- // Create new paragraphs for the wrapped text
170- while ( remainder && remainder . length > 0 ) {
171- // Prepend indent to the remainder before wrapping it
172- const indentedLine = indent + remainder ;
173- const { newLine : finalLine , newRemainder : finalRem } = wrapLine ( {
174- line : indentedLine ,
175- maxLength,
176- indent
177- } ) ;
178-
179- const newParagraph = new ParagraphNode ( ) ;
180- const newTextNode = new TextNode ( finalLine ) ;
181- newParagraph . append ( newTextNode ) ;
182- lastParagraph . insertAfter ( newParagraph ) ;
183-
184- lastParagraph = newParagraph ;
185- remainder = finalRem ;
217+ // Create new paragraphs for additional wrapped lines
218+ let lastParagraph = paragraph ;
219+ for ( let i = 1 ; i < wrappedLines . length ; i ++ ) {
220+ const newParagraph = new ParagraphNode ( ) ;
221+ newParagraph . append ( new TextNode ( wrappedLines [ i ] ) ) ;
222+ lastParagraph . insertAfter ( newParagraph ) ;
223+ lastParagraph = newParagraph ;
224+ }
225+ }
226+
227+ /**
228+ * Repositions the cursor to the appropriate location after wrapping.
229+ */
230+ function repositionCursor (
231+ paragraph : ParagraphNode ,
232+ wrappedLines : string [ ] ,
233+ selectionOffset : number
234+ ) : void {
235+ const firstTextNode = paragraph . getFirstChild ( ) ;
236+ if ( ! isTextNode ( firstTextNode ) ) return ;
237+
238+ let remainingOffset = selectionOffset ;
239+ let targetLineIndex = 0 ;
240+
241+ // Find which line the cursor should be on
242+ for ( let i = 0 ; i < wrappedLines . length ; i ++ ) {
243+ if ( remainingOffset <= wrappedLines [ i ] . length ) {
244+ targetLineIndex = i ;
245+ break ;
186246 }
247+ remainingOffset -= wrappedLines [ i ] . length + 1 ; // +1 for space between lines
248+ }
187249
188- // Try to maintain cursor position
189- // Calculate which paragraph the cursor should end up in
190- let remainingOffset = selectionOffset ;
191-
192- // If cursor was in the first line
193- if ( remainingOffset <= newLine . length ) {
194- // Keep cursor in the current paragraph at the same position
195- currentTextNode . select ( remainingOffset , remainingOffset ) ;
196- } else {
197- // Cursor should be in one of the wrapped paragraphs
198- remainingOffset -= newLine . length + 1 ; // Account for the line and space
199-
200- // Walk through the created paragraphs to find where cursor belongs
201- let currentPara : ParagraphNode | null = paragraph . getNextSibling ( ) as ParagraphNode | null ;
202- let tempRemainder = newRemainder ;
203-
204- // Calculate all the wrapped lines to find cursor position
205- while ( tempRemainder && tempRemainder . length > 0 ) {
206- const indentedLine = indent + tempRemainder ;
207- const { newLine : tempLine , newRemainder : tempRem } = wrapLine ( {
208- line : indentedLine ,
209- maxLength,
210- indent
211- } ) ;
212-
213- // tempLine now includes the indent, so just check against its length
214- if ( remainingOffset <= tempLine . length ) {
215- // Cursor belongs in this line
216- break ;
217- }
218- remainingOffset -= tempLine . length + 1 ; // +1 for space between lines
219- tempRemainder = tempRem ;
220- currentPara = currentPara ?. getNextSibling ( ) as ParagraphNode | null ;
221- }
250+ // Set cursor in the appropriate paragraph
251+ if ( targetLineIndex === 0 ) {
252+ firstTextNode . select ( Math . max ( 0 , remainingOffset ) , Math . max ( 0 , remainingOffset ) ) ;
253+ return ;
254+ }
222255
223- // Set cursor in the appropriate paragraph
224- if ( currentPara && $isParagraphNode ( currentPara ) ) {
225- const textNode = currentPara . getFirstChild ( ) ;
226- if ( isTextNode ( textNode ) ) {
227- textNode . select ( Math . max ( 0 , remainingOffset ) , Math . max ( 0 , remainingOffset ) ) ;
228- }
229- } else {
230- // Fallback: put cursor at end of last created paragraph
231- if ( lastParagraph ) {
232- const textNode = lastParagraph . getFirstChild ( ) ;
233- if ( isTextNode ( textNode ) ) {
234- textNode . selectEnd ( ) ;
235- }
236- }
237- }
256+ // Navigate to the target paragraph
257+ let currentPara : ParagraphNode | null = paragraph . getNextSibling ( ) as ParagraphNode | null ;
258+ for ( let i = 1 ; i < targetLineIndex && currentPara ; i ++ ) {
259+ currentPara = currentPara . getNextSibling ( ) as ParagraphNode | null ;
260+ }
261+
262+ if ( currentPara && $isParagraphNode ( currentPara ) ) {
263+ const textNode = currentPara . getFirstChild ( ) ;
264+ if ( isTextNode ( textNode ) ) {
265+ textNode . select ( Math . max ( 0 , remainingOffset ) , Math . max ( 0 , remainingOffset ) ) ;
238266 }
239267 }
240268}
241269
270+ export function wrapIfNecessary ( { node, maxLength } : { node : TextNode ; maxLength : number } ) {
271+ const paragraph = node . getParent ( ) ;
272+
273+ if ( ! $isParagraphNode ( paragraph ) ) {
274+ console . warn ( '[wrapIfNecessary] Node parent is not a paragraph:' , paragraph ?. getType ( ) ) ;
275+ return ;
276+ }
277+
278+ const line = paragraph . getTextContent ( ) ;
279+
280+ // Early returns for cases where wrapping isn't needed
281+ if ( line . length <= maxLength || ! line . includes ( ' ' ) || isWrappingExempt ( line ) ) {
282+ return ;
283+ }
284+
285+ const bullet = parseBullet ( line ) ;
286+ const indent = bullet ? bullet . indent : parseIndent ( line ) ;
287+ const selection = getSelection ( ) ;
288+ const selectionOffset = isRangeSelection ( selection ) ? selection . focus . offset : 0 ;
289+
290+ // Collect, combine, and wrap the logical paragraph
291+ const paragraphsToRewrap = collectLogicalParagraph ( paragraph , indent ) ;
292+ const combinedText = combineLogicalParagraphText ( paragraphsToRewrap , indent , line ) ;
293+ const wrappedLines = wrapCombinedText ( combinedText , maxLength , indent , bullet ) ;
294+
295+ // Update the DOM with wrapped lines
296+ updateParagraphsWithWrappedLines ( paragraph , paragraphsToRewrap , wrappedLines ) ;
297+
298+ // Restore cursor position
299+ repositionCursor ( paragraph , wrappedLines , selectionOffset ) ;
300+ }
301+
242302export function wrapAll ( editor : LexicalEditor , maxLength : number ) {
243303 editor . update (
244304 ( ) => {
0 commit comments