@@ -4,6 +4,13 @@ import { createDragToMoveGestureController } from "./drag_to_move.js";
44import { getDropTargetInfo } from "./drop_target_detection.js" ;
55import { moveCSSVars } from "./move_css_vars.js" ;
66
7+ // How long the copy takes to leave the screen, and to come back. Written into the
8+ // CSS below from here: the flight has to be waited for, and a duration living only
9+ // in a stylesheet is a timing JS cannot read reliably.
10+ const TOSS_DURATION_MS = 320 ;
11+ // Far enough to be off any screen, in the direction the hand was going.
12+ const TOSS_DISTANCE = 900 ;
13+
714const css = /* css */ `
815 /* IN THE PAGE, NOT IN THE LIST: the hint lands on the edge of a row, which
916 for the last one is the very bottom of the scroll area — drawn inside it,
@@ -105,13 +112,6 @@ const css = /* css */ `
105112 }
106113
107114 [navi-drag-clone-wrapper] {
108- /* Nothing in a copy being carried by a pointer is text to select: the
109- selection belongs to the original, which is still in the page. This is the
110- one place the rule is unconditional — an element that can be dragged is
111- usually selectable too (a link is both), and forcing it there would take
112- away a selection made from outside the element. */
113- user-select: none;
114-
115115 /* Also a popover (see .navi_drop_hint): in the top layer it is over the
116116 page whatever the page's own stacking is, and the coordinates it is
117117 given are viewport ones — which is what the pointer carrying it works
@@ -132,9 +132,26 @@ const css = /* css */ `
132132 opacity: 0.95;
133133 transition: box-shadow 0.15s ease;
134134 pointer-events: none;
135+ /* Nothing in a copy being carried by a pointer is text to select: the
136+ selection belongs to the original, which is still in the page. This is the
137+ one place the rule is unconditional — an element that can be dragged is
138+ usually selectable too (a link is both), and forcing it there would take
139+ away a selection made from outside the element. */
140+ user-select: none;
135141 overflow: visible;
136142 }
137143
144+ /* Ce qui a été lancé: il continue dans la direction du geste jusqu'à sortir de
145+ l'écran, et revient par le même chemin si la réponse refuse. */
146+ [navi-drag-clone-wrapper][data-tossed] {
147+ transition:
148+ translate ${ TOSS_DURATION_MS } ms ease-out,
149+ opacity ${ TOSS_DURATION_MS } ms ease-out;
150+ }
151+ [navi-drag-clone-wrapper][data-tossed="away"] {
152+ opacity: 0;
153+ }
154+
138155 [navi-drag-clone] {
139156 transform: scale(var(--drag-clone-scale, 1.03));
140157 transform-origin: var(--drag-origin);
@@ -206,9 +223,11 @@ const dragCSSVars = [
206223 * The list item to drag.
207224 * @param {Element } [options.containerElement=draggedElement.parentElement]
208225 * Element searched with `itemSelector` to find the items to drop between.
209- * @param {string } options.itemSelector
226+ * @param {string } [ options.itemSelector]
210227 * CSS selector that matches all list items inside `containerElement`.
211- * Used for drop-target detection and no-op filtering.
228+ * Used for drop-target detection and no-op filtering. Left out, nothing is a
229+ * drop target: no hint is drawn and no reorder can be answered — which is what
230+ * a drag that only ever throws the thing away asks for.
212231 * @param {function } options.getItemId
213232 * Returns the stable ID for a given DOM element.
214233 * Signature: `getItemId(element) → id`.
@@ -220,6 +239,15 @@ const dragCSSVars = [
220239 * - `syncCloneWithDropTarget`: call it synchronously inside a
221240 * `document.startViewTransition` callback, next to the DOM mutation, so the
222241 * clone is captured at its landing position.
242+ * @param {(detail: {gestureInfo: object, dropTarget: Element|null}) => "reorder"|"toss"|"cancel" } [options.resolveDrop]
243+ * What THIS release means, when the answer is not simply "a target was found or
244+ * not": the same grab can be meant to reorder or to get rid of the thing, and
245+ * only the caller knows which — far and fast is a throw, over a row is a move.
246+ * Left out, a drop target reorders and anything else is cancelled.
247+ * @param {(detail: {gestureInfo: object}) => Promise|void } [options.onToss]
248+ * The release was a throw. The clone leaves the screen the way it was thrown
249+ * while this runs; it comes back if the promise rejects, because the thing still
250+ * exists and the screen has to say so.
223251 * @param {object } [options.direction={ x: false, y: true }]
224252 * Axes along which dragging is allowed. Passed to `createDragToMoveGestureController`.
225253 * @param {number } [options.threshold=5]
@@ -245,6 +273,8 @@ export const startDragToReorder = (
245273 itemSelector,
246274 getItemId,
247275 onReorder,
276+ resolveDrop,
277+ onToss,
248278 direction = { x : false , y : true } ,
249279 threshold,
250280 longPress,
@@ -319,6 +349,11 @@ export const startDragToReorder = (
319349 } ;
320350
321351 dragGesture . addDragCallback ( ( gestureInfo ) => {
352+ if ( ! itemSelector ) {
353+ // Nothing is a drop target: there is no hint to draw and no landing to
354+ // look for (see itemSelector).
355+ return ;
356+ }
322357 const allItems = [ ] ;
323358 const items = [ ] ;
324359 for ( const el of containerElement . querySelectorAll ( itemSelector ) ) {
@@ -397,7 +432,29 @@ export const startDragToReorder = (
397432 dropHintEl . remove ( ) ;
398433 restoreCSSVars ( ) ;
399434
400- if ( currentBeforeElement !== undefined ) {
435+ const hasDropTarget = currentBeforeElement !== undefined ;
436+ const dropMeans = resolveDrop
437+ ? resolveDrop ( {
438+ gestureInfo,
439+ dropTarget : hasDropTarget ? currentReleaseElement : null ,
440+ } )
441+ : hasDropTarget
442+ ? "reorder"
443+ : "cancel" ;
444+
445+ if ( dropMeans === "toss" ) {
446+ // Bake the position the hand left it at, so the flight starts from
447+ // there rather than from where the clone was declared.
448+ setCloneViewportRect ( cloneWrapper , cloneWrapper ) ;
449+ gestureInfo . cancelPosition ( ) ;
450+ const gone = await tossCloneAway ( cloneWrapper , gestureInfo , onToss ) ;
451+ if ( ! gone ) {
452+ // It still exists, so the screen has to say so: the copy comes back
453+ // over the original, and taking it away then reveals the row in
454+ // place.
455+ await settleCloneBack ( cloneWrapper , draggedElement ) ;
456+ }
457+ } else if ( dropMeans === "reorder" && hasDropTarget ) {
401458 const clone = cloneWrapper . firstElementChild ;
402459 // Bake the current visual position (transform included) into the CSS vars
403460 // so the clone stays where the user released it when we clear the transform.
@@ -491,6 +548,54 @@ const createDropHint = () => {
491548 return div . firstElementChild ;
492549} ;
493550
551+ /**
552+ * The copy leaves the screen the way it was thrown, and the caller says what that
553+ * meant. Resolves true when it is really gone.
554+ *
555+ * The answer is asked for WHILE it flies rather than after: the thing is already
556+ * far away by the time the request lands, which is the whole point of a gesture
557+ * that means "get rid of this" — nobody waits to watch it go.
558+ */
559+ const tossCloneAway = async ( cloneWrapper , gestureInfo , onToss ) => {
560+ const { xDelta, yDelta } = gestureInfo . layout ;
561+ const distance = Math . hypot ( xDelta , yDelta ) || 1 ;
562+ cloneWrapper . dataset . tossed = "away" ;
563+ cloneWrapper . style . translate = `${ ( xDelta / distance ) * TOSS_DISTANCE } px ${
564+ ( yDelta / distance ) * TOSS_DISTANCE
565+ } px`;
566+ try {
567+ await onToss ?. ( { gestureInfo } ) ;
568+ return true ;
569+ } catch {
570+ return false ;
571+ }
572+ } ;
573+
574+ /**
575+ * It comes back where it came from, and only then is taken away — which is what
576+ * makes the original reappear in place rather than blink back into it.
577+ *
578+ * Flown home on `translate` rather than by rewriting the position vars: the vars
579+ * hold where the hand let go, the transition is on translate, and moving the vars
580+ * would put the copy there instantly instead of taking it there.
581+ */
582+ const settleCloneBack = ( cloneWrapper , sourceElement ) => {
583+ const sourceRect = sourceElement . getBoundingClientRect ( ) ;
584+ const releaseLeft = parseFloat (
585+ cloneWrapper . style . getPropertyValue ( "--clone-left" ) ,
586+ ) ;
587+ const releaseTop = parseFloat (
588+ cloneWrapper . style . getPropertyValue ( "--clone-top" ) ,
589+ ) ;
590+ cloneWrapper . dataset . tossed = "back" ;
591+ cloneWrapper . style . translate = `${ sourceRect . left - releaseLeft } px ${
592+ sourceRect . top - releaseTop
593+ } px`;
594+ return new Promise ( ( resolve ) => {
595+ setTimeout ( resolve , TOSS_DURATION_MS ) ;
596+ } ) ;
597+ } ;
598+
494599const createDragClone = ( element , pointerEvent ) => {
495600 const rect = element . getBoundingClientRect ( ) ;
496601
0 commit comments