Initial commit: MVP+ wish board with FastAPI + Tailwind

This commit is contained in:
Nikita committed 2026-08-05 17:06:36 +05:00
commit cd945a6128
2277 files changed
+843095

No files matched your search

+39
View File
@@ -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());
};
@@ -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);
};
@@ -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;
@@ -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;
@@ -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;
@@ -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));
};
@@ -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,
};
};
@@ -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
View File
@@ -0,0 +1 @@
export { default } from './drop-middleware';
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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;
@@ -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 };
};
@@ -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;
};
@@ -0,0 +1 @@
export { default } from './responders-middleware';
@@ -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;
};
@@ -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,
};
};
@@ -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
View File
@@ -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
View File
@@ -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);
};
@@ -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);
}
}