[lexical] Feature: Add touch swipe indentation for list items#8478
[lexical] Feature: Add touch swipe indentation for list items#8478abhishekvishwakarma007 wants to merge 4 commits into
Conversation
…ok#8268) Adds the ability to indent/outdent list items by swiping left or right on touch devices, similar to Apple Notes. The feature only triggers on bullet and numbered list items, not on paragraphs or other blocks. Includes a new TouchIndentationExtension and a React plugin wrapper (TouchIndentationPlugin). The swipe threshold is configurable and the feature can be disabled via config.
- Move list item check to touchstart to prevent blocking scroll on non-list content - Set touchstart listener to passive:true since it never calls preventDefault() - Add multi-touch guard to avoid triggering on pinch-to-zoom gestures - Add editor.isEditable() check to skip gestures in read-only mode - Complete Flow type declarations for LexicalTouchIndentationPlugin
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Let’s get rid of the plugin, no reason to add new legacy code. |
| TabIndentationExtension, | ||
| } from './TabIndentationExtension'; | ||
| export { | ||
| registerTouchIndentation, |
There was a problem hiding this comment.
| registerTouchIndentation, |
The only extensions that export their internals are doing so to support legacy plugins. No reason to do that here.
| return listItem != null; | ||
| } | ||
|
|
||
| export function registerTouchIndentation( |
There was a problem hiding this comment.
| export function registerTouchIndentation( | |
| function registerTouchIndentation( |
|
|
||
| export function registerTouchIndentation( | ||
| editor: LexicalEditor, | ||
| swipeThreshold: number = DEFAULT_SWIPE_THRESHOLD, |
There was a problem hiding this comment.
This can use a signal and doesn't need to support defaults for legacy code
| swipeThreshold: number = DEFAULT_SWIPE_THRESHOLD, | |
| swipeThreshold: Signal<number>, |
| const deltaX = touch.clientX - startX; | ||
| const deltaY = touch.clientY - startY; | ||
| if ( | ||
| Math.abs(deltaX) > swipeThreshold && |
There was a problem hiding this comment.
| Math.abs(deltaX) > swipeThreshold && | |
| Math.abs(deltaX) > swipeThreshold.peek() && |
| const deltaX = touch.clientX - startX; | ||
| const deltaY = touch.clientY - startY; | ||
| if ( | ||
| Math.abs(deltaX) > swipeThreshold && |
There was a problem hiding this comment.
| Math.abs(deltaX) > swipeThreshold && | |
| Math.abs(deltaX) > swipeThreshold.peek() && |
| "types": "./LexicalTablePlugin.d.ts", | ||
| "development": "./LexicalTablePlugin.dev.js", | ||
| "production": "./LexicalTablePlugin.prod.js", | ||
| "default": "./LexicalTablePlugin.js" |
There was a problem hiding this comment.
this file doesn't need to change
| module.name_mapper='^@lexical/react/LexicalPlainTextPlugin$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/LexicalPlainTextPlugin.js.flow' | ||
| module.name_mapper='^@lexical/react/LexicalRichTextPlugin$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/LexicalRichTextPlugin.js.flow' | ||
| module.name_mapper='^@lexical/react/LexicalSelectionAlwaysOnDisplay$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/LexicalSelectionAlwaysOnDisplay.js.flow' | ||
| module.name_mapper='^@lexical/react/LexicalTabIndentationPlugin$' -> '<PROJECT_ROOT>/packages/lexical-react/flow/LexicalTabIndentationPlugin.js.flow' |
There was a problem hiding this comment.
also doesn't need to change
| "@lexical/react/LexicalTablePlugin": [ | ||
| "./packages/lexical-react/src/LexicalTablePlugin.ts" | ||
| ], | ||
| "@lexical/react/LexicalTouchIndentationPlugin": [ |
| "@lexical/react/LexicalTablePlugin": [ | ||
| "./packages/lexical-react/src/LexicalTablePlugin.ts" | ||
| ], | ||
| "@lexical/react/LexicalTouchIndentationPlugin": [ |
| type TouchIndentationConfig, | ||
| TouchIndentationExtension, |
There was a problem hiding this comment.
These could have flow type coverage
Per etrepum's review: - Delete React plugin (LexicalTouchIndentationPlugin) and Flow types - Make registerTouchIndentation private (not exported) - Use Signal<number> for swipeThreshold instead of plain number with defaults - Use swipeThreshold.peek() in event handlers - Simplify indent/outdent dispatch to ternary - Pass signal directly to registerTouchIndentation - Add TouchIndentationExtension to playground via App.tsx instead of plugin - Revert auto-generated changes to .flowconfig, tsconfig, package.json
…ild.json - Add Flow type declarations for TouchIndentationConfig and TouchIndentationExtension - Revert stray tsconfig.build.json change (__bench__ exclusion)
etrepum
left a comment
There was a problem hiding this comment.
Since this extension only applies to lists it should probably be in @lexical/list and documented as such.
| function setupEditorWithList() { | ||
| const root = document.createElement('div'); | ||
| root.contentEditable = 'true'; | ||
| document.body.appendChild(root); | ||
|
|
||
| const editor = buildEditorFromExtensions( | ||
| TouchIndentationExtension, | ||
| defineExtension({name: 'test-nodes', nodes: [ListNode, ListItemNode]}), | ||
| ); | ||
| editor.setRootElement(root); | ||
|
|
||
| editor.update( | ||
| () => { | ||
| const list = $createListNode('bullet'); | ||
| const item = $createListItemNode(); | ||
| item.append($createTextNode('Hello')); | ||
| list.append(item); | ||
| $getRoot().append(list); | ||
| item.selectEnd(); | ||
| }, | ||
| {discrete: true}, | ||
| ); | ||
|
|
||
| return {editor, root}; | ||
| } | ||
|
|
||
| function setupEditorWithParagraph() { | ||
| const root = document.createElement('div'); | ||
| root.contentEditable = 'true'; | ||
| document.body.appendChild(root); | ||
|
|
||
| const editor = buildEditorFromExtensions( | ||
| TouchIndentationExtension, | ||
| defineExtension({name: 'test-nodes', nodes: [ListNode, ListItemNode]}), | ||
| ); | ||
| editor.setRootElement(root); | ||
|
|
||
| editor.update( | ||
| () => { | ||
| const paragraph = $createParagraphNode(); | ||
| paragraph.append($createTextNode('Hello')); | ||
| $getRoot().append(paragraph); | ||
| paragraph.selectEnd(); | ||
| }, | ||
| {discrete: true}, | ||
| ); | ||
|
|
||
| return {editor, root}; | ||
| } |
There was a problem hiding this comment.
The only difference between these two are the update to initialize it, which can also be done with $initialEditorState.
It will be simpler to only return editor, you can retrieve the root with editor.getRootElement(). This way you can also easily use using editor = … rather than defining a custom dispose or another variable.
| startX = touch.clientX; | ||
| startY = touch.clientY; | ||
| isSwiping = false; | ||
| isInListItem = editor.read(() => $isSelectionInListItem()); |
There was a problem hiding this comment.
| isInListItem = editor.read(() => $isSelectionInListItem()); | |
| isInListItem = editor.read($isSelectionInListItem); |
potatowagon
left a comment
There was a problem hiding this comment.
Review — Touch Swipe Indentation for List Items
Assessment: Looks good to land ✅
What I verified:
-
Feature scope: Adds a
TouchIndentationExtensionthat lets mobile users swipe left/right on list items to indent/outdent. Uses touch events (touchstart/touchmove/touchend) with configurable swipe threshold. -
Implementation quality:
- Only activates on list items (
$isSelectionInListItem()guard) - Vertical guard (30px) prevents accidental triggers during scrolling
- Multi-touch ignored (single finger only)
- Dispatches standard
INDENT_CONTENT_COMMAND/OUTDENT_CONTENT_COMMANDso existing listeners work - Uses the signal/effect pattern consistent with other extensions
- Properly cleans up event listeners on root change
- Only activates on list items (
-
CI status: Full CI suite green (40 checks pass).
-
Risk: Opt-in extension, no changes to existing behavior. Uses established patterns.
— via Navi on behalf of potatowagon
Description
Adds touch swipe indentation for list items on mobile devices — swipe right to indent, swipe left to outdent (similar to Apple Notes). Only triggers on bullet and numbered list items, not on paragraphs or other blocks.
Changes
TouchIndentationExtension— New extension in@lexical/extensionwithregisterTouchIndentation()that registerstouchstart/touchmove/touchendlisteners and dispatches existingINDENT_CONTENT_COMMAND/OUTDENT_CONTENT_COMMANDApp.tsxfor demo/testingTouchIndentationConfigandTouchIndentationExtensiondeclarationsConfiguration
Design decisions
$isSelectionInListItem()attouchstart; swipe over non-list content does not block scrollingeditor.isEditable()is falsepassive: truesince it never callspreventDefault(), preserving scroll performance|deltaY| < 30pxto avoid triggering during vertical scrollSignal<number>with.peek()for reactive swipe thresholdCloses #8268
Test plan
Before
TouchindentBefore.mov
After
TouchindentAfter.mov