diff --git a/.maestro/enrichedInput/flows/list_enter_at_item_start.yaml b/.maestro/enrichedInput/flows/list_enter_at_item_start.yaml new file mode 100644 index 000000000..9d210d055 --- /dev/null +++ b/.maestro/enrichedInput/flows/list_enter_at_item_start.yaml @@ -0,0 +1,30 @@ +appId: swmansion.enriched.example +--- +# Issue #810 - Enter at the start of a populated list item splits it instead of ending the list +# Android only: every Android list item starts with a ZWS, which made it look like an empty item. +# The caret is moved with D-pad keys, which Maestro supports only on Android. +- launchApp + +- tapOn: + id: "toggle-screen-button" + +- tapOn: + id: "editor-input" + +- tapOn: + id: "toolbar-ordered-list" + +- runFlow: + when: + platform: Android + commands: + - inputText: "Item" + - pressKey: Remote Dpad Left + - pressKey: Remote Dpad Left + - pressKey: Remote Dpad Left + - pressKey: Remote Dpad Left + - pressKey: Enter + - runFlow: + file: "../subflows/capture_or_assert_screenshot.yaml" + env: + SCREENSHOT_NAME: "list_enter_at_item_start" diff --git a/.maestro/enrichedInput/flows/scrolling_with_paragraph_styles.yaml b/.maestro/enrichedInput/flows/scrolling_with_paragraph_styles.yaml index 96e987fe1..ec8cc69a6 100644 --- a/.maestro/enrichedInput/flows/scrolling_with_paragraph_styles.yaml +++ b/.maestro/enrichedInput/flows/scrolling_with_paragraph_styles.yaml @@ -38,7 +38,6 @@ appId: swmansion.enriched.example - pressKey: Enter - inputText: "Numbered item three" - pressKey: Enter -- pressKey: Enter - pressKey: Backspace diff --git a/.maestro/enrichedInput/screenshots/android/list_enter_at_item_start.png b/.maestro/enrichedInput/screenshots/android/list_enter_at_item_start.png new file mode 100644 index 000000000..01b1a91f2 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/list_enter_at_item_start.png differ diff --git a/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png index 9c2dd9795..e05aed35c 100644 Binary files a/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png and b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_bottom.png differ diff --git a/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png index 3b946339f..c13fbd9a5 100644 Binary files a/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png and b/.maestro/enrichedInput/screenshots/android/scrolling_paragraph_styles_top.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png index 96087f395..3f5d42cc1 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png and b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_bottom.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png index 241461ace..82e016e50 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png and b/.maestro/enrichedInput/screenshots/ios/scrolling_paragraph_styles_top.png differ diff --git a/.playwright/screenshots/list-keyboard-enter-bullet.png b/.playwright/screenshots/list-keyboard-enter-bullet.png index 95f214da6..ecda6118e 100644 Binary files a/.playwright/screenshots/list-keyboard-enter-bullet.png and b/.playwright/screenshots/list-keyboard-enter-bullet.png differ diff --git a/.playwright/screenshots/list-keyboard-enter-causes-scrolling-bullet.png b/.playwright/screenshots/list-keyboard-enter-causes-scrolling-bullet.png index 162a4693b..ea7dbd598 100644 Binary files a/.playwright/screenshots/list-keyboard-enter-causes-scrolling-bullet.png and b/.playwright/screenshots/list-keyboard-enter-causes-scrolling-bullet.png differ diff --git a/.playwright/screenshots/list-keyboard-enter-causes-scrolling-checkbox.png b/.playwright/screenshots/list-keyboard-enter-causes-scrolling-checkbox.png index 4e0d1f0e2..5729c6a55 100644 Binary files a/.playwright/screenshots/list-keyboard-enter-causes-scrolling-checkbox.png and b/.playwright/screenshots/list-keyboard-enter-causes-scrolling-checkbox.png differ diff --git a/.playwright/screenshots/list-keyboard-enter-causes-scrolling-ordered.png b/.playwright/screenshots/list-keyboard-enter-causes-scrolling-ordered.png index 483791ce6..d75a56739 100644 Binary files a/.playwright/screenshots/list-keyboard-enter-causes-scrolling-ordered.png and b/.playwright/screenshots/list-keyboard-enter-causes-scrolling-ordered.png differ diff --git a/.playwright/screenshots/list-keyboard-enter-checkbox.png b/.playwright/screenshots/list-keyboard-enter-checkbox.png index 380929782..20cc8f5d9 100644 Binary files a/.playwright/screenshots/list-keyboard-enter-checkbox.png and b/.playwright/screenshots/list-keyboard-enter-checkbox.png differ diff --git a/.playwright/screenshots/list-keyboard-enter-exit-bullet.png b/.playwright/screenshots/list-keyboard-enter-exit-bullet.png new file mode 100644 index 000000000..b12ce6ae8 Binary files /dev/null and b/.playwright/screenshots/list-keyboard-enter-exit-bullet.png differ diff --git a/.playwright/screenshots/list-keyboard-enter-exit-checkbox.png b/.playwright/screenshots/list-keyboard-enter-exit-checkbox.png new file mode 100644 index 000000000..9a306d035 Binary files /dev/null and b/.playwright/screenshots/list-keyboard-enter-exit-checkbox.png differ diff --git a/.playwright/screenshots/list-keyboard-enter-exit-ordered.png b/.playwright/screenshots/list-keyboard-enter-exit-ordered.png new file mode 100644 index 000000000..4188a68e9 Binary files /dev/null and b/.playwright/screenshots/list-keyboard-enter-exit-ordered.png differ diff --git a/.playwright/screenshots/list-keyboard-enter-ordered.png b/.playwright/screenshots/list-keyboard-enter-ordered.png index 70fc97c73..faa32c622 100644 Binary files a/.playwright/screenshots/list-keyboard-enter-ordered.png and b/.playwright/screenshots/list-keyboard-enter-ordered.png differ diff --git a/.playwright/tests/listKeyboard.spec.ts b/.playwright/tests/listKeyboard.spec.ts index 4787da0d4..2b22793fe 100644 --- a/.playwright/tests/listKeyboard.spec.ts +++ b/.playwright/tests/listKeyboard.spec.ts @@ -53,7 +53,9 @@ for (const { await gotoVisualRegression(page); }); - test('Enter extends list', async ({ page }) => { + test('Enter extends list, Enter on empty item ends it', async ({ + page, + }) => { const editor = editorLocator(page); const wrapper = page.locator(wrapperSelector); const items = wrapper.locator('li'); @@ -64,12 +66,18 @@ for (const { await items.first().click(); await editor.press('End'); - for (let i = 0; i < 3; i++) { - await editor.press('Enter', { delay: KEY_ACTION_DELAY }); - } + await editor.press('Enter', { delay: KEY_ACTION_DELAY }); await page.waitForTimeout(SCREENSHOT_STABILIZE_MS); await expect(editor).toHaveScreenshot(`list-keyboard-enter-${label}.png`); + + await editor.press('Enter', { delay: KEY_ACTION_DELAY }); + await page.keyboard.type('Plain'); + + await page.waitForTimeout(SCREENSHOT_STABILIZE_MS); + await expect(editor).toHaveScreenshot( + `list-keyboard-enter-exit-${label}.png` + ); }); test('Enter causes scrolling', async ({ page }) => { @@ -86,7 +94,7 @@ for (const { await items.last().click(); await page.keyboard.press('Enter', { delay: KEY_ACTION_DELAY }); - await page.keyboard.type('\n\n\n\n\n', { delay: 10 }); + await page.keyboard.type('d\ne\nf\ng\nh\n', { delay: 10 }); await page.waitForTimeout(SCREENSHOT_STABILIZE_MS); await expect(editor).toHaveScreenshot( diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt index 236560f10..b137e7fa4 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/ListStyles.kt @@ -192,6 +192,16 @@ class ListStyles( } if (!isBackspace && isNewLine && isPreviousParagraphList(s, start, config.clazz)) { + // Enter in an empty list item ends the list instead of adding another empty item. + // Populated items also start with a ZWS, so the new paragraph has to be empty too. + val (previousStart, previousEnd) = s.getParagraphBounds(start - 1) + if (start == end && s.substring(previousStart, previousEnd) == EnrichedConstants.ZWS_STRING) { + s.delete(start - 1, start) + removeSpansForRange(s, previousStart, previousEnd, config.clazz) + view.selection?.validateStyles() + return + } + // Check if the span from the previous line "leaked" into this one if (spans.isNotEmpty()) { val existingSpan = spans[0] diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 7731542b5..c7de09222 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -2104,6 +2104,11 @@ - (bool)textView:(UITextView *)textView [ZeroWidthSpaceUtils handleBackspaceInRange:range replacementText:text host:self] || + // Enter in an empty list item ends the list. Has to run before the + // checkbox newline handling. + [ParagraphAttributesUtils handleNewlineInEmptyListItem:range + replacementText:text + input:self] || [cbLStyle handleNewlinesInRange:range replacementText:text] || [h1Style handleNewlinesInRange:range replacementText:text] || [h2Style handleNewlinesInRange:range replacementText:text] || diff --git a/ios/utils/ParagraphAttributesUtils.h b/ios/utils/ParagraphAttributesUtils.h index bef8b4587..68969b607 100644 --- a/ios/utils/ParagraphAttributesUtils.h +++ b/ios/utils/ParagraphAttributesUtils.h @@ -12,6 +12,9 @@ + (BOOL)handleResetTypingAttributesOnBackspace:(NSRange)range replacementText:(NSString *)text input:(id)input; ++ (BOOL)handleNewlineInEmptyListItem:(NSRange)range + replacementText:(NSString *)text + input:(id)input; + (BOOL)isParagraphEmpty:(NSRange)range inString:(NSString *)string; + (void)resetTypingAttributes:(EnrichedTextInputView *)input preservingAlignment:(NSTextAlignment)alignment; diff --git a/ios/utils/ParagraphAttributesUtils.mm b/ios/utils/ParagraphAttributesUtils.mm index a8b6d3f3e..e35494b45 100644 --- a/ios/utils/ParagraphAttributesUtils.mm +++ b/ios/utils/ParagraphAttributesUtils.mm @@ -283,6 +283,66 @@ + (void)resetTypingAttributes:(EnrichedTextInputView *)input input->textView.typingAttributes = resetAttrs; } +// Pressing Enter in an empty list item (unordered, ordered or checkbox) ends +// the list instead of adding yet another empty item. ++ (BOOL)handleNewlineInEmptyListItem:(NSRange)range + replacementText:(NSString *)text + input:(id)input { + EnrichedTextInputView *typedInput = (EnrichedTextInputView *)input; + if (typedInput == nullptr) { + return NO; + } + + // Don't intervene during IME composition. + if (typedInput->textView.markedTextRange != nullptr) { + return NO; + } + + // A single newline character only. + if (text.length != 1 || ![[NSCharacterSet newlineCharacterSet] + characterIsMember:[text characterAtIndex:0]]) { + return NO; + } + + NSString *string = typedInput->textView.textStorage.string; + NSRange paragraphRange = [string paragraphRangeForRange:range]; + + // Content of the paragraph without its trailing newline. + NSUInteger contentLength = paragraphRange.length; + if (contentLength > 0 && + [[NSCharacterSet newlineCharacterSet] + characterIsMember:[string + characterAtIndex:NSMaxRange(paragraphRange) - + 1]]) { + contentLength -= 1; + } + + // An empty list item consists of exactly one zero-width space. + if (contentLength != 1 || + [string characterAtIndex:paragraphRange.location] != 0x200B) { + return NO; + } + // We want the caret before or after the ZWS, or have the ZWS itself selected + // - a selection that also covers the trailing newline isn't handled here. + if (NSMaxRange(range) > paragraphRange.location + 1) { + return NO; + } + + NSArray *listTypes = @[ + @([UnorderedListStyle getType]), @([OrderedListStyle getType]), + @([CheckboxListStyle getType]) + ]; + for (NSNumber *type in listTypes) { + StyleBase *style = typedInput->stylesDict[type]; + if (style != nullptr && [style detect:paragraphRange]) { + [style remove:paragraphRange withDirtyRange:YES]; + return YES; + } + } + + return NO; +} + + (BOOL)isParagraphEmpty:(NSRange)range inString:(NSString *)string { if (range.length == 0) return YES; diff --git a/src/web/formats/listKeyboard.ts b/src/web/formats/listKeyboard.ts index 1d1bbaf59..87840d691 100644 --- a/src/web/formats/listKeyboard.ts +++ b/src/web/formats/listKeyboard.ts @@ -1,16 +1,10 @@ -import { isTextSelection, type Editor, type JSONContent } from '@tiptap/core'; +import { isTextSelection, type Editor } from '@tiptap/core'; import { lineStartBackspace } from './wrappedBlockKeyboard'; import { withPreservedAlignment } from './formatRules'; -function emptyListItemContent(itemName: string): JSONContent { - return { - type: itemName, - content: [{ type: 'paragraph' }], - }; -} - -// Enter: always extend the list — splitListItem fails on an empty last item (see TipTap splitListItem). +// Enter: extend the list — splitListItem fails on an empty item (see TipTap splitListItem), +// in which case the item gets lifted out, ending the list. export function listEnter(editor: Editor, itemName: string): boolean { if (!editor.isActive(itemName)) { return false; @@ -29,19 +23,9 @@ export function listEnter(editor: Editor, itemName: string): boolean { return false; } - // Flat lists only: list item is always the parent block of the paragraph (depth − 1). - const itemDepth = $from.depth - 1; - if (itemDepth < 1) { - return false; - } - const insertPos = $from.after(itemDepth); - - return editor - .chain() - .focus() - .insertContentAt(insertPos, emptyListItemContent(itemName)) - .scrollIntoView() - .run(); + return withPreservedAlignment(editor, editor.chain(), (c) => + c.focus().liftListItem(itemName) + ); } // Backspace: first press at line start lifts the list item; second press (paragraph below list) joins backward.