-
-
Notifications
You must be signed in to change notification settings - Fork 550
feat(plugin-block): configurable block handle hover responsiveness #2456
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
de8d9ba
076f63f
a4fade3
846d658
3e16e85
8a9d640
901a836
9fbb4ac
2839d0d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,10 @@ interface BlockEditConfig { | |
| handleDragIcon: string | ||
| buildMenu: (builder: GroupBuilder<SlashMenuItem>) => void | ||
|
|
||
| blockConfig?: { | ||
| mousemoveThrottle?: number | ||
| } | ||
|
Comment on lines
+20
to
+22
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two things:
More importantly, |
||
|
|
||
| blockHandle: Pick< | ||
| BlockProviderOptions, | ||
| | 'shouldShow' | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can this stay optional? { filterNodes: FilterNodes; mousemoveThrottle?: number }
The runtime side is the bigger problem. So for anyone who sets Making the field optional and reading it with |
||
| 'blockConfig' | ||
| ) | ||
| >({ filterNodes: defaultNodeFilter, mousemoveThrottle: 50 }, 'blockConfig') | ||
|
|
||
| withMeta(blockConfig, { | ||
| displayName: 'Ctx<blockConfig>', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs the same try/catch as It fails badly here. try {
this.#init()
this.#initialized = true
} catch {
// ignore
}So Reading the throttle lazily on the first mousemove would also work. |
||
| ) | ||
| } | ||
|
|
||
| /// 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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. An early return should be enough: if (result.el === this.#active?.el && result.$pos.pos === this.#active.$pos.pos)
return |
||
| }, 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }) | ||
| } | ||
|
|
||
| /// @internal | ||
| #mousemoveCallback: DebouncedFunc< | ||
| (view: EditorView, event: MouseEvent) => void | ||
| > = throttle(() => {}, 50) | ||
|
|
||
| /// @internal | ||
| mousemoveCallback = (view: EditorView, event: MouseEvent) => { | ||
|
|
||
There was a problem hiding this comment.
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.