Initial commit: MVP+ wish board with FastAPI + Tailwind
This commit is contained in:
commit
cd945a6128
2277 files changed
+843095
No files matched your search
+39
@@ -0,0 +1,39 @@
|
||||
import { invariant } from '../../invariant';
|
||||
import type { AutoScroller } from '../auto-scroller/auto-scroller-types';
|
||||
import type { Middleware } from '../store-types';
|
||||
import { guard } from '../action-creators';
|
||||
import type { State } from '../../types';
|
||||
|
||||
const shouldStop = (action: unknown) =>
|
||||
guard(action, 'DROP_COMPLETE') ||
|
||||
guard(action, 'DROP_ANIMATE') ||
|
||||
guard(action, 'FLUSH');
|
||||
|
||||
export default (autoScroller: AutoScroller): Middleware =>
|
||||
(store) =>
|
||||
(next) =>
|
||||
(action) => {
|
||||
if (shouldStop(action)) {
|
||||
autoScroller.stop();
|
||||
next(action);
|
||||
return;
|
||||
}
|
||||
|
||||
if (guard(action, 'INITIAL_PUBLISH')) {
|
||||
// letting the action go first to hydrate the state
|
||||
next(action);
|
||||
const state: State = store.getState();
|
||||
invariant(
|
||||
state.phase === 'DRAGGING',
|
||||
'Expected phase to be DRAGGING after INITIAL_PUBLISH',
|
||||
);
|
||||
autoScroller.start(state);
|
||||
return;
|
||||
}
|
||||
|
||||
// auto scroll happens in response to state changes
|
||||
// releasing all actions to the reducer first
|
||||
next(action);
|
||||
|
||||
autoScroller.scroll(store.getState());
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { guard } from '../action-creators';
|
||||
import type { Middleware } from '../store-types';
|
||||
import type { DimensionMarshal } from '../dimension-marshal/dimension-marshal-types';
|
||||
|
||||
export default (marshal: DimensionMarshal): Middleware =>
|
||||
() =>
|
||||
(next) =>
|
||||
(action) => {
|
||||
// Not stopping a collection on a 'DROP' as we want a collection to continue
|
||||
if (
|
||||
// drag is finished
|
||||
guard(action, 'DROP_COMPLETE') ||
|
||||
guard(action, 'FLUSH') ||
|
||||
// no longer accepting changes once the drop has started
|
||||
guard(action, 'DROP_ANIMATE')
|
||||
) {
|
||||
marshal.stopPublishing();
|
||||
}
|
||||
|
||||
next(action);
|
||||
};
|
||||
Generated
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
import { invariant } from '../../../invariant';
|
||||
import { completeDrop, guard } from '../../action-creators';
|
||||
import type { State } from '../../../types';
|
||||
import type { Middleware } from '../../store-types';
|
||||
|
||||
const dropAnimationFinishMiddleware: Middleware =
|
||||
(store) => (next) => (action) => {
|
||||
if (!guard(action, 'DROP_ANIMATION_FINISHED')) {
|
||||
next(action);
|
||||
return;
|
||||
}
|
||||
|
||||
const state: State = store.getState();
|
||||
invariant(
|
||||
state.phase === 'DROP_ANIMATING',
|
||||
'Cannot finish a drop animating when no drop is occurring',
|
||||
);
|
||||
store.dispatch(completeDrop({ completed: state.completed }));
|
||||
};
|
||||
|
||||
export default dropAnimationFinishMiddleware;
|
||||
Generated
Vendored
+64
@@ -0,0 +1,64 @@
|
||||
import { dropAnimationFinished, guard } from '../../action-creators';
|
||||
import type { State } from '../../../types';
|
||||
import type { Middleware } from '../../store-types';
|
||||
import type { EventBinding } from '../../../view/event-bindings/event-types';
|
||||
import bindEvents from '../../../view/event-bindings/bind-events';
|
||||
|
||||
const dropAnimationFlushOnScrollMiddleware: Middleware = (store) => {
|
||||
let unbind: (() => void) | null = null;
|
||||
let frameId: AnimationFrameID | null = null;
|
||||
|
||||
function clear() {
|
||||
if (frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
}
|
||||
|
||||
if (unbind) {
|
||||
unbind();
|
||||
unbind = null;
|
||||
}
|
||||
}
|
||||
|
||||
return (next) => (action) => {
|
||||
if (
|
||||
guard(action, 'FLUSH') ||
|
||||
guard(action, 'DROP_COMPLETE') ||
|
||||
guard(action, 'DROP_ANIMATION_FINISHED')
|
||||
) {
|
||||
clear();
|
||||
}
|
||||
|
||||
next(action);
|
||||
|
||||
if (!guard(action, 'DROP_ANIMATE')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const binding: EventBinding = {
|
||||
eventName: 'scroll',
|
||||
// capture: true will catch all scroll events, event from scroll containers
|
||||
// once: just in case, we only want to ever fire one
|
||||
options: { capture: true, passive: false, once: true },
|
||||
fn: function flushDropAnimation() {
|
||||
const state: State = store.getState();
|
||||
if (state.phase === 'DROP_ANIMATING') {
|
||||
store.dispatch(dropAnimationFinished());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// The browser can batch a few scroll events in a single frame
|
||||
// including the one that ended the drag.
|
||||
// Binding after a requestAnimationFrame ensures that any scrolls caused
|
||||
// by the auto scroller are finished
|
||||
// TODO: why is a second window scroll being fired?
|
||||
// It leads to funny drop positions :(
|
||||
frameId = requestAnimationFrame(() => {
|
||||
frameId = null;
|
||||
unbind = bindEvents(window, [binding]);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export default dropAnimationFlushOnScrollMiddleware;
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import type { Position } from 'css-box-model';
|
||||
import { invariant } from '../../../invariant';
|
||||
import type {
|
||||
State,
|
||||
DropReason,
|
||||
Critical,
|
||||
DraggableLocation,
|
||||
DropResult,
|
||||
CompletedDrag,
|
||||
Combine,
|
||||
DimensionMap,
|
||||
DraggableDimension,
|
||||
} from '../../../types';
|
||||
import type { Middleware } from '../../store-types';
|
||||
import {
|
||||
animateDrop,
|
||||
completeDrop,
|
||||
dropPending,
|
||||
guard,
|
||||
} from '../../action-creators';
|
||||
import type { AnimateDropArgs } from '../../action-creators';
|
||||
import { isEqual } from '../../position';
|
||||
import getDropDuration from './get-drop-duration';
|
||||
import getNewHomeClientOffset from './get-new-home-client-offset';
|
||||
import getDropImpact from './get-drop-impact';
|
||||
import type { Result } from './get-drop-impact';
|
||||
import { tryGetCombine, tryGetDestination } from '../../get-impact-location';
|
||||
|
||||
const dropMiddleware: Middleware =
|
||||
({ getState, dispatch }) =>
|
||||
(next) =>
|
||||
(action) => {
|
||||
if (!guard(action, 'DROP')) {
|
||||
next(action);
|
||||
return;
|
||||
}
|
||||
|
||||
const state: State = getState();
|
||||
const reason: DropReason = action.payload.reason;
|
||||
|
||||
// Still waiting for a bulk collection to publish
|
||||
// We are now shifting the application into the 'DROP_PENDING' phase
|
||||
if (state.phase === 'COLLECTING') {
|
||||
dispatch(dropPending({ reason }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Could have occurred in response to an error
|
||||
if (state.phase === 'IDLE') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Still waiting for our drop pending to end
|
||||
// TODO: should this throw?
|
||||
const isWaitingForDrop: boolean =
|
||||
state.phase === 'DROP_PENDING' && state.isWaiting;
|
||||
invariant(
|
||||
!isWaitingForDrop,
|
||||
'A DROP action occurred while DROP_PENDING and still waiting',
|
||||
);
|
||||
|
||||
invariant(
|
||||
state.phase === 'DRAGGING' || state.phase === 'DROP_PENDING',
|
||||
`Cannot drop in phase: ${state.phase}`,
|
||||
);
|
||||
// We are now in the DRAGGING or DROP_PENDING phase
|
||||
|
||||
const critical: Critical = state.critical;
|
||||
const dimensions: DimensionMap = state.dimensions;
|
||||
const draggable: DraggableDimension =
|
||||
dimensions.draggables[state.critical.draggable.id];
|
||||
// Only keeping impact when doing a user drop - otherwise we are cancelling
|
||||
|
||||
const { impact, didDropInsideDroppable }: Result = getDropImpact({
|
||||
reason,
|
||||
lastImpact: state.impact,
|
||||
afterCritical: state.afterCritical,
|
||||
onLiftImpact: state.onLiftImpact,
|
||||
home: state.dimensions.droppables[state.critical.droppable.id],
|
||||
viewport: state.viewport,
|
||||
draggables: state.dimensions.draggables,
|
||||
});
|
||||
|
||||
// only populating destination / combine if 'didDropInsideDroppable' is true
|
||||
const destination: DraggableLocation | null = didDropInsideDroppable
|
||||
? tryGetDestination(impact)
|
||||
: null;
|
||||
const combine: Combine | null = didDropInsideDroppable
|
||||
? tryGetCombine(impact)
|
||||
: null;
|
||||
|
||||
const source: DraggableLocation = {
|
||||
index: critical.draggable.index,
|
||||
droppableId: critical.droppable.id,
|
||||
};
|
||||
|
||||
const result: DropResult = {
|
||||
draggableId: draggable.descriptor.id,
|
||||
type: draggable.descriptor.type,
|
||||
source,
|
||||
reason,
|
||||
mode: state.movementMode,
|
||||
// destination / combine will be null if didDropInsideDroppable is true
|
||||
destination,
|
||||
combine,
|
||||
};
|
||||
|
||||
const newHomeClientOffset: Position = getNewHomeClientOffset({
|
||||
impact,
|
||||
draggable,
|
||||
dimensions,
|
||||
viewport: state.viewport,
|
||||
afterCritical: state.afterCritical,
|
||||
});
|
||||
|
||||
const completed: CompletedDrag = {
|
||||
critical: state.critical,
|
||||
afterCritical: state.afterCritical,
|
||||
result,
|
||||
impact,
|
||||
};
|
||||
|
||||
const isAnimationRequired: boolean =
|
||||
// 1. not already in the right spot
|
||||
!isEqual(state.current.client.offset, newHomeClientOffset) ||
|
||||
// 2. doing a combine (we still want to animate the scale and opacity fade)
|
||||
// looking at the result and not the impact as the combine impact is cleared
|
||||
Boolean(result.combine);
|
||||
|
||||
if (!isAnimationRequired) {
|
||||
dispatch(completeDrop({ completed }));
|
||||
return;
|
||||
}
|
||||
|
||||
const dropDuration: number = getDropDuration({
|
||||
current: state.current.client.offset,
|
||||
destination: newHomeClientOffset,
|
||||
reason,
|
||||
});
|
||||
|
||||
const args: AnimateDropArgs = {
|
||||
newHomeClientOffset,
|
||||
dropDuration,
|
||||
completed,
|
||||
};
|
||||
|
||||
dispatch(animateDrop(args));
|
||||
};
|
||||
|
||||
export default dropMiddleware;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { Position } from 'css-box-model';
|
||||
import { distance as getDistance } from '../../position';
|
||||
import { timings } from '../../../animation';
|
||||
import type { DropReason } from '../../../types';
|
||||
|
||||
interface GetDropDurationArgs {
|
||||
current: Position;
|
||||
destination: Position;
|
||||
reason: DropReason;
|
||||
}
|
||||
|
||||
const { minDropTime, maxDropTime } = timings;
|
||||
const dropTimeRange: number = maxDropTime - minDropTime;
|
||||
const maxDropTimeAtDistance = 1500;
|
||||
// will bring a time lower - which makes it faster
|
||||
const cancelDropModifier = 0.6;
|
||||
|
||||
export default ({
|
||||
current,
|
||||
destination,
|
||||
reason,
|
||||
}: GetDropDurationArgs): number => {
|
||||
const distance: number = getDistance(current, destination);
|
||||
// even if there is no distance to travel, we might still need to animate opacity
|
||||
if (distance <= 0) {
|
||||
return minDropTime;
|
||||
}
|
||||
|
||||
if (distance >= maxDropTimeAtDistance) {
|
||||
return maxDropTime;
|
||||
}
|
||||
|
||||
// * range from:
|
||||
// 0px = 0.33s
|
||||
// 1500px and over = 0.55s
|
||||
// * If reason === 'CANCEL' then speeding up the animation
|
||||
// * round to 2 decimal points
|
||||
|
||||
const percentage: number = distance / maxDropTimeAtDistance;
|
||||
const duration: number = minDropTime + dropTimeRange * percentage;
|
||||
|
||||
const withDuration: number =
|
||||
reason === 'CANCEL' ? duration * cancelDropModifier : duration;
|
||||
// To two decimal points by converting to string and back
|
||||
return Number(withDuration.toFixed(2));
|
||||
};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
DropReason,
|
||||
DragImpact,
|
||||
Viewport,
|
||||
DroppableDimension,
|
||||
DraggableDimensionMap,
|
||||
LiftEffect,
|
||||
} from '../../../types';
|
||||
import recompute from '../../update-displacement-visibility/recompute';
|
||||
import { emptyGroups } from '../../no-impact';
|
||||
|
||||
interface Args {
|
||||
draggables: DraggableDimensionMap;
|
||||
home: DroppableDimension;
|
||||
reason: DropReason;
|
||||
lastImpact: DragImpact;
|
||||
onLiftImpact: DragImpact;
|
||||
viewport: Viewport;
|
||||
afterCritical: LiftEffect;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
impact: DragImpact;
|
||||
didDropInsideDroppable: boolean;
|
||||
}
|
||||
|
||||
export default ({
|
||||
draggables,
|
||||
reason,
|
||||
lastImpact,
|
||||
home,
|
||||
viewport,
|
||||
onLiftImpact,
|
||||
}: Args): Result => {
|
||||
if (!lastImpact.at || reason !== 'DROP') {
|
||||
// Dropping outside of a list or the drag was cancelled
|
||||
|
||||
// Going to use the on lift impact
|
||||
// Need to recompute the visibility of the original impact
|
||||
// What is visible can be different to when the drag started
|
||||
|
||||
const recomputedHomeImpact: DragImpact = recompute({
|
||||
draggables,
|
||||
impact: onLiftImpact,
|
||||
destination: home,
|
||||
viewport,
|
||||
// We need the draggables to animate back to their positions
|
||||
forceShouldAnimate: true,
|
||||
});
|
||||
|
||||
return {
|
||||
impact: recomputedHomeImpact,
|
||||
didDropInsideDroppable: false,
|
||||
};
|
||||
}
|
||||
|
||||
// use the existing impact
|
||||
if (lastImpact.at.type === 'REORDER') {
|
||||
return {
|
||||
impact: lastImpact,
|
||||
didDropInsideDroppable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// When merging we remove the movement so that everything
|
||||
// will animate closed
|
||||
const withoutMovement: DragImpact = {
|
||||
...lastImpact,
|
||||
displaced: emptyGroups,
|
||||
};
|
||||
|
||||
return {
|
||||
impact: withoutMovement,
|
||||
didDropInsideDroppable: true,
|
||||
};
|
||||
};
|
||||
Generated
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
import type { Position } from 'css-box-model';
|
||||
import type {
|
||||
DroppableDimension,
|
||||
Viewport,
|
||||
DragImpact,
|
||||
DimensionMap,
|
||||
DraggableDimension,
|
||||
DroppableId,
|
||||
LiftEffect,
|
||||
} from '../../../types';
|
||||
import whatIsDraggedOver from '../../droppable/what-is-dragged-over';
|
||||
import { subtract } from '../../position';
|
||||
import getClientBorderBoxCenter from '../../get-center-from-impact/get-client-border-box-center';
|
||||
|
||||
interface Args {
|
||||
impact: DragImpact;
|
||||
draggable: DraggableDimension;
|
||||
dimensions: DimensionMap;
|
||||
viewport: Viewport;
|
||||
afterCritical: LiftEffect;
|
||||
}
|
||||
|
||||
export default ({
|
||||
impact,
|
||||
draggable,
|
||||
dimensions,
|
||||
viewport,
|
||||
afterCritical,
|
||||
}: Args): Position => {
|
||||
const { draggables, droppables } = dimensions;
|
||||
const droppableId: DroppableId | null = whatIsDraggedOver(impact);
|
||||
const destination: DroppableDimension | null = droppableId
|
||||
? droppables[droppableId]
|
||||
: null;
|
||||
const home: DroppableDimension = droppables[draggable.descriptor.droppableId];
|
||||
|
||||
const newClientCenter: Position = getClientBorderBoxCenter({
|
||||
impact,
|
||||
draggable,
|
||||
draggables,
|
||||
// if there is no destination, then we will be dropping back into the home
|
||||
afterCritical,
|
||||
droppable: destination || home,
|
||||
viewport,
|
||||
});
|
||||
|
||||
const offset: Position = subtract(
|
||||
newClientCenter,
|
||||
draggable.client.borderBox.center,
|
||||
);
|
||||
|
||||
return offset;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from './drop-middleware';
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import type { DropResult } from '../../types';
|
||||
import type { Middleware } from '../store-types';
|
||||
import { guard } from '../action-creators';
|
||||
import type { FocusMarshal } from '../../view/use-focus-marshal/focus-marshal-types';
|
||||
|
||||
export default (marshal: FocusMarshal): Middleware => {
|
||||
let isWatching = false;
|
||||
|
||||
return () => (next) => (action) => {
|
||||
if (guard(action, 'INITIAL_PUBLISH')) {
|
||||
isWatching = true;
|
||||
|
||||
marshal.tryRecordFocus(action.payload.critical.draggable.id);
|
||||
next(action);
|
||||
marshal.tryRestoreFocusRecorded();
|
||||
return;
|
||||
}
|
||||
|
||||
next(action);
|
||||
|
||||
if (!isWatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (guard(action, 'FLUSH')) {
|
||||
isWatching = false;
|
||||
marshal.tryRestoreFocusRecorded();
|
||||
return;
|
||||
}
|
||||
|
||||
if (guard(action, 'DROP_COMPLETE')) {
|
||||
isWatching = false;
|
||||
const result: DropResult = action.payload.completed.result;
|
||||
|
||||
// give focus to the combine target when combining
|
||||
if (result.combine) {
|
||||
marshal.tryShiftRecord(result.draggableId, result.combine.draggableId);
|
||||
}
|
||||
marshal.tryRestoreFocusRecorded();
|
||||
}
|
||||
};
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { invariant } from '../../invariant';
|
||||
import type { DimensionMarshal } from '../dimension-marshal/dimension-marshal-types';
|
||||
import type { State, ScrollOptions, LiftRequest } from '../../types';
|
||||
import type { Middleware } from '../store-types';
|
||||
import {
|
||||
completeDrop,
|
||||
initialPublish,
|
||||
flush,
|
||||
beforeInitialCapture,
|
||||
guard,
|
||||
} from '../action-creators';
|
||||
import validateDimensions from './util/validate-dimensions';
|
||||
|
||||
export default (marshal: DimensionMarshal): Middleware =>
|
||||
({ getState, dispatch }) =>
|
||||
(next) =>
|
||||
(action) => {
|
||||
if (!guard(action, 'LIFT')) {
|
||||
next(action);
|
||||
return;
|
||||
}
|
||||
const { id, clientSelection, movementMode } = action.payload;
|
||||
const initial: State = getState();
|
||||
|
||||
// flush dropping animation if needed
|
||||
// this can change the descriptor of the dragging item
|
||||
// Will call the onDragEnd responders
|
||||
|
||||
if (initial.phase === 'DROP_ANIMATING') {
|
||||
dispatch(completeDrop({ completed: initial.completed }));
|
||||
}
|
||||
|
||||
invariant(getState().phase === 'IDLE', 'Unexpected phase to start a drag');
|
||||
|
||||
// Removing any placeholders before we capture any starting dimensions
|
||||
dispatch(flush());
|
||||
|
||||
// Let consumers know we are just about to publish
|
||||
// We are only publishing a small amount of information as
|
||||
// things might change as a result of the onBeforeCapture callback
|
||||
dispatch(beforeInitialCapture({ draggableId: id, movementMode }));
|
||||
|
||||
// will communicate with the marshal to start requesting dimensions
|
||||
const scrollOptions: ScrollOptions = {
|
||||
shouldPublishImmediately: movementMode === 'SNAP',
|
||||
};
|
||||
const request: LiftRequest = {
|
||||
draggableId: id,
|
||||
scrollOptions,
|
||||
};
|
||||
// Let's get the marshal started!
|
||||
const { critical, dimensions, viewport } = marshal.startPublishing(request);
|
||||
|
||||
validateDimensions(critical, dimensions);
|
||||
|
||||
// Okay, we are good to start dragging now
|
||||
dispatch(
|
||||
initialPublish({
|
||||
critical,
|
||||
dimensions,
|
||||
clientSelection,
|
||||
movementMode,
|
||||
viewport,
|
||||
}),
|
||||
);
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { drop, guard } from '../action-creators';
|
||||
import type { State } from '../../types';
|
||||
import type { Middleware } from '../store-types';
|
||||
|
||||
const pendingDrop: Middleware = (store) => (next) => (action) => {
|
||||
// Always let the action go through first
|
||||
next(action);
|
||||
|
||||
if (!guard(action, 'PUBLISH_WHILE_DRAGGING')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A bulk replace occurred - check if
|
||||
// 1. there is a pending drop
|
||||
// 2. that the pending drop is no longer waiting
|
||||
|
||||
const postActionState: State = store.getState();
|
||||
|
||||
// no pending drop after the publish
|
||||
if (postActionState.phase !== 'DROP_PENDING') {
|
||||
return;
|
||||
}
|
||||
|
||||
// the pending drop is still waiting for completion
|
||||
if (postActionState.isWaiting) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
drop({
|
||||
reason: postActionState.reason,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export default pendingDrop;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { invariant } from '../../../invariant';
|
||||
|
||||
interface Entry {
|
||||
timerId: TimeoutID;
|
||||
callback: () => void;
|
||||
}
|
||||
|
||||
export interface AsyncMarshal {
|
||||
add: (fn: () => void) => void;
|
||||
flush: () => void;
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const entries: Entry[] = [];
|
||||
|
||||
const execute = (timerId: TimeoutID) => {
|
||||
const index: number = entries.findIndex(
|
||||
(item): boolean => item.timerId === timerId,
|
||||
);
|
||||
invariant(index !== -1, 'Could not find timer');
|
||||
// delete in place
|
||||
const [entry] = entries.splice(index, 1);
|
||||
entry.callback();
|
||||
};
|
||||
|
||||
const add = (fn: () => void) => {
|
||||
const timerId: TimeoutID = setTimeout(() => execute(timerId));
|
||||
const entry: Entry = {
|
||||
timerId,
|
||||
callback: fn,
|
||||
};
|
||||
entries.push(entry);
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
// nothing to flush
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shallow: Entry[] = [...entries];
|
||||
// clearing entries in case a callback adds some more callbacks
|
||||
entries.length = 0;
|
||||
|
||||
shallow.forEach((entry: Entry) => {
|
||||
clearTimeout(entry.timerId);
|
||||
entry.callback();
|
||||
});
|
||||
};
|
||||
|
||||
return { add, flush };
|
||||
};
|
||||
Generated
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
import type { Announce } from '../../../types';
|
||||
import { warning } from '../../../dev-warning';
|
||||
|
||||
export type ExpiringAnnounce = Announce & {
|
||||
wasCalled: () => boolean;
|
||||
};
|
||||
|
||||
export default (announce: Announce): ExpiringAnnounce => {
|
||||
let wasCalled = false;
|
||||
let isExpired = false;
|
||||
|
||||
// not allowing async announcements
|
||||
const timeoutId: TimeoutID = setTimeout(() => {
|
||||
isExpired = true;
|
||||
});
|
||||
|
||||
const result = (message: string): void => {
|
||||
if (wasCalled) {
|
||||
warning('Announcement already made. Not making a second announcement');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
warning(`
|
||||
Announcements cannot be made asynchronously.
|
||||
Default message has already been announced.
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
wasCalled = true;
|
||||
announce(message);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
// getter for isExpired
|
||||
// using this technique so that a consumer cannot
|
||||
// set the isExpired or wasCalled flags
|
||||
result.wasCalled = (): boolean => wasCalled;
|
||||
|
||||
return result;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from './responders-middleware';
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import type { Critical, DraggableLocation, Combine } from '../../../types';
|
||||
|
||||
export const areLocationsEqual = (
|
||||
first?: DraggableLocation | null,
|
||||
second?: DraggableLocation | null,
|
||||
): boolean => {
|
||||
// if both are null - we are equal
|
||||
if (first == null && second == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if one is null - then they are not equal
|
||||
if (first == null || second == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// compare their actual values
|
||||
return (
|
||||
first.droppableId === second.droppableId && first.index === second.index
|
||||
);
|
||||
};
|
||||
|
||||
export const isCombineEqual = (
|
||||
first?: Combine | null,
|
||||
second?: Combine | null,
|
||||
): boolean => {
|
||||
// if both are null - we are equal
|
||||
if (first == null && second == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// only one is null
|
||||
if (first == null || second == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
first.draggableId === second.draggableId &&
|
||||
first.droppableId === second.droppableId
|
||||
);
|
||||
};
|
||||
|
||||
export const isCriticalEqual = (first: Critical, second: Critical): boolean => {
|
||||
if (first === second) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isDraggableEqual: boolean =
|
||||
first.draggable.id === second.draggable.id &&
|
||||
first.draggable.droppableId === second.draggable.droppableId &&
|
||||
first.draggable.type === second.draggable.type &&
|
||||
first.draggable.index === second.draggable.index;
|
||||
|
||||
const isDroppableEqual: boolean =
|
||||
first.droppable.id === second.droppable.id &&
|
||||
first.droppable.type === second.droppable.type;
|
||||
|
||||
return isDraggableEqual && isDroppableEqual;
|
||||
};
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import { invariant } from '../../../invariant';
|
||||
import messagePreset from '../../../screen-reader-message-preset';
|
||||
import * as timings from '../../../debug/timings';
|
||||
import getExpiringAnnounce from './expiring-announce';
|
||||
import type { ExpiringAnnounce } from './expiring-announce';
|
||||
import getAsyncMarshal from './async-marshal';
|
||||
import type { AsyncMarshal } from './async-marshal';
|
||||
import type {
|
||||
DropResult,
|
||||
Responders,
|
||||
ResponderProvided,
|
||||
Critical,
|
||||
BeforeCapture,
|
||||
DragImpact,
|
||||
DraggableLocation,
|
||||
Combine,
|
||||
DragStart,
|
||||
Announce,
|
||||
DragUpdate,
|
||||
MovementMode,
|
||||
DraggableId,
|
||||
OnBeforeCaptureResponder,
|
||||
OnBeforeDragStartResponder,
|
||||
} from '../../../types';
|
||||
import { isCombineEqual, isCriticalEqual, areLocationsEqual } from './is-equal';
|
||||
import { tryGetDestination, tryGetCombine } from '../../get-impact-location';
|
||||
|
||||
const withTimings = (key: string, fn: () => void) => {
|
||||
timings.start(key);
|
||||
fn();
|
||||
timings.finish(key);
|
||||
};
|
||||
|
||||
const getDragStart = (critical: Critical, mode: MovementMode): DragStart => ({
|
||||
draggableId: critical.draggable.id,
|
||||
type: critical.droppable.type,
|
||||
|
||||
source: {
|
||||
droppableId: critical.droppable.id,
|
||||
index: critical.draggable.index,
|
||||
},
|
||||
|
||||
mode,
|
||||
});
|
||||
|
||||
type AnyResponderData = DragStart | DragUpdate | DropResult;
|
||||
|
||||
type PrimaryResponderFn<TData extends AnyResponderData> = (
|
||||
data: TData,
|
||||
provided: ResponderProvided,
|
||||
) => unknown;
|
||||
|
||||
type GetDefaultMessage<TData extends AnyResponderData> = (
|
||||
data: TData,
|
||||
) => string;
|
||||
|
||||
function execute<TData extends AnyResponderData>(
|
||||
responder: PrimaryResponderFn<TData> | undefined,
|
||||
data: TData,
|
||||
announce: Announce,
|
||||
// eslint-disable-next-line no-shadow
|
||||
getDefaultMessage: GetDefaultMessage<TData>,
|
||||
) {
|
||||
if (!responder) {
|
||||
announce(getDefaultMessage(data));
|
||||
return;
|
||||
}
|
||||
|
||||
const willExpire: ExpiringAnnounce = getExpiringAnnounce(announce);
|
||||
const provided: ResponderProvided = {
|
||||
announce: willExpire,
|
||||
};
|
||||
|
||||
// Casting because we are not validating which data type is going into which responder
|
||||
responder(data, provided);
|
||||
|
||||
if (!willExpire.wasCalled()) {
|
||||
announce(getDefaultMessage(data));
|
||||
}
|
||||
}
|
||||
|
||||
interface WhileDragging {
|
||||
mode: MovementMode;
|
||||
lastCritical: Critical;
|
||||
lastCombine: Combine | null;
|
||||
lastLocation: DraggableLocation | null;
|
||||
}
|
||||
|
||||
export default (getResponders: () => Responders, announce: Announce) => {
|
||||
const asyncMarshal: AsyncMarshal = getAsyncMarshal();
|
||||
let dragging: WhileDragging | null = null;
|
||||
|
||||
const beforeCapture = (draggableId: DraggableId, mode: MovementMode) => {
|
||||
invariant(
|
||||
!dragging,
|
||||
'Cannot fire onBeforeCapture as a drag start has already been published',
|
||||
);
|
||||
withTimings('onBeforeCapture', () => {
|
||||
// No use of screen reader for this responder
|
||||
const fn: OnBeforeCaptureResponder | undefined =
|
||||
getResponders().onBeforeCapture;
|
||||
if (fn) {
|
||||
const before: BeforeCapture = {
|
||||
draggableId,
|
||||
mode,
|
||||
};
|
||||
fn(before);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const beforeStart = (critical: Critical, mode: MovementMode) => {
|
||||
invariant(
|
||||
!dragging,
|
||||
'Cannot fire onBeforeDragStart as a drag start has already been published',
|
||||
);
|
||||
withTimings('onBeforeDragStart', () => {
|
||||
// No use of screen reader for this responder
|
||||
const fn: OnBeforeDragStartResponder | undefined =
|
||||
getResponders().onBeforeDragStart;
|
||||
if (fn) {
|
||||
fn(getDragStart(critical, mode));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const start = (critical: Critical, mode: MovementMode) => {
|
||||
invariant(
|
||||
!dragging,
|
||||
'Cannot fire onBeforeDragStart as a drag start has already been published',
|
||||
);
|
||||
const data: DragStart = getDragStart(critical, mode);
|
||||
dragging = {
|
||||
mode,
|
||||
lastCritical: critical,
|
||||
lastLocation: data.source,
|
||||
lastCombine: null,
|
||||
};
|
||||
|
||||
// we will flush this frame if we receive any responder updates
|
||||
asyncMarshal.add(() => {
|
||||
withTimings('onDragStart', () =>
|
||||
execute(
|
||||
getResponders().onDragStart,
|
||||
data,
|
||||
announce,
|
||||
messagePreset.onDragStart,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Passing in the critical location again as it can change during a drag
|
||||
const update = (critical: Critical, impact: DragImpact) => {
|
||||
const location: DraggableLocation | null = tryGetDestination(impact);
|
||||
const combine: Combine | null = tryGetCombine(impact);
|
||||
|
||||
invariant(
|
||||
dragging,
|
||||
'Cannot fire onDragMove when onDragStart has not been called',
|
||||
);
|
||||
|
||||
// Has the critical changed? Will result in a source change
|
||||
const hasCriticalChanged = !isCriticalEqual(
|
||||
critical,
|
||||
dragging.lastCritical,
|
||||
);
|
||||
if (hasCriticalChanged) {
|
||||
dragging.lastCritical = critical;
|
||||
}
|
||||
|
||||
// Has the location changed? Will result in a destination change
|
||||
const hasLocationChanged = !areLocationsEqual(
|
||||
dragging.lastLocation,
|
||||
location,
|
||||
);
|
||||
if (hasLocationChanged) {
|
||||
dragging.lastLocation = location;
|
||||
}
|
||||
const hasGroupingChanged = !isCombineEqual(dragging.lastCombine, combine);
|
||||
if (hasGroupingChanged) {
|
||||
dragging.lastCombine = combine;
|
||||
}
|
||||
|
||||
// Nothing has changed - no update needed
|
||||
if (!hasCriticalChanged && !hasLocationChanged && !hasGroupingChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: DragUpdate = {
|
||||
...getDragStart(critical, dragging.mode),
|
||||
combine,
|
||||
destination: location,
|
||||
};
|
||||
|
||||
asyncMarshal.add(() => {
|
||||
withTimings('onDragUpdate', () =>
|
||||
execute(
|
||||
getResponders().onDragUpdate,
|
||||
data,
|
||||
announce,
|
||||
messagePreset.onDragUpdate,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
invariant(dragging, 'Can only flush responders while dragging');
|
||||
asyncMarshal.flush();
|
||||
};
|
||||
|
||||
const drop = (result: DropResult) => {
|
||||
invariant(
|
||||
dragging,
|
||||
'Cannot fire onDragEnd when there is no matching onDragStart',
|
||||
);
|
||||
dragging = null;
|
||||
// not adding to frame marshal - we want this to be done in the same render pass
|
||||
// we also want the consumers reorder logic to be in the same render pass
|
||||
withTimings('onDragEnd', () =>
|
||||
execute(
|
||||
getResponders().onDragEnd,
|
||||
result,
|
||||
announce,
|
||||
messagePreset.onDragEnd,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// A non user initiated cancel
|
||||
const abort = () => {
|
||||
// aborting can happen defensively
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result: DropResult = {
|
||||
...getDragStart(dragging.lastCritical, dragging.mode),
|
||||
combine: null,
|
||||
destination: null,
|
||||
reason: 'CANCEL',
|
||||
};
|
||||
drop(result);
|
||||
};
|
||||
|
||||
return {
|
||||
beforeCapture,
|
||||
beforeStart,
|
||||
start,
|
||||
update,
|
||||
flush,
|
||||
drop,
|
||||
abort,
|
||||
};
|
||||
};
|
||||
Generated
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
import getPublisher from './publisher';
|
||||
import type {
|
||||
State,
|
||||
DropResult,
|
||||
Responders,
|
||||
Critical,
|
||||
Announce,
|
||||
} from '../../../types';
|
||||
import type { Middleware } from '../../store-types';
|
||||
import { guard } from '../../action-creators';
|
||||
|
||||
export default (
|
||||
getResponders: () => Responders,
|
||||
announce: Announce,
|
||||
): Middleware => {
|
||||
const publisher = getPublisher(
|
||||
getResponders as () => Responders,
|
||||
announce as Announce,
|
||||
);
|
||||
|
||||
return (store) => (next) => (action) => {
|
||||
if (guard(action, 'BEFORE_INITIAL_CAPTURE')) {
|
||||
publisher.beforeCapture(
|
||||
action.payload.draggableId,
|
||||
action.payload.movementMode,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (guard(action, 'INITIAL_PUBLISH')) {
|
||||
const critical: Critical = action.payload.critical;
|
||||
publisher.beforeStart(critical, action.payload.movementMode);
|
||||
next(action);
|
||||
publisher.start(critical, action.payload.movementMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Drag end
|
||||
if (guard(action, 'DROP_COMPLETE')) {
|
||||
// it is important that we use the result and not the last impact
|
||||
// the last impact might be different to the result for visual reasons
|
||||
const result: DropResult = action.payload.completed.result;
|
||||
// flushing all pending responders before snapshots are updated
|
||||
publisher.flush();
|
||||
next(action);
|
||||
publisher.drop(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// All other responders can fire after we have updated our connected components
|
||||
next(action);
|
||||
|
||||
// Drag state resetting - need to check if
|
||||
// we should fire a onDragEnd responder
|
||||
if (guard(action, 'FLUSH')) {
|
||||
publisher.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// ## Perform drag updates
|
||||
// impact of action has already been reduced
|
||||
|
||||
const state: State = store.getState();
|
||||
if (state.phase === 'DRAGGING') {
|
||||
publisher.update(state.critical, state.impact);
|
||||
}
|
||||
};
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { Position } from 'css-box-model';
|
||||
import { moveByWindowScroll, guard } from '../action-creators';
|
||||
import type { Middleware } from '../store-types';
|
||||
import getScrollListener from '../../view/scroll-listener';
|
||||
|
||||
// TODO: this is taken from auto-scroll. Let's make it a util
|
||||
const shouldStop = (action: unknown) =>
|
||||
guard(action, 'DROP_COMPLETE') ||
|
||||
guard(action, 'DROP_ANIMATE') ||
|
||||
guard(action, 'FLUSH');
|
||||
|
||||
const scrollListener: Middleware = (store) => {
|
||||
const listener = getScrollListener({
|
||||
onWindowScroll: (newScroll: Position) => {
|
||||
store.dispatch(moveByWindowScroll({ newScroll }));
|
||||
},
|
||||
});
|
||||
|
||||
return (next) => (action) => {
|
||||
if (!listener.isActive() && guard(action, 'INITIAL_PUBLISH')) {
|
||||
listener.start();
|
||||
}
|
||||
|
||||
if (listener.isActive() && shouldStop(action)) {
|
||||
listener.stop();
|
||||
}
|
||||
|
||||
next(action);
|
||||
};
|
||||
};
|
||||
|
||||
export default scrollListener;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { guard } from '../action-creators';
|
||||
import type { Middleware } from '../store-types';
|
||||
import type { StyleMarshal } from '../../view/use-style-marshal/style-marshal-types';
|
||||
|
||||
export default (marshal: StyleMarshal): Middleware =>
|
||||
() =>
|
||||
(next) =>
|
||||
(action) => {
|
||||
if (guard(action, 'INITIAL_PUBLISH')) {
|
||||
marshal.dragging();
|
||||
}
|
||||
|
||||
if (guard(action, 'DROP_ANIMATE')) {
|
||||
marshal.dropping(action.payload.completed.result.reason);
|
||||
}
|
||||
|
||||
// this will clear any styles immediately before a reorder
|
||||
if (guard(action, 'FLUSH') || guard(action, 'DROP_COMPLETE')) {
|
||||
marshal.resting();
|
||||
}
|
||||
|
||||
next(action);
|
||||
};
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
Critical,
|
||||
DimensionMap,
|
||||
DraggableDimension,
|
||||
} from '../../../types';
|
||||
import getDraggablesInsideDroppable from '../../get-draggables-inside-droppable';
|
||||
import { warning } from '../../../dev-warning';
|
||||
|
||||
interface ErrorMap {
|
||||
[index: number]: true;
|
||||
}
|
||||
|
||||
function checkIndexes(insideDestination: DraggableDimension[]) {
|
||||
// no point running if there are 1 or less items
|
||||
if (insideDestination.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexes: number[] = insideDestination.map(
|
||||
(d: DraggableDimension): number => d.descriptor.index,
|
||||
);
|
||||
|
||||
const errors: ErrorMap = {};
|
||||
|
||||
for (let i = 1; i < indexes.length; i++) {
|
||||
const current: number = indexes[i];
|
||||
const previous: number = indexes[i - 1];
|
||||
|
||||
// this will be an error if:
|
||||
// 1. index is not consecutive
|
||||
// 2. index is duplicated (which is true if #1 is not passed)
|
||||
if (current !== previous + 1) {
|
||||
errors[current] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(errors).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted: string = indexes
|
||||
.map((index: number): string => {
|
||||
const hasError = Boolean(errors[index]);
|
||||
|
||||
return hasError ? `[🔥${index}]` : `${index}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
warning(`
|
||||
Detected non-consecutive <Draggable /> indexes.
|
||||
|
||||
(This can cause unexpected bugs)
|
||||
|
||||
${formatted}
|
||||
`);
|
||||
}
|
||||
|
||||
export default function validateDimensions(
|
||||
critical: Critical,
|
||||
dimensions: DimensionMap,
|
||||
): void {
|
||||
// wrapping entire block for better minification
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const insideDestination: DraggableDimension[] =
|
||||
getDraggablesInsideDroppable(
|
||||
critical.droppable.id,
|
||||
dimensions.draggables,
|
||||
);
|
||||
checkIndexes(insideDestination);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user