Skip to content

Commit 12a32b6

Browse files
committed
Fix Safari bug preventing multiple empty paragraphs on Enter
Safari/WebKit was not creating multiple empty paragraphs when pressing Enter repeatedly at the end of text. The IndentPlugin's handleEnter function only handled text nodes, but Safari can have the selection directly on an empty paragraph node. Updated handleEnter to handle both TextNode and ParagraphNode selections, ensuring consistent behavior across browsers. Added Playwright component test to verify the fix and enabled WebKit testing in CI to prevent regressions. Changes: - IndentPlugin: Handle empty paragraph nodes in addition to text nodes - Added browser-agnostic unit test using KEY_ENTER_COMMAND dispatch - Added Playwright test "should allow adding multiple newlines at end" - Enabled WebKit in playwright-ct.config.ts projects - Updated playwright:install to include webkit browser
1 parent 76c68eb commit 12a32b6

File tree

5 files changed

+134
-49
lines changed

5 files changed

+134
-49
lines changed

packages/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"test:watch": "vitest --watch --mode development",
2727
"test:ct": "playwright test -c playwright-ct.config.ts",
2828
"test:ct:ui": "playwright test -c playwright-ct.config.ts --ui",
29-
"playwright:install": "playwright install --with-deps chromium"
29+
"playwright:install": "playwright install --with-deps chromium webkit"
3030
},
3131
"devDependencies": {
3232
"@codemirror/lang-cpp": "^6.0.3",

packages/ui/playwright-ct.config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ export default defineConfig({
3636
}
3737
}
3838
},
39-
40-
/* Configure projects for major browsers */
4139
projects: [
4240
{
4341
name: 'chromium',
4442
use: { ...devices['Desktop Chrome'] }
43+
},
44+
{
45+
name: 'webkit',
46+
use: { ...devices['Desktop Safari'] }
4547
}
4648
]
4749
});

packages/ui/src/lib/richText/plugins/HardWrapPlugin.test.ts

Lines changed: 58 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import { wrapIfNecessary } from '$lib/richText/linewrap';
2-
import { createEditor, ParagraphNode, type LexicalEditor } from 'lexical';
2+
import { handleEnter } from '$lib/richText/plugins/IndentPlugin.svelte';
3+
import {
4+
createEditor,
5+
TextNode,
6+
type LexicalEditor,
7+
COMMAND_PRIORITY_CRITICAL,
8+
KEY_ENTER_COMMAND,
9+
type NodeKey,
10+
type NodeMutation
11+
} from 'lexical';
312
import {
413
$createParagraphNode as createParagraphNode,
514
$createTextNode as createTextNode,
615
$getRoot as getRoot,
716
$getNodeByKey as getNodeByKey,
817
$getSelection as getSelection,
918
$isRangeSelection as isRangeSelection,
10-
type TextNode
19+
$isTextNode as isTextNode,
20+
type TextNode as LexicalTextNode
1121
} from 'lexical';
1222
import { describe, it, expect, beforeEach } from 'vitest';
1323

@@ -176,7 +186,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
176186
});
177187

178188
editor.update(() => {
179-
const node = getNodeByKey(textNodeKey) as TextNode;
189+
const node = getNodeByKey(textNodeKey) as LexicalTextNode;
180190
wrapIfNecessary({ node, maxLength: 20 });
181191
});
182192

@@ -209,7 +219,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
209219
});
210220

211221
editor.update(() => {
212-
const node = getNodeByKey(textNodeKey) as TextNode;
222+
const node = getNodeByKey(textNodeKey) as LexicalTextNode;
213223
wrapIfNecessary({ node, maxLength: 25 });
214224
});
215225

@@ -243,7 +253,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
243253
});
244254

245255
editor.update(() => {
246-
const node = getNodeByKey(textNodeKey) as TextNode;
256+
const node = getNodeByKey(textNodeKey) as LexicalTextNode;
247257
wrapIfNecessary({ node, maxLength: 20 });
248258
});
249259

@@ -282,7 +292,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
282292
root.append(para3);
283293

284294
// Now edit the first paragraph to make it longer
285-
const textNode = para1.getFirstChild() as TextNode;
295+
const textNode = para1.getFirstChild() as LexicalTextNode;
286296
textNode.setTextContent('This is the first line that has been made much longer');
287297

288298
// Trigger rewrap
@@ -323,7 +333,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
323333
});
324334

325335
editor.update(() => {
326-
const node = getNodeByKey(textNodeKey) as TextNode;
336+
const node = getNodeByKey(textNodeKey) as LexicalTextNode;
327337
// Simulate adding text by modifying the content
328338
node.setTextContent(
329339
'This is a very long line that has not been wrapped yet and definitely needs to be wrapped now'
@@ -370,7 +380,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
370380
root.append(para2);
371381

372382
// Trigger wrap on first paragraph
373-
const textNode = para1.getFirstChild() as TextNode;
383+
const textNode = para1.getFirstChild() as LexicalTextNode;
374384
wrapIfNecessary({ node: textNode, maxLength: 20 });
375385
});
376386

@@ -401,7 +411,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
401411
root.append(para2);
402412

403413
// Trigger wrap on first paragraph
404-
const textNode = para1.getFirstChild() as TextNode;
414+
const textNode = para1.getFirstChild() as LexicalTextNode;
405415
wrapIfNecessary({ node: textNode, maxLength: 20 });
406416
});
407417

@@ -435,7 +445,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
435445
root.append(para3);
436446

437447
// Trigger wrap on first paragraph
438-
const textNode = para1.getFirstChild() as TextNode;
448+
const textNode = para1.getFirstChild() as LexicalTextNode;
439449
wrapIfNecessary({ node: textNode, maxLength: 20 });
440450
});
441451

@@ -466,7 +476,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
466476
});
467477

468478
editor.update(() => {
469-
const node = getNodeByKey(textNodeKey) as TextNode;
479+
const node = getNodeByKey(textNodeKey) as LexicalTextNode;
470480
// Simulate typing " a word" in the middle, making it overflow
471481
node.setTextContent('This a word is exactly at');
472482

@@ -514,7 +524,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
514524
root.append(para3);
515525

516526
// Now edit the first paragraph to add more content
517-
const textNode = para1.getFirstChild() as TextNode;
527+
const textNode = para1.getFirstChild() as LexicalTextNode;
518528
textNode.setTextContent('First line text that has been extended');
519529

520530
// Trigger rewrap
@@ -557,7 +567,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
557567
});
558568

559569
editor.update(() => {
560-
const node = getNodeByKey(textNodeKey) as TextNode;
570+
const node = getNodeByKey(textNodeKey) as LexicalTextNode;
561571
// Simulate typing "inserted " in the middle, making it overflow
562572
node.setTextContent('- First inserted second third');
563573

@@ -606,7 +616,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
606616
root.append(para3);
607617

608618
// Now edit the first line to make it much longer
609-
const textNode = para1.getFirstChild() as TextNode;
619+
const textNode = para1.getFirstChild() as LexicalTextNode;
610620
textNode.setTextContent('- Short bullet that has been made significantly longer');
611621

612622
// Trigger rewrap
@@ -664,7 +674,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
664674
root.append(emptyPara2);
665675

666676
// Trigger wrap on the first paragraph (should not affect empty paragraphs)
667-
const textNode = para1.getFirstChild() as TextNode;
677+
const textNode = para1.getFirstChild() as LexicalTextNode;
668678
wrapIfNecessary({ node: textNode, maxLength: 72 });
669679
});
670680

@@ -685,6 +695,31 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
685695
});
686696

687697
it('should allow adding multiple newlines at the end by typing', () => {
698+
const maxLength = 72;
699+
700+
// Register the IndentPlugin's Enter handler (simulating real editor behavior)
701+
editor.registerCommand(KEY_ENTER_COMMAND, handleEnter, COMMAND_PRIORITY_CRITICAL);
702+
703+
// Register the HardWrapPlugin's mutation listener (simulating the plugin being active)
704+
editor.registerMutationListener(TextNode, (nodes: Map<NodeKey, NodeMutation>) => {
705+
editor.update(
706+
() => {
707+
for (const [key, type] of nodes.entries()) {
708+
if (type !== 'updated') continue;
709+
710+
const node = getNodeByKey(key);
711+
if (!node || !isTextNode(node)) continue;
712+
713+
wrapIfNecessary({ node, maxLength });
714+
}
715+
},
716+
{
717+
tag: 'history-merge'
718+
}
719+
);
720+
});
721+
722+
// Set up initial state
688723
editor.update(() => {
689724
const root = getRoot();
690725
const paragraph = createParagraphNode();
@@ -695,38 +730,22 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
695730
textNode.select(9, 9);
696731
});
697732

698-
// Add an empty paragraph at the end
699-
editor.update(() => {
700-
const root = getRoot();
701-
const emptyPara = createParagraphNode();
702-
emptyPara.append(createTextNode(''));
703-
root.append(emptyPara);
704-
});
705-
706-
// Add another empty paragraph at the end
733+
// Simulate pressing Enter (like a user would)
707734
editor.update(() => {
708-
const root = getRoot();
709-
const emptyPara = createParagraphNode();
710-
emptyPara.append(createTextNode(''));
711-
root.append(emptyPara);
735+
editor.dispatchCommand(KEY_ENTER_COMMAND, null);
712736
});
713737

714-
// Now trigger wrapping on an empty paragraph (simulating typing in the empty area)
738+
// Press Enter again
715739
editor.update(() => {
716-
const root = getRoot();
717-
const children = root.getChildren();
718-
const lastPara = children[children.length - 1] as ParagraphNode;
719-
const textNode = lastPara.getFirstChild() as TextNode;
720-
if (textNode) {
721-
wrapIfNecessary({ node: textNode, maxLength: 72 });
722-
}
740+
editor.dispatchCommand(KEY_ENTER_COMMAND, null);
723741
});
724742

743+
// Verify the result
725744
editor.read(() => {
726745
const root = getRoot();
727746
const children = root.getChildren();
728747

729-
// Should still have all paragraphs
748+
// Should have 3 paragraphs total
730749
expect(children.length).toBe(3);
731750

732751
// First paragraph should have text
@@ -756,7 +775,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
756775
root.append(emptyPara2);
757776

758777
// Now type something in the last empty paragraph
759-
const lastTextNode = emptyPara2.getFirstChild() as TextNode;
778+
const lastTextNode = emptyPara2.getFirstChild() as LexicalTextNode;
760779
lastTextNode.setTextContent('x');
761780

762781
// Trigger wrapping on the last paragraph (this simulates what happens when typing)
@@ -795,7 +814,7 @@ describe('HardWrapPlugin with multi-paragraph structure', () => {
795814
root.append(emptyPara2);
796815

797816
// Now add more text to the first paragraph
798-
const firstTextNode = para1.getFirstChild() as TextNode;
817+
const firstTextNode = para1.getFirstChild() as LexicalTextNode;
799818
firstTextNode.setTextContent('Some text with more content added');
800819

801820
// Trigger wrapping on the first paragraph (this simulates what happens when typing)

packages/ui/src/lib/richText/plugins/IndentPlugin.svelte

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,32 @@ This component overrides enter key command to handle indentation and bullets in
1515
const anchor = selection.anchor;
1616
const node = anchor.getNode();
1717
18-
// Only handle if we're in a text node
19-
if (!isTextNode(node)) return false;
18+
// Handle both text nodes and paragraph nodes (for empty paragraphs)
19+
let parent;
20+
let textNode;
21+
let offset = anchor.offset;
22+
23+
if (isTextNode(node)) {
24+
textNode = node;
25+
parent = node.getParent();
26+
} else if (isParagraphNode(node)) {
27+
// Selection is directly on paragraph (empty paragraph case)
28+
parent = node;
29+
textNode = parent.getFirstChild();
30+
// If paragraph has no text node, create one
31+
if (!textNode || !isTextNode(textNode)) {
32+
textNode = createTextNode('');
33+
parent.append(textNode);
34+
offset = 0;
35+
}
36+
} else {
37+
return false;
38+
}
2039
21-
// Get the parent paragraph
22-
const parent = node.getParent();
2340
if (!isParagraphNode(parent)) return false;
2441
2542
// Get the current paragraph text
2643
const currentLineText = parent.getTextContent();
27-
const offset = anchor.offset;
2844
2945
// Parse indentation and bullets from current line
3046
const indent = parseIndent(currentLineText);
@@ -54,12 +70,12 @@ This component overrides enter key command to handle indentation and bullets in
5470
}
5571
5672
// Split the paragraph at the cursor position
57-
const textContent = node.getTextContent();
73+
const textContent = textNode.getTextContent();
5874
const textAfterCursor = textContent.substring(offset);
5975
const textBeforeCursor = textContent.substring(0, offset);
6076
6177
// Update current node's text to everything before cursor
62-
node.setTextContent(textBeforeCursor);
78+
textNode.setTextContent(textBeforeCursor);
6379
6480
// Create new paragraph with indented text
6581
const newParagraph = createParagraphNode();

packages/ui/tests/HardWrapPlugin.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,4 +287,52 @@ test.describe('HardWrapPlugin', () => {
287287
const paragraphCount = await getParagraphCount(component);
288288
expect(paragraphCount).toBeGreaterThan(1);
289289
});
290+
291+
test('should allow adding multiple newlines at the end by pressing Enter', async ({
292+
mount,
293+
page
294+
}) => {
295+
const component = await mount(HardWrapPluginTestWrapper, {
296+
props: {
297+
maxLength: 72,
298+
enabled: true,
299+
initialText: 'Some text'
300+
}
301+
});
302+
303+
// Wait for initial render
304+
await waitForTextContent(component, 'Some text');
305+
await waitForParagraphCount(component, 1);
306+
307+
// Focus the editor
308+
await component.getByTestId('focus-button').click();
309+
310+
// Click at the end of the text
311+
const editorWrapper = component.getByTestId('editor-wrapper');
312+
const contentEditable = editorWrapper.locator('[contenteditable="true"]').first();
313+
await contentEditable.click();
314+
315+
// Move cursor to end
316+
await page.keyboard.press('End');
317+
318+
// Press Enter to create first empty paragraph
319+
await page.keyboard.press('Enter');
320+
321+
// Wait for paragraph count to increase
322+
await waitForParagraphCount(component, 2);
323+
324+
// Press Enter again to create second empty paragraph
325+
await page.keyboard.press('Enter');
326+
327+
// Wait for paragraph count to increase
328+
await waitForParagraphCount(component, 3);
329+
330+
// Verify we have 3 paragraphs
331+
const paragraphCount = await getParagraphCount(component);
332+
expect(paragraphCount).toBe(3);
333+
334+
// Verify the first paragraph still has text
335+
const text = await getTextContent(component);
336+
expect(text).toContain('Some text');
337+
});
290338
});

0 commit comments

Comments
 (0)