Drags and Ghosts
On this page
TODO: General session, mode specific session
Drag Start
When a drag starts, several things can happen depending on the current mode.
Update Item
If session.dragVisual === "item", the dragged item must begin moving alongside
the pointer. SnapSort will directly set the DOM element position to absolute,
and begin applying CSS transforms to move the item to where the pointer is.
If session.dragVisual === "preview", the dragged item remains stationary in the DOM,
while the visual representation of the drag is handled through ghost items.
Ghost Creation
After an accepted drag activates, it may create a ghost item by calling the onGhostInsert callback. The frontend framework must handle this callback
to add the ghost to the DOM with proper styling.
The callback passes a GhostInsertEvent with the following fields:
type GhostInsertEvent = {
readonly operation: "insert";
readonly ghost: GhostState;
readonly to: GhostState["location"];
readonly beforeElement: HTMLElement | null;
}; Of particular interest here is the ghost.type field, which can have
the following values that indicate how the ghost should be placed.
target-spacer: The ghost is placed where the item is about to be dropped, in other words where the drop target is located. Used by euclidean and progressive modes.insertion-marker: The ghost is a marker indicating the gap between items where the dragged item will be dropped. Used by insertion mode.pointer-preview: The ghost follows the pointer. Used by swap mode.source-spacerthe ghost stays in the original position of the item being dragged. Currently not used by any mode.
Application code must render the appropriate ghost item based on this field.
For convenience, SnapSort also provides a Ghost components that can dynamically
render each type. See Ghost.
Item Drag
As the pointer moves during the drag gesture, behind the scenes SnapSort is busy at work calculating the drop target based on the current position of the pointer and the item. Every mode uses a different algorithm, but they all eventually fire a predefined set of callbacks.
onGhostMove
If the mode involves moving ghosts, SnapSort fires the onGhostMove callback
with the updated ghost state. This requests the application to move
ghosts between locations or update their visual representation.
The application does not, however, need to handle animations; SnapSort handles that for you. This is not only for convenience (you’re welcome) but also a necessity, as SnapSort, and the engine it’s built on, SnapEngine, runs a carefully coordinated animation system to ensure smooth DOM updates. Under the hood, SnapSort takes a snapshot of the ghost before calling the callback. After the callback, another snapshot is taken, and an animation is played to interpolate between the two snapshots. Meaning, in fact, you should avoid triggering custom animations as it can interfere with the animation system.
Ghost Lifecycle Callbacks
All in all, there are three flavors of callbacks for ghosts:
onGhostInsertreports absent → present withghost,to, andbeforeElement.onGhostMovereports present → changed withghost,from,to, andbeforeElement.onGhostRemovereports present → absent withghostandfrom.
The callbacks remain separate because SnapSort owns all three transitions.
It can understandably be a bit annoying to handle all yourself, so
SnapSort ships with the reduceRenderTree helper which you can pass the event argument of all three callbacks directly. Combined with the
aforementioned Ghost component, this should make it quick and easy
to set up ghosts.
<script lang="ts">
import {
reduceRenderTree,
type ContainerCallbacks,
type GhostLifecycleEvent,
} from "@snap-engine/snapsort";
import { Ghost, Item } from "@snap-engine/snapsort/svelte";
function applyGhostEvent(event: GhostLifecycleEvent) {
tree = reduceRenderTree(tree, event);
}
const callbacks = {
onGhostInsert: applyGhostEvent,
onGhostMove: applyGhostEvent,
onGhostRemove: applyGhostEvent,
} satisfies ContainerCallbacks;
</script>
{#each tree.entries as entry (entry.itemId)}
{#if entry.isGhost}
<Ghost ghost={entry.ghost} />
{:else}
<Item itemId={entry.itemId}>{entry.value.label}</Item>
{/if}
{/each}import { useCallback, useMemo } from "react";
import { Ghost, Item } from "@snap-engine/snapsort/react";
import {
reduceRenderTree,
type ContainerCallbacks,
type GhostLifecycleEvent,
} from "@snap-engine/snapsort";
const applyGhostEvent = useCallback((event: GhostLifecycleEvent) => {
setTree((current) => reduceRenderTree(current, event));
}, []);
const callbacks = useMemo(
() =>
({
onGhostInsert: applyGhostEvent,
onGhostMove: applyGhostEvent,
onGhostRemove: applyGhostEvent,
}) satisfies ContainerCallbacks,
[applyGhostEvent],
);
const children = tree.entries.map((entry) =>
entry.isGhost ? (
<Ghost key={entry.itemId} ghost={entry.ghost} />
) : (
<Item key={entry.itemId} itemId={entry.itemId}>
{entry.value.label}
</Item>
),
);onDropTargetChange
When the algorithm detects that a new drop target has been selected,
SnapSort fires the onDropTargetChange callback with the new target location
and relevant data.
This is usually used by swap mode, which can update the styling of the hovered item to make it clear it has been selected as a drop target.
Resolution Order
Each built-in SnapSort mode algorithm resolves a prospective drop target in this order:
- Generate the active mode’s candidate positions and group them by their directly owning container.
- Start with each container’s configured
dropPriority, then invokegetDropPriorityonce. A returned number replaces the configured value;undefinedpreserves it. - Remove containers whose effective priority is
-1, then keep only candidates tied at the highest remaining priority. - Let the active mode rank that candidate set and choose the exact container and index.
Priority and Rejection
By default, all items and containers have a dropPriority of 0.
The getDropPriority callback can replace it every time the item moves.
Return -1 to reject the destination, a nonnegative number to rank it, or undefined to preserve its configured value.
import {
DROP_REJECT_PRIORITY,
type DropPriorityEvent,
} from "@snap-engine/snapsort";
function prioritizeUrgentItems(event: DropPriorityEvent) {
if (event.containerMetadata.accepts !== event.itemMetadata.kind) {
return DROP_REJECT_PRIORITY;
}
return event.itemsMetadata.every((metadata) => metadata.urgent === true)
? event.staticPriority + 1
: undefined;
} This is useful if you want to make exceptions to the mode’s algorithm and prioritize specific containers, or use as tie breakers when the algorithm assigns multiple possible drop locations with the same preference.
<Container
itemId="words-root"
metadata={{ boardId: "sentence" }}
config={{ dropPriority: 2, callbacks }}
>
{#each words.entries as entry (entry.itemId)}
{#if entry.isGhost}
<Ghost ghost={entry.ghost} />
{:else if entry.childTree}
<WordContainer {entry} />
{:else}
<Item itemId={entry.itemId}>{entry.value.label}</Item>
{/if}
{/each}
</Container><Container
itemId="words-root"
metadata={{ boardId: "sentence" }}
config={{ dropPriority: 2, callbacks }}
>
{words.entries.map((entry) =>
entry.isGhost ? (
<Ghost key={entry.itemId} ghost={entry.ghost} />
) : entry.childTree ? (
<WordContainer key={entry.itemId} entry={entry} />
) : (
<Item key={entry.itemId} itemId={entry.itemId}>
{entry.value.label}
</Item>
),
)}
</Container>For the more common scenarios you would want to define priorities, SnapSort provided pre-made helpers you can use. See Standard Callbacks reference for their exact signatures, geometry, return values, and import path.