Skip to content

[lexical-markdown] Feature: Add $convertSelectionToMarkdownString API#8395

Merged
etrepum merged 3 commits intofacebook:mainfrom
mayrang:feat/selection-to-markdown
Apr 29, 2026
Merged

[lexical-markdown] Feature: Add $convertSelectionToMarkdownString API#8395
etrepum merged 3 commits intofacebook:mainfrom
mayrang:feat/selection-to-markdown

Conversation

@mayrang
Copy link
Copy Markdown
Contributor

@mayrang mayrang commented Apr 27, 2026

Description

Fixes #6503

Adds a new $convertSelectionToMarkdownString function that converts only the selected content to a markdown string. Previously, $convertToMarkdownString could only convert the entire editor content.

What changed

  • Added $convertSelectionToMarkdownString(transformers, selection, shouldPreserveNewLines) to @lexical/markdown
  • Reuses existing markdown export logic (exportTopLevelElements, exportChildren, exportTextFormat) with selection filtering
  • Handles partial text selection by slicing text at anchor/focus offsets
  • Returns empty string for null or collapsed selections
  • Works inside editor.read() — no node creation needed

Usage

import {$convertSelectionToMarkdownString, TRANSFORMERS} from '@lexical/markdown';

editor.read(() => {
  const markdown = $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection());
});

Implementation approach

Rather than creating a separate export pipeline, this extends the existing createMarkdownExport with an optional selection parameter. When provided, nodes are filtered via isSelected() and text content is sliced at selection boundaries. This provides a cleaner API for the use case described by @etrepum in #6503, without requiring editor.update() or manual node reconstruction.

Open to alternative approaches if a different direction is preferred.

Test Plan

  • Full text selection → returns full markdown
  • Partial text selection → returns sliced text
  • Bold text selection → preserves formatting
  • Null selection → returns empty string
  • Collapsed selection → returns empty string
  • Backward selection → correct slicing
  • Multi-paragraph selection → includes both paragraphs
  • List selection → preserves list markdown syntax

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

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

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Apr 29, 2026 5:42am
lexical-playground Ready Ready Preview, Comment Apr 29, 2026 5:42am

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 Apr 27, 2026
@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 27, 2026

Not fully sure this is the right approach — feedback on the direction would be appreciated before going further.

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Apr 27, 2026

I won't have time to read through the code tonight but if it is following the same approach that HTML and JSON export takes then it's probably the right direction. If it's some other way then maybe not.

@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 27, 2026

Thanks — checked how HTML and JSON export handle this, they both use $sliceSelectedTextNodeContent with 'clone' mode. My initial version did the slicing manually, which worked but missed token/segmented node handling. Switching to $sliceSelectedTextNodeContent to stay consistent and cover those cases.

Copy link
Copy Markdown
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

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

I think there is probably some additional work that should be done to support extractWithChild to support situations like a partial LinkNode selection. I think taking the 'html' path makes the most sense for markdown (not 'clone').

Note that main has changed its prettier config so it might be easiest to copy over the new config, run prettier, commit, and then merge to sync.

@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 27, 2026

For extractWithChild — adding the check inside exportChildren gets messy with nested structures (e.g. link inside a list item), so I looked into restructuring the export like $appendNodesToHTML does. Problem is that changes the return type of exportChildren from string to object, which would break $convertToMarkdownString and any external transformers using the current callbacks.

Thinking the safest route is building the recursive structure separately for $convertSelectionToMarkdownString, keeping the existing export untouched. Some duplication (~50-70 lines) but no regression risk. Does that work, or do you have a different approach in mind?

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Apr 27, 2026

I think building something separate is probably best, and then we can worry about refactoring or deprecating the current implementation later. Eventually I think the markdown module will be changing a lot for correctness and flexibility reasons, so I don’t think we should care all that much about the existing code other than to keep it backwards compatible.

@mayrang mayrang force-pushed the feat/selection-to-markdown branch from 2c3e359 to c34bd2a Compare April 27, 2026 18:29
@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 27, 2026

Added extractWithChild support as a separate recursive path (createSelectionMarkdownExport), keeping the existing export untouched. Using $sliceSelectedTextNodeContent with 'clone' and extractWithChild with 'html' as suggested.

Went through the transformer implementations looking for edge cases — found one with the list transformer. It iterates children itself and adds - prefixes, so unselected items show up as empty prefixed lines (e.g. - \n- Item 2 instead of - Item 2). The CODE transformer also skips exportChildren and calls getTextContent() directly, so selection doesn't apply there.

To fix the list issue, the transformer callback would need to support skipping nodes, which means changing the (node) => string interface — breaking change for external transformers. Since the markdown module is getting a bigger refactor anyway, maybe this is better addressed then? Let me know what you think.

Added a test showing the current list behavior so you can see the edge case directly.

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Apr 27, 2026

Yeah, to that point, maybe it would be better to not add this API until we have better markdown infrastructure to support it.

I think in an ideal world we would have separate markdown shortcuts (that operate as you type) and markdown import/export (full document or selection import/export) infrastructure. They are very similar in theory but in practice trying to write code that does the right thing in both scenarios doesn't work very well. It would also be much easier to wire up a fully featured markdown implementation like remark or micromark to the import/export operations.

@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 27, 2026

Makes sense. Would it be useful to keep this as a draft until then, or better to close it entirely? The basic cases (text, formatting, links) work well — just wanted to check if there's value in having it available even with the list limitation.

@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented Apr 27, 2026

Up to you whether you keep it draft or close it. I don't think it can work correctly without refactoring other parts as you've noted. Maybe there's a possible refactoring of the existing transformer interface that allows this to work (adding an argument wouldn't necessarily break existing transformers or their types), if you want to look into that it would be a good interim solution for anyone looking for this functionality.

@mayrang mayrang force-pushed the feat/selection-to-markdown branch from c34bd2a to 2d67545 Compare April 27, 2026 20:31
@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 27, 2026

Thanks for the pointer on the transformer interface — added an optional selection parameter to ElementTransformer.export and updated $listExport to skip unselected items when provided. Since the parameter is optional, existing transformers work as-is. Nested lists are handled correctly too.

The list edge case from earlier is now fixed.

Copy link
Copy Markdown
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

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

Overall this looks great, a welcome addition to the current API. All of the comments are really just syntax nits and simplifications

): void;

declare export function $convertSelectionToMarkdownString(
transformers?: Array<Transformer>,
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.

Current flow syntax and conventions are much closer to Typescript

Suggested change
transformers?: Array<Transformer>,
transformers?: Transformer[],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

Comment on lines +1926 to +1930
normal.select(0, 6);
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.focus.set(bold.getKey(), 4, 'text');
}
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
normal.select(0, 6);
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.focus.set(bold.getKey(), 4, 'text');
}
const selection = normal.select(0, 6);
selection.focus.set(bold.getKey(), 4, 'text');

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.

or alternatively you could use the NodeCaret APIs:

$setSelectionFromCaretRange(
  $getCaretRange(
    $getTextPointCaret(normal, 'next', 0),
    $getTextPointCaret(bold, 'next', 4),
  )
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Switched to NodeCaret API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Went with this approach, cleaner to read.

p2.append(t2);
root.append(p1, p2);
// Select from "First" to "Second"
t1.select(0, 15);
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.

see above for simpler ways to create this RangeSelection

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated.

item2.append(t2);
list.append(item1, item2);
root.append(list);
t1.select(0, 6);
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.

see above for simpler ways to create this RangeSelection

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated.

list.append(item1, item2, item3);
root.append(list);
// Select only "Item 2" and partial "Item 3"
t2.select(0, 6);
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.

see above for simpler ways to create this RangeSelection

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated.

Comment thread packages/lexical-markdown/src/index.ts Outdated
Comment on lines +97 to +102
if (!selection) {
return '';
}
if ($isRangeSelection(selection) && selection.isCollapsed()) {
return '';
}
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 can be fused to save a few bytes

Suggested change
if (!selection) {
return '';
}
if ($isRangeSelection(selection) && selection.isCollapsed()) {
return '';
}
if (!selection || $isRangeSelection(selection) && selection.isCollapsed()) {
return '';
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fused.

* inline elements like links.
*/
export function createSelectionMarkdownExport(
transformers: Array<Transformer>,
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
transformers: Array<Transformer>,
transformers: Transformer[],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

Comment on lines +146 to +148
elementTransformers: Array<ElementTransformer | MultilineElementTransformer>,
textFormatTransformers: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
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
elementTransformers: Array<ElementTransformer | MultilineElementTransformer>,
textFormatTransformers: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
elementTransformers: (ElementTransformer | MultilineElementTransformer)[],
textFormatTransformers: TextFormatTransformer[],
textMatchTransformers: TextMatchTransformer[],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

Comment on lines +160 to +162
_node =>
$exportChildrenForSelection(
_node,
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.

underscore prefix is for unused variables, shouldn't really be used when just avoiding shadowing

Suggested change
_node =>
$exportChildrenForSelection(
_node,
node_ =>
$exportChildrenForSelection(
node_,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, fixed.

Comment on lines +213 to +217
textFormatTransformers: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
shouldPreserveNewLines: boolean,
unclosedTags?: Array<{format: TextFormatType; tag: string}>,
unclosableTags?: Array<{format: TextFormatType; tag: string}>,
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
textFormatTransformers: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
shouldPreserveNewLines: boolean,
unclosedTags?: Array<{format: TextFormatType; tag: string}>,
unclosableTags?: Array<{format: TextFormatType; tag: string}>,
textFormatTransformers: TextFormatTransformer[],
textMatchTransformers: TextMatchTransformer[],
shouldPreserveNewLines: boolean,
unclosedTags?: {format: TextFormatType; tag: string}[],
unclosableTags?: {format: TextFormatType; tag: string}[],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented Apr 28, 2026

All addressed, force-pushed. Thanks for the review!

Add a new function that converts only the selected content to a
markdown string, reusing existing export logic with selection filtering.
Handles partial text selection by slicing at anchor/focus offsets and
returns empty string for null or collapsed selections.

Fixes facebook#6503
@etrepum etrepum added this pull request to the merge queue Apr 29, 2026
Merged via the queue into facebook:main with commit 838b19e Apr 29, 2026
41 checks passed
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: Get markdown content for selection

2 participants