Skip to content

feat(plugin-block): configurable block handle hover responsiveness - #2456

Open
johnfumaster wants to merge 9 commits into
Milkdown:mainfrom
brainfish-ai:feat/block-handle-animation
Open

feat(plugin-block): configurable block handle hover responsiveness#2456
johnfumaster wants to merge 9 commits into
Milkdown:mainfrom
brainfish-ai:feat/block-handle-animation

Conversation

@johnfumaster

Copy link
Copy Markdown
Contributor
  • I read the contributing guide
  • I agree to follow the code of conduct

Summary

Improves block handle hover detection an dmakes drag initiation more reliable when the user clicks the handle before the hover RAF has run.

Problem:

Block handle hover used a fixed 200ms throttle and did all hit-testing inside that callback. That made the handle feel laggy when moving vertically between blocks and mousedown on the handle could run before #active was set - so #createSelection() had nothing to select and drag could target the wrong block.

Changes:

  1. Configurable hover throttle - Add mousemoveThrottle to blockConfig (default 50ms, down from the previous hardcoded 200ms). Expose it via Crepe BlockEdit as blockConfig.mousemoveThrottle.

  2. RAF-batched hover updates - Move elementFromPoint / selectRootNodeByDom work into requestAnimationFrame, coalescing rapid mousemove events and cancelling stale frames when the pointer moves again.

  3. Cheaper mousemove path - Skip work when Y movement is under 5px and the active block is unchanged; skip #hide() / #show() when the resolved block is the same element and position.

  4. Immediate active block on mousedown - On handle mousedown, synchronously resolve the block under the last known pointer Y (#ensureActiveForPointer) before creating the node selection, so click-to-drag works even if hover detection has not painted yet.

How did you test this change?

  • Move the pointer vertically across multiple blocks - handle should track blocks with noticeably less lag than before
  • Move horizontally within a block - handle should not flicker
  • Dragging and dropping blocks should work
  • Have been using this on our production environment for a couple of months now.

- Introduced `onImageLoadError` callback in the image block configuration and image input component.
- Updated relevant components to utilize the new error handling feature for image loading failures.
- Enhanced documentation to reflect the new callback functionality.
Adds mousemoveThrottle config for the Block Plugin. Defaulted throttle
to 50ms as 200ms seems too slow. Heavy work runs inside a single
requestAnimationFrame, and a new move cancels the previous RAF. So you
never run the expensive path more than once per frame, and it’s aligned
with the browser’s paint. That reduces layout thrash and avoids stacking
work when many events fire in one frame.
Ensures the block currently under the pointer is determined
synchronously when a mousedown event occurs. This prevents delays in
block activation and ensures correct selection, especially if a hover
update was pending.
@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 2839d0d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

@johnfumaster is attempting to deploy a commit to the Milkdown Team on Vercel.

A member of the Team first needs to authorize it.

@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@milkdown/components

npm i https://pkg.pr.new/@milkdown/components@2456

@milkdown/core

npm i https://pkg.pr.new/@milkdown/core@2456

@milkdown/crepe

npm i https://pkg.pr.new/@milkdown/crepe@2456

@milkdown/ctx

npm i https://pkg.pr.new/@milkdown/ctx@2456

@milkdown/exception

npm i https://pkg.pr.new/@milkdown/exception@2456

@milkdown/kit

npm i https://pkg.pr.new/@milkdown/kit@2456

@milkdown/prose

npm i https://pkg.pr.new/@milkdown/prose@2456

@milkdown/transformer

npm i https://pkg.pr.new/@milkdown/transformer@2456

@milkdown/utils

npm i https://pkg.pr.new/@milkdown/utils@2456

@milkdown/react

npm i https://pkg.pr.new/@milkdown/react@2456

@milkdown/vue

npm i https://pkg.pr.new/@milkdown/vue@2456

@milkdown/plugin-automd

npm i https://pkg.pr.new/@milkdown/plugin-automd@2456

@milkdown/plugin-block

npm i https://pkg.pr.new/@milkdown/plugin-block@2456

@milkdown/plugin-clipboard

npm i https://pkg.pr.new/@milkdown/plugin-clipboard@2456

@milkdown/plugin-collab

npm i https://pkg.pr.new/@milkdown/plugin-collab@2456

@milkdown/plugin-cursor

npm i https://pkg.pr.new/@milkdown/plugin-cursor@2456

@milkdown/plugin-diff

npm i https://pkg.pr.new/@milkdown/plugin-diff@2456

@milkdown/plugin-emoji

npm i https://pkg.pr.new/@milkdown/plugin-emoji@2456

@milkdown/plugin-highlight

npm i https://pkg.pr.new/@milkdown/plugin-highlight@2456

@milkdown/plugin-history

npm i https://pkg.pr.new/@milkdown/plugin-history@2456

@milkdown/plugin-indent

npm i https://pkg.pr.new/@milkdown/plugin-indent@2456

@milkdown/plugin-listener

npm i https://pkg.pr.new/@milkdown/plugin-listener@2456

@milkdown/plugin-prism

npm i https://pkg.pr.new/@milkdown/plugin-prism@2456

@milkdown/plugin-slash

npm i https://pkg.pr.new/@milkdown/plugin-slash@2456

@milkdown/plugin-streaming

npm i https://pkg.pr.new/@milkdown/plugin-streaming@2456

@milkdown/plugin-tooltip

npm i https://pkg.pr.new/@milkdown/plugin-tooltip@2456

@milkdown/plugin-trailing

npm i https://pkg.pr.new/@milkdown/plugin-trailing@2456

@milkdown/plugin-upload

npm i https://pkg.pr.new/@milkdown/plugin-upload@2456

@milkdown/preset-commonmark

npm i https://pkg.pr.new/@milkdown/preset-commonmark@2456

@milkdown/preset-gfm

npm i https://pkg.pr.new/@milkdown/preset-gfm@2456

@milkdown/theme-nord

npm i https://pkg.pr.new/@milkdown/theme-nord@2456

commit: 2839d0d

@johnfumaster johnfumaster changed the title feat: block handle animation feat(plugin-block): improve block handle hover responsiveness and mousedown reliability Aug 12, 2026
@johnfumaster johnfumaster changed the title feat(plugin-block): improve block handle hover responsiveness and mousedown reliability feat(plugin-block): configurable block handle hover responsiveness Aug 12, 2026
@johnfumaster
johnfumaster marked this pull request as draft August 12, 2026 05:02
@autofix-ci

autofix-ci Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Hi! I'm autofix logoautofix.ci, a bot that automatically fixes trivial issues such as code formatting in pull requests.

I would like to apply some automated changes to this pull request, but it looks like I don't have the necessary permissions to do so. To get this pull request into a mergeable state, please do one of the following two things:

  1. Allow edits by maintainers for your pull request, and then re-trigger CI (for example by pushing a new commit).
  2. Manually fix the issues identified for your pull request (see the GitHub Actions output for details on what I would like to change).

@johnfumaster
johnfumaster force-pushed the feat/block-handle-animation branch from 7b8cb64 to 2839d0d Compare August 12, 2026 05:40
@johnfumaster
johnfumaster marked this pull request as ready for review August 12, 2026 05:41
@Saul-Mirone

Copy link
Copy Markdown
Member

Could you please enable Allow edits by maintainers so I can fix some concerns? Or you want me to comment it here and let you fix it yourself?

@johnfumaster

Copy link
Copy Markdown
Contributor Author

Sorry @Saul-Mirone for the late reply. My forked branch belongs to our org and I don't see the option to do that. Please comment on the PR and I'll update it. 🙏🏼

@Saul-Mirone Saul-Mirone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the PR. The direction is right, 200ms is too slow and moving the work into a RAF makes sense. A few things to fix first.

Two blocking ones, both left inline:

  1. bind() reads blockConfig without a try/catch. That is the crash #1958 fixed, and here it fails silently instead of throwing, so the handle just stops working.
  2. Making mousemoveThrottle required is a breaking type change, and at runtime a missing value means no throttling at all.

Smaller things:

  • The description says same-block updates are skipped, but I don't see that in the code. #resolveHover always calls #show(), so computePosition() now runs 4x more often than before. Either add it or drop it from the description.
  • docs/api/crepe.md should document the new Crepe option.
  • No tests. The mousedown path and the 5px skip are both easy to cover. block-drag.spec.ts also mocks blockConfig without mousemoveThrottle, which is why the throttle=0 problem doesn't show up in CI.
  • The option is called mousemoveThrottle but the plugin listens to pointermove. The internal naming is already like that so I don't mind much, but this one is public.

Checked out locally: lint, tsc and the unit tests all pass.

this.#mousemoveCallback.cancel()
this.#mousemoveCallback = throttle(
this.#onMousemove,
ctx.get(blockConfig.key).mousemoveThrottle

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This needs the same try/catch as #filterNodes above. ctx.get() throws when the slice isn't registered yet, which is what #1958 was about.

It fails badly here. bind() is called from BlockProvider.#init(), and update() catches and ignores the error:

try {
  this.#init()
  this.#initialized = true
} catch {
  // ignore
}

So this.#service never gets assigned, addEvent() and draggable = true never run, and the handle is dead with nothing in the console. #ctx and #notify are already set by the time it throws, so the service is left half bound too.

Reading the throttle lazily on the first mousemove would also work.

{ filterNodes: defaultNodeFilter },
/// - `mousemoveThrottle`: Throttle delay in ms for block hover detection (default 50).
export const blockConfig = $ctx<
{ filterNodes: FilterNodes; mousemoveThrottle: number },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can this stay optional?

{ filterNodes: FilterNodes; mousemoveThrottle?: number }

ctx.set replaces the whole object, so ctx.set(blockConfig.key, { filterNodes }) becomes a type error after this change, and that is a common thing for people to have.

The runtime side is the bigger problem. throttle(fn, undefined) does not throttle at all, lodash does toNumber(wait) || 0 so wait becomes 0:

wait=undefined -> invoked 100 /100
wait=50        -> invoked   1 /100

So for anyone who sets blockConfig without the new field, #onMousemove runs on every pointermove, including the getBoundingClientRect() in the 5px check which forces layout. The RAF only batches what comes after it.

Making the field optional and reading it with ?? 50 fixes both.

this.#hide()
return
}
this.#show(result)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Point 3 in the description says this is skipped when the block hasn't changed, but it runs every time. #show goes to BlockProvider.show(), which calls floating-ui's computePosition() with flip(). At 50ms instead of 200ms that is 4x the calls.

An early return should be enough:

if (result.el === this.#active?.el && result.$pos.pos === this.#active.$pos.pos)
  return

/// @internal
#handleMouseDown = () => {
const view = this.#view
if (view && this.#lastMouseY >= 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

#lastMouseY >= 0 only matters for the else branch below, the #active branch doesn't use it. Moving the check into the else would make that clearer.

if (this.#rafId !== null) cancelAnimationFrame(this.#rafId)
this.#rafId = requestAnimationFrame(() => {
this.#rafId = null
this.#resolveHover(view, event.clientY)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this.#lastMouseY holds the same value here, and a later mousemove cancels this frame before it runs. Using the field instead would avoid keeping the event alive in the closure.

Comment on lines +20 to +22
blockConfig?: {
mousemoveThrottle?: number
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two things:

BlockEditFeatureConfig is DeepPartial<BlockEditConfig>, so the ? are redundant. Every other field in this interface is declared as required.

More importantly, blockConfig is the name of an internal ctx slice and I'd rather not expose it in the Crepe config. Either flatten it to mousemoveThrottle, or move it under the existing blockHandle. This is public API so it is awkward to change later.


return true
},
mousemoveThrottle: config?.blockConfig?.mousemoveThrottle ?? 50,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the read side falls back to 50 (see my comment on block-config.ts), this can just pass the value through. Right now 50 is written in three places: here, the ctx default and the doc comment. A shared constant would be better.

@johnfumaster

Copy link
Copy Markdown
Contributor Author

Awesome review @Saul-Mirone. I'll update the PR as soon as I find some time to work on it. 🙏🏼

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.

2 participants