diff --git a/packages/crepe/src/feature/block-edit/handle/index.ts b/packages/crepe/src/feature/block-edit/handle/index.ts index 09bfd3d0166..90e56155944 100644 --- a/packages/crepe/src/feature/block-edit/handle/index.ts +++ b/packages/crepe/src/feature/block-edit/handle/index.ts @@ -116,6 +116,7 @@ export function configureBlockHandle( return true }, + mousemoveThrottle: config?.blockConfig?.mousemoveThrottle ?? 50, }) ctx.set(block.key, { view: () => new BlockHandleView(ctx, config), diff --git a/packages/crepe/src/feature/block-edit/index.ts b/packages/crepe/src/feature/block-edit/index.ts index 7daa8fd434a..cdd322fdd52 100644 --- a/packages/crepe/src/feature/block-edit/index.ts +++ b/packages/crepe/src/feature/block-edit/index.ts @@ -17,6 +17,10 @@ interface BlockEditConfig { handleDragIcon: string buildMenu: (builder: GroupBuilder) => void + blockConfig?: { + mousemoveThrottle?: number + } + blockHandle: Pick< BlockProviderOptions, | 'shouldShow' diff --git a/packages/plugins/plugin-block/src/block-config.ts b/packages/plugins/plugin-block/src/block-config.ts index 675027f5450..17ea52c981c 100644 --- a/packages/plugins/plugin-block/src/block-config.ts +++ b/packages/plugins/plugin-block/src/block-config.ts @@ -19,10 +19,11 @@ export const defaultNodeFilter: FilterNodes = (pos) => { /// A slice contains the block config. /// Possible properties: /// - `filterNodes`: A function to filter nodes that can be dragged. -export const blockConfig = $ctx<{ filterNodes: FilterNodes }, 'blockConfig'>( - { filterNodes: defaultNodeFilter }, +/// - `mousemoveThrottle`: Throttle delay in ms for block hover detection (default 50). +export const blockConfig = $ctx< + { filterNodes: FilterNodes; mousemoveThrottle: number }, 'blockConfig' -) +>({ filterNodes: defaultNodeFilter, mousemoveThrottle: 50 }, 'blockConfig') withMeta(blockConfig, { displayName: 'Ctx', diff --git a/packages/plugins/plugin-block/src/block-service.ts b/packages/plugins/plugin-block/src/block-service.ts index 17b0f4e8616..8a8e049070d 100644 --- a/packages/plugins/plugin-block/src/block-service.ts +++ b/packages/plugins/plugin-block/src/block-service.ts @@ -4,7 +4,7 @@ import type { EditorView } from '@milkdown/prose/view' import { editorViewCtx } from '@milkdown/core' import { browser } from '@milkdown/prose' import { NodeSelection } from '@milkdown/prose/state' -import { throttle } from 'lodash-es' +import { throttle, type DebouncedFunc } from 'lodash-es' import type { FilterNodes } from './block-config' import type { ActiveNode } from './types' @@ -79,6 +79,11 @@ export class BlockService { /// @internal #dragging = false + /// @internal + #lastMouseY = -1 + /// @internal + #rafId: number | null = null + /// @internal get #filterNodes(): FilterNodes | undefined { try { @@ -112,6 +117,11 @@ export class BlockService { bind = (ctx: Ctx, notify: BlockServiceMessage) => { this.#ctx = ctx this.#notify = notify + this.#mousemoveCallback.cancel() + this.#mousemoveCallback = throttle( + this.#onMousemove, + ctx.get(blockConfig.key).mousemoveThrottle + ) } /// Add mouse event to the dom. @@ -132,11 +142,39 @@ export class BlockService { /// Unbind the notify function. unBind = () => { + if (this.#rafId !== null) { + cancelAnimationFrame(this.#rafId) + this.#rafId = null + } + this.#mousemoveCallback.cancel() this.#notify = undefined } /// @internal #handleMouseDown = () => { + const view = this.#view + if (view && this.#lastMouseY >= 0) { + if (this.#rafId !== null) { + cancelAnimationFrame(this.#rafId) + this.#rafId = null + } + // Prefer the block already shown on the handle; only resolve from the + // pointer when hover has not established one yet. + if (this.#active) { + const filterNodes = this.#filterNodes + if (filterNodes) { + const rect = this.#active.el.getBoundingClientRect() + const result = selectRootNodeByDom( + view, + { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + filterNodes + ) + if (result?.el === this.#active.el) this.#active = result + } + } else { + this.#resolveHover(view, this.#lastMouseY) + } + } this.#activeDOMRect = this.#active?.el.getBoundingClientRect() this.#createSelection() } @@ -218,12 +256,10 @@ export class BlockService { } /// @internal - #mousemoveCallback = throttle((view: EditorView, event: MouseEvent) => { - if (!view.editable) return - + #resolveHover = (view: EditorView, mouseY: number) => { const rect = view.dom.getBoundingClientRect() const x = rect.left + rect.width / 2 - const dom = view.root.elementFromPoint(x, event.clientY) + const dom = view.root.elementFromPoint(x, mouseY) if (!(dom instanceof Element)) { this.#hide() return @@ -232,18 +268,39 @@ export class BlockService { const filterNodes = this.#filterNodes if (!filterNodes) return - const result = selectRootNodeByDom( - view, - { x, y: event.clientY }, - filterNodes - ) + const result = selectRootNodeByDom(view, { x, y: mouseY }, filterNodes) if (!result) { this.#hide() return } this.#show(result) - }, 200) + } + + /// @internal + #onMousemove = (view: EditorView, event: MouseEvent) => { + if (!view.editable) return + + // Skip tiny Y jitter while still inside the active block; leaving its + // vertical bounds always resolves so adjacent blocks are not missed. + if (this.#active && Math.abs(event.clientY - this.#lastMouseY) < 5) { + const activeRect = this.#active.el.getBoundingClientRect() + if (event.clientY >= activeRect.top && event.clientY <= activeRect.bottom) + return + } + this.#lastMouseY = event.clientY + + if (this.#rafId !== null) cancelAnimationFrame(this.#rafId) + this.#rafId = requestAnimationFrame(() => { + this.#rafId = null + this.#resolveHover(view, event.clientY) + }) + } + + /// @internal + #mousemoveCallback: DebouncedFunc< + (view: EditorView, event: MouseEvent) => void + > = throttle(() => {}, 50) /// @internal mousemoveCallback = (view: EditorView, event: MouseEvent) => { diff --git a/packages/plugins/plugin-block/src/index.ts b/packages/plugins/plugin-block/src/index.ts index 5d4309863b3..330a190ac54 100644 --- a/packages/plugins/plugin-block/src/index.ts +++ b/packages/plugins/plugin-block/src/index.ts @@ -22,7 +22,7 @@ export * from './types' /// @internal export type BlockPlugin = [ $Ctx, 'blockSpec'>, - $Ctx<{ filterNodes: FilterNodes }, 'blockConfig'>, + $Ctx<{ filterNodes: FilterNodes; mousemoveThrottle: number }, 'blockConfig'>, $Ctx<() => BlockService, 'blockService'>, $Ctx, $Prose,