feat(vue-nodes): snap link preview; connect on drop (#5780)
## Summary Snap link preview to the nearest compatible slot while dragging in Vue Nodes mode, and complete the connection on drop using the snapped target. Mirrors LiteGraph’s first-compatible-slot logic for node-level snapping and reuses the computed candidate for performance. ## Changes - Snap preview end to compatible slot - slot under cursor via `data-slot-key` fast-path - node under cursor via `findInputByType` / `findOutputByType` - Render path - `slotLinkPreviewRenderer.ts` now renders to `state.candidate.layout.position` - Complete on drop - Prefer `state.candidate` (no re-hit-testing) - Fallbacks: DOM slot → node first-compatible → reroute - Disconnects moving input link when dropped on canvas ## Review Focus - UX feel of snapping and drop completion (both directions) - Performance on large graphs (mousemove path is O(1) with dataset + single validation) - Edge cases: reroutes, moving existing links, collapsed nodes ## Screenshots (if applicable) https://github.com/user-attachments/assets/fbed0ae2-2231-473b-a05a-9aaf68e3f820 https://github.com/Comfy-Org/ComfyUI_frontend/pull/5780 (snapping) <-- https://github.com/Comfy-Org/ComfyUI_frontend/pull/5898 (drop on canvas + linkconnectoradapter refactor) <-- https://github.com/Comfy-Org/ComfyUI_frontend/pull/5903 (fix reroute snapping) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5780-feat-vue-nodes-snap-link-preview-connect-on-drop-27a6d73d365081d89c8cf570e2049c89) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
@@ -13,6 +13,13 @@ export class VueNodeHelpers {
|
||||
return this.page.locator('[data-node-id]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for a Vue node by its NodeId
|
||||
*/
|
||||
getNodeLocator(nodeId: string): Locator {
|
||||
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for selected Vue node components (using visual selection indicators)
|
||||
*/
|
||||
|
||||
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -693,4 +693,99 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
test('should snap to node center while dragging and link on drop', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(clipNode && samplerNode).toBeTruthy()
|
||||
|
||||
// Start drag from CLIP output[0]
|
||||
const clipOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
clipNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
|
||||
// Drag to the visual center of the KSampler Vue node (not a slot)
|
||||
const samplerVue = comfyPage.vueNodes.getNodeLocator(String(samplerNode.id))
|
||||
await expect(samplerVue).toBeVisible()
|
||||
const samplerCenter = await getCenter(samplerVue)
|
||||
|
||||
await comfyMouse.move(clipOutputCenter)
|
||||
await comfyMouse.drag(samplerCenter)
|
||||
|
||||
// During drag, the preview should snap/highlight a compatible input on KSampler
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-snap-to-node.png')
|
||||
|
||||
// Drop to create the link
|
||||
await comfyMouse.drop()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Validate a link was created to one of KSampler's compatible inputs (1 or 2)
|
||||
const linkOnInput1 = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
1
|
||||
)
|
||||
const linkOnInput2 = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2
|
||||
)
|
||||
|
||||
const linked = linkOnInput1 ?? linkOnInput2
|
||||
expect(linked).not.toBeNull()
|
||||
expect(linked?.originId).toBe(clipNode.id)
|
||||
expect(linked?.targetId).toBe(samplerNode.id)
|
||||
})
|
||||
|
||||
test('should snap to a specific compatible slot when targeting it', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(clipNode && samplerNode).toBeTruthy()
|
||||
|
||||
// Drag from CLIP output[0] to KSampler input[2] (third slot) which is the
|
||||
// second compatible input for CLIP
|
||||
const clipOutputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
clipNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const samplerInput3Center = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2,
|
||||
true
|
||||
)
|
||||
|
||||
await comfyMouse.move(clipOutputCenter)
|
||||
await comfyMouse.drag(samplerInput3Center)
|
||||
|
||||
// Expect the preview to show snapping to the targeted slot
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-snap-to-slot.png')
|
||||
|
||||
// Finish the connection
|
||||
await comfyMouse.drop()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const linkDetails = await getInputLinkDetails(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
2
|
||||
)
|
||||
expect(linkDetails).not.toBeNull()
|
||||
expect(linkDetails).toMatchObject({
|
||||
originId: clipNode.id,
|
||||
targetId: samplerNode.id,
|
||||
targetSlot: 2
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 53 KiB |
@@ -5625,7 +5625,9 @@ export class LGraphCanvas
|
||||
const { link, inputNode, input } = resolved
|
||||
if (!inputNode || !input) continue
|
||||
|
||||
const endPos = inputNode.getInputPos(link.target_slot)
|
||||
const endPos = LiteGraph.vueNodesMode
|
||||
? getSlotPosition(inputNode, link.target_slot, true)
|
||||
: inputNode.getInputPos(link.target_slot)
|
||||
|
||||
this.#renderAllLinkSegments(
|
||||
ctx,
|
||||
@@ -5650,7 +5652,9 @@ export class LGraphCanvas
|
||||
const { link, outputNode, output } = resolved
|
||||
if (!outputNode || !output) continue
|
||||
|
||||
const startPos = outputNode.getOutputPos(link.origin_slot)
|
||||
const startPos = LiteGraph.vueNodesMode
|
||||
? getSlotPosition(outputNode, link.origin_slot, false)
|
||||
: outputNode.getOutputPos(link.origin_slot)
|
||||
|
||||
this.#renderAllLinkSegments(
|
||||
ctx,
|
||||
|
||||
@@ -49,7 +49,9 @@ export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
|
||||
const renderLinks = createLinkConnectorAdapter()?.renderLinks
|
||||
if (!renderLinks || renderLinks.length === 0) return
|
||||
|
||||
const to: Readonly<Point> = [pointer.canvas.x, pointer.canvas.y]
|
||||
const to: Readonly<Point> = state.candidate?.compatible
|
||||
? [state.candidate.layout.position.x, state.candidate.layout.position.y]
|
||||
: [pointer.canvas.x, pointer.canvas.y]
|
||||
ctx.save()
|
||||
for (const link of renderLinks) {
|
||||
const startDir = link.fromDirection ?? LinkDirection.RIGHT
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SlotLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
interface PendingMoveData {
|
||||
clientX: number
|
||||
clientY: number
|
||||
target: EventTarget | null
|
||||
}
|
||||
|
||||
interface SlotLinkDragSession {
|
||||
compatCache: Map<string, boolean>
|
||||
nodePreferred: Map<
|
||||
number,
|
||||
{ index: number; key: string; layout: SlotLayout } | null
|
||||
>
|
||||
lastHoverSlotKey: string | null
|
||||
lastHoverNodeId: number | null
|
||||
lastCandidateKey: string | null
|
||||
pendingMove: PendingMoveData | null
|
||||
reset: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function createSlotLinkDragSession(): SlotLinkDragSession {
|
||||
const state: SlotLinkDragSession = {
|
||||
compatCache: new Map(),
|
||||
nodePreferred: new Map(),
|
||||
lastHoverSlotKey: null,
|
||||
lastHoverNodeId: null,
|
||||
lastCandidateKey: null,
|
||||
pendingMove: null,
|
||||
reset: () => {
|
||||
state.compatCache = new Map()
|
||||
state.nodePreferred = new Map()
|
||||
state.lastHoverSlotKey = null
|
||||
state.lastHoverNodeId = null
|
||||
state.lastCandidateKey = null
|
||||
state.pendingMove = null
|
||||
},
|
||||
dispose: () => {
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -17,19 +17,17 @@ import {
|
||||
isSizeEqual
|
||||
} from '@/renderer/core/layout/utils/geometry'
|
||||
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
|
||||
import { createRafBatch } from '@/utils/rafBatch'
|
||||
|
||||
// RAF batching
|
||||
const pendingNodes = new Set<string>()
|
||||
let rafId: number | null = null
|
||||
const raf = createRafBatch(() => {
|
||||
flushScheduledSlotLayoutSync()
|
||||
})
|
||||
|
||||
function scheduleSlotLayoutSync(nodeId: string) {
|
||||
pendingNodes.add(nodeId)
|
||||
if (rafId == null) {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
flushScheduledSlotLayoutSync()
|
||||
})
|
||||
}
|
||||
raf.schedule()
|
||||
}
|
||||
|
||||
function flushScheduledSlotLayoutSync() {
|
||||
|
||||
@@ -22,7 +22,9 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import { toPoint } from '@/renderer/core/layout/utils/geometry'
|
||||
import { createSlotLinkDragSession } from '@/renderer/extensions/vueNodes/composables/slotLinkDragSession'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createRafBatch } from '@/utils/rafBatch'
|
||||
|
||||
interface SlotInteractionOptions {
|
||||
nodeId: string
|
||||
@@ -79,14 +81,21 @@ export function useSlotLinkInteraction({
|
||||
index,
|
||||
type
|
||||
}: SlotInteractionOptions): SlotInteractionHandlers {
|
||||
const { state, beginDrag, endDrag, updatePointerPosition } =
|
||||
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } =
|
||||
useSlotLinkDragState()
|
||||
const conversion = useSharedCanvasPositionConversion()
|
||||
const pointerSession = createPointerSession()
|
||||
let activeAdapter: LinkConnectorAdapter | null = null
|
||||
|
||||
// Per-drag drag-state cache
|
||||
const dragSession = createSlotLinkDragSession()
|
||||
|
||||
function candidateFromTarget(
|
||||
target: EventTarget | null
|
||||
): SlotDropCandidate | null {
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
const key = target.dataset['slotKey']
|
||||
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
|
||||
const key = elWithKey?.dataset['slotKey']
|
||||
if (!key) return null
|
||||
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
@@ -94,32 +103,85 @@ export function useSlotLinkInteraction({
|
||||
|
||||
const candidate: SlotDropCandidate = { layout, compatible: false }
|
||||
|
||||
if (state.source) {
|
||||
const canvas = app.canvas
|
||||
const graph = canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (graph && adapter) {
|
||||
if (layout.type === 'input') {
|
||||
candidate.compatible = adapter.isInputValidDrop(
|
||||
layout.nodeId,
|
||||
layout.index
|
||||
)
|
||||
} else if (layout.type === 'output') {
|
||||
candidate.compatible = adapter.isOutputValidDrop(
|
||||
layout.nodeId,
|
||||
layout.index
|
||||
)
|
||||
}
|
||||
const graph = app.canvas?.graph
|
||||
const adapter = ensureActiveAdapter()
|
||||
if (graph && adapter) {
|
||||
const cached = dragSession.compatCache.get(key)
|
||||
if (cached != null) {
|
||||
candidate.compatible = cached
|
||||
} else {
|
||||
const compatible =
|
||||
layout.type === 'input'
|
||||
? adapter.isInputValidDrop(layout.nodeId, layout.index)
|
||||
: adapter.isOutputValidDrop(layout.nodeId, layout.index)
|
||||
dragSession.compatCache.set(key, compatible)
|
||||
candidate.compatible = compatible
|
||||
}
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
const conversion = useSharedCanvasPositionConversion()
|
||||
function candidateFromNodeTarget(
|
||||
target: EventTarget | null
|
||||
): SlotDropCandidate | null {
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
|
||||
const nodeIdStr = elWithNode?.dataset['nodeId']
|
||||
if (!nodeIdStr) return null
|
||||
|
||||
const pointerSession = createPointerSession()
|
||||
let activeAdapter: LinkConnectorAdapter | null = null
|
||||
const adapter = ensureActiveAdapter()
|
||||
const graph = app.canvas?.graph
|
||||
if (!adapter || !graph) return null
|
||||
|
||||
const nodeId = Number(nodeIdStr)
|
||||
|
||||
// Cached preferred slot for this node within this drag
|
||||
const cachedPreferred = dragSession.nodePreferred.get(nodeId)
|
||||
if (cachedPreferred !== undefined) {
|
||||
return cachedPreferred
|
||||
? { layout: cachedPreferred.layout, compatible: true }
|
||||
: null
|
||||
}
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!node) return null
|
||||
|
||||
const firstLink = adapter.renderLinks[0]
|
||||
if (!firstLink) return null
|
||||
const connectingTo = adapter.linkConnector.state.connectingTo
|
||||
|
||||
if (connectingTo !== 'input' && connectingTo !== 'output') return null
|
||||
|
||||
const isInput = connectingTo === 'input'
|
||||
const slotType = firstLink.fromSlot.type
|
||||
|
||||
const res = isInput
|
||||
? node.findInputByType(slotType)
|
||||
: node.findOutputByType(slotType)
|
||||
|
||||
const index = res?.index
|
||||
if (index == null) return null
|
||||
|
||||
const key = getSlotKey(String(nodeId), index, isInput)
|
||||
const layout = layoutStore.getSlotLayout(key)
|
||||
if (!layout) return null
|
||||
|
||||
const compatible = isInput
|
||||
? adapter.isInputValidDrop(nodeId, index)
|
||||
: adapter.isOutputValidDrop(nodeId, index)
|
||||
|
||||
if (compatible) {
|
||||
dragSession.compatCache.set(key, true)
|
||||
const preferred = { index, key, layout }
|
||||
dragSession.nodePreferred.set(nodeId, preferred)
|
||||
return { layout, compatible: true }
|
||||
} else {
|
||||
dragSession.compatCache.set(key, false)
|
||||
dragSession.nodePreferred.set(nodeId, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
|
||||
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
|
||||
@@ -251,6 +313,8 @@ export function useSlotLinkInteraction({
|
||||
pointerSession.clear()
|
||||
endDrag()
|
||||
activeAdapter = null
|
||||
raf.cancel()
|
||||
dragSession.dispose()
|
||||
}
|
||||
|
||||
const updatePointerState = (event: PointerEvent) => {
|
||||
@@ -264,10 +328,73 @@ export function useSlotLinkInteraction({
|
||||
updatePointerPosition(clientX, clientY, canvasX, canvasY)
|
||||
}
|
||||
|
||||
const processPointerMoveFrame = () => {
|
||||
const data = dragSession.pendingMove
|
||||
if (!data) return
|
||||
dragSession.pendingMove = null
|
||||
|
||||
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
|
||||
data.clientX,
|
||||
data.clientY
|
||||
])
|
||||
updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY)
|
||||
|
||||
let hoveredSlotKey: string | null = null
|
||||
let hoveredNodeId: number | null = null
|
||||
const target = data.target
|
||||
if (target instanceof HTMLElement) {
|
||||
hoveredSlotKey =
|
||||
target.closest<HTMLElement>('[data-slot-key]')?.dataset['slotKey'] ??
|
||||
null
|
||||
if (!hoveredSlotKey) {
|
||||
const nodeIdStr =
|
||||
target.closest<HTMLElement>('[data-node-id]')?.dataset['nodeId']
|
||||
hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : null
|
||||
}
|
||||
}
|
||||
|
||||
const hoverChanged =
|
||||
hoveredSlotKey !== dragSession.lastHoverSlotKey ||
|
||||
hoveredNodeId !== dragSession.lastHoverNodeId
|
||||
|
||||
let candidate: SlotDropCandidate | null = state.candidate
|
||||
|
||||
if (hoverChanged) {
|
||||
const slotCandidate = candidateFromTarget(target)
|
||||
const nodeCandidate = slotCandidate
|
||||
? null
|
||||
: candidateFromNodeTarget(target)
|
||||
candidate = slotCandidate ?? nodeCandidate
|
||||
dragSession.lastHoverSlotKey = hoveredSlotKey
|
||||
dragSession.lastHoverNodeId = hoveredNodeId
|
||||
}
|
||||
|
||||
const newCandidate = candidate?.compatible ? candidate : null
|
||||
const newCandidateKey = newCandidate
|
||||
? getSlotKey(
|
||||
newCandidate.layout.nodeId,
|
||||
newCandidate.layout.index,
|
||||
newCandidate.layout.type === 'input'
|
||||
)
|
||||
: null
|
||||
|
||||
if (newCandidateKey !== dragSession.lastCandidateKey) {
|
||||
setCandidate(newCandidate)
|
||||
dragSession.lastCandidateKey = newCandidateKey
|
||||
}
|
||||
|
||||
app.canvas?.setDirty(true)
|
||||
}
|
||||
const raf = createRafBatch(processPointerMoveFrame)
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
updatePointerState(event)
|
||||
app.canvas?.setDirty(true)
|
||||
dragSession.pendingMove = {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
target: event.target
|
||||
}
|
||||
raf.schedule()
|
||||
}
|
||||
|
||||
// Attempt to finalize by connecting to a DOM slot candidate
|
||||
@@ -359,18 +486,36 @@ export function useSlotLinkInteraction({
|
||||
if (!pointerSession.matches(event)) return
|
||||
event.preventDefault()
|
||||
|
||||
raf.flush()
|
||||
|
||||
if (!state.source) {
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = candidateFromTarget(event.target)
|
||||
let connected = tryConnectToCandidate(candidate)
|
||||
// Prefer using the snapped candidate captured during hover for perf + consistency
|
||||
const snappedCandidate = state.candidate?.compatible
|
||||
? state.candidate
|
||||
: null
|
||||
|
||||
let connected = tryConnectToCandidate(snappedCandidate)
|
||||
|
||||
// Fallback to DOM slot under pointer (if any), then node fallback, then reroute
|
||||
if (!connected) {
|
||||
const domCandidate = candidateFromTarget(event.target)
|
||||
connected = tryConnectToCandidate(domCandidate)
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
const nodeCandidate = candidateFromNodeTarget(event.target)
|
||||
connected = tryConnectToCandidate(nodeCandidate)
|
||||
}
|
||||
|
||||
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
|
||||
|
||||
// Drop on canvas: disconnect moving input link(s)
|
||||
if (!connected && !candidate && state.source.type === 'input') {
|
||||
if (!connected && !snappedCandidate && state.source.type === 'input') {
|
||||
ensureActiveAdapter()?.disconnectMovingLinks()
|
||||
}
|
||||
|
||||
@@ -384,6 +529,8 @@ export function useSlotLinkInteraction({
|
||||
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
|
||||
raf.flush()
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true)
|
||||
}
|
||||
@@ -398,6 +545,8 @@ export function useSlotLinkInteraction({
|
||||
if (!canvas || !graph) return
|
||||
|
||||
ensureActiveAdapter()
|
||||
raf.cancel()
|
||||
dragSession.reset()
|
||||
|
||||
const layout = layoutStore.getSlotLayout(
|
||||
getSlotKey(nodeId, index, type === 'input')
|
||||
|
||||
29
src/utils/rafBatch.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function createRafBatch(run: () => void) {
|
||||
let rafId: number | null = null
|
||||
|
||||
const schedule = () => {
|
||||
if (rafId != null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
run()
|
||||
})
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (rafId == null) return
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
run()
|
||||
}
|
||||
|
||||
const isScheduled = () => rafId != null
|
||||
|
||||
return { schedule, cancel, flush, isScheduled }
|
||||
}
|
||||