Skip to content

Commit 4c1c95f

Browse files
committed
Implement greedy line breaking for hard-wrap across logical paragraphs
The rich text editor now uses a greedy line breaking algorithm that properly cascades rewrapping across logical paragraphs. Previously, when typing in the middle of a line that exceeded maxLength, only the single current ParagraphNode was rewrapped. This caused incorrect behavior: the word after the cursor would wrap to the next line (instead of the overflowing word at the end), and continuation lines in the same logical paragraph would not be rewrapped. The updated implementation: - Collects all ParagraphNodes belonging to the same logical paragraph (until hitting boundaries like empty lines, bullets, code blocks, or different indentation) - Combines their text and applies greedy line breaking to rewrap the entire logical paragraph - Moves overflowing words to subsequent lines and cascades the rewrap through all affected lines - Preserves proper indentation, bullet formatting, and cursor position Tests verify the cascading rewrap behavior, including overflow in the middle of a line and rewrapping across multiple existing paragraphs. Simplify HardWrapPlugin logic and remove redundant caching Refactor HardWrapPlugin.svelte to streamline node processing by consolidating early-return checks and removing unnecessary variable indirection. Eliminated unused lastCheckedLine/lastCheckedResult caching and updated isInCodeBlock to accept a node object, use optional chaining, and return early when parent depth is excessive. These changes simplify control flow, reduce repeated lookups, and make the code clearer and safer when traversing parent chains. Refactor paragraph wrapping into helper functions Break out paragraph wrapping logic into smaller helper functions to improve readability and maintainability. The change consolidates paragraph collection, text combination, wrapping, DOM updates, and cursor repositioning into separate functions (collectLogicalParagraph, combineLogicalParagraphText, wrapCombinedText, updateParagraphsWithWrappedLines, repositionCursor), and simplifies isLogicalParagraphBoundary. wrapIfNecessary was rewritten to use these helpers and to short-circuit early for common no-op cases.
1 parent 6baa6f9 commit 4c1c95f

File tree

3 files changed

+377
-154
lines changed

3 files changed

+377
-154
lines changed

packages/ui/src/lib/richText/linewrap.ts

Lines changed: 172 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
242302
export function wrapAll(editor: LexicalEditor, maxLength: number) {
243303
editor.update(
244304
() => {

0 commit comments

Comments
 (0)