Skip to content

Backspace on the empty line under an image deletes the image #376

Description

@HMarzban

Related

No GitHub issue records this bug. The earlier record is the clean-room spec delete-collision.cy.ts, added in 667d4acc1.

That spec expects one Backspace to delete the image. Its comment names joinBackward.

The hover half of that spec was fixed in 2.0.0. The image delete was left as the expected result. See the CHANGELOG note that a text caret keeps editing text. That is why this stayed unfixed.

The same library path is already filed for a Divider in #340. That issue is a different node.

ProseMirror's author says an app that wants another result should put a command in front of joinBackward. See prosemirror-commands#6.

What happened

This is planned for the next release of @docs.plus/extension-hypermultimedia.

Edward Saperia reported this on 2026-09-26. He wrote: "I want to remove this gap under the picture but if I press backspace the picture is deleted".

A heading sits above an image. An empty line sits under the image, and a bullet list follows. He presses Backspace to remove the empty line. The image is deleted instead.

The latest version on npm is 2.0.0, dated 2026-08-11. Version 2.1.0, dated 2026-09-17, is in the CHANGELOG but is not on npm yet. See #358. [Unreleased] in the package CHANGELOG has no Fixed entry yet. The fix belongs in the next release after 2.1.0.

What we expect

Backspace in the empty line under an image removes that line and keeps the image. The caret ends after the image.

The same holds for every media type the resize gripper accepts. Delete at the end of the image line does the same.

A second Backspace is the maintainer's choice. No Google Docs, Word, Notion, or Slack help page was found that states that press.

Steps to reproduce

  1. Open a pad you can edit.
  2. Add a heading, then an image, then an empty paragraph, then a bullet list.
  3. Put the caret at the start of the empty paragraph.
  4. Press Backspace once.

The empty paragraph should go, and the image should stay. The report says the image is deleted.

Also put the caret at the end of the paragraph that holds the image, and press Delete.

The chat report includes a screenshot. It is not attached here. The maintainer will drag it into this issue.

Findings

Read from code, not measured. This report does not press a key in an editor.

  • The pad sets Image to inline: true and allowBase64: true in TipTap.tsx. Video, audio, and the embed nodes stay block nodes. The clean-room page uses a block image unless the query is inlineImage=true.
  • Every media node is a leaf. In prosemirror-model 1.25.11, NodeType.isAtom is true for every leaf. So every media node is an atom. selectable is not false, so the node can be selected.
  • The base keymap binds Backspace to deleteSelection, then joinBackward, then selectNodeBackward. That chain is in prosemirror-commands 1.7.2, through @tiptap/pm 3.31.3.
  • joinBackward calls deleteBarrier first. If the current textblock is empty and the node before can be selected, it deletes the empty textblock and selects that node. If the node before is an atom at the parent depth, it deletes the atom.
  • The clean-room spec places a block image before a paragraph that has text. The empty-textblock branch does not run. The atom branch deletes the image. That file is the earlier record. It was not re-run here.
  • A block atom before an empty paragraph takes the empty-textblock branch first. The first Backspace selects the atom. A second Backspace is deleteSelection, which deletes it. Read from code, not measured.
  • A pad image is inline, so it lives inside a paragraph. A following empty paragraph meets another paragraph. deleteBarrier joins them, and the image stays. Read from code, not measured.
  • The image node view root is a div. The default display is block. A caret after the image can sit on the next visual line inside the same paragraph. joinBackward does not run, because the caret is not at the start of the textblock. The guide says the browser then handles Backspace. That key was not measured.
  • handleMediaDeleteKey in media-resize-controls.ts returns false for a focused TextSelection. Hover does not delete the image while the caret is in text. Read from code, not measured. The same spec already asserts that hover case.
  • Image stopEvent returns true only for events inside the caption. It does not handle this Backspace.
  • The extension binds no Backspace command of its own. The symbol that deletes a leaf atom is joinBackward, in prosemirror-commands src/commands.ts.
  • No settled rule in the root CLAUDE.md forbids this fix.
  • On 2026-09-29, dist/index.js was dated 2026-09-28 and the newest source file was dated 2026-09-22. dist was not stale, and it was not rebuilt.

Reference points

  • joinBackward joins or lifts when the caret is at the start of a textblock. selectNodeBackward selects the node before that textblock when joining is not allowed.
  • The ProseMirror guide says the base keymap chains deleteSelection, joinBackward, and selectNodeBackward. When none apply, the browser runs Backspace inside the textblock.
  • prosemirror-commands#6. Marijn Haverbeke writes that deleting an atom block on Backspace is intentional. He tells the app to put its own command in front of joinBackward.
  • Google Docs, Word, Notion, and Slack help pages do not state this key. Those claims are left out.

Acceptance criteria

  • Backspace at the start of an empty paragraph under an image removes that paragraph and keeps the image. The caret ends after the image.
  • The same holds for every node MediaResizeGripper accepts: image, video, audio, YouTube, Vimeo, SoundCloud, Spotify, and Loom.
  • The maintainer writes, in a comment here, what a second Backspace does. The change follows that comment. It does not invent the second press.
  • Delete at the end of the image line removes the following empty paragraph and keeps the image. The caret ends after the image.
  • Hover on an image does not delete it when the caret is in text elsewhere.
  • The media toolbar, the resize gripper, undo, markdown round trip, and HTML round trip still work.
  • A list and a heading next to an image still work.
  • A clean-room Cypress spec under extensions/extension-hypermultimedia/cypress/e2e/ fails before the fix. It asserts rendered DOM, not only node attrs. The old "image is deleted" assertion in delete-collision.cy.ts is updated in the same change. Its hover case stays.
  • CHANGELOG.md has a Fixed entry under [Unreleased]. Package docs note the key if readers need it. No release command is run in the fix.

Blocked by

None.

Agent brief

Type: HITL for the second Backspace. AFK for the first press, the Delete press, the spec, and the CHANGELOG entry.

Category: bug

Current behavior: Read from code, not measured. A leaf media node is an atom. joinBackward deletes that atom when it is the block before a textblock that is not empty. An empty textblock selects the atom instead. A pad image is inline, so a real empty paragraph after it should join and keep the image. A block-level node view can still put the caret on a visual line after the image, inside the same paragraph, where joinBackward does not run. handleMediaDeleteKey does not take a focused text caret.

Desired behavior: The first Backspace, and Delete at the end of the image line, remove the empty paragraph and keep the media node. The caret ends after the media node. The second Backspace follows the maintainer comment.

Where to start: Put a command in front of joinBackward and joinForward for these nodes. Search joinBackward in prosemirror-commands src/commands.ts, handleMediaDeleteKey in extensions/extension-hypermultimedia/src/utils/media-resize-controls.ts, inline() in src/nodes/image/image.ts, and the node view div in src/nodes/image/nodeView.ts. The pad option is in apps/webapp/src/components/TipTap/TipTap.tsx. The spec to update is cypress/e2e/delete/delete-collision.cy.ts. The playground image is inline only with inlineImage=true in test/playground/main.ts. File names are hints as of 2026-09-29.

Rules that apply: extensions/CLAUDE.md §Hypermultimedia Extension. Cypress checks for resize and loading assert rendered DOM, not node attrs alone. apps/webapp/src/components/TipTap/CLAUDE.md §Editor Performance: a leaf node view must not expose contentDOM. AGENTS.md §Test Policy allows this spec because it pins a reported bug. The release-extensions skill: add a CHANGELOG entry, and do not publish from the fix. After any extension source change, run bunx tsup in the package. No settled rule in the root CLAUDE.md forbids this fix.

Verify: Run only the new spec and delete-collision.cy.ts from extensions/extension-hypermultimedia. Do not run the whole webapp Jest suite. Then press Backspace on a real pad, with the caret in the empty line under an image, and confirm the image stays. Check one block node, such as YouTube, the same way.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions