Skip to content

[lexical] Feature: Add touch swipe indentation for list items#8478

Draft
abhishekvishwakarma007 wants to merge 4 commits into
facebook:mainfrom
abhishekvishwakarma007:feat/touch-swipe-indentation
Draft

[lexical] Feature: Add touch swipe indentation for list items#8478
abhishekvishwakarma007 wants to merge 4 commits into
facebook:mainfrom
abhishekvishwakarma007:feat/touch-swipe-indentation

Conversation

@abhishekvishwakarma007

@abhishekvishwakarma007 abhishekvishwakarma007 commented May 7, 2026

Copy link
Copy Markdown
Contributor

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/extension with registerTouchIndentation() that registers touchstart/touchmove/touchend listeners and dispatches existing INDENT_CONTENT_COMMAND / OUTDENT_CONTENT_COMMAND
  • Playground — Extension added via App.tsx for demo/testing
  • Flow types — Added TouchIndentationConfig and TouchIndentationExtension declarations

Configuration

// Default: enabled with 50px swipe threshold
TouchIndentationExtension

// Custom swipe threshold
configExtension(TouchIndentationExtension, { swipeThreshold: 80 })

// Disable
configExtension(TouchIndentationExtension, { disabled: true })

Design decisions

  • Extension-only — No legacy React plugin; uses the extension API directly
  • List-only — Checks $isSelectionInListItem() at touchstart; swipe over non-list content does not block scrolling
  • Multi-touch guard — Ignores pinch-to-zoom (2+ finger gestures)
  • Read-only guard — Skips gestures when editor.isEditable() is false
  • Passive touchstart — Uses passive: true since it never calls preventDefault(), preserving scroll performance
  • Vertical guard — Requires |deltaY| < 30px to avoid triggering during vertical scroll
  • Signal-based threshold — Uses Signal<number> with .peek() for reactive swipe threshold

Closes #8268

Test plan

Before

TouchindentBefore.mov

Swiping on list items has no effect

After

TouchindentAfter.mov

Swiping right indents, swiping left outdents the list item

abhishekvishwakarma007 added 2 commits May 8, 2026 00:28
…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
@vercel

vercel Bot commented May 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment May 14, 2026 2:20pm
lexical-playground Ready Ready Preview, Comment May 14, 2026 2:20pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 7, 2026
@etrepum

etrepum commented May 7, 2026

Copy link
Copy Markdown
Collaborator

Let’s get rid of the plugin, no reason to add new legacy code.

@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label May 7, 2026
Comment thread packages/lexical-extension/src/index.ts Outdated
TabIndentationExtension,
} from './TabIndentationExtension';
export {
registerTouchIndentation,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function registerTouchIndentation(
function registerTouchIndentation(


export function registerTouchIndentation(
editor: LexicalEditor,
swipeThreshold: number = DEFAULT_SWIPE_THRESHOLD,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can use a signal and doesn't need to support defaults for legacy code

Suggested change
swipeThreshold: number = DEFAULT_SWIPE_THRESHOLD,
swipeThreshold: Signal<number>,

const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (
Math.abs(deltaX) > swipeThreshold &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Math.abs(deltaX) > swipeThreshold &&
Math.abs(deltaX) > swipeThreshold.peek() &&

const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (
Math.abs(deltaX) > swipeThreshold &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Math.abs(deltaX) > swipeThreshold &&
Math.abs(deltaX) > swipeThreshold.peek() &&

"types": "./LexicalTablePlugin.d.ts",
"development": "./LexicalTablePlugin.dev.js",
"production": "./LexicalTablePlugin.prod.js",
"default": "./LexicalTablePlugin.js"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file doesn't need to change

Comment thread .flowconfig
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'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also doesn't need to change

Comment thread tsconfig.build.json Outdated
"@lexical/react/LexicalTablePlugin": [
"./packages/lexical-react/src/LexicalTablePlugin.ts"
],
"@lexical/react/LexicalTouchIndentationPlugin": [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't need to change

Comment thread tsconfig.json Outdated
"@lexical/react/LexicalTablePlugin": [
"./packages/lexical-react/src/LexicalTablePlugin.ts"
],
"@lexical/react/LexicalTouchIndentationPlugin": [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't need to change

Comment on lines +69 to +70
type TouchIndentationConfig,
TouchIndentationExtension,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this extension only applies to lists it should probably be in @lexical/list and documented as such.

Comment on lines +63 to +111
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};
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
isInListItem = editor.read(() => $isSelectionInListItem());
isInListItem = editor.read($isSelectionInListItem);

@potatowagon potatowagon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — Touch Swipe Indentation for List Items

Assessment: Looks good to land

What I verified:

  1. Feature scope: Adds a TouchIndentationExtension that lets mobile users swipe left/right on list items to indent/outdent. Uses touch events (touchstart/touchmove/touchend) with configurable swipe threshold.

  2. 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_COMMAND so existing listeners work
    • Uses the signal/effect pattern consistent with other extensions
    • Properly cleans up event listeners on root change
  3. CI status: Full CI suite green (40 checks pass).

  4. Risk: Opt-in extension, no changes to existing behavior. Uses established patterns.

— via Navi on behalf of potatowagon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Indentation list via touch right/left

3 participants