Skip to content

fix: prevent paste from indenting blocks under the current block#342

Merged
appflowy merged 1 commit into
mainfrom
fix/paste-indent-and-selection
May 14, 2026
Merged

fix: prevent paste from indenting blocks under the current block#342
appflowy merged 1 commit into
mainfrom
fix/paste-indent-and-selection

Conversation

@appflowy
Copy link
Copy Markdown
Contributor

@appflowy appflowy commented May 14, 2026

Summary

Pasting content copied from within the editor produced blocks that were nested one level under the cursor's parent — e.g. typing 1,2,3,4 then select-all → copy → Enter → paste rendered the pasted lines indented under 4. Deleting that parent then broke Backspace because the empty parent still held nested children.

Three root causes were fixed:

  • convertSlateFragmentTo treated Slate's inner text-wrapper element (type: 'text') as a regular block. Since 'text' isn't in the BlockType enum it was rewritten into a nested Paragraph, double-wrapping every pasted block.
  • Slate fragment was not being read from HTML. The system clipboard often drops the application/x-slate-fragment MIME entry; Slate's own slate-dom falls back to a regex on data-slate-fragment in the HTML. We weren't doing that, so paste went through the HTML-parser path with stale shape.
  • Slate's insertFragment nests the fragment when the cursor sits inside a text-wrapper (deep path). A new insertFragmentAsSiblings writes pasted blocks directly to the YJS doc as siblings of the current block.

The sibling-insert path also mirrors Transforms.insertFragment semantics: deletes the expanded selection first, replaces the current block if empty, and places the cursor at the end of the last inserted block.

Test plan

  • playwright/e2e/editor/blocks/paste_indent.spec.ts — 3 cases (indent regression, Backspace-after-delete, paste-over-selection)
  • All playwright/e2e/editor/blocks/ tests pass (12/12) — no regression in code-block paste, scroll, or unsupported-block tests
  • Manual sanity: paste across documents, paste plain text from outside the editor, paste inside a table cell, paste a single inline word

🤖 Generated with Claude Code

Summary by Sourcery

Fix pasting Slate fragments so blocks are inserted as siblings at the correct indent level instead of becoming nested under the current block.

Bug Fixes:

  • Correct handling of Slate text-wrapper nodes when converting fragments to prevent pasted blocks from being double-wrapped and indented.
  • Recover Slate fragments from HTML when the dedicated clipboard MIME type is missing, avoiding fallback to stale HTML parsing on paste.
  • Insert pasted block fragments directly into the YJS document as siblings of the current block, including proper handling of empty target blocks and expanded selections.

Enhancements:

  • Improve fragment conversion to preserve text-wrapper structure and ensure generated blocks always include an appropriate text child wrapper.
  • Align cursor placement after YJS-based fragment insertion with Slate’s default insertFragment semantics for a consistent editing experience.

Tests:

  • Add Playwright end-to-end coverage for paste indentation behavior, backspace-after-delete, and pasting over a selection in the blocks editor.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 14, 2026

Reviewer's Guide

Adjusts paste handling in the Slate/Yjs editor so that pasted blocks become siblings at the correct indent level, correctly decodes Slate fragments from clipboard HTML, and fixes fragment conversion so inner text-wrapper nodes are preserved rather than turned into nested blocks, with new e2e coverage for paste indentation behavior.

Sequence diagram for updated paste handling with sibling block insertion

sequenceDiagram
  actor User
  participant ReactEditor as ReactEditor_withInsertData
  participant Clipboard
  participant YjsEditor
  participant YjsDoc

  User->>ReactEditor: paste
  ReactEditor->>Clipboard: data.getData(application/x-slate-fragment)
  alt no_native_fragment
    ReactEditor->>Clipboard: data.getData(text/html)
    ReactEditor->>ReactEditor: extractSlateFragmentFromHTML(html)
  end
  alt rawFragment_found
    ReactEditor->>ReactEditor: decodeSlateFragment(rawFragment)
    alt parsed_ok
      ReactEditor->>ReactEditor: convertSlateFragmentTo(parsed)
      ReactEditor->>YjsEditor: insertFragmentAsSiblings(fragment)
      alt inserted_as_siblings
        YjsEditor->>YjsDoc: slateContentInsertToYData(parentId,index,fragment,doc)
        YjsEditor->>YjsEditor: Transforms.select(Editor.end(...))
      else fallback_to_slate
        ReactEditor->>ReactEditor: insertFragment(fragment)
      end
    else malformed_fragment
      ReactEditor->>ReactEditor: fallback_to_other_handlers
    end
  else no_fragment
    ReactEditor->>ReactEditor: existing_paste_handlers
  end
Loading

File-Level Changes

Change Details Files
Recover and decode Slate fragment data from clipboard HTML when the dedicated MIME type is missing, and handle malformed fragments safely.
  • Add HTML-based extraction of the data-slate-fragment attribute using a regex that matches Slate's slate-dom implementation.
  • Introduce a decodeSlateFragment helper that base64+URI decodes the fragment JSON into Slate nodes, returning null on failure instead of throwing.
  • Log warnings when fragment decoding fails so paste can gracefully fall back to other handlers.
src/components/editor/plugins/withInsertData.ts
Insert pasted Slate fragments as siblings of the current block via the Yjs document to avoid unintended nesting and to mirror Slate's Transforms.insertFragment semantics.
  • Add insertFragmentAsSiblings which validates that the fragment consists of block elements with YjsEditorKey.text wrappers, otherwise falls back to Slate's default insertFragment.
  • On expanded selections, delete the selected range first, then re-resolve the current block and its parent in the Yjs tree before inserting.
  • Use slateContentInsertToYData to insert new blocks either in place of an empty current block or after the current block, wrapped in a Yjs transaction, and then move the cursor to the end of the last inserted block with defensive error handling.
  • Wire the new sibling-insert path into the paste handler so that, when it succeeds, Slate's insertFragment is skipped.
src/components/editor/plugins/withInsertData.ts
Fix Slate fragment-to-Yjs block conversion so inner text-wrapper nodes are preserved and not turned into nested block elements, preventing extra indentation on paste.
  • Refactor convertSlateFragmentTo's traverse function to handle non-element nodes early and return null for unsupported node types.
  • Treat nodes with type === YjsEditorKey.text as text wrappers: keep them as text elements with a generated textId and text children, instead of coercing them into block nodes.
  • Ensure non-text wrapper blocks get a synthetic text child when they don’t already have one, using YjsEditorKey.text and a generated blockId, so resulting blocks always have the expected shape.
  • Build the final block children list based on whether a text wrapper already exists, avoiding double-wrapping and unintended child nesting.
src/components/editor/utils/fragment.ts
Minor refactors and typing improvements to support new paste behavior and logging.
  • Introduce a BlockElement type alias to clarify elements that may carry a blockId and use it when extracting the table-check block id.
  • Import and use additional Slate/Yjs utilities (Editor, Range, Transforms, getSharedRoot, assertDocExists, getBlock, getChildrenArray, slateContentInsertToYData, YjsEditorKey, and Log) needed by the new insertion path and helpers.
src/components/editor/plugins/withInsertData.ts
src/components/editor/utils/fragment.ts
Add end‑to‑end coverage for paste indentation behavior in the editor.
  • Create a new Playwright spec validating that pasted content is not indented under the last block, that deleting the parent doesn’t break Backspace, and that pasting over a selection behaves correctly.
playwright/e2e/editor/blocks/paste_indent.spec.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The extractSlateFragmentFromHTML helper uses a very narrow regex (data-slate-fragment="(.+?)"), which won’t handle single quotes, escaped quotes, or attributes split across lines; consider either reusing slate-dom’s helper directly or switching to a more robust approach (e.g. DOMParser + attribute read, or a regex that mirrors slate-dom’s implementation more closely).
  • In insertFragmentAsSiblings, the allBlocks guard assumes every fragment node is a block whose first child is a YjsEditorKey.text wrapper; as more block/inline shapes are introduced this may cause unexpected fallbacks to insertFragment—it might be safer to either log when this guard fails or broaden the shape handling to explicitly support common non-text-wrapper cases (e.g. void blocks).
  • In convertSlateFragmentTo, Object.values(BlockType).includes(type) is evaluated for every node; consider precomputing a Set of valid block types or a predicate function to avoid repeated array scans in large fragments.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `extractSlateFragmentFromHTML` helper uses a very narrow regex (`data-slate-fragment="(.+?)"`), which won’t handle single quotes, escaped quotes, or attributes split across lines; consider either reusing `slate-dom`’s helper directly or switching to a more robust approach (e.g. DOMParser + attribute read, or a regex that mirrors `slate-dom`’s implementation more closely).
- In `insertFragmentAsSiblings`, the `allBlocks` guard assumes every fragment node is a block whose first child is a `YjsEditorKey.text` wrapper; as more block/inline shapes are introduced this may cause unexpected fallbacks to `insertFragment`—it might be safer to either log when this guard fails or broaden the shape handling to explicitly support common non-text-wrapper cases (e.g. void blocks).
- In `convertSlateFragmentTo`, `Object.values(BlockType).includes(type)` is evaluated for every node; consider precomputing a `Set` of valid block types or a predicate function to avoid repeated array scans in large fragments.

## Individual Comments

### Comment 1
<location path="src/components/editor/utils/fragment.ts" line_range="550-542" />
<code_context>
     }

-    return null;
+    const mappedChildren = node.children
+      .map(traverse)
+      .filter(Boolean) as SlateNode[];
+
+    const hasTextWrapper =
+      mappedChildren.length > 0 &&
+      SlateElement.isElement(mappedChildren[0] as SlateNode) &&
+      (mappedChildren[0] as SlateElement).type === YjsEditorKey.text;
+
+    const blockChildren = hasTextWrapper
+      ? mappedChildren
+      : [
+          {
+            textId: blockId,
</code_context>
<issue_to_address>
**issue (bug_risk):** Blocks may end up with mixed element/text children, which can violate the expected block schema.

Because `traverse` now returns raw `SlateText` for text nodes and `SlateElement` for elements, `mappedChildren` can contain both. In the `hasTextWrapper === false` branch, you prepend a synthetic text wrapper but still spread `...mappedChildren`, which can leave raw `SlateText` nodes as direct block children alongside the wrapper. Many Slate/Yjs schemas assume blocks only have element children, so this mixed structure may break downstream logic. Consider normalizing `mappedChildren` here (e.g. wrapping stray text nodes) or enforcing that only elements are present before building `blockChildren`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

},
...children,
];
const blockId = generateBlockId();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Blocks may end up with mixed element/text children, which can violate the expected block schema.

Because traverse now returns raw SlateText for text nodes and SlateElement for elements, mappedChildren can contain both. In the hasTextWrapper === false branch, you prepend a synthetic text wrapper but still spread ...mappedChildren, which can leave raw SlateText nodes as direct block children alongside the wrapper. Many Slate/Yjs schemas assume blocks only have element children, so this mixed structure may break downstream logic. Consider normalizing mappedChildren here (e.g. wrapping stray text nodes) or enforcing that only elements are present before building blockChildren.

`convertSlateFragmentTo` treated Slate's inner text-wrapper (type:'text')
as a regular block, wrapping every pasted block in an extra Paragraph and
causing each paste to be indented one level. Slate's `insertFragment`
then nested those blocks under the cursor's parent, which also broke
Backspace after deleting the parent.

Fix paste in three places:
- `convertSlateFragmentTo`: preserve text-wrapper nodes as-is, only treat
  real BlockType elements as blocks.
- `withInsertData`: recover the Slate fragment from the `data-slate-fragment`
  HTML attribute when the `application/x-slate-fragment` clipboard MIME
  entry is missing (matches slate-dom's regex). Wrap decode in try/catch
  so malformed clipboard data falls through to the text/HTML handlers.
- New `insertFragmentAsSiblings` writes pasted blocks directly to the YJS
  doc as siblings of the current block, mirroring `Transforms.insertFragment`
  semantics: deletes expanded selection first, replaces the current block
  if empty, and places the cursor at the end of the last inserted block.

Adds a Playwright spec covering the indent regression, the
backspace-after-delete follow-up, and paste-over-selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@appflowy appflowy force-pushed the fix/paste-indent-and-selection branch from a7da7ae to 4cde60d Compare May 14, 2026 11:00
@appflowy appflowy merged commit 8246fc4 into main May 14, 2026
12 of 13 checks passed
@appflowy appflowy deleted the fix/paste-indent-and-selection branch May 14, 2026 12:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant